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
This commit is contained in:
Nelson Pires
2026-02-28 20:16:31 +02:00
committed by GitHub
parent a1f01cf5bd
commit be413771dc
7 changed files with 164 additions and 19 deletions
@@ -389,6 +389,11 @@ const ChatMessage: React.FC<ChatMessageProps> = ({
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<ChatMessageProps> = ({
isMessageCompleted={isMessageCompleted}
messageFinish={messageFinish}
messageCompletedAt={messageCompletedAt ?? undefined}
messageCreatedAt={messageCreatedAt ?? undefined}
syntaxTheme={syntaxTheme}
isMobile={isMobile}
hasTouchInput={hasTouchInput}
@@ -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<Omit<MessageBodyProps, 'isUser'>> = ({
isMessageCompleted,
messageFinish,
messageCompletedAt,
messageCreatedAt,
syntaxTheme,
isMobile,
@@ -1123,6 +1126,16 @@ const AssistantMessageBody: React.FC<Omit<MessageBodyProps, 'isUser'>> = ({
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<Omit<MessageBodyProps, 'isUser'>> = ({
<div className="flex items-center gap-1.5">
{footerButtons}
</div>
{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>
) : null}
<div className="flex items-center gap-1.5">
{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>
) : null}
{footerTimestamp ? (
<span className="text-sm text-muted-foreground/60 tabular-nums">{footerTimestamp}</span>
) : null}
</div>
</div>
)}
</div>
@@ -1277,12 +1295,17 @@ const AssistantMessageBody: React.FC<Omit<MessageBodyProps, 'isUser'>> = ({
<div className="flex items-center gap-1.5">
{footerButtons}
</div>
{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>
) : null}
<div className="flex items-center gap-1.5">
{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>
) : null}
{footerTimestamp ? (
<span className="text-sm text-muted-foreground/60 tabular-nums">{footerTimestamp}</span>
) : null}
</div>
</div>
)}
@@ -61,6 +61,7 @@ const AssistantTextPart: React.FC<AssistantTextPartProps> = ({
variant="justification"
onContentChange={onContentChange}
blockId={part.id || `${messageId}-reasoning-text`}
time={time}
/>
);
}
@@ -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<JustificationBlockProps> = ({
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<JustificationBlockProps> = ({
variant="justification"
onContentChange={onContentChange}
blockId={part.id || `${messageId}-justification`}
time={time}
/>
);
};
@@ -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<ReasoningTimelineBlockProps> = ({
@@ -68,11 +95,22 @@ export const ReasoningTimelineBlock: React.FC<ReasoningTimelineBlockProps> = ({
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<ReasoningTimelineBlockProps> = ({
<span className="typography-meta font-medium">{label}</span>
</div>
{summary && (
<div className="flex-1 min-w-0 typography-meta text-muted-foreground/70">
<span className="truncate block italic">{summary}</span>
{(summary || typeof timeStart === 'number' || endedTimestampText) ? (
<div className="flex items-center gap-1 flex-1 min-w-0 typography-meta text-muted-foreground/70">
{summary ? <span className="truncate italic">{summary}</span> : null}
{typeof timeStart === 'number' ? (
<span className="text-muted-foreground/80 flex-shrink-0 tabular-nums">
<LiveDuration
start={timeStart}
end={timeEnd}
active={typeof timeEnd !== 'number'}
/>
</span>
) : null}
{endedTimestampText ? (
<span
className={cn(
'text-muted-foreground/70 flex-shrink-0 tabular-nums transition-opacity duration-150',
'opacity-0 group-hover/tool:opacity-100'
)}
>
{endedTimestampText}
</span>
) : null}
</div>
)}
) : null}
</div>
{isExpanded && (
@@ -159,6 +216,7 @@ const ReasoningPart: React.FC<ReasoningPartProps> = ({
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<ReasoningPartProps> = ({
variant="thinking"
onContentChange={onContentChange}
blockId={part.id || `${messageId}-reasoning`}
time={time}
/>
);
};
@@ -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<ToolPartProps> = ({
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<ToolPartProps> = ({
</span>
)}
{typeof effectiveTimeStart === 'number' && (
<span className="text-muted-foreground/80 flex-shrink-0">
<span className="text-muted-foreground/80 flex-shrink-0 tabular-nums">
<LiveDuration
start={effectiveTimeStart}
end={typeof effectiveTimeEnd === 'number' ? effectiveTimeEnd : undefined}
@@ -1675,6 +1685,16 @@ const ToolPart: React.FC<ToolPartProps> = ({
/>
</span>
)}
{endedTimestampText ? (
<span
className={cn(
'text-muted-foreground/70 flex-shrink-0 tabular-nums transition-opacity duration-150',
'opacity-0 group-hover/tool:opacity-100'
)}
>
{endedTimestampText}
</span>
) : null}
</div>
</div>
@@ -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}`;
};