Files
orca/mobile/src/session/mobile-native-chat-render-data.ts
T
Brennan Benson 6a8431df62 fix(native-chat): stop the spinner on a not-yet-flushed transcript
A brand-new agent session can take minutes to write its first JSONL line,
and one that is never prompted never writes it at all. The host emitted no
stream frame until the file resolved, so every native-chat client sat on a
bare spinner with the composer enabled but the transcript blank -- forever,
in the never-prompted case.

The resolve poll now reports the transcript as pending after a short grace,
and both host handlers emit a `pending: true` snapshot. It is deliberately
not a plain empty snapshot: an empty window sold as a settled read would
capture over retained history and unblock consumers that require a
trustworthy transcript (the launch-draft adoption would re-offer a prompt
the agent may already have taken).

Clients render it as the "start a chat" empty state while keeping the read
unsettled -- `awaiting-transcript` on mobile, an `awaiting` read phase on
desktop, which also stops the seed loop expiring into an error card for a
session that is simply new. New optional field only, so older clients
ignore it and still stop spinning.
2026-08-25 13:16:33 -07:00

116 lines
4.4 KiB
TypeScript

import { formatAgentTypeLabel } from '../../../src/shared/agent-type-label'
import {
formatNativeChatEmptyStateCopy,
type NativeChatEmptyStateCopy
} from '../../../src/shared/native-chat-empty-state'
import { stripNoiseMessages } from '../../../src/shared/native-chat-noise'
import { foldToolMessages } from '../../../src/shared/native-chat-tool-fold'
import { isImageRefBlock, type NativeChatMessage } from '../../../src/shared/native-chat-types'
import { normalizeImageTranscriptMessages } from './mobile-native-chat-image-transcript-markers'
import type { MobileNativeChatStatus } from './use-mobile-native-chat-session'
/** The centered empty-state copy for a chat with no messages, mirroring the
* desktop `NativeChatEmptyState` (shared copy + agent label) so the two surfaces
* stay in lockstep. Returns null when the list should stay bare (idle, or the
* loading spinner owns the view). */
export function mobileNativeChatEmptyState(
status: MobileNativeChatStatus,
agent: string | null,
error?: string
): NativeChatEmptyStateCopy | null {
const agentLabel = agent ? formatAgentTypeLabel(agent) : 'the agent'
switch (status) {
// A live agent with no transcript yet — an unwritten transcript file, or a
// loaded-but-empty one — is "start a chat"; invite the first message instead
// of implying the agent is still starting up.
case 'waiting-session':
case 'awaiting-transcript':
case 'ready':
return formatNativeChatEmptyStateCopy('empty', agentLabel)
case 'error': {
const copy = formatNativeChatEmptyStateCopy('error', agentLabel)
return error ? { ...copy, subtitle: error } : copy
}
default:
return null
}
}
/** An optimistic user echo: the text and/or the local preview URIs of any images
* ridden along on the send, shown until the transcript catches up. */
export type MobileNativeChatPendingItem = {
id: string
text: string
images?: string[]
}
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 accepted optimistic messages at the tail. */
export function buildMobileNativeChatTransientData({
folded,
streaming,
pending,
imagePreviewsByMessageId
}: {
folded: NativeChatMessage[]
/** Streaming bubble text, already gated by `deriveMobileNativeChatStreaming`. */
streaming: string | null
pending: MobileNativeChatPendingItem[]
imagePreviewsByMessageId?: Record<string, string[]>
}): { folded: NativeChatMessage[]; streaming: string | null; data: NativeChatMessage[] } {
const renderedFolded = folded.map((message) => {
const previews = imagePreviewsByMessageId?.[message.id]
if (message.role !== 'user' || !previews?.length) {
return message
}
let previewIndex = 0
const blocks = message.blocks.map((block) => {
if (!isImageRefBlock(block)) {
return block
}
const url = previews[previewIndex]
previewIndex += 1
return url ? { ...block, url } : block
})
while (previewIndex < previews.length) {
blocks.push({ type: 'image-ref', url: previews[previewIndex] })
previewIndex += 1
}
return { ...message, blocks }
})
const data: NativeChatMessage[] = [
...renderedFolded,
...(streaming
? [
{
id: 'streaming',
role: 'assistant' as const,
blocks: [{ type: 'text' as const, text: streaming }],
timestamp: null,
source: 'hook' as const
}
]
: []),
...pending.map((p) => ({
id: p.id,
role: 'user' as const,
// Text first (when present), then a thumbnail per ridden-along image so the
// sent photo shows immediately, before the transcript echo lands.
blocks: [
...(p.text ? [{ type: 'text' as const, text: p.text }] : []),
...(p.images ?? []).map((uri) => ({ type: 'image-ref' as const, url: uri }))
],
timestamp: null,
source: 'transcript' as const
}))
]
return { folded: renderedFolded, streaming, data }
}