Merge commit '64181fdd424d6b6a796e99b8bf023595629b23c0' into brennanb2025/skill-freshness-detection

This commit is contained in:
Brennan Benson
2026-07-16 13:53:02 -07:00
247 changed files with 17512 additions and 1989 deletions
+35 -6
View File
@@ -3896,6 +3896,7 @@
"layer": "runtime-rpc-contract",
"surfaces": [
"mobile terminal",
"native-chat terminal overlay",
"runtime RPC",
"binary multiplex",
"JSON subscribe fallback",
@@ -3917,21 +3918,25 @@
"macos"
],
"coveredProviders": [],
"coverageNotes": "Local macOS evidence over the runtime-RPC stream budgets on main@1282f5c2d. The pending stack adds byte-exact 512KB/2MB/256KB/48KB budget assertions; legacy JSON subscribe parity remains undecided.",
"coverageNotes": "Local macOS evidence over the runtime-RPC stream budgets on main@1282f5c2d. PR #5824 adds a platform-neutral mobile decision gate proving chat-covered terminal streams pause and resume only after the mounted WebView is ready; live Android restore evidence remains required. The pending stack adds byte-exact 512KB/2MB/256KB/48KB budget assertions; legacy JSON subscribe parity remains undecided.",
"motivatingLinks": [
"https://github.com/stablyai/orca/pull/6951",
"https://github.com/stablyai/orca/pull/6955",
"https://github.com/stablyai/orca/pull/7009"
],
"invariant": "Runtime and mobile terminal subscriptions must cap initial snapshots, live output buffered while snapshots load, chunk sizes, and batch sizes, while preserving output order, input locks, resize/driver events, and fallback parity or explicit fallback deprecation.",
"oracle": "The current executable slice asserts mobile initial snapshots downgrade until they fit <=512KB, requested binary snapshots downgrade until they fit <=2MB, binary live output queued while the initial snapshot loads stays <=256KB while preserving the newest tail, large binary output is split into <=48KB frames, output bursts are coalesced before emit, aborts do not register stale listeners, and stale mobile resize re-stream completions are dropped. JSON fallback parity/deprecation remains an explicit gap.",
"invariant": "Runtime and mobile terminal subscriptions must cap initial snapshots, live output buffered while snapshots load, chunk sizes, and batch sizes; a terminal covered by native chat must have no live output subscription and must restore from fresh scrollback when revealed, while preserving output order, input locks, resize/driver events, and fallback parity or explicit fallback deprecation.",
"oracle": "The current executable slice asserts mobile initial snapshots downgrade until they fit <=512KB, requested binary snapshots downgrade until they fit <=2MB, binary live output queued while the initial snapshot loads stays <=256KB while preserving the newest tail, large binary output is split into <=48KB frames, output bursts are coalesced before emit, aborts do not register stale listeners, and stale mobile resize re-stream completions are dropped. The mobile native-chat decision test asserts an active stream pauses while covered and resumes only for a ready active terminal. JSON fallback parity and live Android scrollback restoration remain explicit gaps.",
"commands": [
"pnpm exec vitest run --config config/vitest.config.ts src/main/runtime/rpc/terminal-subscribe-buffer.test.ts src/main/runtime/rpc/terminal-output-batching.test.ts src/main/runtime/rpc/terminal-multiplex.test.ts"
"pnpm exec vitest run --config config/vitest.config.ts src/main/runtime/rpc/terminal-subscribe-buffer.test.ts src/main/runtime/rpc/terminal-output-batching.test.ts src/main/runtime/rpc/terminal-multiplex.test.ts",
"pnpm --dir mobile exec vitest run --root .. mobile/src/session/mobile-native-chat-terminal-stream.test.ts",
"pnpm --dir mobile exec vitest run --root .. mobile/src/session/use-mobile-native-chat-terminal-stream.test.ts"
],
"testFiles": [
"src/main/runtime/rpc/terminal-subscribe-buffer.test.ts",
"src/main/runtime/rpc/terminal-output-batching.test.ts",
"src/main/runtime/rpc/terminal-multiplex.test.ts"
"src/main/runtime/rpc/terminal-multiplex.test.ts",
"mobile/src/session/mobile-native-chat-terminal-stream.test.ts",
"mobile/src/session/use-mobile-native-chat-terminal-stream.test.ts"
],
"assertionRefs": [
{
@@ -3956,6 +3961,21 @@
"oversized live output frames are bounded for subscribed binary streams",
"multibyte live output flushes when encoded bytes reach the batch budget"
]
},
{
"file": "mobile/src/session/mobile-native-chat-terminal-stream.test.ts",
"assertions": [
"an active terminal stream pauses while native chat covers its WebView",
"the stream resumes only for the ready active terminal after chat closes",
"non-terminal and already-settled stream states perform no lifecycle work"
]
},
{
"file": "mobile/src/session/use-mobile-native-chat-terminal-stream.test.ts",
"assertions": [
"a late WebView-ready transition converts a cold-start lease-only stream back to output",
"ordinary WebView readiness does not rerender the mobile session route"
]
}
],
"evidenceRuns": [
@@ -3967,6 +3987,15 @@
"result": "passed",
"durationSeconds": 3.8,
"summary": "3 test file(s) passed, 28 tests passed on main@1282f5c2d in a clean checkout."
},
{
"date": "2026-07-12",
"runner": "local",
"platform": "macos",
"command": "pnpm --dir mobile exec vitest run --root .. mobile/src/session/mobile-native-chat-terminal-stream.test.ts",
"result": "passed",
"durationSeconds": 0.2,
"summary": "The focused mobile native-chat suite passed with 3 terminal-stream lifecycle assertions in the staged PR #5824 worktree."
}
],
"runtimeBudget": {
@@ -3991,7 +4020,7 @@
"Record byte counts and order/cursor artifacts on failure."
],
"knownGaps": [
"Current command covers binary runtime/mobile stream contracts only.",
"The pure mobile decision gate does not yet prove live Android WebView scrollback restore after a chat toggle.",
"Legacy JSON subscribe parity is undecided."
],
"demotionRule": "Cannot promote while a supported stream path has uncapped snapshot or live-output buffering."
+4 -4
View File
@@ -1,5 +1,5 @@
<svg xmlns="http://www.w3.org/2000/svg" width="113" height="20" role="img" aria-label="downloads: 5.7m">
<title>downloads: 5.7m</title>
<svg xmlns="http://www.w3.org/2000/svg" width="113" height="20" role="img" aria-label="downloads: 5.8m">
<title>downloads: 5.8m</title>
<linearGradient id="s" x2="0" y2="100%">
<stop offset="0" stop-color="#bbb" stop-opacity=".1"/>
<stop offset="1" stop-opacity=".1"/>
@@ -15,7 +15,7 @@
<g fill="#fff" text-anchor="middle" font-family="Verdana,Geneva,DejaVu Sans,sans-serif" text-rendering="geometricPrecision" font-size="11">
<text x="37" y="15" fill="#010101" fill-opacity=".3">downloads</text>
<text x="37" y="14">downloads</text>
<text x="93.5" y="15" fill="#010101" fill-opacity=".3">5.7m</text>
<text x="93.5" y="14">5.7m</text>
<text x="93.5" y="15" fill="#010101" fill-opacity=".3">5.8m</text>
<text x="93.5" y="14">5.8m</text>
</g>
</svg>

Before

Width:  |  Height:  |  Size: 943 B

After

Width:  |  Height:  |  Size: 943 B

+164 -158
View File
@@ -26,10 +26,8 @@ import {
Bot,
ChevronDown,
ChevronLeft,
ChevronRight,
ChevronsRight,
Copy,
Eraser,
Folder,
File,
FileText,
@@ -174,6 +172,7 @@ import {
type MobileNewTabAgentSettings
} from '../../../../src/session/mobile-new-tab-agent-options'
import { useMobileImageAttachment } from '../../../../src/session/use-mobile-image-attachment'
import { useMobileAttachmentInputLeaseGate } from '../../../../src/session/use-mobile-attachment-input-lease-gate'
import { useMobileTerminalPaste } from '../../../../src/session/use-mobile-terminal-paste'
import { useTerminalLiveInputModePreference } from '../../../../src/session/use-terminal-live-input-mode-preference'
import { MobileTerminalLiveInputStatus } from '../../../../src/session/MobileTerminalLiveInputStatus'
@@ -198,6 +197,15 @@ import {
isDictationSetupRequiredError
} from '../../../../src/dictation/mobile-dictation-setup'
import { TerminalPaneView } from '../../../../src/session/TerminalPaneView'
import { MobileNativeChatOverlay } from '../../../../src/session/MobileNativeChatOverlay'
import { MobileBrowserTabActionSheet } from '../../../../src/session/MobileBrowserTabActionSheet'
import { useMobileNativeChatController } from '../../../../src/session/use-mobile-native-chat-controller'
import { useMobileNativeChatReadability } from '../../../../src/session/use-mobile-native-chat-readability'
import { useMobileNativeChatInputLease } from '../../../../src/session/use-mobile-native-chat-input-lease'
import { getMobileTerminalActionSheetActions } from '../../../../src/session/mobile-terminal-action-sheet-actions'
import * as nativeChatTerminalStream from '../../../../src/session/mobile-native-chat-terminal-stream'
import { useMobileNativeChatTerminalStream } from '../../../../src/session/use-mobile-native-chat-terminal-stream'
import { subscribeMobileTerminalSafely } from '../../../../src/session/mobile-terminal-stream-subscribe'
import {
activateMobileSessionTab,
focusMobileTerminal
@@ -249,8 +257,6 @@ import type {
TerminalGestureInputQueue
} from './mobile-session-route-types'
type TerminalLiveAccessoryInput = ReturnType<typeof createTerminalLiveAccessoryInput>
const TERMINAL_KEYBOARD_DISMISS_ACTION_SHEET_FALLBACK_MS = 450
function MarkdownReader({
@@ -1167,11 +1173,48 @@ export default function SessionScreen() {
},
[clearToastHideTimer]
)
const showNativeChatSendError = useCallback(
(message: string) => showToast(message, 1600),
[showToast]
)
const nativeChatTranscriptIsLocalReadable = useMobileNativeChatReadability(client, worktreeId)
const {
ready: nativeChatInputLeaseReady,
readyRef: nativeChatInputLeaseReadyRef,
lockReason: nativeChatInputLockReason,
markReady: markNativeChatInputLeaseReady,
clear: clearNativeChatInputLease
} = useMobileNativeChatInputLease({
activeHandle,
connected: connState === 'connected'
})
const nativeChatController = useMobileNativeChatController({
client,
hostId,
worktreeId,
activeSessionTab,
activeSessionTabId,
activeHandleRef,
deviceTokenRef,
nativeChatTranscriptIsLocalReadable,
nativeChatInputLeaseReady,
onSendError: showNativeChatSendError
})
const { chatTabIds, toggleTabChatView, showNativeChat, showNativeChatRef } = nativeChatController
const dictation = useMobileDictation({
client,
enabled: canSend,
onTranscript: (text) => {
// Why: dictation belongs to the visible composer. Native chat consumes it
// locally; terminal mode retains the live-input routing and flush contract.
if (showNativeChatRef.current) {
nativeChatController.setChatComposerText((current) =>
appendBufferedDictation(current, text)
)
showToast('Dictation inserted')
return
}
// Live mode inserts the transcript straight into its originating PTY as
// text (no Return — the user sends it themselves), matching live keystroke
// semantics; buffered mode keeps appending to the command field.
@@ -1290,22 +1333,27 @@ export default function SessionScreen() {
return handle ? terminalRefs.current.get(handle) : undefined
}, [])
const unsubscribeTerminal = useCallback((handle: string) => {
terminalUnsubsRef.current.get(handle)?.()
terminalUnsubsRef.current.delete(handle)
subscribingHandlesRef.current.delete(handle)
terminalDiagnosticsRef.current.terminalUnsubscribed(handle)
subscribeSeqRef.current.set(handle, (subscribeSeqRef.current.get(handle) ?? 0) + 1)
// Why: a fresh subscription will land on a new server-side state machine
// run (or the same one with a higher seq); reset the high-water mark so
// the first scrollback isn't accidentally dropped as stale.
layoutSeqRef.current.delete(handle)
}, [])
const unsubscribeTerminal = useCallback(
(handle: string) => {
terminalUnsubsRef.current.get(handle)?.()
terminalUnsubsRef.current.delete(handle)
subscribingHandlesRef.current.delete(handle)
terminalDiagnosticsRef.current.terminalUnsubscribed(handle)
subscribeSeqRef.current.set(handle, (subscribeSeqRef.current.get(handle) ?? 0) + 1)
// Why: a fresh subscription will land on a new server-side state machine
// run (or the same one with a higher seq); reset the high-water mark so
// the first scrollback isn't accidentally dropped as stale.
layoutSeqRef.current.delete(handle)
clearNativeChatInputLease(handle)
},
[clearNativeChatInputLease]
)
const unsubscribeTerminalRef = useRef(unsubscribeTerminal)
unsubscribeTerminalRef.current = unsubscribeTerminal
const clearTerminalCache = useCallback(() => {
for (const unsub of terminalUnsubsRef.current.values()) {
unsub()
}
terminalUnsubsRef.current.forEach((unsub) => unsub())
clearNativeChatInputLease()
terminalUnsubsRef.current.clear()
subscribingHandlesRef.current.clear()
initializedHandlesRef.current.clear()
@@ -1318,7 +1366,7 @@ export default function SessionScreen() {
for (const term of terminalRefs.current.values()) {
term.clear()
}
}, [])
}, [clearNativeChatInputLease])
// Why: measures the phone viewport once from the first available TerminalWebView.
// The viewport dims are passed with every subscribe call so the server can
@@ -1357,13 +1405,22 @@ export default function SessionScreen() {
logSkippedGate('subscribe-in-flight')
return
}
if (!getTerminalRef(handle)) {
logSkippedGate('no-webview-ref')
return
}
if (!webReadyHandlesRef.current.has(handle)) {
logSkippedGate('webview-not-ready')
return
const covered = nativeChatTerminalStream.isTerminalCoveredByNativeChat(
showNativeChatRef.current,
activeHandleRef.current,
handle
)
// Why: a native-chat-covered terminal subscribes as the input-floor lease
// without a mounted xterm webview, so only gate on the webview when NOT covered.
if (!covered) {
if (!getTerminalRef(handle)) {
logSkippedGate('no-webview-ref')
return
}
if (!webReadyHandlesRef.current.has(handle)) {
logSkippedGate('webview-not-ready')
return
}
}
subscribingHandlesRef.current.add(handle)
@@ -1375,13 +1432,13 @@ export default function SessionScreen() {
// The viewport is embedded in the subscribe params so the server resizes
// the PTY before serializing scrollback. This eliminates the focus→safeFit
// race and the measure→resize→resubscribe pipeline.
const unsub = client.subscribe(
'terminal.subscribe',
const unsub = subscribeMobileTerminalSafely(
client,
{
terminal: handle,
client: { id: deviceTokenRef.current!, type: 'mobile' as const },
viewport: viewportRef.current ?? undefined,
capabilities: { terminalBinaryStream: 1 }
capabilities: nativeChatTerminalStream.mobileNativeChatTerminalCapabilities(covered)
},
(result) => {
if (subscribeSeqRef.current.get(handle) !== seq) {
@@ -1389,6 +1446,25 @@ export default function SessionScreen() {
}
const data = result as Record<string, unknown>
diagnostics.firstStreamEvent(handle, seq, data.type)
if (data.type === 'end' || data.type === 'error') {
unsubscribeTerminalRef.current(handle)
return
}
if (data.type === 'subscribed') {
markNativeChatInputLeaseReady(handle)
return
}
// Why: retain the subscription as the mobile input-floor lease, but
// do not mutate covered xterm state; return-to-terminal resubscribes.
if (
nativeChatTerminalStream.isTerminalCoveredByNativeChat(
showNativeChatRef.current,
activeHandleRef.current,
handle
)
) {
return
}
// Why: stale-event filter. Server-side state machine bumps a
// monotonic seq on every applyLayout. Drop `resized` events
// whose seq is strictly older than what we've already observed
@@ -1417,9 +1493,6 @@ export default function SessionScreen() {
} else if (eventSeq != null && data.type === 'scrollback') {
layoutSeqRef.current.set(handle, eventSeq)
}
if (data.type === 'subscribed') {
return
}
if (data.type === 'scrollback') {
diagnostics.streamScrollback(handle, seq, eventSeq, data)
if (initializedHandlesRef.current.has(handle)) {
@@ -1572,7 +1645,8 @@ export default function SessionScreen() {
}
scheduleDelayedAction(() => getTerminalRef(handle)?.resetZoom(), 200)
}
}
},
() => unsubscribeTerminalRef.current(handle)
)
if (subscribeSeqRef.current.get(handle) === seq) {
@@ -1582,9 +1656,21 @@ export default function SessionScreen() {
}
subscribingHandlesRef.current.delete(handle)
},
[client, getTerminalRef, scheduleDelayedAction]
[client, getTerminalRef, markNativeChatInputLeaseReady, scheduleDelayedAction]
)
const notifyTerminalWebReady = useMobileNativeChatTerminalStream({
showNativeChat,
activeHandle,
activeTabType: activeSessionTab?.type ?? null,
subscriptionsRef: terminalUnsubsRef,
subscribingRef: subscribingHandlesRef,
webReadyRef: webReadyHandlesRef,
initializedRef: initializedHandlesRef,
subscribe: subscribeToTerminal,
unsubscribe: unsubscribeTerminal
})
// Why: toggles between phone and desktop mode via server RPC. The server
// handles the actual resize and emits a 'resized' event on the existing
// subscription stream — no client-side state tracking needed.
@@ -3064,6 +3150,7 @@ export default function SessionScreen() {
(handle: string) => {
const wasAlreadyReady = webReadyHandlesRef.current.has(handle)
webReadyHandlesRef.current.add(handle)
notifyTerminalWebReady(handle, wasAlreadyReady)
terminalDiagnosticsRef.current.webViewReady(
handle,
wasAlreadyReady,
@@ -3105,7 +3192,7 @@ export default function SessionScreen() {
})()
}
},
[getTerminalRef, measureViewportOnce, subscribeToTerminal, unsubscribeTerminal]
[measureViewportOnce, notifyTerminalWebReady, subscribeToTerminal, unsubscribeTerminal]
)
useEffect(() => {
@@ -3156,7 +3243,7 @@ export default function SessionScreen() {
}
}
async function handleAccessoryKey(input: TerminalLiveAccessoryInput) {
async function handleAccessoryKey(input: ReturnType<typeof createTerminalLiveAccessoryInput>) {
if (!client || !activeHandle || !canSend) {
return
}
@@ -3643,7 +3730,7 @@ export default function SessionScreen() {
}
}, [])
const startAccessoryRepeat = useCallback(
(input: TerminalLiveAccessoryInput) => {
(input: ReturnType<typeof createTerminalLiveAccessoryInput>) => {
stopAccessoryRepeat()
repeatTimeoutRef.current = setTimeout(() => {
repeatIntervalRef.current = setInterval(() => {
@@ -3815,19 +3902,14 @@ export default function SessionScreen() {
showToast
})
const flushPendingLiveInputBeforeAttachmentSend = useCallback(
async (targetHandle: string): Promise<boolean> => {
const flushedPendingInput = await flushPendingLiveInputBeforeExternalSend(targetHandle)
// Why: image picking/upload and IME flushing can outlive the original tab.
return (
flushedPendingInput &&
connStateRef.current === 'connected' &&
targetHandle === activeHandleRef.current &&
activeSessionTabTypeRef.current === 'terminal'
)
},
[flushPendingLiveInputBeforeExternalSend]
)
const flushPendingLiveInputBeforeAttachmentSend = useMobileAttachmentInputLeaseGate({
flushPendingLiveInputBeforeExternalSend,
connStateRef,
activeHandleRef,
activeSessionTabTypeRef,
nativeChatInputLeaseReadyRef,
showToast
})
const { attachImage, isAttaching } = useMobileImageAttachment({
client,
@@ -4887,6 +4969,18 @@ export default function SessionScreen() {
onOpenUrl={handleTerminalOpenUrl}
/>
))}
<MobileNativeChatOverlay
controller={nativeChatController}
onAttachImage={() => void attachImage('library')}
isAttaching={isAttaching}
onMicPress={handleDictationToggle}
micActive={dictation.isRecording}
dictationMode={dictationMode}
onMicPressIn={handleDictationPressIn}
onMicPressOut={handleDictationPressOut}
inputLockReason={nativeChatInputLockReason}
keyboardInset={keyboardLift}
/>
{toastMessage && (
<Animated.View pointerEvents="none" style={[styles.toast, toastAnimatedStyle]}>
<Text style={styles.toastText}>{toastMessage}</Text>
@@ -4896,8 +4990,9 @@ export default function SessionScreen() {
)}
{/* Why: translate instead of resizing so keyboard open/close does not
trigger a server-side PTY viewport change. */}
{!activeMarkdownTab && !activeFileTab && !activeBrowserTab && (
trigger a server-side PTY viewport change. The dock hides in native
chat because that view supplies its own composer. */}
{!activeMarkdownTab && !activeFileTab && !activeBrowserTab && !showNativeChat && (
<View
style={[
styles.commandDock,
@@ -5318,55 +5413,19 @@ export default function SessionScreen() {
<ActionSheetModal
visible={actionTarget != null}
title={actionTarget?.title || 'Terminal'}
actions={[
...(actionTarget
? [
{
label: isPhoneMode(actionTarget.handle) ? 'Switch to Desktop' : 'Switch to Phone',
icon: isPhoneMode(actionTarget.handle) ? Monitor : Smartphone,
onPress: () => {
const target = actionTarget
setActionTarget(null)
if (target) {
void toggleDisplayMode(target.handle)
}
}
}
]
: []),
{
label: 'Rename',
onPress: () => {
const target = actionTarget
setActionTarget(null)
if (target) {
setRenameTarget(target)
}
}
},
{
label: 'Clear Terminal',
icon: Eraser,
onPress: () => {
const target = actionTarget
setActionTarget(null)
if (target) {
void handleClearTerminal(target)
}
}
},
{
label: 'Close',
destructive: true,
onPress: () => {
const target = actionTarget
setActionTarget(null)
if (target) {
void handleCloseTerminal(target)
}
}
}
]}
actions={getMobileTerminalActionSheetActions({
target: actionTarget,
tabs: sessionTabs.filter((tab) => tab.type === 'terminal'),
chatTabIds,
nativeChatTranscriptIsLocalReadable,
onDismiss: () => setActionTarget(null),
onToggleChat: toggleTabChatView,
isPhoneMode,
onToggleDisplayMode: (handle) => void toggleDisplayMode(handle),
onRename: setRenameTarget,
onClear: (target) => void handleClearTerminal(target),
onClose: (target) => void handleCloseTerminal(target)
})}
onClose={() => setActionTarget(null)}
/>
<ActionSheetModal
@@ -5439,64 +5498,11 @@ export default function SessionScreen() {
]}
onClose={() => setFileActionTarget(null)}
/>
<ActionSheetModal
visible={browserActionTarget != null}
title={browserActionTarget ? getMobileSessionTabTitle(browserActionTarget) : 'Browser'}
actions={[
...(browserActionTarget?.canGoBack
? [
{
label: 'Back',
icon: ChevronLeft,
onPress: () => {
const target = browserActionTarget
setBrowserActionTarget(null)
if (target) {
void handleBrowserNavigationCommand(target, 'browser.back')
}
}
}
]
: []),
...(browserActionTarget?.canGoForward
? [
{
label: 'Forward',
icon: ChevronRight,
onPress: () => {
const target = browserActionTarget
setBrowserActionTarget(null)
if (target) {
void handleBrowserNavigationCommand(target, 'browser.forward')
}
}
}
]
: []),
{
label: 'Reload',
icon: RefreshCw,
onPress: () => {
const target = browserActionTarget
setBrowserActionTarget(null)
if (target) {
void handleBrowserNavigationCommand(target, 'browser.reload')
}
}
},
{
label: 'Close',
destructive: true,
onPress: () => {
const target = browserActionTarget
setBrowserActionTarget(null)
if (target) {
void handleCloseSessionTab(target)
}
}
}
]}
<MobileBrowserTabActionSheet
target={browserActionTarget}
onClose={() => setBrowserActionTarget(null)}
onNavigate={handleBrowserNavigationCommand}
onCloseTab={handleCloseSessionTab}
/>
<ActionSheetModal
visible={leaveDrafts != null}
@@ -23,6 +23,8 @@ export type MobileSessionTab =
status?: 'pending-handle' | 'ready'
terminal: string | null
agentStatus?: AgentStatusEntry | null
/** Agent Orca launched in this terminal, if any. This makes chat eligible
* before the first live agent-status update reaches the mobile client. */
launchAgent?: TuiAgent
terminalTheme?: MobileTerminalTheme
isActive: boolean
+80 -162
View File
@@ -1,12 +1,24 @@
import { Fragment, memo, useMemo, type ReactNode } from 'react'
import { Linking, Pressable, ScrollView, StyleSheet, Text, View } from 'react-native'
import { colors, radii, spacing, typography } from '../theme/mobile-theme'
import { Linking, Pressable, ScrollView, Text, View } from 'react-native'
import { normalizeMobileMarkdownPreviewHtml } from './mobile-markdown-preview-html'
import { styles } from './mobile-markdown-styles'
import {
detectFilePathSegments,
isFilePathCodeSpan,
normalizeFilePath
} from './markdown-file-path-detection'
import { parseMobileMarkdown } from './mobile-markdown-parser'
type Props = {
content?: string
fallback?: string
/** Multiplier for prose font size (paragraphs, lists, quotes). Defaults to 1;
* the chat view passes >1 so agent prose reads larger than the compact base. */
textScale?: number
/** When provided, detected file-path tokens render as tappable and invoke this
* with the worktree-relative path. Omitted on screens with no file viewer, where
* paths render as plain text (no behavior change). */
onOpenFile?: (relativePath: string) => void
}
const MAX_TABLE_ROWS = 40
@@ -19,7 +31,37 @@ function openMarkdownUrl(url: string): void {
}
}
function renderInline(text: string): ReactNode[] {
// Render a plain (non-token) text run, splitting out tappable file paths when
// onOpenFile is provided. Without it, paths stay plain text.
function renderTextRun(
text: string,
keyPrefix: string,
onOpenFile?: (relativePath: string) => void
): ReactNode {
if (!onOpenFile) {
return text
}
const segments = detectFilePathSegments(text)
if (segments.length === 1 && segments[0]!.type === 'text') {
return text
}
return segments.map((segment, segmentIndex) => {
if (segment.type === 'file') {
return (
<Text
key={`${keyPrefix}:${segmentIndex}`}
style={styles.link}
onPress={() => onOpenFile(segment.path)}
>
{segment.value}
</Text>
)
}
return <Fragment key={`${keyPrefix}:${segmentIndex}`}>{segment.value}</Fragment>
})
}
function renderInline(text: string, onOpenFile?: (relativePath: string) => void): ReactNode[] {
const parts: ReactNode[] = []
const pattern =
/(!\[[^\]]*\]\([^)]+\)|`[^`]+`|~~[^~]+~~|\*\*[^*]+\*\*|__[^_]+__|\*[^*\n]+\*|_[^_\n]+_|\[[^\]]+\]\([^)]+\)|https?:\/\/[^\s<]+)/g
@@ -28,7 +70,7 @@ function renderInline(text: string): ReactNode[] {
while ((match = pattern.exec(text))) {
if (match.index > lastIndex) {
parts.push(text.slice(lastIndex, match.index))
parts.push(renderTextRun(text.slice(lastIndex, match.index), `t${lastIndex}`, onOpenFile))
}
const token = match[0]
const key = `${match.index}:${token}`
@@ -53,11 +95,24 @@ function renderInline(text: string): ReactNode[] {
</Text>
)
} else if (token.startsWith('`')) {
parts.push(
<Text key={key} style={styles.inlineCode}>
{token.slice(1, -1)}
</Text>
)
const code = token.slice(1, -1)
if (onOpenFile && isFilePathCodeSpan(code)) {
parts.push(
<Text
key={key}
style={[styles.inlineCode, styles.inlineCodeLink]}
onPress={() => onOpenFile(normalizeFilePath(code.trim()))}
>
{code}
</Text>
)
} else {
parts.push(
<Text key={key} style={styles.inlineCode}>
{code}
</Text>
)
}
} else if (token.startsWith('~~')) {
parts.push(
<Text key={key} style={styles.strike}>
@@ -81,15 +136,20 @@ function renderInline(text: string): ReactNode[] {
}
if (lastIndex < text.length) {
parts.push(text.slice(lastIndex))
parts.push(renderTextRun(text.slice(lastIndex), `t${lastIndex}`, onOpenFile))
}
return parts
}
function MobileMarkdownInner({ content, fallback = '' }: Props) {
function MobileMarkdownInner({ content, fallback = '', textScale = 1, onOpenFile }: Props) {
const text = content?.trim() ?? ''
const previewText = useMemo(() => normalizeMobileMarkdownPreviewHtml(text), [text])
const blocks = useMemo(() => parseMobileMarkdown(previewText), [previewText])
// Scale prose sizes; inline spans inherit fontSize from the wrapping Text.
const scaled = (size: number): { fontSize: number; lineHeight: number } | null =>
textScale !== 1 ? { fontSize: size * textScale, lineHeight: (size + 6) * textScale } : null
const proseScale = scaled(13)
const listScale = scaled(14)
if (!text) {
return fallback ? <Text style={styles.paragraph}>{fallback}</Text> : null
}
@@ -103,14 +163,14 @@ function MobileMarkdownInner({ content, fallback = '' }: Props) {
key={index}
style={[styles.heading, block.level <= 2 ? styles.headingLarge : null]}
>
{renderInline(block.text)}
{renderInline(block.text, onOpenFile)}
</Text>
)
}
if (block.type === 'quote') {
return (
<View key={index} style={styles.quote}>
<Text style={styles.quoteText}>{renderInline(block.text)}</Text>
<Text style={styles.quoteText}>{renderInline(block.text, onOpenFile)}</Text>
</View>
)
}
@@ -147,7 +207,7 @@ function MobileMarkdownInner({ content, fallback = '' }: Props) {
<View style={styles.tableRow}>
{visibleHeaders.map((header, cellIndex) => (
<Text key={cellIndex} style={[styles.tableCell, styles.tableHeader]}>
{renderInline(header)}
{renderInline(header, onOpenFile)}
</Text>
))}
</View>
@@ -155,7 +215,7 @@ function MobileMarkdownInner({ content, fallback = '' }: Props) {
<View key={rowIndex} style={styles.tableRow}>
{visibleHeaders.map((_, cellIndex) => (
<Text key={cellIndex} style={styles.tableCell}>
{renderInline(row[cellIndex] ?? '')}
{renderInline(row[cellIndex] ?? '', onOpenFile)}
</Text>
))}
</View>
@@ -185,7 +245,9 @@ function MobileMarkdownInner({ content, fallback = '' }: Props) {
? '[x]'
: '[ ]'}
</Text>
<Text style={styles.listText}>{renderInline(item.text)}</Text>
<Text style={[styles.listText, listScale]}>
{renderInline(item.text, onOpenFile)}
</Text>
</View>
))}
</View>
@@ -195,11 +257,11 @@ function MobileMarkdownInner({ content, fallback = '' }: Props) {
return <View key={index} style={styles.rule} />
}
return (
<Text key={index} style={styles.paragraph}>
<Text key={index} style={[styles.paragraph, proseScale]}>
{block.text.split('\n').map((line, lineIndex) => (
<Fragment key={lineIndex}>
{lineIndex > 0 ? '\n' : null}
{renderInline(line)}
{renderInline(line, onOpenFile)}
</Fragment>
))}
</Text>
@@ -210,147 +272,3 @@ function MobileMarkdownInner({ content, fallback = '' }: Props) {
}
export const MobileMarkdown = memo(MobileMarkdownInner)
const styles = StyleSheet.create({
root: {
gap: spacing.sm
},
paragraph: {
fontSize: 13,
lineHeight: 19,
color: colors.textPrimary
},
heading: {
fontSize: 14,
lineHeight: 20,
fontWeight: '700',
color: colors.textPrimary
},
headingLarge: {
fontSize: 15,
lineHeight: 21
},
bold: {
fontWeight: '700',
color: colors.textPrimary
},
italic: {
fontStyle: 'italic'
},
strike: {
textDecorationLine: 'line-through'
},
link: {
color: colors.accentBlue,
textDecorationLine: 'underline'
},
inlineCode: {
fontFamily: typography.monoFamily,
fontSize: 12,
color: colors.textPrimary,
backgroundColor: colors.bgRaised,
borderRadius: radii.row,
paddingHorizontal: 4
},
quote: {
borderLeftWidth: 2,
borderLeftColor: colors.borderSubtle,
paddingLeft: spacing.sm
},
quoteText: {
fontSize: 13,
lineHeight: 19,
color: colors.textSecondary
},
codeBlock: {
backgroundColor: colors.bgRaised,
borderWidth: 1,
borderColor: colors.borderSubtle,
borderRadius: radii.input,
padding: spacing.sm
},
codeLanguage: {
fontSize: 10,
color: colors.textMuted,
marginBottom: spacing.xs,
textTransform: 'uppercase'
},
codeText: {
fontFamily: typography.monoFamily,
fontSize: 12,
lineHeight: 17,
color: colors.textPrimary
},
imageFrame: {
borderWidth: 1,
borderColor: colors.borderSubtle,
borderRadius: radii.input,
backgroundColor: colors.bgRaised,
overflow: 'hidden',
padding: spacing.sm
},
imageCaption: {
paddingHorizontal: spacing.sm,
paddingVertical: spacing.xs,
fontSize: 11,
color: colors.textSecondary
},
table: {
borderTopWidth: 1,
borderLeftWidth: 1,
borderColor: colors.borderSubtle,
borderRadius: radii.input,
overflow: 'hidden',
backgroundColor: colors.bgPanel
},
tableRow: {
flexDirection: 'row'
},
tableCell: {
minWidth: 112,
maxWidth: 220,
borderRightWidth: 1,
borderBottomWidth: 1,
borderColor: colors.borderSubtle,
paddingHorizontal: spacing.sm,
paddingVertical: spacing.xs,
fontSize: 12,
lineHeight: 17,
color: colors.textPrimary
},
tableHeader: {
fontWeight: '700',
backgroundColor: colors.bgRaised
},
tableTruncated: {
padding: spacing.sm,
fontSize: 12,
color: colors.textMuted
},
list: {
gap: spacing.xs
},
listItem: {
flexDirection: 'row',
alignItems: 'flex-start',
gap: spacing.sm
},
listMarker: {
width: 22,
fontSize: 13,
lineHeight: 19,
color: colors.textSecondary,
fontFamily: typography.monoFamily
},
listText: {
flex: 1,
minWidth: 0,
fontSize: 13,
lineHeight: 19,
color: colors.textPrimary
},
rule: {
height: StyleSheet.hairlineWidth,
backgroundColor: colors.borderSubtle
}
})
@@ -0,0 +1,190 @@
import { describe, expect, it } from 'vitest'
import {
detectFilePathSegments,
isFilePathCodeSpan,
normalizeFilePath
} from './markdown-file-path-detection'
describe('detectFilePathSegments', () => {
it('returns a single text segment when there is no path', () => {
expect(detectFilePathSegments('just some prose here')).toEqual([
{ type: 'text', value: 'just some prose here' }
])
})
it('detects a relative source path with surrounding prose', () => {
const segments = detectFilePathSegments('Edit src/app/Main.tsx now')
expect(segments).toEqual([
{ type: 'text', value: 'Edit ' },
{ type: 'file', value: 'src/app/Main.tsx', path: 'src/app/Main.tsx' },
{ type: 'text', value: ' now' }
])
})
it('strips a leading ./ in the path but keeps the displayed value', () => {
const segments = detectFilePathSegments('see ./lib/x.ts')
expect(segments).toEqual([
{ type: 'text', value: 'see ' },
{ type: 'file', value: './lib/x.ts', path: 'lib/x.ts' }
])
})
it('keeps ../ parent-relative paths intact', () => {
const segments = detectFilePathSegments('../shared/util.ts')
expect(segments).toEqual([
{ type: 'file', value: '../shared/util.ts', path: '../shared/util.ts' }
])
})
it('detects multiple paths in one run', () => {
const segments = detectFilePathSegments('a/b.ts and c/d/e.json')
expect(segments.filter((s) => s.type === 'file')).toEqual([
{ type: 'file', value: 'a/b.ts', path: 'a/b.ts' },
{ type: 'file', value: 'c/d/e.json', path: 'c/d/e.json' }
])
})
it('detects Windows relative, drive, and UNC paths', () => {
const segments = detectFilePathSegments(
String.raw`Edit src\app\Main.tsx, C:\repo\config.json, and \\server\share\docs\readme.md`
)
expect(segments.filter((segment) => segment.type === 'file')).toEqual([
{ type: 'file', value: String.raw`src\app\Main.tsx`, path: String.raw`src\app\Main.tsx` },
{
type: 'file',
value: String.raw`C:\repo\config.json`,
path: String.raw`C:\repo\config.json`
},
{
type: 'file',
value: String.raw`\\server\share\docs\readme.md`,
path: String.raw`\\server\share\docs\readme.md`
}
])
})
it('does not match bare filenames without a slash', () => {
expect(detectFilePathSegments('open Main.tsx please')).toEqual([
{ type: 'text', value: 'open Main.tsx please' }
])
})
it('does not match URLs', () => {
expect(detectFilePathSegments('https://example.com/path/file.ts')).toEqual([
{ type: 'text', value: 'https://example.com/path/file.ts' }
])
})
it('does not match version numbers', () => {
expect(detectFilePathSegments('upgraded to 1.2.3 today')).toEqual([
{ type: 'text', value: 'upgraded to 1.2.3 today' }
])
})
it('does not match unknown extensions', () => {
expect(detectFilePathSegments('path/to/thing.whatever')).toEqual([
{ type: 'text', value: 'path/to/thing.whatever' }
])
})
it('detects scoped-package file paths with a segment-leading @', () => {
expect(detectFilePathSegments('open @types/react/index.d.ts here')).toEqual([
{ type: 'text', value: 'open ' },
{
type: 'file',
value: '@types/react/index.d.ts',
path: '@types/react/index.d.ts'
},
{ type: 'text', value: ' here' }
])
expect(
detectFilePathSegments('node_modules/@scope/pkg/file.ts').filter((s) => s.type === 'file')
).toEqual([
{
type: 'file',
value: 'node_modules/@scope/pkg/file.ts',
path: 'node_modules/@scope/pkg/file.ts'
}
])
})
it('does not match emails or git URLs with a mid-token @', () => {
expect(detectFilePathSegments('clone git@github.com:user/repo.git')).toEqual([
{ type: 'text', value: 'clone git@github.com:user/repo.git' }
])
expect(detectFilePathSegments('open user@host.com/path/file.txt')).toEqual([
{ type: 'text', value: 'open user@host.com/path/file.txt' }
])
})
it('returns a single text segment when the run has no dot', () => {
const text = 'a/'.repeat(8192)
expect(detectFilePathSegments(text)).toEqual([{ type: 'text', value: text }])
})
it('skips detection for runs over the length cap even with dots', () => {
// 'a.b/'-repeats pass the dot precheck, so this exercises the length cap that
// bounds CANDIDATE_PATTERN's worst-case backtracking.
const text = 'a.b/'.repeat(2000)
expect(detectFilePathSegments(text)).toEqual([{ type: 'text', value: text }])
})
it('still detects a path in a long-but-under-cap run', () => {
const prefix = 'context '.repeat(200)
const segments = detectFilePathSegments(`${prefix}src/app/Main.tsx`)
expect(segments.filter((s) => s.type === 'file')).toEqual([
{ type: 'file', value: 'src/app/Main.tsx', path: 'src/app/Main.tsx' }
])
})
})
describe('isFilePathCodeSpan', () => {
it('accepts a slashed path code span', () => {
expect(isFilePathCodeSpan('src/app/Main.tsx')).toBe(true)
})
it('accepts Windows paths in code spans', () => {
expect(isFilePathCodeSpan(String.raw`src\app\Main.tsx`)).toBe(true)
expect(isFilePathCodeSpan(String.raw`C:\repo\Main.tsx`)).toBe(true)
expect(isFilePathCodeSpan(String.raw`\\server\share\Main.tsx`)).toBe(true)
})
it('accepts a bare filename code span', () => {
expect(isFilePathCodeSpan('package.json')).toBe(true)
})
it('rejects multi-word code spans', () => {
expect(isFilePathCodeSpan('npm run build')).toBe(false)
})
it('rejects non-file code spans', () => {
expect(isFilePathCodeSpan('someVariable')).toBe(false)
})
it('rejects urls in code spans', () => {
expect(isFilePathCodeSpan('https://x.com/a.ts')).toBe(false)
})
it('accepts scoped-package paths with a segment-leading @', () => {
expect(isFilePathCodeSpan('@types/react/index.d.ts')).toBe(true)
expect(isFilePathCodeSpan('node_modules/@scope/pkg/file.ts')).toBe(true)
})
it('rejects emails and git URLs with a mid-token @', () => {
expect(isFilePathCodeSpan('git@github.com:user/repo.git')).toBe(false)
expect(isFilePathCodeSpan('user@host.com/path/file.txt')).toBe(false)
})
})
describe('normalizeFilePath', () => {
it('strips a leading ./', () => {
expect(normalizeFilePath('./a/b.ts')).toBe('a/b.ts')
expect(normalizeFilePath(String.raw`.\a\b.ts`)).toBe(String.raw`a\b.ts`)
})
it('leaves other paths unchanged', () => {
expect(normalizeFilePath('../a/b.ts')).toBe('../a/b.ts')
expect(normalizeFilePath('a/b.ts')).toBe('a/b.ts')
})
})
@@ -0,0 +1,212 @@
// Conservative detection of file-path-like tokens inside an inline markdown text
// run, so the chat view can render them as tappable (opening the mobile file
// viewer). We deliberately favor precision over recall: a missed path is a minor
// annoyance, but a false positive on prose or a version number is a broken tap.
export type FilePathSegment =
| { type: 'text'; value: string }
| { type: 'file'; value: string; path: string }
// Common source/code/config extensions we treat as openable file paths. Kept
// explicit (rather than "any extension") so prose like "etc." or "e.g." and
// domain-ish tokens like "example.com" don't get matched.
const FILE_EXTENSIONS = [
'ts',
'tsx',
'js',
'jsx',
'mjs',
'cjs',
'json',
'jsonc',
'css',
'scss',
'sass',
'less',
'html',
'htm',
'md',
'mdx',
'markdown',
'txt',
'py',
'rb',
'go',
'rs',
'java',
'kt',
'kts',
'swift',
'c',
'h',
'cc',
'cpp',
'hpp',
'cs',
'php',
'sh',
'bash',
'zsh',
'fish',
'yml',
'yaml',
'toml',
'ini',
'cfg',
'conf',
'env',
'lock',
'sql',
'graphql',
'gql',
'proto',
'xml',
'svg',
'vue',
'svelte',
'astro',
'dart',
'ex',
'exs',
'erl',
'lua',
'pl',
'r',
'scala',
'clj',
'gradle',
'dockerfile',
'gitignore',
'npmrc'
] as const
const EXTENSION_SET = new Set<string>(FILE_EXTENSIONS)
// Accept the host's native separator because transcript paths originate on the
// connected runtime, which may be Windows even when the phone is not.
const CANDIDATE_PATTERN =
/(?:[A-Za-z]:[\\/]|\\\\)?(?:\.{1,2}[\\/])?(?:[\w.@~+-]+[\\/])+[\w.@+-]+\.[A-Za-z0-9]+/g
// A path candidate in chat prose is short; a much longer run can't hold one worth
// linkifying but can push CANDIDATE_PATTERN into super-linear backtracking, so we
// skip detection entirely above this cap.
const MAX_DETECTION_LENGTH = 2000
// A mid-token '@' (one preceded by a non-separator) marks an email or git URL such
// as git@github.com; a segment-leading '@' is a scoped package dir (@scope/…) and
// stays eligible.
function hasMidTokenAt(candidate: string): boolean {
return /[^\\/]@/.test(candidate)
}
function isOpenablePath(candidate: string): boolean {
// Reject anything URL-ish or scheme-bearing — those are handled as web links.
if (candidate.includes('://') || hasMidTokenAt(candidate)) {
return false
}
// Must contain a separator (a bare "file.ts" is too ambiguous in prose).
if (!/[\\/]/.test(candidate)) {
return false
}
const lastSeparator = Math.max(candidate.lastIndexOf('/'), candidate.lastIndexOf('\\'))
const lastSegment = candidate.slice(lastSeparator + 1)
const dot = lastSegment.lastIndexOf('.')
// A leading-dot dotfile in the final segment (e.g. ".env") has no extension to
// anchor on; require a real name.ext shape.
if (dot <= 0) {
return false
}
const ext = lastSegment.slice(dot + 1).toLowerCase()
if (!EXTENSION_SET.has(ext)) {
return false
}
// Guard against version-number-ish tails ("1.2.3" style) where the "extension"
// is purely numeric — those aren't files.
if (/^\d+$/.test(ext)) {
return false
}
return true
}
// Strip the leading ./ marker so callers receive a clean worktree-relative path,
// while keeping ../ (which is meaningful) intact.
export function normalizeFilePath(path: string): string {
return path.replace(/^\.[\\/]/, '')
}
/**
* Given a plain inline text run, return ordered segments marking which substrings
* are openable file paths. Non-path text is preserved verbatim so the renderer can
* reassemble the run exactly. Returns a single text segment when nothing matches.
*/
export function detectFilePathSegments(text: string): FilePathSegment[] {
// Every real match ends in a name.ext, so a dot is mandatory; skip the regex when
// there is none, or when the run is too long to hold a chat path but long enough
// to drive CANDIDATE_PATTERN into super-linear backtracking.
if (text.length > MAX_DETECTION_LENGTH || !text.includes('.')) {
return [{ type: 'text', value: text }]
}
const segments: FilePathSegment[] = []
let lastIndex = 0
let match: RegExpExecArray | null
CANDIDATE_PATTERN.lastIndex = 0
while ((match = CANDIDATE_PATTERN.exec(text))) {
const candidate = match[0]
// Skip candidates that are part of a URL (preceded by a scheme colon or an
// alphanumeric/host char that would make this a domain tail, not a path).
const prev = match.index > 0 ? text[match.index - 1]! : ''
if (prev === ':' || prev === '/' || /[\w.@]/.test(prev)) {
continue
}
if (!isOpenablePath(candidate)) {
continue
}
if (match.index > lastIndex) {
segments.push({ type: 'text', value: text.slice(lastIndex, match.index) })
}
segments.push({ type: 'file', value: candidate, path: normalizeFilePath(candidate) })
lastIndex = match.index + candidate.length
}
if (lastIndex < text.length) {
segments.push({ type: 'text', value: text.slice(lastIndex) })
}
// Collapse to a single text segment for the common no-match case.
if (segments.length === 0) {
return [{ type: 'text', value: text }]
}
return segments
}
/**
* True when an inline-code span's entire content is a single openable file path
* (e.g. `src/app/Main.tsx`). Code spans are a strong signal, so we also accept a
* bare `file.ts` here even without a slash.
*/
export function isFilePathCodeSpan(code: string): boolean {
const trimmed = code.trim()
if (!trimmed || /\s/.test(trimmed)) {
return false
}
if (trimmed.includes('://') || hasMidTokenAt(trimmed)) {
return false
}
if (isOpenablePath(trimmed)) {
return true
}
// Separator-less code span: accept a clean name.ext with a known extension.
if (/[\\/]/.test(trimmed)) {
return false
}
const dot = trimmed.lastIndexOf('.')
if (dot <= 0) {
return false
}
const name = trimmed.slice(0, dot)
const ext = trimmed.slice(dot + 1).toLowerCase()
if (/[^\w.@+-]/.test(name)) {
return false
}
return EXTENSION_SET.has(ext)
}
@@ -0,0 +1,150 @@
import { StyleSheet } from 'react-native'
import { colors, radii, spacing, typography } from '../theme/mobile-theme'
export const styles = StyleSheet.create({
root: {
gap: spacing.sm
},
paragraph: {
fontSize: 13,
lineHeight: 19,
color: colors.textPrimary
},
heading: {
fontSize: 14,
lineHeight: 20,
fontWeight: '700',
color: colors.textPrimary
},
headingLarge: {
fontSize: 15,
lineHeight: 21
},
bold: {
fontWeight: '700',
color: colors.textPrimary
},
italic: {
fontStyle: 'italic'
},
strike: {
textDecorationLine: 'line-through'
},
link: {
color: colors.accentBlue,
textDecorationLine: 'underline'
},
inlineCode: {
fontFamily: typography.monoFamily,
fontSize: 12,
color: colors.textPrimary,
backgroundColor: colors.bgRaised,
borderRadius: radii.row,
paddingHorizontal: 4
},
inlineCodeLink: {
color: colors.accentBlue,
textDecorationLine: 'underline'
},
quote: {
borderLeftWidth: 2,
borderLeftColor: colors.borderSubtle,
paddingLeft: spacing.sm
},
quoteText: {
fontSize: 13,
lineHeight: 19,
color: colors.textSecondary
},
codeBlock: {
backgroundColor: colors.bgRaised,
borderWidth: 1,
borderColor: colors.borderSubtle,
borderRadius: radii.input,
padding: spacing.sm
},
codeLanguage: {
fontSize: 10,
color: colors.textMuted,
marginBottom: spacing.xs,
textTransform: 'uppercase'
},
codeText: {
fontFamily: typography.monoFamily,
fontSize: 12,
lineHeight: 17,
color: colors.textPrimary
},
imageFrame: {
borderWidth: 1,
borderColor: colors.borderSubtle,
borderRadius: radii.input,
backgroundColor: colors.bgRaised,
overflow: 'hidden',
padding: spacing.sm
},
imageCaption: {
paddingHorizontal: spacing.sm,
paddingVertical: spacing.xs,
fontSize: 11,
color: colors.textSecondary
},
table: {
borderTopWidth: 1,
borderLeftWidth: 1,
borderColor: colors.borderSubtle,
borderRadius: radii.input,
overflow: 'hidden',
backgroundColor: colors.bgPanel
},
tableRow: {
flexDirection: 'row'
},
tableCell: {
minWidth: 112,
maxWidth: 220,
borderRightWidth: 1,
borderBottomWidth: 1,
borderColor: colors.borderSubtle,
paddingHorizontal: spacing.sm,
paddingVertical: spacing.xs,
fontSize: 12,
lineHeight: 17,
color: colors.textPrimary
},
tableHeader: {
fontWeight: '700',
backgroundColor: colors.bgRaised
},
tableTruncated: {
padding: spacing.sm,
fontSize: 12,
color: colors.textMuted
},
list: {
gap: spacing.xs
},
listItem: {
flexDirection: 'row',
alignItems: 'flex-start',
gap: spacing.sm
},
listMarker: {
width: 22,
fontSize: 13,
lineHeight: 19,
color: colors.textSecondary,
fontFamily: typography.monoFamily
},
listText: {
flex: 1,
minWidth: 0,
fontSize: 13,
lineHeight: 19,
color: colors.textPrimary
},
rule: {
height: StyleSheet.hairlineWidth,
backgroundColor: colors.borderSubtle
}
})
@@ -0,0 +1,64 @@
import { useEffect, useRef } from 'react'
import { Animated, StyleSheet, Text, View } from 'react-native'
import { colors, spacing, typography } from '../theme/mobile-theme'
/** Animated three-dot "agent is working" row, shown while the active agent is
* still producing a reply. Pure presentation — visibility is the caller's call. */
export function MobileAgentWorkingIndicator(): React.JSX.Element {
const dots = [
useRef(new Animated.Value(0.3)).current,
useRef(new Animated.Value(0.3)).current,
useRef(new Animated.Value(0.3)).current
]
useEffect(() => {
const animations = dots.map((dot, i) =>
Animated.loop(
Animated.sequence([
Animated.delay(i * 160),
Animated.timing(dot, { toValue: 1, duration: 320, useNativeDriver: true }),
Animated.timing(dot, { toValue: 0.3, duration: 320, useNativeDriver: true })
])
)
)
animations.forEach((a) => a.start())
return () => animations.forEach((a) => a.stop())
// eslint-disable-next-line react-hooks/exhaustive-deps
}, [])
return (
<View style={styles.row}>
<Text style={styles.label}>Agent is working</Text>
<View style={styles.dots}>
{dots.map((dot, i) => (
<Animated.View key={i} style={[styles.dot, { opacity: dot }]} />
))}
</View>
</View>
)
}
const styles = StyleSheet.create({
row: {
flexDirection: 'row',
alignItems: 'center',
gap: spacing.sm,
paddingHorizontal: spacing.md,
paddingVertical: spacing.sm
},
label: {
color: colors.textMuted,
fontSize: typography.metaSize,
fontStyle: 'italic'
},
dots: {
flexDirection: 'row',
gap: 4
},
dot: {
width: 5,
height: 5,
borderRadius: 2.5,
backgroundColor: colors.textSecondary
}
})
@@ -0,0 +1,79 @@
import { ChevronLeft, ChevronRight, RefreshCw } from 'lucide-react-native'
import type { MobileSessionTab } from '../../app/h/[hostId]/session/mobile-session-route-types'
import { ActionSheetModal } from '../components/ActionSheetModal'
import { getMobileSessionTabTitle } from './mobile-terminal-tab-agent'
type BrowserTab = Extract<MobileSessionTab, { type: 'browser' }>
export type MobileBrowserNavigationMethod = 'browser.back' | 'browser.forward' | 'browser.reload'
/** Keeps browser-tab navigation actions out of the session route while preserving
* the target captured at the moment each drawer action is pressed. */
export function MobileBrowserTabActionSheet(props: {
target: BrowserTab | null
onClose: () => void
onNavigate: (target: BrowserTab, method: MobileBrowserNavigationMethod) => void
onCloseTab: (target: BrowserTab) => void
}): React.JSX.Element {
const { target, onClose, onNavigate, onCloseTab } = props
return (
<ActionSheetModal
visible={target != null}
title={target ? getMobileSessionTabTitle(target) : 'Browser'}
actions={[
...(target?.canGoBack
? [
{
label: 'Back',
icon: ChevronLeft,
onPress: () => {
const current = target
onClose()
if (current) {
onNavigate(current, 'browser.back')
}
}
}
]
: []),
...(target?.canGoForward
? [
{
label: 'Forward',
icon: ChevronRight,
onPress: () => {
const current = target
onClose()
if (current) {
onNavigate(current, 'browser.forward')
}
}
}
]
: []),
{
label: 'Reload',
icon: RefreshCw,
onPress: () => {
const current = target
onClose()
if (current) {
onNavigate(current, 'browser.reload')
}
}
},
{
label: 'Close',
destructive: true,
onPress: () => {
const current = target
onClose()
if (current) {
onCloseTab(current)
}
}
}
]}
onClose={onClose}
/>
)
}
+378
View File
@@ -0,0 +1,378 @@
import { useMemo, useRef, useState } from 'react'
import { Pressable, ScrollView, StyleSheet, Text, TextInput, View } from 'react-native'
import { Check } from 'lucide-react-native'
import { colors, radii, spacing, typography } from '../theme/mobile-theme'
import type { AskAnswerSelection, AskPrompt } from './mobile-native-chat-ask'
type Props = {
prompt: AskPrompt
/** Deliver the chosen answer (per-question option indices + free text) —
* index-based so Claude's arrow-navigate selector can be driven by the
* option's stable number instead of pasted label text (STA-1860). */
onAnswer: (selections: AskAnswerSelection[]) => Promise<boolean>
onCancel?: () => Promise<boolean>
}
// Sentinel index for the free-text "Other…" row (never a real option index).
const OTHER = -1
/** Native renderer for an agent's AskUserQuestion prompt as a wizard: one
* question per step with tabs across the top, a Next button that advances (Send
* on the last step), and a Cancel that dismisses the prompt. Neutral styling
* with a subtle green accent on the active choice to match the rest of the app. */
export function MobileNativeChatAsk({ prompt, onAnswer, onCancel }: Props): React.JSX.Element {
const [index, setIndex] = useState(0)
const [selections, setSelections] = useState<number[][]>(() => prompt.questions.map(() => []))
const [otherText, setOtherText] = useState<string[]>(() => prompt.questions.map(() => ''))
const [submitting, setSubmitting] = useState(false)
const submittingRef = useRef(false)
const toggle = (qi: number, optIndex: number, multi: boolean): void => {
setSelections((prev) => {
const next = prev.map((s) => [...s])
const cur = next[qi] ?? []
if (multi) {
next[qi] = cur.includes(optIndex) ? cur.filter((i) => i !== optIndex) : [...cur, optIndex]
} else {
next[qi] = cur.includes(optIndex) ? [] : [optIndex]
}
return next
})
}
const setOther = (qi: number, value: string): void => {
setOtherText((prev) => {
const next = [...prev]
next[qi] = value
return next
})
}
const selectionFor = (qi: number): AskAnswerSelection => {
const picked = (selections[qi] ?? []).filter((i) => i !== OTHER)
const other = (selections[qi] ?? []).includes(OTHER) ? (otherText[qi] ?? '').trim() : ''
return other ? { indices: picked, other } : { indices: picked }
}
const isAnswered = (qi: number): boolean => {
const sel = selectionFor(qi)
return sel.indices.length > 0 || (sel.other ?? '').length > 0
}
const total = prompt.questions.length
const isLast = index === total - 1
const currentAnswered = useMemo(
() => isAnswered(index),
// eslint-disable-next-line react-hooks/exhaustive-deps
[selections, otherText, index]
)
const allAnswered = useMemo(
() => prompt.questions.every((_, i) => isAnswered(i)),
// eslint-disable-next-line react-hooks/exhaustive-deps
[otherText, prompt.questions, selections]
)
const canAdvance = !submitting && (isLast ? allAnswered : currentAnswered)
const submit = async (): Promise<void> => {
if (!allAnswered || submittingRef.current) {
return
}
submittingRef.current = true
setSubmitting(true)
try {
await onAnswer(prompt.questions.map((_, i) => selectionFor(i)))
} finally {
submittingRef.current = false
setSubmitting(false)
}
}
const advance = async (): Promise<void> => {
if (isLast) {
await submit()
} else {
setIndex((i) => Math.min(i + 1, total - 1))
}
}
const q = prompt.questions[index]!
const otherSelected = (selections[index] ?? []).includes(OTHER)
return (
<View style={styles.card}>
{total > 1 ? (
<ScrollView
horizontal
showsHorizontalScrollIndicator={false}
style={styles.tabs}
contentContainerStyle={styles.tabsContent}
keyboardShouldPersistTaps="always"
>
{prompt.questions.map((qq, i) => (
<Pressable
key={i}
style={[styles.tab, i === index && styles.tabActive]}
onPress={() => setIndex(i)}
>
<Text style={[styles.tabText, i === index && styles.tabTextActive]} numberOfLines={1}>
{qq.header || `Step ${i + 1}`}
</Text>
{isAnswered(i) ? (
<Check size={11} color={colors.statusGreen} strokeWidth={3} />
) : null}
</Pressable>
))}
</ScrollView>
) : null}
<ScrollView style={styles.scroll} keyboardShouldPersistTaps="always">
<Text style={styles.questionText}>{q.question}</Text>
{q.options.map((opt, optIndex) => (
<OptionRow
key={`${optIndex}:${opt.label}`}
label={opt.label}
description={opt.description}
selected={(selections[index] ?? []).includes(optIndex)}
multi={q.multiSelect}
onPress={() => toggle(index, optIndex, q.multiSelect)}
/>
))}
<OptionRow
label="Other…"
selected={otherSelected}
multi={q.multiSelect}
onPress={() => toggle(index, OTHER, q.multiSelect)}
/>
{otherSelected ? (
<TextInput
style={styles.input}
value={otherText[index]}
onChangeText={(v) => setOther(index, v)}
placeholder="Type your answer"
placeholderTextColor={colors.textMuted}
multiline
autoFocus
/>
) : null}
</ScrollView>
<View style={styles.footer}>
<Pressable
style={styles.cancel}
onPress={async () => {
if (!submittingRef.current && onCancel) {
submittingRef.current = true
setSubmitting(true)
try {
await onCancel()
} finally {
submittingRef.current = false
setSubmitting(false)
}
}
}}
disabled={submitting}
hitSlop={8}
>
<Text style={styles.cancelText}>Cancel</Text>
</Pressable>
{total > 1 ? (
<Text style={styles.progress}>
{index + 1}/{total}
</Text>
) : null}
<Pressable
style={[styles.next, !canAdvance && styles.nextDisabled]}
onPress={advance}
disabled={!canAdvance}
>
<Text style={[styles.nextText, !canAdvance && styles.nextTextDisabled]}>
{isLast ? 'Send answer' : 'Next'}
</Text>
</Pressable>
</View>
</View>
)
}
function OptionRow({
label,
description,
selected,
multi,
onPress
}: {
label: string
description?: string
selected: boolean
multi?: boolean
onPress: () => void
}): React.JSX.Element {
return (
<Pressable style={[styles.option, selected && styles.optionSelected]} onPress={onPress}>
{/* Multi-select reads as a checkbox (square); single-select as a radio (circle). */}
<View
style={[
styles.check,
multi ? styles.checkSquare : styles.checkCircle,
selected && styles.checkOn
]}
>
{selected ? <Check size={12} color={colors.bgBase} strokeWidth={3} /> : null}
</View>
<View style={styles.optionBody}>
<Text style={styles.optionLabel}>{label}</Text>
{description ? (
<Text style={styles.optionDescription} numberOfLines={3}>
{description}
</Text>
) : null}
</View>
</Pressable>
)
}
const styles = StyleSheet.create({
card: {
maxHeight: 380,
backgroundColor: colors.bgPanel,
borderTopWidth: StyleSheet.hairlineWidth,
borderTopColor: colors.borderSubtle
},
tabs: {
flexGrow: 0,
paddingTop: spacing.sm,
borderBottomWidth: StyleSheet.hairlineWidth,
borderBottomColor: colors.borderSubtle
},
tabsContent: {
paddingHorizontal: spacing.sm,
gap: spacing.xs,
alignItems: 'center'
},
tab: {
flexDirection: 'row',
alignItems: 'center',
gap: 4,
minHeight: 36,
paddingHorizontal: spacing.sm,
paddingVertical: spacing.xs,
borderBottomWidth: 2,
borderBottomColor: 'transparent'
},
tabActive: {
borderBottomColor: colors.statusGreen
},
tabText: {
color: colors.textSecondary,
fontSize: typography.metaSize,
fontWeight: '600'
},
tabTextActive: {
color: colors.textPrimary
},
scroll: {
paddingHorizontal: spacing.md
},
questionText: {
color: colors.textPrimary,
fontSize: typography.bodySize + 1,
fontWeight: '600',
marginVertical: spacing.sm
},
option: {
flexDirection: 'row',
gap: spacing.sm,
padding: spacing.sm,
borderRadius: radii.card,
backgroundColor: colors.bgRaised,
borderWidth: 1,
borderColor: colors.borderSubtle,
marginBottom: spacing.xs
},
optionSelected: {
borderColor: colors.statusGreen
},
check: {
width: 18,
height: 18,
borderWidth: 1.5,
borderColor: colors.textMuted,
alignItems: 'center',
justifyContent: 'center',
marginTop: 1
},
checkCircle: {
borderRadius: 9
},
checkSquare: {
borderRadius: 4
},
checkOn: {
backgroundColor: colors.statusGreen,
borderColor: colors.statusGreen
},
optionBody: {
flex: 1,
gap: 2
},
optionLabel: {
color: colors.textPrimary,
fontSize: typography.bodySize,
fontWeight: '600'
},
optionDescription: {
color: colors.textSecondary,
fontSize: typography.metaSize
},
input: {
backgroundColor: colors.bgRaised,
borderWidth: 1,
borderColor: colors.borderSubtle,
borderRadius: radii.card,
color: colors.textPrimary,
fontSize: typography.bodySize,
padding: spacing.sm,
minHeight: 44,
marginBottom: spacing.xs
},
footer: {
flexDirection: 'row',
alignItems: 'center',
justifyContent: 'space-between',
padding: spacing.md,
gap: spacing.sm,
borderTopWidth: StyleSheet.hairlineWidth,
borderTopColor: colors.borderSubtle
},
cancel: {
paddingVertical: spacing.sm,
paddingHorizontal: spacing.sm
},
cancelText: {
color: colors.textSecondary,
fontSize: typography.bodySize,
fontWeight: '600'
},
progress: {
color: colors.textMuted,
fontSize: typography.metaSize
},
next: {
paddingVertical: spacing.sm,
paddingHorizontal: spacing.lg,
borderRadius: radii.button,
backgroundColor: colors.textPrimary
},
nextDisabled: {
backgroundColor: colors.bgRaised
},
nextText: {
color: colors.bgBase,
fontSize: typography.bodySize,
fontWeight: '700'
},
nextTextDisabled: {
color: colors.textMuted
}
})
@@ -0,0 +1,206 @@
import { createElement } from 'react'
import { act, create, type ReactTestRenderer } from 'react-test-renderer'
import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest'
import { MobileNativeChatComposer } from './MobileNativeChatComposer'
vi.mock('react-native', async () => {
const React = await import('react')
return {
ActivityIndicator: 'ActivityIndicator',
Pressable: 'Pressable',
ScrollView: ({ children, ...props }: { children?: unknown }) =>
React.createElement('ScrollView', props, children),
StyleSheet: {
create: (styles: unknown) => styles,
hairlineWidth: 1
},
Text: 'Text',
TextInput: 'TextInput',
View: 'View'
}
})
vi.mock('lucide-react-native', () => ({
ArrowUp: 'ArrowUp',
ImagePlus: 'ImagePlus',
Mic: 'Mic',
Square: 'Square'
}))
function suppressRendererWarning(): () => void {
const original = console.error
const spy = vi.spyOn(console, 'error').mockImplementation((...args) => {
if (typeof args[0] === 'string' && args[0].includes('react-test-renderer is deprecated')) {
return
}
original(...args)
})
return () => spy.mockRestore()
}
describe('MobileNativeChatComposer', () => {
let renderer: ReactTestRenderer | null = null
beforeEach(() => {
globalThis.IS_REACT_ACT_ENVIRONMENT = true
})
afterEach(() => {
act(() => renderer?.unmount())
renderer = null
})
async function render(
onSend: (text: string) => Promise<boolean>,
onChangeText: () => void,
isAttaching = false
) {
const restore = suppressRendererWarning()
try {
await act(async () => {
renderer = create(
createElement(MobileNativeChatComposer, {
value: ' hello ',
onChangeText,
onSend,
isAttaching
})
)
})
} finally {
restore()
}
}
function sendButton(): { props: { onPress: () => Promise<void> } } {
if (!renderer) {
throw new Error('Composer was not rendered')
}
return renderer.root.find(
(node) => node.type === 'Pressable' && node.props.accessibilityLabel === 'Send message'
) as { props: { onPress: () => Promise<void> } }
}
it('reports an accepted send without owning route-scoped draft cleanup', async () => {
const onChangeText = vi.fn()
const onSend = vi.fn().mockResolvedValue(true)
await render(onSend, onChangeText)
await act(async () => sendButton().props.onPress())
expect(onSend).toHaveBeenCalledWith('hello')
expect(onChangeText).not.toHaveBeenCalled()
})
it('keeps the draft when the send is rejected', async () => {
const onChangeText = vi.fn()
const onSend = vi.fn().mockResolvedValue(false)
await render(onSend, onChangeText)
await act(async () => sendButton().props.onPress())
expect(onSend).toHaveBeenCalledWith('hello')
expect(onChangeText).not.toHaveBeenCalled()
})
it('disables send while an attachment path is still being injected', async () => {
const onSend = vi.fn().mockResolvedValue(true)
await render(onSend, vi.fn(), true)
expect(sendButton().props).toMatchObject({ disabled: true })
await act(async () => sendButton().props.onPress())
expect(onSend).not.toHaveBeenCalled()
})
it('moves the caret to the insert point after an autocomplete pick, then releases control', async () => {
const restore = suppressRendererWarning()
try {
await act(async () => {
renderer = create(
createElement(MobileNativeChatComposer, {
value: '/c',
onChangeText: vi.fn(),
onSend: vi.fn().mockResolvedValue(true)
})
)
})
} finally {
restore()
}
const input = () =>
renderer!.root.find((node) => node.type === 'TextInput') as {
props: {
selection?: { start: number; end: number }
onSelectionChange: (e: { nativeEvent: { selection: { end: number } } }) => void
}
}
// Uncontrolled selection until a suggestion is applied.
expect(input().props.selection).toBeUndefined()
// Place the caret at the end so the slash trigger is active and suggestions render.
await act(async () =>
input().props.onSelectionChange({ nativeEvent: { selection: { end: 2 } } })
)
const firstSuggestion = renderer!.root.findAll(
(node) => node.type === 'Pressable' && !node.props.accessibilityLabel
)[0] as { props: { onPress: () => void } }
await act(async () => firstSuggestion.props.onPress())
// `/clear ` is 7 chars — the caret jumps just past the inserted command + space.
expect(input().props.selection).toEqual({ start: 7, end: 7 })
// The next native selection event releases control so manual placement still works.
await act(async () =>
input().props.onSelectionChange({ nativeEvent: { selection: { end: 7 } } })
)
expect(input().props.selection).toBeUndefined()
})
it('wires the mic for hold vs toggle dictation like the terminal composer', async () => {
const onMicPress = vi.fn()
const onMicPressIn = vi.fn()
const onMicPressOut = vi.fn()
const mic = () =>
renderer!.root.find(
(node) => node.type === 'Pressable' && node.props.accessibilityLabel === 'Dictate'
) as { props: { onPress?: unknown; onPressIn?: unknown; onPressOut?: unknown } }
const restore = suppressRendererWarning()
try {
await act(async () => {
renderer = create(
createElement(MobileNativeChatComposer, {
value: '',
onChangeText: vi.fn(),
onSend: vi.fn().mockResolvedValue(true),
onMicPress,
dictationMode: 'hold',
onMicPressIn,
onMicPressOut
})
)
})
// Hold mode is walkie-talkie: press-in/out drive dictation, tap is inert.
expect(mic().props.onPress).toBeUndefined()
expect(mic().props.onPressIn).toBe(onMicPressIn)
expect(mic().props.onPressOut).toBe(onMicPressOut)
await act(async () => {
renderer!.update(
createElement(MobileNativeChatComposer, {
value: '',
onChangeText: vi.fn(),
onSend: vi.fn().mockResolvedValue(true),
onMicPress,
dictationMode: 'toggle',
onMicPressIn,
onMicPressOut
})
)
})
// Toggle mode: tap drives dictation, press-in/out inert.
expect(mic().props.onPress).toBe(onMicPress)
expect(mic().props.onPressIn).toBeUndefined()
expect(mic().props.onPressOut).toBeUndefined()
} finally {
restore()
}
})
})
@@ -0,0 +1,285 @@
import { useEffect, useMemo, useRef, useState } from 'react'
import {
ActivityIndicator,
Pressable,
ScrollView,
StyleSheet,
Text,
TextInput,
View
} from 'react-native'
import { ArrowUp, ImagePlus, Mic, Square } from 'lucide-react-native'
import { colors, radii, spacing, typography } from '../theme/mobile-theme'
import {
applyAutocomplete,
detectAutocompleteTrigger,
rankSuggestions
} from './mobile-native-chat-autocomplete'
// Common agent slash commands offered as autocomplete; sending them is just text
// to the agent's terminal, so the set is intentionally provider-agnostic.
const SLASH_COMMANDS = [
'/clear',
'/compact',
'/review',
'/model',
'/help',
'/init',
'/cost',
'/diff'
]
const NO_FILE_PATHS: string[] = []
type Props = {
/** Controlled composer text — owned by the parent so dictation can write to it. */
value: string
onChangeText: (text: string) => void
onSend: (text: string) => Promise<boolean>
onAttachImage?: () => void
isAttaching?: boolean
onMicPress?: () => void
micActive?: boolean
/** Dictation trigger style — 'hold' uses press-in/out, 'toggle' uses tap. */
dictationMode?: 'toggle' | 'hold'
onMicPressIn?: () => void
onMicPressOut?: () => void
disabled?: boolean
placeholder?: string
filePaths?: string[]
onNeedFiles?: (query: string) => void
}
export function MobileNativeChatComposer({
value,
onChangeText,
onSend,
onAttachImage,
isAttaching = false,
onMicPress,
micActive = false,
dictationMode = 'toggle',
onMicPressIn,
onMicPressOut,
disabled = false,
placeholder = 'Message, @files, /commands',
filePaths = NO_FILE_PATHS,
onNeedFiles
}: Props): React.JSX.Element {
const [cursor, setCursor] = useState(0)
// Transiently drives the native caret after a mid-text autocomplete insert,
// then released on the next selection change so manual caret placement still
// works (a permanently controlled `selection` breaks it in React Native).
const [pendingSelection, setPendingSelection] = useState<{ start: number; end: number } | null>(
null
)
const sendingRef = useRef(false)
const [sending, setSending] = useState(false)
const trimmed = value.trim()
const canSend = trimmed.length > 0 && !disabled && !sending && !isAttaching
const trigger = useMemo(() => detectAutocompleteTrigger(value, cursor), [value, cursor])
const suggestions = useMemo(() => {
if (!trigger) {
return []
}
if (trigger.kind === 'slash') {
return rankSuggestions(SLASH_COMMANDS, trigger.query)
}
return rankSuggestions(filePaths, trigger.query).map((p) => `@${p}`)
}, [trigger, filePaths])
useEffect(() => {
if (trigger?.kind === 'file') {
onNeedFiles?.(trigger.query)
}
}, [onNeedFiles, trigger?.kind, trigger?.query])
const handleChange = (next: string): void => {
onChangeText(next)
}
const pickSuggestion = (suggestion: string): void => {
if (!trigger) {
return
}
const { text: nextText, cursor: nextCursor } = applyAutocomplete(value, trigger, suggestion)
onChangeText(nextText)
setCursor(nextCursor)
setPendingSelection({ start: nextCursor, end: nextCursor })
}
const handleSend = async (): Promise<void> => {
if (!canSend || sendingRef.current) {
return
}
sendingRef.current = true
setSending(true)
try {
const accepted = await onSend(trimmed)
if (accepted) {
setCursor(0)
}
} finally {
sendingRef.current = false
setSending(false)
}
}
return (
<View>
{suggestions.length > 0 ? (
<View style={styles.suggestions}>
<ScrollView keyboardShouldPersistTaps="always" style={styles.suggestionScroll}>
{suggestions.map((s) => (
<Pressable
key={s}
style={({ pressed }) => [styles.suggestion, pressed && styles.suggestionPressed]}
onPress={() => pickSuggestion(s)}
>
<Text style={styles.suggestionText} numberOfLines={1}>
{s}
</Text>
</Pressable>
))}
</ScrollView>
</View>
) : null}
<View style={styles.bar}>
{onAttachImage ? (
<Pressable
accessibilityLabel="Attach image"
style={({ pressed }) => [styles.iconButton, pressed && styles.pressed]}
onPress={onAttachImage}
disabled={isAttaching || disabled}
>
{isAttaching ? (
<ActivityIndicator size="small" color={colors.textSecondary} />
) : (
<ImagePlus size={20} color={colors.textSecondary} strokeWidth={2} />
)}
</Pressable>
) : null}
<TextInput
style={styles.input}
value={value}
onChangeText={handleChange}
// Controlled only transiently right after an autocomplete insert.
selection={pendingSelection ?? undefined}
onSelectionChange={(e) => {
setCursor(e.nativeEvent.selection.end)
setPendingSelection(null)
}}
placeholder={placeholder}
placeholderTextColor={colors.textMuted}
selectionColor={colors.accentBlue}
multiline
editable={!disabled}
textAlignVertical="top"
/>
{onMicPress ? (
<Pressable
accessibilityLabel={micActive ? 'Stop dictation' : 'Dictate'}
style={({ pressed }) => [styles.iconButton, pressed && styles.pressed]}
// Hold mode is walkie-talkie (press-in/out); toggle mode taps.
onPress={dictationMode === 'hold' ? undefined : onMicPress}
onPressIn={dictationMode === 'hold' ? onMicPressIn : undefined}
onPressOut={dictationMode === 'hold' ? onMicPressOut : undefined}
disabled={disabled}
>
{micActive ? (
<Square
size={18}
color={colors.statusRed}
strokeWidth={2.4}
fill={colors.statusRed}
/>
) : (
<Mic size={20} color={colors.textSecondary} strokeWidth={2} />
)}
</Pressable>
) : null}
<Pressable
accessibilityLabel="Send message"
style={({ pressed }) => [
styles.sendButton,
!canSend && styles.sendButtonDisabled,
pressed && canSend && styles.pressed
]}
onPress={handleSend}
disabled={!canSend}
>
<ArrowUp size={20} color={canSend ? colors.bgBase : colors.textMuted} strokeWidth={2.6} />
</Pressable>
</View>
</View>
)
}
const styles = StyleSheet.create({
suggestions: {
borderTopWidth: StyleSheet.hairlineWidth,
borderTopColor: colors.borderSubtle,
backgroundColor: colors.bgPanel
},
suggestionScroll: {
maxHeight: 180
},
suggestion: {
paddingHorizontal: spacing.md,
paddingVertical: spacing.sm,
borderBottomWidth: StyleSheet.hairlineWidth,
borderBottomColor: colors.borderSubtle
},
suggestionPressed: {
backgroundColor: colors.bgRaised
},
suggestionText: {
color: colors.textPrimary,
fontFamily: typography.monoFamily,
fontSize: typography.metaSize
},
bar: {
flexDirection: 'row',
alignItems: 'flex-end',
gap: spacing.sm,
paddingHorizontal: spacing.md,
paddingVertical: spacing.sm,
borderTopWidth: StyleSheet.hairlineWidth,
borderTopColor: colors.borderSubtle,
backgroundColor: colors.bgPanel
},
input: {
flex: 1,
maxHeight: 140,
minHeight: 40,
color: colors.textPrimary,
fontSize: typography.bodySize + 1,
backgroundColor: colors.bgRaised,
borderRadius: radii.input,
paddingHorizontal: spacing.md,
paddingTop: spacing.sm,
paddingBottom: spacing.sm
},
iconButton: {
width: 40,
height: 40,
alignItems: 'center',
justifyContent: 'center'
},
sendButton: {
width: 40,
height: 40,
borderRadius: 20,
alignItems: 'center',
justifyContent: 'center',
// White send affordance per design — dark arrow on a light circle.
backgroundColor: colors.textPrimary
},
sendButtonDisabled: {
backgroundColor: colors.bgRaised
},
pressed: {
opacity: 0.7
}
})
@@ -0,0 +1,371 @@
import { memo, useEffect, useRef, useState } from 'react'
import { Pressable, Text, View } from 'react-native'
import * as Clipboard from 'expo-clipboard'
import { ArrowUp, ChevronDown, Copy, SquareChevronRight } from 'lucide-react-native'
import type { NativeChatBlock, NativeChatMessage } from '../../../src/shared/native-chat-types'
import { MobileMarkdown } from '../components/MobileMarkdown'
import { colors } from '../theme/mobile-theme'
import {
isImageRefBlock,
isTextBlock,
pairToolBlocks,
splitNativeChatBlocks,
type ToolPair
} from './mobile-native-chat-blocks'
import { diffFromText, diffFromToolCall, type DiffLine } from './mobile-native-chat-diff'
import { MAX_TOOL_RESULT_CHARS, styles, TEXT_SIZE } from './mobile-native-chat-message-styles'
import { nativeChatMessageText } from './mobile-native-chat-message-text'
import {
summarizeToolInput,
summarizeToolRun,
toolFilePath
} from './mobile-native-chat-tool-summary'
const MAX_VISIBLE_TOOL_PAIRS = 6
const MAX_TOOL_RUN_DIFF_ROWS = 240
function DiffView({ lines }: { lines: DiffLine[] }): React.JSX.Element {
return (
<View style={styles.diff}>
{lines.map((line, i) => (
<Text
key={i}
style={[
styles.diffLine,
line.kind === 'add' && styles.diffAdd,
line.kind === 'del' && styles.diffDel,
line.kind === 'meta' && styles.diffMeta
]}
>
{line.kind === 'add' ? '+' : line.kind === 'del' ? '-' : ' '}
{line.text}
</Text>
))}
</View>
)
}
/** A single inline tool line — `▸ ToolName preview` — that expands in place to
* show the call's diff/input or the result's body. Mirrors the reference design
* where tool calls read as flat lines in the conversation, not boxed blocks. */
function ResultBody({
output,
isError,
diff
}: {
output: string
isError?: boolean
diff: DiffLine[] | null
}): React.JSX.Element {
if (diff) {
return <DiffView lines={diff} />
}
return (
<View style={[styles.toolResult, isError && styles.toolResultError]}>
<Text style={styles.mono}>
{output.length > MAX_TOOL_RESULT_CHARS
? `${output.slice(0, MAX_TOOL_RESULT_CHARS)}…`
: output}
</Text>
</View>
)
}
/** One request: a tool call and its result rendered together as a single
* expandable line. `defaultExpanded` lets the group toggle open every line. */
function ToolLine({
pair,
defaultExpanded,
diffLineLimit,
onOpenFile
}: {
pair: ToolPair
defaultExpanded: boolean
diffLineLimit: number
onOpenFile?: (relativePath: string) => void
}): React.JSX.Element {
const [expanded, setExpanded] = useState(defaultExpanded)
const { call, result } = pair
const name = call ? call.name : 'Result'
const preview = call
? summarizeToolInput(call.input)
: (result?.output.split('\n')[0]?.slice(0, 80) ?? '')
// Why: collapsed tool rows are the common path; defer bounded diff parsing
// until the user asks to reveal the detail.
const callDiff = expanded && call ? diffFromToolCall(call.name, call.input, diffLineLimit) : null
const resultDiff = expanded && result ? diffFromText(result.output, diffLineLimit) : null
const hasDetail = callDiff !== null || result !== undefined || preview.length > 40
// A tool that targets a file (Read/Edit/Write…) renders its preview as a
// tappable link that opens the file, independent of the line's expand tap.
const filePath = call ? toolFilePath(call.input) : null
const openable = filePath !== null && onOpenFile !== undefined
return (
<View>
<Pressable
style={styles.toolLine}
onPress={() => hasDetail && setExpanded((v) => !v)}
hitSlop={6}
>
{expanded ? (
<ChevronDown size={15} color={colors.textMuted} strokeWidth={2} />
) : (
<SquareChevronRight size={15} color={colors.textMuted} strokeWidth={2} />
)}
<Text style={styles.toolName}>{name}</Text>
{preview ? (
<Text
style={[styles.toolPreview, openable && styles.toolPreviewLink]}
numberOfLines={1}
onPress={openable ? () => onOpenFile!(filePath!) : undefined}
suppressHighlighting={!openable}
>
{preview}
</Text>
) : null}
</Pressable>
{expanded ? (
<View style={styles.toolDetail}>
{callDiff ? <DiffView lines={callDiff} /> : null}
{!callDiff && call && preview ? <Text style={styles.mono}>{preview}</Text> : null}
{result ? (
<ResultBody output={result.output} isError={result.isError} diff={resultDiff} />
) : null}
</View>
) : null}
</View>
)
}
function Prose({
block,
invert,
fontScale,
onOpenFile
}: {
block: NativeChatBlock
invert?: boolean
fontScale: number
onOpenFile?: (relativePath: string) => void
}): React.JSX.Element | null {
if (isTextBlock(block)) {
// Inverted (user) bubbles use a fixed dark-on-light text rather than the
// markdown renderer's light-on-dark palette.
if (invert) {
return (
<Text style={[styles.userText, { fontSize: TEXT_SIZE * fontScale }]}>{block.text}</Text>
)
}
return (
<MobileMarkdown content={block.text} textScale={1.25 * fontScale} onOpenFile={onOpenFile} />
)
}
if (isImageRefBlock(block)) {
return (
<Text style={[styles.imageRef, { fontSize: TEXT_SIZE * fontScale }]}>
🖼 {block.alt ?? block.path ?? block.url ?? 'image'}
</Text>
)
}
return null
}
/** A run of a message's tool calls/results, collapsed to a one-line summary that
* expands to the individual inline tool lines. `defaultExpanded` lets the global
* toolbar toggle drive every run at once while still allowing per-run override. */
function ToolRun({
blocks,
defaultExpanded,
trailing,
onOpenFile
}: {
blocks: NativeChatBlock[]
defaultExpanded: boolean
trailing?: React.ReactNode
onOpenFile?: (relativePath: string) => void
}): React.JSX.Element {
const [open, setOpen] = useState(defaultExpanded)
const pairs = pairToolBlocks(blocks, MAX_VISIBLE_TOOL_PAIRS)
const diffLineLimit = Math.max(1, Math.floor(MAX_TOOL_RUN_DIFF_ROWS / (pairs.length * 2 || 1)))
let callCount = 0
for (const block of blocks) {
if (block.type === 'tool-call') {
callCount++
}
}
callCount ||= pairs.length
const summary = summarizeToolRun(blocks)
return (
<View style={styles.toolRun}>
<View style={styles.toolRunHeader}>
<Pressable style={styles.toolRunToggle} onPress={() => setOpen((v) => !v)} hitSlop={6}>
{open ? (
<ChevronDown size={15} color={colors.textMuted} strokeWidth={2} />
) : (
<SquareChevronRight size={15} color={colors.textMuted} strokeWidth={2} />
)}
<Text style={styles.toolRunCount}>{callCount}×</Text>
<Text style={styles.toolRunLabel} numberOfLines={1}>
{summary || `${callCount} tool ${callCount === 1 ? 'call' : 'calls'}`}
</Text>
</Pressable>
{trailing}
</View>
{open ? (
<View style={styles.toolRunBody}>
{pairs.map((pair, i) => (
<ToolLine
key={i}
pair={pair}
defaultExpanded={defaultExpanded}
diffLineLimit={diffLineLimit}
onOpenFile={onOpenFile}
/>
))}
{callCount > pairs.length ? (
<Text style={styles.toolPreview}>… {callCount - pairs.length} more tool calls</Text>
) : null}
</View>
) : null}
</View>
)
}
/** Subtle top-right controls for an agent message: copy its prose, or scroll so
* this message's top aligns to the top of the viewport. */
function AgentControls({
onCopy,
onScrollToTop
}: {
onCopy: () => void
onScrollToTop?: () => void
}): React.JSX.Element {
return (
<View style={styles.controls}>
<Pressable
style={({ pressed }) => [styles.controlButton, pressed && styles.controlPressed]}
onPress={onCopy}
hitSlop={8}
accessibilityLabel="Copy message"
>
<Copy size={14} color={colors.textMuted} strokeWidth={2} />
</Pressable>
{onScrollToTop ? (
<Pressable
style={({ pressed }) => [styles.controlButton, pressed && styles.controlPressed]}
onPress={onScrollToTop}
hitSlop={8}
accessibilityLabel="Scroll this message to top"
>
<ArrowUp size={14} color={colors.textMuted} strokeWidth={2} />
</Pressable>
) : null}
</View>
)
}
function MobileNativeChatMessageImpl({
message,
queued,
toolsExpanded = false,
fontScale = 1,
messageIndex,
onScrollToMessage,
onOpenFile
}: {
message: NativeChatMessage
queued?: boolean
toolsExpanded?: boolean
/** Multiplies all chat text sizes for pinch-to-zoom (1 = no change). */
fontScale?: number
/** This message's index in the list, paired with onScrollToMessage. */
messageIndex?: number
/** Ask the list to align this message's top to the top of the viewport. */
onScrollToMessage?: (index: number) => void
onOpenFile?: (relativePath: string) => void
}): React.JSX.Element {
const isUser = message.role === 'user'
const isReasoning = message.role === 'reasoning'
const isAgent = !isUser
// Briefly tint the bubble to confirm a copy landed.
const [copied, setCopied] = useState(false)
const copyTimer = useRef<ReturnType<typeof setTimeout> | null>(null)
useEffect(
() => () => {
if (copyTimer.current) {
clearTimeout(copyTimer.current)
}
},
[]
)
// Separate the agent's words from its tool activity: prose renders first, the
// tool calls fold into a collapsible run beneath. The user's own messages get
// an inverted (filled accent) bubble so they stand apart from agent prose.
const { prose, tools } = splitNativeChatBlocks(message.blocks)
const handleCopy = (): void => {
const text = nativeChatMessageText(message.blocks)
if (!text) {
return
}
void Clipboard.setStringAsync(text)
setCopied(true)
if (copyTimer.current) {
clearTimeout(copyTimer.current)
}
copyTimer.current = setTimeout(() => setCopied(false), 700)
}
// Copy + scroll-to-top, shown inline with the first tool call (or after the
// prose when there are no tools).
const controls =
isAgent && !queued ? (
<AgentControls
onCopy={handleCopy}
onScrollToTop={
onScrollToMessage && messageIndex !== undefined
? () => onScrollToMessage(messageIndex)
: undefined
}
/>
) : null
return (
<View style={[styles.row, isUser && styles.rowUser]}>
{isUser && queued ? <Text style={styles.queuedTag}>Queued</Text> : null}
<View
style={[
styles.content,
isUser && styles.userBubble,
isReasoning && styles.reasoning,
queued && styles.queued,
copied && styles.copied
]}
>
{prose.map((block, index) => (
<Prose
key={index}
block={block}
invert={isUser}
fontScale={fontScale}
onOpenFile={onOpenFile}
/>
))}
{tools.length > 0 ? (
<ToolRun
// Why: a global toggle intentionally resets all per-run/per-line
// overrides in one remount, avoiding an effect-driven second render.
key={toolsExpanded ? 'expanded' : 'collapsed'}
blocks={tools}
defaultExpanded={toolsExpanded}
trailing={controls}
onOpenFile={onOpenFile}
/>
) : controls ? (
<View style={styles.controlsRow}>{controls}</View>
) : null}
</View>
</View>
)
}
export const MobileNativeChatMessage = memo(MobileNativeChatMessageImpl)
@@ -0,0 +1,79 @@
import { StyleSheet, View } from 'react-native'
import { MobileNativeChatView, type MobileNativeChatInputLockReason } from './MobileNativeChatView'
import type { MobileNativeChatController } from './use-mobile-native-chat-controller'
type Props = {
controller: MobileNativeChatController
onAttachImage: () => void
isAttaching: boolean
onMicPress: () => void
micActive: boolean
dictationMode: 'toggle' | 'hold'
onMicPressIn: () => void
onMicPressOut: () => void
inputLockReason: MobileNativeChatInputLockReason | null
keyboardInset: number
}
/** Keeps the terminal mounted underneath chat so its PTY subscription survives
* view toggles while the native surface owns the visible composer. */
export function MobileNativeChatOverlay({
controller,
onAttachImage,
isAttaching,
onMicPress,
micActive,
dictationMode,
onMicPressIn,
onMicPressOut,
inputLockReason,
keyboardInset
}: Props): React.JSX.Element | null {
if (!controller.showNativeChat) {
return null
}
const session = controller.nativeChatSession
return (
<View style={styles.overlay}>
<MobileNativeChatView
messages={session.messages}
status={session.status}
error={session.error}
agent={controller.nativeChatAgent}
agentWorking={controller.nativeChatAgentWorking}
streamingText={controller.nativeChatStreamingText}
onStop={controller.handleNativeChatStop}
ask={controller.nativeChatAsk}
onAnswerAsk={controller.handleNativeChatAnswerAsk}
onCancelAsk={controller.handleNativeChatCancelAsk}
question={controller.nativeChatQuestion}
onAnswerQuestion={controller.handleNativeChatSend}
permission={controller.nativeChatPermission}
onRespondPermission={controller.handleNativeChatRespondPermission}
onOpenFile={controller.handleNativeChatOpenFile}
hasMore={session.hasMore}
loadingEarlier={session.loadingEarlier}
onLoadEarlier={session.loadEarlier}
onSend={controller.handleNativeChatSend}
pending={controller.chatPending}
composerText={controller.chatComposerText}
onComposerTextChange={controller.setChatComposerText}
onAttachImage={onAttachImage}
isAttaching={isAttaching}
onMicPress={onMicPress}
micActive={micActive}
dictationMode={dictationMode}
onMicPressIn={onMicPressIn}
onMicPressOut={onMicPressOut}
inputLockReason={inputLockReason}
filePaths={controller.nativeChatFilePaths}
onNeedFiles={controller.loadNativeChatFiles}
keyboardInset={keyboardInset}
/>
</View>
)
}
const styles = StyleSheet.create({
overlay: StyleSheet.absoluteFillObject
})
@@ -0,0 +1,60 @@
import { createElement } from 'react'
import { act, create, type ReactTestRenderer } from 'react-test-renderer'
import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest'
import { MobileNativeChatPermission } from './MobileNativeChatPermission'
vi.mock('react-native', () => ({
Pressable: 'Pressable',
StyleSheet: { create: (styles: unknown) => styles, hairlineWidth: 1 },
Text: 'Text',
View: 'View'
}))
vi.mock('lucide-react-native', () => ({ ShieldQuestion: 'ShieldQuestion' }))
describe('MobileNativeChatPermission', () => {
let renderer: ReactTestRenderer | null = null
beforeEach(() => {
globalThis.IS_REACT_ACT_ENVIRONMENT = true
})
afterEach(() => {
act(() => renderer?.unmount())
renderer = null
})
it('accepts only one response when two presses land in the same render batch', async () => {
let resolveResponse: (accepted: boolean) => void = () => {}
const response = new Promise<boolean>((resolve) => (resolveResponse = resolve))
const onRespond = vi.fn(() => response)
const original = console.error
const consoleSpy = vi.spyOn(console, 'error').mockImplementation((...args) => {
if (typeof args[0] === 'string' && args[0].includes('react-test-renderer is deprecated')) {
return
}
original(...args)
})
try {
await act(async () => {
renderer = create(
createElement(MobileNativeChatPermission, {
permission: { title: 'Approve?', options: [{ label: 'Allow', send: '1' }] },
onRespond
})
)
})
} finally {
consoleSpy.mockRestore()
}
const button = renderer.root.findByType('Pressable')
act(() => {
button.props.onPress()
button.props.onPress()
})
expect(onRespond).toHaveBeenCalledOnce()
await act(async () => resolveResponse(true))
})
})
@@ -0,0 +1,123 @@
import { memo, useRef, useState } from 'react'
import { Pressable, StyleSheet, Text, View } from 'react-native'
import { ShieldQuestion } from 'lucide-react-native'
import { colors, radii, spacing, typography } from '../theme/mobile-theme'
import type { MobileChatPermission } from './mobile-native-chat-permission'
// Renders a detected agent permission ask as a card with tappable options.
// The first option is treated as the primary (allow) action and gets a filled
// accent button so the affirmative choice reads as distinct from the rest.
function MobileNativeChatPermissionImpl({
permission,
onRespond
}: {
permission: MobileChatPermission
onRespond: (send: string) => Promise<boolean>
}): React.JSX.Element {
const [submitting, setSubmitting] = useState(false)
const submittingRef = useRef(false)
const respond = async (send: string): Promise<void> => {
if (submittingRef.current) {
return
}
submittingRef.current = true
setSubmitting(true)
const accepted = await onRespond(send)
if (!accepted) {
submittingRef.current = false
setSubmitting(false)
}
}
return (
<View style={styles.card}>
<View style={styles.header}>
<ShieldQuestion size={16} color={colors.accentBlue} strokeWidth={2} />
<Text style={styles.title}>{permission.title}</Text>
</View>
{permission.detail ? <Text style={styles.detail}>{permission.detail}</Text> : null}
<View style={styles.options}>
{permission.options.map((option, index) => {
const isPrimary = index === 0
return (
<Pressable
key={`${option.send}:${option.label}`}
style={({ pressed }) => [
styles.option,
isPrimary ? styles.optionPrimary : styles.optionSecondary,
pressed && !submitting && styles.optionPressed
]}
hitSlop={6}
onPress={() => respond(option.send)}
disabled={submitting}
>
<Text style={[styles.optionText, isPrimary && styles.optionTextPrimary]}>
{option.label}
</Text>
</Pressable>
)
})}
</View>
</View>
)
}
export const MobileNativeChatPermission = memo(MobileNativeChatPermissionImpl)
const styles = StyleSheet.create({
card: {
marginHorizontal: spacing.lg,
marginVertical: spacing.sm,
padding: spacing.md,
gap: spacing.sm,
borderRadius: radii.card,
borderWidth: StyleSheet.hairlineWidth,
borderColor: colors.borderSubtle,
backgroundColor: colors.bgPanel
},
header: {
flexDirection: 'row',
alignItems: 'center',
gap: spacing.sm
},
title: {
color: colors.textPrimary,
fontSize: typography.bodySize,
fontWeight: '600'
},
detail: {
color: colors.textSecondary,
fontSize: typography.metaSize,
lineHeight: typography.metaSize + 5
},
options: {
flexDirection: 'row',
flexWrap: 'wrap',
gap: spacing.sm
},
option: {
minHeight: 44,
justifyContent: 'center',
paddingHorizontal: spacing.md,
paddingVertical: spacing.sm,
borderRadius: radii.button
},
optionPrimary: {
backgroundColor: colors.accentBlue
},
optionSecondary: {
backgroundColor: colors.bgRaised,
borderWidth: StyleSheet.hairlineWidth,
borderColor: colors.borderSubtle
},
optionPressed: {
opacity: 0.7
},
optionText: {
color: colors.textPrimary,
fontSize: typography.bodySize,
fontWeight: '600'
},
optionTextPrimary: {
color: colors.onAccent
}
})
@@ -0,0 +1,268 @@
import { useMemo, useRef, useState } from 'react'
import { Pressable, StyleSheet, Text, TextInput, View } from 'react-native'
import { ArrowUp, Check, CircleHelp } from 'lucide-react-native'
import { colors, radii, spacing, typography } from '../theme/mobile-theme'
import { formatQuestionAnswer, type MobileChatQuestion } from './mobile-native-chat-question'
type Props = {
question: MobileChatQuestion
onAnswer: (text: string) => Promise<boolean>
}
/** Renders an agent's choice prompt as a tappable card. Single-select answers
* on tap; multi-select toggles then Submits; an always-present text entry lets
* the user answer freely (the escape hatch) when the heuristic misreads the
* options or none apply. */
export function MobileNativeChatQuestion({ question, onAnswer }: Props): React.JSX.Element {
const [selected, setSelected] = useState<string[]>([])
const [freeText, setFreeText] = useState('')
const [sending, setSending] = useState(false)
const sendingRef = useRef(false)
const hasOptions = question.options.length > 0
const trimmedFreeText = freeText.trim()
const toggle = (option: string): void => {
setSelected((prev) =>
prev.includes(option) ? prev.filter((o) => o !== option) : [...prev, option]
)
}
const sendAnswer = async (text: string): Promise<boolean> => {
if (sendingRef.current) {
return false
}
sendingRef.current = true
setSending(true)
try {
return await onAnswer(text)
} finally {
sendingRef.current = false
setSending(false)
}
}
const answerSingle = async (option: string): Promise<void> => {
await sendAnswer(formatQuestionAnswer(question, [option]))
}
const submitMulti = async (): Promise<void> => {
if (selected.length === 0) {
return
}
await sendAnswer(formatQuestionAnswer(question, selected))
}
const submitFreeText = async (): Promise<void> => {
if (trimmedFreeText.length === 0) {
return
}
// Free text is an unknown entry; formatQuestionAnswer passes it through.
if (await sendAnswer(formatQuestionAnswer(question, [trimmedFreeText]))) {
setFreeText('')
}
}
const canSubmitMulti = selected.length > 0 && !sending
const canSendFreeText = trimmedFreeText.length > 0 && !sending
// Stable keys for option rows even if an agent repeats a label.
const optionRows = useMemo(
() => question.options.map((label, index) => ({ label, key: `${index}:${label}` })),
[question.options]
)
return (
<View style={styles.card}>
<View style={styles.header}>
<CircleHelp size={15} color={colors.accentBlue} strokeWidth={2.2} />
<Text style={styles.question}>{question.question}</Text>
</View>
{hasOptions ? (
<View style={styles.options}>
{optionRows.map(({ label, key }) => {
const isSelected = selected.includes(label)
return (
<Pressable
key={key}
accessibilityRole={question.multiSelect ? 'checkbox' : 'button'}
accessibilityState={question.multiSelect ? { checked: isSelected } : undefined}
style={({ pressed }) => [
styles.option,
isSelected && styles.optionSelected,
pressed && styles.pressed
]}
onPress={() => (question.multiSelect ? toggle(label) : answerSingle(label))}
>
{question.multiSelect ? (
<View style={[styles.checkbox, isSelected && styles.checkboxOn]}>
{isSelected ? <Check size={13} color={colors.bgBase} strokeWidth={3} /> : null}
</View>
) : null}
<Text style={styles.optionText}>{label}</Text>
</Pressable>
)
})}
</View>
) : null}
{question.multiSelect && hasOptions ? (
<Pressable
accessibilityLabel="Submit selected options"
style={({ pressed }) => [
styles.submit,
!canSubmitMulti && styles.submitDisabled,
pressed && canSubmitMulti && styles.pressed
]}
onPress={submitMulti}
disabled={!canSubmitMulti}
>
<Text style={[styles.submitText, !canSubmitMulti && styles.submitTextDisabled]}>
Submit{selected.length > 0 ? ` (${selected.length})` : ''}
</Text>
</Pressable>
) : null}
<View style={styles.freeTextRow}>
<TextInput
style={styles.freeInput}
value={freeText}
onChangeText={setFreeText}
placeholder={hasOptions ? 'Or type a reply…' : 'Type your reply…'}
placeholderTextColor={colors.textMuted}
selectionColor={colors.accentBlue}
onSubmitEditing={submitFreeText}
returnKeyType="send"
multiline
/>
<Pressable
accessibilityLabel="Send reply"
style={({ pressed }) => [
styles.freeSend,
!canSendFreeText && styles.freeSendDisabled,
pressed && canSendFreeText && styles.pressed
]}
onPress={submitFreeText}
disabled={!canSendFreeText}
>
<ArrowUp
size={18}
color={canSendFreeText ? colors.bgBase : colors.textMuted}
strokeWidth={2.6}
/>
</Pressable>
</View>
</View>
)
}
const styles = StyleSheet.create({
card: {
marginHorizontal: spacing.lg,
marginVertical: spacing.sm,
padding: spacing.md,
gap: spacing.sm,
backgroundColor: colors.bgPanel,
borderRadius: radii.card,
borderWidth: StyleSheet.hairlineWidth,
borderColor: colors.borderSubtle
},
header: {
flexDirection: 'row',
alignItems: 'center',
gap: spacing.sm
},
question: {
flex: 1,
color: colors.textPrimary,
fontSize: typography.bodySize + 1,
fontWeight: '600',
lineHeight: typography.bodySize + 7
},
options: {
gap: spacing.xs
},
option: {
flexDirection: 'row',
alignItems: 'center',
gap: spacing.sm,
minHeight: 44,
paddingHorizontal: spacing.md,
paddingVertical: spacing.sm,
backgroundColor: colors.bgRaised,
borderRadius: radii.button,
borderWidth: StyleSheet.hairlineWidth,
borderColor: colors.borderSubtle
},
optionSelected: {
borderColor: colors.accentBlue
},
optionText: {
flex: 1,
color: colors.textPrimary,
fontSize: typography.bodySize + 1
},
checkbox: {
width: 20,
height: 20,
borderRadius: radii.button,
borderWidth: 1.5,
borderColor: colors.textMuted,
alignItems: 'center',
justifyContent: 'center'
},
checkboxOn: {
backgroundColor: colors.accentBlue,
borderColor: colors.accentBlue
},
submit: {
minHeight: 44,
alignItems: 'center',
justifyContent: 'center',
borderRadius: radii.button,
backgroundColor: colors.accentBlue
},
submitDisabled: {
backgroundColor: colors.bgRaised
},
submitText: {
color: colors.onMergeGreen,
fontSize: typography.bodySize + 1,
fontWeight: '600'
},
submitTextDisabled: {
color: colors.textMuted
},
freeTextRow: {
flexDirection: 'row',
alignItems: 'flex-end',
gap: spacing.sm
},
freeInput: {
flex: 1,
minHeight: 40,
maxHeight: 120,
color: colors.textPrimary,
fontSize: typography.bodySize + 1,
backgroundColor: colors.bgRaised,
borderRadius: radii.input,
paddingHorizontal: spacing.md,
paddingTop: spacing.sm,
paddingBottom: spacing.sm
},
freeSend: {
width: 40,
height: 40,
borderRadius: 20,
alignItems: 'center',
justifyContent: 'center',
backgroundColor: colors.textPrimary
},
freeSendDisabled: {
backgroundColor: colors.bgRaised
},
pressed: {
opacity: 0.7
}
})
+425
View File
@@ -0,0 +1,425 @@
import { useCallback, useEffect, useMemo, useRef, useState } from 'react'
import {
ActivityIndicator,
FlatList,
type NativeScrollEvent,
type NativeSyntheticEvent,
Pressable,
Text,
View
} from 'react-native'
import { useSafeAreaInsets } from 'react-native-safe-area-context'
import { GestureDetector, GestureHandlerRootView } from 'react-native-gesture-handler'
import { ArrowDown, ChevronsDownUp, ChevronsUpDown, Square } from 'lucide-react-native'
import type { NativeChatMessage } from '../../../src/shared/native-chat-types'
import { colors } from '../theme/mobile-theme'
import { styles } from './mobile-native-chat-view-styles'
import {
buildMobileNativeChatTransientData,
foldMobileNativeChatMessages,
mobileNativeChatEmptyState
} from './mobile-native-chat-render-data'
import { useMobileNativeChatAskDismiss } from './use-mobile-native-chat-ask-dismiss'
import { useMobileNativeChatPinchGesture } from './use-mobile-native-chat-pinch-gesture'
import { MobileAgentWorkingIndicator } from './MobileAgentWorkingIndicator'
import { MobileNativeChatComposer } from './MobileNativeChatComposer'
import { MobileNativeChatMessage } from './MobileNativeChatMessage'
import { MobileNativeChatAsk } from './MobileNativeChatAsk'
import type { AskAnswerSelection, AskPrompt } from './mobile-native-chat-ask'
import { MobileNativeChatPermission } from './MobileNativeChatPermission'
import type { MobileChatPermission } from './mobile-native-chat-permission'
import { MobileNativeChatQuestion } from './MobileNativeChatQuestion'
import { mobileChatQuestionKey, type MobileChatQuestion } from './mobile-native-chat-question'
import type { MobileNativeChatStatus } from './use-mobile-native-chat-session'
/** Why the composer input is locked: the transport is disconnected, or the
* terminal subscription has not acknowledged its input lease yet. */
export type MobileNativeChatInputLockReason = 'disconnected' | 'waiting'
type Props = {
messages: NativeChatMessage[]
status: MobileNativeChatStatus
error?: string
/** Resolved agent for this chat; names the empty-state copy (desktop parity). */
agent?: string | null
agentWorking?: boolean
/** Interrupt the agent mid-turn (shown as a Stop button on the working bar). */
onStop?: () => void
/** Live partial assistant text while a turn is still streaming (from the agent
* status hook). Shown as an in-progress bubble until the transcript catches up. */
streamingText?: string
hasMore?: boolean
loadingEarlier?: boolean
onLoadEarlier?: () => void
onSend: (text: string) => Promise<boolean>
/** Optimistic queued sends (owned by the route so they survive view switches). */
pending: Array<{ id: string; text: string }>
/** Controlled composer text (owned by the route so dictation can write to it). */
composerText: string
onComposerTextChange: (text: string) => void
onAttachImage?: () => void
isAttaching?: boolean
onMicPress?: () => void
micActive?: boolean
dictationMode?: 'toggle' | 'hold'
onMicPressIn?: () => void
onMicPressOut?: () => void
inputLockReason?: MobileNativeChatInputLockReason | null
filePaths?: string[]
onNeedFiles?: (query: string) => void
/** A pending agent question/permission detected from live status, shown as a
* native card above the composer; answering sends text to the agent. */
/** Structured AskUserQuestion prompt parsed from the transcript (preferred over
* the heuristic question card). */
ask?: AskPrompt | null
/** Deliver the ask answer as per-question selections; the send hook turns them
* into selector keystrokes (Claude) or pasted label text (other agents). */
onAnswerAsk?: (prompt: AskPrompt, selections: AskAnswerSelection[]) => Promise<boolean>
onCancelAsk?: () => Promise<boolean>
question?: MobileChatQuestion | null
onAnswerQuestion?: (text: string) => Promise<boolean>
permission?: MobileChatPermission | null
onRespondPermission?: (send: string) => Promise<boolean>
/** Open a worktree file tapped in agent markdown. */
onOpenFile?: (relativePath: string) => void
/** Pixels to lift the composer by when the soft keyboard is open. The route
* owns keyboard tracking (the app uses manual lift, not KeyboardAvoidingView). */
keyboardInset?: number
}
export function MobileNativeChatView({
messages,
status,
error,
agent,
agentWorking,
onStop,
streamingText,
hasMore,
loadingEarlier,
onLoadEarlier,
onSend,
pending,
composerText,
onComposerTextChange,
onAttachImage,
isAttaching,
onMicPress,
micActive,
dictationMode,
onMicPressIn,
onMicPressOut,
inputLockReason,
filePaths,
onNeedFiles,
ask,
onAnswerAsk,
onCancelAsk,
question,
onAnswerQuestion,
permission,
onRespondPermission,
onOpenFile,
keyboardInset = 0
}: Props): React.JSX.Element {
const insets = useSafeAreaInsets()
const listRef = useRef<FlatList<NativeChatMessage>>(null)
const [toolsExpanded, setToolsExpanded] = useState(false)
// Dismiss the question card as soon as it's answered; the live status lingers
// briefly (the agent emits a post-tool event with the same prompt), so hide it
// until a genuinely different question arrives.
const { askKey, showAsk, dismissAsk } = useMobileNativeChatAskDismiss(ask)
// Lift the composer clear of the keyboard, plus the bottom safe-area so it
// never sits under the home indicator / nav bar (mirrors the terminal dock).
const bottomPad = keyboardInset > 0 ? keyboardInset + insets.bottom : insets.bottom
const [atBottom, setAtBottom] = useState(true)
const sendScrollTimerRef = useRef<ReturnType<typeof setTimeout> | null>(null)
const { fontScale, pinchGesture } = useMobileNativeChatPinchGesture()
// Surface a rejected send inline above the composer — a bottom toast gets hidden
// behind the keyboard (the case that prompted this). Auto-dismisses after a beat.
const [sendFailed, setSendFailed] = useState(false)
useEffect(() => {
if (!sendFailed) {
return
}
const t = setTimeout(() => setSendFailed(false), 4000)
return () => clearTimeout(t)
}, [sendFailed])
useEffect(
() => () => {
if (sendScrollTimerRef.current) {
clearTimeout(sendScrollTimerRef.current)
}
},
[]
)
const pendingIds = useMemo(() => new Set(pending.map((p) => p.id)), [pending])
// `data` is the list source: folded transcript + synthetic streaming bubble +
// route-owned optimistic queued messages. Memoize on the same deps so the
// downstream autoscroll effects/`renderItem` keep referential stability.
const foldedMessages = useMemo(() => foldMobileNativeChatMessages(messages), [messages])
const { data } = useMemo(
() => buildMobileNativeChatTransientData({ folded: foldedMessages, streamingText, pending }),
[foldedMessages, streamingText, pending]
)
// Follow the tail as the conversation grows and keep the newest message above
// the keyboard when it opens — but only when already pinned to the bottom, so
// we don't yank the user away while they read history. (Also fires on keyboard
// close, which is harmless while atBottom.)
useEffect(() => {
if (data.length === 0 || !atBottom) {
return
}
const t = setTimeout(() => listRef.current?.scrollToEnd({ animated: true }), 60)
return () => clearTimeout(t)
}, [data.length, atBottom, keyboardInset])
const handleSend = useCallback(
async (text: string): Promise<boolean> => {
const accepted = await onSend(text)
if (!accepted) {
setSendFailed(true)
return false
}
setSendFailed(false)
// Always jump to the newest message when the user sends.
setAtBottom(true)
if (sendScrollTimerRef.current) {
clearTimeout(sendScrollTimerRef.current)
}
sendScrollTimerRef.current = setTimeout(() => {
sendScrollTimerRef.current = null
listRef.current?.scrollToEnd({ animated: true })
}, 60)
return true
},
[onSend]
)
const onScroll = useCallback(
(e: NativeSyntheticEvent<NativeScrollEvent>) => {
const { contentOffset, contentSize, layoutMeasurement } = e.nativeEvent
const distanceFromBottom = contentSize.height - (contentOffset.y + layoutMeasurement.height)
setAtBottom(distanceFromBottom < 80)
// Near the top — page in older history.
if (contentOffset.y < 60 && hasMore && !loadingEarlier) {
onLoadEarlier?.()
}
},
[hasMore, loadingEarlier, onLoadEarlier]
)
// Align a single message's top to the top of the viewport.
const onScrollToMessage = useCallback((index: number) => {
listRef.current?.scrollToIndex({ index, viewPosition: 0, animated: true })
}, [])
const renderItem = useCallback(
({ item, index }: { item: NativeChatMessage; index: number }) => (
<MobileNativeChatMessage
message={item}
queued={pendingIds.has(item.id)}
toolsExpanded={toolsExpanded}
fontScale={fontScale}
messageIndex={index}
onScrollToMessage={onScrollToMessage}
onOpenFile={onOpenFile}
/>
),
[pendingIds, toolsExpanded, fontScale, onScrollToMessage, onOpenFile]
)
const emptyState = mobileNativeChatEmptyState(status, agent ?? null, error)
const showLoading = status === 'loading' && messages.length === 0
// Composer-lock flicker guard: on a remote link, brief connState blips or lease
// hand-offs would otherwise toggle the lock placeholder on and off. Only surface
// a lock once it has held ~600ms; drop it instantly so unlocking stays snappy.
const rawLockReason = inputLockReason ?? null
const [lockHeld, setLockHeld] = useState(false)
useEffect(() => {
if (rawLockReason === null) {
setLockHeld(false)
return
}
const timer = setTimeout(() => setLockHeld(true), 600)
return () => clearTimeout(timer)
}, [rawLockReason])
const lockReason = lockHeld ? rawLockReason : null
return (
<View style={[styles.root, { paddingBottom: bottomPad }]}>
{showLoading ? (
<View style={styles.center}>
<ActivityIndicator color={colors.textSecondary} />
</View>
) : (
<GestureHandlerRootView style={styles.listWrap}>
<GestureDetector gesture={pinchGesture}>
<FlatList
ref={listRef}
data={data}
keyExtractor={(item) => item.id}
renderItem={renderItem}
contentContainerStyle={styles.listContent}
onScroll={onScroll}
scrollEventThrottle={32}
onContentSizeChange={() => {
if (data.length > 0 && atBottom) {
listRef.current?.scrollToEnd({ animated: false })
}
}}
// scrollToIndex can fail before an off-screen row is measured —
// fall back to an estimated offset, then retry once it's laid out.
onScrollToIndexFailed={(info) => {
listRef.current?.scrollToOffset({
offset: info.averageItemLength * info.index,
animated: true
})
setTimeout(() => {
listRef.current?.scrollToIndex({
index: info.index,
viewPosition: 0,
animated: true
})
}, 120)
}}
ListHeaderComponent={
hasMore ? (
<Pressable
style={styles.loadEarlier}
onPress={onLoadEarlier}
disabled={loadingEarlier}
>
{loadingEarlier ? (
<ActivityIndicator size="small" color={colors.textMuted} />
) : (
<Text style={styles.loadEarlierText}>Load earlier messages</Text>
)}
</Pressable>
) : null
}
ListEmptyComponent={
emptyState ? (
<View style={styles.center}>
<Text style={styles.emptyTitle}>{emptyState.title}</Text>
<Text style={styles.emptySubtitle}>{emptyState.subtitle}</Text>
</View>
) : null
}
/>
</GestureDetector>
{/* Jump-to-latest control. The scroll-to-top affordance now lives
per-message (the up-arrow in each agent message's controls). */}
{!atBottom ? (
<Pressable
accessibilityLabel="Scroll to latest"
style={[styles.fab, styles.fabBottom]}
onPress={() => listRef.current?.scrollToEnd({ animated: true })}
>
<ArrowDown size={18} color={colors.textPrimary} strokeWidth={2.2} />
</Pressable>
) : null}
</GestureHandlerRootView>
)}
{/* Pending agent prompt: a structured AskUserQuestion wins, then a
heuristic permission, then a heuristic question. */}
{showAsk && ask ? (
<MobileNativeChatAsk
key={askKey ?? 'ask'}
prompt={ask}
onAnswer={async (selections) => {
const accepted = (await onAnswerAsk?.(ask, selections)) ?? false
if (accepted) {
dismissAsk()
}
return accepted
}}
onCancel={async () => {
const accepted = (await onCancelAsk?.()) ?? false
if (accepted) {
dismissAsk()
}
return accepted
}}
/>
) : permission ? (
<MobileNativeChatPermission
key={JSON.stringify(permission)}
permission={permission}
onRespond={async (send) => (await onRespondPermission?.(send)) ?? false}
/>
) : question ? (
<MobileNativeChatQuestion
key={mobileChatQuestionKey(question)}
question={question}
onAnswer={async (text) => (await onAnswerQuestion?.(text)) ?? false}
/>
) : null}
{/* Chrome row above the composer: the working indicator and the global
tool-calls expand/collapse toggle on the left, Stop in the far corner. */}
<View style={styles.chromeRow}>
<View style={styles.chromeLeft}>
{agentWorking ? <MobileAgentWorkingIndicator /> : null}
<Pressable
style={({ pressed }) => [styles.chromeToggle, pressed && styles.pressed]}
onPress={() => setToolsExpanded((v) => !v)}
hitSlop={8}
>
{toolsExpanded ? (
<ChevronsDownUp size={14} color={colors.textMuted} strokeWidth={2} />
) : (
<ChevronsUpDown size={14} color={colors.textMuted} strokeWidth={2} />
)}
<Text style={styles.chromeToggleLabel}>{toolsExpanded ? 'Collapse' : 'Tools'}</Text>
</Pressable>
</View>
{agentWorking ? (
<Pressable
style={({ pressed }) => [styles.stopButton, pressed && styles.pressed]}
onPress={onStop}
hitSlop={8}
accessibilityLabel="Stop the agent"
>
<Square size={13} color={colors.statusRed} strokeWidth={2.4} fill={colors.statusRed} />
<Text style={styles.stopLabel}>Stop</Text>
</Pressable>
) : null}
</View>
{sendFailed ? (
<View style={styles.sendError}>
<Text style={styles.sendErrorText}>
{rawLockReason === 'disconnected'
? 'Message not sent — reconnecting…'
: 'Message not sent'}
</Text>
</View>
) : null}
<MobileNativeChatComposer
value={composerText}
onChangeText={onComposerTextChange}
onSend={handleSend}
onAttachImage={onAttachImage}
isAttaching={isAttaching}
onMicPress={onMicPress}
micActive={micActive}
dictationMode={dictationMode}
onMicPressIn={onMicPressIn}
onMicPressOut={onMicPressOut}
disabled={lockReason !== null}
placeholder={
lockReason === 'disconnected'
? 'Reconnecting…'
: lockReason === 'waiting'
? 'Waiting for terminal…'
: 'Message, @files, /commands'
}
filePaths={filePaths}
onNeedFiles={onNeedFiles}
/>
</View>
)
}
@@ -8,9 +8,9 @@ function ok(id: string, result: unknown): RpcSuccess {
}
function clientWithResponses(responses: RpcResponse[]): Pick<RpcClient, 'sendRequest'> & {
calls: Array<{ method: string; params: unknown }>
calls: { method: string; params: unknown }[]
} {
const calls: Array<{ method: string; params: unknown }> = []
const calls: { method: string; params: unknown }[] = []
return {
calls,
sendRequest: vi.fn(async (method: string, params?: unknown) => {
@@ -35,7 +35,7 @@ describe('attachMobileImageToTerminal', () => {
_meta: { runtimeId: 'r' }
},
ok('save', '/tmp/orca-attach.png'),
ok('send', { ok: true })
ok('send', { send: { accepted: true } })
])
const sent = await attachMobileImageToTerminal('library', {
@@ -65,7 +65,7 @@ describe('attachMobileImageToTerminal', () => {
_meta: { runtimeId: 'r' }
},
ok('save', '/tmp/x.png'),
ok('send', { ok: true })
ok('send', { send: { accepted: true } })
])
await attachMobileImageToTerminal('files', {
@@ -104,7 +104,7 @@ describe('attachMobileImageToTerminal', () => {
_meta: { runtimeId: 'r' }
},
ok('save', '/tmp/y.png'),
ok('send', { ok: true })
ok('send', { send: { accepted: true } })
])
await attachMobileImageToTerminal('library', {
@@ -119,7 +119,7 @@ describe('attachMobileImageToTerminal', () => {
expect(sendCall?.params).not.toHaveProperty('client')
})
it('waits for pending live input before sending the image payload', async () => {
it('drops the image payload when the final pre-send check observes an input lease gap', async () => {
const client = clientWithResponses([
{
id: 'start',
@@ -129,6 +129,8 @@ describe('attachMobileImageToTerminal', () => {
},
ok('save', '/tmp/pending.png')
])
// Why: upload can outlive the stream subscription whose acknowledgement
// originally enabled the composer.
const beforeTerminalSend = vi.fn(async () => false)
const sent = await attachMobileImageToTerminal('library', {
@@ -144,4 +146,27 @@ describe('attachMobileImageToTerminal', () => {
expect(beforeTerminalSend).toHaveBeenCalledWith('term-pending')
expect(client.calls.some((call) => call.method === 'terminal.send')).toBe(false)
})
it('reports failure when the terminal rejects the uploaded image path', async () => {
const client = clientWithResponses([
{
id: 'start',
ok: false,
error: { code: 'method_not_found', message: 'no' },
_meta: { runtimeId: 'r' }
},
ok('save', '/tmp/rejected.png'),
ok('send', { send: { accepted: false } })
])
const sent = await attachMobileImageToTerminal('library', {
client,
terminal: 'term-rejected',
deviceToken: null,
getConnectionId: async () => null,
pickImage: vi.fn().mockResolvedValue({ base64: 'EEEE' })
})
expect(sent).toBe(false)
})
})
@@ -4,6 +4,7 @@ import {
saveMobileClipboardImageAsTempFile
} from './mobile-clipboard-image'
import type { MobileImageSource, PickedMobileImage } from './mobile-image-source-picker'
import { isTerminalSendRpcAccepted } from '../terminal/terminal-send-rpc-response'
export type AttachMobileImageDeps = {
readonly client: Pick<RpcClient, 'sendRequest'>
@@ -50,11 +51,11 @@ export async function attachMobileImageToTerminal(
if (beforeTerminalSend && !(await beforeTerminalSend(terminal))) {
return false
}
await client.sendRequest('terminal.send', {
const response = await client.sendRequest('terminal.send', {
terminal,
text: payload,
enter: false,
...(deviceToken ? { client: { id: deviceToken, type: 'mobile' as const } } : {})
})
return true
return isTerminalSendRpcAccepted(response)
}
@@ -0,0 +1,61 @@
import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest'
import {
mobileNativeChatQuestionOffsets,
scheduleMobileClaudeAnswer,
MOBILE_NATIVE_CHAT_ADVANCE_BUFFER_MS,
MOBILE_NATIVE_CHAT_QUESTION_STEP_MS,
MOBILE_NATIVE_CHAT_SUBMIT_DELAY_MS
} from './mobile-native-chat-answer-stepping'
describe('mobileNativeChatQuestionOffsets', () => {
it('matches the desktop cadence constants', () => {
expect(MOBILE_NATIVE_CHAT_SUBMIT_DELAY_MS).toBe(500)
expect(MOBILE_NATIVE_CHAT_ADVANCE_BUFFER_MS).toBe(500)
expect(MOBILE_NATIVE_CHAT_QUESTION_STEP_MS).toBe(1000)
})
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'], // blank middle answer must still get its own body + Enter
(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)
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([])
})
})
@@ -0,0 +1,13 @@
import {
nativeChatQuestionOffsets,
scheduleNativeChatAnswer,
NATIVE_CHAT_ADVANCE_BUFFER_MS,
NATIVE_CHAT_QUESTION_STEP_MS,
NATIVE_CHAT_SUBMIT_DELAY_MS
} from '../../../src/shared/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
@@ -0,0 +1,160 @@
import { describe, expect, it } from 'vitest'
import type { NativeChatMessage } from '../../../src/shared/native-chat-types'
import {
buildAskAnswerKeys,
extractPendingAsk,
formatAskAnswer,
parseAskFromStatus
} from './mobile-native-chat-ask'
function msg(blocks: NativeChatMessage['blocks'], id = 'm'): NativeChatMessage {
return { id, role: 'assistant', blocks, timestamp: 0, source: 'transcript' }
}
const askInput = {
questions: [
{
question: 'Pick one',
header: 'Choice',
multiSelect: false,
options: [
{ label: 'A', description: 'first' },
{ label: 'B', description: 'second' }
]
}
]
}
describe('extractPendingAsk', () => {
it('returns the structured prompt from a pending AskUserQuestion call', () => {
const ask = extractPendingAsk([
msg([{ type: 'tool-call', name: 'AskUserQuestion', input: askInput }])
])
expect(ask?.questions[0]).toMatchObject({
question: 'Pick one',
header: 'Choice',
multiSelect: false
})
expect(ask?.questions[0]!.options.map((o) => o.label)).toEqual(['A', 'B'])
})
it('returns null once a tool-result follows the ask', () => {
const ask = extractPendingAsk([
msg([{ type: 'tool-call', name: 'AskUserQuestion', input: askInput }], 'a'),
msg([{ type: 'tool-result', output: 'answered' }], 'r')
])
expect(ask).toBeNull()
})
it('ignores non-ask tool calls', () => {
expect(
extractPendingAsk([msg([{ type: 'tool-call', name: 'Bash', input: { command: 'ls' } }])])
).toBeNull()
})
it('survives an unrelated earlier tool result (FIFO, not adjacency)', () => {
// A parallel Bash call precedes the ask; its result resolves the Bash call
// (oldest outstanding), so the unanswered question must remain pending.
const ask = extractPendingAsk([
msg([{ type: 'tool-call', name: 'Bash', input: { command: 'ls' } }], 'c1'),
msg([{ type: 'tool-call', name: 'AskUserQuestion', input: askInput }], 'a1'),
msg([{ type: 'tool-result', output: 'ls output' }], 'r1')
])
expect(ask?.questions[0]!.question).toBe('Pick one')
})
it("clears the ask only when the ask's own result arrives", () => {
const ask = extractPendingAsk([
msg([{ type: 'tool-call', name: 'Bash', input: { command: 'ls' } }], 'c1'),
msg([{ type: 'tool-call', name: 'AskUserQuestion', input: askInput }], 'a1'),
msg([{ type: 'tool-result', output: 'ls output' }], 'r1'),
msg([{ type: 'tool-result', output: 'answered' }], 'r2')
])
expect(ask).toBeNull()
})
it('keeps the latest ask when several appear', () => {
const ask = extractPendingAsk([
msg([{ type: 'tool-call', name: 'AskUserQuestion', input: askInput }], 'a1'),
msg([{ type: 'tool-result', output: 'x' }], 'r1'),
msg([
{
type: 'tool-call',
name: 'AskUserQuestion',
input: {
questions: [{ question: 'Second', multiSelect: false, options: [{ label: 'Z' }] }]
}
}
])
])
expect(ask?.questions[0]!.question).toBe('Second')
})
})
describe('parseAskFromStatus', () => {
it('parses the live interactivePrompt JSON into a prompt', () => {
const ask = parseAskFromStatus(JSON.stringify(askInput))
expect(ask?.questions[0]!.options.map((o) => o.label)).toEqual(['A', 'B'])
})
it('returns null for empty or malformed input', () => {
expect(parseAskFromStatus(undefined)).toBeNull()
expect(parseAskFromStatus('')).toBeNull()
expect(parseAskFromStatus('{not json')).toBeNull()
expect(parseAskFromStatus('{"foo":1}')).toBeNull()
})
})
describe('formatAskAnswer', () => {
it('joins selected labels per question', () => {
const prompt = {
questions: [
{ question: 'q1', multiSelect: true, options: [{ label: 'A' }, { label: 'B' }] },
{ question: 'q2', multiSelect: false, options: [{ label: 'C' }] }
]
}
expect(formatAskAnswer(prompt, [{ indices: [0, 1] }, { indices: [0] }])).toBe('A, B\nC')
})
it('keeps one line per question with a blank middle answer (3 questions)', () => {
const prompt = {
questions: [
{ question: 'q1', multiSelect: false, options: [{ label: 'A' }] },
{ question: 'q2', multiSelect: false, options: [{ label: 'B' }] },
{ question: 'q3', multiSelect: false, options: [{ label: 'C' }] }
]
}
const answer = formatAskAnswer(prompt, [{ indices: [0] }, { indices: [] }, { indices: [0] }])
expect(answer).toBe('A\n\nC')
expect(answer.split('\n')).toHaveLength(3)
})
})
describe('buildAskAnswerKeys', () => {
it('answers a single-select pick with its option number only', () => {
const prompt = {
questions: [
{ question: 'q', multiSelect: false, options: [{ label: 'Tabs' }, { label: 'Spaces' }] }
]
}
expect(buildAskAnswerKeys(prompt, [{ indices: [1] }])).toEqual([{ raw: '2' }])
})
it('toggles multi-select numbers then steps to Submit and confirms', () => {
const prompt = {
questions: [
{
question: 'q',
multiSelect: true,
options: [{ label: 'A' }, { label: 'B' }, { label: 'C' }]
}
]
}
expect(buildAskAnswerKeys(prompt, [{ indices: [0, 2] }])).toEqual([
{ raw: '1' },
{ raw: '3' },
{ raw: '\x1b[C' },
{ raw: '\r' }
])
})
})
@@ -0,0 +1,2 @@
// Mobile and desktop share one parser/formatter so question-tool behavior cannot drift.
export * from '../../../src/shared/native-chat-ask'
@@ -0,0 +1,59 @@
import { describe, expect, it } from 'vitest'
import {
applyAutocomplete,
detectAutocompleteTrigger,
rankSuggestions
} from './mobile-native-chat-autocomplete'
describe('detectAutocompleteTrigger', () => {
it('detects a slash command only at the start', () => {
expect(detectAutocompleteTrigger('/rev', 4)).toEqual({
kind: 'slash',
query: 'rev',
start: 0,
end: 4
})
expect(detectAutocompleteTrigger('hi /rev', 7)).toBeNull()
})
it('detects an @ mention after whitespace or at start', () => {
expect(detectAutocompleteTrigger('@src', 4)).toMatchObject({ kind: 'file', query: 'src' })
expect(detectAutocompleteTrigger('look at @comp', 13)).toMatchObject({
kind: 'file',
query: 'comp'
})
})
it('does not trigger @ mid-word (email-like)', () => {
expect(detectAutocompleteTrigger('me@host', 7)).toBeNull()
})
it('closes the token once a space is typed', () => {
expect(detectAutocompleteTrigger('@src ', 5)).toBeNull()
})
it('returns empty query right after the trigger char', () => {
expect(detectAutocompleteTrigger('@', 1)).toMatchObject({ kind: 'file', query: '' })
})
})
describe('applyAutocomplete', () => {
it('replaces the trigger span and leaves a trailing space + cursor', () => {
const trigger = detectAutocompleteTrigger('look at @comp', 13)!
const { text, cursor } = applyAutocomplete('look at @comp', trigger, '@src/App.tsx')
expect(text).toBe('look at @src/App.tsx ')
expect(cursor).toBe(text.length)
})
})
describe('rankSuggestions', () => {
it('prefers prefix matches on the basename', () => {
const out = rankSuggestions(['src/app/Main.tsx', 'src/AppBar.tsx', 'lib/zapp.ts'], 'app')
expect(out[0]).toBe('src/AppBar.tsx')
expect(out).toContain('lib/zapp.ts')
})
it('returns the head of the list for an empty query', () => {
expect(rankSuggestions(['a', 'b', 'c'], '', 2)).toEqual(['a', 'b'])
})
})
@@ -0,0 +1,93 @@
// Why: the composer offers two kinds of autocomplete — `@file` mentions and
// `/slash` commands. Detection is pure (text + cursor → active trigger) so it's
// unit-testable and the composer stays a thin view over it.
export type AutocompleteKind = 'file' | 'slash'
export type AutocompleteTrigger = {
kind: AutocompleteKind
/** The query typed after the trigger char (may be empty). */
query: string
/** Index of the trigger char in the text (inclusive). */
start: number
/** Index just past the cursor / token end (exclusive) — the replace span end. */
end: number
}
const TOKEN_CHAR = /[^\s]/
/** Detect an active autocomplete trigger at the cursor. A `@` mention triggers
* anywhere it follows whitespace/start; a `/` command only at the very start of
* the input (slash commands are line-leading). Returns null when the cursor is
* not inside such a token. */
export function detectAutocompleteTrigger(
text: string,
cursor: number
): AutocompleteTrigger | null {
const pos = Math.max(0, Math.min(cursor, text.length))
// Walk left from the cursor over non-whitespace token chars to find the trigger.
let i = pos - 1
while (i >= 0 && TOKEN_CHAR.test(text[i]!)) {
i--
}
const triggerIndex = i + 1
const triggerChar = text[triggerIndex]
if (triggerChar !== '@' && triggerChar !== '/') {
return null
}
const before = triggerIndex === 0 ? '' : text[triggerIndex - 1]!
if (triggerChar === '/' && triggerIndex !== 0) {
// Slash commands are only offered at the very start of the message.
return null
}
if (triggerChar === '@' && triggerIndex !== 0 && !/\s/.test(before)) {
return null
}
const query = text.slice(triggerIndex + 1, pos)
// A space in the query means the token already closed.
if (/\s/.test(query)) {
return null
}
return {
kind: triggerChar === '@' ? 'file' : 'slash',
query,
start: triggerIndex,
end: pos
}
}
/** Replace the trigger span with `value`, leaving a trailing space and the
* cursor after it. Returns the new text and the new cursor position. */
export function applyAutocomplete(
text: string,
trigger: AutocompleteTrigger,
value: string
): { text: string; cursor: number } {
const inserted = `${value} `
const next = text.slice(0, trigger.start) + inserted + text.slice(trigger.end)
return { text: next, cursor: trigger.start + inserted.length }
}
/** Rank suggestions for a query: case-insensitive, prefix matches first, then
* substring matches, capped. Used for both file paths and command names. */
export function rankSuggestions(candidates: readonly string[], query: string, limit = 8): string[] {
const q = query.toLowerCase()
if (q.length === 0) {
return candidates.slice(0, limit)
}
const prefix: string[] = []
const substring: string[] = []
for (const candidate of candidates) {
const lower = candidate.toLowerCase()
const base = lower.split('/').pop() ?? lower
if (lower.startsWith(q) || base.startsWith(q)) {
prefix.push(candidate)
} else if (lower.includes(q)) {
substring.push(candidate)
}
if (prefix.length >= limit) {
break
}
}
return [...prefix, ...substring].slice(0, limit)
}
@@ -0,0 +1,153 @@
import { describe, expect, it } from 'vitest'
import type { NativeChatMessage } from '../../../src/shared/native-chat-types'
import {
foldToolMessages,
pairToolBlocks,
splitNativeChatBlocks
} from './mobile-native-chat-blocks'
function msg(
role: NativeChatMessage['role'],
blocks: NativeChatMessage['blocks'],
id: string = role
): NativeChatMessage {
return { id, role, blocks, timestamp: 0, source: 'transcript' }
}
describe('splitNativeChatBlocks', () => {
it('separates prose from tool blocks', () => {
const { prose, tools } = splitNativeChatBlocks([
{ type: 'text', text: 'hi' },
{ type: 'tool-call', name: 'Bash', input: {} },
{ type: 'tool-result', output: 'ok' }
])
expect(prose.map((b) => b.type)).toEqual(['text'])
expect(tools.map((b) => b.type)).toEqual(['tool-call', 'tool-result'])
})
})
describe('pairToolBlocks', () => {
it('pairs each call with the following result', () => {
const pairs = pairToolBlocks([
{ type: 'tool-call', name: 'Bash', input: { command: 'ls' } },
{ type: 'tool-result', output: 'a\nb' },
{ type: 'tool-call', name: 'Edit', input: {} },
{ type: 'tool-result', output: 'ok' }
])
expect(pairs).toHaveLength(2)
expect(pairs[0]!.call?.name).toBe('Bash')
expect(pairs[0]!.result?.output).toBe('a\nb')
expect(pairs[1]!.call?.name).toBe('Edit')
})
it('keeps an orphan result on its own', () => {
const pairs = pairToolBlocks([{ type: 'tool-result', output: 'x' }])
expect(pairs).toEqual([{ result: { type: 'tool-result', output: 'x' } }])
})
it('does not attach a second result to an already-filled pair', () => {
const pairs = pairToolBlocks([
{ type: 'tool-call', name: 'Bash', input: {} },
{ type: 'tool-result', output: '1' },
{ type: 'tool-result', output: '2' }
])
expect(pairs).toHaveLength(2)
expect(pairs[1]!.call).toBeUndefined()
expect(pairs[1]!.result?.output).toBe('2')
})
it('pairs batched parallel calls by ordinal, not adjacency', () => {
const call1 = { type: 'tool-call' as const, name: 'Bash', input: { command: 'a' } }
const call2 = { type: 'tool-call' as const, name: 'Bash', input: { command: 'b' } }
const result1 = { type: 'tool-result' as const, output: 'out-a' }
const result2 = { type: 'tool-result' as const, output: 'out-b' }
// Parallel calls stream as [call, call, result, result]; the Nth result
// answers the Nth call, so result1 must land on call1 (not the nearer call2).
const pairs = pairToolBlocks([call1, call2, result1, result2])
expect(pairs).toHaveLength(2)
expect(pairs[0]).toEqual({ call: call1, result: result1 })
expect(pairs[1]).toEqual({ call: call2, result: result2 })
})
it('does not graft a dropped over-limit call result onto a kept call', () => {
const call1 = { type: 'tool-call' as const, name: 'Bash', input: { command: 'a' } }
const call2 = { type: 'tool-call' as const, name: 'Bash', input: { command: 'b' } }
const call3 = { type: 'tool-call' as const, name: 'Bash', input: { command: 'c' } }
const result1 = { type: 'tool-result' as const, output: 'out-a' }
const result2 = { type: 'tool-result' as const, output: 'out-b' }
const result3 = { type: 'tool-result' as const, output: 'out-c' }
const pairs = pairToolBlocks([call1, call2, call3, result1, result2, result3], 2)
expect(pairs).toHaveLength(2)
expect(pairs[0]).toEqual({ call: call1, result: result1 })
// call3 was dropped past the limit, so result3 must not misgraft onto call2.
expect(pairs[1]).toEqual({ call: call2, result: result2 })
})
it('retains the last result while bounding rendered pairs', () => {
const blocks = Array.from({ length: 20 }, (_unused, index) => [
{ type: 'tool-call' as const, name: 'Bash', input: { index } },
{ type: 'tool-result' as const, output: String(index) }
]).flat()
const pairs = pairToolBlocks(blocks, 6)
expect(pairs).toHaveLength(6)
expect(pairs[5].result?.output).toBe('5')
})
})
describe('foldToolMessages', () => {
it('folds tool-call-only assistant messages and result messages into the prose turn', () => {
const folded = foldToolMessages([
msg('assistant', [{ type: 'text', text: 'doing' }], 'a0'),
msg('assistant', [{ type: 'tool-call', name: 'Bash', input: {} }], 'a1'),
msg('tool', [{ type: 'tool-result', output: 'done' }], 't1'),
msg('assistant', [{ type: 'tool-call', name: 'Edit', input: {} }], 'a2'),
msg('tool', [{ type: 'tool-result', output: 'ok' }], 't2')
])
expect(folded).toHaveLength(1)
expect(folded[0]!.blocks.map((b) => b.type)).toEqual([
'text',
'tool-call',
'tool-result',
'tool-call',
'tool-result'
])
})
it('starts a new turn at the next prose assistant message', () => {
const folded = foldToolMessages([
msg('assistant', [{ type: 'text', text: 't1' }], 'a0'),
msg('assistant', [{ type: 'tool-call', name: 'Bash', input: {} }], 'a1'),
msg('assistant', [{ type: 'text', text: 't2' }], 'a2')
])
expect(folded.map((m) => m.id)).toEqual(['a0', 'a2'])
})
it('keeps a tool message standalone when no assistant precedes it', () => {
const folded = foldToolMessages([msg('tool', [{ type: 'tool-result', output: 'x' }])])
expect(folded).toHaveLength(1)
expect(folded[0]!.role).toBe('tool')
})
it('does not merge across a user message', () => {
const folded = foldToolMessages([
msg('assistant', [{ type: 'tool-call', name: 'Bash', input: {} }], 'a'),
msg('user', [{ type: 'text', text: 'q' }], 'u'),
msg('tool', [{ type: 'tool-result', output: 'r' }], 't')
])
expect(folded.map((m) => m.role)).toEqual(['assistant', 'user', 'tool'])
})
it('folds a long tool run without mutating the source assistant', () => {
const assistant = msg('assistant', [{ type: 'text', text: 'working' }], 'a')
const tools = Array.from({ length: 1_000 }, (_unused, index) =>
msg('tool', [{ type: 'tool-result', output: String(index) }], `t-${index}`)
)
const folded = foldToolMessages([assistant, ...tools])
expect(folded[0].blocks).toHaveLength(1_001)
expect(assistant.blocks).toHaveLength(1)
})
})
@@ -0,0 +1,11 @@
export {
isImageRefBlock,
isTextBlock,
NATIVE_CHAT_SOURCE_PRIORITY
} from '../../../src/shared/native-chat-types'
export {
foldToolMessages,
pairToolBlocks,
splitNativeChatBlocks,
type NativeChatToolPair as ToolPair
} from '../../../src/shared/native-chat-tool-fold'
@@ -0,0 +1,58 @@
import { describe, expect, it } from 'vitest'
import { diffFromText, diffFromToolCall } from './mobile-native-chat-diff'
describe('diffFromToolCall', () => {
it('builds del/add lines from an Edit tool call', () => {
const lines = diffFromToolCall('Edit', {
file_path: 'src/a.ts',
old_string: 'const a = 1\nconst b = 2',
new_string: 'const a = 10'
})
expect(lines).toEqual([
{ kind: 'meta', text: 'src/a.ts' },
{ kind: 'del', text: 'const a = 1' },
{ kind: 'del', text: 'const b = 2' },
{ kind: 'add', text: 'const a = 10' }
])
})
it('returns null for non-edit tools', () => {
expect(diffFromToolCall('Bash', { command: 'ls' })).toBeNull()
})
})
describe('diffFromText', () => {
it('parses unified diff lines with colours', () => {
const lines = diffFromText('@@ -1,2 +1,2 @@\n context\n-old\n+new')
expect(lines).toEqual([
{ kind: 'meta', text: '@@ -1,2 +1,2 @@' },
{ kind: 'context', text: ' context' },
{ kind: 'del', text: 'old' },
{ kind: 'add', text: 'new' }
])
})
it('does not treat ordinary prose as a diff', () => {
expect(diffFromText('Here is a list:\n- one bullet\nthat is all')).toBeNull()
})
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)
})
})
@@ -0,0 +1,6 @@
export {
diffFromText,
diffFromToolCall,
type NativeChatDiffLine as DiffLine,
type NativeChatDiffLineKind as DiffLineKind
} from '../../../src/shared/native-chat-diff'
@@ -0,0 +1,112 @@
import { describe, expect, it } from 'vitest'
import type { AgentStatusEntry } from '../../../src/shared/agent-status-types'
import {
canShowMobileNativeChat,
isMobileNativeChatTranscriptReadable,
resolveMobileNativeChat
} from './mobile-native-chat-eligibility'
function status(overrides: Partial<AgentStatusEntry> = {}): AgentStatusEntry {
return {
state: 'working',
prompt: '',
updatedAt: 0,
stateStartedAt: 0,
paneKey: 'tab:leaf',
...overrides
} as AgentStatusEntry
}
describe('resolveMobileNativeChat', () => {
it('prefers the authoritative supported live agent over a stale launch hint', () => {
expect(
resolveMobileNativeChat({
type: 'terminal',
launchAgent: 'claude',
agentStatus: {
agentType: 'codex',
providerSession: { id: 'codex-session', transcriptPath: '/tmp/codex.jsonl' }
}
} as never)
).toMatchObject({ agent: 'codex', sessionId: 'codex-session' })
})
it('rejects an unsupported live agent instead of combining it with a stale hint', () => {
expect(
resolveMobileNativeChat({
type: 'terminal',
launchAgent: 'claude',
agentStatus: {
agentType: 'gemini',
providerSession: { id: 'gemini-session', transcriptPath: '/tmp/gemini.jsonl' }
}
} as never)
).toBeNull()
})
it('resolves agent + sessionId from launchAgent and provider session', () => {
expect(
resolveMobileNativeChat({
type: 'terminal',
launchAgent: 'claude',
agentStatus: status({
providerSession: {
key: 'session_id',
id: 'sess-1',
transcriptPath: '/tmp/claude-real-transcript.jsonl'
}
})
})
).toEqual({
agent: 'claude',
sessionId: 'sess-1',
transcriptPath: '/tmp/claude-real-transcript.jsonl'
})
})
it('falls back to agentStatus.agentType when no launchAgent', () => {
expect(
resolveMobileNativeChat({
type: 'terminal',
agentStatus: status({ agentType: 'codex' })
})
).toEqual({ agent: 'codex', sessionId: null, transcriptPath: null })
})
it('admits OpenClaude with its distinct agent identity', () => {
expect(resolveMobileNativeChat({ type: 'terminal', launchAgent: 'openclaude' })).toEqual({
agent: 'openclaude',
sessionId: null,
transcriptPath: null
})
})
it('returns null for unsupported agents', () => {
expect(resolveMobileNativeChat({ type: 'terminal', launchAgent: 'gemini' })).toBeNull()
})
it('admits Grok only when its transcript is readable by the serving host', () => {
const tab = { type: 'terminal', launchAgent: 'grok' }
expect(resolveMobileNativeChat(tab, isMobileNativeChatTranscriptReadable(null))).toMatchObject({
agent: 'grok'
})
expect(
resolveMobileNativeChat(tab, isMobileNativeChatTranscriptReadable('runtime-ssh-environment'))
).toMatchObject({ agent: 'grok' })
expect(
resolveMobileNativeChat(tab, isMobileNativeChatTranscriptReadable('model-a-ssh'))
).toBeNull()
})
it('returns null for a plain shell (no agent)', () => {
expect(resolveMobileNativeChat({ type: 'terminal' })).toBeNull()
})
it('returns null for non-terminal tabs', () => {
expect(resolveMobileNativeChat({ type: 'browser', launchAgent: 'claude' })).toBeNull()
})
it('canShowMobileNativeChat mirrors resolution', () => {
expect(canShowMobileNativeChat({ type: 'terminal', launchAgent: 'claude' })).toBe(true)
expect(canShowMobileNativeChat(null)).toBe(false)
})
})
@@ -0,0 +1,66 @@
import type { AgentStatusEntry } from '../../../src/shared/agent-status-types'
import { isRuntimeOwnedSshTargetId } from '../../../src/shared/execution-host'
import { isNativeChatSupportedAgent } from '../../../src/shared/native-chat-agent-support'
// Why: native chat renders an agent's own JSONL transcript, and the host
// resolver knows these transcript layouts. Grok is additionally gated on host
// readability because Model-A SSH stores its transcript on the remote target.
export function isMobileNativeChatTranscriptReadable(
connectionId: string | null | undefined
): boolean {
return connectionId === null || isRuntimeOwnedSshTargetId(connectionId)
}
export type MobileNativeChatResolution = {
agent: string
/** The agent's own session id, or null before it has reported one (the view
* then shows a waiting state instead of trying to read an unaddressable file). */
sessionId: string | null
/** Hook-reported transcript path. Recent Claude sessions cannot always be
* resolved from the provider session id, so mobile forwards this to runtime. */
transcriptPath: string | null
}
export type MobileNativeChatTab = {
type: string
launchAgent?: string | null
agentStatus?: AgentStatusEntry | null
}
/** Resolve a session tab to the transcript identity native chat needs, or
* null when the tab can't show native chat (not a terminal, no agent, or an
* agent whose transcript the host can't read). Agent comes from the launch
* hint or the live status; session id from the captured provider session. */
export function resolveMobileNativeChat(
tab: MobileNativeChatTab | null,
nativeChatTranscriptIsLocalReadable = false
): MobileNativeChatResolution | null {
if (!tab || tab.type !== 'terminal') {
return null
}
const liveAgent = tab.agentStatus?.agentType ?? null
const agent = liveAgent
? isNativeChatSupportedAgent(liveAgent)
? liveAgent
: null
: tab.launchAgent
if (!agent || !isNativeChatSupportedAgent(agent)) {
return null
}
if (agent === 'grok' && !nativeChatTranscriptIsLocalReadable) {
return null
}
return {
agent,
sessionId: tab.agentStatus?.providerSession?.id ?? null,
transcriptPath: tab.agentStatus?.providerSession?.transcriptPath ?? null
}
}
/** Whether the tab can toggle into native chat — gates the long-press item. */
export function canShowMobileNativeChat(
tab: MobileNativeChatTab | null,
nativeChatTranscriptIsLocalReadable = false
): boolean {
return resolveMobileNativeChat(tab, nativeChatTranscriptIsLocalReadable) !== null
}
@@ -0,0 +1,49 @@
import { describe, expect, it } from 'vitest'
import type { NativeChatMessage } from '../../../src/shared/native-chat-types'
import { mergeNativeChatMessages } from './mobile-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
}
}
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'])
})
})
@@ -0,0 +1,9 @@
// Keep the established mobile import path while executing the shared desktop/mobile merger.
export {
applyAppend,
boundNativeChatWindow,
createNativeChatMerger,
mergeNativeChatMessages,
replaceList,
type NativeChatMerger
} from '../../../src/shared/native-chat-merge'
@@ -0,0 +1,77 @@
import { describe, expect, it } from 'vitest'
import type { NativeChatMessage } from '../../../src/shared/native-chat-types'
import {
applyAppend,
boundNativeChatWindow,
createNativeChatMerger,
mergeNativeChatMessages,
replaceList
} from './mobile-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
}
}
// ORACLE: the stateful merger (cached index, applyAppend) must deep-equal the
// pure mergeNativeChatMessages for every prefix of an adversarial subscribe
// sequence — the cache must never diverge from a full rebuild (#18).
describe('mobile stateful merger — oracle vs pure merge', () => {
const base = [msg('a'), msg('b', { source: 'hook' })]
const batches: NativeChatMessage[][] = [
[msg('c')], // pure tail
[], // empty frame
[msg('b', { source: 'transcript', blocks: [{ type: 'text', text: 'final' }] })], // supersede
[msg('b', { source: 'scrape' })], // lower priority — ignored
[msg('a')], // re-emit, no change
[msg('d'), msg('e')] // multi tail
]
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 the window to the most-recent tail', () => {
const long = Array.from({ length: 10 }, (_, i) => msg(`m${i}`))
expect(boundNativeChatWindow(long, 3).map((m) => m.id)).toEqual(['m7', 'm8', 'm9'])
expect(boundNativeChatWindow(long, 0)).toBe(long)
expect(boundNativeChatWindow(long, 20)).toBe(long)
})
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' }] })
})
})
@@ -0,0 +1,168 @@
import { StyleSheet } from 'react-native'
import { colors, radii, spacing, typography } from '../theme/mobile-theme'
export const TEXT_SIZE = 17
export const MONO_SIZE = 12
export const MAX_TOOL_RESULT_CHARS = 4000
export const styles = StyleSheet.create({
row: {
paddingHorizontal: spacing.lg,
paddingVertical: spacing.sm
},
rowUser: {
alignItems: 'flex-end'
},
content: {
maxWidth: '100%',
gap: spacing.sm
},
userBubble: {
maxWidth: '88%',
backgroundColor: colors.textPrimary,
borderRadius: radii.card,
paddingHorizontal: spacing.md,
paddingVertical: spacing.sm
},
userText: {
color: colors.bgBase,
fontSize: TEXT_SIZE,
lineHeight: TEXT_SIZE + 6,
fontWeight: '500'
},
controls: {
flexDirection: 'row',
justifyContent: 'flex-end',
gap: spacing.xs,
marginBottom: 2,
opacity: 0.7
},
controlButton: {
padding: 3
},
controlPressed: {
opacity: 0.5
},
copied: {
backgroundColor: colors.diffAddedBg,
borderRadius: radii.card
},
reasoning: {
opacity: 0.7
},
queued: {
opacity: 0.55
},
queuedTag: {
color: colors.textMuted,
fontSize: 11,
fontWeight: '600',
marginBottom: 2
},
toolRun: {
marginTop: spacing.xs
},
toolRunHeader: {
flexDirection: 'row',
alignItems: 'center',
gap: spacing.sm
},
toolRunToggle: {
flex: 1,
flexDirection: 'row',
alignItems: 'center',
gap: spacing.sm,
paddingVertical: 3
},
controlsRow: {
flexDirection: 'row',
justifyContent: 'flex-end'
},
toolRunCount: {
color: colors.statusGreen,
fontFamily: typography.monoFamily,
fontSize: MONO_SIZE,
fontWeight: '700'
},
toolRunLabel: {
flex: 1,
color: colors.textMuted,
fontFamily: typography.monoFamily,
fontSize: MONO_SIZE
},
toolRunBody: {
paddingLeft: spacing.sm,
borderLeftWidth: 2,
borderLeftColor: colors.borderSubtle,
marginTop: spacing.xs
},
toolLine: {
flexDirection: 'row',
alignItems: 'center',
gap: spacing.sm,
paddingVertical: 3
},
toolName: {
color: colors.textPrimary,
fontFamily: typography.monoFamily,
fontSize: MONO_SIZE + 1,
fontWeight: '600'
},
toolPreview: {
flex: 1,
color: colors.textMuted,
fontFamily: typography.monoFamily,
fontSize: MONO_SIZE
},
toolPreviewLink: {
color: colors.accentBlue,
textDecorationLine: 'underline'
},
toolDetail: {
paddingLeft: spacing.lg,
paddingBottom: spacing.xs,
gap: spacing.xs
},
mono: {
color: colors.textSecondary,
fontFamily: typography.monoFamily,
fontSize: MONO_SIZE,
lineHeight: MONO_SIZE + 5
},
toolResult: {
borderRadius: radii.button,
backgroundColor: colors.bgPanel,
padding: spacing.md
},
toolResultError: {
backgroundColor: colors.diffDeletedBg
},
imageRef: {
color: colors.textSecondary,
fontSize: TEXT_SIZE
},
diff: {
borderRadius: radii.button,
backgroundColor: colors.bgPanel,
paddingVertical: spacing.xs,
overflow: 'hidden'
},
diffLine: {
color: colors.textSecondary,
fontFamily: typography.monoFamily,
fontSize: MONO_SIZE,
lineHeight: MONO_SIZE + 5,
paddingHorizontal: spacing.sm
},
diffAdd: {
color: colors.gitDecorationAdded,
backgroundColor: colors.diffAddedBg
},
diffDel: {
color: colors.gitDecorationDeleted,
backgroundColor: colors.diffDeletedBg
},
diffMeta: {
color: colors.textMuted
}
})
@@ -0,0 +1,46 @@
import { describe, expect, it } from 'vitest'
import type { NativeChatBlock } from '../../../src/shared/native-chat-types'
import {
clampFontScale,
FONT_SCALE_MAX,
FONT_SCALE_MIN,
nativeChatMessageText
} from './mobile-native-chat-message-text'
describe('nativeChatMessageText', () => {
it('joins text blocks and skips non-text blocks', () => {
const blocks: NativeChatBlock[] = [
{ type: 'text', text: 'Hello' },
{ type: 'tool-call', name: 'Read', input: {} },
{ type: 'text', text: 'World' }
]
expect(nativeChatMessageText(blocks)).toBe('Hello\n\nWorld')
})
it('returns an empty string when there is no prose', () => {
const blocks: NativeChatBlock[] = [{ type: 'tool-call', name: 'Read', input: {} }]
expect(nativeChatMessageText(blocks)).toBe('')
})
it('trims surrounding whitespace', () => {
expect(nativeChatMessageText([{ type: 'text', text: ' hi ' }])).toBe('hi')
})
})
describe('clampFontScale', () => {
it('clamps below the minimum', () => {
expect(clampFontScale(0.1)).toBe(FONT_SCALE_MIN)
})
it('clamps above the maximum', () => {
expect(clampFontScale(5)).toBe(FONT_SCALE_MAX)
})
it('passes through an in-range value', () => {
expect(clampFontScale(1.2)).toBe(1.2)
})
it('falls back to 1 for NaN', () => {
expect(clampFontScale(Number.NaN)).toBe(1)
})
})
@@ -0,0 +1,24 @@
import type { NativeChatBlock } from '../../../src/shared/native-chat-types'
import { isTextBlock } from './mobile-native-chat-blocks'
/** Concatenate a message's text blocks into a single copyable string. Tool
* calls/results and image refs are skipped — Copy is for the agent's prose. */
export function nativeChatMessageText(blocks: readonly NativeChatBlock[]): string {
return blocks
.filter(isTextBlock)
.map((b) => b.text)
.join('\n\n')
.trim()
}
/** Pinch-to-zoom font bounds. Default 1 means no visible change until pinched. */
export const FONT_SCALE_MIN = 0.8
export const FONT_SCALE_MAX = 1.8
/** Clamp a proposed font scale into the supported range. */
export function clampFontScale(scale: number): number {
if (Number.isNaN(scale)) {
return 1
}
return Math.min(FONT_SCALE_MAX, Math.max(FONT_SCALE_MIN, scale))
}
@@ -0,0 +1,61 @@
import { describe, expect, it } from 'vitest'
import type { NativeChatMessage } from '../../../src/shared/native-chat-types'
import { isNoiseMessage, stripNoiseMessages } from './mobile-native-chat-noise'
function msg(
role: NativeChatMessage['role'],
text: string,
blocks?: NativeChatMessage['blocks']
): NativeChatMessage {
return {
id: text.slice(0, 8),
role,
blocks: blocks ?? [{ type: 'text', text }],
timestamp: 0,
source: 'transcript'
}
}
describe('isNoiseMessage', () => {
it('flags task-notification and system-reminder user turns', () => {
expect(isNoiseMessage(msg('user', '<task-notification>\n<task-id>x</task-id>'))).toBe(true)
expect(isNoiseMessage(msg('user', '<system-reminder>\nbe careful'))).toBe(true)
expect(
isNoiseMessage(
msg(
'user',
'Caveat: The messages below were generated by the user while running local commands'
)
)
).toBe(true)
expect(isNoiseMessage(msg('user', '[Request interrupted by user]'))).toBe(true)
})
it('flags inter-agent harness turns (teammate/agent messages)', () => {
expect(isNoiseMessage(msg('user', '<teammate-message>ping</teammate-message>'))).toBe(true)
expect(isNoiseMessage(msg('user', '<agent-message>status update'))).toBe(true)
})
it('keeps real user messages', () => {
expect(isNoiseMessage(msg('user', 'make it work for codex'))).toBe(false)
// A genuine custom tag is not a known harness tag, so it stays a user turn.
expect(isNoiseMessage(msg('user', '<bash-brothers> is my band'))).toBe(false)
})
it('keeps assistant and tool turns', () => {
expect(isNoiseMessage(msg('assistant', '<system-reminder> in prose'))).toBe(false)
})
it('keeps a user turn that carries tool results', () => {
expect(isNoiseMessage(msg('user', '', [{ type: 'tool-result', output: 'ok' }]))).toBe(false)
})
it('stripNoiseMessages removes only the noise', () => {
const out = stripNoiseMessages([
msg('user', 'hello'),
msg('user', '<task-notification>done'),
msg('assistant', 'hi')
])
expect(out.map((m) => m.role)).toEqual(['user', 'assistant'])
})
})
@@ -0,0 +1 @@
export { isNoiseMessage, stripNoiseMessages } from '../../../src/shared/native-chat-noise'
@@ -0,0 +1,93 @@
import { describe, expect, it, vi } from 'vitest'
import type { RpcClient } from '../transport/rpc-client'
import {
openMobileNativeChatFile,
resolveMobileNativeChatWorktreePath
} from './mobile-native-chat-open-file'
describe('resolveMobileNativeChatWorktreePath', () => {
it('resolves an absolute tool path to a worktree-relative open target', async () => {
const sendRequest = vi.fn().mockResolvedValue({
ok: true,
result: {
exists: true,
isDirectory: false,
openTarget: { kind: 'worktree-file', relativePath: 'src/app.ts' }
}
})
await expect(
resolveMobileNativeChatWorktreePath({
client: { sendRequest } as unknown as RpcClient,
worktreeId: 'worktree',
pathText: '/repo/src/app.ts',
terminal: 'terminal'
})
).resolves.toBe('src/app.ts')
expect(sendRequest).toHaveBeenCalledWith('files.resolveTerminalPath', {
worktree: 'id:worktree',
pathText: '/repo/src/app.ts',
terminal: 'terminal'
})
})
it('opens only the resolved worktree-relative target', async () => {
const sendRequest = vi
.fn()
.mockResolvedValueOnce({
ok: true,
result: {
exists: true,
isDirectory: false,
openTarget: { kind: 'worktree-file', relativePath: 'src/app.ts' }
}
})
.mockResolvedValueOnce({ ok: true, result: {} })
await openMobileNativeChatFile({
client: { sendRequest } as unknown as RpcClient,
worktreeId: 'worktree',
pathText: '../repo/src/app.ts',
terminal: 'terminal'
})
expect(sendRequest).toHaveBeenLastCalledWith('files.open', {
worktree: 'id:worktree',
relativePath: 'src/app.ts'
})
})
it('resolves null when the resolve request rejects', async () => {
const sendRequest = vi.fn().mockRejectedValue(new Error('Request timed out'))
await expect(
resolveMobileNativeChatWorktreePath({
client: { sendRequest } as unknown as RpcClient,
worktreeId: 'worktree',
pathText: 'src/app.ts',
terminal: null
})
).resolves.toBeNull()
})
it('does not reject when the open request fails', async () => {
const sendRequest = vi
.fn()
.mockResolvedValueOnce({
ok: true,
result: {
exists: true,
isDirectory: false,
openTarget: { kind: 'worktree-file', relativePath: 'src/app.ts' }
}
})
.mockRejectedValueOnce(new Error('connection interrupted'))
await expect(
openMobileNativeChatFile({
client: { sendRequest } as unknown as RpcClient,
worktreeId: 'worktree',
pathText: 'src/app.ts',
terminal: null
})
).resolves.toBeUndefined()
})
})
@@ -0,0 +1,51 @@
import type { RuntimeTerminalPathResolution } from '../../../src/shared/runtime-types'
import type { RpcClient } from '../transport/rpc-client'
export async function resolveMobileNativeChatWorktreePath(args: {
client: RpcClient
worktreeId: string
pathText: string
terminal: string | null
}): Promise<string | null> {
try {
const response = await args.client.sendRequest('files.resolveTerminalPath', {
worktree: `id:${args.worktreeId}`,
pathText: args.pathText,
...(args.terminal ? { terminal: args.terminal } : {})
})
if (!response.ok) {
return null
}
const resolved = response.result as RuntimeTerminalPathResolution
if (!resolved.exists || resolved.isDirectory) {
return null
}
return resolved.openTarget?.kind === 'worktree-file'
? resolved.openTarget.relativePath
: (resolved.relativePath ?? null)
} catch {
// Callers fire-and-forget file opens; a disconnect/timeout must not become
// an unhandled rejection.
return null
}
}
export async function openMobileNativeChatFile(args: {
client: RpcClient
worktreeId: string
pathText: string
terminal: string | null
}): Promise<void> {
const relativePath = await resolveMobileNativeChatWorktreePath(args)
if (relativePath) {
try {
await args.client.sendRequest('files.open', {
worktree: `id:${args.worktreeId}`,
relativePath
})
} catch {
// Best-effort open; failures surface as a no-op rather than an
// unhandled rejection.
}
}
}
@@ -0,0 +1,27 @@
import { describe, expect, it, vi } from 'vitest'
import type { RpcClient } from '../transport/rpc-client'
import { sendMobileNativeChatPermissionResponse } from './mobile-native-chat-permission-send'
describe('sendMobileNativeChatPermissionResponse', () => {
it('writes an approval as raw bytes without appending Return', async () => {
const sendRequest = vi.fn().mockResolvedValue({
ok: true,
result: { send: { handle: 'terminal', accepted: true, bytesWritten: 1 } }
})
await expect(
sendMobileNativeChatPermissionResponse({
client: { sendRequest } as unknown as RpcClient,
terminal: 'terminal',
deviceToken: 'phone',
text: '1'
})
).resolves.toBe(true)
expect(sendRequest).toHaveBeenCalledWith('terminal.send', {
terminal: 'terminal',
text: '1',
enter: false,
client: { id: 'phone', type: 'mobile' }
})
})
})
@@ -0,0 +1,49 @@
import { useCallback, type MutableRefObject } from 'react'
import type { RpcClient } from '../transport/rpc-client'
import { sendMobileNativeChatMessage } from './mobile-native-chat-send'
export function sendMobileNativeChatPermissionResponse(args: {
client: RpcClient
terminal: string
deviceToken: string | null
text: string
}): Promise<boolean> {
// Why: approval choices are already complete terminal control sequences;
// appending Return changes both numbered choices and Escape denial.
return sendMobileNativeChatMessage({
client: args.client,
terminal: args.terminal,
text: args.text,
enter: false,
...(args.deviceToken ? { mobileClient: { id: args.deviceToken, type: 'mobile' as const } } : {})
})
}
export function useMobileNativeChatPermissionSend(args: {
client: RpcClient | null
enabled: boolean
handleRef: MutableRefObject<string | null>
deviceTokenRef: MutableRefObject<string | null>
onSendError: (message: string) => void
}): (text: string) => Promise<boolean> {
return useCallback(
async (text: string): Promise<boolean> => {
const terminal = args.handleRef.current
if (!args.client || !terminal || !args.enabled) {
args.onSendError('Response not sent (disconnected)')
return false
}
const accepted = await sendMobileNativeChatPermissionResponse({
client: args.client,
terminal,
deviceToken: args.deviceTokenRef.current,
text
})
if (!accepted) {
args.onSendError('Response not sent')
}
return accepted
},
[args.client, args.deviceTokenRef, args.enabled, args.handleRef, args.onSendError]
)
}
@@ -0,0 +1,144 @@
import { describe, expect, it } from 'vitest'
import { detectAgentPermission, parseApprovalFromStatus } from './mobile-native-chat-permission'
describe('detectAgentPermission', () => {
it('returns null for a working agent even with permission-like text', () => {
expect(
detectAgentPermission({
state: 'working',
lastAssistantMessage: 'Do you want to proceed? (y/n)'
})
).toBeNull()
})
it('returns null when state is undefined', () => {
expect(
detectAgentPermission({ lastAssistantMessage: 'Do you want to proceed? (y/n)' })
).toBeNull()
})
it('returns null for ordinary prose while blocked', () => {
expect(
detectAgentPermission({
state: 'blocked',
lastAssistantMessage: 'I finished editing the file and ran the tests.'
})
).toBeNull()
})
it('returns null when the message is empty', () => {
expect(detectAgentPermission({ state: 'waiting', lastAssistantMessage: ' ' })).toBeNull()
})
it('detects a (y/n) prompt and offers Allow/Deny with y/n send values', () => {
const result = detectAgentPermission({
state: 'waiting',
lastAssistantMessage: 'Allow running `rm -rf build`? (y/n)'
})
expect(result).not.toBeNull()
expect(result?.title).toBe('Permission requested')
expect(result?.options).toEqual([
{ label: 'Allow', send: 'y' },
{ label: 'Deny', send: 'n' }
])
expect(result?.detail).toContain('Allow running')
})
it('detects a numbered allow/deny menu and sends the digit', () => {
const result = detectAgentPermission({
state: 'blocked',
lastAssistantMessage:
'Claude wants to edit App.tsx. Do you want to proceed?\n' +
'1. Yes\n' +
'2. No, and tell Claude what to do differently'
})
expect(result).not.toBeNull()
expect(result?.options).toHaveLength(2)
expect(result?.options[0]).toEqual({ label: 'Yes', send: '1' })
expect(result?.options[1]?.send).toBe('2')
expect(result?.options[1]?.label).toContain('No')
})
it('handles parenthesized numbered options "1) Yes 2) No"', () => {
const result = detectAgentPermission({
state: 'waiting',
lastAssistantMessage: 'Approve this command?\n1) Yes\n2) No'
})
expect(result?.options.map((o) => o.send)).toEqual(['1', '2'])
})
it('supports a three-option numbered menu (Yes / Yes always / No)', () => {
const result = detectAgentPermission({
state: 'blocked',
lastAssistantMessage:
"Allow this tool call?\n1. Yes\n2. Yes, and don't ask again this session\n3. No"
})
expect(result?.options).toHaveLength(3)
expect(result?.options.map((o) => o.send)).toEqual(['1', '2', '3'])
})
it('adds an "Allow always" option when the text offers a persistent grant', () => {
const result = detectAgentPermission({
state: 'waiting',
lastAssistantMessage:
'Do you want to allow this? You can allow always for this session. (y/n)'
})
expect(result?.options.map((o) => o.label)).toEqual(['Allow', 'Allow always', 'Deny'])
expect(result?.options.map((o) => o.send)).toEqual(['y', 'a', 'n'])
})
it('detects keyword-only permission asks without explicit y/n tokens', () => {
const result = detectAgentPermission({
state: 'blocked',
lastAssistantMessage: 'I need your permission to run this Bash command.'
})
expect(result).not.toBeNull()
expect(result?.options.map((o) => o.send)).toEqual(['y', 'n'])
})
it('detects "approve" / "deny" phrasing', () => {
const result = detectAgentPermission({
state: 'waiting',
lastAssistantMessage: 'Please approve or deny this write to /etc/hosts.'
})
expect(result).not.toBeNull()
expect(result?.options).toHaveLength(2)
})
it('truncates a very long detail line', () => {
const long = `Do you want to proceed? ${'x'.repeat(300)}`
const result = detectAgentPermission({ state: 'waiting', lastAssistantMessage: long })
expect(result).not.toBeNull()
expect((result?.detail ?? '').length).toBeLessThanOrEqual(160)
})
it('shortens long numbered option labels', () => {
const result = detectAgentPermission({
state: 'blocked',
lastAssistantMessage:
'Proceed?\n1. Yes\n2. No, and explain in great detail exactly why this particular approach is wrong'
})
const second = result?.options[1]
expect(second?.send).toBe('2')
expect((second?.label ?? '').length).toBeLessThanOrEqual(40)
})
})
describe('parseApprovalFromStatus', () => {
it('parses an approval envelope into an Allow/Deny card', () => {
const card = parseApprovalFromStatus(
JSON.stringify({ approval: { tool: 'Bash', summary: 'rm -rf build' } })
)
expect(card?.title).toBe('Allow Bash?')
expect(card?.detail).toBe('rm -rf build')
expect(card?.options.map((o) => o.label)).toEqual(['Allow', 'Deny'])
expect(card?.options[0]!.send).toBe('1')
})
it('returns null for non-approval / malformed input', () => {
expect(parseApprovalFromStatus(undefined)).toBeNull()
expect(parseApprovalFromStatus('{bad')).toBeNull()
expect(parseApprovalFromStatus(JSON.stringify({ questions: [] }))).toBeNull()
expect(parseApprovalFromStatus(JSON.stringify({ approval: {} }))).toBeNull()
})
})
@@ -0,0 +1,179 @@
// Agent permission asks (e.g. Claude/Codex "Do you want to proceed?") surface
// as plain TUI text in the agent's last assistant message — there is no
// structured permission event on mobile. We detect them heuristically so the
// native chat can render tappable Allow/Deny buttons instead of forcing the
// user to type into the composer. Be conservative: only fire when the agent is
// actually paused (blocked/waiting) AND the text reads like an approval ask.
/** A detected permission prompt, rendered as a card with tappable options.
* Each option's `send` is the literal string to write back to the agent
* (e.g. "y", "1") when the user taps it. */
export type MobileChatPermission = {
title: string
detail?: string
options: Array<{ label: string; send: string }>
}
const ESCAPE = String.fromCharCode(27)
/** Parse the live `agentStatus.interactivePrompt` approval envelope
* (`{ approval: { tool, summary } }`, emitted by the host on a PermissionRequest)
* into an Allow/Deny card. This is the reliable, agent-emitted signal — unlike
* detectAgentPermission it doesn't depend on heuristic text parsing. The default
* sends (number for allow, Escape for deny) match the common TUI approval prompt;
* detectAgentPermission still takes precedence when it can read the real numbered
* options from the prompt text. */
export function parseApprovalFromStatus(
interactivePrompt: string | undefined | null
): MobileChatPermission | null {
if (!interactivePrompt) {
return null
}
let parsed: unknown
try {
parsed = JSON.parse(interactivePrompt)
} catch {
return null
}
if (!parsed || typeof parsed !== 'object') {
return null
}
const approval = (parsed as { approval?: unknown }).approval
if (!approval || typeof approval !== 'object') {
return null
}
const tool = (approval as { tool?: unknown }).tool
if (typeof tool !== 'string' || tool.length === 0) {
return null
}
const summary = (approval as { summary?: unknown }).summary
return {
title: `Allow ${tool}?`,
detail: typeof summary === 'string' && summary.length > 0 ? summary : undefined,
options: [
{ label: 'Allow', send: '1' },
{ label: 'Deny', send: ESCAPE }
]
}
}
type PermissionInput = {
state?: string
lastAssistantMessage?: string
toolName?: string
toolInput?: unknown
}
// States where the agent is paused waiting on the human. Only these can yield a
// permission prompt — a "working" agent is mid-turn and must not be answered.
const PAUSED_STATES: ReadonlySet<string> = new Set(['blocked', 'waiting'])
// Phrases that read as an approval request. Kept broad but anchored to
// approval/permission language so ordinary prose doesn't trip detection.
const PERMISSION_PATTERNS: RegExp[] = [
/\bpermission\b/i,
/\bapprove\b/i,
/\bapproval\b/i,
/\ballow\b/i,
/\bdeny\b/i,
/\bgrant\b/i,
/\bauthorize\b/i,
/\bdo you want to\b/i,
/\bwould you like to\b/i,
/\bproceed\?/i,
/\bconfirm\b/i,
/\(y\/n\)/i,
/\by\/n\b/i,
/\byes\/no\b/i,
/\bplease confirm\b/i
]
function looksLikePermissionAsk(text: string): boolean {
return PERMISSION_PATTERNS.some((re) => re.test(text))
}
// A numbered-choice prompt like "1. Yes 2. No" / "2) No, and tell Claude…".
// Captures the option number and its label so we can send the literal digit.
const NUMBERED_OPTION_RE = /(?:^|\n)\s*(\d+)[.)]\s*([^\n]+)/g
type NumberedOption = { num: string; text: string }
function parseNumberedOptions(text: string): NumberedOption[] {
const out: NumberedOption[] = []
for (const match of text.matchAll(NUMBERED_OPTION_RE)) {
const num = match[1]
const body = match[2]?.trim()
if (num && body) {
out.push({ num, text: body })
}
}
return out
}
// Whether a label reads as "allow for every future call" rather than just once.
function isAlwaysLabel(text: string): boolean {
return /\balways\b|don't ask again|do not ask again|for the rest|this session/i.test(text)
}
function shortLabel(text: string, max = 40): string {
const trimmed = text.replace(/\s+/g, ' ').trim()
return trimmed.length > max ? `${trimmed.slice(0, max - 1)}…` : trimmed
}
function buildNumberedPermission(
options: NumberedOption[],
detail: string | undefined
): MobileChatPermission {
return {
title: 'Permission requested',
detail,
options: options.map((opt) => ({ label: shortLabel(opt.text), send: opt.num }))
}
}
function firstLine(text: string): string {
return text.split('\n')[0]?.trim() ?? ''
}
/**
* Heuristically detect an agent permission ask from its paused-state context.
* Returns a renderable prompt, or null when the agent is working or the text
* doesn't read like an approval request.
*/
export function detectAgentPermission(input: PermissionInput): MobileChatPermission | null {
// Only answer a genuinely paused agent. A working agent is mid-turn.
if (!input.state || !PAUSED_STATES.has(input.state)) {
return null
}
const text = typeof input.lastAssistantMessage === 'string' ? input.lastAssistantMessage : ''
if (!text.trim()) {
return null
}
if (!looksLikePermissionAsk(text)) {
return null
}
const detail = shortLabel(firstLine(text), 160) || undefined
// Prefer an explicit numbered menu ("1. Yes 2. No, and tell…") — its labels
// and send-digits come straight from the agent, so no guessing.
const numbered = parseNumberedOptions(text)
if (numbered.length >= 2) {
return buildNumberedPermission(numbered, detail)
}
// Otherwise fall back to a y/n prompt. We surface "Allow always" only when the
// text actually offers a persistent option, to avoid sending a token the agent
// doesn't understand.
const options: MobileChatPermission['options'] = [
{ label: 'Allow', send: 'y' },
{ label: 'Deny', send: 'n' }
]
if (isAlwaysLabel(text)) {
options.splice(1, 0, { label: 'Allow always', send: 'a' })
}
return { title: 'Permission requested', detail, options }
}
@@ -0,0 +1,158 @@
import { describe, expect, it } from 'vitest'
import {
formatQuestionAnswer,
mobileChatQuestionKey,
parseAgentQuestion,
type MobileChatQuestion
} from './mobile-native-chat-question'
describe('parseAgentQuestion', () => {
it('parses a numbered list with a question line', () => {
const text = ['Which database should I use?', '1. PostgreSQL', '2. MySQL', '3. SQLite'].join(
'\n'
)
const q = parseAgentQuestion(text)
expect(q).not.toBeNull()
expect(q?.question).toBe('Which database should I use?')
expect(q?.options).toEqual(['PostgreSQL', 'MySQL', 'SQLite'])
expect(q?.optionTokens).toEqual(['1', '2', '3'])
expect(q?.multiSelect).toBe(false)
})
it('parses numbered lists using ) markers', () => {
const q = parseAgentQuestion('Pick:\n1) Yes\n2) No')
expect(q?.options).toEqual(['Yes', 'No'])
expect(q?.optionTokens).toEqual(['1', '2'])
})
it('parses a bulleted list', () => {
const text = ['How should we proceed?', '- Rebase', '- Merge', '* Squash'].join('\n')
const q = parseAgentQuestion(text)
expect(q?.options).toEqual(['Rebase', 'Merge', 'Squash'])
expect(q?.optionTokens).toEqual([null, null, null])
expect(q?.question).toBe('How should we proceed?')
})
it('parses lettered options in brackets and parens', () => {
const q = parseAgentQuestion('Choose one:\n[a] Alpha\n[b] Beta')
expect(q?.options).toEqual(['Alpha', 'Beta'])
expect(q?.optionTokens).toEqual(['a', 'b'])
const q2 = parseAgentQuestion('Choose one:\na) Alpha\nb) Beta')
expect(q2?.options).toEqual(['Alpha', 'Beta'])
expect(q2?.optionTokens).toEqual(['a', 'b'])
})
it('strips a leading pointer/highlight glyph from the selected row', () => {
const text = ['Select a target:', '❯ 1. main', ' 2. develop'].join('\n')
const q = parseAgentQuestion(text)
expect(q?.options).toEqual(['main', 'develop'])
expect(q?.optionTokens).toEqual(['1', '2'])
})
it('drops a trailing colon but keeps a question mark in the title', () => {
expect(parseAgentQuestion('Options:\n1. A\n2. B')?.question).toBe('Options')
expect(parseAgentQuestion('Ready?\n1. A\n2. B')?.question).toBe('Ready?')
})
it('returns null for ordinary prose with no option list', () => {
expect(parseAgentQuestion('I finished the refactor and ran the tests.')).toBeNull()
expect(parseAgentQuestion('')).toBeNull()
expect(parseAgentQuestion(' ')).toBeNull()
})
it('returns null for a single bare option with no introducing prompt', () => {
// A lone "- item" in prose must not be treated as a question.
expect(parseAgentQuestion('Here is what I did:\nsome work\n- one stray bullet')).toBeNull()
})
it('accepts a single option when introduced by a prompt line', () => {
const q = parseAgentQuestion('Apply this change?\n1. Yes, apply it')
expect(q).not.toBeNull()
expect(q?.options).toEqual(['Yes, apply it'])
})
it('uses a fallback title when no introducing line exists', () => {
const q = parseAgentQuestion('1. Alpha\n2. Beta')
expect(q?.question).toBe('Choose an option')
expect(q?.options).toEqual(['Alpha', 'Beta'])
})
it('detects multi-select hints', () => {
expect(parseAgentQuestion('Select all that apply:\n1. A\n2. B')?.multiSelect).toBe(true)
expect(parseAgentQuestion('Choose multiple:\n- A\n- B')?.multiSelect).toBe(true)
expect(parseAgentQuestion('Pick one:\n1. A\n2. B')?.multiSelect).toBe(false)
})
it('does not flag multi-select when only one option is present', () => {
const q = parseAgentQuestion('Select all that apply?\n1. Only one')
expect(q?.multiSelect).toBe(false)
})
it('finds the question line nearest the options, skipping blanks', () => {
const text = ['Some preamble.', '', 'Which branch?', '', '1. main', '2. dev'].join('\n')
const q = parseAgentQuestion(text)
expect(q?.question).toBe('Which branch?')
})
})
describe('formatQuestionAnswer', () => {
const numbered: MobileChatQuestion = {
question: 'Pick',
options: ['Alpha', 'Beta', 'Gamma'],
multiSelect: false,
optionTokens: ['1', '2', '3']
}
it('echoes the leading token for single-select', () => {
expect(formatQuestionAnswer(numbered, ['Beta'])).toBe('2')
})
it('comma-joins tokens for multi-select', () => {
const multi: MobileChatQuestion = { ...numbered, multiSelect: true }
expect(formatQuestionAnswer(multi, ['Alpha', 'Gamma'])).toBe('1, 3')
})
it('sends the label text when the option had no token (bullet list)', () => {
const bullets: MobileChatQuestion = {
question: 'Pick',
options: ['Rebase', 'Merge'],
multiSelect: false,
optionTokens: [null, null]
}
expect(formatQuestionAnswer(bullets, ['Merge'])).toBe('Merge')
})
it('echoes letter tokens', () => {
const lettered: MobileChatQuestion = {
question: 'Pick',
options: ['Alpha', 'Beta'],
multiSelect: false,
optionTokens: ['a', 'b']
}
expect(formatQuestionAnswer(lettered, ['Beta'])).toBe('b')
})
it('passes free-text / unknown entries through verbatim', () => {
expect(formatQuestionAnswer(numbered, ['something custom'])).toBe('something custom')
})
it('returns empty string when nothing is selected', () => {
expect(formatQuestionAnswer(numbered, [])).toBe('')
expect(formatQuestionAnswer(numbered, [' '])).toBe('')
})
})
describe('mobileChatQuestionKey', () => {
it('changes when a replacement prompt changes any positional content', () => {
const first: MobileChatQuestion = {
question: 'Pick',
options: ['A', 'B'],
multiSelect: true,
optionTokens: ['1', '2']
}
expect(mobileChatQuestionKey({ ...first, options: ['A', 'C'] })).not.toBe(
mobileChatQuestionKey(first)
)
})
})
@@ -0,0 +1,154 @@
// Heuristic detection of an agent's "pick an option" prompt from its status /
// assistant text. Agents (Claude et al.) render these as a TUI choice list; we
// have no structured signal, so we parse the text conservatively and only treat
// it as a question when a clear option list is present.
export type MobileChatQuestion = {
question: string
options: string[]
multiSelect: boolean
/** Per-option leading marker ("1", "b", …) when the source line carried one,
* parallel to `options`. Null where the option was a plain bullet. Used to
* echo the exact choice the agent listed back to the terminal. */
optionTokens: (string | null)[]
}
export function mobileChatQuestionKey(question: MobileChatQuestion): string {
return JSON.stringify(question)
}
type ParsedOption = {
label: string
token: string | null
}
// A pointer/highlight glyph some TUIs prefix onto the *currently selected* row.
// Stripped first so "❯ 2. Foo" parses identically to "2. Foo".
const POINTER_PREFIX = /^(\s*)(?:❯|›|»)\s+/
// Ordered most-specific-first so a numbered/lettered marker wins over the
// bullet fallback. `token` is the capture index of the leading marker (0 = none),
// `label` the capture index of the choice text.
const OPTION_PATTERNS: { re: RegExp; token: number; label: number }[] = [
// 1. Option 12) Option
{ re: /^\s*(\d{1,2})[.)]\s+(\S.*?)\s*$/, token: 1, label: 2 },
// [a] Option [1] Option
{ re: /^\s*\[([0-9a-zA-Z])\]\s+(\S.*?)\s*$/, token: 1, label: 2 },
// a) Option a. Option (single letter, to avoid eating prose like "e.g.")
{ re: /^\s*([a-zA-Z])[.)]\s+(\S.*?)\s*$/, token: 1, label: 2 },
// - Option * Option • Option > Option (no token)
{ re: /^\s*(?:[-*•>])\s+(\S.*?)\s*$/, token: 0, label: 1 }
]
function parseOptionLine(line: string): ParsedOption | null {
const stripped = line.replace(POINTER_PREFIX, '$1')
for (const { re, token, label } of OPTION_PATTERNS) {
const m = stripped.match(re)
if (!m) {
continue
}
const text = m[label].trim()
if (text.length === 0) {
continue
}
return { label: text, token: token > 0 ? m[token] : null }
}
return null
}
const MULTI_SELECT_HINT =
/\b(select all|choose all|choose multiple|select multiple|pick multiple|all that apply|one or more|comma[- ]separated|multiple options)\b/i
// Question-like introducing line: ends in ? or :.
const QUESTION_LINE = /[?:]\s*$/
// Drop a trailing ":" off a card title but keep a meaningful "?".
function cleanQuestionText(raw: string): string {
const trimmed = raw.trim()
return trimmed.endsWith(':') ? trimmed.slice(0, -1).trim() : trimmed
}
/**
* Heuristically parse a question + its option list from agent text. Returns null
* when no clear option list is present (so ordinary prose is never treated as a
* question). Conservative on purpose: requires at least two option lines, or one
* option line introduced by a question-like prompt line.
*/
export function parseAgentQuestion(text: string): MobileChatQuestion | null {
if (typeof text !== 'string' || text.trim().length === 0) {
return null
}
const lines = text.replace(/\r\n/g, '\n').split('\n')
const parsed: { index: number; option: ParsedOption }[] = []
lines.forEach((line, index) => {
const option = parseOptionLine(line)
if (option) {
parsed.push({ index, option })
}
})
if (parsed.length === 0) {
return null
}
const firstOptionIndex = parsed[0].index
const options = parsed.map((p) => p.option.label)
const optionTokens = parsed.map((p) => p.option.token)
// Find the introducing question: nearest non-empty, non-option line above the
// first option.
let question = ''
let questionLooksLikePrompt = false
for (let i = firstOptionIndex - 1; i >= 0; i--) {
if (lines[i].trim().length === 0 || parseOptionLine(lines[i])) {
continue
}
question = lines[i]
questionLooksLikePrompt = QUESTION_LINE.test(lines[i])
break
}
// Conservative gate: a single bare option with no introducing prompt is more
// likely stray prose (a lone "- item") than a real choice list.
if (options.length < 2 && !questionLooksLikePrompt) {
return null
}
const multiSelect = MULTI_SELECT_HINT.test(text) && options.length > 1
return {
question: question.length > 0 ? cleanQuestionText(question) : 'Choose an option',
options,
multiSelect,
optionTokens
}
}
/**
* Build the text to send to the agent terminal for the selected option(s).
* Convention: echo the option's leading marker (number/letter) when the list had
* one; otherwise send the option label text (TUIs accept the literal choice).
* Multi-select answers are comma-joined; single-select is sent as-is. Unknown
* entries (free-text escape hatch) pass through verbatim. Returns '' when
* nothing is selected.
*/
export function formatQuestionAnswer(question: MobileChatQuestion, selected: string[]): string {
const labels = selected.map((s) => s.trim()).filter((s) => s.length > 0)
if (labels.length === 0) {
return ''
}
const parts = labels.map((label) => {
const index = question.options.indexOf(label)
if (index === -1) {
// Free-text / unknown entry: pass the user's text straight through.
return label
}
const token = question.optionTokens[index]
return token != null && token.length > 0 ? token : label
})
return parts.join(question.multiSelect ? ', ' : ' ')
}
@@ -0,0 +1,104 @@
import { describe, expect, it } from 'vitest'
import type { NativeChatMessage } from '../../../src/shared/native-chat-types'
import {
buildMobileNativeChatData,
mobileNativeChatEmptyState
} from './mobile-native-chat-render-data'
function assistant(id: string, text: string): NativeChatMessage {
return {
id,
role: 'assistant',
blocks: [{ type: 'text', text }],
timestamp: 0,
source: 'transcript'
}
}
function user(id: string, text: string): NativeChatMessage {
return { id, role: 'user', blocks: [{ type: 'text', text }], timestamp: 0, source: 'transcript' }
}
describe('mobileNativeChatEmptyState', () => {
it('invites a first message naming the agent, matching desktop copy', () => {
// waiting-session (live agent, no transcript) and ready (loaded, empty) both
// resolve to the shared "empty" copy with the agent label substituted.
const waiting = mobileNativeChatEmptyState('waiting-session', 'claude')
expect(waiting).toEqual({
title: 'Start a chat with Claude',
subtitle: 'Ask Claude to inspect code, explain output, or make a change.'
})
expect(mobileNativeChatEmptyState('ready', 'codex')?.title).toBe('Start a chat with Codex')
})
it('falls back to "the agent" when the agent is unknown', () => {
expect(mobileNativeChatEmptyState('waiting-session', null)?.title).toBe(
'Start a chat with the agent'
)
})
it('prefers the provided error message over the default subtitle', () => {
expect(mobileNativeChatEmptyState('error', 'claude', 'boom')?.subtitle).toBe('boom')
expect(mobileNativeChatEmptyState('error', 'claude')?.subtitle).toBe(
'The transcript could not be read. Toggle back to the terminal to keep working.'
)
})
it('returns null for states that show no empty copy', () => {
expect(mobileNativeChatEmptyState('loading', 'claude')).toBeNull()
expect(mobileNativeChatEmptyState('idle', 'claude')).toBeNull()
})
})
describe('buildMobileNativeChatData', () => {
it('appends pending optimistic messages at the tail as user turns', () => {
const { data } = buildMobileNativeChatData({
messages: [assistant('a1', 'hello')],
streamingText: undefined,
pending: [{ id: 'p1', text: 'queued' }]
})
const last = data[data.length - 1]
expect(last.id).toBe('p1')
expect(last.role).toBe('user')
expect(last.blocks).toEqual([{ type: 'text', text: 'queued' }])
})
it('adds a synthetic streaming bubble while the partial text leads the transcript', () => {
const { streaming, data } = buildMobileNativeChatData({
messages: [user('u1', 'hi')],
streamingText: 'thinking out loud',
pending: []
})
expect(streaming).toBe('thinking out loud')
expect(data.some((m) => m.id === 'streaming')).toBe(true)
})
it('shows a short new streaming reply even after a longer previous turn', () => {
// The last folded turn is a long completed reply; a short new stream must not
// be suppressed just for being shorter than the prior turn.
const { streaming, data } = buildMobileNativeChatData({
messages: [assistant('a1', 'This is a long completed previous answer that ran on a while')],
streamingText: 'Ok',
pending: []
})
expect(streaming).toBe('Ok')
expect(data.some((m) => m.id === 'streaming')).toBe(true)
})
it('drops the streaming bubble once the real assistant turn already contains it', () => {
const { streaming, data } = buildMobileNativeChatData({
messages: [assistant('a1', 'done answer')],
streamingText: 'done',
pending: []
})
expect(streaming).toBeNull()
expect(data.some((m) => m.id === 'streaming')).toBe(false)
})
it('returns no streaming bubble for empty/whitespace streaming text', () => {
expect(
buildMobileNativeChatData({ messages: [], streamingText: ' ', pending: [] }).streaming
).toBeNull()
expect(buildMobileNativeChatData({ messages: [], pending: [] }).streaming).toBeNull()
})
})
@@ -0,0 +1,115 @@
import { formatAgentTypeLabel } from '../../../src/shared/agent-type-label'
import {
formatNativeChatEmptyStateCopy,
type NativeChatEmptyStateCopy
} from '../../../src/shared/native-chat-empty-state'
import type { NativeChatMessage } from '../../../src/shared/native-chat-types'
import { foldToolMessages } from './mobile-native-chat-blocks'
import { stripNoiseMessages } from './mobile-native-chat-noise'
import type { MobileNativeChatStatus } from './use-mobile-native-chat-session'
/** The centered empty-state copy for a chat with no messages, mirroring the
* desktop `NativeChatEmptyState` (shared copy + agent label) so the two surfaces
* stay in lockstep. Returns null when the list should stay bare (idle, or the
* loading spinner owns the view). */
export function mobileNativeChatEmptyState(
status: MobileNativeChatStatus,
agent: string | null,
error?: string
): NativeChatEmptyStateCopy | null {
const agentLabel = agent ? formatAgentTypeLabel(agent) : 'the agent'
switch (status) {
// A live agent with no transcript yet — and a loaded-but-empty transcript —
// are both "start a chat"; invite the first message instead of implying the
// agent is still starting up.
case 'waiting-session':
case 'ready':
return formatNativeChatEmptyStateCopy('empty', agentLabel)
case 'error': {
const copy = formatNativeChatEmptyStateCopy('error', agentLabel)
return error ? { ...copy, subtitle: error } : copy
}
default:
return null
}
}
/** Derive the list data from the raw transcript: fold tool turns into the
* assistant turn, optionally append a synthetic streaming bubble, then the
* route-owned optimistic "queued" messages at the tail. Returns the
* intermediate `folded`/`streaming` so the caller can memoize on them. */
export function buildMobileNativeChatData({
messages,
streamingText,
pending
}: {
messages: NativeChatMessage[]
streamingText?: string
pending: Array<{ id: string; text: string }>
}): { folded: NativeChatMessage[]; streaming: string | null; data: NativeChatMessage[] } {
const folded = foldMobileNativeChatMessages(messages)
return buildMobileNativeChatTransientData({ folded, streamingText, pending })
}
export function foldMobileNativeChatMessages(messages: NativeChatMessage[]): NativeChatMessage[] {
return foldToolMessages(stripNoiseMessages(messages))
}
export function buildMobileNativeChatTransientData({
folded,
streamingText,
pending
}: {
folded: NativeChatMessage[]
streamingText?: string
pending: Array<{ id: string; text: string }>
}): { folded: NativeChatMessage[]; streaming: string | null; data: NativeChatMessage[] } {
// Only show the streaming bubble while its text leads the transcript — once the
// real assistant turn lands with the same text, drop the synthetic one.
const streaming = deriveStreaming(folded, streamingText)
const data: NativeChatMessage[] = [
...folded,
...(streaming
? [
{
id: 'streaming',
role: 'assistant' as const,
blocks: [{ type: 'text' as const, text: streaming }],
timestamp: null,
source: 'hook' as const
}
]
: []),
...pending.map((p) => ({
id: p.id,
role: 'user' as const,
blocks: [{ type: 'text' as const, text: p.text }],
timestamp: null,
source: 'transcript' as const
}))
]
return { folded, streaming, data }
}
function deriveStreaming(folded: NativeChatMessage[], streamingText?: string): string | null {
const text = streamingText?.trim()
if (!text) {
return null
}
const last = folded[folded.length - 1]
const lastText =
last?.role === 'assistant'
? last.blocks
.filter((b) => b.type === 'text')
.map((b) => (b.type === 'text' ? b.text : ''))
.join('')
.trim()
: ''
// Hide the synthetic bubble only once the real turn has landed leading with the
// streamed text. A bare length compare would suppress a short new reply behind a
// longer previous turn; a completed prior turn won't start with the new prefix.
if (lastText.startsWith(text)) {
return null
}
return text
}
@@ -0,0 +1,79 @@
import { describe, expect, it, vi } from 'vitest'
import type { RpcClient } from '../transport/rpc-client'
import { sendMobileNativeChatMessage } from './mobile-native-chat-send'
function clientWithResponse(response: unknown): RpcClient {
return {
sendRequest: vi.fn().mockResolvedValue(response)
} as unknown as RpcClient
}
describe('sendMobileNativeChatMessage', () => {
it('returns true only when the terminal accepts the send', async () => {
const client = clientWithResponse({
id: 'request',
ok: true,
result: { send: { accepted: true } },
_meta: { runtimeId: 'runtime' }
})
await expect(
sendMobileNativeChatMessage({
client,
terminal: 'term',
text: 'hello',
mobileClient: { id: 'device', type: 'mobile' }
})
).resolves.toBe(true)
expect(client.sendRequest).toHaveBeenCalledWith('terminal.send', {
terminal: 'term',
text: 'hello',
enter: true,
client: { id: 'device', type: 'mobile' }
})
})
it('returns false when the terminal rejects the send', async () => {
const client = clientWithResponse({
id: 'request',
ok: true,
result: { send: { accepted: false } },
_meta: { runtimeId: 'runtime' }
})
await expect(
sendMobileNativeChatMessage({ client, terminal: 'term', text: 'hello' })
).resolves.toBe(false)
})
it('returns false when the RPC fails', async () => {
const client = {
sendRequest: vi.fn().mockRejectedValue(new Error('disconnected'))
} as unknown as RpcClient
await expect(
sendMobileNativeChatMessage({ client, terminal: 'term', text: 'hello' })
).resolves.toBe(false)
})
it('sends a single non-submitting Escape for prompt cancellation', async () => {
const client = clientWithResponse({
id: 'request',
ok: true,
result: { send: { accepted: true } },
_meta: { runtimeId: 'runtime' }
})
await sendMobileNativeChatMessage({
client,
terminal: 'term',
text: String.fromCharCode(27),
enter: false
})
expect(client.sendRequest).toHaveBeenCalledWith('terminal.send', {
terminal: 'term',
text: String.fromCharCode(27),
enter: false
})
})
})
@@ -0,0 +1,27 @@
import type { RpcClient } from '../transport/rpc-client'
import { isTerminalSendRpcAccepted } from '../terminal/terminal-send-rpc-response'
type MobileTerminalClient = {
id: string
type: 'mobile'
}
export async function sendMobileNativeChatMessage(args: {
client: RpcClient
terminal: string
text: string
enter?: boolean
mobileClient?: MobileTerminalClient
}): Promise<boolean> {
try {
const response = await args.client.sendRequest('terminal.send', {
terminal: args.terminal,
text: args.text,
enter: args.enter ?? true,
...(args.mobileClient ? { client: args.mobileClient } : {})
})
return isTerminalSendRpcAccepted(response)
} catch {
return false
}
}
@@ -0,0 +1,112 @@
import { describe, expect, it } from 'vitest'
import type { NativeChatMessage } from '../../../src/shared/native-chat-types'
import { createNativeChatMerger, replaceList } from './mobile-native-chat-merge'
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('uses the first snapshot as the ordered base and carries pagination state', () => {
const merger = createNativeChatMerger()
const result = applyMobileNativeChatStreamFrame({
merger,
frame: {
type: 'snapshot',
messages: [message('a'), message('b')],
hasMore: true,
beforeOffset: 123
},
limit: 40,
replaceSnapshot: true
})
expect(result).toEqual({
kind: 'messages',
messages: [message('a'), message('b')],
hasMore: true,
beforeOffset: 123
})
})
it('merges reconnect snapshots and live appends into the bounded window', () => {
const merger = createNativeChatMerger()
replaceList(merger, [message('a'), message('b')])
const result = applyMobileNativeChatStreamFrame({
merger,
frame: { type: 'snapshot', messages: [message('b'), message('c'), message('d')] },
limit: 3,
replaceSnapshot: false
})
expect(result).toMatchObject({
kind: 'messages',
messages: [message('b'), message('c'), message('d')],
cursorInvalidated: true
})
})
it('keeps the pagination cursor when an append does not trim history', () => {
const merger = createNativeChatMerger()
replaceList(merger, [message('a')])
expect(
applyMobileNativeChatStreamFrame({
merger,
frame: { type: 'appended', messages: [message('b')] },
limit: 3,
replaceSnapshot: false
})
).toEqual({ kind: 'messages', messages: [message('a'), message('b')] })
})
it('replaces stale history for an explicit transcript replacement frame', () => {
const merger = createNativeChatMerger()
replaceList(merger, [message('old')])
expect(
applyMobileNativeChatStreamFrame({
merger,
frame: { type: 'replacement', messages: [message('new')], hasMore: false },
limit: 40,
replaceSnapshot: false
})
).toEqual({ kind: 'messages', messages: [message('new')], hasMore: false })
})
it('surfaces snapshot errors and ignores unrelated frames', () => {
const merger = createNativeChatMerger()
expect(
applyMobileNativeChatStreamFrame({
merger,
frame: { type: 'snapshot', error: 'Transcript unavailable' },
limit: 40,
replaceSnapshot: true
})
).toEqual({ kind: 'error', error: 'Transcript unavailable' })
expect(
applyMobileNativeChatStreamFrame({
merger,
frame: { type: 'error', message: 'Socket closed' },
limit: 40,
replaceSnapshot: false
})
).toEqual({ kind: 'error', error: 'Socket closed' })
expect(
applyMobileNativeChatStreamFrame({
merger,
frame: { type: 'end' },
limit: 40,
replaceSnapshot: true
})
).toEqual({ kind: 'ignored' })
})
})
@@ -0,0 +1,63 @@
import type { NativeChatMessage } from '../../../src/shared/native-chat-types'
import { applyAppend, replaceList, type NativeChatMerger } from './mobile-native-chat-merge'
export type MobileNativeChatStreamFrame = {
type?: string
messages?: NativeChatMessage[]
hasMore?: boolean
beforeOffset?: number
error?: string
message?: string
}
export type AppliedMobileNativeChatFrame =
| { kind: 'ignored' }
| { kind: 'error'; error: string }
| {
kind: 'messages'
messages: NativeChatMessage[]
hasMore?: boolean
beforeOffset?: number
cursorInvalidated?: boolean
}
/** Applies runtime stream frames while preserving the initial-snapshot versus
* reconnect-replay distinction owned by the session hook. */
export function applyMobileNativeChatStreamFrame(args: {
merger: NativeChatMerger
frame: MobileNativeChatStreamFrame
limit: number
replaceSnapshot: boolean
}): AppliedMobileNativeChatFrame {
const { merger, frame, limit, replaceSnapshot } = args
if (frame.type === 'error') {
return { kind: 'error', error: frame.message ?? frame.error ?? 'Transcript stream failed' }
}
if (frame.type !== 'snapshot' && frame.type !== 'replacement' && frame.type !== 'appended') {
return { kind: 'ignored' }
}
if (frame.error) {
return { kind: 'error', error: frame.error }
}
if (!Array.isArray(frame.messages)) {
return { kind: 'ignored' }
}
if (frame.type === 'replacement' || (frame.type === 'snapshot' && replaceSnapshot)) {
replaceList(merger, frame.messages)
return {
kind: 'messages',
messages: merger.list,
hasMore: frame.hasMore,
...(frame.beforeOffset == null ? {} : { beforeOffset: frame.beforeOffset })
}
}
const previousFirstId = merger.list[0]?.id
const messages = applyAppend(merger, frame.messages, limit)
return {
kind: 'messages',
messages,
// Why: once the bounded live window drops its oldest row, the snapshot's
// byte cursor no longer describes the oldest retained message.
...(previousFirstId && messages[0]?.id !== previousFirstId ? { cursorInvalidated: true } : {})
}
}
@@ -0,0 +1,88 @@
import { describe, expect, it } from 'vitest'
import {
isTerminalCoveredByNativeChat,
mobileNativeChatTerminalCapabilities,
resolveMobileNativeChatTerminalStreamAction
} from './mobile-native-chat-terminal-stream'
const base = {
showNativeChat: false,
activeHandle: 'pty-1',
activeTabType: 'terminal',
streamActive: true,
streamCovered: false,
webViewReady: true
}
describe('mobile native-chat terminal stream lifecycle', () => {
it('pauses an active terminal stream while chat covers it', () => {
expect(resolveMobileNativeChatTerminalStreamAction({ ...base, showNativeChat: true })).toBe(
'pause'
)
expect(
resolveMobileNativeChatTerminalStreamAction({
...base,
showNativeChat: true,
streamActive: false
})
).toBe('pause')
expect(isTerminalCoveredByNativeChat(true, 'pty-1', 'pty-1')).toBe(true)
expect(mobileNativeChatTerminalCapabilities(true)).toEqual({
terminalBinaryStream: 1,
mobileInputLeaseOnly: 1
})
expect(mobileNativeChatTerminalCapabilities(false)).toEqual({ terminalBinaryStream: 1 })
})
it('records a cold-start cover before WebView readiness so return refreshes', () => {
expect(
resolveMobileNativeChatTerminalStreamAction({
...base,
showNativeChat: true,
streamActive: false,
webViewReady: false
})
).toBe('pause')
expect(
resolveMobileNativeChatTerminalStreamAction({
...base,
streamActive: true,
streamCovered: true
})
).toBe('resume')
})
it('resumes only the ready active terminal after chat closes', () => {
expect(resolveMobileNativeChatTerminalStreamAction({ ...base, streamActive: false })).toBe(
'resume'
)
expect(
resolveMobileNativeChatTerminalStreamAction({
...base,
streamActive: false,
webViewReady: false
})
).toBe('none')
expect(resolveMobileNativeChatTerminalStreamAction({ ...base, streamCovered: true })).toBe(
'resume'
)
})
it('does nothing for non-terminal tabs, missing handles, or settled states', () => {
expect(resolveMobileNativeChatTerminalStreamAction(base)).toBe('none')
expect(
resolveMobileNativeChatTerminalStreamAction({
...base,
showNativeChat: true,
streamActive: false,
streamCovered: true
})
).toBe('none')
expect(resolveMobileNativeChatTerminalStreamAction({ ...base, activeTabType: 'browser' })).toBe(
'none'
)
expect(resolveMobileNativeChatTerminalStreamAction({ ...base, activeHandle: null })).toBe(
'none'
)
})
})
@@ -0,0 +1,37 @@
export type MobileNativeChatTerminalStreamAction = 'pause' | 'resume' | 'none'
/** Decides whether the active mobile terminal stream should run while native chat
* covers its WebView. Resume is allowed only once the mounted WebView is ready. */
export function resolveMobileNativeChatTerminalStreamAction(args: {
showNativeChat: boolean
activeHandle: string | null
activeTabType: string | null
streamActive: boolean
streamCovered: boolean
webViewReady: boolean
}): MobileNativeChatTerminalStreamAction {
if (!args.activeHandle || args.activeTabType !== 'terminal') {
return 'none'
}
if (args.showNativeChat) {
return !args.streamCovered ? 'pause' : 'none'
}
return (args.streamCovered || !args.streamActive) && args.webViewReady ? 'resume' : 'none'
}
export function isTerminalCoveredByNativeChat(
showNativeChat: boolean,
activeHandle: string | null,
handle: string
): boolean {
return showNativeChat && activeHandle === handle
}
export function mobileNativeChatTerminalCapabilities(covered: boolean): {
terminalBinaryStream: 1
mobileInputLeaseOnly?: 1
} {
return covered
? { terminalBinaryStream: 1, mobileInputLeaseOnly: 1 }
: { terminalBinaryStream: 1 }
}
@@ -0,0 +1,37 @@
import { MessageSquare, SquareTerminal } from 'lucide-react-native'
import type { ActionSheetAction } from '../components/ActionSheetModal'
import { resolveMobileNativeChat, type MobileNativeChatTab } from './mobile-native-chat-eligibility'
type ToggleTab = MobileNativeChatTab & {
id: string
terminal: string | null
}
/** Builds the optional terminal/chat switch shown in a terminal's long-press menu. */
export function getMobileNativeChatToggleActions(args: {
terminalHandle: string | null
tabs: readonly ToggleTab[]
chatTabIds: ReadonlySet<string>
nativeChatTranscriptIsLocalReadable: boolean
onClose: () => void
onToggle: (tabId: string) => void
}): ActionSheetAction[] {
const { terminalHandle, tabs, chatTabIds, onClose, onToggle } = args
const tab = terminalHandle
? tabs.find((candidate) => candidate.terminal === terminalHandle)
: null
if (!tab || !resolveMobileNativeChat(tab, args.nativeChatTranscriptIsLocalReadable)) {
return []
}
const isChat = chatTabIds.has(tab.id)
return [
{
label: isChat ? 'Switch to terminal view' : 'Switch to chat view',
icon: isChat ? SquareTerminal : MessageSquare,
onPress: () => {
onClose()
onToggle(tab.id)
}
}
]
}
@@ -0,0 +1,64 @@
import { describe, expect, it, vi } from 'vitest'
import { briefToolArg, summarizeToolInput, toolFilePath } from './mobile-native-chat-tool-summary'
describe('summarizeToolInput', () => {
it('passes short strings through, collapsing whitespace', () => {
expect(summarizeToolInput('ls -la\n/tmp')).toBe('ls -la /tmp')
})
it('serializes objects to compact JSON', () => {
expect(summarizeToolInput({ path: 'a.ts', limit: 5 })).toBe('{"path":"a.ts","limit":5}')
})
it('truncates long previews with an ellipsis', () => {
const long = 'x'.repeat(200)
const out = summarizeToolInput(long)
expect(out.length).toBe(80)
expect(out.endsWith('…')).toBe(true)
})
it('returns empty string for null/undefined', () => {
expect(summarizeToolInput(null)).toBe('')
expect(summarizeToolInput(undefined)).toBe('')
})
it('handles numbers and booleans', () => {
expect(summarizeToolInput(42)).toBe('42')
expect(summarizeToolInput(true)).toBe('true')
})
it('does not traverse an unbounded tool-input object for a short preview', () => {
const input: Record<string, unknown> = Object.fromEntries(
Array.from({ length: 8 }, (_unused, index) => [`key${index}`, index])
)
const lateGetter = vi.fn(() => 'should not be read')
Object.defineProperty(input, 'late', { enumerable: true, get: lateGetter })
expect(summarizeToolInput(input)).not.toBe('')
expect(lateGetter).not.toHaveBeenCalled()
})
})
describe('toolFilePath', () => {
it('extracts file_path / filePath / path / notebook_path', () => {
expect(toolFilePath({ file_path: 'src/a.ts' })).toBe('src/a.ts')
expect(toolFilePath({ filePath: 'src/b.ts' })).toBe('src/b.ts')
expect(toolFilePath({ path: 'src/c.ts' })).toBe('src/c.ts')
expect(toolFilePath({ notebook_path: 'n.ipynb' })).toBe('n.ipynb')
})
it('returns null when there is no file target', () => {
expect(toolFilePath({ command: 'ls' })).toBeNull()
expect(toolFilePath('x')).toBeNull()
expect(toolFilePath(null)).toBeNull()
})
})
describe('briefToolArg', () => {
it('takes the basename of a forward-slash path', () => {
expect(briefToolArg({ path: 'src/session/app.ts' })).toBe('app.ts')
})
it('takes the basename of a Windows backslash path from a raw transcript', () => {
expect(briefToolArg({ file_path: 'C:\\Users\\me\\project\\app.tsx' })).toBe('app.tsx')
})
})
@@ -0,0 +1,7 @@
export {
briefToolArg,
countToolCalls,
summarizeToolInput,
summarizeToolRun,
toolFilePath
} from '../../../src/shared/native-chat-tool-summary'
@@ -0,0 +1,109 @@
import { StyleSheet } from 'react-native'
import { colors, spacing, typography } from '../theme/mobile-theme'
export const styles = StyleSheet.create({
root: {
flex: 1,
backgroundColor: colors.bgBase
},
chromeRow: {
flexDirection: 'row',
alignItems: 'center',
justifyContent: 'space-between',
minHeight: 28,
paddingHorizontal: spacing.md
},
chromeLeft: {
flex: 1,
flexDirection: 'row',
alignItems: 'center',
gap: spacing.sm
},
stopButton: {
flexDirection: 'row',
alignItems: 'center',
gap: spacing.xs
},
stopLabel: {
color: colors.statusRed,
fontSize: typography.metaSize,
fontWeight: '700'
},
sendError: {
alignItems: 'center',
paddingHorizontal: spacing.md,
paddingBottom: spacing.xs
},
sendErrorText: {
color: colors.statusRed,
fontSize: typography.metaSize,
fontWeight: '600'
},
chromeToggle: {
flexDirection: 'row',
alignItems: 'center',
gap: 4,
paddingVertical: 4,
paddingHorizontal: spacing.xs
},
chromeToggleLabel: {
color: colors.textMuted,
fontSize: typography.metaSize,
fontWeight: '600'
},
pressed: {
opacity: 0.6
},
listWrap: {
flex: 1,
position: 'relative'
},
listContent: {
paddingVertical: spacing.sm,
flexGrow: 1
},
center: {
flex: 1,
alignItems: 'center',
justifyContent: 'center',
padding: spacing.xl
},
emptyTitle: {
color: colors.textSecondary,
fontSize: typography.bodySize,
fontWeight: '600',
textAlign: 'center',
marginBottom: spacing.xs
},
emptySubtitle: {
color: colors.textMuted,
fontSize: typography.metaSize,
textAlign: 'center'
},
fab: {
position: 'absolute',
right: spacing.md,
width: 38,
height: 38,
borderRadius: 19,
alignItems: 'center',
justifyContent: 'center',
backgroundColor: colors.bgRaised,
borderWidth: StyleSheet.hairlineWidth,
borderColor: colors.borderSubtle
},
fabBottom: {
bottom: spacing.md
},
loadEarlier: {
alignItems: 'center',
justifyContent: 'center',
paddingVertical: spacing.md,
minHeight: 36
},
loadEarlierText: {
color: colors.textMuted,
fontSize: typography.metaSize,
fontWeight: '600'
}
})
@@ -0,0 +1,69 @@
import { Eraser, Monitor, Smartphone } from 'lucide-react-native'
import type { ActionSheetAction } from '../components/ActionSheetModal'
import type { MobileNativeChatTab } from './mobile-native-chat-eligibility'
import { getMobileNativeChatToggleActions } from './mobile-native-chat-toggle-action'
type TerminalTab = MobileNativeChatTab & { id: string; terminal: string | null }
/** Builds the terminal long-press menu without adding another action block to the
* already dense session route. Native chat stays first as the view switch. */
export function getMobileTerminalActionSheetActions<Target extends { handle: string }>(args: {
target: Target | null
tabs: readonly TerminalTab[]
chatTabIds: ReadonlySet<string>
nativeChatTranscriptIsLocalReadable: boolean
onDismiss: () => void
onToggleChat: (tabId: string) => void
isPhoneMode: (handle: string) => boolean
onToggleDisplayMode: (handle: string) => void
onRename: (target: Target) => void
onClear: (target: Target) => void
onClose: (target: Target) => void
}): ActionSheetAction[] {
const { target } = args
if (!target) {
return []
}
const phoneMode = args.isPhoneMode(target.handle)
return [
...getMobileNativeChatToggleActions({
terminalHandle: target.handle,
tabs: args.tabs,
chatTabIds: args.chatTabIds,
nativeChatTranscriptIsLocalReadable: args.nativeChatTranscriptIsLocalReadable,
onClose: args.onDismiss,
onToggle: args.onToggleChat
}),
{
label: phoneMode ? 'Switch to Desktop' : 'Switch to Phone',
icon: phoneMode ? Monitor : Smartphone,
onPress: () => {
args.onDismiss()
args.onToggleDisplayMode(target.handle)
}
},
{
label: 'Rename',
onPress: () => {
args.onDismiss()
args.onRename(target)
}
},
{
label: 'Clear Terminal',
icon: Eraser,
onPress: () => {
args.onDismiss()
args.onClear(target)
}
},
{
label: 'Close',
destructive: true,
onPress: () => {
args.onDismiss()
args.onClose(target)
}
}
]
}
@@ -0,0 +1,24 @@
import { describe, expect, it, vi } from 'vitest'
import type { RpcClient } from '../transport/rpc-client'
import { subscribeMobileTerminalSafely } from './mobile-terminal-stream-subscribe'
describe('subscribeMobileTerminalSafely', () => {
it('reports a synchronous subscribe failure and returns a safe no-op cleanup', () => {
const onSynchronousError = vi.fn()
const client = {
subscribe: vi.fn(() => {
throw new Error('socket closed')
})
} as unknown as Pick<RpcClient, 'subscribe'>
const unsubscribe = subscribeMobileTerminalSafely(
client,
{ terminal: 'terminal-1' },
vi.fn(),
onSynchronousError
)
expect(onSynchronousError).toHaveBeenCalledOnce()
expect(unsubscribe).not.toThrow()
})
})
@@ -0,0 +1,17 @@
import type { RpcClient } from '../transport/rpc-client'
export function subscribeMobileTerminalSafely(
client: Pick<RpcClient, 'subscribe'>,
params: unknown,
onData: Parameters<RpcClient['subscribe']>[2],
onSynchronousError: () => void
): () => void {
try {
return client.subscribe('terminal.subscribe', params, onData)
} catch {
// Why: a transport mock or closing socket can reject before returning an
// unsubscribe handle; callers must still release their subscribing marker.
onSynchronousError()
return () => {}
}
}
@@ -0,0 +1,134 @@
import { createElement } from 'react'
import { act, create, type ReactTestRenderer } from 'react-test-renderer'
import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest'
import { useMobileAttachmentInputLeaseGate } from './use-mobile-attachment-input-lease-gate'
type Gate = (targetHandle: string) => Promise<boolean>
describe('useMobileAttachmentInputLeaseGate', () => {
let renderer: ReactTestRenderer | null = null
let errorSpy: ReturnType<typeof vi.spyOn> | null = null
beforeEach(() => {
const original = console.error
errorSpy = vi.spyOn(console, 'error').mockImplementation((...args: unknown[]) => {
if (typeof args[0] === 'string' && args[0].includes('react-test-renderer is deprecated')) {
return
}
original(...(args as Parameters<typeof console.error>))
})
vi.useFakeTimers()
})
afterEach(() => {
act(() => renderer?.unmount())
renderer = null
vi.useRealTimers()
errorSpy?.mockRestore()
})
function renderGate(args: {
connState: { current: string }
activeHandle: { current: string | null }
tabType: { current: string | null }
leaseReady: { current: boolean }
showToast: (message: string, durationMs?: number) => void
}): { gate: () => Gate } {
let gate: Gate = () => Promise.resolve(false)
function Probe(): null {
gate = useMobileAttachmentInputLeaseGate({
flushPendingLiveInputBeforeExternalSend: () => Promise.resolve(true),
connStateRef: args.connState,
activeHandleRef: args.activeHandle,
activeSessionTabTypeRef: args.tabType,
nativeChatInputLeaseReadyRef: args.leaseReady,
showToast: args.showToast
})
return null
}
act(() => {
renderer = create(createElement(Probe))
})
return { gate: () => gate }
}
function baseRefs(): {
connState: { current: string }
activeHandle: { current: string | null }
tabType: { current: string | null }
leaseReady: { current: boolean }
} {
return {
connState: { current: 'connected' },
activeHandle: { current: 'terminal-1' },
tabType: { current: 'terminal' },
leaseReady: { current: true }
}
}
it('passes immediately when the lease is ready and the target is active', async () => {
const refs = baseRefs()
const showToast = vi.fn()
const { gate } = renderGate({ ...refs, showToast })
await expect(gate()('terminal-1')).resolves.toBe(true)
expect(showToast).not.toHaveBeenCalled()
})
it('waits out a lease-not-ready window and then sends', async () => {
const refs = baseRefs()
refs.leaseReady.current = false
const showToast = vi.fn()
const { gate } = renderGate({ ...refs, showToast })
const result = gate()('terminal-1')
await vi.advanceTimersByTimeAsync(200)
refs.leaseReady.current = true
await vi.advanceTimersByTimeAsync(100)
await expect(result).resolves.toBe(true)
expect(showToast).not.toHaveBeenCalled()
})
it('surfaces a toast when the lease never recovers', async () => {
const refs = baseRefs()
refs.leaseReady.current = false
const showToast = vi.fn()
const { gate } = renderGate({ ...refs, showToast })
const result = gate()('terminal-1')
await vi.advanceTimersByTimeAsync(3200)
await expect(result).resolves.toBe(false)
expect(showToast).toHaveBeenCalledWith('Attach failed (reconnecting)', 1500)
})
it('drops silently when the target changes while waiting for the lease', async () => {
const refs = baseRefs()
refs.leaseReady.current = false
const showToast = vi.fn()
const { gate } = renderGate({ ...refs, showToast })
const result = gate()('terminal-1')
// Mid-wait the user switches tabs: the lease recovers, but for a different
// target — the attach must not proceed against the stale handle.
await vi.advanceTimersByTimeAsync(200)
refs.activeHandle.current = 'terminal-2'
refs.leaseReady.current = true
await vi.advanceTimersByTimeAsync(100)
await expect(result).resolves.toBe(false)
expect(showToast).not.toHaveBeenCalled()
})
it('drops silently when the connection is lost while waiting', async () => {
const refs = baseRefs()
refs.leaseReady.current = false
const showToast = vi.fn()
const { gate } = renderGate({ ...refs, showToast })
const result = gate()('terminal-1')
await vi.advanceTimersByTimeAsync(200)
refs.connState.current = 'reconnecting'
await vi.advanceTimersByTimeAsync(3200)
await expect(result).resolves.toBe(false)
expect(showToast).not.toHaveBeenCalled()
})
})
@@ -0,0 +1,75 @@
import { useCallback } from 'react'
type CurrentRef<T> = { readonly current: T }
type AttachmentInputLeaseGateArgs = {
readonly flushPendingLiveInputBeforeExternalSend: (handle: string) => Promise<boolean>
readonly connStateRef: CurrentRef<string>
readonly activeHandleRef: CurrentRef<string | null>
readonly activeSessionTabTypeRef: CurrentRef<string | null>
readonly nativeChatInputLeaseReadyRef: CurrentRef<boolean>
readonly showToast: (message: string, durationMs?: number) => void
}
// Poll cadence + ceiling for riding out a terminal resubscribe (WS reconnect or
// return-to-terminal) during which the input lease is briefly not ready.
const LEASE_READY_POLL_MS = 100
const LEASE_READY_TIMEOUT_MS = 3000
/** Gates an image attachment's terminal.send on a ready input lease. Flushes any
* pending IME/live input, confirms the send still targets the connected terminal
* tab, then waits out a short lease-not-ready window so a finished upload isn't
* dropped as if the picker were cancelled. Returns false only when the lease
* never recovers — surfacing a toast, since the caller treats a bare false as a
* silent picker-cancel (no error path). */
export function useMobileAttachmentInputLeaseGate({
flushPendingLiveInputBeforeExternalSend,
connStateRef,
activeHandleRef,
activeSessionTabTypeRef,
nativeChatInputLeaseReadyRef,
showToast
}: AttachmentInputLeaseGateArgs): (targetHandle: string) => Promise<boolean> {
return useCallback(
async (targetHandle: string): Promise<boolean> => {
const flushedPendingInput = await flushPendingLiveInputBeforeExternalSend(targetHandle)
// Why: image picking/upload and IME flushing can outlive the original tab.
if (
!flushedPendingInput ||
connStateRef.current !== 'connected' ||
targetHandle !== activeHandleRef.current ||
activeSessionTabTypeRef.current !== 'terminal'
) {
return false
}
const deadline = Date.now() + LEASE_READY_TIMEOUT_MS
while (!nativeChatInputLeaseReadyRef.current && Date.now() < deadline) {
await new Promise((resolve) => setTimeout(resolve, LEASE_READY_POLL_MS))
}
// Why: the wait can outlive the target too — re-check so a tab/host switch
// or disconnect mid-wait doesn't send into the wrong (or dead) terminal.
// A moved-away target drops silently like the pre-wait guard; only a lease
// that never recovered warrants the toast.
if (
connStateRef.current !== 'connected' ||
targetHandle !== activeHandleRef.current ||
activeSessionTabTypeRef.current !== 'terminal'
) {
return false
}
if (nativeChatInputLeaseReadyRef.current) {
return true
}
showToast('Attach failed (reconnecting)', 1500)
return false
},
[
activeHandleRef,
activeSessionTabTypeRef,
connStateRef,
flushPendingLiveInputBeforeExternalSend,
nativeChatInputLeaseReadyRef,
showToast
]
)
}
@@ -0,0 +1,243 @@
import { createElement } from 'react'
import { act, create, type ReactTestRenderer } from 'react-test-renderer'
import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest'
import type { AgentType } from '../../../src/shared/native-chat-types'
import type { RpcClient } from '../transport/rpc-client'
import { MOBILE_NATIVE_CHAT_QUESTION_STEP_MS } from './mobile-native-chat-answer-stepping'
import type { AskPrompt } from './mobile-native-chat-ask'
import { useMobileNativeChatAnswerSend } from './use-mobile-native-chat-answer-send'
type AnswerSend = ReturnType<typeof useMobileNativeChatAnswerSend>
function acceptedResponse() {
return {
id: 'send',
ok: true as const,
result: { send: { accepted: true } },
_meta: { runtimeId: 'runtime' }
}
}
const TABS_OR_SPACES: AskPrompt = {
questions: [
{
question: 'Tabs or spaces?',
multiSelect: false,
options: [{ label: 'Tabs' }, { label: 'Spaces' }]
}
]
}
describe('useMobileNativeChatAnswerSend', () => {
let renderer: ReactTestRenderer | null = null
let answerSend: AnswerSend | null = null
let mountedClient: RpcClient | null = null
let mountedOnSendError: ((message: string) => void) | null = null
let mountedAgent: AgentType = 'claude'
beforeEach(() => {
vi.useFakeTimers()
globalThis.IS_REACT_ACT_ENVIRONMENT = true
})
afterEach(() => {
act(() => renderer?.unmount())
renderer = null
answerSend = null
mountedClient = null
mountedOnSendError = null
mountedAgent = 'claude'
vi.useRealTimers()
})
function Harness({ enabled }: { enabled: boolean }): null {
answerSend = useMobileNativeChatAnswerSend({
client: mountedClient,
enabled,
handleRef: { current: 'terminal' },
deviceTokenRef: { current: 'device' },
agentRef: { current: mountedAgent },
sessionId: 'session',
streamIdentity: 'host\0worktree\0tab\0session',
onSendError: mountedOnSendError!
})
return null
}
async function mount(
client: RpcClient,
onSendError: (message: string) => void,
agent: AgentType = 'claude'
): Promise<void> {
mountedClient = client
mountedOnSendError = onSendError
mountedAgent = agent
const original = console.error
const consoleSpy = vi.spyOn(console, 'error').mockImplementation((...args) => {
if (typeof args[0] === 'string' && args[0].includes('react-test-renderer is deprecated')) {
return
}
original(...args)
})
try {
await act(async () => {
renderer = create(createElement(Harness, { enabled: true }))
})
} finally {
consoleSpy.mockRestore()
}
}
async function setEnabled(enabled: boolean): Promise<void> {
await act(async () => {
renderer?.update(createElement(Harness, { enabled }))
})
}
it('single-select: sends the picked option NUMBER (not the label), no trailing Enter', async () => {
const sendRequest = vi.fn().mockResolvedValue(acceptedResponse())
await mount({ sendRequest } as unknown as RpcClient, vi.fn())
// Spaces is option 2 — the STA-1860 case where label text committed Tabs.
await expect(answerSend?.answerAsk(TABS_OR_SPACES, [{ indices: [1] }])).resolves.toBe(true)
expect(sendRequest).toHaveBeenCalledTimes(1)
expect(sendRequest.mock.calls[0]?.[1]).toMatchObject({ text: '2', enter: false })
})
it('multi-select: toggles each option number, steps to Submit, confirms — paced apart', async () => {
const sendRequest = vi.fn().mockResolvedValue(acceptedResponse())
await mount({ sendRequest } as unknown as RpcClient, vi.fn())
const prompt: AskPrompt = {
questions: [
{
question: 'Pick fruits',
multiSelect: true,
options: [{ label: 'Apple' }, { label: 'Banana' }, { label: 'Cherry' }]
}
]
}
let result: Promise<boolean> | undefined
await act(async () => {
result = answerSend?.answerAsk(prompt, [{ indices: [0, 2] }])
})
expect(sendRequest).toHaveBeenCalledTimes(1)
expect(sendRequest.mock.calls[0]?.[1]).toMatchObject({ text: '1', enter: false })
await act(async () => vi.advanceTimersByTimeAsync(MOBILE_NATIVE_CHAT_QUESTION_STEP_MS))
expect(sendRequest.mock.calls[1]?.[1]).toMatchObject({ text: '3', enter: false })
await act(async () => vi.advanceTimersByTimeAsync(MOBILE_NATIVE_CHAT_QUESTION_STEP_MS))
expect(sendRequest.mock.calls[2]?.[1]).toMatchObject({ text: '\x1b[C', enter: false })
await act(async () => vi.advanceTimersByTimeAsync(MOBILE_NATIVE_CHAT_QUESTION_STEP_MS))
await expect(result).resolves.toBe(true)
expect(sendRequest.mock.calls[3]?.[1]).toMatchObject({ text: '\r', enter: false })
})
it('multi-question: option numbers auto-advance, one final submit Enter', async () => {
const sendRequest = vi.fn().mockResolvedValue(acceptedResponse())
await mount({ sendRequest } as unknown as RpcClient, vi.fn())
const prompt: AskPrompt = {
questions: [
{ question: 'q1', multiSelect: false, options: [{ label: 'A' }, { label: 'B' }] },
{ question: 'q2', multiSelect: false, options: [{ label: 'C' }, { label: 'D' }] }
]
}
let result: Promise<boolean> | undefined
await act(async () => {
result = answerSend?.answerAsk(prompt, [{ indices: [1] }, { indices: [0] }])
})
await act(async () => vi.runAllTimersAsync())
await expect(result).resolves.toBe(true)
expect(sendRequest.mock.calls.map((call) => call[1])).toEqual([
expect.objectContaining({ text: '2', enter: false }),
expect.objectContaining({ text: '1', enter: false }),
expect.objectContaining({ text: '\r', enter: false })
])
})
it('free text: opens "Type something", types the sanitized answer, then Enter', async () => {
const sendRequest = vi.fn().mockResolvedValue(acceptedResponse())
await mount({ sendRequest } as unknown as RpcClient, vi.fn())
let result: Promise<boolean> | undefined
await act(async () => {
// A newline in raw keystrokes would submit early — must collapse to space.
result = answerSend?.answerAsk(TABS_OR_SPACES, [{ indices: [], other: 'zeta\nspaces' }])
})
await act(async () => vi.runAllTimersAsync())
await expect(result).resolves.toBe(true)
expect(sendRequest.mock.calls.map((call) => call[1])).toEqual([
expect.objectContaining({ text: '3', enter: false }),
expect.objectContaining({ text: 'zeta spaces', enter: false }),
expect.objectContaining({ text: '\r', enter: false })
])
})
it('answers OpenClaude asks with Claude selector keystrokes', async () => {
const sendRequest = vi.fn().mockResolvedValue(acceptedResponse())
await mount({ sendRequest } as unknown as RpcClient, vi.fn(), 'openclaude')
await expect(answerSend?.answerAsk(TABS_OR_SPACES, [{ indices: [1] }])).resolves.toBe(true)
expect(sendRequest).toHaveBeenCalledTimes(1)
expect(sendRequest.mock.calls[0]?.[1]).toMatchObject({ text: '2', enter: false })
})
it('submits a non-Claude answer as pasted label text with a single Enter', async () => {
const sendRequest = vi.fn().mockResolvedValue(acceptedResponse())
await mount({ sendRequest } as unknown as RpcClient, vi.fn(), 'codex')
await expect(answerSend?.answerAsk(TABS_OR_SPACES, [{ indices: [1] }])).resolves.toBe(true)
// Codex's question tool commits the pasted answer: label text + one Enter.
expect(sendRequest).toHaveBeenCalledTimes(1)
expect(sendRequest.mock.calls[0]?.[1]).toMatchObject({ text: 'Spaces', enter: true })
})
it('stops at the first rejected write and reports failure', async () => {
const onSendError = vi.fn()
const sendRequest = vi.fn().mockResolvedValue({
id: 'send',
ok: true,
result: { send: { accepted: false } },
_meta: { runtimeId: 'runtime' }
})
await mount({ sendRequest } as unknown as RpcClient, onSendError)
await expect(answerSend?.answerAsk(TABS_OR_SPACES, [{ indices: [1] }])).resolves.toBe(false)
expect(sendRequest).toHaveBeenCalledTimes(1)
expect(onSendError).toHaveBeenCalledWith('Answer not sent')
})
it('rejects an empty selection without writing anything', async () => {
const sendRequest = vi.fn().mockResolvedValue(acceptedResponse())
await mount({ sendRequest } as unknown as RpcClient, vi.fn())
await expect(answerSend?.answerAsk(TABS_OR_SPACES, [{ indices: [] }])).resolves.toBe(false)
expect(sendRequest).not.toHaveBeenCalled()
})
it('cancels delayed keystrokes when the acknowledged input lease is lost', async () => {
const sendRequest = vi.fn().mockResolvedValue(acceptedResponse())
await mount({ sendRequest } as unknown as RpcClient, vi.fn())
const prompt: AskPrompt = {
questions: [
{ question: 'q1', multiSelect: false, options: [{ label: 'A' }, { label: 'B' }] },
{ question: 'q2', multiSelect: false, options: [{ label: 'C' }, { label: 'D' }] }
]
}
let result: Promise<boolean> | undefined
await act(async () => {
result = answerSend?.answerAsk(prompt, [{ indices: [1] }, { indices: [0] }])
})
expect(sendRequest).toHaveBeenCalledTimes(1)
await setEnabled(false)
await act(async () => vi.runAllTimersAsync())
await expect(result).resolves.toBe(false)
expect(sendRequest).toHaveBeenCalledTimes(1)
})
})
@@ -0,0 +1,176 @@
import { useCallback, useEffect, useRef, type MutableRefObject } from 'react'
import type { RpcClient } from '../transport/rpc-client'
import { MOBILE_NATIVE_CHAT_QUESTION_STEP_MS } from './mobile-native-chat-answer-stepping'
import {
buildAskAnswerKeys,
formatAskAnswer,
hasAskAnswer,
type AskAnswerSelection,
type AskPrompt
} from './mobile-native-chat-ask'
import { sendMobileNativeChatMessage } from './mobile-native-chat-send'
import { shouldStepNativeChatAskAnswer } from '../../../src/shared/native-chat-agent-support'
/** Sends an AskUserQuestion answer to the active chat pane. Claude's selector is
* answered by option-number keystrokes; other agents get pasted label text.
* Extracted from the session route to keep that file under its line cap and to
* own the pending-timer lifecycle in one place. */
export type MobileNativeChatAnswerSend = {
/** Answer the current question(s) from the card's per-question selections. */
answerAsk: (prompt: AskPrompt, selections: AskAnswerSelection[]) => Promise<boolean>
/** Drop any in-flight per-keystroke writes (call on Stop). */
cancelPending: () => void
}
// A free-text answer is written as raw keystrokes into Claude's "Type something"
// input (terminal.send has no paste framing), so an embedded newline would
// submit it early — collapse line breaks to spaces.
function sanitizeAskFreeText(text: string): string {
return text.replace(/[\r\n]+/g, ' ')
}
/**
* Owns the ask-answer send sequence for the mobile native chat. Reads the live
* pane/agent through refs (the route already keeps them current) so the returned
* callbacks stay stable. Claude answers are delivered as `buildAskAnswerKeys`
* keystroke groups written one selector-step apart over the EXISTING
* `terminal.send` passthrough (raw text, no enter) — same contract the
* permission card already uses, so old runtimes replay them verbatim (no new
* RPC; keystrokes are built client-side). The scheduled wait chain is cancelled
* on a new answer, on `cancelPending` (Stop), and on unmount / session swap — so
* a detached chain can never write PTY bytes to a stale pane.
*/
export function useMobileNativeChatAnswerSend(args: {
client: RpcClient | null
enabled: boolean
handleRef: MutableRefObject<string | null>
deviceTokenRef: MutableRefObject<string | null>
agentRef: MutableRefObject<string | null>
/** Changes on chat session swap; cancels pending writes when it does. */
sessionId: string | null
streamIdentity: string
onSendError: (message: string) => void
}): MobileNativeChatAnswerSend {
const {
client,
enabled,
handleRef,
deviceTokenRef,
agentRef,
sessionId,
streamIdentity,
onSendError
} = args
const generationRef = useRef(0)
const activeRouteRef = useRef({ client, enabled, sessionId, streamIdentity })
activeRouteRef.current = { client, enabled, sessionId, streamIdentity }
const delaysRef = useRef<
Set<{ timer: ReturnType<typeof setTimeout>; resolve: (completed: boolean) => void }>
>(new Set())
const cancelPending = useCallback(() => {
generationRef.current += 1
for (const delay of delaysRef.current) {
clearTimeout(delay.timer)
delay.resolve(false)
}
delaysRef.current.clear()
}, [])
// Cancel pending writes on unmount and whenever the chat session swaps.
useEffect(() => {
if (!enabled) {
cancelPending()
}
return cancelPending
}, [client, enabled, sessionId, streamIdentity, cancelPending])
const answerAsk = useCallback(
async (prompt: AskPrompt, selections: AskAnswerSelection[]): Promise<boolean> => {
const handle = handleRef.current
if (!client || !handle || !enabled) {
onSendError('Answer not sent (disconnected)')
return false
}
if (!hasAskAnswer(prompt, selections)) {
return false
}
// A new answer supersedes any still-pending keystroke writes.
cancelPending()
const generation = generationRef.current
const sendTerminal = (body: string, enter: boolean): Promise<boolean> => {
const activeRoute = activeRouteRef.current
if (
!activeRoute.enabled ||
activeRoute.client !== client ||
activeRoute.sessionId !== sessionId ||
activeRoute.streamIdentity !== streamIdentity ||
handleRef.current !== handle
) {
return Promise.resolve(false)
}
return sendMobileNativeChatMessage({
client,
terminal: handle,
text: body,
enter,
...(deviceTokenRef.current
? { mobileClient: { id: deviceTokenRef.current, type: 'mobile' } }
: {})
})
}
const wait = (ms: number): Promise<boolean> =>
new Promise((resolve) => {
const delay = {
timer: setTimeout(() => {
delaysRef.current.delete(delay)
resolve(generationRef.current === generation)
}, ms),
resolve
}
delaysRef.current.add(delay)
})
const fail = (): false => {
if (generationRef.current === generation) {
onSendError('Answer not sent')
}
return false
}
// Non-Claude question tools commit a pasted answer, so send the label text
// with one Enter. Claude's arrow-navigate selector ignores pasted labels
// (STA-1860): drive it by option-number keystrokes instead, one group per
// selector step so each renders before the next lands.
if (!shouldStepNativeChatAskAnswer(agentRef.current)) {
return (await sendTerminal(formatAskAnswer(prompt, selections), true)) || fail()
}
const groups = buildAskAnswerKeys(prompt, selections)
for (let index = 0; index < groups.length; index += 1) {
if (generationRef.current !== generation) {
return false
}
const group = groups[index]!
const body = 'raw' in group ? group.raw : sanitizeAskFreeText(group.text)
if (!(await sendTerminal(body, false))) {
return fail()
}
if (index < groups.length - 1 && !(await wait(MOBILE_NATIVE_CHAT_QUESTION_STEP_MS))) {
return false
}
}
return groups.length > 0
},
[
agentRef,
enabled,
cancelPending,
client,
deviceTokenRef,
handleRef,
onSendError,
sessionId,
streamIdentity
]
)
return { answerAsk, cancelPending }
}
@@ -0,0 +1,60 @@
import { createElement } from 'react'
import { act, create, type ReactTestRenderer } from 'react-test-renderer'
import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest'
import type { AskPrompt } from './mobile-native-chat-ask'
import { useMobileNativeChatAskDismiss } from './use-mobile-native-chat-ask-dismiss'
describe('useMobileNativeChatAskDismiss', () => {
let renderer: ReactTestRenderer | null = null
let state: ReturnType<typeof useMobileNativeChatAskDismiss> | null = null
beforeEach(() => {
globalThis.IS_REACT_ACT_ENVIRONMENT = true
})
afterEach(() => {
act(() => renderer?.unmount())
renderer = null
state = null
})
function Harness({ prompt }: { prompt: AskPrompt }): null {
state = useMobileNativeChatAskDismiss(prompt)
return null
}
const first: AskPrompt = {
questions: [
{ question: 'same first', multiSelect: false, options: [] },
{ question: 'old second', multiSelect: false, options: [] }
]
}
const replacement: AskPrompt = {
questions: [
{ question: 'same first', multiSelect: false, options: [] },
{ question: 'new second', multiSelect: false, options: [] }
]
}
it('shows a structurally different replacement without an intervening null', async () => {
const original = console.error
const consoleSpy = vi.spyOn(console, 'error').mockImplementation((...args) => {
if (typeof args[0] === 'string' && args[0].includes('react-test-renderer is deprecated')) {
return
}
original(...args)
})
try {
await act(async () => {
renderer = create(createElement(Harness, { prompt: first }))
})
} finally {
consoleSpy.mockRestore()
}
act(() => state?.dismissAsk())
expect(state?.showAsk).toBe(false)
await act(async () => renderer?.update(createElement(Harness, { prompt: replacement })))
expect(state?.showAsk).toBe(true)
})
})
@@ -0,0 +1,26 @@
import { useEffect, useState } from 'react'
import type { AskPrompt } from './mobile-native-chat-ask'
/** Track the answered-ask key so the lingering live status doesn't re-show the
* same card. The agent emits a post-tool event with the same prompt right after
* an answer, so the card is hidden until a genuinely different question arrives. */
export function useMobileNativeChatAskDismiss(ask?: AskPrompt | null): {
askKey: string | null
showAsk: boolean
dismissAsk: () => void
} {
const askKey = ask ? JSON.stringify(ask.questions) : null
const [dismissedAskKey, setDismissedAskKey] = useState<string | null>(null)
// Once the prompt clears (agent moved on), forget the dismissal so a later
// question — even an identical one — shows again instead of staying hidden.
const askPresent = ask != null
useEffect(() => {
if (!askPresent) {
setDismissedAskKey(null)
}
}, [askPresent])
const showAsk = askPresent && askKey !== dismissedAskKey
const dismissAsk = (): void => setDismissedAskKey(askKey)
return { askKey, showAsk, dismissAsk }
}
@@ -0,0 +1,314 @@
import {
useCallback,
useEffect,
useRef,
useState,
type Dispatch,
type MutableRefObject,
type SetStateAction
} from 'react'
import { loadNativeChatTabIds, saveNativeChatTabIds } from '../storage/preferences'
import type { RpcClient } from '../transport/rpc-client'
import {
parseAskFromStatus,
type AskAnswerSelection,
type AskPrompt
} from './mobile-native-chat-ask'
import { type MobileNativeChatTab, resolveMobileNativeChat } from './mobile-native-chat-eligibility'
import { detectAgentPermission } from './mobile-native-chat-permission'
import { parseAgentQuestion } from './mobile-native-chat-question'
import { openMobileNativeChatFile } from './mobile-native-chat-open-file'
import { useMobileNativeChatPermissionSend } from './mobile-native-chat-permission-send'
import { sendMobileNativeChatMessage } from './mobile-native-chat-send'
import { useMobileNativeChatAnswerSend } from './use-mobile-native-chat-answer-send'
import { useMobileNativeChatDrafts } from './use-mobile-native-chat-drafts'
import { useMobileNativeChatFileSearch } from './use-mobile-native-chat-file-search'
import { useMobileNativeChatSession } from './use-mobile-native-chat-session'
import { useMobileNativeChatPrompts } from './use-mobile-native-chat-prompts'
import { useMobileNativeChatStop } from './use-mobile-native-chat-stop'
import { useThrottledLatestValue } from './use-throttled-latest-value'
const NATIVE_CHAT_STREAM_THROTTLE_MS = 50
export type MobileNativeChatController = {
chatTabIds: Set<string>
toggleTabChatView: (tabId: string) => void
showNativeChat: boolean
showNativeChatRef: MutableRefObject<boolean>
/** Resolved agent for the active chat tab (names the empty-state copy). */
nativeChatAgent: string | null
chatComposerText: string
setChatComposerText: Dispatch<SetStateAction<string>>
chatPending: Array<{ id: string; text: string }>
nativeChatSession: ReturnType<typeof useMobileNativeChatSession>
nativeChatAgentWorking: boolean
nativeChatStreamingText?: string
nativeChatPermission: ReturnType<typeof detectAgentPermission>
nativeChatQuestion: ReturnType<typeof parseAgentQuestion>
nativeChatAsk: ReturnType<typeof parseAskFromStatus>
handleNativeChatOpenFile: (relativePath: string) => void
handleNativeChatAnswerAsk: (
prompt: AskPrompt,
selections: AskAnswerSelection[]
) => Promise<boolean>
handleNativeChatCancelAsk: () => Promise<boolean>
handleNativeChatRespondPermission: (text: string) => Promise<boolean>
handleNativeChatStop: () => void
nativeChatFilePaths: string[]
loadNativeChatFiles: (query: string) => void
handleNativeChatSend: (text: string) => Promise<boolean>
}
/** Owns mobile native-chat state and teardown outside the already dense session
* route. The route remains responsible only for choosing and rendering the view. */
export function useMobileNativeChatController(args: {
client: RpcClient | null
hostId: string
worktreeId: string
activeSessionTab: MobileNativeChatTab | null
activeSessionTabId: string | null
activeHandleRef: MutableRefObject<string | null>
deviceTokenRef: MutableRefObject<string | null>
nativeChatTranscriptIsLocalReadable: boolean
nativeChatInputLeaseReady: boolean
onSendError: (message: string) => void
}): MobileNativeChatController {
const {
client,
hostId,
worktreeId,
activeSessionTab,
activeSessionTabId,
activeHandleRef,
deviceTokenRef,
nativeChatTranscriptIsLocalReadable,
nativeChatInputLeaseReady,
onSendError
} = args
const [chatTabIds, setChatTabIds] = useState<Set<string>>(new Set())
const chatTabIdsToggledRef = useRef(false)
useEffect(() => {
let active = true
// Re-arm per host/worktree so the fresh load can seed the new selection.
chatTabIdsToggledRef.current = false
void loadNativeChatTabIds(hostId, worktreeId).then((ids) => {
// Why: a toggle before this load resolves is authoritative; the load must
// not revert the user's in-memory choice back to the persisted state.
if (active && !chatTabIdsToggledRef.current) {
// Why: route reuse must clear the prior worktree's selection even when
// the newly loaded worktree has no saved chat tabs.
setChatTabIds(new Set(ids))
}
})
return () => {
active = false
}
}, [hostId, worktreeId])
const toggleTabChatView = useCallback(
(tabId: string) => {
chatTabIdsToggledRef.current = true
setChatTabIds((previous) => {
const next = new Set(previous)
if (next.has(tabId)) {
next.delete(tabId)
} else {
next.add(tabId)
}
void saveNativeChatTabIds(hostId, worktreeId, [...next])
return next
})
},
[hostId, worktreeId]
)
const activeChatResolution =
activeSessionTab && activeSessionTabId && chatTabIds.has(activeSessionTabId)
? resolveMobileNativeChat(activeSessionTab, nativeChatTranscriptIsLocalReadable)
: null
const showNativeChat = activeChatResolution != null
const showNativeChatRef = useRef(showNativeChat)
showNativeChatRef.current = showNativeChat
const activeChatAgentRef = useRef<string | null>(activeChatResolution?.agent ?? null)
activeChatAgentRef.current = activeChatResolution?.agent ?? null
const activeChatSessionId = activeChatResolution?.sessionId ?? null
const streamIdentity = `${hostId}\0${worktreeId}\0${activeSessionTabId ?? ''}\0${activeChatSessionId ?? ''}\0${activeHandleRef.current ?? ''}`
const nativeChatSession = useMobileNativeChatSession({
client,
agent: activeChatResolution?.agent ?? null,
sessionId: activeChatSessionId,
transcriptPath: activeChatResolution?.transcriptPath ?? null
})
const {
composerText: chatComposerText,
setComposerText: setChatComposerText,
pending: chatPending,
captureSendOrigin,
acceptSend
} = useMobileNativeChatDrafts({
hostId,
worktreeId,
tabId: activeSessionTabId,
sessionId: activeChatSessionId,
messages: nativeChatSession.messages
})
const nativeChatStatus = activeChatResolution ? activeSessionTab?.agentStatus : null
const nativeChatAgentWorking = nativeChatStatus?.state === 'working'
// Throttle the streaming bubble: OpenCode emits a status frame per streamed
// part, and each one re-renders and re-parses the whole accumulated markdown.
const nativeChatStreamingText = useThrottledLatestValue(
nativeChatAgentWorking ? nativeChatStatus?.lastAssistantMessage : undefined,
NATIVE_CHAT_STREAM_THROTTLE_MS
)
const {
permission: nativeChatPermission,
question: nativeChatQuestion,
ask: nativeChatAsk
} = useMobileNativeChatPrompts({
enabled: activeChatResolution != null,
status: nativeChatStatus,
messages: nativeChatSession.messages
})
const handleNativeChatOpenFile = useCallback(
(pathText: string) => {
if (!client) {
return
}
void openMobileNativeChatFile({
client,
worktreeId,
pathText,
terminal: activeHandleRef.current
})
},
[activeHandleRef, client, worktreeId]
)
const { answerAsk: handleNativeChatAnswerAsk, cancelPending: cancelNativeChatAnswer } =
useMobileNativeChatAnswerSend({
client,
enabled: nativeChatInputLeaseReady,
handleRef: activeHandleRef,
deviceTokenRef,
agentRef: activeChatAgentRef,
sessionId: activeChatSessionId,
streamIdentity,
onSendError
})
const handleNativeChatCancelAsk = useCallback(async (): Promise<boolean> => {
const handle = activeHandleRef.current
if (!client || !handle || !nativeChatInputLeaseReady) {
onSendError('Cancel not sent (disconnected)')
return false
}
cancelNativeChatAnswer()
const accepted = await sendMobileNativeChatMessage({
client,
terminal: handle,
text: String.fromCharCode(27),
enter: false,
...(deviceTokenRef.current
? { mobileClient: { id: deviceTokenRef.current, type: 'mobile' } }
: {})
})
if (!accepted) {
onSendError('Cancel not sent')
}
return accepted
}, [
activeHandleRef,
cancelNativeChatAnswer,
client,
deviceTokenRef,
nativeChatInputLeaseReady,
onSendError
])
const handleNativeChatRespondPermission = useMobileNativeChatPermissionSend({
client,
enabled: nativeChatInputLeaseReady,
handleRef: activeHandleRef,
deviceTokenRef,
onSendError
})
const handleNativeChatStop = useMobileNativeChatStop({
client,
enabled: nativeChatInputLeaseReady,
handleRef: activeHandleRef,
deviceTokenRef,
streamIdentity,
cancelPending: cancelNativeChatAnswer,
onSendError
})
const { nativeChatFilePaths, loadNativeChatFiles } = useMobileNativeChatFileSearch({
client,
worktreeId
})
const handleNativeChatSend = useCallback(
async (text: string): Promise<boolean> => {
const handle = activeHandleRef.current
const origin = captureSendOrigin(text)
if (!client || !handle || !origin || !nativeChatInputLeaseReady) {
onSendError('Message not sent (disconnected)')
return false
}
const accepted = await sendMobileNativeChatMessage({
client,
terminal: handle,
text,
...(deviceTokenRef.current
? { mobileClient: { id: deviceTokenRef.current, type: 'mobile' } }
: {})
})
if (!accepted) {
onSendError('Message not sent')
return false
}
acceptSend(origin, text)
return true
},
[
acceptSend,
activeHandleRef,
captureSendOrigin,
client,
deviceTokenRef,
nativeChatInputLeaseReady,
onSendError
]
)
return {
chatTabIds,
toggleTabChatView,
showNativeChat,
showNativeChatRef,
nativeChatAgent: activeChatResolution?.agent ?? null,
chatComposerText,
setChatComposerText,
chatPending,
nativeChatSession,
nativeChatAgentWorking,
nativeChatStreamingText,
nativeChatPermission,
nativeChatQuestion,
nativeChatAsk,
handleNativeChatOpenFile,
handleNativeChatAnswerAsk,
handleNativeChatCancelAsk,
handleNativeChatRespondPermission,
handleNativeChatStop,
nativeChatFilePaths,
loadNativeChatFiles,
handleNativeChatSend
}
}
@@ -0,0 +1,192 @@
import { createElement } 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-types'
import { useMobileNativeChatDrafts } from './use-mobile-native-chat-drafts'
type DraftState = ReturnType<typeof useMobileNativeChatDrafts>
function userTextMessage(id: string, text: string): NativeChatMessage {
return {
id,
role: 'user',
blocks: [{ type: 'text', text }],
timestamp: null,
source: 'transcript'
}
}
function assistantTextMessage(id: string, text: string): NativeChatMessage {
return {
id,
role: 'assistant',
blocks: [{ type: 'text', text }],
timestamp: null,
source: 'transcript'
}
}
describe('useMobileNativeChatDrafts', () => {
let renderer: ReactTestRenderer | null = null
let state: DraftState | null = null
beforeEach(() => {
globalThis.IS_REACT_ACT_ENVIRONMENT = true
})
afterEach(() => {
act(() => renderer?.unmount())
renderer = null
state = null
})
function Harness({
tabId,
sessionId = `session-${tabId}`,
messages = []
}: {
tabId: string
sessionId?: string | null
messages?: NativeChatMessage[]
}): null {
state = useMobileNativeChatDrafts({
hostId: 'host',
worktreeId: 'worktree',
tabId,
sessionId,
messages
})
return null
}
async function mount(tabId: string): Promise<void> {
const original = console.error
const consoleSpy = vi.spyOn(console, 'error').mockImplementation((...args) => {
if (typeof args[0] === 'string' && args[0].includes('react-test-renderer is deprecated')) {
return
}
original(...args)
})
try {
await act(async () => {
renderer = create(createElement(Harness, { tabId }))
})
} finally {
consoleSpy.mockRestore()
}
}
async function switchTo(tabId: string): Promise<void> {
await act(async () => renderer?.update(createElement(Harness, { tabId })))
}
it('keeps drafts and accepted pending messages on their originating tabs', async () => {
await mount('a')
act(() => state?.setComposerText('from a'))
const originA = state?.captureSendOrigin('from a')
expect(originA).not.toBeNull()
await switchTo('b')
act(() => state?.setComposerText('from b'))
act(() => {
if (originA) {
state?.acceptSend(originA, 'from a')
}
})
expect(state?.composerText).toBe('from b')
expect(state?.pending).toEqual([])
await switchTo('a')
expect(state?.composerText).toBe('')
expect(state?.pending.map((pending) => pending.text)).toEqual(['from a'])
})
it('clears one pending per landed message so duplicate sends are not all dropped', async () => {
await mount('a')
const origin = state?.captureSendOrigin('ping')
act(() => {
if (origin) {
state?.acceptSend(origin, 'ping')
state?.acceptSend(origin, 'ping')
}
})
expect(state?.pending.map((pending) => pending.text)).toEqual(['ping', 'ping'])
await act(async () =>
renderer?.update(
createElement(Harness, { tabId: 'a', messages: [userTextMessage('m1', 'ping')] })
)
)
expect(state?.pending.map((pending) => pending.text)).toEqual(['ping'])
})
it('does not reconcile a repeated send against an older identical turn', async () => {
await mount('a')
await act(async () =>
renderer?.update(
createElement(Harness, { tabId: 'a', messages: [userTextMessage('old', 'ping')] })
)
)
const origin = state?.captureSendOrigin('ping')
act(() => {
if (origin) {
state?.acceptSend(origin, 'ping')
}
})
await act(async () =>
renderer?.update(
createElement(Harness, {
tabId: 'a',
messages: [userTextMessage('old', 'ping'), assistantTextMessage('other', 'working')]
})
)
)
expect(state?.pending.map((pending) => pending.text)).toEqual(['ping'])
await act(async () =>
renderer?.update(
createElement(Harness, {
tabId: 'a',
messages: [
userTextMessage('old', 'ping'),
assistantTextMessage('other', 'working'),
userTextMessage('new', 'ping')
]
})
)
)
expect(state?.pending).toEqual([])
})
it('does not erase newer edits when an older send settles', async () => {
await mount('a')
act(() => state?.setComposerText('submitted'))
const origin = state?.captureSendOrigin('submitted')
act(() => state?.setComposerText('new edit'))
act(() => {
if (origin) {
state?.acceptSend(origin, 'submitted')
}
})
expect(state?.composerText).toBe('new edit')
})
it('accepts and clears the first send before a provider session id exists', async () => {
await mount('a')
await act(async () => renderer?.update(createElement(Harness, { tabId: 'a', sessionId: null })))
act(() => state?.setComposerText('start the session'))
const origin = state?.captureSendOrigin('start the session')
expect(origin).toMatchObject({ pendingKey: null })
act(() => {
if (origin) {
state?.acceptSend(origin, 'start the session')
}
})
expect(state?.composerText).toBe('')
expect(state?.pending).toEqual([])
})
})
@@ -0,0 +1,165 @@
import { useCallback, useEffect, useRef, useState, type Dispatch, type SetStateAction } from 'react'
import type { NativeChatMessage } from '../../../src/shared/native-chat-types'
export type MobileNativeChatPendingMessage = {
id: string
text: string
expectedOccurrence: number
}
export type MobileNativeChatSendOrigin = {
draftKey: string
pendingKey: string | null
normalizedText: string
baselineOccurrences: number
}
const NO_PENDING_MESSAGES: MobileNativeChatPendingMessage[] = []
function normalizedUserText(message: NativeChatMessage): string | null {
if (message.role !== 'user') {
return null
}
const text = message.blocks
.filter((block) => block.type === 'text')
.map((block) => (block.type === 'text' ? block.text : ''))
.join('')
.trim()
return text || null
}
function countUserTextOccurrences(messages: readonly NativeChatMessage[], text: string): number {
let count = 0
for (const message of messages) {
if (normalizedUserText(message) === text) {
count++
}
}
return count
}
export function useMobileNativeChatDrafts(args: {
hostId: string
worktreeId: string
tabId: string | null
sessionId: string | null
messages: readonly NativeChatMessage[]
}): {
composerText: string
setComposerText: Dispatch<SetStateAction<string>>
pending: MobileNativeChatPendingMessage[]
captureSendOrigin: (text: string) => MobileNativeChatSendOrigin | null
acceptSend: (origin: MobileNativeChatSendOrigin, text: string) => void
} {
const { hostId, worktreeId, tabId, sessionId, messages } = args
const draftKey = tabId ? `${hostId}\0${worktreeId}\0${tabId}` : null
const pendingKey = draftKey && sessionId ? `${draftKey}\0${sessionId}` : null
const [drafts, setDrafts] = useState<Record<string, string>>({})
const [pendingBySession, setPendingBySession] = useState<
Record<string, MobileNativeChatPendingMessage[]>
>({})
const pendingCounterRef = useRef(0)
const messagesRef = useRef(messages)
messagesRef.current = messages
const setComposerText: Dispatch<SetStateAction<string>> = useCallback(
(value) => {
if (!draftKey) {
return
}
setDrafts((previous) => {
const current = previous[draftKey] ?? ''
const next = typeof value === 'function' ? value(current) : value
return next === current ? previous : { ...previous, [draftKey]: next }
})
},
[draftKey]
)
const captureSendOrigin = useCallback(
(text: string) => {
if (!draftKey) {
return null
}
const normalizedText = text.trim()
return {
draftKey,
pendingKey,
normalizedText,
baselineOccurrences: countUserTextOccurrences(messagesRef.current, normalizedText)
}
},
[draftKey, pendingKey]
)
const acceptSend = useCallback((origin: MobileNativeChatSendOrigin, text: string) => {
// Why: an RPC may settle after a tab switch; mutate only the tab that
// originated the send, without erasing edits typed after it began.
setDrafts((previous) =>
(previous[origin.draftKey] ?? '').trim() === text.trim()
? { ...previous, [origin.draftKey]: '' }
: previous
)
// Why: the first prompt can be sent before the provider reports a session
// id; clear its draft, but wait for an id before keying an optimistic echo.
if (!origin.pendingKey) {
return
}
const pendingKey = origin.pendingKey
pendingCounterRef.current += 1
setPendingBySession((previous) => {
const current = previous[pendingKey] ?? NO_PENDING_MESSAGES
const earlierOutstanding = current.filter(
(pending) =>
pending.text.trim() === origin.normalizedText &&
pending.expectedOccurrence > origin.baselineOccurrences
).length
const pending = {
id: `pending-${pendingCounterRef.current}`,
text,
expectedOccurrence: origin.baselineOccurrences + earlierOutstanding + 1
}
return { ...previous, [pendingKey]: [...current, pending] }
})
}, [])
const pending = pendingKey
? (pendingBySession[pendingKey] ?? NO_PENDING_MESSAGES)
: NO_PENDING_MESSAGES
useEffect(() => {
if (!pendingKey || pending.length === 0) {
return
}
setPendingBySession((previous) => {
const current = previous[pendingKey] ?? []
const landedCounts = new Map<string, number>()
for (const message of messages) {
const text = normalizedUserText(message)
if (text) {
landedCounts.set(text, (landedCounts.get(text) ?? 0) + 1)
}
}
// Why: compare against the count captured before send; historical equal
// turns cannot clear a new echo, while duplicates land one occurrence each.
const next = current.filter(
(item) => (landedCounts.get(item.text.trim()) ?? 0) < item.expectedOccurrence
)
if (next.length === current.length) {
return previous
}
if (next.length > 0) {
return { ...previous, [pendingKey]: next }
}
const remaining = { ...previous }
delete remaining[pendingKey]
return remaining
})
}, [messages, pending, pendingKey])
return {
composerText: draftKey ? (drafts[draftKey] ?? '') : '',
setComposerText,
pending,
captureSendOrigin,
acceptSend
}
}
@@ -0,0 +1,165 @@
import { createElement } from 'react'
import { act, create, type ReactTestRenderer } from 'react-test-renderer'
import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest'
import type { RpcClient } from '../transport/rpc-client'
import { useMobileNativeChatFileSearch } from './use-mobile-native-chat-file-search'
type SearchState = ReturnType<typeof useMobileNativeChatFileSearch>
function rpcSuccess(files: string[]): Awaited<ReturnType<RpcClient['sendRequest']>> {
return {
id: 'files',
ok: true,
result: { files: files.map((relativePath) => ({ relativePath })) },
_meta: { runtimeId: 'runtime-1' }
}
}
function suppressRendererWarning(): () => void {
const original = console.error
const spy = vi.spyOn(console, 'error').mockImplementation((...args) => {
if (typeof args[0] === 'string' && args[0].includes('react-test-renderer is deprecated')) {
return
}
original(...args)
})
return () => spy.mockRestore()
}
describe('useMobileNativeChatFileSearch', () => {
let renderer: ReactTestRenderer | null = null
let state: SearchState | null = null
async function mount(client: RpcClient): Promise<void> {
function Harness(): null {
state = useMobileNativeChatFileSearch({ client, worktreeId: 'wt-1' })
return null
}
const restore = suppressRendererWarning()
try {
await act(async () => {
renderer = create(createElement(Harness))
})
} finally {
restore()
}
}
beforeEach(() => {
vi.useFakeTimers()
globalThis.IS_REACT_ACT_ENVIRONMENT = true
state = null
})
afterEach(() => {
act(() => renderer?.unmount())
renderer = null
vi.useRealTimers()
})
it('coalesces rapid queries and retains only the bounded host result', async () => {
const sendRequest = vi.fn().mockResolvedValue(rpcSuccess(['src/app.ts', 'src/app.test.ts']))
await mount({ sendRequest } as unknown as RpcClient)
act(() => {
state?.loadNativeChatFiles('a')
state?.loadNativeChatFiles('app')
})
await act(async () => vi.advanceTimersByTimeAsync(119))
expect(sendRequest).not.toHaveBeenCalled()
await act(async () => vi.advanceTimersByTimeAsync(1))
expect(sendRequest).toHaveBeenCalledTimes(1)
expect(sendRequest).toHaveBeenCalledWith('files.searchPaths', {
worktree: 'id:wt-1',
query: 'app',
limit: 16
})
expect(state?.nativeChatFilePaths).toEqual(['src/app.ts', 'src/app.test.ts'])
})
it('loads the legacy inventory once when an older host lacks searchPaths', async () => {
const sendRequest = vi.fn(async (method: string) => {
if (method === 'files.searchPaths') {
return {
id: 'missing',
ok: false as const,
error: { code: 'method_not_found', message: 'Unknown method' },
_meta: { runtimeId: 'runtime-1' }
}
}
return rpcSuccess(['src/apple.ts', 'docs/readme.md'])
})
await mount({ sendRequest } as unknown as RpcClient)
act(() => state?.loadNativeChatFiles('apple'))
await act(async () => vi.advanceTimersByTimeAsync(120))
expect(state?.nativeChatFilePaths).toEqual(['src/apple.ts'])
act(() => state?.loadNativeChatFiles('readme'))
await act(async () => vi.advanceTimersByTimeAsync(120))
expect(state?.nativeChatFilePaths).toEqual(['docs/readme.md'])
expect(sendRequest.mock.calls.map(([method]) => method)).toEqual([
'files.searchPaths',
'files.list'
])
})
it('cancels an in-flight query on a cache hit so a stale result cannot clobber it', async () => {
const sendRequest = vi.fn(async (_method: string, params: { query: string }) =>
rpcSuccess(params.query === 'app' ? ['src/app.ts'] : ['src/beta.ts'])
)
await mount({ sendRequest } as unknown as RpcClient)
// Populate the cache for 'app'.
act(() => state?.loadNativeChatFiles('app'))
await act(async () => vi.advanceTimersByTimeAsync(120))
expect(state?.nativeChatFilePaths).toEqual(['src/app.ts'])
// Schedule 'beta' (debounced, unresolved), then hit the cache for 'app'.
act(() => {
state?.loadNativeChatFiles('beta')
state?.loadNativeChatFiles('app')
})
expect(state?.nativeChatFilePaths).toEqual(['src/app.ts'])
// The cancelled 'beta' request must never fire and overwrite the cached result.
await act(async () => vi.advanceTimersByTimeAsync(120))
expect(state?.nativeChatFilePaths).toEqual(['src/app.ts'])
expect(
sendRequest.mock.calls.filter(([, params]) => (params as { query: string }).query === 'beta')
).toHaveLength(0)
})
it('coalesces overlapping legacy inventory requests on a slow host', async () => {
let resolveList: (value: Awaited<ReturnType<RpcClient['sendRequest']>>) => void = () => {}
const listResponse = new Promise<Awaited<ReturnType<RpcClient['sendRequest']>>>((resolve) => {
resolveList = resolve
})
const sendRequest = vi.fn((method: string) => {
if (method === 'files.searchPaths') {
return Promise.resolve({
id: 'missing',
ok: false as const,
error: { code: 'method_not_found', message: 'Unknown method' },
_meta: { runtimeId: 'runtime-1' }
})
}
return listResponse
})
await mount({ sendRequest } as unknown as RpcClient)
act(() => state?.loadNativeChatFiles('apple'))
await act(async () => vi.advanceTimersByTimeAsync(120))
act(() => state?.loadNativeChatFiles('readme'))
await act(async () => vi.advanceTimersByTimeAsync(120))
expect(sendRequest.mock.calls.filter(([method]) => method === 'files.list')).toHaveLength(1)
await act(async () => {
resolveList(rpcSuccess(['src/apple.ts', 'docs/readme.md']))
await Promise.resolve()
await Promise.resolve()
})
expect(state?.nativeChatFilePaths).toEqual(['docs/readme.md'])
})
})
@@ -0,0 +1,146 @@
import { useCallback, useEffect, useRef, useState } from 'react'
import type { RpcClient } from '../transport/rpc-client'
import { rankSuggestions } from './mobile-native-chat-autocomplete'
const FILE_SEARCH_DEBOUNCE_MS = 120
const FILE_SEARCH_RESULT_LIMIT = 16
const FILE_SEARCH_QUERY_CACHE_LIMIT = 20
function extractPaths(result: unknown): string[] {
const files = (result as { files?: Array<{ relativePath?: string }> }).files ?? []
return files
.map((file) => file.relativePath ?? '')
.filter((path): path is string => path.length > 0)
}
/** Debounces current-host path searches, bounds the mobile result/cache, and
* falls back to the legacy one-time full list when paired to an older host. */
export function useMobileNativeChatFileSearch(args: {
client: RpcClient | null
worktreeId: string
}): { nativeChatFilePaths: string[]; loadNativeChatFiles: (query: string) => void } {
const { client, worktreeId } = args
const [nativeChatFilePaths, setNativeChatFilePaths] = useState<string[]>([])
const timerRef = useRef<ReturnType<typeof setTimeout> | null>(null)
const sequenceRef = useRef(0)
const generationRef = useRef(0)
const queryCacheRef = useRef(new Map<string, string[]>())
const legacyPathsRef = useRef<string[] | null>(null)
const legacyLoadRef = useRef<Promise<string[] | null> | null>(null)
const searchSupportedRef = useRef<boolean | null>(null)
useEffect(() => {
sequenceRef.current++
generationRef.current++
queryCacheRef.current.clear()
legacyPathsRef.current = null
legacyLoadRef.current = null
searchSupportedRef.current = null
setNativeChatFilePaths([])
return () => {
if (timerRef.current) {
clearTimeout(timerRef.current)
timerRef.current = null
}
}
}, [client, worktreeId])
const loadNativeChatFiles = useCallback(
(query: string) => {
if (!client) {
return
}
const normalizedQuery = query.trim().toLowerCase().slice(0, 256)
const cached = queryCacheRef.current.get(normalizedQuery)
if (cached) {
// Why: cancel and stale-out any in-flight debounced query so an older
// request cannot later clobber this displayed cached result.
if (timerRef.current) {
clearTimeout(timerRef.current)
timerRef.current = null
}
sequenceRef.current++
setNativeChatFilePaths(cached)
return
}
if (timerRef.current) {
clearTimeout(timerRef.current)
}
const sequence = ++sequenceRef.current
const generation = generationRef.current
setNativeChatFilePaths([])
timerRef.current = setTimeout(() => {
timerRef.current = null
const applyPaths = (paths: string[]): void => {
if (sequenceRef.current !== sequence) {
return
}
queryCacheRef.current.set(normalizedQuery, paths)
while (queryCacheRef.current.size > FILE_SEARCH_QUERY_CACHE_LIMIT) {
const oldest = queryCacheRef.current.keys().next().value as string | undefined
if (!oldest) {
break
}
queryCacheRef.current.delete(oldest)
}
setNativeChatFilePaths(paths)
}
const loadLegacyPaths = async (): Promise<void> => {
if (!legacyPathsRef.current) {
if (!legacyLoadRef.current) {
const request = client
.sendRequest('files.list', { worktree: `id:${worktreeId}` })
.then((response) => {
if (!response.ok || generationRef.current !== generation) {
return null
}
const paths = extractPaths(response.result)
legacyPathsRef.current = paths
return paths
})
.finally(() => {
if (legacyLoadRef.current === request && !legacyPathsRef.current) {
legacyLoadRef.current = null
}
})
// Why: older hosts expose only the full inventory RPC; queries that
// overlap its slow local/SSH read must share one request.
legacyLoadRef.current = request
}
const paths = await legacyLoadRef.current
if (!paths) {
return
}
}
const legacyPaths = legacyPathsRef.current
if (legacyPaths) {
applyPaths(rankSuggestions(legacyPaths, normalizedQuery, FILE_SEARCH_RESULT_LIMIT))
}
}
void (async () => {
if (searchSupportedRef.current === false) {
await loadLegacyPaths()
return
}
const response = await client.sendRequest('files.searchPaths', {
worktree: `id:${worktreeId}`,
query: normalizedQuery,
limit: FILE_SEARCH_RESULT_LIMIT
})
if (response.ok) {
searchSupportedRef.current = true
applyPaths(extractPaths(response.result))
return
}
if (response.error.code === 'method_not_found') {
searchSupportedRef.current = false
await loadLegacyPaths()
}
})().catch(() => {})
}, FILE_SEARCH_DEBOUNCE_MS)
},
[client, worktreeId]
)
return { nativeChatFilePaths, loadNativeChatFiles }
}
@@ -0,0 +1,57 @@
import { createElement } from 'react'
import { act, create, type ReactTestRenderer } from 'react-test-renderer'
import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest'
import { useMobileNativeChatInputLease } from './use-mobile-native-chat-input-lease'
type Lease = ReturnType<typeof useMobileNativeChatInputLease>
describe('useMobileNativeChatInputLease', () => {
let renderer: ReactTestRenderer | null = null
let lease: Lease | null = null
beforeEach(() => {
globalThis.IS_REACT_ACT_ENVIRONMENT = true
})
afterEach(() => {
act(() => renderer?.unmount())
renderer = null
lease = null
})
function Harness({ connected }: { connected: boolean }): null {
lease = useMobileNativeChatInputLease({ activeHandle: 'terminal', connected })
return null
}
it('unlocks only after acknowledgement and clears on disconnect', async () => {
const original = console.error
const consoleSpy = vi.spyOn(console, 'error').mockImplementation((...args) => {
if (typeof args[0] === 'string' && args[0].includes('react-test-renderer is deprecated')) {
return
}
original(...args)
})
try {
await act(async () => {
renderer = create(createElement(Harness, { connected: true }))
})
expect(lease?.ready).toBe(false)
expect(lease?.lockReason).toBe('waiting')
act(() => lease?.markReady('terminal'))
expect(lease?.ready).toBe(true)
expect(lease?.lockReason).toBeNull()
act(() => lease?.clear())
expect(lease?.ready).toBe(false)
act(() => lease?.markReady('terminal'))
expect(lease?.ready).toBe(true)
await act(async () => renderer?.update(createElement(Harness, { connected: false })))
expect(lease?.ready).toBe(false)
expect(lease?.lockReason).toBe('disconnected')
} finally {
consoleSpy.mockRestore()
}
})
})
@@ -0,0 +1,53 @@
import { useCallback, useEffect, useRef, useState } from 'react'
import type { MobileNativeChatInputLockReason } from './MobileNativeChatView'
export function useMobileNativeChatInputLease(args: {
activeHandle: string | null
connected: boolean
}): {
ready: boolean
readyRef: { readonly current: boolean }
lockReason: MobileNativeChatInputLockReason | null
markReady: (handle: string) => void
clear: (handle?: string) => void
} {
const [readyHandles, setReadyHandles] = useState<Set<string>>(new Set())
const ready = args.activeHandle != null && readyHandles.has(args.activeHandle)
// Why: absence of an acknowledgement proves only that setup is still pending;
// the protocol does not report evidence that another client owns the floor.
const lockReason: MobileNativeChatInputLockReason | null = !args.connected
? 'disconnected'
: ready
? null
: 'waiting'
const readyRef = useRef(ready)
readyRef.current = ready
useEffect(() => {
if (!args.connected) {
setReadyHandles(new Set())
}
}, [args.connected])
const markReady = useCallback((handle: string) => {
setReadyHandles((current) => new Set(current).add(handle))
}, [])
const clear = useCallback((handle?: string) => {
setReadyHandles((current) => {
if (handle === undefined) {
return new Set()
}
if (!current.has(handle)) {
return current
}
const next = new Set(current)
next.delete(handle)
return next
})
}, [])
return {
ready,
readyRef,
lockReason,
markReady,
clear
}
}
@@ -0,0 +1,39 @@
import { useMemo, useRef, useState } from 'react'
import { Gesture } from 'react-native-gesture-handler'
import type { ComposedGesture } from 'react-native-gesture-handler'
import { clampFontScale } from './mobile-native-chat-message-text'
/** Pinch-to-zoom chat font. `fontScale` is the committed size; `pinchBase`
* anchors the live gesture so successive pinches compound rather than reset. */
export function useMobileNativeChatPinchGesture(): {
fontScale: number
pinchGesture: ComposedGesture
} {
const [fontScale, setFontScale] = useState(1)
const fontScaleRef = useRef(1)
fontScaleRef.current = fontScale
const pinchBase = useRef(1)
// Why: run the gesture callbacks on the JS thread (not a reanimated worklet) so
// they can touch React refs/state and clampFontScale directly — accessing those
// from the UI-thread worklet crashes the app.
// Compose the pinch with the list's native scroll as Simultaneous so a
// two-finger pinch is recognized even while the scroll view is active —
// otherwise the scroll grabs the gesture first and the pinch never fires.
const pinchGesture = useMemo(
() =>
Gesture.Simultaneous(
Gesture.Native(),
Gesture.Pinch()
.runOnJS(true)
.onStart(() => {
pinchBase.current = fontScaleRef.current
})
.onUpdate((e) => {
setFontScale(clampFontScale(pinchBase.current * e.scale))
})
),
[]
)
return { fontScale, pinchGesture }
}
@@ -0,0 +1,47 @@
import { useMemo } from 'react'
import type { AgentStatusEntry } from '../../../src/shared/agent-status-types'
import type { NativeChatMessage } from '../../../src/shared/native-chat-types'
import { extractPendingAsk, parseAskFromStatus } from './mobile-native-chat-ask'
import { detectAgentPermission, parseApprovalFromStatus } from './mobile-native-chat-permission'
import { parseAgentQuestion } from './mobile-native-chat-question'
export type MobileNativeChatPrompts = {
permission: ReturnType<typeof detectAgentPermission>
question: ReturnType<typeof parseAgentQuestion>
ask: ReturnType<typeof parseAskFromStatus>
}
/** Derives the prompt cards shown above the composer. */
export function useMobileNativeChatPrompts(args: {
enabled: boolean
status: AgentStatusEntry | null | undefined
messages: readonly NativeChatMessage[]
}): MobileNativeChatPrompts {
const { enabled, status, messages } = args
const blocked = status?.state === 'waiting' || status?.state === 'blocked'
const permission =
(blocked && status
? detectAgentPermission({
state: status.state,
lastAssistantMessage: status.lastAssistantMessage,
toolName: status.toolName,
toolInput: status.toolInput
})
: null) ?? parseApprovalFromStatus(status?.interactivePrompt)
const question =
blocked && status && !permission ? parseAgentQuestion(status.lastAssistantMessage ?? '') : null
const askFromStatus = useMemo(
() => parseAskFromStatus(status?.interactivePrompt, status?.toolName),
[status?.interactivePrompt, status?.toolName]
)
const askFromMessages = useMemo(
() => (askFromStatus ? null : extractPendingAsk(messages)),
[askFromStatus, messages]
)
return {
permission,
question,
ask: enabled ? (askFromStatus ?? askFromMessages) : null
}
}
@@ -0,0 +1,107 @@
import { createElement } from 'react'
import { act, create, type ReactTestRenderer } from 'react-test-renderer'
import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest'
import type { RpcClient } from '../transport/rpc-client'
import { useMobileNativeChatReadability } from './use-mobile-native-chat-readability'
describe('useMobileNativeChatReadability', () => {
let renderer: ReactTestRenderer | null = null
let readable = false
beforeEach(() => {
globalThis.IS_REACT_ACT_ENVIRONMENT = true
readable = false
})
afterEach(() => {
act(() => renderer?.unmount())
renderer = null
})
async function mount(connectionId: string | null): Promise<void> {
const client = {
sendRequest: vi.fn().mockResolvedValue({
ok: true,
result: { repos: [{ id: 'repo', connectionId }] }
})
} as unknown as RpcClient
function Harness(): null {
readable = useMobileNativeChatReadability(client, 'repo::/worktree')
return null
}
const original = console.error
const consoleSpy = vi.spyOn(console, 'error').mockImplementation((...args) => {
if (typeof args[0] === 'string' && args[0].includes('react-test-renderer is deprecated')) {
return
}
original(...args)
})
try {
await act(async () => {
renderer = create(createElement(Harness))
await Promise.resolve()
})
} finally {
consoleSpy.mockRestore()
}
}
it('admits local and runtime-owned transcript hosts', async () => {
await mount(null)
expect(readable).toBe(true)
act(() => renderer?.unmount())
renderer = null
await mount('runtime-ssh-environment')
expect(readable).toBe(true)
})
it('fails closed for Model-A SSH transcript hosts', async () => {
await mount('model-a-ssh')
expect(readable).toBe(false)
})
it('fails closed immediately while a reused route resolves its new worktree', async () => {
let resolveNext: (response: unknown) => void = () => {}
const client = {
sendRequest: vi
.fn()
.mockResolvedValueOnce({
ok: true,
result: { repos: [{ id: 'local-repo', connectionId: null }] }
})
.mockImplementationOnce(() => new Promise((resolve) => (resolveNext = resolve)))
} as unknown as RpcClient
function Harness({ worktreeId }: { worktreeId: string }): null {
readable = useMobileNativeChatReadability(client, worktreeId)
return null
}
const original = console.error
const consoleSpy = vi.spyOn(console, 'error').mockImplementation((...args) => {
if (typeof args[0] === 'string' && args[0].includes('react-test-renderer is deprecated')) {
return
}
original(...args)
})
try {
await act(async () => {
renderer = create(createElement(Harness, { worktreeId: 'local-repo::/one' }))
await Promise.resolve()
})
expect(readable).toBe(true)
act(() => renderer?.update(createElement(Harness, { worktreeId: 'ssh-repo::/two' })))
expect(readable).toBe(false)
await act(async () => {
resolveNext({
ok: true,
result: { repos: [{ id: 'ssh-repo', connectionId: 'model-a-ssh' }] }
})
await Promise.resolve()
})
expect(readable).toBe(false)
} finally {
consoleSpy.mockRestore()
}
})
})
@@ -0,0 +1,53 @@
import { useEffect, useState } from 'react'
import type { RpcClient } from '../transport/rpc-client'
import { isMobileNativeChatTranscriptReadable } from './mobile-native-chat-eligibility'
import { getRepoIdFromMobileWorktreeId } from './mobile-session-route-helpers'
type RepoSummary = { id: string; connectionId?: string | null }
type ReadabilityState = { client: RpcClient | null; worktreeId: string; readable: boolean }
export function useMobileNativeChatReadability(
client: RpcClient | null,
worktreeId: string
): boolean {
const [state, setState] = useState<ReadabilityState>({
client: null,
worktreeId: '',
readable: false
})
useEffect(() => {
let active = true
if (!client) {
setState({ client, worktreeId, readable: false })
return
}
void client
.sendRequest('repo.list')
.then((response) => {
if (!active) {
return
}
const repos = response.ok
? ((response.result as { repos?: RepoSummary[] }).repos ?? [])
: []
const repoId = getRepoIdFromMobileWorktreeId(worktreeId)
const repo = repos.find((candidate) => candidate.id === repoId)
setState({
client,
worktreeId,
readable: repo ? isMobileNativeChatTranscriptReadable(repo.connectionId ?? null) : false
})
})
.catch(() => {
if (active) {
setState({ client, worktreeId, readable: false })
}
})
return () => {
active = false
}
}, [client, worktreeId])
// Why: route reuse renders before its new effect resolves; never expose the
// previous repo's readability under a different client/worktree key.
return state.client === client && state.worktreeId === worktreeId ? state.readable : false
}
@@ -0,0 +1,220 @@
import { createElement } 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-types'
import type { RpcClient } from '../transport/rpc-client'
import {
useMobileNativeChatSession,
type MobileNativeChatSession
} from './use-mobile-native-chat-session'
function message(id: string): NativeChatMessage {
return {
id,
role: 'assistant',
blocks: [{ type: 'text', text: id }],
timestamp: 1,
source: 'transcript'
}
}
describe('useMobileNativeChatSession', () => {
let renderer: ReactTestRenderer | null = null
let state: MobileNativeChatSession | null = null
let emit: (frame: unknown) => void = () => {}
beforeEach(() => {
globalThis.IS_REACT_ACT_ENVIRONMENT = true
state = null
})
afterEach(() => {
act(() => renderer?.unmount())
renderer = null
})
function Harness({ client }: { client: RpcClient | null }): null {
state = useMobileNativeChatSession({
client,
agent: 'claude',
sessionId: 'session',
transcriptPath: null
})
return null
}
async function mount(client: RpcClient): Promise<void> {
const original = console.error
const consoleSpy = vi.spyOn(console, 'error').mockImplementation((...args) => {
if (typeof args[0] === 'string' && args[0].includes('react-test-renderer is deprecated')) {
return
}
original(...args)
})
try {
await act(async () => {
renderer = create(createElement(Harness, { client }))
})
} finally {
consoleSpy.mockRestore()
}
}
it('drops an older-page response captured before transcript replacement', async () => {
let resolveEarlier: (response: unknown) => void = () => {}
const sendRequest = vi.fn(
() => new Promise((resolve) => (resolveEarlier = resolve))
) as unknown as RpcClient['sendRequest']
const subscribe: RpcClient['subscribe'] = vi.fn((_method, _params, onData) => {
emit = onData
onData({
type: 'snapshot',
messages: Array.from({ length: 40 }, (_unused, index) => message(`old-${index}`)),
hasMore: true,
beforeOffset: 100
})
return () => {}
})
await mount({ sendRequest, subscribe } as unknown as RpcClient)
act(() => state?.loadEarlier())
await act(async () => {
emit({
type: 'replacement',
messages: [message('replacement')],
hasMore: false,
beforeOffset: 0
})
})
await act(async () => {
resolveEarlier({
ok: true,
result: { messages: [message('stale-page')], hasMore: false, beforeOffset: 0 }
})
await Promise.resolve()
})
expect(state?.messages.map((entry) => entry.id)).toEqual(['replacement'])
expect(state?.loadingEarlier).toBe(false)
})
it('drops an older-page response after the client source disappears', async () => {
let resolveEarlier: (response: unknown) => void = () => {}
const sendRequest = vi.fn(
() => new Promise((resolve) => (resolveEarlier = resolve))
) as unknown as RpcClient['sendRequest']
const subscribe: RpcClient['subscribe'] = vi.fn((_method, _params, onData) => {
onData({
type: 'snapshot',
messages: Array.from({ length: 40 }, (_unused, index) => message(`old-${index}`)),
hasMore: true,
beforeOffset: 100
})
return () => {}
})
await mount({ sendRequest, subscribe } as unknown as RpcClient)
act(() => state?.loadEarlier())
await act(async () => renderer?.update(createElement(Harness, { client: null })))
await act(async () => {
resolveEarlier({ ok: true, result: { messages: [message('stale-page')] } })
await Promise.resolve()
})
expect(state?.messages).toEqual([])
expect(state?.status).toBe('idle')
expect(state?.loadingEarlier).toBe(false)
})
it.each(['replacement', 'snapshot'] as const)(
'can page again after an authoritative %s resets a maxed-out read window',
async (frameType) => {
const sendRequest = vi.fn().mockResolvedValue({
ok: true,
result: { messages: [message('older')], hasMore: true, beforeOffset: 50 }
})
const subscribe: RpcClient['subscribe'] = vi.fn((_method, _params, onData) => {
emit = onData
onData({
type: 'snapshot',
messages: Array.from({ length: 40 }, (_unused, index) => message(`old-${index}`)),
hasMore: true,
beforeOffset: 100
})
return () => {}
})
await mount({ sendRequest, subscribe } as unknown as RpcClient)
for (let page = 0; page < 33; page += 1) {
await act(async () => {
state?.loadEarlier()
await Promise.resolve()
})
}
const requestsAtCap = sendRequest.mock.calls.length
await act(async () =>
emit({
type: frameType,
messages: [message('authoritative')],
hasMore: true,
beforeOffset: 500
})
)
await act(async () => {
state?.loadEarlier()
await Promise.resolve()
})
expect(sendRequest).toHaveBeenCalledTimes(requestsAtCap + 1)
expect(sendRequest).toHaveBeenLastCalledWith('nativeChat.readSession', {
agent: 'claude',
sessionId: 'session',
limit: 60,
beforeOffset: 500
})
}
)
it('rejects a cursor page invalidated by live trim and retries with a growing tail', async () => {
let resolveCursorPage: (response: unknown) => void = () => {}
const sendRequest = vi
.fn()
.mockImplementationOnce(() => new Promise((resolve) => (resolveCursorPage = resolve)))
.mockResolvedValueOnce({
ok: true,
result: { messages: [message('fresh-growing-tail')], hasMore: false }
})
const subscribe: RpcClient['subscribe'] = vi.fn((_method, _params, onData) => {
emit = onData
onData({
type: 'snapshot',
messages: Array.from({ length: 40 }, (_unused, index) => message(`window-${index}`)),
hasMore: true,
beforeOffset: 100
})
return () => {}
})
await mount({ sendRequest, subscribe } as unknown as RpcClient)
act(() => state?.loadEarlier())
await act(async () => emit({ type: 'appended', messages: [message('live-trim')] }))
await act(async () => {
resolveCursorPage({
ok: true,
result: { messages: [message('stale-cursor-page')], hasMore: true, beforeOffset: 50 }
})
await Promise.resolve()
})
expect(state?.messages.map((entry) => entry.id)).not.toContain('stale-cursor-page')
await act(async () => {
state?.loadEarlier()
await Promise.resolve()
})
expect(sendRequest).toHaveBeenLastCalledWith('nativeChat.readSession', {
agent: 'claude',
sessionId: 'session',
limit: 100
})
expect(state?.messages.map((entry) => entry.id)).toEqual(['fresh-growing-tail'])
})
})
@@ -0,0 +1,219 @@
import { useCallback, useEffect, useRef, useState } from 'react'
import type { NativeChatMessage } from '../../../src/shared/native-chat-types'
import { buildNativeChatSubscriptionId } from '../../../src/shared/native-chat-stream-unsubscribe'
import type { RpcClient } from '../transport/rpc-client'
import { createNativeChatMerger, replaceList } from './mobile-native-chat-merge'
import {
applyMobileNativeChatStreamFrame,
type MobileNativeChatStreamFrame
} from './mobile-native-chat-stream-frame'
export type MobileNativeChatStatus = 'idle' | 'loading' | 'waiting-session' | 'ready' | 'error'
export type MobileNativeChatSession = {
messages: NativeChatMessage[]
status: MobileNativeChatStatus
error?: string
/** True when an older page may exist (the last read filled the window). */
hasMore: boolean
/** Whether an older-history page is currently loading. */
loadingEarlier: boolean
/** Grow the window to page in older history. */
loadEarlier: () => void
}
// Small first page for a fast first paint; grows by a page as the user scrolls.
const INITIAL_LIMIT = 40
const PAGE = 60
const MAX_MESSAGES = 2000
type ReadSessionResult =
| { messages: NativeChatMessage[]; hasMore?: boolean; beforeOffset?: number }
| { error: string }
/** Subscribe to an agent's native-chat transcript over the paired connection.
* Reads a small recent window for a fast first paint, tails it for live turns,
* and pages in older history on demand. Read results replace the list (they are
* an ordered tail); live appends merge by id so order stays stable. */
export function useMobileNativeChatSession(args: {
client: RpcClient | null
agent: string | null
sessionId: string | null
transcriptPath: string | null
}): MobileNativeChatSession {
const { client, agent, sessionId, transcriptPath } = args
const [messages, setMessages] = useState<NativeChatMessage[]>([])
const [status, setStatus] = useState<MobileNativeChatStatus>('idle')
const [error, setError] = useState<string | undefined>(undefined)
const [hasMore, setHasMore] = useState(false)
const [loadingEarlier, setLoadingEarlier] = useState(false)
const loadingEarlierRef = useRef(false)
const beforeOffsetRef = useRef<number | null>(null)
// Stateful id-dedup merger: caches the id→index map so each live append frame
// costs O(incoming), not O(existing+incoming) (#18). `replaceList` resets the
// base (read / loadEarlier ordered tails); `applyAppend` folds live frames in.
const mergerRef = useRef(createNativeChatMerger())
const limitRef = useRef(INITIAL_LIMIT)
// Tracks the live session so a late loadEarlier resolve can detect a swap.
const sessionIdRef = useRef<string | null>(sessionId)
sessionIdRef.current = sessionId
const streamGenerationRef = useRef(0)
// Replace the base list (read results are an ordered tail). Resets the merger
// cache so the index is rebuilt once over the new base.
const setList = useCallback((next: readonly NativeChatMessage[]) => {
replaceList(mergerRef.current, next)
setMessages(mergerRef.current.list)
}, [])
useEffect(() => {
let cancelled = false
// Why: disconnect/agent/session loss must invalidate a page request before
// the early idle/waiting return can clear the visible source.
streamGenerationRef.current += 1
limitRef.current = INITIAL_LIMIT
loadingEarlierRef.current = false
setLoadingEarlier(false)
setList([])
setError(undefined)
setHasMore(false)
beforeOffsetRef.current = null
if (!client || !agent) {
setStatus('idle')
return
}
if (!sessionId) {
setStatus('waiting-session')
return
}
setStatus('loading')
const unsubscribe = client.subscribe(
'nativeChat.subscribe',
{
agent,
sessionId,
limit: limitRef.current,
subscriptionId: buildNativeChatSubscriptionId(agent, sessionId),
...(transcriptPath ? { transcriptPath } : {})
},
(raw) => {
if (cancelled) {
return
}
const frame = raw as MobileNativeChatStreamFrame
if (frame.type === 'replacement' || frame.type === 'snapshot') {
// Why: replacement and reconnect snapshots are authoritative windows;
// stale page limits/results must not constrain the fresh generation.
streamGenerationRef.current += 1
limitRef.current = INITIAL_LIMIT
loadingEarlierRef.current = false
setLoadingEarlier(false)
}
const replaceSnapshot = frame.type === 'snapshot'
const applied = applyMobileNativeChatStreamFrame({
merger: mergerRef.current,
frame,
limit: limitRef.current,
replaceSnapshot
})
if (applied.kind === 'ignored') {
return
}
if (applied.kind === 'error') {
setStatus('error')
setError(applied.error)
return
}
setMessages(applied.messages)
if (applied.hasMore != null) {
setHasMore(applied.hasMore)
}
if (applied.beforeOffset != null) {
beforeOffsetRef.current = applied.beforeOffset
}
if (applied.cursorInvalidated) {
// Fall back to a growing-tail read so history trimmed by live appends
// cannot leave a gap between the retained window and the old cursor.
streamGenerationRef.current += 1
loadingEarlierRef.current = false
setLoadingEarlier(false)
beforeOffsetRef.current = null
}
setStatus('ready')
}
)
return () => {
cancelled = true
unsubscribe()
}
}, [client, agent, sessionId, transcriptPath, setList])
const loadEarlier = useCallback(() => {
if (!client || !agent || !sessionId || loadingEarlierRef.current || !hasMore) {
return
}
// Capture the session this page belongs to; a swap underneath us must not
// apply this read's result onto the new session (mirrors desktop's guard).
const requestSessionId = sessionId
const requestGeneration = streamGenerationRef.current
const nextLimit = Math.min(limitRef.current + PAGE, MAX_MESSAGES)
const pageLimit = nextLimit - limitRef.current
if (pageLimit <= 0) {
setHasMore(false)
return
}
const beforeOffset = beforeOffsetRef.current
loadingEarlierRef.current = true
setLoadingEarlier(true)
void (async () => {
try {
const response = await client.sendRequest('nativeChat.readSession', {
agent,
sessionId,
limit: beforeOffset === null ? nextLimit : pageLimit,
...(beforeOffset === null ? {} : { beforeOffset }),
...(transcriptPath ? { transcriptPath } : {})
})
if (!response.ok) {
return
}
const result = response.result as ReadSessionResult
if ('error' in result) {
return
}
// Drop a stale resolve from a session that swapped underneath us.
if (
sessionIdRef.current !== requestSessionId ||
streamGenerationRef.current !== requestGeneration
) {
return
}
limitRef.current = nextLimit
if (beforeOffset !== null && result.beforeOffset != null) {
beforeOffsetRef.current = result.beforeOffset
setList([...result.messages, ...mergerRef.current.list])
setHasMore(
nextLimit < MAX_MESSAGES && (result.hasMore ?? result.messages.length >= pageLimit)
)
} else {
// Older runtimes ignore the cursor and return the growing tail.
setList(result.messages)
setHasMore(result.messages.length >= nextLimit)
}
} finally {
// A late page from a prior tab must not unlock the current tab's request.
if (
sessionIdRef.current === requestSessionId &&
streamGenerationRef.current === requestGeneration
) {
loadingEarlierRef.current = false
setLoadingEarlier(false)
}
}
})()
}, [client, agent, sessionId, transcriptPath, hasMore, setList])
return { messages, status, error, hasMore, loadingEarlier, loadEarlier }
}
@@ -0,0 +1,85 @@
import { createElement } from 'react'
import { act, create, type ReactTestRenderer } from 'react-test-renderer'
import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest'
import type { RpcClient } from '../transport/rpc-client'
import { useMobileNativeChatStop } from './use-mobile-native-chat-stop'
describe('useMobileNativeChatStop', () => {
let renderer: ReactTestRenderer | null = null
let stop: (() => void) | null = null
const sendRequest = vi.fn()
const onSendError = vi.fn()
beforeEach(() => {
vi.useFakeTimers()
globalThis.IS_REACT_ACT_ENVIRONMENT = true
sendRequest.mockReset().mockResolvedValue({ ok: true })
onSendError.mockReset()
})
afterEach(() => {
act(() => renderer?.unmount())
renderer = null
stop = null
vi.useRealTimers()
})
function Harness({
enabled,
streamIdentity
}: {
enabled: boolean
streamIdentity: string
}): null {
stop = useMobileNativeChatStop({
client: { sendRequest } as unknown as RpcClient,
enabled,
handleRef: { current: 'terminal-1' },
deviceTokenRef: { current: 'mobile-1' },
streamIdentity,
cancelPending: vi.fn(),
onSendError
})
return null
}
async function render(enabled: boolean, streamIdentity: string): Promise<void> {
await act(async () => {
const element = createElement(Harness, { enabled, streamIdentity })
if (renderer) {
renderer.update(element)
} else {
renderer = create(element)
}
})
}
it.each([
['the acknowledged input lease is lost', false, 'stream-1'],
['the active stream changes', true, 'stream-2']
])('cancels the delayed second Escape when %s', async (_case, enabled, streamIdentity) => {
await render(true, 'stream-1')
act(() => stop?.())
expect(sendRequest).toHaveBeenCalledTimes(1)
await render(enabled as boolean, streamIdentity as string)
await act(async () => vi.runAllTimersAsync())
expect(sendRequest).toHaveBeenCalledTimes(1)
})
it('handles a rejected Escape without leaking an unhandled rejection', async () => {
sendRequest.mockRejectedValue(new Error('disconnected'))
await render(true, 'stream-1')
act(() => stop?.())
await act(async () => {
await Promise.resolve()
await vi.runAllTimersAsync()
})
expect(onSendError).toHaveBeenCalledOnce()
expect(onSendError).toHaveBeenCalledWith('Stop not sent')
})
})
@@ -0,0 +1,76 @@
import { useCallback, useEffect, useRef, type MutableRefObject } from 'react'
import type { RpcClient } from '../transport/rpc-client'
export function useMobileNativeChatStop(args: {
client: RpcClient | null
enabled: boolean
handleRef: MutableRefObject<string | null>
deviceTokenRef: MutableRefObject<string | null>
streamIdentity: string
cancelPending: () => void
onSendError: (message: string) => void
}): () => void {
const { client, enabled, handleRef, deviceTokenRef, streamIdentity, cancelPending, onSendError } =
args
const timerRef = useRef<ReturnType<typeof setTimeout> | null>(null)
const activeRouteRef = useRef({ client, enabled, streamIdentity })
activeRouteRef.current = { client, enabled, streamIdentity }
useEffect(() => {
if (!enabled && timerRef.current) {
clearTimeout(timerRef.current)
timerRef.current = null
}
return () => {
if (timerRef.current) {
clearTimeout(timerRef.current)
timerRef.current = null
}
}
}, [client, enabled, streamIdentity])
return useCallback(() => {
const handle = handleRef.current
if (!client || !handle || !enabled) {
onSendError('Stop not sent (terminal not ready)')
return
}
cancelPending()
if (timerRef.current) {
clearTimeout(timerRef.current)
}
const stopStreamIdentity = streamIdentity
let failureReported = false
const sendEscape = (): void => {
const activeRoute = activeRouteRef.current
if (
!activeRoute.enabled ||
activeRoute.client !== client ||
activeRoute.streamIdentity !== stopStreamIdentity ||
handleRef.current !== handle
) {
return
}
void client
.sendRequest('terminal.send', {
terminal: handle,
text: String.fromCharCode(27),
...(deviceTokenRef.current
? { client: { id: deviceTokenRef.current, type: 'mobile' as const } }
: {})
})
.catch(() => {
// Why: disconnect can race either fire-and-forget Escape; surface one
// failure instead of leaking an unhandled RPC rejection.
if (!failureReported) {
failureReported = true
onSendError('Stop not sent')
}
})
}
sendEscape()
// Why: two paced Escape bytes reliably stop TUIs without remote coalescing.
timerRef.current = setTimeout(() => {
timerRef.current = null
sendEscape()
}, 80)
}, [cancelPending, client, deviceTokenRef, enabled, handleRef, onSendError, streamIdentity])
}
@@ -0,0 +1,115 @@
import { createElement } from 'react'
import { act, create, type ReactTestRenderer } from 'react-test-renderer'
import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest'
import { useMobileNativeChatTerminalStream } from './use-mobile-native-chat-terminal-stream'
describe('useMobileNativeChatTerminalStream', () => {
let renderer: ReactTestRenderer | null = null
let harnessRenderCount = 0
const subscriptionsRef = { current: new Map<string, () => void>() }
const subscribingRef = { current: new Set<string>() }
const webReadyRef = { current: new Set(['terminal-1']) }
const initializedRef = { current: new Set(['terminal-1']) }
const subscribe = vi.fn((handle: string) => subscriptionsRef.current.set(handle, () => {}))
const unsubscribe = vi.fn((handle: string) => subscriptionsRef.current.delete(handle))
const notifyWebReadyRef = { current: (_handle: string, _wasAlreadyReady: boolean): void => {} }
beforeEach(() => {
globalThis.IS_REACT_ACT_ENVIRONMENT = true
subscriptionsRef.current = new Map([['terminal-1', () => {}]])
subscribingRef.current = new Set()
webReadyRef.current = new Set(['terminal-1'])
initializedRef.current = new Set(['terminal-1'])
harnessRenderCount = 0
subscribe.mockClear()
unsubscribe.mockClear()
})
afterEach(() => {
act(() => renderer?.unmount())
renderer = null
})
function Harness({ showNativeChat }: { showNativeChat: boolean }): null {
harnessRenderCount += 1
notifyWebReadyRef.current = useMobileNativeChatTerminalStream({
showNativeChat,
activeHandle: 'terminal-1',
activeTabType: 'terminal',
subscriptionsRef,
subscribingRef,
webReadyRef,
initializedRef,
subscribe,
unsubscribe
})
return null
}
it('replaces output with a lease-only stream while covered, then restores output', async () => {
const original = console.error
const consoleSpy = vi.spyOn(console, 'error').mockImplementation((...args) => {
if (typeof args[0] === 'string' && args[0].includes('react-test-renderer is deprecated')) {
return
}
original(...args)
})
try {
await act(async () => {
renderer = create(createElement(Harness, { showNativeChat: false }))
})
await act(async () => {
notifyWebReadyRef.current('terminal-1', false)
})
expect(harnessRenderCount).toBe(1)
await act(async () => {
renderer?.update(createElement(Harness, { showNativeChat: true }))
})
expect(unsubscribe).toHaveBeenNthCalledWith(1, 'terminal-1')
expect(subscribe).toHaveBeenNthCalledWith(1, 'terminal-1')
expect(initializedRef.current.has('terminal-1')).toBe(false)
await act(async () => {
renderer?.update(createElement(Harness, { showNativeChat: false }))
})
expect(unsubscribe).toHaveBeenNthCalledWith(2, 'terminal-1')
expect(subscribe).toHaveBeenNthCalledWith(2, 'terminal-1')
} finally {
consoleSpy.mockRestore()
}
})
it('resumes a cold-start lease-only stream when WebView readiness arrives late', async () => {
const original = console.error
const consoleSpy = vi.spyOn(console, 'error').mockImplementation((...args) => {
if (typeof args[0] === 'string' && args[0].includes('react-test-renderer is deprecated')) {
return
}
original(...args)
})
try {
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')
} finally {
consoleSpy.mockRestore()
}
})
})
@@ -0,0 +1,76 @@
import { useCallback, useEffect, useRef, useState, type MutableRefObject } from 'react'
import { resolveMobileNativeChatTerminalStreamAction } from './mobile-native-chat-terminal-stream'
/** Pauses the active terminal stream while native chat covers its mounted WebView,
* then resumes from a fresh scrollback snapshot when terminal view returns. */
export function useMobileNativeChatTerminalStream(args: {
showNativeChat: boolean
activeHandle: string | null
activeTabType: string | null
subscriptionsRef: MutableRefObject<Map<string, () => void>>
subscribingRef: MutableRefObject<Set<string>>
webReadyRef: MutableRefObject<Set<string>>
initializedRef: MutableRefObject<Set<string>>
subscribe: (handle: string) => void
unsubscribe: (handle: string) => void
}): (handle: string, wasAlreadyReady: boolean) => void {
const coveredHandleRef = useRef<string | null>(null)
const [webReadyRevision, setWebReadyRevision] = useState(0)
const notifyWebReady = useCallback((handle: string, wasAlreadyReady: boolean) => {
// Why: ordinary WebView startups must not rerender the large session route;
// only readiness that can release a native-chat lease needs reconciliation.
if (!wasAlreadyReady && coveredHandleRef.current === handle) {
setWebReadyRevision((revision) => revision + 1)
}
}, [])
useEffect(() => {
const handle = args.activeHandle
if (coveredHandleRef.current && coveredHandleRef.current !== handle) {
coveredHandleRef.current = null
}
const streamActive =
handle != null &&
(args.subscriptionsRef.current.has(handle) || args.subscribingRef.current.has(handle))
const action = resolveMobileNativeChatTerminalStreamAction({
showNativeChat: args.showNativeChat,
activeHandle: handle,
activeTabType: args.activeTabType,
streamActive,
streamCovered: coveredHandleRef.current === handle,
webViewReady: handle != null && args.webReadyRef.current.has(handle)
})
if (!handle || action === 'none') {
return
}
if (action === 'pause') {
coveredHandleRef.current = handle
// Why: returning to terminal must accept the fresh scrollback snapshot;
// the stream was paused while chat covered output that xterm never saw.
args.initializedRef.current.delete(handle)
// Why: covered chat needs the input-floor lease without paying to stream
// duplicate PTY output. Replace any view stream with a lease-only stream.
if (streamActive) {
args.unsubscribe(handle)
}
args.subscribe(handle)
return
}
if (coveredHandleRef.current === handle) {
args.unsubscribe(handle)
coveredHandleRef.current = null
}
args.subscribe(handle)
}, [
args.activeHandle,
args.activeTabType,
args.initializedRef,
args.showNativeChat,
args.subscribe,
args.subscribingRef,
args.subscriptionsRef,
args.unsubscribe,
args.webReadyRef,
webReadyRevision
])
return notifyWebReady
}
@@ -0,0 +1,69 @@
import { createElement } from 'react'
import { act, create, type ReactTestRenderer } from 'react-test-renderer'
import { afterEach, beforeEach, describe, expect, it, vi, type MockInstance } from 'vitest'
import { useThrottledLatestValue } from './use-throttled-latest-value'
describe('useThrottledLatestValue', () => {
let renderer: ReactTestRenderer | null = null
let latest: string | undefined
let consoleSpy: MockInstance
function Harness({ value }: { value: string | undefined }): null {
latest = useThrottledLatestValue(value, 50)
return null
}
function render(value: string | undefined): void {
act(() => {
renderer = create(createElement(Harness, { value }))
})
}
function update(value: string | undefined): void {
act(() => renderer?.update(createElement(Harness, { value })))
}
beforeEach(() => {
vi.useFakeTimers()
globalThis.IS_REACT_ACT_ENVIRONMENT = true
latest = undefined
const original = console.error
consoleSpy = vi.spyOn(console, 'error').mockImplementation((...args) => {
if (typeof args[0] === 'string' && args[0].includes('react-test-renderer is deprecated')) {
return
}
original(...args)
})
})
afterEach(() => {
act(() => renderer?.unmount())
renderer = null
vi.useRealTimers()
consoleSpy.mockRestore()
})
it('emits the first frame immediately', () => {
render('a')
expect(latest).toBe('a')
})
it('holds rapid updates but eventually emits the latest value', () => {
render('a')
update('ab')
update('abc')
expect(latest).toBe('a')
act(() => vi.advanceTimersByTime(50))
expect(latest).toBe('abc')
})
it('clears immediately and drops the trailing emit when the value goes undefined', () => {
render('a')
update('ab')
expect(latest).toBe('a')
update(undefined)
expect(latest).toBeUndefined()
act(() => vi.advanceTimersByTime(50))
expect(latest).toBeUndefined()
})
})
@@ -0,0 +1,52 @@
import { useEffect, useRef, useState } from 'react'
/** Rate-limits a rapidly-changing value to at most one emit per `intervalMs`
* while always surfacing the latest value. OpenCode publishes a streaming
* assistant frame per part; unthrottled, each re-parses the whole bubble. */
export function useThrottledLatestValue<T>(value: T, intervalMs: number): T {
const [throttled, setThrottled] = useState(value)
const valueRef = useRef(value)
valueRef.current = value
const lastEmitRef = useRef(0)
const timerRef = useRef<ReturnType<typeof setTimeout> | null>(null)
useEffect(() => {
if (value == null) {
// Turn ended: drop any trailing emit and reset so the next stream's first
// frame shows at once instead of the stale bubble lingering.
if (timerRef.current) {
clearTimeout(timerRef.current)
timerRef.current = null
}
lastEmitRef.current = 0
setThrottled(value)
return
}
const elapsed = Date.now() - lastEmitRef.current
if (elapsed >= intervalMs) {
lastEmitRef.current = Date.now()
setThrottled(value)
return
}
if (timerRef.current) {
return
}
timerRef.current = setTimeout(() => {
timerRef.current = null
lastEmitRef.current = Date.now()
setThrottled(valueRef.current)
}, intervalMs - elapsed)
}, [value, intervalMs])
useEffect(
() => () => {
if (timerRef.current) {
clearTimeout(timerRef.current)
timerRef.current = null
}
},
[]
)
return throttled
}
+27
View File
@@ -8,6 +8,7 @@ import {
HOST_SIDEBAR_MIN_WIDTH,
clampHostDockWidth,
clampHostSidebarWidth,
loadNativeChatTabIds,
loadDisabledTerminalLiveInputHandles,
loadHostSidebarWidth,
loadPushNotificationsEnabled,
@@ -17,6 +18,7 @@ import {
readDisabledTerminalLiveInputHandlesPreference,
saveDisabledTerminalLiveInputHandles,
saveHostSidebarWidth,
saveNativeChatTabIds,
savePushNotificationsEnabled,
saveTerminalAutocompleteEnabled,
saveTerminalLinkOpenMode
@@ -29,6 +31,31 @@ vi.mock('@react-native-async-storage/async-storage', () => ({
}
}))
describe('native chat tab preference', () => {
beforeEach(() => {
vi.mocked(AsyncStorage.getItem).mockReset()
vi.mocked(AsyncStorage.setItem).mockReset()
})
it('loads and saves tab ids under a host-and-worktree scoped key', async () => {
vi.mocked(AsyncStorage.getItem).mockResolvedValue(JSON.stringify(['tab-1', 42, 'tab-2']))
await expect(loadNativeChatTabIds('host/one', 'folder:C:\\repo')).resolves.toEqual([
'tab-1',
'tab-2'
])
expect(AsyncStorage.getItem).toHaveBeenCalledWith(
'orca:nativeChatTabs:host%2Fone:folder%3AC%3A%5Crepo'
)
await saveNativeChatTabIds('host/one', 'folder:C:\\repo', ['tab-2'])
expect(AsyncStorage.setItem).toHaveBeenCalledWith(
'orca:nativeChatTabs:host%2Fone:folder%3AC%3A%5Crepo',
JSON.stringify(['tab-2'])
)
})
})
describe('push notification preference', () => {
beforeEach(() => {
vi.mocked(AsyncStorage.getItem).mockReset()
+29
View File
@@ -2,6 +2,35 @@ import AsyncStorage from '@react-native-async-storage/async-storage'
const PINS_PREFIX = 'orca:pins:'
const NOTIF_KEY = 'orca:pushNotificationsEnabled'
const NATIVE_CHAT_TABS_PREFIX = 'orca:nativeChatTabs:'
function nativeChatTabIdsKey(hostId: string, worktreeId: string): string {
return `${NATIVE_CHAT_TABS_PREFIX}${encodeURIComponent(hostId)}:${encodeURIComponent(worktreeId)}`
}
/** Tab ids currently showing native chat, scoped to the paired host and worktree
* so colliding remote ids cannot activate a transcript watcher on another host. */
export async function loadNativeChatTabIds(hostId: string, worktreeId: string): Promise<string[]> {
try {
const raw = await AsyncStorage.getItem(nativeChatTabIdsKey(hostId, worktreeId))
const parsed = raw ? (JSON.parse(raw) as unknown) : null
return Array.isArray(parsed) ? parsed.filter((id): id is string => typeof id === 'string') : []
} catch {
return []
}
}
export async function saveNativeChatTabIds(
hostId: string,
worktreeId: string,
ids: string[]
): Promise<void> {
try {
await AsyncStorage.setItem(nativeChatTabIdsKey(hostId, worktreeId), JSON.stringify(ids))
} catch {
// Best-effort persistence; a failed write just forgets the view choice.
}
}
export type PushNotificationsPreference = {
readonly value: boolean | null
+3
View File
@@ -18,6 +18,9 @@ export const colors = {
surfaceBright: '#f5f5f5',
accentBlue: '#3b82f6',
// Text/icon color on a filled accent (accentBlue) button, where the muted
// textPrimary would lack contrast against the saturated fill.
onAccent: '#ffffff',
statusGreen: '#22c55e',
statusAmber: '#f59e0b',
@@ -1,3 +1,5 @@
import { buildNativeChatUnsubscribe } from '../../../src/shared/native-chat-stream-unsubscribe'
type TerminalStreamParams = {
terminal?: unknown
}
@@ -31,6 +33,37 @@ export function updateTerminalSubscriptionViewport(
}
}
/** Build the unsubscribe RPC for a streaming method that needs the host told to
* tear down (session tabs, native chat), or null when none is required. Keeps
* the per-method echo logic out of the rpc-client teardown closure. */
export function buildStreamUnsubscribe(
method: string | undefined,
params: unknown
): { method: string; params: Record<string, unknown> } | null {
if (!params || typeof params !== 'object') {
return null
}
if (method === 'session.tabs.subscribe') {
const worktree = (params as { worktree?: unknown }).worktree
return typeof worktree === 'string'
? { method: 'session.tabs.unsubscribe', params: { worktree } }
: null
}
if (method === 'nativeChat.subscribe') {
const subscriptionId = (params as { subscriptionId?: unknown }).subscriptionId
if (typeof subscriptionId === 'string') {
return { method: 'nativeChat.unsubscribe', params: { subscriptionId } }
}
// Backward compatibility for callers that predate explicit cleanup tokens.
const agent = (params as { agent?: unknown }).agent
const sessionId = (params as { sessionId?: unknown }).sessionId
return typeof agent === 'string' && typeof sessionId === 'string'
? buildNativeChatUnsubscribe(agent, sessionId)
: null
}
return null
}
export function buildTerminalUnsubscribeParams(
params: unknown
): { subscriptionId: string; client?: { id: string } } | null {
+6 -12
View File
@@ -23,6 +23,7 @@ import {
type BrowserScreencastFrame
} from './browser-screencast-protocol'
import {
buildStreamUnsubscribe,
buildTerminalUnsubscribeParams,
updateTerminalSubscriptionViewport as updateCachedTerminalSubscriptionViewport
} from './rpc-client-terminal-subscription'
@@ -1171,18 +1172,11 @@ export function connect(
params: unsubscribeParams
})
}
} else if (
stream?.method === 'session.tabs.subscribe' &&
stream.params &&
typeof stream.params === 'object' &&
typeof (stream.params as { worktree?: unknown }).worktree === 'string'
) {
sendEncrypted({
id: nextId(),
deviceToken,
method: 'session.tabs.unsubscribe',
params: { worktree: (stream.params as { worktree: string }).worktree }
})
} else {
const unsub = buildStreamUnsubscribe(stream?.method, stream?.params)
if (unsub) {
sendEncrypted({ id: nextId(), deviceToken, method: unsub.method, params: unsub.params })
}
}
removeStreamListener(id)
}
+1 -1
View File
@@ -1,6 +1,6 @@
{
"name": "orca",
"version": "1.4.143-rc.0",
"version": "1.4.144-rc.1",
"description": "Next-gen IDE for parallel agentic development",
"homepage": "https://github.com/stablyai/orca",
"author": "stablyai",
Binary file not shown.

After

Width:  |  Height:  |  Size: 324 B

Binary file not shown.

After

Width:  |  Height:  |  Size: 625 B

@@ -0,0 +1,168 @@
import { extname } from 'node:path'
import type { AiVaultAgent, AiVaultScanIssue } from '../../shared/ai-vault-types'
import type { ExecutionHostId } from '../../shared/execution-host'
import type { FileStat, IFilesystemProvider } from '../providers/types'
import { joinRemotePath } from '../ssh/ssh-remote-platform'
import { partitionSubagentTranscriptPaths } from './session-scanner-subagent-transcripts'
import type { FileWithMtime } from './session-scanner-types'
import { errorMessage } from './session-scanner-values'
import type {
RemoteScannerContext,
RemoteSessionCandidate,
RemoteSessionSource
} from './remote-session-scanner-types'
const REMOTE_DISCOVERY_CONCURRENCY = 8
export async function discoverRemoteSourceCandidates(args: {
source: RemoteSessionSource
context: RemoteScannerContext
issues: AiVaultScanIssue[]
}): Promise<RemoteSessionCandidate[]> {
const walked = args.source.fixedChildFileSegments
? await listRemoteFixedChildFiles(args.source, args.context, args.issues)
: await walkRemoteSessionFiles(args.source, args.context, args.issues)
const partition = args.source.collectSubagentSiblingCounts
? partitionSubagentTranscriptPaths(walked)
: null
const paths = partition ? partition.sessionFilePaths : walked
const files = await mapDiscoveryConcurrently(paths, (path) =>
statRemoteFile(
args.context.provider,
path,
args.source.agent,
args.context.executionHostId,
args.issues,
Boolean(args.source.fixedChildFileSegments)
)
)
return files
.filter((file): file is FileWithMtime => Boolean(file))
.map((file) => ({
source: args.source,
file,
subagentTranscriptCount: partition?.subagentTranscriptCounts.get(file.path) ?? 0
}))
}
async function listRemoteFixedChildFiles(
source: RemoteSessionSource,
context: RemoteScannerContext,
issues: AiVaultScanIssue[]
): Promise<string[]> {
let entries
try {
entries = await context.provider.readDir(source.rootDir)
} catch (err) {
recordRemoteDirectoryIssue(source, context.executionHostId, issues, source.rootDir, err)
return []
}
const segments = source.fixedChildFileSegments ?? []
// Why: Antigravity's transcript path is fixed. Constructing it avoids three
// serialized SSH readDir round trips for every conversation directory.
return entries
.filter((entry) => entry.isDirectory && !entry.isSymlink)
.map((entry) => joinRemotePath(context.hostPlatform, source.rootDir, entry.name, ...segments))
.filter((path) => source.filePredicate?.(path) ?? true)
}
async function walkRemoteSessionFiles(
source: RemoteSessionSource,
context: RemoteScannerContext,
issues: AiVaultScanIssue[],
dirPath = source.rootDir,
depth = 0
): Promise<string[]> {
let entries
try {
entries = await context.provider.readDir(dirPath)
} catch (err) {
recordRemoteDirectoryIssue(source, context.executionHostId, issues, dirPath, err)
return []
}
const extensions = new Set(source.extensions)
const files: string[] = []
for (const entry of entries) {
const fullPath = joinRemotePath(context.hostPlatform, dirPath, entry.name)
if (
entry.isDirectory &&
!entry.isSymlink &&
(source.directoryPredicate?.(entry.name, depth) ?? true)
) {
files.push(...(await walkRemoteSessionFiles(source, context, issues, fullPath, depth + 1)))
continue
}
if (
!entry.isSymlink &&
extensions.has(extname(entry.name).toLowerCase()) &&
(source.filePredicate?.(fullPath) ?? true)
) {
files.push(fullPath)
}
}
return files
}
async function statRemoteFile(
provider: IFilesystemProvider,
path: string,
agent: AiVaultAgent,
executionHostId: ExecutionHostId,
issues: AiVaultScanIssue[],
missingIsExpected: boolean
): Promise<FileWithMtime | null> {
try {
const stat = await provider.stat(path)
const mtimeMs = remoteStatMtimeMs(stat)
return { path, mtimeMs, modifiedAt: new Date(mtimeMs).toISOString() }
} catch (err) {
if (!missingIsExpected || !isMissingRemotePathError(err)) {
issues.push({ executionHostId, agent, path, message: errorMessage(err) })
}
return null
}
}
function recordRemoteDirectoryIssue(
source: RemoteSessionSource,
executionHostId: ExecutionHostId,
issues: AiVaultScanIssue[],
path: string,
err: unknown
): void {
if (!isMissingRemotePathError(err)) {
issues.push({ executionHostId, agent: source.agent, path, message: errorMessage(err) })
}
}
function isMissingRemotePathError(err: unknown): boolean {
const code =
err && typeof err === 'object' && 'code' in err && typeof err.code === 'string'
? err.code
: null
if (code === 'ENOENT' || code === 'ENOTDIR') {
return true
}
// Relay/provider boundaries can preserve only the underlying Node error text.
return /(?:^|[\s:])(ENOENT|ENOTDIR)(?=[\s:]|$)/.test(errorMessage(err))
}
function remoteStatMtimeMs(stat: FileStat): number {
if (typeof stat.mtimeMs === 'number' && Number.isFinite(stat.mtimeMs)) {
return stat.mtimeMs
}
return stat.mtime > 10_000_000_000 ? stat.mtime : stat.mtime * 1000
}
async function mapDiscoveryConcurrently<T, U>(
items: readonly T[],
mapper: (item: T) => Promise<U>
): Promise<U[]> {
const results: U[] = []
for (let index = 0; index < items.length; index += REMOTE_DISCOVERY_CONCURRENCY) {
const batch = items.slice(index, index + REMOTE_DISCOVERY_CONCURRENCY)
results.push(...(await Promise.all(batch.map(mapper))))
}
return results
}
@@ -1,6 +1,8 @@
import type { AiVaultAgent, AiVaultSession } from '../../shared/ai-vault-types'
import type { RemoteHostPlatform } from '../ssh/ssh-remote-platform'
import { joinRemotePath } from '../ssh/ssh-remote-platform'
import { parseAntigravitySessionContent } from './session-scanner-antigravity-parser'
import { isAntigravityTranscriptPath } from './session-scanner-antigravity-paths'
import { parseCodexSessionContent } from './session-scanner-codex-parser'
import { parseDevinSessionContent } from './session-scanner-devin-parser'
import { parseDroidSessionContent } from './session-scanner-droid-parser'
@@ -49,6 +51,7 @@ export function remoteSessionSources(
// top-level sessions carrying the parent's sessionId.
collectSubagentSiblingCounts: true
},
remoteAntigravitySource(remoteHome, hostPlatform),
source(
'gemini',
remoteHome,
@@ -108,6 +111,30 @@ export function remoteSessionSources(
]
}
function remoteAntigravitySource(
remoteHome: string,
hostPlatform: RemoteHostPlatform
): RemoteSessionSource {
const cliRoot = joinRemotePath(hostPlatform, remoteHome, '.gemini', 'antigravity-cli')
const historyPath = joinRemotePath(hostPlatform, cliRoot, 'history.jsonl')
return {
agent: 'antigravity',
rootDir: joinRemotePath(hostPlatform, cliRoot, 'brain'),
extensions: ['.jsonl'],
filePredicate: isAntigravityTranscriptPath,
fixedChildFileSegments: ['.system_generated', 'logs', 'transcript.jsonl'],
parse: async (file, content, context) => {
const session = await parseAntigravitySessionContent(
file,
content,
context.hostPlatform.os,
parserOptions(context)
)
return session ? context.antigravityWorkspaceResolver.enrich(session, historyPath) : null
}
}
}
function source(
agent: AiVaultAgent,
remoteHome: string,
@@ -115,13 +142,15 @@ function source(
segments: readonly string[],
extensions: readonly string[],
parseContent: RemoteContentParser,
filePredicate?: (path: string) => boolean
filePredicate?: (path: string) => boolean,
directoryPredicate?: (name: string, depth: number) => boolean
): RemoteSessionSource {
return {
agent,
rootDir: joinRemotePath(hostPlatform, remoteHome, ...segments),
extensions,
filePredicate,
directoryPredicate,
parse: (file, content, context) =>
Promise.resolve(parseContent(file, content, context.hostPlatform.os, parserOptions(context)))
}
@@ -3,12 +3,14 @@ import type { ExecutionHostId } from '../../shared/execution-host'
import type { IFilesystemProvider } from '../providers/types'
import type { RemoteHostPlatform } from '../ssh/ssh-remote-platform'
import type { FileWithMtime } from './session-scanner-types'
import type { AntigravityWorkspaceResolver } from './session-scanner-antigravity-history'
export type RemoteScannerContext = {
provider: IFilesystemProvider
executionHostId: ExecutionHostId
hostPlatform: RemoteHostPlatform
titleCaches: Map<string, Promise<Map<string, string>>>
antigravityWorkspaceResolver: AntigravityWorkspaceResolver
}
export type RemoteParserOptions = {
@@ -21,6 +23,10 @@ export type RemoteSessionSource = {
rootDir: string
extensions: readonly string[]
filePredicate?: (path: string) => boolean
// Depth 0 denotes a direct child of rootDir.
directoryPredicate?: (name: string, depth: number) => boolean
// A canonical file directly beneath every top-level session directory.
fixedChildFileSegments?: readonly string[]
// Claude layout: count `<session>/subagents/*.jsonl` siblings from the walked
// listing and drop them from candidates instead of indexing them as sessions.
collectSubagentSiblingCounts?: boolean

Some files were not shown because too many files have changed in this diff Show More