diff --git a/mobile/src/session/use-mobile-native-chat-controller.ts b/mobile/src/session/use-mobile-native-chat-controller.ts index 5ed98925e..eaa36fc50 100644 --- a/mobile/src/session/use-mobile-native-chat-controller.ts +++ b/mobile/src/session/use-mobile-native-chat-controller.ts @@ -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 = diff --git a/mobile/src/session/use-mobile-native-chat-prompts.test.ts b/mobile/src/session/use-mobile-native-chat-prompts.test.ts index f8a1d2a61..905b92155 100644 --- a/mobile/src/session/use-mobile-native-chat-prompts.test.ts +++ b/mobile/src/session/use-mobile-native-chat-prompts.test.ts @@ -15,14 +15,16 @@ const ASK = JSON.stringify({ function promptsFor( status: Partial | null, - messages: NativeChatMessage[] = [] + messages: NativeChatMessage[] = [], + transcriptLoading = false ): ReturnType { let captured: ReturnType | 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 diff --git a/mobile/src/session/use-mobile-native-chat-prompts.ts b/mobile/src/session/use-mobile-native-chat-prompts.ts index 717bd505a..61b831daf 100644 --- a/mobile/src/session/use-mobile-native-chat-prompts.ts +++ b/mobile/src/session/use-mobile-native-chat-prompts.ts @@ -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 { diff --git a/mobile/src/session/use-mobile-native-chat-session.test.ts b/mobile/src/session/use-mobile-native-chat-session.test.ts index 844c311b8..8c685d002 100644 --- a/mobile/src/session/use-mobile-native-chat-session.test.ts +++ b/mobile/src/session/use-mobile-native-chat-session.test.ts @@ -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 () => { diff --git a/mobile/src/session/use-mobile-native-chat-session.ts b/mobile/src/session/use-mobile-native-chat-session.ts index 56db7def5..6305d0418 100644 --- a/mobile/src/session/use-mobile-native-chat-session.ts +++ b/mobile/src/session/use-mobile-native-chat-session.ts @@ -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([]) - 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, diff --git a/src/renderer/src/components/native-chat/NativeChatInteractiveCard.test.tsx b/src/renderer/src/components/native-chat/NativeChatInteractiveCard.test.tsx index a9b65f950..7f5c41034 100644 --- a/src/renderer/src/components/native-chat/NativeChatInteractiveCard.test.tsx +++ b/src/renderer/src/components/native-chat/NativeChatInteractiveCard.test.tsx @@ -48,13 +48,15 @@ function renderCard(canSend = true): ReturnType { function cardElement( canSend = true, messages?: readonly NativeChatMessage[], - onShowingQuestionChange?: (showing: boolean) => void + onShowingQuestionChange?: (showing: boolean) => void, + transcriptSettled = true ): React.JSX.Element { return ( { 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?')])) diff --git a/src/renderer/src/components/native-chat/NativeChatInteractiveCard.tsx b/src/renderer/src/components/native-chat/NativeChatInteractiveCard.tsx index c02df5c13..0b3e110c0 100644 --- a/src/renderer/src/components/native-chat/NativeChatInteractiveCard.tsx +++ b/src/renderer/src/components/native-chat/NativeChatInteractiveCard.tsx @@ -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(null) // A question answer is a paced multi-step write (body→Enter per question); keep diff --git a/src/renderer/src/components/native-chat/NativeChatView.tsx b/src/renderer/src/components/native-chat/NativeChatView.tsx index 33a8ddecb..2fb458166 100644 --- a/src/renderer/src/components/native-chat/NativeChatView.tsx +++ b/src/renderer/src/components/native-chat/NativeChatView.tsx @@ -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} /> diff --git a/src/renderer/src/components/native-chat/use-native-chat-retained-session.test.ts b/src/renderer/src/components/native-chat/use-native-chat-retained-session.test.ts new file mode 100644 index 000000000..17b5801b0 --- /dev/null +++ b/src/renderer/src/components/native-chat/use-native-chat-retained-session.test.ts @@ -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 { + 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']) + }) +}) diff --git a/src/renderer/src/components/native-chat/use-native-chat-retained-session.ts b/src/renderer/src/components/native-chat/use-native-chat-retained-session.ts new file mode 100644 index 000000000..e10ccecf5 --- /dev/null +++ b/src/renderer/src/components/native-chat/use-native-chat-retained-session.ts @@ -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 }) + } +} diff --git a/src/shared/native-chat-ask.test.ts b/src/shared/native-chat-ask.test.ts index 4cdad0148..292209671 100644 --- a/src/shared/native-chat-ask.test.ts +++ b/src/shared/native-chat-ask.test.ts @@ -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 + ) + }) +}) diff --git a/src/shared/native-chat-ask.ts b/src/shared/native-chat-ask.ts index 1a37cedf3..5b21520fd 100644 --- a/src/shared/native-chat-ask.ts +++ b/src/shared/native-chat-ask.ts @@ -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 diff --git a/src/shared/native-chat-transcript-retention.test.ts b/src/shared/native-chat-transcript-retention.test.ts new file mode 100644 index 000000000..e62d980bd --- /dev/null +++ b/src/shared/native-chat-transcript-retention.test.ts @@ -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']) + ) + }) +}) diff --git a/src/shared/native-chat-transcript-retention.ts b/src/shared/native-chat-transcript-retention.ts new file mode 100644 index 000000000..a2f891bb7 --- /dev/null +++ b/src/shared/native-chat-transcript-retention.ts @@ -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 + } + } +}