From 903dc22c2d010f25ce4361e4e330eb5c8fd8b97e Mon Sep 17 00:00:00 2001 From: Lluis Agusti Date: Thu, 26 Mar 2026 18:23:54 +0800 Subject: [PATCH 01/10] feat(frontend/copilot): add useElapsedTimer hook and formatElapsed helper Co-Authored-By: Claude Opus 4.6 (1M context) --- .../components/JobStatsBar/formatElapsed.ts | 7 +++++ .../components/JobStatsBar/useElapsedTimer.ts | 31 +++++++++++++++++++ 2 files changed, 38 insertions(+) create mode 100644 autogpt_platform/frontend/src/app/(platform)/copilot/components/JobStatsBar/formatElapsed.ts create mode 100644 autogpt_platform/frontend/src/app/(platform)/copilot/components/JobStatsBar/useElapsedTimer.ts diff --git a/autogpt_platform/frontend/src/app/(platform)/copilot/components/JobStatsBar/formatElapsed.ts b/autogpt_platform/frontend/src/app/(platform)/copilot/components/JobStatsBar/formatElapsed.ts new file mode 100644 index 000000000000..7fc52ddead5d --- /dev/null +++ b/autogpt_platform/frontend/src/app/(platform)/copilot/components/JobStatsBar/formatElapsed.ts @@ -0,0 +1,7 @@ +export function formatElapsed(totalSeconds: number): string { + const minutes = Math.floor(totalSeconds / 60); + const seconds = totalSeconds % 60; + + if (minutes === 0) return `${seconds}s`; + return `${minutes}m ${seconds}s`; +} 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 new file mode 100644 index 000000000000..f8247786cb4d --- /dev/null +++ b/autogpt_platform/frontend/src/app/(platform)/copilot/components/JobStatsBar/useElapsedTimer.ts @@ -0,0 +1,31 @@ +import { useEffect, useRef, useState } from "react"; + +export function useElapsedTimer(isRunning: boolean) { + 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); + } + + intervalRef.current = setInterval(() => { + if (startTimeRef.current !== null) { + setElapsedSeconds( + Math.floor((Date.now() - startTimeRef.current) / 1000), + ); + } + }, 1000); + + return () => clearInterval(intervalRef.current); + } + + clearInterval(intervalRef.current); + startTimeRef.current = null; + }, [isRunning]); + + return { elapsedSeconds }; +} From 7ed0b45b7cef13ed1b4dd6a5d258a8992165d89f Mon Sep 17 00:00:00 2001 From: Lluis Agusti Date: Thu, 26 Mar 2026 18:25:42 +0800 Subject: [PATCH 02/10] feat(frontend/copilot): add elapsed timer display to ThinkingIndicator Co-Authored-By: Claude Opus 4.6 (1M context) --- .../components/ThinkingIndicator.tsx | 9 ++++++++- 1 file changed, 8 insertions(+), 1 deletion(-) diff --git a/autogpt_platform/frontend/src/app/(platform)/copilot/components/ChatMessagesContainer/components/ThinkingIndicator.tsx b/autogpt_platform/frontend/src/app/(platform)/copilot/components/ChatMessagesContainer/components/ThinkingIndicator.tsx index bdd68fbf874f..5c756cc83674 100644 --- a/autogpt_platform/frontend/src/app/(platform)/copilot/components/ChatMessagesContainer/components/ThinkingIndicator.tsx +++ b/autogpt_platform/frontend/src/app/(platform)/copilot/components/ChatMessagesContainer/components/ThinkingIndicator.tsx @@ -1,4 +1,5 @@ import { useEffect, useRef, useState } from "react"; +import { formatElapsed } from "../../JobStatsBar/formatElapsed"; import { ScaleLoader } from "../../ScaleLoader/ScaleLoader"; const THINKING_PHRASES = [ @@ -72,9 +73,10 @@ function useCyclingPhrase(active: boolean) { interface Props { active: boolean; + elapsedSeconds: number; } -export function ThinkingIndicator({ active }: Props) { +export function ThinkingIndicator({ active, elapsedSeconds }: Props) { const { phrase, visible } = useCyclingPhrase(active); return ( @@ -88,6 +90,11 @@ export function ThinkingIndicator({ active }: Props) { {phrase} + {elapsedSeconds > 0 && ( + + {formatElapsed(elapsedSeconds)} + + )} ); } From ef33f2dfe797d4c2d3e76283cee265f6de8fecb0 Mon Sep 17 00:00:00 2001 From: Lluis Agusti Date: Thu, 26 Mar 2026 18:28:15 +0800 Subject: [PATCH 03/10] feat(frontend/copilot): add "Thought for Xm Ys" to TurnStatsBar completion line Co-Authored-By: Claude Opus 4.6 (1M context) --- .../components/JobStatsBar/TurnStatsBar.tsx | 15 ++++++++++++--- 1 file changed, 12 insertions(+), 3 deletions(-) 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 9735addea2d3..a5c9382052ba 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,21 +1,30 @@ import type { UIDataTypes, UIMessage, UITools } from "ai"; +import { formatElapsed } from "./formatElapsed"; import { getWorkDoneCounters } from "./useWorkDoneCounters"; interface Props { turnMessages: UIMessage[]; + elapsedSeconds?: number; } -export function TurnStatsBar({ turnMessages }: Props) { +export function TurnStatsBar({ turnMessages, elapsedSeconds }: Props) { const { counters } = getWorkDoneCounters(turnMessages); + const hasTime = elapsedSeconds !== undefined && elapsedSeconds > 0; - if (counters.length === 0) return null; + if (counters.length === 0 && !hasTime) return null; return (
+ {hasTime && ( + + Thought for {formatElapsed(elapsedSeconds)} + + )} {counters.map(function renderCounter(counter, index) { + const needsDot = index > 0 || hasTime; return ( - {index > 0 && ( + {needsDot && ( · )} From da15a3710273676dc7c78af4339360e1bbfb02d0 Mon Sep 17 00:00:00 2001 From: Lluis Agusti Date: Thu, 26 Mar 2026 18:31:31 +0800 Subject: [PATCH 04/10] feat(frontend/copilot): wire elapsed timer into ChatMessagesContainer for live + frozen display Co-Authored-By: Claude Opus 4.6 (1M context) --- .../ChatMessagesContainer.tsx | 35 +++++++++++++++++-- 1 file changed, 32 insertions(+), 3 deletions(-) diff --git a/autogpt_platform/frontend/src/app/(platform)/copilot/components/ChatMessagesContainer/ChatMessagesContainer.tsx b/autogpt_platform/frontend/src/app/(platform)/copilot/components/ChatMessagesContainer/ChatMessagesContainer.tsx index 7fcb34c4c46f..56efa102976d 100644 --- a/autogpt_platform/frontend/src/app/(platform)/copilot/components/ChatMessagesContainer/ChatMessagesContainer.tsx +++ b/autogpt_platform/frontend/src/app/(platform)/copilot/components/ChatMessagesContainer/ChatMessagesContainer.tsx @@ -1,4 +1,4 @@ -import { useMemo } from "react"; +import { useMemo, useRef } from "react"; import { Conversation, ConversationContent, @@ -13,6 +13,7 @@ import { LoadingSpinner } from "@/components/atoms/LoadingSpinner/LoadingSpinner import { FileUIPart, UIDataTypes, UIMessage, UITools } from "ai"; import { TOOL_PART_PREFIX } from "../JobStatsBar/constants"; import { TurnStatsBar } from "../JobStatsBar/TurnStatsBar"; +import { useElapsedTimer } from "../JobStatsBar/useElapsedTimer"; import { CopilotPendingReviews } from "../CopilotPendingReviews/CopilotPendingReviews"; import { buildRenderSegments, @@ -139,6 +140,23 @@ export function ChatMessagesContainer({ const showThinking = status === "submitted" || (status === "streaming" && !hasInflight); + const isActivelyStreaming = status === "streaming" || status === "submitted"; + const { elapsedSeconds } = useElapsedTimer(isActivelyStreaming); + + // Freeze elapsed time when streaming ends so TurnStatsBar shows the final value. + // Reset when a new streaming turn begins. + const frozenElapsedRef = useRef(0); + const wasStreamingRef = useRef(false); + if (isActivelyStreaming) { + if (!wasStreamingRef.current) { + frozenElapsedRef.current = 0; + } + if (elapsedSeconds > 0) { + frozenElapsedRef.current = elapsedSeconds; + } + } + wasStreamingRef.current = isActivelyStreaming; + return ( @@ -239,10 +257,18 @@ export function ChatMessagesContainer({ {isLastInTurn && !isCurrentlyStreaming && ( )} {isLastAssistant && showThinking && ( - + )} {message.role === "user" && textParts.length > 0 && ( @@ -268,7 +294,10 @@ export function ChatMessagesContainer({ {showThinking && lastMessage?.role !== "assistant" && ( - + )} From cacdf603892c19da74f76b979d817ea7ab461fcb Mon Sep 17 00:00:00 2001 From: Lluis Agusti Date: Thu, 26 Mar 2026 23:01:15 +0800 Subject: [PATCH 05/10] feat(platform/copilot): persist turn duration in DB and show on reload - Add durationMs column to ChatMessage (Prisma migration) - Compute wall-clock duration in mark_session_completed from session created_at and save it on the last assistant message via DatabaseManager - Invalidate Redis session cache after setting duration so GET returns fresh data - Frontend reads durationMs from historical messages and displays "Thought for Xm Ys" in TurnStatsBar on page reload - Simplify ThinkingIndicator to show only elapsed time after 20s (no cycling phrases), with font-mono text-sm styling Co-Authored-By: Claude Opus 4.6 (1M context) --- .../backend/backend/copilot/db.py | 24 +++++ .../backend/backend/copilot/model.py | 3 + .../backend/copilot/stream_registry.py | 25 ++++++ .../backend/backend/data/db_manager.py | 2 + .../migration.sql | 2 + autogpt_platform/backend/schema.prisma | 3 +- .../app/(platform)/copilot/CopilotPage.tsx | 3 + .../ChatContainer/ChatContainer.tsx | 4 + .../ChatMessagesContainer.tsx | 3 + .../components/ThinkingIndicator.tsx | 89 ++----------------- .../components/JobStatsBar/TurnStatsBar.tsx | 20 ++++- .../helpers/convertChatSessionToUiMessages.ts | 22 ++++- .../app/(platform)/copilot/useChatSession.ts | 15 +++- .../app/(platform)/copilot/useCopilotPage.ts | 3 + 14 files changed, 125 insertions(+), 93 deletions(-) create mode 100644 autogpt_platform/backend/migrations/20260326120000_add_chat_message_duration_ms/migration.sql diff --git a/autogpt_platform/backend/backend/copilot/db.py b/autogpt_platform/backend/backend/copilot/db.py index b50e157e5f70..de39649fb069 100644 --- a/autogpt_platform/backend/backend/copilot/db.py +++ b/autogpt_platform/backend/backend/copilot/db.py @@ -217,6 +217,9 @@ async def add_chat_messages_batch( if msg.get("function_call") is not None: data["functionCall"] = SafeJson(msg["function_call"]) + if msg.get("duration_ms") is not None: + data["durationMs"] = msg["duration_ms"] + messages_data.append(data) # Run create_many and session update in parallel within transaction @@ -359,3 +362,24 @@ async def update_tool_message_content( f"tool_call_id {tool_call_id}: {e}" ) return False + + +async def set_turn_duration(session_id: str, duration_ms: int) -> None: + """Set durationMs on the last assistant message in a session. + + Also invalidates the Redis session cache so the next GET returns + the updated duration. + """ + last_msg = await PrismaChatMessage.prisma().find_first( + where={"sessionId": session_id, "role": "assistant"}, + order={"sequence": "desc"}, + ) + if last_msg: + await PrismaChatMessage.prisma().update( + where={"id": last_msg.id}, + data={"durationMs": duration_ms}, + ) + # Invalidate cache so the session is re-fetched from DB with durationMs + from backend.copilot.model import invalidate_session_cache + + await invalidate_session_cache(session_id) diff --git a/autogpt_platform/backend/backend/copilot/model.py b/autogpt_platform/backend/backend/copilot/model.py index a3dde30d8492..f41e5bf55d3f 100644 --- a/autogpt_platform/backend/backend/copilot/model.py +++ b/autogpt_platform/backend/backend/copilot/model.py @@ -54,6 +54,7 @@ class ChatMessage(BaseModel): refusal: str | None = None tool_calls: list[dict] | None = None function_call: dict | None = None + duration_ms: int | None = None @staticmethod def from_db(prisma_message: PrismaChatMessage) -> "ChatMessage": @@ -66,6 +67,7 @@ def from_db(prisma_message: PrismaChatMessage) -> "ChatMessage": refusal=prisma_message.refusal, tool_calls=_parse_json_field(prisma_message.toolCalls), function_call=_parse_json_field(prisma_message.functionCall), + duration_ms=prisma_message.durationMs, ) @@ -561,6 +563,7 @@ async def _save_session_to_db( "refusal": msg.refusal, "tool_calls": msg.tool_calls, "function_call": msg.function_call, + "duration_ms": msg.duration_ms, } ) logger.info( diff --git a/autogpt_platform/backend/backend/copilot/stream_registry.py b/autogpt_platform/backend/backend/copilot/stream_registry.py index 3c8691603ee6..4b246e7ba345 100644 --- a/autogpt_platform/backend/backend/copilot/stream_registry.py +++ b/autogpt_platform/backend/backend/copilot/stream_registry.py @@ -111,6 +111,14 @@ def _parse_session_meta(meta: dict[Any, Any], session_id: str = "") -> ActiveSes ``session_id`` is used as a fallback for ``turn_id`` when the meta hash pre-dates the turn_id field (backward compat for in-flight sessions). """ + created_at = datetime.now(timezone.utc) + created_at_raw = meta.get("created_at") + if created_at_raw: + try: + created_at = datetime.fromisoformat(str(created_at_raw)) + except (ValueError, TypeError): + pass + return ActiveSession( session_id=meta.get("session_id", "") or session_id, user_id=meta.get("user_id", "") or None, @@ -119,6 +127,7 @@ def _parse_session_meta(meta: dict[Any, Any], session_id: str = "") -> ActiveSes turn_id=meta.get("turn_id", "") or session_id, blocking=meta.get("blocking") == "1", status=meta.get("status", "running"), # type: ignore[arg-type] + created_at=created_at, ) @@ -802,6 +811,22 @@ async def mark_session_completed( f"Failed to publish error event for session {session_id}: {e}" ) + # Compute wall-clock duration from session created_at + duration_ms: int | None = None + if meta: + parsed_meta = _parse_session_meta(meta, session_id) + elapsed = datetime.now(timezone.utc) - parsed_meta.created_at + duration_ms = max(0, int(elapsed.total_seconds() * 1000)) + + # Persist duration on the last assistant message + if duration_ms is not None: + try: + from backend.data.db_accessors import chat_db + + await chat_db().set_turn_duration(session_id, duration_ms) + except Exception as e: + logger.warning(f"Failed to save turn duration for {session_id}: {e}") + # Publish StreamFinish AFTER status is set to "completed"/"failed". # This is the SINGLE place that publishes StreamFinish — services and # the processor must NOT publish it themselves. diff --git a/autogpt_platform/backend/backend/data/db_manager.py b/autogpt_platform/backend/backend/data/db_manager.py index 2409268e87aa..72dccc634b31 100644 --- a/autogpt_platform/backend/backend/data/db_manager.py +++ b/autogpt_platform/backend/backend/data/db_manager.py @@ -344,6 +344,7 @@ def _( get_next_sequence = _(chat_db.get_next_sequence) update_tool_message_content = _(chat_db.update_tool_message_content) update_chat_session_title = _(chat_db.update_chat_session_title) + set_turn_duration = _(chat_db.set_turn_duration) class DatabaseManagerClient(AppServiceClient): @@ -540,3 +541,4 @@ def get_service_type(cls): get_next_sequence = d.get_next_sequence update_tool_message_content = d.update_tool_message_content update_chat_session_title = d.update_chat_session_title + set_turn_duration = d.set_turn_duration diff --git a/autogpt_platform/backend/migrations/20260326120000_add_chat_message_duration_ms/migration.sql b/autogpt_platform/backend/migrations/20260326120000_add_chat_message_duration_ms/migration.sql new file mode 100644 index 000000000000..5302b3f641ff --- /dev/null +++ b/autogpt_platform/backend/migrations/20260326120000_add_chat_message_duration_ms/migration.sql @@ -0,0 +1,2 @@ +-- Add durationMs column to ChatMessage for persisting turn elapsed time. +ALTER TABLE "ChatMessage" ADD COLUMN "durationMs" INTEGER; diff --git a/autogpt_platform/backend/schema.prisma b/autogpt_platform/backend/schema.prisma index f269d45016e1..e84c3b79ba1e 100644 --- a/autogpt_platform/backend/schema.prisma +++ b/autogpt_platform/backend/schema.prisma @@ -246,7 +246,8 @@ model ChatMessage { functionCall Json? // Deprecated but kept for compatibility // Ordering within session - sequence Int + sequence Int + durationMs Int? // Wall-clock milliseconds for this assistant turn @@unique([sessionId, sequence]) } diff --git a/autogpt_platform/frontend/src/app/(platform)/copilot/CopilotPage.tsx b/autogpt_platform/frontend/src/app/(platform)/copilot/CopilotPage.tsx index 9d481aa42e11..0ca904824b70 100644 --- a/autogpt_platform/frontend/src/app/(platform)/copilot/CopilotPage.tsx +++ b/autogpt_platform/frontend/src/app/(platform)/copilot/CopilotPage.tsx @@ -89,6 +89,8 @@ export function CopilotPage() { isDeleting, handleConfirmDelete, handleCancelDelete, + // Historical durations for persisted timer stats + historicalDurations, } = useCopilotPage(); if (isUserLoading || !isLoggedIn) { @@ -143,6 +145,7 @@ export function CopilotPage() { isUploadingFiles={isUploadingFiles} droppedFiles={droppedFiles} onDroppedFilesConsumed={handleDroppedFilesConsumed} + historicalDurations={historicalDurations} />
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 1b49a055ead8..3b42b1a41521 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 @@ -27,6 +27,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; } export const ChatContainer = ({ messages, @@ -44,6 +46,7 @@ export const ChatContainer = ({ isUploadingFiles, droppedFiles, onDroppedFilesConsumed, + historicalDurations, }: ChatContainerProps) => { const isBusy = status === "streaming" || @@ -81,6 +84,7 @@ export const ChatContainer = ({ isLoading={isLoadingSession} sessionID={sessionId} onRetry={handleRetry} + historicalDurations={historicalDurations} /> void; + historicalDurations?: Map; } function renderSegments( @@ -112,6 +113,7 @@ export function ChatMessagesContainer({ isLoading, sessionID, onRetry, + historicalDurations, }: Props) { const lastMessage = messages[messages.length - 1]; const graphExecId = useMemo(() => extractGraphExecId(messages), [messages]); @@ -262,6 +264,7 @@ export function ChatMessagesContainer({ ? frozenElapsedRef.current : undefined } + durationMs={historicalDurations?.get(message.id)} /> )} {isLastAssistant && showThinking && ( diff --git a/autogpt_platform/frontend/src/app/(platform)/copilot/components/ChatMessagesContainer/components/ThinkingIndicator.tsx b/autogpt_platform/frontend/src/app/(platform)/copilot/components/ChatMessagesContainer/components/ThinkingIndicator.tsx index 5c756cc83674..60f928ce5e20 100644 --- a/autogpt_platform/frontend/src/app/(platform)/copilot/components/ChatMessagesContainer/components/ThinkingIndicator.tsx +++ b/autogpt_platform/frontend/src/app/(platform)/copilot/components/ChatMessagesContainer/components/ThinkingIndicator.tsx @@ -1,75 +1,8 @@ -import { useEffect, useRef, useState } from "react"; import { formatElapsed } from "../../JobStatsBar/formatElapsed"; import { ScaleLoader } from "../../ScaleLoader/ScaleLoader"; -const THINKING_PHRASES = [ - "Thinking...", - "Considering this...", - "Working through this...", - "Analyzing your request...", - "Reasoning...", - "Looking into it...", - "Processing your request...", - "Mulling this over...", - "Piecing it together...", - "On it...", - "Connecting the dots...", - "Exploring possibilities...", - "Weighing options...", - "Diving deeper...", - "Gathering thoughts...", - "Almost there...", - "Figuring this out...", - "Putting it together...", - "Running through ideas...", - "Wrapping my head around this...", -]; - -const PHRASE_CYCLE_MS = 6_000; -const FADE_DURATION_MS = 300; - -/** - * Cycles through thinking phrases sequentially with a fade-out/in transition. - * Returns the current phrase and whether it's visible (for opacity). - */ -function useCyclingPhrase(active: boolean) { - const indexRef = useRef(0); - const [phrase, setPhrase] = useState(THINKING_PHRASES[0]); - const [visible, setVisible] = useState(true); - const fadeTimeoutRef = useRef | null>(null); - - // Reset to the first phrase when thinking restarts - const prevActive = useRef(active); - useEffect(() => { - if (active && !prevActive.current) { - indexRef.current = 0; - setPhrase(THINKING_PHRASES[0]); - setVisible(true); - } - prevActive.current = active; - }, [active]); - - useEffect(() => { - if (!active) return; - const id = setInterval(() => { - setVisible(false); - fadeTimeoutRef.current = setTimeout(() => { - indexRef.current = (indexRef.current + 1) % THINKING_PHRASES.length; - setPhrase(THINKING_PHRASES[indexRef.current]); - setVisible(true); - }, FADE_DURATION_MS); - }, PHRASE_CYCLE_MS); - return () => { - clearInterval(id); - if (fadeTimeoutRef.current) { - clearTimeout(fadeTimeoutRef.current); - fadeTimeoutRef.current = null; - } - }; - }, [active]); - - return { phrase, visible }; -} +/** Only show elapsed time after this many seconds. */ +const SHOW_AFTER_SECONDS = 20; interface Props { active: boolean; @@ -77,23 +10,13 @@ interface Props { } export function ThinkingIndicator({ active, elapsedSeconds }: Props) { - const { phrase, visible } = useCyclingPhrase(active); + const showTime = active && elapsedSeconds >= SHOW_AFTER_SECONDS; return ( - + - - - {phrase} - - - {elapsedSeconds > 0 && ( - - {formatElapsed(elapsedSeconds)} - + {showTime && ( + {formatElapsed(elapsedSeconds)} )} ); 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 a5c9382052ba..1b21316c891b 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 @@ -5,11 +5,25 @@ import { getWorkDoneCounters } from "./useWorkDoneCounters"; interface Props { turnMessages: UIMessage[]; elapsedSeconds?: number; + durationMs?: number; } -export function TurnStatsBar({ turnMessages, elapsedSeconds }: Props) { +export function TurnStatsBar({ + turnMessages, + elapsedSeconds, + durationMs, +}: Props) { const { counters } = getWorkDoneCounters(turnMessages); - const hasTime = elapsedSeconds !== undefined && elapsedSeconds > 0; + + // Prefer live elapsedSeconds, fall back to persisted durationMs + const displaySeconds = + elapsedSeconds !== undefined && elapsedSeconds > 0 + ? elapsedSeconds + : durationMs !== undefined + ? Math.round(durationMs / 1000) + : undefined; + + const hasTime = displaySeconds !== undefined && displaySeconds > 0; if (counters.length === 0 && !hasTime) return null; @@ -17,7 +31,7 @@ export function TurnStatsBar({ turnMessages, elapsedSeconds }: Props) {
{hasTime && ( - Thought for {formatElapsed(elapsedSeconds)} + Thought for {formatElapsed(displaySeconds)} )} {counters.map(function renderCounter(counter, index) { 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 f6e008467665..2211c272770d 100644 --- a/autogpt_platform/frontend/src/app/(platform)/copilot/helpers/convertChatSessionToUiMessages.ts +++ b/autogpt_platform/frontend/src/app/(platform)/copilot/helpers/convertChatSessionToUiMessages.ts @@ -6,6 +6,7 @@ interface SessionChatMessage { content: string | null; tool_call_id: string | null; tool_calls: unknown[] | null; + duration_ms: number | null; } function coerceSessionChatMessages( @@ -34,6 +35,8 @@ function coerceSessionChatMessages( ? null : String(msg.tool_call_id), tool_calls: Array.isArray(msg.tool_calls) ? msg.tool_calls : null, + duration_ms: + typeof msg.duration_ms === "number" ? msg.duration_ms : null, }; }) .filter((m): m is SessionChatMessage => m !== null); @@ -102,7 +105,10 @@ export function convertChatSessionMessagesToUiMessages( sessionId: string, rawMessages: unknown[], options?: { isComplete?: boolean }, -): UIMessage[] { +): { + messages: UIMessage[]; + durations: Map; +} { const messages = coerceSessionChatMessages(rawMessages); const toolOutputsByCallId = new Map(); @@ -114,6 +120,7 @@ export function convertChatSessionMessagesToUiMessages( } const uiMessages: UIMessage[] = []; + const durations = new Map(); messages.forEach((msg, index) => { if (msg.role === "tool") return; @@ -186,15 +193,24 @@ export function convertChatSessionMessagesToUiMessages( const prevUI = uiMessages[uiMessages.length - 1]; if (msg.role === "assistant" && prevUI && prevUI.role === "assistant") { 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); + } return; } + const msgId = `${sessionId}-${index}`; uiMessages.push({ - id: `${sessionId}-${index}`, + id: msgId, role: msg.role, parts, }); + + if (msg.role === "assistant" && msg.duration_ms != null) { + durations.set(msgId, msg.duration_ms); + } }); - return uiMessages; + return { messages: uiMessages, durations }; } diff --git a/autogpt_platform/frontend/src/app/(platform)/copilot/useChatSession.ts b/autogpt_platform/frontend/src/app/(platform)/copilot/useChatSession.ts index afbe525ed63e..52b3ffa6c808 100644 --- a/autogpt_platform/frontend/src/app/(platform)/copilot/useChatSession.ts +++ b/autogpt_platform/frontend/src/app/(platform)/copilot/useChatSession.ts @@ -61,13 +61,21 @@ export function useChatSession() { // 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 = useMemo(() => { - if (sessionQuery.data?.status !== 200 || !sessionId) return undefined; - return convertChatSessionMessagesToUiMessages( + const { hydratedMessages, historicalDurations } = useMemo(() => { + if (sessionQuery.data?.status !== 200 || !sessionId) + return { + hydratedMessages: undefined, + historicalDurations: new Map(), + }; + const result = convertChatSessionMessagesToUiMessages( sessionId, sessionQuery.data.data.messages ?? [], { isComplete: !hasActiveStream }, ); + return { + hydratedMessages: result.messages, + historicalDurations: result.durations, + }; }, [sessionQuery.data, sessionId, hasActiveStream]); const { mutateAsync: createSessionMutation, isPending: isCreatingSession } = @@ -122,6 +130,7 @@ export function useChatSession() { sessionId, setSessionId, hydratedMessages, + historicalDurations, hasActiveStream, isLoadingSession: sessionQuery.isLoading, isSessionError: sessionQuery.isError, diff --git a/autogpt_platform/frontend/src/app/(platform)/copilot/useCopilotPage.ts b/autogpt_platform/frontend/src/app/(platform)/copilot/useCopilotPage.ts index bf82fc66ec6b..79f6ceaf6e1e 100644 --- a/autogpt_platform/frontend/src/app/(platform)/copilot/useCopilotPage.ts +++ b/autogpt_platform/frontend/src/app/(platform)/copilot/useCopilotPage.ts @@ -39,6 +39,7 @@ export function useCopilotPage() { sessionId, setSessionId, hydratedMessages, + historicalDurations, hasActiveStream, isLoadingSession, isSessionError, @@ -375,5 +376,7 @@ export function useCopilotPage() { handleDeleteClick, handleConfirmDelete, handleCancelDelete, + // Historical durations for persisted timer stats + historicalDurations, }; } From ea4659624d8f893a7baa79270fe90b517d4b84d8 Mon Sep 17 00:00:00 2001 From: Lluis Agusti Date: Fri, 27 Mar 2026 17:55:22 +0800 Subject: [PATCH 06/10] fix(platform/copilot): address PR review feedback on timer stats - Skip duration persistence on error (sentry bug report) - Check raw created_at from Redis instead of parsed fallback to avoid storing durationMs=0 for pre-existing sessions (majdyz, copilot-reviewer) - Handle naive datetime by normalizing to UTC (copilot-reviewer) - Move chat_db import to top-level in stream_registry.py (coderabbitai) - Move invalidate_session_cache import to top-level in db.py (coderabbitai, majdyz) - Remove duration_ms from _save_session_to_db to prevent race condition where delayed cache flush could overwrite correct value (majdyz) - Move ref mutations from render phase to useEffect in ChatMessagesContainer (majdyz) Co-Authored-By: Claude Opus 4.6 (1M context) --- .../backend/backend/copilot/db.py | 3 +-- .../backend/backend/copilot/model.py | 1 - .../backend/copilot/stream_registry.py | 27 ++++++++++++++----- .../ChatMessagesContainer.tsx | 20 +++++++------- 4 files changed, 32 insertions(+), 19 deletions(-) diff --git a/autogpt_platform/backend/backend/copilot/db.py b/autogpt_platform/backend/backend/copilot/db.py index de39649fb069..56336cdd97b5 100644 --- a/autogpt_platform/backend/backend/copilot/db.py +++ b/autogpt_platform/backend/backend/copilot/db.py @@ -15,6 +15,7 @@ ChatSessionWhereInput, ) +from backend.copilot.model import invalidate_session_cache from backend.data import db from backend.util.json import SafeJson, sanitize_string @@ -380,6 +381,4 @@ async def set_turn_duration(session_id: str, duration_ms: int) -> None: data={"durationMs": duration_ms}, ) # Invalidate cache so the session is re-fetched from DB with durationMs - from backend.copilot.model import invalidate_session_cache - await invalidate_session_cache(session_id) diff --git a/autogpt_platform/backend/backend/copilot/model.py b/autogpt_platform/backend/backend/copilot/model.py index f41e5bf55d3f..c96b027e20f6 100644 --- a/autogpt_platform/backend/backend/copilot/model.py +++ b/autogpt_platform/backend/backend/copilot/model.py @@ -563,7 +563,6 @@ async def _save_session_to_db( "refusal": msg.refusal, "tool_calls": msg.tool_calls, "function_call": msg.function_call, - "duration_ms": msg.duration_ms, } ) logger.info( diff --git a/autogpt_platform/backend/backend/copilot/stream_registry.py b/autogpt_platform/backend/backend/copilot/stream_registry.py index 4b246e7ba345..092349166446 100644 --- a/autogpt_platform/backend/backend/copilot/stream_registry.py +++ b/autogpt_platform/backend/backend/copilot/stream_registry.py @@ -30,6 +30,7 @@ AsyncRedisNotificationEventBus, NotificationEvent, ) +from backend.data.db_accessors import chat_db from backend.data.redis_client import get_redis_async from .config import ChatConfig @@ -811,18 +812,30 @@ async def mark_session_completed( f"Failed to publish error event for session {session_id}: {e}" ) - # Compute wall-clock duration from session created_at + # 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). duration_ms: int | None = None - if meta: - parsed_meta = _parse_session_meta(meta, session_id) - elapsed = datetime.now(timezone.utc) - parsed_meta.created_at - duration_ms = max(0, int(elapsed.total_seconds() * 1000)) + if meta and not error_message: + created_at_raw = meta.get("created_at") + if created_at_raw: + try: + created_at = datetime.fromisoformat(str(created_at_raw)) + if created_at.tzinfo is None: + created_at = created_at.replace(tzinfo=timezone.utc) + elapsed = datetime.now(timezone.utc) - created_at + duration_ms = max(0, int(elapsed.total_seconds() * 1000)) + except (ValueError, TypeError): + logger.warning( + "Failed to compute session duration for %s " + "(created_at=%r)", + session_id, + created_at_raw, + ) # Persist duration on the last assistant message if duration_ms is not None: try: - from backend.data.db_accessors import chat_db - await chat_db().set_turn_duration(session_id, duration_ms) except Exception as e: logger.warning(f"Failed to save turn duration for {session_id}: {e}") diff --git a/autogpt_platform/frontend/src/app/(platform)/copilot/components/ChatMessagesContainer/ChatMessagesContainer.tsx b/autogpt_platform/frontend/src/app/(platform)/copilot/components/ChatMessagesContainer/ChatMessagesContainer.tsx index b9e1825ad2f0..205fa74bd0ce 100644 --- a/autogpt_platform/frontend/src/app/(platform)/copilot/components/ChatMessagesContainer/ChatMessagesContainer.tsx +++ b/autogpt_platform/frontend/src/app/(platform)/copilot/components/ChatMessagesContainer/ChatMessagesContainer.tsx @@ -1,4 +1,4 @@ -import { useMemo, useRef } from "react"; +import { useEffect, useMemo, useRef } from "react"; import { Conversation, ConversationContent, @@ -149,15 +149,17 @@ export function ChatMessagesContainer({ // Reset when a new streaming turn begins. const frozenElapsedRef = useRef(0); const wasStreamingRef = useRef(false); - if (isActivelyStreaming) { - if (!wasStreamingRef.current) { - frozenElapsedRef.current = 0; - } - if (elapsedSeconds > 0) { - frozenElapsedRef.current = elapsedSeconds; + useEffect(() => { + if (isActivelyStreaming) { + if (!wasStreamingRef.current) { + frozenElapsedRef.current = 0; + } + if (elapsedSeconds > 0) { + frozenElapsedRef.current = elapsedSeconds; + } } - } - wasStreamingRef.current = isActivelyStreaming; + wasStreamingRef.current = isActivelyStreaming; + }); return ( From 333d8ca2067977985e35c0868486b309a8f18d7e Mon Sep 17 00:00:00 2001 From: Lluis Agusti Date: Fri, 27 Mar 2026 18:09:25 +0800 Subject: [PATCH 07/10] fix(backend): fix import ordering and string formatting for lint - Sort chat_db import alphabetically (isort) - Combine invalidate_session_cache with sibling .model imports - Join multiline log string onto one line (black) Co-Authored-By: Claude Opus 4.6 (1M context) --- autogpt_platform/backend/backend/copilot/db.py | 3 +-- autogpt_platform/backend/backend/copilot/stream_registry.py | 5 ++--- 2 files changed, 3 insertions(+), 5 deletions(-) diff --git a/autogpt_platform/backend/backend/copilot/db.py b/autogpt_platform/backend/backend/copilot/db.py index 56336cdd97b5..74a030888d70 100644 --- a/autogpt_platform/backend/backend/copilot/db.py +++ b/autogpt_platform/backend/backend/copilot/db.py @@ -15,11 +15,10 @@ ChatSessionWhereInput, ) -from backend.copilot.model import invalidate_session_cache from backend.data import db from backend.util.json import SafeJson, sanitize_string -from .model import ChatMessage, ChatSession, ChatSessionInfo +from .model import ChatMessage, ChatSession, ChatSessionInfo, invalidate_session_cache logger = logging.getLogger(__name__) diff --git a/autogpt_platform/backend/backend/copilot/stream_registry.py b/autogpt_platform/backend/backend/copilot/stream_registry.py index 092349166446..ac4017d8a230 100644 --- a/autogpt_platform/backend/backend/copilot/stream_registry.py +++ b/autogpt_platform/backend/backend/copilot/stream_registry.py @@ -26,11 +26,11 @@ from redis.exceptions import RedisError from backend.api.model import CopilotCompletionPayload +from backend.data.db_accessors import chat_db from backend.data.notification_bus import ( AsyncRedisNotificationEventBus, NotificationEvent, ) -from backend.data.db_accessors import chat_db from backend.data.redis_client import get_redis_async from .config import ChatConfig @@ -827,8 +827,7 @@ async def mark_session_completed( duration_ms = max(0, int(elapsed.total_seconds() * 1000)) except (ValueError, TypeError): logger.warning( - "Failed to compute session duration for %s " - "(created_at=%r)", + "Failed to compute session duration for %s (created_at=%r)", session_id, created_at_raw, ) From 490f5e9ef4982d82451c0972db667e93aad5618f Mon Sep 17 00:00:00 2001 From: Lluis Agusti Date: Fri, 27 Mar 2026 18:27:43 +0800 Subject: [PATCH 08/10] fix(frontend/copilot): restore thinking phrases in ThinkingIndicator MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Always show [Pulse] [Thinking phrase]... with cycling fade transitions. After 20s elapsed, append "• 23s" at the end instead of replacing the phrase with just the timer. Co-Authored-By: Claude Opus 4.6 (1M context) --- .../components/ThinkingIndicator.tsx | 89 ++++++++++++++++++- 1 file changed, 85 insertions(+), 4 deletions(-) diff --git a/autogpt_platform/frontend/src/app/(platform)/copilot/components/ChatMessagesContainer/components/ThinkingIndicator.tsx b/autogpt_platform/frontend/src/app/(platform)/copilot/components/ChatMessagesContainer/components/ThinkingIndicator.tsx index 60f928ce5e20..20d7591c8ec4 100644 --- a/autogpt_platform/frontend/src/app/(platform)/copilot/components/ChatMessagesContainer/components/ThinkingIndicator.tsx +++ b/autogpt_platform/frontend/src/app/(platform)/copilot/components/ChatMessagesContainer/components/ThinkingIndicator.tsx @@ -1,8 +1,78 @@ +import { useEffect, useRef, useState } from "react"; import { formatElapsed } from "../../JobStatsBar/formatElapsed"; import { ScaleLoader } from "../../ScaleLoader/ScaleLoader"; +const THINKING_PHRASES = [ + "Thinking...", + "Considering this...", + "Working through this...", + "Analyzing your request...", + "Reasoning...", + "Looking into it...", + "Processing your request...", + "Mulling this over...", + "Piecing it together...", + "On it...", + "Connecting the dots...", + "Exploring possibilities...", + "Weighing options...", + "Diving deeper...", + "Gathering thoughts...", + "Almost there...", + "Figuring this out...", + "Putting it together...", + "Running through ideas...", + "Wrapping my head around this...", +]; + +const PHRASE_CYCLE_MS = 6_000; +const FADE_DURATION_MS = 300; + /** Only show elapsed time after this many seconds. */ -const SHOW_AFTER_SECONDS = 20; +const SHOW_TIME_AFTER_SECONDS = 20; + +/** + * Cycles through thinking phrases sequentially with a fade-out/in transition. + * Returns the current phrase and whether it's visible (for opacity). + */ +function useCyclingPhrase(active: boolean) { + const indexRef = useRef(0); + const [phrase, setPhrase] = useState(THINKING_PHRASES[0]); + const [visible, setVisible] = useState(true); + const fadeTimeoutRef = useRef | null>(null); + + // Reset to the first phrase when thinking restarts + const prevActive = useRef(active); + useEffect(() => { + if (active && !prevActive.current) { + indexRef.current = 0; + setPhrase(THINKING_PHRASES[0]); + setVisible(true); + } + prevActive.current = active; + }, [active]); + + useEffect(() => { + if (!active) return; + const id = setInterval(() => { + setVisible(false); + fadeTimeoutRef.current = setTimeout(() => { + indexRef.current = (indexRef.current + 1) % THINKING_PHRASES.length; + setPhrase(THINKING_PHRASES[indexRef.current]); + setVisible(true); + }, FADE_DURATION_MS); + }, PHRASE_CYCLE_MS); + return () => { + clearInterval(id); + if (fadeTimeoutRef.current) { + clearTimeout(fadeTimeoutRef.current); + fadeTimeoutRef.current = null; + } + }; + }, [active]); + + return { phrase, visible }; +} interface Props { active: boolean; @@ -10,13 +80,24 @@ interface Props { } export function ThinkingIndicator({ active, elapsedSeconds }: Props) { - const showTime = active && elapsedSeconds >= SHOW_AFTER_SECONDS; + const { phrase, visible } = useCyclingPhrase(active); + const showTime = active && elapsedSeconds >= SHOW_TIME_AFTER_SECONDS; return ( - + + + + {phrase} + + {showTime && ( - {formatElapsed(elapsedSeconds)} + + • {formatElapsed(elapsedSeconds)} + )} ); From 37e7af3e097cef327797a6e7c38036eab9b77160 Mon Sep 17 00:00:00 2001 From: Lluis Agusti Date: Fri, 27 Mar 2026 18:42:28 +0800 Subject: [PATCH 09/10] fix(frontend/copilot): match timer font style with thinking phrase Remove font-mono and text-sm overrides from elapsed time span so it inherits the same font-size, family and colour as the phrase text. Co-Authored-By: Claude Opus 4.6 (1M context) --- .../ChatMessagesContainer/components/ThinkingIndicator.tsx | 4 +--- 1 file changed, 1 insertion(+), 3 deletions(-) diff --git a/autogpt_platform/frontend/src/app/(platform)/copilot/components/ChatMessagesContainer/components/ThinkingIndicator.tsx b/autogpt_platform/frontend/src/app/(platform)/copilot/components/ChatMessagesContainer/components/ThinkingIndicator.tsx index 20d7591c8ec4..430e72e3f5c1 100644 --- a/autogpt_platform/frontend/src/app/(platform)/copilot/components/ChatMessagesContainer/components/ThinkingIndicator.tsx +++ b/autogpt_platform/frontend/src/app/(platform)/copilot/components/ChatMessagesContainer/components/ThinkingIndicator.tsx @@ -95,9 +95,7 @@ export function ThinkingIndicator({ active, elapsedSeconds }: Props) { {showTime && ( - - • {formatElapsed(elapsedSeconds)} - + • {formatElapsed(elapsedSeconds)} )} ); From 9a1159d4181ec70d0c41b11fae2eeb1c1fd9ea52 Mon Sep 17 00:00:00 2001 From: Lluis Agusti Date: Fri, 27 Mar 2026 18:57:16 +0800 Subject: [PATCH 10/10] fix(frontend/copilot): add pulse animation to elapsed timer text MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Apply the same animate-pulse shimmer to the "• 27s" span as the thinking phrase. Co-Authored-By: Claude Opus 4.6 (1M context) --- .../ChatMessagesContainer/components/ThinkingIndicator.tsx | 4 +++- 1 file changed, 3 insertions(+), 1 deletion(-) diff --git a/autogpt_platform/frontend/src/app/(platform)/copilot/components/ChatMessagesContainer/components/ThinkingIndicator.tsx b/autogpt_platform/frontend/src/app/(platform)/copilot/components/ChatMessagesContainer/components/ThinkingIndicator.tsx index 430e72e3f5c1..99d4ef75b6fe 100644 --- a/autogpt_platform/frontend/src/app/(platform)/copilot/components/ChatMessagesContainer/components/ThinkingIndicator.tsx +++ b/autogpt_platform/frontend/src/app/(platform)/copilot/components/ChatMessagesContainer/components/ThinkingIndicator.tsx @@ -95,7 +95,9 @@ export function ThinkingIndicator({ active, elapsedSeconds }: Props) { {showTime && ( - • {formatElapsed(elapsedSeconds)} + + • {formatElapsed(elapsedSeconds)} + )} );