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:
committed by
brooklyn!
parent
69fdbb7cdc
commit
b3059921bc
@@ -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={() => {
|
||||
|
||||
@@ -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>
|
||||
)
|
||||
}
|
||||
@@ -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:
|
||||
|
||||
@@ -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' })
|
||||
|
||||
|
||||
Reference in New Issue
Block a user