From be413771dc1a950d8d02cd3595fceb79e5a6c3de Mon Sep 17 00:00:00 2001 From: Nelson Pires Date: Sat, 28 Feb 2026 15:16:31 -0300 Subject: [PATCH] feat(chat): align activity timing UI with hover-only end timestamps (#557) * feat(chat): add shared activity timestamp formatter * feat(chat): pass assistant created timestamp to message body * feat(chat): render footer timestamp beside elapsed time * feat(chat): pass timing metadata in reasoning text blocks * feat(chat): propagate justification timing to timeline block * feat(chat): show thinking duration with hover end time * feat(chat): reveal tool end time on activity hover --- .../ui/src/components/chat/ChatMessage.tsx | 6 ++ .../components/chat/message/MessageBody.tsx | 47 +++++++++---- .../chat/message/parts/AssistantTextPart.tsx | 1 + .../chat/message/parts/JustificationBlock.tsx | 4 +- .../chat/message/parts/ReasoningPart.tsx | 69 +++++++++++++++++-- .../chat/message/parts/ToolPart.tsx | 22 +++++- .../src/components/chat/message/timeFormat.ts | 34 +++++++++ 7 files changed, 164 insertions(+), 19 deletions(-) create mode 100644 packages/ui/src/components/chat/message/timeFormat.ts diff --git a/packages/ui/src/components/chat/ChatMessage.tsx b/packages/ui/src/components/chat/ChatMessage.tsx index f9d448b3..4b08f72c 100644 --- a/packages/ui/src/components/chat/ChatMessage.tsx +++ b/packages/ui/src/components/chat/ChatMessage.tsx @@ -389,6 +389,11 @@ const ChatMessage: React.FC = ({ return typeof timeInfo?.completed === 'number' ? timeInfo.completed : null; }, [message.info.time]); + const messageCreatedAt = React.useMemo(() => { + const timeInfo = message.info.time as { created?: number } | undefined; + return typeof timeInfo?.created === 'number' ? timeInfo.created : null; + }, [message.info.time]); + const isMessageCompleted = React.useMemo(() => { if (isUser) return true; return Boolean(messageCompletedAt && messageCompletedAt > 0); @@ -1004,6 +1009,7 @@ const ChatMessage: React.FC = ({ isMessageCompleted={isMessageCompleted} messageFinish={messageFinish} messageCompletedAt={messageCompletedAt ?? undefined} + messageCreatedAt={messageCreatedAt ?? undefined} syntaxTheme={syntaxTheme} isMobile={isMobile} hasTouchInput={hasTouchInput} diff --git a/packages/ui/src/components/chat/message/MessageBody.tsx b/packages/ui/src/components/chat/message/MessageBody.tsx index 3d2f4c02..56074ca5 100644 --- a/packages/ui/src/components/chat/message/MessageBody.tsx +++ b/packages/ui/src/components/chat/message/MessageBody.tsx @@ -28,6 +28,7 @@ import { useMessageTTS } from '@/hooks/useMessageTTS'; import { useConfigStore } from '@/stores/useConfigStore'; import { TextSelectionMenu } from './TextSelectionMenu'; import { copyTextToClipboard } from '@/lib/clipboard'; +import { formatTimestampForDisplay } from './timeFormat'; type SubtaskPartLike = Part & { type: 'subtask'; @@ -257,6 +258,7 @@ interface MessageBodyProps { isMessageCompleted: boolean; messageFinish?: string; messageCompletedAt?: number; + messageCreatedAt?: number; syntaxTheme: { [key: string]: React.CSSProperties }; @@ -508,6 +510,7 @@ const AssistantMessageBody: React.FC> = ({ isMessageCompleted, messageFinish, messageCompletedAt, + messageCreatedAt, syntaxTheme, isMobile, @@ -1123,6 +1126,16 @@ const AssistantMessageBody: React.FC> = ({ return formatTurnDuration(messageCompletedAt - userCreatedAt); }, [isLastAssistantInTurn, hasStopFinish, turnGroupingContext?.userMessageCreatedAt, messageCompletedAt]); + const footerTimestamp = React.useMemo(() => { + const timestamp = typeof messageCompletedAt === 'number' && messageCompletedAt > 0 + ? messageCompletedAt + : (typeof messageCreatedAt === 'number' && messageCreatedAt > 0 ? messageCreatedAt : null); + if (timestamp === null) return null; + + const formatted = formatTimestampForDisplay(timestamp); + return formatted.length > 0 ? formatted : null; + }, [messageCompletedAt, messageCreatedAt]); + const footerButtons = ( <> {onCopyMessage && ( @@ -1259,12 +1272,17 @@ const AssistantMessageBody: React.FC> = ({
{footerButtons}
- {turnDurationText ? ( - - - {turnDurationText} - - ) : null} +
+ {turnDurationText ? ( + + + {turnDurationText} + + ) : null} + {footerTimestamp ? ( + {footerTimestamp} + ) : null} +
)} @@ -1277,12 +1295,17 @@ const AssistantMessageBody: React.FC> = ({
{footerButtons}
- {turnDurationText ? ( - - - {turnDurationText} - - ) : null} +
+ {turnDurationText ? ( + + + {turnDurationText} + + ) : null} + {footerTimestamp ? ( + {footerTimestamp} + ) : null} +
)} diff --git a/packages/ui/src/components/chat/message/parts/AssistantTextPart.tsx b/packages/ui/src/components/chat/message/parts/AssistantTextPart.tsx index 5eb7bfbb..d589007a 100644 --- a/packages/ui/src/components/chat/message/parts/AssistantTextPart.tsx +++ b/packages/ui/src/components/chat/message/parts/AssistantTextPart.tsx @@ -61,6 +61,7 @@ const AssistantTextPart: React.FC = ({ variant="justification" onContentChange={onContentChange} blockId={part.id || `${messageId}-reasoning-text`} + time={time} /> ); } diff --git a/packages/ui/src/components/chat/message/parts/JustificationBlock.tsx b/packages/ui/src/components/chat/message/parts/JustificationBlock.tsx index eb5a52f6..9d340514 100644 --- a/packages/ui/src/components/chat/message/parts/JustificationBlock.tsx +++ b/packages/ui/src/components/chat/message/parts/JustificationBlock.tsx @@ -3,7 +3,7 @@ import type { Part } from '@opencode-ai/sdk/v2'; import type { ContentChangeReason } from '@/hooks/useChatScrollManager'; import { ReasoningTimelineBlock } from './ReasoningPart'; -type PartWithText = Part & { text?: string; content?: string }; +type PartWithText = Part & { text?: string; content?: string; time?: { start?: number; end?: number } }; const cleanJustificationText = (text: string): string => { if (typeof text !== 'string' || text.trim().length === 0) { @@ -32,6 +32,7 @@ const JustificationBlock: React.FC = ({ const partWithText = part as PartWithText; const rawText = partWithText.text || partWithText.content || ''; const textContent = React.useMemo(() => cleanJustificationText(rawText), [rawText]); + const time = partWithText.time; // Don't render if there's no text content if (!textContent || textContent.trim().length === 0) { @@ -44,6 +45,7 @@ const JustificationBlock: React.FC = ({ variant="justification" onContentChange={onContentChange} blockId={part.id || `${messageId}-justification`} + time={time} /> ); }; diff --git a/packages/ui/src/components/chat/message/parts/ReasoningPart.tsx b/packages/ui/src/components/chat/message/parts/ReasoningPart.tsx index 71c02482..84684d63 100644 --- a/packages/ui/src/components/chat/message/parts/ReasoningPart.tsx +++ b/packages/ui/src/components/chat/message/parts/ReasoningPart.tsx @@ -3,10 +3,11 @@ import type { ComponentType } from 'react'; import type { Part } from '@opencode-ai/sdk/v2'; import { RiArrowDownSLine, RiArrowRightSLine, RiBrainAi3Line, RiChatAi3Line } from '@remixicon/react'; import { cn } from '@/lib/utils'; +import { formatTimestampForDisplay } from '../timeFormat'; import type { ContentChangeReason } from '@/hooks/useChatScrollManager'; import { ScrollableOverlay } from '@/components/ui/ScrollableOverlay'; -type PartWithText = Part & { text?: string; content?: string }; +type PartWithText = Part & { text?: string; content?: string; time?: { start?: number; end?: number } }; export type ReasoningVariant = 'thinking' | 'justification'; @@ -56,11 +57,37 @@ const getReasoningSummary = (text: string): string => { return trimmed.substring(0, cutoff).trim(); }; +const formatDuration = (start: number, end?: number, now: number = Date.now()): string => { + const duration = end ? end - start : now - start; + const seconds = duration / 1000; + const displaySeconds = seconds < 0.05 && end !== undefined ? 0.1 : seconds; + return `${displaySeconds.toFixed(1)}s`; +}; + +const LiveDuration: React.FC<{ start: number; end?: number; active: boolean }> = ({ start, end, active }) => { + const [now, setNow] = React.useState(() => Date.now()); + + React.useEffect(() => { + if (!active) { + return; + } + + const timer = window.setInterval(() => { + setNow(Date.now()); + }, 100); + + return () => window.clearInterval(timer); + }, [active]); + + return <>{formatDuration(start, end, now)}; +}; + type ReasoningTimelineBlockProps = { text: string; variant: ReasoningVariant; onContentChange?: (reason?: ContentChangeReason) => void; blockId: string; + time?: { start?: number; end?: number }; }; export const ReasoningTimelineBlock: React.FC = ({ @@ -68,11 +95,22 @@ export const ReasoningTimelineBlock: React.FC = ({ variant, onContentChange, blockId, + time, }) => { const [isExpanded, setIsExpanded] = React.useState(false); const summary = React.useMemo(() => getReasoningSummary(text), [text]); const { label, Icon } = variantConfig[variant]; + const timeStart = typeof time?.start === 'number' && Number.isFinite(time.start) ? time.start : undefined; + const timeEnd = typeof time?.end === 'number' && Number.isFinite(time.end) ? time.end : undefined; + const endedTimestampText = React.useMemo(() => { + if (typeof timeEnd !== 'number') { + return null; + } + + const formatted = formatTimestampForDisplay(timeEnd); + return formatted.length > 0 ? formatted : null; + }, [timeEnd]); React.useEffect(() => { if (text.trim().length === 0) { @@ -117,11 +155,30 @@ export const ReasoningTimelineBlock: React.FC = ({ {label} - {summary && ( -
- {summary} + {(summary || typeof timeStart === 'number' || endedTimestampText) ? ( +
+ {summary ? {summary} : null} + {typeof timeStart === 'number' ? ( + + + + ) : null} + {endedTimestampText ? ( + + {endedTimestampText} + + ) : null}
- )} + ) : null}
{isExpanded && ( @@ -159,6 +216,7 @@ const ReasoningPart: React.FC = ({ const partWithText = part as PartWithText; const rawText = partWithText.text || partWithText.content || ''; const textContent = React.useMemo(() => cleanReasoningText(rawText), [rawText]); + const time = partWithText.time; // Show reasoning even if time.end isn't set yet (during streaming) // Only hide if there's no text content @@ -172,6 +230,7 @@ const ReasoningPart: React.FC = ({ variant="thinking" onContentChange={onContentChange} blockId={part.id || `${messageId}-reasoning`} + time={time} /> ); }; diff --git a/packages/ui/src/components/chat/message/parts/ToolPart.tsx b/packages/ui/src/components/chat/message/parts/ToolPart.tsx index 0b006639..84507850 100644 --- a/packages/ui/src/components/chat/message/parts/ToolPart.tsx +++ b/packages/ui/src/components/chat/message/parts/ToolPart.tsx @@ -4,6 +4,7 @@ import { RuntimeAPIContext } from '@/contexts/runtimeAPIContext'; import { RiAiAgentLine, RiArrowDownSLine, RiArrowRightSLine, RiBookLine, RiExternalLinkLine, RiFileEditLine, RiFileList2Line, RiFileSearchLine, RiFileTextLine, RiFolder6Line, RiGitBranchLine, RiGlobalLine, RiListCheck2, RiListCheck3, RiMenuSearchLine, RiPencilLine, RiSurveyLine, RiTaskLine, RiTerminalBoxLine, RiToolsLine } from '@remixicon/react'; import { File as PierreFile, PatchDiff } from '@pierre/diffs/react'; import { cn } from '@/lib/utils'; +import { formatTimestampForDisplay } from '../timeFormat'; import { SimpleMarkdownRenderer } from '../../MarkdownRenderer'; import { getToolMetadata, getLanguageFromExtension, isImageFile, getImageMimeType } from '@/lib/toolHelpers'; import type { ToolPart as ToolPartType, ToolState as ToolStateUnion } from '@opencode-ai/sdk/v2'; @@ -1419,6 +1420,15 @@ const ToolPart: React.FC = ({ const effectiveTimeStart = isTaskTool ? (pinnedTaskTime.start ?? time?.start) : time?.start; const effectiveTimeEnd = isTaskTool ? (pinnedTaskTime.end ?? time?.end) : time?.end; + const endedTimestampText = React.useMemo(() => { + if (typeof effectiveTimeEnd !== 'number' || !Number.isFinite(effectiveTimeEnd)) { + return null; + } + + const formatted = formatTimestampForDisplay(effectiveTimeEnd); + return formatted.length > 0 ? formatted : null; + }, [effectiveTimeEnd]); + const taskOutputString = React.useMemo(() => { return typeof stateWithData.output === 'string' ? stateWithData.output : undefined; }, [stateWithData.output]); @@ -1667,7 +1677,7 @@ const ToolPart: React.FC = ({ )} {typeof effectiveTimeStart === 'number' && ( - + = ({ /> )} + {endedTimestampText ? ( + + {endedTimestampText} + + ) : null} diff --git a/packages/ui/src/components/chat/message/timeFormat.ts b/packages/ui/src/components/chat/message/timeFormat.ts new file mode 100644 index 00000000..f4dbf496 --- /dev/null +++ b/packages/ui/src/components/chat/message/timeFormat.ts @@ -0,0 +1,34 @@ +const pad2 = (value: number): string => String(value).padStart(2, '0'); + +const isSameDay = (left: Date, right: Date): boolean => { + return ( + left.getFullYear() === right.getFullYear() && + left.getMonth() === right.getMonth() && + left.getDate() === right.getDate() + ); +}; + +const isValidTimestamp = (timestamp: number): boolean => { + return Number.isFinite(timestamp) && !Number.isNaN(new Date(timestamp).getTime()); +}; + +export const formatTimestampForDisplay = (timestamp: number): string => { + if (!isValidTimestamp(timestamp)) { + return ''; + } + + const date = new Date(timestamp); + const now = new Date(); + + const timePart = `${pad2(date.getHours())}:${pad2(date.getMinutes())}:${pad2(date.getSeconds())}`; + + if (isSameDay(date, now)) { + return timePart; + } + + const yearPart = String(date.getFullYear()).slice(-2); + const monthPart = pad2(date.getMonth() + 1); + const dayPart = pad2(date.getDate()); + + return `${yearPart}-${monthPart}-${dayPart} ${timePart}`; +};