mirror of
https://github.com/stablyai/orca.git
synced 2026-09-30 08:03:12 +00:00
* feat(mobile): port the restructured native-chat turn status and live tool progress Mobile chat had a single static "Agent is working" row and no live tool activity, while the desktop restructure (#17597, #18705) replaced that with a per-turn status row and a running-tool label. This brings mobile to parity and puts the derivation in one place instead of two. Shared (new, pure, RN-safe — desktop uses them as i18n fallbacks, mobile directly, matching the native-chat-empty-state pattern): - `native-chat-turn-status.ts`: duration formatting, label selection, the turn-timing state machine, and the active/settled split. - `native-chat-tool-activity.ts`: command-tool classification, the running-tool label descriptor, and running-call selection. Desktop now consumes both; `NativeChatWorkingStatus`, `NativeChatToolRun` and `use-native-chat-turn-status` keep their existing behavior and strings. Mobile gains the "Thinking" / "Working for 12s" / "Worked for 3m 4s" row with a caret that discloses the turn's tool activity, the pulsing "Running npm test" row with terminal-vs-wrench glyphs, and desktop's rule that a completed turn's tool run hides behind the turn caret. The bridge lane is untouched and keeps its three-dot indicator. Headings, quotes, code, lists and table cells are now selectable. Files at their max-lines cap were split rather than bumped: the tool-run subtree, the prompt card, the session-lane wiring, and the turn-disclosure state each move to their own module. * perf(mobile): stop the turn-status rows from re-rendering the whole transcript A streaming turn re-renders the chat list many times a second. The disclosure wiring handed every row a fresh status object and a fresh toggle closure on each of those renders, so `MobileNativeChatMessage`'s memo never held and every visible row re-rendered per tick — including settled turns that had not changed. Memoize the status selection on the timing map, and keep one stable toggle handler per turn (pruned when a turn leaves the transcript) attached only to the settled rows that can actually disclose anything. Now only the live turn's row changes identity while the agent works. * fix(mobile): keep the turn clock running when the optimistic echo is replaced An accepted send renders as `pending-N` until the transcript echo lands under its real message id. That flips the active turn key mid-turn, and the timing reducer treated the new key as a new turn — so a turn that had reached "Working for 8s" visibly restarted at "Working for 0s". The reducer now carries the start over when the previous key names a turn that has since left the transcript, which is exactly the echo-replacement case. A genuinely new turn (the previous key still in the transcript) and a turn that had already settled both keep their own clock; both are pinned by tests. Desktop does not pass the new key and is unaffected. * fix(mobile): keep the Tools toggle working on settled turns Hiding a settled turn's tool run behind the turn caret (desktop parity) also made the composer's global Tools control a no-op on every completed turn: the run it wanted to expand was not rendered at all. Let that toggle override the hiding, so it still reveals every run at once the way it did before. * fix(mobile): re-key the turn timing instead of only carrying its start The previous fix carried the start forward only while the turn was still working. When the transcript echo landed after the turn had already settled, the new key inherited nothing, the settled timing was pruned with the old key, and the turn's "Worked for N" row disappeared entirely. Move the timing onto the new key instead, which covers both orderings: an in-flight turn keeps counting from its original start (and later settles against it), and an already-settled turn keeps its duration. Both orderings are pinned. * test(mobile): pin the structured turn-status wiring at the view level Emulator QA could not reach the structured lane (mobile's Create Tab -> Codex falls back to a terminal tab when agentSession.createSupport says unsupported), so the view's own lane wiring had no coverage — the one seam between the shared turn-timing reducer and the rendered rows. Assert what the view hands each row: the live user turn gets a status object and the three-dot indicator is gone on the structured lane; the bridge lane keeps the indicator and gets no status; a finished turn settles to a numeric duration with a toggle; and an assistant row never carries a status row of its own. * fix(mobile): isolate structured chat turn state * fix(mobile): let the capability RPC actually store what a phone advertises `runtime.clientCapabilities.update` records the advertised set by assigning `authenticatedSocket.clientCapabilities`, but the socket handed to the dispatcher defined that property with a getter only. In strict mode the assignment throws `TypeError: Cannot set property clientCapabilities ... which has only a getter`, so the RPC answered `runtime_error` and the set was never stored. The consequence is not subtle: `supportsStructuredAgentSessions` requires the capability, so `projectSessionTabAgentStatus` removed every `agent-session` tab from a phone that had advertised it correctly. A paired phone saw ZERO tabs on a worktree whose only tab was a structured Codex chat — structured native chat was unreachable on mobile over this transport, not just missing its new turn UI. Give the socket a setter that writes through to the channel, which already owns the set for the connection's lifetime, so later requests on the same socket see it. Found while trying to capture emulator screenshots of the turn-status port: two full QA runs reported the new UI "missing" because the phone could only ever get a bridge/PTY tab. * fix(mobile): carry the turn key instead of caching a handler in a ref Builds on the scope-isolation fix: that kept (and extended) a ref that is written during render — once to memoize a per-turn handler, once to prune dead turns, once to reset on a scope change. React Doctor's "Ref mutated during render" is what CI's `check:react-doctor:changed` was failing on (x2), and on mobile it is a real hazard rather than a style note: react-freeze discards renders, and a discarded render would leave the cache mutated. Pass the settled turn's key down the row instead and let it call one stable handler with it. That preserves both properties the cache was bought for — per scope isolation, and identity stability so a streaming transcript does not defeat the row's memo — with no ref writes and no pruning to get wrong. The scope-keyed expanded set and the 128-turn cap are untouched; their tests move to the new contract and one now pins handler identity across a re-render. Note for future changes here: `check:code-quality:changed` does NOT cover this. CI additionally runs the standalone react-doctor CLI, which has rules the oxlint plugin config does not enable. * fix: ship native chat status translations * test(native-chat): pin the shared copy against the English catalog The shared constants are desktop's i18n fallback and mobile's actually-rendered string. If one changes without the other, desktop keeps rendering en.json while mobile renders the constant — and nothing fails, because a fallback is only used when the key is missing. That silent divergence is the exact thing the shared module exists to prevent, and it is now reachable precisely because these strings are runtime-required rather than statically extracted. Assert every key in both shared copy objects matches en.json byte for byte, plus the interpolation placeholders the catalog interpolates on. --------- Co-authored-by: Merge Sim <sim@local>
245 lines
8.4 KiB
TypeScript
245 lines
8.4 KiB
TypeScript
import { memo, useEffect, useRef, useState } from 'react'
|
|
import { Image, Pressable, Text, View } from 'react-native'
|
|
import * as Clipboard from 'expo-clipboard'
|
|
import { ArrowUp, Copy } from 'lucide-react-native'
|
|
import { splitNativeChatBlocks } from '../../../src/shared/native-chat-tool-fold'
|
|
import { selectActiveToolCall } from '../../../src/shared/native-chat-tool-activity'
|
|
import { isImageRefBlock, isTextBlock } from '../../../src/shared/native-chat-types'
|
|
import type { NativeChatBlock, NativeChatMessage } from '../../../src/shared/native-chat-types'
|
|
import { MobileMarkdown } from '../components/MobileMarkdown'
|
|
import { MobileNativeChatTurnStatus } from './MobileNativeChatTurnStatus'
|
|
import { ToolRun } from './MobileNativeChatToolRun'
|
|
import type { NativeChatTurnStatus } from './use-mobile-native-chat-turn-status'
|
|
import { colors } from '../theme/mobile-theme'
|
|
import { isRenderableImageUri } from './mobile-native-chat-image-preview'
|
|
import { styles, TEXT_SIZE } from './mobile-native-chat-message-styles'
|
|
import { nativeChatMessageText } from './mobile-native-chat-message-text'
|
|
|
|
function Prose({
|
|
block,
|
|
invert,
|
|
fontScale,
|
|
onOpenFile
|
|
}: {
|
|
block: NativeChatBlock
|
|
invert?: boolean
|
|
fontScale: number
|
|
onOpenFile?: (relativePath: string) => void
|
|
}): React.JSX.Element | null {
|
|
if (isTextBlock(block)) {
|
|
// Inverted (user) bubbles use a fixed dark-on-light text rather than the
|
|
// markdown renderer's light-on-dark palette.
|
|
if (invert) {
|
|
return (
|
|
<Text selectable style={[styles.userText, { fontSize: TEXT_SIZE * fontScale }]}>
|
|
{block.text}
|
|
</Text>
|
|
)
|
|
}
|
|
return (
|
|
<MobileMarkdown content={block.text} textScale={1.25 * fontScale} onOpenFile={onOpenFile} />
|
|
)
|
|
}
|
|
if (isImageRefBlock(block)) {
|
|
// A local preview (composer echo) or real URL renders as a thumbnail; a bare
|
|
// host path (not loadable on the device) falls back to a text placeholder.
|
|
const uri = block.url ?? block.path
|
|
if (isRenderableImageUri(uri)) {
|
|
return (
|
|
<Image
|
|
source={{ uri }}
|
|
style={styles.imageThumb}
|
|
resizeMode="contain"
|
|
accessibilityLabel={block.alt ?? 'Attached image'}
|
|
/>
|
|
)
|
|
}
|
|
return (
|
|
<Text style={[styles.imageRef, { fontSize: TEXT_SIZE * fontScale }]}>
|
|
🖼 {block.alt ?? block.path ?? block.url ?? 'image'}
|
|
</Text>
|
|
)
|
|
}
|
|
return null
|
|
}
|
|
|
|
/** Subtle top-right controls for an agent message: copy its prose, or scroll so
|
|
* this message's top aligns to the top of the viewport. */
|
|
function AgentControls({
|
|
onCopy,
|
|
onScrollToTop
|
|
}: {
|
|
onCopy: () => void
|
|
onScrollToTop?: () => void
|
|
}): React.JSX.Element {
|
|
return (
|
|
<View style={styles.controls}>
|
|
<Pressable
|
|
style={({ pressed }) => [styles.controlButton, pressed && styles.controlPressed]}
|
|
onPress={onCopy}
|
|
hitSlop={8}
|
|
accessibilityLabel="Copy message"
|
|
>
|
|
<Copy size={14} color={colors.textMuted} strokeWidth={2} />
|
|
</Pressable>
|
|
{onScrollToTop ? (
|
|
<Pressable
|
|
style={({ pressed }) => [styles.controlButton, pressed && styles.controlPressed]}
|
|
onPress={onScrollToTop}
|
|
hitSlop={8}
|
|
accessibilityLabel="Scroll this message to top"
|
|
>
|
|
<ArrowUp size={14} color={colors.textMuted} strokeWidth={2} />
|
|
</Pressable>
|
|
) : null}
|
|
</View>
|
|
)
|
|
}
|
|
|
|
function MobileNativeChatMessageImpl({
|
|
message,
|
|
toolsExpanded = false,
|
|
fontScale = 1,
|
|
messageIndex,
|
|
onScrollToMessage,
|
|
onOpenFile,
|
|
turnStatus,
|
|
turnExpanded,
|
|
turnKey,
|
|
onToggleTurn,
|
|
activeTurnIsWorking,
|
|
structuredActivityUi = false
|
|
}: {
|
|
message: NativeChatMessage
|
|
toolsExpanded?: boolean
|
|
/** Multiplies all chat text sizes for pinch-to-zoom (1 = no change). */
|
|
fontScale?: number
|
|
/** This message's index in the list, paired with onScrollToMessage. */
|
|
messageIndex?: number
|
|
/** Ask the list to align this message's top to the top of the viewport. */
|
|
onScrollToMessage?: (index: number) => void
|
|
onOpenFile?: (relativePath: string) => void
|
|
/** This turn's status row, rendered under a user message (desktop parity). */
|
|
turnStatus?: NativeChatTurnStatus | null
|
|
/** Whether the turn caret has disclosed this turn's activity. */
|
|
turnExpanded?: boolean
|
|
/** Set only when this row's turn has settled and can disclose its activity. */
|
|
turnKey?: string
|
|
/** Stable across renders; the row supplies its own key when tapped. */
|
|
onToggleTurn?: (turnKey: string) => void
|
|
/** Session-level working state for this message's turn; gates the live tool row. */
|
|
activeTurnIsWorking?: boolean
|
|
/** Structured lane only: live tool progress plus the turn-status disclosure. */
|
|
structuredActivityUi?: boolean
|
|
}): React.JSX.Element {
|
|
const isUser = message.role === 'user'
|
|
const isReasoning = message.role === 'reasoning'
|
|
const isAgent = !isUser
|
|
// Briefly tint the bubble to confirm a copy landed.
|
|
const [copied, setCopied] = useState(false)
|
|
const copyTimer = useRef<ReturnType<typeof setTimeout> | null>(null)
|
|
useEffect(
|
|
() => () => {
|
|
if (copyTimer.current) {
|
|
clearTimeout(copyTimer.current)
|
|
}
|
|
},
|
|
[]
|
|
)
|
|
// Separate the agent's words from its tool activity: prose renders first, the
|
|
// tool calls fold into a collapsible run beneath. The user's own messages get
|
|
// an inverted (filled accent) bubble so they stand apart from agent prose.
|
|
const { prose, tools } = splitNativeChatBlocks(message.blocks)
|
|
const activeCall = structuredActivityUi
|
|
? selectActiveToolCall(tools, { activeTurnIsWorking })
|
|
: null
|
|
// A completed turn's activity belongs behind the turn-status caret. Leaving the
|
|
// grouped row visible made a failed child command read as a failed response.
|
|
// The composer's global Tools toggle still overrides this, or it would silently
|
|
// do nothing on every settled turn.
|
|
const settledToolsHidden =
|
|
structuredActivityUi &&
|
|
activeCall == null &&
|
|
activeTurnIsWorking === false &&
|
|
!turnExpanded &&
|
|
!toolsExpanded
|
|
const showToolRun = tools.length > 0 && !settledToolsHidden
|
|
|
|
const handleCopy = (): void => {
|
|
const text = nativeChatMessageText(message.blocks)
|
|
if (!text) {
|
|
return
|
|
}
|
|
void Clipboard.setStringAsync(text)
|
|
setCopied(true)
|
|
if (copyTimer.current) {
|
|
clearTimeout(copyTimer.current)
|
|
}
|
|
copyTimer.current = setTimeout(() => setCopied(false), 700)
|
|
}
|
|
|
|
// Copy + scroll-to-top, shown inline with the first tool call (or after the
|
|
// prose when there are no tools).
|
|
const controls = isAgent ? (
|
|
<AgentControls
|
|
onCopy={handleCopy}
|
|
onScrollToTop={
|
|
onScrollToMessage && messageIndex !== undefined
|
|
? () => onScrollToMessage(messageIndex)
|
|
: undefined
|
|
}
|
|
/>
|
|
) : null
|
|
|
|
return (
|
|
<>
|
|
<View style={[styles.row, isUser && styles.rowUser]}>
|
|
<View
|
|
style={[
|
|
styles.content,
|
|
isUser && styles.userBubble,
|
|
isReasoning && styles.reasoning,
|
|
copied && styles.copied
|
|
]}
|
|
>
|
|
{prose.map((block, index) => (
|
|
<Prose
|
|
key={index}
|
|
block={block}
|
|
invert={isUser}
|
|
fontScale={fontScale}
|
|
onOpenFile={onOpenFile}
|
|
/>
|
|
))}
|
|
{showToolRun ? (
|
|
<ToolRun
|
|
// Why: a global toggle intentionally resets all per-run/per-line
|
|
// overrides in one remount, avoiding an effect-driven second render.
|
|
key={`${toolsExpanded ? 'expanded' : 'collapsed'}:${turnExpanded ? 'turn' : 'flat'}`}
|
|
blocks={tools}
|
|
defaultExpanded={turnExpanded || toolsExpanded}
|
|
expandChildren={turnExpanded ? false : toolsExpanded}
|
|
activeCall={activeCall}
|
|
trailing={controls}
|
|
onOpenFile={onOpenFile}
|
|
/>
|
|
) : controls ? (
|
|
<View style={styles.controlsRow}>{controls}</View>
|
|
) : null}
|
|
</View>
|
|
</View>
|
|
{turnStatus ? (
|
|
<MobileNativeChatTurnStatus
|
|
startedAt={turnStatus.startedAt}
|
|
thinking={turnStatus.thinking}
|
|
workedSeconds={turnStatus.workedSeconds}
|
|
expanded={turnExpanded ?? false}
|
|
onToggleExpanded={turnKey && onToggleTurn ? () => onToggleTurn(turnKey) : undefined}
|
|
/>
|
|
) : null}
|
|
</>
|
|
)
|
|
}
|
|
|
|
export const MobileNativeChatMessage = memo(MobileNativeChatMessageImpl)
|