mirror of
https://github.com/onyx-dot-app/onyx.git
synced 2026-10-19 01:50:51 -05:00
feat(mobile): agent timeline composition + parallel tabs (#13695)
Co-authored-by: Claude Opus 5 (1M context) <noreply@anthropic.com>
This commit is contained in:
1 parent
45b92975cc
commit
b345a930ed
40 files changed
+2397
-255
No files matched your search
@@ -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)
|
||||
|
||||
|
||||
@@ -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();
|
||||
});
|
||||
});
|
||||
@@ -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);
|
||||
|
||||
@@ -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.
|
||||
|
||||
@@ -466,3 +466,14 @@ export interface StreamingError {
|
||||
is_retryable?: boolean;
|
||||
details?: Record<string, unknown> | 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 },
|
||||
};
|
||||
}
|
||||
@@ -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 (
|
||||
<View>
|
||||
<View className="h-36 flex-row items-center">
|
||||
<View className={cn("h-36 items-center justify-center", RAIL)}>
|
||||
{agent ? <AgentAvatar agent={agent} size={AVATAR_SIZE} /> : null}
|
||||
</View>
|
||||
{isLoading ? <ThinkingLabel /> : null}
|
||||
</View>
|
||||
|
||||
{steps.map((step, index) => (
|
||||
<TimelineStep
|
||||
key={step.key}
|
||||
step={step}
|
||||
isFirst={index === 0}
|
||||
isLast={index === steps.length - 1}
|
||||
/>
|
||||
))}
|
||||
</View>
|
||||
);
|
||||
}
|
||||
|
||||
// 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 (
|
||||
<Animated.View style={animatedStyle}>
|
||||
<Text font="main-ui-action" color="text-03">
|
||||
Thinking…
|
||||
</Text>
|
||||
</Animated.View>
|
||||
);
|
||||
}
|
||||
|
||||
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 (
|
||||
<View className="flex-row">
|
||||
<View className={cn("items-center", RAIL)}>
|
||||
<View
|
||||
className={cn("h-8 w-[1px] bg-border-01", isFirst && "opacity-0")}
|
||||
/>
|
||||
{step.icon ? (
|
||||
<Icon
|
||||
as={step.icon}
|
||||
size={12}
|
||||
className={isError ? "text-status-error-05" : "text-text-02"}
|
||||
/>
|
||||
) : (
|
||||
<View
|
||||
className={cn(
|
||||
"h-8 w-8 rounded-full",
|
||||
isError ? "bg-status-error-05" : "bg-text-04",
|
||||
)}
|
||||
/>
|
||||
)}
|
||||
<View
|
||||
className={cn("w-[1px] flex-1 bg-border-01", isLast && "opacity-0")}
|
||||
/>
|
||||
</View>
|
||||
<View className="flex-1 py-4">
|
||||
<Text
|
||||
font="main-ui-muted"
|
||||
color={isError ? "status-error-05" : "text-04"}
|
||||
>
|
||||
{step.label}
|
||||
</Text>
|
||||
</View>
|
||||
</View>
|
||||
<TimelineRoot>
|
||||
<TimelineHeaderRow
|
||||
left={agent ? <AgentAvatar agent={agent} size={AVATAR_SIZE} /> : null}
|
||||
>
|
||||
{headerContent}
|
||||
</TimelineHeaderRow>
|
||||
{children}
|
||||
</TimelineRoot>
|
||||
);
|
||||
}
|
||||
|
||||
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 (
|
||||
<TimelineContainer
|
||||
agent={chatState.agent}
|
||||
headerContent={
|
||||
<View
|
||||
className="h-full w-full flex-row items-center"
|
||||
style={{
|
||||
paddingLeft: timelineTokens.headerPaddingLeft,
|
||||
paddingRight: timelineTokens.headerPaddingRight,
|
||||
}}
|
||||
>
|
||||
<ShimmerText>{headerText}</ShimmerText>
|
||||
</View>
|
||||
}
|
||||
/>
|
||||
);
|
||||
}
|
||||
|
||||
if (uiState === TimelineUIState.DISPLAY_CONTENT_ONLY) {
|
||||
return <TimelineContainer agent={chatState.agent} />;
|
||||
}
|
||||
|
||||
// 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 = (
|
||||
<ParallelStreamingHeader
|
||||
steps={lastTurnGroup.steps}
|
||||
activeTab={parallelActiveTab}
|
||||
onTabChange={setParallelActiveTab}
|
||||
collapsible={collapsible}
|
||||
isExpanded={isExpanded}
|
||||
onToggle={handleToggle}
|
||||
/>
|
||||
);
|
||||
} else if (isStreamingState) {
|
||||
header = (
|
||||
<StreamingHeader
|
||||
headerText={headerText}
|
||||
collapsible={collapsible}
|
||||
buttonTitle={buttonTitle}
|
||||
isExpanded={isExpanded}
|
||||
onToggle={handleToggle}
|
||||
streamingStartTime={streamingStartTime}
|
||||
toolProcessingDuration={toolProcessingDuration}
|
||||
/>
|
||||
);
|
||||
} else if (uiState === TimelineUIState.STOPPED) {
|
||||
header = (
|
||||
<StoppedHeader
|
||||
totalSteps={stoppedStepsCount}
|
||||
collapsible={collapsible}
|
||||
isExpanded={isExpanded}
|
||||
onToggle={handleToggle}
|
||||
/>
|
||||
);
|
||||
} else {
|
||||
header = (
|
||||
<CompletedHeader
|
||||
totalSteps={totalSteps}
|
||||
collapsible={collapsible}
|
||||
isExpanded={isExpanded}
|
||||
onToggle={handleToggle}
|
||||
processingDurationSeconds={
|
||||
toolProcessingDuration ?? processingDurationSeconds
|
||||
}
|
||||
generatedImageCount={generatedImageCount}
|
||||
/>
|
||||
);
|
||||
}
|
||||
|
||||
return (
|
||||
<TimelineContainer
|
||||
agent={chatState.agent}
|
||||
headerContent={
|
||||
<View
|
||||
className={cn(
|
||||
"h-full min-w-0 flex-1 flex-row items-center justify-between rounded-t-12 p-4",
|
||||
showTintedBackground && "bg-background-tint-00",
|
||||
showRoundedBottom && "rounded-b-12",
|
||||
)}
|
||||
>
|
||||
{header}
|
||||
</View>
|
||||
}
|
||||
>
|
||||
{showCollapsedCompact && lastStep ? (
|
||||
<CollapsedStreamingContent
|
||||
step={lastStep}
|
||||
chatState={chatState}
|
||||
stopReason={stopReason}
|
||||
renderTypeOverride={collapsedRenderTypeOverride}
|
||||
/>
|
||||
) : null}
|
||||
|
||||
{showCollapsedParallel && parallelActiveStep ? (
|
||||
<CollapsedStreamingContent
|
||||
step={parallelActiveStep}
|
||||
chatState={chatState}
|
||||
stopReason={stopReason}
|
||||
renderTypeOverride={RenderType.HIGHLIGHT}
|
||||
/>
|
||||
) : null}
|
||||
|
||||
{isExpanded ? (
|
||||
<ExpandedTimelineContent
|
||||
turnGroups={turnGroups}
|
||||
chatState={chatState}
|
||||
stopPacketSeen={stopPacketSeen}
|
||||
stopReason={stopReason}
|
||||
isSingleStep={isSingleStep}
|
||||
userStopped={userStopped}
|
||||
showDoneStep={showDoneStep}
|
||||
showStoppedStep={showStoppedStep}
|
||||
hasDoneIndicator={hasDoneIndicator}
|
||||
/>
|
||||
) : null}
|
||||
</TimelineContainer>
|
||||
);
|
||||
}, areAgentTimelinePropsEqual);
|
||||
|
||||
export default AgentTimeline;
|
||||
@@ -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 }) => <MessageRow node={item} agent={agent} />,
|
||||
({ item }: { item: Message }) => (
|
||||
<MessageRow key={item.nodeId} node={item} agent={agent} />
|
||||
),
|
||||
[agent],
|
||||
);
|
||||
|
||||
|
||||
@@ -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<string | null>(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<FullChatState>(
|
||||
() => ({
|
||||
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 (
|
||||
<View className="gap-12 py-6">
|
||||
<AgentTimeline
|
||||
agent={agent}
|
||||
isLoading={!hasContent && !processed.isComplete}
|
||||
turnGroups={pacedTurnGroups}
|
||||
chatState={chatState}
|
||||
stopPacketSeen={stopPacketSeen}
|
||||
stopReason={stopReason}
|
||||
hasDisplayContent={hasDisplayContent}
|
||||
finalAnswerComing={pacedFinalAnswerComing}
|
||||
processingDurationSeconds={node.processingDurationSeconds ?? undefined}
|
||||
isGeneratingImage={isGeneratingImage}
|
||||
generatedImageCount={generatedImageCount}
|
||||
toolProcessingDuration={toolProcessingDuration}
|
||||
streamingStartTime={node.streamingStartedAt}
|
||||
/>
|
||||
{hasContent ? (
|
||||
{hasDisplayContent ? (
|
||||
// px-12 aligns the answer under the avatar rail (web's px-3).
|
||||
<View className="gap-12 px-12">
|
||||
{pacedDisplayGroups.map((displayGroup, groupIndex) => (
|
||||
<RendererComponent
|
||||
key={`${displayGroup.turn_index}-${displayGroup.tab_index}`}
|
||||
packets={displayGroup.packets}
|
||||
chatState={chatState}
|
||||
messageNodeId={node.nodeId}
|
||||
hasTimelineThinking={pacedTurnGroups.length > 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) => (
|
||||
<Fragment key={index}>{result.content}</Fragment>
|
||||
))}
|
||||
</>
|
||||
)}
|
||||
</RendererComponent>
|
||||
))}
|
||||
</View>
|
||||
) : stopReason === StopReason.USER_CANCELLED ? (
|
||||
<View className="px-12">
|
||||
<RendererComponent
|
||||
packets={packets}
|
||||
chatState={chatState}
|
||||
messageNodeId={node.nodeId}
|
||||
onComplete={NOOP}
|
||||
animate={!processed.isComplete}
|
||||
stopPacketSeen={processed.stopPacketSeen}
|
||||
stopReason={processed.stopReason}
|
||||
>
|
||||
{(results) => (
|
||||
<>
|
||||
{results.map((result, index) => (
|
||||
<Fragment key={index}>{result.content}</Fragment>
|
||||
))}
|
||||
</>
|
||||
)}
|
||||
</RendererComponent>
|
||||
<Text font="secondary-body" color="text-04">
|
||||
User has stopped generation
|
||||
</Text>
|
||||
</View>
|
||||
) : 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,
|
||||
|
||||
@@ -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<typeof import("@/components/ui/text")>(
|
||||
"@/components/ui/text",
|
||||
);
|
||||
return {
|
||||
StreamingMarkdown: ({ content }: { content: string }) => (
|
||||
<Text>{content}</Text>
|
||||
),
|
||||
};
|
||||
});
|
||||
|
||||
// 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(
|
||||
<AgentTimeline
|
||||
turnGroups={turnGroups}
|
||||
chatState={chatState}
|
||||
stopPacketSeen={state.stopPacketSeen}
|
||||
stopReason={state.stopReason}
|
||||
{...overrides}
|
||||
/>,
|
||||
);
|
||||
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(
|
||||
<AgentTimeline
|
||||
turnGroups={turnGroups}
|
||||
chatState={chatState}
|
||||
stopPacketSeen
|
||||
hasDisplayContent
|
||||
processingDurationSeconds={4}
|
||||
/>,
|
||||
);
|
||||
|
||||
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(
|
||||
<AgentTimeline
|
||||
turnGroups={turnGroups}
|
||||
chatState={chatState}
|
||||
stopPacketSeen
|
||||
hasDisplayContent
|
||||
/>,
|
||||
);
|
||||
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(
|
||||
<AgentTimeline
|
||||
turnGroups={turnGroups}
|
||||
chatState={chatState}
|
||||
stopPacketSeen
|
||||
hasDisplayContent
|
||||
processingDurationSeconds={2}
|
||||
/>,
|
||||
);
|
||||
|
||||
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(
|
||||
<AgentTimeline
|
||||
turnGroups={turnGroups}
|
||||
chatState={chatState}
|
||||
stopPacketSeen
|
||||
hasDisplayContent
|
||||
processingDurationSeconds={9}
|
||||
/>,
|
||||
);
|
||||
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(
|
||||
<AgentTimeline
|
||||
turnGroups={turnGroups}
|
||||
chatState={chatState}
|
||||
stopPacketSeen
|
||||
stopReason={state.stopReason}
|
||||
/>,
|
||||
);
|
||||
|
||||
// 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(
|
||||
<AgentTimeline turnGroups={[]} chatState={chatState} hasDisplayContent />,
|
||||
);
|
||||
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(<AgentTimeline turnGroups={turnGroups} chatState={chatState} />);
|
||||
|
||||
// "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(
|
||||
<AgentTimeline
|
||||
turnGroups={turnGroups}
|
||||
chatState={chatState}
|
||||
stopPacketSeen
|
||||
hasDisplayContent
|
||||
processingDurationSeconds={3}
|
||||
/>,
|
||||
);
|
||||
|
||||
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(
|
||||
<AgentTimeline
|
||||
turnGroups={turnGroups}
|
||||
chatState={chatState}
|
||||
stopPacketSeen
|
||||
hasDisplayContent
|
||||
processingDurationSeconds={3}
|
||||
/>,
|
||||
);
|
||||
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();
|
||||
});
|
||||
});
|
||||
});
|
||||
@@ -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) => (
|
||||
<Fragment key={index}>{result.content}</Fragment>
|
||||
))}
|
||||
</>
|
||||
),
|
||||
[],
|
||||
);
|
||||
|
||||
return (
|
||||
<TimelineRow railVariant="spacer">
|
||||
<TimelineSurface className="px-8 pb-8" roundedBottom>
|
||||
<TimelineRendererComponent
|
||||
key={`${step.key}-compact`}
|
||||
packets={step.packets}
|
||||
chatState={chatState}
|
||||
animate
|
||||
stopPacketSeen={false}
|
||||
stopReason={stopReason}
|
||||
defaultExpanded={false}
|
||||
renderTypeOverride={renderTypeOverride}
|
||||
isLastStep
|
||||
>
|
||||
{renderContentOnly}
|
||||
</TimelineRendererComponent>
|
||||
</TimelineSurface>
|
||||
</TimelineRow>
|
||||
);
|
||||
},
|
||||
);
|
||||
|
||||
export default CollapsedStreamingContent;
|
||||
@@ -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 (
|
||||
<View className="w-full">
|
||||
{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 (
|
||||
<ParallelTimelineTabs
|
||||
key={turnGroup.turnIndex}
|
||||
turnGroup={turnGroup}
|
||||
chatState={chatState}
|
||||
stopPacketSeen={stopPacketSeen}
|
||||
stopReason={stopReason}
|
||||
isLastTurnGroup={
|
||||
turnIdx === turnGroups.length - 1 &&
|
||||
!showDoneStep &&
|
||||
!showStoppedStep
|
||||
}
|
||||
isFirstTurnGroup={turnIdx === 0}
|
||||
/>
|
||||
);
|
||||
}
|
||||
|
||||
return turnGroup.steps.map((step, stepIdx) => (
|
||||
<TimelineStep
|
||||
key={step.key}
|
||||
step={step}
|
||||
chatState={chatState}
|
||||
stopPacketSeen={stopPacketSeen}
|
||||
stopReason={stopReason}
|
||||
isLastStep={
|
||||
turnIdx === turnGroups.length - 1 &&
|
||||
stepIdx === turnGroup.steps.length - 1 &&
|
||||
!hasDoneIndicator &&
|
||||
!userStopped
|
||||
}
|
||||
isFirstStep={turnIdx === 0 && stepIdx === 0}
|
||||
isSingleStep={isSingleStep}
|
||||
isStreaming={!stopPacketSeen && !userStopped}
|
||||
/>
|
||||
));
|
||||
})}
|
||||
|
||||
{showDoneStep ? (
|
||||
<StepContainer stepIcon={SvgCheckCircle} header="Done" isLastStep>
|
||||
{null}
|
||||
</StepContainer>
|
||||
) : null}
|
||||
|
||||
{showStoppedStep ? (
|
||||
<StepContainer stepIcon={SvgStopCircle} header="Stopped" isLastStep>
|
||||
{null}
|
||||
</StepContainer>
|
||||
) : null}
|
||||
</View>
|
||||
);
|
||||
});
|
||||
|
||||
export default ExpandedTimelineContent;
|
||||
@@ -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[]) => (
|
||||
<TimelineStepComposer
|
||||
results={results}
|
||||
isLastStep={isLastTurnGroup}
|
||||
isFirstStep={false}
|
||||
isSingleStep={false}
|
||||
collapsible
|
||||
/>
|
||||
),
|
||||
[isLastTurnGroup],
|
||||
);
|
||||
|
||||
const hasActivePackets = Boolean(activeStep && activeStep.packets.length > 0);
|
||||
const headerIsLast =
|
||||
isLastTurnGroup && (!shouldShowResults || !hasActivePackets);
|
||||
|
||||
return (
|
||||
<View className="w-full flex-col">
|
||||
<TimelineRow
|
||||
railVariant="rail"
|
||||
isFirst={isFirstTurnGroup}
|
||||
isLast={headerIsLast}
|
||||
icon={
|
||||
<Icon
|
||||
as={SvgBranch}
|
||||
size={timelineTokens.branchIconSize}
|
||||
className="text-text-02"
|
||||
/>
|
||||
}
|
||||
>
|
||||
<TimelineSurface className="flex-1" roundedBottom={headerIsLast}>
|
||||
<TimelineTopSpacer variant={topSpacerVariant} />
|
||||
<View
|
||||
className="flex-row items-center"
|
||||
style={{
|
||||
minHeight: timelineTokens.stepHeaderHeight,
|
||||
paddingLeft: timelineTokens.headerPaddingLeft,
|
||||
paddingRight: timelineTokens.headerPaddingRight,
|
||||
}}
|
||||
>
|
||||
<Tabs value={activeTab} onValueChange={setActiveTab}>
|
||||
<Tabs.List
|
||||
rightChildren={
|
||||
<Button
|
||||
prominence="tertiary"
|
||||
size="sm"
|
||||
onPress={handleToggle}
|
||||
icon={isExpanded ? SvgFold : SvgExpand}
|
||||
accessibilityLabel={
|
||||
isExpanded ? "Collapse branch" : "Expand branch"
|
||||
}
|
||||
/>
|
||||
}
|
||||
>
|
||||
{turnGroup.steps.map((step) => (
|
||||
<Tabs.Trigger
|
||||
key={step.key}
|
||||
value={step.key}
|
||||
icon={getToolIcon(step.packets)}
|
||||
isLoading={loadingStates.get(step.key)}
|
||||
>
|
||||
{getToolName(step.packets)}
|
||||
</Tabs.Trigger>
|
||||
))}
|
||||
</Tabs.List>
|
||||
</Tabs>
|
||||
</View>
|
||||
</TimelineSurface>
|
||||
</TimelineRow>
|
||||
|
||||
{shouldShowResults && activeStep ? (
|
||||
<TimelineRendererComponent
|
||||
// Re-key so switching tab or folding remounts the renderer at the right expansion.
|
||||
key={`${activeTab}-${isExpanded}`}
|
||||
packets={activeStep.packets}
|
||||
chatState={chatState}
|
||||
animate={!stopPacketSeen}
|
||||
stopPacketSeen={stopPacketSeen}
|
||||
stopReason={stopReason}
|
||||
defaultExpanded={isExpanded}
|
||||
isLastStep={isLastTurnGroup}
|
||||
>
|
||||
{renderTabContent}
|
||||
</TimelineRendererComponent>
|
||||
) : null}
|
||||
</View>
|
||||
);
|
||||
}
|
||||
|
||||
export default ParallelTimelineTabs;
|
||||
@@ -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 (
|
||||
<Animated.View style={{ opacity }}>
|
||||
<Text font="main-ui-action" color="text-03">
|
||||
{children}
|
||||
</Text>
|
||||
</Animated.View>
|
||||
);
|
||||
}
|
||||
|
||||
export default ShimmerText;
|
||||
@@ -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[]) => (
|
||||
<TimelineStepComposer
|
||||
results={results}
|
||||
isLastStep={isLastStep}
|
||||
isFirstStep={isFirstStep}
|
||||
isSingleStep={isSingleStep}
|
||||
collapsible
|
||||
getCollapsedIcon={getCollapsedIcon}
|
||||
/>
|
||||
),
|
||||
[isFirstStep, isLastStep, isSingleStep, getCollapsedIcon],
|
||||
);
|
||||
|
||||
return (
|
||||
<TimelineRendererComponent
|
||||
packets={step.packets}
|
||||
chatState={chatState}
|
||||
animate={!stopPacketSeen}
|
||||
stopPacketSeen={stopPacketSeen}
|
||||
stopReason={stopReason}
|
||||
defaultExpanded={isStreaming || (isSingleStep && !isPythonTool)}
|
||||
isLastStep={isLastStep}
|
||||
>
|
||||
{renderStep}
|
||||
</TimelineRendererComponent>
|
||||
);
|
||||
});
|
||||
|
||||
export default TimelineStep;
|
||||
@@ -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" ? (
|
||||
<Fragment key={index}>{result.content}</Fragment>
|
||||
) : (
|
||||
<StepContainer
|
||||
key={index}
|
||||
stepIcon={result.icon ?? undefined}
|
||||
header={result.status}
|
||||
isExpanded={result.isExpanded}
|
||||
onToggle={result.onToggle}
|
||||
collapsible={
|
||||
collapsible && (!isSingleStep || !!result.alwaysCollapsible)
|
||||
}
|
||||
supportsCollapsible={result.supportsCollapsible}
|
||||
isLastStep={index === results.length - 1 && isLastStep}
|
||||
isFirstStep={index === 0 && isFirstStep}
|
||||
hideHeader={
|
||||
results.length === 1 &&
|
||||
isSingleStep &&
|
||||
!result.supportsCollapsible
|
||||
}
|
||||
collapsedIcon={getCollapsedIcon?.(result)}
|
||||
noPaddingRight={result.noPaddingRight ?? false}
|
||||
surfaceBackground={result.surfaceBackground}
|
||||
>
|
||||
{result.content}
|
||||
</StepContainer>
|
||||
),
|
||||
)}
|
||||
</>
|
||||
);
|
||||
}
|
||||
|
||||
export default TimelineStepComposer;
|
||||
@@ -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(
|
||||
<StreamingHeader
|
||||
headerText="Thinking"
|
||||
collapsible
|
||||
isExpanded={false}
|
||||
onToggle={jest.fn()}
|
||||
{...overrides}
|
||||
/>,
|
||||
);
|
||||
}
|
||||
|
||||
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();
|
||||
});
|
||||
});
|
||||
@@ -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);
|
||||
});
|
||||
});
|
||||
@@ -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 (
|
||||
<Pressable
|
||||
accessibilityRole="button"
|
||||
accessibilityState={{ expanded: isExpanded }}
|
||||
onPress={onToggle}
|
||||
className="w-full flex-row items-center justify-between"
|
||||
>
|
||||
<View
|
||||
className="flex-row items-center gap-8"
|
||||
style={{
|
||||
paddingHorizontal: timelineTokens.headerTextPaddingX,
|
||||
paddingVertical: timelineTokens.headerTextPaddingY,
|
||||
}}
|
||||
>
|
||||
<Text font="main-ui-action" color="text-03">
|
||||
{isExpanded ? durationText : (imageText ?? durationText)}
|
||||
</Text>
|
||||
</View>
|
||||
|
||||
{showToggle ? (
|
||||
<Button
|
||||
prominence="tertiary"
|
||||
size="md"
|
||||
onPress={onToggle}
|
||||
rightIcon={isExpanded ? SvgFold : SvgExpand}
|
||||
>
|
||||
{`${totalSteps} ${totalSteps === 1 ? "step" : "steps"}`}
|
||||
</Button>
|
||||
) : null}
|
||||
</Pressable>
|
||||
);
|
||||
});
|
||||
|
||||
export default CompletedHeader;
|
||||
@@ -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 (
|
||||
<Tabs value={activeTab} onValueChange={onTabChange}>
|
||||
<Tabs.List
|
||||
rightChildren={
|
||||
collapsible ? (
|
||||
<Button
|
||||
prominence="tertiary"
|
||||
size="sm"
|
||||
onPress={onToggle}
|
||||
icon={isExpanded ? SvgFold : SvgExpand}
|
||||
accessibilityLabel={
|
||||
isExpanded ? "Collapse timeline" : "Expand timeline"
|
||||
}
|
||||
/>
|
||||
) : undefined
|
||||
}
|
||||
>
|
||||
{steps.map((step) => (
|
||||
<Tabs.Trigger
|
||||
key={step.key}
|
||||
value={step.key}
|
||||
icon={getToolIcon(step.packets)}
|
||||
isLoading={loadingStates.get(step.key)}
|
||||
>
|
||||
{getToolName(step.packets)}
|
||||
</Tabs.Trigger>
|
||||
))}
|
||||
</Tabs.List>
|
||||
</Tabs>
|
||||
);
|
||||
});
|
||||
|
||||
export default ParallelStreamingHeader;
|
||||
@@ -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 = (
|
||||
<View
|
||||
style={{
|
||||
paddingHorizontal: timelineTokens.headerTextPaddingX,
|
||||
paddingVertical: timelineTokens.headerTextPaddingY,
|
||||
}}
|
||||
>
|
||||
<Text font="main-ui-action" color="text-03">
|
||||
Interrupted Thinking
|
||||
</Text>
|
||||
</View>
|
||||
);
|
||||
|
||||
if (!isInteractive) {
|
||||
return (
|
||||
<View className="w-full flex-row items-center justify-between">
|
||||
{label}
|
||||
</View>
|
||||
);
|
||||
}
|
||||
|
||||
return (
|
||||
<Pressable
|
||||
accessibilityRole="button"
|
||||
accessibilityState={{ expanded: isExpanded }}
|
||||
onPress={onToggle}
|
||||
className="w-full flex-row items-center justify-between rounded-12"
|
||||
>
|
||||
{label}
|
||||
<Button
|
||||
prominence="tertiary"
|
||||
size="md"
|
||||
onPress={onToggle}
|
||||
rightIcon={isExpanded ? SvgFold : SvgExpand}
|
||||
>
|
||||
{`${totalSteps} ${totalSteps === 1 ? "step" : "steps"}`}
|
||||
</Button>
|
||||
</Pressable>
|
||||
);
|
||||
});
|
||||
|
||||
export default StoppedHeader;
|
||||
@@ -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 (
|
||||
<>
|
||||
<View
|
||||
style={{
|
||||
paddingHorizontal: timelineTokens.headerTextPaddingX,
|
||||
paddingVertical: timelineTokens.headerTextPaddingY,
|
||||
}}
|
||||
>
|
||||
<ShimmerText>{headerText}</ShimmerText>
|
||||
</View>
|
||||
|
||||
{collapsible ? (
|
||||
buttonTitle ? (
|
||||
<Button
|
||||
prominence="tertiary"
|
||||
size="md"
|
||||
onPress={onToggle}
|
||||
rightIcon={toggleIcon}
|
||||
>
|
||||
{buttonTitle}
|
||||
</Button>
|
||||
) : showElapsedTime ? (
|
||||
<Button
|
||||
prominence="tertiary"
|
||||
size="md"
|
||||
onPress={onToggle}
|
||||
rightIcon={SvgFold}
|
||||
>
|
||||
{formatDurationSeconds(elapsedSeconds)}
|
||||
</Button>
|
||||
) : (
|
||||
<Button
|
||||
prominence="tertiary"
|
||||
size="md"
|
||||
onPress={onToggle}
|
||||
icon={toggleIcon}
|
||||
accessibilityLabel={
|
||||
isExpanded ? "Collapse timeline" : "Expand timeline"
|
||||
}
|
||||
/>
|
||||
)
|
||||
) : null}
|
||||
</>
|
||||
);
|
||||
});
|
||||
|
||||
export default StreamingHeader;
|
||||
@@ -0,0 +1,31 @@
|
||||
// Reserves the connector's height so a tabbed group's first row lines up with the sequential steps
|
||||
// around it, which get that height from the connector itself.
|
||||
import { View } from "react-native";
|
||||
|
||||
import { timelineTokens } from "./timelineTokens";
|
||||
|
||||
export type TimelineTopSpacerVariant = "default" | "first" | "none";
|
||||
|
||||
export interface TimelineTopSpacerProps {
|
||||
variant?: TimelineTopSpacerVariant;
|
||||
}
|
||||
|
||||
export function TimelineTopSpacer({
|
||||
variant = "default",
|
||||
}: TimelineTopSpacerProps) {
|
||||
if (variant === "none") {
|
||||
return null;
|
||||
}
|
||||
return (
|
||||
<View
|
||||
style={{
|
||||
height:
|
||||
variant === "first"
|
||||
? timelineTokens.firstTopSpacerHeight
|
||||
: timelineTokens.topConnectorHeight,
|
||||
}}
|
||||
/>
|
||||
);
|
||||
}
|
||||
|
||||
export default TimelineTopSpacer;
|
||||
@@ -0,0 +1,59 @@
|
||||
// Port of web `toolDisplayHelpers.getToolIcon`, returning the component rather than web's JSX so
|
||||
// callers control size and colour through `Icon`.
|
||||
//
|
||||
// Web falls back to react-icons for two slots, against its own icon rule; mobile substitutes the
|
||||
// Onyx glyph `chat/tools.ts` already assigns — `FiTool` → `cpu` (its custom-tool fallback),
|
||||
// `FiList` → `text-lines-small`.
|
||||
import {
|
||||
PacketType,
|
||||
type Packet,
|
||||
type SearchToolStart,
|
||||
} from "@/chat/streamingModels";
|
||||
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 SvgUser from "@/icons/user";
|
||||
import type { IconFunctionComponent } from "@/icons/types";
|
||||
|
||||
export function getToolIcon(packets: Packet[]): IconFunctionComponent {
|
||||
const firstPacket = packets[0];
|
||||
if (!firstPacket) return SvgCircle;
|
||||
|
||||
switch (firstPacket.obj.type) {
|
||||
case PacketType.SEARCH_TOOL_START:
|
||||
// No search-state reducer yet (it lands with the search phase); the start packet already
|
||||
// carries the only field this branch reads.
|
||||
return (firstPacket.obj as SearchToolStart).is_internet_search
|
||||
? SvgGlobe
|
||||
: SvgSearch;
|
||||
case PacketType.PYTHON_TOOL_START:
|
||||
return SvgTerminalSmall;
|
||||
case PacketType.FETCH_TOOL_START:
|
||||
return SvgLink;
|
||||
case PacketType.CUSTOM_TOOL_START:
|
||||
return SvgCpu;
|
||||
case PacketType.IMAGE_GENERATION_TOOL_START:
|
||||
return SvgImage;
|
||||
case PacketType.DEEP_RESEARCH_PLAN_START:
|
||||
return SvgTextLinesSmall;
|
||||
case PacketType.RESEARCH_AGENT_START:
|
||||
return SvgUser;
|
||||
case PacketType.CODING_AGENT_START:
|
||||
return SvgCode;
|
||||
case PacketType.REASONING_START:
|
||||
return SvgSlowTime;
|
||||
case PacketType.MEMORY_TOOL_START:
|
||||
case PacketType.MEMORY_TOOL_NO_ACCESS:
|
||||
return SvgBookOpen;
|
||||
default:
|
||||
return SvgCircle;
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,74 @@
|
||||
import { describe, expect, it, jest } from "@jest/globals";
|
||||
import { fireEvent, render, screen } from "@testing-library/react-native";
|
||||
|
||||
import { Button } from "@/components/ui/button";
|
||||
import { Tabs } from "@/components/ui/tabs";
|
||||
import SvgGlobe from "@/icons/globe";
|
||||
import SvgLoader from "@/icons/loader";
|
||||
import SvgSearch from "@/icons/search";
|
||||
|
||||
// Button navigates via expo-router's `router`; stub it so the import resolves under jest.
|
||||
jest.mock("expo-router", () => ({ router: { navigate: jest.fn() } }));
|
||||
|
||||
function renderTabs(props: {
|
||||
value: string;
|
||||
onValueChange: (value: string) => void;
|
||||
loading?: boolean;
|
||||
rightChildren?: React.ReactNode;
|
||||
}) {
|
||||
return render(
|
||||
<Tabs value={props.value} onValueChange={props.onValueChange}>
|
||||
<Tabs.List rightChildren={props.rightChildren}>
|
||||
<Tabs.Trigger value="a" icon={SvgSearch} isLoading={props.loading}>
|
||||
Internal Search
|
||||
</Tabs.Trigger>
|
||||
<Tabs.Trigger value="b" icon={SvgGlobe}>
|
||||
Web Search
|
||||
</Tabs.Trigger>
|
||||
</Tabs.List>
|
||||
</Tabs>,
|
||||
);
|
||||
}
|
||||
|
||||
describe("Tabs", () => {
|
||||
it("renders a trigger per tab with its icon", () => {
|
||||
renderTabs({ value: "a", onValueChange: jest.fn() });
|
||||
expect(screen.getByText("Internal Search")).toBeTruthy();
|
||||
expect(screen.getByText("Web Search")).toBeTruthy();
|
||||
expect(screen.UNSAFE_queryByType(SvgSearch)).toBeTruthy();
|
||||
expect(screen.UNSAFE_queryByType(SvgGlobe)).toBeTruthy();
|
||||
});
|
||||
|
||||
it("marks only the active trigger as selected", () => {
|
||||
renderTabs({ value: "b", onValueChange: jest.fn() });
|
||||
const tabs = screen.getAllByRole("tab");
|
||||
expect(tabs[0]?.props.accessibilityState.selected).toBe(false);
|
||||
expect(tabs[1]?.props.accessibilityState.selected).toBe(true);
|
||||
});
|
||||
|
||||
it("reports the pressed tab's value", () => {
|
||||
const onValueChange = jest.fn();
|
||||
renderTabs({ value: "a", onValueChange });
|
||||
fireEvent.press(screen.getByText("Web Search"));
|
||||
expect(onValueChange).toHaveBeenCalledWith("b");
|
||||
});
|
||||
|
||||
it("renders the rightChildren slot outside the tab strip", () => {
|
||||
renderTabs({
|
||||
value: "a",
|
||||
onValueChange: jest.fn(),
|
||||
rightChildren: <Button prominence="tertiary">Fold</Button>,
|
||||
});
|
||||
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();
|
||||
});
|
||||
});
|
||||
@@ -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<TabsContextValue | null>(null);
|
||||
|
||||
function useTabsContext(): TabsContextValue {
|
||||
const context = useContext(TabsContext);
|
||||
if (!context) {
|
||||
throw new Error("Tabs.List / Tabs.Trigger must be rendered inside <Tabs>");
|
||||
}
|
||||
return context;
|
||||
}
|
||||
|
||||
// Split from TabsContext so a Trigger reporting its layout doesn't re-render every other Trigger.
|
||||
const LayoutsContext = createContext<Record<string, TriggerLayout>>({});
|
||||
|
||||
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<Record<string, TriggerLayout>>({});
|
||||
|
||||
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 (
|
||||
<TabsContext.Provider value={context}>
|
||||
<LayoutsContext.Provider value={layouts}>
|
||||
<View className="w-full">{children}</View>
|
||||
</LayoutsContext.Provider>
|
||||
</TabsContext.Provider>
|
||||
);
|
||||
}
|
||||
|
||||
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 (
|
||||
<View className="w-full flex-row items-center overflow-hidden bg-background-tint-00">
|
||||
<View className="min-w-0 flex-1">
|
||||
{/* Declared before the strip so the indicator paints over it; RN has no z-index, which is
|
||||
how Opal orders these two. */}
|
||||
<View className="absolute bottom-0 left-0 right-0 h-[1px] bg-border-02" />
|
||||
<ScrollView
|
||||
horizontal
|
||||
showsHorizontalScrollIndicator={false}
|
||||
// All inline: NativeWind maps `contentContainerClassName` onto this same prop, so passing
|
||||
// both would have them overwrite each other.
|
||||
contentContainerStyle={{
|
||||
flexDirection: "row",
|
||||
alignItems: "center",
|
||||
gap: TRIGGER_GAP,
|
||||
paddingBottom: LIST_BOTTOM_PADDING,
|
||||
}}
|
||||
>
|
||||
{children}
|
||||
{active ? (
|
||||
<Animated.View
|
||||
className="absolute bottom-0 bg-background-tint-inverted-03"
|
||||
style={{ height: INDICATOR_HEIGHT, left, width }}
|
||||
/>
|
||||
) : null}
|
||||
</ScrollView>
|
||||
</View>
|
||||
{rightChildren ? <View className="shrink-0">{rightChildren}</View> : null}
|
||||
</View>
|
||||
);
|
||||
}
|
||||
|
||||
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 (
|
||||
<Pressable
|
||||
accessibilityRole="tab"
|
||||
accessibilityState={{ selected }}
|
||||
onPress={() => 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 ? (
|
||||
<View className="p-2">
|
||||
<Icon as={icon} size={TRIGGER_ICON_SIZE} className={foreground} />
|
||||
</View>
|
||||
) : null}
|
||||
<View className="px-2">
|
||||
<Text
|
||||
font="secondary-action"
|
||||
color={selected ? "text-inverted-05" : "text-03"}
|
||||
nowrap
|
||||
>
|
||||
{children}
|
||||
</Text>
|
||||
</View>
|
||||
{isLoading ? (
|
||||
<View className="ml-4">
|
||||
<Spinner size={TRIGGER_SPINNER_SIZE} className={foreground} />
|
||||
</View>
|
||||
) : null}
|
||||
</Pressable>
|
||||
);
|
||||
}
|
||||
|
||||
export const Tabs = Object.assign(TabsRoot, {
|
||||
List: TabsList,
|
||||
Trigger: TabsTrigger,
|
||||
});
|
||||
@@ -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<void>((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(
|
||||
|
||||
@@ -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);
|
||||
});
|
||||
});
|
||||
@@ -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,
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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 = () =>
|
||||
|
||||
@@ -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,
|
||||
};
|
||||
}
|
||||
@@ -0,0 +1,23 @@
|
||||
import Svg, { Path } from "react-native-svg";
|
||||
|
||||
import type { IconProps } from "@/icons/types";
|
||||
|
||||
const SvgBookOpen = ({ size = 16, ...props }: IconProps) => (
|
||||
<Svg
|
||||
width={size}
|
||||
height={size}
|
||||
viewBox="0 0 16 16"
|
||||
fill="none"
|
||||
stroke="currentColor"
|
||||
{...props}
|
||||
>
|
||||
<Path
|
||||
d="M7.99999 4.66667C7.99999 3.95942 7.71904 3.28115 7.21895 2.78105C6.71885 2.28095 6.04057 2 5.33333 2H1.33333V12H5.99999C6.53043 12 7.03914 12.2107 7.41421 12.5858C7.78928 12.9609 7.99999 13.4696 7.99999 14M7.99999 4.66667V14M7.99999 4.66667C7.99999 3.95942 8.28095 3.28115 8.78104 2.78105C9.28114 2.28095 9.95942 2 10.6667 2H14.6667V12H9.99999C9.46956 12 8.96085 12.2107 8.58578 12.5858C8.21071 12.9609 7.99999 13.4696 7.99999 14"
|
||||
strokeWidth={1.5}
|
||||
strokeLinecap="round"
|
||||
strokeLinejoin="round"
|
||||
/>
|
||||
</Svg>
|
||||
);
|
||||
|
||||
export default SvgBookOpen;
|
||||
@@ -0,0 +1,23 @@
|
||||
import Svg, { Path } from "react-native-svg";
|
||||
|
||||
import type { IconProps } from "@/icons/types";
|
||||
|
||||
const SvgBranch = ({ size = 16, ...props }: IconProps) => (
|
||||
<Svg
|
||||
width={size}
|
||||
height={size}
|
||||
viewBox="0 0 16 16"
|
||||
fill="none"
|
||||
stroke="currentColor"
|
||||
{...props}
|
||||
>
|
||||
<Path
|
||||
d="M4.75001 5C5.71651 5 6.50001 4.2165 6.50001 3.25C6.50001 2.2835 5.7165 1.5 4.75 1.5C3.78351 1.5 3.00001 2.2835 3.00001 3.25C3.00001 4.2165 3.78351 5 4.75001 5ZM4.75001 5L4.75001 6.24999M4.75 11C3.7835 11 3 11.7835 3 12.75C3 13.7165 3.7835 14.5 4.75 14.5C5.7165 14.5 6.5 13.7165 6.5 12.75C6.5 11.7835 5.71649 11 4.75 11ZM4.75 11L4.75001 6.24999M10.5 8.74997C10.5 9.71646 11.2835 10.5 12.25 10.5C13.2165 10.5 14 9.71646 14 8.74997C14 7.78347 13.2165 7 12.25 7C11.2835 7 10.5 7.78347 10.5 8.74997ZM10.5 8.74997L7.25001 8.74999C5.8693 8.74999 4.75001 7.6307 4.75001 6.24999"
|
||||
strokeWidth={1.5}
|
||||
strokeLinecap="round"
|
||||
strokeLinejoin="round"
|
||||
/>
|
||||
</Svg>
|
||||
);
|
||||
|
||||
export default SvgBranch;
|
||||
@@ -0,0 +1,23 @@
|
||||
import Svg, { Path } from "react-native-svg";
|
||||
|
||||
import type { IconProps } from "@/icons/types";
|
||||
|
||||
const SvgCode = ({ size = 16, ...props }: IconProps) => (
|
||||
<Svg
|
||||
width={size}
|
||||
height={size}
|
||||
viewBox="0 0 16 16"
|
||||
fill="none"
|
||||
stroke="currentColor"
|
||||
{...props}
|
||||
>
|
||||
<Path
|
||||
d="M10.6667 12L14.6667 8L10.6667 4M5.33334 4L1.33334 8L5.33334 12"
|
||||
strokeWidth={1.5}
|
||||
strokeLinecap="round"
|
||||
strokeLinejoin="round"
|
||||
/>
|
||||
</Svg>
|
||||
);
|
||||
|
||||
export default SvgCode;
|
||||
@@ -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) => (
|
||||
<Svg
|
||||
width={size}
|
||||
height={size}
|
||||
viewBox="0 0 16 16"
|
||||
fill="none"
|
||||
stroke="currentColor"
|
||||
{...props}
|
||||
>
|
||||
<Path
|
||||
d="M8 4.00001V8.00001L11 9.5M13.1404 12.2453C11.9176 13.7243 10.0689 14.6667 7.99999 14.6667C6.70211 14.6667 5.49086 14.2958 4.46643 13.6542M14.4826 9.5624C14.6029 9.06125 14.6667 8.53806 14.6667 7.99999C14.6667 4.83387 12.4596 2.18324 9.5 1.50275M6.5 1.50275C5.76902 1.67082 5.08394 1.95908 4.46668 2.3456M2.34559 4.4667C1.95907 5.08396 1.67082 5.76903 1.50275 6.50001M1.50276 9.50001C1.67083 10.231 1.95909 10.916 2.34561 11.5333"
|
||||
strokeWidth={1.5}
|
||||
strokeLinecap="round"
|
||||
strokeLinejoin="round"
|
||||
/>
|
||||
</Svg>
|
||||
);
|
||||
|
||||
export default SvgSlowTime;
|
||||
@@ -0,0 +1,29 @@
|
||||
import Svg, { Path } from "react-native-svg";
|
||||
|
||||
import type { IconProps } from "@/icons/types";
|
||||
|
||||
const SvgStopCircle = ({ size = 16, ...props }: IconProps) => (
|
||||
<Svg
|
||||
width={size}
|
||||
height={size}
|
||||
viewBox="0 0 15 15"
|
||||
fill="none"
|
||||
stroke="currentColor"
|
||||
{...props}
|
||||
>
|
||||
<Path
|
||||
d="M7.41667 14.0833C11.0986 14.0833 14.0833 11.0986 14.0833 7.41667C14.0833 3.73477 11.0986 0.75 7.41667 0.75C3.73477 0.75 0.75 3.73477 0.75 7.41667C0.75 11.0986 3.73477 14.0833 7.41667 14.0833Z"
|
||||
strokeWidth={1.5}
|
||||
strokeLinecap="round"
|
||||
strokeLinejoin="round"
|
||||
/>
|
||||
<Path
|
||||
d="M9.41667 5.41667H5.41667V9.41667H9.41667V5.41667Z"
|
||||
strokeWidth={1.5}
|
||||
strokeLinecap="round"
|
||||
strokeLinejoin="round"
|
||||
/>
|
||||
</Svg>
|
||||
);
|
||||
|
||||
export default SvgStopCircle;
|
||||
@@ -0,0 +1,23 @@
|
||||
import Svg, { Path } from "react-native-svg";
|
||||
|
||||
import type { IconProps } from "@/icons/types";
|
||||
|
||||
const SvgUser = ({ size = 16, ...props }: IconProps) => (
|
||||
<Svg
|
||||
width={size}
|
||||
height={size}
|
||||
viewBox="0 0 16 16"
|
||||
fill="none"
|
||||
stroke="currentColor"
|
||||
{...props}
|
||||
>
|
||||
<Path
|
||||
d="M13.3333 14V12.6667C13.3333 11.9594 13.0524 11.2811 12.5523 10.781C12.0522 10.281 11.3739 10 10.6667 10H5.33334C4.62609 10 3.94782 10.281 3.44772 10.781C2.94762 11.2811 2.66667 11.9594 2.66667 12.6667V14M10.6667 4.66667C10.6667 6.13943 9.47276 7.33333 8.00001 7.33333C6.52725 7.33333 5.33334 6.13943 5.33334 4.66667C5.33334 3.19391 6.52725 2 8.00001 2C9.47276 2 10.6667 3.19391 10.6667 4.66667Z"
|
||||
strokeWidth={1.5}
|
||||
strokeLinecap="round"
|
||||
strokeLinejoin="round"
|
||||
/>
|
||||
</Svg>
|
||||
);
|
||||
|
||||
export default SvgUser;
|
||||
@@ -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");
|
||||
|
||||
@@ -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();
|
||||
|
||||
Reference in new issue
Block a user