diff --git a/.npmrc b/.npmrc index 9e4363a8cd1..196b4fee4cf 100644 --- a/.npmrc +++ b/.npmrc @@ -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 diff --git a/config/scripts/audit-localization-coverage.mjs b/config/scripts/audit-localization-coverage.mjs index 5a9a0ac2ef3..8b978e38ed4 100644 --- a/config/scripts/audit-localization-coverage.mjs +++ b/config/scripts/audit-localization-coverage.mjs @@ -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']) diff --git a/config/scripts/localize-renderer-strings.mjs b/config/scripts/localize-renderer-strings.mjs index 459a16ee387..8ca12e9a13b 100644 --- a/config/scripts/localize-renderer-strings.mjs +++ b/config/scripts/localize-renderer-strings.mjs @@ -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' diff --git a/config/scripts/styled-scrollbars/styled-scrollbar-jsx-check.mjs b/config/scripts/styled-scrollbars/styled-scrollbar-jsx-check.mjs index 4dab6f3b687..2a9d13b6fee 100644 --- a/config/scripts/styled-scrollbars/styled-scrollbar-jsx-check.mjs +++ b/config/scripts/styled-scrollbars/styled-scrollbar-jsx-check.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(' ') diff --git a/config/scripts/verify-localization-catalog.mjs b/config/scripts/verify-localization-catalog.mjs index e49c9413da3..23126da730f 100644 --- a/config/scripts/verify-localization-catalog.mjs +++ b/config/scripts/verify-localization-catalog.mjs @@ -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']) diff --git a/config/tsconfig.cli.json b/config/tsconfig.cli.json index 8d92fa208ca..5c1ec10b7b2 100644 --- a/config/tsconfig.cli.json +++ b/config/tsconfig.cli.json @@ -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" } diff --git a/config/tsconfig.tc.web.json b/config/tsconfig.tc.web.json index f3f66700bc1..9f71e6e7900 100644 --- a/config/tsconfig.tc.web.json +++ b/config/tsconfig.tc.web.json @@ -13,7 +13,6 @@ "../src/main/wsl.ts" ], "compilerOptions": { - "baseUrl": null, "paths": { "@renderer/*": ["../src/renderer/src/*"], "@/*": ["../src/renderer/src/*"] diff --git a/mobile/app/h/[hostId]/index.tsx b/mobile/app/h/[hostId]/index.tsx index 7fb6a1f6eea..d613bff4575 100644 --- a/mobile/app/h/[hostId]/index.tsx +++ b/mobile/app/h/[hostId]/index.tsx @@ -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 && ( - - - {search.length > 0 && ( - setSearch('')}> - - - )} )} @@ -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', diff --git a/mobile/app/h/[hostId]/session/[worktreeId].tsx b/mobile/app/h/[hostId]/session/[worktreeId].tsx index b881ddebabb..18a393cae28 100644 --- a/mobile/app/h/[hostId]/session/[worktreeId].tsx +++ b/mobile/app/h/[hostId]/session/[worktreeId].tsx @@ -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, diff --git a/mobile/app/h/[hostId]/session/mobile-session-route-types.ts b/mobile/app/h/[hostId]/session/mobile-session-route-types.ts index 5f0f800d9c2..4bc6215f4a2 100644 --- a/mobile/app/h/[hostId]/session/mobile-session-route-types.ts +++ b/mobile/app/h/[hostId]/session/mobile-session-route-types.ts @@ -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, diff --git a/mobile/app/h/[hostId]/tasks.tsx b/mobile/app/h/[hostId]/tasks.tsx index 08f69b4972f..fea23f01764 100644 --- a/mobile/app/h/[hostId]/tasks.tsx +++ b/mobile/app/h/[hostId]/tasks.tsx @@ -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 ( @@ -9048,31 +9049,33 @@ export default function MobileTasksScreen() { {provider === 'gitlab' && gitlabView === 'todos' ? null : provider === 'linear' && !linearConnected ? null : ( - - 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) && ( - { - 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: '' }) - } - }} - > - - - )} )} @@ -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, diff --git a/mobile/package.json b/mobile/package.json index 948191886d1..9f91777a842 100644 --- a/mobile/package.json +++ b/mobile/package.json @@ -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" + } } } diff --git a/mobile/pnpm-lock.yaml b/mobile/pnpm-lock.yaml index f3c4b72ed1c..496855fc266 100644 --- a/mobile/pnpm-lock.yaml +++ b/mobile/pnpm-lock.yaml @@ -4,13 +4,16 @@ settings: autoInstallPeers: true excludeLinksFromLockfile: false +overrides: + xcode>uuid: 11.1.1 + importers: .: dependencies: '@orca/expo-two-way-audio': specifier: file:./packages/expo-two-way-audio - version: file:packages/expo-two-way-audio(expo@55.0.23)(react-native@0.83.9(@babel/core@7.29.7)(@react-native/metro-config@0.85.2(@babel/core@7.29.7))(@types/react@19.2.14)(react@19.2.6))(react@19.2.6) + version: file:packages/expo-two-way-audio(expo@55.0.27)(react-native@0.83.9(@babel/core@7.29.7)(@react-native/metro-config@0.85.2(@babel/core@7.29.7))(@types/react@19.2.14)(react@19.2.6))(react@19.2.6) '@react-native-async-storage/async-storage': specifier: ^2.2.0 version: 2.2.0(react-native@0.83.9(@babel/core@7.29.7)(@react-native/metro-config@0.85.2(@babel/core@7.29.7))(@types/react@19.2.14)(react@19.2.6)) @@ -27,62 +30,62 @@ importers: specifier: ^6.0.3 version: 6.0.3 expo: - specifier: ^55.0.23 - version: 55.0.23(@babel/core@7.29.7)(@expo/dom-webview@55.0.5)(@expo/metro-runtime@55.0.10)(expo-router@55.0.14)(react-dom@19.2.6(react@19.2.6))(react-native-webview@13.16.1(react-native@0.83.9(@babel/core@7.29.7)(@react-native/metro-config@0.85.2(@babel/core@7.29.7))(@types/react@19.2.14)(react@19.2.6))(react@19.2.6))(react-native-worklets@0.8.3(@babel/core@7.29.7)(@react-native/metro-config@0.85.2(@babel/core@7.29.7))(react-native@0.83.9(@babel/core@7.29.7)(@react-native/metro-config@0.85.2(@babel/core@7.29.7))(@types/react@19.2.14)(react@19.2.6))(react@19.2.6))(react-native@0.83.9(@babel/core@7.29.7)(@react-native/metro-config@0.85.2(@babel/core@7.29.7))(@types/react@19.2.14)(react@19.2.6))(react@19.2.6)(typescript@5.9.3) + specifier: ^55.0.27 + version: 55.0.27(@babel/core@7.29.7)(@expo/dom-webview@55.0.5)(@expo/metro-runtime@55.0.10)(expo-router@55.0.14)(react-dom@19.2.6(react@19.2.6))(react-native-webview@13.16.1(react-native@0.83.9(@babel/core@7.29.7)(@react-native/metro-config@0.85.2(@babel/core@7.29.7))(@types/react@19.2.14)(react@19.2.6))(react@19.2.6))(react-native-worklets@0.8.3(@babel/core@7.29.7)(@react-native/metro-config@0.85.2(@babel/core@7.29.7))(react-native@0.83.9(@babel/core@7.29.7)(@react-native/metro-config@0.85.2(@babel/core@7.29.7))(@types/react@19.2.14)(react@19.2.6))(react@19.2.6))(react-native@0.83.9(@babel/core@7.29.7)(@react-native/metro-config@0.85.2(@babel/core@7.29.7))(@types/react@19.2.14)(react@19.2.6))(react@19.2.6)(typescript@7.0.2) expo-build-properties: specifier: ^55.0.13 - version: 55.0.13(expo@55.0.23) + version: 55.0.13(expo@55.0.27) expo-camera: specifier: ^55.0.18 - version: 55.0.18(@types/emscripten@1.41.5)(expo@55.0.23)(react-native-web@0.21.2(react-dom@19.2.6(react@19.2.6))(react@19.2.6))(react-native@0.83.9(@babel/core@7.29.7)(@react-native/metro-config@0.85.2(@babel/core@7.29.7))(@types/react@19.2.14)(react@19.2.6))(react@19.2.6) + version: 55.0.18(@types/emscripten@1.41.5)(expo@55.0.27)(react-native-web@0.21.2(react-dom@19.2.6(react@19.2.6))(react@19.2.6))(react-native@0.83.9(@babel/core@7.29.7)(@react-native/metro-config@0.85.2(@babel/core@7.29.7))(@types/react@19.2.14)(react@19.2.6))(react@19.2.6) expo-clipboard: specifier: ^55.0.13 - version: 55.0.13(expo@55.0.23)(react-native@0.83.9(@babel/core@7.29.7)(@react-native/metro-config@0.85.2(@babel/core@7.29.7))(@types/react@19.2.14)(react@19.2.6))(react@19.2.6) + version: 55.0.13(expo@55.0.27)(react-native@0.83.9(@babel/core@7.29.7)(@react-native/metro-config@0.85.2(@babel/core@7.29.7))(@types/react@19.2.14)(react@19.2.6))(react@19.2.6) expo-constants: specifier: ^55.0.16 - version: 55.0.16(expo@55.0.23)(react-native@0.83.9(@babel/core@7.29.7)(@react-native/metro-config@0.85.2(@babel/core@7.29.7))(@types/react@19.2.14)(react@19.2.6)) + version: 55.0.16(expo@55.0.27)(react-native@0.83.9(@babel/core@7.29.7)(@react-native/metro-config@0.85.2(@babel/core@7.29.7))(@types/react@19.2.14)(react@19.2.6)) expo-crypto: specifier: ^55.0.14 - version: 55.0.14(expo@55.0.23) + version: 55.0.14(expo@55.0.27) expo-dev-client: specifier: ~55.0.35 - version: 55.0.35(expo@55.0.23) + version: 55.0.35(expo@55.0.27) expo-document-picker: specifier: ^55.0.13 - version: 55.0.13(expo@55.0.23) + version: 55.0.13(expo@55.0.27) expo-file-system: specifier: 55.0.19 - version: 55.0.19(expo@55.0.23)(react-native@0.83.9(@babel/core@7.29.7)(@react-native/metro-config@0.85.2(@babel/core@7.29.7))(@types/react@19.2.14)(react@19.2.6)) + version: 55.0.19(expo@55.0.27)(react-native@0.83.9(@babel/core@7.29.7)(@react-native/metro-config@0.85.2(@babel/core@7.29.7))(@types/react@19.2.14)(react@19.2.6)) expo-haptics: specifier: ^55.0.14 - version: 55.0.14(expo@55.0.23) + version: 55.0.14(expo@55.0.27) expo-image-manipulator: specifier: ^55.0.17 - version: 55.0.17(expo@55.0.23) + version: 55.0.17(expo@55.0.27) expo-image-picker: specifier: ^55.0.20 - version: 55.0.20(expo@55.0.23) + version: 55.0.20(expo@55.0.27) expo-linking: specifier: ^55.0.15 - version: 55.0.15(expo@55.0.23)(react-native@0.83.9(@babel/core@7.29.7)(@react-native/metro-config@0.85.2(@babel/core@7.29.7))(@types/react@19.2.14)(react@19.2.6))(react@19.2.6) + version: 55.0.15(expo@55.0.27)(react-native@0.83.9(@babel/core@7.29.7)(@react-native/metro-config@0.85.2(@babel/core@7.29.7))(@types/react@19.2.14)(react@19.2.6))(react@19.2.6) expo-modules-core: specifier: ~55.0.25 version: 55.0.25(react-native-worklets@0.8.3(@babel/core@7.29.7)(@react-native/metro-config@0.85.2(@babel/core@7.29.7))(react-native@0.83.9(@babel/core@7.29.7)(@react-native/metro-config@0.85.2(@babel/core@7.29.7))(@types/react@19.2.14)(react@19.2.6))(react@19.2.6))(react-native@0.83.9(@babel/core@7.29.7)(@react-native/metro-config@0.85.2(@babel/core@7.29.7))(@types/react@19.2.14)(react@19.2.6))(react@19.2.6) expo-network: specifier: ~55.0.14 - version: 55.0.14(expo@55.0.23)(react@19.2.6) + version: 55.0.14(expo@55.0.27)(react@19.2.6) expo-notifications: specifier: ^55.0.22 - version: 55.0.22(expo@55.0.23)(react-native@0.83.9(@babel/core@7.29.7)(@react-native/metro-config@0.85.2(@babel/core@7.29.7))(@types/react@19.2.14)(react@19.2.6))(react@19.2.6)(typescript@5.9.3) + version: 55.0.22(expo@55.0.27)(react-native@0.83.9(@babel/core@7.29.7)(@react-native/metro-config@0.85.2(@babel/core@7.29.7))(@types/react@19.2.14)(react@19.2.6))(react@19.2.6)(typescript@7.0.2) expo-router: specifier: ^55.0.14 - version: 55.0.14(e0a8113e3689d84edb7af23d00a72ffa) + version: 55.0.14(7ffd97bbc113b875771c40f21f44415d) expo-secure-store: specifier: ^55.0.13 - version: 55.0.13(expo@55.0.23) + version: 55.0.13(expo@55.0.27) expo-splash-screen: specifier: ^55.0.20 - version: 55.0.20(expo@55.0.23)(typescript@5.9.3) + version: 55.0.20(expo@55.0.27)(typescript@7.0.2) expo-status-bar: specifier: ^55.0.6 version: 55.0.6(react-native@0.83.9(@babel/core@7.29.7)(@react-native/metro-config@0.85.2(@babel/core@7.29.7))(@types/react@19.2.14)(react@19.2.6))(react@19.2.6) @@ -158,7 +161,7 @@ importers: version: 0.25.4 expo-module-scripts: specifier: ^55.0.2 - version: 55.0.2(@babel/core@7.29.7)(@babel/runtime@7.29.2)(@jest/types@29.6.3)(babel-jest@29.7.0(@babel/core@7.29.7))(esbuild@0.25.4)(eslint@9.39.4)(expo@55.0.23)(jest@29.7.0(@types/node@25.9.3))(prettier@2.8.8)(react-native@0.83.9(@babel/core@7.29.7)(@react-native/metro-config@0.85.2(@babel/core@7.29.7))(@types/react@19.2.14)(react@19.2.6))(react-refresh@0.14.2)(react-test-renderer@19.2.6(react@19.2.6))(react@19.2.6) + version: 55.0.2(@babel/core@7.29.7)(@babel/runtime@7.29.7)(@jest/types@29.6.3)(babel-jest@29.7.0(@babel/core@7.29.7))(esbuild@0.25.4)(eslint@9.39.4)(expo@55.0.27)(jest@29.7.0(@types/node@26.1.1))(prettier@2.8.8)(react-native@0.83.9(@babel/core@7.29.7)(@react-native/metro-config@0.85.2(@babel/core@7.29.7))(@types/react@19.2.14)(react@19.2.6))(react-refresh@0.14.2)(react-test-renderer@19.2.6(react@19.2.6))(react@19.2.6) oxfmt: specifier: ^0.52.0 version: 0.52.0 @@ -172,14 +175,14 @@ importers: specifier: ^4.22.4 version: 4.22.4 typescript: - specifier: ^5.9.3 - version: 5.9.3 + specifier: ^7.0.2 + version: 7.0.2 vite: specifier: ^8.0.16 - version: 8.1.0(@types/node@25.9.3)(esbuild@0.25.4)(terser@5.48.0)(tsx@4.22.4)(yaml@2.9.0) + version: 8.1.0(@types/node@26.1.1)(esbuild@0.25.4)(terser@5.49.0)(tsx@4.22.4)(yaml@2.9.0) vitest: specifier: ^4.1.9 - version: 4.1.9(@types/node@25.9.3)(jsdom@20.0.3)(vite@8.1.0(@types/node@25.9.3)(esbuild@0.25.4)(terser@5.48.0)(tsx@4.22.4)(yaml@2.9.0)) + version: 4.1.9(@types/node@26.1.1)(jsdom@20.0.3)(vite@8.1.0(@types/node@26.1.1)(esbuild@0.25.4)(terser@5.49.0)(tsx@4.22.4)(yaml@2.9.0)) packages: @@ -190,6 +193,9 @@ packages: peerDependencies: '@babel/core': ^7.0.0-0 + '@babel/code-frame@7.10.4': + resolution: {integrity: sha512-vG6SvB6oYEhvgisZNFRmRCUkLz11c7rp+tbNTynGqc6mS1d5ATd/sGyV6W0KZZnXRKMTzZDRgQT3Ou9jhpAfUg==} + '@babel/code-frame@7.29.0': resolution: {integrity: sha512-9NhCeYjq9+3uxgdtp20LSiJXJvN0FeCtNGpJxuMFZ1Kv3cWUNb6DOhJwUvcVCzKGR66cw4njwM6hrJLqgOwbcw==} engines: {node: '>=6.9.0'} @@ -383,6 +389,10 @@ packages: resolution: {integrity: sha512-1k2lAGRMfHTcwuNYcCNUmaUffmQv8KWMfh2iJUUeRlwlwH4FdNG7mfPI10NPfLHJFThE4Tyr4mv7kTNZOiPuBg==} engines: {node: '>=6.9.0'} + '@babel/highlight@7.25.9': + resolution: {integrity: sha512-llL88JShoCsth8fF8R4SJnIn+WLvR6ccFxu1H3FlMhDontdcmZWf2HgIZ7AIqV3Xcck1idlohrN4EUBQz6klbw==} + engines: {node: '>=6.9.0'} + '@babel/parser@7.29.3': resolution: {integrity: sha512-b3ctpQwp+PROvU/cttc4OYl4MzfJUWy6FZg+PMXfzmt/+39iHVF0sDfqay8TQM3JA2EUOyKcFZt75jWriQijsA==} engines: {node: '>=6.0.0'} @@ -1500,8 +1510,8 @@ packages: resolution: {integrity: sha512-yL/sLrpmtDaFEiUj1osRP4TI2MDz1AddJL+jZ7KSqvBuliN4xqYY54IfdN8qD8Toa6g1iloph1fxQNkjOxrrpQ==} engines: {node: ^18.18.0 || ^20.9.0 || >=21.1.0} - '@eslint/eslintrc@3.3.5': - resolution: {integrity: sha512-4IlJx0X0qftVsN5E+/vGujTRIFtwuLbNsVUe7TO6zYPDR1O6nFwvwhIKEKSrl6dZchmYBITazxKoUYOjdtjlRg==} + '@eslint/eslintrc@3.3.6': + resolution: {integrity: sha512-l2Ul9PrHsPCKcEY/ac7VgFj9D80C7S68sOKc618SyHDPK36s1XcFebXY0iTzUVn4Yq+YbwvSnDmCz9yxjX+QrA==} engines: {node: ^18.18.0 || ^20.9.0 || >=21.1.0} '@eslint/js@9.39.4': @@ -1519,8 +1529,8 @@ packages: '@expo-google-fonts/material-symbols@0.4.34': resolution: {integrity: sha512-PdwETUhvu1gHF1e8eIyEHnBJLq/dRNoTrT5yhsGUfGyRxH5pbm54dF3+QPknxwMKj0M1trN7PSelYz+yzlt3lA==} - '@expo/cli@55.0.29': - resolution: {integrity: sha512-r2dXQ82e/3nwxS7faLRL6HBD8UWDo/IyptQ0Vg6Z5Bgyp2Kd24h8xPn3RHfY3LLJ3wfEXglf4E79/Dqkm1Z6WA==} + '@expo/cli@55.0.33': + resolution: {integrity: sha512-mCpv91YrgiQUCXEHbNOQNPeStO4NKvYaXgKuzzLjklrCUPf3f+u0oYcS5rl3KoFvTEifhOiuA0VkLDR8U0GGPg==} hasBin: true peerDependencies: expo: '*' @@ -1535,6 +1545,9 @@ packages: '@expo/code-signing-certificates@0.0.6': resolution: {integrity: sha512-iNe0puxwBNEcuua9gmTGzq+SuMDa0iATai1FlFTMHJ/vUmKvN/V//drXoLJkVb5i5H3iE/n/qIJxyoBnXouD0w==} + '@expo/config-plugins@55.0.10': + resolution: {integrity: sha512-1txnRnMLIO5lM/Of/VyvDkCwZap0YFvCyfSTIlUQamhwhx6Rh7r8TXfcIstaDYUQ7X6GTMkNxLXWbcYS6ZAFDw==} + '@expo/config-plugins@55.0.8': resolution: {integrity: sha512-8WfWTRntTCcowfOS+tHdB0z98gKetTwktg4G5TWkCkXVa8Jt1NUnvzaaU4UHk2vbR2U4N84RyZJFizSwfF6C9g==} @@ -1544,6 +1557,9 @@ packages: '@expo/config@55.0.16': resolution: {integrity: sha512-H5dpQv5TfyZDNheZAWO3SmP10diGWZwN5QOUsArkDJih0QKNtahQBOmrV2xbhgln/nrUGoy41U/ZIY/MEx63Ug==} + '@expo/config@55.0.18': + resolution: {integrity: sha512-yezuyUk1VvVuwVyvKQrMHGosoC0WTh7HFAycpISMyeinSUHDjEBGvHUSQxtQ/71MR4g9lPJkEhFKeRjGx7I/Yg==} + '@expo/devcert@1.2.1': resolution: {integrity: sha512-qC4eaxmKMTmJC2ahwyui6ud8f3W60Ss7pMkpBq40Hu3zyiAaugPXnZ24145U7K36qO9UHdZUVxsCvIpz2RYYCA==} @@ -1579,8 +1595,17 @@ packages: '@expo/json-file@10.0.14': resolution: {integrity: sha512-yWwBFywFv+SxkJp/pIzzA416JVYflNUh7pqQzgaA6nXDqRyK7KfrqVzk8PdUfDnqbBcaZZxpzNssfQZzp5KHrA==} - '@expo/local-build-cache-provider@55.0.12': - resolution: {integrity: sha512-Wqhe7ajt6lyIEQvqDC1zm0MQ1RqQLlM9awCepY9pz+tm9rvhuxGPZTSddWeD8k4kolinBlDbLDFnNi06XgaDWQ==} + '@expo/json-file@10.0.16': + resolution: {integrity: sha512-fcVkWEj+hLuP2yt5W0aw6LmDRqSPWDLUSxOMcmFeV+algmIF59sQVKCwB9btjQLd4V6x9N0pISkQEkBubUHrCw==} + + '@expo/json-file@10.2.0': + resolution: {integrity: sha512-S6XzKe3R9GQeHiUPXc3xJjOv2VJhOEwFYf7xdC2z2cUqt3kZJ9mSO877sNQloVdnW/SUCtPY3bexlM7nwq+CAQ==} + + '@expo/json-file@11.0.0': + resolution: {integrity: sha512-pHJCETqFL5x5BzNV6cEPwjwuECgGmnl0bNmfHIJ6LM1tlh2eVXi5HEdit3zby/JO/B8Otk5cgcqtJXgvvUat3A==} + + '@expo/local-build-cache-provider@55.0.14': + resolution: {integrity: sha512-Itv/Wm8wBuq2QoJeda7N0Ys2ciq3ZQW+JLL8XijWMYOO/llieKeZue95FsuaREp3Ijdo0pSw0/cu+EkrwmU0vA==} '@expo/log-box@55.0.11': resolution: {integrity: sha512-JQHFLWkskIbJi6cxYMjErx8lQqfFJilDQLKmdTO3m3YkdmN9GE/CrzjOfVlCG0DGEGZJ90br0pGKvGPdXNsHKw==} @@ -1598,8 +1623,8 @@ packages: react: '*' react-native: '*' - '@expo/metro-config@55.0.20': - resolution: {integrity: sha512-dUv0simEyPbN2wbOjI+BdEZyXdghgCZD0+3rrA1WxXZN1lRofUx6g2+Nik2Qg61v/BXFrCTh8reYEzQPzHOhdQ==} + '@expo/metro-config@55.0.24': + resolution: {integrity: sha512-GQ8vernAY3BClpntFj+DDm4rmTpuFZYKxZ/82Q0XTZAOHnum68WIgRbgG2aRpAyVVs1wRBToKlLiSFCkpSS41w==} peerDependencies: expo: '*' peerDependenciesMeta: @@ -1624,21 +1649,29 @@ packages: resolution: {integrity: sha512-yDyBlNIgg+rKoKCOVM9ZyAtgqRx6gHw+JtmZTrYYW4auAfwS6kE/iInmCpVXPqRy0OhJHgXj6q5PCgWELLVMeg==} hasBin: true - '@expo/osascript@2.4.3': - resolution: {integrity: sha512-wbuj3EebM7W9hN/Wp4xTzKd6rQ2zKJzAxkFxkOOwyysLp0HOAgQ4/5RINyoS241pZUX2rUHq7mAJ7pcCQ8U0Ow==} + '@expo/osascript@2.7.0': + resolution: {integrity: sha512-wKIXL8UtbuX4KwavPasIW3CUcgTbYfjzLcgUhjyKUAYDEqMaf6gmU1bqz3ffBPTokmX+G8/vFG1ZuI9etQWukA==} engines: {node: '>=12'} - '@expo/package-manager@1.10.5': - resolution: {integrity: sha512-nCP9Mebfl3jvOr0/P6VAuyah6PAtun+aihIL2zAtuE8uSe94JWkVZ7051i0MUVO+y3gFpBqnr8IIH5ch+VJjHA==} + '@expo/package-manager@1.13.0': + resolution: {integrity: sha512-s3W3eZafJDEyVL7W/jxj2Nz3eONKxSCU604S5xj8ijrVaRz83x0DnZznLf/UXQEI1w+FyibH68nHeQyk767b1A==} '@expo/plist@0.5.3': resolution: {integrity: sha512-jz5oPcPDd3fygwVxwSwmO6wodTwm0Qa14NUyPy0ka7H8sFmCtNZUI2+DzVe/EXjOhq1FbEjrwl89gdlWYOnVjQ==} + '@expo/plist@0.5.4': + resolution: {integrity: sha512-Jqppj0FULNq6Zp5JtQrFICl8TtpMjwwUbxEcEC2T3z7m+TOrTQEHZXz3D3Ay7vhbmvD+VMgfWJ4ARclJXeN8Eg==} + '@expo/prebuild-config@55.0.17': resolution: {integrity: sha512-Mcs+dg4Ripu0yCtzf66KZr18PehI1O8HxzJw+G5SUF8VWX+ic99aci1PltvmydWepLwTQL6ykmpXicAUA31IqA==} peerDependencies: expo: '*' + '@expo/prebuild-config@55.0.19': + resolution: {integrity: sha512-rLGjUXW0EUoxu/PJUOC0Lz5PEs27DmBA9Es3t2xV2ftI3kGZJK6oNt1vVq5D/Rn6MhZ5SFxqd3kziyP8ELoN6g==} + peerDependencies: + expo: '*' + '@expo/require-utils@55.0.5': resolution: {integrity: sha512-U4K/CQ2VpXuwfNGsN+daKmYOt15hCP8v/pXaYH6eut7kdYZo6SfJ1yr67BIcJ+1Gzzs+QzTxswAZChKpXmceyw==} peerDependencies: @@ -1647,15 +1680,15 @@ packages: typescript: optional: true - '@expo/router-server@55.0.16': - resolution: {integrity: sha512-LvAdrm039nQBG+95+ff5Rc4CsBuoc/giDhjQrgxB9lKJqC/ZTq1xbwfEZFNq6yokX6fOCs/vlxdhmSkOjMIrvg==} + '@expo/router-server@55.0.18': + resolution: {integrity: sha512-W0VsvIiR48OvdlAOUlag4qspGYT/DV4srfYowlbYxwZh5Qw0MjiZAID4Zt7F0qynGZZxx8OZPpFhIX7XsqtRmg==} peerDependencies: '@expo/metro-runtime': ^55.0.11 expo: '*' expo-constants: ^55.0.16 - expo-font: ^55.0.7 + expo-font: ^55.0.8 expo-router: '*' - expo-server: ^55.0.9 + expo-server: ^55.0.11 react: '*' react-dom: '*' react-server-dom-webpack: ~19.0.1 || ~19.1.2 || ~19.2.1 @@ -1679,6 +1712,10 @@ packages: resolution: {integrity: sha512-QdWi16+CHB9JYP7gma19OVVg0BFkvU8zNj9GjWorYI8Iv8FUxjOCcYRuAmX4s/h91e4e7BPsskc8cSrZYho9Ew==} engines: {node: '>=12'} + '@expo/spawn-async@1.8.0': + resolution: {integrity: sha512-eb9xxd/LbuEGSdua4NumCu/McVB9EM+F/JxB9pWgnERw4HQ9XyTNH1KapG6oqLWR8TuRK2LQfzJlmNi94CVobw==} + engines: {node: '>=12'} + '@expo/sudo-prompt@9.3.2': resolution: {integrity: sha512-HHQigo3rQWKMDzYDLkubN5WQOYXJJE2eNqIQC2axC2iO3mHdwnIR7FgZVvHWtBwAdzBgAP0ECp8KqS8TiMKvgw==} @@ -2666,8 +2703,8 @@ packages: '@types/node@25.6.0': resolution: {integrity: sha512-+qIYRKdNYJwY3vRCZMdJbPLJAtGjQBudzZzdzwQYkEPQd+PJGixUL5QfvCLDaULoLv+RhT3LDkwEfKaAkgSmNQ==} - '@types/node@25.9.3': - resolution: {integrity: sha512-603BddQMv3pUcr4U2dhujk83N2tTDVr/34wII2B6bJy6g+8WD6yUb11jszNs0gdi4PesVWl7ABt8nYMVpnLUcg==} + '@types/node@26.1.1': + resolution: {integrity: sha512-nxAkRSVkN1Y0JC1W8ky/fTfkGsMmcrRsbx+3XoZE+rMOX71kLYTV7fLXpqud1GpbpP5TuffXFqfX7fH2GgZREw==} '@types/react-native@0.73.0': resolution: {integrity: sha512-6ZRPQrYM72qYKGWidEttRe6M5DZBEV5F+MHMHqd4TTYx0tfkcdrUFGdef6CCxY0jXU7wldvd/zA/b0A/kTeJmA==} @@ -2756,6 +2793,126 @@ packages: resolution: {integrity: sha512-NwjLUnGy8/Zfx23fl50tRC8rYaYnM52xNRYFAXvmiil9yh1+K6aRVQMnzW6gQB/1DLgWt977lYQn7C+wtgXZiA==} engines: {node: ^18.18.0 || ^20.9.0 || >=21.1.0} + '@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/typescript-darwin-x64@7.0.2': + resolution: {integrity: sha512-SZ9xZInqApNlNGc9s0W1VSsktYSOe9cFqNOIqmN1Gs8SmkjKZYFt017G4VwPxASInODuAdbTW7sXiFUf893RgA==} + engines: {node: '>=16.20.0'} + cpu: [x64] + os: [darwin] + + '@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/typescript-linux-arm@7.0.2': + resolution: {integrity: sha512-gffT3xPz9sR7j/YJExkyPntrI0P2EP9XbOyWzth2/Gs0RstK+90RBcO0ncXoXy/beYll1SXw846Nf2zdnEz0QQ==} + engines: {node: '>=16.20.0'} + cpu: [arm] + os: [linux] + + '@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/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/typescript-win32-x64@7.0.2': + resolution: {integrity: sha512-0BQ3HkAHHlKLSp1qRvf3SUhGpGsDuhB/jgFw75guyqbxJqEaS0Cw/VFO8i2nHglJUzQCRtMMR/IBAKE3ETMC4g==} + engines: {node: '>=16.20.0'} + cpu: [x64] + os: [win32] + '@ungap/structured-clone@1.3.1': resolution: {integrity: sha512-mUFwbeTqrVgDQxFveS+df2yfap6iuP20NAKAsBt5jDEoOTDew+zwLAOilHCeQJOVSvmgCX4ogqIrA0mnyr08yQ==} @@ -2842,11 +2999,6 @@ packages: engines: {node: '>=0.4.0'} hasBin: true - acorn@8.17.0: - resolution: {integrity: sha512-xRQbDb9BnwDafYNn6Vwl839DYVjqXYb1XVGtWAZ1kcDc6iwAL4hg3B1dZlRiuENFeO2H53gFG3in621AdERVAg==} - engines: {node: '>=0.4.0'} - hasBin: true - agent-base@6.0.2: resolution: {integrity: sha512-RZNwNclF7+MS/8bDg70amg32dyeZGZxiDuQmZxKLAlQjr3jGyLx+4Kkk58UO7D2QdgFIQCovuSuZESne6RG6XQ==} engines: {node: '>= 6.0.0'} @@ -3035,6 +3187,21 @@ packages: expo-widgets: optional: true + babel-preset-expo@55.0.23: + resolution: {integrity: sha512-oY+UFcXRLtidYp0JcttzxV5Qxx7KBjBJIShalb+hk4TQoLYafJr4ubpYajLwt7BBgAIVwBdxltKeYeNYXYf02A==} + peerDependencies: + '@babel/runtime': ^7.20.0 + expo: '*' + expo-widgets: ^55.0.20 + react-refresh: '>=0.14.0 <1.0.0' + peerDependenciesMeta: + '@babel/runtime': + optional: true + expo: + optional: true + expo-widgets: + optional: true + babel-preset-jest@29.6.3: resolution: {integrity: sha512-0B3bhxR6snWXJZtR/RliHTDPRgn1sNHOR0yVtq/IiQFyuOVjFS+wuio/R4gSNkyYmKmJB4wGZv2NZanmKmTnNA==} engines: {node: ^14.15.0 || ^16.10.0 || >=18.0.0} @@ -3431,8 +3598,8 @@ packages: resolution: {integrity: sha512-EjePK1srD3P08o2j4f0ExnylqRs5B9tJjcp9t1krH2qRi8CCdsYfwe9JgSLurFBWwq4uOlipzfk5fHNvwFKr8Q==} engines: {node: ^14.15.0 || ^16.10.0 || >=18.0.0} - dnssd-advertise@1.1.4: - resolution: {integrity: sha512-AmGyK9WpNf06WeP5TjHZq/wNzP76OuEeaiTlKr9E/EEelYLczywUKoqRz+DPRq/ErssjT4lU+/W7wzJW+7K/ZA==} + dnssd-advertise@1.1.6: + resolution: {integrity: sha512-Ndrrf6BMPalkQPd/zubL+4YghH2J9NspapQ09uDXwYbvOPkP0oaqf5CkcwJ0b50kS2O3ul6yVu+jz+RY62Cejg==} doctrine@2.1.0: resolution: {integrity: sha512-35mSku4ZXK0vfCuHEDAwt55dg2jNajHZ1odvF+8SSr82EsZY4QmXfuWso8oEd8zRhVObSN18aM0CjSdoBX7zIw==} @@ -3836,8 +4003,14 @@ packages: expo: '*' react-native: '*' - expo-font@55.0.7: - resolution: {integrity: sha512-oH39Xb+3i6Y69b7YRP+P+5WLx7621t+ep/RAgLwJJYpTjs7CnSohUG+873rEtqsTAuQGi63ms7x9ZeHj1E9LYw==} + expo-file-system@55.0.23: + resolution: {integrity: sha512-XNV/NaPc7Tvd2JIsWkPW8s2vfJUI/Oe4UMX9TmOGpc6cn5BckDw/nn2Qre9lq4btT4FWHJw4SAOHmATShLC0og==} + peerDependencies: + expo: '*' + react-native: '*' + + expo-font@55.0.8: + resolution: {integrity: sha512-WyP75pnKqhLNktYwDn3xKAUNt5rLihRDv8XWGhhz6VEhVqypixpT86NA3uGtiDTlM3gGjhrYCY7o7ypXgCUOZg==} peerDependencies: expo: '*' react: '*' @@ -3905,8 +4078,8 @@ packages: resolution: {integrity: sha512-gtFQzs6wduZxYo4/1udcVLANDvQbVUVFjK1xJjgtZuUBlbJ+xFjQg5wOsQaffUDO3kqrG+DRXt1ihSxK72Wqkg==} hasBin: true - expo-modules-autolinking@55.0.21: - resolution: {integrity: sha512-P9KsJgOwI7JVwxmGfRvcXkXO4LNRvHRdWmb4ukLmX15G/vZ7b6SM17yiYkPceWq1F5KeeZ11KFjEcl0y17xy7w==} + expo-modules-autolinking@55.0.24: + resolution: {integrity: sha512-A0OyMbTPZqibYrwqj98HFYTNSvl4NSS4Zt+R5A8qiAx3nM0mc81e6Iqw7Wl4J8M/t36lJ+cT3WuVTz5Oszj6Hw==} hasBin: true expo-modules-core@55.0.25: @@ -3972,6 +4145,10 @@ packages: peerDependencies: expo: '*' + expo-server@55.0.11: + resolution: {integrity: sha512-AxRdHqcv0H1g4s923vu+5n1Nrhne23bjXbP+Vl7+Lwfpe7MG9PuU1IS95IJK6a+7BVV1mRN6QlZvs8Yv7EEXNQ==} + engines: {node: '>=20.16.0'} + expo-server@55.0.9: resolution: {integrity: sha512-N5Ipn1NwqaJzEm+G97o0Jbe4g/th3R/16N1DabnYryXKCiZwDkK13/w3VfGkQN9LOOaBP+JIRxGf4M8lQKPzyA==} engines: {node: '>=20.16.0'} @@ -4000,8 +4177,8 @@ packages: peerDependencies: expo: '*' - expo@55.0.23: - resolution: {integrity: sha512-b+lKwfzJzFiSm9G0wVGWw3c2YoZyubbl9gHOF1ZFuK8FqtxSge8pDDJMuEFmTi14dbKwh/tirB7MiORq54r7CQ==} + expo@55.0.27: + resolution: {integrity: sha512-uB8fbowkb/Xel2/x4b+cFfg76GGb2lhgaPVdm/ago+QL/852VJP+bUU6LzQVszQFmCTMHCJyrQ4l8enThCnfxw==} hasBin: true peerDependencies: '@expo/dom-webview': '*' @@ -4714,12 +4891,12 @@ packages: js-tokens@4.0.0: resolution: {integrity: sha512-RdJUflcE3cUzKiMqQgsCu06FPu9UdIJO0beYbPhHN4k6apgJtifcoCtT9bcxOpYBtpD2kCM6Sbzg4CausW/PKQ==} - js-yaml@3.14.2: - resolution: {integrity: sha512-PMSmkqxr106Xa156c2M265Z+FTrPl+oxd/rgOQy2tijQeK5TxQ43psO1ZCwhVOSdnn+RzkzlRz/eY4BgJBYVpg==} + js-yaml@3.15.0: + resolution: {integrity: sha512-ttBQIIQPDeLjpPOohtUdXuXUVoA2uIB6fEH9HyJ7234s5mBJ5wTx20njxplLZQgLaOfpmPQA7X2t5AX6tIPbog==} hasBin: true - js-yaml@4.2.0: - resolution: {integrity: sha512-ePWsvanv0DWuDRsW8dnt+R4jQ31SCRCQ7hhNcPXZPsoBZiemuZNYGf7adZdqX2D86j6rvKp3RpCxVTSb8WQlOw==} + js-yaml@4.3.0: + resolution: {integrity: sha512-1td788aAnnZ5qs7V2QIRl1owjtYpbKt749Y3xauqQgwIIGF/xXWz1wMTEBx5O3LK3lXLVuqXPdPxj2BoFHaW9Q==} hasBin: true jsc-safe-url@0.2.4: @@ -5406,10 +5583,6 @@ packages: postcss-value-parser@4.2.0: resolution: {integrity: sha512-1NNCs6uurfkVbeXG4S8JFT9t19m45ICnif8zWLd5oPSZ50QnwMfK+H3jv408d4jw/7Bttv5axS5IiHoLaVNHeQ==} - postcss@8.4.49: - resolution: {integrity: sha512-OCVPnIObs4N29kxTjzLfUryOkvZEq+pf8jTF0lg8E7uETuWHA+v7j3c/xJmiqpX450191LlmZfUKkXxkTry7nA==} - engines: {node: ^10 || ^12 || >=14} - postcss@8.5.15: resolution: {integrity: sha512-FfR8sjd4em2T6fb3I2MwAJU7HWVMr9zba+enmQeeWFfCbm+UOC/0X4DS8XtpUTMwWMGbjKYP7xjfNekzyGmB3A==} engines: {node: ^10 || ^12 || >=14} @@ -5768,6 +5941,11 @@ packages: engines: {node: '>=10'} hasBin: true + semver@7.8.5: + resolution: {integrity: sha512-Y7/KDsb8LjooZpwaqGyulO6DQlksgCncchHGk+sZIY4SBvUocMBEFH5Ur1fI4dV+Jvl0w6cjvucaIi40puRioA==} + engines: {node: '>=10'} + hasBin: true + send@0.19.2: resolution: {integrity: sha512-VMbMxbDeehAxpOtWJXlcUS5E8iXh6QmN+BkRX1GARS3wRaXEEgzCcB10gTQazO42tpNIya8xIyNx8fll1OFPrg==} engines: {node: '>= 0.8.0'} @@ -6055,8 +6233,8 @@ packages: engines: {node: '>=10'} hasBin: true - terser@5.48.0: - resolution: {integrity: sha512-J/9An6vs9Us6wKRriSFXBWdRZapREHqFzdNUKk0pmu804EMR6dr6winwo7e5JDxN4xahxQsuysyYFwlwj4XN/Q==} + terser@5.49.0: + resolution: {integrity: sha512-SNiDnXyHSrxVcIOtVbULzcTmniUiwcV7Nwdyj1twVubeTmbjoa8p69KKDpfkdoOavuM4/GRm1+ykI8qqnavHoA==} engines: {node: '>=10'} hasBin: true @@ -6198,6 +6376,11 @@ packages: engines: {node: '>=14.17'} hasBin: true + typescript@7.0.2: + resolution: {integrity: sha512-8FYau96o3NKOhbjKi/qNvG/W5jhzxkbdm5sj9AbZ/5T5sWqn3hJgLfGx27sRKZWTvyzCP8dLRBTf5tBTSRVUNA==} + engines: {node: '>=16.20.0'} + hasBin: true + ua-parser-js@1.0.41: resolution: {integrity: sha512-LbBDqdIC5s8iROCUjMbW1f5dJQTEFB1+KO9ogbvlb3nm9n4YHa5p4KTvFPWvh2Hs8gZMBuiB1/8+pdfe/tDPug==} hasBin: true @@ -6209,8 +6392,8 @@ packages: undici-types@7.19.2: resolution: {integrity: sha512-qYVnV5OEm2AW8cJMCpdV20CDyaN3g0AjDlOGf1OW4iaDEx8MwdtChUp4zu4H0VP3nDRF/8RKWH+IPp9uW0YGZg==} - undici-types@7.24.6: - resolution: {integrity: sha512-WRNW+sJgj5OBN4/0JpHFqtqzhpbnV0GuB+OozA9gCL7a993SmU+1JBZCzLNxYsbMfIeDL+lTsphD5jN5N+n0zg==} + undici-types@8.3.0: + resolution: {integrity: sha512-j375ScV60dom+YkPFIfTLcOiPxkN/buHz5GobjLhixFuANaNs3C9l4GmrWqejgXWJ7BbJcFYpTEUkS1Ge8bpZQ==} unicode-canonical-property-names-ecmascript@2.0.1: resolution: {integrity: sha512-dA8WbNeb2a6oQzAQ55YlT5vQAWGV9WXOsi3SskE3bcCdM0P4SDd+24zS/OCacdRq5BkdsRj9q3Pg6YyQoxIGqg==} @@ -6282,9 +6465,8 @@ packages: resolution: {integrity: sha512-pMZTvIkT1d+TFGvDOqodOclx0QWkkgi6Tdoa8gC8ffGAAqz9pzPTZWAybbsHHoED/ztMtkv/VoYTYyShUn81hA==} engines: {node: '>= 0.4.0'} - uuid@7.0.3: - resolution: {integrity: sha512-DPSke0pXhTZgoF/d+WSt2QaKMCFSfx7QegxEWT+JOuHF5aWrKEn0G+ztjuJg/gG8/ItK+rbPCD/yNv8yyih6Cg==} - deprecated: uuid@10 and below is no longer supported. For ESM codebases, update to uuid@latest. For CommonJS codebases, use uuid@11 (but be aware this version will likely be deprecated in 2028). + uuid@11.1.1: + resolution: {integrity: sha512-vIYxrBCC/N/K+Js3qSN88go7kIfNPssr/hHCesKCQNAjmgvYS2oqr69kIufEG+O4+PfezOH4EbIeHCfFov8ZgQ==} hasBin: true v8-to-istanbul@9.3.0: @@ -6424,8 +6606,8 @@ packages: resolution: {integrity: sha512-nt+N2dzIutVRxARx1nghPKGv1xHikU7HKdfafKkLNLindmPU/ch3U31NOCGGA/dmPcmb1VlofO0vnKAcsm0o/Q==} engines: {node: '>=12'} - whatwg-url-minimum@0.1.1: - resolution: {integrity: sha512-u2FNVjFVFZhdjb502KzXy1gKn1mEisQRJssmSJT8CPhZdZa0AP6VCbWlXERKyGu0l09t0k50FiDiralpGhBxgA==} + whatwg-url-minimum@0.1.2: + resolution: {integrity: sha512-XPEm0XFQWNVG292lII1PrRRJl3sItrs7CettZ4ncYxuDVpLyy+NwlGyut2hXI0JswcJUxeCH+CyOJK0ZzAXD6A==} whatwg-url@11.0.0: resolution: {integrity: sha512-RKT8HExMpoYx4igMiVMY83lN6UeITKJlBQ+vR/8ZJ8OCdSiN3RwCq+9gH0+Xzj0+5IrM6i4j/6LuvzbZIQgEcQ==} @@ -6547,6 +6729,10 @@ packages: resolution: {integrity: sha512-7dSzzRQ++CKnNI/krKnYRV7JKKPUXMEh61soaHKg9mrWEhzFWhFnxPxGl+69cD1Ou63C13NUPCnmIcrvqCuM6w==} engines: {node: '>=12'} + yargs@17.7.3: + resolution: {integrity: sha512-GZtjxm/J/4TSxuL3FNYjCmLktBTnIw/rVmKSIyKeYAZpmJB2ig9VauCC5xsa82GNKVKDAqpOn3KVzNt0zmrU0g==} + engines: {node: '>=12'} + yocto-queue@0.1.0: resolution: {integrity: sha512-rVksvsnNCdJ/ohGc6xgPwyN8eheCxsiLM8mxuE/t/mOVqJewPuO1miLpTHQiRgTKCLexL4MeAFVagts7HmNZ2Q==} engines: {node: '>=10'} @@ -6596,6 +6782,10 @@ snapshots: '@nicolo-ribaudo/chokidar-2': 2.1.8-no-fsevents.3 chokidar: 3.6.0 + '@babel/code-frame@7.10.4': + dependencies: + '@babel/highlight': 7.25.9 + '@babel/code-frame@7.29.0': dependencies: '@babel/helper-validator-identifier': 7.28.5 @@ -6868,6 +7058,13 @@ snapshots: '@babel/template': 7.29.7 '@babel/types': 7.29.7 + '@babel/highlight@7.25.9': + dependencies: + '@babel/helper-validator-identifier': 7.29.7 + chalk: 2.4.2 + js-tokens: 4.0.0 + picocolors: 1.1.1 + '@babel/parser@7.29.3': dependencies: '@babel/types': 7.29.0 @@ -8008,7 +8205,7 @@ snapshots: dependencies: '@types/json-schema': 7.0.15 - '@eslint/eslintrc@3.3.5': + '@eslint/eslintrc@3.3.6': dependencies: ajv: 6.15.0 debug: 4.4.3 @@ -8016,7 +8213,7 @@ snapshots: globals: 14.0.0 ignore: 5.3.2 import-fresh: 3.3.1 - js-yaml: 4.2.0 + js-yaml: 4.3.0 minimatch: 3.1.5 strip-json-comments: 3.1.1 transitivePeerDependencies: @@ -8033,24 +8230,24 @@ snapshots: '@expo-google-fonts/material-symbols@0.4.34': {} - '@expo/cli@55.0.29(@expo/dom-webview@55.0.5)(@expo/metro-runtime@55.0.10)(expo-constants@55.0.16)(expo-font@55.0.7)(expo-router@55.0.14)(expo@55.0.23)(react-dom@19.2.6(react@19.2.6))(react-native@0.83.9(@babel/core@7.29.7)(@react-native/metro-config@0.85.2(@babel/core@7.29.7))(@types/react@19.2.14)(react@19.2.6))(react@19.2.6)(typescript@5.9.3)': + '@expo/cli@55.0.33(@expo/dom-webview@55.0.5)(@expo/metro-runtime@55.0.10)(expo-constants@55.0.16)(expo-font@55.0.8)(expo-router@55.0.14)(expo@55.0.27)(react-dom@19.2.6(react@19.2.6))(react-native@0.83.9(@babel/core@7.29.7)(@react-native/metro-config@0.85.2(@babel/core@7.29.7))(@types/react@19.2.14)(react@19.2.6))(react@19.2.6)(typescript@7.0.2)': dependencies: '@expo/code-signing-certificates': 0.0.6 - '@expo/config': 55.0.16(typescript@5.9.3) - '@expo/config-plugins': 55.0.8 + '@expo/config': 55.0.18(typescript@7.0.2) + '@expo/config-plugins': 55.0.10 '@expo/devcert': 1.2.1 '@expo/env': 2.1.2 - '@expo/image-utils': 0.8.14(typescript@5.9.3) - '@expo/json-file': 10.0.14 - '@expo/log-box': 55.0.12(@expo/dom-webview@55.0.5)(expo@55.0.23)(react-native@0.83.9(@babel/core@7.29.7)(@react-native/metro-config@0.85.2(@babel/core@7.29.7))(@types/react@19.2.14)(react@19.2.6))(react@19.2.6) + '@expo/image-utils': 0.8.14(typescript@7.0.2) + '@expo/json-file': 10.2.0 + '@expo/log-box': 55.0.12(@expo/dom-webview@55.0.5)(expo@55.0.27)(react-native@0.83.9(@babel/core@7.29.7)(@react-native/metro-config@0.85.2(@babel/core@7.29.7))(@types/react@19.2.14)(react@19.2.6))(react@19.2.6) '@expo/metro': 55.1.1 - '@expo/metro-config': 55.0.20(expo@55.0.23)(typescript@5.9.3) - '@expo/osascript': 2.4.3 - '@expo/package-manager': 1.10.5 - '@expo/plist': 0.5.3 - '@expo/prebuild-config': 55.0.17(expo@55.0.23)(typescript@5.9.3) - '@expo/require-utils': 55.0.5(typescript@5.9.3) - '@expo/router-server': 55.0.16(@expo/metro-runtime@55.0.10)(expo-constants@55.0.16)(expo-font@55.0.7)(expo-router@55.0.14)(expo-server@55.0.9)(expo@55.0.23)(react-dom@19.2.6(react@19.2.6))(react@19.2.6) + '@expo/metro-config': 55.0.24(expo@55.0.27)(typescript@7.0.2) + '@expo/osascript': 2.7.0 + '@expo/package-manager': 1.13.0 + '@expo/plist': 0.5.4 + '@expo/prebuild-config': 55.0.19(expo@55.0.27)(typescript@7.0.2) + '@expo/require-utils': 55.0.5(typescript@7.0.2) + '@expo/router-server': 55.0.18(@expo/metro-runtime@55.0.10)(expo-constants@55.0.16)(expo-font@55.0.8)(expo-router@55.0.14)(expo-server@55.0.11)(expo@55.0.27)(react-dom@19.2.6(react@19.2.6))(react@19.2.6) '@expo/schema-utils': 55.0.4 '@expo/spawn-async': 1.7.2 '@expo/ws-tunnel': 1.0.6 @@ -8066,9 +8263,9 @@ snapshots: compression: 1.8.1 connect: 3.7.0 debug: 4.4.3 - dnssd-advertise: 1.1.4 - expo: 55.0.23(@babel/core@7.29.7)(@expo/dom-webview@55.0.5)(@expo/metro-runtime@55.0.10)(expo-router@55.0.14)(react-dom@19.2.6(react@19.2.6))(react-native-webview@13.16.1(react-native@0.83.9(@babel/core@7.29.7)(@react-native/metro-config@0.85.2(@babel/core@7.29.7))(@types/react@19.2.14)(react@19.2.6))(react@19.2.6))(react-native-worklets@0.8.3(@babel/core@7.29.7)(@react-native/metro-config@0.85.2(@babel/core@7.29.7))(react-native@0.83.9(@babel/core@7.29.7)(@react-native/metro-config@0.85.2(@babel/core@7.29.7))(@types/react@19.2.14)(react@19.2.6))(react@19.2.6))(react-native@0.83.9(@babel/core@7.29.7)(@react-native/metro-config@0.85.2(@babel/core@7.29.7))(@types/react@19.2.14)(react@19.2.6))(react@19.2.6)(typescript@5.9.3) - expo-server: 55.0.9 + dnssd-advertise: 1.1.6 + expo: 55.0.27(@babel/core@7.29.7)(@expo/dom-webview@55.0.5)(@expo/metro-runtime@55.0.10)(expo-router@55.0.14)(react-dom@19.2.6(react@19.2.6))(react-native-webview@13.16.1(react-native@0.83.9(@babel/core@7.29.7)(@react-native/metro-config@0.85.2(@babel/core@7.29.7))(@types/react@19.2.14)(react@19.2.6))(react@19.2.6))(react-native-worklets@0.8.3(@babel/core@7.29.7)(@react-native/metro-config@0.85.2(@babel/core@7.29.7))(react-native@0.83.9(@babel/core@7.29.7)(@react-native/metro-config@0.85.2(@babel/core@7.29.7))(@types/react@19.2.14)(react@19.2.6))(react@19.2.6))(react-native@0.83.9(@babel/core@7.29.7)(@react-native/metro-config@0.85.2(@babel/core@7.29.7))(@types/react@19.2.14)(react@19.2.6))(react@19.2.6)(typescript@7.0.2) + expo-server: 55.0.11 fetch-nodeshim: 0.4.10 getenv: 2.0.0 glob: 13.0.6 @@ -8082,7 +8279,7 @@ snapshots: progress: 2.0.3 prompts: 2.4.2 resolve-from: 5.0.0 - semver: 7.7.4 + semver: 7.8.4 send: 0.19.2 slugify: 1.6.9 source-map-support: 0.5.21 @@ -8094,7 +8291,7 @@ snapshots: ws: 8.21.0 zod: 3.25.76 optionalDependencies: - expo-router: 55.0.14(e0a8113e3689d84edb7af23d00a72ffa) + expo-router: 55.0.14(7ffd97bbc113b875771c40f21f44415d) react-native: 0.83.9(@babel/core@7.29.7)(@react-native/metro-config@0.85.2(@babel/core@7.29.7))(@types/react@19.2.14)(react@19.2.6) transitivePeerDependencies: - '@expo/dom-webview' @@ -8113,6 +8310,24 @@ snapshots: dependencies: node-forge: 1.4.0 + '@expo/config-plugins@55.0.10': + dependencies: + '@expo/config-types': 55.0.5 + '@expo/json-file': 10.0.16 + '@expo/plist': 0.5.4 + '@expo/sdk-runtime-versions': 1.0.0 + chalk: 4.1.2 + debug: 4.4.3 + getenv: 2.0.0 + glob: 13.0.6 + resolve-from: 5.0.0 + semver: 7.8.4 + slugify: 1.6.9 + xcode: 3.0.1 + xml2js: 0.6.0 + transitivePeerDependencies: + - supports-color + '@expo/config-plugins@55.0.8': dependencies: '@expo/config-types': 55.0.5 @@ -8149,6 +8364,38 @@ snapshots: - supports-color - typescript + '@expo/config@55.0.16(typescript@7.0.2)': + dependencies: + '@expo/config-plugins': 55.0.8 + '@expo/config-types': 55.0.5 + '@expo/json-file': 10.0.14 + '@expo/require-utils': 55.0.5(typescript@7.0.2) + deepmerge: 4.3.1 + getenv: 2.0.0 + glob: 13.0.6 + resolve-workspace-root: 2.0.1 + semver: 7.7.4 + slugify: 1.6.9 + transitivePeerDependencies: + - supports-color + - typescript + + '@expo/config@55.0.18(typescript@7.0.2)': + dependencies: + '@expo/config-plugins': 55.0.10 + '@expo/config-types': 55.0.5 + '@expo/json-file': 10.2.0 + '@expo/require-utils': 55.0.5(typescript@7.0.2) + deepmerge: 4.3.1 + getenv: 2.0.0 + glob: 13.0.6 + resolve-workspace-root: 2.0.1 + semver: 7.8.4 + slugify: 1.6.9 + transitivePeerDependencies: + - supports-color + - typescript + '@expo/devcert@1.2.1': dependencies: '@expo/sudo-prompt': 9.3.2 @@ -8163,9 +8410,9 @@ snapshots: react: 19.2.6 react-native: 0.83.9(@babel/core@7.29.7)(@react-native/metro-config@0.85.2(@babel/core@7.29.7))(@types/react@19.2.14)(react@19.2.6) - '@expo/dom-webview@55.0.5(expo@55.0.23)(react-native@0.83.9(@babel/core@7.29.7)(@react-native/metro-config@0.85.2(@babel/core@7.29.7))(@types/react@19.2.14)(react@19.2.6))(react@19.2.6)': + '@expo/dom-webview@55.0.5(expo@55.0.27)(react-native@0.83.9(@babel/core@7.29.7)(@react-native/metro-config@0.85.2(@babel/core@7.29.7))(@types/react@19.2.14)(react@19.2.6))(react@19.2.6)': dependencies: - expo: 55.0.23(@babel/core@7.29.7)(@expo/dom-webview@55.0.5)(@expo/metro-runtime@55.0.10)(expo-router@55.0.14)(react-dom@19.2.6(react@19.2.6))(react-native-webview@13.16.1(react-native@0.83.9(@babel/core@7.29.7)(@react-native/metro-config@0.85.2(@babel/core@7.29.7))(@types/react@19.2.14)(react@19.2.6))(react@19.2.6))(react-native-worklets@0.8.3(@babel/core@7.29.7)(@react-native/metro-config@0.85.2(@babel/core@7.29.7))(react-native@0.83.9(@babel/core@7.29.7)(@react-native/metro-config@0.85.2(@babel/core@7.29.7))(@types/react@19.2.14)(react@19.2.6))(react@19.2.6))(react-native@0.83.9(@babel/core@7.29.7)(@react-native/metro-config@0.85.2(@babel/core@7.29.7))(@types/react@19.2.14)(react@19.2.6))(react@19.2.6)(typescript@5.9.3) + expo: 55.0.27(@babel/core@7.29.7)(@expo/dom-webview@55.0.5)(@expo/metro-runtime@55.0.10)(expo-router@55.0.14)(react-dom@19.2.6(react@19.2.6))(react-native-webview@13.16.1(react-native@0.83.9(@babel/core@7.29.7)(@react-native/metro-config@0.85.2(@babel/core@7.29.7))(@types/react@19.2.14)(react@19.2.6))(react@19.2.6))(react-native-worklets@0.8.3(@babel/core@7.29.7)(@react-native/metro-config@0.85.2(@babel/core@7.29.7))(react-native@0.83.9(@babel/core@7.29.7)(@react-native/metro-config@0.85.2(@babel/core@7.29.7))(@types/react@19.2.14)(react@19.2.6))(react@19.2.6))(react-native@0.83.9(@babel/core@7.29.7)(@react-native/metro-config@0.85.2(@babel/core@7.29.7))(@types/react@19.2.14)(react@19.2.6))(react@19.2.6)(typescript@7.0.2) react: 19.2.6 react-native: 0.83.9(@babel/core@7.29.7)(@react-native/metro-config@0.85.2(@babel/core@7.29.7))(@types/react@19.2.14)(react@19.2.6) @@ -8189,13 +8436,13 @@ snapshots: ignore: 5.3.2 minimatch: 10.2.5 resolve-from: 5.0.0 - semver: 7.7.4 + semver: 7.8.4 transitivePeerDependencies: - supports-color - '@expo/image-utils@0.8.14(typescript@5.9.3)': + '@expo/image-utils@0.8.14(typescript@7.0.2)': dependencies: - '@expo/require-utils': 55.0.5(typescript@5.9.3) + '@expo/require-utils': 55.0.5(typescript@7.0.2) '@expo/spawn-async': 1.7.2 chalk: 4.1.2 getenv: 2.0.0 @@ -8211,40 +8458,55 @@ snapshots: '@babel/code-frame': 7.29.0 json5: 2.2.3 - '@expo/local-build-cache-provider@55.0.12(typescript@5.9.3)': + '@expo/json-file@10.0.16': dependencies: - '@expo/config': 55.0.16(typescript@5.9.3) + '@babel/code-frame': 7.10.4 + json5: 2.2.3 + + '@expo/json-file@10.2.0': + dependencies: + '@babel/code-frame': 7.29.7 + json5: 2.2.3 + + '@expo/json-file@11.0.0': + dependencies: + '@babel/code-frame': 7.29.7 + json5: 2.2.3 + + '@expo/local-build-cache-provider@55.0.14(typescript@7.0.2)': + dependencies: + '@expo/config': 55.0.18(typescript@7.0.2) chalk: 4.1.2 transitivePeerDependencies: - supports-color - typescript - '@expo/log-box@55.0.11(@expo/dom-webview@55.0.5)(expo@55.0.23)(react-native@0.83.9(@babel/core@7.29.7)(@react-native/metro-config@0.85.2(@babel/core@7.29.7))(@types/react@19.2.14)(react@19.2.6))(react@19.2.6)': + '@expo/log-box@55.0.11(@expo/dom-webview@55.0.5)(expo@55.0.27)(react-native@0.83.9(@babel/core@7.29.7)(@react-native/metro-config@0.85.2(@babel/core@7.29.7))(@types/react@19.2.14)(react@19.2.6))(react@19.2.6)': dependencies: - '@expo/dom-webview': 55.0.5(expo@55.0.23)(react-native@0.83.9(@babel/core@7.29.7)(@react-native/metro-config@0.85.2(@babel/core@7.29.7))(@types/react@19.2.14)(react@19.2.6))(react@19.2.6) + '@expo/dom-webview': 55.0.5(expo@55.0.27)(react-native@0.83.9(@babel/core@7.29.7)(@react-native/metro-config@0.85.2(@babel/core@7.29.7))(@types/react@19.2.14)(react@19.2.6))(react@19.2.6) anser: 1.4.10 - expo: 55.0.23(@babel/core@7.29.7)(@expo/dom-webview@55.0.5)(@expo/metro-runtime@55.0.10)(expo-router@55.0.14)(react-dom@19.2.6(react@19.2.6))(react-native-webview@13.16.1(react-native@0.83.9(@babel/core@7.29.7)(@react-native/metro-config@0.85.2(@babel/core@7.29.7))(@types/react@19.2.14)(react@19.2.6))(react@19.2.6))(react-native-worklets@0.8.3(@babel/core@7.29.7)(@react-native/metro-config@0.85.2(@babel/core@7.29.7))(react-native@0.83.9(@babel/core@7.29.7)(@react-native/metro-config@0.85.2(@babel/core@7.29.7))(@types/react@19.2.14)(react@19.2.6))(react@19.2.6))(react-native@0.83.9(@babel/core@7.29.7)(@react-native/metro-config@0.85.2(@babel/core@7.29.7))(@types/react@19.2.14)(react@19.2.6))(react@19.2.6)(typescript@5.9.3) + expo: 55.0.27(@babel/core@7.29.7)(@expo/dom-webview@55.0.5)(@expo/metro-runtime@55.0.10)(expo-router@55.0.14)(react-dom@19.2.6(react@19.2.6))(react-native-webview@13.16.1(react-native@0.83.9(@babel/core@7.29.7)(@react-native/metro-config@0.85.2(@babel/core@7.29.7))(@types/react@19.2.14)(react@19.2.6))(react@19.2.6))(react-native-worklets@0.8.3(@babel/core@7.29.7)(@react-native/metro-config@0.85.2(@babel/core@7.29.7))(react-native@0.83.9(@babel/core@7.29.7)(@react-native/metro-config@0.85.2(@babel/core@7.29.7))(@types/react@19.2.14)(react@19.2.6))(react@19.2.6))(react-native@0.83.9(@babel/core@7.29.7)(@react-native/metro-config@0.85.2(@babel/core@7.29.7))(@types/react@19.2.14)(react@19.2.6))(react@19.2.6)(typescript@7.0.2) react: 19.2.6 react-native: 0.83.9(@babel/core@7.29.7)(@react-native/metro-config@0.85.2(@babel/core@7.29.7))(@types/react@19.2.14)(react@19.2.6) stacktrace-parser: 0.1.11 - '@expo/log-box@55.0.12(@expo/dom-webview@55.0.5)(expo@55.0.23)(react-native@0.83.9(@babel/core@7.29.7)(@react-native/metro-config@0.85.2(@babel/core@7.29.7))(@types/react@19.2.14)(react@19.2.6))(react@19.2.6)': + '@expo/log-box@55.0.12(@expo/dom-webview@55.0.5)(expo@55.0.27)(react-native@0.83.9(@babel/core@7.29.7)(@react-native/metro-config@0.85.2(@babel/core@7.29.7))(@types/react@19.2.14)(react@19.2.6))(react@19.2.6)': dependencies: - '@expo/dom-webview': 55.0.5(expo@55.0.23)(react-native@0.83.9(@babel/core@7.29.7)(@react-native/metro-config@0.85.2(@babel/core@7.29.7))(@types/react@19.2.14)(react@19.2.6))(react@19.2.6) + '@expo/dom-webview': 55.0.5(expo@55.0.27)(react-native@0.83.9(@babel/core@7.29.7)(@react-native/metro-config@0.85.2(@babel/core@7.29.7))(@types/react@19.2.14)(react@19.2.6))(react@19.2.6) anser: 1.4.10 - expo: 55.0.23(@babel/core@7.29.7)(@expo/dom-webview@55.0.5)(@expo/metro-runtime@55.0.10)(expo-router@55.0.14)(react-dom@19.2.6(react@19.2.6))(react-native-webview@13.16.1(react-native@0.83.9(@babel/core@7.29.7)(@react-native/metro-config@0.85.2(@babel/core@7.29.7))(@types/react@19.2.14)(react@19.2.6))(react@19.2.6))(react-native-worklets@0.8.3(@babel/core@7.29.7)(@react-native/metro-config@0.85.2(@babel/core@7.29.7))(react-native@0.83.9(@babel/core@7.29.7)(@react-native/metro-config@0.85.2(@babel/core@7.29.7))(@types/react@19.2.14)(react@19.2.6))(react@19.2.6))(react-native@0.83.9(@babel/core@7.29.7)(@react-native/metro-config@0.85.2(@babel/core@7.29.7))(@types/react@19.2.14)(react@19.2.6))(react@19.2.6)(typescript@5.9.3) + expo: 55.0.27(@babel/core@7.29.7)(@expo/dom-webview@55.0.5)(@expo/metro-runtime@55.0.10)(expo-router@55.0.14)(react-dom@19.2.6(react@19.2.6))(react-native-webview@13.16.1(react-native@0.83.9(@babel/core@7.29.7)(@react-native/metro-config@0.85.2(@babel/core@7.29.7))(@types/react@19.2.14)(react@19.2.6))(react@19.2.6))(react-native-worklets@0.8.3(@babel/core@7.29.7)(@react-native/metro-config@0.85.2(@babel/core@7.29.7))(react-native@0.83.9(@babel/core@7.29.7)(@react-native/metro-config@0.85.2(@babel/core@7.29.7))(@types/react@19.2.14)(react@19.2.6))(react@19.2.6))(react-native@0.83.9(@babel/core@7.29.7)(@react-native/metro-config@0.85.2(@babel/core@7.29.7))(@types/react@19.2.14)(react@19.2.6))(react@19.2.6)(typescript@7.0.2) react: 19.2.6 react-native: 0.83.9(@babel/core@7.29.7)(@react-native/metro-config@0.85.2(@babel/core@7.29.7))(@types/react@19.2.14)(react@19.2.6) stacktrace-parser: 0.1.11 - '@expo/metro-config@55.0.20(expo@55.0.23)(typescript@5.9.3)': + '@expo/metro-config@55.0.24(expo@55.0.27)(typescript@7.0.2)': dependencies: - '@babel/code-frame': 7.29.0 + '@babel/code-frame': 7.29.7 '@babel/core': 7.29.7 - '@babel/generator': 7.29.1 - '@expo/config': 55.0.16(typescript@5.9.3) + '@babel/generator': 7.29.7 + '@expo/config': 55.0.18(typescript@7.0.2) '@expo/env': 2.1.2 - '@expo/json-file': 10.0.14 + '@expo/json-file': 10.0.16 '@expo/metro': 55.1.1 '@expo/spawn-async': 1.7.2 browserslist: 4.28.2 @@ -8256,21 +8518,21 @@ snapshots: jsc-safe-url: 0.2.4 lightningcss: 1.32.0 picomatch: 4.0.4 - postcss: 8.4.49 + postcss: 8.5.15 resolve-from: 5.0.0 optionalDependencies: - expo: 55.0.23(@babel/core@7.29.7)(@expo/dom-webview@55.0.5)(@expo/metro-runtime@55.0.10)(expo-router@55.0.14)(react-dom@19.2.6(react@19.2.6))(react-native-webview@13.16.1(react-native@0.83.9(@babel/core@7.29.7)(@react-native/metro-config@0.85.2(@babel/core@7.29.7))(@types/react@19.2.14)(react@19.2.6))(react@19.2.6))(react-native-worklets@0.8.3(@babel/core@7.29.7)(@react-native/metro-config@0.85.2(@babel/core@7.29.7))(react-native@0.83.9(@babel/core@7.29.7)(@react-native/metro-config@0.85.2(@babel/core@7.29.7))(@types/react@19.2.14)(react@19.2.6))(react@19.2.6))(react-native@0.83.9(@babel/core@7.29.7)(@react-native/metro-config@0.85.2(@babel/core@7.29.7))(@types/react@19.2.14)(react@19.2.6))(react@19.2.6)(typescript@5.9.3) + expo: 55.0.27(@babel/core@7.29.7)(@expo/dom-webview@55.0.5)(@expo/metro-runtime@55.0.10)(expo-router@55.0.14)(react-dom@19.2.6(react@19.2.6))(react-native-webview@13.16.1(react-native@0.83.9(@babel/core@7.29.7)(@react-native/metro-config@0.85.2(@babel/core@7.29.7))(@types/react@19.2.14)(react@19.2.6))(react@19.2.6))(react-native-worklets@0.8.3(@babel/core@7.29.7)(@react-native/metro-config@0.85.2(@babel/core@7.29.7))(react-native@0.83.9(@babel/core@7.29.7)(@react-native/metro-config@0.85.2(@babel/core@7.29.7))(@types/react@19.2.14)(react@19.2.6))(react@19.2.6))(react-native@0.83.9(@babel/core@7.29.7)(@react-native/metro-config@0.85.2(@babel/core@7.29.7))(@types/react@19.2.14)(react@19.2.6))(react@19.2.6)(typescript@7.0.2) transitivePeerDependencies: - bufferutil - supports-color - typescript - utf-8-validate - '@expo/metro-runtime@55.0.10(@expo/dom-webview@55.0.5)(expo@55.0.23)(react-dom@19.2.6(react@19.2.6))(react-native@0.83.9(@babel/core@7.29.7)(@react-native/metro-config@0.85.2(@babel/core@7.29.7))(@types/react@19.2.14)(react@19.2.6))(react@19.2.6)': + '@expo/metro-runtime@55.0.10(@expo/dom-webview@55.0.5)(expo@55.0.27)(react-dom@19.2.6(react@19.2.6))(react-native@0.83.9(@babel/core@7.29.7)(@react-native/metro-config@0.85.2(@babel/core@7.29.7))(@types/react@19.2.14)(react@19.2.6))(react@19.2.6)': dependencies: - '@expo/log-box': 55.0.11(@expo/dom-webview@55.0.5)(expo@55.0.23)(react-native@0.83.9(@babel/core@7.29.7)(@react-native/metro-config@0.85.2(@babel/core@7.29.7))(@types/react@19.2.14)(react@19.2.6))(react@19.2.6) + '@expo/log-box': 55.0.11(@expo/dom-webview@55.0.5)(expo@55.0.27)(react-native@0.83.9(@babel/core@7.29.7)(@react-native/metro-config@0.85.2(@babel/core@7.29.7))(@types/react@19.2.14)(react@19.2.6))(react@19.2.6) anser: 1.4.10 - expo: 55.0.23(@babel/core@7.29.7)(@expo/dom-webview@55.0.5)(@expo/metro-runtime@55.0.10)(expo-router@55.0.14)(react-dom@19.2.6(react@19.2.6))(react-native-webview@13.16.1(react-native@0.83.9(@babel/core@7.29.7)(@react-native/metro-config@0.85.2(@babel/core@7.29.7))(@types/react@19.2.14)(react@19.2.6))(react@19.2.6))(react-native-worklets@0.8.3(@babel/core@7.29.7)(@react-native/metro-config@0.85.2(@babel/core@7.29.7))(react-native@0.83.9(@babel/core@7.29.7)(@react-native/metro-config@0.85.2(@babel/core@7.29.7))(@types/react@19.2.14)(react@19.2.6))(react@19.2.6))(react-native@0.83.9(@babel/core@7.29.7)(@react-native/metro-config@0.85.2(@babel/core@7.29.7))(@types/react@19.2.14)(react@19.2.6))(react@19.2.6)(typescript@5.9.3) + expo: 55.0.27(@babel/core@7.29.7)(@expo/dom-webview@55.0.5)(@expo/metro-runtime@55.0.10)(expo-router@55.0.14)(react-dom@19.2.6(react@19.2.6))(react-native-webview@13.16.1(react-native@0.83.9(@babel/core@7.29.7)(@react-native/metro-config@0.85.2(@babel/core@7.29.7))(@types/react@19.2.14)(react@19.2.6))(react@19.2.6))(react-native-worklets@0.8.3(@babel/core@7.29.7)(@react-native/metro-config@0.85.2(@babel/core@7.29.7))(react-native@0.83.9(@babel/core@7.29.7)(@react-native/metro-config@0.85.2(@babel/core@7.29.7))(@types/react@19.2.14)(react@19.2.6))(react@19.2.6))(react-native@0.83.9(@babel/core@7.29.7)(@react-native/metro-config@0.85.2(@babel/core@7.29.7))(@types/react@19.2.14)(react@19.2.6))(react@19.2.6)(typescript@7.0.2) pretty-format: 29.7.0 react: 19.2.6 react-native: 0.83.9(@babel/core@7.29.7)(@react-native/metro-config@0.85.2(@babel/core@7.29.7))(@types/react@19.2.14)(react@19.2.6) @@ -8306,14 +8568,14 @@ snapshots: dependencies: semver: 5.7.2 - '@expo/osascript@2.4.3': + '@expo/osascript@2.7.0': dependencies: - '@expo/spawn-async': 1.7.2 + '@expo/spawn-async': 1.8.0 - '@expo/package-manager@1.10.5': + '@expo/package-manager@1.13.0': dependencies: - '@expo/json-file': 10.0.14 - '@expo/spawn-async': 1.7.2 + '@expo/json-file': 11.0.0 + '@expo/spawn-async': 1.8.0 chalk: 4.1.2 npm-package-arg: 11.0.3 ora: 3.4.0 @@ -8325,16 +8587,22 @@ snapshots: base64-js: 1.5.1 xmlbuilder: 15.1.1 - '@expo/prebuild-config@55.0.17(expo@55.0.23)(typescript@5.9.3)': + '@expo/plist@0.5.4': dependencies: - '@expo/config': 55.0.16(typescript@5.9.3) + '@xmldom/xmldom': 0.8.13 + base64-js: 1.5.1 + xmlbuilder: 15.1.1 + + '@expo/prebuild-config@55.0.17(expo@55.0.27)(typescript@7.0.2)': + dependencies: + '@expo/config': 55.0.16(typescript@7.0.2) '@expo/config-plugins': 55.0.8 '@expo/config-types': 55.0.5 - '@expo/image-utils': 0.8.14(typescript@5.9.3) + '@expo/image-utils': 0.8.14(typescript@7.0.2) '@expo/json-file': 10.0.14 '@react-native/normalize-colors': 0.83.6 debug: 4.4.3 - expo: 55.0.23(@babel/core@7.29.7)(@expo/dom-webview@55.0.5)(@expo/metro-runtime@55.0.10)(expo-router@55.0.14)(react-dom@19.2.6(react@19.2.6))(react-native-webview@13.16.1(react-native@0.83.9(@babel/core@7.29.7)(@react-native/metro-config@0.85.2(@babel/core@7.29.7))(@types/react@19.2.14)(react@19.2.6))(react@19.2.6))(react-native-worklets@0.8.3(@babel/core@7.29.7)(@react-native/metro-config@0.85.2(@babel/core@7.29.7))(react-native@0.83.9(@babel/core@7.29.7)(@react-native/metro-config@0.85.2(@babel/core@7.29.7))(@types/react@19.2.14)(react@19.2.6))(react@19.2.6))(react-native@0.83.9(@babel/core@7.29.7)(@react-native/metro-config@0.85.2(@babel/core@7.29.7))(@types/react@19.2.14)(react@19.2.6))(react@19.2.6)(typescript@5.9.3) + expo: 55.0.27(@babel/core@7.29.7)(@expo/dom-webview@55.0.5)(@expo/metro-runtime@55.0.10)(expo-router@55.0.14)(react-dom@19.2.6(react@19.2.6))(react-native-webview@13.16.1(react-native@0.83.9(@babel/core@7.29.7)(@react-native/metro-config@0.85.2(@babel/core@7.29.7))(@types/react@19.2.14)(react@19.2.6))(react@19.2.6))(react-native-worklets@0.8.3(@babel/core@7.29.7)(@react-native/metro-config@0.85.2(@babel/core@7.29.7))(react-native@0.83.9(@babel/core@7.29.7)(@react-native/metro-config@0.85.2(@babel/core@7.29.7))(@types/react@19.2.14)(react@19.2.6))(react@19.2.6))(react-native@0.83.9(@babel/core@7.29.7)(@react-native/metro-config@0.85.2(@babel/core@7.29.7))(@types/react@19.2.14)(react@19.2.6))(react@19.2.6)(typescript@7.0.2) resolve-from: 5.0.0 semver: 7.7.4 xml2js: 0.6.0 @@ -8342,6 +8610,23 @@ snapshots: - supports-color - typescript + '@expo/prebuild-config@55.0.19(expo@55.0.27)(typescript@7.0.2)': + dependencies: + '@expo/config': 55.0.18(typescript@7.0.2) + '@expo/config-plugins': 55.0.10 + '@expo/config-types': 55.0.5 + '@expo/image-utils': 0.8.14(typescript@7.0.2) + '@expo/json-file': 10.2.0 + '@react-native/normalize-colors': 0.83.6 + debug: 4.4.3 + expo: 55.0.27(@babel/core@7.29.7)(@expo/dom-webview@55.0.5)(@expo/metro-runtime@55.0.10)(expo-router@55.0.14)(react-dom@19.2.6(react@19.2.6))(react-native-webview@13.16.1(react-native@0.83.9(@babel/core@7.29.7)(@react-native/metro-config@0.85.2(@babel/core@7.29.7))(@types/react@19.2.14)(react@19.2.6))(react@19.2.6))(react-native-worklets@0.8.3(@babel/core@7.29.7)(@react-native/metro-config@0.85.2(@babel/core@7.29.7))(react-native@0.83.9(@babel/core@7.29.7)(@react-native/metro-config@0.85.2(@babel/core@7.29.7))(@types/react@19.2.14)(react@19.2.6))(react@19.2.6))(react-native@0.83.9(@babel/core@7.29.7)(@react-native/metro-config@0.85.2(@babel/core@7.29.7))(@types/react@19.2.14)(react@19.2.6))(react@19.2.6)(typescript@7.0.2) + resolve-from: 5.0.0 + semver: 7.8.4 + xml2js: 0.6.0 + transitivePeerDependencies: + - supports-color + - typescript + '@expo/require-utils@55.0.5(typescript@5.9.3)': dependencies: '@babel/code-frame': 7.29.0 @@ -8352,17 +8637,27 @@ snapshots: transitivePeerDependencies: - supports-color - '@expo/router-server@55.0.16(@expo/metro-runtime@55.0.10)(expo-constants@55.0.16)(expo-font@55.0.7)(expo-router@55.0.14)(expo-server@55.0.9)(expo@55.0.23)(react-dom@19.2.6(react@19.2.6))(react@19.2.6)': + '@expo/require-utils@55.0.5(typescript@7.0.2)': + dependencies: + '@babel/code-frame': 7.29.0 + '@babel/core': 7.29.7 + '@babel/plugin-transform-modules-commonjs': 7.28.6(@babel/core@7.29.7) + optionalDependencies: + typescript: 7.0.2 + transitivePeerDependencies: + - supports-color + + '@expo/router-server@55.0.18(@expo/metro-runtime@55.0.10)(expo-constants@55.0.16)(expo-font@55.0.8)(expo-router@55.0.14)(expo-server@55.0.11)(expo@55.0.27)(react-dom@19.2.6(react@19.2.6))(react@19.2.6)': dependencies: debug: 4.4.3 - expo: 55.0.23(@babel/core@7.29.7)(@expo/dom-webview@55.0.5)(@expo/metro-runtime@55.0.10)(expo-router@55.0.14)(react-dom@19.2.6(react@19.2.6))(react-native-webview@13.16.1(react-native@0.83.9(@babel/core@7.29.7)(@react-native/metro-config@0.85.2(@babel/core@7.29.7))(@types/react@19.2.14)(react@19.2.6))(react@19.2.6))(react-native-worklets@0.8.3(@babel/core@7.29.7)(@react-native/metro-config@0.85.2(@babel/core@7.29.7))(react-native@0.83.9(@babel/core@7.29.7)(@react-native/metro-config@0.85.2(@babel/core@7.29.7))(@types/react@19.2.14)(react@19.2.6))(react@19.2.6))(react-native@0.83.9(@babel/core@7.29.7)(@react-native/metro-config@0.85.2(@babel/core@7.29.7))(@types/react@19.2.14)(react@19.2.6))(react@19.2.6)(typescript@5.9.3) - expo-constants: 55.0.16(expo@55.0.23)(react-native@0.83.9(@babel/core@7.29.7)(@react-native/metro-config@0.85.2(@babel/core@7.29.7))(@types/react@19.2.14)(react@19.2.6)) - expo-font: 55.0.7(expo@55.0.23)(react-native@0.83.9(@babel/core@7.29.7)(@react-native/metro-config@0.85.2(@babel/core@7.29.7))(@types/react@19.2.14)(react@19.2.6))(react@19.2.6) - expo-server: 55.0.9 + expo: 55.0.27(@babel/core@7.29.7)(@expo/dom-webview@55.0.5)(@expo/metro-runtime@55.0.10)(expo-router@55.0.14)(react-dom@19.2.6(react@19.2.6))(react-native-webview@13.16.1(react-native@0.83.9(@babel/core@7.29.7)(@react-native/metro-config@0.85.2(@babel/core@7.29.7))(@types/react@19.2.14)(react@19.2.6))(react@19.2.6))(react-native-worklets@0.8.3(@babel/core@7.29.7)(@react-native/metro-config@0.85.2(@babel/core@7.29.7))(react-native@0.83.9(@babel/core@7.29.7)(@react-native/metro-config@0.85.2(@babel/core@7.29.7))(@types/react@19.2.14)(react@19.2.6))(react@19.2.6))(react-native@0.83.9(@babel/core@7.29.7)(@react-native/metro-config@0.85.2(@babel/core@7.29.7))(@types/react@19.2.14)(react@19.2.6))(react@19.2.6)(typescript@7.0.2) + expo-constants: 55.0.16(expo@55.0.27)(react-native@0.83.9(@babel/core@7.29.7)(@react-native/metro-config@0.85.2(@babel/core@7.29.7))(@types/react@19.2.14)(react@19.2.6)) + expo-font: 55.0.8(expo@55.0.27)(react-native@0.83.9(@babel/core@7.29.7)(@react-native/metro-config@0.85.2(@babel/core@7.29.7))(@types/react@19.2.14)(react@19.2.6))(react@19.2.6) + expo-server: 55.0.11 react: 19.2.6 optionalDependencies: - '@expo/metro-runtime': 55.0.10(@expo/dom-webview@55.0.5)(expo@55.0.23)(react-dom@19.2.6(react@19.2.6))(react-native@0.83.9(@babel/core@7.29.7)(@react-native/metro-config@0.85.2(@babel/core@7.29.7))(@types/react@19.2.14)(react@19.2.6))(react@19.2.6) - expo-router: 55.0.14(e0a8113e3689d84edb7af23d00a72ffa) + '@expo/metro-runtime': 55.0.10(@expo/dom-webview@55.0.5)(expo@55.0.27)(react-dom@19.2.6(react@19.2.6))(react-native@0.83.9(@babel/core@7.29.7)(@react-native/metro-config@0.85.2(@babel/core@7.29.7))(@types/react@19.2.14)(react@19.2.6))(react@19.2.6) + expo-router: 55.0.14(7ffd97bbc113b875771c40f21f44415d) react-dom: 19.2.6(react@19.2.6) transitivePeerDependencies: - supports-color @@ -8375,11 +8670,15 @@ snapshots: dependencies: cross-spawn: 7.0.6 + '@expo/spawn-async@1.8.0': + dependencies: + cross-spawn: 7.0.6 + '@expo/sudo-prompt@9.3.2': {} - '@expo/vector-icons@15.1.1(expo-font@55.0.7)(react-native@0.83.9(@babel/core@7.29.7)(@react-native/metro-config@0.85.2(@babel/core@7.29.7))(@types/react@19.2.14)(react@19.2.6))(react@19.2.6)': + '@expo/vector-icons@15.1.1(expo-font@55.0.8)(react-native@0.83.9(@babel/core@7.29.7)(@react-native/metro-config@0.85.2(@babel/core@7.29.7))(@types/react@19.2.14)(react@19.2.6))(react@19.2.6)': dependencies: - expo-font: 55.0.7(expo@55.0.23)(react-native@0.83.9(@babel/core@7.29.7)(@react-native/metro-config@0.85.2(@babel/core@7.29.7))(@types/react@19.2.14)(react@19.2.6))(react@19.2.6) + expo-font: 55.0.8(expo@55.0.27)(react-native@0.83.9(@babel/core@7.29.7)(@react-native/metro-config@0.85.2(@babel/core@7.29.7))(@types/react@19.2.14)(react@19.2.6))(react@19.2.6) react: 19.2.6 react-native: 0.83.9(@babel/core@7.29.7)(@react-native/metro-config@0.85.2(@babel/core@7.29.7))(@types/react@19.2.14)(react@19.2.6) @@ -8387,9 +8686,9 @@ snapshots: '@expo/xcpretty@4.4.4': dependencies: - '@babel/code-frame': 7.29.0 + '@babel/code-frame': 7.29.7 chalk: 4.1.2 - js-yaml: 4.2.0 + js-yaml: 4.3.0 '@humanfs/core@0.19.2': dependencies: @@ -8414,7 +8713,7 @@ snapshots: camelcase: 5.3.1 find-up: 4.1.0 get-package-type: 0.1.0 - js-yaml: 3.14.2 + js-yaml: 3.15.0 resolve-from: 5.0.0 '@istanbuljs/schema@0.1.6': {} @@ -8435,14 +8734,14 @@ snapshots: '@jest/test-result': 29.7.0 '@jest/transform': 29.7.0 '@jest/types': 29.6.3 - '@types/node': 25.9.3 + '@types/node': 26.1.1 ansi-escapes: 4.3.2 chalk: 4.1.2 ci-info: 3.9.0 exit: 0.1.2 graceful-fs: 4.2.11 jest-changed-files: 29.7.0 - jest-config: 29.7.0(@types/node@25.9.3) + jest-config: 29.7.0(@types/node@26.1.1) jest-haste-map: 29.7.0 jest-message-util: 29.7.0 jest-regex-util: 29.6.3 @@ -8515,7 +8814,7 @@ snapshots: '@jest/transform': 29.7.0 '@jest/types': 29.6.3 '@jridgewell/trace-mapping': 0.3.31 - '@types/node': 25.9.3 + '@types/node': 26.1.1 chalk: 4.1.2 collect-v8-coverage: 1.0.3 exit: 0.1.2 @@ -8627,9 +8926,9 @@ snapshots: '@nicolo-ribaudo/chokidar-2@2.1.8-no-fsevents.3': optional: true - '@orca/expo-two-way-audio@file:packages/expo-two-way-audio(expo@55.0.23)(react-native@0.83.9(@babel/core@7.29.7)(@react-native/metro-config@0.85.2(@babel/core@7.29.7))(@types/react@19.2.14)(react@19.2.6))(react@19.2.6)': + '@orca/expo-two-way-audio@file:packages/expo-two-way-audio(expo@55.0.27)(react-native@0.83.9(@babel/core@7.29.7)(@react-native/metro-config@0.85.2(@babel/core@7.29.7))(@types/react@19.2.14)(react@19.2.6))(react@19.2.6)': dependencies: - expo: 55.0.23(@babel/core@7.29.7)(@expo/dom-webview@55.0.5)(@expo/metro-runtime@55.0.10)(expo-router@55.0.14)(react-dom@19.2.6(react@19.2.6))(react-native-webview@13.16.1(react-native@0.83.9(@babel/core@7.29.7)(@react-native/metro-config@0.85.2(@babel/core@7.29.7))(@types/react@19.2.14)(react@19.2.6))(react@19.2.6))(react-native-worklets@0.8.3(@babel/core@7.29.7)(@react-native/metro-config@0.85.2(@babel/core@7.29.7))(react-native@0.83.9(@babel/core@7.29.7)(@react-native/metro-config@0.85.2(@babel/core@7.29.7))(@types/react@19.2.14)(react@19.2.6))(react@19.2.6))(react-native@0.83.9(@babel/core@7.29.7)(@react-native/metro-config@0.85.2(@babel/core@7.29.7))(@types/react@19.2.14)(react@19.2.6))(react@19.2.6)(typescript@5.9.3) + expo: 55.0.27(@babel/core@7.29.7)(@expo/dom-webview@55.0.5)(@expo/metro-runtime@55.0.10)(expo-router@55.0.14)(react-dom@19.2.6(react@19.2.6))(react-native-webview@13.16.1(react-native@0.83.9(@babel/core@7.29.7)(@react-native/metro-config@0.85.2(@babel/core@7.29.7))(@types/react@19.2.14)(react@19.2.6))(react@19.2.6))(react-native-worklets@0.8.3(@babel/core@7.29.7)(@react-native/metro-config@0.85.2(@babel/core@7.29.7))(react-native@0.83.9(@babel/core@7.29.7)(@react-native/metro-config@0.85.2(@babel/core@7.29.7))(@types/react@19.2.14)(react@19.2.6))(react@19.2.6))(react-native@0.83.9(@babel/core@7.29.7)(@react-native/metro-config@0.85.2(@babel/core@7.29.7))(@types/react@19.2.14)(react@19.2.6))(react@19.2.6)(typescript@7.0.2) react: 19.2.6 react-native: 0.83.9(@babel/core@7.29.7)(@react-native/metro-config@0.85.2(@babel/core@7.29.7))(@types/react@19.2.14)(react@19.2.6) @@ -9082,7 +9381,7 @@ snapshots: invariant: 2.2.4 nullthrows: 1.1.1 tinyglobby: 0.2.17 - yargs: 17.7.2 + yargs: 17.7.3 '@react-native/community-cli-plugin@0.83.9(@react-native/metro-config@0.85.2(@babel/core@7.29.7))': dependencies: @@ -9324,7 +9623,7 @@ snapshots: '@standard-schema/spec@1.1.0': {} - '@testing-library/react-native@13.3.3(jest@29.7.0(@types/node@25.9.3))(react-native@0.83.9(@babel/core@7.29.7)(@react-native/metro-config@0.85.2(@babel/core@7.29.7))(@types/react@19.2.14)(react@19.2.6))(react-test-renderer@19.2.6(react@19.2.6))(react@19.2.6)': + '@testing-library/react-native@13.3.3(jest@29.7.0(@types/node@26.1.1))(react-native@0.83.9(@babel/core@7.29.7)(@react-native/metro-config@0.85.2(@babel/core@7.29.7))(@types/react@19.2.14)(react@19.2.6))(react-test-renderer@19.2.6(react@19.2.6))(react@19.2.6)': dependencies: jest-matcher-utils: 30.3.0 picocolors: 1.1.1 @@ -9334,7 +9633,7 @@ snapshots: react-test-renderer: 19.2.6(react@19.2.6) redent: 3.0.0 optionalDependencies: - jest: 29.7.0(@types/node@25.9.3) + jest: 29.7.0(@types/node@26.1.1) '@tootallnate/once@2.0.1': {} @@ -9416,9 +9715,9 @@ snapshots: dependencies: undici-types: 7.19.2 - '@types/node@25.9.3': + '@types/node@26.1.1': dependencies: - undici-types: 7.24.6 + undici-types: 8.3.0 '@types/react-native@0.73.0(@babel/core@7.29.7)(@react-native/metro-config@0.85.2(@babel/core@7.29.7))(@types/react@19.2.14)(react@19.2.6)': dependencies: @@ -9460,7 +9759,7 @@ snapshots: '@typescript-eslint/eslint-plugin@8.59.2(@typescript-eslint/parser@8.59.2(eslint@9.39.4)(typescript@5.9.3))(eslint@9.39.4)(typescript@5.9.3)': dependencies: '@eslint-community/regexpp': 4.12.2 - '@typescript-eslint/parser': 8.59.2(eslint@9.39.4)(typescript@5.9.3) + '@typescript-eslint/parser': 8.59.2(eslint@9.39.4)(typescript@7.0.2) '@typescript-eslint/scope-manager': 8.59.2 '@typescript-eslint/type-utils': 8.59.2(eslint@9.39.4)(typescript@5.9.3) '@typescript-eslint/utils': 8.59.2(eslint@9.39.4)(typescript@5.9.3) @@ -9473,7 +9772,7 @@ snapshots: transitivePeerDependencies: - supports-color - '@typescript-eslint/parser@8.59.2(eslint@9.39.4)(typescript@5.9.3)': + '@typescript-eslint/parser@8.59.2(eslint@9.39.4)(typescript@7.0.2)': dependencies: '@typescript-eslint/scope-manager': 8.59.2 '@typescript-eslint/types': 8.59.2 @@ -9481,7 +9780,7 @@ snapshots: '@typescript-eslint/visitor-keys': 8.59.2 debug: 4.4.3 eslint: 9.39.4 - typescript: 5.9.3 + typescript: 7.0.2 transitivePeerDependencies: - supports-color @@ -9548,6 +9847,66 @@ snapshots: '@typescript-eslint/types': 8.59.2 eslint-visitor-keys: 5.0.1 + '@typescript/typescript-aix-ppc64@7.0.2': + optional: true + + '@typescript/typescript-darwin-arm64@7.0.2': + optional: true + + '@typescript/typescript-darwin-x64@7.0.2': + optional: true + + '@typescript/typescript-freebsd-arm64@7.0.2': + optional: true + + '@typescript/typescript-freebsd-x64@7.0.2': + optional: true + + '@typescript/typescript-linux-arm64@7.0.2': + optional: true + + '@typescript/typescript-linux-arm@7.0.2': + optional: true + + '@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': {} '@vitest/expect@4.1.9': @@ -9559,13 +9918,13 @@ snapshots: chai: 6.2.2 tinyrainbow: 3.1.0 - '@vitest/mocker@4.1.9(vite@8.1.0(@types/node@25.9.3)(esbuild@0.25.4)(terser@5.48.0)(tsx@4.22.4)(yaml@2.9.0))': + '@vitest/mocker@4.1.9(vite@8.1.0(@types/node@26.1.1)(esbuild@0.25.4)(terser@5.49.0)(tsx@4.22.4)(yaml@2.9.0))': dependencies: '@vitest/spy': 4.1.9 estree-walker: 3.0.3 magic-string: 0.30.21 optionalDependencies: - vite: 8.1.0(@types/node@25.9.3)(esbuild@0.25.4)(terser@5.48.0)(tsx@4.22.4)(yaml@2.9.0) + vite: 8.1.0(@types/node@26.1.1)(esbuild@0.25.4)(terser@5.49.0)(tsx@4.22.4)(yaml@2.9.0) '@vitest/pretty-format@4.1.9': dependencies: @@ -9626,9 +9985,9 @@ snapshots: acorn: 8.15.0 acorn-walk: 8.3.5 - acorn-jsx@5.3.2(acorn@8.17.0): + acorn-jsx@5.3.2(acorn@8.15.0): dependencies: - acorn: 8.17.0 + acorn: 8.15.0 acorn-walk@8.3.5: dependencies: @@ -9636,8 +9995,6 @@ snapshots: acorn@8.15.0: {} - acorn@8.17.0: {} - agent-base@6.0.2: dependencies: debug: 4.4.3 @@ -9882,7 +10239,7 @@ snapshots: '@babel/plugin-syntax-private-property-in-object': 7.14.5(@babel/core@7.29.7) '@babel/plugin-syntax-top-level-await': 7.14.5(@babel/core@7.29.7) - babel-preset-expo@55.0.21(@babel/core@7.29.7)(@babel/runtime@7.29.2)(expo@55.0.23)(react-refresh@0.14.2): + babel-preset-expo@55.0.21(@babel/core@7.29.7)(@babel/runtime@7.29.7)(expo@55.0.27)(react-refresh@0.14.2): dependencies: '@babel/generator': 7.29.1 '@babel/helper-module-imports': 7.28.6 @@ -9909,8 +10266,41 @@ snapshots: react-refresh: 0.14.2 resolve-from: 5.0.0 optionalDependencies: - '@babel/runtime': 7.29.2 - expo: 55.0.23(@babel/core@7.29.7)(@expo/dom-webview@55.0.5)(@expo/metro-runtime@55.0.10)(expo-router@55.0.14)(react-dom@19.2.6(react@19.2.6))(react-native-webview@13.16.1(react-native@0.83.9(@babel/core@7.29.7)(@react-native/metro-config@0.85.2(@babel/core@7.29.7))(@types/react@19.2.14)(react@19.2.6))(react@19.2.6))(react-native-worklets@0.8.3(@babel/core@7.29.7)(@react-native/metro-config@0.85.2(@babel/core@7.29.7))(react-native@0.83.9(@babel/core@7.29.7)(@react-native/metro-config@0.85.2(@babel/core@7.29.7))(@types/react@19.2.14)(react@19.2.6))(react@19.2.6))(react-native@0.83.9(@babel/core@7.29.7)(@react-native/metro-config@0.85.2(@babel/core@7.29.7))(@types/react@19.2.14)(react@19.2.6))(react@19.2.6)(typescript@5.9.3) + '@babel/runtime': 7.29.7 + expo: 55.0.27(@babel/core@7.29.7)(@expo/dom-webview@55.0.5)(@expo/metro-runtime@55.0.10)(expo-router@55.0.14)(react-dom@19.2.6(react@19.2.6))(react-native-webview@13.16.1(react-native@0.83.9(@babel/core@7.29.7)(@react-native/metro-config@0.85.2(@babel/core@7.29.7))(@types/react@19.2.14)(react@19.2.6))(react@19.2.6))(react-native-worklets@0.8.3(@babel/core@7.29.7)(@react-native/metro-config@0.85.2(@babel/core@7.29.7))(react-native@0.83.9(@babel/core@7.29.7)(@react-native/metro-config@0.85.2(@babel/core@7.29.7))(@types/react@19.2.14)(react@19.2.6))(react@19.2.6))(react-native@0.83.9(@babel/core@7.29.7)(@react-native/metro-config@0.85.2(@babel/core@7.29.7))(@types/react@19.2.14)(react@19.2.6))(react@19.2.6)(typescript@7.0.2) + transitivePeerDependencies: + - '@babel/core' + - supports-color + + babel-preset-expo@55.0.23(@babel/core@7.29.7)(@babel/runtime@7.29.7)(expo@55.0.27)(react-refresh@0.14.2): + dependencies: + '@babel/generator': 7.29.7 + '@babel/helper-module-imports': 7.29.7 + '@babel/plugin-proposal-decorators': 7.29.0(@babel/core@7.29.7) + '@babel/plugin-proposal-export-default-from': 7.29.7(@babel/core@7.29.7) + '@babel/plugin-syntax-export-default-from': 7.29.7(@babel/core@7.29.7) + '@babel/plugin-transform-class-static-block': 7.28.6(@babel/core@7.29.7) + '@babel/plugin-transform-export-namespace-from': 7.27.1(@babel/core@7.29.7) + '@babel/plugin-transform-flow-strip-types': 7.29.7(@babel/core@7.29.7) + '@babel/plugin-transform-modules-commonjs': 7.29.7(@babel/core@7.29.7) + '@babel/plugin-transform-object-rest-spread': 7.28.6(@babel/core@7.29.7) + '@babel/plugin-transform-parameters': 7.27.7(@babel/core@7.29.7) + '@babel/plugin-transform-private-methods': 7.29.7(@babel/core@7.29.7) + '@babel/plugin-transform-private-property-in-object': 7.29.7(@babel/core@7.29.7) + '@babel/plugin-transform-runtime': 7.29.7(@babel/core@7.29.7) + '@babel/preset-react': 7.28.5(@babel/core@7.29.7) + '@babel/preset-typescript': 7.28.5(@babel/core@7.29.7) + '@react-native/babel-preset': 0.83.6(@babel/core@7.29.7) + babel-plugin-react-compiler: 1.0.0 + babel-plugin-react-native-web: 0.21.2 + babel-plugin-syntax-hermes-parser: 0.32.1 + babel-plugin-transform-flow-enums: 0.0.2(@babel/core@7.29.7) + debug: 4.4.3 + react-refresh: 0.14.2 + resolve-from: 5.0.0 + optionalDependencies: + '@babel/runtime': 7.29.7 + expo: 55.0.27(@babel/core@7.29.7)(@expo/dom-webview@55.0.5)(@expo/metro-runtime@55.0.10)(expo-router@55.0.14)(react-dom@19.2.6(react@19.2.6))(react-native-webview@13.16.1(react-native@0.83.9(@babel/core@7.29.7)(@react-native/metro-config@0.85.2(@babel/core@7.29.7))(@types/react@19.2.14)(react@19.2.6))(react@19.2.6))(react-native-worklets@0.8.3(@babel/core@7.29.7)(@react-native/metro-config@0.85.2(@babel/core@7.29.7))(react-native@0.83.9(@babel/core@7.29.7)(@react-native/metro-config@0.85.2(@babel/core@7.29.7))(@types/react@19.2.14)(react@19.2.6))(react@19.2.6))(react-native@0.83.9(@babel/core@7.29.7)(@react-native/metro-config@0.85.2(@babel/core@7.29.7))(@types/react@19.2.14)(react@19.2.6))(react@19.2.6)(typescript@7.0.2) transitivePeerDependencies: - '@babel/core' - supports-color @@ -10171,13 +10561,13 @@ snapshots: dependencies: browserslist: 4.28.2 - create-jest@29.7.0(@types/node@25.9.3): + create-jest@29.7.0(@types/node@26.1.1): dependencies: '@jest/types': 29.6.3 chalk: 4.1.2 exit: 0.1.2 graceful-fs: 4.2.11 - jest-config: 29.7.0(@types/node@25.9.3) + jest-config: 29.7.0(@types/node@26.1.1) jest-util: 29.7.0 prompts: 2.4.2 transitivePeerDependencies: @@ -10311,7 +10701,7 @@ snapshots: diff-sequences@29.6.3: {} - dnssd-advertise@1.1.4: {} + dnssd-advertise@1.1.6: {} doctrine@2.1.0: dependencies: @@ -10564,7 +10954,7 @@ snapshots: eslint-config-universe@15.0.4(eslint@9.39.4)(prettier@2.8.8)(typescript@5.9.3): dependencies: '@typescript-eslint/eslint-plugin': 8.59.2(@typescript-eslint/parser@8.59.2(eslint@9.39.4)(typescript@5.9.3))(eslint@9.39.4)(typescript@5.9.3) - '@typescript-eslint/parser': 8.59.2(eslint@9.39.4)(typescript@5.9.3) + '@typescript-eslint/parser': 8.59.2(eslint@9.39.4)(typescript@7.0.2) eslint: 9.39.4 eslint-config-prettier: 9.1.2(eslint@9.39.4) eslint-plugin-import: 2.32.0(@typescript-eslint/parser@8.59.2(eslint@9.39.4)(typescript@5.9.3))(eslint@9.39.4) @@ -10595,7 +10985,7 @@ snapshots: dependencies: debug: 3.2.7 optionalDependencies: - '@typescript-eslint/parser': 8.59.2(eslint@9.39.4)(typescript@5.9.3) + '@typescript-eslint/parser': 8.59.2(eslint@9.39.4)(typescript@7.0.2) eslint: 9.39.4 eslint-import-resolver-node: 0.3.10 transitivePeerDependencies: @@ -10637,7 +11027,7 @@ snapshots: string.prototype.trimend: 1.0.9 tsconfig-paths: 3.15.0 optionalDependencies: - '@typescript-eslint/parser': 8.59.2(eslint@9.39.4)(typescript@5.9.3) + '@typescript-eslint/parser': 8.59.2(eslint@9.39.4)(typescript@7.0.2) transitivePeerDependencies: - eslint-import-resolver-typescript - eslint-import-resolver-webpack @@ -10727,7 +11117,7 @@ snapshots: '@eslint/config-array': 0.21.2 '@eslint/config-helpers': 0.4.2 '@eslint/core': 0.17.0 - '@eslint/eslintrc': 3.3.5 + '@eslint/eslintrc': 3.3.6 '@eslint/js': 9.39.4 '@eslint/plugin-kit': 0.4.1 '@humanfs/node': 0.16.8 @@ -10761,8 +11151,8 @@ snapshots: espree@10.4.0: dependencies: - acorn: 8.17.0 - acorn-jsx: 5.3.2(acorn@8.17.0) + acorn: 8.15.0 + acorn-jsx: 5.3.2(acorn@8.15.0) eslint-visitor-keys: 4.2.1 esprima@4.0.1: {} @@ -10811,32 +11201,32 @@ snapshots: jest-message-util: 29.7.0 jest-util: 29.7.0 - expo-application@55.0.14(expo@55.0.23): + expo-application@55.0.14(expo@55.0.27): dependencies: - expo: 55.0.23(@babel/core@7.29.7)(@expo/dom-webview@55.0.5)(@expo/metro-runtime@55.0.10)(expo-router@55.0.14)(react-dom@19.2.6(react@19.2.6))(react-native-webview@13.16.1(react-native@0.83.9(@babel/core@7.29.7)(@react-native/metro-config@0.85.2(@babel/core@7.29.7))(@types/react@19.2.14)(react@19.2.6))(react@19.2.6))(react-native-worklets@0.8.3(@babel/core@7.29.7)(@react-native/metro-config@0.85.2(@babel/core@7.29.7))(react-native@0.83.9(@babel/core@7.29.7)(@react-native/metro-config@0.85.2(@babel/core@7.29.7))(@types/react@19.2.14)(react@19.2.6))(react@19.2.6))(react-native@0.83.9(@babel/core@7.29.7)(@react-native/metro-config@0.85.2(@babel/core@7.29.7))(@types/react@19.2.14)(react@19.2.6))(react@19.2.6)(typescript@5.9.3) + expo: 55.0.27(@babel/core@7.29.7)(@expo/dom-webview@55.0.5)(@expo/metro-runtime@55.0.10)(expo-router@55.0.14)(react-dom@19.2.6(react@19.2.6))(react-native-webview@13.16.1(react-native@0.83.9(@babel/core@7.29.7)(@react-native/metro-config@0.85.2(@babel/core@7.29.7))(@types/react@19.2.14)(react@19.2.6))(react@19.2.6))(react-native-worklets@0.8.3(@babel/core@7.29.7)(@react-native/metro-config@0.85.2(@babel/core@7.29.7))(react-native@0.83.9(@babel/core@7.29.7)(@react-native/metro-config@0.85.2(@babel/core@7.29.7))(@types/react@19.2.14)(react@19.2.6))(react@19.2.6))(react-native@0.83.9(@babel/core@7.29.7)(@react-native/metro-config@0.85.2(@babel/core@7.29.7))(@types/react@19.2.14)(react@19.2.6))(react@19.2.6)(typescript@7.0.2) - expo-asset@55.0.17(expo@55.0.23)(react-native@0.83.9(@babel/core@7.29.7)(@react-native/metro-config@0.85.2(@babel/core@7.29.7))(@types/react@19.2.14)(react@19.2.6))(react@19.2.6)(typescript@5.9.3): + expo-asset@55.0.17(expo@55.0.27)(react-native@0.83.9(@babel/core@7.29.7)(@react-native/metro-config@0.85.2(@babel/core@7.29.7))(@types/react@19.2.14)(react@19.2.6))(react@19.2.6)(typescript@7.0.2): dependencies: - '@expo/image-utils': 0.8.14(typescript@5.9.3) - expo: 55.0.23(@babel/core@7.29.7)(@expo/dom-webview@55.0.5)(@expo/metro-runtime@55.0.10)(expo-router@55.0.14)(react-dom@19.2.6(react@19.2.6))(react-native-webview@13.16.1(react-native@0.83.9(@babel/core@7.29.7)(@react-native/metro-config@0.85.2(@babel/core@7.29.7))(@types/react@19.2.14)(react@19.2.6))(react@19.2.6))(react-native-worklets@0.8.3(@babel/core@7.29.7)(@react-native/metro-config@0.85.2(@babel/core@7.29.7))(react-native@0.83.9(@babel/core@7.29.7)(@react-native/metro-config@0.85.2(@babel/core@7.29.7))(@types/react@19.2.14)(react@19.2.6))(react@19.2.6))(react-native@0.83.9(@babel/core@7.29.7)(@react-native/metro-config@0.85.2(@babel/core@7.29.7))(@types/react@19.2.14)(react@19.2.6))(react@19.2.6)(typescript@5.9.3) - expo-constants: 55.0.16(expo@55.0.23)(react-native@0.83.9(@babel/core@7.29.7)(@react-native/metro-config@0.85.2(@babel/core@7.29.7))(@types/react@19.2.14)(react@19.2.6)) + '@expo/image-utils': 0.8.14(typescript@7.0.2) + expo: 55.0.27(@babel/core@7.29.7)(@expo/dom-webview@55.0.5)(@expo/metro-runtime@55.0.10)(expo-router@55.0.14)(react-dom@19.2.6(react@19.2.6))(react-native-webview@13.16.1(react-native@0.83.9(@babel/core@7.29.7)(@react-native/metro-config@0.85.2(@babel/core@7.29.7))(@types/react@19.2.14)(react@19.2.6))(react@19.2.6))(react-native-worklets@0.8.3(@babel/core@7.29.7)(@react-native/metro-config@0.85.2(@babel/core@7.29.7))(react-native@0.83.9(@babel/core@7.29.7)(@react-native/metro-config@0.85.2(@babel/core@7.29.7))(@types/react@19.2.14)(react@19.2.6))(react@19.2.6))(react-native@0.83.9(@babel/core@7.29.7)(@react-native/metro-config@0.85.2(@babel/core@7.29.7))(@types/react@19.2.14)(react@19.2.6))(react@19.2.6)(typescript@7.0.2) + expo-constants: 55.0.16(expo@55.0.27)(react-native@0.83.9(@babel/core@7.29.7)(@react-native/metro-config@0.85.2(@babel/core@7.29.7))(@types/react@19.2.14)(react@19.2.6)) react: 19.2.6 react-native: 0.83.9(@babel/core@7.29.7)(@react-native/metro-config@0.85.2(@babel/core@7.29.7))(@types/react@19.2.14)(react@19.2.6) transitivePeerDependencies: - supports-color - typescript - expo-build-properties@55.0.13(expo@55.0.23): + expo-build-properties@55.0.13(expo@55.0.27): dependencies: '@expo/schema-utils': 55.0.4 - expo: 55.0.23(@babel/core@7.29.7)(@expo/dom-webview@55.0.5)(@expo/metro-runtime@55.0.10)(expo-router@55.0.14)(react-dom@19.2.6(react@19.2.6))(react-native-webview@13.16.1(react-native@0.83.9(@babel/core@7.29.7)(@react-native/metro-config@0.85.2(@babel/core@7.29.7))(@types/react@19.2.14)(react@19.2.6))(react@19.2.6))(react-native-worklets@0.8.3(@babel/core@7.29.7)(@react-native/metro-config@0.85.2(@babel/core@7.29.7))(react-native@0.83.9(@babel/core@7.29.7)(@react-native/metro-config@0.85.2(@babel/core@7.29.7))(@types/react@19.2.14)(react@19.2.6))(react@19.2.6))(react-native@0.83.9(@babel/core@7.29.7)(@react-native/metro-config@0.85.2(@babel/core@7.29.7))(@types/react@19.2.14)(react@19.2.6))(react@19.2.6)(typescript@5.9.3) + expo: 55.0.27(@babel/core@7.29.7)(@expo/dom-webview@55.0.5)(@expo/metro-runtime@55.0.10)(expo-router@55.0.14)(react-dom@19.2.6(react@19.2.6))(react-native-webview@13.16.1(react-native@0.83.9(@babel/core@7.29.7)(@react-native/metro-config@0.85.2(@babel/core@7.29.7))(@types/react@19.2.14)(react@19.2.6))(react@19.2.6))(react-native-worklets@0.8.3(@babel/core@7.29.7)(@react-native/metro-config@0.85.2(@babel/core@7.29.7))(react-native@0.83.9(@babel/core@7.29.7)(@react-native/metro-config@0.85.2(@babel/core@7.29.7))(@types/react@19.2.14)(react@19.2.6))(react@19.2.6))(react-native@0.83.9(@babel/core@7.29.7)(@react-native/metro-config@0.85.2(@babel/core@7.29.7))(@types/react@19.2.14)(react@19.2.6))(react@19.2.6)(typescript@7.0.2) resolve-from: 5.0.0 semver: 7.7.4 - expo-camera@55.0.18(@types/emscripten@1.41.5)(expo@55.0.23)(react-native-web@0.21.2(react-dom@19.2.6(react@19.2.6))(react@19.2.6))(react-native@0.83.9(@babel/core@7.29.7)(@react-native/metro-config@0.85.2(@babel/core@7.29.7))(@types/react@19.2.14)(react@19.2.6))(react@19.2.6): + expo-camera@55.0.18(@types/emscripten@1.41.5)(expo@55.0.27)(react-native-web@0.21.2(react-dom@19.2.6(react@19.2.6))(react@19.2.6))(react-native@0.83.9(@babel/core@7.29.7)(@react-native/metro-config@0.85.2(@babel/core@7.29.7))(@types/react@19.2.14)(react@19.2.6))(react@19.2.6): dependencies: barcode-detector: 3.1.3(@types/emscripten@1.41.5) - expo: 55.0.23(@babel/core@7.29.7)(@expo/dom-webview@55.0.5)(@expo/metro-runtime@55.0.10)(expo-router@55.0.14)(react-dom@19.2.6(react@19.2.6))(react-native-webview@13.16.1(react-native@0.83.9(@babel/core@7.29.7)(@react-native/metro-config@0.85.2(@babel/core@7.29.7))(@types/react@19.2.14)(react@19.2.6))(react@19.2.6))(react-native-worklets@0.8.3(@babel/core@7.29.7)(@react-native/metro-config@0.85.2(@babel/core@7.29.7))(react-native@0.83.9(@babel/core@7.29.7)(@react-native/metro-config@0.85.2(@babel/core@7.29.7))(@types/react@19.2.14)(react@19.2.6))(react@19.2.6))(react-native@0.83.9(@babel/core@7.29.7)(@react-native/metro-config@0.85.2(@babel/core@7.29.7))(@types/react@19.2.14)(react@19.2.6))(react@19.2.6)(typescript@5.9.3) + expo: 55.0.27(@babel/core@7.29.7)(@expo/dom-webview@55.0.5)(@expo/metro-runtime@55.0.10)(expo-router@55.0.14)(react-dom@19.2.6(react@19.2.6))(react-native-webview@13.16.1(react-native@0.83.9(@babel/core@7.29.7)(@react-native/metro-config@0.85.2(@babel/core@7.29.7))(@types/react@19.2.14)(react@19.2.6))(react@19.2.6))(react-native-worklets@0.8.3(@babel/core@7.29.7)(@react-native/metro-config@0.85.2(@babel/core@7.29.7))(react-native@0.83.9(@babel/core@7.29.7)(@react-native/metro-config@0.85.2(@babel/core@7.29.7))(@types/react@19.2.14)(react@19.2.6))(react@19.2.6))(react-native@0.83.9(@babel/core@7.29.7)(@react-native/metro-config@0.85.2(@babel/core@7.29.7))(@types/react@19.2.14)(react@19.2.6))(react@19.2.6)(typescript@7.0.2) react: 19.2.6 react-native: 0.83.9(@babel/core@7.29.7)(@react-native/metro-config@0.85.2(@babel/core@7.29.7))(@types/react@19.2.14)(react@19.2.6) optionalDependencies: @@ -10844,92 +11234,97 @@ snapshots: transitivePeerDependencies: - '@types/emscripten' - expo-clipboard@55.0.13(expo@55.0.23)(react-native@0.83.9(@babel/core@7.29.7)(@react-native/metro-config@0.85.2(@babel/core@7.29.7))(@types/react@19.2.14)(react@19.2.6))(react@19.2.6): + expo-clipboard@55.0.13(expo@55.0.27)(react-native@0.83.9(@babel/core@7.29.7)(@react-native/metro-config@0.85.2(@babel/core@7.29.7))(@types/react@19.2.14)(react@19.2.6))(react@19.2.6): dependencies: - expo: 55.0.23(@babel/core@7.29.7)(@expo/dom-webview@55.0.5)(@expo/metro-runtime@55.0.10)(expo-router@55.0.14)(react-dom@19.2.6(react@19.2.6))(react-native-webview@13.16.1(react-native@0.83.9(@babel/core@7.29.7)(@react-native/metro-config@0.85.2(@babel/core@7.29.7))(@types/react@19.2.14)(react@19.2.6))(react@19.2.6))(react-native-worklets@0.8.3(@babel/core@7.29.7)(@react-native/metro-config@0.85.2(@babel/core@7.29.7))(react-native@0.83.9(@babel/core@7.29.7)(@react-native/metro-config@0.85.2(@babel/core@7.29.7))(@types/react@19.2.14)(react@19.2.6))(react@19.2.6))(react-native@0.83.9(@babel/core@7.29.7)(@react-native/metro-config@0.85.2(@babel/core@7.29.7))(@types/react@19.2.14)(react@19.2.6))(react@19.2.6)(typescript@5.9.3) + expo: 55.0.27(@babel/core@7.29.7)(@expo/dom-webview@55.0.5)(@expo/metro-runtime@55.0.10)(expo-router@55.0.14)(react-dom@19.2.6(react@19.2.6))(react-native-webview@13.16.1(react-native@0.83.9(@babel/core@7.29.7)(@react-native/metro-config@0.85.2(@babel/core@7.29.7))(@types/react@19.2.14)(react@19.2.6))(react@19.2.6))(react-native-worklets@0.8.3(@babel/core@7.29.7)(@react-native/metro-config@0.85.2(@babel/core@7.29.7))(react-native@0.83.9(@babel/core@7.29.7)(@react-native/metro-config@0.85.2(@babel/core@7.29.7))(@types/react@19.2.14)(react@19.2.6))(react@19.2.6))(react-native@0.83.9(@babel/core@7.29.7)(@react-native/metro-config@0.85.2(@babel/core@7.29.7))(@types/react@19.2.14)(react@19.2.6))(react@19.2.6)(typescript@7.0.2) react: 19.2.6 react-native: 0.83.9(@babel/core@7.29.7)(@react-native/metro-config@0.85.2(@babel/core@7.29.7))(@types/react@19.2.14)(react@19.2.6) - expo-constants@55.0.16(expo@55.0.23)(react-native@0.83.9(@babel/core@7.29.7)(@react-native/metro-config@0.85.2(@babel/core@7.29.7))(@types/react@19.2.14)(react@19.2.6)): + expo-constants@55.0.16(expo@55.0.27)(react-native@0.83.9(@babel/core@7.29.7)(@react-native/metro-config@0.85.2(@babel/core@7.29.7))(@types/react@19.2.14)(react@19.2.6)): dependencies: '@expo/env': 2.1.2 - expo: 55.0.23(@babel/core@7.29.7)(@expo/dom-webview@55.0.5)(@expo/metro-runtime@55.0.10)(expo-router@55.0.14)(react-dom@19.2.6(react@19.2.6))(react-native-webview@13.16.1(react-native@0.83.9(@babel/core@7.29.7)(@react-native/metro-config@0.85.2(@babel/core@7.29.7))(@types/react@19.2.14)(react@19.2.6))(react@19.2.6))(react-native-worklets@0.8.3(@babel/core@7.29.7)(@react-native/metro-config@0.85.2(@babel/core@7.29.7))(react-native@0.83.9(@babel/core@7.29.7)(@react-native/metro-config@0.85.2(@babel/core@7.29.7))(@types/react@19.2.14)(react@19.2.6))(react@19.2.6))(react-native@0.83.9(@babel/core@7.29.7)(@react-native/metro-config@0.85.2(@babel/core@7.29.7))(@types/react@19.2.14)(react@19.2.6))(react@19.2.6)(typescript@5.9.3) + expo: 55.0.27(@babel/core@7.29.7)(@expo/dom-webview@55.0.5)(@expo/metro-runtime@55.0.10)(expo-router@55.0.14)(react-dom@19.2.6(react@19.2.6))(react-native-webview@13.16.1(react-native@0.83.9(@babel/core@7.29.7)(@react-native/metro-config@0.85.2(@babel/core@7.29.7))(@types/react@19.2.14)(react@19.2.6))(react@19.2.6))(react-native-worklets@0.8.3(@babel/core@7.29.7)(@react-native/metro-config@0.85.2(@babel/core@7.29.7))(react-native@0.83.9(@babel/core@7.29.7)(@react-native/metro-config@0.85.2(@babel/core@7.29.7))(@types/react@19.2.14)(react@19.2.6))(react@19.2.6))(react-native@0.83.9(@babel/core@7.29.7)(@react-native/metro-config@0.85.2(@babel/core@7.29.7))(@types/react@19.2.14)(react@19.2.6))(react@19.2.6)(typescript@7.0.2) react-native: 0.83.9(@babel/core@7.29.7)(@react-native/metro-config@0.85.2(@babel/core@7.29.7))(@types/react@19.2.14)(react@19.2.6) transitivePeerDependencies: - supports-color - expo-crypto@55.0.14(expo@55.0.23): + expo-crypto@55.0.14(expo@55.0.27): dependencies: - expo: 55.0.23(@babel/core@7.29.7)(@expo/dom-webview@55.0.5)(@expo/metro-runtime@55.0.10)(expo-router@55.0.14)(react-dom@19.2.6(react@19.2.6))(react-native-webview@13.16.1(react-native@0.83.9(@babel/core@7.29.7)(@react-native/metro-config@0.85.2(@babel/core@7.29.7))(@types/react@19.2.14)(react@19.2.6))(react@19.2.6))(react-native-worklets@0.8.3(@babel/core@7.29.7)(@react-native/metro-config@0.85.2(@babel/core@7.29.7))(react-native@0.83.9(@babel/core@7.29.7)(@react-native/metro-config@0.85.2(@babel/core@7.29.7))(@types/react@19.2.14)(react@19.2.6))(react@19.2.6))(react-native@0.83.9(@babel/core@7.29.7)(@react-native/metro-config@0.85.2(@babel/core@7.29.7))(@types/react@19.2.14)(react@19.2.6))(react@19.2.6)(typescript@5.9.3) + expo: 55.0.27(@babel/core@7.29.7)(@expo/dom-webview@55.0.5)(@expo/metro-runtime@55.0.10)(expo-router@55.0.14)(react-dom@19.2.6(react@19.2.6))(react-native-webview@13.16.1(react-native@0.83.9(@babel/core@7.29.7)(@react-native/metro-config@0.85.2(@babel/core@7.29.7))(@types/react@19.2.14)(react@19.2.6))(react@19.2.6))(react-native-worklets@0.8.3(@babel/core@7.29.7)(@react-native/metro-config@0.85.2(@babel/core@7.29.7))(react-native@0.83.9(@babel/core@7.29.7)(@react-native/metro-config@0.85.2(@babel/core@7.29.7))(@types/react@19.2.14)(react@19.2.6))(react@19.2.6))(react-native@0.83.9(@babel/core@7.29.7)(@react-native/metro-config@0.85.2(@babel/core@7.29.7))(@types/react@19.2.14)(react@19.2.6))(react@19.2.6)(typescript@7.0.2) - expo-dev-client@55.0.35(expo@55.0.23): + expo-dev-client@55.0.35(expo@55.0.27): dependencies: - expo: 55.0.23(@babel/core@7.29.7)(@expo/dom-webview@55.0.5)(@expo/metro-runtime@55.0.10)(expo-router@55.0.14)(react-dom@19.2.6(react@19.2.6))(react-native-webview@13.16.1(react-native@0.83.9(@babel/core@7.29.7)(@react-native/metro-config@0.85.2(@babel/core@7.29.7))(@types/react@19.2.14)(react@19.2.6))(react@19.2.6))(react-native-worklets@0.8.3(@babel/core@7.29.7)(@react-native/metro-config@0.85.2(@babel/core@7.29.7))(react-native@0.83.9(@babel/core@7.29.7)(@react-native/metro-config@0.85.2(@babel/core@7.29.7))(@types/react@19.2.14)(react@19.2.6))(react@19.2.6))(react-native@0.83.9(@babel/core@7.29.7)(@react-native/metro-config@0.85.2(@babel/core@7.29.7))(@types/react@19.2.14)(react@19.2.6))(react@19.2.6)(typescript@5.9.3) - expo-dev-launcher: 55.0.36(expo@55.0.23) - expo-dev-menu: 55.0.30(expo@55.0.23) - expo-dev-menu-interface: 55.0.2(expo@55.0.23) - expo-manifests: 55.0.17(expo@55.0.23) - expo-updates-interface: 55.1.6(expo@55.0.23) + expo: 55.0.27(@babel/core@7.29.7)(@expo/dom-webview@55.0.5)(@expo/metro-runtime@55.0.10)(expo-router@55.0.14)(react-dom@19.2.6(react@19.2.6))(react-native-webview@13.16.1(react-native@0.83.9(@babel/core@7.29.7)(@react-native/metro-config@0.85.2(@babel/core@7.29.7))(@types/react@19.2.14)(react@19.2.6))(react@19.2.6))(react-native-worklets@0.8.3(@babel/core@7.29.7)(@react-native/metro-config@0.85.2(@babel/core@7.29.7))(react-native@0.83.9(@babel/core@7.29.7)(@react-native/metro-config@0.85.2(@babel/core@7.29.7))(@types/react@19.2.14)(react@19.2.6))(react@19.2.6))(react-native@0.83.9(@babel/core@7.29.7)(@react-native/metro-config@0.85.2(@babel/core@7.29.7))(@types/react@19.2.14)(react@19.2.6))(react@19.2.6)(typescript@7.0.2) + expo-dev-launcher: 55.0.36(expo@55.0.27) + expo-dev-menu: 55.0.30(expo@55.0.27) + expo-dev-menu-interface: 55.0.2(expo@55.0.27) + expo-manifests: 55.0.17(expo@55.0.27) + expo-updates-interface: 55.1.6(expo@55.0.27) - expo-dev-launcher@55.0.36(expo@55.0.23): + expo-dev-launcher@55.0.36(expo@55.0.27): dependencies: '@expo/schema-utils': 55.0.4 - expo: 55.0.23(@babel/core@7.29.7)(@expo/dom-webview@55.0.5)(@expo/metro-runtime@55.0.10)(expo-router@55.0.14)(react-dom@19.2.6(react@19.2.6))(react-native-webview@13.16.1(react-native@0.83.9(@babel/core@7.29.7)(@react-native/metro-config@0.85.2(@babel/core@7.29.7))(@types/react@19.2.14)(react@19.2.6))(react@19.2.6))(react-native-worklets@0.8.3(@babel/core@7.29.7)(@react-native/metro-config@0.85.2(@babel/core@7.29.7))(react-native@0.83.9(@babel/core@7.29.7)(@react-native/metro-config@0.85.2(@babel/core@7.29.7))(@types/react@19.2.14)(react@19.2.6))(react@19.2.6))(react-native@0.83.9(@babel/core@7.29.7)(@react-native/metro-config@0.85.2(@babel/core@7.29.7))(@types/react@19.2.14)(react@19.2.6))(react@19.2.6)(typescript@5.9.3) - expo-dev-menu: 55.0.30(expo@55.0.23) - expo-manifests: 55.0.17(expo@55.0.23) + expo: 55.0.27(@babel/core@7.29.7)(@expo/dom-webview@55.0.5)(@expo/metro-runtime@55.0.10)(expo-router@55.0.14)(react-dom@19.2.6(react@19.2.6))(react-native-webview@13.16.1(react-native@0.83.9(@babel/core@7.29.7)(@react-native/metro-config@0.85.2(@babel/core@7.29.7))(@types/react@19.2.14)(react@19.2.6))(react@19.2.6))(react-native-worklets@0.8.3(@babel/core@7.29.7)(@react-native/metro-config@0.85.2(@babel/core@7.29.7))(react-native@0.83.9(@babel/core@7.29.7)(@react-native/metro-config@0.85.2(@babel/core@7.29.7))(@types/react@19.2.14)(react@19.2.6))(react@19.2.6))(react-native@0.83.9(@babel/core@7.29.7)(@react-native/metro-config@0.85.2(@babel/core@7.29.7))(@types/react@19.2.14)(react@19.2.6))(react@19.2.6)(typescript@7.0.2) + expo-dev-menu: 55.0.30(expo@55.0.27) + expo-manifests: 55.0.17(expo@55.0.27) - expo-dev-menu-interface@55.0.2(expo@55.0.23): + expo-dev-menu-interface@55.0.2(expo@55.0.27): dependencies: - expo: 55.0.23(@babel/core@7.29.7)(@expo/dom-webview@55.0.5)(@expo/metro-runtime@55.0.10)(expo-router@55.0.14)(react-dom@19.2.6(react@19.2.6))(react-native-webview@13.16.1(react-native@0.83.9(@babel/core@7.29.7)(@react-native/metro-config@0.85.2(@babel/core@7.29.7))(@types/react@19.2.14)(react@19.2.6))(react@19.2.6))(react-native-worklets@0.8.3(@babel/core@7.29.7)(@react-native/metro-config@0.85.2(@babel/core@7.29.7))(react-native@0.83.9(@babel/core@7.29.7)(@react-native/metro-config@0.85.2(@babel/core@7.29.7))(@types/react@19.2.14)(react@19.2.6))(react@19.2.6))(react-native@0.83.9(@babel/core@7.29.7)(@react-native/metro-config@0.85.2(@babel/core@7.29.7))(@types/react@19.2.14)(react@19.2.6))(react@19.2.6)(typescript@5.9.3) + expo: 55.0.27(@babel/core@7.29.7)(@expo/dom-webview@55.0.5)(@expo/metro-runtime@55.0.10)(expo-router@55.0.14)(react-dom@19.2.6(react@19.2.6))(react-native-webview@13.16.1(react-native@0.83.9(@babel/core@7.29.7)(@react-native/metro-config@0.85.2(@babel/core@7.29.7))(@types/react@19.2.14)(react@19.2.6))(react@19.2.6))(react-native-worklets@0.8.3(@babel/core@7.29.7)(@react-native/metro-config@0.85.2(@babel/core@7.29.7))(react-native@0.83.9(@babel/core@7.29.7)(@react-native/metro-config@0.85.2(@babel/core@7.29.7))(@types/react@19.2.14)(react@19.2.6))(react@19.2.6))(react-native@0.83.9(@babel/core@7.29.7)(@react-native/metro-config@0.85.2(@babel/core@7.29.7))(@types/react@19.2.14)(react@19.2.6))(react@19.2.6)(typescript@7.0.2) - expo-dev-menu@55.0.30(expo@55.0.23): + expo-dev-menu@55.0.30(expo@55.0.27): dependencies: - expo: 55.0.23(@babel/core@7.29.7)(@expo/dom-webview@55.0.5)(@expo/metro-runtime@55.0.10)(expo-router@55.0.14)(react-dom@19.2.6(react@19.2.6))(react-native-webview@13.16.1(react-native@0.83.9(@babel/core@7.29.7)(@react-native/metro-config@0.85.2(@babel/core@7.29.7))(@types/react@19.2.14)(react@19.2.6))(react@19.2.6))(react-native-worklets@0.8.3(@babel/core@7.29.7)(@react-native/metro-config@0.85.2(@babel/core@7.29.7))(react-native@0.83.9(@babel/core@7.29.7)(@react-native/metro-config@0.85.2(@babel/core@7.29.7))(@types/react@19.2.14)(react@19.2.6))(react@19.2.6))(react-native@0.83.9(@babel/core@7.29.7)(@react-native/metro-config@0.85.2(@babel/core@7.29.7))(@types/react@19.2.14)(react@19.2.6))(react@19.2.6)(typescript@5.9.3) - expo-dev-menu-interface: 55.0.2(expo@55.0.23) + expo: 55.0.27(@babel/core@7.29.7)(@expo/dom-webview@55.0.5)(@expo/metro-runtime@55.0.10)(expo-router@55.0.14)(react-dom@19.2.6(react@19.2.6))(react-native-webview@13.16.1(react-native@0.83.9(@babel/core@7.29.7)(@react-native/metro-config@0.85.2(@babel/core@7.29.7))(@types/react@19.2.14)(react@19.2.6))(react@19.2.6))(react-native-worklets@0.8.3(@babel/core@7.29.7)(@react-native/metro-config@0.85.2(@babel/core@7.29.7))(react-native@0.83.9(@babel/core@7.29.7)(@react-native/metro-config@0.85.2(@babel/core@7.29.7))(@types/react@19.2.14)(react@19.2.6))(react@19.2.6))(react-native@0.83.9(@babel/core@7.29.7)(@react-native/metro-config@0.85.2(@babel/core@7.29.7))(@types/react@19.2.14)(react@19.2.6))(react@19.2.6)(typescript@7.0.2) + expo-dev-menu-interface: 55.0.2(expo@55.0.27) - expo-document-picker@55.0.13(expo@55.0.23): + expo-document-picker@55.0.13(expo@55.0.27): dependencies: - expo: 55.0.23(@babel/core@7.29.7)(@expo/dom-webview@55.0.5)(@expo/metro-runtime@55.0.10)(expo-router@55.0.14)(react-dom@19.2.6(react@19.2.6))(react-native-webview@13.16.1(react-native@0.83.9(@babel/core@7.29.7)(@react-native/metro-config@0.85.2(@babel/core@7.29.7))(@types/react@19.2.14)(react@19.2.6))(react@19.2.6))(react-native-worklets@0.8.3(@babel/core@7.29.7)(@react-native/metro-config@0.85.2(@babel/core@7.29.7))(react-native@0.83.9(@babel/core@7.29.7)(@react-native/metro-config@0.85.2(@babel/core@7.29.7))(@types/react@19.2.14)(react@19.2.6))(react@19.2.6))(react-native@0.83.9(@babel/core@7.29.7)(@react-native/metro-config@0.85.2(@babel/core@7.29.7))(@types/react@19.2.14)(react@19.2.6))(react@19.2.6)(typescript@5.9.3) + expo: 55.0.27(@babel/core@7.29.7)(@expo/dom-webview@55.0.5)(@expo/metro-runtime@55.0.10)(expo-router@55.0.14)(react-dom@19.2.6(react@19.2.6))(react-native-webview@13.16.1(react-native@0.83.9(@babel/core@7.29.7)(@react-native/metro-config@0.85.2(@babel/core@7.29.7))(@types/react@19.2.14)(react@19.2.6))(react@19.2.6))(react-native-worklets@0.8.3(@babel/core@7.29.7)(@react-native/metro-config@0.85.2(@babel/core@7.29.7))(react-native@0.83.9(@babel/core@7.29.7)(@react-native/metro-config@0.85.2(@babel/core@7.29.7))(@types/react@19.2.14)(react@19.2.6))(react@19.2.6))(react-native@0.83.9(@babel/core@7.29.7)(@react-native/metro-config@0.85.2(@babel/core@7.29.7))(@types/react@19.2.14)(react@19.2.6))(react@19.2.6)(typescript@7.0.2) - expo-file-system@55.0.19(expo@55.0.23)(react-native@0.83.9(@babel/core@7.29.7)(@react-native/metro-config@0.85.2(@babel/core@7.29.7))(@types/react@19.2.14)(react@19.2.6)): + expo-file-system@55.0.19(expo@55.0.27)(react-native@0.83.9(@babel/core@7.29.7)(@react-native/metro-config@0.85.2(@babel/core@7.29.7))(@types/react@19.2.14)(react@19.2.6)): dependencies: - expo: 55.0.23(@babel/core@7.29.7)(@expo/dom-webview@55.0.5)(@expo/metro-runtime@55.0.10)(expo-router@55.0.14)(react-dom@19.2.6(react@19.2.6))(react-native-webview@13.16.1(react-native@0.83.9(@babel/core@7.29.7)(@react-native/metro-config@0.85.2(@babel/core@7.29.7))(@types/react@19.2.14)(react@19.2.6))(react@19.2.6))(react-native-worklets@0.8.3(@babel/core@7.29.7)(@react-native/metro-config@0.85.2(@babel/core@7.29.7))(react-native@0.83.9(@babel/core@7.29.7)(@react-native/metro-config@0.85.2(@babel/core@7.29.7))(@types/react@19.2.14)(react@19.2.6))(react@19.2.6))(react-native@0.83.9(@babel/core@7.29.7)(@react-native/metro-config@0.85.2(@babel/core@7.29.7))(@types/react@19.2.14)(react@19.2.6))(react@19.2.6)(typescript@5.9.3) + expo: 55.0.27(@babel/core@7.29.7)(@expo/dom-webview@55.0.5)(@expo/metro-runtime@55.0.10)(expo-router@55.0.14)(react-dom@19.2.6(react@19.2.6))(react-native-webview@13.16.1(react-native@0.83.9(@babel/core@7.29.7)(@react-native/metro-config@0.85.2(@babel/core@7.29.7))(@types/react@19.2.14)(react@19.2.6))(react@19.2.6))(react-native-worklets@0.8.3(@babel/core@7.29.7)(@react-native/metro-config@0.85.2(@babel/core@7.29.7))(react-native@0.83.9(@babel/core@7.29.7)(@react-native/metro-config@0.85.2(@babel/core@7.29.7))(@types/react@19.2.14)(react@19.2.6))(react@19.2.6))(react-native@0.83.9(@babel/core@7.29.7)(@react-native/metro-config@0.85.2(@babel/core@7.29.7))(@types/react@19.2.14)(react@19.2.6))(react@19.2.6)(typescript@7.0.2) react-native: 0.83.9(@babel/core@7.29.7)(@react-native/metro-config@0.85.2(@babel/core@7.29.7))(@types/react@19.2.14)(react@19.2.6) - expo-font@55.0.7(expo@55.0.23)(react-native@0.83.9(@babel/core@7.29.7)(@react-native/metro-config@0.85.2(@babel/core@7.29.7))(@types/react@19.2.14)(react@19.2.6))(react@19.2.6): + expo-file-system@55.0.23(expo@55.0.27)(react-native@0.83.9(@babel/core@7.29.7)(@react-native/metro-config@0.85.2(@babel/core@7.29.7))(@types/react@19.2.14)(react@19.2.6)): dependencies: - expo: 55.0.23(@babel/core@7.29.7)(@expo/dom-webview@55.0.5)(@expo/metro-runtime@55.0.10)(expo-router@55.0.14)(react-dom@19.2.6(react@19.2.6))(react-native-webview@13.16.1(react-native@0.83.9(@babel/core@7.29.7)(@react-native/metro-config@0.85.2(@babel/core@7.29.7))(@types/react@19.2.14)(react@19.2.6))(react@19.2.6))(react-native-worklets@0.8.3(@babel/core@7.29.7)(@react-native/metro-config@0.85.2(@babel/core@7.29.7))(react-native@0.83.9(@babel/core@7.29.7)(@react-native/metro-config@0.85.2(@babel/core@7.29.7))(@types/react@19.2.14)(react@19.2.6))(react@19.2.6))(react-native@0.83.9(@babel/core@7.29.7)(@react-native/metro-config@0.85.2(@babel/core@7.29.7))(@types/react@19.2.14)(react@19.2.6))(react@19.2.6)(typescript@5.9.3) + expo: 55.0.27(@babel/core@7.29.7)(@expo/dom-webview@55.0.5)(@expo/metro-runtime@55.0.10)(expo-router@55.0.14)(react-dom@19.2.6(react@19.2.6))(react-native-webview@13.16.1(react-native@0.83.9(@babel/core@7.29.7)(@react-native/metro-config@0.85.2(@babel/core@7.29.7))(@types/react@19.2.14)(react@19.2.6))(react@19.2.6))(react-native-worklets@0.8.3(@babel/core@7.29.7)(@react-native/metro-config@0.85.2(@babel/core@7.29.7))(react-native@0.83.9(@babel/core@7.29.7)(@react-native/metro-config@0.85.2(@babel/core@7.29.7))(@types/react@19.2.14)(react@19.2.6))(react@19.2.6))(react-native@0.83.9(@babel/core@7.29.7)(@react-native/metro-config@0.85.2(@babel/core@7.29.7))(@types/react@19.2.14)(react@19.2.6))(react@19.2.6)(typescript@7.0.2) + react-native: 0.83.9(@babel/core@7.29.7)(@react-native/metro-config@0.85.2(@babel/core@7.29.7))(@types/react@19.2.14)(react@19.2.6) + + expo-font@55.0.8(expo@55.0.27)(react-native@0.83.9(@babel/core@7.29.7)(@react-native/metro-config@0.85.2(@babel/core@7.29.7))(@types/react@19.2.14)(react@19.2.6))(react@19.2.6): + dependencies: + expo: 55.0.27(@babel/core@7.29.7)(@expo/dom-webview@55.0.5)(@expo/metro-runtime@55.0.10)(expo-router@55.0.14)(react-dom@19.2.6(react@19.2.6))(react-native-webview@13.16.1(react-native@0.83.9(@babel/core@7.29.7)(@react-native/metro-config@0.85.2(@babel/core@7.29.7))(@types/react@19.2.14)(react@19.2.6))(react@19.2.6))(react-native-worklets@0.8.3(@babel/core@7.29.7)(@react-native/metro-config@0.85.2(@babel/core@7.29.7))(react-native@0.83.9(@babel/core@7.29.7)(@react-native/metro-config@0.85.2(@babel/core@7.29.7))(@types/react@19.2.14)(react@19.2.6))(react@19.2.6))(react-native@0.83.9(@babel/core@7.29.7)(@react-native/metro-config@0.85.2(@babel/core@7.29.7))(@types/react@19.2.14)(react@19.2.6))(react@19.2.6)(typescript@7.0.2) fontfaceobserver: 2.3.0 react: 19.2.6 react-native: 0.83.9(@babel/core@7.29.7)(@react-native/metro-config@0.85.2(@babel/core@7.29.7))(@types/react@19.2.14)(react@19.2.6) - expo-glass-effect@55.0.11(expo@55.0.23)(react-native@0.83.9(@babel/core@7.29.7)(@react-native/metro-config@0.85.2(@babel/core@7.29.7))(@types/react@19.2.14)(react@19.2.6))(react@19.2.6): + expo-glass-effect@55.0.11(expo@55.0.27)(react-native@0.83.9(@babel/core@7.29.7)(@react-native/metro-config@0.85.2(@babel/core@7.29.7))(@types/react@19.2.14)(react@19.2.6))(react@19.2.6): dependencies: - expo: 55.0.23(@babel/core@7.29.7)(@expo/dom-webview@55.0.5)(@expo/metro-runtime@55.0.10)(expo-router@55.0.14)(react-dom@19.2.6(react@19.2.6))(react-native-webview@13.16.1(react-native@0.83.9(@babel/core@7.29.7)(@react-native/metro-config@0.85.2(@babel/core@7.29.7))(@types/react@19.2.14)(react@19.2.6))(react@19.2.6))(react-native-worklets@0.8.3(@babel/core@7.29.7)(@react-native/metro-config@0.85.2(@babel/core@7.29.7))(react-native@0.83.9(@babel/core@7.29.7)(@react-native/metro-config@0.85.2(@babel/core@7.29.7))(@types/react@19.2.14)(react@19.2.6))(react@19.2.6))(react-native@0.83.9(@babel/core@7.29.7)(@react-native/metro-config@0.85.2(@babel/core@7.29.7))(@types/react@19.2.14)(react@19.2.6))(react@19.2.6)(typescript@5.9.3) + expo: 55.0.27(@babel/core@7.29.7)(@expo/dom-webview@55.0.5)(@expo/metro-runtime@55.0.10)(expo-router@55.0.14)(react-dom@19.2.6(react@19.2.6))(react-native-webview@13.16.1(react-native@0.83.9(@babel/core@7.29.7)(@react-native/metro-config@0.85.2(@babel/core@7.29.7))(@types/react@19.2.14)(react@19.2.6))(react@19.2.6))(react-native-worklets@0.8.3(@babel/core@7.29.7)(@react-native/metro-config@0.85.2(@babel/core@7.29.7))(react-native@0.83.9(@babel/core@7.29.7)(@react-native/metro-config@0.85.2(@babel/core@7.29.7))(@types/react@19.2.14)(react@19.2.6))(react@19.2.6))(react-native@0.83.9(@babel/core@7.29.7)(@react-native/metro-config@0.85.2(@babel/core@7.29.7))(@types/react@19.2.14)(react@19.2.6))(react@19.2.6)(typescript@7.0.2) react: 19.2.6 react-native: 0.83.9(@babel/core@7.29.7)(@react-native/metro-config@0.85.2(@babel/core@7.29.7))(@types/react@19.2.14)(react@19.2.6) - expo-haptics@55.0.14(expo@55.0.23): + expo-haptics@55.0.14(expo@55.0.27): dependencies: - expo: 55.0.23(@babel/core@7.29.7)(@expo/dom-webview@55.0.5)(@expo/metro-runtime@55.0.10)(expo-router@55.0.14)(react-dom@19.2.6(react@19.2.6))(react-native-webview@13.16.1(react-native@0.83.9(@babel/core@7.29.7)(@react-native/metro-config@0.85.2(@babel/core@7.29.7))(@types/react@19.2.14)(react@19.2.6))(react@19.2.6))(react-native-worklets@0.8.3(@babel/core@7.29.7)(@react-native/metro-config@0.85.2(@babel/core@7.29.7))(react-native@0.83.9(@babel/core@7.29.7)(@react-native/metro-config@0.85.2(@babel/core@7.29.7))(@types/react@19.2.14)(react@19.2.6))(react@19.2.6))(react-native@0.83.9(@babel/core@7.29.7)(@react-native/metro-config@0.85.2(@babel/core@7.29.7))(@types/react@19.2.14)(react@19.2.6))(react@19.2.6)(typescript@5.9.3) + expo: 55.0.27(@babel/core@7.29.7)(@expo/dom-webview@55.0.5)(@expo/metro-runtime@55.0.10)(expo-router@55.0.14)(react-dom@19.2.6(react@19.2.6))(react-native-webview@13.16.1(react-native@0.83.9(@babel/core@7.29.7)(@react-native/metro-config@0.85.2(@babel/core@7.29.7))(@types/react@19.2.14)(react@19.2.6))(react@19.2.6))(react-native-worklets@0.8.3(@babel/core@7.29.7)(@react-native/metro-config@0.85.2(@babel/core@7.29.7))(react-native@0.83.9(@babel/core@7.29.7)(@react-native/metro-config@0.85.2(@babel/core@7.29.7))(@types/react@19.2.14)(react@19.2.6))(react@19.2.6))(react-native@0.83.9(@babel/core@7.29.7)(@react-native/metro-config@0.85.2(@babel/core@7.29.7))(@types/react@19.2.14)(react@19.2.6))(react@19.2.6)(typescript@7.0.2) - expo-image-loader@55.0.1(expo@55.0.23): + expo-image-loader@55.0.1(expo@55.0.27): dependencies: - expo: 55.0.23(@babel/core@7.29.7)(@expo/dom-webview@55.0.5)(@expo/metro-runtime@55.0.10)(expo-router@55.0.14)(react-dom@19.2.6(react@19.2.6))(react-native-webview@13.16.1(react-native@0.83.9(@babel/core@7.29.7)(@react-native/metro-config@0.85.2(@babel/core@7.29.7))(@types/react@19.2.14)(react@19.2.6))(react@19.2.6))(react-native-worklets@0.8.3(@babel/core@7.29.7)(@react-native/metro-config@0.85.2(@babel/core@7.29.7))(react-native@0.83.9(@babel/core@7.29.7)(@react-native/metro-config@0.85.2(@babel/core@7.29.7))(@types/react@19.2.14)(react@19.2.6))(react@19.2.6))(react-native@0.83.9(@babel/core@7.29.7)(@react-native/metro-config@0.85.2(@babel/core@7.29.7))(@types/react@19.2.14)(react@19.2.6))(react@19.2.6)(typescript@5.9.3) + expo: 55.0.27(@babel/core@7.29.7)(@expo/dom-webview@55.0.5)(@expo/metro-runtime@55.0.10)(expo-router@55.0.14)(react-dom@19.2.6(react@19.2.6))(react-native-webview@13.16.1(react-native@0.83.9(@babel/core@7.29.7)(@react-native/metro-config@0.85.2(@babel/core@7.29.7))(@types/react@19.2.14)(react@19.2.6))(react@19.2.6))(react-native-worklets@0.8.3(@babel/core@7.29.7)(@react-native/metro-config@0.85.2(@babel/core@7.29.7))(react-native@0.83.9(@babel/core@7.29.7)(@react-native/metro-config@0.85.2(@babel/core@7.29.7))(@types/react@19.2.14)(react@19.2.6))(react@19.2.6))(react-native@0.83.9(@babel/core@7.29.7)(@react-native/metro-config@0.85.2(@babel/core@7.29.7))(@types/react@19.2.14)(react@19.2.6))(react@19.2.6)(typescript@7.0.2) - expo-image-manipulator@55.0.17(expo@55.0.23): + expo-image-manipulator@55.0.17(expo@55.0.27): dependencies: - expo: 55.0.23(@babel/core@7.29.7)(@expo/dom-webview@55.0.5)(@expo/metro-runtime@55.0.10)(expo-router@55.0.14)(react-dom@19.2.6(react@19.2.6))(react-native-webview@13.16.1(react-native@0.83.9(@babel/core@7.29.7)(@react-native/metro-config@0.85.2(@babel/core@7.29.7))(@types/react@19.2.14)(react@19.2.6))(react@19.2.6))(react-native-worklets@0.8.3(@babel/core@7.29.7)(@react-native/metro-config@0.85.2(@babel/core@7.29.7))(react-native@0.83.9(@babel/core@7.29.7)(@react-native/metro-config@0.85.2(@babel/core@7.29.7))(@types/react@19.2.14)(react@19.2.6))(react@19.2.6))(react-native@0.83.9(@babel/core@7.29.7)(@react-native/metro-config@0.85.2(@babel/core@7.29.7))(@types/react@19.2.14)(react@19.2.6))(react@19.2.6)(typescript@5.9.3) - expo-image-loader: 55.0.1(expo@55.0.23) + expo: 55.0.27(@babel/core@7.29.7)(@expo/dom-webview@55.0.5)(@expo/metro-runtime@55.0.10)(expo-router@55.0.14)(react-dom@19.2.6(react@19.2.6))(react-native-webview@13.16.1(react-native@0.83.9(@babel/core@7.29.7)(@react-native/metro-config@0.85.2(@babel/core@7.29.7))(@types/react@19.2.14)(react@19.2.6))(react@19.2.6))(react-native-worklets@0.8.3(@babel/core@7.29.7)(@react-native/metro-config@0.85.2(@babel/core@7.29.7))(react-native@0.83.9(@babel/core@7.29.7)(@react-native/metro-config@0.85.2(@babel/core@7.29.7))(@types/react@19.2.14)(react@19.2.6))(react@19.2.6))(react-native@0.83.9(@babel/core@7.29.7)(@react-native/metro-config@0.85.2(@babel/core@7.29.7))(@types/react@19.2.14)(react@19.2.6))(react@19.2.6)(typescript@7.0.2) + expo-image-loader: 55.0.1(expo@55.0.27) - expo-image-picker@55.0.20(expo@55.0.23): + expo-image-picker@55.0.20(expo@55.0.27): dependencies: - expo: 55.0.23(@babel/core@7.29.7)(@expo/dom-webview@55.0.5)(@expo/metro-runtime@55.0.10)(expo-router@55.0.14)(react-dom@19.2.6(react@19.2.6))(react-native-webview@13.16.1(react-native@0.83.9(@babel/core@7.29.7)(@react-native/metro-config@0.85.2(@babel/core@7.29.7))(@types/react@19.2.14)(react@19.2.6))(react@19.2.6))(react-native-worklets@0.8.3(@babel/core@7.29.7)(@react-native/metro-config@0.85.2(@babel/core@7.29.7))(react-native@0.83.9(@babel/core@7.29.7)(@react-native/metro-config@0.85.2(@babel/core@7.29.7))(@types/react@19.2.14)(react@19.2.6))(react@19.2.6))(react-native@0.83.9(@babel/core@7.29.7)(@react-native/metro-config@0.85.2(@babel/core@7.29.7))(@types/react@19.2.14)(react@19.2.6))(react@19.2.6)(typescript@5.9.3) - expo-image-loader: 55.0.1(expo@55.0.23) + expo: 55.0.27(@babel/core@7.29.7)(@expo/dom-webview@55.0.5)(@expo/metro-runtime@55.0.10)(expo-router@55.0.14)(react-dom@19.2.6(react@19.2.6))(react-native-webview@13.16.1(react-native@0.83.9(@babel/core@7.29.7)(@react-native/metro-config@0.85.2(@babel/core@7.29.7))(@types/react@19.2.14)(react@19.2.6))(react@19.2.6))(react-native-worklets@0.8.3(@babel/core@7.29.7)(@react-native/metro-config@0.85.2(@babel/core@7.29.7))(react-native@0.83.9(@babel/core@7.29.7)(@react-native/metro-config@0.85.2(@babel/core@7.29.7))(@types/react@19.2.14)(react@19.2.6))(react@19.2.6))(react-native@0.83.9(@babel/core@7.29.7)(@react-native/metro-config@0.85.2(@babel/core@7.29.7))(@types/react@19.2.14)(react@19.2.6))(react@19.2.6)(typescript@7.0.2) + expo-image-loader: 55.0.1(expo@55.0.27) - expo-image@55.0.10(expo@55.0.23)(react-native-web@0.21.2(react-dom@19.2.6(react@19.2.6))(react@19.2.6))(react-native@0.83.9(@babel/core@7.29.7)(@react-native/metro-config@0.85.2(@babel/core@7.29.7))(@types/react@19.2.14)(react@19.2.6))(react@19.2.6): + expo-image@55.0.10(expo@55.0.27)(react-native-web@0.21.2(react-dom@19.2.6(react@19.2.6))(react@19.2.6))(react-native@0.83.9(@babel/core@7.29.7)(@react-native/metro-config@0.85.2(@babel/core@7.29.7))(@types/react@19.2.14)(react@19.2.6))(react@19.2.6): dependencies: - expo: 55.0.23(@babel/core@7.29.7)(@expo/dom-webview@55.0.5)(@expo/metro-runtime@55.0.10)(expo-router@55.0.14)(react-dom@19.2.6(react@19.2.6))(react-native-webview@13.16.1(react-native@0.83.9(@babel/core@7.29.7)(@react-native/metro-config@0.85.2(@babel/core@7.29.7))(@types/react@19.2.14)(react@19.2.6))(react@19.2.6))(react-native-worklets@0.8.3(@babel/core@7.29.7)(@react-native/metro-config@0.85.2(@babel/core@7.29.7))(react-native@0.83.9(@babel/core@7.29.7)(@react-native/metro-config@0.85.2(@babel/core@7.29.7))(@types/react@19.2.14)(react@19.2.6))(react@19.2.6))(react-native@0.83.9(@babel/core@7.29.7)(@react-native/metro-config@0.85.2(@babel/core@7.29.7))(@types/react@19.2.14)(react@19.2.6))(react@19.2.6)(typescript@5.9.3) + expo: 55.0.27(@babel/core@7.29.7)(@expo/dom-webview@55.0.5)(@expo/metro-runtime@55.0.10)(expo-router@55.0.14)(react-dom@19.2.6(react@19.2.6))(react-native-webview@13.16.1(react-native@0.83.9(@babel/core@7.29.7)(@react-native/metro-config@0.85.2(@babel/core@7.29.7))(@types/react@19.2.14)(react@19.2.6))(react@19.2.6))(react-native-worklets@0.8.3(@babel/core@7.29.7)(@react-native/metro-config@0.85.2(@babel/core@7.29.7))(react-native@0.83.9(@babel/core@7.29.7)(@react-native/metro-config@0.85.2(@babel/core@7.29.7))(@types/react@19.2.14)(react@19.2.6))(react@19.2.6))(react-native@0.83.9(@babel/core@7.29.7)(@react-native/metro-config@0.85.2(@babel/core@7.29.7))(@types/react@19.2.14)(react@19.2.6))(react@19.2.6)(typescript@7.0.2) react: 19.2.6 react-native: 0.83.9(@babel/core@7.29.7)(@react-native/metro-config@0.85.2(@babel/core@7.29.7))(@types/react@19.2.14)(react@19.2.6) sf-symbols-typescript: 2.2.0 @@ -10938,14 +11333,14 @@ snapshots: expo-json-utils@55.0.2: {} - expo-keep-awake@55.0.8(expo@55.0.23)(react@19.2.6): + expo-keep-awake@55.0.8(expo@55.0.27)(react@19.2.6): dependencies: - expo: 55.0.23(@babel/core@7.29.7)(@expo/dom-webview@55.0.5)(@expo/metro-runtime@55.0.10)(expo-router@55.0.14)(react-dom@19.2.6(react@19.2.6))(react-native-webview@13.16.1(react-native@0.83.9(@babel/core@7.29.7)(@react-native/metro-config@0.85.2(@babel/core@7.29.7))(@types/react@19.2.14)(react@19.2.6))(react@19.2.6))(react-native-worklets@0.8.3(@babel/core@7.29.7)(@react-native/metro-config@0.85.2(@babel/core@7.29.7))(react-native@0.83.9(@babel/core@7.29.7)(@react-native/metro-config@0.85.2(@babel/core@7.29.7))(@types/react@19.2.14)(react@19.2.6))(react@19.2.6))(react-native@0.83.9(@babel/core@7.29.7)(@react-native/metro-config@0.85.2(@babel/core@7.29.7))(@types/react@19.2.14)(react@19.2.6))(react@19.2.6)(typescript@5.9.3) + expo: 55.0.27(@babel/core@7.29.7)(@expo/dom-webview@55.0.5)(@expo/metro-runtime@55.0.10)(expo-router@55.0.14)(react-dom@19.2.6(react@19.2.6))(react-native-webview@13.16.1(react-native@0.83.9(@babel/core@7.29.7)(@react-native/metro-config@0.85.2(@babel/core@7.29.7))(@types/react@19.2.14)(react@19.2.6))(react@19.2.6))(react-native-worklets@0.8.3(@babel/core@7.29.7)(@react-native/metro-config@0.85.2(@babel/core@7.29.7))(react-native@0.83.9(@babel/core@7.29.7)(@react-native/metro-config@0.85.2(@babel/core@7.29.7))(@types/react@19.2.14)(react@19.2.6))(react@19.2.6))(react-native@0.83.9(@babel/core@7.29.7)(@react-native/metro-config@0.85.2(@babel/core@7.29.7))(@types/react@19.2.14)(react@19.2.6))(react@19.2.6)(typescript@7.0.2) react: 19.2.6 - expo-linking@55.0.15(expo@55.0.23)(react-native@0.83.9(@babel/core@7.29.7)(@react-native/metro-config@0.85.2(@babel/core@7.29.7))(@types/react@19.2.14)(react@19.2.6))(react@19.2.6): + expo-linking@55.0.15(expo@55.0.27)(react-native@0.83.9(@babel/core@7.29.7)(@react-native/metro-config@0.85.2(@babel/core@7.29.7))(@types/react@19.2.14)(react@19.2.6))(react@19.2.6): dependencies: - expo-constants: 55.0.16(expo@55.0.23)(react-native@0.83.9(@babel/core@7.29.7)(@react-native/metro-config@0.85.2(@babel/core@7.29.7))(@types/react@19.2.14)(react@19.2.6)) + expo-constants: 55.0.16(expo@55.0.27)(react-native@0.83.9(@babel/core@7.29.7)(@react-native/metro-config@0.85.2(@babel/core@7.29.7))(@types/react@19.2.14)(react@19.2.6)) invariant: 2.2.4 react: 19.2.6 react-native: 0.83.9(@babel/core@7.29.7)(@react-native/metro-config@0.85.2(@babel/core@7.29.7))(@types/react@19.2.14)(react@19.2.6) @@ -10953,12 +11348,12 @@ snapshots: - expo - supports-color - expo-manifests@55.0.17(expo@55.0.23): + expo-manifests@55.0.17(expo@55.0.27): dependencies: - expo: 55.0.23(@babel/core@7.29.7)(@expo/dom-webview@55.0.5)(@expo/metro-runtime@55.0.10)(expo-router@55.0.14)(react-dom@19.2.6(react@19.2.6))(react-native-webview@13.16.1(react-native@0.83.9(@babel/core@7.29.7)(@react-native/metro-config@0.85.2(@babel/core@7.29.7))(@types/react@19.2.14)(react@19.2.6))(react@19.2.6))(react-native-worklets@0.8.3(@babel/core@7.29.7)(@react-native/metro-config@0.85.2(@babel/core@7.29.7))(react-native@0.83.9(@babel/core@7.29.7)(@react-native/metro-config@0.85.2(@babel/core@7.29.7))(@types/react@19.2.14)(react@19.2.6))(react@19.2.6))(react-native@0.83.9(@babel/core@7.29.7)(@react-native/metro-config@0.85.2(@babel/core@7.29.7))(@types/react@19.2.14)(react@19.2.6))(react@19.2.6)(typescript@5.9.3) + expo: 55.0.27(@babel/core@7.29.7)(@expo/dom-webview@55.0.5)(@expo/metro-runtime@55.0.10)(expo-router@55.0.14)(react-dom@19.2.6(react@19.2.6))(react-native-webview@13.16.1(react-native@0.83.9(@babel/core@7.29.7)(@react-native/metro-config@0.85.2(@babel/core@7.29.7))(@types/react@19.2.14)(react@19.2.6))(react@19.2.6))(react-native-worklets@0.8.3(@babel/core@7.29.7)(@react-native/metro-config@0.85.2(@babel/core@7.29.7))(react-native@0.83.9(@babel/core@7.29.7)(@react-native/metro-config@0.85.2(@babel/core@7.29.7))(@types/react@19.2.14)(react@19.2.6))(react@19.2.6))(react-native@0.83.9(@babel/core@7.29.7)(@react-native/metro-config@0.85.2(@babel/core@7.29.7))(@types/react@19.2.14)(react@19.2.6))(react@19.2.6)(typescript@7.0.2) expo-json-utils: 55.0.2 - expo-module-scripts@55.0.2(@babel/core@7.29.7)(@babel/runtime@7.29.2)(@jest/types@29.6.3)(babel-jest@29.7.0(@babel/core@7.29.7))(esbuild@0.25.4)(eslint@9.39.4)(expo@55.0.23)(jest@29.7.0(@types/node@25.9.3))(prettier@2.8.8)(react-native@0.83.9(@babel/core@7.29.7)(@react-native/metro-config@0.85.2(@babel/core@7.29.7))(@types/react@19.2.14)(react@19.2.6))(react-refresh@0.14.2)(react-test-renderer@19.2.6(react@19.2.6))(react@19.2.6): + expo-module-scripts@55.0.2(@babel/core@7.29.7)(@babel/runtime@7.29.7)(@jest/types@29.6.3)(babel-jest@29.7.0(@babel/core@7.29.7))(esbuild@0.25.4)(eslint@9.39.4)(expo@55.0.27)(jest@29.7.0(@types/node@26.1.1))(prettier@2.8.8)(react-native@0.83.9(@babel/core@7.29.7)(@react-native/metro-config@0.85.2(@babel/core@7.29.7))(@types/react@19.2.14)(react@19.2.6))(react-refresh@0.14.2)(react-test-renderer@19.2.6(react@19.2.6))(react@19.2.6): dependencies: '@babel/cli': 7.28.6(@babel/core@7.29.7) '@babel/plugin-transform-export-namespace-from': 7.27.1(@babel/core@7.29.7) @@ -10966,19 +11361,19 @@ snapshots: '@babel/preset-typescript': 7.28.5(@babel/core@7.29.7) '@expo/npm-proofread': 1.0.1 '@expo/spawn-async': 1.7.2 - '@testing-library/react-native': 13.3.3(jest@29.7.0(@types/node@25.9.3))(react-native@0.83.9(@babel/core@7.29.7)(@react-native/metro-config@0.85.2(@babel/core@7.29.7))(@types/react@19.2.14)(react@19.2.6))(react-test-renderer@19.2.6(react@19.2.6))(react@19.2.6) + '@testing-library/react-native': 13.3.3(jest@29.7.0(@types/node@26.1.1))(react-native@0.83.9(@babel/core@7.29.7)(@react-native/metro-config@0.85.2(@babel/core@7.29.7))(@types/react@19.2.14)(react@19.2.6))(react-test-renderer@19.2.6(react@19.2.6))(react@19.2.6) '@tsconfig/node18': 18.2.6 '@types/jest': 29.5.14 babel-plugin-dynamic-import-node: 2.3.3 - babel-preset-expo: 55.0.21(@babel/core@7.29.7)(@babel/runtime@7.29.2)(expo@55.0.23)(react-refresh@0.14.2) + babel-preset-expo: 55.0.21(@babel/core@7.29.7)(@babel/runtime@7.29.7)(expo@55.0.27)(react-refresh@0.14.2) commander: 12.1.0 eslint-config-universe: 15.0.4(eslint@9.39.4)(prettier@2.8.8)(typescript@5.9.3) glob: 13.0.6 - jest-expo: 55.0.17(@babel/core@7.29.7)(expo@55.0.23)(jest@29.7.0(@types/node@25.9.3))(react-native@0.83.9(@babel/core@7.29.7)(@react-native/metro-config@0.85.2(@babel/core@7.29.7))(@types/react@19.2.14)(react@19.2.6))(react@19.2.6)(typescript@5.9.3) + jest-expo: 55.0.17(@babel/core@7.29.7)(expo@55.0.27)(jest@29.7.0(@types/node@26.1.1))(react-native@0.83.9(@babel/core@7.29.7)(@react-native/metro-config@0.85.2(@babel/core@7.29.7))(@types/react@19.2.14)(react@19.2.6))(react@19.2.6)(typescript@5.9.3) jest-snapshot-prettier: prettier@2.8.8 - jest-watch-typeahead: 2.2.1(jest@29.7.0(@types/node@25.9.3)) + jest-watch-typeahead: 2.2.1(jest@29.7.0(@types/node@26.1.1)) resolve-workspace-root: 2.0.1 - ts-jest: 29.0.5(@babel/core@7.29.7)(@jest/types@29.6.3)(babel-jest@29.7.0(@babel/core@7.29.7))(esbuild@0.25.4)(jest@29.7.0(@types/node@25.9.3))(typescript@5.9.3) + ts-jest: 29.0.5(@babel/core@7.29.7)(@jest/types@29.6.3)(babel-jest@29.7.0(@babel/core@7.29.7))(esbuild@0.25.4)(jest@29.7.0(@types/node@26.1.1))(typescript@5.9.3) typescript: 5.9.3 transitivePeerDependencies: - '@babel/core' @@ -11004,9 +11399,9 @@ snapshots: - supports-color - utf-8-validate - expo-modules-autolinking@55.0.21(typescript@5.9.3): + expo-modules-autolinking@55.0.24(typescript@7.0.2): dependencies: - '@expo/require-utils': 55.0.5(typescript@5.9.3) + '@expo/require-utils': 55.0.5(typescript@7.0.2) '@expo/spawn-async': 1.7.2 chalk: 4.1.2 commander: 7.2.0 @@ -11022,29 +11417,29 @@ snapshots: optionalDependencies: react-native-worklets: 0.8.3(@babel/core@7.29.7)(@react-native/metro-config@0.85.2(@babel/core@7.29.7))(react-native@0.83.9(@babel/core@7.29.7)(@react-native/metro-config@0.85.2(@babel/core@7.29.7))(@types/react@19.2.14)(react@19.2.6))(react@19.2.6) - expo-network@55.0.14(expo@55.0.23)(react@19.2.6): + expo-network@55.0.14(expo@55.0.27)(react@19.2.6): dependencies: - expo: 55.0.23(@babel/core@7.29.7)(@expo/dom-webview@55.0.5)(@expo/metro-runtime@55.0.10)(expo-router@55.0.14)(react-dom@19.2.6(react@19.2.6))(react-native-webview@13.16.1(react-native@0.83.9(@babel/core@7.29.7)(@react-native/metro-config@0.85.2(@babel/core@7.29.7))(@types/react@19.2.14)(react@19.2.6))(react@19.2.6))(react-native-worklets@0.8.3(@babel/core@7.29.7)(@react-native/metro-config@0.85.2(@babel/core@7.29.7))(react-native@0.83.9(@babel/core@7.29.7)(@react-native/metro-config@0.85.2(@babel/core@7.29.7))(@types/react@19.2.14)(react@19.2.6))(react@19.2.6))(react-native@0.83.9(@babel/core@7.29.7)(@react-native/metro-config@0.85.2(@babel/core@7.29.7))(@types/react@19.2.14)(react@19.2.6))(react@19.2.6)(typescript@5.9.3) + expo: 55.0.27(@babel/core@7.29.7)(@expo/dom-webview@55.0.5)(@expo/metro-runtime@55.0.10)(expo-router@55.0.14)(react-dom@19.2.6(react@19.2.6))(react-native-webview@13.16.1(react-native@0.83.9(@babel/core@7.29.7)(@react-native/metro-config@0.85.2(@babel/core@7.29.7))(@types/react@19.2.14)(react@19.2.6))(react@19.2.6))(react-native-worklets@0.8.3(@babel/core@7.29.7)(@react-native/metro-config@0.85.2(@babel/core@7.29.7))(react-native@0.83.9(@babel/core@7.29.7)(@react-native/metro-config@0.85.2(@babel/core@7.29.7))(@types/react@19.2.14)(react@19.2.6))(react@19.2.6))(react-native@0.83.9(@babel/core@7.29.7)(@react-native/metro-config@0.85.2(@babel/core@7.29.7))(@types/react@19.2.14)(react@19.2.6))(react@19.2.6)(typescript@7.0.2) react: 19.2.6 - expo-notifications@55.0.22(expo@55.0.23)(react-native@0.83.9(@babel/core@7.29.7)(@react-native/metro-config@0.85.2(@babel/core@7.29.7))(@types/react@19.2.14)(react@19.2.6))(react@19.2.6)(typescript@5.9.3): + expo-notifications@55.0.22(expo@55.0.27)(react-native@0.83.9(@babel/core@7.29.7)(@react-native/metro-config@0.85.2(@babel/core@7.29.7))(@types/react@19.2.14)(react@19.2.6))(react@19.2.6)(typescript@7.0.2): dependencies: - '@expo/image-utils': 0.8.14(typescript@5.9.3) + '@expo/image-utils': 0.8.14(typescript@7.0.2) abort-controller: 3.0.0 badgin: 1.2.3 - expo: 55.0.23(@babel/core@7.29.7)(@expo/dom-webview@55.0.5)(@expo/metro-runtime@55.0.10)(expo-router@55.0.14)(react-dom@19.2.6(react@19.2.6))(react-native-webview@13.16.1(react-native@0.83.9(@babel/core@7.29.7)(@react-native/metro-config@0.85.2(@babel/core@7.29.7))(@types/react@19.2.14)(react@19.2.6))(react@19.2.6))(react-native-worklets@0.8.3(@babel/core@7.29.7)(@react-native/metro-config@0.85.2(@babel/core@7.29.7))(react-native@0.83.9(@babel/core@7.29.7)(@react-native/metro-config@0.85.2(@babel/core@7.29.7))(@types/react@19.2.14)(react@19.2.6))(react@19.2.6))(react-native@0.83.9(@babel/core@7.29.7)(@react-native/metro-config@0.85.2(@babel/core@7.29.7))(@types/react@19.2.14)(react@19.2.6))(react@19.2.6)(typescript@5.9.3) - expo-application: 55.0.14(expo@55.0.23) - expo-constants: 55.0.16(expo@55.0.23)(react-native@0.83.9(@babel/core@7.29.7)(@react-native/metro-config@0.85.2(@babel/core@7.29.7))(@types/react@19.2.14)(react@19.2.6)) + expo: 55.0.27(@babel/core@7.29.7)(@expo/dom-webview@55.0.5)(@expo/metro-runtime@55.0.10)(expo-router@55.0.14)(react-dom@19.2.6(react@19.2.6))(react-native-webview@13.16.1(react-native@0.83.9(@babel/core@7.29.7)(@react-native/metro-config@0.85.2(@babel/core@7.29.7))(@types/react@19.2.14)(react@19.2.6))(react@19.2.6))(react-native-worklets@0.8.3(@babel/core@7.29.7)(@react-native/metro-config@0.85.2(@babel/core@7.29.7))(react-native@0.83.9(@babel/core@7.29.7)(@react-native/metro-config@0.85.2(@babel/core@7.29.7))(@types/react@19.2.14)(react@19.2.6))(react@19.2.6))(react-native@0.83.9(@babel/core@7.29.7)(@react-native/metro-config@0.85.2(@babel/core@7.29.7))(@types/react@19.2.14)(react@19.2.6))(react@19.2.6)(typescript@7.0.2) + expo-application: 55.0.14(expo@55.0.27) + expo-constants: 55.0.16(expo@55.0.27)(react-native@0.83.9(@babel/core@7.29.7)(@react-native/metro-config@0.85.2(@babel/core@7.29.7))(@types/react@19.2.14)(react@19.2.6)) react: 19.2.6 react-native: 0.83.9(@babel/core@7.29.7)(@react-native/metro-config@0.85.2(@babel/core@7.29.7))(@types/react@19.2.14)(react@19.2.6) transitivePeerDependencies: - supports-color - typescript - expo-router@55.0.14(e0a8113e3689d84edb7af23d00a72ffa): + expo-router@55.0.14(7ffd97bbc113b875771c40f21f44415d): dependencies: - '@expo/log-box': 55.0.12(@expo/dom-webview@55.0.5)(expo@55.0.23)(react-native@0.83.9(@babel/core@7.29.7)(@react-native/metro-config@0.85.2(@babel/core@7.29.7))(@types/react@19.2.14)(react@19.2.6))(react@19.2.6) - '@expo/metro-runtime': 55.0.10(@expo/dom-webview@55.0.5)(expo@55.0.23)(react-dom@19.2.6(react@19.2.6))(react-native@0.83.9(@babel/core@7.29.7)(@react-native/metro-config@0.85.2(@babel/core@7.29.7))(@types/react@19.2.14)(react@19.2.6))(react@19.2.6) + '@expo/log-box': 55.0.12(@expo/dom-webview@55.0.5)(expo@55.0.27)(react-native@0.83.9(@babel/core@7.29.7)(@react-native/metro-config@0.85.2(@babel/core@7.29.7))(@types/react@19.2.14)(react@19.2.6))(react@19.2.6) + '@expo/metro-runtime': 55.0.10(@expo/dom-webview@55.0.5)(expo@55.0.27)(react-dom@19.2.6(react@19.2.6))(react-native@0.83.9(@babel/core@7.29.7)(@react-native/metro-config@0.85.2(@babel/core@7.29.7))(@types/react@19.2.14)(react@19.2.6))(react@19.2.6) '@expo/schema-utils': 55.0.4 '@radix-ui/react-slot': 1.2.4(@types/react@19.2.14)(react@19.2.6) '@radix-ui/react-tabs': 1.1.13(@types/react@19.2.14)(react-dom@19.2.6(react@19.2.6))(react@19.2.6) @@ -11054,13 +11449,13 @@ snapshots: client-only: 0.0.1 debug: 4.4.3 escape-string-regexp: 4.0.0 - expo: 55.0.23(@babel/core@7.29.7)(@expo/dom-webview@55.0.5)(@expo/metro-runtime@55.0.10)(expo-router@55.0.14)(react-dom@19.2.6(react@19.2.6))(react-native-webview@13.16.1(react-native@0.83.9(@babel/core@7.29.7)(@react-native/metro-config@0.85.2(@babel/core@7.29.7))(@types/react@19.2.14)(react@19.2.6))(react@19.2.6))(react-native-worklets@0.8.3(@babel/core@7.29.7)(@react-native/metro-config@0.85.2(@babel/core@7.29.7))(react-native@0.83.9(@babel/core@7.29.7)(@react-native/metro-config@0.85.2(@babel/core@7.29.7))(@types/react@19.2.14)(react@19.2.6))(react@19.2.6))(react-native@0.83.9(@babel/core@7.29.7)(@react-native/metro-config@0.85.2(@babel/core@7.29.7))(@types/react@19.2.14)(react@19.2.6))(react@19.2.6)(typescript@5.9.3) - expo-constants: 55.0.16(expo@55.0.23)(react-native@0.83.9(@babel/core@7.29.7)(@react-native/metro-config@0.85.2(@babel/core@7.29.7))(@types/react@19.2.14)(react@19.2.6)) - expo-glass-effect: 55.0.11(expo@55.0.23)(react-native@0.83.9(@babel/core@7.29.7)(@react-native/metro-config@0.85.2(@babel/core@7.29.7))(@types/react@19.2.14)(react@19.2.6))(react@19.2.6) - expo-image: 55.0.10(expo@55.0.23)(react-native-web@0.21.2(react-dom@19.2.6(react@19.2.6))(react@19.2.6))(react-native@0.83.9(@babel/core@7.29.7)(@react-native/metro-config@0.85.2(@babel/core@7.29.7))(@types/react@19.2.14)(react@19.2.6))(react@19.2.6) - expo-linking: 55.0.15(expo@55.0.23)(react-native@0.83.9(@babel/core@7.29.7)(@react-native/metro-config@0.85.2(@babel/core@7.29.7))(@types/react@19.2.14)(react@19.2.6))(react@19.2.6) + expo: 55.0.27(@babel/core@7.29.7)(@expo/dom-webview@55.0.5)(@expo/metro-runtime@55.0.10)(expo-router@55.0.14)(react-dom@19.2.6(react@19.2.6))(react-native-webview@13.16.1(react-native@0.83.9(@babel/core@7.29.7)(@react-native/metro-config@0.85.2(@babel/core@7.29.7))(@types/react@19.2.14)(react@19.2.6))(react@19.2.6))(react-native-worklets@0.8.3(@babel/core@7.29.7)(@react-native/metro-config@0.85.2(@babel/core@7.29.7))(react-native@0.83.9(@babel/core@7.29.7)(@react-native/metro-config@0.85.2(@babel/core@7.29.7))(@types/react@19.2.14)(react@19.2.6))(react@19.2.6))(react-native@0.83.9(@babel/core@7.29.7)(@react-native/metro-config@0.85.2(@babel/core@7.29.7))(@types/react@19.2.14)(react@19.2.6))(react@19.2.6)(typescript@7.0.2) + expo-constants: 55.0.16(expo@55.0.27)(react-native@0.83.9(@babel/core@7.29.7)(@react-native/metro-config@0.85.2(@babel/core@7.29.7))(@types/react@19.2.14)(react@19.2.6)) + expo-glass-effect: 55.0.11(expo@55.0.27)(react-native@0.83.9(@babel/core@7.29.7)(@react-native/metro-config@0.85.2(@babel/core@7.29.7))(@types/react@19.2.14)(react@19.2.6))(react@19.2.6) + expo-image: 55.0.10(expo@55.0.27)(react-native-web@0.21.2(react-dom@19.2.6(react@19.2.6))(react@19.2.6))(react-native@0.83.9(@babel/core@7.29.7)(@react-native/metro-config@0.85.2(@babel/core@7.29.7))(@types/react@19.2.14)(react@19.2.6))(react@19.2.6) + expo-linking: 55.0.15(expo@55.0.27)(react-native@0.83.9(@babel/core@7.29.7)(@react-native/metro-config@0.85.2(@babel/core@7.29.7))(@types/react@19.2.14)(react@19.2.6))(react@19.2.6) expo-server: 55.0.9 - expo-symbols: 55.0.8(expo-font@55.0.7)(expo@55.0.23)(react-native@0.83.9(@babel/core@7.29.7)(@react-native/metro-config@0.85.2(@babel/core@7.29.7))(@types/react@19.2.14)(react@19.2.6))(react@19.2.6) + expo-symbols: 55.0.8(expo-font@55.0.8)(expo@55.0.27)(react-native@0.83.9(@babel/core@7.29.7)(@react-native/metro-config@0.85.2(@babel/core@7.29.7))(@types/react@19.2.14)(react@19.2.6))(react@19.2.6) fast-deep-equal: 3.1.3 invariant: 2.2.4 nanoid: 3.3.12 @@ -11078,7 +11473,7 @@ snapshots: use-latest-callback: 0.2.6(react@19.2.6) vaul: 1.1.2(@types/react@19.2.14)(react-dom@19.2.6(react@19.2.6))(react@19.2.6) optionalDependencies: - '@testing-library/react-native': 13.3.3(jest@29.7.0(@types/node@25.9.3))(react-native@0.83.9(@babel/core@7.29.7)(@react-native/metro-config@0.85.2(@babel/core@7.29.7))(@types/react@19.2.14)(react@19.2.6))(react-test-renderer@19.2.6(react@19.2.6))(react@19.2.6) + '@testing-library/react-native': 13.3.3(jest@29.7.0(@types/node@26.1.1))(react-native@0.83.9(@babel/core@7.29.7)(@react-native/metro-config@0.85.2(@babel/core@7.29.7))(@types/react@19.2.14)(react@19.2.6))(react-test-renderer@19.2.6(react@19.2.6))(react@19.2.6) react-dom: 19.2.6(react@19.2.6) react-native-gesture-handler: 2.31.2(react-native@0.83.9(@babel/core@7.29.7)(@react-native/metro-config@0.85.2(@babel/core@7.29.7))(@types/react@19.2.14)(react@19.2.6))(react@19.2.6) react-native-reanimated: 4.3.0(react-native-worklets@0.8.3(@babel/core@7.29.7)(@react-native/metro-config@0.85.2(@babel/core@7.29.7))(react-native@0.83.9(@babel/core@7.29.7)(@react-native/metro-config@0.85.2(@babel/core@7.29.7))(@types/react@19.2.14)(react@19.2.6))(react@19.2.6))(react-native@0.83.9(@babel/core@7.29.7)(@react-native/metro-config@0.85.2(@babel/core@7.29.7))(@types/react@19.2.14)(react@19.2.6))(react@19.2.6) @@ -11090,16 +11485,18 @@ snapshots: - expo-font - supports-color - expo-secure-store@55.0.13(expo@55.0.23): + expo-secure-store@55.0.13(expo@55.0.27): dependencies: - expo: 55.0.23(@babel/core@7.29.7)(@expo/dom-webview@55.0.5)(@expo/metro-runtime@55.0.10)(expo-router@55.0.14)(react-dom@19.2.6(react@19.2.6))(react-native-webview@13.16.1(react-native@0.83.9(@babel/core@7.29.7)(@react-native/metro-config@0.85.2(@babel/core@7.29.7))(@types/react@19.2.14)(react@19.2.6))(react@19.2.6))(react-native-worklets@0.8.3(@babel/core@7.29.7)(@react-native/metro-config@0.85.2(@babel/core@7.29.7))(react-native@0.83.9(@babel/core@7.29.7)(@react-native/metro-config@0.85.2(@babel/core@7.29.7))(@types/react@19.2.14)(react@19.2.6))(react@19.2.6))(react-native@0.83.9(@babel/core@7.29.7)(@react-native/metro-config@0.85.2(@babel/core@7.29.7))(@types/react@19.2.14)(react@19.2.6))(react@19.2.6)(typescript@5.9.3) + expo: 55.0.27(@babel/core@7.29.7)(@expo/dom-webview@55.0.5)(@expo/metro-runtime@55.0.10)(expo-router@55.0.14)(react-dom@19.2.6(react@19.2.6))(react-native-webview@13.16.1(react-native@0.83.9(@babel/core@7.29.7)(@react-native/metro-config@0.85.2(@babel/core@7.29.7))(@types/react@19.2.14)(react@19.2.6))(react@19.2.6))(react-native-worklets@0.8.3(@babel/core@7.29.7)(@react-native/metro-config@0.85.2(@babel/core@7.29.7))(react-native@0.83.9(@babel/core@7.29.7)(@react-native/metro-config@0.85.2(@babel/core@7.29.7))(@types/react@19.2.14)(react@19.2.6))(react@19.2.6))(react-native@0.83.9(@babel/core@7.29.7)(@react-native/metro-config@0.85.2(@babel/core@7.29.7))(@types/react@19.2.14)(react@19.2.6))(react@19.2.6)(typescript@7.0.2) + + expo-server@55.0.11: {} expo-server@55.0.9: {} - expo-splash-screen@55.0.20(expo@55.0.23)(typescript@5.9.3): + expo-splash-screen@55.0.20(expo@55.0.27)(typescript@7.0.2): dependencies: - '@expo/prebuild-config': 55.0.17(expo@55.0.23)(typescript@5.9.3) - expo: 55.0.23(@babel/core@7.29.7)(@expo/dom-webview@55.0.5)(@expo/metro-runtime@55.0.10)(expo-router@55.0.14)(react-dom@19.2.6(react@19.2.6))(react-native-webview@13.16.1(react-native@0.83.9(@babel/core@7.29.7)(@react-native/metro-config@0.85.2(@babel/core@7.29.7))(@types/react@19.2.14)(react@19.2.6))(react@19.2.6))(react-native-worklets@0.8.3(@babel/core@7.29.7)(@react-native/metro-config@0.85.2(@babel/core@7.29.7))(react-native@0.83.9(@babel/core@7.29.7)(@react-native/metro-config@0.85.2(@babel/core@7.29.7))(@types/react@19.2.14)(react@19.2.6))(react@19.2.6))(react-native@0.83.9(@babel/core@7.29.7)(@react-native/metro-config@0.85.2(@babel/core@7.29.7))(@types/react@19.2.14)(react@19.2.6))(react@19.2.6)(typescript@5.9.3) + '@expo/prebuild-config': 55.0.17(expo@55.0.27)(typescript@7.0.2) + expo: 55.0.27(@babel/core@7.29.7)(@expo/dom-webview@55.0.5)(@expo/metro-runtime@55.0.10)(expo-router@55.0.14)(react-dom@19.2.6(react@19.2.6))(react-native-webview@13.16.1(react-native@0.83.9(@babel/core@7.29.7)(@react-native/metro-config@0.85.2(@babel/core@7.29.7))(@types/react@19.2.14)(react@19.2.6))(react@19.2.6))(react-native-worklets@0.8.3(@babel/core@7.29.7)(@react-native/metro-config@0.85.2(@babel/core@7.29.7))(react-native@0.83.9(@babel/core@7.29.7)(@react-native/metro-config@0.85.2(@babel/core@7.29.7))(@types/react@19.2.14)(react@19.2.6))(react@19.2.6))(react-native@0.83.9(@babel/core@7.29.7)(@react-native/metro-config@0.85.2(@babel/core@7.29.7))(@types/react@19.2.14)(react@19.2.6))(react@19.2.6)(typescript@7.0.2) transitivePeerDependencies: - supports-color - typescript @@ -11110,49 +11507,49 @@ snapshots: react-native: 0.83.9(@babel/core@7.29.7)(@react-native/metro-config@0.85.2(@babel/core@7.29.7))(@types/react@19.2.14)(react@19.2.6) react-native-is-edge-to-edge: 1.3.1(react-native@0.83.9(@babel/core@7.29.7)(@react-native/metro-config@0.85.2(@babel/core@7.29.7))(@types/react@19.2.14)(react@19.2.6))(react@19.2.6) - expo-symbols@55.0.8(expo-font@55.0.7)(expo@55.0.23)(react-native@0.83.9(@babel/core@7.29.7)(@react-native/metro-config@0.85.2(@babel/core@7.29.7))(@types/react@19.2.14)(react@19.2.6))(react@19.2.6): + expo-symbols@55.0.8(expo-font@55.0.8)(expo@55.0.27)(react-native@0.83.9(@babel/core@7.29.7)(@react-native/metro-config@0.85.2(@babel/core@7.29.7))(@types/react@19.2.14)(react@19.2.6))(react@19.2.6): dependencies: '@expo-google-fonts/material-symbols': 0.4.34 - expo: 55.0.23(@babel/core@7.29.7)(@expo/dom-webview@55.0.5)(@expo/metro-runtime@55.0.10)(expo-router@55.0.14)(react-dom@19.2.6(react@19.2.6))(react-native-webview@13.16.1(react-native@0.83.9(@babel/core@7.29.7)(@react-native/metro-config@0.85.2(@babel/core@7.29.7))(@types/react@19.2.14)(react@19.2.6))(react@19.2.6))(react-native-worklets@0.8.3(@babel/core@7.29.7)(@react-native/metro-config@0.85.2(@babel/core@7.29.7))(react-native@0.83.9(@babel/core@7.29.7)(@react-native/metro-config@0.85.2(@babel/core@7.29.7))(@types/react@19.2.14)(react@19.2.6))(react@19.2.6))(react-native@0.83.9(@babel/core@7.29.7)(@react-native/metro-config@0.85.2(@babel/core@7.29.7))(@types/react@19.2.14)(react@19.2.6))(react@19.2.6)(typescript@5.9.3) - expo-font: 55.0.7(expo@55.0.23)(react-native@0.83.9(@babel/core@7.29.7)(@react-native/metro-config@0.85.2(@babel/core@7.29.7))(@types/react@19.2.14)(react@19.2.6))(react@19.2.6) + expo: 55.0.27(@babel/core@7.29.7)(@expo/dom-webview@55.0.5)(@expo/metro-runtime@55.0.10)(expo-router@55.0.14)(react-dom@19.2.6(react@19.2.6))(react-native-webview@13.16.1(react-native@0.83.9(@babel/core@7.29.7)(@react-native/metro-config@0.85.2(@babel/core@7.29.7))(@types/react@19.2.14)(react@19.2.6))(react@19.2.6))(react-native-worklets@0.8.3(@babel/core@7.29.7)(@react-native/metro-config@0.85.2(@babel/core@7.29.7))(react-native@0.83.9(@babel/core@7.29.7)(@react-native/metro-config@0.85.2(@babel/core@7.29.7))(@types/react@19.2.14)(react@19.2.6))(react@19.2.6))(react-native@0.83.9(@babel/core@7.29.7)(@react-native/metro-config@0.85.2(@babel/core@7.29.7))(@types/react@19.2.14)(react@19.2.6))(react@19.2.6)(typescript@7.0.2) + expo-font: 55.0.8(expo@55.0.27)(react-native@0.83.9(@babel/core@7.29.7)(@react-native/metro-config@0.85.2(@babel/core@7.29.7))(@types/react@19.2.14)(react@19.2.6))(react@19.2.6) react: 19.2.6 react-native: 0.83.9(@babel/core@7.29.7)(@react-native/metro-config@0.85.2(@babel/core@7.29.7))(@types/react@19.2.14)(react@19.2.6) sf-symbols-typescript: 2.2.0 - expo-updates-interface@55.1.6(expo@55.0.23): + expo-updates-interface@55.1.6(expo@55.0.27): dependencies: - expo: 55.0.23(@babel/core@7.29.7)(@expo/dom-webview@55.0.5)(@expo/metro-runtime@55.0.10)(expo-router@55.0.14)(react-dom@19.2.6(react@19.2.6))(react-native-webview@13.16.1(react-native@0.83.9(@babel/core@7.29.7)(@react-native/metro-config@0.85.2(@babel/core@7.29.7))(@types/react@19.2.14)(react@19.2.6))(react@19.2.6))(react-native-worklets@0.8.3(@babel/core@7.29.7)(@react-native/metro-config@0.85.2(@babel/core@7.29.7))(react-native@0.83.9(@babel/core@7.29.7)(@react-native/metro-config@0.85.2(@babel/core@7.29.7))(@types/react@19.2.14)(react@19.2.6))(react@19.2.6))(react-native@0.83.9(@babel/core@7.29.7)(@react-native/metro-config@0.85.2(@babel/core@7.29.7))(@types/react@19.2.14)(react@19.2.6))(react@19.2.6)(typescript@5.9.3) + expo: 55.0.27(@babel/core@7.29.7)(@expo/dom-webview@55.0.5)(@expo/metro-runtime@55.0.10)(expo-router@55.0.14)(react-dom@19.2.6(react@19.2.6))(react-native-webview@13.16.1(react-native@0.83.9(@babel/core@7.29.7)(@react-native/metro-config@0.85.2(@babel/core@7.29.7))(@types/react@19.2.14)(react@19.2.6))(react@19.2.6))(react-native-worklets@0.8.3(@babel/core@7.29.7)(@react-native/metro-config@0.85.2(@babel/core@7.29.7))(react-native@0.83.9(@babel/core@7.29.7)(@react-native/metro-config@0.85.2(@babel/core@7.29.7))(@types/react@19.2.14)(react@19.2.6))(react@19.2.6))(react-native@0.83.9(@babel/core@7.29.7)(@react-native/metro-config@0.85.2(@babel/core@7.29.7))(@types/react@19.2.14)(react@19.2.6))(react@19.2.6)(typescript@7.0.2) - expo@55.0.23(@babel/core@7.29.7)(@expo/dom-webview@55.0.5)(@expo/metro-runtime@55.0.10)(expo-router@55.0.14)(react-dom@19.2.6(react@19.2.6))(react-native-webview@13.16.1(react-native@0.83.9(@babel/core@7.29.7)(@react-native/metro-config@0.85.2(@babel/core@7.29.7))(@types/react@19.2.14)(react@19.2.6))(react@19.2.6))(react-native-worklets@0.8.3(@babel/core@7.29.7)(@react-native/metro-config@0.85.2(@babel/core@7.29.7))(react-native@0.83.9(@babel/core@7.29.7)(@react-native/metro-config@0.85.2(@babel/core@7.29.7))(@types/react@19.2.14)(react@19.2.6))(react@19.2.6))(react-native@0.83.9(@babel/core@7.29.7)(@react-native/metro-config@0.85.2(@babel/core@7.29.7))(@types/react@19.2.14)(react@19.2.6))(react@19.2.6)(typescript@5.9.3): + expo@55.0.27(@babel/core@7.29.7)(@expo/dom-webview@55.0.5)(@expo/metro-runtime@55.0.10)(expo-router@55.0.14)(react-dom@19.2.6(react@19.2.6))(react-native-webview@13.16.1(react-native@0.83.9(@babel/core@7.29.7)(@react-native/metro-config@0.85.2(@babel/core@7.29.7))(@types/react@19.2.14)(react@19.2.6))(react@19.2.6))(react-native-worklets@0.8.3(@babel/core@7.29.7)(@react-native/metro-config@0.85.2(@babel/core@7.29.7))(react-native@0.83.9(@babel/core@7.29.7)(@react-native/metro-config@0.85.2(@babel/core@7.29.7))(@types/react@19.2.14)(react@19.2.6))(react@19.2.6))(react-native@0.83.9(@babel/core@7.29.7)(@react-native/metro-config@0.85.2(@babel/core@7.29.7))(@types/react@19.2.14)(react@19.2.6))(react@19.2.6)(typescript@7.0.2): dependencies: - '@babel/runtime': 7.29.2 - '@expo/cli': 55.0.29(@expo/dom-webview@55.0.5)(@expo/metro-runtime@55.0.10)(expo-constants@55.0.16)(expo-font@55.0.7)(expo-router@55.0.14)(expo@55.0.23)(react-dom@19.2.6(react@19.2.6))(react-native@0.83.9(@babel/core@7.29.7)(@react-native/metro-config@0.85.2(@babel/core@7.29.7))(@types/react@19.2.14)(react@19.2.6))(react@19.2.6)(typescript@5.9.3) - '@expo/config': 55.0.16(typescript@5.9.3) - '@expo/config-plugins': 55.0.8 + '@babel/runtime': 7.29.7 + '@expo/cli': 55.0.33(@expo/dom-webview@55.0.5)(@expo/metro-runtime@55.0.10)(expo-constants@55.0.16)(expo-font@55.0.8)(expo-router@55.0.14)(expo@55.0.27)(react-dom@19.2.6(react@19.2.6))(react-native@0.83.9(@babel/core@7.29.7)(@react-native/metro-config@0.85.2(@babel/core@7.29.7))(@types/react@19.2.14)(react@19.2.6))(react@19.2.6)(typescript@7.0.2) + '@expo/config': 55.0.18(typescript@7.0.2) + '@expo/config-plugins': 55.0.10 '@expo/devtools': 55.0.3(react-native@0.83.9(@babel/core@7.29.7)(@react-native/metro-config@0.85.2(@babel/core@7.29.7))(@types/react@19.2.14)(react@19.2.6))(react@19.2.6) '@expo/fingerprint': 0.16.7 - '@expo/local-build-cache-provider': 55.0.12(typescript@5.9.3) - '@expo/log-box': 55.0.12(@expo/dom-webview@55.0.5)(expo@55.0.23)(react-native@0.83.9(@babel/core@7.29.7)(@react-native/metro-config@0.85.2(@babel/core@7.29.7))(@types/react@19.2.14)(react@19.2.6))(react@19.2.6) + '@expo/local-build-cache-provider': 55.0.14(typescript@7.0.2) + '@expo/log-box': 55.0.12(@expo/dom-webview@55.0.5)(expo@55.0.27)(react-native@0.83.9(@babel/core@7.29.7)(@react-native/metro-config@0.85.2(@babel/core@7.29.7))(@types/react@19.2.14)(react@19.2.6))(react@19.2.6) '@expo/metro': 55.1.1 - '@expo/metro-config': 55.0.20(expo@55.0.23)(typescript@5.9.3) - '@expo/vector-icons': 15.1.1(expo-font@55.0.7)(react-native@0.83.9(@babel/core@7.29.7)(@react-native/metro-config@0.85.2(@babel/core@7.29.7))(@types/react@19.2.14)(react@19.2.6))(react@19.2.6) + '@expo/metro-config': 55.0.24(expo@55.0.27)(typescript@7.0.2) + '@expo/vector-icons': 15.1.1(expo-font@55.0.8)(react-native@0.83.9(@babel/core@7.29.7)(@react-native/metro-config@0.85.2(@babel/core@7.29.7))(@types/react@19.2.14)(react@19.2.6))(react@19.2.6) '@ungap/structured-clone': 1.3.1 - babel-preset-expo: 55.0.21(@babel/core@7.29.7)(@babel/runtime@7.29.2)(expo@55.0.23)(react-refresh@0.14.2) - expo-asset: 55.0.17(expo@55.0.23)(react-native@0.83.9(@babel/core@7.29.7)(@react-native/metro-config@0.85.2(@babel/core@7.29.7))(@types/react@19.2.14)(react@19.2.6))(react@19.2.6)(typescript@5.9.3) - expo-constants: 55.0.16(expo@55.0.23)(react-native@0.83.9(@babel/core@7.29.7)(@react-native/metro-config@0.85.2(@babel/core@7.29.7))(@types/react@19.2.14)(react@19.2.6)) - expo-file-system: 55.0.19(expo@55.0.23)(react-native@0.83.9(@babel/core@7.29.7)(@react-native/metro-config@0.85.2(@babel/core@7.29.7))(@types/react@19.2.14)(react@19.2.6)) - expo-font: 55.0.7(expo@55.0.23)(react-native@0.83.9(@babel/core@7.29.7)(@react-native/metro-config@0.85.2(@babel/core@7.29.7))(@types/react@19.2.14)(react@19.2.6))(react@19.2.6) - expo-keep-awake: 55.0.8(expo@55.0.23)(react@19.2.6) - expo-modules-autolinking: 55.0.21(typescript@5.9.3) + babel-preset-expo: 55.0.23(@babel/core@7.29.7)(@babel/runtime@7.29.7)(expo@55.0.27)(react-refresh@0.14.2) + expo-asset: 55.0.17(expo@55.0.27)(react-native@0.83.9(@babel/core@7.29.7)(@react-native/metro-config@0.85.2(@babel/core@7.29.7))(@types/react@19.2.14)(react@19.2.6))(react@19.2.6)(typescript@7.0.2) + expo-constants: 55.0.16(expo@55.0.27)(react-native@0.83.9(@babel/core@7.29.7)(@react-native/metro-config@0.85.2(@babel/core@7.29.7))(@types/react@19.2.14)(react@19.2.6)) + expo-file-system: 55.0.23(expo@55.0.27)(react-native@0.83.9(@babel/core@7.29.7)(@react-native/metro-config@0.85.2(@babel/core@7.29.7))(@types/react@19.2.14)(react@19.2.6)) + expo-font: 55.0.8(expo@55.0.27)(react-native@0.83.9(@babel/core@7.29.7)(@react-native/metro-config@0.85.2(@babel/core@7.29.7))(@types/react@19.2.14)(react@19.2.6))(react@19.2.6) + expo-keep-awake: 55.0.8(expo@55.0.27)(react@19.2.6) + expo-modules-autolinking: 55.0.24(typescript@7.0.2) expo-modules-core: 55.0.25(react-native-worklets@0.8.3(@babel/core@7.29.7)(@react-native/metro-config@0.85.2(@babel/core@7.29.7))(react-native@0.83.9(@babel/core@7.29.7)(@react-native/metro-config@0.85.2(@babel/core@7.29.7))(@types/react@19.2.14)(react@19.2.6))(react@19.2.6))(react-native@0.83.9(@babel/core@7.29.7)(@react-native/metro-config@0.85.2(@babel/core@7.29.7))(@types/react@19.2.14)(react@19.2.6))(react@19.2.6) pretty-format: 29.7.0 react: 19.2.6 react-native: 0.83.9(@babel/core@7.29.7)(@react-native/metro-config@0.85.2(@babel/core@7.29.7))(@types/react@19.2.14)(react@19.2.6) react-refresh: 0.14.2 - whatwg-url-minimum: 0.1.1 + whatwg-url-minimum: 0.1.2 optionalDependencies: - '@expo/dom-webview': 55.0.5(expo@55.0.23)(react-native@0.83.9(@babel/core@7.29.7)(@react-native/metro-config@0.85.2(@babel/core@7.29.7))(@types/react@19.2.14)(react@19.2.6))(react@19.2.6) - '@expo/metro-runtime': 55.0.10(@expo/dom-webview@55.0.5)(expo@55.0.23)(react-dom@19.2.6(react@19.2.6))(react-native@0.83.9(@babel/core@7.29.7)(@react-native/metro-config@0.85.2(@babel/core@7.29.7))(@types/react@19.2.14)(react@19.2.6))(react@19.2.6) + '@expo/dom-webview': 55.0.5(expo@55.0.27)(react-native@0.83.9(@babel/core@7.29.7)(@react-native/metro-config@0.85.2(@babel/core@7.29.7))(@types/react@19.2.14)(react@19.2.6))(react@19.2.6) + '@expo/metro-runtime': 55.0.10(@expo/dom-webview@55.0.5)(expo@55.0.27)(react-dom@19.2.6(react@19.2.6))(react-native@0.83.9(@babel/core@7.29.7)(@react-native/metro-config@0.85.2(@babel/core@7.29.7))(@types/react@19.2.14)(react@19.2.6))(react@19.2.6) react-native-webview: 13.16.1(react-native@0.83.9(@babel/core@7.29.7)(@react-native/metro-config@0.85.2(@babel/core@7.29.7))(@types/react@19.2.14)(react@19.2.6))(react@19.2.6) transitivePeerDependencies: - '@babel/core' @@ -11670,7 +12067,7 @@ snapshots: '@babel/parser': 7.29.7 '@istanbuljs/schema': 0.1.6 istanbul-lib-coverage: 3.2.2 - semver: 7.8.4 + semver: 7.8.5 transitivePeerDependencies: - supports-color @@ -11714,7 +12111,7 @@ snapshots: '@jest/expect': 29.7.0 '@jest/test-result': 29.7.0 '@jest/types': 29.6.3 - '@types/node': 25.9.3 + '@types/node': 26.1.1 chalk: 4.1.2 co: 4.6.0 dedent: 1.7.2 @@ -11734,26 +12131,26 @@ snapshots: - babel-plugin-macros - supports-color - jest-cli@29.7.0(@types/node@25.9.3): + jest-cli@29.7.0(@types/node@26.1.1): dependencies: '@jest/core': 29.7.0 '@jest/test-result': 29.7.0 '@jest/types': 29.6.3 chalk: 4.1.2 - create-jest: 29.7.0(@types/node@25.9.3) + create-jest: 29.7.0(@types/node@26.1.1) exit: 0.1.2 import-local: 3.2.0 - jest-config: 29.7.0(@types/node@25.9.3) + jest-config: 29.7.0(@types/node@26.1.1) jest-util: 29.7.0 jest-validate: 29.7.0 - yargs: 17.7.2 + yargs: 17.7.3 transitivePeerDependencies: - '@types/node' - babel-plugin-macros - supports-color - ts-node - jest-config@29.7.0(@types/node@25.9.3): + jest-config@29.7.0(@types/node@26.1.1): dependencies: '@babel/core': 7.29.7 '@jest/test-sequencer': 29.7.0 @@ -11778,7 +12175,7 @@ snapshots: slash: 3.0.0 strip-json-comments: 3.1.1 optionalDependencies: - '@types/node': 25.9.3 + '@types/node': 26.1.1 transitivePeerDependencies: - babel-plugin-macros - supports-color @@ -11833,18 +12230,18 @@ snapshots: jest-mock: 29.7.0 jest-util: 29.7.0 - jest-expo@55.0.17(@babel/core@7.29.7)(expo@55.0.23)(jest@29.7.0(@types/node@25.9.3))(react-native@0.83.9(@babel/core@7.29.7)(@react-native/metro-config@0.85.2(@babel/core@7.29.7))(@types/react@19.2.14)(react@19.2.6))(react@19.2.6)(typescript@5.9.3): + jest-expo@55.0.17(@babel/core@7.29.7)(expo@55.0.27)(jest@29.7.0(@types/node@26.1.1))(react-native@0.83.9(@babel/core@7.29.7)(@react-native/metro-config@0.85.2(@babel/core@7.29.7))(@types/react@19.2.14)(react@19.2.6))(react@19.2.6)(typescript@5.9.3): dependencies: '@expo/config': 55.0.16(typescript@5.9.3) '@expo/json-file': 10.0.14 '@jest/create-cache-key-function': 29.7.0 '@jest/globals': 29.7.0 babel-jest: 29.7.0(@babel/core@7.29.7) - expo: 55.0.23(@babel/core@7.29.7)(@expo/dom-webview@55.0.5)(@expo/metro-runtime@55.0.10)(expo-router@55.0.14)(react-dom@19.2.6(react@19.2.6))(react-native-webview@13.16.1(react-native@0.83.9(@babel/core@7.29.7)(@react-native/metro-config@0.85.2(@babel/core@7.29.7))(@types/react@19.2.14)(react@19.2.6))(react@19.2.6))(react-native-worklets@0.8.3(@babel/core@7.29.7)(@react-native/metro-config@0.85.2(@babel/core@7.29.7))(react-native@0.83.9(@babel/core@7.29.7)(@react-native/metro-config@0.85.2(@babel/core@7.29.7))(@types/react@19.2.14)(react@19.2.6))(react@19.2.6))(react-native@0.83.9(@babel/core@7.29.7)(@react-native/metro-config@0.85.2(@babel/core@7.29.7))(@types/react@19.2.14)(react@19.2.6))(react@19.2.6)(typescript@5.9.3) + expo: 55.0.27(@babel/core@7.29.7)(@expo/dom-webview@55.0.5)(@expo/metro-runtime@55.0.10)(expo-router@55.0.14)(react-dom@19.2.6(react@19.2.6))(react-native-webview@13.16.1(react-native@0.83.9(@babel/core@7.29.7)(@react-native/metro-config@0.85.2(@babel/core@7.29.7))(@types/react@19.2.14)(react@19.2.6))(react@19.2.6))(react-native-worklets@0.8.3(@babel/core@7.29.7)(@react-native/metro-config@0.85.2(@babel/core@7.29.7))(react-native@0.83.9(@babel/core@7.29.7)(@react-native/metro-config@0.85.2(@babel/core@7.29.7))(@types/react@19.2.14)(react@19.2.6))(react@19.2.6))(react-native@0.83.9(@babel/core@7.29.7)(@react-native/metro-config@0.85.2(@babel/core@7.29.7))(@types/react@19.2.14)(react@19.2.6))(react@19.2.6)(typescript@7.0.2) jest-environment-jsdom: 29.7.0 jest-snapshot: 29.7.0 jest-watch-select-projects: 2.0.0 - jest-watch-typeahead: 2.2.1(jest@29.7.0(@types/node@25.9.3)) + jest-watch-typeahead: 2.2.1(jest@29.7.0(@types/node@26.1.1)) json5: 2.2.3 lodash: 4.18.1 react-native: 0.83.9(@babel/core@7.29.7)(@react-native/metro-config@0.85.2(@babel/core@7.29.7))(@types/react@19.2.14)(react@19.2.6) @@ -11948,7 +12345,7 @@ snapshots: '@jest/test-result': 29.7.0 '@jest/transform': 29.7.0 '@jest/types': 29.6.3 - '@types/node': 25.9.3 + '@types/node': 26.1.1 chalk: 4.1.2 emittery: 0.13.1 graceful-fs: 4.2.11 @@ -11976,7 +12373,7 @@ snapshots: '@jest/test-result': 29.7.0 '@jest/transform': 29.7.0 '@jest/types': 29.6.3 - '@types/node': 25.9.3 + '@types/node': 26.1.1 chalk: 4.1.2 cjs-module-lexer: 1.4.3 collect-v8-coverage: 1.0.3 @@ -12043,11 +12440,11 @@ snapshots: chalk: 3.0.0 prompts: 2.4.2 - jest-watch-typeahead@2.2.1(jest@29.7.0(@types/node@25.9.3)): + jest-watch-typeahead@2.2.1(jest@29.7.0(@types/node@26.1.1)): dependencies: ansi-escapes: 6.2.1 chalk: 4.1.2 - jest: 29.7.0(@types/node@25.9.3) + jest: 29.7.0(@types/node@26.1.1) jest-regex-util: 29.6.3 jest-watcher: 29.7.0 slash: 5.1.0 @@ -12072,12 +12469,12 @@ snapshots: merge-stream: 2.0.0 supports-color: 8.1.1 - jest@29.7.0(@types/node@25.9.3): + jest@29.7.0(@types/node@26.1.1): dependencies: '@jest/core': 29.7.0 '@jest/types': 29.6.3 import-local: 3.2.0 - jest-cli: 29.7.0(@types/node@25.9.3) + jest-cli: 29.7.0(@types/node@26.1.1) transitivePeerDependencies: - '@types/node' - babel-plugin-macros @@ -12088,12 +12485,12 @@ snapshots: js-tokens@4.0.0: {} - js-yaml@3.14.2: + js-yaml@3.15.0: dependencies: argparse: 1.0.10 esprima: 4.0.1 - js-yaml@4.2.0: + js-yaml@4.3.0: dependencies: argparse: 2.0.1 @@ -12285,7 +12682,7 @@ snapshots: make-dir@4.0.0: dependencies: - semver: 7.8.4 + semver: 7.8.5 make-error@1.3.6: {} @@ -12433,7 +12830,7 @@ snapshots: metro-minify-terser@0.84.4: dependencies: flow-enums-runtime: 0.0.6 - terser: 5.48.0 + terser: 5.49.0 metro-resolver@0.83.7: dependencies: @@ -12651,7 +13048,7 @@ snapshots: source-map: 0.5.7 throat: 5.0.0 ws: 7.5.11 - yargs: 17.7.2 + yargs: 17.7.3 transitivePeerDependencies: - bufferutil - supports-color @@ -12735,7 +13132,7 @@ snapshots: dependencies: hosted-git-info: 7.0.2 proc-log: 4.2.0 - semver: 7.7.4 + semver: 7.8.4 validate-npm-package-name: 5.0.1 npm-run-path@4.0.1: @@ -12985,12 +13382,6 @@ snapshots: postcss-value-parser@4.2.0: {} - postcss@8.4.49: - dependencies: - nanoid: 3.3.12 - picocolors: 1.1.1 - source-map-js: 1.2.1 - postcss@8.5.15: dependencies: nanoid: 3.3.12 @@ -13423,6 +13814,8 @@ snapshots: semver@7.8.4: {} + semver@7.8.5: {} + send@0.19.2: dependencies: debug: 2.6.9 @@ -13737,10 +14130,10 @@ snapshots: commander: 2.20.3 source-map-support: 0.5.21 - terser@5.48.0: + terser@5.49.0: dependencies: '@jridgewell/source-map': 0.3.11 - acorn: 8.17.0 + acorn: 8.15.0 commander: 2.20.3 source-map-support: 0.5.21 @@ -13797,11 +14190,11 @@ snapshots: picomatch: 4.0.4 typescript: 5.9.3 - ts-jest@29.0.5(@babel/core@7.29.7)(@jest/types@29.6.3)(babel-jest@29.7.0(@babel/core@7.29.7))(esbuild@0.25.4)(jest@29.7.0(@types/node@25.9.3))(typescript@5.9.3): + ts-jest@29.0.5(@babel/core@7.29.7)(@jest/types@29.6.3)(babel-jest@29.7.0(@babel/core@7.29.7))(esbuild@0.25.4)(jest@29.7.0(@types/node@26.1.1))(typescript@5.9.3): dependencies: bs-logger: 0.2.6 fast-json-stable-stringify: 2.1.0 - jest: 29.7.0(@types/node@25.9.3) + jest: 29.7.0(@types/node@26.1.1) jest-util: 29.7.0 json5: 2.2.3 lodash.memoize: 4.1.2 @@ -13881,6 +14274,29 @@ snapshots: typescript@5.9.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 + ua-parser-js@1.0.41: {} unbox-primitive@1.1.0: @@ -13892,7 +14308,7 @@ snapshots: undici-types@7.19.2: {} - undici-types@7.24.6: {} + undici-types@8.3.0: {} unicode-canonical-property-names-ecmascript@2.0.1: {} @@ -13949,7 +14365,7 @@ snapshots: utils-merge@1.0.1: {} - uuid@7.0.3: {} + uuid@11.1.1: {} v8-to-istanbul@9.3.0: dependencies: @@ -13970,7 +14386,7 @@ snapshots: - '@types/react' - '@types/react-dom' - vite@8.1.0(@types/node@25.9.3)(esbuild@0.25.4)(terser@5.48.0)(tsx@4.22.4)(yaml@2.9.0): + vite@8.1.0(@types/node@26.1.1)(esbuild@0.25.4)(terser@5.49.0)(tsx@4.22.4)(yaml@2.9.0): dependencies: lightningcss: 1.32.0 picomatch: 4.0.4 @@ -13978,17 +14394,17 @@ snapshots: rolldown: 1.1.3 tinyglobby: 0.2.17 optionalDependencies: - '@types/node': 25.9.3 + '@types/node': 26.1.1 esbuild: 0.25.4 fsevents: 2.3.3 - terser: 5.48.0 + terser: 5.49.0 tsx: 4.22.4 yaml: 2.9.0 - vitest@4.1.9(@types/node@25.9.3)(jsdom@20.0.3)(vite@8.1.0(@types/node@25.9.3)(esbuild@0.25.4)(terser@5.48.0)(tsx@4.22.4)(yaml@2.9.0)): + vitest@4.1.9(@types/node@26.1.1)(jsdom@20.0.3)(vite@8.1.0(@types/node@26.1.1)(esbuild@0.25.4)(terser@5.49.0)(tsx@4.22.4)(yaml@2.9.0)): dependencies: '@vitest/expect': 4.1.9 - '@vitest/mocker': 4.1.9(vite@8.1.0(@types/node@25.9.3)(esbuild@0.25.4)(terser@5.48.0)(tsx@4.22.4)(yaml@2.9.0)) + '@vitest/mocker': 4.1.9(vite@8.1.0(@types/node@26.1.1)(esbuild@0.25.4)(terser@5.49.0)(tsx@4.22.4)(yaml@2.9.0)) '@vitest/pretty-format': 4.1.9 '@vitest/runner': 4.1.9 '@vitest/snapshot': 4.1.9 @@ -14005,10 +14421,10 @@ snapshots: tinyexec: 1.1.2 tinyglobby: 0.2.17 tinyrainbow: 3.1.0 - vite: 8.1.0(@types/node@25.9.3)(esbuild@0.25.4)(terser@5.48.0)(tsx@4.22.4)(yaml@2.9.0) + vite: 8.1.0(@types/node@26.1.1)(esbuild@0.25.4)(terser@5.49.0)(tsx@4.22.4)(yaml@2.9.0) why-is-node-running: 2.3.0 optionalDependencies: - '@types/node': 25.9.3 + '@types/node': 26.1.1 jsdom: 20.0.3 transitivePeerDependencies: - msw @@ -14041,7 +14457,7 @@ snapshots: whatwg-mimetype@3.0.0: {} - whatwg-url-minimum@0.1.1: {} + whatwg-url-minimum@0.1.2: {} whatwg-url@11.0.0: dependencies: @@ -14125,7 +14541,7 @@ snapshots: xcode@3.0.1: dependencies: simple-plist: 1.3.1 - uuid: 7.0.3 + uuid: 11.1.1 xml-name-validator@4.0.0: {} @@ -14160,6 +14576,16 @@ snapshots: y18n: 5.0.8 yargs-parser: 21.1.1 + yargs@17.7.3: + dependencies: + cliui: 8.0.1 + escalade: 3.2.0 + get-caller-file: 2.0.5 + require-directory: 2.1.1 + string-width: 4.2.3 + y18n: 5.0.8 + yargs-parser: 21.1.1 + yocto-queue@0.1.0: {} zod@3.25.76: {} diff --git a/mobile/src/components/MobileSearchField.tsx b/mobile/src/components/MobileSearchField.tsx new file mode 100644 index 00000000000..2dfd6bf26e4 --- /dev/null +++ b/mobile/src/components/MobileSearchField.tsx @@ -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(null) + const [focused, setFocused] = useState(false) + const clearVisible = showClear ?? value.length > 0 + + useEffect(() => { + if (!autoFocus || !editable) { + return + } + + let timeout: ReturnType | 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 ( + + + setFocused(true)} + onBlur={(event) => { + setFocused(false) + onBlur?.(event) + }} + clearButtonMode="never" + accessibilityLabel={accessibilityLabel ?? placeholder} + selectionColor={colors.accentBlue} + /> + {clearVisible ? ( + [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. */} + + + + + ) : null} + + ) +} + +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 + } +}) diff --git a/mobile/src/session/TerminalPaneView.tsx b/mobile/src/session/TerminalPaneView.tsx index da34ed870c1..beb8118dabf 100644 --- a/mobile/src/session/TerminalPaneView.tsx +++ b/mobile/src/session/TerminalPaneView.tsx @@ -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 diff --git a/mobile/src/session/mobile-session-route-helpers.ts b/mobile/src/session/mobile-session-route-helpers.ts index e5a8d4329b7..18434e21f5a 100644 --- a/mobile/src/session/mobile-session-route-helpers.ts +++ b/mobile/src/session/mobile-session-route-helpers.ts @@ -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 = { diff --git a/mobile/src/session/mobile-terminal-records.test.ts b/mobile/src/session/mobile-terminal-records.test.ts index 4ebf7476682..0ebd2c55631 100644 --- a/mobile/src/session/mobile-terminal-records.test.ts +++ b/mobile/src/session/mobile-terminal-records.test.ts @@ -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 }, diff --git a/mobile/src/session/mobile-terminal-records.ts b/mobile/src/session/mobile-terminal-records.ts index 0f97af2529e..ffe93ac29dd 100644 --- a/mobile/src/session/mobile-terminal-records.ts +++ b/mobile/src/session/mobile-terminal-records.ts @@ -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)) ] } diff --git a/mobile/src/session/use-mobile-terminal-paste.ts b/mobile/src/session/use-mobile-terminal-paste.ts index e3148be67f0..57ea720c4c8 100644 --- a/mobile/src/session/use-mobile-terminal-paste.ts +++ b/mobile/src/session/use-mobile-terminal-paste.ts @@ -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 { diff --git a/mobile/src/terminal/TerminalWebView.tsx b/mobile/src/terminal/TerminalWebView.tsx index 1798fedd1d8..4b84e44081a 100644 --- a/mobile/src/terminal/TerminalWebView.tsx +++ b/mobile/src/terminal/TerminalWebView.tsx @@ -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 - 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(function TerminalWebView( { @@ -61,6 +43,7 @@ export const TerminalWebView = forwardRef(function const isWebReadyRef = useRef(false) const pendingMessages = useMemo(() => createTerminalWebViewPendingMessages(), []) const messageIdRef = useRef(0) + const pendingPingIdRef = useRef(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(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(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 @@ -108,11 +117,13 @@ export const TerminalWebView = forwardRef(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(function } }, [ - flushPendingMessages, - clearEngineError, - clearWebReadyWatchdog, + confirmWebReady, reportEngineError, - onWebReady, onSelectionMode, onSelectionCopy, onSelectionEvicted, @@ -229,6 +237,7 @@ export const TerminalWebView = forwardRef(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(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(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(function }) } }), - [postMessage, sendToWebView, terminalTheme, textScale] + [armWebReadyWatchdog, postMessage, sendToWebView, terminalTheme, textScale] ) return ( @@ -389,9 +419,7 @@ export const TerminalWebView = forwardRef(function onRenderProcessGone={(event) => reportNativeEngineError('Terminal WebView render process ended', event) } - onContentProcessDidTerminate={(event) => - reportNativeEngineError('Terminal WebView content process ended', event) - } + onContentProcessDidTerminate={handleContentProcessDidTerminate} /> {engineError ? ( diff --git a/mobile/src/terminal/terminal-foreground-recovery.test.ts b/mobile/src/terminal/terminal-foreground-recovery.test.ts index 4eda53a9c22..6aeceaaa14a 100644 --- a/mobile/src/terminal/terminal-foreground-recovery.test.ts +++ b/mobile/src/terminal/terminal-foreground-recovery.test.ts @@ -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) }) }) diff --git a/mobile/src/terminal/terminal-viewport-refit.test.ts b/mobile/src/terminal/terminal-viewport-refit.test.ts index 4cd3794ad0f..b658dad437a 100644 --- a/mobile/src/terminal/terminal-viewport-refit.test.ts +++ b/mobile/src/terminal/terminal-viewport-refit.test.ts @@ -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', () => { diff --git a/mobile/src/terminal/terminal-viewport-refit.ts b/mobile/src/terminal/terminal-viewport-refit.ts index b8a823be8c7..44c6a9b7e03 100644 --- a/mobile/src/terminal/terminal-viewport-refit.ts +++ b/mobile/src/terminal/terminal-viewport-refit.ts @@ -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 deviceTokenRef: RefObject initializedHandlesRef: RefObject> + 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 | 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 () => { diff --git a/mobile/src/terminal/terminal-webview-contract.ts b/mobile/src/terminal/terminal-webview-contract.ts index aff86cecad2..808dece2648 100644 --- a/mobile/src/terminal/terminal-webview-contract.ts +++ b/mobile/src/terminal/terminal-webview-contract.ts @@ -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 + 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, diff --git a/mobile/src/terminal/terminal-webview-engine-error.test.ts b/mobile/src/terminal/terminal-webview-engine-error.test.ts index 82267967b7c..4e29c287321 100644 --- a/mobile/src/terminal/terminal-webview-engine-error.test.ts +++ b/mobile/src/terminal/terminal-webview-engine-error.test.ts @@ -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, 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 = {} +) { 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> { + 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() + 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() + 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() + 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']) + }) }) diff --git a/mobile/src/terminal/terminal-webview-engine.test.ts b/mobile/src/terminal/terminal-webview-engine.test.ts index 36c8c039493..503a25a1961 100644 --- a/mobile/src/terminal/terminal-webview-engine.test.ts +++ b/mobile/src/terminal/terminal-webview-engine.test.ts @@ -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 + dispose: ReturnType + 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 })") + }) }) diff --git a/mobile/src/terminal/terminal-webview-html.ts b/mobile/src/terminal/terminal-webview-html.ts index 63e4d2b9ed7..fcd5ea9cafa 100644 --- a/mobile/src/terminal/terminal-webview-html.ts +++ b/mobile/src/terminal/terminal-webview-html.ts @@ -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 === diff --git a/mobile/src/terminal/terminal-webview-messages.ts b/mobile/src/terminal/terminal-webview-messages.ts index f38c5a1dfbb..c031363268c 100644 --- a/mobile/src/terminal/terminal-webview-messages.ts +++ b/mobile/src/terminal/terminal-webview-messages.ts @@ -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' diff --git a/mobile/src/terminal/terminal-webview-text-zoom.test.ts b/mobile/src/terminal/terminal-webview-text-zoom.test.ts index aeb7785751c..09aeb82d3f7 100644 --- a/mobile/src/terminal/terminal-webview-text-zoom.test.ts +++ b/mobile/src/terminal/terminal-webview-text-zoom.test.ts @@ -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 = { diff --git a/mobile/src/terminal/terminal-webview-theme-injected.ts b/mobile/src/terminal/terminal-webview-theme-injected.ts new file mode 100644 index 00000000000..1f798fe337e --- /dev/null +++ b/mobile/src/terminal/terminal-webview-theme-injected.ts @@ -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; + } +` diff --git a/mobile/src/terminal/terminal-webview-webgl-recovery-injected.ts b/mobile/src/terminal/terminal-webview-webgl-recovery-injected.ts new file mode 100644 index 00000000000..8e4d27c2348 --- /dev/null +++ b/mobile/src/terminal/terminal-webview-webgl-recovery-injected.ts @@ -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(); + }); +` diff --git a/mobile/src/transport/rpc-response-shape.test.ts b/mobile/src/transport/rpc-response-shape.test.ts index 52ae58e621d..29a23d49cf9 100644 --- a/mobile/src/transport/rpc-response-shape.test.ts +++ b/mobile/src/transport/rpc-response-shape.test.ts @@ -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) + }) }) diff --git a/mobile/tsconfig.json b/mobile/tsconfig.json index cb352882776..754545ed727 100644 --- a/mobile/tsconfig.json +++ b/mobile/tsconfig.json @@ -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"], diff --git a/package.json b/package.json index c2af24f800a..e6870ba4102 100644 --- a/package.json +++ b/package.json @@ -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", diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml index b17bd6adb8b..b48ad9b4f29 100644 --- a/pnpm-lock.yaml +++ b/pnpm-lock.yaml @@ -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: diff --git a/src/cli/agent-context.test.ts b/src/cli/agent-context.test.ts new file mode 100644 index 00000000000..4574b795c00 --- /dev/null +++ b/src/cli/agent-context.test.ts @@ -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([]) + }) +}) diff --git a/src/cli/agent-context.ts b/src/cli/agent-context.ts new file mode 100644 index 00000000000..ca718cc0fe6 --- /dev/null +++ b/src/cli/agent-context.ts @@ -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') +} diff --git a/src/cli/args.test.ts b/src/cli/args.test.ts index 6755fca0a2b..f6f72111565 100644 --- a/src/cli/args.test.ts +++ b/src/cli/args.test.ts @@ -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 ', + allowedFlags: ['worktree', 'force'] + }, + { + path: ['repo', 'show'], + summary: 'Show a repo', + usage: 'orca repo show --repo ', + 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') + } + }) }) diff --git a/src/cli/args.ts b/src/cli/args.ts index 5700a17ebef..aa1289867f6 100644 --- a/src/cli/args.ts +++ b/src/cli/args.ts @@ -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, 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() @@ -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)) ) } } diff --git a/src/cli/command-suggestion.test.ts b/src/cli/command-suggestion.test.ts new file mode 100644 index 00000000000..b5c70e021c6 --- /dev/null +++ b/src/cli/command-suggestion.test.ts @@ -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([]) + }) +}) diff --git a/src/cli/command-suggestion.ts b/src/cli/command-suggestion.ts new file mode 100644 index 00000000000..ccc3712b40b --- /dev/null +++ b/src/cli/command-suggestion.ts @@ -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() + 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 } +} diff --git a/src/cli/dispatch.ts b/src/cli/dispatch.ts index 85faf69364f..24582318e74 100644 --- a/src/cli/dispatch.ts +++ b/src/cli/dispatch.ts @@ -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 { COMPUTER_HANDLERS, AGENT_HOOK_HANDLERS, DIAGNOSTICS_HANDLERS, + INTROSPECTION_HANDLERS, ENVIRONMENT_HANDLERS, LINEAR_HANDLERS, VM_HANDLERS @@ -74,6 +76,10 @@ function buildHandlers(): Map { 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 = new Set(HANDLERS.keys()) + export async function dispatch(commandPath: string[], ctx: HandlerContext): Promise { const handler = HANDLERS.get(commandPath.join(' ')) if (!handler) { diff --git a/src/cli/format-recovery.test.ts b/src/cli/format-recovery.test.ts new file mode 100644 index 00000000000..9646c32d85b --- /dev/null +++ b/src/cli/format-recovery.test.ts @@ -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') + }) +}) diff --git a/src/cli/format.ts b/src/cli/format.ts index e6b751d42eb..80c621418e8 100644 --- a/src/cli/format.ts +++ b/src/cli/format.ts @@ -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' && diff --git a/src/cli/handlers/introspection.ts b/src/cli/handlers/introspection.ts new file mode 100644 index 00000000000..92cef638e10 --- /dev/null +++ b/src/cli/handlers/introspection.ts @@ -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 = { + // 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)) + } +} diff --git a/src/cli/handlers/terminal.ts b/src/cli/handlers/terminal.ts index d329e1e9d96..fb2eefc3748 100644 --- a/src/cli/handlers/terminal.ts +++ b/src/cli/handlers/terminal.ts @@ -147,9 +147,7 @@ export const TERMINAL_HANDLERS: Record = { }) 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', { diff --git a/src/cli/help.ts b/src/cli/help.ts index 8c1cce60757..494e2969915 100644 --- a/src/cli/help.ts +++ b/src/cli/help.ts @@ -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 ] [--pairing-address ] [--mobile-pairing] [--no-pairing] [--project-root ] [--recipe-json] [--json] orca status [--json] orca diagnostics memory [--json] + orca agent-context [--json] orca environment add --name --pairing-code [--json] orca environment list [--json] orca environment show --environment [--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:') diff --git a/src/cli/index.test.ts b/src/cli/index.test.ts index 0ee5498bf85..2309b7a77f5 100644 --- a/src/cli/index.test.ts +++ b/src/cli/index.test.ts @@ -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() 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 + + 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 + + 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(() => {}) diff --git a/src/cli/index.ts b/src/cli/index.ts index e9c12859063..c35559123d1 100644 --- a/src/cli/index.ts +++ b/src/cli/index.ts @@ -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 }) diff --git a/src/cli/registry-parity.test.ts b/src/cli/registry-parity.test.ts new file mode 100644 index 00000000000..588a4e14d08 --- /dev/null +++ b/src/cli/registry-parity.test.ts @@ -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([]) + }) +}) diff --git a/src/cli/registry-parity.ts b/src/cli/registry-parity.ts new file mode 100644 index 00000000000..27c0130b555 --- /dev/null +++ b/src/cli/registry-parity.ts @@ -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 +): 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)) + } +} diff --git a/src/cli/runtime/envelope-schema.test.ts b/src/cli/runtime/envelope-schema.test.ts index 263bdf38096..0e83de3ab44 100644 --- a/src/cli/runtime/envelope-schema.test.ts +++ b/src/cli/runtime/envelope-schema.test.ts @@ -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) diff --git a/src/cli/runtime/types.ts b/src/cli/runtime/types.ts index 220d3e3aba5..ea347efe09f 100644 --- a/src/cli/runtime/types.ts +++ b/src/cli/runtime/types.ts @@ -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 } } diff --git a/src/cli/specs/core.ts b/src/cli/specs/core.ts index 1c9fdc4b63b..e4f2ffe9f91 100644 --- a/src/cli/specs/core.ts +++ b/src/cli/specs/core.ts @@ -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 [--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 ] [--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 ] [--json]', - allowedFlags: [...GLOBAL_FLAGS, 'terminal'], - examples: ['orca terminal focus --terminal term_abc123'] - }, { path: ['terminal', 'close'], summary: 'Close a terminal tab (kills PTY if running)', diff --git a/src/cli/specs/index.ts b/src/cli/specs/index.ts index df090fcebca..5521b39d7c5 100644 --- a/src/cli/specs/index.ts +++ b/src/cli/specs/index.ts @@ -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, diff --git a/src/cli/specs/introspection.ts b/src/cli/specs/introspection.ts new file mode 100644 index 00000000000..fda273df13f --- /dev/null +++ b/src/cli/specs/introspection.ts @@ -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'] + } +] diff --git a/src/cli/vocabulary-policy.test.ts b/src/cli/vocabulary-policy.test.ts new file mode 100644 index 00000000000..409a6304d09 --- /dev/null +++ b/src/cli/vocabulary-policy.test.ts @@ -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' }) + }) +}) diff --git a/src/cli/vocabulary-policy.ts b/src/cli/vocabulary-policy.ts new file mode 100644 index 00000000000..311d22c8c1a --- /dev/null +++ b/src/cli/vocabulary-policy.ts @@ -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 + canonical: string + allowlist: Set +} + +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 +} diff --git a/src/main/automations/service.test.ts b/src/main/automations/service.test.ts index 786ff017bb2..1c9c314b41b 100644 --- a/src/main/automations/service.test.ts +++ b/src/main/automations/service.test.ts @@ -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() diff --git a/src/main/automations/service.ts b/src/main/automations/service.ts index dc22550e7ec..91b70ff6088 100644 --- a/src/main/automations/service.ts +++ b/src/main/automations/service.ts @@ -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 { 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' - ) -} diff --git a/src/main/git/check-ignored-paths.test.ts b/src/main/git/check-ignored-paths.test.ts index 3338ee23027..398d7c18650 100644 --- a/src/main/git/check-ignored-paths.test.ts +++ b/src/main/git/check-ignored-paths.test.ts @@ -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() + }) }) diff --git a/src/main/git/check-ignored-paths.ts b/src/main/git/check-ignored-paths.ts index 4a03bbb3127..65e2a88f73d 100644 --- a/src/main/git/check-ignored-paths.ts +++ b/src/main/git/check-ignored-paths.ts @@ -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 { 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 { + if (relativePaths.length === 0) { + return [] + } const ignored = new Set() - 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) } } diff --git a/src/main/git/runner.ts b/src/main/git/runner.ts index bd541da3a93..9ef2e66fc86 100644 --- a/src/main/git/runner.ts +++ b/src/main/git/runner.ts @@ -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 diff --git a/src/main/memory/hydrate-local-pty-registry.test.ts b/src/main/memory/hydrate-local-pty-registry.test.ts index 0854fa2493b..714f18b3cba 100644 --- a/src/main/memory/hydrate-local-pty-registry.test.ts +++ b/src/main/memory/hydrate-local-pty-registry.test.ts @@ -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(): { + promise: Promise + resolve: (value: T) => void +} { + let resolve!: (value: T) => void + const promise = new Promise((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() diff --git a/src/main/memory/hydrate-local-pty-registry.ts b/src/main/memory/hydrate-local-pty-registry.ts index 4bb82763ec7..f6563049318 100644 --- a/src/main/memory/hydrate-local-pty-registry.ts +++ b/src/main/memory/hydrate-local-pty-registry.ts @@ -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() + // 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() + const resolvedRepoIds = new Set() - 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[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 { 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[] } + const template = persisted.automationRuns[0] + persisted.automationRuns = Array.from({ length: 250 }, (_, i) => { + const legacy: Record = { + ...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[] } + 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()) diff --git a/src/main/persistence.ts b/src/main/persistence.ts index 3cd0fe52a72..d4b68e7f4b4 100644 --- a/src/main/persistence.ts +++ b/src/main/persistence.ts @@ -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') } diff --git a/src/main/pi/agent-status-extension-source.test.ts b/src/main/pi/agent-status-extension-source.test.ts index f6532c94c16..09796786192 100644 --- a/src/main/pi/agent-status-extension-source.test.ts +++ b/src/main/pi/agent-status-extension-source.test.ts @@ -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' diff --git a/src/main/runtime/mobile-presence-lock.test.ts b/src/main/runtime/mobile-presence-lock.test.ts index a38084c5199..eddb3f299cf 100644 --- a/src/main/runtime/mobile-presence-lock.test.ts +++ b/src/main/runtime/mobile-presence-lock.test.ts @@ -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 diff --git a/src/main/runtime/orca-runtime.test.ts b/src/main/runtime/orca-runtime.test.ts index 090251dc8a0..2b515e230bb 100644 --- a/src/main/runtime/orca-runtime.test.ts +++ b/src/main/runtime/orca-runtime.test.ts @@ -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') diff --git a/src/main/runtime/orca-runtime.ts b/src/main/runtime/orca-runtime.ts index 421826028c4..6152a33c720 100644 --- a/src/main/runtime/orca-runtime.ts +++ b/src/main/runtime/orca-runtime.ts @@ -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 { 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 diff --git a/src/relay/git-handler-check-ignore.test.ts b/src/relay/git-handler-check-ignore.test.ts new file mode 100644 index 00000000000..678798bf748 --- /dev/null +++ b/src/relay/git-handler-check-ignore.test.ts @@ -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().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().mockRejectedValue(noMatches) + + await expect( + checkIgnoredPathsOp(git, { + worktreePath: '/repo', + paths: ['src/index.ts'] + }) + ).resolves.toEqual([]) + }) +}) diff --git a/src/relay/git-handler-check-ignore.ts b/src/relay/git-handler-check-ignore.ts new file mode 100644 index 00000000000..e458aba7af6 --- /dev/null +++ b/src/relay/git-handler-check-ignore.ts @@ -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 +): Promise { + 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 +} diff --git a/src/relay/git-handler-ops.ts b/src/relay/git-handler-ops.ts index 1ff93a4a23a..0bb862fbcea 100644 --- a/src/relay/git-handler-ops.ts +++ b/src/relay/git-handler-ops.ts @@ -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 diff --git a/src/relay/git-handler-status-ops.ts b/src/relay/git-handler-status-ops.ts index 34e96b906ad..4f48ec9b6da 100644 --- a/src/relay/git-handler-status-ops.ts +++ b/src/relay/git-handler-status-ops.ts @@ -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 -): Promise { - 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 - } -} diff --git a/src/relay/git-handler.ts b/src/relay/git-handler.ts index 533c06f27b7..67e78821d9a 100644 --- a/src/relay/git-handler.ts +++ b/src/relay/git-handler.ts @@ -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) { diff --git a/src/renderer/src/components/TaskPage.tsx b/src/renderer/src/components/TaskPage.tsx index 5e74b874b55..8d37800b8d9 100644 --- a/src/renderer/src/components/TaskPage.tsx +++ b/src/renderer/src/components/TaskPage.tsx @@ -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('updated') + const [jiraOrderDirection, setJiraOrderDirection] = useState('desc') + const [jiraPrioritiesBySite, setJiraPrioritiesBySite] = useState( + () => 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 { -
- {translate('auto.components.TaskPage.37e7ee311e', 'Key')} - {translate('auto.components.TaskPage.b1eaa18ace', 'Issue')} - {translate('auto.components.TaskPage.154b0fa623', 'Status')} - {translate('auto.components.TaskPage.c8d5bec5f7', 'Priority')} - - {translate('auto.components.TaskPage.d2a876ca53', 'Assignee')} - - {translate('auto.components.TaskPage.f362667d55', 'Updated')} - -
+
diff --git a/src/renderer/src/components/jira-issue-sorter.ts b/src/renderer/src/components/jira-issue-sorter.ts new file mode 100644 index 00000000000..a95680c1c34 --- /dev/null +++ b/src/renderer/src/components/jira-issue-sorter.ts @@ -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 + +// Why: Jira instances can use synonymous names for the same standard priority tier. +export const JIRA_PRIORITY_ORDER: Record = { + 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 + }) +} diff --git a/src/renderer/src/components/right-sidebar/ai-vault-original-pane-actions.ts b/src/renderer/src/components/right-sidebar/ai-vault-original-pane-actions.ts index 51646b559b7..86077e1c598 100644 --- a/src/renderer/src/components/right-sidebar/ai-vault-original-pane-actions.ts +++ b/src/renderer/src/components/right-sidebar/ai-vault-original-pane-actions.ts @@ -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 => { diff --git a/src/renderer/src/components/right-sidebar/ai-vault-original-pane-index.test.ts b/src/renderer/src/components/right-sidebar/ai-vault-original-pane-index.test.ts new file mode 100644 index 00000000000..127ec6553b7 --- /dev/null +++ b/src/renderer/src/components/right-sidebar/ai-vault-original-pane-index.test.ts @@ -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(count: number, makeValue: (index: number) => T) { + const reads = { value: 0 } + const record: Record = {} + 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) + ) + } + }) +}) diff --git a/src/renderer/src/components/right-sidebar/ai-vault-original-pane-index.ts b/src/renderer/src/components/right-sidebar/ai-vault-original-pane-index.ts new file mode 100644 index 00000000000..95079f43593 --- /dev/null +++ b/src/renderer/src/components/right-sidebar/ai-vault-original-pane-index.ts @@ -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 +type RetainedEntry = NonNullable +type SleepingEntry = NonNullable + +type ProviderIndex = Map +type AgentIndex = Map + +export type AiVaultOriginalPaneIndex = { + state: OriginalPaneState + liveByProvider: ProviderIndex + liveWithoutProviderByAgent: AgentIndex + retainedByProvider: ProviderIndex + retainedWithoutProviderByAgent: AgentIndex + sleepingByProvider: ProviderIndex +} + +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(index: Map, 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 = new Map() + const liveWithoutProviderByAgent: AgentIndex = new Map() + const retainedByProvider: ProviderIndex = new Map() + const retainedWithoutProviderByAgent: AgentIndex = new Map() + const sleepingByProvider: ProviderIndex = 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 +} diff --git a/src/renderer/src/components/right-sidebar/ai-vault-original-pane.ts b/src/renderer/src/components/right-sidebar/ai-vault-original-pane.ts index 48f76ac29e2..013684c7870 100644 --- a/src/renderer/src/components/right-sidebar/ai-vault-original-pane.ts +++ b/src/renderer/src/components/right-sidebar/ai-vault-original-pane.ts @@ -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[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 diff --git a/src/renderer/src/components/right-sidebar/file-explorer-runtime-owner-boundary.test.ts b/src/renderer/src/components/right-sidebar/file-explorer-runtime-owner-boundary.test.ts index 9282328cc49..19c8b2a9bee 100644 --- a/src/renderer/src/components/right-sidebar/file-explorer-runtime-owner-boundary.test.ts +++ b/src/renderer/src/components/right-sidebar/file-explorer-runtime-owner-boundary.test.ts @@ -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' diff --git a/src/renderer/src/components/right-sidebar/use-file-explorer-ignored-paths.ts b/src/renderer/src/components/right-sidebar/use-file-explorer-ignored-paths.ts new file mode 100644 index 00000000000..9f420b6aba0 --- /dev/null +++ b/src/renderer/src/components/right-sidebar/use-file-explorer-ignored-paths.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(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 + }) +} diff --git a/src/renderer/src/components/right-sidebar/useFileExplorerVisibleRowProjection.debounce.test.tsx b/src/renderer/src/components/right-sidebar/useFileExplorerVisibleRowProjection.debounce.test.tsx new file mode 100644 index 00000000000..b6dd71c8a05 --- /dev/null +++ b/src/renderer/src/components/right-sidebar/useFileExplorerVisibleRowProjection.debounce.test.tsx @@ -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']) + }) +}) diff --git a/src/renderer/src/components/right-sidebar/useFileExplorerVisibleRowProjection.test.ts b/src/renderer/src/components/right-sidebar/useFileExplorerVisibleRowProjection.test.ts index 600d4aa76a5..692ffebe729 100644 --- a/src/renderer/src/components/right-sidebar/useFileExplorerVisibleRowProjection.test.ts +++ b/src/renderer/src/components/right-sidebar/useFileExplorerVisibleRowProjection.test.ts @@ -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' diff --git a/src/renderer/src/components/right-sidebar/useFileExplorerVisibleRowProjection.ts b/src/renderer/src/components/right-sidebar/useFileExplorerVisibleRowProjection.ts index acda16138bf..813555af1c5 100644 --- a/src/renderer/src/components/right-sidebar/useFileExplorerVisibleRowProjection.ts +++ b/src/renderer/src/components/right-sidebar/useFileExplorerVisibleRowProjection.ts @@ -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 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(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]) diff --git a/src/renderer/src/components/settings/AccountsPane.tsx b/src/renderer/src/components/settings/AccountsPane.tsx index dbd4b752e6b..5268a890e36 100644 --- a/src/renderer/src/components/settings/AccountsPane.tsx +++ b/src/renderer/src/components/settings/AccountsPane.tsx @@ -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({ - accounts: [], - activeAccountId: null, - activeAccountIdsByRuntime: { host: null, wsl: {} } - }) + const [codexAccounts, setCodexAccounts] = + useState(emptyCodexAccountsState) const [codexAccountsLoaded, setCodexAccountsLoaded] = useState(false) const [codexAction, setCodexAction] = useState< 'idle' | 'adding' | `reauth:${string}` | `remove:${string}` | `select:${string | 'system'}` >('idle') - const [claudeAccounts, setClaudeAccounts] = useState({ - accounts: [], - activeAccountId: null, - activeAccountIdsByRuntime: { host: null, wsl: {} } - }) + const [claudeAccounts, setClaudeAccounts] = + useState(emptyClaudeAccountsState) const [claudeAction, setClaudeAction] = useState< 'idle' | 'adding' | `reauth:${string}` | `remove:${string}` | `select:${string | 'system'}` >('idle') - const [removeAccountId, setRemoveAccountId] = useState(null) - const [removeClaudeAccountId, setRemoveClaudeAccountId] = useState(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' ? ( onSort(value as JiraIssueSortColumn)}> + + + + + {columns.map((column) => ( + + {column.label} + + ))} + + + + + + + + {toggleDirectionLabel} + + + + + ) +} diff --git a/src/renderer/src/components/task-page-jira-sorting.test.ts b/src/renderer/src/components/task-page-jira-sorting.test.ts new file mode 100644 index 00000000000..767fbf7c1cb --- /dev/null +++ b/src/renderer/src/components/task-page-jira-sorting.test.ts @@ -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([ + ['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([ + ['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') + }) + }) +}) diff --git a/src/renderer/src/components/terminal-pane/layout-serialization.test.ts b/src/renderer/src/components/terminal-pane/layout-serialization.test.ts index f4237e507f0..7977196979e 100644 --- a/src/renderer/src/components/terminal-pane/layout-serialization.test.ts +++ b/src/renderer/src/components/terminal-pane/layout-serialization.test.ts @@ -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() } const restoredViewportBlankingPanesRef = { current: new Set() } @@ -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) }) diff --git a/src/renderer/src/components/terminal-pane/layout-serialization.ts b/src/renderer/src/components/terminal-pane/layout-serialization.ts index d47ced05b9b..5f581cb9e94 100644 --- a/src/renderer/src/components/terminal-pane/layout-serialization.ts +++ b/src/renderer/src/components/terminal-pane/layout-serialization.ts @@ -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) diff --git a/src/renderer/src/components/terminal-pane/pty-connection.test.ts b/src/renderer/src/components/terminal-pane/pty-connection.test.ts index 52457e170f7..fe1b2e51c30 100644 --- a/src/renderer/src/components/terminal-pane/pty-connection.test.ts +++ b/src/renderer/src/components/terminal-pane/pty-connection.test.ts @@ -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 diff --git a/src/renderer/src/components/terminal-pane/pty-connection.ts b/src/renderer/src/components/terminal-pane/pty-connection.ts index 19046aa0071..f93bba90068 100644 --- a/src/renderer/src/components/terminal-pane/pty-connection.ts +++ b/src/renderer/src/components/terminal-pane/pty-connection.ts @@ -103,6 +103,7 @@ import { resolveWindowsShellOverride } from '@/lib/pane-manager/windows-pty-compatibility' import { recordTerminalOutput } from '@/lib/pane-manager/pane-scroll' +import { ensureArabicShapingJoinerForText } from '@/lib/pane-manager/terminal-arabic-shaping-joiner' import { captureTerminalWriteScrollIntent, enforceTerminalWriteScrollIntent @@ -935,6 +936,7 @@ export function connectPanePty( manager: PaneManager, deps: PtyConnectionDeps ): PanePtyBinding { + const shouldRefreshForegroundSynchronously = (): boolean => !manager.hasWebglRenderer(pane.id) exposeE2eTerminalPtyOutputDebug() let disposed = false let connectFrame: number | null = null @@ -2278,7 +2280,9 @@ export function connectPanePty( // frame still has mouse-tracking/bracketed-paste armed, which silently // eats every click and keystroke against a dead transport — disarm the // modes now and arm the reveal-time wake. - replayIntoTerminal(pane, deps.replayingPanesRef, POST_REPLAY_MODE_RESET) + replayIntoTerminal(pane, deps.replayingPanesRef, POST_REPLAY_MODE_RESET, { + shouldRefreshViewportSynchronously: shouldRefreshForegroundSynchronously + }) hibernatedWakeTarget = { ptyId, record: sleepingRecordEntry.record } const pendingWakeMatches = pendingHibernatedWakeTarget?.ptyId === ptyId && @@ -4300,14 +4304,18 @@ export function connectPanePty( // Why: drain any queued background bytes BEFORE the replay paint, so the // scheduler's deferred drain cannot land older bytes on top of the replay. flushTerminalOutput(pane.terminal) - replayIntoTerminal(pane, deps.replayingPanesRef, data) + replayIntoTerminal(pane, deps.replayingPanesRef, data, { + shouldRefreshViewportSynchronously: shouldRefreshForegroundSynchronously + }) } const writeReplayDataAsync = (data: string): Promise => { // Why: WebGL must be rebuilt after xterm has parsed replay bytes, not // merely after the write was queued. flushTerminalOutput(pane.terminal) - return replayIntoTerminalAsync(pane, deps.replayingPanesRef, data) + return replayIntoTerminalAsync(pane, deps.replayingPanesRef, data, { + shouldRefreshViewportSynchronously: shouldRefreshForegroundSynchronously + }) } const reattachReplayResetSequence = (payload: string): string => { @@ -4742,7 +4750,9 @@ export function connectPanePty( modelRestoreSubscribedPtyId = null } - function beforeTerminalOutputWrite(): void { + function beforeTerminalOutputWrite(data: string): void { + // Why: shaping must register before xterm parses the RTL bytes that need it. + ensureArabicShapingJoinerForText(pane.terminal, data) recordTerminalOutput(pane.terminal) } @@ -5004,6 +5014,9 @@ export function connectPanePty( foregroundRenderRefreshNeeded), followupForegroundRefresh: nativeWindowsCursorRestore || nativeWindowsInPlaceRewriteFollowup, + // Why: xterm already queued a WebGL frame while parsing this chunk; + // merge the repair into it instead of rendering the full grid twice. + shouldRefreshForegroundSynchronously, onParsed: onParsedAtlasRecovery, stripTransientCursorShows: shouldProtectNativeWindowsSynchronizedOutput && foreground, coalesceForeground: synchronizedForegroundOutput && synchronizedOutputEnded, diff --git a/src/renderer/src/components/terminal-pane/replay-guard.test.ts b/src/renderer/src/components/terminal-pane/replay-guard.test.ts index c26acbfca7c..e69194db7cc 100644 --- a/src/renderer/src/components/terminal-pane/replay-guard.test.ts +++ b/src/renderer/src/components/terminal-pane/replay-guard.test.ts @@ -6,6 +6,7 @@ import { replayIntoTerminalAsync, type ReplayingPanesRef } from './replay-guard' +import { configureLazyArabicShapingJoiner } from '@/lib/pane-manager/terminal-arabic-shaping-joiner' const mocks = vi.hoisted(() => ({ recordRendererCrashBreadcrumb: vi.fn() @@ -41,6 +42,7 @@ type FakeTerminal = { _core: { refresh: (start: number, end: number, sync?: boolean) => void } + refresh: (start: number, end: number) => void /** Flush all pending xterm write callbacks, simulating parse completion. */ flush: () => void } @@ -60,6 +62,7 @@ function makeFakePane(paneId: number): { pane: ManagedPane; terminal: FakeTermin _core: { refresh() {} }, + refresh() {}, write(data: string, cb?: () => void) { terminal.lastData.push(data) if (cb) { @@ -83,6 +86,53 @@ describe('replay-guard', () => { expect(isPaneReplaying(ref, 1)).toBe(false) }) + it('registers Arabic shaping before replay bytes are written', () => { + const ref = makeRef() + const { pane, terminal } = makeFakePane(1) + const events: string[] = [] + const joinerTerminal = terminal as FakeTerminal & { + registerCharacterJoiner: (handler: (text: string) => [number, number][]) => number + deregisterCharacterJoiner: (joinerId: number) => void + } + joinerTerminal.registerCharacterJoiner = () => { + events.push('register') + return 5 + } + joinerTerminal.deregisterCharacterJoiner = () => undefined + terminal.write = (data: string, callback?: () => void) => { + events.push(`write:${data}`) + if (callback) { + terminal.pendingCallbacks.push(callback) + } + } + const cleanup = configureLazyArabicShapingJoiner(joinerTerminal as never, () => true) + + replayIntoTerminal(pane, ref, 'مرحبا') + + expect(events).toEqual(['register', 'write:مرحبا']) + cleanup() + }) + + it('still replays RTL bytes when joiner registration fails', () => { + const ref = makeRef() + const { pane, terminal } = makeFakePane(1) + const joinerTerminal = terminal as FakeTerminal & { + registerCharacterJoiner: () => number + deregisterCharacterJoiner: () => void + } + joinerTerminal.registerCharacterJoiner = () => { + throw new Error('terminal disposed') + } + joinerTerminal.deregisterCharacterJoiner = () => undefined + configureLazyArabicShapingJoiner(joinerTerminal as never, () => true) + + replayIntoTerminal(pane, ref, 'مرحبا') + + expect(terminal.lastData).toEqual(['مرحبا']) + terminal.flush() + expect(isPaneReplaying(ref, pane.id)).toBe(false) + }) + it('is replaying between write dispatch and xterm parse completion', () => { const ref = makeRef() const { pane, terminal } = makeFakePane(1) @@ -168,9 +218,9 @@ describe('replay-guard', () => { try { const ref = makeRef() const { pane } = makeFakePane(1) - replayIntoTerminal(pane, ref, '\x1b[2J\x1b[3J\x1b[H', 400) - replayIntoTerminal(pane, ref, 'scrollback bytes', 400) - replayIntoTerminal(pane, ref, '--- session restored ---', 400) + replayIntoTerminal(pane, ref, '\x1b[2J\x1b[3J\x1b[H', { stallCheckMs: 400 }) + replayIntoTerminal(pane, ref, 'scrollback bytes', { stallCheckMs: 400 }) + replayIntoTerminal(pane, ref, '--- session restored ---', { stallCheckMs: 400 }) expect(isPaneReplaying(ref, 1)).toBe(true) // Never flush — the probe write never parses; the wedged release fires @@ -189,8 +239,8 @@ describe('replay-guard', () => { try { const ref = makeRef() const { pane, terminal } = makeFakePane(1) - replayIntoTerminal(pane, ref, 'a', 400) - replayIntoTerminal(pane, ref, 'b', 400) + replayIntoTerminal(pane, ref, 'a', { stallCheckMs: 400 }) + replayIntoTerminal(pane, ref, 'b', { stallCheckMs: 400 }) terminal.flush() expect(isPaneReplaying(ref, 1)).toBe(false) @@ -210,7 +260,7 @@ describe('replay-guard', () => { const ref = makeRef() const { pane } = makeFakePane(1) let resolved = false - const promise = replayIntoTerminalAsync(pane, ref, 'x', 400).then(() => { + const promise = replayIntoTerminalAsync(pane, ref, 'x', { stallCheckMs: 400 }).then(() => { resolved = true }) expect(isPaneReplaying(ref, 1)).toBe(true) @@ -257,6 +307,42 @@ describe('replay-guard', () => { globalThis.cancelAnimationFrame = originalCancelAnimationFrame } }) + + it('coalesces WebGL replay refreshes and rechecks before the follow-up', () => { + const scheduledFrames: FrameRequestCallback[] = [] + const originalRequestAnimationFrame = globalThis.requestAnimationFrame + const originalCancelAnimationFrame = globalThis.cancelAnimationFrame + globalThis.requestAnimationFrame = ((callback: FrameRequestCallback) => { + scheduledFrames.push(callback) + return scheduledFrames.length + }) as typeof requestAnimationFrame + globalThis.cancelAnimationFrame = (() => {}) as typeof cancelAnimationFrame + + try { + const ref = makeRef() + const { pane, terminal } = makeFakePane(1) + const synchronousRefresh = vi.fn() + const debouncedRefresh = vi.fn() + terminal._core.refresh = synchronousRefresh + terminal.refresh = debouncedRefresh + let webglLive = true + + replayIntoTerminal(pane, ref, 'snapshot bytes', { + shouldRefreshViewportSynchronously: () => !webglLive + }) + terminal.flush() + + expect(debouncedRefresh).toHaveBeenCalledWith(0, 23) + expect(synchronousRefresh).not.toHaveBeenCalled() + webglLive = false + scheduledFrames[0]?.(16) + + expect(synchronousRefresh).toHaveBeenCalledWith(0, 23, true) + } finally { + globalThis.requestAnimationFrame = originalRequestAnimationFrame + globalThis.cancelAnimationFrame = originalCancelAnimationFrame + } + }) }) describe('replay-guard stall handling (probe-certified release)', () => { @@ -269,7 +355,7 @@ describe('replay-guard stall handling (probe-certified release)', () => { const ref = makeRef() const { pane, terminal } = makeFakePane(1) - replayIntoTerminal(pane, ref, 'slow but alive', 1_000) + replayIntoTerminal(pane, ref, 'slow but alive', { stallCheckMs: 1_000 }) expect(isPaneReplaying(ref, 1)).toBe(true) // Stall check fires: an empty probe write is enqueued behind the replay. @@ -298,7 +384,7 @@ describe('replay-guard stall handling (probe-certified release)', () => { const ref = makeRef() const { pane, terminal } = makeFakePane(1) - replayIntoTerminal(pane, ref, 'restored bytes', 1_000) + replayIntoTerminal(pane, ref, 'restored bytes', { stallCheckMs: 1_000 }) terminal.pendingCallbacks.shift() // xterm lost the replay's completion vi.advanceTimersByTime(1_000) // stall check → probe enqueued @@ -322,7 +408,7 @@ describe('replay-guard stall handling (probe-certified release)', () => { const ref = makeRef() const { pane } = makeFakePane(1) - replayIntoTerminal(pane, ref, 'restored bytes', 1_000) + replayIntoTerminal(pane, ref, 'restored bytes', { stallCheckMs: 1_000 }) vi.advanceTimersByTime(1_000) // stall check → probe enqueued expect(isPaneReplaying(ref, 1)).toBe(true) @@ -346,7 +432,7 @@ describe('replay-guard stall handling (probe-certified release)', () => { const ref = makeRef() const { pane, terminal } = makeFakePane(1) - replayIntoTerminal(pane, ref, 'restored bytes', 1_000) + replayIntoTerminal(pane, ref, 'restored bytes', { stallCheckMs: 1_000 }) terminal.write = () => { throw new Error('terminal disposed') } @@ -368,8 +454,8 @@ describe('replay-guard stall handling (probe-certified release)', () => { const ref = makeRef() const { pane, terminal } = makeFakePane(1) - replayIntoTerminal(pane, ref, 'lost completion', 1_000) - replayIntoTerminal(pane, ref, 'healthy completion', 60_000) + replayIntoTerminal(pane, ref, 'lost completion', { stallCheckMs: 1_000 }) + replayIntoTerminal(pane, ref, 'healthy completion', { stallCheckMs: 60_000 }) terminal.pendingCallbacks.shift() // drop only the first completion vi.advanceTimersByTime(1_000) // first engagement's probe enqueued @@ -389,7 +475,7 @@ describe('replay-guard stall handling (probe-certified release)', () => { const ref = makeRef() const { pane, terminal } = makeFakePane(1) - replayIntoTerminal(pane, ref, 'healthy', 1_000) + replayIntoTerminal(pane, ref, 'healthy', { stallCheckMs: 1_000 }) terminal.flush() expect(isPaneReplaying(ref, 1)).toBe(false) @@ -405,7 +491,9 @@ describe('replay-guard stall handling (probe-certified release)', () => { const ref = makeRef() const { pane } = makeFakePane(1) - const replayDone = replayIntoTerminalAsync(pane, ref, 'restored bytes', 1_000) + const replayDone = replayIntoTerminalAsync(pane, ref, 'restored bytes', { + stallCheckMs: 1_000 + }) let resolved = false void replayDone.then(() => { resolved = true diff --git a/src/renderer/src/components/terminal-pane/replay-guard.ts b/src/renderer/src/components/terminal-pane/replay-guard.ts index 6bf821ea62d..fa2cc1913be 100644 --- a/src/renderer/src/components/terminal-pane/replay-guard.ts +++ b/src/renderer/src/components/terminal-pane/replay-guard.ts @@ -1,6 +1,7 @@ import type { ManagedPane } from '@/lib/pane-manager/pane-manager' import { writeForegroundTerminalChunk } from '@/lib/pane-manager/pane-terminal-foreground-render-settle' import { recordRendererCrashBreadcrumb } from '@/lib/crash-breadcrumb-recorder' +import { ensureArabicShapingJoinerForText } from '@/lib/pane-manager/terminal-arabic-shaping-joiner' // Why: xterm.js auto-responds to terminal query sequences (DA1 `CSI c`, // DECRQM `CSI ? Ps $ p`, OSC 10/11 color queries, focus events, CPR) by @@ -52,6 +53,11 @@ export type ReplayingPanesRef = React.RefObject> // While the probe is pending (slow-but-alive replay), the guard HOLDS. const REPLAY_GUARD_STALL_CHECK_MS = 10_000 +type ReplayTerminalOptions = { + shouldRefreshViewportSynchronously?: () => boolean + stallCheckMs?: number +} + export function isPaneReplaying(ref: ReplayingPanesRef, paneId: number): boolean { return (ref.current.get(paneId) ?? 0) > 0 } @@ -130,22 +136,24 @@ export function replayIntoTerminal( pane: ManagedPane, replayingPanesRef: ReplayingPanesRef, data: string, - stallCheckMs: number = REPLAY_GUARD_STALL_CHECK_MS + options: ReplayTerminalOptions = {} ): void { if (!data) { return } + ensureArabicShapingJoinerForText(pane.terminal, data) const releaseParsed = engageReplayGuard( replayingPanesRef.current, pane.id, pane.terminal, - stallCheckMs + options.stallCheckMs ?? REPLAY_GUARD_STALL_CHECK_MS ) // Why: hidden/snapshot replay bypasses the live foreground write path, but // WebGL/canvas renderers still need a post-parse repaint to drop stale cells. writeForegroundTerminalChunk(pane.terminal, data, { forceViewportRefresh: true, followupViewportRefresh: true, + shouldRefreshViewportSynchronously: options.shouldRefreshViewportSynchronously, onParsed: releaseParsed }) } @@ -154,11 +162,12 @@ export function replayIntoTerminalAsync( pane: ManagedPane, replayingPanesRef: ReplayingPanesRef, data: string, - stallCheckMs: number = REPLAY_GUARD_STALL_CHECK_MS + options: ReplayTerminalOptions = {} ): Promise { if (!data) { return Promise.resolve() } + ensureArabicShapingJoinerForText(pane.terminal, data) return new Promise((resolve) => { // Why resolve on either release path: callers await this to sequence // restore steps; a lost write completion must not hang the restore chain. @@ -166,12 +175,13 @@ export function replayIntoTerminalAsync( replayingPanesRef.current, pane.id, pane.terminal, - stallCheckMs, + options.stallCheckMs ?? REPLAY_GUARD_STALL_CHECK_MS, resolve ) writeForegroundTerminalChunk(pane.terminal, data, { forceViewportRefresh: true, followupViewportRefresh: true, + shouldRefreshViewportSynchronously: options.shouldRefreshViewportSynchronously, onParsed: releaseParsed }) }) diff --git a/src/renderer/src/components/terminal-pane/terminal-fit-restore.ts b/src/renderer/src/components/terminal-pane/terminal-fit-restore.ts index bdf152a35b9..54738f59021 100644 --- a/src/renderer/src/components/terminal-pane/terminal-fit-restore.ts +++ b/src/renderer/src/components/terminal-pane/terminal-fit-restore.ts @@ -1,4 +1,5 @@ import type { GlobalSettings } from '../../../../shared/types' +import { mapWithConcurrency } from '../../../../shared/map-with-concurrency' import { callRuntimeRpc } from '@/runtime/runtime-rpc-client' import { getRemoteRuntimePtyEnvironmentId, @@ -7,6 +8,13 @@ import { type TerminalFitRestoreSettings = Pick | undefined +// Why: "take back all terminals" can target a phone that controls hundreds of +// PTYs. Fanning out one IPC/RPC reclaim per PTY unbounded would burst the +// runtime transport; cap the in-flight reclaims so a huge session degrades to +// steady throughput instead of a thundering herd. Each reclaim is a short +// round-trip, so a modest pool keeps latency low without overwhelming it. +const RESTORE_FIT_CONCURRENCY = 8 + const restoreFailedResult = (): { restored: boolean } => { // Why: terminal fit restore is best-effort when mobile/remote transports disappear. return { restored: false } @@ -37,8 +45,8 @@ export async function restoreTerminalFitsToDesktop( settings: TerminalFitRestoreSettings ): Promise { const uniquePtyIds = [...new Set(ptyIds)] - const results = await Promise.all( - uniquePtyIds.map((ptyId) => restoreTerminalFitToDesktop(ptyId, settings)) + const results = await mapWithConcurrency(uniquePtyIds, RESTORE_FIT_CONCURRENCY, (ptyId) => + restoreTerminalFitToDesktop(ptyId, settings) ) return results.some(Boolean) } diff --git a/src/renderer/src/components/workspace-cleanup/WorkspaceCleanupDialog.tsx b/src/renderer/src/components/workspace-cleanup/WorkspaceCleanupDialog.tsx index ba50c8dc435..f5a16a872b9 100644 --- a/src/renderer/src/components/workspace-cleanup/WorkspaceCleanupDialog.tsx +++ b/src/renderer/src/components/workspace-cleanup/WorkspaceCleanupDialog.tsx @@ -617,6 +617,28 @@ export default function WorkspaceCleanupDialog(): React.JSX.Element { } removalInFlightRef.current = false }, + onLateResult: (result) => { + if (!mountedRef.current) { + return + } + setRowFailures((current) => { + const next = { ...current } + for (const id of result.removedIds) { + delete next[id] + } + for (const failure of result.failures) { + next[failure.worktreeId] = failure.message + } + return next + }) + setSelectedIds((current) => { + const next = new Set(current) + for (const id of result.removedIds) { + next.delete(id) + } + return next + }) + }, onError: () => { for (const candidate of removableCandidates) { clearWorktreeDeleteState(candidate.worktreeId) diff --git a/src/renderer/src/components/workspace-cleanup/workspace-cleanup-background-removal-late-settlement.test.ts b/src/renderer/src/components/workspace-cleanup/workspace-cleanup-background-removal-late-settlement.test.ts new file mode 100644 index 00000000000..2198bf562a0 --- /dev/null +++ b/src/renderer/src/components/workspace-cleanup/workspace-cleanup-background-removal-late-settlement.test.ts @@ -0,0 +1,92 @@ +import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest' +import { toast } from 'sonner' +import { startWorkspaceCleanupBackgroundRemoval } from './workspace-cleanup-background-removal' +import { makeCandidate } from './workspace-cleanup-presentation-fixtures' + +vi.mock('sonner', () => ({ + toast: { + error: vi.fn(), + success: vi.fn() + } +})) + +async function settleBackgroundRemoval(): Promise { + for (let index = 0; index < 10; index += 1) { + await Promise.resolve() + } +} + +describe('workspace cleanup late settlement reconciliation', () => { + beforeEach(() => { + vi.clearAllMocks() + vi.useFakeTimers() + }) + + afterEach(() => { + vi.useRealTimers() + }) + + it('uses a post-grace child success that arrives before its parent is considered', async () => { + const parent = makeCandidate({ + worktreeId: 'repo-1::/repo/p', + displayName: 'parent', + branch: 'parent', + path: '/repo/p' + }) + const child = makeCandidate({ + worktreeId: 'repo-1::/repo/p/child-long', + displayName: 'child', + branch: 'child', + path: '/repo/p/child-long' + }) + const unrelated = makeCandidate({ + worktreeId: 'repo-1::/repo/other', + displayName: 'other', + branch: 'other', + path: '/repo/other' + }) + let resolveChild: (result: { removedIds: string[]; failures: [] }) => void = () => {} + let resolveUnrelated: (result: { removedIds: string[]; failures: [] }) => void = () => {} + const removeCandidates = vi + .fn() + .mockImplementationOnce( + () => + new Promise<{ removedIds: string[]; failures: [] }>((resolve) => { + resolveChild = resolve + }) + ) + .mockImplementationOnce( + () => + new Promise<{ removedIds: string[]; failures: [] }>((resolve) => { + resolveUnrelated = resolve + }) + ) + .mockResolvedValueOnce({ removedIds: [parent.worktreeId], failures: [] }) + const onResult = vi.fn() + + startWorkspaceCleanupBackgroundRemoval({ + candidates: [parent, unrelated, child], + removeCandidates, + onProgress: vi.fn(), + onResult, + removalTimeoutMs: 5, + removalSettlementGraceMs: 5 + }) + + await vi.advanceTimersByTimeAsync(10) + expect(removeCandidates).toHaveBeenCalledTimes(2) + resolveChild({ removedIds: [child.worktreeId], failures: [] }) + await settleBackgroundRemoval() + resolveUnrelated({ removedIds: [unrelated.worktreeId], failures: [] }) + await settleBackgroundRemoval() + + expect(removeCandidates).toHaveBeenNthCalledWith(3, [parent.worktreeId], { + approvedCandidates: [parent] + }) + expect(onResult).toHaveBeenCalledWith({ + removedIds: [child.worktreeId, unrelated.worktreeId, parent.worktreeId], + failures: [] + }) + expect(toast.error).not.toHaveBeenCalled() + }) +}) diff --git a/src/renderer/src/components/workspace-cleanup/workspace-cleanup-background-removal.test.ts b/src/renderer/src/components/workspace-cleanup/workspace-cleanup-background-removal.test.ts index abe1c5ab396..238cc7bd6cb 100644 --- a/src/renderer/src/components/workspace-cleanup/workspace-cleanup-background-removal.test.ts +++ b/src/renderer/src/components/workspace-cleanup/workspace-cleanup-background-removal.test.ts @@ -322,45 +322,217 @@ describe('startWorkspaceCleanupBackgroundRemoval', () => { ) }) - it('times out a stalled row removal and continues reporting progress', async () => { + it('uses a success confirmed after the initial timeout and proceeds with its parent', async () => { vi.useFakeTimers() - const candidate = makeCandidate() - const onProgress = vi.fn() + const parent = makeCandidate({ + worktreeId: 'repo-1::/repo/parent', + displayName: 'parent', + branch: 'parent', + path: '/repo/parent' + }) + const child = makeCandidate({ + worktreeId: 'repo-1::/repo/parent/child', + displayName: 'child', + branch: 'child', + path: '/repo/parent/child' + }) + let resolveChild: (result: { removedIds: string[]; failures: [] }) => void = () => {} + const removeCandidates = vi + .fn() + .mockImplementationOnce( + () => + new Promise<{ removedIds: string[]; failures: [] }>((resolve) => { + resolveChild = resolve + }) + ) + .mockResolvedValueOnce({ removedIds: [parent.worktreeId], failures: [] }) const onResult = vi.fn() startWorkspaceCleanupBackgroundRemoval({ - candidates: [candidate], - removeCandidates: vi.fn( - () => - new Promise< - Awaited> - >(() => undefined) - ), - onProgress, + candidates: [parent, child], + removeCandidates, + onProgress: vi.fn(), onResult, - removalTimeoutMs: 5 + removalTimeoutMs: 5, + removalSettlementGraceMs: 5 }) await vi.advanceTimersByTimeAsync(5) + resolveChild({ removedIds: [child.worktreeId], failures: [] }) await settleBackgroundRemoval() + expect(removeCandidates).toHaveBeenNthCalledWith(2, [parent.worktreeId], { + approvedCandidates: [parent] + }) + expect(onResult).toHaveBeenCalledWith({ + removedIds: [child.worktreeId, parent.worktreeId], + failures: [] + }) + expect(toast.error).not.toHaveBeenCalled() + }) + + it('reports a definitive late failure and skips its parent', async () => { + vi.useFakeTimers() + const parent = makeCandidate({ + worktreeId: 'repo-1::C:\\repo\\parent', + displayName: 'parent', + branch: 'parent', + path: 'C:\\repo\\parent' + }) + const child = makeCandidate({ + worktreeId: 'repo-1::C:\\repo\\parent\\child', + displayName: 'child', + branch: 'child', + path: 'C:\\repo\\parent\\child' + }) + let rejectChild: (error: Error) => void = () => {} + const removeCandidates = vi.fn( + () => + new Promise<{ removedIds: string[]; failures: [] }>((_resolve, reject) => { + rejectChild = reject + }) + ) + const onResult = vi.fn() + + startWorkspaceCleanupBackgroundRemoval({ + candidates: [parent, child], + removeCandidates, + onProgress: vi.fn(), + onResult, + removalTimeoutMs: 5, + removalSettlementGraceMs: 5 + }) + + await vi.advanceTimersByTimeAsync(5) + rejectChild(new Error('remote removal failed')) + await settleBackgroundRemoval() + + expect(removeCandidates).toHaveBeenCalledTimes(1) + expect(onResult).toHaveBeenCalledWith({ + removedIds: [], + failures: [ + { + worktreeId: child.worktreeId, + displayName: child.displayName, + message: 'remote removal failed' + }, + { + worktreeId: parent.worktreeId, + displayName: parent.displayName, + message: 'Skipped because a nested workspace could not be removed.' + } + ] + }) + }) + + it('reports a timeout, skips its parent, then reports the authoritative result', async () => { + vi.useFakeTimers() + const parent = makeCandidate({ + worktreeId: 'repo-1::/repo/parent', + displayName: 'parent', + branch: 'parent', + path: '/repo/parent' + }) + const child = makeCandidate({ + worktreeId: 'repo-1::/repo/parent/child', + displayName: 'child', + branch: 'child', + path: '/repo/parent/child' + }) + let resolveChild: (result: { removedIds: string[]; failures: [] }) => void = () => {} + const removeCandidates = vi.fn( + () => + new Promise<{ removedIds: string[]; failures: [] }>((resolve) => { + resolveChild = resolve + }) + ) + const onProgress = vi.fn() + const onResult = vi.fn() + const onLateResult = vi.fn() + + startWorkspaceCleanupBackgroundRemoval({ + candidates: [parent, child], + removeCandidates, + onProgress, + onResult, + onLateResult, + removalTimeoutMs: 5, + removalSettlementGraceMs: 5 + }) + + await vi.advanceTimersByTimeAsync(5) + expect(onResult).not.toHaveBeenCalled() + await vi.advanceTimersByTimeAsync(5) + await settleBackgroundRemoval() + + expect(removeCandidates).toHaveBeenCalledTimes(1) expect(onProgress).toHaveBeenLastCalledWith({ - totalCount: 1, - processedCount: 1, + totalCount: 2, + processedCount: 2, removedCount: 0, - failedCount: 1 + failedCount: 2 }) expect(onResult).toHaveBeenCalledWith({ removedIds: [], failures: [ { - worktreeId: candidate.worktreeId, - displayName: candidate.displayName, + worktreeId: child.worktreeId, + displayName: child.displayName, message: - 'Removing alpha is taking longer than expected. It will keep running in the background.' + 'Removing child is taking longer than expected. It will keep running in the background.' + }, + { + worktreeId: parent.worktreeId, + displayName: parent.displayName, + message: 'Skipped because a nested workspace could not be removed.' } ] }) + + resolveChild({ removedIds: [child.worktreeId], failures: [] }) + await settleBackgroundRemoval() + + expect(removeCandidates).toHaveBeenCalledTimes(1) + expect(onResult).toHaveBeenCalledTimes(1) + expect(onLateResult).toHaveBeenCalledWith({ + removedIds: [child.worktreeId], + failures: [] + }) + expect(toast.success).toHaveBeenLastCalledWith('Removed workspaces: 1') + }) + + it('reports an authoritative rejection after the timeout result', async () => { + vi.useFakeTimers() + const candidate = makeCandidate() + let rejectRemoval: (error: Error) => void = () => {} + const onLateResult = vi.fn() + + startWorkspaceCleanupBackgroundRemoval({ + candidates: [candidate], + removeCandidates: vi.fn( + () => + new Promise<{ removedIds: string[]; failures: [] }>((_resolve, reject) => { + rejectRemoval = reject + }) + ), + onProgress: vi.fn(), + onLateResult, + removalTimeoutMs: 5, + removalSettlementGraceMs: 5 + }) + + await vi.advanceTimersByTimeAsync(10) + rejectRemoval(new Error('remote removal failed')) + await settleBackgroundRemoval() + + expect(onLateResult).toHaveBeenCalledWith({ + removedIds: [], + failures: [expect.objectContaining({ message: 'remote removal failed' })] + }) + expect(toast.error).toHaveBeenLastCalledWith( + 'Workspaces not removed: 1', + expect.objectContaining({ description: 'remote removal failed' }) + ) }) it('keeps removal outcome toasts when the result callback throws', async () => { diff --git a/src/renderer/src/components/workspace-cleanup/workspace-cleanup-background-removal.ts b/src/renderer/src/components/workspace-cleanup/workspace-cleanup-background-removal.ts index 9823aa04033..cb45bb5849a 100644 --- a/src/renderer/src/components/workspace-cleanup/workspace-cleanup-background-removal.ts +++ b/src/renderer/src/components/workspace-cleanup/workspace-cleanup-background-removal.ts @@ -10,8 +10,14 @@ import type { WorkspaceCleanupRemoveResult } from '@/store/slices/workspace-cleanup' import { translate } from '@/i18n/i18n' +import { + getWorkspaceCleanupTimeoutFailure, + trackWorkspaceCleanupLateSettlement, + waitForWorkspaceCleanupRemovalWithTimeout +} from './workspace-cleanup-removal-settlement' const DEFAULT_WORKSPACE_CLEANUP_REMOVAL_TIMEOUT_MS = 120_000 +const DEFAULT_WORKSPACE_CLEANUP_SETTLEMENT_GRACE_MS = 5_000 export type WorkspaceCleanupRemovalProgress = { totalCount: number @@ -28,12 +34,16 @@ export type WorkspaceCleanupBackgroundRemovalArgs = { ) => Promise onProgress: (progress: WorkspaceCleanupRemovalProgress) => void onResult?: (result: WorkspaceCleanupRemoveResult) => void + // Why: the timeout is provisional because renderer IPC cannot be cancelled; + // consumers need the eventual outcome to replace stale timeout UI. + onLateResult?: (result: WorkspaceCleanupRemoveResult) => void onError?: (error: unknown) => void // Why: a row can fail before its removal starts (preflight failure or a // skipped nested workspace); report it now so its queued overlay can clear // instead of waiting for the whole batch to settle. onRowFailed?: (failure: WorkspaceCleanupFailure) => void removalTimeoutMs?: number + removalSettlementGraceMs?: number } export function startWorkspaceCleanupBackgroundRemoval({ @@ -41,9 +51,11 @@ export function startWorkspaceCleanupBackgroundRemoval({ removeCandidates, onProgress, onResult, + onLateResult, onError, onRowFailed, - removalTimeoutMs = DEFAULT_WORKSPACE_CLEANUP_REMOVAL_TIMEOUT_MS + removalTimeoutMs = DEFAULT_WORKSPACE_CLEANUP_REMOVAL_TIMEOUT_MS, + removalSettlementGraceMs = DEFAULT_WORKSPACE_CLEANUP_SETTLEMENT_GRACE_MS }: WorkspaceCleanupBackgroundRemovalArgs): void { if (candidates.length === 0) { try { @@ -58,6 +70,7 @@ export function startWorkspaceCleanupBackgroundRemoval({ const removedIds: string[] = [] const failures: WorkspaceCleanupFailure[] = [] const failedCandidates: WorkspaceCleanupCandidate[] = [] + const detachLateResultReconcilers: (() => void)[] = [] let processedCount = 0 const emitProgress = (): void => { @@ -109,11 +122,46 @@ export function startWorkspaceCleanupBackgroundRemoval({ continue } try { - const result = await withWorkspaceCleanupRemovalTimeout( + const outcome = await waitForWorkspaceCleanupRemovalWithTimeout( removeCandidates([candidate.worktreeId], { approvedCandidates: [candidate] }), - candidate, - removalTimeoutMs + removalTimeoutMs, + removalSettlementGraceMs ) + if (outcome.status === 'rejected') { + throw outcome.error + } + if (outcome.status === 'unresolved') { + const timeoutFailure = getWorkspaceCleanupTimeoutFailure(candidate) + failedCandidates.push(candidate) + reportFailures([timeoutFailure]) + // Why: renderer IPC cannot be cancelled at the timeout boundary. Keep + // its authoritative settlement without letting an unknown child + // outcome permit deletion of an ancestor. + detachLateResultReconcilers.push( + trackWorkspaceCleanupLateSettlement( + outcome.settlement, + candidate, + (lateResult) => { + const timeoutIndex = failures.indexOf(timeoutFailure) + if (timeoutIndex >= 0) { + failures.splice(timeoutIndex, 1) + } + removedIds.push(...lateResult.removedIds) + reportFailures(lateResult.failures) + if (lateResult.failures.length === 0) { + const failedCandidateIndex = failedCandidates.indexOf(candidate) + if (failedCandidateIndex >= 0) { + failedCandidates.splice(failedCandidateIndex, 1) + } + } + emitProgress() + }, + (lateResult) => reportLateWorkspaceCleanupResult(lateResult, onLateResult) + ) + ) + continue + } + const result = outcome.result removedIds.push(...result.removedIds) reportFailures(result.failures) if (result.failures.length > 0) { @@ -134,6 +182,9 @@ export function startWorkspaceCleanupBackgroundRemoval({ } } + for (const detach of detachLateResultReconcilers) { + detach() + } const result = { removedIds, failures } try { onResult?.(result) @@ -141,33 +192,11 @@ export function startWorkspaceCleanupBackgroundRemoval({ console.error('Workspace cleanup result callback failed', callbackError) } - if (result.removedIds.length > 0) { - toast.success( - translate( - 'auto.components.workspace.cleanup.backgroundRemoval.removed', - 'Removed workspaces: {{value0}}', - { - value0: result.removedIds.length - } - ) - ) - } - - if (result.failures.length > 0) { - toast.error( - translate( - 'auto.components.workspace.cleanup.backgroundRemoval.failed', - 'Workspaces not removed: {{value0}}', - { - value0: result.failures.length - } - ), - { - description: result.failures.map((failure) => failure.message).join('; ') - } - ) - } + showWorkspaceCleanupRemovalResultToasts(result) })().catch((error: unknown) => { + for (const detach of detachLateResultReconcilers) { + detach() + } onError?.(error) toast.error( translate( @@ -181,40 +210,37 @@ export function startWorkspaceCleanupBackgroundRemoval({ }) } -async function withWorkspaceCleanupRemovalTimeout( - promise: Promise, - candidate: WorkspaceCleanupCandidate, - timeoutMs: number -): Promise { - if (timeoutMs <= 0 || !Number.isFinite(timeoutMs)) { - return promise - } - - let timeout: ReturnType | null = null +function reportLateWorkspaceCleanupResult( + result: WorkspaceCleanupRemoveResult, + onLateResult: WorkspaceCleanupBackgroundRemovalArgs['onLateResult'] +): void { try { - return await Promise.race([ - promise, - new Promise((_resolve, reject) => { - timeout = setTimeout(() => { - // Why: the underlying removal cannot be cancelled from the renderer, - // so the row stays "Deleting" and this message must not claim the - // removal stopped. - reject( - new Error( - translate( - 'auto.components.workspace.cleanup.backgroundRemoval.timedOut', - 'Removing {{value0}} is taking longer than expected. It will keep running in the background.', - { value0: candidate.displayName } - ) - ) - ) - }, timeoutMs) - }) - ]) - } finally { - if (timeout) { - clearTimeout(timeout) - } + onLateResult?.(result) + } catch (callbackError) { + console.error('Workspace cleanup late result callback failed', callbackError) + } + showWorkspaceCleanupRemovalResultToasts(result) +} + +function showWorkspaceCleanupRemovalResultToasts(result: WorkspaceCleanupRemoveResult): void { + if (result.removedIds.length > 0) { + toast.success( + translate( + 'auto.components.workspace.cleanup.backgroundRemoval.removed', + 'Removed workspaces: {{value0}}', + { value0: result.removedIds.length } + ) + ) + } + if (result.failures.length > 0) { + toast.error( + translate( + 'auto.components.workspace.cleanup.backgroundRemoval.failed', + 'Workspaces not removed: {{value0}}', + { value0: result.failures.length } + ), + { description: result.failures.map((failure) => failure.message).join('; ') } + ) } } diff --git a/src/renderer/src/components/workspace-cleanup/workspace-cleanup-removal-settlement.ts b/src/renderer/src/components/workspace-cleanup/workspace-cleanup-removal-settlement.ts new file mode 100644 index 00000000000..c09df7321d1 --- /dev/null +++ b/src/renderer/src/components/workspace-cleanup/workspace-cleanup-removal-settlement.ts @@ -0,0 +1,137 @@ +import type { WorkspaceCleanupCandidate } from '../../../../shared/workspace-cleanup' +import type { + WorkspaceCleanupFailure, + WorkspaceCleanupRemoveResult +} from '@/store/slices/workspace-cleanup' +import { translate } from '@/i18n/i18n' + +export type WorkspaceCleanupRemovalSettlement = + | { status: 'fulfilled'; result: WorkspaceCleanupRemoveResult } + | { status: 'rejected'; error: unknown } + +type WorkspaceCleanupRemovalPollResult = + | WorkspaceCleanupRemovalSettlement + | { status: 'unresolved' } + +export type WorkspaceCleanupRemovalWaitResult = + | WorkspaceCleanupRemovalSettlement + | { status: 'unresolved'; settlement: Promise } + +export async function waitForWorkspaceCleanupRemovalWithTimeout( + promise: Promise, + timeoutMs: number, + settlementGraceMs: number +): Promise { + if (timeoutMs <= 0 || !Number.isFinite(timeoutMs)) { + return promise.then( + (result) => ({ status: 'fulfilled', result }), + (error: unknown) => ({ status: 'rejected', error }) + ) + } + + // Why: a timeout does not cancel renderer IPC. Preserve the eventual outcome + // so a confirmation racing the deadline remains authoritative. + const settlement = promise.then< + WorkspaceCleanupRemovalSettlement, + WorkspaceCleanupRemovalSettlement + >( + (result) => ({ status: 'fulfilled', result }), + (error: unknown) => ({ status: 'rejected', error }) + ) + const initialOutcome = await pollWorkspaceCleanupRemoval(settlement, timeoutMs) + const outcome = + initialOutcome.status === 'unresolved' && + settlementGraceMs > 0 && + Number.isFinite(settlementGraceMs) + ? await pollWorkspaceCleanupRemoval(settlement, settlementGraceMs) + : initialOutcome + + return outcome.status === 'unresolved' ? { ...outcome, settlement } : outcome +} + +export function getWorkspaceCleanupTimeoutFailure( + candidate: WorkspaceCleanupCandidate +): WorkspaceCleanupFailure { + return { + worktreeId: candidate.worktreeId, + displayName: candidate.displayName, + message: translate( + 'auto.components.workspace.cleanup.backgroundRemoval.timedOut', + 'Removing {{value0}} is taking longer than expected. It will keep running in the background.', + { value0: candidate.displayName } + ) + } +} + +export function trackWorkspaceCleanupLateSettlement( + settlement: Promise, + candidate: WorkspaceCleanupCandidate, + reconcileBeforeBatchResult: (result: WorkspaceCleanupRemoveResult) => void, + reportAfterBatchResult: (result: WorkspaceCleanupRemoveResult) => void +): () => void { + const state: { reconcile: ((result: WorkspaceCleanupRemoveResult) => void) | null } = { + reconcile: reconcileBeforeBatchResult + } + void settlement.then(createLateSettlementReporter(state, candidate, reportAfterBatchResult)) + // Why: a truly hung IPC promise can live for the renderer's lifetime. Drop + // the batch-array closure once its initial result has been reported. + return () => { + state.reconcile = null + } +} + +function createLateSettlementReporter( + state: { reconcile: ((result: WorkspaceCleanupRemoveResult) => void) | null }, + candidate: WorkspaceCleanupCandidate, + reportAfterBatchResult: (result: WorkspaceCleanupRemoveResult) => void +): (settlement: WorkspaceCleanupRemovalSettlement) => void { + return (settlement) => { + const result = toWorkspaceCleanupRemoveResult(candidate, settlement) + if (state.reconcile) { + state.reconcile(result) + return + } + reportAfterBatchResult(result) + } +} + +function toWorkspaceCleanupRemoveResult( + candidate: WorkspaceCleanupCandidate, + settlement: WorkspaceCleanupRemovalSettlement +): WorkspaceCleanupRemoveResult { + if (settlement.status === 'fulfilled') { + return settlement.result + } + return { + removedIds: [], + failures: [ + { + worktreeId: candidate.worktreeId, + displayName: candidate.displayName, + message: + settlement.error instanceof Error ? settlement.error.message : String(settlement.error) + } + ] + } +} + +async function pollWorkspaceCleanupRemoval( + settlement: Promise, + timeoutMs: number +): Promise { + let timeout: ReturnType | null = null + try { + return await Promise.race([ + settlement, + new Promise<{ status: 'unresolved' }>((resolve) => { + timeout = setTimeout(() => { + resolve({ status: 'unresolved' }) + }, timeoutMs) + }) + ]) + } finally { + if (timeout) { + clearTimeout(timeout) + } + } +} diff --git a/src/renderer/src/i18n/locales/en.json b/src/renderer/src/i18n/locales/en.json index f4f12c6b292..e471516ace1 100644 --- a/src/renderer/src/i18n/locales/en.json +++ b/src/renderer/src/i18n/locales/en.json @@ -1736,7 +1736,11 @@ "editReviewersWithCurrent": "Edit reviewers: {{value0}}", "linearEmptyAttributeFilter": "No issues match the selected filters. Clear a filter or try different criteria.", "linearEmptyUnfilteredScope": "No issues in this workspace scope. Try searching or adjusting teams.", - "linearFetchMore": "Fetch more" + "linearFetchMore": "Fetch more", + "jiraSortAscending": "ascending", + "jiraSortDescending": "descending", + "jiraSortBy": "Sort by", + "jiraToggleSortDirection": "Sort {{value0}}" }, "Terminal": { "73768427cf": "Close", diff --git a/src/renderer/src/i18n/locales/es.json b/src/renderer/src/i18n/locales/es.json index 982f57cab2e..0bc6654f7f7 100644 --- a/src/renderer/src/i18n/locales/es.json +++ b/src/renderer/src/i18n/locales/es.json @@ -1736,7 +1736,11 @@ "editReviewersWithCurrent": "Editar revisores: {{value0}}", "linearEmptyAttributeFilter": "Ningún issue coincide con los filtros seleccionados. Limpia un filtro o prueba con otros criterios.", "linearEmptyUnfilteredScope": "No hay issues en el alcance de este espacio de trabajo. Intenta buscar o ajustar los equipos.", - "linearFetchMore": "Cargar más" + "linearFetchMore": "Cargar más", + "jiraSortAscending": "ascendente", + "jiraSortDescending": "descendente", + "jiraSortBy": "Ordenar por", + "jiraToggleSortDirection": "Ordenar de forma {{value0}}" }, "Terminal": { "73768427cf": "Cerrar", diff --git a/src/renderer/src/i18n/locales/ja.json b/src/renderer/src/i18n/locales/ja.json index a1f798ff39e..dfe0a4b5e20 100644 --- a/src/renderer/src/i18n/locales/ja.json +++ b/src/renderer/src/i18n/locales/ja.json @@ -1736,7 +1736,11 @@ "editReviewersWithCurrent": "レビュアーを編集: {{value0}}", "linearEmptyAttributeFilter": "選択したフィルターに一致するイシューがありません。フィルターを解除するか、異なる条件を試してください。", "linearEmptyUnfilteredScope": "このワークスペーススコープにイシューがありません。検索するか、チームを調整してください。", - "linearFetchMore": "さらに読み込む" + "linearFetchMore": "さらに読み込む", + "jiraSortAscending": "昇順", + "jiraSortDescending": "降順", + "jiraSortBy": "並べ替え", + "jiraToggleSortDirection": "{{value0}}に並べ替え" }, "Terminal": { "73768427cf": "閉じる", diff --git a/src/renderer/src/i18n/locales/ko.json b/src/renderer/src/i18n/locales/ko.json index 4bc5826c3f4..eeb2f6b46bf 100644 --- a/src/renderer/src/i18n/locales/ko.json +++ b/src/renderer/src/i18n/locales/ko.json @@ -1736,7 +1736,11 @@ "editReviewersWithCurrent": "리뷰어 편집: {{value0}}", "linearEmptyAttributeFilter": "선택한 필터와 일치하는 이슈가 없습니다. 필터를 지우거나 다른 조건을 시도하세요.", "linearEmptyUnfilteredScope": "이 워크트리 범위에 이슈가 없습니다. 검색하거나 팀을 조정해 보세요.", - "linearFetchMore": "더 불러오기" + "linearFetchMore": "더 불러오기", + "jiraSortAscending": "오름차순", + "jiraSortDescending": "내림차순", + "jiraSortBy": "정렬 기준", + "jiraToggleSortDirection": "{{value0}}으로 정렬" }, "Terminal": { "73768427cf": "닫기", diff --git a/src/renderer/src/i18n/locales/zh.json b/src/renderer/src/i18n/locales/zh.json index 2955385f20e..15bb4dfb7a0 100644 --- a/src/renderer/src/i18n/locales/zh.json +++ b/src/renderer/src/i18n/locales/zh.json @@ -1736,7 +1736,11 @@ "editReviewersWithCurrent": "编辑评审人:{{value0}}", "linearEmptyAttributeFilter": "没有匹配所选筛选条件的问题。请清除筛选条件或尝试其他条件。", "linearEmptyUnfilteredScope": "此工作区范围内没有问题。请尝试搜索或调整团队。", - "linearFetchMore": "加载更多" + "linearFetchMore": "加载更多", + "jiraSortAscending": "升序", + "jiraSortDescending": "降序", + "jiraSortBy": "排序方式", + "jiraToggleSortDirection": "按{{value0}}排序" }, "Terminal": { "73768427cf": "关闭", diff --git a/src/renderer/src/i18n/no-top-level-translate.test.ts b/src/renderer/src/i18n/no-top-level-translate.test.ts index bbbc0827ab8..1709bfb35fd 100644 --- a/src/renderer/src/i18n/no-top-level-translate.test.ts +++ b/src/renderer/src/i18n/no-top-level-translate.test.ts @@ -1,6 +1,7 @@ import { readdirSync, readFileSync, statSync } from 'node:fs' import { relative, resolve } from 'node:path' -import ts from 'typescript' +// TypeScript 7 is a native CLI; AST tests still need the legacy JavaScript API. +import ts from 'typescript-api' import { describe, expect, it } from 'vitest' const RENDERER_ROOT = resolve('src/renderer/src') diff --git a/src/renderer/src/lib/agent-startup-delayed-delivery-perf.test.ts b/src/renderer/src/lib/agent-startup-delayed-delivery-perf.test.ts new file mode 100644 index 00000000000..3ea4a168b7c --- /dev/null +++ b/src/renderer/src/lib/agent-startup-delayed-delivery-perf.test.ts @@ -0,0 +1,164 @@ +import { afterEach, describe, expect, it, vi } from 'vitest' +import { useAppStore } from '@/store' +import { + queuePendingAgentStartupDelivery, + resetAgentStartupDelayedDeliveryForTests +} from './agent-startup-delayed-delivery' + +const originalState = useAppStore.getState() +const LEAF_ID = '11111111-1111-4111-8111-111111111111' + +function seedPendingState(agentLaunchConfigByPaneKey: Record = {}): void { + useAppStore.setState({ + tabsByWorktree: { + 'wt-background': [ + { + id: 'tab-background', + ptyId: null, + worktreeId: 'wt-background', + title: 'Agent', + customTitle: null, + color: null, + sortOrder: 0, + createdAt: 1 + } + ] + }, + pendingStartupByTabId: { + 'tab-background': { launchToken: 'target-launch' } + }, + agentLaunchConfigByPaneKey, + ptyIdsByTabId: { 'tab-background': [] }, + terminalLayoutsByTabId: {} + } as never) +} + +function bindPendingPty(): void { + useAppStore.setState({ + agentLaunchConfigByPaneKey: { + [`tab-background:${LEAF_ID}`]: { + identity: { + tabId: 'tab-background', + leafId: LEAF_ID, + launchToken: 'target-launch' + } + } + }, + ptyIdsByTabId: { 'tab-background': ['pty-background'] }, + terminalLayoutsByTabId: { + 'tab-background': { + root: { type: 'leaf', leafId: LEAF_ID }, + activeLeafId: LEAF_ID, + expandedLeafId: null, + ptyIdsByLeafId: { [LEAF_ID]: 'pty-background' } + } + } + } as never) +} + +function countedLaunchConfigs(count: number): { + reads: { value: number } + record: Record +} { + const reads = { value: 0 } + const record: Record = {} + for (let index = 0; index < count; index += 1) { + Object.defineProperty(record, `other-pane-${index}`, { + enumerable: true, + get: () => { + reads.value += 1 + return { + identity: { + tabId: `other-tab-${index}`, + launchToken: `other-launch-${index}` + } + } + } + }) + } + return { reads, record } +} + +afterEach(() => { + resetAgentStartupDelayedDeliveryForTests() + useAppStore.setState(originalState, true) +}) + +describe('delayed agent startup subscription', () => { + it('does not rescan launch metadata for unrelated store updates', () => { + const launchConfigs = countedLaunchConfigs(500) + seedPendingState(launchConfigs.record) + + queuePendingAgentStartupDelivery({ + worktreeId: 'wt-background', + tabId: 'tab-background', + launchToken: 'target-launch', + startup: {} as never, + deliver: vi.fn() + }) + expect(launchConfigs.reads.value).toBe(500) + + launchConfigs.reads.value = 0 + for (let update = 0; update < 100; update += 1) { + useAppStore.setState({ activeView: update % 2 === 0 ? 'terminal' : 'settings' } as never) + } + + expect(launchConfigs.reads.value).toBe(0) + }) + + it('delivers when launch registration, PTY ownership, and layout binding arrive', () => { + seedPendingState() + const deliver = vi.fn().mockResolvedValue(undefined) + const startup = {} as never + queuePendingAgentStartupDelivery({ + worktreeId: 'wt-background', + tabId: 'tab-background', + launchToken: 'target-launch', + startup, + deliver + }) + + bindPendingPty() + + expect(deliver).toHaveBeenCalledWith('tab-background', 'pty-background', startup) + }) + + it('drops a delivery when its tab is removed before PTY binding', () => { + seedPendingState() + const deliver = vi.fn().mockResolvedValue(undefined) + queuePendingAgentStartupDelivery({ + worktreeId: 'wt-background', + tabId: 'tab-background', + launchToken: 'target-launch', + startup: {} as never, + deliver + }) + + useAppStore.setState({ tabsByWorktree: {} }) + seedPendingState() + bindPendingPty() + + expect(deliver).not.toHaveBeenCalled() + }) + + it('drops a delivery when a newer pending launch token replaces it', () => { + seedPendingState() + const deliver = vi.fn().mockResolvedValue(undefined) + queuePendingAgentStartupDelivery({ + worktreeId: 'wt-background', + tabId: 'tab-background', + launchToken: 'target-launch', + startup: {} as never, + deliver + }) + + useAppStore.setState({ + pendingStartupByTabId: { + 'tab-background': { launchToken: 'newer-launch' } + } + } as never) + bindPendingPty() + + expect(deliver).not.toHaveBeenCalled() + }) +}) diff --git a/src/renderer/src/lib/agent-startup-delayed-delivery.ts b/src/renderer/src/lib/agent-startup-delayed-delivery.ts index 5ca13944b32..2e38fad41d1 100644 --- a/src/renderer/src/lib/agent-startup-delayed-delivery.ts +++ b/src/renderer/src/lib/agent-startup-delayed-delivery.ts @@ -90,7 +90,33 @@ function ensurePendingAgentStartupSubscription(): void { if (unsubscribePendingAgentStartupDeliveries) { return } - unsubscribePendingAgentStartupDeliveries = useAppStore.subscribe(() => { + const initial = useAppStore.getState() + // Capture the individual references so the gate stays allocation-free and + // remains correct even if a subscribe adapter reuses its state object. + let previousTabs = initial.tabsByWorktree + let previousPendingStartups = initial.pendingStartupByTabId + let previousLaunchConfigs = initial.agentLaunchConfigByPaneKey + let previousPtyIds = initial.ptyIdsByTabId + let previousLayouts = initial.terminalLayoutsByTabId + unsubscribePendingAgentStartupDeliveries = useAppStore.subscribe((state) => { + // Why: a background workspace can stay unmounted indefinitely. Only these + // five immutable slices can change delivery eligibility; unrelated title, + // status, focus, and usage ticks must not rescan every launch registration. + if ( + state.tabsByWorktree === previousTabs && + state.pendingStartupByTabId === previousPendingStartups && + state.agentLaunchConfigByPaneKey === previousLaunchConfigs && + state.ptyIdsByTabId === previousPtyIds && + state.terminalLayoutsByTabId === previousLayouts + ) { + return + } + // Update before flushing because delivery can synchronously write the store. + previousTabs = state.tabsByWorktree + previousPendingStartups = state.pendingStartupByTabId + previousLaunchConfigs = state.agentLaunchConfigByPaneKey + previousPtyIds = state.ptyIdsByTabId + previousLayouts = state.terminalLayoutsByTabId flushPendingAgentStartupDeliveries() }) } diff --git a/src/renderer/src/lib/hook-command-delayed-delivery-perf.test.ts b/src/renderer/src/lib/hook-command-delayed-delivery-perf.test.ts new file mode 100644 index 00000000000..f0b28c30887 --- /dev/null +++ b/src/renderer/src/lib/hook-command-delayed-delivery-perf.test.ts @@ -0,0 +1,40 @@ +import { afterEach, describe, expect, it, vi } from 'vitest' +import { useAppStore } from '@/store' +import { + queueHookCommandsForFirstWorktreeTab, + resetHookCommandDelayedDeliveryForTests +} from './hook-command-delayed-delivery' + +const initialState = useAppStore.getState() + +afterEach(() => { + resetHookCommandDelayedDeliveryForTests() + useAppStore.setState(initialState, true) +}) + +describe('delayed hook-command subscription', () => { + it('does not rescan pending worktrees for unrelated store updates', () => { + const reads = { value: 0 } + useAppStore.setState({ + tabsByWorktree: {}, + getKnownWorktreeById: ((worktreeId: string) => { + reads.value += 1 + return { id: worktreeId } + }) as never + } as never) + + for (let index = 0; index < 500; index += 1) { + queueHookCommandsForFirstWorktreeTab({ + worktreeId: `runtime-worktree-${index}`, + deliver: vi.fn() + }) + } + + reads.value = 0 + for (let update = 0; update < 100; update += 1) { + useAppStore.setState({ activeView: update % 2 === 0 ? 'terminal' : 'settings' } as never) + } + + expect(reads.value).toBe(0) + }) +}) diff --git a/src/renderer/src/lib/hook-command-delayed-delivery.ts b/src/renderer/src/lib/hook-command-delayed-delivery.ts index 234126f73ad..5f2db5d0cd2 100644 --- a/src/renderer/src/lib/hook-command-delayed-delivery.ts +++ b/src/renderer/src/lib/hook-command-delayed-delivery.ts @@ -33,7 +33,31 @@ function ensurePendingHookCommandSubscription(): void { if (unsubscribePendingHookCommandDeliveries) { return } - unsubscribePendingHookCommandDeliveries = useAppStore.subscribe(() => { + const initial = useAppStore.getState() + // Capture references so unrelated PTY/status updates do not rescan every + // runtime worktree waiting for its first mirrored terminal tab. + let previousTabsByWorktree = initial.tabsByWorktree + let previousWorktreesByRepo = initial.worktreesByRepo + let previousDetectedWorktreesByRepo = initial.detectedWorktreesByRepo + let previousFolderWorkspaces = initial.folderWorkspaces + let previousWorktreeLookup = initial.getKnownWorktreeById + unsubscribePendingHookCommandDeliveries = useAppStore.subscribe((state) => { + // Why: only these slices (or the test-adapter lookup itself) can change + // whether a pending worktree exists or has received its first tab. + if ( + state.tabsByWorktree === previousTabsByWorktree && + state.worktreesByRepo === previousWorktreesByRepo && + state.detectedWorktreesByRepo === previousDetectedWorktreesByRepo && + state.folderWorkspaces === previousFolderWorkspaces && + state.getKnownWorktreeById === previousWorktreeLookup + ) { + return + } + previousTabsByWorktree = state.tabsByWorktree + previousWorktreesByRepo = state.worktreesByRepo + previousDetectedWorktreesByRepo = state.detectedWorktreesByRepo + previousFolderWorkspaces = state.folderWorkspaces + previousWorktreeLookup = state.getKnownWorktreeById flushPendingHookCommandDeliveries() }) } diff --git a/src/renderer/src/lib/pane-manager/pane-lifecycle.test.ts b/src/renderer/src/lib/pane-manager/pane-lifecycle.test.ts index c4cb7808225..4b0fef324ac 100644 --- a/src/renderer/src/lib/pane-manager/pane-lifecycle.test.ts +++ b/src/renderer/src/lib/pane-manager/pane-lifecycle.test.ts @@ -7,6 +7,7 @@ import { resetTerminalWebglSuggestion } from './pane-webgl-renderer' import { attachLigatures, disposePane, openTerminal } from './pane-lifecycle' +import { ensureArabicShapingJoinerForText } from './terminal-arabic-shaping-joiner' import { buildDefaultTerminalOptions, DEFAULT_TERMINAL_FAST_SCROLL_SENSITIVITY, @@ -585,17 +586,17 @@ describe('openTerminal — addon and provider wiring', () => { expect(events.indexOf('open')).toBeLessThan(loadUnicodeIdx) }) - // Why: terminal.dispose() does not deregister character joiners, so the - // pane lifecycle must — this locks the register/deregister pairing that - // makes Arabic/RTL shaping (#5262) actually reach a real terminal. - it('registers the Arabic shaping joiner on open and deregisters it on dispose', () => { + // Why: ordinary panes must avoid xterm's full-grid character-joiner scan, + // while the first RTL write still registers before xterm parses the text. + it('registers Arabic shaping lazily and deregisters it on dispose', () => { const { pane, events } = createOpenTerminalHarness() openTerminal(pane) - expect(events).toContain('registerCharacterJoiner') - expect(events.indexOf('open')).toBeLessThan(events.indexOf('registerCharacterJoiner')) + expect(events).not.toContain('registerCharacterJoiner') expect(pane.arabicShapingJoinerCleanup).toBeTypeOf('function') + ensureArabicShapingJoinerForText(pane.terminal, 'مرحبا') + expect(events).toContain('registerCharacterJoiner') disposePane(pane, new Map([[pane.id, pane]])) @@ -610,6 +611,7 @@ describe('openTerminal — addon and provider wiring', () => { const { pane, getRegisteredJoinHandler } = createOpenTerminalHarness() openTerminal(pane) + ensureArabicShapingJoinerForText(pane.terminal, 'مرحبا') const handler = getRegisteredJoinHandler()! expect(pane.webglAddon).toBeNull() diff --git a/src/renderer/src/lib/pane-manager/pane-lifecycle.ts b/src/renderer/src/lib/pane-manager/pane-lifecycle.ts index c984508d463..adc43af01c1 100644 --- a/src/renderer/src/lib/pane-manager/pane-lifecycle.ts +++ b/src/renderer/src/lib/pane-manager/pane-lifecycle.ts @@ -1,10 +1,3 @@ -// Upstream packaging bug: @xterm/addon-ligatures declares `"main": -// "lib/addon-ligatures.js"` but ships only the `.mjs` entry, so Vite fails to -// resolve the bare import. Fixed locally via config/patches/@xterm__addon-ligatures*. -// Tracking upstream: https://github.com/xtermjs/xterm.js/issues/5822 and -// https://github.com/xtermjs/xterm.js/pull/5828 — drop the patch once that lands. -import { LigaturesAddon } from '@xterm/addon-ligatures' - import type { ManagedPaneInternal } from './pane-manager-types' import { safeFit } from './pane-tree-ops' import { @@ -17,7 +10,8 @@ import { attachTerminalMouseWheelMultiplier } from './pane-terminal-mouse-wheel' import { attachTerminalScrollIntentTracking } from './terminal-scroll-intent' import { attachDomRendererFocusClassSync } from './pane-dom-focus-class-sync' import { attachWebgl, cancelPendingWebglRefresh, disposeWebgl } from './pane-webgl-renderer' -import { registerArabicShapingJoiner } from './terminal-arabic-shaping-joiner' +import { configureLazyArabicShapingJoiner } from './terminal-arabic-shaping-joiner' +import { TerminalLigaturesAddon } from './terminal-ligatures-addon' import { resolveCursorAgentImeAnchor } from './terminal-ime-anchor' // --------------------------------------------------------------------------- @@ -73,12 +67,10 @@ export function openTerminal(pane: ManagedPaneInternal): void { // so the activation must stay at this position. activateOrcaTerminalUnicodeProvider(terminal) - // Why: without run-joining, Arabic/Hebrew output renders as disconnected - // letters in reversed order (#5262). Registered up front so restored - // scrollback and reattach replays shape correctly, not just live output. - // Joining tracks the live WebGL renderer: the DOM fallback misrenders - // joined spans (see registerArabicShapingJoiner), so it stays per-cell. - pane.arabicShapingJoinerCleanup = registerArabicShapingJoiner( + // Why: any xterm character joiner makes every repaint scan the whole grid. + // Defer registration until the first RTL write; replay and live paths both + // ensure it before parsing, so restored Arabic still shapes immediately. + pane.arabicShapingJoinerCleanup = configureLazyArabicShapingJoiner( terminal, () => pane.webglAddon != null ) @@ -173,7 +165,7 @@ export function attachLigatures(pane: ManagedPaneInternal): void { return } try { - const ligaturesAddon = new LigaturesAddon() + const ligaturesAddon = new TerminalLigaturesAddon() pane.terminal.loadAddon(ligaturesAddon) pane.ligaturesAddon = ligaturesAddon // Why: ligatures can be enabled after rows already rendered, especially diff --git a/src/renderer/src/lib/pane-manager/pane-manager.ts b/src/renderer/src/lib/pane-manager/pane-manager.ts index f04d5d9c3e2..ab9ac757588 100644 --- a/src/renderer/src/lib/pane-manager/pane-manager.ts +++ b/src/renderer/src/lib/pane-manager/pane-manager.ts @@ -245,6 +245,10 @@ export class PaneManager { })) } + hasWebglRenderer(paneId: number): boolean { + return this.panes.get(paneId)?.webglAddon != null + } + getLeafId(numericPaneId: number): TerminalLeafId | null { return this.identities.getLeafId(numericPaneId) } diff --git a/src/renderer/src/lib/pane-manager/pane-terminal-foreground-render-settle.ts b/src/renderer/src/lib/pane-manager/pane-terminal-foreground-render-settle.ts index eab1caa497d..5319e259a47 100644 --- a/src/renderer/src/lib/pane-manager/pane-terminal-foreground-render-settle.ts +++ b/src/renderer/src/lib/pane-manager/pane-terminal-foreground-render-settle.ts @@ -19,6 +19,7 @@ export type ForegroundTerminalOutputTarget = { type ForegroundTerminalWriteOptions = { forceViewportRefresh?: boolean followupViewportRefresh?: boolean + shouldRefreshViewportSynchronously?: () => boolean onParsed?: () => void } @@ -32,7 +33,10 @@ type ViewportSnapshot = { viewportY: number | null } -function refreshVisibleRowsNow(terminal: ForegroundTerminalOutputTarget): void { +function refreshVisibleRows( + terminal: ForegroundTerminalOutputTarget, + synchronously: boolean +): void { if (typeof terminal.rows !== 'number' || terminal.rows < 1) { return } @@ -40,14 +44,17 @@ function refreshVisibleRowsNow(terminal: ForegroundTerminalOutputTarget): void { const start = 0 const end = Math.max(0, terminal.rows - 1) try { - // Why: xterm's DOM renderer batches row paints; Windows ConPTY CR-style - // rewrites can leave stale CJK glyph cells until a resize unless we paint - // the parsed foreground state before Chromium's next frame. - if (typeof terminal._core?.refresh === 'function') { + // Why: DOM-rendered Windows ConPTY rewrites need an immediate repair, while + // WebGL can merge this full-grid request into xterm's already-queued frame. + if (synchronously && typeof terminal._core?.refresh === 'function') { terminal._core.refresh(start, end, true) return } - terminal.refresh?.(start, end) + if (typeof terminal.refresh === 'function') { + terminal.refresh(start, end) + return + } + terminal._core?.refresh?.(start, end, false) } catch { // Ignore disposed terminals; PTY output can race pane teardown. } @@ -90,12 +97,15 @@ function cancelScheduledViewportSettleRefresh(terminal: ForegroundTerminalOutput clearTimeout(pending.id) } -function scheduleViewportSettleRefresh(terminal: ForegroundTerminalOutputTarget): void { +function scheduleViewportSettleRefresh( + terminal: ForegroundTerminalOutputTarget, + shouldRefreshSynchronously?: () => boolean +): void { cancelScheduledViewportSettleRefresh(terminal) if (typeof requestAnimationFrame === 'function') { const id = requestAnimationFrame(() => { pendingViewportSettleRefreshByTerminal.delete(terminal) - refreshVisibleRowsNow(terminal) + refreshVisibleRows(terminal, shouldRefreshSynchronously?.() ?? true) }) pendingViewportSettleRefreshByTerminal.set(terminal, { kind: 'raf', id }) return @@ -103,7 +113,7 @@ function scheduleViewportSettleRefresh(terminal: ForegroundTerminalOutputTarget) const id = setTimeout(() => { pendingViewportSettleRefreshByTerminal.delete(terminal) - refreshVisibleRowsNow(terminal) + refreshVisibleRows(terminal, shouldRefreshSynchronously?.() ?? true) }, 16) pendingViewportSettleRefreshByTerminal.set(terminal, { kind: 'timeout', id }) } @@ -113,7 +123,7 @@ function settleForegroundRender( beforeWriteViewport: ViewportSnapshot, options: ForegroundTerminalWriteOptions ): void { - refreshVisibleRowsNow(terminal) + refreshVisibleRows(terminal, options.shouldRefreshViewportSynchronously?.() ?? true) // Why: when output advances the viewport, Chromium can paint the freshly // scrolled top row one frame later than xterm finishes parsing. Repaint once // more after the scroll settles so the user doesn't need to jiggle the window. @@ -121,7 +131,7 @@ function settleForegroundRender( options.followupViewportRefresh || viewportChangedDuringWrite(terminal, beforeWriteViewport) ) { - scheduleViewportSettleRefresh(terminal) + scheduleViewportSettleRefresh(terminal, options.shouldRefreshViewportSynchronously) } } diff --git a/src/renderer/src/lib/pane-manager/pane-terminal-output-scheduler.test.ts b/src/renderer/src/lib/pane-manager/pane-terminal-output-scheduler.test.ts index f85d9b8c56f..363264c9cb3 100644 --- a/src/renderer/src/lib/pane-manager/pane-terminal-output-scheduler.test.ts +++ b/src/renderer/src/lib/pane-manager/pane-terminal-output-scheduler.test.ts @@ -300,6 +300,93 @@ describe('pane terminal output scheduler', () => { expect(terminal.refresh).not.toHaveBeenCalled() }) + it('coalesces a WebGL foreground refresh through xterm public refresh', async () => { + const { writeTerminalOutput } = await loadScheduler() + const terminal = createForegroundTerminal() + + writeTerminalOutput(terminal, 'active TUI repaint\r\n', { + foreground: true, + forceForegroundRefresh: true, + shouldRefreshForegroundSynchronously: () => false + }) + + expect(terminal.refresh).toHaveBeenCalledWith(0, 23) + expect(terminal._core.refresh).not.toHaveBeenCalled() + }) + + it('resolves the live renderer after xterm finishes parsing', async () => { + const { writeTerminalOutput } = await loadScheduler() + const terminal = createForegroundTerminal() + let parseCallback: (() => void) | undefined + let webglLive = false + terminal.write.mockImplementation((_data: string, callback?: () => void) => { + parseCallback = callback + }) + + writeTerminalOutput(terminal, 'queued renderer transition\r\n', { + foreground: true, + forceForegroundRefresh: true, + shouldRefreshForegroundSynchronously: () => !webglLive + }) + webglLive = true + parseCallback?.() + + expect(terminal.refresh).toHaveBeenCalledWith(0, 23) + expect(terminal._core.refresh).not.toHaveBeenCalled() + }) + + it('keeps the WebGL follow-up repair on the debounced path', async () => { + const scheduledFrames: FrameRequestCallback[] = [] + vi.stubGlobal('requestAnimationFrame', (callback: FrameRequestCallback) => { + scheduledFrames.push(callback) + return scheduledFrames.length + }) + vi.stubGlobal('cancelAnimationFrame', vi.fn()) + + const { writeTerminalOutput } = await loadScheduler() + const terminal = createForegroundTerminal() + + writeTerminalOutput(terminal, 'WebGL cursor restore', { + foreground: true, + forceForegroundRefresh: true, + followupForegroundRefresh: true, + shouldRefreshForegroundSynchronously: () => false + }) + + expect(terminal.refresh).toHaveBeenCalledTimes(1) + expect(scheduledFrames).toHaveLength(1) + scheduledFrames[0]?.(16) + + expect(terminal.refresh).toHaveBeenCalledTimes(2) + expect(terminal._core.refresh).not.toHaveBeenCalled() + }) + + it('resolves WebGL loss again before the follow-up repair', async () => { + const scheduledFrames: FrameRequestCallback[] = [] + vi.stubGlobal('requestAnimationFrame', (callback: FrameRequestCallback) => { + scheduledFrames.push(callback) + return scheduledFrames.length + }) + vi.stubGlobal('cancelAnimationFrame', vi.fn()) + + const { writeTerminalOutput } = await loadScheduler() + const terminal = createForegroundTerminal() + let webglLive = true + + writeTerminalOutput(terminal, 'renderer transition', { + foreground: true, + forceForegroundRefresh: true, + followupForegroundRefresh: true, + shouldRefreshForegroundSynchronously: () => !webglLive + }) + + expect(terminal.refresh).toHaveBeenCalledTimes(1) + webglLive = false + scheduledFrames[0]?.(16) + + expect(terminal._core.refresh).toHaveBeenCalledWith(0, 23, true) + }) + it('repaints the viewport again on the next frame when foreground output scrolls', async () => { const scheduledFrames: FrameRequestCallback[] = [] vi.stubGlobal('requestAnimationFrame', (callback: FrameRequestCallback) => { @@ -916,6 +1003,41 @@ describe('pane terminal output scheduler', () => { expect(terminal.write).toHaveBeenCalledWith('ab') }) + it('keeps preparation attached when a later producer omits it', async () => { + vi.useFakeTimers() + const { writeTerminalOutput } = await loadScheduler() + const terminal = createTerminal() + const beforeWrite = vi.fn() + + writeTerminalOutput(terminal, 'مرحبا', { foreground: false, beforeWrite }) + writeTerminalOutput(terminal, ' fallback notice', { foreground: false }) + vi.advanceTimersByTime(50) + + expect(beforeWrite).toHaveBeenCalledWith('مرحبا fallback notice') + expect(terminal.write).toHaveBeenCalledWith('مرحبا fallback notice') + }) + + it('ignores unforced chunks when resolving a coalesced forced refresh', async () => { + vi.useFakeTimers() + const { writeTerminalOutput } = await loadScheduler() + const terminal = createForegroundTerminal() + + writeTerminalOutput(terminal, 'forced', { + foreground: true, + latencySensitive: false, + forceForegroundRefresh: true, + shouldRefreshForegroundSynchronously: () => false + }) + writeTerminalOutput(terminal, ' ordinary', { + foreground: true, + latencySensitive: false + }) + vi.advanceTimersByTime(0) + + expect(terminal.refresh).toHaveBeenCalledWith(0, 23) + expect(terminal._core.refresh).not.toHaveBeenCalled() + }) + it('runs deferred write preparation before explicit background flushes', async () => { vi.useFakeTimers() const { flushTerminalOutput, writeTerminalOutput } = await loadScheduler() diff --git a/src/renderer/src/lib/pane-manager/pane-terminal-output-scheduler.ts b/src/renderer/src/lib/pane-manager/pane-terminal-output-scheduler.ts index 79ddae6e086..e12eeaccb89 100644 --- a/src/renderer/src/lib/pane-manager/pane-terminal-output-scheduler.ts +++ b/src/renderer/src/lib/pane-manager/pane-terminal-output-scheduler.ts @@ -27,6 +27,7 @@ type TerminalOutputTarget = ForegroundTerminalOutputTarget type TerminalOutputBeforeWrite = (data: string) => void type TerminalBacklogRecoveryRequest = () => boolean type TerminalOutputParsedCallback = () => void +type ForegroundRefreshSyncResolver = () => boolean type WriteTerminalOutputOptions = { foreground: boolean @@ -42,6 +43,7 @@ type WriteTerminalOutputOptions = { latencySensitive?: boolean forceForegroundRefresh?: boolean followupForegroundRefresh?: boolean + shouldRefreshForegroundSynchronously?: ForegroundRefreshSyncResolver stripTransientCursorShows?: boolean coalesceForeground?: boolean holdForeground?: boolean @@ -52,7 +54,9 @@ type QueueChunk = { foreground: boolean forceForegroundRefresh: boolean followupForegroundRefresh: boolean + shouldRefreshForegroundSynchronously: ForegroundRefreshSyncResolver stripTransientCursorShows: boolean + beforeWrite?: TerminalOutputBeforeWrite onParsed?: TerminalOutputParsedCallback ackCredit?: () => void } @@ -62,7 +66,9 @@ type QueuedWrite = { foreground: boolean forceForegroundRefresh: boolean followupForegroundRefresh: boolean + shouldRefreshForegroundSynchronously: ForegroundRefreshSyncResolver stripTransientCursorShows: boolean + beforeWrite?: TerminalOutputBeforeWrite onParsed?: TerminalOutputParsedCallback ackCredits: (() => void)[] } @@ -72,7 +78,6 @@ type QueueEntry = { chunks: QueueChunk[] chunkIndex: number queuedChars: number - beforeWrite?: TerminalOutputBeforeWrite onBackgroundBacklogDropped?: () => void backgroundBacklogDropped: boolean highPriority: boolean @@ -129,6 +134,7 @@ const BACKGROUND_BACKLOG_WARNING = // skipped, not merely produced while hidden. const FOREGROUND_BACKLOG_WARNING = '\x18\x1b[0m\r\n[Orca skipped a burst of terminal output because the backlog grew too large.]\r\n' +const ALWAYS_REFRESH_FOREGROUND_SYNCHRONOUSLY = (): boolean => true const queuedByTerminal = new Map() const backlogRecoveryByTerminal = new WeakMap< @@ -334,7 +340,6 @@ function createQueueEntry( chunks: [], chunkIndex: 0, queuedChars: 0, - beforeWrite: options.beforeWrite, onBackgroundBacklogDropped: options.onBackgroundBacklogDropped, backgroundBacklogDropped: false, highPriority: true, @@ -571,7 +576,11 @@ function takeQueuedChunk(entry: QueueEntry, limit: number): QueuedWrite | null { let foreground: boolean | null = null let forceForegroundRefresh = false let followupForegroundRefresh = false + let shouldRefreshForegroundSynchronously: ForegroundRefreshSyncResolver | null = null + let additionalRefreshSyncResolvers: ForegroundRefreshSyncResolver[] | null = null let stripTransientCursorShows = false + let beforeWrite: TerminalOutputBeforeWrite | undefined + let additionalBeforeWriteCallbacks: TerminalOutputBeforeWrite[] | null = null const parsedCallbacks: TerminalOutputParsedCallback[] = [] const ackCredits: (() => void)[] = [] @@ -583,7 +592,30 @@ function takeQueuedChunk(entry: QueueEntry, limit: number): QueuedWrite | null { foreground ??= chunk.foreground forceForegroundRefresh ||= chunk.forceForegroundRefresh followupForegroundRefresh ||= chunk.followupForegroundRefresh + // Why: one drained write can combine chunks from different renderer + // states or producers; preserve every forced policy and preparation hook. + if (chunk.forceForegroundRefresh) { + if (shouldRefreshForegroundSynchronously === null) { + shouldRefreshForegroundSynchronously = chunk.shouldRefreshForegroundSynchronously + } else if ( + chunk.shouldRefreshForegroundSynchronously !== shouldRefreshForegroundSynchronously && + !additionalRefreshSyncResolvers?.includes(chunk.shouldRefreshForegroundSynchronously) + ) { + additionalRefreshSyncResolvers ??= [] + additionalRefreshSyncResolvers.push(chunk.shouldRefreshForegroundSynchronously) + } + } stripTransientCursorShows ||= chunk.stripTransientCursorShows + if (!beforeWrite) { + beforeWrite = chunk.beforeWrite + } else if ( + chunk.beforeWrite && + chunk.beforeWrite !== beforeWrite && + !additionalBeforeWriteCallbacks?.includes(chunk.beforeWrite) + ) { + additionalBeforeWriteCallbacks ??= [] + additionalBeforeWriteCallbacks.push(chunk.beforeWrite) + } if (chunk.data.length <= remaining) { data += chunk.data remaining -= chunk.data.length @@ -618,7 +650,22 @@ function takeQueuedChunk(entry: QueueEntry, limit: number): QueuedWrite | null { foreground: foreground === true, forceForegroundRefresh, followupForegroundRefresh, + shouldRefreshForegroundSynchronously: + additionalRefreshSyncResolvers && shouldRefreshForegroundSynchronously + ? () => + shouldRefreshForegroundSynchronously() || + additionalRefreshSyncResolvers.some((resolve) => resolve()) + : (shouldRefreshForegroundSynchronously ?? ALWAYS_REFRESH_FOREGROUND_SYNCHRONOUSLY), stripTransientCursorShows, + beforeWrite: + additionalBeforeWriteCallbacks && beforeWrite + ? (queuedData) => { + beforeWrite(queuedData) + for (const callback of additionalBeforeWriteCallbacks) { + callback(queuedData) + } + } + : beforeWrite, onParsed: parsedCallbacks.length > 0 ? () => { @@ -654,7 +701,9 @@ function enqueueChunk( foreground?: boolean forceForegroundRefresh?: boolean followupForegroundRefresh?: boolean + shouldRefreshForegroundSynchronously?: ForegroundRefreshSyncResolver stripTransientCursorShows?: boolean + beforeWrite?: TerminalOutputBeforeWrite onParsed?: TerminalOutputParsedCallback ackCredit?: () => void } @@ -664,7 +713,10 @@ function enqueueChunk( foreground: options?.foreground === true, forceForegroundRefresh: options?.forceForegroundRefresh === true, followupForegroundRefresh: options?.followupForegroundRefresh === true, + shouldRefreshForegroundSynchronously: + options?.shouldRefreshForegroundSynchronously ?? ALWAYS_REFRESH_FOREGROUND_SYNCHRONOUSLY, stripTransientCursorShows: options?.stripTransientCursorShows === true, + beforeWrite: options?.beforeWrite, onParsed: options?.onParsed, ackCredit: options?.ackCredit }) @@ -703,6 +755,13 @@ function replaceBacklogWithWarning( capChars: maxQueueChars }) } + let beforeWrite: TerminalOutputBeforeWrite | undefined + for (let index = entry.chunks.length - 1; index >= entry.chunkIndex; index--) { + if (entry.chunks[index]?.beforeWrite) { + beforeWrite = entry.chunks[index].beforeWrite + break + } + } clearForegroundHoldSafety(entry) fireQueuedAckCredits(entry) entry.chunks = [ @@ -711,7 +770,9 @@ function replaceBacklogWithWarning( foreground: false, forceForegroundRefresh: false, followupForegroundRefresh: false, - stripTransientCursorShows: false + shouldRefreshForegroundSynchronously: ALWAYS_REFRESH_FOREGROUND_SYNCHRONOUSLY, + stripTransientCursorShows: false, + beforeWrite } ] entry.chunkIndex = 0 @@ -795,6 +856,7 @@ function writeForegroundTerminalChunkWithIntent( options: { forceViewportRefresh: boolean followupViewportRefresh: boolean + shouldRefreshViewportSynchronously: ForegroundRefreshSyncResolver onParsed?: TerminalOutputParsedCallback } ): void { @@ -802,6 +864,7 @@ function writeForegroundTerminalChunkWithIntent( writeForegroundTerminalChunk(terminal, data, { forceViewportRefresh: options.forceViewportRefresh, followupViewportRefresh: options.followupViewportRefresh, + shouldRefreshViewportSynchronously: options.shouldRefreshViewportSynchronously, onParsed: () => { // Why: recovery must repaint from the scrolled buffer state that xterm // will keep, not from a pre-intent-restored viewport snapshot. @@ -886,7 +949,7 @@ function writeQueuedChunk(entry: QueueEntry): 'foreground' | 'background' | null const pacer = entry.highPriority ? makeParseClockPacer() : undefined const ackCreditsParsed = registerTerminalOutputAckCredits(entry.terminal, queuedWrite.ackCredits) try { - entry.beforeWrite?.(queuedWrite.data) + queuedWrite.beforeWrite?.(queuedWrite.data) if (queuedWrite.foreground) { writeForegroundTerminalChunkWithIntent( entry.terminal, @@ -896,6 +959,7 @@ function writeQueuedChunk(entry: QueueEntry): 'foreground' | 'background' | null { forceViewportRefresh: queuedWrite.forceForegroundRefresh, followupViewportRefresh: queuedWrite.followupForegroundRefresh, + shouldRefreshViewportSynchronously: queuedWrite.shouldRefreshForegroundSynchronously, onParsed: composeParsedCallback(queuedWrite.onParsed, ackCreditsParsed, pacer) } ) @@ -1007,7 +1071,6 @@ export function writeTerminalOutput( const entry = queuedByTerminal.get(terminal) if (entry?.highPriority || options.coalesceForeground || options.holdForeground) { const queued = entry ?? createQueueEntry(terminal, options) - queued.beforeWrite = options.beforeWrite queued.onBackgroundBacklogDropped = options.onBackgroundBacklogDropped queued.highPriority = true queuedByTerminal.set(terminal, queued) @@ -1015,7 +1078,9 @@ export function writeTerminalOutput( foreground: true, forceForegroundRefresh: options.forceForegroundRefresh, followupForegroundRefresh: options.followupForegroundRefresh, + shouldRefreshForegroundSynchronously: options.shouldRefreshForegroundSynchronously, stripTransientCursorShows: options.stripTransientCursorShows, + beforeWrite: options.beforeWrite, onParsed: options.onParsed, ackCredit: options.ackCredit }) @@ -1084,13 +1149,14 @@ export function writeTerminalOutput( return } if (entry && entry.queuedChars > SYNC_FOREGROUND_FLUSH_CHARS) { - entry.beforeWrite = options.beforeWrite entry.highPriority = true enqueueChunk(entry, data, { foreground: true, forceForegroundRefresh: options.forceForegroundRefresh, followupForegroundRefresh: options.followupForegroundRefresh, + shouldRefreshForegroundSynchronously: options.shouldRefreshForegroundSynchronously, stripTransientCursorShows: options.stripTransientCursorShows, + beforeWrite: options.beforeWrite, onParsed: options.onParsed, ackCredit: options.ackCredit }) @@ -1113,7 +1179,6 @@ export function writeTerminalOutput( queued = createQueueEntry(terminal, options) queuedByTerminal.set(terminal, queued) } else { - queued.beforeWrite = options.beforeWrite queued.onBackgroundBacklogDropped = options.onBackgroundBacklogDropped queued.highPriority = true } @@ -1121,7 +1186,9 @@ export function writeTerminalOutput( foreground: true, forceForegroundRefresh: options.forceForegroundRefresh, followupForegroundRefresh: options.followupForegroundRefresh, + shouldRefreshForegroundSynchronously: options.shouldRefreshForegroundSynchronously, stripTransientCursorShows: options.stripTransientCursorShows, + beforeWrite: options.beforeWrite, onParsed: options.onParsed, ackCredit: options.ackCredit }) @@ -1154,6 +1221,8 @@ export function writeTerminalOutput( { forceViewportRefresh: options.forceForegroundRefresh === true, followupViewportRefresh: options.followupForegroundRefresh === true, + shouldRefreshViewportSynchronously: + options.shouldRefreshForegroundSynchronously ?? ALWAYS_REFRESH_FOREGROUND_SYNCHRONOUSLY, onParsed: composeParsedCallback(options.onParsed, ackCreditsParsed, undefined) } ) @@ -1172,10 +1241,10 @@ export function writeTerminalOutput( entry.highPriority = false queuedByTerminal.set(terminal, entry) } else { - entry.beforeWrite = options.beforeWrite entry.onBackgroundBacklogDropped = options.onBackgroundBacklogDropped } enqueueChunk(entry, data, { + beforeWrite: options.beforeWrite, onParsed: options.onParsed, ackCredit: options.ackCredit }) @@ -1228,7 +1297,7 @@ export function flushTerminalOutput( } const ackCreditsParsed = registerTerminalOutputAckCredits(terminal, queuedWrite.ackCredits) try { - entry.beforeWrite?.(queuedWrite.data) + queuedWrite.beforeWrite?.(queuedWrite.data) if (queuedWrite.foreground) { writeForegroundTerminalChunkWithIntent( terminal, @@ -1238,6 +1307,7 @@ export function flushTerminalOutput( { forceViewportRefresh: queuedWrite.forceForegroundRefresh, followupViewportRefresh: queuedWrite.followupForegroundRefresh, + shouldRefreshViewportSynchronously: queuedWrite.shouldRefreshForegroundSynchronously, onParsed: composeParsedCallback(queuedWrite.onParsed, ackCreditsParsed, undefined) } ) diff --git a/src/renderer/src/lib/pane-manager/terminal-arabic-shaping-joiner.test.ts b/src/renderer/src/lib/pane-manager/terminal-arabic-shaping-joiner.test.ts index 6bed3d7c01d..6895d629f87 100644 --- a/src/renderer/src/lib/pane-manager/terminal-arabic-shaping-joiner.test.ts +++ b/src/renderer/src/lib/pane-manager/terminal-arabic-shaping-joiner.test.ts @@ -1,6 +1,8 @@ import { describe, expect, it } from 'vitest' import { + configureLazyArabicShapingJoiner, + ensureArabicShapingJoinerForText, findRtlJoinRanges, isStrongRtlCodePoint, registerArabicShapingJoiner @@ -248,3 +250,75 @@ describe('registerArabicShapingJoiner', () => { expect(handler('مرحبا')).toEqual([[0, 5]]) }) }) + +describe('configureLazyArabicShapingJoiner', () => { + function createLazyHost() { + const events: string[] = [] + let handler: ((text: string) => [number, number][]) | null = null + const terminal = { + registerCharacterJoiner(nextHandler: (text: string) => [number, number][]): number { + events.push('register') + handler = nextHandler + return 11 + }, + deregisterCharacterJoiner(joinerId: number): void { + events.push(`deregister:${joinerId}`) + } + } + return { events, terminal, getHandler: () => handler } + } + + it('does not register for ordinary terminal output', () => { + const host = createLazyHost() + const cleanup = configureLazyArabicShapingJoiner(host.terminal, () => true) + + ensureArabicShapingJoinerForText(host.terminal, 'ASCII, 中文, and emoji 😀') + + expect(host.events).toEqual([]) + expect(host.getHandler()).toBeNull() + cleanup() + expect(host.events).toEqual([]) + }) + + it('registers once before the first RTL write and cleans it up', () => { + const host = createLazyHost() + const cleanup = configureLazyArabicShapingJoiner(host.terminal, () => true) + + ensureArabicShapingJoinerForText(host.terminal, 'مرحبا') + ensureArabicShapingJoinerForText(host.terminal, 'שלום') + + expect(host.events).toEqual(['register']) + expect(host.getHandler()!('مرحبا')).toEqual([[0, 5]]) + cleanup() + expect(host.events).toEqual(['register', 'deregister:11']) + }) + + it('recognizes a supplementary-plane RTL code point split across writes', () => { + const host = createLazyHost() + configureLazyArabicShapingJoiner(host.terminal, () => true) + const adlam = String.fromCodePoint(0x1e900) + + ensureArabicShapingJoinerForText(host.terminal, adlam.charAt(0)) + expect(host.events).toEqual([]) + ensureArabicShapingJoinerForText(host.terminal, adlam.charAt(1)) + + expect(host.events).toEqual(['register']) + }) + + it('contains a registration failure and does not retry every write', () => { + let attempts = 0 + const terminal = { + registerCharacterJoiner(): number { + attempts++ + throw new Error('terminal disposed') + }, + deregisterCharacterJoiner(): void {} + } + configureLazyArabicShapingJoiner(terminal, () => true) + + expect(() => ensureArabicShapingJoinerForText(terminal, 'مرحبا')).not.toThrow() + expect(() => ensureArabicShapingJoinerForText(terminal, 'שלום')).not.toThrow() + + expect(attempts).toBe(1) + }) +}) diff --git a/src/renderer/src/lib/pane-manager/terminal-arabic-shaping-joiner.ts b/src/renderer/src/lib/pane-manager/terminal-arabic-shaping-joiner.ts index 5b9b5f8dc95..802f708b8e4 100644 --- a/src/renderer/src/lib/pane-manager/terminal-arabic-shaping-joiner.ts +++ b/src/renderer/src/lib/pane-manager/terminal-arabic-shaping-joiner.ts @@ -1,5 +1,19 @@ import type { Terminal } from '@xterm/xterm' +type ArabicShapingTerminal = Pick + +type LazyArabicShapingJoinerState = { + cleanup: (() => void) | null + isShapingActive: () => boolean + registrationAttempted: boolean + trailingHighSurrogate: string +} + +const lazyArabicShapingJoinerByTerminal = new WeakMap< + ArabicShapingTerminal, + LazyArabicShapingJoinerState +>() + // Why: xterm draws every cell's glyph in isolation, so Arabic output shows // disconnected letterforms in logical (reversed) order — upstream has no // BiDi/shaping support (xtermjs/xterm.js#701, Orca #5262). Joining each RTL @@ -29,6 +43,27 @@ export function isStrongRtlCodePoint(codePoint: number): boolean { ) } +function containsStrongRtlText(text: string): boolean { + for (let index = 0; index < text.length; index++) { + const unit = text.charCodeAt(index) + if (unit < RTL_SCAN_FLOOR) { + continue + } + let codePoint = unit + if (unit >= 0xd800 && unit <= 0xdbff && index + 1 < text.length) { + const low = text.charCodeAt(index + 1) + if (low >= 0xdc00 && low <= 0xdfff) { + codePoint = (unit - 0xd800) * 0x400 + (low - 0xdc00) + 0x10000 + index++ + } + } + if (isStrongRtlCodePoint(codePoint)) { + return true + } + } + return false +} + // Neutral characters may sit inside an RTL run (so a multi-word phrase joins // as one unit and keeps right-to-left word order) but never start or end one: // ASCII space/digits/punctuation and NBSP. ASCII letters are strong LTR and @@ -169,7 +204,7 @@ export function findRtlJoinRanges(text: string): [number, number][] { * character joiners, so disposePane() must call this to avoid leaking the * registration (xtermjs/xterm.js#3289). */ export function registerArabicShapingJoiner( - terminal: Pick, + terminal: ArabicShapingTerminal, isShapingActive: () => boolean ): () => void { // Why: the DOM renderer sizes a joined span with one letter-spacing value @@ -185,3 +220,70 @@ export function registerArabicShapingJoiner( terminal.deregisterCharacterJoiner(joinerId) } } + +/** Configure RTL shaping without registering an xterm character joiner until + * output actually contains RTL text. Any registered joiner makes xterm scan + * every visible cell on every repaint, even when its handler returns no ranges. */ +export function configureLazyArabicShapingJoiner( + terminal: ArabicShapingTerminal, + isShapingActive: () => boolean +): () => void { + const previousState = lazyArabicShapingJoinerByTerminal.get(terminal) + try { + previousState?.cleanup?.() + } catch { + // A disposed terminal can reject deregistration; replace stale state anyway. + } + + const state: LazyArabicShapingJoinerState = { + cleanup: null, + isShapingActive, + registrationAttempted: false, + trailingHighSurrogate: '' + } + lazyArabicShapingJoinerByTerminal.set(terminal, state) + + return () => { + if (lazyArabicShapingJoinerByTerminal.get(terminal) !== state) { + return + } + try { + state.cleanup?.() + } catch { + // Pane teardown must continue if xterm disposed before deregistration. + } finally { + lazyArabicShapingJoinerByTerminal.delete(terminal) + } + } +} + +/** Register the configured joiner immediately before the first RTL write. */ +export function ensureArabicShapingJoinerForText( + terminal: ArabicShapingTerminal, + text: string +): void { + const state = lazyArabicShapingJoinerByTerminal.get(terminal) + if (!state || state.cleanup || state.registrationAttempted) { + return + } + + // Why: PTY/replay chunks can split supplementary-plane RTL code points + // between their surrogate halves; retain only that one boundary code unit. + const scanText = state.trailingHighSurrogate + text + const finalCharacter = scanText.at(-1) ?? '' + const finalCodeUnit = finalCharacter.charCodeAt(0) + state.trailingHighSurrogate = + finalCodeUnit >= 0xd800 && finalCodeUnit <= 0xdbff ? finalCharacter : '' + if (!containsStrongRtlText(scanText)) { + return + } + + state.trailingHighSurrogate = '' + state.registrationAttempted = true + try { + state.cleanup = registerArabicShapingJoiner(terminal, state.isShapingActive) + } catch { + // Why: shaping is optional; a registration race with pane disposal must + // never drop the PTY/replay bytes that triggered it or retry every chunk. + } +} diff --git a/src/renderer/src/lib/pane-manager/terminal-ligatures-addon.test.ts b/src/renderer/src/lib/pane-manager/terminal-ligatures-addon.test.ts new file mode 100644 index 00000000000..75f52f3f4f8 --- /dev/null +++ b/src/renderer/src/lib/pane-manager/terminal-ligatures-addon.test.ts @@ -0,0 +1,154 @@ +import { beforeEach, describe, expect, it, vi } from 'vitest' +import type { Terminal } from '@xterm/xterm' + +const addonMock = vi.hoisted(() => ({ + delegateTerminal: null as Terminal | null, + joiner: vi.fn<(text: string) => [number, number][]>() +})) + +vi.mock('@xterm/addon-ligatures', () => ({ + LigaturesAddon: class { + private joinerId: number | null = null + + activate(terminal: Terminal): void { + addonMock.delegateTerminal = terminal + this.joinerId = terminal.registerCharacterJoiner(addonMock.joiner) + } + + dispose(): void { + if (this.joinerId !== null) { + addonMock.delegateTerminal?.deregisterCharacterJoiner(this.joinerId) + } + } + } +})) + +import { TerminalLigaturesAddon } from './terminal-ligatures-addon' + +function createTerminalHarness() { + let registeredJoiner: ((text: string) => [number, number][]) | null = null + const refresh = vi.fn() + const deregisterCharacterJoiner = vi.fn() + const terminal = { + element: { style: {} }, + options: { fontFamily: 'Fira Code' }, + refresh, + registerCharacterJoiner(joiner: (text: string) => [number, number][]): number { + registeredJoiner = joiner + return 17 + }, + deregisterCharacterJoiner + } as unknown as Terminal + return { + terminal, + refresh, + deregisterCharacterJoiner, + getRegisteredJoiner: () => registeredJoiner! + } +} + +describe('TerminalLigaturesAddon', () => { + beforeEach(() => { + addonMock.delegateTerminal = null + addonMock.joiner.mockReset() + addonMock.joiner.mockImplementation((text) => (text.includes('=>') ? [[2, 4]] : [])) + }) + + it('reuses joiner results for unchanged row text', () => { + const harness = createTerminalHarness() + new TerminalLigaturesAddon().activate(harness.terminal) + const joiner = harness.getRegisteredJoiner() + + expect(joiner('a => b')).toEqual([[2, 4]]) + expect(joiner('a => b')).toEqual([[2, 4]]) + + expect(addonMock.joiner).toHaveBeenCalledTimes(1) + }) + + it('returns fresh tuples because xterm mutates joiner ranges', () => { + const harness = createTerminalHarness() + new TerminalLigaturesAddon().activate(harness.terminal) + const joiner = harness.getRegisteredJoiner() + + const first = joiner('a => b') + first[0]![0] = 99 + + expect(joiner('a => b')).toEqual([[2, 4]]) + }) + + it('invalidates fallback results when font discovery refreshes', () => { + const harness = createTerminalHarness() + new TerminalLigaturesAddon().activate(harness.terminal) + const joiner = harness.getRegisteredJoiner() + joiner('a => b') + + addonMock.delegateTerminal!.refresh(0, 23) + joiner('a => b') + + expect(harness.refresh).toHaveBeenCalledWith(0, 23) + expect(addonMock.joiner).toHaveBeenCalledTimes(2) + }) + + it('does not reuse results after the terminal font changes', () => { + const harness = createTerminalHarness() + new TerminalLigaturesAddon().activate(harness.terminal) + const joiner = harness.getRegisteredJoiner() + joiner('a => b') + + harness.terminal.options.fontFamily = 'JetBrains Mono' + joiner('a => b') + + expect(addonMock.joiner).toHaveBeenCalledTimes(2) + }) + + it('caps cached short segments by entry count', () => { + const harness = createTerminalHarness() + new TerminalLigaturesAddon().activate(harness.terminal) + const joiner = harness.getRegisteredJoiner() + + for (let index = 0; index <= 2_048; index++) { + joiner(`s${index}`) + } + joiner('s0') + + expect(addonMock.joiner).toHaveBeenCalledTimes(2_050) + }) + + it('evicts the least-recently-used segment', () => { + const harness = createTerminalHarness() + new TerminalLigaturesAddon().activate(harness.terminal) + const joiner = harness.getRegisteredJoiner() + + for (let index = 0; index < 2_048; index++) { + joiner(`s${index}`) + } + joiner('s0') + joiner('new segment') + joiner('s1') + joiner('s0') + + expect(addonMock.joiner).toHaveBeenCalledTimes(2_050) + }) + + it('does not retain a segment above the character budget', () => { + const harness = createTerminalHarness() + new TerminalLigaturesAddon().activate(harness.terminal) + const joiner = harness.getRegisteredJoiner() + const oversizedSegment = 'x'.repeat(100_001) + + joiner(oversizedSegment) + joiner(oversizedSegment) + + expect(addonMock.joiner).toHaveBeenCalledTimes(2) + }) + + it('deregisters the wrapped joiner through the real terminal', () => { + const harness = createTerminalHarness() + const addon = new TerminalLigaturesAddon() + addon.activate(harness.terminal) + + addon.dispose() + + expect(harness.deregisterCharacterJoiner).toHaveBeenCalledWith(17) + }) +}) diff --git a/src/renderer/src/lib/pane-manager/terminal-ligatures-addon.ts b/src/renderer/src/lib/pane-manager/terminal-ligatures-addon.ts new file mode 100644 index 00000000000..256f38befdc --- /dev/null +++ b/src/renderer/src/lib/pane-manager/terminal-ligatures-addon.ts @@ -0,0 +1,120 @@ +// Upstream packaging bug: @xterm/addon-ligatures declares a missing module +// entry. config/patches/@xterm__addon-ligatures* keeps the runtime import valid. +import { LigaturesAddon } from '@xterm/addon-ligatures' +import type { Terminal } from '@xterm/xterm' + +type LigatureRange = [number, number] +type CharacterJoiner = (text: string) => LigatureRange[] + +const LIGATURE_CACHE_CHARACTER_BUDGET = 100_000 +// Why: short attribute segments can otherwise create tens of thousands of Map +// entries per pane; 2K is still far beyond one visible grid's working set. +const LIGATURE_CACHE_ENTRY_BUDGET = 2_048 + +function cloneRanges(ranges: readonly LigatureRange[]): LigatureRange[] { + return ranges.map(([start, end]) => [start, end]) +} + +class LigatureRangeCache { + private readonly entries = new Map() + private cachedCharacters = 0 + generation = 0 + + get(text: string): LigatureRange[] | undefined { + const entry = this.entries.get(text) + if (!entry) { + return undefined + } + this.entries.delete(text) + this.entries.set(text, entry) + // Why: xterm translates and merges joiner ranges in place, so callers must + // never receive the cache's retained tuples. + return cloneRanges(entry.ranges) + } + + set(text: string, ranges: readonly LigatureRange[]): void { + if (text.length > LIGATURE_CACHE_CHARACTER_BUDGET) { + return + } + const previous = this.entries.get(text) + if (previous) { + this.cachedCharacters -= previous.size + this.entries.delete(text) + } + const entry = { ranges: cloneRanges(ranges), size: text.length } + this.entries.set(text, entry) + this.cachedCharacters += entry.size + while ( + this.cachedCharacters > LIGATURE_CACHE_CHARACTER_BUDGET || + this.entries.size > LIGATURE_CACHE_ENTRY_BUDGET + ) { + const oldest = this.entries.entries().next().value as + | [string, { ranges: LigatureRange[]; size: number }] + | undefined + if (!oldest) { + break + } + this.entries.delete(oldest[0]) + this.cachedCharacters -= oldest[1].size + } + } + + clear(): void { + this.entries.clear() + this.cachedCharacters = 0 + this.generation++ + } +} + +function createCachedCharacterJoiner( + terminal: Terminal, + joiner: CharacterJoiner, + cache: LigatureRangeCache +): CharacterJoiner { + let cachedFontFamily = terminal.options.fontFamily + return (text) => { + const fontFamily = terminal.options.fontFamily + if (fontFamily !== cachedFontFamily) { + cachedFontFamily = fontFamily + cache.clear() + } + const cached = cache.get(text) + if (cached) { + return cached + } + const generationBeforeJoin = cache.generation + const ranges = joiner(text) + // Why: the addon refreshes when async font discovery completes. If that + // happened during this call, do not repopulate the cleared fallback data. + if (cache.generation === generationBeforeJoin) { + cache.set(text, ranges) + } + return ranges + } +} + +/** LigaturesAddon with a bounded exact-row cache around its character joiner. + * Active TUIs repaint mostly unchanged rows, while the upstream fallback + * matcher otherwise retries every known ligature at every character. */ +export class TerminalLigaturesAddon extends LigaturesAddon { + override activate(terminal: Terminal): void { + const cache = new LigatureRangeCache() + const terminalForAddon = new Proxy(terminal, { + get(target, property) { + if (property === 'registerCharacterJoiner') { + return (joiner: CharacterJoiner): number => + target.registerCharacterJoiner(createCachedCharacterJoiner(target, joiner, cache)) + } + if (property === 'refresh') { + return (start: number, end: number): void => { + cache.clear() + target.refresh(start, end) + } + } + const value = Reflect.get(target, property, target) as unknown + return typeof value === 'function' ? value.bind(target) : value + } + }) + super.activate(terminalForAddon) + } +} diff --git a/src/renderer/src/runtime/runtime-linear-client.test.ts b/src/renderer/src/runtime/runtime-linear-client.test.ts index 57f182f8341..8ebdfffc55e 100644 --- a/src/renderer/src/runtime/runtime-linear-client.test.ts +++ b/src/renderer/src/runtime/runtime-linear-client.test.ts @@ -18,6 +18,7 @@ import { linearUpdateIssue } from './runtime-linear-client' import { + createCompatibleRuntimeStatusResponse, createCompatibleRuntimeStatusResponseIfNeeded, type RuntimeEnvironmentCallRequest } from './runtime-compatibility-test-fixture' @@ -343,6 +344,74 @@ describe('runtime linear client', () => { }) }) + it('rejects filtered reads before an older runtime can return unfiltered issues', async () => { + runtimeEnvironmentCall.mockResolvedValueOnce({ + id: 'rpc-list', + ok: true, + result: { items: [{ id: 'unfiltered-issue' }] }, + _meta: { runtimeId: 'runtime-old' } + }) + const oldServerStatus = createCompatibleRuntimeStatusResponse('runtime-old') + if (oldServerStatus.ok) { + oldServerStatus.result.capabilities = oldServerStatus.result.capabilities?.filter( + (capability) => capability !== 'linear.issue-attribute-filter.v1' + ) + } + runtimeEnvironmentTransportCall.mockImplementation((args: RuntimeEnvironmentCallRequest) => + args.method === 'status.get' ? oldServerStatus : runtimeEnvironmentCall(args) + ) + + await expect( + linearListIssues({ activeRuntimeEnvironmentId: 'env-1' }, 'all', 20, 'workspace-1', { + stateIds: ['state-1'], + priorities: [], + assignee: null, + labelIds: [] + }) + ).rejects.toMatchObject({ + name: 'LinearIssueAttributeFilterUnsupportedError', + message: 'This remote runtime must be updated to filter Linear issues.' + }) + + expect(runtimeEnvironmentTransportCall).toHaveBeenCalledTimes(1) + expect(runtimeEnvironmentCall).not.toHaveBeenCalled() + }) + + it('sends attribute filters to a runtime that advertises support', async () => { + runtimeEnvironmentCall.mockResolvedValueOnce({ + id: 'rpc-list', + ok: true, + result: { items: [] }, + _meta: { runtimeId: 'runtime-1' } + }) + + await expect( + linearListIssues({ activeRuntimeEnvironmentId: 'env-1' }, 'all', 20, 'workspace-1', { + stateIds: [], + priorities: [2], + assignee: null, + labelIds: [] + }) + ).resolves.toEqual({ items: [] }) + + expect(runtimeEnvironmentCall).toHaveBeenCalledWith({ + selector: 'env-1', + method: 'linear.listIssues', + params: { + filter: 'all', + limit: 20, + workspaceId: 'workspace-1', + attributeFilter: { + stateIds: [], + priorities: [2], + assignee: null, + labelIds: [] + } + }, + timeoutMs: 30_000 + }) + }) + it('falls back to an empty Linear issue collection for malformed list responses', async () => { runtimeEnvironmentCall.mockResolvedValueOnce({ id: 'rpc-list', diff --git a/src/renderer/src/runtime/runtime-linear-client.ts b/src/renderer/src/runtime/runtime-linear-client.ts index 04692269ec2..4bf5d5797e2 100644 --- a/src/renderer/src/runtime/runtime-linear-client.ts +++ b/src/renderer/src/runtime/runtime-linear-client.ts @@ -19,7 +19,11 @@ import type { LinearWorkspaceSelection, LinearWorkflowState } from '../../../shared/types' -import { callRuntimeRpc, getActiveRuntimeTarget } from './runtime-rpc-client' +import { + callRuntimeRpc, + getActiveRuntimeTarget, + runtimeEnvironmentSupportsCapability +} from './runtime-rpc-client' import { getTaskSourceRuntimeSettings, type TaskSourceContext @@ -30,6 +34,7 @@ import { canonicalizeLinearIssueAttributeFilter, isEmptyLinearIssueAttributeFilter } from '../../../shared/linear-issue-attribute-filter' +import { LINEAR_ISSUE_ATTRIBUTE_FILTER_RUNTIME_CAPABILITY } from '../../../shared/protocol-version' export type RuntimeLinearSettings = | Pick @@ -37,6 +42,22 @@ export type RuntimeLinearSettings = | null | undefined +// Why: mixed-version remotes must not look like an empty filtered result. The +// Linear store swallows most read failures; this typed error is rethrown so UI +// can show an upgrade message instead of "no matching issues". +export class LinearIssueAttributeFilterUnsupportedError extends Error { + constructor(message = 'This remote runtime must be updated to filter Linear issues.') { + super(message) + this.name = 'LinearIssueAttributeFilterUnsupportedError' + } +} + +export function isLinearIssueAttributeFilterUnsupportedError( + error: unknown +): error is LinearIssueAttributeFilterUnsupportedError { + return error instanceof LinearIssueAttributeFilterUnsupportedError +} + export type LinearIssueFilter = 'assigned' | 'created' | 'all' | 'completed' export type LinearConnectResult = { ok: true; viewer: LinearViewer } | { ok: false; error: string } export type LinearCreateIssueResult = @@ -208,6 +229,19 @@ export async function linearListIssues( workspaceId: workspaceId ?? undefined, ...(canonicalAttributeFilter ? { attributeFilter: canonicalAttributeFilter } : {}) } + if ( + target.kind === 'environment' && + canonicalAttributeFilter && + !(await runtimeEnvironmentSupportsCapability( + target.environmentId, + LINEAR_ISSUE_ATTRIBUTE_FILTER_RUNTIME_CAPABILITY, + 30_000 + )) + ) { + // Why: older runtimes silently strip unknown RPC params; rejecting here + // prevents their unfiltered rows from being presented or cached as filtered. + throw new LinearIssueAttributeFilterUnsupportedError() + } const result = target.kind === 'environment' ? await callRuntimeRpc(target, 'linear.listIssues', payload, { diff --git a/src/renderer/src/runtime/runtime-provider-accounts-client.test.ts b/src/renderer/src/runtime/runtime-provider-accounts-client.test.ts index 663d052e30a..c53d7198a85 100644 --- a/src/renderer/src/runtime/runtime-provider-accounts-client.test.ts +++ b/src/renderer/src/runtime/runtime-provider-accounts-client.test.ts @@ -155,6 +155,78 @@ describe('watchProviderAccounts', () => { expect(snapshots).toHaveLength(0) }) + it('keeps a healthy local provider snapshot when the other provider fails', async () => { + const codexState = { ...emptyCodexState(), activeAccountId: 'codex-local' } + claudeListLocal.mockRejectedValue(new Error('Claude keychain unavailable')) + codexListLocal.mockResolvedValue(codexState) + const snapshots: ProviderAccountsSnapshot[] = [] + const errors: unknown[] = [] + + watchProviderAccounts(LOCAL, { + onSnapshot: (snapshot) => snapshots.push(snapshot), + onError: (error) => errors.push(error) + }) + await flushMicrotasks() + + expect(snapshots).toEqual([ + { + claude: emptyClaudeState(), + codex: codexState, + rateLimits: null, + failedProviders: ['claude'] + } + ]) + expect(errors).toHaveLength(1) + expect((errors[0] as Error).message).toBe( + 'Could not load Claude accounts: Claude keychain unavailable' + ) + }) + + it('keeps a healthy Claude snapshot when only Codex fails', async () => { + const claudeState = { ...emptyClaudeState(), activeAccountId: 'claude-local' } + claudeListLocal.mockResolvedValue(claudeState) + codexListLocal.mockRejectedValue(new Error('Codex home missing')) + const snapshots: ProviderAccountsSnapshot[] = [] + const errors: unknown[] = [] + + watchProviderAccounts(LOCAL, { + onSnapshot: (snapshot) => snapshots.push(snapshot), + onError: (error) => errors.push(error) + }) + await flushMicrotasks() + + expect(snapshots).toEqual([ + { + claude: claudeState, + codex: emptyCodexState(), + rateLimits: null, + failedProviders: ['codex'] + } + ]) + expect(errors).toHaveLength(1) + expect((errors[0] as Error).message).toBe('Could not load Codex accounts: Codex home missing') + }) + + it('aggregates errors without a snapshot when both local providers fail', async () => { + claudeListLocal.mockRejectedValue(new Error('Claude keychain unavailable')) + codexListLocal.mockRejectedValue(new Error('Codex home missing')) + const snapshots: ProviderAccountsSnapshot[] = [] + const errors: unknown[] = [] + + watchProviderAccounts(LOCAL, { + onSnapshot: (snapshot) => snapshots.push(snapshot), + onError: (error) => errors.push(error) + }) + await flushMicrotasks() + + expect(snapshots).toHaveLength(0) + expect(errors).toHaveLength(1) + expect(errors[0]).toBeInstanceOf(AggregateError) + expect((errors[0] as AggregateError).message).toContain('Could not load Claude accounts') + expect((errors[0] as AggregateError).message).toContain('Could not load Codex accounts') + expect((errors[0] as AggregateError).errors).toHaveLength(2) + }) + it('streams remote snapshots from accounts.subscribe and unsubscribes on close', async () => { const snapshots: ProviderAccountsSnapshot[] = [] const watcher = watchProviderAccounts(REMOTE, { @@ -232,6 +304,21 @@ describe('fetchProviderAccountsSnapshot', () => { await expect(pending).rejects.toThrow('subscription closed') }) + + it('resolves partial local snapshots so one healthy provider is still usable', async () => { + const codexState = { ...emptyCodexState(), activeAccountId: 'codex-local' } + claudeListLocal.mockRejectedValue(new Error('Claude keychain unavailable')) + codexListLocal.mockResolvedValue(codexState) + + // Why: one-shot consumers (status bar menus) must keep the healthy provider + // even when the sibling list rejects instead of seeing a rejected promise. + await expect(fetchProviderAccountsSnapshot(LOCAL)).resolves.toEqual({ + claude: emptyClaudeState(), + codex: codexState, + rateLimits: null, + failedProviders: ['claude'] + }) + }) }) describe('provider account mutations', () => { diff --git a/src/renderer/src/runtime/runtime-provider-accounts-client.ts b/src/renderer/src/runtime/runtime-provider-accounts-client.ts index 1bb85fdea85..ec579069c87 100644 --- a/src/renderer/src/runtime/runtime-provider-accounts-client.ts +++ b/src/renderer/src/runtime/runtime-provider-accounts-client.ts @@ -12,6 +12,9 @@ export type ProviderAccountsSnapshot = { claude: ClaudeRateLimitAccountsState codex: CodexRateLimitAccountsState rateLimits: RateLimitState | null + // Why: a partial local load substitutes an empty state for the failed + // provider; consumers must not treat that half as an authoritative roster. + failedProviders?: ('claude' | 'codex')[] } type ProviderAccountSelection = { @@ -41,6 +44,19 @@ export type ProviderAccountsWatcher = { close: () => void } +export function emptyClaudeAccountsState(): ClaudeRateLimitAccountsState { + return { accounts: [], activeAccountId: null, activeAccountIdsByRuntime: { host: null, wsl: {} } } +} + +export function emptyCodexAccountsState(): CodexRateLimitAccountsState { + return { accounts: [], activeAccountId: null, activeAccountIdsByRuntime: { host: null, wsl: {} } } +} + +function providerAccountsLoadError(provider: 'Claude' | 'Codex', cause: unknown): Error { + const message = String((cause as Error)?.message ?? cause) + return new Error(`Could not load ${provider} accounts: ${message}`) +} + // Watches the provider-account snapshot for whichever runtime owns accounts. // Local: one-shot reads of the desktop services (they have no push channel // here; the pane refetches after each mutation). Remote: a live @@ -58,17 +74,50 @@ export function watchProviderAccounts( const target = getActiveRuntimeTarget(settings) if (target.kind === 'local') { let closed = false - void Promise.all([window.api.claudeAccounts.list(), window.api.codexAccounts.list()]) - .then(([claude, codex]) => { - if (!closed) { - handlers.onSnapshot({ claude, codex, rateLimits: null }) - } + void Promise.allSettled([ + window.api.claudeAccounts.list(), + window.api.codexAccounts.list() + ]).then(([claudeResult, codexResult]) => { + if (closed) { + return + } + + const claudeError = + claudeResult.status === 'rejected' + ? providerAccountsLoadError('Claude', claudeResult.reason) + : null + const codexError = + codexResult.status === 'rejected' + ? providerAccountsLoadError('Codex', codexResult.reason) + : null + if (claudeError && codexError) { + const errors = [claudeError, codexError] + handlers.onError(new AggregateError(errors, errors.map((error) => error.message).join(' '))) + return + } + + const failedProviders: ('claude' | 'codex')[] = [] + if (claudeError) { + failedProviders.push('claude') + } + if (codexError) { + failedProviders.push('codex') + } + handlers.onSnapshot({ + claude: + claudeResult.status === 'fulfilled' ? claudeResult.value : emptyClaudeAccountsState(), + codex: codexResult.status === 'fulfilled' ? codexResult.value : emptyCodexAccountsState(), + rateLimits: null, + ...(failedProviders.length > 0 ? { failedProviders } : {}) }) - .catch((error: unknown) => { - if (!closed) { + // Why: publish the healthy provider first so one-shot consumers keep it, + // but re-check closed since an onSnapshot handler may close the watcher. + for (const error of [claudeError, codexError]) { + if (error && !closed) { handlers.onError(error) } - }) + } + }) return { close: () => { closed = true diff --git a/src/renderer/src/runtime/runtime-rpc-client.test.ts b/src/renderer/src/runtime/runtime-rpc-client.test.ts index 515f8573586..0bf2ac4f8f0 100644 --- a/src/renderer/src/runtime/runtime-rpc-client.test.ts +++ b/src/renderer/src/runtime/runtime-rpc-client.test.ts @@ -5,6 +5,7 @@ import { clearRecentRuntimeCompatibilityFailure, clearRuntimeCompatibilityCacheForTests, getActiveRuntimeTarget, + runtimeEnvironmentSupportsCapability, RuntimeRpcCallError, unwrapRuntimeRpcResult } from './runtime-rpc-client' @@ -456,6 +457,156 @@ describe('runtime RPC client routing', () => { ).resolves.toBeUndefined() }) + it('re-probes capability support after a failed compatibility cache entry', async () => { + let statusCalls = 0 + runtimeEnvironmentCall.mockImplementation(({ method }: { method: string }) => { + if (method === 'status.get') { + statusCalls += 1 + if (statusCalls === 1) { + return Promise.resolve({ + id: 'status', + ok: false, + error: { code: 'runtime_unavailable', message: 'offline' }, + _meta: { runtimeId: 'remote-runtime' } + }) + } + return Promise.resolve({ + id: 'status', + ok: true, + result: { + runtimeId: 'remote-runtime', + graphStatus: 'ready', + runtimeProtocolVersion: RUNTIME_PROTOCOL_VERSION, + minCompatibleRuntimeClientVersion: MIN_COMPATIBLE_RUNTIME_CLIENT_VERSION, + capabilities: ['linear.issue-attribute-filter.v1'] + }, + _meta: { runtimeId: 'remote-runtime' } + }) + } + return Promise.resolve({ + id: method, + ok: true, + result: { ok: true }, + _meta: { runtimeId: 'remote-runtime' } + }) + }) + const target = { kind: 'environment', environmentId: 'env-cap-recover' } as const + + await expect(callRuntimeRpc(target, 'repo.list')).rejects.toThrow('offline') + await expect( + runtimeEnvironmentSupportsCapability('env-cap-recover', 'linear.issue-attribute-filter.v1') + ).resolves.toBe(true) + expect(statusCalls).toBe(2) + }) + + it('re-probes a missing capability on retry so a runtime upgrade can recover', async () => { + let statusCalls = 0 + runtimeEnvironmentCall.mockImplementation(() => { + statusCalls += 1 + return Promise.resolve({ + id: 'status', + ok: true, + result: { + runtimeId: 'remote-runtime', + graphStatus: 'ready', + runtimeProtocolVersion: RUNTIME_PROTOCOL_VERSION, + minCompatibleRuntimeClientVersion: MIN_COMPATIBLE_RUNTIME_CLIENT_VERSION, + capabilities: statusCalls === 1 ? [] : ['linear.issue-attribute-filter.v1'] + }, + _meta: { runtimeId: 'remote-runtime' } + }) + }) + + await expect( + runtimeEnvironmentSupportsCapability('env-cap-upgrade', 'linear.issue-attribute-filter.v1') + ).resolves.toBe(false) + await expect( + runtimeEnvironmentSupportsCapability('env-cap-upgrade', 'linear.issue-attribute-filter.v1') + ).resolves.toBe(true) + expect(statusCalls).toBe(2) + }) + + it('coalesces concurrent cold-cache capability probes onto one status.get', async () => { + let statusCalls = 0 + runtimeEnvironmentCall.mockImplementation(() => { + statusCalls += 1 + return Promise.resolve({ + id: 'status', + ok: true, + result: { + runtimeId: 'remote-runtime', + graphStatus: 'ready', + runtimeProtocolVersion: RUNTIME_PROTOCOL_VERSION, + minCompatibleRuntimeClientVersion: MIN_COMPATIBLE_RUNTIME_CLIENT_VERSION, + capabilities: ['linear.issue-attribute-filter.v1'] + }, + _meta: { runtimeId: 'remote-runtime' } + }) + }) + + const [a, b, c] = await Promise.all([ + runtimeEnvironmentSupportsCapability( + 'env-cap-concurrent', + 'linear.issue-attribute-filter.v1' + ), + runtimeEnvironmentSupportsCapability( + 'env-cap-concurrent', + 'linear.issue-attribute-filter.v1' + ), + runtimeEnvironmentSupportsCapability('env-cap-concurrent', 'linear.issue-attribute-filter.v1') + ]) + + expect([a, b, c]).toEqual([true, true, true]) + expect(statusCalls).toBe(1) + }) + + it('expires a supported capability verdict so a runtime downgrade is detected', async () => { + vi.useFakeTimers() + vi.setSystemTime(new Date(0)) + try { + let statusCalls = 0 + runtimeEnvironmentCall.mockImplementation(() => { + statusCalls += 1 + return Promise.resolve({ + id: 'status', + ok: true, + result: { + runtimeId: 'remote-runtime', + graphStatus: 'ready', + runtimeProtocolVersion: RUNTIME_PROTOCOL_VERSION, + minCompatibleRuntimeClientVersion: MIN_COMPATIBLE_RUNTIME_CLIENT_VERSION, + capabilities: statusCalls === 1 ? ['linear.issue-attribute-filter.v1'] : [] + }, + _meta: { runtimeId: 'remote-runtime' } + }) + }) + + await expect( + runtimeEnvironmentSupportsCapability( + 'env-cap-downgrade', + 'linear.issue-attribute-filter.v1' + ) + ).resolves.toBe(true) + vi.setSystemTime(new Date(59_999)) + await expect( + runtimeEnvironmentSupportsCapability( + 'env-cap-downgrade', + 'linear.issue-attribute-filter.v1' + ) + ).resolves.toBe(true) + vi.setSystemTime(new Date(60_000)) + await expect( + runtimeEnvironmentSupportsCapability( + 'env-cap-downgrade', + 'linear.issue-attribute-filter.v1' + ) + ).resolves.toBe(false) + expect(statusCalls).toBe(2) + } finally { + vi.useRealTimers() + } + }) + it('rejects missing advertised runtime capabilities with the caller message', async () => { runtimeEnvironmentCall.mockResolvedValue({ id: 'status', diff --git a/src/renderer/src/runtime/runtime-rpc-client.ts b/src/renderer/src/runtime/runtime-rpc-client.ts index 4b47315db34..e9b58f9d694 100644 --- a/src/renderer/src/runtime/runtime-rpc-client.ts +++ b/src/renderer/src/runtime/runtime-rpc-client.ts @@ -9,6 +9,9 @@ export type RuntimeClientTarget = { kind: 'local' } | { kind: 'environment'; env const RUNTIME_COMPATIBILITY_CACHE_MAX = 32 const RECENT_RUNTIME_COMPATIBILITY_FAILURE_TTL_MS = 60_000 +// Why: a saved environment can restart into a different Orca version without +// changing ids; capability verdicts must eventually follow that version change. +const RUNTIME_CAPABILITY_STATUS_TTL_MS = 60_000 type RuntimeCompatibilityCacheEntry = { check: Promise @@ -16,6 +19,8 @@ type RuntimeCompatibilityCacheEntry = { // True only once status.get settled and proved compatible. Stays false while // the probe is in flight, so a recovery clear can drop a doomed pending probe. provenCompatible: boolean + status: RuntimeStatus | null + statusCheckedAt: number | null } const runtimeCompatibilityChecks = new Map() @@ -108,7 +113,9 @@ async function ensureRuntimeEnvironmentCompatible( const entry: RuntimeCompatibilityCacheEntry = { check: Promise.resolve(), failedAt: null, - provenCompatible: false + provenCompatible: false, + status: null, + statusCheckedAt: null } const check = (async () => { const response = await window.api.runtimeEnvironments.call({ @@ -120,6 +127,8 @@ async function ensureRuntimeEnvironmentCompatible( response as RuntimeRpcResponse ) assertRuntimeStatusCompatible(status) + entry.status = status + entry.statusCheckedAt = Date.now() })() entry.check = check rememberRuntimeEnvironmentCompatibility(environmentId, entry) @@ -211,7 +220,9 @@ export function markRuntimeEnvironmentCompatible(environmentId: string): void { rememberRuntimeEnvironmentCompatibility(trimmed, { check: Promise.resolve(), failedAt: null, - provenCompatible: true + provenCompatible: true, + status: null, + statusCheckedAt: null }) } @@ -219,17 +230,87 @@ export async function getRuntimeEnvironmentStatus( environmentId: string, timeoutMs?: number ): Promise { - const response = await window.api.runtimeEnvironments.call({ - selector: environmentId, - method: 'status.get', - timeoutMs - }) - const status = unwrapRuntimeRpcResult( - response as RuntimeRpcResponse - ) - assertRuntimeStatusCompatible(status) - markRuntimeEnvironmentCompatible(environmentId) - return status + const trimmed = environmentId.trim() + const entry: RuntimeCompatibilityCacheEntry = { + check: Promise.resolve(), + failedAt: null, + provenCompatible: false, + status: null, + statusCheckedAt: null + } + // Why: publish the in-flight probe before awaiting so concurrent cold-cache + // capability lookups coalesce onto this one status.get (via the cache-hit path + // in runtimeEnvironmentSupportsCapability) instead of each firing their own. + const check = (async () => { + const response = await window.api.runtimeEnvironments.call({ + selector: trimmed, + method: 'status.get', + timeoutMs + }) + const status = unwrapRuntimeRpcResult( + response as RuntimeRpcResponse + ) + assertRuntimeStatusCompatible(status) + entry.status = status + entry.statusCheckedAt = Date.now() + entry.provenCompatible = true + })() + entry.check = check + rememberRuntimeEnvironmentCompatibility(trimmed, entry) + try { + await check + } catch (error) { + // Why: this probe always re-fetches, so a failure must not linger as a + // cached verdict; drop the entry so the next call re-probes cleanly. + if (runtimeCompatibilityChecks.get(trimmed) === entry) { + runtimeCompatibilityChecks.delete(trimmed) + } + throw error + } + if (!entry.status) { + // Unreachable: a resolved probe always assigns status; narrows the type. + throw new Error('Runtime status probe resolved without a status.') + } + return entry.status +} + +export async function runtimeEnvironmentSupportsCapability( + environmentId: string, + capability: RuntimeCapability, + timeoutMs?: number +): Promise { + const trimmed = environmentId.trim() + const cached = runtimeCompatibilityChecks.get(trimmed) + // Why: callRuntimeRpc re-probes after failed status checks by default. Capability + // lookups must not pin to a rejected cache promise or they block recovery for + // the full failure TTL even though the next RPC would re-probe successfully. + if (cached && cached.failedAt === null) { + try { + await cached.check + if ( + runtimeCompatibilityChecks.get(trimmed) === cached && + cached.status && + cached.statusCheckedAt !== null && + Date.now() - cached.statusCheckedAt < RUNTIME_CAPABILITY_STATUS_TTL_MS + ) { + const supported = cached.status.capabilities?.includes(capability) === true + if (!supported) { + // Why: an unsupported verdict must not survive a remote upgrade. The + // next explicit retry re-probes instead of pinning the old capability set. + runtimeCompatibilityChecks.delete(trimmed) + } + return supported + } + } catch { + // Fall through to a fresh status.get that refreshes the cache. + } + } + const status = await getRuntimeEnvironmentStatus(trimmed, timeoutMs) + const supported = status.capabilities?.includes(capability) === true + if (!supported && runtimeCompatibilityChecks.get(trimmed)?.status === status) { + runtimeCompatibilityChecks.delete(trimmed) + } + return supported } export async function assertRuntimeEnvironmentCapability( diff --git a/src/renderer/src/store/slices/linear-invalidation.test.ts b/src/renderer/src/store/slices/linear-invalidation.test.ts index fdea0a7d19e..95c1d99ffaf 100644 --- a/src/renderer/src/store/slices/linear-invalidation.test.ts +++ b/src/renderer/src/store/slices/linear-invalidation.test.ts @@ -16,6 +16,7 @@ vi.mock('@/runtime/runtime-linear-client', () => ({ linearDisconnect: vi.fn(), linearDisconnectWorkspace: vi.fn(), linearGetIssue: (...args: unknown[]) => linearGetIssue(...args), + isLinearIssueAttributeFilterUnsupportedError: () => false, linearListIssues: (...args: unknown[]) => linearListIssues(...args), linearListTeams: (...args: unknown[]) => linearListTeams(...args), linearSearchIssues: (...args: unknown[]) => linearSearchIssues(...args), diff --git a/src/renderer/src/store/slices/linear.test.ts b/src/renderer/src/store/slices/linear.test.ts index ca342f79f6f..f18f27a3261 100644 --- a/src/renderer/src/store/slices/linear.test.ts +++ b/src/renderer/src/store/slices/linear.test.ts @@ -17,6 +17,7 @@ import { type TaskSourceContext } from '../../../../shared/task-source-context' import { credentialDecryptionMessage } from '../../../../shared/integration-credential-errors' +import { LinearIssueAttributeFilterUnsupportedError } from '@/runtime/runtime-linear-client' import { createLinearSlice } from './linear' const linearStatus = vi.fn() @@ -35,25 +36,29 @@ const linearListCustomViewIssues = vi.fn() const linearListCustomViewProjects = vi.fn() const linearTestConnection = vi.fn() -vi.mock('@/runtime/runtime-linear-client', () => ({ - linearConnect: (...args: unknown[]) => linearConnect(...args), - linearDisconnect: (...args: unknown[]) => linearDisconnect(...args), - linearDisconnectWorkspace: vi.fn(), - linearGetCustomView: (...args: unknown[]) => linearGetCustomView(...args), - linearGetProject: (...args: unknown[]) => linearGetProject(...args), - linearGetIssue: (...args: unknown[]) => linearGetIssue(...args), - linearListCustomViewIssues: (...args: unknown[]) => linearListCustomViewIssues(...args), - linearListCustomViewProjects: (...args: unknown[]) => linearListCustomViewProjects(...args), - linearListCustomViews: (...args: unknown[]) => linearListCustomViews(...args), - linearListIssues: (...args: unknown[]) => linearListIssues(...args), - linearListProjectIssues: (...args: unknown[]) => linearListProjectIssues(...args), - linearListProjects: (...args: unknown[]) => linearListProjects(...args), - linearListTeams: (...args: unknown[]) => linearListTeams(...args), - linearSearchIssues: (...args: unknown[]) => linearSearchIssues(...args), - linearSelectWorkspace: vi.fn(), - linearStatus: (...args: unknown[]) => linearStatus(...args), - linearTestConnection: (...args: unknown[]) => linearTestConnection(...args) -})) +vi.mock('@/runtime/runtime-linear-client', async (importOriginal) => { + const actual = await importOriginal>() + return { + ...actual, + linearConnect: (...args: unknown[]) => linearConnect(...args), + linearDisconnect: (...args: unknown[]) => linearDisconnect(...args), + linearDisconnectWorkspace: vi.fn(), + linearGetCustomView: (...args: unknown[]) => linearGetCustomView(...args), + linearGetProject: (...args: unknown[]) => linearGetProject(...args), + linearGetIssue: (...args: unknown[]) => linearGetIssue(...args), + linearListCustomViewIssues: (...args: unknown[]) => linearListCustomViewIssues(...args), + linearListCustomViewProjects: (...args: unknown[]) => linearListCustomViewProjects(...args), + linearListCustomViews: (...args: unknown[]) => linearListCustomViews(...args), + linearListIssues: (...args: unknown[]) => linearListIssues(...args), + linearListProjectIssues: (...args: unknown[]) => linearListProjectIssues(...args), + linearListProjects: (...args: unknown[]) => linearListProjects(...args), + linearListTeams: (...args: unknown[]) => linearListTeams(...args), + linearSearchIssues: (...args: unknown[]) => linearSearchIssues(...args), + linearSelectWorkspace: vi.fn(), + linearStatus: (...args: unknown[]) => linearStatus(...args), + linearTestConnection: (...args: unknown[]) => linearTestConnection(...args) + } +}) vi.mock('../../hooks/useIssueMetadata', () => ({ clearLinearMetadataCache: vi.fn() @@ -274,6 +279,50 @@ describe('createLinearSlice caching', () => { ).resolves.toMatchObject({ items: [{ id: 'LIN-CACHED' }] }) }) + it('rethrows attribute-filter unsupported errors so UI can surface an upgrade message', async () => { + const store = createTestStore() + store.setState({ + linearStatus: { connected: true, viewer: null, selectedWorkspaceId: 'workspace-1' } + }) + const attributeFilter = { + stateIds: ['state-1'], + priorities: [] as number[], + assignee: null as null, + labelIds: [] as string[] + } + // Seed via a successful read so the cache key matches production signing. + linearListIssues.mockResolvedValueOnce({ items: [issue('LIN-CACHED')] }) + await store.getState().listLinearIssues({ + kind: 'list', + filter: 'all', + limit: 36, + attributeFilter + }) + linearListIssues.mockRejectedValueOnce(new LinearIssueAttributeFilterUnsupportedError()) + + await expect( + store.getState().listLinearIssues( + { + kind: 'list', + filter: 'all', + limit: 36, + attributeFilter + }, + { force: true } + ) + ).rejects.toBeInstanceOf(LinearIssueAttributeFilterUnsupportedError) + + // Why: must not replace a warm filtered cache with empty on capability miss. + expect( + store.getState().getCachedLinearIssues({ + kind: 'list', + filter: 'all', + limit: 36, + attributeFilter + }) + ).toMatchObject({ items: [{ id: 'LIN-CACHED' }] }) + }) + it('returns an empty list and refreshes status on Linear decrypt errors during list reads', async () => { const store = createTestStore() const error = new Error(credentialDecryptionMessage('Linear')) diff --git a/src/renderer/src/store/slices/linear.ts b/src/renderer/src/store/slices/linear.ts index e62a883b5b4..63991d49148 100644 --- a/src/renderer/src/store/slices/linear.ts +++ b/src/renderer/src/store/slices/linear.ts @@ -22,6 +22,7 @@ import { clampLinearIssueListLimit } from '../../../../shared/linear-issue-read- import { isIntegrationCredentialDecryptionError } from '../../../../shared/integration-credential-errors' import { clearLinearMetadataCache } from '../../hooks/useIssueMetadata' import { + isLinearIssueAttributeFilterUnsupportedError, linearConnect, linearDisconnect, linearDisconnectWorkspace, @@ -1238,6 +1239,11 @@ export const createLinearSlice: StateCreator = (s }) .catch((error) => { console.warn('[linear] listLinearIssues failed:', error) + // Why: capability mismatch is actionable (update remote runtime). Swallowing + // it as [] would look like "no issues match filters" and hide the fix. + if (isLinearIssueAttributeFilterUnsupportedError(error)) { + throw error + } if ( (isIntegrationCredentialDecryptionError(error) || looksLikeAuthError(error)) && canWriteLinearReadResult( diff --git a/src/renderer/src/store/slices/workspace-cleanup.ts b/src/renderer/src/store/slices/workspace-cleanup.ts index ed2e12d49be..bffe5fb93d4 100644 --- a/src/renderer/src/store/slices/workspace-cleanup.ts +++ b/src/renderer/src/store/slices/workspace-cleanup.ts @@ -21,6 +21,7 @@ import { type WorkspaceCleanupScanProgress, type WorkspaceCleanupScanResult } from '../../../../shared/workspace-cleanup' +import { mapWithConcurrency } from '../../../../shared/map-with-concurrency' import { classifyTitleActivity, isExplicitAgentStatusFresh } from '@/lib/pane-agent-evidence' import { translate } from '@/i18n/i18n' @@ -553,26 +554,6 @@ function getWorkspaceCleanupProgressCandidateIndex( } } -async function mapWithConcurrency( - items: readonly T[], - limit: number, - fn: (item: T) => Promise -): Promise { - const results: R[] = [] - let nextIndex = 0 - const workerCount = Math.min(limit, items.length) - await Promise.all( - Array.from({ length: workerCount }, async () => { - while (nextIndex < items.length) { - const index = nextIndex - nextIndex += 1 - results[index] = await fn(items[index]) - } - }) - ) - return results -} - function getInitialWorkspaceCleanupGitDeferrals(state: AppState): string[] { const ids = new Set() if (state.activeWorktreeId) { diff --git a/src/shared/automation-run-retention.test.ts b/src/shared/automation-run-retention.test.ts new file mode 100644 index 00000000000..c405c43f37b --- /dev/null +++ b/src/shared/automation-run-retention.test.ts @@ -0,0 +1,173 @@ +import { describe, expect, it } from 'vitest' +import type { AutomationRun } from './automations-types' +import { + MAX_AUTOMATION_RUNS_PER_AUTOMATION, + backfillAutomationRunNumbers, + nextAutomationRunNumber, + pruneAutomationRuns +} from './automation-run-retention' + +function run(overrides: Partial & Pick) { + return { + runContext: null, + sourceContext: null, + title: overrides.id, + scheduledFor: 0, + status: 'skipped_precheck', + trigger: 'scheduled', + workspaceId: null, + sessionKind: 'terminal', + chatSessionId: null, + terminalSessionId: null, + terminalPaneKey: null, + terminalPtyId: null, + outputSnapshot: null, + precheckResult: null, + usage: null, + error: null, + startedAt: null, + dispatchedAt: null, + createdAt: 0, + ...overrides + } as AutomationRun +} + +function makeRuns(automationId: string, count: number, from = 0): AutomationRun[] { + return Array.from({ length: count }, (_, i) => + run({ id: `${automationId}-${from + i}`, automationId, createdAt: from + i }) + ) +} + +describe('pruneAutomationRuns', () => { + it('keeps everything below the cap', () => { + const runs = makeRuns('a', 5) + expect(pruneAutomationRuns(runs, 10)).toEqual(runs) + }) + + it('keeps only the newest N per automation', () => { + const kept = pruneAutomationRuns(makeRuns('a', 10), 3) + expect(kept.map((r) => r.id)).toEqual(['a-7', 'a-8', 'a-9']) + }) + + it('caps each automation independently', () => { + const runs = [...makeRuns('a', 6), ...makeRuns('b', 2)] + const kept = pruneAutomationRuns(runs, 3) + expect(kept.filter((r) => r.automationId === 'a')).toHaveLength(3) + // 'b' is under the cap and must survive intact. + expect(kept.filter((r) => r.automationId === 'b')).toHaveLength(2) + }) + + it('preserves original append order among survivors', () => { + const runs = [...makeRuns('a', 4), ...makeRuns('b', 4)] + const kept = pruneAutomationRuns(runs, 2) + expect(kept.map((r) => r.id)).toEqual(['a-2', 'a-3', 'b-2', 'b-3']) + }) + + it('breaks createdAt ties on scheduledFor so pruning is deterministic', () => { + const runs = [ + run({ id: 'x', automationId: 'a', createdAt: 5, scheduledFor: 1 }), + run({ id: 'y', automationId: 'a', createdAt: 5, scheduledFor: 2 }) + ] + expect(pruneAutomationRuns(runs, 1).map((r) => r.id)).toEqual(['y']) + }) + + it('returns nothing when the cap is zero or negative', () => { + expect(pruneAutomationRuns(makeRuns('a', 3), 0)).toEqual([]) + expect(pruneAutomationRuns(makeRuns('a', 3), -1)).toEqual([]) + }) + + // Why: a dispatched run's completion can land hours later; evicting the row + // would make updateAutomationRun throw 'Automation run not found.' + it('never evicts in-flight runs, even far past the cap', () => { + const inFlight = [ + run({ id: 'old-pending', automationId: 'a', status: 'pending', createdAt: -3 }), + run({ id: 'old-dispatching', automationId: 'a', status: 'dispatching', createdAt: -2 }), + run({ id: 'old-dispatched', automationId: 'a', status: 'dispatched', createdAt: -1 }) + ] + const kept = pruneAutomationRuns([...inFlight, ...makeRuns('a', 10)], 3) + expect(kept.map((r) => r.id)).toEqual([ + 'old-pending', + 'old-dispatching', + 'old-dispatched', + 'a-7', + 'a-8', + 'a-9' + ]) + }) + + it('shrinks a realistic runaway history to the cap', () => { + const runaway = [ + ...makeRuns('a', 2796), + ...makeRuns('b', 2796), + ...makeRuns('c', 2796), + ...makeRuns('d', 2796) + ] + expect(runaway).toHaveLength(11_184) + expect(pruneAutomationRuns(runaway)).toHaveLength(4 * MAX_AUTOMATION_RUNS_PER_AUTOMATION) + }) +}) + +describe('backfillAutomationRunNumbers', () => { + it('numbers legacy runs by append position within their automation', () => { + const runs = [ + run({ id: 'a-0', automationId: 'a' }), + run({ id: 'b-0', automationId: 'b' }), + run({ id: 'a-1', automationId: 'a' }) + ] + expect(backfillAutomationRunNumbers(runs).map((r) => r.runNumber)).toEqual([1, 1, 2]) + }) + + it('leaves an existing runNumber untouched', () => { + const runs = [run({ id: 'a-0', automationId: 'a', runNumber: 42 })] + expect(backfillAutomationRunNumbers(runs)[0].runNumber).toBe(42) + }) + + // Why: a downgrade to a build that predates `runNumber` appends unnumbered runs + // after pruned, high-numbered survivors. Numbering by append position reissues a + // number a survivor still holds, and the two runs end up sharing a title. + it('never reissues a number a numbered run already holds', () => { + const runs = [ + run({ id: 'a-0', automationId: 'a', runNumber: 2 }), + run({ id: 'a-1', automationId: 'a' }) + ] + const numbers = backfillAutomationRunNumbers(runs).map((r) => r.runNumber) + expect(numbers).toEqual([2, 3]) + expect(new Set(numbers).size).toBe(numbers.length) + }) + + it('numbers unnumbered runs above the highest survivor, per automation', () => { + const runs = [ + run({ id: 'a-0', automationId: 'a', runNumber: 200 }), + run({ id: 'b-0', automationId: 'b', runNumber: 7 }), + run({ id: 'a-1', automationId: 'a' }), + run({ id: 'b-1', automationId: 'b' }) + ] + expect(backfillAutomationRunNumbers(runs).map((r) => r.runNumber)).toEqual([200, 7, 201, 8]) + }) +}) + +describe('nextAutomationRunNumber', () => { + it('continues from the highest surviving run number, not the retained count', () => { + const retained = [ + run({ id: 'a-0', automationId: 'a', runNumber: 2795 }), + run({ id: 'a-1', automationId: 'a', runNumber: 2796 }) + ] + expect(nextAutomationRunNumber(retained)).toBe(2797) + }) + + it('falls back to the count for legacy runs with no numbers', () => { + expect(nextAutomationRunNumber(makeRuns('a', 100))).toBe(101) + }) + + it('starts at 1 for a brand new automation', () => { + expect(nextAutomationRunNumber([])).toBe(1) + }) + + it('never repeats a number across a prune cycle', () => { + let runs = backfillAutomationRunNumbers(makeRuns('a', 250)) + runs = pruneAutomationRuns(runs, 100) + const next = nextAutomationRunNumber(runs) + expect(next).toBe(251) + expect(runs.some((r) => r.runNumber === next)).toBe(false) + }) +}) diff --git a/src/shared/automation-run-retention.ts b/src/shared/automation-run-retention.ts new file mode 100644 index 00000000000..3b0eae648ad --- /dev/null +++ b/src/shared/automation-run-retention.ts @@ -0,0 +1,65 @@ +import { isFinalAutomationRunStatus, type AutomationRun } from './automations-types' + +export const MAX_AUTOMATION_RUNS_PER_AUTOMATION = 100 + +// Why: the whole state blob is re-serialized on every save, so an unbounded +// automationRuns made each flush() permanently slower (28.5 MB → 210 ms blocked). +export function pruneAutomationRuns( + runs: readonly AutomationRun[], + maxPerAutomation: number = MAX_AUTOMATION_RUNS_PER_AUTOMATION +): AutomationRun[] { + const kept = new Set() + // Why: a dispatched run's completion can land hours later, and + // updateAutomationRun throws if its row is gone — only final runs are evictable. + const finalRuns = runs.filter((run) => isFinalAutomationRunStatus(run.status)) + for (const automationRuns of Map.groupBy(finalRuns, (run) => run.automationId).values()) { + // Why: `createdAt` is the append time; `scheduledFor` breaks ties so runs + // minted in the same millisecond drop in a stable, reproducible order. + automationRuns.sort((a, b) => b.createdAt - a.createdAt || b.scheduledFor - a.scheduledFor) + // Why: clamp — a negative `slice` end drops from the tail instead of keeping nothing. + for (const run of automationRuns.slice(0, Math.max(0, maxPerAutomation))) { + kept.add(run.id) + } + } + + // Survivors keep their original append order — callers index by position. + return runs.filter((run) => kept.has(run.id) || !isFinalAutomationRunStatus(run.status)) +} + +/** + * Stamp `runNumber` onto legacy runs that predate the field. Must run before + * {@link pruneAutomationRuns} so the surviving runs carry their true numbers and + * numbering never restarts. + * + * Numbers continue from the highest number the automation already carries, not from + * the run's append position: a downgrade to a pre-`runNumber` build appends unnumbered + * runs after pruned survivors numbered 101+, and a position would reissue one of those. + */ +export function backfillAutomationRunNumbers(runs: readonly AutomationRun[]): AutomationRun[] { + const highestPerAutomation = new Map() + for (const run of runs) { + if (run.runNumber !== undefined) { + const highest = highestPerAutomation.get(run.automationId) ?? 0 + highestPerAutomation.set(run.automationId, Math.max(highest, run.runNumber)) + } + } + return runs.map((run) => { + if (run.runNumber !== undefined) { + return run + } + const runNumber = (highestPerAutomation.get(run.automationId) ?? 0) + 1 + highestPerAutomation.set(run.automationId, runNumber) + return { ...run, runNumber } + }) +} + +/** Next run number for one automation, given only the runs still retained. */ +export function nextAutomationRunNumber(runsForAutomation: readonly AutomationRun[]): number { + // Why: seed with the count so legacy runs that predate `runNumber` still advance. + return ( + runsForAutomation.reduce( + (n, run) => Math.max(n, run.runNumber ?? 0), + runsForAutomation.length + ) + 1 + ) +} diff --git a/src/shared/automations-types.ts b/src/shared/automations-types.ts index 56788595e95..a41b364ef2b 100644 --- a/src/shared/automations-types.ts +++ b/src/shared/automations-types.ts @@ -17,6 +17,18 @@ export type AutomationRunStatus = | 'dispatch_failed' export type AutomationRunTrigger = 'scheduled' | 'manual' +/** Statuses a run can never leave; only these are safe to evict from history. */ +export function isFinalAutomationRunStatus(status: AutomationRunStatus): boolean { + return ( + status === 'completed' || + status === 'dispatch_failed' || + status === 'skipped_precheck' || + status === 'skipped_missed' || + status === 'skipped_unavailable' || + status === 'skipped_needs_interactive_auth' + ) +} + export type AutomationSchedulePreset = 'hourly' | 'daily' | 'weekdays' | 'weekly' | 'custom' export type AutomationRunUsageProvider = 'claude' | 'codex' export type AutomationRunUsageStatus = 'known' | 'unavailable' @@ -140,6 +152,9 @@ export type AutomationRun = { startedAt: number | null dispatchedAt: number | null createdAt: number + /** Why: run titles must stay unique once retention prunes old runs, so the + * number can no longer be derived from how many runs are currently kept. */ + runNumber?: number } export type AutomationCreateInput = { diff --git a/src/shared/git-check-ignore-stdio.test.ts b/src/shared/git-check-ignore-stdio.test.ts new file mode 100644 index 00000000000..e0b7ed789da --- /dev/null +++ b/src/shared/git-check-ignore-stdio.test.ts @@ -0,0 +1,38 @@ +import { describe, expect, it } from 'vitest' +import { + encodeGitCheckIgnorePaths, + GIT_CHECK_IGNORE_STDIN_ARGS, + parseGitCheckIgnorePaths, + splitGitCheckIgnorePathsByStdinBytes +} from './git-check-ignore-stdio' + +describe('git check-ignore stdio', () => { + it('uses the NUL-delimited stdin command shape', () => { + expect(GIT_CHECK_IGNORE_STDIN_ARGS).toEqual([ + '-c', + 'core.quotePath=false', + 'check-ignore', + '-z', + '--stdin' + ]) + expect(encodeGitCheckIgnorePaths(['dist/bundle.js', 'line\nbreak.txt', '-leading.txt'])).toBe( + 'dist/bundle.js\0line\nbreak.txt\0-leading.txt\0' + ) + }) + + it('parses exact paths without treating embedded newlines as records', () => { + expect(parseGitCheckIgnorePaths('dist/bundle.js\0line\nbreak.txt\0-leading.txt\0')).toEqual([ + 'dist/bundle.js', + 'line\nbreak.txt', + '-leading.txt' + ]) + }) + + it('bounds stdin chunks by encoded bytes without splitting a path', () => { + expect(splitGitCheckIgnorePathsByStdinBytes(['abcd', 'efgh', 'ijk'], 10)).toEqual([ + ['abcd', 'efgh'], + ['ijk'] + ]) + expect(splitGitCheckIgnorePathsByStdinBytes(['éé', 'a'], 5)).toEqual([['éé'], ['a']]) + }) +}) diff --git a/src/shared/git-check-ignore-stdio.ts b/src/shared/git-check-ignore-stdio.ts new file mode 100644 index 00000000000..40eb3c5cac6 --- /dev/null +++ b/src/shared/git-check-ignore-stdio.ts @@ -0,0 +1,42 @@ +export const GIT_CHECK_IGNORE_TIMEOUT_MS = 15_000 +export const GIT_CHECK_IGNORE_STDIN_CHUNK_BYTES = 1024 * 1024 + +export const GIT_CHECK_IGNORE_STDIN_ARGS = [ + '-c', + 'core.quotePath=false', + 'check-ignore', + '-z', + '--stdin' +] as const + +export function encodeGitCheckIgnorePaths(paths: readonly string[]): string { + return `${paths.join('\0')}\0` +} + +export function splitGitCheckIgnorePathsByStdinBytes( + paths: readonly string[], + maxBytes: number = GIT_CHECK_IGNORE_STDIN_CHUNK_BYTES +): string[][] { + const encoder = new TextEncoder() + const chunks: string[][] = [] + let chunk: string[] = [] + let chunkBytes = 0 + for (const path of paths) { + const pathBytes = encoder.encode(path).byteLength + 1 + if (chunk.length > 0 && chunkBytes + pathBytes > maxBytes) { + chunks.push(chunk) + chunk = [] + chunkBytes = 0 + } + chunk.push(path) + chunkBytes += pathBytes + } + if (chunk.length > 0) { + chunks.push(chunk) + } + return chunks +} + +export function parseGitCheckIgnorePaths(stdout: string): string[] { + return stdout.split('\0').filter((path) => path.length > 0) +} diff --git a/src/shared/map-with-concurrency.test.ts b/src/shared/map-with-concurrency.test.ts new file mode 100644 index 00000000000..fb81bc0ed75 --- /dev/null +++ b/src/shared/map-with-concurrency.test.ts @@ -0,0 +1,50 @@ +import { describe, expect, it } from 'vitest' +import { mapWithConcurrency } from './map-with-concurrency' + +describe('mapWithConcurrency', () => { + it('preserves input order in the result array regardless of settle order', async () => { + // Later items resolve sooner, so a naive push-on-resolve would reorder. + const results = await mapWithConcurrency([30, 20, 10], 3, async (ms, index) => { + await new Promise((resolve) => setTimeout(resolve, ms)) + return index + }) + expect(results).toEqual([0, 1, 2]) + }) + + it('never exceeds the concurrency limit', async () => { + let inFlight = 0 + let peak = 0 + const items = Array.from({ length: 50 }, (_, i) => i) + await mapWithConcurrency(items, 8, async () => { + inFlight += 1 + peak = Math.max(peak, inFlight) + await new Promise((resolve) => setTimeout(resolve, 1)) + inFlight -= 1 + }) + expect(peak).toBeLessThanOrEqual(8) + // With 50 items and limit 8, the pool should actually saturate. + expect(peak).toBe(8) + }) + + it('processes every item exactly once', async () => { + const seen: number[] = [] + await mapWithConcurrency([1, 2, 3, 4, 5], 2, async (n) => { + seen.push(n) + }) + expect(seen.sort((a, b) => a - b)).toEqual([1, 2, 3, 4, 5]) + }) + + it('returns an empty array for no items without spawning workers', async () => { + let calls = 0 + const results = await mapWithConcurrency([], 4, async () => { + calls += 1 + }) + expect(results).toEqual([]) + expect(calls).toBe(0) + }) + + it('clamps a limit below one to a single worker instead of stalling', async () => { + const results = await mapWithConcurrency([1, 2, 3], 0, async (n) => n * 2) + expect(results).toEqual([2, 4, 6]) + }) +}) diff --git a/src/shared/map-with-concurrency.ts b/src/shared/map-with-concurrency.ts new file mode 100644 index 00000000000..5930ddbed9b --- /dev/null +++ b/src/shared/map-with-concurrency.ts @@ -0,0 +1,24 @@ +// Why: bounded-concurrency map. `Promise.all(items.map(fn))` fans out every +// item at once — fine for a handful, but a burst of hundreds of concurrent +// IPC/RPC round-trips can swamp the transport or its call queue. This runs at +// most `limit` calls in flight via a fixed worker pool while preserving input +// order in the result array (results[i] corresponds to items[i]). +export async function mapWithConcurrency( + items: readonly T[], + limit: number, + fn: (item: T, index: number) => Promise +): Promise { + const results: R[] = [] + let nextIndex = 0 + const workerCount = Math.max(1, Math.min(limit, items.length)) + await Promise.all( + Array.from({ length: workerCount }, async () => { + while (nextIndex < items.length) { + const index = nextIndex + nextIndex += 1 + results[index] = await fn(items[index], index) + } + }) + ) + return results +} diff --git a/src/shared/protocol-version.ts b/src/shared/protocol-version.ts index 287b3ced8fc..68a693ab42b 100644 --- a/src/shared/protocol-version.ts +++ b/src/shared/protocol-version.ts @@ -27,6 +27,8 @@ export const WORKSPACE_RUN_CONTEXT_RUNTIME_CAPABILITY = 'workspace-run-context.v export const REMOTE_RUNTIME_SHARED_CONTROL_CAPABILITY = 'remote-runtime.shared-control.v1' as const export const FOLDER_WORKSPACE_PATH_STATUS_RUNTIME_CAPABILITY = 'folder-workspace.path-status.v1' as const +export const LINEAR_ISSUE_ATTRIBUTE_FILTER_RUNTIME_CAPABILITY = + 'linear.issue-attribute-filter.v1' as const // Why: signals the host exposes the Agent Session History scanner over RPC // (aiVault.listSessions). Registered unconditionally for every build, so it is a // STATIC capability advertised by getStatus() automatically — NOT a runtime @@ -50,6 +52,7 @@ export const RUNTIME_CAPABILITIES = [ TASK_SOURCE_CONTEXT_RUNTIME_CAPABILITY, WORKSPACE_RUN_CONTEXT_RUNTIME_CAPABILITY, FOLDER_WORKSPACE_PATH_STATUS_RUNTIME_CAPABILITY, + LINEAR_ISSUE_ATTRIBUTE_FILTER_RUNTIME_CAPABILITY, AI_VAULT_RUNTIME_CAPABILITY ] as const diff --git a/src/shared/remote-runtime-shared-control-boundary.test.ts b/src/shared/remote-runtime-shared-control-boundary.test.ts index da2dbd68a3d..70811d7273c 100644 --- a/src/shared/remote-runtime-shared-control-boundary.test.ts +++ b/src/shared/remote-runtime-shared-control-boundary.test.ts @@ -1,6 +1,7 @@ import { readdirSync, readFileSync, statSync } from 'node:fs' import { join } from 'node:path' -import ts from 'typescript' +// TypeScript 7 is a native CLI; AST tests still need the legacy JavaScript API. +import ts from 'typescript-api' import { describe, expect, it } from 'vitest' const FORBIDDEN_REMOTE_SERVER_IMPORTS = [ diff --git a/src/shared/runtime-rpc-envelope.ts b/src/shared/runtime-rpc-envelope.ts index c72811c18a3..8884d63313c 100644 --- a/src/shared/runtime-rpc-envelope.ts +++ b/src/shared/runtime-rpc-envelope.ts @@ -3,37 +3,50 @@ // other just to validate the shared RPC frame shape. import { z } from 'zod' -const MetaSuccess = z.object({ - runtimeId: z.string() -}) +// Why: clients and runtimes update independently; strip additive envelope +// fields while continuing to validate every known discriminator and field. +const MetaSuccess = z + .object({ + runtimeId: z.string() + }) + .strip() const MetaFailure = z .object({ runtimeId: z.union([z.string(), z.null()]) }) + .strip() .optional() -const Success = z.object({ - id: z.string(), - ok: z.literal(true), - result: z.unknown(), - _meta: MetaSuccess -}) +const Success = z + .object({ + id: z.string(), + ok: z.literal(true), + result: z.unknown(), + _meta: MetaSuccess + }) + .strip() -const Failure = z.object({ - id: z.string(), - ok: z.literal(false), - error: z.object({ - code: z.string(), - message: z.string(), - data: z.unknown().optional() - }), - _meta: MetaFailure -}) +const Failure = z + .object({ + id: z.string(), + ok: z.literal(false), + error: z + .object({ + code: z.string(), + message: z.string(), + data: z.unknown().optional() + }) + .strip(), + _meta: MetaFailure + }) + .strip() -const Keepalive = z.object({ - _keepalive: z.literal(true) -}) +const Keepalive = z + .object({ + _keepalive: z.literal(true) + }) + .strip() export const RuntimeRpcEnvelopeSchema = z.union([Success, Failure, Keepalive]) diff --git a/src/shared/subprocess-stdin-write.test.ts b/src/shared/subprocess-stdin-write.test.ts new file mode 100644 index 00000000000..49fd8e94f0d --- /dev/null +++ b/src/shared/subprocess-stdin-write.test.ts @@ -0,0 +1,30 @@ +import { EventEmitter } from 'node:events' +import type { Writable } from 'node:stream' +import { describe, expect, it, vi } from 'vitest' +import { endSubprocessStdin } from './subprocess-stdin-write' + +describe('endSubprocessStdin', () => { + it('handles an early-exit pipe error emitted while ending a large write', () => { + const pipeError = Object.assign(new Error('write EPIPE'), { code: 'EPIPE' }) + const stdin = new EventEmitter() as Writable + stdin.end = vi.fn(() => { + stdin.emit('error', pipeError) + return stdin + }) as Writable['end'] + expect(() => endSubprocessStdin(stdin, 'x'.repeat(1_000_000))).not.toThrow() + + expect(stdin.listenerCount('error')).toBe(0) + }) + + it('keeps the error handler attached for a pipe failure after the caller times out', () => { + const stdin = new EventEmitter() as Writable + stdin.end = vi.fn(() => stdin) as Writable['end'] + endSubprocessStdin(stdin, 'payload') + expect(stdin.listenerCount('error')).toBe(1) + + const pipeError = Object.assign(new Error('write EPIPE'), { code: 'EPIPE' }) + expect(() => stdin.emit('error', pipeError)).not.toThrow() + + expect(stdin.listenerCount('error')).toBe(0) + }) +}) diff --git a/src/shared/subprocess-stdin-write.ts b/src/shared/subprocess-stdin-write.ts new file mode 100644 index 00000000000..4fa2628452f --- /dev/null +++ b/src/shared/subprocess-stdin-write.ts @@ -0,0 +1,10 @@ +import type { Writable } from 'node:stream' + +export function endSubprocessStdin(stdin: Writable | null | undefined, input: string): void { + if (!stdin) { + return + } + // Why: early exit or timeout can close a large write; the child callback owns the command result. + stdin.once('error', () => {}) + stdin.end(input) +} diff --git a/tests/e2e/terminal-foreground-redraw-freeze.spec.ts b/tests/e2e/terminal-foreground-redraw-freeze.spec.ts index fe32e0236e0..fd6fad6f42d 100644 --- a/tests/e2e/terminal-foreground-redraw-freeze.spec.ts +++ b/tests/e2e/terminal-foreground-redraw-freeze.spec.ts @@ -41,11 +41,18 @@ type SchedulerDebugWindow = Window & { type RefreshProbeWindow = SchedulerDebugWindow & { __terminalRefreshProbe?: { - count: () => number + snapshot: () => RefreshProbeSnapshot dispose: () => void } } +type RefreshProbeSnapshot = { + synchronousWebgl: number + synchronousDom: number + debouncedWebgl: number + debouncedDom: number +} + const REDRAW_FRAME_COUNT = 270 const REDRAW_PAYLOAD_CHARS = 520 const REWRITE_REDRAW_FRAME_COUNT = REDRAW_FRAME_COUNT @@ -172,32 +179,115 @@ async function installActivePaneRefreshProbe(page: Page): Promise { } const terminal = pane.terminal as unknown as { _core?: { refresh?: (start: number, end: number, sync?: boolean) => void } + refresh: (start: number, end: number) => void } const originalCoreRefresh = terminal._core?.refresh?.bind(terminal._core) + const originalPublicRefresh = terminal.refresh.bind(terminal) if (!terminal._core || !originalCoreRefresh) { throw new Error('Active terminal core refresh hook is unavailable') } - let refreshCount = 0 + let synchronousWebgl = 0 + let synchronousDom = 0 + let debouncedWebgl = 0 + let debouncedDom = 0 terminal._core.refresh = (start, end, sync) => { if (sync === true) { - refreshCount += 1 + if (manager.hasWebglRenderer(pane.id)) { + synchronousWebgl += 1 + } else { + synchronousDom += 1 + } } originalCoreRefresh(start, end, sync) } + terminal.refresh = (start, end) => { + if (manager.hasWebglRenderer(pane.id)) { + debouncedWebgl += 1 + } else { + debouncedDom += 1 + } + originalPublicRefresh(start, end) + } ;(window as RefreshProbeWindow).__terminalRefreshProbe = { - count: () => refreshCount, + snapshot: () => ({ + synchronousWebgl, + synchronousDom, + debouncedWebgl, + debouncedDom + }), dispose: () => { if (terminal._core) { terminal._core.refresh = originalCoreRefresh } + terminal.refresh = originalPublicRefresh delete (window as RefreshProbeWindow).__terminalRefreshProbe } } }) } -async function readRefreshProbeCount(page: Page): Promise { - return page.evaluate(() => (window as RefreshProbeWindow).__terminalRefreshProbe?.count() ?? 0) +async function forceActivePaneWebglRenderer(page: Page): Promise { + await page.evaluate(() => { + const state = window.__store?.getState() + if (!state?.settings) { + throw new Error('Store unavailable') + } + window.__store?.setState({ + settings: { ...state.settings, terminalGpuAcceleration: 'on' } + }) + const worktreeId = state.activeWorktreeId + const tabId = + state.activeTabType === 'terminal' + ? state.activeTabId + : worktreeId + ? (state.activeTabIdByWorktree?.[worktreeId] ?? null) + : null + window.__paneManagers?.get(tabId ?? '')?.setTerminalGpuAcceleration?.('on') + }) + return page + .waitForFunction( + () => { + const state = window.__store?.getState() + const worktreeId = state?.activeWorktreeId + const tabId = + state?.activeTabType === 'terminal' + ? state.activeTabId + : worktreeId + ? (state?.activeTabIdByWorktree?.[worktreeId] ?? null) + : null + const manager = tabId ? window.__paneManagers?.get(tabId) : null + const pane = manager?.getActivePane?.() ?? manager?.getPanes?.()[0] ?? null + return pane ? manager?.hasWebglRenderer(pane.id) === true : false + }, + null, + { timeout: 10_000 } + ) + .then(() => true) + .catch(() => false) +} + +async function readRefreshProbe(page: Page): Promise { + return page.evaluate( + () => + (window as RefreshProbeWindow).__terminalRefreshProbe?.snapshot() ?? { + synchronousWebgl: 0, + synchronousDom: 0, + debouncedWebgl: 0, + debouncedDom: 0 + } + ) +} + +function subtractRefreshProbe( + current: RefreshProbeSnapshot, + baseline: RefreshProbeSnapshot +): RefreshProbeSnapshot { + return { + synchronousWebgl: current.synchronousWebgl - baseline.synchronousWebgl, + synchronousDom: current.synchronousDom - baseline.synchronousDom, + debouncedWebgl: current.debouncedWebgl - baseline.debouncedWebgl, + debouncedDom: current.debouncedDom - baseline.debouncedDom + } } async function disposeActivePaneRefreshProbe(page: Page): Promise { @@ -249,7 +339,9 @@ function annotateMeasurement( } test.describe('Terminal foreground redraw freeze repro', () => { - test('Codex-style line rewrites request a visible row refresh', async ({ orcaPage }) => { + test('Codex-style line rewrites request a visible row refresh', async ({ + orcaPage + }, testInfo) => { await waitForSessionReady(orcaPage) await waitForActiveWorktree(orcaPage) await ensureTerminalVisible(orcaPage) @@ -257,9 +349,16 @@ test.describe('Terminal foreground redraw freeze repro', () => { const { paneKey } = await waitForActivePaneHookDescriptor(orcaPage) await waitForTerminalPtyDataInjector(orcaPage, paneKey) + const webglAttached = await forceActivePaneWebglRenderer(orcaPage) + // Why: Linux headless CI intentionally disables GPU. Declare that + // environment unsupported instead of weakening the WebGL-only oracle. + test.skip(!webglAttached, 'WebGL is unavailable for the refresh-policy probe') + if (!webglAttached) { + return + } await installActivePaneRefreshProbe(orcaPage) try { - const refreshBaseline = await readRefreshProbeCount(orcaPage) + const refreshBaseline = await readRefreshProbe(orcaPage) await resetSchedulerDebug(orcaPage) const measurement = await measureRendererDuringRewriteBurst(orcaPage, paneKey) const scheduler = await readSchedulerDebug(orcaPage) @@ -268,11 +367,32 @@ test.describe('Terminal foreground redraw freeze repro', () => { expect(measurement.maxTimerDriftMs).toBeLessThan(MAX_RENDERER_TIMER_DRIFT_MS) expect(scheduler.deferredForegroundEnqueueCount).toBeGreaterThan(0) await expect - .poll(async () => (await readRefreshProbeCount(orcaPage)) - refreshBaseline, { - timeout: 5_000, - message: 'Codex-style terminal rewrites did not request an xterm refresh' - }) + .poll( + async () => { + const refresh = await readRefreshProbe(orcaPage) + const delta = subtractRefreshProbe(refresh, refreshBaseline) + return Object.values(delta).reduce((total, count) => total + count, 0) + }, + { + timeout: 5_000, + message: 'Codex-style terminal rewrites did not request an xterm refresh' + } + ) .toBeGreaterThan(0) + const refresh = await readRefreshProbe(orcaPage) + const refreshDelta = subtractRefreshProbe(refresh, refreshBaseline) + testInfo.annotations.push({ + type: 'terminal-refresh-probe', + description: `syncWebgl=${refreshDelta.synchronousWebgl} syncDom=${ + refreshDelta.synchronousDom + } debouncedWebgl=${refreshDelta.debouncedWebgl} debouncedDom=${refreshDelta.debouncedDom}` + }) + // Why: a synchronous full-grid WebGL refresh duplicates xterm's + // already-queued animation frame and was the #6655 CPU hotspot. + expect(refreshDelta.synchronousWebgl).toBe(0) + // Requiring an observed public WebGL refresh prevents a mid-run DOM + // fallback from turning the zero-sync assertion into a vacuous pass. + expect(refreshDelta.debouncedWebgl).toBeGreaterThan(0) } finally { await disposeActivePaneRefreshProbe(orcaPage) }