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:
@@ -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}`;
|
||||
};
|
||||
Reference in New Issue
Block a user