mirror of
https://github.com/stablyai/orca.git
synced 2026-09-21 16:02:20 +00:00
fix(mobile): keep repeated-prefix native chat replies streaming (STA-3333) (#12501)
* fix(mobile): keep repeated-prefix chat replies streaming Text alone can't tell "the transcript caught up with this stream" from "a new reply repeats the previous turn's prefix", so the old suppress-on- prefix rule swallowed genuine repeated replies. A stateful gate remembers which transcript tail predates the current stream segment and hides the bubble only when that tail moved during the segment, scoped to the active host/workspace/tab/session so a swapped chat can't inherit a baseline. Refs STA-3333. * 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. * test(mobile): model the real re-show ordering in the streaming-gate tests The overlay regression test replayed the transcript before the stream text on the way back from the terminal view. That ordering is backwards: the session withholds `messages` until a fresh read settles (an RPC round trip) while the throttled stream text returns in ~50ms — and with the transcript already back, a gate that got discarded on the toggle still passes. Replay the real order, which pins the gate's lifetime as intended. Swaps the hidden-gap duplicate case for the in-view one (a tool frame clears the assistant text mid-turn), which is where the hold actually earns its keep; the hidden-gap direction stays covered at the gate level. * fix(mobile): stop the streaming gate adopting a reply as its own history A textless status tick was re-anchoring the gate's pre-stream baseline, so two paths still rendered wrong: - The reply's transcript push beats its throttled status text whenever the pane stays `working` past the turn (a live subagent or background task). The tick in between adopted the just-landed reply as history, and the status text that followed rendered it a second time — a duplicate bubble, and a regression against main's suppress-on-prefix rule. - Peeking at the terminal between turns empties the transcript. That empty tail was adopted as the baseline, so the next repeated-prefix reply was swallowed again — the bug this PR exists to fix. Only a tick that carries a real tail and sits outside a live turn anchors now, with an exception for a gate that has never anchored: mounted mid-turn, the first real tail it sees is the best history it will ever get. Also drop `buildMobileNativeChatData`, a test-only builder this PR had wired the new gate into; its green test asserted the exact suppression this PR removes. Its fold/pending/image coverage moves to the builder the view calls. * test(mobile): pin the textless anchor's text reset Mutation testing found the `prevText` reset on an anchoring textless tick unpinned: keeping the previous turn's text there reads the next turn's opener as a new segment, re-anchors onto the reply that just landed, and renders it a second time — the same duplicate-bubble class already fixed twice on this branch.
This commit is contained in:
@@ -0,0 +1,193 @@
|
||||
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 session withholds its transcript until a fresh read
|
||||
// settles, so the throttled stream text returns a round trip ahead of it.
|
||||
await update({ messages: [], streamLive: true })
|
||||
await update({ messages: [], streamingText: 'Done.', streamLive: true })
|
||||
await update({ messages: prior, streamingText: 'Done.', streamLive: true })
|
||||
|
||||
expect(streaming()).toBe('Done.')
|
||||
})
|
||||
|
||||
it('keeps the bubble across a peek at the terminal taken between turns', async () => {
|
||||
// Same toggle, but taken while idle: the transcript empties before the next
|
||||
// turn starts, so the gate has to reject that empty tail as a baseline.
|
||||
const prior = [assistantTurn('a1', 'Done.')]
|
||||
await render({ messages: prior })
|
||||
|
||||
await update({ show: false, messages: [] })
|
||||
await update({ show: false, messages: [], streamLive: true })
|
||||
await update({ messages: [], streamLive: true })
|
||||
await update({ messages: [], streamingText: 'Done.', streamLive: true })
|
||||
await update({ messages: prior, streamingText: 'Done.', streamLive: true })
|
||||
|
||||
expect(streaming()).toBe('Done.')
|
||||
})
|
||||
|
||||
it('hides a repeated part whose own turn landed during a mid-turn gap', async () => {
|
||||
// Between parts the status frame carries no assistant text (a tool call), so
|
||||
// the stream goes textless while the turn is still live and the part that
|
||||
// just finished lands in the transcript. Re-anchoring on that tick would
|
||||
// adopt it as history and render it a second time.
|
||||
const prior = [assistantTurn('a1', 'Done.')]
|
||||
await render({ messages: prior })
|
||||
await update({ messages: prior, streamingText: 'Done.', streamLive: true })
|
||||
expect(streaming()).toBe('Done.')
|
||||
|
||||
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,19 +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}
|
||||
streaming={streaming}
|
||||
onStop={controller.handleNativeChatStop}
|
||||
ask={controller.nativeChatAsk}
|
||||
onAnswerAsk={controller.handleNativeChatAnswerAsk}
|
||||
|
||||
@@ -1,6 +1,7 @@
|
||||
import { createElement } from 'react'
|
||||
import { act, create, type ReactTestInstance, 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 { MobileNativeChatView } from './MobileNativeChatView'
|
||||
|
||||
vi.mock('react-native', () => ({
|
||||
@@ -58,6 +59,9 @@ vi.mock('./MobileNativeChatComposer', async () => {
|
||||
})
|
||||
|
||||
type Overrides = {
|
||||
messages?: Parameters<typeof MobileNativeChatView>[0]['messages']
|
||||
folded?: Parameters<typeof MobileNativeChatView>[0]['folded']
|
||||
streaming?: string | null
|
||||
sendErrorMessage?: string | null
|
||||
onClearSendError?: () => void
|
||||
inputLockReason?: 'disconnected' | 'waiting' | null
|
||||
@@ -75,7 +79,25 @@ function suppressRendererWarning(): () => void {
|
||||
return () => spy.mockRestore()
|
||||
}
|
||||
|
||||
describe('MobileNativeChatView send-error banner', () => {
|
||||
function assistantTurn(id: string, text: string): NativeChatMessage {
|
||||
return { id, role: 'assistant', blocks: [{ type: 'text', text }], timestamp: 0, source: 'hook' }
|
||||
}
|
||||
|
||||
function chatViewElement(overrides: Overrides): ReturnType<typeof createElement> {
|
||||
return createElement(MobileNativeChatView, {
|
||||
messages: [],
|
||||
folded: [],
|
||||
status: 'ready',
|
||||
streaming: null,
|
||||
onSend: vi.fn().mockResolvedValue(true),
|
||||
pending: [],
|
||||
composerText: '',
|
||||
onComposerTextChange: vi.fn(),
|
||||
...overrides
|
||||
})
|
||||
}
|
||||
|
||||
describe('MobileNativeChatView', () => {
|
||||
let renderer: ReactTestRenderer | null = null
|
||||
|
||||
beforeEach(() => {
|
||||
@@ -91,23 +113,25 @@ describe('MobileNativeChatView send-error banner', () => {
|
||||
const restore = suppressRendererWarning()
|
||||
try {
|
||||
await act(async () => {
|
||||
renderer = create(
|
||||
createElement(MobileNativeChatView, {
|
||||
messages: [],
|
||||
status: 'ready',
|
||||
onSend: overrides.onSend ?? vi.fn().mockResolvedValue(true),
|
||||
pending: [],
|
||||
composerText: '',
|
||||
onComposerTextChange: vi.fn(),
|
||||
...overrides
|
||||
})
|
||||
)
|
||||
renderer = create(chatViewElement(overrides))
|
||||
})
|
||||
} finally {
|
||||
restore()
|
||||
}
|
||||
}
|
||||
|
||||
async function update(overrides: Overrides = {}): Promise<void> {
|
||||
await act(async () => {
|
||||
renderer?.update(chatViewElement(overrides))
|
||||
})
|
||||
}
|
||||
|
||||
/** Ids of the rows the list is currently rendering. */
|
||||
function listIds(): string[] {
|
||||
const list = renderer!.root.find((node) => node.type === 'FlatList')
|
||||
return (list.props.data as { id: string }[]).map((row) => row.id)
|
||||
}
|
||||
|
||||
function banners(): ReactTestInstance[] {
|
||||
return renderer!.root.findAll((node) => node.props.accessibilityRole === 'alert')
|
||||
}
|
||||
@@ -161,4 +185,16 @@ describe('MobileNativeChatView send-error banner', () => {
|
||||
|
||||
expect(onClearSendError).toHaveBeenCalledOnce()
|
||||
})
|
||||
|
||||
// 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({ folded, streaming: 'The tests' })
|
||||
|
||||
expect(listIds()).toEqual(['a1', 'streaming'])
|
||||
})
|
||||
})
|
||||
|
||||
@@ -16,7 +16,6 @@ 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'
|
||||
@@ -39,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). */
|
||||
@@ -47,9 +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
|
||||
/** 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
|
||||
@@ -102,12 +104,13 @@ type Props = {
|
||||
|
||||
export function MobileNativeChatView({
|
||||
messages,
|
||||
folded,
|
||||
status,
|
||||
error,
|
||||
agent,
|
||||
agentWorking,
|
||||
onStop,
|
||||
streamingText,
|
||||
streaming,
|
||||
hasMore,
|
||||
loadingEarlier,
|
||||
onLoadEarlier,
|
||||
@@ -165,10 +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 { data } = useMemo(
|
||||
() => buildMobileNativeChatTransientData({ folded: foldedMessages, streamingText, pending }),
|
||||
[foldedMessages, streamingText, pending]
|
||||
() => buildMobileNativeChatTransientData({ folded, streaming, pending }),
|
||||
[folded, streaming, pending]
|
||||
)
|
||||
|
||||
// Follow the tail as the conversation grows and keep the newest message above
|
||||
|
||||
@@ -1,7 +1,8 @@
|
||||
import { describe, expect, it } from 'vitest'
|
||||
import type { NativeChatMessage } from '../../../src/shared/native-chat-types'
|
||||
import {
|
||||
buildMobileNativeChatData,
|
||||
buildMobileNativeChatTransientData,
|
||||
foldMobileNativeChatMessages,
|
||||
mobileNativeChatEmptyState
|
||||
} from './mobile-native-chat-render-data'
|
||||
|
||||
@@ -50,13 +51,22 @@ describe('mobileNativeChatEmptyState', () => {
|
||||
})
|
||||
})
|
||||
|
||||
describe('buildMobileNativeChatData', () => {
|
||||
/** Mirrors the view: fold the raw transcript, then assemble the list. */
|
||||
function build(
|
||||
messages: NativeChatMessage[],
|
||||
streaming: string | null,
|
||||
pending: Parameters<typeof buildMobileNativeChatTransientData>[0]['pending']
|
||||
): NativeChatMessage[] {
|
||||
return buildMobileNativeChatTransientData({
|
||||
folded: foldMobileNativeChatMessages(messages),
|
||||
streaming,
|
||||
pending
|
||||
}).data
|
||||
}
|
||||
|
||||
describe('buildMobileNativeChatTransientData', () => {
|
||||
it('appends pending optimistic messages at the tail as user turns', () => {
|
||||
const { data } = buildMobileNativeChatData({
|
||||
messages: [assistant('a1', 'hello')],
|
||||
streamingText: undefined,
|
||||
pending: [{ id: 'p1', text: 'queued' }]
|
||||
})
|
||||
const data = build([assistant('a1', 'hello')], null, [{ id: 'p1', text: 'queued' }])
|
||||
const last = data[data.length - 1]
|
||||
expect(last.id).toBe('p1')
|
||||
expect(last.role).toBe('user')
|
||||
@@ -64,10 +74,9 @@ describe('buildMobileNativeChatData', () => {
|
||||
})
|
||||
|
||||
it('renders a pending send with images as text followed by image-ref thumbnails', () => {
|
||||
const { data } = buildMobileNativeChatData({
|
||||
messages: [],
|
||||
pending: [{ id: 'p1', text: 'look', images: ['file:///a.jpg', 'file:///b.jpg'] }]
|
||||
})
|
||||
const data = build([], null, [
|
||||
{ id: 'p1', text: 'look', images: ['file:///a.jpg', 'file:///b.jpg'] }
|
||||
])
|
||||
const last = data[data.length - 1]
|
||||
expect(last.role).toBe('user')
|
||||
expect(last.blocks).toEqual([
|
||||
@@ -78,10 +87,7 @@ describe('buildMobileNativeChatData', () => {
|
||||
})
|
||||
|
||||
it('renders an image-only pending send (no text) as just the thumbnail', () => {
|
||||
const { data } = buildMobileNativeChatData({
|
||||
messages: [],
|
||||
pending: [{ id: 'p1', text: '', images: ['file:///a.jpg'] }]
|
||||
})
|
||||
const data = build([], null, [{ id: 'p1', text: '', images: ['file:///a.jpg'] }])
|
||||
expect(data[data.length - 1].blocks).toEqual([{ type: 'image-ref', url: 'file:///a.jpg' }])
|
||||
})
|
||||
|
||||
@@ -89,14 +95,15 @@ describe('buildMobileNativeChatData', () => {
|
||||
// Claude records an attached image as `[Image: source: /path]` + an
|
||||
// `[Image #1] `-prefixed caption turn; the fold must merge them into one
|
||||
// user turn with an image-ref block instead of showing raw marker text.
|
||||
const { data } = buildMobileNativeChatData({
|
||||
messages: [
|
||||
const data = build(
|
||||
[
|
||||
user('u1', '[Image: source: /tmp/a.png]'),
|
||||
user('u2', '[Image #1] look at this'),
|
||||
assistant('a1', 'nice photo')
|
||||
],
|
||||
pending: []
|
||||
})
|
||||
null,
|
||||
[]
|
||||
)
|
||||
const merged = data.find((message) => message.role === 'user')
|
||||
expect(merged?.blocks).toEqual([
|
||||
{ type: 'image-ref', path: '/tmp/a.png' },
|
||||
@@ -105,49 +112,20 @@ describe('buildMobileNativeChatData', () => {
|
||||
})
|
||||
|
||||
it('renders a lone image marker turn (no caption) as an image-ref block', () => {
|
||||
const { data } = buildMobileNativeChatData({
|
||||
messages: [user('u1', '[Image: source: /tmp/a.png]')],
|
||||
pending: []
|
||||
})
|
||||
const data = build([user('u1', '[Image: source: /tmp/a.png]')], null, [])
|
||||
expect(data[0]?.blocks).toEqual([{ type: 'image-ref', path: '/tmp/a.png' }])
|
||||
})
|
||||
|
||||
it('adds a synthetic streaming bubble while the partial text leads the transcript', () => {
|
||||
const { streaming, data } = buildMobileNativeChatData({
|
||||
messages: [user('u1', 'hi')],
|
||||
streamingText: 'thinking out loud',
|
||||
pending: []
|
||||
})
|
||||
expect(streaming).toBe('thinking out loud')
|
||||
expect(data.some((m) => m.id === 'streaming')).toBe(true)
|
||||
it('appends a synthetic bubble for gated streaming text, between transcript and pending', () => {
|
||||
// Whether text streams at all is the gate's call
|
||||
// (`mobile-native-chat-streaming-gate.test.ts`); this only places it.
|
||||
const data = build([user('u1', 'hi')], 'thinking out loud', [{ id: 'p1', text: 'queued' }])
|
||||
expect(data.map((message) => message.id)).toEqual(['u1', 'streaming', 'p1'])
|
||||
expect(data[1].blocks).toEqual([{ type: 'text', text: 'thinking out loud' }])
|
||||
})
|
||||
|
||||
it('shows a short new streaming reply even after a longer previous turn', () => {
|
||||
// The last folded turn is a long completed reply; a short new stream must not
|
||||
// be suppressed just for being shorter than the prior turn.
|
||||
const { streaming, data } = buildMobileNativeChatData({
|
||||
messages: [assistant('a1', 'This is a long completed previous answer that ran on a while')],
|
||||
streamingText: 'Ok',
|
||||
pending: []
|
||||
})
|
||||
expect(streaming).toBe('Ok')
|
||||
expect(data.some((m) => m.id === 'streaming')).toBe(true)
|
||||
})
|
||||
|
||||
it('drops the streaming bubble once the real assistant turn already contains it', () => {
|
||||
const { streaming, data } = buildMobileNativeChatData({
|
||||
messages: [assistant('a1', 'done answer')],
|
||||
streamingText: 'done',
|
||||
pending: []
|
||||
})
|
||||
expect(streaming).toBeNull()
|
||||
expect(data.some((m) => m.id === 'streaming')).toBe(false)
|
||||
})
|
||||
|
||||
it('returns no streaming bubble for empty/whitespace streaming text', () => {
|
||||
expect(
|
||||
buildMobileNativeChatData({ messages: [], streamingText: ' ', pending: [] }).streaming
|
||||
).toBeNull()
|
||||
expect(buildMobileNativeChatData({ messages: [], pending: [] }).streaming).toBeNull()
|
||||
it('omits the bubble when the gate withheld the streaming text', () => {
|
||||
const data = build([assistant('a1', 'done answer')], null, [])
|
||||
expect(data.some((message) => message.id === 'streaming')).toBe(false)
|
||||
})
|
||||
})
|
||||
|
||||
@@ -43,41 +43,25 @@ export type MobileNativeChatPendingItem = {
|
||||
images?: string[]
|
||||
}
|
||||
|
||||
/** Derive the list data from the raw transcript: fold tool turns into the
|
||||
* assistant turn, optionally append a synthetic streaming bubble, then the
|
||||
* route-owned optimistic "queued" messages at the tail. Returns the
|
||||
* intermediate `folded`/`streaming` so the caller can memoize on them. */
|
||||
export function buildMobileNativeChatData({
|
||||
messages,
|
||||
streamingText,
|
||||
pending
|
||||
}: {
|
||||
messages: NativeChatMessage[]
|
||||
streamingText?: string
|
||||
pending: MobileNativeChatPendingItem[]
|
||||
}): { folded: NativeChatMessage[]; streaming: string | null; data: NativeChatMessage[] } {
|
||||
const folded = foldMobileNativeChatMessages(messages)
|
||||
return buildMobileNativeChatTransientData({ folded, streamingText, pending })
|
||||
}
|
||||
|
||||
export function foldMobileNativeChatMessages(messages: NativeChatMessage[]): NativeChatMessage[] {
|
||||
// Normalize first (desktop assembler parity): image marker turns fold into
|
||||
// image-ref blocks instead of rendering as raw `[Image: …]` text.
|
||||
return foldToolMessages(stripNoiseMessages(normalizeImageTranscriptMessages(messages)))
|
||||
}
|
||||
|
||||
/** Assemble the list data the chat renders: the folded transcript, then a
|
||||
* synthetic bubble for the streaming text the gate let through, then the
|
||||
* route-owned optimistic "queued" messages at the tail. */
|
||||
export function buildMobileNativeChatTransientData({
|
||||
folded,
|
||||
streamingText,
|
||||
streaming,
|
||||
pending
|
||||
}: {
|
||||
folded: NativeChatMessage[]
|
||||
streamingText?: string
|
||||
/** Streaming bubble text, already gated by `deriveMobileNativeChatStreaming`. */
|
||||
streaming: string | null
|
||||
pending: MobileNativeChatPendingItem[]
|
||||
}): { folded: NativeChatMessage[]; streaming: string | null; data: NativeChatMessage[] } {
|
||||
// Only show the streaming bubble while its text leads the transcript — once the
|
||||
// real assistant turn lands with the same text, drop the synthetic one.
|
||||
const streaming = deriveStreaming(folded, streamingText)
|
||||
const data: NativeChatMessage[] = [
|
||||
...folded,
|
||||
...(streaming
|
||||
@@ -106,26 +90,3 @@ export function buildMobileNativeChatTransientData({
|
||||
]
|
||||
return { folded, streaming, data }
|
||||
}
|
||||
|
||||
function deriveStreaming(folded: NativeChatMessage[], streamingText?: string): string | null {
|
||||
const text = streamingText?.trim()
|
||||
if (!text) {
|
||||
return null
|
||||
}
|
||||
const last = folded[folded.length - 1]
|
||||
const lastText =
|
||||
last?.role === 'assistant'
|
||||
? last.blocks
|
||||
.filter((b) => b.type === 'text')
|
||||
.map((b) => (b.type === 'text' ? b.text : ''))
|
||||
.join('')
|
||||
.trim()
|
||||
: ''
|
||||
// Hide the synthetic bubble only once the real turn has landed leading with the
|
||||
// streamed text. A bare length compare would suppress a short new reply behind a
|
||||
// longer previous turn; a completed prior turn won't start with the new prefix.
|
||||
if (lastText.startsWith(text)) {
|
||||
return null
|
||||
}
|
||||
return text
|
||||
}
|
||||
|
||||
@@ -0,0 +1,238 @@
|
||||
import { describe, expect, it } from 'vitest'
|
||||
import type { NativeChatMessage } from '../../../src/shared/native-chat-types'
|
||||
import {
|
||||
createMobileNativeChatStreamingGate,
|
||||
deriveMobileNativeChatStreaming,
|
||||
type MobileNativeChatStreamingGate
|
||||
} from './mobile-native-chat-streaming-gate'
|
||||
|
||||
function assistant(id: string, text: string): NativeChatMessage {
|
||||
return {
|
||||
id,
|
||||
role: 'assistant',
|
||||
blocks: [{ type: 'text', text }],
|
||||
timestamp: 0,
|
||||
source: 'transcript'
|
||||
}
|
||||
}
|
||||
|
||||
/** Run a sequence of (folded, streamingText) ticks through one gate. */
|
||||
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, {
|
||||
streamLive: tick.live
|
||||
})
|
||||
gate = step.gate
|
||||
results.push(step.streaming)
|
||||
}
|
||||
return { gate, results }
|
||||
}
|
||||
|
||||
describe('deriveMobileNativeChatStreaming', () => {
|
||||
it('shows a genuine reply that repeats the previous turn as a prefix', () => {
|
||||
const prior = [assistant('a1', 'The tests pass.')]
|
||||
const { results } = run([
|
||||
{ folded: prior }, // idle tick anchors the pre-stream tail
|
||||
{ folded: prior, text: 'The' },
|
||||
{ folded: prior, text: 'The tests' },
|
||||
{ folded: prior, text: 'The tests pass.' }
|
||||
])
|
||||
expect(results).toEqual([null, 'The', 'The tests', 'The tests pass.'])
|
||||
})
|
||||
|
||||
it('hides the bubble once the real turn lands leading with the streamed text', () => {
|
||||
const prior = [assistant('a1', 'earlier turn')]
|
||||
const landed = [...prior, assistant('a2', 'fresh answer with a tail')]
|
||||
const { results } = run([
|
||||
{ folded: prior },
|
||||
{ folded: prior, text: 'fresh answer' },
|
||||
{ folded: landed, text: 'fresh answer' }
|
||||
])
|
||||
expect(results).toEqual([null, 'fresh answer', null])
|
||||
})
|
||||
|
||||
it('suppresses an identical repeated reply once its own turn lands', () => {
|
||||
const prior = [assistant('a1', 'Done.')]
|
||||
const landed = [...prior, assistant('a2', 'Done.')]
|
||||
const { results } = run([
|
||||
{ folded: prior },
|
||||
{ folded: prior, text: 'Done.' }, // repeated-prefix reply stays visible
|
||||
{ folded: landed, text: 'Done.' } // its own turn landed — hide
|
||||
])
|
||||
expect(results).toEqual([null, 'Done.', null])
|
||||
})
|
||||
|
||||
it('keeps hiding for the rest of a segment after the turn lands', () => {
|
||||
const prior = [assistant('a1', 'earlier')]
|
||||
const landed = [...prior, assistant('a2', 'answer body')]
|
||||
const { results } = run([
|
||||
{ folded: prior },
|
||||
{ folded: prior, text: 'answer' },
|
||||
{ folded: landed, text: 'answer' },
|
||||
{ folded: landed, text: 'answer bo' }
|
||||
])
|
||||
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. Coming back, the
|
||||
// stream text returns before the re-read transcript does.
|
||||
const prior = [assistant('a1', 'Done.')]
|
||||
const { results } = run([
|
||||
{ folded: prior },
|
||||
{ folded: prior, text: 'Done.', live: true },
|
||||
{ folded: [], live: true },
|
||||
{ folded: [], text: 'Done.', live: true },
|
||||
{ folded: prior, text: 'Done.', live: true }
|
||||
])
|
||||
expect(results).toEqual([null, 'Done.', null, 'Done.', '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('hides a reply whose own turn landed before its status text arrived', () => {
|
||||
// The pane stays `working` past the reply (a subagent or a background task
|
||||
// is still live), and the transcript push beats the throttled status text.
|
||||
// Anchoring on that textless tick would adopt the reply as pre-stream
|
||||
// history and render it a second time as a bubble.
|
||||
const prior = [assistant('a1', 'Done.')]
|
||||
const landed = [...prior, assistant('a2', 'Done.')]
|
||||
const { results } = run([
|
||||
{ folded: prior, live: true },
|
||||
{ folded: landed, live: true },
|
||||
{ folded: landed, text: 'Done.', live: true },
|
||||
{ folded: landed, text: 'Done.', live: true }
|
||||
])
|
||||
expect(results).toEqual([null, null, null, null])
|
||||
})
|
||||
|
||||
it('keeps the pre-stream baseline across a hidden gap taken between turns', () => {
|
||||
// Peeking at the terminal while idle tears the transcript down to empty. An
|
||||
// empty tail is not history: adopting it strands the baseline and swallows
|
||||
// the repeated-prefix reply that arrives next.
|
||||
const prior = [assistant('a1', 'Done.')]
|
||||
const { results } = run([
|
||||
{ folded: prior },
|
||||
{ folded: [] },
|
||||
{ folded: [], live: true },
|
||||
{ folded: prior, text: 'Done.', live: true }
|
||||
])
|
||||
expect(results).toEqual([null, null, null, 'Done.'])
|
||||
})
|
||||
|
||||
it('anchors on the first tail it sees when mounted mid-turn', () => {
|
||||
// Opening a workspace whose agent is already working: that first textless
|
||||
// tick is the only pre-stream history the gate will ever get.
|
||||
const prior = [assistant('a1', 'Done.')]
|
||||
const { results } = run([
|
||||
{ folded: prior, live: true },
|
||||
{ folded: prior, text: 'Done.', live: true }
|
||||
])
|
||||
expect(results).toEqual([null, 'Done.'])
|
||||
})
|
||||
|
||||
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('does not treat the previous turn as a segment start after re-anchoring', () => {
|
||||
// The textless anchor clears the remembered text too. Keeping it would read
|
||||
// the next turn's opener as a new segment, re-anchor onto the reply that
|
||||
// just landed, and render it a second time as a bubble.
|
||||
const prior = [assistant('a1', 'context')]
|
||||
const firstLanded = [...prior, assistant('a2', 'Alpha done')]
|
||||
const secondLanded = [...firstLanded, assistant('a3', 'Beta reply')]
|
||||
const { results } = run([
|
||||
{ folded: prior },
|
||||
{ folded: prior, text: 'Alpha', live: true },
|
||||
{ folded: firstLanded }, // turn ended — re-anchor onto a2
|
||||
{ folded: secondLanded, text: 'Beta', live: true } // a3 already landed
|
||||
])
|
||||
expect(results).toEqual([null, 'Alpha', null, null])
|
||||
})
|
||||
|
||||
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')]
|
||||
const { results } = run([
|
||||
{ folded: prior },
|
||||
{ folded: prior, text: 'part one' },
|
||||
{ folded: partOneLanded, text: 'part one' }, // caught up — hide
|
||||
// Part two is not an extension of part one: new segment, new baseline.
|
||||
{ folded: partOneLanded, text: 'part' }
|
||||
])
|
||||
expect(results).toEqual([null, 'part one', null, 'part'])
|
||||
})
|
||||
|
||||
it('falls back to suppress-on-prefix when text arrives on the first tick', () => {
|
||||
// No tail ever observed before the text: a duplicate bubble is worse than
|
||||
// briefly hiding a mount-coincident repeated reply.
|
||||
const landed = [assistant('a1', 'flushed part still streaming in status')]
|
||||
const { results } = run([{ folded: landed, text: 'flushed part' }])
|
||||
expect(results).toEqual([null])
|
||||
})
|
||||
|
||||
it('is idempotent for a repeated tick', () => {
|
||||
const prior = [assistant('a1', 'The tests pass.')]
|
||||
const first = run([{ folded: prior }, { folded: prior, text: 'The tests' }])
|
||||
const again = deriveMobileNativeChatStreaming(first.gate, prior, 'The tests')
|
||||
expect(again.streaming).toBe('The tests')
|
||||
expect(again.gate).toBe(first.gate)
|
||||
})
|
||||
|
||||
it('drops a prior chat baseline when the stream identity changes', () => {
|
||||
// The other chat's tail must not license showing a bubble here — a swapped
|
||||
// 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, { scopeKey: 'tab-a' }).gate
|
||||
|
||||
const switched = deriveMobileNativeChatStreaming(gate, repeatedId, 'new answer', {
|
||||
scopeKey: 'tab-b'
|
||||
})
|
||||
|
||||
expect(switched.streaming).toBeNull()
|
||||
expect(switched.gate.scopeKey).toBe('tab-b')
|
||||
expect(switched.gate.baselineTailId).toBeNull()
|
||||
})
|
||||
|
||||
it('returns null for empty or whitespace streaming text', () => {
|
||||
const prior = [assistant('a1', 'x')]
|
||||
expect(run([{ folded: prior, text: ' ' }]).results).toEqual([null])
|
||||
expect(run([{ folded: prior }]).results).toEqual([null])
|
||||
})
|
||||
|
||||
it('shows the first reply of an empty chat and hides it once the turn lands', () => {
|
||||
const landed = [assistant('a1', 'Hello there')]
|
||||
const { results } = run([
|
||||
{ folded: [] },
|
||||
{ folded: [], text: 'Hello' },
|
||||
{ folded: landed, text: 'Hello' }
|
||||
])
|
||||
expect(results).toEqual([null, 'Hello', null])
|
||||
})
|
||||
})
|
||||
@@ -0,0 +1,93 @@
|
||||
import type { NativeChatMessage } from '../../../src/shared/native-chat-types'
|
||||
|
||||
/** Decides whether the live streaming preview should render as a synthetic
|
||||
* bubble. Text alone can't tell "the transcript caught up with this stream"
|
||||
* from "a new reply happens to repeat the previous turn's prefix" — the old
|
||||
* prefix test swallowed genuine repeated-prefix replies. The gate keeps the
|
||||
* transcript tail observed when the current stream segment began: the bubble
|
||||
* hides only when the tail MOVED during the segment and leads with the
|
||||
* streamed text (the real turn landed), never for an older identical turn. */
|
||||
export type MobileNativeChatStreamingGate = {
|
||||
/** Chat/session identity this baseline belongs to. */
|
||||
scopeKey: string | null
|
||||
/** Streamed text seen on the previous tick ('' while idle). */
|
||||
prevText: string
|
||||
/** Folded tail message id when the current segment began; null while the
|
||||
* gate has never observed a transcript tail (text arrived on its very first
|
||||
* tick), where the legacy suppress-on-prefix rule applies. */
|
||||
baselineTailId: string | null
|
||||
}
|
||||
|
||||
export function createMobileNativeChatStreamingGate(
|
||||
scopeKey: string | null = null
|
||||
): MobileNativeChatStreamingGate {
|
||||
return { scopeKey, prevText: '', baselineTailId: null }
|
||||
}
|
||||
|
||||
function assistantTailText(tail: NativeChatMessage | undefined): string {
|
||||
if (!tail || tail.role !== 'assistant') {
|
||||
return ''
|
||||
}
|
||||
return tail.blocks
|
||||
.filter((block) => block.type === 'text')
|
||||
.map((block) => (block.type === 'text' ? block.text : ''))
|
||||
.join('')
|
||||
.trim()
|
||||
}
|
||||
|
||||
// Reuses the incoming gate object when nothing moved, so a caller can detect
|
||||
// "no change" by reference (and a render-time state adjustment can settle).
|
||||
function advanceGate(
|
||||
gate: MobileNativeChatStreamingGate,
|
||||
prevText: string,
|
||||
baselineTailId: string | null
|
||||
): MobileNativeChatStreamingGate {
|
||||
return gate.prevText === prevText && gate.baselineTailId === baselineTailId
|
||||
? gate
|
||||
: { ...gate, prevText, baselineTailId }
|
||||
}
|
||||
|
||||
/** Advance the gate one tick and derive the visible streaming text (null hides
|
||||
* the bubble). Pure and idempotent for a repeated (text, tail) pair, so a
|
||||
* re-render without new data cannot flip the decision. */
|
||||
export function deriveMobileNativeChatStreaming(
|
||||
gate: MobileNativeChatStreamingGate,
|
||||
folded: readonly NativeChatMessage[],
|
||||
streamingText: string | undefined,
|
||||
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) {
|
||||
// Only a textless tick that carries a real tail and is outside a live turn
|
||||
// is trustworthy pre-stream history. Mid-turn gaps (a tool call, a throttle
|
||||
// lull, the transcript landing the reply before its status text) would
|
||||
// otherwise adopt that reply as history and render it a second time as a
|
||||
// bubble; a torn-down transcript carries no tail at all. The exception is a
|
||||
// gate that has never anchored — mounted mid-turn, the first real tail it
|
||||
// sees is the best pre-stream history it will ever get.
|
||||
const canAnchor = tailId !== null && (!options.streamLive || scopedGate.baselineTailId === null)
|
||||
return { gate: canAnchor ? advanceGate(scopedGate, '', tailId) : scopedGate, streaming: null }
|
||||
}
|
||||
// A stream that is not an extension of the previous tick is a new segment
|
||||
// (next reply part); re-anchor to the tail that predates it.
|
||||
const segmentStart = scopedGate.prevText !== '' && !text.startsWith(scopedGate.prevText)
|
||||
const baselineTailId = segmentStart ? tailId : scopedGate.baselineTailId
|
||||
const tailLeadsWithStream = assistantTailText(tail).startsWith(text)
|
||||
// A null baseline (text on the very first tick, no tail ever seen) 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
|
||||
}
|
||||
}
|
||||
@@ -47,8 +47,12 @@ vi.mock('./use-mobile-native-chat-drafts', () => ({
|
||||
vi.mock('./use-mobile-native-chat-prompts', () => ({
|
||||
useMobileNativeChatPrompts: () => ({ permission: null, question: null, ask: null })
|
||||
}))
|
||||
const answerSendArgs: { streamIdentity?: string }[] = []
|
||||
vi.mock('./use-mobile-native-chat-answer-send', () => ({
|
||||
useMobileNativeChatAnswerSend: () => ({ answerAsk: vi.fn(), cancelPending: vi.fn() })
|
||||
useMobileNativeChatAnswerSend: (args: { streamIdentity?: string }) => {
|
||||
answerSendArgs.push(args)
|
||||
return { answerAsk: vi.fn(), cancelPending: vi.fn() }
|
||||
}
|
||||
}))
|
||||
vi.mock('./mobile-native-chat-permission-send', () => ({
|
||||
useMobileNativeChatPermissionSend: () => vi.fn()
|
||||
@@ -421,3 +425,99 @@ 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)
|
||||
})
|
||||
|
||||
it('keeps the delayed-send route guard view-gated, unlike the stream scope', () => {
|
||||
// `streamIdentity` fences a delayed answer/stop to the route it was armed
|
||||
// on, so it must still drop its session when chat closes — the scope key is
|
||||
// the one that has to survive the toggle. Same string while chat is open.
|
||||
const before = answerSendArgs.at(-1)?.streamIdentity
|
||||
expect(before).toBe(controller?.nativeChatStreamScopeKey)
|
||||
|
||||
viewMode.isTabChatView = () => false
|
||||
act(() => renderer?.update(createElement(Harness)))
|
||||
|
||||
const after = answerSendArgs.at(-1)?.streamIdentity
|
||||
expect(after).not.toBe(before)
|
||||
expect(after).not.toContain('session-1')
|
||||
expect(controller?.nativeChatStreamScopeKey).toBe(before)
|
||||
})
|
||||
})
|
||||
|
||||
@@ -49,6 +49,10 @@ export type MobileNativeChatController = {
|
||||
nativeChatSession: ReturnType<typeof useMobileNativeChatSession>
|
||||
nativeChatAgentWorking: boolean
|
||||
nativeChatStreamingText?: 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>
|
||||
@@ -124,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,
|
||||
@@ -160,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(
|
||||
@@ -273,6 +285,8 @@ export function useMobileNativeChatController(args: {
|
||||
nativeChatSession,
|
||||
nativeChatAgentWorking,
|
||||
nativeChatStreamingText,
|
||||
nativeChatStreamLive,
|
||||
nativeChatStreamScopeKey: streamScopeKey,
|
||||
nativeChatPermission,
|
||||
nativeChatQuestion,
|
||||
nativeChatAsk,
|
||||
|
||||
@@ -0,0 +1,31 @@
|
||||
import { useState } from 'react'
|
||||
import type { NativeChatMessage } from '../../../src/shared/native-chat-types'
|
||||
import {
|
||||
createMobileNativeChatStreamingGate,
|
||||
deriveMobileNativeChatStreaming
|
||||
} from './mobile-native-chat-streaming-gate'
|
||||
|
||||
/** 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. 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,
|
||||
streamLive: boolean
|
||||
): string | null {
|
||||
const [gate, setGate] = useState(() => createMobileNativeChatStreamingGate(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.
|
||||
setGate(step.gate)
|
||||
}
|
||||
return step.streaming
|
||||
}
|
||||
Reference in New Issue
Block a user