mirror of
https://github.com/stablyai/orca.git
synced 2026-10-22 00:03:47 +00:00
Revert "refactor(native-chat): split TUI toggle from structured core"
This reverts commit 87759c4463.
This commit is contained in:
@@ -229,6 +229,7 @@ import { TerminalPaneView } from '../../../../src/session/TerminalPaneView'
|
||||
import { MobileNativeChatOverlay } from '../../../../src/session/MobileNativeChatOverlay'
|
||||
import { MobileStructuredAgentSessionView } from '../../../../src/session/MobileStructuredAgentSessionView'
|
||||
import { MobileStructuredSessionCreateError } from '../../../../src/session/MobileStructuredSessionCreateError'
|
||||
import * as mobileStructuredTuiSend from '../../../../src/session/mobile-structured-tui-send'
|
||||
import { useMobileStructuredSessionEntry } from '../../../../src/session/use-mobile-structured-session-entry'
|
||||
import { showMobileStructuredChatChoice } from '../../../../src/session/mobile-structured-session-create'
|
||||
import { MobileBrowserTabActionSheet } from '../../../../src/session/MobileBrowserTabActionSheet'
|
||||
@@ -4675,6 +4676,19 @@ export default function SessionScreen() {
|
||||
}
|
||||
return accepted
|
||||
}}
|
||||
onTuiSend={(text, restored) =>
|
||||
mobileStructuredTuiSend.sendMobileStructuredTuiComposerMessage({
|
||||
client,
|
||||
connected: connState === 'connected',
|
||||
agent: activeStructuredTab.agent,
|
||||
handoff: structuredSessionEntry.session.handoff,
|
||||
deviceToken: deviceTokenRef.current,
|
||||
text,
|
||||
attachments: [...restored, ...structuredSessionEntry.attachments.attachments],
|
||||
onAccepted: structuredSessionEntry.attachments.clear,
|
||||
onToast: showToast
|
||||
})
|
||||
}
|
||||
onTakeQueuedForEdit={structuredSessionEntry.writes.takeQueuedForEdit}
|
||||
onRetry={structuredSessionEntry.writes.retry}
|
||||
onRespondToPrompt={structuredSessionEntry.writes.respondToPrompt}
|
||||
@@ -4684,6 +4698,8 @@ export default function SessionScreen() {
|
||||
onAttachImage={() => void structuredSessionEntry.attachments.attach('library')}
|
||||
onRemoveAttachment={structuredSessionEntry.attachments.remove}
|
||||
onCancel={structuredSessionEntry.writes.cancel}
|
||||
handoff={structuredSessionEntry.session.handoff}
|
||||
onRequestHandoff={structuredSessionEntry.writes.requestHandoff}
|
||||
/>
|
||||
) : activePendingTerminalTab ? (
|
||||
<View style={styles.emptyState}>
|
||||
|
||||
@@ -50,6 +50,7 @@ function options(): MobileNativeChatSessionOptionsController {
|
||||
describe('MobileStructuredAgentSessionView command seam', () => {
|
||||
let renderer: ReactTestRenderer | null = null
|
||||
const onSend = vi.fn(async () => true)
|
||||
const onTuiSend = vi.fn(async () => true)
|
||||
let controller: MobileNativeChatSessionOptionsController
|
||||
|
||||
function props(
|
||||
@@ -63,6 +64,7 @@ describe('MobileStructuredAgentSessionView command seam', () => {
|
||||
onLoadOlder: async () => false,
|
||||
outbox: [],
|
||||
onSend,
|
||||
onTuiSend,
|
||||
onTakeQueuedForEdit: async () => null,
|
||||
onRetry: async () => {},
|
||||
onRespondToPrompt: async () => true,
|
||||
@@ -72,6 +74,8 @@ describe('MobileStructuredAgentSessionView command seam', () => {
|
||||
onAttachImage: () => {},
|
||||
onRemoveAttachment: () => {},
|
||||
onCancel: async () => true,
|
||||
handoff: null,
|
||||
onRequestHandoff: async () => true,
|
||||
...overrides
|
||||
}
|
||||
}
|
||||
@@ -85,6 +89,7 @@ describe('MobileStructuredAgentSessionView command seam', () => {
|
||||
beforeEach(() => {
|
||||
globalThis.IS_REACT_ACT_ENVIRONMENT = true
|
||||
onSend.mockClear()
|
||||
onTuiSend.mockClear()
|
||||
controller = options()
|
||||
render()
|
||||
})
|
||||
@@ -109,6 +114,27 @@ describe('MobileStructuredAgentSessionView command seam', () => {
|
||||
expect(onSend).not.toHaveBeenCalled()
|
||||
})
|
||||
|
||||
it('disables zero-turn TUI resume with truthful guidance', () => {
|
||||
render({
|
||||
handoff: {
|
||||
owner: 'native',
|
||||
direction: null,
|
||||
phase: 'idle',
|
||||
stage: null,
|
||||
operationId: null
|
||||
}
|
||||
})
|
||||
|
||||
const label = renderer!.root
|
||||
.findAllByType('Text')
|
||||
.find((node) => node.children.includes('Open agent TUI'))
|
||||
expect(label?.parent?.props).toMatchObject({
|
||||
disabled: true,
|
||||
accessibilityState: { disabled: true },
|
||||
accessibilityHint: 'Send a message first to open the agent TUI.'
|
||||
})
|
||||
})
|
||||
|
||||
it('renders an explicit refusal for unsupported advertised commands', async () => {
|
||||
await submit('/review')
|
||||
|
||||
@@ -118,9 +144,87 @@ describe('MobileStructuredAgentSessionView command seam', () => {
|
||||
).toEqual(['/review is not available in chat sessions.'])
|
||||
})
|
||||
|
||||
it('keeps the composer enabled and routes stable TUI ownership through its bridge', async () => {
|
||||
act(() => {
|
||||
renderer!.update(
|
||||
createElement(
|
||||
MobileStructuredAgentSessionView,
|
||||
props({
|
||||
handoff: {
|
||||
owner: 'tui',
|
||||
direction: null,
|
||||
phase: 'idle',
|
||||
stage: null,
|
||||
operationId: null,
|
||||
terminal: { handle: 'term-tui', tabId: 'tab-tui', paneKey: 'pane-tui' }
|
||||
}
|
||||
})
|
||||
)
|
||||
)
|
||||
})
|
||||
|
||||
expect(renderer!.root.findByType('MobileNativeChatComposer').props.disabled).toBe(false)
|
||||
await submit('hello from mobile')
|
||||
expect(onTuiSend).toHaveBeenCalledWith('hello from mobile', [])
|
||||
expect(onSend).not.toHaveBeenCalled()
|
||||
})
|
||||
|
||||
it('routes stable TUI slash commands to typed terminal dispatch', async () => {
|
||||
act(() => {
|
||||
renderer!.update(
|
||||
createElement(
|
||||
MobileStructuredAgentSessionView,
|
||||
props({
|
||||
handoff: {
|
||||
owner: 'tui',
|
||||
direction: null,
|
||||
phase: 'idle',
|
||||
stage: null,
|
||||
operationId: null,
|
||||
terminal: { handle: 'term-tui', tabId: 'tab-tui', paneKey: 'pane-tui' }
|
||||
}
|
||||
})
|
||||
)
|
||||
)
|
||||
})
|
||||
|
||||
await submit('/model')
|
||||
expect(onTuiSend).toHaveBeenCalledWith('/model', [])
|
||||
expect(controller.invokeAction).not.toHaveBeenCalled()
|
||||
expect(onSend).not.toHaveBeenCalled()
|
||||
})
|
||||
|
||||
it('disables and refuses composer sends while ownership is switching', async () => {
|
||||
act(() => {
|
||||
renderer!.update(
|
||||
createElement(
|
||||
MobileStructuredAgentSessionView,
|
||||
props({
|
||||
handoff: {
|
||||
owner: 'native',
|
||||
direction: 'to-tui',
|
||||
phase: 'switching',
|
||||
stage: 'preparing',
|
||||
operationId: 'handoff-1'
|
||||
}
|
||||
})
|
||||
)
|
||||
)
|
||||
})
|
||||
|
||||
const composer = renderer!.root.findByType('MobileNativeChatComposer')
|
||||
expect(composer.props.disabled).toBe(true)
|
||||
await act(async () => {
|
||||
expect(await composer.props.onSend('racing send')).toBe(false)
|
||||
})
|
||||
expect(onSend).not.toHaveBeenCalled()
|
||||
expect(onTuiSend).not.toHaveBeenCalled()
|
||||
})
|
||||
|
||||
it('keeps native ownership on the structured agent-session send path', async () => {
|
||||
await submit('native message')
|
||||
|
||||
expect(onSend).toHaveBeenCalledWith('native message', [])
|
||||
expect(onTuiSend).not.toHaveBeenCalled()
|
||||
})
|
||||
})
|
||||
|
||||
@@ -11,6 +11,11 @@ import {
|
||||
} from 'react-native'
|
||||
import { Square } from 'lucide-react-native'
|
||||
import type { AgentJournalRenderItem } from '../../../src/shared/agent-session-journal-types'
|
||||
import type {
|
||||
AgentSessionHandoffDirection,
|
||||
AgentSessionHandoffMode,
|
||||
AgentSessionHandoffStatus
|
||||
} from '../../../src/shared/agent-session-wire'
|
||||
import { colors } from '../theme/mobile-theme'
|
||||
import { MobileNativeChatComposer } from './MobileNativeChatComposer'
|
||||
import { MobileNativeChatMessage } from './MobileNativeChatMessage'
|
||||
@@ -28,6 +33,7 @@ import {
|
||||
restoreMobileStructuredAttachments,
|
||||
type MobileStructuredTimelineRow
|
||||
} from './mobile-structured-session-timeline'
|
||||
import { hasPersistedStructuredAgentSessionTurn } from '../../../src/shared/structured-agent-session-projection'
|
||||
import {
|
||||
admitStructuredOlderPage,
|
||||
beginStructuredUserScroll,
|
||||
@@ -54,6 +60,7 @@ type Props = {
|
||||
onOpenFile?: (path: string) => void
|
||||
outbox: MobileStructuredOutboxEntry[]
|
||||
onSend: (text: string, restored: readonly PendingNativeChatImage[]) => Promise<boolean>
|
||||
onTuiSend?: (text: string, restored: readonly PendingNativeChatImage[]) => Promise<boolean>
|
||||
onTakeQueuedForEdit: (clientMessageId: string) => Promise<MobileStructuredOutboxEntry | null>
|
||||
onRetry: (clientMessageId: string) => Promise<void>
|
||||
onRespondToPrompt: (item: MobileStructuredPromptItem, optionId: string) => Promise<boolean>
|
||||
@@ -63,6 +70,12 @@ type Props = {
|
||||
onAttachImage: () => void
|
||||
onRemoveAttachment: (id: string) => void
|
||||
onCancel: (turnId: string) => Promise<boolean>
|
||||
handoff: AgentSessionHandoffStatus | null
|
||||
onRequestHandoff: (
|
||||
direction: AgentSessionHandoffDirection,
|
||||
mode: AgentSessionHandoffMode,
|
||||
action?: 'start' | 'cancel-queued' | 'retry' | 'recover'
|
||||
) => Promise<boolean>
|
||||
}
|
||||
|
||||
export function MobileStructuredAgentSessionView(props: Props): React.JSX.Element {
|
||||
@@ -75,7 +88,16 @@ export function MobileStructuredAgentSessionView(props: Props): React.JSX.Elemen
|
||||
const [commandError, setCommandError] = useState<string | null>(null)
|
||||
const turnId = activeMobileStructuredTurnId(props.items)
|
||||
const allAttachments = [...restored, ...props.attachments]
|
||||
const composerEnabled = props.status === 'ready'
|
||||
const stableTuiOwner =
|
||||
props.handoff?.owner === 'tui' &&
|
||||
props.handoff.phase === 'idle' &&
|
||||
Boolean(props.handoff.terminal?.handle) &&
|
||||
Boolean(props.onTuiSend)
|
||||
const stableNativeOwner =
|
||||
props.handoff === null ||
|
||||
props.handoff === undefined ||
|
||||
(props.handoff.owner === 'native' && props.handoff.phase === 'idle')
|
||||
const composerEnabled = props.status === 'ready' && (stableNativeOwner || stableTuiOwner)
|
||||
const rows = useMemo(
|
||||
() => buildMobileStructuredTimeline(props.items, props.outbox),
|
||||
[props.items, props.outbox]
|
||||
@@ -104,6 +126,12 @@ export function MobileStructuredAgentSessionView(props: Props): React.JSX.Elemen
|
||||
|
||||
return (
|
||||
<View style={styles.root}>
|
||||
<MobileStructuredHandoffBanner
|
||||
status={props.handoff}
|
||||
isWorking={turnId !== null}
|
||||
hasPersistedTurn={hasPersistedStructuredAgentSessionTurn(props.items)}
|
||||
onRequest={props.onRequestHandoff}
|
||||
/>
|
||||
<FlatList
|
||||
ref={listRef}
|
||||
inverted
|
||||
@@ -215,6 +243,15 @@ export function MobileStructuredAgentSessionView(props: Props): React.JSX.Elemen
|
||||
setCommandError('Remove attachments before using a chat-session command.')
|
||||
return false
|
||||
}
|
||||
if (stableTuiOwner) {
|
||||
setCommandError(null)
|
||||
const accepted = await props.onTuiSend!(text, restored)
|
||||
if (accepted) {
|
||||
setComposerText('')
|
||||
setRestored([])
|
||||
}
|
||||
return accepted
|
||||
}
|
||||
const command = await dispatchMobileStructuredComposerCommand(text, props.sessionOptions)
|
||||
if (command.handled) {
|
||||
setCommandError(command.error)
|
||||
@@ -245,8 +282,116 @@ export function MobileStructuredAgentSessionView(props: Props): React.JSX.Elemen
|
||||
}}
|
||||
isAttaching={props.isAttaching}
|
||||
disabled={!composerEnabled}
|
||||
placeholder={props.status === 'ready' ? 'Message Codex' : 'Reconnecting…'}
|
||||
placeholder={
|
||||
props.handoff?.owner === 'tui' && !stableTuiOwner
|
||||
? 'Agent is open in terminal'
|
||||
: props.status === 'ready'
|
||||
? 'Message Codex'
|
||||
: 'Reconnecting…'
|
||||
}
|
||||
/>
|
||||
</View>
|
||||
)
|
||||
}
|
||||
|
||||
function MobileStructuredHandoffBanner(props: {
|
||||
status: AgentSessionHandoffStatus | null
|
||||
isWorking: boolean
|
||||
hasPersistedTurn: boolean
|
||||
onRequest: Props['onRequestHandoff']
|
||||
}): React.JSX.Element | null {
|
||||
const status = props.status
|
||||
if (!status) {
|
||||
return null
|
||||
}
|
||||
if (status.owner === 'native' && status.phase === 'idle') {
|
||||
return (
|
||||
<View style={styles.handoffBar}>
|
||||
{props.isWorking ? (
|
||||
<>
|
||||
<Pressable
|
||||
style={({ pressed }) => [styles.handoffButton, pressed && styles.pressed]}
|
||||
onPress={() => void props.onRequest('to-tui', 'after-turn')}
|
||||
>
|
||||
<Text style={styles.handoffButtonText}>Switch after this turn</Text>
|
||||
</Pressable>
|
||||
<Pressable
|
||||
style={({ pressed }) => [styles.handoffButton, pressed && styles.pressed]}
|
||||
onPress={() => void props.onRequest('to-tui', 'stop-turn')}
|
||||
>
|
||||
<Text style={styles.handoffButtonText}>Stop turn and switch</Text>
|
||||
</Pressable>
|
||||
</>
|
||||
) : (
|
||||
<Pressable
|
||||
style={({ pressed }) => [
|
||||
styles.handoffButton,
|
||||
!props.hasPersistedTurn && styles.disabled,
|
||||
pressed && props.hasPersistedTurn && styles.pressed
|
||||
]}
|
||||
disabled={!props.hasPersistedTurn}
|
||||
accessibilityRole="button"
|
||||
accessibilityState={{ disabled: !props.hasPersistedTurn }}
|
||||
accessibilityHint={
|
||||
props.hasPersistedTurn ? undefined : 'Send a message first to open the agent TUI.'
|
||||
}
|
||||
// A submitted turn can reach the host before isWorking updates; after-turn is immediate when idle.
|
||||
onPress={() => void props.onRequest('to-tui', 'after-turn')}
|
||||
>
|
||||
<Text style={styles.handoffButtonText}>Open agent TUI</Text>
|
||||
</Pressable>
|
||||
)}
|
||||
</View>
|
||||
)
|
||||
}
|
||||
const copy =
|
||||
status.phase === 'waiting-for-exit'
|
||||
? 'Exit the agent terminal to continue in chat.'
|
||||
: status.phase === 'switching'
|
||||
? mobileHandoffStageCopy(status)
|
||||
: (status.error?.message ??
|
||||
`Agent is open in terminal${status.hostLabel ? ` on ${status.hostLabel}` : ''}`)
|
||||
return (
|
||||
<View style={[styles.handoffBanner, status.error ? styles.handoffError : null]}>
|
||||
<Text style={status.error ? styles.handoffErrorText : styles.handoffText}>{copy}</Text>
|
||||
{status.phase === 'queued' && status.direction ? (
|
||||
<Pressable
|
||||
onPress={() => void props.onRequest(status.direction!, 'after-turn', 'cancel-queued')}
|
||||
>
|
||||
<Text style={styles.handoffButtonText}>Cancel</Text>
|
||||
</Pressable>
|
||||
) : status.owner === 'tui' && status.phase === 'idle' ? (
|
||||
<Pressable onPress={() => void props.onRequest('to-native', 'after-turn')}>
|
||||
<Text style={styles.handoffButtonText}>Return to chat</Text>
|
||||
</Pressable>
|
||||
) : status.phase === 'failed' && status.direction && status.error?.canRetryProof ? (
|
||||
<Pressable onPress={() => void props.onRequest(status.direction!, 'now', 'recover')}>
|
||||
<Text style={styles.handoffButtonText}>Retry proof</Text>
|
||||
</Pressable>
|
||||
) : status.phase === 'failed' &&
|
||||
status.direction &&
|
||||
status.error?.recoverableOwner !== 'none' ? (
|
||||
<Pressable onPress={() => void props.onRequest(status.direction!, 'now', 'retry')}>
|
||||
<Text style={styles.handoffButtonText}>Retry</Text>
|
||||
</Pressable>
|
||||
) : null}
|
||||
</View>
|
||||
)
|
||||
}
|
||||
|
||||
function mobileHandoffStageCopy(status: AgentSessionHandoffStatus): string {
|
||||
switch (status.stage) {
|
||||
case 'preparing':
|
||||
return status.direction === 'to-tui' ? 'Finishing chat session…' : 'Finishing agent terminal…'
|
||||
case 'old-owner-stopped':
|
||||
return status.direction === 'to-tui' ? 'Opening agent terminal…' : 'Resuming chat session…'
|
||||
case 'new-owner-proving':
|
||||
return status.direction === 'to-tui' ? 'Verifying agent terminal…' : 'Verifying chat session…'
|
||||
case 'recovering':
|
||||
return 'Recovering agent session…'
|
||||
case 'manual-recovery':
|
||||
return 'Agent session needs recovery'
|
||||
default:
|
||||
return 'Switching session owner…'
|
||||
}
|
||||
}
|
||||
|
||||
@@ -36,10 +36,36 @@ export const styles = StyleSheet.create({
|
||||
},
|
||||
cancelText: { color: colors.statusRed, fontSize: typography.metaSize, fontWeight: '600' },
|
||||
pressed: { opacity: 0.7 },
|
||||
disabled: { opacity: 0.45 },
|
||||
writeError: {
|
||||
alignItems: 'center',
|
||||
paddingHorizontal: spacing.lg,
|
||||
paddingVertical: spacing.sm
|
||||
},
|
||||
writeErrorText: { color: colors.statusRed, fontSize: typography.metaSize }
|
||||
writeErrorText: { color: colors.statusRed, fontSize: typography.metaSize },
|
||||
handoffBar: {
|
||||
flexDirection: 'row',
|
||||
justifyContent: 'flex-end',
|
||||
alignItems: 'center',
|
||||
gap: spacing.xs,
|
||||
paddingHorizontal: spacing.lg,
|
||||
paddingVertical: spacing.xs
|
||||
},
|
||||
handoffBanner: {
|
||||
minHeight: 42,
|
||||
flexDirection: 'row',
|
||||
alignItems: 'center',
|
||||
justifyContent: 'space-between',
|
||||
gap: spacing.sm,
|
||||
paddingHorizontal: spacing.lg,
|
||||
paddingVertical: spacing.sm,
|
||||
backgroundColor: colors.bgRaised,
|
||||
borderBottomWidth: StyleSheet.hairlineWidth,
|
||||
borderBottomColor: colors.borderSubtle
|
||||
},
|
||||
handoffError: { backgroundColor: colors.bgRaised },
|
||||
handoffText: { flex: 1, color: colors.textSecondary, fontSize: typography.metaSize },
|
||||
handoffErrorText: { flex: 1, color: colors.statusRed, fontSize: typography.metaSize },
|
||||
handoffButton: { paddingHorizontal: spacing.sm, paddingVertical: spacing.xs },
|
||||
handoffButtonText: { color: colors.textPrimary, fontSize: typography.metaSize, fontWeight: '600' }
|
||||
})
|
||||
|
||||
@@ -0,0 +1,131 @@
|
||||
import { afterEach, describe, expect, it, vi } from 'vitest'
|
||||
import type { RpcClient } from '../transport/rpc-client'
|
||||
import { resetMobileNativeChatStaleInputForTests } from './mobile-native-chat-stale-input'
|
||||
import { resetMobileNativeChatTerminalWritesForTests } from './mobile-native-chat-terminal-write-lock'
|
||||
import {
|
||||
sendMobileStructuredTuiComposerMessage,
|
||||
sendMobileStructuredTuiMessage
|
||||
} from './mobile-structured-tui-send'
|
||||
|
||||
function acceptingClient(): RpcClient {
|
||||
return {
|
||||
sendRequest: vi.fn().mockResolvedValue({
|
||||
id: 'send',
|
||||
ok: true,
|
||||
result: { send: { accepted: true } },
|
||||
_meta: { runtimeId: 'runtime' }
|
||||
})
|
||||
} as unknown as RpcClient
|
||||
}
|
||||
|
||||
afterEach(() => {
|
||||
vi.useRealTimers()
|
||||
resetMobileNativeChatStaleInputForTests()
|
||||
resetMobileNativeChatTerminalWritesForTests()
|
||||
})
|
||||
|
||||
describe('sendMobileStructuredTuiMessage', () => {
|
||||
it('routes stable TUI composer text through terminal.send', async () => {
|
||||
const client = acceptingClient()
|
||||
|
||||
await expect(
|
||||
sendMobileStructuredTuiMessage({
|
||||
client,
|
||||
terminal: 'term-tui',
|
||||
agent: 'codex',
|
||||
deviceToken: 'mobile-1',
|
||||
text: 'hello',
|
||||
attachments: []
|
||||
})
|
||||
).resolves.toBe('accepted')
|
||||
|
||||
expect(client.sendRequest).toHaveBeenCalledWith(
|
||||
'terminal.send',
|
||||
{
|
||||
terminal: 'term-tui',
|
||||
text: '\x15hello',
|
||||
enter: true,
|
||||
client: { id: 'mobile-1', type: 'mobile' }
|
||||
},
|
||||
expect.objectContaining({ budgetSpansConnect: true })
|
||||
)
|
||||
})
|
||||
|
||||
it('types stable TUI slash commands one key at a time', async () => {
|
||||
vi.useFakeTimers()
|
||||
const client = acceptingClient()
|
||||
|
||||
const sending = sendMobileStructuredTuiMessage({
|
||||
client,
|
||||
terminal: 'term-tui',
|
||||
agent: 'codex',
|
||||
deviceToken: null,
|
||||
text: '/model',
|
||||
attachments: []
|
||||
})
|
||||
await vi.runAllTimersAsync()
|
||||
await expect(sending).resolves.toBe('accepted')
|
||||
|
||||
expect(
|
||||
vi
|
||||
.mocked(client.sendRequest)
|
||||
.mock.calls.map(([, params]) => (params as { text: string }).text)
|
||||
).toEqual(['\x15', '/', 'm', 'o', 'd', 'e', 'l', '\r'])
|
||||
expect(
|
||||
vi
|
||||
.mocked(client.sendRequest)
|
||||
.mock.calls.every(
|
||||
([method, params]) =>
|
||||
method === 'terminal.send' && (params as { enter: boolean }).enter === false
|
||||
)
|
||||
).toBe(true)
|
||||
})
|
||||
|
||||
it('types Claude-only slash commands through the provider command path', async () => {
|
||||
vi.useFakeTimers()
|
||||
const client = acceptingClient()
|
||||
|
||||
const sending = sendMobileStructuredTuiMessage({
|
||||
client,
|
||||
terminal: 'term-claude',
|
||||
agent: 'claude',
|
||||
deviceToken: null,
|
||||
text: '/clear',
|
||||
attachments: []
|
||||
})
|
||||
await vi.runAllTimersAsync()
|
||||
await expect(sending).resolves.toBe('accepted')
|
||||
|
||||
expect(
|
||||
vi
|
||||
.mocked(client.sendRequest)
|
||||
.mock.calls.map(([, params]) => (params as { text: string }).text)
|
||||
).toEqual(['\x15', '/', 'c', 'l', 'e', 'a', 'r', '\r'])
|
||||
})
|
||||
|
||||
it('settles the composer only after its proven TUI owner accepts the send', async () => {
|
||||
const onAccepted = vi.fn()
|
||||
|
||||
await expect(
|
||||
sendMobileStructuredTuiComposerMessage({
|
||||
client: acceptingClient(),
|
||||
connected: true,
|
||||
agent: 'claude',
|
||||
handoff: {
|
||||
owner: 'tui',
|
||||
direction: null,
|
||||
phase: 'idle',
|
||||
stage: null,
|
||||
operationId: null,
|
||||
terminal: { handle: 'term-claude', tabId: 'tab-claude', paneKey: 'pane-claude' }
|
||||
},
|
||||
deviceToken: null,
|
||||
text: 'hello',
|
||||
attachments: [],
|
||||
onAccepted,
|
||||
onToast: vi.fn()
|
||||
})
|
||||
).resolves.toBe(true)
|
||||
expect(onAccepted).toHaveBeenCalledOnce()
|
||||
})
|
||||
})
|
||||
@@ -0,0 +1,153 @@
|
||||
import { isSlashCommandDraft } from '../../../src/shared/native-chat-slash-commands'
|
||||
import type { AgentSessionHandoffStatus } from '../../../src/shared/agent-session-wire'
|
||||
import type { RpcClient } from '../transport/rpc-client'
|
||||
import type { PendingNativeChatImage } from './mobile-native-chat-image-attachment'
|
||||
import {
|
||||
MOBILE_NATIVE_CHAT_IMAGE_SETTLE_MS,
|
||||
pasteMobileNativeChatImagePaths
|
||||
} from './mobile-native-chat-image-send'
|
||||
import {
|
||||
openMobileNativeChatSendBudget,
|
||||
sendMobileNativeChatMessageWithOutcome,
|
||||
typeMobileNativeChatCommandWithOutcome,
|
||||
type MobileNativeChatSendOutcome
|
||||
} from './mobile-native-chat-send'
|
||||
import { classifyMobileNativeChatSend } from './mobile-native-chat-send-classification'
|
||||
import {
|
||||
clearMobileNativeChatInputStale,
|
||||
healMobileNativeChatStaleInput,
|
||||
markMobileNativeChatInputStale
|
||||
} from './mobile-native-chat-stale-input'
|
||||
import {
|
||||
acquireMobileNativeChatTerminalWrite,
|
||||
releaseMobileNativeChatTerminalWrite
|
||||
} from './mobile-native-chat-terminal-write-lock'
|
||||
|
||||
type MobileStructuredTuiSendArgs = {
|
||||
client: RpcClient
|
||||
terminal: string
|
||||
agent: 'claude' | 'codex'
|
||||
deviceToken: string | null
|
||||
text: string
|
||||
attachments: readonly PendingNativeChatImage[]
|
||||
sleep?: (ms: number) => Promise<void>
|
||||
}
|
||||
|
||||
const wait = (ms: number): Promise<void> =>
|
||||
new Promise((resolve) => {
|
||||
setTimeout(resolve, ms)
|
||||
})
|
||||
|
||||
export function getMobileStructuredTuiTerminal(
|
||||
handoff: AgentSessionHandoffStatus | null
|
||||
): string | null {
|
||||
return handoff?.owner === 'tui' && handoff.phase === 'idle'
|
||||
? (handoff.terminal?.handle ?? null)
|
||||
: null
|
||||
}
|
||||
|
||||
/** Sends the structured transcript composer through its proven TUI owner. */
|
||||
export async function sendMobileStructuredTuiMessage(
|
||||
args: MobileStructuredTuiSendArgs
|
||||
): Promise<MobileNativeChatSendOutcome> {
|
||||
if (!acquireMobileNativeChatTerminalWrite(args.terminal)) {
|
||||
return 'rejected'
|
||||
}
|
||||
try {
|
||||
const deadline = openMobileNativeChatSendBudget()
|
||||
const mobileClient = args.deviceToken
|
||||
? { id: args.deviceToken, type: 'mobile' as const }
|
||||
: undefined
|
||||
if (
|
||||
!(await healMobileNativeChatStaleInput({
|
||||
client: args.client,
|
||||
terminal: args.terminal,
|
||||
deviceToken: args.deviceToken,
|
||||
deadline
|
||||
}))
|
||||
) {
|
||||
return 'rejected'
|
||||
}
|
||||
if (args.attachments.length > 0) {
|
||||
const pasted = await pasteMobileNativeChatImagePaths({
|
||||
client: args.client,
|
||||
terminal: args.terminal,
|
||||
deviceToken: args.deviceToken,
|
||||
imagePaths: args.attachments.map((attachment) => attachment.path),
|
||||
deadline
|
||||
})
|
||||
if (!pasted) {
|
||||
markMobileNativeChatInputStale(args.terminal)
|
||||
return 'rejected'
|
||||
}
|
||||
clearMobileNativeChatInputStale(args.terminal)
|
||||
await (args.sleep ?? wait)(MOBILE_NATIVE_CHAT_IMAGE_SETTLE_MS)
|
||||
}
|
||||
const textDeadline =
|
||||
args.attachments.length > 0 ? deadline + MOBILE_NATIVE_CHAT_IMAGE_SETTLE_MS : deadline
|
||||
const classification = classifyMobileNativeChatSend(args.agent, args.text)
|
||||
const outcome =
|
||||
classification !== 'chat' && isSlashCommandDraft(args.text) && args.attachments.length === 0
|
||||
? await typeMobileNativeChatCommandWithOutcome({
|
||||
client: args.client,
|
||||
terminal: args.terminal,
|
||||
command: args.text,
|
||||
...(mobileClient ? { mobileClient } : {}),
|
||||
deadline: textDeadline
|
||||
})
|
||||
: await sendMobileNativeChatMessageWithOutcome({
|
||||
client: args.client,
|
||||
terminal: args.terminal,
|
||||
text: args.text,
|
||||
clearInputFirst: args.attachments.length === 0,
|
||||
...(mobileClient ? { mobileClient } : {}),
|
||||
deadline: textDeadline
|
||||
})
|
||||
if (args.attachments.length > 0 && outcome !== 'accepted') {
|
||||
markMobileNativeChatInputStale(args.terminal)
|
||||
}
|
||||
return outcome
|
||||
} catch {
|
||||
if (args.attachments.length > 0) {
|
||||
markMobileNativeChatInputStale(args.terminal)
|
||||
}
|
||||
return 'rejected'
|
||||
} finally {
|
||||
releaseMobileNativeChatTerminalWrite(args.terminal)
|
||||
}
|
||||
}
|
||||
|
||||
export async function sendMobileStructuredTuiComposerMessage(args: {
|
||||
client: RpcClient | null
|
||||
connected: boolean
|
||||
agent: 'claude' | 'codex'
|
||||
handoff: AgentSessionHandoffStatus | null
|
||||
deviceToken: string | null
|
||||
text: string
|
||||
attachments: readonly PendingNativeChatImage[]
|
||||
onAccepted: () => void
|
||||
onToast: (message: string, durationMs: number) => void
|
||||
}): Promise<boolean> {
|
||||
const terminal = getMobileStructuredTuiTerminal(args.handoff)
|
||||
if (!args.client || !args.connected || !terminal) {
|
||||
args.onToast('Message not sent (disconnected)', 1800)
|
||||
return false
|
||||
}
|
||||
const outcome = await sendMobileStructuredTuiMessage({
|
||||
client: args.client,
|
||||
terminal,
|
||||
agent: args.agent,
|
||||
deviceToken: args.deviceToken,
|
||||
text: args.text,
|
||||
attachments: args.attachments
|
||||
})
|
||||
if (outcome === 'rejected') {
|
||||
args.onToast('Message not sent', 1800)
|
||||
return false
|
||||
}
|
||||
args.onAccepted()
|
||||
if (outcome === 'unknown') {
|
||||
args.onToast('Delivery unconfirmed — check chat before retrying', 2400)
|
||||
}
|
||||
return true
|
||||
}
|
||||
@@ -2,6 +2,7 @@ import { useCallback, useEffect, useMemo, useReducer, useRef, useState } from 'r
|
||||
import { AppState, type AppStateStatus } from 'react-native'
|
||||
import type {
|
||||
AgentSessionHistoryResult,
|
||||
AgentSessionHandoffStatus,
|
||||
AgentSessionSubscribeEvent
|
||||
} from '../../../src/shared/agent-session-wire'
|
||||
import type { AgentJournalCursor } from '../../../src/shared/agent-session-journal-types'
|
||||
@@ -57,6 +58,7 @@ export function useMobileStructuredAgentSession(args: {
|
||||
hasOlder: boolean
|
||||
loadingOlder: boolean
|
||||
loadOlder: () => Promise<boolean>
|
||||
handoff: AgentSessionHandoffStatus | null
|
||||
} {
|
||||
const { client, sessionId } = args
|
||||
const [state, dispatch] = useReducer(reduceStructuredAgentSession, EMPTY_STRUCTURED_AGENT_SESSION)
|
||||
@@ -134,15 +136,23 @@ export function useMobileStructuredAgentSession(args: {
|
||||
}
|
||||
})
|
||||
}
|
||||
void client
|
||||
.sendRequest('agentSession.history', { sessionId, direction: 'tail', limit: 40 })
|
||||
.then((response) => {
|
||||
void Promise.all([
|
||||
client.sendRequest('agentSession.history', { sessionId, direction: 'tail', limit: 40 }),
|
||||
client.sendRequest('agentSession.handoffStatus', { sessionId }).catch(() => null)
|
||||
])
|
||||
.then(([response, handoffResponse]) => {
|
||||
if (closed) {
|
||||
return
|
||||
}
|
||||
if (!response.ok) {
|
||||
throw new Error(response.error.message)
|
||||
}
|
||||
if (handoffResponse?.ok) {
|
||||
dispatch({
|
||||
type: 'handoff',
|
||||
handoff: handoffResponse.result as AgentSessionHandoffStatus
|
||||
})
|
||||
}
|
||||
const result = response.result as AgentSessionHistoryResult
|
||||
if (result.ok) {
|
||||
dispatch({ type: 'tail-page', page: result.page })
|
||||
@@ -240,6 +250,7 @@ export function useMobileStructuredAgentSession(args: {
|
||||
error: state.error || undefined,
|
||||
hasOlder: state.hasOlder,
|
||||
loadingOlder,
|
||||
loadOlder
|
||||
loadOlder,
|
||||
handoff: state.handoff
|
||||
}
|
||||
}
|
||||
|
||||
@@ -49,7 +49,8 @@ export function useMobileStructuredSessionEntry(args: {
|
||||
connected,
|
||||
sessionId,
|
||||
fence: session.fence,
|
||||
submissions: session.submissions
|
||||
submissions: session.submissions,
|
||||
handoffOperationId: session.handoff?.operationId
|
||||
})
|
||||
const sessionOptions = useMobileStructuredSessionOptions({
|
||||
client,
|
||||
|
||||
@@ -6,6 +6,8 @@ import type {
|
||||
AgentJournalRenderItem
|
||||
} from '../../../src/shared/agent-session-journal-types'
|
||||
import type {
|
||||
AgentSessionHandoffDirection,
|
||||
AgentSessionHandoffMode,
|
||||
AgentSessionMutationResult,
|
||||
AgentSessionOptionResult,
|
||||
AgentSessionPromptResult
|
||||
@@ -27,12 +29,18 @@ export type MobileStructuredSessionMutations = {
|
||||
respondToPrompt: (item: MobileStructuredPromptItem, optionId: string) => Promise<boolean>
|
||||
setOption: (key: string, value: string) => Promise<AgentSessionOptionResult | null>
|
||||
cancel: (turnId: string) => Promise<boolean>
|
||||
requestHandoff: (
|
||||
direction: AgentSessionHandoffDirection,
|
||||
mode: AgentSessionHandoffMode,
|
||||
action?: 'start' | 'cancel-queued' | 'retry' | 'recover'
|
||||
) => Promise<boolean>
|
||||
}
|
||||
|
||||
export function useMobileStructuredSessionMutations(args: {
|
||||
client: RpcClient | null
|
||||
sessionId: string | null
|
||||
fence: number | null
|
||||
handoffOperationId?: string | null
|
||||
onRefusal: (message: string | null) => void
|
||||
}): MobileStructuredSessionMutations {
|
||||
const operationIdsRef = useRef(new Map<string, string>())
|
||||
@@ -134,9 +142,22 @@ export function useMobileStructuredSessionMutations(args: {
|
||||
value
|
||||
}),
|
||||
cancel: async (turnId: string) =>
|
||||
Boolean(await mutate('agentSession.cancel', 'agentSession.cancel', { turnId }))
|
||||
Boolean(await mutate('agentSession.cancel', 'agentSession.cancel', { turnId })),
|
||||
requestHandoff: async (
|
||||
direction: AgentSessionHandoffDirection,
|
||||
mode: AgentSessionHandoffMode,
|
||||
action: 'start' | 'cancel-queued' | 'retry' | 'recover' = 'start'
|
||||
) =>
|
||||
Boolean(
|
||||
await mutate(
|
||||
'agentSession.requestHandoff',
|
||||
'agentSession.requestHandoff',
|
||||
{ direction, mode, action },
|
||||
action === 'retry' ? args.handoffOperationId : null
|
||||
)
|
||||
)
|
||||
}),
|
||||
[mutate]
|
||||
[args.handoffOperationId, mutate]
|
||||
)
|
||||
}
|
||||
|
||||
|
||||
@@ -652,6 +652,56 @@ describe('useMobileStructuredSessionWrites', () => {
|
||||
expect(crypto.randomUUID).toHaveBeenCalledTimes(1)
|
||||
})
|
||||
|
||||
it('mints a fresh handoff operation after a settled refusal', async () => {
|
||||
sendRequest
|
||||
.mockResolvedValueOnce({
|
||||
id: 'handoff-stale',
|
||||
ok: true,
|
||||
_meta: { runtimeId: 'runtime-1' },
|
||||
result: {
|
||||
ok: false,
|
||||
refusal: {
|
||||
code: 'agent_session_checkpoint_stale',
|
||||
message: 'runtime fence advanced',
|
||||
currentFence: 4
|
||||
}
|
||||
}
|
||||
})
|
||||
.mockResolvedValueOnce({
|
||||
id: 'handoff-started',
|
||||
ok: true,
|
||||
_meta: { runtimeId: 'runtime-1' },
|
||||
result: {
|
||||
ok: true,
|
||||
replayed: false,
|
||||
fence: 3,
|
||||
cursor: { epoch: 'epoch-1', sequence: 2 },
|
||||
value: {
|
||||
status: {
|
||||
owner: 'native',
|
||||
direction: 'to-tui',
|
||||
phase: 'switching',
|
||||
stage: 'preparing',
|
||||
operationId: 'second-operation'
|
||||
}
|
||||
}
|
||||
}
|
||||
})
|
||||
|
||||
await act(async () => {
|
||||
expect(await api!.requestHandoff('to-tui', 'now')).toBe(false)
|
||||
expect(await api!.requestHandoff('to-tui', 'now')).toBe(true)
|
||||
})
|
||||
|
||||
const operationIds = sendRequest.mock.calls.map(
|
||||
([, params]) =>
|
||||
(params as { envelope: { clientOperationId: string } }).envelope.clientOperationId
|
||||
)
|
||||
expect(operationIds[0]).toMatch(/-00000000000040008000000000000001$/)
|
||||
expect(operationIds[1]).toMatch(/-00000000000040008000000000000002$/)
|
||||
expect(operationIds[1]).not.toBe(operationIds[0])
|
||||
})
|
||||
|
||||
it('isolates mutation ids and late errors across session changes', async () => {
|
||||
const first = deferred<RpcSuccess>()
|
||||
sendRequest.mockImplementationOnce(() => first.promise).mockResolvedValueOnce(accepted('b'))
|
||||
|
||||
@@ -31,6 +31,7 @@ export function useMobileStructuredSessionWrites(args: {
|
||||
sessionId: string | null
|
||||
fence: number | null
|
||||
submissions: readonly AgentJournalSubmission[]
|
||||
handoffOperationId?: string | null
|
||||
}): MobileStructuredSessionWrites {
|
||||
const { client, connected, sessionId, fence, submissions } = args
|
||||
const [outbox, setOutbox] = useState<MobileStructuredOutboxEntry[]>([])
|
||||
@@ -49,6 +50,7 @@ export function useMobileStructuredSessionWrites(args: {
|
||||
client,
|
||||
sessionId,
|
||||
fence,
|
||||
handoffOperationId: args.handoffOperationId,
|
||||
onRefusal: setError
|
||||
})
|
||||
const outboxMutations = useMobileStructuredOutboxMutations({
|
||||
|
||||
@@ -191,7 +191,7 @@ describe('capability gating', () => {
|
||||
for (const method of STRUCTURED_AGENT_SESSION_METHODS) {
|
||||
expect(names).toContain(method.name)
|
||||
}
|
||||
expect(STRUCTURED_AGENT_SESSION_METHODS).toHaveLength(13)
|
||||
expect(STRUCTURED_AGENT_SESSION_METHODS).toHaveLength(14)
|
||||
})
|
||||
|
||||
it('hides the surface from a declared client that did not advertise it', async () => {
|
||||
@@ -316,16 +316,35 @@ describe('method routing', () => {
|
||||
])
|
||||
})
|
||||
|
||||
it('does not register the structured handoff mutation', async () => {
|
||||
const response = await call('agentSession.requestHandoff', {
|
||||
it('routes a mobile reverse request through the host-owned handoff coordinator', async () => {
|
||||
const params = {
|
||||
envelope: envelope(),
|
||||
direction: 'to-native',
|
||||
mode: 'after-turn',
|
||||
action: 'start'
|
||||
}
|
||||
const response = await call('agentSession.requestHandoff', params, {
|
||||
...STRUCTURED_CLIENT,
|
||||
clientId: 'mobile-device-a'
|
||||
})
|
||||
|
||||
expect(response).toMatchObject({ ok: true })
|
||||
expect(hostCalls.requestHandoff).toHaveBeenCalledWith({ callerKey: 'mobile-device-a' }, params)
|
||||
})
|
||||
|
||||
it('accepts manual proof recovery at the RPC boundary', async () => {
|
||||
const params = {
|
||||
envelope: envelope(),
|
||||
direction: 'to-tui',
|
||||
mode: 'now',
|
||||
action: 'start'
|
||||
})
|
||||
action: 'recover'
|
||||
}
|
||||
|
||||
expect(response).toMatchObject({ ok: false, error: { code: 'method_not_found' } })
|
||||
expect(hostCalls.requestHandoff).not.toHaveBeenCalled()
|
||||
await expect(call('agentSession.requestHandoff', params)).resolves.toMatchObject({ ok: true })
|
||||
expect(hostCalls.requestHandoff).toHaveBeenCalledWith(
|
||||
{ callerKey: 'trusted-local:runtime' },
|
||||
params
|
||||
)
|
||||
})
|
||||
})
|
||||
|
||||
|
||||
@@ -21,6 +21,7 @@ import {
|
||||
CreateParams,
|
||||
CreateSupportParams,
|
||||
HistoryParams,
|
||||
HandoffParams,
|
||||
HandoffStatusParams,
|
||||
OptionsParams,
|
||||
RespondParams,
|
||||
@@ -208,6 +209,11 @@ export const STRUCTURED_AGENT_SESSION_METHODS: RpcAnyMethod[] = [
|
||||
params: SetOptionParams,
|
||||
handler: async (params, ctx) => requireHost(ctx).setOption(callerFor(ctx), params)
|
||||
}),
|
||||
defineMethod({
|
||||
name: 'agentSession.requestHandoff',
|
||||
params: HandoffParams,
|
||||
handler: async (params, ctx) => requireHost(ctx).requestHandoff(callerFor(ctx), params)
|
||||
}),
|
||||
defineMethod({
|
||||
name: 'agentSession.handoffStatus',
|
||||
params: HandoffStatusParams,
|
||||
|
||||
@@ -190,12 +190,14 @@ const MOBILE_RPC_METHOD_ALLOWLIST = new Set([
|
||||
'agentSession.create',
|
||||
'agentSession.createSupport',
|
||||
'agentSession.cancel',
|
||||
'agentSession.handoffStatus',
|
||||
'agentSession.history',
|
||||
'agentSession.options',
|
||||
'agentSession.respondToApproval',
|
||||
'agentSession.respondToQuestion',
|
||||
'agentSession.send',
|
||||
'agentSession.setOption',
|
||||
'agentSession.requestHandoff',
|
||||
'agentSession.subscribe',
|
||||
'agentSession.unsubscribe',
|
||||
'browser.back',
|
||||
|
||||
@@ -17,6 +17,7 @@ import { useNativeChatFileLinkClick } from './use-native-chat-file-link-click'
|
||||
import { useNativeChatFileLinkContext } from './use-native-chat-file-link-context'
|
||||
import { useStructuredAgentSession } from './use-structured-agent-session'
|
||||
import { translate } from '@/i18n/i18n'
|
||||
import { StructuredAgentSessionHandoffChrome } from './StructuredAgentSessionHandoffChrome'
|
||||
|
||||
function encodeQuestionAnswer(questionId: string, answer: string): string {
|
||||
return `${encodeURIComponent(questionId)}:${encodeURIComponent(answer)}`
|
||||
@@ -115,6 +116,14 @@ export function NativeChatStructuredSession(props: {
|
||||
tabIndex={-1}
|
||||
className="flex h-full min-h-0 w-full flex-col bg-background focus:outline-none"
|
||||
>
|
||||
<StructuredAgentSessionHandoffChrome
|
||||
status={controller.handoff}
|
||||
isWorking={controller.isWorking}
|
||||
hasPersistedTurn={controller.hasPersistedTurn}
|
||||
onRequest={(direction, mode, action) => {
|
||||
void controller.requestHandoff(direction, mode, action)
|
||||
}}
|
||||
/>
|
||||
<div className="flex min-h-0 flex-1 flex-col">
|
||||
{viewState.kind === 'loading' ? (
|
||||
<NativeChatEmptyState kind="loading" />
|
||||
@@ -210,7 +219,9 @@ export function NativeChatStructuredSession(props: {
|
||||
{controller.error ?? composerError}
|
||||
</p>
|
||||
) : null}
|
||||
{prompt ? null : (
|
||||
{prompt ||
|
||||
controller.handoff?.owner === 'tui' ||
|
||||
controller.handoff?.phase === 'switching' ? null : (
|
||||
<NativeChatComposer
|
||||
terminalTabId={props.tabId}
|
||||
paneKey={paneKey}
|
||||
|
||||
@@ -0,0 +1,82 @@
|
||||
// @vitest-environment happy-dom
|
||||
|
||||
import { cleanup, fireEvent, render, screen } from '@testing-library/react'
|
||||
import { afterEach, describe, expect, it, vi } from 'vitest'
|
||||
import type { AgentSessionHandoffStatus } from '../../../../shared/agent-session-wire'
|
||||
import { TooltipProvider } from '@/components/ui/tooltip'
|
||||
import { StructuredAgentSessionHandoffChrome } from './StructuredAgentSessionHandoffChrome'
|
||||
|
||||
const IDLE_NATIVE: AgentSessionHandoffStatus = {
|
||||
owner: 'native',
|
||||
direction: null,
|
||||
phase: 'idle',
|
||||
stage: null,
|
||||
operationId: null
|
||||
}
|
||||
|
||||
afterEach(cleanup)
|
||||
|
||||
describe('StructuredAgentSessionHandoffChrome', () => {
|
||||
it('uses queued-safe admission when the native view still appears idle', () => {
|
||||
const onRequest = vi.fn()
|
||||
render(
|
||||
<StructuredAgentSessionHandoffChrome
|
||||
status={IDLE_NATIVE}
|
||||
isWorking={false}
|
||||
hasPersistedTurn
|
||||
onRequest={onRequest}
|
||||
/>,
|
||||
{ wrapper: TooltipProvider }
|
||||
)
|
||||
|
||||
fireEvent.click(screen.getByRole('button', { name: 'Open agent TUI' }))
|
||||
|
||||
expect(onRequest).toHaveBeenCalledWith('to-tui', 'after-turn')
|
||||
})
|
||||
|
||||
it('disables zero-turn TUI resume with truthful guidance', async () => {
|
||||
render(
|
||||
<StructuredAgentSessionHandoffChrome
|
||||
status={IDLE_NATIVE}
|
||||
isWorking={false}
|
||||
hasPersistedTurn={false}
|
||||
onRequest={vi.fn()}
|
||||
/>,
|
||||
{ wrapper: TooltipProvider }
|
||||
)
|
||||
|
||||
const button = screen.getByRole('button', { name: 'Open agent TUI' })
|
||||
expect(button.hasAttribute('disabled')).toBe(true)
|
||||
fireEvent.pointerMove(button.parentElement!)
|
||||
expect(
|
||||
await screen.findAllByText('Send a message first to open the agent TUI.')
|
||||
).not.toHaveLength(0)
|
||||
})
|
||||
|
||||
it('offers one Retry action for a recoverable dead TUI owner', () => {
|
||||
const onRequest = vi.fn()
|
||||
render(
|
||||
<StructuredAgentSessionHandoffChrome
|
||||
status={{
|
||||
owner: 'tui',
|
||||
direction: 'to-native',
|
||||
phase: 'failed',
|
||||
stage: 'old-owner-stopped',
|
||||
operationId: '1800000000000-00000000000000000000000000000001',
|
||||
error: {
|
||||
message: "Couldn't resume chat — the agent terminal still owns this session",
|
||||
recoverableOwner: 'tui'
|
||||
}
|
||||
}}
|
||||
isWorking={false}
|
||||
hasPersistedTurn
|
||||
onRequest={onRequest}
|
||||
/>,
|
||||
{ wrapper: TooltipProvider }
|
||||
)
|
||||
|
||||
expect(screen.queryByRole('button', { name: 'Return to chat' })).toBeNull()
|
||||
fireEvent.click(screen.getByRole('button', { name: 'Retry' }))
|
||||
expect(onRequest).toHaveBeenCalledWith('to-native', 'now', 'retry')
|
||||
})
|
||||
})
|
||||
@@ -0,0 +1,243 @@
|
||||
import type {
|
||||
AgentSessionHandoffDirection,
|
||||
AgentSessionHandoffMode,
|
||||
AgentSessionHandoffStatus
|
||||
} from '../../../../shared/agent-session-wire'
|
||||
import { Button } from '@/components/ui/button'
|
||||
import { Badge } from '@/components/ui/badge'
|
||||
import { Tooltip, TooltipContent, TooltipTrigger } from '@/components/ui/tooltip'
|
||||
import { translate } from '@/i18n/i18n'
|
||||
|
||||
type Props = {
|
||||
status: AgentSessionHandoffStatus | null
|
||||
isWorking: boolean
|
||||
hasPersistedTurn: boolean
|
||||
onRequest: (
|
||||
direction: AgentSessionHandoffDirection,
|
||||
mode: AgentSessionHandoffMode,
|
||||
action?: 'start' | 'cancel-queued' | 'retry' | 'recover'
|
||||
) => void
|
||||
}
|
||||
|
||||
function handoffStageCopy(status: AgentSessionHandoffStatus): string {
|
||||
if (status.stage === 'preparing') {
|
||||
return status.direction === 'to-tui'
|
||||
? translate('components.native-chat.handoff.stage.finishingChat', 'Finishing chat session…')
|
||||
: translate(
|
||||
'components.native-chat.handoff.stage.finishingTerminal',
|
||||
'Finishing agent terminal…'
|
||||
)
|
||||
}
|
||||
if (status.stage === 'old-owner-stopped') {
|
||||
return status.direction === 'to-tui'
|
||||
? translate('components.native-chat.handoff.stage.openingTerminal', 'Opening agent terminal…')
|
||||
: translate('components.native-chat.handoff.stage.resumingChat', 'Resuming chat session…')
|
||||
}
|
||||
if (status.stage === 'new-owner-proving') {
|
||||
return status.direction === 'to-tui'
|
||||
? translate(
|
||||
'components.native-chat.handoff.stage.verifyingTerminal',
|
||||
'Verifying agent terminal…'
|
||||
)
|
||||
: translate('components.native-chat.handoff.stage.verifyingChat', 'Verifying chat session…')
|
||||
}
|
||||
if (status.stage === 'recovering') {
|
||||
return translate('components.native-chat.handoff.stage.recovering', 'Recovering agent session…')
|
||||
}
|
||||
if (status.stage === 'manual-recovery') {
|
||||
return translate(
|
||||
'components.native-chat.handoff.stage.manualRecovery',
|
||||
'Agent session needs recovery'
|
||||
)
|
||||
}
|
||||
return translate('components.native-chat.handoff.switchingOwner', 'Switching session owner…')
|
||||
}
|
||||
|
||||
export function StructuredAgentSessionHandoffChrome({
|
||||
status,
|
||||
isWorking,
|
||||
hasPersistedTurn,
|
||||
onRequest
|
||||
}: Props): React.JSX.Element | null {
|
||||
if (!status) {
|
||||
return null
|
||||
}
|
||||
const owner = status?.owner ?? 'native'
|
||||
const phase = status?.phase ?? 'idle'
|
||||
const switching = phase === 'switching' || phase === 'waiting-for-exit'
|
||||
return (
|
||||
<>
|
||||
<div className="flex min-h-9 items-center gap-2 border-b border-border px-3 py-1.5">
|
||||
<Badge variant="outline">
|
||||
{switching
|
||||
? translate('components.native-chat.handoff.mode.switching', 'Switching')
|
||||
: owner === 'tui'
|
||||
? translate('components.native-chat.handoff.mode.terminal', 'Terminal')
|
||||
: translate('components.native-chat.handoff.mode.chat', 'Chat')}
|
||||
</Badge>
|
||||
<div className="ml-auto flex items-center gap-1.5">
|
||||
{phase === 'queued' && status?.direction ? (
|
||||
<>
|
||||
<span className="text-xs text-muted-foreground">
|
||||
{status.direction === 'to-tui'
|
||||
? translate(
|
||||
'components.native-chat.handoff.switchingAfterTurn',
|
||||
'Switching after this turn'
|
||||
)
|
||||
: translate(
|
||||
'components.native-chat.handoff.returningAfterTurn',
|
||||
'Returning after this turn'
|
||||
)}
|
||||
</span>
|
||||
<Button
|
||||
type="button"
|
||||
variant="ghost"
|
||||
size="xs"
|
||||
onClick={() => onRequest(status.direction!, 'after-turn', 'cancel-queued')}
|
||||
>
|
||||
{translate('components.native-chat.handoff.cancel', 'Cancel')}
|
||||
</Button>
|
||||
</>
|
||||
) : owner === 'native' && phase === 'idle' ? (
|
||||
isWorking ? (
|
||||
<>
|
||||
<Button
|
||||
type="button"
|
||||
variant="ghost"
|
||||
size="xs"
|
||||
onClick={() => onRequest('to-tui', 'after-turn')}
|
||||
>
|
||||
{translate(
|
||||
'components.native-chat.handoff.switchAfterTurn',
|
||||
'Switch after this turn'
|
||||
)}
|
||||
</Button>
|
||||
<Button
|
||||
type="button"
|
||||
variant="secondary"
|
||||
size="xs"
|
||||
onClick={() => onRequest('to-tui', 'stop-turn')}
|
||||
>
|
||||
{translate(
|
||||
'components.native-chat.handoff.stopTurnAndSwitch',
|
||||
'Stop turn and switch'
|
||||
)}
|
||||
</Button>
|
||||
</>
|
||||
) : (
|
||||
<Tooltip>
|
||||
<TooltipTrigger asChild>
|
||||
<span>
|
||||
<Button
|
||||
type="button"
|
||||
variant="ghost"
|
||||
size="xs"
|
||||
disabled={!hasPersistedTurn}
|
||||
// A submitted turn can reach the host before isWorking updates; after-turn is immediate when idle.
|
||||
onClick={() => onRequest('to-tui', 'after-turn')}
|
||||
>
|
||||
{translate('components.native-chat.handoff.openAgentTui', 'Open agent TUI')}
|
||||
</Button>
|
||||
</span>
|
||||
</TooltipTrigger>
|
||||
{!hasPersistedTurn ? (
|
||||
<TooltipContent side="bottom" sideOffset={4}>
|
||||
{translate(
|
||||
'components.native-chat.handoff.sendFirst',
|
||||
'Send a message first to open the agent TUI.'
|
||||
)}
|
||||
</TooltipContent>
|
||||
) : null}
|
||||
</Tooltip>
|
||||
)
|
||||
) : owner === 'tui' && phase === 'idle' ? (
|
||||
<Button
|
||||
type="button"
|
||||
variant="ghost"
|
||||
size="xs"
|
||||
onClick={() => onRequest('to-native', 'after-turn')}
|
||||
>
|
||||
{isWorking
|
||||
? translate(
|
||||
'components.native-chat.handoff.returnAfterTurn',
|
||||
'Return after this turn'
|
||||
)
|
||||
: translate('components.native-chat.handoff.returnToChat', 'Return to chat')}
|
||||
</Button>
|
||||
) : null}
|
||||
</div>
|
||||
</div>
|
||||
{owner === 'tui' && phase === 'idle' ? (
|
||||
<div className="flex items-center justify-between gap-3 border-b border-border bg-muted px-3 py-2 text-xs">
|
||||
<span>
|
||||
{status?.hostLabel
|
||||
? translate(
|
||||
'components.native-chat.handoff.agentOpenOnHost',
|
||||
'Agent is open in terminal on {{value0}}.',
|
||||
{ value0: status.hostLabel }
|
||||
)
|
||||
: translate('components.native-chat.handoff.agentOpen', 'Agent is open in terminal.')}
|
||||
</span>
|
||||
<Button
|
||||
type="button"
|
||||
variant="link"
|
||||
size="xs"
|
||||
onClick={() => onRequest('to-native', 'after-turn')}
|
||||
>
|
||||
{translate('components.native-chat.handoff.returnToChat', 'Return to chat')}
|
||||
</Button>
|
||||
</div>
|
||||
) : null}
|
||||
{switching ? (
|
||||
<div className="border-b border-border bg-muted px-3 py-3 text-center text-sm text-muted-foreground">
|
||||
{phase === 'waiting-for-exit'
|
||||
? translate(
|
||||
'components.native-chat.handoff.exitTerminal',
|
||||
'Exit the agent terminal to continue in chat.'
|
||||
)
|
||||
: status?.stage
|
||||
? handoffStageCopy(status)
|
||||
: translate(
|
||||
'components.native-chat.handoff.switchingOwner',
|
||||
'Switching session owner…'
|
||||
)}
|
||||
</div>
|
||||
) : null}
|
||||
{phase === 'failed' && status?.error ? (
|
||||
<div
|
||||
className="border-b border-destructive/40 bg-destructive/10 px-3 py-2 text-xs text-destructive"
|
||||
role="alert"
|
||||
>
|
||||
<div className="flex items-center justify-between gap-3">
|
||||
<span>{status.error.message}</span>
|
||||
{status.direction && status.error.canRetryProof ? (
|
||||
<Button
|
||||
type="button"
|
||||
variant="ghost"
|
||||
size="xs"
|
||||
onClick={() => onRequest(status.direction!, 'now', 'recover')}
|
||||
>
|
||||
{translate('components.native-chat.handoff.retryProof', 'Retry proof')}
|
||||
</Button>
|
||||
) : status.direction && status.error.recoverableOwner !== 'none' ? (
|
||||
<Button
|
||||
type="button"
|
||||
variant="ghost"
|
||||
size="xs"
|
||||
onClick={() => onRequest(status.direction!, 'now', 'retry')}
|
||||
>
|
||||
{translate('components.native-chat.handoff.retry', 'Retry')}
|
||||
</Button>
|
||||
) : null}
|
||||
</div>
|
||||
{status.error.details ? (
|
||||
<details className="mt-1">
|
||||
<summary>{translate('components.native-chat.handoff.details', 'Details')}</summary>
|
||||
<p className="mt-1 text-muted-foreground">{status.error.details}</p>
|
||||
</details>
|
||||
) : null}
|
||||
</div>
|
||||
) : null}
|
||||
</>
|
||||
)
|
||||
}
|
||||
@@ -1,6 +1,9 @@
|
||||
import { useEffect, useMemo } from 'react'
|
||||
import { useShallow } from 'zustand/react/shallow'
|
||||
import type { AgentSessionHistoryResult } from '../../../../shared/agent-session-wire'
|
||||
import type {
|
||||
AgentSessionHandoffStatus,
|
||||
AgentSessionHistoryResult
|
||||
} from '../../../../shared/agent-session-wire'
|
||||
import {
|
||||
projectStructuredAgentSessionStatus,
|
||||
structuredAgentSessionPaneKey
|
||||
@@ -19,6 +22,10 @@ import {
|
||||
callStructuredAgentSession,
|
||||
subscribeStructuredAgentSession
|
||||
} from '@/runtime/structured-agent-session-client'
|
||||
import {
|
||||
clearStructuredHandoff,
|
||||
publishStructuredHandoff
|
||||
} from '@/runtime/structured-agent-session-handoff-store'
|
||||
|
||||
type StructuredTab = Tab & { contentType: 'agent-session' }
|
||||
|
||||
@@ -87,6 +94,13 @@ function startStatusProjection(tab: StructuredTab, target: RuntimeClientTarget):
|
||||
target,
|
||||
{ sessionId: tab.entityId, ...(state.cursor ? { cursor: state.cursor } : {}) },
|
||||
(event) => {
|
||||
if ('handoff' in event && event.handoff) {
|
||||
publishStructuredHandoff({
|
||||
sessionId: tab.entityId,
|
||||
fence: event.fence ?? state.fence ?? 0,
|
||||
status: event.handoff
|
||||
})
|
||||
}
|
||||
if (
|
||||
event.type === 'batch' &&
|
||||
!shouldAdvanceStructuredResumeCursor(state.cursor, event.batch.cursor)
|
||||
@@ -127,12 +141,17 @@ function startStatusProjection(tab: StructuredTab, target: RuntimeClientTarget):
|
||||
opening = false
|
||||
}
|
||||
}
|
||||
void callStructuredAgentSession<AgentSessionHistoryResult>(target, 'agentSession.history', {
|
||||
sessionId: tab.entityId,
|
||||
direction: 'tail',
|
||||
limit: 40
|
||||
})
|
||||
.then(async (result) => {
|
||||
void Promise.all([
|
||||
callStructuredAgentSession<AgentSessionHistoryResult>(target, 'agentSession.history', {
|
||||
sessionId: tab.entityId,
|
||||
direction: 'tail',
|
||||
limit: 40
|
||||
}),
|
||||
callStructuredAgentSession<AgentSessionHandoffStatus>(target, 'agentSession.handoffStatus', {
|
||||
sessionId: tab.entityId
|
||||
}).catch(() => null)
|
||||
])
|
||||
.then(async ([result, handoff]) => {
|
||||
if (stopped) {
|
||||
return
|
||||
}
|
||||
@@ -150,6 +169,14 @@ function startStatusProjection(tab: StructuredTab, target: RuntimeClientTarget):
|
||||
}
|
||||
})
|
||||
}
|
||||
if (handoff) {
|
||||
apply({ type: 'handoff', handoff })
|
||||
publishStructuredHandoff({
|
||||
sessionId: tab.entityId,
|
||||
fence: result.ok ? (result.page.fence ?? 0) : (result.fence ?? 0),
|
||||
status: handoff
|
||||
})
|
||||
}
|
||||
await open()
|
||||
})
|
||||
.catch(scheduleReconnect)
|
||||
@@ -159,6 +186,7 @@ function startStatusProjection(tab: StructuredTab, target: RuntimeClientTarget):
|
||||
clearTimeout(reconnectTimer)
|
||||
}
|
||||
unsubscribe()
|
||||
clearStructuredHandoff(tab.entityId)
|
||||
useAppStore.getState().removeAgentStatus(structuredAgentSessionPaneKey(tab.id, tab.entityId))
|
||||
}
|
||||
}
|
||||
|
||||
@@ -1,5 +1,6 @@
|
||||
import { useCallback, useEffect, useReducer, useRef, useState } from 'react'
|
||||
import type {
|
||||
AgentSessionHandoffStatus,
|
||||
AgentSessionHistoryResult,
|
||||
AgentSessionSubscribeEvent
|
||||
} from '../../../../shared/agent-session-wire'
|
||||
@@ -129,11 +130,18 @@ export function useStructuredAgentSessionRead(args: {
|
||||
}
|
||||
}
|
||||
async function refreshTail(): Promise<void> {
|
||||
const result = await callStructuredAgentSession<AgentSessionHistoryResult>(
|
||||
target,
|
||||
'agentSession.history',
|
||||
{ sessionId, direction: 'tail', limit: 40 }
|
||||
)
|
||||
const [result, handoff] = await Promise.all([
|
||||
callStructuredAgentSession<AgentSessionHistoryResult>(target, 'agentSession.history', {
|
||||
sessionId,
|
||||
direction: 'tail',
|
||||
limit: 40
|
||||
}),
|
||||
callStructuredAgentSession<AgentSessionHandoffStatus>(
|
||||
target,
|
||||
'agentSession.handoffStatus',
|
||||
{ sessionId }
|
||||
).catch(() => null)
|
||||
])
|
||||
if (stopped) {
|
||||
return
|
||||
}
|
||||
@@ -153,6 +161,9 @@ export function useStructuredAgentSessionRead(args: {
|
||||
})
|
||||
resumeCursorRef.current = result.snapshot.cursor
|
||||
}
|
||||
if (handoff) {
|
||||
dispatch({ type: 'handoff', handoff })
|
||||
}
|
||||
}
|
||||
const refreshOnFocus = (): void => {
|
||||
if (!document.hasFocus()) {
|
||||
|
||||
@@ -259,6 +259,65 @@ describe('useStructuredAgentSession options', () => {
|
||||
expect(mocks.operationId).toHaveBeenCalledTimes(1)
|
||||
})
|
||||
|
||||
it('mints a fresh handoff operation after a settled refusal', async () => {
|
||||
let attempts = 0
|
||||
mocks.call.mockImplementation((_target, method) => {
|
||||
if (method === 'agentSession.options') {
|
||||
return Promise.resolve(OPTIONS)
|
||||
}
|
||||
attempts += 1
|
||||
return Promise.resolve(
|
||||
attempts === 1
|
||||
? {
|
||||
ok: false,
|
||||
refusal: {
|
||||
code: 'agent_session_checkpoint_stale',
|
||||
message: 'runtime fence advanced',
|
||||
currentFence: 4
|
||||
}
|
||||
}
|
||||
: {
|
||||
ok: true,
|
||||
replayed: false,
|
||||
value: {
|
||||
status: {
|
||||
owner: 'native',
|
||||
direction: 'to-tui',
|
||||
phase: 'switching',
|
||||
stage: 'preparing',
|
||||
operationId: 'operation-2'
|
||||
}
|
||||
}
|
||||
}
|
||||
)
|
||||
})
|
||||
const { result } = renderHook(() =>
|
||||
useStructuredAgentSession({
|
||||
sessionId: 'session-1',
|
||||
target: LOCAL_TARGET,
|
||||
agent: 'codex'
|
||||
})
|
||||
)
|
||||
await waitFor(() => expect(result.current.optionSnapshot).toHaveLength(2))
|
||||
|
||||
await act(async () => {
|
||||
expect(await result.current.requestHandoff('to-tui', 'now')).toBeNull()
|
||||
expect(await result.current.requestHandoff('to-tui', 'now')).toMatchObject({
|
||||
status: { phase: 'switching' }
|
||||
})
|
||||
})
|
||||
|
||||
const mutations = mocks.call.mock.calls.filter(
|
||||
([, method]) => method === 'agentSession.requestHandoff'
|
||||
)
|
||||
expect(
|
||||
mutations.map(
|
||||
([, , params]) =>
|
||||
(params as { envelope: { clientOperationId: string } }).envelope.clientOperationId
|
||||
)
|
||||
).toEqual(['operation-1', 'operation-2'])
|
||||
})
|
||||
|
||||
it('ignores an option failure from a superseded fence', async () => {
|
||||
let reject!: (error: Error) => void
|
||||
const pending = new Promise<never>((_resolve, rejectPromise) => {
|
||||
|
||||
@@ -3,6 +3,9 @@ import type { AgentJournalRenderItem } from '../../../../shared/agent-session-jo
|
||||
import type { AgentType } from '../../../../shared/agent-status-types'
|
||||
import type {
|
||||
AgentSessionMutationResult,
|
||||
AgentSessionHandoffDirection,
|
||||
AgentSessionHandoffMode,
|
||||
AgentSessionHandoffResult,
|
||||
AgentSessionOptionResult,
|
||||
AgentSessionOptionsResult,
|
||||
AgentSessionPromptResult
|
||||
@@ -18,7 +21,10 @@ import {
|
||||
createStructuredAgentSessionOptionState,
|
||||
structuredAgentSessionOptionSnapshot
|
||||
} from '../../../../shared/structured-agent-session-options'
|
||||
import { activeStructuredAgentSessionTurnId } from '../../../../shared/structured-agent-session-projection'
|
||||
import {
|
||||
activeStructuredAgentSessionTurnId,
|
||||
hasPersistedStructuredAgentSessionTurn
|
||||
} from '../../../../shared/structured-agent-session-projection'
|
||||
import type { RuntimeClientTarget } from '@/runtime/runtime-rpc-client'
|
||||
import { callStructuredAgentSession } from '@/runtime/structured-agent-session-client'
|
||||
import {
|
||||
@@ -223,6 +229,7 @@ export function useStructuredAgentSession(args: {
|
||||
send: outboxController.send,
|
||||
retry: outboxController.retry,
|
||||
isWorking: turnId !== null,
|
||||
hasPersistedTurn: hasPersistedStructuredAgentSessionTurn(state.items),
|
||||
turnId,
|
||||
cancel: (turnId: string) => mutate('agentSession.cancel', 'agentSession.cancel', { turnId }),
|
||||
respond: (item: StructuredPromptItem, optionId: string) =>
|
||||
@@ -235,6 +242,18 @@ export function useStructuredAgentSession(args: {
|
||||
),
|
||||
optionSnapshot,
|
||||
optionSurface,
|
||||
setStructuredOption
|
||||
setStructuredOption,
|
||||
requestHandoff: (
|
||||
direction: AgentSessionHandoffDirection,
|
||||
mode: AgentSessionHandoffMode,
|
||||
action: 'start' | 'cancel-queued' | 'retry' | 'recover' = 'start'
|
||||
) =>
|
||||
mutate<AgentSessionHandoffResult>(
|
||||
'agentSession.requestHandoff',
|
||||
'agentSession.requestHandoff',
|
||||
{ direction, mode, action },
|
||||
action === 'retry' ? stateRef.current.handoff?.operationId : null
|
||||
),
|
||||
handoff: state.handoff
|
||||
}
|
||||
}
|
||||
|
||||
+25
@@ -0,0 +1,25 @@
|
||||
import { Button } from '@/components/ui/button'
|
||||
import { translate } from '@/i18n/i18n'
|
||||
|
||||
export function StructuredAgentSessionTerminalReturnButton(props: {
|
||||
enabled: boolean
|
||||
onReturn?: () => void
|
||||
}): React.JSX.Element | null {
|
||||
if (!props.enabled) {
|
||||
return null
|
||||
}
|
||||
return (
|
||||
<Button
|
||||
type="button"
|
||||
variant="ghost"
|
||||
size="xs"
|
||||
className="pane-title-split-trigger"
|
||||
onClick={(event) => {
|
||||
event.stopPropagation()
|
||||
props.onReturn?.()
|
||||
}}
|
||||
>
|
||||
{translate('components.native-chat.handoff.returnToChat', 'Return to chat')}
|
||||
</Button>
|
||||
)
|
||||
}
|
||||
@@ -246,6 +246,13 @@ import {
|
||||
} from './regular-terminal-focus-ownership'
|
||||
import { useTerminalQuickCommandHosts } from '@/hooks/use-terminal-quick-command-hosts'
|
||||
import { refreshTerminalImeInputContext } from './terminal-ime-input-context-refresh'
|
||||
import { useTerminalStructuredHandoff } from '@/runtime/structured-agent-session-handoff-store'
|
||||
import { callStructuredAgentSession } from '@/runtime/structured-agent-session-client'
|
||||
import { getActiveRuntimeTarget } from '@/runtime/runtime-rpc-client'
|
||||
import {
|
||||
createStructuredAgentSessionOperationId,
|
||||
structuredAgentSessionPayloadFingerprint
|
||||
} from '../../../../shared/structured-agent-session-mutation'
|
||||
|
||||
type TerminalPaneProps = {
|
||||
tabId: string
|
||||
@@ -343,6 +350,34 @@ function TerminalPane(
|
||||
const isRendererVisible = isVisible && isWorktreeActive
|
||||
const isVisibleRef = useRef(isRendererVisible)
|
||||
isVisibleRef.current = isRendererVisible
|
||||
const structuredHandoff = useTerminalStructuredHandoff(tabId)
|
||||
const returnStructuredSession = useCallback(() => {
|
||||
if (!structuredHandoff) {
|
||||
return
|
||||
}
|
||||
const direction = 'to-native' as const
|
||||
const mode = 'after-turn' as const
|
||||
const action = 'start' as const
|
||||
const sessionId = structuredHandoff.sessionId
|
||||
const environmentId = getRuntimeEnvironmentIdForWorktree(useAppStore.getState(), worktreeId)
|
||||
const target = getActiveRuntimeTarget({ activeRuntimeEnvironmentId: environmentId })
|
||||
const clientOperationId = createStructuredAgentSessionOperationId(() => crypto.randomUUID())
|
||||
void callStructuredAgentSession(target, 'agentSession.requestHandoff', {
|
||||
envelope: {
|
||||
sessionId,
|
||||
clientOperationId,
|
||||
expectedRuntimeFence: structuredHandoff.fence,
|
||||
payloadFingerprint: structuredAgentSessionPayloadFingerprint({
|
||||
method: 'agentSession.requestHandoff',
|
||||
sessionId,
|
||||
fields: { direction, mode, action }
|
||||
})
|
||||
},
|
||||
direction,
|
||||
mode,
|
||||
action
|
||||
})
|
||||
}, [structuredHandoff, worktreeId])
|
||||
const {
|
||||
nativeChatTranscriptIsLocalReadable,
|
||||
sshReconnectEnvironmentId,
|
||||
@@ -3184,6 +3219,8 @@ function TerminalPane(
|
||||
canToggleNativeChat={activePaneCanToggleChat}
|
||||
isChatViewMode={activePaneIsChatLeaf}
|
||||
onToggleNativeChat={handleToggleNativeChat}
|
||||
canReturnStructuredSession={structuredHandoff?.status.owner === 'tui'}
|
||||
onReturnStructuredSession={returnStructuredSession}
|
||||
canContinueAgentSessionInNewSession={activePaneCanContinueInNewSession}
|
||||
onContinueAgentSessionInNewSession={(pane) =>
|
||||
contextMenu.runForPane(pane.id, contextMenu.onContinueAgentSessionInNewSession)
|
||||
|
||||
@@ -8,6 +8,7 @@ import {
|
||||
} from 'lucide-react'
|
||||
import type { ManagedPane, PaneManager } from '@/lib/pane-manager/pane-manager'
|
||||
import { Button } from '@/components/ui/button'
|
||||
import { StructuredAgentSessionTerminalReturnButton } from './StructuredAgentSessionTerminalReturnButton'
|
||||
import { Tooltip, TooltipContent, TooltipTrigger } from '@/components/ui/tooltip'
|
||||
import { translate } from '@/i18n/i18n'
|
||||
import { WORKSPACE_FILE_PATH_MIME, WORKSPACE_FILE_PATHS_MIME } from '@/lib/workspace-file-drag'
|
||||
@@ -50,6 +51,8 @@ type TerminalPaneHeaderOverlayProps = {
|
||||
isChatViewMode?: boolean
|
||||
/** Flip the active pane between the terminal and the native chat view. */
|
||||
onToggleNativeChat?: () => void
|
||||
canReturnStructuredSession?: boolean
|
||||
onReturnStructuredSession?: () => void
|
||||
canContinueAgentSessionInNewSession?: boolean
|
||||
onContinueAgentSessionInNewSession?: (pane: ManagedPane) => void
|
||||
onSplitPane: (pane: ManagedPane, direction: 'vertical' | 'horizontal') => void
|
||||
@@ -88,6 +91,8 @@ export default function TerminalPaneHeaderOverlay({
|
||||
canToggleNativeChat,
|
||||
isChatViewMode,
|
||||
onToggleNativeChat,
|
||||
canReturnStructuredSession,
|
||||
onReturnStructuredSession,
|
||||
canContinueAgentSessionInNewSession,
|
||||
onContinueAgentSessionInNewSession,
|
||||
onSplitPane,
|
||||
@@ -244,6 +249,10 @@ export default function TerminalPaneHeaderOverlay({
|
||||
</button>
|
||||
) : null}
|
||||
<div className="pane-title-actions ml-auto flex shrink-0 items-center gap-0">
|
||||
<StructuredAgentSessionTerminalReturnButton
|
||||
enabled={Boolean(canReturnStructuredSession && isActivePane)}
|
||||
onReturn={onReturnStructuredSession}
|
||||
/>
|
||||
{canContinueAgentSessionInNewSession && isActivePane ? (
|
||||
<Tooltip>
|
||||
<TooltipTrigger asChild>
|
||||
|
||||
@@ -0,0 +1,50 @@
|
||||
import { useSyncExternalStore } from 'react'
|
||||
import type { AgentSessionHandoffStatus } from '../../../shared/agent-session-wire'
|
||||
|
||||
export type TerminalStructuredHandoff = {
|
||||
sessionId: string
|
||||
fence: number
|
||||
status: AgentSessionHandoffStatus
|
||||
}
|
||||
|
||||
const byTerminalTabId = new Map<string, TerminalStructuredHandoff>()
|
||||
const listeners = new Set<() => void>()
|
||||
|
||||
export function publishStructuredHandoff(input: TerminalStructuredHandoff): void {
|
||||
for (const [tabId, current] of byTerminalTabId) {
|
||||
if (current.sessionId === input.sessionId && tabId !== input.status.terminal?.tabId) {
|
||||
byTerminalTabId.delete(tabId)
|
||||
}
|
||||
}
|
||||
if (input.status.terminal) {
|
||||
byTerminalTabId.set(input.status.terminal.tabId, input)
|
||||
}
|
||||
for (const listener of listeners) {
|
||||
listener()
|
||||
}
|
||||
}
|
||||
|
||||
export function clearStructuredHandoff(sessionId: string): void {
|
||||
let changed = false
|
||||
for (const [tabId, current] of byTerminalTabId) {
|
||||
if (current.sessionId === sessionId) {
|
||||
byTerminalTabId.delete(tabId)
|
||||
changed = true
|
||||
}
|
||||
}
|
||||
if (changed) {
|
||||
for (const listener of listeners) {
|
||||
listener()
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
export function useTerminalStructuredHandoff(tabId: string): TerminalStructuredHandoff | null {
|
||||
return useSyncExternalStore(
|
||||
(listener) => {
|
||||
listeners.add(listener)
|
||||
return () => listeners.delete(listener)
|
||||
},
|
||||
() => byTerminalTabId.get(tabId) ?? null
|
||||
)
|
||||
}
|
||||
@@ -46,6 +46,7 @@ const STRUCTURED_CALLS: { method: string; hostMethod: string | null }[] = [
|
||||
{ method: 'agentSession.respondToApproval', hostMethod: 'respondToPrompt' },
|
||||
{ method: 'agentSession.respondToQuestion', hostMethod: 'respondToPrompt' },
|
||||
{ method: 'agentSession.setOption', hostMethod: 'setOption' },
|
||||
{ method: 'agentSession.requestHandoff', hostMethod: 'requestHandoff' },
|
||||
{ method: 'agentSession.handoffStatus', hostMethod: 'handoffStatus' },
|
||||
{ method: 'agentSession.options', hostMethod: 'readOptions' },
|
||||
{ method: 'agentSession.history', hostMethod: 'history' },
|
||||
@@ -152,6 +153,10 @@ function paramsFor(method: string): unknown {
|
||||
const fields = { key: 'model', value: 'gpt-5' }
|
||||
return { envelope: envelope({ method, fields, fence }), ...fields }
|
||||
}
|
||||
case 'agentSession.requestHandoff': {
|
||||
const fields = { direction: 'to-tui', mode: 'now', action: 'start' }
|
||||
return { envelope: envelope({ method, fields, fence }), ...fields }
|
||||
}
|
||||
case 'agentSession.history':
|
||||
return { sessionId: SESSION, direction: 'tail' }
|
||||
default:
|
||||
|
||||
Reference in New Issue
Block a user