diff --git a/docs/mobile-chat/9b-timeline/05-pr-roadmap.md b/docs/mobile-chat/9b-timeline/05-pr-roadmap.md index 4331264ffc..5f515e47bb 100644 --- a/docs/mobile-chat/9b-timeline/05-pr-roadmap.md +++ b/docs/mobile-chat/9b-timeline/05-pr-roadmap.md @@ -198,6 +198,12 @@ that changes the timeline on screen. - **Feature-flag state:** N/A — **the timeline goes live.** - **Tests on merge:** RN Testing Library — mocked reasoning packet stream renders the streaming header → step → auto-collapse → tap-expand → Done; Stopped path. **HARD device gate (owner):** dev build, reasoning model — streaming shimmer + live timer, auto-collapse on answer, tap-to-expand, Done step, hydrated render; migrated answer + 9a Sources intact; streaming re-render stays smooth (perf-isolation from `04`). - **Drift checkpoint:** Re-confirm the `unify-chat-input` `ChatSurface` composition (post-PR6 refactor) is where `MessageRow`/`AgentTimeline` mount, and that per-conversation draft/reset rules don't interfere with the per-node timeline state. +- **As built (2026-08-03, "parallel tabs pulled forward" — owner-gated before coding):** Four `AskUserQuestion` gates. The big one: the owner checked whether parallel tabs were planned anywhere under `docs/mobile-chat/` — they were not (only the unscheduled "After 9b" line below) — and ruled **build them now**, so **`ParallelTimelineTabs` and `ParallelStreamingHeader` ship LIVE, not dormant**, and the linearize divergence (`03` §8(d)) is **retired**. That required a new mobile primitive: **`components/ui/tabs.tsx`**, an RN port of Opal's `pill` `Tabs` (Root context + `List` + `Trigger`), which the owner approved over hand-rolling a lookalike. Other gates: port `stop-circle` 1:1 from Opal (the 9b.5 icon precedent); **fold `usePacketDisplay` into `usePacketProcessor`** (expose `processed`, delete the hook + its test) rather than reduce every packet twice per flush; ship it all as one PR. New: `components/chat/timeline/{ExpandedTimelineContent,CollapsedStreamingContent,TimelineStep,TimelineStepComposer,ParallelTimelineTabs,ShimmerText}.tsx` + `toolIcons.ts`, `timeline/headers/{Streaming,Completed,Stopped,ParallelStreaming}Header.tsx`, `timeline/primitives/TimelineTopSpacer.tsx`, `components/ui/tabs.tsx`, and 6 1:1 Opal icon ports (`stop-circle`, `branch`, `user`, `code`, `book-open`, `slow-time`). Modified: `AgentTimeline.tsx` (stub → full shell), `MessageRow.tsx` (→ the AgentMessage analog), `hooks/timeline/usePacketProcessor.ts` (+`processed`), `chat/interfaces.ts` + `chat/chatHistory.ts` (+`streamingStartedAt`, +`processingDurationSeconds`), `hooks/useChatController.ts` + `hooks/useChatSessionController.ts` (the two stream-start stamps), `lib/time.ts` (+`formatDurationSeconds`, ported from Opal `time.ts`). **~1480 LOC** — roughly double the 500–700 band, which the owner accepted when folding tabs in. +- **Divergences from web (9b.7's own, on top of `03` §8):** (a) **no hover anywhere** — web's `headerIsInteractive` only toggled a hover class, so it has no mobile analog and each header owns its own `Pressable` instead; (b) `useTimelineStepState` is **not called** by the shell (its only web consumers are the memory tag and that hover flag) and `CompletedHeader` **drops web's memory tag/tooltip/`MemoriesModal`** — the memory renderer is a later phase; (c) shimmer = an **opacity pulse** (`ShimmerText`, RN `Animated` so the timeline stays jest-renderable), not web's `background-clip:text` gradient; (d) the expanded body has **no entrance animation** (web's `animate-in fade-in slide-in-from-top-2`) — a 300 ms flourish isn't worth putting reanimated back on the test path; (e) `streamingStartTime` is a **per-node prop**, not web's per-session store read, and a **resumed** run restarts the timer from the resume (this client never observed the run's true start; web shows no timer at all there); (f) the ported `Tabs` ships the **`pill` variant only** (no `contained`/`underline` — no consumer) and **drops the scroll arrows** (a touch list is swiped, not arrow-stepped); (g) `TimelineStep` is split into its own module — web keeps it private inside `ExpandedTimelineContent`, which on mobile would cycle `ExpandedTimelineContent → ParallelTimelineTabs → TimelineStep`; (h) `getToolIcon` uses Onyx glyphs where web falls back to `react-icons` against its own icon rule (`FiTool` → `cpu`, matching `chat/tools.ts`'s custom-tool fallback; `FiList` → `text-lines-small`). +- **Also fixed here:** hydrated turns carried no duration at all, so **every** reopened chat's header would have read "Thought for some time" — `processing_duration_seconds` (already on the backend response, `models.py:232`) is now mapped through `chatHistory`. +- **Adversarial review (4 lenses × 3 refute-first skeptics) — 8 raised, 6 fixed:** **(1) HIGH — the Stopped path was unreachable in production.** A user stop aborts the reader *before* the backend's `stop` packet arrives, so `stopPacketSeen` never flipped: the turn stayed in its STREAMING ui-state (and `useStreamingDuration`'s 250 ms interval kept ticking) until the session was reloaded. Fixed by synthesizing a `USER_CANCELLED` stop packet in `runChatStream`'s `finally` on abort (`buildUserCancelledStopPacket`); the resume path already self-heals, since it re-hydrates from a snapshot the backend replays an `OverallStop` into. **(2) MEDIUM — FlashList recycles React keys**: `RenderStackManager.syncItem` hands a pooled key to a different `stableId`, so a scrolled-away turn's component state (timeline expansion, active branch tab, open sources sheet) was inherited by an unrelated message — and since `userHasToggled` latches, that turn could never auto-collapse. `keyExtractor` only supplies the stableId, not the React key; fixed by keying `MessageRow` on `nodeId` inside `renderItem`. **(3) LOW ×3** — the `chatState` memo's count proxies could pin a stale map on mobile (web gets away with it by mutating in place), the Tabs baseline painted over the sliding indicator, and Opal's `pt-1` belongs to its no-scroll-arrows branch. **(4)** 5 files weren't prettier-formatted. Accepted as-is: the `CitedSources` gate keeps 9a's `processed.isComplete` (a briefly-early Sources bar during answer retraction is not worth changing shipped 9a semantics here). +- **Verified:** `tsc` clean, lint clean (2 pre-existing `_layout` warnings), prettier clean, **596 jest pass** across 76 suites (+40 new: the mandated composition smoke test — reasoning stream → "Thinking" step → auto-collapse to "Thought for Xs · 1 step" → tap-expand → Done; USER_CANCELLED → Interrupted Thinking → Stopped; parallel turn → a tab per branch in both the streaming header and the expanded body, incl. switching branches — plus the `Tabs` primitive, `StreamingHeader` timer/freeze under fake timers, `getToolIcon`, `formatDurationSeconds`, the `chatHistory` duration mapping, the `streamingStartedAt` stamp, and the synthetic-stop-on-abort regression). +- **HARD DEVICE GATE STILL OWED (owner-run):** a dev build against a reasoning model — streaming shimmer + live timer, auto-collapse when the answer starts, tap-to-expand, the Done step, a hydrated (reopened) render showing a real "Thought for Xs", the migrated answer + 9a Sources intact, and that streaming re-render stays smooth. Parallel tabs additionally need a **multi-branch run** to see the pill strip and the sliding indicator on device. ## After 9b — the follow-up renderer phases (not designed here) diff --git a/mobile/src/chat/__tests__/chatHistory.test.ts b/mobile/src/chat/__tests__/chatHistory.test.ts index 636ef179e0..24f4f9c911 100644 --- a/mobile/src/chat/__tests__/chatHistory.test.ts +++ b/mobile/src/chat/__tests__/chatHistory.test.ts @@ -145,4 +145,30 @@ describe("processRawChatHistory", () => { ); expect(tree.get(1)!.packets).toEqual([]); }); + + it("carries processing_duration_seconds through so the timeline can label it", () => { + const tree = processRawChatHistory( + [ + bm({ + message_id: 1, + message_type: "assistant", + parent_message: null, + processing_duration_seconds: 7.5, + }), + bm({ message_id: 2, message_type: "assistant", parent_message: 1 }), + ], + [], + ); + expect(tree.get(1)!.processingDurationSeconds).toBe(7.5); + // Absent on older rows; the header then falls back to "Thought for some time". + expect(tree.get(2)!.processingDurationSeconds).toBeUndefined(); + }); + + it("leaves streamingStartedAt unset on hydrated turns", () => { + const tree = processRawChatHistory( + [bm({ message_id: 1, message_type: "assistant", parent_message: null })], + [], + ); + expect(tree.get(1)!.streamingStartedAt).toBeUndefined(); + }); }); diff --git a/mobile/src/chat/chatHistory.ts b/mobile/src/chat/chatHistory.ts index 300a2a8243..1d73fb200f 100644 --- a/mobile/src/chat/chatHistory.ts +++ b/mobile/src/chat/chatHistory.ts @@ -33,6 +33,9 @@ export function processRawChatHistory( childrenNodeIds: [], latestChildNodeId: messageInfo.latest_child_message, packets: packetsForMessage || [], + // A hydrated turn has no start time to measure from, so without this every reopened turn's + // header reads "Thought for some time". + processingDurationSeconds: messageInfo.processing_duration_seconds, }; messages.set(messageInfo.message_id, message); diff --git a/mobile/src/chat/interfaces.ts b/mobile/src/chat/interfaces.ts index e93f5ff38f..2bb7f9b13f 100644 --- a/mobile/src/chat/interfaces.ts +++ b/mobile/src/chat/interfaces.ts @@ -33,6 +33,11 @@ export interface Message { packets: Packet[]; // Backend StreamingError.error_code on an errored turn; titles the error box. errorCode?: string | null; + // Epoch ms the stream started, for the timeline's elapsed timer. Only set while streaming; a + // hydrated turn has none and falls back to `processingDurationSeconds`. + streamingStartedAt?: number; + // Backend `processing_duration_seconds`; becomes the header's "Thought for Xs". + processingDurationSeconds?: number | null; } // Subset of a loaded session-snapshot row (GET get-chat-session). @@ -45,6 +50,7 @@ export interface BackendMessage { files: FileDescriptor[]; time_sent: string; error: string | null; + processing_duration_seconds?: number | null; } // `packets` is indexed by assistant-message ordinal. diff --git a/mobile/src/chat/streamingModels.ts b/mobile/src/chat/streamingModels.ts index 12d2d10df3..6aa5a072a0 100644 --- a/mobile/src/chat/streamingModels.ts +++ b/mobile/src/chat/streamingModels.ts @@ -466,3 +466,14 @@ export interface StreamingError { is_retryable?: boolean; details?: Record | null; } + +// A user stop aborts the reader before the backend's own `stop` packet can arrive, so without this +// the turn keeps looking like it is streaming — live timer included — until a reload, where the +// backend replays an OverallStop. +export function buildUserCancelledStopPacket(packets: Packet[]): Packet { + const lastPacket = packets[packets.length - 1]; + return { + placement: { turn_index: lastPacket?.placement.turn_index ?? 0 }, + obj: { type: PacketType.STOP, stop_reason: StopReason.USER_CANCELLED }, + }; +} diff --git a/mobile/src/components/chat/AgentTimeline.tsx b/mobile/src/components/chat/AgentTimeline.tsx index f9027f67f9..8eca3f253f 100644 --- a/mobile/src/components/chat/AgentTimeline.tsx +++ b/mobile/src/components/chat/AgentTimeline.tsx @@ -1,131 +1,367 @@ -// Port of web's AgentTimeline (web/src/app/app/message/messageComponents/timeline): the agent -// avatar in a 36px rail plus a status header, sitting above the answer text. -import { useEffect } from "react"; +// Port of web `timeline/AgentTimeline.tsx`. Divergences: web's `headerIsInteractive` is gone, since +// it only drove a hover class and each header owns its own press target here; `useTimelineStepState` +// goes uncalled, because its only web consumers are that flag and the memory tag, which lands with +// the memory renderer; `streamingStartTime` is a prop, since mobile stamps it per assistant node +// rather than once per session; and the expanded body has no entrance animation. +import { memo, useMemo } from "react"; import { View } from "react-native"; -import Animated, { - Easing, - useAnimatedStyle, - useSharedValue, - withRepeat, - withTiming, -} from "react-native-reanimated"; +import type { StopReason } from "@/chat/streamingModels"; +import { + isSearchToolPackets, + stepHasCollapsedStreamingContent, + stepSupportsCollapsedStreaming, +} from "@/chat/timeline/packetHelpers"; +import type { TurnGroup } from "@/chat/timeline/transformers"; import { AgentAvatar } from "@/components/avatars/AgentAvatar"; -import { Icon } from "@/components/ui/icon"; -import { Text } from "@/components/ui/text"; -import { MinimalAgent } from "@/chat/agents"; +import { + RenderType, + type FullChatState, +} from "@/components/chat/renderers/timelineContract"; +import { CollapsedStreamingContent } from "@/components/chat/timeline/CollapsedStreamingContent"; +import { ExpandedTimelineContent } from "@/components/chat/timeline/ExpandedTimelineContent"; +import { CompletedHeader } from "@/components/chat/timeline/headers/CompletedHeader"; +import { ParallelStreamingHeader } from "@/components/chat/timeline/headers/ParallelStreamingHeader"; +import { StoppedHeader } from "@/components/chat/timeline/headers/StoppedHeader"; +import { StreamingHeader } from "@/components/chat/timeline/headers/StreamingHeader"; +import { ShimmerText } from "@/components/chat/timeline/ShimmerText"; +import { timelineTokens } from "@/components/chat/timeline/primitives/timelineTokens"; +import { TimelineHeaderRow } from "@/components/chat/timeline/primitives/TimelineHeaderRow"; +import { TimelineRoot } from "@/components/chat/timeline/primitives/TimelineRoot"; +import { useTimelineExpansion } from "@/hooks/timeline/useTimelineExpansion"; +import { useTimelineHeader } from "@/hooks/timeline/useTimelineHeader"; +import { useTimelineMetrics } from "@/hooks/timeline/useTimelineMetrics"; +import { + TimelineUIState, + useTimelineUIState, +} from "@/hooks/timeline/useTimelineUIState"; import { cn } from "@/lib/utils"; -import type { IconFunctionComponent } from "@/icons/types"; -// Mirrors web's --timeline-rail-width (36px); on mobile w-36 = 36px. -const RAIL = "w-36"; const AVATAR_SIZE = 24; -export type TimelineStepStatus = "running" | "done" | "error"; - -export interface TimelineStepData { - key: string; - label: string; - status: TimelineStepStatus; - icon?: IconFunctionComponent; +export interface AgentTimelineProps { + turnGroups: TurnGroup[]; + chatState: FullChatState; + stopPacketSeen?: boolean; + stopReason?: StopReason; + // Gates the trailing connector and the Done step. + finalAnswerComing?: boolean; + hasDisplayContent?: boolean; + collapsible?: boolean; + buttonTitle?: string; + // Backend duration for a hydrated (already finished) turn. + processingDurationSeconds?: number; + isGeneratingImage?: boolean; + generatedImageCount?: number; + // Backend `pre_answer_processing_seconds`; freezes the live timer once present. + toolProcessingDuration?: number; + // Epoch ms this node's stream started. Absent on history, where the timer falls back to the + // backend duration. + streamingStartTime?: number; } -interface AgentTimelineProps { - agent: MinimalAgent | null; - // EMPTY state (web): run started but no answer content yet → shimmer "Thinking…". - isLoading: boolean; - // Empty until the mobile stream parses reasoning/tool packets (seam). - steps?: TimelineStepData[]; -} - -export function AgentTimeline({ +function TimelineContainer({ agent, - isLoading, - steps = [], -}: AgentTimelineProps) { - return ( - - - - {agent ? : null} - - {isLoading ? : null} - - - {steps.map((step, index) => ( - - ))} - - ); -} - -// Approximates web's .shimmer-text gradient sweep with an opacity pulse (no masked-gradient dep). -function ThinkingLabel() { - const opacity = useSharedValue(0.5); - useEffect(() => { - opacity.value = withRepeat( - withTiming(1, { duration: 900, easing: Easing.inOut(Easing.ease) }), - -1, - true, - ); - }, [opacity]); - const animatedStyle = useAnimatedStyle(() => ({ opacity: opacity.value })); - return ( - - - Thinking… - - - ); -} - -function TimelineStep({ - step, - isFirst, - isLast, + headerContent, + children, }: { - step: TimelineStepData; - isFirst: boolean; - isLast: boolean; + agent: FullChatState["agent"]; + headerContent?: React.ReactNode; + children?: React.ReactNode; }) { - const isError = step.status === "error"; return ( - - - - {step.icon ? ( - - ) : ( - - )} - - - - - {step.label} - - - + + : null} + > + {headerContent} + + {children} + ); } + +function areAgentTimelinePropsEqual( + prev: AgentTimelineProps, + next: AgentTimelineProps, +): boolean { + return ( + prev.turnGroups === next.turnGroups && + prev.stopPacketSeen === next.stopPacketSeen && + prev.stopReason === next.stopReason && + prev.finalAnswerComing === next.finalAnswerComing && + prev.hasDisplayContent === next.hasDisplayContent && + prev.processingDurationSeconds === next.processingDurationSeconds && + prev.collapsible === next.collapsible && + prev.buttonTitle === next.buttonTitle && + prev.chatState === next.chatState && + prev.isGeneratingImage === next.isGeneratingImage && + prev.generatedImageCount === next.generatedImageCount && + prev.toolProcessingDuration === next.toolProcessingDuration && + prev.streamingStartTime === next.streamingStartTime + ); +} + +export const AgentTimeline = memo(function AgentTimeline({ + turnGroups, + chatState, + stopPacketSeen = false, + stopReason, + finalAnswerComing = false, + hasDisplayContent = false, + collapsible = true, + buttonTitle, + processingDurationSeconds, + isGeneratingImage = false, + generatedImageCount = 0, + toolProcessingDuration, + streamingStartTime, +}: AgentTimelineProps) { + const { headerText, hasPackets, userStopped } = useTimelineHeader( + turnGroups, + stopReason, + isGeneratingImage, + ); + + const { + totalSteps, + isSingleStep, + lastTurnGroup, + lastStep, + lastStepIsResearchAgent, + lastStepIsCodingAgent, + lastStepSupportsCollapsedStreaming, + } = useTimelineMetrics(turnGroups, userStopped); + + const lastStepIsSearchTool = useMemo( + () => (lastStep ? isSearchToolPackets(lastStep.packets) : false), + [lastStep], + ); + + const { isExpanded, handleToggle, parallelActiveTab, setParallelActiveTab } = + useTimelineExpansion(stopPacketSeen, lastTurnGroup, hasDisplayContent); + + const parallelActiveStep = useMemo(() => { + if (!lastTurnGroup?.isParallel) return undefined; + return ( + lastTurnGroup.steps.find((step) => step.key === parallelActiveTab) ?? + lastTurnGroup.steps[0] + ); + }, [lastTurnGroup, parallelActiveTab]); + + const parallelActiveStepSupportsCollapsedStreaming = useMemo( + () => + parallelActiveStep + ? stepSupportsCollapsedStreaming(parallelActiveStep.packets) + : false, + [parallelActiveStep], + ); + + const lastStepHasCollapsedContent = useMemo( + () => + lastStep ? stepHasCollapsedStreamingContent(lastStep.packets) : false, + [lastStep], + ); + + const parallelActiveStepHasCollapsedContent = useMemo( + () => + parallelActiveStep + ? stepHasCollapsedStreamingContent(parallelActiveStep.packets) + : false, + [parallelActiveStep], + ); + + // A cancelled run only counts the steps that actually produced something. + const stoppedStepsCount = useMemo(() => { + if (!stopPacketSeen || !userStopped) return totalSteps; + let count = 0; + for (const turnGroup of turnGroups) { + for (const step of turnGroup.steps) { + if (stepHasCollapsedStreamingContent(step.packets)) count += 1; + } + } + return count; + }, [stopPacketSeen, userStopped, totalSteps, turnGroups]); + + const uiStateInput = useMemo( + () => ({ + stopPacketSeen, + hasPackets, + hasDisplayContent, + userStopped, + isExpanded, + lastTurnGroup, + lastStep, + lastStepSupportsCollapsedStreaming, + lastStepHasCollapsedContent, + lastStepIsResearchAgent, + parallelActiveStepSupportsCollapsedStreaming, + parallelActiveStepHasCollapsedContent, + isGeneratingImage, + finalAnswerComing, + }), + [ + stopPacketSeen, + hasPackets, + hasDisplayContent, + userStopped, + isExpanded, + lastTurnGroup, + lastStep, + lastStepSupportsCollapsedStreaming, + lastStepHasCollapsedContent, + lastStepIsResearchAgent, + parallelActiveStepSupportsCollapsedStreaming, + parallelActiveStepHasCollapsedContent, + isGeneratingImage, + finalAnswerComing, + ], + ); + + const { + uiState, + showCollapsedCompact, + showCollapsedParallel, + showParallelTabs, + showDoneStep, + showStoppedStep, + hasDoneIndicator, + showTintedBackground, + showRoundedBottom, + } = useTimelineUIState(uiStateInput); + + const collapsedRenderTypeOverride = useMemo(() => { + if (lastStepIsResearchAgent) return RenderType.HIGHLIGHT; + if (lastStepIsCodingAgent) return RenderType.HIGHLIGHT; + if (lastStepIsSearchTool) return RenderType.INLINE; + return RenderType.COMPACT; + }, [lastStepIsResearchAgent, lastStepIsCodingAgent, lastStepIsSearchTool]); + + if (uiState === TimelineUIState.EMPTY) { + return ( + + {headerText} + + } + /> + ); + } + + if (uiState === TimelineUIState.DISPLAY_CONTENT_ONLY) { + return ; + } + + // Expanded, a parallel turn reads as a sequential run, so it drops back to the normal header. + const showParallelHeader = showParallelTabs && lastTurnGroup !== undefined; + const isStreamingState = + uiState === TimelineUIState.STREAMING_PARALLEL || + uiState === TimelineUIState.STREAMING_SEQUENTIAL; + + let header: React.ReactNode = null; + if (isStreamingState && showParallelHeader) { + header = ( + + ); + } else if (isStreamingState) { + header = ( + + ); + } else if (uiState === TimelineUIState.STOPPED) { + header = ( + + ); + } else { + header = ( + + ); + } + + return ( + + {header} + + } + > + {showCollapsedCompact && lastStep ? ( + + ) : null} + + {showCollapsedParallel && parallelActiveStep ? ( + + ) : null} + + {isExpanded ? ( + + ) : null} + + ); +}, areAgentTimelinePropsEqual); + +export default AgentTimeline; diff --git a/mobile/src/components/chat/MessageList.tsx b/mobile/src/components/chat/MessageList.tsx index a8f3e64399..b5bb3f9924 100644 --- a/mobile/src/components/chat/MessageList.tsx +++ b/mobile/src/components/chat/MessageList.tsx @@ -49,8 +49,14 @@ export function MessageList({ messages, agent }: MessageListProps) { contentSignature: contentSignature(messages), }); + // `key` is load-bearing. FlashList pools its ViewHolder React keys and re-hands them to different + // items, so an unkeyed row inherits a scrolled-away turn's state — timeline expansion, active + // branch tab, open sources sheet. `keyExtractor` doesn't help: it supplies the stable id, not the + // React key. const renderItem = useCallback( - ({ item }: { item: Message }) => , + ({ item }: { item: Message }) => ( + + ), [agent], ); diff --git a/mobile/src/components/chat/MessageRow.tsx b/mobile/src/components/chat/MessageRow.tsx index 6d39c2c454..e24404b9d8 100644 --- a/mobile/src/components/chat/MessageRow.tsx +++ b/mobile/src/components/chat/MessageRow.tsx @@ -4,6 +4,7 @@ import { View } from "react-native"; import { selectSources } from "@/chat/citations"; import { Message } from "@/chat/interfaces"; +import { StopReason } from "@/chat/streamingModels"; import { resolveGroupReasoning } from "@/chat/timeline/reasoningState"; import { MinimalAgent } from "@/chat/agents"; import { getErrorTitle } from "@/chat/errorHelpers"; @@ -21,9 +22,10 @@ import { ReasoningTextSheet } from "@/components/chat/timeline/ReasoningTextShee import { Icon } from "@/components/ui/icon"; import { Text } from "@/components/ui/text"; import SvgAlertCircle from "@/icons/alert-circle"; -import { usePacketDisplay } from "@/hooks/usePacketDisplay"; +import { usePacedTurnGroups } from "@/hooks/timeline/usePacedTurnGroups"; +import { usePacketProcessor } from "@/hooks/timeline/usePacketProcessor"; -// The renderer's onComplete drives the timeline pacing gate (9b.7); nothing consumes it in PR4. +// Non-final display groups don't complete the message. const NOOP = (): void => {}; function UserMessage({ node }: { node: Message }) { @@ -80,16 +82,38 @@ function AssistantMessage({ node: Message; agent: MinimalAgent | null; }) { - const { packets, processed, hasDisplayContent } = usePacketDisplay(node); + const { + processed, + toolTurnGroups, + displayGroups, + hasSteps, + stopPacketSeen, + stopReason, + isGeneratingImage, + generatedImageCount, + finalAnswerComing, + toolProcessingDuration, + onRenderComplete, + } = usePacketProcessor(node.packets, node.nodeId); + + // Withholds the answer until every step has been revealed. + const { pacedTurnGroups, pacedDisplayGroups, pacedFinalAnswerComing } = + usePacedTurnGroups( + toolTurnGroups, + displayGroups, + stopPacketSeen, + node.nodeId, + finalAnswerComing, + ); + const [sourcesOpen, setSourcesOpen] = useState(false); // Which step's text the full-text reader is showing. Owned here, not in the step: the timeline // auto-collapses when the answer starts, which would unmount the step mid-read. const [fullTextKey, setFullTextKey] = useState(null); - const hasContent = hasDisplayContent && packets.length > 0; + const hasDisplayContent = pacedDisplayGroups.length > 0; - // Re-derived every flush so the reader stays live while the step streams on — parking the string - // at open time would freeze the body and make Copy yield a truncated prefix. Reasoning is the only - // renderer using the reader today; other long-form steps would resolve their own group here. + // Re-derived every flush so the reader keeps up with a step that is still streaming; parking the + // string at open time would freeze the body and make Copy yield a truncated prefix. const fullText = useMemo( () => resolveGroupReasoning(processed.groupedPacketsMap, fullTextKey), [fullTextKey, processed], @@ -101,8 +125,9 @@ function AssistantMessage({ [processed], ); - // Context a renderer may read. This identity churns each flush, but RendererComponent's memo keys on - // agent.id, not this object, so that's harmless. + // Keyed on the maps, not on web's `citations.length` proxy: web gets away with a count because it + // mutates one map in place, but mobile rebuilds state each flush, so a count would pin a stale map + // and a re-cited document would resolve against old metadata. const chatState = useMemo( () => ({ agent, @@ -114,33 +139,57 @@ function AssistantMessage({ [agent, processed.citationMap, processed.documentMap], ); - // Web AgentMessage: the timeline (above) owns the loader; answer and sources sit below. + // The timeline owns the loading state, so there's no separate spinner here. return ( - {hasContent ? ( + {hasDisplayContent ? ( // px-12 aligns the answer under the avatar rail (web's px-3). + + {pacedDisplayGroups.map((displayGroup, groupIndex) => ( + 0 || hasSteps} + // Only the last group completes the message. + onComplete={ + groupIndex === pacedDisplayGroups.length - 1 + ? onRenderComplete + : NOOP + } + animate={!stopPacketSeen} + stopPacketSeen={stopPacketSeen} + stopReason={stopReason} + > + {(results) => ( + <> + {results.map((result, index) => ( + {result.content} + ))} + + )} + + ))} + + ) : stopReason === StopReason.USER_CANCELLED ? ( - - {(results) => ( - <> - {results.map((result, index) => ( - {result.content} - ))} - - )} - + + User has stopped generation + ) : null} {sources && sources.hasSources ? ( @@ -188,6 +237,9 @@ export const MessageRow = memo( prev.node.message === next.node.message && prev.node.messageId === next.node.messageId && prev.node.errorCode === next.node.errorCode && + prev.node.streamingStartedAt === next.node.streamingStartedAt && + prev.node.processingDurationSeconds === + next.node.processingDurationSeconds && prev.node.packets.length === next.node.packets.length && prev.node.files === next.node.files && prev.agent === next.agent, diff --git a/mobile/src/components/chat/__tests__/AgentTimeline.test.tsx b/mobile/src/components/chat/__tests__/AgentTimeline.test.tsx new file mode 100644 index 0000000000..42c9446a5c --- /dev/null +++ b/mobile/src/components/chat/__tests__/AgentTimeline.test.tsx @@ -0,0 +1,265 @@ +import { describe, expect, it, jest } from "@jest/globals"; +import { fireEvent, render, screen } from "@testing-library/react-native"; + +import { makePacket, makePlacedPacket } from "@/chat/__tests__/fixtures"; +import { createInitialState, processPackets } from "@/chat/messageProcessor"; +import { StopReason, type Packet } from "@/chat/streamingModels"; +import { + groupStepsByTurn, + transformPacketGroups, +} from "@/chat/timeline/transformers"; +import { AgentTimeline } from "@/components/chat/AgentTimeline"; +import type { FullChatState } from "@/components/chat/renderers/timelineContract"; + +// StreamingMarkdown pulls a native markdown module; render its text so bodies stay assertable. +jest.mock("@/components/chat/StreamingMarkdown", () => { + const { Text } = jest.requireActual( + "@/components/ui/text", + ); + return { + StreamingMarkdown: ({ content }: { content: string }) => ( + {content} + ), + }; +}); + +// Buttons navigate via expo-router's `router`; stub it so the import resolves under jest. +jest.mock("expo-router", () => ({ router: { navigate: jest.fn() } })); + +// The avatar reaches MMKV through its bearer-image path, which has no native binary under jest. +// These tests pass `agent: null`, so it never renders anyway. +jest.mock("@/components/avatars/AgentAvatar", () => ({ + AgentAvatar: () => null, +})); + +const chatState: FullChatState = { agent: null }; + +const reasoningStart = (turn = 0) => + makePacket({ type: "reasoning_start" }, turn); +const reasoningDelta = (reasoning: string, turn = 0) => + makePacket({ type: "reasoning_delta", reasoning }, turn); +const sectionEnd = (turn = 0) => makePacket({ type: "section_end" }, turn); +const overallStop = (stopReason?: StopReason) => + makePacket({ type: "stop", stop_reason: stopReason }); + +// Mirrors what MessageRow feeds the timeline. +function turnGroupsFor(packets: Packet[]) { + const state = processPackets(createInitialState(1), packets); + return { + turnGroups: groupStepsByTurn(transformPacketGroups(state.toolGroups)), + state, + }; +} + +function renderTimeline(packets: Packet[], overrides = {}) { + const { turnGroups, state } = turnGroupsFor(packets); + const result = render( + , + ); + return { ...result, turnGroups, state }; +} + +describe("AgentTimeline", () => { + it("shows the shimmering EMPTY header before any packet arrives", () => { + renderTimeline([]); + expect(screen.getByText("Thinking...")).toBeTruthy(); + }); + + it("streams a reasoning step under a live 'Thinking' header", () => { + renderTimeline([reasoningStart(), reasoningDelta("Weighing the options")]); + + expect(screen.getByText("Thinking")).toBeTruthy(); + expect(screen.getByText("Weighing the options")).toBeTruthy(); + }); + + it("collapses to 'Thought for Xs' with a step count once the answer arrives", () => { + const { turnGroups } = turnGroupsFor([ + reasoningStart(), + reasoningDelta("done thinking"), + sectionEnd(), + overallStop(), + ]); + + render( + , + ); + + expect(screen.getByText("Thought for 4s")).toBeTruthy(); + expect(screen.getByText("1 step")).toBeTruthy(); + expect(screen.queryByText("done thinking")).toBeNull(); + }); + + it("falls back to 'Thought for some time' with no backend duration", () => { + const { turnGroups } = turnGroupsFor([ + reasoningStart(), + sectionEnd(), + overallStop(), + ]); + render( + , + ); + expect(screen.getByText("Thought for some time")).toBeTruthy(); + }); + + it("expands the step list when the completed header is tapped", () => { + const { turnGroups } = turnGroupsFor([ + reasoningStart(), + reasoningDelta("the hidden reasoning"), + sectionEnd(), + overallStop(), + ]); + render( + , + ); + + expect(screen.queryByText("the hidden reasoning")).toBeNull(); + fireEvent.press(screen.getByText("1 step")); + expect(screen.getByText("the hidden reasoning")).toBeTruthy(); + expect(screen.getByText("Done")).toBeTruthy(); + }); + + it("pluralizes the step count across turns", () => { + const { turnGroups } = turnGroupsFor([ + reasoningStart(0), + sectionEnd(0), + reasoningStart(1), + sectionEnd(1), + overallStop(), + ]); + render( + , + ); + expect(screen.getByText("2 steps")).toBeTruthy(); + }); + + it("shows the Stopped header and terminal row after a user cancel", () => { + const { turnGroups, state } = turnGroupsFor([ + reasoningStart(), + reasoningDelta("partial thought"), + overallStop(StopReason.USER_CANCELLED), + ]); + expect(state.stopReason).toBe(StopReason.USER_CANCELLED); + + render( + , + ); + + // Collapsed by default; the terminal Stopped row only exists in the expanded body. + expect(screen.getByText("Interrupted Thinking")).toBeTruthy(); + expect(screen.queryByText("Stopped")).toBeNull(); + + fireEvent.press(screen.getByText("1 step")); + expect(screen.getByText("Stopped")).toBeTruthy(); + expect(screen.getByText("partial thought")).toBeTruthy(); + }); + + it("renders only the avatar row when there is an answer but no steps", () => { + render( + , + ); + expect(screen.queryByText("Thinking...")).toBeNull(); + expect(screen.queryByText("Thought for some time")).toBeNull(); + }); + + describe("parallel turns", () => { + const parallelPackets = [ + makePlacedPacket({ type: "reasoning_start" }, { tab_index: 0 }), + makePlacedPacket( + { type: "reasoning_delta", reasoning: "branch one thinking" }, + { tab_index: 0 }, + ), + makePlacedPacket( + { type: "search_tool_start", is_internet_search: true }, + { tab_index: 1 }, + ), + ]; + + it("is detected as a parallel turn group", () => { + const { turnGroups } = turnGroupsFor(parallelPackets); + expect(turnGroups[0]?.isParallel).toBe(true); + expect(turnGroups[0]?.steps).toHaveLength(2); + }); + + it("shows a pill tab per branch in the streaming header", () => { + const { turnGroups } = turnGroupsFor(parallelPackets); + render(); + + // "Thinking" also appears as the collapsed preview's status, so assert on the unique label. + expect(screen.getByText("Web Search")).toBeTruthy(); + expect(screen.getAllByText("Thinking").length).toBeGreaterThan(0); + }); + + it("shows the branch tab strip in the expanded body", () => { + const { turnGroups } = turnGroupsFor([...parallelPackets, overallStop()]); + render( + , + ); + + expect(screen.queryByText("Web Search")).toBeNull(); + fireEvent.press(screen.getByText("2 steps")); + + expect(screen.getByText("Web Search")).toBeTruthy(); + // The first branch is selected, so its reasoning body renders below the tabs. + expect(screen.getByText("branch one thinking")).toBeTruthy(); + }); + + it("swaps the rendered branch when another tab is selected", () => { + const { turnGroups } = turnGroupsFor([...parallelPackets, overallStop()]); + render( + , + ); + fireEvent.press(screen.getByText("2 steps")); + expect(screen.getByText("branch one thinking")).toBeTruthy(); + + fireEvent.press(screen.getByText("Web Search")); + // No search renderer is registered yet, so the branch body is empty rather than reasoning. + expect(screen.queryByText("branch one thinking")).toBeNull(); + }); + }); +}); diff --git a/mobile/src/components/chat/timeline/CollapsedStreamingContent.tsx b/mobile/src/components/chat/timeline/CollapsedStreamingContent.tsx new file mode 100644 index 0000000000..2504c82802 --- /dev/null +++ b/mobile/src/components/chat/timeline/CollapsedStreamingContent.tsx @@ -0,0 +1,64 @@ +// Live preview of the running step while collapsed. The rail is a spacer, not a rail: the preview +// aligns under the header but must not draw a connector, since it isn't a step in the list. +import { Fragment, memo, useCallback } from "react"; + +import type { TransformedStep } from "@/chat/timeline/transformers"; +import type { StopReason } from "@/chat/streamingModels"; +import { + RenderType, + type FullChatState, + type TimelineRendererResult, +} from "@/components/chat/renderers/timelineContract"; + +import { TimelineRendererComponent } from "./TimelineRendererComponent"; +import { TimelineRow } from "./primitives/TimelineRow"; +import { TimelineSurface } from "./primitives/TimelineSurface"; + +export interface CollapsedStreamingContentProps { + step: TransformedStep; + chatState: FullChatState; + stopReason?: StopReason; + renderTypeOverride?: RenderType; +} + +export const CollapsedStreamingContent = memo( + function CollapsedStreamingContent({ + step, + chatState, + stopReason, + renderTypeOverride, + }: CollapsedStreamingContentProps) { + const renderContentOnly = useCallback( + (results: TimelineRendererResult[]) => ( + <> + {results.map((result, index) => ( + {result.content} + ))} + + ), + [], + ); + + return ( + + + + {renderContentOnly} + + + + ); + }, +); + +export default CollapsedStreamingContent; diff --git a/mobile/src/components/chat/timeline/ExpandedTimelineContent.tsx b/mobile/src/components/chat/timeline/ExpandedTimelineContent.tsx new file mode 100644 index 0000000000..763113490f --- /dev/null +++ b/mobile/src/components/chat/timeline/ExpandedTimelineContent.tsx @@ -0,0 +1,99 @@ +import { memo } from "react"; +import { View } from "react-native"; + +import { isCodingAgentPackets } from "@/chat/timeline/packetHelpers"; +import type { TurnGroup } from "@/chat/timeline/transformers"; +import type { StopReason } from "@/chat/streamingModels"; +import type { FullChatState } from "@/components/chat/renderers/timelineContract"; +import SvgCheckCircle from "@/icons/check-circle"; +import SvgStopCircle from "@/icons/stop-circle"; + +import { ParallelTimelineTabs } from "./ParallelTimelineTabs"; +import { StepContainer } from "./StepContainer"; +import { TimelineStep } from "./TimelineStep"; + +export interface ExpandedTimelineContentProps { + turnGroups: TurnGroup[]; + chatState: FullChatState; + stopPacketSeen: boolean; + stopReason?: StopReason; + isSingleStep: boolean; + userStopped: boolean; + showDoneStep: boolean; + showStoppedStep: boolean; + hasDoneIndicator: boolean; +} + +export const ExpandedTimelineContent = memo(function ExpandedTimelineContent({ + turnGroups, + chatState, + stopPacketSeen, + stopReason, + isSingleStep, + userStopped, + showDoneStep, + showStoppedStep, + hasDoneIndicator, +}: ExpandedTimelineContentProps) { + return ( + + {turnGroups.map((turnGroup, turnIdx) => { + // Coding agents always take the tab chrome, so one looks the same as several side by side. + const renderAsParallelTabs = + turnGroup.isParallel || + turnGroup.steps.some((step) => isCodingAgentPackets(step.packets)); + + if (renderAsParallelTabs) { + return ( + + ); + } + + return turnGroup.steps.map((step, stepIdx) => ( + + )); + })} + + {showDoneStep ? ( + + {null} + + ) : null} + + {showStoppedStep ? ( + + {null} + + ) : null} + + ); +}); + +export default ExpandedTimelineContent; diff --git a/mobile/src/components/chat/timeline/ParallelTimelineTabs.tsx b/mobile/src/components/chat/timeline/ParallelTimelineTabs.tsx new file mode 100644 index 0000000000..c3b4ffc160 --- /dev/null +++ b/mobile/src/components/chat/timeline/ParallelTimelineTabs.tsx @@ -0,0 +1,160 @@ +import { useCallback, useMemo, useState } from "react"; +import { View } from "react-native"; + +import { getToolName, isToolComplete } from "@/chat/timeline/toolDisplay"; +import type { TurnGroup } from "@/chat/timeline/transformers"; +import type { StopReason } from "@/chat/streamingModels"; +import type { + FullChatState, + TimelineRendererResult, +} from "@/components/chat/renderers/timelineContract"; +import { Button } from "@/components/ui/button"; +import { Icon } from "@/components/ui/icon"; +import { Tabs } from "@/components/ui/tabs"; +import SvgBranch from "@/icons/branch"; +import SvgExpand from "@/icons/expand"; +import SvgFold from "@/icons/fold"; + +import { TimelineRendererComponent } from "./TimelineRendererComponent"; +import { TimelineStepComposer } from "./TimelineStepComposer"; +import { getToolIcon } from "./toolIcons"; +import { timelineTokens } from "./primitives/timelineTokens"; +import { TimelineRow } from "./primitives/TimelineRow"; +import { TimelineSurface } from "./primitives/TimelineSurface"; +import { TimelineTopSpacer } from "./primitives/TimelineTopSpacer"; + +export interface ParallelTimelineTabsProps { + turnGroup: TurnGroup; + chatState: FullChatState; + stopPacketSeen: boolean; + stopReason?: StopReason; + // Drives the trailing connector, on whichever row ends up last in the group. + isLastTurnGroup: boolean; + isFirstTurnGroup: boolean; +} + +export function ParallelTimelineTabs({ + turnGroup, + chatState, + stopPacketSeen, + stopReason, + isLastTurnGroup, + isFirstTurnGroup, +}: ParallelTimelineTabsProps) { + const [activeTab, setActiveTab] = useState(turnGroup.steps[0]?.key ?? ""); + const [isExpanded, setIsExpanded] = useState(true); + const handleToggle = useCallback(() => setIsExpanded((prev) => !prev), []); + + const topSpacerVariant = isFirstTurnGroup ? "first" : "none"; + // Folding a finished group leaves just the tabs; folding a running one still shows its output. + const shouldShowResults = !(!isExpanded && stopPacketSeen); + + const activeStep = useMemo( + () => turnGroup.steps.find((step) => step.key === activeTab), + [turnGroup.steps, activeTab], + ); + + const loadingStates = useMemo( + () => + new Map( + turnGroup.steps.map((step) => [ + step.key, + !stopPacketSeen && + step.packets.length > 0 && + !isToolComplete(step.packets), + ]), + ), + [turnGroup.steps, stopPacketSeen], + ); + + const renderTabContent = useCallback( + (results: TimelineRendererResult[]) => ( + + ), + [isLastTurnGroup], + ); + + const hasActivePackets = Boolean(activeStep && activeStep.packets.length > 0); + const headerIsLast = + isLastTurnGroup && (!shouldShowResults || !hasActivePackets); + + return ( + + + } + > + + + + + + } + > + {turnGroup.steps.map((step) => ( + + {getToolName(step.packets)} + + ))} + + + + + + + {shouldShowResults && activeStep ? ( + + {renderTabContent} + + ) : null} + + ); +} + +export default ParallelTimelineTabs; diff --git a/mobile/src/components/chat/timeline/ShimmerText.tsx b/mobile/src/components/chat/timeline/ShimmerText.tsx new file mode 100644 index 0000000000..c0a4823ba7 --- /dev/null +++ b/mobile/src/components/chat/timeline/ShimmerText.tsx @@ -0,0 +1,44 @@ +// Opacity pulse, not web's gradient sweep: that relies on `background-clip: text`, which RN has no +// equivalent for. RN `Animated`, not reanimated, so the timeline stays renderable under jest. +import { useEffect, useMemo } from "react"; +import { Animated, Easing } from "react-native"; + +import { Text } from "@/components/ui/text"; + +const PULSE_DURATION_MS = 900; +const PULSE_MIN_OPACITY = 0.5; + +export function ShimmerText({ children }: { children: string }) { + const opacity = useMemo(() => new Animated.Value(PULSE_MIN_OPACITY), []); + + useEffect(() => { + const animation = Animated.loop( + Animated.sequence([ + Animated.timing(opacity, { + toValue: 1, + duration: PULSE_DURATION_MS, + easing: Easing.inOut(Easing.ease), + useNativeDriver: true, + }), + Animated.timing(opacity, { + toValue: PULSE_MIN_OPACITY, + duration: PULSE_DURATION_MS, + easing: Easing.inOut(Easing.ease), + useNativeDriver: true, + }), + ]), + ); + animation.start(); + return () => animation.stop(); + }, [opacity]); + + return ( + + + {children} + + + ); +} + +export default ShimmerText; diff --git a/mobile/src/components/chat/timeline/TimelineStep.tsx b/mobile/src/components/chat/timeline/TimelineStep.tsx new file mode 100644 index 0000000000..c03bceaf81 --- /dev/null +++ b/mobile/src/components/chat/timeline/TimelineStep.tsx @@ -0,0 +1,86 @@ +// Its own module, unlike web (which keeps this private inside ExpandedTimelineContent): mobile's +// ParallelTimelineTabs also needs it, and importing it from there would cycle. +import { memo, useCallback, useMemo } from "react"; + +import { + isPythonToolPackets, + isSearchToolPackets, +} from "@/chat/timeline/packetHelpers"; +import type { TransformedStep } from "@/chat/timeline/transformers"; +import type { + FullChatState, + TimelineRendererResult, +} from "@/components/chat/renderers/timelineContract"; +import type { StopReason } from "@/chat/streamingModels"; +import type { IconFunctionComponent } from "@/icons/types"; + +import { TimelineRendererComponent } from "./TimelineRendererComponent"; +import { TimelineStepComposer } from "./TimelineStepComposer"; + +export interface TimelineStepProps { + step: TransformedStep; + chatState: FullChatState; + stopPacketSeen: boolean; + stopReason?: StopReason; + isLastStep: boolean; + isFirstStep: boolean; + isSingleStep: boolean; + isStreaming?: boolean; +} + +export const TimelineStep = memo(function TimelineStep({ + step, + chatState, + stopPacketSeen, + stopReason, + isLastStep, + isFirstStep, + isSingleStep, + isStreaming = false, +}: TimelineStepProps) { + const isSearchTool = useMemo( + () => isSearchToolPackets(step.packets), + [step.packets], + ); + const isPythonTool = useMemo( + () => isPythonToolPackets(step.packets), + [step.packets], + ); + + // Collapsed, a search step keeps its own glyph instead of the generic expand chevron. + const getCollapsedIcon = useCallback( + (result: TimelineRendererResult): IconFunctionComponent | undefined => + isSearchTool ? (result.icon ?? undefined) : undefined, + [isSearchTool], + ); + + const renderStep = useCallback( + (results: TimelineRendererResult[]) => ( + + ), + [isFirstStep, isLastStep, isSingleStep, getCollapsedIcon], + ); + + return ( + + {renderStep} + + ); +}); + +export default TimelineStep; diff --git a/mobile/src/components/chat/timeline/TimelineStepComposer.tsx b/mobile/src/components/chat/timeline/TimelineStepComposer.tsx new file mode 100644 index 0000000000..75b7ca87fd --- /dev/null +++ b/mobile/src/components/chat/timeline/TimelineStepComposer.tsx @@ -0,0 +1,64 @@ +import { Fragment } from "react"; + +import type { TimelineRendererResult } from "@/components/chat/renderers/timelineContract"; +import type { IconFunctionComponent } from "@/icons/types"; + +import { StepContainer } from "./StepContainer"; + +export interface TimelineStepComposerProps { + results: TimelineRendererResult[]; + // Drive the rail connectors. + isLastStep: boolean; + isFirstStep: boolean; + // A lone step hides its header, so the body reads as the timeline's only content. + isSingleStep?: boolean; + collapsible?: boolean; + getCollapsedIcon?: ( + result: TimelineRendererResult, + ) => IconFunctionComponent | undefined; +} + +export function TimelineStepComposer({ + results, + isLastStep, + isFirstStep, + isSingleStep = false, + collapsible = true, + getCollapsedIcon, +}: TimelineStepComposerProps) { + return ( + <> + {results.map((result, index) => + result.timelineLayout === "content" ? ( + {result.content} + ) : ( + + {result.content} + + ), + )} + + ); +} + +export default TimelineStepComposer; diff --git a/mobile/src/components/chat/timeline/__tests__/StreamingHeader.test.tsx b/mobile/src/components/chat/timeline/__tests__/StreamingHeader.test.tsx new file mode 100644 index 0000000000..07e9ac3c88 --- /dev/null +++ b/mobile/src/components/chat/timeline/__tests__/StreamingHeader.test.tsx @@ -0,0 +1,88 @@ +import { + afterEach, + beforeEach, + describe, + expect, + it, + jest, +} from "@jest/globals"; +import { act, render, screen } from "@testing-library/react-native"; + +import { StreamingHeader } from "@/components/chat/timeline/headers/StreamingHeader"; + +// Button navigates via expo-router's `router`; stub it so the import resolves under jest. +jest.mock("expo-router", () => ({ router: { navigate: jest.fn() } })); + +const START = 1_700_000_000_000; + +describe("StreamingHeader", () => { + beforeEach(() => { + jest.useFakeTimers(); + jest.setSystemTime(START); + }); + + afterEach(() => { + jest.useRealTimers(); + }); + + function renderHeader(overrides = {}) { + return render( + , + ); + } + + it("renders the activity label", () => { + renderHeader(); + expect(screen.getByText("Thinking")).toBeTruthy(); + }); + + it("hides the elapsed readout while collapsed", () => { + renderHeader({ streamingStartTime: START }); + act(() => { + jest.advanceTimersByTime(5_000); + }); + expect(screen.queryByText("5s")).toBeNull(); + }); + + it("counts up once expanded", () => { + renderHeader({ isExpanded: true, streamingStartTime: START }); + // Modern fake timers advance Date.now() too, so this alone moves the clock 5s. + act(() => { + jest.advanceTimersByTime(5_000); + }); + expect(screen.getByText("5s")).toBeTruthy(); + }); + + it("freezes on the backend duration when one arrives", () => { + renderHeader({ + isExpanded: true, + streamingStartTime: START, + toolProcessingDuration: 12, + }); + act(() => { + jest.advanceTimersByTime(30_000); + }); + expect(screen.getByText("12s")).toBeTruthy(); + expect(screen.queryByText("30s")).toBeNull(); + }); + + it("drops the toggle entirely when not collapsible", () => { + renderHeader({ collapsible: false }); + expect(screen.queryByRole("button")).toBeNull(); + }); + + it("uses buttonTitle in place of the elapsed readout", () => { + renderHeader({ + isExpanded: true, + streamingStartTime: START, + buttonTitle: "Details", + }); + expect(screen.getByText("Details")).toBeTruthy(); + }); +}); diff --git a/mobile/src/components/chat/timeline/__tests__/toolIcons.test.ts b/mobile/src/components/chat/timeline/__tests__/toolIcons.test.ts new file mode 100644 index 0000000000..4f21b366d1 --- /dev/null +++ b/mobile/src/components/chat/timeline/__tests__/toolIcons.test.ts @@ -0,0 +1,59 @@ +import { describe, expect, it } from "@jest/globals"; + +import { makePacket } from "@/chat/__tests__/fixtures"; +import { PacketType, type ObjTypes } from "@/chat/streamingModels"; +import { getToolIcon } from "@/components/chat/timeline/toolIcons"; +import SvgBookOpen from "@/icons/book-open"; +import SvgCircle from "@/icons/circle"; +import SvgCode from "@/icons/code"; +import SvgCpu from "@/icons/cpu"; +import SvgGlobe from "@/icons/globe"; +import SvgImage from "@/icons/image"; +import SvgLink from "@/icons/link"; +import SvgSearch from "@/icons/search"; +import SvgSlowTime from "@/icons/slow-time"; +import SvgTerminalSmall from "@/icons/terminal-small"; +import SvgTextLinesSmall from "@/icons/text-lines-small"; +import type { IconFunctionComponent } from "@/icons/types"; +import SvgUser from "@/icons/user"; + +describe("getToolIcon", () => { + it("splits search on is_internet_search", () => { + expect( + getToolIcon([ + makePacket({ type: "search_tool_start", is_internet_search: true }), + ]), + ).toBe(SvgGlobe); + expect( + getToolIcon([ + makePacket({ type: "search_tool_start", is_internet_search: false }), + ]), + ).toBe(SvgSearch); + // Absent flag reads as an internal search, matching getToolName. + expect(getToolIcon([makePacket({ type: "search_tool_start" })])).toBe( + SvgSearch, + ); + }); + + const cases: [PacketType, IconFunctionComponent][] = [ + [PacketType.PYTHON_TOOL_START, SvgTerminalSmall], + [PacketType.FETCH_TOOL_START, SvgLink], + [PacketType.CUSTOM_TOOL_START, SvgCpu], + [PacketType.IMAGE_GENERATION_TOOL_START, SvgImage], + [PacketType.DEEP_RESEARCH_PLAN_START, SvgTextLinesSmall], + [PacketType.RESEARCH_AGENT_START, SvgUser], + [PacketType.CODING_AGENT_START, SvgCode], + [PacketType.REASONING_START, SvgSlowTime], + [PacketType.MEMORY_TOOL_START, SvgBookOpen], + [PacketType.MEMORY_TOOL_NO_ACCESS, SvgBookOpen], + ]; + + it.each(cases)("maps %s", (type, expected) => { + expect(getToolIcon([makePacket({ type } as ObjTypes)])).toBe(expected); + }); + + it("falls back to the neutral node glyph", () => { + expect(getToolIcon([])).toBe(SvgCircle); + expect(getToolIcon([makePacket({ type: "section_end" })])).toBe(SvgCircle); + }); +}); diff --git a/mobile/src/components/chat/timeline/headers/CompletedHeader.tsx b/mobile/src/components/chat/timeline/headers/CompletedHeader.tsx new file mode 100644 index 0000000000..468947997f --- /dev/null +++ b/mobile/src/components/chat/timeline/headers/CompletedHeader.tsx @@ -0,0 +1,76 @@ +// Web's memory-only branch (a tag whose tooltip opens MemoriesModal) is dropped: the memory renderer +// is a later phase, and the design drops the tooltip/modal on mobile (§8(g)). +import { memo } from "react"; +import { Pressable, View } from "react-native"; + +import { timelineTokens } from "@/components/chat/timeline/primitives/timelineTokens"; +import { Button } from "@/components/ui/button"; +import { Text } from "@/components/ui/text"; +import SvgExpand from "@/icons/expand"; +import SvgFold from "@/icons/fold"; +import { formatDurationSeconds } from "@/lib/time"; + +export interface CompletedHeaderProps { + totalSteps: number; + collapsible: boolean; + isExpanded: boolean; + onToggle: () => void; + processingDurationSeconds?: number; + generatedImageCount?: number; +} + +export const CompletedHeader = memo(function CompletedHeader({ + totalSteps, + collapsible, + isExpanded, + onToggle, + processingDurationSeconds = 0, + generatedImageCount = 0, +}: CompletedHeaderProps) { + const durationText = processingDurationSeconds + ? `Thought for ${formatDurationSeconds(processingDurationSeconds)}` + : "Thought for some time"; + + const imageText = + generatedImageCount > 0 + ? `Generated ${generatedImageCount} ${ + generatedImageCount === 1 ? "image" : "images" + }` + : null; + + const showToggle = collapsible && totalSteps > 0; + + return ( + + + + {isExpanded ? durationText : (imageText ?? durationText)} + + + + {showToggle ? ( + + ) : null} + + ); +}); + +export default CompletedHeader; diff --git a/mobile/src/components/chat/timeline/headers/ParallelStreamingHeader.tsx b/mobile/src/components/chat/timeline/headers/ParallelStreamingHeader.tsx new file mode 100644 index 0000000000..41f343e3f9 --- /dev/null +++ b/mobile/src/components/chat/timeline/headers/ParallelStreamingHeader.tsx @@ -0,0 +1,73 @@ +// The selected tab also decides which branch the collapsed preview below shows, so the shell owns +// `activeTab` rather than this header. +import { memo, useMemo } from "react"; + +import { getToolName, isToolComplete } from "@/chat/timeline/toolDisplay"; +import type { TurnGroup } from "@/chat/timeline/transformers"; +import { getToolIcon } from "@/components/chat/timeline/toolIcons"; +import { Button } from "@/components/ui/button"; +import { Tabs } from "@/components/ui/tabs"; +import SvgExpand from "@/icons/expand"; +import SvgFold from "@/icons/fold"; + +export interface ParallelStreamingHeaderProps { + steps: TurnGroup["steps"]; + activeTab: string; + onTabChange: (tab: string) => void; + collapsible: boolean; + isExpanded: boolean; + onToggle: () => void; +} + +export const ParallelStreamingHeader = memo(function ParallelStreamingHeader({ + steps, + activeTab, + onTabChange, + collapsible, + isExpanded, + onToggle, +}: ParallelStreamingHeaderProps) { + const loadingStates = useMemo( + () => + new Map( + steps.map((step) => [ + step.key, + step.packets.length > 0 && !isToolComplete(step.packets), + ]), + ), + [steps], + ); + + return ( + + + ) : undefined + } + > + {steps.map((step) => ( + + {getToolName(step.packets)} + + ))} + + + ); +}); + +export default ParallelStreamingHeader; diff --git a/mobile/src/components/chat/timeline/headers/StoppedHeader.tsx b/mobile/src/components/chat/timeline/headers/StoppedHeader.tsx new file mode 100644 index 0000000000..1aa941e226 --- /dev/null +++ b/mobile/src/components/chat/timeline/headers/StoppedHeader.tsx @@ -0,0 +1,66 @@ +import { memo } from "react"; +import { Pressable, View } from "react-native"; + +import { timelineTokens } from "@/components/chat/timeline/primitives/timelineTokens"; +import { Button } from "@/components/ui/button"; +import { Text } from "@/components/ui/text"; +import SvgExpand from "@/icons/expand"; +import SvgFold from "@/icons/fold"; + +export interface StoppedHeaderProps { + totalSteps: number; + collapsible: boolean; + isExpanded: boolean; + onToggle: () => void; +} + +export const StoppedHeader = memo(function StoppedHeader({ + totalSteps, + collapsible, + isExpanded, + onToggle, +}: StoppedHeaderProps) { + const isInteractive = collapsible && totalSteps > 0; + + const label = ( + + + Interrupted Thinking + + + ); + + if (!isInteractive) { + return ( + + {label} + + ); + } + + return ( + + {label} + + + ); +}); + +export default StoppedHeader; diff --git a/mobile/src/components/chat/timeline/headers/StreamingHeader.tsx b/mobile/src/components/chat/timeline/headers/StreamingHeader.tsx new file mode 100644 index 0000000000..b141e95523 --- /dev/null +++ b/mobile/src/components/chat/timeline/headers/StreamingHeader.tsx @@ -0,0 +1,87 @@ +import { memo } from "react"; +import { View } from "react-native"; + +import { ShimmerText } from "@/components/chat/timeline/ShimmerText"; +import { timelineTokens } from "@/components/chat/timeline/primitives/timelineTokens"; +import { Button } from "@/components/ui/button"; +import { useStreamingDuration } from "@/hooks/timeline/useStreamingDuration"; +import SvgExpand from "@/icons/expand"; +import SvgFold from "@/icons/fold"; +import { formatDurationSeconds } from "@/lib/time"; + +export interface StreamingHeaderProps { + headerText: string; + collapsible: boolean; + buttonTitle?: string; + isExpanded: boolean; + onToggle: () => void; + streamingStartTime?: number; + // Backend `pre_answer_processing_seconds`; freezes the timer once it arrives. + toolProcessingDuration?: number; +} + +export const StreamingHeader = memo(function StreamingHeader({ + headerText, + collapsible, + buttonTitle, + isExpanded, + onToggle, + streamingStartTime, + toolProcessingDuration, +}: StreamingHeaderProps) { + const elapsedSeconds = useStreamingDuration( + toolProcessingDuration === undefined, + streamingStartTime, + toolProcessingDuration, + ); + const showElapsedTime = + isExpanded && streamingStartTime !== undefined && elapsedSeconds > 0; + const toggleIcon = isExpanded ? SvgFold : SvgExpand; + + return ( + <> + + {headerText} + + + {collapsible ? ( + buttonTitle ? ( + + ) : showElapsedTime ? ( + + ) : ( + , + }); + expect(screen.getByText("Fold")).toBeTruthy(); + }); + + it("shows a spinner only while a tab is still running", () => { + // The Spinner wraps the loader glyph, so its presence is the loading signal. + renderTabs({ value: "a", onValueChange: jest.fn(), loading: true }); + expect(screen.UNSAFE_queryByType(SvgLoader)).toBeTruthy(); + + screen.unmount(); + renderTabs({ value: "a", onValueChange: jest.fn(), loading: false }); + expect(screen.UNSAFE_queryByType(SvgLoader)).toBeNull(); + }); +}); diff --git a/mobile/src/components/ui/tabs.tsx b/mobile/src/components/ui/tabs.tsx new file mode 100644 index 0000000000..55e41b80fa --- /dev/null +++ b/mobile/src/components/ui/tabs.tsx @@ -0,0 +1,231 @@ +// RN port of Opal's Tabs (web/lib/opal/src/components/tabs), `pill` variant only — the other two +// have no mobile consumer. +// +// Divergences: scroll arrows dropped (an overflowing touch list is swiped); the indicator sits +// inside the scroll content so it tracks its tab, rather than web's subtract-scrollLeft; the +// baseline gets its width from layout instead of web's ResizeObserver, since the scroll wrapper +// already ends where `rightChildren` begins; RN `Animated`, not reanimated, so a tab strip stays +// renderable under jest. +import { + createContext, + useCallback, + useContext, + useEffect, + useMemo, + useRef, + useState, + type ReactNode, +} from "react"; +import { Animated, Pressable, ScrollView, View } from "react-native"; + +import { Icon } from "@/components/ui/icon"; +import { Spinner } from "@/components/ui/spinner"; +import { Text } from "@/components/ui/text"; +import type { IconFunctionComponent } from "@/icons/types"; +import { cn } from "@/lib/utils"; + +// Off the token scale, so it can't be a class. +const LIST_BOTTOM_PADDING = 5; +// No top padding to match: Opal's `pt-1` is on its no-scroll-arrows branch, and both call sites here +// use the scrollable one. +const TRIGGER_GAP = 8; +const INDICATOR_HEIGHT = 2; +const TRIGGER_ICON_SIZE = 14; +const TRIGGER_SPINNER_SIZE = 12; +const INDICATOR_DURATION_MS = 200; + +interface TriggerLayout { + x: number; + width: number; +} + +interface TabsContextValue { + value: string; + onValueChange: (value: string) => void; + setTriggerLayout: (value: string, layout: TriggerLayout) => void; +} + +const TabsContext = createContext(null); + +function useTabsContext(): TabsContextValue { + const context = useContext(TabsContext); + if (!context) { + throw new Error("Tabs.List / Tabs.Trigger must be rendered inside "); + } + return context; +} + +// Split from TabsContext so a Trigger reporting its layout doesn't re-render every other Trigger. +const LayoutsContext = createContext>({}); + +export interface TabsProps { + value: string; + onValueChange: (value: string) => void; + children: ReactNode; +} + +function TabsRoot({ value, onValueChange, children }: TabsProps) { + // Held here, not in the Triggers that measure them: the List's indicator is what consumes them. + const [layouts, setLayouts] = useState>({}); + + const setTriggerLayout = useCallback( + (triggerValue: string, layout: TriggerLayout) => { + setLayouts((prev) => { + const current = prev[triggerValue]; + if (current?.x === layout.x && current?.width === layout.width) { + return prev; + } + return { ...prev, [triggerValue]: layout }; + }); + }, + [], + ); + + const context = useMemo( + () => ({ value, onValueChange, setTriggerLayout }), + [value, onValueChange, setTriggerLayout], + ); + + return ( + + + {children} + + + ); +} + +export interface TabsListProps { + children: ReactNode; + // Pinned right, outside the scroll area, so it stays put as the strip scrolls. + rightChildren?: ReactNode; +} + +function TabsList({ children, rightChildren }: TabsListProps) { + const { value } = useTabsContext(); + const layouts = useContext(LayoutsContext); + const active = layouts[value]; + + // useMemo, not useRef: these are read during render to build the style, and the refs lint forbids + // reading a ref there. Same shape `Spinner` uses. + const left = useMemo(() => new Animated.Value(0), []); + const width = useMemo(() => new Animated.Value(0), []); + const hasAnimated = useRef(false); + + useEffect(() => { + if (!active) return; + // First placement snaps — sliding in from x=0 would read as a stray animation on mount. + if (!hasAnimated.current) { + hasAnimated.current = true; + left.setValue(active.x); + width.setValue(active.width); + return; + } + Animated.parallel([ + Animated.timing(left, { + toValue: active.x, + duration: INDICATOR_DURATION_MS, + useNativeDriver: false, + }), + Animated.timing(width, { + toValue: active.width, + duration: INDICATOR_DURATION_MS, + useNativeDriver: false, + }), + ]).start(); + }, [active, left, width]); + + return ( + + + {/* Declared before the strip so the indicator paints over it; RN has no z-index, which is + how Opal orders these two. */} + + + {children} + {active ? ( + + ) : null} + + + {rightChildren ? {rightChildren} : null} + + ); +} + +export interface TabsTriggerProps { + value: string; + children: string; + icon?: IconFunctionComponent; + isLoading?: boolean; +} + +function TabsTrigger({ + value, + children, + icon, + isLoading = false, +}: TabsTriggerProps) { + const { + value: activeValue, + onValueChange, + setTriggerLayout, + } = useTabsContext(); + const selected = value === activeValue; + const foreground = selected ? "text-text-inverted-05" : "text-text-03"; + + return ( + onValueChange(value)} + onLayout={(event) => { + const { x, width } = event.nativeEvent.layout; + setTriggerLayout(value, { x, width }); + }} + className={cn( + "flex-row items-center justify-center rounded-08 p-4", + selected ? "bg-background-tint-inverted-03" : "bg-background-tint-00", + )} + > + {icon ? ( + + + + ) : null} + + + {children} + + + {isLoading ? ( + + + + ) : null} + + ); +} + +export const Tabs = Object.assign(TabsRoot, { + List: TabsList, + Trigger: TabsTrigger, +}); diff --git a/mobile/src/hooks/__tests__/useChatController.test.tsx b/mobile/src/hooks/__tests__/useChatController.test.tsx index b3052be20a..f5d14f8587 100644 --- a/mobile/src/hooks/__tests__/useChatController.test.tsx +++ b/mobile/src/hooks/__tests__/useChatController.test.tsx @@ -13,7 +13,7 @@ import { } from "@/api/chat/sessions"; import { streamChatMessage, type StreamEvent } from "@/api/chat/stream"; import { ChatFileType, type FileDescriptor } from "@/chat/interfaces"; -import { PacketType } from "@/chat/streamingModels"; +import { PacketType, StopReason } from "@/chat/streamingModels"; import { useChatController } from "@/hooks/useChatController"; import { useChatSessionStore } from "@/state/chatSessionStore"; @@ -150,6 +150,73 @@ describe("useChatController", () => { ).toBeNull(); }); + it("stamps streamingStartedAt on the assistant node so the timeline timer has an anchor", async () => { + useChatSessionStore.getState().ensureSession("s1"); + streamMock.mockReturnValue(scripted([startPacket("Hi"), endPacket()])); + + const before = Date.now(); + const { result } = renderHook(() => useChatController("s1"), { wrapper }); + await act(async () => { + await result.current.submit("hi"); + }); + await waitFor(() => expect(result.current.chatState).toBe("input")); + + const [userNode, agentNode] = result.current.messages; + expect(agentNode!.streamingStartedAt).toBeGreaterThanOrEqual(before); + expect(agentNode!.streamingStartedAt).toBeLessThanOrEqual(Date.now()); + expect(userNode!.streamingStartedAt).toBeUndefined(); + }); + + it("closes a user-stopped turn with a synthetic stop packet", async () => { + useChatSessionStore.getState().ensureSession("s1"); + stopSessionMock.mockResolvedValue(); + // Ends only on abort, like the real one when its reader is cut — which is exactly why the + // backend's own stop packet never arrives. + streamMock.mockImplementation((_body, signal) => + (async function* () { + yield startPacket("Thinking"); + await new Promise((resolve) => { + if (signal.aborted) resolve(); + else signal.addEventListener("abort", () => resolve()); + }); + })(), + ); + + const { result } = renderHook(() => useChatController("s1"), { wrapper }); + await act(async () => { + await result.current.submit("hi"); + }); + await act(async () => { + result.current.stop(); + }); + await waitFor(() => expect(result.current.chatState).toBe("input")); + + // Without this the turn keeps looking like it is streaming until the session reloads. + const agentNode = result.current.messages[1]!; + const stopPacket = agentNode.packets.at(-1)!; + expect(stopPacket.obj.type).toBe(PacketType.STOP); + expect((stopPacket.obj as { stop_reason?: string }).stop_reason).toBe( + StopReason.USER_CANCELLED, + ); + }); + + it("does not append a synthetic stop to a turn that ended on its own", async () => { + useChatSessionStore.getState().ensureSession("s1"); + streamMock.mockReturnValue(scripted([startPacket("Hi"), endPacket()])); + + const { result } = renderHook(() => useChatController("s1"), { wrapper }); + await act(async () => { + await result.current.submit("hi"); + }); + await waitFor(() => expect(result.current.chatState).toBe("input")); + + expect( + result.current.messages[1]!.packets.some( + (p) => p.obj.type === PacketType.STOP, + ), + ).toBe(false); + }); + it("surfaces a mid-stream backend error as an error node (no stuck placeholder)", async () => { useChatSessionStore.getState().ensureSession("s1"); streamMock.mockReturnValue( diff --git a/mobile/src/hooks/__tests__/usePacketDisplay.test.ts b/mobile/src/hooks/__tests__/usePacketDisplay.test.ts deleted file mode 100644 index c8677a9179..0000000000 --- a/mobile/src/hooks/__tests__/usePacketDisplay.test.ts +++ /dev/null @@ -1,70 +0,0 @@ -import { describe, expect, it } from "@jest/globals"; -import { renderHook } from "@testing-library/react-native"; - -import { makeMessageStartPacket, makePacket } from "@/chat/__tests__/fixtures"; -import { type Message } from "@/chat/interfaces"; -import { type Packet } from "@/chat/streamingModels"; -import { usePacketDisplay } from "@/hooks/usePacketDisplay"; - -function makeAssistantNode(packets: Packet[]): Message { - return { - nodeId: 1, - parentNodeId: 0, - type: "assistant", - message: "", - files: [], - packets, - }; -} - -const reasoningPackets: Packet[] = [ - makePacket({ type: "reasoning_start" }), - makePacket({ type: "reasoning_delta", reasoning: "thinking hard" }), - makePacket({ type: "reasoning_done" }), -]; - -describe("usePacketDisplay", () => { - it("reports no display content before the answer starts", () => { - const { result } = renderHook(() => - usePacketDisplay(makeAssistantNode([])), - ); - expect(result.current.hasDisplayContent).toBe(false); - }); - - // Keying the gate on "findRenderer matched something" instead would put raw reasoning text in the - // answer slot and kill the loader — reasoning and tool groups belong above the answer, not in it. - it("stays closed for a reasoning-only node", () => { - const { result } = renderHook(() => - usePacketDisplay(makeAssistantNode(reasoningPackets)), - ); - expect(result.current.hasDisplayContent).toBe(false); - }); - - it("opens once the answer's message_start arrives in the next turn", () => { - const { result } = renderHook(() => - usePacketDisplay( - // The backend increments turn_index before the answer, so the answer is always its own - // group (backend/onyx/chat/llm_step.py). - makeAssistantNode([ - ...reasoningPackets, - { ...makeMessageStartPacket(), placement: { turn_index: 1 } }, - ]), - ), - ); - expect(result.current.hasDisplayContent).toBe(true); - }); - - it("still exposes the processed state consumers read (citations, completion)", () => { - const { result } = renderHook(() => - usePacketDisplay( - makeAssistantNode([ - makeMessageStartPacket(), - makePacket({ type: "message_delta", content: "hi" }), - makePacket({ type: "message_end" }), - ]), - ), - ); - expect(result.current.processed.isComplete).toBe(true); - expect(result.current.packets).toHaveLength(3); - }); -}); diff --git a/mobile/src/hooks/timeline/usePacketProcessor.ts b/mobile/src/hooks/timeline/usePacketProcessor.ts index 2002f8f1ad..9f2e19d329 100644 --- a/mobile/src/hooks/timeline/usePacketProcessor.ts +++ b/mobile/src/hooks/timeline/usePacketProcessor.ts @@ -14,6 +14,7 @@ import { } from "@/chat/contracts/documents"; import { GroupedPacket, + ProcessedMessageState, createInitialState, processPackets, } from "@/chat/messageProcessor"; @@ -25,6 +26,9 @@ import { } from "@/chat/timeline/transformers"; export interface UsePacketProcessorResult { + // The raw reducer output, exposed because `selectSources` and the full-text reader want the whole + // state; deriving it in a second hook would reduce every packet twice per flush. + processed: ProcessedMessageState; toolGroups: GroupedPacket[]; displayGroups: GroupedPacket[]; toolTurnGroups: TurnGroup[]; @@ -108,6 +112,7 @@ export function usePacketProcessor( }, []); return { + processed: state, toolGroups: state.toolGroups, displayGroups, toolTurnGroups, diff --git a/mobile/src/hooks/timeline/useTimelineExpansion.ts b/mobile/src/hooks/timeline/useTimelineExpansion.ts index 8d1f5ee074..600f674d96 100644 --- a/mobile/src/hooks/timeline/useTimelineExpansion.ts +++ b/mobile/src/hooks/timeline/useTimelineExpansion.ts @@ -35,8 +35,7 @@ export function useTimelineExpansion( }, [stopPacketSeen, hasDisplayContent]); // Keep the active parallel tab valid. Adjusting state during render (not an effect) avoids - // react-hooks/set-state-in-effect and converges in one pass. Dormant in 9b (parallel turns are - // linearized); kept for the parallel-tab phase. + // react-hooks/set-state-in-effect and converges in one pass. const validTabKeys = useMemo( () => lastTurnGroup?.isParallel && lastTurnGroup.steps.length > 0 diff --git a/mobile/src/hooks/useChatController.ts b/mobile/src/hooks/useChatController.ts index 30d0a05670..b39c3f2793 100644 --- a/mobile/src/hooks/useChatController.ts +++ b/mobile/src/hooks/useChatController.ts @@ -31,7 +31,11 @@ import { SYSTEM_NODE_ID, upsertMessages, } from "@/chat/messageTree"; -import { Packet } from "@/chat/streamingModels"; +import { + Packet, + PacketType, + buildUserCancelledStopPacket, +} from "@/chat/streamingModels"; import { useChatSessionStore } from "@/state/chatSessionStore"; import { useSession } from "@/state/session"; @@ -59,6 +63,16 @@ async function nameNewSession( } } +function appendUserCancelledStop(sessionId: string, nodeId: number): void { + const store = useChatSessionStore.getState(); + const node = store.sessions.get(sessionId)?.messageTree.get(nodeId); + if (!node || node.type !== "assistant") return; + if (node.packets.some((p) => p.obj.type === PacketType.STOP)) return; + store.patchNode(sessionId, nodeId, { + packets: [...node.packets, buildUserCancelledStopPacket(node.packets)], + }); +} + async function runChatStream( sessionId: string, userNodeId: number, @@ -73,6 +87,12 @@ async function runChatStream( let sawStreaming = false; let hadError = false; + // Anchors the timeline's elapsed timer. Stamped here, not at node creation, so it measures the + // stream rather than the session-create round trip that can precede it. + store + .getState() + .patchNode(sessionId, agentNodeId, { streamingStartedAt: Date.now() }); + function flush() { if (pending.length === 0) return; const data = store.getState().sessions.get(sessionId); @@ -156,6 +176,9 @@ async function runChatStream( } finally { if (flushTimer) clearTimeout(flushTimer); flush(); + if (signal.aborted && !hadError) { + appendUserCancelledStop(sessionId, agentNodeId); + } store.getState().updateChatState(sessionId, "input"); store.getState().setAbortController(sessionId, null); // name a new session once it streamed output; skip stop/abort and thrown transport failures diff --git a/mobile/src/hooks/useChatSessionController.ts b/mobile/src/hooks/useChatSessionController.ts index 13bf892c4d..28e97a4e12 100644 --- a/mobile/src/hooks/useChatSessionController.ts +++ b/mobile/src/hooks/useChatSessionController.ts @@ -49,8 +49,13 @@ async function runResumeStream( const controller = new AbortController(); store.getState().setAbortController(sessionId, controller); store.getState().updateChatState(sessionId, "streaming"); - // clear the reserved placeholder so it doesn't render above the replayed stream - store.getState().patchNode(sessionId, nodeId, { message: "", packets: [] }); + // clear the reserved placeholder so it doesn't render above the replayed stream. The timer + // restarts from here: this client never saw the run's true start (web shows no timer at all here). + store.getState().patchNode(sessionId, nodeId, { + message: "", + packets: [], + streamingStartedAt: Date.now(), + }); // writes are safe only while this session stays focused and unaborted const stillCurrent = () => diff --git a/mobile/src/hooks/usePacketDisplay.ts b/mobile/src/hooks/usePacketDisplay.ts deleted file mode 100644 index a062e11c98..0000000000 --- a/mobile/src/hooks/usePacketDisplay.ts +++ /dev/null @@ -1,31 +0,0 @@ -// Reprocesses on each stream flush (the packet array's identity changes); cheap at chat scale. -import { useMemo } from "react"; - -import { Message } from "@/chat/interfaces"; -import { - ProcessedMessageState, - createInitialState, - processPackets, -} from "@/chat/messageProcessor"; -import { Packet } from "@/chat/streamingModels"; - -export interface PacketDisplay { - packets: Packet[]; - processed: ProcessedMessageState; - // Drives the answer-vs-loader gate in MessageRow. Keyed on display groups, not on "some renderer - // matches": a reasoning or tool group matches one too, but belongs above the answer, not in it. - hasDisplayContent: boolean; -} - -export function usePacketDisplay(node: Message): PacketDisplay { - const processed = useMemo( - () => processPackets(createInitialState(node.nodeId), node.packets), - [node.nodeId, node.packets], - ); - - return { - packets: node.packets, - processed, - hasDisplayContent: processed.potentialDisplayGroups.length > 0, - }; -} diff --git a/mobile/src/icons/book-open.tsx b/mobile/src/icons/book-open.tsx new file mode 100644 index 0000000000..8a32df394d --- /dev/null +++ b/mobile/src/icons/book-open.tsx @@ -0,0 +1,23 @@ +import Svg, { Path } from "react-native-svg"; + +import type { IconProps } from "@/icons/types"; + +const SvgBookOpen = ({ size = 16, ...props }: IconProps) => ( + + + +); + +export default SvgBookOpen; diff --git a/mobile/src/icons/branch.tsx b/mobile/src/icons/branch.tsx new file mode 100644 index 0000000000..bb266c4da0 --- /dev/null +++ b/mobile/src/icons/branch.tsx @@ -0,0 +1,23 @@ +import Svg, { Path } from "react-native-svg"; + +import type { IconProps } from "@/icons/types"; + +const SvgBranch = ({ size = 16, ...props }: IconProps) => ( + + + +); + +export default SvgBranch; diff --git a/mobile/src/icons/code.tsx b/mobile/src/icons/code.tsx new file mode 100644 index 0000000000..ae1286fe56 --- /dev/null +++ b/mobile/src/icons/code.tsx @@ -0,0 +1,23 @@ +import Svg, { Path } from "react-native-svg"; + +import type { IconProps } from "@/icons/types"; + +const SvgCode = ({ size = 16, ...props }: IconProps) => ( + + + +); + +export default SvgCode; diff --git a/mobile/src/icons/slow-time.tsx b/mobile/src/icons/slow-time.tsx new file mode 100644 index 0000000000..656707c1da --- /dev/null +++ b/mobile/src/icons/slow-time.tsx @@ -0,0 +1,24 @@ +import Svg, { Path } from "react-native-svg"; + +import type { IconProps } from "@/icons/types"; + +// The web original's clipPath is dropped: every stroke already sits inside the viewBox. +const SvgSlowTime = ({ size = 16, ...props }: IconProps) => ( + + + +); + +export default SvgSlowTime; diff --git a/mobile/src/icons/stop-circle.tsx b/mobile/src/icons/stop-circle.tsx new file mode 100644 index 0000000000..4af3a98c77 --- /dev/null +++ b/mobile/src/icons/stop-circle.tsx @@ -0,0 +1,29 @@ +import Svg, { Path } from "react-native-svg"; + +import type { IconProps } from "@/icons/types"; + +const SvgStopCircle = ({ size = 16, ...props }: IconProps) => ( + + + + +); + +export default SvgStopCircle; diff --git a/mobile/src/icons/user.tsx b/mobile/src/icons/user.tsx new file mode 100644 index 0000000000..f45a242b60 --- /dev/null +++ b/mobile/src/icons/user.tsx @@ -0,0 +1,23 @@ +import Svg, { Path } from "react-native-svg"; + +import type { IconProps } from "@/icons/types"; + +const SvgUser = ({ size = 16, ...props }: IconProps) => ( + + + +); + +export default SvgUser; diff --git a/mobile/src/lib/__tests__/time.test.ts b/mobile/src/lib/__tests__/time.test.ts index 80bcbc1e78..adfdc6d476 100644 --- a/mobile/src/lib/__tests__/time.test.ts +++ b/mobile/src/lib/__tests__/time.test.ts @@ -1,11 +1,27 @@ import { describe, expect, it } from "@jest/globals"; -import { timeAgo } from "@/lib/time"; +import { formatDurationSeconds, timeAgo } from "@/lib/time"; function daysAgo(days: number): string { return new Date(Date.now() - days * 24 * 60 * 60 * 1000).toISOString(); } +describe("formatDurationSeconds", () => { + it("renders sub-minute durations in whole seconds, rounding up", () => { + expect(formatDurationSeconds(0)).toBe("0s"); + expect(formatDurationSeconds(4)).toBe("4s"); + expect(formatDurationSeconds(4.2)).toBe("5s"); + expect(formatDurationSeconds(59)).toBe("59s"); + }); + + it("switches to minutes, dropping a zero seconds remainder", () => { + expect(formatDurationSeconds(60)).toBe("1m"); + expect(formatDurationSeconds(75)).toBe("1m 15s"); + expect(formatDurationSeconds(120)).toBe("2m"); + expect(formatDurationSeconds(3661)).toBe("61m 1s"); + }); +}); + describe("timeAgo", () => { it("labels sub-year deltas by the largest fitting unit", () => { expect(timeAgo(daysAgo(0))).toBe("just now"); diff --git a/mobile/src/lib/time.ts b/mobile/src/lib/time.ts index 5808bf7b8a..c144a630ae 100644 --- a/mobile/src/lib/time.ts +++ b/mobile/src/lib/time.ts @@ -1,3 +1,14 @@ +// Port of Opal `formatDurationSeconds` (web/lib/opal/src/time.ts): "8s", "1m", "1m 15s". +export function formatDurationSeconds(seconds: number): string { + const totalSeconds = Math.ceil(seconds); + if (totalSeconds < 60) { + return `${totalSeconds}s`; + } + const mins = Math.floor(totalSeconds / 60); + const secs = totalSeconds % 60; + return secs > 0 ? `${mins}m ${secs}s` : `${mins}m`; +} + // Compact relative-time label; null for unparseable input. export function timeAgo(iso: string): string | null { const then = new Date(iso).getTime();