refactor(mobile): finish concrete owner and test placement cleanup (#40643)

* refactor(mobile): finish concrete owner and test placement cleanup

* test(mobile): finish whole-case policy cleanup after review
This commit is contained in:
Neil
2026-11-09 22:08:14 -08:00
committed by GitHub
parent c28414c10a
commit f4e285bd9c
47 changed files with 88 additions and 2288 deletions
File diff suppressed because one or more lines are too long
@@ -4,7 +4,6 @@ import '../files/mobile-file-preview-operations'
import { aiVaultResumePreparationSchema } from './mobile-ai-vault-resume-reply-schema'
import '../browser/mobile-session-browser-reply-schema'
import '../files/session-documents/mobile-file-tap-reply-schema'
import '../transport/rpc/rpc-uninterpreted-reply-schemas'
// Legacy resume reads unsupported-method refusals raw to fall back to shared home.
export const aiVaultResumePreparationRun = bindDeferredRpcOperation(
@@ -4,7 +4,6 @@ import '../files/mobile-file-preview-operations'
import { browserTabCreatedSchema } from './mobile-session-browser-reply-schema'
import '../agent-history/mobile-ai-vault-resume-reply-schema'
import '../files/session-documents/mobile-file-tap-reply-schema'
import '../transport/rpc/rpc-uninterpreted-reply-schemas'
export const sessionBrowserTabCreate = bindDeferredRpcOperation(
defineRpcOperation({
@@ -2,13 +2,6 @@ import { bindDeferredRpcOperation, defineRpcOperation } from '../transport/rpc/r
import { rpcResultVariant } from '../transport/rpc/rpc-operation-result-reader'
import '../terminal/terminal-reply-schema'
import { workspaceFilePathsSchema } from './workspace-file-paths-reply-schema'
import '../session/agent-launch/mobile-agent-detection-reply-schema'
import './session-documents/mobile-markdown-tab-reply-schema'
import '../transport/rpc/rpc-uninterpreted-reply-schemas'
import '../terminal/session/mobile-session-terminal-reply-schema'
import '../worktree/session-worktree-record-reply-schema'
import '../session/native-chat/composer/mobile-native-chat-skill-reply-schema'
import '../terminal/quick-commands/mobile-quick-command-reply-schema'
// Skip refusals to retain suggestions; inspect method_not_found first to fall back from search to inventory.
export const nativeChatFileSearchRead = bindDeferredRpcOperation(
@@ -4,7 +4,6 @@ import '../mobile-file-preview-operations'
import { fileTapOpenedSchema } from './mobile-file-tap-reply-schema'
import '../../agent-history/mobile-ai-vault-resume-reply-schema'
import '../../browser/mobile-session-browser-reply-schema'
import '../../transport/rpc/rpc-uninterpreted-reply-schemas'
// Best-effort taps skip refused opens; explicit Changes-screen opens instead show the host error.
export const fileTapOpenRun = bindDeferredRpcOperation(
@@ -19,13 +19,6 @@ export const sessionMarkdownNoteCreate = bindDeferredRpcOperation(
import '../../terminal/terminal-reply-schema'
import { markdownTabDocumentSchema } from './mobile-markdown-tab-reply-schema'
import '../../session/agent-launch/mobile-agent-detection-reply-schema'
import '../../terminal/session/mobile-session-terminal-reply-schema'
import '../../worktree/session-worktree-record-reply-schema'
import '../../session/native-chat/composer/mobile-native-chat-skill-reply-schema'
import '../../terminal/quick-commands/mobile-quick-command-reply-schema'
import '../workspace-file-paths-reply-schema'
// Inspect renderer_unavailable before interpretation so headless hosts can fall back to disk.
export const markdownTabRead = bindDeferredRpcOperation(
@@ -38,8 +31,6 @@ export const markdownTabRead = bindDeferredRpcOperation(
})
)
import '../../session/tabs/mobile-session-tab-write-reply-schema'
import '../../terminal/quick-commands/mobile-quick-command-operations'
export const markdownTabSave = bindDeferredRpcOperation(
@@ -2,13 +2,6 @@ import { bindDeferredRpcOperation, defineRpcOperation } from '../../transport/rp
import { rpcResultVariant } from '../../transport/rpc/rpc-operation-result-reader'
import '../../terminal/terminal-reply-schema'
import { detectedAgentsSchema, refreshedAgentsSchema } from './mobile-agent-detection-reply-schema'
import '../../files/session-documents/mobile-markdown-tab-reply-schema'
import '../../transport/rpc/rpc-uninterpreted-reply-schemas'
import '../../terminal/session/mobile-session-terminal-reply-schema'
import '../../worktree/session-worktree-record-reply-schema'
import '../native-chat/composer/mobile-native-chat-skill-reply-schema'
import '../../terminal/quick-commands/mobile-quick-command-reply-schema'
import '../../files/workspace-file-paths-reply-schema'
// Launch detection surfaces host refusals; advisory task-drawer readers intentionally skip them.
export const preflightDetectAgentsRead = bindDeferredRpcOperation(
@@ -2,13 +2,6 @@ import { bindDeferredRpcOperation, defineRpcOperation } from '../../transport/rp
import { rpcResultVariant } from '../../transport/rpc/rpc-operation-result-reader'
import '../../terminal/terminal-reply-schema'
import { runtimeRepoListSchema } from './mobile-agent-detection-reply-schema'
import '../../files/session-documents/mobile-markdown-tab-reply-schema'
import '../../transport/rpc/rpc-uninterpreted-reply-schemas'
import '../../terminal/session/mobile-session-terminal-reply-schema'
import '../../worktree/session-worktree-record-reply-schema'
import '../native-chat/composer/mobile-native-chat-skill-reply-schema'
import '../../terminal/quick-commands/mobile-quick-command-reply-schema'
import '../../files/workspace-file-paths-reply-schema'
export type MobileRuntimeRepoSummary = {
id: string
+1 -11
View File
@@ -1,9 +1,5 @@
import { describe, expect, it } from 'vitest'
import {
FLOATING_WORKSPACE_WORKTREE_ID,
floatingWorkspaceSessionPath,
isFloatingWorkspaceWorktreeId
} from './floating-workspace'
import { isFloatingWorkspaceWorktreeId } from './floating-workspace'
describe('floating workspace routing', () => {
it('matches only the desktop sentinel id', () => {
@@ -13,10 +9,4 @@ describe('floating workspace routing', () => {
expect(isFloatingWorkspaceWorktreeId(undefined)).toBe(false)
expect(isFloatingWorkspaceWorktreeId(null)).toBe(false)
})
it('builds the session route with an explicit title seed', () => {
expect(floatingWorkspaceSessionPath('host-1')).toBe(
`/h/host-1/session/${FLOATING_WORKSPACE_WORKTREE_ID}?name=Floating%20Workspace`
)
})
})
@@ -1,77 +0,0 @@
import { createElement } from 'react'
import { act, create, type ReactTestInstance, type ReactTestRenderer } from 'react-test-renderer'
import { afterEach, describe, expect, it, vi } from 'vitest'
import { MobileSessionRouteScreen } from './MobileSessionRouteScreen'
// Mocked react-native components are string tags that ReactTestInstance's ElementType omits.
function hostType(node: ReactTestInstance): unknown {
return node.type
}
const state = vi.hoisted(() => ({
terminalPreferencesReady: false,
accessoryPreferencesReady: false,
nativeChatController: { sessionViewReady: false }
}))
const mounts = vi.hoisted(() => ({ count: 0 }))
vi.mock('react-native', () => ({
ActivityIndicator: 'ActivityIndicator',
Text: 'Text',
View: 'View'
}))
vi.mock('./mobile-session-styles', () => ({ styles: {} }))
vi.mock('./use-mobile-session-controller', () => ({ useMobileSessionController: () => state }))
vi.mock('./MobileSessionSurface', async () => {
const react = await import('react')
return {
MobileSessionSurface: () => {
react.useEffect(() => {
mounts.count += 1
}, [])
return react.createElement('ReadySession')
}
}
})
let renderer: ReactTestRenderer | null = null
afterEach(() => {
act(() => renderer?.unmount())
renderer = null
})
describe('first visible session appearance', () => {
it.each(['terminal', 'accessory', 'chat'] as const)(
'waits for the saved %s setting before mounting the session',
(waiting) => {
state.terminalPreferencesReady = waiting !== 'terminal'
state.accessoryPreferencesReady = waiting !== 'accessory'
state.nativeChatController.sessionViewReady = waiting !== 'chat'
mounts.count = 0
const render = () =>
act(() => {
if (renderer) {
renderer.update(createElement(MobileSessionRouteScreen))
} else {
renderer = create(createElement(MobileSessionRouteScreen))
}
})
const expectPreparing = () => {
expect(renderer?.root.findAll((node) => hostType(node) === 'ReadySession')).toHaveLength(0)
expect(
renderer?.root.findAll((node) => hostType(node) === 'ActivityIndicator')
).toHaveLength(1)
expect(mounts.count).toBe(0)
}
render()
expectPreparing()
state.terminalPreferencesReady = true
state.accessoryPreferencesReady = true
state.nativeChatController.sessionViewReady = true
render()
expect(renderer?.root.findAll((node) => hostType(node) === 'ReadySession')).toHaveLength(1)
expect(mounts.count).toBe(1)
render()
expect(mounts.count).toBe(1)
}
)
})
@@ -1,3 +0,0 @@
export type RuntimeStatusResult = {
capabilities?: string[]
}
@@ -1,213 +0,0 @@
import './MobileNativeChatView.harness.test-fixture'
import { createElement, type ReactNode } from 'react'
import { act, create, type ReactTestRenderer } from 'react-test-renderer'
import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest'
import type { NativeChatMessage } from '../../../../src/shared/native-chat/native-chat-types'
import { MobileNativeChatView } from './MobileNativeChatView'
const scrollToEnd = vi.hoisted(() => vi.fn())
const scrollToOffset = vi.hoisted(() => vi.fn())
vi.mock('react-native', async () => {
const React = await import('react')
return {
...(await import('../../test-support/react-native-motion-stub')).reactNativeMotionStub(),
ActivityIndicator: 'ActivityIndicator',
FlatList: React.forwardRef<
unknown,
{
data: NativeChatMessage[]
renderItem: (row: { item: NativeChatMessage; index: number }) => ReactNode
ListFooterComponent: ReactNode
}
>((props, ref) => {
React.useImperativeHandle(ref, () => ({ scrollToEnd, scrollToOffset }), [])
return React.createElement(
'FlatList',
props,
props.data.map((item, index) =>
React.createElement(React.Fragment, { key: item.id }, props.renderItem({ item, index }))
),
props.ListFooterComponent
)
}),
Platform: { OS: 'ios' },
Pressable: 'Pressable',
StyleSheet: { create: (styles: unknown) => styles, hairlineWidth: 1 },
Text: 'Text',
View: 'View'
}
})
vi.mock('react-native-safe-area-context', () => ({
useSafeAreaInsets: () => ({ top: 0, bottom: 0, left: 0, right: 0 })
}))
vi.mock('react-native-gesture-handler', () => {
const chain = {
runOnJS: () => chain,
onStart: () => chain,
onUpdate: () => chain
}
return {
Gesture: { Simultaneous: () => ({}), Native: () => ({}), Pinch: () => chain },
GestureDetector: 'GestureDetector',
GestureHandlerRootView: 'GestureHandlerRootView'
}
})
vi.mock('lucide-react-native', () => ({
ArrowDown: 'ArrowDown',
ChevronsDownUp: 'ChevronsDownUp',
ChevronsUpDown: 'ChevronsUpDown',
Square: 'Square'
}))
vi.mock('./transcript/MobileNativeChatMessage', () => ({
MobileNativeChatMessage: 'ChatMessage'
}))
vi.mock('./transcript/MobileNativeChatLiveLine', () => ({
MobileNativeChatLiveLine: 'LiveStatus'
}))
vi.mock('./input/MobileNativeChatAsk', () => ({ MobileNativeChatAsk: 'ChatAsk' }))
vi.mock('./input/MobileNativeChatPermission', () => ({
MobileNativeChatPermission: 'ChatPermission'
}))
vi.mock('./input/MobileNativeChatQuestion', () => ({
MobileNativeChatQuestion: 'ChatQuestion'
}))
vi.mock('../../components/ActionSheetModal', () => ({ ActionSheetModal: 'ActionSheetModal' }))
vi.mock('./composer/MobileNativeChatComposer', async () => {
const { composerStub } = await import('./MobileNativeChatView.render-stubs.test-fixture')
return composerStub()
})
type Overrides = {
messages?: Parameters<typeof MobileNativeChatView>[0]['messages']
folded?: Parameters<typeof MobileNativeChatView>[0]['folded']
streaming?: string | null
sendErrorMessage?: string | null
onClearSendError?: () => void
inputLockReason?: 'disconnected' | 'waiting' | null
onSend?: (text: string) => Promise<boolean>
pending?: Parameters<typeof MobileNativeChatView>[0]['pending']
structuredActivityUi?: boolean
turnIndicator?: Parameters<typeof MobileNativeChatView>[0]['turnIndicator']
agentWorking?: boolean
canStop?: boolean
ask?: Parameters<typeof MobileNativeChatView>[0]['ask']
question?: Parameters<typeof MobileNativeChatView>[0]['question']
permission?: Parameters<typeof MobileNativeChatView>[0]['permission']
sendSurfaceId?: string
keyboardInset?: number
hasMore?: boolean
onLoadEarlier?: () => void
}
function chatViewElement(overrides: Overrides): ReturnType<typeof createElement> {
return createElement(MobileNativeChatView, {
messages: [],
folded: [],
status: 'ready',
streaming: null,
onSend: vi.fn().mockResolvedValue(true),
sendSurfaceId: 'tab-a',
getSendCompletionGeneration: () => 0,
getComposerEditGeneration: () => 0,
pending: [],
draftKey: null,
onComposerTextChange: vi.fn(),
...overrides
})
}
describe('MobileNativeChatView', () => {
let renderer: ReactTestRenderer | null = null
beforeEach(() => {
vi.stubGlobal('requestAnimationFrame', (callback: FrameRequestCallback) =>
setTimeout(() => callback(0), 0)
)
vi.stubGlobal('cancelAnimationFrame', (handle: ReturnType<typeof setTimeout>) =>
clearTimeout(handle)
)
})
afterEach(() => {
act(() => renderer?.unmount())
renderer = null
scrollToEnd.mockReset()
scrollToOffset.mockReset()
vi.unstubAllGlobals()
})
async function render(overrides: Overrides = {}): Promise<void> {
await act(async () => {
renderer = create(chatViewElement(overrides))
})
}
async function update(overrides: Overrides = {}): Promise<void> {
await act(async () => {
renderer?.update(chatViewElement(overrides))
})
}
function renderedRow(id: string): ReturnType<typeof createElement> {
return createElement(
'Row',
renderer!.root.find(
(node) => String(node.type) === 'ChatMessage' && node.props.message.id === id
).props
)
}
describe('structured turn status wiring', () => {
const userTurn = (id: string, text: string): NativeChatMessage => ({
id,
role: 'user',
blocks: [{ type: 'text', text }],
timestamp: 0,
source: 'transcript'
})
function rowProps(id: string): Record<string, unknown> {
return (renderedRow(id) as { props: Record<string, unknown> }).props
}
it('does not carry a running turn clock across chat surfaces', async () => {
vi.useFakeTimers()
try {
vi.setSystemTime(1_000)
const firstTab = [userTurn('u1', 'first')]
await render({
messages: firstTab,
folded: firstTab,
structuredActivityUi: true,
agentWorking: true,
sendSurfaceId: 'host\0worktree\0tab-a'
})
expect(rowProps('u1').turnStatus).toMatchObject({ startedAt: 1_000 })
vi.setSystemTime(12_000)
const secondTab = [userTurn('u2', 'second')]
await update({
messages: secondTab,
folded: secondTab,
structuredActivityUi: true,
agentWorking: true,
sendSurfaceId: 'host\0worktree\0tab-b'
})
expect(rowProps('u2').turnStatus).toMatchObject({ startedAt: 12_000 })
} finally {
vi.useRealTimers()
}
})
})
})
vi.mock('./input/MobileQueuedMessageComposer', () => ({
MobileQueuedMessageComposer: 'QueuedMessageComposer'
}))
@@ -2,13 +2,6 @@ import { bindDeferredRpcOperation, defineRpcOperation } from '../../../transport
import { rpcResultVariant } from '../../../transport/rpc/rpc-operation-result-reader'
import '../../../terminal/terminal-reply-schema'
import { sessionForwardedReplySchema } from '../../../transport/rpc/rpc-uninterpreted-reply-schemas'
import '../../agent-launch/mobile-agent-detection-reply-schema'
import '../../../files/session-documents/mobile-markdown-tab-reply-schema'
import '../../../terminal/session/mobile-session-terminal-reply-schema'
import '../../../worktree/session-worktree-record-reply-schema'
import '../composer/mobile-native-chat-skill-reply-schema'
import '../../../terminal/quick-commands/mobile-quick-command-reply-schema'
import '../../../files/workspace-file-paths-reply-schema'
// Retain the live window on refusal and let the caller offer retry; read the whole legacy error/window union before selecting messages.
export const nativeChatSessionPageRead = bindDeferredRpcOperation(
@@ -1,166 +0,0 @@
import { createElement } from 'react'
import { act, create } from 'react-test-renderer'
import { afterEach, expect, it, vi } from 'vitest'
import { useMobileNativeChatFileAttachments } from './use-mobile-native-chat-file-attachments'
import { useMobileNativeChatImageAttachments } from './use-mobile-native-chat-image-attachments'
import {
useMobileNativeChatDraft,
setMobileNativeChatDraftText,
resetMobileNativeChatDraftStoreForTests
} from './mobile-native-chat-draft-store'
import {
subscribeMobileNativeChatDraftInputs,
updateMobileNativeChatDraftInputs,
closeMobileNativeChatEditInputs,
useMobileNativeChatDraftInputs
} from './mobile-native-chat-draft-inputs'
const picker = vi.hoisted(() => ({ pickFiles: vi.fn(), pickImages: vi.fn() }))
vi.mock('../../../platform/media-picker', () => ({ useMediaPicker: () => picker }))
afterEach(resetMobileNativeChatDraftStoreForTests)
it.each([20, 200])(
'isolates mounted text, input, image and file consumers across %i other scopes',
async (count) => {
const scopes = [
'own-host:target',
...Array.from({ length: count }, (_, i) => `other-host:${i}`)
]
const own = scopes[0]
const renders = scopes.map(() => ({ text: 0, input: 0, image: 0, file: 0 }))
const values: {
text: string
pending: number
images: readonly string[]
files: readonly string[]
}[] = scopes.map(() => ({ text: '', pending: 0, images: [], files: [] }))
const upstream = scopes.map(() => vi.fn())
const releases = scopes.map((scope, i) =>
subscribeMobileNativeChatDraftInputs(scope, upstream[i])
)
function Text({ index }: { index: number }) {
values[index].text = useMobileNativeChatDraft(scopes[index])
renders[index].text++
return null
}
function Input({ index }: { index: number }) {
values[index].pending = useMobileNativeChatDraftInputs(scopes[index]).pending ?? 0
renders[index].input++
return null
}
function File({ index }: { index: number }) {
const file = useMobileNativeChatFileAttachments({
client: null,
sessionId: null,
scopeKey: scopes[index],
enabled: false,
showToast: vi.fn()
})
values[index].files = file.files.map((entry) => entry.path ?? '')
renders[index].file++
return null
}
function Image({ index }: { index: number }) {
const image = useMobileNativeChatImageAttachments({
client: null,
agent: 'codex',
activeHandleRef: { current: null },
deviceTokenRef: { current: null },
getActiveWorktreeConnectionId: async () => null,
connState: 'disconnected',
scopeKey: scopes[index],
enabled: false,
structuredNativeChat: true,
showToast: vi.fn(),
onSendError: vi.fn(),
baseSend: async () => 'rejected',
readSeededLaunchDraft: () => null
})
values[index].images = image.attachments.map((entry) => entry.path)
renders[index].image++
return null
}
let view: ReturnType<typeof create> | undefined
await act(async () => {
view = create(
createElement(
'fixture',
{},
scopes.flatMap((_, index) => [
createElement(Text, { key: `t${index}`, index }),
createElement(Input, { key: `p${index}`, index }),
createElement(File, { key: `f${index}`, index }),
createElement(Image, { key: `i${index}`, index })
])
)
)
})
renders.forEach((entry) => {
entry.text = entry.input = entry.image = entry.file = 0
})
try {
await act(async () => setMobileNativeChatDraftText(own, 'own text'))
expect(values[0].text).toBe('own text')
expect(renders[0]).toEqual({ text: 1, input: 0, image: 0, file: 0 })
await act(async () =>
updateMobileNativeChatDraftInputs(own, (current) => ({ ...current, pending: 1 }))
)
expect(values[0].pending).toBe(1)
await act(async () =>
updateMobileNativeChatDraftInputs(own, (current) => ({
...current,
images: [{ id: 'image', path: '/saved.png', previewUri: 'file:///saved.png' }]
}))
)
expect(values[0].images).toEqual(['/saved.png'])
await act(async () =>
updateMobileNativeChatDraftInputs(own, (current) => ({
...current,
files: [
{ id: 'file', path: '/saved.txt', name: 'saved.txt', mime: 'text/plain', byteLength: 1 }
]
}))
)
expect(values[0].files).toEqual(['/saved.txt'])
expect(renders[0]).toEqual({ text: 1, input: 3, image: 2, file: 1 })
expect(upstream[0]).toHaveBeenCalledTimes(3)
for (let index = 1; index < scopes.length; index++) {
expect(renders[index]).toEqual({ text: 0, input: 0, image: 0, file: 0 })
expect(upstream[index]).not.toHaveBeenCalled()
}
} finally {
await act(async () => view?.unmount())
releases.forEach((release) => release())
}
await act(async () =>
updateMobileNativeChatDraftInputs(own, (current) => ({ ...current, pending: 0 }))
)
expect(renders[0]).toEqual({ text: 1, input: 3, image: 2, file: 1 })
expect(upstream[0]).toHaveBeenCalledTimes(3)
}
)
it.each([20, 200])(
'notifies only affected scopes across %i retained input subscribers and retires listeners',
(count) => {
const idle = vi.fn()
const releases = Array.from({ length: count }, (_, index) =>
subscribeMobileNativeChatDraftInputs(`idle-${index}`, idle)
)
const source = vi.fn()
const destination = vi.fn()
releases.push(
subscribeMobileNativeChatDraftInputs('queued-edit:one', source),
subscribeMobileNativeChatDraftInputs('bottom', destination)
)
updateMobileNativeChatDraftInputs('queued-edit:one', (inputs) => ({ ...inputs, pending: 1 }))
expect(source).toHaveBeenCalledTimes(1)
expect(idle).not.toHaveBeenCalled()
closeMobileNativeChatEditInputs('queued-edit:one', 'bottom')
expect(source).toHaveBeenCalledTimes(2)
expect(destination).toHaveBeenCalledTimes(1)
expect(idle).not.toHaveBeenCalled()
releases.forEach((release) => release())
updateMobileNativeChatDraftInputs('bottom', (inputs) => ({ ...inputs, pending: 0 }))
expect(destination).toHaveBeenCalledTimes(1)
}
)
@@ -1,8 +1,5 @@
import { describe, expect, it } from 'vitest'
import {
MobileFileTooLargeError,
type PickedMobileFile
} from '../../../platform/media-picker-contract'
import type { PickedMobileFile } from '../../../platform/media-picker-contract'
import {
nativeChatTextWithFileReferences,
uploadPickedNativeChatFiles,
@@ -83,20 +80,4 @@ describe('uploadPickedNativeChatFiles', () => {
expect([...chips.values()].map((chip) => chip.path)).toEqual(['/store/a.txt', '/store/b.txt'])
expect(errors).toEqual([])
})
it('drops the chip of a failed upload, says which file, and carries on', async () => {
const { chips, errors, done } = run(pickedFiles([picked('a.txt'), picked('b.txt')]), 'a.txt')
await done
expect([...chips.values()].map((chip) => chip.name)).toEqual(['b.txt'])
expect(errors).toEqual(["Couldn't upload a.txt"])
})
it('keeps what was stored before the picker refused an oversized file', async () => {
const { chips, errors, done } = run(
pickedFiles([picked('a.txt')], new MobileFileTooLargeError('clip.mov'))
)
await done
expect([...chips.values()].map((chip) => chip.name)).toEqual(['a.txt'])
expect(errors).toEqual([new MobileFileTooLargeError('clip.mov').message])
})
})
@@ -1,18 +0,0 @@
import type { PendingNativeChatImage } from './mobile-native-chat-image-attachment'
export const NO_NATIVE_CHAT_IMAGE_ATTACHMENTS: PendingNativeChatImage[] = []
export type MobileNativeChatImagesByScope = Record<string, PendingNativeChatImage[]>
export function withScopeAttachments<List extends readonly unknown[]>(
byScope: Readonly<Record<string, List>>,
scope: string,
next: List
): Record<string, List> {
if (next.length > 0) {
return { ...byScope, [scope]: next }
}
const remaining = { ...byScope }
delete remaining[scope]
return remaining
}
@@ -2,13 +2,6 @@ import { bindDeferredRpcOperation, defineRpcOperation } from '../../../transport
import { rpcResultVariant } from '../../../transport/rpc/rpc-operation-result-reader'
import '../../../terminal/terminal-reply-schema'
import { skillDiscoveryReplySchema } from './mobile-native-chat-skill-reply-schema'
import '../../agent-launch/mobile-agent-detection-reply-schema'
import '../../../files/session-documents/mobile-markdown-tab-reply-schema'
import '../../../transport/rpc/rpc-uninterpreted-reply-schemas'
import '../../../terminal/session/mobile-session-terminal-reply-schema'
import '../../../worktree/session-worktree-record-reply-schema'
import '../../../terminal/quick-commands/mobile-quick-command-reply-schema'
import '../../../files/workspace-file-paths-reply-schema'
// Mobile names the workspace and lets the host resolve and scan it; see SKILL_DISCOVER_WORKSPACE_CAPABILITY.
export const nativeChatSkillDiscoveryRead = bindDeferredRpcOperation(
@@ -354,52 +354,6 @@ describe('useMobileNativeChatDrafts', () => {
expect(state?.composerText).toBe(' /clear')
})
it('reconciles an image-only unconfirmed send against the next user turn (no false warning)', async () => {
vi.useFakeTimers()
try {
await mount('a')
await act(async () =>
renderer?.update(
createElement(Harness, { tabId: 'a', messages: [assistantTextMessage('a1', 'hi')] })
)
)
const origin = state?.captureSendOrigin('')
const onUnconfirmed = vi.fn()
act(() => {
if (origin) {
state?.holdUnconfirmedSend(origin, '', onUnconfirmed)
}
})
await act(async () =>
renderer?.update(
createElement(Harness, {
tabId: 'a',
messages: [assistantTextMessage('a1', 'hi'), assistantTextMessage('a2', 'ok')]
})
)
)
await act(async () =>
renderer?.update(
createElement(Harness, {
tabId: 'a',
messages: [
assistantTextMessage('a1', 'hi'),
assistantTextMessage('a2', 'ok'),
userTextMessage('u1', '')
]
})
)
)
act(() => {
vi.advanceTimersByTime(30_000)
})
expect(onUnconfirmed).not.toHaveBeenCalled()
} finally {
vi.useRealTimers()
}
})
it('clears image-only echoes one per landed user turn, not all at once', async () => {
await mount('a')
await act(async () =>
@@ -430,59 +384,6 @@ describe('useMobileNativeChatDrafts', () => {
expect(state?.pending.map((pending) => pending.images)).toEqual([['file:///b.jpg']])
})
it('registers no deadline when the transcript echo beat the ambiguous RPC rejection', async () => {
vi.useFakeTimers()
try {
await mount('a')
const origin = state?.captureSendOrigin('ping')
const onUnconfirmed = vi.fn()
await act(async () =>
renderer?.update(
createElement(Harness, { tabId: 'a', messages: [userTextMessage('m1', 'ping')] })
)
)
act(() => {
if (origin) {
state?.holdUnconfirmedSend(origin, 'ping', onUnconfirmed)
}
})
expect(vi.getTimerCount()).toBe(0)
act(() => {
vi.advanceTimersByTime(30_000)
})
expect(onUnconfirmed).not.toHaveBeenCalled()
} finally {
vi.useRealTimers()
}
})
it('surfaces uncertainty when no echo lands before the deadline', async () => {
vi.useFakeTimers()
try {
await mount('a')
const origin = state?.captureSendOrigin('ping')
const onUnconfirmed = vi.fn()
act(() => {
if (origin) {
state?.holdUnconfirmedSend(origin, 'ping', onUnconfirmed)
}
})
act(() => {
vi.advanceTimersByTime(19_999)
})
expect(onUnconfirmed).not.toHaveBeenCalled()
act(() => {
vi.advanceTimersByTime(1)
})
expect(onUnconfirmed).toHaveBeenCalledTimes(1)
} finally {
vi.useRealTimers()
}
})
it('does not confirm an unconfirmed send against an older identical turn', async () => {
vi.useFakeTimers()
try {
@@ -556,63 +457,6 @@ describe('useMobileNativeChatDrafts', () => {
}
})
it('requires one new transcript echo per repeated unconfirmed send', async () => {
vi.useFakeTimers()
try {
await mount('a')
act(() => state?.setComposerText('ping'))
const origin = state?.captureSendOrigin('ping')
const firstUnconfirmed = vi.fn()
const secondUnconfirmed = vi.fn()
act(() => {
if (origin) {
state?.holdUnconfirmedSend(origin, 'ping', firstUnconfirmed)
state?.holdUnconfirmedSend(origin, 'ping', secondUnconfirmed)
}
})
await act(async () =>
renderer?.update(
createElement(Harness, { tabId: 'a', messages: [userTextMessage('echo-1', 'ping')] })
)
)
act(() => {
vi.advanceTimersByTime(30_000)
})
expect(firstUnconfirmed).not.toHaveBeenCalled()
expect(secondUnconfirmed).toHaveBeenCalledTimes(1)
} finally {
vi.useRealTimers()
}
})
it('does not retain a deadline when an ambiguous send settles after unmount', async () => {
vi.useFakeTimers()
try {
await mount('a')
const origin = state?.captureSendOrigin('ping')
const holdUnconfirmedSend = state?.holdUnconfirmedSend
const onUnconfirmed = vi.fn()
act(() => renderer?.unmount())
renderer = null
act(() => {
if (origin) {
holdUnconfirmedSend?.(origin, 'ping', onUnconfirmed)
}
})
expect(vi.getTimerCount()).toBe(0)
act(() => {
vi.advanceTimersByTime(30_000)
})
expect(onUnconfirmed).not.toHaveBeenCalled()
} finally {
vi.useRealTimers()
}
})
it('does not erase newer edits when an unconfirmed send lands', async () => {
await mount('a')
act(() => state?.setComposerText('submitted'))
@@ -52,7 +52,6 @@ type Props = Parameters<typeof MobileNativeChatBackgroundTasks>[0]
type StopResult = Awaited<ReturnType<Props['tasks']['stop']>>
const REJECTED: StopResult = { status: 'rejected' }
const UNCONFIRMED: StopResult = { status: 'unknown' }
const STOPPED: StopResult = { status: 'accepted', value: { cancelled: true }, sameFence: true }
const NOTHING_STOPPED: StopResult = {
status: 'accepted',
@@ -98,20 +97,6 @@ describe('MobileNativeChatBackgroundTasks', () => {
return mounted
}
function styleOf(node: ReactTestInstance): Record<string, unknown> {
const rendered: unknown =
typeof node.props.style === 'function'
? node.props.style({ pressed: false })
: node.props.style
const parts = Array.isArray(rendered) ? rendered : [rendered]
return Object.assign(
{},
...parts.filter(
(part): part is Record<string, unknown> => typeof part === 'object' && part !== null
)
)
}
function header(mounted: ReactTestRenderer): ReactTestInstance {
return mounted.root.findAll(
(node) =>
@@ -132,31 +117,6 @@ describe('MobileNativeChatBackgroundTasks', () => {
)
}
it('stops one row by its provider id and holds its button while the Stop is on its way', async () => {
let finish: (result: StopResult) => void = () => {}
const stop = vi.fn(() => new Promise<StopResult>((resolve) => (finish = resolve)))
const mounted = mount(
tasksFor(
{
state: 'monitoring',
supportsTaskStop: true,
children: [view('a'), view('b', { stoppable: false })]
},
{ stop }
)
)
expand(mounted)
const buttons = stopButtons(mounted)
expect(buttons.map((button) => button.props.accessibilityLabel)).toEqual(['Stop review a'])
act(() => buttons[0]!.props.onPress())
act(() => buttons[0]!.props.onPress())
expect(stop).toHaveBeenCalledTimes(1)
expect(stop).toHaveBeenCalledWith('task-a')
expect(stopButtons(mounted)[0]!.props.disabled).toBe(true)
await act(async () => finish(UNCONFIRMED))
expect(stopButtons(mounted)[0]!.props.disabled).toBe(false)
})
it('keeps a confirmed Stop held until its row leaves, and offers it again if the row comes back', async () => {
let finish: (result: StopResult) => void = () => {}
const stop = vi.fn(() => new Promise<StopResult>((resolve) => (finish = resolve)))
@@ -206,30 +166,4 @@ describe('MobileNativeChatBackgroundTasks', () => {
})
expect(stopButtons(mounted)[0]!.props.disabled).toBe(false)
})
it('offers Stop all only to a host with no per-row stop that still accepts one', async () => {
let finish: (result: StopResult) => void = () => {}
const stop = vi.fn(() => new Promise<StopResult>((resolve) => (finish = resolve)))
const fallback = mount(tasksFor({ state: 'monitoring', children: [view('a')] }, { stop }))
expand(fallback)
expect(stopButtons(fallback).map((button) => button.props.accessibilityLabel)).toEqual([
'Stop background tasks'
])
const button = stopButtons(fallback)[0]!
expect(styleOf(button)).toMatchObject({ minHeight: 32, minWidth: 44 })
expect(button.props.hitSlop).toBe(6)
expect(Number(styleOf(button).minHeight) + 2 * button.props.hitSlop).toBe(44)
act(() => button.props.onPress())
act(() => button.props.onPress())
expect(stop).toHaveBeenCalledTimes(1)
expect(stop).toHaveBeenCalledWith(undefined)
expect(stopButtons(fallback)[0]!.props.disabled).toBe(true)
await act(async () => finish(STOPPED))
expect(stopButtons(fallback)[0]!.props.disabled).toBe(false)
const none = mount(
tasksFor({ state: 'monitoring', supportsStopAll: false, children: [view('a')] })
)
expand(none)
expect(stopButtons(none)).toEqual([])
})
})
@@ -1,56 +0,0 @@
import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest'
import {
mobileNativeChatQuestionOffsets,
scheduleMobileClaudeAnswer,
MOBILE_NATIVE_CHAT_ADVANCE_BUFFER_MS,
MOBILE_NATIVE_CHAT_SUBMIT_DELAY_MS
} from './mobile-native-chat-answer-stepping'
describe('mobileNativeChatQuestionOffsets', () => {
it('paces each question a full step apart, Enter 500ms after its body', () => {
expect(mobileNativeChatQuestionOffsets(0)).toEqual({ bodyAt: 0, enterAt: 500 })
expect(mobileNativeChatQuestionOffsets(1)).toEqual({ bodyAt: 1000, enterAt: 1500 })
expect(mobileNativeChatQuestionOffsets(2)).toEqual({ bodyAt: 2000, enterAt: 2500 })
})
})
describe('scheduleMobileClaudeAnswer', () => {
beforeEach(() => vi.useFakeTimers())
afterEach(() => vi.useRealTimers())
it('writes each question body then its Enter, paced per step, one Enter per line', () => {
const events: string[] = []
const timers = scheduleMobileClaudeAnswer(
['', 'b', 'c'],
(line) => events.push(`body:${line}`),
() => events.push('enter')
)
expect(timers).toHaveLength(6)
vi.advanceTimersByTime(0)
expect(events).toEqual(['body:'])
vi.advanceTimersByTime(MOBILE_NATIVE_CHAT_SUBMIT_DELAY_MS - 1)
expect(events).toEqual(['body:'])
vi.advanceTimersByTime(1)
expect(events).toEqual(['body:', 'enter'])
vi.advanceTimersByTime(MOBILE_NATIVE_CHAT_ADVANCE_BUFFER_MS)
expect(events).toEqual(['body:', 'enter', 'body:b'])
vi.runAllTimers()
expect(events).toEqual(['body:', 'enter', 'body:b', 'enter', 'body:c', 'enter'])
})
it('cancelling the returned timers stops all pending writes', () => {
const events: string[] = []
const timers = scheduleMobileClaudeAnswer(
['a', 'b'],
(line) => events.push(`body:${line}`),
() => events.push('enter')
)
for (const timer of timers) {
clearTimeout(timer)
}
vi.runAllTimers()
expect(events).toEqual([])
})
})
@@ -1,13 +0,0 @@
import {
nativeChatQuestionOffsets,
scheduleNativeChatAnswer,
NATIVE_CHAT_ADVANCE_BUFFER_MS,
NATIVE_CHAT_QUESTION_STEP_MS,
NATIVE_CHAT_SUBMIT_DELAY_MS
} from '../../../../../src/shared/native-chat/input/native-chat-answer-stepping'
export const MOBILE_NATIVE_CHAT_ADVANCE_BUFFER_MS = NATIVE_CHAT_ADVANCE_BUFFER_MS
export const MOBILE_NATIVE_CHAT_QUESTION_STEP_MS = NATIVE_CHAT_QUESTION_STEP_MS
export const MOBILE_NATIVE_CHAT_SUBMIT_DELAY_MS = NATIVE_CHAT_SUBMIT_DELAY_MS
export const mobileNativeChatQuestionOffsets = nativeChatQuestionOffsets
export const scheduleMobileClaudeAnswer = scheduleNativeChatAnswer
@@ -1,261 +0,0 @@
// A structured Claude chat on the desktop, read back with `agentSession.history` after its owner
// approved a Bash call and answered an AskUserQuestion (Claude Code 2.1.300, 2026-10-15).
import type { AgentJournalRenderItem } from '../../../../../src/shared/agent-session/journal/agent-session-journal-types'
export const ANSWERED_PROMPT_ITEMS: AgentJournalRenderItem[] = [
{
itemId: 'orca:1792024009236-87ed41e5430c47008ef7978ca5d60505',
revision: 0,
body: {
kind: 'message',
role: 'user',
blocks: [
{
type: 'text',
text: 'Run the bash command: echo receipt-check > receipt.txt'
}
]
},
sequence: 43,
observedAt: 1792024010442,
turnScope: {
kind: 'thread'
}
},
{
itemId:
'orca:claude-tool%3A2ee61cec-1207-4f44-a915-b6adbcbf169b%3Atoolu_01FhhC72BuRiD3d9hXkAsURD',
revision: 2,
body: {
kind: 'tool-call',
name: 'Bash',
input: {
command: 'echo receipt-check > receipt.txt',
description: 'Write receipt-check to receipt.txt'
},
callId: 'toolu_01FhhC72BuRiD3d9hXkAsURD',
state: 'completed',
output: {
head: '(Bash completed with no output)',
byteLength: 31,
digest: 'cac1685585590361647e7a27b58a25a590f335bfcd0ab70fcdc0424e40c20843',
truncated: false
}
},
sequence: 46,
observedAt: 1792024014854,
turnScope: {
kind: 'turn',
turnItemId:
'legacy:claude:2ee61cec-1207-4f44-a915-b6adbcbf169b:turn-lifecycle%3A6a80bc01-4be4-466f-bd98-5cae8368ed1d'
}
},
{
itemId:
'orca:claude-prompt%3Aclaude_c65b4304_2acf_4eec_a749_e06876328e0c%3A79c45210-730e-482c-82bd-db0708c992e5',
revision: 2,
body: {
kind: 'approval',
title: 'Allow Bash?',
displayName: 'Bash',
description: 'Write receipt-check to receipt.txt',
blockedPath: '/private/tmp/mnw-scratch/receipt.txt',
detail:
'{\n "command": "echo receipt-check > receipt.txt",\n "description": "Write receipt-check to receipt.txt"\n}',
options: [
{
id: 'allow',
label: 'Allow'
},
{
id: 'allowForSession',
label: 'Allow for this session'
},
{
id: 'deny',
label: 'Deny'
}
],
resolution: {
state: 'resolved',
selectedOptionId: 'allow',
resolvedBy: 'desktop-renderer',
resolvedAt: 1792024037937
}
},
sequence: 48,
observedAt: 1792024014910,
turnScope: {
kind: 'turn',
turnItemId:
'legacy:claude:2ee61cec-1207-4f44-a915-b6adbcbf169b:turn-lifecycle%3A6a80bc01-4be4-466f-bd98-5cae8368ed1d'
}
},
{
itemId: 'claude:2ee61cec-1207-4f44-a915-b6adbcbf169b:4408cf7b-bbe2-4a5d-aa80-6471db6d9c18',
revision: 2,
body: {
kind: 'message',
role: 'assistant',
blocks: [
{
type: 'text',
text: 'I ran the command. `receipt.txt` in `/private/tmp/mnw-scratch` now holds the line `receipt-check`.'
}
]
},
sequence: 51,
observedAt: 1792024040901,
turnScope: {
kind: 'turn',
turnItemId:
'legacy:claude:2ee61cec-1207-4f44-a915-b6adbcbf169b:turn-lifecycle%3A6a80bc01-4be4-466f-bd98-5cae8368ed1d'
}
},
{
itemId: 'orca:1792024048049-e58989ce33eb411a970225d03d2e4a18',
revision: 0,
body: {
kind: 'message',
role: 'user',
blocks: [
{
type: 'text',
text: 'Use the AskUserQuestion tool to ask me whether I prefer tea or coffee, then reply with my answer in one short sentence.'
}
]
},
sequence: 57,
observedAt: 1792024048075,
turnScope: {
kind: 'thread'
}
},
{
itemId:
'orca:claude-tool%3A2ee61cec-1207-4f44-a915-b6adbcbf169b%3Atoolu_01WByTgphD4HFByq9zPKXn2J',
revision: 2,
body: {
kind: 'tool-call',
name: 'AskUserQuestion',
input: {
questions: [
{
question: 'Do you prefer tea or coffee?',
header: 'Drink',
multiSelect: false,
options: [
{
label: 'Tea',
description: 'You prefer tea.'
},
{
label: 'Coffee',
description: 'You prefer coffee.'
}
]
}
]
},
callId: 'toolu_01WByTgphD4HFByq9zPKXn2J',
state: 'completed',
output: {
head: 'Your questions have been answered: "Do you prefer tea or coffee?"="Coffee". You can now continue with these answers in mind.',
byteLength: 124,
digest: '65dfb0af315c9ab1c70fc26c6c6bfb4ef0138216e35c89cf332927d10cbb629d',
truncated: false
}
},
sequence: 60,
observedAt: 1792024052205,
turnScope: {
kind: 'turn',
turnItemId:
'legacy:claude:2ee61cec-1207-4f44-a915-b6adbcbf169b:turn-lifecycle%3A1ea7cf09-10c6-4635-93bb-8d4b16fb1c60'
}
},
{
itemId:
'orca:claude-prompt%3Aclaude_c65b4304_2acf_4eec_a749_e06876328e0c%3A6fd8be6a-830f-44b4-a3ef-a7f044cd4a4f',
revision: 2,
body: {
kind: 'question',
question: 'Do you prefer tea or coffee?',
options: [
{
id: 'q1:choice-1',
label: 'Tea',
description: 'You prefer tea.'
},
{
id: 'q1:choice-2',
label: 'Coffee',
description: 'You prefer coffee.'
}
],
freeTextQuestionId: 'q1',
questions: [
{
id: 'q1',
question: 'Do you prefer tea or coffee?',
header: 'Drink',
multiSelect: false,
options: [
{
id: 'q1:choice-1',
label: 'Tea',
description: 'You prefer tea.'
},
{
id: 'q1:choice-2',
label: 'Coffee',
description: 'You prefer coffee.'
}
],
freeTextQuestionId: 'q1'
}
],
resolution: {
state: 'resolved',
selectedOptionId: 'question-group:[{"questionId":"q1","optionIds":["q1:choice-2"]}]',
answers: [
{
questionId: 'q1',
optionIds: ['q1:choice-2']
}
],
resolvedBy: 'desktop-renderer',
resolvedAt: 1792024081980
}
},
sequence: 62,
observedAt: 1792024052228,
turnScope: {
kind: 'turn',
turnItemId:
'legacy:claude:2ee61cec-1207-4f44-a915-b6adbcbf169b:turn-lifecycle%3A1ea7cf09-10c6-4635-93bb-8d4b16fb1c60'
}
},
{
itemId: 'claude:2ee61cec-1207-4f44-a915-b6adbcbf169b:32c8a879-b68c-41ba-9a9e-4b844fa07890',
revision: 2,
body: {
kind: 'message',
role: 'assistant',
blocks: [
{
type: 'text',
text: 'You prefer coffee.'
}
]
},
sequence: 65,
observedAt: 1792024084176,
turnScope: {
kind: 'turn',
turnItemId:
'legacy:claude:2ee61cec-1207-4f44-a915-b6adbcbf169b:turn-lifecycle%3A1ea7cf09-10c6-4635-93bb-8d4b16fb1c60'
}
}
]
@@ -8,7 +8,7 @@ import {
import type { AskAnswerSelection } from '../../../../../src/shared/native-chat/input/native-chat-ask-answer'
import type { AskPrompt } from '../../../../../src/shared/native-chat/input/native-chat-ask-types'
import type { RpcClient } from '../../../transport/rpc/rpc-client-contract'
import { MOBILE_NATIVE_CHAT_QUESTION_STEP_MS } from './mobile-native-chat-answer-stepping'
import { NATIVE_CHAT_QUESTION_STEP_MS } from '../../../../../src/shared/native-chat/input/native-chat-answer-stepping'
import {
openMobileNativeChatSendBudget,
sendMobileNativeChatMessageWithOutcome
@@ -229,11 +229,11 @@ export function useMobileNativeChatAnswerSend(args: {
return fail()
}
if (index < groups.length - 1) {
if (!(await wait(MOBILE_NATIVE_CHAT_QUESTION_STEP_MS))) {
if (!(await wait(NATIVE_CHAT_QUESTION_STEP_MS))) {
return false
}
// Pacing is deliberate, not transport latency — don't charge it to the budget.
deadline += MOBILE_NATIVE_CHAT_QUESTION_STEP_MS
deadline += NATIVE_CHAT_QUESTION_STEP_MS
}
}
return groups.length > 0 && writeTurnsRef.current.get(handle) === turn
@@ -1,107 +0,0 @@
// One phone chat view serves every chat tab, so returning to a chat must not wait on its next read.
import { act } from 'react-test-renderer'
import { expect, it } from 'vitest'
import type { AgentSessionOptionsResult } from '../../../../../src/shared/agent-session/options/agent-session-options-result'
import {
BASE,
OPTIONS,
accepted,
currentValueOf,
deferred,
mountOptions,
optionsClient,
recordingMutate,
settle
} from './use-mobile-structured-agent-options.test-fixture'
const COMMAND = 'compact'
const CHAT_A: AgentSessionOptionsResult = {
...OPTIONS,
acceptsImages: true,
conversationCommands: [COMMAND],
current: {
model: 'gpt-fast',
effort: 'low',
permissionMode: 'own',
reportedPermissionMode: 'ask',
acceptedInputSources: { model: 'saved', effort: 'saved' }
}
}
const CHAT_B: AgentSessionOptionsResult = {
...OPTIONS,
acceptsImages: false,
current: { model: 'gpt-live', effort: 'high', permissionMode: 'ask' }
}
/** Host replies per chat; later reads for chat A wait until the test answers them. */
function chats() {
const later: ReturnType<typeof deferred<AgentSessionOptionsResult>>[] = []
let readsOfA = 0
let readingB = false
const client = optionsClient(() => {
if (readingB) {
return Promise.resolve(CHAT_B)
}
if (readsOfA++ === 0) {
return Promise.resolve(CHAT_A)
}
const reply = deferred<AgentSessionOptionsResult>()
later.push(reply)
return reply.promise
})
const send = client.client.sendRequest
client.client.sendRequest = (method, params, options) => {
if (method === 'agentSession.options') {
readingB = JSON.stringify(params).includes('chat-b')
}
return send(method, params, options)
}
return { client, later }
}
it("shows a chat's last answer on return, without its reports, until its new read lands", async () => {
const { client, later } = chats()
const { mutate } = recordingMutate(async () => accepted({ key: 'model', value: 'x' }, true))
const harness = await mountOptions({
...BASE,
sessionId: 'chat-a',
fence: 1,
client: client.client,
mutate,
permissionOwn: true,
sendSelection: true
})
await harness.rerender({ sessionId: 'chat-b', fence: 7 })
expect(currentValueOf(harness.current().optionSnapshot, 'model')).toBe('gpt-live')
const commitsOnB = harness.commits.length
await harness.rerender({ sessionId: 'chat-a', fence: 2 })
expect(later).toHaveLength(1)
// From the first commit on return, not only after a later effect catches up.
for (const commit of harness.commits.slice(commitsOnB)) {
expect(currentValueOf(commit.optionSnapshot, 'model')).toBe('gpt-fast')
}
const shown = harness.current()
expect(currentValueOf(shown.optionSnapshot, 'model')).toBe('gpt-fast')
expect(currentValueOf(shown.optionSnapshot, 'effort')).toBe('low')
expect(shown.conversationCommands).toEqual([COMMAND])
expect(shown.acceptsImages).toBe(true)
// Saved permission intent survives a restart independently of runtime reports.
expect(currentValueOf(shown.optionSnapshot, 'permissionMode')).toBe('own')
expect(shown.sendSelection()).toEqual({ selection: {} })
await act(async () => {
later[0].resolve({
...CHAT_A,
conversationCommands: [],
current: { ...CHAT_A.current, model: 'gpt-live' }
})
})
await settle()
const answered = harness.current()
expect(currentValueOf(answered.optionSnapshot, 'model')).toBe('gpt-live')
expect(currentValueOf(answered.optionSnapshot, 'permissionMode')).toBe('own')
expect(answered.conversationCommands).toEqual([])
expect(answered.sendSelection()).toEqual({ selection: { model: 'gpt-live', effort: 'low' } })
await harness.unmount()
})
@@ -1,24 +0,0 @@
import { describe, expect, it } from 'vitest'
import { diffFromText } from '../../../../src/shared/native-chat/tools/native-chat-diff'
describe('diffFromText', () => {
it('caps large diffs before creating render rows', () => {
const text = Array.from(
{ length: 1_000 },
(_unused, index) => `${index % 2 === 0 ? '+' : '-'}line-${index}`
).join('\n')
const lines = diffFromText(text)
expect(lines).toHaveLength(120)
expect(lines?.at(-1)).toEqual({ kind: 'meta', text: '… diff truncated …' })
})
it('honors a smaller caller-owned aggregate row budget', () => {
const text = Array.from(
{ length: 100 },
(_unused, index) => `${index % 2 === 0 ? '+' : '-'}line-${index}`
).join('\n')
expect(diffFromText(text, 20)).toHaveLength(20)
})
})
@@ -1,49 +0,0 @@
import { describe, expect, it } from 'vitest'
import { mergeNativeChatMessages } from '../../../../src/shared/native-chat/native-chat-merge'
import type { NativeChatMessage } from '../../../../src/shared/native-chat/native-chat-types'
function msg(id: string, overrides: Partial<NativeChatMessage> = {}): NativeChatMessage {
return {
id,
role: 'assistant',
blocks: [{ type: 'text', text: id }],
timestamp: 0,
source: 'transcript',
...overrides
}
}
describe('mergeNativeChatMessages', () => {
it('appends new ids in arrival order', () => {
const merged = mergeNativeChatMessages([msg('a')], [msg('b'), msg('c')])
expect(merged.map((m) => m.id)).toEqual(['a', 'b', 'c'])
})
it('replaces a re-emitted id in place without reordering', () => {
const merged = mergeNativeChatMessages(
[msg('a'), msg('b'), msg('c')],
[msg('b', { blocks: [{ type: 'text', text: 'updated' }] })]
)
expect(merged.map((m) => m.id)).toEqual(['a', 'b', 'c'])
expect(merged[1]!.blocks).toEqual([{ type: 'text', text: 'updated' }])
})
it('does not let a lower-priority source overwrite a higher one', () => {
const merged = mergeNativeChatMessages(
[msg('a', { source: 'transcript', blocks: [{ type: 'text', text: 'real' }] })],
[msg('a', { source: 'scrape', blocks: [{ type: 'text', text: 'scraped' }] })]
)
expect(merged[0]!.blocks).toEqual([{ type: 'text', text: 'real' }])
})
it('returns the existing array unchanged for an empty batch', () => {
const existing = [msg('a')]
expect(mergeNativeChatMessages(existing, [])).toBe(existing)
})
it('does not mutate the existing array', () => {
const existing = [msg('a')]
mergeNativeChatMessages(existing, [msg('b')])
expect(existing.map((m) => m.id)).toEqual(['a'])
})
})
@@ -1,67 +0,0 @@
import { describe, expect, it } from 'vitest'
import type { NativeChatMessage } from '../../../../src/shared/native-chat/native-chat-types'
import {
applyAppend,
createNativeChatMerger,
mergeNativeChatMessages,
replaceList
} from '../../../../src/shared/native-chat/native-chat-merge'
function msg(id: string, overrides: Partial<NativeChatMessage> = {}): NativeChatMessage {
return {
id,
role: 'assistant',
blocks: [{ type: 'text', text: id }],
timestamp: 0,
source: 'transcript',
...overrides
}
}
// Compare each adversarial prefix with the pure merger so cached indexes cannot diverge from a full rebuild.
describe('mobile stateful merger — oracle vs pure merge', () => {
const base = [msg('a'), msg('b', { source: 'hook' })]
const batches: NativeChatMessage[][] = [
[msg('c')],
[],
[msg('b', { source: 'transcript', blocks: [{ type: 'text', text: 'final' }] })],
[msg('b', { source: 'scrape' })],
[msg('a')],
[msg('d'), msg('e')]
]
it('matches the pure merge for every prefix', () => {
const merger = createNativeChatMerger()
replaceList(merger, base)
let pure = mergeNativeChatMessages(base, [])
expect(merger.list).toEqual(pure)
for (const batch of batches) {
const out = applyAppend(merger, batch)
pure = mergeNativeChatMessages(pure, batch)
expect(out).toEqual(pure)
}
})
it('keeps prior row identity on a pure tail append', () => {
const merger = createNativeChatMerger()
replaceList(merger, base)
const before = merger.list
const tail = msg('z')
const after = applyAppend(merger, [tail])
expect(after).not.toBe(before)
expect(after[0]).toBe(before[0])
expect(after[after.length - 1]).toBe(tail)
})
it('bounds live appends and rebuilds the cached indexes for the retained tail', () => {
const merger = createNativeChatMerger()
replaceList(merger, [msg('a'), msg('b'), msg('c')])
expect(applyAppend(merger, [msg('d'), msg('e')], 3).map((m) => m.id)).toEqual(['c', 'd', 'e'])
expect(merger.indexById.has('a')).toBe(false)
expect(merger.indexById.get('c')).toBe(0)
expect(
applyAppend(merger, [msg('d', { blocks: [{ type: 'text', text: 'updated' }] })], 3)[1]
).toMatchObject({ id: 'd', blocks: [{ type: 'text', text: 'updated' }] })
})
})
@@ -1,147 +0,0 @@
import { createElement, type ReactNode } from 'react'
import { act, create, type ReactTestInstance, type ReactTestRenderer } from 'react-test-renderer'
import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest'
import type { AppStateStatus } from 'react-native'
const appState = vi.hoisted(() => ({
current: 'active',
listeners: new Set<(state: AppStateStatus) => void>()
}))
vi.mock('react-native', async () => {
const React = await import('react')
const Text = ({ children, ...props }: { children?: ReactNode }): unknown =>
React.createElement('Text', props, children)
return {
AppState: {
get currentState() {
return appState.current
},
addEventListener: (_event: string, listener: (state: AppStateStatus) => void) => {
appState.listeners.add(listener)
return {
remove: () => {
appState.listeners.delete(listener)
}
}
}
},
ActivityIndicator: (props: Record<string, unknown>) =>
React.createElement('ActivityIndicator', props),
Pressable: ({ children, ...props }: { children?: ReactNode }) =>
React.createElement('Pressable', props, children),
Text,
View: ({ children, ...props }: { children?: ReactNode }) =>
React.createElement('View', props, children),
StyleSheet: { create: (styles: unknown) => styles, hairlineWidth: 1 }
}
})
vi.mock('lucide-react-native', () => ({ ChevronRight: 'ChevronRight' }))
import { MobileNativeChatTurnStatus } from './MobileNativeChatTurnStatus'
import { findAllByHostType } from '../../../test-support/react-test-renderer-host-nodes'
const labels = (node: ReactTestInstance): string[] =>
findAllByHostType(node, 'Text').map((text) => String(text.children.join('')))
let renderer: ReactTestRenderer | null = null
beforeEach(() => {
vi.useFakeTimers()
vi.setSystemTime(new Date('2026-09-04T00:00:00Z'))
appState.current = 'active'
appState.listeners.clear()
})
afterEach(() => {
act(() => renderer?.unmount())
renderer = null
vi.restoreAllMocks()
vi.useRealTimers()
})
describe('MobileNativeChatTurnStatus', () => {
function changeAppState(state: AppStateStatus) {
act(() => {
appState.current = state
for (const listener of appState.listeners) {
listener(state)
}
})
}
function render(props: {
startedAt: number | null
workedSeconds?: number | null
verdict?: 'interruption' | 'cancellation'
expanded?: boolean
onToggleExpanded?: () => void
}): ReactTestRenderer {
act(() => {
renderer = create(createElement(MobileNativeChatTurnStatus, props))
})
return renderer!
}
it('stops clock callbacks in the background and catches up immediately on resume', () => {
const tree = render({ startedAt: Date.now() })
act(() => {
vi.advanceTimersByTime(2_000)
})
expect(labels(tree.root)).toEqual(['Working for 2s'])
changeAppState('background')
const clockReads = vi.spyOn(Date, 'now')
act(() => {
vi.advanceTimersByTime(60_000)
})
expect(clockReads).not.toHaveBeenCalled()
expect(vi.getTimerCount()).toBe(0)
expect(labels(tree.root)).toEqual(['Working for 2s'])
changeAppState('active')
expect(labels(tree.root)).toEqual(['Working for 1m 2s'])
expect(vi.getTimerCount()).toBe(1)
act(() => {
vi.advanceTimersByTime(1_000)
})
expect(labels(tree.root)).toEqual(['Working for 1m 3s'])
})
it('keeps the original pre-stamp epoch through background and settlement transitions', () => {
const tree = render({ startedAt: null })
act(() => {
vi.advanceTimersByTime(3_000)
})
expect(labels(tree.root)).toEqual(['Working for 3s'])
changeAppState('background')
act(() => {
vi.advanceTimersByTime(10_000)
})
act(() =>
tree.update(createElement(MobileNativeChatTurnStatus, { startedAt: null, workedSeconds: 8 }))
)
changeAppState('active')
expect(labels(tree.root)).toEqual(['Worked for 8s'])
expect(vi.getTimerCount()).toBe(0)
act(() => tree.update(createElement(MobileNativeChatTurnStatus, { startedAt: null })))
expect(labels(tree.root)).toEqual(['Working for 13s'])
expect(vi.getTimerCount()).toBe(1)
act(() => tree.unmount())
expect(vi.getTimerCount()).toBe(0)
expect(appState.listeners.size).toBe(0)
})
it('creates no clock while initially backgrounded and uses a supplied start time on resume', () => {
appState.current = 'background'
const startedAt = Date.now() - 5_000
const tree = render({ startedAt })
expect(vi.getTimerCount()).toBe(0)
act(() => {
vi.advanceTimersByTime(15_000)
})
changeAppState('active')
expect(labels(tree.root)).toEqual(['Working for 20s'])
expect(vi.getTimerCount()).toBe(1)
})
})
@@ -63,15 +63,11 @@ vi.mock('lucide-react-native', () => ({
Wrench: 'Wrench',
Terminal: 'Terminal'
}))
const proseRenders = vi.hoisted(() => ({ count: 0 }))
export function proseRenderCount() {
return proseRenders.count
}
vi.mock('../../../components/MobileMarkdown', async () => {
const React = await import('react')
return {
MobileMarkdown: (props: ComponentProps<typeof MobileMarkdownComponent>) => {
proseRenders.count++
return React.createElement('MobileMarkdown', props)
}
}
@@ -0,0 +1,62 @@
import { describe, expect, it } from 'vitest'
import {
createNativeChatMerger,
replaceList
} from '../../../../../src/shared/native-chat/native-chat-merge'
import type { NativeChatMessage } from '../../../../../src/shared/native-chat/native-chat-types'
import { applyMobileNativeChatStreamFrame } from './mobile-native-chat-stream-frame'
function message(id: string): NativeChatMessage {
return {
id,
role: 'assistant',
blocks: [{ type: 'text', text: id }],
timestamp: 0,
source: 'transcript'
}
}
describe('applyMobileNativeChatStreamFrame', () => {
it('replaces a partially overlapping replay that no longer extends the retained tail', () => {
const merger = createNativeChatMerger()
replaceList(merger, ['old-1', 'shared', 'old-tail'].map(message))
// Keep hasMore true so only the contiguity check can reject the interleaved row.
const replay = [message('shared'), message('compacted-summary'), message('old-tail')]
expect(
applyMobileNativeChatStreamFrame({
merger,
frame: { type: 'snapshot', messages: replay, hasMore: true, beforeOffset: 9 },
limit: 100,
replaceSnapshot: false
})
).toEqual({
kind: 'messages',
messages: replay,
hasMore: true,
beforeOffset: 9,
windowReplaced: true
})
})
it('replaces retained history when replay metadata says no earlier rows remain', () => {
const merger = createNativeChatMerger()
replaceList(merger, ['removed-1', 'removed-2', 'a', 'b'].map(message))
const replay = [message('a'), message('b')]
expect(
applyMobileNativeChatStreamFrame({
merger,
frame: { type: 'snapshot', messages: replay, hasMore: false, beforeOffset: 0 },
limit: 100,
replaceSnapshot: false
})
).toEqual({
kind: 'messages',
messages: replay,
hasMore: false,
beforeOffset: 0,
windowReplaced: true
})
})
})
@@ -1,47 +0,0 @@
import { describe, expect, it, vi } from 'vitest'
import type { SupportedUiLocale } from '../../../../../src/shared/ui-locale'
import {
joinNativeChatToolRunClauses,
nativeChatToolRunSentence
} from '../../../../../src/shared/native-chat/tools/native-chat-tool-run-sentence'
import { getMobileUiLocale } from '../../../i18n/mobile-i18n'
import { nativeChatToolRunWords } from './native-chat-tool-run-words'
vi.mock('../../../i18n/mobile-i18n', () => ({
getMobileUiLocale: vi.fn(() => 'en')
}))
const joiners = {
pair: (first: string, second: string) => `${first} | ${second}`,
list: (leading: readonly string[], last: string) => [...leading, last].join(' | ')
}
function mobileWords(locale: SupportedUiLocale) {
vi.mocked(getMobileUiLocale).mockReturnValue(locale)
return { ...nativeChatToolRunWords((_key, fallback) => fallback), ...joiners }
}
describe('mobile tool-run sentence continuations', () => {
it('keeps German noun capitalization through the sentence formatter', () => {
const words = mobileWords('de')
expect(
nativeChatToolRunSentence(
[
{ type: 'tool-call', name: 'read', input: {} },
{ type: 'tool-call', name: 'Bash', input: { command: 'orca snapshot --page p1' } }
],
{},
{
...words,
clause: (category) => (category === 'browser' ? 'Browser verwendet' : 'Datei gelesen')
}
)
).toBe('Datei gelesen | Browser verwendet')
})
it('uses Turkish casing for both dotted and dotless I in later clauses', () => {
expect(joinNativeChatToolRunClauses(['Başladı', 'İnceledi', 'Isındı'], mobileWords('tr'))).toBe(
'Başladı | inceledi | ısındı'
)
})
})
@@ -1,87 +0,0 @@
import { expect, it } from 'vitest'
import { nativeChatTurnDrawOrder } from '../../../../../src/shared/native-chat/transcript/native-chat-turn-grouping'
import { nativeChatTurnMembership } from '../../../../../src/shared/native-chat/transcript/native-chat-turn-membership'
import type {
AgentJournalItemBody,
AgentJournalRenderItem
} from '../../../../../src/shared/agent-session/journal/agent-session-journal-types'
it.each([10, 100])(
'reads turn ownership linearly for %i queued openers and earlier-turn rows',
(count) => {
const messages = [
{ id: 'first', role: 'user' as const },
...Array.from({ length: count }, (_, index) => ({
id: `queued-${index}`,
role: 'user' as const
})),
...Array.from({ length: count }, (_, index) => ({
id: `tail-${index}`,
role: 'assistant' as const
})),
...Array.from({ length: count }, (_, index) => ({
id: `answer-${index}`,
role: 'assistant' as const
}))
]
const keys = [
'first',
...Array.from({ length: count }, (_, index) => `queued-${index}`),
...Array.from({ length: count }, () => 'first'),
...Array.from({ length: count }, (_, index) => `queued-${index}`)
]
const openers = new Set(keys.slice(0, count + 1))
let reads = 0
keys.forEach((key, index) => {
Object.defineProperty(keys, index, {
get: () => {
reads += 1
return key
}
})
})
const order = nativeChatTurnDrawOrder(messages, keys, openers)
const visits = reads
expect(order?.map((index) => messages[index]?.id)).toEqual([
'first',
...Array.from({ length: count }, (_, index) => `tail-${index}`),
...Array.from({ length: count }, (_, index) => [`queued-${index}`, `answer-${index}`]).flat()
])
const journalItems: AgentJournalRenderItem[] = []
const append = (itemId: string, body: AgentJournalItemBody, turnItemId: string) => {
const sequence = journalItems.length + 1
journalItems.push({
itemId,
body,
revision: 0,
sequence,
observedAt: sequence,
turnScope: { kind: 'turn', turnItemId }
})
}
append('first', { kind: 'message', role: 'user', blocks: [] }, 'turn-first')
append(
'turn-first',
{ kind: 'turn', turnId: 'first', state: 'completed', userItemId: 'first' },
'turn-first'
)
for (let index = 0; index < count; index += 1) {
append(`queued-${index}`, { kind: 'message', role: 'user', blocks: [] }, `turn-${index}`)
}
for (let index = 0; index < count; index += 1) {
append(`tail-${index}`, { kind: 'message', role: 'assistant', blocks: [] }, 'turn-first')
}
for (let index = 0; index < count; index += 1) {
append(
`turn-${index}`,
{ kind: 'turn', turnId: `t${index}`, state: 'completed', userItemId: `queued-${index}` },
`turn-${index}`
)
append(`answer-${index}`, { kind: 'message', role: 'assistant', blocks: [] }, `turn-${index}`)
}
const membership = nativeChatTurnMembership(messages, { items: journalItems, submissions: [] })
expect(membership.turnKeys).toEqual(keys)
expect(membership.drawOrder).toEqual(order)
expect(visits).toBeLessThanOrEqual(messages.length * 4)
}
)
@@ -5,19 +5,13 @@ import { useMobileNativeChatTerminalStream } from './use-mobile-native-chat-term
describe('useMobileNativeChatTerminalStream', () => {
let renderer: ReactTestRenderer | null = null
let harnessRenderCount = 0
const subscriptionsRef = { current: new Map<string, () => void>() }
const subscribingRef = { current: new Set<string>() }
const leaseOnlyRef = { current: new Set<string>() }
const webReadyRef = { current: new Set(['terminal-1']) }
let subscribeOpensLeaseOnly = false
const registerSubscription = (handle: string): boolean => {
subscriptionsRef.current.set(handle, () => {})
if (subscribeOpensLeaseOnly) {
leaseOnlyRef.current.add(handle)
} else {
leaseOnlyRef.current.delete(handle)
}
leaseOnlyRef.current.delete(handle)
return true
}
const subscribe = vi.fn(registerSubscription)
@@ -25,7 +19,6 @@ describe('useMobileNativeChatTerminalStream', () => {
subscriptionsRef.current.delete(handle)
leaseOnlyRef.current.delete(handle)
})
const notifyWebReadyRef = { current: (_handle: string, _wasAlreadyReady: boolean): void => {} }
const notifyListedHandlesRef = { current: (_liveHandles: ReadonlySet<string>): void => {} }
const hasTabsRecoveryNeedRef = { current: (): boolean => false }
@@ -33,9 +26,7 @@ describe('useMobileNativeChatTerminalStream', () => {
subscriptionsRef.current = new Map([['terminal-1', () => {}]])
subscribingRef.current = new Set()
leaseOnlyRef.current = new Set()
subscribeOpensLeaseOnly = false
webReadyRef.current = new Set(['terminal-1'])
harnessRenderCount = 0
subscribe.mockClear()
unsubscribe.mockClear()
})
@@ -56,7 +47,6 @@ describe('useMobileNativeChatTerminalStream', () => {
leaseReady?: boolean
streamRevision?: number
}): null {
harnessRenderCount += 1
const stream = useMobileNativeChatTerminalStream({
showNativeChat,
activeHandle,
@@ -70,7 +60,6 @@ describe('useMobileNativeChatTerminalStream', () => {
subscribe,
unsubscribe
})
notifyWebReadyRef.current = stream.notifyWebReady
notifyListedHandlesRef.current = stream.notifyListedHandles
hasTabsRecoveryNeedRef.current = stream.hasTabsRecoveryNeed
return null
@@ -95,244 +84,6 @@ describe('useMobileNativeChatTerminalStream', () => {
})
}
// A synchronous tab switch still sees old chat coverage and opens the incoming terminal as lease-only.
function handOffToTerminalTwo(): void {
subscribeOpensLeaseOnly = true
subscribe('terminal-2')
subscribeOpensLeaseOnly = false
subscribe.mockClear()
unsubscribe.mockClear()
}
it('trades a lease-only stream for output when a chat tab hands off to a terminal tab', async () => {
webReadyRef.current.add('terminal-2')
await act(async () => {
renderer = create(createElement(Harness, { showNativeChat: true }))
})
handOffToTerminalTwo()
await act(async () => {
renderer?.update(
createElement(Harness, { showNativeChat: false, activeHandle: 'terminal-2' })
)
})
expect(unsubscribe).toHaveBeenCalledWith('terminal-2')
expect(subscribe).toHaveBeenCalledWith('terminal-2')
expect(leaseOnlyRef.current.has('terminal-2')).toBe(false)
})
it('repairs a lease-only stream once its WebView reports ready', async () => {
await act(async () => {
renderer = create(createElement(Harness, { showNativeChat: true }))
})
handOffToTerminalTwo()
await act(async () => {
renderer?.update(
createElement(Harness, { showNativeChat: false, activeHandle: 'terminal-2' })
)
})
// A cold WebView must wait; its normal ready path skips already-live lease subscriptions.
expect(subscribe).not.toHaveBeenCalled()
await act(async () => {
webReadyRef.current.add('terminal-2')
notifyWebReadyRef.current('terminal-2', false)
})
expect(unsubscribe).toHaveBeenCalledWith('terminal-2')
expect(subscribe).toHaveBeenCalledWith('terminal-2')
})
it.each([true, false])(
'stops rearming a handle whose stream never comes back (covered: %s, #10681)',
async (showNativeChat) => {
await act(async () => {
renderer = create(createElement(Harness, { showNativeChat }))
})
subscribe.mockClear()
// Dead PTYs answer subscribed/end repeatedly; rearm must stop rather than loop through host waits.
for (let revision = 1; revision <= 6; revision += 1) {
subscriptionsRef.current.delete('terminal-1')
await act(async () => {
renderer?.update(
createElement(Harness, {
showNativeChat,
leaseReady: false,
streamRevision: revision
})
)
})
}
expect(subscribe.mock.calls.length).toBeLessThanOrEqual(3)
}
)
it('does not charge the rearm budget for a subscribe its own gates turned away', async () => {
try {
await act(async () => {
renderer = create(createElement(Harness, { showNativeChat: true }))
})
subscribe.mockClear()
// Locally rejected subscribes never reached the host and must not spend real attempts.
subscribe.mockImplementation(() => false)
for (let revision = 1; revision <= 5; revision += 1) {
subscriptionsRef.current.delete('terminal-1')
await act(async () => {
renderer?.update(
createElement(Harness, {
showNativeChat: true,
leaseReady: false,
streamRevision: revision
})
)
})
}
expect(subscribe).toHaveBeenCalledTimes(5)
} finally {
subscribe.mockImplementation(registerSubscription)
}
})
it('charges the rearm budget for a subscribe the bridge refused before returning', async () => {
try {
await act(async () => {
renderer = create(createElement(Harness, { showNativeChat: false }))
})
subscribe.mockClear()
// A synchronous refusal ends the stream inside subscribe, so no ref ever shows it was sent.
subscribe.mockImplementation(() => true)
for (let revision = 1; revision <= 5; revision += 1) {
subscriptionsRef.current.delete('terminal-1')
await act(async () => {
renderer?.update(
createElement(Harness, {
showNativeChat: false,
leaseReady: false,
streamRevision: revision
})
)
})
}
expect(subscribe).toHaveBeenCalledTimes(3)
} finally {
subscribe.mockImplementation(registerSubscription)
}
})
it.each([true, false])(
'refills the rearm budget when terminal.list reports the handle again (covered: %s, #10681)',
async (showNativeChat) => {
await act(async () => {
renderer = create(createElement(Harness, { showNativeChat }))
})
for (let revision = 1; revision <= 5; revision += 1) {
subscriptionsRef.current.delete('terminal-1')
await act(async () => {
renderer?.update(
createElement(Harness, {
showNativeChat,
leaseReady: false,
streamRevision: revision
})
)
})
}
subscribe.mockClear()
await act(async () => {
renderer?.update(
createElement(Harness, { showNativeChat, leaseReady: false, streamRevision: 6 })
)
})
expect(subscribe).not.toHaveBeenCalled()
// Still-listed dead handles must not refill on every inventory refresh.
await act(async () => {
notifyListedHandlesRef.current(new Set(['terminal-1']))
notifyListedHandlesRef.current(new Set(['terminal-1']))
})
expect(subscribe).not.toHaveBeenCalled()
await act(async () => {
notifyListedHandlesRef.current(new Set())
notifyListedHandlesRef.current(new Set(['terminal-1']))
})
expect(subscribe).toHaveBeenCalledWith('terminal-1')
}
)
it('does not let a dead PTY buy a new budget with its own `subscribed` ack', async () => {
await act(async () => {
renderer = create(createElement(Harness, { showNativeChat: true }))
})
subscribe.mockClear()
// Subscribed is only half a dead-PTY reply; clearing attempts there would refill the loop forever.
for (let revision = 1; revision <= 6; revision += 1) {
await playDeadPtyRoundTrip(revision)
}
expect(subscribe.mock.calls.length).toBeLessThanOrEqual(3)
})
it('refills the budget for a rearmed stream that outlives the teardown window', async () => {
vi.useFakeTimers()
try {
await act(async () => {
renderer = create(createElement(Harness, { showNativeChat: true }))
})
subscribe.mockClear()
for (let revision = 1; revision <= 3; revision += 1) {
await playDeadPtyRoundTrip(revision)
}
expect(subscribe).toHaveBeenCalledTimes(3)
subscribe.mockClear()
subscriptionsRef.current.set('terminal-1', () => {})
await act(async () => {
renderer?.update(
createElement(Harness, { showNativeChat: true, leaseReady: true, streamRevision: 4 })
)
})
await act(async () => {
vi.advanceTimersByTime(5_000)
})
await playDeadPtyRoundTrip(5)
expect(subscribe).toHaveBeenCalledWith('terminal-1')
} finally {
vi.useRealTimers()
}
})
it('keeps an absence marker observed before the budget ran out', async () => {
await act(async () => {
renderer = create(createElement(Harness, { showNativeChat: true }))
})
for (let revision = 1; revision <= 2; revision += 1) {
await playDeadPtyRoundTrip(revision)
}
// Preserve absence markers until exhaustion actually consumes them; early consumption can permanently lock a still-listed handle.
await act(async () => {
notifyListedHandlesRef.current(new Set())
notifyListedHandlesRef.current(new Set(['terminal-1']))
})
await playDeadPtyRoundTrip(3)
subscribe.mockClear()
await playDeadPtyRoundTrip(4)
expect(subscribe).not.toHaveBeenCalled()
await act(async () => {
notifyListedHandlesRef.current(new Set(['terminal-1']))
})
expect(subscribe).toHaveBeenCalledWith('terminal-1')
})
it('keeps asking until a tab snapshot replaces the exhausted handle', async () => {
await act(async () => {
renderer = create(createElement(Harness, { showNativeChat: true }))
@@ -364,25 +115,4 @@ describe('useMobileNativeChatTerminalStream', () => {
})
expect(hasTabsRecoveryNeedRef.current()).toBe(false)
})
it('resumes a cold-start lease-only stream when WebView readiness arrives late', async () => {
webReadyRef.current.clear()
await act(async () => {
renderer = create(createElement(Harness, { showNativeChat: true }))
})
await act(async () => {
renderer?.update(createElement(Harness, { showNativeChat: false }))
})
expect(unsubscribe).toHaveBeenCalledOnce()
expect(subscribe).toHaveBeenCalledOnce()
webReadyRef.current.add('terminal-1')
await act(async () => {
notifyWebReadyRef.current('terminal-1', false)
})
expect(unsubscribe).toHaveBeenNthCalledWith(2, 'terminal-1')
expect(subscribe).toHaveBeenNthCalledWith(2, 'terminal-1')
})
})
@@ -2,13 +2,6 @@ import { bindDeferredRpcOperation, defineRpcOperation } from '../../transport/rp
import { rpcResultVariant } from '../../transport/rpc/rpc-operation-result-reader'
import '../../terminal/terminal-reply-schema'
import { sessionForwardedReplySchema } from '../../transport/rpc/rpc-uninterpreted-reply-schemas'
import '../agent-launch/mobile-agent-detection-reply-schema'
import '../../files/session-documents/mobile-markdown-tab-reply-schema'
import '../../terminal/session/mobile-session-terminal-reply-schema'
import '../../worktree/session-worktree-record-reply-schema'
import '../native-chat/composer/mobile-native-chat-skill-reply-schema'
import '../../terminal/quick-commands/mobile-quick-command-reply-schema'
import '../../files/workspace-file-paths-reply-schema'
// Leave replies raw until the controller’s generation, barrier and application-revision guards pass.
export const sessionTabsListRead = bindDeferredRpcOperation(
@@ -1,7 +1,5 @@
import { bindDeferredRpcOperation, defineRpcOperation } from '../../transport/rpc/rpc-operation'
import { rpcResultVariant } from '../../transport/rpc/rpc-operation-result-reader'
import '../../files/session-documents/mobile-markdown-tab-reply-schema'
import '../../worktree/session-worktree-record-reply-schema'
import '../../terminal/terminal-reply-schema'
import { sessionCreatedTerminalTabSchema } from '../../terminal/session/mobile-session-terminal-reply-schema'
import { sessionTabCloseReplySchema } from './mobile-session-tab-write-reply-schema'
@@ -1,5 +1,4 @@
import * as z from 'zod'
import '../../../../src/shared/zod-salvage'
// Bare legacy success and delegated closes do not prove permanent retirement.
export const sessionTabCloseReplySchema = z
@@ -1,12 +1,6 @@
import { createElement } from 'react'
import { act, create, type ReactTestRenderer } from 'react-test-renderer'
import { beforeEach, describe, expect, it, vi } from 'vitest'
import type { WorktreeShowResolution } from '../worktree/worktree-show-resolution'
import { describe, expect, it } from 'vitest'
import { isSyntheticWorkspaceRoute } from './synthetic-workspace-route'
import {
shouldBounceMissingWorktree,
useMissingWorktreeBounce
} from './use-missing-worktree-bounce'
import { shouldBounceMissingWorktree } from './use-missing-worktree-bounce'
describe('shouldBounceMissingWorktree', () => {
it('bounces only a host-proven missing worktree', () => {
@@ -21,60 +15,3 @@ describe('shouldBounceMissingWorktree', () => {
expect(shouldBounceMissingWorktree('global-floating-terminal', 'missing')).toBe(false)
})
})
describe('useMissingWorktreeBounce', () => {
let renderer: ReactTestRenderer | null = null
const bounce = vi.fn()
beforeEach(() => {
bounce.mockReset()
renderer = null
})
function Harness(props: { worktreeId: string; resolution: WorktreeShowResolution }): null {
useMissingWorktreeBounce({
hostId: 'host-1',
worktreeId: props.worktreeId,
resolution: props.resolution,
bounce
})
return null
}
function render(worktreeId: string, resolution: WorktreeShowResolution): void {
act(() => {
const element = createElement(Harness, { worktreeId, resolution })
if (renderer) {
renderer.update(element)
} else {
renderer = create(element)
}
})
}
it('bounces exactly once per worktree even across re-renders', () => {
render('repo::wt', 'unknown')
expect(bounce).not.toHaveBeenCalled()
render('repo::wt', 'missing')
expect(bounce).toHaveBeenCalledExactlyOnceWith('host-1')
// Why: navigation lands after the render, so the pre-unmount renders must not re-fire.
render('repo::wt', 'missing')
render('repo::wt', 'unknown')
render('repo::wt', 'missing')
expect(bounce).toHaveBeenCalledTimes(1)
act(() => renderer?.unmount())
})
it('re-arms for a different worktree on the reused screen', () => {
render('repo::wt-1', 'missing')
expect(bounce).toHaveBeenCalledTimes(1)
render('repo::wt-2', 'unknown')
expect(bounce).toHaveBeenCalledTimes(1)
render('repo::wt-2', 'missing')
expect(bounce).toHaveBeenCalledTimes(2)
act(() => renderer?.unmount())
})
})
@@ -68,52 +68,6 @@ describe('useMobileDefaultSessionViewPreference', () => {
expect(saveDefaultSessionView).toHaveBeenCalledWith('chat')
})
it('submits every change immediately so shared persistence preserves event order', async () => {
const firstSave = deferred<void>()
vi.mocked(saveDefaultSessionView)
.mockImplementationOnce(() => firstSave.promise)
.mockResolvedValue(undefined)
await mount()
act(() => preference?.setDefaultView('chat'))
await act(async () => {
await Promise.resolve()
})
act(() => preference?.setDefaultView('terminal'))
act(() => preference?.setDefaultView('chat'))
await act(async () => {
await Promise.resolve()
})
expect(saveDefaultSessionView).toHaveBeenCalledTimes(3)
expect(saveDefaultSessionView).toHaveBeenNthCalledWith(2, 'terminal')
expect(saveDefaultSessionView).toHaveBeenNthCalledWith(3, 'chat')
await act(async () => {
firstSave.resolve()
await firstSave.promise
await Promise.resolve()
})
expect(saveDefaultSessionView).toHaveBeenCalledTimes(3)
})
it('reloads the persisted value when the latest write fails', async () => {
vi.mocked(loadDefaultSessionView)
.mockResolvedValueOnce('terminal')
.mockResolvedValueOnce('terminal')
vi.mocked(saveDefaultSessionView).mockRejectedValue(new Error('storage unavailable'))
await mount()
await act(async () => {
preference?.setDefaultView('chat')
await Promise.resolve()
await Promise.resolve()
await Promise.resolve()
})
expect(preference?.defaultView).toBe('terminal')
expect(loadDefaultSessionView).toHaveBeenCalledTimes(2)
})
it('does not let an older failed write roll back a newer choice', async () => {
const recoveryLoad = deferred<MobileSessionView>()
vi.mocked(loadDefaultSessionView)
@@ -140,27 +94,4 @@ describe('useMobileDefaultSessionViewPreference', () => {
expect(preference?.defaultView).toBe('terminal')
expect(saveDefaultSessionView).toHaveBeenNthCalledWith(2, 'terminal')
})
it('submits the latest write before the Settings route unmounts', async () => {
const firstSave = deferred<void>()
vi.mocked(saveDefaultSessionView)
.mockImplementationOnce(() => firstSave.promise)
.mockResolvedValueOnce(undefined)
await mount()
act(() => preference?.setDefaultView('chat'))
await act(async () => {
await Promise.resolve()
})
act(() => preference?.setDefaultView('terminal'))
expect(saveDefaultSessionView).toHaveBeenNthCalledWith(2, 'terminal')
act(() => renderer?.unmount())
renderer = null
await act(async () => {
firstSave.resolve()
await firstSave.promise
await Promise.resolve()
})
})
})
@@ -146,19 +146,6 @@ describe('useMobileSessionViewMode', () => {
expect(controller?.ready).toBe(true)
})
it('toggles from the effective view and persists the override', async () => {
await mount({ defaultView: 'chat' })
await act(async () => {
controller?.toggleTabChatView('t1')
await Promise.resolve()
})
expect(updateSessionViewOverride).toHaveBeenLastCalledWith('h', 'w', 't1', 'terminal')
expect(controller?.isTabChatView('t1')).toBe(false)
expect(readSessionViewOverridesPreference).toHaveBeenCalledTimes(1)
})
it('toggles a terminal-default tab into chat', async () => {
await mount({ defaultView: 'terminal' })
@@ -293,41 +280,6 @@ describe('useMobileSessionViewMode', () => {
})
})
it('submits rapid mutations in event order', async () => {
await mount({ defaultView: 'terminal' })
act(() => controller?.toggleTabChatView('t1'))
act(() => controller?.toggleTabChatView('t2'))
expect(updateSessionViewOverride).toHaveBeenNthCalledWith(1, 'h', 'w', 't1', 'chat')
expect(updateSessionViewOverride).toHaveBeenNthCalledWith(2, 'h', 'w', 't2', 'chat')
})
it('reconciles the latest optimistic override when persistence fails', async () => {
vi.mocked(updateSessionViewOverride).mockRejectedValue(new Error('storage unavailable'))
vi.mocked(readSessionViewOverridesPreference)
.mockResolvedValueOnce({
overrides: new Map<string, MobileSessionView>([['t1', 'terminal']]),
loaded: true
})
.mockResolvedValueOnce({
overrides: new Map<string, MobileSessionView>([['t1', 'terminal']]),
loaded: true
})
await mount({
defaultView: 'terminal',
overrides: new Map<string, MobileSessionView>([['t1', 'terminal']])
})
await act(async () => {
controller?.toggleTabChatView('t1')
await Promise.resolve()
await Promise.resolve()
})
expect(controller?.isTabChatView('t1')).toBe(false)
expect(readSessionViewOverridesPreference).toHaveBeenCalledTimes(2)
})
it('does not let an older failed override roll back a newer choice', async () => {
const recoveryLoad = deferred<Map<string, MobileSessionView>>()
vi.mocked(updateSessionViewOverride)
@@ -354,47 +306,6 @@ describe('useMobileSessionViewMode', () => {
expect(updateSessionViewOverride).toHaveBeenNthCalledWith(2, 'h', 'w', 't1', 'terminal')
})
it('fails closed when both an override write and its recovery read fail', async () => {
vi.mocked(loadDefaultSessionView).mockResolvedValue('chat')
vi.mocked(updateSessionViewOverride).mockRejectedValue(new Error('storage unavailable'))
vi.mocked(readSessionViewOverridesPreference)
.mockResolvedValueOnce({
overrides: new Map<string, MobileSessionView>([['t1', 'terminal']]),
loaded: true
})
.mockResolvedValueOnce({ overrides: new Map(), loaded: false })
await mount({
defaultView: 'chat',
overrides: new Map<string, MobileSessionView>([['t1', 'terminal']])
})
await act(async () => {
controller?.toggleTabChatView('t1')
await Promise.resolve()
await Promise.resolve()
})
expect(controller?.isTabChatView('t1')).toBe(false)
expect(readSessionViewOverridesPreference).toHaveBeenCalledTimes(2)
})
it('does not reload a failed override after the route unmounts', async () => {
const failedWrite = deferred<void>()
vi.mocked(updateSessionViewOverride).mockReturnValue(failedWrite.promise)
await mount({ defaultView: 'terminal' })
act(() => controller?.toggleTabChatView('t1'))
act(() => renderer?.unmount())
renderer = null
await act(async () => {
failedWrite.reject(new Error('storage unavailable'))
await Promise.resolve()
await Promise.resolve()
})
expect(readSessionViewOverridesPreference).toHaveBeenCalledTimes(1)
})
it('fails closed to terminal when overrides cannot be read under a chat default', async () => {
vi.mocked(loadDefaultSessionView).mockResolvedValue('chat')
vi.mocked(readSessionViewOverridesPreference).mockResolvedValue({
@@ -1,11 +1,7 @@
import { bindDeferredRpcOperation, defineRpcOperation } from '../../transport/rpc/rpc-operation'
import { rpcResultVariant } from '../../transport/rpc/rpc-operation-result-reader'
import { sessionWorktreeRecordSchema } from '../../worktree/session-worktree-record-reply-schema'
import '../../files/session-documents/mobile-markdown-tab-reply-schema'
import '../../terminal/terminal-reply-schema'
import '../../terminal/session/mobile-session-terminal-reply-schema'
import '../../session/tabs/mobile-session-tab-write-reply-schema'
import '../../transport/rpc/rpc-uninterpreted-reply-schemas'
import '../../terminal/quick-commands/mobile-quick-command-operations'
// Preserve host refusal text for optimistic rollback; the reply's record carries the host's merged notes.
@@ -1,110 +0,0 @@
import { createElement } from 'react'
import { act, create, type ReactTestRenderer } from 'react-test-renderer'
import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest'
import type { TerminalQuickCommand } from '../../../../src/shared/terminal/quick-commands/terminal-quick-command-types'
import { QuickCommandRow } from './QuickCommandRow'
const clipboard = vi.hoisted(() => ({ setStringAsync: vi.fn(() => Promise.resolve(true)) }))
const haptics = vi.hoisted(() => ({ notificationAsync: vi.fn(() => Promise.resolve()) }))
vi.mock('react-native', () => ({
Platform: { OS: 'ios' },
Pressable: 'Pressable',
StyleSheet: { create: <T>(styles: T) => styles, hairlineWidth: 1 },
Text: 'Text',
View: 'View'
}))
vi.mock('lucide-react-native', () => ({
Check: 'Check',
Copy: 'Copy',
Pencil: 'Pencil',
Play: 'Play',
Trash2: 'Trash2'
}))
vi.mock('expo-clipboard', () => clipboard)
vi.mock('expo-haptics', () => ({
...haptics,
performAndroidHapticsAsync: vi.fn(() => Promise.resolve()),
AndroidHaptics: { Reject: 'reject' },
NotificationFeedbackType: { Error: 'error', Success: 'success' }
}))
vi.mock('../../components/MobileAgentIcon', () => ({ MobileAgentIcon: 'MobileAgentIcon' }))
const COMMAND: TerminalQuickCommand = {
id: 'qc-1',
label: 'Run tests',
command: 'run the tests',
appendEnter: true
}
// Require visible error feedback and haptics when clipboard writes reject; a failed write must never show Copied.
describe('the quick-command row when the pasteboard refuses the text', () => {
let renderer: ReactTestRenderer | null = null
beforeEach(() => {
clipboard.setStringAsync.mockReset()
haptics.notificationAsync.mockReset()
haptics.notificationAsync.mockImplementation(() => Promise.resolve())
})
afterEach(() => {
act(() => renderer?.unmount())
renderer = null
})
// Find by every copy-state label so feedback transitions cannot make the control disappear from assertions.
const COPY_LABELS = new Set([
`Copy ${COMMAND.label}`,
'Copied',
"Couldn't copy",
'Nothing to copy'
])
function copyButton() {
const button = renderer!.root
.findAll((node) => node.props.accessibilityRole === 'button')
.find((node) => COPY_LABELS.has(String(node.props.accessibilityLabel)))
if (button === undefined) {
throw new Error('the row has no copy button')
}
return button
}
function rowProps() {
return {
command: COMMAND,
first: true,
onLaunch: vi.fn(),
onEdit: vi.fn(),
onDelete: vi.fn(),
disabled: false
}
}
it('emits nothing at all when the row is gone before the refusal arrives', async () => {
// Unmount before rejection; feedback without a surviving row would buzz with no explanation.
let refuse: ((error: Error) => void) | undefined
clipboard.setStringAsync.mockImplementation(
() =>
new Promise((_resolve, reject) => {
refuse = reject
})
)
await act(async () => {
renderer = create(createElement(QuickCommandRow, rowProps()))
})
await act(async () => {
copyButton().props.onPress()
})
act(() => renderer?.unmount())
renderer = null
await act(async () => {
refuse?.(new Error('pasteboard refused'))
})
expect(haptics.notificationAsync).not.toHaveBeenCalled()
})
})
@@ -2,13 +2,6 @@ import { bindDeferredRpcOperation, defineRpcOperation } from '../../transport/rp
import { rpcResultVariant } from '../../transport/rpc/rpc-operation-result-reader'
import '../terminal-reply-schema'
import { terminalQuickCommandsSchema } from './mobile-quick-command-reply-schema'
import '../../session/agent-launch/mobile-agent-detection-reply-schema'
import '../../files/session-documents/mobile-markdown-tab-reply-schema'
import '../../transport/rpc/rpc-uninterpreted-reply-schemas'
import '../session/mobile-session-terminal-reply-schema'
import '../../worktree/session-worktree-record-reply-schema'
import '../../session/native-chat/composer/mobile-native-chat-skill-reply-schema'
import '../../files/workspace-file-paths-reply-schema'
export const quickCommandsReader = rpcResultVariant(
'terminal-quick-commands',
@@ -26,8 +19,6 @@ export const quickCommandsRead = bindDeferredRpcOperation(
})
)
import '../../session/tabs/mobile-session-tab-write-reply-schema'
export const quickCommandsWrite = bindDeferredRpcOperation(
defineRpcOperation({
name: 'settings.quick-commands-write',
@@ -1,5 +1,4 @@
import * as z from 'zod'
import '../../../../src/shared/zod-salvage'
// Preserve null for unreadable canonical command lists so load/save cannot erase host commands with an invented empty list.
export const terminalQuickCommandsSchema = z
@@ -2,13 +2,7 @@ import { bindDeferredRpcOperation, defineRpcOperation } from '../../transport/rp
import { rpcResultVariant } from '../../transport/rpc/rpc-operation-result-reader'
import { terminalComposerDraftSchema, terminalSendAcceptedSchema } from '../terminal-reply-schema'
import { sessionTerminalInventorySchema } from './mobile-session-terminal-reply-schema'
import '../../session/agent-launch/mobile-agent-detection-reply-schema'
import '../../files/session-documents/mobile-markdown-tab-reply-schema'
import { sessionWriteUnreadReplySchema } from '../../transport/rpc/rpc-uninterpreted-reply-schemas'
import '../../worktree/session-worktree-record-reply-schema'
import '../../session/native-chat/composer/mobile-native-chat-skill-reply-schema'
import '../quick-commands/mobile-quick-command-reply-schema'
import '../../files/workspace-file-paths-reply-schema'
export const nativeChatTerminalDraftRead = bindDeferredRpcOperation(
defineRpcOperation({
@@ -31,7 +25,6 @@ export const sessionTerminalListRead = bindDeferredRpcOperation(
})
)
import '../../session/tabs/mobile-session-tab-write-reply-schema'
import '../quick-commands/mobile-quick-command-operations'
// Keep chat writes distinct from query/accessory responders: losing a reply here means unknown delivery and forbids automatic retry.
@@ -1,5 +1,4 @@
import * as z from 'zod'
import '../../../../src/shared/zod-salvage'
// Leave unread replies opaque; structured creates/probes require raw-envelope uncertainty checks at their callers.
export const sessionLaunchUnreadReplySchema = z.unknown()
@@ -2,13 +2,6 @@ import { bindDeferredRpcOperation, defineRpcOperation } from '../transport/rpc/r
import { rpcResultVariant } from '../transport/rpc/rpc-operation-result-reader'
import '../terminal/terminal-reply-schema'
import { sessionWorktreeRecordSchema } from './session-worktree-record-reply-schema'
import '../session/agent-launch/mobile-agent-detection-reply-schema'
import '../files/session-documents/mobile-markdown-tab-reply-schema'
import '../transport/rpc/rpc-uninterpreted-reply-schemas'
import '../terminal/session/mobile-session-terminal-reply-schema'
import '../session/native-chat/composer/mobile-native-chat-skill-reply-schema'
import '../terminal/quick-commands/mobile-quick-command-reply-schema'
import '../files/workspace-file-paths-reply-schema'
// Read the whole worktree for notes and live title; unlike mutation readers, absent records need no error or wrong-host fallback.
export const sessionWorktreeRecordRead = bindDeferredRpcOperation(
@@ -0,0 +1,13 @@
import { describe, expect, it } from 'vitest'
import { diffFromText } from './native-chat-diff'
describe('diffFromText', () => {
it('honors a smaller caller-owned aggregate row budget', () => {
const text = Array.from(
{ length: 100 },
(_unused, index) => `${index % 2 === 0 ? '+' : '-'}line-${index}`
).join('\n')
expect(diffFromText(text, 20)).toHaveLength(20)
})
})