diff --git a/autogpt_platform/backend/backend/copilot/model.py b/autogpt_platform/backend/backend/copilot/model.py index 1adef8e7c882..75d00aceb347 100644 --- a/autogpt_platform/backend/backend/copilot/model.py +++ b/autogpt_platform/backend/backend/copilot/model.py @@ -72,6 +72,7 @@ class ChatMessage(BaseModel): function_call: dict | None = None sequence: int | None = None duration_ms: int | None = None + created_at: datetime | None = None @staticmethod def from_db(prisma_message: PrismaChatMessage) -> "ChatMessage": @@ -86,6 +87,7 @@ def from_db(prisma_message: PrismaChatMessage) -> "ChatMessage": function_call=_parse_json_field(prisma_message.functionCall), sequence=prisma_message.sequence, duration_ms=prisma_message.durationMs, + created_at=prisma_message.createdAt, ) diff --git a/autogpt_platform/backend/backend/copilot/model_test.py b/autogpt_platform/backend/backend/copilot/model_test.py index d7e3696a3120..b1dd1a6596d2 100644 --- a/autogpt_platform/backend/backend/copilot/model_test.py +++ b/autogpt_platform/backend/backend/copilot/model_test.py @@ -1063,3 +1063,36 @@ async def test_get_or_create_builder_session_recreates_when_pointer_stale( assert result is new_session create_mock.assert_awaited_once() library_db_mock.update_library_agent.assert_awaited_once() + + +def test_chat_message_from_db_round_trips_created_at() -> None: + """ChatMessage.from_db surfaces the DB row's createdAt on the pydantic + model so the API response carries it through to the frontend's TurnStats + map (powering the hover-reveal date on the copilot UI).""" + from datetime import datetime, timezone + + from prisma.models import ChatMessage as PrismaChatMessage + + created_at = datetime(2026, 4, 23, 10, 15, 30, tzinfo=timezone.utc) + row = PrismaChatMessage.model_construct( + id="m1", + sessionId="sess-1", + role="assistant", + content="hi", + name=None, + toolCallId=None, + refusal=None, + toolCalls=None, + functionCall=None, + sequence=3, + durationMs=4200, + createdAt=created_at, + ) + + msg = ChatMessage.from_db(row) + + assert msg.role == "assistant" + assert msg.content == "hi" + assert msg.sequence == 3 + assert msg.duration_ms == 4200 + assert msg.created_at == created_at diff --git a/autogpt_platform/backend/backend/copilot/stream_registry.py b/autogpt_platform/backend/backend/copilot/stream_registry.py index 79deadacc0fc..bade6d143e5f 100644 --- a/autogpt_platform/backend/backend/copilot/stream_registry.py +++ b/autogpt_platform/backend/backend/copilot/stream_registry.py @@ -870,9 +870,9 @@ async def mark_session_completed( f"Failed to publish error event for session {session_id}: {e}" ) - # Compute wall-clock duration from session created_at. - # Only persist when (a) the session completed successfully and - # (b) created_at was actually present in Redis meta (not a fallback). + # Compute wall-clock duration from session created_at. Only persist when + # the session completed successfully and created_at was actually present + # in Redis meta (not a fallback). duration_ms: int | None = None if meta and not error_message: created_at_raw = meta.get("created_at") diff --git a/autogpt_platform/backend/backend/copilot/stream_registry_test.py b/autogpt_platform/backend/backend/copilot/stream_registry_test.py index db26a5f524f9..9da23fbda3e2 100644 --- a/autogpt_platform/backend/backend/copilot/stream_registry_test.py +++ b/autogpt_platform/backend/backend/copilot/stream_registry_test.py @@ -249,6 +249,14 @@ async def _record_delete(self, *keys: str): async def hgetall(self, _key: str): return dict(self._meta) + async def hdel(self, _key: str, *fields: str) -> int: + removed = 0 + for f in fields: + if f in self._meta: + del self._meta[f] + removed += 1 + return removed + @pytest.mark.asyncio async def test_mark_session_completed_releases_cluster_lock_on_success(): diff --git a/autogpt_platform/frontend/src/app/(platform)/copilot/CopilotPage.tsx b/autogpt_platform/frontend/src/app/(platform)/copilot/CopilotPage.tsx index c3ac60307374..335cd6bb4bf2 100644 --- a/autogpt_platform/frontend/src/app/(platform)/copilot/CopilotPage.tsx +++ b/autogpt_platform/frontend/src/app/(platform)/copilot/CopilotPage.tsx @@ -110,8 +110,8 @@ export function CopilotPage() { isDeleting, handleConfirmDelete, handleCancelDelete, - // Historical durations for persisted timer stats - historicalDurations, + // Historical per-message stats (duration + reasoning duration + timestamp) + turnStats, // Rate limit reset rateLimitMessage, dismissRateLimit, @@ -223,7 +223,7 @@ export function CopilotPage() { onLoadMore={loadMore} droppedFiles={droppedFiles} onDroppedFilesConsumed={handleDroppedFilesConsumed} - historicalDurations={historicalDurations} + turnStats={turnStats} /> diff --git a/autogpt_platform/frontend/src/app/(platform)/copilot/__tests__/CopilotPage.test.tsx b/autogpt_platform/frontend/src/app/(platform)/copilot/__tests__/CopilotPage.test.tsx index bef9a2a84840..cd1707950cec 100644 --- a/autogpt_platform/frontend/src/app/(platform)/copilot/__tests__/CopilotPage.test.tsx +++ b/autogpt_platform/frontend/src/app/(platform)/copilot/__tests__/CopilotPage.test.tsx @@ -102,7 +102,7 @@ const basePageState = { isDeleting: false, handleConfirmDelete: vi.fn(), handleCancelDelete: vi.fn(), - historicalDurations: {}, + turnStats: new Map(), rateLimitMessage: null, dismissRateLimit: vi.fn(), isDryRun: false, diff --git a/autogpt_platform/frontend/src/app/(platform)/copilot/__tests__/useChatSession.test.ts b/autogpt_platform/frontend/src/app/(platform)/copilot/__tests__/useChatSession.test.ts index a35d5c58a9e8..19ef27836d3a 100644 --- a/autogpt_platform/frontend/src/app/(platform)/copilot/__tests__/useChatSession.test.ts +++ b/autogpt_platform/frontend/src/app/(platform)/copilot/__tests__/useChatSession.test.ts @@ -26,7 +26,7 @@ vi.mock("nuqs", () => ({ vi.mock("../helpers/convertChatSessionToUiMessages", () => ({ convertChatSessionMessagesToUiMessages: vi.fn(() => ({ messages: [], - historicalDurations: new Map(), + stats: new Map(), })), })); diff --git a/autogpt_platform/frontend/src/app/(platform)/copilot/__tests__/useCopilotPage.test.ts b/autogpt_platform/frontend/src/app/(platform)/copilot/__tests__/useCopilotPage.test.ts index 093648d40794..c387ff360545 100644 --- a/autogpt_platform/frontend/src/app/(platform)/copilot/__tests__/useCopilotPage.test.ts +++ b/autogpt_platform/frontend/src/app/(platform)/copilot/__tests__/useCopilotPage.test.ts @@ -79,7 +79,7 @@ function makeBaseChatSession(overrides: Record = {}) { setSessionId: vi.fn(), hydratedMessages: [], rawSessionMessages: [], - historicalDurations: new Map(), + historicalTurnStats: new Map(), hasActiveStream: false, hasMoreMessages: false, oldestSequence: null, @@ -112,6 +112,7 @@ function makeBaseCopilotStream(overrides: Record = {}) { function makeBaseLoadMore(overrides: Record = {}) { return { pagedMessages: [], + pagedTurnStats: new Map(), hasMore: false, isLoadingMore: false, loadMore: vi.fn(), @@ -143,6 +144,48 @@ describe("useCopilotPage — backward pagination message ordering", () => { }); }); +describe("useCopilotPage — turnStats map merge across pages", () => { + beforeEach(() => { + vi.clearAllMocks(); + }); + + it("merges historical (current-page) over paged (older) stats; current-page wins on overlap", () => { + const pagedTurnStats = new Map([ + ["older", { durationMs: 1000, createdAt: "2026-04-20T10:00:00Z" }], + ["shared", { durationMs: 2000, createdAt: "2026-04-20T10:00:00Z" }], + ]); + const historicalTurnStats = new Map([ + ["current", { durationMs: 3000, createdAt: "2026-04-23T08:32:09Z" }], + ["shared", { durationMs: 4000, createdAt: "2026-04-23T08:32:09Z" }], + ]); + + mockUseChatSession.mockReturnValue( + makeBaseChatSession({ historicalTurnStats }), + ); + mockUseCopilotStream.mockReturnValue(makeBaseCopilotStream()); + mockUseLoadMoreMessages.mockReturnValue( + makeBaseLoadMore({ pagedTurnStats }), + ); + + const { result } = renderHook(() => useCopilotPage()); + const stats = result.current.turnStats; + + expect(stats.get("older")).toEqual({ + durationMs: 1000, + createdAt: "2026-04-20T10:00:00Z", + }); + expect(stats.get("current")).toEqual({ + durationMs: 3000, + createdAt: "2026-04-23T08:32:09Z", + }); + // Current-page wins on shared keys. + expect(stats.get("shared")).toEqual({ + durationMs: 4000, + createdAt: "2026-04-23T08:32:09Z", + }); + }); +}); + describe("useCopilotPage — onSend queue-in-flight path", () => { beforeEach(() => { vi.clearAllMocks(); diff --git a/autogpt_platform/frontend/src/app/(platform)/copilot/__tests__/useLoadMoreMessages.test.ts b/autogpt_platform/frontend/src/app/(platform)/copilot/__tests__/useLoadMoreMessages.test.ts index 35c6939f8a32..251b5192d728 100644 --- a/autogpt_platform/frontend/src/app/(platform)/copilot/__tests__/useLoadMoreMessages.test.ts +++ b/autogpt_platform/frontend/src/app/(platform)/copilot/__tests__/useLoadMoreMessages.test.ts @@ -9,7 +9,10 @@ vi.mock("@/app/api/__generated__/endpoints/chat/chat", () => ({ })); vi.mock("../helpers/convertChatSessionToUiMessages", () => ({ - convertChatSessionMessagesToUiMessages: vi.fn(() => ({ messages: [] })), + convertChatSessionMessagesToUiMessages: vi.fn(() => ({ + messages: [], + stats: new Map(), + })), extractToolOutputsFromRaw: vi.fn(() => []), })); diff --git a/autogpt_platform/frontend/src/app/(platform)/copilot/components/ChatContainer/ChatContainer.tsx b/autogpt_platform/frontend/src/app/(platform)/copilot/components/ChatContainer/ChatContainer.tsx index dc01eba286ce..ffad38643660 100644 --- a/autogpt_platform/frontend/src/app/(platform)/copilot/components/ChatContainer/ChatContainer.tsx +++ b/autogpt_platform/frontend/src/app/(platform)/copilot/components/ChatContainer/ChatContainer.tsx @@ -6,6 +6,7 @@ import { UIDataTypes, UIMessage, UITools } from "ai"; import { LayoutGroup, motion } from "framer-motion"; import { useCallback } from "react"; import { useCopilotUIStore } from "../../store"; +import type { TurnStatsMap } from "../../helpers/convertChatSessionToUiMessages"; import { ChatMessagesContainer } from "../ChatMessagesContainer/ChatMessagesContainer"; import { CopilotChatActionsProvider } from "../CopilotChatActionsProvider/CopilotChatActionsProvider"; import { EmptySession } from "../EmptySession/EmptySession"; @@ -38,8 +39,8 @@ export interface ChatContainerProps { droppedFiles?: File[]; /** Called after droppedFiles have been consumed by ChatInput. */ onDroppedFilesConsumed?: () => void; - /** Duration in ms for historical turns, keyed by message ID. */ - historicalDurations?: Map; + /** Per-message stats (durationMs, createdAt), keyed by message ID. */ + turnStats?: TurnStatsMap; } export const ChatContainer = ({ messages, @@ -62,7 +63,7 @@ export const ChatContainer = ({ onLoadMore, droppedFiles, onDroppedFilesConsumed, - historicalDurations, + turnStats, }: ChatContainerProps) => { const isArtifactsEnabled = useGetFlag(Flag.ARTIFACTS); const isArtifactPanelOpen = useCopilotUIStore((s) => s.artifactPanel.isOpen); @@ -116,7 +117,7 @@ export const ChatContainer = ({ isLoadingMore={isLoadingMore} onLoadMore={onLoadMore} onRetry={handleRetry} - historicalDurations={historicalDurations} + turnStats={turnStats} queuedMessages={queuedMessages} /> void; onRetry?: () => void; - historicalDurations?: Map; + turnStats?: TurnStatsMap; /** Pending queued messages waiting to be injected, shown at the end of chat. */ queuedMessages?: string[]; } @@ -256,7 +257,7 @@ export function ChatMessagesContainer({ isLoadingMore, onLoadMore, onRetry, - historicalDurations, + turnStats, queuedMessages, }: Props) { // Hide the container for one frame when messages first load so @@ -304,7 +305,27 @@ export function ChatMessagesContainer({ status === "submitted" || (status === "streaming" && !hasInflight); const isActivelyStreaming = status === "streaming" || status === "submitted"; - const { elapsedSeconds } = useElapsedTimer(isActivelyStreaming); + + // Anchor the live "Thinking Xs" counter to the latest server timestamp + // within the current turn. Messages arrive in chronological order, so + // the first createdAt we hit walking backwards IS the latest one. Stop + // at the user-message boundary so a fresh send (where the user's just- + // optimistic message isn't in turnStats yet) doesn't fall back to the + // previous turn's assistant 30s+ in the past. + const liveAnchorIso = useMemo(() => { + if (!turnStats) return null; + for (let i = messages.length - 1; i >= 0; i--) { + const iso = turnStats.get(messages[i].id)?.createdAt; + if (iso) return iso; + if (messages[i].role === "user") return null; + } + return null; + }, [messages, turnStats]); + + const { elapsedSeconds } = useElapsedTimer( + isActivelyStreaming, + liveAnchorIso, + ); // Freeze elapsed time when streaming ends so TurnStatsBar shows the final value. // Reset when a new streaming turn begins. @@ -441,7 +462,7 @@ export function ChatMessagesContainer({ ? frozenElapsedRef.current : undefined } - durationMs={historicalDurations?.get(message.id)} + stats={turnStats?.get(message.id)} /> )} {isLastAssistant && showThinking && ( @@ -452,7 +473,21 @@ export function ChatMessagesContainer({ )} {message.role === "user" && textParts.length > 0 && ( - + + {(() => { + const createdAt = turnStats?.get(message.id)?.createdAt; + if (!createdAt) return null; + const date = new Date(createdAt); + if (Number.isNaN(date.getTime())) return null; + return ( + + {date.toLocaleString(undefined, { + dateStyle: "medium", + timeStyle: "short", + })} + + ); + })()} p.text).join("\n")} /> )} diff --git a/autogpt_platform/frontend/src/app/(platform)/copilot/components/ChatMessagesContainer/__tests__/ChatMessagesContainer.test.tsx b/autogpt_platform/frontend/src/app/(platform)/copilot/components/ChatMessagesContainer/__tests__/ChatMessagesContainer.test.tsx index 2162e49fbf40..7b4ca811b027 100644 --- a/autogpt_platform/frontend/src/app/(platform)/copilot/components/ChatMessagesContainer/__tests__/ChatMessagesContainer.test.tsx +++ b/autogpt_platform/frontend/src/app/(platform)/copilot/components/ChatMessagesContainer/__tests__/ChatMessagesContainer.test.tsx @@ -264,3 +264,72 @@ describe("ChatMessagesContainer", () => { ).toBeNull(); }); }); + +// ── turnStats plumbing ──────────────────────────────────────────────────── + +describe("ChatMessagesContainer — turnStats", () => { + beforeEach(() => { + mockScrollEl.scrollHeight = 100; + mockScrollEl.scrollTop = 0; + mockScrollEl.clientHeight = 500; + MockIntersectionObserver.lastCallback = null; + vi.stubGlobal("IntersectionObserver", MockIntersectionObserver); + }); + + afterEach(() => { + cleanup(); + vi.unstubAllGlobals(); + }); + + it("renders the local timestamp on a user message (hover reveal)", () => { + const userId = "user-1"; + const turnStats = new Map([ + [userId, { createdAt: "2026-04-23T08:32:09.000Z" }], + ]); + const messages = [ + { + id: userId, + role: "user" as const, + parts: [{ type: "text" as const, text: "hi", state: "done" }], + }, + ]; + render( + // eslint-disable-next-line @typescript-eslint/no-explicit-any + , + ); + // The timestamp is rendered in the MessageActions area alongside CopyButton; + // we just assert that SOMETHING containing the year is in the DOM. + const labels = screen.getAllByText( + (_, el) => + !!el?.className.includes("tabular-nums") && + /2026/.test(el?.textContent ?? ""), + ); + expect(labels.length).toBeGreaterThan(0); + }); + + it("skips the user timestamp when turnStats has no entry for that message id", () => { + const messages = [ + { + id: "user-unknown", + role: "user" as const, + parts: [{ type: "text" as const, text: "hi", state: "done" }], + }, + ]; + render( + // eslint-disable-next-line @typescript-eslint/no-explicit-any + , + ); + const labels = screen.queryAllByText((_, el) => + /2026/.test(el?.textContent ?? ""), + ); + expect(labels.length).toBe(0); + }); +}); diff --git a/autogpt_platform/frontend/src/app/(platform)/copilot/components/JobStatsBar/TurnStatsBar.tsx b/autogpt_platform/frontend/src/app/(platform)/copilot/components/JobStatsBar/TurnStatsBar.tsx index 1b21316c891b..195d5d46ffd2 100644 --- a/autogpt_platform/frontend/src/app/(platform)/copilot/components/JobStatsBar/TurnStatsBar.tsx +++ b/autogpt_platform/frontend/src/app/(platform)/copilot/components/JobStatsBar/TurnStatsBar.tsx @@ -1,41 +1,93 @@ import type { UIDataTypes, UIMessage, UITools } from "ai"; +import { useState } from "react"; +import type { TurnStats } from "../../helpers/convertChatSessionToUiMessages"; import { formatElapsed } from "./formatElapsed"; import { getWorkDoneCounters } from "./useWorkDoneCounters"; interface Props { turnMessages: UIMessage[]; elapsedSeconds?: number; - durationMs?: number; + stats?: TurnStats; } -export function TurnStatsBar({ - turnMessages, - elapsedSeconds, - durationMs, -}: Props) { - const { counters } = getWorkDoneCounters(turnMessages); +function formatLocalDate(iso: string): string { + const date = new Date(iso); + if (Number.isNaN(date.getTime())) return iso; + return date.toLocaleString(undefined, { + dateStyle: "medium", + timeStyle: "short", + }); +} + +/** + * Prefer live elapsedSeconds while streaming; fall back to the persisted + * whole-turn durationMs afterwards. + */ +function resolveDisplaySeconds( + elapsedSeconds: number | undefined, + stats: TurnStats | undefined, +): number | undefined { + if (elapsedSeconds !== undefined && elapsedSeconds > 0) return elapsedSeconds; + if (stats?.durationMs && stats.durationMs > 0) { + return Math.round(stats.durationMs / 1000); + } + return undefined; +} + +/** + * Swap "Thought for X" → the formatted date while the cursor is over the + * label; revert on mouse leave. Pure hover, no click toggle. + */ +function TimeLabel({ + displaySeconds, + localDate, +}: { + displaySeconds: number; + localDate: string | null; +}) { + const [hovered, setHovered] = useState(false); + const labelText = `Thought for ${formatElapsed(displaySeconds)}`; - // Prefer live elapsedSeconds, fall back to persisted durationMs - const displaySeconds = - elapsedSeconds !== undefined && elapsedSeconds > 0 - ? elapsedSeconds - : durationMs !== undefined - ? Math.round(durationMs / 1000) - : undefined; + if (!localDate) { + return ( + + {labelText} + + ); + } - const hasTime = displaySeconds !== undefined && displaySeconds > 0; + return ( + setHovered(true)} + onMouseLeave={() => setHovered(false)} + className="cursor-default text-[11px] tabular-nums text-neutral-500 transition-colors hover:text-neutral-700" + > + + {hovered ? localDate : labelText} + + + ); +} + +export function TurnStatsBar({ turnMessages, elapsedSeconds, stats }: Props) { + const { counters } = getWorkDoneCounters(turnMessages); + const displaySeconds = resolveDisplaySeconds(elapsedSeconds, stats); + const localDate = stats?.createdAt ? formatLocalDate(stats.createdAt) : null; - if (counters.length === 0 && !hasTime) return null; + const showTimeLabel = + displaySeconds !== undefined && displaySeconds > 0 ? displaySeconds : null; + if (counters.length === 0 && showTimeLabel === null) return null; return (
- {hasTime && ( - - Thought for {formatElapsed(displaySeconds)} - + {showTimeLabel !== null && ( + )} {counters.map(function renderCounter(counter, index) { - const needsDot = index > 0 || hasTime; + const needsDot = index > 0 || showTimeLabel !== null; return ( {needsDot && ( diff --git a/autogpt_platform/frontend/src/app/(platform)/copilot/components/JobStatsBar/__tests__/TurnStatsBar.test.tsx b/autogpt_platform/frontend/src/app/(platform)/copilot/components/JobStatsBar/__tests__/TurnStatsBar.test.tsx new file mode 100644 index 000000000000..9b48fd0c7159 --- /dev/null +++ b/autogpt_platform/frontend/src/app/(platform)/copilot/components/JobStatsBar/__tests__/TurnStatsBar.test.tsx @@ -0,0 +1,106 @@ +import { fireEvent, render, screen } from "@/tests/integrations/test-utils"; +import type { UIDataTypes, UIMessage, UITools } from "ai"; +import { describe, expect, it } from "vitest"; +import { TurnStatsBar } from "../TurnStatsBar"; + +type Msg = UIMessage; + +const EMPTY: Msg[] = []; + +describe("TurnStatsBar", () => { + it("renders nothing when there is no time, no timestamp, and no counters", () => { + const { container } = render(); + expect(container.firstChild).toBeNull(); + }); + + it("prefers live elapsedSeconds over the persisted durationMs", () => { + render( + , + ); + expect(screen.getByText(/Thought for 7s/)).toBeDefined(); + }); + + it("uses durationMs when the turn is finalized", () => { + render( + , + ); + expect(screen.getByText(/Thought for 42s/)).toBeDefined(); + }); + + it("renders nothing for sub-second durations (would round to 0s)", () => { + const { container } = render( + , + ); + expect(container.firstChild).toBeNull(); + }); + + it("renders nothing when only a timestamp is present (date is hover-only)", () => { + const { container } = render( + , + ); + // Without a duration there's no label to hover over — render nothing. + expect(container.firstChild).toBeNull(); + }); + + it("swaps to the date on hover and reverts on mouse leave", () => { + const { container } = render( + , + ); + const label = container.querySelector("div.mt-2 > span") as HTMLElement; + expect(label.textContent).toMatch(/Thought for 5s/); + fireEvent.mouseEnter(label); + expect(label.textContent).not.toMatch(/Thought for/); + expect(label.textContent).toMatch(/2026/); + fireEvent.mouseLeave(label); + expect(label.textContent).toMatch(/Thought for 5s/); + }); + + it("renders work-done counters from assistant tool parts", () => { + const messages: Msg[] = [ + { + id: "m1", + role: "assistant", + parts: [ + { + type: "tool-run_agent", + toolCallId: "t1", + state: "output-available", + input: {}, + output: {}, + }, + { + type: "tool-run_agent", + toolCallId: "t2", + state: "output-available", + input: {}, + output: {}, + }, + { + type: "tool-run_block", + toolCallId: "t3", + state: "output-available", + input: {}, + output: {}, + }, + ] as Msg["parts"], + }, + ]; + const { container } = render( + , + ); + expect(screen.getByText(/Thought for 4s/)).toBeDefined(); + const bar = container.querySelector("div.mt-2"); + expect(bar?.textContent).toMatch(/2\s*agents run/); + expect(bar?.textContent).toMatch(/1\s*action/); + }); +}); diff --git a/autogpt_platform/frontend/src/app/(platform)/copilot/components/JobStatsBar/__tests__/useElapsedTimer.test.ts b/autogpt_platform/frontend/src/app/(platform)/copilot/components/JobStatsBar/__tests__/useElapsedTimer.test.ts new file mode 100644 index 000000000000..f07a1c4c0099 --- /dev/null +++ b/autogpt_platform/frontend/src/app/(platform)/copilot/components/JobStatsBar/__tests__/useElapsedTimer.test.ts @@ -0,0 +1,76 @@ +import { act, renderHook } from "@testing-library/react"; +import { afterEach, beforeEach, describe, expect, it, vi } from "vitest"; +import { useElapsedTimer } from "../useElapsedTimer"; + +describe("useElapsedTimer", () => { + beforeEach(() => { + vi.useFakeTimers(); + vi.setSystemTime(new Date("2026-04-23T10:00:00.000Z")); + }); + afterEach(() => { + vi.useRealTimers(); + }); + + it("starts at zero and ticks once per second while running", () => { + const { result } = renderHook(() => useElapsedTimer(true)); + expect(result.current.elapsedSeconds).toBe(0); + act(() => vi.advanceTimersByTime(3000)); + expect(result.current.elapsedSeconds).toBe(3); + }); + + it("stops ticking and resets when isRunning flips to false", () => { + const { result, rerender } = renderHook( + ({ running }) => useElapsedTimer(running), + { initialProps: { running: true } }, + ); + act(() => vi.advanceTimersByTime(2000)); + expect(result.current.elapsedSeconds).toBe(2); + rerender({ running: false }); + act(() => vi.advanceTimersByTime(5000)); + // No ticks after stop — elapsed stays at last reading until the next + // `running:true` transition, which re-anchors to the current time. + expect(result.current.elapsedSeconds).toBe(2); + }); + + it("anchors to an ISO timestamp so a fresh mount reflects real elapsed time", () => { + // Anchor 15s in the past relative to the mocked system time. + const anchor = new Date("2026-04-23T09:59:45.000Z").toISOString(); + const { result } = renderHook(() => useElapsedTimer(true, anchor)); + expect(result.current.elapsedSeconds).toBe(15); + act(() => vi.advanceTimersByTime(5000)); + expect(result.current.elapsedSeconds).toBe(20); + }); + + it("clamps a future-dated anchor to zero rather than a negative seconds count", () => { + const anchor = new Date("2026-04-23T10:00:10.000Z").toISOString(); + const { result } = renderHook(() => useElapsedTimer(true, anchor)); + expect(result.current.elapsedSeconds).toBe(0); + }); + + it("falls back to mount-time counting when anchor is invalid", () => { + const { result } = renderHook(() => useElapsedTimer(true, "not-a-date")); + expect(result.current.elapsedSeconds).toBe(0); + act(() => vi.advanceTimersByTime(4000)); + expect(result.current.elapsedSeconds).toBe(4); + }); + + it("re-syncs when a late-arriving anchor replaces the previous one mid-run", () => { + // Simulate the real case: timer mounts with no anchor (session data + // hasn't loaded yet), starts counting from mount. Then the session + // query resolves and surfaces the actual server timestamp, which should + // correct the elapsed reading rather than being ignored. + const { result, rerender } = renderHook( + ({ anchor }: { anchor: string | null }) => useElapsedTimer(true, anchor), + { initialProps: { anchor: null as string | null } }, + ); + act(() => vi.advanceTimersByTime(2000)); + expect(result.current.elapsedSeconds).toBe(2); + + rerender({ + anchor: new Date("2026-04-23T09:59:00.000Z").toISOString(), + }); + // Clock is at 10:00:02 (after the 2s advance), anchor is 60s earlier, + // so elapsed jumps to 62. + expect(result.current.elapsedSeconds).toBe(62); + }); +}); diff --git a/autogpt_platform/frontend/src/app/(platform)/copilot/components/JobStatsBar/useElapsedTimer.ts b/autogpt_platform/frontend/src/app/(platform)/copilot/components/JobStatsBar/useElapsedTimer.ts index f8247786cb4d..fdc849f4e768 100644 --- a/autogpt_platform/frontend/src/app/(platform)/copilot/components/JobStatsBar/useElapsedTimer.ts +++ b/autogpt_platform/frontend/src/app/(platform)/copilot/components/JobStatsBar/useElapsedTimer.ts @@ -1,21 +1,35 @@ import { useEffect, useRef, useState } from "react"; -export function useElapsedTimer(isRunning: boolean) { +/** + * Ticks once per second while `isRunning` is true. + * + * Pass `anchorIso` (a server-issued ISO timestamp, e.g. the last user / + * tool message's `createdAt`) to count from that absolute wall-clock point + * instead of from when this hook first saw `isRunning = true`. This is what + * makes the "Considering Xs" counter survive a page refresh mid-turn — it + * reflects actual elapsed time since the turn's last recorded activity, not + * the moment the current browser tab mounted. + */ +export function useElapsedTimer(isRunning: boolean, anchorIso?: string | null) { const [elapsedSeconds, setElapsedSeconds] = useState(0); const startTimeRef = useRef(null); const intervalRef = useRef>(); useEffect(() => { if (isRunning) { - if (startTimeRef.current === null) { - startTimeRef.current = Date.now(); - setElapsedSeconds(0); - } + // Re-sync on every re-run so a late-arriving anchorIso (e.g. session + // data loads after the timer started on page refresh) updates the + // start time instead of being ignored. + const anchorMs = anchorIso ? Date.parse(anchorIso) : NaN; + startTimeRef.current = Number.isFinite(anchorMs) ? anchorMs : Date.now(); + setElapsedSeconds( + Math.max(0, Math.floor((Date.now() - startTimeRef.current) / 1000)), + ); intervalRef.current = setInterval(() => { if (startTimeRef.current !== null) { setElapsedSeconds( - Math.floor((Date.now() - startTimeRef.current) / 1000), + Math.max(0, Math.floor((Date.now() - startTimeRef.current) / 1000)), ); } }, 1000); @@ -25,7 +39,7 @@ export function useElapsedTimer(isRunning: boolean) { clearInterval(intervalRef.current); startTimeRef.current = null; - }, [isRunning]); + }, [isRunning, anchorIso]); return { elapsedSeconds }; } diff --git a/autogpt_platform/frontend/src/app/(platform)/copilot/helpers/__tests__/convertChatSessionToUiMessages.test.ts b/autogpt_platform/frontend/src/app/(platform)/copilot/helpers/__tests__/convertChatSessionToUiMessages.test.ts index 102246c6d695..b308ef69df1a 100644 --- a/autogpt_platform/frontend/src/app/(platform)/copilot/helpers/__tests__/convertChatSessionToUiMessages.test.ts +++ b/autogpt_platform/frontend/src/app/(platform)/copilot/helpers/__tests__/convertChatSessionToUiMessages.test.ts @@ -149,7 +149,7 @@ describe("convertChatSessionMessagesToUiMessages", () => { expect(result.messages).toHaveLength(1); const mergedId = result.messages[0].id; - expect(result.durations.get(mergedId)).toBe(750); + expect(result.stats.get(mergedId)?.durationMs).toBe(750); }); it("falls back to idx-based ids when sequence is null so sequence-less rows don't collide", () => { @@ -212,6 +212,66 @@ describe("convertChatSessionMessagesToUiMessages", () => { expect(result.messages).toHaveLength(2); const assistantId = result.messages[1].id; - expect(result.durations.get(assistantId)).toBe(123); + expect(result.stats.get(assistantId)?.durationMs).toBe(123); + }); + + it("captures created_at when supplied as an ISO string", () => { + const iso = "2026-04-23T01:32:09.871Z"; + const result = convertChatSessionMessagesToUiMessages( + SESSION_ID, + [{ role: "user", content: "hi", sequence: 0, created_at: iso }], + { isComplete: true }, + ); + + const userId = result.messages[0].id; + expect(result.stats.get(userId)?.createdAt).toBe(iso); + }); + + it("captures created_at when the API mutator has already converted the field to a Date object", () => { + // The generated `customMutator` runs `transformDates()` on every response, + // which turns ISO date strings into Date objects before they reach the + // UI-shape converter. A literal `typeof === "string"` check would reject + // the Date and silently drop the timestamp — breaking the "Thought for X" + // tooltip. Assert we still recover the ISO value. + const date = new Date("2026-04-23T01:32:09.871Z"); + const result = convertChatSessionMessagesToUiMessages( + SESSION_ID, + [{ role: "user", content: "hi", sequence: 0, created_at: date }], + { isComplete: true }, + ); + + const userId = result.messages[0].id; + expect(result.stats.get(userId)?.createdAt).toBe(date.toISOString()); + }); + + it("advances createdAt to the latest row when merging consecutive assistant rows", () => { + // Reasoning row persisted early + assistant row persisted later should + // leave the merged bubble's stats.createdAt pointing at the LATER row, + // so the live "Thinking Xs" counter anchors to the most recent step. + const early = "2026-04-23T10:00:00.000Z"; + const later = "2026-04-23T10:00:30.000Z"; + const result = convertChatSessionMessagesToUiMessages( + SESSION_ID, + [ + { role: "user", content: "hi", sequence: 0, created_at: early }, + { + role: "reasoning", + content: "ponder", + sequence: 1, + created_at: early, + }, + { + role: "assistant", + content: "reply", + sequence: 2, + created_at: later, + }, + ], + { isComplete: true }, + ); + + expect(result.messages).toHaveLength(2); + const mergedId = result.messages[1].id; + expect(result.stats.get(mergedId)?.createdAt).toBe(later); }); }); diff --git a/autogpt_platform/frontend/src/app/(platform)/copilot/helpers/convertChatSessionToUiMessages.ts b/autogpt_platform/frontend/src/app/(platform)/copilot/helpers/convertChatSessionToUiMessages.ts index 3eeadbfcd730..ea574ac77d44 100644 --- a/autogpt_platform/frontend/src/app/(platform)/copilot/helpers/convertChatSessionToUiMessages.ts +++ b/autogpt_platform/frontend/src/app/(platform)/copilot/helpers/convertChatSessionToUiMessages.ts @@ -1,6 +1,13 @@ import { getGetWorkspaceDownloadFileByIdUrl } from "@/app/api/__generated__/endpoints/workspace/workspace"; import type { FileUIPart, UIMessage, UIDataTypes, UITools } from "ai"; +export interface TurnStats { + durationMs?: number; + createdAt?: string; +} + +export type TurnStatsMap = Map; + interface SessionChatMessage { role: string; content: string | null; @@ -8,6 +15,7 @@ interface SessionChatMessage { tool_calls: unknown[] | null; sequence: number | null; duration_ms: number | null; + created_at: string | null; } function coerceSessionChatMessages( @@ -39,6 +47,14 @@ function coerceSessionChatMessages( sequence: typeof msg.sequence === "number" ? msg.sequence : null, duration_ms: typeof msg.duration_ms === "number" ? msg.duration_ms : null, + // The API mutator transforms ISO strings to Date objects before + // the data reaches here, so accept both string and Date. + created_at: + typeof msg.created_at === "string" + ? msg.created_at + : msg.created_at instanceof Date + ? msg.created_at.toISOString() + : null, }; }) .filter((m): m is SessionChatMessage => m !== null); @@ -166,7 +182,7 @@ export function convertChatSessionMessagesToUiMessages( }, ): { messages: UIMessage[]; - durations: Map; + stats: TurnStatsMap; } { const messages = coerceSessionChatMessages(rawMessages); const toolOutputsByCallId = new Map(); @@ -187,7 +203,12 @@ export function convertChatSessionMessagesToUiMessages( } const uiMessages: UIMessage[] = []; - const durations = new Map(); + const stats: TurnStatsMap = new Map(); + + function patchStats(id: string, patch: Partial) { + const existing = stats.get(id) ?? {}; + stats.set(id, { ...existing, ...patch }); + } messages.forEach((msg, idx) => { if (msg.role === "tool") return; @@ -285,7 +306,17 @@ export function convertChatSessionMessagesToUiMessages( prevUI.parts.push(...parts); // Capture duration on merged message (last assistant msg wins) if (msg.duration_ms != null) { - durations.set(prevUI.id, msg.duration_ms); + patchStats(prevUI.id, { durationMs: msg.duration_ms }); + } + // Advance createdAt to the latest row in the merge so the live + // "Thinking Xs" counter anchors to the most recent sub-step rather + // than the turn's first assistant row. + const existingCreatedAt = stats.get(prevUI.id)?.createdAt; + if ( + msg.created_at && + (!existingCreatedAt || msg.created_at > existingCreatedAt) + ) { + patchStats(prevUI.id, { createdAt: msg.created_at }); } return; } @@ -302,10 +333,13 @@ export function convertChatSessionMessagesToUiMessages( parts, }); + const patch: Partial = {}; + if (msg.created_at) patch.createdAt = msg.created_at; if (uiRole === "assistant" && msg.duration_ms != null) { - durations.set(msgId, msg.duration_ms); + patch.durationMs = msg.duration_ms; } + if (Object.keys(patch).length > 0) patchStats(msgId, patch); }); - return { messages: uiMessages, durations }; + return { messages: uiMessages, stats }; } diff --git a/autogpt_platform/frontend/src/app/(platform)/copilot/useChatSession.ts b/autogpt_platform/frontend/src/app/(platform)/copilot/useChatSession.ts index b5a02620c262..d6a3557bd72e 100644 --- a/autogpt_platform/frontend/src/app/(platform)/copilot/useChatSession.ts +++ b/autogpt_platform/frontend/src/app/(platform)/copilot/useChatSession.ts @@ -9,7 +9,10 @@ import * as Sentry from "@sentry/nextjs"; import { useQueryClient } from "@tanstack/react-query"; import { parseAsString, useQueryState } from "nuqs"; import { useEffect, useMemo, useRef } from "react"; -import { convertChatSessionMessagesToUiMessages } from "./helpers/convertChatSessionToUiMessages"; +import { + convertChatSessionMessagesToUiMessages, + type TurnStatsMap, +} from "./helpers/convertChatSessionToUiMessages"; import { resolveSessionDryRun } from "./helpers"; interface UseChatSessionOptions { @@ -90,11 +93,11 @@ export function useChatSession({ dryRun = false }: UseChatSessionOptions = {}) { // array reference every render. Re-derives only when query data changes. // When the session is complete (no active stream), mark dangling tool // calls as completed so stale spinners don't persist after refresh. - const { hydratedMessages, historicalDurations } = useMemo(() => { + const { hydratedMessages, historicalTurnStats } = useMemo(() => { if (sessionQuery.data?.status !== 200 || !sessionId) return { hydratedMessages: undefined, - historicalDurations: new Map(), + historicalTurnStats: new Map() as TurnStatsMap, }; const result = convertChatSessionMessagesToUiMessages( sessionId, @@ -103,7 +106,7 @@ export function useChatSession({ dryRun = false }: UseChatSessionOptions = {}) { ); return { hydratedMessages: result.messages, - historicalDurations: result.durations, + historicalTurnStats: result.stats, }; }, [sessionQuery.data, sessionId, hasActiveStream]); @@ -181,7 +184,7 @@ export function useChatSession({ dryRun = false }: UseChatSessionOptions = {}) { setSessionId, hydratedMessages, rawSessionMessages, - historicalDurations, + historicalTurnStats, hasActiveStream, hasMoreMessages, oldestSequence, diff --git a/autogpt_platform/frontend/src/app/(platform)/copilot/useCopilotPage.ts b/autogpt_platform/frontend/src/app/(platform)/copilot/useCopilotPage.ts index 0551fb03879c..bdf6524f491d 100644 --- a/autogpt_platform/frontend/src/app/(platform)/copilot/useCopilotPage.ts +++ b/autogpt_platform/frontend/src/app/(platform)/copilot/useCopilotPage.ts @@ -55,7 +55,7 @@ export function useCopilotPage() { setSessionId, hydratedMessages, rawSessionMessages, - historicalDurations, + historicalTurnStats, hasActiveStream, hasMoreMessages, oldestSequence, @@ -88,7 +88,7 @@ export function useCopilotPage() { copilotModel: isModeToggleEnabled ? copilotLlmModel : undefined, }); - const { pagedMessages, hasMore, isLoadingMore, loadMore } = + const { pagedMessages, pagedTurnStats, hasMore, isLoadingMore, loadMore } = useLoadMoreMessages({ sessionId, initialOldestSequence: oldestSequence, @@ -96,6 +96,14 @@ export function useCopilotPage() { initialPageRawMessages: rawSessionMessages, }); + // Merge the older-pages and current-page stat maps; current-page (historical) + // wins on overlap since it was persisted more recently. + const turnStats = useMemo(() => { + const merged = new Map(pagedTurnStats); + historicalTurnStats?.forEach((v, k) => merged.set(k, v)); + return merged; + }, [pagedTurnStats, historicalTurnStats]); + // Ref that mirrors whether a stream turn is currently in-flight. // Updated synchronously on every render so it always reflects the latest // status — unlike reading `status` inside onSend (which captures the @@ -491,8 +499,8 @@ export function useCopilotPage() { handleDeleteClick, handleConfirmDelete, handleCancelDelete, - // Historical durations for persisted timer stats - historicalDurations, + // Per-message stats (duration + reasoning duration + timestamp) + turnStats, // Rate limit reset rateLimitMessage, dismissRateLimit, diff --git a/autogpt_platform/frontend/src/app/(platform)/copilot/useLoadMoreMessages.ts b/autogpt_platform/frontend/src/app/(platform)/copilot/useLoadMoreMessages.ts index 2957070c0f46..1a3d1817ad78 100644 --- a/autogpt_platform/frontend/src/app/(platform)/copilot/useLoadMoreMessages.ts +++ b/autogpt_platform/frontend/src/app/(platform)/copilot/useLoadMoreMessages.ts @@ -4,6 +4,7 @@ import { useEffect, useMemo, useRef, useState } from "react"; import { convertChatSessionMessagesToUiMessages, extractToolOutputsFromRaw, + type TurnStatsMap, } from "./helpers/convertChatSessionToUiMessages"; interface UseLoadMoreMessagesArgs { @@ -82,19 +83,21 @@ export function useLoadMoreMessages({ // are matched across inter-page boundaries. // Include initial page tool outputs so older paged pages can match // tool calls whose outputs landed in the initial page. - const pagedMessages: UIMessage[] = - useMemo(() => { - if (!sessionId || pagedRawMessages.length === 0) return []; - const extraToolOutputs = - initialPageRawMessages.length > 0 - ? extractToolOutputsFromRaw(initialPageRawMessages) - : undefined; - return convertChatSessionMessagesToUiMessages( - sessionId, - pagedRawMessages, - { isComplete: true, extraToolOutputs }, - ).messages; - }, [sessionId, pagedRawMessages, initialPageRawMessages]); + const { messages: pagedMessages, stats: pagedTurnStats } = useMemo((): { + messages: UIMessage[]; + stats: TurnStatsMap; + } => { + if (!sessionId || pagedRawMessages.length === 0) + return { messages: [], stats: new Map() }; + const extraToolOutputs = + initialPageRawMessages.length > 0 + ? extractToolOutputsFromRaw(initialPageRawMessages) + : undefined; + return convertChatSessionMessagesToUiMessages(sessionId, pagedRawMessages, { + isComplete: true, + extraToolOutputs, + }); + }, [sessionId, pagedRawMessages, initialPageRawMessages]); async function loadMore() { if (!sessionId || !hasMore || isLoadingMoreRef.current) return; @@ -159,5 +162,11 @@ export function useLoadMoreMessages({ } } - return { pagedMessages, hasMore, isLoadingMore, loadMore }; + return { + pagedMessages, + pagedTurnStats, + hasMore, + isLoadingMore, + loadMore, + }; }