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.onFileOpen(file)}
className="group/file focus-visible:ring-ring/70 flex min-w-0 flex-1 cursor-pointer items-center gap-3 rounded-xl text-left focus-visible:ring-2 focus-visible:ring-inset focus-visible:outline-none"
>
{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 (
{props.label}
{props.summary}
);
}
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 ? (
{
const preview = buildExpandedImagePreview(regularImages, image.id);
if (!preview) return;
ctx.onImageExpand(preview);
}}
>
{/* Isolate the thumbnail raster from nearby activity repaints in Chromium. */}
) : (
{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 ? (
{!mediaAboveText && mediaBlock}
{userAudio.length > 0 ? (
{userAudio.map((file) => (
ctx.onFileOpen(file)}
/>
))}
) : null}
{fileCardsOnly ? (
{otherUserFiles.map((file) => (
reference.attachmentId === file.id)
?.source
}
/>
))}
) : null}
{!fileCardsOnly && (unchippedFiles.length > 0 || unknownAttachments.length > 0) ? (
{unchippedFiles.map((file) => {
const pastedText = file.source?._tag === "pasted-text";
const label = userMessageFileLabel(file);
const fileIdentity = (
<>
{label}
{file.downloadable === false || pastedText ? (
{file.downloadable === false ? "Sending…" : formatBytes(file.sizeBytes)}
) : null}
>
);
if (file.downloadable !== false) {
return (
ctx.onFileOpen(file)}
className="focus-visible:ring-ring/70 flex min-w-0 flex-1 cursor-pointer items-center gap-2 rounded-md py-1 text-left text-sm hover:underline focus-visible:ring-2 focus-visible:ring-inset focus-visible:outline-none"
>
{fileIdentity}
ctx.onFileDownload(file)}
/>
}
>
Download {label}
);
}
return (
{fileIdentity}
);
})}
{unknownAttachments.map((attachment) => (
))}
) : null}
{fileCardsOnly || audioOnly ? null : (
)}
) : null}
{floatActions ? (
{messageActions}
) : 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 (
}
>
Steer
{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 ? (
setExpanded((value) => !value)}
className="-ml-1"
>
{expanded ? "Show less" : "Show full message"}
) : 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 (
);
});