fix(webui): correct activity timer duration (#4649)
This commit is contained in:
@@ -175,6 +175,8 @@ interface AgentActivityClusterProps {
|
||||
hasBodyBelow: boolean;
|
||||
/** Persisted end-to-end turn latency from the assistant answer, used for history replay. */
|
||||
turnLatencyMs?: number;
|
||||
/** User turn start timestamp for live activity before the first trace/reasoning row. */
|
||||
startedAtMs?: number;
|
||||
cliApps?: CliAppInfo[];
|
||||
mcpPresets?: McpPresetInfo[];
|
||||
onOpenFilePreview?: (path: string) => void;
|
||||
@@ -189,6 +191,7 @@ export function AgentActivityCluster({
|
||||
isTurnStreaming,
|
||||
hasBodyBelow,
|
||||
turnLatencyMs,
|
||||
startedAtMs,
|
||||
cliApps = [],
|
||||
mcpPresets = [],
|
||||
onOpenFilePreview,
|
||||
@@ -251,7 +254,13 @@ export function AgentActivityCluster({
|
||||
const hasVisibleActivity = reasoningSteps > 0 || toolCalls > 0 || cliCount > 0 || mcpCount > 0 || fileCount > 0;
|
||||
const hasOnlyFileActivity = fileCount > 0 && messages.every(messageHasOnlyFileActivity);
|
||||
const hasNonReasoningActivity = toolCalls > 0 || cliCount > 0 || mcpCount > 0 || fileCount > 0;
|
||||
const durationMs = activityDurationMs(messages, isTurnStreaming, now, turnLatencyMs);
|
||||
const durationMs = activityDurationMs(
|
||||
messages,
|
||||
isTurnStreaming,
|
||||
now,
|
||||
turnLatencyMs,
|
||||
startedAtMs,
|
||||
);
|
||||
const activityDuration = formatActivityDuration(durationMs);
|
||||
const thoughtLabel = hasNonReasoningActivity
|
||||
? isTurnStreaming
|
||||
@@ -654,6 +663,7 @@ function activityDurationMs(
|
||||
active: boolean,
|
||||
now: number,
|
||||
completedLatencyMs?: number,
|
||||
activeStartedAtMs?: number,
|
||||
): number {
|
||||
if (!active && Number.isFinite(completedLatencyMs) && completedLatencyMs! >= 0) {
|
||||
return Math.round(completedLatencyMs!);
|
||||
@@ -662,7 +672,9 @@ function activityDurationMs(
|
||||
.map((message) => message.createdAt)
|
||||
.filter((value) => Number.isFinite(value));
|
||||
if (!timestamps.length) return 0;
|
||||
const first = Math.min(...timestamps);
|
||||
const first = active && Number.isFinite(activeStartedAtMs)
|
||||
? activeStartedAtMs!
|
||||
: Math.min(...timestamps);
|
||||
const last = active && first > 1_000_000_000_000
|
||||
? now
|
||||
: Math.max(...timestamps);
|
||||
|
||||
@@ -104,6 +104,7 @@ export function ThreadMessages({
|
||||
isTurnStreaming={liveActivityClusterIndices.has(index)}
|
||||
hasBodyBelow={hasBodyBelow}
|
||||
turnLatencyMs={unit.turnLatencyMs}
|
||||
startedAtMs={unit.startedAtMs}
|
||||
cliApps={cliApps}
|
||||
mcpPresets={mcpPresets}
|
||||
onOpenFilePreview={onOpenFilePreview}
|
||||
|
||||
@@ -35,7 +35,13 @@ export interface ActivityGroup {
|
||||
}
|
||||
|
||||
export type TurnUnit =
|
||||
| { type: "activity"; messages: UIMessage[]; items: ActivityItem[]; turnLatencyMs?: number }
|
||||
| {
|
||||
type: "activity";
|
||||
messages: UIMessage[];
|
||||
items: ActivityItem[];
|
||||
turnLatencyMs?: number;
|
||||
startedAtMs?: number;
|
||||
}
|
||||
| { type: "message"; message: UIMessage };
|
||||
|
||||
interface NormalizeActivityTimelineOptions {
|
||||
@@ -91,11 +97,16 @@ export function normalizeActivityTimeline(
|
||||
const units: TurnUnit[] = [];
|
||||
let turnMessages: UIMessage[] = [];
|
||||
let activeTurnId: string | undefined;
|
||||
let activeTurnStartedAtMs: number | undefined;
|
||||
|
||||
const flushTurn = (flushOptions: NormalizeActivityTimelineOptions = {}) => {
|
||||
if (turnMessages.length === 0) return;
|
||||
if (turnMessages.length === 0) {
|
||||
activeTurnId = undefined;
|
||||
return;
|
||||
}
|
||||
|
||||
const turnUnits: TurnUnit[] = [];
|
||||
const turnStartedAtMs = activeTurnStartedAtMs;
|
||||
const orderedTurnMessages = orderMessagesByTurnSeq(turnMessages);
|
||||
const visibleMessages = visibleMessagesForTurn(orderedTurnMessages);
|
||||
let visibleIndex = 0;
|
||||
@@ -103,7 +114,12 @@ export function normalizeActivityTimeline(
|
||||
|
||||
const flushActivityMessages = () => {
|
||||
if (!activityMessages.length) return;
|
||||
pushActivityUnits(turnUnits, activityMessages, visibleMessages.slice(visibleIndex));
|
||||
pushActivityUnits(
|
||||
turnUnits,
|
||||
activityMessages,
|
||||
visibleMessages.slice(visibleIndex),
|
||||
turnStartedAtMs,
|
||||
);
|
||||
activityMessages = [];
|
||||
};
|
||||
|
||||
@@ -130,6 +146,7 @@ export function normalizeActivityTimeline(
|
||||
units.push(...normalizeCompletedTurnUnits(turnUnits, flushOptions));
|
||||
turnMessages = [];
|
||||
activeTurnId = undefined;
|
||||
activeTurnStartedAtMs = undefined;
|
||||
};
|
||||
|
||||
for (const message of messages) {
|
||||
@@ -137,6 +154,7 @@ export function normalizeActivityTimeline(
|
||||
flushTurn();
|
||||
units.push({ type: "message", message });
|
||||
activeTurnId = message.turnId;
|
||||
activeTurnStartedAtMs = validCreatedAtMs(message.createdAt);
|
||||
continue;
|
||||
}
|
||||
|
||||
@@ -206,7 +224,16 @@ function visibleMessagesForTurn(messages: UIMessage[]): UIMessage[] {
|
||||
return visibleMessages;
|
||||
}
|
||||
|
||||
function pushActivityUnits(units: TurnUnit[], activityMessages: UIMessage[], visibleMessages: UIMessage[]) {
|
||||
function validCreatedAtMs(value: unknown): number | undefined {
|
||||
return typeof value === "number" && Number.isFinite(value) ? value : undefined;
|
||||
}
|
||||
|
||||
function pushActivityUnits(
|
||||
units: TurnUnit[],
|
||||
activityMessages: UIMessage[],
|
||||
visibleMessages: UIMessage[],
|
||||
startedAtMs?: number,
|
||||
) {
|
||||
let runMessages: UIMessage[] = [];
|
||||
let runBucket: "file" | "other" | undefined;
|
||||
let runSegmentId: string | undefined;
|
||||
@@ -218,6 +245,7 @@ function pushActivityUnits(units: TurnUnit[], activityMessages: UIMessage[], vis
|
||||
messages: runMessages,
|
||||
items: runMessages.flatMap(activityItemsForMessage),
|
||||
turnLatencyMs: activityTurnLatencyMs(runMessages, visibleMessages),
|
||||
startedAtMs,
|
||||
});
|
||||
runMessages = [];
|
||||
runBucket = undefined;
|
||||
|
||||
@@ -1,5 +1,5 @@
|
||||
import { render, screen } from "@testing-library/react";
|
||||
import { describe, expect, it } from "vitest";
|
||||
import { afterEach, describe, expect, it, vi } from "vitest";
|
||||
|
||||
import {
|
||||
assistantCopyFlags,
|
||||
@@ -9,6 +9,10 @@ import {
|
||||
} from "@/components/thread/ThreadMessages";
|
||||
import type { UIMessage } from "@/lib/types";
|
||||
|
||||
afterEach(() => {
|
||||
vi.useRealTimers();
|
||||
});
|
||||
|
||||
describe("ThreadMessages", () => {
|
||||
it("groups consecutive reasoning and tool rows into one timeline before the answer", () => {
|
||||
const messages: UIMessage[] = [
|
||||
@@ -293,6 +297,45 @@ describe("ThreadMessages", () => {
|
||||
expect(screen.queryByLabelText(/editing foo\.txt/i)).not.toBeInTheDocument();
|
||||
});
|
||||
|
||||
it("times live activity from the user turn start", () => {
|
||||
vi.useFakeTimers();
|
||||
const startedAt = 1_700_000_000_000;
|
||||
vi.setSystemTime(startedAt + 230_000);
|
||||
const messages: UIMessage[] = [
|
||||
{
|
||||
id: "u1",
|
||||
role: "user",
|
||||
content: "run it",
|
||||
turnId: "turn-1",
|
||||
turnPhase: "user",
|
||||
turnSeq: 1,
|
||||
createdAt: startedAt,
|
||||
},
|
||||
{
|
||||
id: "t1",
|
||||
role: "tool",
|
||||
kind: "trace",
|
||||
content: "exec()",
|
||||
traces: ["exec()"],
|
||||
turnId: "turn-1",
|
||||
turnPhase: "activity",
|
||||
turnSeq: 2,
|
||||
createdAt: startedAt + 220_000,
|
||||
},
|
||||
];
|
||||
|
||||
const units = buildDisplayUnits(messages, true);
|
||||
|
||||
expect(
|
||||
units[1].type === "activity" ? units[1].startedAtMs : undefined,
|
||||
).toBe(startedAt);
|
||||
|
||||
render(<ThreadMessages messages={messages} isStreaming />);
|
||||
|
||||
expect(screen.getByText("Working for 3m 50s")).toBeInTheDocument();
|
||||
expect(screen.queryByText("Working for 10s")).not.toBeInTheDocument();
|
||||
});
|
||||
|
||||
it("folds final answer reasoning into the preceding activity timeline", () => {
|
||||
const messages: UIMessage[] = [
|
||||
{
|
||||
|
||||
Reference in New Issue
Block a user