fix(mobile): revive hybrid architecture on current main

This commit is contained in:
Jinwoo-H
2026-08-09 18:35:02 -04:00
parent ce43d114be
commit 4a4aaf91b6
96 changed files with 1199 additions and 1288 deletions
+1 -1
View File
@@ -185,7 +185,7 @@ export default function RootLayout() {
disposed = true
sub.remove()
}
}, [openNotificationRoute])
}, [router])
// ─── End notification tap routing ───
// Why: hide the native splash only once the navigation Stack has been laid
+4 -2
View File
@@ -76,7 +76,6 @@ import {
import { useWorkspaceSections } from '../../../src/worktree/use-workspace-sections'
import { getMobileWorkspaceLineageGroupKey } from '../../../src/worktree/mobile-workspace-lineage'
import { areWorktreeListsEqual } from '../../../src/worktree/worktree-list-snapshot'
import { WorktreeCatalogSnapshotClient } from '../../../src/worktree/worktree-catalog-snapshot-client'
import { HostWorkspaceListStates } from '../../../src/worktree/host-workspace-list-states'
import { repoColor } from '../../../src/worktree/repo-color'
import {
@@ -136,6 +135,9 @@ export function HostScreen({
const params = useLocalSearchParams<{ hostId: string; action?: string; notice?: string }>()
const hostId = hostIdProp ?? params.hostId
const action = actionProp ?? params.action
const [dismissedNotice, setDismissedNotice] = useState<string | null>(null)
const noticeParam = params.notice?.trim()
const routeNotice = visibleHostRouteNotice(embedded, noticeParam, dismissedNotice)
const insets = useSafeAreaInsets()
const hostState = hostStateProp ?? defaultHostScreenHostState
// Why: cap and center the list on wide/tablet canvases; on phones isWideLayout is false so it stays edge-to-edge.
@@ -504,7 +506,7 @@ export function HostScreen({
})
} catch {
// Will retry on reconnect
if (clientRef.current === requestClient && hostId === requestHostId) {
if (workspaceOperationsRef.current === requestOperations && hostId === requestHostId) {
setCatalogError('network_error')
}
} finally {
+51 -96
View File
@@ -90,12 +90,13 @@ import type {
TerminalWebViewHandle
} from '../../../../src/terminal/terminal-webview-contract'
import { useTerminalViewportRefit } from '../../../../src/terminal/terminal-viewport-refit'
import { computeActiveTerminalKeyboardLift } from '../../../../src/terminal/terminal-keyboard-avoidance-lift'
import {
getDefaultTerminalAccessoryBuiltInIds,
getVisibleTerminalAccessoryKeys
} from '../../../../src/terminal/terminal-accessory-layout'
import { createTerminalLiveAccessoryInput } from '../../../../src/terminal/terminal-live-accessory-input'
import { sendTerminalLiveAccessoryRawBytes } from '../../../../src/terminal/terminal-live-accessory-raw-send'
import { getTerminalLiveAccessoryRawSendTarget } from '../../../../src/terminal/terminal-live-accessory-raw-send-target'
import {
clearTerminalLiveInputFocusTimer,
isTerminalLiveInputWithinByteLimit,
@@ -171,7 +172,6 @@ import { useMobileFileTapHandlers } from '../../../../src/session/use-mobile-fil
import { useLiveWorktreeName } from '../../../../src/session/use-live-worktree-name'
import { useMissingWorktreeBounce } from '../../../../src/session/use-missing-worktree-bounce'
import { hostRouteWithNotice } from '../../../../src/host-route-notice'
import { LAST_VISITED_WORKTREE_STORAGE_KEY } from '../../../../src/worktree/last-visited-worktree-repo'
import {
acceptSessionSnapshot,
applyClosedTabTombstones,
@@ -202,6 +202,12 @@ import {
import { useMobileNativeChatTerminalStream } from '../../../../src/session/use-mobile-native-chat-terminal-stream'
import { MobileTerminalDiagnostics } from '../../../../src/session/mobile-terminal-diagnostics'
import { runAcceptedMobileSessionTabsEffects } from '../../../../src/session/mobile-session-tabs-accepted-effects'
import {
createInitialSessionAutoCreateState,
useInitialSessionTerminalAutoCreate,
useWorktreeSessionTabsLoaded
} from '../../../../src/session/use-initial-session-terminal-autocreate'
import { TerminalViewportResubscribeBudget } from '../../../../src/session/mobile-terminal-viewport-resubscribe'
import type {
SessionTabsApplyOutcome,
SessionTabsStreamSource
@@ -260,8 +266,8 @@ import {
reconcileMobileSessionCreateWarningState
} from '../../../../src/session/mobile-session-create-warning-state'
import { colors, spacing } from '../../../../src/theme/mobile-theme'
import { styles } from './mobile-session-styles'
import { QuickCommandsTabButton } from './QuickCommandsTabButton'
import { styles } from '../../../../src/session/mobile-session-styles'
import { QuickCommandsTabButton } from '../../../../src/session/QuickCommandsTabButton'
import type { DiffComment } from '../../../../../src/shared/types'
import type {
DiffCommentActions,
@@ -1232,13 +1238,12 @@ export function SessionScreen({
useEffect(() => {
sessionTerminalOperationsRef.current = sessionTerminalOperations
}, [sessionTerminalOperations])
const canSend =
connState === 'connected' &&
sessionTerminalOperations != null &&
activeHandle != null &&
activeSessionTab?.type !== 'markdown' &&
activeSessionTab?.type !== 'file' &&
activeSessionTab?.type !== 'browser'
const { canCompose, canSend: inputGateCanSend } = resolveMobileTerminalInputGate({
connState,
activeHandle,
activeSessionTabType: activeSessionTab?.type
})
const canSend = inputGateCanSend && sessionTerminalOperations != null
const liveInputEnabled = activeHandle ? liveInputTerminalHandles.has(activeHandle) : false
const { focusLiveInput, handleTerminalTap, resetLiveInputFocus } = useTerminalLiveInputFocus({
activeHandleRef,
@@ -2999,7 +3004,7 @@ export function SessionScreen({
}, [activeSessionTab, fileDocs, readFileTab])
async function handleSend() {
if (!sessionTerminalOperations || !activeHandle || sendingRef.current) {
if (!sessionTerminalOperations || !activeHandle || !canSend || sendingRef.current) {
return
}
sendingRef.current = true
@@ -3038,10 +3043,7 @@ export function SessionScreen({
const rawSendTarget = getTerminalLiveAccessoryRawSendTarget({
targetHandle,
activeHandle: activeHandleRef.current,
activeSessionTabType: activeSessionTabTypeRef.current,
connState: connStateRef.current,
bytes: input.bytes,
deviceToken: deviceTokenRef.current
activeSessionTabType: activeSessionTabTypeRef.current
})
if (!currentOperations || !rawSendTarget || connStateRef.current !== 'connected') {
return
@@ -3150,62 +3152,22 @@ export function SessionScreen({
})
}, [])
const handleTerminalTap = useCallback(
(handle: string) => {
if (handle !== activeHandleRef.current) {
return
}
focusLiveInput()
},
[focusLiveInput]
)
// Tap a terminal file path → resolve on host, open as file tab (mirrors desktop Cmd/Ctrl-click); silent on a miss.
const handleFileTapActivationSeqRef = useRef(0)
const handleFileTap = useCallback(
(handle: string, pathText: string, line: number | null, column: number | null) => {
if (handle !== activeHandleRef.current || !sessionTerminalFileOperations) {
return
}
const activationSeq = ++handleFileTapActivationSeqRef.current
openMobileTerminalFileTap<MobileSessionTab>({
operations: sessionTerminalFileOperations,
hostId,
worktreeId,
worktreeName: routeWorktreeName,
terminalHandle: handle,
pathText,
cwd: terminalCwdRef.current.get(handle) ?? null,
line,
column,
pushPreviewRoute: (href) => router.push(href),
openBrowser: (url) => void handleCreateBrowserRef.current?.(url),
triggerOpenFeedback: triggerSelection,
fetchSessionTabs,
getSessionTabs: () => sessionTabsRef.current,
getActiveSessionTabId: () => activeSessionTabIdRef.current,
getActivationState: (activated) => ({
activated,
activationSeq,
latestActivationSeq: handleFileTapActivationSeqRef.current,
sourceTerminalHandle: handle,
activeTerminalHandle: activeHandleRef.current,
activeTabType: activeSessionTabTypeRef.current
}),
switchSessionTab: (tab) => switchSessionTabRef.current?.(tab),
scheduleDelayedAction
})
},
[
fetchSessionTabs,
hostId,
routeWorktreeName,
router,
scheduleDelayedAction,
sessionTerminalFileOperations,
worktreeId
]
)
const { handleFileTap, handleNativeChatFileTap } = useMobileFileTapHandlers<MobileSessionTab>({
operations: sessionTerminalFileOperations,
hostId,
worktreeId,
worktreeName: routeWorktreeName,
activeHandleRef,
terminalCwdRef,
openBrowser: (url) => void handleCreateBrowserRef.current?.(url),
fetchSessionTabs,
getSessionTabs: () => sessionTabsRef.current,
getActiveSessionTabId: () => activeSessionTabIdRef.current,
getActiveSessionTabType: () => activeSessionTabTypeRef.current,
switchSessionTab: (tab) => switchSessionTabRef.current?.(tab),
scheduleDelayedAction,
reportChatTapFailure: nativeChatSendError.show
})
const handleOpenedFileDiffActivationSeqRef = useRef(0)
// Capture active tab at tap time; reading it after openDiff would misread a mid-RPC switch and let the retry steal focus.
@@ -3835,7 +3797,8 @@ export function SessionScreen({
text: options.initialPrompt,
enter: options.enter !== false,
deviceToken: deviceTokenRef.current
})
}),
TERMINAL_INPUT_SEND_OPTIONS
)
.then((sendResponse) => {
if (!sendResponse.ok) {
@@ -4192,30 +4155,22 @@ export function SessionScreen({
const showEmptyState =
connState === 'connected' && terminalsLoaded && visibleTabs.length === 0 && !activeHandle
useEffect(() => {
if (
(!client && !sessionTabOperations) ||
!showEmptyState ||
creating ||
creatingBrowser ||
creatingMarkdown ||
initialEmptySessionAutoCreateRef.current === worktreeId
) {
return
useInitialSessionTerminalAutoCreate({
client: client ?? sessionTabOperations,
newlyCreatedWorkspace: created === '1',
connState,
terminalsLoaded,
visibleTabCount: visibleTabs.length,
activeHandle,
createInFlight: creating || creatingBrowser || creatingMarkdown,
stateRef: initialSessionAutoCreateRef,
worktreeId,
consumeCreationRoute: () => router.setParams({ created: undefined }),
createTerminal: () => {
setCreateError('')
void handleCreateTerminal()
}
// Why: a sleeping/new workspace can hydrate with zero tabs; create the first terminal once so mobile isn't blank.
initialEmptySessionAutoCreateRef.current = worktreeId
setCreateError('')
void handleCreateTerminal()
}, [
client,
creating,
creatingBrowser,
creatingMarkdown,
sessionTabOperations,
showEmptyState,
worktreeId
])
})
// Why: reconnect trickles to 90s at its give-up cap; surface tap-to-retry so recovery needn't wait it out (issue #5049).
const connectionVerdict = classifyConnection({
+11
View File
@@ -58,6 +58,13 @@ import {
resolveMobileSyntaxLanguage
} from '../../../src/session/mobile-file-syntax'
import { buildGitHubCheckSummary } from '../../../src/tasks/github-check-summary'
import { buildGitLabCheckSummary } from '../../../src/tasks/gitlab-check-summary'
import {
getHostedChecksLabel,
getHostedMergeLabel,
getHostedReviewLabel,
getHostedReviewSignalTone
} from '../../../src/tasks/mobile-hosted-check-status'
import type { MobileComposerCreateSelection } from '../../../src/tasks/mobile-composer-source-types'
import {
filterWorkspaceAgents,
@@ -265,6 +272,10 @@ type GitLabWorkItem = {
targetId?: string
repoId: string
repoName: string
reviewDecision?: string | null
checksSummary?: ProviderCheckSummary
mergeable?: GitHubPRMergeableState
reviewerCount?: number
}
type GitLabTodo = {
+56 -102
View File
@@ -62,45 +62,14 @@ import {
normalizeVisibleTaskProviders,
type TaskProvider
} from '../src/tasks/mobile-task-providers'
import { useOpenMobileTasks } from '../src/tasks/use-open-mobile-tasks'
import { useResponsiveLayout } from '../src/layout/responsive-layout'
import { navigateFromMobileHome } from '../src/mobile-web/mobile-web-home-navigation'
function endpointLabel(endpoint: string): string {
try {
const url = new URL(endpoint)
return `${url.hostname}${url.port ? `:${url.port}` : ''}`
} catch {
return endpoint
}
}
type StatsSummary = {
totalAgentsSpawned: number
totalPRsCreated: number
totalAgentTimeMs: number
firstEventAt: number | null
}
type WorktreeSummary = {
worktreeId: string
repo: string
branch: string
displayName: string
liveTerminalCount: number
status?: 'working' | 'active' | 'permission' | 'done' | 'inactive'
// The worktree the desktop currently has focused (exactly one is true).
isActive?: boolean
// Last terminal-output time (ms); breaks ties when nothing is focused.
lastOutputAt?: number
}
type HostWorktreeInfo = {
hostId: string
totalWorktrees: number
activeCount: number
lastActiveWorktree: WorktreeSummary | null
}
import {
isResumeTargetConfirmedMissing,
selectHomeResumeCard,
type HomeResumeCard
} from '../src/worktree/home-resume-card'
import { hostEndpointLabel } from '../src/transport/host-endpoint-label'
type HomeTaskSettings = {
visibleTaskProviders?: unknown
@@ -244,15 +213,16 @@ function repoColor(name: string): string {
export default function HomeScreen() {
const router = useRouter()
const openMobileHostEdit = useOpenMobileHostEdit()
const openMobileTasks = useOpenMobileTasks()
const openMobileSession = useOpenMobileSession()
const openMobileAccounts = useOpenMobileAccounts()
const insets = useSafeAreaInsets()
// Why: cap/center content on wide/tablet canvases so cards don't stretch edge-to-edge on iPad.
const { isWideLayout, contentMaxWidth } = useResponsiveLayout()
const [hostCatalog, setHostCatalog] = useState<HostCatalogEntry[]>([])
const [actionTarget, setActionTarget] = useState<HostProfile | null>(null)
const [confirmRemove, setConfirmRemove] = useState<{ id: string; name: string } | null>(null)
const [confirmRemove, setConfirmRemove] = useState<{
id: string
name: string
publicKeyB64: string
} | null>(null)
const [hostStates, setHostStates] = useState<Record<string, ConnectionState>>({})
const [hostAttempts, setHostAttempts] = useState<Record<string, number>>({})
const [hostLastConnected, setHostLastConnected] = useState<Record<string, number | null>>({})
@@ -562,16 +532,20 @@ export default function HomeScreen() {
getProvenCachedWorktrees(card.hostId) as HomeWorktreeSummary[] | null
)
) {
router.push(hostRouteWithNotice(card.hostId, 'worktree-missing'))
navigateFromMobileHome({
router,
hostId: card.hostId,
target: { kind: 'workspaceList' }
})
return
}
openMobileSession({
navigateFromMobileHome({
router,
hostId: card.hostId,
worktreeId: card.worktree.worktreeId,
name: card.worktree.displayName || card.worktree.repo
target: { kind: 'session', hostWorkspaceId: card.worktree.worktreeId }
})
},
[openMobileSession, router]
[router]
)
// Why: only show Account usage for connected hosts; stale cached usage would imply live data.
@@ -612,7 +586,7 @@ export default function HomeScreen() {
target: { kind: 'tasks', ...(provider ? { taskSource: provider } : {}) }
})
},
[openMobileTasks, primaryConnectedHost]
[primaryConnectedHost, router]
)
const renderTaskHomeCard = () => (
<Pressable
@@ -780,6 +754,7 @@ export default function HomeScreen() {
}
ItemSeparatorComponent={CardGap}
renderItem={({ item }) => {
const info = worktreeInfo[item.id]
const state = resolveHomeHostConnectionState(
item.id,
hostStates[item.id],
@@ -800,16 +775,22 @@ export default function HomeScreen() {
state={state}
verdict={verdict}
path={hostPaths[item.id] ?? 'lan'}
worktreeCounts={
info ? { total: info.totalWorktrees, active: info.activeCount } : undefined
}
onPress={() =>
navigateFromMobileHome({
router,
hostId: item.id,
target: { kind: 'workspaceList' }
})
}
worktreeInfo={info}
onPress={() => {
if (item.credentialStatus === 'missing') {
router.push('/pair-scan')
} else if (item.credentialStatus === 'temporarily-unavailable') {
void loadHostCatalog()
.then(setHostCatalog)
.catch(() => Alert.alert('Could not check pairing', 'Please try again.'))
} else {
navigateFromMobileHome({
router,
hostId: item.id,
target: { kind: 'workspaceList' }
})
}
}}
onLongPress={() => {
triggerMediumImpact()
if (item.profile) {
@@ -835,17 +816,13 @@ export default function HomeScreen() {
<>
<Text style={[styles.sectionHeading, styles.sectionHeadingTightTop]}>Resume</Text>
<Pressable
style={({ pressed }) => [styles.resumeCard, pressed && styles.hostCardPressed]}
onPress={() =>
navigateFromMobileHome({
router,
hostId: resumeWorktree.hostId,
target: {
kind: 'session',
hostWorkspaceId: resumeWorktree.worktree.worktreeId
}
})
}
disabled={!resumeCard.actionable}
style={({ pressed }) => [
styles.resumeCard,
!resumeCard.actionable && styles.cardDisabled,
pressed && styles.hostCardPressed
]}
onPress={() => openResume(resumeCard)}
>
<View style={styles.resumeIcon}>
<Terminal size={18} color={colors.textSecondary} />
@@ -876,40 +853,17 @@ export default function HomeScreen() {
{renderTaskHomeCard()}
{/* ─── Quick actions ─── */}
<Text style={[styles.sectionHeading, { marginTop: spacing.xl }]}>Quick Actions</Text>
<View style={styles.quickActions}>
<Pressable
style={({ pressed }) => [styles.quickAction, pressed && styles.hostCardPressed]}
onPress={() => router.push('/pair-scan')}
>
<View style={styles.quickActionIcon}>
<QrCode size={16} color={colors.textSecondary} />
</View>
<Text style={styles.quickActionLabel}>Pair Desktop</Text>
</Pressable>
<Pressable
disabled={!primaryConnectedHost}
style={({ pressed }) => [
styles.quickAction,
!primaryConnectedHost && styles.quickActionDisabled,
pressed && styles.hostCardPressed
]}
onPress={() => {
if (primaryConnectedHost) {
navigateFromMobileHome({
router,
hostId: primaryConnectedHost.id,
target: { kind: 'newWorkspace' }
})
}
}}
>
<View style={styles.quickActionIcon}>
<Plus size={16} color={colors.textSecondary} />
</View>
<Text style={styles.quickActionLabel}>New Workspace</Text>
</Pressable>
</View>
<MobileHomeQuickActions
connectedHosts={connectedHosts}
onPairDesktop={() => router.push('/pair-scan')}
onCreateWorkspace={(hostId) =>
navigateFromMobileHome({
router,
hostId,
target: { kind: 'newWorkspace' }
})
}
/>
{/* ─── Account usage ─── */}
{accountsHosts.length > 0 ? (
+1 -1
View File
@@ -61,7 +61,7 @@
"expo-status-bar": "^55.0.6",
"lowlight": "^3.3.0",
"lucide-react-native": "^1.14.0",
"mermaid": "^11.15.0",
"mermaid": "11.16.1",
"react": "^19.2.6",
"react-dom": "19.2.6",
"react-native": "^0.83.9",
+8 -8
View File
@@ -122,8 +122,8 @@ importers:
specifier: ^1.14.0
version: 1.14.0(react-native-svg@15.15.4(react-native@0.83.9(@babel/core@7.29.7)(@react-native/metro-config@0.85.2(@babel/core@7.29.7))(@types/react@19.2.14)(react@19.2.6))(react@19.2.6))(react-native@0.83.9(@babel/core@7.29.7)(@react-native/metro-config@0.85.2(@babel/core@7.29.7))(@types/react@19.2.14)(react@19.2.6))(react@19.2.6)
mermaid:
specifier: ^11.15.0
version: 11.16.0
specifier: 11.16.1
version: 11.16.1
react:
specifier: ^19.2.6
version: 19.2.6
@@ -5454,8 +5454,8 @@ packages:
merge-stream@2.0.0:
resolution: {integrity: sha512-abv/qOcuPfk3URPfDzmZU1LKmuw8kT+0nIHvKrKgFrwifol/doWcdA4ZqsWQ8ENrFKkd67Mfpo/LovbIUsbt3w==}
mermaid@11.16.0:
resolution: {integrity: sha512-Zvm3kbstgdpvIJPPItlL7fppIZ3kibvc1oZIGxdvk9t6UFz6flv+Jw7FtRGKwfcI8OckmH04LqG6LlS6X4B1pA==}
mermaid@11.16.1:
resolution: {integrity: sha512-TQsq6u22fAn3rek5VOubrhKPo1g5hwC3FXUN9hiyupTckcYiGuuKGkNQrKYwGJkXUxZdojwRG46gsSCFZMDp4g==}
metro-babel-transformer@0.83.7:
resolution: {integrity: sha512-sBqBkt6kNut/88bv+Ucvm4yqdPetbvAEsHzi3MAgJEifOSYYzX5Z5Kgw3TFOrwf/mHJTOBG2ONlaMHoyfP15TA==}
@@ -11113,7 +11113,7 @@ snapshots:
dependencies:
layout-base: 2.0.1
create-jest@29.7.0(@types/node@26.1.1):
create-jest@29.7.0(@types/node@26.1.2):
dependencies:
'@jest/types': 29.6.3
chalk: 4.1.2
@@ -13491,7 +13491,7 @@ snapshots:
merge-stream@2.0.0: {}
mermaid@11.16.0:
mermaid@11.16.1:
dependencies:
'@braintree/sanitize-url': 7.1.2
'@iconify/utils': 3.1.4
@@ -15029,7 +15029,7 @@ snapshots:
ts-dedent@2.3.0: {}
ts-jest@29.0.5(@babel/core@7.29.7)(@jest/types@29.6.3)(babel-jest@29.7.0(@babel/core@7.29.7))(esbuild@0.25.4)(jest@29.7.0(@types/node@26.1.1))(typescript@5.9.3):
ts-jest@29.0.5(@babel/core@7.29.7)(@jest/types@29.6.3)(babel-jest@29.7.0(@babel/core@7.29.7))(esbuild@0.25.4)(jest@29.7.0(@types/node@26.1.2))(typescript@5.9.3):
dependencies:
bs-logger: 0.2.6
fast-json-stable-stringify: 2.1.0
@@ -15038,7 +15038,7 @@ snapshots:
json5: 2.2.3
lodash.memoize: 4.1.2
make-error: 1.3.6
semver: 7.7.4
semver: 7.8.5
typescript: 5.9.3
yargs-parser: 21.1.1
optionalDependencies:
@@ -82,7 +82,7 @@ describe('mobile accounts route', () => {
})
})
it('opens the home account-usage card through the cold-navigator-safe transition', () => {
it('opens the home account-usage card through the hybrid navigation intent', () => {
const start = homeSource.indexOf('{/* ─── Account usage ─── */}')
// Assert the marker first: a renamed banner would otherwise slice garbage and report a
@@ -90,7 +90,8 @@ describe('mobile accounts route', () => {
expect(start).toBeGreaterThanOrEqual(0)
const accountsSection = homeSource.slice(start)
expect(accountsSection).toContain('openMobileAccounts(host.id)')
expect(accountsSection).toContain('navigateFromMobileHome({')
expect(accountsSection).toContain("target: { kind: 'accounts' }")
expect(accountsSection).not.toContain('/accounts`')
})
})
+21 -2
View File
@@ -1,4 +1,4 @@
import { useRef, type ReactNode } from 'react'
import { useEffect, useRef, type ReactNode } from 'react'
import { ActivityIndicator, StyleSheet, View } from 'react-native'
import { useHostClient } from '../transport/client-context'
import { useHostStatusGates } from '../transport/host-status-gates'
@@ -58,7 +58,26 @@ export function HostProtocolGate({ hostId, children }: Props) {
return <ProtocolBlockScreen verdict={compatVerdict} />
}
// Why: the host sidebar needs the same status fields; sharing the result avoids a second status.get per route.
return <HostProtocolGatesProvider value={gates}>{children}</HostProtocolGatesProvider>
return (
<HostProtocolGatesProvider value={gates}>
<View style={styles.host}>
<View
style={styles.host}
importantForAccessibility={pending ? 'no-hide-descendants' : 'auto'}
>
{children}
</View>
{pending ? (
<View style={styles.pendingOverlay} pointerEvents="auto" accessibilityViewIsModal>
<ActivityIndicator
color={colors.textSecondary}
accessibilityLabel="Checking host compatibility"
/>
</View>
) : null}
</View>
</HostProtocolGatesProvider>
)
}
const styles = StyleSheet.create({
@@ -50,7 +50,7 @@ describe('MobileMarkdown file links', () => {
function render(content: string): ReactTestRenderer {
act(() => {
renderer = create(createElement(MobileMarkdown, { content, onOpenFile }))
renderer = create(createElement(MobileMarkdown, { content, onOpenFile, onOpenLink: openURL }))
})
return renderer!
}
@@ -6,6 +6,7 @@ import {
} from '../../../src/shared/mobile-web/native-operation-contract'
import { normalizeMobileMarkdownPreviewHtml } from './mobile-markdown-preview-html'
import { parseMobileMarkdown } from './mobile-markdown-parser'
import { isMobileMermaidLanguage } from './mobile-mermaid-language'
describe('isMobileMermaidLanguage', () => {
it('matches mermaid case-insensitively after trim', () => {
+28 -7
View File
@@ -36,8 +36,17 @@ const MAX_TABLE_COLUMNS = 8
/** Prose base size — passed to MermaidDiagram fallback mono text. */
const MERMAID_BASE = 13
function openMarkdownUrl(url: string, onOpenLink?: (url: string) => void): void {
const externalUrl = normalizeMobileWebExternalUrl(url)
function openMarkdownUrl(
url: string,
onOpenFile?: (pathText: string) => void,
onOpenLink?: (url: string) => void
): void {
const route = routeMarkdownHref(url)
if (route.kind === 'file') {
onOpenFile?.(route.pathText)
return
}
const externalUrl = route.kind === 'web' ? normalizeMobileWebExternalUrl(route.url) : null
if (externalUrl) {
onOpenLink?.(externalUrl)
}
@@ -105,21 +114,33 @@ function renderInline(
const link = token.match(/^\[([^\]]+)\]\(([^)]+)\)$/)
if (image) {
parts.push(
<Text key={key} style={styles.link} onPress={() => openMarkdownUrl(image[2]!, onOpenLink)}>
<Text
key={key}
style={styles.link}
onPress={() => openMarkdownUrl(image[2]!, onOpenFile, onOpenLink)}
>
{image[1] || 'image'}
</Text>
)
} else if (link) {
parts.push(
<Text key={key} style={styles.link} onPress={() => openMarkdownUrl(link[2]!, onOpenLink)}>
<Text
key={key}
style={styles.link}
onPress={() => openMarkdownUrl(link[2]!, onOpenFile, onOpenLink)}
>
{link[1]}
</Text>
)
} else if (/^https?:\/\//i.test(token)) {
const { url, trailing } = trimAutolinkTrailingPunctuation(token)
parts.push(
<Text key={key} style={styles.link} onPress={() => openMarkdownUrl(token, onOpenLink)}>
{token}
<Text
key={key}
style={styles.link}
onPress={() => openMarkdownUrl(url, onOpenFile, onOpenLink)}
>
{url}
</Text>
)
if (trailing) {
@@ -240,7 +261,7 @@ function MobileMarkdownInner({
<Pressable
key={index}
style={styles.imageFrame}
onPress={() => openMarkdownUrl(block.url, onOpenLink)}
onPress={() => openMarkdownUrl(block.url, onOpenFile, onOpenLink)}
>
<Text style={styles.link}>{block.alt || 'Open image'}</Text>
<Text style={styles.imageCaption} numberOfLines={1}>
@@ -1,7 +1,7 @@
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 type { HostWorkspaceCreationOperations } from '../worktree/host-workspace-creation-operations'
const asyncStorage = vi.hoisted(() => ({
getItem: vi.fn().mockResolvedValue(null),
@@ -68,20 +68,36 @@ describe('NewWorktreeModal repo list', () => {
})
it('keeps the cached repos when the in-flight repo.list rejects on a dropped connection', async () => {
const sendRequest = vi.fn().mockImplementation((method: string) => {
if (method === 'repo.list') {
return Promise.reject(new Error('connection closed'))
const listRepositories = vi.fn().mockRejectedValue(new Error('connection closed'))
const operations = new Proxy(
{
listRepositories,
readRuntimeCapabilities: vi.fn().mockResolvedValue({
tasksSupported: false,
idempotentWorktreeCreateSupported: false
}),
readRuntimeSettings: vi.fn().mockResolvedValue({}),
readTrustedHooks: vi.fn().mockResolvedValue({}),
isGitLabCliInstalled: vi.fn().mockResolvedValue(false),
isLinearConnected: vi.fn().mockResolvedValue(false)
},
{
get(target, property) {
if (property in target) {
return target[property as keyof typeof target]
}
return vi.fn().mockResolvedValue(null)
}
}
return new Promise(() => {})
})
const client = { sendRequest } as unknown as RpcClient
) as unknown as HostWorkspaceCreationOperations
await act(async () => {
renderer = create(
createElement(NewWorktreeModal, {
visible: true,
client,
operations,
hostId: 'host-1',
openExternalUrl: vi.fn(),
onCreated: () => {},
onClose: () => {}
})
@@ -92,7 +108,7 @@ describe('NewWorktreeModal repo list', () => {
await Promise.resolve()
})
expect(sendRequest).toHaveBeenCalledWith('repo.list')
expect(listRepositories).toHaveBeenCalledOnce()
expect(repoPickerNames(renderer)).toEqual(['orca'])
})
})
@@ -19,6 +19,7 @@ vi.mock('./mobile-markdown-styles', () => ({
}
)
}))
vi.mock('./pr-sidebar/MermaidDiagram', () => ({ MermaidDiagram: 'MermaidDiagram' }))
describe('MobileMarkdown security', () => {
let renderer: ReactTestRenderer | null = null
@@ -1,60 +0,0 @@
import { describe, expect, it, vi } from 'vitest'
import { buildHtml } from './MermaidDiagram'
import { MERMAID_ENGINE_JS } from './mermaid-webview-engine.generated'
vi.mock('react-native', () => ({
ScrollView: 'ScrollView',
StyleSheet: { create: <T>(styles: T) => styles, hairlineWidth: 1 },
Text: 'Text',
View: 'View'
}))
vi.mock('react-native-webview', () => ({ WebView: 'WebView' }))
// The diagram source is untrusted (agent output, PR/chat content). It is embedded
// inside an inline <script>, so it must not be able to close that script element.
describe('buildHtml source escaping', () => {
it('does not let a </script> payload break out of the inline script', () => {
const payload = 'graph TD; A-->B</script><script>window.evil=1</script>'
const countClosers = (html: string) => (html.match(/<\/script>/gi) ?? []).length
// The payload's two </script> must add zero raw closers over a benign render —
// they were neutralized to \u003c instead of closing our inline script.
const benign = countClosers(buildHtml('graph TD; A-->B'))
expect(countClosers(buildHtml(payload))).toBe(benign)
expect(buildHtml(payload)).toContain('\\u003c/script')
})
it('escapes the U+2028/U+2029 line separators that would break the JS literal', () => {
const payload = `a${String.fromCharCode(0x2028)}b${String.fromCharCode(0x2029)}c`
const html = buildHtml(payload)
expect(html).toContain('\\u2028')
expect(html).toContain('\\u2029')
expect(html.includes(String.fromCharCode(0x2028))).toBe(false)
expect(html.includes(String.fromCharCode(0x2029))).toBe(false)
})
it('still round-trips ordinary source to the exact original string', () => {
const payload = 'graph LR\n A["node & <tag>"] --> B'
const html = buildHtml(payload)
const match = html.match(/\.textContent = (".*?");\n {4}mermaid\.initialize/s)
expect(match).not.toBeNull()
expect(JSON.parse(match![1]!)).toBe(payload)
})
// Same gate as the terminal document: mermaid is embedded from the lockfile-pinned
// package, so the document itself must load nothing external (offline-safe, no CDN
// supply-chain exposure). The engine's internal URL literals (xmlns, docs links)
// are inert data, so the gate checks the document with the engine stripped out.
it('embeds the mermaid engine and loads no external resource', () => {
const html = buildHtml('graph TD; A-->B')
expect(html).toContain(MERMAID_ENGINE_JS)
expect(html.replace(MERMAID_ENGINE_JS, '')).not.toMatch(/\bhttps?:\/\//)
})
it('blocks external resources requested by diagram syntax', () => {
const html = buildHtml('flowchart LR\nA@{ img: "https://example.com/pixel.png" }')
const policy = html.match(/Content-Security-Policy" content="([^"]+)"/)?.[1]
expect(policy).toContain("default-src 'none'")
expect(policy).toContain('img-src data: blob:')
expect(policy).not.toMatch(/https?:/)
})
})
File diff suppressed because one or more lines are too long
@@ -51,6 +51,7 @@ export async function executeMobileWebNativeChatTerminalOperation(args: {
args.terminalClientId,
payload.deadline,
payload.clearInputFirst === true,
payload.resolvedLaunchDraft,
() => assertCurrentBinding(args, payload, binding)
)
)
@@ -82,6 +83,7 @@ export async function executeMobileWebNativeChatTerminalOperation(args: {
args.terminalClientId,
payload.deadline,
false,
undefined,
() => assertCurrentBinding(args, payload, binding)
)
)
@@ -98,6 +100,7 @@ export async function executeMobileWebNativeChatTerminalOperation(args: {
args.terminalClientId,
payload.deadline,
false,
undefined,
() => assertCurrentBinding(args, payload, binding)
)
)
@@ -127,6 +130,7 @@ async function sendTerminal(
clientId: string,
deadline: number,
clearInputFirst: boolean,
resolvedLaunchDraft: { text: string; createdAt: number } | undefined,
assertCurrent: () => void
): Promise<MobileNativeChatSendOutcome> {
const timeoutMs = deadline - Date.now()
@@ -141,7 +145,8 @@ async function sendTerminal(
terminal,
text: clearInputFirst ? `\x15${text}` : text,
enter,
client: { id: clientId, type: 'mobile' }
client: { id: clientId, type: 'mobile' },
...(resolvedLaunchDraft ? { resolvedLaunchDraft } : {})
},
{ timeoutMs, budgetSpansConnect: true }
)
@@ -1,117 +1,34 @@
import { readFileSync } from 'node:fs'
import { describe, expect, it, vi } from 'vitest'
import { describe, expect, it } from 'vitest'
import { getNotificationNavigationTarget } from './notification-routing'
import {
hostStackHostRoute,
navigateToHostStackRoute,
type HostStackNavigationState
} from '../navigation/host-stack-navigation'
const rootLayoutSource = readFileSync(new URL('../../app/_layout.tsx', import.meta.url), 'utf8')
function navigationHarness(initialState: HostStackNavigationState | undefined) {
const stateListeners = new Set<() => void>()
let state = initialState
const navigation = {
addListener: vi.fn((_event: 'state', listener: () => void) => {
stateListeners.add(listener)
return () => stateListeners.delete(listener)
}),
dispatch: vi.fn(),
getState: () => state
}
return {
navigation,
setState(nextState: HostStackNavigationState | undefined) {
state = nextState
for (const listener of stateListeners) {
listener()
}
}
}
}
// A notification tap is handled by app/_layout.tsx, which Expo Router mounts as a screen of its
// own internal navigator — hence the extra `__root` level around the app's root stack.
function rootLayoutScopedState(inner: HostStackNavigationState): HostStackNavigationState {
return { key: 'internal', index: 0, routes: [{ key: '__root', name: '__root', state: inner }] }
}
describe('notification route coordination', () => {
it('mounts the host before replacing it with the notification session, from a cold navigator', () => {
const target = getNotificationNavigationTarget({
hostId: 'host/one',
worktreeId: 'repo::/Users/me/orca/workspaces/feature'
it('keeps host-only and workspace notification intents distinct', () => {
expect(getNotificationNavigationTarget({ hostId: 'host-1' })).toEqual({
kind: 'host',
hostId: 'host-1'
})
// Cold start: the tap is handled before the root navigator has committed any state.
const harness = navigationHarness(undefined)
const push = vi.fn()
navigateToHostStackRoute(
harness.navigation,
{ push, replace: vi.fn() },
target!.hostId,
target!.sessionTarget!
)
expect(push).toHaveBeenCalledWith(hostStackHostRoute('host/one'))
expect(harness.navigation.dispatch).not.toHaveBeenCalled()
harness.setState(rootLayoutScopedState({ index: 0, routes: [{ name: 'index' }] }))
harness.setState(
rootLayoutScopedState({
index: 1,
routes: [{ name: 'index' }, { name: 'h', state: undefined }]
})
)
expect(harness.navigation.dispatch).not.toHaveBeenCalled()
harness.setState(
rootLayoutScopedState({
index: 1,
routes: [
{ name: 'index' },
{
name: 'h',
state: {
key: '/h',
index: 0,
routes: [
{
key: 'host-index',
name: '[hostId]/index',
params: { hostId: encodeURIComponent('host/one') }
}
]
}
}
]
})
)
expect(harness.navigation.dispatch).toHaveBeenCalledWith({
type: 'REPLACE',
target: '/h',
source: 'host-index',
payload: target!.sessionTarget
expect(
getNotificationNavigationTarget({ hostId: 'host-1', worktreeId: 'repo::/tmp/worktree' })
).toEqual({
kind: 'session',
hostId: 'host-1',
hostWorkspaceId: 'repo::/tmp/worktree'
})
})
it('leaves a host-only notification as a shallow push with nothing to coordinate', () => {
expect(getNotificationNavigationTarget({ hostId: 'host-1' })?.sessionTarget).toBeNull()
})
it('routes notification taps through the coordinated transition, not a bare push', () => {
it('publishes the validated intent before entering the hybrid route', () => {
const start = rootLayoutSource.indexOf('// ─── Notification tap routing ───')
const end = rootLayoutSource.indexOf('// ─── End notification tap routing ───', start)
// Assert the markers first: a renamed banner would otherwise slice garbage and report a
// missing call instead of the real cause.
expect(start).toBeGreaterThanOrEqual(0)
expect(end).toBeGreaterThan(start)
const notificationEffect = rootLayoutSource.slice(start, end)
expect(notificationEffect).toContain('openNotificationRoute(target)')
expect(notificationEffect).not.toContain('router.push(')
expect(notificationEffect).toContain('MOBILE_WEB_NAVIGATION_INTENTS.publish(navigation.target)')
expect(notificationEffect).toContain("router.push('/hybrid')")
expect(notificationEffect).not.toContain('navigateToHostStackRoute(')
})
})
@@ -26,7 +26,6 @@ describe('notification routing', () => {
})
})
// Identities stay raw: the target is dispatched as navigator params, not a URL.
it('routes notification taps to the worktree terminal screen', () => {
expect(
getNotificationNavigationTarget({
@@ -43,13 +42,7 @@ describe('notification routing', () => {
hostId: 'host-1',
worktreeId: 'repo::/Users/me/orca/workspaces/feature'
})
).toEqual({
hostId: 'host-1',
sessionTarget: {
name: '[hostId]/session/[worktreeId]',
params: { hostId: 'host-1', worktreeId: 'repo::/Users/me/orca/workspaces/feature' }
}
})
).toBe('/h/host-1/session/repo%3A%3A%2FUsers%2Fme%2Forca%2Fworkspaces%2Ffeature')
})
it('falls back to the host screen when the payload has no worktree id', () => {
@@ -1,7 +1,3 @@
import type { HostStackRouteTarget } from '../navigation/host-stack-navigation'
import { mobileSessionRouteTarget } from '../session/mobile-session-route'
import type { HostCredentialStatus } from '../transport/types'
export type DesktopNotificationSource = 'agent-task-complete' | 'terminal-bell' | 'test'
export type DesktopNotificationEvent = {
@@ -19,7 +15,6 @@ export type LocalNotificationData = {
export type NotificationNavigationOptions = {
knownHostIds?: ReadonlySet<string>
credentialStatusByHostId?: ReadonlyMap<string, HostCredentialStatus>
}
export type NotificationNavigationTarget =
@@ -57,24 +52,7 @@ export function buildLocalNotificationData(
return data
}
/** Where a tap should land. `sessionTarget` is null for a host-only notification, whose
* `/h/<id>` push is shallow enough to need no host-stack coordination. */
export type NotificationNavigationTarget = Readonly<{
hostId: string
sessionTarget: HostStackRouteTarget | null
credentialRecovery?: 'retry' | 're-pair'
}>
export function notificationCredentialRecoveryRoute(
target: NotificationNavigationTarget
): '/' | '/pair-scan' | null {
if (target.credentialRecovery === 're-pair') {
return '/pair-scan'
}
return target.credentialRecovery === 'retry' ? '/' : null
}
export function getNotificationNavigationTarget(
export function getNotificationNavigationPath(
data: unknown,
options: NotificationNavigationOptions = {}
): string | null {
@@ -1,29 +1,16 @@
import { useCallback } from 'react'
import { useRouter } from 'expo-router'
import { hostStackHostRoute } from '../navigation/host-stack-navigation'
import { useOpenHostStackRoute } from '../navigation/use-open-host-stack-route'
import {
notificationCredentialRecoveryRoute,
type NotificationNavigationTarget
} from './notification-routing'
import { MOBILE_WEB_NAVIGATION_INTENTS } from '../mobile-web/mobile-web-navigation-intent-buffer'
import type { NotificationNavigationTarget } from './notification-routing'
export function useOpenNotificationRoute(): (target: NotificationNavigationTarget) => void {
const openHostStackRoute = useOpenHostStackRoute()
const router = useRouter()
return useCallback(
(target) => {
const recoveryRoute = notificationCredentialRecoveryRoute(target)
if (recoveryRoute) {
router.push(recoveryRoute)
return
}
if (target.sessionTarget) {
openHostStackRoute(target.hostId, target.sessionTarget)
return
}
router.push(hostStackHostRoute(target.hostId))
MOBILE_WEB_NAVIGATION_INTENTS.publish(target)
router.push('/hybrid')
},
[openHostStackRoute, router]
[router]
)
}
@@ -47,7 +47,10 @@ describe('MobileNativeChatMessage', () => {
function render(
message: NativeChatMessage,
onCopyText?: (text: string) => Promise<void>
props: {
toolsExpanded?: boolean
onCopyText?: (text: string) => Promise<void>
} = {}
): ReactTestRenderer {
const original = console.error
const spy = vi.spyOn(console, 'error').mockImplementation((...a) => {
@@ -58,7 +61,7 @@ describe('MobileNativeChatMessage', () => {
})
try {
act(() => {
renderer = create(createElement(MobileNativeChatMessage, { message, onCopyText }))
renderer = create(createElement(MobileNativeChatMessage, { message, ...props }))
})
} finally {
spy.mockRestore()
@@ -95,6 +98,70 @@ describe('MobileNativeChatMessage', () => {
expect(texts.some((text) => text.includes('/tmp/host.png'))).toBe(true)
})
it('labels a tool row with the target path instead of raw input JSON', () => {
const tree = render(
toolMessage([{ type: 'tool-call', name: 'Read', input: { file_path: 'src/index.ts' } }]),
{ toolsExpanded: true }
)
const texts = textIn(tree.root)
expect(texts).toContain('src/index.ts')
expect(texts.some((text) => text.includes('"file_path":"src/index.ts"'))).toBe(false)
})
it('bounds expanded diff-less tool input before native text layout', () => {
const tree = render(
toolMessage([
{ type: 'tool-call', name: 'CustomTool', input: { payload: 'x'.repeat(100_000) } }
]),
{ toolsExpanded: true }
)
const detail = textIn(tree.root).find((text) => text.startsWith('{\n'))
expect(detail).toHaveLength(MAX_TOOL_DETAIL_LENGTH + 1)
expect(detail?.endsWith('…')).toBe(true)
})
it('expands formatted detail for a collapsed JSON-string tool input', () => {
const tree = render(
toolMessage([
{
type: 'tool-call',
name: 'CustomTool',
input: '{"cmd":"git status","description":"Inspect changes"}'
}
])
)
const pressableWith = (label: string): ReactTestInstance =>
tree.root.findAllByType('Pressable' as never).find((node) => textIn(node).includes(label))!
act(() => pressableWith('1×').props.onPress())
expect(textIn(tree.root)).toContain('git status')
expect(textIn(tree.root).some((text) => text.startsWith('{\n'))).toBe(false)
act(() => pressableWith('CustomTool').props.onPress())
expect(textIn(tree.root)).toContain(
'{\n "cmd": "git status",\n "description": "Inspect changes"\n}'
)
})
it('does not echo the row label as detail when a row has nothing to expand', () => {
const tree = render(toolMessage([{ type: 'tool-call', name: 'ListTodos', input: '{}' }]), {
toolsExpanded: true
})
expect(textIn(tree.root).filter((text) => text === '{}')).toHaveLength(1)
expect(tree.root.findAllByType('ChevronDown' as never)).toHaveLength(1)
expect(tree.root.findAllByType('SquareChevronRight' as never)).toHaveLength(1)
})
it('does not expand a plain input that already fits in the row label', () => {
const input = 'x'.repeat(60)
const tree = render(toolMessage([{ type: 'tool-call', name: 'CustomTool', input }]), {
toolsExpanded: true
})
expect(textIn(tree.root).filter((text) => text === input)).toHaveLength(1)
expect(tree.root.findAllByType('ChevronDown' as never)).toHaveLength(1)
expect(tree.root.findAllByType('SquareChevronRight' as never)).toHaveLength(1)
})
it('routes agent message copy through the injected device operation', async () => {
const onCopyText = vi.fn().mockResolvedValue(undefined)
const tree = render(
@@ -105,7 +172,7 @@ describe('MobileNativeChatMessage', () => {
timestamp: null,
source: 'transcript'
},
onCopyText
{ onCopyText }
)
await act(async () => {
@@ -80,7 +80,7 @@ export function MobileNativeChatOverlay({
onAnswerQuestion={controller.handleNativeChatQuestionAnswer}
permission={controller.nativeChatPermission}
onRespondPermission={controller.handleNativeChatRespondPermission}
onOpenFile={controller.handleNativeChatOpenFile}
onOpenFile={onOpenFile}
onOpenLink={onOpenLink}
onCopyText={onCopyText}
hasMore={session.hasMore}
@@ -50,7 +50,8 @@ export type HostSessionNativeChatOperations = {
target: HostSessionNativeChatTarget,
text: string,
deadline?: number,
clearInputFirst?: boolean
clearInputFirst?: boolean,
resolvedLaunchDraft?: { text: string; createdAt: number }
): Promise<MobileNativeChatSendOutcome>
prepareCommit(target: HostSessionNativeChatTarget, deadline?: number): Promise<boolean>
respond(
@@ -3,23 +3,26 @@ export type HostSessionTerminalFileTarget =
kind: 'worktree-file'
relativePath: string
localAbsolutePath: string | null
workspaceId?: string
}
| {
kind: 'native-artifact'
absolutePath: string
grantId: string
workspaceId?: string
}
| {
kind: 'web-artifact'
token: string
displayName: string
previewKind: 'text' | 'raster'
workspaceId?: string
}
export type HostSessionTerminalFileResolveRequest = {
workspaceId: string
tabId: string
terminalHandle: string
terminalHandle: string | null
pathText: string
cwd: string | null
line: number | null
+14 -64
View File
@@ -326,26 +326,12 @@ describe('openMobileFileTap', () => {
})
it('reports a failed files.open through onOpenFailed', async () => {
const client = createClient([
ok({
worktree: 'wt-1',
relativePath: 'src/index.ts',
absolutePath: '/repo/src/index.ts',
exists: true,
isDirectory: false,
openTarget: {
kind: 'worktree-file',
provider: 'local',
relativePath: 'src/index.ts',
absolutePath: '/repo/src/index.ts'
}
}),
{ ok: false, error: { message: 'nope' } }
])
const operations = createOperations([worktreeTarget('src/index.ts', '/repo/src/index.ts')])
operations.openWorktreeFile.mockRejectedValue(new Error('nope'))
const onOpenFailed = vi.fn()
openMobileFileTap({
client,
openMobileTerminalFileTap({
operations,
hostId: 'host-1',
worktreeId: 'wt-1',
pathText: 'src/index.ts',
@@ -370,27 +356,13 @@ describe('openMobileFileTap', () => {
})
it('reports an unsupported file when files.open declines it', async () => {
const client = createClient([
ok({
worktree: 'wt-1',
relativePath: 'dist/app.zip',
absolutePath: '/repo/dist/app.zip',
exists: true,
isDirectory: false,
openTarget: {
kind: 'worktree-file',
provider: 'local',
relativePath: 'dist/app.zip',
absolutePath: '/repo/dist/app.zip'
}
}),
ok({ worktree: 'wt-1', relativePath: 'dist/app.zip', kind: 'binary', opened: false })
])
const operations = createOperations([worktreeTarget('dist/app.zip', '/repo/dist/app.zip')])
operations.openWorktreeFile.mockRejectedValue(new Error('unsupported'))
const onOpenFailed = vi.fn()
const scheduleDelayedAction = vi.fn()
openMobileFileTap({
client,
openMobileTerminalFileTap({
operations,
hostId: 'host-1',
worktreeId: 'wt-1',
pathText: 'dist/app.zip',
@@ -416,19 +388,11 @@ describe('openMobileFileTap', () => {
})
it('does not report a stale failure after a newer tap supersedes it', async () => {
const client = createClient([
ok({
worktree: 'wt-1',
relativePath: null,
absolutePath: null,
exists: false,
isDirectory: false
})
])
const operations = createOperations([null])
const onOpenFailed = vi.fn()
openMobileFileTap({
client,
openMobileTerminalFileTap({
operations,
hostId: 'host-1',
worktreeId: 'wt-1',
pathText: 'gone/missing.ts',
@@ -455,25 +419,11 @@ describe('openMobileFileTap', () => {
})
it('does not report a failure when the user left the source tab mid-resolve', async () => {
const client = createClient([
ok({
worktree: 'wt-1',
relativePath: 'src/index.ts',
absolutePath: '/repo/src/index.ts',
exists: true,
isDirectory: false,
openTarget: {
kind: 'worktree-file',
provider: 'local',
relativePath: 'src/index.ts',
absolutePath: '/repo/src/index.ts'
}
})
])
const operations = createOperations([worktreeTarget('src/index.ts', '/repo/src/index.ts')])
const onOpenFailed = vi.fn()
openMobileFileTap({
client,
openMobileTerminalFileTap({
operations,
hostId: 'host-1',
worktreeId: 'wt-1',
pathText: 'src/index.ts',
+8 -7
View File
@@ -9,12 +9,12 @@ export type FileTapSessionTab = {
relativePath?: string
}
type OpenMobileTerminalFileTapOptions<T extends TerminalFileTapSessionTab> = {
export type OpenMobileFileTapOptions<T extends FileTapSessionTab> = {
operations: HostSessionTerminalFileOperations
hostId: string
worktreeId: string
worktreeName?: string
terminalHandle: string
terminalHandle?: string | null
pathText: string
cwd?: string | null
line: number | null
@@ -65,27 +65,28 @@ async function openMobileFileTapAsync<T extends FileTapSessionTab>(
options: OpenMobileFileTapOptions<T>
): Promise<void> {
const terminalHandle = options.terminalHandle?.trim()
if (!terminalHandle) {
const sourceTabId = terminalHandle || options.getActivationState(false).sourceTerminalHandle
if (!sourceTabId) {
return
}
const resolved = await options.operations.resolveTerminalPath({
workspaceId: options.worktreeId,
tabId: terminalHandle,
terminalHandle,
tabId: sourceTabId,
terminalHandle: terminalHandle || null,
pathText: options.pathText,
cwd: options.cwd?.trim() || null,
line: options.line,
column: options.column
})
if (!resolved) {
reportOpenFailure(options)
return
}
// Not a failure: the user moved off the source tab mid-resolve.
if (!shouldActivateOpenedMobileSessionTab(options.getActivationState(false))) {
return
}
const resolvedWorktreeId = resolved.worktree?.trim() || options.worktreeId
const resolvedWorktree = `id:${resolvedWorktreeId}`
const resolvedWorktreeId = resolved.workspaceId?.trim() || options.worktreeId
const resolvedWorktreeName =
resolvedWorktreeId === options.worktreeId ? options.worktreeName : undefined
@@ -1,12 +1,12 @@
import type { Dispatch, MutableRefObject, SetStateAction } from 'react'
import type { parseAskFromStatus } from './mobile-native-chat-ask'
import type { AskAnswerSelection, AskPrompt, parseAskFromStatus } from './mobile-native-chat-ask'
import type { detectAgentPermission } from './mobile-native-chat-permission'
import type { parseAgentQuestion } from './mobile-native-chat-question'
import type { HostSessionNativeChatTarget } from './host-session-native-chat-operations'
import type { MobileNativeChatSendOutcome } from './mobile-native-chat-send'
import type { MobileNativeChatAnswerSend } from './use-mobile-native-chat-answer-send'
import type { MobileNativeChatPendingMessage } from './use-mobile-native-chat-pending-deliveries'
import type { useMobileNativeChatSession } from './use-mobile-native-chat-session'
import type { MobileNativeChatSessionOptionPickersProps } from './MobileNativeChatSessionOptionPickers'
export type MobileNativeChatController = {
isTabChatView: (tabId: string) => boolean
@@ -17,15 +17,22 @@ export type MobileNativeChatController = {
chatComposerText: string
setChatComposerText: Dispatch<SetStateAction<string>>
chatPending: MobileNativeChatPendingMessage[]
chatImagePreviewsByMessageId: Record<string, string[]>
nativeChatSession: ReturnType<typeof useMobileNativeChatSession>
nativeChatAgentWorking: boolean
nativeChatTargetRef: MutableRefObject<HostSessionNativeChatTarget | null>
nativeChatStreamingText?: string
nativeChatStreamLive: boolean
nativeChatStreamScopeKey: string
nativeChatPermission: ReturnType<typeof detectAgentPermission>
nativeChatQuestion: ReturnType<typeof parseAgentQuestion>
nativeChatAsk: ReturnType<typeof parseAskFromStatus>
handleNativeChatOpenFile: (relativePath: string) => void
handleNativeChatAnswerAsk: MobileNativeChatAnswerSend['answerAsk']
nativeChatAskKey: string | null
dismissNativeChatAsk: () => void
handleNativeChatAnswerAsk: (
prompt: AskPrompt,
selections: AskAnswerSelection[]
) => Promise<boolean>
handleNativeChatCancelAsk: () => Promise<boolean>
handleNativeChatRespondPermission: (text: string) => Promise<boolean>
handleNativeChatStop: () => void
@@ -38,4 +45,6 @@ export type MobileNativeChatController = {
images?: string[],
deadline?: number
) => Promise<MobileNativeChatSendOutcome>
readSeededLaunchDraft: () => string | null
nativeChatSessionOptions: MobileNativeChatSessionOptionPickersProps | null
}
@@ -102,7 +102,7 @@ describe('resolveMobileNativeChat', () => {
expect(resolveMobileNativeChat({ type: 'terminal', launchAgent: 'gemini' })).toBeNull()
})
it('admits Grok for local, runtime-owned, and classic SSH transcript readers', () => {
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'
@@ -112,7 +112,7 @@ describe('resolveMobileNativeChat', () => {
).toMatchObject({ agent: 'grok' })
expect(
resolveMobileNativeChat(tab, isMobileNativeChatTranscriptReadable('model-a-ssh'))
).toMatchObject({ agent: 'grok' })
).toBeNull()
})
// Why: omp's hook reports no transcript path either, so mobile can only show
@@ -1,12 +1,14 @@
import type { MobileWebNativeChatAgentStatus } from '../../../src/shared/mobile-web/native-chat-operation-contract'
import { isNativeChatSupportedAgent } from '../../../src/shared/native-chat-agent-support'
import { isRuntimeOwnedSshTargetId } from '../../../src/shared/execution-host'
import {
isNativeChatSupportedAgent,
nativeChatRequiresLocalTranscript
} from '../../../src/shared/native-chat-agent-support'
// Why: undefined means the workspace owner could not be resolved. Local,
// runtime-owned, and classic SSH owners all have bounded transcript readers.
export function isMobileNativeChatTranscriptReadable(
connectionId: string | null | undefined
): boolean {
return connectionId !== undefined
return connectionId === null || isRuntimeOwnedSshTargetId(connectionId)
}
export type MobileNativeChatResolution = {
@@ -25,10 +27,12 @@ export type MobileNativeChatTab = {
agentStatus?: MobileNativeChatAgentStatusWithProvider | null
/** Host-provided launch context still parked as an unsent TUI-input draft. */
launchDraft?: string
launchDraftCreatedAt?: number
nativeChatSessionId?: string | null
}
export type MobileNativeChatAgentStatusWithProvider = MobileWebNativeChatAgentStatus & {
model?: string
providerSession?: {
id: string
transcriptPath?: string
@@ -26,6 +26,8 @@ type PasteImagesArgs = {
/** Budget shared with the rest of the user action (the text body that follows, or
* the send this is healing for). Omit to open a fresh one for this paste alone. */
readonly deadline?: number
/** Clears every parked launch-draft line before the image paste. */
readonly clearInput?: string
readonly assertCurrent?: () => void
}
@@ -40,6 +42,7 @@ export async function pasteMobileNativeChatImagePaths({
deviceToken,
imagePaths,
deadline: sharedDeadline,
clearInput,
assertCurrent = () => {}
}: PasteImagesArgs): Promise<boolean> {
const mobileClient: MobileTerminalClient | null = deviceToken
@@ -1,3 +1,4 @@
import { buildAgentTuiClearInputForText } from '../../../src/shared/agent-tui-input-clear'
import type { RpcClient } from '../transport/rpc-client'
import type { ConnectionState } from '../transport/types'
import type {
@@ -39,6 +40,7 @@ export async function sendMobileNativeChatWithImages(args: {
readonly operations: HostSessionNativeChatOperations | null
readonly targetRef: CurrentRef<HostSessionNativeChatTarget | null>
readonly baseSend: MobileNativeChatImageBaseSend
readonly readSeededLaunchDraft: () => string | null
readonly onSent: (sentIds: ReadonlySet<string>) => void
readonly onError?: () => void
readonly onSendError: (message: string) => void
@@ -62,13 +64,17 @@ export async function sendMobileNativeChatWithImages(args: {
}
try {
const references = args.pendingImages.map((attachment) => attachment.path)
const seededLaunchDraft = args.readSeededLaunchDraft()
const pasted = args.client
? await pasteMobileNativeChatImagePaths({
client: args.client,
terminal: handle,
deviceToken: args.deviceTokenRef.current,
imagePaths: references,
deadline
deadline,
...(seededLaunchDraft
? { clearInput: buildAgentTuiClearInputForText(seededLaunchDraft) }
: {})
})
: await args.operations!.pasteImages!(target!, references, deadline)
if (!pasted) {
@@ -1,5 +1,6 @@
import { describe, expect, it, vi } from 'vitest'
import { openMobileNativeChatFileTap } from './mobile-native-chat-open-file'
import { nativeHostSessionTerminalFileOperations } from './native-host-session-terminal-file-operations'
function ok(result: unknown) {
return { ok: true, result, _meta: { runtimeId: 'runtime-1' } }
@@ -18,7 +19,7 @@ function activationState(activated: boolean) {
function baseOptions(client: { sendRequest: ReturnType<typeof vi.fn> }) {
return {
client,
operations: nativeHostSessionTerminalFileOperations(client as never),
hostId: 'host-1',
worktreeId: 'wt-1',
pushPreviewRoute: vi.fn(),
@@ -71,6 +72,7 @@ describe('openMobileNativeChatFileTap', () => {
openMobileNativeChatFileTap({ ...options, pathText: 'src/app.ts:120:7' })
await Promise.resolve()
await Promise.resolve()
expect(sendRequest).toHaveBeenCalledWith(
'files.resolveTerminalPath',
@@ -120,6 +122,7 @@ describe('openMobileNativeChatFileTap', () => {
openMobileNativeChatFileTap({ ...options, pathText: 'src/app.ts' })
await Promise.resolve()
await Promise.resolve()
await Promise.resolve()
expect(options.onOpenFailed).toHaveBeenCalledTimes(1)
})
@@ -1,4 +1,6 @@
import { splitFilePathLineSuffix } from '../components/markdown-file-path-detection'
import type { RuntimeTerminalPathResolution } from '../../../src/shared/runtime-types'
import type { RpcClient } from '../transport/rpc-client'
import {
openMobileFileTap,
type FileTapSessionTab,
@@ -28,3 +30,50 @@ export function openMobileNativeChatFileTap<T extends FileTapSessionTab>(
column
})
}
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 {
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) {
return
}
try {
await args.client.sendRequest('files.open', {
worktree: `id:${args.worktreeId}`,
relativePath
})
} catch {
// Best-effort open from transcript content.
}
}
@@ -17,6 +17,17 @@ export type MobileNativeChatSendOrigin = {
type PendingByKey = Record<string, MobileNativeChatPendingMessage[]>
export function nextMobileNativeChatPendingId(counter: { current: number }): string {
counter.current += 1
return `pending-${counter.current}`
}
export function omitMobileNativeChatPendingKey(state: PendingByKey, key: string): PendingByKey {
const next = { ...state }
delete next[key]
return next
}
export function combineMobileNativeChatPending(
session: MobileNativeChatPendingMessage[],
waiting: readonly MobileNativeChatPendingMessage[]
@@ -14,6 +14,11 @@ import {
resetMobileNativeChatStaleInputForTests
} from './mobile-native-chat-stale-input'
import { nativeHostSessionNativeChatOperations } from './native-host-session-native-chat-operations'
import {
acquireMobileNativeChatTerminalWrite,
releaseMobileNativeChatTerminalWrite,
resetMobileNativeChatTerminalWritesForTests
} from './mobile-native-chat-terminal-write-lock'
const target = {
workspaceId: 'worktree',
@@ -125,11 +130,13 @@ describe('useMobileNativeChatPermissionSend', () => {
result: { send: { handle: 'terminal', accepted: true, bytesWritten: 1 } }
})
function Harness(): null {
const operations = nativeHostSessionNativeChatOperations({
sendRequest
} as unknown as RpcClient)
respond = useMobileNativeChatPermissionSend({
client: { sendRequest } as unknown as RpcClient,
operations,
targetRef: { current: target },
enabled: true,
handleRef: { current: 'terminal' },
deviceTokenRef: { current: null },
onSendError
})
return null
@@ -4,6 +4,10 @@ import type {
HostSessionNativeChatTarget
} from './host-session-native-chat-operations'
import type { MobileNativeChatSendOutcome } from './mobile-native-chat-send'
import {
acquireMobileNativeChatTerminalWrite,
releaseMobileNativeChatTerminalWrite
} from './mobile-native-chat-terminal-write-lock'
export function sendMobileNativeChatPermissionResponse(args: {
operations: HostSessionNativeChatOperations
@@ -28,6 +32,7 @@ export function useMobileNativeChatPermissionSend(args: {
args.onSendError('Response not sent (disconnected)')
return false
}
const terminal = target.terminalId ?? target.sessionId
// A choice keystroke must not interleave into a mid-flight composed write
// (image paste, paced answer) on the same PTY.
if (!acquireMobileNativeChatTerminalWrite(terminal)) {
@@ -37,11 +42,16 @@ export function useMobileNativeChatPermissionSend(args: {
// No stale-input heal here (unlike the text/ask sends): a choice is an
// `enter: false` key for an active overlay that swallows the clear, so it
// would consume the marker still protecting the next real message.
const outcome = await sendMobileNativeChatPermissionResponse({
operations: args.operations,
target,
text
})
let outcome: MobileNativeChatSendOutcome
try {
outcome = await sendMobileNativeChatPermissionResponse({
operations: args.operations,
target,
text
})
} finally {
releaseMobileNativeChatTerminalWrite(terminal)
}
if (outcome === 'unknown') {
// Why: the response may have been delivered (ack lost / path cutover) —
// a definite "not sent" would invite a double answer.
@@ -331,7 +331,13 @@ describe('applyMobileNativeChatStreamFrame', () => {
limit: 40,
replaceSnapshot: true
})
).toEqual({ kind: 'messages', messages: [], lifecycle })
).toEqual({
kind: 'messages',
messages: [],
hasMore: undefined,
lifecycle,
windowReplaced: true
})
})
it('surfaces snapshot errors and ignores unrelated frames', () => {
@@ -24,6 +24,7 @@ export type AppliedMobileNativeChatFrame =
beforeOffset?: number
cursorInvalidated?: boolean
lifecycle?: NativeChatTurnLifecycle
windowReplaced?: boolean
}
function replayRetainedTailStart(
@@ -90,6 +91,7 @@ export function applyMobileNativeChatStreamFrame(args: {
kind: 'messages',
messages: merger.list,
hasMore: frame.hasMore,
windowReplaced: true,
...(frame.beforeOffset == null ? {} : { beforeOffset: frame.beforeOffset }),
...(frame.lifecycle === undefined ? {} : { lifecycle: frame.lifecycle })
}
@@ -103,7 +105,15 @@ export function applyMobileNativeChatStreamFrame(args: {
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 } : {}),
...(cursorInvalidated ? { cursorInvalidated: true } : {}),
...(frame.type === 'snapshot' && cursorInvalidated
? { hasMore: true }
: replayStillStartsAtOldest
? {
...(frame.hasMore == null ? {} : { hasMore: frame.hasMore }),
...(frame.beforeOffset == null ? {} : { beforeOffset: frame.beforeOffset })
}
: {}),
...(frame.lifecycle === undefined ? {} : { lifecycle: frame.lifecycle })
}
}
@@ -96,7 +96,7 @@ describe('mobile session route', () => {
})
})
it('routes the home Resume card through the cold-navigator-safe transition', () => {
it('routes the home Resume card through the hybrid navigation intent', () => {
const start = homeSource.indexOf('{/* ─── Resume card ─── */}')
const end = homeSource.indexOf('{/* ─── Quick actions ─── */}', start)
@@ -109,16 +109,15 @@ describe('mobile session route', () => {
expect(resumeCard).toContain('openResume(')
expect(resumeCard).not.toContain('router.push(')
// The tap handler itself must go through the coordinated transition; its only
// direct push is the shallow noticed host-index route for a proven-missing target.
const handlerStart = homeSource.indexOf('const openResume = useCallback(')
const handlerEnd = homeSource.indexOf('[openMobileSession, router]', handlerStart)
const handlerEnd = homeSource.indexOf('[router]', handlerStart)
expect(handlerStart).toBeGreaterThanOrEqual(0)
expect(handlerEnd).toBeGreaterThan(handlerStart)
const openResume = homeSource.slice(handlerStart, handlerEnd)
expect(openResume).toContain('openMobileSession({')
expect(openResume.match(/router\.push\(/g)).toHaveLength(1)
expect(openResume).toContain('router.push(hostRouteWithNotice(')
expect(openResume).toContain('navigateFromMobileHome({')
expect(openResume).toContain("target: { kind: 'workspaceList' }")
expect(openResume).toContain("target: { kind: 'session', hostWorkspaceId:")
expect(openResume).not.toContain('router.push(')
})
})
@@ -24,17 +24,17 @@ function sliceBetween(startPattern: string, endPattern: string): string {
describe('mobile session startup', () => {
it('auto-creates one terminal for an initially empty connected session', () => {
expect(source).toContain('const initialEmptySessionAutoCreateRef = useRef<string | null>(null)')
expect(source).toContain('initialEmptySessionAutoCreateRef.current = null')
const autoCreateEffect = sliceBetween(
'if (\n (!client && !sessionTabOperations) ||\n !showEmptyState',
'const terminalSummary ='
expect(source).toContain(
'const initialSessionAutoCreateRef = useRef(createInitialSessionAutoCreateState())'
)
expect(autoCreateEffect).toContain('initialEmptySessionAutoCreateRef.current === worktreeId')
expect(autoCreateEffect).toContain('initialEmptySessionAutoCreateRef.current = worktreeId')
expect(autoCreateEffect).toContain("setCreateError('')")
expect(autoCreateEffect).toContain('void handleCreateTerminal()')
expect(source).toContain(
'initialSessionAutoCreateRef.current = createInitialSessionAutoCreateState()'
)
expect(source).toContain('useInitialSessionTerminalAutoCreate({')
expect(autoCreateHookSource).toContain('stateRef.current.autoCreatedForWorktree = worktreeId')
expect(autoCreateHookSource).toContain('createTerminal()')
expect(source).toContain("setCreateError('')")
expect(source).toContain('void handleCreateTerminal()')
expect(source).toContain(
'const hostedAdapterCreate = !client && sessionTabOperations && !options'
)
@@ -8,7 +8,9 @@ type MobileTerminalDiagnosticEvent =
| 'stream-armed'
| 'stream-first-event'
| 'stream-resized'
| 'stream-resubscribe-exhausted'
| 'stream-resubscribe-for-viewport'
| 'stream-resubscribe-held-absent-dims'
| 'stream-scrollback'
| 'stream-skipped'
| 'tab-switch'
@@ -158,7 +160,13 @@ export class MobileTerminalDiagnostics {
})
}
streamResubscribing(_handle: string, seq: number, dims: { cols: number; rows: number }): void {
streamResubscribing(
_handle: string,
seq: number,
dims: { cols: number; rows: number },
attempt?: number,
delayMs?: number
): void {
logMobileTerminalDiagnostic('stream-resubscribe-for-viewport', {
seq,
cols: dims.cols,
@@ -168,16 +176,14 @@ export class MobileTerminalDiagnostics {
})
}
streamResubscribeHeld(handle: string, seq: number): void {
streamResubscribeHeld(_handle: string, seq: number): void {
logMobileTerminalDiagnostic('stream-resubscribe-held-absent-dims', {
handle: shortenMobileTerminalDiagnosticId(handle),
seq
})
}
streamResubscribeExhausted(handle: string, seq: number, attempts: number): void {
streamResubscribeExhausted(_handle: string, seq: number, attempts: number): void {
logMobileTerminalDiagnostic('stream-resubscribe-exhausted', {
handle: shortenMobileTerminalDiagnosticId(handle),
seq,
attempts
})
@@ -0,0 +1 @@
export { openMobileFileTap as openMobileTerminalFileTap } from './mobile-file-tap-open'
@@ -19,6 +19,7 @@ export type MobileTerminalSessionTab = {
agentStatus?: MobileNativeChatAgentStatusWithProvider | null
/** Host-provided launch context still parked as an unsent TUI-input draft. */
launchDraft?: string
launchDraftCreatedAt?: number
launchAgent?: string
nativeChatSessionId?: string
terminalTheme?: MobileTerminalTheme
@@ -65,8 +65,8 @@ export function nativeHostSessionNativeChatOperations(
return { error: 'Transcript read failed' }
}
},
sendMessage(target, text, deadline, clearInputFirst) {
return sendNative(target, text, true, client, deadline, clearInputFirst)
sendMessage(target, text, deadline, clearInputFirst, resolvedLaunchDraft) {
return sendNative(target, text, true, client, deadline, clearInputFirst, resolvedLaunchDraft)
},
prepareCommit(target, deadline) {
if (!target.terminalId) {
@@ -159,7 +159,8 @@ function sendNative(
enter: boolean,
client: RpcClient,
deadline?: number,
clearInputFirst?: boolean
clearInputFirst?: boolean,
resolvedLaunchDraft?: { text: string; createdAt: number }
): Promise<MobileNativeChatSendOutcome> {
if (!target.terminalId) {
return Promise.resolve('rejected')
@@ -170,6 +171,7 @@ function sendNative(
text,
enter,
clearInputFirst,
resolvedLaunchDraft,
deadline,
...(target.clientId ? { mobileClient: { id: target.clientId, type: 'mobile' as const } } : {})
})
@@ -16,7 +16,8 @@ export function nativeHostSessionTerminalFileOperations(
{
worktree: `id:${request.workspaceId}`,
pathText: request.pathText,
terminal: request.terminalHandle,
crossWorkspace: true,
...(request.terminalHandle ? { terminal: request.terminalHandle } : {}),
...(request.cwd ? { cwd: request.cwd } : {})
},
{ timeoutMs: 10_000 }
@@ -51,7 +52,8 @@ function nativeTerminalFileTarget(
return {
kind: 'native-artifact',
absolutePath: resolved.openTarget.absolutePath,
grantId: resolved.openTarget.grantId
grantId: resolved.openTarget.grantId,
...(resolved.worktree ? { workspaceId: resolved.worktree } : {})
}
}
const relativePath =
@@ -64,6 +66,7 @@ function nativeTerminalFileTarget(
return {
kind: 'worktree-file',
relativePath,
...(resolved.worktree ? { workspaceId: resolved.worktree } : {}),
localAbsolutePath:
resolved.openTarget?.kind === 'worktree-file' && resolved.openTarget.provider === 'local'
? resolved.openTarget.absolutePath
@@ -47,12 +47,16 @@ describe('native host session terminal operations', () => {
await expect(operations.sendInput('terminal-native-1', 'ls', true, 'device-1')).resolves.toBe(
true
)
expect(sendRequest).toHaveBeenCalledWith('terminal.send', {
terminal: 'terminal-native-1',
text: 'ls',
enter: true,
client: { id: 'device-1', type: 'mobile' }
})
expect(sendRequest).toHaveBeenCalledWith(
'terminal.send',
{
terminal: 'terminal-native-1',
text: 'ls',
enter: true,
client: { id: 'device-1', type: 'mobile' }
},
{ failWhenDisconnected: true }
)
await expect(
operations.setDisplayMode('terminal-native-1', 'auto', { cols: 90, rows: 30 }, 'device-1')
).resolves.toBe(true)
@@ -1,5 +1,6 @@
import { sendMobileTerminalQueryReply } from '../terminal/mobile-terminal-query-reply'
import { isTerminalSendRpcAccepted } from '../terminal/terminal-send-rpc-response'
import { TERMINAL_INPUT_SEND_OPTIONS } from '../terminal/terminal-send-request'
import type { RpcClient } from '../transport/rpc-client'
import type { HostSessionTerminalOperations } from './host-session-terminal-operations'
import { subscribeMobileTerminalSafely } from './mobile-terminal-stream-subscribe'
@@ -30,12 +31,16 @@ export function nativeHostSessionTerminalOperations(
acknowledge() {},
async sendInput(terminalId, text, enter, clientId) {
return client
.sendRequest('terminal.send', {
terminal: terminalId,
text,
enter,
...(clientId ? { client: { id: clientId, type: 'mobile' as const } } : {})
})
.sendRequest(
'terminal.send',
{
terminal: terminalId,
text,
enter,
...(clientId ? { client: { id: clientId, type: 'mobile' as const } } : {})
},
TERMINAL_INPUT_SEND_OPTIONS
)
.then(isTerminalSendRpcAccepted, () => false)
},
sendQueryReply(terminalId, bytes, clientId, hostSupportsQueryReply) {
@@ -11,10 +11,8 @@ import {
type MobileDiffReviewQueueItem
} from './mobile-diff-review-queue'
import { findMobileDiffReviewInitialIndex } from './mobile-diff-review-positioning'
import {
loadMobileDiffReviewDiff,
loadMobileDiffReviewSnapshot
} from './mobile-diff-review-loaders'
import { loadMobileDiffReviewSnapshot } from './mobile-diff-review-loaders'
import { useMobileDiffReviewDiffLoading } from './use-mobile-diff-review-diff-loading'
import { canOpenMobileBranchCompareDiff } from '../source-control/mobile-branch-compare'
import type {
ComposerState,
@@ -2,6 +2,7 @@ import { createElement } from 'react'
import { act, create, type ReactTestRenderer } from 'react-test-renderer'
import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest'
import { useMobileFileTapHandlers } from './use-mobile-file-tap-handlers'
import { nativeHostSessionTerminalFileOperations } from './native-host-session-terminal-file-operations'
const push = vi.fn()
@@ -31,7 +32,7 @@ describe('useMobileFileTapHandlers', () => {
function createOptions(sendRequest: ReturnType<typeof vi.fn>) {
return {
client: { sendRequest },
operations: nativeHostSessionTerminalFileOperations({ sendRequest } as never),
hostId: 'host-1',
worktreeId: 'wt-1',
worktreeName: 'Orca',
@@ -77,7 +78,12 @@ describe('useMobileFileTapHandlers', () => {
act(() => {
renderer!.update(
createElement(Harness, {
options: { ...firstOptions, client: { sendRequest: latestSendRequest } }
options: {
...firstOptions,
operations: nativeHostSessionTerminalFileOperations({
sendRequest: latestSendRequest
} as never)
}
})
)
})
@@ -1,12 +1,12 @@
import { useCallback, useLayoutEffect, useRef, type MutableRefObject } from 'react'
import { useRouter } from 'expo-router'
import { triggerSelection } from '../platform/haptics'
import type { RpcClient } from '../transport/rpc-client'
import type { HostSessionTerminalFileOperations } from './host-session-terminal-file-operations'
import { openMobileFileTap, type FileTapSessionTab } from './mobile-file-tap-open'
import { openMobileNativeChatFileTap } from './mobile-native-chat-open-file'
type MobileFileTapHandlerOptions<T extends FileTapSessionTab> = {
client: Pick<RpcClient, 'sendRequest'> | null
operations: HostSessionTerminalFileOperations | null
hostId: string
worktreeId: string
worktreeName?: string
@@ -42,7 +42,7 @@ export function useMobileFileTapHandlers<T extends FileTapSessionTab>(
} {
const {
activeHandleRef,
client,
operations,
fetchSessionTabs,
getActiveSessionTabId,
getActiveSessionTabType,
@@ -65,7 +65,7 @@ export function useMobileFileTapHandlers<T extends FileTapSessionTab>(
routerRef.current = router
optionsRef.current = {
activeHandleRef,
client,
operations,
fetchSessionTabs,
getActiveSessionTabId,
getActiveSessionTabType,
@@ -81,13 +81,13 @@ export function useMobileFileTapHandlers<T extends FileTapSessionTab>(
}
}, [
activeHandleRef,
client,
fetchSessionTabs,
getActiveSessionTabId,
getActiveSessionTabType,
getSessionTabs,
hostId,
openBrowser,
operations,
router,
scheduleDelayedAction,
reportChatTapFailure,
@@ -100,12 +100,12 @@ export function useMobileFileTapHandlers<T extends FileTapSessionTab>(
const handleFileTap = useCallback(
(handle: string, pathText: string, line: number | null, column: number | null) => {
const current = optionsRef.current
if (handle !== current.activeHandleRef.current || !current.client) {
if (handle !== current.activeHandleRef.current || !current.operations) {
return
}
const activationSeq = ++activationSeqRef.current
openMobileFileTap<T>({
client: current.client,
operations: current.operations,
hostId: current.hostId,
worktreeId: current.worktreeId,
worktreeName: current.worktreeName,
@@ -140,12 +140,12 @@ export function useMobileFileTapHandlers<T extends FileTapSessionTab>(
// The chat overlay rides on its backing terminal tab; that handle anchors
// the activation gate even though resolution ignores the terminal's cwd.
const sourceTerminalHandle = current.activeHandleRef.current
if (!current.client || !sourceTerminalHandle) {
if (!current.operations || !sourceTerminalHandle) {
return
}
const activationSeq = ++activationSeqRef.current
openMobileNativeChatFileTap<T>({
client: current.client,
operations: current.operations,
hostId: current.hostId,
worktreeId: current.worktreeId,
worktreeName: current.worktreeName,
@@ -12,6 +12,11 @@ import {
resetMobileNativeChatStaleInputForTests
} from './mobile-native-chat-stale-input'
import { nativeHostSessionNativeChatOperations } from './native-host-session-native-chat-operations'
import {
acquireMobileNativeChatTerminalWrite,
releaseMobileNativeChatTerminalWrite,
resetMobileNativeChatTerminalWritesForTests
} from './mobile-native-chat-terminal-write-lock'
import { useMobileNativeChatAnswerSend } from './use-mobile-native-chat-answer-send'
import { useNativeChatAcceptedAction } from './use-native-chat-action-outcomes'
@@ -67,6 +67,12 @@ export function useMobileNativeChatAnswerSend(args: {
useEffect(() => {
activeRouteRef.current = { operations, enabled, sessionId, streamIdentity }
}, [enabled, operations, sessionId, streamIdentity])
// Per-terminal count of this hook's chains sharing one write-lock hold: a
// superseding answer inherits the cancelled chain's hold (it re-enters before
// the old chain unwinds), and only the last chain out releases the lock.
const writeHoldsRef = useRef(new Map<string, number>())
// Successors wait for the prior RPC and inherit any delivery ambiguity.
const writeTurnsRef = useRef(new Map<string, Promise<boolean>>())
const delaysRef = useRef<
Set<{ timer: ReturnType<typeof setTimeout>; resolve: (completed: boolean) => void }>
>(new Set())
@@ -98,6 +104,7 @@ export function useMobileNativeChatAnswerSend(args: {
if (!hasAskAnswer(prompt, selections)) {
return false
}
const handle = target.terminalId ?? target.sessionId
// One composed write sequence per terminal: an answer landing mid-flight
// in an image paste (or vice versa) would interleave bytes into the PTY.
// A superseding answer shares the cancelled chain's hold on this terminal
@@ -120,74 +127,18 @@ export function useMobileNativeChatAnswerSend(args: {
const generation = generationRef.current
let sawUnknownOutcome = false
let sawAcceptedGroup = false
// One budget for the whole answer instead of a fresh timeout per keystroke
// group, which let an N-group selector hold the card for N × the send timeout.
// It bounds transport time only: each deliberate pacing wait is credited back
// below, so a long multi-question answer still gets a full budget to write in.
let deadline = openMobileNativeChatSendBudget()
const sendTerminal = async (body: string, enter: boolean): Promise<boolean> => {
const activeRoute = activeRouteRef.current
if (
!activeRoute.enabled ||
activeRoute.operations !== operations ||
activeRoute.sessionId !== sessionId ||
activeRoute.streamIdentity !== streamIdentity ||
targetRef.current !== target
) {
return false
}
const outcome = await operations.respond(target, body, enter, deadline)
if (outcome === 'unknown') {
sawUnknownOutcome = true
}
if (outcome === 'accepted') {
sawAcceptedGroup = true
}
return outcome === 'accepted'
}
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) {
// Why: keystrokes that may have landed (ack lost / path cutover) must
// not read as a definite failure — a blind resend could double-step
// the selector. An earlier group that WAS accepted is the same hazard
// in definite form: a multi-question answer whose shared budget ran out
// mid-sequence left the remote selector half-stepped, and telling the
// user nothing was sent invites a retry on top of the advanced state.
onSendError(
sawAcceptedGroup
? 'Answer partly sent — check chat before retrying'
: sawUnknownOutcome
? 'Answer unconfirmed — check chat before retrying'
: 'Answer not sent'
)
}
return false
}
// Grok commits pasted labels; Claude and Codex need their selector-specific
// keystrokes paced so each step renders before the next lands.
if (!shouldStepNativeChatAskAnswer(agentRef.current)) {
// This shape pastes the label into the composer and commits it, so an
// orphaned image paste would be submitted along with the answer (#10228).
// The selector shapes below deliberately skip the heal: their keys are
// `enter: false` for an active overlay, and a single-select answer is a
// bare option digit that cannot submit the line at all, so clearing there
// would consume the marker still protecting the next real message.
// Desktop splits it identically — use-native-chat-interactive-send.ts
// routes only the pasted-label shape through the clearing sender.
if (!(await operations.prepareCommit(target, deadline))) {
if (generationRef.current === generation) {
onSendError('Answer not sent')
let predecessorSafe = true
try {
predecessorSafe = await previousTurn
if (!predecessorSafe) {
// Fenced. Report it: the card re-enables on a false result, so silence
// here is indistinguishable from a dead button. "Check chat" rather than
// a bare "not sent" because the PREVIOUS answer's keys may have landed.
// Gate on the turn slot, not the generation: a dropped input lease bumps
// the generation without writing the Escape that Stop and ask-cancel do,
// so the card is still up and silence there strands an advanced selector.
if (writeTurnsRef.current.get(handle) === turn) {
onSendError('Answer not sent — check chat before retrying')
}
return false
}
@@ -204,23 +155,14 @@ export function useMobileNativeChatAnswerSend(args: {
const activeRoute = activeRouteRef.current
if (
!activeRoute.enabled ||
activeRoute.client !== client ||
activeRoute.operations !== operations ||
activeRoute.sessionId !== sessionId ||
activeRoute.streamIdentity !== streamIdentity ||
handleRef.current !== handle
targetRef.current !== target
) {
return false
}
const outcome = await sendMobileNativeChatMessageWithOutcome({
client,
terminal: handle,
text: body,
enter,
deadline,
...(deviceTokenRef.current
? { mobileClient: { id: deviceTokenRef.current, type: 'mobile' } }
: {})
})
const outcome = await operations.respond(target, body, enter, deadline)
if (outcome === 'unknown') {
sawUnknownOutcome = true
}
@@ -275,14 +217,7 @@ export function useMobileNativeChatAnswerSend(args: {
// would consume the marker still protecting the next real message.
// Desktop splits it identically — use-native-chat-interactive-send.ts
// routes only the pasted-label shape through the clearing sender.
if (
!(await healMobileNativeChatStaleInput({
client,
terminal: handle,
deviceToken: deviceTokenRef.current,
deadline
}))
) {
if (!(await operations.prepareCommit(target, deadline))) {
if (generationRef.current === generation) {
onSendError('Answer not sent')
}
@@ -1,12 +1,10 @@
import { useCallback, useEffect, useMemo, useRef, type MutableRefObject } from 'react'
import { useCallback, useEffect, useLayoutEffect, useRef, type MutableRefObject } from 'react'
import { useMobileSessionViewMode } from './use-mobile-session-view-mode'
import { type MobileNativeChatTab, resolveMobileNativeChat } from './mobile-native-chat-eligibility'
import type {
HostSessionNativeChatOperations,
HostSessionNativeChatTarget
} from './host-session-native-chat-operations'
import type { HostSessionNativeChatOperations } from './host-session-native-chat-operations'
import { useMobileNativeChatPermissionSend } from './mobile-native-chat-permission-send'
import { useMobileNativeChatAnswerSend } from './use-mobile-native-chat-answer-send'
import { useMobileNativeChatAskDismiss } from './use-mobile-native-chat-ask-dismiss'
import { useMobileNativeChatDrafts } from './use-mobile-native-chat-drafts'
import { useMobileNativeChatFileSearch } from './use-mobile-native-chat-file-search'
import { useMobileNativeChatMessageSend } from './use-mobile-native-chat-message-send'
@@ -15,6 +13,7 @@ import { useMobileNativeChatSession } from './use-mobile-native-chat-session'
import { useMobileNativeChatSessionOptions } from './use-mobile-native-chat-session-options'
import { useMobileNativeChatPrompts } from './use-mobile-native-chat-prompts'
import { useMobileNativeChatStop } from './use-mobile-native-chat-stop'
import { useMobileNativeChatTarget } from './use-mobile-native-chat-target'
import { useNativeChatAcceptedAction } from './use-native-chat-action-outcomes'
import { useThrottledLatestValue } from './use-throttled-latest-value'
import { isMobileNativeChatAgentWorking } from './mobile-native-chat-working-state'
@@ -98,33 +97,18 @@ export function useMobileNativeChatController(args: {
const activeChatSessionId = activeChatResolution?.sessionId ?? null
const activeTerminalId = activeHandleRef.current
const nativeClientId = deviceTokenRef.current
const streamIdentity = `${hostId}\0${worktreeId}\0${activeSessionTabId ?? ''}\0${activeChatSessionId ?? ''}\0${activeTerminalId ?? ''}`
const routeKey = `${hostId}\0${worktreeId}\0${activeSessionTabId ?? ''}`
const streamIdentity = `${routeKey}\0${activeChatSessionId ?? ''}\0${activeTerminalId ?? ''}`
const streamScopeKey = `${routeKey}\0${activeSessionTab?.agentStatus?.providerSession?.id ?? ''}\0${activeTerminalId ?? ''}`
const nativeChatTarget = useMemo<HostSessionNativeChatTarget | null>(
() =>
activeChatResolution?.sessionId
? {
workspaceId: worktreeId,
agent: activeChatResolution.agent,
sessionId: activeChatResolution.sessionId,
transcriptPath: activeChatResolution.transcriptPath,
terminalId: activeTerminalId,
clientId: nativeClientId
}
: null,
[
activeChatResolution?.agent,
activeChatResolution?.sessionId,
activeChatResolution?.transcriptPath,
activeTerminalId,
nativeClientId,
worktreeId
]
)
const nativeChatTargetRef = useRef(nativeChatTarget)
useEffect(() => {
nativeChatTargetRef.current = nativeChatTarget
}, [nativeChatTarget])
const { target: nativeChatTarget, targetRef: nativeChatTargetRef } = useMobileNativeChatTarget({
workspaceId: worktreeId,
agent: activeChatResolution?.agent ?? null,
sessionId: activeChatResolution?.sessionId ?? null,
transcriptPath: activeChatResolution?.transcriptPath ?? null,
terminalId: activeTerminalId,
clientId: nativeClientId
})
const nativeChatSession = useMobileNativeChatSession({
operations,
workspaceId: worktreeId,
@@ -168,6 +152,7 @@ export function useMobileNativeChatController(args: {
nativeChatStatus,
nativeChatSession.lifecycle
)
const nativeChatStreamLive = activeSessionTab?.agentStatus?.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(
@@ -203,17 +188,6 @@ export function useMobileNativeChatController(args: {
observing: nativeChatAskObservable
})
const handleNativeChatOpenFile = useCallback(
(pathText: string) => {
const target = nativeChatTargetRef.current
if (!operations || !target) {
return
}
void operations.openFile(target, pathText).catch(() => {})
},
[operations]
)
// The explicit transport state collapses before the input lease on disconnect.
const inputSendable = nativeChatInputLeaseReady && connected
@@ -281,6 +255,8 @@ export function useMobileNativeChatController(args: {
operations,
enabled: inputSendable,
targetRef: nativeChatTargetRef,
agentRef: activeChatAgentRef,
commandSendRef: recordSessionOptionCommandRef,
captureSendOrigin,
readSeededLaunchDraftSeed,
clearDraftForSend,
@@ -0,0 +1,104 @@
import { createElement } from 'react'
import { act, create, type ReactTestRenderer } from 'react-test-renderer'
import { 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'
}
}
describe('useMobileNativeChatDrafts session assignment', () => {
it('preserves first-send images through transcript replacement', async () => {
globalThis.IS_REACT_ACT_ENVIRONMENT = true
let renderer: ReactTestRenderer | null = null
let state: DraftState | null = null
function Harness({
sessionId,
messages = []
}: {
sessionId: string | null
messages?: NativeChatMessage[]
}): null {
state = useMobileNativeChatDrafts({
hostId: 'host',
worktreeId: 'worktree',
tabId: 'a',
sessionId,
messages,
launchDraft: null,
chatActive: true,
transcriptLoading: false
})
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, { sessionId: null }))
})
} finally {
consoleSpy.mockRestore()
}
try {
const images = ['file:///a.jpg', 'file:///b.jpg', 'file:///c.jpg']
act(() => state?.setComposerText('look'))
const origin = state?.captureSendOrigin('look')
expect(origin).toMatchObject({ pendingKey: null })
act(() => {
if (origin) {
state?.clearDraftForSend(origin, 'look')
state?.acceptSend(origin, 'look', images)
}
})
expect(state?.composerText).toBe('')
expect(state?.pending.map((pending) => pending.images)).toEqual([images])
await act(async () => renderer?.update(createElement(Harness, { sessionId: 'assigned' })))
expect(state?.pending.map((pending) => pending.images)).toEqual([images])
const sourceMessages = [
userTextMessage('source-1', '[Image: source: /tmp/a.png]'),
userTextMessage('source-2', '[Image: source: /tmp/b.png]'),
userTextMessage('source-3', '[Image: source: /tmp/c.png]')
]
await act(async () =>
renderer?.update(
createElement(Harness, { sessionId: 'assigned', messages: sourceMessages })
)
)
expect(state?.pending.map((pending) => pending.images)).toEqual([images])
await act(async () =>
renderer?.update(
createElement(Harness, {
sessionId: 'assigned',
messages: [
...sourceMessages,
userTextMessage('prompt', '[Image #1] [Image #2] [Image #3] look')
]
})
)
)
expect(state?.pending).toEqual([])
expect(state?.imagePreviewsByMessageId).toEqual({ prompt: images })
} finally {
act(() => renderer?.unmount())
}
})
})
@@ -854,60 +854,4 @@ describe('useMobileNativeChatDrafts', () => {
vi.useRealTimers()
}
})
it('preserves first-send images through session assignment and transcript replacement', async () => {
await mount('a')
await act(async () => renderer?.update(createElement(Harness, { tabId: 'a', sessionId: null })))
const images = ['file:///a.jpg', 'file:///b.jpg', 'file:///c.jpg']
act(() => state?.setComposerText('look'))
const origin = state?.captureSendOrigin('look')
expect(origin).toMatchObject({ pendingKey: null })
act(() => {
if (origin) {
state?.clearDraftForSend(origin, 'look')
state?.acceptSend(origin, 'look', images)
}
})
expect(state?.composerText).toBe('')
expect(state?.pending.map((pending) => pending.images)).toEqual([images])
await act(async () =>
renderer?.update(createElement(Harness, { tabId: 'a', sessionId: 'assigned' }))
)
expect(state?.pending.map((pending) => pending.images)).toEqual([images])
await act(async () =>
renderer?.update(
createElement(Harness, {
tabId: 'a',
sessionId: 'assigned',
messages: [
userTextMessage('source-1', '[Image: source: /tmp/a.png]'),
userTextMessage('source-2', '[Image: source: /tmp/b.png]'),
userTextMessage('source-3', '[Image: source: /tmp/c.png]')
]
})
)
)
expect(state?.pending.map((pending) => pending.images)).toEqual([images])
await act(async () =>
renderer?.update(
createElement(Harness, {
tabId: 'a',
sessionId: 'assigned',
messages: [
userTextMessage('source-1', '[Image: source: /tmp/a.png]'),
userTextMessage('source-2', '[Image: source: /tmp/b.png]'),
userTextMessage('source-3', '[Image: source: /tmp/c.png]'),
userTextMessage('prompt', '[Image #1] [Image #2] [Image #3] look')
]
})
)
)
expect(state?.pending).toEqual([])
expect(state?.imagePreviewsByMessageId).toEqual({ prompt: images })
})
})
@@ -4,21 +4,12 @@ import type { HostSessionChatDraftOperations } from './host-session-chat-draft-o
import type { HostSessionChatPendingDeliveryOperations } from './host-session-chat-pending-delivery-operations'
import {
findLandedUnconfirmedSends,
mergeLandedImagePreviewEchoes,
migrateImagePreviewMessageIds,
normalizedUserText,
type UnconfirmedSend
} from './mobile-native-chat-draft-reconcile'
import {
appendMobileNativeChatPending,
combineMobileNativeChatPending,
mergeWaitingSessionPending,
removeWaitingSessionPending,
type MobileNativeChatPendingMessage,
type MobileNativeChatSendOrigin
} from './mobile-native-chat-pending-echo'
import { mobileNativeChatScopeKey } from './mobile-native-chat-scope-key'
import { useMobileNativeChatDraftPersistence } from './use-mobile-native-chat-draft-persistence'
import { useMobileNativeChatLaunchDraftSeed } from './use-mobile-native-chat-launch-draft-seed'
import type { MobileNativeChatLaunchDraftSeed } from './use-mobile-native-chat-launch-draft-seed'
import {
useMobileNativeChatPendingDeliveries,
type MobileNativeChatPendingDeliveryOrigin,
@@ -57,6 +48,8 @@ export function useMobileNativeChatDrafts(args: {
* optimistic echo, keyed by authoritative message id. */
imagePreviewsByMessageId: Record<string, string[]>
captureSendOrigin: (text: string) => MobileNativeChatSendOrigin | null
readSeededLaunchDraft: () => string | null
readSeededLaunchDraftSeed: () => MobileNativeChatLaunchDraftSeed | null
clearDraftForSend: (origin: MobileNativeChatSendOrigin, text: string) => void
restoreRejectedDraft: (origin: MobileNativeChatSendOrigin, text: string) => void
acceptSend: (origin: MobileNativeChatSendOrigin, text: string, images?: string[]) => void
@@ -88,6 +81,7 @@ export function useMobileNativeChatDrafts(args: {
const {
pendingKey,
pending,
imagePreviewsByMessageId,
captureOrigin: capturePendingOrigin,
accept: acceptPending
} = useMobileNativeChatPendingDeliveries({
@@ -236,9 +230,7 @@ export function useMobileNativeChatDrafts(args: {
composerText,
setComposerText,
pending,
imagePreviewsByMessageId: pendingKey
? (imagePreviewsBySession[pendingKey] ?? NO_IMAGE_PREVIEWS)
: NO_IMAGE_PREVIEWS,
imagePreviewsByMessageId,
captureSendOrigin,
readSeededLaunchDraft,
readSeededLaunchDraftSeed,
@@ -179,6 +179,7 @@ function baseArgs(
showToast: vi.fn(),
onSendError: vi.fn(),
baseSend,
readSeededLaunchDraft: () => null,
sleep: async () => {}
}
}
@@ -1,6 +1,5 @@
import { useCallback, useRef, useState } from 'react'
import { CLIPBOARD_IMAGE_TOO_LARGE_ERROR } from '../../../src/shared/clipboard-image'
import { buildAgentTuiClearInputForText } from '../../../src/shared/agent-tui-input-clear'
import type { RpcClient } from '../transport/rpc-client'
import type { ConnectionState } from '../transport/types'
import type {
@@ -21,6 +20,10 @@ import {
sendMobileNativeChatWithImages,
type MobileNativeChatImageBaseSend
} from './mobile-native-chat-image-submit'
import {
acquireMobileNativeChatTerminalWrite,
releaseMobileNativeChatTerminalWrite
} from './mobile-native-chat-terminal-write-lock'
type CurrentRef<T> = { readonly current: T }
type ShowToast = (message: string, durationMs?: number) => void
@@ -50,6 +53,10 @@ type Args = {
* Accepts this action's budget so the text body draws from what the paste left
* rather than opening a second one. */
readonly baseSend: MobileNativeChatImageBaseSend
/** Launch-context text parked on the agent's TUI input line, or null. The
* paste's leading clear must cover every line of it, or the draft's earlier
* lines survive and ride along with the image. */
readonly readSeededLaunchDraft: () => string | null
readonly onAttachSuccess?: () => void
readonly onError?: () => void
// Injected so the settle between image paste and submit is instant in tests.
@@ -137,43 +144,36 @@ export function useMobileNativeChatImageAttachments({
let started = false
const uploadedImages: Omit<PendingNativeChatImage, 'id'>[] = []
let uploadError: unknown = null
const onUploadStart = (): void => {
started = true
attachingCount.current += 1
setIsAttaching(true)
}
try {
const onUploadStart = () => {
started = true
attachingCount.current += 1
setIsAttaching(true)
}
const uploaded = client
? await uploadMobileNativeChatImage(source, {
client,
getConnectionId: getActiveWorktreeConnectionId,
pickImage: pickMobileImage,
onUploadStart
if (client) {
await uploadMobileNativeChatImages(source, {
client,
getConnectionId: getActiveWorktreeConnectionId,
pickImages: pickMobileImages,
onImageUploaded: (image) => uploadedImages.push(image),
onUploadStart
})
} else {
onUploadStart()
const result = await operations!.attachImage!(target!, source)
if (result.status === 'permission-denied') {
throw new ImageLibraryPermissionError()
}
if (result.status === 'too-large') {
throw new Error(CLIPBOARD_IMAGE_TOO_LARGE_ERROR)
}
if (result.status === 'accepted') {
uploadedImages.push({
path: result.attachment.reference,
previewUri: result.attachment.previewUri
})
: await (async () => {
onUploadStart()
const result = await operations!.attachImage!(target!, source)
if (result.status === 'permission-denied') {
throw new ImageLibraryPermissionError()
}
if (result.status === 'too-large') {
throw new Error(CLIPBOARD_IMAGE_TOO_LARGE_ERROR)
}
return result.status === 'accepted'
? {
path: result.attachment.reference,
previewUri: result.attachment.previewUri
}
: null
})()
// Cancelled picker: no error, no toast.
if (!uploaded) {
return
}
}
idCounter.current += 1
const chip = { id: `img-${idCounter.current}`, ...uploaded }
setAttachmentsByScope((prev) => ({ ...prev, [scope]: [...(prev[scope] ?? []), chip] }))
onAttachSuccess?.()
} catch (error) {
uploadError = error
} finally {
@@ -256,9 +256,6 @@ export function useMobileNativeChatImageAttachments({
onSendError('Message not sent')
return false
}
if (operationTerminal) {
sendInFlightTerminalsRef.current.add(operationTerminal)
}
try {
const scope = scopeKey
const pendingImages = (scope ? attachmentsByScope[scope] : undefined) ?? NO_ATTACHMENTS
@@ -273,6 +270,7 @@ export function useMobileNativeChatImageAttachments({
operations,
targetRef,
baseSend,
readSeededLaunchDraft,
onError,
onSendError,
sleep,
@@ -306,6 +304,7 @@ export function useMobileNativeChatImageAttachments({
onError,
onSendError,
operations,
readSeededLaunchDraft,
scopeKey,
sleep,
targetRef
@@ -46,11 +46,26 @@ describe('useMobileNativeChatMessageSend', () => {
): void => {
agentRef.current = agent
function Probe(): null {
const targetRef = {
current: {
workspaceId: 'worktree',
agent: agentRef.current ?? 'claude',
sessionId: 'session',
transcriptPath: null,
terminalId: 'term',
clientId: 'device'
}
}
api = useMobileNativeChatMessageSend({
client: { sendRequest: vi.fn() } as never,
operations: {
prepareCommit: async () => true,
respond: async (_target, text) =>
(await clearInputWrite({ clearInput: text })) ? 'accepted' : 'rejected',
sendMessage: (_target, _text, _deadline, clearInputFirst, resolvedLaunchDraft) =>
sendWithOutcome({ clearInputFirst, resolvedLaunchDraft })
} as never,
enabled: true,
handleRef: { current: 'term' },
deviceTokenRef: { current: 'device' },
targetRef,
agentRef,
commandSendRef,
captureSendOrigin: () => ({ draftKey: 'k', pendingKey: 'p' }) as never,
@@ -4,10 +4,14 @@ import type {
HostSessionNativeChatTarget
} from './host-session-native-chat-operations'
import {
clearMobileNativeChatInput,
openMobileNativeChatSendBudget,
type MobileNativeChatSendOutcome
} from './mobile-native-chat-send'
import { classifyMobileNativeChatSend } from './mobile-native-chat-send-classification'
import {
acquireMobileNativeChatTerminalWrite,
releaseMobileNativeChatTerminalWrite
} from './mobile-native-chat-terminal-write-lock'
import type { MobileNativeChatSendOrigin } from './use-mobile-native-chat-drafts'
import type { MobileNativeChatLaunchDraftSeed } from './use-mobile-native-chat-launch-draft-seed'
import { buildAgentTuiClearInputForText } from '../../../src/shared/agent-tui-input-clear'
@@ -34,6 +38,8 @@ export function useMobileNativeChatMessageSend(args: {
operations: HostSessionNativeChatOperations | null
enabled: boolean
targetRef: MutableRefObject<HostSessionNativeChatTarget | null>
agentRef: MutableRefObject<string | null>
commandSendRef: MutableRefObject<(command: string) => void>
captureSendOrigin: (text: string) => MobileNativeChatSendOrigin | null
/** Launch-context text Orca parked on the agent's TUI input line, or null. Read
* at send time so the pre-clear can be sized to every line it occupies. */
@@ -52,6 +58,8 @@ export function useMobileNativeChatMessageSend(args: {
operations,
enabled,
targetRef,
agentRef,
commandSendRef,
captureSendOrigin,
readSeededLaunchDraftSeed,
clearDraftForSend,
@@ -71,6 +79,8 @@ export function useMobileNativeChatMessageSend(args: {
): Promise<MobileNativeChatSendOutcome> => {
const target = targetRef.current
const origin = captureSendOrigin(text)
const agent = agentRef.current
const recordCommand = commandSendRef.current
if (!operations || !target || !origin || !enabled) {
onSendError('Message not sent (disconnected)')
return 'rejected'
@@ -84,11 +94,38 @@ export function useMobileNativeChatMessageSend(args: {
if (syncComposer) {
clearDraftForSend(origin, text)
}
const outcome = await operations.sendMessage(target, text, deadline, !images?.length)
if (outcome === 'unknown') {
holdUnconfirmedSend(origin, text, () =>
onSendError('Delivery unconfirmed — check chat before retrying')
const seededLaunchDraft = readSeededLaunchDraftSeed()
if (seededLaunchDraft && !images?.length) {
const clearOutcome = await operations.respond(
target,
buildAgentTuiClearInputForText(seededLaunchDraft.text),
false,
deadline
)
if (clearOutcome !== 'accepted') {
if (syncComposer) {
restoreRejectedDraft(origin, text)
}
onSendError('Message not sent')
return 'rejected'
}
}
const outcome = await operations.sendMessage(
target,
text,
deadline,
!images?.length && !seededLaunchDraft,
syncComposer && typeof seededLaunchDraft?.createdAt === 'number'
? { text: seededLaunchDraft.text, createdAt: seededLaunchDraft.createdAt }
: undefined
)
const classification = classifyMobileNativeChatSend(agent, text)
if (outcome === 'unknown') {
if (classification === 'chat') {
holdUnconfirmedSend(origin, text, () =>
onSendError('Delivery unconfirmed — check chat before retrying')
)
}
return 'unknown'
}
if (outcome === 'rejected') {
@@ -98,7 +135,11 @@ export function useMobileNativeChatMessageSend(args: {
onSendError('Message not sent')
return 'rejected'
}
acceptSend(origin, text, images)
if (classification === 'chat') {
acceptSend(origin, text, images)
} else if (recordControlSend) {
recordCommand(text.trim())
}
return 'accepted'
},
[
@@ -106,10 +147,12 @@ export function useMobileNativeChatMessageSend(args: {
agentRef,
captureSendOrigin,
clearDraftForSend,
commandSendRef,
enabled,
holdUnconfirmedSend,
onSendError,
operations,
readSeededLaunchDraftSeed,
restoreRejectedDraft,
targetRef
]
@@ -125,8 +168,21 @@ export function useMobileNativeChatMessageSend(args: {
[sendWithOutcome]
)
const answerQuestion = useCallback(
async (text: string) => (await sendMessage(text, undefined, false)) !== 'rejected',
[sendMessage]
async (text: string): Promise<boolean> => {
const terminal = targetRef.current?.terminalId
if (terminal && !acquireMobileNativeChatTerminalWrite(terminal)) {
onSendError('Answer not sent')
return false
}
try {
return (await sendMessage(text, undefined, false, true)) !== 'rejected'
} finally {
if (terminal) {
releaseMobileNativeChatTerminalWrite(terminal)
}
}
},
[onSendError, sendMessage, targetRef]
)
// A session-option apply writes to the same input line as a send, and the host
@@ -134,7 +190,7 @@ export function useMobileNativeChatMessageSend(args: {
// apply lands between them and is submitted as part of the user's prompt.
const dispatchCommand = useCallback(
async (text: string): Promise<MobileNativeChatSendOutcome> => {
const terminal = handleRef.current
const terminal = targetRef.current?.terminalId
if (terminal && !acquireMobileNativeChatTerminalWrite(terminal)) {
return 'rejected'
}
@@ -146,7 +202,7 @@ export function useMobileNativeChatMessageSend(args: {
}
}
},
[handleRef, sendMessage]
[sendMessage, targetRef]
)
return { send, sendWithOutcome, answerQuestion, dispatchCommand }
@@ -5,8 +5,18 @@ import type { HostSessionChatPendingDeliveryOperations } from './host-session-ch
import {
countImageSourceTurnsAfter,
countUserTextOccurrences,
findLandedImagePreviewEchoes,
mergeLandedImagePreviewEchoes,
migrateImagePreviewMessageIds,
normalizedUserText
} from './mobile-native-chat-draft-reconcile'
import {
combineMobileNativeChatPending,
mergeWaitingSessionPending,
nextMobileNativeChatPendingId,
omitMobileNativeChatPendingKey,
removeWaitingSessionPending
} from './mobile-native-chat-pending-echo'
export type MobileNativeChatPendingMessage = {
id: string
@@ -24,6 +34,7 @@ export type MobileNativeChatPendingDeliveryTarget = {
export type MobileNativeChatPendingDeliveryOrigin = {
pendingKey: string | null
pendingStorageKey: string | null
normalizedText: string
baselineOccurrences: number
baselineTailMessageId: string | null
@@ -31,6 +42,7 @@ export type MobileNativeChatPendingDeliveryOrigin = {
}
const NO_PENDING_MESSAGES: MobileNativeChatPendingMessage[] = []
const NO_IMAGE_PREVIEWS: Record<string, string[]> = {}
export function useMobileNativeChatPendingDeliveries(args: {
hostId: string
@@ -42,10 +54,12 @@ export function useMobileNativeChatPendingDeliveries(args: {
}): {
pendingKey: string | null
pending: MobileNativeChatPendingMessage[]
imagePreviewsByMessageId: Record<string, string[]>
captureOrigin: (normalizedText: string) => MobileNativeChatPendingDeliveryOrigin
accept: (origin: MobileNativeChatPendingDeliveryOrigin, text: string, images?: string[]) => void
} {
const { hostId, worktreeId, tabId, sessionId, messages, persistence } = args
const waitingKey = tabId ? `${hostId}\0${worktreeId}\0${tabId}` : null
const pendingKey = tabId && sessionId ? `${hostId}\0${worktreeId}\0${tabId}\0${sessionId}` : null
const pendingTarget = useMemo(
() => (tabId && sessionId ? { workspaceId: worktreeId, tabId, sessionId } : null),
@@ -54,6 +68,9 @@ export function useMobileNativeChatPendingDeliveries(args: {
const [pendingBySession, setPendingBySession] = useState<
Record<string, MobileNativeChatPendingMessage[]>
>({})
const [imagePreviewsBySession, setImagePreviewsBySession] = useState<
Record<string, Record<string, string[]>>
>({})
const pendingBySessionRef = useRef(pendingBySession)
useEffect(() => {
pendingBySessionRef.current = pendingBySession
@@ -105,17 +122,19 @@ export function useMobileNativeChatPendingDeliveries(args: {
const replacePending = useCallback(
(
key: string,
target: MobileNativeChatPendingDeliveryTarget,
target: MobileNativeChatPendingDeliveryTarget | null,
deliveries: MobileNativeChatPendingMessage[]
) => {
editVersionRef.current[key] = (editVersionRef.current[key] ?? 0) + 1
const nextState =
deliveries.length > 0
? { ...pendingBySessionRef.current, [key]: deliveries }
: omitPendingKey(pendingBySessionRef.current, key)
: omitMobileNativeChatPendingKey(pendingBySessionRef.current, key)
pendingBySessionRef.current = nextState
setPendingBySession(nextState)
queueSave(key, target, deliveries)
if (target) {
queueSave(key, target, deliveries)
}
},
[queueSave]
)
@@ -134,7 +153,7 @@ export function useMobileNativeChatPendingDeliveries(args: {
return
}
const deliveries = stored.map((delivery) => ({
id: nextPendingMessageId(nextMessageIdRef),
id: nextMobileNativeChatPendingId(nextMessageIdRef),
...delivery,
baselineTailMessageId: null
}))
@@ -156,20 +175,22 @@ export function useMobileNativeChatPendingDeliveries(args: {
const captureOrigin = useCallback(
(normalizedText: string): MobileNativeChatPendingDeliveryOrigin => ({
pendingKey,
pendingStorageKey: pendingKey ?? waitingKey,
normalizedText,
baselineOccurrences: countUserTextOccurrences(messages, normalizedText),
baselineTailMessageId: messages.at(-1)?.id ?? null,
pendingTarget
}),
[messages, pendingKey, pendingTarget]
[messages, pendingKey, pendingTarget, waitingKey]
)
const accept = useCallback(
(origin: MobileNativeChatPendingDeliveryOrigin, text: string, images?: string[]) => {
if (!origin.pendingKey || !origin.pendingTarget) {
const storageKey = origin.pendingStorageKey
if (!storageKey || (!origin.pendingTarget && !images?.length)) {
return
}
const current = pendingBySessionRef.current[origin.pendingKey] ?? NO_PENDING_MESSAGES
const current = pendingBySessionRef.current[storageKey] ?? NO_PENDING_MESSAGES
const earlierOutstanding = current.filter(
(pending) =>
pending.text.trim() === origin.normalizedText &&
@@ -183,7 +204,7 @@ export function useMobileNativeChatPendingDeliveries(args: {
const next = [
...current,
{
id: nextPendingMessageId(nextMessageIdRef),
id: nextMobileNativeChatPendingId(nextMessageIdRef),
text,
expectedOccurrence:
origin.normalizedText === ''
@@ -193,18 +214,51 @@ export function useMobileNativeChatPendingDeliveries(args: {
...(images && images.length > 0 ? { images } : {})
}
].slice(-MOBILE_WEB_NATIVE_CHAT_PENDING_DELIVERY_LIMIT)
replacePending(origin.pendingKey, origin.pendingTarget, next)
replacePending(storageKey, origin.pendingTarget, next)
},
[replacePending]
)
const pending = pendingKey
const sessionPending = pendingKey
? (pendingBySession[pendingKey] ?? NO_PENDING_MESSAGES)
: NO_PENDING_MESSAGES
const waitingForSession = waitingKey
? (pendingBySession[waitingKey] ?? NO_PENDING_MESSAGES)
: NO_PENDING_MESSAGES
const pending = combineMobileNativeChatPending(sessionPending, waitingForSession)
useEffect(() => {
if (!pendingKey || !pendingTarget || pending.length === 0) {
if (!pendingKey || !pendingTarget || !waitingKey || waitingForSession.length === 0) {
return
}
const movedIds = new Set(waitingForSession.map((item) => item.id))
const merged = mergeWaitingSessionPending(
pendingBySessionRef.current,
pendingKey,
waitingForSession
)
const next = removeWaitingSessionPending(merged, waitingKey, movedIds)
pendingBySessionRef.current = next
editVersionRef.current[pendingKey] = (editVersionRef.current[pendingKey] ?? 0) + 1
setPendingBySession(next)
queueSave(pendingKey, pendingTarget, next[pendingKey] ?? [])
}, [pendingKey, pendingTarget, queueSave, waitingForSession, waitingKey])
useEffect(() => {
if (!pendingKey || !pendingTarget) {
return
}
setImagePreviewsBySession((previous) =>
migrateImagePreviewMessageIds(previous, pendingKey, messages)
)
if (pending.length === 0) {
return
}
const landedImagePreviews = findLandedImagePreviewEchoes(messages, pending)
const landedImagePendingIds = new Set(landedImagePreviews.map((preview) => preview.pendingId))
if (landedImagePreviews.length > 0) {
setImagePreviewsBySession((previous) =>
mergeLandedImagePreviewEchoes(previous, pendingKey, landedImagePreviews)
)
}
const landedCounts = new Map<string, number>()
for (const message of messages) {
const text = normalizedUserText(message)
@@ -212,29 +266,29 @@ export function useMobileNativeChatPendingDeliveries(args: {
landedCounts.set(text, (landedCounts.get(text) ?? 0) + 1)
}
}
const next = pending.filter((item) =>
item.text.trim() === ''
const next = pending.filter((item) => {
if (landedImagePendingIds.has(item.id)) {
return false
}
if (item.images?.length) {
return true
}
return item.text.trim() === ''
? countImageSourceTurnsAfter(messages, item.baselineTailMessageId) < item.expectedOccurrence
: (landedCounts.get(item.text.trim()) ?? 0) < item.expectedOccurrence
)
})
if (next.length !== pending.length) {
replacePending(pendingKey, pendingTarget, next)
}
}, [messages, pending, pendingKey, pendingTarget, replacePending])
return { pendingKey, pending, captureOrigin, accept }
}
function nextPendingMessageId(counter: { current: number }): string {
counter.current += 1
return `pending-${counter.current}`
}
function omitPendingKey(
state: Record<string, MobileNativeChatPendingMessage[]>,
key: string
): Record<string, MobileNativeChatPendingMessage[]> {
const next = { ...state }
delete next[key]
return next
return {
pendingKey,
pending,
imagePreviewsByMessageId: pendingKey
? (imagePreviewsBySession[pendingKey] ?? NO_IMAGE_PREVIEWS)
: NO_IMAGE_PREVIEWS,
captureOrigin,
accept
}
}
@@ -1,7 +1,7 @@
import { useMemo } from 'react'
import type { NativeChatMessage } from '../../../src/shared/native-chat-types'
import type { MobileNativeChatAgentStatusWithProvider } from './mobile-native-chat-eligibility'
import { extractPendingAsk, parseAskFromStatus } from './mobile-native-chat-ask'
import { parseAskFromStatus, resolveNativeChatAsk } from './mobile-native-chat-ask'
import { detectAgentPermission, parseApprovalFromStatus } from './mobile-native-chat-permission'
import { parseAgentQuestion } from './mobile-native-chat-question'
@@ -21,7 +21,8 @@ export function useMobileNativeChatPrompts(args: {
* across a reconnect). Required: an ask derived from it may already be answered. */
transcriptLoading: boolean
}): MobileNativeChatPrompts {
const { enabled, status, messages } = args
const { enabled, status, messages, transcriptLoading } = args
const blocked = status?.state === 'waiting' || status?.state === 'blocked'
const askFromStatus = useMemo(
() => parseAskFromStatus(status?.interactivePrompt, status?.toolName),
[status?.interactivePrompt, status?.toolName]
@@ -35,28 +36,28 @@ export function useMobileNativeChatPrompts(args: {
}),
[askFromStatus, transcriptLoading, messages]
)
const ask = enabled ? (askFromStatus ?? askFromMessages) : null
const blocked = status?.state === 'waiting' || status?.state === 'blocked'
// Why: after a structured answer, its host status can linger briefly; never
// replace that dismissed card with a heuristic reading of the same prompt.
const permission = ask
const askFromMessages = askFromStatus ? null : resolvedAsk
const detectedAsk = askFromStatus ?? askFromMessages
const visibleAsk = enabled ? ((blocked ? askFromStatus : null) ?? askFromMessages) : null
const permission = visibleAsk
? null
: ((blocked && status
? detectAgentPermission({
state: status.state,
lastAssistantMessage: status.lastAssistantMessage,
toolName: status.toolName,
toolInput: status.toolInput
})
: null) ?? parseApprovalFromStatus(status?.interactivePrompt))
: blocked && status
? (detectAgentPermission({
state: status.state,
lastAssistantMessage: status.lastAssistantMessage,
toolName: status.toolName,
toolInput: status.toolInput
}) ?? parseApprovalFromStatus(status.interactivePrompt))
: null
const question =
!ask && blocked && status && !permission
!visibleAsk && blocked && status && !permission
? parseAgentQuestion(status.lastAssistantMessage ?? '')
: null
return {
permission,
question,
ask
detectedAsk: enabled ? detectedAsk : null,
ask: visibleAsk
}
}
@@ -64,9 +64,9 @@ describe('useMobileNativeChatReadability', () => {
expect(readable).toBe(true)
})
it('admits classic SSH transcript hosts', async () => {
it('fails closed for Model-A SSH transcript hosts', async () => {
await mount('model-a-ssh')
expect(readable).toBe(true)
expect(readable).toBe(false)
})
it('treats the host-local floating workspace as readable without listing repos', async () => {
@@ -115,7 +115,7 @@ describe('useMobileNativeChatReadability', () => {
})
await Promise.resolve()
})
expect(readable).toBe(true)
expect(readable).toBe(false)
} finally {
consoleSpy.mockRestore()
}
@@ -280,7 +280,9 @@ describe('useMobileNativeChatSession', () => {
expect(sendRequest).toHaveBeenCalledWith('nativeChat.readSession', {
agent: 'claude',
sessionId: 'session',
limit: 100
limit: 100,
worktreeId: 'worktree',
terminal: 'terminal'
})
})
@@ -340,7 +342,9 @@ describe('useMobileNativeChatSession', () => {
expect(sendRequest).toHaveBeenCalledWith('nativeChat.readSession', {
agent: 'claude',
sessionId: 'session',
limit: 100
limit: 100,
worktreeId: 'worktree',
terminal: 'terminal'
})
})
@@ -492,16 +496,16 @@ describe('useMobileNativeChatSession transcriptLoading', () => {
operations,
sessionId,
agent = 'claude',
sourceIdentity = 'host-a\0workspace-a'
workspaceId = 'worktree'
}: {
operations: HostSessionNativeChatOperations | null
sessionId: string | null
agent?: string | null
sourceIdentity?: string
workspaceId?: string
}): null {
const session = useMobileNativeChatSession({
operations,
workspaceId: 'worktree',
workspaceId,
agent,
sessionId,
transcriptPath: null,
@@ -594,7 +598,13 @@ describe('useMobileNativeChatSession transcriptLoading', () => {
await mountAt(operations, 'session-a')
expect(renders.at(-1)).toMatchObject({ status: 'ready' })
const reconnected = { subscribe: vi.fn(() => () => {}) } as unknown as RpcClient
let emitFresh: (frame: unknown) => void = () => {}
const reconnected = {
subscribe: vi.fn((_method: string, _params: unknown, onData: (frame: unknown) => void) => {
emitFresh = onData
return () => {}
})
} as unknown as RpcClient
const reconnectedOperations = nativeHostSessionNativeChatOperations(reconnected)
renders.length = 0
await act(async () =>
@@ -631,16 +641,17 @@ describe('useMobileNativeChatSession transcriptLoading', () => {
return () => {}
})
} as unknown as RpcClient
await mountAt(firstClient, 'session-a')
await mountAt(nativeHostSessionNativeChatOperations(firstClient), 'session-a')
const secondClient = { subscribe: vi.fn(() => () => {}) } as unknown as RpcClient
const secondOperations = nativeHostSessionNativeChatOperations(secondClient)
renders.length = 0
await act(async () =>
renderer?.update(
createElement(Harness, {
client: secondClient,
operations: secondOperations,
sessionId: 'session-a',
sourceIdentity: 'host-b\0workspace-b'
workspaceId: 'workspace-b'
})
)
)
@@ -1,4 +1,5 @@
import { useCallback, useEffect, useRef, useState } from 'react'
import { createNativeChatTranscriptRetention } from '../../../src/shared/native-chat-transcript-retention'
import type {
NativeChatMessage,
NativeChatTurnLifecycle
@@ -187,7 +188,7 @@ export function useMobileNativeChatSession(args: {
if (applied.lifecycle !== undefined) {
setLifecycle(applied.lifecycle)
}
if (applied.hasMore != null) {
if (!applied.windowReplaced && applied.hasMore != null) {
setHasMore(applied.hasMore)
}
if (!applied.windowReplaced && applied.beforeOffset != null) {
@@ -296,7 +297,7 @@ export function useMobileNativeChatSession(args: {
return {
// Withheld until the settled read belongs to this identity: the effect that
// clears the previous tab's list is passive, so `messages` lags a commit.
messages: settled ? messages : EMPTY_MESSAGES,
messages: visibleMessages,
lifecycle,
status,
transcriptLoading: status === 'loading',
@@ -0,0 +1,38 @@
import { useEffect, useMemo, useRef } from 'react'
import type { HostSessionNativeChatTarget } from './host-session-native-chat-operations'
export function useMobileNativeChatTarget(args: {
workspaceId: string
agent: string | null
sessionId: string | null
transcriptPath: string | null
terminalId: string | null
clientId: string | null
}) {
const target = useMemo<HostSessionNativeChatTarget | null>(
() =>
args.agent && args.sessionId
? {
workspaceId: args.workspaceId,
agent: args.agent,
sessionId: args.sessionId,
transcriptPath: args.transcriptPath,
terminalId: args.terminalId,
clientId: args.clientId
}
: null,
[
args.agent,
args.clientId,
args.sessionId,
args.terminalId,
args.transcriptPath,
args.workspaceId
]
)
const targetRef = useRef(target)
useEffect(() => {
targetRef.current = target
}, [target])
return { target, targetRef }
}
@@ -44,7 +44,7 @@ export function webHostSessionNativeChatOperations(
return { error: 'Transcript read failed' }
}
},
async sendMessage(target, text, deadline, clearInputFirst) {
async sendMessage(target, text, deadline, clearInputFirst, resolvedLaunchDraft) {
const budget = bridgeBudget(deadline)
if (!budget) {
return 'rejected'
@@ -55,7 +55,8 @@ export function webHostSessionNativeChatOperations(
bridgeTarget(target, {
text,
deadline: budget.deadline,
...(clearInputFirst ? { clearInputFirst: true } : {})
...(clearInputFirst ? { clearInputFirst: true } : {}),
...(resolvedLaunchDraft ? { resolvedLaunchDraft } : {})
}),
{ timeoutMs: budget.timeoutMs }
)
@@ -31,12 +31,14 @@ function webTerminalFileTarget(
? {
kind: 'worktree-file',
relativePath: result.relativePath,
localAbsolutePath: null
localAbsolutePath: null,
workspaceId: result.workspaceId
}
: {
kind: 'web-artifact',
token: result.token,
displayName: result.displayName,
previewKind: result.previewKind
previewKind: result.previewKind,
workspaceId: result.workspaceId
}
}
@@ -116,35 +116,12 @@ export const MobileGitHistoryList = memo(function MobileGitHistoryList({
if (!stale) {
setFilesById((prev) => ({ ...prev, [commitId]: entries }))
}
setFilesById((prev) => ({ ...prev, [row.id]: 'loading' }))
void client
.sendRequest('git.commitCompare', {
worktree: `id:${worktreeId}`,
commitId: row.id
})
.then((response) => {
const entries = response.ok
? (
(response as RpcSuccess).result as {
entries: GitBranchChangeEntry[]
}
).entries
: []
setFilesById((prev) => {
// Drop stale responses if the row is no longer loading (collapsed + re-opened).
if (prev[row.id] !== 'loading') {
return prev
}
return { ...prev, [row.id]: entries }
})
})
.catch(() =>
setFilesById((prev) => {
if (prev[row.id] !== 'loading') {
return prev
}
return { ...prev, [row.id]: [] }
})
})
.catch(() => {
// Keep an already-loaded list; a first load that fails resolves to "No file changes".
if (!stale) {
setFilesById((prev) =>
prev[commitId] === 'loading' ? { ...prev, [commitId]: [] } : prev
)
}
})
@@ -132,8 +132,22 @@ export function useMobileSourceControlOpeners(params: Params) {
if (!mountedRef.current) {
return
}
const revealResult = await revealMobileSourceControlSessionDiff({
client,
worktreeId,
relativePath: entry.path,
tabMode: openedTabMode,
staged: entry.area === 'staged',
onOpenedFileDiff,
isCurrent: () => mountedRef.current && openingPathRef.current === entry.path
})
if (revealResult === 'cancelled') {
return
}
if (revealResult === 'timeout') {
throw new Error("The file opened, but its tab isn't ready yet. Try again.")
}
feedback.selection()
onOpenedFileDiff?.(entry.path)
// Why: when launched from the session screen, opening a file dismisses
// this surface back to the session. In embedded mode there is nothing
// to pop (the panel docks beside the terminal), so close the dock
@@ -160,7 +160,6 @@ export function useMobileSourceControlState(params: MobileSourceControlStatePara
)
const branchLabel = formatBranchLabel(status?.branch, status?.head)
const upstream = status?.upstreamStatus
const upstreamKnown = upstream !== undefined
const syncLabel =
upstream && upstream.hasUpstream
? `${upstream.ahead} ahead, ${upstream.behind} behind`
@@ -301,7 +300,7 @@ export function useMobileSourceControlState(params: MobileSourceControlStatePara
unstagedCount,
branchLabel,
upstream,
upstreamKnown,
upstreamKnown: upstream !== undefined,
syncLabel,
primaryAction,
createPrAction,
@@ -22,11 +22,11 @@ const EXPECTED_PERSISTED_STATE_SOURCES = [
'mobile/src/storage/terminal-custom-key-storage.ts',
'mobile/src/terminal/terminal-accessory-layout.ts',
'mobile/src/transport/host-credential-cleanup.ts',
'mobile/src/transport/host-store.ts',
'mobile/src/transport/mobile-relay-credential-bundle.ts',
'mobile/src/transport/mobile-relay-direct-upgrade-journal.ts',
'mobile/src/transport/host-device-token-store.ts',
'mobile/src/transport/host-metadata-store.ts',
'mobile/src/transport/mobile-relay-host-overlay-store.ts',
'mobile/src/transport/mobile-relay-pairing-journal-store.ts',
'mobile/src/transport/pairing-keychain.ts',
'mobile/src/worktree/use-last-visited-worktree-repo.ts'
]
@@ -9,7 +9,10 @@ import type { RpcClient } from '../transport/rpc-client'
import type { RpcSuccess } from '../transport/types'
type GitLabRawDetails = Partial<MobileWebTaskGitLabDetailResult> & {
item?: { labels?: string[] }
item?: {
labels?: string[]
mergeable?: 'MERGEABLE' | 'CONFLICTING' | 'UNKNOWN'
}
}
export function nativeHostTaskDetailOperations(client: RpcClient): HostTaskDetailOperations {
@@ -63,7 +66,10 @@ export function nativeHostTaskDetailOperations(client: RpcClient): HostTaskDetai
comments: details.comments ?? [],
labels: details.item?.labels ?? details.labels,
assignees: details.assignees ?? [],
pipelineJobs: details.pipelineJobs ?? []
pipelineJobs: details.pipelineJobs ?? [],
...(details.item?.mergeable ? { item: { mergeable: details.item.mergeable } } : {}),
...(details.reviewers ? { reviewers: details.reviewers } : {}),
...(details.approvalState ? { approvalState: details.approvalState } : {})
}
},
async loadLinear(payload) {
@@ -7,6 +7,14 @@ const sessionRouteSource = readFileSync(
new URL('../../app/h/[hostId]/session/[worktreeId].tsx', import.meta.url),
'utf8'
)
const nativeTerminalOperationsSource = readFileSync(
new URL('../session/native-host-session-terminal-operations.ts', import.meta.url),
'utf8'
)
const accessoryRawSendSource = readFileSync(
new URL('./terminal-live-accessory-raw-send.ts', import.meta.url),
'utf8'
)
function routeSlice(anchorStart: string, anchorEnd: string): string {
const start = sessionRouteSource.indexOf(anchorStart)
@@ -117,11 +125,8 @@ describe('session route offline-compose wiring', () => {
})
it('keeps every keystroke-grade terminal send now-or-never so nothing replays after reconnect', () => {
// Live mirror, buffered send, and gesture arrows must all opt out of the
// connect wait — a parked send replays stale bytes into the PTY. Accessory
// keys get the same option inside terminal-live-accessory-raw-send.ts.
const optOuts = sessionRouteSource.match(/TERMINAL_INPUT_SEND_OPTIONS/g)?.length ?? 0
expect(optOuts).toBe(4)
expect(nativeTerminalOperationsSource).toContain('TERMINAL_INPUT_SEND_OPTIONS')
expect(accessoryRawSendSource).toContain('TERMINAL_INPUT_SEND_OPTIONS')
expect(TERMINAL_INPUT_SEND_OPTIONS).toEqual({ failWhenDisconnected: true })
})
@@ -45,6 +45,8 @@ class CarrierWebSocket {
static OPEN = 1
static CLOSED = 3
readonly CONNECTING = 0
readonly OPEN = 1
readonly bufferedAmount = 0
readyState = 0
onopen: (() => void) | null = null
onclose: ((event?: unknown) => void) | null = null
@@ -229,17 +231,17 @@ describe('issue #10119 — what a phone shows while it cannot reach the desktop'
expect(after.every((s) => s.label === "Can't reach desktop")).toBe(true)
})
it('resets the failure counter only once a handshake actually completes', () => {
it('resets the failure counter only once a handshake actually completes', async () => {
carrier = { kind: 'slow-handshake', readyAfterMs: 6_000 }
const client = connect('ws://192.168.0.56:6769', 'device-token', 'server-public-key')
vi.advanceTimersByTime(40_000)
await vi.advanceTimersByTimeAsync(40_000)
expect(client.getState()).not.toBe('connected')
expect(client.getReconnectAttempt()).toBeGreaterThanOrEqual(3)
// The link heals: the same desktop now answers inside the budget.
carrier = { kind: 'slow-handshake', readyAfterMs: 50 }
vi.advanceTimersByTime(20_000)
await vi.advanceTimersByTimeAsync(20_000)
expect(client.getState()).toBe('connected')
expect(client.getReconnectAttempt()).toBe(0)
@@ -17,6 +17,7 @@ export class FakeSession implements RpcClient {
)
readonly subscribe = vi.fn(() => () => {})
readonly updateTerminalSubscriptionViewport = vi.fn()
readonly sendTerminalBinaryFrame = vi.fn(() => true)
readonly notifyForeground = vi.fn()
readonly close = vi.fn()
private readonly listeners = new Set<(state: ConnectionState) => void>()
+4 -2
View File
@@ -1,11 +1,13 @@
import type { TerminalStreamFrame } from './terminal-stream-protocol'
import type { RpcClientSubscribeOptions } from './rpc-client-subscribe-options'
import type { ConnectionState, RpcResponse } from './types'
import type { ConnectionState, ForegroundNudgeReason, RpcResponse } from './types'
export type RpcClientSendRequestOptions = {
timeoutMs?: number
/** Spend the timeout across connection wait and acknowledgement. */
budgetSpansConnect?: boolean
/** Reject immediately instead of replaying stale terminal input after reconnect. */
failWhenDisconnected?: boolean
}
export type RpcClient = {
@@ -29,6 +31,6 @@ export type RpcClient = {
getReconnectAttempt: () => number
getLastConnectedAt: () => number | null
onStateChange: (listener: (state: ConnectionState) => void) => () => void
notifyForeground: () => void
notifyForeground: (reason?: ForegroundNudgeReason) => void
close: () => void
}
+21 -41
View File
@@ -27,7 +27,7 @@ import {
buildTerminalUnsubscribeParams,
updateTerminalSubscriptionViewport as updateCachedTerminalSubscriptionViewport
} from './rpc-client-terminal-subscription'
import { describeSocketEvent, redactSocketEndpoint } from './socket-event-debug'
import { describeSocketEvent } from './socket-event-debug'
import {
isStaleRpcSocketEvent,
logRpcSocketClose,
@@ -36,6 +36,8 @@ import {
import { markRpcDeliveryUnknown } from './rpc-delivery-ambiguity'
import { openRpcRequestBudget, resolvePostConnectRequestTimeout } from './rpc-request-budget'
import { isRpcResponse } from './rpc-response-shape'
import { createRpcActivityProbe } from './rpc-client-activity-probe'
import { isStaleForegroundDial } from './rpc-stale-dial'
import {
createMobileInboundFrameQueue,
MOBILE_INBOUND_BUFFER_OVERFLOW_MESSAGE,
@@ -56,7 +58,11 @@ import {
} from './rpc-client-terminal-multiplex'
import type { RpcClient, RpcClientSendRequestOptions } from './rpc-client-contract'
export type { RpcClient } from './rpc-client-contract'
export type {
RpcClient,
RpcClientSendRequestOptions,
RpcClientSendRequestOptions as SendRequestOptions
} from './rpc-client-contract'
type PendingRequest = {
resolve: (response: RpcResponse) => void
@@ -134,6 +140,7 @@ export function connect(
})
}
let ws: WebSocket | null = null
const synthesizedCloses = new RpcSynthesizedCloseIndex()
let outbound: ReturnType<typeof createMobileDirectRpcOutbound> | null = null
let state: ConnectionState = 'disconnected'
let requestCounter = 0
@@ -296,6 +303,8 @@ export function connect(
ws = new WebSocket(endpoint)
const openingWs = ws
let openingWsAuthenticated = false
let openingWsLastInboundAt: number | null = null
const closeForOverload = (direction: 'Inbound' | 'Outbound', detail: string): void => {
emitLog('error', `${direction} WebSocket overload`, detail)
openingWs.close()
@@ -315,19 +324,6 @@ export function connect(
overflowMessage: MOBILE_INBOUND_BUFFER_OVERFLOW_MESSAGE,
frameTooLargeMessage: MOBILE_INBOUND_FRAME_TOO_LARGE_MESSAGE
})
const ignoreStaleSocketEvent = (eventName: string): boolean => {
if (ws === openingWs) {
return false
}
// Why: RN can deliver callbacks from a timed-out socket after reconnect swapped in a replacement — ignore them.
console.log('[net] stale ws event ignored', {
eventName,
state,
attempt: reconnectAttempt
})
return true
}
// Why: RN can leave opens pending forever on flaky handoffs — force reconnect if onopen never arrives.
connectTimer = setTimeout(() => {
connectTimer = null
@@ -393,7 +389,9 @@ export function connect(
}
function handleSocketMessage(rawData: unknown): Promise<void> | void {
lastInboundAt = Date.now()
const receivedAt = Date.now()
lastInboundAt = receivedAt
openingWsLastInboundAt = receivedAt
const raw = typeof rawData === 'string' ? rawData : null
// Why: e2ee_ready is plaintext (precedes encrypted auth); e2ee_authenticated/e2ee_error are encrypted.
@@ -595,34 +593,16 @@ export function connect(
if (outbound === openingOutbound) {
disposeActiveOutbound()
}
const e = event as { code?: number; reason?: string; wasClean?: boolean } | undefined
const closeCode =
typeof e?.code === 'number' && Number.isInteger(e.code) && e.code >= 0 && e.code <= 65_535
? e.code
: undefined
const wasClean = typeof e?.wasClean === 'boolean' ? e.wasClean : undefined
const closeAt = Date.now()
// Why: time-since-construct classifies the failure — instant close = RST/unreachable, slow = SYN timeout/packet loss.
const constructToCloseMs = currentWsOpenedAt != null ? closeAt - currentWsOpenedAt : null
const aliveMs =
currentWsOpenedAt != null && state === 'connected' ? closeAt - currentWsOpenedAt : null
const inboundIdleMs = lastInboundAt != null ? closeAt - lastInboundAt : null
// Why: statically imported — a hot-reload bug came from a stale closure capturing a half-loaded module.
const closeEvent = describeSocketEvent(event)
console.log('[net] ws.onclose', {
code: closeCode,
wasClean,
const closeCode = logRpcSocketClose({
event,
state,
attempt: reconnectAttempt,
intentionallyClosed,
endpoint: redactedWebSocketEndpoint(endpoint),
constructToCloseMs,
aliveMs,
inboundIdleMs,
eventFields: closeEvent.fields
constructedAt: now,
authenticated: openingWsAuthenticated,
lastInboundAt: openingWsLastInboundAt
})
lastWsClosedAt = closeAt
currentWsOpenedAt = null
handleSocketClosed(openingWs, { closeCode })
}
@@ -1137,7 +1117,7 @@ export function connect(
return () => stateListeners.delete(listener)
},
notifyForeground(): void {
notifyForeground(_reason?: ForegroundNudgeReason): void {
if (intentionallyClosed) {
return
}
@@ -1183,7 +1163,7 @@ export function connect(
clearTimeout(handshakeTimer)
handshakeTimer = null
}
stopActivityProbe()
activityProbe.stop()
disposeActiveOutbound()
if (ws) {
ws.close()
@@ -54,7 +54,6 @@ export function logRpcSocketClose(options: SocketCloseLogOptions): number | unde
const closeEvent = describeSocketEvent(options.event)
console.log('[net] ws.onclose', {
code: event?.code,
reason: event?.reason,
wasClean: event?.wasClean,
state: options.state,
attempt: options.attempt,
@@ -63,8 +62,7 @@ export function logRpcSocketClose(options: SocketCloseLogOptions): number | unde
constructToCloseMs,
aliveMs: options.authenticated ? constructToCloseMs : null,
inboundIdleMs: options.lastInboundAt != null ? closeAt - options.lastInboundAt : null,
eventKeys: closeEvent.keys,
eventStr: closeEvent.json
eventFields: closeEvent.fields
})
return event?.code
}
@@ -19,7 +19,7 @@ export enum TerminalStreamOpcode {
ClaimViewport = 14,
OutputSpan = 15,
// Why: query replies must bypass input-floor claims while remaining distinguishable for Desktop authorization.
QueryReply = 16
QueryReply = 18
}
export type TerminalStreamFrame = {
@@ -64,6 +64,7 @@ export function startHostWorktreeRefresh({
return
}
if (event.type === 'reposChanged') {
void fetchWorktrees()
void fetchRepoMetadata({ force: true, queueIfInFlight: true })
} else if (event.type === 'worktreesChanged') {
void fetchWorktrees()
@@ -18,13 +18,31 @@ export type FileReadResult = {
mimeType?: string
}
export type FileChunkReadResult = {
contentBase64: string
bytesRead: number
eof: boolean
}
type FilesystemDirectoryReadOptions = {
maxEntries?: number
maxRetainedBytes?: number
}
export type IFilesystemProvider = {
readDir(dirPath: string): Promise<DirEntry[]>
readDir(dirPath: string, options?: FilesystemDirectoryReadOptions): Promise<DirEntry[]>
readFile(filePath: string): Promise<FileReadResult>
readFileChunk?(filePath: string, offset: number, length: number): Promise<FileChunkReadResult>
readTerminalArtifact?(
filePath: string,
options: TerminalArtifactAccessOptions
): Promise<FileReadResult>
readTerminalArtifactChunk?(
filePath: string,
offset: number,
length: number,
options: TerminalArtifactAccessOptions
): Promise<FileChunkReadResult>
downloadFile?(sourcePath: string, destinationPath: string): Promise<void>
downloadFolder?: (src: string, dest: string, options?: { signal?: AbortSignal }) => Promise<void>
openFileUploadSession?(): Promise<FileUploadSession>
+12 -218
View File
@@ -1,45 +1,6 @@
import type {
DirEntry,
FsChangeEvent,
GitStatusResult,
GitDiffResult,
GitBranchCompareResult,
GitCommitCompareResult,
GitConflictOperation,
GitForkSyncExpectedUpstream,
GitForkSyncResult,
GitPushTarget,
GitStagingArea,
GitUpstreamStatus,
GitWorktreeInfo,
TuiAgent,
RemoveWorktreeResult,
SearchOptions,
SearchResult
} from '../../shared/types'
import type { GitHistoryOptions, GitHistoryResult } from '../../shared/git-history'
import type { PtyStartupIngressIntent } from '../../shared/pty-startup-ingress'
import type { CommitMessageDraftContext } from '../../shared/commit-message-generation'
import type { WorkspaceSpaceDirectoryScanResult } from '../../shared/workspace-space-types'
import type { FilesystemPathListingProvider } from './filesystem-path-listing-provider'
import type { StartupCommandDelivery } from '../../shared/codex-startup-delivery'
import type { TerminalOscLinkRange } from '../../shared/terminal-osc-link-ranges'
import type { GitProviderStatusOptions } from './git-provider-status-options'
import type { PtyBackgroundStreamEvent, PtyDataEvent } from './pty-provider-events'
import type { PtySpawnResult } from './pty-spawn-result'
import type { PtyIncarnationId } from '../../shared/pty-incarnation'
import type {
AgentSessionExecutionClaim,
AgentSessionSurfaceBinding
} from '../../shared/agent-session-host-authority'
import type { PtyProcessInfo } from './pty-process-info'
export type {
PtyBackgroundStreamEvent,
PtyDataEvent,
PtyTransientFact
} from './pty-provider-events'
export type { IProviderRegistry } from './provider-registry-types'
// Aggregate provider contract surface. The three per-domain contracts live in
// their own files; this barrel keeps every historical `providers/types` import
// path working.
// ─── PTY Provider ───────────────────────────────────────────────────
@@ -56,182 +17,15 @@ export type {
// ─── Filesystem Provider ────────────────────────────────────────────
export type FileStat = {
size: number
type: 'file' | 'directory' | 'symlink'
mtime: number
mtimeMs?: number
dev?: number
ino?: number
nlink?: number
}
export type FileReadResult = {
content: string
isBinary: boolean
isImage?: boolean
mimeType?: string
}
export type FileChunkReadResult = { contentBase64: string; bytesRead: number; eof: boolean }
type FilesystemDirectoryReadOptions = { maxEntries?: number; maxRetainedBytes?: number }
export type IFilesystemProvider = FilesystemPathListingProvider & {
readDir(dirPath: string, options?: FilesystemDirectoryReadOptions): Promise<DirEntry[]>
readFile(filePath: string): Promise<FileReadResult>
readFileChunk?(filePath: string, offset: number, length: number): Promise<FileChunkReadResult>
readTerminalArtifact?(
filePath: string,
options: TerminalArtifactAccessOptions
): Promise<FileReadResult>
readTerminalArtifactChunk?(
filePath: string,
offset: number,
length: number,
options: TerminalArtifactAccessOptions
): Promise<FileChunkReadResult>
downloadFile?(sourcePath: string, destinationPath: string): Promise<void>
downloadFolder?: (src: string, dest: string, options?: { signal?: AbortSignal }) => Promise<void>
openFileUploadSession?(): Promise<FileUploadSession>
getTempDir?(): Promise<string>
writeFile(filePath: string, content: string): Promise<void>
writeTerminalArtifact?(
filePath: string,
content: string,
options: TerminalArtifactAccessOptions
): Promise<FileStat>
writeFileBase64(filePath: string, contentBase64: string): Promise<void>
writeFileBase64Chunk(filePath: string, contentBase64: string, append: boolean): Promise<void>
stat(filePath: string): Promise<FileStat>
lstat?(filePath: string): Promise<FileStat>
deletePath(targetPath: string, recursive?: boolean): Promise<void>
createFile(filePath: string): Promise<void>
createDir(dirPath: string): Promise<void>
createDirNoClobber(dirPath: string): Promise<void>
rename(oldPath: string, newPath: string): Promise<void>
renameNoClobber(oldPath: string, newPath: string): Promise<void>
copy(source: string, destination: string): Promise<void>
realpath(filePath: string): Promise<string>
search(opts: SearchOptions): Promise<SearchResult>
listFiles(
rootPath: string,
options?: { excludePaths?: string[]; signal?: AbortSignal; maxResults?: number }
): Promise<string[]>
scanWorkspaceSpace?(
rootPath: string,
options?: { signal?: AbortSignal }
): Promise<WorkspaceSpaceDirectoryScanResult>
watch(
rootPath: string,
callback: (events: FsChangeEvent[]) => void,
options?: { signal?: AbortSignal; onTerminalError?: (error: Error) => void }
): Promise<() => void>
closeWatch?(rootPath: string): Promise<void>
}
export type FileUploadSession = {
uploadFile(
sourcePath: string,
destinationPath: string,
options?: { exclusive?: boolean }
): Promise<void>
close(): void
}
export type TerminalArtifactAccessOptions = {
expectedRealPath: string
expectedStatIdentity: string | null
maxBytes: number
}
export type {
FileChunkReadResult,
FileReadResult,
FileStat,
FileUploadSession,
IFilesystemProvider,
TerminalArtifactAccessOptions
} from './filesystem-provider-contract'
// ─── Git Provider ───────────────────────────────────────────────────
export type { GitProviderStatusOptions } from './git-provider-status-options'
export type IGitProvider = {
getStatus(worktreePath: string, options?: GitProviderStatusOptions): Promise<GitStatusResult>
getSubmoduleStatus(
worktreePath: string,
submodulePath: string,
area?: GitStagingArea
): Promise<GitStatusResult>
checkIgnoredPaths(worktreePath: string, relativePaths: string[]): Promise<string[]>
getHistory(worktreePath: string, options?: GitHistoryOptions): Promise<GitHistoryResult>
commit(worktreePath: string, message: string): Promise<{ success: boolean; error?: string }>
getStagedCommitContext(worktreePath: string): Promise<CommitMessageDraftContext | null>
getDiff(
worktreePath: string,
filePath: string,
staged: boolean,
compareAgainstHead?: boolean
): Promise<GitDiffResult>
stageFile(worktreePath: string, filePath: string): Promise<void>
unstageFile(worktreePath: string, filePath: string): Promise<void>
bulkStageFiles(worktreePath: string, filePaths: string[]): Promise<void>
bulkUnstageFiles(worktreePath: string, filePaths: string[]): Promise<void>
discardChanges(worktreePath: string, filePath: string): Promise<void>
bulkDiscardChanges(worktreePath: string, filePaths: string[]): Promise<void>
detectConflictOperation(worktreePath: string): Promise<GitConflictOperation>
abortMerge(worktreePath: string): Promise<void>
abortRebase(worktreePath: string): Promise<void>
checkoutBranch(worktreePath: string, branch: string): Promise<void>
listLocalBranches(worktreePath: string): Promise<{ current: string | null; branches: string[] }>
getBranchCompare(worktreePath: string, baseRef: string): Promise<GitBranchCompareResult>
getCommitCompare(worktreePath: string, commitId: string): Promise<GitCommitCompareResult>
getUpstreamStatus(worktreePath: string, pushTarget?: GitPushTarget): Promise<GitUpstreamStatus>
pushBranch(
worktreePath: string,
publish?: boolean,
pushTarget?: GitPushTarget,
options?: { forceWithLease?: boolean }
): Promise<void>
pullBranch(worktreePath: string, pushTarget?: GitPushTarget): Promise<void>
fastForwardBranch(worktreePath: string, pushTarget?: GitPushTarget): Promise<void>
rebaseFromBase(worktreePath: string, baseRef: string): Promise<void>
fetchRemote(worktreePath: string, pushTarget?: GitPushTarget): Promise<void>
syncForkDefaultBranch(
worktreePath: string,
expectedUpstream: GitForkSyncExpectedUpstream
): Promise<GitForkSyncResult>
getBranchDiff(
worktreePath: string,
baseRef: string,
options?: { includePatch?: boolean; filePath?: string; oldPath?: string }
): Promise<GitDiffResult[]>
getCommitDiff(
worktreePath: string,
args: { commitOid: string; parentOid?: string | null; filePath: string; oldPath?: string }
): Promise<GitDiffResult>
listWorktrees(repoPath: string, options?: { signal?: AbortSignal }): Promise<GitWorktreeInfo[]>
addWorktree(
repoPath: string,
branchName: string,
targetDir: string,
options?: { base?: string; checkoutExistingBranch?: boolean; noCheckout?: boolean }
): Promise<void>
removeWorktree(
worktreePath: string,
force?: boolean,
options?: { deleteBranch?: boolean; forceBranchDelete?: boolean }
): Promise<RemoveWorktreeResult>
renameCurrentBranch?(worktreePath: string, newBranch: string): Promise<void>
forceDeletePreservedBranch?(
repoPath: string,
branchName: string,
expectedHead: string
): Promise<void>
isGitRepo(path: string): boolean
isGitRepoAsync(dirPath: string): Promise<{ isRepo: boolean; rootPath: string | null }>
exec(
args: string[],
cwd: string,
options?: { signal?: AbortSignal; timeoutMs?: number }
): Promise<{ stdout: string; stderr: string }>
getRemoteFileUrl(worktreePath: string, relativePath: string, line: number): Promise<string | null>
getRemoteCommitUrl(worktreePath: string, sha: string): Promise<string | null>
worktreeIsClean(
worktreePath: string,
options?: { includeUntracked?: boolean }
): Promise<{ clean: boolean; stdout?: string }>
}
export type { GitProviderStatusOptions, IGitProvider } from './git-provider-contract'
+10 -27
View File
@@ -42,6 +42,7 @@ import {
AGENT_STATUS_STALE_AFTER_MS,
isFreshNonDoneAgentStatus,
normalizeAgentStatusPayload,
pickParsedAgentStatusPayload,
type AgentStatusIpcPayload,
type ParsedAgentStatusPayload,
type AgentStatusOrchestrationContext,
@@ -2799,6 +2800,13 @@ async function hasLocalWorktreeBaseRef(
)
}
function getSetupRunnerCommandPlatformForLaunch(
setup: CreateWorktreeResult['setup'],
fallbackPlatform: 'windows' | 'posix'
): 'windows' | 'posix' {
return getSetupRunnerCommandPlatformForPath(setup?.runnerScriptPath ?? '', fallbackPlatform)
}
function normalizeMobileHookStatus(
status: AgentStatusIpcPayload | null,
fresh: boolean
@@ -31092,7 +31100,8 @@ export class OrcaRuntimeService {
// Why not lastOutputAt: this state is title-derived, so it must be dated by
// its evidence. Stamping it with the byte stream made the frame advance on
// every output byte, so a paired client's live status could never outrank it.
const evidenceAt = pty?.lastOscTitleEpochMs ?? hookRow.providerSessionReceivedAt ?? Date.now()
const evidenceAt =
pty?.lastOscTitleEpochMs ?? providerRow.providerSessionReceivedAt ?? Date.now()
const agentType = ownerAgent ?? undefined
return {
agentStatus: {
@@ -31117,32 +31126,6 @@ export class OrcaRuntimeService {
}
}
/** Live hook status to publish for a pane with no retained OSC row, or null when the
* pane's hook evidence only proves identity.
*
* Why the freshness rule: `pty.lastAgentStatus` is title-derived and refreshed live,
* so an unconditional hook precedence would let a 29-minute-old `done` erase a pane
* that is visibly working. A pending `interactivePrompt` outranks title evidence at
* any age — the agent is parked on a selector until it answers — and it is also the
* only signal allowed to survive the #1437 non-agent-title suppression. */
private resolveHookLiveAgentRow(
live: HookLiveAgentRow | null,
pty: RuntimePtyWorktreeRecord | null,
nonAgentTitle: boolean
): HookLiveAgentRow | null {
if (!live) {
return null
}
if (live.payload.interactivePrompt != null) {
return live
}
// Why only this stamp: it is the sole wall-clock date on the pane's live title,
// so it is the only one comparable to a hook `receivedAt`. The sibling
// `titleUpdatedAt`/`lastOscTitleAt`/`paneTitleUpdatedAt` fields are observation
// sequence numbers, and comparing them here can only ever misfire.
return !nonAgentTitle && live.updatedAt >= (pty?.lastOscTitleEpochMs ?? 0) ? live : null
}
/** Hook-reported identity for this pane, newest wins per field.
*
* `providerSession` is deliberately unbounded: it is resume identity, not live
+1
View File
@@ -39,6 +39,7 @@ import { EMULATOR_METHODS } from './emulator'
import { PAIRING_METHODS } from './pairing'
import { UPDATER_METHODS } from './updater'
import { AGENT_SESSION_METHODS } from './agent-session'
import { ARTIFACT_METHODS } from './artifacts'
import { MOBILE_WEB_PACKAGE_METHODS } from './mobile-web-package'
import { MOBILE_FILE_WRITE_METHODS } from './mobile-file-write-if-unchanged'
+3 -3
View File
@@ -340,9 +340,9 @@ async function sendTerminalStreamInput(
try {
if (args.inputKind === 'query-reply') {
if (!clientId || !isTerminalQueryReply(args.text)) {
return
return 'failed'
}
await runtime.sendTerminal(args.terminal, action, {
const result = await runtime.sendTerminal(args.terminal, action, {
beforeWrite: (writePtyId) => {
if (
writePtyId !== args.ptyId ||
@@ -352,7 +352,7 @@ async function sendTerminalStreamInput(
}
}
})
return
return result.accepted ? 'delivered' : 'rejected'
}
if (!clientId) {
const result = await runtime.sendTerminal(args.terminal, action)
+1 -1
View File
@@ -50,7 +50,7 @@ export const RelayErrorCode = {
DiskFull: -33005,
TooManyStreams: -33006,
StreamProtocolError: -33007,
MarkdownDocumentListingCapacity: -33008
MarkdownDocumentListingCapacity: -33009
} as const
export const JsonRpcErrorCode = {
-1
View File
@@ -4,7 +4,6 @@ import { writeFile, stat, lstat, mkdir, rename, cp, rm, realpath } from 'node:fs
import { execFile } from 'node:child_process'
import { tmpdir } from 'node:os'
import type { RelayDispatcher, RequestContext } from './dispatcher'
import { sortDirEntries } from '../shared/file-name-sort'
import type { RelayContext } from './context'
// Why: RelayContext is accepted in the constructor for protocol back-compat
// (see docs/relay-fs-allowlist-removal.md), but no longer consulted on FS ops.
+3 -1
View File
@@ -103,7 +103,9 @@ export type GitResponseStreamMarker = {
export const RelayErrorCode = {
TooManyStreams: -33006,
StreamProtocolError: -33007,
MarkdownDocumentListingCapacity: -33008
/** Substituted for a response too large for the sink's frame capacity. */
ResponseOverCapacity: -33008,
MarkdownDocumentListingCapacity: -33009
} as const
export type JsonRpcRequest = {
+1
View File
@@ -1,4 +1,5 @@
export const AGENT_TYPE_MAX_LENGTH = 40
export const AGENT_MODEL_MAX_LENGTH = 120
export const AGENT_STATUS_TOOL_NAME_MAX_LENGTH = 60
export const AGENT_STATUS_TOOL_INPUT_MAX_LENGTH = 160
export const AGENT_STATUS_ASSISTANT_MESSAGE_MAX_LENGTH = 8000
+3 -31
View File
@@ -8,6 +8,7 @@ import {
AGENT_STATUS_INTERACTIVE_PROMPT_MAX_LENGTH,
AGENT_STATUS_TOOL_INPUT_MAX_LENGTH,
AGENT_STATUS_TOOL_NAME_MAX_LENGTH,
AGENT_MODEL_MAX_LENGTH,
AGENT_TYPE_MAX_LENGTH
} from './agent-status-limits'
import {
@@ -19,11 +20,13 @@ import {
import { assertJsonTextStructureWithinLimits } from './json-text-structure-limit'
export { AGENT_STATUS_MAX_FIELD_LENGTH } from './agent-status-field-normalization'
export { agentSubagentsEqual } from './agent-subagent-equality'
export {
AGENT_STATUS_ASSISTANT_MESSAGE_MAX_LENGTH,
AGENT_STATUS_INTERACTIVE_PROMPT_MAX_LENGTH,
AGENT_STATUS_TOOL_INPUT_MAX_LENGTH,
AGENT_STATUS_TOOL_NAME_MAX_LENGTH,
AGENT_MODEL_MAX_LENGTH,
AGENT_TYPE_MAX_LENGTH
} from './agent-status-limits'
@@ -287,8 +290,6 @@ export function isFreshNonDoneAgentStatus(
// Why: ReadonlySet<string> so .has() accepts any string without a cast here; the narrowing cast stays on the return line where it's proven safe.
const VALID_STATES: ReadonlySet<string> = new Set<string>(AGENT_STATUS_STATES)
export const AGENT_MODEL_MAX_LENGTH = 120
/** Maximum subagent child rows carried per status entry. Bounds per-pane cache
* and IPC fanout against a runaway spawner. */
export const AGENT_STATUS_MAX_SUBAGENTS = 32
@@ -346,35 +347,6 @@ function normalizeSubagentsField(value: unknown): AgentSubagentSnapshot[] | unde
return normalized.length > 0 ? normalized : undefined
}
/** Structural equality for subagent lists so stores can reuse the previous
* array reference (and skip fanout) when nothing actually changed. */
export function agentSubagentsEqual(
a: AgentSubagentSnapshot[] | undefined,
b: AgentSubagentSnapshot[] | undefined
): boolean {
if (a === b) {
return true
}
if (!a || !b || a.length !== b.length) {
return !a && !b
}
for (let i = 0; i < a.length; i++) {
const x = a[i]
const y = b[i]
if (
x.id !== y.id ||
x.state !== y.state ||
x.startedAt !== y.startedAt ||
x.agentType !== y.agentType ||
x.model !== y.model ||
x.description !== y.description
) {
return false
}
}
return true
}
/**
* Normalize and validate an already-parsed agent status object. Shared by the
* JSON string entry point (`parseAgentStatusPayload`) and the object entry
+28
View File
@@ -0,0 +1,28 @@
import type { AgentSubagentSnapshot } from './agent-status-types'
export function agentSubagentsEqual(
a: AgentSubagentSnapshot[] | undefined,
b: AgentSubagentSnapshot[] | undefined
): boolean {
if (a === b) {
return true
}
if (!a || !b || a.length !== b.length) {
return !a && !b
}
for (let i = 0; i < a.length; i++) {
const x = a[i]
const y = b[i]
if (
x.id !== y.id ||
x.state !== y.state ||
x.startedAt !== y.startedAt ||
x.agentType !== y.agentType ||
x.model !== y.model ||
x.description !== y.description
) {
return false
}
}
return true
}
@@ -1,5 +1,6 @@
import { z } from 'zod'
import {
AGENT_MODEL_MAX_LENGTH,
AGENT_STATUS_ASSISTANT_MESSAGE_MAX_LENGTH,
AGENT_STATUS_INTERACTIVE_PROMPT_MAX_LENGTH,
AGENT_STATUS_TOOL_INPUT_MAX_LENGTH,
@@ -30,6 +31,7 @@ export const MobileWebNativeChatAgentStatusSchema = z
state: z.enum(['working', 'blocked', 'waiting', 'done']),
stateStartedAt: z.number().int().nonnegative().max(Number.MAX_SAFE_INTEGER).optional(),
agentType: OptionalBoundedTextSchema(AGENT_TYPE_MAX_LENGTH),
model: OptionalBoundedTextSchema(AGENT_MODEL_MAX_LENGTH),
toolName: OptionalBoundedTextSchema(AGENT_STATUS_TOOL_NAME_MAX_LENGTH),
toolInput: OptionalBoundedTextSchema(AGENT_STATUS_TOOL_INPUT_MAX_LENGTH),
interactivePrompt: OptionalBoundedTextSchema(AGENT_STATUS_INTERACTIVE_PROMPT_MAX_LENGTH),
@@ -145,7 +147,14 @@ export const MobileWebNativeChatSendMessagePayloadSchema = z
.string()
.min(1)
.max(64 * 1024),
clearInputFirst: z.boolean().optional()
clearInputFirst: z.boolean().optional(),
resolvedLaunchDraft: z
.object({
text: z.string().max(64 * 1024),
createdAt: z.number().finite()
})
.strict()
.optional()
})
.strict()
export const MobileWebNativeChatRespondPayloadSchema = z
@@ -127,6 +127,18 @@ export const MobileWebTaskGitLabDetailResultSchema = z
comments: z.array(MobileWebTaskDetailCommentSchema).max(1_000),
labels: z.array(z.string().max(240)).max(1_000).optional(),
assignees: z.array(z.string().max(240)).max(1_000),
item: z
.object({ mergeable: z.enum(['MERGEABLE', 'CONFLICTING', 'UNKNOWN']).optional() })
.strip()
.optional(),
reviewers: z.array(z.unknown()).max(1_000).optional(),
approvalState: z
.object({
approvalsRequired: z.number().int().nonnegative().nullable(),
approvalsLeft: z.number().int().nonnegative().nullable()
})
.strip()
.optional(),
pipelineJobs: z
.array(
z
+8 -2
View File
@@ -29,8 +29,12 @@ export enum TerminalStreamOpcode {
// this opcode and still receive the compatibility Resize frame behind it.
ClaimViewport = 14,
OutputSpan = 15,
// Why: query replies must bypass input-floor claims while remaining distinguishable for Desktop authorization.
QueryReply = 16
// Negotiated per stream; older hosts reject unknown opcodes, so clients send only after capability confirmation.
SetOutputPaused = 16,
// Negotiated per stream because older clients reject unknown opcodes.
WriteUnavailable = 17,
// Negotiated per stream so terminal-generated replies bypass input-floor claims safely.
QueryReply = 18
}
export type TerminalStreamFrame = {
@@ -119,6 +123,8 @@ function isTerminalStreamOpcode(value: number): value is TerminalStreamOpcode {
value === TerminalStreamOpcode.Ack ||
value === TerminalStreamOpcode.ClaimViewport ||
value === TerminalStreamOpcode.OutputSpan ||
value === TerminalStreamOpcode.SetOutputPaused ||
value === TerminalStreamOpcode.WriteUnavailable ||
value === TerminalStreamOpcode.QueryReply
)
}