mirror of
https://github.com/stablyai/orca.git
synced 2026-10-18 08:03:22 +00:00
Merge commit '64181fdd424d6b6a796e99b8bf023595629b23c0' into brennanb2025/skill-freshness-detection
This commit is contained in:
@@ -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."
|
||||
|
||||
@@ -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 |
@@ -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
|
||||
|
||||
@@ -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}
|
||||
/>
|
||||
)
|
||||
}
|
||||
@@ -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
|
||||
}
|
||||
})
|
||||
@@ -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
|
||||
}
|
||||
@@ -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()
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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 {
|
||||
|
||||
@@ -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
@@ -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
Reference in New Issue
Block a user