mirror of
https://github.com/stablyai/orca.git
synced 2026-09-30 00:03:15 +00:00
fix(mobile): revive hybrid architecture on current main
This commit is contained in:
@@ -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
|
||||
|
||||
@@ -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 {
|
||||
|
||||
@@ -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({
|
||||
|
||||
@@ -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
@@ -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
@@ -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",
|
||||
|
||||
Generated
+8
-8
@@ -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`')
|
||||
})
|
||||
})
|
||||
|
||||
@@ -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', () => {
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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',
|
||||
|
||||
@@ -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>()
|
||||
|
||||
@@ -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
|
||||
}
|
||||
|
||||
@@ -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
@@ -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'
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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'
|
||||
|
||||
|
||||
@@ -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)
|
||||
|
||||
@@ -50,7 +50,7 @@ export const RelayErrorCode = {
|
||||
DiskFull: -33005,
|
||||
TooManyStreams: -33006,
|
||||
StreamProtocolError: -33007,
|
||||
MarkdownDocumentListingCapacity: -33008
|
||||
MarkdownDocumentListingCapacity: -33009
|
||||
} as const
|
||||
|
||||
export const JsonRpcErrorCode = {
|
||||
|
||||
@@ -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.
|
||||
|
||||
@@ -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,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
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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
|
||||
)
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user