fix(native chat): retain transcript while reconnecting (STA-3333) (#12495)
* fix(mobile): keep the cached transcript visible while reconnecting A manual retry closes the client and opens a fresh one, so the chat session hook saw a new client under an unchanged identity, dropped its settled read, and handed out an empty list — the transcript collapsed to a full-screen spinner until the swapped client's snapshot landed. Hold the last settled list per identity (captured post-commit) and keep rendering it while the re-read is in flight. `transcriptLoading` still gates consumers that decide from an empty transcript, so the launch-draft seed is unaffected. The held list is keyed by a new `sourceIdentity` (host/workspace) in addition to agent/session/transcript, so it can never serve another source's messages. Refs STA-3333. * test(mobile): assert the whole reconnect window, not just its first frame The re-subscribe lands a commit after the first render of the swap, so a regression that cleared the held list there left frame 0 green and still blanked the transcript. Verified: clearing the cache in the subscribe cleanup now fails this test, where before only the view-toggle test caught it. * fix(mobile): don't derive a tappable ask card from the held transcript The cache this PR adds keeps the previous list rendered while a swapped client re-reads. useMobileNativeChatPrompts was the one consumer reading `messages` without honouring `transcriptLoading`, so an ask answered on the terminal resurrected as a live, tappable card during that window. Gating on `transcriptLoading` is exactly base behaviour: `setRead` only ever stores 'ready'/'error', so status==='loading' implied an empty list before this PR. The live `askFromStatus` path is untouched. * chore: keep merge formatting scoped
This commit is contained in:
parent
aa64ac9606
commit
79896cb9a6
|
|
@ -6,6 +6,7 @@ import {
|
|||
type MutableRefObject,
|
||||
type SetStateAction
|
||||
} from 'react'
|
||||
import { encodeNativeChatTranscriptIdentity } from '../../../src/shared/native-chat-transcript-retention'
|
||||
import { useMobileSessionViewMode } from './use-mobile-session-view-mode'
|
||||
import type { RpcClient } from '../transport/rpc-client'
|
||||
import type { ConnectionState } from '../transport/types'
|
||||
|
|
@ -153,6 +154,7 @@ export function useMobileNativeChatController(args: {
|
|||
|
||||
const nativeChatSession = useMobileNativeChatSession({
|
||||
client,
|
||||
sourceIdentity: encodeNativeChatTranscriptIdentity([hostId, worktreeId]),
|
||||
agent: activeChatResolution?.agent ?? null,
|
||||
sessionId: activeChatSessionId,
|
||||
transcriptPath: activeChatResolution?.transcriptPath ?? null
|
||||
|
|
@ -203,7 +205,8 @@ export function useMobileNativeChatController(args: {
|
|||
} = useMobileNativeChatPrompts({
|
||||
enabled: activeChatResolution != null,
|
||||
status: nativeChatStatus,
|
||||
messages: nativeChatSession.messages
|
||||
messages: nativeChatSession.messages,
|
||||
transcriptLoading: nativeChatSession.transcriptLoading
|
||||
})
|
||||
// A never-read transcript cannot prove that a dismissed prompt cleared.
|
||||
const nativeChatTranscriptSettled =
|
||||
|
|
|
|||
|
|
@ -15,14 +15,16 @@ const ASK = JSON.stringify({
|
|||
|
||||
function promptsFor(
|
||||
status: Partial<AgentStatusEntry> | null,
|
||||
messages: NativeChatMessage[] = []
|
||||
messages: NativeChatMessage[] = [],
|
||||
transcriptLoading = false
|
||||
): ReturnType<typeof useMobileNativeChatPrompts> {
|
||||
let captured: ReturnType<typeof useMobileNativeChatPrompts> | undefined
|
||||
function Probe(): null {
|
||||
captured = useMobileNativeChatPrompts({
|
||||
enabled: true,
|
||||
status: status as AgentStatusEntry | null,
|
||||
messages
|
||||
messages,
|
||||
transcriptLoading
|
||||
})
|
||||
return null
|
||||
}
|
||||
|
|
@ -118,6 +120,18 @@ describe('useMobileNativeChatPrompts ask state gate', () => {
|
|||
expect(promptsFor(null, askMessages).ask).not.toBeNull()
|
||||
})
|
||||
|
||||
it('withholds retained transcript asks while the replacement read is unsettled', () => {
|
||||
const prompts = promptsFor({ state: 'done' }, askMessages, true)
|
||||
expect(prompts.ask).toBeNull()
|
||||
expect(prompts.detectedAsk).toBeNull()
|
||||
})
|
||||
|
||||
it('keeps a paused live status ask authoritative while the read is unsettled', () => {
|
||||
const prompts = promptsFor({ state: 'waiting', interactivePrompt: ASK }, askMessages, true)
|
||||
expect(prompts.ask).toMatchObject({ questions: [{ question: 'Which path?' }] })
|
||||
expect(prompts.detectedAsk).not.toBeNull()
|
||||
})
|
||||
|
||||
it('does not leak a paused-out sticky status prompt through the transcript fallback', () => {
|
||||
// The post-answer window: the status still carries the prompt while flipping
|
||||
// to `working`, and the transcript's tool-result row has not landed yet, so
|
||||
|
|
|
|||
|
|
@ -1,7 +1,7 @@
|
|||
import { useMemo } from 'react'
|
||||
import type { AgentStatusEntry } from '../../../src/shared/agent-status-types'
|
||||
import type { NativeChatMessage } from '../../../src/shared/native-chat-types'
|
||||
import { extractPendingAsk, parseAskFromStatus } from './mobile-native-chat-ask'
|
||||
import { parseAskFromStatus, resolveNativeChatAsk } from './mobile-native-chat-ask'
|
||||
import { detectAgentPermission, parseApprovalFromStatus } from './mobile-native-chat-permission'
|
||||
import { parseAgentQuestion } from './mobile-native-chat-question'
|
||||
|
||||
|
|
@ -17,8 +17,11 @@ export function useMobileNativeChatPrompts(args: {
|
|||
enabled: boolean
|
||||
status: AgentStatusEntry | null | undefined
|
||||
messages: readonly NativeChatMessage[]
|
||||
/** True while `messages` is an unsettled read (including the cached list held
|
||||
* across a reconnect). Required: an ask derived from it may already be answered. */
|
||||
transcriptLoading: boolean
|
||||
}): MobileNativeChatPrompts {
|
||||
const { enabled, status, messages } = args
|
||||
const { enabled, status, messages, transcriptLoading } = args
|
||||
const blocked = status?.state === 'waiting' || status?.state === 'blocked'
|
||||
// Both permission paths sit inside the paused gate: an approval envelope can
|
||||
// outlive its answer (the host keeps it sticky), so only a waiting/blocked
|
||||
|
|
@ -38,11 +41,16 @@ export function useMobileNativeChatPrompts(args: {
|
|||
() => parseAskFromStatus(status?.interactivePrompt, status?.toolName),
|
||||
[status?.interactivePrompt, status?.toolName]
|
||||
)
|
||||
const askFromMessages = useMemo(
|
||||
() => (askFromStatus ? null : extractPendingAsk(messages)),
|
||||
[askFromStatus, messages]
|
||||
const resolvedAsk = useMemo(
|
||||
() =>
|
||||
resolveNativeChatAsk({
|
||||
liveAsk: askFromStatus,
|
||||
messages,
|
||||
transcriptSettled: !transcriptLoading
|
||||
}),
|
||||
[askFromStatus, transcriptLoading, messages]
|
||||
)
|
||||
|
||||
const askFromMessages = askFromStatus ? null : resolvedAsk
|
||||
const detectedAsk = askFromStatus ?? askFromMessages
|
||||
|
||||
return {
|
||||
|
|
|
|||
|
|
@ -36,6 +36,7 @@ describe('useMobileNativeChatSession', () => {
|
|||
function Harness({ client }: { client: RpcClient | null }): null {
|
||||
state = useMobileNativeChatSession({
|
||||
client,
|
||||
sourceIdentity: 'host-a\0workspace-a',
|
||||
agent: 'claude',
|
||||
sessionId: 'session',
|
||||
transcriptPath: null
|
||||
|
|
@ -456,14 +457,17 @@ describe('useMobileNativeChatSession transcriptLoading', () => {
|
|||
function Harness({
|
||||
client,
|
||||
sessionId,
|
||||
agent = 'claude'
|
||||
agent = 'claude',
|
||||
sourceIdentity = 'host-a\0workspace-a'
|
||||
}: {
|
||||
client: RpcClient | null
|
||||
sessionId: string | null
|
||||
agent?: string | null
|
||||
sourceIdentity?: string
|
||||
}): null {
|
||||
const session = useMobileNativeChatSession({
|
||||
client,
|
||||
sourceIdentity,
|
||||
agent,
|
||||
sessionId,
|
||||
transcriptPath: null
|
||||
|
|
@ -506,7 +510,8 @@ describe('useMobileNativeChatSession transcriptLoading', () => {
|
|||
it('re-reads instead of resurfacing a settled read when the same identity returns', async () => {
|
||||
// Leaving chat view nulls the agent, then returning restores the identity a
|
||||
// settled read already matched — but its list was cleared, so trusting it
|
||||
// would report 'ready' over an empty transcript.
|
||||
// would report 'ready' over an empty transcript. The last settled list for
|
||||
// this identity keeps rendering while the re-read is in flight.
|
||||
const subscribe: RpcClient['subscribe'] = vi.fn((_method, _params, onData) => {
|
||||
onData({ type: 'snapshot', messages: [message('a-1')], hasMore: false })
|
||||
return () => {}
|
||||
|
|
@ -524,12 +529,17 @@ describe('useMobileNativeChatSession transcriptLoading', () => {
|
|||
renderer?.update(createElement(Harness, { client, sessionId: 'session-a', agent: 'claude' }))
|
||||
)
|
||||
|
||||
expect(renders[0]).toMatchObject({ status: 'loading', transcriptLoading: true, ids: [] })
|
||||
expect(renders[0]).toMatchObject({
|
||||
status: 'loading',
|
||||
transcriptLoading: true,
|
||||
ids: ['a-1']
|
||||
})
|
||||
})
|
||||
|
||||
it('re-reads instead of resurfacing a settled read after a reconnect', async () => {
|
||||
// A reconnect swaps the client without moving the identity; the effect
|
||||
// re-subscribes and clears the list, so the old outcome must not stand.
|
||||
it('keeps the last settled list rendered while a swapped client re-reads', async () => {
|
||||
// A manual-retry reconnect swaps the client without moving the identity; the
|
||||
// old outcome must not stand ('loading', not 'ready'), but the cached
|
||||
// transcript keeps rendering instead of collapsing to a full-screen spinner.
|
||||
const subscribe: RpcClient['subscribe'] = vi.fn((_method, _params, onData) => {
|
||||
onData({ type: 'snapshot', messages: [message('a-1')], hasMore: false })
|
||||
return () => {}
|
||||
|
|
@ -538,13 +548,61 @@ describe('useMobileNativeChatSession transcriptLoading', () => {
|
|||
await mountAt(client, 'session-a')
|
||||
expect(renders.at(-1)).toMatchObject({ status: 'ready' })
|
||||
|
||||
const reconnected = { subscribe: vi.fn(() => () => {}) } as unknown as RpcClient
|
||||
let emitFresh: (frame: unknown) => void = () => {}
|
||||
const reconnected = {
|
||||
subscribe: vi.fn((_method: string, _params: unknown, onData: (frame: unknown) => void) => {
|
||||
emitFresh = onData
|
||||
return () => {}
|
||||
})
|
||||
} as unknown as RpcClient
|
||||
renders.length = 0
|
||||
await act(async () =>
|
||||
renderer?.update(createElement(Harness, { client: reconnected, sessionId: 'session-a' }))
|
||||
)
|
||||
|
||||
expect(renders[0]).toMatchObject({ status: 'loading', transcriptLoading: true, ids: [] })
|
||||
expect(renders[0]).toMatchObject({
|
||||
status: 'loading',
|
||||
transcriptLoading: true,
|
||||
ids: ['a-1']
|
||||
})
|
||||
// Every commit of the window, not just the first: the re-subscribe lands a
|
||||
// commit after it, so clearing the cache there blanks the transcript the
|
||||
// user actually sees while leaving a first-frame assertion green.
|
||||
expect([...new Set(renders.map((entry) => entry.ids.join(',')))]).toEqual(['a-1'])
|
||||
|
||||
// The fresh client's snapshot supersedes the held list.
|
||||
await act(async () =>
|
||||
emitFresh({ type: 'snapshot', messages: [message('a-1'), message('a-2')], hasMore: false })
|
||||
)
|
||||
expect(renders.at(-1)).toMatchObject({
|
||||
status: 'ready',
|
||||
transcriptLoading: false,
|
||||
ids: ['a-1', 'a-2']
|
||||
})
|
||||
})
|
||||
|
||||
it('never holds a cached list across a host/workspace source change', async () => {
|
||||
const firstClient = {
|
||||
subscribe: vi.fn((_method: string, _params: unknown, onData: (frame: unknown) => void) => {
|
||||
onData({ type: 'snapshot', messages: [message('source-a')], hasMore: false })
|
||||
return () => {}
|
||||
})
|
||||
} as unknown as RpcClient
|
||||
await mountAt(firstClient, 'session-a')
|
||||
|
||||
const secondClient = { subscribe: vi.fn(() => () => {}) } as unknown as RpcClient
|
||||
renders.length = 0
|
||||
await act(async () =>
|
||||
renderer?.update(
|
||||
createElement(Harness, {
|
||||
client: secondClient,
|
||||
sessionId: 'session-a',
|
||||
sourceIdentity: 'host-b\0workspace-b'
|
||||
})
|
||||
)
|
||||
)
|
||||
|
||||
expect(renders[0]).toMatchObject({ status: 'loading', ids: [] })
|
||||
})
|
||||
|
||||
it('never hands out the previous session’s messages under the new session id', async () => {
|
||||
|
|
|
|||
|
|
@ -1,4 +1,8 @@
|
|||
import { useCallback, useEffect, useRef, useState } from 'react'
|
||||
import {
|
||||
createNativeChatTranscriptRetention,
|
||||
encodeNativeChatTranscriptIdentity
|
||||
} from '../../../src/shared/native-chat-transcript-retention'
|
||||
import type { NativeChatMessage } from '../../../src/shared/native-chat-types'
|
||||
import { buildNativeChatSubscriptionId } from '../../../src/shared/native-chat-stream-unsubscribe'
|
||||
import type { RpcClient } from '../transport/rpc-client'
|
||||
|
|
@ -28,9 +32,6 @@ export type MobileNativeChatSession = {
|
|||
loadEarlier: () => void
|
||||
}
|
||||
|
||||
// Stable empty reference so a not-yet-current read doesn't churn consumers.
|
||||
const EMPTY_MESSAGES: NativeChatMessage[] = []
|
||||
|
||||
// Small first page for a fast first paint; grows by a page as the user scrolls.
|
||||
const INITIAL_LIMIT = 40
|
||||
const PAGE = 60
|
||||
|
|
@ -45,13 +46,20 @@ type ReadSessionResult =
|
|||
* an ordered tail); live appends merge by id so order stays stable. */
|
||||
export function useMobileNativeChatSession(args: {
|
||||
client: RpcClient | null
|
||||
/** Stable host/workspace source; unlike `client`, it survives manual reconnect. */
|
||||
sourceIdentity: string
|
||||
agent: string | null
|
||||
sessionId: string | null
|
||||
transcriptPath: string | null
|
||||
}): MobileNativeChatSession {
|
||||
const { client, agent, sessionId, transcriptPath } = args
|
||||
const { client, sourceIdentity, agent, sessionId, transcriptPath } = args
|
||||
const [messages, setMessages] = useState<NativeChatMessage[]>([])
|
||||
const identity = `${agent ?? ''}\0${sessionId ?? ''}\0${transcriptPath ?? ''}`
|
||||
const identity = encodeNativeChatTranscriptIdentity([
|
||||
sourceIdentity,
|
||||
agent,
|
||||
sessionId,
|
||||
transcriptPath
|
||||
])
|
||||
// Pre-read status is a pure function of the props, so derive it rather than
|
||||
// letting the effect write it a commit later.
|
||||
const initialStatus: MobileNativeChatStatus =
|
||||
|
|
@ -95,6 +103,13 @@ export function useMobileNativeChatSession(args: {
|
|||
// Whether this subscription already delivered its base snapshot; later
|
||||
// snapshots on the same subscription are reconnect replays, not fresh bases.
|
||||
const snapshotSeenRef = useRef(false)
|
||||
const transcriptRetentionRef = useRef(createNativeChatTranscriptRetention())
|
||||
const settledReady = settled?.status === 'ready'
|
||||
useEffect(() => {
|
||||
if (settledReady) {
|
||||
transcriptRetentionRef.current.capture(identity, messages)
|
||||
}
|
||||
}, [identity, messages, settledReady])
|
||||
|
||||
// Replace the base list (read results are an ordered tail). Resets the merger
|
||||
// cache so the index is rebuilt once over the new base.
|
||||
|
|
@ -258,10 +273,17 @@ export function useMobileNativeChatSession(args: {
|
|||
})()
|
||||
}, [client, agent, sessionId, transcriptPath, hasMore, setList])
|
||||
|
||||
const visibleMessages = transcriptRetentionRef.current.visible({
|
||||
identity,
|
||||
messages,
|
||||
settled: settledReady,
|
||||
loading: status === 'loading'
|
||||
})
|
||||
|
||||
return {
|
||||
// Withheld until the settled read belongs to this identity: the effect that
|
||||
// clears the previous tab's list is passive, so `messages` lags a commit.
|
||||
messages: settled ? messages : EMPTY_MESSAGES,
|
||||
messages: visibleMessages,
|
||||
status,
|
||||
transcriptLoading: status === 'loading',
|
||||
error,
|
||||
|
|
|
|||
|
|
@ -48,13 +48,15 @@ function renderCard(canSend = true): ReturnType<typeof render> {
|
|||
function cardElement(
|
||||
canSend = true,
|
||||
messages?: readonly NativeChatMessage[],
|
||||
onShowingQuestionChange?: (showing: boolean) => void
|
||||
onShowingQuestionChange?: (showing: boolean) => void,
|
||||
transcriptSettled = true
|
||||
): React.JSX.Element {
|
||||
return (
|
||||
<NativeChatInteractiveCard
|
||||
paneKey="tab-1:leaf-1"
|
||||
canSend={canSend}
|
||||
messages={messages}
|
||||
transcriptSettled={transcriptSettled}
|
||||
onShowingQuestionChange={onShowingQuestionChange}
|
||||
send={{
|
||||
sendAnswer: mocks.sendAnswer,
|
||||
|
|
@ -233,6 +235,12 @@ describe('NativeChatInteractiveCard transcript fallback', () => {
|
|||
expect(onShowingQuestionChange).toHaveBeenCalledWith(true)
|
||||
})
|
||||
|
||||
it('withholds a retained transcript ask while its replacement read is unsettled', () => {
|
||||
render(cardElement(true, [askCallMessage('Stale transcript question?')], undefined, false))
|
||||
|
||||
expect(screen.queryByText('Stale transcript question?')).not.toBeInTheDocument()
|
||||
})
|
||||
|
||||
it('prefers live status over the transcript when both carry a prompt', () => {
|
||||
storeState.agentStatusByPaneKey['tab-1:leaf-1'].interactivePrompt = INITIAL_PROMPT
|
||||
render(cardElement(true, [askCallMessage('Stale transcript question?')]))
|
||||
|
|
|
|||
|
|
@ -1,6 +1,6 @@
|
|||
import { useCallback, useEffect, useLayoutEffect, useMemo, useRef, useState } from 'react'
|
||||
import { useAppStore } from '../../store'
|
||||
import { extractPendingAsk } from '../../../../shared/native-chat-ask'
|
||||
import { resolveNativeChatAsk } from '../../../../shared/native-chat-ask'
|
||||
import type { NativeChatMessage } from '../../../../shared/native-chat-types'
|
||||
import { parseInteractivePrompt } from './native-chat-interactive-prompt'
|
||||
import { nativeChatCardDismissKey } from './native-chat-dismiss-key'
|
||||
|
|
@ -33,6 +33,7 @@ export function NativeChatInteractiveCard({
|
|||
send,
|
||||
canSend,
|
||||
messages,
|
||||
transcriptSettled,
|
||||
onShowingQuestionChange,
|
||||
answerInputRef
|
||||
}: {
|
||||
|
|
@ -42,6 +43,7 @@ export function NativeChatInteractiveCard({
|
|||
/** Transcript to fall back on when live status carries no prompt. Pass the
|
||||
* command-boundary-trimmed messages so an ask abandoned via `/clear` stays gone. */
|
||||
messages?: readonly NativeChatMessage[]
|
||||
transcriptSettled: boolean
|
||||
/** Reports whether a question card is on screen so the view can replace the
|
||||
* composer with it (the card's free-text row is the answer input). */
|
||||
onShowingQuestionChange?: (showing: boolean) => void
|
||||
|
|
@ -59,12 +61,16 @@ export function NativeChatInteractiveCard({
|
|||
|
||||
const card = useMemo(() => {
|
||||
const statusCard = parseInteractivePrompt(interactivePrompt, interactiveToolName ?? undefined)
|
||||
if (statusCard || !messages) {
|
||||
if (statusCard?.kind === 'approval') {
|
||||
return statusCard
|
||||
}
|
||||
const prompt = extractPendingAsk(messages)
|
||||
const prompt = resolveNativeChatAsk({
|
||||
liveAsk: statusCard?.prompt ?? null,
|
||||
messages: messages ?? [],
|
||||
transcriptSettled: transcriptSettled && messages != null
|
||||
})
|
||||
return prompt ? { kind: 'question' as const, prompt } : null
|
||||
}, [interactivePrompt, interactiveToolName, messages])
|
||||
}, [interactivePrompt, interactiveToolName, messages, transcriptSettled])
|
||||
const cardKey = useMemo(() => nativeChatCardDismissKey(card), [card])
|
||||
const [dismissedKey, setDismissedKey] = useState<string | null>(null)
|
||||
// A question answer is a paced multi-step write (body→Enter per question); keep
|
||||
|
|
|
|||
|
|
@ -3,7 +3,7 @@ import { useShallow } from 'zustand/react/shallow'
|
|||
import { useAppStore } from '../../store'
|
||||
import { useNativeChatLaunchDraftSignal } from './use-native-chat-launch-draft-adoption'
|
||||
import type { NativeChatSession } from '../../../../shared/native-chat-types'
|
||||
import { useNativeChatLiveSession } from './use-native-chat-live-session'
|
||||
import { useNativeChatRetainedSession } from './use-native-chat-retained-session'
|
||||
import { selectNativeChatViewState } from './native-chat-view-state'
|
||||
import { NativeChatMessageList } from './NativeChatMessageList'
|
||||
import { NativeChatComposer, type NativeChatComposerHandle } from './NativeChatComposer'
|
||||
|
|
@ -131,7 +131,7 @@ function NativeChatResolvedView({
|
|||
const runtimeEnvironmentId = useAppStore((s) =>
|
||||
selectNativeChatRuntimeEnvironmentId(s, terminalTabId)
|
||||
)
|
||||
const session = useNativeChatLiveSession({
|
||||
const session = useNativeChatRetainedSession({
|
||||
paneKey,
|
||||
agent,
|
||||
sessionId,
|
||||
|
|
@ -430,6 +430,7 @@ function NativeChatResolvedView({
|
|||
send={interactiveSend}
|
||||
canSend={canSend}
|
||||
messages={sessionAfterCommandBoundaries.messages}
|
||||
transcriptSettled={session.readPhase === 'ready'}
|
||||
onShowingQuestionChange={setQuestionActive}
|
||||
answerInputRef={questionAnswerInputRef}
|
||||
/>
|
||||
|
|
|
|||
|
|
@ -0,0 +1,102 @@
|
|||
// @vitest-environment happy-dom
|
||||
|
||||
import { act, createElement } from 'react'
|
||||
import { createRoot, type Root } from 'react-dom/client'
|
||||
import { afterEach, describe, expect, it, vi } from 'vitest'
|
||||
import type { NativeChatMessage } from '../../../../shared/native-chat-types'
|
||||
import type {
|
||||
NativeChatLiveSession,
|
||||
UseNativeChatLiveSessionArgs
|
||||
} from './use-native-chat-live-session'
|
||||
|
||||
const { liveSession } = vi.hoisted(() => ({ liveSession: vi.fn() }))
|
||||
|
||||
vi.mock('./use-native-chat-live-session', () => ({ useNativeChatLiveSession: liveSession }))
|
||||
|
||||
import { useNativeChatRetainedSession } from './use-native-chat-retained-session'
|
||||
|
||||
const ARGS: UseNativeChatLiveSessionArgs = {
|
||||
paneKey: 'tab:leaf',
|
||||
agent: 'claude',
|
||||
sessionId: 'session',
|
||||
runtimeEnvironmentId: 'owner-a'
|
||||
}
|
||||
|
||||
function message(id: string): NativeChatMessage {
|
||||
return { id, role: 'assistant', blocks: [], timestamp: 0, source: 'transcript' }
|
||||
}
|
||||
|
||||
function session(
|
||||
readPhase: NativeChatLiveSession['readPhase'],
|
||||
messages: NativeChatMessage[],
|
||||
sessionId: string | null = 'session'
|
||||
): NativeChatLiveSession {
|
||||
return {
|
||||
agent: 'claude',
|
||||
sessionId,
|
||||
messages,
|
||||
status: readPhase === 'loading' ? 'loading' : 'ready',
|
||||
hasMore: false,
|
||||
loadingEarlier: false,
|
||||
loadEarlier: vi.fn(),
|
||||
readPhase
|
||||
}
|
||||
}
|
||||
|
||||
describe('useNativeChatRetainedSession', () => {
|
||||
let root: Root | null = null
|
||||
let latest: NativeChatLiveSession | null = null
|
||||
|
||||
function Probe(props: UseNativeChatLiveSessionArgs): null {
|
||||
latest = useNativeChatRetainedSession(props)
|
||||
return null
|
||||
}
|
||||
|
||||
async function render(props: UseNativeChatLiveSessionArgs): Promise<void> {
|
||||
if (!root) {
|
||||
root = createRoot(document.createElement('div'))
|
||||
}
|
||||
await act(async () => root?.render(createElement(Probe, props)))
|
||||
}
|
||||
|
||||
afterEach(() => {
|
||||
act(() => root?.unmount())
|
||||
root = null
|
||||
latest = null
|
||||
liveSession.mockReset()
|
||||
})
|
||||
|
||||
it('keeps settled messages during a same-identity rebind', async () => {
|
||||
liveSession.mockReturnValue(session('ready', [message('settled')]))
|
||||
await render(ARGS)
|
||||
|
||||
liveSession.mockReturnValue(session('loading', []))
|
||||
await render(ARGS)
|
||||
|
||||
expect(latest?.readPhase).toBe('loading')
|
||||
expect(latest?.messages.map((entry) => entry.id)).toEqual(['settled'])
|
||||
})
|
||||
|
||||
it('misses retained messages when the source owner changes', async () => {
|
||||
liveSession.mockReturnValue(session('ready', [message('owner-a')]))
|
||||
await render(ARGS)
|
||||
|
||||
liveSession.mockReturnValue(session('loading', []))
|
||||
await render({ ...ARGS, runtimeEnvironmentId: 'owner-b' })
|
||||
|
||||
expect(latest?.readPhase).toBe('loading')
|
||||
expect(latest?.messages).toEqual([])
|
||||
})
|
||||
|
||||
it('does not overwrite retention with the session-less view', async () => {
|
||||
liveSession.mockReturnValue(session('ready', [message('settled')]))
|
||||
await render(ARGS)
|
||||
|
||||
liveSession.mockReturnValue(session('ready', [], null))
|
||||
await render({ ...ARGS, sessionId: null })
|
||||
liveSession.mockReturnValue(session('loading', []))
|
||||
await render(ARGS)
|
||||
|
||||
expect(latest?.messages.map((entry) => entry.id)).toEqual(['settled'])
|
||||
})
|
||||
})
|
||||
|
|
@ -0,0 +1,53 @@
|
|||
import { useEffect, useRef } from 'react'
|
||||
import {
|
||||
createNativeChatTranscriptRetention,
|
||||
encodeNativeChatTranscriptIdentity
|
||||
} from '../../../../shared/native-chat-transcript-retention'
|
||||
import {
|
||||
useNativeChatLiveSession,
|
||||
type NativeChatLiveSession,
|
||||
type UseNativeChatLiveSessionArgs
|
||||
} from './use-native-chat-live-session'
|
||||
|
||||
/** Keeps one committed conversation visible while its exact source rebinds. */
|
||||
export function useNativeChatRetainedSession(
|
||||
args: UseNativeChatLiveSessionArgs
|
||||
): NativeChatLiveSession {
|
||||
const session = useNativeChatLiveSession(args)
|
||||
const identity = encodeNativeChatTranscriptIdentity([
|
||||
args.paneKey,
|
||||
args.runtimeEnvironmentId ?? null,
|
||||
args.agent,
|
||||
args.sessionId,
|
||||
args.transcriptPath ?? null
|
||||
])
|
||||
const activeIdentityRef = useRef(identity)
|
||||
const retentionRef = useRef(createNativeChatTranscriptRetention())
|
||||
const sessionMatchesIdentity = activeIdentityRef.current === identity
|
||||
const readPhase = sessionMatchesIdentity ? session.readPhase : 'loading'
|
||||
|
||||
useEffect(() => {
|
||||
activeIdentityRef.current = identity
|
||||
}, [identity])
|
||||
useEffect(() => {
|
||||
if (sessionMatchesIdentity && args.sessionId !== null && session.readPhase === 'ready') {
|
||||
retentionRef.current.capture(identity, session.messages)
|
||||
}
|
||||
}, [args.sessionId, identity, session.messages, session.readPhase, sessionMatchesIdentity])
|
||||
|
||||
const messages = retentionRef.current.visible({
|
||||
identity,
|
||||
messages: session.messages,
|
||||
settled: readPhase === 'ready',
|
||||
loading: readPhase === 'loading'
|
||||
})
|
||||
if (messages === session.messages && readPhase === session.readPhase) {
|
||||
return session
|
||||
}
|
||||
return {
|
||||
...session,
|
||||
messages,
|
||||
readPhase,
|
||||
...(sessionMatchesIdentity ? {} : { status: 'loading' as const, error: undefined })
|
||||
}
|
||||
}
|
||||
|
|
@ -4,7 +4,12 @@ import {
|
|||
type NativeChatBlock,
|
||||
type NativeChatMessage
|
||||
} from './native-chat-types'
|
||||
import { extractPendingAsk, nativeChatAskDismissKey, parseAskFromStatus } from './native-chat-ask'
|
||||
import {
|
||||
extractPendingAsk,
|
||||
nativeChatAskDismissKey,
|
||||
parseAskFromStatus,
|
||||
resolveNativeChatAsk
|
||||
} from './native-chat-ask'
|
||||
|
||||
function message(id: string, blocks: NativeChatBlock[]): NativeChatMessage {
|
||||
return { id, role: 'assistant', blocks, timestamp: 1, source: 'transcript' }
|
||||
|
|
@ -178,3 +183,23 @@ describe('parseAskFromStatus', () => {
|
|||
expect(prompt?.questions[0]?.options.map((o) => o.label)).toEqual(['a', 'b'])
|
||||
})
|
||||
})
|
||||
|
||||
describe('resolveNativeChatAsk', () => {
|
||||
const transcript = [message('m1', [call('AskUserQuestion', QUESTIONS_INPUT)])]
|
||||
|
||||
it('withholds transcript state until the read settles', () => {
|
||||
expect(
|
||||
resolveNativeChatAsk({ liveAsk: null, messages: transcript, transcriptSettled: false })
|
||||
).toBeNull()
|
||||
expect(
|
||||
resolveNativeChatAsk({ liveAsk: null, messages: transcript, transcriptSettled: true })
|
||||
)?.toMatchObject(QUESTIONS_INPUT)
|
||||
})
|
||||
|
||||
it('keeps a live ask authoritative while transcript history is unsettled', () => {
|
||||
const liveAsk = { questions: [{ question: 'Live?', options: [], multiSelect: false }] }
|
||||
expect(resolveNativeChatAsk({ liveAsk, messages: transcript, transcriptSettled: false })).toBe(
|
||||
liveAsk
|
||||
)
|
||||
})
|
||||
})
|
||||
|
|
|
|||
|
|
@ -131,6 +131,15 @@ export function extractPendingAsk(messages: readonly NativeChatMessage[]): AskPr
|
|||
return pending
|
||||
}
|
||||
|
||||
/** Prefers live status and consults transcript history only after its read settles. */
|
||||
export function resolveNativeChatAsk(args: {
|
||||
liveAsk: AskPrompt | null
|
||||
messages: readonly NativeChatMessage[]
|
||||
transcriptSettled: boolean
|
||||
}): AskPrompt | null {
|
||||
return args.liveAsk ?? (args.transcriptSettled ? extractPendingAsk(args.messages) : null)
|
||||
}
|
||||
|
||||
/** One question's chosen answer, normalized for delivery: the selected option
|
||||
* indices (in option order) plus any free-text "other" answer. Index-based (not
|
||||
* label text) so the answer can be delivered by the selector's stable option
|
||||
|
|
|
|||
|
|
@ -0,0 +1,54 @@
|
|||
import { describe, expect, it } from 'vitest'
|
||||
import type { NativeChatMessage } from './native-chat-types'
|
||||
import {
|
||||
createNativeChatTranscriptRetention,
|
||||
encodeNativeChatTranscriptIdentity
|
||||
} from './native-chat-transcript-retention'
|
||||
|
||||
function message(id: string): NativeChatMessage {
|
||||
return { id, role: 'assistant', blocks: [], timestamp: 0, source: 'transcript' }
|
||||
}
|
||||
|
||||
describe('native chat transcript retention', () => {
|
||||
it('holds only the latest settled transcript for the same identity while loading', () => {
|
||||
const retention = createNativeChatTranscriptRetention()
|
||||
const first = [message('first')]
|
||||
const second = [message('second')]
|
||||
|
||||
retention.capture('source-a', first)
|
||||
expect(
|
||||
retention.visible({ identity: 'source-a', messages: [], settled: false, loading: true })
|
||||
).toBe(first)
|
||||
expect(
|
||||
retention.visible({ identity: 'source-b', messages: [], settled: false, loading: true })
|
||||
).toEqual([])
|
||||
|
||||
retention.capture('source-b', second)
|
||||
expect(
|
||||
retention.visible({ identity: 'source-a', messages: [], settled: false, loading: true })
|
||||
).toEqual([])
|
||||
expect(
|
||||
retention.visible({ identity: 'source-b', messages: [], settled: false, loading: true })
|
||||
).toBe(second)
|
||||
})
|
||||
|
||||
it('never substitutes retained history for a settled or non-loading read', () => {
|
||||
const retention = createNativeChatTranscriptRetention()
|
||||
const retained = [message('retained')]
|
||||
const fresh = [message('fresh')]
|
||||
retention.capture('source', retained)
|
||||
|
||||
expect(
|
||||
retention.visible({ identity: 'source', messages: fresh, settled: true, loading: false })
|
||||
).toBe(fresh)
|
||||
expect(
|
||||
retention.visible({ identity: 'source', messages: [], settled: false, loading: false })
|
||||
).toEqual([])
|
||||
})
|
||||
|
||||
it('encodes identity components without delimiter collisions', () => {
|
||||
expect(encodeNativeChatTranscriptIdentity(['host\0workspace', 'session'])).not.toBe(
|
||||
encodeNativeChatTranscriptIdentity(['host', 'workspace\0session'])
|
||||
)
|
||||
})
|
||||
})
|
||||
|
|
@ -0,0 +1,36 @@
|
|||
import type { NativeChatMessage } from './native-chat-types'
|
||||
|
||||
export const EMPTY_NATIVE_CHAT_TRANSCRIPT: NativeChatMessage[] = []
|
||||
|
||||
export function encodeNativeChatTranscriptIdentity(parts: readonly (string | null)[]): string {
|
||||
return JSON.stringify(parts)
|
||||
}
|
||||
|
||||
export type NativeChatTranscriptRetention = {
|
||||
capture: (identity: string, messages: NativeChatMessage[]) => void
|
||||
visible: (args: {
|
||||
identity: string
|
||||
messages: NativeChatMessage[]
|
||||
settled: boolean
|
||||
loading: boolean
|
||||
}) => NativeChatMessage[]
|
||||
}
|
||||
|
||||
/** Retains one settled transcript so a same-source rebind does not blank the conversation. */
|
||||
export function createNativeChatTranscriptRetention(): NativeChatTranscriptRetention {
|
||||
let captured: { identity: string; messages: NativeChatMessage[] } | null = null
|
||||
|
||||
return {
|
||||
capture(identity, messages) {
|
||||
captured = { identity, messages }
|
||||
},
|
||||
visible({ identity, messages, settled, loading }) {
|
||||
if (settled) {
|
||||
return messages
|
||||
}
|
||||
return loading && captured?.identity === identity
|
||||
? captured.messages
|
||||
: EMPTY_NATIVE_CHAT_TRANSCRIPT
|
||||
}
|
||||
}
|
||||
}
|
||||
Loading…
Reference in New Issue