mirror of
https://github.com/stablyai/orca.git
synced 2026-09-22 08:02:28 +00:00
perf(native-chat): suspend hidden transcript streams (#13622)
* perf(native-chat): suspend hidden transcript streams * fix(native-chat): keep assembly renders pure * fix(native-chat): keep a transient error from stranding a revealed chat - An error snapshot frame no longer latches frameArrived, so the in-flight read can still seed the pane instead of leaving it on the error surface. - Retained history is shown over a full-pane read error for the same source. - The paged read window survives a hide/reveal; only a source change resets it. Co-authored-by: Orca <help@stably.ai> * fix(mobile): match the trimmed native-chat retention signature The shared retention type no longer takes `loading`, so mobile's call was an excess-property error that broke the mobile typecheck job. Dropping it also lets mobile inherit the desktop behavior: a stream error or dropped client keeps the last transcript instead of swapping it for the error empty state. Co-authored-by: Orca <help@stably.ai> --------- Co-authored-by: Orca <help@stably.ai>
This commit is contained in:
@@ -109,7 +109,10 @@ describe('useMobileNativeChatSession', () => {
|
||||
await Promise.resolve()
|
||||
})
|
||||
|
||||
expect(state?.messages).toEqual([])
|
||||
// The retained window keeps rendering while the source is gone; what must
|
||||
// never land is the page that resolved after it disappeared.
|
||||
expect(state?.messages.map((entry) => entry.id)).not.toContain('stale-page')
|
||||
expect(state?.messages).toHaveLength(40)
|
||||
expect(state?.status).toBe('idle')
|
||||
expect(state?.loadingEarlier).toBe(false)
|
||||
})
|
||||
@@ -557,6 +560,30 @@ describe('useMobileNativeChatSession transcriptLoading', () => {
|
||||
})
|
||||
})
|
||||
|
||||
it('keeps the retained transcript rendered when the stream reports an error', async () => {
|
||||
// A transient read failure must not blank a conversation the user is looking
|
||||
// at; the last settled list for this identity stays until a read supersedes it.
|
||||
let emitFrame: (frame: unknown) => void = () => {}
|
||||
const client = {
|
||||
subscribe: vi.fn((_method: string, _params: unknown, onData: (frame: unknown) => void) => {
|
||||
emitFrame = onData
|
||||
onData({ type: 'snapshot', messages: [message('a-1')], hasMore: false })
|
||||
return () => {}
|
||||
})
|
||||
} as unknown as RpcClient
|
||||
await mountAt(client, 'session-a')
|
||||
expect(renders.at(-1)).toMatchObject({ status: 'ready', ids: ['a-1'] })
|
||||
|
||||
renders.length = 0
|
||||
await act(async () => emitFrame({ type: 'error', message: 'stream broke' }))
|
||||
|
||||
expect(renders.at(-1)).toMatchObject({
|
||||
status: 'error',
|
||||
transcriptLoading: false,
|
||||
ids: ['a-1']
|
||||
})
|
||||
})
|
||||
|
||||
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) => {
|
||||
|
||||
@@ -273,11 +273,12 @@ export function useMobileNativeChatSession(args: {
|
||||
})()
|
||||
}, [client, agent, sessionId, transcriptPath, hasMore, setList])
|
||||
|
||||
// Held for any unsettled read, not just an in-flight one: a stream error or a
|
||||
// dropped client would otherwise trade the conversation for an error card.
|
||||
const visibleMessages = transcriptRetentionRef.current.visible({
|
||||
identity,
|
||||
messages,
|
||||
settled: settledReady,
|
||||
loading: status === 'loading'
|
||||
settled: settledReady
|
||||
})
|
||||
|
||||
return {
|
||||
|
||||
@@ -2,7 +2,6 @@ import { useCallback, useEffect, useMemo, useRef, useState } from 'react'
|
||||
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 { useNativeChatRetainedSession } from './use-native-chat-retained-session'
|
||||
import { selectNativeChatViewState } from './native-chat-view-state'
|
||||
import { NativeChatMessageList } from './NativeChatMessageList'
|
||||
@@ -47,18 +46,18 @@ import {
|
||||
emptyNativeChatContextMenuActions,
|
||||
useNativeChatContextMenu
|
||||
} from './use-native-chat-context-menu'
|
||||
import type { NativeChatContextMenuActions } from './use-native-chat-context-menu'
|
||||
import { resolveNativeChatFileLinkContext } from './native-chat-file-link'
|
||||
import { selectNativeChatRuntimeEnvironmentId } from './native-chat-runtime-owner'
|
||||
import { useNativeChatPasteBridge } from './use-native-chat-paste-bridge'
|
||||
import { useNativeChatFileLinkClick } from './use-native-chat-file-link-click'
|
||||
import type { NativeChatViewProps } from './native-chat-view-types'
|
||||
import type { NativeChatResolvedViewProps, NativeChatViewProps } from './native-chat-view-types'
|
||||
|
||||
export type { NativeChatViewProps } from './native-chat-view-types'
|
||||
|
||||
/** Resolves an agent terminal into its native conversation and composer UI. */
|
||||
export default function NativeChatView({
|
||||
terminalTabId,
|
||||
isVisible,
|
||||
paneKey: preferredPaneKey,
|
||||
targetPtyId = null,
|
||||
launchAgent,
|
||||
@@ -94,6 +93,7 @@ export default function NativeChatView({
|
||||
agent={resolution.agent}
|
||||
sessionId={resolution.sessionId}
|
||||
transcriptPath={resolution.transcriptPath}
|
||||
isVisible={isVisible}
|
||||
targetPtyId={targetPtyId}
|
||||
terminalTabId={terminalTabId}
|
||||
onSwitchToTerminal={onSwitchToTerminal}
|
||||
@@ -110,22 +110,13 @@ function NativeChatResolvedView({
|
||||
agent,
|
||||
sessionId,
|
||||
transcriptPath,
|
||||
isVisible,
|
||||
targetPtyId,
|
||||
terminalTabId,
|
||||
onSwitchToTerminal,
|
||||
readTerminalScreen,
|
||||
contextMenuActions
|
||||
}: {
|
||||
paneKey: string
|
||||
agent: NativeChatSession['agent']
|
||||
sessionId: string | null
|
||||
transcriptPath: string | null
|
||||
targetPtyId: string | null
|
||||
terminalTabId: string
|
||||
onSwitchToTerminal?: () => void
|
||||
readTerminalScreen?: () => string | null
|
||||
contextMenuActions?: Omit<NativeChatContextMenuActions, 'onPaste'>
|
||||
}): React.JSX.Element {
|
||||
}: NativeChatResolvedViewProps): React.JSX.Element {
|
||||
// Primitive owner selection (no useShallow): routes the pane's read/subscribe to
|
||||
// the remote runtime host for a runtime-owned pane; null keeps the local path.
|
||||
const runtimeEnvironmentId = useAppStore((s) =>
|
||||
@@ -136,7 +127,8 @@ function NativeChatResolvedView({
|
||||
agent,
|
||||
sessionId,
|
||||
transcriptPath,
|
||||
runtimeEnvironmentId
|
||||
runtimeEnvironmentId,
|
||||
enabled: isVisible
|
||||
})
|
||||
const launchPrompt = useAppStore((s) => s.nativeChatLaunchPromptByTabId[terminalTabId] ?? null)
|
||||
const clearNativeChatLaunchPrompt = useAppStore((s) => s.clearNativeChatLaunchPrompt)
|
||||
|
||||
@@ -0,0 +1,31 @@
|
||||
const RETRY_DELAYS_MS = [1_000, 2_000, 4_000, 8_000]
|
||||
const FIXED_RETRY_DELAY_MS = 10_000
|
||||
|
||||
function retryDelayMs(attempt: number): number {
|
||||
return RETRY_DELAYS_MS[attempt] ?? FIXED_RETRY_DELAY_MS
|
||||
}
|
||||
|
||||
export type NativeChatReadRetryTimer = {
|
||||
schedule: (attempt: number, retry: () => void) => void
|
||||
cancel: () => void
|
||||
}
|
||||
|
||||
export function createNativeChatReadRetryTimer(): NativeChatReadRetryTimer {
|
||||
let timer: ReturnType<typeof setTimeout> | null = null
|
||||
const cancel = (): void => {
|
||||
if (timer !== null) {
|
||||
clearTimeout(timer)
|
||||
timer = null
|
||||
}
|
||||
}
|
||||
return {
|
||||
schedule(attempt, retry): void {
|
||||
cancel()
|
||||
timer = setTimeout(() => {
|
||||
timer = null
|
||||
retry()
|
||||
}, retryDelayMs(attempt))
|
||||
},
|
||||
cancel
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,24 @@
|
||||
import type { NativeChatSessionTransport } from './native-chat-session-transport'
|
||||
|
||||
/** Normalizes desktop's synchronous teardown and the paired web bridge's deferred teardown. */
|
||||
export function openNativeChatTranscriptStream(
|
||||
transport: NativeChatSessionTransport,
|
||||
args: Parameters<NativeChatSessionTransport['subscribe']>[0],
|
||||
onFrame: Parameters<NativeChatSessionTransport['subscribe']>[1]
|
||||
): () => void {
|
||||
const teardown = transport.subscribe(args, onFrame) as unknown
|
||||
if (typeof teardown === 'function') {
|
||||
return teardown as () => void
|
||||
}
|
||||
if (!teardown || typeof (teardown as { then?: unknown }).then !== 'function') {
|
||||
return () => undefined
|
||||
}
|
||||
const deferredTeardown = (teardown as Promise<unknown>).catch(() => undefined)
|
||||
return () => {
|
||||
void deferredTeardown.then((resolvedTeardown) => {
|
||||
if (typeof resolvedTeardown === 'function') {
|
||||
;(resolvedTeardown as () => void)()
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
@@ -1,9 +1,12 @@
|
||||
import type { TuiAgent } from '../../../../shared/types'
|
||||
import type { NativeChatSession } from '../../../../shared/native-chat-types'
|
||||
import type { NativeChatContextMenuActions } from './use-native-chat-context-menu'
|
||||
|
||||
export type NativeChatViewProps = {
|
||||
/** The terminal tab hosting the agent. paneKey is `${tabId}:${leafId}`. */
|
||||
terminalTabId: string
|
||||
/** Whether the hosted terminal surface is currently visible. */
|
||||
isVisible: boolean
|
||||
/** Specific split leaf this chat surface replaces. */
|
||||
paneKey?: string
|
||||
/** PTY bound to `paneKey`, used for composer and interactive-card sends. */
|
||||
@@ -18,3 +21,16 @@ export type NativeChatViewProps = {
|
||||
readTerminalScreen?: () => string | null
|
||||
contextMenuActions?: Omit<NativeChatContextMenuActions, 'onPaste'>
|
||||
}
|
||||
|
||||
export type NativeChatResolvedViewProps = {
|
||||
paneKey: string
|
||||
agent: NativeChatSession['agent']
|
||||
sessionId: string | null
|
||||
transcriptPath: string | null
|
||||
isVisible: boolean
|
||||
targetPtyId: string | null
|
||||
terminalTabId: string
|
||||
onSwitchToTerminal?: () => void
|
||||
readTerminalScreen?: () => string | null
|
||||
contextMenuActions?: Omit<NativeChatContextMenuActions, 'onPaste'>
|
||||
}
|
||||
|
||||
@@ -0,0 +1,86 @@
|
||||
import { useLayoutEffect, useMemo, useRef } from 'react'
|
||||
import type { AgentType, NativeChatMessage } from '../../../../shared/native-chat-types'
|
||||
import {
|
||||
applyAppends,
|
||||
createIncrementalAssembler,
|
||||
type IncrementalChatAssembler,
|
||||
reset as resetAssembler
|
||||
} from './native-chat-incremental-assembler'
|
||||
import { prepareNativeChatLiveMessages } from './native-chat-live-message-preparation'
|
||||
|
||||
type AssemblyCache = {
|
||||
assembler: IncrementalChatAssembler
|
||||
baseSignature: string
|
||||
baseMessages: readonly NativeChatMessage[]
|
||||
transcript: readonly NativeChatMessage[]
|
||||
assembledMessages: NativeChatMessage[]
|
||||
}
|
||||
|
||||
function cloneAssembler(assembler: IncrementalChatAssembler): IncrementalChatAssembler {
|
||||
return {
|
||||
byId: new Map(assembler.byId),
|
||||
byTurn: new Map(assembler.byTurn),
|
||||
messages: assembler.messages
|
||||
}
|
||||
}
|
||||
|
||||
function sharesPrefix(
|
||||
whole: readonly NativeChatMessage[],
|
||||
prefix: readonly NativeChatMessage[],
|
||||
length: number
|
||||
): boolean {
|
||||
for (let index = 0; index < length; index += 1) {
|
||||
if (whole[index] !== prefix[index]) {
|
||||
return false
|
||||
}
|
||||
}
|
||||
return true
|
||||
}
|
||||
|
||||
/** Keeps transcript assembly off the status-only render axis. */
|
||||
export function useNativeChatAssembledMessages(args: {
|
||||
agent: AgentType
|
||||
sessionId: string | null
|
||||
baseMessages: readonly NativeChatMessage[]
|
||||
appended: NativeChatMessage[]
|
||||
}): { assembledMessages: NativeChatMessage[]; normalizedMessages: NativeChatMessage[] } {
|
||||
const committedCacheRef = useRef<AssemblyCache | null>(null)
|
||||
const { agent, sessionId, baseMessages, appended } = args
|
||||
|
||||
const assembly = useMemo<AssemblyCache>(() => {
|
||||
const committed = committedCacheRef.current
|
||||
const transcript =
|
||||
appended.length > 0 ? [...baseMessages, ...appended] : (baseMessages as NativeChatMessage[])
|
||||
const baseSignature = `${agent}\u0000${sessionId ?? ''}`
|
||||
const baseChanged =
|
||||
!committed ||
|
||||
baseSignature !== committed.baseSignature ||
|
||||
baseMessages !== committed.baseMessages
|
||||
const applied = committed?.transcript ?? []
|
||||
const isSuffixExtension =
|
||||
!baseChanged &&
|
||||
transcript.length >= applied.length &&
|
||||
sharesPrefix(transcript, applied, applied.length)
|
||||
// A discarded render must not mutate the last committed assembler.
|
||||
const assembler = baseChanged
|
||||
? createIncrementalAssembler()
|
||||
: cloneAssembler(committed.assembler)
|
||||
|
||||
const assembledMessages = isSuffixExtension
|
||||
? transcript.length > applied.length
|
||||
? applyAppends(assembler, transcript.slice(applied.length))
|
||||
: assembler.messages
|
||||
: resetAssembler(assembler, transcript)
|
||||
return { assembler, baseSignature, baseMessages, transcript, assembledMessages }
|
||||
}, [agent, appended, baseMessages, sessionId])
|
||||
|
||||
useLayoutEffect(() => {
|
||||
committedCacheRef.current = assembly
|
||||
}, [assembly])
|
||||
|
||||
const normalizedMessages = useMemo(
|
||||
() => prepareNativeChatLiveMessages(assembly.assembledMessages, agent),
|
||||
[agent, assembly.assembledMessages]
|
||||
)
|
||||
return { assembledMessages: assembly.assembledMessages, normalizedMessages }
|
||||
}
|
||||
+393
@@ -0,0 +1,393 @@
|
||||
// @vitest-environment happy-dom
|
||||
|
||||
import { act, createElement } from 'react'
|
||||
import { createRoot, type Root } from 'react-dom/client'
|
||||
import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest'
|
||||
import type { NativeChatMessage } from '../../../../shared/native-chat-types'
|
||||
import { useAppStore } from '@/store'
|
||||
import { NATIVE_CHAT_INITIAL_LIMIT, nextNativeChatLimit } from './native-chat-pagination'
|
||||
|
||||
const { resetTransport, subscriptions, transport } = vi.hoisted(() => {
|
||||
type Subscription = {
|
||||
onFrame: (frame: unknown) => void
|
||||
unsubscribe: ReturnType<typeof vi.fn>
|
||||
}
|
||||
const subscriptions: Subscription[] = []
|
||||
const transport = {
|
||||
readSession: vi.fn(),
|
||||
subscribe: vi.fn()
|
||||
}
|
||||
const resetTransport = (): void => {
|
||||
subscriptions.splice(0)
|
||||
transport.readSession.mockReset().mockImplementation(() => new Promise(() => {}))
|
||||
transport.subscribe.mockReset().mockImplementation((_args, onFrame) => {
|
||||
const subscription = { onFrame, unsubscribe: vi.fn() }
|
||||
subscriptions.push(subscription)
|
||||
return subscription.unsubscribe
|
||||
})
|
||||
}
|
||||
return { resetTransport, subscriptions, transport }
|
||||
})
|
||||
|
||||
vi.mock('./native-chat-session-transport', () => ({
|
||||
getNativeChatSessionTransport: () => transport
|
||||
}))
|
||||
|
||||
import type {
|
||||
NativeChatLiveSession,
|
||||
UseNativeChatLiveSessionArgs
|
||||
} from './use-native-chat-live-session'
|
||||
import { useNativeChatRetainedSession } from './use-native-chat-retained-session'
|
||||
|
||||
const BASE_ARGS: UseNativeChatLiveSessionArgs = {
|
||||
paneKey: 'tab-1:leaf-1',
|
||||
agent: 'claude',
|
||||
sessionId: 'session-1',
|
||||
transcriptPath: '/remote/session-1.jsonl',
|
||||
runtimeEnvironmentId: 'environment-1',
|
||||
enabled: true
|
||||
}
|
||||
|
||||
function assistant(id: string, timestamp: number): NativeChatMessage {
|
||||
return {
|
||||
id,
|
||||
role: 'assistant',
|
||||
blocks: [{ type: 'text', text: id }],
|
||||
timestamp,
|
||||
source: 'transcript'
|
||||
}
|
||||
}
|
||||
|
||||
function deferred<T>(): {
|
||||
promise: Promise<T>
|
||||
resolve: (value: T) => void
|
||||
reject: (reason: unknown) => void
|
||||
} {
|
||||
let resolve = (_value: T): void => {}
|
||||
let reject = (_reason: unknown): void => {}
|
||||
const promise = new Promise<T>((promiseResolve, promiseReject) => {
|
||||
resolve = promiseResolve
|
||||
reject = promiseReject
|
||||
})
|
||||
return { promise, reject, resolve }
|
||||
}
|
||||
|
||||
describe('useNativeChatLiveSession visibility', () => {
|
||||
let root: Root
|
||||
let latest: NativeChatLiveSession | null = null
|
||||
const renders = vi.fn()
|
||||
|
||||
function Probe(props: UseNativeChatLiveSessionArgs): null {
|
||||
renders()
|
||||
latest = useNativeChatRetainedSession(props)
|
||||
return null
|
||||
}
|
||||
|
||||
async function render(props: UseNativeChatLiveSessionArgs): Promise<void> {
|
||||
await act(async () => {
|
||||
root.render(createElement(Probe, props))
|
||||
await Promise.resolve()
|
||||
await Promise.resolve()
|
||||
})
|
||||
}
|
||||
|
||||
async function emit(index: number, frame: unknown): Promise<void> {
|
||||
await act(async () => {
|
||||
subscriptions[index]?.onFrame(frame)
|
||||
await Promise.resolve()
|
||||
})
|
||||
}
|
||||
|
||||
beforeEach(() => {
|
||||
globalThis.IS_REACT_ACT_ENVIRONMENT = true
|
||||
root = createRoot(document.createElement('div'))
|
||||
latest = null
|
||||
renders.mockClear()
|
||||
resetTransport()
|
||||
useAppStore.setState({ agentStatusByPaneKey: {} })
|
||||
})
|
||||
|
||||
afterEach(() => {
|
||||
act(() => root.unmount())
|
||||
vi.useRealTimers()
|
||||
})
|
||||
|
||||
it('does no transcript IO when initially hidden', async () => {
|
||||
await render({ ...BASE_ARGS, enabled: false })
|
||||
|
||||
expect(transport.readSession).not.toHaveBeenCalled()
|
||||
expect(transport.subscribe).not.toHaveBeenCalled()
|
||||
})
|
||||
|
||||
it('unsubscribes on hide, retains committed messages, and rejects hidden work', async () => {
|
||||
useAppStore.setState({
|
||||
agentStatusByPaneKey: {
|
||||
[BASE_ARGS.paneKey]: { state: 'working', stateStartedAt: 100 }
|
||||
}
|
||||
} as never)
|
||||
await render(BASE_ARGS)
|
||||
await emit(0, {
|
||||
type: 'snapshot',
|
||||
messages: [assistant('committed', 1)],
|
||||
hasMore: true,
|
||||
lifecycle: { state: 'working', turnId: 'turn-1', timestamp: 100 }
|
||||
})
|
||||
const staleLoadEarlier = latest?.loadEarlier
|
||||
|
||||
await render({ ...BASE_ARGS, enabled: false })
|
||||
|
||||
expect(subscriptions[0]?.unsubscribe).toHaveBeenCalledOnce()
|
||||
expect(latest?.messages.map((message) => message.id)).toEqual(['committed'])
|
||||
expect(latest?.readPhase).toBe('ready')
|
||||
expect(latest?.status).toBe('working')
|
||||
|
||||
const readsAfterHide = transport.readSession.mock.calls.length
|
||||
staleLoadEarlier?.()
|
||||
latest?.loadEarlier()
|
||||
expect(transport.readSession).toHaveBeenCalledTimes(readsAfterHide)
|
||||
|
||||
const rendersAfterHide = renders.mock.calls.length
|
||||
await act(async () => {
|
||||
for (let index = 0; index < 1_000; index += 1) {
|
||||
subscriptions[0]?.onFrame({
|
||||
type: 'appended',
|
||||
messages: [assistant(`stale-hidden-${index}`, index + 2)]
|
||||
})
|
||||
}
|
||||
await Promise.resolve()
|
||||
})
|
||||
expect(renders).toHaveBeenCalledTimes(rendersAfterHide)
|
||||
expect(latest?.messages.map((message) => message.id)).toEqual(['committed'])
|
||||
})
|
||||
|
||||
it('cancels a not-found retry when hidden', async () => {
|
||||
vi.useFakeTimers()
|
||||
transport.readSession.mockResolvedValue({ error: 'not found', notFound: true })
|
||||
|
||||
await render(BASE_ARGS)
|
||||
expect(transport.readSession).toHaveBeenCalledOnce()
|
||||
|
||||
await render({ ...BASE_ARGS, enabled: false })
|
||||
await act(async () => vi.advanceTimersByTimeAsync(60_000))
|
||||
|
||||
expect(transport.readSession).toHaveBeenCalledOnce()
|
||||
expect(subscriptions[0]?.unsubscribe).toHaveBeenCalledOnce()
|
||||
})
|
||||
|
||||
it('reopens one fresh stream whose snapshot wins every stale generation', async () => {
|
||||
const oldSeed = deferred<{ messages: NativeChatMessage[] }>()
|
||||
const freshSeed = deferred<{ messages: NativeChatMessage[] }>()
|
||||
transport.readSession
|
||||
.mockImplementationOnce(() => oldSeed.promise)
|
||||
.mockImplementationOnce(() => freshSeed.promise)
|
||||
|
||||
await render(BASE_ARGS)
|
||||
await emit(0, {
|
||||
type: 'snapshot',
|
||||
messages: [assistant('before-hide', 1)],
|
||||
hasMore: false
|
||||
})
|
||||
await render({ ...BASE_ARGS, enabled: false })
|
||||
await render(BASE_ARGS)
|
||||
|
||||
expect(transport.readSession).toHaveBeenCalledTimes(2)
|
||||
expect(transport.subscribe).toHaveBeenCalledTimes(2)
|
||||
expect(subscriptions[0]?.unsubscribe).toHaveBeenCalledOnce()
|
||||
expect(subscriptions[1]?.unsubscribe).not.toHaveBeenCalled()
|
||||
expect(latest?.readPhase).toBe('loading')
|
||||
expect(latest?.messages.map((message) => message.id)).toEqual(['before-hide'])
|
||||
|
||||
await emit(1, {
|
||||
type: 'snapshot',
|
||||
messages: [assistant('fresh', 3)],
|
||||
hasMore: false
|
||||
})
|
||||
await emit(0, {
|
||||
type: 'replacement',
|
||||
messages: [assistant('stale-stream', 4)],
|
||||
hasMore: false
|
||||
})
|
||||
await act(async () => {
|
||||
oldSeed.resolve({ messages: [assistant('stale-old-seed', 5)] })
|
||||
freshSeed.resolve({ messages: [assistant('stale-fresh-seed', 6)] })
|
||||
await Promise.resolve()
|
||||
await Promise.resolve()
|
||||
})
|
||||
|
||||
expect(latest?.messages.map((message) => message.id)).toEqual(['fresh'])
|
||||
})
|
||||
|
||||
it('closes a deferred paired-client stream after a rapid hide and reveal', async () => {
|
||||
const deferredTeardown = deferred<() => void>()
|
||||
const oldUnsubscribe = vi.fn()
|
||||
transport.subscribe.mockImplementationOnce((_args, onFrame) => {
|
||||
subscriptions.push({ onFrame, unsubscribe: oldUnsubscribe })
|
||||
return deferredTeardown.promise
|
||||
})
|
||||
|
||||
await render(BASE_ARGS)
|
||||
await render({ ...BASE_ARGS, enabled: false })
|
||||
await render(BASE_ARGS)
|
||||
|
||||
expect(transport.subscribe).toHaveBeenCalledTimes(2)
|
||||
expect(subscriptions).toHaveLength(2)
|
||||
expect(subscriptions[1]?.unsubscribe).not.toHaveBeenCalled()
|
||||
|
||||
await act(async () => {
|
||||
deferredTeardown.resolve(oldUnsubscribe)
|
||||
await Promise.resolve()
|
||||
await Promise.resolve()
|
||||
})
|
||||
|
||||
expect(oldUnsubscribe).toHaveBeenCalledOnce()
|
||||
expect(subscriptions[1]?.unsubscribe).not.toHaveBeenCalled()
|
||||
})
|
||||
|
||||
it('contains a deferred paired-client stream-handle rejection', async () => {
|
||||
const deferredTeardown = deferred<() => void>()
|
||||
transport.subscribe.mockImplementationOnce((_args, onFrame) => {
|
||||
subscriptions.push({ onFrame, unsubscribe: vi.fn() })
|
||||
return deferredTeardown.promise
|
||||
})
|
||||
|
||||
await render(BASE_ARGS)
|
||||
await act(async () => {
|
||||
deferredTeardown.reject(new Error('stream setup failed'))
|
||||
await Promise.resolve()
|
||||
await Promise.resolve()
|
||||
})
|
||||
await render({ ...BASE_ARGS, enabled: false })
|
||||
|
||||
expect(transport.subscribe).toHaveBeenCalledOnce()
|
||||
})
|
||||
|
||||
it('drops retained messages when the hidden transcript identity changes', async () => {
|
||||
await render(BASE_ARGS)
|
||||
await emit(0, {
|
||||
type: 'snapshot',
|
||||
messages: [assistant('session-a', 1)],
|
||||
hasMore: false
|
||||
})
|
||||
await render({ ...BASE_ARGS, enabled: false })
|
||||
const hiddenSessionB = {
|
||||
...BASE_ARGS,
|
||||
enabled: false,
|
||||
sessionId: 'session-2',
|
||||
transcriptPath: '/remote/session-2.jsonl',
|
||||
runtimeEnvironmentId: 'environment-2'
|
||||
}
|
||||
|
||||
await render(hiddenSessionB)
|
||||
await render(hiddenSessionB)
|
||||
|
||||
expect(transport.readSession).toHaveBeenCalledOnce()
|
||||
expect(transport.subscribe).toHaveBeenCalledOnce()
|
||||
expect(latest?.readPhase).toBe('loading')
|
||||
expect(latest?.messages).toEqual([])
|
||||
|
||||
await render({ ...hiddenSessionB, enabled: true })
|
||||
await emit(1, {
|
||||
type: 'snapshot',
|
||||
messages: [assistant('session-b', 2)],
|
||||
hasMore: false
|
||||
})
|
||||
|
||||
expect(transport.readSession).toHaveBeenCalledTimes(2)
|
||||
expect(transport.subscribe).toHaveBeenCalledTimes(2)
|
||||
expect(latest?.messages.map((message) => message.id)).toEqual(['session-b'])
|
||||
})
|
||||
|
||||
it('reveals with the paged window and restarts it only on a source change', async () => {
|
||||
const pagedLimit = nextNativeChatLimit(NATIVE_CHAT_INITIAL_LIMIT)
|
||||
transport.readSession.mockResolvedValue({ messages: [assistant('read', 0)] })
|
||||
const initialMessages = Array.from({ length: NATIVE_CHAT_INITIAL_LIMIT }, (_unused, index) =>
|
||||
assistant(`old-${index}`, index)
|
||||
)
|
||||
|
||||
await render(BASE_ARGS)
|
||||
await emit(0, { type: 'snapshot', messages: initialMessages, hasMore: true })
|
||||
await act(async () => {
|
||||
latest?.loadEarlier()
|
||||
await Promise.resolve()
|
||||
await Promise.resolve()
|
||||
})
|
||||
expect(transport.readSession.mock.calls[1]?.[2]).toBe(pagedLimit)
|
||||
|
||||
await render({ ...BASE_ARGS, enabled: false })
|
||||
await render(BASE_ARGS)
|
||||
|
||||
expect(transport.readSession.mock.calls[2]?.[2]).toBe(pagedLimit)
|
||||
expect(transport.subscribe.mock.calls[1]?.[0]?.limit).toBe(pagedLimit)
|
||||
|
||||
await render({ ...BASE_ARGS, sessionId: 'session-2', transcriptPath: '/remote/session-2.jsonl' })
|
||||
|
||||
expect(transport.readSession.mock.calls[3]?.[2]).toBe(NATIVE_CHAT_INITIAL_LIMIT)
|
||||
})
|
||||
|
||||
it('lets the pending read repair a stream error frame', async () => {
|
||||
const seed = deferred<{ messages: NativeChatMessage[] }>()
|
||||
transport.readSession.mockImplementationOnce(() => seed.promise)
|
||||
|
||||
await render(BASE_ARGS)
|
||||
await emit(0, { type: 'snapshot', messages: [], hasMore: false, error: 'stream failed' })
|
||||
expect(latest?.status).toBe('error')
|
||||
|
||||
await act(async () => {
|
||||
seed.resolve({ messages: [assistant('seeded', 1)] })
|
||||
await Promise.resolve()
|
||||
await Promise.resolve()
|
||||
})
|
||||
|
||||
expect(latest?.readPhase).toBe('ready')
|
||||
expect(latest?.messages.map((message) => message.id)).toEqual(['seeded'])
|
||||
})
|
||||
|
||||
it('keeps the retained transcript when the reveal stream errors', async () => {
|
||||
await render(BASE_ARGS)
|
||||
await emit(0, {
|
||||
type: 'snapshot',
|
||||
messages: [assistant('committed', 1)],
|
||||
hasMore: false
|
||||
})
|
||||
await render({ ...BASE_ARGS, enabled: false })
|
||||
await render(BASE_ARGS)
|
||||
await emit(1, { type: 'snapshot', messages: [], hasMore: false, error: 'stream failed' })
|
||||
|
||||
expect(latest?.messages.map((message) => message.id)).toEqual(['committed'])
|
||||
expect(latest?.status).not.toBe('error')
|
||||
})
|
||||
|
||||
it('fences pagination started before a rapid hide and reveal', async () => {
|
||||
const initialSeed = deferred<{ messages: NativeChatMessage[] }>()
|
||||
const oldPage = deferred<{ messages: NativeChatMessage[] }>()
|
||||
const revealSeed = deferred<{ messages: NativeChatMessage[] }>()
|
||||
transport.readSession
|
||||
.mockImplementationOnce(() => initialSeed.promise)
|
||||
.mockImplementationOnce(() => oldPage.promise)
|
||||
.mockImplementationOnce(() => revealSeed.promise)
|
||||
const initialMessages = Array.from({ length: NATIVE_CHAT_INITIAL_LIMIT }, (_unused, index) =>
|
||||
assistant(`old-${index}`, index)
|
||||
)
|
||||
|
||||
await render(BASE_ARGS)
|
||||
await emit(0, { type: 'snapshot', messages: initialMessages, hasMore: true })
|
||||
await act(async () => latest?.loadEarlier())
|
||||
expect(transport.readSession).toHaveBeenCalledTimes(2)
|
||||
|
||||
await render({ ...BASE_ARGS, enabled: false })
|
||||
await render(BASE_ARGS)
|
||||
await emit(1, {
|
||||
type: 'snapshot',
|
||||
messages: [assistant('fresh-after-reveal', 1_000)],
|
||||
hasMore: false
|
||||
})
|
||||
await act(async () => {
|
||||
oldPage.resolve({ messages: [assistant('stale-page', -1)] })
|
||||
await Promise.resolve()
|
||||
await Promise.resolve()
|
||||
})
|
||||
|
||||
expect(transport.readSession).toHaveBeenCalledTimes(3)
|
||||
expect(latest?.messages.map((message) => message.id)).toEqual(['fresh-after-reveal'])
|
||||
})
|
||||
})
|
||||
@@ -1,4 +1,4 @@
|
||||
import { useCallback, useEffect, useMemo, useRef, useState } from 'react'
|
||||
import { useCallback, useEffect, useLayoutEffect, useMemo, useRef, useState } from 'react'
|
||||
import {
|
||||
NATIVE_CHAT_SOURCE_PRIORITY,
|
||||
type AgentType,
|
||||
@@ -10,13 +10,7 @@ import {
|
||||
createNativeChatMerger,
|
||||
replaceList
|
||||
} from '../../../../shared/native-chat-merge'
|
||||
import {
|
||||
applyAppends,
|
||||
createIncrementalAssembler,
|
||||
reset as resetAssembler
|
||||
} from './native-chat-incremental-assembler'
|
||||
import { mergeNativeChatLiveSession } from './native-chat-live-status'
|
||||
import { prepareNativeChatLiveMessages } from './native-chat-live-message-preparation'
|
||||
import {
|
||||
hasMoreNativeChatHistory,
|
||||
NATIVE_CHAT_INITIAL_LIMIT,
|
||||
@@ -25,6 +19,9 @@ import {
|
||||
import { getNativeChatSessionTransport } from './native-chat-session-transport'
|
||||
import { useNativeChatTranscriptLifecycle } from './use-native-chat-transcript-lifecycle'
|
||||
import { useNativeChatHookStatus } from './use-native-chat-hook-status'
|
||||
import { useNativeChatAssembledMessages } from './use-native-chat-assembled-messages'
|
||||
import { createNativeChatReadRetryTimer } from './native-chat-read-retry-timer'
|
||||
import { openNativeChatTranscriptStream } from './native-chat-stream-teardown'
|
||||
|
||||
export type UseNativeChatLiveSessionArgs = {
|
||||
/** Composite `${tabId}:${leafId}` key — selects the live hook entry. */
|
||||
@@ -36,6 +33,8 @@ export type UseNativeChatLiveSessionArgs = {
|
||||
transcriptPath?: string | null
|
||||
/** Runtime owner (Model B): non-null routes read/subscribe to the remote host; null keeps the local IPC path. */
|
||||
runtimeEnvironmentId?: string | null
|
||||
/** False suspends transcript IO while retaining the last committed session. */
|
||||
enabled?: boolean
|
||||
}
|
||||
|
||||
/** A live session plus the older-history pagination controls the view needs. */
|
||||
@@ -55,20 +54,6 @@ export type NativeChatLiveSession = NativeChatSession & {
|
||||
// Stable empty-base reference so a non-ready read doesn't churn the base axis.
|
||||
const EMPTY_MESSAGES: readonly NativeChatMessage[] = []
|
||||
|
||||
/** True when `whole`'s first `len` entries are referentially identical to `prefix` (a tail-extension), so the assembler can splice just the suffix. */
|
||||
function sharesPrefix(
|
||||
whole: readonly NativeChatMessage[],
|
||||
prefix: readonly NativeChatMessage[],
|
||||
len: number
|
||||
): boolean {
|
||||
for (let i = 0; i < len; i += 1) {
|
||||
if (whole[i] !== prefix[i]) {
|
||||
return false
|
||||
}
|
||||
}
|
||||
return true
|
||||
}
|
||||
|
||||
let subscriptionCounter = 0
|
||||
|
||||
function nextSubscriptionId(): string {
|
||||
@@ -76,15 +61,9 @@ function nextSubscriptionId(): string {
|
||||
return `native-chat-${subscriptionCounter}-${Date.now()}`
|
||||
}
|
||||
|
||||
// Why: a new session's transcript can take minutes to appear on disk (#8401); a `notFound` miss retries with backoff until the window below elapses.
|
||||
const NOTFOUND_RETRY_DELAYS_MS = [1_000, 2_000, 4_000, 8_000]
|
||||
const NOTFOUND_RETRY_FIXED_DELAY_MS = 10_000
|
||||
// Why: a new session's transcript can take minutes to appear on disk (#8401).
|
||||
const NOTFOUND_RETRY_WINDOW_MS = 60_000
|
||||
|
||||
function notFoundRetryDelayMs(attempt: number): number {
|
||||
return NOTFOUND_RETRY_DELAYS_MS[attempt] ?? NOTFOUND_RETRY_FIXED_DELAY_MS
|
||||
}
|
||||
|
||||
export type ReadState =
|
||||
| { phase: 'loading' }
|
||||
| { phase: 'ready'; messages: NativeChatMessage[] }
|
||||
@@ -101,13 +80,13 @@ export type ReadState =
|
||||
* Transport: per-owner (getNativeChatSessionTransport) — a runtime-owned pane
|
||||
* (Model B) reads/tails the remote host; local/ssh panes keep the local IPC path.
|
||||
*
|
||||
* Teardown: subscription closes on unmount and on owner/agent/sessionId change so
|
||||
* a swap or owner-flip never leaks a watcher.
|
||||
* Teardown: subscription closes when hidden, unmounted, or rebound so no source
|
||||
* generation leaks a watcher.
|
||||
*/
|
||||
export function useNativeChatLiveSession(
|
||||
args: UseNativeChatLiveSessionArgs
|
||||
): NativeChatLiveSession {
|
||||
const { paneKey, agent, sessionId, transcriptPath, runtimeEnvironmentId } = args
|
||||
const { paneKey, agent, sessionId, transcriptPath, runtimeEnvironmentId, enabled = true } = args
|
||||
// Stable per owner id so a re-render without an owner flip keeps the same transport and doesn't re-subscribe.
|
||||
const transport = useMemo(
|
||||
() => getNativeChatSessionTransport(runtimeEnvironmentId ?? null),
|
||||
@@ -127,23 +106,43 @@ export function useNativeChatLiveSession(
|
||||
|
||||
const [hookState, hookStateStartedAt, hookHasWorkingSubagents] = useNativeChatHookStatus(paneKey)
|
||||
|
||||
const latestEnabled = useRef(enabled)
|
||||
// Fence late frames before passive cleanup closes the previous stream.
|
||||
useLayoutEffect(() => {
|
||||
latestEnabled.current = enabled
|
||||
}, [enabled])
|
||||
const latestSessionId = useRef<string | null>(sessionId)
|
||||
latestSessionId.current = sessionId
|
||||
// Tracks the current transport so a load-earlier resolve from a prior host is discarded after an owner flip (session id can stay the same).
|
||||
const latestTransport = useRef(transport)
|
||||
latestTransport.current = transport
|
||||
const transcriptEpochRef = useRef(0)
|
||||
|
||||
// Incremental assembler: suffix-extensions take the fast append path, anything else resets so the cache can't drift from a full rebuild (#17).
|
||||
const assemblerRef = useRef(createIncrementalAssembler())
|
||||
const appliedTranscriptRef = useRef<readonly NativeChatMessage[]>([])
|
||||
const baseSigRef = useRef<string | null>(null)
|
||||
const baseMessagesRef = useRef<readonly NativeChatMessage[]>(EMPTY_MESSAGES)
|
||||
const sourceKey = JSON.stringify([
|
||||
paneKey,
|
||||
runtimeEnvironmentId ?? null,
|
||||
agent,
|
||||
sessionId,
|
||||
transcriptPath ?? null
|
||||
])
|
||||
const retainedSourceKeyRef = useRef(sourceKey)
|
||||
|
||||
useEffect(() => {
|
||||
// Why: agent/path/owner rebinds can keep the same session; every source generation must invalidate pagination captured before it.
|
||||
transcriptEpochRef.current += 1
|
||||
setLoadingEarlier(false)
|
||||
const sourceChanged = retainedSourceKeyRef.current !== sourceKey
|
||||
retainedSourceKeyRef.current = sourceKey
|
||||
if (!enabled) {
|
||||
if (sourceChanged) {
|
||||
limitRef.current = NATIVE_CHAT_INITIAL_LIMIT
|
||||
transcriptLifecycleControl.reset()
|
||||
setRead({ phase: 'loading' })
|
||||
replaceList(appendMergerRef.current, [])
|
||||
setAppended([])
|
||||
setHasMore(false)
|
||||
}
|
||||
return () => undefined
|
||||
}
|
||||
transcriptLifecycleControl.reset()
|
||||
if (!sessionId) {
|
||||
// No session id yet: surface live hook state on an empty transcript; backfills once the id arrives.
|
||||
@@ -151,17 +150,20 @@ export function useNativeChatLiveSession(
|
||||
replaceList(appendMergerRef.current, [])
|
||||
setAppended([])
|
||||
setHasMore(false)
|
||||
return
|
||||
return () => undefined
|
||||
}
|
||||
|
||||
let cancelled = false
|
||||
// Set by the first authoritative frame so the readSession seed below can't clobber a live snapshot.
|
||||
let frameArrived = false
|
||||
let retryTimer: ReturnType<typeof setTimeout> | null = null
|
||||
const retryTimer = createNativeChatReadRetryTimer()
|
||||
const retryStartedAt = Date.now()
|
||||
// Re-bound as a const: TS drops the `!sessionId` narrowing inside the hoisted nested function.
|
||||
const activeSessionId = sessionId
|
||||
limitRef.current = NATIVE_CHAT_INITIAL_LIMIT
|
||||
// Why: a reveal re-reads the same source, so keep the window the user paged in; only a new source starts over.
|
||||
if (sourceChanged) {
|
||||
limitRef.current = NATIVE_CHAT_INITIAL_LIMIT
|
||||
}
|
||||
setRead({ phase: 'loading' })
|
||||
replaceList(appendMergerRef.current, [])
|
||||
setAppended([])
|
||||
@@ -169,22 +171,19 @@ export function useNativeChatLiveSession(
|
||||
|
||||
// Independent initial seed in case subscribe never delivers a snapshot; applied only until an authoritative frame lands so a live snapshot wins.
|
||||
function loadSession(attempt: number): void {
|
||||
if (frameArrived) {
|
||||
if (!latestEnabled.current || frameArrived) {
|
||||
return
|
||||
}
|
||||
void transport
|
||||
.readSession(agent, activeSessionId, limitRef.current, transcriptPath ?? undefined)
|
||||
.then((result) => {
|
||||
if (cancelled || frameArrived) {
|
||||
if (cancelled || !latestEnabled.current || frameArrived) {
|
||||
return
|
||||
}
|
||||
if (result && 'error' in result) {
|
||||
// A not-yet-flushed transcript: stay in 'loading' and retry with backoff instead of a permanent error (#8401).
|
||||
if (result.notFound && Date.now() - retryStartedAt < NOTFOUND_RETRY_WINDOW_MS) {
|
||||
retryTimer = setTimeout(() => {
|
||||
retryTimer = null
|
||||
loadSession(attempt + 1)
|
||||
}, notFoundRetryDelayMs(attempt))
|
||||
retryTimer.schedule(attempt, () => loadSession(attempt + 1))
|
||||
return
|
||||
}
|
||||
setRead({ phase: 'error', error: result.error })
|
||||
@@ -196,7 +195,7 @@ export function useNativeChatLiveSession(
|
||||
setHasMore(hasMoreNativeChatHistory(messages.length, limitRef.current))
|
||||
})
|
||||
.catch((err: unknown) => {
|
||||
if (!cancelled && !frameArrived) {
|
||||
if (!cancelled && latestEnabled.current && !frameArrived) {
|
||||
setRead({ phase: 'error', error: err instanceof Error ? err.message : String(err) })
|
||||
}
|
||||
})
|
||||
@@ -205,7 +204,8 @@ export function useNativeChatLiveSession(
|
||||
loadSession(0)
|
||||
|
||||
const subscriptionId = nextSubscriptionId()
|
||||
const unsubscribe = transport.subscribe(
|
||||
const closeStream = openNativeChatTranscriptStream(
|
||||
transport,
|
||||
{
|
||||
subscriptionId,
|
||||
agent,
|
||||
@@ -214,53 +214,48 @@ export function useNativeChatLiveSession(
|
||||
limit: limitRef.current
|
||||
},
|
||||
(frame) => {
|
||||
if (!cancelled) {
|
||||
if (frame.type === 'snapshot' || frame.type === 'replacement') {
|
||||
// Why: snapshots and inode replacements are authoritative generations; older pagination must not repaint them.
|
||||
frameArrived = true
|
||||
transcriptEpochRef.current += 1
|
||||
setLoadingEarlier(false)
|
||||
if ('error' in frame && frame.error) {
|
||||
setRead({ phase: 'error', error: frame.error })
|
||||
return
|
||||
}
|
||||
transcriptLifecycleControl.replace(frame.lifecycle)
|
||||
replaceList(appendMergerRef.current, frame.messages)
|
||||
setAppended([])
|
||||
setRead({ phase: 'ready', messages: appendMergerRef.current.list })
|
||||
setHasMore(frame.hasMore)
|
||||
if (cancelled || !latestEnabled.current) {
|
||||
return
|
||||
}
|
||||
if (frame.type === 'snapshot' || frame.type === 'replacement') {
|
||||
// Why: snapshots and inode replacements are authoritative generations; older pagination must not repaint them.
|
||||
transcriptEpochRef.current += 1
|
||||
setLoadingEarlier(false)
|
||||
if ('error' in frame && frame.error) {
|
||||
// Why: an error frame carries no transcript, so it must not consume the seed — a healthy read still has to repair the pane.
|
||||
setRead({ phase: 'error', error: frame.error })
|
||||
return
|
||||
}
|
||||
transcriptLifecycleControl.append(frame.lifecycle)
|
||||
// Merge by id then bound to the window; the base read + assembler re-dedup mean trimming the append tail can't drop a covered turn (#6).
|
||||
setAppended(applyAppend(appendMergerRef.current, frame.messages, limitRef.current))
|
||||
frameArrived = true
|
||||
transcriptLifecycleControl.replace(frame.lifecycle)
|
||||
replaceList(appendMergerRef.current, frame.messages)
|
||||
setAppended([])
|
||||
setRead({ phase: 'ready', messages: appendMergerRef.current.list })
|
||||
setHasMore(frame.hasMore)
|
||||
return
|
||||
}
|
||||
transcriptLifecycleControl.append(frame.lifecycle)
|
||||
// Merge by id then bound to the window; the base read + assembler re-dedup mean trimming the append tail can't drop a covered turn (#6).
|
||||
setAppended(applyAppend(appendMergerRef.current, frame.messages, limitRef.current))
|
||||
}
|
||||
)
|
||||
|
||||
return () => {
|
||||
cancelled = true
|
||||
if (retryTimer) {
|
||||
clearTimeout(retryTimer)
|
||||
retryTimer = null
|
||||
}
|
||||
// Web RPC bridge returns a Promise (not the desktop sync unsubscribe fn); calling it as a function crashed the view, so resolve first.
|
||||
const teardown = unsubscribe as unknown
|
||||
if (typeof teardown === 'function') {
|
||||
;(teardown as () => void)()
|
||||
} else if (teardown && typeof (teardown as { then?: unknown }).then === 'function') {
|
||||
void (teardown as Promise<unknown>).then((fn) => {
|
||||
if (typeof fn === 'function') {
|
||||
;(fn as () => void)()
|
||||
}
|
||||
})
|
||||
}
|
||||
retryTimer.cancel()
|
||||
closeStream()
|
||||
}
|
||||
// `transport` identity changes on an owner flip, re-running this effect to re-subscribe against the new host.
|
||||
}, [agent, sessionId, transcriptPath, transport, transcriptLifecycleControl])
|
||||
}, [agent, enabled, sessionId, sourceKey, transcriptPath, transport, transcriptLifecycleControl])
|
||||
|
||||
const loadEarlier = useCallback(() => {
|
||||
if (!sessionId || loadingEarlier || !hasMore || read.phase !== 'ready') {
|
||||
if (
|
||||
!latestEnabled.current ||
|
||||
!sessionId ||
|
||||
loadingEarlier ||
|
||||
!hasMore ||
|
||||
read.phase !== 'ready'
|
||||
) {
|
||||
return
|
||||
}
|
||||
const nextLimit = nextNativeChatLimit(limitRef.current)
|
||||
@@ -272,6 +267,7 @@ export function useNativeChatLiveSession(
|
||||
.then((result) => {
|
||||
// Ignore a stale resolve from a swapped session or flipped owner — either would paint the wrong host's history.
|
||||
if (
|
||||
!latestEnabled.current ||
|
||||
latestSessionId.current !== sessionId ||
|
||||
latestTransport.current !== transport ||
|
||||
transcriptEpochRef.current !== requestEpoch
|
||||
@@ -292,7 +288,7 @@ export function useNativeChatLiveSession(
|
||||
})
|
||||
.finally(() => {
|
||||
// Clear the loading flag on the current epoch even when the result is discarded, so a stale resolve can't wedge it true.
|
||||
if (transcriptEpochRef.current === requestEpoch) {
|
||||
if (latestEnabled.current && transcriptEpochRef.current === requestEpoch) {
|
||||
setLoadingEarlier(false)
|
||||
}
|
||||
})
|
||||
@@ -309,39 +305,12 @@ export function useNativeChatLiveSession(
|
||||
|
||||
// Computed outside the status memo so hookState churn (status-only) never re-runs the assembler.
|
||||
const baseMessages = read.phase === 'ready' ? read.messages : EMPTY_MESSAGES
|
||||
const assembledMessages = useMemo(() => {
|
||||
const transcript =
|
||||
appended.length > 0 ? [...baseMessages, ...appended] : (baseMessages as NativeChatMessage[])
|
||||
// Base-axis signature: any change forces a full assembler reset so a missed trigger can't leave the cache stale.
|
||||
const baseSig = `${agent}\u0000${sessionId ?? ''}`
|
||||
const baseChanged = baseSig !== baseSigRef.current || baseMessages !== baseMessagesRef.current
|
||||
const applied = appliedTranscriptRef.current
|
||||
const isSuffixExtension =
|
||||
!baseChanged &&
|
||||
transcript.length >= applied.length &&
|
||||
sharesPrefix(transcript, applied, applied.length)
|
||||
|
||||
let out: NativeChatMessage[]
|
||||
if (isSuffixExtension && transcript.length > applied.length) {
|
||||
out = applyAppends(assemblerRef.current, transcript.slice(applied.length))
|
||||
} else if (isSuffixExtension) {
|
||||
out = assemblerRef.current.messages
|
||||
} else {
|
||||
out = resetAssembler(assemblerRef.current, transcript)
|
||||
}
|
||||
baseSigRef.current = baseSig
|
||||
baseMessagesRef.current = baseMessages
|
||||
appliedTranscriptRef.current = transcript
|
||||
return out
|
||||
// baseMessages/appended are the only message-set inputs; sessionId/agent gate the reset. hookState intentionally excluded.
|
||||
// eslint-disable-next-line react-hooks/exhaustive-deps
|
||||
}, [baseMessages, appended, sessionId, agent])
|
||||
|
||||
// Keep presentation transforms off the status-only render axis.
|
||||
const normalizedMessages = useMemo(
|
||||
() => prepareNativeChatLiveMessages(assembledMessages, agent),
|
||||
[assembledMessages, agent]
|
||||
)
|
||||
const { assembledMessages, normalizedMessages } = useNativeChatAssembledMessages({
|
||||
agent,
|
||||
sessionId,
|
||||
baseMessages,
|
||||
appended
|
||||
})
|
||||
|
||||
return useMemo<NativeChatLiveSession>(() => {
|
||||
const session = mergeNativeChatLiveSession({
|
||||
|
||||
@@ -88,6 +88,28 @@ describe('useNativeChatRetainedSession', () => {
|
||||
expect(latest?.messages).toEqual([])
|
||||
})
|
||||
|
||||
it('keeps retained messages instead of a full-pane read error', async () => {
|
||||
liveSession.mockReturnValue(session('ready', [message('settled')]))
|
||||
await render(ARGS)
|
||||
|
||||
liveSession.mockReturnValue({ ...session('error', []), status: 'error', error: 'read failed' })
|
||||
await render(ARGS)
|
||||
|
||||
expect(latest?.messages.map((entry) => entry.id)).toEqual(['settled'])
|
||||
expect(latest?.readPhase).toBe('error')
|
||||
expect(latest?.status).toBe('ready')
|
||||
expect(latest?.error).toBeUndefined()
|
||||
})
|
||||
|
||||
it('surfaces a read error when nothing was retained', async () => {
|
||||
liveSession.mockReturnValue({ ...session('error', []), status: 'error', error: 'read failed' })
|
||||
await render(ARGS)
|
||||
|
||||
expect(latest?.messages).toEqual([])
|
||||
expect(latest?.status).toBe('error')
|
||||
expect(latest?.error).toBe('read failed')
|
||||
})
|
||||
|
||||
it('does not overwrite retention with the session-less view', async () => {
|
||||
liveSession.mockReturnValue(session('ready', [message('settled')]))
|
||||
await render(ARGS)
|
||||
|
||||
@@ -38,16 +38,17 @@ export function useNativeChatRetainedSession(
|
||||
const messages = retentionRef.current.visible({
|
||||
identity,
|
||||
messages: session.messages,
|
||||
settled: readPhase === 'ready',
|
||||
loading: readPhase === 'loading'
|
||||
settled: readPhase === 'ready'
|
||||
})
|
||||
if (messages === session.messages && readPhase === session.readPhase) {
|
||||
return session
|
||||
}
|
||||
return {
|
||||
...session,
|
||||
messages,
|
||||
readPhase,
|
||||
...(sessionMatchesIdentity ? {} : { status: 'loading' as const, error: undefined })
|
||||
if (!sessionMatchesIdentity) {
|
||||
return { ...session, messages, readPhase, status: 'loading', error: undefined }
|
||||
}
|
||||
// Retained history beats the full-pane error: a reveal-time read/stream failure is usually transient.
|
||||
if (session.status === 'error' && messages.length > 0) {
|
||||
return { ...session, messages, readPhase, status: 'ready', error: undefined }
|
||||
}
|
||||
return { ...session, messages, readPhase }
|
||||
}
|
||||
|
||||
@@ -3033,6 +3033,7 @@ function TerminalPane(
|
||||
<div className="absolute inset-0 z-10 flex min-h-0 min-w-0 bg-background">
|
||||
<NativeChatView
|
||||
terminalTabId={tabId}
|
||||
isVisible={isRendererVisible}
|
||||
paneKey={makePaneKey(tabId, chatPane.leafId)}
|
||||
targetPtyId={chatPanePtyId}
|
||||
launchAgent={chatPaneLaunchAgent}
|
||||
|
||||
@@ -10,40 +10,27 @@ function message(id: string): NativeChatMessage {
|
||||
}
|
||||
|
||||
describe('native chat transcript retention', () => {
|
||||
it('holds only the latest settled transcript for the same identity while loading', () => {
|
||||
it('holds only the latest settled transcript for the same identity while unsettled', () => {
|
||||
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([])
|
||||
expect(retention.visible({ identity: 'source-a', messages: [], settled: false })).toBe(first)
|
||||
expect(retention.visible({ identity: 'source-b', messages: [], settled: false })).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)
|
||||
expect(retention.visible({ identity: 'source-a', messages: [], settled: false })).toEqual([])
|
||||
expect(retention.visible({ identity: 'source-b', messages: [], settled: false })).toBe(second)
|
||||
})
|
||||
|
||||
it('never substitutes retained history for a settled or non-loading read', () => {
|
||||
it('never substitutes retained history for a settled 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([])
|
||||
expect(retention.visible({ identity: 'source', messages: fresh, settled: true })).toBe(fresh)
|
||||
})
|
||||
|
||||
it('encodes identity components without delimiter collisions', () => {
|
||||
|
||||
@@ -12,7 +12,6 @@ export type NativeChatTranscriptRetention = {
|
||||
identity: string
|
||||
messages: NativeChatMessage[]
|
||||
settled: boolean
|
||||
loading: boolean
|
||||
}) => NativeChatMessage[]
|
||||
}
|
||||
|
||||
@@ -24,13 +23,12 @@ export function createNativeChatTranscriptRetention(): NativeChatTranscriptReten
|
||||
capture(identity, messages) {
|
||||
captured = { identity, messages }
|
||||
},
|
||||
visible({ identity, messages, settled, loading }) {
|
||||
visible({ identity, messages, settled }) {
|
||||
if (settled) {
|
||||
return messages
|
||||
}
|
||||
return loading && captured?.identity === identity
|
||||
? captured.messages
|
||||
: EMPTY_NATIVE_CHAT_TRANSCRIPT
|
||||
// An unsettled read includes a failed one: retained history beats blanking the pane for a transient error.
|
||||
return captured?.identity === identity ? captured.messages : EMPTY_NATIVE_CHAT_TRANSCRIPT
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user