feat(desktop): show when a message was sent on hover

Hovering a message reveals its time with the hover actions ("3:27 PM",
"Yesterday, 3:27 PM", "Sep 28, 3:27 PM"); the tip adds the age for same-day
messages and the full date. User bubbles show it beside Restore/Stop, replies
at the head of the action bar. It stands down while display.timestamps is on,
since the transcript already prints every stamp (#118405).
This commit is contained in:
Brooklyn Nicholson
2026-10-02 10:45:01 -05:00
committed by brooklyn!
parent 69fdbb7cdc
commit b3059921bc
4 changed files with 109 additions and 33 deletions

View File

@@ -23,6 +23,7 @@ import {
messageContentText,
pickPrimaryPreviewTarget
} from '@/components/assistant-ui/thread/content'
import { MessageHoverTime } from '@/components/assistant-ui/thread/message-hover-time'
import { MESSAGE_PARTS_COMPONENTS } from '@/components/assistant-ui/thread/message-parts'
import { ReactionPicker } from '@/components/assistant-ui/thread/message-reactions'
import { ResponseMessageIds } from '@/components/assistant-ui/thread/response-group'
@@ -1043,6 +1044,7 @@ const AssistantActionBar: FC<MessageActionProps & { durationS?: number }> = ({
}
data-slot="aui_msg-actions"
>
<MessageHoverTime className="mr-1 px-0.5" />
{onBranchInNewChat && (
<TooltipIconButton
onClick={() => {

View File

@@ -0,0 +1,69 @@
import { useAuiState } from '@assistant-ui/react'
import { useStore } from '@nanostores/react'
import type { FC } from 'react'
import { Tip } from '@/components/ui/tooltip'
import { useI18n } from '@/i18n'
import { DAY, fmtFullDateTime, relativeTime } from '@/lib/time'
import { cn } from '@/lib/utils'
import { $displayTimestamps } from '@/store/display-timestamps'
import { formatMessageTimestamp } from './timestamp'
/** When the message landed, in seconds: a reply counts from its completion.
* `createdAt` already carries the row's own timestamp (see
* `messageCreatedAt`), falling back to now only for a just-sent message. */
const useMessageTime = (): number | undefined =>
useAuiState(s => {
const completedAt = (s.message.metadata?.custom as { timelineCompletedAt?: unknown } | undefined)
?.timelineCompletedAt
const at = typeof completedAt === 'number' ? completedAt : (s.message.createdAt?.getTime() ?? NaN) / 1000
return Number.isFinite(at) && at > 0 ? at : undefined
})
/** Rendered only while the tip is open, so "5 min ago" is as of the hover.
* The age is only worth saying within the day; past that the label already
* names the day, and an hour-rounded "2 days ago" would contradict a
* calendar "Yesterday". */
const HoverTimeDetail: FC<{ ms: number }> = ({ ms }) => {
const full = fmtFullDateTime.format(new Date(ms))
return <span className="tabular-nums">{Date.now() - ms < DAY ? `${relativeTime(ms)} · ${full}` : full}</span>
}
const clockOnly = (time: string) => time
/**
* The time a message landed, revealed with the message's hover actions
* ("3:27 PM"; "Yesterday, 3:27 PM"; "Sep 28, 3:27 PM"), with the age and full
* date in its tip. Visibility belongs to the host's hover group: this paints
* the label only. Stands down while `display.timestamps` is on, because the
* transcript already prints every stamp.
*/
export const MessageHoverTime: FC<{ className?: string }> = ({ className }) => {
const { t } = useI18n()
const timelineStamps = useStore($displayTimestamps)
const seconds = useMessageTime()
if (timelineStamps || seconds === undefined) {
return null
}
const date = new Date(seconds * 1000)
return (
<Tip label={<HoverTimeDetail ms={date.getTime()} />} side="top">
<time
className={cn(
'pointer-events-auto cursor-default select-none whitespace-nowrap text-[0.6875rem] leading-5 tabular-nums text-(--ui-text-tertiary)',
className
)}
dateTime={date.toISOString()}
>
{formatMessageTimestamp(date, { today: clockOnly, yesterday: t.assistant.thread.yesterday })}
</time>
</Tip>
)
}

View File

@@ -8,6 +8,7 @@ import {
messageContentText,
PROCESS_NOTIFICATION_RE
} from '@/components/assistant-ui/thread/content'
import { MessageHoverTime } from '@/components/assistant-ui/thread/message-hover-time'
import { ReactionBadge, ReactionPicker } from '@/components/assistant-ui/thread/message-reactions'
import { BackgroundResult } from '@/components/assistant-ui/thread/system-message'
import { threadUserOrdinal } from '@/components/assistant-ui/thread/thread-message-index'
@@ -502,47 +503,48 @@ export const UserMessage: FC<{
</button>
</ActionBarPrimitive.Edit>
)}
{(showStop || showRestore) && (
<div className="pointer-events-none absolute right-2 bottom-2 z-10 flex items-center justify-center opacity-0 transition-opacity group-hover/user-message:opacity-100 group-focus-within/user-message:opacity-100">
{showStop ? (
{/* Hover cluster, bottom-right: when it was sent, then Stop or
Restore. Its fill masks the last line's tail while shown. */}
<div className="pointer-events-none absolute right-2 bottom-2 z-10 flex items-center gap-1 rounded-md bg-(--dt-user-bubble) pl-1 opacity-0 transition-opacity group-hover/user-message:opacity-100 group-hover/user-message:transition-none group-focus-within/user-message:opacity-100">
<MessageHoverTime className={cn(!showStop && !showRestore && 'pr-0.5')} />
{showStop ? (
<button
aria-label={copy.stop}
className={cn('pointer-events-auto size-5', USER_ACTION_ICON_BUTTON_CLASS)}
onClick={event => {
event.preventDefault()
event.stopPropagation()
void onCancel?.()
}}
type="button"
>
{StopGlyph}
</button>
) : showRestore ? (
<Tip label={copy.restoreFromHere}>
<button
aria-label={copy.stop}
className={cn('pointer-events-auto size-5', USER_ACTION_ICON_BUTTON_CLASS)}
aria-label={copy.restoreCheckpoint}
className={cn('pointer-events-auto size-6', USER_ACTION_ICON_BUTTON_CLASS)}
onClick={event => {
event.preventDefault()
event.stopPropagation()
void onCancel?.()
triggerHaptic('selection')
onRequestRestoreConfirm?.(messageId, {
text: messageText,
userOrdinal: runtimeUserOrdinal
})
}}
onPointerDown={event => {
event.preventDefault()
event.stopPropagation()
}}
type="button"
>
{StopGlyph}
<Codicon name="discard" size="0.875rem" />
</button>
) : (
<Tip label={copy.restoreFromHere}>
<button
aria-label={copy.restoreCheckpoint}
className={cn('pointer-events-auto size-6', USER_ACTION_ICON_BUTTON_CLASS)}
onClick={event => {
event.preventDefault()
event.stopPropagation()
triggerHaptic('selection')
onRequestRestoreConfirm?.(messageId, {
text: messageText,
userOrdinal: runtimeUserOrdinal
})
}}
onPointerDown={event => {
event.preventDefault()
event.stopPropagation()
}}
type="button"
>
<Codicon name="discard" size="0.875rem" />
</button>
</Tip>
)}
</div>
)}
</Tip>
) : null}
</div>
</div>
</ReactionPicker>
{/* Below the bubble, same register as the assistant action row:

View File

@@ -22,6 +22,9 @@ export const fmtDayTime = new Intl.DateTimeFormat(undefined, {
// Medium date + short time (command center session detail).
export const fmtDateTime = new Intl.DateTimeFormat(undefined, { dateStyle: 'medium', timeStyle: 'short' })
// Weekday + full date + clock (message time tooltips).
export const fmtFullDateTime = new Intl.DateTimeFormat(undefined, { dateStyle: 'full', timeStyle: 'short' })
// Date only, "5 Jun 2026" (starmap tooltip).
export const fmtDate = new Intl.DateTimeFormat(undefined, { day: 'numeric', month: 'short', year: 'numeric' })