Stabilize agent turn footer layout

This commit is contained in:
Mohamed Boudra
2026-05-15 15:19:00 +07:00
parent 9bf36cdb56
commit bb6403e426
7 changed files with 546 additions and 449 deletions

View File

@@ -26,7 +26,7 @@ export interface StreamHistoryBoundary {
export interface StreamRenderAuxiliary {
pendingPermissions: ReactNode;
workingIndicator: ReactNode;
turnFooter: ReactNode;
}
export interface AgentStreamRenderModel {
@@ -46,7 +46,7 @@ export interface BuildAgentStreamRenderModelInput {
const EMPTY_STREAM_ITEMS: StreamItem[] = [];
const EMPTY_AUXILIARY: StreamRenderAuxiliary = {
pendingPermissions: null,
workingIndicator: null,
turnFooter: null,
};
const orderedTailCache = new WeakMap<StreamItem[], Map<string, StreamItem[]>>();

View File

@@ -0,0 +1,273 @@
import React, { memo, useCallback, useEffect, useMemo, type ReactNode } from "react";
import { View } from "react-native";
import Animated, {
cancelAnimation,
Easing,
useAnimatedStyle,
useSharedValue,
withRepeat,
withTiming,
} from "react-native-reanimated";
import { StyleSheet } from "react-native-unistyles";
import { MAX_CONTENT_WIDTH } from "@/constants/layout";
import { findTurnHeaderForAssistantTurn } from "@/timeline/turn-time";
import type { StreamItem } from "@/types/stream";
import {
getWorkingIndicatorDotStrength,
WORKING_INDICATOR_CYCLE_MS,
WORKING_INDICATOR_OFFSETS,
} from "@/utils/working-indicator";
import {
collectAssistantTurnContentForStreamRenderStrategy,
type StreamStrategy,
} from "./agent-stream-render-strategy";
import { AssistantTurnFooter, LiveElapsed, STREAM_METADATA_FONT_SIZE } from "./message";
export type TurnContentStrategy = StreamStrategy;
export interface TurnFooterHost {
itemId: string;
items: StreamItem[];
startIndex: number;
}
export function resolveBottomTurnFooterHost(input: {
agentStatus: string;
history: StreamItem[];
liveHead: StreamItem[];
isInverted: boolean;
}): TurnFooterHost | null {
if (input.agentStatus === "running") {
return null;
}
const footerItems = input.liveHead.length > 0 ? input.liveHead : input.history;
const startIndex = input.isInverted ? 0 : footerItems.length - 1;
const item = footerItems[startIndex];
if (!item || item.kind !== "assistant_message") {
return null;
}
return {
itemId: item.id,
items: footerItems,
startIndex,
};
}
export function shouldRenderCompletedTurnFooter(input: {
item: StreamItem;
belowItem: StreamItem | undefined;
agentStatus: string;
suppressTurnFooter: boolean | undefined;
}): boolean {
return (
input.item.kind === "assistant_message" &&
!input.suppressTurnFooter &&
(input.belowItem?.kind === "user_message" ||
(input.belowItem === undefined && input.agentStatus !== "running"))
);
}
export const TurnFooter = memo(function TurnFooter({
isRunning,
inFlightTurnStartedAt,
host,
strategy,
}: {
isRunning: boolean;
inFlightTurnStartedAt: Date | null;
host: TurnFooterHost | null;
strategy: TurnContentStrategy;
}) {
if (isRunning) {
return (
<TurnFooterRow>
<RunningTurnFooter inFlightTurnStartedAt={inFlightTurnStartedAt} />
</TurnFooterRow>
);
}
if (!host) {
return null;
}
return (
<CompletedTurnFooterRow strategy={strategy} items={host.items} startIndex={host.startIndex} />
);
});
export const CompletedTurnFooterRow = memo(function CompletedTurnFooterRow({
strategy,
items,
startIndex,
}: {
strategy: TurnContentStrategy;
items: StreamItem[];
startIndex: number;
}) {
return (
<TurnFooterRow>
<CompletedTurnFooter strategy={strategy} items={items} startIndex={startIndex} />
</TurnFooterRow>
);
});
const WorkingIndicator = memo(function WorkingIndicator({
inFlightTurnStartedAt = null,
}: {
inFlightTurnStartedAt?: Date | null;
}) {
const progress = useSharedValue(0);
useEffect(() => {
progress.value = 0;
progress.value = withRepeat(
withTiming(1, {
duration: WORKING_INDICATOR_CYCLE_MS,
easing: Easing.linear,
}),
-1,
false,
);
return () => {
cancelAnimation(progress);
progress.value = 0;
};
}, [progress]);
const translateDistance = -2;
const dotOneStyle = useAnimatedStyle(() => {
const strength = getWorkingIndicatorDotStrength(progress.value, WORKING_INDICATOR_OFFSETS[0]);
return {
opacity: 0.3 + strength * 0.7,
transform: [{ translateY: strength * translateDistance }],
};
});
const dotTwoStyle = useAnimatedStyle(() => {
const strength = getWorkingIndicatorDotStrength(progress.value, WORKING_INDICATOR_OFFSETS[1]);
return {
opacity: 0.3 + strength * 0.7,
transform: [{ translateY: strength * translateDistance }],
};
});
const dotThreeStyle = useAnimatedStyle(() => {
const strength = getWorkingIndicatorDotStrength(progress.value, WORKING_INDICATOR_OFFSETS[2]);
return {
opacity: 0.3 + strength * 0.7,
transform: [{ translateY: strength * translateDistance }],
};
});
const dotOneCombinedStyle = useMemo(() => [stylesheet.workingDot, dotOneStyle], [dotOneStyle]);
const dotTwoCombinedStyle = useMemo(() => [stylesheet.workingDot, dotTwoStyle], [dotTwoStyle]);
const dotThreeCombinedStyle = useMemo(
() => [stylesheet.workingDot, dotThreeStyle],
[dotThreeStyle],
);
return (
<View style={stylesheet.turnFooterContent}>
<View style={stylesheet.workingDotsRow}>
<Animated.View style={dotOneCombinedStyle} />
<Animated.View style={dotTwoCombinedStyle} />
<Animated.View style={dotThreeCombinedStyle} />
</View>
{inFlightTurnStartedAt ? (
<LiveElapsed
startedAt={inFlightTurnStartedAt}
style={stylesheet.workingElapsed}
testID="turn-working-elapsed"
/>
) : null}
</View>
);
});
function RunningTurnFooter({ inFlightTurnStartedAt }: { inFlightTurnStartedAt: Date | null }) {
return (
<View style={stylesheet.turnFooterSlot} testID="turn-working-indicator">
<WorkingIndicator inFlightTurnStartedAt={inFlightTurnStartedAt} />
</View>
);
}
function CompletedTurnFooter({
strategy,
items,
startIndex,
}: {
strategy: TurnContentStrategy;
items: StreamItem[];
startIndex: number;
}) {
const getContent = useCallback(
() =>
collectAssistantTurnContentForStreamRenderStrategy({
strategy,
items,
startIndex,
}),
[strategy, items, startIndex],
);
const header = useMemo(
() => findTurnHeaderForAssistantTurn({ strategy, items, startIndex }),
[strategy, items, startIndex],
);
return (
<View style={stylesheet.turnFooterSlot}>
<AssistantTurnFooter
getContent={getContent}
startedAt={header?.startedAt}
durationMs={header?.durationMs}
/>
</View>
);
}
function TurnFooterRow({ children }: { children: ReactNode }) {
const rowStyle = useMemo(() => [stylesheet.streamItemWrapper, stylesheet.turnFooterRow], []);
return <View style={rowStyle}>{children}</View>;
}
const stylesheet = StyleSheet.create((theme) => ({
streamItemWrapper: {
width: "100%",
maxWidth: MAX_CONTENT_WIDTH,
alignSelf: "center",
paddingHorizontal: theme.spacing[2],
},
turnFooterRow: {
marginTop: theme.spacing[4],
},
turnFooterSlot: {
flexDirection: "row",
alignItems: "center",
alignSelf: "flex-start",
minHeight: 24,
paddingBottom: theme.spacing[6],
},
turnFooterContent: {
height: 24,
flexDirection: "row",
alignItems: "center",
justifyContent: "flex-start",
gap: theme.spacing[3],
},
workingElapsed: {
color: theme.colors.foregroundMuted,
fontSize: STREAM_METADATA_FONT_SIZE,
fontVariant: ["tabular-nums"],
},
workingDotsRow: {
flexDirection: "row",
alignItems: "center",
gap: theme.spacing[1],
transform: [{ translateY: 1 }],
},
workingDot: {
width: 6,
height: 6,
borderRadius: 3,
backgroundColor: theme.colors.foregroundMuted,
},
}));

View File

@@ -1,20 +1,6 @@
import { describe, expect, it } from "vitest";
import type { StreamItem } from "@/types/stream";
import type { NeighborResolver } from "./agent-stream-view-data";
import {
getAssistantBlockSpacing,
isSameAssistantBlockGroup,
resolveInlineWorkingIndicatorItemId,
} from "./agent-stream-view-data";
// Minimal forward-order resolver: "below" = next item in array.
// Matches web strategy rendering order (chronological, top-to-bottom).
const forwardStrategy: NeighborResolver = {
getNeighborItem(items, index, relation) {
const neighborIndex = relation === "below" ? index + 1 : index - 1;
return items[neighborIndex];
},
};
import { getAssistantBlockSpacing, isSameAssistantBlockGroup } from "./agent-stream-view-data";
function assistantBlock(params: {
id: string;
@@ -135,46 +121,3 @@ describe("getAssistantBlockSpacing", () => {
).toBe("compactTop");
});
});
describe("resolveInlineWorkingIndicatorItemId", () => {
it("returns null when the agent is not running", () => {
const head = assistantBlock({ id: "head", blockGroupId: "group-1", blockIndex: 0 });
expect(resolveInlineWorkingIndicatorItemId("idle", [head], forwardStrategy)).toBeNull();
});
it("returns the last assistant block id when running with a single head block", () => {
const head = assistantBlock({ id: "group-1:head", blockGroupId: "group-1", blockIndex: 0 });
expect(resolveInlineWorkingIndicatorItemId("running", [head], forwardStrategy)).toBe(
"group-1:head",
);
});
it("returns null when live head contains only a tool call (uses auxiliary indicator instead)", () => {
const tc = toolCallBlock("tool-1");
expect(resolveInlineWorkingIndicatorItemId("running", [tc], forwardStrategy)).toBeNull();
});
it("returns the footer assistant block when history and streaming head coexist", () => {
const historyBlock = assistantBlock({
id: "group-1:block:0",
blockGroupId: "group-1",
blockIndex: 0,
});
const streamingBlock = assistantBlock({
id: "group-2:head",
blockGroupId: "group-2",
blockIndex: 0,
});
// historyBlock is in streamItems (tail), not liveHead — liveHead holds only the streaming block
expect(resolveInlineWorkingIndicatorItemId("running", [streamingBlock], forwardStrategy)).toBe(
"group-2:head",
);
expect(
resolveInlineWorkingIndicatorItemId(
"running",
[historyBlock, streamingBlock],
forwardStrategy,
),
).toBe("group-2:head");
});
});

View File

@@ -1,4 +1,5 @@
import type { StreamItem } from "@/types/stream";
import { SPACING } from "@/styles/theme";
export function isSameAssistantBlockGroup(params: {
item: StreamItem | null | undefined;
@@ -28,24 +29,35 @@ export function getAssistantBlockSpacing(params: {
return "default";
}
export interface NeighborResolver {
getNeighborItem(
items: StreamItem[],
index: number,
relation: "above" | "below",
): StreamItem | undefined;
}
const isUserMessageItem = (item?: StreamItem | null) => item?.kind === "user_message";
const isToolSequenceItem = (item?: StreamItem | null) =>
item?.kind === "tool_call" || item?.kind === "thought" || item?.kind === "todo_list";
// null → auxiliary working indicator; non-null → inline footer on that block.
export function resolveInlineWorkingIndicatorItemId(
status: string,
liveHeadItems: StreamItem[],
strategy: NeighborResolver,
): string | null {
if (status !== "running") return null;
const footerItem = liveHeadItems.find((item, index, items) => {
if (item.kind !== "assistant_message") return false;
return strategy.getNeighborItem(items, index, "below") === undefined;
});
return footerItem?.id ?? null;
export function getGapBetweenStreamItems(
item: StreamItem | null,
belowItem: StreamItem | null,
): number {
if (!item || !belowItem) {
return 0;
}
if (isUserMessageItem(item) && isUserMessageItem(belowItem)) {
return SPACING[1];
}
if (isToolSequenceItem(item) && isToolSequenceItem(belowItem)) {
return 0;
}
if (item.kind === "user_message" && isToolSequenceItem(belowItem)) {
return SPACING[4];
}
if (item.kind === "assistant_message" && isToolSequenceItem(belowItem)) {
return SPACING[1];
}
if (isToolSequenceItem(item) && belowItem.kind === "assistant_message") {
return SPACING[1];
}
if (isSameAssistantBlockGroup({ item, other: belowItem })) {
return SPACING[3];
}
return SPACING[4];
}

View File

@@ -17,34 +17,24 @@ import {
Platform,
ActivityIndicator,
type PressableStateCallbackType,
type StyleProp,
type ViewStyle,
} from "react-native";
import { StyleSheet, withUnistyles } from "react-native-unistyles";
import { useIsCompactFormFactor } from "@/constants/layout";
import { useMutation } from "@tanstack/react-query";
import Animated, {
FadeIn,
FadeOut,
cancelAnimation,
Easing,
useAnimatedStyle,
useSharedValue,
withRepeat,
withTiming,
} from "react-native-reanimated";
import Animated, { FadeIn, FadeOut } from "react-native-reanimated";
import { Check, ChevronDown, X } from "lucide-react-native";
import { usePanelStore } from "@/stores/panel-store";
import {
AssistantMessage,
AssistantTurnFooter,
SpeakMessage,
UserMessage,
ActivityLog,
ToolCall,
TodoListCard,
CompactionMarker,
LiveElapsed,
MessageOuterSpacingProvider,
STREAM_METADATA_FONT_SIZE,
type InlinePathTarget,
} from "./message";
import { PlanCard } from "./plan-card";
@@ -65,18 +55,21 @@ import { QuestionFormCard } from "./question-form-card";
import { ToolCallSheetProvider } from "./tool-call-sheet";
import {
buildAgentStreamRenderModel,
collectAssistantTurnContentForStreamRenderStrategy,
getStreamNeighborItem,
resolveStreamRenderStrategy,
type AgentStreamRenderModel,
type StreamSegmentRenderers,
type StreamViewportHandle,
} from "./agent-stream-render-strategy";
import { getAssistantBlockSpacing, getGapBetweenStreamItems } from "./agent-stream-view-data";
import {
getAssistantBlockSpacing,
isSameAssistantBlockGroup,
resolveInlineWorkingIndicatorItemId,
} from "./agent-stream-view-data";
CompletedTurnFooterRow,
resolveBottomTurnFooterHost,
shouldRenderCompletedTurnFooter,
TurnFooter,
type TurnContentStrategy,
type TurnFooterHost,
} from "./agent-stream-turn-footer";
import {
type BottomAnchorLocalRequest,
type BottomAnchorRouteRequest,
@@ -86,22 +79,153 @@ import { normalizeInlinePathTarget } from "@/utils/inline-path";
import { resolveWorkspaceIdByExecutionDirectory } from "@/utils/workspace-execution";
import { navigateToPreparedWorkspaceTab } from "@/utils/workspace-navigation";
import { useStableEvent } from "@/hooks/use-stable-event";
import {
getWorkingIndicatorDotStrength,
WORKING_INDICATOR_CYCLE_MS,
WORKING_INDICATOR_OFFSETS,
} from "@/utils/working-indicator";
import { findInFlightTurnStartedAt, findTurnHeaderForAssistantTurn } from "@/timeline/turn-time";
import { findInFlightTurnStartedAt } from "@/timeline/turn-time";
import { isWeb } from "@/constants/platform";
import { SPACING, type Theme } from "@/styles/theme";
const isUserMessageItem = (item?: StreamItem) => item?.kind === "user_message";
const isToolSequenceItem = (item?: StreamItem) =>
item?.kind === "tool_call" || item?.kind === "thought" || item?.kind === "todo_list";
import type { Theme } from "@/styles/theme";
interface StreamItemBoundarySeams {
aboveItem?: StreamItem | null;
belowItem?: StreamItem | null;
suppressTurnFooter?: boolean;
}
function renderLiveAuxiliaryNode(input: {
pendingPermissions: ReactNode;
turnFooter: ReactNode;
}): ReactNode {
if (!input.pendingPermissions && !input.turnFooter) {
return null;
}
return (
<>
{input.turnFooter}
{input.pendingPermissions ? (
<View style={stylesheet.contentWrapper}>
<View style={stylesheet.listHeaderContent}>{input.pendingPermissions}</View>
</View>
) : null}
</>
);
}
function renderPendingPermissionsNode(input: {
pendingPermissions: PendingPermission[];
client: DaemonClient | null;
}): ReactNode {
if (input.pendingPermissions.length === 0) {
return null;
}
return (
<View style={stylesheet.permissionsContainer}>
{input.pendingPermissions.map((permission) => (
<PermissionRequestCard key={permission.key} permission={permission} client={input.client} />
))}
</View>
);
}
function renderStreamItemWithTurnFooter(input: {
content: ReactNode;
item: StreamItem;
nextItem: StreamItem | undefined;
items: StreamItem[];
index: number;
agentStatus: string;
suppressTurnFooter: boolean | undefined;
strategy: TurnContentStrategy;
}): ReactNode {
if (!input.content) {
return null;
}
const showCompletedFooter = shouldRenderCompletedTurnFooter({
item: input.item,
belowItem: input.nextItem,
agentStatus: input.agentStatus,
suppressTurnFooter: input.suppressTurnFooter,
});
const gapBelow = showCompletedFooter
? 0
: getGapBetweenStreamItems(input.item, input.nextItem ?? null);
return (
<>
<StreamItemWrapper gapBelow={gapBelow}>{input.content}</StreamItemWrapper>
{showCompletedFooter ? (
<CompletedTurnFooterRow
strategy={input.strategy}
items={input.items}
startIndex={input.index}
/>
) : null}
</>
);
}
function renderListEmptyComponent(input: {
renderModel: AgentStreamRenderModel;
emptyStateStyle: StyleProp<ViewStyle>;
}): ReactNode {
if (
input.renderModel.boundary.hasVirtualizedHistory ||
input.renderModel.boundary.hasMountedHistory ||
input.renderModel.boundary.hasLiveHead ||
input.renderModel.auxiliary.pendingPermissions ||
input.renderModel.auxiliary.turnFooter
) {
return null;
}
return (
<View style={input.emptyStateStyle}>
<Text style={stylesheet.emptyStateText}>Start chatting with this agent...</Text>
</View>
);
}
function renderHistoryStreamItem(input: {
item: StreamItem;
historyIndexById: Map<string, number>;
historyItems: StreamItem[];
lastHistoryItem: StreamItem | null;
firstLiveHeadItem: StreamItem | null;
bottomTurnFooterHost: TurnFooterHost | null;
renderStreamItem: (
item: StreamItem,
index: number,
items: StreamItem[],
seams?: StreamItemBoundarySeams,
) => ReactNode;
}): ReactNode {
const historyIndex = input.historyIndexById.get(input.item.id);
if (historyIndex === undefined) {
return null;
}
const seamBelowItem =
input.item.id === input.lastHistoryItem?.id ? input.firstLiveHeadItem : null;
return input.renderStreamItem(input.item, historyIndex, input.historyItems, {
belowItem: seamBelowItem,
suppressTurnFooter: input.item.id === input.bottomTurnFooterHost?.itemId,
});
}
function renderLiveHeadStreamItem(input: {
item: StreamItem;
index: number;
items: StreamItem[];
lastHistoryItem: StreamItem | null;
bottomTurnFooterHost: TurnFooterHost | null;
renderStreamItem: (
item: StreamItem,
index: number,
items: StreamItem[],
seams?: StreamItemBoundarySeams,
) => ReactNode;
}): ReactNode {
return input.renderStreamItem(input.item, input.index, input.items, {
aboveItem: input.index === 0 ? input.lastHistoryItem : null,
suppressTurnFooter: input.item.id === input.bottomTurnFooterHost?.itemId,
});
}
export interface AgentStreamViewHandle {
@@ -268,15 +392,6 @@ const AgentStreamViewComponent = forwardRef<AgentStreamViewHandle, AgentStreamVi
isMobileBreakpoint: isMobile,
});
}, [isMobile, streamHead, streamItems]);
const inlineWorkingIndicatorItemId = useMemo(
() =>
resolveInlineWorkingIndicatorItemId(
agent.status,
baseRenderModel.segments.liveHead,
streamRenderStrategy,
),
[agent.status, baseRenderModel.segments.liveHead, streamRenderStrategy],
);
const inFlightTurnStartedAt = useMemo(
() =>
findInFlightTurnStartedAt({
@@ -303,39 +418,6 @@ const AgentStreamViewComponent = forwardRef<AgentStreamViewHandle, AgentStreamVi
viewportRef.current?.scrollToBottom("jump-to-bottom");
}, []);
const tightGap = SPACING[1];
const assistantBlockGap = SPACING[3];
const looseGap = SPACING[4];
const getGapBetween = useCallback(
(item: StreamItem | null, belowItem: StreamItem | null) => {
if (!item || !belowItem) {
return 0;
}
if (isUserMessageItem(item) && isUserMessageItem(belowItem)) {
return tightGap;
}
if (isToolSequenceItem(item) && isToolSequenceItem(belowItem)) {
return 0;
}
if (item.kind === "user_message" && isToolSequenceItem(belowItem)) {
return looseGap;
}
if (item.kind === "assistant_message" && isToolSequenceItem(belowItem)) {
return tightGap;
}
if (isToolSequenceItem(item) && belowItem.kind === "assistant_message") {
return tightGap;
}
if (isSameAssistantBlockGroup({ item, other: belowItem })) {
return assistantBlockGap;
}
return looseGap;
},
[assistantBlockGap, looseGap, tightGap],
);
const setInlineDetailsExpanded = useCallback(
(itemId: string, expanded: boolean) => {
if (!streamRenderStrategy.shouldDisableParentScrollOnInlineDetailsExpansion()) {
@@ -563,7 +645,7 @@ const AgentStreamViewComponent = forwardRef<AgentStreamViewHandle, AgentStreamVi
case "turn_header":
// Purely a data carrier - duration renders next to the assistant
// turn's copy button (TurnCopyButtonSlot), and the in-flight
// turn's copy button (CompletedTurnFooter), and the in-flight
// ticker renders next to the WorkingIndicator dots.
return null;
@@ -574,6 +656,20 @@ const AgentStreamViewComponent = forwardRef<AgentStreamViewHandle, AgentStreamVi
[renderUserMessageItem, renderAssistantMessageItem, renderThoughtItem, renderToolCallItem],
);
const bottomTurnFooterHost = useMemo(() => {
return resolveBottomTurnFooterHost({
agentStatus: agent.status,
history: baseRenderModel.history,
liveHead: baseRenderModel.segments.liveHead,
isInverted: streamRenderStrategy.getFlatListInverted(),
});
}, [
agent.status,
baseRenderModel.history,
baseRenderModel.segments.liveHead,
streamRenderStrategy,
]);
const renderStreamItem = useCallback(
(
item: StreamItem,
@@ -582,47 +678,24 @@ const AgentStreamViewComponent = forwardRef<AgentStreamViewHandle, AgentStreamVi
seams: StreamItemBoundarySeams = {},
) => {
const content = renderStreamItemContent(item, index, items, seams);
if (!content) {
return null;
}
const nextItem = getStreamNeighborItem({
strategy: streamRenderStrategy,
items,
index,
relation: "below",
});
const gapBelow = getGapBetween(item, nextItem ?? null);
const isEndOfAssistantTurn =
item.kind === "assistant_message" &&
(nextItem?.kind === "user_message" ||
(nextItem === undefined && agent.status !== "running"));
const isRunningAssistantTurnFooter =
item.kind === "assistant_message" && item.id === inlineWorkingIndicatorItemId;
let footer: ReactNode = null;
if (isRunningAssistantTurnFooter) {
footer = <InlineWorkingIndicatorSlot inFlightTurnStartedAt={inFlightTurnStartedAt} />;
} else if (isEndOfAssistantTurn) {
footer = (
<TurnCopyButtonSlot strategy={streamRenderStrategy} items={items} startIndex={index} />
);
}
return (
<StreamItemWrapper gapBelow={gapBelow}>
{content}
{footer}
</StreamItemWrapper>
);
return renderStreamItemWithTurnFooter({
content,
item,
nextItem,
items,
index,
agentStatus: agent.status,
suppressTurnFooter: seams.suppressTurnFooter,
strategy: streamRenderStrategy,
});
},
[
getGapBetween,
renderStreamItemContent,
agent.status,
streamRenderStrategy,
inlineWorkingIndicatorItemId,
inFlightTurnStartedAt,
],
[renderStreamItemContent, agent.status, streamRenderStrategy],
);
const pendingPermissionItems = useMemo(
@@ -630,63 +703,49 @@ const AgentStreamViewComponent = forwardRef<AgentStreamViewHandle, AgentStreamVi
[pendingPermissions, agentId],
);
const showAuxiliaryWorkingIndicator =
agent.status === "running" && inlineWorkingIndicatorItemId === null;
const showRunningTurnFooter = agent.status === "running";
const pendingPermissionsNode = useMemo(
() =>
pendingPermissionItems.length > 0 ? (
<View style={stylesheet.permissionsContainer}>
{pendingPermissionItems.map((permission) => (
<PermissionRequestCard key={permission.key} permission={permission} client={client} />
))}
</View>
) : null,
renderPendingPermissionsNode({
pendingPermissions: pendingPermissionItems,
client,
}),
[client, pendingPermissionItems],
);
const workingIndicatorNode = useMemo(
const turnFooterNode = useMemo(
() =>
showAuxiliaryWorkingIndicator ? (
<View style={stylesheet.bottomBarWrapper} testID="stream-working-indicator-auxiliary">
<WorkingIndicator inFlightTurnStartedAt={inFlightTurnStartedAt} />
</View>
showRunningTurnFooter || bottomTurnFooterHost ? (
<TurnFooter
isRunning={showRunningTurnFooter}
inFlightTurnStartedAt={inFlightTurnStartedAt}
host={bottomTurnFooterHost}
strategy={streamRenderStrategy}
/>
) : null,
[showAuxiliaryWorkingIndicator, inFlightTurnStartedAt],
[showRunningTurnFooter, inFlightTurnStartedAt, bottomTurnFooterHost, streamRenderStrategy],
);
const renderModel = useMemo<AgentStreamRenderModel>(() => {
return {
...baseRenderModel,
boundary: {
...baseRenderModel.boundary,
historyToHeadGap: getGapBetween(
historyToHeadGap: getGapBetweenStreamItems(
baseRenderModel.history.at(-1) ?? null,
baseRenderModel.segments.liveHead[0] ?? null,
),
},
auxiliary: {
pendingPermissions: pendingPermissionsNode,
workingIndicator: workingIndicatorNode,
turnFooter: turnFooterNode,
},
};
}, [baseRenderModel, getGapBetween, pendingPermissionsNode, workingIndicatorNode]);
}, [baseRenderModel, pendingPermissionsNode, turnFooterNode]);
const emptyStateStyle = useMemo(() => [stylesheet.emptyState, stylesheet.contentWrapper], []);
const listEmptyComponent = useMemo(() => {
if (
renderModel.boundary.hasVirtualizedHistory ||
renderModel.boundary.hasMountedHistory ||
renderModel.boundary.hasLiveHead ||
renderModel.auxiliary.pendingPermissions ||
renderModel.auxiliary.workingIndicator
) {
return null;
}
return (
<View style={emptyStateStyle}>
<Text style={stylesheet.emptyStateText}>Start chatting with this agent...</Text>
</View>
);
}, [renderModel, emptyStateStyle]);
const listEmptyComponent = useMemo(
() => renderListEmptyComponent({ renderModel, emptyStateStyle }),
[renderModel, emptyStateStyle],
);
const historyItems = renderModel.history;
const _liveHeadItems = renderModel.segments.liveHead;
@@ -703,17 +762,24 @@ const AgentStreamViewComponent = forwardRef<AgentStreamViewHandle, AgentStreamVi
}, [historyItems]);
const renderHistoryRow = useCallback(
(item: StreamItem) => {
const historyIndex = historyIndexById.get(item.id);
if (historyIndex === undefined) {
return null;
}
const seamBelowItem = item.id === lastHistoryItem?.id ? firstLiveHeadItem : null;
return renderStreamItem(item, historyIndex, historyItems, {
belowItem: seamBelowItem,
});
},
[firstLiveHeadItem, historyIndexById, historyItems, lastHistoryItem?.id, renderStreamItem],
(item: StreamItem) =>
renderHistoryStreamItem({
item,
historyIndexById,
historyItems,
lastHistoryItem,
firstLiveHeadItem,
bottomTurnFooterHost,
renderStreamItem,
}),
[
bottomTurnFooterHost,
firstLiveHeadItem,
historyIndexById,
historyItems,
lastHistoryItem,
renderStreamItem,
],
);
const renderHistoryVirtualizedRow = useCallback<
@@ -725,32 +791,22 @@ const AgentStreamViewComponent = forwardRef<AgentStreamViewHandle, AgentStreamVi
);
const renderLiveHeadRow = useCallback<StreamSegmentRenderers["renderLiveHeadRow"]>(
(item, index, items) =>
renderStreamItem(item, index, items, {
aboveItem: index === 0 ? lastHistoryItem : null,
renderLiveHeadStreamItem({
item,
index,
items,
lastHistoryItem,
bottomTurnFooterHost,
renderStreamItem,
}),
[lastHistoryItem, renderStreamItem],
[bottomTurnFooterHost, lastHistoryItem, renderStreamItem],
);
const liveAuxiliaryHeaderStyle = useMemo(() => {
let headerPadding: { paddingBottom: number } | { paddingTop: number } | null;
if (!boundary.hasLiveHead) headerPadding = null;
else if (streamRenderStrategy.getFlatListInverted())
headerPadding = { paddingBottom: looseGap };
else headerPadding = { paddingTop: looseGap };
return [stylesheet.listHeaderContent, headerPadding];
}, [boundary.hasLiveHead, streamRenderStrategy, looseGap]);
const renderLiveAuxiliary = useCallback<StreamSegmentRenderers["renderLiveAuxiliary"]>(() => {
if (!auxiliary.pendingPermissions && !auxiliary.workingIndicator) {
return null;
}
return (
<View style={stylesheet.contentWrapper}>
<View style={liveAuxiliaryHeaderStyle}>
{auxiliary.pendingPermissions}
{auxiliary.workingIndicator}
</View>
</View>
);
}, [auxiliary.pendingPermissions, auxiliary.workingIndicator, liveAuxiliaryHeaderStyle]);
return renderLiveAuxiliaryNode({
pendingPermissions: auxiliary.pendingPermissions,
turnFooter: auxiliary.turnFooter,
});
}, [auxiliary.pendingPermissions, auxiliary.turnFooter]);
const renderers = useMemo<StreamSegmentRenderers>(
() => ({
@@ -822,135 +878,6 @@ const AgentStreamViewComponent = forwardRef<AgentStreamViewHandle, AgentStreamVi
export const AgentStreamView = memo(AgentStreamViewComponent);
AgentStreamView.displayName = "AgentStreamView";
function WorkingIndicator({
variant = "auxiliary",
inFlightTurnStartedAt = null,
}: {
variant?: "auxiliary" | "inline";
inFlightTurnStartedAt?: Date | null;
}) {
const progress = useSharedValue(0);
useEffect(() => {
progress.value = 0;
progress.value = withRepeat(
withTiming(1, {
duration: WORKING_INDICATOR_CYCLE_MS,
easing: Easing.linear,
}),
-1,
false,
);
return () => {
cancelAnimation(progress);
progress.value = 0;
};
}, [progress]);
const translateDistance = -2;
const dotOneStyle = useAnimatedStyle(() => {
const strength = getWorkingIndicatorDotStrength(progress.value, WORKING_INDICATOR_OFFSETS[0]);
return {
opacity: 0.3 + strength * 0.7,
transform: [{ translateY: strength * translateDistance }],
};
});
const dotTwoStyle = useAnimatedStyle(() => {
const strength = getWorkingIndicatorDotStrength(progress.value, WORKING_INDICATOR_OFFSETS[1]);
return {
opacity: 0.3 + strength * 0.7,
transform: [{ translateY: strength * translateDistance }],
};
});
const dotThreeStyle = useAnimatedStyle(() => {
const strength = getWorkingIndicatorDotStrength(progress.value, WORKING_INDICATOR_OFFSETS[2]);
return {
opacity: 0.3 + strength * 0.7,
transform: [{ translateY: strength * translateDistance }],
};
});
const dotOneCombinedStyle = useMemo(() => [stylesheet.workingDot, dotOneStyle], [dotOneStyle]);
const dotTwoCombinedStyle = useMemo(() => [stylesheet.workingDot, dotTwoStyle], [dotTwoStyle]);
const dotThreeCombinedStyle = useMemo(
() => [stylesheet.workingDot, dotThreeStyle],
[dotThreeStyle],
);
const containerStyle =
variant === "inline"
? stylesheet.inlineWorkingIndicatorFrame
: stylesheet.workingIndicatorBubble;
return (
<View style={containerStyle}>
<View style={stylesheet.workingDotsRow}>
<Animated.View style={dotOneCombinedStyle} />
<Animated.View style={dotTwoCombinedStyle} />
<Animated.View style={dotThreeCombinedStyle} />
</View>
{inFlightTurnStartedAt ? (
<LiveElapsed
startedAt={inFlightTurnStartedAt}
style={stylesheet.workingElapsed}
testID="turn-working-elapsed"
/>
) : null}
</View>
);
}
function InlineWorkingIndicatorSlot({
inFlightTurnStartedAt,
}: {
inFlightTurnStartedAt: Date | null;
}) {
return (
<View style={stylesheet.turnFooterSlot} testID="turn-working-indicator">
<WorkingIndicator variant="inline" inFlightTurnStartedAt={inFlightTurnStartedAt} />
</View>
);
}
// Permission Request Card Component
type TurnContentStrategy = Parameters<
typeof collectAssistantTurnContentForStreamRenderStrategy
>[0]["strategy"];
interface TurnCopyButtonSlotProps {
strategy: TurnContentStrategy;
items: StreamItem[];
startIndex: number;
}
function TurnCopyButtonSlot({ strategy, items, startIndex }: TurnCopyButtonSlotProps) {
const getContent = useCallback(
() =>
collectAssistantTurnContentForStreamRenderStrategy({
strategy,
items,
startIndex,
}),
[strategy, items, startIndex],
);
const header = useMemo(
() => findTurnHeaderForAssistantTurn({ strategy, items, startIndex }),
[strategy, items, startIndex],
);
return (
<View style={stylesheet.turnFooterSlot}>
<AssistantTurnFooter
getContent={getContent}
startedAt={header?.startedAt}
durationMs={header?.durationMs}
/>
</View>
);
}
interface ToolCallSlotProps extends Omit<
ComponentProps<typeof ToolCall>,
"onInlineDetailsExpandedChange"
@@ -1282,57 +1209,6 @@ const stylesheet = StyleSheet.create((theme) => ({
listHeaderContent: {
gap: theme.spacing[3],
},
bottomBarWrapper: {
flexDirection: "row",
alignItems: "center",
justifyContent: "flex-start",
marginTop: theme.spacing[4],
paddingTop: theme.spacing[3],
paddingBottom: theme.spacing[2],
gap: theme.spacing[2],
},
turnFooterSlot: {
flexDirection: "row",
alignItems: "center",
alignSelf: "flex-start",
minHeight: 24,
marginTop: theme.spacing[1],
paddingBottom: theme.spacing[6],
},
inlineWorkingIndicatorFrame: {
height: 24,
flexDirection: "row",
alignItems: "center",
justifyContent: "flex-start",
gap: theme.spacing[3],
},
workingElapsed: {
color: theme.colors.foregroundMuted,
fontSize: STREAM_METADATA_FONT_SIZE,
fontVariant: ["tabular-nums"],
},
workingIndicatorBubble: {
flexDirection: "row",
alignItems: "center",
gap: theme.spacing[3],
paddingVertical: theme.spacing[1],
borderRadius: theme.borderRadius.full,
backgroundColor: "transparent",
borderWidth: 0,
alignSelf: "flex-start",
},
workingDotsRow: {
flexDirection: "row",
alignItems: "center",
gap: theme.spacing[1],
transform: [{ translateY: 1 }],
},
workingDot: {
width: 6,
height: 6,
borderRadius: 3,
backgroundColor: theme.colors.foregroundMuted,
},
syncingIndicator: {
flexDirection: "row",
alignItems: "center",

View File

@@ -2,13 +2,10 @@ import { describe, it, expect } from "vitest";
import { formatDuration, formatMessageTimestamp } from "./time";
describe("formatDuration", () => {
it("renders 0-10s with one decimal", () => {
expect(formatDuration(0)).toBe("0.0s");
expect(formatDuration(5_600)).toBe("5.6s");
expect(formatDuration(9_900)).toBe("9.9s");
});
it("renders 10s-60s as whole seconds", () => {
it("renders sub-minute durations as whole seconds", () => {
expect(formatDuration(0)).toBe("0s");
expect(formatDuration(5_600)).toBe("5s");
expect(formatDuration(9_900)).toBe("9s");
expect(formatDuration(10_400)).toBe("10s");
expect(formatDuration(12_340)).toBe("12s");
expect(formatDuration(47_000)).toBe("47s");
@@ -26,8 +23,8 @@ describe("formatDuration", () => {
});
it("guards against negative and NaN", () => {
expect(formatDuration(-1)).toBe("0.0s");
expect(formatDuration(Number.NaN)).toBe("0.0s");
expect(formatDuration(-1)).toBe("0s");
expect(formatDuration(Number.NaN)).toBe("0s");
});
});

View File

@@ -92,19 +92,15 @@ export function formatMessageTimestamp(date: Date, now: Date = new Date()): stri
/**
* Format a duration as a compact human-readable string.
* - 0-10s: one decimal ("3.4s")
* - 10s-60s: whole seconds ("47s")
* - 0-60s: whole seconds ("47s")
* - Minutes/hours: integers only ("2m 12s", "1h 5m")
*/
export function formatDuration(durationMs: number): string {
if (!Number.isFinite(durationMs) || durationMs < 0) {
return "0.0s";
return "0s";
}
const totalSeconds = durationMs / 1000;
if (totalSeconds < 10) {
return `${totalSeconds.toFixed(1)}s`;
}
if (totalSeconds < 60) {
return `${Math.floor(totalSeconds)}s`;
}