mirror of
https://github.com/stablyai/orca.git
synced 2026-10-22 00:03:47 +00:00
Merge branch 'main' into brennan/sta-3536-terminal-pane-owner-unverified-error-on-latest-hourly-build
This commit is contained in:
+1
-6
@@ -150,10 +150,5 @@
|
||||
}
|
||||
}
|
||||
],
|
||||
"ignorePatterns": [
|
||||
"**/node_modules",
|
||||
"**/dist",
|
||||
"**/out",
|
||||
"tests/e2e/.cross-version-checkouts"
|
||||
]
|
||||
"ignorePatterns": ["**/node_modules", "**/dist", "**/out", "tests/e2e/.cross-version-checkouts"]
|
||||
}
|
||||
|
||||
@@ -109,9 +109,7 @@ function waitForStdoutSentinel(proc, protocol, stderr) {
|
||||
settled = true
|
||||
proc.stdout.off('data', onData)
|
||||
rejectPromise(
|
||||
new Error(
|
||||
`process exited before sentinel (code=${code}, signal=${signal})\n${stderr()}`
|
||||
)
|
||||
new Error(`process exited before sentinel (code=${code}, signal=${signal})\n${stderr()}`)
|
||||
)
|
||||
}
|
||||
proc.stdout.on('data', onData)
|
||||
@@ -162,11 +160,7 @@ function createRelayClient(entryPath, args, env, protocol) {
|
||||
})
|
||||
|
||||
const waitForMessage = (startIndex, predicate, label) =>
|
||||
pollUntil(
|
||||
() => messages.slice(startIndex).find(predicate),
|
||||
label,
|
||||
streams.stderr
|
||||
)
|
||||
pollUntil(() => messages.slice(startIndex).find(predicate), label, streams.stderr)
|
||||
|
||||
const request = async (method, params = {}) => {
|
||||
const id = nextSequence++
|
||||
@@ -317,8 +311,10 @@ async function main() {
|
||||
)
|
||||
|
||||
await relay.request('fs.watch', { rootPath: watchRoot })
|
||||
const firstWatcherPid = await waitForWatcherPid(pidFile, undefined, () =>
|
||||
`${daemonStreams.stderr()}\n${relay.stderr()}`
|
||||
const firstWatcherPid = await waitForWatcherPid(
|
||||
pidFile,
|
||||
undefined,
|
||||
() => `${daemonStreams.stderr()}\n${relay.stderr()}`
|
||||
)
|
||||
const beforePath = join(watchRoot, 'before.txt')
|
||||
startIndex = relay.messageCount()
|
||||
@@ -330,8 +326,10 @@ async function main() {
|
||||
const faultSignal = process.platform === 'win32' ? 'SIGTERM' : 'SIGSEGV'
|
||||
startIndex = relay.messageCount()
|
||||
process.kill(firstWatcherPid, faultSignal)
|
||||
const replacementWatcherPid = await waitForWatcherPid(pidFile, firstWatcherPid, () =>
|
||||
`${daemonStreams.stderr()}\n${relay.stderr()}`
|
||||
const replacementWatcherPid = await waitForWatcherPid(
|
||||
pidFile,
|
||||
firstWatcherPid,
|
||||
() => `${daemonStreams.stderr()}\n${relay.stderr()}`
|
||||
)
|
||||
await relay.waitForNotification(startIndex, 'fs.changed', (params) =>
|
||||
Array.isArray(params.events)
|
||||
|
||||
@@ -63,6 +63,7 @@
|
||||
"../src/main/droid/hook-service.ts",
|
||||
"../src/main/gemini/hook-service.ts",
|
||||
"../src/main/grok/hook-service.ts",
|
||||
"../src/main/grok/windows-grok-hook-script.ts",
|
||||
"../src/main/devin/hook-settings.ts",
|
||||
"../src/main/devin/hook-service.ts",
|
||||
"../src/main/devin/hook-config-json.ts",
|
||||
|
||||
+2
-2
@@ -2,7 +2,7 @@
|
||||
"expo": {
|
||||
"name": "Orca",
|
||||
"slug": "orca-mobile",
|
||||
"version": "0.0.37",
|
||||
"version": "0.0.41",
|
||||
"orientation": "default",
|
||||
"icon": "./assets/icon.png",
|
||||
"userInterfaceStyle": "automatic",
|
||||
@@ -75,7 +75,7 @@
|
||||
"allowBackup": false,
|
||||
"permissions": ["RECORD_AUDIO", "MODIFY_AUDIO_SETTINGS"],
|
||||
"package": "com.stably.orca.mobile",
|
||||
"versionCode": 10
|
||||
"versionCode": 11
|
||||
},
|
||||
"plugins": [
|
||||
"expo-router",
|
||||
|
||||
@@ -4986,6 +4986,7 @@ export default function SessionScreen() {
|
||||
<MobileTerminalLiveInputStatus
|
||||
dictation={dictation}
|
||||
isAttaching={isAttaching}
|
||||
liveInputText={liveInputCapture}
|
||||
/>
|
||||
</Pressable>
|
||||
<MobileTerminalInputActions
|
||||
|
||||
@@ -45,7 +45,7 @@ describe('mobile accounts route', () => {
|
||||
|
||||
navigateToHostStackRoute(
|
||||
harness.navigation,
|
||||
{ push },
|
||||
{ push, replace: vi.fn() },
|
||||
'host/one',
|
||||
mobileAccountsRouteTarget('host/one')
|
||||
)
|
||||
|
||||
@@ -34,6 +34,18 @@ describe('file-tree', () => {
|
||||
])
|
||||
})
|
||||
|
||||
it('orders numbered names naturally, matching the desktop explorer', () => {
|
||||
const cache: DirectoryCache = {
|
||||
'': { entries: [entry('100 - b.txt'), entry('9 - c.txt'), entry('99 - a.txt')] }
|
||||
}
|
||||
|
||||
expect(flattenDirectoryCache(cache, new Set()).map((row) => row.id)).toEqual([
|
||||
'file:9 - c.txt',
|
||||
'file:99 - a.txt',
|
||||
'file:100 - b.txt'
|
||||
])
|
||||
})
|
||||
|
||||
it('mirrors desktop default browse exclusions while keeping dotfiles visible', () => {
|
||||
const cache: DirectoryCache = {
|
||||
'': {
|
||||
|
||||
@@ -1,5 +1,6 @@
|
||||
// Pure tree projection for the mobile file explorer. Mobile mirrors desktop
|
||||
// browse semantics by flattening cached files.readDir results as folders open.
|
||||
import { compareFileNames } from '../../../src/shared/file-name-sort'
|
||||
|
||||
export type MobileDirEntry = {
|
||||
name: string
|
||||
@@ -118,7 +119,7 @@ function compareDirectoryEntries(a: MobileDirEntry, b: MobileDirEntry): number {
|
||||
if (a.isDirectory !== b.isDirectory) {
|
||||
return a.isDirectory ? -1 : 1
|
||||
}
|
||||
return a.name.localeCompare(b.name)
|
||||
return compareFileNames(a.name, b.name)
|
||||
}
|
||||
|
||||
export function shouldIncludeMobileFileExplorerEntry(entry: MobileDirEntry): boolean {
|
||||
|
||||
@@ -2,6 +2,7 @@ import { describe, expect, it, vi } from 'vitest'
|
||||
import {
|
||||
coordinateHostStackNavigation,
|
||||
hostStackHostRoute,
|
||||
hostStackRouteHref,
|
||||
navigateToHostStackRoute,
|
||||
type HostStackNavigationState
|
||||
} from './host-stack-navigation'
|
||||
@@ -69,7 +70,7 @@ describe('host stack navigation', () => {
|
||||
const harness = navigationHarness({ index: 0, routes: [{ name: 'index' }] })
|
||||
const push = vi.fn()
|
||||
|
||||
navigateToHostStackRoute(harness.navigation, { push }, 'host/one', TARGET)
|
||||
navigateToHostStackRoute(harness.navigation, { push, replace: vi.fn() }, 'host/one', TARGET)
|
||||
expect(push).toHaveBeenCalledWith(hostStackHostRoute('host/one'))
|
||||
expect(harness.navigation.dispatch).not.toHaveBeenCalled()
|
||||
|
||||
@@ -89,7 +90,12 @@ describe('host stack navigation', () => {
|
||||
rootLayoutScopedState({ index: 0, routes: [{ name: 'index' }] })
|
||||
)
|
||||
|
||||
navigateToHostStackRoute(harness.navigation, { push: vi.fn() }, 'host/one', TARGET)
|
||||
navigateToHostStackRoute(
|
||||
harness.navigation,
|
||||
{ push: vi.fn(), replace: vi.fn() },
|
||||
'host/one',
|
||||
TARGET
|
||||
)
|
||||
harness.setState(rootLayoutScopedState(committedHostState('host/one')))
|
||||
|
||||
expect(harness.navigation.dispatch).toHaveBeenCalledWith({
|
||||
@@ -103,7 +109,12 @@ describe('host stack navigation', () => {
|
||||
it('survives the state emitted before the root navigator has hydrated', () => {
|
||||
const harness = navigationHarness(undefined)
|
||||
|
||||
navigateToHostStackRoute(harness.navigation, { push: vi.fn() }, 'host/one', TARGET)
|
||||
navigateToHostStackRoute(
|
||||
harness.navigation,
|
||||
{ push: vi.fn(), replace: vi.fn() },
|
||||
'host/one',
|
||||
TARGET
|
||||
)
|
||||
|
||||
expect(() => harness.setState(undefined)).not.toThrow()
|
||||
expect(harness.navigation.dispatch).not.toHaveBeenCalled()
|
||||
@@ -112,10 +123,30 @@ describe('host stack navigation', () => {
|
||||
expect(harness.navigation.dispatch).toHaveBeenCalledTimes(1)
|
||||
})
|
||||
|
||||
it('replaces through Expo Router when root state omits the mounted child stack', () => {
|
||||
const harness = navigationHarness({ index: 0, routes: [{ name: 'index' }] })
|
||||
const replace = vi.fn()
|
||||
|
||||
navigateToHostStackRoute(harness.navigation, { push: vi.fn(), replace }, 'host/one', TARGET)
|
||||
harness.setState({
|
||||
index: 0,
|
||||
routes: [{ name: 'h', params: { hostId: 'host/one' } }]
|
||||
})
|
||||
|
||||
expect(harness.navigation.dispatch).not.toHaveBeenCalled()
|
||||
expect(replace).toHaveBeenCalledWith(hostStackRouteHref(TARGET))
|
||||
expect(harness.listenerCount()).toBe(0)
|
||||
})
|
||||
|
||||
it('abandons the transition when navigation leaves the host route it was waiting on', () => {
|
||||
const harness = navigationHarness({ index: 0, routes: [{ name: 'index' }] })
|
||||
|
||||
navigateToHostStackRoute(harness.navigation, { push: vi.fn() }, 'host/one', TARGET)
|
||||
navigateToHostStackRoute(
|
||||
harness.navigation,
|
||||
{ push: vi.fn(), replace: vi.fn() },
|
||||
'host/one',
|
||||
TARGET
|
||||
)
|
||||
harness.setState({ index: 0, routes: [{ name: 'h' }] })
|
||||
harness.setState({ index: 0, routes: [{ name: 'index' }] })
|
||||
harness.setState(committedHostState('host/one'))
|
||||
@@ -127,7 +158,12 @@ describe('host stack navigation', () => {
|
||||
it('ignores a different host whose id merely decodes badly', () => {
|
||||
const harness = navigationHarness({ index: 0, routes: [{ name: 'index' }] })
|
||||
|
||||
navigateToHostStackRoute(harness.navigation, { push: vi.fn() }, 'host/one', TARGET)
|
||||
navigateToHostStackRoute(
|
||||
harness.navigation,
|
||||
{ push: vi.fn(), replace: vi.fn() },
|
||||
'host/one',
|
||||
TARGET
|
||||
)
|
||||
harness.setState(committedHostState('100%'))
|
||||
|
||||
expect(harness.navigation.dispatch).not.toHaveBeenCalled()
|
||||
@@ -138,7 +174,7 @@ describe('host stack navigation', () => {
|
||||
|
||||
const controller = navigateToHostStackRoute(
|
||||
harness.navigation,
|
||||
{ push: vi.fn() },
|
||||
{ push: vi.fn(), replace: vi.fn() },
|
||||
'host/one',
|
||||
TARGET
|
||||
)
|
||||
@@ -156,7 +192,7 @@ describe('host stack navigation', () => {
|
||||
it('retargets a still-pending transition to the same host instead of pushing twice', () => {
|
||||
const harness = navigationHarness({ index: 0, routes: [{ name: 'index' }] })
|
||||
const push = vi.fn()
|
||||
const router = { push }
|
||||
const router = { push, replace: vi.fn() }
|
||||
|
||||
const pending = coordinateHostStackNavigation(
|
||||
null,
|
||||
|
||||
@@ -35,8 +35,14 @@ export type HostStackRootNavigation = {
|
||||
|
||||
export type HostStackHostRoute = `/h/${string}`
|
||||
|
||||
export type HostStackRouteHref = Readonly<{
|
||||
pathname: `/h/${string}`
|
||||
params: Readonly<Record<string, string>>
|
||||
}>
|
||||
|
||||
export type HostStackRouter = {
|
||||
push: (route: HostStackHostRoute) => void
|
||||
replace: (route: HostStackRouteHref) => void
|
||||
}
|
||||
|
||||
export type HostStackNavigationController = Readonly<{
|
||||
@@ -54,6 +60,10 @@ export function hostStackHostRoute(hostId: string): HostStackHostRoute {
|
||||
return `/h/${encodeURIComponent(hostId)}`
|
||||
}
|
||||
|
||||
export function hostStackRouteHref(target: HostStackRouteTarget): HostStackRouteHref {
|
||||
return { pathname: `/h/${target.name}`, params: target.params }
|
||||
}
|
||||
|
||||
// Why: the host is pushed as an encoded segment, so the committed route may hold
|
||||
// either form — an id with `/`, `#`, or `%` must still match its own push.
|
||||
function hostParamMatches(param: unknown, expectedHostId: string): boolean {
|
||||
@@ -144,6 +154,10 @@ export function navigateToHostStackRoute(
|
||||
}
|
||||
const hostStack = hostContainer && mountedHostStack(hostContainer, hostId)
|
||||
if (!hostStack) {
|
||||
if (hostContainer && hostParamMatches(hostContainer.params?.hostId, hostId)) {
|
||||
dispose()
|
||||
router.replace(hostStackRouteHref(selectedTarget))
|
||||
}
|
||||
return
|
||||
}
|
||||
dispose()
|
||||
|
||||
@@ -47,7 +47,12 @@ describe('notification route coordination', () => {
|
||||
const harness = navigationHarness(undefined)
|
||||
const push = vi.fn()
|
||||
|
||||
navigateToHostStackRoute(harness.navigation, { push }, target!.hostId, target!.sessionTarget!)
|
||||
navigateToHostStackRoute(
|
||||
harness.navigation,
|
||||
{ push, replace: vi.fn() },
|
||||
target!.hostId,
|
||||
target!.sessionTarget!
|
||||
)
|
||||
|
||||
expect(push).toHaveBeenCalledWith(hostStackHostRoute('host/one'))
|
||||
expect(harness.navigation.dispatch).not.toHaveBeenCalled()
|
||||
|
||||
@@ -187,7 +187,7 @@ export function MobileNativeChatAsk({ prompt, onAnswer, onCancel }: Props): Reac
|
||||
disabled={!canAdvance}
|
||||
>
|
||||
<Text style={[styles.nextText, !canAdvance && styles.nextTextDisabled]}>
|
||||
{isLast ? 'Send answer' : 'Next'}
|
||||
{isLast ? 'Submit' : 'Next'}
|
||||
</Text>
|
||||
</Pressable>
|
||||
</View>
|
||||
|
||||
@@ -47,6 +47,7 @@ function overlayElement(tick: Tick): ReturnType<typeof createElement> {
|
||||
nativeChatStreamLive: tick.streamLive ?? false,
|
||||
nativeChatStreamScopeKey: tick.identity ?? 'tab-a',
|
||||
chatPending: [],
|
||||
chatImagePreviewsByMessageId: {},
|
||||
chatComposerText: '',
|
||||
setChatComposerText: vi.fn()
|
||||
} as unknown as MobileNativeChatController
|
||||
|
||||
@@ -82,6 +82,7 @@ export function MobileNativeChatOverlay({
|
||||
onLoadEarlier={session.loadEarlier}
|
||||
onSend={images.sendNativeChat}
|
||||
pending={controller.chatPending}
|
||||
imagePreviewsByMessageId={controller.chatImagePreviewsByMessageId}
|
||||
composerText={controller.chatComposerText}
|
||||
onComposerTextChange={controller.setChatComposerText}
|
||||
onAttachImage={() => void images.attachImage('library')}
|
||||
|
||||
@@ -50,8 +50,13 @@ vi.mock('./MobileAgentWorkingIndicator', () => ({
|
||||
vi.mock('./MobileNativeChatComposer', async () => {
|
||||
const React = await import('react')
|
||||
return {
|
||||
MobileNativeChatComposer: (props: { onSend: (text: string) => Promise<boolean> }) =>
|
||||
MobileNativeChatComposer: (props: {
|
||||
onSend: (text: string) => Promise<boolean>
|
||||
disabled?: boolean
|
||||
placeholder?: string
|
||||
}) =>
|
||||
React.createElement('Composer', {
|
||||
...props,
|
||||
accessibilityLabel: 'Send message',
|
||||
onPress: () => props.onSend('hi')
|
||||
})
|
||||
@@ -136,6 +141,10 @@ describe('MobileNativeChatView', () => {
|
||||
return renderer!.root.findAll((node) => node.props.accessibilityRole === 'alert')
|
||||
}
|
||||
|
||||
function composer(): ReactTestInstance {
|
||||
return renderer!.root.find((node) => node.type === 'Composer')
|
||||
}
|
||||
|
||||
function bannerText(): string {
|
||||
const [alert, ...rest] = banners()
|
||||
expect(rest).toHaveLength(0)
|
||||
@@ -197,4 +206,42 @@ describe('MobileNativeChatView', () => {
|
||||
|
||||
expect(listIds()).toEqual(['a1', 'streaming'])
|
||||
})
|
||||
|
||||
it('keeps a visible lock through a subscribed-end lease blip', async () => {
|
||||
vi.useFakeTimers()
|
||||
try {
|
||||
await render({ inputLockReason: 'waiting' })
|
||||
await act(async () => vi.advanceTimersByTime(600))
|
||||
expect(composer().props.disabled).toBe(true)
|
||||
|
||||
await update({ inputLockReason: null })
|
||||
expect(composer().props.disabled).toBe(true)
|
||||
await act(async () => vi.advanceTimersByTime(300))
|
||||
await update({ inputLockReason: 'waiting' })
|
||||
await act(async () => vi.advanceTimersByTime(600))
|
||||
|
||||
expect(composer().props.disabled).toBe(true)
|
||||
expect(composer().props.placeholder).toBe('Waiting for terminal…')
|
||||
} finally {
|
||||
vi.useRealTimers()
|
||||
}
|
||||
})
|
||||
|
||||
it('unlocks after the lease stays ready', async () => {
|
||||
vi.useFakeTimers()
|
||||
try {
|
||||
await render({ inputLockReason: 'waiting' })
|
||||
await act(async () => vi.advanceTimersByTime(600))
|
||||
await update({ inputLockReason: null })
|
||||
await act(async () => vi.advanceTimersByTime(599))
|
||||
expect(composer().props.disabled).toBe(true)
|
||||
|
||||
await act(async () => vi.advanceTimersByTime(1))
|
||||
|
||||
expect(composer().props.disabled).toBe(false)
|
||||
expect(composer().props.placeholder).toBe('Message, @files, /commands')
|
||||
} finally {
|
||||
vi.useRealTimers()
|
||||
}
|
||||
})
|
||||
})
|
||||
|
||||
@@ -33,6 +33,8 @@ import { MobileNativeChatQuestion } from './MobileNativeChatQuestion'
|
||||
import { mobileChatQuestionKey, type MobileChatQuestion } from './mobile-native-chat-question'
|
||||
import type { MobileNativeChatStatus } from './use-mobile-native-chat-session'
|
||||
|
||||
const INPUT_LOCK_SETTLE_MS = 600
|
||||
|
||||
/** Why the composer input is locked: the transport is disconnected, or the
|
||||
* terminal subscription has not acknowledged its input lease yet. */
|
||||
export type MobileNativeChatInputLockReason = 'disconnected' | 'waiting'
|
||||
@@ -59,6 +61,9 @@ type Props = {
|
||||
/** Optimistic queued sends (owned by the route so they survive view switches). */
|
||||
/** Optimistic user echoes, including any ridden-along image preview URIs. */
|
||||
pending: MobileNativeChatPendingItem[]
|
||||
/** Local photo URIs retained when the authoritative transcript replaces an
|
||||
* optimistic image bubble. */
|
||||
imagePreviewsByMessageId?: Record<string, string[]>
|
||||
/** Controlled composer text (owned by the route so dictation can write to it). */
|
||||
composerText: string
|
||||
onComposerTextChange: (text: string) => void
|
||||
@@ -123,6 +128,7 @@ export function MobileNativeChatView({
|
||||
onLoadEarlier,
|
||||
onSend,
|
||||
pending,
|
||||
imagePreviewsByMessageId,
|
||||
composerText,
|
||||
onComposerTextChange,
|
||||
onAttachImage,
|
||||
@@ -175,8 +181,14 @@ export function MobileNativeChatView({
|
||||
// route-owned optimistic queued messages. Memoize on the same deps so the
|
||||
// downstream autoscroll effects/`renderItem` keep referential stability.
|
||||
const { data } = useMemo(
|
||||
() => buildMobileNativeChatTransientData({ folded, streaming, pending }),
|
||||
[folded, streaming, pending]
|
||||
() =>
|
||||
buildMobileNativeChatTransientData({
|
||||
folded,
|
||||
streaming,
|
||||
pending,
|
||||
imagePreviewsByMessageId
|
||||
}),
|
||||
[folded, streaming, pending, imagePreviewsByMessageId]
|
||||
)
|
||||
|
||||
// Follow the tail as the conversation grows and keep the newest message above
|
||||
@@ -250,20 +262,18 @@ export function MobileNativeChatView({
|
||||
const emptyState = mobileNativeChatEmptyState(status, agent ?? null, error)
|
||||
const showLoading = status === 'loading' && messages.length === 0
|
||||
|
||||
// Composer-lock flicker guard: on a remote link, brief connState blips or lease
|
||||
// hand-offs would otherwise toggle the lock placeholder on and off. Only surface
|
||||
// a lock once it has held ~600ms; drop it instantly so unlocking stays snappy.
|
||||
// A dead PTY emits subscribed→end; settle both edges so its false lease cannot flash the composer enabled.
|
||||
const rawLockReason = inputLockReason ?? null
|
||||
const rawLockHeld = rawLockReason !== null
|
||||
const [lockHeld, setLockHeld] = useState(false)
|
||||
useEffect(() => {
|
||||
if (rawLockReason === null) {
|
||||
setLockHeld(false)
|
||||
if (rawLockHeld === lockHeld) {
|
||||
return
|
||||
}
|
||||
const timer = setTimeout(() => setLockHeld(true), 600)
|
||||
const timer = setTimeout(() => setLockHeld(rawLockHeld), INPUT_LOCK_SETTLE_MS)
|
||||
return () => clearTimeout(timer)
|
||||
}, [rawLockReason])
|
||||
const lockReason = lockHeld ? rawLockReason : null
|
||||
}, [lockHeld, rawLockHeld])
|
||||
const lockReason = lockHeld ? (rawLockReason ?? 'waiting') : null
|
||||
|
||||
return (
|
||||
<View style={[styles.root, { paddingBottom: bottomPad }]}>
|
||||
|
||||
@@ -10,11 +10,13 @@ type DictationStatus = {
|
||||
type MobileTerminalLiveInputStatusProps = {
|
||||
readonly dictation: DictationStatus
|
||||
readonly isAttaching: boolean
|
||||
readonly liveInputText: string
|
||||
}
|
||||
|
||||
export function MobileTerminalLiveInputStatus({
|
||||
dictation,
|
||||
isAttaching
|
||||
isAttaching,
|
||||
liveInputText
|
||||
}: MobileTerminalLiveInputStatusProps) {
|
||||
const title = dictation.isRecording
|
||||
? 'Listening'
|
||||
@@ -31,14 +33,14 @@ export function MobileTerminalLiveInputStatus({
|
||||
? 'Preparing microphone'
|
||||
: isAttaching
|
||||
? 'Uploading image to host'
|
||||
: 'Tap to show keyboard'
|
||||
: liveInputText || 'Tap to show keyboard'
|
||||
|
||||
return (
|
||||
<View style={styles.status}>
|
||||
<Text style={styles.title} numberOfLines={1}>
|
||||
{title}
|
||||
</Text>
|
||||
<Text style={styles.detail} numberOfLines={1}>
|
||||
<Text style={styles.detail} numberOfLines={1} ellipsizeMode="head">
|
||||
{detail}
|
||||
</Text>
|
||||
</View>
|
||||
|
||||
@@ -12,13 +12,28 @@ vi.mock('expo-file-system', () => ({
|
||||
File: vi.fn()
|
||||
}))
|
||||
|
||||
import { ImageLibraryPermissionError, pickMobileImage } from './mobile-image-source-picker'
|
||||
import {
|
||||
ImageLibraryPermissionError,
|
||||
pickMobileImage,
|
||||
pickMobileImages,
|
||||
type PickedMobileImage
|
||||
} from './mobile-image-source-picker'
|
||||
|
||||
const granted = { granted: true } as Awaited<
|
||||
ReturnType<typeof import('expo-image-picker').requestMediaLibraryPermissionsAsync>
|
||||
>
|
||||
const denied = { granted: false } as typeof granted
|
||||
|
||||
async function collectImages(
|
||||
images: AsyncIterable<PickedMobileImage>
|
||||
): Promise<PickedMobileImage[]> {
|
||||
const collected: PickedMobileImage[] = []
|
||||
for await (const image of images) {
|
||||
collected.push(image)
|
||||
}
|
||||
return collected
|
||||
}
|
||||
|
||||
function fileFactory(
|
||||
bytes: Uint8Array,
|
||||
options?: { fileSize?: number; handleSize?: number | null; readError?: Error }
|
||||
@@ -68,6 +83,57 @@ describe('pickMobileImage', () => {
|
||||
expect(file.close).toHaveBeenCalledTimes(1)
|
||||
})
|
||||
|
||||
it('returns every selected library photo in order', async () => {
|
||||
const bytesByUri = new Map([
|
||||
['file:///a.jpg', new Uint8Array([1])],
|
||||
['file:///b.jpg', new Uint8Array([2])],
|
||||
['file:///c.jpg', new Uint8Array([3])]
|
||||
])
|
||||
const createFile = vi.fn((uri: string) => {
|
||||
const bytes = bytesByUri.get(uri)!
|
||||
let read = false
|
||||
return {
|
||||
size: bytes.length,
|
||||
open: () => ({
|
||||
size: bytes.length,
|
||||
readBytes: () => {
|
||||
if (read) {
|
||||
return new Uint8Array()
|
||||
}
|
||||
read = true
|
||||
return bytes
|
||||
},
|
||||
close: vi.fn()
|
||||
})
|
||||
}
|
||||
})
|
||||
const launchLibrary = vi.fn().mockResolvedValue({
|
||||
canceled: false,
|
||||
assets: [...bytesByUri].map(([uri, bytes]) => ({ uri, fileSize: bytes.length }))
|
||||
})
|
||||
|
||||
const result = await collectImages(
|
||||
pickMobileImages('library', {
|
||||
requestLibraryPermission: vi.fn().mockResolvedValue(granted),
|
||||
launchLibrary,
|
||||
createFile
|
||||
})
|
||||
)
|
||||
|
||||
expect(result.map((image) => image.uri)).toEqual([
|
||||
'file:///a.jpg',
|
||||
'file:///b.jpg',
|
||||
'file:///c.jpg'
|
||||
])
|
||||
expect(launchLibrary).toHaveBeenCalledWith(
|
||||
expect.objectContaining({
|
||||
allowsMultipleSelection: true,
|
||||
orderedSelection: true,
|
||||
selectionLimit: 0
|
||||
})
|
||||
)
|
||||
})
|
||||
|
||||
it('throws when photo library permission is denied', async () => {
|
||||
await expect(
|
||||
pickMobileImage('library', {
|
||||
|
||||
@@ -83,11 +83,12 @@ async function readUriAsBase64(
|
||||
}
|
||||
}
|
||||
|
||||
async function pickFromLibrary(
|
||||
async function* pickFromLibrary(
|
||||
multiple: boolean,
|
||||
requestPermission: typeof ImagePicker.requestMediaLibraryPermissionsAsync = ImagePicker.requestMediaLibraryPermissionsAsync,
|
||||
launch: typeof ImagePicker.launchImageLibraryAsync = ImagePicker.launchImageLibraryAsync,
|
||||
createFile: MobileImageFileFactory = defaultMobileImageFileFactory
|
||||
): Promise<PickedMobileImage | null> {
|
||||
): AsyncGenerator<PickedMobileImage> {
|
||||
const permission = await requestPermission()
|
||||
// Why: `granted` covers full + limited iOS access; only a hard denial blocks us.
|
||||
if (!permission.granted) {
|
||||
@@ -96,51 +97,84 @@ async function pickFromLibrary(
|
||||
const result = await launch({
|
||||
mediaTypes: ['images'],
|
||||
base64: false,
|
||||
allowsMultipleSelection: false,
|
||||
allowsMultipleSelection: multiple,
|
||||
...(multiple ? { selectionLimit: 0, orderedSelection: true } : {}),
|
||||
quality: 1
|
||||
})
|
||||
if (result.canceled) {
|
||||
return null
|
||||
return
|
||||
}
|
||||
const asset = result.assets[0]
|
||||
const base64 = asset?.uri ? await readUriAsBase64(asset.uri, asset.fileSize, createFile) : null
|
||||
if (!base64) {
|
||||
return null
|
||||
for (const asset of result.assets) {
|
||||
if (!asset.uri) {
|
||||
continue
|
||||
}
|
||||
const base64 = await readUriAsBase64(asset.uri, asset.fileSize, createFile)
|
||||
if (base64) {
|
||||
yield { base64, uri: asset.uri }
|
||||
}
|
||||
}
|
||||
return { base64, ...(asset?.uri ? { uri: asset.uri } : {}) }
|
||||
}
|
||||
|
||||
async function pickFromFiles(
|
||||
async function* pickFromFiles(
|
||||
multiple: boolean,
|
||||
launch: typeof DocumentPicker.getDocumentAsync = DocumentPicker.getDocumentAsync,
|
||||
createFile: MobileImageFileFactory = defaultMobileImageFileFactory
|
||||
): Promise<PickedMobileImage | null> {
|
||||
): AsyncGenerator<PickedMobileImage> {
|
||||
const result = await launch({
|
||||
type: 'image/*',
|
||||
multiple: false,
|
||||
multiple,
|
||||
copyToCacheDirectory: true
|
||||
})
|
||||
if (result.canceled) {
|
||||
return null
|
||||
return
|
||||
}
|
||||
const asset = result.assets[0]
|
||||
if (!asset?.uri) {
|
||||
return null
|
||||
for (const asset of result.assets) {
|
||||
if (!asset.uri) {
|
||||
continue
|
||||
}
|
||||
const base64 = await readUriAsBase64(asset.uri, asset.size, createFile)
|
||||
if (base64) {
|
||||
yield { base64, uri: asset.uri }
|
||||
}
|
||||
}
|
||||
const base64 = await readUriAsBase64(asset.uri, asset.size, createFile)
|
||||
return base64 ? { base64, uri: asset.uri } : null
|
||||
}
|
||||
|
||||
type MobileImagePickerDeps = {
|
||||
readonly requestLibraryPermission?: typeof ImagePicker.requestMediaLibraryPermissionsAsync
|
||||
readonly launchLibrary?: typeof ImagePicker.launchImageLibraryAsync
|
||||
readonly launchFiles?: typeof DocumentPicker.getDocumentAsync
|
||||
readonly createFile?: MobileImageFileFactory
|
||||
}
|
||||
|
||||
function pickMobileImagesWithMode(
|
||||
source: MobileImageSource,
|
||||
multiple: boolean,
|
||||
deps?: MobileImagePickerDeps
|
||||
): AsyncIterable<PickedMobileImage> {
|
||||
if (source === 'library') {
|
||||
return pickFromLibrary(
|
||||
multiple,
|
||||
deps?.requestLibraryPermission,
|
||||
deps?.launchLibrary,
|
||||
deps?.createFile
|
||||
)
|
||||
}
|
||||
return pickFromFiles(multiple, deps?.launchFiles, deps?.createFile)
|
||||
}
|
||||
|
||||
export async function pickMobileImage(
|
||||
source: MobileImageSource,
|
||||
deps?: {
|
||||
readonly requestLibraryPermission?: typeof ImagePicker.requestMediaLibraryPermissionsAsync
|
||||
readonly launchLibrary?: typeof ImagePicker.launchImageLibraryAsync
|
||||
readonly launchFiles?: typeof DocumentPicker.getDocumentAsync
|
||||
readonly createFile?: MobileImageFileFactory
|
||||
}
|
||||
deps?: MobileImagePickerDeps
|
||||
): Promise<PickedMobileImage | null> {
|
||||
if (source === 'library') {
|
||||
return pickFromLibrary(deps?.requestLibraryPermission, deps?.launchLibrary, deps?.createFile)
|
||||
for await (const image of pickMobileImagesWithMode(source, false, deps)) {
|
||||
return image
|
||||
}
|
||||
return pickFromFiles(deps?.launchFiles, deps?.createFile)
|
||||
return null
|
||||
}
|
||||
|
||||
export function pickMobileImages(
|
||||
source: MobileImageSource,
|
||||
deps?: MobileImagePickerDeps
|
||||
): AsyncIterable<PickedMobileImage> {
|
||||
return pickMobileImagesWithMode(source, true, deps)
|
||||
}
|
||||
|
||||
@@ -0,0 +1,64 @@
|
||||
import type { Dispatch, MutableRefObject, SetStateAction } from 'react'
|
||||
import type { detectAgentPermission } from './mobile-native-chat-permission'
|
||||
import type { parseAgentQuestion } from './mobile-native-chat-question'
|
||||
import type { AskAnswerSelection, AskPrompt, parseAskFromStatus } from './mobile-native-chat-ask'
|
||||
import type { MobileNativeChatSendOutcome } from './mobile-native-chat-send'
|
||||
import type { MobileNativeChatPendingMessage } from './use-mobile-native-chat-drafts'
|
||||
import type { useMobileNativeChatSession } from './use-mobile-native-chat-session'
|
||||
import type { MobileNativeChatSessionOptionPickersProps } from './MobileNativeChatSessionOptionPickers'
|
||||
|
||||
export type MobileNativeChatController = {
|
||||
/** Whether a tab's effective view is chat (per-tab override, else the default). */
|
||||
isTabChatView: (tabId: string) => boolean
|
||||
toggleTabChatView: (tabId: string) => void
|
||||
showNativeChat: boolean
|
||||
showNativeChatRef: MutableRefObject<boolean>
|
||||
/** Resolved agent for the active chat tab (names the empty-state copy). */
|
||||
nativeChatAgent: string | null
|
||||
chatComposerText: string
|
||||
setChatComposerText: Dispatch<SetStateAction<string>>
|
||||
chatPending: MobileNativeChatPendingMessage[]
|
||||
chatImagePreviewsByMessageId: Record<string, string[]>
|
||||
nativeChatSession: ReturnType<typeof useMobileNativeChatSession>
|
||||
nativeChatAgentWorking: boolean
|
||||
nativeChatStreamingText?: string
|
||||
/** Agent mid-turn, regardless of whether chat is the visible view. */
|
||||
nativeChatStreamLive: boolean
|
||||
/** Host/workspace/tab/session scope for stateful streaming suppression. */
|
||||
nativeChatStreamScopeKey: string
|
||||
nativeChatPermission: ReturnType<typeof detectAgentPermission>
|
||||
nativeChatQuestion: ReturnType<typeof parseAgentQuestion>
|
||||
/** The pending ask, already null while dismissed (dismissal lives here so it
|
||||
* survives the chat-view subtree unmounting on a view toggle). */
|
||||
nativeChatAsk: ReturnType<typeof parseAskFromStatus>
|
||||
/** Stable key for the current ask card (keys the card component). */
|
||||
nativeChatAskKey: string | null
|
||||
/** Hide the current ask until a genuinely different question arrives. */
|
||||
dismissNativeChatAsk: () => void
|
||||
handleNativeChatAnswerAsk: (
|
||||
prompt: AskPrompt,
|
||||
selections: AskAnswerSelection[]
|
||||
) => Promise<boolean>
|
||||
handleNativeChatCancelAsk: () => Promise<boolean>
|
||||
handleNativeChatRespondPermission: (text: string) => Promise<boolean>
|
||||
handleNativeChatStop: () => void
|
||||
nativeChatFilePaths: string[]
|
||||
loadNativeChatFiles: (query: string) => void
|
||||
handleNativeChatQuestionAnswer: (text: string) => Promise<boolean>
|
||||
handleNativeChatSend: (text: string, images?: string[]) => Promise<boolean>
|
||||
/** Outcome-preserving send: callers that pasted terminal input beforehand
|
||||
* (image sends) must see 'unknown' to heal a possibly-orphaned paste. Such a
|
||||
* caller passes its own `deadline` so the paste it already spent and this text
|
||||
* body share one budget instead of holding the composer for two. */
|
||||
handleNativeChatSendWithOutcome: (
|
||||
text: string,
|
||||
images?: string[],
|
||||
deadline?: number
|
||||
) => Promise<MobileNativeChatSendOutcome>
|
||||
/** Launch-context text still parked on the agent's TUI input line, or null.
|
||||
* Image sends read it to size their leading clear (one Ctrl+U per line). */
|
||||
readSeededLaunchDraft: () => string | null
|
||||
/** Model/session-option pickers for the composer, or null when the active
|
||||
* agent has no session-option catalog. */
|
||||
nativeChatSessionOptions: MobileNativeChatSessionOptionPickersProps | null
|
||||
}
|
||||
@@ -0,0 +1,72 @@
|
||||
import { describe, expect, it } from 'vitest'
|
||||
import type { NativeChatMessage } from '../../../src/shared/native-chat-types'
|
||||
import {
|
||||
findLandedImagePreviewEchoes,
|
||||
migrateImagePreviewMessageIds,
|
||||
type PendingImagePreviewEcho
|
||||
} from './mobile-native-chat-draft-reconcile'
|
||||
|
||||
function userText(id: string, text: string): NativeChatMessage {
|
||||
return {
|
||||
id,
|
||||
role: 'user',
|
||||
blocks: [{ type: 'text', text }],
|
||||
timestamp: null,
|
||||
source: 'transcript'
|
||||
}
|
||||
}
|
||||
|
||||
function pending(id: string, images: string[], expectedOccurrence = 1): PendingImagePreviewEcho {
|
||||
return { id, text: '', images, expectedOccurrence, baselineTailMessageId: null }
|
||||
}
|
||||
|
||||
describe('mobile native chat image preview reconciliation', () => {
|
||||
it('keeps separate adjacent image-only sends independently reconcilable', () => {
|
||||
const landed = findLandedImagePreviewEchoes(
|
||||
[
|
||||
userText('source-a', '[Image: source: /tmp/a.png]'),
|
||||
userText('source-b', '[Image: source: /tmp/b.png]')
|
||||
],
|
||||
[pending('pending-a', ['file:///a.jpg']), pending('pending-b', ['file:///b.jpg'], 2)]
|
||||
)
|
||||
|
||||
expect(landed).toEqual([
|
||||
{ pendingId: 'pending-a', messageId: 'source-a', images: ['file:///a.jpg'] },
|
||||
{ pendingId: 'pending-b', messageId: 'source-b', images: ['file:///b.jpg'] }
|
||||
])
|
||||
})
|
||||
|
||||
it('waits for a complete multi-image turn as transcript source frames stream in', () => {
|
||||
const entry = pending('pending', ['file:///a.jpg', 'file:///b.jpg'])
|
||||
const sourceA = userText('source-a', '[Image: source: /tmp/a.png]')
|
||||
const sourceB = userText('source-b', '[Image: source: /tmp/b.png]')
|
||||
|
||||
expect(findLandedImagePreviewEchoes([sourceA], [entry])).toEqual([])
|
||||
expect(findLandedImagePreviewEchoes([sourceA, sourceB], [entry])).toEqual([])
|
||||
expect(
|
||||
findLandedImagePreviewEchoes(
|
||||
[sourceA, sourceB, userText('prompt', '[Image #1] [Image #2]')],
|
||||
[entry]
|
||||
)
|
||||
).toEqual([
|
||||
{
|
||||
pendingId: 'pending',
|
||||
messageId: 'prompt',
|
||||
images: ['file:///a.jpg', 'file:///b.jpg']
|
||||
}
|
||||
])
|
||||
})
|
||||
|
||||
it('moves an early standalone preview to the later folded prompt id', () => {
|
||||
const sessionKey = 'host\0worktree\0tab\0session'
|
||||
const previous = { [sessionKey]: { source: ['file:///a.jpg'] } }
|
||||
const messages = [
|
||||
userText('source', '[Image: source: /tmp/a.png]'),
|
||||
userText('prompt', '[Image #1]')
|
||||
]
|
||||
|
||||
expect(migrateImagePreviewMessageIds(previous, sessionKey, messages)).toEqual({
|
||||
[sessionKey]: { prompt: ['file:///a.jpg'] }
|
||||
})
|
||||
})
|
||||
})
|
||||
@@ -1,6 +1,7 @@
|
||||
import type { NativeChatMessage } from '../../../src/shared/native-chat-types'
|
||||
import { isImageRefBlock, type NativeChatMessage } from '../../../src/shared/native-chat-types'
|
||||
import {
|
||||
isImageSourceUserTurn,
|
||||
normalizeImageTranscriptMessages,
|
||||
stripImagePromptMarker
|
||||
} from './mobile-native-chat-image-transcript-markers'
|
||||
|
||||
@@ -61,6 +62,149 @@ export function countImageSourceTurnsAfter(
|
||||
return count
|
||||
}
|
||||
|
||||
export type PendingImagePreviewEcho = {
|
||||
id: string
|
||||
text: string
|
||||
images?: string[]
|
||||
expectedOccurrence: number
|
||||
baselineTailMessageId: string | null
|
||||
}
|
||||
|
||||
export type LandedImagePreviewEcho = {
|
||||
pendingId: string
|
||||
messageId: string
|
||||
images: string[]
|
||||
}
|
||||
|
||||
const SENT_IMAGE_PREVIEW_LIMIT = 32
|
||||
const SENT_IMAGE_PREVIEW_SESSION_LIMIT = 8
|
||||
|
||||
export function mergeLandedImagePreviewEchoes(
|
||||
previous: Record<string, Record<string, string[]>>,
|
||||
sessionKey: string,
|
||||
landed: readonly LandedImagePreviewEcho[]
|
||||
): Record<string, Record<string, string[]>> {
|
||||
const entries = Object.entries(previous[sessionKey] ?? {})
|
||||
for (const preview of landed) {
|
||||
const existingIndex = entries.findIndex(([messageId]) => messageId === preview.messageId)
|
||||
if (existingIndex >= 0) {
|
||||
entries.splice(existingIndex, 1)
|
||||
}
|
||||
entries.push([preview.messageId, preview.images])
|
||||
}
|
||||
const next = { ...previous }
|
||||
delete next[sessionKey]
|
||||
next[sessionKey] = Object.fromEntries(entries.slice(-SENT_IMAGE_PREVIEW_LIMIT))
|
||||
for (const key of Object.keys(next).slice(0, -SENT_IMAGE_PREVIEW_SESSION_LIMIT)) {
|
||||
delete next[key]
|
||||
}
|
||||
return next
|
||||
}
|
||||
|
||||
function imagePreviewReplacementMessageId(
|
||||
messages: readonly NativeChatMessage[],
|
||||
sourceIndex: number
|
||||
): string | null {
|
||||
const source = messages[sourceIndex]
|
||||
if (!source || !isImageSourceUserTurn(source)) {
|
||||
return null
|
||||
}
|
||||
let nextIndex = sourceIndex + 1
|
||||
while (
|
||||
messages[nextIndex]?.source === source.source &&
|
||||
isImageSourceUserTurn(messages[nextIndex]!)
|
||||
) {
|
||||
nextIndex++
|
||||
}
|
||||
const prompt = messages[nextIndex]
|
||||
const firstText = prompt?.blocks.find((block) => block.type === 'text')
|
||||
return prompt?.role === 'user' &&
|
||||
prompt.source === source.source &&
|
||||
firstText?.type === 'text' &&
|
||||
stripImagePromptMarker(firstText.text) !== firstText.text
|
||||
? prompt.id
|
||||
: null
|
||||
}
|
||||
|
||||
/** Moves previews forward when a progressive source-only transcript frame later
|
||||
* folds into the marker-prefixed prompt with a different authoritative id. */
|
||||
export function migrateImagePreviewMessageIds(
|
||||
previous: Record<string, Record<string, string[]>>,
|
||||
sessionKey: string,
|
||||
messages: readonly NativeChatMessage[]
|
||||
): Record<string, Record<string, string[]>> {
|
||||
const sessionPreviews = previous[sessionKey]
|
||||
if (!sessionPreviews) {
|
||||
return previous
|
||||
}
|
||||
const messageIndexById = new Map(messages.map((message, index) => [message.id, index]))
|
||||
let nextSession: Record<string, string[]> | null = null
|
||||
for (const [messageId, images] of Object.entries(sessionPreviews)) {
|
||||
const sourceIndex = messageIndexById.get(messageId)
|
||||
if (sourceIndex === undefined) {
|
||||
continue
|
||||
}
|
||||
const replacementId = imagePreviewReplacementMessageId(messages, sourceIndex)
|
||||
if (!replacementId) {
|
||||
continue
|
||||
}
|
||||
nextSession ??= { ...sessionPreviews }
|
||||
delete nextSession[messageId]
|
||||
nextSession[replacementId] = [...(nextSession[replacementId] ?? []), ...images]
|
||||
}
|
||||
return nextSession ? { ...previous, [sessionKey]: nextSession } : previous
|
||||
}
|
||||
|
||||
/** Binds local preview URIs to the authoritative transcript turn that replaced
|
||||
* the optimistic bubble. Host paths and marker-only Codex turns cannot render
|
||||
* the phone-local photo without this handoff. */
|
||||
export function findLandedImagePreviewEchoes(
|
||||
messages: readonly NativeChatMessage[],
|
||||
entries: readonly PendingImagePreviewEcho[]
|
||||
): LandedImagePreviewEcho[] {
|
||||
const normalized = normalizeImageTranscriptMessages(messages)
|
||||
const messageIndexById = new Map(normalized.map((message, index) => [message.id, index]))
|
||||
const claimedMessageIds = new Set<string>()
|
||||
const landed: LandedImagePreviewEcho[] = []
|
||||
|
||||
for (const entry of entries) {
|
||||
if (!entry.images?.length) {
|
||||
continue
|
||||
}
|
||||
const targetText = entry.text.trim()
|
||||
const candidates = normalized.filter((message) => {
|
||||
if (message.role !== 'user') {
|
||||
return false
|
||||
}
|
||||
if (targetText) {
|
||||
return normalizedUserText(message) === targetText
|
||||
}
|
||||
const imageCount = message.blocks.filter(isImageRefBlock).length
|
||||
return message.blocks.length === 0 || imageCount >= entry.images!.length
|
||||
})
|
||||
const tailIndex = entry.baselineTailMessageId
|
||||
? messageIndexById.get(entry.baselineTailMessageId)
|
||||
: -1
|
||||
const occurrenceIndex = Math.max(0, entry.expectedOccurrence - 1)
|
||||
const candidate = targetText
|
||||
? candidates[occurrenceIndex]
|
||||
: candidates.filter(
|
||||
(message) =>
|
||||
tailIndex === undefined || (messageIndexById.get(message.id) ?? -1) > tailIndex
|
||||
)[occurrenceIndex]
|
||||
if (
|
||||
!candidate ||
|
||||
claimedMessageIds.has(candidate.id) ||
|
||||
(tailIndex !== undefined && (messageIndexById.get(candidate.id) ?? -1) <= tailIndex)
|
||||
) {
|
||||
continue
|
||||
}
|
||||
claimedMessageIds.add(candidate.id)
|
||||
landed.push({ pendingId: entry.id, messageId: candidate.id, images: entry.images })
|
||||
}
|
||||
return landed
|
||||
}
|
||||
|
||||
export function findLandedUnconfirmedSends(
|
||||
messages: readonly NativeChatMessage[],
|
||||
entries: readonly UnconfirmedSend[]
|
||||
|
||||
@@ -1,7 +1,7 @@
|
||||
import { describe, expect, it, vi } from 'vitest'
|
||||
import type { RpcClient } from '../transport/rpc-client'
|
||||
import type { RpcResponse, RpcSuccess } from '../transport/types'
|
||||
import { uploadMobileNativeChatImage } from './mobile-native-chat-image-attachment'
|
||||
import { uploadMobileNativeChatImages } from './mobile-native-chat-image-attachment'
|
||||
|
||||
function ok(id: string, result: unknown): RpcSuccess {
|
||||
return { id, ok: true, result, _meta: { runtimeId: 'runtime-1' } }
|
||||
@@ -16,6 +16,10 @@ function methodNotFound(id: string): RpcResponse {
|
||||
}
|
||||
}
|
||||
|
||||
function failed(id: string, message: string): RpcResponse {
|
||||
return { id, ok: false, error: { code: 'failed', message }, _meta: { runtimeId: 'r' } }
|
||||
}
|
||||
|
||||
function clientWithResponses(responses: RpcResponse[]): Pick<RpcClient, 'sendRequest'> & {
|
||||
calls: { method: string; params: unknown }[]
|
||||
} {
|
||||
@@ -33,67 +37,137 @@ function clientWithResponses(responses: RpcResponse[]): Pick<RpcClient, 'sendReq
|
||||
}
|
||||
}
|
||||
|
||||
describe('uploadMobileNativeChatImage', () => {
|
||||
describe('uploadMobileNativeChatImages', () => {
|
||||
it('uploads the picked image and returns its host path + local preview uri, without any terminal.send', async () => {
|
||||
const client = clientWithResponses([
|
||||
methodNotFound('start'),
|
||||
ok('save', '/tmp/orca-attach.png')
|
||||
])
|
||||
|
||||
const result = await uploadMobileNativeChatImage('library', {
|
||||
const result = await uploadMobileNativeChatImages('library', {
|
||||
client,
|
||||
getConnectionId: async () => 'conn-7',
|
||||
pickImage: vi.fn().mockResolvedValue({ base64: 'AAAA', uri: 'file:///photo.jpg' })
|
||||
pickImages: vi.fn().mockResolvedValue([{ base64: 'AAAA', uri: 'file:///photo.jpg' }])
|
||||
})
|
||||
|
||||
expect(result).toEqual({ path: '/tmp/orca-attach.png', previewUri: 'file:///photo.jpg' })
|
||||
expect(result).toEqual([{ path: '/tmp/orca-attach.png', previewUri: 'file:///photo.jpg' }])
|
||||
// Native chat defers the paste to submit — nothing is sent to the terminal here.
|
||||
expect(client.calls.some((call) => call.method === 'terminal.send')).toBe(false)
|
||||
const saveCall = client.calls.find((c) => c.method === 'clipboard.saveImageAsTempFile')
|
||||
expect(saveCall?.params).toMatchObject({ connectionId: 'conn-7' })
|
||||
})
|
||||
|
||||
it('uploads all three selected images in picker order', async () => {
|
||||
const client = clientWithResponses([
|
||||
methodNotFound('start-a'),
|
||||
ok('save-a', '/tmp/a.png'),
|
||||
methodNotFound('start-b'),
|
||||
ok('save-b', '/tmp/b.png'),
|
||||
methodNotFound('start-c'),
|
||||
ok('save-c', '/tmp/c.png')
|
||||
])
|
||||
|
||||
const order: string[] = []
|
||||
async function* pickImages() {
|
||||
for (const image of [
|
||||
{ base64: 'AAAA', uri: 'file:///a.jpg' },
|
||||
{ base64: 'BBBB', uri: 'file:///b.jpg' },
|
||||
{ base64: 'CCCC', uri: 'file:///c.jpg' }
|
||||
]) {
|
||||
order.push(`read:${image.uri}`)
|
||||
yield image
|
||||
}
|
||||
}
|
||||
const result = await uploadMobileNativeChatImages('library', {
|
||||
client,
|
||||
getConnectionId: async () => 'conn-7',
|
||||
pickImages,
|
||||
onImageUploaded: (image) => order.push(`uploaded:${image.previewUri}`)
|
||||
})
|
||||
|
||||
expect(result).toEqual([
|
||||
{ path: '/tmp/a.png', previewUri: 'file:///a.jpg' },
|
||||
{ path: '/tmp/b.png', previewUri: 'file:///b.jpg' },
|
||||
{ path: '/tmp/c.png', previewUri: 'file:///c.jpg' }
|
||||
])
|
||||
expect(order).toEqual([
|
||||
'read:file:///a.jpg',
|
||||
'uploaded:file:///a.jpg',
|
||||
'read:file:///b.jpg',
|
||||
'uploaded:file:///b.jpg',
|
||||
'read:file:///c.jpg',
|
||||
'uploaded:file:///c.jpg'
|
||||
])
|
||||
})
|
||||
|
||||
it('returns null when the picker is cancelled and uploads nothing', async () => {
|
||||
const client = clientWithResponses([])
|
||||
|
||||
const result = await uploadMobileNativeChatImage('library', {
|
||||
const result = await uploadMobileNativeChatImages('library', {
|
||||
client,
|
||||
getConnectionId: async () => null,
|
||||
pickImage: vi.fn().mockResolvedValue(null)
|
||||
pickImages: vi.fn().mockResolvedValue([])
|
||||
})
|
||||
|
||||
expect(result).toBeNull()
|
||||
expect(result).toEqual([])
|
||||
expect(client.calls).toEqual([])
|
||||
})
|
||||
|
||||
it('reports completed uploads before a later image fails', async () => {
|
||||
const client = clientWithResponses([
|
||||
methodNotFound('start-a'),
|
||||
ok('save-a', '/tmp/a.png'),
|
||||
methodNotFound('start-b'),
|
||||
failed('save-b', 'upload failed')
|
||||
])
|
||||
const onImageUploaded = vi.fn()
|
||||
|
||||
await expect(
|
||||
uploadMobileNativeChatImages('library', {
|
||||
client,
|
||||
getConnectionId: async () => null,
|
||||
pickImages: vi.fn().mockResolvedValue([
|
||||
{ base64: 'AAAA', uri: 'file:///a.jpg' },
|
||||
{ base64: 'BBBB', uri: 'file:///b.jpg' }
|
||||
]),
|
||||
onImageUploaded
|
||||
})
|
||||
).rejects.toThrow('upload failed')
|
||||
expect(onImageUploaded).toHaveBeenCalledOnce()
|
||||
expect(onImageUploaded).toHaveBeenCalledWith({
|
||||
path: '/tmp/a.png',
|
||||
previewUri: 'file:///a.jpg'
|
||||
})
|
||||
})
|
||||
|
||||
it('falls back to an inline data uri for the preview when the picker omits a uri', async () => {
|
||||
const client = clientWithResponses([methodNotFound('start'), ok('save', '/tmp/x.png')])
|
||||
|
||||
const result = await uploadMobileNativeChatImage('files', {
|
||||
const result = await uploadMobileNativeChatImages('files', {
|
||||
client,
|
||||
getConnectionId: async () => null,
|
||||
pickImage: vi.fn().mockResolvedValue({ base64: 'BBBB' })
|
||||
pickImages: vi.fn().mockResolvedValue([{ base64: 'BBBB' }])
|
||||
})
|
||||
|
||||
expect(result).toEqual({ path: '/tmp/x.png', previewUri: 'data:image/png;base64,BBBB' })
|
||||
expect(result).toEqual([{ path: '/tmp/x.png', previewUri: 'data:image/png;base64,BBBB' }])
|
||||
})
|
||||
|
||||
it('signals upload start only after a real image is picked', async () => {
|
||||
const onUploadStart = vi.fn()
|
||||
const cancelledClient = clientWithResponses([])
|
||||
await uploadMobileNativeChatImage('library', {
|
||||
await uploadMobileNativeChatImages('library', {
|
||||
client: cancelledClient,
|
||||
getConnectionId: async () => null,
|
||||
pickImage: vi.fn().mockResolvedValue(null),
|
||||
pickImages: vi.fn().mockResolvedValue([]),
|
||||
onUploadStart
|
||||
})
|
||||
expect(onUploadStart).not.toHaveBeenCalled()
|
||||
|
||||
const client = clientWithResponses([methodNotFound('start'), ok('save', '/tmp/y.png')])
|
||||
await uploadMobileNativeChatImage('library', {
|
||||
await uploadMobileNativeChatImages('library', {
|
||||
client,
|
||||
getConnectionId: async () => null,
|
||||
pickImage: vi.fn().mockResolvedValue({ base64: 'CCCC', uri: 'file:///y.jpg' }),
|
||||
pickImages: vi.fn().mockResolvedValue([{ base64: 'CCCC', uri: 'file:///y.jpg' }]),
|
||||
onUploadStart
|
||||
})
|
||||
expect(onUploadStart).toHaveBeenCalledTimes(1)
|
||||
|
||||
@@ -13,34 +13,67 @@ export type PendingNativeChatImage = {
|
||||
readonly previewUri: string
|
||||
}
|
||||
|
||||
export type UploadNativeChatImageDeps = {
|
||||
export function appendPendingNativeChatImages(
|
||||
current: readonly PendingNativeChatImage[],
|
||||
uploaded: readonly Omit<PendingNativeChatImage, 'id'>[],
|
||||
idCounter: { current: number }
|
||||
): PendingNativeChatImage[] {
|
||||
return [
|
||||
...current,
|
||||
...uploaded.map((image) => {
|
||||
idCounter.current += 1
|
||||
return { id: `img-${idCounter.current}`, ...image }
|
||||
})
|
||||
]
|
||||
}
|
||||
|
||||
export type UploadNativeChatImagesDeps = {
|
||||
readonly client: Pick<RpcClient, 'sendRequest'>
|
||||
readonly getConnectionId: () => Promise<string | null>
|
||||
// Injected so this module stays free of expo/react-native imports (unit-testable).
|
||||
readonly pickImage: (source: MobileImageSource) => Promise<PickedMobileImage | null>
|
||||
readonly pickImages: (
|
||||
source: MobileImageSource
|
||||
) =>
|
||||
| Iterable<PickedMobileImage>
|
||||
| AsyncIterable<PickedMobileImage>
|
||||
| Promise<Iterable<PickedMobileImage> | AsyncIterable<PickedMobileImage>>
|
||||
// Fired once the user has picked an image and the host upload is about to start —
|
||||
// lets the UI show the attach spinner only for the transfer, not the picker.
|
||||
readonly onUploadStart?: () => void
|
||||
/** Retains each completed upload if a later image in the same selection fails. */
|
||||
readonly onImageUploaded?: (image: Omit<PendingNativeChatImage, 'id'>) => void
|
||||
}
|
||||
|
||||
/** Picks an image and uploads it to the host, returning the host path + a local
|
||||
* preview URI — but does NOT paste it into the terminal. Unlike the terminal
|
||||
* attach flow, native chat holds the image as a composer chip and rides it along
|
||||
* on submit (desktop parity), so the chip and the agent input never diverge.
|
||||
* Returns null when the user cancels the picker. */
|
||||
export async function uploadMobileNativeChatImage(
|
||||
* Returns an empty array when the user cancels the picker. */
|
||||
export async function uploadMobileNativeChatImages(
|
||||
source: MobileImageSource,
|
||||
{ client, getConnectionId, pickImage, onUploadStart }: UploadNativeChatImageDeps
|
||||
): Promise<Omit<PendingNativeChatImage, 'id'> | null> {
|
||||
const picked = await pickImage(source)
|
||||
if (!picked) {
|
||||
return null
|
||||
{
|
||||
client,
|
||||
getConnectionId,
|
||||
pickImages,
|
||||
onUploadStart,
|
||||
onImageUploaded
|
||||
}: UploadNativeChatImagesDeps
|
||||
): Promise<Omit<PendingNativeChatImage, 'id'>[]> {
|
||||
const picked = await pickImages(source)
|
||||
const uploaded: Omit<PendingNativeChatImage, 'id'>[] = []
|
||||
let connectionId: string | null = null
|
||||
for await (const image of picked) {
|
||||
if (uploaded.length === 0) {
|
||||
onUploadStart?.()
|
||||
connectionId = await getConnectionId()
|
||||
}
|
||||
const path = await saveMobileClipboardImageAsTempFile(client, image.base64, { connectionId })
|
||||
// Prefer the picker's local URI for the thumbnail; fall back to an inline data
|
||||
// URI when the source omitted one (RN <Image> renders both).
|
||||
const previewUri = image.uri ?? `data:image/png;base64,${image.base64}`
|
||||
const result = { path, previewUri }
|
||||
uploaded.push(result)
|
||||
onImageUploaded?.(result)
|
||||
}
|
||||
onUploadStart?.()
|
||||
const connectionId = await getConnectionId()
|
||||
const path = await saveMobileClipboardImageAsTempFile(client, picked.base64, { connectionId })
|
||||
// Prefer the picker's local URI for the thumbnail; fall back to an inline data
|
||||
// URI when the source omitted one (RN <Image> renders both).
|
||||
const previewUri = picked.uri ?? `data:image/png;base64,${picked.base64}`
|
||||
return { path, previewUri }
|
||||
return uploaded
|
||||
}
|
||||
|
||||
@@ -27,17 +27,22 @@ function clientWithResponses(responses: RpcResponse[]): Pick<RpcClient, 'sendReq
|
||||
|
||||
describe('pasteMobileNativeChatImagePaths', () => {
|
||||
it('clears the input line, then pastes each path as a bracketed, non-submitting terminal.send with the mobile client tag', async () => {
|
||||
const client = clientWithResponses([sendResult(true), sendResult(true), sendResult(true)])
|
||||
const client = clientWithResponses([
|
||||
sendResult(true),
|
||||
sendResult(true),
|
||||
sendResult(true),
|
||||
sendResult(true)
|
||||
])
|
||||
|
||||
const ok = await pasteMobileNativeChatImagePaths({
|
||||
client,
|
||||
terminal: 'term-1',
|
||||
deviceToken: 'device-9',
|
||||
imagePaths: ['/tmp/a.png', '/tmp/b.png']
|
||||
imagePaths: ['/tmp/a.png', '/tmp/b.png', '/tmp/c.png']
|
||||
})
|
||||
|
||||
expect(ok).toBe(true)
|
||||
expect(client.calls).toHaveLength(3)
|
||||
expect(client.calls).toHaveLength(4)
|
||||
// Leading Ctrl+U clears any stale input so a retry can't duplicate the image.
|
||||
expect(client.calls[0]).toEqual({
|
||||
method: 'terminal.send',
|
||||
@@ -50,6 +55,7 @@ describe('pasteMobileNativeChatImagePaths', () => {
|
||||
})
|
||||
expect(client.calls[1]?.params.text).toBe('\x1b[200~/tmp/a.png\x1b[201~')
|
||||
expect(client.calls[2]?.params.text).toBe('\x1b[200~/tmp/b.png\x1b[201~')
|
||||
expect(client.calls[3]?.params.text).toBe('\x1b[200~/tmp/c.png\x1b[201~')
|
||||
})
|
||||
|
||||
it('stops and reports failure as soon as a paste is rejected', async () => {
|
||||
|
||||
@@ -0,0 +1,91 @@
|
||||
export type MobileNativeChatPendingMessage = {
|
||||
id: string
|
||||
text: string
|
||||
expectedOccurrence: number
|
||||
/** Local preview URIs carried by the send for its optimistic echo. */
|
||||
images?: string[]
|
||||
baselineTailMessageId: string | null
|
||||
}
|
||||
|
||||
export type MobileNativeChatSendOrigin = {
|
||||
draftKey: string
|
||||
pendingKey: string | null
|
||||
normalizedText: string
|
||||
baselineOccurrences: number
|
||||
baselineTailMessageId: string | null
|
||||
}
|
||||
|
||||
type PendingByKey = Record<string, MobileNativeChatPendingMessage[]>
|
||||
|
||||
export function combineMobileNativeChatPending(
|
||||
session: MobileNativeChatPendingMessage[],
|
||||
waiting: readonly MobileNativeChatPendingMessage[]
|
||||
): MobileNativeChatPendingMessage[] {
|
||||
if (waiting.length === 0) {
|
||||
return session
|
||||
}
|
||||
const sessionIds = new Set(session.map((item) => item.id))
|
||||
return [...session, ...waiting.filter((item) => !sessionIds.has(item.id))]
|
||||
}
|
||||
|
||||
export function appendMobileNativeChatPending(
|
||||
previous: PendingByKey,
|
||||
key: string,
|
||||
id: string,
|
||||
origin: MobileNativeChatSendOrigin,
|
||||
text: string,
|
||||
images?: string[]
|
||||
): PendingByKey {
|
||||
const current = previous[key] ?? []
|
||||
const earlierOutstanding = current.filter(
|
||||
(pending) =>
|
||||
pending.text.trim() === origin.normalizedText &&
|
||||
pending.expectedOccurrence > origin.baselineOccurrences
|
||||
).length
|
||||
const expectedImageEchoOrdinal =
|
||||
current.filter((pending) => pending.text.trim() === '' && pending.images?.length).length + 1
|
||||
return {
|
||||
...previous,
|
||||
[key]: [
|
||||
...current,
|
||||
{
|
||||
id,
|
||||
text,
|
||||
expectedOccurrence:
|
||||
origin.normalizedText === ''
|
||||
? expectedImageEchoOrdinal
|
||||
: origin.baselineOccurrences + earlierOutstanding + 1,
|
||||
baselineTailMessageId: origin.baselineTailMessageId,
|
||||
...(images?.length ? { images } : {})
|
||||
}
|
||||
]
|
||||
}
|
||||
}
|
||||
|
||||
export function mergeWaitingSessionPending(
|
||||
previous: PendingByKey,
|
||||
sessionKey: string,
|
||||
waiting: readonly MobileNativeChatPendingMessage[]
|
||||
): PendingByKey {
|
||||
const current = previous[sessionKey] ?? []
|
||||
const currentIds = new Set(current.map((item) => item.id))
|
||||
const moved = waiting.filter((item) => !currentIds.has(item.id))
|
||||
return moved.length > 0 ? { ...previous, [sessionKey]: [...current, ...moved] } : previous
|
||||
}
|
||||
|
||||
export function removeWaitingSessionPending(
|
||||
previous: PendingByKey,
|
||||
draftKey: string,
|
||||
movedIds: ReadonlySet<string>
|
||||
): PendingByKey {
|
||||
const remaining = (previous[draftKey] ?? []).filter((item) => !movedIds.has(item.id))
|
||||
if (remaining.length > 0) {
|
||||
return { ...previous, [draftKey]: remaining }
|
||||
}
|
||||
if (!(draftKey in previous)) {
|
||||
return previous
|
||||
}
|
||||
const next = { ...previous }
|
||||
delete next[draftKey]
|
||||
return next
|
||||
}
|
||||
@@ -116,6 +116,36 @@ describe('buildMobileNativeChatTransientData', () => {
|
||||
expect(data[0]?.blocks).toEqual([{ type: 'image-ref', path: '/tmp/a.png' }])
|
||||
})
|
||||
|
||||
it('keeps the phone-local image visible when the transcript replaces its optimistic echo', () => {
|
||||
const folded = foldMobileNativeChatMessages([
|
||||
user('source', '[Image: source: /tmp/a.png]'),
|
||||
user('prompt', '[Image #1] look at this')
|
||||
])
|
||||
const result = buildMobileNativeChatTransientData({
|
||||
folded,
|
||||
streaming: null,
|
||||
pending: [],
|
||||
imagePreviewsByMessageId: { prompt: ['file:///phone-photo.jpg'] }
|
||||
})
|
||||
|
||||
expect(result.data).toHaveLength(1)
|
||||
expect(result.data[0]?.blocks).toEqual([
|
||||
{ type: 'image-ref', path: '/tmp/a.png', url: 'file:///phone-photo.jpg' },
|
||||
{ type: 'text', text: 'look at this' }
|
||||
])
|
||||
})
|
||||
|
||||
it('restores the local preview onto a marker-only transcript turn', () => {
|
||||
const result = buildMobileNativeChatTransientData({
|
||||
folded: foldMobileNativeChatMessages([user('prompt', '[Image #1]')]),
|
||||
streaming: null,
|
||||
pending: [],
|
||||
imagePreviewsByMessageId: { prompt: ['file:///phone-photo.jpg'] }
|
||||
})
|
||||
|
||||
expect(result.data[0]?.blocks).toEqual([{ type: 'image-ref', url: 'file:///phone-photo.jpg' }])
|
||||
})
|
||||
|
||||
it('appends a synthetic bubble for gated streaming text, between transcript and pending', () => {
|
||||
// Whether text streams at all is the gate's call
|
||||
// (`mobile-native-chat-streaming-gate.test.ts`); this only places it.
|
||||
|
||||
@@ -3,7 +3,7 @@ import {
|
||||
formatNativeChatEmptyStateCopy,
|
||||
type NativeChatEmptyStateCopy
|
||||
} from '../../../src/shared/native-chat-empty-state'
|
||||
import type { NativeChatMessage } from '../../../src/shared/native-chat-types'
|
||||
import { isImageRefBlock, type NativeChatMessage } from '../../../src/shared/native-chat-types'
|
||||
import { foldToolMessages } from './mobile-native-chat-blocks'
|
||||
import { normalizeImageTranscriptMessages } from './mobile-native-chat-image-transcript-markers'
|
||||
import { stripNoiseMessages } from './mobile-native-chat-noise'
|
||||
@@ -55,15 +55,37 @@ export function foldMobileNativeChatMessages(messages: NativeChatMessage[]): Nat
|
||||
export function buildMobileNativeChatTransientData({
|
||||
folded,
|
||||
streaming,
|
||||
pending
|
||||
pending,
|
||||
imagePreviewsByMessageId
|
||||
}: {
|
||||
folded: NativeChatMessage[]
|
||||
/** Streaming bubble text, already gated by `deriveMobileNativeChatStreaming`. */
|
||||
streaming: string | null
|
||||
pending: MobileNativeChatPendingItem[]
|
||||
imagePreviewsByMessageId?: Record<string, string[]>
|
||||
}): { folded: NativeChatMessage[]; streaming: string | null; data: NativeChatMessage[] } {
|
||||
const renderedFolded = folded.map((message) => {
|
||||
const previews = imagePreviewsByMessageId?.[message.id]
|
||||
if (message.role !== 'user' || !previews?.length) {
|
||||
return message
|
||||
}
|
||||
let previewIndex = 0
|
||||
const blocks = message.blocks.map((block) => {
|
||||
if (!isImageRefBlock(block)) {
|
||||
return block
|
||||
}
|
||||
const url = previews[previewIndex]
|
||||
previewIndex += 1
|
||||
return url ? { ...block, url } : block
|
||||
})
|
||||
while (previewIndex < previews.length) {
|
||||
blocks.push({ type: 'image-ref', url: previews[previewIndex] })
|
||||
previewIndex += 1
|
||||
}
|
||||
return { ...message, blocks }
|
||||
})
|
||||
const data: NativeChatMessage[] = [
|
||||
...folded,
|
||||
...renderedFolded,
|
||||
...(streaming
|
||||
? [
|
||||
{
|
||||
@@ -88,5 +110,5 @@ export function buildMobileNativeChatTransientData({
|
||||
source: 'transcript' as const
|
||||
}))
|
||||
]
|
||||
return { folded, streaming, data }
|
||||
return { folded: renderedFolded, streaming, data }
|
||||
}
|
||||
|
||||
@@ -81,7 +81,7 @@ describe('mobile session route', () => {
|
||||
name: 'Fix #1'
|
||||
})
|
||||
|
||||
navigateToHostStackRoute(harness.navigation, { push }, 'host/one', target)
|
||||
navigateToHostStackRoute(harness.navigation, { push, replace: vi.fn() }, 'host/one', target)
|
||||
|
||||
expect(push).toHaveBeenCalledWith(hostStackHostRoute('host/one'))
|
||||
expect(harness.navigation.dispatch).not.toHaveBeenCalled()
|
||||
|
||||
@@ -40,6 +40,7 @@ vi.mock('./use-mobile-native-chat-drafts', () => ({
|
||||
composerText: '',
|
||||
setComposerText: vi.fn(),
|
||||
pending: [],
|
||||
imagePreviewsByMessageId: {},
|
||||
captureSendOrigin,
|
||||
readSeededLaunchDraft: () => null,
|
||||
readSeededLaunchDraftSeed: () => null,
|
||||
|
||||
@@ -1,99 +1,29 @@
|
||||
import {
|
||||
useCallback,
|
||||
useLayoutEffect,
|
||||
useRef,
|
||||
type Dispatch,
|
||||
type MutableRefObject,
|
||||
type SetStateAction
|
||||
} from 'react'
|
||||
import { useCallback, useLayoutEffect, useRef, type MutableRefObject } from 'react'
|
||||
import { encodeNativeChatTranscriptIdentity } from '../../../src/shared/native-chat-transcript-retention'
|
||||
import { useMobileSessionViewMode } from './use-mobile-session-view-mode'
|
||||
import type { RpcClient } from '../transport/rpc-client'
|
||||
import type { ConnectionState } from '../transport/types'
|
||||
import {
|
||||
parseAskFromStatus,
|
||||
type AskAnswerSelection,
|
||||
type AskPrompt
|
||||
} from './mobile-native-chat-ask'
|
||||
import { type MobileNativeChatTab, resolveMobileNativeChat } from './mobile-native-chat-eligibility'
|
||||
import { detectAgentPermission } from './mobile-native-chat-permission'
|
||||
import { parseAgentQuestion } from './mobile-native-chat-question'
|
||||
import { useMobileNativeChatPermissionSend } from './mobile-native-chat-permission-send'
|
||||
import type { MobileNativeChatSendOutcome } from './mobile-native-chat-send'
|
||||
import { useMobileNativeChatAnswerSend } from './use-mobile-native-chat-answer-send'
|
||||
import { useMobileNativeChatAskDismiss } from './use-mobile-native-chat-ask-dismiss'
|
||||
import { useMobileNativeChatCancelAsk } from './use-mobile-native-chat-cancel-ask'
|
||||
import {
|
||||
useMobileNativeChatDrafts,
|
||||
type MobileNativeChatPendingMessage
|
||||
} from './use-mobile-native-chat-drafts'
|
||||
import { useMobileNativeChatDrafts } from './use-mobile-native-chat-drafts'
|
||||
import { useMobileNativeChatFileSearch } from './use-mobile-native-chat-file-search'
|
||||
import { useMobileNativeChatMessageSend } from './use-mobile-native-chat-message-send'
|
||||
import { mobileNativeChatScopeKey } from './mobile-native-chat-scope-key'
|
||||
import { useMobileNativeChatSession } from './use-mobile-native-chat-session'
|
||||
import { useMobileNativeChatSessionOptions } from './use-mobile-native-chat-session-options'
|
||||
import type { MobileNativeChatSessionOptionPickersProps } from './MobileNativeChatSessionOptionPickers'
|
||||
import { useMobileNativeChatPrompts } from './use-mobile-native-chat-prompts'
|
||||
import { useMobileNativeChatStop } from './use-mobile-native-chat-stop'
|
||||
import { useNativeChatAcceptedAction } from './use-native-chat-action-outcomes'
|
||||
import { useThrottledLatestValue } from './use-throttled-latest-value'
|
||||
import type { MobileNativeChatController } from './mobile-native-chat-controller-contract'
|
||||
|
||||
export type { MobileNativeChatController } from './mobile-native-chat-controller-contract'
|
||||
|
||||
const NATIVE_CHAT_STREAM_THROTTLE_MS = 50
|
||||
|
||||
export type MobileNativeChatController = {
|
||||
/** Whether a tab's effective view is chat (per-tab override, else the default). */
|
||||
isTabChatView: (tabId: string) => boolean
|
||||
toggleTabChatView: (tabId: string) => void
|
||||
showNativeChat: boolean
|
||||
showNativeChatRef: MutableRefObject<boolean>
|
||||
/** Resolved agent for the active chat tab (names the empty-state copy). */
|
||||
nativeChatAgent: string | null
|
||||
chatComposerText: string
|
||||
setChatComposerText: Dispatch<SetStateAction<string>>
|
||||
chatPending: MobileNativeChatPendingMessage[]
|
||||
nativeChatSession: ReturnType<typeof useMobileNativeChatSession>
|
||||
nativeChatAgentWorking: boolean
|
||||
nativeChatStreamingText?: string
|
||||
/** Agent mid-turn, regardless of whether chat is the visible view. */
|
||||
nativeChatStreamLive: boolean
|
||||
/** Host/workspace/tab/session scope for stateful streaming suppression. */
|
||||
nativeChatStreamScopeKey: string
|
||||
nativeChatPermission: ReturnType<typeof detectAgentPermission>
|
||||
nativeChatQuestion: ReturnType<typeof parseAgentQuestion>
|
||||
/** The pending ask, already null while dismissed (dismissal lives here so it
|
||||
* survives the chat-view subtree unmounting on a view toggle). */
|
||||
nativeChatAsk: ReturnType<typeof parseAskFromStatus>
|
||||
/** Stable key for the current ask card (keys the card component). */
|
||||
nativeChatAskKey: string | null
|
||||
/** Hide the current ask until a genuinely different question arrives. */
|
||||
dismissNativeChatAsk: () => void
|
||||
handleNativeChatAnswerAsk: (
|
||||
prompt: AskPrompt,
|
||||
selections: AskAnswerSelection[]
|
||||
) => Promise<boolean>
|
||||
handleNativeChatCancelAsk: () => Promise<boolean>
|
||||
handleNativeChatRespondPermission: (text: string) => Promise<boolean>
|
||||
handleNativeChatStop: () => void
|
||||
nativeChatFilePaths: string[]
|
||||
loadNativeChatFiles: (query: string) => void
|
||||
handleNativeChatQuestionAnswer: (text: string) => Promise<boolean>
|
||||
handleNativeChatSend: (text: string, images?: string[]) => Promise<boolean>
|
||||
/** Outcome-preserving send: callers that pasted terminal input beforehand
|
||||
* (image sends) must see 'unknown' to heal a possibly-orphaned paste. Such a
|
||||
* caller passes its own `deadline` so the paste it already spent and this text
|
||||
* body share one budget instead of holding the composer for two. */
|
||||
handleNativeChatSendWithOutcome: (
|
||||
text: string,
|
||||
images?: string[],
|
||||
deadline?: number
|
||||
) => Promise<MobileNativeChatSendOutcome>
|
||||
/** Launch-context text still parked on the agent's TUI input line, or null.
|
||||
* Image sends read it to size their leading clear (one Ctrl+U per line). */
|
||||
readSeededLaunchDraft: () => string | null
|
||||
/** Model/session-option pickers for the composer, or null when the active
|
||||
* agent has no session-option catalog. */
|
||||
nativeChatSessionOptions: MobileNativeChatSessionOptionPickersProps | null
|
||||
}
|
||||
|
||||
/** Owns mobile native-chat state and teardown outside the already dense session
|
||||
* route. The route remains responsible only for choosing and rendering the view. */
|
||||
export function useMobileNativeChatController(args: {
|
||||
@@ -152,6 +82,7 @@ export function useMobileNativeChatController(args: {
|
||||
|
||||
const nativeChatSession = useMobileNativeChatSession({
|
||||
client,
|
||||
sourceIdentity: encodeNativeChatTranscriptIdentity([hostId, worktreeId]),
|
||||
agent: activeChatResolution?.agent ?? null,
|
||||
sessionId: activeChatSessionId,
|
||||
transcriptPath: activeChatResolution?.transcriptPath ?? null
|
||||
@@ -160,6 +91,7 @@ export function useMobileNativeChatController(args: {
|
||||
composerText: chatComposerText,
|
||||
setComposerText: setChatComposerText,
|
||||
pending: chatPending,
|
||||
imagePreviewsByMessageId: chatImagePreviewsByMessageId,
|
||||
captureSendOrigin,
|
||||
readSeededLaunchDraft,
|
||||
readSeededLaunchDraftSeed,
|
||||
@@ -201,7 +133,8 @@ export function useMobileNativeChatController(args: {
|
||||
} = useMobileNativeChatPrompts({
|
||||
enabled: activeChatResolution != null,
|
||||
status: nativeChatStatus,
|
||||
messages: nativeChatSession.messages
|
||||
messages: nativeChatSession.messages,
|
||||
transcriptLoading: nativeChatSession.transcriptLoading
|
||||
})
|
||||
// A never-read transcript cannot prove that a dismissed prompt cleared.
|
||||
const nativeChatTranscriptSettled =
|
||||
@@ -326,6 +259,7 @@ export function useMobileNativeChatController(args: {
|
||||
chatComposerText,
|
||||
setChatComposerText,
|
||||
chatPending,
|
||||
chatImagePreviewsByMessageId,
|
||||
nativeChatSession,
|
||||
nativeChatAgentWorking,
|
||||
nativeChatStreamingText,
|
||||
|
||||
@@ -269,6 +269,7 @@ describe('useMobileNativeChatDrafts', () => {
|
||||
)
|
||||
)
|
||||
expect(state?.pending).toEqual([])
|
||||
expect(state?.imagePreviewsByMessageId).toEqual({ u1: ['file:///a.jpg'] })
|
||||
})
|
||||
|
||||
it("keeps an image-only echo when an unrelated text send's echo lands", async () => {
|
||||
@@ -345,6 +346,29 @@ describe('useMobileNativeChatDrafts', () => {
|
||||
)
|
||||
)
|
||||
expect(state?.pending).toEqual([])
|
||||
expect(state?.imagePreviewsByMessageId).toEqual({ u2: ['file:///a.jpg'] })
|
||||
})
|
||||
|
||||
it('hands a marker-only image preview to the authoritative user bubble', async () => {
|
||||
await mount('a')
|
||||
const origin = state?.captureSendOrigin('')
|
||||
act(() => {
|
||||
if (origin) {
|
||||
state?.acceptSend(origin, '', ['file:///a.jpg'])
|
||||
}
|
||||
})
|
||||
|
||||
await act(async () =>
|
||||
renderer?.update(
|
||||
createElement(Harness, {
|
||||
tabId: 'a',
|
||||
messages: [userTextMessage('u1', '[Image #1]')]
|
||||
})
|
||||
)
|
||||
)
|
||||
|
||||
expect(state?.pending).toEqual([])
|
||||
expect(state?.imagePreviewsByMessageId).toEqual({ u1: ['file:///a.jpg'] })
|
||||
})
|
||||
|
||||
it('does not reconcile a repeated send against an older identical turn', async () => {
|
||||
@@ -722,21 +746,59 @@ describe('useMobileNativeChatDrafts', () => {
|
||||
}
|
||||
})
|
||||
|
||||
it('accepts and clears the first send before a provider session id exists', async () => {
|
||||
it('preserves first-send images through session assignment and transcript replacement', async () => {
|
||||
await mount('a')
|
||||
await act(async () => renderer?.update(createElement(Harness, { tabId: 'a', sessionId: null })))
|
||||
act(() => state?.setComposerText('start the session'))
|
||||
const images = ['file:///a.jpg', 'file:///b.jpg', 'file:///c.jpg']
|
||||
act(() => state?.setComposerText('look'))
|
||||
|
||||
const origin = state?.captureSendOrigin('start the session')
|
||||
const origin = state?.captureSendOrigin('look')
|
||||
expect(origin).toMatchObject({ pendingKey: null })
|
||||
act(() => {
|
||||
if (origin) {
|
||||
state?.clearDraftForSend(origin, 'start the session')
|
||||
state?.acceptSend(origin, 'start the session')
|
||||
state?.clearDraftForSend(origin, 'look')
|
||||
state?.acceptSend(origin, 'look', images)
|
||||
}
|
||||
})
|
||||
|
||||
expect(state?.composerText).toBe('')
|
||||
expect(state?.pending.map((pending) => pending.images)).toEqual([images])
|
||||
|
||||
await act(async () =>
|
||||
renderer?.update(createElement(Harness, { tabId: 'a', sessionId: 'assigned' }))
|
||||
)
|
||||
expect(state?.pending.map((pending) => pending.images)).toEqual([images])
|
||||
|
||||
await act(async () =>
|
||||
renderer?.update(
|
||||
createElement(Harness, {
|
||||
tabId: 'a',
|
||||
sessionId: 'assigned',
|
||||
messages: [
|
||||
userTextMessage('source-1', '[Image: source: /tmp/a.png]'),
|
||||
userTextMessage('source-2', '[Image: source: /tmp/b.png]'),
|
||||
userTextMessage('source-3', '[Image: source: /tmp/c.png]')
|
||||
]
|
||||
})
|
||||
)
|
||||
)
|
||||
expect(state?.pending.map((pending) => pending.images)).toEqual([images])
|
||||
|
||||
await act(async () =>
|
||||
renderer?.update(
|
||||
createElement(Harness, {
|
||||
tabId: 'a',
|
||||
sessionId: 'assigned',
|
||||
messages: [
|
||||
userTextMessage('source-1', '[Image: source: /tmp/a.png]'),
|
||||
userTextMessage('source-2', '[Image: source: /tmp/b.png]'),
|
||||
userTextMessage('source-3', '[Image: source: /tmp/c.png]'),
|
||||
userTextMessage('prompt', '[Image #1] [Image #2] [Image #3] look')
|
||||
]
|
||||
})
|
||||
)
|
||||
)
|
||||
expect(state?.pending).toEqual([])
|
||||
expect(state?.imagePreviewsByMessageId).toEqual({ prompt: images })
|
||||
})
|
||||
})
|
||||
|
||||
@@ -3,35 +3,29 @@ import type { NativeChatMessage } from '../../../src/shared/native-chat-types'
|
||||
import {
|
||||
countImageSourceTurnsAfter,
|
||||
countUserTextOccurrences,
|
||||
findLandedImagePreviewEchoes,
|
||||
findLandedUnconfirmedSends,
|
||||
mergeLandedImagePreviewEchoes,
|
||||
migrateImagePreviewMessageIds,
|
||||
normalizedUserText,
|
||||
type UnconfirmedSend
|
||||
} from './mobile-native-chat-draft-reconcile'
|
||||
import {
|
||||
appendMobileNativeChatPending,
|
||||
combineMobileNativeChatPending,
|
||||
mergeWaitingSessionPending,
|
||||
removeWaitingSessionPending,
|
||||
type MobileNativeChatPendingMessage,
|
||||
type MobileNativeChatSendOrigin
|
||||
} from './mobile-native-chat-pending-echo'
|
||||
import { mobileNativeChatScopeKey } from './mobile-native-chat-scope-key'
|
||||
import { useMobileNativeChatLaunchDraftSeed } from './use-mobile-native-chat-launch-draft-seed'
|
||||
import type { MobileNativeChatLaunchDraftSeed } from './use-mobile-native-chat-launch-draft-seed'
|
||||
|
||||
export type MobileNativeChatPendingMessage = {
|
||||
id: string
|
||||
text: string
|
||||
expectedOccurrence: number
|
||||
/** Local preview URIs of images ridden along on the send, rendered as thumbnails
|
||||
* on the echo bubble so the sent photo shows before the transcript catches up. */
|
||||
images?: string[]
|
||||
/** Transcript tail when sent — an image-only echo (no text to match) reconciles
|
||||
* against new `[Image: source: …]` echo turns after this id, so pagination,
|
||||
* agent replies, and unrelated text echoes can't clear it early. */
|
||||
baselineTailMessageId: string | null
|
||||
}
|
||||
export type MobileNativeChatSendOrigin = {
|
||||
draftKey: string
|
||||
pendingKey: string | null
|
||||
normalizedText: string
|
||||
baselineOccurrences: number
|
||||
baselineTailMessageId: string | null
|
||||
}
|
||||
export type { MobileNativeChatPendingMessage, MobileNativeChatSendOrigin }
|
||||
|
||||
const NO_PENDING_MESSAGES: MobileNativeChatPendingMessage[] = []
|
||||
const NO_IMAGE_PREVIEWS: Record<string, string[]> = {}
|
||||
|
||||
// How long an ack-lost send waits for its transcript echo before the UI surfaces
|
||||
// that delivery remains unconfirmed.
|
||||
@@ -57,6 +51,9 @@ export function useMobileNativeChatDrafts(args: {
|
||||
composerText: string
|
||||
setComposerText: Dispatch<SetStateAction<string>>
|
||||
pending: MobileNativeChatPendingMessage[]
|
||||
/** Phone-local previews rebound to the transcript message that replaced the
|
||||
* optimistic echo, keyed by authoritative message id. */
|
||||
imagePreviewsByMessageId: Record<string, string[]>
|
||||
captureSendOrigin: (text: string) => MobileNativeChatSendOrigin | null
|
||||
/** Launch-context text still believed to be parked on the agent's TUI input
|
||||
* line, or null once it has been declined or retired. Send paths size their
|
||||
@@ -91,6 +88,12 @@ export function useMobileNativeChatDrafts(args: {
|
||||
const [pendingBySession, setPendingBySession] = useState<
|
||||
Record<string, MobileNativeChatPendingMessage[]>
|
||||
>({})
|
||||
const [pendingWaitingForSession, setPendingWaitingForSession] = useState<
|
||||
Record<string, MobileNativeChatPendingMessage[]>
|
||||
>({})
|
||||
const [imagePreviewsBySession, setImagePreviewsBySession] = useState<
|
||||
Record<string, Record<string, string[]>>
|
||||
>({})
|
||||
const pendingCounterRef = useRef(0)
|
||||
const messagesRef = useRef(messages)
|
||||
messagesRef.current = messages
|
||||
@@ -162,41 +165,21 @@ export function useMobileNativeChatDrafts(args: {
|
||||
|
||||
const acceptSend = useCallback(
|
||||
(origin: MobileNativeChatSendOrigin, text: string, images?: string[]) => {
|
||||
// Why: the first prompt can be sent before the provider reports a session
|
||||
// id; wait for an id before keying an optimistic echo.
|
||||
if (!origin.pendingKey) {
|
||||
if (!origin.pendingKey && !images?.length) {
|
||||
return
|
||||
}
|
||||
const pendingKey = origin.pendingKey
|
||||
pendingCounterRef.current += 1
|
||||
setPendingBySession((previous) => {
|
||||
const current = previous[pendingKey] ?? NO_PENDING_MESSAGES
|
||||
const earlierOutstanding = current.filter(
|
||||
(pending) =>
|
||||
pending.text.trim() === origin.normalizedText &&
|
||||
pending.expectedOccurrence > origin.baselineOccurrences
|
||||
).length
|
||||
// An empty-text send reconciles by image-echo ordinal: every outstanding
|
||||
// send's ridden-along images echo as `[Image: source: …]` turns after
|
||||
// this send's baseline tail, ahead of this send's own echo.
|
||||
const expectedImageEchoOrdinal =
|
||||
current.reduce(
|
||||
(sum, pending) =>
|
||||
sum + (pending.images?.length ?? (pending.text.trim() === '' ? 1 : 0)),
|
||||
0
|
||||
) + 1
|
||||
const pending: MobileNativeChatPendingMessage = {
|
||||
id: `pending-${pendingCounterRef.current}`,
|
||||
text,
|
||||
expectedOccurrence:
|
||||
origin.normalizedText === ''
|
||||
? expectedImageEchoOrdinal
|
||||
: origin.baselineOccurrences + earlierOutstanding + 1,
|
||||
baselineTailMessageId: origin.baselineTailMessageId,
|
||||
...(images && images.length > 0 ? { images } : {})
|
||||
}
|
||||
return { ...previous, [pendingKey]: [...current, pending] }
|
||||
})
|
||||
const id = `pending-${pendingCounterRef.current}`
|
||||
const key = origin.pendingKey
|
||||
if (key) {
|
||||
setPendingBySession((previous) =>
|
||||
appendMobileNativeChatPending(previous, key, id, origin, text, images)
|
||||
)
|
||||
} else {
|
||||
setPendingWaitingForSession((previous) =>
|
||||
appendMobileNativeChatPending(previous, origin.draftKey, id, origin, text, images)
|
||||
)
|
||||
}
|
||||
},
|
||||
[]
|
||||
)
|
||||
@@ -255,9 +238,7 @@ export function useMobileNativeChatDrafts(args: {
|
||||
const landedSet = new Set(landed)
|
||||
unconfirmedRef.current = unconfirmedRef.current.filter((entry) => !landedSet.has(entry))
|
||||
for (const entry of landed) {
|
||||
if (entry.deadline !== null) {
|
||||
clearTimeout(entry.deadline)
|
||||
}
|
||||
clearTimeout(entry.deadline ?? undefined)
|
||||
}
|
||||
}, [messages, draftKey, pendingKey])
|
||||
|
||||
@@ -266,21 +247,49 @@ export function useMobileNativeChatDrafts(args: {
|
||||
return () => {
|
||||
mountedRef.current = false
|
||||
for (const entry of unconfirmedRef.current) {
|
||||
if (entry.deadline !== null) {
|
||||
clearTimeout(entry.deadline)
|
||||
}
|
||||
clearTimeout(entry.deadline ?? undefined)
|
||||
}
|
||||
unconfirmedRef.current = []
|
||||
}
|
||||
}, [])
|
||||
|
||||
const pending = pendingKey
|
||||
? (pendingBySession[pendingKey] ?? NO_PENDING_MESSAGES)
|
||||
const waitingForSession = draftKey
|
||||
? (pendingWaitingForSession[draftKey] ?? NO_PENDING_MESSAGES)
|
||||
: NO_PENDING_MESSAGES
|
||||
useEffect(() => {
|
||||
if (!pendingKey || pending.length === 0) {
|
||||
if (!draftKey || !pendingKey || waitingForSession.length === 0) {
|
||||
return
|
||||
}
|
||||
const movedIds = new Set(waitingForSession.map((item) => item.id))
|
||||
setPendingBySession((previous) =>
|
||||
mergeWaitingSessionPending(previous, pendingKey, waitingForSession)
|
||||
)
|
||||
setPendingWaitingForSession((previous) =>
|
||||
removeWaitingSessionPending(previous, draftKey, movedIds)
|
||||
)
|
||||
}, [draftKey, pendingKey, waitingForSession])
|
||||
|
||||
const sessionPending = pendingKey
|
||||
? (pendingBySession[pendingKey] ?? NO_PENDING_MESSAGES)
|
||||
: NO_PENDING_MESSAGES
|
||||
const pending = combineMobileNativeChatPending(sessionPending, waitingForSession)
|
||||
useEffect(() => {
|
||||
if (!pendingKey) {
|
||||
return
|
||||
}
|
||||
setImagePreviewsBySession((previous) =>
|
||||
migrateImagePreviewMessageIds(previous, pendingKey, messages)
|
||||
)
|
||||
if (pending.length === 0) {
|
||||
return
|
||||
}
|
||||
const landedImagePreviews = findLandedImagePreviewEchoes(messages, pending)
|
||||
const landedImagePendingIds = new Set(landedImagePreviews.map((preview) => preview.pendingId))
|
||||
if (landedImagePreviews.length > 0) {
|
||||
setImagePreviewsBySession((previous) =>
|
||||
mergeLandedImagePreviewEchoes(previous, pendingKey, landedImagePreviews)
|
||||
)
|
||||
}
|
||||
setPendingBySession((previous) => {
|
||||
const current = previous[pendingKey] ?? []
|
||||
const landedCounts = new Map<string, number>()
|
||||
@@ -297,12 +306,20 @@ export function useMobileNativeChatDrafts(args: {
|
||||
// baseline tail — text echoes are excluded so an unrelated outstanding
|
||||
// text send cannot clear it. Ordinal-vs-count stays stable when the effect
|
||||
// re-runs on the shrunken list, and ignores paginated-in history.
|
||||
const next = current.filter((item) =>
|
||||
item.text.trim() === ''
|
||||
const next = current.filter((item) => {
|
||||
if (landedImagePendingIds.has(item.id)) {
|
||||
return false
|
||||
}
|
||||
// Image echoes hand their local URIs to the authoritative message above;
|
||||
// never drop them through the text-only fallback before that handoff.
|
||||
if (item.images?.length) {
|
||||
return true
|
||||
}
|
||||
return item.text.trim() === ''
|
||||
? countImageSourceTurnsAfter(messages, item.baselineTailMessageId) <
|
||||
item.expectedOccurrence
|
||||
item.expectedOccurrence
|
||||
: (landedCounts.get(item.text.trim()) ?? 0) < item.expectedOccurrence
|
||||
)
|
||||
})
|
||||
if (next.length === current.length) {
|
||||
return previous
|
||||
}
|
||||
@@ -319,6 +336,9 @@ export function useMobileNativeChatDrafts(args: {
|
||||
composerText: draftKey ? (drafts[draftKey] ?? '') : '',
|
||||
setComposerText,
|
||||
pending,
|
||||
imagePreviewsByMessageId: pendingKey
|
||||
? (imagePreviewsBySession[pendingKey] ?? NO_IMAGE_PREVIEWS)
|
||||
: NO_IMAGE_PREVIEWS,
|
||||
captureSendOrigin,
|
||||
readSeededLaunchDraft,
|
||||
readSeededLaunchDraftSeed,
|
||||
|
||||
@@ -11,13 +11,13 @@ import { useMobileNativeChatImageAttachments } from './use-mobile-native-chat-im
|
||||
// Fully stub the picker so the real expo/react-native chain never loads under
|
||||
// the vitest transform (react-native ships Flow syntax rolldown can't parse).
|
||||
vi.mock('./mobile-image-source-picker', () => ({
|
||||
pickMobileImage: vi.fn(),
|
||||
pickMobileImages: vi.fn(),
|
||||
ImageLibraryPermissionError: class ImageLibraryPermissionError extends Error {}
|
||||
}))
|
||||
|
||||
import { pickMobileImage } from './mobile-image-source-picker'
|
||||
import { pickMobileImages } from './mobile-image-source-picker'
|
||||
|
||||
const pick = vi.mocked(pickMobileImage)
|
||||
const pick = vi.mocked(pickMobileImages)
|
||||
|
||||
function ok(id: string, result: unknown): RpcSuccess {
|
||||
return { id, ok: true, result, _meta: { runtimeId: 'r' } }
|
||||
@@ -124,7 +124,7 @@ describe('useMobileNativeChatImageAttachments', () => {
|
||||
}
|
||||
|
||||
it('adds an uploaded image as a chip without pasting to the terminal', async () => {
|
||||
pick.mockResolvedValue({ base64: 'AAAA', uri: 'file:///a.jpg' })
|
||||
pick.mockResolvedValue([{ base64: 'AAAA', uri: 'file:///a.jpg' }])
|
||||
const client = makeClient([methodNotFound('start'), ok('save', '/tmp/a.png')])
|
||||
mount(
|
||||
baseArgs({
|
||||
@@ -145,7 +145,7 @@ describe('useMobileNativeChatImageAttachments', () => {
|
||||
})
|
||||
|
||||
it('rides pending images along on send: pastes the path, settles, then delegates the text', async () => {
|
||||
pick.mockResolvedValue({ base64: 'AAAA', uri: 'file:///a.jpg' })
|
||||
pick.mockResolvedValue([{ base64: 'AAAA', uri: 'file:///a.jpg' }])
|
||||
const client = makeClient([
|
||||
methodNotFound('start'),
|
||||
ok('save', '/tmp/a.png'),
|
||||
@@ -208,7 +208,7 @@ describe('useMobileNativeChatImageAttachments', () => {
|
||||
it('leads the image paste with a clear sized to a parked multi-line launch draft', async () => {
|
||||
// A single Ctrl+U kills only the last line, so the draft's earlier lines
|
||||
// would survive the clear and ride along with the image as prompt body.
|
||||
pick.mockResolvedValue({ base64: 'AAAA', uri: 'file:///a.jpg' })
|
||||
pick.mockResolvedValue([{ base64: 'AAAA', uri: 'file:///a.jpg' }])
|
||||
const client = makeClient([
|
||||
methodNotFound('start'),
|
||||
ok('save', '/tmp/a.png'),
|
||||
@@ -242,7 +242,7 @@ describe('useMobileNativeChatImageAttachments', () => {
|
||||
it('spends one budget across the image paste and the text body that follows', async () => {
|
||||
vi.useFakeTimers()
|
||||
try {
|
||||
pick.mockResolvedValue({ base64: 'AAAA', uri: 'file:///a.jpg' })
|
||||
pick.mockResolvedValue([{ base64: 'AAAA', uri: 'file:///a.jpg' }])
|
||||
const client = makeClient([
|
||||
methodNotFound('start'),
|
||||
ok('save', '/tmp/a.png'),
|
||||
@@ -280,7 +280,7 @@ describe('useMobileNativeChatImageAttachments', () => {
|
||||
})
|
||||
|
||||
it('routes an attachments-only send through baseSend with empty text so the echo still shows the photo', async () => {
|
||||
pick.mockResolvedValue({ base64: 'AAAA', uri: 'file:///a.jpg' })
|
||||
pick.mockResolvedValue([{ base64: 'AAAA', uri: 'file:///a.jpg' }])
|
||||
const client = makeClient([
|
||||
methodNotFound('start'),
|
||||
ok('save', '/tmp/a.png'),
|
||||
@@ -321,7 +321,7 @@ describe('useMobileNativeChatImageAttachments', () => {
|
||||
})
|
||||
|
||||
it('keeps the chips and does not submit when the image paste is rejected', async () => {
|
||||
pick.mockResolvedValue({ base64: 'AAAA', uri: 'file:///a.jpg' })
|
||||
pick.mockResolvedValue([{ base64: 'AAAA', uri: 'file:///a.jpg' }])
|
||||
const client = makeClient([
|
||||
methodNotFound('start'),
|
||||
ok('save', '/tmp/a.png'),
|
||||
@@ -345,7 +345,7 @@ describe('useMobileNativeChatImageAttachments', () => {
|
||||
})
|
||||
|
||||
it('keeps the chips and reports failure when the paste transport throws', async () => {
|
||||
pick.mockResolvedValue({ base64: 'AAAA', uri: 'file:///a.jpg' })
|
||||
pick.mockResolvedValue([{ base64: 'AAAA', uri: 'file:///a.jpg' }])
|
||||
// No terminal.send responses queued: the clear write throws (dropped transport).
|
||||
const client = makeClient([methodNotFound('start'), ok('save', '/tmp/a.png')])
|
||||
const baseSend = vi.fn().mockResolvedValue('accepted')
|
||||
@@ -365,7 +365,7 @@ describe('useMobileNativeChatImageAttachments', () => {
|
||||
})
|
||||
|
||||
it('surfaces an error instead of a silent no-op when the input lease gate is closed', async () => {
|
||||
pick.mockResolvedValue({ base64: 'AAAA', uri: 'file:///a.jpg' })
|
||||
pick.mockResolvedValue([{ base64: 'AAAA', uri: 'file:///a.jpg' }])
|
||||
const client = makeClient([methodNotFound('start'), ok('save', '/tmp/a.png')])
|
||||
const baseSend = vi.fn().mockResolvedValue('accepted')
|
||||
const onSendError = vi.fn()
|
||||
@@ -387,7 +387,7 @@ describe('useMobileNativeChatImageAttachments', () => {
|
||||
})
|
||||
|
||||
it('scopes chips to the tab that attached them', async () => {
|
||||
pick.mockResolvedValue({ base64: 'AAAA', uri: 'file:///a.jpg' })
|
||||
pick.mockResolvedValue([{ base64: 'AAAA', uri: 'file:///a.jpg' }])
|
||||
const client = makeClient([methodNotFound('start'), ok('save', '/tmp/a.png')])
|
||||
const baseSend = vi.fn().mockResolvedValue('accepted')
|
||||
const args = baseArgs({ client: client as unknown as RpcClient, baseSend })
|
||||
@@ -426,7 +426,7 @@ describe('useMobileNativeChatImageAttachments', () => {
|
||||
})
|
||||
mount(args)
|
||||
|
||||
pick.mockResolvedValue({ base64: 'AAAA', uri: 'file:///a.jpg' })
|
||||
pick.mockResolvedValue([{ base64: 'AAAA', uri: 'file:///a.jpg' }])
|
||||
let firstAttach: Promise<void> | null = null
|
||||
await act(async () => {
|
||||
firstAttach = hook!.attachImage('library')
|
||||
@@ -438,7 +438,7 @@ describe('useMobileNativeChatImageAttachments', () => {
|
||||
expect(hook!.isAttaching).toBe(true)
|
||||
|
||||
// A concurrent cancelled pick — its finally must leave the counter alone.
|
||||
pick.mockResolvedValue(null)
|
||||
pick.mockResolvedValue([])
|
||||
await act(async () => {
|
||||
await hook!.attachImage('library')
|
||||
})
|
||||
@@ -454,7 +454,7 @@ describe('useMobileNativeChatImageAttachments', () => {
|
||||
})
|
||||
|
||||
it('clears only the chips that were sent, keeping one attached mid-send', async () => {
|
||||
pick.mockResolvedValue({ base64: 'AAAA', uri: 'file:///a.jpg' })
|
||||
pick.mockResolvedValue([{ base64: 'AAAA', uri: 'file:///a.jpg' }])
|
||||
const client = makeClient([
|
||||
methodNotFound('start'),
|
||||
ok('save', '/tmp/a.png'), // first attach
|
||||
@@ -488,7 +488,7 @@ describe('useMobileNativeChatImageAttachments', () => {
|
||||
})
|
||||
expect(releaseSettle).not.toBeNull()
|
||||
|
||||
pick.mockResolvedValue({ base64: 'BBBB', uri: 'file:///b.jpg' })
|
||||
pick.mockResolvedValue([{ base64: 'BBBB', uri: 'file:///b.jpg' }])
|
||||
await act(async () => {
|
||||
await hook!.attachImage('library')
|
||||
})
|
||||
@@ -511,7 +511,7 @@ describe('useMobileNativeChatImageAttachments', () => {
|
||||
})
|
||||
|
||||
it('aborts the send when the active terminal changes during the settle window', async () => {
|
||||
pick.mockResolvedValue({ base64: 'AAAA', uri: 'file:///a.jpg' })
|
||||
pick.mockResolvedValue([{ base64: 'AAAA', uri: 'file:///a.jpg' }])
|
||||
const client = makeClient([
|
||||
methodNotFound('start'),
|
||||
ok('save', '/tmp/a.png'),
|
||||
@@ -560,7 +560,7 @@ describe('useMobileNativeChatImageAttachments', () => {
|
||||
})
|
||||
|
||||
it('leads the next text-only send with Ctrl+U after a failed paste, even with the chip removed', async () => {
|
||||
pick.mockResolvedValue({ base64: 'AAAA', uri: 'file:///a.jpg' })
|
||||
pick.mockResolvedValue([{ base64: 'AAAA', uri: 'file:///a.jpg' }])
|
||||
const client = makeClient([
|
||||
methodNotFound('start'),
|
||||
ok('save', '/tmp/a.png'),
|
||||
@@ -596,7 +596,7 @@ describe('useMobileNativeChatImageAttachments', () => {
|
||||
})
|
||||
|
||||
it('heals before the next text-only send when an image submit delivery is unknown (#10228)', async () => {
|
||||
pick.mockResolvedValue({ base64: 'AAAA', uri: 'file:///a.jpg' })
|
||||
pick.mockResolvedValue([{ base64: 'AAAA', uri: 'file:///a.jpg' }])
|
||||
const client = makeClient([
|
||||
methodNotFound('start'),
|
||||
ok('save', '/tmp/a.png'),
|
||||
@@ -634,7 +634,7 @@ describe('useMobileNativeChatImageAttachments', () => {
|
||||
})
|
||||
|
||||
it('still heals after the session screen unmounts and remounts (#10228)', async () => {
|
||||
pick.mockResolvedValue({ base64: 'AAAA', uri: 'file:///a.jpg' })
|
||||
pick.mockResolvedValue([{ base64: 'AAAA', uri: 'file:///a.jpg' }])
|
||||
const client = makeClient([
|
||||
methodNotFound('start'),
|
||||
ok('save', '/tmp/a.png'),
|
||||
@@ -688,7 +688,7 @@ describe('useMobileNativeChatImageAttachments', () => {
|
||||
})
|
||||
|
||||
it('retains the stale marker when a rejected healing clear blocks text-only send', async () => {
|
||||
pick.mockResolvedValue({ base64: 'AAAA', uri: 'file:///a.jpg' })
|
||||
pick.mockResolvedValue([{ base64: 'AAAA', uri: 'file:///a.jpg' }])
|
||||
const client = makeClient([
|
||||
methodNotFound('start'),
|
||||
ok('save', '/tmp/a.png'),
|
||||
@@ -730,7 +730,7 @@ describe('useMobileNativeChatImageAttachments', () => {
|
||||
})
|
||||
|
||||
it('does not reroute text when the active terminal changes during a healing clear', async () => {
|
||||
pick.mockResolvedValue({ base64: 'AAAA', uri: 'file:///a.jpg' })
|
||||
pick.mockResolvedValue([{ base64: 'AAAA', uri: 'file:///a.jpg' }])
|
||||
let releaseClear: ((response: RpcResponse) => void) | null = null
|
||||
const deferredClear = new Promise<RpcResponse>((resolve) => {
|
||||
releaseClear = resolve
|
||||
@@ -774,7 +774,7 @@ describe('useMobileNativeChatImageAttachments', () => {
|
||||
})
|
||||
|
||||
it('defers the heal instead of burning a rejected clear while the lease is closed', async () => {
|
||||
pick.mockResolvedValue({ base64: 'AAAA', uri: 'file:///a.jpg' })
|
||||
pick.mockResolvedValue([{ base64: 'AAAA', uri: 'file:///a.jpg' }])
|
||||
const client = makeClient([
|
||||
methodNotFound('start'),
|
||||
ok('save', '/tmp/a.png'),
|
||||
@@ -818,8 +818,8 @@ describe('useMobileNativeChatImageAttachments', () => {
|
||||
|
||||
it('heals rejected image submits independently across terminals', async () => {
|
||||
pick
|
||||
.mockResolvedValueOnce({ base64: 'AAAA', uri: 'file:///a.jpg' })
|
||||
.mockResolvedValueOnce({ base64: 'BBBB', uri: 'file:///b.jpg' })
|
||||
.mockResolvedValueOnce([{ base64: 'AAAA', uri: 'file:///a.jpg' }])
|
||||
.mockResolvedValueOnce([{ base64: 'BBBB', uri: 'file:///b.jpg' }])
|
||||
const client = makeClient([
|
||||
methodNotFound('start-a'),
|
||||
ok('save-a', '/tmp/a.png'),
|
||||
@@ -896,7 +896,7 @@ describe('useMobileNativeChatImageAttachments', () => {
|
||||
})
|
||||
})
|
||||
mount(args)
|
||||
pick.mockResolvedValue({ base64: 'AAAA', uri: 'file:///a.jpg' })
|
||||
pick.mockResolvedValue([{ base64: 'AAAA', uri: 'file:///a.jpg' }])
|
||||
let attach: Promise<void> | null = null
|
||||
await act(async () => {
|
||||
attach = hook!.attachImage('library')
|
||||
|
||||
@@ -5,11 +5,12 @@ import type { RpcClient } from '../transport/rpc-client'
|
||||
import type { ConnectionState } from '../transport/types'
|
||||
import {
|
||||
ImageLibraryPermissionError,
|
||||
pickMobileImage,
|
||||
pickMobileImages,
|
||||
type MobileImageSource
|
||||
} from './mobile-image-source-picker'
|
||||
import {
|
||||
uploadMobileNativeChatImage,
|
||||
appendPendingNativeChatImages,
|
||||
uploadMobileNativeChatImages,
|
||||
type PendingNativeChatImage
|
||||
} from './mobile-native-chat-image-attachment'
|
||||
import {
|
||||
@@ -82,10 +83,6 @@ export type MobileNativeChatImageAttachments = {
|
||||
readonly sendNativeChat: (text: string) => Promise<boolean>
|
||||
}
|
||||
|
||||
function getErrorMessage(error: unknown): string {
|
||||
return error instanceof Error ? error.message : String(error)
|
||||
}
|
||||
|
||||
const NO_ATTACHMENTS: PendingNativeChatImage[] = []
|
||||
|
||||
function withScopeAttachments(
|
||||
@@ -146,48 +143,53 @@ export function useMobileNativeChatImageAttachments({
|
||||
// pick or pre-upload error never ran `onUploadStart`, so decrementing the
|
||||
// shared counter would clear a concurrent upload's in-flight flag early.
|
||||
let started = false
|
||||
const uploadedImages: Omit<PendingNativeChatImage, 'id'>[] = []
|
||||
let uploadError: unknown = null
|
||||
try {
|
||||
const uploaded = await uploadMobileNativeChatImage(source, {
|
||||
await uploadMobileNativeChatImages(source, {
|
||||
client,
|
||||
getConnectionId: getActiveWorktreeConnectionId,
|
||||
pickImage: pickMobileImage,
|
||||
pickImages: pickMobileImages,
|
||||
onImageUploaded: (image) => uploadedImages.push(image),
|
||||
onUploadStart: () => {
|
||||
started = true
|
||||
attachingCount.current += 1
|
||||
setIsAttaching(true)
|
||||
}
|
||||
})
|
||||
// Cancelled picker: no error, no toast.
|
||||
if (!uploaded) {
|
||||
return
|
||||
}
|
||||
idCounter.current += 1
|
||||
const chip = { id: `img-${idCounter.current}`, ...uploaded }
|
||||
setAttachmentsByScope((prev) => ({ ...prev, [scope]: [...(prev[scope] ?? []), chip] }))
|
||||
onAttachSuccess?.()
|
||||
} catch (error) {
|
||||
uploadError = error
|
||||
} finally {
|
||||
if (started) {
|
||||
attachingCount.current -= 1
|
||||
if (attachingCount.current === 0) {
|
||||
setIsAttaching(false)
|
||||
}
|
||||
}
|
||||
}
|
||||
if (uploadedImages.length > 0) {
|
||||
setAttachmentsByScope((prev) => ({
|
||||
...prev,
|
||||
[scope]: appendPendingNativeChatImages(prev[scope] ?? [], uploadedImages, idCounter)
|
||||
}))
|
||||
onAttachSuccess?.()
|
||||
}
|
||||
if (uploadError !== null) {
|
||||
const message = uploadError instanceof Error ? uploadError.message : String(uploadError)
|
||||
onError?.()
|
||||
if (connStateRef.current !== 'connected') {
|
||||
showToast('Attach failed (disconnected)', 1500)
|
||||
return
|
||||
}
|
||||
if (error instanceof ImageLibraryPermissionError) {
|
||||
if (uploadError instanceof ImageLibraryPermissionError) {
|
||||
showToast('Photo permission denied', 1500)
|
||||
return
|
||||
}
|
||||
if (getErrorMessage(error) === CLIPBOARD_IMAGE_TOO_LARGE_ERROR) {
|
||||
if (message === CLIPBOARD_IMAGE_TOO_LARGE_ERROR) {
|
||||
showToast('Image too large to attach', 1500)
|
||||
return
|
||||
}
|
||||
showToast('Attach failed', 1500)
|
||||
} finally {
|
||||
if (started) {
|
||||
attachingCount.current -= 1
|
||||
if (attachingCount.current <= 0) {
|
||||
attachingCount.current = 0
|
||||
setIsAttaching(false)
|
||||
}
|
||||
}
|
||||
}
|
||||
},
|
||||
[
|
||||
|
||||
@@ -15,14 +15,16 @@ const ASK = JSON.stringify({
|
||||
|
||||
function promptsFor(
|
||||
status: Partial<AgentStatusEntry> | null,
|
||||
messages: NativeChatMessage[] = []
|
||||
messages: NativeChatMessage[] = [],
|
||||
transcriptLoading = false
|
||||
): ReturnType<typeof useMobileNativeChatPrompts> {
|
||||
let captured: ReturnType<typeof useMobileNativeChatPrompts> | undefined
|
||||
function Probe(): null {
|
||||
captured = useMobileNativeChatPrompts({
|
||||
enabled: true,
|
||||
status: status as AgentStatusEntry | null,
|
||||
messages
|
||||
messages,
|
||||
transcriptLoading
|
||||
})
|
||||
return null
|
||||
}
|
||||
@@ -118,6 +120,18 @@ describe('useMobileNativeChatPrompts ask state gate', () => {
|
||||
expect(promptsFor(null, askMessages).ask).not.toBeNull()
|
||||
})
|
||||
|
||||
it('withholds retained transcript asks while the replacement read is unsettled', () => {
|
||||
const prompts = promptsFor({ state: 'done' }, askMessages, true)
|
||||
expect(prompts.ask).toBeNull()
|
||||
expect(prompts.detectedAsk).toBeNull()
|
||||
})
|
||||
|
||||
it('keeps a paused live status ask authoritative while the read is unsettled', () => {
|
||||
const prompts = promptsFor({ state: 'waiting', interactivePrompt: ASK }, askMessages, true)
|
||||
expect(prompts.ask).toMatchObject({ questions: [{ question: 'Which path?' }] })
|
||||
expect(prompts.detectedAsk).not.toBeNull()
|
||||
})
|
||||
|
||||
it('does not leak a paused-out sticky status prompt through the transcript fallback', () => {
|
||||
// The post-answer window: the status still carries the prompt while flipping
|
||||
// to `working`, and the transcript's tool-result row has not landed yet, so
|
||||
|
||||
@@ -1,7 +1,7 @@
|
||||
import { useMemo } from 'react'
|
||||
import type { AgentStatusEntry } from '../../../src/shared/agent-status-types'
|
||||
import type { NativeChatMessage } from '../../../src/shared/native-chat-types'
|
||||
import { extractPendingAsk, parseAskFromStatus } from './mobile-native-chat-ask'
|
||||
import { parseAskFromStatus, resolveNativeChatAsk } from './mobile-native-chat-ask'
|
||||
import { detectAgentPermission, parseApprovalFromStatus } from './mobile-native-chat-permission'
|
||||
import { parseAgentQuestion } from './mobile-native-chat-question'
|
||||
|
||||
@@ -17,8 +17,11 @@ export function useMobileNativeChatPrompts(args: {
|
||||
enabled: boolean
|
||||
status: AgentStatusEntry | null | undefined
|
||||
messages: readonly NativeChatMessage[]
|
||||
/** True while `messages` is an unsettled read (including the cached list held
|
||||
* across a reconnect). Required: an ask derived from it may already be answered. */
|
||||
transcriptLoading: boolean
|
||||
}): MobileNativeChatPrompts {
|
||||
const { enabled, status, messages } = args
|
||||
const { enabled, status, messages, transcriptLoading } = args
|
||||
const blocked = status?.state === 'waiting' || status?.state === 'blocked'
|
||||
// Both permission paths sit inside the paused gate: an approval envelope can
|
||||
// outlive its answer (the host keeps it sticky), so only a waiting/blocked
|
||||
@@ -38,11 +41,16 @@ export function useMobileNativeChatPrompts(args: {
|
||||
() => parseAskFromStatus(status?.interactivePrompt, status?.toolName),
|
||||
[status?.interactivePrompt, status?.toolName]
|
||||
)
|
||||
const askFromMessages = useMemo(
|
||||
() => (askFromStatus ? null : extractPendingAsk(messages)),
|
||||
[askFromStatus, messages]
|
||||
const resolvedAsk = useMemo(
|
||||
() =>
|
||||
resolveNativeChatAsk({
|
||||
liveAsk: askFromStatus,
|
||||
messages,
|
||||
transcriptSettled: !transcriptLoading
|
||||
}),
|
||||
[askFromStatus, transcriptLoading, messages]
|
||||
)
|
||||
|
||||
const askFromMessages = askFromStatus ? null : resolvedAsk
|
||||
const detectedAsk = askFromStatus ?? askFromMessages
|
||||
|
||||
return {
|
||||
|
||||
@@ -36,6 +36,7 @@ describe('useMobileNativeChatSession', () => {
|
||||
function Harness({ client }: { client: RpcClient | null }): null {
|
||||
state = useMobileNativeChatSession({
|
||||
client,
|
||||
sourceIdentity: 'host-a\0workspace-a',
|
||||
agent: 'claude',
|
||||
sessionId: 'session',
|
||||
transcriptPath: null
|
||||
@@ -456,14 +457,17 @@ describe('useMobileNativeChatSession transcriptLoading', () => {
|
||||
function Harness({
|
||||
client,
|
||||
sessionId,
|
||||
agent = 'claude'
|
||||
agent = 'claude',
|
||||
sourceIdentity = 'host-a\0workspace-a'
|
||||
}: {
|
||||
client: RpcClient | null
|
||||
sessionId: string | null
|
||||
agent?: string | null
|
||||
sourceIdentity?: string
|
||||
}): null {
|
||||
const session = useMobileNativeChatSession({
|
||||
client,
|
||||
sourceIdentity,
|
||||
agent,
|
||||
sessionId,
|
||||
transcriptPath: null
|
||||
@@ -506,7 +510,8 @@ describe('useMobileNativeChatSession transcriptLoading', () => {
|
||||
it('re-reads instead of resurfacing a settled read when the same identity returns', async () => {
|
||||
// Leaving chat view nulls the agent, then returning restores the identity a
|
||||
// settled read already matched — but its list was cleared, so trusting it
|
||||
// would report 'ready' over an empty transcript.
|
||||
// would report 'ready' over an empty transcript. The last settled list for
|
||||
// this identity keeps rendering while the re-read is in flight.
|
||||
const subscribe: RpcClient['subscribe'] = vi.fn((_method, _params, onData) => {
|
||||
onData({ type: 'snapshot', messages: [message('a-1')], hasMore: false })
|
||||
return () => {}
|
||||
@@ -524,12 +529,17 @@ describe('useMobileNativeChatSession transcriptLoading', () => {
|
||||
renderer?.update(createElement(Harness, { client, sessionId: 'session-a', agent: 'claude' }))
|
||||
)
|
||||
|
||||
expect(renders[0]).toMatchObject({ status: 'loading', transcriptLoading: true, ids: [] })
|
||||
expect(renders[0]).toMatchObject({
|
||||
status: 'loading',
|
||||
transcriptLoading: true,
|
||||
ids: ['a-1']
|
||||
})
|
||||
})
|
||||
|
||||
it('re-reads instead of resurfacing a settled read after a reconnect', async () => {
|
||||
// A reconnect swaps the client without moving the identity; the effect
|
||||
// re-subscribes and clears the list, so the old outcome must not stand.
|
||||
it('keeps the last settled list rendered while a swapped client re-reads', async () => {
|
||||
// A manual-retry reconnect swaps the client without moving the identity; the
|
||||
// old outcome must not stand ('loading', not 'ready'), but the cached
|
||||
// transcript keeps rendering instead of collapsing to a full-screen spinner.
|
||||
const subscribe: RpcClient['subscribe'] = vi.fn((_method, _params, onData) => {
|
||||
onData({ type: 'snapshot', messages: [message('a-1')], hasMore: false })
|
||||
return () => {}
|
||||
@@ -538,13 +548,61 @@ describe('useMobileNativeChatSession transcriptLoading', () => {
|
||||
await mountAt(client, 'session-a')
|
||||
expect(renders.at(-1)).toMatchObject({ status: 'ready' })
|
||||
|
||||
const reconnected = { subscribe: vi.fn(() => () => {}) } as unknown as RpcClient
|
||||
let emitFresh: (frame: unknown) => void = () => {}
|
||||
const reconnected = {
|
||||
subscribe: vi.fn((_method: string, _params: unknown, onData: (frame: unknown) => void) => {
|
||||
emitFresh = onData
|
||||
return () => {}
|
||||
})
|
||||
} as unknown as RpcClient
|
||||
renders.length = 0
|
||||
await act(async () =>
|
||||
renderer?.update(createElement(Harness, { client: reconnected, sessionId: 'session-a' }))
|
||||
)
|
||||
|
||||
expect(renders[0]).toMatchObject({ status: 'loading', transcriptLoading: true, ids: [] })
|
||||
expect(renders[0]).toMatchObject({
|
||||
status: 'loading',
|
||||
transcriptLoading: true,
|
||||
ids: ['a-1']
|
||||
})
|
||||
// Every commit of the window, not just the first: the re-subscribe lands a
|
||||
// commit after it, so clearing the cache there blanks the transcript the
|
||||
// user actually sees while leaving a first-frame assertion green.
|
||||
expect([...new Set(renders.map((entry) => entry.ids.join(',')))]).toEqual(['a-1'])
|
||||
|
||||
// The fresh client's snapshot supersedes the held list.
|
||||
await act(async () =>
|
||||
emitFresh({ type: 'snapshot', messages: [message('a-1'), message('a-2')], hasMore: false })
|
||||
)
|
||||
expect(renders.at(-1)).toMatchObject({
|
||||
status: 'ready',
|
||||
transcriptLoading: false,
|
||||
ids: ['a-1', 'a-2']
|
||||
})
|
||||
})
|
||||
|
||||
it('never holds a cached list across a host/workspace source change', async () => {
|
||||
const firstClient = {
|
||||
subscribe: vi.fn((_method: string, _params: unknown, onData: (frame: unknown) => void) => {
|
||||
onData({ type: 'snapshot', messages: [message('source-a')], hasMore: false })
|
||||
return () => {}
|
||||
})
|
||||
} as unknown as RpcClient
|
||||
await mountAt(firstClient, 'session-a')
|
||||
|
||||
const secondClient = { subscribe: vi.fn(() => () => {}) } as unknown as RpcClient
|
||||
renders.length = 0
|
||||
await act(async () =>
|
||||
renderer?.update(
|
||||
createElement(Harness, {
|
||||
client: secondClient,
|
||||
sessionId: 'session-a',
|
||||
sourceIdentity: 'host-b\0workspace-b'
|
||||
})
|
||||
)
|
||||
)
|
||||
|
||||
expect(renders[0]).toMatchObject({ status: 'loading', ids: [] })
|
||||
})
|
||||
|
||||
it('never hands out the previous session’s messages under the new session id', async () => {
|
||||
|
||||
@@ -1,4 +1,8 @@
|
||||
import { useCallback, useEffect, useRef, useState } from 'react'
|
||||
import {
|
||||
createNativeChatTranscriptRetention,
|
||||
encodeNativeChatTranscriptIdentity
|
||||
} from '../../../src/shared/native-chat-transcript-retention'
|
||||
import type { NativeChatMessage } from '../../../src/shared/native-chat-types'
|
||||
import { buildNativeChatSubscriptionId } from '../../../src/shared/native-chat-stream-unsubscribe'
|
||||
import type { RpcClient } from '../transport/rpc-client'
|
||||
@@ -28,9 +32,6 @@ export type MobileNativeChatSession = {
|
||||
loadEarlier: () => void
|
||||
}
|
||||
|
||||
// Stable empty reference so a not-yet-current read doesn't churn consumers.
|
||||
const EMPTY_MESSAGES: NativeChatMessage[] = []
|
||||
|
||||
// Small first page for a fast first paint; grows by a page as the user scrolls.
|
||||
const INITIAL_LIMIT = 40
|
||||
const PAGE = 60
|
||||
@@ -45,13 +46,20 @@ type ReadSessionResult =
|
||||
* an ordered tail); live appends merge by id so order stays stable. */
|
||||
export function useMobileNativeChatSession(args: {
|
||||
client: RpcClient | null
|
||||
/** Stable host/workspace source; unlike `client`, it survives manual reconnect. */
|
||||
sourceIdentity: string
|
||||
agent: string | null
|
||||
sessionId: string | null
|
||||
transcriptPath: string | null
|
||||
}): MobileNativeChatSession {
|
||||
const { client, agent, sessionId, transcriptPath } = args
|
||||
const { client, sourceIdentity, agent, sessionId, transcriptPath } = args
|
||||
const [messages, setMessages] = useState<NativeChatMessage[]>([])
|
||||
const identity = `${agent ?? ''}\0${sessionId ?? ''}\0${transcriptPath ?? ''}`
|
||||
const identity = encodeNativeChatTranscriptIdentity([
|
||||
sourceIdentity,
|
||||
agent,
|
||||
sessionId,
|
||||
transcriptPath
|
||||
])
|
||||
// Pre-read status is a pure function of the props, so derive it rather than
|
||||
// letting the effect write it a commit later.
|
||||
const initialStatus: MobileNativeChatStatus =
|
||||
@@ -95,6 +103,13 @@ export function useMobileNativeChatSession(args: {
|
||||
// Whether this subscription already delivered its base snapshot; later
|
||||
// snapshots on the same subscription are reconnect replays, not fresh bases.
|
||||
const snapshotSeenRef = useRef(false)
|
||||
const transcriptRetentionRef = useRef(createNativeChatTranscriptRetention())
|
||||
const settledReady = settled?.status === 'ready'
|
||||
useEffect(() => {
|
||||
if (settledReady) {
|
||||
transcriptRetentionRef.current.capture(identity, messages)
|
||||
}
|
||||
}, [identity, messages, settledReady])
|
||||
|
||||
// Replace the base list (read results are an ordered tail). Resets the merger
|
||||
// cache so the index is rebuilt once over the new base.
|
||||
@@ -258,10 +273,17 @@ export function useMobileNativeChatSession(args: {
|
||||
})()
|
||||
}, [client, agent, sessionId, transcriptPath, hasMore, setList])
|
||||
|
||||
const visibleMessages = transcriptRetentionRef.current.visible({
|
||||
identity,
|
||||
messages,
|
||||
settled: settledReady,
|
||||
loading: status === 'loading'
|
||||
})
|
||||
|
||||
return {
|
||||
// Withheld until the settled read belongs to this identity: the effect that
|
||||
// clears the previous tab's list is passive, so `messages` lags a commit.
|
||||
messages: settled ? messages : EMPTY_MESSAGES,
|
||||
messages: visibleMessages,
|
||||
status,
|
||||
transcriptLoading: status === 'loading',
|
||||
error,
|
||||
|
||||
@@ -29,11 +29,8 @@ describe('mobile source control status helpers', () => {
|
||||
it('builds sections in the mobile source control order', () => {
|
||||
const sections = buildMobileSourceControlSections(entries)
|
||||
|
||||
expect(sections.map((section) => section.title)).toEqual([
|
||||
'Changes',
|
||||
'Untracked Files',
|
||||
'Staged Changes'
|
||||
])
|
||||
expect(sections.map((section) => section.title)).toEqual(['Changes', 'Staged Changes'])
|
||||
expect(sections[0]?.data.map((entry) => entry.path)).toEqual(['a.ts', 'new.ts'])
|
||||
})
|
||||
|
||||
it('computes actionable path sets', () => {
|
||||
|
||||
@@ -19,7 +19,7 @@ export type MobileSourceControlSection<TEntry extends MobileGitStatusEntry = Mob
|
||||
data: TEntry[]
|
||||
}
|
||||
|
||||
const AREA_ORDER: MobileGitStagingArea[] = ['unstaged', 'untracked', 'staged']
|
||||
const AREA_ORDER: MobileGitStagingArea[] = ['unstaged', 'staged']
|
||||
|
||||
const AREA_TITLES: Record<MobileGitStagingArea, string> = {
|
||||
unstaged: 'Changes',
|
||||
@@ -59,7 +59,13 @@ export function buildMobileSourceControlSections<TEntry extends MobileGitStatusE
|
||||
return AREA_ORDER.map((area) => ({
|
||||
area,
|
||||
title: AREA_TITLES[area],
|
||||
data: entries.filter((entry) => entry.area === area).sort(compareGitStatusEntries)
|
||||
data: entries
|
||||
.filter((entry) =>
|
||||
area === 'unstaged'
|
||||
? entry.area === 'unstaged' || entry.area === 'untracked'
|
||||
: entry.area === area
|
||||
)
|
||||
.sort(compareGitStatusEntries)
|
||||
})).filter((section) => section.data.length > 0)
|
||||
}
|
||||
|
||||
|
||||
@@ -0,0 +1,233 @@
|
||||
import { afterEach, describe, expect, it, vi } from 'vitest'
|
||||
import type { RpcClient } from '../transport/rpc-client'
|
||||
import type { RpcResponse } from '../transport/types'
|
||||
import { revealMobileSourceControlSessionDiff } from './reveal-mobile-source-control-session-diff'
|
||||
|
||||
function success(result: unknown): RpcResponse {
|
||||
return { id: 'rpc-1', ok: true, result, _meta: { runtimeId: 'runtime-1' } }
|
||||
}
|
||||
|
||||
function clientWith(sendRequest: RpcClient['sendRequest']): Pick<RpcClient, 'sendRequest'> {
|
||||
return { sendRequest }
|
||||
}
|
||||
|
||||
function options(sendRequest: RpcClient['sendRequest']) {
|
||||
return {
|
||||
client: clientWith(sendRequest),
|
||||
worktreeId: 'worktree-1',
|
||||
relativePath: 'src/target.ts',
|
||||
tabMode: 'diff' as const,
|
||||
staged: true
|
||||
}
|
||||
}
|
||||
|
||||
afterEach(() => {
|
||||
vi.useRealTimers()
|
||||
})
|
||||
|
||||
describe('revealMobileSourceControlSessionDiff', () => {
|
||||
it('activates the requested file when its diff tab is already open', async () => {
|
||||
const sendRequest = vi
|
||||
.fn<RpcClient['sendRequest']>()
|
||||
.mockResolvedValueOnce(
|
||||
success({
|
||||
activeTabId: 'agent-tab',
|
||||
tabs: [
|
||||
{ id: 'agent-tab', type: 'terminal' },
|
||||
{
|
||||
id: 'other-diff',
|
||||
type: 'file',
|
||||
mode: 'diff',
|
||||
diffSource: 'staged',
|
||||
relativePath: 'src/other.ts'
|
||||
},
|
||||
{
|
||||
id: 'target-diff',
|
||||
type: 'file',
|
||||
mode: 'diff',
|
||||
diffSource: 'staged',
|
||||
relativePath: 'src/target.ts'
|
||||
}
|
||||
]
|
||||
})
|
||||
)
|
||||
.mockResolvedValueOnce(success({ activeTabId: 'target-diff' }))
|
||||
|
||||
await expect(revealMobileSourceControlSessionDiff(options(sendRequest))).resolves.toBe(
|
||||
'revealed'
|
||||
)
|
||||
expect(sendRequest).toHaveBeenNthCalledWith(2, 'session.tabs.activate', {
|
||||
worktree: 'id:worktree-1',
|
||||
tabId: 'target-diff',
|
||||
notifyClients: false,
|
||||
navigation: 'caller',
|
||||
intent: 'user'
|
||||
})
|
||||
})
|
||||
|
||||
it('selects the staged diff when both versions of a file are open', async () => {
|
||||
const sendRequest = vi
|
||||
.fn<RpcClient['sendRequest']>()
|
||||
.mockResolvedValueOnce(
|
||||
success({
|
||||
tabs: [
|
||||
{
|
||||
id: 'unstaged-diff',
|
||||
type: 'file',
|
||||
mode: 'diff',
|
||||
diffSource: 'unstaged',
|
||||
relativePath: 'src/target.ts'
|
||||
},
|
||||
{
|
||||
id: 'staged-diff',
|
||||
type: 'file',
|
||||
mode: 'diff',
|
||||
diffSource: 'staged',
|
||||
relativePath: 'src/target.ts'
|
||||
}
|
||||
]
|
||||
})
|
||||
)
|
||||
.mockResolvedValueOnce(success({ activeTabId: 'staged-diff' }))
|
||||
|
||||
await expect(revealMobileSourceControlSessionDiff(options(sendRequest))).resolves.toBe(
|
||||
'revealed'
|
||||
)
|
||||
expect(sendRequest).toHaveBeenLastCalledWith(
|
||||
'session.tabs.activate',
|
||||
expect.objectContaining({ tabId: 'staged-diff' })
|
||||
)
|
||||
})
|
||||
|
||||
it('waits for the requested diff source instead of activating the other one', async () => {
|
||||
vi.useFakeTimers()
|
||||
const sendRequest = vi
|
||||
.fn<RpcClient['sendRequest']>()
|
||||
.mockResolvedValueOnce(
|
||||
success({
|
||||
tabs: [
|
||||
{
|
||||
id: 'unstaged-diff',
|
||||
type: 'file',
|
||||
mode: 'diff',
|
||||
diffSource: 'unstaged',
|
||||
relativePath: 'src/target.ts'
|
||||
}
|
||||
]
|
||||
})
|
||||
)
|
||||
.mockResolvedValueOnce(
|
||||
success({
|
||||
tabs: [
|
||||
{
|
||||
id: 'staged-diff',
|
||||
type: 'file',
|
||||
mode: 'diff',
|
||||
diffSource: 'staged',
|
||||
relativePath: 'src/target.ts'
|
||||
}
|
||||
]
|
||||
})
|
||||
)
|
||||
.mockResolvedValueOnce(success({ activeTabId: 'staged-diff' }))
|
||||
|
||||
const reveal = revealMobileSourceControlSessionDiff(options(sendRequest))
|
||||
await vi.advanceTimersByTimeAsync(300)
|
||||
|
||||
await expect(reveal).resolves.toBe('revealed')
|
||||
expect(sendRequest).toHaveBeenLastCalledWith(
|
||||
'session.tabs.activate',
|
||||
expect.objectContaining({ tabId: 'staged-diff' })
|
||||
)
|
||||
})
|
||||
|
||||
it('activates a legacy edit tab when opening a diff falls back to files.open', async () => {
|
||||
const sendRequest = vi
|
||||
.fn<RpcClient['sendRequest']>()
|
||||
.mockResolvedValueOnce(
|
||||
success({
|
||||
tabs: [
|
||||
{
|
||||
id: 'stale-diff',
|
||||
type: 'file',
|
||||
mode: 'diff',
|
||||
diffSource: 'staged',
|
||||
relativePath: 'src/target.ts'
|
||||
},
|
||||
{
|
||||
id: 'target-edit',
|
||||
type: 'file',
|
||||
relativePath: 'src/target.ts'
|
||||
}
|
||||
]
|
||||
})
|
||||
)
|
||||
.mockResolvedValueOnce(success({ activeTabId: 'target-edit' }))
|
||||
|
||||
await expect(
|
||||
revealMobileSourceControlSessionDiff({ ...options(sendRequest), tabMode: 'edit' })
|
||||
).resolves.toBe('revealed')
|
||||
expect(sendRequest).toHaveBeenLastCalledWith(
|
||||
'session.tabs.activate',
|
||||
expect.objectContaining({ tabId: 'target-edit' })
|
||||
)
|
||||
})
|
||||
|
||||
it('retries until the opened diff appears in the session snapshot', async () => {
|
||||
vi.useFakeTimers()
|
||||
const sendRequest = vi
|
||||
.fn<RpcClient['sendRequest']>()
|
||||
.mockResolvedValueOnce(success({ tabs: [{ id: 'agent-tab', type: 'terminal' }] }))
|
||||
.mockResolvedValueOnce(
|
||||
success({
|
||||
tabs: [
|
||||
{
|
||||
id: 'target-diff',
|
||||
type: 'file',
|
||||
mode: 'diff',
|
||||
diffSource: 'staged',
|
||||
relativePath: 'src/target.ts'
|
||||
}
|
||||
]
|
||||
})
|
||||
)
|
||||
.mockResolvedValueOnce(success({ activeTabId: 'target-diff' }))
|
||||
|
||||
const reveal = revealMobileSourceControlSessionDiff(options(sendRequest))
|
||||
await vi.advanceTimersByTimeAsync(300)
|
||||
|
||||
await expect(reveal).resolves.toBe('revealed')
|
||||
expect(sendRequest).toHaveBeenCalledTimes(3)
|
||||
})
|
||||
|
||||
it('delegates to the mounted session when the dock callback is available', async () => {
|
||||
const sendRequest = vi.fn<RpcClient['sendRequest']>()
|
||||
const onOpenedFileDiff = vi.fn()
|
||||
|
||||
await expect(
|
||||
revealMobileSourceControlSessionDiff({
|
||||
...options(sendRequest),
|
||||
onOpenedFileDiff
|
||||
})
|
||||
).resolves.toBe('revealed')
|
||||
|
||||
expect(onOpenedFileDiff).toHaveBeenCalledWith('src/target.ts')
|
||||
expect(sendRequest).not.toHaveBeenCalled()
|
||||
})
|
||||
|
||||
it('cancels route-owned polling after the source-control screen unmounts', async () => {
|
||||
let current = true
|
||||
const sendRequest = vi.fn<RpcClient['sendRequest']>().mockImplementation(async () => {
|
||||
current = false
|
||||
return success({ tabs: [] })
|
||||
})
|
||||
|
||||
await expect(
|
||||
revealMobileSourceControlSessionDiff({
|
||||
...options(sendRequest),
|
||||
isCurrent: () => current
|
||||
})
|
||||
).resolves.toBe('cancelled')
|
||||
expect(sendRequest).toHaveBeenCalledOnce()
|
||||
})
|
||||
})
|
||||
@@ -0,0 +1,143 @@
|
||||
import type { RpcClient } from '../transport/rpc-client'
|
||||
import { activateMobileSessionTab } from '../session/mobile-session-tab-activation'
|
||||
|
||||
type ActivationClient = Pick<RpcClient, 'sendRequest'>
|
||||
|
||||
type SessionFileTabCandidate = {
|
||||
id: string
|
||||
type: string
|
||||
mode?: unknown
|
||||
relativePath?: unknown
|
||||
diffSource?: unknown
|
||||
}
|
||||
|
||||
type Options = {
|
||||
client: ActivationClient
|
||||
worktreeId: string
|
||||
relativePath: string
|
||||
tabMode: 'diff' | 'edit'
|
||||
staged: boolean
|
||||
onOpenedFileDiff?: (relativePath: string) => void
|
||||
isCurrent?: () => boolean
|
||||
}
|
||||
|
||||
export type MobileSourceControlSessionDiffRevealResult = 'revealed' | 'cancelled' | 'timeout'
|
||||
|
||||
const TAB_POLL_DELAYS_MS = [0, 300, 600, 900] as const
|
||||
|
||||
export async function revealMobileSourceControlSessionDiff(
|
||||
options: Options
|
||||
): Promise<MobileSourceControlSessionDiffRevealResult> {
|
||||
if (options.onOpenedFileDiff) {
|
||||
options.onOpenedFileDiff(options.relativePath)
|
||||
return 'revealed'
|
||||
}
|
||||
|
||||
for (const delayMs of TAB_POLL_DELAYS_MS) {
|
||||
await waitForDelay(delayMs)
|
||||
if (options.isCurrent?.() === false) {
|
||||
return 'cancelled'
|
||||
}
|
||||
|
||||
const tab = await findOpenedSessionFileTab(options)
|
||||
if (options.isCurrent?.() === false) {
|
||||
return 'cancelled'
|
||||
}
|
||||
if (!tab) {
|
||||
continue
|
||||
}
|
||||
|
||||
const activated = await activateSessionFileTab(options, tab.id)
|
||||
if (options.isCurrent?.() === false) {
|
||||
return 'cancelled'
|
||||
}
|
||||
if (activated) {
|
||||
return 'revealed'
|
||||
}
|
||||
}
|
||||
|
||||
return 'timeout'
|
||||
}
|
||||
|
||||
async function findOpenedSessionFileTab(options: Options): Promise<SessionFileTabCandidate | null> {
|
||||
try {
|
||||
const response = await options.client.sendRequest('session.tabs.list', {
|
||||
worktree: `id:${options.worktreeId}`
|
||||
})
|
||||
if (!response.ok) {
|
||||
return null
|
||||
}
|
||||
const snapshot = readTabSnapshot(response.result)
|
||||
if (!snapshot) {
|
||||
return null
|
||||
}
|
||||
|
||||
const matches = snapshot.tabs.filter(
|
||||
(tab) =>
|
||||
tab.type !== 'browser' &&
|
||||
tab.type !== 'terminal' &&
|
||||
matchesTabMode(tab.mode, options.tabMode) &&
|
||||
tab.relativePath === options.relativePath
|
||||
)
|
||||
if (options.tabMode === 'edit') {
|
||||
return matches[0] ?? null
|
||||
}
|
||||
const source = options.staged ? 'staged' : 'unstaged'
|
||||
return (
|
||||
matches.find((tab) => tab.diffSource === source) ??
|
||||
matches.find((tab) => tab.diffSource == null) ??
|
||||
null
|
||||
)
|
||||
} catch {
|
||||
return null
|
||||
}
|
||||
}
|
||||
|
||||
function matchesTabMode(mode: unknown, expected: 'diff' | 'edit'): boolean {
|
||||
return expected === 'diff' ? mode === 'diff' : mode === 'edit' || mode == null
|
||||
}
|
||||
|
||||
async function activateSessionFileTab(options: Options, tabId: string): Promise<boolean> {
|
||||
try {
|
||||
const response = await activateMobileSessionTab(options.client, {
|
||||
worktree: `id:${options.worktreeId}`,
|
||||
tabId,
|
||||
notifyClients: false,
|
||||
navigation: 'caller',
|
||||
intent: 'user'
|
||||
})
|
||||
return response.ok && readActiveTabId(response.result) === tabId
|
||||
} catch {
|
||||
return false
|
||||
}
|
||||
}
|
||||
|
||||
function readTabSnapshot(value: unknown): { tabs: SessionFileTabCandidate[] } | null {
|
||||
if (
|
||||
!isRecord(value) ||
|
||||
!Array.isArray(value.tabs) ||
|
||||
!value.tabs.every(isSessionFileTabCandidate)
|
||||
) {
|
||||
return null
|
||||
}
|
||||
return { tabs: value.tabs }
|
||||
}
|
||||
|
||||
function isSessionFileTabCandidate(value: unknown): value is SessionFileTabCandidate {
|
||||
return isRecord(value) && typeof value.id === 'string' && typeof value.type === 'string'
|
||||
}
|
||||
|
||||
function readActiveTabId(value: unknown): string | null {
|
||||
return isRecord(value) && typeof value.activeTabId === 'string' ? value.activeTabId : null
|
||||
}
|
||||
|
||||
function isRecord(value: unknown): value is Record<string, unknown> {
|
||||
return typeof value === 'object' && value !== null
|
||||
}
|
||||
|
||||
async function waitForDelay(delayMs: number): Promise<void> {
|
||||
if (delayMs === 0) {
|
||||
return
|
||||
}
|
||||
await new Promise<void>((resolve) => setTimeout(resolve, delayMs))
|
||||
}
|
||||
@@ -18,6 +18,7 @@ import {
|
||||
type MobileGitStatusEntry
|
||||
} from './mobile-git-status'
|
||||
import { buildMobileReviewFileRoute } from './mobile-review-route'
|
||||
import { revealMobileSourceControlSessionDiff } from './reveal-mobile-source-control-session-diff'
|
||||
import type {
|
||||
GitDiffTextResult,
|
||||
MobileBranchCompareState,
|
||||
@@ -115,11 +116,13 @@ export function useMobileSourceControlOpeners(params: Params) {
|
||||
relativePath: entry.path,
|
||||
staged: entry.area === 'staged'
|
||||
})
|
||||
let openedTabMode: 'diff' | 'edit' = 'diff'
|
||||
if (!response.ok && isMobileGitUnavailable(response.error?.code, response.error?.message)) {
|
||||
response = await client.sendRequest('files.open', {
|
||||
worktree: `id:${worktreeId}`,
|
||||
relativePath: entry.path
|
||||
})
|
||||
openedTabMode = 'edit'
|
||||
}
|
||||
if (!response.ok) {
|
||||
throw new Error(response.error?.message || 'Unable to open diff')
|
||||
@@ -127,8 +130,22 @@ export function useMobileSourceControlOpeners(params: Params) {
|
||||
if (!mountedRef.current) {
|
||||
return
|
||||
}
|
||||
const revealResult = await revealMobileSourceControlSessionDiff({
|
||||
client,
|
||||
worktreeId,
|
||||
relativePath: entry.path,
|
||||
tabMode: openedTabMode,
|
||||
staged: entry.area === 'staged',
|
||||
onOpenedFileDiff,
|
||||
isCurrent: () => mountedRef.current && openingPathRef.current === entry.path
|
||||
})
|
||||
if (revealResult === 'cancelled') {
|
||||
return
|
||||
}
|
||||
if (revealResult === 'timeout') {
|
||||
throw new Error("The file opened, but its tab isn't ready yet. Try again.")
|
||||
}
|
||||
triggerSelection()
|
||||
onOpenedFileDiff?.(entry.path)
|
||||
// Why: when launched from the session screen, opening a file dismisses
|
||||
// this surface back to the session. In embedded mode there is nothing
|
||||
// to pop (the panel docks beside the terminal), so close the dock
|
||||
|
||||
@@ -32,8 +32,9 @@ describe('mobile task navigation', () => {
|
||||
it('waits for the expected host commit before navigating to Tasks', () => {
|
||||
const harness = navigationHarness({ index: 0, routes: [{ name: 'index' }] })
|
||||
const push = vi.fn()
|
||||
const replace = vi.fn()
|
||||
|
||||
navigateToMobileTasks(harness.navigation, { push }, 'host/1')
|
||||
navigateToMobileTasks(harness.navigation, { push, replace }, 'host/1')
|
||||
|
||||
expect(harness.navigation.addListener.mock.invocationCallOrder[0]).toBeLessThan(
|
||||
push.mock.invocationCallOrder[0]!
|
||||
@@ -46,38 +47,25 @@ describe('mobile task navigation', () => {
|
||||
routes: [{ name: 'index' }, { name: 'h', params: { hostId: 'host/1' } }]
|
||||
})
|
||||
expect(harness.navigation.dispatch).not.toHaveBeenCalled()
|
||||
|
||||
harness.setState({
|
||||
index: 1,
|
||||
routes: [
|
||||
{ name: 'index' },
|
||||
{
|
||||
name: 'h',
|
||||
state: {
|
||||
key: '/h',
|
||||
index: 0,
|
||||
routes: [{ key: 'host-index', name: '[hostId]/index', params: { hostId: 'host/1' } }]
|
||||
}
|
||||
}
|
||||
]
|
||||
})
|
||||
|
||||
expect(harness.unsubscribeState).toHaveBeenCalledOnce()
|
||||
expect(harness.unsubscribeState.mock.invocationCallOrder[0]).toBeLessThan(
|
||||
harness.navigation.dispatch.mock.invocationCallOrder[0]!
|
||||
replace.mock.invocationCallOrder[0]!
|
||||
)
|
||||
expect(harness.navigation.dispatch).toHaveBeenCalledWith({
|
||||
type: 'REPLACE',
|
||||
target: '/h',
|
||||
source: 'host-index',
|
||||
payload: { name: '[hostId]/tasks', params: { hostId: 'host/1' } }
|
||||
expect(replace).toHaveBeenCalledWith({
|
||||
pathname: '/h/[hostId]/tasks',
|
||||
params: { hostId: 'host/1' }
|
||||
})
|
||||
})
|
||||
|
||||
it('ignores unrelated state events and preserves the provider', () => {
|
||||
const harness = navigationHarness({ index: 0, routes: [{ name: 'index' }] })
|
||||
|
||||
navigateToMobileTasks(harness.navigation, { push: vi.fn() }, 'host-1', 'linear')
|
||||
navigateToMobileTasks(
|
||||
harness.navigation,
|
||||
{ push: vi.fn(), replace: vi.fn() },
|
||||
'host-1',
|
||||
'linear'
|
||||
)
|
||||
harness.setState({ index: 1, routes: [{ name: 'index' }, { name: 'settings' }] })
|
||||
expect(harness.navigation.dispatch).not.toHaveBeenCalled()
|
||||
harness.setState({ index: 0, routes: [{ name: 'h', params: { hostId: 'host-2' } }] })
|
||||
@@ -108,30 +96,37 @@ describe('mobile task navigation', () => {
|
||||
|
||||
it('cleanup prevents a stale navigation from replacing', () => {
|
||||
const harness = navigationHarness({ index: 0, routes: [{ name: 'index' }] })
|
||||
const controller = navigateToMobileTasks(harness.navigation, { push: vi.fn() }, 'host-1')
|
||||
const replace = vi.fn()
|
||||
const controller = navigateToMobileTasks(
|
||||
harness.navigation,
|
||||
{ push: vi.fn(), replace },
|
||||
'host-1'
|
||||
)
|
||||
|
||||
controller.cancel()
|
||||
harness.setState({ index: 0, routes: [{ name: 'h', params: { hostId: 'host-1' } }] })
|
||||
|
||||
expect(harness.unsubscribeState).toHaveBeenCalledOnce()
|
||||
expect(harness.navigation.dispatch).not.toHaveBeenCalled()
|
||||
expect(replace).not.toHaveBeenCalled()
|
||||
})
|
||||
|
||||
it('reuses a pending host push and applies the latest provider', () => {
|
||||
const harness = navigationHarness({ index: 0, routes: [{ name: 'index' }] })
|
||||
const push = vi.fn()
|
||||
const router = { push, replace: vi.fn() }
|
||||
|
||||
const first = coordinateMobileTasksNavigation(
|
||||
null,
|
||||
harness.navigation,
|
||||
{ push },
|
||||
router,
|
||||
'host-1',
|
||||
'github'
|
||||
)
|
||||
const second = coordinateMobileTasksNavigation(
|
||||
first,
|
||||
harness.navigation,
|
||||
{ push },
|
||||
router,
|
||||
'host-1',
|
||||
'linear'
|
||||
)
|
||||
@@ -161,11 +156,12 @@ describe('mobile task navigation', () => {
|
||||
)
|
||||
})
|
||||
|
||||
it('keeps waiting through host setup and cleans up when navigation leaves', () => {
|
||||
it('keeps waiting through host setup without params and cleans up when navigation leaves', () => {
|
||||
const harness = navigationHarness({ index: 0, routes: [{ name: 'index' }] })
|
||||
const replace = vi.fn()
|
||||
|
||||
navigateToMobileTasks(harness.navigation, { push: vi.fn() }, 'host-1')
|
||||
harness.setState({ index: 0, routes: [{ name: 'h', params: { hostId: 'host-1' } }] })
|
||||
navigateToMobileTasks(harness.navigation, { push: vi.fn(), replace }, 'host-1')
|
||||
harness.setState({ index: 0, routes: [{ name: 'h' }] })
|
||||
expect(harness.unsubscribeState).not.toHaveBeenCalled()
|
||||
harness.setState({ index: 0, routes: [{ name: 'index' }] })
|
||||
harness.setState({
|
||||
@@ -184,6 +180,7 @@ describe('mobile task navigation', () => {
|
||||
|
||||
expect(harness.unsubscribeState).toHaveBeenCalledOnce()
|
||||
expect(harness.navigation.dispatch).not.toHaveBeenCalled()
|
||||
expect(replace).not.toHaveBeenCalled()
|
||||
})
|
||||
|
||||
it('unsubscribes when mounting the host throws synchronously', () => {
|
||||
@@ -196,7 +193,8 @@ describe('mobile task navigation', () => {
|
||||
{
|
||||
push: () => {
|
||||
throw error
|
||||
}
|
||||
},
|
||||
replace: vi.fn()
|
||||
},
|
||||
'host-1'
|
||||
)
|
||||
|
||||
@@ -39,6 +39,7 @@ describe('terminal live input affordance', () => {
|
||||
expect(block).toContain('pressed && styles.liveInputFocusTargetPressed')
|
||||
expect(block).toContain('!canSend && styles.liveInputFocusTargetDisabled')
|
||||
expect(block).toContain('showSoftInputOnFocus')
|
||||
expect(block).toContain('liveInputText={liveInputCapture}')
|
||||
expect(sessionRouteSource).toContain('useTerminalLiveInputFocus({')
|
||||
expect(sessionRouteSource).toContain('return resetLiveInputFocus')
|
||||
expect(liveInputFocusSource).toContain('focusTerminalLiveInputTarget(inputRef.current')
|
||||
@@ -52,6 +53,8 @@ describe('terminal live input affordance', () => {
|
||||
|
||||
it('makes the live keyboard target visible instead of status-only chrome', () => {
|
||||
expect(liveInputStatusSource).toContain("'Tap to show keyboard'")
|
||||
expect(liveInputStatusSource).toContain("liveInputText || 'Tap to show keyboard'")
|
||||
expect(liveInputStatusSource).toContain('ellipsizeMode="head"')
|
||||
expect(commandInputStylesSource).toContain('backgroundColor: colors.bgRaised')
|
||||
expect(commandInputStylesSource).toContain('borderWidth: 1')
|
||||
expect(commandInputStylesSource).toContain('liveInputFocusTargetPressed')
|
||||
|
||||
@@ -1,15 +1,16 @@
|
||||
import { describe, expect, it } from 'vitest'
|
||||
import { sendTerminalLiveControlAfterPendingFlush } from './terminal-live-control-send-order'
|
||||
import {
|
||||
cancelTerminalLivePendingFlush,
|
||||
createTerminalLivePendingFlushState,
|
||||
queueTerminalLiveMirrorSend,
|
||||
waitForTerminalLivePendingFlush,
|
||||
type TerminalLivePendingFlushState
|
||||
waitForTerminalLivePendingFlush
|
||||
} from './terminal-live-pending-flush-state'
|
||||
|
||||
describe('terminal live pending flush state', () => {
|
||||
it('Given no in-flight flush When waiting for the barrier Then allows control input', async () => {
|
||||
// Given
|
||||
const state: TerminalLivePendingFlushState = { current: null }
|
||||
const state = createTerminalLivePendingFlushState()
|
||||
|
||||
// When / Then
|
||||
await expect(waitForTerminalLivePendingFlush(state)).resolves.toBe(true)
|
||||
@@ -22,7 +23,8 @@ describe('terminal live pending flush state', () => {
|
||||
const flushPromise = new Promise<boolean>((resolve) => {
|
||||
resolveFlush = resolve
|
||||
})
|
||||
const state: TerminalLivePendingFlushState = { current: flushPromise }
|
||||
const state = createTerminalLivePendingFlushState()
|
||||
state.current = flushPromise
|
||||
|
||||
// When
|
||||
const controlSend = sendTerminalLiveControlAfterPendingFlush(
|
||||
@@ -48,7 +50,8 @@ describe('terminal live pending flush state', () => {
|
||||
const flushPromise = new Promise<boolean>((resolve) => {
|
||||
resolveFlush = resolve
|
||||
})
|
||||
const state: TerminalLivePendingFlushState = { current: flushPromise }
|
||||
const state = createTerminalLivePendingFlushState()
|
||||
state.current = flushPromise
|
||||
|
||||
// When
|
||||
const controlSend = sendTerminalLiveControlAfterPendingFlush(
|
||||
@@ -67,17 +70,45 @@ describe('terminal live pending flush state', () => {
|
||||
})
|
||||
|
||||
describe('terminal live mirror send queue', () => {
|
||||
it('Given high RTT When more input queues Then pending bytes share one follow-up send', async () => {
|
||||
// Given
|
||||
const state = createTerminalLivePendingFlushState()
|
||||
const payloads: string[] = []
|
||||
let resolveFirstSend: (value: boolean) => void = () => {}
|
||||
const sender = async (_handle: string, payload: string): Promise<boolean> => {
|
||||
payloads.push(payload)
|
||||
if (payloads.length === 1) {
|
||||
return new Promise<boolean>((resolve) => {
|
||||
resolveFirstSend = resolve
|
||||
})
|
||||
}
|
||||
return true
|
||||
}
|
||||
|
||||
// When
|
||||
const first = queueTerminalLiveMirrorSend(state, 'terminal-1', 'a', sender)
|
||||
const second = queueTerminalLiveMirrorSend(state, 'terminal-1', 'b', sender)
|
||||
const third = queueTerminalLiveMirrorSend(state, 'terminal-1', 'c', sender)
|
||||
await Promise.resolve()
|
||||
|
||||
// Then
|
||||
expect(payloads).toEqual(['a'])
|
||||
resolveFirstSend(true)
|
||||
await expect(Promise.all([first, second, third])).resolves.toEqual([true, true, true])
|
||||
expect(payloads).toEqual(['a', 'bc'])
|
||||
})
|
||||
|
||||
it('Given a failed previous send When a mirror send queues Then it still runs in order', async () => {
|
||||
// Given
|
||||
const state: TerminalLivePendingFlushState = { current: null }
|
||||
const state = createTerminalLivePendingFlushState()
|
||||
const order: string[] = []
|
||||
const first = queueTerminalLiveMirrorSend(state, async () => {
|
||||
const first = queueTerminalLiveMirrorSend(state, 'terminal-1', 'first', async () => {
|
||||
order.push('first')
|
||||
return false
|
||||
})
|
||||
|
||||
// When
|
||||
const second = queueTerminalLiveMirrorSend(state, async () => {
|
||||
const second = queueTerminalLiveMirrorSend(state, 'terminal-1', 'second', async () => {
|
||||
order.push('second')
|
||||
return true
|
||||
})
|
||||
@@ -90,13 +121,13 @@ describe('terminal live mirror send queue', () => {
|
||||
|
||||
it('Given a throwing send When a mirror send queues Then the promise resolves false and the chain continues', async () => {
|
||||
// Given
|
||||
const state: TerminalLivePendingFlushState = { current: null }
|
||||
const first = queueTerminalLiveMirrorSend(state, async () => {
|
||||
const state = createTerminalLivePendingFlushState()
|
||||
const first = queueTerminalLiveMirrorSend(state, 'terminal-1', 'first', async () => {
|
||||
throw new Error('boom')
|
||||
})
|
||||
|
||||
// When
|
||||
const second = queueTerminalLiveMirrorSend(state, async () => true)
|
||||
const second = queueTerminalLiveMirrorSend(state, 'terminal-1', 'second', async () => true)
|
||||
|
||||
// Then
|
||||
await expect(first).resolves.toBe(false)
|
||||
@@ -105,13 +136,33 @@ describe('terminal live mirror send queue', () => {
|
||||
|
||||
it('Given a settled mirror send When it was the newest Then the state resets to null', async () => {
|
||||
// Given
|
||||
const state: TerminalLivePendingFlushState = { current: null }
|
||||
const state = createTerminalLivePendingFlushState()
|
||||
|
||||
// When
|
||||
await queueTerminalLiveMirrorSend(state, async () => true)
|
||||
await queueTerminalLiveMirrorSend(state, 'terminal-1', 'payload', async () => true)
|
||||
await Promise.resolve()
|
||||
|
||||
// Then
|
||||
expect(state.current).toBeNull()
|
||||
})
|
||||
|
||||
it('Given queued input When the queue is cancelled Then unsent input is dropped', async () => {
|
||||
// Given
|
||||
const state = createTerminalLivePendingFlushState()
|
||||
let resolveSend: (value: boolean) => void = () => {}
|
||||
const sender = async (): Promise<boolean> =>
|
||||
new Promise((resolve) => {
|
||||
resolveSend = resolve
|
||||
})
|
||||
const active = queueTerminalLiveMirrorSend(state, 'terminal-1', 'a', sender)
|
||||
const pending = queueTerminalLiveMirrorSend(state, 'terminal-1', 'b', sender)
|
||||
|
||||
// When
|
||||
cancelTerminalLivePendingFlush(state)
|
||||
|
||||
// Then
|
||||
await expect(Promise.all([active, pending])).resolves.toEqual([false, false])
|
||||
expect(state.current).toBeNull()
|
||||
resolveSend(true)
|
||||
})
|
||||
})
|
||||
|
||||
@@ -1,5 +1,30 @@
|
||||
type TerminalLiveMirrorSender = (handle: string, payload: string) => Promise<boolean>
|
||||
|
||||
type TerminalLivePendingRequest = {
|
||||
readonly resolve: (sent: boolean) => void
|
||||
}
|
||||
|
||||
type TerminalLivePendingBatch = {
|
||||
readonly handle: string
|
||||
payload: string
|
||||
readonly requests: TerminalLivePendingRequest[]
|
||||
readonly sender: TerminalLiveMirrorSender
|
||||
}
|
||||
|
||||
export type TerminalLivePendingFlushState = {
|
||||
current: Promise<boolean> | null
|
||||
activeRequests: TerminalLivePendingRequest[]
|
||||
generation: number
|
||||
pendingBatches: TerminalLivePendingBatch[]
|
||||
}
|
||||
|
||||
export function createTerminalLivePendingFlushState(): TerminalLivePendingFlushState {
|
||||
return {
|
||||
current: null,
|
||||
activeRequests: [],
|
||||
generation: 0,
|
||||
pendingBatches: []
|
||||
}
|
||||
}
|
||||
|
||||
export function waitForTerminalLivePendingFlush(
|
||||
@@ -8,26 +33,76 @@ export function waitForTerminalLivePendingFlush(
|
||||
return state.current ?? Promise.resolve(true)
|
||||
}
|
||||
|
||||
// Why: mirror payloads are erase/append deltas against the PTY echo. A skipped
|
||||
// delta desyncs every later diff, so this chain runs each send even when the
|
||||
// previous one failed. state.current should never reject; the catch keeps a
|
||||
// future raw assignment from skipping a delta.
|
||||
export function cancelTerminalLivePendingFlush(state: TerminalLivePendingFlushState): void {
|
||||
state.generation += 1
|
||||
const requests = [
|
||||
...state.activeRequests,
|
||||
...state.pendingBatches.flatMap((batch) => batch.requests)
|
||||
]
|
||||
state.activeRequests = []
|
||||
state.pendingBatches = []
|
||||
state.current = null
|
||||
requests.forEach(({ resolve }) => resolve(false))
|
||||
}
|
||||
|
||||
async function drainTerminalLiveMirrorSends(
|
||||
state: TerminalLivePendingFlushState,
|
||||
generation: number
|
||||
): Promise<boolean> {
|
||||
let allSent = true
|
||||
while (state.generation === generation) {
|
||||
const batch = state.pendingBatches.shift()
|
||||
if (!batch) {
|
||||
state.current = null
|
||||
return allSent
|
||||
}
|
||||
|
||||
state.activeRequests = batch.requests
|
||||
const sent = await batch.sender(batch.handle, batch.payload).catch(() => false)
|
||||
if (state.generation !== generation) {
|
||||
return false
|
||||
}
|
||||
|
||||
state.activeRequests = []
|
||||
batch.requests.forEach(({ resolve }) => resolve(sent))
|
||||
allSent &&= sent
|
||||
}
|
||||
return false
|
||||
}
|
||||
|
||||
// Mirror deltas are ordered PTY bytes; batching pending bytes avoids one RTT per keystroke.
|
||||
export function queueTerminalLiveMirrorSend(
|
||||
state: TerminalLivePendingFlushState,
|
||||
sendMirrorPayload: () => Promise<boolean>
|
||||
handle: string,
|
||||
payload: string,
|
||||
sender: TerminalLiveMirrorSender
|
||||
): Promise<boolean> {
|
||||
const previousSend = state.current
|
||||
const sendPromise = (async () => {
|
||||
if (previousSend) {
|
||||
await previousSend.catch(() => false)
|
||||
}
|
||||
return sendMirrorPayload()
|
||||
})().catch(() => false)
|
||||
state.current = sendPromise
|
||||
void sendPromise.then(() => {
|
||||
if (state.current === sendPromise) {
|
||||
state.current = null
|
||||
}
|
||||
let resolveRequest: (sent: boolean) => void = () => {}
|
||||
const request = new Promise<boolean>((resolve) => {
|
||||
resolveRequest = resolve
|
||||
})
|
||||
return sendPromise
|
||||
const pendingTail = state.pendingBatches.at(-1)
|
||||
if (pendingTail?.handle === handle && pendingTail.sender === sender) {
|
||||
pendingTail.payload += payload
|
||||
pendingTail.requests.push({ resolve: resolveRequest })
|
||||
} else {
|
||||
state.pendingBatches.push({
|
||||
handle,
|
||||
payload,
|
||||
requests: [{ resolve: resolveRequest }],
|
||||
sender
|
||||
})
|
||||
}
|
||||
|
||||
if (!state.current) {
|
||||
const generation = state.generation
|
||||
const drain = drainTerminalLiveMirrorSends(state, generation).catch(() => false)
|
||||
state.current = drain
|
||||
void drain.then(() => {
|
||||
if (state.current === drain) {
|
||||
state.current = null
|
||||
}
|
||||
})
|
||||
}
|
||||
return request
|
||||
}
|
||||
|
||||
@@ -7,6 +7,8 @@ import {
|
||||
TERMINAL_LIVE_HELD_SYLLABLE_COMMIT_DELAY_MS
|
||||
} from './terminal-live-hangul-mirror'
|
||||
import {
|
||||
cancelTerminalLivePendingFlush,
|
||||
createTerminalLivePendingFlushState,
|
||||
queueTerminalLiveMirrorSend,
|
||||
waitForTerminalLivePendingFlush
|
||||
} from './terminal-live-pending-flush-state'
|
||||
@@ -39,7 +41,7 @@ export function useTerminalLivePendingInputFlush<TTabType extends string>({
|
||||
setLiveInputCapture
|
||||
}: TerminalLivePendingInputFlushOptions<TTabType>): TerminalLivePendingInputFlush {
|
||||
const heldCommitTimerRef = useRef<ReturnType<typeof setTimeout> | null>(null)
|
||||
const pendingLiveInputFlushRef = useRef<Promise<boolean> | null>(null)
|
||||
const pendingLiveInputFlushRef = useRef(createTerminalLivePendingFlushState())
|
||||
const heldLiveInputTextRef = useRef('')
|
||||
const sentLiveInputTextRef = useRef('')
|
||||
const pendingLiveInputHandleRef = useRef<string | null>(null)
|
||||
@@ -56,6 +58,7 @@ export function useTerminalLivePendingInputFlush<TTabType extends string>({
|
||||
|
||||
const resetMirrorState = useCallback(() => {
|
||||
clearHeldCommitTimer()
|
||||
cancelTerminalLivePendingFlush(pendingLiveInputFlushRef.current)
|
||||
heldLiveInputTextRef.current = ''
|
||||
sentLiveInputTextRef.current = ''
|
||||
pendingLiveInputHandleRef.current = null
|
||||
@@ -68,9 +71,15 @@ export function useTerminalLivePendingInputFlush<TTabType extends string>({
|
||||
}, [liveInputRef, resetMirrorState, setLiveInputCapture])
|
||||
|
||||
const waitForPendingLiveInputFlush = useCallback(async (): Promise<boolean> => {
|
||||
return waitForTerminalLivePendingFlush(pendingLiveInputFlushRef)
|
||||
return waitForTerminalLivePendingFlush(pendingLiveInputFlushRef.current)
|
||||
}, [])
|
||||
|
||||
const sendQueuedMirrorPayload = useCallback(
|
||||
(handle: string, payload: string): Promise<boolean> =>
|
||||
sendLiveTerminalInputRef.current(handle, payload),
|
||||
[sendLiveTerminalInputRef]
|
||||
)
|
||||
|
||||
const runMirrorStep = useCallback(
|
||||
async (handle: string, fieldText: string, commitHeld: boolean): Promise<boolean> => {
|
||||
if (
|
||||
@@ -107,8 +116,11 @@ export function useTerminalLivePendingInputFlush<TTabType extends string>({
|
||||
if (payload.length === 0) {
|
||||
return waitForPendingLiveInputFlush()
|
||||
}
|
||||
return queueTerminalLiveMirrorSend(pendingLiveInputFlushRef, () =>
|
||||
sendLiveTerminalInputRef.current(handle, payload)
|
||||
return queueTerminalLiveMirrorSend(
|
||||
pendingLiveInputFlushRef.current,
|
||||
handle,
|
||||
payload,
|
||||
sendQueuedMirrorPayload
|
||||
)
|
||||
},
|
||||
[
|
||||
@@ -117,7 +129,7 @@ export function useTerminalLivePendingInputFlush<TTabType extends string>({
|
||||
clearHeldCommitTimer,
|
||||
liveInputTerminalHandlesRef,
|
||||
resetMirrorState,
|
||||
sendLiveTerminalInputRef,
|
||||
sendQueuedMirrorPayload,
|
||||
waitForPendingLiveInputFlush
|
||||
]
|
||||
)
|
||||
@@ -164,7 +176,7 @@ export function useTerminalLivePendingInputFlush<TTabType extends string>({
|
||||
heldLiveInputTextRef.current = ''
|
||||
sentLiveInputTextRef.current = ''
|
||||
pendingLiveInputHandleRef.current = null
|
||||
pendingLiveInputFlushRef.current = null
|
||||
cancelTerminalLivePendingFlush(pendingLiveInputFlushRef.current)
|
||||
}
|
||||
}, [])
|
||||
|
||||
|
||||
@@ -52,7 +52,7 @@ describe('mobile host edit navigation', () => {
|
||||
const harness = navigationHarness({ index: 0, routes: [{ name: 'index' }] })
|
||||
const push = vi.fn()
|
||||
|
||||
navigateToMobileHostEdit(harness.navigation, { push }, 'host/1')
|
||||
navigateToMobileHostEdit(harness.navigation, { push, replace: vi.fn() }, 'host/1')
|
||||
|
||||
expect(push).toHaveBeenCalledWith(mobileHostEditHostRoute('host/1'))
|
||||
expect(harness.navigation.dispatch).not.toHaveBeenCalled()
|
||||
@@ -75,7 +75,7 @@ describe('mobile host edit navigation', () => {
|
||||
it('does not replace an unrelated host route', () => {
|
||||
const harness = navigationHarness({ index: 0, routes: [{ name: 'index' }] })
|
||||
|
||||
navigateToMobileHostEdit(harness.navigation, { push: vi.fn() }, 'host-1')
|
||||
navigateToMobileHostEdit(harness.navigation, { push: vi.fn(), replace: vi.fn() }, 'host-1')
|
||||
harness.setState(committedHostState('host-2'))
|
||||
|
||||
expect(harness.navigation.dispatch).not.toHaveBeenCalled()
|
||||
@@ -83,7 +83,7 @@ describe('mobile host edit navigation', () => {
|
||||
|
||||
it('disposes itself when navigation leaves the host flow without cancel()', () => {
|
||||
const harness = navigationHarness({ index: 0, routes: [{ name: 'index' }] })
|
||||
navigateToMobileHostEdit(harness.navigation, { push: vi.fn() }, 'host-1')
|
||||
navigateToMobileHostEdit(harness.navigation, { push: vi.fn(), replace: vi.fn() }, 'host-1')
|
||||
|
||||
// Enter the host flow for a different host, then leave it entirely.
|
||||
harness.setState(committedHostState('host-2'))
|
||||
@@ -97,7 +97,11 @@ describe('mobile host edit navigation', () => {
|
||||
|
||||
it('cancels a pending replacement when navigation leaves the host flow', () => {
|
||||
const harness = navigationHarness({ index: 0, routes: [{ name: 'index' }] })
|
||||
const controller = navigateToMobileHostEdit(harness.navigation, { push: vi.fn() }, 'host-1')
|
||||
const controller = navigateToMobileHostEdit(
|
||||
harness.navigation,
|
||||
{ push: vi.fn(), replace: vi.fn() },
|
||||
'host-1'
|
||||
)
|
||||
|
||||
harness.setState(committedHostState('host-2'))
|
||||
controller.cancel()
|
||||
|
||||
@@ -220,4 +220,53 @@ describe('mobile relay pairing recovery', () => {
|
||||
expect(saved.metadata.authorizationMode).toBe('authenticated-direct')
|
||||
expect(deps.updateJournal).toHaveBeenCalledTimes(2)
|
||||
})
|
||||
// Why: a journal stranded by a relay outage used to block every later pairing
|
||||
// with "recovery pending" forever, because recovery only ever deferred.
|
||||
it('abandons a journal once its invite expired and no credential can reconcile', async () => {
|
||||
const saved = journal()
|
||||
const unreachable = client(async () => {
|
||||
throw new Error('relay unreachable')
|
||||
})
|
||||
const deps = {
|
||||
...dependencies({ journal: saved, connectRelay: vi.fn(() => unreachable) }),
|
||||
now: () => saved.metadata.relay.inviteExpiresAt + 10 * 60 * 1000 + 1
|
||||
}
|
||||
|
||||
await expect(recoverMobileRelayPairing(deps)).resolves.toBe('abandoned')
|
||||
expect(deps.clearJournal).toHaveBeenCalledWith(saved.metadata.journalId)
|
||||
})
|
||||
|
||||
it('keeps a just-expired journal so a brief outage cannot discard it', async () => {
|
||||
const saved = journal()
|
||||
const unreachable = client(async () => {
|
||||
throw new Error('relay unreachable')
|
||||
})
|
||||
const deps = {
|
||||
...dependencies({ journal: saved, connectRelay: vi.fn(() => unreachable) }),
|
||||
now: () => saved.metadata.relay.inviteExpiresAt + 1
|
||||
}
|
||||
|
||||
await expect(recoverMobileRelayPairing(deps)).resolves.toBe('deferred')
|
||||
expect(deps.clearJournal).not.toHaveBeenCalled()
|
||||
})
|
||||
|
||||
// Why: a committed install whose local persistence failed is the one case the
|
||||
// journal must survive — it is the only record left to retry the write from.
|
||||
it('keeps a journal when the server committed but the local write failed', async () => {
|
||||
const saved = journal()
|
||||
const directInstalled = installed(saved, 'authenticated-direct')
|
||||
const committed = client(async () =>
|
||||
response(endpoints(saved, { state: 'committed', result: directInstalled }))
|
||||
)
|
||||
const deps = {
|
||||
...dependencies({ journal: saved, connectRelay: vi.fn(() => committed) }),
|
||||
now: () => saved.metadata.relay.inviteExpiresAt + 10 * 60 * 1000 + 1,
|
||||
writeCredentialBundle: vi.fn(async () => {
|
||||
throw new Error('keychain unavailable')
|
||||
})
|
||||
}
|
||||
|
||||
await expect(recoverMobileRelayPairing(deps)).resolves.toBe('deferred')
|
||||
expect(deps.clearJournal).not.toHaveBeenCalled()
|
||||
})
|
||||
})
|
||||
|
||||
@@ -27,7 +27,7 @@ import {
|
||||
import { createRecoveringPairingRelayCandidate } from './pairing-relay-candidate'
|
||||
import type { HostProfile, RpcResponse } from './types'
|
||||
|
||||
export type MobileRelayPairingRecoveryResult = 'none' | 'recovered' | 'deferred'
|
||||
export type MobileRelayPairingRecoveryResult = 'none' | 'recovered' | 'deferred' | 'abandoned'
|
||||
|
||||
type RecoveryDependencies = {
|
||||
loadJournal: typeof loadMobileRelayPairingJournal
|
||||
@@ -57,6 +57,10 @@ const defaultDependencies: RecoveryDependencies = {
|
||||
platform: Platform.OS
|
||||
}
|
||||
|
||||
// One full invite lifetime past expiry, so a momentary outage never discards a
|
||||
// journal that a later launch could still reconcile.
|
||||
const ABANDON_GRACE_MS = 10 * 60 * 1000
|
||||
|
||||
let recoveryPromise: Promise<MobileRelayPairingRecoveryResult> | null = null
|
||||
|
||||
export function recoverMobileRelayPairing(
|
||||
@@ -96,6 +100,10 @@ async function runRecovery(
|
||||
}
|
||||
|
||||
const credentials = recoveryCredentials(journal, bundle, dependencies.now())
|
||||
// Why: publishCommitted runs inside the catch below, so a failed local write
|
||||
// of an authoritatively committed install must not look like "nothing to
|
||||
// reconcile" — that journal is the only record left to retry the write from.
|
||||
let observedCommitted = false
|
||||
for (const credential of credentials) {
|
||||
let client: PairingCandidateClient | null = null
|
||||
try {
|
||||
@@ -113,6 +121,7 @@ async function runRecovery(
|
||||
})
|
||||
const endpoints = await getRecoveryStatus(client, journal, credential.kind)
|
||||
if (endpoints.installStatus?.state === 'committed') {
|
||||
observedCommitted = true
|
||||
await publishCommitted(journal, endpoints, dependencies)
|
||||
return 'recovered'
|
||||
}
|
||||
@@ -128,6 +137,7 @@ async function runRecovery(
|
||||
)
|
||||
const reconciled = await getRecoveryStatus(client, journal, 'invite')
|
||||
assertCommitted(reconciled, installed)
|
||||
observedCommitted = true
|
||||
await publishCommitted(journal, reconciled, dependencies)
|
||||
return 'recovered'
|
||||
}
|
||||
@@ -138,6 +148,19 @@ async function runRecovery(
|
||||
client?.close()
|
||||
}
|
||||
}
|
||||
// Why: past invite expiry no credential can still establish what happened, so
|
||||
// retaining the journal cannot reconcile anything — it only fails every later
|
||||
// pairing with "recovery pending" forever. Re-pairing mints a fresh device and
|
||||
// any uncommitted server-side install expires on its own. The extra invite
|
||||
// lifetime of slack keeps a brief relay outage from discarding a journal whose
|
||||
// resume credential would have reconciled it on the next launch.
|
||||
if (
|
||||
!observedCommitted &&
|
||||
journal.metadata.relay.inviteExpiresAt + ABANDON_GRACE_MS <= dependencies.now()
|
||||
) {
|
||||
await dependencies.clearJournal(journal.metadata.journalId).catch(() => {})
|
||||
return 'abandoned'
|
||||
}
|
||||
return 'deferred'
|
||||
}
|
||||
|
||||
|
||||
+1
-1
@@ -1,6 +1,6 @@
|
||||
{
|
||||
"name": "orca",
|
||||
"version": "1.4.169-rc.0",
|
||||
"version": "1.4.174-rc.0",
|
||||
"description": "Next-gen IDE for parallel agentic development",
|
||||
"homepage": "https://github.com/stablyai/orca",
|
||||
"author": "stablyai",
|
||||
|
||||
@@ -161,7 +161,14 @@ export function wrapWindowsGitBashHookCommand(scriptPath: string): string {
|
||||
: wrapWindowsHookCommand(scriptPath)
|
||||
}
|
||||
|
||||
export function buildWindowsAgentHookPostCommand(source: AgentHookSource): string {
|
||||
/**
|
||||
* Extra form lines inserted before the final `payload@-` line (each should end with ` ^`).
|
||||
* Used by Grok to attach `grokHome` without fragile string replace on the shared template.
|
||||
*/
|
||||
export function buildWindowsAgentHookPostCommand(
|
||||
source: AgentHookSource,
|
||||
extraFormLines: readonly string[] = []
|
||||
): string {
|
||||
// Why: PowerShell startup makes inline per-turn Codex hooks visibly slow, so mirror the POSIX curl path.
|
||||
// Why: fully-qualify curl so a repo-local curl.exe can't hijack hook payloads.
|
||||
return [
|
||||
@@ -175,6 +182,7 @@ export function buildWindowsAgentHookPostCommand(source: AgentHookSource): strin
|
||||
' --data-urlencode "worktreeId=%ORCA_WORKTREE_ID%" ^',
|
||||
' --data-urlencode "env=%ORCA_AGENT_HOOK_ENV%" ^',
|
||||
' --data-urlencode "version=%ORCA_AGENT_HOOK_VERSION%" ^',
|
||||
...extraFormLines,
|
||||
' --data-urlencode "payload@-" >nul 2>nul'
|
||||
].join('\r\n')
|
||||
}
|
||||
|
||||
@@ -105,7 +105,6 @@ function createSettings(overrides: TestSettingsOverrides = {}): GlobalSettings {
|
||||
openLinksInAppPreferencePrompted: false,
|
||||
rightSidebarOpenByDefault: true,
|
||||
sourceControlViewMode: 'list',
|
||||
sourceControlGroupOrder: 'changes-first',
|
||||
sourceControlCompareAgainstUpstream: false,
|
||||
showTitlebarAppName: true,
|
||||
showTasksButton: true,
|
||||
|
||||
@@ -97,7 +97,6 @@ function createSettings(overrides: Partial<GlobalSettings> = {}): GlobalSettings
|
||||
openLinksInAppPreferencePrompted: false,
|
||||
rightSidebarOpenByDefault: true,
|
||||
sourceControlViewMode: 'list',
|
||||
sourceControlGroupOrder: 'changes-first',
|
||||
sourceControlCompareAgainstUpstream: false,
|
||||
showTitlebarAppName: true,
|
||||
showTasksButton: true,
|
||||
|
||||
@@ -1,101 +1,3 @@
|
||||
type StatusReadEntry<T> = {
|
||||
controller: AbortController
|
||||
promise: Promise<T>
|
||||
liveLeases: number
|
||||
settled: boolean
|
||||
}
|
||||
|
||||
function getAbortReason(signal: AbortSignal): unknown {
|
||||
try {
|
||||
signal.throwIfAborted()
|
||||
} catch (error) {
|
||||
return error
|
||||
}
|
||||
return new DOMException('This operation was aborted', 'AbortError')
|
||||
}
|
||||
|
||||
export class GitStatusReadLeaseOwner<T> {
|
||||
private readonly entries = new Map<string, StatusReadEntry<T>>()
|
||||
|
||||
lease(
|
||||
key: string,
|
||||
signal: AbortSignal | undefined,
|
||||
load: (sharedSignal: AbortSignal) => Promise<T>
|
||||
): Promise<T> {
|
||||
if (signal?.aborted) {
|
||||
return Promise.reject(getAbortReason(signal))
|
||||
}
|
||||
|
||||
let entry = this.entries.get(key)
|
||||
if (!entry) {
|
||||
const controller = new AbortController()
|
||||
const promise = load(controller.signal)
|
||||
const createdEntry = { controller, promise, liveLeases: 0, settled: false }
|
||||
entry = createdEntry
|
||||
this.entries.set(key, createdEntry)
|
||||
void promise.then(
|
||||
() => this.settle(key, createdEntry),
|
||||
() => this.settle(key, createdEntry)
|
||||
)
|
||||
}
|
||||
|
||||
entry.liveLeases += 1
|
||||
return this.createLease(key, entry, signal)
|
||||
}
|
||||
|
||||
invalidate(): void {
|
||||
this.entries.clear()
|
||||
}
|
||||
|
||||
private createLease(
|
||||
key: string,
|
||||
entry: StatusReadEntry<T>,
|
||||
signal: AbortSignal | undefined
|
||||
): Promise<T> {
|
||||
return new Promise<T>((resolve, reject) => {
|
||||
let active = true
|
||||
const release = (abortReason?: unknown): boolean => {
|
||||
if (!active) {
|
||||
return false
|
||||
}
|
||||
active = false
|
||||
signal?.removeEventListener('abort', onAbort)
|
||||
entry.liveLeases -= 1
|
||||
if (abortReason !== undefined && entry.liveLeases === 0 && !entry.settled) {
|
||||
if (this.entries.get(key) === entry) {
|
||||
this.entries.delete(key)
|
||||
}
|
||||
entry.controller.abort(abortReason)
|
||||
}
|
||||
return true
|
||||
}
|
||||
const onAbort = (): void => {
|
||||
const reason = getAbortReason(signal!)
|
||||
if (release(reason)) {
|
||||
reject(reason)
|
||||
}
|
||||
}
|
||||
|
||||
signal?.addEventListener('abort', onAbort, { once: true })
|
||||
void entry.promise.then(
|
||||
(value) => {
|
||||
if (release()) {
|
||||
resolve(value)
|
||||
}
|
||||
},
|
||||
(error: unknown) => {
|
||||
if (release()) {
|
||||
reject(error)
|
||||
}
|
||||
}
|
||||
)
|
||||
})
|
||||
}
|
||||
|
||||
private settle(key: string, entry: StatusReadEntry<T>): void {
|
||||
entry.settled = true
|
||||
if (this.entries.get(key) === entry) {
|
||||
this.entries.delete(key)
|
||||
}
|
||||
}
|
||||
}
|
||||
// Why: the class moved to src/shared so the relay host can coalesce the same
|
||||
// reads; this re-export keeps main's import path (and its tests) stable.
|
||||
export { GitStatusReadLeaseOwner } from '../../shared/git-status-read-lease-owner'
|
||||
|
||||
@@ -0,0 +1,330 @@
|
||||
import { execFileSync } from 'node:child_process'
|
||||
import { mkdir, mkdtemp, rm, writeFile } from 'node:fs/promises'
|
||||
import { tmpdir } from 'node:os'
|
||||
import * as path from 'node:path'
|
||||
import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest'
|
||||
import {
|
||||
buildGitBranchLineTotalDiffArgs,
|
||||
invalidateGitBranchLineTotalInFlight
|
||||
} from '../../shared/git-branch-line-total'
|
||||
|
||||
import type * as BranchLineTotal from '../../shared/git-branch-line-total'
|
||||
import type * as GitRunner from './runner'
|
||||
|
||||
// Why: real git, spied argv. Every exec is recorded so the performance contract
|
||||
// ("no ranged diff unless asked", "one exec for concurrent callers") can be
|
||||
// asserted on the actual command list rather than on a stubbed return value.
|
||||
const { gitExecCalls, execHooks, coalescerJoins } = vi.hoisted(() => ({
|
||||
gitExecCalls: [] as string[][],
|
||||
execHooks: { beforeExec: undefined as undefined | ((args: string[]) => Promise<void> | void) },
|
||||
coalescerJoins: { count: 0, onJoin: undefined as undefined | (() => void) }
|
||||
}))
|
||||
|
||||
vi.mock('../../shared/git-branch-line-total', async (importOriginal) => {
|
||||
const actual = await importOriginal<typeof BranchLineTotal>()
|
||||
return {
|
||||
...actual,
|
||||
computeGitBranchLineTotal: (
|
||||
input: Parameters<typeof BranchLineTotal.computeGitBranchLineTotal>[0]
|
||||
) => {
|
||||
// The lease is taken synchronously inside, so counting after the call
|
||||
// means a join is observable the instant it has happened.
|
||||
const total = actual.computeGitBranchLineTotal(input)
|
||||
coalescerJoins.count += 1
|
||||
coalescerJoins.onJoin?.()
|
||||
return total
|
||||
}
|
||||
}
|
||||
})
|
||||
|
||||
vi.mock('./runner', async (importOriginal) => {
|
||||
const actual = await importOriginal<typeof GitRunner>()
|
||||
return {
|
||||
...actual,
|
||||
gitExecFileAsync: async (...args: Parameters<typeof GitRunner.gitExecFileAsync>) => {
|
||||
gitExecCalls.push(args[0])
|
||||
await execHooks.beforeExec?.(args[0])
|
||||
return actual.gitExecFileAsync(...args)
|
||||
},
|
||||
gitExecFileAsyncBuffer: async (
|
||||
...args: Parameters<typeof GitRunner.gitExecFileAsyncBuffer>
|
||||
) => {
|
||||
gitExecCalls.push(args[0])
|
||||
return actual.gitExecFileAsyncBuffer(...args)
|
||||
},
|
||||
gitStreamStdout: async (...args: Parameters<typeof GitRunner.gitStreamStdout>) => {
|
||||
gitExecCalls.push(args[0])
|
||||
return actual.gitStreamStdout(...args)
|
||||
}
|
||||
}
|
||||
})
|
||||
|
||||
import {
|
||||
clearEffectiveUpstreamStatusCacheForTests,
|
||||
clearSubmodulePathsCacheForTests,
|
||||
getStatus,
|
||||
invalidateGitReadCaches
|
||||
} from './status'
|
||||
|
||||
const BOGUS_MERGE_BASE = 'deadbeef'.repeat(5)
|
||||
const tempRoots: string[] = []
|
||||
|
||||
function git(repo: string, args: string[]): string {
|
||||
return execFileSync('git', args, {
|
||||
cwd: repo,
|
||||
encoding: 'utf8',
|
||||
stdio: ['ignore', 'pipe', 'pipe']
|
||||
}).trim()
|
||||
}
|
||||
|
||||
async function createFixtureRepo(): Promise<{ repo: string; mergeBase: string }> {
|
||||
const root = await mkdtemp(path.join(tmpdir(), 'orca-branch-line-total-exec-'))
|
||||
tempRoots.push(root)
|
||||
const repo = path.join(root, 'repo')
|
||||
execFileSync('git', ['init', '-q', repo])
|
||||
git(repo, ['config', 'user.email', 'test@example.com'])
|
||||
git(repo, ['config', 'user.name', 'Test User'])
|
||||
git(repo, ['config', 'commit.gpgSign', 'false'])
|
||||
await write(repo, 'f.txt', 'a\nb\n')
|
||||
git(repo, ['add', '-A'])
|
||||
git(repo, ['commit', '-q', '-m', 'base'])
|
||||
await write(repo, 'f.txt', 'a\nb\nc\n')
|
||||
return { repo, mergeBase: git(repo, ['rev-parse', 'HEAD']) }
|
||||
}
|
||||
|
||||
async function write(repo: string, relativePath: string, contents: string): Promise<void> {
|
||||
const target = path.join(repo, relativePath)
|
||||
await mkdir(path.dirname(target), { recursive: true })
|
||||
await writeFile(target, contents)
|
||||
}
|
||||
|
||||
/** The `mergeBase → worktree` diff, distinguished from the per-area numstats by its rev + `--`. */
|
||||
function rangedDiffCalls(): string[][] {
|
||||
return gitExecCalls.filter((args) => args.includes('--numstat') && args.at(-1) === '--')
|
||||
}
|
||||
|
||||
function numstatCalls(): string[][] {
|
||||
return gitExecCalls.filter((args) => args.includes('--numstat'))
|
||||
}
|
||||
|
||||
/** Resolves once `count` status passes have entered the branch-total coalescer. */
|
||||
function waitForCoalescerJoins(count: number): Promise<void> {
|
||||
if (coalescerJoins.count >= count) {
|
||||
return Promise.resolve()
|
||||
}
|
||||
return new Promise((resolve) => {
|
||||
coalescerJoins.onJoin = () => {
|
||||
if (coalescerJoins.count >= count) {
|
||||
coalescerJoins.onJoin = undefined
|
||||
resolve()
|
||||
}
|
||||
}
|
||||
})
|
||||
}
|
||||
|
||||
function resetGitReadCaches(): void {
|
||||
clearEffectiveUpstreamStatusCacheForTests()
|
||||
clearSubmodulePathsCacheForTests()
|
||||
invalidateGitReadCaches()
|
||||
invalidateGitBranchLineTotalInFlight()
|
||||
}
|
||||
|
||||
beforeEach(() => {
|
||||
gitExecCalls.length = 0
|
||||
execHooks.beforeExec = undefined
|
||||
coalescerJoins.count = 0
|
||||
coalescerJoins.onJoin = undefined
|
||||
resetGitReadCaches()
|
||||
})
|
||||
|
||||
afterEach(async () => {
|
||||
execHooks.beforeExec = undefined
|
||||
coalescerJoins.onJoin = undefined
|
||||
resetGitReadCaches()
|
||||
await Promise.all(tempRoots.splice(0).map((root) => rm(root, { recursive: true, force: true })))
|
||||
})
|
||||
|
||||
describe('branch line total completeness', () => {
|
||||
it('omits the total when the status listing hit its limit', async () => {
|
||||
const { repo, mergeBase } = await createFixtureRepo()
|
||||
await write(repo, 'u1.txt', 'a\n')
|
||||
await write(repo, 'u2.txt', 'b\n')
|
||||
await write(repo, 'u3.txt', 'c\n')
|
||||
|
||||
const result = await getStatus(repo, { branchLineTotalMergeBase: mergeBase, limit: 1 })
|
||||
|
||||
expect(result.didHitLimit).toBe(true)
|
||||
expect(result.branchLineTotal).toBeUndefined()
|
||||
expect(rangedDiffCalls()).toEqual([])
|
||||
})
|
||||
|
||||
it('omits the total when the ranged numstat fails, rather than reporting zero', async () => {
|
||||
const { repo } = await createFixtureRepo()
|
||||
|
||||
const result = await getStatus(repo, { branchLineTotalMergeBase: BOGUS_MERGE_BASE })
|
||||
|
||||
expect(result.entries.length).toBeGreaterThan(0)
|
||||
expect(result.branchLineTotal).toBeUndefined()
|
||||
// The diff really was attempted, so this is a failure path and not a gate.
|
||||
expect(rangedDiffCalls()).toHaveLength(1)
|
||||
})
|
||||
|
||||
it('omits the total for a directory that is not a git worktree', async () => {
|
||||
const root = await mkdtemp(path.join(tmpdir(), 'orca-branch-line-total-folder-'))
|
||||
tempRoots.push(root)
|
||||
|
||||
const result = await getStatus(root, { branchLineTotalMergeBase: BOGUS_MERGE_BASE })
|
||||
|
||||
expect(result.branchLineTotal).toBeUndefined()
|
||||
expect(rangedDiffCalls()).toEqual([])
|
||||
})
|
||||
|
||||
it('rejects with an AbortError when the pass is cancelled mid-diff', async () => {
|
||||
const { repo, mergeBase } = await createFixtureRepo()
|
||||
const controller = new AbortController()
|
||||
execHooks.beforeExec = (args) => {
|
||||
if (args.includes('--numstat') && args.at(-1) === '--') {
|
||||
controller.abort()
|
||||
}
|
||||
}
|
||||
|
||||
await expect(
|
||||
getStatus(repo, { branchLineTotalMergeBase: mergeBase, signal: controller.signal })
|
||||
).rejects.toMatchObject({ name: 'AbortError' })
|
||||
// The pass had already streamed status and reached the ranged diff, so this
|
||||
// is a cancellation in flight rather than a pre-flight rejection.
|
||||
expect(rangedDiffCalls()).toHaveLength(1)
|
||||
})
|
||||
})
|
||||
|
||||
describe('branch line total merge-base gate', () => {
|
||||
it('runs no ranged diff and returns no total without a merge base', async () => {
|
||||
const { repo } = await createFixtureRepo()
|
||||
|
||||
const result = await getStatus(repo)
|
||||
|
||||
expect(result.branchLineTotal).toBeUndefined()
|
||||
expect(rangedDiffCalls()).toEqual([])
|
||||
})
|
||||
|
||||
it.each(['--upload-pack=x', 'HEAD', '', 'origin/main', '-M', 'A1B2C3D'])(
|
||||
'never spawns a ranged diff for a merge base of %j',
|
||||
async (branchLineTotalMergeBase) => {
|
||||
const { repo } = await createFixtureRepo()
|
||||
|
||||
const result = await getStatus(repo, { branchLineTotalMergeBase })
|
||||
|
||||
expect(result.branchLineTotal).toBeUndefined()
|
||||
expect(rangedDiffCalls()).toEqual([])
|
||||
expect(gitExecCalls).not.toContainEqual(
|
||||
buildGitBranchLineTotalDiffArgs(branchLineTotalMergeBase)
|
||||
)
|
||||
}
|
||||
)
|
||||
|
||||
it('never leaks a flag-shaped merge base into any git argv', async () => {
|
||||
const { repo } = await createFixtureRepo()
|
||||
|
||||
await getStatus(repo, { branchLineTotalMergeBase: '--upload-pack=/tmp/evil' })
|
||||
|
||||
for (const args of gitExecCalls) {
|
||||
expect(args).not.toContain('--upload-pack=/tmp/evil')
|
||||
}
|
||||
})
|
||||
})
|
||||
|
||||
describe('branch line total exec budget', () => {
|
||||
it('costs exactly one extra exec versus the same pass with the chip hidden', async () => {
|
||||
const { repo, mergeBase } = await createFixtureRepo()
|
||||
|
||||
await getStatus(repo)
|
||||
const withoutChip = gitExecCalls.map((args) => args.join(' '))
|
||||
|
||||
gitExecCalls.length = 0
|
||||
resetGitReadCaches()
|
||||
await getStatus(repo, { branchLineTotalMergeBase: mergeBase })
|
||||
const withChip = gitExecCalls.map((args) => args.join(' '))
|
||||
|
||||
expect(withChip.filter((args) => !args.endsWith(`${mergeBase} --`))).toEqual(withoutChip)
|
||||
expect(withChip).toHaveLength(withoutChip.length + 1)
|
||||
})
|
||||
|
||||
it('shares one ranged diff between two concurrent identical status passes', async () => {
|
||||
const { repo, mergeBase } = await createFixtureRepo()
|
||||
|
||||
const [first, second] = await Promise.all([
|
||||
getStatus(repo, { branchLineTotalMergeBase: mergeBase }),
|
||||
getStatus(repo, { branchLineTotalMergeBase: mergeBase })
|
||||
])
|
||||
|
||||
expect(first.branchLineTotal).toEqual({ added: 1, removed: 0, mergeBase })
|
||||
expect(second.branchLineTotal).toEqual(first.branchLineTotal)
|
||||
expect(rangedDiffCalls()).toHaveLength(1)
|
||||
})
|
||||
|
||||
it('shares one ranged diff across status passes the status lease cannot merge', async () => {
|
||||
const { repo, mergeBase } = await createFixtureRepo()
|
||||
// Why: `limit` is part of the status read key, so these two passes each run
|
||||
// their own `git status`; only the branch-total coalescer can merge the diff.
|
||||
// Hold the first diff exactly until the second pass has taken the lease: a
|
||||
// fixed sleep would either release too early or, on a slow machine, outrun
|
||||
// GIT_BRANCH_LINE_TOTAL_SOFT_DEADLINE_MS and publish without an exact total.
|
||||
execHooks.beforeExec = async (args) => {
|
||||
if (args.includes('--numstat') && args.at(-1) === '--') {
|
||||
await waitForCoalescerJoins(2)
|
||||
}
|
||||
}
|
||||
|
||||
const [first, second] = await Promise.all([
|
||||
getStatus(repo, { branchLineTotalMergeBase: mergeBase, limit: 0 }),
|
||||
getStatus(repo, { branchLineTotalMergeBase: mergeBase, limit: 4096 })
|
||||
])
|
||||
|
||||
expect(first.branchLineTotal).toEqual({ added: 1, removed: 0, mergeBase })
|
||||
expect(second.branchLineTotal).toEqual(first.branchLineTotal)
|
||||
// Proves the saving came from the branch-total coalescer: both passes really
|
||||
// ran their own `git status`, so the status lease merged nothing.
|
||||
expect(gitExecCalls.filter((args) => args.includes('status'))).toHaveLength(2)
|
||||
expect(rangedDiffCalls()).toHaveLength(1)
|
||||
})
|
||||
|
||||
it('reuses the cached total when the pass reuses cached line stats', async () => {
|
||||
const { repo, mergeBase } = await createFixtureRepo()
|
||||
|
||||
const first = await getStatus(repo, {
|
||||
branchLineTotalMergeBase: mergeBase,
|
||||
reuseLineStats: true
|
||||
})
|
||||
expect(rangedDiffCalls()).toHaveLength(1)
|
||||
expect(numstatCalls()).toHaveLength(2)
|
||||
|
||||
gitExecCalls.length = 0
|
||||
invalidateGitBranchLineTotalInFlight()
|
||||
const second = await getStatus(repo, {
|
||||
branchLineTotalMergeBase: mergeBase,
|
||||
reuseLineStats: true
|
||||
})
|
||||
|
||||
expect(second.branchLineTotal).toEqual(first.branchLineTotal)
|
||||
expect(second.branchLineTotal).toEqual({ added: 1, removed: 0, mergeBase })
|
||||
expect(numstatCalls()).toEqual([])
|
||||
})
|
||||
|
||||
it('recomputes rather than reusing a total measured against another merge base', async () => {
|
||||
const { repo, mergeBase } = await createFixtureRepo()
|
||||
git(repo, ['add', '-A'])
|
||||
git(repo, ['commit', '-q', '-m', 'second'])
|
||||
const laterMergeBase = git(repo, ['rev-parse', 'HEAD'])
|
||||
|
||||
await getStatus(repo, { branchLineTotalMergeBase: mergeBase, reuseLineStats: true })
|
||||
gitExecCalls.length = 0
|
||||
invalidateGitBranchLineTotalInFlight()
|
||||
const result = await getStatus(repo, {
|
||||
branchLineTotalMergeBase: laterMergeBase,
|
||||
reuseLineStats: true
|
||||
})
|
||||
|
||||
expect(result.branchLineTotal).toEqual({ added: 0, removed: 0, mergeBase: laterMergeBase })
|
||||
expect(rangedDiffCalls()).toHaveLength(1)
|
||||
})
|
||||
})
|
||||
@@ -0,0 +1,203 @@
|
||||
import { execFileSync } from 'node:child_process'
|
||||
import { mkdir, mkdtemp, rm, writeFile } from 'node:fs/promises'
|
||||
import { tmpdir } from 'node:os'
|
||||
import * as path from 'node:path'
|
||||
import { afterEach, beforeEach, describe, expect, it } from 'vitest'
|
||||
import { invalidateGitBranchLineTotalInFlight } from '../../shared/git-branch-line-total'
|
||||
import { getStatus, invalidateGitReadCaches } from './status'
|
||||
|
||||
const tempRoots: string[] = []
|
||||
|
||||
function git(repo: string, args: string[]): string {
|
||||
return execFileSync('git', args, {
|
||||
cwd: repo,
|
||||
encoding: 'utf8',
|
||||
stdio: ['ignore', 'pipe', 'pipe']
|
||||
}).trim()
|
||||
}
|
||||
|
||||
async function createFixtureRepo(): Promise<string> {
|
||||
const root = await mkdtemp(path.join(tmpdir(), 'orca-branch-line-total-'))
|
||||
tempRoots.push(root)
|
||||
const repo = path.join(root, 'repo')
|
||||
execFileSync('git', ['init', '-q', repo])
|
||||
git(repo, ['config', 'user.email', 'test@example.com'])
|
||||
git(repo, ['config', 'user.name', 'Test User'])
|
||||
git(repo, ['config', 'commit.gpgSign', 'false'])
|
||||
return repo
|
||||
}
|
||||
|
||||
async function write(repo: string, relativePath: string, contents: string | Buffer): Promise<void> {
|
||||
const target = path.join(repo, relativePath)
|
||||
await mkdir(path.dirname(target), { recursive: true })
|
||||
await writeFile(target, contents)
|
||||
}
|
||||
|
||||
/** The forked-from commit the chip measures against, as branch compare would resolve it. */
|
||||
function commitAll(repo: string, message: string): string {
|
||||
git(repo, ['add', '-A'])
|
||||
git(repo, ['commit', '-q', '-m', message])
|
||||
return git(repo, ['rev-parse', 'HEAD'])
|
||||
}
|
||||
|
||||
/** What a plain `git diff <mergeBase>` reports, i.e. the number the chip must not disagree with. */
|
||||
function rangedDiffTotal(repo: string, mergeBase: string): { added: number; removed: number } {
|
||||
let added = 0
|
||||
let removed = 0
|
||||
for (const line of git(repo, ['diff', '--numstat', '-M', mergeBase, '--']).split(/\r?\n/)) {
|
||||
if (!line) {
|
||||
continue
|
||||
}
|
||||
const [rawAdded, rawRemoved] = line.split('\t')
|
||||
added += rawAdded === '-' ? 0 : Number.parseInt(rawAdded ?? '0', 10)
|
||||
removed += rawRemoved === '-' ? 0 : Number.parseInt(rawRemoved ?? '0', 10)
|
||||
}
|
||||
return { added, removed }
|
||||
}
|
||||
|
||||
beforeEach(() => {
|
||||
invalidateGitReadCaches()
|
||||
invalidateGitBranchLineTotalInFlight()
|
||||
})
|
||||
|
||||
afterEach(async () => {
|
||||
invalidateGitReadCaches()
|
||||
invalidateGitBranchLineTotalInFlight()
|
||||
await Promise.all(tempRoots.splice(0).map((root) => rm(root, { recursive: true, force: true })))
|
||||
})
|
||||
|
||||
describe('branch line total against a real repository', () => {
|
||||
it('measures a partially staged line once, not once per status area', async () => {
|
||||
const repo = await createFixtureRepo()
|
||||
await write(repo, 'f.txt', 'line1\n')
|
||||
const mergeBase = commitAll(repo, 'base')
|
||||
// Stage "+foo", then edit that same line to "bar" without staging it: the
|
||||
// per-area rows read +1/-0 staged and +1/-1 unstaged, summing to +2/-1.
|
||||
await write(repo, 'f.txt', 'line1\nfoo\n')
|
||||
git(repo, ['add', 'f.txt'])
|
||||
await write(repo, 'f.txt', 'line1\nbar\n')
|
||||
|
||||
const result = await getStatus(repo, { branchLineTotalMergeBase: mergeBase })
|
||||
|
||||
expect(rangedDiffTotal(repo, mergeBase)).toEqual({ added: 1, removed: 0 })
|
||||
expect(result.branchLineTotal).toEqual({ added: 1, removed: 0, mergeBase })
|
||||
expect(result.entries.map((entry) => [entry.area, entry.added, entry.removed])).toEqual([
|
||||
['staged', 1, 0],
|
||||
['unstaged', 1, 1]
|
||||
])
|
||||
})
|
||||
|
||||
it('nets a line added in a branch commit and deleted in the working tree to zero', async () => {
|
||||
const repo = await createFixtureRepo()
|
||||
await write(repo, 'f.txt', 'a\n')
|
||||
const mergeBase = commitAll(repo, 'base')
|
||||
await write(repo, 'f.txt', 'a\nb\n')
|
||||
commitAll(repo, 'add b')
|
||||
await write(repo, 'f.txt', 'a\n')
|
||||
|
||||
const result = await getStatus(repo, { branchLineTotalMergeBase: mergeBase })
|
||||
|
||||
expect(result.branchLineTotal).toEqual({ added: 0, removed: 0, mergeBase })
|
||||
})
|
||||
|
||||
it('keeps the total across a commit and updates it on the next status call after an edit', async () => {
|
||||
const repo = await createFixtureRepo()
|
||||
await write(repo, 'f.txt', 'a\n')
|
||||
const mergeBase = commitAll(repo, 'base')
|
||||
await write(repo, 'f.txt', 'a\nb\nc\n')
|
||||
|
||||
const beforeCommit = await getStatus(repo, { branchLineTotalMergeBase: mergeBase })
|
||||
expect(beforeCommit.branchLineTotal).toEqual({ added: 2, removed: 0, mergeBase })
|
||||
|
||||
commitAll(repo, 'commit the edit')
|
||||
const afterCommit = await getStatus(repo, { branchLineTotalMergeBase: mergeBase })
|
||||
expect(afterCommit.branchLineTotal).toEqual({ added: 2, removed: 0, mergeBase })
|
||||
|
||||
await write(repo, 'f.txt', 'a\nb\nc\nd\n')
|
||||
const afterSecondEdit = await getStatus(repo, { branchLineTotalMergeBase: mergeBase })
|
||||
expect(afterSecondEdit.branchLineTotal).toEqual({ added: 3, removed: 0, mergeBase })
|
||||
})
|
||||
|
||||
it('counts a rename across the commit boundary once, using the post-rename path', async () => {
|
||||
const repo = await createFixtureRepo()
|
||||
await write(repo, 'old.txt', 'a\nb\nc\nd\ne\n')
|
||||
const mergeBase = commitAll(repo, 'base')
|
||||
git(repo, ['mv', 'old.txt', 'new.txt'])
|
||||
commitAll(repo, 'rename')
|
||||
await write(repo, 'new.txt', 'a\nb\nc\nd\ne\nf\n')
|
||||
|
||||
const result = await getStatus(repo, { branchLineTotalMergeBase: mergeBase })
|
||||
|
||||
expect(rangedDiffTotal(repo, mergeBase)).toEqual({ added: 1, removed: 0 })
|
||||
expect(result.branchLineTotal).toEqual({ added: 1, removed: 0, mergeBase })
|
||||
})
|
||||
|
||||
it('counts an untracked-only branch from the untracked file contents', async () => {
|
||||
const repo = await createFixtureRepo()
|
||||
await write(repo, 'kept.txt', 'a\n')
|
||||
const mergeBase = commitAll(repo, 'base')
|
||||
await write(repo, 'src/new.ts', 'one\ntwo\nthree\n')
|
||||
|
||||
const result = await getStatus(repo, { branchLineTotalMergeBase: mergeBase })
|
||||
|
||||
expect(result.entries.map((entry) => entry.area)).toEqual(['untracked'])
|
||||
expect(result.branchLineTotal).toEqual({ added: 3, removed: 0, mergeBase })
|
||||
})
|
||||
|
||||
it('excludes a binary-only change, matching numstat reporting it as "-"', async () => {
|
||||
const repo = await createFixtureRepo()
|
||||
await write(repo, 'blob.bin', Buffer.from([0, 1, 2, 3, 10, 65, 66]))
|
||||
const mergeBase = commitAll(repo, 'base')
|
||||
await write(repo, 'blob.bin', Buffer.from([0, 9, 10, 11, 12, 67, 68, 69, 70]))
|
||||
|
||||
const result = await getStatus(repo, { branchLineTotalMergeBase: mergeBase })
|
||||
|
||||
expect(git(repo, ['diff', '--numstat', mergeBase, '--'])).toMatch(/^-\t-\t/)
|
||||
expect(result.branchLineTotal).toEqual({ added: 0, removed: 0, mergeBase })
|
||||
})
|
||||
|
||||
it('contributes nothing for a pure rename', async () => {
|
||||
const repo = await createFixtureRepo()
|
||||
await write(repo, 'f.txt', 'a\nb\nc\nd\n')
|
||||
const mergeBase = commitAll(repo, 'base')
|
||||
git(repo, ['mv', 'f.txt', 'g.txt'])
|
||||
|
||||
const result = await getStatus(repo, { branchLineTotalMergeBase: mergeBase })
|
||||
|
||||
expect(result.branchLineTotal).toEqual({ added: 0, removed: 0, mergeBase })
|
||||
})
|
||||
|
||||
it('includes untracked additions alongside tracked range changes', async () => {
|
||||
const repo = await createFixtureRepo()
|
||||
await write(repo, 'f.txt', 'a\nb\n')
|
||||
const mergeBase = commitAll(repo, 'base')
|
||||
await write(repo, 'f.txt', 'a\nb\nc\n')
|
||||
commitAll(repo, 'append c')
|
||||
await write(repo, 'untracked.txt', 'u1\nu2\nu3\nu4\n')
|
||||
|
||||
const result = await getStatus(repo, { branchLineTotalMergeBase: mergeBase })
|
||||
|
||||
expect(rangedDiffTotal(repo, mergeBase)).toEqual({ added: 1, removed: 0 })
|
||||
expect(result.branchLineTotal).toEqual({ added: 5, removed: 0, mergeBase })
|
||||
})
|
||||
|
||||
// Documented deviation from a pure `git diff`: the untracked half is added on
|
||||
// top of the tracked range, so a deleted-then-recreated path is counted twice.
|
||||
// Locked in deliberately — changing it must be a deliberate spec change.
|
||||
it('counts both the deletion and the recreated untracked file for the same path', async () => {
|
||||
const repo = await createFixtureRepo()
|
||||
await write(repo, 'gone.txt', 'a\nb\nc\n')
|
||||
const mergeBase = commitAll(repo, 'base')
|
||||
git(repo, ['rm', '-q', 'gone.txt'])
|
||||
commitAll(repo, 'delete gone.txt')
|
||||
await write(repo, 'gone.txt', 'n1\nn2\n')
|
||||
|
||||
const result = await getStatus(repo, { branchLineTotalMergeBase: mergeBase })
|
||||
|
||||
expect(result.entries.map((entry) => [entry.area, entry.path])).toEqual([
|
||||
['untracked', 'gone.txt']
|
||||
])
|
||||
expect(rangedDiffTotal(repo, mergeBase)).toEqual({ added: 0, removed: 3 })
|
||||
expect(result.branchLineTotal).toEqual({ added: 2, removed: 3, mergeBase })
|
||||
})
|
||||
})
|
||||
@@ -0,0 +1,172 @@
|
||||
/**
|
||||
* Main and relay must publish the same `branchLineTotal` for the same fixture
|
||||
* repo. Both call sites
|
||||
* share `src/shared/git-branch-line-total.ts`; this is the test that catches one
|
||||
* of them wiring it up differently — different flags, a different untracked
|
||||
* source, a different completeness gate.
|
||||
*/
|
||||
import { execFile, execFileSync } from 'node:child_process'
|
||||
import { mkdtemp, rm, writeFile } from 'node:fs/promises'
|
||||
import { tmpdir } from 'node:os'
|
||||
import * as path from 'node:path'
|
||||
import { promisify } from 'node:util'
|
||||
import { afterEach, beforeEach, describe, expect, it } from 'vitest'
|
||||
import { getStatus } from './status'
|
||||
import type { GitExec } from '../../relay/git-handler-ops'
|
||||
import { getStatusOp } from '../../relay/git-handler-status-ops'
|
||||
import type { RelayGitStreamExec } from '../../relay/git-stdout-stream'
|
||||
import { invalidateGitBranchLineTotalInFlight } from '../../shared/git-branch-line-total'
|
||||
import { clearGitStatusLineStatsCache } from '../../shared/git-status-line-stats-cache'
|
||||
|
||||
const execFileAsync = promisify(execFile)
|
||||
|
||||
// Fork point → working tree for the fixture below:
|
||||
// tracked.txt +2 (branch commit only)
|
||||
// partial.txt +1 (staged +2 then one of those lines removed unstaged)
|
||||
// flip.txt 0 (added in the branch commit, removed again in the worktree)
|
||||
// moved.txt 0 (pure rename)
|
||||
// fresh.txt +3 (untracked)
|
||||
const EXPECTED_TOTAL = { added: 6, removed: 0 }
|
||||
// Summing the per-area status rows instead would give this — the wrong answer
|
||||
// the shared module exists to avoid.
|
||||
const AREA_ROW_SUM = { added: 5, removed: 2 }
|
||||
|
||||
const relayGit: GitExec = async (args, cwd, opts) => {
|
||||
const { stdout, stderr } = await execFileAsync('git', args, {
|
||||
cwd,
|
||||
encoding: 'utf8',
|
||||
...(opts?.signal ? { signal: opts.signal } : {}),
|
||||
...(opts?.timeout ? { timeout: opts.timeout } : {})
|
||||
})
|
||||
return { stdout, stderr }
|
||||
}
|
||||
|
||||
const relayStreamGit: RelayGitStreamExec = async (args, cwd, options) => {
|
||||
const { stdout } = await relayGit(args, cwd, {
|
||||
disableOptionalLocks: options.disableOptionalLocks,
|
||||
signal: options.signal
|
||||
})
|
||||
return { stoppedEarly: options.onStdout(stdout) === true }
|
||||
}
|
||||
|
||||
function runFixtureGit(repo: string, args: string[]): string {
|
||||
return execFileSync(
|
||||
'git',
|
||||
[
|
||||
'-c',
|
||||
'user.email=test@test.com',
|
||||
'-c',
|
||||
'user.name=Test',
|
||||
'-c',
|
||||
'commit.gpgSign=false',
|
||||
...args
|
||||
],
|
||||
{ cwd: repo, encoding: 'utf8', stdio: ['ignore', 'pipe', 'pipe'] }
|
||||
).trim()
|
||||
}
|
||||
|
||||
/** Returns the merge-base OID the chip is measured against. */
|
||||
async function seedParityFixture(repo: string): Promise<string> {
|
||||
execFileSync('git', ['init', '-q', repo], { stdio: 'pipe' })
|
||||
await writeFile(path.join(repo, 'tracked.txt'), 'a\nb\n')
|
||||
await writeFile(path.join(repo, 'partial.txt'), 'one\ntwo\nthree\n')
|
||||
await writeFile(path.join(repo, 'renamed.txt'), 'stable\n')
|
||||
await writeFile(path.join(repo, 'flip.txt'), 'p\n')
|
||||
runFixtureGit(repo, ['add', '.'])
|
||||
runFixtureGit(repo, ['commit', '-m', 'base'])
|
||||
const mergeBase = runFixtureGit(repo, ['rev-parse', 'HEAD'])
|
||||
|
||||
runFixtureGit(repo, ['checkout', '-q', '-b', 'feature'])
|
||||
await writeFile(path.join(repo, 'tracked.txt'), 'a\nb\nc\nd\n')
|
||||
await writeFile(path.join(repo, 'flip.txt'), 'p\nq\n')
|
||||
runFixtureGit(repo, ['add', '-A'])
|
||||
runFixtureGit(repo, ['commit', '-m', 'branch commit'])
|
||||
|
||||
runFixtureGit(repo, ['mv', 'renamed.txt', 'moved.txt'])
|
||||
// Staged and unstaged hunks land on the same added lines, so an area sum
|
||||
// double-counts them.
|
||||
await writeFile(path.join(repo, 'partial.txt'), 'one\ntwo\nthree\nfoo\nbaz\n')
|
||||
runFixtureGit(repo, ['add', 'partial.txt'])
|
||||
await writeFile(path.join(repo, 'partial.txt'), 'one\ntwo\nthree\nfoo\n')
|
||||
// The branch commit's line, taken back out in the worktree: net zero.
|
||||
await writeFile(path.join(repo, 'flip.txt'), 'p\n')
|
||||
await writeFile(path.join(repo, 'fresh.txt'), 'n1\nn2\nn3\n')
|
||||
return mergeBase
|
||||
}
|
||||
|
||||
function sumAreaRows(entries: readonly { added?: number; removed?: number }[]): {
|
||||
added: number
|
||||
removed: number
|
||||
} {
|
||||
let added = 0
|
||||
let removed = 0
|
||||
for (const entry of entries) {
|
||||
added += entry.added ?? 0
|
||||
removed += entry.removed ?? 0
|
||||
}
|
||||
return { added, removed }
|
||||
}
|
||||
|
||||
describe('branch line total parity between main and relay', () => {
|
||||
let repo: string
|
||||
|
||||
beforeEach(async () => {
|
||||
clearGitStatusLineStatsCache()
|
||||
invalidateGitBranchLineTotalInFlight()
|
||||
repo = await mkdtemp(path.join(tmpdir(), 'branch-line-total-parity-'))
|
||||
})
|
||||
|
||||
afterEach(async () => {
|
||||
clearGitStatusLineStatsCache()
|
||||
invalidateGitBranchLineTotalInFlight()
|
||||
await rm(repo, { recursive: true, force: true })
|
||||
})
|
||||
|
||||
it('produces identical totals for the same fixture repo', async () => {
|
||||
const mergeBase = await seedParityFixture(repo)
|
||||
|
||||
const mainStatus = await getStatus(repo, { branchLineTotalMergeBase: mergeBase })
|
||||
clearGitStatusLineStatsCache()
|
||||
const relayStatus = await getStatusOp(relayGit, relayStreamGit, {
|
||||
worktreePath: repo,
|
||||
branchLineTotalMergeBase: mergeBase
|
||||
})
|
||||
|
||||
expect(mainStatus.branchLineTotal).toEqual({ ...EXPECTED_TOTAL, mergeBase })
|
||||
expect(relayStatus.branchLineTotal).toEqual(mainStatus.branchLineTotal)
|
||||
// Both sides model the same worktree, so a parity pass on a mismatched
|
||||
// entry list would be meaningless.
|
||||
expect(relayStatus.entries.map((entry) => entry.path).sort()).toEqual(
|
||||
mainStatus.entries.map((entry) => entry.path).sort()
|
||||
)
|
||||
})
|
||||
|
||||
it('agrees on a number no per-area row sum could produce', async () => {
|
||||
const mergeBase = await seedParityFixture(repo)
|
||||
|
||||
const mainStatus = await getStatus(repo, { branchLineTotalMergeBase: mergeBase })
|
||||
clearGitStatusLineStatsCache()
|
||||
const relayStatus = await getStatusOp(relayGit, relayStreamGit, {
|
||||
worktreePath: repo,
|
||||
branchLineTotalMergeBase: mergeBase
|
||||
})
|
||||
|
||||
expect(sumAreaRows(mainStatus.entries)).toEqual(AREA_ROW_SUM)
|
||||
expect(sumAreaRows(relayStatus.entries as { added?: number; removed?: number }[])).toEqual(
|
||||
AREA_ROW_SUM
|
||||
)
|
||||
expect(mainStatus.branchLineTotal).not.toMatchObject(AREA_ROW_SUM)
|
||||
expect(relayStatus.branchLineTotal).toEqual(mainStatus.branchLineTotal)
|
||||
})
|
||||
|
||||
it('omits the total on both sides when no merge base is requested', async () => {
|
||||
await seedParityFixture(repo)
|
||||
|
||||
const mainStatus = await getStatus(repo)
|
||||
clearGitStatusLineStatsCache()
|
||||
const relayStatus = await getStatusOp(relayGit, relayStreamGit, { worktreePath: repo })
|
||||
|
||||
expect(Object.hasOwn(mainStatus, 'branchLineTotal')).toBe(false)
|
||||
expect(Object.hasOwn(relayStatus, 'branchLineTotal')).toBe(false)
|
||||
})
|
||||
})
|
||||
+65
-3
@@ -54,6 +54,13 @@ import type { GitRuntimeOptions } from './git-runtime-options'
|
||||
import { gitOptionsForWorktree } from './git-runtime-options'
|
||||
import { GitStatusReadLeaseOwner } from './git-status-read-lease-owner'
|
||||
import { parseGitRevListFirstParentOid } from '../../shared/git-rev-list-output'
|
||||
import {
|
||||
computeGitBranchLineTotal,
|
||||
invalidateGitBranchLineTotalInFlight,
|
||||
readGitBranchLineTotalMergeBaseParam,
|
||||
GIT_BRANCH_LINE_TOTAL_TIMEOUT_MS,
|
||||
type GitBranchLineTotal
|
||||
} from '../../shared/git-branch-line-total'
|
||||
import {
|
||||
beginGitStatusLineStatsCacheWrite,
|
||||
clearGitStatusLineStatsCache,
|
||||
@@ -95,10 +102,12 @@ const gitDiffReadDedupe = new InFlightPromiseDedupe<GitDiffResult>()
|
||||
const effectiveUpstreamStatusWriteGeneration = new Map<string, number>()
|
||||
const statusReadLeaseOwner = new GitStatusReadLeaseOwner<GitStatusResult>()
|
||||
|
||||
// Why: clear both diff and status in-flight caches; clearing only diff would let getStatus() join a pre-mutation read.
|
||||
// Why: clear every in-flight git read cache; clearing only some would let a post-mutation
|
||||
// getStatus() join a pre-mutation read and publish it as current.
|
||||
export function invalidateGitReadCaches(): void {
|
||||
gitDiffReadDedupe.clear()
|
||||
statusReadLeaseOwner.invalidate()
|
||||
invalidateGitBranchLineTotalInFlight()
|
||||
clearGitStatusLineStatsCache()
|
||||
clearSubmodulePathsCache()
|
||||
resolvedUpstreamNameCache.clear()
|
||||
@@ -195,6 +204,9 @@ export function getEffectiveUpstreamStatusGenerationCountForTests(): number {
|
||||
export type GetStatusOptions = GitRuntimeOptions & {
|
||||
includeIgnored?: boolean
|
||||
reuseLineStats?: boolean
|
||||
/** Merge-base OID the caller wants the branch line total measured against;
|
||||
* omitted means the chip is hidden, so no ranged diff runs at all. */
|
||||
branchLineTotalMergeBase?: string
|
||||
/**
|
||||
* Max changed-file entries before git is stopped and the result is marked
|
||||
* `didHitLimit`. Defaults to DEFAULT_GIT_STATUS_LIMIT; 0 disables the cap.
|
||||
@@ -232,6 +244,9 @@ function getStatusReadKey(worktreePath: string, options: GetStatusOptions): stri
|
||||
options.wslDistro ?? '',
|
||||
options.includeIgnored === true,
|
||||
options.reuseLineStats === true,
|
||||
// Why: the result carries a total only for callers who asked, and only for
|
||||
// this fork point, so a shared lease must never serve one to the other.
|
||||
options.branchLineTotalMergeBase ?? '',
|
||||
options.bypassEffectiveUpstreamNegativeCache === true,
|
||||
limit,
|
||||
// Why: this changes which entries survive, so it must not share a cache slot.
|
||||
@@ -371,16 +386,25 @@ async function runGetStatus(
|
||||
}
|
||||
|
||||
// Why: line counts run only for areas with entries (clean tree = 0 calls); skip past the limit to avoid numstat over a huge set.
|
||||
let branchLineTotal: GitBranchLineTotal | undefined
|
||||
if (!didHitLimit) {
|
||||
await reuseOrRecomputeGitStatusLineStats({
|
||||
const branchLineTotalInput = createBranchLineTotalInput(
|
||||
worktreePath,
|
||||
entries,
|
||||
options,
|
||||
statusSucceeded
|
||||
)
|
||||
const lineStats = await reuseOrRecomputeGitStatusLineStats({
|
||||
cacheKey: lineStatsCacheKey,
|
||||
head,
|
||||
entries,
|
||||
writeToken: lineStatsWriteToken,
|
||||
reuse: options.reuseLineStats === true,
|
||||
isAborted: () => options.signal?.aborted === true,
|
||||
recompute: () => attachLineStats(worktreePath, entries, options)
|
||||
recompute: () => attachLineStats(worktreePath, entries, options),
|
||||
...(branchLineTotalInput ? { branchLineTotal: branchLineTotalInput } : {})
|
||||
})
|
||||
branchLineTotal = lineStats.branchLineTotal
|
||||
} else {
|
||||
clearGitStatusLineStatsCacheKey(lineStatsCacheKey, lineStatsWriteToken)
|
||||
}
|
||||
@@ -398,6 +422,7 @@ async function runGetStatus(
|
||||
head,
|
||||
branch,
|
||||
...(options.includeIgnored ? { ignoredPaths: parser.ignoredPaths } : {}),
|
||||
...(branchLineTotal ? { branchLineTotal } : {}),
|
||||
...(didHitLimit ? { didHitLimit: true, statusLength: parser.statusLength } : {}),
|
||||
...(statusSucceeded
|
||||
? {
|
||||
@@ -416,6 +441,43 @@ async function runGetStatus(
|
||||
}
|
||||
}
|
||||
|
||||
/** Undefined — and therefore zero extra work — unless the caller asked for a total we can know exact. */
|
||||
function createBranchLineTotalInput(
|
||||
worktreePath: string,
|
||||
entries: GitStatusEntry[],
|
||||
options: GetStatusOptions,
|
||||
statusSucceeded: boolean
|
||||
): { mergeBase: string; compute: () => Promise<GitBranchLineTotal | undefined> } | undefined {
|
||||
const mergeBase = readGitBranchLineTotalMergeBaseParam(options.branchLineTotalMergeBase)
|
||||
// Why: a failed status scan leaves the untracked list untrustworthy, so the
|
||||
// total would silently under-count rather than be absent.
|
||||
if (mergeBase === undefined || !statusSucceeded) {
|
||||
return undefined
|
||||
}
|
||||
return {
|
||||
mergeBase,
|
||||
compute: () =>
|
||||
computeGitBranchLineTotal({
|
||||
worktreePath,
|
||||
// Why: the same path can be a different filesystem per WSL distro.
|
||||
hostKey: options.wslDistro ?? 'native',
|
||||
mergeBase,
|
||||
untrackedPaths: entries
|
||||
.filter((entry) => entry.area === 'untracked')
|
||||
.map((entry) => entry.path),
|
||||
runDiffNumstat: (args, signal) =>
|
||||
gitExecFileAsync(args, {
|
||||
...gitOptionsForWorktree(worktreePath, options),
|
||||
// Why: after the spread, so the shared lease signal wins over this caller's own.
|
||||
signal,
|
||||
env: gitOptionalLocksDisabledEnv(),
|
||||
timeout: GIT_BRANCH_LINE_TOTAL_TIMEOUT_MS
|
||||
}).then((result) => result.stdout),
|
||||
...(options.signal ? { signal: options.signal } : {})
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
function getStatusLineStatsCacheKey(worktreePath: string, options: GitRuntimeOptions = {}): string {
|
||||
// Why: identical paths can map to different WSL-distro filesystems, so key stats by Git's execution host.
|
||||
return `${options.wslDistro ?? 'native'}\0${worktreePath}`
|
||||
|
||||
@@ -1,5 +1,7 @@
|
||||
import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest'
|
||||
import { spawn } from 'node:child_process'
|
||||
import { mkdirSync, mkdtempSync, readFileSync, rmSync, writeFileSync } from 'node:fs'
|
||||
import { createServer } from 'node:http'
|
||||
import { tmpdir } from 'node:os'
|
||||
import { dirname, join } from 'node:path'
|
||||
|
||||
@@ -16,12 +18,86 @@ vi.mock('os', async () => {
|
||||
})
|
||||
|
||||
import { getGrokToolEventMatcherForTests, GrokHookService } from './hook-service'
|
||||
import { buildWindowsGrokHookScript } from './windows-grok-hook-script'
|
||||
import { POSIX_HOOK_STDIN_READER } from '../agent-hooks/hook-stdin-contract'
|
||||
|
||||
const GROK_SCRIPT_FILE_NAME = process.platform === 'win32' ? 'grok-hook.cmd' : 'grok-hook.sh'
|
||||
const WINDOWS_POWERSHELL_LAUNCHER =
|
||||
/^[A-Za-z]:\/[^"]*\/System32\/WindowsPowerShell\/v1\.0\/powershell\.exe -NoProfile -ExecutionPolicy Bypass -EncodedCommand \S+$/
|
||||
|
||||
type WindowsGrokHookRun = {
|
||||
status: number | null
|
||||
stderr: string
|
||||
stdout: string
|
||||
request?: { path: string; body: string }
|
||||
}
|
||||
|
||||
function createWindowsGrokHookEnvironment(grokHome?: string): NodeJS.ProcessEnv {
|
||||
const env = { ...process.env } as NodeJS.ProcessEnv
|
||||
delete env.ORCA_AGENT_HOOK_ENDPOINT
|
||||
if (grokHome === undefined) {
|
||||
delete env.GROK_HOME
|
||||
} else {
|
||||
env.GROK_HOME = grokHome
|
||||
}
|
||||
env.ORCA_AGENT_HOOK_TOKEN = 'test-token'
|
||||
env.ORCA_PANE_KEY = 'pane-test'
|
||||
return env
|
||||
}
|
||||
|
||||
async function runWindowsGrokHook(
|
||||
scriptPath: string,
|
||||
env: NodeJS.ProcessEnv,
|
||||
input: string
|
||||
): Promise<WindowsGrokHookRun> {
|
||||
let request: WindowsGrokHookRun['request']
|
||||
const server = createServer((incoming, response) => {
|
||||
let body = ''
|
||||
incoming.setEncoding('utf8')
|
||||
incoming.on('data', (chunk: string) => {
|
||||
body += chunk
|
||||
})
|
||||
incoming.on('end', () => {
|
||||
request = { path: incoming.url ?? '', body }
|
||||
response.writeHead(204).end()
|
||||
})
|
||||
})
|
||||
await new Promise<void>((resolve, reject) => {
|
||||
server.once('error', reject)
|
||||
server.listen(0, '127.0.0.1', () => {
|
||||
server.off('error', reject)
|
||||
resolve()
|
||||
})
|
||||
})
|
||||
const address = server.address()
|
||||
if (!address || typeof address === 'string') {
|
||||
throw new Error('Could not resolve Windows Grok hook test listener port')
|
||||
}
|
||||
env.ORCA_AGENT_HOOK_PORT = String(address.port)
|
||||
try {
|
||||
const result = await new Promise<Omit<WindowsGrokHookRun, 'request'>>((resolve, reject) => {
|
||||
const child = spawn(process.env.ComSpec ?? 'cmd.exe', ['/d', '/c', scriptPath], {
|
||||
env,
|
||||
stdio: ['pipe', 'pipe', 'pipe']
|
||||
})
|
||||
let stderr = ''
|
||||
let stdout = ''
|
||||
child.stderr.setEncoding('utf8').on('data', (chunk: string) => {
|
||||
stderr += chunk
|
||||
})
|
||||
child.stdout.setEncoding('utf8').on('data', (chunk: string) => {
|
||||
stdout += chunk
|
||||
})
|
||||
child.once('error', reject)
|
||||
child.once('close', (status) => resolve({ status, stderr, stdout }))
|
||||
child.stdin.end(input)
|
||||
})
|
||||
return { ...result, request }
|
||||
} finally {
|
||||
await new Promise<void>((resolve) => server.close(() => resolve()))
|
||||
}
|
||||
}
|
||||
|
||||
describe('GrokHookService', () => {
|
||||
let homeDir: string
|
||||
|
||||
@@ -35,6 +111,108 @@ describe('GrokHookService', () => {
|
||||
rmSync(homeDir, { recursive: true, force: true })
|
||||
})
|
||||
|
||||
// Why: #9358 / #9941 — empty GROK_HOME + parse-time %VAR:~n,m% / `"\"` broke
|
||||
// every SessionStart/UserPromptSubmit on Windows outside Orca terminals.
|
||||
it('guards Windows GROK_HOME substring checks when empty (#9358)', () => {
|
||||
const script = buildWindowsGrokHookScript()
|
||||
expect(script).toContain('set "ORCA_GROK_HOME="')
|
||||
expect(script).toContain('if not defined GROK_HOME goto :orca_grok_home_ready')
|
||||
expect(script).toContain('%GROK_HOME:~4096,1%')
|
||||
expect(script).toContain('set "ORCA_GROK_HOME=%GROK_HOME%"')
|
||||
expect(script).toContain('%ORCA_GROK_HOME:~4096,1%')
|
||||
expect(script).toContain(':orca_grok_home_ready')
|
||||
expect(script).toContain('if not defined ORCA_GROK_HOME goto :orca_grok_home_ready')
|
||||
expect(script).toContain('if "%ORCA_GROK_HOME:~-1%"=="\\"')
|
||||
expect(script).toContain('if not "%GROK_HOME:~4096,1%"=="" goto :orca_grok_home_ready')
|
||||
// Why: parenthesized `if defined (...)` still parse-expands the body early.
|
||||
expect(script).not.toMatch(/if defined GROK_HOME \(/)
|
||||
})
|
||||
|
||||
it.skipIf(process.platform !== 'win32')(
|
||||
'generated grok-hook.cmd exits 0 when GROK_HOME is unset (#9358)',
|
||||
async () => {
|
||||
const scriptPath = join(homeDir, 'grok-hook-unset.cmd')
|
||||
writeFileSync(scriptPath, buildWindowsGrokHookScript(), 'utf8')
|
||||
// Why: delete GROK_HOME rather than set '' so cmd sees "not defined".
|
||||
const result = await runWindowsGrokHook(
|
||||
scriptPath,
|
||||
createWindowsGrokHookEnvironment(),
|
||||
'{"hook_event_name":"SessionStart"}'
|
||||
)
|
||||
expect(result.status, `stderr=${result.stderr}\nstdout=${result.stdout}`).toBe(0)
|
||||
expect(`${result.stderr ?? ''}${result.stdout ?? ''}`).not.toMatch(
|
||||
/syntax of the command is incorrect|命令语法不正确/i
|
||||
)
|
||||
expect(result.request?.path).toBe('/hook/grok')
|
||||
const form = new URLSearchParams(result.request?.body)
|
||||
expect(form.get('grokHome')).toBe('')
|
||||
expect(form.get('payload')).toBe('{"hook_event_name":"SessionStart"}')
|
||||
}
|
||||
)
|
||||
|
||||
it.skipIf(process.platform !== 'win32')(
|
||||
'generated grok-hook.cmd exits 0 with trailing-backslash GROK_HOME (#9358)',
|
||||
async () => {
|
||||
const scriptPath = join(homeDir, 'grok-hook-slash.cmd')
|
||||
writeFileSync(scriptPath, buildWindowsGrokHookScript(), 'utf8')
|
||||
const trailing = `${join(homeDir, 'grok-home-with-slash')}\\`
|
||||
const result = await runWindowsGrokHook(
|
||||
scriptPath,
|
||||
createWindowsGrokHookEnvironment(trailing),
|
||||
'{"hook_event_name":"UserPromptSubmit"}'
|
||||
)
|
||||
expect(result.status, `stderr=${result.stderr}\nstdout=${result.stdout}`).toBe(0)
|
||||
expect(`${result.stderr ?? ''}${result.stdout ?? ''}`).not.toMatch(
|
||||
/syntax of the command is incorrect|命令语法不正确/i
|
||||
)
|
||||
expect(result.request?.path).toBe('/hook/grok')
|
||||
const form = new URLSearchParams(result.request?.body)
|
||||
expect(form.get('grokHome')).toBe(`${trailing}.`)
|
||||
expect(form.get('payload')).toBe('{"hook_event_name":"UserPromptSubmit"}')
|
||||
}
|
||||
)
|
||||
|
||||
it.skipIf(process.platform !== 'win32')(
|
||||
'generated grok-hook.cmd exits 0 with an oversized GROK_HOME (#9358)',
|
||||
async () => {
|
||||
const scriptPath = join(homeDir, 'grok-hook-oversized.cmd')
|
||||
writeFileSync(scriptPath, buildWindowsGrokHookScript(), 'utf8')
|
||||
const result = await runWindowsGrokHook(
|
||||
scriptPath,
|
||||
createWindowsGrokHookEnvironment(`C:\\${'a'.repeat(9000)}`),
|
||||
'{"hook_event_name":"UserPromptSubmit"}'
|
||||
)
|
||||
expect(result.status, `stderr=${result.stderr}\nstdout=${result.stdout}`).toBe(0)
|
||||
expect(`${result.stderr ?? ''}${result.stdout ?? ''}`).not.toMatch(
|
||||
/syntax of the command is incorrect|命令语法不正确/i
|
||||
)
|
||||
expect(result.request?.path).toBe('/hook/grok')
|
||||
const form = new URLSearchParams(result.request?.body)
|
||||
expect(form.get('grokHome')).toBe('')
|
||||
expect(form.get('payload')).toBe('{"hook_event_name":"UserPromptSubmit"}')
|
||||
}
|
||||
)
|
||||
|
||||
it.skipIf(process.platform !== 'win32')(
|
||||
'omits a max-length trailing-backslash GROK_HOME after safe normalization (#9358)',
|
||||
async () => {
|
||||
const scriptPath = join(homeDir, 'grok-hook-max-trailing.cmd')
|
||||
writeFileSync(scriptPath, buildWindowsGrokHookScript(), 'utf8')
|
||||
const trailingAtLimit = `C:\\${'a'.repeat(4092)}\\`
|
||||
expect(trailingAtLimit).toHaveLength(4096)
|
||||
const result = await runWindowsGrokHook(
|
||||
scriptPath,
|
||||
createWindowsGrokHookEnvironment(trailingAtLimit),
|
||||
'{"hook_event_name":"UserPromptSubmit"}'
|
||||
)
|
||||
expect(result.status, `stderr=${result.stderr}\nstdout=${result.stdout}`).toBe(0)
|
||||
expect(result.request?.path).toBe('/hook/grok')
|
||||
const form = new URLSearchParams(result.request?.body)
|
||||
expect(form.get('grokHome')).toBe('')
|
||||
expect(form.get('payload')).toBe('{"hook_event_name":"UserPromptSubmit"}')
|
||||
}
|
||||
)
|
||||
|
||||
it('installs a dedicated global Grok hook config and managed script', () => {
|
||||
const status = new GrokHookService().install()
|
||||
|
||||
@@ -89,11 +267,12 @@ describe('GrokHookService', () => {
|
||||
expect(script).toContain('/hook/grok')
|
||||
if (process.platform === 'win32') {
|
||||
expect(script).toContain('%SystemRoot%\\System32\\curl.exe')
|
||||
expect(script).toContain('set "ORCA_GROK_HOME=%GROK_HOME%"')
|
||||
expect(script).toContain('if not defined GROK_HOME goto :orca_grok_home_ready')
|
||||
expect(script).toContain('%GROK_HOME:~4096,1%')
|
||||
expect(script).toContain(
|
||||
'if "%ORCA_GROK_HOME:~-1%"=="\\" set "ORCA_GROK_HOME=%ORCA_GROK_HOME%."'
|
||||
)
|
||||
expect(script).toContain('set "ORCA_GROK_HOME=%GROK_HOME%"')
|
||||
expect(script).toContain('%ORCA_GROK_HOME:~4096,1%')
|
||||
expect(script).toContain('if not defined ORCA_GROK_HOME goto :orca_grok_home_ready')
|
||||
expect(script).toContain('if "%ORCA_GROK_HOME:~-1%"=="\\"')
|
||||
expect(script).toContain('--data-urlencode "grokHome=%ORCA_GROK_HOME%"')
|
||||
} else {
|
||||
// Why: payload is piped to curl via stdin (`payload@-`) so it never lands
|
||||
|
||||
@@ -5,7 +5,6 @@ import { resolveGrokHomeDir } from '../../shared/grok-session-paths'
|
||||
import {
|
||||
buildManagedCommandHook,
|
||||
createManagedCommandMatcher,
|
||||
buildWindowsAgentHookPostCommand,
|
||||
getSharedManagedScriptPath,
|
||||
readHooksJson,
|
||||
removeManagedCommands,
|
||||
@@ -20,19 +19,17 @@ import {
|
||||
writeHooksJsonRemote,
|
||||
writeManagedScriptRemote
|
||||
} from '../agent-hooks/installer-utils-remote'
|
||||
import { buildPosixHookPayloadCapture } from '../agent-hooks/hook-stdin-contract'
|
||||
import {
|
||||
buildPosixHookPayloadCapture,
|
||||
buildWindowsHookEnvironmentGuardLines,
|
||||
buildWindowsHookStdinDrainEpilogue
|
||||
} from '../agent-hooks/hook-stdin-contract'
|
||||
buildWindowsGrokHookScript,
|
||||
GROK_HOME_ENVELOPE_MAX_LENGTH
|
||||
} from './windows-grok-hook-script'
|
||||
|
||||
// Why: Grok's tool-event matcher is a real regex (see Grok hooks docs). Bare
|
||||
// `*` is not a valid "match all" pattern and can fail to load/match, so tool
|
||||
// lifecycle hooks never fire. `.*` matches every tool name (same as Command
|
||||
// Code's managed hooks).
|
||||
const GROK_TOOL_EVENT_MATCHER = '.*'
|
||||
const GROK_HOME_ENVELOPE_MAX_LENGTH = 4096
|
||||
const WINDOWS_HOOK_PAYLOAD_FORM_LINE = ' --data-urlencode "payload@-" >nul 2>nul'
|
||||
|
||||
const GROK_EVENTS = [
|
||||
{ eventName: 'SessionStart', definition: { hooks: [{ type: 'command', command: '' }] } },
|
||||
@@ -95,11 +92,6 @@ function hasControlCharacter(value: string): boolean {
|
||||
})
|
||||
}
|
||||
|
||||
const WINDOWS_GROK_HOOK_POST_COMMAND = buildWindowsAgentHookPostCommand('grok').replace(
|
||||
WINDOWS_HOOK_PAYLOAD_FORM_LINE,
|
||||
` --data-urlencode "grokHome=%ORCA_GROK_HOME%" ^\r\n${WINDOWS_HOOK_PAYLOAD_FORM_LINE}`
|
||||
)
|
||||
|
||||
function getManagedScriptFileName(): string {
|
||||
return process.platform === 'win32' ? 'grok-hook.cmd' : 'grok-hook.sh'
|
||||
}
|
||||
@@ -116,21 +108,7 @@ function getManagedCommand(scriptPath: string): string {
|
||||
|
||||
function getManagedScript(target: 'local' | 'posix' = 'local'): string {
|
||||
if (target === 'local' && process.platform === 'win32') {
|
||||
return [
|
||||
'@echo off',
|
||||
'setlocal',
|
||||
'if defined ORCA_AGENT_HOOK_ENDPOINT if exist "%ORCA_AGENT_HOOK_ENDPOINT%" call "%ORCA_AGENT_HOOK_ENDPOINT%" 2>nul',
|
||||
...buildWindowsHookEnvironmentGuardLines(),
|
||||
'set "ORCA_GROK_HOME=%GROK_HOME%"',
|
||||
`if not "%GROK_HOME:~${GROK_HOME_ENVELOPE_MAX_LENGTH},1%"=="" set "ORCA_GROK_HOME="`,
|
||||
// Why: a trailing backslash escapes curl's closing argv quote on Windows,
|
||||
// merging the payload option into grokHome and dropping the hook body.
|
||||
'if "%ORCA_GROK_HOME:~-1%"=="\\" set "ORCA_GROK_HOME=%ORCA_GROK_HOME%."',
|
||||
WINDOWS_GROK_HOOK_POST_COMMAND,
|
||||
'exit /b 0',
|
||||
...buildWindowsHookStdinDrainEpilogue(),
|
||||
''
|
||||
].join('\r\n')
|
||||
return buildWindowsGrokHookScript()
|
||||
}
|
||||
|
||||
return [
|
||||
|
||||
@@ -0,0 +1,45 @@
|
||||
import { buildWindowsAgentHookPostCommand } from '../agent-hooks/installer-utils'
|
||||
import {
|
||||
buildWindowsHookEnvironmentGuardLines,
|
||||
buildWindowsHookStdinDrainEpilogue
|
||||
} from '../agent-hooks/hook-stdin-contract'
|
||||
|
||||
/** Matches the envelope length cap used by the POSIX grok-hook branch. */
|
||||
export const GROK_HOME_ENVELOPE_MAX_LENGTH = 4096
|
||||
|
||||
const WINDOWS_GROK_HOOK_POST_COMMAND = buildWindowsAgentHookPostCommand('grok', [
|
||||
// Why: attach grokHome before payload@- without string-replacing the shared template.
|
||||
' --data-urlencode "grokHome=%ORCA_GROK_HOME%" ^'
|
||||
])
|
||||
|
||||
/**
|
||||
* Windows `grok-hook.cmd` body.
|
||||
*
|
||||
* Why (#9358 / #9941): cmd expands `%VAR:~n,m%` at parse time. When `GROK_HOME`
|
||||
* is unset (the default outside an Orca-managed terminal), length/trailing
|
||||
* guards become a syntax error and every Grok hook event fails with exit 255.
|
||||
*
|
||||
* - Guard substring ops behind `if defined` + goto (not a parenthesized block).
|
||||
* - Reject oversized values before copying them onto a cmd input line.
|
||||
*/
|
||||
export function buildWindowsGrokHookScript(): string {
|
||||
return [
|
||||
'@echo off',
|
||||
'setlocal',
|
||||
'if defined ORCA_AGENT_HOOK_ENDPOINT if exist "%ORCA_AGENT_HOOK_ENDPOINT%" call "%ORCA_AGENT_HOOK_ENDPOINT%" 2>nul',
|
||||
...buildWindowsHookEnvironmentGuardLines(),
|
||||
'set "ORCA_GROK_HOME="',
|
||||
'if not defined GROK_HOME goto :orca_grok_home_ready',
|
||||
`if not "%GROK_HOME:~${GROK_HOME_ENVELOPE_MAX_LENGTH},1%"=="" goto :orca_grok_home_ready`,
|
||||
'set "ORCA_GROK_HOME=%GROK_HOME%"',
|
||||
'if not defined ORCA_GROK_HOME goto :orca_grok_home_ready',
|
||||
'if "%ORCA_GROK_HOME:~-1%"=="\\" set "ORCA_GROK_HOME=%ORCA_GROK_HOME%."',
|
||||
// Why: the trailing-backslash safety sentinel counts toward the relay envelope.
|
||||
`if not "%ORCA_GROK_HOME:~${GROK_HOME_ENVELOPE_MAX_LENGTH},1%"=="" set "ORCA_GROK_HOME="`,
|
||||
':orca_grok_home_ready',
|
||||
WINDOWS_GROK_HOOK_POST_COMMAND,
|
||||
'exit /b 0',
|
||||
...buildWindowsHookStdinDrainEpilogue(),
|
||||
''
|
||||
].join('\r\n')
|
||||
}
|
||||
@@ -366,6 +366,30 @@ describe('registerFilesystemHandlers', () => {
|
||||
lstatMock.mockRejectedValue(Object.assign(new Error('missing'), { code: 'ENOENT' }))
|
||||
})
|
||||
|
||||
it('re-sorts SSH provider listings directories-first in natural order', async () => {
|
||||
// Why: the remote relay may be an older build that still sorts lexicographically.
|
||||
getSshFilesystemProviderMock.mockReturnValueOnce({
|
||||
readDir: vi.fn().mockResolvedValue([
|
||||
{ name: '100 - b.txt', isDirectory: false, isSymlink: false },
|
||||
{ name: '9 - c.txt', isDirectory: false, isSymlink: false },
|
||||
{ name: '10 - dir', isDirectory: true, isSymlink: false },
|
||||
{ name: '99 - a.txt', isDirectory: false, isSymlink: false }
|
||||
])
|
||||
})
|
||||
registerFilesystemHandlers(store as never)
|
||||
|
||||
const result = (await handlers.get('fs:readDir')!(null, {
|
||||
dirPath: '/remote/repo',
|
||||
connectionId: 'ssh-1'
|
||||
})) as { name: string }[]
|
||||
expect(result.map((e) => e.name)).toEqual([
|
||||
'10 - dir',
|
||||
'9 - c.txt',
|
||||
'99 - a.txt',
|
||||
'100 - b.txt'
|
||||
])
|
||||
})
|
||||
|
||||
it('returns an actionable reconnect error when the SSH filesystem provider is unavailable', async () => {
|
||||
registerFilesystemHandlers(store as never)
|
||||
|
||||
|
||||
@@ -30,6 +30,7 @@ import type {
|
||||
} from '../../shared/types'
|
||||
import type { GitHistoryOptions, GitHistoryResult } from '../../shared/git-history'
|
||||
import type { SshMutationExpectation } from '../../shared/ssh-types'
|
||||
import { sortDirEntries } from '../../shared/file-name-sort'
|
||||
import { assertSshMutationExpectation } from '../ssh/ssh-connection-generation'
|
||||
import {
|
||||
buildRgArgs,
|
||||
@@ -540,7 +541,9 @@ export function registerFilesystemHandlers(
|
||||
if (args.connectionId) {
|
||||
throwSite = 'ssh-provider'
|
||||
const provider = requireSshFilesystemProvider(args.connectionId)
|
||||
return await provider.readDir(args.dirPath)
|
||||
// Why: re-sort locally — the remote relay may be an older build with
|
||||
// lexicographic ordering.
|
||||
return sortDirEntries(await provider.readDir(args.dirPath))
|
||||
}
|
||||
throwSite = 'authorize'
|
||||
const dirPath = await resolveAuthorizedPath(args.dirPath, store)
|
||||
@@ -555,12 +558,7 @@ export function registerFilesystemHandlers(
|
||||
isSymlink: entry.isSymbolicLink()
|
||||
}))
|
||||
)
|
||||
return mapped.sort((a, b) => {
|
||||
if (a.isDirectory !== b.isDirectory) {
|
||||
return a.isDirectory ? -1 : 1
|
||||
}
|
||||
return a.name.localeCompare(b.name)
|
||||
})
|
||||
return sortDirEntries(mapped)
|
||||
} catch (error: unknown) {
|
||||
recordCrashBreadcrumb(
|
||||
'fs_readdir_error',
|
||||
@@ -1127,6 +1125,7 @@ export function registerFilesystemHandlers(
|
||||
includeIgnored?: boolean
|
||||
bypassEffectiveUpstreamNegativeCache?: boolean
|
||||
reuseLineStats?: boolean
|
||||
branchLineTotalMergeBase?: string
|
||||
requestToken?: string
|
||||
}
|
||||
): Promise<GitStatusResult> => {
|
||||
@@ -1134,6 +1133,9 @@ export function registerFilesystemHandlers(
|
||||
const options = {
|
||||
includeIgnored: args.includeIgnored ?? false,
|
||||
...(args.reuseLineStats === true ? { reuseLineStats: true } : {}),
|
||||
...(args.branchLineTotalMergeBase === undefined
|
||||
? {}
|
||||
: { branchLineTotalMergeBase: args.branchLineTotalMergeBase }),
|
||||
...(args.bypassEffectiveUpstreamNegativeCache === true
|
||||
? { bypassEffectiveUpstreamNegativeCache: true }
|
||||
: {}),
|
||||
|
||||
@@ -3,6 +3,7 @@ import type { SshConnectionManager } from '../ssh/ssh-connection-manager'
|
||||
import type { SshExecOptions } from '../ssh/ssh-connection-utils'
|
||||
import { powerShellCommand, powerShellLiteral } from '../ssh/ssh-remote-powershell'
|
||||
import type { FilesystemPathFlavor } from '../../shared/types'
|
||||
import { sortDirEntries } from '../../shared/file-name-sort'
|
||||
|
||||
export type RemoteDirEntry = {
|
||||
name: string
|
||||
@@ -220,13 +221,8 @@ async function runBrowseCommand(
|
||||
}
|
||||
}
|
||||
|
||||
// Sort: directories first, then alphabetical
|
||||
entries.sort((a, b) => {
|
||||
if (a.isDirectory !== b.isDirectory) {
|
||||
return a.isDirectory ? -1 : 1
|
||||
}
|
||||
return a.name.localeCompare(b.name)
|
||||
})
|
||||
// Sort: directories first, then natural name order (matches the Explorer)
|
||||
sortDirEntries(entries)
|
||||
|
||||
resolveOnce({ entries, resolvedPath, pathFlavor })
|
||||
}
|
||||
|
||||
@@ -6,7 +6,7 @@ import { tmpdir } from 'node:os'
|
||||
import { join, resolve } from 'node:path'
|
||||
import type { CreateWorktreeResult, GitWorktreeInfo, Repo, Worktree } from '../../shared/types'
|
||||
import type { ProviderRequestId } from '../../shared/detected-worktree-provider-contract'
|
||||
import { toSshExecutionHostId } from '../../shared/execution-host'
|
||||
import { LOCAL_EXECUTION_HOST_ID, toSshExecutionHostId } from '../../shared/execution-host'
|
||||
import * as localWorktreeFilesystem from '../local-worktree-filesystem'
|
||||
|
||||
const ORIGINAL_PLATFORM = process.platform
|
||||
@@ -2589,6 +2589,139 @@ describe('registerWorktreeHandlers', () => {
|
||||
})
|
||||
})
|
||||
|
||||
it('lists every persisted SSH worktree without accessing the live provider', async () => {
|
||||
const sshHostId = toSshExecutionHostId('target-a')
|
||||
const sshRepo = {
|
||||
id: 'repo-1',
|
||||
path: '/remote/repo',
|
||||
displayName: 'repo',
|
||||
badgeColor: '#000',
|
||||
addedAt: 0,
|
||||
connectionId: 'target-a'
|
||||
}
|
||||
const metaById = {
|
||||
'repo-1::/remote/repo': makeWorktreeMeta({
|
||||
displayName: 'main',
|
||||
hostId: sshHostId
|
||||
}),
|
||||
'repo-1::/remote/queued': makeWorktreeMeta({
|
||||
displayName: 'queued',
|
||||
hostId: sshHostId
|
||||
}),
|
||||
'repo-1::/remote/other-host': makeWorktreeMeta({
|
||||
displayName: 'other host',
|
||||
hostId: toSshExecutionHostId('target-b')
|
||||
})
|
||||
}
|
||||
store.getRepos.mockReturnValue([sshRepo])
|
||||
store.getProjectHostSetups.mockReturnValue([])
|
||||
store.getAllWorktreeMeta.mockReturnValue(metaById)
|
||||
store.getWorktreeMeta.mockImplementation((worktreeId: string) => metaById[worktreeId])
|
||||
|
||||
const result = await handlers['worktrees:listKnownForExecutionHost'](null, {
|
||||
repoId: sshRepo.id,
|
||||
executionHostId: sshHostId
|
||||
})
|
||||
|
||||
expect(result).toMatchObject({
|
||||
status: 'complete',
|
||||
repoId: sshRepo.id,
|
||||
executionHostId: sshHostId,
|
||||
result: {
|
||||
repoId: sshRepo.id,
|
||||
authoritative: false,
|
||||
source: 'metadata-fallback',
|
||||
worktrees: [
|
||||
expect.objectContaining({ path: '/remote/repo', isMainWorktree: true }),
|
||||
expect.objectContaining({ path: '/remote/queued', isMainWorktree: false })
|
||||
]
|
||||
}
|
||||
})
|
||||
expect(getSshGitProviderMock).not.toHaveBeenCalled()
|
||||
expect(listWorktreesMock).not.toHaveBeenCalled()
|
||||
})
|
||||
|
||||
it('lists SSH folder workspaces at the folder path, not the instance-suffixed id', async () => {
|
||||
const sshHostId = toSshExecutionHostId('target-a')
|
||||
const folderRepo = {
|
||||
id: 'repo-1',
|
||||
path: '/remote/folder',
|
||||
displayName: 'folder',
|
||||
badgeColor: '#000',
|
||||
addedAt: 0,
|
||||
connectionId: 'target-a',
|
||||
kind: 'folder' as const
|
||||
}
|
||||
const rootId = `${folderRepo.id}::${folderRepo.path}`
|
||||
const instanceId = `${rootId}::workspace:11111111-2222-3333-4444-555555555555`
|
||||
const metaById = {
|
||||
[rootId]: makeWorktreeMeta({ displayName: 'root', hostId: sshHostId }),
|
||||
[instanceId]: makeWorktreeMeta({
|
||||
displayName: 'second workspace',
|
||||
hostId: sshHostId,
|
||||
instanceId: '11111111-2222-3333-4444-555555555555'
|
||||
})
|
||||
}
|
||||
store.getRepos.mockReturnValue([folderRepo])
|
||||
store.getProjectHostSetups.mockReturnValue([])
|
||||
store.getAllWorktreeMeta.mockReturnValue(metaById)
|
||||
store.getWorktreeMeta.mockImplementation((worktreeId: string) => metaById[worktreeId])
|
||||
|
||||
const result = (await handlers['worktrees:listKnownForExecutionHost'](null, {
|
||||
repoId: folderRepo.id,
|
||||
executionHostId: sshHostId
|
||||
})) as { status: string; result: { authoritative: boolean; worktrees: Worktree[] } }
|
||||
|
||||
expect(result.status).toBe('complete')
|
||||
expect(result.result.authoritative).toBe(false)
|
||||
// Why: the git-worktree synthesizer would read the "::workspace:<uuid>" tail as a directory.
|
||||
expect(result.result.worktrees).toEqual([
|
||||
expect.objectContaining({ id: rootId, path: folderRepo.path, isMainWorktree: true }),
|
||||
expect.objectContaining({ id: instanceId, path: folderRepo.path, isMainWorktree: false })
|
||||
])
|
||||
expect(getSshGitProviderMock).not.toHaveBeenCalled()
|
||||
})
|
||||
|
||||
it('rejects metadata-only reads for non-SSH execution hosts', async () => {
|
||||
const result = await handlers['worktrees:listKnownForExecutionHost'](null, {
|
||||
repoId: 'repo-1',
|
||||
executionHostId: LOCAL_EXECUTION_HOST_ID
|
||||
})
|
||||
|
||||
expect(result).toEqual({
|
||||
status: 'rejected',
|
||||
repoId: 'repo-1',
|
||||
executionHostId: LOCAL_EXECUTION_HOST_ID
|
||||
})
|
||||
expect(store.getRepos).not.toHaveBeenCalled()
|
||||
})
|
||||
|
||||
it('rejects ambiguous metadata-only SSH owners', async () => {
|
||||
const sshHostId = toSshExecutionHostId('target-a')
|
||||
const sshRepo = {
|
||||
id: 'repo-1',
|
||||
path: '/remote/repo-a',
|
||||
displayName: 'repo',
|
||||
badgeColor: '#000',
|
||||
addedAt: 0,
|
||||
connectionId: 'target-a'
|
||||
}
|
||||
store.getRepos.mockReturnValue([sshRepo, { ...sshRepo, path: '/remote/repo-b' }])
|
||||
|
||||
const result = await handlers['worktrees:listKnownForExecutionHost'](null, {
|
||||
repoId: sshRepo.id,
|
||||
executionHostId: sshHostId
|
||||
})
|
||||
|
||||
expect(result).toEqual({
|
||||
status: 'rejected',
|
||||
repoId: sshRepo.id,
|
||||
executionHostId: sshHostId
|
||||
})
|
||||
expect(store.getAllWorktreeMeta).not.toHaveBeenCalled()
|
||||
expect(getSshGitProviderMock).not.toHaveBeenCalled()
|
||||
})
|
||||
|
||||
it('fails closed for duplicate exact owners and ambiguous legacy repo IDs', async () => {
|
||||
const sshRepo = {
|
||||
id: 'shared-repo',
|
||||
|
||||
@@ -49,7 +49,9 @@ import {
|
||||
import {
|
||||
PROVIDER_REQUEST_ID_MAX_UTF8_BYTES,
|
||||
type DirectSshDetectedWorktreeRequest,
|
||||
type HostQualifiedKnownWorktreeResult,
|
||||
type HostQualifiedDetectedWorktreeResult,
|
||||
type ListKnownWorktreesForExecutionHostArgs,
|
||||
type ListDetectedWorktreesArgs,
|
||||
type ProviderRequestId
|
||||
} from '../../shared/detected-worktree-provider-contract'
|
||||
@@ -229,7 +231,8 @@ import {
|
||||
import { DEFAULT_WORKSPACE_STATUS_ID } from '../../shared/workspace-statuses'
|
||||
import {
|
||||
FOLDER_WORKSPACE_INSTANCE_SEPARATOR,
|
||||
getRepoIdFromWorktreeId
|
||||
getRepoIdFromWorktreeId,
|
||||
getWorktreePathBasenameFromId
|
||||
} from '../../shared/worktree-id'
|
||||
import { prefetchWorktreeCreateBase } from '../worktree-create-base-prefetch'
|
||||
import {
|
||||
@@ -804,6 +807,17 @@ function createSshWorktreeMetaIndex(entries: [string, WorktreeMeta][]): SshWorkt
|
||||
return index
|
||||
}
|
||||
|
||||
// Why: scopes parseWorktreeId to one repo's keys. The entry list itself is still materialized for the whole
|
||||
// store, so this is cheaper per call than the unfiltered index, not free.
|
||||
function createSshWorktreeMetaIndexForRepo(
|
||||
allMeta: Record<string, WorktreeMeta>,
|
||||
repoId: string
|
||||
): SshWorktreeMetaIndex {
|
||||
return createSshWorktreeMetaIndex(
|
||||
Object.entries(allMeta).filter(([worktreeId]) => getRepoIdFromWorktreeId(worktreeId) === repoId)
|
||||
)
|
||||
}
|
||||
|
||||
function synthesizeSshGitWorktree(repo: Repo, path: string, meta: WorktreeMeta): GitWorktreeInfo {
|
||||
return {
|
||||
path,
|
||||
@@ -842,10 +856,15 @@ function listDisconnectedSshWorktrees(
|
||||
if (Object.keys(ownershipUpdates).length > 0) {
|
||||
store.setWorktreeMeta(candidate.id, ownershipUpdates)
|
||||
}
|
||||
// Why: synthesized rows carry no branch, so the title would fall through to the DESKTOP's basename()
|
||||
// applied to a REMOTE path — a Windows remote then renders its whole C:\... path as the name. Rows must
|
||||
// stay per-directory (repo.displayName would title every row identically), so use the separator-agnostic
|
||||
// basename instead.
|
||||
const worktree = mergeWorktree(
|
||||
repo.id,
|
||||
synthesizeSshGitWorktree(repo, candidate.path, meta),
|
||||
meta
|
||||
meta,
|
||||
getWorktreePathBasenameFromId(candidate.id) ?? undefined
|
||||
)
|
||||
byWorktreeId.delete(worktree.id)
|
||||
byWorktreeId.set(worktree.id, worktree)
|
||||
@@ -1826,6 +1845,7 @@ export function registerWorktreeHandlers(
|
||||
ipcMain.removeHandler('worktrees:listAll')
|
||||
ipcMain.removeHandler('worktrees:list')
|
||||
ipcMain.removeHandler('worktrees:listDetected')
|
||||
ipcMain.removeHandler('worktrees:listKnownForExecutionHost')
|
||||
ipcMain.removeHandler('worktrees:cancelListDetected')
|
||||
ipcMain.removeHandler('worktrees:create')
|
||||
ipcMain.removeHandler('worktrees:prefetchCreateBase')
|
||||
@@ -1970,6 +1990,66 @@ export function registerWorktreeHandlers(
|
||||
}
|
||||
})
|
||||
|
||||
ipcMain.handle(
|
||||
'worktrees:listKnownForExecutionHost',
|
||||
(_event, args: ListKnownWorktreesForExecutionHostArgs): HostQualifiedKnownWorktreeResult => {
|
||||
// Why: a malformed invoke must fail closed as `rejected`, not throw out of the handler. `ssh:` is inert —
|
||||
// it owns no repo, so every guard below still rejects it.
|
||||
const requestedRepoId = args?.repoId ?? ''
|
||||
const requestedExecutionHostId = args?.executionHostId ?? 'ssh:'
|
||||
const rejected = (): HostQualifiedKnownWorktreeResult => ({
|
||||
status: 'rejected',
|
||||
repoId: requestedRepoId,
|
||||
executionHostId: requestedExecutionHostId
|
||||
})
|
||||
const parsedHost = parseExecutionHostId(requestedExecutionHostId)
|
||||
if (parsedHost?.kind !== 'ssh') {
|
||||
return rejected()
|
||||
}
|
||||
// Why: findExactRepoOwner repeats this same all-candidates-owned check, and getRepos() re-hydrates the
|
||||
// whole catalog, so a separate pass here is pure cost.
|
||||
const repo = findExactRepoOwner(store, requestedRepoId, requestedExecutionHostId)
|
||||
if (!repo || repo.connectionId !== parsedHost.targetId) {
|
||||
return rejected()
|
||||
}
|
||||
const complete = (worktrees: DetectedWorktree[]): HostQualifiedKnownWorktreeResult => ({
|
||||
status: 'complete',
|
||||
repoId: repo.id,
|
||||
executionHostId: requestedExecutionHostId,
|
||||
result: {
|
||||
repoId: repo.id,
|
||||
authoritative: false,
|
||||
source: 'metadata-fallback',
|
||||
worktrees
|
||||
}
|
||||
})
|
||||
// Why: folder workspace ids carry an instance suffix the git-worktree synthesizer would read as a directory; build them the way every other listing does.
|
||||
if (isFolderRepo(repo)) {
|
||||
const folderWorkspaceIds = Object.keys(store.getAllWorktreeMeta()).filter((worktreeId) =>
|
||||
isFolderWorkspaceIdForRepo(repo, worktreeId)
|
||||
)
|
||||
return hasConflictingStoredWorktreeOwner(store, repo, folderWorkspaceIds)
|
||||
? rejected()
|
||||
: complete(
|
||||
// Why: match the authoritative folder listing; without lineage these rows render flat and then
|
||||
// reshuffle once the real scan lands.
|
||||
projectResolvedWorktreeLineage(
|
||||
buildFolderDetectedWorktrees(store, repo),
|
||||
store.getAllWorktreeLineage?.() ?? {}
|
||||
)
|
||||
)
|
||||
}
|
||||
const metaIndex = createSshWorktreeMetaIndexForRepo(store.getAllWorktreeMeta(), repo.id)
|
||||
return complete(
|
||||
buildDisconnectedDetectedWorktrees(
|
||||
store,
|
||||
repo,
|
||||
listDisconnectedSshWorktrees(store, repo, metaIndex)
|
||||
)
|
||||
)
|
||||
}
|
||||
)
|
||||
|
||||
ipcMain.handle(
|
||||
'worktrees:listDetected',
|
||||
async (
|
||||
|
||||
@@ -676,7 +676,6 @@ describe('Store', () => {
|
||||
const settings = store.getSettings()
|
||||
expect(settings.branchPrefix).toBe('git-username')
|
||||
expect(settings.refreshLocalBaseRefOnWorktreeCreate).toBe(false)
|
||||
expect(settings.sourceControlGroupOrder).toBe('changes-first')
|
||||
expect(settings.theme).toBe('system')
|
||||
expect(settings.appIcon).toBe('classic')
|
||||
expect(settings.appFontFamily).toBe('Geist')
|
||||
@@ -2784,21 +2783,6 @@ describe('Store', () => {
|
||||
expect(store.getSettings().terminalShortcutPolicy).toBe('orca-first')
|
||||
})
|
||||
|
||||
it('normalizes malformed source control group order on load', async () => {
|
||||
writeDataFile({
|
||||
schemaVersion: 1,
|
||||
repos: [],
|
||||
worktreeMeta: {},
|
||||
settings: { sourceControlGroupOrder: 'tracked-first' },
|
||||
ui: {},
|
||||
githubCache: { pr: {}, issue: {} },
|
||||
workspaceSession: {}
|
||||
})
|
||||
|
||||
const store = await createStore()
|
||||
expect(store.getSettings().sourceControlGroupOrder).toBe('changes-first')
|
||||
})
|
||||
|
||||
it('repairs drifted task provider defaults on load', async () => {
|
||||
writeDataFile({
|
||||
schemaVersion: 1,
|
||||
@@ -5817,15 +5801,21 @@ describe('Store', () => {
|
||||
expect(store.getSettings().sourceControlViewMode).toBe('tree')
|
||||
})
|
||||
|
||||
it('updateSettings persists sourceControlGroupOrder as a user setting', async () => {
|
||||
it('drops retired Source Control order preferences', async () => {
|
||||
writeDataFile({
|
||||
...getDefaultPersistedState(testState.dir),
|
||||
settings: {
|
||||
...getDefaultPersistedState(testState.dir).settings,
|
||||
sourceControlGroupOrder: 'changes-first',
|
||||
sourceControlHierarchyDefaultedV2: true
|
||||
}
|
||||
} as never)
|
||||
|
||||
const store = await createStore()
|
||||
expect(store.getSettings().sourceControlGroupOrder).toBe('changes-first')
|
||||
|
||||
store.updateSettings({ sourceControlGroupOrder: 'staged-first' })
|
||||
expect(store.getSettings().sourceControlGroupOrder).toBe('staged-first')
|
||||
|
||||
store.updateSettings({ sourceControlGroupOrder: 'tracked-first' as never })
|
||||
expect(store.getSettings().sourceControlGroupOrder).toBe('changes-first')
|
||||
store.flush()
|
||||
const persisted = readDataFile() as { settings?: Record<string, unknown> }
|
||||
expect(persisted.settings).not.toHaveProperty('sourceControlGroupOrder')
|
||||
expect(persisted.settings).not.toHaveProperty('sourceControlHierarchyDefaultedV2')
|
||||
})
|
||||
|
||||
it('updateSettings normalizes terminal shortcut policy', async () => {
|
||||
@@ -5883,18 +5873,15 @@ describe('Store', () => {
|
||||
|
||||
const store = await createStore()
|
||||
expect(store.getSettings().sourceControlViewMode).toBe('list')
|
||||
expect(store.getSettings().sourceControlGroupOrder).toBe('changes-first')
|
||||
|
||||
store.updateSettings({ sourceControlViewMode: 'tree', sourceControlGroupOrder: 'staged-first' })
|
||||
store.updateSettings({ sourceControlViewMode: 'tree' })
|
||||
store.flush()
|
||||
|
||||
const persisted = readDataFile() as {
|
||||
settings?: { sourceControlGroupOrder?: string; sourceControlViewMode?: string }
|
||||
settings?: { sourceControlViewMode?: string }
|
||||
workspaceSession?: typeof workspaceSession
|
||||
worktreeMeta?: Record<string, unknown>
|
||||
}
|
||||
expect(persisted.settings?.sourceControlViewMode).toBe('tree')
|
||||
expect(persisted.settings?.sourceControlGroupOrder).toBe('staged-first')
|
||||
expect(persisted.workspaceSession).toEqual({
|
||||
...getDefaultWorkspaceSession(),
|
||||
...workspaceSession
|
||||
@@ -5906,13 +5893,9 @@ describe('Store', () => {
|
||||
expect(collectPropertyPaths(persisted, 'sourceControlViewMode')).toEqual([
|
||||
'settings.sourceControlViewMode'
|
||||
])
|
||||
expect(collectPropertyPaths(persisted, 'sourceControlGroupOrder')).toEqual([
|
||||
'settings.sourceControlGroupOrder'
|
||||
])
|
||||
|
||||
const reloaded = await createStore()
|
||||
expect(reloaded.getSettings().sourceControlViewMode).toBe('tree')
|
||||
expect(reloaded.getSettings().sourceControlGroupOrder).toBe('staged-first')
|
||||
expect(reloaded.getWorkspaceSession().activeWorktreeId).toBe('repo1::/worktree-a')
|
||||
})
|
||||
|
||||
|
||||
+15
-21
@@ -182,7 +182,6 @@ import {
|
||||
} from '../shared/mobile-pairing-custom-address'
|
||||
import { normalizeOpenInApplications } from '../shared/open-in-applications'
|
||||
import { normalizeTerminalShortcutPolicy } from '../shared/keybindings'
|
||||
import { normalizeSourceControlGroupOrder } from '../shared/source-control-group-order'
|
||||
import { normalizeAppIconId } from '../shared/app-icon'
|
||||
import { normalizeTerminalCustomThemes } from '../shared/terminal-custom-themes'
|
||||
import {
|
||||
@@ -650,12 +649,22 @@ function readLegacyTerminalScrollbackSettings(settings: unknown): LegacyTerminal
|
||||
: {}
|
||||
}
|
||||
|
||||
function stripLegacyTerminalScrollbackBytes(
|
||||
function stripRetiredSettingsFields(
|
||||
settings: Partial<GlobalSettings> | undefined
|
||||
): Partial<GlobalSettings> {
|
||||
const { terminalScrollbackBytes: _legacyScrollbackBytes, ...rest } = (settings ??
|
||||
{}) as Partial<GlobalSettings> & { terminalScrollbackBytes?: unknown }
|
||||
const {
|
||||
terminalScrollbackBytes: _legacyScrollbackBytes,
|
||||
sourceControlGroupOrder: _sourceControlGroupOrder,
|
||||
sourceControlHierarchyDefaultedV2: _sourceControlHierarchyDefaultedV2,
|
||||
...rest
|
||||
} = (settings ?? {}) as Partial<GlobalSettings> & {
|
||||
terminalScrollbackBytes?: unknown
|
||||
sourceControlGroupOrder?: unknown
|
||||
sourceControlHierarchyDefaultedV2?: unknown
|
||||
}
|
||||
void _legacyScrollbackBytes
|
||||
void _sourceControlGroupOrder
|
||||
void _sourceControlHierarchyDefaultedV2
|
||||
return rest
|
||||
}
|
||||
|
||||
@@ -3316,15 +3325,6 @@ export class Store {
|
||||
) {
|
||||
this.loadNeedsSave = true
|
||||
}
|
||||
const normalizedSourceControlGroupOrder = normalizeSourceControlGroupOrder(
|
||||
parsed.settings?.sourceControlGroupOrder
|
||||
)
|
||||
if (
|
||||
parsed.settings?.sourceControlGroupOrder !== undefined &&
|
||||
parsed.settings.sourceControlGroupOrder !== normalizedSourceControlGroupOrder
|
||||
) {
|
||||
this.loadNeedsSave = true
|
||||
}
|
||||
result = {
|
||||
...defaults,
|
||||
...parsed,
|
||||
@@ -3346,7 +3346,7 @@ export class Store {
|
||||
settings: {
|
||||
...defaults.settings,
|
||||
// Why (#7977): keep persisted experimentalNewWorktreeCardStyle:true — v1.4.130's onboarding auto-wrote it as a plain boolean, so it's indistinguishable from a real opt-in; only the default changed.
|
||||
...stripLegacyTerminalScrollbackBytes(parsed.settings),
|
||||
...stripRetiredSettingsFields(parsed.settings),
|
||||
prBotAuthorOverrides: normalizePRBotAuthorOverrides(
|
||||
parsed.settings?.prBotAuthorOverrides
|
||||
),
|
||||
@@ -3420,7 +3420,6 @@ export class Store {
|
||||
}),
|
||||
notifications: normalizeNotificationSettings(parsed.settings?.notifications),
|
||||
sourceControlAi: migratedSourceControlAi,
|
||||
sourceControlGroupOrder: normalizedSourceControlGroupOrder,
|
||||
// Why: rollback builds still read commitMessageAi, so refresh the legacy projection from sourceControlAi for compat.
|
||||
commitMessageAi: projectSourceControlAiToLegacyCommitMessageAi(
|
||||
migratedSourceControlAi,
|
||||
@@ -5691,7 +5690,7 @@ export class Store {
|
||||
updates: Partial<GlobalSettings>,
|
||||
options: { notifyListeners?: boolean; originWebContentsId?: number } = {}
|
||||
): GlobalSettings {
|
||||
const sanitizedUpdates = stripLegacyTerminalScrollbackBytes(updates)
|
||||
const sanitizedUpdates = stripRetiredSettingsFields(updates)
|
||||
// Why: coerce to boolean here (not the IPC edge) so every write path is covered and a truthy non-bool can't persist as "tray-minimize on".
|
||||
if ('minimizeToTrayOnClose' in updates) {
|
||||
sanitizedUpdates.minimizeToTrayOnClose = updates.minimizeToTrayOnClose === true
|
||||
@@ -5759,11 +5758,6 @@ export class Store {
|
||||
updates.terminalShortcutPolicy
|
||||
)
|
||||
}
|
||||
if ('sourceControlGroupOrder' in updates) {
|
||||
sanitizedUpdates.sourceControlGroupOrder = normalizeSourceControlGroupOrder(
|
||||
updates.sourceControlGroupOrder
|
||||
)
|
||||
}
|
||||
if ('appIcon' in updates) {
|
||||
sanitizedUpdates.appIcon = normalizeAppIconId(updates.appIcon)
|
||||
}
|
||||
|
||||
@@ -3,5 +3,7 @@ export type GitProviderStatusOptions = {
|
||||
includeIgnored?: boolean
|
||||
bypassEffectiveUpstreamNegativeCache?: boolean
|
||||
reuseLineStats?: boolean
|
||||
/** Merge-base OID to measure the branch line total against; omit to skip the work. */
|
||||
branchLineTotalMergeBase?: string
|
||||
signal?: AbortSignal
|
||||
}
|
||||
|
||||
@@ -129,11 +129,16 @@ export class SshGitProvider implements IGitProvider {
|
||||
? { bypassEffectiveUpstreamNegativeCache: true }
|
||||
: {}
|
||||
const lineStatsReuseArgs = options?.reuseLineStats ? { reuseLineStats: true } : {}
|
||||
const branchLineTotalArgs =
|
||||
options?.branchLineTotalMergeBase === undefined
|
||||
? {}
|
||||
: { branchLineTotalMergeBase: options.branchLineTotalMergeBase }
|
||||
const request = {
|
||||
worktreePath,
|
||||
...includeIgnoredArgs,
|
||||
...upstreamCacheBypassArgs,
|
||||
...lineStatsReuseArgs
|
||||
...lineStatsReuseArgs,
|
||||
...branchLineTotalArgs
|
||||
}
|
||||
return (await (options?.signal
|
||||
? this.mux.request('git.status', request, { signal: options.signal })
|
||||
|
||||
@@ -38,6 +38,7 @@ import {
|
||||
resolveRuntimePath
|
||||
} from '../../shared/cross-platform-path'
|
||||
import { PhysicalExitTracker } from '../../shared/physical-exit-tracker'
|
||||
import { sortDirEntries } from '../../shared/file-name-sort'
|
||||
import type {
|
||||
RuntimeFileListResult,
|
||||
RuntimeFileOpenResult,
|
||||
@@ -1292,7 +1293,9 @@ export class RuntimeFileCommands {
|
||||
if (!provider) {
|
||||
throw new Error(SSH_FILESYSTEM_PROVIDER_UNAVAILABLE_MESSAGE)
|
||||
}
|
||||
return provider.readDir(target.path)
|
||||
// Why: re-sort locally — the remote relay may be an older build with
|
||||
// lexicographic ordering.
|
||||
return sortDirEntries(await provider.readDir(target.path))
|
||||
}
|
||||
|
||||
const dirPath = await resolveAuthorizedPath(target.path, this.host.requireStore())
|
||||
@@ -1307,12 +1310,7 @@ export class RuntimeFileCommands {
|
||||
}
|
||||
})
|
||||
)
|
||||
return mapped.sort((a, b) => {
|
||||
if (a.isDirectory !== b.isDirectory) {
|
||||
return a.isDirectory ? -1 : 1
|
||||
}
|
||||
return a.name.localeCompare(b.name)
|
||||
})
|
||||
return sortDirEntries(mapped)
|
||||
}
|
||||
|
||||
async watchFileExplorer(
|
||||
|
||||
@@ -13948,6 +13948,212 @@ describe('OrcaRuntimeService', () => {
|
||||
)
|
||||
})
|
||||
|
||||
// Why (#10333): `orca serve` publishes a ready graph under
|
||||
// HEADLESS_RUNTIME_WINDOW_ID with no BrowserWindow behind it, so every
|
||||
// focus-requested create used to fall through to getAuthoritativeWindow().
|
||||
const wireHeadlessServeRuntime = (): OrcaRuntimeService => {
|
||||
const runtime = new OrcaRuntimeService(store)
|
||||
electronMocks.BrowserWindow.fromId.mockReturnValue(null as never)
|
||||
runtime.syncWindowGraph(HEADLESS_RUNTIME_WINDOW_ID, { tabs: [], leaves: [] })
|
||||
return runtime
|
||||
}
|
||||
|
||||
it('spawns focus-requested CLI terminal creates in background on headless serve', async () => {
|
||||
const spawn = vi.fn().mockResolvedValue({ id: 'pty-focus-headless' })
|
||||
const runtime = wireHeadlessServeRuntime()
|
||||
const revealTerminalSession = vi.fn()
|
||||
runtime.setNotifier({
|
||||
worktreesChanged: vi.fn(),
|
||||
reposChanged: vi.fn(),
|
||||
activateWorktree: vi.fn(),
|
||||
createTerminal: vi.fn(),
|
||||
revealTerminalSession,
|
||||
splitTerminal: vi.fn(),
|
||||
renameTerminal: vi.fn(),
|
||||
focusTerminal: vi.fn(),
|
||||
closeTerminal: vi.fn(),
|
||||
sleepWorktree: vi.fn(),
|
||||
resumeSleepingAgents: vi.fn(),
|
||||
terminalFitOverrideChanged: vi.fn(),
|
||||
terminalDriverChanged: vi.fn()
|
||||
} as never)
|
||||
runtime.setPtyController({
|
||||
spawn,
|
||||
write: () => true,
|
||||
kill: () => true,
|
||||
getForegroundProcess: async () => null
|
||||
})
|
||||
|
||||
// `orca terminal create --worktree <wt> --command "echo test" --focus`
|
||||
await expect(
|
||||
runtime.createTerminal(`path:${TEST_WORKTREE_PATH}`, {
|
||||
command: 'echo test',
|
||||
focus: true
|
||||
})
|
||||
).resolves.toMatchObject({
|
||||
worktreeId: TEST_WORKTREE_ID,
|
||||
handle: expect.stringMatching(/^term_/)
|
||||
})
|
||||
expect(spawn).toHaveBeenCalledWith(
|
||||
expect.objectContaining({
|
||||
command: 'echo test',
|
||||
cwd: TEST_WORKTREE_PATH,
|
||||
worktreeId: TEST_WORKTREE_ID,
|
||||
persistHostSessionBinding: true
|
||||
})
|
||||
)
|
||||
// Why: degrading the create must not silently drop the focus request — the
|
||||
// host still asks whatever surface exists to activate the new pane.
|
||||
expect(revealTerminalSession).toHaveBeenCalledWith(
|
||||
TEST_WORKTREE_ID,
|
||||
expect.objectContaining({ activate: true, presentation: 'focused' })
|
||||
)
|
||||
})
|
||||
|
||||
it('spawns presentation:focused terminal creates in background on headless serve', async () => {
|
||||
const spawn = vi.fn().mockResolvedValue({ id: 'pty-presentation-headless' })
|
||||
const runtime = wireHeadlessServeRuntime()
|
||||
runtime.setPtyController({
|
||||
spawn,
|
||||
write: () => true,
|
||||
kill: () => true,
|
||||
getForegroundProcess: async () => null
|
||||
})
|
||||
|
||||
// Paired desktop `+` button: clients send presentation:'focused', not focus.
|
||||
await expect(
|
||||
runtime.createTerminal(`path:${TEST_WORKTREE_PATH}`, {
|
||||
command: 'codex',
|
||||
presentation: 'focused'
|
||||
})
|
||||
).resolves.toMatchObject({
|
||||
worktreeId: TEST_WORKTREE_ID,
|
||||
surface: 'background',
|
||||
handle: expect.stringMatching(/^term_/)
|
||||
})
|
||||
expect(spawn).toHaveBeenCalled()
|
||||
})
|
||||
|
||||
it('spawns focused agent-session creates in background on headless serve', async () => {
|
||||
// Why: RPC only downgrades `focused` for clients that report a clientKind
|
||||
// (#10193), so loopback/CLI callers still reach the runtime asking for focus.
|
||||
const spawn = vi.fn().mockResolvedValue({ id: 'pty-agent-headless' })
|
||||
const runtime = wireHeadlessServeRuntime()
|
||||
runtime.setPtyController({
|
||||
spawn,
|
||||
write: () => true,
|
||||
kill: () => true,
|
||||
getForegroundProcess: async () => null
|
||||
})
|
||||
|
||||
await expect(
|
||||
runtime.createAgentSession({
|
||||
clientOperationId: `${Date.now()}-0123456789abcdef0123456789abcdef`,
|
||||
worktree: `path:${TEST_WORKTREE_PATH}`,
|
||||
agent: 'codex',
|
||||
prompt: 'hello',
|
||||
presentation: 'focused'
|
||||
})
|
||||
).resolves.toMatchObject({
|
||||
disposition: 'created',
|
||||
terminal: expect.objectContaining({
|
||||
worktreeId: TEST_WORKTREE_ID,
|
||||
handle: expect.stringMatching(/^term_/)
|
||||
})
|
||||
})
|
||||
expect(spawn).toHaveBeenCalled()
|
||||
})
|
||||
|
||||
it('activates the paired mobile session tab for a degraded focused headless create', async () => {
|
||||
const spawn = vi.fn().mockResolvedValue({ id: 'pty-focus-publish' })
|
||||
const runtime = wireHeadlessServeRuntime()
|
||||
runtime.setPtyController({
|
||||
spawn,
|
||||
write: () => true,
|
||||
kill: () => true,
|
||||
getForegroundProcess: async () => null
|
||||
})
|
||||
|
||||
const created = await runtime.createTerminal(`path:${TEST_WORKTREE_PATH}`, {
|
||||
command: 'echo test',
|
||||
focus: true
|
||||
})
|
||||
|
||||
// Why: with no renderer notifier on a serve host, the session-tab publish is
|
||||
// the only channel a paired client learns about the terminal on — a degraded
|
||||
// focused create must still land there selected, or focus is silently lost.
|
||||
const tabs = await runtime.listMobileSessionTabs(`id:${TEST_WORKTREE_ID}`)
|
||||
const published = tabs.tabs.find(
|
||||
(tab) => tab.type === 'terminal' && tab.parentTabId === created.tabId
|
||||
)
|
||||
expect(published).toBeDefined()
|
||||
expect(published?.isActive).toBe(true)
|
||||
expect(tabs.activeTabId).toBe(published?.id)
|
||||
})
|
||||
|
||||
it('keeps focus-requested terminal creates on the renderer when a window exists', async () => {
|
||||
const spawn = vi.fn().mockResolvedValue({ id: 'pty-should-not-spawn' })
|
||||
const webContents = { send: vi.fn() }
|
||||
const send = vi.fn((_channel: string, payload: { requestId: string }) => {
|
||||
runtime.syncWindowGraph(1, {
|
||||
tabs: [
|
||||
{
|
||||
tabId: 'tab-focused',
|
||||
worktreeId: TEST_WORKTREE_ID,
|
||||
title: 'Focused Terminal',
|
||||
activeLeafId: 'pane:1',
|
||||
layout: null
|
||||
}
|
||||
],
|
||||
leaves: [
|
||||
{
|
||||
tabId: 'tab-focused',
|
||||
worktreeId: TEST_WORKTREE_ID,
|
||||
leafId: 'pane:1',
|
||||
paneRuntimeId: 1,
|
||||
ptyId: 'pty-focused',
|
||||
paneTitle: null
|
||||
}
|
||||
]
|
||||
})
|
||||
ipcMain.emit(
|
||||
'terminal:tabCreateReply',
|
||||
{ sender: webContents },
|
||||
{ requestId: payload.requestId, tabId: 'tab-focused', title: 'Focused Terminal' }
|
||||
)
|
||||
})
|
||||
webContents.send = send
|
||||
const runtime = new OrcaRuntimeService(store)
|
||||
runtime.attachWindow(1)
|
||||
runtime.syncWindowGraph(1, { tabs: [], leaves: [] })
|
||||
electronMocks.BrowserWindow.fromId.mockReturnValue({
|
||||
isDestroyed: () => false,
|
||||
webContents
|
||||
})
|
||||
runtime.setPtyController({
|
||||
spawn,
|
||||
write: () => true,
|
||||
kill: () => true,
|
||||
getForegroundProcess: async () => null
|
||||
})
|
||||
|
||||
await expect(
|
||||
runtime.createTerminal(`id:${TEST_WORKTREE_ID}`, {
|
||||
command: 'echo test',
|
||||
focus: true
|
||||
})
|
||||
).resolves.toMatchObject({
|
||||
tabId: 'tab-focused',
|
||||
worktreeId: TEST_WORKTREE_ID,
|
||||
surface: 'visible'
|
||||
})
|
||||
expect(send).toHaveBeenCalledWith(
|
||||
'terminal:requestTabCreate',
|
||||
expect.objectContaining({ activate: true, presentation: 'focused' })
|
||||
)
|
||||
expect(spawn).not.toHaveBeenCalled()
|
||||
})
|
||||
|
||||
it('accepts renderer-backed terminal create replies only from the target renderer', async () => {
|
||||
const webContents = { send: vi.fn() }
|
||||
const send = vi.fn((_channel: string, payload: { requestId: string }) => {
|
||||
|
||||
@@ -10,6 +10,7 @@ import {
|
||||
normalizeTerminalTitle
|
||||
} from '../../shared/agent-detection'
|
||||
import { extractOscTitleScanTail } from '../../shared/osc-title-scan-tail'
|
||||
import { sortDirEntries } from '../../shared/file-name-sort'
|
||||
import { isServerDriveListRequest, listWindowsDrives } from './windows-drive-listing'
|
||||
import { extractLastOsc7Uri, extractOscScanTail } from '../daemon/osc7-uri-extraction'
|
||||
import { parseFileUriPathParts } from '../daemon/osc7-file-uri'
|
||||
@@ -10762,10 +10763,7 @@ export class OrcaRuntimeService {
|
||||
// A spawn published (or admission pending) this generation already
|
||||
// attaches the provider stream; a replacement under a reused id must not
|
||||
// read as the discovered never-attached session it replaced.
|
||||
if (
|
||||
this.spawnPublishedPtys.has(ptyId) ||
|
||||
this.pendingPtyRegistrationIncarnations.has(ptyId)
|
||||
) {
|
||||
if (this.spawnPublishedPtys.has(ptyId) || this.pendingPtyRegistrationIncarnations.has(ptyId)) {
|
||||
return false
|
||||
}
|
||||
// SSH panes have their own lease/reattach machinery.
|
||||
@@ -18253,12 +18251,7 @@ export class OrcaRuntimeService {
|
||||
isDirectory: entry.isDirectory(),
|
||||
isSymlink: entry.isSymbolicLink()
|
||||
}))
|
||||
mapped.sort((a, b) => {
|
||||
if (a.isDirectory !== b.isDirectory) {
|
||||
return a.isDirectory ? -1 : 1
|
||||
}
|
||||
return a.name.localeCompare(b.name)
|
||||
})
|
||||
sortDirEntries(mapped)
|
||||
return {
|
||||
resolvedPath: dirPath,
|
||||
entries: mapped,
|
||||
@@ -25061,9 +25054,12 @@ export class OrcaRuntimeService {
|
||||
(Boolean(opts.agentSessionClaim) ||
|
||||
(!requiresRendererFocus && opts.rendererBacked !== true) ||
|
||||
// Why: `orca serve` exposes the local runtime without a renderer
|
||||
// window. Renderer-backed Codex terminals are preferred for the app,
|
||||
// but headless CLI users still need a usable terminal handle.
|
||||
(opts.rendererBacked === true && rendererWindow === null))
|
||||
// window. Renderer-backed and focus-requested creates are preferred on
|
||||
// the renderer, but with no window a background spawn is the only
|
||||
// usable path — otherwise getAuthoritativeWindow() below throws and the
|
||||
// caller gets no terminal at all (#10333). Focus is not lost: the
|
||||
// spawned pane is still published and revealed with `activate`.
|
||||
availableAuthoritativeWindow === null)
|
||||
|
||||
if (shouldCreateInBackground) {
|
||||
if (!this.ptyController?.spawn) {
|
||||
|
||||
@@ -10,7 +10,9 @@ export const WorktreeSelector = z.object({
|
||||
export const GitStatusParams = WorktreeSelector.extend({
|
||||
includeIgnored: z.boolean().optional(),
|
||||
bypassEffectiveUpstreamNegativeCache: z.boolean().optional(),
|
||||
reuseLineStats: z.boolean().optional()
|
||||
reuseLineStats: z.boolean().optional(),
|
||||
// Shape is re-validated host-side before it reaches a git argv.
|
||||
branchLineTotalMergeBase: z.string().optional()
|
||||
})
|
||||
|
||||
export const GitCheckIgnored = WorktreeSelector.extend({
|
||||
|
||||
@@ -93,6 +93,7 @@ export const GIT_METHODS: RpcMethod[] = [
|
||||
params.includeIgnored === undefined &&
|
||||
params.bypassEffectiveUpstreamNegativeCache === undefined &&
|
||||
params.reuseLineStats === undefined &&
|
||||
params.branchLineTotalMergeBase === undefined &&
|
||||
signal === undefined
|
||||
? undefined
|
||||
: {
|
||||
@@ -103,6 +104,9 @@ export const GIT_METHODS: RpcMethod[] = [
|
||||
? { bypassEffectiveUpstreamNegativeCache: true }
|
||||
: {}),
|
||||
...(params.reuseLineStats === true ? { reuseLineStats: true } : {}),
|
||||
...(params.branchLineTotalMergeBase === undefined
|
||||
? {}
|
||||
: { branchLineTotalMergeBase: params.branchLineTotalMergeBase }),
|
||||
...(signal ? { signal } : {})
|
||||
}
|
||||
return options === undefined
|
||||
|
||||
@@ -30,8 +30,10 @@ import type { ReadClipboardTextOptions } from '../shared/clipboard-text'
|
||||
import type { AppIdentity } from '../shared/app-identity'
|
||||
import type { ReleaseChannel } from '../shared/release-channel'
|
||||
import type {
|
||||
HostQualifiedKnownWorktreeResult,
|
||||
HostQualifiedDetectedWorktreeResult,
|
||||
LegacyDetectedWorktreeRequest,
|
||||
ListKnownWorktreesForExecutionHostArgs,
|
||||
ListDetectedWorktreesArgs,
|
||||
ProviderRequestId
|
||||
} from '../shared/detected-worktree-provider-contract'
|
||||
@@ -1399,6 +1401,9 @@ export type PreloadApi = {
|
||||
): Promise<HostQualifiedDetectedWorktreeResult | DetectedWorktreeListResult>
|
||||
(args: LegacyDetectedWorktreeRequest): Promise<DetectedWorktreeListResult>
|
||||
}
|
||||
listKnownForExecutionHost?: (
|
||||
args: ListKnownWorktreesForExecutionHostArgs
|
||||
) => Promise<HostQualifiedKnownWorktreeResult>
|
||||
cancelListDetected?: (args: { providerRequestId: ProviderRequestId }) => Promise<void>
|
||||
listAll: () => Promise<Worktree[]>
|
||||
create: (args: CreateWorktreeArgs) => Promise<CreateWorktreeResult>
|
||||
@@ -2898,6 +2903,8 @@ export type PreloadApi = {
|
||||
includeIgnored?: boolean
|
||||
bypassEffectiveUpstreamNegativeCache?: boolean
|
||||
reuseLineStats?: boolean
|
||||
/** Merge-base OID to measure the branch line total against; omit to skip the work. */
|
||||
branchLineTotalMergeBase?: string
|
||||
requestToken?: string
|
||||
}) => Promise<GitStatusResult>
|
||||
cancelStatus: (args: { requestToken: string }) => Promise<void>
|
||||
|
||||
@@ -766,6 +766,9 @@ const api = {
|
||||
|
||||
listDetected: (args) => ipcRenderer.invoke('worktrees:listDetected', args),
|
||||
|
||||
listKnownForExecutionHost: (args) =>
|
||||
ipcRenderer.invoke('worktrees:listKnownForExecutionHost', args),
|
||||
|
||||
cancelListDetected: (args) => ipcRenderer.invoke('worktrees:cancelListDetected', args),
|
||||
|
||||
listAll: () => ipcRenderer.invoke('worktrees:listAll'),
|
||||
@@ -3230,6 +3233,7 @@ const api = {
|
||||
includeIgnored?: boolean
|
||||
bypassEffectiveUpstreamNegativeCache?: boolean
|
||||
reuseLineStats?: boolean
|
||||
branchLineTotalMergeBase?: string
|
||||
requestToken?: string
|
||||
}): Promise<unknown> => ipcRenderer.invoke('git:status', args),
|
||||
cancelStatus: (args: { requestToken: string }): Promise<void> =>
|
||||
|
||||
@@ -68,6 +68,16 @@ describe('native preload SSH authority forwarding', () => {
|
||||
>()
|
||||
})
|
||||
|
||||
it('forwards host-qualified metadata-only worktree reads', async () => {
|
||||
await import('./index')
|
||||
const api = exposeInMainWorld.mock.calls.find(([name]) => name === 'api')?.[1] as PreloadApi
|
||||
const args = { repoId: 'repo-1', executionHostId: 'ssh:ssh-1' as const }
|
||||
|
||||
await api.worktrees.listKnownForExecutionHost?.(args)
|
||||
|
||||
expect(invoke).toHaveBeenCalledWith('worktrees:listKnownForExecutionHost', args)
|
||||
})
|
||||
|
||||
it('forwards full-pair get and push states without cloning away authority', async () => {
|
||||
const state: SshConnectionState = {
|
||||
targetId: 'ssh-1',
|
||||
|
||||
@@ -164,19 +164,23 @@ describe('FsHandler', () => {
|
||||
await expect(dispatcher.callRequest('fs.tempDir')).resolves.toBe(tmpdir())
|
||||
})
|
||||
|
||||
it('readDir returns sorted entries with directories first', async () => {
|
||||
it('readDir returns entries directories-first in natural name order', async () => {
|
||||
mkdirSync(path.join(tmpDir, 'subdir'))
|
||||
writeFileSync(path.join(tmpDir, 'file.txt'), 'hello')
|
||||
writeFileSync(path.join(tmpDir, 'aaa.txt'), 'world')
|
||||
for (const name of ['file.txt', '100 - b.txt', '99 - a.txt', '9 - c.txt']) {
|
||||
writeFileSync(path.join(tmpDir, name), 'x')
|
||||
}
|
||||
|
||||
const result = (await dispatcher.callRequest('fs.readDir', { dirPath: tmpDir })) as {
|
||||
name: string
|
||||
isDirectory: boolean
|
||||
}[]
|
||||
expect(result[0].name).toBe('subdir')
|
||||
expect(result[0].isDirectory).toBe(true)
|
||||
expect(result.find((e) => e.name === 'file.txt')).toBeDefined()
|
||||
expect(result.find((e) => e.name === 'aaa.txt')).toBeDefined()
|
||||
expect(result[0]).toMatchObject({ name: 'subdir', isDirectory: true })
|
||||
expect(result.slice(1).map((e) => e.name)).toEqual([
|
||||
'9 - c.txt',
|
||||
'99 - a.txt',
|
||||
'100 - b.txt',
|
||||
'file.txt'
|
||||
])
|
||||
})
|
||||
|
||||
it('readDir reports symlinked directories as directories', async () => {
|
||||
|
||||
@@ -5,6 +5,7 @@ import { execFile } from 'node:child_process'
|
||||
import { tmpdir } from 'node:os'
|
||||
import { join } from 'node:path'
|
||||
import type { RelayDispatcher, RequestContext } from './dispatcher'
|
||||
import { sortDirEntries } from '../shared/file-name-sort'
|
||||
import type { RelayContext } from './context'
|
||||
// Why: RelayContext is accepted in the constructor for protocol back-compat
|
||||
// (see docs/relay-fs-allowlist-removal.md), but no longer consulted on FS ops.
|
||||
@@ -148,12 +149,7 @@ export class FsHandler {
|
||||
isSymlink: entry.isSymbolicLink()
|
||||
}))
|
||||
)
|
||||
return mapped.sort((a, b) => {
|
||||
if (a.isDirectory !== b.isDirectory) {
|
||||
return a.isDirectory ? -1 : 1
|
||||
}
|
||||
return a.name.localeCompare(b.name)
|
||||
})
|
||||
return sortDirEntries(mapped)
|
||||
}
|
||||
|
||||
private async readFile(params: Record<string, unknown>) {
|
||||
|
||||
@@ -24,6 +24,11 @@ import {
|
||||
clearGitStatusLineStatsCacheKey,
|
||||
reuseOrRecomputeGitStatusLineStats
|
||||
} from '../shared/git-status-line-stats-cache'
|
||||
import {
|
||||
readGitBranchLineTotalMergeBaseParam,
|
||||
type GitBranchLineTotal
|
||||
} from '../shared/git-branch-line-total'
|
||||
import { buildBranchLineTotalInput } from './git-status-branch-line-total'
|
||||
|
||||
export async function resolveGitDir(worktreePath: string): Promise<string> {
|
||||
const dotGitPath = path.join(worktreePath, '.git')
|
||||
@@ -74,11 +79,16 @@ export async function getStatusOp(
|
||||
ignoredPaths?: string[]
|
||||
didHitLimit?: boolean
|
||||
statusLength?: number
|
||||
branchLineTotal?: GitBranchLineTotal
|
||||
}> {
|
||||
const worktreePath = params.worktreePath as string
|
||||
const lineStatsCacheKey = `relay\0${worktreePath}`
|
||||
const lineStatsWriteToken = beginGitStatusLineStatsCacheWrite(lineStatsCacheKey)
|
||||
const includeIgnored = params.includeIgnored === true
|
||||
// Why: untrusted RPC input spliced into a git argv — only an OID shape may pass.
|
||||
const branchLineTotalMergeBase = readGitBranchLineTotalMergeBaseParam(
|
||||
params.branchLineTotalMergeBase
|
||||
)
|
||||
// Why: reject NaN/negative limits — NaN would silently disable capping, negatives would over-truncate.
|
||||
const limit = resolveGitStatusLimit(params.limit)
|
||||
const conflictOperation = await detectConflictOperation(worktreePath)
|
||||
@@ -89,6 +99,8 @@ export async function getStatusOp(
|
||||
let ignoredPaths: string[] = []
|
||||
let didHitLimit = false
|
||||
let statusLength = 0
|
||||
let statusSucceeded = false
|
||||
let branchLineTotal: GitBranchLineTotal | undefined
|
||||
|
||||
try {
|
||||
// Why: core.quotePath=false keeps non-ASCII filenames as raw UTF-8 instead of octal escapes that render as gibberish.
|
||||
@@ -118,6 +130,7 @@ export async function getStatusOp(
|
||||
ignoredPaths = parser.ignoredPaths
|
||||
statusLength = parser.statusLength
|
||||
didHitLimit = stoppedEarly
|
||||
statusSucceeded = true
|
||||
const { upstreamName, upstreamAheadBehind } = parser.branch
|
||||
upstreamStatus = upstreamName
|
||||
? {
|
||||
@@ -173,15 +186,26 @@ export async function getStatusOp(
|
||||
|
||||
// Why: skip numstat after the limit to avoid reintroducing its cost.
|
||||
if (!didHitLimit) {
|
||||
await reuseOrRecomputeGitStatusLineStats({
|
||||
const branchLineTotalInput = buildBranchLineTotalInput(
|
||||
git,
|
||||
worktreePath,
|
||||
entries,
|
||||
// Why: a failed scan leaves the untracked list untrustworthy, so the total
|
||||
// would under-count — omit it rather than publish a confident wrong number.
|
||||
statusSucceeded ? branchLineTotalMergeBase : undefined,
|
||||
options.signal
|
||||
)
|
||||
// Why: passed in so the ranged diff runs alongside the per-area numstats, not after them.
|
||||
;({ branchLineTotal } = await reuseOrRecomputeGitStatusLineStats({
|
||||
cacheKey: lineStatsCacheKey,
|
||||
head,
|
||||
entries,
|
||||
writeToken: lineStatsWriteToken,
|
||||
reuse: params.reuseLineStats === true,
|
||||
isAborted: () => options.signal?.aborted === true,
|
||||
recompute: () => attachLineStats(git, worktreePath, entries, options.signal)
|
||||
})
|
||||
recompute: () => attachLineStats(git, worktreePath, entries, options.signal),
|
||||
...(branchLineTotalInput ? { branchLineTotal: branchLineTotalInput } : {})
|
||||
}))
|
||||
} else {
|
||||
clearGitStatusLineStatsCacheKey(lineStatsCacheKey, lineStatsWriteToken)
|
||||
}
|
||||
@@ -200,7 +224,8 @@ export async function getStatusOp(
|
||||
branch,
|
||||
upstreamStatus,
|
||||
...(includeIgnored ? { ignoredPaths } : {}),
|
||||
...(didHitLimit ? { didHitLimit: true, statusLength } : {})
|
||||
...(didHitLimit ? { didHitLimit: true, statusLength } : {}),
|
||||
...(branchLineTotal ? { branchLineTotal } : {})
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -89,6 +89,7 @@ import { GitResponseStreamRegistry } from './git-response-stream'
|
||||
import { GIT_RESPONSE_STREAM_THRESHOLD } from './protocol'
|
||||
import { endSubprocessStdin } from '../shared/subprocess-stdin-write'
|
||||
import { clearGitStatusLineStatsCache } from '../shared/git-status-line-stats-cache'
|
||||
import { invalidateGitBranchLineTotalInFlight } from '../shared/git-branch-line-total'
|
||||
import { streamRelayGitStdout } from './git-stdout-stream'
|
||||
|
||||
const execFileAsync = promisify(execFile)
|
||||
@@ -298,6 +299,7 @@ export class GitHandler {
|
||||
|
||||
private clearGitMutationReadCaches(): void {
|
||||
this.gitDiffReadDedupe.clear()
|
||||
invalidateGitBranchLineTotalInFlight()
|
||||
clearGitStatusLineStatsCache()
|
||||
clearSubmodulePathsCache(this.submodulePathsCache)
|
||||
}
|
||||
|
||||
@@ -0,0 +1,475 @@
|
||||
/**
|
||||
* Relay-side contract for the status pass's branch line total: the chip is an
|
||||
* exact number or absent, and costs nothing when nobody asked for it.
|
||||
*/
|
||||
import { execFile, execFileSync } from 'node:child_process'
|
||||
import { mkdtempSync } from 'node:fs'
|
||||
import * as fs from 'node:fs/promises'
|
||||
import { tmpdir } from 'node:os'
|
||||
import * as path from 'node:path'
|
||||
import { promisify } from 'node:util'
|
||||
import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest'
|
||||
import { invalidateGitBranchLineTotalInFlight } from '../shared/git-branch-line-total'
|
||||
import { clearGitStatusLineStatsCache } from '../shared/git-status-line-stats-cache'
|
||||
import type { GitExec } from './git-handler-ops'
|
||||
import { getStatusOp } from './git-handler-status-ops'
|
||||
import type { RelayGitStreamExec } from './git-stdout-stream'
|
||||
import { clearNoEffectiveUpstreamStatusCache } from './git-status-upstream-negative-cache'
|
||||
|
||||
type GitCall = Parameters<GitExec>
|
||||
|
||||
const execFileAsync = promisify(execFile)
|
||||
const MERGE_BASE = '0123456789abcdef0123456789abcdef01234567'
|
||||
const OTHER_MERGE_BASE = 'fedcba9876543210fedcba9876543210fedcba98'
|
||||
// Untracked additions ride on top of the ranged diff, so the fixture needs a real file.
|
||||
const UNTRACKED_FILE = 'notes.md'
|
||||
const UNTRACKED_LINES = 4
|
||||
const STATUS_OUTPUT = [
|
||||
'# branch.oid 1111111111111111111111111111111111111111',
|
||||
'# branch.head (detached)',
|
||||
'1 .M N... 100644 100644 100644 aaaa aaaa src/a.ts',
|
||||
`? ${UNTRACKED_FILE}`
|
||||
].join('\n')
|
||||
|
||||
function isRangedNumstat(args: string[]): boolean {
|
||||
return args.includes('diff') && args.includes('--numstat') && args.includes('-z')
|
||||
}
|
||||
|
||||
function rangedDiffCalls(calls: readonly GitCall[]): string[][] {
|
||||
return calls.map(([args]) => args).filter((args) => isRangedNumstat(args))
|
||||
}
|
||||
|
||||
function streamGitFromCapture(git: GitExec): RelayGitStreamExec {
|
||||
return async (args, cwd, options) => {
|
||||
const { stdout } = await git(args, cwd, {
|
||||
disableOptionalLocks: options.disableOptionalLocks,
|
||||
signal: options.signal
|
||||
})
|
||||
return { stoppedEarly: options.onStdout(stdout) === true }
|
||||
}
|
||||
}
|
||||
|
||||
/** Mock host; the fixture-repo cases below run real git instead. */
|
||||
function createMockGit(overrides: {
|
||||
status?: string
|
||||
ranged?: () => Promise<{ stdout: string; stderr: string }>
|
||||
areaNumstat?: string
|
||||
}) {
|
||||
return vi.fn<GitExec>(async (args) => {
|
||||
if (args.includes('status')) {
|
||||
return { stdout: overrides.status ?? STATUS_OUTPUT, stderr: '' }
|
||||
}
|
||||
if (isRangedNumstat(args)) {
|
||||
return overrides.ranged ? overrides.ranged() : { stdout: '12\t5\tsrc/a.ts\n', stderr: '' }
|
||||
}
|
||||
if (args.includes('diff')) {
|
||||
return { stdout: overrides.areaNumstat ?? '3\t2\tsrc/a.ts\n', stderr: '' }
|
||||
}
|
||||
throw new Error(`Unexpected git command: ${args.join(' ')}`)
|
||||
})
|
||||
}
|
||||
|
||||
const realGitExec: GitExec = async (args, cwd, opts) => {
|
||||
const { stdout, stderr } = await execFileAsync('git', args, {
|
||||
cwd,
|
||||
encoding: 'utf8',
|
||||
...(opts?.signal ? { signal: opts.signal } : {}),
|
||||
...(opts?.timeout ? { timeout: opts.timeout } : {})
|
||||
})
|
||||
return { stdout, stderr }
|
||||
}
|
||||
|
||||
function runFixtureGit(repo: string, args: string[]): string {
|
||||
return execFileSync(
|
||||
'git',
|
||||
[
|
||||
'-c',
|
||||
'user.email=test@test.com',
|
||||
'-c',
|
||||
'user.name=Test',
|
||||
'-c',
|
||||
'commit.gpgSign=false',
|
||||
...args
|
||||
],
|
||||
{ cwd: repo, encoding: 'utf8', stdio: ['ignore', 'pipe', 'pipe'] }
|
||||
).trim()
|
||||
}
|
||||
|
||||
/**
|
||||
* Fork point → working tree: +3 tracked (2 committed, 1 unstaged), -2 from the
|
||||
* file the branch deleted, +4 untracked. The pure rename contributes nothing.
|
||||
*/
|
||||
async function seedBranchFixture(repo: string): Promise<string> {
|
||||
await fs.rm(path.join(repo, UNTRACKED_FILE), { force: true })
|
||||
execFileSync('git', ['init', '-q', repo], { stdio: 'pipe' })
|
||||
await fs.writeFile(path.join(repo, 'tracked.txt'), 'a\nb\nc\n')
|
||||
await fs.writeFile(path.join(repo, 'doomed.txt'), 'x\ny\n')
|
||||
await fs.writeFile(path.join(repo, 'old-name.txt'), 'stable\n')
|
||||
runFixtureGit(repo, ['add', '.'])
|
||||
runFixtureGit(repo, ['commit', '-m', 'base'])
|
||||
const mergeBase = runFixtureGit(repo, ['rev-parse', 'HEAD'])
|
||||
|
||||
runFixtureGit(repo, ['checkout', '-q', '-b', 'feature'])
|
||||
await fs.writeFile(path.join(repo, 'tracked.txt'), 'a\nb\nc\nd\ne\n')
|
||||
await fs.rm(path.join(repo, 'doomed.txt'))
|
||||
runFixtureGit(repo, ['add', '-A'])
|
||||
runFixtureGit(repo, ['commit', '-m', 'branch commit'])
|
||||
|
||||
runFixtureGit(repo, ['mv', 'old-name.txt', 'new-name.txt'])
|
||||
await fs.writeFile(path.join(repo, 'tracked.txt'), 'a\nb\nc\nd\ne\nf\n')
|
||||
await fs.writeFile(path.join(repo, UNTRACKED_FILE), 'n1\nn2\nn3\nn4\n')
|
||||
return mergeBase
|
||||
}
|
||||
|
||||
describe('getStatusOp branch line total', () => {
|
||||
let tmpDir: string
|
||||
|
||||
beforeEach(async () => {
|
||||
clearNoEffectiveUpstreamStatusCache()
|
||||
clearGitStatusLineStatsCache()
|
||||
invalidateGitBranchLineTotalInFlight()
|
||||
tmpDir = mkdtempSync(path.join(tmpdir(), 'relay-branch-line-total-'))
|
||||
await fs.writeFile(path.join(tmpDir, UNTRACKED_FILE), 'n1\nn2\nn3\nn4\n')
|
||||
})
|
||||
|
||||
afterEach(async () => {
|
||||
clearNoEffectiveUpstreamStatusCache()
|
||||
clearGitStatusLineStatsCache()
|
||||
invalidateGitBranchLineTotalInFlight()
|
||||
await fs.rm(tmpDir, { recursive: true, force: true })
|
||||
})
|
||||
|
||||
it('runs no ranged diff and returns no total when the merge base param is absent', async () => {
|
||||
const git = createMockGit({})
|
||||
|
||||
const result = await getStatusOp(git, streamGitFromCapture(git), { worktreePath: tmpDir })
|
||||
|
||||
expect(rangedDiffCalls(git.mock.calls)).toEqual([])
|
||||
expect(Object.hasOwn(result, 'branchLineTotal')).toBe(false)
|
||||
expect(result.branchLineTotal).toBeUndefined()
|
||||
// The per-area numstats the CHANGES rows need still ran, so this is a
|
||||
// zero-cost omission rather than a disabled status pass.
|
||||
expect(result.entries).toContainEqual(
|
||||
expect.objectContaining({ path: 'src/a.ts', added: 3, removed: 2 })
|
||||
)
|
||||
})
|
||||
|
||||
it('sums the ranged diff and untracked additions against a real fixture repo', async () => {
|
||||
const mergeBase = await seedBranchFixture(tmpDir)
|
||||
const git = vi.fn<GitExec>(realGitExec)
|
||||
|
||||
const result = await getStatusOp(git, streamGitFromCapture(git), {
|
||||
worktreePath: tmpDir,
|
||||
branchLineTotalMergeBase: mergeBase
|
||||
})
|
||||
|
||||
expect(result.branchLineTotal).toEqual({
|
||||
added: 3 + UNTRACKED_LINES,
|
||||
removed: 2,
|
||||
mergeBase
|
||||
})
|
||||
expect(rangedDiffCalls(git.mock.calls)).toEqual([
|
||||
['-c', 'core.quotePath=false', 'diff', '-z', '--numstat', '-M', mergeBase, '--']
|
||||
])
|
||||
})
|
||||
|
||||
it('omits the total when the status listing hit its limit', async () => {
|
||||
const manyEntries = Array.from(
|
||||
{ length: 6 },
|
||||
(_, index) => `1 A. N... 100644 100644 100644 000000 111111 generated-${index}.txt`
|
||||
).join('\n')
|
||||
const git = createMockGit({ status: manyEntries })
|
||||
|
||||
const result = await getStatusOp(git, streamGitFromCapture(git), {
|
||||
worktreePath: tmpDir,
|
||||
limit: 3,
|
||||
branchLineTotalMergeBase: MERGE_BASE
|
||||
})
|
||||
|
||||
expect(result.didHitLimit).toBe(true)
|
||||
// The untracked list is truncated, so any total would silently under-count.
|
||||
expect(Object.hasOwn(result, 'branchLineTotal')).toBe(false)
|
||||
expect(rangedDiffCalls(git.mock.calls)).toEqual([])
|
||||
})
|
||||
|
||||
it('omits the total, never zero, when the ranged diff fails', async () => {
|
||||
const git = createMockGit({
|
||||
ranged: () => Promise.reject(new Error('fatal: bad object 0123456789abcdef'))
|
||||
})
|
||||
|
||||
const result = await getStatusOp(git, streamGitFromCapture(git), {
|
||||
worktreePath: tmpDir,
|
||||
branchLineTotalMergeBase: MERGE_BASE
|
||||
})
|
||||
|
||||
expect(Object.hasOwn(result, 'branchLineTotal')).toBe(false)
|
||||
expect(result.branchLineTotal).toBeUndefined()
|
||||
expect(rangedDiffCalls(git.mock.calls)).toHaveLength(1)
|
||||
// A failed ranged diff must not take the per-file rows down with it.
|
||||
expect(result.entries).toContainEqual(expect.objectContaining({ path: 'src/a.ts', added: 3 }))
|
||||
})
|
||||
|
||||
it('omits the total, never zero, for a well-formed but unknown merge-base oid', async () => {
|
||||
await seedBranchFixture(tmpDir)
|
||||
const git = vi.fn<GitExec>(realGitExec)
|
||||
|
||||
const result = await getStatusOp(git, streamGitFromCapture(git), {
|
||||
worktreePath: tmpDir,
|
||||
branchLineTotalMergeBase: 'deadbeefdeadbeefdeadbeefdeadbeefdeadbeef'
|
||||
})
|
||||
|
||||
expect(rangedDiffCalls(git.mock.calls)).toHaveLength(1)
|
||||
expect(Object.hasOwn(result, 'branchLineTotal')).toBe(false)
|
||||
expect(result.entries.length).toBeGreaterThan(0)
|
||||
})
|
||||
|
||||
it('omits the total when the ranged diff exceeds its time budget', async () => {
|
||||
const git = createMockGit({
|
||||
ranged: () => {
|
||||
const error: Error & { killed?: boolean } = new Error('spawn git ETIMEDOUT')
|
||||
error.killed = true
|
||||
return Promise.reject(error)
|
||||
}
|
||||
})
|
||||
|
||||
const result = await getStatusOp(git, streamGitFromCapture(git), {
|
||||
worktreePath: tmpDir,
|
||||
branchLineTotalMergeBase: MERGE_BASE
|
||||
})
|
||||
|
||||
expect(Object.hasOwn(result, 'branchLineTotal')).toBe(false)
|
||||
expect(rangedDiffCalls(git.mock.calls)).toHaveLength(1)
|
||||
// The budget is handed to the subprocess rather than raced on a wall clock.
|
||||
const rangedOptions = git.mock.calls.find(([args]) => isRangedNumstat(args))?.[2]
|
||||
expect(rangedOptions).toMatchObject({ disableOptionalLocks: true, timeout: expect.any(Number) })
|
||||
})
|
||||
|
||||
it('omits the total when the status scan itself failed', async () => {
|
||||
const git = vi.fn<GitExec>(async (args) => {
|
||||
if (args.includes('status')) {
|
||||
throw new Error('fatal: not a git repository')
|
||||
}
|
||||
throw new Error(`Unexpected git command: ${args.join(' ')}`)
|
||||
})
|
||||
|
||||
const result = await getStatusOp(git, streamGitFromCapture(git), {
|
||||
worktreePath: tmpDir,
|
||||
branchLineTotalMergeBase: MERGE_BASE
|
||||
})
|
||||
|
||||
expect(result.entries).toEqual([])
|
||||
// A failed scan yields an empty untracked list, which would read as "no
|
||||
// untracked additions" rather than "unknown".
|
||||
expect(Object.hasOwn(result, 'branchLineTotal')).toBe(false)
|
||||
expect(rangedDiffCalls(git.mock.calls)).toEqual([])
|
||||
})
|
||||
|
||||
// Relay params are an untyped bag, so the merge base must be proven to be an
|
||||
// object name before it can be spliced into an argv.
|
||||
it.each([
|
||||
['a flag-shaped value', '--upload-pack=x'],
|
||||
['a rev name', 'HEAD'],
|
||||
['a ref path', 'refs/heads/main'],
|
||||
['a range', `${MERGE_BASE}..HEAD`],
|
||||
['a number', 123],
|
||||
['null', null],
|
||||
['an empty string', '']
|
||||
])('rejects %s before it reaches a git argv', async (_label, value) => {
|
||||
const git = createMockGit({})
|
||||
|
||||
const result = await getStatusOp(git, streamGitFromCapture(git), {
|
||||
worktreePath: tmpDir,
|
||||
branchLineTotalMergeBase: value
|
||||
})
|
||||
|
||||
expect(Object.hasOwn(result, 'branchLineTotal')).toBe(false)
|
||||
expect(rangedDiffCalls(git.mock.calls)).toEqual([])
|
||||
for (const [args] of git.mock.calls) {
|
||||
expect(args).not.toContain(String(value))
|
||||
}
|
||||
})
|
||||
|
||||
it('answers a rejected merge base with the byte-identical no-merge-base response', async () => {
|
||||
const gitWithout = createMockGit({})
|
||||
const withoutParam = await getStatusOp(gitWithout, streamGitFromCapture(gitWithout), {
|
||||
worktreePath: tmpDir
|
||||
})
|
||||
clearGitStatusLineStatsCache()
|
||||
const gitRejected = createMockGit({})
|
||||
const rejectedParam = await getStatusOp(gitRejected, streamGitFromCapture(gitRejected), {
|
||||
worktreePath: tmpDir,
|
||||
branchLineTotalMergeBase: '--upload-pack=x'
|
||||
})
|
||||
|
||||
expect(JSON.stringify(rejectedParam)).toBe(JSON.stringify(withoutParam))
|
||||
expect(gitRejected.mock.calls.map(([args]) => args)).toEqual(
|
||||
gitWithout.mock.calls.map(([args]) => args)
|
||||
)
|
||||
})
|
||||
|
||||
it('rejects an aborted scan instead of resolving a partial total', async () => {
|
||||
const controller = new AbortController()
|
||||
const git = vi.fn<GitExec>(async (args) => {
|
||||
if (args.includes('status')) {
|
||||
return { stdout: STATUS_OUTPUT, stderr: '' }
|
||||
}
|
||||
if (isRangedNumstat(args)) {
|
||||
controller.abort()
|
||||
const error = new Error('The operation was aborted.')
|
||||
error.name = 'AbortError'
|
||||
throw error
|
||||
}
|
||||
if (args.includes('diff')) {
|
||||
return { stdout: '3\t2\tsrc/a.ts\n', stderr: '' }
|
||||
}
|
||||
throw new Error(`Unexpected git command: ${args.join(' ')}`)
|
||||
})
|
||||
|
||||
await expect(
|
||||
getStatusOp(
|
||||
git,
|
||||
streamGitFromCapture(git),
|
||||
{ worktreePath: tmpDir, branchLineTotalMergeBase: MERGE_BASE },
|
||||
{ signal: controller.signal }
|
||||
)
|
||||
).rejects.toThrow(/abort/i)
|
||||
expect(rangedDiffCalls(git.mock.calls)).toHaveLength(1)
|
||||
})
|
||||
|
||||
it('reuses the cached total instead of re-running the ranged diff', async () => {
|
||||
const git = createMockGit({})
|
||||
|
||||
const first = await getStatusOp(git, streamGitFromCapture(git), {
|
||||
worktreePath: tmpDir,
|
||||
branchLineTotalMergeBase: MERGE_BASE
|
||||
})
|
||||
const reused = await getStatusOp(git, streamGitFromCapture(git), {
|
||||
worktreePath: tmpDir,
|
||||
branchLineTotalMergeBase: MERGE_BASE,
|
||||
reuseLineStats: true
|
||||
})
|
||||
|
||||
expect(first.branchLineTotal).toEqual({
|
||||
added: 12 + UNTRACKED_LINES,
|
||||
removed: 5,
|
||||
mergeBase: MERGE_BASE
|
||||
})
|
||||
expect(reused.branchLineTotal).toEqual(first.branchLineTotal)
|
||||
expect(rangedDiffCalls(git.mock.calls)).toHaveLength(1)
|
||||
})
|
||||
|
||||
it('coalesces concurrent status passes onto one ranged diff', async () => {
|
||||
// The renderer's own in-flight refs only dedupe one renderer; a second
|
||||
// window or an fs-watcher burst must not run the ranged diff twice.
|
||||
const git = vi.fn<GitExec>(async (args) => {
|
||||
if (args.includes('status')) {
|
||||
return { stdout: STATUS_OUTPUT, stderr: '' }
|
||||
}
|
||||
if (isRangedNumstat(args)) {
|
||||
await new Promise((resolve) => setTimeout(resolve, 20))
|
||||
return { stdout: '12\t5\tsrc/a.ts\n', stderr: '' }
|
||||
}
|
||||
if (args.includes('diff')) {
|
||||
return { stdout: '3\t2\tsrc/a.ts\n', stderr: '' }
|
||||
}
|
||||
throw new Error(`Unexpected git command: ${args.join(' ')}`)
|
||||
})
|
||||
|
||||
const [first, second] = await Promise.all([
|
||||
getStatusOp(git, streamGitFromCapture(git), {
|
||||
worktreePath: tmpDir,
|
||||
branchLineTotalMergeBase: MERGE_BASE
|
||||
}),
|
||||
getStatusOp(git, streamGitFromCapture(git), {
|
||||
worktreePath: tmpDir,
|
||||
branchLineTotalMergeBase: MERGE_BASE
|
||||
})
|
||||
])
|
||||
|
||||
expect(rangedDiffCalls(git.mock.calls)).toHaveLength(1)
|
||||
expect(first.branchLineTotal).toEqual(second.branchLineTotal)
|
||||
expect(first.branchLineTotal).toEqual({
|
||||
added: 12 + UNTRACKED_LINES,
|
||||
removed: 5,
|
||||
mergeBase: MERGE_BASE
|
||||
})
|
||||
})
|
||||
|
||||
it('recomputes rather than reusing a total measured against another fork point', async () => {
|
||||
const git = createMockGit({})
|
||||
|
||||
await getStatusOp(git, streamGitFromCapture(git), {
|
||||
worktreePath: tmpDir,
|
||||
branchLineTotalMergeBase: MERGE_BASE
|
||||
})
|
||||
const moved = await getStatusOp(git, streamGitFromCapture(git), {
|
||||
worktreePath: tmpDir,
|
||||
branchLineTotalMergeBase: OTHER_MERGE_BASE,
|
||||
reuseLineStats: true
|
||||
})
|
||||
|
||||
expect(moved.branchLineTotal).toEqual({
|
||||
added: 12 + UNTRACKED_LINES,
|
||||
removed: 5,
|
||||
mergeBase: OTHER_MERGE_BASE
|
||||
})
|
||||
expect(rangedDiffCalls(git.mock.calls)).toHaveLength(2)
|
||||
})
|
||||
|
||||
// Rule 1 of docs/reference/remote-wire-compatibility.md: a new optional field
|
||||
// is safe only while every reader survives its absence.
|
||||
describe('wire compatibility', () => {
|
||||
it('drops the key from the wire payload so a reader sees undefined, not 0 or NaN', async () => {
|
||||
const git = createMockGit({ ranged: () => Promise.reject(new Error('fatal: bad object')) })
|
||||
|
||||
const result = await getStatusOp(git, streamGitFromCapture(git), {
|
||||
worktreePath: tmpDir,
|
||||
branchLineTotalMergeBase: MERGE_BASE
|
||||
})
|
||||
// An old server's payload and a new server's "not known exact" payload are
|
||||
// the same thing on the wire: no key at all.
|
||||
const overTheWire = JSON.parse(JSON.stringify(result)) as {
|
||||
branchLineTotal?: { added: number; removed: number }
|
||||
}
|
||||
|
||||
expect(Object.hasOwn(overTheWire, 'branchLineTotal')).toBe(false)
|
||||
expect(overTheWire.branchLineTotal).toBeUndefined()
|
||||
expect(overTheWire.branchLineTotal?.added).toBeUndefined()
|
||||
expect(overTheWire.branchLineTotal?.removed).toBeUndefined()
|
||||
})
|
||||
|
||||
it('keeps the new request param out of the status argv an old server would run', async () => {
|
||||
const git = createMockGit({})
|
||||
|
||||
await getStatusOp(git, streamGitFromCapture(git), {
|
||||
worktreePath: tmpDir,
|
||||
branchLineTotalMergeBase: MERGE_BASE
|
||||
})
|
||||
|
||||
const statusArgs = git.mock.calls
|
||||
.map(([args]) => args)
|
||||
.filter((args) => args.includes('status'))
|
||||
expect(statusArgs).toHaveLength(1)
|
||||
expect(statusArgs[0]).not.toContain(MERGE_BASE)
|
||||
})
|
||||
|
||||
it('publishes finite counts when the ranged diff reports only binaries', async () => {
|
||||
const git = createMockGit({
|
||||
ranged: () => Promise.resolve({ stdout: '-\t-\tlogo.png\n', stderr: '' })
|
||||
})
|
||||
|
||||
const result = await getStatusOp(git, streamGitFromCapture(git), {
|
||||
worktreePath: tmpDir,
|
||||
branchLineTotalMergeBase: MERGE_BASE
|
||||
})
|
||||
|
||||
expect(result.branchLineTotal).toEqual({
|
||||
added: UNTRACKED_LINES,
|
||||
removed: 0,
|
||||
mergeBase: MERGE_BASE
|
||||
})
|
||||
expect(Number.isFinite(result.branchLineTotal?.added)).toBe(true)
|
||||
expect(Number.isFinite(result.branchLineTotal?.removed)).toBe(true)
|
||||
})
|
||||
})
|
||||
})
|
||||
@@ -0,0 +1,44 @@
|
||||
/**
|
||||
* Relay-side wiring for the status pass's branch line total.
|
||||
* Why: separate file so git-handler-status-ops.ts stays under oxlint max-lines (300).
|
||||
*/
|
||||
import {
|
||||
computeGitBranchLineTotal,
|
||||
GIT_BRANCH_LINE_TOTAL_TIMEOUT_MS,
|
||||
type GitBranchLineTotal
|
||||
} from '../shared/git-branch-line-total'
|
||||
import type { GitExec } from './git-handler-ops'
|
||||
|
||||
/** Undefined when no valid merge base was requested, which keeps the ranged diff entirely off. */
|
||||
export function buildBranchLineTotalInput(
|
||||
git: GitExec,
|
||||
worktreePath: string,
|
||||
entries: Record<string, unknown>[],
|
||||
mergeBase: string | undefined,
|
||||
signal?: AbortSignal
|
||||
): { mergeBase: string; compute: () => Promise<GitBranchLineTotal | undefined> } | undefined {
|
||||
if (!mergeBase) {
|
||||
return undefined
|
||||
}
|
||||
return {
|
||||
mergeBase,
|
||||
compute: () =>
|
||||
computeGitBranchLineTotal({
|
||||
worktreePath,
|
||||
// Matches the `relay\0<path>` line-stats cache-key convention.
|
||||
hostKey: 'relay',
|
||||
mergeBase,
|
||||
untrackedPaths: entries
|
||||
.filter((entry) => entry.area === 'untracked')
|
||||
.map((entry) => entry.path as string),
|
||||
runDiffNumstat: (args, diffSignal) =>
|
||||
git(args, worktreePath, {
|
||||
// Why: a working-tree diff must not take index.lock away from terminal Git.
|
||||
disableOptionalLocks: true,
|
||||
signal: diffSignal,
|
||||
timeout: GIT_BRANCH_LINE_TOTAL_TIMEOUT_MS
|
||||
}).then(({ stdout }) => stdout),
|
||||
...(signal ? { signal } : {})
|
||||
})
|
||||
}
|
||||
}
|
||||
@@ -10,7 +10,7 @@ import {
|
||||
compactSourceControlTree,
|
||||
flattenSourceControlTree
|
||||
} from '@/components/right-sidebar/source-control-tree'
|
||||
import type { GitBranchChangeEntry, GitStagingArea, GitStatusEntry } from '../../../../shared/types'
|
||||
import type { GitBranchChangeEntry, GitStagingArea } from '../../../../shared/types'
|
||||
import {
|
||||
getEntryExtension,
|
||||
getFilteredCombinedDiffFileTreeEntries,
|
||||
@@ -22,6 +22,11 @@ import {
|
||||
import { CombinedDiffFileTreeRow, type CombinedDiffTreeNode } from './combined-diff-file-tree-row'
|
||||
import { useCombinedDiffFileTreeResize } from './use-combined-diff-file-tree-resize'
|
||||
import { translate } from '@/i18n/i18n'
|
||||
import {
|
||||
mergeUntrackedIntoChanges,
|
||||
SOURCE_CONTROL_GROUP_ORDER,
|
||||
type SourceControlEntryGroups
|
||||
} from '@/components/right-sidebar/source-control-section-order'
|
||||
|
||||
export {
|
||||
createCombinedDiffSectionIndexMap,
|
||||
@@ -30,7 +35,6 @@ export {
|
||||
handleCombinedDiffFileTreeNavigation
|
||||
} from './combined-diff-file-tree-model'
|
||||
|
||||
const UNCOMMITTED_AREA_ORDER: readonly GitStagingArea[] = ['unstaged', 'staged', 'untracked']
|
||||
const UNCOMMITTED_AREA_LABELS: Record<GitStagingArea, string> = {
|
||||
unstaged: 'Changes',
|
||||
staged: 'Staged Changes',
|
||||
@@ -39,26 +43,35 @@ const UNCOMMITTED_AREA_LABELS: Record<GitStagingArea, string> = {
|
||||
|
||||
function buildUncommittedRows(
|
||||
entries: readonly CombinedDiffFileTreeEntry[],
|
||||
collapsedDirectoryKeys: ReadonlySet<string>
|
||||
collapsedDirectoryKeys: ReadonlySet<string>,
|
||||
areaOrder: readonly GitStagingArea[]
|
||||
): { area: GitStagingArea; label: string; rows: CombinedDiffTreeNode[] }[] {
|
||||
return UNCOMMITTED_AREA_ORDER.map((area) => {
|
||||
const areaEntries = entries.filter(
|
||||
(entry): entry is GitStatusEntry => isGitStatusEntry(entry) && entry.area === area
|
||||
)
|
||||
if (areaEntries.length === 0) {
|
||||
return null
|
||||
const groups: SourceControlEntryGroups = { staged: [], unstaged: [], untracked: [] }
|
||||
for (const entry of entries) {
|
||||
if (isGitStatusEntry(entry)) {
|
||||
groups[entry.area].push(entry)
|
||||
}
|
||||
}
|
||||
const displayGroups = mergeUntrackedIntoChanges(groups)
|
||||
|
||||
const roots = compactSourceControlTree(buildGitStatusSourceControlTree(area, areaEntries))
|
||||
return {
|
||||
area,
|
||||
label: UNCOMMITTED_AREA_LABELS[area],
|
||||
rows: flattenSourceControlTree(roots, collapsedDirectoryKeys) as CombinedDiffTreeNode[]
|
||||
}
|
||||
}).filter(
|
||||
(group): group is { area: GitStagingArea; label: string; rows: CombinedDiffTreeNode[] } =>
|
||||
Boolean(group)
|
||||
)
|
||||
return areaOrder
|
||||
.map((area) => {
|
||||
const areaEntries = displayGroups[area]
|
||||
if (areaEntries.length === 0) {
|
||||
return null
|
||||
}
|
||||
|
||||
const roots = compactSourceControlTree(buildGitStatusSourceControlTree(area, areaEntries))
|
||||
return {
|
||||
area,
|
||||
label: UNCOMMITTED_AREA_LABELS[area],
|
||||
rows: flattenSourceControlTree(roots, collapsedDirectoryKeys) as CombinedDiffTreeNode[]
|
||||
}
|
||||
})
|
||||
.filter(
|
||||
(group): group is { area: GitStagingArea; label: string; rows: CombinedDiffTreeNode[] } =>
|
||||
Boolean(group)
|
||||
)
|
||||
}
|
||||
|
||||
function buildBranchRows(
|
||||
@@ -153,7 +166,7 @@ export function CombinedDiffFileTree({
|
||||
const uncommittedGroups = React.useMemo(
|
||||
() =>
|
||||
mode === 'all' || mode === 'uncommitted'
|
||||
? buildUncommittedRows(filteredEntries, collapsedDirectoryKeys)
|
||||
? buildUncommittedRows(filteredEntries, collapsedDirectoryKeys, SOURCE_CONTROL_GROUP_ORDER)
|
||||
: [],
|
||||
[collapsedDirectoryKeys, filteredEntries, mode]
|
||||
)
|
||||
|
||||
@@ -13,7 +13,10 @@ export function GitHubMarkdownComposerEditorPane({
|
||||
const scrollContainerRef = useRef<HTMLDivElement | null>(null)
|
||||
|
||||
return (
|
||||
<div ref={scrollContainerRef} className="relative max-h-[360px] overflow-y-auto scrollbar-sleek">
|
||||
<div
|
||||
ref={scrollContainerRef}
|
||||
className="relative max-h-[360px] overflow-y-auto scrollbar-sleek"
|
||||
>
|
||||
<EditorContent editor={editor} />
|
||||
<RichMarkdownTableControls
|
||||
disabled={disabled}
|
||||
|
||||
@@ -48,13 +48,15 @@ function renderCard(canSend = true): ReturnType<typeof render> {
|
||||
function cardElement(
|
||||
canSend = true,
|
||||
messages?: readonly NativeChatMessage[],
|
||||
onShowingQuestionChange?: (showing: boolean) => void
|
||||
onShowingQuestionChange?: (showing: boolean) => void,
|
||||
transcriptSettled = true
|
||||
): React.JSX.Element {
|
||||
return (
|
||||
<NativeChatInteractiveCard
|
||||
paneKey="tab-1:leaf-1"
|
||||
canSend={canSend}
|
||||
messages={messages}
|
||||
transcriptSettled={transcriptSettled}
|
||||
onShowingQuestionChange={onShowingQuestionChange}
|
||||
send={{
|
||||
sendAnswer: mocks.sendAnswer,
|
||||
@@ -101,7 +103,7 @@ function askResultMessage(): NativeChatMessage {
|
||||
|
||||
function chooseSpacesAndSubmit(): void {
|
||||
fireEvent.click(screen.getByRole('button', { name: /Spaces/ }))
|
||||
fireEvent.click(screen.getByRole('button', { name: 'Send answer' }))
|
||||
fireEvent.click(screen.getByRole('button', { name: 'Submit' }))
|
||||
}
|
||||
|
||||
describe('NativeChatInteractiveCard answer lifecycle', () => {
|
||||
@@ -122,7 +124,7 @@ describe('NativeChatInteractiveCard answer lifecycle', () => {
|
||||
chooseSpacesAndSubmit()
|
||||
expect(screen.getByText('Tabs or spaces?')).toBeInTheDocument()
|
||||
|
||||
fireEvent.click(screen.getByRole('button', { name: 'Send answer' }))
|
||||
fireEvent.click(screen.getByRole('button', { name: 'Submit' }))
|
||||
expect(mocks.sendAnswer).toHaveBeenCalledTimes(2)
|
||||
})
|
||||
|
||||
@@ -205,7 +207,7 @@ describe('NativeChatInteractiveCard answer lifecycle', () => {
|
||||
chooseSpacesAndSubmit()
|
||||
act(() => settleDelivery?.(false))
|
||||
|
||||
expect(screen.getByRole('button', { name: 'Send answer' })).toBeEnabled()
|
||||
expect(screen.getByRole('button', { name: 'Submit' })).toBeEnabled()
|
||||
expect(screen.getByText('Tabs or spaces?')).toBeInTheDocument()
|
||||
})
|
||||
})
|
||||
@@ -233,6 +235,12 @@ describe('NativeChatInteractiveCard transcript fallback', () => {
|
||||
expect(onShowingQuestionChange).toHaveBeenCalledWith(true)
|
||||
})
|
||||
|
||||
it('withholds a retained transcript ask while its replacement read is unsettled', () => {
|
||||
render(cardElement(true, [askCallMessage('Stale transcript question?')], undefined, false))
|
||||
|
||||
expect(screen.queryByText('Stale transcript question?')).not.toBeInTheDocument()
|
||||
})
|
||||
|
||||
it('prefers live status over the transcript when both carry a prompt', () => {
|
||||
storeState.agentStatusByPaneKey['tab-1:leaf-1'].interactivePrompt = INITIAL_PROMPT
|
||||
render(cardElement(true, [askCallMessage('Stale transcript question?')]))
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
import { useCallback, useEffect, useLayoutEffect, useMemo, useRef, useState } from 'react'
|
||||
import { useAppStore } from '../../store'
|
||||
import { extractPendingAsk } from '../../../../shared/native-chat-ask'
|
||||
import { resolveNativeChatAsk } from '../../../../shared/native-chat-ask'
|
||||
import type { NativeChatMessage } from '../../../../shared/native-chat-types'
|
||||
import { parseInteractivePrompt } from './native-chat-interactive-prompt'
|
||||
import { nativeChatCardDismissKey } from './native-chat-dismiss-key'
|
||||
@@ -33,6 +33,7 @@ export function NativeChatInteractiveCard({
|
||||
send,
|
||||
canSend,
|
||||
messages,
|
||||
transcriptSettled,
|
||||
onShowingQuestionChange,
|
||||
answerInputRef
|
||||
}: {
|
||||
@@ -42,6 +43,7 @@ export function NativeChatInteractiveCard({
|
||||
/** Transcript to fall back on when live status carries no prompt. Pass the
|
||||
* command-boundary-trimmed messages so an ask abandoned via `/clear` stays gone. */
|
||||
messages?: readonly NativeChatMessage[]
|
||||
transcriptSettled: boolean
|
||||
/** Reports whether a question card is on screen so the view can replace the
|
||||
* composer with it (the card's free-text row is the answer input). */
|
||||
onShowingQuestionChange?: (showing: boolean) => void
|
||||
@@ -59,12 +61,16 @@ export function NativeChatInteractiveCard({
|
||||
|
||||
const card = useMemo(() => {
|
||||
const statusCard = parseInteractivePrompt(interactivePrompt, interactiveToolName ?? undefined)
|
||||
if (statusCard || !messages) {
|
||||
if (statusCard?.kind === 'approval') {
|
||||
return statusCard
|
||||
}
|
||||
const prompt = extractPendingAsk(messages)
|
||||
const prompt = resolveNativeChatAsk({
|
||||
liveAsk: statusCard?.prompt ?? null,
|
||||
messages: messages ?? [],
|
||||
transcriptSettled: transcriptSettled && messages != null
|
||||
})
|
||||
return prompt ? { kind: 'question' as const, prompt } : null
|
||||
}, [interactivePrompt, interactiveToolName, messages])
|
||||
}, [interactivePrompt, interactiveToolName, messages, transcriptSettled])
|
||||
const cardKey = useMemo(() => nativeChatCardDismissKey(card), [card])
|
||||
const [dismissedKey, setDismissedKey] = useState<string | null>(null)
|
||||
// A question answer is a paced multi-step write (body→Enter per question); keep
|
||||
|
||||
@@ -75,7 +75,7 @@ describe('NativeChatQuestionCard', () => {
|
||||
render(tabsOrSpaces, onAnswer)
|
||||
|
||||
clickOption('Spaces')
|
||||
clickAction('Send answer')
|
||||
clickAction('Submit')
|
||||
|
||||
expect(onAnswer).toHaveBeenCalledWith([{ indices: [1], other: '' }])
|
||||
})
|
||||
@@ -97,7 +97,7 @@ describe('NativeChatQuestionCard', () => {
|
||||
|
||||
clickOption('Cherry')
|
||||
clickOption('Apple')
|
||||
clickAction('Send answer')
|
||||
clickAction('Submit')
|
||||
|
||||
expect(onAnswer).toHaveBeenCalledWith([{ indices: [0, 2], other: '' }])
|
||||
})
|
||||
@@ -118,7 +118,7 @@ describe('NativeChatQuestionCard', () => {
|
||||
)
|
||||
|
||||
clickOptionAt(1)
|
||||
clickAction('Send answer')
|
||||
clickAction('Submit')
|
||||
|
||||
expect(onAnswer).toHaveBeenCalledWith([{ indices: [1], other: '' }])
|
||||
})
|
||||
@@ -133,7 +133,7 @@ describe('NativeChatQuestionCard', () => {
|
||||
setter.call(input, 'four spaces')
|
||||
input.dispatchEvent(new Event('input', { bubbles: true }))
|
||||
})
|
||||
clickAction('Send answer')
|
||||
clickAction('Submit')
|
||||
|
||||
expect(onAnswer).toHaveBeenCalledWith([{ indices: [], other: 'four spaces' }])
|
||||
})
|
||||
|
||||
@@ -209,7 +209,7 @@ export function NativeChatQuestionCard({
|
||||
disabled={isSubmitting}
|
||||
onClick={() => confirm()}
|
||||
className={cn(
|
||||
'w-24 shrink-0 rounded-md px-3 py-1 text-xs font-semibold transition-colors focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-ring disabled:cursor-default disabled:opacity-50',
|
||||
'shrink-0 whitespace-nowrap rounded-md px-3 py-1 text-xs font-semibold transition-colors focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-ring disabled:cursor-default disabled:opacity-50',
|
||||
currentAnswered
|
||||
? 'bg-primary text-primary-foreground hover:bg-primary/90'
|
||||
: 'text-muted-foreground hover:bg-accent hover:text-accent-foreground'
|
||||
@@ -219,7 +219,7 @@ export function NativeChatQuestionCard({
|
||||
? translate('components.native-chat.question.sending', 'Sending…')
|
||||
: currentAnswered
|
||||
? isLast
|
||||
? translate('components.native-chat.question.send', 'Send answer')
|
||||
? translate('components.native-chat.question.send', 'Submit')
|
||||
: translate('components.native-chat.question.next', 'Next')
|
||||
: translate('components.native-chat.question.skip', 'Skip')}
|
||||
</button>
|
||||
|
||||
@@ -3,7 +3,7 @@ import { useShallow } from 'zustand/react/shallow'
|
||||
import { useAppStore } from '../../store'
|
||||
import { useNativeChatLaunchDraftSignal } from './use-native-chat-launch-draft-adoption'
|
||||
import type { NativeChatSession } from '../../../../shared/native-chat-types'
|
||||
import { useNativeChatLiveSession } from './use-native-chat-live-session'
|
||||
import { useNativeChatRetainedSession } from './use-native-chat-retained-session'
|
||||
import { selectNativeChatViewState } from './native-chat-view-state'
|
||||
import { NativeChatMessageList } from './NativeChatMessageList'
|
||||
import { NativeChatComposer, type NativeChatComposerHandle } from './NativeChatComposer'
|
||||
@@ -131,7 +131,7 @@ function NativeChatResolvedView({
|
||||
const runtimeEnvironmentId = useAppStore((s) =>
|
||||
selectNativeChatRuntimeEnvironmentId(s, terminalTabId)
|
||||
)
|
||||
const session = useNativeChatLiveSession({
|
||||
const session = useNativeChatRetainedSession({
|
||||
paneKey,
|
||||
agent,
|
||||
sessionId,
|
||||
@@ -430,6 +430,7 @@ function NativeChatResolvedView({
|
||||
send={interactiveSend}
|
||||
canSend={canSend}
|
||||
messages={sessionAfterCommandBoundaries.messages}
|
||||
transcriptSettled={session.readPhase === 'ready'}
|
||||
onShowingQuestionChange={setQuestionActive}
|
||||
answerInputRef={questionAnswerInputRef}
|
||||
/>
|
||||
|
||||
@@ -0,0 +1,102 @@
|
||||
// @vitest-environment happy-dom
|
||||
|
||||
import { act, createElement } from 'react'
|
||||
import { createRoot, type Root } from 'react-dom/client'
|
||||
import { afterEach, describe, expect, it, vi } from 'vitest'
|
||||
import type { NativeChatMessage } from '../../../../shared/native-chat-types'
|
||||
import type {
|
||||
NativeChatLiveSession,
|
||||
UseNativeChatLiveSessionArgs
|
||||
} from './use-native-chat-live-session'
|
||||
|
||||
const { liveSession } = vi.hoisted(() => ({ liveSession: vi.fn() }))
|
||||
|
||||
vi.mock('./use-native-chat-live-session', () => ({ useNativeChatLiveSession: liveSession }))
|
||||
|
||||
import { useNativeChatRetainedSession } from './use-native-chat-retained-session'
|
||||
|
||||
const ARGS: UseNativeChatLiveSessionArgs = {
|
||||
paneKey: 'tab:leaf',
|
||||
agent: 'claude',
|
||||
sessionId: 'session',
|
||||
runtimeEnvironmentId: 'owner-a'
|
||||
}
|
||||
|
||||
function message(id: string): NativeChatMessage {
|
||||
return { id, role: 'assistant', blocks: [], timestamp: 0, source: 'transcript' }
|
||||
}
|
||||
|
||||
function session(
|
||||
readPhase: NativeChatLiveSession['readPhase'],
|
||||
messages: NativeChatMessage[],
|
||||
sessionId: string | null = 'session'
|
||||
): NativeChatLiveSession {
|
||||
return {
|
||||
agent: 'claude',
|
||||
sessionId,
|
||||
messages,
|
||||
status: readPhase === 'loading' ? 'loading' : 'ready',
|
||||
hasMore: false,
|
||||
loadingEarlier: false,
|
||||
loadEarlier: vi.fn(),
|
||||
readPhase
|
||||
}
|
||||
}
|
||||
|
||||
describe('useNativeChatRetainedSession', () => {
|
||||
let root: Root | null = null
|
||||
let latest: NativeChatLiveSession | null = null
|
||||
|
||||
function Probe(props: UseNativeChatLiveSessionArgs): null {
|
||||
latest = useNativeChatRetainedSession(props)
|
||||
return null
|
||||
}
|
||||
|
||||
async function render(props: UseNativeChatLiveSessionArgs): Promise<void> {
|
||||
if (!root) {
|
||||
root = createRoot(document.createElement('div'))
|
||||
}
|
||||
await act(async () => root?.render(createElement(Probe, props)))
|
||||
}
|
||||
|
||||
afterEach(() => {
|
||||
act(() => root?.unmount())
|
||||
root = null
|
||||
latest = null
|
||||
liveSession.mockReset()
|
||||
})
|
||||
|
||||
it('keeps settled messages during a same-identity rebind', async () => {
|
||||
liveSession.mockReturnValue(session('ready', [message('settled')]))
|
||||
await render(ARGS)
|
||||
|
||||
liveSession.mockReturnValue(session('loading', []))
|
||||
await render(ARGS)
|
||||
|
||||
expect(latest?.readPhase).toBe('loading')
|
||||
expect(latest?.messages.map((entry) => entry.id)).toEqual(['settled'])
|
||||
})
|
||||
|
||||
it('misses retained messages when the source owner changes', async () => {
|
||||
liveSession.mockReturnValue(session('ready', [message('owner-a')]))
|
||||
await render(ARGS)
|
||||
|
||||
liveSession.mockReturnValue(session('loading', []))
|
||||
await render({ ...ARGS, runtimeEnvironmentId: 'owner-b' })
|
||||
|
||||
expect(latest?.readPhase).toBe('loading')
|
||||
expect(latest?.messages).toEqual([])
|
||||
})
|
||||
|
||||
it('does not overwrite retention with the session-less view', async () => {
|
||||
liveSession.mockReturnValue(session('ready', [message('settled')]))
|
||||
await render(ARGS)
|
||||
|
||||
liveSession.mockReturnValue(session('ready', [], null))
|
||||
await render({ ...ARGS, sessionId: null })
|
||||
liveSession.mockReturnValue(session('loading', []))
|
||||
await render(ARGS)
|
||||
|
||||
expect(latest?.messages.map((entry) => entry.id)).toEqual(['settled'])
|
||||
})
|
||||
})
|
||||
@@ -0,0 +1,53 @@
|
||||
import { useEffect, useRef } from 'react'
|
||||
import {
|
||||
createNativeChatTranscriptRetention,
|
||||
encodeNativeChatTranscriptIdentity
|
||||
} from '../../../../shared/native-chat-transcript-retention'
|
||||
import {
|
||||
useNativeChatLiveSession,
|
||||
type NativeChatLiveSession,
|
||||
type UseNativeChatLiveSessionArgs
|
||||
} from './use-native-chat-live-session'
|
||||
|
||||
/** Keeps one committed conversation visible while its exact source rebinds. */
|
||||
export function useNativeChatRetainedSession(
|
||||
args: UseNativeChatLiveSessionArgs
|
||||
): NativeChatLiveSession {
|
||||
const session = useNativeChatLiveSession(args)
|
||||
const identity = encodeNativeChatTranscriptIdentity([
|
||||
args.paneKey,
|
||||
args.runtimeEnvironmentId ?? null,
|
||||
args.agent,
|
||||
args.sessionId,
|
||||
args.transcriptPath ?? null
|
||||
])
|
||||
const activeIdentityRef = useRef(identity)
|
||||
const retentionRef = useRef(createNativeChatTranscriptRetention())
|
||||
const sessionMatchesIdentity = activeIdentityRef.current === identity
|
||||
const readPhase = sessionMatchesIdentity ? session.readPhase : 'loading'
|
||||
|
||||
useEffect(() => {
|
||||
activeIdentityRef.current = identity
|
||||
}, [identity])
|
||||
useEffect(() => {
|
||||
if (sessionMatchesIdentity && args.sessionId !== null && session.readPhase === 'ready') {
|
||||
retentionRef.current.capture(identity, session.messages)
|
||||
}
|
||||
}, [args.sessionId, identity, session.messages, session.readPhase, sessionMatchesIdentity])
|
||||
|
||||
const messages = retentionRef.current.visible({
|
||||
identity,
|
||||
messages: session.messages,
|
||||
settled: readPhase === 'ready',
|
||||
loading: readPhase === 'loading'
|
||||
})
|
||||
if (messages === session.messages && readPhase === session.readPhase) {
|
||||
return session
|
||||
}
|
||||
return {
|
||||
...session,
|
||||
messages,
|
||||
readPhase,
|
||||
...(sessionMatchesIdentity ? {} : { status: 'loading' as const, error: undefined })
|
||||
}
|
||||
}
|
||||
@@ -9,11 +9,33 @@ import {
|
||||
} from './quick-open-search'
|
||||
|
||||
describe('quick-open-search', () => {
|
||||
it('returns the first 50 paths with score 0 for an empty query', () => {
|
||||
const files = Array.from({ length: 75 }, (_, index) => `src/file-${index}.ts`)
|
||||
it('orders numbered paths naturally for empty queries and fuzzy-score ties', () => {
|
||||
const files = prepareQuickOpenFiles([
|
||||
'songs/100 - b.txt',
|
||||
'songs/9 - c.txt',
|
||||
'songs/99 - a.txt'
|
||||
])
|
||||
|
||||
expect(rankQuickOpenFiles('', files).map((item) => item.path)).toEqual([
|
||||
'songs/9 - c.txt',
|
||||
'songs/99 - a.txt',
|
||||
'songs/100 - b.txt'
|
||||
])
|
||||
expect(rankQuickOpenFiles('songs', files).map((item) => item.path)).toEqual([
|
||||
'songs/9 - c.txt',
|
||||
'songs/99 - a.txt',
|
||||
'songs/100 - b.txt'
|
||||
])
|
||||
})
|
||||
|
||||
it('returns the first 50 naturally sorted paths with score 0 for an empty query', () => {
|
||||
const files = Array.from({ length: 75 }, (_, index) => `src/file-${74 - index}.ts`)
|
||||
|
||||
expect(rankQuickOpenFiles('', prepareQuickOpenFiles(files))).toEqual(
|
||||
files.slice(0, QUICK_OPEN_RESULT_LIMIT).map((path) => ({ path, score: 0 }))
|
||||
Array.from({ length: QUICK_OPEN_RESULT_LIMIT }, (_, index) => ({
|
||||
path: `src/file-${index}.ts`,
|
||||
score: 0
|
||||
}))
|
||||
)
|
||||
})
|
||||
|
||||
@@ -35,8 +57,8 @@ describe('quick-open-search', () => {
|
||||
).toEqual(['src/components/Button.tsx', 'button-area/deep/path/file.tsx'])
|
||||
})
|
||||
|
||||
it('keeps first-seen order for tie-heavy results at the limit boundary', () => {
|
||||
const files = Array.from({ length: 10 }, (_, index) => `src/path-${index}.bin`)
|
||||
it('uses natural order for tie-heavy results at the limit boundary', () => {
|
||||
const files = Array.from({ length: 10 }, (_, index) => `src/path-${9 - index}.bin`)
|
||||
|
||||
expect(rankQuickOpenFiles('s', prepareQuickOpenFiles(files), 4)).toEqual([
|
||||
{ path: 'src/path-0.bin', score: 0 },
|
||||
@@ -85,9 +107,9 @@ describe('quick-open-search', () => {
|
||||
it('indexes normalized relative paths without changing path semantics', () => {
|
||||
const files = [
|
||||
'src/renderer/src/components/QuickOpen.tsx',
|
||||
'legacy\\provider\\raw-path.ts',
|
||||
'packages/windows-origin/src/App.tsx',
|
||||
'single-file.ts',
|
||||
'legacy\\provider\\raw-path.ts'
|
||||
'single-file.ts'
|
||||
]
|
||||
|
||||
expect(prepareQuickOpenFiles(files)).toEqual([
|
||||
@@ -97,22 +119,22 @@ describe('quick-open-search', () => {
|
||||
lowerFilename: 'quickopen.tsx',
|
||||
inputIndex: 0
|
||||
},
|
||||
{
|
||||
path: 'legacy\\provider\\raw-path.ts',
|
||||
lowerPath: 'legacy/provider/raw-path.ts',
|
||||
lowerFilename: 'raw-path.ts',
|
||||
inputIndex: 1
|
||||
},
|
||||
{
|
||||
path: 'packages/windows-origin/src/App.tsx',
|
||||
lowerPath: 'packages/windows-origin/src/app.tsx',
|
||||
lowerFilename: 'app.tsx',
|
||||
inputIndex: 1
|
||||
inputIndex: 2
|
||||
},
|
||||
{
|
||||
path: 'single-file.ts',
|
||||
lowerPath: 'single-file.ts',
|
||||
lowerFilename: 'single-file.ts',
|
||||
inputIndex: 2
|
||||
},
|
||||
{
|
||||
path: 'legacy\\provider\\raw-path.ts',
|
||||
lowerPath: 'legacy/provider/raw-path.ts',
|
||||
lowerFilename: 'raw-path.ts',
|
||||
inputIndex: 3
|
||||
}
|
||||
])
|
||||
|
||||
@@ -1,4 +1,5 @@
|
||||
import { isClipboardTextByteLengthOverLimit } from '../../../shared/clipboard-text'
|
||||
import { compareFileNames } from '../../../shared/file-name-sort'
|
||||
|
||||
export const QUICK_OPEN_RESULT_LIMIT = 50
|
||||
export const QUICK_OPEN_QUERY_MAX_BYTES = 2 * 1024
|
||||
@@ -51,7 +52,11 @@ export function rankQuickOpenFiles(
|
||||
// still naturally type backslashes in path queries.
|
||||
const normalizedQuery = query.trim().replace(/\\/g, '/').toLowerCase()
|
||||
if (!normalizedQuery) {
|
||||
return files.slice(0, limit).map((file) => ({ path: file.path, score: 0 }))
|
||||
const results: QuickOpenRankedResult[] = []
|
||||
for (const file of files) {
|
||||
retainTopResult(results, { path: file.path, score: 0, inputIndex: file.inputIndex }, limit)
|
||||
}
|
||||
return finalizeResults(results)
|
||||
}
|
||||
|
||||
const results: QuickOpenRankedResult[] = []
|
||||
@@ -61,10 +66,10 @@ export function rankQuickOpenFiles(
|
||||
continue
|
||||
}
|
||||
|
||||
insertTopResult(results, { path: file.path, score, inputIndex: file.inputIndex }, limit)
|
||||
retainTopResult(results, { path: file.path, score, inputIndex: file.inputIndex }, limit)
|
||||
}
|
||||
|
||||
return results.map(({ path, score }) => ({ path, score }))
|
||||
return finalizeResults(results)
|
||||
}
|
||||
|
||||
function fuzzyMatchIndexedFile(query: string, file: QuickOpenIndexedFile): number {
|
||||
@@ -106,42 +111,63 @@ type QuickOpenRankedResult = QuickOpenSearchResult & {
|
||||
inputIndex: number
|
||||
}
|
||||
|
||||
function insertTopResult(
|
||||
results: QuickOpenRankedResult[],
|
||||
function retainTopResult(
|
||||
heap: QuickOpenRankedResult[],
|
||||
candidate: QuickOpenRankedResult,
|
||||
limit: number
|
||||
): void {
|
||||
const worst = results.at(-1)
|
||||
if (results.length === limit && worst && compareRankedResult(candidate, worst) >= 0) {
|
||||
if (heap.length === limit && compareRankedResult(candidate, heap[0]) >= 0) {
|
||||
return
|
||||
}
|
||||
|
||||
const insertAt = findInsertionIndex(results, candidate)
|
||||
results.splice(insertAt, 0, candidate)
|
||||
if (results.length > limit) {
|
||||
results.pop()
|
||||
if (heap.length < limit) {
|
||||
heap.push(candidate)
|
||||
siftResultUp(heap, heap.length - 1)
|
||||
return
|
||||
}
|
||||
|
||||
heap[0] = candidate
|
||||
siftResultDown(heap)
|
||||
}
|
||||
|
||||
function siftResultUp(heap: QuickOpenRankedResult[], startIndex: number): void {
|
||||
let index = startIndex
|
||||
while (index > 0) {
|
||||
const parentIndex = Math.floor((index - 1) / 2)
|
||||
if (compareRankedResult(heap[index], heap[parentIndex]) <= 0) {
|
||||
return
|
||||
}
|
||||
;[heap[index], heap[parentIndex]] = [heap[parentIndex], heap[index]]
|
||||
index = parentIndex
|
||||
}
|
||||
}
|
||||
|
||||
function findInsertionIndex(
|
||||
results: readonly QuickOpenRankedResult[],
|
||||
candidate: QuickOpenRankedResult
|
||||
): number {
|
||||
let low = 0
|
||||
let high = results.length
|
||||
|
||||
while (low < high) {
|
||||
const mid = Math.floor((low + high) / 2)
|
||||
if (compareRankedResult(candidate, results[mid]) < 0) {
|
||||
high = mid
|
||||
} else {
|
||||
low = mid + 1
|
||||
function siftResultDown(heap: QuickOpenRankedResult[]): void {
|
||||
let index = 0
|
||||
while (true) {
|
||||
const leftIndex = index * 2 + 1
|
||||
if (leftIndex >= heap.length) {
|
||||
return
|
||||
}
|
||||
const rightIndex = leftIndex + 1
|
||||
const worseChildIndex =
|
||||
rightIndex < heap.length && compareRankedResult(heap[rightIndex], heap[leftIndex]) > 0
|
||||
? rightIndex
|
||||
: leftIndex
|
||||
if (compareRankedResult(heap[worseChildIndex], heap[index]) <= 0) {
|
||||
return
|
||||
}
|
||||
;[heap[index], heap[worseChildIndex]] = [heap[worseChildIndex], heap[index]]
|
||||
index = worseChildIndex
|
||||
}
|
||||
}
|
||||
|
||||
return low
|
||||
function finalizeResults(results: QuickOpenRankedResult[]): QuickOpenSearchResult[] {
|
||||
return results
|
||||
.sort(compareRankedResult)
|
||||
.map(({ path, score }): QuickOpenSearchResult => ({ path, score }))
|
||||
}
|
||||
|
||||
function compareRankedResult(a: QuickOpenRankedResult, b: QuickOpenRankedResult): number {
|
||||
return a.score - b.score || a.inputIndex - b.inputIndex
|
||||
return a.score - b.score || compareFileNames(a.path, b.path) || a.inputIndex - b.inputIndex
|
||||
}
|
||||
|
||||
@@ -0,0 +1,340 @@
|
||||
// @vitest-environment happy-dom
|
||||
|
||||
import { act } from 'react'
|
||||
import { createRoot, type Root } from 'react-dom/client'
|
||||
import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest'
|
||||
import { TooltipProvider } from '@/components/ui/tooltip'
|
||||
import type { GitBranchCompareSummary } from '../../../../shared/types'
|
||||
import {
|
||||
clearBranchLineTotalRequestGateForTests,
|
||||
getBranchLineTotalMergeBase
|
||||
} from './branch-line-total-request-gate'
|
||||
import SourceControl from './SourceControl'
|
||||
|
||||
const MERGE_BASE = '1f3c0d9a5b6e7f8091a2b3c4d5e6f708192a3b4c'
|
||||
|
||||
const mocks = vi.hoisted(() => {
|
||||
const activeRepo = {
|
||||
id: 'repo-1',
|
||||
path: '/repo',
|
||||
displayName: 'Repo',
|
||||
badgeColor: '#000',
|
||||
addedAt: 0,
|
||||
kind: 'git' as string
|
||||
}
|
||||
const activeWorktree = {
|
||||
id: 'wt-1',
|
||||
repoId: 'repo-1',
|
||||
path: '/repo/wt',
|
||||
head: 'head-1',
|
||||
branch: 'refs/heads/feature/line-total',
|
||||
isBare: false,
|
||||
isMainWorktree: false,
|
||||
displayName: 'feature/line-total',
|
||||
comment: '',
|
||||
linkedIssue: null,
|
||||
linkedPR: null,
|
||||
linkedLinearIssue: null,
|
||||
linkedGitLabMR: null,
|
||||
linkedGitLabIssue: null,
|
||||
isArchived: false,
|
||||
isUnread: false,
|
||||
isPinned: false,
|
||||
sortOrder: 0,
|
||||
lastActivityAt: 0
|
||||
}
|
||||
return {
|
||||
activeRepo,
|
||||
activeWorktree,
|
||||
state: {} as Record<string, unknown>
|
||||
}
|
||||
})
|
||||
|
||||
vi.mock('@/store', () => {
|
||||
const useAppStore = Object.assign(
|
||||
(selector?: (state: Record<string, unknown>) => unknown) =>
|
||||
selector ? selector(mocks.state) : mocks.state,
|
||||
{
|
||||
getState: () => mocks.state
|
||||
}
|
||||
)
|
||||
return { useAppStore }
|
||||
})
|
||||
|
||||
vi.mock('@/store/selectors', () => ({
|
||||
useActiveWorktree: () => mocks.activeWorktree,
|
||||
useRepoById: (repoId: string | null) =>
|
||||
repoId === mocks.activeRepo.id ? mocks.activeRepo : null,
|
||||
useWorktreeMap: () => new Map([[mocks.activeWorktree.id, mocks.activeWorktree]])
|
||||
}))
|
||||
|
||||
vi.mock('@/components/confirmation-dialog-context', () => ({
|
||||
useConfirmationDialog: () => vi.fn().mockResolvedValue(true)
|
||||
}))
|
||||
|
||||
vi.mock('./git-status-refresh', () => ({
|
||||
refreshGitStatusForWorktree: vi.fn().mockResolvedValue(undefined),
|
||||
refreshGitStatusForWorktreeStrict: vi.fn().mockResolvedValue(undefined)
|
||||
}))
|
||||
|
||||
function noopAsync(value: unknown = undefined): () => Promise<unknown> {
|
||||
return vi.fn().mockResolvedValue(value)
|
||||
}
|
||||
|
||||
const readySummary: GitBranchCompareSummary = {
|
||||
baseRef: 'refs/remotes/origin/main',
|
||||
baseOid: 'base-oid',
|
||||
compareRef: 'feature/line-total',
|
||||
headOid: 'head-1',
|
||||
mergeBase: MERGE_BASE,
|
||||
changedFiles: 2,
|
||||
commitsAhead: 1,
|
||||
status: 'ready'
|
||||
}
|
||||
|
||||
function resetState(overrides: Partial<Record<string, unknown>> = {}): void {
|
||||
vi.clearAllMocks()
|
||||
mocks.activeRepo.kind = 'git'
|
||||
mocks.state = {
|
||||
activeWorktreeId: mocks.activeWorktree.id,
|
||||
activeGroupIdByWorktree: { [mocks.activeWorktree.id]: 'group-1' },
|
||||
groupsByWorktree: { [mocks.activeWorktree.id]: [{ id: 'group-1', activeTabId: null }] },
|
||||
repos: [mocks.activeRepo],
|
||||
worktreesByRepo: { [mocks.activeRepo.id]: [mocks.activeWorktree] },
|
||||
rightSidebarOpen: true,
|
||||
rightSidebarTab: 'source-control',
|
||||
gitStatusByWorktree: { [mocks.activeWorktree.id]: [] },
|
||||
gitBranchChangesByWorktree: { [mocks.activeWorktree.id]: [] },
|
||||
gitBranchCompareSummaryByWorktree: { [mocks.activeWorktree.id]: readySummary },
|
||||
gitBranchLineTotalByWorktree: {},
|
||||
gitConflictOperationByWorktree: {},
|
||||
remoteStatusesByWorktree: {},
|
||||
isRemoteOperationActive: false,
|
||||
inFlightRemoteOpKind: null,
|
||||
settings: null,
|
||||
hostedReviewCache: {},
|
||||
prCache: {},
|
||||
commitMessageGenerationRecords: {},
|
||||
pullRequestGenerationRecords: {},
|
||||
openFiles: [],
|
||||
activeFileIdByWorktree: {},
|
||||
activeTabTypeByWorktree: {},
|
||||
getDiffComments: vi.fn(() => []),
|
||||
updateSettings: noopAsync(),
|
||||
openSettingsTarget: vi.fn(),
|
||||
openSettingsPage: vi.fn(),
|
||||
fetchHostedReviewForBranch: noopAsync(),
|
||||
getHostedReviewCreationEligibility: noopAsync(null),
|
||||
createHostedReview: noopAsync({ ok: false, error: 'not available' }),
|
||||
updateWorktreeMeta: noopAsync(),
|
||||
fetchPRForBranch: noopAsync(),
|
||||
enqueueGitHubPRRefresh: vi.fn(),
|
||||
updateRepo: noopAsync(),
|
||||
setGitStatus: vi.fn(),
|
||||
updateWorktreeGitIdentity: vi.fn(),
|
||||
beginGitBranchCompareRequest: vi.fn(() => 'request-key'),
|
||||
setGitBranchCompareResult: vi.fn(),
|
||||
clearGitBranchCompare: vi.fn(),
|
||||
fetchUpstreamStatus: noopAsync(),
|
||||
setUpstreamStatus: vi.fn(),
|
||||
pushBranch: noopAsync(),
|
||||
pullBranch: noopAsync(),
|
||||
fastForwardBranch: noopAsync(),
|
||||
syncBranch: noopAsync(),
|
||||
rebaseFromBase: noopAsync(),
|
||||
fetchBranch: noopAsync(),
|
||||
revealInExplorer: vi.fn(),
|
||||
trackConflictPath: vi.fn(),
|
||||
openDiff: vi.fn(),
|
||||
openFile: vi.fn(),
|
||||
setEditorViewMode: vi.fn(),
|
||||
setMarkdownViewMode: vi.fn(),
|
||||
setPendingEditorReveal: vi.fn(),
|
||||
openConflictFile: vi.fn(),
|
||||
openConflictReview: vi.fn(),
|
||||
openBranchDiff: vi.fn(),
|
||||
createEmptySplitGroup: vi.fn(() => 'group-2'),
|
||||
openAllDiffs: vi.fn(),
|
||||
openBranchAllDiffs: vi.fn(),
|
||||
openCommitAllDiffs: vi.fn(),
|
||||
deleteDiffComment: noopAsync(true),
|
||||
clearDiffComments: noopAsync(true),
|
||||
clearDiffCommentsForFile: noopAsync(true),
|
||||
setScrollToDiffCommentId: vi.fn(),
|
||||
setRightSidebarOpen: vi.fn(),
|
||||
setRightSidebarTab: vi.fn(),
|
||||
allocateCommitMessageGenerationRequestId: vi.fn(() => 'commit-generation-1'),
|
||||
setCommitMessageGenerationRecord: vi.fn(),
|
||||
updateCommitMessageGenerationRecord: vi.fn(),
|
||||
pruneCommitMessageGenerationRecords: vi.fn(),
|
||||
allocatePullRequestGenerationRequestId: vi.fn(() => 'pr-generation-1'),
|
||||
setPullRequestGenerationRecord: vi.fn(),
|
||||
updatePullRequestGenerationRecord: vi.fn(),
|
||||
prunePullRequestGenerationRecords: vi.fn(),
|
||||
...overrides
|
||||
}
|
||||
}
|
||||
|
||||
let container: HTMLDivElement
|
||||
let root: Root
|
||||
|
||||
beforeEach(() => {
|
||||
globalThis.IS_REACT_ACT_ENVIRONMENT = true
|
||||
clearBranchLineTotalRequestGateForTests()
|
||||
resetState()
|
||||
container = document.createElement('div')
|
||||
document.body.appendChild(container)
|
||||
root = createRoot(container)
|
||||
})
|
||||
|
||||
afterEach(() => {
|
||||
act(() => root.unmount())
|
||||
container.remove()
|
||||
clearBranchLineTotalRequestGateForTests()
|
||||
})
|
||||
|
||||
function renderSourceControl(): void {
|
||||
act(() => {
|
||||
root.render(
|
||||
<TooltipProvider>
|
||||
<SourceControl />
|
||||
</TooltipProvider>
|
||||
)
|
||||
})
|
||||
}
|
||||
|
||||
function chip(): HTMLElement | null {
|
||||
return container.querySelector<HTMLElement>('[data-testid="source-control-branch-line-total"]')
|
||||
}
|
||||
|
||||
describe('SourceControl branch line total request gate', () => {
|
||||
it('asks for a total when the panel is visible and compare is ready', () => {
|
||||
renderSourceControl()
|
||||
|
||||
expect(getBranchLineTotalMergeBase(mocks.activeWorktree.id)).toBe(MERGE_BASE)
|
||||
})
|
||||
|
||||
it('asks for nothing while the sidebar is closed', () => {
|
||||
resetState({ rightSidebarOpen: false })
|
||||
renderSourceControl()
|
||||
|
||||
expect(getBranchLineTotalMergeBase(mocks.activeWorktree.id)).toBeUndefined()
|
||||
})
|
||||
|
||||
it('asks for nothing while another right-sidebar tab is showing', () => {
|
||||
resetState({ rightSidebarTab: 'checks' })
|
||||
renderSourceControl()
|
||||
|
||||
expect(getBranchLineTotalMergeBase(mocks.activeWorktree.id)).toBeUndefined()
|
||||
})
|
||||
|
||||
it('asks for nothing until branch compare is ready', () => {
|
||||
resetState({
|
||||
gitBranchCompareSummaryByWorktree: {
|
||||
[mocks.activeWorktree.id]: { ...readySummary, status: 'loading' }
|
||||
}
|
||||
})
|
||||
renderSourceControl()
|
||||
|
||||
expect(getBranchLineTotalMergeBase(mocks.activeWorktree.id)).toBeUndefined()
|
||||
})
|
||||
|
||||
it('asks for nothing when compare has no merge base', () => {
|
||||
resetState({
|
||||
gitBranchCompareSummaryByWorktree: {
|
||||
[mocks.activeWorktree.id]: { ...readySummary, status: 'invalid-base' }
|
||||
}
|
||||
})
|
||||
renderSourceControl()
|
||||
|
||||
expect(getBranchLineTotalMergeBase(mocks.activeWorktree.id)).toBeUndefined()
|
||||
})
|
||||
|
||||
it('asks for nothing in a folder workspace', () => {
|
||||
resetState()
|
||||
mocks.activeRepo.kind = 'folder'
|
||||
renderSourceControl()
|
||||
|
||||
expect(getBranchLineTotalMergeBase(mocks.activeWorktree.id)).toBeUndefined()
|
||||
})
|
||||
|
||||
it('releases the gate when the panel unmounts', () => {
|
||||
renderSourceControl()
|
||||
expect(getBranchLineTotalMergeBase(mocks.activeWorktree.id)).toBe(MERGE_BASE)
|
||||
|
||||
act(() => root.render(<TooltipProvider>{null}</TooltipProvider>))
|
||||
|
||||
expect(getBranchLineTotalMergeBase(mocks.activeWorktree.id)).toBeUndefined()
|
||||
})
|
||||
})
|
||||
|
||||
describe('SourceControl branch line total chip', () => {
|
||||
it('renders a total measured against the current fork point', () => {
|
||||
resetState({
|
||||
gitBranchLineTotalByWorktree: {
|
||||
[mocks.activeWorktree.id]: { added: 8259, removed: 670, mergeBase: MERGE_BASE }
|
||||
}
|
||||
})
|
||||
renderSourceControl()
|
||||
|
||||
expect(chip()?.getAttribute('aria-label')).toBe('8259 additions, 670 deletions')
|
||||
// Grouping is pinned to the app locale (`en`), not the runner's host locale.
|
||||
expect(chip()?.textContent).toBe('+8,259-670')
|
||||
})
|
||||
|
||||
it('drops a total whose fork point has since moved', () => {
|
||||
// Why: status and branch compare refresh on different cadences, so a total
|
||||
// can outlive the merge base it measured. Hidden beats a stale number.
|
||||
resetState({
|
||||
gitBranchLineTotalByWorktree: {
|
||||
[mocks.activeWorktree.id]: { added: 8259, removed: 670, mergeBase: 'stale-merge-base' }
|
||||
}
|
||||
})
|
||||
renderSourceControl()
|
||||
|
||||
expect(chip()).toBeNull()
|
||||
})
|
||||
|
||||
it('drops a published total while branch compare has no ready summary', () => {
|
||||
resetState({
|
||||
gitBranchCompareSummaryByWorktree: {
|
||||
[mocks.activeWorktree.id]: { ...readySummary, status: 'loading', mergeBase: '' }
|
||||
},
|
||||
gitBranchLineTotalByWorktree: {
|
||||
[mocks.activeWorktree.id]: { added: 8259, removed: 670, mergeBase: MERGE_BASE }
|
||||
}
|
||||
})
|
||||
renderSourceControl()
|
||||
|
||||
expect(chip()).toBeNull()
|
||||
})
|
||||
|
||||
it('renders nothing when no total was published', () => {
|
||||
renderSourceControl()
|
||||
|
||||
expect(chip()).toBeNull()
|
||||
})
|
||||
|
||||
it('renders nothing for an exact zero total', () => {
|
||||
resetState({
|
||||
gitBranchLineTotalByWorktree: {
|
||||
[mocks.activeWorktree.id]: { added: 0, removed: 0, mergeBase: MERGE_BASE }
|
||||
}
|
||||
})
|
||||
renderSourceControl()
|
||||
|
||||
expect(chip()).toBeNull()
|
||||
})
|
||||
|
||||
it('omits the zero half of a one-sided total', () => {
|
||||
resetState({
|
||||
gitBranchLineTotalByWorktree: {
|
||||
[mocks.activeWorktree.id]: { added: 42, removed: 0, mergeBase: MERGE_BASE }
|
||||
}
|
||||
})
|
||||
renderSourceControl()
|
||||
|
||||
expect(chip()?.textContent).toBe('+42')
|
||||
expect(chip()?.getAttribute('aria-label')).toBe('42 additions')
|
||||
})
|
||||
})
|
||||
@@ -104,6 +104,7 @@ function resetState(overrides: Partial<Record<string, unknown>> = {}): void {
|
||||
gitStatusByWorktree: { [mocks.activeWorktree.id]: [] },
|
||||
gitBranchChangesByWorktree: { [mocks.activeWorktree.id]: [] },
|
||||
gitBranchCompareSummaryByWorktree: { [mocks.activeWorktree.id]: null },
|
||||
gitBranchLineTotalByWorktree: {},
|
||||
gitConflictOperationByWorktree: {},
|
||||
remoteStatusesByWorktree: {},
|
||||
isRemoteOperationActive: false,
|
||||
|
||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user