Use daemon timestamps for turn timing (#1033)

This commit is contained in:
Mohamed Boudra
2026-05-15 11:09:13 +08:00
committed by GitHub
parent 1962225647
commit a72c7f6c4b
22 changed files with 1284 additions and 193 deletions
@@ -35,13 +35,14 @@ import { Check, ChevronDown, X } from "lucide-react-native";
import { usePanelStore } from "@/stores/panel-store";
import {
AssistantMessage,
AssistantTurnFooter,
SpeakMessage,
UserMessage,
ActivityLog,
ToolCall,
TodoListCard,
CompactionMarker,
TurnCopyButton,
LiveElapsed,
MessageOuterSpacingProvider,
type InlinePathTarget,
} from "./message";
@@ -89,6 +90,7 @@ import {
WORKING_INDICATOR_CYCLE_MS,
WORKING_INDICATOR_OFFSETS,
} from "@/utils/working-indicator";
import { findInFlightTurnStartedAt, findTurnHeaderForAssistantTurn } from "@/timeline/turn-time";
import { isWeb } from "@/constants/platform";
import { SPACING, type Theme } from "@/styles/theme";
@@ -274,6 +276,15 @@ const AgentStreamViewComponent = forwardRef<AgentStreamViewHandle, AgentStreamVi
),
[agent.status, baseRenderModel.segments.liveHead, streamRenderStrategy],
);
const inFlightTurnStartedAt = useMemo(
() =>
findInFlightTurnStartedAt({
agentStatus: agent.status,
liveHead: baseRenderModel.segments.liveHead,
tail: streamItems,
}),
[agent.status, baseRenderModel.segments.liveHead, streamItems],
);
useImperativeHandle(
ref,
() => ({
@@ -549,6 +560,12 @@ 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
// ticker renders next to the WorkingIndicator dots.
return null;
default:
return null;
}
@@ -583,7 +600,7 @@ const AgentStreamViewComponent = forwardRef<AgentStreamViewHandle, AgentStreamVi
item.kind === "assistant_message" && item.id === inlineWorkingIndicatorItemId;
let footer: ReactNode = null;
if (isRunningAssistantTurnFooter) {
footer = <InlineWorkingIndicatorSlot />;
footer = <InlineWorkingIndicatorSlot inFlightTurnStartedAt={inFlightTurnStartedAt} />;
} else if (isEndOfAssistantTurn) {
footer = (
<TurnCopyButtonSlot strategy={streamRenderStrategy} items={items} startIndex={index} />
@@ -603,6 +620,7 @@ const AgentStreamViewComponent = forwardRef<AgentStreamViewHandle, AgentStreamVi
agent.status,
streamRenderStrategy,
inlineWorkingIndicatorItemId,
inFlightTurnStartedAt,
],
);
@@ -628,10 +646,10 @@ const AgentStreamViewComponent = forwardRef<AgentStreamViewHandle, AgentStreamVi
() =>
showAuxiliaryWorkingIndicator ? (
<View style={stylesheet.bottomBarWrapper} testID="stream-working-indicator-auxiliary">
<WorkingIndicator />
<WorkingIndicator inFlightTurnStartedAt={inFlightTurnStartedAt} />
</View>
) : null,
[showAuxiliaryWorkingIndicator],
[showAuxiliaryWorkingIndicator, inFlightTurnStartedAt],
);
const renderModel = useMemo<AgentStreamRenderModel>(() => {
return {
@@ -803,7 +821,13 @@ const AgentStreamViewComponent = forwardRef<AgentStreamViewHandle, AgentStreamVi
export const AgentStreamView = memo(AgentStreamViewComponent);
AgentStreamView.displayName = "AgentStreamView";
function WorkingIndicator({ variant = "auxiliary" }: { variant?: "auxiliary" | "inline" }) {
function WorkingIndicator({
variant = "auxiliary",
inFlightTurnStartedAt = null,
}: {
variant?: "auxiliary" | "inline";
inFlightTurnStartedAt?: Date | null;
}) {
const progress = useSharedValue(0);
useEffect(() => {
@@ -867,14 +891,25 @@ function WorkingIndicator({ variant = "auxiliary" }: { variant?: "auxiliary" | "
<Animated.View style={dotTwoCombinedStyle} />
<Animated.View style={dotThreeCombinedStyle} />
</View>
{inFlightTurnStartedAt ? (
<LiveElapsed
startedAt={inFlightTurnStartedAt}
style={stylesheet.workingElapsed}
testID="turn-working-elapsed"
/>
) : null}
</View>
);
}
function InlineWorkingIndicatorSlot() {
function InlineWorkingIndicatorSlot({
inFlightTurnStartedAt,
}: {
inFlightTurnStartedAt: Date | null;
}) {
return (
<View style={stylesheet.inlineTurnFooter} testID="turn-working-indicator">
<WorkingIndicator variant="inline" />
<WorkingIndicator variant="inline" inFlightTurnStartedAt={inFlightTurnStartedAt} />
</View>
);
}
@@ -900,7 +935,17 @@ function TurnCopyButtonSlot({ strategy, items, startIndex }: TurnCopyButtonSlotP
}),
[strategy, items, startIndex],
);
return <TurnCopyButton getContent={getContent} />;
const header = useMemo(
() => findTurnHeaderForAssistantTurn({ strategy, items, startIndex }),
[strategy, items, startIndex],
);
return (
<AssistantTurnFooter
getContent={getContent}
startedAt={header?.startedAt}
durationMs={header?.durationMs}
/>
);
}
interface ToolCallSlotProps extends Omit<
@@ -1251,8 +1296,15 @@ const stylesheet = StyleSheet.create((theme) => ({
},
inlineWorkingIndicatorFrame: {
height: 18,
flexDirection: "row",
alignItems: "center",
justifyContent: "center",
justifyContent: "flex-start",
gap: theme.spacing[2],
},
workingElapsed: {
color: theme.colors.foregroundMuted,
fontSize: theme.fontSize.sm,
fontVariant: ["tabular-nums"],
},
workingIndicatorBubble: {
flexDirection: "row",
+225 -57
View File
@@ -74,6 +74,7 @@ import { type AssistantFileLinkSource } from "@/utils/assistant-file-link-resolv
import { useAssistantFileLinkResolver } from "@/hooks/use-assistant-file-link-resolver";
import type { ToastApi } from "@/components/toast-host";
import { splitMarkdownBlocks } from "@/utils/split-markdown-blocks";
import { formatDuration, formatMessageTimestamp } from "@/utils/time";
import {
getAssistantImageLoadStateFromMetadata,
getAssistantImageMetadata,
@@ -391,16 +392,25 @@ const userMessageStylesheet = StyleSheet.create((theme) => ({
fontSize: theme.fontSize.sm,
},
copyButton: {
alignSelf: "flex-end",
padding: theme.spacing[1],
},
trailingRow: {
alignSelf: "flex-end",
flexDirection: "row",
alignItems: "center",
gap: theme.spacing[2],
marginTop: theme.spacing[2],
},
copyButtonHidden: {
trailingRowHidden: {
opacity: 0,
},
copyButtonVisible: {
trailingRowVisible: {
opacity: 1,
},
timestampText: {
color: theme.colors.foregroundMuted,
fontSize: theme.fontSize.xs,
},
}));
function UserMessageAttachmentThumbnail({ image }: { image: UserMessageImageAttachment }) {
@@ -433,22 +443,26 @@ export const UserMessage = memo(function UserMessage({
message,
images = [],
attachments = [],
timestamp: _timestamp,
timestamp,
isFirstInGroup = true,
isLastInGroup = true,
disableOuterSpacing,
}: UserMessageProps) {
const isCompact = useIsCompactFormFactor();
const [messageHovered, setMessageHovered] = useState(false);
const [copyButtonHovered, setCopyButtonHovered] = useState(false);
const [isHovered, setIsHovered] = useState(false);
const [isCopyHovered, setIsCopyHovered] = useState(false);
const resolvedDisableOuterSpacing = useDisableOuterSpacing(disableOuterSpacing);
const hasText = message.trim().length > 0;
const hasImages = images.length > 0;
const hasAttachments = attachments.length > 0;
const showCopyButton = hasText && (isCompact || messageHovered || copyButtonHovered);
const showTrailingRow = hasText && (isCompact || isNative || isHovered || isCopyHovered);
const formattedTimestamp = useMemo(
() => formatMessageTimestamp(new Date(timestamp)),
[timestamp],
);
const handleHoverIn = useCallback(() => setMessageHovered(true), []);
const handleHoverOut = useCallback(() => setMessageHovered(false), []);
const handleHoverIn = useCallback(() => setIsHovered(true), []);
const handleHoverOut = useCallback(() => setIsHovered(false), []);
const getMessageContent = useCallback(() => message, [message]);
const containerStyle = useMemo(
@@ -476,66 +490,220 @@ export const UserMessage = memo(function UserMessage({
],
[hasText],
);
const copyButtonStyle = useMemo(
const trailingRowStyle = useMemo(
() => [
userMessageStylesheet.copyButton,
showCopyButton
? userMessageStylesheet.copyButtonVisible
: userMessageStylesheet.copyButtonHidden,
userMessageStylesheet.trailingRow,
showTrailingRow
? userMessageStylesheet.trailingRowVisible
: userMessageStylesheet.trailingRowHidden,
],
[showCopyButton],
[showTrailingRow],
);
return (
<View style={containerStyle}>
<Pressable
style={userMessageStylesheet.content}
onHoverIn={handleHoverIn}
onHoverOut={handleHoverOut}
>
<View style={userMessageStylesheet.bubble}>
{hasImages ? (
<View style={imagePreviewContainerStyle}>
{images.map((image) => (
<View key={image.id} style={userMessageStylesheet.imagePill}>
<UserMessageAttachmentThumbnail image={image} />
</View>
))}
</View>
) : null}
{hasAttachments ? (
<View style={attachmentPreviewContainerStyle}>
{attachments.map((attachment, index) => (
<View
key={`${attachment.type}:${"number" in attachment ? attachment.number : index}`}
style={userMessageStylesheet.structuredAttachmentPill}
>
<Text style={userMessageStylesheet.structuredAttachmentText} numberOfLines={1}>
{getUserMessageAttachmentLabel(attachment)}
</Text>
</View>
))}
</View>
) : null}
{hasText ? (
<Text selectable style={userMessageStylesheet.text}>
{message}
</Text>
) : null}
</View>
<View style={userMessageStylesheet.content}>
<Pressable onHoverIn={handleHoverIn} onHoverOut={handleHoverOut}>
<View style={userMessageStylesheet.bubble}>
{hasImages ? (
<View style={imagePreviewContainerStyle}>
{images.map((image) => (
<View key={image.id} style={userMessageStylesheet.imagePill}>
<UserMessageAttachmentThumbnail image={image} />
</View>
))}
</View>
) : null}
{hasAttachments ? (
<View style={attachmentPreviewContainerStyle}>
{attachments.map((attachment, index) => (
<View
key={`${attachment.type}:${"number" in attachment ? attachment.number : index}`}
style={userMessageStylesheet.structuredAttachmentPill}
>
<Text style={userMessageStylesheet.structuredAttachmentText} numberOfLines={1}>
{getUserMessageAttachmentLabel(attachment)}
</Text>
</View>
))}
</View>
) : null}
{hasText ? (
<Text selectable style={userMessageStylesheet.text}>
{message}
</Text>
) : null}
</View>
</Pressable>
{hasText ? (
<TurnCopyButton
getContent={getMessageContent}
containerStyle={copyButtonStyle}
accessibilityLabel="Copy message"
onHoverChange={setCopyButtonHovered}
/>
<View style={trailingRowStyle} pointerEvents={showTrailingRow ? "auto" : "none"}>
<Text style={userMessageStylesheet.timestampText}>{formattedTimestamp}</Text>
<TurnCopyButton
getContent={getMessageContent}
containerStyle={userMessageStylesheet.copyButton}
accessibilityLabel="Copy message"
onHoverChange={setIsCopyHovered}
/>
</View>
) : null}
</Pressable>
</View>
</View>
);
});
interface AssistantTurnFooterProps {
getContent: () => string;
startedAt?: Date;
durationMs?: number;
}
const assistantTurnFooterStylesheet = StyleSheet.create((theme) => ({
container: {
flexDirection: "row",
alignItems: "center",
gap: theme.spacing[2],
paddingVertical: theme.spacing[1],
},
copyButton: {
alignSelf: "center",
padding: theme.spacing[1],
paddingTop: theme.spacing[1],
marginTop: 0,
},
labelWrapper: {
position: "relative",
},
labelSizer: {
color: theme.colors.foregroundMuted,
fontSize: theme.fontSize.xs,
opacity: 0,
},
labelOverlay: {
position: "absolute",
top: 0,
left: 0,
color: theme.colors.foregroundMuted,
fontSize: theme.fontSize.xs,
},
}));
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)
* or tap (native). The hidden sizer keeps the label width stable while the
* visible text swaps.
*/
export const AssistantTurnFooter = memo(function AssistantTurnFooter({
getContent,
startedAt,
durationMs,
}: AssistantTurnFooterProps) {
const [hovered, setHovered] = useState(false);
const [pressedReveal, setPressedReveal] = useState(false);
const revealTimerRef = useRef<ReturnType<typeof setTimeout> | null>(null);
useEffect(() => {
return () => {
if (revealTimerRef.current) {
clearTimeout(revealTimerRef.current);
revealTimerRef.current = null;
}
};
}, []);
const durationLabel = useMemo(
() => (durationMs !== undefined ? `Worked for ${formatDuration(durationMs)}` : ""),
[durationMs],
);
const timestampLabel = useMemo(
() => (startedAt ? formatMessageTimestamp(startedAt) : ""),
[startedAt],
);
const canSwap = Boolean(timestampLabel);
const showTimestamp = canSwap && (isWeb ? hovered : pressedReveal);
const handleHoverIn = useCallback(() => setHovered(true), []);
const handleHoverOut = useCallback(() => setHovered(false), []);
const handlePress = useCallback(() => {
if (isWeb || !canSwap) return;
if (revealTimerRef.current) {
clearTimeout(revealTimerRef.current);
}
setPressedReveal((prev) => !prev);
revealTimerRef.current = setTimeout(() => {
setPressedReveal(false);
revealTimerRef.current = null;
}, TIMESTAMP_REVEAL_MS);
}, [canSwap]);
return (
<View style={assistantTurnFooterStylesheet.container}>
<TurnCopyButton
getContent={getContent}
containerStyle={assistantTurnFooterStylesheet.copyButton}
/>
{durationLabel ? (
<Pressable
onPress={handlePress}
onHoverIn={handleHoverIn}
onHoverOut={handleHoverOut}
accessibilityRole={canSwap ? "button" : undefined}
accessibilityLabel={
canSwap ? `${durationLabel}, started ${timestampLabel}` : durationLabel
}
>
<View style={assistantTurnFooterStylesheet.labelWrapper}>
{/* Sizer reserves space for whichever label is longer so the
container width is stable across hover transitions. */}
<Text style={assistantTurnFooterStylesheet.labelSizer} aria-hidden>
{durationLabel.length >= timestampLabel.length ? durationLabel : timestampLabel}
</Text>
<Text style={assistantTurnFooterStylesheet.labelOverlay}>
{showTimestamp ? timestampLabel : durationLabel}
</Text>
</View>
</Pressable>
) : null}
</View>
);
});
interface LiveElapsedProps {
startedAt: Date;
style?: StyleProp<TextStyle>;
testID?: string;
}
/**
* Ticks every 100ms to render an elapsed duration. Isolated from parents so
* only this component re-renders on each tick.
*/
export const LiveElapsed = memo(function LiveElapsed({
startedAt,
style,
testID,
}: LiveElapsedProps) {
const startedAtMs = startedAt.getTime();
const [elapsedMs, setElapsedMs] = useState(() => Math.max(0, Date.now() - startedAtMs));
useEffect(() => {
setElapsedMs(Math.max(0, Date.now() - startedAtMs));
const handle = setInterval(() => {
setElapsedMs(Math.max(0, Date.now() - startedAtMs));
}, 100);
return () => clearInterval(handle);
}, [startedAtMs]);
return (
<Text style={style} testID={testID}>
{formatDuration(elapsedMs, { mode: "live" })}
</Text>
);
});
interface AssistantMessageProps {
message: string;
timestamp: number;