- {CAPABILITIES.map((line) => (
- -
+ {CAPABILITIES.map(({ key, fallback }) => (
+
-
- {line}
+ {translate(key, fallback)}
))}
diff --git a/src/renderer/src/components/onboarding/OnboardingFlow.tsx b/src/renderer/src/components/onboarding/OnboardingFlow.tsx
index 9e82e42547c..c57e2556014 100644
--- a/src/renderer/src/components/onboarding/OnboardingFlow.tsx
+++ b/src/renderer/src/components/onboarding/OnboardingFlow.tsx
@@ -90,11 +90,31 @@ const stepCopy = {
} as const
const stepTooltipLabels = {
- agent: 'Default Agent',
- theme: 'Appearance',
- windows_terminal: 'Windows Terminal',
- notifications: 'Notifications',
- integrations: 'Integrations'
+ agent: {
+ get value() {
+ return translate('components.onboarding.flow.stepTooltip.agent', 'Default Agent')
+ }
+ },
+ theme: {
+ get value() {
+ return translate('components.onboarding.flow.stepTooltip.theme', 'Appearance')
+ }
+ },
+ windows_terminal: {
+ get value() {
+ return translate('components.onboarding.flow.stepTooltip.windowsTerminal', 'Windows Terminal')
+ }
+ },
+ notifications: {
+ get value() {
+ return translate('components.onboarding.flow.stepTooltip.notifications', 'Notifications')
+ }
+ },
+ integrations: {
+ get value() {
+ return translate('components.onboarding.flow.stepTooltip.integrations', 'Integrations')
+ }
+ }
} as const
type OnboardingFlowProps = {
@@ -113,7 +133,10 @@ export default function OnboardingFlow({
const shouldShowSkipToProjectSetup = currentStep.id !== 'notifications'
const shouldShowFooterBusy = Boolean(busyLabel)
const footerPrimaryLabel =
- busyLabel ?? (currentStep.id === 'notifications' ? 'Add your first project' : 'Continue')
+ busyLabel ??
+ (currentStep.id === 'notifications'
+ ? translate('components.onboarding.flow.actions.addFirstProject', 'Add your first project')
+ : translate('components.onboarding.flow.actions.continue', 'Continue'))
const [skipConfirmOpen, setSkipConfirmOpen] = useState(false)
const skipConfirmAdvancedViaRef = useRef<'button' | 'keyboard'>('button')
const { next: flowNext, dismissOnboarding: flowDismissOnboarding } = flow
@@ -218,6 +241,7 @@ export default function OnboardingFlow({
{flow.progressSteps.map(({ step, index: realStepIndex }, progressIdx) => {
const isActive = realStepIndex === stepIndex
const isDone = realStepIndex < stepIndex
+ const stepTooltipLabel = stepTooltipLabels[step.id].value
return (
@@ -236,14 +260,14 @@ export default function OnboardingFlow({
aria-label={translate(
'auto.components.onboarding.OnboardingFlow.adaa0aa627',
'Go to onboarding step {{value0}}: {{value1}}',
- { value0: progressIdx + 1, value1: stepTooltipLabels[step.id] }
+ { value0: progressIdx + 1, value1: stepTooltipLabel }
)}
aria-current={isActive ? 'step' : undefined}
onClick={() => flow.jumpToStep(realStepIndex)}
/>
- {stepTooltipLabels[step.id]}
+ {stepTooltipLabel}
)
diff --git a/src/renderer/src/components/onboarding/OnboardingSkipConfirmationDialog.tsx b/src/renderer/src/components/onboarding/OnboardingSkipConfirmationDialog.tsx
index 1a721a6d774..6851ea83399 100644
--- a/src/renderer/src/components/onboarding/OnboardingSkipConfirmationDialog.tsx
+++ b/src/renderer/src/components/onboarding/OnboardingSkipConfirmationDialog.tsx
@@ -22,8 +22,12 @@ export const ONBOARDING_SKIP_CONFIRMATION_COPY = {
"It won't take long!"
)
},
- skipLabel: 'Skip',
- keepGoingLabel: 'No, keep going'
+ get skipLabel() {
+ return translate('components.onboarding.skipConfirmation.skip', 'Skip')
+ },
+ get keepGoingLabel() {
+ return translate('components.onboarding.skipConfirmation.keepGoing', 'No, keep going')
+ }
} as const
export function OnboardingSkipConfirmationDialog(props: {
diff --git a/src/renderer/src/components/onboarding/ThemeStep.tsx b/src/renderer/src/components/onboarding/ThemeStep.tsx
index bb797a12a6e..0929aba0d59 100644
--- a/src/renderer/src/components/onboarding/ThemeStep.tsx
+++ b/src/renderer/src/components/onboarding/ThemeStep.tsx
@@ -195,19 +195,19 @@ export function ThemeStep({ theme, onThemeChange, settings, updateSettings }: Th
{
id: 'system',
label: translate('auto.components.onboarding.ThemeStep.827ea7b4a2', 'System'),
- hint: 'Match OS',
+ hint: translate('components.onboarding.theme.hints.system', 'Match OS'),
icon: Monitor
},
{
id: 'dark',
label: translate('auto.components.onboarding.ThemeStep.fa7b673ea9', 'Dark'),
- hint: 'Easy on the eyes',
+ hint: translate('components.onboarding.theme.hints.dark', 'Easy on the eyes'),
icon: Moon
},
{
id: 'light',
label: translate('auto.components.onboarding.ThemeStep.ad192706e6', 'Light'),
- hint: 'Bright & crisp',
+ hint: translate('components.onboarding.theme.hints.light', 'Bright & crisp'),
icon: Sun
}
]
diff --git a/src/renderer/src/components/onboarding/use-onboarding-flow-actions.ts b/src/renderer/src/components/onboarding/use-onboarding-flow-actions.ts
index 9216de04e61..9fac360b162 100644
--- a/src/renderer/src/components/onboarding/use-onboarding-flow-actions.ts
+++ b/src/renderer/src/components/onboarding/use-onboarding-flow-actions.ts
@@ -117,7 +117,12 @@ export function useOnboardingFlowActions({
if (result.ok) {
trackCurrentStepCompleted(advancedVia)
if (currentStep.id === 'notifications') {
- setBusyLabel('Opening Add Project...')
+ setBusyLabel(
+ translate(
+ 'components.onboarding.flow.actions.openingAddProject',
+ 'Opening Add Project...'
+ )
+ )
const closed = await closeWith('completed', ONBOARDING_FINAL_STEP, 'add_project_modal')
if (closed) {
openModal('add-repo')
@@ -190,7 +195,9 @@ export function useOnboardingFlowActions({
const stepId = currentStep.id
const stepNumber = currentStep.stepNumber
const valueKind = currentStep.valueKind
- setBusyLabel('Opening Add Project...')
+ setBusyLabel(
+ translate('components.onboarding.flow.actions.openingAddProject', 'Opening Add Project...')
+ )
try {
const closed = await closeWith('completed', ONBOARDING_FINAL_STEP, 'add_project_modal')
if (!closed) {
diff --git a/src/renderer/src/i18n/locales/en.json b/src/renderer/src/i18n/locales/en.json
index 712df329873..57db52974bd 100644
--- a/src/renderer/src/i18n/locales/en.json
+++ b/src/renderer/src/i18n/locales/en.json
@@ -16820,6 +16820,41 @@
}
},
"components": {
+ "onboarding": {
+ "flow": {
+ "stepTooltip": {
+ "agent": "Default Agent",
+ "theme": "Appearance",
+ "windowsTerminal": "Windows Terminal",
+ "notifications": "Notifications",
+ "integrations": "Integrations"
+ },
+ "actions": {
+ "addFirstProject": "Add your first project",
+ "continue": "Continue",
+ "openingAddProject": "Opening Add Project..."
+ }
+ },
+ "skipConfirmation": {
+ "skip": "Skip",
+ "keepGoing": "No, keep going"
+ },
+ "theme": {
+ "hints": {
+ "system": "Match OS",
+ "dark": "Easy on the eyes",
+ "light": "Bright & crisp"
+ }
+ },
+ "integrations": {
+ "capabilities": {
+ "startWorkspaceFromIssue": "Start a workspace from any GitHub issue or pull request, prefilled with its title and context",
+ "browseIssues": "Browse GitHub issues and pull requests in the Tasks view without leaving Orca",
+ "reviewStatus": "See issue state, review status, and CI checks on every worktree",
+ "managePullRequests": "Read, comment on, and merge pull requests without leaving Orca"
+ }
+ }
+ },
"jiraUserPicker": {
"select": "Select {{value0}}",
"search": "Search users",
diff --git a/src/renderer/src/i18n/locales/ko.json b/src/renderer/src/i18n/locales/ko.json
index 47d9e48fd26..d05754d176e 100644
--- a/src/renderer/src/i18n/locales/ko.json
+++ b/src/renderer/src/i18n/locales/ko.json
@@ -14616,6 +14616,41 @@
}
},
"components": {
+ "onboarding": {
+ "flow": {
+ "stepTooltip": {
+ "agent": "기본 Agent",
+ "theme": "테마",
+ "windowsTerminal": "Windows 터미널",
+ "notifications": "알림",
+ "integrations": "연동"
+ },
+ "actions": {
+ "addFirstProject": "첫 프로젝트 추가",
+ "continue": "계속",
+ "openingAddProject": "프로젝트 추가 화면을 여는 중…"
+ }
+ },
+ "skipConfirmation": {
+ "skip": "건너뛰기",
+ "keepGoing": "아니요, 계속하기"
+ },
+ "theme": {
+ "hints": {
+ "system": "OS 설정에 맞춤",
+ "dark": "눈이 편안한 화면",
+ "light": "밝고 선명한 화면"
+ }
+ },
+ "integrations": {
+ "capabilities": {
+ "startWorkspaceFromIssue": "GitHub 이슈나 PR에서 제목과 컨텍스트가 미리 채워진 워크스페이스를 시작합니다",
+ "browseIssues": "Orca를 떠나지 않고 작업 화면에서 GitHub 이슈와 PR을 탐색합니다",
+ "reviewStatus": "모든 워크트리에서 이슈 상태, 리뷰 상태 및 CI 체크를 확인합니다",
+ "managePullRequests": "Orca를 떠나지 않고 PR을 읽고, 댓글을 달고, 병합합니다"
+ }
+ }
+ },
"native-chat": {
"composer": {
"imageUnsupported": "이 에이전트는 이미지 붙여넣기를 지원하지 않습니다.",
From 98e77ef1a70e2f619ca7f3b216db13cd20f02461 Mon Sep 17 00:00:00 2001
From: Brennan Benson <79079362+brennanb2025@users.noreply.github.com>
Date: Thu, 3 Sep 2026 15:19:26 -0700
Subject: [PATCH 22/76] feat(mobile): structured native Codex chat (#18074)
MIME-Version: 1.0
Content-Type: text/plain; charset=UTF-8
Content-Transfer-Encoding: 8bit
* feat(mobile): finalize structured native Codex chat
* fix(mobile): close structured chat lifecycle gaps
* wip(mobile): fence stale structured inventory and bound operation-id retention
Fence local structured-session inventory and subscription responses with a
sync generation so a toggle-off clear, reconnect restore, or retry cannot
apply a mirror from a superseded instance. Bound mobile ambiguous
operation-ID retention at 128 with unmount cleanup.
Staged on the reconcile branch only: the sync module is now 312 lines and
needs a real split before this can reach the PR head.
* fix(ci): split the structured session-tabs sync and give static analysis mobile types
The local structured session-tabs sync module outgrew the 300-line cap once it
took on generation fencing, so split it along its real seams instead of raising
the cap: the generation/cursor fence, snapshot projection, snapshot apply,
inventory refresh, and the subscription loop. The original path stays as a
barrel so no importer moves.
Repoint the host-session-mirror settle census at the apply module, which owns
two receipts now — the snapshot it mirrors in, and the toggle-off teardown that
retracts what it published. The teardown receipt is named rather than anonymous
so the pin says which direction it settles.
The changed-code quality gate lints mobile files and resolves their types from
mobile/node_modules, but mobile is a separate pnpm project that the root install
never populates, so every mobile type degraded to an `error` type and the gate
reported phantom findings. Install mobile dependencies in static analysis when
the diff touches mobile, gated on a new classifier output.
* fix(mobile): let a slow capability handshake still reach connected
The mobile capability update is an advisory whose result is discarded, yet an
unanswered one was fatal while an explicit rejection was tolerated. A 5s timeout
on the direct client force-closed the socket, and on the relay path it failed
`confirmResume` before `connected` was ever published, so a consistently slow
link redialled forever. Both paths now share one helper that settles every
ambiguous outcome (timeout, mid-flight drop) like a rejection and rejects only
when the frame never reached the wire — the one case nothing else recovers from,
since the socket's own desync force-close is gated on already being connected.
The generation guard still keeps a replaced session from connecting.
Retained structured-session operation ids were capped at 128 with oldest-first
eviction, but every retained id belongs to a send whose outcome is unknown, so
eviction turned a user's retry into a second message on the host. Bound the map
by expiry against the id's own embedded timestamp instead, mirroring the host's
operation ledger, so no id is released while the host would still honour it.
Also give the mobile CI install the root install's lockfile drift guard (mobile's
lockfile carries patchedDependencies a silent rewrite would drop), gate
mobile_dependencies on should_run, and key the pnpm store cache on both lockfiles.
* refactor(mobile): extract the relay pending-request registry
The merge composed two independently-sized changes — this branch's capability
handshake settle and main's dial-stage tracking — pushing the relay session file
to 304 lines against a 300 cap. Neither side broke it alone.
Move the in-flight request registry (id generation, tracking, settlement, and
reject-all with its delivery-ambiguity marking) into RelayPendingRequests,
matching the existing collaborator pattern alongside RelayDialStageTracker and
RpcSessionLivenessWatchdog. No behavior change.
---------
Co-authored-by: Merge Sim
---
.../install-node-dependencies/action.yml | 9 +
.github/workflows/pr.yml | 20 +
config/scripts/pr-code-change-scope.mjs | 9 +
config/scripts/pr-code-change-scope.test.mjs | 32 +
.../src/session/MobileNativeChatQuestion.tsx | 81 +-
.../session/MobileSessionActiveContent.tsx | 4 +-
mobile/src/session/MobileSessionHeader.tsx | 1 +
mobile/src/session/MobileSessionSheets.tsx | 10 +
mobile/src/session/mobile-file-tap-open.ts | 4 +-
.../mobile-native-chat-controller-contract.ts | 7 +-
.../mobile-native-chat-eligibility.test.ts | 24 +
.../session/mobile-native-chat-eligibility.ts | 24 +-
.../mobile-native-chat-image-scope-state.ts | 18 +
.../mobile-native-chat-question.test.ts | 10 +
.../session/mobile-native-chat-question.ts | 14 +
.../mobile-session-route-parity.test.ts | 22 +-
.../src/session/mobile-session-route-types.ts | 10 +-
.../mobile-structured-agent-prompts.ts | 251 ++++++
...le-structured-agent-session-launch.test.ts | 142 +++
.../mobile-structured-agent-session-launch.ts | 158 ++++
.../mobile-structured-agent-session-rpc.ts | 152 ++++
...ctured-session-operation-retention.test.ts | 58 ++
.../session/mobile-terminal-records.test.ts | 14 +
mobile/src/session/mobile-terminal-records.ts | 10 +
.../session/mobile-terminal-tab-agent.test.ts | 13 +
.../src/session/mobile-terminal-tab-agent.ts | 3 +
.../session/opened-mobile-session-tab.test.ts | 15 +
.../src/session/opened-mobile-session-tab.ts | 19 +-
.../use-mobile-file-tap-handlers.test.ts | 27 +
.../session/use-mobile-file-tap-handlers.ts | 10 +-
...se-mobile-native-chat-active-resolution.ts | 83 ++
.../use-mobile-native-chat-controller.test.ts | 163 +++-
.../use-mobile-native-chat-controller.ts | 206 +++--
...se-mobile-native-chat-image-attachments.ts | 167 ++--
.../use-mobile-native-chat-image-upload.ts | 126 +++
...e-native-chat-session-option-controller.ts | 100 +++
.../session/use-mobile-session-attachments.ts | 4 +-
.../use-mobile-session-file-actions.ts | 6 +-
...-mobile-session-image-attachments.test.tsx | 123 +++
.../use-mobile-session-image-attachments.ts | 13 +-
...se-mobile-session-native-chat-dictation.ts | 7 +
.../use-mobile-session-screen-state.ts | 24 +-
.../use-mobile-session-tab-action-targets.ts | 85 ++
.../use-mobile-session-tab-switching.ts | 3 +
...le-session-terminal-create-actions.test.ts | 231 +++++
...-mobile-session-terminal-create-actions.ts | 31 +
...se-mobile-session-terminal-send-actions.ts | 28 +-
.../use-mobile-structured-agent-options.ts | 161 ++++
...e-mobile-structured-agent-session.test.tsx | 849 ++++++++++++++++++
.../use-mobile-structured-agent-session.ts | 315 +++++++
.../use-mobile-structured-agent-state.ts | 198 ++++
...bile-structured-native-chat-send-bridge.ts | 100 +++
.../cellular-connecting-label-stall.test.ts | 4 +
mobile/src/transport/direct-connection-log.ts | 4 +
mobile/src/transport/direct-rpc-client.ts | 22 +-
.../foreground-stale-dial-restart.test.ts | 4 +
.../mobile-relay-rpc-session-liveness.test.ts | 10 +
.../mobile-relay-rpc-session.test.ts | 59 +-
.../src/transport/mobile-relay-rpc-session.ts | 57 +-
...ile-runtime-capability-negotiation.test.ts | 69 ++
.../mobile-runtime-capability-negotiation.ts | 57 ++
.../mobile-runtime-client-capabilities.ts | 44 +
.../src/transport/relay-pending-requests.ts | 53 ++
.../transport/rpc-client-capabilities.test.ts | 161 ++++
.../rpc-client-connect-wait-replay.test.ts | 4 +
.../rpc-client-delivery-ambiguity.test.ts | 4 +
.../rpc-client-request-deadline.test.ts | 4 +
.../transport/rpc-client-request-tracker.ts | 21 +-
.../rpc-client-runtime-events.test.ts | 4 +
...ient-synthesized-close-diagnostics.test.ts | 4 +
.../rpc-client-terminal-reconnect.test.ts | 4 +
.../rpc-client-unauthorized-close.test.ts | 4 +
mobile/src/transport/rpc-client.test.ts | 41 +-
.../rpc-session-liveness-integration.test.ts | 4 +
src/main/runtime/mobile-rpc-allowlist.test.ts | 31 +-
...time-close-structured-agent-session-tab.ts | 10 +-
src/main/runtime/orca-runtime-state-fields.ts | 5 +
...me-structured-native-chat-settings.test.ts | 21 +
...runtime-structured-session-restore.test.ts | 12 +
src/main/runtime/rpc/core.ts | 2 +
.../runtime/rpc/dispatcher-stream-options.ts | 1 +
src/main/runtime/rpc/dispatcher.ts | 4 +-
.../runtime/rpc/methods/client-ui.test.ts | 19 +
src/main/runtime/rpc/methods/index.ts | 2 +
.../runtime-client-capabilities.test.ts | 74 ++
.../methods/runtime-client-capabilities.ts | 24 +
...ion-tab-agent-capability-mutations.test.ts | 42 +
...ession-tab-agent-status-projection.test.ts | 24 +
.../session-tab-agent-status-projection.ts | 14 +-
.../rpc/methods/session-tab-close-methods.ts | 11 +-
.../methods/session-tab-mutation-methods.ts | 21 +-
.../rpc/methods/session-tabs-inventory.ts | 27 +-
.../runtime/rpc/methods/session-tabs.test.ts | 47 +-
src/main/runtime/rpc/methods/session-tabs.ts | 26 +-
.../methods/structured-agent-session-gate.ts | 7 +-
.../structured-agent-session-policy.ts | 47 +
.../methods/structured-agent-session.test.ts | 76 +-
.../rpc/methods/structured-agent-session.ts | 25 +-
.../methods/structured-session-tab-restore.ts | 12 +-
.../runtime/rpc/rpc-streaming-dispatcher.ts | 2 +
src/main/runtime/runtime-client-settings.ts | 2 +
.../runtime-rpc-mobile-method-allowlist.ts | 17 +
.../runtime-rpc-websocket-dispatch.ts | 6 +
src/main/runtime/runtime-store-contract.ts | 2 +
.../app-shell/use-app-startup-hydration.ts | 8 +-
src/renderer/src/app-startup-routing.test.ts | 10 +
...ctured-agent-session-message-projection.ts | 37 +-
.../use-structured-agent-session-hold.ts | 8 +-
.../host-session-mirror-settle-census.test.ts | 14 +-
...local-structured-session-tab-retirement.ts | 64 ++
...local-structured-session-tabs-sync.test.ts | 90 ++
.../local-structured-session-tabs-sync.ts | 301 +------
.../inventory-generation-fence.ts | 57 ++
.../inventory-refresh.ts | 37 +
.../snapshot-apply.ts | 118 +++
.../snapshot-projection.ts | 37 +
.../subscription.ts | 122 +++
src/shared/structured-agent-session-holder.ts | 6 +
...ctured-agent-session-message-projection.ts | 29 +
.../structured-agent-session-reducer.ts | 3 +-
...ss-version-agent-session-wire.unit.test.ts | 44 +
.../versioned-agent-session-wire.ts | 1 +
122 files changed, 5667 insertions(+), 764 deletions(-)
create mode 100644 mobile/src/session/mobile-native-chat-image-scope-state.ts
create mode 100644 mobile/src/session/mobile-structured-agent-prompts.ts
create mode 100644 mobile/src/session/mobile-structured-agent-session-launch.test.ts
create mode 100644 mobile/src/session/mobile-structured-agent-session-launch.ts
create mode 100644 mobile/src/session/mobile-structured-agent-session-rpc.ts
create mode 100644 mobile/src/session/mobile-structured-session-operation-retention.test.ts
create mode 100644 mobile/src/session/use-mobile-native-chat-active-resolution.ts
create mode 100644 mobile/src/session/use-mobile-native-chat-image-upload.ts
create mode 100644 mobile/src/session/use-mobile-native-chat-session-option-controller.ts
create mode 100644 mobile/src/session/use-mobile-session-image-attachments.test.tsx
create mode 100644 mobile/src/session/use-mobile-session-tab-action-targets.ts
create mode 100644 mobile/src/session/use-mobile-session-terminal-create-actions.test.ts
create mode 100644 mobile/src/session/use-mobile-structured-agent-options.ts
create mode 100644 mobile/src/session/use-mobile-structured-agent-session.test.tsx
create mode 100644 mobile/src/session/use-mobile-structured-agent-session.ts
create mode 100644 mobile/src/session/use-mobile-structured-agent-state.ts
create mode 100644 mobile/src/session/use-mobile-structured-native-chat-send-bridge.ts
create mode 100644 mobile/src/transport/mobile-runtime-capability-negotiation.test.ts
create mode 100644 mobile/src/transport/mobile-runtime-capability-negotiation.ts
create mode 100644 mobile/src/transport/mobile-runtime-client-capabilities.ts
create mode 100644 mobile/src/transport/relay-pending-requests.ts
create mode 100644 mobile/src/transport/rpc-client-capabilities.test.ts
create mode 100644 src/main/runtime/orca-runtime-structured-native-chat-settings.test.ts
create mode 100644 src/main/runtime/rpc/methods/runtime-client-capabilities.test.ts
create mode 100644 src/main/runtime/rpc/methods/runtime-client-capabilities.ts
create mode 100644 src/main/runtime/rpc/methods/structured-agent-session-policy.ts
create mode 100644 src/renderer/src/runtime/local-structured-session-tab-retirement.ts
create mode 100644 src/renderer/src/runtime/local-structured-session-tabs-sync/inventory-generation-fence.ts
create mode 100644 src/renderer/src/runtime/local-structured-session-tabs-sync/inventory-refresh.ts
create mode 100644 src/renderer/src/runtime/local-structured-session-tabs-sync/snapshot-apply.ts
create mode 100644 src/renderer/src/runtime/local-structured-session-tabs-sync/snapshot-projection.ts
create mode 100644 src/renderer/src/runtime/local-structured-session-tabs-sync/subscription.ts
create mode 100644 src/shared/structured-agent-session-holder.ts
create mode 100644 src/shared/structured-agent-session-message-projection.ts
diff --git a/.github/actions/install-node-dependencies/action.yml b/.github/actions/install-node-dependencies/action.yml
index 46edfc54111..e36ec4c65d8 100644
--- a/.github/actions/install-node-dependencies/action.yml
+++ b/.github/actions/install-node-dependencies/action.yml
@@ -39,6 +39,9 @@ runs:
with:
install: false
+ # Why both lockfiles: setup-node keys the pnpm store on the root lockfile alone, so
+ # jobs that also install mobile restored a store with none of the React Native tree
+ # in it and re-downloaded the lot on every run.
- name: Setup Node.js
id: default-node
if: inputs.node-version == ''
@@ -46,6 +49,9 @@ runs:
with:
node-version-file: package.json
cache: pnpm
+ cache-dependency-path: |
+ pnpm-lock.yaml
+ mobile/pnpm-lock.yaml
- name: Setup requested Node.js
id: requested-node
@@ -54,6 +60,9 @@ runs:
with:
node-version: ${{ inputs.node-version }}
cache: pnpm
+ cache-dependency-path: |
+ pnpm-lock.yaml
+ mobile/pnpm-lock.yaml
- name: Validate native runtime
shell: bash
diff --git a/.github/workflows/pr.yml b/.github/workflows/pr.yml
index bde0b5e05d8..d21b1a23784 100644
--- a/.github/workflows/pr.yml
+++ b/.github/workflows/pr.yml
@@ -28,6 +28,7 @@ jobs:
outputs:
should_run: ${{ steps.filter.outputs.should_run }}
native_cache_changed: ${{ steps.filter.outputs.native_cache_changed }}
+ mobile_dependencies: ${{ steps.filter.outputs.mobile_dependencies }}
static_analysis: ${{ steps.filter.outputs.static_analysis }}
typecheck: ${{ steps.filter.outputs.typecheck }}
git_compatibility: ${{ steps.filter.outputs.git_compatibility }}
@@ -95,6 +96,25 @@ jobs:
- name: Enforce type-aware code-quality baseline
run: pnpm run audit:code-quality:type-aware
+ # Why: the changed-code gate lints mobile files too, and its type-aware pass
+ # resolves types from mobile/node_modules. Mobile is a separate pnpm project,
+ # so the root install above leaves it empty and every mobile type degrades to
+ # an `error` type — reported as phantom findings against the changed lines.
+ # Why no --ignore-scripts, unlike the root install: mobile's postinstall generates
+ # the gitignored terminal/mermaid webview engine modules that tracked source imports,
+ # and skipping it degrades those very types the step exists to resolve. The drift
+ # guard mirrors the root install so a stale mobile lockfile fails by name — mobile's
+ # lockfile carries patchedDependencies that a silent rewrite would drop.
+ - name: Install mobile dependencies
+ if: needs.code_paths.outputs.mobile_dependencies == 'true'
+ working-directory: mobile
+ run: |
+ pnpm install --frozen-lockfile
+ if [ "$(git -C "$GITHUB_WORKSPACE" rev-parse --is-inside-work-tree 2>/dev/null)" = true ]; then
+ git -C "$GITHUB_WORKSPACE" diff --exit-code -- \
+ mobile/package.json mobile/pnpm-lock.yaml mobile/pnpm-workspace.yaml
+ fi
+
- name: Enforce changed-code quality
run: pnpm run check:code-quality:changed -- "${{ github.event.pull_request.base.sha }}"
diff --git a/config/scripts/pr-code-change-scope.mjs b/config/scripts/pr-code-change-scope.mjs
index 8bc10fc5b72..7111531c35c 100644
--- a/config/scripts/pr-code-change-scope.mjs
+++ b/config/scripts/pr-code-change-scope.mjs
@@ -263,6 +263,14 @@ export function shouldRunPrChecks(changedFiles) {
return changedFiles.some((file) => !isDocsOnlyPath(file) && !isDesktopIrrelevantPath(file))
}
+export function needsMobileDependencies(changedFiles) {
+ // Why: static analysis lints CHANGED files, mobile ones included, and its
+ // type-aware pass resolves types from mobile/node_modules. Mobile is a
+ // separate pnpm project, so without this the root-only install leaves every
+ // mobile type an `error` type and the gate reports phantom findings.
+ return changedFiles.length === 0 || changedFiles.some((file) => file.startsWith('mobile/'))
+}
+
export function classifyPrJobs(changedFiles) {
const emptyDiff = changedFiles.length === 0
const shouldRun = shouldRunPrChecks(changedFiles)
@@ -276,6 +284,7 @@ export function classifyPrJobs(changedFiles) {
return {
should_run: shouldRun,
native_cache_changed: shouldRun && (emptyDiff || changedFiles.some(isNativeCacheInputPath)),
+ mobile_dependencies: shouldRun && needsMobileDependencies(changedFiles),
...jobs
}
}
diff --git a/config/scripts/pr-code-change-scope.test.mjs b/config/scripts/pr-code-change-scope.test.mjs
index 1fe296af265..4642372135c 100644
--- a/config/scripts/pr-code-change-scope.test.mjs
+++ b/config/scripts/pr-code-change-scope.test.mjs
@@ -316,6 +316,24 @@ describe('per-job path classification', () => {
}
})
+ // Why: static analysis lints changed mobile files with a type-aware pass, and
+ // mobile is a separate pnpm project. Without its node_modules every mobile type
+ // resolves to an `error` type and the changed-code gate fails on phantom
+ // findings, which is exactly how a react-test-renderer union broke a PR.
+ it('installs mobile dependencies exactly when mobile files change', () => {
+ expect(classifyPrJobs([]).mobile_dependencies).toBe(true)
+ expect(classifyPrJobs(['README.md']).mobile_dependencies).toBe(false)
+ expect(classifyPrJobs(['src/main/index.ts']).mobile_dependencies).toBe(false)
+ expect(
+ classifyPrJobs(['src/main/index.ts', 'mobile/src/session/a.test.ts']).mobile_dependencies
+ ).toBe(true)
+ // Why false: a mobile-only diff skips every desktop job, so the install step's own
+ // job never runs and claiming the install is needed contradicts should_run.
+ expect(classifyPrJobs(['mobile/package.json']).mobile_dependencies).toBe(false)
+ expect(classifyPrJobs(['mobile/package.json']).should_run).toBe(false)
+ expect(classifyPrJobs(['README.md', 'mobile/src/a.ts']).mobile_dependencies).toBe(false)
+ })
+
it('keeps unit-test-only diffs out of packaging', () => {
expectClassification(['src/main/git/git-status.test.ts'], {
git_compatibility: true
@@ -354,6 +372,20 @@ describe('PR Checks skip wiring', () => {
}
})
+ it('gives static analysis the mobile types its type-aware pass resolves', () => {
+ expect(prWorkflow.jobs.code_paths.outputs.mobile_dependencies).toBe(
+ '${{ steps.filter.outputs.mobile_dependencies }}'
+ )
+ const steps = prWorkflow.jobs.static_analysis.steps
+ const install = steps.findIndex((step) => step.name === 'Install mobile dependencies')
+ const gate = steps.findIndex((step) => step.name === 'Enforce changed-code quality')
+ expect(install).toBeGreaterThan(-1)
+ expect(install).toBeLessThan(gate)
+ expect(steps[install].if).toBe("needs.code_paths.outputs.mobile_dependencies == 'true'")
+ expect(steps[install]['working-directory']).toBe('mobile')
+ expect(steps[install].run).toContain('--frozen-lockfile')
+ })
+
it('keeps the cheap root-directory guard on docs-only PRs', () => {
expect(prWorkflow.jobs.root_directory_guard.if).toBeUndefined()
expect(prWorkflow.jobs.root_directory_guard.needs).toBeUndefined()
diff --git a/mobile/src/session/MobileNativeChatQuestion.tsx b/mobile/src/session/MobileNativeChatQuestion.tsx
index f470214dbed..f4a34494328 100644
--- a/mobile/src/session/MobileNativeChatQuestion.tsx
+++ b/mobile/src/session/MobileNativeChatQuestion.tsx
@@ -2,7 +2,11 @@ import { useMemo, useRef, useState } from 'react'
import { Pressable, StyleSheet, Text, TextInput, View } from 'react-native'
import { ArrowUp, Check, CircleHelp } from 'lucide-react-native'
import { colors, radii, spacing, typography } from '../theme/mobile-theme'
-import { formatQuestionAnswer, type MobileChatQuestion } from './mobile-native-chat-question'
+import {
+ formatQuestionAnswer,
+ formatQuestionFreeTextAnswer,
+ type MobileChatQuestion
+} from './mobile-native-chat-question'
type Props = {
question: MobileChatQuestion
@@ -18,6 +22,7 @@ export function MobileNativeChatQuestion({ question, onAnswer }: Props): React.J
const [freeText, setFreeText] = useState('')
const [sending, setSending] = useState(false)
const sendingRef = useRef(false)
+ const allowOther = question.allowOther !== false
const hasOptions = question.options.length > 0
const trimmedFreeText = freeText.trim()
@@ -42,8 +47,9 @@ export function MobileNativeChatQuestion({ question, onAnswer }: Props): React.J
}
}
- const answerSingle = async (option: string): Promise => {
- await sendAnswer(formatQuestionAnswer(question, [option]))
+ const answerSingle = async (option: string, optionIndex: number): Promise => {
+ const token = question.optionTokens[optionIndex]
+ await sendAnswer(token && token.length > 0 ? token : formatQuestionAnswer(question, [option]))
}
const submitMulti = async (): Promise => {
@@ -57,14 +63,13 @@ export function MobileNativeChatQuestion({ question, onAnswer }: Props): React.J
if (trimmedFreeText.length === 0) {
return
}
- // Free text is an unknown entry; formatQuestionAnswer passes it through.
- if (await sendAnswer(formatQuestionAnswer(question, [trimmedFreeText]))) {
+ if (await sendAnswer(formatQuestionFreeTextAnswer(question, trimmedFreeText))) {
setFreeText('')
}
}
const canSubmitMulti = selected.length > 0 && !sending
- const canSendFreeText = trimmedFreeText.length > 0 && !sending
+ const canSendFreeText = allowOther && trimmedFreeText.length > 0 && !sending
// Stable keys for option rows even if an agent repeats a label.
const optionRows = useMemo(
@@ -81,7 +86,7 @@ export function MobileNativeChatQuestion({ question, onAnswer }: Props): React.J
{hasOptions ? (
- {optionRows.map(({ label, key }) => {
+ {optionRows.map(({ label, key }, optIndex) => {
const isSelected = selected.includes(label)
return (
(question.multiSelect ? toggle(label) : answerSingle(label))}
+ onPress={() =>
+ question.multiSelect ? toggle(label) : answerSingle(label, optIndex)
+ }
>
{question.multiSelect ? (
@@ -124,35 +131,37 @@ export function MobileNativeChatQuestion({ question, onAnswer }: Props): React.J
) : null}
-
-
- [
- styles.freeSend,
- !canSendFreeText && styles.freeSendDisabled,
- pressed && canSendFreeText && styles.pressed
- ]}
- onPress={submitFreeText}
- disabled={!canSendFreeText}
- >
-
+
-
-
+ [
+ styles.freeSend,
+ !canSendFreeText && styles.freeSendDisabled,
+ pressed && canSendFreeText && styles.pressed
+ ]}
+ onPress={submitFreeText}
+ disabled={!canSendFreeText}
+ >
+
+
+
+ ) : null}
)
}
diff --git a/mobile/src/session/MobileSessionActiveContent.tsx b/mobile/src/session/MobileSessionActiveContent.tsx
index 7dd09977c45..019e83c6a99 100644
--- a/mobile/src/session/MobileSessionActiveContent.tsx
+++ b/mobile/src/session/MobileSessionActiveContent.tsx
@@ -38,7 +38,7 @@ export function MobileSessionActiveContent({
browserScreencastSupported,
showToast,
nativeChatSendError,
- nativeChatInputLockReason,
+ nativeChatOverlayInputLockReason,
nativeChatController,
dictation,
handleDictationToggle,
@@ -240,7 +240,7 @@ export function MobileSessionActiveContent({
dictationMode={dictationMode}
onMicPressIn={handleDictationPressIn}
onMicPressOut={handleDictationPressOut}
- inputLockReason={nativeChatInputLockReason}
+ inputLockReason={nativeChatOverlayInputLockReason}
sendErrorMessage={nativeChatSendError.message}
onClearSendError={nativeChatSendError.clear}
sendSurfaceId={controller.nativeChatScopeKey ?? ''}
diff --git a/mobile/src/session/MobileSessionHeader.tsx b/mobile/src/session/MobileSessionHeader.tsx
index 1ddc7cb1d83..552f507a787 100644
--- a/mobile/src/session/MobileSessionHeader.tsx
+++ b/mobile/src/session/MobileSessionHeader.tsx
@@ -168,6 +168,7 @@ export function MobileSessionHeader({ controller }: { controller: MobileSessionC
{t.type === 'file' && (
)}
+ {t.type === 'agent-session' && }
{t.type === 'terminal' &&
(() => {
const agentId = resolveMobileTerminalTabAgentId(t)
diff --git a/mobile/src/session/MobileSessionSheets.tsx b/mobile/src/session/MobileSessionSheets.tsx
index 48dcabed23c..0aac2bb9d42 100644
--- a/mobile/src/session/MobileSessionSheets.tsx
+++ b/mobile/src/session/MobileSessionSheets.tsx
@@ -43,6 +43,8 @@ export function MobileSessionSheets({ controller }: { controller: MobileSessionC
setFileActionTarget,
browserActionTarget,
setBrowserActionTarget,
+ agentSessionActionTarget,
+ setAgentSessionActionTarget,
discardMarkdownTarget,
setDiscardMarkdownTarget,
leaveDrafts,
@@ -261,6 +263,14 @@ export function MobileSessionSheets({ controller }: { controller: MobileSessionC
onCloseTab={handleCloseSessionTab}
bulkCloseActions={bulkCloseActions}
/>
+
+ setAgentSessionActionTarget(null)
+ )}
+ onClose={() => setAgentSessionActionTarget(null)}
+ />
= {
activated: boolean
activationSeq: number
latestActivationSeq: number
- sourceTerminalHandle: string
+ sourceTerminalHandle: string | null
activeTerminalHandle: string | null
+ sourceSessionTabId?: string | null
+ activeSessionTabId?: string | null
activeTabType: string | null
}
switchSessionTab: (tab: T) => void
diff --git a/mobile/src/session/mobile-native-chat-controller-contract.ts b/mobile/src/session/mobile-native-chat-controller-contract.ts
index 2283256da05..890a3a1562e 100644
--- a/mobile/src/session/mobile-native-chat-controller-contract.ts
+++ b/mobile/src/session/mobile-native-chat-controller-contract.ts
@@ -58,7 +58,12 @@ export type MobileNativeChatController = {
handleNativeChatSendWithOutcome: (
text: string,
images?: string[],
- deadline?: number
+ deadline?: number,
+ attachments?: readonly {
+ id?: string
+ path: string
+ previewUri: string
+ }[]
) => Promise
/** Launch-context text still parked on the agent's TUI input line, or null.
* Image sends read it to size their leading clear (one Ctrl+U per line). */
diff --git a/mobile/src/session/mobile-native-chat-eligibility.test.ts b/mobile/src/session/mobile-native-chat-eligibility.test.ts
index 7daa4babea6..e1bd97cad8f 100644
--- a/mobile/src/session/mobile-native-chat-eligibility.test.ts
+++ b/mobile/src/session/mobile-native-chat-eligibility.test.ts
@@ -123,6 +123,30 @@ describe('resolveMobileNativeChat', () => {
expect(resolveMobileNativeChat({ type: 'browser', launchAgent: 'claude' })).toBeNull()
})
+ it('resolves Codex structured agent-session tabs directly', () => {
+ expect(
+ resolveMobileNativeChat({
+ type: 'agent-session',
+ sessionId: 'structured-1',
+ agent: 'codex'
+ })
+ ).toEqual({
+ agent: 'codex',
+ sessionId: 'structured-1',
+ transcriptPath: null
+ })
+ })
+
+ it('rejects non-Codex structured agent-session tabs', () => {
+ expect(
+ resolveMobileNativeChat({
+ type: 'agent-session',
+ sessionId: 'structured-1',
+ agent: 'claude'
+ } as never)
+ ).toBeNull()
+ })
+
it('canShowMobileNativeChat mirrors resolution', () => {
expect(canShowMobileNativeChat({ type: 'terminal', launchAgent: 'claude' })).toBe(true)
expect(canShowMobileNativeChat(null)).toBe(false)
diff --git a/mobile/src/session/mobile-native-chat-eligibility.ts b/mobile/src/session/mobile-native-chat-eligibility.ts
index abda64b04ab..a3f66eb14aa 100644
--- a/mobile/src/session/mobile-native-chat-eligibility.ts
+++ b/mobile/src/session/mobile-native-chat-eligibility.ts
@@ -32,6 +32,8 @@ export type MobileNativeChatTab = {
/** Host-provided launch context still parked as an unsent TUI-input draft. */
launchDraft?: string
launchDraftCreatedAt?: number
+ sessionId?: string | null
+ agent?: string | null
}
/** Resolve a session tab to the transcript identity native chat needs, or
@@ -42,7 +44,15 @@ export function resolveMobileNativeChat(
tab: MobileNativeChatTab | null,
nativeChatTranscriptIsLocalReadable = false
): MobileNativeChatResolution | null {
- if (!tab || tab.type !== 'terminal') {
+ if (!tab) {
+ return null
+ }
+ if (tab.type === 'agent-session') {
+ return tab.sessionId && tab.agent === 'codex'
+ ? { agent: tab.agent, sessionId: tab.sessionId, transcriptPath: null }
+ : null
+ }
+ if (tab.type !== 'terminal') {
return null
}
const liveAgent = tab.agentStatus?.agentType ?? null
@@ -71,3 +81,15 @@ export function canShowMobileNativeChat(
): boolean {
return resolveMobileNativeChat(tab, nativeChatTranscriptIsLocalReadable) !== null
}
+
+export function resolveMobileNativeChatFileSessionId(
+ tab: MobileNativeChatTab | null
+): string | null {
+ if (tab?.type === 'agent-session') {
+ return tab.sessionId ?? null
+ }
+ if (tab?.type === 'terminal') {
+ return tab.agentStatus?.providerSession?.id ?? null
+ }
+ return null
+}
diff --git a/mobile/src/session/mobile-native-chat-image-scope-state.ts b/mobile/src/session/mobile-native-chat-image-scope-state.ts
new file mode 100644
index 00000000000..8d7de510e3a
--- /dev/null
+++ b/mobile/src/session/mobile-native-chat-image-scope-state.ts
@@ -0,0 +1,18 @@
+import type { PendingNativeChatImage } from './mobile-native-chat-image-attachment'
+
+export const NO_NATIVE_CHAT_IMAGE_ATTACHMENTS: PendingNativeChatImage[] = []
+
+export type MobileNativeChatImagesByScope = Record
+
+export function withScopeAttachments(
+ byScope: MobileNativeChatImagesByScope,
+ scope: string,
+ next: PendingNativeChatImage[]
+): MobileNativeChatImagesByScope {
+ if (next.length > 0) {
+ return { ...byScope, [scope]: next }
+ }
+ const remaining = { ...byScope }
+ delete remaining[scope]
+ return remaining
+}
diff --git a/mobile/src/session/mobile-native-chat-question.test.ts b/mobile/src/session/mobile-native-chat-question.test.ts
index 94fbcf055a9..079e661e545 100644
--- a/mobile/src/session/mobile-native-chat-question.test.ts
+++ b/mobile/src/session/mobile-native-chat-question.test.ts
@@ -1,6 +1,7 @@
import { describe, expect, it } from 'vitest'
import {
formatQuestionAnswer,
+ formatQuestionFreeTextAnswer,
mobileChatQuestionKey,
parseAgentQuestion,
type MobileChatQuestion
@@ -141,6 +142,12 @@ describe('formatQuestionAnswer', () => {
expect(formatQuestionAnswer(numbered, [])).toBe('')
expect(formatQuestionAnswer(numbered, [' '])).toBe('')
})
+
+ it('prefixes free-text answers with an opaque prompt token when provided', () => {
+ expect(
+ formatQuestionFreeTextAnswer({ ...numbered, freeTextToken: 'target' }, ' hi there ')
+ ).toBe(`target:${encodeURIComponent('hi there')}`)
+ })
})
describe('mobileChatQuestionKey', () => {
@@ -154,5 +161,8 @@ describe('mobileChatQuestionKey', () => {
expect(mobileChatQuestionKey({ ...first, options: ['A', 'C'] })).not.toBe(
mobileChatQuestionKey(first)
)
+ expect(mobileChatQuestionKey({ ...first, freeTextToken: 'target-2' })).not.toBe(
+ mobileChatQuestionKey(first)
+ )
})
})
diff --git a/mobile/src/session/mobile-native-chat-question.ts b/mobile/src/session/mobile-native-chat-question.ts
index 8a1f06dfbf7..5d4e65a46ff 100644
--- a/mobile/src/session/mobile-native-chat-question.ts
+++ b/mobile/src/session/mobile-native-chat-question.ts
@@ -7,10 +7,14 @@ export type MobileChatQuestion = {
question: string
options: string[]
multiSelect: boolean
+ /** Structured questions hide the free-text row when the provider does not accept it. */
+ allowOther?: boolean
/** Per-option leading marker ("1", "b", …) when the source line carried one,
* parallel to `options`. Null where the option was a plain bullet. Used to
* echo the exact choice the agent listed back to the terminal. */
optionTokens: (string | null)[]
+ /** Opaque prefix used when free-text answers must target a specific prompt. */
+ freeTextToken?: string
}
export function mobileChatQuestionKey(question: MobileChatQuestion): string {
@@ -152,3 +156,13 @@ export function formatQuestionAnswer(question: MobileChatQuestion, selected: str
return parts.join(question.multiSelect ? ', ' : ' ')
}
+
+export function formatQuestionFreeTextAnswer(question: MobileChatQuestion, text: string): string {
+ const trimmed = text.trim()
+ if (trimmed.length === 0) {
+ return ''
+ }
+ return question.freeTextToken
+ ? `${question.freeTextToken}:${encodeURIComponent(trimmed)}`
+ : formatQuestionAnswer(question, [trimmed])
+}
diff --git a/mobile/src/session/mobile-session-route-parity.test.ts b/mobile/src/session/mobile-session-route-parity.test.ts
index 5134cd373d4..b6abab8001e 100644
--- a/mobile/src/session/mobile-session-route-parity.test.ts
+++ b/mobile/src/session/mobile-session-route-parity.test.ts
@@ -62,15 +62,15 @@ const HOST_COMPONENT_NAMES = new Set([
'View'
])
-const HEAD_MAIN_HOOK_SHA256 = '5c475b904928f418c76a7885afdbed7adbfea3fe3ea05e85d956dc22f958a302'
-const HEAD_HOOK_BINDING_SHA256 = '028f99dd14fea2110cff446418ee71513aeed38484c2dcea68bf0da8eff377c0'
+const HEAD_MAIN_HOOK_SHA256 = '10071240ef9edafc2b9c8bed73be83dceaf7828e3b29f17dab55da020a7697a6'
+const HEAD_HOOK_BINDING_SHA256 = 'ecd4c1dad066cf13698447b8ffb61f82e6cc3ebe7d484f71189626efed430272'
const HEAD_CALLBACK_IDENTITY_SHA256 =
- 'd60ffe53f8d77f2dd3ebd14a5de162bb399113c170b59bdc917de6318ec433ec'
-const HEAD_CALLBACK_BODY_SHA256 = '69dfda53fd700f4395a18a37ffdaa530e187bc24b4986d8fdc0184127c00b52d'
+ 'df073bc13d94a93e7fbd8b1fca2b57eaf43cbf7ca799a649e0ebb783e5b8eecc'
+const HEAD_CALLBACK_BODY_SHA256 = '690e3069e08ecf805af726b658e900c973565259160f25e3a643175e2ab1bc75'
const HEAD_EFFECT_SHA256 = '346d384ea0bf2f8f926c5092c5bf57bc2a03494f49f9639e9d6b8a2c51c9f882'
const HEAD_CONTENT_HOOK_SHA256 = '9c3b612fef3f370d66873aefdbe1d701f20cb64ded31fef5cc45fde6f8189581'
const HEAD_NESTED_FUNCTION_SHA256 =
- 'b562c117eb1e4532dd656d8bdd3ca3bc58ce65d78a7ed740dbd866a48d4d8dbe'
+ '6a13919ede2a8033436fb03e0ff7c426fbed97f470875a7b21b00aaada17fb73'
const HEAD_NATIVE_REGISTRATION_SHA256 =
'cab85e4e4a3f43289ba93ddea9ccce57aea83e0bf14fd1620a965aad0c1cb49e'
const HEAD_NATIVE_REMOVAL_SHA256 =
@@ -79,9 +79,9 @@ const HEAD_TIMER_CREATION_SHA256 =
'1a31b625e2174c3db77272249843196d2b6b06ab1e654a96d8f7858e3082e66b'
const HEAD_TIMER_CLEANUP_SHA256 = 'c73f1d1c2cc89642f3d727d6f3b6b81860a9d6f34234541a2065ec3d1a8cd116'
const HEAD_RUNTIME_STRING_SHA256 =
- 'ad0def23206f08d0523c155fe730e86824876e67cf1db6b597541b9c35b54447'
+ '1cb95fe0095c1c57e1b0629472e1cce5328eb7f5bfeca38095f41f4612a37887'
const HEAD_HOST_JSX_SHA256 = '390405926b1695fa3a33686f0bc192b432f5468d8576499d7cafbb4922defbb5'
-const HEAD_LEAF_JSX_SHA256 = 'b070e25c47b3e298be02a4ffe1572b36e204446fc161bad894690e9939403f54'
+const HEAD_LEAF_JSX_SHA256 = '21dba981875e173f692590bf910d60964660c5f4cbb79f3a377c7e54f6a1f016'
const HEAD_STYLE_REFERENCE_SHA256 =
'295a3501c2c6d7bea7c8bbf38b3f3534f01344cd7e1b91bb8e07c040821d596a'
const HEAD_IDENTITY_FIELD_SHA256 =
@@ -472,10 +472,10 @@ describe('mobile session route extraction parity', () => {
const contentBindings = CONTENT_COMPONENT_NAMES.flatMap(
(name) => readHookFacts(name, definitions).bindings
)
- expect(main.hooks).toHaveLength(269)
+ expect(main.hooks).toHaveLength(266)
expect(hash(main.hooks)).toBe(HEAD_MAIN_HOOK_SHA256)
expect(hash(main.bindings)).toBe(HEAD_HOOK_BINDING_SHA256)
- expect(main.callbacks).toHaveLength(78)
+ expect(main.callbacks).toHaveLength(77)
expect(hash(main.callbacks)).toBe(HEAD_CALLBACK_IDENTITY_SHA256)
expect(hash(main.callbackBodies)).toBe(HEAD_CALLBACK_BODY_SHA256)
expect(main.effects).toHaveLength(24)
@@ -517,12 +517,12 @@ describe('mobile session route extraction parity', () => {
it('preserves runtime strings, styles, and the expanded JSX tree', () => {
const strings = readRuntimeStrings()
- expect(strings).toHaveLength(537)
+ expect(strings).toHaveLength(545)
expect(hash(strings)).toBe(HEAD_RUNTIME_STRING_SHA256)
const jsx = readJsxFacts(readDefinitions())
expect(jsx.host).toHaveLength(124)
expect(hash(jsx.host)).toBe(HEAD_HOST_JSX_SHA256)
- expect(jsx.leaf).toHaveLength(59)
+ expect(jsx.leaf).toHaveLength(61)
expect(hash(jsx.leaf)).toBe(HEAD_LEAF_JSX_SHA256)
expect(jsx.styleReferences).toHaveLength(172)
expect(hash(jsx.styleReferences)).toBe(HEAD_STYLE_REFERENCE_SHA256)
diff --git a/mobile/src/session/mobile-session-route-types.ts b/mobile/src/session/mobile-session-route-types.ts
index a61b653a0e3..36c90b0a29d 100644
--- a/mobile/src/session/mobile-session-route-types.ts
+++ b/mobile/src/session/mobile-session-route-types.ts
@@ -9,7 +9,7 @@ import type { TerminalRecord } from './mobile-terminal-records'
export type Terminal = TerminalRecord
-export type MobileSessionTabType = 'terminal' | 'markdown' | 'file' | 'browser'
+export type MobileSessionTabType = 'terminal' | 'markdown' | 'file' | 'browser' | 'agent-session'
export type MobileSessionTab =
| {
@@ -30,6 +30,14 @@ export type MobileSessionTab =
terminalTheme?: MobileTerminalTheme
isActive: boolean
}
+ | {
+ type: 'agent-session'
+ id: string
+ title: string
+ sessionId: string
+ agent: 'codex'
+ isActive: boolean
+ }
| {
type: 'markdown'
id: string
diff --git a/mobile/src/session/mobile-structured-agent-prompts.ts b/mobile/src/session/mobile-structured-agent-prompts.ts
new file mode 100644
index 00000000000..84cb7033d30
--- /dev/null
+++ b/mobile/src/session/mobile-structured-agent-prompts.ts
@@ -0,0 +1,251 @@
+import type { AgentJournalRenderItem } from '../../../src/shared/agent-session-journal-types'
+import type { MobileChatPermission } from './mobile-native-chat-permission'
+import type { MobileChatQuestion } from './mobile-native-chat-question'
+
+export type StructuredApprovalItem = AgentJournalRenderItem & {
+ body: Extract
+}
+
+export type StructuredQuestionItem = AgentJournalRenderItem & {
+ body: Extract
+}
+
+export type StructuredPromptResponseTarget = {
+ itemId: string
+ expectedRevision: number
+ optionId: string
+}
+
+type PromptTokenPayload =
+ | {
+ kind: 'approval'
+ itemId: string
+ revision: number
+ optionId: string
+ }
+ | {
+ kind: 'question-option'
+ itemId: string
+ revision: number
+ optionId: string
+ }
+ | {
+ kind: 'question-free-text'
+ itemId: string
+ revision: number
+ questionId: string
+ }
+
+const STRUCTURED_PROMPT_TOKEN_PREFIX = 'structured-agent-prompt:'
+
+export function pendingStructuredApproval(
+ item: AgentJournalRenderItem
+): item is StructuredApprovalItem {
+ return item.body.kind === 'approval' && item.body.resolution.state === 'pending'
+}
+
+export function pendingStructuredQuestion(
+ item: AgentJournalRenderItem
+): item is StructuredQuestionItem {
+ return item.body.kind === 'question' && item.body.resolution.state === 'pending'
+}
+
+function encodeQuestionAnswer(questionId: string, answer: string): string {
+ return `${encodeURIComponent(questionId)}:${encodeURIComponent(answer)}`
+}
+
+function encodePromptToken(payload: PromptTokenPayload): string {
+ return `${STRUCTURED_PROMPT_TOKEN_PREFIX}${encodeURIComponent(JSON.stringify(payload))}`
+}
+
+function decodePromptToken(value: string): PromptTokenPayload | null {
+ if (!value.startsWith(STRUCTURED_PROMPT_TOKEN_PREFIX)) {
+ return null
+ }
+ try {
+ const decoded = JSON.parse(
+ decodeURIComponent(value.slice(STRUCTURED_PROMPT_TOKEN_PREFIX.length))
+ ) as Record
+ if (
+ typeof decoded.itemId !== 'string' ||
+ typeof decoded.revision !== 'number' ||
+ !Number.isFinite(decoded.revision)
+ ) {
+ return null
+ }
+ if (decoded.kind === 'approval' && typeof decoded.optionId === 'string') {
+ return {
+ kind: decoded.kind,
+ itemId: decoded.itemId,
+ revision: decoded.revision,
+ optionId: decoded.optionId
+ }
+ }
+ if (decoded.kind === 'question-option' && typeof decoded.optionId === 'string') {
+ return {
+ kind: decoded.kind,
+ itemId: decoded.itemId,
+ revision: decoded.revision,
+ optionId: decoded.optionId
+ }
+ }
+ if (decoded.kind === 'question-free-text' && typeof decoded.questionId === 'string') {
+ return {
+ kind: decoded.kind,
+ itemId: decoded.itemId,
+ revision: decoded.revision,
+ questionId: decoded.questionId
+ }
+ }
+ } catch {
+ return null
+ }
+ return null
+}
+
+function decodeQuestionFreeTextAnswer(value: string): {
+ payload: Extract
+ answer: string
+} | null {
+ if (!value.startsWith(STRUCTURED_PROMPT_TOKEN_PREFIX)) {
+ return null
+ }
+ const separator = value.indexOf(':', STRUCTURED_PROMPT_TOKEN_PREFIX.length)
+ if (separator === -1) {
+ return null
+ }
+ const payload = decodePromptToken(value.slice(0, separator))
+ if (payload?.kind !== 'question-free-text') {
+ return null
+ }
+ return { payload, answer: decodeURIComponent(value.slice(separator + 1)) }
+}
+
+export function projectStructuredPermission(
+ prompt: StructuredApprovalItem | null
+): MobileChatPermission | null {
+ if (prompt?.body.kind !== 'approval') {
+ return null
+ }
+ return {
+ title: prompt.body.title,
+ ...(prompt.body.detail ? { detail: prompt.body.detail } : {}),
+ options: prompt.body.options.map((option) => ({
+ label: option.label,
+ send: encodePromptToken({
+ kind: 'approval',
+ itemId: prompt.itemId,
+ revision: prompt.revision,
+ optionId: option.id
+ })
+ }))
+ }
+}
+
+export function projectStructuredQuestion(
+ prompt: StructuredQuestionItem | null
+): MobileChatQuestion | null {
+ if (prompt?.body.kind !== 'question') {
+ return null
+ }
+ return {
+ question: prompt.body.question,
+ options: prompt.body.options.map((option) => option.label),
+ multiSelect: false,
+ allowOther: Boolean(prompt.body.freeTextQuestionId),
+ optionTokens: prompt.body.options.map((option) =>
+ encodePromptToken({
+ kind: 'question-option',
+ itemId: prompt.itemId,
+ revision: prompt.revision,
+ optionId: option.id
+ })
+ ),
+ ...(prompt.body.freeTextQuestionId
+ ? {
+ freeTextToken: encodePromptToken({
+ kind: 'question-free-text',
+ itemId: prompt.itemId,
+ revision: prompt.revision,
+ questionId: prompt.body.freeTextQuestionId
+ })
+ }
+ : {})
+ }
+}
+
+export function structuredApprovalResponseTarget(
+ response: string,
+ currentPrompt: StructuredApprovalItem | null
+): StructuredPromptResponseTarget | null {
+ const token = decodePromptToken(response)
+ if (token?.kind === 'approval') {
+ return {
+ itemId: token.itemId,
+ expectedRevision: token.revision,
+ optionId: token.optionId
+ }
+ }
+ if (token) {
+ return null
+ }
+ const option = currentPrompt?.body.options.find(
+ (candidate) => candidate.id === response || candidate.label === response
+ )
+ return currentPrompt && option
+ ? {
+ itemId: currentPrompt.itemId,
+ expectedRevision: currentPrompt.revision,
+ optionId: option.id
+ }
+ : null
+}
+
+export function structuredQuestionResponseTarget(
+ response: string,
+ currentPrompt: StructuredQuestionItem | null
+): StructuredPromptResponseTarget | null {
+ const token = decodePromptToken(response)
+ if (token?.kind === 'question-option') {
+ return {
+ itemId: token.itemId,
+ expectedRevision: token.revision,
+ optionId: token.optionId
+ }
+ }
+ if (token) {
+ return null
+ }
+ const freeText = decodeQuestionFreeTextAnswer(response)
+ if (freeText) {
+ const answer = freeText.answer.trim()
+ return answer.length > 0
+ ? {
+ itemId: freeText.payload.itemId,
+ expectedRevision: freeText.payload.revision,
+ optionId: encodeQuestionAnswer(freeText.payload.questionId, answer)
+ }
+ : null
+ }
+ if (!currentPrompt) {
+ return null
+ }
+ const trimmed = response.trim()
+ const option = currentPrompt.body.options.find(
+ (candidate) => candidate.id === response || candidate.label === trimmed
+ )
+ if (option) {
+ return {
+ itemId: currentPrompt.itemId,
+ expectedRevision: currentPrompt.revision,
+ optionId: option.id
+ }
+ }
+ return currentPrompt.body.freeTextQuestionId && trimmed
+ ? {
+ itemId: currentPrompt.itemId,
+ expectedRevision: currentPrompt.revision,
+ optionId: encodeQuestionAnswer(currentPrompt.body.freeTextQuestionId, trimmed)
+ }
+ : null
+}
diff --git a/mobile/src/session/mobile-structured-agent-session-launch.test.ts b/mobile/src/session/mobile-structured-agent-session-launch.test.ts
new file mode 100644
index 00000000000..54f9b5cbe88
--- /dev/null
+++ b/mobile/src/session/mobile-structured-agent-session-launch.test.ts
@@ -0,0 +1,142 @@
+import { describe, expect, it, vi } from 'vitest'
+import type { RpcClient } from '../transport/rpc-client'
+import { markRpcDeliveryUnknown } from '../transport/rpc-delivery-ambiguity'
+import { createMobileStructuredCodexSession } from './mobile-structured-agent-session-launch'
+
+function clientReturning(
+ ...responses: unknown[]
+): RpcClient & { sendRequest: ReturnType } {
+ let responseIndex = 0
+ const sendRequest = vi.fn(async () => responses[responseIndex++])
+ return { sendRequest } as unknown as RpcClient & { sendRequest: ReturnType }
+}
+
+const acceptedCreateResult = {
+ ok: true,
+ replayed: false,
+ fence: 1,
+ cursor: { epoch: 'epoch-1', sequence: 0 },
+ value: {
+ sessionId: 'codex_session_1',
+ fence: 1,
+ page: {
+ sessionId: 'codex_session_1',
+ epoch: 'epoch-1',
+ direction: 'tail',
+ items: [],
+ removedItemIds: [],
+ submissions: [],
+ window: { oldest: null, newest: null, nextCursor: { epoch: 'epoch-1', sequence: 0 } },
+ liveCursor: { epoch: 'epoch-1', sequence: 0 },
+ hasOlder: false,
+ hasNewer: false
+ },
+ unconfirmedClientMessageIds: []
+ }
+}
+const acceptedCreate = { ok: true, result: acceptedCreateResult }
+
+describe('mobile structured Codex launch', () => {
+ it('creates through the structured agent-session intent after support is confirmed', async () => {
+ const client = clientReturning({ ok: true, result: { supported: true } }, acceptedCreate)
+
+ await expect(createMobileStructuredCodexSession(client, 'workspace-1')).resolves.toMatchObject({
+ kind: 'created',
+ sessionId: expect.stringMatching(/^codex_[A-Za-z0-9_]{8,128}$/)
+ })
+ expect(client.sendRequest).toHaveBeenNthCalledWith(1, 'agentSession.createSupport', {
+ worktree: 'id:workspace-1',
+ agent: 'codex'
+ })
+ expect(client.sendRequest).toHaveBeenNthCalledWith(
+ 2,
+ 'agentSession.create',
+ expect.objectContaining({
+ worktree: 'id:workspace-1',
+ agent: 'codex',
+ envelope: expect.objectContaining({ expectedRuntimeFence: null })
+ }),
+ expect.objectContaining({ budgetSpansConnect: true })
+ )
+ const params = client.sendRequest.mock.calls[1]?.[1] as {
+ envelope: { sessionId: string; payloadFingerprint: string }
+ worktree: string
+ agent: 'codex'
+ }
+ expect(params.envelope.payloadFingerprint).toMatch(/^[0-9a-f]{64}$/)
+ expect(params.envelope.sessionId).toMatch(/^codex_[A-Za-z0-9_]{8,128}$/)
+ })
+
+ it('reports unsupported without creating a terminal when the structured path is unavailable', async () => {
+ const client = clientReturning({ ok: true, result: { supported: false, reason: 'remote' } })
+
+ await expect(createMobileStructuredCodexSession(client, 'workspace-1')).resolves.toEqual({
+ kind: 'unsupported',
+ reason: 'remote'
+ })
+ expect(client.sendRequest).toHaveBeenCalledTimes(1)
+ })
+
+ it('keeps an unknown create outcome distinct so callers do not create a duplicate terminal', async () => {
+ const client = clientReturning({ ok: true, result: { supported: true } })
+ client.sendRequest.mockImplementationOnce(async () => ({
+ ok: true,
+ result: { supported: true }
+ }))
+ client.sendRequest.mockRejectedValue(markRpcDeliveryUnknown(new Error('response lost')))
+
+ await expect(createMobileStructuredCodexSession(client, 'workspace-1')).resolves.toMatchObject({
+ kind: 'unknown'
+ })
+ expect(client.sendRequest.mock.calls.map(([method]) => method)).toEqual([
+ 'agentSession.createSupport',
+ 'agentSession.create',
+ 'agentSession.create'
+ ])
+ expect(client.sendRequest.mock.calls[1]?.[1]).toBe(client.sendRequest.mock.calls[2]?.[1])
+ })
+
+ it('keeps the outcome unknown when the idempotent retry cannot be sent', async () => {
+ const client = clientReturning({ ok: true, result: { supported: true } })
+ client.sendRequest.mockImplementationOnce(async () => ({
+ ok: true,
+ result: { supported: true }
+ }))
+ client.sendRequest.mockRejectedValueOnce(markRpcDeliveryUnknown(new Error('response lost')))
+ client.sendRequest.mockRejectedValueOnce(new Error('connection interrupted'))
+
+ await expect(createMobileStructuredCodexSession(client, 'workspace-1')).resolves.toMatchObject({
+ kind: 'unknown'
+ })
+ })
+
+ it('never creates a legacy sibling after an unclassified create exception', async () => {
+ const client = clientReturning({ ok: true, result: { supported: true } })
+ client.sendRequest.mockImplementationOnce(async () => ({
+ ok: true,
+ result: { supported: true }
+ }))
+ client.sendRequest.mockRejectedValue(new Error('internal error after commit'))
+
+ await expect(createMobileStructuredCodexSession(client, 'workspace-1')).resolves.toMatchObject({
+ kind: 'unknown'
+ })
+ expect(client.sendRequest.mock.calls.map(([method]) => method)).toEqual([
+ 'agentSession.createSupport',
+ 'agentSession.create',
+ 'agentSession.create'
+ ])
+ expect(client.sendRequest.mock.calls[1]?.[1]).toBe(client.sendRequest.mock.calls[2]?.[1])
+ })
+
+ it('treats malformed structured responses as unknown', async () => {
+ const client = clientReturning(
+ { ok: true, result: { supported: true } },
+ { ok: true, result: { ok: true, value: { sessionId: '' } } }
+ )
+
+ await expect(createMobileStructuredCodexSession(client, 'workspace-1')).resolves.toMatchObject({
+ kind: 'unknown'
+ })
+ })
+})
diff --git a/mobile/src/session/mobile-structured-agent-session-launch.ts b/mobile/src/session/mobile-structured-agent-session-launch.ts
new file mode 100644
index 00000000000..ecad0410dfd
--- /dev/null
+++ b/mobile/src/session/mobile-structured-agent-session-launch.ts
@@ -0,0 +1,158 @@
+import type {
+ AgentSessionAttachResult,
+ AgentSessionMutationResult
+} from '../../../src/shared/agent-session-wire'
+import { structuredAgentSessionPayloadFingerprint } from '../../../src/shared/structured-agent-session-mutation'
+import type { RpcClient } from '../transport/rpc-client'
+import { structuredSessionOperationId } from './mobile-structured-agent-session-rpc'
+
+type StructuredCreateSupport = {
+ supported?: boolean
+ reason?: 'agent' | 'remote' | 'wsl'
+}
+
+export type MobileStructuredCodexLaunchResult =
+ | { kind: 'created'; sessionId: string }
+ | { kind: 'unsupported'; reason?: StructuredCreateSupport['reason'] }
+ | { kind: 'failed'; message: string }
+ | { kind: 'unknown'; message: string }
+
+type StructuredCreateParams = {
+ envelope: {
+ sessionId: string
+ clientOperationId: string
+ expectedRuntimeFence: null
+ payloadFingerprint: string
+ }
+ worktree: string
+ agent: 'codex'
+}
+
+function createStructuredCodexSessionId(): string {
+ return `codex_${createRandomUuid().replaceAll('-', '_')}`
+}
+
+function createRandomUuid(): string {
+ if (typeof globalThis.crypto?.randomUUID === 'function') {
+ return globalThis.crypto.randomUUID()
+ }
+ return Array.from({ length: 32 }, () => Math.floor(Math.random() * 16).toString(16)).join('')
+}
+
+function createStructuredCodexSessionParams(worktreeId: string): StructuredCreateParams {
+ const sessionId = createStructuredCodexSessionId()
+ const worktree = `id:${worktreeId}`
+ const fields = { worktree, agent: 'codex' as const }
+ return {
+ envelope: {
+ sessionId,
+ clientOperationId: structuredSessionOperationId(),
+ expectedRuntimeFence: null,
+ payloadFingerprint: structuredAgentSessionPayloadFingerprint({
+ method: 'agentSession.create',
+ sessionId,
+ fields
+ })
+ },
+ ...fields
+ }
+}
+
+function unknownCreateResult(error: unknown): MobileStructuredCodexLaunchResult {
+ const message = error instanceof Error ? error.message.trim() : ''
+ return {
+ kind: 'unknown',
+ message: message || 'The Codex chat result could not be confirmed.'
+ }
+}
+
+export async function createMobileStructuredCodexSession(
+ client: RpcClient,
+ worktreeId: string
+): Promise {
+ const worktree = `id:${worktreeId}`
+ let supportResponse
+ try {
+ supportResponse = await client.sendRequest('agentSession.createSupport', {
+ worktree,
+ agent: 'codex'
+ })
+ } catch {
+ // A support probe has no side effect; an unavailable probe safely degrades to terminal chat.
+ return { kind: 'unsupported' }
+ }
+ if (
+ !supportResponse ||
+ typeof supportResponse !== 'object' ||
+ typeof supportResponse.ok !== 'boolean' ||
+ !supportResponse.ok
+ ) {
+ return { kind: 'unsupported' }
+ }
+ const support = supportResponse.result as StructuredCreateSupport | null
+ if (!support || typeof support !== 'object' || support.supported !== true) {
+ return { kind: 'unsupported', reason: support?.reason }
+ }
+
+ const params = createStructuredCodexSessionParams(worktreeId)
+ let response
+ try {
+ response = await client.sendRequest('agentSession.create', params, {
+ timeoutMs: 15_000,
+ budgetSpansConnect: true
+ })
+ } catch {
+ // Replay the durable envelope once so a lost acknowledgement cannot create a sibling.
+ try {
+ response = await client.sendRequest('agentSession.create', params, {
+ timeoutMs: 15_000,
+ budgetSpansConnect: true
+ })
+ } catch (retryError) {
+ // A second transport error cannot disprove the first attempt committed.
+ return unknownCreateResult(retryError)
+ }
+ }
+
+ if (!response || typeof response !== 'object' || typeof response.ok !== 'boolean') {
+ return unknownCreateResult(new Error('The Codex chat result could not be confirmed.'))
+ }
+ if (!response.ok) {
+ if (
+ !response.error ||
+ typeof response.error !== 'object' ||
+ typeof response.error.code !== 'string'
+ ) {
+ return unknownCreateResult(new Error('The Codex chat result could not be confirmed.'))
+ }
+ if (response.error.code === 'agent_session_operation_unknown') {
+ return unknownCreateResult(new Error(response.error.message))
+ }
+ return { kind: 'failed', message: response.error.message || 'Could not open Codex chat.' }
+ }
+ const result = response.result as AgentSessionMutationResult
+ if (!result || typeof result !== 'object' || typeof result.ok !== 'boolean') {
+ return unknownCreateResult(new Error('The Codex chat result could not be confirmed.'))
+ }
+ if (!result.ok) {
+ if (
+ !result.refusal ||
+ typeof result.refusal !== 'object' ||
+ typeof result.refusal.code !== 'string'
+ ) {
+ return unknownCreateResult(new Error('The Codex chat result could not be confirmed.'))
+ }
+ if (result.refusal.code === 'agent_session_operation_unknown') {
+ return unknownCreateResult(new Error(result.refusal.message))
+ }
+ return { kind: 'failed', message: result.refusal.message || 'Could not open Codex chat.' }
+ }
+ if (
+ !result.value ||
+ typeof result.value.sessionId !== 'string' ||
+ !result.value.sessionId.trim()
+ ) {
+ return unknownCreateResult(new Error('The Codex chat result could not be confirmed.'))
+ }
+ return { kind: 'created', sessionId: result.value.sessionId }
+}
diff --git a/mobile/src/session/mobile-structured-agent-session-rpc.ts b/mobile/src/session/mobile-structured-agent-session-rpc.ts
new file mode 100644
index 00000000000..a602122978e
--- /dev/null
+++ b/mobile/src/session/mobile-structured-agent-session-rpc.ts
@@ -0,0 +1,152 @@
+import {
+ AGENT_SESSION_MAX_NEW_OPERATION_AGE_MS,
+ parseAgentSessionOperationTimestamp
+} from '../../../src/shared/agent-session-host-authority'
+import type { AgentSessionMutationResult } from '../../../src/shared/agent-session-wire'
+import {
+ createStructuredAgentSessionOperationId,
+ structuredAgentSessionPayloadFingerprint
+} from '../../../src/shared/structured-agent-session-mutation'
+import { isRpcDeliveryUnknown } from '../transport/rpc-delivery-ambiguity'
+import type { RpcClient } from '../transport/rpc-client'
+import { isLogicalClientCutoverError } from '../transport/stable-logical-rpc-client'
+import { MOBILE_NATIVE_CHAT_MIN_WRITE_TIMEOUT_MS } from './mobile-native-chat-send'
+
+export const STRUCTURED_SEND_TIMEOUT_MS = 15_000
+
+export type StructuredAgentSessionMutationCallResult =
+ | { status: 'accepted'; value: TValue }
+ | { status: 'refused'; message: string }
+ | { status: 'failed'; message: string }
+ | { status: 'unknown' }
+
+export type StructuredAgentSessionMutationResult =
+ | { status: 'accepted'; value: TValue; sameFence: boolean }
+ | { status: 'rejected' }
+ | { status: 'unknown' }
+
+export type StructuredAgentSessionMutate = (
+ method: string,
+ fingerprintMethod: string,
+ fields: Record
+) => Promise>
+
+export async function callAgentSession(
+ client: RpcClient,
+ method: string,
+ params: unknown,
+ timeoutMs = STRUCTURED_SEND_TIMEOUT_MS,
+ options?: { failWhenDisconnected?: boolean }
+): Promise {
+ const response = await client.sendRequest(method, params, {
+ timeoutMs,
+ budgetSpansConnect: true,
+ ...(options?.failWhenDisconnected ? { failWhenDisconnected: true } : {})
+ })
+ if (!response.ok) {
+ throw new Error(response.error.message)
+ }
+ return response.result as TResult
+}
+
+export function structuredSessionOperationId(): string {
+ const randomUuid =
+ typeof globalThis.crypto?.randomUUID === 'function'
+ ? () => globalThis.crypto.randomUUID()
+ : () => {
+ return Array.from({ length: 32 }, () => Math.floor(Math.random() * 16).toString(16)).join(
+ ''
+ )
+ }
+ return createStructuredAgentSessionOperationId(randomUuid)
+}
+
+/**
+ * Bounded by expiry, never by count: every retained id belongs to a send whose outcome is still
+ * unknown, so dropping one turns the user's retry into a second message on the host. Only an id
+ * the host would already refuse — unparseable, or past the window in which it can be admitted —
+ * is safe to release, which matches the host's own tombstone retention.
+ */
+export function retainStructuredSessionOperationId(
+ operationIds: Map,
+ key: string,
+ operationId = structuredSessionOperationId(),
+ now: number = Date.now()
+): string {
+ operationIds.delete(key)
+ operationIds.set(key, operationId)
+ for (const [retainedKey, retainedId] of operationIds) {
+ if (retainedKey === key) {
+ continue
+ }
+ const timestamp = parseAgentSessionOperationTimestamp(retainedId)
+ if (timestamp === null || now - timestamp > AGENT_SESSION_MAX_NEW_OPERATION_AGE_MS) {
+ operationIds.delete(retainedKey)
+ }
+ }
+ return operationId
+}
+
+export function timeoutForDeadline(deadline: number | undefined): number | null {
+ if (deadline === undefined) {
+ return STRUCTURED_SEND_TIMEOUT_MS
+ }
+ const timeoutMs = deadline - Date.now()
+ return timeoutMs >= MOBILE_NATIVE_CHAT_MIN_WRITE_TIMEOUT_MS ? timeoutMs : null
+}
+
+export async function requestStructuredAgentSessionMutation(args: {
+ client: RpcClient
+ method: string
+ fingerprintMethod: string
+ sessionId: string
+ expectedRuntimeFence: number
+ fields: Record
+ clientOperationId?: string
+ retryUnknown?: boolean
+ timeoutMs?: number
+}): Promise> {
+ const {
+ client,
+ method,
+ fingerprintMethod,
+ sessionId,
+ expectedRuntimeFence,
+ fields,
+ clientOperationId,
+ retryUnknown,
+ timeoutMs
+ } = args
+ try {
+ const result = await callAgentSession>(
+ client,
+ method,
+ {
+ envelope: {
+ sessionId,
+ clientOperationId: clientOperationId ?? structuredSessionOperationId(),
+ expectedRuntimeFence,
+ payloadFingerprint: structuredAgentSessionPayloadFingerprint({
+ method: fingerprintMethod,
+ sessionId,
+ fields
+ })
+ },
+ ...(retryUnknown ? { retryUnknown: true } : {}),
+ ...fields
+ },
+ timeoutMs
+ )
+ return result.ok
+ ? { status: 'accepted', value: result.value }
+ : { status: 'refused', message: result.refusal.message }
+ } catch (error) {
+ if (isRpcDeliveryUnknown(error) || isLogicalClientCutoverError(error)) {
+ return { status: 'unknown' }
+ }
+ return {
+ status: 'failed',
+ message: error instanceof Error ? error.message : 'Request not sent'
+ }
+ }
+}
diff --git a/mobile/src/session/mobile-structured-session-operation-retention.test.ts b/mobile/src/session/mobile-structured-session-operation-retention.test.ts
new file mode 100644
index 00000000000..209f28f65c9
--- /dev/null
+++ b/mobile/src/session/mobile-structured-session-operation-retention.test.ts
@@ -0,0 +1,58 @@
+import { describe, expect, it } from 'vitest'
+import { AGENT_SESSION_MAX_NEW_OPERATION_AGE_MS } from '../../../src/shared/agent-session-host-authority'
+import { retainStructuredSessionOperationId } from './mobile-structured-agent-session-rpc'
+
+const NOW = 1_900_000_000_000
+
+function operationIdAt(timestamp: number, entropy: string): string {
+ return `${timestamp}-${entropy.repeat(32).slice(0, 32)}`
+}
+
+describe('structured session operation retention', () => {
+ it('keeps every unconfirmed operation id past the old 128-entry cap', () => {
+ const operationIds = new Map()
+ for (let index = 0; index < 400; index += 1) {
+ retainStructuredSessionOperationId(
+ operationIds,
+ `request-${index}`,
+ operationIdAt(NOW, 'a'),
+ NOW
+ )
+ }
+
+ expect(operationIds.size).toBe(400)
+ // Why: the first send is exactly the one a retry would duplicate if it were evicted.
+ expect(operationIds.get('request-0')).toBe(operationIdAt(NOW, 'a'))
+ })
+
+ it('releases only ids the host would already refuse as expired', () => {
+ const operationIds = new Map()
+ const expired = operationIdAt(NOW - AGENT_SESSION_MAX_NEW_OPERATION_AGE_MS - 1, 'b')
+ const admissible = operationIdAt(NOW - AGENT_SESSION_MAX_NEW_OPERATION_AGE_MS, 'c')
+ retainStructuredSessionOperationId(operationIds, 'stale', expired, NOW)
+ retainStructuredSessionOperationId(operationIds, 'live', admissible, NOW)
+
+ retainStructuredSessionOperationId(operationIds, 'fresh', operationIdAt(NOW, 'd'), NOW)
+
+ expect(operationIds.has('stale')).toBe(false)
+ expect(operationIds.get('live')).toBe(admissible)
+ expect(operationIds.get('fresh')).toBe(operationIdAt(NOW, 'd'))
+ })
+
+ it('drops ids the host could never admit and re-keys a repeated send', () => {
+ const operationIds = new Map()
+ retainStructuredSessionOperationId(operationIds, 'unparseable', 'not-an-operation-id', NOW)
+ const reused = retainStructuredSessionOperationId(
+ operationIds,
+ 'send',
+ operationIdAt(NOW, 'e'),
+ NOW
+ )
+
+ // A retry of the same send reuses the retained id rather than minting a duplicate.
+ expect(
+ retainStructuredSessionOperationId(operationIds, 'send', operationIds.get('send'), NOW)
+ ).toBe(reused)
+ expect(operationIds.has('unparseable')).toBe(false)
+ })
+})
diff --git a/mobile/src/session/mobile-terminal-records.test.ts b/mobile/src/session/mobile-terminal-records.test.ts
index e4ce55a84aa..bcc9510d0e8 100644
--- a/mobile/src/session/mobile-terminal-records.test.ts
+++ b/mobile/src/session/mobile-terminal-records.test.ts
@@ -182,6 +182,20 @@ describe('mobile terminal records', () => {
).toBe(false)
})
+ it('treats structured agent-session identity changes as session-tab changes', () => {
+ const base = {
+ type: 'agent-session' as const,
+ id: 'agent-tab-1',
+ title: 'Codex',
+ sessionId: 'session-1',
+ agent: 'codex',
+ isActive: true
+ }
+
+ expect(mobileSessionTabsEqual([base], [{ ...base }])).toBe(true)
+ expect(mobileSessionTabsEqual([base], [{ ...base, sessionId: 'session-2' }])).toBe(false)
+ })
+
const record = (over: Partial & { handle: string }): TerminalRecord => ({
title: 'Terminal',
terminalTheme: undefined,
diff --git a/mobile/src/session/mobile-terminal-records.ts b/mobile/src/session/mobile-terminal-records.ts
index 09426863b99..f03a31acf41 100644
--- a/mobile/src/session/mobile-terminal-records.ts
+++ b/mobile/src/session/mobile-terminal-records.ts
@@ -62,6 +62,14 @@ type MobileSessionTabLike =
canGoForward?: boolean
isActive?: boolean
}
+ | {
+ type: 'agent-session'
+ id: string
+ title?: string
+ sessionId?: string
+ agent?: string
+ isActive?: boolean
+ }
export function mobileTerminalThemesEqual(
left: MobileTerminalTheme | null | undefined,
@@ -152,6 +160,8 @@ function mobileSessionTabEqual(
a.canGoBack === b.canGoBack &&
a.canGoForward === b.canGoForward
)
+ case 'agent-session':
+ return b.type === 'agent-session' && a.sessionId === b.sessionId && a.agent === b.agent
}
}
diff --git a/mobile/src/session/mobile-terminal-tab-agent.test.ts b/mobile/src/session/mobile-terminal-tab-agent.test.ts
index 5177981f0e6..6ad034335ad 100644
--- a/mobile/src/session/mobile-terminal-tab-agent.test.ts
+++ b/mobile/src/session/mobile-terminal-tab-agent.test.ts
@@ -120,4 +120,17 @@ describe('getMobileSessionTabTitle', () => {
expect(getMobileSessionTabTitle(blankBrowserTab)).toBe('New Browser')
})
+
+ it('labels structured agent-session tabs without terminal decoration rules', () => {
+ expect(
+ getMobileSessionTabTitle({
+ type: 'agent-session',
+ id: 'agent-tab-1',
+ title: 'Codex Chat',
+ sessionId: 'session-1',
+ agent: 'codex',
+ isActive: true
+ })
+ ).toBe('Codex Chat')
+ })
})
diff --git a/mobile/src/session/mobile-terminal-tab-agent.ts b/mobile/src/session/mobile-terminal-tab-agent.ts
index 20326d41c98..dfc7be812e8 100644
--- a/mobile/src/session/mobile-terminal-tab-agent.ts
+++ b/mobile/src/session/mobile-terminal-tab-agent.ts
@@ -62,6 +62,9 @@ export function getMobileSessionTabTitle(tab: MobileSessionTab): string {
if (tab.type === 'file') {
return tab.title || 'File'
}
+ if (tab.type === 'agent-session') {
+ return tab.title || 'Chat'
+ }
// Why: strip the leading agent status glyph (✳ etc.) once the tab shows the
// provider icon. Mobile falls back for glyph-only titles because iOS can
// render the bare status glyph as a stray colored box beside the icon.
diff --git a/mobile/src/session/opened-mobile-session-tab.test.ts b/mobile/src/session/opened-mobile-session-tab.test.ts
index 988a3ea313f..dbeed5ed830 100644
--- a/mobile/src/session/opened-mobile-session-tab.test.ts
+++ b/mobile/src/session/opened-mobile-session-tab.test.ts
@@ -378,4 +378,19 @@ describe('shouldActivateOpenedMobileSessionTab', () => {
})
).toBe(false)
})
+
+ it('allows a structured agent-session tab to anchor chat file activation', () => {
+ expect(
+ shouldActivateOpenedMobileSessionTab({
+ activated: false,
+ activationSeq: 2,
+ latestActivationSeq: 2,
+ sourceTerminalHandle: null,
+ activeTerminalHandle: null,
+ sourceSessionTabId: 'agent-tab-1',
+ activeSessionTabId: 'agent-tab-1',
+ activeTabType: 'agent-session'
+ })
+ ).toBe(true)
+ })
})
diff --git a/mobile/src/session/opened-mobile-session-tab.ts b/mobile/src/session/opened-mobile-session-tab.ts
index 17c8cff9848..f76571eceef 100644
--- a/mobile/src/session/opened-mobile-session-tab.ts
+++ b/mobile/src/session/opened-mobile-session-tab.ts
@@ -9,8 +9,10 @@ export type OpenedMobileSessionTabActivationState = {
activated: boolean
activationSeq: number
latestActivationSeq: number
- sourceTerminalHandle: string
+ sourceTerminalHandle: string | null
activeTerminalHandle: string | null
+ sourceSessionTabId?: string | null
+ activeSessionTabId?: string | null
activeTabType: string | null
}
@@ -114,12 +116,15 @@ export async function activateOpenedSourceControlDiffTab(
diff --git a/mobile/src/session/use-mobile-file-tap-handlers.test.ts b/mobile/src/session/use-mobile-file-tap-handlers.test.ts
index 21f07562459..6d0adbbf8df 100644
--- a/mobile/src/session/use-mobile-file-tap-handlers.test.ts
+++ b/mobile/src/session/use-mobile-file-tap-handlers.test.ts
@@ -142,4 +142,31 @@ describe('useMobileFileTapHandlers', () => {
)
expect(options.reportChatTapFailure).toHaveBeenCalledWith("Couldn't open mobile/src/x.ts:12")
})
+
+ it('lets structured chat file taps resolve without a backing terminal handle', async () => {
+ const sendRequest = vi.fn(async () => ok({ exists: false, isDirectory: false }))
+ const options = {
+ ...createOptions(sendRequest),
+ activeHandleRef: { current: null as string | null },
+ getActiveSessionTabId: () => 'agent-tab-1',
+ getActiveSessionTabType: () => 'agent-session'
+ }
+ act(() => {
+ renderer = create(createElement(Harness, { options }))
+ })
+
+ handlers!.handleNativeChatFileTap('src/app.ts')
+ await act(async () => {})
+
+ expect(sendRequest).toHaveBeenCalledWith(
+ 'files.resolveTerminalPath',
+ {
+ worktree: 'id:wt-1',
+ pathText: 'src/app.ts',
+ crossWorkspace: true,
+ nativeChatContext: { tabId: 'agent-tab-1', sessionId: 'session-1' }
+ },
+ { timeoutMs: 10_000 }
+ )
+ })
})
diff --git a/mobile/src/session/use-mobile-file-tap-handlers.ts b/mobile/src/session/use-mobile-file-tap-handlers.ts
index 67995115f45..5bfe71f839b 100644
--- a/mobile/src/session/use-mobile-file-tap-handlers.ts
+++ b/mobile/src/session/use-mobile-file-tap-handlers.ts
@@ -141,15 +141,13 @@ export function useMobileFileTapHandlers(
const handleNativeChatFileTap = useCallback((pathText: string) => {
const current = optionsRef.current
- // 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) {
+ const nativeChatSessionId = current.nativeChatSessionId
+ const nativeChatTabId = current.getActiveSessionTabId()
+ if (!current.client || (!sourceTerminalHandle && !(nativeChatSessionId && nativeChatTabId))) {
return
}
const activationSeq = ++activationSeqRef.current
- const nativeChatSessionId = current.nativeChatSessionId
- const nativeChatTabId = current.getActiveSessionTabId()
openMobileNativeChatFileTap({
client: current.client,
hostId: current.hostId,
@@ -172,6 +170,8 @@ export function useMobileFileTapHandlers(
latestActivationSeq: activationSeqRef.current,
sourceTerminalHandle,
activeTerminalHandle: current.activeHandleRef.current,
+ sourceSessionTabId: nativeChatTabId,
+ activeSessionTabId: current.getActiveSessionTabId(),
activeTabType: current.getActiveSessionTabType()
}),
switchSessionTab: current.switchSessionTab,
diff --git a/mobile/src/session/use-mobile-native-chat-active-resolution.ts b/mobile/src/session/use-mobile-native-chat-active-resolution.ts
new file mode 100644
index 00000000000..ea7f923de47
--- /dev/null
+++ b/mobile/src/session/use-mobile-native-chat-active-resolution.ts
@@ -0,0 +1,83 @@
+import { useLayoutEffect, useRef, type MutableRefObject } from 'react'
+import { encodeNativeChatTranscriptIdentity } from '../../../src/shared/native-chat-transcript-retention'
+import { resolveMobileNativeChat, type MobileNativeChatTab } from './mobile-native-chat-eligibility'
+import { useMobileSessionViewMode } from './use-mobile-session-view-mode'
+
+export function useMobileNativeChatActiveResolution(args: {
+ hostId: string
+ worktreeId: string
+ activeSessionTab: MobileNativeChatTab | null
+ activeSessionTabId: string | null
+ activeHandleRef: MutableRefObject
+ nativeChatTranscriptIsLocalReadable: boolean
+}): {
+ isTabChatView: (tabId: string) => boolean
+ toggleTabChatView: (tabId: string) => void
+ showNativeChat: boolean
+ showNativeChatRef: MutableRefObject
+ activeChatAgent: string | null
+ activeChatAgentRef: MutableRefObject
+ activeChatSessionId: string | null
+ activeChatStructured: boolean
+ activeChatResolution: ReturnType
+ activeTabAgentWorking: boolean
+ nativeChatStatus: MobileNativeChatTab['agentStatus'] | null
+ sourceIdentity: string
+ streamIdentity: string
+ streamScopeKey: string
+} {
+ const {
+ activeHandleRef,
+ activeSessionTab,
+ activeSessionTabId,
+ hostId,
+ nativeChatTranscriptIsLocalReadable,
+ worktreeId
+ } = args
+ const { isTabChatView, toggleTabChatView } = useMobileSessionViewMode({ hostId, worktreeId })
+ const tabWantsChat =
+ activeSessionTab?.type === 'agent-session' ||
+ (activeSessionTabId ? isTabChatView(activeSessionTabId) : false)
+ const activeChatResolution =
+ activeSessionTab && activeSessionTabId && tabWantsChat
+ ? resolveMobileNativeChat(activeSessionTab, nativeChatTranscriptIsLocalReadable)
+ : null
+ const showNativeChat = activeChatResolution != null
+ const showNativeChatRef = useRef(showNativeChat)
+ const activeChatAgent = activeChatResolution?.agent ?? null
+ const activeChatAgentRef = useRef(activeChatAgent)
+
+ useLayoutEffect(() => {
+ showNativeChatRef.current = showNativeChat
+ activeChatAgentRef.current = activeChatAgent
+ }, [activeChatAgent, showNativeChat])
+
+ const activeChatSessionId = activeChatResolution?.sessionId ?? null
+ const activeChatStructured =
+ activeChatResolution != null && activeSessionTab?.type === 'agent-session'
+ const activeTabStatus = activeSessionTab?.agentStatus
+ const activeTabAgentWorking =
+ activeTabStatus?.state === 'working' && activeTabStatus.workingMode !== 'monitoring'
+ const nativeChatStatus = activeChatResolution && !activeChatStructured ? activeTabStatus : null
+ const routeKey = `${hostId}\0${worktreeId}\0${activeSessionTabId ?? ''}`
+ const streamIdentity = `${routeKey}\0${activeChatSessionId ?? ''}\0${activeHandleRef.current ?? ''}`
+ const providerSessionId = activeSessionTab?.agentStatus?.providerSession?.id ?? ''
+ const streamScopeKey = `${routeKey}\0${activeChatSessionId ?? providerSessionId}\0${activeHandleRef.current ?? ''}`
+
+ return {
+ isTabChatView,
+ toggleTabChatView,
+ showNativeChat,
+ showNativeChatRef,
+ activeChatAgent,
+ activeChatAgentRef,
+ activeChatSessionId,
+ activeChatStructured,
+ activeChatResolution,
+ activeTabAgentWorking,
+ nativeChatStatus,
+ sourceIdentity: encodeNativeChatTranscriptIdentity([hostId, worktreeId]),
+ streamIdentity,
+ streamScopeKey
+ }
+}
diff --git a/mobile/src/session/use-mobile-native-chat-controller.test.ts b/mobile/src/session/use-mobile-native-chat-controller.test.ts
index 40937adc0e0..83f8075d914 100644
--- a/mobile/src/session/use-mobile-native-chat-controller.test.ts
+++ b/mobile/src/session/use-mobile-native-chat-controller.test.ts
@@ -1,6 +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 { SessionOptionDescriptor } from '../../../src/shared/native-chat-session-options'
import type { RpcClient } from '../transport/rpc-client'
import type { ConnectionState } from '../transport/types'
@@ -14,6 +15,55 @@ const holdUnconfirmedSend = vi.fn()
// and transcript state; defaults keep the send-seam tests unchanged.
const viewMode = { isTabChatView: (_tabId: string) => true }
const sessionState = { messages: [] as unknown[], status: 'ready', transcriptLoading: false }
+const structuredSendWithOutcome = vi.fn()
+const structuredCancel = vi.fn()
+const structuredRespondPermission = vi.fn(async () => true)
+const structuredRespondQuestion = vi.fn(async () => true)
+const structuredSetOption = vi.fn(async () => true)
+const structuredInvokeOption = vi.fn(async () => true)
+const structuredOptionSnapshot: SessionOptionDescriptor[] = [
+ {
+ id: 'model',
+ label: 'Model',
+ category: 'model',
+ kind: {
+ type: 'select',
+ currentValue: 'gpt-fast',
+ choices: [{ value: 'gpt-fast', label: 'GPT Fast' }]
+ },
+ valueSource: 'reported',
+ settable: true
+ }
+]
+const structuredOptionSurface = {
+ getSnapshot: () => structuredOptionSnapshot,
+ setOption: async () => ({ snapshot: structuredOptionSnapshot }),
+ invokeAction: async () => ({ snapshot: structuredOptionSnapshot }),
+ subscribe: () => () => {}
+}
+const structuredPermission = {
+ title: 'Allow Bash?',
+ detail: 'rm -rf build',
+ options: [
+ { label: 'Allow once', send: 'allow-once' },
+ { label: 'Deny', send: 'deny' }
+ ]
+}
+const structuredQuestion = {
+ question: 'Pick destination',
+ options: ['Choice A', 'Choice B'],
+ allowOther: true,
+ optionTokens: ['choice-a', 'choice-b']
+}
+const structuredSessionState = {
+ messages: [] as unknown[],
+ status: 'ready',
+ transcriptLoading: false,
+ error: undefined,
+ hasMore: false,
+ loadingEarlier: false,
+ loadEarlier: vi.fn()
+}
const draftsArgs: Record[] = []
const promptsState = {
permission: null as unknown,
@@ -33,6 +83,24 @@ vi.mock('./use-mobile-session-view-mode', () => ({
vi.mock('./use-mobile-native-chat-session', () => ({
useMobileNativeChatSession: () => sessionState
}))
+vi.mock('./use-mobile-structured-agent-session', () => ({
+ useMobileStructuredAgentSession: () => ({
+ session: structuredSessionState,
+ isWorking: false,
+ turnId: null,
+ sendWithOutcome: structuredSendWithOutcome,
+ cancel: structuredCancel,
+ permission: structuredPermission,
+ question: structuredQuestion,
+ optionSnapshot: structuredOptionSnapshot,
+ optionSurface: structuredOptionSurface,
+ pendingOptionId: 'model',
+ respondPermission: structuredRespondPermission,
+ respondQuestion: structuredRespondQuestion,
+ setStructuredOption: structuredSetOption,
+ invokeStructuredOption: structuredInvokeOption
+ })
+}))
vi.mock('./use-mobile-native-chat-drafts', () => ({
useMobileNativeChatDrafts: (args: Record) => {
draftsArgs.push(args)
@@ -110,18 +178,28 @@ describe('useMobileNativeChatController handleNativeChatSend', () => {
// itself is mocked above).
const clientStub = { sendRequest: vi.fn() }
- function Harness({ connState = 'connected' }: { connState?: ConnectionState }): null {
+ function Harness({
+ connState = 'connected',
+ tab = null,
+ activeHandle = 'term-1',
+ inputLeaseReady = true
+ }: {
+ connState?: ConnectionState
+ tab?: unknown
+ activeHandle?: string | null
+ inputLeaseReady?: boolean
+ }): null {
controller = useMobileNativeChatController({
client: clientStub as unknown as RpcClient,
connState,
hostId: 'h',
worktreeId: 'w',
- activeSessionTab: null,
- activeSessionTabId: 'tab-1',
- activeHandleRef: { current: 'term-1' },
+ activeSessionTab: tab as never,
+ activeSessionTabId: (tab as { id?: string } | null)?.id ?? 'tab-1',
+ activeHandleRef: { current: activeHandle },
deviceTokenRef: { current: null },
nativeChatTranscriptIsLocalReadable: true,
- nativeChatInputLeaseReady: true,
+ nativeChatInputLeaseReady: inputLeaseReady,
onSendError,
onSendResolved
})
@@ -138,6 +216,7 @@ describe('useMobileNativeChatController handleNativeChatSend', () => {
})
resetMobileNativeChatStaleInputForTests()
captureSendOrigin.mockReturnValue(ORIGIN)
+ structuredSendWithOutcome.mockResolvedValue('accepted')
act(() => {
renderer = create(createElement(Harness))
})
@@ -233,6 +312,80 @@ describe('useMobileNativeChatController handleNativeChatSend', () => {
expect(restoreRejectedDraft).not.toHaveBeenCalled()
})
+ it('routes structured agent-session sends away from terminal/nativeChat transports', async () => {
+ await act(async () => {
+ renderer?.update(
+ createElement(Harness, {
+ tab: {
+ type: 'agent-session',
+ id: 'agent-tab-1',
+ title: 'Codex Chat',
+ sessionId: 'session-structured',
+ agent: 'codex',
+ isActive: true
+ },
+ activeHandle: null,
+ inputLeaseReady: false
+ })
+ )
+ })
+
+ let accepted = false
+ await act(async () => {
+ accepted = await controller!.handleNativeChatSend('look')
+ })
+
+ expect(accepted).toBe(true)
+ expect(structuredSendWithOutcome).toHaveBeenCalledWith('look')
+ expect(sendWithOutcome).not.toHaveBeenCalled()
+ expect(clientStub.sendRequest).not.toHaveBeenCalled()
+ })
+
+ it('exposes structured prompt cards and session options on structured tabs', async () => {
+ await act(async () => {
+ renderer?.update(
+ createElement(Harness, {
+ tab: {
+ type: 'agent-session',
+ id: 'agent-tab-1',
+ title: 'Codex Chat',
+ sessionId: 'session-structured',
+ agent: 'codex',
+ isActive: true
+ },
+ activeHandle: null,
+ inputLeaseReady: false
+ })
+ )
+ })
+
+ expect(controller!.nativeChatPermission).toEqual(structuredPermission)
+ expect(controller!.nativeChatQuestion).toEqual(structuredQuestion)
+ expect(controller!.nativeChatSessionOptions).not.toBeNull()
+ expect(controller!.nativeChatSessionOptions?.controller.snapshot).toEqual(
+ structuredOptionSnapshot
+ )
+
+ await act(async () => {
+ expect(await controller!.handleNativeChatRespondPermission('allow-once')).toBe(true)
+ })
+ expect(structuredRespondPermission).toHaveBeenCalledWith('allow-once')
+ expect(sendWithOutcome).not.toHaveBeenCalled()
+
+ await act(async () => {
+ expect(await controller!.handleNativeChatQuestionAnswer('choice-a')).toBe(true)
+ })
+ expect(structuredRespondQuestion).toHaveBeenCalledWith('choice-a')
+ expect(clientStub.sendRequest).not.toHaveBeenCalled()
+
+ await act(async () => {
+ expect(
+ await controller!.nativeChatSessionOptions!.controller.setOption('model', 'gpt-fast')
+ ).toBe(true)
+ })
+ expect(structuredSetOption).toHaveBeenCalledWith('model', 'gpt-fast')
+ })
+
it('pre-clears separately for a text-only send but never for an image send', async () => {
// The image path pastes the image behind its OWN leading Ctrl+U and then calls
// this send; a second clear here wipes the image off the input line and the
diff --git a/mobile/src/session/use-mobile-native-chat-controller.ts b/mobile/src/session/use-mobile-native-chat-controller.ts
index 109dea93ec3..bf944398e87 100644
--- a/mobile/src/session/use-mobile-native-chat-controller.ts
+++ b/mobile/src/session/use-mobile-native-chat-controller.ts
@@ -1,9 +1,7 @@
-import { useCallback, useLayoutEffect, useRef, type MutableRefObject } from 'react'
-import { encodeNativeChatTranscriptIdentity } from '../../../src/shared/native-chat-transcript-retention'
-import { useMobileSessionViewMode } from './use-mobile-session-view-mode'
+import { useLayoutEffect, useRef, type MutableRefObject } from 'react'
import type { RpcClient } from '../transport/rpc-client'
import type { ConnectionState } from '../transport/types'
-import { type MobileNativeChatTab, resolveMobileNativeChat } from './mobile-native-chat-eligibility'
+import type { MobileNativeChatTab } from './mobile-native-chat-eligibility'
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'
@@ -11,15 +9,17 @@ import { useMobileNativeChatCancelAsk } from './use-mobile-native-chat-cancel-as
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'
-import { mobileNativeChatScopeKey } from './mobile-native-chat-scope-key'
import { mobileNativeChatStreamPreview } from './mobile-native-chat-streaming-gate'
import { useMobileNativeChatSession } from './use-mobile-native-chat-session'
-import { useMobileNativeChatSessionOptions } from './use-mobile-native-chat-session-options'
+import { useMobileNativeChatSessionOptionController } from './use-mobile-native-chat-session-option-controller'
+import { useMobileStructuredAgentSession } from './use-mobile-structured-agent-session'
+import { useMobileStructuredNativeChatSendBridge } from './use-mobile-structured-native-chat-send-bridge'
import { useMobileNativeChatPrompts } from './use-mobile-native-chat-prompts'
import { useMobileNativeChatStop } from './use-mobile-native-chat-stop'
import { useNativeChatAcceptedAction } from './use-native-chat-action-outcomes'
import { useThrottledLatestValue } from './use-throttled-latest-value'
import type { MobileNativeChatController } from './mobile-native-chat-controller-contract'
+import { useMobileNativeChatActiveResolution } from './use-mobile-native-chat-active-resolution'
export type { MobileNativeChatController } from './mobile-native-chat-controller-contract'
@@ -58,36 +58,51 @@ export function useMobileNativeChatController(args: {
onSendError,
onSendResolved
} = args
- const { isTabChatView, toggleTabChatView } = useMobileSessionViewMode({ hostId, worktreeId })
-
- const activeChatResolution =
- activeSessionTab && activeSessionTabId && isTabChatView(activeSessionTabId)
- ? resolveMobileNativeChat(activeSessionTab, nativeChatTranscriptIsLocalReadable)
- : null
- const showNativeChat = activeChatResolution != null
- const showNativeChatRef = useRef(showNativeChat)
- const activeChatAgent = activeChatResolution?.agent ?? null
- const activeChatAgentRef = useRef(activeChatAgent)
- useLayoutEffect(() => {
- showNativeChatRef.current = showNativeChat
- activeChatAgentRef.current = activeChatAgent
- }, [activeChatAgent, showNativeChat])
-
- const activeChatSessionId = activeChatResolution?.sessionId ?? null
- const routeKey = `${hostId}\0${worktreeId}\0${activeSessionTabId ?? ''}`
- const streamIdentity = `${routeKey}\0${activeChatSessionId ?? ''}\0${activeHandleRef.current ?? ''}`
- // Same chat, but keyed off the tab rather than the view-gated resolution:
- // `streamIdentity` goes session-less the moment the user peeks at the terminal,
- // and a scope that flips on a view toggle throws the gate's baseline away.
- const streamScopeKey = `${routeKey}\0${activeSessionTab?.agentStatus?.providerSession?.id ?? ''}\0${activeHandleRef.current ?? ''}`
-
- const nativeChatSession = useMobileNativeChatSession({
- client,
- sourceIdentity: encodeNativeChatTranscriptIdentity([hostId, worktreeId]),
- agent: activeChatResolution?.agent ?? null,
- sessionId: activeChatSessionId,
- transcriptPath: activeChatResolution?.transcriptPath ?? null
+ const {
+ activeChatAgent,
+ activeChatAgentRef,
+ activeChatResolution,
+ activeChatSessionId,
+ activeChatStructured,
+ activeTabAgentWorking,
+ isTabChatView,
+ nativeChatStatus,
+ showNativeChat,
+ showNativeChatRef,
+ sourceIdentity,
+ streamIdentity,
+ streamScopeKey,
+ toggleTabChatView
+ } = useMobileNativeChatActiveResolution({
+ hostId,
+ worktreeId,
+ activeSessionTab,
+ activeSessionTabId,
+ activeHandleRef,
+ nativeChatTranscriptIsLocalReadable
})
+
+ const legacyNativeChatSession = useMobileNativeChatSession({
+ client,
+ sourceIdentity,
+ agent: activeChatStructured ? null : (activeChatResolution?.agent ?? null),
+ sessionId: activeChatStructured ? null : activeChatSessionId,
+ transcriptPath: activeChatStructured ? null : (activeChatResolution?.transcriptPath ?? null)
+ })
+ const structuredNativeChat = useMobileStructuredAgentSession({
+ client,
+ sessionId: activeChatStructured ? activeChatSessionId : null,
+ sourceIdentity,
+ enabled: showNativeChat,
+ // Holds are connection-scoped; dropping this on transport loss lets the hook
+ // reacquire the provider without clearing the cached transcript.
+ connected: connState === 'connected',
+ agent: activeChatStructured ? activeChatAgent : null,
+ onSendError
+ })
+ const nativeChatSession = activeChatStructured
+ ? structuredNativeChat.session
+ : legacyNativeChatSession
const {
composerText: chatComposerText,
setComposerText: setChatComposerText,
@@ -117,27 +132,29 @@ export function useMobileNativeChatController(args: {
transcriptSettled: nativeChatSession.status === 'ready'
})
- const activeTabStatus = activeSessionTab?.agentStatus
- const activeTabAgentWorking =
- activeTabStatus?.state === 'working' && activeTabStatus.workingMode !== 'monitoring'
- const nativeChatStatus = activeChatResolution ? activeTabStatus : null
- const nativeChatAgentWorking = activeChatResolution != null && activeTabAgentWorking
+ const nativeChatAgentWorking = activeChatStructured
+ ? structuredNativeChat.isWorking
+ : activeChatResolution != null && activeTabAgentWorking
// Deliberately not gated on the chat view being visible: the streaming gate
// has to tell "hidden mid-turn" from "the turn ended".
- const nativeChatStreamLive = activeTabAgentWorking
+ const nativeChatStreamLive = activeChatStructured
+ ? structuredNativeChat.isWorking
+ : activeTabAgentWorking
// 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(
- mobileNativeChatStreamPreview(nativeChatStatus, nativeChatAgentWorking),
+ activeChatStructured
+ ? undefined
+ : mobileNativeChatStreamPreview(nativeChatStatus, nativeChatAgentWorking),
NATIVE_CHAT_STREAM_THROTTLE_MS
)
const {
- permission: nativeChatPermission,
- question: nativeChatQuestion,
+ permission: legacyNativeChatPermission,
+ question: legacyNativeChatQuestion,
detectedAsk: nativeChatDetectedAsk,
ask: nativeChatAskPrompt
} = useMobileNativeChatPrompts({
- enabled: activeChatResolution != null,
+ enabled: activeChatResolution != null && !activeChatStructured,
status: nativeChatStatus,
messages: nativeChatSession.messages,
transcriptLoading: nativeChatSession.transcriptLoading
@@ -146,8 +163,6 @@ export function useMobileNativeChatController(args: {
const nativeChatTranscriptSettled =
nativeChatSession.status === 'ready' ||
(nativeChatSession.status === 'error' && nativeChatSession.messages.length > 0)
- const nativeChatAskObservable =
- showNativeChat && (nativeChatDetectedAsk != null || nativeChatTranscriptSettled)
const {
askKey: nativeChatAskKey,
showAsk: showNativeChatAsk,
@@ -157,17 +172,19 @@ export function useMobileNativeChatController(args: {
detectedAsk: nativeChatDetectedAsk,
scopeKey: activeSessionTabId,
sessionKey: activeChatSessionId,
- observing: nativeChatAskObservable
+ observing: showNativeChat && (nativeChatDetectedAsk != null || nativeChatTranscriptSettled)
})
// Every chat write gates on both: the lease proves the input floor is ours, and
// `connState` collapses a render before the lease does on disconnect.
- const inputSendable = nativeChatInputLeaseReady && connState === 'connected'
+ const inputSendable = activeChatStructured
+ ? client != null && activeChatSessionId != null && connState === 'connected'
+ : nativeChatInputLeaseReady && connState === 'connected'
const { answerAsk: handleNativeChatAnswerAsk, cancelPending: cancelNativeChatAnswer } =
useMobileNativeChatAnswerSend({
client,
- enabled: inputSendable,
+ enabled: inputSendable && !activeChatStructured,
handleRef: activeHandleRef,
deviceTokenRef,
agentRef: activeChatAgentRef,
@@ -178,16 +195,16 @@ export function useMobileNativeChatController(args: {
const handleNativeChatCancelAsk = useMobileNativeChatCancelAsk({
client,
- enabled: inputSendable,
+ enabled: inputSendable && !activeChatStructured,
handleRef: activeHandleRef,
deviceTokenRef,
cancelPending: cancelNativeChatAnswer,
onSendError
})
- const handleNativeChatRespondPermission = useMobileNativeChatPermissionSend({
+ const legacyHandleNativeChatRespondPermission = useMobileNativeChatPermissionSend({
client,
- enabled: inputSendable,
+ enabled: inputSendable && !activeChatStructured,
handleRef: activeHandleRef,
deviceTokenRef,
onSendError
@@ -195,7 +212,7 @@ export function useMobileNativeChatController(args: {
const handleNativeChatStop = useMobileNativeChatStop({
client,
- enabled: inputSendable,
+ enabled: inputSendable && !activeChatStructured,
handleRef: activeHandleRef,
deviceTokenRef,
streamIdentity,
@@ -216,11 +233,11 @@ export function useMobileNativeChatController(args: {
const {
send: handleNativeChatSend,
sendWithOutcome: handleNativeChatSendWithOutcome,
- answerQuestion: handleNativeChatQuestionAnswer,
+ answerQuestion: legacyHandleNativeChatQuestionAnswer,
dispatchCommand: handleNativeChatDispatchCommand
} = useMobileNativeChatMessageSend({
client,
- enabled: inputSendable,
+ enabled: inputSendable && !activeChatStructured,
handleRef: activeHandleRef,
deviceTokenRef,
agentRef: activeChatAgentRef,
@@ -234,26 +251,44 @@ export function useMobileNativeChatController(args: {
onSendError
})
- // Bring the terminal view forward when an agent-owned picker command is used.
- const handleAgentPicker = useCallback(() => {
- if (activeSessionTabId && isTabChatView(activeSessionTabId)) {
- toggleTabChatView(activeSessionTabId)
- }
- }, [activeSessionTabId, isTabChatView, toggleTabChatView])
-
- const sessionOptions = useMobileNativeChatSessionOptions({
- agent: activeChatResolution?.agent ?? null,
- scopeKey: mobileNativeChatScopeKey(hostId, worktreeId, activeSessionTabId),
- reportedModel: activeSessionTab?.agentStatus?.model ?? null,
- dispatchCommand: handleNativeChatDispatchCommand,
- onAgentPicker: handleAgentPicker
+ const structuredNativeChatSend = useMobileStructuredNativeChatSendBridge({
+ sendStructured: structuredNativeChat.sendWithOutcome,
+ captureSendOrigin,
+ clearDraftForSend,
+ acceptSend,
+ holdUnconfirmedSend,
+ restoreRejectedDraft,
+ onSendError
})
+
+ const { nativeChatSessionOptions, recordCommand: recordNativeChatSessionOptionCommand } =
+ useMobileNativeChatSessionOptionController({
+ activeChatStructured,
+ activeSessionTabId,
+ agent: activeChatResolution?.agent ?? null,
+ dispatchCommand: handleNativeChatDispatchCommand,
+ hostId,
+ isTabChatView,
+ isWorking: nativeChatAgentWorking,
+ reportedModel: activeSessionTab?.agentStatus?.model ?? null,
+ structured: {
+ snapshot: structuredNativeChat.optionSnapshot,
+ pendingId: structuredNativeChat.pendingOptionId,
+ setOption: structuredNativeChat.setStructuredOption,
+ invokeAction: structuredNativeChat.invokeStructuredOption
+ },
+ toggleTabChatView,
+ worktreeId
+ })
useLayoutEffect(() => {
- recordSessionOptionCommandRef.current = sessionOptions.recordCommand
- }, [sessionOptions.recordCommand])
+ recordSessionOptionCommandRef.current = recordNativeChatSessionOptionCommand
+ }, [recordNativeChatSessionOptionCommand])
// Card actions retire the route's held failure banner too, not just sends.
const answerAsk = useNativeChatAcceptedAction(handleNativeChatAnswerAsk, onSendResolved)
const cancelAsk = useNativeChatAcceptedAction(handleNativeChatCancelAsk, onSendResolved)
+ const handleNativeChatRespondPermission = activeChatStructured
+ ? structuredNativeChat.respondPermission
+ : legacyHandleNativeChatRespondPermission
const respond = useNativeChatAcceptedAction(handleNativeChatRespondPermission, onSendResolved)
return {
@@ -272,24 +307,31 @@ export function useMobileNativeChatController(args: {
nativeChatStreamingText,
nativeChatStreamLive,
nativeChatStreamScopeKey: streamScopeKey,
- nativeChatPermission,
- nativeChatQuestion,
- nativeChatAsk: showNativeChatAsk ? nativeChatAskPrompt : null,
+ nativeChatPermission: activeChatStructured
+ ? structuredNativeChat.permission
+ : legacyNativeChatPermission,
+ nativeChatQuestion: activeChatStructured
+ ? structuredNativeChat.question
+ : legacyNativeChatQuestion,
+ nativeChatAsk: !activeChatStructured && showNativeChatAsk ? nativeChatAskPrompt : null,
nativeChatAskKey,
dismissNativeChatAsk,
handleNativeChatAnswerAsk: answerAsk,
handleNativeChatCancelAsk: cancelAsk,
handleNativeChatRespondPermission: respond,
- handleNativeChatStop,
+ handleNativeChatStop: activeChatStructured ? structuredNativeChat.cancel : handleNativeChatStop,
nativeChatFilePaths,
loadNativeChatFiles,
- handleNativeChatQuestionAnswer,
- handleNativeChatSend,
- handleNativeChatSendWithOutcome,
+ handleNativeChatQuestionAnswer: activeChatStructured
+ ? structuredNativeChat.respondQuestion
+ : legacyHandleNativeChatQuestionAnswer,
+ handleNativeChatSend: activeChatStructured
+ ? structuredNativeChatSend.send
+ : handleNativeChatSend,
+ handleNativeChatSendWithOutcome: activeChatStructured
+ ? structuredNativeChatSend.sendWithOutcome
+ : handleNativeChatSendWithOutcome,
readSeededLaunchDraft,
- nativeChatSessionOptions:
- sessionOptions.snapshot.length > 0
- ? { controller: sessionOptions, isWorking: nativeChatAgentWorking }
- : null
+ nativeChatSessionOptions
}
}
diff --git a/mobile/src/session/use-mobile-native-chat-image-attachments.ts b/mobile/src/session/use-mobile-native-chat-image-attachments.ts
index dbcb97df527..77d839adf34 100644
--- a/mobile/src/session/use-mobile-native-chat-image-attachments.ts
+++ b/mobile/src/session/use-mobile-native-chat-image-attachments.ts
@@ -1,18 +1,17 @@
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 {
- ImageLibraryPermissionError,
- pickMobileImages,
- type MobileImageSource
-} from './mobile-image-source-picker'
+import type { MobileImageSource } from './mobile-image-source-picker'
import {
appendPendingNativeChatImages,
- uploadMobileNativeChatImages,
type PendingNativeChatImage
} from './mobile-native-chat-image-attachment'
+import {
+ NO_NATIVE_CHAT_IMAGE_ATTACHMENTS,
+ withScopeAttachments,
+ type MobileNativeChatImagesByScope
+} from './mobile-native-chat-image-scope-state'
import {
MOBILE_NATIVE_CHAT_IMAGE_SETTLE_MS,
pasteMobileNativeChatImagePaths
@@ -31,6 +30,7 @@ import {
acquireMobileNativeChatTerminalWrite,
releaseMobileNativeChatTerminalWrite
} from './mobile-native-chat-terminal-write-lock'
+import { useMobileNativeChatImageUpload } from './use-mobile-native-chat-image-upload'
type CurrentRef = { readonly current: T }
type ShowToast = (message: string, durationMs?: number) => void
@@ -60,8 +60,11 @@ type Args = {
readonly baseSend: (
text: string,
imagePreviewUris?: string[],
- deadline?: number
+ deadline?: number,
+ attachments?: readonly PendingNativeChatImage[]
) => Promise
+ /** Structured sessions send attachments without the terminal paste path. */
+ readonly structuredNativeChat: boolean
/** 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. */
@@ -83,21 +86,6 @@ export type MobileNativeChatImageAttachments = {
readonly sendNativeChat: (text: string) => Promise
}
-const NO_ATTACHMENTS: PendingNativeChatImage[] = []
-
-function withScopeAttachments(
- byScope: Record,
- scope: string,
- next: PendingNativeChatImage[]
-): Record {
- if (next.length > 0) {
- return { ...byScope, [scope]: next }
- }
- const remaining = { ...byScope }
- delete remaining[scope]
- return remaining
-}
-
const defaultSleep = (ms: number): Promise =>
new Promise((resolve) => setTimeout(resolve, ms))
@@ -112,98 +100,40 @@ export function useMobileNativeChatImageAttachments({
showToast,
onSendError,
baseSend,
+ structuredNativeChat,
readSeededLaunchDraft,
onAttachSuccess,
onError,
sleep = defaultSleep
}: Args): MobileNativeChatImageAttachments {
- const [attachmentsByScope, setAttachmentsByScope] = useState<
- Record
- >({})
- const [isAttaching, setIsAttaching] = useState(false)
+ const [attachmentsByScope, setAttachmentsByScope] = useState({})
const idCounter = useRef(0)
- // Count in-flight uploads so an overlapping attach can't clear the flag early.
- const attachingCount = useRef(0)
- // Live connState for attachImage's catch: the closure's value was already
- // checked 'connected' at entry, so only a ref can see a mid-upload disconnect.
- const connStateRef = useRef(connState)
- connStateRef.current = connState
+ const attachments =
+ (scopeKey ? attachmentsByScope[scopeKey] : undefined) ?? NO_NATIVE_CHAT_IMAGE_ATTACHMENTS
- const attachments = (scopeKey ? attachmentsByScope[scopeKey] : undefined) ?? NO_ATTACHMENTS
-
- const attachImage = useCallback(
- async (source: MobileImageSource): Promise => {
- // The chip lands in the scope that initiated the pick, even if the user
- // switches tabs while the upload is in flight.
- const scope = scopeKey
- if (!client || !scope || !activeHandleRef.current || connState !== 'connected') {
- return
- }
- // Only this call's own increment may be undone in `finally`; a cancelled
- // pick or pre-upload error never ran `onUploadStart`, so decrementing the
- // shared counter would clear a concurrent upload's in-flight flag early.
- let started = false
- const uploadedImages: Omit[] = []
- let uploadError: unknown = null
- try {
- await uploadMobileNativeChatImages(source, {
- client,
- getConnectionId: getActiveWorktreeConnectionId,
- pickImages: pickMobileImages,
- onImageUploaded: (image) => uploadedImages.push(image),
- onUploadStart: () => {
- started = true
- attachingCount.current += 1
- setIsAttaching(true)
- }
- })
- } catch (error) {
- uploadError = error
- } finally {
- if (started) {
- attachingCount.current -= 1
- if (attachingCount.current === 0) {
- setIsAttaching(false)
- }
- }
- }
- if (uploadedImages.length > 0) {
- setAttachmentsByScope((prev) => ({
- ...prev,
- [scope]: appendPendingNativeChatImages(prev[scope] ?? [], uploadedImages, idCounter)
- }))
- onAttachSuccess?.()
- }
- if (uploadError !== null) {
- const message = uploadError instanceof Error ? uploadError.message : String(uploadError)
- onError?.()
- if (connStateRef.current !== 'connected') {
- showToast('Attach failed (disconnected)', 1500)
- return
- }
- if (uploadError instanceof ImageLibraryPermissionError) {
- showToast('Photo permission denied', 1500)
- return
- }
- if (message === CLIPBOARD_IMAGE_TOO_LARGE_ERROR) {
- showToast('Image too large to attach', 1500)
- return
- }
- showToast('Attach failed', 1500)
- }
+ const addUploadedImages = useCallback(
+ (scope: string, uploadedImages: Omit[]) => {
+ setAttachmentsByScope((prev) => ({
+ ...prev,
+ [scope]: appendPendingNativeChatImages(prev[scope] ?? [], uploadedImages, idCounter)
+ }))
},
- [
- activeHandleRef,
- client,
- connState,
- getActiveWorktreeConnectionId,
- onAttachSuccess,
- onError,
- scopeKey,
- showToast
- ]
+ []
)
+ const { attachImage, isAttaching } = useMobileNativeChatImageUpload({
+ client,
+ activeHandleRef,
+ getActiveWorktreeConnectionId,
+ connState,
+ scopeKey,
+ structuredNativeChat,
+ showToast,
+ onImagesUploaded: addUploadedImages,
+ onAttachSuccess,
+ onError
+ })
+
const removeAttachment = useCallback(
(id: string): void => {
const scope = scopeKey
@@ -238,7 +168,32 @@ export function useMobileNativeChatImageAttachments({
const deadline = openMobileNativeChatSendBudget()
try {
const scope = scopeKey
- const pendingImages = (scope ? attachmentsByScope[scope] : undefined) ?? NO_ATTACHMENTS
+ const pendingImages =
+ (scope ? attachmentsByScope[scope] : undefined) ?? NO_NATIVE_CHAT_IMAGE_ATTACHMENTS
+ if (structuredNativeChat && pendingImages.length > 0 && scope) {
+ if (!client || !enabled || connState !== 'connected') {
+ onError?.()
+ onSendError('Message not sent (disconnected)')
+ return false
+ }
+ const outcome = await baseSend(
+ text,
+ pendingImages.map((attachment) => attachment.previewUri),
+ deadline,
+ pendingImages
+ )
+ if (outcome !== 'rejected') {
+ const sentIds = new Set(pendingImages.map((attachment) => attachment.id))
+ setAttachmentsByScope((prev) =>
+ withScopeAttachments(
+ prev,
+ scope,
+ (prev[scope] ?? []).filter((attachment) => !sentIds.has(attachment.id))
+ )
+ )
+ }
+ return outcome !== 'rejected'
+ }
if (pendingImages.length === 0 || !scope) {
// Heal a previously failed paste: a text-only send to that terminal would
// otherwise glue the stale image paste onto this message. Best-effort —
diff --git a/mobile/src/session/use-mobile-native-chat-image-upload.ts b/mobile/src/session/use-mobile-native-chat-image-upload.ts
new file mode 100644
index 00000000000..01567b5c727
--- /dev/null
+++ b/mobile/src/session/use-mobile-native-chat-image-upload.ts
@@ -0,0 +1,126 @@
+import { useCallback, useLayoutEffect, useRef, useState } from 'react'
+import { CLIPBOARD_IMAGE_TOO_LARGE_ERROR } from '../../../src/shared/clipboard-image'
+import type { RpcClient } from '../transport/rpc-client'
+import type { ConnectionState } from '../transport/types'
+import {
+ ImageLibraryPermissionError,
+ pickMobileImages,
+ type MobileImageSource
+} from './mobile-image-source-picker'
+import {
+ uploadMobileNativeChatImages,
+ type PendingNativeChatImage
+} from './mobile-native-chat-image-attachment'
+
+type CurrentRef = { readonly current: T }
+type UploadedNativeChatImage = Omit
+type ShowToast = (message: string, durationMs?: number) => void
+
+export function useMobileNativeChatImageUpload(args: {
+ client: RpcClient | null
+ activeHandleRef: CurrentRef