mirror of
https://github.com/stablyai/orca.git
synced 2026-10-21 08:03:33 +00:00
fix(mobile): keep the streaming gate alive across chat/terminal toggles
The gate lived in MobileNativeChatView, but MobileNativeChatOverlay returns null whenever the user peeks at the terminal — that unmounts the view and throws the baseline away, so the repeated-prefix reply was swallowed again on the way back. Move the gate (and the fold memo it reads) up to the overlay, which stays mounted across those toggles. While hidden the transcript is empty and the throttled stream reports no text, which the gate would have read as "idle" and re-anchored on. Pass the agent's working state so a textless tick inside a live segment holds the baseline instead. The scope key is now keyed off the tab rather than the view-gated chat resolution, so it survives the toggle too; streamIdentity keeps its exact previous value because the delayed-send guards compare against it. Also drops a dead disjunct in the caught-up test: a null baseline is already unequal to every real tail id.
This commit is contained in:
@@ -0,0 +1,172 @@
|
||||
import { createElement } from 'react'
|
||||
import { act, create, type ReactTestRenderer } from 'react-test-renderer'
|
||||
import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest'
|
||||
import type { NativeChatMessage } from '../../../src/shared/native-chat-types'
|
||||
import { MobileNativeChatOverlay } from './MobileNativeChatOverlay'
|
||||
import type { MobileNativeChatController } from './use-mobile-native-chat-controller'
|
||||
|
||||
vi.mock('react-native', () => ({
|
||||
StyleSheet: { create: (styles: unknown) => styles, absoluteFillObject: {} },
|
||||
View: 'View'
|
||||
}))
|
||||
|
||||
vi.mock('./MobileNativeChatView', () => ({ MobileNativeChatView: 'ChatView' }))
|
||||
|
||||
function assistantTurn(id: string, text: string): NativeChatMessage {
|
||||
return { id, role: 'assistant', blocks: [{ type: 'text', text }], timestamp: 0, source: 'hook' }
|
||||
}
|
||||
|
||||
function suppressRendererWarning(): () => void {
|
||||
const original = console.error
|
||||
const spy = vi.spyOn(console, 'error').mockImplementation((...args) => {
|
||||
if (typeof args[0] === 'string' && args[0].includes('react-test-renderer is deprecated')) {
|
||||
return
|
||||
}
|
||||
original(...args)
|
||||
})
|
||||
return () => spy.mockRestore()
|
||||
}
|
||||
|
||||
/** One render of the route: chat visible or not, the transcript it currently
|
||||
* holds, and the agent-status stream behind it. */
|
||||
type Tick = {
|
||||
show?: boolean
|
||||
messages?: NativeChatMessage[]
|
||||
streamingText?: string
|
||||
streamLive?: boolean
|
||||
identity?: string
|
||||
}
|
||||
|
||||
function overlayElement(tick: Tick): ReturnType<typeof createElement> {
|
||||
const controller = {
|
||||
showNativeChat: tick.show ?? true,
|
||||
nativeChatSession: { messages: tick.messages ?? [], status: 'ready' },
|
||||
nativeChatAgent: 'claude',
|
||||
nativeChatAgentWorking: tick.streamLive ?? false,
|
||||
nativeChatStreamingText: tick.streamingText,
|
||||
nativeChatStreamLive: tick.streamLive ?? false,
|
||||
nativeChatStreamScopeKey: tick.identity ?? 'tab-a',
|
||||
chatPending: [],
|
||||
chatComposerText: '',
|
||||
setChatComposerText: vi.fn()
|
||||
} as unknown as MobileNativeChatController
|
||||
return createElement(MobileNativeChatOverlay, {
|
||||
controller,
|
||||
images: {} as never,
|
||||
onMicPress: vi.fn(),
|
||||
micActive: false,
|
||||
dictationMode: 'toggle',
|
||||
onMicPressIn: vi.fn(),
|
||||
onMicPressOut: vi.fn(),
|
||||
inputLockReason: null,
|
||||
sendErrorMessage: null,
|
||||
onClearSendError: vi.fn(),
|
||||
keyboardInset: 0
|
||||
})
|
||||
}
|
||||
|
||||
describe('MobileNativeChatOverlay streaming gate', () => {
|
||||
let renderer: ReactTestRenderer | null = null
|
||||
|
||||
beforeEach(() => {
|
||||
globalThis.IS_REACT_ACT_ENVIRONMENT = true
|
||||
})
|
||||
|
||||
afterEach(() => {
|
||||
act(() => renderer?.unmount())
|
||||
renderer = null
|
||||
})
|
||||
|
||||
async function render(tick: Tick): Promise<void> {
|
||||
const restore = suppressRendererWarning()
|
||||
try {
|
||||
await act(async () => {
|
||||
renderer = create(overlayElement(tick))
|
||||
})
|
||||
} finally {
|
||||
restore()
|
||||
}
|
||||
}
|
||||
|
||||
async function update(tick: Tick): Promise<void> {
|
||||
await act(async () => {
|
||||
renderer?.update(overlayElement(tick))
|
||||
})
|
||||
}
|
||||
|
||||
/** The bubble text handed to the chat list, or `'hidden'` when chat is off. */
|
||||
function streaming(): string | null | 'hidden' {
|
||||
const views = renderer!.root.findAll((node) => node.type === 'ChatView')
|
||||
return views.length === 0 ? 'hidden' : (views[0].props.streaming as string | null)
|
||||
}
|
||||
|
||||
it('keeps streaming a reply that repeats the previous turn as a prefix', async () => {
|
||||
const prior = [assistantTurn('a1', 'The tests pass.')]
|
||||
await render({ messages: prior })
|
||||
expect(streaming()).toBeNull()
|
||||
|
||||
await update({ messages: prior, streamingText: 'The tests', streamLive: true })
|
||||
|
||||
expect(streaming()).toBe('The tests')
|
||||
})
|
||||
|
||||
it('drops the streaming bubble once the reply lands as its own turn', async () => {
|
||||
const prior = [assistantTurn('a1', 'Done.')]
|
||||
await render({ messages: prior })
|
||||
await update({ messages: prior, streamingText: 'Done.', streamLive: true })
|
||||
expect(streaming()).toBe('Done.')
|
||||
|
||||
await update({
|
||||
messages: [...prior, assistantTurn('a2', 'Done.')],
|
||||
streamingText: 'Done.',
|
||||
streamLive: true
|
||||
})
|
||||
|
||||
expect(streaming()).toBeNull()
|
||||
})
|
||||
|
||||
it('keeps the bubble across a peek at the terminal view', async () => {
|
||||
// Toggling to the terminal unmounts the chat list and unsubscribes its
|
||||
// transcript. The gate lives above that boundary, so the baseline survives
|
||||
// and the repeated-prefix reply keeps streaming on the way back.
|
||||
const prior = [assistantTurn('a1', 'Done.')]
|
||||
await render({ messages: prior })
|
||||
await update({ messages: prior, streamingText: 'Done.', streamLive: true })
|
||||
expect(streaming()).toBe('Done.')
|
||||
|
||||
await update({ show: false, messages: [], streamLive: true })
|
||||
expect(streaming()).toBe('hidden')
|
||||
// Back on chat: the throttled status trails the first re-render by a tick.
|
||||
await update({ messages: prior, streamLive: true })
|
||||
await update({ messages: prior, streamingText: 'Done.', streamLive: true })
|
||||
|
||||
expect(streaming()).toBe('Done.')
|
||||
})
|
||||
|
||||
it('still hides the bubble when the reply landed while chat was hidden', async () => {
|
||||
const prior = [assistantTurn('a1', 'Done.')]
|
||||
await render({ messages: prior })
|
||||
await update({ messages: prior, streamingText: 'Done.', streamLive: true })
|
||||
|
||||
await update({ show: false, messages: [], streamLive: true })
|
||||
const landed = [...prior, assistantTurn('a2', 'Done.')]
|
||||
await update({ messages: landed, streamLive: true })
|
||||
await update({ messages: landed, streamingText: 'Done.', streamLive: true })
|
||||
|
||||
expect(streaming()).toBeNull()
|
||||
})
|
||||
|
||||
it("does not carry one chat's baseline into another stream identity", async () => {
|
||||
const prior = [assistantTurn('a1', 'Shared answer text')]
|
||||
await render({ messages: prior, identity: 'tab-a' })
|
||||
|
||||
await update({
|
||||
messages: prior,
|
||||
streamingText: 'Shared answer',
|
||||
streamLive: true,
|
||||
identity: 'tab-b'
|
||||
})
|
||||
|
||||
expect(streaming()).toBeNull()
|
||||
})
|
||||
})
|
||||
@@ -1,7 +1,10 @@
|
||||
import { useMemo } from 'react'
|
||||
import { StyleSheet, View } from 'react-native'
|
||||
import { MobileNativeChatView, type MobileNativeChatInputLockReason } from './MobileNativeChatView'
|
||||
import { foldMobileNativeChatMessages } from './mobile-native-chat-render-data'
|
||||
import type { MobileNativeChatImageAttachments } from './use-mobile-native-chat-image-attachments'
|
||||
import type { MobileNativeChatController } from './use-mobile-native-chat-controller'
|
||||
import { useMobileNativeChatStreamingBubble } from './use-mobile-native-chat-streaming-bubble'
|
||||
|
||||
type Props = {
|
||||
controller: MobileNativeChatController
|
||||
@@ -22,7 +25,9 @@ type Props = {
|
||||
}
|
||||
|
||||
/** Keeps the terminal mounted underneath chat so its PTY subscription survives
|
||||
* view toggles while the native surface owns the visible composer. */
|
||||
* view toggles while the native surface owns the visible composer. Also owns
|
||||
* the streaming gate: this component stays mounted across those toggles, while
|
||||
* the chat list below it does not. */
|
||||
export function MobileNativeChatOverlay({
|
||||
controller,
|
||||
images,
|
||||
@@ -36,20 +41,27 @@ export function MobileNativeChatOverlay({
|
||||
onClearSendError,
|
||||
keyboardInset
|
||||
}: Props): React.JSX.Element | null {
|
||||
const session = controller.nativeChatSession
|
||||
const folded = useMemo(() => foldMobileNativeChatMessages(session.messages), [session.messages])
|
||||
const streaming = useMobileNativeChatStreamingBubble(
|
||||
folded,
|
||||
controller.nativeChatStreamingText,
|
||||
controller.nativeChatStreamScopeKey,
|
||||
controller.nativeChatStreamLive
|
||||
)
|
||||
if (!controller.showNativeChat) {
|
||||
return null
|
||||
}
|
||||
const session = controller.nativeChatSession
|
||||
return (
|
||||
<View style={styles.overlay}>
|
||||
<MobileNativeChatView
|
||||
messages={session.messages}
|
||||
folded={folded}
|
||||
status={session.status}
|
||||
error={session.error}
|
||||
agent={controller.nativeChatAgent}
|
||||
agentWorking={controller.nativeChatAgentWorking}
|
||||
streamingText={controller.nativeChatStreamingText}
|
||||
streamIdentity={controller.nativeChatStreamIdentity}
|
||||
streaming={streaming}
|
||||
onStop={controller.handleNativeChatStop}
|
||||
ask={controller.nativeChatAsk}
|
||||
onAnswerAsk={controller.handleNativeChatAnswerAsk}
|
||||
|
||||
@@ -60,8 +60,8 @@ vi.mock('./MobileNativeChatComposer', async () => {
|
||||
|
||||
type Overrides = {
|
||||
messages?: Parameters<typeof MobileNativeChatView>[0]['messages']
|
||||
streamingText?: string
|
||||
streamIdentity?: string
|
||||
folded?: Parameters<typeof MobileNativeChatView>[0]['folded']
|
||||
streaming?: string | null
|
||||
sendErrorMessage?: string | null
|
||||
onClearSendError?: () => void
|
||||
inputLockReason?: 'disconnected' | 'waiting' | null
|
||||
@@ -86,8 +86,9 @@ function assistantTurn(id: string, text: string): NativeChatMessage {
|
||||
function chatViewElement(overrides: Overrides): ReturnType<typeof createElement> {
|
||||
return createElement(MobileNativeChatView, {
|
||||
messages: [],
|
||||
folded: [],
|
||||
status: 'ready',
|
||||
streamIdentity: 'test-stream',
|
||||
streaming: null,
|
||||
onSend: vi.fn().mockResolvedValue(true),
|
||||
pending: [],
|
||||
composerText: '',
|
||||
@@ -96,7 +97,7 @@ function chatViewElement(overrides: Overrides): ReturnType<typeof createElement>
|
||||
})
|
||||
}
|
||||
|
||||
describe('MobileNativeChatView send-error banner', () => {
|
||||
describe('MobileNativeChatView', () => {
|
||||
let renderer: ReactTestRenderer | null = null
|
||||
|
||||
beforeEach(() => {
|
||||
@@ -185,36 +186,15 @@ describe('MobileNativeChatView send-error banner', () => {
|
||||
expect(onClearSendError).toHaveBeenCalledOnce()
|
||||
})
|
||||
|
||||
it('keeps streaming a reply that repeats the previous turn as a prefix', async () => {
|
||||
const prior = [assistantTurn('a1', 'The tests pass.')]
|
||||
await render({ messages: prior })
|
||||
// The gate that decides `streaming` lives in MobileNativeChatOverlay, which
|
||||
// outlives this view; see MobileNativeChatOverlay.test.ts.
|
||||
it('appends the gated streaming bubble after the folded transcript', async () => {
|
||||
const folded = [assistantTurn('a1', 'The tests pass.')]
|
||||
await render({ folded })
|
||||
expect(listIds()).toEqual(['a1'])
|
||||
|
||||
await update({ messages: prior, streamingText: 'The tests' })
|
||||
await update({ folded, streaming: 'The tests' })
|
||||
|
||||
expect(listIds()).toEqual(['a1', 'streaming'])
|
||||
})
|
||||
|
||||
it('drops the streaming bubble once the reply lands as its own turn', async () => {
|
||||
const prior = [assistantTurn('a1', 'Done.')]
|
||||
await render({ messages: prior })
|
||||
await update({ messages: prior, streamingText: 'Done.' })
|
||||
expect(listIds()).toEqual(['a1', 'streaming'])
|
||||
|
||||
await update({
|
||||
messages: [...prior, assistantTurn('a2', 'Done.')],
|
||||
streamingText: 'Done.'
|
||||
})
|
||||
|
||||
expect(listIds()).toEqual(['a1', 'a2'])
|
||||
})
|
||||
|
||||
it("does not carry one chat's baseline into another stream identity", async () => {
|
||||
const prior = [assistantTurn('a1', 'Shared answer text')]
|
||||
await render({ messages: prior, streamIdentity: 'tab-a' })
|
||||
|
||||
await update({ messages: prior, streamingText: 'Shared answer', streamIdentity: 'tab-b' })
|
||||
|
||||
expect(listIds()).toEqual(['a1'])
|
||||
})
|
||||
})
|
||||
|
||||
@@ -16,12 +16,10 @@ import { colors } from '../theme/mobile-theme'
|
||||
import { styles } from './mobile-native-chat-view-styles'
|
||||
import {
|
||||
buildMobileNativeChatTransientData,
|
||||
foldMobileNativeChatMessages,
|
||||
mobileNativeChatEmptyState,
|
||||
type MobileNativeChatPendingItem
|
||||
} from './mobile-native-chat-render-data'
|
||||
import { useMobileNativeChatAskDismiss } from './use-mobile-native-chat-ask-dismiss'
|
||||
import { useMobileNativeChatStreamingBubble } from './use-mobile-native-chat-streaming-bubble'
|
||||
import { useMobileNativeChatPinchGesture } from './use-mobile-native-chat-pinch-gesture'
|
||||
import { MobileAgentWorkingIndicator } from './MobileAgentWorkingIndicator'
|
||||
import type { PendingNativeChatImage } from './mobile-native-chat-image-attachment'
|
||||
@@ -40,7 +38,10 @@ import type { MobileNativeChatStatus } from './use-mobile-native-chat-session'
|
||||
export type MobileNativeChatInputLockReason = 'disconnected' | 'waiting'
|
||||
|
||||
type Props = {
|
||||
/** Raw transcript, only for telling "still loading" from "loaded and empty". */
|
||||
messages: NativeChatMessage[]
|
||||
/** `messages` with noise stripped and tool turns folded in, from the overlay. */
|
||||
folded: NativeChatMessage[]
|
||||
status: MobileNativeChatStatus
|
||||
error?: string
|
||||
/** Resolved agent for this chat; names the empty-state copy (desktop parity). */
|
||||
@@ -48,11 +49,9 @@ type Props = {
|
||||
agentWorking?: boolean
|
||||
/** Interrupt the agent mid-turn (shown as a Stop button on the working bar). */
|
||||
onStop?: () => void
|
||||
/** Live partial assistant text while a turn is still streaming (from the agent
|
||||
* status hook). Shown as an in-progress bubble until the transcript catches up. */
|
||||
streamingText?: string
|
||||
/** Scopes streaming catch-up state to the active host/workspace/tab/session. */
|
||||
streamIdentity: string
|
||||
/** Live partial assistant text to show as an in-progress bubble, already gated
|
||||
* by the overlay against the transcript catching up. */
|
||||
streaming: string | null
|
||||
hasMore?: boolean
|
||||
loadingEarlier?: boolean
|
||||
onLoadEarlier?: () => void
|
||||
@@ -105,13 +104,13 @@ type Props = {
|
||||
|
||||
export function MobileNativeChatView({
|
||||
messages,
|
||||
folded,
|
||||
status,
|
||||
error,
|
||||
agent,
|
||||
agentWorking,
|
||||
onStop,
|
||||
streamingText,
|
||||
streamIdentity,
|
||||
streaming,
|
||||
hasMore,
|
||||
loadingEarlier,
|
||||
onLoadEarlier,
|
||||
@@ -169,15 +168,9 @@ export function MobileNativeChatView({
|
||||
// `data` is the list source: folded transcript + synthetic streaming bubble +
|
||||
// route-owned optimistic queued messages. Memoize on the same deps so the
|
||||
// downstream autoscroll effects/`renderItem` keep referential stability.
|
||||
const foldedMessages = useMemo(() => foldMobileNativeChatMessages(messages), [messages])
|
||||
const streaming = useMobileNativeChatStreamingBubble(
|
||||
foldedMessages,
|
||||
streamingText,
|
||||
streamIdentity
|
||||
)
|
||||
const { data } = useMemo(
|
||||
() => buildMobileNativeChatTransientData({ folded: foldedMessages, streaming, pending }),
|
||||
[foldedMessages, streaming, pending]
|
||||
() => buildMobileNativeChatTransientData({ folded, streaming, pending }),
|
||||
[folded, streaming, pending]
|
||||
)
|
||||
|
||||
// Follow the tail as the conversation grows and keep the newest message above
|
||||
|
||||
@@ -17,14 +17,16 @@ function assistant(id: string, text: string): NativeChatMessage {
|
||||
}
|
||||
|
||||
/** Run a sequence of (folded, streamingText) ticks through one gate. */
|
||||
function run(ticks: { folded: NativeChatMessage[]; text?: string }[]): {
|
||||
function run(ticks: { folded: NativeChatMessage[]; text?: string; live?: boolean }[]): {
|
||||
gate: MobileNativeChatStreamingGate
|
||||
results: (string | null)[]
|
||||
} {
|
||||
let gate = createMobileNativeChatStreamingGate()
|
||||
const results: (string | null)[] = []
|
||||
for (const tick of ticks) {
|
||||
const step = deriveMobileNativeChatStreaming(gate, tick.folded, tick.text)
|
||||
const step = deriveMobileNativeChatStreaming(gate, tick.folded, tick.text, {
|
||||
streamLive: tick.live
|
||||
})
|
||||
gate = step.gate
|
||||
results.push(step.streaming)
|
||||
}
|
||||
@@ -77,6 +79,43 @@ describe('deriveMobileNativeChatStreaming', () => {
|
||||
expect(results).toEqual([null, 'answer', null, null])
|
||||
})
|
||||
|
||||
it('keeps the segment baseline through textless ticks while the turn is live', () => {
|
||||
// Chat is hidden mid-stream: the transcript unsubscribes and the status
|
||||
// stops reaching the gate, but the turn has not ended.
|
||||
const prior = [assistant('a1', 'Done.')]
|
||||
const { results } = run([
|
||||
{ folded: prior },
|
||||
{ folded: prior, text: 'Done.', live: true },
|
||||
{ folded: [], live: true },
|
||||
{ folded: prior, text: 'Done.', live: true }
|
||||
])
|
||||
expect(results).toEqual([null, 'Done.', null, 'Done.'])
|
||||
})
|
||||
|
||||
it('still hides after a hidden gap once the reply landed as its own turn', () => {
|
||||
const prior = [assistant('a1', 'Done.')]
|
||||
const landed = [...prior, assistant('a2', 'Done.')]
|
||||
const { results } = run([
|
||||
{ folded: prior },
|
||||
{ folded: prior, text: 'Done.', live: true },
|
||||
{ folded: [], live: true },
|
||||
{ folded: landed, text: 'Done.', live: true }
|
||||
])
|
||||
expect(results).toEqual([null, 'Done.', null, null])
|
||||
})
|
||||
|
||||
it('anchors on a textless tick once the turn ends', () => {
|
||||
const prior = [assistant('a1', 'first answer')]
|
||||
const landed = [...prior, assistant('a2', 'second answer')]
|
||||
const { results } = run([
|
||||
{ folded: prior },
|
||||
{ folded: prior, text: 'second answer', live: true },
|
||||
{ folded: landed },
|
||||
{ folded: landed, text: 'second answer', live: true }
|
||||
])
|
||||
expect(results).toEqual([null, 'second answer', null, 'second answer'])
|
||||
})
|
||||
|
||||
it('re-anchors when a new reply part replaces the stream mid-turn', () => {
|
||||
const prior = [assistant('a1', 'context')]
|
||||
const partOneLanded = [...prior, assistant('a2', 'part one full text')]
|
||||
@@ -111,9 +150,11 @@ describe('deriveMobileNativeChatStreaming', () => {
|
||||
// scope resets to the mid-stream fallback rather than reusing its baseline.
|
||||
const repeatedId = [assistant('a1', 'new answer landed')]
|
||||
let gate = createMobileNativeChatStreamingGate('tab-a')
|
||||
gate = deriveMobileNativeChatStreaming(gate, repeatedId, undefined, 'tab-a').gate
|
||||
gate = deriveMobileNativeChatStreaming(gate, repeatedId, undefined, { scopeKey: 'tab-a' }).gate
|
||||
|
||||
const switched = deriveMobileNativeChatStreaming(gate, repeatedId, 'new answer', 'tab-b')
|
||||
const switched = deriveMobileNativeChatStreaming(gate, repeatedId, 'new answer', {
|
||||
scopeKey: 'tab-b'
|
||||
})
|
||||
|
||||
expect(switched.streaming).toBeNull()
|
||||
expect(switched.gate.scopeKey).toBe('tab-b')
|
||||
|
||||
@@ -54,14 +54,26 @@ export function deriveMobileNativeChatStreaming(
|
||||
gate: MobileNativeChatStreamingGate,
|
||||
folded: readonly NativeChatMessage[],
|
||||
streamingText: string | undefined,
|
||||
scopeKey: string | null = gate.scopeKey
|
||||
options: {
|
||||
scopeKey?: string | null
|
||||
/** Whether the agent is still mid-turn. A textless tick then means "no
|
||||
* observation this render", not "the stream ended". */
|
||||
streamLive?: boolean
|
||||
} = {}
|
||||
): { gate: MobileNativeChatStreamingGate; streaming: string | null } {
|
||||
const scopeKey = options.scopeKey === undefined ? gate.scopeKey : options.scopeKey
|
||||
const scopedGate =
|
||||
gate.scopeKey === scopeKey ? gate : createMobileNativeChatStreamingGate(scopeKey)
|
||||
const text = streamingText?.trim() ?? ''
|
||||
const tail = folded.at(-1)
|
||||
const tailId = tail?.id ?? null
|
||||
if (!text) {
|
||||
if (options.streamLive && scopedGate.prevText !== '') {
|
||||
// A gap inside a live segment (chat hidden, transcript reloading, throttle
|
||||
// not caught up): re-anchoring here would adopt the reply that just landed
|
||||
// as pre-stream history and render it a second time as a bubble.
|
||||
return { gate: scopedGate, streaming: null }
|
||||
}
|
||||
// Idle: keep anchoring the baseline to the live tail so the next segment
|
||||
// knows which tail predates it.
|
||||
return { gate: advanceGate(scopedGate, '', tailId), streaming: null }
|
||||
@@ -71,10 +83,9 @@ export function deriveMobileNativeChatStreaming(
|
||||
const segmentStart = scopedGate.prevText !== '' && !text.startsWith(scopedGate.prevText)
|
||||
const baselineTailId = segmentStart ? tailId : scopedGate.baselineTailId
|
||||
const tailLeadsWithStream = assistantTailText(tail).startsWith(text)
|
||||
// baselineTailId === null: mounted mid-stream with no pre-stream observation —
|
||||
// fall back to suppress-on-prefix (a duplicate bubble is worse than briefly
|
||||
// hiding an improbable mount-coincident repeated reply).
|
||||
const caughtUp = tailLeadsWithStream && (baselineTailId === null || tailId !== baselineTailId)
|
||||
// A null baseline (mounted mid-stream, never saw an idle tick) is unequal to
|
||||
// every real tail id, so this degrades to the legacy suppress-on-prefix rule.
|
||||
const caughtUp = tailLeadsWithStream && tailId !== baselineTailId
|
||||
return {
|
||||
gate: advanceGate(scopedGate, text, baselineTailId),
|
||||
streaming: caughtUp ? null : text
|
||||
|
||||
@@ -421,3 +421,83 @@ describe('useMobileNativeChatController launch-draft wiring', () => {
|
||||
expect(draftsArgs.at(-1)).toMatchObject({ launchDraft: null, chatActive: true })
|
||||
})
|
||||
})
|
||||
|
||||
describe('useMobileNativeChatController streaming scope', () => {
|
||||
let renderer: ReactTestRenderer | null = null
|
||||
let controller: MobileNativeChatController | null = null
|
||||
const clientStub = { sendRequest: vi.fn() }
|
||||
|
||||
const workingTab = {
|
||||
type: 'terminal',
|
||||
id: 'tab-1',
|
||||
terminal: 'term-1',
|
||||
launchAgent: 'claude',
|
||||
agentStatus: {
|
||||
state: 'working',
|
||||
agentType: 'claude',
|
||||
providerSession: { id: 'session-1' }
|
||||
},
|
||||
isActive: true
|
||||
}
|
||||
|
||||
function Harness(): null {
|
||||
controller = useMobileNativeChatController({
|
||||
client: clientStub as unknown as RpcClient,
|
||||
connState: 'connected',
|
||||
hostId: 'h',
|
||||
worktreeId: 'w',
|
||||
activeSessionTab: workingTab as never,
|
||||
activeSessionTabId: 'tab-1',
|
||||
activeHandleRef: { current: 'term-1' },
|
||||
deviceTokenRef: { current: null },
|
||||
nativeChatTranscriptIsLocalReadable: true,
|
||||
nativeChatInputLeaseReady: true,
|
||||
onSendError: vi.fn(),
|
||||
onSendResolved: vi.fn()
|
||||
})
|
||||
return null
|
||||
}
|
||||
|
||||
beforeEach(() => {
|
||||
globalThis.IS_REACT_ACT_ENVIRONMENT = true
|
||||
viewMode.isTabChatView = () => true
|
||||
const original = console.error
|
||||
const spy = vi.spyOn(console, 'error').mockImplementation((...a) => {
|
||||
if (typeof a[0] === 'string' && a[0].includes('react-test-renderer is deprecated')) {
|
||||
return
|
||||
}
|
||||
original(...a)
|
||||
})
|
||||
try {
|
||||
act(() => {
|
||||
renderer = create(createElement(Harness))
|
||||
})
|
||||
} finally {
|
||||
spy.mockRestore()
|
||||
}
|
||||
})
|
||||
|
||||
afterEach(() => {
|
||||
act(() => renderer?.unmount())
|
||||
renderer = null
|
||||
controller = null
|
||||
viewMode.isTabChatView = () => true
|
||||
})
|
||||
|
||||
it('holds the stream scope and liveness while the user peeks at the terminal', () => {
|
||||
// Both feed the streaming gate, which lives above the chat view's mount and
|
||||
// must not be reset by a view toggle.
|
||||
expect(controller?.showNativeChat).toBe(true)
|
||||
const scopeKey = controller?.nativeChatStreamScopeKey
|
||||
expect(scopeKey).toContain('session-1')
|
||||
expect(controller?.nativeChatStreamLive).toBe(true)
|
||||
|
||||
viewMode.isTabChatView = () => false
|
||||
act(() => renderer?.update(createElement(Harness)))
|
||||
|
||||
expect(controller?.showNativeChat).toBe(false)
|
||||
expect(controller?.nativeChatAgentWorking).toBe(false)
|
||||
expect(controller?.nativeChatStreamScopeKey).toBe(scopeKey)
|
||||
expect(controller?.nativeChatStreamLive).toBe(true)
|
||||
})
|
||||
})
|
||||
|
||||
@@ -49,8 +49,10 @@ export type MobileNativeChatController = {
|
||||
nativeChatSession: ReturnType<typeof useMobileNativeChatSession>
|
||||
nativeChatAgentWorking: boolean
|
||||
nativeChatStreamingText?: string
|
||||
/** Host/workspace/tab/session identity for stateful streaming suppression. */
|
||||
nativeChatStreamIdentity: string
|
||||
/** Agent mid-turn, regardless of whether chat is the visible view. */
|
||||
nativeChatStreamLive: boolean
|
||||
/** Host/workspace/tab/session scope for stateful streaming suppression. */
|
||||
nativeChatStreamScopeKey: string
|
||||
nativeChatPermission: ReturnType<typeof detectAgentPermission>
|
||||
nativeChatQuestion: ReturnType<typeof parseAgentQuestion>
|
||||
nativeChatAsk: ReturnType<typeof parseAskFromStatus>
|
||||
@@ -126,7 +128,12 @@ export function useMobileNativeChatController(args: {
|
||||
activeChatAgentRef.current = activeChatResolution?.agent ?? null
|
||||
|
||||
const activeChatSessionId = activeChatResolution?.sessionId ?? null
|
||||
const streamIdentity = `${hostId}\0${worktreeId}\0${activeSessionTabId ?? ''}\0${activeChatSessionId ?? ''}\0${activeHandleRef.current ?? ''}`
|
||||
const routeKey = `${hostId}\0${worktreeId}\0${activeSessionTabId ?? ''}`
|
||||
const streamIdentity = `${routeKey}\0${activeChatSessionId ?? ''}\0${activeHandleRef.current ?? ''}`
|
||||
// Same chat, but keyed off the tab rather than the view-gated resolution:
|
||||
// `streamIdentity` goes session-less the moment the user peeks at the terminal,
|
||||
// and a scope that flips on a view toggle throws the gate's baseline away.
|
||||
const streamScopeKey = `${routeKey}\0${activeSessionTab?.agentStatus?.providerSession?.id ?? ''}\0${activeHandleRef.current ?? ''}`
|
||||
|
||||
const nativeChatSession = useMobileNativeChatSession({
|
||||
client,
|
||||
@@ -162,6 +169,9 @@ export function useMobileNativeChatController(args: {
|
||||
|
||||
const nativeChatStatus = activeChatResolution ? activeSessionTab?.agentStatus : null
|
||||
const nativeChatAgentWorking = nativeChatStatus?.state === 'working'
|
||||
// Deliberately not gated on the chat view being visible: the streaming gate
|
||||
// has to tell "hidden mid-turn" from "the turn ended".
|
||||
const nativeChatStreamLive = activeSessionTab?.agentStatus?.state === 'working'
|
||||
// Throttle the streaming bubble: OpenCode emits a status frame per streamed
|
||||
// part, and each one re-renders and re-parses the whole accumulated markdown.
|
||||
const nativeChatStreamingText = useThrottledLatestValue(
|
||||
@@ -275,7 +285,8 @@ export function useMobileNativeChatController(args: {
|
||||
nativeChatSession,
|
||||
nativeChatAgentWorking,
|
||||
nativeChatStreamingText,
|
||||
nativeChatStreamIdentity: streamIdentity,
|
||||
nativeChatStreamLive,
|
||||
nativeChatStreamScopeKey: streamScopeKey,
|
||||
nativeChatPermission,
|
||||
nativeChatQuestion,
|
||||
nativeChatAsk,
|
||||
|
||||
@@ -7,14 +7,21 @@ import {
|
||||
|
||||
/** Live streaming-bubble text for the chat list. The gate remembers which
|
||||
* transcript tail predates the current stream segment, so a reply that repeats
|
||||
* the previous turn's prefix still shows while streaming. */
|
||||
* the previous turn's prefix still shows while streaming. Call this from a
|
||||
* component that outlives the chat list itself: the baseline has to survive the
|
||||
* view toggles that unmount it, or the next segment reverts to prefix-matching.
|
||||
* `streamLive` keeps those textless gaps from reading as an idle stream. */
|
||||
export function useMobileNativeChatStreamingBubble(
|
||||
folded: readonly NativeChatMessage[],
|
||||
streamingText: string | undefined,
|
||||
scopeKey: string
|
||||
scopeKey: string,
|
||||
streamLive: boolean
|
||||
): string | null {
|
||||
const [gate, setGate] = useState(() => createMobileNativeChatStreamingGate(scopeKey))
|
||||
const step = deriveMobileNativeChatStreaming(gate, folded, streamingText, scopeKey)
|
||||
const step = deriveMobileNativeChatStreaming(gate, folded, streamingText, {
|
||||
scopeKey,
|
||||
streamLive
|
||||
})
|
||||
if (step.gate !== gate) {
|
||||
// Render-time state adjustment (derived-state pattern): the advance is
|
||||
// idempotent for a repeated (text, tail) pair, so this settles in one pass.
|
||||
|
||||
Reference in New Issue
Block a user