Fix agent turn timing persistence
This commit is contained in:
@@ -35,6 +35,7 @@ describe("buildAgentStreamRenderModel", () => {
|
||||
const head = [assistantMessage("live-a", 121)];
|
||||
|
||||
const model = buildAgentStreamRenderModel({
|
||||
agentStatus: "running",
|
||||
tail,
|
||||
head,
|
||||
platform: "web",
|
||||
@@ -52,6 +53,7 @@ describe("buildAgentStreamRenderModel", () => {
|
||||
const head = [assistantMessage("live-a", 3)];
|
||||
|
||||
const model = buildAgentStreamRenderModel({
|
||||
agentStatus: "running",
|
||||
tail,
|
||||
head,
|
||||
platform: "web",
|
||||
@@ -69,12 +71,14 @@ describe("buildAgentStreamRenderModel", () => {
|
||||
const secondHead = [assistantMessage("live-b", 4)];
|
||||
|
||||
const first = buildAgentStreamRenderModel({
|
||||
agentStatus: "running",
|
||||
tail,
|
||||
head: firstHead,
|
||||
platform: "native",
|
||||
isMobileBreakpoint: false,
|
||||
});
|
||||
const second = buildAgentStreamRenderModel({
|
||||
agentStatus: "running",
|
||||
tail,
|
||||
head: secondHead,
|
||||
platform: "native",
|
||||
@@ -85,4 +89,73 @@ describe("buildAgentStreamRenderModel", () => {
|
||||
expect(first.segments.historyMounted).toBe(second.segments.historyMounted);
|
||||
expect(second.segments.liveHead.map((item) => item.id)).toEqual(["live-b"]);
|
||||
});
|
||||
|
||||
it("derives running turn timing across committed history and live head", () => {
|
||||
const tail = [userMessage("u1", 1)];
|
||||
const head = [assistantMessage("live-a", 4)];
|
||||
|
||||
const model = buildAgentStreamRenderModel({
|
||||
agentStatus: "running",
|
||||
tail,
|
||||
head,
|
||||
platform: "web",
|
||||
isMobileBreakpoint: false,
|
||||
});
|
||||
|
||||
expect(model.turnTiming.runningStartedAt).toBe(tail[0]?.timestamp);
|
||||
expect(model.turnTiming.byAssistantId.has("live-a")).toBe(false);
|
||||
});
|
||||
|
||||
it("maps completed turn timing to assistant ids across committed history and live head", () => {
|
||||
const tail = [userMessage("u1", 1)];
|
||||
const head = [assistantMessage("live-a", 4)];
|
||||
|
||||
const model = buildAgentStreamRenderModel({
|
||||
agentStatus: "idle",
|
||||
tail,
|
||||
head,
|
||||
platform: "web",
|
||||
isMobileBreakpoint: false,
|
||||
});
|
||||
|
||||
expect(model.turnTiming.runningStartedAt).toBe(null);
|
||||
expect(model.turnTiming.byAssistantId.get("live-a")).toEqual({
|
||||
startedAt: tail[0]?.timestamp,
|
||||
completedAt: head[0]?.timestamp,
|
||||
durationMs: 3000,
|
||||
});
|
||||
});
|
||||
|
||||
it("derives the same timing for native inverted rendering", () => {
|
||||
const tail = [userMessage("u1", 1), assistantMessage("a1", 4)];
|
||||
|
||||
const model = buildAgentStreamRenderModel({
|
||||
agentStatus: "idle",
|
||||
tail,
|
||||
head: [],
|
||||
platform: "native",
|
||||
isMobileBreakpoint: false,
|
||||
});
|
||||
|
||||
expect(model.segments.historyMounted.map((item) => item.id)).toEqual(["a1", "u1"]);
|
||||
expect(model.turnTiming.byAssistantId.get("a1")).toEqual({
|
||||
startedAt: tail[0]?.timestamp,
|
||||
completedAt: tail[1]?.timestamp,
|
||||
durationMs: 3000,
|
||||
});
|
||||
});
|
||||
|
||||
it("does not create completed timing for adjacent user messages", () => {
|
||||
const tail = [userMessage("u1", 1), userMessage("u2", 4)];
|
||||
|
||||
const model = buildAgentStreamRenderModel({
|
||||
agentStatus: "idle",
|
||||
tail,
|
||||
head: [],
|
||||
platform: "web",
|
||||
isMobileBreakpoint: false,
|
||||
});
|
||||
|
||||
expect(model.turnTiming.byAssistantId.size).toBe(0);
|
||||
});
|
||||
});
|
||||
|
||||
@@ -1,4 +1,5 @@
|
||||
import type { ReactNode } from "react";
|
||||
import { deriveStreamTurnTiming, type StreamTurnTiming } from "@/timeline/turn-time";
|
||||
import type { StreamItem } from "@/types/stream";
|
||||
import {
|
||||
findMountedWindowStart,
|
||||
@@ -32,11 +33,13 @@ export interface StreamRenderAuxiliary {
|
||||
export interface AgentStreamRenderModel {
|
||||
history: StreamItem[];
|
||||
segments: StreamRenderSegments;
|
||||
turnTiming: StreamTurnTiming;
|
||||
boundary: StreamHistoryBoundary;
|
||||
auxiliary: StreamRenderAuxiliary;
|
||||
}
|
||||
|
||||
export interface BuildAgentStreamRenderModelInput {
|
||||
agentStatus: string;
|
||||
tail: StreamItem[];
|
||||
head: StreamItem[];
|
||||
platform: "web" | "native";
|
||||
@@ -55,6 +58,10 @@ const splitHistoryCache = new WeakMap<
|
||||
StreamItem[],
|
||||
Map<string, Pick<AgentStreamRenderModel, "history" | "segments">>
|
||||
>();
|
||||
const turnTimingCache = new WeakMap<
|
||||
StreamItem[],
|
||||
WeakMap<StreamItem[], Map<string, StreamTurnTiming>>
|
||||
>();
|
||||
|
||||
function getOrderedItems(params: {
|
||||
cache: WeakMap<StreamItem[], Map<string, StreamItem[]>>;
|
||||
@@ -127,6 +134,30 @@ function splitOrderedTail(params: {
|
||||
return split;
|
||||
}
|
||||
|
||||
function getTurnTiming(params: {
|
||||
agentStatus: string;
|
||||
tail: StreamItem[];
|
||||
head: StreamItem[];
|
||||
}): StreamTurnTiming {
|
||||
let cachedByHead = turnTimingCache.get(params.tail);
|
||||
if (!cachedByHead) {
|
||||
cachedByHead = new WeakMap();
|
||||
turnTimingCache.set(params.tail, cachedByHead);
|
||||
}
|
||||
let cachedByStatus = cachedByHead.get(params.head);
|
||||
if (!cachedByStatus) {
|
||||
cachedByStatus = new Map();
|
||||
cachedByHead.set(params.head, cachedByStatus);
|
||||
}
|
||||
const cached = cachedByStatus.get(params.agentStatus);
|
||||
if (cached) {
|
||||
return cached;
|
||||
}
|
||||
const timing = deriveStreamTurnTiming(params);
|
||||
cachedByStatus.set(params.agentStatus, timing);
|
||||
return timing;
|
||||
}
|
||||
|
||||
export function buildAgentStreamRenderModel(
|
||||
input: BuildAgentStreamRenderModelInput,
|
||||
): AgentStreamRenderModel {
|
||||
@@ -160,6 +191,11 @@ export function buildAgentStreamRenderModel(
|
||||
platform: input.platform,
|
||||
isMobileBreakpoint: input.isMobileBreakpoint,
|
||||
});
|
||||
const turnTiming = getTurnTiming({
|
||||
agentStatus: input.agentStatus,
|
||||
tail: input.tail,
|
||||
head: input.head,
|
||||
});
|
||||
|
||||
return {
|
||||
history: splitHistory.history,
|
||||
@@ -167,6 +203,7 @@ export function buildAgentStreamRenderModel(
|
||||
...splitHistory.segments,
|
||||
liveHead: orderedHead,
|
||||
},
|
||||
turnTiming,
|
||||
boundary: {
|
||||
hasVirtualizedHistory: splitHistory.segments.historyVirtualized.length > 0,
|
||||
hasMountedHistory: splitHistory.segments.historyMounted.length > 0,
|
||||
|
||||
@@ -10,7 +10,7 @@ import Animated, {
|
||||
} 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 { TurnTiming } from "@/timeline/turn-time";
|
||||
import type { StreamItem } from "@/types/stream";
|
||||
import {
|
||||
getWorkingIndicatorDotStrength,
|
||||
@@ -28,6 +28,7 @@ export type TurnContentStrategy = StreamStrategy;
|
||||
export interface TurnFooterHost {
|
||||
itemId: string;
|
||||
items: StreamItem[];
|
||||
timing?: TurnTiming;
|
||||
startIndex: number;
|
||||
}
|
||||
|
||||
@@ -36,11 +37,13 @@ export function resolveBottomTurnFooterHost(input: {
|
||||
history: StreamItem[];
|
||||
liveHead: StreamItem[];
|
||||
isInverted: boolean;
|
||||
timingByAssistantId: Map<string, TurnTiming>;
|
||||
}): TurnFooterHost | null {
|
||||
if (input.agentStatus === "running") {
|
||||
return null;
|
||||
}
|
||||
const footerItems = input.liveHead.length > 0 ? input.liveHead : input.history;
|
||||
const usesLiveHead = input.liveHead.length > 0;
|
||||
const footerItems = usesLiveHead ? input.liveHead : input.history;
|
||||
const startIndex = input.isInverted ? 0 : footerItems.length - 1;
|
||||
const item = footerItems[startIndex];
|
||||
if (!item || item.kind !== "assistant_message") {
|
||||
@@ -49,6 +52,7 @@ export function resolveBottomTurnFooterHost(input: {
|
||||
return {
|
||||
itemId: item.id,
|
||||
items: footerItems,
|
||||
timing: input.timingByAssistantId.get(item.id),
|
||||
startIndex,
|
||||
};
|
||||
}
|
||||
@@ -89,22 +93,34 @@ export const TurnFooter = memo(function TurnFooter({
|
||||
return null;
|
||||
}
|
||||
return (
|
||||
<CompletedTurnFooterRow strategy={strategy} items={host.items} startIndex={host.startIndex} />
|
||||
<CompletedTurnFooterRow
|
||||
strategy={strategy}
|
||||
items={host.items}
|
||||
timing={host.timing}
|
||||
startIndex={host.startIndex}
|
||||
/>
|
||||
);
|
||||
});
|
||||
|
||||
export const CompletedTurnFooterRow = memo(function CompletedTurnFooterRow({
|
||||
strategy,
|
||||
items,
|
||||
timing,
|
||||
startIndex,
|
||||
}: {
|
||||
strategy: TurnContentStrategy;
|
||||
items: StreamItem[];
|
||||
timing?: TurnTiming;
|
||||
startIndex: number;
|
||||
}) {
|
||||
return (
|
||||
<TurnFooterRow>
|
||||
<CompletedTurnFooter strategy={strategy} items={items} startIndex={startIndex} />
|
||||
<CompletedTurnFooter
|
||||
strategy={strategy}
|
||||
items={items}
|
||||
timing={timing}
|
||||
startIndex={startIndex}
|
||||
/>
|
||||
</TurnFooterRow>
|
||||
);
|
||||
});
|
||||
@@ -194,10 +210,12 @@ function RunningTurnFooter({ inFlightTurnStartedAt }: { inFlightTurnStartedAt: D
|
||||
function CompletedTurnFooter({
|
||||
strategy,
|
||||
items,
|
||||
timing,
|
||||
startIndex,
|
||||
}: {
|
||||
strategy: TurnContentStrategy;
|
||||
items: StreamItem[];
|
||||
timing?: TurnTiming;
|
||||
startIndex: number;
|
||||
}) {
|
||||
const getContent = useCallback(
|
||||
@@ -209,16 +227,12 @@ function CompletedTurnFooter({
|
||||
}),
|
||||
[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}
|
||||
completedAt={timing?.completedAt}
|
||||
durationMs={timing?.durationMs}
|
||||
/>
|
||||
</View>
|
||||
);
|
||||
|
||||
@@ -21,7 +21,7 @@ import {
|
||||
type ViewStyle,
|
||||
} from "react-native";
|
||||
import { StyleSheet, withUnistyles } from "react-native-unistyles";
|
||||
import { useIsCompactFormFactor } from "@/constants/layout";
|
||||
import { MAX_CONTENT_WIDTH, useIsCompactFormFactor } from "@/constants/layout";
|
||||
import { useMutation } from "@tanstack/react-query";
|
||||
import Animated, { FadeIn, FadeOut } from "react-native-reanimated";
|
||||
import { Check, ChevronDown, X } from "lucide-react-native";
|
||||
@@ -74,12 +74,10 @@ import {
|
||||
type BottomAnchorLocalRequest,
|
||||
type BottomAnchorRouteRequest,
|
||||
} from "./use-bottom-anchor-controller";
|
||||
import { MAX_CONTENT_WIDTH } from "@/constants/layout";
|
||||
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 { findInFlightTurnStartedAt } from "@/timeline/turn-time";
|
||||
import { isWeb } from "@/constants/platform";
|
||||
import type { Theme } from "@/styles/theme";
|
||||
|
||||
@@ -129,6 +127,7 @@ function renderStreamItemWithTurnFooter(input: {
|
||||
item: StreamItem;
|
||||
nextItem: StreamItem | undefined;
|
||||
items: StreamItem[];
|
||||
timing: AgentStreamRenderModel["turnTiming"]["byAssistantId"];
|
||||
index: number;
|
||||
agentStatus: string;
|
||||
suppressTurnFooter: boolean | undefined;
|
||||
@@ -155,6 +154,7 @@ function renderStreamItemWithTurnFooter(input: {
|
||||
<CompletedTurnFooterRow
|
||||
strategy={input.strategy}
|
||||
items={input.items}
|
||||
timing={input.timing.get(input.item.id)}
|
||||
startIndex={input.index}
|
||||
/>
|
||||
) : null}
|
||||
@@ -245,6 +245,8 @@ export interface AgentStreamViewProps {
|
||||
onOpenWorkspaceFile?: (input: { filePath: string }) => void;
|
||||
}
|
||||
|
||||
const EMPTY_STREAM_HEAD: StreamItem[] = [];
|
||||
|
||||
const AgentStreamViewComponent = forwardRef<AgentStreamViewHandle, AgentStreamViewProps>(
|
||||
function AgentStreamView(
|
||||
{
|
||||
@@ -386,21 +388,13 @@ const AgentStreamViewComponent = forwardRef<AgentStreamViewHandle, AgentStreamVi
|
||||
|
||||
const baseRenderModel = useMemo(() => {
|
||||
return buildAgentStreamRenderModel({
|
||||
agentStatus: agent.status,
|
||||
tail: streamItems,
|
||||
head: streamHead ?? [],
|
||||
head: streamHead ?? EMPTY_STREAM_HEAD,
|
||||
platform: isWeb ? "web" : "native",
|
||||
isMobileBreakpoint: isMobile,
|
||||
});
|
||||
}, [isMobile, streamHead, streamItems]);
|
||||
const inFlightTurnStartedAt = useMemo(
|
||||
() =>
|
||||
findInFlightTurnStartedAt({
|
||||
agentStatus: agent.status,
|
||||
liveHead: baseRenderModel.segments.liveHead,
|
||||
tail: streamItems,
|
||||
}),
|
||||
[agent.status, baseRenderModel.segments.liveHead, streamItems],
|
||||
);
|
||||
}, [agent.status, isMobile, streamHead, streamItems]);
|
||||
useImperativeHandle(
|
||||
ref,
|
||||
() => ({
|
||||
@@ -643,12 +637,6 @@ const AgentStreamViewComponent = forwardRef<AgentStreamViewHandle, AgentStreamVi
|
||||
/>
|
||||
);
|
||||
|
||||
case "turn_header":
|
||||
// Purely a data carrier - duration renders next to the assistant
|
||||
// turn's copy button (CompletedTurnFooter), and the in-flight
|
||||
// ticker renders next to the WorkingIndicator dots.
|
||||
return null;
|
||||
|
||||
default:
|
||||
return null;
|
||||
}
|
||||
@@ -662,11 +650,13 @@ const AgentStreamViewComponent = forwardRef<AgentStreamViewHandle, AgentStreamVi
|
||||
history: baseRenderModel.history,
|
||||
liveHead: baseRenderModel.segments.liveHead,
|
||||
isInverted: streamRenderStrategy.getFlatListInverted(),
|
||||
timingByAssistantId: baseRenderModel.turnTiming.byAssistantId,
|
||||
});
|
||||
}, [
|
||||
agent.status,
|
||||
baseRenderModel.history,
|
||||
baseRenderModel.segments.liveHead,
|
||||
baseRenderModel.turnTiming.byAssistantId,
|
||||
streamRenderStrategy,
|
||||
]);
|
||||
|
||||
@@ -689,13 +679,19 @@ const AgentStreamViewComponent = forwardRef<AgentStreamViewHandle, AgentStreamVi
|
||||
item,
|
||||
nextItem,
|
||||
items,
|
||||
timing: baseRenderModel.turnTiming.byAssistantId,
|
||||
index,
|
||||
agentStatus: agent.status,
|
||||
suppressTurnFooter: seams.suppressTurnFooter,
|
||||
strategy: streamRenderStrategy,
|
||||
});
|
||||
},
|
||||
[renderStreamItemContent, agent.status, streamRenderStrategy],
|
||||
[
|
||||
agent.status,
|
||||
baseRenderModel.turnTiming.byAssistantId,
|
||||
renderStreamItemContent,
|
||||
streamRenderStrategy,
|
||||
],
|
||||
);
|
||||
|
||||
const pendingPermissionItems = useMemo(
|
||||
@@ -717,12 +713,17 @@ const AgentStreamViewComponent = forwardRef<AgentStreamViewHandle, AgentStreamVi
|
||||
showRunningTurnFooter || bottomTurnFooterHost ? (
|
||||
<TurnFooter
|
||||
isRunning={showRunningTurnFooter}
|
||||
inFlightTurnStartedAt={inFlightTurnStartedAt}
|
||||
inFlightTurnStartedAt={baseRenderModel.turnTiming.runningStartedAt}
|
||||
host={bottomTurnFooterHost}
|
||||
strategy={streamRenderStrategy}
|
||||
/>
|
||||
) : null,
|
||||
[showRunningTurnFooter, inFlightTurnStartedAt, bottomTurnFooterHost, streamRenderStrategy],
|
||||
[
|
||||
showRunningTurnFooter,
|
||||
baseRenderModel.turnTiming.runningStartedAt,
|
||||
bottomTurnFooterHost,
|
||||
streamRenderStrategy,
|
||||
],
|
||||
);
|
||||
const renderModel = useMemo<AgentStreamRenderModel>(() => {
|
||||
return {
|
||||
@@ -748,7 +749,6 @@ const AgentStreamViewComponent = forwardRef<AgentStreamViewHandle, AgentStreamVi
|
||||
);
|
||||
|
||||
const historyItems = renderModel.history;
|
||||
const _liveHeadItems = renderModel.segments.liveHead;
|
||||
const { boundary, auxiliary } = renderModel;
|
||||
const lastHistoryItem = historyItems.at(-1) ?? null;
|
||||
const firstLiveHeadItem = renderModel.segments.liveHead[0] ?? null;
|
||||
|
||||
@@ -557,7 +557,7 @@ export const UserMessage = memo(function UserMessage({
|
||||
|
||||
interface AssistantTurnFooterProps {
|
||||
getContent: () => string;
|
||||
startedAt?: Date;
|
||||
completedAt?: Date;
|
||||
durationMs?: number;
|
||||
}
|
||||
|
||||
@@ -595,13 +595,13 @@ const TIMESTAMP_REVEAL_MS = 3000;
|
||||
|
||||
/**
|
||||
* Footer rendered next to the copy button at the end of an assistant turn.
|
||||
* Always shows the turn duration; swaps to the start timestamp on hover (web)
|
||||
* Always shows the turn duration; swaps to the end timestamp on hover (web)
|
||||
* or tap (native). The hidden sizer keeps the label width stable while the
|
||||
* visible text swaps.
|
||||
*/
|
||||
export const AssistantTurnFooter = memo(function AssistantTurnFooter({
|
||||
getContent,
|
||||
startedAt,
|
||||
completedAt,
|
||||
durationMs,
|
||||
}: AssistantTurnFooterProps) {
|
||||
const [hovered, setHovered] = useState(false);
|
||||
@@ -622,8 +622,8 @@ export const AssistantTurnFooter = memo(function AssistantTurnFooter({
|
||||
[durationMs],
|
||||
);
|
||||
const timestampLabel = useMemo(
|
||||
() => (startedAt ? formatMessageTimestamp(startedAt) : ""),
|
||||
[startedAt],
|
||||
() => (completedAt ? formatMessageTimestamp(completedAt) : ""),
|
||||
[completedAt],
|
||||
);
|
||||
|
||||
const canSwap = Boolean(timestampLabel);
|
||||
@@ -655,9 +655,7 @@ export const AssistantTurnFooter = memo(function AssistantTurnFooter({
|
||||
onHoverIn={handleHoverIn}
|
||||
onHoverOut={handleHoverOut}
|
||||
accessibilityRole={canSwap ? "button" : undefined}
|
||||
accessibilityLabel={
|
||||
canSwap ? `${durationLabel}, started ${timestampLabel}` : durationLabel
|
||||
}
|
||||
accessibilityLabel={canSwap ? `${durationLabel}, ended ${timestampLabel}` : durationLabel}
|
||||
>
|
||||
<View style={assistantTurnFooterStylesheet.labelWrapper}>
|
||||
{/* Sizer reserves space for whichever label is longer so the
|
||||
|
||||
Reference in New Issue
Block a user