diff --git a/frontend-web/src/components/workspace/messages/message-list-item.tsx b/frontend-web/src/components/workspace/messages/message-list-item.tsx index 44721c07..6ddf0f4a 100644 --- a/frontend-web/src/components/workspace/messages/message-list-item.tsx +++ b/frontend-web/src/components/workspace/messages/message-list-item.tsx @@ -138,6 +138,7 @@ export function MessageListItem({ extraRehypePlugins, onEditResubmit, onMessageDeleted, + turnTime, }: { className?: string; fitRowToContent?: boolean; @@ -154,6 +155,13 @@ export function MessageListItem({ extraRehypePlugins?: ReturnType; onEditResubmit?: (text: string) => void; onMessageDeleted?: (deletedIds: string[]) => void; + /** + * Durable ask time of the turn this message belongs to (the preceding + * question's `client_ts`). Used to time-stamp the AI answer, which carries no + * timestamp of its own — without it the answer would fall back to the current + * wall clock at render time and appear to "drift" to now on every reload. + */ + turnTime?: string; }) { const isHuman = message.type === 'human'; const messageMarkdown = @@ -182,6 +190,7 @@ export function MessageListItem({ extraRehypePlugins={extraRehypePlugins} onEditResubmit={onEditResubmit} onMessageDeleted={onMessageDeleted} + turnTime={turnTime} /> {/* Human messages carry their own inline copy/edit toolbar inside @@ -390,6 +399,7 @@ function MessageContent_({ extraRehypePlugins, onEditResubmit, onMessageDeleted, + turnTime, }: { className?: string; message: Message; @@ -400,6 +410,7 @@ function MessageContent_({ extraRehypePlugins?: ReturnType; onEditResubmit?: (text: string) => void; onMessageDeleted?: (deletedIds: string[]) => void; + turnTime?: string; }) { const baseRehypePlugins = useRehypeSplitWordsIntoSpans(isLoading); @@ -456,9 +467,15 @@ function MessageContent_({ [threadId, resolveArtifact], ); + // Question carries its own `client_ts` (round-trips through the checkpoint). + // The AI answer has no timestamp of its own, so it inherits the turn's ask + // time (its question's `client_ts`, threaded down as `turnTime`). This is + // durable and turn-accurate, and — unlike the old per-id `now` fallback — + // never drifts to the current clock when an unfinished answer is reloaded. + const ownAskTime = message.id ? askTimes[message.id] : undefined; const messageTime = formatAskedTime( message.additional_kwargs?.client_ts ?? - (message.id ? askTimes[message.id] : undefined), + (isHuman ? ownAskTime : (turnTime ?? ownAskTime)), ); const rawContent = extractContentFromMessage(message); diff --git a/frontend-web/src/components/workspace/messages/message-list.tsx b/frontend-web/src/components/workspace/messages/message-list.tsx index 95c5668c..aa833058 100644 --- a/frontend-web/src/components/workspace/messages/message-list.tsx +++ b/frontend-web/src/components/workspace/messages/message-list.tsx @@ -11,7 +11,7 @@ import { } from "@/components/ai-elements/conversation"; import { Button } from "@/components/ui/button"; import { Textarea } from "@/components/ui/textarea"; -import { rememberAskTimes } from "@/core/chat-timestamps"; +import { rememberAskTimes, useChatAskTimes } from "@/core/chat-timestamps"; import { useI18n } from "@/core/i18n/hooks"; import { extractContentFromMessage, @@ -167,15 +167,24 @@ export function MessageList({ [allMessages, hiddenMessageIds], ); - // Stamp a wall-clock time onto every question/answer the first time it is - // seen, in a single batched commit. Questions sent this session already carry - // an accurate time (see useThreadStream); this also covers assistant answers - // and messages loaded from history. First-write-wins keeps the earliest time. + const askTimes = useChatAskTimes(); + + // Stamp a wall-clock time onto every *question* the first time it is seen, in + // a single batched commit. Questions sent this session already carry an + // accurate `client_ts` (see useThreadStream) and are skipped; this only backs + // up legacy questions loaded from history that predate `client_ts`. + // First-write-wins keeps the earliest time. + // + // AI answers are deliberately NOT stamped here: they carry no timestamp of + // their own, so a `now` stamp would be the *load* time, not the answer time — + // and for an unfinished/interrupted turn that re-stamps to the current clock + // on every reload. Instead the answer inherits its question's durable time + // via `turnTimes` below. useEffect(() => { const now = new Date().toISOString(); const entries: Record = {}; for (const message of messages) { - if (message.type !== "human" && message.type !== "ai") continue; + if (message.type !== "human") continue; if (!message.id || message.id.startsWith("opt-")) continue; if (typeof message.additional_kwargs?.client_ts === "string") continue; entries[message.id] = now; @@ -183,6 +192,24 @@ export function MessageList({ if (Object.keys(entries).length > 0) rememberAskTimes(entries); }, [messages]); + // Durable ask time per message id, used to time-stamp the AI answer (which has + // no timestamp of its own) with the time of the question that triggered it. + const turnTimes = useMemo(() => { + const map = new Map(); + let lastQuestionTime: string | undefined; + for (const message of messages) { + if (message.type === "human") { + const ts = message.additional_kwargs?.client_ts; + const fromAskTimes = message.id ? askTimes[message.id] : undefined; + lastQuestionTime = + typeof ts === "string" && ts ? ts : fromAskTimes; + } else if (message.type === "ai" && message.id && lastQuestionTime) { + map.set(message.id, lastQuestionTime); + } + } + return map; + }, [messages, askTimes]); + const [settings] = useLocalSettings(); const ragModeEnabled = settings.display.ragModeEnabled || isThreadRagModeLocked(threadId); @@ -358,6 +385,7 @@ export function MessageList({ onMessageDeleted={ allowDelete ? handleMessageDeleted : undefined } + turnTime={msg.id ? turnTimes.get(msg.id) : undefined} /> ); });