feat(chat): add turn duration display
This commit is contained in:
@@ -856,6 +856,7 @@ const ChatMessage: React.FC<ChatMessageProps> = ({
|
|||||||
isUser={isUser}
|
isUser={isUser}
|
||||||
isMessageCompleted={isMessageCompleted}
|
isMessageCompleted={isMessageCompleted}
|
||||||
messageFinish={messageFinish}
|
messageFinish={messageFinish}
|
||||||
|
messageCompletedAt={messageCompletedAt ?? undefined}
|
||||||
syntaxTheme={syntaxTheme}
|
syntaxTheme={syntaxTheme}
|
||||||
isMobile={isMobile}
|
isMobile={isMobile}
|
||||||
hasTouchInput={hasTouchInput}
|
hasTouchInput={hasTouchInput}
|
||||||
|
|||||||
@@ -51,6 +51,7 @@ export interface TurnGroupingContext {
|
|||||||
hasTools: boolean;
|
hasTools: boolean;
|
||||||
hasReasoning: boolean;
|
hasReasoning: boolean;
|
||||||
diffStats?: TurnDiffStats;
|
diffStats?: TurnDiffStats;
|
||||||
|
userMessageCreatedAt?: number;
|
||||||
|
|
||||||
isWorking: boolean;
|
isWorking: boolean;
|
||||||
isGroupExpanded: boolean;
|
isGroupExpanded: boolean;
|
||||||
@@ -488,6 +489,9 @@ export const useTurnGrouping = (messages: ChatMessageEntry[]): UseTurnGroupingRe
|
|||||||
const uiState = getOrCreateTurnState(turn.turnId);
|
const uiState = getOrCreateTurnState(turn.turnId);
|
||||||
const isTurnWorking = sessionIsWorking && lastTurnId === 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 {
|
return {
|
||||||
turnId: turn.turnId,
|
turnId: turn.turnId,
|
||||||
isFirstAssistantInTurn,
|
isFirstAssistantInTurn,
|
||||||
@@ -499,6 +503,7 @@ export const useTurnGrouping = (messages: ChatMessageEntry[]): UseTurnGroupingRe
|
|||||||
hasTools,
|
hasTools,
|
||||||
hasReasoning,
|
hasReasoning,
|
||||||
diffStats,
|
diffStats,
|
||||||
|
userMessageCreatedAt,
|
||||||
isWorking: isTurnWorking,
|
isWorking: isTurnWorking,
|
||||||
isGroupExpanded: uiState.isExpanded,
|
isGroupExpanded: uiState.isExpanded,
|
||||||
previewedPartIds: uiState.previewedPartIds,
|
previewedPartIds: uiState.previewedPartIds,
|
||||||
|
|||||||
@@ -16,7 +16,7 @@ import { isEmptyTextPart, extractTextContent } from './partUtils';
|
|||||||
import { FadeInOnReveal } from './FadeInOnReveal';
|
import { FadeInOnReveal } from './FadeInOnReveal';
|
||||||
import { Button } from '@/components/ui/button';
|
import { Button } from '@/components/ui/button';
|
||||||
import { Tooltip, TooltipContent, TooltipTrigger } from '@/components/ui/tooltip';
|
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 { ArrowsMerge } from '@/components/icons/ArrowsMerge';
|
||||||
import type { ContentChangeReason } from '@/hooks/useChatScrollManager';
|
import type { ContentChangeReason } from '@/hooks/useChatScrollManager';
|
||||||
|
|
||||||
@@ -27,6 +27,16 @@ import { useUIStore } from '@/stores/useUIStore';
|
|||||||
import { flattenAssistantTextParts } from '@/lib/messages/messageText';
|
import { flattenAssistantTextParts } from '@/lib/messages/messageText';
|
||||||
import { MULTIRUN_EXECUTION_FORK_PROMPT_META_TEXT } from '@/lib/messages/executionMeta';
|
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 = (
|
const useMigrationTimer = (
|
||||||
turnGroupingContext: TurnGroupingContext | undefined,
|
turnGroupingContext: TurnGroupingContext | undefined,
|
||||||
@@ -110,6 +120,7 @@ interface MessageBodyProps {
|
|||||||
isUser: boolean;
|
isUser: boolean;
|
||||||
isMessageCompleted: boolean;
|
isMessageCompleted: boolean;
|
||||||
messageFinish?: string;
|
messageFinish?: string;
|
||||||
|
messageCompletedAt?: number;
|
||||||
|
|
||||||
syntaxTheme: { [key: string]: React.CSSProperties };
|
syntaxTheme: { [key: string]: React.CSSProperties };
|
||||||
|
|
||||||
@@ -327,6 +338,7 @@ const AssistantMessageBody: React.FC<Omit<MessageBodyProps, 'isUser'>> = ({
|
|||||||
parts,
|
parts,
|
||||||
isMessageCompleted,
|
isMessageCompleted,
|
||||||
messageFinish,
|
messageFinish,
|
||||||
|
messageCompletedAt,
|
||||||
|
|
||||||
syntaxTheme,
|
syntaxTheme,
|
||||||
isMobile,
|
isMobile,
|
||||||
@@ -1099,6 +1111,14 @@ const AssistantMessageBody: React.FC<Omit<MessageBodyProps, 'isUser'>> = ({
|
|||||||
const shouldShowFooter = isLastAssistantInTurn && hasTextContent && (hasStopFinish || Boolean(errorMessage));
|
const shouldShowFooter = isLastAssistantInTurn && hasTextContent && (hasStopFinish || Boolean(errorMessage));
|
||||||
const [isSummaryHovered, setIsSummaryHovered] = React.useState(false);
|
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 = (
|
const footerButtons = (
|
||||||
<>
|
<>
|
||||||
<Tooltip delayDuration={1000}>
|
<Tooltip delayDuration={1000}>
|
||||||
@@ -1208,14 +1228,22 @@ const AssistantMessageBody: React.FC<Omit<MessageBodyProps, 'isUser'>> = ({
|
|||||||
>
|
>
|
||||||
<SimpleMarkdownRenderer content={summaryBody} />
|
<SimpleMarkdownRenderer content={summaryBody} />
|
||||||
{shouldShowFooter && (
|
{shouldShowFooter && (
|
||||||
|
<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
|
<div
|
||||||
className={cn(
|
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",
|
"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",
|
isSummaryHovered && "opacity-100 pointer-events-auto",
|
||||||
)}
|
)}
|
||||||
>
|
>
|
||||||
{footerButtons}
|
{footerButtons}
|
||||||
</div>
|
</div>
|
||||||
|
</div>
|
||||||
)}
|
)}
|
||||||
</div>
|
</div>
|
||||||
</FadeInOnReveal>
|
</FadeInOnReveal>
|
||||||
@@ -1223,9 +1251,17 @@ const AssistantMessageBody: React.FC<Omit<MessageBodyProps, 'isUser'>> = ({
|
|||||||
</div>
|
</div>
|
||||||
<MessageFilesDisplay files={parts} onShowPopup={onShowPopup} />
|
<MessageFilesDisplay files={parts} onShowPopup={onShowPopup} />
|
||||||
{!showSummaryBody && shouldShowFooter && (
|
{!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">
|
<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}
|
{footerButtons}
|
||||||
</div>
|
</div>
|
||||||
|
</div>
|
||||||
)}
|
)}
|
||||||
|
|
||||||
</div>
|
</div>
|
||||||
|
|||||||
Reference in New Issue
Block a user