Use daemon timestamps for turn timing (#1033)
This commit is contained in:
@@ -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",
|
||||
|
||||
@@ -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;
|
||||
|
||||
@@ -1,3 +1,4 @@
|
||||
import invariant from "tiny-invariant";
|
||||
import { describe, expect, it } from "vitest";
|
||||
import type { AgentStreamEventPayload } from "@server/shared/messages";
|
||||
import type { StreamItem } from "@/types/stream";
|
||||
@@ -218,6 +219,36 @@ describe("processTimelineResponse", () => {
|
||||
expect(result.sideEffects.some((e) => e.type === "flush_pending_updates")).toBe(true);
|
||||
});
|
||||
|
||||
it("uses the timeline entry timestamp as canonical", () => {
|
||||
const result = processTimelineResponse({
|
||||
...baseTimelineInput,
|
||||
payload: {
|
||||
...baseTimelineInput.payload,
|
||||
reset: true,
|
||||
startCursor: { seq: 1 },
|
||||
endCursor: { seq: 2 },
|
||||
entries: [
|
||||
{
|
||||
...makeTimelineEntry(1, "hello", "user_message"),
|
||||
timestamp: new Date("2025-01-01T12:00:03Z").toISOString(),
|
||||
},
|
||||
{
|
||||
...makeTimelineEntry(2, "reply"),
|
||||
timestamp: new Date("2025-01-01T12:00:04Z").toISOString(),
|
||||
},
|
||||
],
|
||||
},
|
||||
});
|
||||
|
||||
const user = result.tail.find((item) => item.kind === "user_message");
|
||||
const header = result.tail.find((item) => item.kind === "turn_header");
|
||||
|
||||
expect(user?.timestamp.toISOString()).toBe("2025-01-01T12:00:03.000Z");
|
||||
invariant(header?.kind === "turn_header");
|
||||
expect(header.startedAt.toISOString()).toBe("2025-01-01T12:00:04.000Z");
|
||||
expect(header.completedAt?.toISOString()).toBe("2025-01-01T12:00:04.000Z");
|
||||
});
|
||||
|
||||
it("sets cursor to null when reset=true but no cursors in payload", () => {
|
||||
const result = processTimelineResponse({
|
||||
...baseTimelineInput,
|
||||
|
||||
@@ -395,7 +395,10 @@ function applyTimelineIncrementalPath(args: {
|
||||
if (acceptedUnits.length > 0) {
|
||||
if (payload.direction === "before") {
|
||||
const olderTail = hydrateStreamState(
|
||||
acceptedUnits.map(({ event, timestamp }) => ({ event, timestamp })),
|
||||
acceptedUnits.map(({ event, timestamp }) => ({
|
||||
event,
|
||||
timestamp,
|
||||
})),
|
||||
{ source: "canonical" },
|
||||
);
|
||||
nextTail = mergePrependedCanonicalTail(olderTail, currentTail);
|
||||
@@ -414,7 +417,9 @@ function applyTimelineIncrementalPath(args: {
|
||||
} else {
|
||||
nextTail = acceptedUnits.reduce<StreamItem[]>(
|
||||
(state, { event, timestamp }) =>
|
||||
reduceStreamUpdate(state, event, timestamp, { source: "canonical" }),
|
||||
reduceStreamUpdate(state, event, timestamp, {
|
||||
source: "canonical",
|
||||
}),
|
||||
currentTail,
|
||||
);
|
||||
}
|
||||
@@ -482,7 +487,7 @@ export function processTimelineResponse(
|
||||
}));
|
||||
|
||||
const toHydratedEvents = (
|
||||
units: typeof timelineUnits,
|
||||
units: TimelineUnit[],
|
||||
): Array<{ event: AgentStreamEventPayload; timestamp: Date }> =>
|
||||
units.map(({ event, timestamp }) => ({ event, timestamp }));
|
||||
|
||||
|
||||
@@ -0,0 +1,147 @@
|
||||
import type { StreamItem, TurnHeaderItem, TurnHeaderOutcome } from "@/types/stream";
|
||||
|
||||
export function appendLiveTurnHeader(state: StreamItem[], startedAt: Date): StreamItem[] {
|
||||
const lastHeader = findLastTurnHeader(state);
|
||||
if (lastHeader && lastHeader.completedAt === undefined) {
|
||||
return state;
|
||||
}
|
||||
const header: TurnHeaderItem = {
|
||||
kind: "turn_header",
|
||||
id: `turn_${startedAt.getTime()}_${state.length.toString(36)}`,
|
||||
timestamp: startedAt,
|
||||
startedAt,
|
||||
source: "live",
|
||||
};
|
||||
return [...state, header];
|
||||
}
|
||||
|
||||
export function completeLastTurnHeader(
|
||||
state: StreamItem[],
|
||||
completedAt: Date,
|
||||
outcome: TurnHeaderOutcome,
|
||||
): StreamItem[] {
|
||||
for (let i = state.length - 1; i >= 0; i -= 1) {
|
||||
const item = state[i];
|
||||
if (item?.kind !== "turn_header") {
|
||||
continue;
|
||||
}
|
||||
if (item.completedAt !== undefined) {
|
||||
return state;
|
||||
}
|
||||
const updated: TurnHeaderItem = {
|
||||
...item,
|
||||
completedAt,
|
||||
durationMs: Math.max(0, completedAt.getTime() - item.startedAt.getTime()),
|
||||
outcome,
|
||||
};
|
||||
const next = [...state];
|
||||
next[i] = updated;
|
||||
return next;
|
||||
}
|
||||
return state;
|
||||
}
|
||||
|
||||
export function synthesizeMissingTurnHeaders(state: StreamItem[]): StreamItem[] {
|
||||
const result: StreamItem[] = [];
|
||||
let segment: StreamItem[] = [];
|
||||
let needsHeader = true;
|
||||
|
||||
function flushSegment() {
|
||||
if (segment.length === 0) {
|
||||
return;
|
||||
}
|
||||
if (needsHeader) {
|
||||
const first = segment[0];
|
||||
const last = segment[segment.length - 1];
|
||||
const startedAt = first.timestamp;
|
||||
const completedAt = last.timestamp;
|
||||
const header: TurnHeaderItem = {
|
||||
kind: "turn_header",
|
||||
id: `turn_derived_${startedAt.getTime()}_${result.length.toString(36)}`,
|
||||
timestamp: startedAt,
|
||||
startedAt,
|
||||
completedAt,
|
||||
durationMs: Math.max(0, completedAt.getTime() - startedAt.getTime()),
|
||||
outcome: "completed",
|
||||
source: "derived",
|
||||
};
|
||||
result.push(header);
|
||||
}
|
||||
result.push(...segment);
|
||||
segment = [];
|
||||
}
|
||||
|
||||
for (const item of state) {
|
||||
if (item.kind === "user_message") {
|
||||
flushSegment();
|
||||
needsHeader = true;
|
||||
result.push(item);
|
||||
continue;
|
||||
}
|
||||
if (item.kind === "turn_header") {
|
||||
flushSegment();
|
||||
needsHeader = false;
|
||||
result.push(item);
|
||||
continue;
|
||||
}
|
||||
segment.push(item);
|
||||
}
|
||||
|
||||
flushSegment();
|
||||
return result;
|
||||
}
|
||||
|
||||
export function findInFlightTurnStartedAt(params: {
|
||||
agentStatus: string;
|
||||
liveHead: StreamItem[];
|
||||
tail: StreamItem[];
|
||||
}): Date | null {
|
||||
if (params.agentStatus !== "running") {
|
||||
return null;
|
||||
}
|
||||
const headStartedAt = findLastOpenTurnHeader(params.liveHead)?.startedAt;
|
||||
if (headStartedAt) {
|
||||
return headStartedAt;
|
||||
}
|
||||
return findLastOpenTurnHeader(params.tail)?.startedAt ?? null;
|
||||
}
|
||||
|
||||
export interface TurnHeaderNeighborResolver {
|
||||
getNeighborIndex(index: number, relation: "above" | "below"): number;
|
||||
}
|
||||
|
||||
export function findTurnHeaderForAssistantTurn(params: {
|
||||
strategy: TurnHeaderNeighborResolver;
|
||||
items: StreamItem[];
|
||||
startIndex: number;
|
||||
}): TurnHeaderItem | null {
|
||||
let index = params.startIndex;
|
||||
while (index >= 0 && index < params.items.length) {
|
||||
const item = params.items[index];
|
||||
if (!item) return null;
|
||||
if (item.kind === "turn_header") return item;
|
||||
if (item.kind === "user_message") return null;
|
||||
index = params.strategy.getNeighborIndex(index, "above");
|
||||
}
|
||||
return null;
|
||||
}
|
||||
|
||||
function findLastTurnHeader(state: StreamItem[]): TurnHeaderItem | null {
|
||||
for (let i = state.length - 1; i >= 0; i -= 1) {
|
||||
const item = state[i];
|
||||
if (item?.kind === "turn_header") {
|
||||
return item;
|
||||
}
|
||||
}
|
||||
return null;
|
||||
}
|
||||
|
||||
function findLastOpenTurnHeader(state: StreamItem[]): TurnHeaderItem | null {
|
||||
for (let i = state.length - 1; i >= 0; i -= 1) {
|
||||
const item = state[i];
|
||||
if (item?.kind === "turn_header" && item.completedAt === undefined) {
|
||||
return item;
|
||||
}
|
||||
}
|
||||
return null;
|
||||
}
|
||||
@@ -1,4 +1,5 @@
|
||||
import assert from "node:assert/strict";
|
||||
import invariant from "tiny-invariant";
|
||||
import { describe, it } from "vitest";
|
||||
|
||||
import {
|
||||
@@ -325,14 +326,14 @@ describe("stream reducer canonical tool calls", () => {
|
||||
},
|
||||
]);
|
||||
|
||||
assert.deepStrictEqual(
|
||||
state.map((item) => (item.kind === "assistant_message" ? item.text : item.kind)),
|
||||
["First answer.", "Second answer."],
|
||||
);
|
||||
assert.deepStrictEqual(
|
||||
state.map((item) => (item.kind === "assistant_message" ? item.messageId : null)),
|
||||
["msg-first", "msg-second"],
|
||||
);
|
||||
const messages = state.filter((item) => item.kind === "assistant_message");
|
||||
assert.strictEqual(messages.length, 2);
|
||||
const first = messages[0];
|
||||
const second = messages[1];
|
||||
invariant(first?.kind === "assistant_message");
|
||||
invariant(second?.kind === "assistant_message");
|
||||
assert.deepStrictEqual([first.text, second.text], ["First answer.", "Second answer."]);
|
||||
assert.deepStrictEqual([first.messageId, second.messageId], ["msg-first", "msg-second"]);
|
||||
});
|
||||
|
||||
it("merges adjacent assistant deltas when message ids match", () => {
|
||||
@@ -347,13 +348,13 @@ describe("stream reducer canonical tool calls", () => {
|
||||
},
|
||||
]);
|
||||
|
||||
assert.strictEqual(state.length, 1);
|
||||
assert.strictEqual(state[0]?.kind, "assistant_message");
|
||||
if (state[0]?.kind === "assistant_message") {
|
||||
assert.strictEqual(state[0].text, "Hello");
|
||||
assert.strictEqual(state[0].id, "msg-same");
|
||||
assert.strictEqual(state[0].messageId, "msg-same");
|
||||
}
|
||||
const messages = state.filter((item) => item.kind === "assistant_message");
|
||||
assert.strictEqual(messages.length, 1);
|
||||
const first = messages[0];
|
||||
invariant(first?.kind === "assistant_message");
|
||||
assert.strictEqual(first.text, "Hello");
|
||||
assert.strictEqual(first.id, "msg-same");
|
||||
assert.strictEqual(first.messageId, "msg-same");
|
||||
});
|
||||
|
||||
it("preserves old assistant merge behavior when message ids are absent", () => {
|
||||
@@ -368,8 +369,11 @@ describe("stream reducer canonical tool calls", () => {
|
||||
},
|
||||
]);
|
||||
|
||||
assert.strictEqual(state.length, 1);
|
||||
assert.strictEqual(state[0]?.kind === "assistant_message" ? state[0].text : null, "Hello");
|
||||
const messages = state.filter((item) => item.kind === "assistant_message");
|
||||
assert.strictEqual(messages.length, 1);
|
||||
const first = messages[0];
|
||||
invariant(first?.kind === "assistant_message");
|
||||
assert.strictEqual(first.text, "Hello");
|
||||
});
|
||||
|
||||
it("merges running and completed events by callId", () => {
|
||||
@@ -836,3 +840,113 @@ describe("stream reducer canonical tool calls", () => {
|
||||
);
|
||||
});
|
||||
});
|
||||
|
||||
describe("turn headers", () => {
|
||||
it("inserts a live header on turn_started and closes it on turn_completed", () => {
|
||||
const startedAt = new Date("2025-01-01T12:00:00Z");
|
||||
const completedAt = new Date("2025-01-01T12:00:05Z");
|
||||
|
||||
let state = reduceStreamUpdate([], { type: "turn_started", provider: "claude" }, startedAt);
|
||||
state = reduceStreamUpdate(
|
||||
state,
|
||||
{ type: "timeline", provider: "claude", item: { type: "assistant_message", text: "ok" } },
|
||||
new Date("2025-01-01T12:00:02Z"),
|
||||
);
|
||||
state = reduceStreamUpdate(state, { type: "turn_completed", provider: "claude" }, completedAt);
|
||||
|
||||
const header = state.find((item) => item.kind === "turn_header");
|
||||
invariant(header?.kind === "turn_header");
|
||||
assert.strictEqual(header.source, "live");
|
||||
assert.strictEqual(header.outcome, "completed");
|
||||
assert.strictEqual(header.durationMs, 5_000);
|
||||
assert.strictEqual(header.startedAt.getTime(), startedAt.getTime());
|
||||
assert.strictEqual(header.completedAt?.getTime(), completedAt.getTime());
|
||||
});
|
||||
|
||||
it("synthesizes a derived header during hydration when turn events are missing", () => {
|
||||
const state = hydrateStreamState([
|
||||
{
|
||||
event: {
|
||||
type: "timeline",
|
||||
provider: "claude",
|
||||
item: { type: "user_message", text: "hi" },
|
||||
},
|
||||
timestamp: new Date("2025-01-01T13:00:00Z"),
|
||||
},
|
||||
{
|
||||
event: assistantTimeline("Working on it.", "claude", "msg-1"),
|
||||
timestamp: new Date("2025-01-01T13:00:01Z"),
|
||||
},
|
||||
{
|
||||
event: assistantTimeline("Done.", "claude", "msg-2"),
|
||||
timestamp: new Date("2025-01-01T13:00:04Z"),
|
||||
},
|
||||
]);
|
||||
|
||||
const headers = state.filter((item) => item.kind === "turn_header");
|
||||
assert.strictEqual(headers.length, 1);
|
||||
const [header] = headers;
|
||||
invariant(header?.kind === "turn_header");
|
||||
assert.strictEqual(header.source, "derived");
|
||||
assert.strictEqual(header.outcome, "completed");
|
||||
assert.strictEqual(header.durationMs, 3_000);
|
||||
// Header should sit between the user message and the first assistant item.
|
||||
const kinds = state.map((item) => item.kind);
|
||||
assert.deepStrictEqual(kinds, [
|
||||
"user_message",
|
||||
"turn_header",
|
||||
"assistant_message",
|
||||
"assistant_message",
|
||||
]);
|
||||
});
|
||||
|
||||
it("does not synthesize a derived header when a live header is already present", () => {
|
||||
const startedAt = new Date("2025-01-01T14:00:00Z");
|
||||
const state = hydrateStreamState([
|
||||
{
|
||||
event: {
|
||||
type: "timeline",
|
||||
provider: "claude",
|
||||
item: { type: "user_message", text: "hi" },
|
||||
},
|
||||
timestamp: new Date("2025-01-01T13:59:59Z"),
|
||||
},
|
||||
{ event: { type: "turn_started", provider: "claude" }, timestamp: startedAt },
|
||||
{
|
||||
event: assistantTimeline("ok", "claude", "msg-1"),
|
||||
timestamp: new Date("2025-01-01T14:00:02Z"),
|
||||
},
|
||||
]);
|
||||
|
||||
const headers = state.filter((item) => item.kind === "turn_header");
|
||||
assert.strictEqual(headers.length, 1);
|
||||
const header = headers[0];
|
||||
invariant(header?.kind === "turn_header");
|
||||
assert.strictEqual(header.source, "live");
|
||||
assert.strictEqual(header.completedAt, undefined);
|
||||
});
|
||||
|
||||
it("skips synthesis for turns with no assistant output", () => {
|
||||
const state = hydrateStreamState([
|
||||
{
|
||||
event: {
|
||||
type: "timeline",
|
||||
provider: "claude",
|
||||
item: { type: "user_message", text: "hi" },
|
||||
},
|
||||
timestamp: new Date("2025-01-01T15:00:00Z"),
|
||||
},
|
||||
{
|
||||
event: {
|
||||
type: "timeline",
|
||||
provider: "claude",
|
||||
item: { type: "user_message", text: "still there?" },
|
||||
},
|
||||
timestamp: new Date("2025-01-01T15:01:00Z"),
|
||||
},
|
||||
]);
|
||||
|
||||
const headers = state.filter((item) => item.kind === "turn_header");
|
||||
assert.strictEqual(headers.length, 0);
|
||||
});
|
||||
});
|
||||
|
||||
@@ -3,6 +3,11 @@ import type { AgentAttachment, AgentStreamEventPayload } from "@server/shared/me
|
||||
import type { AttachmentMetadata } from "@/attachments/types";
|
||||
import { extractTaskEntriesFromToolCall } from "../utils/tool-call-parsers";
|
||||
import { splitMarkdownBlocks } from "@/utils/split-markdown-blocks";
|
||||
import {
|
||||
appendLiveTurnHeader,
|
||||
completeLastTurnHeader,
|
||||
synthesizeMissingTurnHeaders,
|
||||
} from "@/timeline/turn-time";
|
||||
|
||||
/**
|
||||
* Simple hash function for deterministic ID generation
|
||||
@@ -50,7 +55,21 @@ export type StreamItem =
|
||||
| ToolCallItem
|
||||
| TodoListItem
|
||||
| ActivityLogItem
|
||||
| CompactionItem;
|
||||
| CompactionItem
|
||||
| TurnHeaderItem;
|
||||
|
||||
export type TurnHeaderOutcome = "completed" | "failed" | "canceled";
|
||||
|
||||
export interface TurnHeaderItem {
|
||||
kind: "turn_header";
|
||||
id: string;
|
||||
timestamp: Date;
|
||||
startedAt: Date;
|
||||
completedAt?: Date;
|
||||
durationMs?: number;
|
||||
outcome?: TurnHeaderOutcome;
|
||||
source: "live" | "derived";
|
||||
}
|
||||
|
||||
export type UserMessageImageAttachment = AttachmentMetadata;
|
||||
|
||||
@@ -704,10 +723,15 @@ export function reduceStreamUpdate(
|
||||
case "timeline":
|
||||
return reduceTimelineEvent(state, event, timestamp, source);
|
||||
case "thread_started":
|
||||
return finalizeActiveThoughts(state);
|
||||
case "turn_started":
|
||||
return appendLiveTurnHeader(finalizeActiveThoughts(state), timestamp);
|
||||
case "turn_completed":
|
||||
return completeLastTurnHeader(finalizeActiveThoughts(state), timestamp, "completed");
|
||||
case "turn_failed":
|
||||
return completeLastTurnHeader(finalizeActiveThoughts(state), timestamp, "failed");
|
||||
case "turn_canceled":
|
||||
return completeLastTurnHeader(finalizeActiveThoughts(state), timestamp, "canceled");
|
||||
case "permission_requested":
|
||||
case "permission_resolved":
|
||||
case "attention_required":
|
||||
@@ -721,14 +745,17 @@ export function reduceStreamUpdate(
|
||||
* Hydrate stream state from a batch of AgentManager stream events
|
||||
*/
|
||||
export function hydrateStreamState(
|
||||
events: Array<{ event: AgentStreamEventPayload; timestamp: Date }>,
|
||||
events: Array<{
|
||||
event: AgentStreamEventPayload;
|
||||
timestamp: Date;
|
||||
}>,
|
||||
options?: { source?: StreamUpdateSource },
|
||||
): StreamItem[] {
|
||||
const hydrated = events.reduce<StreamItem[]>((state, { event, timestamp }) => {
|
||||
return reduceStreamUpdate(state, event, timestamp, options);
|
||||
}, []);
|
||||
|
||||
return finalizeActiveThoughts(hydrated);
|
||||
return synthesizeMissingTurnHeaders(finalizeActiveThoughts(hydrated));
|
||||
}
|
||||
|
||||
/**
|
||||
@@ -749,6 +776,16 @@ const STREAM_COMPLETION_EVENTS = new Set<AgentStreamEventPayload["type"]>([
|
||||
"turn_canceled",
|
||||
]);
|
||||
|
||||
function applyCompletionToTail(
|
||||
tail: StreamItem[],
|
||||
event: AgentStreamEventPayload,
|
||||
timestamp: Date,
|
||||
source: StreamUpdateSource,
|
||||
): StreamItem[] {
|
||||
const finalized = finalizeActiveThoughts(tail);
|
||||
return reduceStreamUpdate(finalized, event, timestamp, { source });
|
||||
}
|
||||
|
||||
/**
|
||||
* Determine what kind of StreamItem an event would produce
|
||||
*/
|
||||
@@ -813,6 +850,28 @@ function getActiveAssistantHeadIndex(head: StreamItem[]): number {
|
||||
return -1;
|
||||
}
|
||||
|
||||
function getTailAssistantToResume(params: {
|
||||
incomingKind: StreamItem["kind"] | null;
|
||||
event: AgentStreamEventPayload;
|
||||
nextHead: StreamItem[];
|
||||
tailAssistant: StreamItem | undefined;
|
||||
}): AssistantMessageItem | null {
|
||||
if (params.incomingKind !== "assistant_message" || params.nextHead.length !== 0) {
|
||||
return null;
|
||||
}
|
||||
if (params.tailAssistant?.kind !== "assistant_message") {
|
||||
return null;
|
||||
}
|
||||
const incomingMessageId =
|
||||
params.event.type === "timeline" && params.event.item.type === "assistant_message"
|
||||
? params.event.item.messageId
|
||||
: undefined;
|
||||
if (incomingMessageId !== undefined && params.tailAssistant.messageId !== incomingMessageId) {
|
||||
return null;
|
||||
}
|
||||
return params.tailAssistant;
|
||||
}
|
||||
|
||||
function promoteCompletedAssistantBlocks(params: { tail: StreamItem[]; head: StreamItem[] }): {
|
||||
tail: StreamItem[];
|
||||
head: StreamItem[];
|
||||
@@ -986,8 +1045,7 @@ export function applyStreamEvent(params: {
|
||||
// Handle turn completion events - flush everything
|
||||
if (STREAM_COMPLETION_EVENTS.has(event.type)) {
|
||||
flushHead();
|
||||
// Also finalize any remaining thoughts in tail
|
||||
const finalized = finalizeActiveThoughts(nextTail);
|
||||
const finalized = applyCompletionToTail(nextTail, event, timestamp, source);
|
||||
if (finalized !== nextTail) {
|
||||
nextTail = finalized;
|
||||
changedTail = true;
|
||||
@@ -1002,21 +1060,17 @@ export function applyStreamEvent(params: {
|
||||
flushHead();
|
||||
}
|
||||
|
||||
if (incomingKind === "assistant_message" && nextHead.length === 0) {
|
||||
const tailAssistant = nextTail.at(-1);
|
||||
const incomingMessageId =
|
||||
event.type === "timeline" && event.item.type === "assistant_message"
|
||||
? event.item.messageId
|
||||
: undefined;
|
||||
const shouldContinueTailAssistant =
|
||||
tailAssistant?.kind === "assistant_message" &&
|
||||
(incomingMessageId === undefined || tailAssistant.messageId === incomingMessageId);
|
||||
if (shouldContinueTailAssistant) {
|
||||
nextTail = nextTail.slice(0, -1);
|
||||
nextHead = [tailAssistant];
|
||||
changedTail = true;
|
||||
changedHead = true;
|
||||
}
|
||||
const tailAssistant = getTailAssistantToResume({
|
||||
incomingKind,
|
||||
event,
|
||||
nextHead,
|
||||
tailAssistant: nextTail.at(-1),
|
||||
});
|
||||
if (tailAssistant) {
|
||||
nextTail = nextTail.slice(0, -1);
|
||||
nextHead = [tailAssistant];
|
||||
changedTail = true;
|
||||
changedHead = true;
|
||||
}
|
||||
|
||||
// For streamable kinds, apply to head
|
||||
|
||||
@@ -0,0 +1,63 @@
|
||||
import { describe, it, expect } from "vitest";
|
||||
import { formatDuration, formatMessageTimestamp } from "./time";
|
||||
|
||||
describe("formatDuration", () => {
|
||||
it("renders static durations as integers", () => {
|
||||
expect(formatDuration(5_600)).toBe("5s");
|
||||
expect(formatDuration(0)).toBe("0s");
|
||||
});
|
||||
|
||||
it("renders 10s-60s as whole seconds", () => {
|
||||
expect(formatDuration(47_000)).toBe("47s");
|
||||
expect(formatDuration(10_400)).toBe("10s");
|
||||
});
|
||||
|
||||
it("renders minutes and remainder seconds", () => {
|
||||
expect(formatDuration(132_000)).toBe("2m 12s");
|
||||
expect(formatDuration(120_000)).toBe("2m");
|
||||
});
|
||||
|
||||
it("renders hours and remainder minutes", () => {
|
||||
expect(formatDuration(3_900_000)).toBe("1h 5m");
|
||||
expect(formatDuration(3_600_000)).toBe("1h");
|
||||
});
|
||||
|
||||
it("guards against negative and NaN", () => {
|
||||
expect(formatDuration(-1)).toBe("0s");
|
||||
expect(formatDuration(Number.NaN)).toBe("0s");
|
||||
});
|
||||
|
||||
it("keeps one decimal for live durations", () => {
|
||||
expect(formatDuration(5_600, { mode: "live" })).toBe("5.6s");
|
||||
expect(formatDuration(12_340, { mode: "live" })).toBe("12.3s");
|
||||
expect(formatDuration(75_230, { mode: "live" })).toBe("1m 15.2s");
|
||||
expect(formatDuration(0, { mode: "live" })).toBe("0.0s");
|
||||
});
|
||||
});
|
||||
|
||||
describe("formatMessageTimestamp", () => {
|
||||
it("shows only time for same-day timestamps", () => {
|
||||
const now = new Date(2026, 4, 14, 17, 30);
|
||||
const date = new Date(2026, 4, 14, 12, 23);
|
||||
const formatted = formatMessageTimestamp(date, now);
|
||||
expect(formatted).toMatch(/12:23/);
|
||||
expect(formatted).not.toMatch(/Thursday|Wednesday/);
|
||||
});
|
||||
|
||||
it("includes weekday for timestamps within the last 6 days", () => {
|
||||
// 2026-05-14 is a Thursday. 2026-05-11 is a Monday.
|
||||
const now = new Date(2026, 4, 14, 17, 30);
|
||||
const date = new Date(2026, 4, 11, 22, 12);
|
||||
const formatted = formatMessageTimestamp(date, now);
|
||||
expect(formatted).toMatch(/Monday/);
|
||||
expect(formatted).toMatch(/10:12 PM|22:12/);
|
||||
});
|
||||
|
||||
it("includes full date for older timestamps", () => {
|
||||
const now = new Date(2026, 4, 14, 17, 30);
|
||||
const date = new Date(2026, 3, 1, 9, 5);
|
||||
const formatted = formatMessageTimestamp(date, now);
|
||||
expect(formatted).toMatch(/Apr|April/);
|
||||
expect(formatted).toMatch(/2026/);
|
||||
});
|
||||
});
|
||||
@@ -35,3 +35,87 @@ export function formatTimeAgo(date: Date): string {
|
||||
const day = date.getDate();
|
||||
return `${month} ${day}`;
|
||||
}
|
||||
|
||||
function isSameLocalDay(a: Date, b: Date): boolean {
|
||||
return (
|
||||
a.getFullYear() === b.getFullYear() &&
|
||||
a.getMonth() === b.getMonth() &&
|
||||
a.getDate() === b.getDate()
|
||||
);
|
||||
}
|
||||
|
||||
/**
|
||||
* Format a chat-message timestamp for hover-revealed UI.
|
||||
* - Same day: "10:11 PM"
|
||||
* - Within ~6 days: "Wednesday 10:11 PM"
|
||||
* - Older: "14 May 2026, 10:11 PM"
|
||||
*/
|
||||
export function formatMessageTimestamp(date: Date, now: Date = new Date()): string {
|
||||
const time = date.toLocaleTimeString(undefined, {
|
||||
hour: "numeric",
|
||||
minute: "2-digit",
|
||||
});
|
||||
|
||||
if (isSameLocalDay(date, now)) {
|
||||
return time;
|
||||
}
|
||||
|
||||
const diffMs = now.getTime() - date.getTime();
|
||||
const diffDays = Math.floor(diffMs / (1000 * 60 * 60 * 24));
|
||||
if (diffDays >= 0 && diffDays < 7) {
|
||||
const weekday = date.toLocaleDateString(undefined, { weekday: "long" });
|
||||
return `${weekday} ${time}`;
|
||||
}
|
||||
|
||||
const dateLabel = date.toLocaleDateString(undefined, {
|
||||
day: "numeric",
|
||||
month: "short",
|
||||
year: "numeric",
|
||||
});
|
||||
return `${dateLabel}, ${time}`;
|
||||
}
|
||||
|
||||
type DurationFormatMode = "static" | "live";
|
||||
|
||||
/**
|
||||
* Format a duration as a compact human-readable string.
|
||||
* - Static: integer units only
|
||||
* - Live: always keep one decimal in the smallest displayed unit
|
||||
*/
|
||||
export function formatDuration(
|
||||
durationMs: number,
|
||||
options?: { mode?: DurationFormatMode },
|
||||
): string {
|
||||
const mode = options?.mode ?? "static";
|
||||
if (!Number.isFinite(durationMs) || durationMs < 0) {
|
||||
return mode === "live" ? "0.0s" : "0s";
|
||||
}
|
||||
const totalSeconds = durationMs / 1000;
|
||||
|
||||
if (mode === "live") {
|
||||
if (totalSeconds < 60) {
|
||||
return `${totalSeconds.toFixed(1)}s`;
|
||||
}
|
||||
if (totalSeconds < 60 * 60) {
|
||||
const minutes = Math.floor(totalSeconds / 60);
|
||||
const seconds = totalSeconds - minutes * 60;
|
||||
return `${minutes}m ${seconds.toFixed(1)}s`;
|
||||
}
|
||||
const hours = Math.floor(totalSeconds / (60 * 60));
|
||||
const minutes = (totalSeconds - hours * 60 * 60) / 60;
|
||||
return `${hours}h ${minutes.toFixed(1)}m`;
|
||||
}
|
||||
|
||||
const wholeSeconds = Math.floor(totalSeconds);
|
||||
if (wholeSeconds < 60) {
|
||||
return `${wholeSeconds}s`;
|
||||
}
|
||||
const minutes = Math.floor(wholeSeconds / 60);
|
||||
if (minutes < 60) {
|
||||
const seconds = wholeSeconds % 60;
|
||||
return seconds === 0 ? `${minutes}m` : `${minutes}m ${seconds}s`;
|
||||
}
|
||||
const hours = Math.floor(minutes / 60);
|
||||
const remMinutes = minutes % 60;
|
||||
return remMinutes === 0 ? `${hours}h` : `${hours}h ${remMinutes}m`;
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user