mirror of
https://github.com/stablyai/orca.git
synced 2026-10-20 08:03:28 +00:00
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.
This commit is contained in:
@@ -49,6 +49,7 @@ export function MobileNativeChatOverlay({
|
||||
agent={controller.nativeChatAgent}
|
||||
agentWorking={controller.nativeChatAgentWorking}
|
||||
streamingText={controller.nativeChatStreamingText}
|
||||
streamIdentity={controller.nativeChatStreamIdentity}
|
||||
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']
|
||||
streamingText?: string
|
||||
streamIdentity?: string
|
||||
sendErrorMessage?: string | null
|
||||
onClearSendError?: () => void
|
||||
inputLockReason?: 'disconnected' | 'waiting' | null
|
||||
@@ -75,6 +79,23 @@ function suppressRendererWarning(): () => void {
|
||||
return () => spy.mockRestore()
|
||||
}
|
||||
|
||||
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: [],
|
||||
status: 'ready',
|
||||
streamIdentity: 'test-stream',
|
||||
onSend: vi.fn().mockResolvedValue(true),
|
||||
pending: [],
|
||||
composerText: '',
|
||||
onComposerTextChange: vi.fn(),
|
||||
...overrides
|
||||
})
|
||||
}
|
||||
|
||||
describe('MobileNativeChatView send-error banner', () => {
|
||||
let renderer: ReactTestRenderer | null = null
|
||||
|
||||
@@ -91,23 +112,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 +184,37 @@ 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 })
|
||||
expect(listIds()).toEqual(['a1'])
|
||||
|
||||
await update({ messages: prior, streamingText: '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'])
|
||||
})
|
||||
})
|
||||
|
||||
@@ -21,6 +21,7 @@ import {
|
||||
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'
|
||||
@@ -50,6 +51,8 @@ type Props = {
|
||||
/** 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
|
||||
hasMore?: boolean
|
||||
loadingEarlier?: boolean
|
||||
onLoadEarlier?: () => void
|
||||
@@ -108,6 +111,7 @@ export function MobileNativeChatView({
|
||||
agentWorking,
|
||||
onStop,
|
||||
streamingText,
|
||||
streamIdentity,
|
||||
hasMore,
|
||||
loadingEarlier,
|
||||
onLoadEarlier,
|
||||
@@ -166,9 +170,14 @@ export function MobileNativeChatView({
|
||||
// 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, streamingText, pending }),
|
||||
[foldedMessages, streamingText, pending]
|
||||
() => buildMobileNativeChatTransientData({ folded: foldedMessages, streaming, pending }),
|
||||
[foldedMessages, streaming, pending]
|
||||
)
|
||||
|
||||
// Follow the tail as the conversation grows and keep the newest message above
|
||||
|
||||
@@ -7,6 +7,10 @@ import type { NativeChatMessage } from '../../../src/shared/native-chat-types'
|
||||
import { foldToolMessages } from './mobile-native-chat-blocks'
|
||||
import { normalizeImageTranscriptMessages } from './mobile-native-chat-image-transcript-markers'
|
||||
import { stripNoiseMessages } from './mobile-native-chat-noise'
|
||||
import {
|
||||
createMobileNativeChatStreamingGate,
|
||||
deriveMobileNativeChatStreaming
|
||||
} from './mobile-native-chat-streaming-gate'
|
||||
import type { MobileNativeChatStatus } from './use-mobile-native-chat-session'
|
||||
|
||||
/** The centered empty-state copy for a chat with no messages, mirroring the
|
||||
@@ -46,7 +50,9 @@ export type MobileNativeChatPendingItem = {
|
||||
/** 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. */
|
||||
* intermediate `folded`/`streaming` so the caller can memoize on them.
|
||||
* One-shot form: derives the streaming bubble through a fresh gate (no
|
||||
* cross-tick memory); the live view threads its own gate instead. */
|
||||
export function buildMobileNativeChatData({
|
||||
messages,
|
||||
streamingText,
|
||||
@@ -57,7 +63,12 @@ export function buildMobileNativeChatData({
|
||||
pending: MobileNativeChatPendingItem[]
|
||||
}): { folded: NativeChatMessage[]; streaming: string | null; data: NativeChatMessage[] } {
|
||||
const folded = foldMobileNativeChatMessages(messages)
|
||||
return buildMobileNativeChatTransientData({ folded, streamingText, pending })
|
||||
const { streaming } = deriveMobileNativeChatStreaming(
|
||||
createMobileNativeChatStreamingGate(),
|
||||
folded,
|
||||
streamingText
|
||||
)
|
||||
return buildMobileNativeChatTransientData({ folded, streaming, pending })
|
||||
}
|
||||
|
||||
export function foldMobileNativeChatMessages(messages: NativeChatMessage[]): NativeChatMessage[] {
|
||||
@@ -68,16 +79,14 @@ export function foldMobileNativeChatMessages(messages: NativeChatMessage[]): Nat
|
||||
|
||||
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 +115,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,138 @@
|
||||
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 }[]): {
|
||||
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)
|
||||
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('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 mounted mid-stream', () => {
|
||||
// No idle tick ever observed: 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, 'tab-a').gate
|
||||
|
||||
const switched = deriveMobileNativeChatStreaming(gate, repeatedId, 'new answer', '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,82 @@
|
||||
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 an idle tick (mounted mid-stream), 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,
|
||||
scopeKey: string | null = gate.scopeKey
|
||||
): { gate: MobileNativeChatStreamingGate; streaming: string | null } {
|
||||
const scopedGate =
|
||||
gate.scopeKey === scopeKey ? gate : createMobileNativeChatStreamingGate(scopeKey)
|
||||
const text = streamingText?.trim() ?? ''
|
||||
const tail = folded.at(-1)
|
||||
const tailId = tail?.id ?? null
|
||||
if (!text) {
|
||||
// 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 }
|
||||
}
|
||||
// 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)
|
||||
// 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)
|
||||
return {
|
||||
gate: advanceGate(scopedGate, text, baselineTailId),
|
||||
streaming: caughtUp ? null : text
|
||||
}
|
||||
}
|
||||
@@ -49,6 +49,8 @@ export type MobileNativeChatController = {
|
||||
nativeChatSession: ReturnType<typeof useMobileNativeChatSession>
|
||||
nativeChatAgentWorking: boolean
|
||||
nativeChatStreamingText?: string
|
||||
/** Host/workspace/tab/session identity for stateful streaming suppression. */
|
||||
nativeChatStreamIdentity: string
|
||||
nativeChatPermission: ReturnType<typeof detectAgentPermission>
|
||||
nativeChatQuestion: ReturnType<typeof parseAgentQuestion>
|
||||
nativeChatAsk: ReturnType<typeof parseAskFromStatus>
|
||||
@@ -273,6 +275,7 @@ export function useMobileNativeChatController(args: {
|
||||
nativeChatSession,
|
||||
nativeChatAgentWorking,
|
||||
nativeChatStreamingText,
|
||||
nativeChatStreamIdentity: streamIdentity,
|
||||
nativeChatPermission,
|
||||
nativeChatQuestion,
|
||||
nativeChatAsk,
|
||||
|
||||
@@ -0,0 +1,24 @@
|
||||
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. */
|
||||
export function useMobileNativeChatStreamingBubble(
|
||||
folded: readonly NativeChatMessage[],
|
||||
streamingText: string | undefined,
|
||||
scopeKey: string
|
||||
): string | null {
|
||||
const [gate, setGate] = useState(() => createMobileNativeChatStreamingGate(scopeKey))
|
||||
const step = deriveMobileNativeChatStreaming(gate, folded, streamingText, scopeKey)
|
||||
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