Files
orca/mobile/src/session/use-mobile-native-chat-controller.test.ts
T
Kelvin Amoaba e310a63624 fix(mobile): use desktop's rules for queued messages, turn status, Stop and the chat composer (#34000)
* refactor(mobile): share the queued-card projection with desktop

Image-only queued drafts showed a blank card on the phone.

* fix(mobile): honor the host's Queue follow-ups setting

With it off, a mid-turn send from the phone still queued while desktop sent it into the running turn.

* fix(mobile): keep a queued-card edit when the card leaves the queue

The phone closed the editor and dropped the typing; it now goes to the chat box as on desktop.

* fix(mobile): hold /clear and /compact for background work and sends

The phone fired the command where desktop holds it, showed start failures twice, and left the agent unnamed in failures.

* fix(mobile): hand a queued edit back to the chat it came from

* fix(mobile): scope a command's send hold to its own chat

* fix(mobile): read a published null queue as empty

* refactor(mobile): share the returned queued-card caption rule

* test(mobile): drop queued-card tests the shared rules already own

* refactor(native-chat): share the needs-attention rule and drop leftover wrappers

* fix(mobile): type the structured sends mock, drop the moved queued-cards path and ship queued edit copy at boot

* fix(native-chat): drop the queued card's unused image-label import after the rebase

* fix(mobile): order the phone's queue reorder preview by position, as the shared cards sort

* test(mobile): keep the queued-messages test within its line budget

* fix(mobile): settle terminal chat working state and turn timing with desktop's rules

Move desktop's live-status merge, Stop suppression, hook-state resolver and
terminal turn timing to src/shared/native-chat. Mobile reads the transcript
lifecycle the host already sends, so a provider turn end settles Working the
same way desktop does. Terminal chats now get the turn bar, timer and folding,
and the streaming preview uses the shared deriveNativeChatStreamingText.

* fix(mobile): send desktop's terminal Stop keys, a second Escape only for OpenCode

Desktop sends one Escape on Stop. It adds a paced second Escape only for
OpenCode, which confirms an interrupt that way. Claude Code and Codex read
Esc-Esc at an idle prompt as rewind. The rule moves to
native-chat-agent-support, and the phone now paces the second Escape at
desktop's answer step instead of 80ms.

* fix(mobile): word Orca-stop and usage-limit notices like desktop

Move desktop's Orca-stop sentences to src/shared with an English table that
desktop translates. The phone names the Orca host it pairs with, and when
that host is unnamed it says "the remote server". Usage-limit rows rebuild
desktop's sentence from the failure fact. The usageLimitLineText fallback,
which only the phone used, is deleted.

* test(mobile): pass the overlay's host name in the prompt controller harness

* test(mobile): re-record Stop goldens at desktop's step, split status-notice tests, pin the turn clock and ship notice copy at boot

* fix(mobile): offer structured Stop while a send is out and read queue-sends-next as working

Move desktop's structured Stop rule, its Stop-joining helpers and the
queue-sends-next rule to src/shared. Both sides now use them. The phone
counts its in-flight sends, so a conversation-stop host offers Stop before
the turn opens. Between queued turns the phone reads as working, and
commands that can't wait in line hold like they do on desktop. Untargeted
Stops are no longer joined on legacy hosts.

* fix(mobile): offer terminal Stop again when its Escape went nowhere

Stop hides Working on the phone like it does on desktop. The phone's Escape
can fail outright, though, while desktop writes to a local PTY. When no Escape
could have landed, the phone now drops that suppression. The streaming preview
also treats a pending echo as the transcript's tail, as desktop does, so an
earlier reply can't hide a new preview.

* fix(mobile): hold structured Stop while a send it covers is in flight

On desktop, Stop's held and Stopping state depends on whether the chat can
stop, and that includes a send still in flight. The phone now does the same.
Once Stop is pressed before the turn opens, the button holds and repeated
presses send no more untargeted cancels.

* fix(mobile): end the terminal Stop hold when a new message is sent

Desktop clears Stop's working suppression on an optimistic send, so a new turn
that keeps the same hook epoch (Claude with working subagents) still offers
Stop. The phone now does the same when a new pending echo appears.

* refactor(mobile): trim chat turn wiring after the desktop rule move

Drop the controller's unread nativeChatAgentWorking field. Re-render only when
a chat starts or stops having a send out. Keep the structured turn indicator
stable across stream frames. Type the Stop test table instead of casting.

