feat(chat): add turn duration display

This commit is contained in:
Bohdan Triapitsyn
2026-01-14 01:23:24 +02:00
parent b84c17e0e2
commit 84650e16c3
3 changed files with 52 additions and 10 deletions
@@ -856,6 +856,7 @@ const ChatMessage: React.FC<ChatMessageProps> = ({
isUser={isUser}
isMessageCompleted={isMessageCompleted}
messageFinish={messageFinish}
messageCompletedAt={messageCompletedAt ?? undefined}
syntaxTheme={syntaxTheme}
isMobile={isMobile}
hasTouchInput={hasTouchInput}
@@ -51,6 +51,7 @@ export interface TurnGroupingContext {
hasTools: boolean;
hasReasoning: boolean;
diffStats?: TurnDiffStats;
userMessageCreatedAt?: number;
isWorking: boolean;
isGroupExpanded: boolean;
@@ -488,6 +489,9 @@ export const useTurnGrouping = (messages: ChatMessageEntry[]): UseTurnGroupingRe
const uiState = getOrCreateTurnState(turn.turnId);
const isTurnWorking = sessionIsWorking && lastTurnId === turn.turnId;
const userTimeInfo = turn.userMessage.info.time as { created?: number } | undefined;
const userMessageCreatedAt = typeof userTimeInfo?.created === 'number' ? userTimeInfo.created : undefined;
return {
turnId: turn.turnId,
isFirstAssistantInTurn,
@@ -499,6 +503,7 @@ export const useTurnGrouping = (messages: ChatMessageEntry[]): UseTurnGroupingRe
hasTools,
hasReasoning,
diffStats,
userMessageCreatedAt,
isWorking: isTurnWorking,
isGroupExpanded: uiState.isExpanded,
previewedPartIds: uiState.previewedPartIds,
@@ -16,7 +16,7 @@ import { isEmptyTextPart, extractTextContent } from './partUtils';
import { FadeInOnReveal } from './FadeInOnReveal';
import { Button } from '@/components/ui/button';
import { Tooltip, TooltipContent, TooltipTrigger } from '@/components/ui/tooltip';
import { RiCheckLine, RiFileCopyLine, RiChatNewLine, RiArrowGoBackLine, RiGitBranchLine } from '@remixicon/react';
import { RiCheckLine, RiFileCopyLine, RiChatNewLine, RiArrowGoBackLine, RiGitBranchLine, RiHourglassLine } from '@remixicon/react';
import { ArrowsMerge } from '@/components/icons/ArrowsMerge';
import type { ContentChangeReason } from '@/hooks/useChatScrollManager';
@@ -27,6 +27,16 @@ import { useUIStore } from '@/stores/useUIStore';
import { flattenAssistantTextParts } from '@/lib/messages/messageText';
import { MULTIRUN_EXECUTION_FORK_PROMPT_META_TEXT } from '@/lib/messages/executionMeta';
const formatTurnDuration = (durationMs: number): string => {
const totalSeconds = durationMs / 1000;
if (totalSeconds < 60) {
return `${totalSeconds.toFixed(1)}s`;
}
const minutes = Math.floor(totalSeconds / 60);
const seconds = Math.round(totalSeconds % 60);
return `${minutes}m ${seconds}s`;
};
const useMigrationTimer = (
turnGroupingContext: TurnGroupingContext | undefined,
@@ -110,6 +120,7 @@ interface MessageBodyProps {
isUser: boolean;
isMessageCompleted: boolean;
messageFinish?: string;
messageCompletedAt?: number;
syntaxTheme: { [key: string]: React.CSSProperties };
@@ -327,6 +338,7 @@ const AssistantMessageBody: React.FC<Omit<MessageBodyProps, 'isUser'>> = ({
parts,
isMessageCompleted,
messageFinish,
messageCompletedAt,
syntaxTheme,
isMobile,
@@ -1099,6 +1111,14 @@ const AssistantMessageBody: React.FC<Omit<MessageBodyProps, 'isUser'>> = ({
const shouldShowFooter = isLastAssistantInTurn && hasTextContent && (hasStopFinish || Boolean(errorMessage));
const [isSummaryHovered, setIsSummaryHovered] = React.useState(false);
const turnDurationText = React.useMemo(() => {
if (!isLastAssistantInTurn || !hasStopFinish) return undefined;
const userCreatedAt = turnGroupingContext?.userMessageCreatedAt;
if (typeof userCreatedAt !== 'number' || typeof messageCompletedAt !== 'number') return undefined;
if (messageCompletedAt <= userCreatedAt) return undefined;
return formatTurnDuration(messageCompletedAt - userCreatedAt);
}, [isLastAssistantInTurn, hasStopFinish, turnGroupingContext?.userMessageCreatedAt, messageCompletedAt]);
const footerButtons = (
<>
<Tooltip delayDuration={1000}>
@@ -1208,13 +1228,21 @@ const AssistantMessageBody: React.FC<Omit<MessageBodyProps, 'isUser'>> = ({
>
<SimpleMarkdownRenderer content={summaryBody} />
{shouldShowFooter && (
<div
className={cn(
"mt-2 mb-1 flex items-center justify-end gap-2 opacity-0 pointer-events-none transition-opacity duration-150 focus-within:opacity-100 focus-within:pointer-events-auto",
isSummaryHovered && "opacity-100 pointer-events-auto",
)}
>
{footerButtons}
<div className="mt-2 mb-1 flex items-center justify-between gap-2">
{turnDurationText ? (
<span className="text-sm text-muted-foreground/60 tabular-nums flex items-center gap-1">
<RiHourglassLine className="h-3.5 w-3.5" />
{turnDurationText}
</span>
) : <span />}
<div
className={cn(
"flex items-center gap-2 opacity-0 pointer-events-none transition-opacity duration-150 focus-within:opacity-100 focus-within:pointer-events-auto",
isSummaryHovered && "opacity-100 pointer-events-auto",
)}
>
{footerButtons}
</div>
</div>
)}
</div>
@@ -1223,8 +1251,16 @@ const AssistantMessageBody: React.FC<Omit<MessageBodyProps, 'isUser'>> = ({
</div>
<MessageFilesDisplay files={parts} onShowPopup={onShowPopup} />
{!showSummaryBody && shouldShowFooter && (
<div className="mt-2 mb-1 flex items-center justify-end gap-2 opacity-0 pointer-events-none transition-opacity duration-150 group-hover/message:opacity-100 group-hover/message:pointer-events-auto focus-within:opacity-100 focus-within:pointer-events-auto">
{footerButtons}
<div className="mt-2 mb-1 flex items-center justify-between gap-2">
{turnDurationText ? (
<span className="text-sm text-muted-foreground/60 tabular-nums flex items-center gap-1">
<RiHourglassLine className="h-3.5 w-3.5" />
{turnDurationText}
</span>
) : <span />}
<div className="flex items-center gap-2 opacity-0 pointer-events-none transition-opacity duration-150 group-hover/message:opacity-100 group-hover/message:pointer-events-auto focus-within:opacity-100 focus-within:pointer-events-auto">
{footerButtons}
</div>
</div>
)}