mirror of
https://github.com/stablyai/orca.git
synced 2026-11-13 17:37:40 +00:00
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:
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,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'
|
||||
}))
|
||||
-7
@@ -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
|
||||
-261
@@ -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
|
||||
|
||||
-107
@@ -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)
|
||||
})
|
||||
})
|
||||
+1
-5
@@ -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)
|
||||
})
|
||||
})
|
||||
Reference in New Issue
Block a user