* refactor(mobile): track structured sends in flight once for Stop and command holds

* test(mobile): re-record Stop goldens now that Stop settles with its Escape delivery

* fix(mobile): escape terminal image paths like desktop drops

* fix(mobile): keep launch-draft prefill over older user turns

* fix(mobile): rank @ file matches with desktop's fuzzy Quick Open search

* fix(mobile): use desktop's composer triggers for mid-prompt / and $ picks

Picked @ files are now quoted when the path has spaces.

* fix(mobile): send picked skill paths on structured chats

* fix(mobile): list the host's disk-scanned skills in the / and $ menus

The phone names the workspace and the host resolves its folder, refusing SSH
workspaces as desktop does; hosts without skills.discover-workspace.v1 list none.

* fix(mobile): forget a picked skill once its token is deleted

* fix(mobile): drop a picked skill as soon as its token is deleted, and retry failed skill scans

* fix(mobile): treat a picked leading skill as chat and stop failed skill scans from refiring

* test(mobile): drop the menu ranking case the shared picker tests own

* refactor(mobile): keep skill picks on the draft record and reuse shared send rules

A rejected send now keeps its picks and leading-skill origin on both send paths.

* fix(mobile): replace a re-picked skill's file and restore picks with a rejected send

* fix(mobile): read the chat agent for the skill catalog from the active resolution

* style(native-chat): format the composer autocomplete files

* fix(native-chat): import the picker's apply and send-classify rules from the shared composer module

* refactor(mobile): import the composer's draft-store hooks once

* style(mobile): format the notice row's style import
2026-10-27 15:34:56 +00:00

