Stabilize agent turn footer layout
This commit is contained in:
@@ -26,7 +26,7 @@ export interface StreamHistoryBoundary {
|
|||||||
|
|
||||||
export interface StreamRenderAuxiliary {
|
export interface StreamRenderAuxiliary {
|
||||||
pendingPermissions: ReactNode;
|
pendingPermissions: ReactNode;
|
||||||
workingIndicator: ReactNode;
|
turnFooter: ReactNode;
|
||||||
}
|
}
|
||||||
|
|
||||||
export interface AgentStreamRenderModel {
|
export interface AgentStreamRenderModel {
|
||||||
@@ -46,7 +46,7 @@ export interface BuildAgentStreamRenderModelInput {
|
|||||||
const EMPTY_STREAM_ITEMS: StreamItem[] = [];
|
const EMPTY_STREAM_ITEMS: StreamItem[] = [];
|
||||||
const EMPTY_AUXILIARY: StreamRenderAuxiliary = {
|
const EMPTY_AUXILIARY: StreamRenderAuxiliary = {
|
||||||
pendingPermissions: null,
|
pendingPermissions: null,
|
||||||
workingIndicator: null,
|
turnFooter: null,
|
||||||
};
|
};
|
||||||
|
|
||||||
const orderedTailCache = new WeakMap<StreamItem[], Map<string, StreamItem[]>>();
|
const orderedTailCache = new WeakMap<StreamItem[], Map<string, StreamItem[]>>();
|
||||||
|
|||||||
@@ -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 { describe, expect, it } from "vitest";
|
||||||
import type { StreamItem } from "@/types/stream";
|
import type { StreamItem } from "@/types/stream";
|
||||||
import type { NeighborResolver } from "./agent-stream-view-data";
|
import { getAssistantBlockSpacing, isSameAssistantBlockGroup } 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];
|
|
||||||
},
|
|
||||||
};
|
|
||||||
|
|
||||||
function assistantBlock(params: {
|
function assistantBlock(params: {
|
||||||
id: string;
|
id: string;
|
||||||
@@ -135,46 +121,3 @@ describe("getAssistantBlockSpacing", () => {
|
|||||||
).toBe("compactTop");
|
).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 type { StreamItem } from "@/types/stream";
|
||||||
|
import { SPACING } from "@/styles/theme";
|
||||||
|
|
||||||
export function isSameAssistantBlockGroup(params: {
|
export function isSameAssistantBlockGroup(params: {
|
||||||
item: StreamItem | null | undefined;
|
item: StreamItem | null | undefined;
|
||||||
@@ -28,24 +29,35 @@ export function getAssistantBlockSpacing(params: {
|
|||||||
return "default";
|
return "default";
|
||||||
}
|
}
|
||||||
|
|
||||||
export interface NeighborResolver {
|
const isUserMessageItem = (item?: StreamItem | null) => item?.kind === "user_message";
|
||||||
getNeighborItem(
|
const isToolSequenceItem = (item?: StreamItem | null) =>
|
||||||
items: StreamItem[],
|
item?.kind === "tool_call" || item?.kind === "thought" || item?.kind === "todo_list";
|
||||||
index: number,
|
|
||||||
relation: "above" | "below",
|
|
||||||
): StreamItem | undefined;
|
|
||||||
}
|
|
||||||
|
|
||||||
// null → auxiliary working indicator; non-null → inline footer on that block.
|
export function getGapBetweenStreamItems(
|
||||||
export function resolveInlineWorkingIndicatorItemId(
|
item: StreamItem | null,
|
||||||
status: string,
|
belowItem: StreamItem | null,
|
||||||
liveHeadItems: StreamItem[],
|
): number {
|
||||||
strategy: NeighborResolver,
|
if (!item || !belowItem) {
|
||||||
): string | null {
|
return 0;
|
||||||
if (status !== "running") return null;
|
}
|
||||||
const footerItem = liveHeadItems.find((item, index, items) => {
|
|
||||||
if (item.kind !== "assistant_message") return false;
|
if (isUserMessageItem(item) && isUserMessageItem(belowItem)) {
|
||||||
return strategy.getNeighborItem(items, index, "below") === undefined;
|
return SPACING[1];
|
||||||
});
|
}
|
||||||
return footerItem?.id ?? null;
|
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,
|
Platform,
|
||||||
ActivityIndicator,
|
ActivityIndicator,
|
||||||
type PressableStateCallbackType,
|
type PressableStateCallbackType,
|
||||||
|
type StyleProp,
|
||||||
|
type ViewStyle,
|
||||||
} from "react-native";
|
} from "react-native";
|
||||||
import { StyleSheet, withUnistyles } from "react-native-unistyles";
|
import { StyleSheet, withUnistyles } from "react-native-unistyles";
|
||||||
import { useIsCompactFormFactor } from "@/constants/layout";
|
import { useIsCompactFormFactor } from "@/constants/layout";
|
||||||
import { useMutation } from "@tanstack/react-query";
|
import { useMutation } from "@tanstack/react-query";
|
||||||
import Animated, {
|
import Animated, { FadeIn, FadeOut } from "react-native-reanimated";
|
||||||
FadeIn,
|
|
||||||
FadeOut,
|
|
||||||
cancelAnimation,
|
|
||||||
Easing,
|
|
||||||
useAnimatedStyle,
|
|
||||||
useSharedValue,
|
|
||||||
withRepeat,
|
|
||||||
withTiming,
|
|
||||||
} from "react-native-reanimated";
|
|
||||||
import { Check, ChevronDown, X } from "lucide-react-native";
|
import { Check, ChevronDown, X } from "lucide-react-native";
|
||||||
import { usePanelStore } from "@/stores/panel-store";
|
import { usePanelStore } from "@/stores/panel-store";
|
||||||
import {
|
import {
|
||||||
AssistantMessage,
|
AssistantMessage,
|
||||||
AssistantTurnFooter,
|
|
||||||
SpeakMessage,
|
SpeakMessage,
|
||||||
UserMessage,
|
UserMessage,
|
||||||
ActivityLog,
|
ActivityLog,
|
||||||
ToolCall,
|
ToolCall,
|
||||||
TodoListCard,
|
TodoListCard,
|
||||||
CompactionMarker,
|
CompactionMarker,
|
||||||
LiveElapsed,
|
|
||||||
MessageOuterSpacingProvider,
|
MessageOuterSpacingProvider,
|
||||||
STREAM_METADATA_FONT_SIZE,
|
|
||||||
type InlinePathTarget,
|
type InlinePathTarget,
|
||||||
} from "./message";
|
} from "./message";
|
||||||
import { PlanCard } from "./plan-card";
|
import { PlanCard } from "./plan-card";
|
||||||
@@ -65,18 +55,21 @@ import { QuestionFormCard } from "./question-form-card";
|
|||||||
import { ToolCallSheetProvider } from "./tool-call-sheet";
|
import { ToolCallSheetProvider } from "./tool-call-sheet";
|
||||||
import {
|
import {
|
||||||
buildAgentStreamRenderModel,
|
buildAgentStreamRenderModel,
|
||||||
collectAssistantTurnContentForStreamRenderStrategy,
|
|
||||||
getStreamNeighborItem,
|
getStreamNeighborItem,
|
||||||
resolveStreamRenderStrategy,
|
resolveStreamRenderStrategy,
|
||||||
type AgentStreamRenderModel,
|
type AgentStreamRenderModel,
|
||||||
type StreamSegmentRenderers,
|
type StreamSegmentRenderers,
|
||||||
type StreamViewportHandle,
|
type StreamViewportHandle,
|
||||||
} from "./agent-stream-render-strategy";
|
} from "./agent-stream-render-strategy";
|
||||||
|
import { getAssistantBlockSpacing, getGapBetweenStreamItems } from "./agent-stream-view-data";
|
||||||
import {
|
import {
|
||||||
getAssistantBlockSpacing,
|
CompletedTurnFooterRow,
|
||||||
isSameAssistantBlockGroup,
|
resolveBottomTurnFooterHost,
|
||||||
resolveInlineWorkingIndicatorItemId,
|
shouldRenderCompletedTurnFooter,
|
||||||
} from "./agent-stream-view-data";
|
TurnFooter,
|
||||||
|
type TurnContentStrategy,
|
||||||
|
type TurnFooterHost,
|
||||||
|
} from "./agent-stream-turn-footer";
|
||||||
import {
|
import {
|
||||||
type BottomAnchorLocalRequest,
|
type BottomAnchorLocalRequest,
|
||||||
type BottomAnchorRouteRequest,
|
type BottomAnchorRouteRequest,
|
||||||
@@ -86,22 +79,153 @@ import { normalizeInlinePathTarget } from "@/utils/inline-path";
|
|||||||
import { resolveWorkspaceIdByExecutionDirectory } from "@/utils/workspace-execution";
|
import { resolveWorkspaceIdByExecutionDirectory } from "@/utils/workspace-execution";
|
||||||
import { navigateToPreparedWorkspaceTab } from "@/utils/workspace-navigation";
|
import { navigateToPreparedWorkspaceTab } from "@/utils/workspace-navigation";
|
||||||
import { useStableEvent } from "@/hooks/use-stable-event";
|
import { useStableEvent } from "@/hooks/use-stable-event";
|
||||||
import {
|
import { findInFlightTurnStartedAt } from "@/timeline/turn-time";
|
||||||
getWorkingIndicatorDotStrength,
|
|
||||||
WORKING_INDICATOR_CYCLE_MS,
|
|
||||||
WORKING_INDICATOR_OFFSETS,
|
|
||||||
} from "@/utils/working-indicator";
|
|
||||||
import { findInFlightTurnStartedAt, findTurnHeaderForAssistantTurn } from "@/timeline/turn-time";
|
|
||||||
import { isWeb } from "@/constants/platform";
|
import { isWeb } from "@/constants/platform";
|
||||||
import { SPACING, type Theme } from "@/styles/theme";
|
import 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";
|
|
||||||
|
|
||||||
interface StreamItemBoundarySeams {
|
interface StreamItemBoundarySeams {
|
||||||
aboveItem?: StreamItem | null;
|
aboveItem?: StreamItem | null;
|
||||||
belowItem?: 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 {
|
export interface AgentStreamViewHandle {
|
||||||
@@ -268,15 +392,6 @@ const AgentStreamViewComponent = forwardRef<AgentStreamViewHandle, AgentStreamVi
|
|||||||
isMobileBreakpoint: isMobile,
|
isMobileBreakpoint: isMobile,
|
||||||
});
|
});
|
||||||
}, [isMobile, streamHead, streamItems]);
|
}, [isMobile, streamHead, streamItems]);
|
||||||
const inlineWorkingIndicatorItemId = useMemo(
|
|
||||||
() =>
|
|
||||||
resolveInlineWorkingIndicatorItemId(
|
|
||||||
agent.status,
|
|
||||||
baseRenderModel.segments.liveHead,
|
|
||||||
streamRenderStrategy,
|
|
||||||
),
|
|
||||||
[agent.status, baseRenderModel.segments.liveHead, streamRenderStrategy],
|
|
||||||
);
|
|
||||||
const inFlightTurnStartedAt = useMemo(
|
const inFlightTurnStartedAt = useMemo(
|
||||||
() =>
|
() =>
|
||||||
findInFlightTurnStartedAt({
|
findInFlightTurnStartedAt({
|
||||||
@@ -303,39 +418,6 @@ const AgentStreamViewComponent = forwardRef<AgentStreamViewHandle, AgentStreamVi
|
|||||||
viewportRef.current?.scrollToBottom("jump-to-bottom");
|
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(
|
const setInlineDetailsExpanded = useCallback(
|
||||||
(itemId: string, expanded: boolean) => {
|
(itemId: string, expanded: boolean) => {
|
||||||
if (!streamRenderStrategy.shouldDisableParentScrollOnInlineDetailsExpansion()) {
|
if (!streamRenderStrategy.shouldDisableParentScrollOnInlineDetailsExpansion()) {
|
||||||
@@ -563,7 +645,7 @@ const AgentStreamViewComponent = forwardRef<AgentStreamViewHandle, AgentStreamVi
|
|||||||
|
|
||||||
case "turn_header":
|
case "turn_header":
|
||||||
// Purely a data carrier - duration renders next to the assistant
|
// 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.
|
// ticker renders next to the WorkingIndicator dots.
|
||||||
return null;
|
return null;
|
||||||
|
|
||||||
@@ -574,6 +656,20 @@ const AgentStreamViewComponent = forwardRef<AgentStreamViewHandle, AgentStreamVi
|
|||||||
[renderUserMessageItem, renderAssistantMessageItem, renderThoughtItem, renderToolCallItem],
|
[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(
|
const renderStreamItem = useCallback(
|
||||||
(
|
(
|
||||||
item: StreamItem,
|
item: StreamItem,
|
||||||
@@ -582,47 +678,24 @@ const AgentStreamViewComponent = forwardRef<AgentStreamViewHandle, AgentStreamVi
|
|||||||
seams: StreamItemBoundarySeams = {},
|
seams: StreamItemBoundarySeams = {},
|
||||||
) => {
|
) => {
|
||||||
const content = renderStreamItemContent(item, index, items, seams);
|
const content = renderStreamItemContent(item, index, items, seams);
|
||||||
if (!content) {
|
|
||||||
return null;
|
|
||||||
}
|
|
||||||
|
|
||||||
const nextItem = getStreamNeighborItem({
|
const nextItem = getStreamNeighborItem({
|
||||||
strategy: streamRenderStrategy,
|
strategy: streamRenderStrategy,
|
||||||
items,
|
items,
|
||||||
index,
|
index,
|
||||||
relation: "below",
|
relation: "below",
|
||||||
});
|
});
|
||||||
const gapBelow = getGapBetween(item, nextItem ?? null);
|
return renderStreamItemWithTurnFooter({
|
||||||
const isEndOfAssistantTurn =
|
content,
|
||||||
item.kind === "assistant_message" &&
|
item,
|
||||||
(nextItem?.kind === "user_message" ||
|
nextItem,
|
||||||
(nextItem === undefined && agent.status !== "running"));
|
items,
|
||||||
const isRunningAssistantTurnFooter =
|
index,
|
||||||
item.kind === "assistant_message" && item.id === inlineWorkingIndicatorItemId;
|
agentStatus: agent.status,
|
||||||
let footer: ReactNode = null;
|
suppressTurnFooter: seams.suppressTurnFooter,
|
||||||
if (isRunningAssistantTurnFooter) {
|
strategy: streamRenderStrategy,
|
||||||
footer = <InlineWorkingIndicatorSlot inFlightTurnStartedAt={inFlightTurnStartedAt} />;
|
});
|
||||||
} else if (isEndOfAssistantTurn) {
|
|
||||||
footer = (
|
|
||||||
<TurnCopyButtonSlot strategy={streamRenderStrategy} items={items} startIndex={index} />
|
|
||||||
);
|
|
||||||
}
|
|
||||||
|
|
||||||
return (
|
|
||||||
<StreamItemWrapper gapBelow={gapBelow}>
|
|
||||||
{content}
|
|
||||||
{footer}
|
|
||||||
</StreamItemWrapper>
|
|
||||||
);
|
|
||||||
},
|
},
|
||||||
[
|
[renderStreamItemContent, agent.status, streamRenderStrategy],
|
||||||
getGapBetween,
|
|
||||||
renderStreamItemContent,
|
|
||||||
agent.status,
|
|
||||||
streamRenderStrategy,
|
|
||||||
inlineWorkingIndicatorItemId,
|
|
||||||
inFlightTurnStartedAt,
|
|
||||||
],
|
|
||||||
);
|
);
|
||||||
|
|
||||||
const pendingPermissionItems = useMemo(
|
const pendingPermissionItems = useMemo(
|
||||||
@@ -630,63 +703,49 @@ const AgentStreamViewComponent = forwardRef<AgentStreamViewHandle, AgentStreamVi
|
|||||||
[pendingPermissions, agentId],
|
[pendingPermissions, agentId],
|
||||||
);
|
);
|
||||||
|
|
||||||
const showAuxiliaryWorkingIndicator =
|
const showRunningTurnFooter = agent.status === "running";
|
||||||
agent.status === "running" && inlineWorkingIndicatorItemId === null;
|
|
||||||
const pendingPermissionsNode = useMemo(
|
const pendingPermissionsNode = useMemo(
|
||||||
() =>
|
() =>
|
||||||
pendingPermissionItems.length > 0 ? (
|
renderPendingPermissionsNode({
|
||||||
<View style={stylesheet.permissionsContainer}>
|
pendingPermissions: pendingPermissionItems,
|
||||||
{pendingPermissionItems.map((permission) => (
|
client,
|
||||||
<PermissionRequestCard key={permission.key} permission={permission} client={client} />
|
}),
|
||||||
))}
|
|
||||||
</View>
|
|
||||||
) : null,
|
|
||||||
[client, pendingPermissionItems],
|
[client, pendingPermissionItems],
|
||||||
);
|
);
|
||||||
const workingIndicatorNode = useMemo(
|
const turnFooterNode = useMemo(
|
||||||
() =>
|
() =>
|
||||||
showAuxiliaryWorkingIndicator ? (
|
showRunningTurnFooter || bottomTurnFooterHost ? (
|
||||||
<View style={stylesheet.bottomBarWrapper} testID="stream-working-indicator-auxiliary">
|
<TurnFooter
|
||||||
<WorkingIndicator inFlightTurnStartedAt={inFlightTurnStartedAt} />
|
isRunning={showRunningTurnFooter}
|
||||||
</View>
|
inFlightTurnStartedAt={inFlightTurnStartedAt}
|
||||||
|
host={bottomTurnFooterHost}
|
||||||
|
strategy={streamRenderStrategy}
|
||||||
|
/>
|
||||||
) : null,
|
) : null,
|
||||||
[showAuxiliaryWorkingIndicator, inFlightTurnStartedAt],
|
[showRunningTurnFooter, inFlightTurnStartedAt, bottomTurnFooterHost, streamRenderStrategy],
|
||||||
);
|
);
|
||||||
const renderModel = useMemo<AgentStreamRenderModel>(() => {
|
const renderModel = useMemo<AgentStreamRenderModel>(() => {
|
||||||
return {
|
return {
|
||||||
...baseRenderModel,
|
...baseRenderModel,
|
||||||
boundary: {
|
boundary: {
|
||||||
...baseRenderModel.boundary,
|
...baseRenderModel.boundary,
|
||||||
historyToHeadGap: getGapBetween(
|
historyToHeadGap: getGapBetweenStreamItems(
|
||||||
baseRenderModel.history.at(-1) ?? null,
|
baseRenderModel.history.at(-1) ?? null,
|
||||||
baseRenderModel.segments.liveHead[0] ?? null,
|
baseRenderModel.segments.liveHead[0] ?? null,
|
||||||
),
|
),
|
||||||
},
|
},
|
||||||
auxiliary: {
|
auxiliary: {
|
||||||
pendingPermissions: pendingPermissionsNode,
|
pendingPermissions: pendingPermissionsNode,
|
||||||
workingIndicator: workingIndicatorNode,
|
turnFooter: turnFooterNode,
|
||||||
},
|
},
|
||||||
};
|
};
|
||||||
}, [baseRenderModel, getGapBetween, pendingPermissionsNode, workingIndicatorNode]);
|
}, [baseRenderModel, pendingPermissionsNode, turnFooterNode]);
|
||||||
|
|
||||||
const emptyStateStyle = useMemo(() => [stylesheet.emptyState, stylesheet.contentWrapper], []);
|
const emptyStateStyle = useMemo(() => [stylesheet.emptyState, stylesheet.contentWrapper], []);
|
||||||
const listEmptyComponent = useMemo(() => {
|
const listEmptyComponent = useMemo(
|
||||||
if (
|
() => renderListEmptyComponent({ renderModel, emptyStateStyle }),
|
||||||
renderModel.boundary.hasVirtualizedHistory ||
|
[renderModel, emptyStateStyle],
|
||||||
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 historyItems = renderModel.history;
|
const historyItems = renderModel.history;
|
||||||
const _liveHeadItems = renderModel.segments.liveHead;
|
const _liveHeadItems = renderModel.segments.liveHead;
|
||||||
@@ -703,17 +762,24 @@ const AgentStreamViewComponent = forwardRef<AgentStreamViewHandle, AgentStreamVi
|
|||||||
}, [historyItems]);
|
}, [historyItems]);
|
||||||
|
|
||||||
const renderHistoryRow = useCallback(
|
const renderHistoryRow = useCallback(
|
||||||
(item: StreamItem) => {
|
(item: StreamItem) =>
|
||||||
const historyIndex = historyIndexById.get(item.id);
|
renderHistoryStreamItem({
|
||||||
if (historyIndex === undefined) {
|
item,
|
||||||
return null;
|
historyIndexById,
|
||||||
}
|
historyItems,
|
||||||
const seamBelowItem = item.id === lastHistoryItem?.id ? firstLiveHeadItem : null;
|
lastHistoryItem,
|
||||||
return renderStreamItem(item, historyIndex, historyItems, {
|
firstLiveHeadItem,
|
||||||
belowItem: seamBelowItem,
|
bottomTurnFooterHost,
|
||||||
});
|
renderStreamItem,
|
||||||
},
|
}),
|
||||||
[firstLiveHeadItem, historyIndexById, historyItems, lastHistoryItem?.id, renderStreamItem],
|
[
|
||||||
|
bottomTurnFooterHost,
|
||||||
|
firstLiveHeadItem,
|
||||||
|
historyIndexById,
|
||||||
|
historyItems,
|
||||||
|
lastHistoryItem,
|
||||||
|
renderStreamItem,
|
||||||
|
],
|
||||||
);
|
);
|
||||||
|
|
||||||
const renderHistoryVirtualizedRow = useCallback<
|
const renderHistoryVirtualizedRow = useCallback<
|
||||||
@@ -725,32 +791,22 @@ const AgentStreamViewComponent = forwardRef<AgentStreamViewHandle, AgentStreamVi
|
|||||||
);
|
);
|
||||||
const renderLiveHeadRow = useCallback<StreamSegmentRenderers["renderLiveHeadRow"]>(
|
const renderLiveHeadRow = useCallback<StreamSegmentRenderers["renderLiveHeadRow"]>(
|
||||||
(item, index, items) =>
|
(item, index, items) =>
|
||||||
renderStreamItem(item, index, items, {
|
renderLiveHeadStreamItem({
|
||||||
aboveItem: index === 0 ? lastHistoryItem : null,
|
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"]>(() => {
|
const renderLiveAuxiliary = useCallback<StreamSegmentRenderers["renderLiveAuxiliary"]>(() => {
|
||||||
if (!auxiliary.pendingPermissions && !auxiliary.workingIndicator) {
|
return renderLiveAuxiliaryNode({
|
||||||
return null;
|
pendingPermissions: auxiliary.pendingPermissions,
|
||||||
}
|
turnFooter: auxiliary.turnFooter,
|
||||||
return (
|
});
|
||||||
<View style={stylesheet.contentWrapper}>
|
}, [auxiliary.pendingPermissions, auxiliary.turnFooter]);
|
||||||
<View style={liveAuxiliaryHeaderStyle}>
|
|
||||||
{auxiliary.pendingPermissions}
|
|
||||||
{auxiliary.workingIndicator}
|
|
||||||
</View>
|
|
||||||
</View>
|
|
||||||
);
|
|
||||||
}, [auxiliary.pendingPermissions, auxiliary.workingIndicator, liveAuxiliaryHeaderStyle]);
|
|
||||||
|
|
||||||
const renderers = useMemo<StreamSegmentRenderers>(
|
const renderers = useMemo<StreamSegmentRenderers>(
|
||||||
() => ({
|
() => ({
|
||||||
@@ -822,135 +878,6 @@ const AgentStreamViewComponent = forwardRef<AgentStreamViewHandle, AgentStreamVi
|
|||||||
export const AgentStreamView = memo(AgentStreamViewComponent);
|
export const AgentStreamView = memo(AgentStreamViewComponent);
|
||||||
AgentStreamView.displayName = "AgentStreamView";
|
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<
|
interface ToolCallSlotProps extends Omit<
|
||||||
ComponentProps<typeof ToolCall>,
|
ComponentProps<typeof ToolCall>,
|
||||||
"onInlineDetailsExpandedChange"
|
"onInlineDetailsExpandedChange"
|
||||||
@@ -1282,57 +1209,6 @@ const stylesheet = StyleSheet.create((theme) => ({
|
|||||||
listHeaderContent: {
|
listHeaderContent: {
|
||||||
gap: theme.spacing[3],
|
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: {
|
syncingIndicator: {
|
||||||
flexDirection: "row",
|
flexDirection: "row",
|
||||||
alignItems: "center",
|
alignItems: "center",
|
||||||
|
|||||||
@@ -2,13 +2,10 @@ import { describe, it, expect } from "vitest";
|
|||||||
import { formatDuration, formatMessageTimestamp } from "./time";
|
import { formatDuration, formatMessageTimestamp } from "./time";
|
||||||
|
|
||||||
describe("formatDuration", () => {
|
describe("formatDuration", () => {
|
||||||
it("renders 0-10s with one decimal", () => {
|
it("renders sub-minute durations as whole seconds", () => {
|
||||||
expect(formatDuration(0)).toBe("0.0s");
|
expect(formatDuration(0)).toBe("0s");
|
||||||
expect(formatDuration(5_600)).toBe("5.6s");
|
expect(formatDuration(5_600)).toBe("5s");
|
||||||
expect(formatDuration(9_900)).toBe("9.9s");
|
expect(formatDuration(9_900)).toBe("9s");
|
||||||
});
|
|
||||||
|
|
||||||
it("renders 10s-60s as whole seconds", () => {
|
|
||||||
expect(formatDuration(10_400)).toBe("10s");
|
expect(formatDuration(10_400)).toBe("10s");
|
||||||
expect(formatDuration(12_340)).toBe("12s");
|
expect(formatDuration(12_340)).toBe("12s");
|
||||||
expect(formatDuration(47_000)).toBe("47s");
|
expect(formatDuration(47_000)).toBe("47s");
|
||||||
@@ -26,8 +23,8 @@ describe("formatDuration", () => {
|
|||||||
});
|
});
|
||||||
|
|
||||||
it("guards against negative and NaN", () => {
|
it("guards against negative and NaN", () => {
|
||||||
expect(formatDuration(-1)).toBe("0.0s");
|
expect(formatDuration(-1)).toBe("0s");
|
||||||
expect(formatDuration(Number.NaN)).toBe("0.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.
|
* Format a duration as a compact human-readable string.
|
||||||
* - 0-10s: one decimal ("3.4s")
|
* - 0-60s: whole seconds ("47s")
|
||||||
* - 10s-60s: whole seconds ("47s")
|
|
||||||
* - Minutes/hours: integers only ("2m 12s", "1h 5m")
|
* - Minutes/hours: integers only ("2m 12s", "1h 5m")
|
||||||
*/
|
*/
|
||||||
export function formatDuration(durationMs: number): string {
|
export function formatDuration(durationMs: number): string {
|
||||||
if (!Number.isFinite(durationMs) || durationMs < 0) {
|
if (!Number.isFinite(durationMs) || durationMs < 0) {
|
||||||
return "0.0s";
|
return "0s";
|
||||||
}
|
}
|
||||||
const totalSeconds = durationMs / 1000;
|
const totalSeconds = durationMs / 1000;
|
||||||
|
|
||||||
if (totalSeconds < 10) {
|
|
||||||
return `${totalSeconds.toFixed(1)}s`;
|
|
||||||
}
|
|
||||||
if (totalSeconds < 60) {
|
if (totalSeconds < 60) {
|
||||||
return `${Math.floor(totalSeconds)}s`;
|
return `${Math.floor(totalSeconds)}s`;
|
||||||
}
|
}
|
||||||
|
|||||||
Reference in New Issue
Block a user