import { InlineAudioPreview } from "../files/InlineAudioPreview"; import { filePreviewKind } from "@t3tools/shared/filePreview"; import { findRevealRef } from "./markdownFindContext"; import { type EnvironmentId, type MessageId, type ScheduledTaskId, type ServerProviderSkill, ThreadId, } from "@t3tools/contracts"; import { resolveUserMessagePresentation } from "@t3tools/client-runtime/user-message"; import { Link } from "@tanstack/react-router"; import { useQuietThreadShell } from "../../state/entities"; import { useEnvironmentQuery } from "../../state/query"; import { serverEnvironment } from "../../state/server"; import { replaceComposerContextReferences } from "@t3tools/shared/composerContextReferences"; import { memo, use, useState, type ReactNode } from "react"; import { createMessageAttachmentPreviewProjector, selectMessageImageResources, } from "../../session-logic"; import { type ChatFileAttachment, isFileAttachment, isImageAttachment, isVideoAttachment, } from "../../types"; import ChatMarkdown from "../ChatMarkdown"; import { CalendarClockIcon, ChevronDownIcon, DownloadIcon, EyeIcon, type LucideIcon, MessagesSquareIcon, Redo2Icon, PencilIcon, } from "lucide-react"; import { Link as LinkIconNode } from "lucide"; import { Button, InlineButton } from "../ui/button"; import { ModelSwitchRow } from "./V2LifecycleRow"; import { useAssetUrlRefresh, useAssetUrls, useAssetUrlState } from "../../assets/assetUrls"; import { AttachmentImage } from "../media/AttachmentImage"; import { MediaVideoPlayer } from "../media/MediaVideoPlayer"; import { buildAttachmentVideoAsset, buildAttachmentVideoPreview, buildExpandedImagePreview, } from "./ExpandedImagePreview"; import { SNAP_SHOT_ATTACHMENT_FRAME_CLASS, SnapShotAttachmentDetails, } from "./SnapShotAttachmentDetails"; import { GuestMessageName, guestBubbleClassName } from "./guestMessageStyle"; import { MessageCopyButton } from "./MessageCopyButton"; import { promptLinkFor } from "../promptLinks/copyPromptLink"; import { PierreEntryIcon } from "./PierreEntryIcon"; import { formatBytes } from "../../lib/formatBytes"; import { shouldCollapseUserMessage } from "./MessagesTimeline.logic"; import { Tooltip, TooltipPopup, TooltipTrigger } from "../ui/tooltip"; import { asKnownContextRecord, resolveUserMessageContext, selectedMessageContextFragment, } from "~/lib/composerContextRecords"; import { collectComposerContextReferences, formatComposerContextReference, } from "@t3tools/shared/composerContextReferences"; import { COMPOSER_CONTEXT_CLIPBOARD_MIME, encodeComposerContextClipboardHtml, encodeComposerContextFragment, } from "@t3tools/shared/composerContextClipboard"; import { chatMarkdownClipboardPayload } from "../../markdown-clipboard"; import type { ChatMarkdownContextReference } from "../ChatMarkdown"; import { cn } from "~/lib/utils"; import { markdownProseDirection } from "~/textDirection"; import { useMediaQuery } from "~/hooks/useMediaQuery"; import { formatChatTimestampTooltip, formatDayAwareTimestamp } from "../../timestampFormat"; import { WorkflowOpeningBlock } from "./WorkflowOpeningBlock"; import { TimelineRowCtx, TimelineRowActivityCtx, type TimelineRow } from "./timelineRowContext"; import { MESSAGE_HEADING_LEVEL, MessageAuthorHeading, TimelineRowTimestamp, } from "./TimelineRowParts"; import { UserMessageContextReferenceChip } from "./UserMessageContextChips"; import { resolvePreviewAnnotationImage, userMessageFileLabel, } from "./TimelineUserMessageRow.logic"; // Drawn inside the media so dark screenshots keep an edge on the dark background. const MEDIA_EDGE_CLASS_NAME = "relative after:pointer-events-none after:absolute after:inset-0 after:z-10 after:rounded-[inherit] after:ring-1 after:ring-inset after:ring-black/9 dark:after:ring-white/12"; const BUBBLE_CHIP_CLASS_NAME = "[&_[data-slot=context-chip]]:[--context-chip-ink:var(--context-chip-message,var(--context-chip-accent))]"; function UserFileCard({ file, copyMarkdown, }: { readonly file: ChatFileAttachment; readonly copyMarkdown: string | undefined; }) { const ctx = use(TimelineRowCtx); const pastedText = file.source?._tag === "pasted-text"; const label = userMessageFileLabel(file); const extension = /\.([a-z0-9]{1,8})$/i.exec(file.name)?.[1]?.toUpperCase() ?? "File"; const identity = ( <> {label} {file.downloadable === false ? ( "Sending…" ) : ( <> {!pastedText && `${extension} · `} {formatBytes(file.sizeBytes)} )} ); return (
{file.downloadable === false ? ( <>
{identity}
) : ( <> ctx.onFileDownload(file)} /> } > Download {label} )}
); } function UserVideoAttachment({ file, standalone = false, cornerClassName, }: { readonly file: ChatFileAttachment; readonly standalone?: boolean; readonly cornerClassName?: string | undefined; }) { const ctx = use(TimelineRowCtx); // Row updates rebuild attachment objects, so the asset follows only the // fields it reads. const { downloadable, id, mimeType, name } = file; const environmentId = ctx.activeThreadEnvironmentId; const asset = downloadable === false ? null : buildAttachmentVideoAsset(environmentId, { id, mimeType, name }); const resource = asset?.resource ?? null; const assetUrl = useAssetUrlState(ctx.activeThreadEnvironmentId, resource); const refreshAssetUrl = useAssetUrlRefresh(ctx.activeThreadEnvironmentId, resource); const src = assetUrl._tag === "Success" ? assetUrl.url : (file.previewUrl ?? null); if (asset === null && src === null) { return (
{file.name}
); } const player = ( { const preview = buildAttachmentVideoPreview(ctx.activeThreadEnvironmentId, file); if (preview) ctx.onImageExpand(preview); }} className="block aspect-[4/3] w-full" videoClassName={cn( "aspect-auto size-full", !standalone && "rounded-lg border border-border/80", )} stateClassName={cn( "aspect-auto min-h-full bg-black text-white", !standalone && "rounded-lg border border-border/80", )} onRetry={asset ? refreshAssetUrl : undefined} actionsSource={asset ? { kind: "video", name: file.name, src, asset } : undefined} /> ); return standalone ? (
{player}
) : ( player ); } export function UserMessageRow({ row }: { row: Extract }) { const ctx = use(TimelineRowCtx); const item = row.projectedItem?.item; const modelSwitch = item?.type === "user_message" ? item.modelSwitch : undefined; return ( <> {modelSwitch ? ( ) : null} ); } function UserMessageContent({ row }: { row: Extract }) { const ctx = use(TimelineRowCtx); return ctx.workflowOpening?.messageId === row.message.id ? ( ctx.onOpenThread(ThreadId.make(threadId))} /> ) : resolveUserMessagePresentation(row.message).attribution === "automation" ? ( ) : ( ); } /** Automation prompts are long, repeated runbooks; a folded bubble keeps them out of the way. */ function AutomationMessageRow({ row }: { row: Extract }) { const ctx = use(TimelineRowCtx); const [expanded, setExpanded] = useState(false); const revealRef = findRevealRef(() => setExpanded(true)); const { text, scheduledTaskId } = resolveUserMessagePresentation(row.message); const summary = useScheduledTaskTitle(ctx.activeThreadEnvironmentId, scheduledTaskId) ?? text.trim().split(/\r?\n/, 1)[0] ?? ""; const revertTurnCount = row.revertTurnCount; const promptLink = promptLinkFor(text); // Find reads the DOM, so a folded prompt stays mounted but hidden while find is open. const bodyMounted = expanded || ctx.findActive; return (
{row.message.inputIntent === "steer" || row.message.inputIntent === "promoted_queued_to_steer" ? ( ) : null}
Automation setExpanded((value) => !value)} /> {bodyMounted ? ( // `beforematch` does not bubble, so the reveal listener sits on the hidden fold itself. ) : null}
{expanded ? (
{scheduledTaskId ? ( Open automation ) : null}
{typeof revertTurnCount === "number" && ( )} {promptLink !== null && ( )}
) : null}
); } export function useScheduledTaskTitle( environmentId: EnvironmentId, scheduledTaskId: ScheduledTaskId | undefined, ): string | undefined { const tasksQuery = useEnvironmentQuery( scheduledTaskId ? serverEnvironment.scheduledTasksLive({ environmentId, input: {} }) : null, ); return tasksQuery.data?.tasks.find((task) => task.id === scheduledTaskId)?.title; } export function FoldedMessageHeader(props: { icon: LucideIcon; label: string; summary: string; expanded: boolean; onToggle: () => void; }) { const Icon = props.icon; return ( ); } function UserMessageProjectedStatus({ row }: { row: Extract }) { const status = row.projectedItem?.item.status; if ( status === undefined || status === "completed" || status === "pending" || status === "waiting" ) return null; return (
{status}
); } function UserTimelineRow({ row }: { row: Extract }) { const ctx = use(TimelineRowCtx); const { onImageExpand, onFileOpen } = ctx; const coarsePointer = useMediaQuery({ pointer: "coarse" }); const senderThreadId = row.message.senderThreadId; const resources = selectMessageImageResources(row.message.attachments); const previewUrls = useAssetUrls(ctx.activeThreadEnvironmentId, resources); const [projectPreviews] = useState(createMessageAttachmentPreviewProjector); const previewUrlsById = new Map( resources.flatMap((resource, index) => { const url = previewUrls[index]; return url ? [[resource.attachmentId, url] as const] : []; }), ); const messageWithPreviews = projectPreviews(row.message, (attachment) => previewUrlsById.get(attachment.id), ); const userImages = (messageWithPreviews.attachments ?? []).filter(isImageAttachment); const userFiles = (row.message.attachments ?? []).filter(isFileAttachment); const userVideos = userFiles.filter(isVideoAttachment); const userAudio = userFiles.filter( (file) => file.downloadable !== false && filePreviewKind(file) === "audio", ); const otherUserFiles = userFiles.filter( (file) => !isVideoAttachment(file) && !userAudio.includes(file), ); const unknownAttachments = (row.message.attachments ?? []).filter( (attachment) => !isImageAttachment(attachment) && !isFileAttachment(attachment), ); const userMessage = resolveUserMessagePresentation(row.message); // Messages from other agents fold to one line so the user's own messages stay easy to find. const folded = userMessage.attribution === "agent"; const [expanded, setExpanded] = useState(false); const revealFoldRef = findRevealRef(() => setExpanded(true)); const senderShell = useQuietThreadShell( folded && senderThreadId ? { environmentId: ctx.activeThreadEnvironmentId, threadId: senderThreadId } : null, ); const senderLabel = senderShell?.title.trim() || "Agent"; const resolvedContext = resolveUserMessageContext({ ...row.message, text: userMessage.text }); const previewImages = userImages.filter((image) => image.name.startsWith("preview-annotation-")); const revertTurnCount = row.revertTurnCount; const attachmentReferences = collectComposerContextReferences(resolvedContext.text).flatMap( (occurrence) => { const record = asKnownContextRecord(resolvedContext.recordsById.get(occurrence.contextId)); return record?.kind === "file" || record?.kind === "image" ? [ { attachmentId: record.attachmentId, contextId: occurrence.contextId, source: occurrence.source, }, ] : []; }, ); const chippedAttachmentIds = new Set( attachmentReferences.map((reference) => reference.attachmentId), ); const regularImages = userImages.filter((image) => !image.name.startsWith("preview-annotation-")); const unchippedFiles = otherUserFiles.filter((file) => !chippedAttachmentIds.has(file.id)); // Images or videos sent without words show as media, not as a bubble around them. const mediaOnly = !folded && userMessage.attribution !== "guest" && resolvedContext.text.trim() === "" && userAudio.length === 0 && unchippedFiles.length === 0 && unknownAttachments.length === 0 && regularImages.length + userVideos.length > 0; // Media sent with words sits above the bubble as its own shape, like a chat app. const mediaAboveText = !folded && userMessage.attribution !== "guest" && !mediaOnly && regularImages.length + userVideos.length > 0; const standaloneMedia = mediaOnly || mediaAboveText; // A lone image keeps its own shape; a 4:3 crop hides most of a wide screenshot. const loneImage = standaloneMedia && userVideos.length === 0 && regularImages.length === 1; // Several images share one rounded shape, split by hairlines. const collage = standaloneMedia && userVideos.length === 0 && regularImages.length > 1 && regularImages.every((image) => image.source?.kind !== "snap-shot"); // Corners that touch the bubble or a neighbouring message tighten, like the bubble's. const mediaCornerClassName = cn( row.groupedWithPrevious && "rounded-tr-md", (mediaAboveText || row.groupedWithNext) && "rounded-br-md", ); // Files sent without words show as cards, not as a bubble around a name chip. const fileCardsOnly = !folded && userMessage.attribution !== "guest" && otherUserFiles.length > 0 && regularImages.length + userVideos.length + userAudio.length + unknownAttachments.length === 0 && replaceComposerContextReferences(resolvedContext.text, (reference) => attachmentReferences.some( (attached) => attached.contextId === reference.contextId && otherUserFiles.some((file) => file.id === attached.attachmentId), ) ? "" : reference.label, ).trim() === ""; const audioOnly = !folded && userMessage.attribution !== "guest" && userAudio.length > 0 && regularImages.length + userVideos.length + otherUserFiles.length + unknownAttachments.length === 0 && replaceComposerContextReferences(resolvedContext.text, (reference) => attachmentReferences.some( (attached) => attached.contextId === reference.contextId && userAudio.some((file) => file.id === attached.attachmentId), ) ? "" : reference.label, ).trim() === ""; const annotationRecordIds = resolvedContext.records .filter((record) => record.kind === "preview-annotation") .map((record) => record.contextId); const plainText = replaceComposerContextReferences( resolvedContext.text, (reference) => reference.label, ); const promptLink = promptLinkFor(plainText); const foldSummary = plainText .split(/\r?\n/) .find((line) => line.trim().length > 0) ?.trim() ?? ""; const mediaCopyRecords = mediaOnly ? resolvedContext.records.flatMap((record) => { const known = asKnownContextRecord(record); return (known?.kind === "image" || known?.kind === "file") && (regularImages.some((image) => image.id === known.attachmentId) || userVideos.some((file) => file.id === known.attachmentId)) ? [known] : []; }) : []; const copyRecords = mediaOnly ? mediaCopyRecords : resolvedContext.records; const contextClipboardFragment = copyRecords.length === 0 ? null : encodeComposerContextFragment({ version: 1, source: { environmentId: ctx.activeThreadEnvironmentId, ...(ctx.threadRef ? { threadId: ctx.threadRef.threadId } : {}), messageId: row.message.id, }, records: copyRecords, }); const copyText = resolvedContext.text || mediaCopyRecords.map(formatComposerContextReference).join("\n"); const onBodyCopyCapture = (event: React.ClipboardEvent) => { if (resolvedContext.records.length === 0 || !event.clipboardData) return; const selection = window.getSelection(); if (!selection || selection.isCollapsed) return; const copiedMarkdown: string[] = []; for (let index = 0; index < selection.rangeCount; index += 1) { const container = document.createElement("div"); container.appendChild(selection.getRangeAt(index).cloneContents()); // Diagram source is copied text, not authority to export context records. for (const element of container.querySelectorAll( "[data-markdown-copy]:not([data-markdown-mermaid])", )) { copiedMarkdown.push(element.getAttribute("data-markdown-copy") ?? ""); } } const fragment = selectedMessageContextFragment({ markdown: copiedMarkdown.join("\n"), records: resolvedContext.records, environmentId: ctx.activeThreadEnvironmentId, ...(ctx.threadRef ? { threadId: ctx.threadRef.threadId } : {}), messageId: row.message.id, }); if (!fragment) return; // Claim the copy: without preventDefault the browser default overwrites the // custom MIME type. The default content must then be written back explicitly. const payload = chatMarkdownClipboardPayload(selection); event.preventDefault(); event.clipboardData.setData("text/plain", payload?.text ?? selection.toString()); if (payload) { event.clipboardData.setData( "text/html", encodeComposerContextClipboardHtml(payload.text, fragment, payload.html), ); } event.clipboardData.setData(COMPOSER_CONTEXT_CLIPBOARD_MIME, fragment); }; const renderContextReference = (reference: ChatMarkdownContextReference) => { const record = asKnownContextRecord(resolvedContext.recordsById.get(reference.contextId)); if (record?.kind === "file" && userAudio.some((file) => file.id === record.attachmentId)) return null; const annotationImage = record?.kind === "preview-annotation" ? resolvePreviewAnnotationImage({ record, recordsById: resolvedContext.recordsById, userImages, previewImages, annotationRecordIds, }) : null; const attachment = record?.kind === "image" ? (userImages.find((image) => image.id === record.attachmentId) ?? null) : record?.kind === "file" ? (userFiles.find((file) => file.id === record.attachmentId) ?? null) : null; return ( { const preview = buildExpandedImagePreview(userImages, image.id); if (preview) onImageExpand(preview); }} onOpenFile={onFileOpen} onExpandVideo={(file) => { const preview = buildAttachmentVideoPreview(ctx.activeThreadEnvironmentId, file); if (preview) onImageExpand(preview); }} /> ); }; const mediaBlock = regularImages.length > 0 || userVideos.length > 0 ? (
{regularImages.map((image, index) => (
3 && regularImages.length % 2 === 1 && index === regularImages.length - 1 && "col-span-2", ) : standaloneMedia ? cn(MEDIA_EDGE_CLASS_NAME, "aspect-[4/3] overflow-hidden rounded-2xl") : "aspect-[4/3] overflow-hidden rounded-lg border border-border/80", )} > {image.previewUrl ? ( ) : (
{image.name}
)} {image.previewUrl && image.source?.kind === "snap-shot" ? ( ) : null}
))} {userVideos.map((file) => ( ))}
) : null; // Touch shows actions all the time, and narrow lanes have no room beside the // bubble, so both keep the actions on their own line. const floatActions = row.groupedWithNext === true && !coarsePointer; const messageActions = (
{folded && senderThreadId ? ( ctx.onOpenThread(senderThreadId)} tone="muted"> Open thread ) : null} }> {formatDayAwareTimestamp(row.message.createdAt, ctx.timestampFormat)} {formatChatTimestampTooltip(row.message.createdAt, ctx.timestampFormat)}
{typeof revertTurnCount === "number" && ( )} {copyText && ( )} {promptLink !== null && ( )}
); return (
{userMessage.attribution === "t3code" ? (

Sent by T3 Code

) : null} {/* The row's heading already names the guest for screen readers. */} {userMessage.guestName !== undefined ? (
) : null} {(row.message.inputIntent === "steer" || row.message.inputIntent === "promoted_queued_to_steer") && !row.continuesSteer ? ( ) : null} {mediaAboveText ? (
{/* The heading leads the media so screen readers reach it before the images. */} You {mediaBlock}
) : null}
{/* Outside the fold so a collapsed message still has a heading to navigate to. */} {mediaAboveText ? null : ( {folded ? senderLabel : userMessage.guestName === undefined ? "You" : `${userMessage.guestName} (guest)`} )} {folded ? ( setExpanded((value) => !value)} /> ) : null} {/* Find reads the DOM, so a folded message stays mounted but hidden while find is open. */} {!folded || expanded || ctx.findActive ? ( ) : null} {floatActions ? ( ) : null}
{!folded || expanded ? (
{messageActions}
) : null}
); } const ACTIONS_REVEAL_CLASS_NAME = "items-center text-xs tabular-nums opacity-0 transition-opacity duration-200 pointer-coarse:opacity-100 focus-within:opacity-100 group-hover:opacity-100"; function UserMessageIntentMarker({ intent, }: { readonly intent: "steer" | "promoted_queued_to_steer"; }) { return ( } > {intent === "promoted_queued_to_steer" ? "Originally queued, then promoted to steer the active turn" : "Steered the active turn"} ); } function RevertUserMessageButton({ turnCount, messageId, }: { turnCount: number; messageId: MessageId; }) { const ctx = use(TimelineRowCtx); const activity = use(TimelineRowActivityCtx); return ( ctx.onRevertToTurnCount(turnCount, messageId)} aria-label="Edit from here" /> } > Edit from here ); } const COLLAPSED_USER_MESSAGE_FADE_HEIGHT_REM = 1.75; const COLLAPSED_USER_MESSAGE_FADE_MASK = `linear-gradient(to bottom, black calc(100% - ${COLLAPSED_USER_MESSAGE_FADE_HEIGHT_REM}rem), transparent)`; const CollapsibleUserMessageBody = memo(function CollapsibleUserMessageBody(props: { text: string; renderContextReference: (reference: ChatMarkdownContextReference) => ReactNode; skills: ReadonlyArray; markdownCwd: string | undefined; collapsible?: boolean; }) { const [expanded, setExpanded] = useState(false); const revealForFind = () => setExpanded(true); const revealRef = findRevealRef(revealForFind); const hasVisibleBody = props.text.trim().length > 0; const canCollapse = props.collapsible !== false && hasVisibleBody && shouldCollapseUserMessage(props.text); const isCollapsed = canCollapse && !expanded; const showCollapseControl = canCollapse; return (
{hasVisibleBody ? (
) : null} {showCollapseControl ? (
{showCollapseControl ? ( ) : null}
) : null}
); }); const UserMessageBody = memo(function UserMessageBody(props: { text: string; renderContextReference?: (reference: ChatMarkdownContextReference) => ReactNode; skills: ReadonlyArray; markdownCwd: string | undefined; }) { const ctx = use(TimelineRowCtx); if (props.text.length === 0) { return null; } return ( ); });