830 lines
28 KiB
TypeScript
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
import { createElement } from 'react'
import { act, create, type ReactTestRenderer } from 'react-test-renderer'
import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest'
import type { SessionOptionDescriptor } from '../../../src/shared/native-chat/input/native-chat-session-options'
import type { RpcClient } from '../transport/rpc-client'
import type { ConnectionState } from '../transport/types'
import type { MobileNativeChatPrompts } from './native-chat/input/use-mobile-native-chat-prompts'
const acceptSend = vi.fn()
const captureSendOrigin = vi.fn()
const clearDraftForSend = vi.fn()
const restoreRejectedDraft = vi.fn()
const holdUnconfirmedSend = vi.fn()
const viewMode = { isTabChatView: (_tabId: string) => true }
let draftPending: { id: string }[] = []
const sessionState = { messages: [] as unknown[], status: 'ready', transcriptLoading: false }
const structuredSendWithOutcome = vi.fn()
const structuredCancel = vi.fn()
const structuredCancelPrompt = vi.fn(async () => true)
const structuredRespondPermission = vi.fn(async () => true)
const structuredRespondQuestion = vi.fn(async () => true)
const structuredSetOption = vi.fn(async () => true)
const structuredInvokeOption = vi.fn(async () => true)
const structuredOptionSnapshot: SessionOptionDescriptor[] = [
{
id: 'model',
label: 'Model',
category: 'model',
kind: {
type: 'select',
currentValue: 'gpt-fast',
choices: [{ value: 'gpt-fast', label: 'GPT Fast' }]
},
valueSource: 'reported',
transport: 'agent-session',
settable: true
}
]
const structuredOptionSurface = {
getSnapshot: () => structuredOptionSnapshot,
setOption: async () => ({ snapshot: structuredOptionSnapshot }),
invokeAction: async () => ({ snapshot: structuredOptionSnapshot }),
subscribe: () => () => {}
}
const structuredPermission = {
title: 'Allow Bash?',
detail: 'rm -rf build',
options: [
{ label: 'Allow once', send: 'allow-once' },
{ label: 'Deny', send: 'deny' }
]
}
const structuredQuestion = {
question: 'Pick destination',
options: ['Choice A', 'Choice B'],
allowOther: true,
optionTokens: ['choice-a', 'choice-b']
}
const structuredActivity = { isWorking: false, turnId: null as string | null }
let harnessHostSupport: ReturnType<typeof structuredAgentSessionHostSupport> | null = null
const structuredSessionState = {
messages: [] as unknown[],
status: 'ready',
transcriptLoading: false,
error: undefined,
hasMore: false,
loadingEarlier: false,
loadEarlier: vi.fn()
}
const promptsState: Pick<MobileNativeChatPrompts, 'permission' | 'ask' | 'waitStartedAt'> = {
permission: null,
ask: null,
waitStartedAt: null
}
// Isolate send outcomes and draft accounting by stubbing unrelated controller hooks.
vi.mock('./use-mobile-session-view-mode', () => ({
useMobileSessionViewMode: () => ({
isTabChatView: (tabId: string) => viewMode.isTabChatView(tabId),
setTabView: vi.fn(),
toggleTabChatView: vi.fn()
})
}))
vi.mock('./use-mobile-structured-session-commands', () => ({
useMobileStructuredSessionCommands: () => ({ commands: undefined, refresh: vi.fn() })
}))
vi.mock('./use-mobile-native-chat-session', () => ({
useMobileNativeChatSession: () => sessionState
}))
vi.mock('./use-mobile-structured-agent-session', () => ({
useMobileStructuredAgentSession: () => ({
session: structuredSessionState,
...structuredActivity,
queued: { cards: [], send: vi.fn(), delete: vi.fn(), edit: vi.fn() },
sendWithOutcome: structuredSendWithOutcome,
acceptsImages: false,
cancel: structuredCancel,
cancelPrompt: structuredCancelPrompt,
permission: structuredPermission,
question: structuredQuestion,
optionSnapshot: structuredOptionSnapshot,
optionSurface: structuredOptionSurface,
pendingOptionId: 'model',
respondPermission: structuredRespondPermission,
respondQuestion: structuredRespondQuestion,
setStructuredOption: structuredSetOption,
invokeStructuredOption: structuredInvokeOption
})
}))
vi.mock('./native-chat/composer/use-mobile-native-chat-drafts', () => ({
useMobileNativeChatDrafts: () => {
return {
draftKey: null,
setComposerText: vi.fn(),
pending: draftPending,
imagePreviewsByMessageId: {},
captureSendOrigin,
readSeededLaunchDraft: () => null,
readSeededLaunchDraftSeed: () => null,
clearDraftForSend,
restoreRejectedDraft,
acceptSend,
holdUnconfirmedSend
}
}
}))
vi.mock('./native-chat/input/use-mobile-native-chat-prompts', () => ({
useMobileNativeChatPrompts: () => promptsState
}))
const answerSendArgs: { streamIdentity?: string }[] = []
vi.mock('./native-chat/input/use-mobile-native-chat-answer-send', () => ({
useMobileNativeChatAnswerSend: (args: { streamIdentity?: string }) => {
answerSendArgs.push(args)
return { answerAsk: vi.fn(), cancelPending: vi.fn() }
}
}))
vi.mock('./native-chat/input/mobile-native-chat-permission-send', () => ({
useMobileNativeChatPermissionSend: () => vi.fn()
}))
const terminalStop = vi.fn(async () => true)
vi.mock('./use-mobile-native-chat-stop', () => ({
useMobileNativeChatStop: () => terminalStop
}))
vi.mock('./native-chat/composer/use-mobile-native-chat-file-search', () => ({
useMobileNativeChatFileSearch: () => ({ nativeChatFilePaths: [], loadNativeChatFiles: vi.fn() })
}))
// Keep the real timeout constant available to stale-input heal through the partial send mock.
vi.mock('./native-chat/mobile-native-chat-send', async (importOriginal) => ({
...(await importOriginal<typeof NativeChatSend>()),
sendMobileNativeChatMessageWithOutcome: vi.fn()
}))
import * as NativeChatSend from './native-chat/mobile-native-chat-send'
import {
isMobileNativeChatInputStale,
markMobileNativeChatInputStale,
resetMobileNativeChatStaleInputForTests
} from './native-chat/mobile-native-chat-stale-input'
import { useMobileNativeChatController } from './use-mobile-native-chat-controller'
import type { MobileNativeChatController } from './mobile-native-chat-controller-contract'
import type { MobileNativeChatStatus } from './use-mobile-native-chat-session'
import { mobileNativeChatPromptDismissals } from './native-chat/input/mobile-native-chat-prompt-dismissals'
import { structuredAgentSessionHostSupport } from './mobile-structured-agent-session-host-support'
const sendWithOutcome = vi.mocked(NativeChatSend.sendMobileNativeChatMessageWithOutcome)
const ORIGIN = {
draftKey: 'h\0w\0tab-1',
draftEditGeneration: 0,
pendingKey: 'h\0w\0tab-1\0session-1',
normalizedText: 'look',
boundary: { afterMessageId: null },
sentAt: 0
}
function structuredTab(agent: string) {
const tab = {
type: 'agent-session',
id: 'agent-tab-1',
title: 'Chat',
sessionId: 'session-structured',
agent,
isActive: true
}
return { tab, activeHandle: null, inputLeaseReady: false }
}
describe('useMobileNativeChatController handleNativeChatSend', () => {
let renderer: ReactTestRenderer | null = null
let controller: MobileNativeChatController | null = null
const onSendError = vi.fn()
const onSendResolved = vi.fn()
const clientStub = { sendRequest: vi.fn() }
function Harness({
connState = 'connected',
tab = null,
activeHandle = 'term-1',
inputLeaseReady = true
}: {
connState?: ConnectionState
tab?: unknown
activeHandle?: string | null
inputLeaseReady?: boolean
}): null {
controller = useMobileNativeChatController({
client: clientStub as unknown as RpcClient,
connState,
hostId: 'h',
worktreeId: 'w',
activeSessionTab: tab as never,
activeSessionTabId: (tab as { id?: string } | null)?.id ?? 'tab-1',
activeHandleRef: { current: activeHandle },
deviceTokenRef: { current: null },
nativeChatTranscriptIsLocalReadable: true,
nativeChatInputLeaseReady: inputLeaseReady,
agentSessionHostSupport: harnessHostSupport,
onSendError,
onSendResolved
})
return null
}
beforeEach(() => {
vi.clearAllMocks()
clientStub.sendRequest.mockResolvedValue({
id: 'send',
ok: true,
result: { send: { accepted: true } },
_meta: { runtimeId: 'r' }
})
resetMobileNativeChatStaleInputForTests()
captureSendOrigin.mockReturnValue(ORIGIN)
structuredSendWithOutcome.mockResolvedValue('accepted')
act(() => {
renderer = create(createElement(Harness))
})
})
afterEach(() => {
act(() => renderer?.unmount())
renderer = null
controller = null
})
it('clears an orphaned image paste before a question-card answer (#10228)', async () => {
markMobileNativeChatInputStale('term-1')
clientStub.sendRequest.mockResolvedValue({
id: 'send',
ok: true,
result: { send: { accepted: true } },
_meta: { runtimeId: 'r' }
})
sendWithOutcome.mockResolvedValue('accepted')
let accepted = false
await act(async () => {
accepted = await controller!.handleNativeChatSend('answer')
})
expect(accepted).toBe(true)
expect(clientStub.sendRequest).toHaveBeenCalledTimes(2)
for (const call of clientStub.sendRequest.mock.calls) {
expect(call[1]).toMatchObject({ terminal: 'term-1', text: '\x15', enter: false })
}
expect(isMobileNativeChatInputStale('term-1')).toBe(false)
})
it('does not send when the healing clear is rejected, keeping the marker', async () => {
markMobileNativeChatInputStale('term-1')
clientStub.sendRequest.mockResolvedValue({
id: 'send',
ok: true,
result: { send: { accepted: false } },
_meta: { runtimeId: 'r' }
})
let accepted = true
await act(async () => {
accepted = await controller!.handleNativeChatSend('answer')
})
expect(accepted).toBe(false)
expect(sendWithOutcome).not.toHaveBeenCalled()
expect(onSendError).toHaveBeenCalledWith('Message not sent')
expect(isMobileNativeChatInputStale('term-1')).toBe(true)
})
it('keeps the marker when Escape cancels an ask, which never submits the composer', async () => {
markMobileNativeChatInputStale('term-1')
sendWithOutcome.mockResolvedValue('accepted')
let accepted = false
await act(async () => {
accepted = await controller!.handleNativeChatCancelAsk()
})
expect(accepted).toBe(true)
// A swallowed but acknowledged clear would consume the marker without removing stale input.
expect(clientStub.sendRequest).not.toHaveBeenCalled()
expect(isMobileNativeChatInputStale('term-1')).toBe(true)
})
it('retires a held failure banner when a card action is accepted', async () => {
// Accepted answers/permission replies must clear the route-owned send-error banner too.
sendWithOutcome.mockResolvedValue('accepted')
await act(async () => {
await controller!.handleNativeChatCancelAsk()
})
expect(onSendResolved).toHaveBeenCalled()
onSendResolved.mockClear()
sendWithOutcome.mockResolvedValue('rejected')
await act(async () => {
await controller!.handleNativeChatCancelAsk()
})
expect(onSendResolved).not.toHaveBeenCalled()
})
it('routes structured agent-session sends away from terminal/nativeChat transports', async () => {
await act(async () => {
renderer?.update(createElement(Harness, structuredTab('codex')))
})
let accepted = false
await act(async () => {
accepted = await controller!.handleNativeChatSend('look')
})
expect(accepted).toBe(true)
expect(structuredSendWithOutcome).toHaveBeenCalledWith(
'look',
undefined,
undefined,
undefined,
[]
)
expect(sendWithOutcome).not.toHaveBeenCalled()
expect(clientStub.sendRequest).not.toHaveBeenCalled()
})
it("leaves a command the chat's own agent runs to its session, though Codex's host claims it", async () => {
await act(async () => {
renderer?.update(createElement(Harness, structuredTab('opencode')))
})
await act(async () => {
await controller!.handleNativeChatSend('/review')
})
expect(structuredSendWithOutcome).toHaveBeenCalledWith(
'/review',
undefined,
undefined,
undefined,
[]
)
expect(acceptSend).not.toHaveBeenCalled()
expect(restoreRejectedDraft).not.toHaveBeenCalled()
expect(controller!.nativeChatAcceptsImages).toBe(false)
})
it('offers prompt Cancel only while a provider turn runs', async () => {
const props = structuredTab('codex')
structuredActivity.isWorking = true
try {
await act(async () => {
renderer?.update(createElement(Harness, props))
})
expect(controller?.nativeChatTurnActive).toBe(true)
expect(controller?.handleNativeChatCancelPrompt).toBeUndefined()
structuredActivity.turnId = 'provider-turn'
await act(async () => {
renderer?.update(createElement(Harness, props))
})
expect(controller?.handleNativeChatCancelPrompt).toEqual(expect.any(Function))
structuredActivity.turnId = null
harnessHostSupport = structuredAgentSessionHostSupport(['agent-session.conversation-stop.v1'])
await act(async () => renderer?.update(createElement(Harness, props)))
expect(controller?.handleNativeChatCancelPrompt).toBeUndefined()
} finally {
harnessHostSupport = null
structuredActivity.isWorking = false
structuredActivity.turnId = null
}
})
it('exposes structured prompt cards and session options on structured tabs', async () => {
await act(async () => {
renderer?.update(createElement(Harness, structuredTab('codex')))
})
expect(controller!.nativeChatPermission).toEqual(structuredPermission)
expect(controller!.nativeChatQuestion).toEqual(structuredQuestion)
expect(controller!.nativeChatSessionOptions).not.toBeNull()
expect(controller!.nativeChatSessionOptions?.controller.snapshot).toEqual(
structuredOptionSnapshot
)
await act(async () => {
expect(await controller!.handleNativeChatRespondPermission('allow-once')).toBe(true)
})
expect(structuredRespondPermission).toHaveBeenCalledWith('allow-once')
expect(sendWithOutcome).not.toHaveBeenCalled()
await act(async () => {
expect(await controller!.handleNativeChatQuestionAnswer?.('choice-a')).toBe(true)
})
expect(structuredRespondQuestion).toHaveBeenCalledWith('choice-a')
expect(clientStub.sendRequest).not.toHaveBeenCalled()
await act(async () => {
expect(
await controller!.nativeChatSessionOptions!.controller.setOption('model', 'gpt-fast')
).toBe(true)
})
expect(structuredSetOption).toHaveBeenCalledWith('model', 'gpt-fast')
})
it('pre-clears separately for a text-only send but never for an image send', async () => {
// Image paste already cleared input; clearing again here would erase the photo before the text send.
sendWithOutcome.mockResolvedValue('accepted')
await act(async () => {
await controller!.handleNativeChatSend('answer')
})
expect(clientStub.sendRequest).toHaveBeenCalledWith(
'terminal.send',
expect.objectContaining({ text: '\x15', enter: false }),
expect.any(Object)
)
expect(sendWithOutcome).toHaveBeenLastCalledWith(
expect.not.objectContaining({ clearInputFirst: expect.anything() })
)
clientStub.sendRequest.mockClear()
await act(async () => {
await controller!.handleNativeChatSend('look', ['file:///a.jpg'])
})
expect(clientStub.sendRequest).not.toHaveBeenCalled()
expect(sendWithOutcome).toHaveBeenLastCalledWith(expect.objectContaining({ text: 'look' }))
expect(sendWithOutcome.mock.calls.at(-1)?.[0]).not.toHaveProperty('clearInputFirst')
})
it('holds an unknown-outcome send without posting the optimistic echo', async () => {
sendWithOutcome.mockResolvedValue('unknown')
let accepted = false
await act(async () => {
accepted = await controller!.handleNativeChatSend('look', ['file:///a.jpg'])
})
expect(accepted).toBe(true)
expect(acceptSend).not.toHaveBeenCalled()
expect(holdUnconfirmedSend).toHaveBeenCalledWith(ORIGIN, 'look', expect.any(Function))
expect(clearDraftForSend).toHaveBeenCalledWith(ORIGIN, 'look')
expect(restoreRejectedDraft).not.toHaveBeenCalled()
})
it('preserves the unknown outcome on the WithOutcome surface for paste-first callers', async () => {
sendWithOutcome.mockResolvedValue('unknown')
let outcome = 'accepted'
await act(async () => {
outcome = await controller!.handleNativeChatSendWithOutcome('look', ['file:///a.jpg'])
})
// Preserve unknown outcomes so image callers can heal potentially orphaned pastes.
expect(outcome).toBe('unknown')
expect(holdUnconfirmedSend).toHaveBeenCalledWith(ORIGIN, 'look', expect.any(Function))
})
it('fails a send fast while the socket is down, before spending the heal budget', async () => {
// Gate on live connection immediately; lease state collapses one render later.
markMobileNativeChatInputStale('term-1')
await act(async () => {
renderer?.update(createElement(Harness, { connState: 'connecting' }))
})
let accepted = true
await act(async () => {
accepted = await controller!.handleNativeChatSend('answer')
})
expect(accepted).toBe(false)
expect(clientStub.sendRequest).not.toHaveBeenCalled()
expect(sendWithOutcome).not.toHaveBeenCalled()
expect(onSendError).toHaveBeenCalledWith('Message not sent (disconnected)')
})
it('reports a rejected send and posts no echo', async () => {
sendWithOutcome.mockResolvedValue('rejected')
let accepted = true
await act(async () => {
accepted = await controller!.handleNativeChatSend('look', ['file:///a.jpg'])
})
expect(accepted).toBe(false)
expect(acceptSend).not.toHaveBeenCalled()
expect(onSendError).toHaveBeenCalledWith('Message not sent')
expect(restoreRejectedDraft).toHaveBeenCalledWith(ORIGIN, 'look')
})
})
describe('useMobileNativeChatController ask dismissal across a transcript reload', () => {
let renderer: ReactTestRenderer | null = null
let controller: MobileNativeChatController | null = null
const clientStub = { sendRequest: vi.fn() }
const PROMPT = { questions: [{ question: 'Which path?', multiSelect: false, options: [] }] }
const chatTab = { type: 'terminal', id: 'tab-1', launchAgent: 'claude' }
// Allow tab/session changes mid-render to expose stale scope state.
const activeTab = { id: 'tab-1', sessionId: 'session-1' }
function Harness(): null {
controller = useMobileNativeChatController({
client: clientStub as unknown as RpcClient,
connState: 'connected',
hostId: 'h',
worktreeId: 'w',
activeSessionTab: {
...chatTab,
id: activeTab.id,
agentStatus: { agentType: 'claude', providerSession: { id: activeTab.sessionId } }
} as never,
activeSessionTabId: activeTab.id,
activeHandleRef: { current: 'term-1' },
deviceTokenRef: { current: null },
nativeChatTranscriptIsLocalReadable: true,
nativeChatInputLeaseReady: true,
onSendError: vi.fn(),
onSendResolved: vi.fn()
})
return null
}
function step(): void {
act(() => {
renderer?.update(createElement(Harness))
})
}
// Couple transcript doubles as production does: loading withholds messages, while retained rows may survive a read failure.
function setTranscript(status: MobileNativeChatStatus, rows = 1): void {
sessionState.status = status
sessionState.transcriptLoading = status === 'loading'
sessionState.messages =
status === 'ready' || status === 'error' ? Array.from({ length: rows }, () => ({})) : []
}
beforeEach(() => {
mobileNativeChatPromptDismissals.clearForTests()
viewMode.isTabChatView = () => true
setTranscript('ready')
promptsState.ask = PROMPT
act(() => {
renderer = create(createElement(Harness))
})
})
afterEach(() => {
act(() => renderer?.unmount())
renderer = null
controller = null
promptsState.ask = null
setTranscript('ready', 0)
viewMode.isTabChatView = () => true
activeTab.id = 'tab-1'
activeTab.sessionId = 'session-1'
})
it('scopes the dismissal to the tab it was taken on', () => {
act(() => controller?.dismissNativeChatAsk())
expect(controller?.nativeChatAsk).toBeNull()
// Identical asks on different tabs are distinct occurrences.
activeTab.id = 'tab-2'
step()
expect(controller?.nativeChatAsk).not.toBeNull()
})
it('shows a restarted session’s identical first question on the same tab', () => {
// Provider-session changes within one tab must retire dismissal identity even when question text repeats.
act(() => controller?.dismissNativeChatAsk())
expect(controller?.nativeChatAsk).toBeNull()
activeTab.sessionId = 'session-2'
setTranscript('loading')
promptsState.ask = null
step()
setTranscript('ready')
promptsState.ask = PROMPT
step()
expect(controller?.nativeChatAsk).not.toBeNull()
})
it('keeps the dismissal while the re-subscribed transcript is still empty', () => {
// Re-subscribing after a view toggle briefly hides transcript asks; that null must not retire their dismissals.
expect(controller?.nativeChatAsk).not.toBeNull()
act(() => controller?.dismissNativeChatAsk())
expect(controller?.nativeChatAsk).toBeNull()
viewMode.isTabChatView = () => false
promptsState.ask = null
step()
viewMode.isTabChatView = () => true
setTranscript('loading')
step()
setTranscript('ready')
promptsState.ask = PROMPT
step()
expect(controller?.nativeChatAsk).toBeNull()
})
it('accepts an answer taken while the first transcript read is still in flight', () => {
// A visible status ask remains observable before transcript arrival so answering it can still dismiss the card.
act(() => renderer?.unmount())
setTranscript('loading')
act(() => {
renderer = create(createElement(Harness))
})
expect(controller?.nativeChatAsk).not.toBeNull()
act(() => controller?.dismissNativeChatAsk())
expect(controller?.nativeChatAsk).toBeNull()
})
it('still retires the dismissal once a settled transcript reports no prompt', () => {
act(() => controller?.dismissNativeChatAsk())
expect(controller?.nativeChatAsk).toBeNull()
promptsState.ask = null
step()
promptsState.ask = PROMPT
step()
expect(controller?.nativeChatAsk).not.toBeNull()
})
it('keeps the dismissal while a dropped client empties the transcript', () => {
// Disconnected idle also withholds messages without loading; its null cannot prove the prompt cleared.
act(() => controller?.dismissNativeChatAsk())
expect(controller?.nativeChatAsk).toBeNull()
setTranscript('idle')
promptsState.ask = null
step()
setTranscript('ready')
promptsState.ask = PROMPT
step()
expect(controller?.nativeChatAsk).toBeNull()
})
it('keeps the dismissal when the first read of a transcript errors', () => {
// Initial-drain errors publish empty rows before later recovery; do not treat never-read emptiness as prompt clearance.
act(() => controller?.dismissNativeChatAsk())
expect(controller?.nativeChatAsk).toBeNull()
setTranscript('error', 0)
promptsState.ask = null
step()
setTranscript('ready')
promptsState.ask = PROMPT
step()
expect(controller?.nativeChatAsk).toBeNull()
})
it('retires the dismissal when a failed read still reports the last transcript', () => {
// Errors with retained history can still prove a prompt cleared; blanket error suppression would hide the next identical ask.
act(() => controller?.dismissNativeChatAsk())
expect(controller?.nativeChatAsk).toBeNull()
setTranscript('error')
promptsState.ask = null
step()
promptsState.ask = PROMPT
step()
expect(controller?.nativeChatAsk).not.toBeNull()
})
it('keeps the dismissal while the tab has no provider session yet', () => {
// Waiting-session withholds messages without loading while launch identity can keep chat visible.
act(() => controller?.dismissNativeChatAsk())
expect(controller?.nativeChatAsk).toBeNull()
setTranscript('waiting-session')
promptsState.ask = null
step()
setTranscript('ready')
promptsState.ask = PROMPT
step()
expect(controller?.nativeChatAsk).toBeNull()
})
})
describe('useMobileNativeChatController terminal turn', () => {
let renderer: ReactTestRenderer | null = null
let controller: MobileNativeChatController | null = null
const clientStub = { sendRequest: vi.fn() }
const turnStartedAt = 1_700_000_000_000
const workingTab = {
type: 'terminal',
id: 'tab-1',
terminal: 'term-1',
launchAgent: 'claude',
agentStatus: {
state: 'working',
workingMode: undefined as 'monitoring' | undefined,
agentType: 'claude',
lastAssistantMessage: 'Partial reply',
providerSession: { id: 'session-1' },
updatedAt: turnStartedAt,
stateStartedAt: turnStartedAt,
turnStartedAt
},
isActive: true
}
function Harness(): null {
controller = useMobileNativeChatController({
client: clientStub as unknown as RpcClient,
connState: 'connected',
hostId: 'h',
worktreeId: 'w',
activeSessionTab: workingTab as never,
activeSessionTabId: 'tab-1',
activeHandleRef: { current: 'term-1' },
deviceTokenRef: { current: null },
nativeChatTranscriptIsLocalReadable: true,
nativeChatInputLeaseReady: true,
onSendError: vi.fn(),
onSendResolved: vi.fn()
})
return null
}
function rerender(): void {
act(() => renderer?.update(createElement(Harness)))
}
beforeEach(() => {
// Hook freshness is read against the clock, so pin it to the hook's own update.
vi.useFakeTimers({ toFake: ['Date'] })
vi.setSystemTime(turnStartedAt)
viewMode.isTabChatView = () => true
workingTab.agentStatus.workingMode = undefined
workingTab.agentStatus.stateStartedAt = turnStartedAt
act(() => {
renderer = create(createElement(Harness))
})
})
afterEach(() => {
act(() => renderer?.unmount())
renderer = null
controller = null
viewMode.isTabChatView = () => true
Reflect.deleteProperty(sessionState, 'transcriptLifecycle')
vi.useRealTimers()
})
it('runs the turn clock and streams the hook preview while the agent works', () => {
expect(controller?.nativeChatTurnActive).toBe(true)
expect(controller?.nativeChatCanStop).toBe(true)
expect(controller?.nativeChatStreamingText).toBe('Partial reply')
expect(controller?.nativeChatWorkingStartedAt).toBe(turnStartedAt)
})
it('treats passive monitoring as outside the foreground turn', () => {
workingTab.agentStatus.workingMode = 'monitoring'
rerender()
expect(controller?.nativeChatTurnActive).toBe(false)
expect(controller?.nativeChatStreamingText).toBeUndefined()
expect(controller?.nativeChatSessionOptions?.isWorking).toBe(false)
})
it("ends working at the transcript's turn end when the final hook never came", () => {
Object.assign(sessionState, {
transcriptLifecycle: { state: 'completed', turnId: 't1', timestamp: turnStartedAt + 30_000 }
})
rerender()
expect(controller?.nativeChatTurnActive).toBe(false)
expect(controller?.nativeChatCanStop).toBe(false)
})
it('holds Stop until the agent starts a newer turn', () => {
act(() => controller?.handleNativeChatStop())
expect(controller?.nativeChatCanStop).toBe(false)
workingTab.agentStatus.stateStartedAt = turnStartedAt + 60_000
rerender()
expect(controller?.nativeChatCanStop).toBe(true)
})
it('ends the Stop hold once a new message is sent, as desktop does', () => {
act(() => controller?.handleNativeChatStop())
expect(controller?.nativeChatCanStop).toBe(false)
draftPending = [{ id: 'pending-1' }]
try {
rerender()
expect(controller?.nativeChatCanStop).toBe(true)
} finally {
draftPending = []
}
})
it('offers Stop again when the Escape definitely went nowhere', async () => {
terminalStop.mockResolvedValueOnce(false)
await act(async () => controller?.handleNativeChatStop())
expect(controller?.nativeChatCanStop).toBe(true)
})
it('keeps the delayed-send route guard view-gated', () => {
const before = answerSendArgs.at(-1)?.streamIdentity
expect(before).toContain('term-1')
viewMode.isTabChatView = () => false
rerender()
expect(answerSendArgs.at(-1)?.streamIdentity).not.toBe(before)
expect(controller?.nativeChatTurnActive).toBe(false)
})
})