mirror of
https://github.com/getpaseo/paseo.git
synced 2026-07-29 12:01:31 +00:00
Stabilize agent turn footer layout
This commit is contained in:
@@ -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[]>>();
|
||||
|
||||
273
packages/app/src/components/agent-stream-turn-footer.tsx
Normal file
273
packages/app/src/components/agent-stream-turn-footer.tsx
Normal 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,
|
||||
},
|
||||
}));
|
||||
@@ -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");
|
||||
});
|
||||
});
|
||||
|
||||
@@ -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];
|
||||
}
|
||||
|
||||
@@ -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",
|
||||
|
||||
@@ -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");
|
||||
});
|
||||
});
|
||||
|
||||
|
||||
@@ -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`;
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user