Chat Transcript

The small parts every transcript repeats: the day a run of messages belongs to, the clock beside a turn, a human's message with its links made clickable, pictures that open in the lightbox, files that can only be named, and a turn that never reached the server. Assemble them inside message-scroller, message and bubble.

Day divider and timestamp

`startsNewDay(previous, current)` says when to draw the divider, `dayLabelOf` says what it reads: Today, Yesterday, or the date. `ChatTimestamp` is the clock beside a turn, and it never fades: an action you go looking for can hide until hover, context you read cannot.

Dark previewReplay previewOpen in new tab
The export finished but the file came out empty.

11:25 AM

Looking into it now.

12:25 PM

Working again, thank you.

12:25 PM

Glad to hear it. Anything else?

12:24 PM

Usage

Import the parts you need from the component's folder.

ts
import {
  ChatDayDivider,
  ChatTimestamp,
  ChatText,
  ChatPictures,
  ChatFileChips,
  ChatFailedTurn,
  ChatUnreadDot,
  dayLabelOf,
  startsNewDay,
  midnightOf,
  formatMessageTime,
  relativeTimeOf,
  linkSegments,
} from "@/components/ui/chat";

Text with links

A human's message is text, not markdown. `ChatText` splits it into segments and builds an element for each URL, so whatever someone typed stays text and nothing they wrote is parsed as markup. Trailing punctuation stays with the sentence.

Dark previewReplay previewOpen in new tab
Please check https://example.com/docs/exports, then reply "ok". Markup like <b>this</b> stays text.

Pictures and file chips

Pictures are shown, not named. One picture is an `Attachment` card sized to the photo, several go through the lightbox's own grid, which shows the first four and says +2 for the rest. Either way the lightbox is what opens.

Anything that cannot be drawn, a document or an image whose bytes are gone, goes through `ChatFileChips`: an icon picked from the extension, the name, and the type and size underneath.

Dark previewReplay previewOpen in new tab
cover-art.png
Here is the artwork.
And the rest of the set.
proposal.pdfPDF - 2.3 MB
expired-screenshot.pngPNG
The proposal is attached.

Download affordance

Give an item a `downloadUrl` and it gets a download button. A picture and a named file are the same `Attachment` in two orientations, so one structure serves both: `AttachmentActions` sits inline after the title on `orientation="horizontal"`, and becomes an absolute overlay at top-3 right-3 on `orientation="vertical"`. Neither component writes a button of its own.

On the picture, `AttachmentTrigger` covers the card to open the lightbox and sits under the actions, so the download stays separately clickable. A grid of several pictures gets no per-tile button: the lightbox already carries one.

Dark previewReplay previewOpen in new tab
cover-art.png
proposal.pdfPDF - 2.3 MB
Grab a copy of either one.

Failed turn

A turn that never reached the server keeps the reader's own words where they wrote them, says what went wrong, and offers Retry and Discard. The host owns the message and decides what retrying means.

Dark previewReplay previewOpen in new tab
Any update on the export?
Message failed to send.

Unread dot

For a launcher whose panel is shut while something arrived. A dot rather than a count: on a small round button a number needs a size the style guide does not allow for a value, and the next action is the same either way. It positions itself against the nearest positioned ancestor, so give the launcher `relative`.

Dark previewReplay previewOpen in new tab
Click to toggle the mark.

Anatomy

Import all parts and piece them together.

vue
<script setup>
import {
  ChatDayDivider,
  ChatFailedTurn,
  ChatFileChips,
  ChatPictures,
  ChatText,
  ChatTimestamp,
  ChatUnreadDot,
} from "@/components/ui/chat";
</script>

<template>
  <ChatDayDivider />
  <ChatPictures />
  <ChatFileChips />
  <ChatText />
  <ChatTimestamp />
  <ChatFailedTurn />
  <ChatUnreadDot />
</template>

API Reference

ChatDayDivider

PropTypeDefaultDescription
labelstring—Usually dayLabelOf(iso).
classstring—Extra classes, merged with cn().

ChatTimestamp

PropTypeDefaultDescription
atstring—ISO timestamp; renders formatMessageTime(at).
classstring—Extra classes, merged with cn().

ChatText

PropTypeDefaultDescription
textstring—Plain text. URLs become links, everything else stays text.

ChatPictures

PropTypeDefaultDescription
items{ url: string; name?: string | null; downloadUrl?: string | null }[]—Images with a URL. One renders as a card, several as a lightbox grid capped at four. Renders nothing for an empty list.
classstring—Extra classes, merged with cn().

ChatFileChips

PropTypeDefaultDescription
items{ name?: string | null; kind: "image" | "document"; mime?: string | null; size?: number | null; downloadUrl?: string | null }[]—Files to name rather than draw. The icon comes from the extension, and size becomes the PDF - 2.4 MB line under the name.
classstring—Extra classes, merged with cn().

ChatFailedTurn

PropTypeDefaultDescription
errorstring—What went wrong, in the reader's words.
disabledbooleanfalseWhile another turn is in flight.
classstring—Extra classes, merged with cn().
EventDescription
retrySend the same thing again. The host still owns the message.
discardGive up on the turn and drop it from the transcript.

ChatUnreadDot

PropTypeDefaultDescription
classstring—No props of its own. Attributes fall through to the root span, so a class merges with the placement it already carries.

Helpers

PropTypeDefaultDescription
formatMessageTime(iso)string—"10:02", or "" when the date does not parse.
midnightOf(iso)number | null—The local midnight a timestamp belongs to, in epoch milliseconds. null when the date does not parse.
dayLabelOf(iso)string—"Today", "Yesterday", or the date.
startsNewDay(previousIso, currentIso)boolean—True for the first message and whenever the calendar date changes.
relativeTimeOf(iso)string—"2 hours ago", for a conversation list row. Computed at render rather than on a timer, so a long list does not run one interval per row.
linkSegments(text)TextSegment[]—The split ChatText renders.
TextSegment{ text: string; href?: string }—One piece of a split message. href is set only on the pieces that are URLs.
ChatAlertOptions{ soundSrc?: string; storageKey?: string; vibration?: number[] }—Options for useChatAlerts, which the same index exports. Documented on the Chat Composer page.

Accessibility

Keyboard shortcuts and ARIA behavior.

Shortcut Description
TabMoves through the links in a message, then to Retry and Discard.
EnterFollows the focused link, or runs Retry or Discard.
  • ChatDayDivider is a role=separator carrying the day as its aria-label, so the date is announced as a boundary rather than read as a stray line of text.
  • ChatText builds an element per URL instead of setting innerHTML, so nothing anyone types is parsed as markup. Links open in a new tab with rel=noopener noreferrer.
  • ChatTimestamp stays visible rather than appearing on hover, so the clock is readable without a pointer.
  • ChatPictures labels each image with its file name, falling back to 'Attached image' when a message did not carry one.
  • ChatUnreadDot is aria-hidden and pointer-events-none. It marks a launcher that already has its own label, so announce the unread state on that button.