mirror of
https://github.com/stablyai/orca.git
synced 2026-10-17 08:03:14 +00:00
Merge remote-tracking branch 'origin/main' into issue-7652-oauth-popup-loop
This commit is contained in:
@@ -1,6 +1,9 @@
|
||||
shamefully-hoist=true
|
||||
minimum-release-age=4320
|
||||
minimum-release-age-exclude[]=electron@42.3.3
|
||||
minimum-release-age-exclude[]=electron@43.1.0
|
||||
minimum-release-age-exclude[]=node-abi@4.33.0
|
||||
minimum-release-age-exclude[]=typescript@7.0.2
|
||||
minimum-release-age-exclude[]=@typescript/typescript-*
|
||||
minimum-release-age-exclude[]=mermaid@11.15.0
|
||||
minimum-release-age-exclude[]=@mermaid-js/parser@1.1.1
|
||||
minimum-release-age-exclude[]=serve-sim@0.1.40
|
||||
|
||||
@@ -3,7 +3,8 @@ import path from 'node:path'
|
||||
import { pathToFileURL } from 'node:url'
|
||||
import process from 'node:process'
|
||||
|
||||
import ts from 'typescript'
|
||||
// TypeScript 7 is a native CLI; AST consumers still need the legacy JavaScript API.
|
||||
import ts from 'typescript-api'
|
||||
|
||||
const SOURCE_EXTENSIONS = new Set(['.ts', '.tsx', '.js', '.jsx', '.mts', '.cts'])
|
||||
const SKIP_PATH_PARTS = new Set(['.git', 'dist', 'node_modules', 'out', '__snapshots__', 'assets'])
|
||||
|
||||
@@ -4,7 +4,8 @@ import { createHash } from 'node:crypto'
|
||||
import { pathToFileURL } from 'node:url'
|
||||
import process from 'node:process'
|
||||
|
||||
import ts from 'typescript'
|
||||
// TypeScript 7 is a native CLI; AST consumers still need the legacy JavaScript API.
|
||||
import ts from 'typescript-api'
|
||||
|
||||
import { collectLocalizationCandidates } from './audit-localization-coverage.mjs'
|
||||
|
||||
|
||||
@@ -1,4 +1,5 @@
|
||||
import ts from 'typescript'
|
||||
// TypeScript 7 is a native CLI; AST consumers still need the legacy JavaScript API.
|
||||
import ts from 'typescript-api'
|
||||
|
||||
const STYLED_SCROLLBAR_CLASSES = new Set(
|
||||
'scrollbar-sleek scrollbar-editor worktree-sidebar-scrollbar'.split(' ')
|
||||
|
||||
@@ -3,7 +3,8 @@ import path from 'node:path'
|
||||
import { pathToFileURL } from 'node:url'
|
||||
import process from 'node:process'
|
||||
|
||||
import ts from 'typescript'
|
||||
// TypeScript 7 is a native CLI; AST consumers still need the legacy JavaScript API.
|
||||
import ts from 'typescript-api'
|
||||
|
||||
const SOURCE_EXTENSIONS = new Set(['.ts', '.tsx', '.js', '.jsx', '.mts', '.cts'])
|
||||
const SKIP_PATH_PARTS = new Set(['.git', 'dist', 'node_modules', 'out', '__snapshots__', 'assets'])
|
||||
|
||||
@@ -40,8 +40,9 @@
|
||||
],
|
||||
"compilerOptions": {
|
||||
"composite": true,
|
||||
"module": "CommonJS",
|
||||
"moduleResolution": "Node",
|
||||
// TypeScript 7 removed node10 resolution; Node16 preserves CommonJS emit for this package.
|
||||
"module": "Node16",
|
||||
"moduleResolution": "Node16",
|
||||
"rootDir": "../src",
|
||||
"outDir": "../out"
|
||||
}
|
||||
|
||||
@@ -13,7 +13,6 @@
|
||||
"../src/main/wsl.ts"
|
||||
],
|
||||
"compilerOptions": {
|
||||
"baseUrl": null,
|
||||
"paths": {
|
||||
"@renderer/*": ["../src/renderer/src/*"],
|
||||
"@/*": ["../src/renderer/src/*"]
|
||||
|
||||
@@ -1,13 +1,5 @@
|
||||
import { useState, useEffect, useCallback, useMemo, useRef } from 'react'
|
||||
import {
|
||||
View,
|
||||
Text,
|
||||
StyleSheet,
|
||||
SectionList,
|
||||
Pressable,
|
||||
ActivityIndicator,
|
||||
TextInput
|
||||
} from 'react-native'
|
||||
import { View, Text, StyleSheet, SectionList, Pressable, ActivityIndicator } from 'react-native'
|
||||
import { SafeAreaView, useSafeAreaInsets } from 'react-native-safe-area-context'
|
||||
import { useFocusEffect, useLocalSearchParams, usePathname, useRouter } from 'expo-router'
|
||||
import {
|
||||
@@ -56,6 +48,7 @@ import { ConfirmModal } from '../../../src/components/ConfirmModal'
|
||||
import { BottomDrawer } from '../../../src/components/BottomDrawer'
|
||||
import { ProtocolBlockScreen } from '../../../src/components/ProtocolBlockScreen'
|
||||
import { AuthFailedBanner } from '../../../src/components/AuthFailedBanner'
|
||||
import { MobileSearchField } from '../../../src/components/MobileSearchField'
|
||||
import { WorkspaceDetailPlaceholder } from '../../../src/components/WorkspaceDetailPlaceholder'
|
||||
import { getCachedWorktrees } from '../../../src/cache/worktree-cache'
|
||||
import { setCachedRepos } from '../../../src/cache/repo-cache'
|
||||
@@ -1134,22 +1127,16 @@ export function HostScreen({
|
||||
{/* Search bar */}
|
||||
{showSearch && (
|
||||
<View style={styles.searchBar}>
|
||||
<Search size={14} color={colors.textMuted} />
|
||||
<TextInput
|
||||
style={styles.searchInput}
|
||||
<MobileSearchField
|
||||
value={search}
|
||||
onChangeText={setSearch}
|
||||
placeholder="Search worktrees…"
|
||||
placeholderTextColor={colors.textMuted}
|
||||
autoCapitalize="none"
|
||||
autoCorrect={false}
|
||||
autoFocus
|
||||
// Why: new key each open remounts focus effect if the field stays mounted
|
||||
// across rapid toggles; pairs with delayed focus so the keyboard appears.
|
||||
focusKey={showSearch}
|
||||
accessibilityLabel="Search worktrees"
|
||||
/>
|
||||
{search.length > 0 && (
|
||||
<Pressable onPress={() => setSearch('')}>
|
||||
<X size={14} color={colors.textSecondary} />
|
||||
</Pressable>
|
||||
)}
|
||||
</View>
|
||||
)}
|
||||
|
||||
@@ -1181,6 +1168,9 @@ export function HostScreen({
|
||||
sections={sections}
|
||||
keyExtractor={(w) => w.sectionListKey ?? w.worktreeId}
|
||||
stickySectionHeadersEnabled={false}
|
||||
// Why: keep the search IME up while tapping clear / scrolling results.
|
||||
keyboardShouldPersistTaps="handled"
|
||||
keyboardDismissMode="on-drag"
|
||||
onScrollToIndexFailed={onScrollToIndexFailed}
|
||||
// Why: edge-to-edge — the list scrolls under the system nav bar
|
||||
// while reserving insets.bottom keeps the last worktree row reachable
|
||||
@@ -1615,21 +1605,12 @@ const styles = StyleSheet.create({
|
||||
padding: spacing.xs
|
||||
},
|
||||
searchBar: {
|
||||
flexDirection: 'row',
|
||||
alignItems: 'center',
|
||||
paddingHorizontal: spacing.md,
|
||||
paddingVertical: spacing.xs + 2,
|
||||
gap: spacing.sm,
|
||||
borderBottomWidth: 1,
|
||||
paddingVertical: spacing.sm,
|
||||
borderBottomWidth: StyleSheet.hairlineWidth,
|
||||
borderBottomColor: colors.borderSubtle,
|
||||
backgroundColor: colors.bgPanel
|
||||
},
|
||||
searchInput: {
|
||||
flex: 1,
|
||||
color: colors.textPrimary,
|
||||
fontSize: 13,
|
||||
paddingVertical: 2
|
||||
},
|
||||
centered: {
|
||||
flex: 1,
|
||||
alignItems: 'center',
|
||||
|
||||
@@ -93,7 +93,7 @@ import type {
|
||||
TerminalKeyboardAvoidanceMetrics,
|
||||
TerminalModes,
|
||||
TerminalWebViewHandle
|
||||
} from '../../../../src/terminal/TerminalWebView'
|
||||
} from '../../../../src/terminal/terminal-webview-contract'
|
||||
import { isTerminalOscLinkRanges } from '../../../../src/terminal/terminal-osc-link-ranges'
|
||||
import { useTerminalViewportRefit } from '../../../../src/terminal/terminal-viewport-refit'
|
||||
import {
|
||||
@@ -2530,8 +2530,12 @@ export default function SessionScreen() {
|
||||
if (!shouldRecover) {
|
||||
return
|
||||
}
|
||||
for (const terminalRef of terminalRefs.current.values()) {
|
||||
terminalRef.prepareForForegroundRecovery()
|
||||
}
|
||||
// Why: iOS can resume a live WKWebView with a blank xterm backing store
|
||||
// without firing web-ready/reconnect; replay scrollback to repaint it.
|
||||
// without firing web-ready/reconnect; invalidate the native readiness
|
||||
// latch before replay so init waits for the document's pong.
|
||||
recoverActiveTerminalAfterForeground({
|
||||
activeHandleRef,
|
||||
terminalRefs,
|
||||
@@ -2559,6 +2563,7 @@ export default function SessionScreen() {
|
||||
clientRef,
|
||||
deviceTokenRef,
|
||||
initializedHandlesRef,
|
||||
connState,
|
||||
tabStripVisible: terminals.length > 1,
|
||||
textScale: terminalTextScale,
|
||||
terminalFrameWidth,
|
||||
|
||||
@@ -1,5 +1,5 @@
|
||||
import type { MobileBrowserTab } from '../../../../src/browser/MobileBrowserPane'
|
||||
import type { MobileTerminalTheme } from '../../../../src/terminal/TerminalWebView'
|
||||
import type { MobileTerminalTheme } from '../../../../src/terminal/terminal-webview-contract'
|
||||
import type { MobileDiffLine } from '../../../../src/session/mobile-diff-lines'
|
||||
import type {
|
||||
MobileHighlightedDiffLine,
|
||||
|
||||
@@ -29,7 +29,6 @@ import {
|
||||
Pencil,
|
||||
Plus,
|
||||
RefreshCw,
|
||||
Search,
|
||||
Send,
|
||||
X
|
||||
} from 'lucide-react-native'
|
||||
@@ -48,6 +47,7 @@ import { ConfirmModal } from '../../../src/components/ConfirmModal'
|
||||
import { MobileMarkdown } from '../../../src/components/MobileMarkdown'
|
||||
import { MobileAgentIcon } from '../../../src/components/MobileAgentIcon'
|
||||
import { MobileWorkspaceNameInput } from '../../../src/components/MobileWorkspaceNameInput'
|
||||
import { MobileSearchField } from '../../../src/components/MobileSearchField'
|
||||
import { MobileSyntaxSegments } from '../../../src/components/MobileSyntaxSegments'
|
||||
import { PickerModal, type PickerOption } from '../../../src/components/PickerModal'
|
||||
import { TaskProviderLogo } from '../../../src/components/TaskProviderLogo'
|
||||
@@ -8668,6 +8668,7 @@ export default function MobileTasksScreen() {
|
||||
: provider === 'gitlab'
|
||||
? 'No GitLab tasks'
|
||||
: 'No Linear tasks'
|
||||
const isGithubProjectSearch = provider === 'github' && githubMode === 'project'
|
||||
|
||||
return (
|
||||
<SafeAreaView style={styles.container} edges={['top']}>
|
||||
@@ -9048,31 +9049,33 @@ export default function MobileTasksScreen() {
|
||||
{provider === 'gitlab' && gitlabView === 'todos' ? null : provider === 'linear' &&
|
||||
!linearConnected ? null : (
|
||||
<View style={styles.searchBar}>
|
||||
<Search size={14} color={colors.textMuted} />
|
||||
<TextInput
|
||||
style={styles.searchInput}
|
||||
value={
|
||||
provider === 'github' && githubMode === 'project' ? githubProjectSearch : query
|
||||
}
|
||||
onChangeText={
|
||||
provider === 'github' && githubMode === 'project'
|
||||
? setGithubProjectSearch
|
||||
: setQuery
|
||||
}
|
||||
<MobileSearchField
|
||||
value={isGithubProjectSearch ? githubProjectSearch : query}
|
||||
onChangeText={isGithubProjectSearch ? setGithubProjectSearch : setQuery}
|
||||
placeholder={
|
||||
provider === 'github' && githubMode === 'project'
|
||||
isGithubProjectSearch
|
||||
? 'Search project view...'
|
||||
: `Search ${providerLabel} tasks...`
|
||||
}
|
||||
placeholderTextColor={colors.textMuted}
|
||||
autoCapitalize="none"
|
||||
autoCorrect={false}
|
||||
returnKeyType="search"
|
||||
// Why: GitHub items seed the field with a preset query, so a bare
|
||||
// value.length check would always show clear. Project mode shows clear
|
||||
// for draft text or a non-empty applied override — not for applied ''
|
||||
// (explicit unfiltered after clear), or the button sticks forever.
|
||||
showClear={
|
||||
isGithubProjectSearch
|
||||
? githubProjectSearch.length > 0 ||
|
||||
(appliedGithubProjectSearch !== undefined &&
|
||||
appliedGithubProjectSearch.length > 0)
|
||||
: provider === 'github'
|
||||
? query.trim() !== getTaskPresetQuery(githubPreset).trim()
|
||||
: undefined
|
||||
}
|
||||
editable={taskUiReady}
|
||||
onSubmitEditing={() => {
|
||||
if (!taskUiReady) {
|
||||
return
|
||||
}
|
||||
if (provider === 'github' && githubMode === 'project') {
|
||||
if (isGithubProjectSearch) {
|
||||
applyGitHubProjectSearch()
|
||||
return
|
||||
}
|
||||
@@ -9091,42 +9094,38 @@ export default function MobileTasksScreen() {
|
||||
}
|
||||
}}
|
||||
onBlur={() => {
|
||||
if (provider === 'github' && githubMode === 'project') {
|
||||
if (isGithubProjectSearch) {
|
||||
applyGitHubProjectSearch()
|
||||
}
|
||||
}}
|
||||
// Why: Project clear means unfiltered results ('' override when the view
|
||||
// has a default filter), not restore view default. GitHub items clear
|
||||
// restores the preset query. Linear clears and persists empty resume.
|
||||
onClear={() => {
|
||||
if (isGithubProjectSearch) {
|
||||
const viewFilter = githubProjectTable?.selectedView.filter ?? ''
|
||||
setGithubProjectSearch('')
|
||||
// Why: undefined = use view default; '' = explicit unfiltered override.
|
||||
setAppliedGithubProjectSearch(viewFilter ? '' : undefined)
|
||||
return
|
||||
}
|
||||
if (provider === 'github') {
|
||||
const nextQuery = getTaskPresetQuery(githubPreset)
|
||||
setQuery(nextQuery)
|
||||
setAppliedQuery(nextQuery)
|
||||
persistTaskResumeState({
|
||||
githubItemsPreset: githubPreset,
|
||||
githubItemsQuery: nextQuery
|
||||
})
|
||||
return
|
||||
}
|
||||
setQuery('')
|
||||
setAppliedQuery('')
|
||||
if (provider === 'linear') {
|
||||
persistTaskResumeState({ linearQuery: '' })
|
||||
}
|
||||
}}
|
||||
/>
|
||||
{(provider === 'github' && githubMode === 'project'
|
||||
? githubProjectSearch.length > 0 || appliedGithubProjectSearch !== undefined
|
||||
: query.length > 0) && (
|
||||
<Pressable
|
||||
onPress={() => {
|
||||
if (provider === 'github' && githubMode === 'project') {
|
||||
const viewFilter = githubProjectTable?.selectedView.filter ?? ''
|
||||
setGithubProjectSearch('')
|
||||
setAppliedGithubProjectSearch(viewFilter ? '' : undefined)
|
||||
return
|
||||
}
|
||||
if (provider === 'github') {
|
||||
const nextQuery = getTaskPresetQuery(githubPreset)
|
||||
setQuery(nextQuery)
|
||||
setAppliedQuery(nextQuery)
|
||||
persistTaskResumeState({
|
||||
githubItemsPreset: githubPreset,
|
||||
githubItemsQuery: nextQuery
|
||||
})
|
||||
return
|
||||
}
|
||||
setQuery('')
|
||||
setAppliedQuery('')
|
||||
if (provider === 'linear') {
|
||||
persistTaskResumeState({ linearQuery: '' })
|
||||
}
|
||||
}}
|
||||
>
|
||||
<X size={14} color={colors.textSecondary} />
|
||||
</Pressable>
|
||||
)}
|
||||
</View>
|
||||
)}
|
||||
</View>
|
||||
@@ -13818,20 +13817,11 @@ const styles = StyleSheet.create({
|
||||
color: colors.textSecondary
|
||||
},
|
||||
searchBar: {
|
||||
flexDirection: 'row',
|
||||
alignItems: 'center',
|
||||
paddingHorizontal: spacing.md,
|
||||
paddingVertical: spacing.xs + 2,
|
||||
gap: spacing.sm,
|
||||
borderTopWidth: 1,
|
||||
paddingVertical: spacing.sm,
|
||||
borderTopWidth: StyleSheet.hairlineWidth,
|
||||
borderTopColor: colors.borderSubtle
|
||||
},
|
||||
searchInput: {
|
||||
flex: 1,
|
||||
color: colors.textPrimary,
|
||||
fontSize: 13,
|
||||
paddingVertical: 2
|
||||
},
|
||||
errorBanner: {
|
||||
paddingHorizontal: spacing.lg,
|
||||
paddingVertical: spacing.sm,
|
||||
|
||||
+7
-2
@@ -23,7 +23,7 @@
|
||||
"@xterm/addon-webgl": "0.20.0-beta.284",
|
||||
"@xterm/xterm": "6.1.0-beta.285",
|
||||
"buffer": "^6.0.3",
|
||||
"expo": "^55.0.23",
|
||||
"expo": "^55.0.27",
|
||||
"expo-build-properties": "^55.0.13",
|
||||
"expo-camera": "^55.0.18",
|
||||
"expo-clipboard": "^55.0.13",
|
||||
@@ -73,8 +73,13 @@
|
||||
"oxlint": "^1.71.0",
|
||||
"react-test-renderer": "19.2.6",
|
||||
"tsx": "^4.22.4",
|
||||
"typescript": "^5.9.3",
|
||||
"typescript": "^7.0.2",
|
||||
"vite": "^8.0.16",
|
||||
"vitest": "^4.1.9"
|
||||
},
|
||||
"pnpm": {
|
||||
"overrides": {
|
||||
"xcode>uuid": "11.1.1"
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
Generated
+750
-324
File diff suppressed because it is too large
Load Diff
@@ -0,0 +1,201 @@
|
||||
import { useEffect, useRef, useState } from 'react'
|
||||
import {
|
||||
InteractionManager,
|
||||
Platform,
|
||||
Pressable,
|
||||
StyleSheet,
|
||||
TextInput,
|
||||
View,
|
||||
type TextInputProps
|
||||
} from 'react-native'
|
||||
import { Search, X } from 'lucide-react-native'
|
||||
import { colors, radii, spacing, typography } from '../theme/mobile-theme'
|
||||
|
||||
// Why: toolbar/list chrome paints and settles after the open tap; native
|
||||
// autoFocus alone often fails to raise the soft keyboard on iOS/Android.
|
||||
const SEARCH_AUTO_FOCUS_DELAY_MS = 120
|
||||
|
||||
type MobileSearchFieldProps = {
|
||||
value: string
|
||||
onChangeText: (text: string) => void
|
||||
placeholder: string
|
||||
onClear?: () => void
|
||||
/** Override clear-button visibility (default: value is non-empty). */
|
||||
showClear?: boolean
|
||||
clearAccessibilityLabel?: string
|
||||
autoFocus?: boolean
|
||||
/** Re-run delayed focus when this identity changes (e.g. each time search opens). */
|
||||
focusKey?: unknown
|
||||
returnKeyType?: TextInputProps['returnKeyType']
|
||||
onSubmitEditing?: TextInputProps['onSubmitEditing']
|
||||
onBlur?: TextInputProps['onBlur']
|
||||
editable?: boolean
|
||||
accessibilityLabel?: string
|
||||
}
|
||||
|
||||
/**
|
||||
* Raised search field used on list screens. Sits above the base/panel canvas
|
||||
* so it reads as a tappable control instead of chrome that blends into the list.
|
||||
*/
|
||||
export function MobileSearchField({
|
||||
value,
|
||||
onChangeText,
|
||||
placeholder,
|
||||
onClear,
|
||||
showClear,
|
||||
clearAccessibilityLabel = 'Clear search',
|
||||
autoFocus = false,
|
||||
focusKey,
|
||||
returnKeyType = 'search',
|
||||
onSubmitEditing,
|
||||
onBlur,
|
||||
editable = true,
|
||||
accessibilityLabel
|
||||
}: MobileSearchFieldProps) {
|
||||
const inputRef = useRef<TextInput>(null)
|
||||
const [focused, setFocused] = useState(false)
|
||||
const clearVisible = showClear ?? value.length > 0
|
||||
|
||||
useEffect(() => {
|
||||
if (!autoFocus || !editable) {
|
||||
return
|
||||
}
|
||||
|
||||
let timeout: ReturnType<typeof setTimeout> | undefined
|
||||
// Why: wait for the open-press interaction + layout to finish, then focus
|
||||
// so the soft keyboard actually appears (not just a caret with no IME).
|
||||
const task = InteractionManager.runAfterInteractions(() => {
|
||||
timeout = setTimeout(() => {
|
||||
inputRef.current?.focus()
|
||||
}, SEARCH_AUTO_FOCUS_DELAY_MS)
|
||||
})
|
||||
|
||||
return () => {
|
||||
task.cancel()
|
||||
if (timeout) {
|
||||
clearTimeout(timeout)
|
||||
}
|
||||
}
|
||||
}, [autoFocus, editable, focusKey])
|
||||
|
||||
function handleClear() {
|
||||
if (onClear) {
|
||||
onClear()
|
||||
} else {
|
||||
onChangeText('')
|
||||
}
|
||||
// Why: pressing the clear chip steals focus and drops the keyboard;
|
||||
// re-focus so the user can keep typing without tapping the field again.
|
||||
requestAnimationFrame(() => {
|
||||
inputRef.current?.focus()
|
||||
})
|
||||
}
|
||||
|
||||
return (
|
||||
<View style={[styles.shell, focused && styles.shellFocused, !editable && styles.shellDisabled]}>
|
||||
<Search
|
||||
size={15}
|
||||
color={focused ? colors.textPrimary : colors.textSecondary}
|
||||
strokeWidth={2.2}
|
||||
/>
|
||||
<TextInput
|
||||
ref={inputRef}
|
||||
style={styles.input}
|
||||
value={value}
|
||||
onChangeText={onChangeText}
|
||||
placeholder={placeholder}
|
||||
// Why: textSecondary keeps the hint readable on bgRaised; textMuted
|
||||
// disappears against the raised shell and makes the field look empty.
|
||||
placeholderTextColor={colors.textSecondary}
|
||||
autoCapitalize="none"
|
||||
autoCorrect={false}
|
||||
// Still request native auto-focus; the delayed ref focus is the reliable path.
|
||||
autoFocus={autoFocus}
|
||||
showSoftInputOnFocus
|
||||
editable={editable}
|
||||
returnKeyType={returnKeyType}
|
||||
onSubmitEditing={onSubmitEditing}
|
||||
onFocus={() => setFocused(true)}
|
||||
onBlur={(event) => {
|
||||
setFocused(false)
|
||||
onBlur?.(event)
|
||||
}}
|
||||
clearButtonMode="never"
|
||||
accessibilityLabel={accessibilityLabel ?? placeholder}
|
||||
selectionColor={colors.accentBlue}
|
||||
/>
|
||||
{clearVisible ? (
|
||||
<Pressable
|
||||
onPress={handleClear}
|
||||
hitSlop={8}
|
||||
accessibilityRole="button"
|
||||
accessibilityLabel={clearAccessibilityLabel}
|
||||
style={({ pressed }) => [styles.clearButton, pressed && styles.clearButtonPressed]}
|
||||
>
|
||||
{/* Why: chip + larger hit target — a bare 14px X was hard to tap and
|
||||
read as decoration rather than a clear control. */}
|
||||
<View style={styles.clearChip}>
|
||||
<X size={12} color={colors.surfaceBright} strokeWidth={2.6} />
|
||||
</View>
|
||||
</Pressable>
|
||||
) : null}
|
||||
</View>
|
||||
)
|
||||
}
|
||||
|
||||
const styles = StyleSheet.create({
|
||||
shell: {
|
||||
minHeight: 42,
|
||||
flexDirection: 'row',
|
||||
alignItems: 'center',
|
||||
gap: spacing.sm,
|
||||
// Why: bgRaised lifts the field off bgBase/bgPanel so search is an obvious
|
||||
// control, matching TextInputModal / MobilePrBasePicker input shells.
|
||||
backgroundColor: colors.bgRaised,
|
||||
borderWidth: 1,
|
||||
borderColor: colors.borderSubtle,
|
||||
borderRadius: radii.input,
|
||||
paddingLeft: spacing.md,
|
||||
paddingRight: spacing.xs,
|
||||
paddingVertical: Platform.OS === 'ios' ? spacing.sm : spacing.xs + 2
|
||||
},
|
||||
shellFocused: {
|
||||
// Why: monochrome focus cue without burning the blue accent token
|
||||
// (reserved for state/selection). textMuted reads clearly on bgRaised.
|
||||
borderColor: colors.textMuted
|
||||
},
|
||||
shellDisabled: {
|
||||
opacity: 0.55
|
||||
},
|
||||
input: {
|
||||
flex: 1,
|
||||
minWidth: 0,
|
||||
padding: 0,
|
||||
margin: 0,
|
||||
color: colors.textPrimary,
|
||||
fontSize: typography.bodySize,
|
||||
// Why: Android TextInput draws extra vertical padding that misaligns the
|
||||
// icon/clear chip unless we zero it out.
|
||||
includeFontPadding: false,
|
||||
textAlignVertical: 'center'
|
||||
},
|
||||
clearButton: {
|
||||
minWidth: 36,
|
||||
minHeight: 36,
|
||||
alignItems: 'center',
|
||||
justifyContent: 'center'
|
||||
},
|
||||
clearButtonPressed: {
|
||||
opacity: 0.7
|
||||
},
|
||||
clearChip: {
|
||||
width: 24,
|
||||
height: 24,
|
||||
borderRadius: 12,
|
||||
alignItems: 'center',
|
||||
justifyContent: 'center',
|
||||
// Why: textMuted reads as a solid chip on bgRaised; borderSubtle was nearly
|
||||
// invisible and made the clear control feel like decorative chrome.
|
||||
backgroundColor: colors.textMuted
|
||||
}
|
||||
})
|
||||
@@ -1,12 +1,12 @@
|
||||
import { useCallback } from 'react'
|
||||
import { StyleSheet, View } from 'react-native'
|
||||
import {
|
||||
TerminalWebView,
|
||||
type MobileTerminalTheme,
|
||||
type TerminalKeyboardAvoidanceMetrics,
|
||||
type TerminalModes,
|
||||
type TerminalWebViewHandle
|
||||
} from '../terminal/TerminalWebView'
|
||||
import { TerminalWebView } from '../terminal/TerminalWebView'
|
||||
import type {
|
||||
MobileTerminalTheme,
|
||||
TerminalKeyboardAvoidanceMetrics,
|
||||
TerminalModes,
|
||||
TerminalWebViewHandle
|
||||
} from '../terminal/terminal-webview-contract'
|
||||
|
||||
type TerminalPaneViewProps = {
|
||||
handle: string
|
||||
|
||||
@@ -1,4 +1,4 @@
|
||||
import type { TerminalModes } from '../terminal/TerminalWebView'
|
||||
import type { TerminalModes } from '../terminal/terminal-webview-contract'
|
||||
import type { ConnectionState } from '../transport/types'
|
||||
|
||||
export const MOBILE_SESSION_STATUS_LABELS: Record<ConnectionState, string> = {
|
||||
|
||||
@@ -2,6 +2,7 @@ import { describe, expect, it } from 'vitest'
|
||||
import {
|
||||
getTerminalRecordsFromSessionTabs,
|
||||
mergeTerminalListWithKnownRecords,
|
||||
mergeTerminalRecordsByCurrentOrder,
|
||||
mobileSessionTabsEqual,
|
||||
type MobileTerminalSessionTab,
|
||||
type TerminalRecord
|
||||
@@ -24,6 +25,17 @@ const darkTheme = {
|
||||
}
|
||||
|
||||
describe('mobile terminal records', () => {
|
||||
it('keeps the known theme when a session-tab snapshot omits it', () => {
|
||||
const known: TerminalRecord[] = [
|
||||
{ handle: 'pty-1', title: 'Old title', terminalTheme: darkTheme, isActive: false }
|
||||
]
|
||||
const snapshot: TerminalRecord[] = [{ handle: 'pty-1', title: 'Current title', isActive: true }]
|
||||
|
||||
expect(mergeTerminalRecordsByCurrentOrder(snapshot, known)).toEqual([
|
||||
{ handle: 'pty-1', title: 'Current title', terminalTheme: darkTheme, isActive: true }
|
||||
])
|
||||
})
|
||||
|
||||
it('keeps session-tab terminal themes when terminal.list omits them', () => {
|
||||
const terminalList: TerminalRecord[] = [
|
||||
{ handle: 'pty-1', title: 'Terminal', isActive: true },
|
||||
|
||||
@@ -1,4 +1,4 @@
|
||||
import type { MobileTerminalTheme } from '../terminal/TerminalWebView'
|
||||
import type { MobileTerminalTheme } from '../terminal/terminal-webview-contract'
|
||||
import type { AgentStatusEntry } from '../../../src/shared/agent-status-types'
|
||||
|
||||
export type TerminalRecord = {
|
||||
@@ -116,6 +116,20 @@ function mobileSessionTabEqual(
|
||||
}
|
||||
}
|
||||
|
||||
// Reconcile a partial session snapshot against the last known record for the
|
||||
// same terminal. The snapshot owns live fields (title, activity); the known
|
||||
// theme only survives when the snapshot omits it, since lightweight snapshots
|
||||
// can drop it.
|
||||
function mergeTerminalSnapshotWithKnownRecord(
|
||||
snapshot: TerminalRecord,
|
||||
known: TerminalRecord
|
||||
): TerminalRecord {
|
||||
return {
|
||||
...snapshot,
|
||||
terminalTheme: snapshot.terminalTheme ?? known.terminalTheme
|
||||
}
|
||||
}
|
||||
|
||||
export function mergeTerminalRecordsByCurrentOrder(
|
||||
terminalTabs: TerminalRecord[],
|
||||
currentTerminals: TerminalRecord[]
|
||||
@@ -126,7 +140,12 @@ export function mergeTerminalRecordsByCurrentOrder(
|
||||
const terminalTabsByHandle = new Map(terminalTabs.map((tab) => [tab.handle, tab]))
|
||||
const currentHandles = new Set(currentTerminals.map((terminal) => terminal.handle))
|
||||
return [
|
||||
...currentTerminals.map((terminal) => terminalTabsByHandle.get(terminal.handle) ?? terminal),
|
||||
...currentTerminals.map((terminal) => {
|
||||
const snapshotTerminal = terminalTabsByHandle.get(terminal.handle)
|
||||
return snapshotTerminal
|
||||
? mergeTerminalSnapshotWithKnownRecord(snapshotTerminal, terminal)
|
||||
: terminal
|
||||
}),
|
||||
...terminalTabs.filter((terminal) => !currentHandles.has(terminal.handle))
|
||||
]
|
||||
}
|
||||
|
||||
@@ -2,7 +2,7 @@ import { useCallback, type RefObject } from 'react'
|
||||
import * as Clipboard from 'expo-clipboard'
|
||||
import { File as FsFile, Paths } from 'expo-file-system'
|
||||
import { ImageManipulator, SaveFormat } from 'expo-image-manipulator'
|
||||
import type { TerminalModes } from '../terminal/TerminalWebView'
|
||||
import type { TerminalModes } from '../terminal/terminal-webview-contract'
|
||||
import type { RpcClient } from '../transport/rpc-client'
|
||||
import type { ConnectionState } from '../transport/types'
|
||||
import {
|
||||
|
||||
@@ -1,13 +1,9 @@
|
||||
import { useRef, useCallback, forwardRef, useImperativeHandle, useEffect, useMemo } from 'react'
|
||||
import { View, type StyleProp, type ViewStyle } from 'react-native'
|
||||
import { Platform, View } from 'react-native'
|
||||
import { WebView } from 'react-native-webview'
|
||||
import type { WebViewMessageEvent } from 'react-native-webview'
|
||||
import type { TerminalOscLinkRange } from './terminal-osc-link-ranges'
|
||||
import type {
|
||||
MobileTerminalTheme,
|
||||
TerminalSelectionEvents,
|
||||
TerminalWebViewHandle
|
||||
} from './terminal-webview-contract'
|
||||
import type { TerminalWebViewHandle, TerminalWebViewProps } from './terminal-webview-contract'
|
||||
import {
|
||||
TerminalWebViewEngineErrorOverlay,
|
||||
useTerminalWebViewEngineErrorState
|
||||
@@ -18,23 +14,9 @@ import { XTERM_WEBVIEW_SOURCE } from './terminal-webview-html'
|
||||
import type { TerminalWebViewCommand } from './terminal-webview-messages'
|
||||
import { createTerminalWebViewPendingMessages } from './terminal-webview-pending-messages'
|
||||
|
||||
export type {
|
||||
MobileTerminalTheme,
|
||||
TerminalKeyboardAvoidanceMetrics,
|
||||
TerminalModes,
|
||||
TerminalSelectionEvents,
|
||||
TerminalWebViewHandle
|
||||
} from './terminal-webview-contract'
|
||||
type Props = TerminalWebViewProps
|
||||
|
||||
type Props = {
|
||||
style?: StyleProp<ViewStyle>
|
||||
terminalTheme?: MobileTerminalTheme
|
||||
// Why: baseline zoom multiplier ("text size") applied on top of the fit-to-width
|
||||
// scale; raw xterm fontSize can't drive apparent size because the fit cancels it.
|
||||
textScale?: number
|
||||
onWebReady?: () => void
|
||||
onEngineError?: (message: string) => void
|
||||
} & TerminalSelectionEvents
|
||||
export type { TerminalWebViewHandle } from './terminal-webview-contract'
|
||||
|
||||
export const TerminalWebView = forwardRef<TerminalWebViewHandle, Props>(function TerminalWebView(
|
||||
{
|
||||
@@ -61,6 +43,7 @@ export const TerminalWebView = forwardRef<TerminalWebViewHandle, Props>(function
|
||||
const isWebReadyRef = useRef(false)
|
||||
const pendingMessages = useMemo(() => createTerminalWebViewPendingMessages(), [])
|
||||
const messageIdRef = useRef(0)
|
||||
const pendingPingIdRef = useRef<number | null>(null)
|
||||
const terminalThemeKey = useMemo(() => JSON.stringify(terminalTheme ?? null), [terminalTheme])
|
||||
const measureResolveRef = useRef<
|
||||
((result: { cols: number; rows: number } | null) => void) | null
|
||||
@@ -80,7 +63,9 @@ export const TerminalWebView = forwardRef<TerminalWebViewHandle, Props>(function
|
||||
|
||||
const sendToWebView = useCallback((msg: TerminalWebViewCommand) => {
|
||||
messageIdRef.current += 1
|
||||
webViewRef.current?.postMessage(JSON.stringify({ ...msg, id: messageIdRef.current }))
|
||||
const id = messageIdRef.current
|
||||
webViewRef.current?.postMessage(JSON.stringify({ ...msg, id }))
|
||||
return id
|
||||
}, [])
|
||||
|
||||
const flushPendingMessages = useCallback(() => {
|
||||
@@ -98,6 +83,30 @@ export const TerminalWebView = forwardRef<TerminalWebViewHandle, Props>(function
|
||||
[pendingMessages, sendToWebView]
|
||||
)
|
||||
|
||||
const confirmWebReady = useCallback(
|
||||
(notifyParent: boolean) => {
|
||||
pendingPingIdRef.current = null
|
||||
isWebReadyRef.current = true
|
||||
clearWebReadyWatchdog()
|
||||
clearEngineError()
|
||||
if (notifyParent) {
|
||||
onWebReady?.()
|
||||
}
|
||||
// Why: reload clears queued commands, so readiness must always restore the
|
||||
// native-selected theme even when its value did not change in React.
|
||||
sendToWebView({ type: 'set-theme', terminalTheme })
|
||||
flushPendingMessages()
|
||||
},
|
||||
[
|
||||
clearEngineError,
|
||||
clearWebReadyWatchdog,
|
||||
flushPendingMessages,
|
||||
onWebReady,
|
||||
sendToWebView,
|
||||
terminalTheme
|
||||
]
|
||||
)
|
||||
|
||||
const handleMessage = useCallback(
|
||||
(event: WebViewMessageEvent) => {
|
||||
let msg: Record<string, unknown>
|
||||
@@ -108,11 +117,13 @@ export const TerminalWebView = forwardRef<TerminalWebViewHandle, Props>(function
|
||||
}
|
||||
|
||||
if (msg.type === 'web-ready') {
|
||||
isWebReadyRef.current = true
|
||||
clearWebReadyWatchdog()
|
||||
clearEngineError()
|
||||
onWebReady?.()
|
||||
flushPendingMessages()
|
||||
confirmWebReady(true)
|
||||
} else if (
|
||||
msg.type === 'pong' &&
|
||||
typeof msg.pingId === 'number' &&
|
||||
msg.pingId === pendingPingIdRef.current
|
||||
) {
|
||||
confirmWebReady(false)
|
||||
} else if (msg.type === 'ready') {
|
||||
// Why: the WebView's init() rAF chain has run — term is open,
|
||||
// renderService is populated, first paint has happened. Resolve
|
||||
@@ -208,11 +219,8 @@ export const TerminalWebView = forwardRef<TerminalWebViewHandle, Props>(function
|
||||
}
|
||||
},
|
||||
[
|
||||
flushPendingMessages,
|
||||
clearEngineError,
|
||||
clearWebReadyWatchdog,
|
||||
confirmWebReady,
|
||||
reportEngineError,
|
||||
onWebReady,
|
||||
onSelectionMode,
|
||||
onSelectionCopy,
|
||||
onSelectionEvicted,
|
||||
@@ -229,6 +237,7 @@ export const TerminalWebView = forwardRef<TerminalWebViewHandle, Props>(function
|
||||
|
||||
const handleLoadStart = useCallback(() => {
|
||||
isWebReadyRef.current = false
|
||||
pendingPingIdRef.current = null
|
||||
armWebReadyWatchdog()
|
||||
// Why: messages queued for a previous WebView generation are stale after a reload;
|
||||
// dropping them avoids replaying terminal chunks before the next init snapshot.
|
||||
@@ -240,6 +249,17 @@ export const TerminalWebView = forwardRef<TerminalWebViewHandle, Props>(function
|
||||
webViewRef.current?.reload()
|
||||
}, [clearEngineError])
|
||||
|
||||
const handleContentProcessDidTerminate = useCallback(() => {
|
||||
// Why: WKWebView content-process loss is recoverable; stale commands belong
|
||||
// to the dead document and the replacement must prove readiness before replay.
|
||||
isWebReadyRef.current = false
|
||||
pendingPingIdRef.current = null
|
||||
pendingMessages.clear()
|
||||
clearEngineError()
|
||||
armWebReadyWatchdog()
|
||||
webViewRef.current?.reload()
|
||||
}, [armWebReadyWatchdog, clearEngineError, pendingMessages])
|
||||
|
||||
useEffect(() => {
|
||||
postMessage({ type: 'set-theme', terminalTheme })
|
||||
}, [postMessage, terminalThemeKey, terminalTheme])
|
||||
@@ -253,6 +273,16 @@ export const TerminalWebView = forwardRef<TerminalWebViewHandle, Props>(function
|
||||
useImperativeHandle(
|
||||
ref,
|
||||
() => ({
|
||||
prepareForForegroundRecovery() {
|
||||
if (Platform.OS !== 'ios') {
|
||||
return
|
||||
}
|
||||
// Why: direct ping is the only command allowed through while readiness is
|
||||
// invalid; init/write commands queue until this exact document answers.
|
||||
isWebReadyRef.current = false
|
||||
armWebReadyWatchdog()
|
||||
pendingPingIdRef.current = sendToWebView({ type: 'ping' })
|
||||
},
|
||||
write(data: string) {
|
||||
postMessage({ type: 'write', data })
|
||||
},
|
||||
@@ -363,7 +393,7 @@ export const TerminalWebView = forwardRef<TerminalWebViewHandle, Props>(function
|
||||
})
|
||||
}
|
||||
}),
|
||||
[postMessage, sendToWebView, terminalTheme, textScale]
|
||||
[armWebReadyWatchdog, postMessage, sendToWebView, terminalTheme, textScale]
|
||||
)
|
||||
|
||||
return (
|
||||
@@ -389,9 +419,7 @@ export const TerminalWebView = forwardRef<TerminalWebViewHandle, Props>(function
|
||||
onRenderProcessGone={(event) =>
|
||||
reportNativeEngineError('Terminal WebView render process ended', event)
|
||||
}
|
||||
onContentProcessDidTerminate={(event) =>
|
||||
reportNativeEngineError('Terminal WebView content process ended', event)
|
||||
}
|
||||
onContentProcessDidTerminate={handleContentProcessDidTerminate}
|
||||
/>
|
||||
{engineError ? (
|
||||
<TerminalWebViewEngineErrorOverlay message={engineError} onReload={handleReload} />
|
||||
|
||||
@@ -127,5 +127,11 @@ describe('terminal foreground recovery', () => {
|
||||
expect(foregroundPredicate).toContain('Platform.OS')
|
||||
expect(sessionSource).toContain('recoverActiveTerminalAfterForeground({')
|
||||
expect(sessionSource).toContain("AppState.addEventListener('change'")
|
||||
const readinessInvalidation = sessionSource.indexOf(
|
||||
'terminalRef.prepareForForegroundRecovery()'
|
||||
)
|
||||
const replay = sessionSource.indexOf('recoverActiveTerminalAfterForeground({')
|
||||
expect(readinessInvalidation).toBeGreaterThanOrEqual(0)
|
||||
expect(replay).toBeGreaterThan(readinessInvalidation)
|
||||
})
|
||||
})
|
||||
|
||||
@@ -52,6 +52,36 @@ describe('terminal viewport refit', () => {
|
||||
expect(sessionSource).toContain('useTerminalViewportRefit({')
|
||||
expect(sessionSource).toContain('tabStripVisible: terminals.length > 1')
|
||||
expect(sessionSource).toContain('textScale: terminalTextScale')
|
||||
expect(sessionSource).toContain('connState,')
|
||||
})
|
||||
|
||||
it('forces a refit on iOS foreground and connection recovery', () => {
|
||||
const foregroundEffect = hookSource.slice(
|
||||
hookSource.indexOf("if (Platform.OS !== 'ios')"),
|
||||
hookSource.indexOf('const previousConnStateRef')
|
||||
)
|
||||
const reconnectEffect = hookSource.slice(
|
||||
hookSource.indexOf('const previousConnStateRef'),
|
||||
hookSource.indexOf('disposedRef.current = false')
|
||||
)
|
||||
|
||||
expect(foregroundEffect).toContain("AppState.addEventListener('change'")
|
||||
expect(foregroundEffect).toContain('viewportMeasuredRef.current = false')
|
||||
expect(foregroundEffect).toContain('scheduleForcedViewportRefit()')
|
||||
expect(reconnectEffect).toContain("connState !== 'connected'")
|
||||
expect(reconnectEffect).toContain('viewportMeasuredRef.current = false')
|
||||
expect(reconnectEffect).toContain('scheduleForcedViewportRefit()')
|
||||
})
|
||||
|
||||
it('bypasses the equal-dimensions guard for resume and reconnect refits', () => {
|
||||
expect(hookSource).toContain('const forceRefit = forceNextRefitRef.current')
|
||||
expect(hookSource).toContain(
|
||||
'if (!forceRefit && prev && prev.cols === dims.cols && prev.rows === dims.rows)'
|
||||
)
|
||||
const forceRead = hookSource.indexOf('const forceRefit = forceNextRefitRef.current')
|
||||
const updateViewport = hookSource.indexOf("sendRequest('terminal.updateViewport'")
|
||||
expect(forceRead).toBeGreaterThanOrEqual(0)
|
||||
expect(updateViewport).toBeGreaterThan(forceRead)
|
||||
})
|
||||
|
||||
it('prefers the in-place updateViewport RPC over resubscribe', () => {
|
||||
|
||||
@@ -1,7 +1,9 @@
|
||||
import { useCallback, useEffect, useRef, type RefObject } from 'react'
|
||||
import { useWindowDimensions } from 'react-native'
|
||||
import { AppState, Platform, useWindowDimensions, type AppStateStatus } from 'react-native'
|
||||
import type { RpcClient } from '../transport/rpc-client'
|
||||
import type { ConnectionState } from '../transport/types'
|
||||
import type { TerminalWebViewHandle } from './TerminalWebView'
|
||||
import { shouldRecoverTerminalOnAppStateChange } from './terminal-foreground-recovery'
|
||||
import {
|
||||
isTerminalUpdateViewportApplied,
|
||||
isTerminalUpdateViewportUpdated,
|
||||
@@ -19,6 +21,7 @@ type TerminalViewportRefitOptions = {
|
||||
clientRef: RefObject<RpcClient | null>
|
||||
deviceTokenRef: RefObject<string | null>
|
||||
initializedHandlesRef: RefObject<Set<string>>
|
||||
connState: ConnectionState
|
||||
tabStripVisible: boolean
|
||||
// Why: terminal text size (font scale) — changing it changes the cell size, so
|
||||
// the PTY must be re-fitted to a new column count and reflowed.
|
||||
@@ -49,6 +52,7 @@ export function useTerminalViewportRefit(options: TerminalViewportRefitOptions):
|
||||
clientRef,
|
||||
deviceTokenRef,
|
||||
initializedHandlesRef,
|
||||
connState,
|
||||
tabStripVisible,
|
||||
textScale,
|
||||
terminalFrameWidth,
|
||||
@@ -58,6 +62,7 @@ export function useTerminalViewportRefit(options: TerminalViewportRefitOptions):
|
||||
|
||||
const refitTimerRef = useRef<ReturnType<typeof setTimeout> | null>(null)
|
||||
const refitRunSeqRef = useRef(0)
|
||||
const forceNextRefitRef = useRef(false)
|
||||
const disposedRef = useRef(false)
|
||||
const scheduleViewportRefit = useCallback(() => {
|
||||
if (refitTimerRef.current) {
|
||||
@@ -93,8 +98,10 @@ export function useTerminalViewportRefit(options: TerminalViewportRefitOptions):
|
||||
if (!dims) {
|
||||
return
|
||||
}
|
||||
const forceRefit = forceNextRefitRef.current
|
||||
forceNextRefitRef.current = false
|
||||
const prev = viewportRef.current
|
||||
if (prev && prev.cols === dims.cols && prev.rows === dims.rows) {
|
||||
if (!forceRefit && prev && prev.cols === dims.cols && prev.rows === dims.rows) {
|
||||
return
|
||||
}
|
||||
viewportRef.current = dims
|
||||
@@ -152,6 +159,10 @@ export function useTerminalViewportRefit(options: TerminalViewportRefitOptions):
|
||||
unsubscribeTerminal,
|
||||
subscribeToTerminal
|
||||
])
|
||||
const scheduleForcedViewportRefit = useCallback(() => {
|
||||
forceNextRefitRef.current = true
|
||||
scheduleViewportRefit()
|
||||
}, [scheduleViewportRefit])
|
||||
|
||||
// Why: the tab strip is hidden when only one terminal exists and shown
|
||||
// once a second is created. Crossing the 1↔2 boundary changes the
|
||||
@@ -215,6 +226,42 @@ export function useTerminalViewportRefit(options: TerminalViewportRefitOptions):
|
||||
scheduleViewportRefit()
|
||||
}, [terminalFrameWidth, viewportMeasuredRef, scheduleViewportRefit])
|
||||
|
||||
useEffect(() => {
|
||||
if (Platform.OS !== 'ios') {
|
||||
return
|
||||
}
|
||||
let previousAppState: AppStateStatus | null = AppState.currentState
|
||||
const sub = AppState.addEventListener('change', (nextAppState: AppStateStatus) => {
|
||||
const shouldRefit = shouldRecoverTerminalOnAppStateChange(
|
||||
previousAppState,
|
||||
nextAppState,
|
||||
Platform.OS
|
||||
)
|
||||
previousAppState = nextAppState
|
||||
if (!shouldRefit) {
|
||||
return
|
||||
}
|
||||
// Why: the cached grid can match while the host PTY changed in background;
|
||||
// reasserting equal dimensions is the convergence signal after iOS resume.
|
||||
viewportMeasuredRef.current = false
|
||||
scheduleForcedViewportRefit()
|
||||
})
|
||||
return () => sub.remove()
|
||||
}, [viewportMeasuredRef, scheduleForcedViewportRefit])
|
||||
|
||||
const previousConnStateRef = useRef(connState)
|
||||
useEffect(() => {
|
||||
const previous = previousConnStateRef.current
|
||||
previousConnStateRef.current = connState
|
||||
if (previous === 'connected' || connState !== 'connected') {
|
||||
return
|
||||
}
|
||||
// Why: reconnect can restore a PTY whose host-side size changed while the
|
||||
// socket was down, so equal cached dimensions still need reassertion.
|
||||
viewportMeasuredRef.current = false
|
||||
scheduleForcedViewportRefit()
|
||||
}, [connState, viewportMeasuredRef, scheduleForcedViewportRefit])
|
||||
|
||||
useEffect(() => {
|
||||
disposedRef.current = false
|
||||
return () => {
|
||||
|
||||
@@ -1,4 +1,5 @@
|
||||
import type { RuntimeMobileTerminalTheme } from '../../../src/shared/runtime-types'
|
||||
import type { StyleProp, ViewStyle } from 'react-native'
|
||||
import type { TerminalOscLinkRange } from './terminal-osc-link-ranges'
|
||||
|
||||
type TerminalMouseTrackingMode = 'none' | 'x10' | 'vt200' | 'drag' | 'any'
|
||||
@@ -37,7 +38,20 @@ export type TerminalSelectionEvents = {
|
||||
onTextScaleChange?: (scale: number) => void
|
||||
}
|
||||
|
||||
export type TerminalWebViewProps = {
|
||||
style?: StyleProp<ViewStyle>
|
||||
terminalTheme?: MobileTerminalTheme
|
||||
// Why: baseline zoom multiplier applied on top of fit-to-width scale; raw
|
||||
// xterm fontSize alone cannot drive apparent size because fitting cancels it.
|
||||
textScale?: number
|
||||
onWebReady?: () => void
|
||||
onEngineError?: (message: string) => void
|
||||
} & TerminalSelectionEvents
|
||||
|
||||
export type TerminalWebViewHandle = {
|
||||
// Why: iOS can preserve the native view while discarding its JS/backing-store
|
||||
// state; foreground recovery must wait for the document to answer before replay.
|
||||
prepareForForegroundRecovery: () => void
|
||||
write: (data: string) => void
|
||||
init: (
|
||||
cols: number,
|
||||
|
||||
@@ -1,10 +1,18 @@
|
||||
import { createElement } from 'react'
|
||||
import { createElement, createRef } from 'react'
|
||||
import { Platform } from 'react-native'
|
||||
import { act, create, type ReactTestRenderer } from 'react-test-renderer'
|
||||
import { afterEach, describe, expect, it, vi } from 'vitest'
|
||||
import { TerminalWebView } from './TerminalWebView'
|
||||
import type { TerminalWebViewHandle } from './terminal-webview-contract'
|
||||
|
||||
const nativeWebViewMethods = vi.hoisted(() => ({
|
||||
postMessage: vi.fn<(message: string) => void>(),
|
||||
reload: vi.fn<() => void>()
|
||||
}))
|
||||
|
||||
vi.mock('react-native', () => ({
|
||||
AppState: { currentState: 'active' },
|
||||
Platform: { OS: 'ios' },
|
||||
Pressable: 'Pressable',
|
||||
StyleSheet: {
|
||||
absoluteFillObject: {
|
||||
@@ -20,10 +28,14 @@ vi.mock('react-native', () => ({
|
||||
View: 'View'
|
||||
}))
|
||||
|
||||
vi.mock('react-native-webview', () => ({
|
||||
WebView: 'WebView',
|
||||
default: 'WebView'
|
||||
}))
|
||||
vi.mock('react-native-webview', async () => {
|
||||
const React = await import('react')
|
||||
const WebView = React.forwardRef((props: Record<string, unknown>, ref) => {
|
||||
React.useImperativeHandle(ref, () => nativeWebViewMethods)
|
||||
return React.createElement('WebView', props)
|
||||
})
|
||||
return { WebView, default: WebView }
|
||||
})
|
||||
|
||||
vi.mock('lucide-react-native', () => ({
|
||||
RefreshCw: 'RefreshCw'
|
||||
@@ -45,12 +57,15 @@ function suppressReactTestRendererDeprecationWarning(): () => void {
|
||||
// unmount so the timer can't survive into later tests and fire in teardown.
|
||||
let activeRenderer: ReactTestRenderer | null = null
|
||||
|
||||
function createTerminalWebViewRenderer(onEngineError = vi.fn()) {
|
||||
function createTerminalWebViewRenderer(
|
||||
onEngineError = vi.fn(),
|
||||
props: Record<string, unknown> = {}
|
||||
) {
|
||||
let renderer: ReactTestRenderer | null = null
|
||||
const restoreConsoleError = suppressReactTestRendererDeprecationWarning()
|
||||
try {
|
||||
act(() => {
|
||||
renderer = create(createElement(TerminalWebView, { onEngineError }))
|
||||
renderer = create(createElement(TerminalWebView, { onEngineError, ...props }))
|
||||
})
|
||||
} finally {
|
||||
restoreConsoleError()
|
||||
@@ -76,6 +91,10 @@ function renderedText(renderer: ReactTestRenderer): string {
|
||||
.join(' ')
|
||||
}
|
||||
|
||||
function postedCommands(): Array<Record<string, unknown>> {
|
||||
return nativeWebViewMethods.postMessage.mock.calls.map(([message]) => JSON.parse(message))
|
||||
}
|
||||
|
||||
describe('TerminalWebView engine errors', () => {
|
||||
afterEach(() => {
|
||||
if (activeRenderer) {
|
||||
@@ -84,6 +103,7 @@ describe('TerminalWebView engine errors', () => {
|
||||
})
|
||||
activeRenderer = null
|
||||
}
|
||||
vi.clearAllMocks()
|
||||
vi.restoreAllMocks()
|
||||
})
|
||||
|
||||
@@ -175,4 +195,78 @@ describe('TerminalWebView engine errors', () => {
|
||||
vi.useRealTimers()
|
||||
}
|
||||
})
|
||||
|
||||
it('queues iOS foreground traffic until the current document answers its ping', () => {
|
||||
const terminalRef = createRef<TerminalWebViewHandle>()
|
||||
const onWebReady = vi.fn()
|
||||
const terminalTheme = {
|
||||
mode: 'dark',
|
||||
theme: { background: '#111111', foreground: '#eeeeee' }
|
||||
}
|
||||
const { renderer } = createTerminalWebViewRenderer(vi.fn(), {
|
||||
ref: terminalRef,
|
||||
onWebReady,
|
||||
terminalTheme
|
||||
})
|
||||
postWebViewMessage(renderer, { type: 'web-ready' })
|
||||
nativeWebViewMethods.postMessage.mockClear()
|
||||
|
||||
act(() => {
|
||||
terminalRef.current?.prepareForForegroundRecovery()
|
||||
terminalRef.current?.write('queued output')
|
||||
})
|
||||
|
||||
const ping = postedCommands()[0]
|
||||
expect(postedCommands().map((command) => command.type)).toEqual(['ping'])
|
||||
postWebViewMessage(renderer, { type: 'pong', pingId: Number(ping?.id) + 1 })
|
||||
expect(postedCommands().map((command) => command.type)).toEqual(['ping'])
|
||||
|
||||
postWebViewMessage(renderer, { type: 'pong', pingId: ping?.id })
|
||||
expect(postedCommands().map((command) => command.type)).toEqual(['ping', 'set-theme', 'write'])
|
||||
expect(onWebReady).toHaveBeenCalledTimes(1)
|
||||
})
|
||||
|
||||
it('keeps Android foreground traffic on the existing ready document', () => {
|
||||
const terminalRef = createRef<TerminalWebViewHandle>()
|
||||
const { renderer } = createTerminalWebViewRenderer(vi.fn(), { ref: terminalRef })
|
||||
postWebViewMessage(renderer, { type: 'web-ready' })
|
||||
nativeWebViewMethods.postMessage.mockClear()
|
||||
const mutablePlatform = Platform as { OS: string }
|
||||
mutablePlatform.OS = 'android'
|
||||
try {
|
||||
act(() => {
|
||||
terminalRef.current?.prepareForForegroundRecovery()
|
||||
terminalRef.current?.write('android output')
|
||||
})
|
||||
expect(postedCommands().map((command) => command.type)).toEqual(['write'])
|
||||
} finally {
|
||||
mutablePlatform.OS = 'ios'
|
||||
}
|
||||
})
|
||||
|
||||
it('reloads a terminated iOS content process and restores theme on readiness', () => {
|
||||
const terminalRef = createRef<TerminalWebViewHandle>()
|
||||
const { onEngineError, renderer } = createTerminalWebViewRenderer(vi.fn(), {
|
||||
ref: terminalRef,
|
||||
terminalTheme: {
|
||||
mode: 'light',
|
||||
theme: { background: '#ffffff', foreground: '#111111' }
|
||||
}
|
||||
})
|
||||
postWebViewMessage(renderer, { type: 'web-ready' })
|
||||
nativeWebViewMethods.postMessage.mockClear()
|
||||
const webView = renderer.root.findByType('WebView')
|
||||
|
||||
act(() => {
|
||||
webView.props.onContentProcessDidTerminate({ nativeEvent: {} })
|
||||
terminalRef.current?.write('after termination')
|
||||
})
|
||||
|
||||
expect(nativeWebViewMethods.reload).toHaveBeenCalledTimes(1)
|
||||
expect(nativeWebViewMethods.postMessage).not.toHaveBeenCalled()
|
||||
expect(onEngineError).not.toHaveBeenCalled()
|
||||
|
||||
postWebViewMessage(renderer, { type: 'web-ready' })
|
||||
expect(postedCommands().map((command) => command.type)).toEqual(['set-theme', 'write'])
|
||||
})
|
||||
})
|
||||
|
||||
@@ -1,15 +1,95 @@
|
||||
import { readFileSync } from 'node:fs'
|
||||
import { Script } from 'node:vm'
|
||||
import { parse } from 'acorn'
|
||||
import { describe, expect, it } from 'vitest'
|
||||
import { describe, expect, it, vi } from 'vitest'
|
||||
import { XTERM_ENGINE_CSS, XTERM_ENGINE_JS } from './terminal-webview-engine.generated'
|
||||
import { XTERM_HTML } from './terminal-webview-html'
|
||||
import { TERMINAL_WEBGL_RECOVERY_JS } from './terminal-webview-webgl-recovery-injected'
|
||||
|
||||
const terminalHtmlSource = readFileSync(
|
||||
new URL('./terminal-webview-html.ts', import.meta.url),
|
||||
'utf8'
|
||||
)
|
||||
|
||||
function createWebglRecoveryHarness(failSecondAttach = false) {
|
||||
const variablesStart = terminalHtmlSource.indexOf(' var webglAddon = null;')
|
||||
const variablesEnd = terminalHtmlSource.indexOf(
|
||||
'\n',
|
||||
terminalHtmlSource.indexOf(' var webglRecoveryTimer = null;')
|
||||
)
|
||||
expect(variablesStart).toBeGreaterThanOrEqual(0)
|
||||
expect(variablesEnd).toBeGreaterThan(variablesStart)
|
||||
|
||||
const timers: Array<() => void> = []
|
||||
const addons: Array<{
|
||||
clearTextureAtlas: ReturnType<typeof vi.fn>
|
||||
dispose: ReturnType<typeof vi.fn>
|
||||
fireContextLoss: () => void
|
||||
}> = []
|
||||
const term = {
|
||||
rows: 24,
|
||||
refresh: vi.fn(),
|
||||
loadAddon: vi.fn(() => {
|
||||
if (failSecondAttach && addons.length === 2) {
|
||||
throw new Error('retry unavailable')
|
||||
}
|
||||
})
|
||||
}
|
||||
function WebglAddon() {
|
||||
let contextLoss = () => {}
|
||||
const addon = {
|
||||
clearTextureAtlas: vi.fn(),
|
||||
dispose: vi.fn(),
|
||||
fireContextLoss: () => contextLoss()
|
||||
}
|
||||
addons.push(addon)
|
||||
return Object.assign(addon, {
|
||||
onContextLoss: (listener: () => void) => {
|
||||
contextLoss = listener
|
||||
}
|
||||
})
|
||||
}
|
||||
let visibilityChange = () => {}
|
||||
const document = {
|
||||
addEventListener: vi.fn((eventName: string, listener: () => void) => {
|
||||
if (eventName === 'visibilitychange') {
|
||||
visibilityChange = listener
|
||||
}
|
||||
}),
|
||||
visibilityState: 'hidden'
|
||||
}
|
||||
const applyTerminalTheme = vi.fn()
|
||||
const flog = vi.fn()
|
||||
const terminalThemeInput = { mode: 'dark' }
|
||||
const context = {
|
||||
applyTerminalTheme,
|
||||
clearTimeout: vi.fn(),
|
||||
document,
|
||||
flog,
|
||||
setTimeout: (callback: () => void) => {
|
||||
timers.push(callback)
|
||||
return timers.length
|
||||
},
|
||||
term,
|
||||
terminalGeneration: 1,
|
||||
terminalThemeInput,
|
||||
window: { WebglAddon: { WebglAddon } }
|
||||
}
|
||||
new Script(`${terminalHtmlSource.slice(variablesStart, variablesEnd)}
|
||||
${TERMINAL_WEBGL_RECOVERY_JS}
|
||||
attachWebglAddon(true);`).runInNewContext(context)
|
||||
return {
|
||||
addons,
|
||||
applyTerminalTheme,
|
||||
document,
|
||||
fireVisibilityChange: () => visibilityChange(),
|
||||
flog,
|
||||
term,
|
||||
terminalThemeInput,
|
||||
timers
|
||||
}
|
||||
}
|
||||
|
||||
describe('terminal WebView bundled engine', () => {
|
||||
it('keeps the assembled terminal HTML free of external engine URLs', () => {
|
||||
expect(XTERM_HTML).not.toMatch(/\bhttps?:\/\//)
|
||||
@@ -106,4 +186,57 @@ describe('terminal WebView bundled engine', () => {
|
||||
expect(capSites.length).toBe(2)
|
||||
expect(terminalHtmlSource).toContain('nonFatalErrorNotifies > 5')
|
||||
})
|
||||
|
||||
it('recreates WebGL once after context loss, then stays on the DOM renderer', () => {
|
||||
const { addons, flog, term, timers } = createWebglRecoveryHarness()
|
||||
|
||||
expect(addons).toHaveLength(1)
|
||||
addons[0]?.fireContextLoss()
|
||||
expect(flog).toHaveBeenCalledWith(
|
||||
'webgl-context-loss',
|
||||
expect.objectContaining({ retry: true })
|
||||
)
|
||||
expect(addons[0]?.dispose).toHaveBeenCalledTimes(1)
|
||||
expect(term.refresh).toHaveBeenCalledTimes(1)
|
||||
expect(timers).toHaveLength(1)
|
||||
|
||||
timers.shift()?.()
|
||||
expect(addons).toHaveLength(2)
|
||||
expect(addons[1]?.clearTextureAtlas).toHaveBeenCalledTimes(1)
|
||||
expect(term.refresh).toHaveBeenCalledTimes(2)
|
||||
addons[1]?.fireContextLoss()
|
||||
expect(addons[1]?.dispose).toHaveBeenCalledTimes(1)
|
||||
expect(term.refresh).toHaveBeenCalledTimes(3)
|
||||
expect(timers).toHaveLength(0)
|
||||
})
|
||||
|
||||
it('falls back to a refreshed DOM renderer when the delayed WebGL retry fails', () => {
|
||||
const { addons, term, timers } = createWebglRecoveryHarness(true)
|
||||
|
||||
addons[0]?.fireContextLoss()
|
||||
timers.shift()?.()
|
||||
|
||||
expect(addons).toHaveLength(2)
|
||||
expect(addons[1]?.dispose).toHaveBeenCalledTimes(1)
|
||||
expect(term.refresh).toHaveBeenCalledTimes(2)
|
||||
})
|
||||
|
||||
it('reapplies theme, clears the active atlas, and refreshes when visible', () => {
|
||||
const harness = createWebglRecoveryHarness()
|
||||
|
||||
harness.fireVisibilityChange()
|
||||
expect(harness.applyTerminalTheme).not.toHaveBeenCalled()
|
||||
|
||||
harness.document.visibilityState = 'visible'
|
||||
harness.fireVisibilityChange()
|
||||
|
||||
expect(harness.applyTerminalTheme).toHaveBeenCalledWith(harness.terminalThemeInput)
|
||||
expect(harness.addons[0]?.clearTextureAtlas).toHaveBeenCalledTimes(1)
|
||||
expect(harness.term.refresh).toHaveBeenCalledTimes(1)
|
||||
})
|
||||
|
||||
it('answers native readiness probes from the live document', () => {
|
||||
expect(terminalHtmlSource).toContain("if (msg.type === 'ping')")
|
||||
expect(terminalHtmlSource).toContain("notify({ type: 'pong', pingId: msg.id })")
|
||||
})
|
||||
})
|
||||
|
||||
@@ -7,7 +7,9 @@ import { TERMINAL_PATH_TAP_JS } from './terminal-path-tap-injected'
|
||||
import { XTERM_ENGINE_CSS, XTERM_ENGINE_JS } from './terminal-webview-engine.generated'
|
||||
import { TERMINAL_REFLOW_JS } from './terminal-webview-reflow-injected'
|
||||
import { TERMINAL_TAP_DISPATCH_JS } from './terminal-webview-tap-dispatch-injected'
|
||||
import { TERMINAL_WEBVIEW_THEME_JS } from './terminal-webview-theme-injected'
|
||||
import { URL_TAP_WEBVIEW_JS } from './terminal-webview-url-tap'
|
||||
import { TERMINAL_WEBGL_RECOVERY_JS } from './terminal-webview-webgl-recovery-injected'
|
||||
|
||||
const DEFAULT_TERMINAL_THEME: RuntimeMobileTerminalTheme['theme'] = {
|
||||
background: colors.terminalBg,
|
||||
@@ -292,7 +294,10 @@ window.onerror = function(msg) {
|
||||
var initRows = 24;
|
||||
var terminalGeneration = 0;
|
||||
var defaultTheme = ${JSON.stringify(DEFAULT_TERMINAL_THEME)};
|
||||
var terminalThemeInput = null;
|
||||
var terminalTheme = defaultTheme;
|
||||
var webglAddon = null;
|
||||
var webglRecoveryTimer = null;
|
||||
var activeAltScreenSnapshot = false;
|
||||
var trackedMouseTrackingMode = 'none';
|
||||
var sgrMouseMode = false;
|
||||
@@ -380,27 +385,7 @@ window.onerror = function(msg) {
|
||||
}, 550);
|
||||
}
|
||||
|
||||
function normalizeTerminalTheme(input) {
|
||||
var source = input && typeof input === 'object' && input.theme && typeof input.theme === 'object'
|
||||
? input.theme
|
||||
: null;
|
||||
if (!source) return defaultTheme;
|
||||
var next = {};
|
||||
var keys = Object.keys(defaultTheme);
|
||||
for (var i = 0; i < keys.length; i++) {
|
||||
var key = keys[i];
|
||||
if (typeof source[key] === 'string') next[key] = source[key];
|
||||
}
|
||||
return Object.assign({}, defaultTheme, next);
|
||||
}
|
||||
|
||||
function applyTerminalTheme(input) {
|
||||
terminalTheme = normalizeTerminalTheme(input);
|
||||
var background = terminalTheme.background || '${colors.terminalBg}';
|
||||
document.documentElement.style.background = background;
|
||||
document.body.style.background = background;
|
||||
if (term) term.options.theme = terminalTheme;
|
||||
}
|
||||
${TERMINAL_WEBVIEW_THEME_JS}
|
||||
|
||||
function getCellHeight() {
|
||||
if (!term || !term._core) return 15;
|
||||
@@ -679,6 +664,8 @@ window.onerror = function(msg) {
|
||||
pumpWrites(terminalGeneration);
|
||||
}
|
||||
|
||||
${TERMINAL_WEBGL_RECOVERY_JS}
|
||||
|
||||
function init(cols, rows, initialData, nextTheme, nextFontScale, preserveScroll, nextOscLinks) {
|
||||
if (typeof nextFontScale === 'number' && nextFontScale > 0) currentTextScale = nextFontScale;
|
||||
// Why: a width-reflow re-stream rewraps the same content at new cols.
|
||||
@@ -688,6 +675,8 @@ window.onerror = function(msg) {
|
||||
var scrollAnchorRows = prevB ? Math.max(0, (prevB.baseY || 0) - (prevB.viewportY || 0)) : -1;
|
||||
terminalGeneration++;
|
||||
var gen = terminalGeneration;
|
||||
cancelWebglContextRecovery();
|
||||
webglAddon = null;
|
||||
ready = false;
|
||||
resetWriteQueue();
|
||||
statusDotPendingSelector = false;
|
||||
@@ -750,9 +739,7 @@ window.onerror = function(msg) {
|
||||
allowProposedApi: true
|
||||
});
|
||||
term.open(surface);
|
||||
if (window.WebglAddon && window.WebglAddon.WebglAddon) {
|
||||
try { var webglAddon = new window.WebglAddon.WebglAddon(); term.loadAddon(webglAddon); if (webglAddon.onContextLoss) webglAddon.onContextLoss(function() { try { webglAddon && webglAddon.dispose && webglAddon.dispose(); } catch (e) {} }); } catch (e) {}
|
||||
}
|
||||
attachWebglAddon(true);
|
||||
if (window.Unicode11Addon && window.Unicode11Addon.Unicode11Addon) try { term.loadAddon(new window.Unicode11Addon.Unicode11Addon()); term.unicode.activeVersion = '11'; } catch (e) {}
|
||||
if (typeof replayData === 'string' && replayData.length > 0) {
|
||||
enqueueWrite(replayData);
|
||||
@@ -936,7 +923,9 @@ window.onerror = function(msg) {
|
||||
handledMessageIds.push(msg.id);
|
||||
if (handledMessageIds.length > 256) handledMessageIds.shift();
|
||||
}
|
||||
if (msg.type === 'init') {
|
||||
if (msg.type === 'ping') {
|
||||
notify({ type: 'pong', pingId: msg.id });
|
||||
} else if (msg.type === 'init') {
|
||||
init(msg.cols, msg.rows, msg.initialData, msg.terminalTheme, msg.fontScale, msg.preserveScroll, msg.oscLinks);
|
||||
} else if (msg.type === 'set-font-scale') {
|
||||
// Why: ignore RN echoing back the value a pinch just set (msg.fontScale ===
|
||||
|
||||
@@ -2,6 +2,7 @@ import type { RuntimeMobileTerminalTheme } from '../../../src/shared/runtime-typ
|
||||
import type { TerminalOscLinkRange } from './terminal-osc-link-ranges'
|
||||
|
||||
export type TerminalWebViewCommand =
|
||||
| { type: 'ping'; id?: number }
|
||||
| { type: 'write'; id?: number; data: string }
|
||||
| {
|
||||
type: 'init'
|
||||
|
||||
@@ -10,6 +10,10 @@ const terminalHtmlSource = readFileSync(
|
||||
new URL('./terminal-webview-html.ts', import.meta.url),
|
||||
'utf8'
|
||||
)
|
||||
const terminalWebglRecoverySource = readFileSync(
|
||||
new URL('./terminal-webview-webgl-recovery-injected.ts', import.meta.url),
|
||||
'utf8'
|
||||
)
|
||||
|
||||
function extractStatusDotNormalizer() {
|
||||
const declarationStart = terminalHtmlSource.indexOf(' var CLAUDE_STATUS_DOT =')
|
||||
@@ -148,12 +152,12 @@ describe('TerminalWebView text zoom', () => {
|
||||
|
||||
it('uses the bundled WebGL-capable xterm stack and platform-safe font fallbacks', () => {
|
||||
expect(terminalHtmlSource).not.toContain('cdn.jsdelivr.net')
|
||||
expect(terminalHtmlSource).toContain('window.WebglAddon.WebglAddon')
|
||||
expect(terminalWebglRecoverySource).toContain('window.WebglAddon.WebglAddon')
|
||||
expect(terminalHtmlSource).toContain('function isIOSWebView()')
|
||||
expect(terminalHtmlSource).toContain('fontFamily: terminalFontFamily')
|
||||
expect(terminalHtmlSource).toContain("fontWeight: '300'")
|
||||
expect(terminalHtmlSource).toContain("fontWeightBold: '500'")
|
||||
expect(terminalHtmlSource).toContain('new window.WebglAddon.WebglAddon()')
|
||||
expect(terminalWebglRecoverySource).toContain('new window.WebglAddon.WebglAddon()')
|
||||
})
|
||||
|
||||
const IOS_IPHONE_NAVIGATOR = {
|
||||
|
||||
@@ -0,0 +1,27 @@
|
||||
import { colors } from '../theme/mobile-theme'
|
||||
|
||||
// Theme normalization and page-surface painting injected into the WebView IIFE.
|
||||
export const TERMINAL_WEBVIEW_THEME_JS = `
|
||||
function normalizeTerminalTheme(input) {
|
||||
var source = input && typeof input === 'object' && input.theme && typeof input.theme === 'object'
|
||||
? input.theme
|
||||
: null;
|
||||
if (!source) return defaultTheme;
|
||||
var next = {};
|
||||
var keys = Object.keys(defaultTheme);
|
||||
for (var i = 0; i < keys.length; i++) {
|
||||
var key = keys[i];
|
||||
if (typeof source[key] === 'string') next[key] = source[key];
|
||||
}
|
||||
return Object.assign({}, defaultTheme, next);
|
||||
}
|
||||
|
||||
function applyTerminalTheme(input) {
|
||||
terminalThemeInput = input;
|
||||
terminalTheme = normalizeTerminalTheme(input);
|
||||
var background = terminalTheme.background || '${colors.terminalBg}';
|
||||
document.documentElement.style.background = background;
|
||||
document.body.style.background = background;
|
||||
if (term) term.options.theme = terminalTheme;
|
||||
}
|
||||
`
|
||||
@@ -0,0 +1,62 @@
|
||||
// WebGL loss and visibility recovery injected into the terminal WebView IIFE.
|
||||
// It closes over term, terminalGeneration, theme state, and xterm's addon global.
|
||||
export const TERMINAL_WEBGL_RECOVERY_JS = `
|
||||
function refreshTerminalSurface() {
|
||||
if (!term) return;
|
||||
try { term.refresh(0, Math.max(0, term.rows - 1)); } catch (e) {}
|
||||
}
|
||||
|
||||
function cancelWebglContextRecovery() {
|
||||
if (!webglRecoveryTimer) return;
|
||||
clearTimeout(webglRecoveryTimer);
|
||||
webglRecoveryTimer = null;
|
||||
}
|
||||
|
||||
function attachWebglAddon(allowRecovery) {
|
||||
if (!term || !window.WebglAddon || !window.WebglAddon.WebglAddon) return false;
|
||||
var addon = null;
|
||||
try {
|
||||
addon = new window.WebglAddon.WebglAddon();
|
||||
webglAddon = addon;
|
||||
if (addon.onContextLoss) addon.onContextLoss(function() {
|
||||
if (webglAddon !== addon) return;
|
||||
flog('webgl-context-loss', { retry: allowRecovery });
|
||||
webglAddon = null;
|
||||
try { addon.dispose(); } catch (e) {}
|
||||
refreshTerminalSurface();
|
||||
if (!allowRecovery) return;
|
||||
// Why: one delayed retry handles transient iOS context loss without
|
||||
// entering a GPU crash loop; a second loss stays on the DOM renderer.
|
||||
cancelWebglContextRecovery();
|
||||
var recoveryTerm = term;
|
||||
var recoveryGeneration = terminalGeneration;
|
||||
webglRecoveryTimer = setTimeout(function() {
|
||||
webglRecoveryTimer = null;
|
||||
if (term !== recoveryTerm || terminalGeneration !== recoveryGeneration) return;
|
||||
attachWebglAddon(false);
|
||||
}, 100);
|
||||
});
|
||||
term.loadAddon(addon);
|
||||
if (!allowRecovery) {
|
||||
try { if (addon.clearTextureAtlas) addon.clearTextureAtlas(); } catch (e) {}
|
||||
refreshTerminalSurface();
|
||||
}
|
||||
return true;
|
||||
} catch (e) {
|
||||
flog('webgl-attach-failed', { retry: !allowRecovery, message: String(e) });
|
||||
if (webglAddon === addon) webglAddon = null;
|
||||
try { if (addon) addon.dispose(); } catch (disposeError) {}
|
||||
refreshTerminalSurface();
|
||||
return false;
|
||||
}
|
||||
}
|
||||
|
||||
document.addEventListener('visibilitychange', function() {
|
||||
if (document.visibilityState !== 'visible') return;
|
||||
// Why: iOS may restore the xterm model while discarding GPU pixels/theme
|
||||
// paint state, so visibility must rebuild the atlas and repaint every row.
|
||||
applyTerminalTheme(terminalThemeInput);
|
||||
try { if (webglAddon && webglAddon.clearTextureAtlas) webglAddon.clearTextureAtlas(); } catch (e) {}
|
||||
refreshTerminalSurface();
|
||||
});
|
||||
`
|
||||
@@ -17,4 +17,23 @@ describe('isRpcResponse', () => {
|
||||
).toBe(true)
|
||||
expect(isRpcResponse({ id: 'rpc-1', ok: false, error: { code: 'failed' } })).toBe(false)
|
||||
})
|
||||
|
||||
it('accepts additive future fields without weakening known-field validation', () => {
|
||||
expect(
|
||||
isRpcResponse({
|
||||
id: 'rpc-1',
|
||||
ok: true,
|
||||
result: { value: 1 },
|
||||
futureEnvelopeField: true
|
||||
})
|
||||
).toBe(true)
|
||||
expect(
|
||||
isRpcResponse({
|
||||
id: 'rpc-1',
|
||||
ok: false,
|
||||
error: { code: 500, message: 'Nope', futureErrorField: true },
|
||||
futureEnvelopeField: true
|
||||
})
|
||||
).toBe(false)
|
||||
})
|
||||
})
|
||||
|
||||
@@ -1,8 +1,8 @@
|
||||
{
|
||||
"extends": "expo/tsconfig.base.json",
|
||||
"compilerOptions": {
|
||||
"baseUrl": ".",
|
||||
"strict": true,
|
||||
// TypeScript 7 resolves path targets relative to this config without baseUrl.
|
||||
"paths": {
|
||||
"@/*": ["./src/*"],
|
||||
"tweetnacl": ["./node_modules/tweetnacl"],
|
||||
|
||||
+12
-9
@@ -1,6 +1,6 @@
|
||||
{
|
||||
"name": "orca",
|
||||
"version": "1.4.135-rc.4",
|
||||
"version": "1.4.135-rc.5",
|
||||
"description": "Next-gen IDE for parallel agentic development",
|
||||
"homepage": "https://github.com/stablyai/orca",
|
||||
"author": "stablyai",
|
||||
@@ -27,10 +27,10 @@
|
||||
"tc:cli": "pnpm run typecheck:cli",
|
||||
"tc:web": "pnpm run typecheck:web",
|
||||
"tc": "pnpm run typecheck",
|
||||
"typecheck:node": "tsgo --noEmit -p config/tsconfig.node.json",
|
||||
"typecheck:cli": "tsgo --noEmit -p config/tsconfig.tc.cli.json",
|
||||
"typecheck:web": "tsgo --noEmit -p config/tsconfig.tc.web.json",
|
||||
"typecheck": "tsgo --noEmit -p config/tsconfig.node.json && tsgo --noEmit -p config/tsconfig.tc.cli.json && tsgo --noEmit -p config/tsconfig.tc.web.json",
|
||||
"typecheck:node": "tsc --noEmit -p config/tsconfig.node.json",
|
||||
"typecheck:cli": "tsc --noEmit -p config/tsconfig.tc.cli.json",
|
||||
"typecheck:web": "tsc --noEmit -p config/tsconfig.tc.web.json",
|
||||
"typecheck": "tsc --noEmit -p config/tsconfig.node.json && tsc --noEmit -p config/tsconfig.tc.cli.json && tsc --noEmit -p config/tsconfig.tc.web.json",
|
||||
"typecheck:tsc:node": "tsc --noEmit -p config/tsconfig.node.json --composite false",
|
||||
"typecheck:tsc:cli": "tsc --noEmit -p config/tsconfig.cli.json --composite false",
|
||||
"typecheck:tsc:web": "tsc --noEmit -p config/tsconfig.web.json --composite false",
|
||||
@@ -123,7 +123,7 @@
|
||||
"@dnd-kit/core": "^6.3.1",
|
||||
"@dnd-kit/sortable": "^10.0.0",
|
||||
"@electron-toolkit/tsconfig": "^2.0.0",
|
||||
"@electron/rebuild": "^4.0.4",
|
||||
"@electron/rebuild": "^4.2.0",
|
||||
"@monaco-editor/react": "^4.7.0",
|
||||
"@playwright/test": "^1.59.1",
|
||||
"@stablyai/playwright-test": "^2.1.14",
|
||||
@@ -154,7 +154,6 @@
|
||||
"@types/react-dom": "^19.2.3",
|
||||
"@types/ssh2": "^1.15.5",
|
||||
"@types/ws": "^8.18.1",
|
||||
"@typescript/native-preview": "7.0.0-dev.20260421.2",
|
||||
"@vitejs/plugin-react": "^5.2.0",
|
||||
"@xterm/addon-fit": "0.12.0-beta.287",
|
||||
"@xterm/addon-ligatures": "0.11.0-beta.287",
|
||||
@@ -167,7 +166,7 @@
|
||||
"clsx": "^2.1.1",
|
||||
"cmdk": "^1.1.1",
|
||||
"dompurify": "^3.4.11",
|
||||
"electron": "^42.3.3",
|
||||
"electron": "^43.1.0",
|
||||
"electron-builder": "^26.8.1",
|
||||
"electron-vite": "^5.0.0",
|
||||
"emoji-picker-react": "^4.19.1",
|
||||
@@ -206,7 +205,8 @@
|
||||
"tailwind-merge": "^3.5.0",
|
||||
"tailwindcss": "^4.2.4",
|
||||
"tw-animate-css": "^1.4.0",
|
||||
"typescript": "^5.9.3",
|
||||
"typescript": "^7.0.2",
|
||||
"typescript-api": "npm:typescript@6.0.3",
|
||||
"unified": "^11.0.5",
|
||||
"vite": "^7.3.6",
|
||||
"vitest": "^4.1.5",
|
||||
@@ -236,6 +236,9 @@
|
||||
},
|
||||
"packageManager": "pnpm@10.24.0+sha512.01ff8ae71b4419903b65c60fb2dc9d34cf8bb6e06d03bde112ef38f7a34d6904c424ba66bea5cdcf12890230bf39f9580473140ed9c946fef328b6e5238a345a",
|
||||
"pnpm": {
|
||||
"overrides": {
|
||||
"monaco-editor>dompurify": "3.4.11"
|
||||
},
|
||||
"supportedArchitectures": {
|
||||
"os": [
|
||||
"current",
|
||||
|
||||
Generated
+252
-154
@@ -4,6 +4,9 @@ settings:
|
||||
autoInstallPeers: true
|
||||
excludeLinksFromLockfile: false
|
||||
|
||||
overrides:
|
||||
monaco-editor>dompurify: 3.4.11
|
||||
|
||||
patchedDependencies:
|
||||
'@xterm/addon-ligatures@0.11.0-beta.287':
|
||||
hash: 47405b9994b5acf1b4e90b49250358c1ca03649854d59560e7732b72fe336920
|
||||
@@ -24,10 +27,10 @@ importers:
|
||||
dependencies:
|
||||
'@electron-toolkit/preload':
|
||||
specifier: ^3.0.2
|
||||
version: 3.0.2(electron@42.3.3)
|
||||
version: 3.0.2(electron@43.1.0)
|
||||
'@electron-toolkit/utils':
|
||||
specifier: ^4.0.0
|
||||
version: 4.0.0(electron@42.3.3)
|
||||
version: 4.0.0(electron@43.1.0)
|
||||
'@floating-ui/dom':
|
||||
specifier: 1.7.6
|
||||
version: 1.7.6
|
||||
@@ -51,7 +54,7 @@ importers:
|
||||
version: 6.8.3
|
||||
i18next:
|
||||
specifier: ^26.3.1
|
||||
version: 26.3.1(typescript@5.9.3)
|
||||
version: 26.3.1(typescript@7.0.2)
|
||||
jsonc-parser:
|
||||
specifier: ^3.3.1
|
||||
version: 3.3.1
|
||||
@@ -66,10 +69,10 @@ importers:
|
||||
version: 1.5.4
|
||||
react-i18next:
|
||||
specifier: ^17.0.8
|
||||
version: 17.0.8(i18next@26.3.1(typescript@5.9.3))(react-dom@19.2.5(react@19.2.5))(react@19.2.5)(typescript@5.9.3)
|
||||
version: 17.0.8(i18next@26.3.1(typescript@7.0.2))(react-dom@19.2.5(react@19.2.5))(react@19.2.5)(typescript@7.0.2)
|
||||
serve-sim:
|
||||
specifier: ^0.1.40
|
||||
version: 0.1.40(typescript@5.9.3)
|
||||
version: 0.1.40(typescript@7.0.2)
|
||||
sherpa-onnx:
|
||||
specifier: 1.12.37
|
||||
version: 1.12.37
|
||||
@@ -99,8 +102,8 @@ importers:
|
||||
specifier: ^2.0.0
|
||||
version: 2.0.0(@types/node@25.6.0)
|
||||
'@electron/rebuild':
|
||||
specifier: ^4.0.4
|
||||
version: 4.0.4
|
||||
specifier: ^4.2.0
|
||||
version: 4.2.0
|
||||
'@monaco-editor/react':
|
||||
specifier: ^4.7.0
|
||||
version: 4.7.0(monaco-editor@0.55.1)(react-dom@19.2.5(react@19.2.5))(react@19.2.5)
|
||||
@@ -191,9 +194,6 @@ importers:
|
||||
'@types/ws':
|
||||
specifier: ^8.18.1
|
||||
version: 8.18.1
|
||||
'@typescript/native-preview':
|
||||
specifier: 7.0.0-dev.20260421.2
|
||||
version: 7.0.0-dev.20260421.2
|
||||
'@vitejs/plugin-react':
|
||||
specifier: ^5.2.0
|
||||
version: 5.2.0(vite@7.3.6(@types/node@25.6.0)(jiti@2.7.0)(lightningcss@1.32.0)(yaml@2.8.4))
|
||||
@@ -231,8 +231,8 @@ importers:
|
||||
specifier: ^3.4.11
|
||||
version: 3.4.11
|
||||
electron:
|
||||
specifier: ^42.3.3
|
||||
version: 42.3.3
|
||||
specifier: ^43.1.0
|
||||
version: 43.1.0
|
||||
electron-builder:
|
||||
specifier: ^26.8.1
|
||||
version: 26.8.1(electron-builder-squirrel-windows@26.8.1)
|
||||
@@ -334,7 +334,7 @@ importers:
|
||||
version: 11.0.0
|
||||
shadcn:
|
||||
specifier: ^4.7.0
|
||||
version: 4.7.0(@types/node@25.6.0)(typescript@5.9.3)
|
||||
version: 4.7.0(@types/node@25.6.0)(typescript@7.0.2)
|
||||
sonner:
|
||||
specifier: ^2.0.7
|
||||
version: 2.0.7(react-dom@19.2.5(react@19.2.5))(react@19.2.5)
|
||||
@@ -348,8 +348,11 @@ importers:
|
||||
specifier: ^1.4.0
|
||||
version: 1.4.0
|
||||
typescript:
|
||||
specifier: ^5.9.3
|
||||
version: 5.9.3
|
||||
specifier: ^7.0.2
|
||||
version: 7.0.2
|
||||
typescript-api:
|
||||
specifier: npm:typescript@6.0.3
|
||||
version: typescript@6.0.3
|
||||
unified:
|
||||
specifier: ^11.0.5
|
||||
version: 11.0.5
|
||||
@@ -358,7 +361,7 @@ importers:
|
||||
version: 7.3.6(@types/node@25.6.0)(jiti@2.7.0)(lightningcss@1.32.0)(yaml@2.8.4)
|
||||
vitest:
|
||||
specifier: ^4.1.5
|
||||
version: 4.1.5(@types/node@25.6.0)(happy-dom@20.9.0)(msw@2.14.3(@types/node@25.6.0)(typescript@5.9.3))(vite@7.3.6(@types/node@25.6.0)(jiti@2.7.0)(lightningcss@1.32.0)(yaml@2.8.4))
|
||||
version: 4.1.5(@types/node@25.6.0)(happy-dom@20.9.0)(msw@2.14.3(@types/node@25.6.0)(typescript@7.0.2))(vite@7.3.6(@types/node@25.6.0)(jiti@2.7.0)(lightningcss@1.32.0)(yaml@2.8.4))
|
||||
vscode-oniguruma:
|
||||
specifier: ^2.0.1
|
||||
version: 2.0.1
|
||||
@@ -640,6 +643,10 @@ packages:
|
||||
peerDependencies:
|
||||
'@noble/ciphers': ^1.0.0
|
||||
|
||||
'@electron-internal/extract-zip@1.0.4':
|
||||
resolution: {integrity: sha512-Zr1Vs7E9tpCNhZHDAbFVXc2gEVCG9RqPDjrno5+bdgB6LRAuvgyMHJut4NCVyYwtAieapMzc3fiQ3CSTi75ARg==}
|
||||
engines: {node: '>=22.12.0'}
|
||||
|
||||
'@electron-toolkit/preload@3.0.2':
|
||||
resolution: {integrity: sha512-TWWPToXd8qPRfSXwzf5KVhpXMfONaUuRAZJHsKthKgZR/+LqX1dZVSSClQ8OTAEduvLGdecljCsoT2jSshfoUg==}
|
||||
peerDependencies:
|
||||
@@ -681,8 +688,8 @@ packages:
|
||||
engines: {node: '>=12.0.0'}
|
||||
hasBin: true
|
||||
|
||||
'@electron/rebuild@4.0.4':
|
||||
resolution: {integrity: sha512-Rzc39XPdk/+/wBG8MfwAHohXflep0ITUfulb6Rgz3R0NeSB1noE+E9/M/cb8ftCAiyDD9PPhLuuWgE1GaInbKg==}
|
||||
'@electron/rebuild@4.2.0':
|
||||
resolution: {integrity: sha512-RKL/O+jGoXJMxrx/5771y1n0xTKmFuOYGO3gMmwypBM6rsH0kou0mswwdXA2JrhIkE4xyC7v9vGk0n6NPzgOxQ==}
|
||||
engines: {node: '>=22.12.0'}
|
||||
hasBin: true
|
||||
|
||||
@@ -3086,52 +3093,130 @@ packages:
|
||||
'@types/ws@8.18.1':
|
||||
resolution: {integrity: sha512-ThVF6DCVhA8kUGy+aazFQ4kXQ7E1Ty7A3ypFOe0IcJV8O/M511G99AW24irKrW56Wt44yG9+ij8FaqoBGkuBXg==}
|
||||
|
||||
'@types/yauzl@2.10.3':
|
||||
resolution: {integrity: sha512-oJoftv0LSuaDZE3Le4DbKX+KS9G36NzOeSap90UIK0yMA/NhKJhqlSGtNDORNRaIbQfzjXDrQa0ytJ6mNRGz/Q==}
|
||||
|
||||
'@typescript-eslint/types@8.60.0':
|
||||
resolution: {integrity: sha512-AsE7x2XaAK+CVbeih0Fvbn+r1qHxtpLDJ3XUuFcIinT318T90yHMJC+Zgv+jUuDjQQd06HKwxnDu6sz1IcTilA==}
|
||||
engines: {node: ^18.18.0 || ^20.9.0 || >=21.1.0}
|
||||
|
||||
'@typescript/native-preview-darwin-arm64@7.0.0-dev.20260421.2':
|
||||
resolution: {integrity: sha512-fHv1r3ZmVo6zxuAIFmuX3w9QxbcauoG0SsWhmDwm6VmRubLlOJIcmTtlmV3JAb9oOnq8LuzZljzT7Q39fSMQDw==}
|
||||
'@typescript/typescript-aix-ppc64@7.0.2':
|
||||
resolution: {integrity: sha512-MTKKkWB7p/0E9xi1d1tHtZ5PiLkGEMIq88pK2CubZjOsLtYTLqhgIgi6zepFa+9GHZ6h05NMCkQxGKiPXMxXtQ==}
|
||||
engines: {node: '>=16.20.0'}
|
||||
cpu: [ppc64]
|
||||
os: [aix]
|
||||
|
||||
'@typescript/typescript-darwin-arm64@7.0.2':
|
||||
resolution: {integrity: sha512-gowzar9MwS/aRWp6f3a4KUqzRjAZjOsmGNCM6LcTgXum+dBfgsBVMN+AgvOCCbguXyick6LJhpBszxMebJ8syA==}
|
||||
engines: {node: '>=16.20.0'}
|
||||
cpu: [arm64]
|
||||
os: [darwin]
|
||||
|
||||
'@typescript/native-preview-darwin-x64@7.0.0-dev.20260421.2':
|
||||
resolution: {integrity: sha512-KWTR6xbW9t+JS7D5DQIzo75pqVXVWUxF9PMv/+S6xsnOjCVd6g0ixHcFpFMJMKSUQpGPr8Z5f7b8ks6LHW01jg==}
|
||||
'@typescript/typescript-darwin-x64@7.0.2':
|
||||
resolution: {integrity: sha512-SZ9xZInqApNlNGc9s0W1VSsktYSOe9cFqNOIqmN1Gs8SmkjKZYFt017G4VwPxASInODuAdbTW7sXiFUf893RgA==}
|
||||
engines: {node: '>=16.20.0'}
|
||||
cpu: [x64]
|
||||
os: [darwin]
|
||||
|
||||
'@typescript/native-preview-linux-arm64@7.0.0-dev.20260421.2':
|
||||
resolution: {integrity: sha512-VLMEuml3BhUb+jaL0TXQ4xvVODxJF+RhkI+tBWvlynsJI4khTXEiwWh+wPOJrsfBRYFRMXEu28Odl/HXkYze8w==}
|
||||
'@typescript/typescript-freebsd-arm64@7.0.2':
|
||||
resolution: {integrity: sha512-W5NH4y/J0plIIS5b2xvTEkU7JFxyqdMAOgf+Ilhl0vHQXKO5dZoxd+C/jEtq56c4F3wk71RB4BMRQ2XdI+bwYQ==}
|
||||
engines: {node: '>=16.20.0'}
|
||||
cpu: [arm64]
|
||||
os: [freebsd]
|
||||
|
||||
'@typescript/typescript-freebsd-x64@7.0.2':
|
||||
resolution: {integrity: sha512-UMGDx5sTpzNw3WiPebH7l90IWfJggEd+egHt/q6p7/Cm3zqoV7VxkGXt+3DxPIw8CcmvAB0j3sVVfbhX+M4Tpw==}
|
||||
engines: {node: '>=16.20.0'}
|
||||
cpu: [x64]
|
||||
os: [freebsd]
|
||||
|
||||
'@typescript/typescript-linux-arm64@7.0.2':
|
||||
resolution: {integrity: sha512-Qh4eU4/y3yDjnfjjyPYihMj5/ODIlmt+Bzu17OI+fiSRDW57QmU5SiN63exPRNJPKUzcc1INa1NXdrJ+MqHjUQ==}
|
||||
engines: {node: '>=16.20.0'}
|
||||
cpu: [arm64]
|
||||
os: [linux]
|
||||
|
||||
'@typescript/native-preview-linux-arm@7.0.0-dev.20260421.2':
|
||||
resolution: {integrity: sha512-BWLQO3nemLDSV5PoE5GPHe1dU9Dth77Kv8/cle9Ujcp4LhPo0KincdPqFH/qKeU/xvW25mgFueflZ1nc4rKuww==}
|
||||
'@typescript/typescript-linux-arm@7.0.2':
|
||||
resolution: {integrity: sha512-gffT3xPz9sR7j/YJExkyPntrI0P2EP9XbOyWzth2/Gs0RstK+90RBcO0ncXoXy/beYll1SXw846Nf2zdnEz0QQ==}
|
||||
engines: {node: '>=16.20.0'}
|
||||
cpu: [arm]
|
||||
os: [linux]
|
||||
|
||||
'@typescript/native-preview-linux-x64@7.0.0-dev.20260421.2':
|
||||
resolution: {integrity: sha512-qUrJWTB5/wv4wnRG0TRXElAxc2kykNiRNyEIEqBbLmzDlrcvAW7RRy8MXoY1ZyTiKGMu14itZ3x9oW6+blFpRw==}
|
||||
'@typescript/typescript-linux-loong64@7.0.2':
|
||||
resolution: {integrity: sha512-uEHck9i8hoAzXPiYRib1O7miOnz23SxIeVl6F4LXox+qov1K35jHcEW6VHKvZI+pyvl7fZEP4MCU5LYvIq1GuQ==}
|
||||
engines: {node: '>=16.20.0'}
|
||||
cpu: [loong64]
|
||||
os: [linux]
|
||||
|
||||
'@typescript/typescript-linux-mips64el@7.0.2':
|
||||
resolution: {integrity: sha512-R4KvAMnE43W5Qeqb0Ly56O3mWMWIAgsMyz36DCaycd5nbg/9kzm0liw3JocfRqyJY0KPmzFjbswozXyW0DnIYA==}
|
||||
engines: {node: '>=16.20.0'}
|
||||
cpu: [mips64el]
|
||||
os: [linux]
|
||||
|
||||
'@typescript/typescript-linux-ppc64@7.0.2':
|
||||
resolution: {integrity: sha512-DORx5b3sd/4S7eayxm4FQv+A7CrkUIGRaHiwI8oiHTAI1fAPWhF4J0vAlkC8biAlHSVVwxMQ3tjZ2/DVbnQiiA==}
|
||||
engines: {node: '>=16.20.0'}
|
||||
cpu: [ppc64]
|
||||
os: [linux]
|
||||
|
||||
'@typescript/typescript-linux-riscv64@7.0.2':
|
||||
resolution: {integrity: sha512-wf0jqEDOjrPRnKwYRyyJDRo11KMbvMFrU+q4zqKyChODBzvlkbhNQfKvLxQCcwTpdDaXSHZTVuh0JoCrKCUMHQ==}
|
||||
engines: {node: '>=16.20.0'}
|
||||
cpu: [riscv64]
|
||||
os: [linux]
|
||||
|
||||
'@typescript/typescript-linux-s390x@7.0.2':
|
||||
resolution: {integrity: sha512-IkwJc3L7yhytWd/ewjyxNDfOmswCm9GWMJT/ue/dU4aZNbwZeYAetq42VyLmsmSjvoX7z74X6ZaYCtzAr0EuGw==}
|
||||
engines: {node: '>=16.20.0'}
|
||||
cpu: [s390x]
|
||||
os: [linux]
|
||||
|
||||
'@typescript/typescript-linux-x64@7.0.2':
|
||||
resolution: {integrity: sha512-EYdf2cNg7rgCWJnxCdJ+F3V39O8ihb37eHAu1LK8oAFizgTQbPOK7zHHXbPt8rX24COqODXeI3sIf0fCXG7H/A==}
|
||||
engines: {node: '>=16.20.0'}
|
||||
cpu: [x64]
|
||||
os: [linux]
|
||||
|
||||
'@typescript/native-preview-win32-arm64@7.0.0-dev.20260421.2':
|
||||
resolution: {integrity: sha512-Rc6NsWlZmCs5YUKVzKgwoBOoRUGsPzct4BDMRX0csD1devLBBc4AbUXWKsJRbpwIAnqMO1ld4sNHEb+wXgfNHQ==}
|
||||
'@typescript/typescript-netbsd-arm64@7.0.2':
|
||||
resolution: {integrity: sha512-+polYF4MF04aPpO5FTkHran9yUQDSXqy5GiSDKpsll5jy3l3+g9QLhpf39T+ePtefhXLOGrLl0QIjkQP6VnelA==}
|
||||
engines: {node: '>=16.20.0'}
|
||||
cpu: [arm64]
|
||||
os: [netbsd]
|
||||
|
||||
'@typescript/typescript-netbsd-x64@7.0.2':
|
||||
resolution: {integrity: sha512-8YIT0EHM/3dq10ZOVF/A7pc/YSMtbcecct4rWtexrnSCHOPcpC2KTLXfTCR6vDpnSiY12heNb1GiN/wu+T/FyA==}
|
||||
engines: {node: '>=16.20.0'}
|
||||
cpu: [x64]
|
||||
os: [netbsd]
|
||||
|
||||
'@typescript/typescript-openbsd-arm64@7.0.2':
|
||||
resolution: {integrity: sha512-APT8+ClYnuYm1u9+kgGXoMj2VzWzcymwh2gNSQVySHfkRDGOTVkoWLjCmOQSaO+PoqQ57B0flRp9SA+7GnnkzQ==}
|
||||
engines: {node: '>=16.20.0'}
|
||||
cpu: [arm64]
|
||||
os: [openbsd]
|
||||
|
||||
'@typescript/typescript-openbsd-x64@7.0.2':
|
||||
resolution: {integrity: sha512-yX7s+Q0Dln0Dt9tEzZsAjXXR/+ytBM7AlglaqyeMPxQszJ1JhlJdZ6jLA+IzldHtflX81em7lDao1xXu+aRRkg==}
|
||||
engines: {node: '>=16.20.0'}
|
||||
cpu: [x64]
|
||||
os: [openbsd]
|
||||
|
||||
'@typescript/typescript-sunos-x64@7.0.2':
|
||||
resolution: {integrity: sha512-dLJDGaLZ1D4HPQn62u1n8mBDkJREwMsAkCdkwd4Ieqw+x3TUyTsqY0YiBCtE6H6OzzgGk3iuZ3vFWRS+E8/d1g==}
|
||||
engines: {node: '>=16.20.0'}
|
||||
cpu: [x64]
|
||||
os: [sunos]
|
||||
|
||||
'@typescript/typescript-win32-arm64@7.0.2':
|
||||
resolution: {integrity: sha512-Gyl1Vy6OsWesLzmq+EP0Fb7b4Nid5232AvcA2SFcdYreldpNtYFFofPjnt62y9hQy7VTaZp65ICJjuAQRaVcIQ==}
|
||||
engines: {node: '>=16.20.0'}
|
||||
cpu: [arm64]
|
||||
os: [win32]
|
||||
|
||||
'@typescript/native-preview-win32-x64@7.0.0-dev.20260421.2':
|
||||
resolution: {integrity: sha512-GQv1+dya1t6EqF2Cpsb+xoozovdX10JUSf6Kl/8xNkTapzmlHd+uMr+8ku3jIASTxoRGn0Mklgjj3MDKrOTuLg==}
|
||||
'@typescript/typescript-win32-x64@7.0.2':
|
||||
resolution: {integrity: sha512-0BQ3HkAHHlKLSp1qRvf3SUhGpGsDuhB/jgFw75guyqbxJqEaS0Cw/VFO8i2nHglJUzQCRtMMR/IBAKE3ETMC4g==}
|
||||
engines: {node: '>=16.20.0'}
|
||||
cpu: [x64]
|
||||
os: [win32]
|
||||
|
||||
'@typescript/native-preview@7.0.0-dev.20260421.2':
|
||||
resolution: {integrity: sha512-CmajHI25HpVWE9R1XFoxr+cphJPxoYD3eFioQtAvXYkMFKnLdICMS9pXre9Pybizb75ejRxjKD5/CVG055rEIg==}
|
||||
hasBin: true
|
||||
|
||||
'@ungap/structured-clone@1.3.1':
|
||||
resolution: {integrity: sha512-mUFwbeTqrVgDQxFveS+df2yfap6iuP20NAKAsBt5jDEoOTDew+zwLAOilHCeQJOVSvmgCX4ogqIrA0mnyr08yQ==}
|
||||
|
||||
@@ -3372,7 +3457,6 @@ packages:
|
||||
|
||||
boolean@3.2.0:
|
||||
resolution: {integrity: sha512-d0II/GO9uf9lfUHH2BQsjxzRJZBdsjgsBiW4BvhWk/3qoKwQFjIDVN19PfX8F2D/r9PCMTtLWjYVCFrpeYUzsw==}
|
||||
deprecated: Package no longer supported. Contact Support at https://www.npmjs.com/support for more info.
|
||||
|
||||
brace-expansion@1.1.14:
|
||||
resolution: {integrity: sha512-MWPGfDxnyzKU7rNOW9SP/c50vi3xrmrua/+6hfPbCS2ABNWfx24vPidzvC7krjU/RTo235sV776ymlsMtGKj8g==}
|
||||
@@ -3393,9 +3477,6 @@ packages:
|
||||
engines: {node: ^6 || ^7 || ^8 || ^9 || ^10 || ^11 || ^12 || >=13.7}
|
||||
hasBin: true
|
||||
|
||||
buffer-crc32@0.2.13:
|
||||
resolution: {integrity: sha512-VO9Ht/+p3SN7SKWqcrgEzjGbRSJYTx+Q1pTQC0wrWqHx0vpJraQ6GtHx8tvcg1rlK1byhU5gccxgOgj7B0TDkQ==}
|
||||
|
||||
buffer-from@1.1.2:
|
||||
resolution: {integrity: sha512-E+XQCRwSbaaiChtv6k6Dwgc+bx+Bs6vuKJHHl5kox/BaKbhiXzqQOwK4cO22yElGp2OCmjwVhT3HmxgyPGnJfQ==}
|
||||
|
||||
@@ -3941,9 +4022,6 @@ packages:
|
||||
dom-accessibility-api@0.6.3:
|
||||
resolution: {integrity: sha512-7ZgogeTnjuHbo+ct10G9Ffp0mif17idi0IyWNVA/wcwcm7NPOD/WEHVP3n7n3MhXqxoIYm8d6MuZohYWIZ4T3w==}
|
||||
|
||||
dompurify@3.2.7:
|
||||
resolution: {integrity: sha512-WhL/YuveyGXJaerVlMYGWhvQswa7myDG17P7Vu65EWC05o8vfeNbvNf4d/BOvH99+ZW+LlQsc1GDKMa1vNK6dw==}
|
||||
|
||||
dompurify@3.4.11:
|
||||
resolution: {integrity: sha512-zhlUV12GsaRzMsf9q5M254YhA4+VuF0fG+QFqu6aYpoGlKtz+w8//jBcGVYBgQkR5GHjUomejY84AV+/uPbWdw==}
|
||||
|
||||
@@ -4007,8 +4085,8 @@ packages:
|
||||
resolution: {integrity: sha512-bO3y10YikuUwUuDUQRM4KfwNkKhnpVO7IPdbsrejwN9/AABJzzTQ4GeHwyzNSrVO+tEH3/Np255a3sVZpZDjvg==}
|
||||
engines: {node: '>=8.0.0'}
|
||||
|
||||
electron@42.3.3:
|
||||
resolution: {integrity: sha512-0MwYp9wTb7TrtTalOYqeW+suqd9T/Znstr/nDLKqFGIjHdBZX339guo3mQqTPURRZ/UQmYM4uMpzKpI5wLptfQ==}
|
||||
electron@43.1.0:
|
||||
resolution: {integrity: sha512-DPfxpQLd4NL3BJ8DBxYAfmLUKKesF5Rx9dQx5FyczAP8bhOPScjHE48GArVeXu68LlAainuwkmQTQvdZwpIIAQ==}
|
||||
engines: {node: '>= 22.12.0'}
|
||||
hasBin: true
|
||||
|
||||
@@ -4181,11 +4259,6 @@ packages:
|
||||
extend@3.0.2:
|
||||
resolution: {integrity: sha512-fjquC59cD7CyW6urNXK0FBufkZcoiGG80wTuPujX590cB5Ttln20E2UB4S/WARVqhXffZl2LNgS+gQdPIIim/g==}
|
||||
|
||||
extract-zip@2.0.1:
|
||||
resolution: {integrity: sha512-GDhU9ntwuKyGXdZBUgTIe+vXnWj0fppUEtMDL0+idd5Sta8TGpHssn/eusA9mrPr9qNDym6SxAYZjNvCn/9RBg==}
|
||||
engines: {node: '>= 10.17.0'}
|
||||
hasBin: true
|
||||
|
||||
extsprintf@1.4.1:
|
||||
resolution: {integrity: sha512-Wrk35e8ydCKDj/ArClo1VrPVmN8zph5V4AtHwIuHhvMXsKf73UT3BOD+azBIW+3wOJ4FhEH7zyaJCFvChjYvMA==}
|
||||
engines: {'0': node >=0.6.0}
|
||||
@@ -4222,9 +4295,6 @@ packages:
|
||||
fault@2.0.1:
|
||||
resolution: {integrity: sha512-WtySTkS4OKev5JtpHXnib4Gxiurzh5NCGvWrFaZ34m6JehfTUhKZvn9njTfw48t6JumVQOmrKqpmGcdwxnhqBQ==}
|
||||
|
||||
fd-slicer@1.1.0:
|
||||
resolution: {integrity: sha512-cE1qsB/VwyQozZ+q1dGxR8LBYNZeofhEdUNGSMbQD3Gw2lAzX9Zb3uIU6Ebc/Fmyjo9AWWfnn0AUCHqtevs/8g==}
|
||||
|
||||
fdir@6.5.0:
|
||||
resolution: {integrity: sha512-tIbYtZbucOs0BRGqPJkshJUYdL+SDH7dVM8gjy+ERp3WAUjLEFJE+02kanyHtwjWOnwrKYBiwAmM0p4kLJAnXg==}
|
||||
engines: {node: '>=12.0.0'}
|
||||
@@ -4292,6 +4362,10 @@ packages:
|
||||
resolution: {integrity: sha512-eKpRKAovdpZtR1WopLHxlBWvAgPny3c4gX1G5Jhwmmw4XJj0ifSD5qB5TOo8hmA0wlRKDAOAhEE1yVPgs6Fgcg==}
|
||||
engines: {node: '>=14.14'}
|
||||
|
||||
fs-extra@11.3.6:
|
||||
resolution: {integrity: sha512-w8ZNZr2mKIc7qeNaQ9AVPT1+iFaI+Avd4xudVOvdDJ8VytREi1Ft5Ih7hd9jjehod8vAM5GMsfQ/TpPf4EyoEA==}
|
||||
engines: {node: '>=14.14'}
|
||||
|
||||
fs-extra@7.0.1:
|
||||
resolution: {integrity: sha512-YJDaCJZEnBmcbw13fvdAM9AwNOJwOzrE4pqMqBq5nFiEqXUqHwlK4B+3pUw6JNvfSPtX05xFHtYy/1ni01eGCw==}
|
||||
engines: {node: '>=6 <7 || >=8'}
|
||||
@@ -4372,7 +4446,6 @@ packages:
|
||||
|
||||
glob@7.2.3:
|
||||
resolution: {integrity: sha512-nFR0zLpU2YCaRxwoCJvL6UvCH2JFyFVIvwTLsIf21AuHlMskA1hhTdk+LlYJtOlYt9v6dvszD2BGRqBL+iQK9Q==}
|
||||
deprecated: Old versions of glob are not supported, and contain widely publicized security vulnerabilities, which have been fixed in the current version. Please update. Support for old versions may be purchased (at exorbitant rates) by contacting i@izs.me
|
||||
|
||||
global-agent@3.0.0:
|
||||
resolution: {integrity: sha512-PT6XReJ+D07JvGoxQMkT6qji/jVNfX/h364XHZOWeRzy64sSFr+xJ5OX7LI3b4MPQzdL4H8Y8M0xzPpsVMwA8Q==}
|
||||
@@ -4569,7 +4642,6 @@ packages:
|
||||
|
||||
inflight@1.0.6:
|
||||
resolution: {integrity: sha512-k92I/b08q4wvFscXCLvqfsHCrjrF7yiXsQuIVvVE7N82W3+aqpzuUdBbfhWcy/FZR3/4IgflMgKLOsvPDrGCJA==}
|
||||
deprecated: This module is not supported, and leaks memory. Do not use it. Check out lru-cache if you want a good and tested way to coalesce async requests by a key value, which is much more comprehensive and powerful.
|
||||
|
||||
inherits@2.0.4:
|
||||
resolution: {integrity: sha512-k/vGaX4/Yla3WzyMCvTQOXYeIHvqOKtnqBduzTHpzpQZzAskKMhZ2K+EnBiSM9zGSoIFeMpXKxa4dYeZIQqewQ==}
|
||||
@@ -4895,7 +4967,6 @@ packages:
|
||||
|
||||
lodash.isequal@4.5.0:
|
||||
resolution: {integrity: sha512-pDo3lu8Jhfjqls6GkMgpahsF9kCyayhgykjyLMNFTKWrpVdAQtYyB4muAMWozBB4ig/dtWAmsMxLEI8wuz+DYQ==}
|
||||
deprecated: This package is deprecated. Use require('node:util').isDeepStrictEqual instead.
|
||||
|
||||
lodash@4.18.1:
|
||||
resolution: {integrity: sha512-dMInicTPVE8d1e5otfwmmjlxkZoUpiVLwyeTdUsi/Caj/gfzzblBcCE5sRHV/AsjuCmxWrte2TNGSYuCeCq+0Q==}
|
||||
@@ -5243,8 +5314,8 @@ packages:
|
||||
resolution: {integrity: sha512-8Ofs/AUQh8MaEcrlq5xOX0CQ9ypTF5dl78mjlMNfOK08fzpgTHQRQPBxcPlEtIw0yRpws+Zo/3r+5WRby7u3Gg==}
|
||||
engines: {node: '>= 0.6'}
|
||||
|
||||
node-abi@4.30.0:
|
||||
resolution: {integrity: sha512-D7v3slBz2jgsBLCY9FnFsD0nlXvuKd49Pl9L+Vj+7ZlivHsLK2mkekQo7vZwYco+kyb4OJlaWfYdr3+27vfYtA==}
|
||||
node-abi@4.33.0:
|
||||
resolution: {integrity: sha512-vLBWCKb+7LWsX+TbfzWOkw0W81m377tyx3hOweBTjO43CXZnRGS1/JPWs20fr0PgZyDXk6ROYrylsEycK8raDA==}
|
||||
engines: {node: '>=22.12.0'}
|
||||
|
||||
node-addon-api@1.7.2:
|
||||
@@ -5259,7 +5330,6 @@ packages:
|
||||
node-domexception@1.0.0:
|
||||
resolution: {integrity: sha512-/jKZoMpw0F8GRwl4/eLROPA3cfcXtLApP0QzLmUT/HuPCZWyB7IY9ZrMeKw2O/nFIqPQB3PVM9aYm0F312AXDQ==}
|
||||
engines: {node: '>=10.5.0'}
|
||||
deprecated: Use your platform's native DOMException instead
|
||||
|
||||
node-fetch@3.3.2:
|
||||
resolution: {integrity: sha512-dRB78srN/l6gqWulah9SrxeYnxeddIG30+GOqK/9OlLVyLg3HPnr6SqOWTWOXKRwC2eGYCkZ59NNuSgvSrpgOA==}
|
||||
@@ -5471,9 +5541,6 @@ packages:
|
||||
resolution: {integrity: sha512-eRWB5LBz7PpDu4PUlwT0PhnQfTQJlDDdPa35urV4Osrm0t0AqQFGn+UIkU3klZvwJ8KPO3VbBFsXquA6p6kqZw==}
|
||||
engines: {node: '>=12', npm: '>=6'}
|
||||
|
||||
pend@1.2.0:
|
||||
resolution: {integrity: sha512-F3asv42UuXchdzt+xXqfW1OGlVBe+mxa2mqI0pg5yAHZPvFmY3Y6drSf/GQ1A86WgWEN9Kzh/WrgKa6iGcHXLg==}
|
||||
|
||||
picocolors@1.1.1:
|
||||
resolution: {integrity: sha512-xceH2snhtb5M9liqDsmEw56le376mTZkEX/jEb/RxNFyegNul7eNslCXP9FDj/Lcu0X8KEyMceP2ntpaHrDEVA==}
|
||||
|
||||
@@ -6274,11 +6341,16 @@ packages:
|
||||
resolution: {integrity: sha512-faYHw0anBbc/kWF3zFTEnxSFOAGUX9GFbOBthvDdLsIlEoWOFOtS0zgCiQYwIskL9iGXZL3kAXD8OoZ4GmMATA==}
|
||||
engines: {node: '>= 18'}
|
||||
|
||||
typescript@5.9.3:
|
||||
resolution: {integrity: sha512-jl1vZzPDinLr9eUt3J/t7V6FgNEw9QjvBPdysz9KfQDD41fQrC2Y4vKQdiaUpFT4bXlb1RHhLpp8wtm6M5TgSw==}
|
||||
typescript@6.0.3:
|
||||
resolution: {integrity: sha512-y2TvuxSZPDyQakkFRPZHKFm+KKVqIisdg9/CZwm9ftvKXLP8NRWj38/ODjNbr43SsoXqNuAisEf1GdCxqWcdBw==}
|
||||
engines: {node: '>=14.17'}
|
||||
hasBin: true
|
||||
|
||||
typescript@7.0.2:
|
||||
resolution: {integrity: sha512-8FYau96o3NKOhbjKi/qNvG/W5jhzxkbdm5sj9AbZ/5T5sWqn3hJgLfGx27sRKZWTvyzCP8dLRBTf5tBTSRVUNA==}
|
||||
engines: {node: '>=16.20.0'}
|
||||
hasBin: true
|
||||
|
||||
ufo@1.6.4:
|
||||
resolution: {integrity: sha512-JFNbkD1Svwe0KvGi8GOeLcP4kAWQ609twvCdcHxq1oSL8svv39ZuSvajcD8B+5D0eL4+s1Is2D/O6KN3qcTeRA==}
|
||||
|
||||
@@ -6613,9 +6685,6 @@ packages:
|
||||
resolution: {integrity: sha512-7dSzzRQ++CKnNI/krKnYRV7JKKPUXMEh61soaHKg9mrWEhzFWhFnxPxGl+69cD1Ou63C13NUPCnmIcrvqCuM6w==}
|
||||
engines: {node: '>=12'}
|
||||
|
||||
yauzl@2.10.0:
|
||||
resolution: {integrity: sha512-p4a9I6X6nu6IhoGmBqAcbJy1mlC4j27vEPZX9F4L4/vZT3Lyq1VkFHw/V/PUcB9Buo+DG3iHkT0x3Qya58zc3g==}
|
||||
|
||||
yocto-queue@0.1.0:
|
||||
resolution: {integrity: sha512-rVksvsnNCdJ/ohGc6xgPwyN8eheCxsiLM8mxuE/t/mOVqJewPuO1miLpTHQiRgTKCLexL4MeAFVagts7HmNZ2Q==}
|
||||
engines: {node: '>=10'}
|
||||
@@ -6990,17 +7059,19 @@ snapshots:
|
||||
dependencies:
|
||||
'@noble/ciphers': 1.3.0
|
||||
|
||||
'@electron-toolkit/preload@3.0.2(electron@42.3.3)':
|
||||
'@electron-internal/extract-zip@1.0.4': {}
|
||||
|
||||
'@electron-toolkit/preload@3.0.2(electron@43.1.0)':
|
||||
dependencies:
|
||||
electron: 42.3.3
|
||||
electron: 43.1.0
|
||||
|
||||
'@electron-toolkit/tsconfig@2.0.0(@types/node@25.6.0)':
|
||||
dependencies:
|
||||
'@types/node': 25.6.0
|
||||
|
||||
'@electron-toolkit/utils@4.0.0(electron@42.3.3)':
|
||||
'@electron-toolkit/utils@4.0.0(electron@43.1.0)':
|
||||
dependencies:
|
||||
electron: 42.3.3
|
||||
electron: 43.1.0
|
||||
|
||||
'@electron/asar@3.4.1':
|
||||
dependencies:
|
||||
@@ -7060,11 +7131,11 @@ snapshots:
|
||||
transitivePeerDependencies:
|
||||
- supports-color
|
||||
|
||||
'@electron/rebuild@4.0.4':
|
||||
'@electron/rebuild@4.2.0':
|
||||
dependencies:
|
||||
'@malept/cross-spawn-promise': 2.0.0
|
||||
debug: 4.4.3
|
||||
node-abi: 4.30.0
|
||||
node-abi: 4.33.0
|
||||
node-api-version: 0.2.1
|
||||
node-gyp: 12.3.0
|
||||
read-binary-file-arch: 1.0.6
|
||||
@@ -7087,7 +7158,7 @@ snapshots:
|
||||
dependencies:
|
||||
cross-dirname: 0.1.0
|
||||
debug: 4.4.3
|
||||
fs-extra: 11.3.5
|
||||
fs-extra: 11.3.6
|
||||
minimist: 1.2.8
|
||||
postject: 1.0.0-alpha.6
|
||||
transitivePeerDependencies:
|
||||
@@ -9173,43 +9244,67 @@ snapshots:
|
||||
dependencies:
|
||||
'@types/node': 25.6.0
|
||||
|
||||
'@types/yauzl@2.10.3':
|
||||
dependencies:
|
||||
'@types/node': 25.6.0
|
||||
optional: true
|
||||
|
||||
'@typescript-eslint/types@8.60.0': {}
|
||||
|
||||
'@typescript/native-preview-darwin-arm64@7.0.0-dev.20260421.2':
|
||||
'@typescript/typescript-aix-ppc64@7.0.2':
|
||||
optional: true
|
||||
|
||||
'@typescript/native-preview-darwin-x64@7.0.0-dev.20260421.2':
|
||||
'@typescript/typescript-darwin-arm64@7.0.2':
|
||||
optional: true
|
||||
|
||||
'@typescript/native-preview-linux-arm64@7.0.0-dev.20260421.2':
|
||||
'@typescript/typescript-darwin-x64@7.0.2':
|
||||
optional: true
|
||||
|
||||
'@typescript/native-preview-linux-arm@7.0.0-dev.20260421.2':
|
||||
'@typescript/typescript-freebsd-arm64@7.0.2':
|
||||
optional: true
|
||||
|
||||
'@typescript/native-preview-linux-x64@7.0.0-dev.20260421.2':
|
||||
'@typescript/typescript-freebsd-x64@7.0.2':
|
||||
optional: true
|
||||
|
||||
'@typescript/native-preview-win32-arm64@7.0.0-dev.20260421.2':
|
||||
'@typescript/typescript-linux-arm64@7.0.2':
|
||||
optional: true
|
||||
|
||||
'@typescript/native-preview-win32-x64@7.0.0-dev.20260421.2':
|
||||
'@typescript/typescript-linux-arm@7.0.2':
|
||||
optional: true
|
||||
|
||||
'@typescript/native-preview@7.0.0-dev.20260421.2':
|
||||
optionalDependencies:
|
||||
'@typescript/native-preview-darwin-arm64': 7.0.0-dev.20260421.2
|
||||
'@typescript/native-preview-darwin-x64': 7.0.0-dev.20260421.2
|
||||
'@typescript/native-preview-linux-arm': 7.0.0-dev.20260421.2
|
||||
'@typescript/native-preview-linux-arm64': 7.0.0-dev.20260421.2
|
||||
'@typescript/native-preview-linux-x64': 7.0.0-dev.20260421.2
|
||||
'@typescript/native-preview-win32-arm64': 7.0.0-dev.20260421.2
|
||||
'@typescript/native-preview-win32-x64': 7.0.0-dev.20260421.2
|
||||
'@typescript/typescript-linux-loong64@7.0.2':
|
||||
optional: true
|
||||
|
||||
'@typescript/typescript-linux-mips64el@7.0.2':
|
||||
optional: true
|
||||
|
||||
'@typescript/typescript-linux-ppc64@7.0.2':
|
||||
optional: true
|
||||
|
||||
'@typescript/typescript-linux-riscv64@7.0.2':
|
||||
optional: true
|
||||
|
||||
'@typescript/typescript-linux-s390x@7.0.2':
|
||||
optional: true
|
||||
|
||||
'@typescript/typescript-linux-x64@7.0.2':
|
||||
optional: true
|
||||
|
||||
'@typescript/typescript-netbsd-arm64@7.0.2':
|
||||
optional: true
|
||||
|
||||
'@typescript/typescript-netbsd-x64@7.0.2':
|
||||
optional: true
|
||||
|
||||
'@typescript/typescript-openbsd-arm64@7.0.2':
|
||||
optional: true
|
||||
|
||||
'@typescript/typescript-openbsd-x64@7.0.2':
|
||||
optional: true
|
||||
|
||||
'@typescript/typescript-sunos-x64@7.0.2':
|
||||
optional: true
|
||||
|
||||
'@typescript/typescript-win32-arm64@7.0.2':
|
||||
optional: true
|
||||
|
||||
'@typescript/typescript-win32-x64@7.0.2':
|
||||
optional: true
|
||||
|
||||
'@ungap/structured-clone@1.3.1': {}
|
||||
|
||||
@@ -9239,13 +9334,13 @@ snapshots:
|
||||
chai: 6.2.2
|
||||
tinyrainbow: 3.1.0
|
||||
|
||||
'@vitest/mocker@4.1.5(msw@2.14.3(@types/node@25.6.0)(typescript@5.9.3))(vite@7.3.6(@types/node@25.6.0)(jiti@2.7.0)(lightningcss@1.32.0)(yaml@2.8.4))':
|
||||
'@vitest/mocker@4.1.5(msw@2.14.3(@types/node@25.6.0)(typescript@7.0.2))(vite@7.3.6(@types/node@25.6.0)(jiti@2.7.0)(lightningcss@1.32.0)(yaml@2.8.4))':
|
||||
dependencies:
|
||||
'@vitest/spy': 4.1.5
|
||||
estree-walker: 3.0.3
|
||||
magic-string: 0.30.21
|
||||
optionalDependencies:
|
||||
msw: 2.14.3(@types/node@25.6.0)(typescript@5.9.3)
|
||||
msw: 2.14.3(@types/node@25.6.0)(typescript@7.0.2)
|
||||
vite: 7.3.6(@types/node@25.6.0)(jiti@2.7.0)(lightningcss@1.32.0)(yaml@2.8.4)
|
||||
|
||||
'@vitest/pretty-format@4.1.5':
|
||||
@@ -9372,7 +9467,7 @@ snapshots:
|
||||
'@electron/get': 3.1.0
|
||||
'@electron/notarize': 2.5.0
|
||||
'@electron/osx-sign': 1.3.3
|
||||
'@electron/rebuild': 4.0.4
|
||||
'@electron/rebuild': 4.2.0
|
||||
'@electron/universal': 2.0.3
|
||||
'@malept/flatpak-bundler': 0.4.0
|
||||
'@types/fs-extra': 9.0.13
|
||||
@@ -9501,8 +9596,6 @@ snapshots:
|
||||
node-releases: 2.0.38
|
||||
update-browserslist-db: 1.2.3(browserslist@4.28.2)
|
||||
|
||||
buffer-crc32@0.2.13: {}
|
||||
|
||||
buffer-from@1.1.2: {}
|
||||
|
||||
buffer@5.7.1:
|
||||
@@ -9725,14 +9818,14 @@ snapshots:
|
||||
dependencies:
|
||||
layout-base: 2.0.1
|
||||
|
||||
cosmiconfig@9.0.1(typescript@5.9.3):
|
||||
cosmiconfig@9.0.1(typescript@7.0.2):
|
||||
dependencies:
|
||||
env-paths: 2.2.1
|
||||
import-fresh: 3.3.1
|
||||
js-yaml: 4.2.0
|
||||
parse-json: 5.2.0
|
||||
optionalDependencies:
|
||||
typescript: 5.9.3
|
||||
typescript: 7.0.2
|
||||
|
||||
cpu-features@0.0.10:
|
||||
dependencies:
|
||||
@@ -10050,10 +10143,6 @@ snapshots:
|
||||
|
||||
dom-accessibility-api@0.6.3: {}
|
||||
|
||||
dompurify@3.2.7:
|
||||
optionalDependencies:
|
||||
'@types/trusted-types': 2.0.7
|
||||
|
||||
dompurify@3.4.11:
|
||||
optionalDependencies:
|
||||
'@types/trusted-types': 2.0.7
|
||||
@@ -10162,11 +10251,11 @@ snapshots:
|
||||
transitivePeerDependencies:
|
||||
- supports-color
|
||||
|
||||
electron@42.3.3:
|
||||
electron@43.1.0:
|
||||
dependencies:
|
||||
'@electron-internal/extract-zip': 1.0.4
|
||||
'@electron/get': 5.0.0
|
||||
'@types/node': 24.12.4
|
||||
extract-zip: 2.0.1
|
||||
transitivePeerDependencies:
|
||||
- supports-color
|
||||
|
||||
@@ -10399,16 +10488,6 @@ snapshots:
|
||||
|
||||
extend@3.0.2: {}
|
||||
|
||||
extract-zip@2.0.1:
|
||||
dependencies:
|
||||
debug: 4.4.3
|
||||
get-stream: 5.2.0
|
||||
yauzl: 2.10.0
|
||||
optionalDependencies:
|
||||
'@types/yauzl': 2.10.3
|
||||
transitivePeerDependencies:
|
||||
- supports-color
|
||||
|
||||
extsprintf@1.4.1:
|
||||
optional: true
|
||||
|
||||
@@ -10446,10 +10525,6 @@ snapshots:
|
||||
dependencies:
|
||||
format: 0.2.2
|
||||
|
||||
fd-slicer@1.1.0:
|
||||
dependencies:
|
||||
pend: 1.2.0
|
||||
|
||||
fdir@6.5.0(picomatch@4.0.4):
|
||||
optionalDependencies:
|
||||
picomatch: 4.0.4
|
||||
@@ -10525,6 +10600,13 @@ snapshots:
|
||||
jsonfile: 6.2.1
|
||||
universalify: 2.0.1
|
||||
|
||||
fs-extra@11.3.6:
|
||||
dependencies:
|
||||
graceful-fs: 4.2.11
|
||||
jsonfile: 6.2.1
|
||||
universalify: 2.0.1
|
||||
optional: true
|
||||
|
||||
fs-extra@7.0.1:
|
||||
dependencies:
|
||||
graceful-fs: 4.2.11
|
||||
@@ -10855,9 +10937,9 @@ snapshots:
|
||||
|
||||
husky@9.1.7: {}
|
||||
|
||||
i18next@26.3.1(typescript@5.9.3):
|
||||
i18next@26.3.1(typescript@7.0.2):
|
||||
optionalDependencies:
|
||||
typescript: 5.9.3
|
||||
typescript: 7.0.2
|
||||
|
||||
iconv-corefoundation@1.1.7:
|
||||
dependencies:
|
||||
@@ -10896,9 +10978,9 @@ snapshots:
|
||||
|
||||
inline-style-parser@0.2.7: {}
|
||||
|
||||
inspect-webkit@0.0.5(typescript@5.9.3):
|
||||
inspect-webkit@0.0.5(typescript@7.0.2):
|
||||
dependencies:
|
||||
typescript: 5.9.3
|
||||
typescript: 7.0.2
|
||||
|
||||
internmap@1.0.1: {}
|
||||
|
||||
@@ -11677,12 +11759,12 @@ snapshots:
|
||||
|
||||
monaco-editor@0.55.1:
|
||||
dependencies:
|
||||
dompurify: 3.2.7
|
||||
dompurify: 3.4.11
|
||||
marked: 14.0.0
|
||||
|
||||
ms@2.1.3: {}
|
||||
|
||||
msw@2.14.3(@types/node@25.6.0)(typescript@5.9.3):
|
||||
msw@2.14.3(@types/node@25.6.0)(typescript@7.0.2):
|
||||
dependencies:
|
||||
'@inquirer/confirm': 6.0.12(@types/node@25.6.0)
|
||||
'@mswjs/interceptors': 0.41.8
|
||||
@@ -11703,7 +11785,7 @@ snapshots:
|
||||
until-async: 3.0.2
|
||||
yargs: 17.7.2
|
||||
optionalDependencies:
|
||||
typescript: 5.9.3
|
||||
typescript: 7.0.2
|
||||
transitivePeerDependencies:
|
||||
- '@types/node'
|
||||
|
||||
@@ -11716,9 +11798,9 @@ snapshots:
|
||||
|
||||
negotiator@1.0.0: {}
|
||||
|
||||
node-abi@4.30.0:
|
||||
node-abi@4.33.0:
|
||||
dependencies:
|
||||
semver: 7.7.4
|
||||
semver: 7.8.1
|
||||
|
||||
node-addon-api@1.7.2:
|
||||
optional: true
|
||||
@@ -11727,7 +11809,7 @@ snapshots:
|
||||
|
||||
node-api-version@0.2.1:
|
||||
dependencies:
|
||||
semver: 7.7.4
|
||||
semver: 7.8.1
|
||||
|
||||
node-domexception@1.0.0: {}
|
||||
|
||||
@@ -11744,7 +11826,7 @@ snapshots:
|
||||
graceful-fs: 4.2.11
|
||||
nopt: 9.0.0
|
||||
proc-log: 6.1.0
|
||||
semver: 7.7.4
|
||||
semver: 7.8.1
|
||||
tar: 7.5.16
|
||||
tinyglobby: 0.2.16
|
||||
undici: 6.27.0
|
||||
@@ -11974,8 +12056,6 @@ snapshots:
|
||||
|
||||
pe-library@0.4.1: {}
|
||||
|
||||
pend@1.2.0: {}
|
||||
|
||||
picocolors@1.1.1: {}
|
||||
|
||||
picomatch@2.3.2: {}
|
||||
@@ -12260,16 +12340,16 @@ snapshots:
|
||||
optionalDependencies:
|
||||
react: 19.2.5
|
||||
|
||||
react-i18next@17.0.8(i18next@26.3.1(typescript@5.9.3))(react-dom@19.2.5(react@19.2.5))(react@19.2.5)(typescript@5.9.3):
|
||||
react-i18next@17.0.8(i18next@26.3.1(typescript@7.0.2))(react-dom@19.2.5(react@19.2.5))(react@19.2.5)(typescript@7.0.2):
|
||||
dependencies:
|
||||
'@babel/runtime': 7.29.7
|
||||
html-parse-stringify: 3.0.1
|
||||
i18next: 26.3.1(typescript@5.9.3)
|
||||
i18next: 26.3.1(typescript@7.0.2)
|
||||
react: 19.2.5
|
||||
use-sync-external-store: 1.6.0(react@19.2.5)
|
||||
optionalDependencies:
|
||||
react-dom: 19.2.5(react@19.2.5)
|
||||
typescript: 5.9.3
|
||||
typescript: 7.0.2
|
||||
|
||||
react-is@17.0.2: {}
|
||||
|
||||
@@ -12585,9 +12665,9 @@ snapshots:
|
||||
type-fest: 0.13.1
|
||||
optional: true
|
||||
|
||||
serve-sim@0.1.40(typescript@5.9.3):
|
||||
serve-sim@0.1.40(typescript@7.0.2):
|
||||
dependencies:
|
||||
inspect-webkit: 0.0.5(typescript@5.9.3)
|
||||
inspect-webkit: 0.0.5(typescript@7.0.2)
|
||||
transitivePeerDependencies:
|
||||
- typescript
|
||||
|
||||
@@ -12606,7 +12686,7 @@ snapshots:
|
||||
|
||||
setprototypeof@1.2.0: {}
|
||||
|
||||
shadcn@4.7.0(@types/node@25.6.0)(typescript@5.9.3):
|
||||
shadcn@4.7.0(@types/node@25.6.0)(typescript@7.0.2):
|
||||
dependencies:
|
||||
'@babel/core': 7.29.7
|
||||
'@babel/parser': 7.29.3
|
||||
@@ -12617,7 +12697,7 @@ snapshots:
|
||||
'@types/validate-npm-package-name': 4.0.2
|
||||
browserslist: 4.28.2
|
||||
commander: 14.0.3
|
||||
cosmiconfig: 9.0.1(typescript@5.9.3)
|
||||
cosmiconfig: 9.0.1(typescript@7.0.2)
|
||||
dedent: 1.7.2
|
||||
deepmerge: 4.3.1
|
||||
diff: 8.0.4
|
||||
@@ -12627,7 +12707,7 @@ snapshots:
|
||||
fuzzysort: 3.1.0
|
||||
https-proxy-agent: 7.0.6
|
||||
kleur: 4.1.5
|
||||
msw: 2.14.3(@types/node@25.6.0)(typescript@5.9.3)
|
||||
msw: 2.14.3(@types/node@25.6.0)(typescript@7.0.2)
|
||||
node-fetch: 3.3.2
|
||||
open: 11.0.0
|
||||
ora: 8.2.0
|
||||
@@ -12956,7 +13036,30 @@ snapshots:
|
||||
media-typer: 1.1.0
|
||||
mime-types: 3.0.2
|
||||
|
||||
typescript@5.9.3: {}
|
||||
typescript@6.0.3: {}
|
||||
|
||||
typescript@7.0.2:
|
||||
optionalDependencies:
|
||||
'@typescript/typescript-aix-ppc64': 7.0.2
|
||||
'@typescript/typescript-darwin-arm64': 7.0.2
|
||||
'@typescript/typescript-darwin-x64': 7.0.2
|
||||
'@typescript/typescript-freebsd-arm64': 7.0.2
|
||||
'@typescript/typescript-freebsd-x64': 7.0.2
|
||||
'@typescript/typescript-linux-arm': 7.0.2
|
||||
'@typescript/typescript-linux-arm64': 7.0.2
|
||||
'@typescript/typescript-linux-loong64': 7.0.2
|
||||
'@typescript/typescript-linux-mips64el': 7.0.2
|
||||
'@typescript/typescript-linux-ppc64': 7.0.2
|
||||
'@typescript/typescript-linux-riscv64': 7.0.2
|
||||
'@typescript/typescript-linux-s390x': 7.0.2
|
||||
'@typescript/typescript-linux-x64': 7.0.2
|
||||
'@typescript/typescript-netbsd-arm64': 7.0.2
|
||||
'@typescript/typescript-netbsd-x64': 7.0.2
|
||||
'@typescript/typescript-openbsd-arm64': 7.0.2
|
||||
'@typescript/typescript-openbsd-x64': 7.0.2
|
||||
'@typescript/typescript-sunos-x64': 7.0.2
|
||||
'@typescript/typescript-win32-arm64': 7.0.2
|
||||
'@typescript/typescript-win32-x64': 7.0.2
|
||||
|
||||
ufo@1.6.4: {}
|
||||
|
||||
@@ -13100,10 +13203,10 @@ snapshots:
|
||||
lightningcss: 1.32.0
|
||||
yaml: 2.8.4
|
||||
|
||||
vitest@4.1.5(@types/node@25.6.0)(happy-dom@20.9.0)(msw@2.14.3(@types/node@25.6.0)(typescript@5.9.3))(vite@7.3.6(@types/node@25.6.0)(jiti@2.7.0)(lightningcss@1.32.0)(yaml@2.8.4)):
|
||||
vitest@4.1.5(@types/node@25.6.0)(happy-dom@20.9.0)(msw@2.14.3(@types/node@25.6.0)(typescript@7.0.2))(vite@7.3.6(@types/node@25.6.0)(jiti@2.7.0)(lightningcss@1.32.0)(yaml@2.8.4)):
|
||||
dependencies:
|
||||
'@vitest/expect': 4.1.5
|
||||
'@vitest/mocker': 4.1.5(msw@2.14.3(@types/node@25.6.0)(typescript@5.9.3))(vite@7.3.6(@types/node@25.6.0)(jiti@2.7.0)(lightningcss@1.32.0)(yaml@2.8.4))
|
||||
'@vitest/mocker': 4.1.5(msw@2.14.3(@types/node@25.6.0)(typescript@7.0.2))(vite@7.3.6(@types/node@25.6.0)(jiti@2.7.0)(lightningcss@1.32.0)(yaml@2.8.4))
|
||||
'@vitest/pretty-format': 4.1.5
|
||||
'@vitest/runner': 4.1.5
|
||||
'@vitest/snapshot': 4.1.5
|
||||
@@ -13237,11 +13340,6 @@ snapshots:
|
||||
y18n: 5.0.8
|
||||
yargs-parser: 21.1.1
|
||||
|
||||
yauzl@2.10.0:
|
||||
dependencies:
|
||||
buffer-crc32: 0.2.13
|
||||
fd-slicer: 1.1.0
|
||||
|
||||
yocto-queue@0.1.0: {}
|
||||
|
||||
yocto-spinner@1.2.0:
|
||||
|
||||
@@ -0,0 +1,93 @@
|
||||
import { describe, expect, it } from 'vitest'
|
||||
|
||||
import type { CommandSpec } from './args'
|
||||
import { buildAgentContext, formatAgentContextSummary } from './agent-context'
|
||||
import { COMMAND_SPECS } from './specs'
|
||||
|
||||
describe('buildAgentContext', () => {
|
||||
const specs: CommandSpec[] = [
|
||||
{
|
||||
path: ['worktree', 'rm'],
|
||||
aliases: [
|
||||
['worktree', 'remove'],
|
||||
['worktree', 'delete']
|
||||
],
|
||||
summary: 'Remove a worktree',
|
||||
usage: 'orca worktree rm',
|
||||
allowedFlags: ['worktree', 'force']
|
||||
},
|
||||
{
|
||||
path: ['agent-context'],
|
||||
summary: 'Print the schema',
|
||||
usage: 'orca agent-context',
|
||||
allowedFlags: []
|
||||
}
|
||||
]
|
||||
|
||||
it('emits a versioned envelope with a command count', () => {
|
||||
const schema = buildAgentContext(specs)
|
||||
expect(schema.schemaVersion).toBe(1)
|
||||
expect(schema.commandCount).toBe(2)
|
||||
expect(schema.commands).toHaveLength(2)
|
||||
})
|
||||
|
||||
it('includes resolved aliases for a command', () => {
|
||||
const schema = buildAgentContext(specs)
|
||||
const rm = schema.commands.find((command) => command.command === 'worktree rm')
|
||||
expect(rm?.aliases).toEqual([
|
||||
['worktree', 'remove'],
|
||||
['worktree', 'delete']
|
||||
])
|
||||
})
|
||||
|
||||
it('reports effective flags including globals, not just allowedFlags', () => {
|
||||
const schema = buildAgentContext(specs)
|
||||
const rm = schema.commands.find((command) => command.command === 'worktree rm')
|
||||
expect(rm?.flags).toContain('worktree')
|
||||
expect(rm?.flags).toContain('force')
|
||||
expect(rm?.flags).toContain('json')
|
||||
expect(rm?.flags).toContain('help')
|
||||
})
|
||||
|
||||
it('orders commands deterministically', () => {
|
||||
const schema = buildAgentContext(specs)
|
||||
expect(schema.commands.map((command) => command.command)).toEqual([
|
||||
'agent-context',
|
||||
'worktree rm'
|
||||
])
|
||||
})
|
||||
|
||||
it('defaults optional fields to empty arrays', () => {
|
||||
const schema = buildAgentContext(specs)
|
||||
const agentContext = schema.commands.find((command) => command.command === 'agent-context')
|
||||
expect(agentContext?.aliases).toEqual([])
|
||||
expect(agentContext?.examples).toEqual([])
|
||||
})
|
||||
})
|
||||
|
||||
describe('agent-context over the live registry', () => {
|
||||
it('exposes the worktree rm command with its remove/delete aliases', () => {
|
||||
const schema = buildAgentContext(COMMAND_SPECS)
|
||||
const rm = schema.commands.find((command) => command.command === 'worktree rm')
|
||||
expect(rm).toBeDefined()
|
||||
expect(rm?.aliases).toContainEqual(['worktree', 'remove'])
|
||||
})
|
||||
|
||||
it('human summary count matches the command count', () => {
|
||||
const schema = buildAgentContext(COMMAND_SPECS)
|
||||
expect(formatAgentContextSummary(schema)).toContain(`${schema.commandCount} commands`)
|
||||
})
|
||||
|
||||
it('does not advertise browser page targeting for local discovery', () => {
|
||||
const schema = buildAgentContext(COMMAND_SPECS)
|
||||
const agentContext = schema.commands.find((command) => command.command === 'agent-context')
|
||||
expect(agentContext?.flags).not.toContain('page')
|
||||
})
|
||||
|
||||
it('marks raw passthrough commands without synthesizing Orca flags', () => {
|
||||
const schema = buildAgentContext(COMMAND_SPECS)
|
||||
const claudeTeams = schema.commands.find((command) => command.command === 'claude-teams')
|
||||
expect(claudeTeams?.argumentMode).toBe('passthrough')
|
||||
expect(claudeTeams?.flags).toEqual([])
|
||||
})
|
||||
})
|
||||
@@ -0,0 +1,60 @@
|
||||
import type { CommandSpec } from './args'
|
||||
import { effectiveAllowedFlags } from './args'
|
||||
|
||||
// Why: serialize the live spec table so agent discovery cannot drift from the
|
||||
// command surface it describes.
|
||||
|
||||
const SCHEMA_VERSION = 1
|
||||
|
||||
export type AgentContextCommand = {
|
||||
command: string
|
||||
path: string[]
|
||||
aliases: string[][]
|
||||
argumentMode: 'parsed' | 'passthrough'
|
||||
summary: string
|
||||
usage: string
|
||||
flags: string[]
|
||||
positionalArgs: string[]
|
||||
examples: string[]
|
||||
notes: string[]
|
||||
}
|
||||
|
||||
export type AgentContextSchema = {
|
||||
schemaVersion: number
|
||||
commandCount: number
|
||||
commands: AgentContextCommand[]
|
||||
}
|
||||
|
||||
export function buildAgentContext(specs: CommandSpec[]): AgentContextSchema {
|
||||
const commands = specs
|
||||
.map((spec) => ({
|
||||
command: spec.path.join(' '),
|
||||
path: spec.path,
|
||||
aliases: spec.aliases ?? [],
|
||||
argumentMode: spec.argumentMode ?? 'parsed',
|
||||
summary: spec.summary,
|
||||
usage: spec.usage,
|
||||
// Why: the effective accepted set (globals + conditional --page), not just
|
||||
// allowedFlags — otherwise agents treat --json/--help as unsupported.
|
||||
flags: effectiveAllowedFlags(spec),
|
||||
positionalArgs: spec.positionalArgs ?? [],
|
||||
examples: spec.examples ?? [],
|
||||
notes: spec.notes ?? []
|
||||
}))
|
||||
// Why: deterministic ordering so the JSON diffs cleanly across runs.
|
||||
.sort((a, b) => a.command.localeCompare(b.command))
|
||||
return {
|
||||
schemaVersion: SCHEMA_VERSION,
|
||||
commandCount: commands.length,
|
||||
commands
|
||||
}
|
||||
}
|
||||
|
||||
export function formatAgentContextSummary(schema: AgentContextSchema): string {
|
||||
// Why: keep the default (human) output bounded — the full surface is large, so
|
||||
// point the reader at --json rather than dumping every command.
|
||||
return [
|
||||
`${schema.commandCount} commands (schema v${schema.schemaVersion}).`,
|
||||
'Run `orca agent-context --json` for the full machine-readable command schema.'
|
||||
].join('\n')
|
||||
}
|
||||
@@ -1,7 +1,9 @@
|
||||
import { describe, expect, it } from 'vitest'
|
||||
|
||||
import type { CommandSpec } from './args'
|
||||
import {
|
||||
REPEATED_FLAG_SEPARATOR,
|
||||
findCommandSpec,
|
||||
normalizeCommandPositionals,
|
||||
parseArgs,
|
||||
supportsBrowserPageFlag,
|
||||
@@ -44,6 +46,73 @@ describe('parseArgs', () => {
|
||||
expect(parsed.flags.get('url')).toBe('https://example.com')
|
||||
})
|
||||
|
||||
it('does not consume a command token after an unknown flag', () => {
|
||||
const parsed = parseArgs(['--jso', 'worktree', 'list'], [['worktree', 'list']])
|
||||
|
||||
expect(parsed.commandPath).toEqual(['worktree', 'list'])
|
||||
expect(parsed.flags.get('jso')).toBe(true)
|
||||
})
|
||||
|
||||
it('does not consume a command token after a flag valid on another command', () => {
|
||||
const parsed = parseArgs(['--workspace', 'worktree', 'list'], [['worktree', 'list']])
|
||||
|
||||
expect(parsed.commandPath).toEqual(['worktree', 'list'])
|
||||
expect(parsed.flags.get('workspace')).toBe(true)
|
||||
})
|
||||
|
||||
it('recognizes a command path with a boolean flag between its segments', () => {
|
||||
const parsed = parseArgs(['--jso', 'worktree', '--json', 'list'], [['worktree', 'list']])
|
||||
|
||||
expect(parsed.commandPath).toEqual(['worktree', 'list'])
|
||||
expect(parsed.flags.get('jso')).toBe(true)
|
||||
expect(parsed.flags.get('json')).toBe(true)
|
||||
})
|
||||
|
||||
it('recognizes a command path with a value flag between its segments', () => {
|
||||
const parsed = parseArgs(
|
||||
['--jso', 'worktree', '--repo', 'id:abc', 'list'],
|
||||
[['worktree', 'list']]
|
||||
)
|
||||
|
||||
expect(parsed.commandPath).toEqual(['worktree', 'list'])
|
||||
expect(parsed.flags.get('jso')).toBe(true)
|
||||
expect(parsed.flags.get('repo')).toBe('id:abc')
|
||||
})
|
||||
|
||||
it('still consumes a pre-command flag value before a later command path', () => {
|
||||
const parsed = parseArgs(['--workspace', 'team-1', 'linear', 'list'], [['linear', 'list']])
|
||||
|
||||
expect(parsed.commandPath).toEqual(['linear', 'list'])
|
||||
expect(parsed.flags.get('workspace')).toBe('team-1')
|
||||
})
|
||||
|
||||
it('preserves existing pre-command selector values', () => {
|
||||
const parsed = parseArgs(['--repo', 'id:abc', 'worktree', 'list'], [['worktree', 'list']])
|
||||
|
||||
expect(parsed.commandPath).toEqual(['worktree', 'list'])
|
||||
expect(parsed.flags.get('repo')).toBe('id:abc')
|
||||
})
|
||||
|
||||
it('preserves a selector value that is also a registered command', () => {
|
||||
const parsed = parseArgs(
|
||||
['--environment', 'status', 'worktree', 'list'],
|
||||
[['status'], ['worktree', 'list']]
|
||||
)
|
||||
|
||||
expect(parsed.commandPath).toEqual(['worktree', 'list'])
|
||||
expect(parsed.flags.get('environment')).toBe('status')
|
||||
})
|
||||
|
||||
it('preserves a selector value that is also a command group', () => {
|
||||
const parsed = parseArgs(
|
||||
['--environment', 'worktree', 'status'],
|
||||
[['status'], ['worktree', 'list']]
|
||||
)
|
||||
|
||||
expect(parsed.commandPath).toEqual(['status'])
|
||||
expect(parsed.flags.get('environment')).toBe('worktree')
|
||||
})
|
||||
|
||||
it('parses emulator reinstall as a boolean flag', () => {
|
||||
const parsed = parseArgs(['emulator', 'install', 'app.apk', '--reinstall', '--device', 'emu'])
|
||||
|
||||
@@ -94,10 +163,75 @@ describe('parseArgs', () => {
|
||||
})
|
||||
})
|
||||
|
||||
describe('command aliases', () => {
|
||||
const specs: CommandSpec[] = [
|
||||
{
|
||||
path: ['worktree', 'rm'],
|
||||
aliases: [
|
||||
['worktree', 'remove'],
|
||||
['worktree', 'delete']
|
||||
],
|
||||
summary: 'Remove a worktree',
|
||||
usage: 'orca worktree rm --worktree <selector>',
|
||||
allowedFlags: ['worktree', 'force']
|
||||
},
|
||||
{
|
||||
path: ['repo', 'show'],
|
||||
summary: 'Show a repo',
|
||||
usage: 'orca repo show --repo <selector>',
|
||||
allowedFlags: ['repo'],
|
||||
positionalArgs: ['repo']
|
||||
}
|
||||
]
|
||||
|
||||
it('resolves an exact canonical path to its spec', () => {
|
||||
expect(findCommandSpec(specs, ['worktree', 'rm'])?.path).toEqual(['worktree', 'rm'])
|
||||
})
|
||||
|
||||
it('resolves an aliased path to the canonical spec', () => {
|
||||
expect(findCommandSpec(specs, ['worktree', 'remove'])?.path).toEqual(['worktree', 'rm'])
|
||||
})
|
||||
|
||||
it('resolves each declared alias to the canonical spec', () => {
|
||||
expect(findCommandSpec(specs, ['worktree', 'delete'])?.path).toEqual(['worktree', 'rm'])
|
||||
})
|
||||
|
||||
it('returns undefined for a path matching neither canonical nor alias', () => {
|
||||
expect(findCommandSpec(specs, ['worktree', 'destroy'])).toBeUndefined()
|
||||
})
|
||||
|
||||
it('canonicalizes an aliased command path during normalization', () => {
|
||||
const normalized = normalizeCommandPositionals(
|
||||
specs,
|
||||
parseArgs(['worktree', 'remove', '--force'])
|
||||
)
|
||||
|
||||
expect(normalized.commandPath).toEqual(['worktree', 'rm'])
|
||||
expect(normalized.flags.get('force')).toBe(true)
|
||||
})
|
||||
|
||||
it('leaves a canonical command path unchanged during normalization', () => {
|
||||
const normalized = normalizeCommandPositionals(specs, parseArgs(['worktree', 'rm']))
|
||||
|
||||
expect(normalized.commandPath).toEqual(['worktree', 'rm'])
|
||||
})
|
||||
|
||||
it('binds a trailing positional after canonicalizing the base path', () => {
|
||||
const normalized = normalizeCommandPositionals(specs, parseArgs(['repo', 'show', 'id:abc']))
|
||||
|
||||
expect(normalized.commandPath).toEqual(['repo', 'show'])
|
||||
expect(normalized.flags.get('repo')).toBe('id:abc')
|
||||
})
|
||||
})
|
||||
|
||||
describe('supportsBrowserPageFlag', () => {
|
||||
it('does not expose browser page targeting on orchestration commands', () => {
|
||||
expect(supportsBrowserPageFlag(['orchestration', 'send'])).toBe(false)
|
||||
})
|
||||
|
||||
it('does not expose browser page targeting on local agent discovery', () => {
|
||||
expect(supportsBrowserPageFlag(['agent-context'])).toBe(false)
|
||||
})
|
||||
})
|
||||
|
||||
describe('validateCommandAndFlags', () => {
|
||||
@@ -123,6 +257,18 @@ describe('validateCommandAndFlags', () => {
|
||||
expect(() => validateCommandAndFlags(specs, parsed)).not.toThrow()
|
||||
})
|
||||
|
||||
it.each(['environment', 'pairing-code'])('rejects --%s without a value', (flag) => {
|
||||
const parsed = parseArgs([`--${flag}`, 'demo'], [['demo']])
|
||||
|
||||
expect(() => validateCommandAndFlags(specs, parsed)).toThrow(`Flag --${flag} requires a value.`)
|
||||
})
|
||||
|
||||
it.each(['environment', 'pairing-code'])('rejects an empty --%s= value', (flag) => {
|
||||
const parsed = parseArgs(['demo', `--${flag}=`])
|
||||
|
||||
expect(() => validateCommandAndFlags(specs, parsed)).toThrow(`Flag --${flag} requires a value.`)
|
||||
})
|
||||
|
||||
it('still rejects unknown command-specific flags', () => {
|
||||
const parsed = parseArgs(['demo', '--bogus'])
|
||||
|
||||
@@ -130,4 +276,48 @@ describe('validateCommandAndFlags', () => {
|
||||
'Unknown flag --bogus for command: demo'
|
||||
)
|
||||
})
|
||||
|
||||
it('enumerates valid flags and suggests a near-miss on unknown-flag errors', () => {
|
||||
const flagSpecs: CommandSpec[] = [
|
||||
{
|
||||
path: ['worktree', 'rm'],
|
||||
summary: 'Remove a worktree',
|
||||
usage: 'orca worktree rm',
|
||||
allowedFlags: ['worktree', 'force', 'run-hooks']
|
||||
}
|
||||
]
|
||||
const parsed = parseArgs(['worktree', 'rm', '--forcce'])
|
||||
|
||||
try {
|
||||
validateCommandAndFlags(flagSpecs, parsed)
|
||||
throw new Error('expected validateCommandAndFlags to throw')
|
||||
} catch (error) {
|
||||
const data = (error as { data?: { validFlags: string[]; nextSteps: string[] } }).data
|
||||
expect(data?.validFlags).toContain('force')
|
||||
expect(data?.validFlags).toContain('json')
|
||||
expect(data?.nextSteps.join('\n')).toContain('--force')
|
||||
expect(data?.nextSteps.join('\n')).toContain('Valid flags:')
|
||||
}
|
||||
})
|
||||
|
||||
it('attaches did-you-mean suggestions to unknown-command errors', () => {
|
||||
const suggestSpecs: CommandSpec[] = [
|
||||
{
|
||||
path: ['worktree', 'rm'],
|
||||
summary: 'Remove a worktree',
|
||||
usage: 'orca worktree rm',
|
||||
allowedFlags: []
|
||||
}
|
||||
]
|
||||
const parsed = parseArgs(['worktree', 'remov'])
|
||||
|
||||
try {
|
||||
validateCommandAndFlags(suggestSpecs, parsed)
|
||||
throw new Error('expected validateCommandAndFlags to throw')
|
||||
} catch (error) {
|
||||
const data = (error as { data?: { suggestions: string[]; nextSteps: string[] } }).data
|
||||
expect(data?.suggestions).toContain('worktree rm')
|
||||
expect(data?.nextSteps[0]).toContain('orca worktree rm')
|
||||
}
|
||||
})
|
||||
})
|
||||
|
||||
+89
-31
@@ -1,4 +1,5 @@
|
||||
import { RuntimeClientError } from './runtime-client'
|
||||
import { unknownCommandData, unknownFlagData } from './command-suggestion'
|
||||
|
||||
export type ParsedArgs = {
|
||||
commandPath: string[]
|
||||
@@ -8,6 +9,10 @@ export type ParsedArgs = {
|
||||
|
||||
export type CommandSpec = {
|
||||
path: string[]
|
||||
// Why: conventional alternate verbs should resolve without duplicating specs
|
||||
// or handler registrations.
|
||||
aliases?: string[][]
|
||||
argumentMode?: 'parsed' | 'passthrough'
|
||||
summary: string
|
||||
usage: string
|
||||
allowedFlags: string[]
|
||||
@@ -17,6 +22,7 @@ export type CommandSpec = {
|
||||
}
|
||||
|
||||
export const GLOBAL_FLAGS = ['help', 'json', 'pairing-code', 'environment']
|
||||
const GLOBAL_VALUE_FLAGS = new Set(['pairing-code', 'environment'])
|
||||
export const BOOLEAN_FLAGS = new Set([
|
||||
'all',
|
||||
'attachments',
|
||||
@@ -68,7 +74,23 @@ function setFlagValue(flags: Map<string, string | boolean>, name: string, value:
|
||||
flags.set(name, value)
|
||||
}
|
||||
|
||||
export function parseArgs(argv: string[]): ParsedArgs {
|
||||
function commandPathStartsAt(argv: string[], tokenIndex: number, path: string[]): boolean {
|
||||
let cursor = tokenIndex
|
||||
for (const part of path) {
|
||||
while (argv[cursor]?.startsWith('--')) {
|
||||
const assignment = argv[cursor].slice(2)
|
||||
const flag = assignment.split('=', 1)[0]
|
||||
cursor += assignment.includes('=') || BOOLEAN_FLAGS.has(flag) ? 1 : 2
|
||||
}
|
||||
if (argv[cursor] !== part) {
|
||||
return false
|
||||
}
|
||||
cursor += 1
|
||||
}
|
||||
return true
|
||||
}
|
||||
|
||||
export function parseArgs(argv: string[], commandPaths?: readonly string[][]): ParsedArgs {
|
||||
const commandPath: string[] = []
|
||||
const flags = new Map<string, string | boolean>()
|
||||
|
||||
@@ -94,6 +116,13 @@ export function parseArgs(argv: string[]): ParsedArgs {
|
||||
flags.set(flag, true)
|
||||
continue
|
||||
}
|
||||
// Why: a pre-command flag must not consume a registry-resolvable command path.
|
||||
const startsCommandAt = (tokenIndex: number): boolean =>
|
||||
commandPaths?.some((path) => commandPathStartsAt(argv, tokenIndex, path)) ?? false
|
||||
if (commandPath.length === 0 && startsCommandAt(i + 1) && !startsCommandAt(i + 2)) {
|
||||
flags.set(flag, true)
|
||||
continue
|
||||
}
|
||||
const hasNext = i + 1 < argv.length
|
||||
const next = argv[i + 1]
|
||||
if (!hasNext || next.startsWith('--')) {
|
||||
@@ -123,6 +152,12 @@ export function matches(actual: string[], expected: string[]): boolean {
|
||||
)
|
||||
}
|
||||
|
||||
// Why: a spec is reachable by its canonical path plus any declared aliases — one
|
||||
// definition so resolution, validation, help, and agent-context never disagree.
|
||||
export function specPaths(spec: CommandSpec): string[][] {
|
||||
return spec.aliases ? [spec.path, ...spec.aliases] : [spec.path]
|
||||
}
|
||||
|
||||
export function supportsBrowserPageFlag(commandPath: string[]): boolean {
|
||||
const joined = commandPath.join(' ')
|
||||
if (['open', 'status'].includes(commandPath[0])) {
|
||||
@@ -141,7 +176,8 @@ export function supportsBrowserPageFlag(commandPath: string[]): boolean {
|
||||
'emulator',
|
||||
'note',
|
||||
'diagnostics',
|
||||
'linear'
|
||||
'linear',
|
||||
'agent-context'
|
||||
].includes(commandPath[0])
|
||||
) {
|
||||
return false
|
||||
@@ -156,6 +192,20 @@ export function supportsBrowserPageFlag(commandPath: string[]): boolean {
|
||||
].includes(joined)
|
||||
}
|
||||
|
||||
// Why: validation and agent discovery must expose the same effective flag set.
|
||||
export function effectiveAllowedFlags(spec: CommandSpec): string[] {
|
||||
if (spec.argumentMode === 'passthrough') {
|
||||
return []
|
||||
}
|
||||
return [
|
||||
...new Set([
|
||||
...GLOBAL_FLAGS,
|
||||
...spec.allowedFlags,
|
||||
...(supportsBrowserPageFlag(spec.path) ? ['page'] : [])
|
||||
])
|
||||
]
|
||||
}
|
||||
|
||||
export function isCommandGroup(commandPath: string[]): boolean {
|
||||
return (
|
||||
(commandPath.length === 1 &&
|
||||
@@ -194,29 +244,35 @@ export function isCommandGroup(commandPath: string[]): boolean {
|
||||
export function normalizeCommandPositionals(specs: CommandSpec[], parsed: ParsedArgs): ParsedArgs {
|
||||
for (const spec of specs) {
|
||||
const positionalArgs = spec.positionalArgs ?? []
|
||||
if (positionalArgs.length === 0) {
|
||||
// Why: aliased paths still need canonicalization when there are no positionals.
|
||||
if (positionalArgs.length === 0 && !spec.aliases) {
|
||||
continue
|
||||
}
|
||||
const positionalCount = parsed.commandPath.length - spec.path.length
|
||||
if (positionalCount <= 0 || positionalCount > positionalArgs.length) {
|
||||
continue
|
||||
}
|
||||
if (!matches(parsed.commandPath.slice(0, spec.path.length), spec.path)) {
|
||||
continue
|
||||
}
|
||||
const flags = new Map(parsed.flags)
|
||||
const values = parsed.commandPath.slice(spec.path.length)
|
||||
// Why: validation runs inside main's error-reporting path, so normalization
|
||||
// records ambiguity instead of throwing before CLI errors can be formatted.
|
||||
const providedPositionals = values.map((_, index) => positionalArgs[index])
|
||||
const positionalFlagConflicts = providedPositionals.filter((name) => flags.has(name))
|
||||
values.forEach((value, index) => {
|
||||
const name = positionalArgs[index]
|
||||
if (!flags.has(name)) {
|
||||
flags.set(name, value)
|
||||
// Why: canonicalize aliases before validation and dispatch so both use one key.
|
||||
for (const base of specPaths(spec)) {
|
||||
// Why: `< 0` (not `<= 0`) so an exact base match with zero positionals
|
||||
// still canonicalizes an aliased path; upper bound guards over-consumption.
|
||||
const positionalCount = parsed.commandPath.length - base.length
|
||||
if (positionalCount < 0 || positionalCount > positionalArgs.length) {
|
||||
continue
|
||||
}
|
||||
})
|
||||
return { commandPath: spec.path, flags, positionalFlagConflicts }
|
||||
if (!matches(parsed.commandPath.slice(0, base.length), base)) {
|
||||
continue
|
||||
}
|
||||
const flags = new Map(parsed.flags)
|
||||
const values = parsed.commandPath.slice(base.length)
|
||||
// Why: validation runs inside main's error-reporting path, so normalization
|
||||
// records ambiguity instead of throwing before CLI errors can be formatted.
|
||||
const providedPositionals = values.map((_, index) => positionalArgs[index])
|
||||
const positionalFlagConflicts = providedPositionals.filter((name) => flags.has(name))
|
||||
values.forEach((value, index) => {
|
||||
const name = positionalArgs[index]
|
||||
if (!flags.has(name)) {
|
||||
flags.set(name, value)
|
||||
}
|
||||
})
|
||||
return { commandPath: spec.path, flags, positionalFlagConflicts }
|
||||
}
|
||||
}
|
||||
return parsed
|
||||
}
|
||||
@@ -225,7 +281,7 @@ export function findCommandSpec(
|
||||
specs: CommandSpec[],
|
||||
commandPath: string[]
|
||||
): CommandSpec | undefined {
|
||||
return specs.find((spec) => matches(spec.path, commandPath))
|
||||
return specs.find((spec) => specPaths(spec).some((candidate) => matches(candidate, commandPath)))
|
||||
}
|
||||
|
||||
export function validateCommandAndFlags(specs: CommandSpec[], parsed: ParsedArgs): void {
|
||||
@@ -233,7 +289,8 @@ export function validateCommandAndFlags(specs: CommandSpec[], parsed: ParsedArgs
|
||||
if (!spec) {
|
||||
throw new RuntimeClientError(
|
||||
'invalid_argument',
|
||||
`Unknown command: ${parsed.commandPath.join(' ')}`
|
||||
`Unknown command: ${parsed.commandPath.join(' ')}`,
|
||||
unknownCommandData(specs, parsed.commandPath)
|
||||
)
|
||||
}
|
||||
|
||||
@@ -246,16 +303,17 @@ export function validateCommandAndFlags(specs: CommandSpec[], parsed: ParsedArgs
|
||||
)
|
||||
}
|
||||
|
||||
for (const flag of parsed.flags.keys()) {
|
||||
const pageAllowed = supportsBrowserPageFlag(spec.path)
|
||||
for (const [flag, value] of parsed.flags) {
|
||||
const isGlobalFlag = GLOBAL_FLAGS.includes(flag)
|
||||
if (
|
||||
!isGlobalFlag &&
|
||||
!spec.allowedFlags.includes(flag) &&
|
||||
!(flag === 'page' && supportsBrowserPageFlag(spec.path))
|
||||
) {
|
||||
if (GLOBAL_VALUE_FLAGS.has(flag) && (typeof value !== 'string' || value.length === 0)) {
|
||||
throw new RuntimeClientError('invalid_argument', `Flag --${flag} requires a value.`)
|
||||
}
|
||||
if (!isGlobalFlag && !spec.allowedFlags.includes(flag) && !(flag === 'page' && pageAllowed)) {
|
||||
throw new RuntimeClientError(
|
||||
'invalid_argument',
|
||||
`Unknown flag --${flag} for command: ${spec.path.join(' ')}`
|
||||
`Unknown flag --${flag} for command: ${spec.path.join(' ')}`,
|
||||
unknownFlagData(flag, effectiveAllowedFlags(spec))
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
@@ -0,0 +1,85 @@
|
||||
import { describe, expect, it } from 'vitest'
|
||||
|
||||
import type { CommandSpec } from './args'
|
||||
import { levenshtein, suggestCommands, unknownCommandData } from './command-suggestion'
|
||||
|
||||
const specs: CommandSpec[] = [
|
||||
{
|
||||
path: ['worktree', 'rm'],
|
||||
aliases: [['worktree', 'remove']],
|
||||
summary: 'Remove a worktree',
|
||||
usage: 'orca worktree rm',
|
||||
allowedFlags: []
|
||||
},
|
||||
{
|
||||
path: ['worktree', 'list'],
|
||||
summary: 'List worktrees',
|
||||
usage: 'orca worktree list',
|
||||
allowedFlags: []
|
||||
},
|
||||
{
|
||||
path: ['terminal', 'send'],
|
||||
summary: 'Send input',
|
||||
usage: 'orca terminal send',
|
||||
allowedFlags: []
|
||||
}
|
||||
]
|
||||
|
||||
describe('levenshtein', () => {
|
||||
it('returns 0 for identical strings', () => {
|
||||
expect(levenshtein('rm', 'rm')).toBe(0)
|
||||
})
|
||||
|
||||
it('counts single-edit distance', () => {
|
||||
expect(levenshtein('remov', 'remove')).toBe(1)
|
||||
})
|
||||
|
||||
it('handles empty operands', () => {
|
||||
expect(levenshtein('', 'abc')).toBe(3)
|
||||
expect(levenshtein('abc', '')).toBe(3)
|
||||
})
|
||||
})
|
||||
|
||||
describe('suggestCommands', () => {
|
||||
it('suggests the closest command for a near-miss verb', () => {
|
||||
expect(suggestCommands(specs, ['worktree', 'remov'])).toContain('worktree rm')
|
||||
})
|
||||
|
||||
it('includes alias paths among suggestions', () => {
|
||||
// `worktree remove` is the alias; a typo near it should surface it (an exact
|
||||
// match would resolve as a real command, not trigger a suggestion).
|
||||
expect(suggestCommands(specs, ['worktree', 'remov'])).toContain('worktree remove')
|
||||
})
|
||||
|
||||
it('returns nothing for a wildly-off token', () => {
|
||||
expect(suggestCommands(specs, ['worktree', 'zzzzz'])).toEqual([])
|
||||
})
|
||||
|
||||
it('only considers commands of the same depth', () => {
|
||||
expect(suggestCommands(specs, ['worktree', 'list', 'extra'])).toEqual([])
|
||||
})
|
||||
|
||||
it('suggests a top-level command group near-miss', () => {
|
||||
expect(suggestCommands(specs, ['worktre'])).toEqual(['worktree'])
|
||||
})
|
||||
|
||||
it('ranks closer matches first', () => {
|
||||
const result = suggestCommands(specs, ['terminal', 'sen'])
|
||||
expect(result[0]).toBe('terminal send')
|
||||
})
|
||||
})
|
||||
|
||||
describe('unknownCommandData', () => {
|
||||
it('produces a human nextSteps line when a suggestion exists', () => {
|
||||
const data = unknownCommandData(specs, ['worktree', 'remov'])
|
||||
expect(data.suggestions).toContain('worktree rm')
|
||||
expect(data.nextSteps[0]).toContain('Did you mean')
|
||||
expect(data.nextSteps[0]).toContain('orca worktree rm')
|
||||
})
|
||||
|
||||
it('produces empty nextSteps when nothing is close', () => {
|
||||
const data = unknownCommandData(specs, ['worktree', 'zzzzz'])
|
||||
expect(data.suggestions).toEqual([])
|
||||
expect(data.nextSteps).toEqual([])
|
||||
})
|
||||
})
|
||||
@@ -0,0 +1,101 @@
|
||||
import type { CommandSpec } from './args'
|
||||
import { specPaths } from './args'
|
||||
|
||||
// Why: rank the live registry so typo recovery cannot drift from accepted paths.
|
||||
|
||||
const SUGGESTION_THRESHOLD = 3
|
||||
const MAX_SUGGESTIONS = 3
|
||||
|
||||
export type CommandErrorData = {
|
||||
suggestions: string[]
|
||||
nextSteps: string[]
|
||||
}
|
||||
|
||||
export function levenshtein(a: string, b: string): number {
|
||||
const m = a.length
|
||||
const n = b.length
|
||||
if (m === 0) {
|
||||
return n
|
||||
}
|
||||
if (n === 0) {
|
||||
return m
|
||||
}
|
||||
let prev = Array.from({ length: n + 1 }, (_, index) => index)
|
||||
let curr = Array.from({ length: n + 1 }, () => 0)
|
||||
for (let i = 1; i <= m; i += 1) {
|
||||
curr[0] = i
|
||||
for (let j = 1; j <= n; j += 1) {
|
||||
const cost = a[i - 1] === b[j - 1] ? 0 : 1
|
||||
curr[j] = Math.min(prev[j] + 1, curr[j - 1] + 1, prev[j - 1] + cost)
|
||||
}
|
||||
const swap = prev
|
||||
prev = curr
|
||||
curr = swap
|
||||
}
|
||||
return prev[n]
|
||||
}
|
||||
|
||||
// Why: one bounded near-match ranking keeps command and flag recovery consistent.
|
||||
function rankByDistance(scored: { label: string; distance: number }[]): string[] {
|
||||
return scored
|
||||
.filter((entry) => entry.distance > 0 && entry.distance <= SUGGESTION_THRESHOLD)
|
||||
.sort((a, b) => a.distance - b.distance || a.label.localeCompare(b.label))
|
||||
.slice(0, MAX_SUGGESTIONS)
|
||||
.map((entry) => entry.label)
|
||||
}
|
||||
|
||||
// Why: same-depth matching avoids suggesting parent groups or unrelated commands.
|
||||
export function suggestCommands(specs: CommandSpec[], commandPath: string[]): string[] {
|
||||
const input = commandPath.join(' ')
|
||||
const seen = new Set<string>()
|
||||
const scored: { label: string; distance: number }[] = []
|
||||
for (const spec of specs) {
|
||||
const candidates = specPaths(spec).map((path) =>
|
||||
commandPath.length === 1 ? path.slice(0, 1) : path
|
||||
)
|
||||
for (const candidate of candidates) {
|
||||
if (candidate.length !== commandPath.length) {
|
||||
continue
|
||||
}
|
||||
const joined = candidate.join(' ')
|
||||
if (seen.has(joined)) {
|
||||
continue
|
||||
}
|
||||
seen.add(joined)
|
||||
scored.push({ label: joined, distance: levenshtein(input, joined) })
|
||||
}
|
||||
}
|
||||
return rankByDistance(scored)
|
||||
}
|
||||
|
||||
export function unknownCommandData(specs: CommandSpec[], commandPath: string[]): CommandErrorData {
|
||||
const suggestions = suggestCommands(specs, commandPath)
|
||||
const nextSteps = suggestions.length
|
||||
? [`Did you mean: ${suggestions.map((path) => `orca ${path}`).join(', ')}`]
|
||||
: []
|
||||
return { suggestions, nextSteps }
|
||||
}
|
||||
|
||||
export type FlagErrorData = {
|
||||
validFlags: string[]
|
||||
suggestions: string[]
|
||||
nextSteps: string[]
|
||||
}
|
||||
|
||||
function suggestFlags(flag: string, validFlags: string[]): string[] {
|
||||
return rankByDistance(
|
||||
validFlags.map((candidate) => ({ label: candidate, distance: levenshtein(flag, candidate) }))
|
||||
)
|
||||
}
|
||||
|
||||
// Why: include the accepted set so agents can recover without another help call.
|
||||
export function unknownFlagData(flag: string, validFlags: string[]): FlagErrorData {
|
||||
const sortedValid = [...validFlags].sort((a, b) => a.localeCompare(b))
|
||||
const suggestions = suggestFlags(flag, sortedValid)
|
||||
const nextSteps: string[] = []
|
||||
if (suggestions.length > 0) {
|
||||
nextSteps.push(`Did you mean: ${suggestions.map((name) => `--${name}`).join(', ')}`)
|
||||
}
|
||||
nextSteps.push(`Valid flags: ${sortedValid.map((name) => `--${name}`).join(', ')}`)
|
||||
return { validFlags: sortedValid, suggestions, nextSteps }
|
||||
}
|
||||
@@ -20,6 +20,7 @@ import { COMPUTER_HANDLERS } from './handlers/computer'
|
||||
import { ENVIRONMENT_HANDLERS } from './handlers/environment'
|
||||
import { AGENT_HOOK_HANDLERS } from './handlers/agent-hooks'
|
||||
import { DIAGNOSTICS_HANDLERS } from './handlers/diagnostics'
|
||||
import { INTROSPECTION_HANDLERS } from './handlers/introspection'
|
||||
import { EMULATOR_HANDLERS } from './handlers/emulator'
|
||||
import { LINEAR_HANDLERS } from './handlers/linear'
|
||||
import { VM_HANDLERS } from './handlers/vm'
|
||||
@@ -57,6 +58,7 @@ function buildHandlers(): Map<string, CommandHandler> {
|
||||
COMPUTER_HANDLERS,
|
||||
AGENT_HOOK_HANDLERS,
|
||||
DIAGNOSTICS_HANDLERS,
|
||||
INTROSPECTION_HANDLERS,
|
||||
ENVIRONMENT_HANDLERS,
|
||||
LINEAR_HANDLERS,
|
||||
VM_HANDLERS
|
||||
@@ -74,6 +76,10 @@ function buildHandlers(): Map<string, CommandHandler> {
|
||||
|
||||
const HANDLERS = buildHandlers()
|
||||
|
||||
// Why: exposes only the canonical command keys (not the handler internals) so the
|
||||
// registry-parity guard can check specs↔handlers without rebuilding the table.
|
||||
export const HANDLER_COMMAND_KEYS: ReadonlySet<string> = new Set(HANDLERS.keys())
|
||||
|
||||
export async function dispatch(commandPath: string[], ctx: HandlerContext): Promise<void> {
|
||||
const handler = HANDLERS.get(commandPath.join(' '))
|
||||
if (!handler) {
|
||||
|
||||
@@ -0,0 +1,60 @@
|
||||
import { describe, expect, it } from 'vitest'
|
||||
|
||||
import { formatCliError } from './format'
|
||||
import { RuntimeClientError, RuntimeRpcFailureError } from './runtime-client'
|
||||
|
||||
describe('CLI error recovery', () => {
|
||||
it('prints did-you-mean next steps for an unknown-command error carrying data', () => {
|
||||
const error = new RuntimeClientError('invalid_argument', 'Unknown command: worktree remov', {
|
||||
suggestions: ['worktree rm'],
|
||||
nextSteps: ['Did you mean: orca worktree rm']
|
||||
})
|
||||
|
||||
const output = formatCliError(error)
|
||||
|
||||
expect(output).toContain('Unknown command: worktree remov')
|
||||
expect(output).toContain('Next step: Did you mean: orca worktree rm')
|
||||
})
|
||||
|
||||
it('prefers structured recovery over generic computer hints in text output', () => {
|
||||
const error = new RuntimeClientError('invalid_argument', 'Unknown flag --forcce', {
|
||||
nextSteps: ['Did you mean: --force']
|
||||
})
|
||||
|
||||
const output = formatCliError(error, { commandPath: ['computer', 'click'] })
|
||||
|
||||
expect(output).toContain('Next step: Did you mean: --force')
|
||||
expect(output).not.toContain('Fix the command flags or RPC params')
|
||||
})
|
||||
|
||||
it('prefers RPC recovery over generic computer hints in text output', () => {
|
||||
const error = new RuntimeRpcFailureError({
|
||||
id: 'req_rpc_recovery',
|
||||
ok: false,
|
||||
error: {
|
||||
code: 'invalid_argument',
|
||||
message: 'Unknown runtime argument',
|
||||
data: { nextSteps: ['Use the runtime-specific option'] }
|
||||
},
|
||||
_meta: { runtimeId: 'runtime_local' }
|
||||
})
|
||||
|
||||
const output = formatCliError(error, { commandPath: ['computer', 'click'] })
|
||||
|
||||
expect(output).toContain('Next step: Use the runtime-specific option')
|
||||
expect(output).not.toContain('Fix the command flags or RPC params')
|
||||
})
|
||||
|
||||
it('keeps generic computer hints when an RPC error has no recovery data', () => {
|
||||
const error = new RuntimeRpcFailureError({
|
||||
id: 'req_rpc_fallback',
|
||||
ok: false,
|
||||
error: { code: 'invalid_argument', message: 'Invalid computer argument' },
|
||||
_meta: { runtimeId: 'runtime_local' }
|
||||
})
|
||||
|
||||
const output = formatCliError(error, { commandPath: ['computer', 'click'] })
|
||||
|
||||
expect(output).toContain('Fix the command flags or RPC params')
|
||||
})
|
||||
})
|
||||
+30
-17
@@ -80,15 +80,18 @@ export function formatCliError(error: unknown, context: CliErrorContext = {}): s
|
||||
if (error instanceof RuntimeClientError && error.code === 'runtime_unavailable') {
|
||||
return `${message}\nOrca is not running. Run 'orca open' first.`
|
||||
}
|
||||
if (
|
||||
error instanceof RuntimeClientError &&
|
||||
error.code === 'invalid_argument' &&
|
||||
context.commandPath?.[0] === 'computer'
|
||||
) {
|
||||
return formatMessageWithNextSteps(
|
||||
message,
|
||||
computerUseErrorRecoveryData('invalid_argument')?.nextSteps ?? []
|
||||
)
|
||||
// Why: error-specific recovery must win over the generic computer fallback.
|
||||
if (error instanceof RuntimeClientError) {
|
||||
const nextSteps = nextStepsFromData(error.data)
|
||||
if (nextSteps.length > 0) {
|
||||
return formatMessageWithNextSteps(message, nextSteps)
|
||||
}
|
||||
if (error.code === 'invalid_argument' && context.commandPath?.[0] === 'computer') {
|
||||
return formatMessageWithNextSteps(
|
||||
message,
|
||||
computerUseErrorRecoveryData('invalid_argument')?.nextSteps ?? []
|
||||
)
|
||||
}
|
||||
}
|
||||
if (
|
||||
error instanceof RuntimeRpcFailureError &&
|
||||
@@ -97,14 +100,7 @@ export function formatCliError(error: unknown, context: CliErrorContext = {}): s
|
||||
return `${message}\nOrca is not running. Run 'orca open' first.`
|
||||
}
|
||||
if (error instanceof RuntimeRpcFailureError) {
|
||||
const data = error.response.error.data
|
||||
const nextSteps =
|
||||
data && typeof data === 'object' && Array.isArray((data as { nextSteps?: unknown }).nextSteps)
|
||||
? (data as { nextSteps: unknown[] }).nextSteps.filter(
|
||||
(step): step is string => typeof step === 'string'
|
||||
)
|
||||
: []
|
||||
return formatMessageWithNextSteps(message, nextSteps)
|
||||
return formatMessageWithNextSteps(message, nextStepsFromData(error.response.error.data))
|
||||
}
|
||||
return message
|
||||
}
|
||||
@@ -140,7 +136,24 @@ function formatMessageWithNextSteps(message: string, nextSteps: readonly string[
|
||||
return `${message}\n${nextSteps.map((step) => `Next step: ${step}`).join('\n')}`
|
||||
}
|
||||
|
||||
function nextStepsFromData(data: unknown): string[] {
|
||||
if (
|
||||
data &&
|
||||
typeof data === 'object' &&
|
||||
Array.isArray((data as { nextSteps?: unknown }).nextSteps)
|
||||
) {
|
||||
return (data as { nextSteps: unknown[] }).nextSteps.filter(
|
||||
(step): step is string => typeof step === 'string'
|
||||
)
|
||||
}
|
||||
return []
|
||||
}
|
||||
|
||||
function localCliErrorData(error: unknown, context: CliErrorContext): unknown {
|
||||
// Why: error-specific recovery must win over the generic computer fallback.
|
||||
if (error instanceof RuntimeClientError && error.data !== undefined) {
|
||||
return error.data
|
||||
}
|
||||
if (
|
||||
error instanceof RuntimeClientError &&
|
||||
error.code === 'invalid_argument' &&
|
||||
|
||||
@@ -0,0 +1,16 @@
|
||||
import type { CommandHandler } from '../dispatch'
|
||||
import { COMMAND_SPECS } from '../specs'
|
||||
import { buildAgentContext, formatAgentContextSummary } from '../agent-context'
|
||||
|
||||
export const INTROSPECTION_HANDLERS: Record<string, CommandHandler> = {
|
||||
// Why: pure local command — reads the static spec registry and prints it, with
|
||||
// no runtime RPC, so it works when the Orca app is not running (SSH/headless).
|
||||
'agent-context': async ({ json }) => {
|
||||
const schema = buildAgentContext(COMMAND_SPECS)
|
||||
if (json) {
|
||||
console.log(JSON.stringify(schema, null, 2))
|
||||
return
|
||||
}
|
||||
console.log(formatAgentContextSummary(schema))
|
||||
}
|
||||
}
|
||||
@@ -147,9 +147,7 @@ export const TERMINAL_HANDLERS: Record<string, CommandHandler> = {
|
||||
})
|
||||
printResult(result, json, formatTerminalCreate)
|
||||
},
|
||||
// Why: `focus` and `switch` are aliases — register the same handler under
|
||||
// both keys so the dispatch table stays a plain command-path lookup.
|
||||
'terminal focus': terminalFocusHandler,
|
||||
// `focus` resolves to this canonical path via CommandSpec.aliases before dispatch.
|
||||
'terminal switch': terminalFocusHandler,
|
||||
'terminal close': async ({ flags, client, cwd, json }) => {
|
||||
const result = await client.call<{ close: RuntimeTerminalClose }>('terminal.close', {
|
||||
|
||||
+14
-4
@@ -1,6 +1,7 @@
|
||||
/* eslint-disable max-lines -- Why: root and generated command help text live together so CLI discovery stays greppable. */
|
||||
import type { CommandSpec } from './args'
|
||||
import { findCommandSpec, isCommandGroup, supportsBrowserPageFlag } from './args'
|
||||
import { unknownCommandData } from './command-suggestion'
|
||||
|
||||
const ROOT_HELP_TEXT = `orca
|
||||
|
||||
@@ -14,6 +15,9 @@ Startup:
|
||||
Diagnostics:
|
||||
diagnostics memory Collect a memory snapshot for Orca and managed terminals
|
||||
|
||||
Agent Discovery:
|
||||
agent-context Print the machine-readable command schema for agents
|
||||
|
||||
Environments:
|
||||
environment add Save a remote Orca runtime from a pairing code
|
||||
environment list List saved remote Orca runtimes
|
||||
@@ -192,6 +196,7 @@ Common Commands:
|
||||
orca serve [--port <port>] [--pairing-address <host>] [--mobile-pairing] [--no-pairing] [--project-root <path>] [--recipe-json] [--json]
|
||||
orca status [--json]
|
||||
orca diagnostics memory [--json]
|
||||
orca agent-context [--json]
|
||||
orca environment add --name <name> --pairing-code <code> [--json]
|
||||
orca environment list [--json]
|
||||
orca environment show --environment <selector> [--json]
|
||||
@@ -339,7 +344,9 @@ export function printHelp(specs: CommandSpec[], commandPath: string[] = []): voi
|
||||
}
|
||||
|
||||
if (commandPath.length > 0) {
|
||||
console.log(`Unknown command: ${commandPath.join(' ')}\n`)
|
||||
const { nextSteps } = unknownCommandData(specs, commandPath)
|
||||
const recovery = nextSteps.map((step) => `Next step: ${step}`).join('\n')
|
||||
console.log(`Unknown command: ${commandPath.join(' ')}${recovery ? `\n${recovery}` : ''}\n`)
|
||||
}
|
||||
|
||||
console.log(ROOT_HELP_TEXT)
|
||||
@@ -347,9 +354,12 @@ export function printHelp(specs: CommandSpec[], commandPath: string[] = []): voi
|
||||
|
||||
export function formatCommandHelp(spec: CommandSpec): string {
|
||||
const lines = [`orca ${spec.path.join(' ')}`, '', `Usage: ${spec.usage}`, '', spec.summary]
|
||||
const displayedFlags = supportsBrowserPageFlag(spec.path)
|
||||
? [...spec.allowedFlags, 'page']
|
||||
: spec.allowedFlags
|
||||
const displayedFlags =
|
||||
spec.argumentMode === 'passthrough'
|
||||
? []
|
||||
: supportsBrowserPageFlag(spec.path)
|
||||
? [...spec.allowedFlags, 'page']
|
||||
: spec.allowedFlags
|
||||
|
||||
if (displayedFlags.length > 0) {
|
||||
lines.push('', 'Options:')
|
||||
|
||||
+181
-6
@@ -6,6 +6,7 @@ import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest'
|
||||
|
||||
const {
|
||||
callMock,
|
||||
runtimeClientConstructorMock,
|
||||
serveOrcaAppMock,
|
||||
getDefaultUserDataPathMock,
|
||||
addEnvironmentFromPairingCodeMock,
|
||||
@@ -13,6 +14,7 @@ const {
|
||||
spawnMock
|
||||
} = vi.hoisted(() => ({
|
||||
callMock: vi.fn(),
|
||||
runtimeClientConstructorMock: vi.fn(),
|
||||
serveOrcaAppMock: vi.fn(),
|
||||
getDefaultUserDataPathMock: vi.fn(() => '/tmp/orca-user-data'),
|
||||
addEnvironmentFromPairingCodeMock: vi.fn(),
|
||||
@@ -33,6 +35,7 @@ vi.mock('./runtime-client', () => {
|
||||
remotePairingCode?: string | null,
|
||||
environmentSelector?: string | null
|
||||
) {
|
||||
runtimeClientConstructorMock()
|
||||
const effectivePairingCode =
|
||||
remotePairingCode === undefined
|
||||
? (process.env.ORCA_PAIRING_CODE ?? process.env.ORCA_REMOTE_PAIRING)
|
||||
@@ -51,10 +54,12 @@ vi.mock('./runtime-client', () => {
|
||||
|
||||
class RuntimeClientError extends Error {
|
||||
readonly code: string
|
||||
readonly data?: unknown
|
||||
|
||||
constructor(code: string, message: string) {
|
||||
constructor(code: string, message: string, data?: unknown) {
|
||||
super(message)
|
||||
this.code = code
|
||||
this.data = data
|
||||
}
|
||||
}
|
||||
|
||||
@@ -111,18 +116,21 @@ import {
|
||||
main,
|
||||
normalizeWorktreeSelector
|
||||
} from './index'
|
||||
import { GLOBAL_FLAGS } from './args'
|
||||
import { GLOBAL_FLAGS, specPaths } from './args'
|
||||
import { RuntimeRpcFailureError } from './runtime-client'
|
||||
import { buildWorktree, okFixture, queueFixtures, worktreeListFixture } from './test-fixtures'
|
||||
import { encodePairingOffer, PAIRING_OFFER_VERSION } from '../shared/pairing'
|
||||
|
||||
describe('COMMAND_SPECS collision check', () => {
|
||||
it('has no duplicate command paths', () => {
|
||||
it('has no duplicate command or alias paths', () => {
|
||||
// Why: first-match resolution would silently shadow duplicate aliases.
|
||||
const seen = new Set<string>()
|
||||
for (const spec of COMMAND_SPECS) {
|
||||
const key = spec.path.join(' ')
|
||||
expect(seen.has(key), `Duplicate COMMAND_SPECS path: "${key}"`).toBe(false)
|
||||
seen.add(key)
|
||||
for (const path of specPaths(spec)) {
|
||||
const key = path.join(' ')
|
||||
expect(seen.has(key), `Duplicate command/alias path: "${key}"`).toBe(false)
|
||||
seen.add(key)
|
||||
}
|
||||
}
|
||||
})
|
||||
|
||||
@@ -141,7 +149,174 @@ describe('COMMAND_SPECS collision check', () => {
|
||||
})
|
||||
})
|
||||
|
||||
describe('command aliases dispatch to the canonical handler', () => {
|
||||
let logSpy: ReturnType<typeof vi.spyOn>
|
||||
|
||||
beforeEach(() => {
|
||||
callMock.mockReset()
|
||||
logSpy = vi.spyOn(console, 'log').mockImplementation(() => {})
|
||||
})
|
||||
|
||||
afterEach(() => {
|
||||
callMock.mockReset()
|
||||
// Why: restore console.log so a downstream describe's vi.spyOn starts from a
|
||||
// clean spy — otherwise this block's --json output leaks into its calls[0].
|
||||
logSpy.mockRestore()
|
||||
})
|
||||
|
||||
it('runs `worktree remove` as the canonical `worktree rm` (the incident)', async () => {
|
||||
queueFixtures(callMock, okFixture('req', { removed: true }))
|
||||
|
||||
await main(['worktree', 'remove', '--worktree', 'id:wt-1', '--force', '--json'], '/tmp/repo')
|
||||
|
||||
expect(callMock).toHaveBeenCalledWith(
|
||||
'worktree.rm',
|
||||
expect.objectContaining({ worktree: 'id:wt-1', force: true })
|
||||
)
|
||||
})
|
||||
|
||||
it('runs `worktree delete` as the canonical `worktree rm`', async () => {
|
||||
queueFixtures(callMock, okFixture('req', { removed: true }))
|
||||
|
||||
await main(['worktree', 'delete', '--worktree', 'id:wt-1', '--json'], '/tmp/repo')
|
||||
|
||||
expect(callMock).toHaveBeenCalledWith(
|
||||
'worktree.rm',
|
||||
expect.objectContaining({ worktree: 'id:wt-1' })
|
||||
)
|
||||
})
|
||||
|
||||
it('still runs `terminal focus` after the handler de-duplication', async () => {
|
||||
queueFixtures(callMock, okFixture('req', { focus: { ok: true } }))
|
||||
|
||||
await main(['terminal', 'focus', '--terminal', 'term_abc', '--json'], '/tmp/repo')
|
||||
|
||||
expect(callMock).toHaveBeenCalledWith('terminal.focus', expect.objectContaining({}))
|
||||
})
|
||||
|
||||
it('serves `agent-context --json` without contacting the runtime', async () => {
|
||||
runtimeClientConstructorMock.mockClear()
|
||||
await main(['agent-context', '--json'], '/tmp/repo')
|
||||
|
||||
// Why: pure local read — proves the SSH/offline property (no RPC).
|
||||
expect(runtimeClientConstructorMock).not.toHaveBeenCalled()
|
||||
expect(callMock).not.toHaveBeenCalled()
|
||||
const schema = JSON.parse(String(logSpy.mock.calls.at(-1)?.[0]))
|
||||
expect(schema.schemaVersion).toBe(1)
|
||||
const rm = schema.commands.find(
|
||||
(command: { command: string }) => command.command === 'worktree rm'
|
||||
)
|
||||
expect(rm.aliases).toContainEqual(['worktree', 'remove'])
|
||||
})
|
||||
|
||||
it('keeps `agent-context` local when remote environment variables are set', async () => {
|
||||
vi.stubEnv('ORCA_PAIRING_CODE', 'pairing-code')
|
||||
vi.stubEnv('ORCA_ENVIRONMENT', 'stale-environment')
|
||||
try {
|
||||
await main(['agent-context', '--json'], '/tmp/repo')
|
||||
|
||||
expect(process.exitCode).not.toBe(1)
|
||||
expect(callMock).not.toHaveBeenCalled()
|
||||
} finally {
|
||||
vi.unstubAllEnvs()
|
||||
}
|
||||
})
|
||||
})
|
||||
|
||||
describe('unknown command surfaces a suggestion', () => {
|
||||
let errorSpy: ReturnType<typeof vi.spyOn>
|
||||
|
||||
beforeEach(() => {
|
||||
callMock.mockReset()
|
||||
errorSpy = vi.spyOn(console, 'error').mockImplementation(() => {})
|
||||
})
|
||||
|
||||
afterEach(() => {
|
||||
errorSpy.mockRestore()
|
||||
process.exitCode = 0
|
||||
})
|
||||
|
||||
it('prints did-you-mean for a near-miss command and exits non-zero', async () => {
|
||||
await main(['worktree', 'remov'], '/tmp/repo')
|
||||
|
||||
expect(process.exitCode).toBe(1)
|
||||
const stderr = errorSpy.mock.calls.map((call) => String(call[0])).join('\n')
|
||||
expect(stderr).toContain('Unknown command: worktree remov')
|
||||
expect(stderr).toContain('orca worktree')
|
||||
})
|
||||
|
||||
it('reports a mistyped pre-command flag without swallowing the command', async () => {
|
||||
await main(['--jso', 'worktree', 'list'], '/tmp/repo')
|
||||
|
||||
expect(process.exitCode).toBe(1)
|
||||
const stderr = errorSpy.mock.calls.map((call) => String(call[0])).join('\n')
|
||||
expect(stderr).toContain('Unknown flag --jso for command: worktree list')
|
||||
expect(stderr).toContain('--json')
|
||||
})
|
||||
|
||||
it('reports a pre-command flag that belongs to another command', async () => {
|
||||
await main(['--workspace', 'worktree', 'list'], '/tmp/repo')
|
||||
|
||||
expect(process.exitCode).toBe(1)
|
||||
const stderr = errorSpy.mock.calls.map((call) => String(call[0])).join('\n')
|
||||
expect(stderr).toContain('Unknown flag --workspace for command: worktree list')
|
||||
})
|
||||
|
||||
it('reports a pre-command typo when a global flag splits the command path', async () => {
|
||||
const logSpy = vi.spyOn(console, 'log').mockImplementation(() => {})
|
||||
|
||||
await main(['--jso', 'worktree', '--json', 'list'], '/tmp/repo')
|
||||
|
||||
expect(process.exitCode).toBe(1)
|
||||
expect(logSpy.mock.calls.flat().join('\n')).toContain(
|
||||
'Unknown flag --jso for command: worktree list'
|
||||
)
|
||||
expect(callMock).not.toHaveBeenCalled()
|
||||
logSpy.mockRestore()
|
||||
})
|
||||
|
||||
it.each(['environment', 'pairing-code'])(
|
||||
'rejects --%s without a selector before runtime construction',
|
||||
async (flag) => {
|
||||
runtimeClientConstructorMock.mockClear()
|
||||
|
||||
await main([`--${flag}`, 'worktree', 'list'], '/tmp/repo')
|
||||
|
||||
expect(process.exitCode).toBe(1)
|
||||
const stderr = errorSpy.mock.calls.map((call) => String(call[0])).join('\n')
|
||||
expect(stderr).toContain(`Flag --${flag} requires a value.`)
|
||||
expect(runtimeClientConstructorMock).not.toHaveBeenCalled()
|
||||
expect(callMock).not.toHaveBeenCalled()
|
||||
}
|
||||
)
|
||||
})
|
||||
|
||||
describe('unknown help command surfaces a suggestion', () => {
|
||||
it.each([
|
||||
['help prefix', ['help', 'worktree', 'remov']],
|
||||
['help flag', ['worktree', 'remov', '--help']]
|
||||
])('prints did-you-mean for the %s form', async (_label, argv) => {
|
||||
const logSpy = vi.spyOn(console, 'log').mockImplementation(() => {})
|
||||
|
||||
await main(argv, '/tmp/repo')
|
||||
|
||||
expect(process.exitCode).toBe(1)
|
||||
expect(logSpy.mock.calls.flat().join('\n')).toContain('Did you mean: orca worktree')
|
||||
logSpy.mockRestore()
|
||||
process.exitCode = 0
|
||||
})
|
||||
})
|
||||
|
||||
describe('orca root help', () => {
|
||||
it('advertises machine-readable agent discovery', async () => {
|
||||
const logSpy = vi.spyOn(console, 'log').mockImplementation(() => {})
|
||||
|
||||
await main([], '/tmp/repo')
|
||||
|
||||
expect(logSpy.mock.calls.flat().join('\n')).toContain('agent-context')
|
||||
logSpy.mockRestore()
|
||||
})
|
||||
|
||||
it('advertises computer-use capabilities discovery', async () => {
|
||||
const logSpy = vi.spyOn(console, 'log').mockImplementation(() => {})
|
||||
|
||||
|
||||
+21
-13
@@ -5,6 +5,7 @@ import {
|
||||
normalizeCommandPositionals,
|
||||
parseArgs,
|
||||
resolveHelpPath,
|
||||
specPaths,
|
||||
validateCommandAndFlags
|
||||
} from './args'
|
||||
import { dispatch } from './dispatch'
|
||||
@@ -16,12 +17,15 @@ import { COMMAND_SPECS } from './specs'
|
||||
export { COMMAND_SPECS } from './specs'
|
||||
export { buildCurrentWorktreeSelector, normalizeWorktreeSelector } from './selectors'
|
||||
|
||||
const COMMAND_PATHS = COMMAND_SPECS.flatMap((spec) => specPaths(spec))
|
||||
|
||||
function shouldIgnoreRemoteSelection(commandPath: string[]): boolean {
|
||||
return (
|
||||
commandPath[0] === 'environment' ||
|
||||
commandPath[0] === 'serve' ||
|
||||
commandPath[0] === 'agent' ||
|
||||
commandPath[0] === 'vm'
|
||||
commandPath[0] === 'vm' ||
|
||||
commandPath[0] === 'agent-context'
|
||||
)
|
||||
}
|
||||
|
||||
@@ -46,7 +50,7 @@ export async function main(
|
||||
await runClaudeTeams(argv.slice(1), cwd)
|
||||
return
|
||||
}
|
||||
const parsed = normalizeCommandPositionals(COMMAND_SPECS, parseArgs(argv))
|
||||
const parsed = normalizeCommandPositionals(COMMAND_SPECS, parseArgs(argv, COMMAND_PATHS))
|
||||
const helpPath = resolveHelpPath(parsed)
|
||||
if (helpPath !== null) {
|
||||
printHelp(COMMAND_SPECS, helpPath)
|
||||
@@ -77,19 +81,23 @@ export async function main(
|
||||
// so the RuntimeClient default parameter does not re-activate the
|
||||
// ORCA_PAIRING_CODE / ORCA_ENVIRONMENT env-var fallback for commands
|
||||
// that must run locally (environment / serve).
|
||||
const client = new RuntimeClient(
|
||||
undefined,
|
||||
undefined,
|
||||
typeof pairingCode === 'string' ? pairingCode : ignoreRemoteSelection ? null : undefined,
|
||||
typeof environmentSelector === 'string'
|
||||
? environmentSelector
|
||||
: ignoreRemoteSelection
|
||||
? null
|
||||
: undefined
|
||||
)
|
||||
let client: RuntimeClient | undefined
|
||||
await dispatch(parsed.commandPath, {
|
||||
flags: parsed.flags,
|
||||
client,
|
||||
// Why: local-only handlers must not resolve runtime metadata just to dispatch.
|
||||
get client() {
|
||||
client ??= new RuntimeClient(
|
||||
undefined,
|
||||
undefined,
|
||||
typeof pairingCode === 'string' ? pairingCode : ignoreRemoteSelection ? null : undefined,
|
||||
typeof environmentSelector === 'string'
|
||||
? environmentSelector
|
||||
: ignoreRemoteSelection
|
||||
? null
|
||||
: undefined
|
||||
)
|
||||
return client
|
||||
},
|
||||
cwd,
|
||||
json
|
||||
})
|
||||
|
||||
@@ -0,0 +1,46 @@
|
||||
import { describe, expect, it } from 'vitest'
|
||||
|
||||
import type { CommandSpec } from './args'
|
||||
import { HANDLER_COMMAND_KEYS } from './dispatch'
|
||||
import { findRegistryParityGaps } from './registry-parity'
|
||||
import { COMMAND_SPECS } from './specs'
|
||||
|
||||
describe('registry parity (live tree)', () => {
|
||||
const handlerKeys = [...HANDLER_COMMAND_KEYS]
|
||||
|
||||
it('has a handler for every canonical spec path', () => {
|
||||
const { specsWithoutHandler } = findRegistryParityGaps(COMMAND_SPECS, handlerKeys)
|
||||
expect(specsWithoutHandler).toEqual([])
|
||||
})
|
||||
|
||||
it('has a spec for every handler key', () => {
|
||||
const { handlersWithoutSpec } = findRegistryParityGaps(COMMAND_SPECS, handlerKeys)
|
||||
expect(handlersWithoutSpec).toEqual([])
|
||||
})
|
||||
})
|
||||
|
||||
describe('findRegistryParityGaps (fixtures)', () => {
|
||||
const spec = (path: string[], aliases?: string[][]): CommandSpec => ({
|
||||
path,
|
||||
aliases,
|
||||
summary: 's',
|
||||
usage: 'u',
|
||||
allowedFlags: []
|
||||
})
|
||||
|
||||
it('flags a spec with no handler', () => {
|
||||
const gaps = findRegistryParityGaps([spec(['foo', 'bar'])], [])
|
||||
expect(gaps.specsWithoutHandler).toEqual(['foo bar'])
|
||||
})
|
||||
|
||||
it('flags a handler with no spec', () => {
|
||||
const gaps = findRegistryParityGaps([spec(['foo', 'bar'])], ['foo bar', 'ghost cmd'])
|
||||
expect(gaps.handlersWithoutSpec).toEqual(['ghost cmd'])
|
||||
})
|
||||
|
||||
it('does not require a handler for an alias path', () => {
|
||||
const gaps = findRegistryParityGaps([spec(['foo', 'rm'], [['foo', 'remove']])], ['foo rm'])
|
||||
expect(gaps.specsWithoutHandler).toEqual([])
|
||||
expect(gaps.handlersWithoutSpec).toEqual([])
|
||||
})
|
||||
})
|
||||
@@ -0,0 +1,21 @@
|
||||
import type { CommandSpec } from './args'
|
||||
|
||||
// Why: validation/help and dispatch use parallel registries that must not drift.
|
||||
|
||||
export type RegistryParityGaps = {
|
||||
handlersWithoutSpec: string[]
|
||||
specsWithoutHandler: string[]
|
||||
}
|
||||
|
||||
export function findRegistryParityGaps(
|
||||
specs: CommandSpec[],
|
||||
handlerKeys: Iterable<string>
|
||||
): RegistryParityGaps {
|
||||
// Why: aliases resolve before dispatch and deliberately have no handler key.
|
||||
const canonical = new Set(specs.map((spec) => spec.path.join(' ')))
|
||||
const handlers = new Set(handlerKeys)
|
||||
return {
|
||||
handlersWithoutSpec: [...handlers].filter((key) => !canonical.has(key)),
|
||||
specsWithoutHandler: [...canonical].filter((key) => !handlers.has(key))
|
||||
}
|
||||
}
|
||||
@@ -12,6 +12,26 @@ describe('RuntimeRpcEnvelopeSchema', () => {
|
||||
expect(parsed.success).toBe(true)
|
||||
})
|
||||
|
||||
it('accepts and strips additive future success fields', () => {
|
||||
const parsed = RuntimeRpcEnvelopeSchema.safeParse({
|
||||
id: 'req-1',
|
||||
ok: true,
|
||||
result: { value: 1, futureResultField: true },
|
||||
futureEnvelopeField: 'new',
|
||||
_meta: { runtimeId: 'runtime-1', futureMetaField: 2 }
|
||||
})
|
||||
|
||||
expect(parsed).toEqual({
|
||||
success: true,
|
||||
data: {
|
||||
id: 'req-1',
|
||||
ok: true,
|
||||
result: { value: 1, futureResultField: true },
|
||||
_meta: { runtimeId: 'runtime-1' }
|
||||
}
|
||||
})
|
||||
})
|
||||
|
||||
it('accepts a well-formed failure envelope', () => {
|
||||
const parsed = RuntimeRpcEnvelopeSchema.safeParse({
|
||||
id: 'req-1',
|
||||
@@ -39,6 +59,31 @@ describe('RuntimeRpcEnvelopeSchema', () => {
|
||||
expect(parsed.success).toBe(true)
|
||||
})
|
||||
|
||||
it('accepts and strips additive future failure fields', () => {
|
||||
const parsed = RuntimeRpcEnvelopeSchema.safeParse({
|
||||
id: 'req-1',
|
||||
ok: false,
|
||||
futureEnvelopeField: 'new',
|
||||
error: {
|
||||
code: 'failed',
|
||||
message: 'Nope',
|
||||
data: { retryable: true },
|
||||
futureErrorField: 2
|
||||
},
|
||||
_meta: { runtimeId: 'runtime-1', futureMetaField: 3 }
|
||||
})
|
||||
|
||||
expect(parsed).toEqual({
|
||||
success: true,
|
||||
data: {
|
||||
id: 'req-1',
|
||||
ok: false,
|
||||
error: { code: 'failed', message: 'Nope', data: { retryable: true } },
|
||||
_meta: { runtimeId: 'runtime-1' }
|
||||
}
|
||||
})
|
||||
})
|
||||
|
||||
it('accepts a failure envelope without _meta', () => {
|
||||
// Why: the runtime may fail before it has resolved its own runtimeId, in
|
||||
// which case _meta is omitted. The schema must tolerate that rather than
|
||||
@@ -70,6 +115,17 @@ describe('RuntimeRpcEnvelopeSchema', () => {
|
||||
expect(parsed.success).toBe(false)
|
||||
})
|
||||
|
||||
it('still rejects malformed known fields when future fields are present', () => {
|
||||
const parsed = RuntimeRpcEnvelopeSchema.safeParse({
|
||||
id: 'req-1',
|
||||
ok: false,
|
||||
error: { code: 500, message: 'Nope', futureErrorField: true },
|
||||
futureEnvelopeField: 'new'
|
||||
})
|
||||
|
||||
expect(parsed.success).toBe(false)
|
||||
})
|
||||
|
||||
it('rejects a frame with unrelated fields only', () => {
|
||||
const parsed = RuntimeRpcEnvelopeSchema.safeParse({ hello: 'world' })
|
||||
expect(parsed.success).toBe(false)
|
||||
|
||||
@@ -8,10 +8,14 @@ export type {
|
||||
|
||||
export class RuntimeClientError extends Error {
|
||||
readonly code: string
|
||||
// Why: optional structured recovery payload (e.g. did-you-mean suggestions,
|
||||
// valid-flag enumeration) surfaced into both the human and --json error output.
|
||||
readonly data?: unknown
|
||||
|
||||
constructor(code: string, message: string) {
|
||||
constructor(code: string, message: string, data?: unknown) {
|
||||
super(message)
|
||||
this.code = code
|
||||
this.data = data
|
||||
}
|
||||
}
|
||||
|
||||
@@ -19,7 +23,8 @@ export class RuntimeRpcFailureError extends RuntimeClientError {
|
||||
readonly response: RuntimeRpcFailure
|
||||
|
||||
constructor(response: RuntimeRpcFailure) {
|
||||
super(response.error.code, response.error.message)
|
||||
// Why: all client errors expose recovery through the same inherited channel.
|
||||
super(response.error.code, response.error.message, response.error.data)
|
||||
this.response = response
|
||||
}
|
||||
}
|
||||
|
||||
+10
-7
@@ -20,6 +20,7 @@ export const CORE_COMMAND_SPECS: CommandSpec[] = [
|
||||
},
|
||||
{
|
||||
path: ['claude-teams'],
|
||||
argumentMode: 'passthrough',
|
||||
summary: 'Start Claude Code Agent Teams in the current Orca terminal',
|
||||
usage: 'orca claude-teams [claude args...]',
|
||||
allowedFlags: [...GLOBAL_FLAGS],
|
||||
@@ -157,6 +158,12 @@ export const CORE_COMMAND_SPECS: CommandSpec[] = [
|
||||
},
|
||||
{
|
||||
path: ['worktree', 'rm'],
|
||||
// Why: agents reach for git's `remove`/`delete` verbs; accept them as
|
||||
// aliases so a conventional guess resolves instead of dead-ending.
|
||||
aliases: [
|
||||
['worktree', 'remove'],
|
||||
['worktree', 'delete']
|
||||
],
|
||||
summary: 'Remove a worktree from Orca and git',
|
||||
usage: 'orca worktree rm --worktree <selector> [--force] [--run-hooks] [--json]',
|
||||
allowedFlags: [...GLOBAL_FLAGS, 'worktree', 'force', 'run-hooks'],
|
||||
@@ -235,18 +242,14 @@ export const CORE_COMMAND_SPECS: CommandSpec[] = [
|
||||
},
|
||||
{
|
||||
path: ['terminal', 'switch'],
|
||||
// Why: `focus` is the legacy verb for this action; keep it working as an
|
||||
// alias rather than a duplicate spec + handler registration.
|
||||
aliases: [['terminal', 'focus']],
|
||||
summary: 'Switch to a terminal tab in the UI',
|
||||
usage: 'orca terminal switch [--terminal <handle>] [--json]',
|
||||
allowedFlags: [...GLOBAL_FLAGS, 'terminal'],
|
||||
examples: ['orca terminal switch --terminal term_abc123']
|
||||
},
|
||||
{
|
||||
path: ['terminal', 'focus'],
|
||||
summary: 'Switch to a terminal tab in the UI (alias for terminal switch)',
|
||||
usage: 'orca terminal focus [--terminal <handle>] [--json]',
|
||||
allowedFlags: [...GLOBAL_FLAGS, 'terminal'],
|
||||
examples: ['orca terminal focus --terminal term_abc123']
|
||||
},
|
||||
{
|
||||
path: ['terminal', 'close'],
|
||||
summary: 'Close a terminal tab (kills PTY if running)',
|
||||
|
||||
@@ -11,6 +11,7 @@ import { ENVIRONMENT_COMMAND_SPECS } from './environment'
|
||||
import { AGENT_HOOK_COMMAND_SPECS } from './agent-hooks'
|
||||
import { DIAGNOSTICS_COMMAND_SPECS } from './diagnostics'
|
||||
import { EMULATOR_COMMAND_SPECS } from './emulator'
|
||||
import { INTROSPECTION_COMMAND_SPECS } from './introspection'
|
||||
import { LINEAR_COMMAND_SPECS } from './linear'
|
||||
import { VM_COMMAND_SPECS } from './vm'
|
||||
|
||||
@@ -25,6 +26,7 @@ export const COMMAND_SPECS: CommandSpec[] = [
|
||||
...COMPUTER_COMMAND_SPECS,
|
||||
...AGENT_HOOK_COMMAND_SPECS,
|
||||
...DIAGNOSTICS_COMMAND_SPECS,
|
||||
...INTROSPECTION_COMMAND_SPECS,
|
||||
...ENVIRONMENT_COMMAND_SPECS,
|
||||
...LINEAR_COMMAND_SPECS,
|
||||
...VM_COMMAND_SPECS,
|
||||
|
||||
@@ -0,0 +1,15 @@
|
||||
import type { CommandSpec } from '../args'
|
||||
import { GLOBAL_FLAGS } from '../args'
|
||||
|
||||
export const INTROSPECTION_COMMAND_SPECS: CommandSpec[] = [
|
||||
{
|
||||
path: ['agent-context'],
|
||||
summary: 'Print the machine-readable command schema for agents',
|
||||
usage: 'orca agent-context [--json]',
|
||||
allowedFlags: [...GLOBAL_FLAGS],
|
||||
notes: [
|
||||
'Pure local read of the command registry — works without a running Orca app, so it is safe over SSH and in headless contexts.'
|
||||
],
|
||||
examples: ['orca agent-context --json']
|
||||
}
|
||||
]
|
||||
@@ -0,0 +1,53 @@
|
||||
import { describe, expect, it } from 'vitest'
|
||||
|
||||
import type { CommandSpec } from './args'
|
||||
import { COMMAND_SPECS } from './specs'
|
||||
import { findVocabularyViolations } from './vocabulary-policy'
|
||||
|
||||
const spec = (path: string[], aliases?: string[][]): CommandSpec => ({
|
||||
path,
|
||||
aliases,
|
||||
summary: 's',
|
||||
usage: 'u',
|
||||
allowedFlags: []
|
||||
})
|
||||
|
||||
describe('vocabulary policy (live tree)', () => {
|
||||
it('has no off-policy verbs that are not grandfathered or alias-bridged', () => {
|
||||
expect(findVocabularyViolations(COMMAND_SPECS)).toEqual([])
|
||||
})
|
||||
})
|
||||
|
||||
describe('findVocabularyViolations (fixtures)', () => {
|
||||
it('flags a new deletion command using an off-policy verb', () => {
|
||||
const violations = findVocabularyViolations([spec(['gadget', 'delete'])])
|
||||
expect(violations).toHaveLength(1)
|
||||
expect(violations[0]).toMatchObject({ command: 'gadget delete', canonical: 'rm' })
|
||||
})
|
||||
|
||||
it('passes a deletion command that bridges to the canonical verb via an alias', () => {
|
||||
const violations = findVocabularyViolations([spec(['gadget', 'delete'], [['gadget', 'rm']])])
|
||||
expect(violations).toEqual([])
|
||||
})
|
||||
|
||||
it('rejects a canonical verb alias under an unrelated command prefix', () => {
|
||||
const violations = findVocabularyViolations([spec(['gadget', 'delete'], [['other', 'rm']])])
|
||||
expect(violations).toHaveLength(1)
|
||||
})
|
||||
|
||||
it('rejects a canonical verb alias at a different command depth', () => {
|
||||
const violations = findVocabularyViolations([
|
||||
spec(['gadget', 'delete'], [['gadget', 'nested', 'rm']])
|
||||
])
|
||||
expect(violations).toHaveLength(1)
|
||||
})
|
||||
|
||||
it('passes the canonical deletion verb outright', () => {
|
||||
expect(findVocabularyViolations([spec(['gadget', 'rm'])])).toEqual([])
|
||||
})
|
||||
|
||||
it('flags a new single-item read using get instead of show', () => {
|
||||
const violations = findVocabularyViolations([spec(['gadget', 'get'])])
|
||||
expect(violations[0]).toMatchObject({ verb: 'get', canonical: 'show' })
|
||||
})
|
||||
})
|
||||
@@ -0,0 +1,73 @@
|
||||
import type { CommandSpec } from './args'
|
||||
|
||||
// Why: predictable verbs prevent failed agent guesses; existing divergences stay
|
||||
// grandfathered because renaming them would break compatibility.
|
||||
|
||||
type VerbFamily = {
|
||||
name: string
|
||||
offPolicyVerbs: Set<string>
|
||||
canonical: string
|
||||
allowlist: Set<string>
|
||||
}
|
||||
|
||||
const FAMILIES: VerbFamily[] = [
|
||||
{
|
||||
name: 'deletion',
|
||||
offPolicyVerbs: new Set(['remove', 'delete', 'destroy']),
|
||||
canonical: 'rm',
|
||||
allowlist: new Set([
|
||||
'cookie delete',
|
||||
'tab profile delete',
|
||||
'automations remove',
|
||||
'linear label remove'
|
||||
])
|
||||
},
|
||||
{
|
||||
name: 'single-item read',
|
||||
offPolicyVerbs: new Set(['get']),
|
||||
canonical: 'show',
|
||||
allowlist: new Set(['get', 'cookie get', 'storage local get', 'storage session get'])
|
||||
}
|
||||
]
|
||||
|
||||
export type VocabularyViolation = {
|
||||
command: string
|
||||
verb: string
|
||||
family: string
|
||||
canonical: string
|
||||
}
|
||||
|
||||
function terminalVerb(path: string[]): string {
|
||||
return path.at(-1) ?? ''
|
||||
}
|
||||
|
||||
function hasCanonicalAlias(spec: CommandSpec, canonical: string): boolean {
|
||||
const expected = [...spec.path.slice(0, -1), canonical]
|
||||
return (spec.aliases ?? []).some(
|
||||
(alias) =>
|
||||
alias.length === expected.length && alias.every((part, index) => part === expected[index])
|
||||
)
|
||||
}
|
||||
|
||||
export function findVocabularyViolations(specs: CommandSpec[]): VocabularyViolation[] {
|
||||
const violations: VocabularyViolation[] = []
|
||||
for (const spec of specs) {
|
||||
const command = spec.path.join(' ')
|
||||
const verb = terminalVerb(spec.path)
|
||||
for (const family of FAMILIES) {
|
||||
if (!family.offPolicyVerbs.has(verb)) {
|
||||
continue
|
||||
}
|
||||
if (family.allowlist.has(command)) {
|
||||
continue
|
||||
}
|
||||
// Why: an alias on the canonical verb makes the command reachable the
|
||||
// canonical way, which satisfies the policy without a rename.
|
||||
if (hasCanonicalAlias(spec, family.canonical)) {
|
||||
continue
|
||||
}
|
||||
violations.push({ command, verb, family: family.name, canonical: family.canonical })
|
||||
}
|
||||
}
|
||||
return violations
|
||||
}
|
||||
@@ -538,6 +538,62 @@ describe('AutomationService', () => {
|
||||
expect(getAutomationRunUsage).toHaveBeenCalledTimes(1)
|
||||
})
|
||||
|
||||
it('does not throw when retention evicts the run during usage collection', async () => {
|
||||
vi.setSystemTime(new Date('2026-05-13T10:00:00'))
|
||||
const store = await createStore()
|
||||
store.addRepo(makeRepo())
|
||||
const automation = store.createAutomation({
|
||||
name: 'Costed check',
|
||||
prompt: 'Check spend',
|
||||
agentId: 'claude',
|
||||
projectId: 'r1',
|
||||
workspaceMode: 'existing',
|
||||
workspaceId: 'wt1',
|
||||
timezone: 'UTC',
|
||||
rrule: 'FREQ=DAILY;BYHOUR=9;BYMINUTE=0',
|
||||
dtstart: new Date('2026-05-13T00:00:00Z').getTime()
|
||||
})
|
||||
const base = Date.now()
|
||||
const run = store.createAutomationRun(automation, base, 'manual')
|
||||
store.updateAutomationRun({
|
||||
runId: run.id,
|
||||
status: 'dispatched',
|
||||
workspaceId: 'wt1',
|
||||
terminalSessionId: 'tab-1',
|
||||
error: null
|
||||
})
|
||||
// While usage collection is awaited the run is already final, so a
|
||||
// scheduler tick creating newer runs can prune it away mid-flight.
|
||||
const getAutomationRunUsage = vi.fn().mockImplementation(async () => {
|
||||
for (let i = 1; i <= 120; i++) {
|
||||
const later = store.createAutomationRun(automation, base + i * 60_000)
|
||||
store.updateAutomationRun({
|
||||
runId: later.id,
|
||||
status: 'completed',
|
||||
workspaceId: 'wt1',
|
||||
error: null
|
||||
})
|
||||
}
|
||||
return null
|
||||
})
|
||||
const service = new AutomationService(store, {
|
||||
tickMs: 60_000,
|
||||
claudeUsage: { getAutomationRunUsage } as never
|
||||
})
|
||||
|
||||
const updated = await service.markDispatchResult({
|
||||
runId: run.id,
|
||||
status: 'completed',
|
||||
workspaceId: 'wt1',
|
||||
terminalSessionId: 'tab-1',
|
||||
error: null
|
||||
})
|
||||
|
||||
expect(updated.id).toBe(run.id)
|
||||
// The run really was evicted; the usage write was skipped instead of throwing.
|
||||
expect(store.listAutomationRuns(automation.id).some((entry) => entry.id === run.id)).toBe(false)
|
||||
})
|
||||
|
||||
it('records unsupported usage cleanly for completed agents without local usage stores', async () => {
|
||||
vi.setSystemTime(new Date('2026-05-13T10:00:00'))
|
||||
const store = await createStore()
|
||||
|
||||
@@ -1,12 +1,12 @@
|
||||
import type { WebContents } from 'electron'
|
||||
import type { Store } from '../persistence'
|
||||
import type {
|
||||
Automation,
|
||||
AutomationDispatchRequest,
|
||||
AutomationDispatchResult,
|
||||
AutomationPrecheckResult,
|
||||
AutomationRun,
|
||||
AutomationRunStatus
|
||||
import {
|
||||
isFinalAutomationRunStatus,
|
||||
type Automation,
|
||||
type AutomationDispatchRequest,
|
||||
type AutomationDispatchResult,
|
||||
type AutomationPrecheckResult,
|
||||
type AutomationRun
|
||||
} from '../../shared/automations-types'
|
||||
import type { ClaudeUsageStore } from '../claude-usage/store'
|
||||
import type { CodexUsageStore } from '../codex-usage/store'
|
||||
@@ -135,7 +135,7 @@ export class AutomationService {
|
||||
async markDispatchResult(result: AutomationDispatchResult): Promise<AutomationRun> {
|
||||
const run = this.store.updateAutomationRun(result)
|
||||
clearAutomationDispatchTokens(run.automationId, run.id)
|
||||
if (!isFinalRunStatus(run.status)) {
|
||||
if (!isFinalAutomationRunStatus(run.status)) {
|
||||
return run
|
||||
}
|
||||
// Why: the renderer's mark-completed effect can re-fire for the same run
|
||||
@@ -151,6 +151,11 @@ export class AutomationService {
|
||||
claudeUsage: this.claudeUsage,
|
||||
codexUsage: this.codexUsage
|
||||
})
|
||||
// Why: the run is final during the await above, so a concurrent create-time
|
||||
// retention prune may have evicted it — the usage write must not throw then.
|
||||
if (!this.store.listAutomationRuns(run.automationId).some((entry) => entry.id === run.id)) {
|
||||
return run
|
||||
}
|
||||
return this.store.updateAutomationRun({
|
||||
runId: run.id,
|
||||
status: run.status,
|
||||
@@ -309,14 +314,3 @@ export class AutomationService {
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
function isFinalRunStatus(status: AutomationRunStatus): boolean {
|
||||
return (
|
||||
status === 'completed' ||
|
||||
status === 'dispatch_failed' ||
|
||||
status === 'skipped_precheck' ||
|
||||
status === 'skipped_missed' ||
|
||||
status === 'skipped_unavailable' ||
|
||||
status === 'skipped_needs_interactive_auth'
|
||||
)
|
||||
}
|
||||
|
||||
@@ -1,6 +1,7 @@
|
||||
import { beforeEach, describe, expect, it, vi } from 'vitest'
|
||||
import { checkIgnoredPaths } from './check-ignored-paths'
|
||||
import { gitExecFileAsync } from './runner'
|
||||
import { GIT_CHECK_IGNORE_TIMEOUT_MS } from '../../shared/git-check-ignore-stdio'
|
||||
|
||||
vi.mock('./runner', () => ({
|
||||
gitExecFileAsync: vi.fn()
|
||||
@@ -14,29 +15,43 @@ describe('checkIgnoredPaths', () => {
|
||||
})
|
||||
|
||||
it('returns ignored paths from git check-ignore output', async () => {
|
||||
gitExecFileAsyncMock.mockResolvedValue({ stdout: 'dist/bundle.js\n.env\n', stderr: '' })
|
||||
gitExecFileAsyncMock.mockResolvedValue({ stdout: 'dist/bundle.js\0.env\0', stderr: '' })
|
||||
|
||||
await expect(
|
||||
checkIgnoredPaths('/repo', ['dist/bundle.js', 'src/index.ts', '.env'])
|
||||
).resolves.toEqual(['dist/bundle.js', '.env'])
|
||||
|
||||
expect(gitExecFileAsyncMock).toHaveBeenCalledWith(
|
||||
[
|
||||
'-c',
|
||||
'core.quotePath=false',
|
||||
'check-ignore',
|
||||
'--',
|
||||
'dist/bundle.js',
|
||||
'src/index.ts',
|
||||
'.env'
|
||||
],
|
||||
{ cwd: '/repo' }
|
||||
['-c', 'core.quotePath=false', 'check-ignore', '-z', '--stdin'],
|
||||
{
|
||||
cwd: '/repo',
|
||||
stdin: 'dist/bundle.js\0src/index.ts\0.env\0',
|
||||
timeout: GIT_CHECK_IGNORE_TIMEOUT_MS
|
||||
}
|
||||
)
|
||||
})
|
||||
|
||||
it('checks ten thousand paths with one bounded subprocess', async () => {
|
||||
gitExecFileAsyncMock.mockResolvedValue({ stdout: '', stderr: '' })
|
||||
const paths = Array.from({ length: 10_000 }, (_, index) => `generated/file-${index}.js`)
|
||||
|
||||
await expect(checkIgnoredPaths('/repo', paths)).resolves.toEqual([])
|
||||
|
||||
expect(gitExecFileAsyncMock).toHaveBeenCalledTimes(1)
|
||||
expect(gitExecFileAsyncMock.mock.calls[0]?.[1]).toMatchObject({
|
||||
timeout: GIT_CHECK_IGNORE_TIMEOUT_MS
|
||||
})
|
||||
expect(gitExecFileAsyncMock.mock.calls[0]?.[1].stdin?.split('\0')).toHaveLength(10_001)
|
||||
})
|
||||
|
||||
it('treats exit code 1 as no ignored paths', async () => {
|
||||
gitExecFileAsyncMock.mockRejectedValue(Object.assign(new Error('no matches'), { code: 1 }))
|
||||
|
||||
await expect(checkIgnoredPaths('/repo', ['src/index.ts'])).resolves.toEqual([])
|
||||
})
|
||||
|
||||
it('skips the subprocess for an empty path list', async () => {
|
||||
await expect(checkIgnoredPaths('/repo', [])).resolves.toEqual([])
|
||||
expect(gitExecFileAsyncMock).not.toHaveBeenCalled()
|
||||
})
|
||||
})
|
||||
|
||||
@@ -1,30 +1,32 @@
|
||||
import type { GitRuntimeOptions } from './git-runtime-options'
|
||||
import { gitOptionsForWorktree } from './git-runtime-options'
|
||||
import { gitExecFileAsync } from './runner'
|
||||
|
||||
const CHECK_IGNORE_CHUNK_SIZE = 100
|
||||
import {
|
||||
encodeGitCheckIgnorePaths,
|
||||
GIT_CHECK_IGNORE_STDIN_ARGS,
|
||||
GIT_CHECK_IGNORE_TIMEOUT_MS,
|
||||
parseGitCheckIgnorePaths,
|
||||
splitGitCheckIgnorePathsByStdinBytes
|
||||
} from '../../shared/git-check-ignore-stdio'
|
||||
|
||||
type GitExecError = Error & { stdout?: string; code?: number | string }
|
||||
|
||||
function parseCheckIgnoreOutput(stdout: string): string[] {
|
||||
return stdout.split(/\r?\n/).filter(Boolean)
|
||||
}
|
||||
|
||||
async function runCheckIgnoreChunk(
|
||||
async function runCheckIgnoredPaths(
|
||||
worktreePath: string,
|
||||
relativePaths: string[],
|
||||
options: GitRuntimeOptions = {}
|
||||
options: GitRuntimeOptions
|
||||
): Promise<string[]> {
|
||||
try {
|
||||
const { stdout } = await gitExecFileAsync(
|
||||
['-c', 'core.quotePath=false', 'check-ignore', '--', ...relativePaths],
|
||||
gitOptionsForWorktree(worktreePath, options)
|
||||
)
|
||||
return parseCheckIgnoreOutput(stdout)
|
||||
const { stdout } = await gitExecFileAsync([...GIT_CHECK_IGNORE_STDIN_ARGS], {
|
||||
...gitOptionsForWorktree(worktreePath, options),
|
||||
stdin: encodeGitCheckIgnorePaths(relativePaths),
|
||||
timeout: GIT_CHECK_IGNORE_TIMEOUT_MS
|
||||
})
|
||||
return parseGitCheckIgnorePaths(stdout)
|
||||
} catch (error) {
|
||||
const gitError = error as GitExecError
|
||||
if (gitError.code === 1) {
|
||||
return parseCheckIgnoreOutput(gitError.stdout ?? '')
|
||||
return parseGitCheckIgnorePaths(gitError.stdout ?? '')
|
||||
}
|
||||
throw error
|
||||
}
|
||||
@@ -35,10 +37,12 @@ export async function checkIgnoredPaths(
|
||||
relativePaths: string[],
|
||||
options: GitRuntimeOptions = {}
|
||||
): Promise<string[]> {
|
||||
if (relativePaths.length === 0) {
|
||||
return []
|
||||
}
|
||||
const ignored = new Set<string>()
|
||||
for (let i = 0; i < relativePaths.length; i += CHECK_IGNORE_CHUNK_SIZE) {
|
||||
const chunk = relativePaths.slice(i, i + CHECK_IGNORE_CHUNK_SIZE)
|
||||
for (const ignoredPath of await runCheckIgnoreChunk(worktreePath, chunk, options)) {
|
||||
for (const chunk of splitGitCheckIgnorePathsByStdinBytes(relativePaths)) {
|
||||
for (const ignoredPath of await runCheckIgnoredPaths(worktreePath, chunk, options)) {
|
||||
ignored.add(ignoredPath)
|
||||
}
|
||||
}
|
||||
|
||||
@@ -37,6 +37,7 @@ import {
|
||||
quotePosixShell
|
||||
} from '../../shared/wsl-login-shell-command'
|
||||
import { UNTRANSLATED_GIT_OUTPUT_ENV } from '../../shared/git-output-locale'
|
||||
import { endSubprocessStdin } from '../../shared/subprocess-stdin-write'
|
||||
|
||||
// ─── Core resolution ────────────────────────────────────────────────
|
||||
|
||||
@@ -431,7 +432,7 @@ function execFileCapture(
|
||||
child.once('error', (error) => finish(error))
|
||||
|
||||
if (options.stdin !== undefined) {
|
||||
child.stdin?.end(options.stdin)
|
||||
endSubprocessStdin(child.stdin, options.stdin)
|
||||
}
|
||||
|
||||
// Why: Node's native execFile timeout waits for the child to exit after
|
||||
|
||||
@@ -32,9 +32,9 @@ vi.mock('../daemon/daemon-init', () => ({
|
||||
getDaemonProvider: () => getDaemonProviderMock()
|
||||
}))
|
||||
|
||||
// Why: the hydrator builds its worktreeId → connectionId map by calling
|
||||
// listRepoWorktrees(repo) for every repo in the store. The git I/O is
|
||||
// out of scope for this unit; mock returns whatever the test wants.
|
||||
// Why: the hydrator builds its worktreeId → connectionId map through
|
||||
// listRepoWorktrees(repo). The git I/O is out of scope for this unit; the mock
|
||||
// also lets count tests prove repos without live sessions launch no Git work.
|
||||
const listRepoWorktreesMock = vi.fn()
|
||||
vi.mock('../repo-worktrees', () => ({
|
||||
listRepoWorktrees: (repo: unknown) => listRepoWorktreesMock(repo)
|
||||
@@ -79,6 +79,17 @@ function makeLocalSessions(repoId: string, worktreePath: string, count: number):
|
||||
return sessions
|
||||
}
|
||||
|
||||
function createDeferred<T>(): {
|
||||
promise: Promise<T>
|
||||
resolve: (value: T) => void
|
||||
} {
|
||||
let resolve!: (value: T) => void
|
||||
const promise = new Promise<T>((resolver) => {
|
||||
resolve = resolver
|
||||
})
|
||||
return { promise, resolve }
|
||||
}
|
||||
|
||||
// Why: the module under test memoizes `hasHydrated` at module scope so it
|
||||
// only runs the git/RPC pass once per process. The pty-registry module
|
||||
// also stashes state in a module-level Map, so we have to load BOTH
|
||||
@@ -127,6 +138,7 @@ describe('hydrateLocalPtyRegistryAtBoot', () => {
|
||||
await hydrate(makeStore([{ id: 'repo-a' }]))
|
||||
|
||||
expect(provider.listSessions).toHaveBeenCalledTimes(1)
|
||||
expect(listRepoWorktreesMock).not.toHaveBeenCalled()
|
||||
})
|
||||
|
||||
it('catches provider.listSessions rejection and does not throw', async () => {
|
||||
@@ -145,6 +157,7 @@ describe('hydrateLocalPtyRegistryAtBoot', () => {
|
||||
warnSpy.mockRestore()
|
||||
|
||||
expect(listRegisteredPtys()).toHaveLength(0)
|
||||
expect(listRepoWorktreesMock).not.toHaveBeenCalled()
|
||||
})
|
||||
|
||||
it('does not clobber a pre-existing registry pid with a null pid from listSessions', async () => {
|
||||
@@ -180,6 +193,69 @@ describe('hydrateLocalPtyRegistryAtBoot', () => {
|
||||
expect(entry).toBeDefined()
|
||||
expect(entry!.pid).toBe(12345)
|
||||
expect(entry!.paneKey).toBe('tab-1:1')
|
||||
expect(listRepoWorktreesMock).not.toHaveBeenCalled()
|
||||
})
|
||||
|
||||
it('does not clobber a pty:spawn registration that arrives during worktree enumeration', async () => {
|
||||
const { hydrate, listRegisteredPtys, registerPty } = await loadFresh()
|
||||
const ptyId = 'repo-a::/local/Triton@@deadbeef'
|
||||
const provider = makeProvider([
|
||||
{ sessionId: ptyId, pid: null, cwd: '/local/Triton' } as unknown as SessionInfo
|
||||
])
|
||||
getDaemonProviderMock.mockReturnValue(provider)
|
||||
const worktrees =
|
||||
createDeferred<
|
||||
{ path: string; head: string; branch: string; isBare: boolean; isMainWorktree: boolean }[]
|
||||
>()
|
||||
listRepoWorktreesMock.mockReturnValue(worktrees.promise)
|
||||
|
||||
const hydration = hydrate(makeStore([{ id: 'repo-a', connectionId: null }]))
|
||||
await vi.waitFor(() => expect(listRepoWorktreesMock).toHaveBeenCalledTimes(1))
|
||||
registerPty({
|
||||
ptyId,
|
||||
worktreeId: 'repo-a::/local/Triton',
|
||||
sessionId: ptyId,
|
||||
paneKey: 'tab-1:1',
|
||||
pid: 12345
|
||||
})
|
||||
worktrees.resolve([
|
||||
{ path: '/local/Triton', head: '', branch: '', isBare: false, isMainWorktree: true }
|
||||
])
|
||||
await hydration
|
||||
|
||||
expect(listRegisteredPtys()).toEqual([
|
||||
expect.objectContaining({ ptyId, pid: 12345, paneKey: 'tab-1:1' })
|
||||
])
|
||||
})
|
||||
|
||||
it('does not resurrect a daemon session that exits during worktree enumeration', async () => {
|
||||
const { hydrate, listRegisteredPtys, unregisterPty } = await loadFresh()
|
||||
const ptyId = 'repo-a::/local/Triton@@deadbeef'
|
||||
const provider = {
|
||||
listSessions: vi
|
||||
.fn()
|
||||
.mockResolvedValueOnce([
|
||||
{ sessionId: ptyId, pid: 4242, cwd: '/local/Triton' } as unknown as SessionInfo
|
||||
])
|
||||
.mockResolvedValueOnce([])
|
||||
}
|
||||
getDaemonProviderMock.mockReturnValue(provider)
|
||||
const worktrees =
|
||||
createDeferred<
|
||||
{ path: string; head: string; branch: string; isBare: boolean; isMainWorktree: boolean }[]
|
||||
>()
|
||||
listRepoWorktreesMock.mockReturnValue(worktrees.promise)
|
||||
|
||||
const hydration = hydrate(makeStore([{ id: 'repo-a', connectionId: null }]))
|
||||
await vi.waitFor(() => expect(listRepoWorktreesMock).toHaveBeenCalledTimes(1))
|
||||
unregisterPty(ptyId)
|
||||
worktrees.resolve([
|
||||
{ path: '/local/Triton', head: '', branch: '', isBare: false, isMainWorktree: true }
|
||||
])
|
||||
await hydration
|
||||
|
||||
expect(provider.listSessions).toHaveBeenCalledTimes(2)
|
||||
expect(listRegisteredPtys()).toHaveLength(0)
|
||||
})
|
||||
|
||||
it('skips SSH repos before enumerating worktrees', async () => {
|
||||
@@ -223,6 +299,99 @@ describe('hydrateLocalPtyRegistryAtBoot', () => {
|
||||
expect(entry!.worktreeId).toBe('repo-a::/local/Triton')
|
||||
})
|
||||
|
||||
it('does not register a daemon session whose worktree was removed', async () => {
|
||||
const { hydrate, listRegisteredPtys } = await loadFresh()
|
||||
const provider = makeProvider([
|
||||
{
|
||||
sessionId: 'repo-a::/local/removed@@deadbeef',
|
||||
pid: 4242,
|
||||
cwd: '/local/removed'
|
||||
} as unknown as SessionInfo
|
||||
])
|
||||
getDaemonProviderMock.mockReturnValue(provider)
|
||||
listRepoWorktreesMock.mockResolvedValue([
|
||||
{ path: '/local/current', head: '', branch: '', isBare: false, isMainWorktree: true }
|
||||
])
|
||||
|
||||
await hydrate(makeStore([{ id: 'repo-a', connectionId: null }]))
|
||||
|
||||
expect(listRepoWorktreesMock).toHaveBeenCalledTimes(1)
|
||||
expect(listRegisteredPtys()).toHaveLength(0)
|
||||
})
|
||||
|
||||
it('enumerates worktrees only for repos referenced by live daemon sessions', async () => {
|
||||
const { hydrate, listRegisteredPtys } = await loadFresh()
|
||||
const repos = Array.from({ length: 100 }, (_, index) => ({ id: `repo-${index}` }))
|
||||
const activeRepoIds = ['repo-17', 'repo-83']
|
||||
const provider = makeProvider(
|
||||
activeRepoIds.map(
|
||||
(repoId, index) =>
|
||||
({
|
||||
sessionId: `${repoId}::/local/${repoId}@@0000000${index}`,
|
||||
pid: 4000 + index,
|
||||
cwd: `/local/${repoId}`
|
||||
}) as unknown as SessionInfo
|
||||
)
|
||||
)
|
||||
getDaemonProviderMock.mockReturnValue(provider)
|
||||
listRepoWorktreesMock.mockImplementation(async (repo: Repo) => [
|
||||
{
|
||||
path: `/local/${repo.id}`,
|
||||
head: '',
|
||||
branch: '',
|
||||
isBare: false,
|
||||
isMainWorktree: true
|
||||
}
|
||||
])
|
||||
|
||||
await hydrate(makeStore(repos))
|
||||
|
||||
expect(listRepoWorktreesMock).toHaveBeenCalledTimes(activeRepoIds.length)
|
||||
expect(listRepoWorktreesMock.mock.calls.map(([repo]) => (repo as Repo).id)).toEqual(
|
||||
activeRepoIds
|
||||
)
|
||||
expect(listRegisteredPtys()).toHaveLength(activeRepoIds.length)
|
||||
})
|
||||
|
||||
it('scans a repo that only becomes visible on the post-enumeration re-read', async () => {
|
||||
const { hydrate, listRegisteredPtys } = await loadFresh()
|
||||
|
||||
const sessionA = {
|
||||
sessionId: 'repo-a::/local/repo-a@@00000001',
|
||||
pid: 4001,
|
||||
cwd: '/local/repo-a'
|
||||
} as unknown as SessionInfo
|
||||
const sessionB = {
|
||||
sessionId: 'repo-b::/local/repo-b@@00000002',
|
||||
pid: 4002,
|
||||
cwd: '/local/repo-b'
|
||||
} as unknown as SessionInfo
|
||||
// Why: a briefly unreachable adapter can omit a session from the first
|
||||
// listing; once it reappears on the re-read its repo must still be
|
||||
// scanned and the session registered instead of silently dropped.
|
||||
const listSessions = vi
|
||||
.fn()
|
||||
.mockResolvedValueOnce([sessionA])
|
||||
.mockResolvedValue([sessionA, sessionB])
|
||||
getDaemonProviderMock.mockReturnValue({ listSessions })
|
||||
listRepoWorktreesMock.mockImplementation(async (repo: Repo) => [
|
||||
{ path: `/local/${repo.id}`, head: '', branch: '', isBare: false, isMainWorktree: true }
|
||||
])
|
||||
|
||||
await hydrate(makeStore([{ id: 'repo-a' }, { id: 'repo-b' }]))
|
||||
|
||||
expect(listRepoWorktreesMock.mock.calls.map(([repo]) => (repo as Repo).id)).toEqual([
|
||||
'repo-a',
|
||||
'repo-b'
|
||||
])
|
||||
expect(listSessions).toHaveBeenCalledTimes(3)
|
||||
const registered = listRegisteredPtys()
|
||||
expect(registered.map((p) => p.ptyId).sort()).toEqual([
|
||||
'repo-a::/local/repo-a@@00000001',
|
||||
'repo-b::/local/repo-b@@00000002'
|
||||
])
|
||||
})
|
||||
|
||||
it('hydrates large daemon session lists', async () => {
|
||||
const { hydrate, listRegisteredPtys } = await loadFresh()
|
||||
|
||||
|
||||
@@ -25,6 +25,7 @@ import type { SessionInfo } from '../daemon/types'
|
||||
import { listRegisteredPtys, registerPty } from './pty-registry'
|
||||
import { listRepoWorktrees } from '../repo-worktrees'
|
||||
import { parsePtySessionId } from '../../shared/pty-session-id-format'
|
||||
import { splitWorktreeId } from '../../shared/worktree-id'
|
||||
import type { Store } from '../persistence'
|
||||
|
||||
// Why: `attachMainWindowServices` runs on every macOS dock re-activation
|
||||
@@ -70,34 +71,62 @@ export async function hydrateLocalPtyRegistryAtBoot(store: Pick<Store, 'getRepos
|
||||
// renderer-side union still covers that case.
|
||||
hasHydrated = true
|
||||
|
||||
// Why: build a worktree-id → connectionId map so we can SSH-gate each
|
||||
// session before registering. Live git enumeration matches the path
|
||||
// shape used by `mintPtySessionId` (`${repoId}::${path}`).
|
||||
const repos = store.getRepos()
|
||||
const repoConnectionIdByWorktreeId = new Map<string, string | null>()
|
||||
// Why: ask the daemon which repos matter before launching Git worktree
|
||||
// enumeration. Most configured repos have no preserved session at boot,
|
||||
// so scanning all of them creates pure background subprocess churn.
|
||||
const reposById = new Map(store.getRepos().map((repo) => [repo.id, repo]))
|
||||
// Why: live git enumeration verifies that a referenced local worktree
|
||||
// still exists instead of resurrecting removed worktrees.
|
||||
const liveLocalWorktreeIds = new Set<string>()
|
||||
const resolvedRepoIds = new Set<string>()
|
||||
|
||||
for (const repo of repos) {
|
||||
const connectionId = repo.connectionId ?? null
|
||||
if (connectionId) {
|
||||
// Why: SSH PTYs are never registered for local process sampling, so
|
||||
// avoid startup SSH/git enumeration for repos we will skip anyway.
|
||||
continue
|
||||
let sessionInfos = await collectSessionInfos(provider)
|
||||
let alreadyRegistered = new Set(listRegisteredPtys().map((p) => p.ptyId))
|
||||
|
||||
// Why: repo selection and registration must come from the same daemon
|
||||
// snapshot. Git enumeration can take seconds, so after each scan pass we
|
||||
// re-read daemon and registry state; sessions that exited or were
|
||||
// authoritatively registered meanwhile are not resurrected or overwritten,
|
||||
// and a session that only became visible during a slow scan (e.g. a
|
||||
// briefly unreachable legacy adapter) gets its repo scanned on the next
|
||||
// pass instead of being silently dropped. Terminates because every pass
|
||||
// permanently resolves at least one new repo id.
|
||||
for (;;) {
|
||||
const newlyReferencedRepos = new Map<string, ReturnType<(typeof store)['getRepos']>[number]>()
|
||||
for (const info of sessionInfos) {
|
||||
if (alreadyRegistered.has(info.sessionId)) {
|
||||
continue
|
||||
}
|
||||
const { worktreeId } = parsePtySessionId(info.sessionId)
|
||||
const parsedWorktreeId = worktreeId ? splitWorktreeId(worktreeId) : null
|
||||
if (!parsedWorktreeId || resolvedRepoIds.has(parsedWorktreeId.repoId)) {
|
||||
continue
|
||||
}
|
||||
const repo = reposById.get(parsedWorktreeId.repoId)
|
||||
if (!repo || (repo.connectionId ?? null)) {
|
||||
// Why: unknown repos can't be proven local, and SSH PTYs are never
|
||||
// registered for local process sampling — resolve without git
|
||||
// enumeration so neither can extend the loop.
|
||||
resolvedRepoIds.add(parsedWorktreeId.repoId)
|
||||
continue
|
||||
}
|
||||
newlyReferencedRepos.set(repo.id, repo)
|
||||
}
|
||||
const worktrees = await listRepoWorktrees(repo)
|
||||
for (const wt of worktrees) {
|
||||
const worktreeId = `${repo.id}::${wt.path}`
|
||||
repoConnectionIdByWorktreeId.set(worktreeId, connectionId)
|
||||
if (newlyReferencedRepos.size === 0) {
|
||||
break
|
||||
}
|
||||
|
||||
for (const repo of newlyReferencedRepos.values()) {
|
||||
resolvedRepoIds.add(repo.id)
|
||||
const worktrees = await listRepoWorktrees(repo)
|
||||
for (const wt of worktrees) {
|
||||
liveLocalWorktreeIds.add(`${repo.id}::${wt.path}`)
|
||||
}
|
||||
}
|
||||
|
||||
sessionInfos = await collectSessionInfos(provider)
|
||||
alreadyRegistered = new Set(listRegisteredPtys().map((p) => p.ptyId))
|
||||
}
|
||||
|
||||
// Why: SessionInfo is read through the adapter's listSessions() so we
|
||||
// get the pid alongside each id. Routing through every adapter
|
||||
// (current + legacy) keeps protocol coverage symmetric with the
|
||||
// orphan-cleanup path.
|
||||
const sessionInfos = await collectSessionInfos(provider)
|
||||
|
||||
const alreadyRegistered = new Set(listRegisteredPtys().map((p) => p.ptyId))
|
||||
|
||||
for (const info of sessionInfos) {
|
||||
// Why: pid-write ordering — `pty:spawn` is the authoritative
|
||||
// writer for in-session sessions; if that fired before this loop
|
||||
@@ -115,10 +144,7 @@ export async function hydrateLocalPtyRegistryAtBoot(store: Pick<Store, 'getRepos
|
||||
// `src/main/ipc/pty.ts`. If the repo isn't in the store, skip the
|
||||
// session: we can't prove it's local, and the renderer-side union
|
||||
// still surfaces the session at the cost of a missing pid sample.
|
||||
if (!repoConnectionIdByWorktreeId.has(worktreeId)) {
|
||||
continue
|
||||
}
|
||||
if (repoConnectionIdByWorktreeId.get(worktreeId)) {
|
||||
if (!liveLocalWorktreeIds.has(worktreeId)) {
|
||||
continue
|
||||
}
|
||||
registerPty({
|
||||
|
||||
@@ -1699,6 +1699,128 @@ describe('Store', () => {
|
||||
expect(migratedRun?.sourceContext).toEqual(migratedAutomation?.sourceContext)
|
||||
})
|
||||
|
||||
it('shrinks an oversized automationRuns file on load without any later mutation', async () => {
|
||||
const seed = await createStore()
|
||||
seed.addRepo(
|
||||
makeRepo({
|
||||
upstream: { owner: 'stablyai', repo: 'orca' },
|
||||
connectionId: 'builder'
|
||||
})
|
||||
)
|
||||
const automation = seed.createAutomation({
|
||||
name: 'Every minute',
|
||||
prompt: 'Run checks',
|
||||
agentId: 'claude',
|
||||
projectId: 'r1',
|
||||
workspaceMode: 'new_per_run',
|
||||
timezone: 'UTC',
|
||||
rrule: 'FREQ=DAILY;BYHOUR=9;BYMINUTE=0',
|
||||
dtstart: new Date('2026-05-13T00:00:00Z').getTime()
|
||||
})
|
||||
seed.createAutomationRun(automation, new Date('2026-05-13T09:00:00Z').getTime())
|
||||
seed.flush()
|
||||
|
||||
// Why: a fresh store never stamps the one-shot UI migration flags, so a
|
||||
// second load+flush settles them — otherwise they, not the prune, mark dirty.
|
||||
const warm = await createStore()
|
||||
warm.flush()
|
||||
|
||||
const persisted = readDataFile() as { automationRuns: Record<string, unknown>[] }
|
||||
const template = persisted.automationRuns[0]
|
||||
persisted.automationRuns = Array.from({ length: 250 }, (_, i) => {
|
||||
const legacy: Record<string, unknown> = {
|
||||
...template,
|
||||
id: `legacy-run-${i}`,
|
||||
// Only final runs are evictable; the real-world blowup was skipped_precheck rows.
|
||||
status: 'skipped_precheck',
|
||||
createdAt: 1_000 + i,
|
||||
scheduledFor: 1_000 + i
|
||||
}
|
||||
// A real legacy file predates runNumber; backfill must run BEFORE the prune
|
||||
// so survivors keep their true ordinals instead of restarting at 1.
|
||||
delete legacy.runNumber
|
||||
return legacy
|
||||
})
|
||||
writeDataFile(persisted)
|
||||
|
||||
vi.useFakeTimers()
|
||||
try {
|
||||
const reloaded = await createStore()
|
||||
expect(reloaded.listAutomationRuns(automation.id)).toHaveLength(100)
|
||||
|
||||
// The load-path prune must mark state dirty on its own; nothing else here saves.
|
||||
vi.advanceTimersByTime(1000)
|
||||
await reloaded.waitForPendingWrite()
|
||||
} finally {
|
||||
vi.useRealTimers()
|
||||
}
|
||||
|
||||
const healed = readDataFile() as { automationRuns: Record<string, unknown>[] }
|
||||
expect(healed.automationRuns).toHaveLength(100)
|
||||
expect(healed.automationRuns.at(-1)?.id).toBe('legacy-run-249')
|
||||
// Survivors carry their true lifetime ordinals (151..250), not restarted ones.
|
||||
expect(healed.automationRuns[0]?.runNumber).toBe(151)
|
||||
expect(healed.automationRuns.at(-1)?.runNumber).toBe(250)
|
||||
})
|
||||
|
||||
it('does not strand an in-flight run whose completion lands after the retention cap', async () => {
|
||||
const store = await createStore()
|
||||
store.addRepo(
|
||||
makeRepo({
|
||||
upstream: { owner: 'stablyai', repo: 'orca' },
|
||||
connectionId: 'builder'
|
||||
})
|
||||
)
|
||||
const automation = store.createAutomation({
|
||||
name: 'Every minute',
|
||||
prompt: 'Run checks',
|
||||
agentId: 'claude',
|
||||
projectId: 'r1',
|
||||
workspaceMode: 'new_per_run',
|
||||
timezone: 'UTC',
|
||||
rrule: 'FREQ=DAILY;BYHOUR=9;BYMINUTE=0',
|
||||
dtstart: new Date('2026-05-13T00:00:00Z').getTime()
|
||||
})
|
||||
const base = new Date('2026-05-13T09:00:00Z').getTime()
|
||||
const inFlight = store.createAutomationRun(automation, base)
|
||||
store.updateAutomationRun({
|
||||
runId: inFlight.id,
|
||||
status: 'dispatched',
|
||||
workspaceId: null,
|
||||
error: null
|
||||
})
|
||||
|
||||
// 120 later runs reach a final status while the first one is still dispatched.
|
||||
let firstCompletedId = ''
|
||||
for (let i = 1; i <= 120; i++) {
|
||||
const later = store.createAutomationRun(automation, base + i * 60_000)
|
||||
firstCompletedId ||= later.id
|
||||
store.updateAutomationRun({
|
||||
runId: later.id,
|
||||
status: 'completed',
|
||||
workspaceId: null,
|
||||
error: null
|
||||
})
|
||||
}
|
||||
|
||||
// The late completion must still find its row.
|
||||
const completed = store.updateAutomationRun({
|
||||
runId: inFlight.id,
|
||||
status: 'completed',
|
||||
workspaceId: null,
|
||||
error: null
|
||||
})
|
||||
expect(completed.id).toBe(inFlight.id)
|
||||
|
||||
const runs = store.listAutomationRuns(automation.id)
|
||||
expect(runs.some((run) => run.id === inFlight.id)).toBe(true)
|
||||
// Final runs beyond the cap were still evicted. The store can briefly hold
|
||||
// cap + 2: the last-created run finalizes after the prune at its creation,
|
||||
// and the late completion lands without a prune of its own.
|
||||
expect(runs.some((run) => run.id === firstCompletedId)).toBe(false)
|
||||
expect(runs.length).toBeLessThanOrEqual(102)
|
||||
})
|
||||
|
||||
it('persists automation precheck config and run results', async () => {
|
||||
const store = await createStore()
|
||||
store.addRepo(makeRepo())
|
||||
|
||||
+24
-5
@@ -130,6 +130,11 @@ import {
|
||||
} from './agent-hooks/migration-unsupported-pty-state'
|
||||
import { agentHookServer } from './agent-hooks/server'
|
||||
import { pruneLocalTerminalScrollbackBuffers } from '../shared/workspace-session-terminal-buffers'
|
||||
import {
|
||||
backfillAutomationRunNumbers,
|
||||
nextAutomationRunNumber,
|
||||
pruneAutomationRuns
|
||||
} from '../shared/automation-run-retention'
|
||||
import { pruneWorkspaceSessionBrowserHistory } from '../shared/workspace-session-browser-history'
|
||||
import {
|
||||
FOLDER_WORKSPACE_INSTANCE_SEPARATOR,
|
||||
@@ -3387,7 +3392,18 @@ export class Store {
|
||||
parsed.legacyPaneKeyAliasEntries
|
||||
),
|
||||
automations: Array.isArray(parsed.automations) ? parsed.automations : [],
|
||||
automationRuns: Array.isArray(parsed.automationRuns) ? parsed.automationRuns : [],
|
||||
automationRuns: (() => {
|
||||
if (!Array.isArray(parsed.automationRuns)) {
|
||||
return []
|
||||
}
|
||||
const runs = pruneAutomationRuns(backfillAutomationRunNumbers(parsed.automationRuns))
|
||||
// Why: nothing else on the load path marks state dirty, so without
|
||||
// this an oversized legacy file only shrinks at the next unrelated save.
|
||||
if (runs.length !== parsed.automationRuns.length) {
|
||||
this.loadNeedsSave = true
|
||||
}
|
||||
return runs
|
||||
})(),
|
||||
onboarding: normalizedOnboarding
|
||||
}
|
||||
}
|
||||
@@ -4701,12 +4717,15 @@ export class Store {
|
||||
return existing
|
||||
}
|
||||
const now = Date.now()
|
||||
const runNumber =
|
||||
(this.state.automationRuns ?? []).filter((run) => run.automationId === automation.id).length +
|
||||
1
|
||||
// Why: retention prunes old runs, so the count of retained runs is no longer
|
||||
// the run's ordinal — carry the number forward from the newest survivor.
|
||||
const runNumber = nextAutomationRunNumber(
|
||||
(this.state.automationRuns ?? []).filter((run) => run.automationId === automation.id)
|
||||
)
|
||||
const run: AutomationRun = {
|
||||
id: randomUUID(),
|
||||
automationId: automation.id,
|
||||
runNumber,
|
||||
runContext: automation.runContext ?? null,
|
||||
sourceContext: automation.sourceContext ?? null,
|
||||
title: `${automation.name} run ${runNumber}`,
|
||||
@@ -4728,7 +4747,7 @@ export class Store {
|
||||
dispatchedAt: null,
|
||||
createdAt: now
|
||||
}
|
||||
this.state.automationRuns = [...(this.state.automationRuns ?? []), run]
|
||||
this.state.automationRuns = pruneAutomationRuns([...(this.state.automationRuns ?? []), run])
|
||||
if (trigger === 'manual') {
|
||||
this.recordFeatureInteraction('automation-run')
|
||||
}
|
||||
|
||||
@@ -1,5 +1,6 @@
|
||||
import { runInNewContext } from 'node:vm'
|
||||
import ts from 'typescript'
|
||||
// TypeScript 7 is a native CLI; transpile tests still need the legacy JavaScript API.
|
||||
import ts from 'typescript-api'
|
||||
import { describe, expect, it, vi } from 'vitest'
|
||||
|
||||
import { getPiAgentStatusExtensionSource } from './agent-status-extension-source'
|
||||
|
||||
@@ -574,9 +574,13 @@ describe('mobile presence lock — issue #7588 held-modal restore convergence',
|
||||
expect(listenerEvents.some((e) => e.mode === 'desktop-fit')).toBe(true)
|
||||
})
|
||||
|
||||
// Scenario 4: a failing resize during a HELD (no-subscriber) restore returns
|
||||
// false and keeps the override — no lying, no phantom desktop-fit event.
|
||||
it('held restore with a failing resize returns false and keeps the override', async () => {
|
||||
// Scenario 4: an explicit take-back on a HELD (no-subscriber) override must
|
||||
// ALWAYS release, even when the desktop resize fails. The override is cleared
|
||||
// optimistically with a paired desktop-fit 0×0 so the banner can't linger; the
|
||||
// desktop renderer refits the PTY on its next settled frame. (The gesture
|
||||
// deliberately overrides #7588's keep-lock-on-failed-resize rule, which still
|
||||
// governs the auto-restore and phone-initiated paths.)
|
||||
it('held restore with a failing resize still releases and clears the override', async () => {
|
||||
const { runtime, fitOverrideEvents, setResizeSucceeds } = createRuntime(null)
|
||||
const listenerEvents: { mode: string; cols: number; rows: number }[] = []
|
||||
runtime.subscribeToFitOverrideChanges('pty-1', (e) => listenerEvents.push(e))
|
||||
@@ -594,18 +598,17 @@ describe('mobile presence lock — issue #7588 held-modal restore convergence',
|
||||
|
||||
const restored = await runtime.reclaimTerminalForDesktop('pty-1')
|
||||
|
||||
expect(restored).toBe(false)
|
||||
expect(runtime.getTerminalFitOverride('pty-1')).not.toBeNull()
|
||||
expect(fitOverrideEvents.slice(notifierBefore).some((e) => e.mode === 'desktop-fit')).toBe(
|
||||
false
|
||||
)
|
||||
expect(listenerEvents.slice(listenerBefore).some((e) => e.mode === 'desktop-fit')).toBe(false)
|
||||
expect(restored).toBe(true)
|
||||
expect(runtime.getTerminalFitOverride('pty-1')).toBeNull()
|
||||
expect(fitOverrideEvents.slice(notifierBefore).some((e) => e.mode === 'desktop-fit')).toBe(true)
|
||||
expect(listenerEvents.slice(listenerBefore).some((e) => e.mode === 'desktop-fit')).toBe(true)
|
||||
})
|
||||
|
||||
// Scenario 5: a failing resize during an ACTIVE-SUBSCRIBER take-back returns
|
||||
// false, keeps the override, leaves the driver on its mobile lock, and
|
||||
// restores the prior display mode ('auto') — the fix #3 P1 correction.
|
||||
it('active-subscriber take-back with a failing resize returns false and preserves the mobile lock', async () => {
|
||||
// Scenario 5: an explicit ACTIVE-SUBSCRIBER take-back with a failing resize
|
||||
// must still release — driver → desktop, override cleared, banner dismissed,
|
||||
// mode reset to 'auto'. This is the "take back all terminals" guarantee: a
|
||||
// background PTY that can't converge must not strand its banner.
|
||||
it('active-subscriber take-back with a failing resize still releases the lock', async () => {
|
||||
const { runtime, fitOverrideEvents, setResizeSucceeds } = createRuntime()
|
||||
const listenerEvents: { mode: string; cols: number; rows: number }[] = []
|
||||
runtime.subscribeToFitOverrideChanges('pty-1', (e) => listenerEvents.push(e))
|
||||
@@ -619,43 +622,30 @@ describe('mobile presence lock — issue #7588 held-modal restore convergence',
|
||||
|
||||
const restored = await runtime.reclaimTerminalForDesktop('pty-1')
|
||||
|
||||
expect(restored).toBe(false)
|
||||
expect(runtime.getTerminalFitOverride('pty-1')).not.toBeNull()
|
||||
// Driver stays mobile (lock retained) and mode is not left lying at 'desktop'.
|
||||
expect(runtime.getDriver('pty-1')).toEqual({ kind: 'mobile', clientId: 'phone-A' })
|
||||
expect(restored).toBe(true)
|
||||
// Lock released and banner dismissed despite the failed resize.
|
||||
expect(runtime.getDriver('pty-1')).toEqual({ kind: 'desktop' })
|
||||
expect(runtime.getTerminalFitOverride('pty-1')).toBeNull()
|
||||
expect(runtime.getMobileDisplayMode('pty-1')).toBe('auto')
|
||||
expect(fitOverrideEvents.slice(notifierBefore).some((e) => e.mode === 'desktop-fit')).toBe(
|
||||
false
|
||||
)
|
||||
expect(listenerEvents.slice(listenerBefore).some((e) => e.mode === 'desktop-fit')).toBe(false)
|
||||
expect(fitOverrideEvents.slice(notifierBefore).some((e) => e.mode === 'desktop-fit')).toBe(true)
|
||||
expect(listenerEvents.slice(listenerBefore).some((e) => e.mode === 'desktop-fit')).toBe(true)
|
||||
})
|
||||
|
||||
// Scenario 5b: after a FAILED active-subscriber take-back, wasResizedToPhone
|
||||
// must be re-armed so a later unsubscribe under a finite auto-restore setting
|
||||
// still schedules its timer and eventually clears the override. Without the
|
||||
// re-arm the flag would be stuck false and the phone-fit would strand.
|
||||
it('failed take-back re-arms wasResizedToPhone so a later unsubscribe still auto-restores', async () => {
|
||||
// Finite auto-restore (5s default from the rig store).
|
||||
const { runtime, ptySizes, setResizeSucceeds } = createRuntime()
|
||||
// Scenario 5b: the failed-resize take-back leaves NO stranded phone-fit —
|
||||
// driver released to desktop, override cleared, mode reset to 'auto'. (The
|
||||
// pre-revision behavior kept the lock and relied on a later auto-restore; the
|
||||
// explicit gesture now releases unconditionally.)
|
||||
it('failed take-back leaves no stranded override or lock', async () => {
|
||||
const { runtime, setResizeSucceeds } = createRuntime()
|
||||
|
||||
await runtime.handleMobileSubscribe('pty-1', 'phone-A', { cols: 45, rows: 20 })
|
||||
expect(ptySizes.get('pty-1')).toEqual({ cols: 45, rows: 20 })
|
||||
|
||||
// Take-back fails → false, flag re-armed, mode rolled back to 'auto'.
|
||||
setResizeSucceeds(false)
|
||||
expect(await runtime.reclaimTerminalForDesktop('pty-1')).toBe(false)
|
||||
expect(runtime.getTerminalFitOverride('pty-1')).not.toBeNull()
|
||||
expect(await runtime.reclaimTerminalForDesktop('pty-1')).toBe(true)
|
||||
|
||||
// Phone then leaves the terminal. Resize works again for the auto-restore.
|
||||
setResizeSucceeds(true)
|
||||
runtime.handleMobileUnsubscribe('pty-1', 'phone-A')
|
||||
// Soft-leave grace, then the finite auto-restore timer.
|
||||
await vi.advanceTimersByTimeAsync(300)
|
||||
await vi.advanceTimersByTimeAsync(5_000)
|
||||
|
||||
// The scheduled auto-restore fired: override cleared, PTY back to desktop.
|
||||
expect(runtime.getDriver('pty-1')).toEqual({ kind: 'desktop' })
|
||||
expect(runtime.getTerminalFitOverride('pty-1')).toBeNull()
|
||||
expect(ptySizes.get('pty-1')).toEqual({ cols: 150, rows: 40 })
|
||||
expect(runtime.getMobileDisplayMode('pty-1')).toBe('auto')
|
||||
})
|
||||
|
||||
// Scenario 6: a phone-initiated setDisplayMode('desktop') against a stale
|
||||
|
||||
@@ -1372,6 +1372,7 @@ describe('OrcaRuntimeService', () => {
|
||||
expect(status.capabilities).toContain('workspace-ports.v1')
|
||||
expect(status.capabilities).toContain('mobile.tasks.v1')
|
||||
expect(status.capabilities).toContain('project-host-setup.v1')
|
||||
expect(status.capabilities).toContain('linear.issue-attribute-filter.v1')
|
||||
expect(status.capabilities).not.toContain('browser.screencast.v1')
|
||||
expect(typeof status.protocolVersion).toBe('number')
|
||||
expect(typeof status.minCompatibleMobileVersion).toBe('number')
|
||||
|
||||
@@ -8197,42 +8197,35 @@ export class OrcaRuntimeService {
|
||||
}
|
||||
|
||||
// Why: invoked from `runtime:restoreTerminalFit` IPC (the desktop "Take
|
||||
// back" / "Restore" button). Forces the PTY back to desktop dims and
|
||||
// flips the driver to `desktop`, suppressing further mobile-driven dim
|
||||
// changes until a mobile actor takes the floor again. Two cases:
|
||||
// 1. Active mobile subscriber: route through applyMobileDisplayMode so
|
||||
// the existing 'resized' event reaches the phone.
|
||||
// 2. Held with no mobile subscriber (post-indefinite-hold): no inner
|
||||
// subscriber to notify; resolve restore target and enqueueLayout
|
||||
// directly. applyLayout is the SOLE writer of terminalFitOverrides on
|
||||
// the normal path, so the held branch defers the mutation to it — the
|
||||
// one exception is the dead-pty orphan cleanup below, which mirrors
|
||||
// onPtyExit's sanctioned delete. See docs/mobile-fit-hold.md.
|
||||
// back" / "Restore" button). Forces the PTY back to desktop dims and flips
|
||||
// the driver to `desktop`, suppressing further mobile-driven dim changes
|
||||
// until a mobile actor takes the floor again. Three cases, each ending in
|
||||
// releaseDesktopTakeBack:
|
||||
// 1. Active mobile subscriber: route through applyMobileDisplayMode so the
|
||||
// existing 'resized' event reaches the phone.
|
||||
// 2. Held override, no subscriber (post-indefinite-hold): resolve the
|
||||
// restore target and enqueueLayout directly.
|
||||
// 3. Stale mobile driver, no subscriber and no override: nothing to resize,
|
||||
// just drop the lock. See docs/mobile-fit-hold.md.
|
||||
//
|
||||
// Returns `true` only when the pty ends converged (no fit-override held);
|
||||
// `false` when a restore was attempted but the resize failed (#7588), or
|
||||
// when there was nothing to reclaim. On a failed-restore `false`, driver/mode
|
||||
// are left unchanged so a driving phone keeps its lock and the modal
|
||||
// truthfully stays — the user can retry.
|
||||
// Why: explicit desktop take-back is a user command to reclaim input control
|
||||
// NOW. Unlike the auto-restore timer and phone-initiated setDisplayMode paths
|
||||
// (which keep the lock when a resize can't converge, #7588), this gesture
|
||||
// ALWAYS drops the presence lock and banner. "Take back all terminals"
|
||||
// reclaims several PTYs at once; a background pane whose desktop resize can't
|
||||
// converge must not strand its banner on the other terminals. The resize is
|
||||
// best-effort — the desktop renderer refits the PTY on its next settled
|
||||
// frame. Returns `true` whenever there was a lock to reclaim, `false` only
|
||||
// when there was nothing to reclaim.
|
||||
async reclaimTerminalForDesktop(ptyId: string): Promise<boolean> {
|
||||
if (this.isMobileSubscriberActive(ptyId)) {
|
||||
// Why (#7588): capture the prior mode so a failed restore can roll the
|
||||
// routing-only 'desktop' write back — driver/mode transitions must be
|
||||
// gated on convergence, not committed before we know the resize took.
|
||||
const priorMode = this.getMobileDisplayMode(ptyId)
|
||||
this.setMobileDisplayMode(ptyId, 'desktop')
|
||||
const converged = await this.applyMobileDisplayMode(ptyId)
|
||||
if (!converged) {
|
||||
// Resize failed — override still held. Leave the driver on its mobile
|
||||
// lock and undo the mode write so nothing is left lying at 'desktop'.
|
||||
this.setMobileDisplayMode(ptyId, priorMode)
|
||||
return false
|
||||
}
|
||||
this.setDriver(ptyId, { kind: 'desktop' })
|
||||
// Why: a desktop-initiated reclaim is "I'm taking over right now",
|
||||
// not a sticky preference. The next mobile subscribe (e.g. user
|
||||
// switches back to the terminal tab on the phone) must default to
|
||||
// phone-fit again, not stay in passive desktop-watch mode.
|
||||
await this.applyMobileDisplayMode(ptyId)
|
||||
this.releaseDesktopTakeBack(ptyId)
|
||||
// Why: a desktop-initiated reclaim is "I'm taking over right now", not a
|
||||
// sticky preference. The next mobile subscribe (e.g. user switches back to
|
||||
// the terminal tab on the phone) must default to phone-fit again, not stay
|
||||
// in passive desktop-watch mode.
|
||||
this.setMobileDisplayMode(ptyId, 'auto')
|
||||
return true
|
||||
}
|
||||
@@ -8251,36 +8244,38 @@ export class OrcaRuntimeService {
|
||||
const renderer = this.lastRendererSizes.get(ptyId)
|
||||
const cols = renderer?.cols ?? heldOverride.previousCols ?? fallback.cols
|
||||
const rows = renderer?.rows ?? heldOverride.previousRows ?? fallback.rows
|
||||
const result = await this.enqueueLayout(ptyId, { kind: 'desktop', cols, rows })
|
||||
if (result.ok) {
|
||||
this.setDriver(ptyId, { kind: 'desktop' })
|
||||
// Why: a desktop-initiated reclaim is "I'm taking over right now",
|
||||
// not a sticky preference. Reset to auto so the next mobile subscribe
|
||||
// re-enters phone-fit. (Held-PTY branch may not have an entry, but
|
||||
// calling setMobileDisplayMode('auto') is a no-op deletion in that
|
||||
// case — safe and idempotent.)
|
||||
this.setMobileDisplayMode(ptyId, 'auto')
|
||||
return true
|
||||
}
|
||||
// Why (#7588): the layout entry is gone (pty exited) but the override is
|
||||
// still held — unreachable via public APIs today (onPtyExit deletes both
|
||||
// in lockstep), but reporting success while stranding the override would
|
||||
// re-show the modal on the next hydrate. Run the same cleanup onPtyExit
|
||||
// does (delete override + paired desktop-fit 0×0) so the renderer
|
||||
// converges; nothing to resize on a dead pty.
|
||||
if (result.reason === 'pty-exited' && this.terminalFitOverrides.has(ptyId)) {
|
||||
this.terminalFitOverrides.delete(ptyId)
|
||||
this.notifier?.terminalFitOverrideChanged(ptyId, 'desktop-fit', 0, 0)
|
||||
this.notifyFitOverrideListeners(ptyId, 'desktop-fit', 0, 0)
|
||||
return true
|
||||
}
|
||||
// resize-failed: the override remains held; report the truth so the
|
||||
// modal correctly stays and the user can retry. Driver/mode untouched.
|
||||
return false
|
||||
await this.enqueueLayout(ptyId, { kind: 'desktop', cols, rows })
|
||||
this.releaseDesktopTakeBack(ptyId)
|
||||
this.setMobileDisplayMode(ptyId, 'auto')
|
||||
return true
|
||||
}
|
||||
// Why: a stale lock — driver still reads mobile with no active subscriber
|
||||
// and no held override (e.g. reclaimed inside the soft-leave grace, or a
|
||||
// subscriber that dropped without a clean unsubscribe). Release it so the
|
||||
// banner can't linger; there is nothing to resize.
|
||||
if (this.getDriver(ptyId).kind === 'mobile') {
|
||||
this.releaseDesktopTakeBack(ptyId)
|
||||
return true
|
||||
}
|
||||
return false
|
||||
}
|
||||
|
||||
// Why: the shared "banner must be gone now" step for an explicit desktop
|
||||
// take-back. Releases the presence lock (driver → desktop) and, if the
|
||||
// best-effort resize left a fit-override held (resize didn't converge),
|
||||
// clears it optimistically with a paired desktop-fit 0×0 — the same signal
|
||||
// onPtyExit emits — so neither the presence-lock banner nor the held-fit
|
||||
// banner can survive the reclaim. The desktop renderer refits the PTY to real
|
||||
// dims on its next settled frame.
|
||||
private releaseDesktopTakeBack(ptyId: string): void {
|
||||
this.setDriver(ptyId, { kind: 'desktop' })
|
||||
if (this.terminalFitOverrides.has(ptyId)) {
|
||||
this.terminalFitOverrides.delete(ptyId)
|
||||
this.notifier?.terminalFitOverrideChanged(ptyId, 'desktop-fit', 0, 0)
|
||||
this.notifyFitOverrideListeners(ptyId, 'desktop-fit', 0, 0)
|
||||
}
|
||||
}
|
||||
|
||||
// Why: read-side clamp for mobileAutoRestoreFitMs. `null` means
|
||||
// indefinite hold (no auto-restore timer). A finite value is clamped
|
||||
// to [MIN, MAX] to defend against bad config — the smallest useful
|
||||
|
||||
@@ -0,0 +1,44 @@
|
||||
import { describe, expect, it, vi } from 'vitest'
|
||||
import type { GitExec } from './git-handler-ops'
|
||||
import { checkIgnoredPathsOp } from './git-handler-check-ignore'
|
||||
import { GIT_CHECK_IGNORE_TIMEOUT_MS } from '../shared/git-check-ignore-stdio'
|
||||
|
||||
describe('checkIgnoredPathsOp', () => {
|
||||
it('passes exact paths over one bounded NUL-delimited stdin invocation', async () => {
|
||||
const git = vi.fn<GitExec>().mockResolvedValue({
|
||||
stdout: 'dist/bundle.js\0line\nbreak.txt\0',
|
||||
stderr: ''
|
||||
})
|
||||
|
||||
await expect(
|
||||
checkIgnoredPathsOp(git, {
|
||||
worktreePath: '/repo',
|
||||
paths: ['dist/bundle.js', 'line\nbreak.txt', 'src/index.ts']
|
||||
})
|
||||
).resolves.toEqual(['dist/bundle.js', 'line\nbreak.txt'])
|
||||
|
||||
expect(git).toHaveBeenCalledWith(
|
||||
['-c', 'core.quotePath=false', 'check-ignore', '-z', '--stdin'],
|
||||
'/repo',
|
||||
{
|
||||
stdin: 'dist/bundle.js\0line\nbreak.txt\0src/index.ts\0',
|
||||
timeout: GIT_CHECK_IGNORE_TIMEOUT_MS
|
||||
}
|
||||
)
|
||||
})
|
||||
|
||||
it('treats git exit code 1 as no ignored paths', async () => {
|
||||
const noMatches = Object.assign(new Error('no ignored paths'), {
|
||||
code: 1,
|
||||
stdout: ''
|
||||
})
|
||||
const git = vi.fn<GitExec>().mockRejectedValue(noMatches)
|
||||
|
||||
await expect(
|
||||
checkIgnoredPathsOp(git, {
|
||||
worktreePath: '/repo',
|
||||
paths: ['src/index.ts']
|
||||
})
|
||||
).resolves.toEqual([])
|
||||
})
|
||||
})
|
||||
@@ -0,0 +1,35 @@
|
||||
import type { GitExec } from './git-handler-ops'
|
||||
import {
|
||||
encodeGitCheckIgnorePaths,
|
||||
GIT_CHECK_IGNORE_STDIN_ARGS,
|
||||
GIT_CHECK_IGNORE_TIMEOUT_MS,
|
||||
parseGitCheckIgnorePaths,
|
||||
splitGitCheckIgnorePathsByStdinBytes
|
||||
} from '../shared/git-check-ignore-stdio'
|
||||
|
||||
export async function checkIgnoredPathsOp(
|
||||
git: GitExec,
|
||||
params: Record<string, unknown>
|
||||
): Promise<string[]> {
|
||||
const worktreePath = params.worktreePath as string
|
||||
const paths = Array.isArray(params.paths)
|
||||
? params.paths.filter((path): path is string => typeof path === 'string' && path.length > 0)
|
||||
: []
|
||||
const ignored: string[] = []
|
||||
for (const chunk of splitGitCheckIgnorePathsByStdinBytes(paths)) {
|
||||
try {
|
||||
const { stdout } = await git([...GIT_CHECK_IGNORE_STDIN_ARGS], worktreePath, {
|
||||
stdin: encodeGitCheckIgnorePaths(chunk),
|
||||
timeout: GIT_CHECK_IGNORE_TIMEOUT_MS
|
||||
})
|
||||
ignored.push(...parseGitCheckIgnorePaths(stdout))
|
||||
} catch (error) {
|
||||
const gitError = error as Error & { code?: number | string; stdout?: string }
|
||||
if (gitError.code !== 1) {
|
||||
throw error
|
||||
}
|
||||
ignored.push(...parseGitCheckIgnorePaths(gitError.stdout ?? ''))
|
||||
}
|
||||
}
|
||||
return ignored
|
||||
}
|
||||
@@ -16,7 +16,7 @@ import { readWorkingDiffFile } from './git-working-file-read'
|
||||
export type GitExec = (
|
||||
args: string[],
|
||||
cwd: string,
|
||||
opts?: { maxBuffer?: number; disableOptionalLocks?: boolean; stdin?: string }
|
||||
opts?: { maxBuffer?: number; disableOptionalLocks?: boolean; stdin?: string; timeout?: number }
|
||||
) => Promise<{ stdout: string; stderr: string }>
|
||||
|
||||
export type GitBufferExec = (args: string[], cwd: string) => Promise<Buffer>
|
||||
|
||||
@@ -249,34 +249,3 @@ function shouldProbeEffectiveUpstreamStatus(
|
||||
const parsed = splitRemoteBranchName(upstreamName)
|
||||
return parsed?.remoteName === 'origin' && parsed.branchName !== branchName
|
||||
}
|
||||
|
||||
function parseCheckIgnoreOutput(stdout: string): string[] {
|
||||
return stdout.split(/\r?\n/).filter(Boolean)
|
||||
}
|
||||
|
||||
export async function checkIgnoredPathsOp(
|
||||
git: GitExec,
|
||||
params: Record<string, unknown>
|
||||
): Promise<string[]> {
|
||||
const worktreePath = params.worktreePath as string
|
||||
const paths = Array.isArray(params.paths)
|
||||
? params.paths.filter((path): path is string => typeof path === 'string' && path.length > 0)
|
||||
: []
|
||||
if (paths.length === 0) {
|
||||
return []
|
||||
}
|
||||
|
||||
try {
|
||||
const { stdout } = await git(
|
||||
['-c', 'core.quotePath=false', 'check-ignore', '--', ...paths],
|
||||
worktreePath
|
||||
)
|
||||
return parseCheckIgnoreOutput(stdout)
|
||||
} catch (error) {
|
||||
const gitError = error as Error & { code?: number | string; stdout?: string }
|
||||
if (gitError.code === 1) {
|
||||
return parseCheckIgnoreOutput(gitError.stdout ?? '')
|
||||
}
|
||||
throw error
|
||||
}
|
||||
}
|
||||
|
||||
@@ -39,7 +39,8 @@ import {
|
||||
} from './git-handler-worktree-ops'
|
||||
import { forceDeletePreservedRelayBranch } from './git-handler-branch-cleanup'
|
||||
import { refreshLocalBaseRefForWorktreeCreateOp } from './git-handler-local-base-ref-refresh'
|
||||
import { checkIgnoredPathsOp, detectConflictOperation, getStatusOp } from './git-handler-status-ops'
|
||||
import { detectConflictOperation, getStatusOp } from './git-handler-status-ops'
|
||||
import { checkIgnoredPathsOp } from './git-handler-check-ignore'
|
||||
import { resolveRelayPushTarget } from './git-handler-push-target'
|
||||
import { isNoUpstreamError, normalizeGitErrorMessage } from '../shared/git-remote-error'
|
||||
import { upstreamOnlyCommitsArePatchEquivalent } from '../shared/git-upstream-status'
|
||||
@@ -68,6 +69,7 @@ import {
|
||||
} from '../shared/git-worktree-command-capabilities'
|
||||
import { GitResponseStreamRegistry } from './git-response-stream'
|
||||
import { GIT_RESPONSE_STREAM_THRESHOLD } from './protocol'
|
||||
import { endSubprocessStdin } from '../shared/subprocess-stdin-write'
|
||||
|
||||
const execFileAsync = promisify(execFile)
|
||||
const MAX_GIT_BUFFER = 10 * 1024 * 1024
|
||||
@@ -151,7 +153,7 @@ function execFileWithStdin(
|
||||
finish(null, stdout, stderr)
|
||||
})
|
||||
child.once('error', (error) => finish(error))
|
||||
child.stdin?.end(stdin)
|
||||
endSubprocessStdin(child.stdin, stdin)
|
||||
})
|
||||
}
|
||||
|
||||
@@ -290,6 +292,7 @@ export class GitHandler {
|
||||
signal?: AbortSignal
|
||||
nonInteractive?: boolean
|
||||
stdin?: string
|
||||
timeout?: number
|
||||
}
|
||||
): Promise<{ stdout: string; stderr: string }> {
|
||||
const env = buildRelayGitEnv()
|
||||
@@ -307,6 +310,7 @@ export class GitHandler {
|
||||
env,
|
||||
encoding: 'utf-8',
|
||||
maxBuffer: opts?.maxBuffer ?? MAX_GIT_BUFFER,
|
||||
timeout: opts?.timeout,
|
||||
signal: opts?.signal
|
||||
} satisfies ExecFileOptions
|
||||
if (opts?.stdin !== undefined) {
|
||||
|
||||
@@ -284,6 +284,7 @@ import type {
|
||||
JiraIssueType,
|
||||
JiraProject,
|
||||
JiraProjectStatusOrder,
|
||||
JiraPriority,
|
||||
LinearIssue,
|
||||
LinearProjectDetail,
|
||||
LinearProjectSummary,
|
||||
@@ -323,8 +324,16 @@ import {
|
||||
jiraGetIssue,
|
||||
jiraListCreateFields,
|
||||
jiraListIssueTypes,
|
||||
jiraListProjects
|
||||
jiraListProjects,
|
||||
jiraListPriorities
|
||||
} from '@/runtime/runtime-jira-client'
|
||||
import {
|
||||
sortJiraIssues,
|
||||
type JiraIssueSortColumn,
|
||||
type JiraIssueSortDirection,
|
||||
type JiraPrioritiesBySite
|
||||
} from './jira-issue-sorter'
|
||||
import { TaskPageJiraSortControls } from './task-page-jira-sort-controls'
|
||||
import {
|
||||
normalizeVisibleTaskProviders,
|
||||
restoreAvailableDefaultTaskProvider,
|
||||
@@ -4589,6 +4598,66 @@ export default function TaskPage(): React.JSX.Element {
|
||||
order: JiraProjectStatusOrder
|
||||
scopeKey: string
|
||||
} | null>(null)
|
||||
const [jiraOrderBy, setJiraOrderBy] = useState<JiraIssueSortColumn>('updated')
|
||||
const [jiraOrderDirection, setJiraOrderDirection] = useState<JiraIssueSortDirection>('desc')
|
||||
const [jiraPrioritiesBySite, setJiraPrioritiesBySite] = useState<JiraPrioritiesBySite>(
|
||||
() => new Map()
|
||||
)
|
||||
const jiraPrioritySiteIdsKey = useMemo(() => {
|
||||
const siteIds =
|
||||
selectedJiraSiteId && selectedJiraSiteId !== 'all'
|
||||
? [selectedJiraSiteId]
|
||||
: jiraIssues.flatMap((issue) => (issue.siteId ? [issue.siteId] : []))
|
||||
// Why: result refreshes replace the issue array; depend on the represented sites, not identity.
|
||||
return JSON.stringify([...new Set(siteIds)].sort())
|
||||
}, [jiraIssues, selectedJiraSiteId])
|
||||
|
||||
useEffect(() => {
|
||||
if (taskSource !== 'jira' || !jiraConnected || jiraOrderBy !== 'priority') {
|
||||
setJiraPrioritiesBySite((current) => (current.size === 0 ? current : new Map()))
|
||||
return
|
||||
}
|
||||
let cancelled = false
|
||||
const jiraPrioritySiteIds = JSON.parse(jiraPrioritySiteIdsKey) as string[]
|
||||
void Promise.all(
|
||||
jiraPrioritySiteIds.map(async (siteId) => {
|
||||
try {
|
||||
return [
|
||||
siteId,
|
||||
await jiraListPriorities(jiraTaskSourceContext ?? settings, siteId)
|
||||
] as const
|
||||
} catch {
|
||||
return [siteId, [] as JiraPriority[]] as const
|
||||
}
|
||||
})
|
||||
).then((prioritiesBySite) => {
|
||||
if (!cancelled) {
|
||||
setJiraPrioritiesBySite(new Map(prioritiesBySite))
|
||||
}
|
||||
})
|
||||
return () => {
|
||||
cancelled = true
|
||||
}
|
||||
}, [
|
||||
jiraConnected,
|
||||
jiraOrderBy,
|
||||
jiraPrioritySiteIdsKey,
|
||||
jiraTaskSourceContext,
|
||||
settings,
|
||||
taskSource
|
||||
])
|
||||
|
||||
const handleJiraSort = useCallback(
|
||||
(column: JiraIssueSortColumn) => {
|
||||
if (jiraOrderBy === column) {
|
||||
setJiraOrderDirection((prevDir) => (prevDir === 'asc' ? 'desc' : 'asc'))
|
||||
} else {
|
||||
setJiraOrderBy(column)
|
||||
setJiraOrderDirection(column === 'updated' || column === 'status' ? 'desc' : 'asc')
|
||||
}
|
||||
},
|
||||
[jiraOrderBy]
|
||||
)
|
||||
|
||||
useEffect(() => {
|
||||
if (taskResumeAppliedRef.current || !persistedUIReady || !settings) {
|
||||
@@ -5621,6 +5690,14 @@ export default function TaskPage(): React.JSX.Element {
|
||||
? jiraProjectStatusOrder.order
|
||||
: null
|
||||
|
||||
const sortedJiraIssues = useMemo(() => {
|
||||
return sortJiraIssues(
|
||||
displayedJiraIssues,
|
||||
jiraOrderBy,
|
||||
jiraOrderDirection,
|
||||
jiraPrioritiesBySite
|
||||
)
|
||||
}, [displayedJiraIssues, jiraOrderBy, jiraOrderDirection, jiraPrioritiesBySite])
|
||||
// New Linear project dialog state
|
||||
const [newLinearProjectOpen, setNewLinearProjectOpen] = useState(false)
|
||||
const [newLinearProjectName, setNewLinearProjectName] = useState('')
|
||||
@@ -10010,17 +10087,11 @@ export default function TaskPage(): React.JSX.Element {
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="grid h-8 flex-none grid-cols-[90px_minmax(0,1fr)_128px_92px_80px] items-center gap-3 border-b border-border/50 bg-muted/25 px-3 text-[11px] font-medium uppercase tracking-[0.08em] text-muted-foreground max-md:!hidden lg:grid-cols-[96px_minmax(0,1.25fr)_132px_120px_136px_96px_64px] xl:grid-cols-[104px_minmax(0,1.45fr)_144px_132px_160px_128px_72px]">
|
||||
<span>{translate('auto.components.TaskPage.37e7ee311e', 'Key')}</span>
|
||||
<span>{translate('auto.components.TaskPage.b1eaa18ace', 'Issue')}</span>
|
||||
<span>{translate('auto.components.TaskPage.154b0fa623', 'Status')}</span>
|
||||
<span>{translate('auto.components.TaskPage.c8d5bec5f7', 'Priority')}</span>
|
||||
<span className="block max-lg:!hidden">
|
||||
{translate('auto.components.TaskPage.d2a876ca53', 'Assignee')}
|
||||
</span>
|
||||
<span>{translate('auto.components.TaskPage.f362667d55', 'Updated')}</span>
|
||||
<span />
|
||||
</div>
|
||||
<TaskPageJiraSortControls
|
||||
direction={jiraOrderDirection}
|
||||
onSort={handleJiraSort}
|
||||
orderBy={jiraOrderBy}
|
||||
/>
|
||||
|
||||
<div
|
||||
className="min-h-0 flex-1 overflow-y-auto scrollbar-sleek"
|
||||
@@ -10075,11 +10146,12 @@ export default function TaskPage(): React.JSX.Element {
|
||||
<TaskPageJiraIssueList
|
||||
formatUpdatedAt={formatRelativeTime}
|
||||
getStatusTone={getJiraStatusTone}
|
||||
issues={displayedJiraIssues}
|
||||
issues={sortedJiraIssues}
|
||||
onOpenIssue={openJiraDetailPage}
|
||||
onStartWorkspace={handleUseJiraItem}
|
||||
selectedIssue={selectedJiraIssue}
|
||||
showSiteContext={selectedJiraSiteId === 'all'}
|
||||
statusDirection={jiraOrderBy === 'status' ? jiraOrderDirection : 'asc'}
|
||||
statusOrder={displayedJiraStatusOrder}
|
||||
/>
|
||||
</div>
|
||||
|
||||
@@ -0,0 +1,80 @@
|
||||
import type { JiraIssue, JiraPriority } from '../../../shared/types'
|
||||
|
||||
export type JiraIssueSortColumn = 'key' | 'title' | 'status' | 'priority' | 'assignee' | 'updated'
|
||||
|
||||
export type JiraIssueSortDirection = 'asc' | 'desc'
|
||||
export type JiraPrioritiesBySite = ReadonlyMap<string, readonly JiraPriority[]>
|
||||
|
||||
// Why: Jira instances can use synonymous names for the same standard priority tier.
|
||||
export const JIRA_PRIORITY_ORDER: Record<string, number> = {
|
||||
blocker: 99,
|
||||
highest: 99,
|
||||
critical: 99,
|
||||
high: 75,
|
||||
major: 75,
|
||||
medium: 50,
|
||||
normal: 50,
|
||||
low: 25,
|
||||
minor: 25,
|
||||
lowest: 1,
|
||||
trivial: 1
|
||||
}
|
||||
|
||||
export function getJiraPriorityWeight(
|
||||
priorityName?: string,
|
||||
priorityId?: string,
|
||||
jiraPriorities: readonly JiraPriority[] = []
|
||||
): number {
|
||||
if (!priorityName) {
|
||||
return 0
|
||||
}
|
||||
if (jiraPriorities.length > 0) {
|
||||
const idx = jiraPriorities.findIndex(
|
||||
(p) => p.id === priorityId || p.name.toLowerCase() === priorityName.toLowerCase()
|
||||
)
|
||||
if (idx !== -1) {
|
||||
// Why: site schemes have different tier counts, so raw indices are not cross-site comparable.
|
||||
if (jiraPriorities.length === 1) {
|
||||
return 50
|
||||
}
|
||||
return 1 + ((jiraPriorities.length - 1 - idx) / (jiraPriorities.length - 1)) * 98
|
||||
}
|
||||
}
|
||||
const nameKey = priorityName.toLowerCase()
|
||||
if (nameKey in JIRA_PRIORITY_ORDER) {
|
||||
return JIRA_PRIORITY_ORDER[nameKey]
|
||||
}
|
||||
// Why: custom priority IDs are opaque identifiers, not ordering ranks.
|
||||
return 50
|
||||
}
|
||||
|
||||
export function sortJiraIssues(
|
||||
issues: readonly JiraIssue[],
|
||||
orderBy: JiraIssueSortColumn,
|
||||
orderDirection: JiraIssueSortDirection,
|
||||
jiraPrioritiesBySite: JiraPrioritiesBySite = new Map()
|
||||
): JiraIssue[] {
|
||||
return [...issues].sort((a, b) => {
|
||||
let comparison = 0
|
||||
if (orderBy === 'key') {
|
||||
comparison = a.key.localeCompare(b.key, undefined, { numeric: true })
|
||||
} else if (orderBy === 'title') {
|
||||
comparison = a.title.localeCompare(b.title)
|
||||
} else if (orderBy === 'status') {
|
||||
comparison = 0
|
||||
} else if (orderBy === 'priority') {
|
||||
const prioritiesA = jiraPrioritiesBySite.get(a.siteId ?? '')
|
||||
const prioritiesB = jiraPrioritiesBySite.get(b.siteId ?? '')
|
||||
const weightA = getJiraPriorityWeight(a.priority?.name, a.priority?.id, prioritiesA)
|
||||
const weightB = getJiraPriorityWeight(b.priority?.name, b.priority?.id, prioritiesB)
|
||||
comparison = weightA - weightB
|
||||
} else if (orderBy === 'assignee') {
|
||||
const userA = a.assignee?.displayName ?? ''
|
||||
const userB = b.assignee?.displayName ?? ''
|
||||
comparison = userA.localeCompare(userB)
|
||||
} else if (orderBy === 'updated') {
|
||||
comparison = new Date(a.updatedAt).getTime() - new Date(b.updatedAt).getTime()
|
||||
}
|
||||
return orderDirection === 'asc' ? comparison : -comparison
|
||||
})
|
||||
}
|
||||
@@ -1,4 +1,4 @@
|
||||
import { useCallback } from 'react'
|
||||
import { useCallback, useMemo } from 'react'
|
||||
import { useShallow } from 'zustand/react/shallow'
|
||||
import { toast } from 'sonner'
|
||||
import { activateTabAndFocusPane } from '@/lib/activate-tab-and-focus-pane'
|
||||
@@ -7,10 +7,12 @@ import { useAppStore } from '@/store'
|
||||
import type { AgentStatusState } from '../../../../shared/agent-status-types'
|
||||
import type { AiVaultSession } from '../../../../shared/ai-vault-types'
|
||||
import { translate } from '@/i18n/i18n'
|
||||
import { findOriginalAiVaultSessionPane } from './ai-vault-original-pane'
|
||||
import {
|
||||
findAiVaultSessionLiveState,
|
||||
findOriginalAiVaultSessionPane
|
||||
} from './ai-vault-original-pane'
|
||||
createLazyAiVaultOriginalPaneIndex,
|
||||
findAiVaultSessionLiveStateInIndex,
|
||||
findOriginalAiVaultSessionPaneInIndex
|
||||
} from './ai-vault-original-pane-index'
|
||||
|
||||
export function useAiVaultOriginalPaneActions(): {
|
||||
getOriginalPaneTarget: (
|
||||
@@ -29,15 +31,23 @@ export function useAiVaultOriginalPaneActions(): {
|
||||
terminalLayoutsByTabId: s.terminalLayoutsByTabId
|
||||
}))
|
||||
)
|
||||
|
||||
const getOriginalPaneTarget = useCallback(
|
||||
(session: AiVaultSession) => findOriginalAiVaultSessionPane(originalPaneLookupState, session),
|
||||
// Why: loading, filtered, or collapsed views may render no session rows.
|
||||
// Build once on the first actual lookup, then share it across visible rows.
|
||||
const getOriginalPaneIndex = useMemo(
|
||||
() => createLazyAiVaultOriginalPaneIndex(originalPaneLookupState),
|
||||
[originalPaneLookupState]
|
||||
)
|
||||
|
||||
const getOriginalPaneTarget = useCallback(
|
||||
(session: AiVaultSession) =>
|
||||
findOriginalAiVaultSessionPaneInIndex(getOriginalPaneIndex(), session),
|
||||
[getOriginalPaneIndex]
|
||||
)
|
||||
|
||||
const getSessionLiveState = useCallback(
|
||||
(session: AiVaultSession) => findAiVaultSessionLiveState(originalPaneLookupState, session),
|
||||
[originalPaneLookupState]
|
||||
(session: AiVaultSession) =>
|
||||
findAiVaultSessionLiveStateInIndex(getOriginalPaneIndex(), session),
|
||||
[getOriginalPaneIndex]
|
||||
)
|
||||
|
||||
const jumpToOriginalPane = useCallback((session: AiVaultSession): void => {
|
||||
|
||||
@@ -0,0 +1,254 @@
|
||||
import { describe, expect, it } from 'vitest'
|
||||
import type { AgentStatusEntry } from '../../../../shared/agent-status-types'
|
||||
import type { AiVaultSession } from '../../../../shared/ai-vault-types'
|
||||
import {
|
||||
buildAiVaultOriginalPaneIndex,
|
||||
createLazyAiVaultOriginalPaneIndex,
|
||||
findAiVaultSessionLiveStateInIndex,
|
||||
findOriginalAiVaultSessionPaneInIndex
|
||||
} from './ai-vault-original-pane-index'
|
||||
import {
|
||||
findAiVaultSessionLiveState,
|
||||
findOriginalAiVaultSessionPane
|
||||
} from './ai-vault-original-pane'
|
||||
|
||||
const SESSION: AiVaultSession = {
|
||||
id: 'codex:target-session',
|
||||
executionHostId: 'local',
|
||||
agent: 'codex',
|
||||
sessionId: 'target-session',
|
||||
title: 'Target session',
|
||||
cwd: '/repo',
|
||||
branch: null,
|
||||
model: null,
|
||||
filePath: '/tmp/target-session.jsonl',
|
||||
codexHome: null,
|
||||
createdAt: null,
|
||||
updatedAt: '2026-07-10T00:00:00.000Z',
|
||||
modifiedAt: '2026-07-10T00:00:00.000Z',
|
||||
messageCount: 1,
|
||||
totalTokens: 1,
|
||||
previewMessages: [],
|
||||
queuedMessageCount: 0,
|
||||
subagentTranscriptCount: 0,
|
||||
resumeCommand: "codex resume 'target-session'",
|
||||
subagent: null
|
||||
}
|
||||
|
||||
function countedRecord<T>(count: number, makeValue: (index: number) => T) {
|
||||
const reads = { value: 0 }
|
||||
const record: Record<string, T> = {}
|
||||
for (let index = 0; index < count; index += 1) {
|
||||
Object.defineProperty(record, `entry-${index}`, {
|
||||
enumerable: true,
|
||||
get: () => {
|
||||
reads.value += 1
|
||||
return makeValue(index)
|
||||
}
|
||||
})
|
||||
}
|
||||
return { reads, record }
|
||||
}
|
||||
|
||||
function unrelatedEntry(index: number): AgentStatusEntry {
|
||||
return {
|
||||
state: 'done',
|
||||
prompt: `Other task ${index}`,
|
||||
updatedAt: index,
|
||||
stateStartedAt: index,
|
||||
agentType: 'claude',
|
||||
paneKey: `other-tab-${index}:11111111-1111-4111-8111-111111111111`,
|
||||
tabId: `other-tab-${index}`,
|
||||
worktreeId: 'other-worktree',
|
||||
stateHistory: [],
|
||||
providerSession: { key: 'session_id', id: `other-session-${index}` }
|
||||
}
|
||||
}
|
||||
|
||||
describe('AI Vault original-pane index', () => {
|
||||
it('builds lazily on the first lookup and shares one index across callbacks', () => {
|
||||
const live = countedRecord(500, unrelatedEntry)
|
||||
const retained = countedRecord(500, (index) => ({
|
||||
entry: unrelatedEntry(index),
|
||||
worktreeId: 'other-worktree',
|
||||
tab: { id: `other-tab-${index}` },
|
||||
agentType: 'claude',
|
||||
startedAt: index
|
||||
}))
|
||||
const sleeping = countedRecord(500, (index) => ({
|
||||
paneKey: `other-tab-${index}:11111111-1111-4111-8111-111111111111`,
|
||||
tabId: `other-tab-${index}`,
|
||||
worktreeId: 'other-worktree',
|
||||
agent: 'claude',
|
||||
providerSession: { key: 'session_id', id: `other-session-${index}` },
|
||||
prompt: `Other task ${index}`,
|
||||
state: 'done',
|
||||
capturedAt: index,
|
||||
updatedAt: index,
|
||||
origin: 'live'
|
||||
}))
|
||||
const getIndex = createLazyAiVaultOriginalPaneIndex({
|
||||
agentStatusByPaneKey: live.record,
|
||||
retainedAgentsByPaneKey: retained.record,
|
||||
sleepingAgentSessionsByPaneKey: sleeping.record,
|
||||
tabsByWorktree: {},
|
||||
terminalLayoutsByTabId: {}
|
||||
} as never)
|
||||
|
||||
expect(live.reads.value + retained.reads.value + sleeping.reads.value).toBe(0)
|
||||
expect(findAiVaultSessionLiveStateInIndex(getIndex(), SESSION)).toBeNull()
|
||||
expect(live.reads.value + retained.reads.value + sleeping.reads.value).toBe(1_500)
|
||||
expect(findOriginalAiVaultSessionPaneInIndex(getIndex(), SESSION)).toBeNull()
|
||||
expect(live.reads.value + retained.reads.value + sleeping.reads.value).toBe(1_500)
|
||||
})
|
||||
|
||||
it('builds once instead of rescanning every agent collection for every visible row', () => {
|
||||
const live = countedRecord(500, unrelatedEntry)
|
||||
const retained = countedRecord(500, (index) => ({
|
||||
entry: unrelatedEntry(index),
|
||||
worktreeId: 'other-worktree',
|
||||
tab: { id: `other-tab-${index}` },
|
||||
agentType: 'claude',
|
||||
startedAt: index
|
||||
}))
|
||||
const sleeping = countedRecord(500, (index) => ({
|
||||
paneKey: `other-tab-${index}:11111111-1111-4111-8111-111111111111`,
|
||||
tabId: `other-tab-${index}`,
|
||||
worktreeId: 'other-worktree',
|
||||
agent: 'claude',
|
||||
providerSession: { key: 'session_id', id: `other-session-${index}` },
|
||||
prompt: `Other task ${index}`,
|
||||
state: 'done',
|
||||
capturedAt: index,
|
||||
updatedAt: index,
|
||||
origin: 'live'
|
||||
}))
|
||||
const state = {
|
||||
agentStatusByPaneKey: live.record,
|
||||
retainedAgentsByPaneKey: retained.record,
|
||||
sleepingAgentSessionsByPaneKey: sleeping.record,
|
||||
tabsByWorktree: {},
|
||||
terminalLayoutsByTabId: {}
|
||||
} as never
|
||||
|
||||
const index = buildAiVaultOriginalPaneIndex(state)
|
||||
expect(live.reads.value + retained.reads.value + sleeping.reads.value).toBe(1_500)
|
||||
|
||||
live.reads.value = 0
|
||||
retained.reads.value = 0
|
||||
sleeping.reads.value = 0
|
||||
for (let row = 0; row < 20; row += 1) {
|
||||
expect(findOriginalAiVaultSessionPaneInIndex(index, SESSION)).toBeNull()
|
||||
expect(findAiVaultSessionLiveStateInIndex(index, SESSION)).toBeNull()
|
||||
}
|
||||
expect(live.reads.value + retained.reads.value + sleeping.reads.value).toBe(0)
|
||||
})
|
||||
|
||||
it('preserves provider, prompt-fallback, retained, sleeping, and missing matches', () => {
|
||||
const leafIds = [
|
||||
'11111111-1111-4111-8111-111111111111',
|
||||
'22222222-2222-4222-8222-222222222222',
|
||||
'33333333-3333-4333-8333-333333333333',
|
||||
'44444444-4444-4444-8444-444444444444'
|
||||
]
|
||||
const tabs = leafIds.map((_, index) => ({
|
||||
id: `tab-${index + 1}`,
|
||||
ptyId: null,
|
||||
worktreeId: 'wt-1',
|
||||
title: 'Agent',
|
||||
customTitle: null,
|
||||
color: null,
|
||||
sortOrder: index,
|
||||
createdAt: index
|
||||
}))
|
||||
const layouts = Object.fromEntries(
|
||||
leafIds.map((leafId, index) => [
|
||||
`tab-${index + 1}`,
|
||||
{
|
||||
root: { type: 'leaf', leafId },
|
||||
activeLeafId: leafId,
|
||||
expandedLeafId: null,
|
||||
ptyIdsByLeafId: { [leafId]: `pty-${index + 1}` }
|
||||
}
|
||||
])
|
||||
)
|
||||
const liveDirect = {
|
||||
...unrelatedEntry(1),
|
||||
agentType: 'codex',
|
||||
paneKey: `tab-1:${leafIds[0]}`,
|
||||
tabId: 'tab-1',
|
||||
worktreeId: 'wt-1',
|
||||
state: 'blocked',
|
||||
providerSession: { key: 'session_id', id: 'live-direct' }
|
||||
} as AgentStatusEntry
|
||||
const livePrompt = {
|
||||
...unrelatedEntry(2),
|
||||
agentType: 'codex',
|
||||
paneKey: `tab-2:${leafIds[1]}`,
|
||||
tabId: 'tab-2',
|
||||
worktreeId: 'wt-1',
|
||||
prompt: 'Unique prompt match that is long enough',
|
||||
providerSession: undefined
|
||||
} as AgentStatusEntry
|
||||
const retainedEntry = {
|
||||
...unrelatedEntry(3),
|
||||
agentType: 'codex',
|
||||
paneKey: `tab-3:${leafIds[2]}`,
|
||||
tabId: 'tab-3',
|
||||
worktreeId: 'wt-1',
|
||||
providerSession: { key: 'session_id', id: 'retained-direct' }
|
||||
} as AgentStatusEntry
|
||||
const state = {
|
||||
agentStatusByPaneKey: {
|
||||
[liveDirect.paneKey]: liveDirect,
|
||||
[livePrompt.paneKey]: livePrompt
|
||||
},
|
||||
retainedAgentsByPaneKey: {
|
||||
[retainedEntry.paneKey]: {
|
||||
entry: retainedEntry,
|
||||
worktreeId: 'wt-1',
|
||||
tab: tabs[2],
|
||||
agentType: 'codex',
|
||||
startedAt: 1
|
||||
}
|
||||
},
|
||||
sleepingAgentSessionsByPaneKey: {
|
||||
'tab-4:1': {
|
||||
paneKey: 'tab-4:1',
|
||||
tabId: 'tab-4',
|
||||
worktreeId: 'wt-1',
|
||||
agent: 'codex',
|
||||
providerSession: { key: 'session_id', id: 'sleeping-direct' },
|
||||
prompt: 'Sleeping',
|
||||
state: 'done',
|
||||
capturedAt: 1,
|
||||
updatedAt: 1,
|
||||
origin: 'live'
|
||||
}
|
||||
},
|
||||
tabsByWorktree: { 'wt-1': tabs },
|
||||
terminalLayoutsByTabId: layouts
|
||||
} as never
|
||||
const index = buildAiVaultOriginalPaneIndex(state)
|
||||
const sessions = [
|
||||
{ ...SESSION, sessionId: 'live-direct' },
|
||||
{
|
||||
...SESSION,
|
||||
sessionId: 'prompt-only',
|
||||
title: 'Unique prompt match that is long enough'
|
||||
},
|
||||
{ ...SESSION, sessionId: 'retained-direct' },
|
||||
{ ...SESSION, sessionId: 'sleeping-direct' },
|
||||
{ ...SESSION, sessionId: 'missing' }
|
||||
]
|
||||
|
||||
for (const session of sessions) {
|
||||
expect(findOriginalAiVaultSessionPaneInIndex(index, session)).toEqual(
|
||||
findOriginalAiVaultSessionPane(state, session)
|
||||
)
|
||||
expect(findAiVaultSessionLiveStateInIndex(index, session)).toBe(
|
||||
findAiVaultSessionLiveState(state, session)
|
||||
)
|
||||
}
|
||||
})
|
||||
})
|
||||
@@ -0,0 +1,189 @@
|
||||
import type { AgentStatusState } from '../../../../shared/agent-status-types'
|
||||
import type { AiVaultSession } from '../../../../shared/ai-vault-types'
|
||||
import {
|
||||
promptsMatchSession,
|
||||
resolveOriginalPaneTarget,
|
||||
type OriginalPaneState,
|
||||
type AiVaultOriginalPaneTarget
|
||||
} from './ai-vault-original-pane'
|
||||
|
||||
type LiveEntry = NonNullable<OriginalPaneState['agentStatusByPaneKey'][string]>
|
||||
type RetainedEntry = NonNullable<OriginalPaneState['retainedAgentsByPaneKey'][string]>
|
||||
type SleepingEntry = NonNullable<OriginalPaneState['sleepingAgentSessionsByPaneKey'][string]>
|
||||
|
||||
type ProviderIndex<T> = Map<string, T[]>
|
||||
type AgentIndex<T> = Map<string, T[]>
|
||||
|
||||
export type AiVaultOriginalPaneIndex = {
|
||||
state: OriginalPaneState
|
||||
liveByProvider: ProviderIndex<LiveEntry>
|
||||
liveWithoutProviderByAgent: AgentIndex<LiveEntry>
|
||||
retainedByProvider: ProviderIndex<RetainedEntry>
|
||||
retainedWithoutProviderByAgent: AgentIndex<RetainedEntry>
|
||||
sleepingByProvider: ProviderIndex<SleepingEntry>
|
||||
}
|
||||
|
||||
function providerKey(agent: string, sessionId: string): string {
|
||||
// Why: unlike punctuation delimiters, NUL cannot collide with agent or
|
||||
// provider-session text, so distinct identity pairs stay distinct keys.
|
||||
return `${agent}\u0000${sessionId}`
|
||||
}
|
||||
|
||||
function appendToIndex<T>(index: Map<string, T[]>, key: string, value: T): void {
|
||||
const entries = index.get(key)
|
||||
if (entries) {
|
||||
entries.push(value)
|
||||
} else {
|
||||
index.set(key, [value])
|
||||
}
|
||||
}
|
||||
|
||||
export function buildAiVaultOriginalPaneIndex(state: OriginalPaneState): AiVaultOriginalPaneIndex {
|
||||
const liveByProvider: ProviderIndex<LiveEntry> = new Map()
|
||||
const liveWithoutProviderByAgent: AgentIndex<LiveEntry> = new Map()
|
||||
const retainedByProvider: ProviderIndex<RetainedEntry> = new Map()
|
||||
const retainedWithoutProviderByAgent: AgentIndex<RetainedEntry> = new Map()
|
||||
const sleepingByProvider: ProviderIndex<SleepingEntry> = new Map()
|
||||
|
||||
for (const entry of Object.values(state.agentStatusByPaneKey)) {
|
||||
if (!entry?.agentType) {
|
||||
continue
|
||||
}
|
||||
if (entry.providerSession) {
|
||||
appendToIndex(liveByProvider, providerKey(entry.agentType, entry.providerSession.id), entry)
|
||||
} else if (entry.providerSession === undefined) {
|
||||
appendToIndex(liveWithoutProviderByAgent, entry.agentType, entry)
|
||||
}
|
||||
}
|
||||
for (const retained of Object.values(state.retainedAgentsByPaneKey)) {
|
||||
if (!retained?.agentType) {
|
||||
continue
|
||||
}
|
||||
if (retained.entry.providerSession) {
|
||||
appendToIndex(
|
||||
retainedByProvider,
|
||||
providerKey(retained.agentType, retained.entry.providerSession.id),
|
||||
retained
|
||||
)
|
||||
} else if (retained.entry.providerSession === undefined) {
|
||||
appendToIndex(retainedWithoutProviderByAgent, retained.agentType, retained)
|
||||
}
|
||||
}
|
||||
for (const record of Object.values(state.sleepingAgentSessionsByPaneKey)) {
|
||||
if (record) {
|
||||
appendToIndex(
|
||||
sleepingByProvider,
|
||||
providerKey(record.agent, record.providerSession.id),
|
||||
record
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
return {
|
||||
state,
|
||||
liveByProvider,
|
||||
liveWithoutProviderByAgent,
|
||||
retainedByProvider,
|
||||
retainedWithoutProviderByAgent,
|
||||
sleepingByProvider
|
||||
}
|
||||
}
|
||||
|
||||
export function createLazyAiVaultOriginalPaneIndex(
|
||||
state: OriginalPaneState
|
||||
): () => AiVaultOriginalPaneIndex {
|
||||
let index: AiVaultOriginalPaneIndex | null = null
|
||||
return () => {
|
||||
index ??= buildAiVaultOriginalPaneIndex(state)
|
||||
return index
|
||||
}
|
||||
}
|
||||
|
||||
export function findOriginalAiVaultSessionPaneInIndex(
|
||||
index: AiVaultOriginalPaneIndex,
|
||||
session: AiVaultSession
|
||||
): AiVaultOriginalPaneTarget | null {
|
||||
const key = providerKey(session.agent, session.sessionId)
|
||||
const promptMatchedTargets: AiVaultOriginalPaneTarget[] = []
|
||||
|
||||
for (const entry of index.liveByProvider.get(key) ?? []) {
|
||||
const target = resolveOriginalPaneTarget({
|
||||
state: index.state,
|
||||
paneKey: entry.paneKey,
|
||||
worktreeIdHint: entry.worktreeId,
|
||||
tabIdHint: entry.tabId
|
||||
})
|
||||
if (target) {
|
||||
return target
|
||||
}
|
||||
}
|
||||
for (const entry of index.liveWithoutProviderByAgent.get(session.agent) ?? []) {
|
||||
if (!promptsMatchSession(session, entry)) {
|
||||
continue
|
||||
}
|
||||
const target = resolveOriginalPaneTarget({
|
||||
state: index.state,
|
||||
paneKey: entry.paneKey,
|
||||
worktreeIdHint: entry.worktreeId,
|
||||
tabIdHint: entry.tabId
|
||||
})
|
||||
if (target) {
|
||||
promptMatchedTargets.push(target)
|
||||
}
|
||||
}
|
||||
for (const retained of index.retainedByProvider.get(key) ?? []) {
|
||||
const target = resolveOriginalPaneTarget({
|
||||
state: index.state,
|
||||
paneKey: retained.entry.paneKey,
|
||||
worktreeIdHint: retained.worktreeId,
|
||||
tabIdHint: retained.entry.tabId ?? retained.tab.id
|
||||
})
|
||||
if (target) {
|
||||
return target
|
||||
}
|
||||
}
|
||||
for (const retained of index.retainedWithoutProviderByAgent.get(session.agent) ?? []) {
|
||||
if (!promptsMatchSession(session, retained.entry)) {
|
||||
continue
|
||||
}
|
||||
const target = resolveOriginalPaneTarget({
|
||||
state: index.state,
|
||||
paneKey: retained.entry.paneKey,
|
||||
worktreeIdHint: retained.worktreeId,
|
||||
tabIdHint: retained.entry.tabId ?? retained.tab.id
|
||||
})
|
||||
if (target) {
|
||||
promptMatchedTargets.push(target)
|
||||
}
|
||||
}
|
||||
for (const record of index.sleepingByProvider.get(key) ?? []) {
|
||||
const target = resolveOriginalPaneTarget({
|
||||
state: index.state,
|
||||
paneKey: record.paneKey,
|
||||
worktreeIdHint: record.worktreeId,
|
||||
tabIdHint: record.tabId
|
||||
})
|
||||
if (target) {
|
||||
return target
|
||||
}
|
||||
}
|
||||
|
||||
return promptMatchedTargets.length === 1 ? promptMatchedTargets[0] : null
|
||||
}
|
||||
|
||||
export function findAiVaultSessionLiveStateInIndex(
|
||||
index: AiVaultOriginalPaneIndex,
|
||||
session: AiVaultSession
|
||||
): AgentStatusState | null {
|
||||
const direct = index.liveByProvider.get(providerKey(session.agent, session.sessionId))
|
||||
if (direct?.[0]) {
|
||||
return direct[0].state
|
||||
}
|
||||
const promptMatchedStates: AgentStatusState[] = []
|
||||
for (const entry of index.liveWithoutProviderByAgent.get(session.agent) ?? []) {
|
||||
if (promptsMatchSession(session, entry)) {
|
||||
promptMatchedStates.push(entry.state)
|
||||
}
|
||||
}
|
||||
return promptMatchedStates.length === 1 ? promptMatchedStates[0] : null
|
||||
}
|
||||
@@ -12,7 +12,7 @@ export type AiVaultOriginalPaneTarget = {
|
||||
leafId: string
|
||||
}
|
||||
|
||||
type OriginalPaneState = Pick<
|
||||
export type OriginalPaneState = Pick<
|
||||
AppState,
|
||||
| 'agentStatusByPaneKey'
|
||||
| 'retainedAgentsByPaneKey'
|
||||
@@ -87,10 +87,10 @@ function entryPromptCandidates(entry: {
|
||||
return [...candidates]
|
||||
}
|
||||
|
||||
function promptsMatchSession(
|
||||
export function promptsMatchSession(
|
||||
session: AiVaultSession,
|
||||
entry: Parameters<typeof entryPromptCandidates>[0]
|
||||
) {
|
||||
): boolean {
|
||||
const sessionCandidates = sessionPromptCandidates(session)
|
||||
if (sessionCandidates.length === 0) {
|
||||
return false
|
||||
@@ -134,7 +134,7 @@ function getTabOwnerWorktreeId(
|
||||
return null
|
||||
}
|
||||
|
||||
function resolveOriginalPaneTarget(args: {
|
||||
export function resolveOriginalPaneTarget(args: {
|
||||
state: OriginalPaneState
|
||||
paneKey: string
|
||||
worktreeIdHint?: string
|
||||
|
||||
+1
-1
@@ -15,7 +15,7 @@ describe('right sidebar file/git runtime ownership boundaries', () => {
|
||||
'src/renderer/src/components/right-sidebar/useFileExplorerInlineInput.ts',
|
||||
'src/renderer/src/components/right-sidebar/useFileExplorerDragDrop.ts',
|
||||
'src/renderer/src/components/right-sidebar/useFileDuplicate.ts',
|
||||
'src/renderer/src/components/right-sidebar/useFileExplorerVisibleRowProjection.ts',
|
||||
'src/renderer/src/components/right-sidebar/use-file-explorer-ignored-paths.ts',
|
||||
'src/renderer/src/components/right-sidebar/useGitStatusPolling.ts',
|
||||
'src/renderer/src/components/right-sidebar/useFileSearchRunner.ts',
|
||||
'src/renderer/src/components/quick-open-file-list.ts'
|
||||
|
||||
@@ -0,0 +1,114 @@
|
||||
import { useEffect, useState } from 'react'
|
||||
import { getConnectionId } from '@/lib/connection-context'
|
||||
import { getRuntimeGitIgnoredPaths } from '@/runtime/runtime-git-client'
|
||||
import { getRightSidebarWorktreeRuntimeSettings } from './file-explorer-runtime-owner'
|
||||
|
||||
const EMPTY_IGNORED_PATHS: readonly string[] = []
|
||||
export const FILE_EXPLORER_IGNORED_QUERY_DEBOUNCE_MS = 300
|
||||
|
||||
export type IgnoredPathResult = {
|
||||
activeWorktreeId: string
|
||||
paths: string[]
|
||||
worktreePath: string
|
||||
}
|
||||
|
||||
export function getEffectiveFileExplorerIgnoredPaths({
|
||||
activeWorktreeId,
|
||||
canLoadIgnoredPaths,
|
||||
ignoredPathResult,
|
||||
worktreePath
|
||||
}: {
|
||||
activeWorktreeId: string | null
|
||||
canLoadIgnoredPaths: boolean
|
||||
ignoredPathResult: IgnoredPathResult | null
|
||||
worktreePath: string | null
|
||||
}): readonly string[] {
|
||||
const ignoredPathResultMatchesCurrentWorktree =
|
||||
ignoredPathResult !== null &&
|
||||
ignoredPathResult.activeWorktreeId === activeWorktreeId &&
|
||||
ignoredPathResult.worktreePath === worktreePath
|
||||
|
||||
if (!canLoadIgnoredPaths || !ignoredPathResultMatchesCurrentWorktree) {
|
||||
return EMPTY_IGNORED_PATHS
|
||||
}
|
||||
|
||||
// Why: expanding folders changes the query before the async ignored refresh returns.
|
||||
// Keep same-worktree answers so known ignored rows do not flash as normal text.
|
||||
return ignoredPathResult.paths
|
||||
}
|
||||
|
||||
export function useFileExplorerIgnoredPaths({
|
||||
activeWorktreeId,
|
||||
canLoadIgnoredPaths,
|
||||
relativePaths,
|
||||
shouldDebounceIgnoredQuery,
|
||||
worktreePath
|
||||
}: {
|
||||
activeWorktreeId: string | null
|
||||
canLoadIgnoredPaths: boolean
|
||||
relativePaths: readonly string[]
|
||||
shouldDebounceIgnoredQuery: boolean
|
||||
worktreePath: string | null
|
||||
}): readonly string[] {
|
||||
const [ignoredPathResult, setIgnoredPathResult] = useState<IgnoredPathResult | null>(null)
|
||||
|
||||
useEffect(() => {
|
||||
if (!canLoadIgnoredPaths || !activeWorktreeId || !worktreePath) {
|
||||
return
|
||||
}
|
||||
|
||||
let canceled = false
|
||||
const refresh = (): void => {
|
||||
const connectionId = getConnectionId(activeWorktreeId) ?? undefined
|
||||
void getRuntimeGitIgnoredPaths(
|
||||
{
|
||||
settings: getRightSidebarWorktreeRuntimeSettings(activeWorktreeId),
|
||||
worktreeId: activeWorktreeId,
|
||||
worktreePath,
|
||||
connectionId
|
||||
},
|
||||
[...relativePaths]
|
||||
)
|
||||
.then((paths) => {
|
||||
if (!canceled) {
|
||||
setIgnoredPathResult({ activeWorktreeId, paths, worktreePath })
|
||||
}
|
||||
})
|
||||
.catch(() => {
|
||||
if (!canceled) {
|
||||
setIgnoredPathResult({ activeWorktreeId, paths: [], worktreePath })
|
||||
}
|
||||
})
|
||||
}
|
||||
|
||||
// Why: every filter keystroke changes relativePaths. Waiting for a short
|
||||
// quiet window prevents obsolete queries from launching uncancellable Git
|
||||
// subprocess chains while the visible name projection stays immediate.
|
||||
const timer = shouldDebounceIgnoredQuery
|
||||
? window.setTimeout(refresh, FILE_EXPLORER_IGNORED_QUERY_DEBOUNCE_MS)
|
||||
: null
|
||||
if (timer === null) {
|
||||
refresh()
|
||||
}
|
||||
|
||||
return () => {
|
||||
canceled = true
|
||||
if (timer !== null) {
|
||||
window.clearTimeout(timer)
|
||||
}
|
||||
}
|
||||
}, [
|
||||
activeWorktreeId,
|
||||
canLoadIgnoredPaths,
|
||||
relativePaths,
|
||||
shouldDebounceIgnoredQuery,
|
||||
worktreePath
|
||||
])
|
||||
|
||||
return getEffectiveFileExplorerIgnoredPaths({
|
||||
activeWorktreeId,
|
||||
canLoadIgnoredPaths,
|
||||
ignoredPathResult,
|
||||
worktreePath
|
||||
})
|
||||
}
|
||||
+98
@@ -0,0 +1,98 @@
|
||||
// @vitest-environment happy-dom
|
||||
|
||||
import { act, cleanup, renderHook } from '@testing-library/react'
|
||||
import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest'
|
||||
import { useAppStore } from '@/store'
|
||||
import type { AppState } from '@/store/types'
|
||||
import { useFileExplorerVisibleRowProjection } from './useFileExplorerVisibleRowProjection'
|
||||
import { FILE_EXPLORER_IGNORED_QUERY_DEBOUNCE_MS } from './use-file-explorer-ignored-paths'
|
||||
|
||||
const getRuntimeGitIgnoredPathsMock = vi.hoisted(() => vi.fn())
|
||||
vi.mock('@/runtime/runtime-git-client', () => ({
|
||||
getRuntimeGitIgnoredPaths: getRuntimeGitIgnoredPathsMock
|
||||
}))
|
||||
|
||||
const initialAppState = useAppStore.getInitialState()
|
||||
const relativePaths = Array.from({ length: 5_000 }, (_, index) => `src/generated-${index}.ts`)
|
||||
|
||||
function useProjection(query: string) {
|
||||
return useFileExplorerVisibleRowProjection('worktree-1', '/repo', {}, new Set(), true, true, {
|
||||
query,
|
||||
relativePaths
|
||||
})
|
||||
}
|
||||
|
||||
function useTreeProjection() {
|
||||
return useFileExplorerVisibleRowProjection(
|
||||
'worktree-1',
|
||||
'/repo',
|
||||
{
|
||||
'/repo': {
|
||||
children: [
|
||||
{
|
||||
name: 'src',
|
||||
path: '/repo/src',
|
||||
relativePath: 'src',
|
||||
isDirectory: true,
|
||||
depth: 0
|
||||
}
|
||||
],
|
||||
loading: false
|
||||
}
|
||||
},
|
||||
new Set(),
|
||||
true,
|
||||
true,
|
||||
null
|
||||
)
|
||||
}
|
||||
|
||||
describe('file explorer ignored-path query debounce', () => {
|
||||
beforeEach(() => {
|
||||
vi.useFakeTimers()
|
||||
getRuntimeGitIgnoredPathsMock.mockReset().mockResolvedValue([])
|
||||
useAppStore.setState(initialAppState, true)
|
||||
useAppStore.setState({
|
||||
settings: { activeRuntimeEnvironmentId: null } as AppState['settings']
|
||||
})
|
||||
})
|
||||
|
||||
afterEach(() => {
|
||||
cleanup()
|
||||
useAppStore.setState(initialAppState, true)
|
||||
vi.useRealTimers()
|
||||
})
|
||||
|
||||
it('coalesces a broad typing burst into one final ignored-path request', async () => {
|
||||
const hook = renderHook(({ query }) => useProjection(query), {
|
||||
initialProps: { query: 's' }
|
||||
})
|
||||
|
||||
await act(async () => vi.advanceTimersByTimeAsync(100))
|
||||
hook.rerender({ query: 'sr' })
|
||||
await act(async () => vi.advanceTimersByTimeAsync(100))
|
||||
hook.rerender({ query: 'src' })
|
||||
|
||||
await act(async () => vi.advanceTimersByTimeAsync(FILE_EXPLORER_IGNORED_QUERY_DEBOUNCE_MS - 1))
|
||||
expect(getRuntimeGitIgnoredPathsMock).not.toHaveBeenCalled()
|
||||
|
||||
await act(async () => vi.advanceTimersByTimeAsync(1))
|
||||
expect(getRuntimeGitIgnoredPathsMock).toHaveBeenCalledTimes(1)
|
||||
expect(getRuntimeGitIgnoredPathsMock.mock.calls[0]?.[1]).toHaveLength(relativePaths.length)
|
||||
})
|
||||
|
||||
it('cancels the pending ignored-path request when the explorer unmounts', async () => {
|
||||
const hook = renderHook(() => useProjection('src'))
|
||||
hook.unmount()
|
||||
|
||||
await act(async () => vi.advanceTimersByTimeAsync(FILE_EXPLORER_IGNORED_QUERY_DEBOUNCE_MS))
|
||||
expect(getRuntimeGitIgnoredPathsMock).not.toHaveBeenCalled()
|
||||
})
|
||||
|
||||
it('keeps ordinary expanded-tree ignored checks immediate', () => {
|
||||
renderHook(() => useTreeProjection())
|
||||
|
||||
expect(getRuntimeGitIgnoredPathsMock).toHaveBeenCalledTimes(1)
|
||||
expect(getRuntimeGitIgnoredPathsMock.mock.calls[0]?.[1]).toEqual(['src'])
|
||||
})
|
||||
})
|
||||
+1
-6
@@ -2,9 +2,9 @@ import { afterEach, describe, expect, it, vi } from 'vitest'
|
||||
import type { DirCache, TreeNode } from './file-explorer-types'
|
||||
import {
|
||||
createVisibleFileExplorerRowProjection,
|
||||
getEffectiveFileExplorerIgnoredPaths,
|
||||
getFileExplorerIgnoredQueryRelativePaths
|
||||
} from './useFileExplorerVisibleRowProjection'
|
||||
import { getEffectiveFileExplorerIgnoredPaths } from './use-file-explorer-ignored-paths'
|
||||
import {
|
||||
FILE_EXPLORER_NAME_FILTER_QUERY_MAX_BYTES,
|
||||
getFileExplorerNameFilterExpandedPaths,
|
||||
@@ -336,8 +336,6 @@ describe('file explorer visible row projection', () => {
|
||||
})
|
||||
|
||||
it('keeps same-worktree ignored paths while an expanded-folder query is loading', () => {
|
||||
const previousRelativePaths = ['out', 'src']
|
||||
|
||||
expect(
|
||||
getEffectiveFileExplorerIgnoredPaths({
|
||||
activeWorktreeId: 'worktree-1',
|
||||
@@ -345,7 +343,6 @@ describe('file explorer visible row projection', () => {
|
||||
ignoredPathResult: {
|
||||
activeWorktreeId: 'worktree-1',
|
||||
paths: ['out'],
|
||||
relativePaths: previousRelativePaths,
|
||||
worktreePath: '/repo'
|
||||
},
|
||||
worktreePath: '/repo'
|
||||
@@ -372,7 +369,6 @@ describe('file explorer visible row projection', () => {
|
||||
ignoredPathResult: {
|
||||
activeWorktreeId: 'worktree-1',
|
||||
paths: ['out'],
|
||||
relativePaths: ['out'],
|
||||
worktreePath: '/repo'
|
||||
},
|
||||
worktreePath: '/repo'
|
||||
@@ -386,7 +382,6 @@ describe('file explorer visible row projection', () => {
|
||||
ignoredPathResult: {
|
||||
activeWorktreeId: 'worktree-1',
|
||||
paths: ['out'],
|
||||
relativePaths: ['out'],
|
||||
worktreePath: '/repo'
|
||||
},
|
||||
worktreePath: '/other-repo'
|
||||
|
||||
@@ -1,8 +1,5 @@
|
||||
import { useCallback, useEffect, useMemo, useState } from 'react'
|
||||
import { useCallback, useMemo } from 'react'
|
||||
import { useAppStore } from '@/store'
|
||||
import { getConnectionId } from '@/lib/connection-context'
|
||||
import { getRuntimeGitIgnoredPaths } from '@/runtime/runtime-git-client'
|
||||
import { getRightSidebarWorktreeRuntimeSettings } from './file-explorer-runtime-owner'
|
||||
import { isDotfileRelativePath } from './file-explorer-entries'
|
||||
import type { DirCache, TreeNode } from './file-explorer-types'
|
||||
import {
|
||||
@@ -16,17 +13,10 @@ import {
|
||||
getFileExplorerNameFilterIgnoredQueryRelativePaths,
|
||||
type FileExplorerNameFilterProjectionSource
|
||||
} from './file-explorer-name-filter-projection'
|
||||
import { useFileExplorerIgnoredPaths } from './use-file-explorer-ignored-paths'
|
||||
|
||||
const EMPTY_IGNORED_PATHS: readonly string[] = []
|
||||
const EMPTY_RELATIVE_PATHS: string[] = []
|
||||
|
||||
export type IgnoredPathResult = {
|
||||
activeWorktreeId: string
|
||||
paths: string[]
|
||||
relativePaths: readonly string[]
|
||||
worktreePath: string
|
||||
}
|
||||
|
||||
type VisibleFileExplorerRowProjectionOptions = {
|
||||
ignoredSet: Set<string>
|
||||
nameFilter?: FileExplorerNameFilterProjectionSource | null
|
||||
@@ -119,31 +109,6 @@ export function createVisibleFileExplorerRowProjection(
|
||||
return createFileExplorerRowProjectionFromParts(visibleFlatRows, rowsByPath)
|
||||
}
|
||||
|
||||
export function getEffectiveFileExplorerIgnoredPaths({
|
||||
activeWorktreeId,
|
||||
canLoadIgnoredPaths,
|
||||
ignoredPathResult,
|
||||
worktreePath
|
||||
}: {
|
||||
activeWorktreeId: string | null
|
||||
canLoadIgnoredPaths: boolean
|
||||
ignoredPathResult: IgnoredPathResult | null
|
||||
worktreePath: string | null
|
||||
}): readonly string[] {
|
||||
const ignoredPathResultMatchesCurrentWorktree =
|
||||
ignoredPathResult !== null &&
|
||||
ignoredPathResult.activeWorktreeId === activeWorktreeId &&
|
||||
ignoredPathResult.worktreePath === worktreePath
|
||||
|
||||
if (!canLoadIgnoredPaths || !ignoredPathResultMatchesCurrentWorktree) {
|
||||
return EMPTY_IGNORED_PATHS
|
||||
}
|
||||
|
||||
// Why: expanding folders changes the query before the async ignored refresh returns.
|
||||
// Keep same-worktree answers so known ignored rows do not flash as normal text.
|
||||
return ignoredPathResult.paths
|
||||
}
|
||||
|
||||
export function useFileExplorerVisibleRowProjection(
|
||||
activeWorktreeId: string | null,
|
||||
worktreePath: string | null,
|
||||
@@ -163,7 +128,6 @@ export function useFileExplorerVisibleRowProjection(
|
||||
const settings = useAppStore((s) => s.settings)
|
||||
const updateSettings = useAppStore((s) => s.updateSettings)
|
||||
const showGitIgnoredFiles = settings?.showGitIgnoredFiles ?? true
|
||||
const [ignoredPathResult, setIgnoredPathResult] = useState<IgnoredPathResult | null>(null)
|
||||
const relativePaths = useMemo(
|
||||
() =>
|
||||
activeRepoSupportsGit
|
||||
@@ -181,53 +145,12 @@ export function useFileExplorerVisibleRowProjection(
|
||||
Boolean(activeWorktreeId) &&
|
||||
Boolean(worktreePath) &&
|
||||
relativePaths.length > 0
|
||||
|
||||
useEffect(() => {
|
||||
if (!canLoadIgnoredPaths || !activeWorktreeId || !worktreePath) {
|
||||
return
|
||||
}
|
||||
|
||||
let canceled = false
|
||||
const connectionId = getConnectionId(activeWorktreeId) ?? undefined
|
||||
void getRuntimeGitIgnoredPaths(
|
||||
{
|
||||
settings: getRightSidebarWorktreeRuntimeSettings(activeWorktreeId),
|
||||
worktreeId: activeWorktreeId,
|
||||
worktreePath,
|
||||
connectionId
|
||||
},
|
||||
[...relativePaths]
|
||||
)
|
||||
.then((nextIgnoredPaths) => {
|
||||
if (!canceled) {
|
||||
setIgnoredPathResult({
|
||||
activeWorktreeId,
|
||||
paths: nextIgnoredPaths,
|
||||
relativePaths,
|
||||
worktreePath
|
||||
})
|
||||
}
|
||||
})
|
||||
.catch(() => {
|
||||
if (!canceled) {
|
||||
setIgnoredPathResult({
|
||||
activeWorktreeId,
|
||||
paths: [],
|
||||
relativePaths,
|
||||
worktreePath
|
||||
})
|
||||
}
|
||||
})
|
||||
|
||||
return () => {
|
||||
canceled = true
|
||||
}
|
||||
}, [activeWorktreeId, canLoadIgnoredPaths, relativePaths, worktreePath])
|
||||
|
||||
const effectiveIgnoredPaths = getEffectiveFileExplorerIgnoredPaths({
|
||||
const shouldDebounceIgnoredQuery = nameFilter !== null
|
||||
const effectiveIgnoredPaths = useFileExplorerIgnoredPaths({
|
||||
activeWorktreeId,
|
||||
canLoadIgnoredPaths,
|
||||
ignoredPathResult,
|
||||
relativePaths,
|
||||
shouldDebounceIgnoredQuery,
|
||||
worktreePath
|
||||
})
|
||||
const ignoredSet = useMemo(() => buildIgnoredSet(effectiveIgnoredPaths), [effectiveIgnoredPaths])
|
||||
|
||||
@@ -62,9 +62,19 @@ import {
|
||||
DialogTitle
|
||||
} from '../ui/dialog'
|
||||
import { getCodexAccountAuthWarning } from './codex-account-auth-warning'
|
||||
import {
|
||||
getProviderAccountActiveIdForView,
|
||||
getProviderAccountRuntime,
|
||||
providerAccountIsActiveInView,
|
||||
providerAccountMatchesView,
|
||||
WSL_DEFAULT_DISTRO_KEY,
|
||||
type ProviderAccountRuntimeView
|
||||
} from './provider-account-visibility'
|
||||
import { translate } from '@/i18n/i18n'
|
||||
import { cn } from '@/lib/utils'
|
||||
import {
|
||||
emptyClaudeAccountsState,
|
||||
emptyCodexAccountsState,
|
||||
hasRemoteProviderAccountOwner,
|
||||
removeClaudeProviderAccount,
|
||||
removeCodexProviderAccount,
|
||||
@@ -146,6 +156,7 @@ type AccountsPaneProps = {
|
||||
wslAvailable?: boolean
|
||||
wslDistros?: string[]
|
||||
wslCapabilitiesLoading?: boolean
|
||||
accountOwnerPlatform?: NodeJS.Platform | null
|
||||
}
|
||||
|
||||
function getHostRuntimeLabel(): string {
|
||||
@@ -164,46 +175,6 @@ function getCodexAccountLabel(
|
||||
return state.accounts.find((account) => account.id === accountId)?.email ?? 'Codex account'
|
||||
}
|
||||
|
||||
function getActiveCodexAccountIdForRuntime(
|
||||
state: CodexRateLimitAccountsState,
|
||||
runtime: LocalAccountRuntime
|
||||
): string | null {
|
||||
if (runtime.runtime === 'host') {
|
||||
return state.activeAccountIdsByRuntime?.host ?? state.activeAccountId
|
||||
}
|
||||
if (runtime.wslDistro) {
|
||||
return state.activeAccountIdsByRuntime?.wsl?.[runtime.wslDistro] ?? null
|
||||
}
|
||||
const defaultSelection = state.activeAccountIdsByRuntime?.wsl?.__default__
|
||||
if (defaultSelection) {
|
||||
return defaultSelection
|
||||
}
|
||||
const selectedIds = Array.from(
|
||||
new Set(Object.values(state.activeAccountIdsByRuntime?.wsl ?? {}).filter(Boolean))
|
||||
)
|
||||
return selectedIds.length === 1 ? selectedIds[0] : null
|
||||
}
|
||||
|
||||
function getActiveClaudeAccountIdForRuntime(
|
||||
state: ClaudeRateLimitAccountsState,
|
||||
runtime: LocalAccountRuntime
|
||||
): string | null {
|
||||
if (runtime.runtime === 'host') {
|
||||
return state.activeAccountIdsByRuntime?.host ?? state.activeAccountId
|
||||
}
|
||||
if (runtime.wslDistro) {
|
||||
return state.activeAccountIdsByRuntime?.wsl?.[runtime.wslDistro] ?? null
|
||||
}
|
||||
const defaultSelection = state.activeAccountIdsByRuntime?.wsl?.__default__
|
||||
if (defaultSelection) {
|
||||
return defaultSelection
|
||||
}
|
||||
const selectedIds = Array.from(
|
||||
new Set(Object.values(state.activeAccountIdsByRuntime?.wsl ?? {}).filter(Boolean))
|
||||
)
|
||||
return selectedIds.length === 1 ? selectedIds[0] : null
|
||||
}
|
||||
|
||||
function getClaudeAccountLabel(
|
||||
state: ClaudeRateLimitAccountsState,
|
||||
accountId: string | null | undefined
|
||||
@@ -288,26 +259,6 @@ type LocalAccountRuntime = {
|
||||
label: string
|
||||
}
|
||||
|
||||
function accountMatchesRuntime(
|
||||
account:
|
||||
| CodexRateLimitAccountsState['accounts'][number]
|
||||
| ClaudeRateLimitAccountsState['accounts'][number],
|
||||
runtime: LocalAccountRuntime
|
||||
): boolean {
|
||||
const accountRuntime =
|
||||
'authMethod' in account
|
||||
? (account.managedAuthRuntime ?? 'host')
|
||||
: (account.managedHomeRuntime ?? 'host')
|
||||
const accountDistro = account.wslDistro ?? null
|
||||
if (runtime.runtime === 'host') {
|
||||
return accountRuntime !== 'wsl'
|
||||
}
|
||||
if (accountRuntime !== 'wsl') {
|
||||
return false
|
||||
}
|
||||
return runtime.wslDistro ? accountDistro === runtime.wslDistro : true
|
||||
}
|
||||
|
||||
function getSelectedAccountRuntime(
|
||||
settings: GlobalSettings,
|
||||
wslSupportedPlatform: boolean,
|
||||
@@ -344,7 +295,8 @@ export function AccountsPane({
|
||||
wslSupportedPlatform = false,
|
||||
wslAvailable = false,
|
||||
wslDistros = EMPTY_WSL_DISTROS,
|
||||
wslCapabilitiesLoading = false
|
||||
wslCapabilitiesLoading = false,
|
||||
accountOwnerPlatform = null
|
||||
}: AccountsPaneProps): React.JSX.Element {
|
||||
const searchQuery = useAppStore((s) => s.settingsSearchQuery)
|
||||
const codexRateLimits = useAppStore((s) => s.rateLimits.codex)
|
||||
@@ -388,33 +340,51 @@ export function AccountsPane({
|
||||
? `${localAccountRuntime.label.charAt(0).toLocaleLowerCase()}${localAccountRuntime.label.slice(1)}`
|
||||
: localAccountRuntime.label
|
||||
|
||||
const [codexAccounts, setCodexAccounts] = useState<CodexRateLimitAccountsState>({
|
||||
accounts: [],
|
||||
activeAccountId: null,
|
||||
activeAccountIdsByRuntime: { host: null, wsl: {} }
|
||||
})
|
||||
const [codexAccounts, setCodexAccounts] =
|
||||
useState<CodexRateLimitAccountsState>(emptyCodexAccountsState)
|
||||
const [codexAccountsLoaded, setCodexAccountsLoaded] = useState(false)
|
||||
const [codexAction, setCodexAction] = useState<
|
||||
'idle' | 'adding' | `reauth:${string}` | `remove:${string}` | `select:${string | 'system'}`
|
||||
>('idle')
|
||||
const [claudeAccounts, setClaudeAccounts] = useState<ClaudeRateLimitAccountsState>({
|
||||
accounts: [],
|
||||
activeAccountId: null,
|
||||
activeAccountIdsByRuntime: { host: null, wsl: {} }
|
||||
})
|
||||
const [claudeAccounts, setClaudeAccounts] =
|
||||
useState<ClaudeRateLimitAccountsState>(emptyClaudeAccountsState)
|
||||
const [claudeAction, setClaudeAction] = useState<
|
||||
'idle' | 'adding' | `reauth:${string}` | `remove:${string}` | `select:${string | 'system'}`
|
||||
>('idle')
|
||||
const [removeAccountId, setRemoveAccountId] = useState<string | null>(null)
|
||||
const [removeClaudeAccountId, setRemoveClaudeAccountId] = useState<string | null>(null)
|
||||
// Why: capture the account's runtime slot when the dialog opens; the roster
|
||||
// can change underneath an open dialog and lose the slot to diff for restarts.
|
||||
const [removeCodexTarget, setRemoveCodexTarget] = useState<{
|
||||
id: string
|
||||
runtime: ProviderAccountRuntimeView
|
||||
} | null>(null)
|
||||
const [removeClaudeTarget, setRemoveClaudeTarget] = useState<{
|
||||
id: string
|
||||
runtime: ProviderAccountRuntimeView
|
||||
} | null>(null)
|
||||
const accountVisibilityOptions = {
|
||||
remoteOwner: isRemoteAccountScope,
|
||||
ownerPlatform: accountOwnerPlatform
|
||||
}
|
||||
const visibleClaudeAccounts = claudeAccounts.accounts.filter((account) =>
|
||||
accountMatchesRuntime(account, accountRuntime)
|
||||
providerAccountMatchesView(account, accountRuntime, accountVisibilityOptions)
|
||||
)
|
||||
const visibleCodexAccounts = codexAccounts.accounts.filter((account) =>
|
||||
accountMatchesRuntime(account, accountRuntime)
|
||||
providerAccountMatchesView(account, accountRuntime, accountVisibilityOptions)
|
||||
)
|
||||
const activeCodexAccountId = getProviderAccountActiveIdForView(codexAccounts, accountRuntime)
|
||||
// Why: System default lights only when no account row is active; while a remote
|
||||
// owner's platform is unknown WSL rows hide fail-closed, so check the full roster.
|
||||
const ownerPlatformUnknown = isRemoteAccountScope && accountOwnerPlatform === null
|
||||
const systemCodexActive = !(
|
||||
ownerPlatformUnknown ? codexAccounts.accounts : visibleCodexAccounts
|
||||
).some((account) =>
|
||||
providerAccountIsActiveInView(account, codexAccounts, accountRuntime, accountVisibilityOptions)
|
||||
)
|
||||
const systemClaudeActive = !(
|
||||
ownerPlatformUnknown ? claudeAccounts.accounts : visibleClaudeAccounts
|
||||
).some((account) =>
|
||||
providerAccountIsActiveInView(account, claudeAccounts, accountRuntime, accountVisibilityOptions)
|
||||
)
|
||||
const activeCodexAccountId = getActiveCodexAccountIdForRuntime(codexAccounts, accountRuntime)
|
||||
const activeClaudeAccountId = getActiveClaudeAccountIdForRuntime(claudeAccounts, accountRuntime)
|
||||
// Why: the auth warning is derived from the desktop's own rate-limit poll;
|
||||
// with a remote owner it would misattribute local auth state to the server.
|
||||
const activeCodexAuthWarning =
|
||||
@@ -518,9 +488,15 @@ export function AccountsPane({
|
||||
{ activeRuntimeEnvironmentId },
|
||||
{
|
||||
onSnapshot: (snapshot) => {
|
||||
setCodexAccounts(snapshot.codex)
|
||||
setClaudeAccounts(snapshot.claude)
|
||||
setCodexAccountsLoaded(true)
|
||||
// Why: a failed provider's half is a substituted empty roster, not
|
||||
// authoritative data; keep prior state and leave the loaded gate shut.
|
||||
if (!snapshot.failedProviders?.includes('codex')) {
|
||||
setCodexAccounts(snapshot.codex)
|
||||
setCodexAccountsLoaded(true)
|
||||
}
|
||||
if (!snapshot.failedProviders?.includes('claude')) {
|
||||
setClaudeAccounts(snapshot.claude)
|
||||
}
|
||||
},
|
||||
onError: (error) => {
|
||||
toast.error(
|
||||
@@ -615,11 +591,11 @@ export function AccountsPane({
|
||||
/>
|
||||
{wslSupportedPlatform && accountRuntime.runtime === 'wsl' ? (
|
||||
<Select
|
||||
value={accountRuntime.wslDistro ?? '__default__'}
|
||||
value={accountRuntime.wslDistro ?? WSL_DEFAULT_DISTRO_KEY}
|
||||
onValueChange={(value) =>
|
||||
updateSettings({
|
||||
localAccountRuntime: 'wsl',
|
||||
localAccountWslDistro: value === '__default__' ? null : value
|
||||
localAccountWslDistro: value === WSL_DEFAULT_DISTRO_KEY ? null : value
|
||||
})
|
||||
}
|
||||
disabled={wslCapabilitiesLoading || !wslAvailable}
|
||||
@@ -640,7 +616,7 @@ export function AccountsPane({
|
||||
/>
|
||||
</SelectTrigger>
|
||||
<SelectContent>
|
||||
<SelectItem value="__default__">
|
||||
<SelectItem value={WSL_DEFAULT_DISTRO_KEY}>
|
||||
{translate('auto.components.settings.AccountsPane.2358ac71d2', 'WSL default')}
|
||||
</SelectItem>
|
||||
{wslDistros.map((distro) => (
|
||||
@@ -657,17 +633,20 @@ export function AccountsPane({
|
||||
</SearchableSetting>
|
||||
) : null
|
||||
|
||||
// Why: remote Windows flattens host and WSL rows, so mutation follow-up must
|
||||
// compare the selected row's runtime slot instead of the forced host view.
|
||||
const runCodexAccountAction = async (
|
||||
action: typeof codexAction,
|
||||
operation: () => Promise<CodexRateLimitAccountsState>
|
||||
operation: () => Promise<CodexRateLimitAccountsState>,
|
||||
actionRuntime: ProviderAccountRuntimeView = accountRuntime
|
||||
): Promise<void> => {
|
||||
const previousActiveAccountId = getActiveCodexAccountIdForRuntime(codexAccounts, accountRuntime)
|
||||
const previousActiveAccountId = getProviderAccountActiveIdForView(codexAccounts, actionRuntime)
|
||||
setCodexAction(action)
|
||||
try {
|
||||
const next = await operation()
|
||||
await syncCodexAccounts(next)
|
||||
recordFeatureInteraction('codex-account-switching')
|
||||
const nextActiveAccountId = getActiveCodexAccountIdForRuntime(next, accountRuntime)
|
||||
const nextActiveAccountId = getProviderAccountActiveIdForView(next, actionRuntime)
|
||||
const shouldPromptRestart =
|
||||
action === 'adding' ||
|
||||
(action.startsWith('select:') && previousActiveAccountId !== nextActiveAccountId) ||
|
||||
@@ -698,18 +677,16 @@ export function AccountsPane({
|
||||
|
||||
const runClaudeAccountAction = async (
|
||||
action: typeof claudeAction,
|
||||
operation: () => Promise<ClaudeRateLimitAccountsState>
|
||||
operation: () => Promise<ClaudeRateLimitAccountsState>,
|
||||
actionRuntime: ProviderAccountRuntimeView = accountRuntime
|
||||
): Promise<void> => {
|
||||
const previousActiveAccountId = getActiveClaudeAccountIdForRuntime(
|
||||
claudeAccounts,
|
||||
accountRuntime
|
||||
)
|
||||
const previousActiveAccountId = getProviderAccountActiveIdForView(claudeAccounts, actionRuntime)
|
||||
setClaudeAction(action)
|
||||
try {
|
||||
const next = await operation()
|
||||
await syncClaudeAccounts(next)
|
||||
recordFeatureInteraction('claude-account-switching')
|
||||
const nextActiveAccountId = getActiveClaudeAccountIdForRuntime(next, accountRuntime)
|
||||
const nextActiveAccountId = getProviderAccountActiveIdForView(next, actionRuntime)
|
||||
const shouldPromptRestart =
|
||||
action === 'adding' ||
|
||||
previousActiveAccountId !== nextActiveAccountId ||
|
||||
@@ -857,7 +834,7 @@ export function AccountsPane({
|
||||
}
|
||||
disabled={claudeAction !== 'idle' || accountRuntimeUnavailable}
|
||||
className={`flex w-full items-center justify-between gap-3 rounded-md border px-3 py-2.5 text-left transition-colors ${
|
||||
activeClaudeAccountId === null
|
||||
systemClaudeActive
|
||||
? 'border-foreground/20 bg-accent/15'
|
||||
: 'border-border/70 hover:border-border hover:bg-accent/8'
|
||||
} disabled:cursor-default disabled:opacity-100`}
|
||||
@@ -870,7 +847,7 @@ export function AccountsPane({
|
||||
'System default'
|
||||
)}
|
||||
</span>
|
||||
{activeClaudeAccountId === null ? (
|
||||
{systemClaudeActive ? (
|
||||
<Badge
|
||||
variant="outline"
|
||||
className="h-4 shrink-0 rounded px-1.5 text-[10px] font-medium leading-none text-foreground/80"
|
||||
@@ -904,7 +881,12 @@ export function AccountsPane({
|
||||
</div>
|
||||
) : (
|
||||
visibleClaudeAccounts.map((account) => {
|
||||
const isActive = activeClaudeAccountId === account.id
|
||||
const isActive = providerAccountIsActiveInView(
|
||||
account,
|
||||
claudeAccounts,
|
||||
accountRuntime,
|
||||
accountVisibilityOptions
|
||||
)
|
||||
const isReauthing = claudeAction === `reauth:${account.id}`
|
||||
const isBusy = claudeAction !== 'idle' || accountRuntimeUnavailable
|
||||
|
||||
@@ -920,15 +902,18 @@ export function AccountsPane({
|
||||
<div className="flex w-full items-center justify-between gap-3 max-md:flex-col max-md:items-start">
|
||||
<button
|
||||
type="button"
|
||||
onClick={() =>
|
||||
void runClaudeAccountAction(`select:${account.id}`, () =>
|
||||
selectClaudeProviderAccount(settings, {
|
||||
accountId: account.id,
|
||||
runtime: account.managedAuthRuntime ?? 'host',
|
||||
wslDistro: account.wslDistro ?? null
|
||||
})
|
||||
onClick={() => {
|
||||
const accountRuntimeView = getProviderAccountRuntime(account)
|
||||
void runClaudeAccountAction(
|
||||
`select:${account.id}`,
|
||||
() =>
|
||||
selectClaudeProviderAccount(settings, {
|
||||
accountId: account.id,
|
||||
...accountRuntimeView
|
||||
}),
|
||||
accountRuntimeView
|
||||
)
|
||||
}
|
||||
}}
|
||||
disabled={isBusy}
|
||||
className="flex min-w-0 flex-1 flex-col gap-0.5 text-left disabled:cursor-default"
|
||||
>
|
||||
@@ -964,8 +949,13 @@ export function AccountsPane({
|
||||
size="xs"
|
||||
onClick={(event) => {
|
||||
event.stopPropagation()
|
||||
void runClaudeAccountAction(`reauth:${account.id}`, () =>
|
||||
window.api.claudeAccounts.reauthenticate({ accountId: account.id })
|
||||
void runClaudeAccountAction(
|
||||
`reauth:${account.id}`,
|
||||
() =>
|
||||
window.api.claudeAccounts.reauthenticate({
|
||||
accountId: account.id
|
||||
}),
|
||||
getProviderAccountRuntime(account)
|
||||
)
|
||||
}}
|
||||
disabled={isRemoteAccountScope || isBusy}
|
||||
@@ -986,7 +976,10 @@ export function AccountsPane({
|
||||
size="xs"
|
||||
onClick={(event) => {
|
||||
event.stopPropagation()
|
||||
setRemoveClaudeAccountId(account.id)
|
||||
setRemoveClaudeTarget({
|
||||
id: account.id,
|
||||
runtime: getProviderAccountRuntime(account)
|
||||
})
|
||||
}}
|
||||
disabled={isBusy}
|
||||
className="h-6 px-2 text-muted-foreground hover:text-destructive"
|
||||
@@ -1135,7 +1128,7 @@ export function AccountsPane({
|
||||
className={`flex w-full items-center justify-between gap-3 rounded-md border px-3 py-2.5 text-left transition-colors ${
|
||||
systemCodexNeedsReauthentication
|
||||
? 'border-destructive/50 bg-destructive/5'
|
||||
: activeCodexAccountId === null
|
||||
: systemCodexActive
|
||||
? 'border-foreground/20 bg-accent/15'
|
||||
: 'border-border/70 hover:border-border hover:bg-accent/8'
|
||||
} disabled:cursor-default disabled:opacity-100`}
|
||||
@@ -1148,7 +1141,7 @@ export function AccountsPane({
|
||||
'System default'
|
||||
)}
|
||||
</span>
|
||||
{activeCodexAccountId === null ? (
|
||||
{systemCodexActive ? (
|
||||
<Badge
|
||||
variant="outline"
|
||||
className="h-4 shrink-0 rounded px-1.5 text-[10px] font-medium leading-none text-foreground/80"
|
||||
@@ -1203,7 +1196,12 @@ export function AccountsPane({
|
||||
</div>
|
||||
) : (
|
||||
visibleCodexAccounts.map((account) => {
|
||||
const isActive = activeCodexAccountId === account.id
|
||||
const isActive = providerAccountIsActiveInView(
|
||||
account,
|
||||
codexAccounts,
|
||||
accountRuntime,
|
||||
accountVisibilityOptions
|
||||
)
|
||||
// Why: same remote gate as the section-level warning — the
|
||||
// desktop's rate-limit poll says nothing about server accounts.
|
||||
const accountAuthWarning = isRemoteAccountScope
|
||||
@@ -1234,15 +1232,18 @@ export function AccountsPane({
|
||||
<div className="flex w-full items-center justify-between gap-3 max-md:flex-col max-md:items-start">
|
||||
<button
|
||||
type="button"
|
||||
onClick={() =>
|
||||
void runCodexAccountAction(`select:${account.id}`, () =>
|
||||
selectCodexProviderAccount(settings, {
|
||||
accountId: account.id,
|
||||
runtime: account.managedHomeRuntime ?? 'host',
|
||||
wslDistro: account.wslDistro ?? null
|
||||
})
|
||||
onClick={() => {
|
||||
const accountRuntimeView = getProviderAccountRuntime(account)
|
||||
void runCodexAccountAction(
|
||||
`select:${account.id}`,
|
||||
() =>
|
||||
selectCodexProviderAccount(settings, {
|
||||
accountId: account.id,
|
||||
...accountRuntimeView
|
||||
}),
|
||||
accountRuntimeView
|
||||
)
|
||||
}
|
||||
}}
|
||||
disabled={isBusy}
|
||||
className="flex min-w-0 flex-1 flex-col gap-0.5 text-left disabled:cursor-default"
|
||||
>
|
||||
@@ -1310,8 +1311,13 @@ export function AccountsPane({
|
||||
size="xs"
|
||||
onClick={(event) => {
|
||||
event.stopPropagation()
|
||||
void runCodexAccountAction(`reauth:${account.id}`, () =>
|
||||
window.api.codexAccounts.reauthenticate({ accountId: account.id })
|
||||
void runCodexAccountAction(
|
||||
`reauth:${account.id}`,
|
||||
() =>
|
||||
window.api.codexAccounts.reauthenticate({
|
||||
accountId: account.id
|
||||
}),
|
||||
getProviderAccountRuntime(account)
|
||||
)
|
||||
}}
|
||||
disabled={isRemoteAccountScope || isBusy}
|
||||
@@ -1332,7 +1338,10 @@ export function AccountsPane({
|
||||
size="xs"
|
||||
onClick={(event) => {
|
||||
event.stopPropagation()
|
||||
setRemoveAccountId(account.id)
|
||||
setRemoveCodexTarget({
|
||||
id: account.id,
|
||||
runtime: getProviderAccountRuntime(account)
|
||||
})
|
||||
}}
|
||||
disabled={isBusy}
|
||||
className="h-6 px-2 text-muted-foreground hover:text-destructive"
|
||||
@@ -1807,8 +1816,8 @@ export function AccountsPane({
|
||||
return (
|
||||
<div className="space-y-8">
|
||||
<Dialog
|
||||
open={removeAccountId !== null}
|
||||
onOpenChange={(open) => !open && setRemoveAccountId(null)}
|
||||
open={removeCodexTarget !== null}
|
||||
onOpenChange={(open) => !open && setRemoveCodexTarget(null)}
|
||||
>
|
||||
<DialogContent showCloseButton={false}>
|
||||
<DialogHeader>
|
||||
@@ -1826,19 +1835,21 @@ export function AccountsPane({
|
||||
</DialogDescription>
|
||||
</DialogHeader>
|
||||
<DialogFooter>
|
||||
<Button variant="outline" onClick={() => setRemoveAccountId(null)}>
|
||||
<Button variant="outline" onClick={() => setRemoveCodexTarget(null)}>
|
||||
{translate('auto.components.settings.AccountsPane.dbb9626ed1', 'Cancel')}
|
||||
</Button>
|
||||
<Button
|
||||
variant="destructive"
|
||||
onClick={() => {
|
||||
const accountId = removeAccountId
|
||||
if (!accountId) {
|
||||
const target = removeCodexTarget
|
||||
if (!target) {
|
||||
return
|
||||
}
|
||||
setRemoveAccountId(null)
|
||||
void runCodexAccountAction(`remove:${accountId}`, () =>
|
||||
removeCodexProviderAccount(settings, accountId)
|
||||
setRemoveCodexTarget(null)
|
||||
void runCodexAccountAction(
|
||||
`remove:${target.id}`,
|
||||
() => removeCodexProviderAccount(settings, target.id),
|
||||
target.runtime
|
||||
)
|
||||
}}
|
||||
>
|
||||
@@ -1848,8 +1859,8 @@ export function AccountsPane({
|
||||
</DialogContent>
|
||||
</Dialog>
|
||||
<Dialog
|
||||
open={removeClaudeAccountId !== null}
|
||||
onOpenChange={(open) => !open && setRemoveClaudeAccountId(null)}
|
||||
open={removeClaudeTarget !== null}
|
||||
onOpenChange={(open) => !open && setRemoveClaudeTarget(null)}
|
||||
>
|
||||
<DialogContent showCloseButton={false}>
|
||||
<DialogHeader>
|
||||
@@ -1867,19 +1878,21 @@ export function AccountsPane({
|
||||
</DialogDescription>
|
||||
</DialogHeader>
|
||||
<DialogFooter>
|
||||
<Button variant="outline" onClick={() => setRemoveClaudeAccountId(null)}>
|
||||
<Button variant="outline" onClick={() => setRemoveClaudeTarget(null)}>
|
||||
{translate('auto.components.settings.AccountsPane.dbb9626ed1', 'Cancel')}
|
||||
</Button>
|
||||
<Button
|
||||
variant="destructive"
|
||||
onClick={() => {
|
||||
const accountId = removeClaudeAccountId
|
||||
if (!accountId) {
|
||||
const target = removeClaudeTarget
|
||||
if (!target) {
|
||||
return
|
||||
}
|
||||
setRemoveClaudeAccountId(null)
|
||||
void runClaudeAccountAction(`remove:${accountId}`, () =>
|
||||
removeClaudeProviderAccount(settings, accountId)
|
||||
setRemoveClaudeTarget(null)
|
||||
void runClaudeAccountAction(
|
||||
`remove:${target.id}`,
|
||||
() => removeClaudeProviderAccount(settings, target.id),
|
||||
target.runtime
|
||||
)
|
||||
}}
|
||||
>
|
||||
|
||||
@@ -1136,6 +1136,7 @@ function Settings(): React.JSX.Element {
|
||||
wslAvailable={windowsTerminalCapabilities.wslAvailable}
|
||||
wslDistros={windowsTerminalCapabilities.wslDistros}
|
||||
wslCapabilitiesLoading={windowsTerminalCapabilities.isLoading}
|
||||
accountOwnerPlatform={windowsTerminalCapabilities.hostPlatform}
|
||||
/>
|
||||
) : null}
|
||||
</SettingsSection>
|
||||
|
||||
@@ -0,0 +1,227 @@
|
||||
import { describe, expect, it } from 'vitest'
|
||||
import type {
|
||||
ClaudeRateLimitAccountsState,
|
||||
CodexRateLimitAccountsState
|
||||
} from '../../../../shared/types'
|
||||
import {
|
||||
getProviderAccountActiveIdForView,
|
||||
getProviderAccountRuntime,
|
||||
providerAccountIsActiveInView,
|
||||
providerAccountMatchesView
|
||||
} from './provider-account-visibility'
|
||||
|
||||
const codexWslAccount = {
|
||||
id: 'codex-wsl',
|
||||
email: 'wsl@example.com',
|
||||
managedHomeRuntime: 'wsl',
|
||||
wslDistro: 'Ubuntu',
|
||||
providerAccountId: null,
|
||||
workspaceLabel: null,
|
||||
workspaceAccountId: null,
|
||||
createdAt: 1,
|
||||
updatedAt: 1,
|
||||
lastAuthenticatedAt: 1
|
||||
} satisfies CodexRateLimitAccountsState['accounts'][number]
|
||||
|
||||
const codexHostAccount = {
|
||||
id: 'codex-host',
|
||||
email: 'host@example.com',
|
||||
managedHomeRuntime: 'host',
|
||||
wslDistro: null,
|
||||
providerAccountId: null,
|
||||
workspaceLabel: null,
|
||||
workspaceAccountId: null,
|
||||
createdAt: 1,
|
||||
updatedAt: 1,
|
||||
lastAuthenticatedAt: 1
|
||||
} satisfies CodexRateLimitAccountsState['accounts'][number]
|
||||
|
||||
const claudeHostAccount = {
|
||||
id: 'claude-host',
|
||||
email: 'host@example.com',
|
||||
managedAuthRuntime: 'host',
|
||||
wslDistro: null,
|
||||
authMethod: 'subscription-oauth',
|
||||
organizationUuid: null,
|
||||
organizationName: null,
|
||||
createdAt: 1,
|
||||
updatedAt: 1,
|
||||
lastAuthenticatedAt: 1
|
||||
} satisfies ClaudeRateLimitAccountsState['accounts'][number]
|
||||
|
||||
describe('providerAccountMatchesView', () => {
|
||||
it('shows WSL accounts owned by a Windows server regardless of the client platform', () => {
|
||||
expect(
|
||||
providerAccountMatchesView(
|
||||
codexWslAccount,
|
||||
{ runtime: 'host' },
|
||||
{
|
||||
remoteOwner: true,
|
||||
ownerPlatform: 'win32'
|
||||
}
|
||||
)
|
||||
).toBe(true)
|
||||
})
|
||||
|
||||
it('does not expose stale WSL accounts from a non-Windows remote runtime', () => {
|
||||
expect(
|
||||
providerAccountMatchesView(
|
||||
codexWslAccount,
|
||||
{ runtime: 'host' },
|
||||
{
|
||||
remoteOwner: true,
|
||||
ownerPlatform: 'linux'
|
||||
}
|
||||
)
|
||||
).toBe(false)
|
||||
expect(
|
||||
providerAccountMatchesView(
|
||||
claudeHostAccount,
|
||||
{ runtime: 'wsl' },
|
||||
{
|
||||
remoteOwner: true,
|
||||
ownerPlatform: 'linux'
|
||||
}
|
||||
)
|
||||
).toBe(true)
|
||||
})
|
||||
|
||||
it('keeps local host and WSL views isolated by runtime and distro', () => {
|
||||
const localOptions = { remoteOwner: false, ownerPlatform: 'win32' as const }
|
||||
|
||||
expect(providerAccountMatchesView(codexWslAccount, { runtime: 'host' }, localOptions)).toBe(
|
||||
false
|
||||
)
|
||||
expect(
|
||||
providerAccountMatchesView(
|
||||
codexWslAccount,
|
||||
{ runtime: 'wsl', wslDistro: 'Ubuntu' },
|
||||
localOptions
|
||||
)
|
||||
).toBe(true)
|
||||
expect(
|
||||
providerAccountMatchesView(
|
||||
codexWslAccount,
|
||||
{ runtime: 'wsl', wslDistro: 'Debian' },
|
||||
localOptions
|
||||
)
|
||||
).toBe(false)
|
||||
})
|
||||
|
||||
it('hides WSL accounts while remote owner platform is still unknown', () => {
|
||||
// Why: unknown platform is fail-closed so stale WSL rows do not flash on a
|
||||
// non-Windows remote while capabilities load.
|
||||
expect(
|
||||
providerAccountMatchesView(
|
||||
codexWslAccount,
|
||||
{ runtime: 'host' },
|
||||
{
|
||||
remoteOwner: true,
|
||||
ownerPlatform: null
|
||||
}
|
||||
)
|
||||
).toBe(false)
|
||||
expect(
|
||||
providerAccountMatchesView(
|
||||
codexHostAccount,
|
||||
{ runtime: 'host' },
|
||||
{
|
||||
remoteOwner: true,
|
||||
ownerPlatform: null
|
||||
}
|
||||
)
|
||||
).toBe(true)
|
||||
})
|
||||
})
|
||||
|
||||
describe('providerAccountIsActiveInView', () => {
|
||||
it('detects a remote WSL selection change even when the host selection is unchanged', () => {
|
||||
const before = {
|
||||
activeAccountId: 'codex-host',
|
||||
activeAccountIdsByRuntime: {
|
||||
host: 'codex-host',
|
||||
wsl: { Ubuntu: 'codex-wsl-old' }
|
||||
}
|
||||
}
|
||||
const after = {
|
||||
...before,
|
||||
activeAccountIdsByRuntime: {
|
||||
...before.activeAccountIdsByRuntime,
|
||||
wsl: { Ubuntu: 'codex-wsl' }
|
||||
}
|
||||
}
|
||||
const actionRuntime = getProviderAccountRuntime(codexWslAccount)
|
||||
|
||||
// Why: AccountsPane's remote view is forced to host, but restart prompts
|
||||
// must compare the WSL slot changed by selecting or removing this row.
|
||||
expect(getProviderAccountActiveIdForView(before, actionRuntime)).toBe('codex-wsl-old')
|
||||
expect(getProviderAccountActiveIdForView(after, actionRuntime)).toBe('codex-wsl')
|
||||
expect(getProviderAccountActiveIdForView(before, { runtime: 'host' })).toBe(
|
||||
getProviderAccountActiveIdForView(after, { runtime: 'host' })
|
||||
)
|
||||
})
|
||||
|
||||
it('marks remote WSL accounts active from their own runtime selection', () => {
|
||||
const selection = {
|
||||
activeAccountId: 'codex-host',
|
||||
activeAccountIdsByRuntime: {
|
||||
host: 'codex-host',
|
||||
wsl: { Ubuntu: 'codex-wsl' }
|
||||
}
|
||||
}
|
||||
|
||||
// Why: remote Windows forces the host view filter, but Active must still
|
||||
// light for the WSL slot that actually selected this account.
|
||||
expect(
|
||||
providerAccountIsActiveInView(
|
||||
codexWslAccount,
|
||||
selection,
|
||||
{ runtime: 'host' },
|
||||
{ remoteOwner: true }
|
||||
)
|
||||
).toBe(true)
|
||||
expect(
|
||||
providerAccountIsActiveInView(
|
||||
codexHostAccount,
|
||||
selection,
|
||||
{ runtime: 'host' },
|
||||
{ remoteOwner: true }
|
||||
)
|
||||
).toBe(true)
|
||||
})
|
||||
|
||||
it('keeps local Active scoped to the selected host/WSL view', () => {
|
||||
const selection = {
|
||||
activeAccountId: 'codex-host',
|
||||
activeAccountIdsByRuntime: {
|
||||
host: 'codex-host',
|
||||
wsl: { Ubuntu: 'codex-wsl' }
|
||||
}
|
||||
}
|
||||
|
||||
expect(
|
||||
providerAccountIsActiveInView(
|
||||
codexHostAccount,
|
||||
selection,
|
||||
{ runtime: 'host' },
|
||||
{ remoteOwner: false }
|
||||
)
|
||||
).toBe(true)
|
||||
expect(
|
||||
providerAccountIsActiveInView(
|
||||
codexWslAccount,
|
||||
selection,
|
||||
{ runtime: 'host' },
|
||||
{ remoteOwner: false }
|
||||
)
|
||||
).toBe(false)
|
||||
expect(
|
||||
providerAccountIsActiveInView(
|
||||
codexWslAccount,
|
||||
selection,
|
||||
{ runtime: 'wsl', wslDistro: 'Ubuntu' },
|
||||
{ remoteOwner: false }
|
||||
)
|
||||
).toBe(true)
|
||||
})
|
||||
})
|
||||
@@ -0,0 +1,101 @@
|
||||
import type {
|
||||
ClaudeManagedAccountRuntimeSelection,
|
||||
ClaudeRateLimitAccountsState,
|
||||
CodexManagedAccountRuntimeSelection,
|
||||
CodexRateLimitAccountsState
|
||||
} from '../../../../shared/types'
|
||||
|
||||
type ProviderAccount =
|
||||
| ClaudeRateLimitAccountsState['accounts'][number]
|
||||
| CodexRateLimitAccountsState['accounts'][number]
|
||||
|
||||
type ProviderAccountSelection = {
|
||||
activeAccountId: string | null
|
||||
activeAccountIdsByRuntime?:
|
||||
| ClaudeManagedAccountRuntimeSelection
|
||||
| CodexManagedAccountRuntimeSelection
|
||||
}
|
||||
|
||||
export type ProviderAccountRuntimeView = {
|
||||
runtime: 'host' | 'wsl'
|
||||
wslDistro?: string | null
|
||||
}
|
||||
|
||||
// Why: sentinel for the "WSL default" distro slot; shared so the AccountsPane
|
||||
// distro Select and this active-account resolution can't drift out of sync.
|
||||
export const WSL_DEFAULT_DISTRO_KEY = '__default__'
|
||||
|
||||
export function getProviderAccountRuntime(account: ProviderAccount): {
|
||||
runtime: 'host' | 'wsl'
|
||||
wslDistro: string | null
|
||||
} {
|
||||
const runtime =
|
||||
'authMethod' in account
|
||||
? (account.managedAuthRuntime ?? 'host')
|
||||
: (account.managedHomeRuntime ?? 'host')
|
||||
return {
|
||||
runtime,
|
||||
wslDistro: account.wslDistro ?? null
|
||||
}
|
||||
}
|
||||
|
||||
export function getProviderAccountActiveIdForView(
|
||||
selection: ProviderAccountSelection,
|
||||
runtime: ProviderAccountRuntimeView
|
||||
): string | null {
|
||||
if (runtime.runtime === 'host') {
|
||||
return selection.activeAccountIdsByRuntime?.host ?? selection.activeAccountId ?? null
|
||||
}
|
||||
if (runtime.wslDistro) {
|
||||
return selection.activeAccountIdsByRuntime?.wsl?.[runtime.wslDistro] ?? null
|
||||
}
|
||||
const wsl = selection.activeAccountIdsByRuntime?.wsl ?? {}
|
||||
if (wsl[WSL_DEFAULT_DISTRO_KEY]) {
|
||||
return wsl[WSL_DEFAULT_DISTRO_KEY]
|
||||
}
|
||||
const selectedIds = Array.from(new Set(Object.values(wsl).filter(Boolean)))
|
||||
return selectedIds.length === 1 ? selectedIds[0] : null
|
||||
}
|
||||
|
||||
export function providerAccountMatchesView(
|
||||
account: ProviderAccount,
|
||||
runtime: ProviderAccountRuntimeView,
|
||||
options: {
|
||||
remoteOwner: boolean
|
||||
ownerPlatform: NodeJS.Platform | null
|
||||
}
|
||||
): boolean {
|
||||
const accountView = getProviderAccountRuntime(account)
|
||||
|
||||
if (options.remoteOwner) {
|
||||
// Why: provider accounts belong to the Orca runtime, not its client or a
|
||||
// downstream SSH host; a Windows runtime owns both host and WSL accounts.
|
||||
return options.ownerPlatform === 'win32' || accountView.runtime !== 'wsl'
|
||||
}
|
||||
if (runtime.runtime === 'host') {
|
||||
return accountView.runtime !== 'wsl'
|
||||
}
|
||||
if (accountView.runtime !== 'wsl') {
|
||||
return false
|
||||
}
|
||||
return runtime.wslDistro ? accountView.wslDistro === runtime.wslDistro : true
|
||||
}
|
||||
|
||||
export function providerAccountIsActiveInView(
|
||||
account: ProviderAccount,
|
||||
selection: ProviderAccountSelection,
|
||||
runtime: ProviderAccountRuntimeView,
|
||||
options: {
|
||||
remoteOwner: boolean
|
||||
}
|
||||
): boolean {
|
||||
if (options.remoteOwner) {
|
||||
// Why: remote Windows lists host and WSL accounts in one roster; Active must
|
||||
// follow each account's own runtime slot, not the forced host view filter.
|
||||
return (
|
||||
getProviderAccountActiveIdForView(selection, getProviderAccountRuntime(account)) ===
|
||||
account.id
|
||||
)
|
||||
}
|
||||
return getProviderAccountActiveIdForView(selection, runtime) === account.id
|
||||
}
|
||||
@@ -717,6 +717,11 @@ function ClaudeSwitcherMenu({
|
||||
// settings mutations don't re-run the remote snapshot round trip.
|
||||
const loadAccounts = useCallback(async () => {
|
||||
const snapshot = await fetchProviderAccountsSnapshot({ activeRuntimeEnvironmentId })
|
||||
// Why: a failed Claude half is a substituted empty roster; keep prior state.
|
||||
if (snapshot.failedProviders?.includes('claude')) {
|
||||
console.error('Claude account list failed; keeping previous status bar state.')
|
||||
return
|
||||
}
|
||||
if (mountedRef.current) {
|
||||
setAccounts(snapshot.claude)
|
||||
}
|
||||
@@ -1285,6 +1290,11 @@ function CodexSwitcherMenu({
|
||||
// settings mutations don't re-run the remote snapshot round trip.
|
||||
const loadAccounts = useCallback(async () => {
|
||||
const snapshot = await fetchProviderAccountsSnapshot({ activeRuntimeEnvironmentId })
|
||||
// Why: a failed Codex half is a substituted empty roster; keep prior state.
|
||||
if (snapshot.failedProviders?.includes('codex')) {
|
||||
console.error('Codex account list failed; keeping previous status bar state.')
|
||||
return
|
||||
}
|
||||
if (mountedRef.current) {
|
||||
setAccounts(snapshot.codex)
|
||||
}
|
||||
|
||||
@@ -8,6 +8,7 @@ import { cleanup, render, screen } from '@testing-library/react'
|
||||
import userEvent from '@testing-library/user-event'
|
||||
|
||||
import { TooltipProvider } from '@/components/ui/tooltip'
|
||||
import { sortJiraIssues } from './jira-issue-sorter'
|
||||
import { groupJiraIssuesByStatus, TaskPageJiraIssueList } from './task-page-jira-issue-list'
|
||||
import {
|
||||
getSingleJiraProjectScope,
|
||||
@@ -34,7 +35,7 @@ function jiraIssue(
|
||||
title: string,
|
||||
statusId: string,
|
||||
statusName: string,
|
||||
options: { projectKey?: string; siteId?: string } = {}
|
||||
options: { priority?: string; projectKey?: string; siteId?: string } = {}
|
||||
): JiraIssue {
|
||||
const siteId = options.siteId ?? 'site-1'
|
||||
const projectKey = options.projectKey ?? 'ALP'
|
||||
@@ -48,6 +49,7 @@ function jiraIssue(
|
||||
project: { id: projectKey, key: projectKey, name: projectKey, siteId },
|
||||
issueType: { id: '10001', name: 'Bug' },
|
||||
status: { id: statusId, name: statusName, categoryKey: 'new', categoryName: statusName },
|
||||
priority: options.priority ? { id: options.priority, name: options.priority } : undefined,
|
||||
labels: [],
|
||||
createdAt: '2026-01-01T00:00:00.000Z',
|
||||
updatedAt: '2026-01-01T00:00:00.000Z'
|
||||
@@ -83,6 +85,35 @@ describe('Jira issue status grouping', () => {
|
||||
expect(sections.map((section) => section.label)).toEqual(['In Progress', 'To Do', 'Done'])
|
||||
})
|
||||
|
||||
it('reverses Jira column order for descending status sort', () => {
|
||||
const sections = groupJiraIssuesByStatus(
|
||||
[
|
||||
jiraIssue('ALP-1', 'Done issue', '3', 'Done'),
|
||||
jiraIssue('ALP-2', 'To do issue', '1', 'To Do'),
|
||||
jiraIssue('ALP-3', 'Progress issue', '2', 'In Progress')
|
||||
],
|
||||
statusOrder([['1', '2'], ['3']]),
|
||||
'desc'
|
||||
)
|
||||
|
||||
expect(sections.map((section) => section.label)).toEqual(['Done', 'To Do', 'In Progress'])
|
||||
})
|
||||
|
||||
it('preserves priority sorting inside the production status groups', () => {
|
||||
const sortedIssues = sortJiraIssues(
|
||||
[
|
||||
jiraIssue('ALP-1', 'High issue', '1', 'To Do', { priority: 'High' }),
|
||||
jiraIssue('ALP-2', 'Low issue', '1', 'To Do', { priority: 'Low' })
|
||||
],
|
||||
'priority',
|
||||
'asc'
|
||||
)
|
||||
|
||||
const sections = groupJiraIssuesByStatus(sortedIssues, statusOrder([['1']]))
|
||||
|
||||
expect(sections[0]?.issues.map((issue) => issue.key)).toEqual(['ALP-2', 'ALP-1'])
|
||||
})
|
||||
|
||||
it('places statuses missing from board configuration last in alphabetical order', () => {
|
||||
const sections = groupJiraIssuesByStatus(
|
||||
[
|
||||
|
||||
@@ -22,6 +22,7 @@ type TaskPageJiraIssueListProps = {
|
||||
onStartWorkspace: (issue: JiraIssue) => void
|
||||
selectedIssue: JiraIssue | null
|
||||
showSiteContext: boolean
|
||||
statusDirection?: 'asc' | 'desc'
|
||||
statusOrder: JiraProjectStatusOrder | null
|
||||
}
|
||||
|
||||
@@ -50,7 +51,8 @@ function sectionColumnRank(
|
||||
|
||||
export function groupJiraIssuesByStatus(
|
||||
issues: readonly JiraIssue[],
|
||||
statusOrder: JiraProjectStatusOrder | null
|
||||
statusOrder: JiraProjectStatusOrder | null,
|
||||
statusDirection: 'asc' | 'desc' = 'asc'
|
||||
): TaskPageJiraIssueSection[] {
|
||||
const sections = new Map<string, TaskPageJiraIssueSection>()
|
||||
for (const issue of issues) {
|
||||
@@ -67,11 +69,12 @@ export function groupJiraIssuesByStatus(
|
||||
const sectionRanks = new Map(
|
||||
[...sections.values()].map((section) => [section.key, sectionColumnRank(section, ranks)])
|
||||
)
|
||||
return [...sections.values()].sort((a, b) => {
|
||||
const sortedSections = [...sections.values()].sort((a, b) => {
|
||||
const rankA = sectionRanks.get(a.key) ?? Number.POSITIVE_INFINITY
|
||||
const rankB = sectionRanks.get(b.key) ?? Number.POSITIVE_INFINITY
|
||||
return rankA === rankB ? a.label.localeCompare(b.label) : rankA - rankB
|
||||
})
|
||||
return statusDirection === 'desc' ? sortedSections.toReversed() : sortedSections
|
||||
}
|
||||
|
||||
function isSelectedIssue(issue: JiraIssue, selectedIssue: JiraIssue | null): boolean {
|
||||
@@ -123,7 +126,7 @@ function JiraIssueRow({
|
||||
}
|
||||
}}
|
||||
className={cn(
|
||||
'group/row grid min-h-12 cursor-pointer grid-cols-[minmax(0,1fr)_auto] items-center gap-3 px-3 py-2 text-left transition hover:bg-accent focus-visible:outline-none focus-visible:ring-1 focus-visible:ring-ring md:grid-cols-[90px_minmax(0,1fr)_128px_92px_80px] lg:grid-cols-[96px_minmax(0,1.25fr)_132px_120px_136px_96px_64px] xl:grid-cols-[104px_minmax(0,1.45fr)_144px_132px_160px_128px_72px]',
|
||||
'group/row grid min-h-12 cursor-pointer grid-cols-[minmax(0,1fr)_auto] items-center gap-3 px-3 py-2 text-left transition hover:bg-accent focus-visible:outline-none focus-visible:ring-1 focus-visible:ring-ring md:grid-cols-[90px_minmax(0,1fr)_128px_92px_80px_64px] lg:grid-cols-[96px_minmax(0,1.25fr)_132px_120px_136px_96px_64px] xl:grid-cols-[104px_minmax(0,1.45fr)_144px_132px_160px_128px_72px]',
|
||||
selected && 'bg-accent'
|
||||
)}
|
||||
>
|
||||
@@ -280,12 +283,13 @@ export function TaskPageJiraIssueList({
|
||||
onStartWorkspace,
|
||||
selectedIssue,
|
||||
showSiteContext,
|
||||
statusDirection = 'asc',
|
||||
statusOrder
|
||||
}: TaskPageJiraIssueListProps): React.JSX.Element {
|
||||
const [collapsedGroups, setCollapsedGroups] = useState<Set<string>>(() => new Set())
|
||||
const sections = useMemo(
|
||||
() => groupJiraIssuesByStatus(issues, statusOrder),
|
||||
[issues, statusOrder]
|
||||
() => groupJiraIssuesByStatus(issues, statusOrder, statusDirection),
|
||||
[issues, statusDirection, statusOrder]
|
||||
)
|
||||
|
||||
return (
|
||||
|
||||
@@ -0,0 +1,56 @@
|
||||
// @vitest-environment happy-dom
|
||||
|
||||
import '@testing-library/jest-dom/vitest'
|
||||
|
||||
import { afterEach, describe, expect, it, vi } from 'vitest'
|
||||
import { cleanup, render, screen } from '@testing-library/react'
|
||||
import userEvent from '@testing-library/user-event'
|
||||
|
||||
import { TooltipProvider } from '@/components/ui/tooltip'
|
||||
import { TaskPageJiraSortControls } from './task-page-jira-sort-controls'
|
||||
|
||||
afterEach(cleanup)
|
||||
|
||||
function renderControls(
|
||||
onSort = vi.fn(),
|
||||
orderBy: 'key' | 'title' | 'status' | 'priority' | 'assignee' | 'updated' = 'updated',
|
||||
direction: 'asc' | 'desc' = 'desc'
|
||||
): ReturnType<typeof render> {
|
||||
return render(
|
||||
<TooltipProvider>
|
||||
<TaskPageJiraSortControls direction={direction} onSort={onSort} orderBy={orderBy} />
|
||||
</TooltipProvider>
|
||||
)
|
||||
}
|
||||
|
||||
describe('TaskPage Jira sort controls', () => {
|
||||
it('exposes the active desktop column and translated direction', async () => {
|
||||
const user = userEvent.setup()
|
||||
const onSort = vi.fn()
|
||||
renderControls(onSort)
|
||||
|
||||
expect(screen.getByRole('button', { name: 'Updated, descending' })).toHaveAttribute(
|
||||
'aria-pressed',
|
||||
'true'
|
||||
)
|
||||
expect(screen.getByRole('button', { name: 'Assignee' })).toHaveClass('flex', 'max-lg:!hidden')
|
||||
expect(screen.getByRole('button', { name: 'Assignee' })).not.toHaveClass('block')
|
||||
|
||||
await user.click(screen.getByRole('button', { name: 'Key' }))
|
||||
expect(onSort).toHaveBeenCalledWith('key')
|
||||
})
|
||||
|
||||
it('keeps mobile column and direction controls available below md', async () => {
|
||||
const user = userEvent.setup()
|
||||
const onSort = vi.fn()
|
||||
renderControls(onSort)
|
||||
|
||||
expect(screen.getByTestId('jira-mobile-sort-controls')).toHaveClass('hidden', 'max-md:!flex')
|
||||
await user.click(screen.getByRole('combobox', { name: 'Sort by' }))
|
||||
await user.click(screen.getByRole('option', { name: 'Priority' }))
|
||||
expect(onSort).toHaveBeenCalledWith('priority')
|
||||
|
||||
await user.click(screen.getByRole('button', { name: 'Sort ascending' }))
|
||||
expect(onSort).toHaveBeenCalledWith('updated')
|
||||
})
|
||||
})
|
||||
@@ -0,0 +1,137 @@
|
||||
import { ArrowDown, ArrowUp } from 'lucide-react'
|
||||
|
||||
import { Button } from '@/components/ui/button'
|
||||
import {
|
||||
Select,
|
||||
SelectContent,
|
||||
SelectItem,
|
||||
SelectTrigger,
|
||||
SelectValue
|
||||
} from '@/components/ui/select'
|
||||
import { Tooltip, TooltipContent, TooltipTrigger } from '@/components/ui/tooltip'
|
||||
import { translate } from '@/i18n/i18n'
|
||||
import { cn } from '@/lib/utils'
|
||||
import type { JiraIssueSortColumn, JiraIssueSortDirection } from './jira-issue-sorter'
|
||||
|
||||
type JiraSortColumn = {
|
||||
id: JiraIssueSortColumn
|
||||
label: string
|
||||
className?: string
|
||||
}
|
||||
|
||||
type TaskPageJiraSortControlsProps = {
|
||||
direction: JiraIssueSortDirection
|
||||
onSort: (column: JiraIssueSortColumn) => void
|
||||
orderBy: JiraIssueSortColumn
|
||||
}
|
||||
|
||||
function getJiraSortColumns(): JiraSortColumn[] {
|
||||
return [
|
||||
{ id: 'key', label: translate('auto.components.TaskPage.37e7ee311e', 'Key') },
|
||||
{ id: 'title', label: translate('auto.components.TaskPage.b1eaa18ace', 'Issue') },
|
||||
{ id: 'status', label: translate('auto.components.TaskPage.154b0fa623', 'Status') },
|
||||
{ id: 'priority', label: translate('auto.components.TaskPage.c8d5bec5f7', 'Priority') },
|
||||
{
|
||||
id: 'assignee',
|
||||
label: translate('auto.components.TaskPage.d2a876ca53', 'Assignee'),
|
||||
className: 'max-lg:!hidden'
|
||||
},
|
||||
{ id: 'updated', label: translate('auto.components.TaskPage.f362667d55', 'Updated') }
|
||||
]
|
||||
}
|
||||
|
||||
export function TaskPageJiraSortControls({
|
||||
direction,
|
||||
onSort,
|
||||
orderBy
|
||||
}: TaskPageJiraSortControlsProps): React.JSX.Element {
|
||||
const columns = getJiraSortColumns()
|
||||
const directionLabel =
|
||||
direction === 'asc'
|
||||
? translate('auto.components.TaskPage.jiraSortAscending', 'ascending')
|
||||
: translate('auto.components.TaskPage.jiraSortDescending', 'descending')
|
||||
const nextDirectionLabel =
|
||||
direction === 'asc'
|
||||
? translate('auto.components.TaskPage.jiraSortDescending', 'descending')
|
||||
: translate('auto.components.TaskPage.jiraSortAscending', 'ascending')
|
||||
const sortByLabel = translate('auto.components.TaskPage.jiraSortBy', 'Sort by')
|
||||
const toggleDirectionLabel = translate(
|
||||
'auto.components.TaskPage.jiraToggleSortDirection',
|
||||
'Sort {{value0}}',
|
||||
{ value0: nextDirectionLabel }
|
||||
)
|
||||
|
||||
return (
|
||||
<>
|
||||
<div className="grid h-8 flex-none grid-cols-[90px_minmax(0,1fr)_128px_92px_80px_64px] items-center gap-3 border-b border-border/50 bg-muted/25 px-3 text-[11px] font-medium uppercase tracking-[0.08em] text-muted-foreground max-md:!hidden lg:grid-cols-[96px_minmax(0,1.25fr)_132px_120px_136px_96px_64px] xl:grid-cols-[104px_minmax(0,1.45fr)_144px_132px_160px_128px_72px]">
|
||||
{columns.map((column) => (
|
||||
<button
|
||||
key={column.id}
|
||||
type="button"
|
||||
onClick={() => onSort(column.id)}
|
||||
aria-label={orderBy === column.id ? `${column.label}, ${directionLabel}` : column.label}
|
||||
aria-pressed={orderBy === column.id}
|
||||
className={cn(
|
||||
'flex items-center gap-1 rounded-sm text-left text-[11px] font-semibold tracking-[0.08em] uppercase select-none hover:text-foreground focus-visible:ring-2 focus-visible:ring-ring/50 focus-visible:outline-none',
|
||||
column.className
|
||||
)}
|
||||
>
|
||||
{column.label}
|
||||
{orderBy === column.id &&
|
||||
(direction === 'asc' ? (
|
||||
<ArrowUp aria-hidden="true" className="size-3" />
|
||||
) : (
|
||||
<ArrowDown aria-hidden="true" className="size-3" />
|
||||
))}
|
||||
</button>
|
||||
))}
|
||||
<span />
|
||||
</div>
|
||||
|
||||
<div
|
||||
data-testid="jira-mobile-sort-controls"
|
||||
className="hidden h-10 flex-none items-center gap-2 border-b border-border/50 bg-muted/25 px-3 max-md:!flex"
|
||||
>
|
||||
<span className="shrink-0 text-[11px] font-semibold tracking-[0.05em] text-muted-foreground uppercase">
|
||||
{sortByLabel}
|
||||
</span>
|
||||
<Select value={orderBy} onValueChange={(value) => onSort(value as JiraIssueSortColumn)}>
|
||||
<SelectTrigger
|
||||
size="sm"
|
||||
aria-label={sortByLabel}
|
||||
className="min-w-0 flex-1 border-border/50 bg-background text-xs shadow-none"
|
||||
>
|
||||
<SelectValue />
|
||||
</SelectTrigger>
|
||||
<SelectContent>
|
||||
{columns.map((column) => (
|
||||
<SelectItem key={column.id} value={column.id}>
|
||||
{column.label}
|
||||
</SelectItem>
|
||||
))}
|
||||
</SelectContent>
|
||||
</Select>
|
||||
<Tooltip>
|
||||
<TooltipTrigger asChild>
|
||||
<Button
|
||||
type="button"
|
||||
variant="ghost"
|
||||
size="icon-sm"
|
||||
aria-label={toggleDirectionLabel}
|
||||
onClick={() => onSort(orderBy)}
|
||||
>
|
||||
{direction === 'asc' ? (
|
||||
<ArrowUp aria-hidden="true" className="size-3.5" />
|
||||
) : (
|
||||
<ArrowDown aria-hidden="true" className="size-3.5" />
|
||||
)}
|
||||
</Button>
|
||||
</TooltipTrigger>
|
||||
<TooltipContent side="bottom" sideOffset={6}>
|
||||
{toggleDirectionLabel}
|
||||
</TooltipContent>
|
||||
</Tooltip>
|
||||
</div>
|
||||
</>
|
||||
)
|
||||
}
|
||||
@@ -0,0 +1,269 @@
|
||||
import { describe, expect, it } from 'vitest'
|
||||
import type { JiraIssue, JiraPriority } from '../../../shared/types'
|
||||
import { getJiraPriorityWeight, sortJiraIssues } from './jira-issue-sorter'
|
||||
|
||||
describe('TaskPage Jira sorting functionality', () => {
|
||||
function jiraIssue(
|
||||
key: string,
|
||||
title: string,
|
||||
statusName: string,
|
||||
priorityName?: string,
|
||||
priorityId?: string,
|
||||
assigneeDisplayName?: string,
|
||||
updatedAt = '2026-01-01T00:00:00.000Z',
|
||||
siteId = 'site-1',
|
||||
categoryKey = 'new'
|
||||
): JiraIssue {
|
||||
return {
|
||||
id: `${siteId}:${key}`,
|
||||
key,
|
||||
title,
|
||||
url: `https://example.atlassian.net/browse/${key}`,
|
||||
siteId,
|
||||
siteName: 'Example Jira',
|
||||
project: { id: '10000', key: 'ALP', name: 'Alpha', siteId },
|
||||
issueType: { id: '10001', name: 'Bug' },
|
||||
status: { id: '1', name: statusName, categoryKey, categoryName: statusName },
|
||||
priority: priorityName ? { id: priorityId ?? '1', name: priorityName } : undefined,
|
||||
assignee: assigneeDisplayName
|
||||
? { accountId: 'user-1', displayName: assigneeDisplayName }
|
||||
: undefined,
|
||||
labels: [],
|
||||
createdAt: '2026-01-01T00:00:00.000Z',
|
||||
updatedAt
|
||||
}
|
||||
}
|
||||
|
||||
function jiraPriority(id: string, name: string): JiraPriority {
|
||||
return { id, name }
|
||||
}
|
||||
|
||||
describe('getJiraPriorityWeight', () => {
|
||||
it('returns default weight for missing priority', () => {
|
||||
expect(getJiraPriorityWeight(undefined, undefined, [])).toBe(0)
|
||||
})
|
||||
|
||||
it('uses priority index from Jira priorities list when available', () => {
|
||||
const jiraPriorities: JiraPriority[] = [
|
||||
jiraPriority('1', 'Highest'),
|
||||
jiraPriority('2', 'High'),
|
||||
jiraPriority('3', 'Medium'),
|
||||
jiraPriority('4', 'Low')
|
||||
]
|
||||
|
||||
expect(getJiraPriorityWeight('High', '2', jiraPriorities)).toBeCloseTo(66.33, 2)
|
||||
expect(getJiraPriorityWeight('Medium', '3', jiraPriorities)).toBeCloseTo(33.67, 2)
|
||||
})
|
||||
|
||||
it('keeps missing priority below the lowest configured priority', () => {
|
||||
const jiraPriorities = [jiraPriority('1', 'High'), jiraPriority('2', 'Low')]
|
||||
|
||||
expect(getJiraPriorityWeight(undefined, undefined, jiraPriorities)).toBe(0)
|
||||
expect(getJiraPriorityWeight('Low', '2', jiraPriorities)).toBe(1)
|
||||
})
|
||||
|
||||
it('falls back to priority name mapping when not in priorities list', () => {
|
||||
expect(getJiraPriorityWeight('Blocker', '1', [])).toBe(99)
|
||||
expect(getJiraPriorityWeight('High', '2', [])).toBe(75)
|
||||
expect(getJiraPriorityWeight('Medium', '3', [])).toBe(50)
|
||||
expect(getJiraPriorityWeight('Low', '4', [])).toBe(25)
|
||||
expect(getJiraPriorityWeight('Lowest', '5', [])).toBe(1)
|
||||
})
|
||||
|
||||
it('does not treat opaque custom priority IDs as ordering ranks', () => {
|
||||
expect(getJiraPriorityWeight('Custom Priority', '10', [])).toBe(50)
|
||||
expect(getJiraPriorityWeight('Another', '5', [])).toBe(50)
|
||||
expect(getJiraPriorityWeight('Unknown Priority', 'invalid', [])).toBe(50)
|
||||
})
|
||||
})
|
||||
|
||||
describe('issue sorting', () => {
|
||||
it('sorts by key in ascending order', () => {
|
||||
const issues = [
|
||||
jiraIssue('ALP-10', 'Issue 10', 'To Do'),
|
||||
jiraIssue('ALP-2', 'Issue 2', 'To Do'),
|
||||
jiraIssue('ALP-1', 'Issue 1', 'To Do')
|
||||
]
|
||||
|
||||
const sorted = sortJiraIssues(issues, 'key', 'asc')
|
||||
|
||||
expect(sorted[0].key).toBe('ALP-1')
|
||||
expect(sorted[1].key).toBe('ALP-2')
|
||||
expect(sorted[2].key).toBe('ALP-10')
|
||||
})
|
||||
|
||||
it('sorts by key in descending order', () => {
|
||||
const issues = [
|
||||
jiraIssue('ALP-1', 'Issue 1', 'To Do'),
|
||||
jiraIssue('ALP-2', 'Issue 2', 'To Do'),
|
||||
jiraIssue('ALP-10', 'Issue 10', 'To Do')
|
||||
]
|
||||
|
||||
const sorted = sortJiraIssues(issues, 'key', 'desc')
|
||||
|
||||
expect(sorted[0].key).toBe('ALP-10')
|
||||
expect(sorted[1].key).toBe('ALP-2')
|
||||
expect(sorted[2].key).toBe('ALP-1')
|
||||
})
|
||||
|
||||
it('sorts by title alphabetically', () => {
|
||||
const issues = [
|
||||
jiraIssue('ALP-1', 'Zebra Issue', 'To Do'),
|
||||
jiraIssue('ALP-2', 'Apple Issue', 'To Do'),
|
||||
jiraIssue('ALP-3', 'Banana Issue', 'To Do')
|
||||
]
|
||||
|
||||
const sorted = sortJiraIssues(issues, 'title', 'asc')
|
||||
|
||||
expect(sorted[0].title).toBe('Apple Issue')
|
||||
expect(sorted[1].title).toBe('Banana Issue')
|
||||
expect(sorted[2].title).toBe('Zebra Issue')
|
||||
})
|
||||
|
||||
it('sorts by priority weight (lowest priority first)', () => {
|
||||
const issues = [
|
||||
jiraIssue('ALP-1', 'Issue 1', 'To Do', 'Low', '4'),
|
||||
jiraIssue('ALP-2', 'Issue 2', 'To Do', 'High', '2'),
|
||||
jiraIssue('ALP-3', 'Issue 3', 'To Do', 'Medium', '3')
|
||||
]
|
||||
|
||||
const sorted = sortJiraIssues(issues, 'priority', 'asc')
|
||||
|
||||
expect(sorted[0].priority?.name).toBe('Low')
|
||||
expect(sorted[1].priority?.name).toBe('Medium')
|
||||
expect(sorted[2].priority?.name).toBe('High')
|
||||
})
|
||||
|
||||
it('uses each Jira site priority order when sorting an all-sites list', () => {
|
||||
const issues = [
|
||||
jiraIssue(
|
||||
'BRV-1',
|
||||
'Site B low by name',
|
||||
'To Do',
|
||||
'Low',
|
||||
'2',
|
||||
undefined,
|
||||
undefined,
|
||||
'site-b'
|
||||
),
|
||||
jiraIssue(
|
||||
'BRV-2',
|
||||
'Site B high by name',
|
||||
'To Do',
|
||||
'High',
|
||||
'1',
|
||||
undefined,
|
||||
undefined,
|
||||
'site-b'
|
||||
)
|
||||
]
|
||||
const prioritiesBySite = new Map<string, JiraPriority[]>([
|
||||
['site-a', [jiraPriority('1', 'High'), jiraPriority('2', 'Low')]],
|
||||
['site-b', [jiraPriority('2', 'Low'), jiraPriority('1', 'High')]]
|
||||
])
|
||||
|
||||
const sorted = sortJiraIssues(issues, 'priority', 'asc', prioritiesBySite)
|
||||
|
||||
expect(sorted.map((issue) => issue.key)).toEqual(['BRV-2', 'BRV-1'])
|
||||
})
|
||||
|
||||
it('normalizes priority ranks across sites with different tier counts', () => {
|
||||
const issues = [
|
||||
jiraIssue(
|
||||
'ALP-1',
|
||||
'Site A highest',
|
||||
'To Do',
|
||||
'Highest',
|
||||
'1',
|
||||
undefined,
|
||||
undefined,
|
||||
'site-a'
|
||||
),
|
||||
jiraIssue('BRV-1', 'Site B medium', 'To Do', 'Medium', '3', undefined, undefined, 'site-b')
|
||||
]
|
||||
const prioritiesBySite = new Map<string, JiraPriority[]>([
|
||||
['site-a', [jiraPriority('1', 'Highest'), jiraPriority('2', 'Lowest')]],
|
||||
[
|
||||
'site-b',
|
||||
[
|
||||
jiraPriority('1', 'Highest'),
|
||||
jiraPriority('2', 'High'),
|
||||
jiraPriority('3', 'Medium'),
|
||||
jiraPriority('4', 'Low'),
|
||||
jiraPriority('5', 'Lowest')
|
||||
]
|
||||
]
|
||||
])
|
||||
|
||||
const sorted = sortJiraIssues(issues, 'priority', 'asc', prioritiesBySite)
|
||||
|
||||
expect(sorted.map((issue) => issue.key)).toEqual(['BRV-1', 'ALP-1'])
|
||||
})
|
||||
|
||||
it('sorts by assignee alphabetically', () => {
|
||||
const issues = [
|
||||
jiraIssue('ALP-1', 'Issue 1', 'To Do', undefined, undefined, 'Zoe'),
|
||||
jiraIssue('ALP-2', 'Issue 2', 'To Do', undefined, undefined, 'Alice'),
|
||||
jiraIssue('ALP-3', 'Issue 3', 'To Do', undefined, undefined, 'Bob')
|
||||
]
|
||||
|
||||
const sorted = sortJiraIssues(issues, 'assignee', 'asc')
|
||||
|
||||
expect(sorted[0].assignee?.displayName).toBe('Alice')
|
||||
expect(sorted[1].assignee?.displayName).toBe('Bob')
|
||||
expect(sorted[2].assignee?.displayName).toBe('Zoe')
|
||||
})
|
||||
|
||||
it('sorts by updated date (newest first)', () => {
|
||||
const issues = [
|
||||
jiraIssue(
|
||||
'ALP-1',
|
||||
'Issue 1',
|
||||
'To Do',
|
||||
undefined,
|
||||
undefined,
|
||||
undefined,
|
||||
'2026-01-01T00:00:00.000Z'
|
||||
),
|
||||
jiraIssue(
|
||||
'ALP-2',
|
||||
'Issue 2',
|
||||
'To Do',
|
||||
undefined,
|
||||
undefined,
|
||||
undefined,
|
||||
'2026-01-03T00:00:00.000Z'
|
||||
),
|
||||
jiraIssue(
|
||||
'ALP-3',
|
||||
'Issue 3',
|
||||
'To Do',
|
||||
undefined,
|
||||
undefined,
|
||||
undefined,
|
||||
'2026-01-02T00:00:00.000Z'
|
||||
)
|
||||
]
|
||||
|
||||
const sorted = sortJiraIssues(issues, 'updated', 'desc')
|
||||
|
||||
expect(sorted[0].key).toBe('ALP-2')
|
||||
expect(sorted[1].key).toBe('ALP-3')
|
||||
expect(sorted[2].key).toBe('ALP-1')
|
||||
})
|
||||
|
||||
it('handles unassigned assignees in sorting', () => {
|
||||
const issues = [
|
||||
jiraIssue('ALP-1', 'Issue 1', 'To Do', undefined, undefined, 'Alice'),
|
||||
jiraIssue('ALP-2', 'Issue 2', 'To Do', undefined, undefined, undefined),
|
||||
jiraIssue('ALP-3', 'Issue 3', 'To Do', undefined, undefined, 'Bob')
|
||||
]
|
||||
|
||||
const sorted = sortJiraIssues(issues, 'assignee', 'asc')
|
||||
|
||||
expect(sorted[0].assignee?.displayName).toBeUndefined()
|
||||
expect(sorted[1].assignee?.displayName).toBe('Alice')
|
||||
expect(sorted[2].assignee?.displayName).toBe('Bob')
|
||||
})
|
||||
})
|
||||
})
|
||||
@@ -440,7 +440,8 @@ describe('restoreScrollbackBuffers', () => {
|
||||
}
|
||||
}
|
||||
const manager = {
|
||||
getPanes: vi.fn(() => [pane])
|
||||
getPanes: vi.fn(() => [pane]),
|
||||
hasWebglRenderer: vi.fn(() => true)
|
||||
}
|
||||
const replayingPanesRef = { current: new Map<number, number>() }
|
||||
const restoredViewportBlankingPanesRef = { current: new Set<number>() }
|
||||
@@ -454,6 +455,7 @@ describe('restoreScrollbackBuffers', () => {
|
||||
)
|
||||
|
||||
expect(writes).toEqual(['restored output', '\r\n', POST_REPLAY_MODE_RESET])
|
||||
expect(manager.hasWebglRenderer).toHaveBeenCalledWith(1)
|
||||
expect(restoredViewportBlankingPanesRef.current.has(1)).toBe(true)
|
||||
expect(replayingPanesRef.current.size).toBe(0)
|
||||
})
|
||||
|
||||
@@ -265,6 +265,9 @@ export function restoreScrollbackBuffers(
|
||||
continue
|
||||
}
|
||||
try {
|
||||
const renderOptions = {
|
||||
shouldRefreshViewportSynchronously: () => !manager.hasWebglRenderer(pane.id)
|
||||
}
|
||||
let buf = buffer
|
||||
// If buffer ends in alt-screen mode (agent TUI was running at
|
||||
// shutdown), exit alt-screen so the user sees a usable terminal.
|
||||
@@ -278,14 +281,14 @@ export function restoreScrollbackBuffers(
|
||||
// sequences from the prior session (DA1, DECRQM, OSC 10/11, focus,
|
||||
// CPR). Writing those through xterm.write would trigger auto-replies
|
||||
// that land in the new shell's stdin. See replay-guard.ts.
|
||||
replayIntoTerminal(pane, replayingPanesRef, buf)
|
||||
replayIntoTerminal(pane, replayingPanesRef, buf, renderOptions)
|
||||
// Ensure cursor is on a new line so the new shell prompt
|
||||
// doesn't trigger zsh's PROMPT_EOL_MARK (%) indicator.
|
||||
replayIntoTerminal(pane, replayingPanesRef, '\r\n')
|
||||
replayIntoTerminal(pane, replayingPanesRef, '\r\n', renderOptions)
|
||||
// Clear any mode bits the serialized buffer replayed into xterm.
|
||||
// The shell underneath is fresh and has no TUI consuming these modes.
|
||||
// See POST_REPLAY_MODE_RESET comment.
|
||||
replayIntoTerminal(pane, replayingPanesRef, POST_REPLAY_MODE_RESET)
|
||||
replayIntoTerminal(pane, replayingPanesRef, POST_REPLAY_MODE_RESET, renderOptions)
|
||||
// Why: connection resolution happens after layout replay; only the
|
||||
// fresh-shell paths should move these visible rows into scrollback.
|
||||
restoredViewportBlankingPanesRef?.current.add(pane.id)
|
||||
|
||||
@@ -467,6 +467,7 @@ function createManager(paneCount = 1, initialActivePaneId: number | null = null)
|
||||
setPaneGpuRendering: vi.fn(),
|
||||
markPaneHasComplexScriptOutput: vi.fn(),
|
||||
rebuildPaneWebgl: vi.fn(),
|
||||
hasWebglRenderer: vi.fn(() => false),
|
||||
getPanes: vi.fn(() => panes),
|
||||
closePane: vi.fn(),
|
||||
getActivePane: vi.fn<() => { id: number; leafId?: string } | null>(() =>
|
||||
@@ -12635,6 +12636,45 @@ describe('connectPanePty', () => {
|
||||
}
|
||||
})
|
||||
|
||||
it('coalesces forced foreground refreshes when WebGL is live', async () => {
|
||||
const restoreNavigator = temporarilySetNavigatorUserAgent('Mozilla/5.0 (Macintosh)')
|
||||
try {
|
||||
const { connectPanePty } = await import('./pty-connection')
|
||||
const transport = createMockTransport()
|
||||
const capturedDataCallback: { current: ((data: string) => void) | null } = { current: null }
|
||||
transport.connect.mockImplementation(
|
||||
async ({ callbacks }: { callbacks: ConnectCallbacks }) => {
|
||||
capturedDataCallback.current = callbacks.onData ?? null
|
||||
return 'pty-id'
|
||||
}
|
||||
)
|
||||
transportFactoryQueue.push(transport)
|
||||
|
||||
const pane = createPane(1)
|
||||
const synchronousRefresh = vi.fn()
|
||||
const debouncedRefresh = vi.fn()
|
||||
const terminal = pane.terminal as typeof pane.terminal & {
|
||||
_core?: { refresh: typeof synchronousRefresh }
|
||||
refresh: typeof debouncedRefresh
|
||||
}
|
||||
terminal._core = { refresh: synchronousRefresh }
|
||||
terminal.refresh = debouncedRefresh
|
||||
terminal.write = vi.fn((_data: string, callback?: () => void) => callback?.())
|
||||
const manager = createManager(1)
|
||||
manager.hasWebglRenderer.mockReturnValue(true)
|
||||
|
||||
connectPanePty(pane as never, manager as never, createDeps() as never)
|
||||
await flushAsyncTicks(6)
|
||||
|
||||
capturedDataCallback.current?.('\r\x1b[3Gzzzx\x1b[K')
|
||||
|
||||
expect(debouncedRefresh).toHaveBeenCalledWith(0, 39)
|
||||
expect(synchronousRefresh).not.toHaveBeenCalled()
|
||||
} finally {
|
||||
restoreNavigator()
|
||||
}
|
||||
})
|
||||
|
||||
it('drains a post-submit synchronized frame on the fast path when its end marker arrives late', async () => {
|
||||
// Why (STA-1041): OpenCode wraps each submit repaint in a DEC 2026 frame.
|
||||
// Under CPU contention ConPTY splits the closing chunk past the 150ms redraw
|
||||
|
||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user