mirror of
https://github.com/stablyai/orca.git
synced 2026-10-18 08:03:22 +00:00
fix(chat): preserve drafts through reconnects
This commit is contained in:
@@ -30,7 +30,8 @@ const mocks = vi.hoisted(() => ({
|
||||
sendNativeChatMessage: vi.fn(),
|
||||
sendNativeChatMessageVerified: vi.fn(),
|
||||
trackPendingSend: vi.fn(),
|
||||
setDraft: vi.fn()
|
||||
setDraft: vi.fn(),
|
||||
draftScopeKeys: [] as string[]
|
||||
}))
|
||||
|
||||
vi.mock('../../store', () => {
|
||||
@@ -69,7 +70,10 @@ vi.mock('@/lib/native-chat-telemetry', () => ({
|
||||
emitNativeChatMessageSent: vi.fn()
|
||||
}))
|
||||
vi.mock('./use-native-chat-draft', () => ({
|
||||
useNativeChatDraft: () => ({ draft: 'hello', setDraft: mocks.setDraft })
|
||||
useNativeChatDraft: (scopeKey: string) => {
|
||||
mocks.draftScopeKeys.push(scopeKey)
|
||||
return { draft: 'hello', setDraft: mocks.setDraft }
|
||||
}
|
||||
}))
|
||||
vi.mock('./native-chat-draft-cache', () => ({
|
||||
readNativeChatDraftCache: () => ''
|
||||
@@ -122,6 +126,7 @@ describe('NativeChatComposer', () => {
|
||||
clearNativeChatSessionOptionCacheForTests()
|
||||
mocks.fieldProps = null
|
||||
mocks.modelSwitchOutcome = 'applied'
|
||||
mocks.draftScopeKeys.length = 0
|
||||
mocks.confirmationObserver = null
|
||||
mocks.createClaudeModelSwitchConfirmationObserver.mockImplementation(() => {
|
||||
const observer = {
|
||||
@@ -154,6 +159,7 @@ describe('NativeChatComposer', () => {
|
||||
render(
|
||||
<NativeChatComposer
|
||||
terminalTabId="tab-1"
|
||||
paneKey="tab-1:leaf-1"
|
||||
targetPtyId="pty-1"
|
||||
agent="codex"
|
||||
isWorking
|
||||
@@ -175,6 +181,7 @@ describe('NativeChatComposer', () => {
|
||||
render(
|
||||
<NativeChatComposer
|
||||
terminalTabId="tab-1"
|
||||
paneKey="tab-1:leaf-1"
|
||||
targetPtyId="pty-1"
|
||||
agent="codex"
|
||||
onOptimisticSend={onOptimisticSend}
|
||||
@@ -187,6 +194,36 @@ describe('NativeChatComposer', () => {
|
||||
expect(mocks.trackPendingSend).toHaveBeenCalledWith(mocks.sendHandle, 'pending-1')
|
||||
})
|
||||
|
||||
it('keeps the draft scope anchored to the pane while the PTY reconnects', () => {
|
||||
const view = render(
|
||||
<NativeChatComposer
|
||||
terminalTabId="tab-1"
|
||||
paneKey="tab-1:leaf-1"
|
||||
targetPtyId="pty-before"
|
||||
agent="codex"
|
||||
/>
|
||||
)
|
||||
|
||||
view.rerender(
|
||||
<NativeChatComposer
|
||||
terminalTabId="tab-1"
|
||||
paneKey="tab-1:leaf-1"
|
||||
targetPtyId={null}
|
||||
agent="codex"
|
||||
/>
|
||||
)
|
||||
view.rerender(
|
||||
<NativeChatComposer
|
||||
terminalTabId="tab-1"
|
||||
paneKey="tab-1:leaf-1"
|
||||
targetPtyId="pty-after"
|
||||
agent="codex"
|
||||
/>
|
||||
)
|
||||
|
||||
expect(new Set(mocks.draftScopeKeys)).toEqual(new Set(['tab-1:leaf-1']))
|
||||
})
|
||||
|
||||
it('shows the model already selected in the Claude TUI when chat opens', async () => {
|
||||
mocks.getMainBufferSnapshot.mockResolvedValue({
|
||||
data: 'Claude Code v2.1.211\r\nOpus 4.8 with medium effort · API Usage Billing',
|
||||
@@ -196,6 +233,7 @@ describe('NativeChatComposer', () => {
|
||||
render(
|
||||
<NativeChatComposer
|
||||
terminalTabId="tab-1"
|
||||
paneKey="tab-1:leaf-1"
|
||||
targetPtyId="pty-1"
|
||||
agent="claude"
|
||||
readTerminalScreen={() => null}
|
||||
@@ -231,6 +269,7 @@ describe('NativeChatComposer', () => {
|
||||
render(
|
||||
<NativeChatComposer
|
||||
terminalTabId="tab-1"
|
||||
paneKey="tab-1:leaf-1"
|
||||
targetPtyId="pty-1"
|
||||
agent="claude"
|
||||
readTerminalScreen={() =>
|
||||
@@ -266,6 +305,7 @@ describe('NativeChatComposer', () => {
|
||||
render(
|
||||
<NativeChatComposer
|
||||
terminalTabId="tab-1"
|
||||
paneKey="tab-1:leaf-1"
|
||||
targetPtyId="pty-1"
|
||||
agent="claude"
|
||||
onSlashCommand={onSlashCommand}
|
||||
@@ -300,6 +340,7 @@ describe('NativeChatComposer', () => {
|
||||
render(
|
||||
<NativeChatComposer
|
||||
terminalTabId="tab-1"
|
||||
paneKey="tab-1:leaf-1"
|
||||
targetPtyId="pty-1"
|
||||
agent="claude"
|
||||
onSwitchToTerminal={onSwitchToTerminal}
|
||||
@@ -340,6 +381,7 @@ describe('NativeChatComposer', () => {
|
||||
render(
|
||||
<NativeChatComposer
|
||||
terminalTabId="tab-1"
|
||||
paneKey="tab-1:leaf-1"
|
||||
targetPtyId="pty-1"
|
||||
agent="claude"
|
||||
onSwitchToTerminal={onSwitchToTerminal}
|
||||
@@ -365,6 +407,7 @@ describe('NativeChatComposer', () => {
|
||||
render(
|
||||
<NativeChatComposer
|
||||
terminalTabId="tab-1"
|
||||
paneKey="tab-1:leaf-1"
|
||||
targetPtyId="pty-1"
|
||||
agent="codex"
|
||||
onSwitchToTerminal={onSwitchToTerminal}
|
||||
|
||||
@@ -1,6 +1,5 @@
|
||||
import { forwardRef, useCallback, useImperativeHandle, useMemo, useRef, useState } from 'react'
|
||||
import { useAppStore } from '../../store'
|
||||
import type { AgentType } from '../../../../shared/agent-status-types'
|
||||
import { sendRuntimePtyInput } from '@/runtime/runtime-terminal-inspection'
|
||||
import { getSettingsForAgentTabRuntimeOwner } from '@/lib/agent-paste-draft'
|
||||
import {
|
||||
@@ -40,6 +39,15 @@ import { useNativeChatSessionOptions } from './use-native-chat-session-options'
|
||||
import { useNativeChatFileAttachmentActions } from './use-native-chat-file-attachment-actions'
|
||||
import { useNativeChatDictationActions } from './use-native-chat-dictation-actions'
|
||||
import { useNativeChatSessionOptionCommand } from './use-native-chat-session-option-command'
|
||||
import type {
|
||||
NativeChatComposerHandle,
|
||||
NativeChatComposerProps
|
||||
} from './native-chat-composer-types'
|
||||
|
||||
export type {
|
||||
NativeChatComposerHandle,
|
||||
NativeChatComposerProps
|
||||
} from './native-chat-composer-types'
|
||||
|
||||
// Why: a plain ESC byte is what the agent TUIs read as the interrupt key over a
|
||||
// PTY (matching how xterm forwards Escape). The richer interrupt-intent
|
||||
@@ -47,54 +55,6 @@ import { useNativeChatSessionOptionCommand } from './use-native-chat-session-opt
|
||||
// observers, so writing ESC through the same send path feeds that machinery.
|
||||
const ESC = '\x1b'
|
||||
|
||||
export type NativeChatComposerProps = {
|
||||
/** Tab hosting the agent; used to resolve the live ptyId + runtime settings. */
|
||||
terminalTabId: string
|
||||
/** Specific split-pane PTY this chat view owns. */
|
||||
targetPtyId: string | null
|
||||
agent: AgentType
|
||||
/**
|
||||
* Mobile presence-lock seam (R8): when a mobile client holds the pty, desktop
|
||||
* sends must be guarded rather than silently dropped. U9 wires the real lock
|
||||
* state in; until then this defaults to `true` (sendable) and the composer
|
||||
* already renders the guarded/disabled affordance when it is `false`.
|
||||
*/
|
||||
canSend?: boolean
|
||||
/** True while the hosted TUI reports an in-flight turn; swaps Send to Stop. */
|
||||
isWorking?: boolean
|
||||
/** Interrupt the hosted agent, usually by sending ESC into the PTY. */
|
||||
onStop?: () => void
|
||||
/** Optional optimistic-send hook: called with the sent text so the view can
|
||||
* render a "queued" echo until the real transcript turn lands (mobile parity). */
|
||||
onOptimisticSend?: (text: string, imagePaths?: string[]) => string | undefined
|
||||
/** Remove an optimistic echo when its delayed submit is canceled. */
|
||||
onOptimisticSendCanceled?: (pendingId: string) => void
|
||||
/** Called with a dispatched slash command (e.g. `/clear`) so the view can show
|
||||
* a small "Ran /clear" system line — slash commands aren't chat turns and
|
||||
* otherwise leave no visible trace that anything happened. */
|
||||
onSlashCommand?: (command: string) => void
|
||||
/** Picker-only agent commands continue in the hosted TUI after dispatch. */
|
||||
onSwitchToTerminal?: () => void
|
||||
/** Reads the hosted TUI's current rendered screen when chat is entered. */
|
||||
readTerminalScreen?: () => string | null
|
||||
}
|
||||
|
||||
export type NativeChatComposerHandle = {
|
||||
focus: () => boolean
|
||||
insertTypedText: (text: string) => boolean
|
||||
/** Handle a paste event captured at the pane root (the OS frequently
|
||||
* retargets the paste off the focused textarea, so its own onPaste can't be
|
||||
* relied on). An image is intercepted and attached; text falls through. */
|
||||
handlePasteEvent: (event: {
|
||||
clipboardData: DataTransfer | null
|
||||
preventDefault: () => void
|
||||
defaultPrevented: boolean
|
||||
}) => void
|
||||
/** Paste the clipboard into the composer with no event in hand (menu paste):
|
||||
* an image becomes an attachment, otherwise text is inserted at the caret. */
|
||||
pasteFromClipboard: () => void
|
||||
}
|
||||
|
||||
/**
|
||||
* Rich native input for the chat view. Sends prompts into the running agent
|
||||
* through the same verified runtime path as typed input (KTD4), so the agent
|
||||
@@ -107,6 +67,7 @@ export const NativeChatComposer = forwardRef<NativeChatComposerHandle, NativeCha
|
||||
function NativeChatComposer(
|
||||
{
|
||||
terminalTabId,
|
||||
paneKey,
|
||||
targetPtyId,
|
||||
agent,
|
||||
canSend = true,
|
||||
@@ -121,8 +82,10 @@ export const NativeChatComposer = forwardRef<NativeChatComposerHandle, NativeCha
|
||||
ref
|
||||
): React.JSX.Element {
|
||||
// Scope key shared with image attachments so an unsent draft + its attached
|
||||
// images survive the composer unmounting on a TUI/GUI toggle.
|
||||
const draftScopeKey = targetPtyId ?? terminalTabId
|
||||
// images survive both TUI/GUI toggles and PTY replacement on reconnect.
|
||||
// Why: local, SSH, and runtime reconnects can replace or temporarily clear
|
||||
// the PTY id. Pane identity is the stable ownership key for unsent input.
|
||||
const draftScopeKey = paneKey
|
||||
const { draft, setDraft } = useNativeChatDraft(draftScopeKey)
|
||||
const [caret, setCaret] = useState(draft.length)
|
||||
const [history, setHistory] = useState<HistoryState>(EMPTY_HISTORY)
|
||||
@@ -180,7 +143,7 @@ export const NativeChatComposer = forwardRef<NativeChatComposerHandle, NativeCha
|
||||
|
||||
const { imageAttachments, attachResolvedPaths, clearImageAttachments, removeImageAttachment } =
|
||||
useNativeChatComposerAttachments({
|
||||
attachmentScopeKey: targetPtyId ?? terminalTabId,
|
||||
attachmentScopeKey: paneKey,
|
||||
caret,
|
||||
resolveTarget,
|
||||
textareaRef,
|
||||
|
||||
@@ -1,4 +1,4 @@
|
||||
import type * as React from 'react'
|
||||
import { useEffect, useRef, type JSX, type ReactNode } from 'react'
|
||||
import { NativeChatEmptyState } from './NativeChatEmptyState'
|
||||
import {
|
||||
resolveNativeChatSession,
|
||||
@@ -7,7 +7,7 @@ import {
|
||||
} from './native-chat-pane-resolution'
|
||||
|
||||
export type NativeChatSessionGateProps = NativeChatPaneResolutionInput & {
|
||||
children: (resolution: NativeChatPaneResolution) => React.ReactNode
|
||||
children: (resolution: NativeChatPaneResolution) => ReactNode
|
||||
}
|
||||
|
||||
/** Keeps NativeChatView's agent/session resolution separate from the heavy
|
||||
@@ -15,8 +15,38 @@ export type NativeChatSessionGateProps = NativeChatPaneResolutionInput & {
|
||||
export function NativeChatSessionGate({
|
||||
children,
|
||||
...input
|
||||
}: NativeChatSessionGateProps): React.JSX.Element {
|
||||
const resolution = resolveNativeChatSession(input)
|
||||
}: NativeChatSessionGateProps): JSX.Element {
|
||||
const lastResolutionRef = useRef<NativeChatPaneResolution | null>(null)
|
||||
const currentResolution = resolveNativeChatSession(input)
|
||||
const previousResolution =
|
||||
lastResolutionRef.current?.paneKey === input.paneKey ? lastResolutionRef.current : null
|
||||
const resolution = (() => {
|
||||
if (!currentResolution) {
|
||||
return previousResolution
|
||||
}
|
||||
if (
|
||||
previousResolution?.agent === currentResolution.agent &&
|
||||
previousResolution.sessionId &&
|
||||
!currentResolution.sessionId
|
||||
) {
|
||||
// Why: reconnect snapshots can retain a pane/agent fallback while briefly
|
||||
// omitting provider-session metadata. Keep the conversation generation
|
||||
// stable so transcript IO and the composer do not reset mid-reconnect.
|
||||
return {
|
||||
...currentResolution,
|
||||
sessionId: previousResolution.sessionId,
|
||||
transcriptPath: previousResolution.transcriptPath
|
||||
}
|
||||
}
|
||||
return currentResolution
|
||||
})()
|
||||
useEffect(() => {
|
||||
if (resolution) {
|
||||
// Why: hook and title evidence are transport-fed and can vanish between a
|
||||
// disconnect and replay. Commit the last rendered conversation identity.
|
||||
lastResolutionRef.current = resolution
|
||||
}
|
||||
}, [resolution])
|
||||
if (!resolution) {
|
||||
return <NativeChatEmptyState kind="not-agent" />
|
||||
}
|
||||
|
||||
@@ -3,10 +3,12 @@
|
||||
import '@testing-library/jest-dom/vitest'
|
||||
|
||||
import { afterEach, describe, expect, it } from 'vitest'
|
||||
import { cleanup, render, screen } from '@testing-library/react'
|
||||
import { cleanup, fireEvent, render, screen } from '@testing-library/react'
|
||||
import type * as React from 'react'
|
||||
import type { AgentStatusEntry } from '../../../../shared/agent-status-types'
|
||||
import { NativeChatSessionGate } from './NativeChatSessionGate'
|
||||
import { useNativeChatDraft } from './use-native-chat-draft'
|
||||
import { clearNativeChatDraftCacheForTests } from './native-chat-draft-cache'
|
||||
|
||||
function entry(overrides: Partial<AgentStatusEntry> & Pick<AgentStatusEntry, 'paneKey'>) {
|
||||
return {
|
||||
@@ -33,9 +35,24 @@ function renderResolution(
|
||||
)
|
||||
}
|
||||
|
||||
function DraftProbe({ paneKey, sessionId }: { paneKey: string; sessionId: string | null }) {
|
||||
const { draft, setDraft } = useNativeChatDraft(paneKey)
|
||||
return (
|
||||
<label>
|
||||
Session {sessionId ?? 'none'}
|
||||
<input
|
||||
aria-label="Message draft"
|
||||
value={draft}
|
||||
onChange={(event) => setDraft(event.currentTarget.value)}
|
||||
/>
|
||||
</label>
|
||||
)
|
||||
}
|
||||
|
||||
describe('NativeChatSessionGate', () => {
|
||||
afterEach(() => {
|
||||
cleanup()
|
||||
clearNativeChatDraftCacheForTests()
|
||||
})
|
||||
|
||||
it.each(['codex', 'claude'] as const)(
|
||||
@@ -72,6 +89,54 @@ describe('NativeChatSessionGate', () => {
|
||||
)
|
||||
})
|
||||
|
||||
it('preserves the open composer, session, and draft through disconnect and reconnect', () => {
|
||||
const paneKey = 'tab-1:leaf-1'
|
||||
const connectedEntry = entry({
|
||||
paneKey,
|
||||
agentType: 'codex',
|
||||
providerSession: { key: 'session_id', id: 'codex-session' }
|
||||
})
|
||||
const renderGate = (
|
||||
agentStatusEntry?: AgentStatusEntry,
|
||||
launchAgent: 'codex' | null = null
|
||||
) => (
|
||||
<NativeChatSessionGate
|
||||
paneKey={paneKey}
|
||||
launchAgent={launchAgent}
|
||||
resolvedAgent={null}
|
||||
agentStatusEntry={agentStatusEntry}
|
||||
ptyId={agentStatusEntry ? 'pty-connected' : null}
|
||||
>
|
||||
{(resolution) => (
|
||||
<DraftProbe paneKey={resolution.paneKey} sessionId={resolution.sessionId} />
|
||||
)}
|
||||
</NativeChatSessionGate>
|
||||
)
|
||||
const view = render(renderGate(connectedEntry))
|
||||
const composer = screen.getByRole('textbox', { name: 'Message draft' })
|
||||
|
||||
fireEvent.change(composer, { target: { value: 'keep this unsent message' } })
|
||||
view.rerender(renderGate(undefined, 'codex'))
|
||||
|
||||
expect(screen.getByText('Session codex-session')).toBeInTheDocument()
|
||||
expect(screen.getByRole('textbox', { name: 'Message draft' })).toHaveValue(
|
||||
'keep this unsent message'
|
||||
)
|
||||
|
||||
view.rerender(renderGate())
|
||||
|
||||
expect(screen.getByText('Session codex-session')).toBeInTheDocument()
|
||||
expect(screen.getByRole('textbox', { name: 'Message draft' })).toHaveValue(
|
||||
'keep this unsent message'
|
||||
)
|
||||
|
||||
view.rerender(renderGate(connectedEntry))
|
||||
expect(screen.getByText('Session codex-session')).toBeInTheDocument()
|
||||
expect(screen.getByRole('textbox', { name: 'Message draft' })).toHaveValue(
|
||||
'keep this unsent message'
|
||||
)
|
||||
})
|
||||
|
||||
it('does not open native chat from an unsupported title fallback', () => {
|
||||
renderResolution({
|
||||
paneKey: 'tab-1:leaf-1',
|
||||
|
||||
@@ -430,6 +430,7 @@ function NativeChatResolvedView({
|
||||
<NativeChatComposer
|
||||
ref={composerRef}
|
||||
terminalTabId={terminalTabId}
|
||||
paneKey={paneKey}
|
||||
targetPtyId={targetPtyId}
|
||||
agent={agent}
|
||||
canSend={canSend}
|
||||
|
||||
@@ -1,5 +1,5 @@
|
||||
// Shared LRU bound for the native-chat composer's per-scope caches (draft text
|
||||
// and image attachments), both keyed by `targetPtyId ?? terminalTabId`. The
|
||||
// and image attachments), both keyed by stable pane identity. The
|
||||
// caches exist so an in-progress message survives the composer unmounting on a
|
||||
// TUI/GUI toggle, but a scope key for a permanently-removed pane is never
|
||||
// revisited, so without a bound its unsent entry would linger for the renderer's
|
||||
|
||||
@@ -0,0 +1,40 @@
|
||||
import type { AgentType } from '../../../../shared/agent-status-types'
|
||||
|
||||
export type NativeChatComposerProps = {
|
||||
/** Tab hosting the agent; used to resolve the live ptyId + runtime settings. */
|
||||
terminalTabId: string
|
||||
/** Stable split-leaf identity; unlike a PTY id, this survives reconnects. */
|
||||
paneKey: string
|
||||
/** Specific split-pane PTY this chat view owns. */
|
||||
targetPtyId: string | null
|
||||
agent: AgentType
|
||||
/** Guard desktop sends while a mobile client owns the terminal input lease. */
|
||||
canSend?: boolean
|
||||
/** True while the hosted TUI reports an in-flight turn; swaps Send to Stop. */
|
||||
isWorking?: boolean
|
||||
/** Interrupt the hosted agent, usually by sending ESC into the PTY. */
|
||||
onStop?: () => void
|
||||
/** Render an optimistic echo until the real transcript turn lands. */
|
||||
onOptimisticSend?: (text: string, imagePaths?: string[]) => string | undefined
|
||||
/** Remove an optimistic echo when its delayed submit is canceled. */
|
||||
onOptimisticSendCanceled?: (pendingId: string) => void
|
||||
/** Record a dispatched slash command that does not create a chat turn. */
|
||||
onSlashCommand?: (command: string) => void
|
||||
/** Picker-only agent commands continue in the hosted TUI after dispatch. */
|
||||
onSwitchToTerminal?: () => void
|
||||
/** Reads the hosted TUI's current rendered screen when chat is entered. */
|
||||
readTerminalScreen?: () => string | null
|
||||
}
|
||||
|
||||
export type NativeChatComposerHandle = {
|
||||
focus: () => boolean
|
||||
insertTypedText: (text: string) => boolean
|
||||
/** Routes pane-level paste events back to the composer field. */
|
||||
handlePasteEvent: (event: {
|
||||
clipboardData: DataTransfer | null
|
||||
preventDefault: () => void
|
||||
defaultPrevented: boolean
|
||||
}) => void
|
||||
/** Pastes clipboard content when no DOM paste event is available. */
|
||||
pasteFromClipboard: () => void
|
||||
}
|
||||
@@ -1,8 +1,8 @@
|
||||
// Module-level cache for the composer's in-progress draft text, keyed by the
|
||||
// same scope as image attachments (targetPtyId ?? terminalTabId). The composer
|
||||
// unmounts when the pane toggles back to the hosted terminal, so without this
|
||||
// the typed-but-unsent draft would be lost on every TUI/GUI round-trip. Mirrors
|
||||
// the attachment cache so both halves of an unsent message survive the toggle.
|
||||
// same stable pane scope as image attachments. The composer unmounts when the
|
||||
// pane toggles back to the hosted terminal, so without this the typed-but-unsent
|
||||
// draft would be lost on every TUI/GUI round-trip. Mirrors the attachment cache
|
||||
// so both halves of an unsent message survive toggles and reconnects.
|
||||
|
||||
import { setBoundedScopeCacheEntry } from './native-chat-composer-scope-cache'
|
||||
|
||||
|
||||
@@ -73,12 +73,32 @@ describe('resolveNativeChatLeafRoute', () => {
|
||||
chatLeafId: 'agent-leaf',
|
||||
activeLeafId: 'shell-leaf',
|
||||
chatLeafStillMounted: true,
|
||||
chatLeafIsEligible: true,
|
||||
activeLeafIsEligible: false
|
||||
})
|
||||
).toEqual({ chatLeafId: 'agent-leaf', exitChat: false })
|
||||
})
|
||||
|
||||
it('keeps chat attached through a transient eligibility loss and reconnect', () => {
|
||||
const disconnected = resolveNativeChatLeafRoute({
|
||||
isChatViewMode: true,
|
||||
chatLeafId: 'agent-leaf',
|
||||
activeLeafId: 'agent-leaf',
|
||||
chatLeafStillMounted: true,
|
||||
activeLeafIsEligible: false
|
||||
})
|
||||
|
||||
expect(disconnected).toEqual({ chatLeafId: 'agent-leaf', exitChat: false })
|
||||
expect(
|
||||
resolveNativeChatLeafRoute({
|
||||
isChatViewMode: true,
|
||||
chatLeafId: disconnected.chatLeafId,
|
||||
activeLeafId: 'agent-leaf',
|
||||
chatLeafStillMounted: true,
|
||||
activeLeafIsEligible: true
|
||||
})
|
||||
).toEqual({ chatLeafId: 'agent-leaf', exitChat: false })
|
||||
})
|
||||
|
||||
it('moves chat to an eligible active sibling after its leaf closes', () => {
|
||||
expect(
|
||||
resolveNativeChatLeafRoute({
|
||||
@@ -86,23 +106,21 @@ describe('resolveNativeChatLeafRoute', () => {
|
||||
chatLeafId: 'closed-leaf',
|
||||
activeLeafId: 'agent-sibling',
|
||||
chatLeafStillMounted: false,
|
||||
chatLeafIsEligible: false,
|
||||
activeLeafIsEligible: true
|
||||
})
|
||||
).toEqual({ chatLeafId: 'agent-sibling', exitChat: false })
|
||||
})
|
||||
|
||||
it('moves chat to an eligible active sibling when its mounted leaf becomes ineligible', () => {
|
||||
it('does not move chat when its mounted leaf temporarily becomes ineligible', () => {
|
||||
expect(
|
||||
resolveNativeChatLeafRoute({
|
||||
isChatViewMode: true,
|
||||
chatLeafId: 'stopped-agent',
|
||||
activeLeafId: 'agent-sibling',
|
||||
chatLeafStillMounted: true,
|
||||
chatLeafIsEligible: false,
|
||||
activeLeafIsEligible: true
|
||||
})
|
||||
).toEqual({ chatLeafId: 'agent-sibling', exitChat: false })
|
||||
).toEqual({ chatLeafId: 'stopped-agent', exitChat: false })
|
||||
})
|
||||
|
||||
it('exits chat rather than inheriting an active shell after close', () => {
|
||||
@@ -112,23 +130,21 @@ describe('resolveNativeChatLeafRoute', () => {
|
||||
chatLeafId: 'closed-agent',
|
||||
activeLeafId: 'shell-leaf',
|
||||
chatLeafStillMounted: false,
|
||||
chatLeafIsEligible: false,
|
||||
activeLeafIsEligible: false
|
||||
})
|
||||
).toEqual({ chatLeafId: null, exitChat: true })
|
||||
})
|
||||
|
||||
it('exits chat when its leaf becomes ineligible and the active leaf is a shell', () => {
|
||||
it('keeps chat open when its mounted leaf loses agent evidence', () => {
|
||||
expect(
|
||||
resolveNativeChatLeafRoute({
|
||||
isChatViewMode: true,
|
||||
chatLeafId: 'stopped-agent',
|
||||
activeLeafId: 'shell-leaf',
|
||||
chatLeafStillMounted: true,
|
||||
chatLeafIsEligible: false,
|
||||
activeLeafIsEligible: false
|
||||
})
|
||||
).toEqual({ chatLeafId: null, exitChat: true })
|
||||
).toEqual({ chatLeafId: 'stopped-agent', exitChat: false })
|
||||
})
|
||||
|
||||
it('attaches a tab-level chat request to the eligible active leaf', () => {
|
||||
@@ -138,7 +154,6 @@ describe('resolveNativeChatLeafRoute', () => {
|
||||
chatLeafId: null,
|
||||
activeLeafId: 'active-agent',
|
||||
chatLeafStillMounted: false,
|
||||
chatLeafIsEligible: false,
|
||||
activeLeafIsEligible: true
|
||||
})
|
||||
).toEqual({ chatLeafId: 'active-agent', exitChat: false })
|
||||
@@ -151,7 +166,6 @@ describe('resolveNativeChatLeafRoute', () => {
|
||||
chatLeafId: 'restored-agent',
|
||||
activeLeafId: null,
|
||||
chatLeafStillMounted: false,
|
||||
chatLeafIsEligible: false,
|
||||
activeLeafIsEligible: false
|
||||
})
|
||||
).toEqual({ chatLeafId: 'restored-agent', exitChat: false })
|
||||
@@ -164,7 +178,6 @@ describe('resolveNativeChatLeafRoute', () => {
|
||||
chatLeafId: 'agent-leaf',
|
||||
activeLeafId: 'agent-leaf',
|
||||
chatLeafStillMounted: true,
|
||||
chatLeafIsEligible: true,
|
||||
activeLeafIsEligible: true
|
||||
})
|
||||
).toEqual({ chatLeafId: null, exitChat: false })
|
||||
|
||||
@@ -71,13 +71,15 @@ export function resolveNativeChatLeafRoute(args: {
|
||||
chatLeafId: string | null
|
||||
activeLeafId: string | null
|
||||
chatLeafStillMounted: boolean
|
||||
chatLeafIsEligible: boolean
|
||||
activeLeafIsEligible: boolean
|
||||
}): NativeChatLeafRoute {
|
||||
if (!args.isChatViewMode) {
|
||||
return { chatLeafId: null, exitChat: false }
|
||||
}
|
||||
if (args.chatLeafId && args.chatLeafStillMounted && args.chatLeafIsEligible) {
|
||||
if (args.chatLeafId && args.chatLeafStillMounted) {
|
||||
// Why: agent/title evidence can disappear while local, SSH, or runtime
|
||||
// transports reconnect. A mounted owning pane is not a terminal lifecycle
|
||||
// event, so keep its chat surface until the pane itself is removed.
|
||||
return { chatLeafId: args.chatLeafId, exitChat: false }
|
||||
}
|
||||
// Manager hydration can briefly have no active pane; preserve the requested
|
||||
@@ -88,7 +90,7 @@ export function resolveNativeChatLeafRoute(args: {
|
||||
if (args.activeLeafIsEligible) {
|
||||
return { chatLeafId: args.activeLeafId, exitChat: false }
|
||||
}
|
||||
// Why: closing or invalidating the chat-owning leaf must not move its composer
|
||||
// onto a plain-shell sibling. Return the tab to terminal mode instead.
|
||||
// Why: removing the chat-owning leaf must not move its composer onto a
|
||||
// plain-shell sibling. Return the tab to terminal mode instead.
|
||||
return { chatLeafId: null, exitChat: true }
|
||||
}
|
||||
|
||||
@@ -3,9 +3,9 @@ import { readNativeChatDraftCache, writeNativeChatDraftCache } from './native-ch
|
||||
|
||||
/**
|
||||
* Composer draft state backed by the scope cache so a typed-but-unsent message
|
||||
* survives the composer unmounting on a TUI/GUI toggle. `scopeKey` is the same
|
||||
* key used for image attachments (targetPtyId ?? terminalTabId); when it changes
|
||||
* (the composer is reused for a different pane) the cached draft is reloaded.
|
||||
* survives the composer unmounting on a TUI/GUI toggle. `scopeKey` is the stable
|
||||
* pane key also used for image attachments; when it changes (the composer is
|
||||
* reused for a different pane) the cached draft is reloaded.
|
||||
*/
|
||||
export function useNativeChatDraft(scopeKey: string): {
|
||||
draft: string
|
||||
|
||||
@@ -2987,7 +2987,6 @@ export default function TerminalPane({
|
||||
chatLeafId,
|
||||
activeLeafId,
|
||||
chatLeafStillMounted,
|
||||
chatLeafIsEligible: isChatEligibleForLeaf(chatLeafId),
|
||||
activeLeafIsEligible: isChatEligibleForLeaf(activeLeafId)
|
||||
})
|
||||
if (route.chatLeafId !== chatLeafId) {
|
||||
|
||||
@@ -459,10 +459,13 @@ describe('createRemoteRuntimePtyTransport', () => {
|
||||
)
|
||||
})
|
||||
|
||||
it('retires stale host-owned terminal handles without surfacing pane errors', async () => {
|
||||
it('keeps the regular TUI surface and draft through stale-handle reconnect', async () => {
|
||||
const { createRemoteRuntimePtyTransport } = await import('./remote-runtime-pty-transport')
|
||||
const onError = vi.fn()
|
||||
const onPtyExit = vi.fn()
|
||||
const onExit = vi.fn()
|
||||
const onDisconnect = vi.fn()
|
||||
const renderedScreen: string[] = []
|
||||
const transport = createRemoteRuntimePtyTransport('env-1', {
|
||||
worktreeId: 'wt-1',
|
||||
tabId: 'web-terminal-tab-1',
|
||||
@@ -474,19 +477,97 @@ describe('createRemoteRuntimePtyTransport', () => {
|
||||
existingPtyId: 'remote:env-1@@terminal-stale',
|
||||
cols: 80,
|
||||
rows: 24,
|
||||
callbacks: { onError }
|
||||
callbacks: {
|
||||
onError,
|
||||
onExit,
|
||||
onDisconnect,
|
||||
onData: (data) => renderedScreen.push(data),
|
||||
onReplayData: (data) => renderedScreen.push(data)
|
||||
}
|
||||
})
|
||||
await vi.waitFor(() => expect(subscriptionSendBinary).toHaveBeenCalled())
|
||||
const { streamId } = latestSubscribePayload()
|
||||
const initialStreamId = latestSubscribePayload().streamId
|
||||
const draft = 'QA regular reconnect draft - keep this unsent'
|
||||
emitOutput(initialStreamId, draft)
|
||||
|
||||
runtimeCall.mockImplementation(async (args: { method: string }) =>
|
||||
args.method === 'session.tabs.list'
|
||||
? {
|
||||
ok: true,
|
||||
result: {
|
||||
worktree: 'wt-1',
|
||||
publicationEpoch: 'epoch-1',
|
||||
snapshotVersion: 2,
|
||||
activeGroupId: null,
|
||||
activeTabId: 'tab-1::pane:1',
|
||||
activeTabType: 'terminal',
|
||||
tabs: [
|
||||
{
|
||||
type: 'terminal',
|
||||
id: 'tab-1::pane:1',
|
||||
parentTabId: 'tab-1',
|
||||
leafId: 'pane:1',
|
||||
title: 'Claude Code',
|
||||
isActive: true,
|
||||
status: 'ready',
|
||||
terminal: 'terminal-reconnected'
|
||||
}
|
||||
]
|
||||
}
|
||||
}
|
||||
: { ok: true, result: {} }
|
||||
)
|
||||
|
||||
subscriptionCallbacks?.onResponse({
|
||||
ok: true,
|
||||
result: { type: 'error', streamId, message: 'terminal_handle_stale' }
|
||||
result: { type: 'error', streamId: initialStreamId, message: 'terminal_handle_stale' }
|
||||
})
|
||||
|
||||
await vi.waitFor(() =>
|
||||
expect(latestSubscribePayload()).toMatchObject({ terminal: 'terminal-reconnected' })
|
||||
)
|
||||
const replacementStreamId = latestSubscribePayload().streamId
|
||||
emitSnapshot(replacementStreamId, draft)
|
||||
|
||||
expect(onError).not.toHaveBeenCalled()
|
||||
expect(onPtyExit).toHaveBeenCalledWith('remote:env-1@@terminal-stale')
|
||||
expect(onPtyExit).not.toHaveBeenCalled()
|
||||
expect(onExit).not.toHaveBeenCalled()
|
||||
expect(onDisconnect).not.toHaveBeenCalled()
|
||||
expect(transport.getPtyId()).toBe('remote:env-1@@terminal-reconnected')
|
||||
expect(transport.isConnected()).toBe(true)
|
||||
expect(renderedScreen.at(-1)).toBe(draft)
|
||||
})
|
||||
|
||||
it('still retires the regular TUI surface after an explicit terminal exit', async () => {
|
||||
const { createRemoteRuntimePtyTransport } = await import('./remote-runtime-pty-transport')
|
||||
const onPtyExit = vi.fn()
|
||||
const transport = createRemoteRuntimePtyTransport('env-1', {
|
||||
worktreeId: 'wt-1',
|
||||
tabId: 'web-terminal-tab-1',
|
||||
leafId: 'pane:1',
|
||||
onPtyExit
|
||||
})
|
||||
|
||||
transport.attach({
|
||||
existingPtyId: 'remote:env-1@@terminal-exited',
|
||||
cols: 80,
|
||||
rows: 24,
|
||||
callbacks: {}
|
||||
})
|
||||
await vi.waitFor(() => expect(subscriptionSendBinary).toHaveBeenCalled())
|
||||
|
||||
subscriptionCallbacks?.onResponse({
|
||||
ok: true,
|
||||
result: {
|
||||
type: 'error',
|
||||
streamId: latestSubscribePayload().streamId,
|
||||
message: 'terminal_exited'
|
||||
}
|
||||
})
|
||||
|
||||
expect(onPtyExit).toHaveBeenCalledWith('remote:env-1@@terminal-exited')
|
||||
expect(transport.getPtyId()).toBeNull()
|
||||
expect(transport.isConnected()).toBe(false)
|
||||
})
|
||||
|
||||
it('ignores stale stream end after reattaching a newer remote terminal', async () => {
|
||||
|
||||
@@ -42,9 +42,12 @@ const REMOTE_TERMINAL_VIEWPORT_FLUSH_MS = 33
|
||||
const HOST_SESSION_ATTACH_POLL_MS = 150
|
||||
const HOST_SESSION_ATTACH_TIMEOUT_MS = 15_000
|
||||
|
||||
function isRemoteTerminalStaleMessage(message: string): boolean {
|
||||
return message.includes('terminal_handle_stale')
|
||||
}
|
||||
|
||||
function isRemoteTerminalGoneMessage(message: string): boolean {
|
||||
return (
|
||||
message.includes('terminal_handle_stale') ||
|
||||
message.includes('terminal_exited') ||
|
||||
message.includes('terminal_gone') ||
|
||||
message.includes('no_connected_pty')
|
||||
@@ -421,10 +424,20 @@ export function createRemoteRuntimePtyTransport(
|
||||
// flowing — informational, not fatal, so never surface a red xterm banner.
|
||||
return
|
||||
}
|
||||
if (isRemoteTerminalStaleMessage(message)) {
|
||||
if (tabId && isWebTerminalSurfaceTabId(tabId)) {
|
||||
// Why: reconnect can re-mint a mirrored pane's handle while its host tab
|
||||
// remains alive. Keep xterm/composer state mounted while we re-resolve it.
|
||||
closeMultiplexedStream()
|
||||
scheduleResubscribeAfterTransportClose()
|
||||
} else {
|
||||
retireRemoteTerminalId()
|
||||
}
|
||||
return
|
||||
}
|
||||
if (isRemoteTerminalGoneMessage(message)) {
|
||||
// Why: paired web clients consume host-published PTY handles. If the host
|
||||
// retires one between snapshots, clear this mirror and wait for the next
|
||||
// session-tabs update instead of surfacing a red xterm error.
|
||||
// Why: an explicit terminal-gone response is terminal lifecycle evidence,
|
||||
// unlike a replaceable stale handle observed during reconnect.
|
||||
retireRemoteTerminalId()
|
||||
return
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user