Skip to content
Source
Sign inSign up
Open History to see the last commit

TimelineUserMessageRow.tsx

1,235 lines46.2 KB

1import { InlineAudioPreview } from "../files/InlineAudioPreview";
2import { filePreviewKind } from "@t3tools/shared/filePreview";
3import {
4 CHAT_USER_MARKDOWN_REMARK_PLUGINS,
5 remarkEnableOrderedListNumbers,
6} from "@t3tools/shared/markdownPipeline";
7import { findRevealRef } from "./markdownFindContext";
8import {
9 type EnvironmentId,
10 type MessageId,
11 type ScheduledTaskId,
12 type ServerProviderSkill,
13 ThreadId,
14} from "@t3tools/contracts";
15import { scopeThreadRef } from "@t3tools/client-runtime/environment";
16import { resolveUserMessagePresentation } from "@t3tools/client-runtime/user-message";
17import { Link } from "@tanstack/react-router";
18import { useQuietThreadShell } from "../../state/entities";
19import { useEnvironment } from "../../state/environments";
20import { useEnvironmentQuery } from "../../state/query";
21import { serverEnvironment } from "../../state/server";
22import { replaceComposerContextReferences } from "@t3tools/shared/composerContextReferences";
23import { memo, use, useState, type ReactNode } from "react";
24import {
25 createMessageAttachmentPreviewProjector,
26 selectMessageImageResources,
27} from "../../session-logic";
28import {
29 type ChatFileAttachment,
30 isFileAttachment,
31 isImageAttachment,
32 isVideoAttachment,
33} from "../../types";
34import ChatMarkdown from "../ChatMarkdown";
35import {
36 CalendarClockIcon,
37 ChevronDownIcon,
38 DownloadIcon,
39 EyeIcon,
40 type LucideIcon,
41 MessagesSquareIcon,
42 Redo2Icon,
43 PencilIcon,
44 LinkIcon,
45} from "lucide-react";
46import { Button, InlineButton } from "../ui/button";
47import { FreshSessionRow, ModelSwitchRow } from "./V2LifecycleRow";
48import { useAssetUrlRefresh, useAssetUrls, useAssetUrlState } from "../../assets/assetUrls";
49import { AttachmentImage } from "../media/AttachmentImage";
50import { MediaVideoPlayer } from "../media/MediaVideoPlayer";
51import {
52 buildAttachmentVideoAsset,
53 buildAttachmentVideoPreview,
54 buildExpandedImagePreview,
55} from "./ExpandedImagePreview";
56import {
57 SNAP_SHOT_ATTACHMENT_FRAME_CLASS,
58 SnapShotAttachmentDetails,
59} from "./SnapShotAttachmentDetails";
60import { GuestMessageName, guestBubbleClassName } from "./guestMessageStyle";
61import { MessageCopyButton } from "./MessageCopyButton";
62import { promptLinkFor } from "../promptLinks/copyPromptLink";
63import { PierreEntryIcon } from "./PierreEntryIcon";
64import { formatBytes } from "../../lib/formatBytes";
65import { Tooltip, TooltipPopup, TooltipTrigger } from "../ui/tooltip";
66import {
67 asKnownContextRecord,
68 resolveUserMessageContext,
69 selectedMessageContextFragment,
70} from "~/lib/composerContextRecords";
71import {
72 collectComposerContextReferences,
73 formatComposerContextReference,
74} from "@t3tools/shared/composerContextReferences";
75import {
76 COMPOSER_CONTEXT_CLIPBOARD_MIME,
77 encodeComposerContextClipboardHtml,
78 encodeComposerContextFragment,
79} from "@t3tools/shared/composerContextClipboard";
80import { chatMarkdownClipboardPayload } from "../../markdown-clipboard";
81import type { ChatMarkdownContextReference } from "../ChatMarkdown";
82import { cn } from "~/lib/utils";
83import { markdownProseDirection } from "~/textDirection";
84import { useMediaQuery } from "~/hooks/useMediaQuery";
85import { formatChatTimestampTooltip, formatDayAwareTimestamp } from "../../timestampFormat";
86import { WorkflowOpeningBlock } from "./WorkflowOpeningBlock";
87import { useTallEnoughToFold } from "./useTallEnoughToFold";
88import { TimelineRowCtx, TimelineRowActivityCtx, type TimelineRow } from "./timelineRowContext";
89import {
90 MESSAGE_HEADING_LEVEL,
91 MessageAuthorHeading,
92 TimelineRowTimestamp,
93} from "./TimelineRowParts";
94import { UserMessageContextReferenceChip } from "./UserMessageContextChips";
95import {
96 resolvePreviewAnnotationImage,
97 userMessageFileLabel,
98} from "./TimelineUserMessageRow.logic";
99​
100// Drawn inside the media so dark screenshots keep an edge on the dark background.
101const MEDIA_EDGE_CLASS_NAME =
102 "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";
103​
104const BUBBLE_CHIP_CLASS_NAME =
105 "[&_[data-slot=context-chip]]:[--context-chip-ink:var(--context-chip-message,var(--context-chip-accent))]";
106​
107function UserFileCard({
108 file,
109 copyMarkdown,
110}: {
111 readonly file: ChatFileAttachment;
112 readonly copyMarkdown: string | undefined;
113}) {
114 const ctx = use(TimelineRowCtx);
115 const pastedText = file.source?._tag === "pasted-text";
116 const label = userMessageFileLabel(file);
117 const extension = /\.([a-z0-9]{1,8})$/i.exec(file.name)?.[1]?.toUpperCase() ?? "File";
118 const identity = (
119 <>
120 <span className="flex size-9 shrink-0 items-center justify-center rounded-lg bg-background/60">
121 <PierreEntryIcon pathValue={file.name} kind="file" theme={ctx.resolvedTheme} />
122 </span>
123 <span className="min-w-0 flex-1">
124 <span className="block truncate text-sm font-medium group-hover/file:underline">
125 {label}
126 </span>
127 <span className="block text-xs text-muted-foreground tabular-nums">
128 {file.downloadable === false ? (
129 "Sending…"
130 ) : (
131 <>
132 {!pastedText && `${extension} · `}
133 {formatBytes(file.sizeBytes)}
134 </>
135 )}
136 </span>
137 </span>
138 </>
139 );
140 return (
141 <div
142 data-markdown-copy={copyMarkdown}
143 className="flex w-64 max-w-full items-center gap-1 rounded-2xl bg-message py-2 ps-2 pe-1 text-message-foreground"
144 >
145 {file.downloadable === false ? (
146 <>
147 <div className="flex min-w-0 flex-1 items-center gap-3">{identity}</div>
148 <Button size="icon-xs" variant="ghost-muted" disabled aria-label="Sending attachment">
149 <DownloadIcon />
150 </Button>
151 </>
152 ) : (
153 <>
154 <button
155 type="button"
156 aria-label={`Preview ${label}`}
157 onClick={() => ctx.onFileOpen(file)}
158 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"
159 >
160 {identity}
161 </button>
162 <Tooltip>
163 <TooltipTrigger
164 render={
165 <Button
166 size="icon-xs"
167 variant="ghost-muted"
168 aria-label={`Download ${label}`}
169 onClick={() => ctx.onFileDownload(file)}
170 />
171 }
172 >
173 <DownloadIcon />
174 </TooltipTrigger>
175 <TooltipPopup side="top">Download {label}</TooltipPopup>
176 </Tooltip>
177 </>
178 )}
179 </div>
180 );
182​
183function UserVideoAttachment({
184 file,
185 standalone = false,
186 cornerClassName,
187}: {
188 readonly file: ChatFileAttachment;
189 readonly standalone?: boolean;
190 readonly cornerClassName?: string | undefined;
191}) {
192 const ctx = use(TimelineRowCtx);
193 // Row updates rebuild attachment objects, so the asset follows only the
194 // fields it reads.
195 const { downloadable, id, mimeType, name } = file;
196 const environmentId = ctx.activeThreadEnvironmentId;
197 const asset =
198 downloadable === false
199 ? null
200 : buildAttachmentVideoAsset(environmentId, { id, mimeType, name });
201 const resource = asset?.resource ?? null;
202 const assetUrl = useAssetUrlState(ctx.activeThreadEnvironmentId, resource);
203 const refreshAssetUrl = useAssetUrlRefresh(ctx.activeThreadEnvironmentId, resource);
204 const src = assetUrl._tag === "Success" ? assetUrl.url : (file.previewUrl ?? null);
205​
206 if (asset === null && src === null) {
207 return (
208 <div
209 className={cn(
210 "flex aspect-[4/3] w-full items-center justify-center bg-black px-2 py-3 text-center text-2xs text-white/70",
211 standalone
212 ? cn(MEDIA_EDGE_CLASS_NAME, "rounded-2xl", cornerClassName)
213 : "rounded-lg border border-border/80",
214 )}
215 >
216 {file.name}
217 </div>
218 );
219 }
220​
221 const player = (
222 <MediaVideoPlayer
223 src={src}
224 sourceFailed={
225 file.previewUrl === undefined && resource !== null && assetUrl._tag === "Failure"
226 }
227 label={file.name}
228 preload="visible"
229 onOpen={() => {
230 const preview = buildAttachmentVideoPreview(ctx.activeThreadEnvironmentId, file);
231 if (preview) ctx.onImageExpand(preview);
232 }}
233 className="block aspect-[4/3] w-full"
234 videoClassName={cn(
235 "aspect-auto size-full",
236 !standalone && "rounded-lg border border-border/80",
237 )}
238 stateClassName={cn(
239 "aspect-auto min-h-full bg-black text-white",
240 !standalone && "rounded-lg border border-border/80",
241 )}
242 onRetry={asset ? refreshAssetUrl : undefined}
243 actionsSource={asset ? { kind: "video", name: file.name, src, asset } : undefined}
244 />
245 );
246 return standalone ? (
247 <div className={cn(MEDIA_EDGE_CLASS_NAME, "overflow-hidden rounded-2xl", cornerClassName)}>
248 {player}
249 </div>
250 ) : (
251 player
252 );
254​
255export function UserMessageRow({ row }: { row: Extract<TimelineRow, { kind: "message" }> }) {
256 const ctx = use(TimelineRowCtx);
257 const item = row.projectedItem?.item;
258 const modelSwitch = item?.type === "user_message" ? item.modelSwitch : undefined;
259 return (
260 <>
261 {item?.type === "user_message" && item.freshContext ? <FreshSessionRow /> : null}
262 {modelSwitch ? (
263 <ModelSwitchRow environmentId={ctx.activeThreadEnvironmentId} modelSwitch={modelSwitch} />
264 ) : null}
265 <UserMessageContent row={row} />
266 </>
267 );
269​
270function UserMessageContent({ row }: { row: Extract<TimelineRow, { kind: "message" }> }) {
271 const ctx = use(TimelineRowCtx);
272 return ctx.workflowOpening?.messageId === row.message.id ? (
273 <WorkflowOpeningBlock
274 opening={ctx.workflowOpening}
275 environmentId={ctx.activeThreadEnvironmentId}
276 onOpenThread={(threadId) => ctx.onOpenThread(ThreadId.make(threadId))}
277 />
278 ) : resolveUserMessagePresentation(row.message).attribution === "automation" ? (
279 <AutomationMessageRow row={row} />
280 ) : (
281 <UserTimelineRow row={row} />
282 );
284​
285/** Automation prompts are long, repeated runbooks; a folded bubble keeps them out of the way. */
286function AutomationMessageRow({ row }: { row: Extract<TimelineRow, { kind: "message" }> }) {
287 const ctx = use(TimelineRowCtx);
288 const [expanded, setExpanded] = useState(false);
289 const revealRef = findRevealRef(() => setExpanded(true));
290 const { text, scheduledTaskId } = resolveUserMessagePresentation(row.message);
291 const summary =
292 useScheduledTaskTitle(ctx.activeThreadEnvironmentId, scheduledTaskId) ??
293 text.trim().split(/\r?\n/, 1)[0] ??
294 "";
295​
296 const revertTurnCount = row.revertTurnCount;
297 const promptLink = promptLinkFor(text);
298 // Find reads the DOM, so a folded prompt stays mounted but hidden while find is open.
299 const bodyMounted = expanded || ctx.findActive;
300​
301 return (
302 <div className="group flex flex-col items-end gap-1" data-user-message-attribution="automation">
303 {row.message.inputIntent === "steer" ||
304 row.message.inputIntent === "promoted_queued_to_steer" ? (
305 <UserMessageIntentMarker
306 intent={row.message.inputIntent}
307 unread={row.projectedItem?.item.status === "pending"}
308 />
309 ) : null}
310 <div
311 className={cn(
312 "min-w-0 max-w-[80%] rounded-2xl bg-message text-message-foreground",
313 BUBBLE_CHIP_CLASS_NAME,
314 expanded && "w-full",
315 )}
316 >
317 <MessageAuthorHeading>Automation</MessageAuthorHeading>
318 <FoldedMessageHeader
319 icon={CalendarClockIcon}
320 label="Automation"
321 summary={summary}
322 expanded={expanded}
323 onToggle={() => setExpanded((value) => !value)}
324 />
325 {bodyMounted ? (
326 // `beforematch` does not bubble, so the reveal listener sits on the hidden fold itself.
327 <div
328 ref={revealRef}
329 hidden={!expanded}
330 className={cn("px-3 pb-3", !ctx.findActive && "max-h-96 overflow-auto")}
331 data-thread-find-text="true"
332 >
333 <UserMessageBody text={text} skills={ctx.skills} markdownCwd={ctx.markdownCwd} />
334 </div>
335 ) : null}
336 </div>
337 <UserMessageProjectedStatus row={row} />
338 {expanded ? (
339 <div className="flex w-full max-w-[80%] items-center justify-end pe-1 text-xs tabular-nums opacity-0 transition-opacity duration-200 pointer-coarse:opacity-100 focus-within:opacity-100 group-hover:opacity-100">
340 <div className="flex shrink-0 items-center gap-2">
341 {scheduledTaskId ? (
342 <Link
343 to="/settings/scheduled-tasks"
344 search={{ environmentId: ctx.activeThreadEnvironmentId, taskId: scheduledTaskId }}
345 className="rounded-sm text-xs text-muted-foreground hover:underline focus-visible:outline-2 focus-visible:outline-ring"
346 >
347 Open automation
348 </Link>
349 ) : null}
350 <TimelineRowTimestamp
351 createdAt={row.message.createdAt}
352 timestampFormat={ctx.timestampFormat}
353 alwaysVisible
354 />
355 <div className="flex items-center gap-0.5">
356 {typeof revertTurnCount === "number" && (
357 <RevertUserMessageButton turnCount={revertTurnCount} messageId={row.message.id} />
358 )}
359 <MessageCopyButton text={text} variant="ghost" />
360 {promptLink !== null && (
361 <MessageCopyButton
362 text={promptLink}
363 label="Copy prompt link"
364 icon={LinkIcon}
365 variant="ghost"
366 />
367 )}
368 </div>
369 </div>
370 </div>
371 ) : null}
372 </div>
373 );
375​
376export function useScheduledTaskTitle(
377 environmentId: EnvironmentId,
378 scheduledTaskId: ScheduledTaskId | undefined,
379): string | undefined {
380 const tasksQuery = useEnvironmentQuery(
381 scheduledTaskId ? serverEnvironment.scheduledTasksLive({ environmentId, input: {} }) : null,
382 );
383 return tasksQuery.data?.tasks.find((task) => task.id === scheduledTaskId)?.title;
385​
386export function FoldedMessageHeader(props: {
387 icon: LucideIcon;
388 label: string;
389 summary: string;
390 expanded: boolean;
391 onToggle: () => void;
392}) {
393 const Icon = props.icon;
394 return (
395 <button
396 type="button"
397 aria-expanded={props.expanded}
398 data-scroll-anchor-ignore
399 onClick={props.onToggle}
400 className="flex w-full min-w-0 items-center gap-1.5 rounded-2xl px-3 py-2 text-left text-sm focus-visible:outline-2 focus-visible:outline-ring"
401 >
402 <Icon className="size-4 shrink-0 stroke-2 text-muted-foreground" />
403 <span className="max-w-1/2 shrink-0 truncate text-muted-foreground">{props.label}</span>
404 <span className="min-w-0 flex-1 truncate">{props.summary}</span>
405 <ChevronDownIcon
406 className={cn(
407 "size-3.5 shrink-0 text-muted-foreground transition-transform",
408 props.expanded && "rotate-180",
409 )}
410 />
411 </button>
412 );
414​
415function UserMessageProjectedStatus({ row }: { row: Extract<TimelineRow, { kind: "message" }> }) {
416 const status = row.projectedItem?.item.status;
417 if (
418 status === undefined ||
419 status === "completed" ||
420 status === "pending" ||
421 status === "waiting"
422 )
423 return null;
424 return (
425 <div className="me-1 flex items-center gap-1.5">
426 <span className="rounded-full border border-destructive/25 bg-destructive/8 px-1.5 py-0.5 text-3xs font-medium text-destructive">
427 {status}
428 </span>
429 </div>
430 );
432​
433function UserTimelineRow({ row }: { row: Extract<TimelineRow, { kind: "message" }> }) {
434 const ctx = use(TimelineRowCtx);
435 const { onImageExpand, onFileOpen } = ctx;
436 const coarsePointer = useMediaQuery({ pointer: "coarse" });
437 // A linked environment's agent sent it from a thread there.
438 const senderRef =
439 row.message.linkedSender ??
440 (row.message.senderThreadId === undefined
441 ? null
442 : scopeThreadRef(ctx.activeThreadEnvironmentId, row.message.senderThreadId));
443 const resources = selectMessageImageResources(row.message.attachments);
444 const previewUrls = useAssetUrls(ctx.activeThreadEnvironmentId, resources);
445 const [projectPreviews] = useState(createMessageAttachmentPreviewProjector);
446 const previewUrlsById = new Map(
447 resources.flatMap((resource, index) => {
448 const url = previewUrls[index];
449 return url ? [[resource.attachmentId, url] as const] : [];
450 }),
451 );
452 const messageWithPreviews = projectPreviews(row.message, (attachment) =>
453 previewUrlsById.get(attachment.id),
454 );
455 const userImages = (messageWithPreviews.attachments ?? []).filter(isImageAttachment);
456 const userFiles = (row.message.attachments ?? []).filter(isFileAttachment);
457 const userVideos = userFiles.filter(isVideoAttachment);
458 const userAudio = userFiles.filter(
459 (file) => file.downloadable !== false && filePreviewKind(file) === "audio",
460 );
461 const otherUserFiles = userFiles.filter(
462 (file) => !isVideoAttachment(file) && !userAudio.includes(file),
463 );
464 const unknownAttachments = (row.message.attachments ?? []).filter(
465 (attachment) => !isImageAttachment(attachment) && !isFileAttachment(attachment),
466 );
467 const userMessage = resolveUserMessagePresentation(row.message);
468 // Messages from other agents fold to one line so the user's own messages stay easy to find.
469 const folded = userMessage.attribution === "agent";
470 const [expanded, setExpanded] = useState(false);
471 const revealFoldRef = findRevealRef(() => setExpanded(true));
472 const senderShell = useQuietThreadShell(folded ? senderRef : null);
473 const senderLabel =
474 senderShell?.title.trim() || row.message.linkedSender?.title.trim() || "Agent";
475 // Only an environment this client is connected to can open the sender's thread there.
476 const linkedSenderEnvironment = useEnvironment(row.message.linkedSender?.environmentId ?? null);
477 const canOpenSender =
478 senderRef !== null &&
479 (row.message.linkedSender === undefined || linkedSenderEnvironment !== null);
480 const resolvedContext = resolveUserMessageContext({ ...row.message, text: userMessage.text });
481 const previewImages = userImages.filter((image) => image.name.startsWith("preview-annotation-"));
482 const revertTurnCount = row.revertTurnCount;
483 const attachmentReferences = collectComposerContextReferences(resolvedContext.text).flatMap(
484 (occurrence) => {
485 const record = asKnownContextRecord(resolvedContext.recordsById.get(occurrence.contextId));
486 return record?.kind === "file" || record?.kind === "image"
487 ? [
488 {
489 attachmentId: record.attachmentId,
490 contextId: occurrence.contextId,
491 source: occurrence.source,
492 },
493 ]
494 : [];
495 },
496 );
497 const chippedAttachmentIds = new Set(
498 attachmentReferences.map((reference) => reference.attachmentId),
499 );
500 const regularImages = userImages.filter((image) => !image.name.startsWith("preview-annotation-"));
501 const unchippedFiles = otherUserFiles.filter((file) => !chippedAttachmentIds.has(file.id));
502 // Images or videos sent without words show as media, not as a bubble around them.
503 const mediaOnly =
504 !folded &&
505 userMessage.attribution !== "guest" &&
506 resolvedContext.text.trim() === "" &&
507 userAudio.length === 0 &&
508 unchippedFiles.length === 0 &&
509 unknownAttachments.length === 0 &&
510 regularImages.length + userVideos.length > 0;
511 // Media sent with words sits above the bubble as its own shape, like a chat app.
512 const mediaAboveText =
513 !folded &&
514 userMessage.attribution !== "guest" &&
515 !mediaOnly &&
516 regularImages.length + userVideos.length > 0;
517 const standaloneMedia = mediaOnly || mediaAboveText;
518 // A lone image keeps its own shape; a 4:3 crop hides most of a wide screenshot.
519 const loneImage = standaloneMedia && userVideos.length === 0 && regularImages.length === 1;
520 // Several images share one rounded shape, split by hairlines.
521 const collage =
522 standaloneMedia &&
523 userVideos.length === 0 &&
524 regularImages.length > 1 &&
525 regularImages.every((image) => image.source?.kind !== "snap-shot");
526 // Corners that touch the bubble or a neighbouring message tighten, like the bubble's.
527 const mediaCornerClassName = cn(
528 row.groupedWithPrevious && "rounded-tr-md",
529 (mediaAboveText || row.groupedWithNext) && "rounded-br-md",
530 );
531 // Files sent without words show as cards, not as a bubble around a name chip.
532 const fileCardsOnly =
533 !folded &&
534 userMessage.attribution !== "guest" &&
535 otherUserFiles.length > 0 &&
536 regularImages.length + userVideos.length + userAudio.length + unknownAttachments.length === 0 &&
537 replaceComposerContextReferences(resolvedContext.text, (reference) =>
538 attachmentReferences.some(
539 (attached) =>
540 attached.contextId === reference.contextId &&
541 otherUserFiles.some((file) => file.id === attached.attachmentId),
542 )
543 ? ""
544 : reference.label,
545 ).trim() === "";
546 const audioOnly =
547 !folded &&
548 userMessage.attribution !== "guest" &&
549 userAudio.length > 0 &&
550 regularImages.length + userVideos.length + otherUserFiles.length + unknownAttachments.length ===
551 0 &&
552 replaceComposerContextReferences(resolvedContext.text, (reference) =>
553 attachmentReferences.some(
554 (attached) =>
555 attached.contextId === reference.contextId &&
556 userAudio.some((file) => file.id === attached.attachmentId),
557 )
558 ? ""
559 : reference.label,
560 ).trim() === "";
561 const annotationRecordIds = resolvedContext.records
562 .filter((record) => record.kind === "preview-annotation")
563 .map((record) => record.contextId);
564 const plainText = replaceComposerContextReferences(
565 resolvedContext.text,
566 (reference) => reference.label,
567 );
568 const promptLink = promptLinkFor(plainText);
569 const foldSummary =
570 plainText
571 .split(/\r?\n/)
572 .find((line) => line.trim().length > 0)
573 ?.trim() ?? "";
574 const mediaCopyRecords = mediaOnly
575 ? resolvedContext.records.flatMap((record) => {
576 const known = asKnownContextRecord(record);
577 return (known?.kind === "image" || known?.kind === "file") &&
578 (regularImages.some((image) => image.id === known.attachmentId) ||
579 userVideos.some((file) => file.id === known.attachmentId))
580 ? [known]
581 : [];
582 })
583 : [];
584 const copyRecords = mediaOnly ? mediaCopyRecords : resolvedContext.records;
585 const contextClipboardFragment =
586 copyRecords.length === 0
587 ? null
588 : encodeComposerContextFragment({
589 version: 1,
590 source: {
591 environmentId: ctx.activeThreadEnvironmentId,
592 ...(ctx.threadRef ? { threadId: ctx.threadRef.threadId } : {}),
593 messageId: row.message.id,
594 },
595 records: copyRecords,
596 });
597 const copyText =
598 resolvedContext.text || mediaCopyRecords.map(formatComposerContextReference).join("\n");
599 const onBodyCopyCapture = (event: React.ClipboardEvent<HTMLDivElement>) => {
600 if (resolvedContext.records.length === 0 || !event.clipboardData) return;
601 const selection = window.getSelection();
602 if (!selection || selection.isCollapsed) return;
603 const copiedMarkdown: string[] = [];
604 for (let index = 0; index < selection.rangeCount; index += 1) {
605 const container = document.createElement("div");
606 container.appendChild(selection.getRangeAt(index).cloneContents());
607 // Diagram source is copied text, not authority to export context records.
608 for (const element of container.querySelectorAll(
609 "[data-markdown-copy]:not([data-markdown-mermaid])",
610 )) {
611 copiedMarkdown.push(element.getAttribute("data-markdown-copy") ?? "");
612 }
613 }
614 const fragment = selectedMessageContextFragment({
615 markdown: copiedMarkdown.join("\n"),
616 records: resolvedContext.records,
617 environmentId: ctx.activeThreadEnvironmentId,
618 ...(ctx.threadRef ? { threadId: ctx.threadRef.threadId } : {}),
619 messageId: row.message.id,
620 });
621 if (!fragment) return;
622 // Claim the copy: without preventDefault the browser default overwrites the
623 // custom MIME type. The default content must then be written back explicitly.
624 const payload = chatMarkdownClipboardPayload(selection);
625 event.preventDefault();
626 event.clipboardData.setData("text/plain", payload?.text ?? selection.toString());
627 if (payload) {
628 event.clipboardData.setData(
629 "text/html",
630 encodeComposerContextClipboardHtml(payload.text, fragment, payload.html),
631 );
632 }
633 event.clipboardData.setData(COMPOSER_CONTEXT_CLIPBOARD_MIME, fragment);
634 };
635 const renderContextReference = (reference: ChatMarkdownContextReference) => {
636 const record = asKnownContextRecord(resolvedContext.recordsById.get(reference.contextId));
637 if (record?.kind === "file" && userAudio.some((file) => file.id === record.attachmentId))
638 return null;
639 const annotationImage =
640 record?.kind === "preview-annotation"
641 ? resolvePreviewAnnotationImage({
642 record,
643 recordsById: resolvedContext.recordsById,
644 userImages,
645 previewImages,
646 annotationRecordIds,
647 })
648 : null;
649 const attachment =
650 record?.kind === "image"
651 ? (userImages.find((image) => image.id === record.attachmentId) ?? null)
652 : record?.kind === "file"
653 ? (userFiles.find((file) => file.id === record.attachmentId) ?? null)
654 : null;
655 return (
656 <span data-thread-find-ignore>
657 <UserMessageContextReferenceChip
658 reference={reference}
659 record={record}
660 annotationImage={annotationImage}
661 attachment={attachment}
662 onExpandImage={(image) => {
663 const preview = buildExpandedImagePreview(userImages, image.id);
664 if (preview) onImageExpand(preview);
665 }}
666 onOpenFile={onFileOpen}
667 onExpandVideo={(file) => {
668 const preview = buildAttachmentVideoPreview(ctx.activeThreadEnvironmentId, file);
669 if (preview) onImageExpand(preview);
670 }}
671 />
672 </span>
673 );
674 };
675​
676 const mediaBlock =
677 regularImages.length > 0 || userVideos.length > 0 ? (
678 <div
679 className={cn(
680 "grid",
681 !standaloneMedia
682 ? "mb-2 max-w-[210px] grid-cols-2 gap-2"
683 : loneImage
684 ? "max-w-full"
685 : collage
686 ? cn(
687 MEDIA_EDGE_CLASS_NAME,
688 "w-80 max-w-full gap-px overflow-hidden rounded-2xl bg-black/9 dark:bg-white/12",
689 mediaCornerClassName,
690 regularImages.length === 2
691 ? "auto-rows-[12rem] grid-cols-2"
692 : regularImages.length === 3
693 ? "auto-rows-[calc(6rem-0.5px)] grid-cols-[3fr_2fr]"
694 : "auto-rows-[6rem] grid-cols-2",
695 )
696 : regularImages.length + userVideos.length === 1
697 ? "w-80 max-w-full"
698 : "w-80 max-w-full grid-cols-2 gap-2",
699 )}
700 >
701 {regularImages.map((image, index) => (
702 <div
703 key={image.id}
704 className={cn(
705 "bg-background/70",
706 image.source?.kind === "snap-shot" && image.previewUrl
707 ? cn(
708 "col-span-2",
709 standaloneMedia
710 ? cn(
711 MEDIA_EDGE_CLASS_NAME,
712 "min-h-16 justify-self-end overflow-hidden rounded-2xl",
713 loneImage && mediaCornerClassName,
714 )
715 : SNAP_SHOT_ATTACHMENT_FRAME_CLASS,
716 )
717 : loneImage
718 ? cn(MEDIA_EDGE_CLASS_NAME, "overflow-hidden rounded-2xl", mediaCornerClassName)
719 : collage
720 ? cn(
721 "overflow-hidden bg-background",
722 regularImages.length === 3 && index === 0 && "row-span-2",
723 regularImages.length > 3 &&
724 regularImages.length % 2 === 1 &&
725 index === regularImages.length - 1 &&
726 "col-span-2",
727 )
728 : standaloneMedia
729 ? cn(MEDIA_EDGE_CLASS_NAME, "aspect-[4/3] overflow-hidden rounded-2xl")
730 : "aspect-[4/3] overflow-hidden rounded-lg border border-border/80",
731 )}
732 >
733 {image.previewUrl ? (
734 <button
735 type="button"
736 className="block h-full w-full cursor-zoom-in"
737 aria-label={`Preview ${image.name}`}
738 onClick={() => {
739 const preview = buildExpandedImagePreview(regularImages, image.id);
740 if (!preview) return;
741 ctx.onImageExpand(preview);
742 }}
743 >
744 {/* Isolate the thumbnail raster from nearby activity repaints in Chromium. */}
745 <AttachmentImage
746 name={image.name}
747 mimeType={image.mimeType}
748 src={image.previewUrl}
749 alt={image.name}
750 decoding="async"
751 loading="lazy"
752 className={cn(
753 "block transform-gpu",
754 standaloneMedia && image.source?.kind === "snap-shot"
755 ? "h-auto max-h-48 w-auto min-w-24 max-w-full object-contain"
756 : loneImage
757 ? "h-48 w-auto min-w-24 max-w-full object-cover"
758 : "size-full object-cover",
759 )}
760 />
761 </button>
762 ) : (
763 <div
764 className={cn(
765 "flex min-h-[72px] items-center justify-center px-2 py-3 text-center text-2xs text-muted-foreground/70",
766 loneImage && "h-48 w-64 max-w-full",
767 collage && "h-full",
768 )}
769 >
770 {image.name}
771 </div>
772 )}
773 {image.previewUrl && image.source?.kind === "snap-shot" ? (
774 <SnapShotAttachmentDetails source={image.source} />
775 ) : null}
776 </div>
777 ))}
778 {userVideos.map((file) => (
779 <UserVideoAttachment
780 key={file.id}
781 file={file}
782 standalone={standaloneMedia}
783 cornerClassName={
784 regularImages.length + userVideos.length === 1 ? mediaCornerClassName : undefined
785 }
786 />
787 ))}
788 </div>
789 ) : null;
790​
791 // Touch shows actions all the time, and narrow lanes have no room beside the
792 // bubble, so both keep the actions on their own line.
793 const floatActions = row.groupedWithNext === true && !coarsePointer;
794 const messageActions = (
795 <div
796 className={cn(
797 "flex items-center justify-end gap-x-2 gap-y-0.5",
798 floatActions ? "flex-wrap" : "shrink-0",
799 )}
800 >
801 {folded && senderRef && canOpenSender ? (
802 <InlineButton
803 onClick={() => ctx.onOpenThread(senderRef.threadId, senderRef.environmentId)}
804 tone="muted"
805 >
806 Open thread
807 </InlineButton>
808 ) : null}
809 <Tooltip>
810 <TooltipTrigger render={<p className="text-muted-foreground text-xs tabular-nums" />}>
811 {formatDayAwareTimestamp(row.message.createdAt, ctx.timestampFormat)}
812 </TooltipTrigger>
813 <TooltipPopup>
814 {formatChatTimestampTooltip(row.message.createdAt, ctx.timestampFormat)}
815 </TooltipPopup>
816 </Tooltip>
817 <div className={cn("flex items-center gap-0.5", floatActions && "flex-wrap justify-end")}>
818 {typeof revertTurnCount === "number" && (
819 <RevertUserMessageButton turnCount={revertTurnCount} messageId={row.message.id} />
820 )}
821 {copyText && (
822 <MessageCopyButton
823 text={contextClipboardFragment ? copyText : plainText}
824 {...(contextClipboardFragment
825 ? {
826 extraFlavors: {
827 [COMPOSER_CONTEXT_CLIPBOARD_MIME]: contextClipboardFragment,
828 },
829 }
830 : {})}
831 variant="ghost"
832 />
833 )}
834 {promptLink !== null && (
835 <MessageCopyButton
836 text={promptLink}
837 label="Copy prompt link"
838 icon={LinkIcon}
839 variant="ghost"
840 />
841 )}
842 </div>
843 </div>
844 );
845 return (
846 <div
847 className="group @container flex flex-col items-end gap-1"
848 data-user-message-attribution={folded ? "agent" : undefined}
849 >
850 {userMessage.attribution === "t3code" ? (
851 <p
852 className="me-1 text-2xs text-muted-foreground/70"
853 data-user-message-attribution="t3code"
854 >
855 Sent by T3 Code
856 </p>
857 ) : null}
858 {/* The row's heading already names the guest for screen readers. */}
859 {userMessage.guestName !== undefined ? (
860 <div aria-hidden>
861 <GuestMessageName name={userMessage.guestName} />
862 </div>
863 ) : null}
864 {(row.message.inputIntent === "steer" ||
865 row.message.inputIntent === "promoted_queued_to_steer") &&
866 !row.continuesSteer ? (
867 <UserMessageIntentMarker
868 intent={row.message.inputIntent}
869 unread={row.projectedItem?.item.status === "pending"}
870 />
871 ) : null}
872 {mediaAboveText ? (
873 <div className="flex max-w-[80%] justify-end">
874 {/* The heading leads the media so screen readers reach it before the images. */}
875 <MessageAuthorHeading>You</MessageAuthorHeading>
876 {mediaBlock}
877 </div>
878 ) : null}
879 <div
880 className={cn(
881 "relative min-w-0 max-w-[80%] rounded-2xl text-message-foreground",
882 (mediaAboveText || row.groupedWithPrevious) && "rounded-tr-md",
883 row.groupedWithNext && "rounded-br-md",
884 BUBBLE_CHIP_CLASS_NAME,
885 userMessage.attribution === "guest"
886 ? guestBubbleClassName(false)
887 : !mediaOnly && !fileCardsOnly && !audioOnly && "bg-message",
888 !folded && !mediaOnly && !fileCardsOnly && !audioOnly && "p-3",
889 folded && expanded && "w-full",
890 )}
891 >
892 {/* Outside the fold so a collapsed message still has a heading to navigate to. */}
893 {mediaAboveText ? null : (
894 <MessageAuthorHeading>
895 {folded
896 ? senderLabel
897 : userMessage.guestName === undefined
898 ? "You"
899 : `${userMessage.guestName} (guest)`}
900 </MessageAuthorHeading>
901 )}
902 {folded ? (
903 <FoldedMessageHeader
904 icon={MessagesSquareIcon}
905 label={senderLabel}
906 summary={foldSummary}
907 expanded={expanded}
908 onToggle={() => setExpanded((value) => !value)}
909 />
910 ) : null}
911 {/* Find reads the DOM, so a folded message stays mounted but hidden while find is open. */}
912 {!folded || expanded || ctx.findActive ? (
913 <div
914 ref={folded ? revealFoldRef : undefined}
915 hidden={folded && !expanded}
916 dir={markdownProseDirection(resolvedContext.text)}
917 className={cn(
918 folded && "px-3 pb-3",
919 folded && !ctx.findActive && "max-h-96 overflow-auto",
920 )}
921 >
922 {!mediaAboveText && mediaBlock}
923 {userAudio.length > 0 ? (
924 <div className={cn("flex max-w-full flex-col gap-1", !audioOnly && "mb-2")}>
925 {userAudio.map((file) => (
926 <InlineAudioPreview
927 key={file.id}
928 name={file.name}
929 variant={audioOnly ? "bubble" : "chip"}
930 environmentId={ctx.activeThreadEnvironmentId}
931 resource={{
932 _tag: "attachment",
933 attachmentId: file.id,
934 fileName: file.name,
935 mimeType: file.mimeType,
936 disposition: "inline",
937 }}
938 onOpen={() => ctx.onFileOpen(file)}
939 />
940 ))}
941 </div>
942 ) : null}
943 {fileCardsOnly ? (
944 <div className="flex flex-col items-end gap-1" onCopyCapture={onBodyCopyCapture}>
945 {otherUserFiles.map((file) => (
946 <UserFileCard
947 key={file.id}
948 file={file}
949 copyMarkdown={
950 attachmentReferences.find((reference) => reference.attachmentId === file.id)
951 ?.source
952 }
953 />
954 ))}
955 </div>
956 ) : null}
957 {!fileCardsOnly && (unchippedFiles.length > 0 || unknownAttachments.length > 0) ? (
958 <div className="mb-2 flex flex-col gap-1">
959 {unchippedFiles.map((file) => {
960 const pastedText = file.source?._tag === "pasted-text";
961 const label = userMessageFileLabel(file);
962 const fileIdentity = (
963 <>
964 <PierreEntryIcon
965 pathValue={file.name}
966 kind="file"
967 theme={ctx.resolvedTheme}
968 />
969 <span className="min-w-0 flex-1">
970 <span className="block truncate">{label}</span>
971 {file.downloadable === false || pastedText ? (
972 <span className="block text-xs text-muted-foreground tabular-nums">
973 {file.downloadable === false ? "Sending…" : formatBytes(file.sizeBytes)}
974 </span>
975 ) : null}
976 </span>
977 </>
978 );
979 if (file.downloadable !== false) {
980 return (
981 <div key={file.id} className="flex min-w-0 items-center gap-1">
982 <button
983 type="button"
984 aria-label={`Preview ${label}`}
985 onClick={() => ctx.onFileOpen(file)}
986 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"
987 >
988 {fileIdentity}
989 <EyeIcon className="size-4 shrink-0" />
990 </button>
991 <Tooltip>
992 <TooltipTrigger
993 render={
994 <Button
995 size="icon-xs"
996 variant="ghost-muted"
997 aria-label={`Download ${label}`}
998 onClick={() => ctx.onFileDownload(file)}
999 />
1002 <DownloadIcon />
1003 </TooltipTrigger>
1004 <TooltipPopup side="top">Download {label}</TooltipPopup>
1005 </Tooltip>
1006 </div>
1007 );
1009​
1010 return (
1011 <div key={file.id} className="flex min-w-0 items-center gap-2 py-1 text-sm">
1012 {fileIdentity}
1013 </div>
1014 );
1015 })}
1016 {unknownAttachments.map((attachment) => (
1017 <div key={attachment.id} className="flex min-w-0 items-center gap-2 py-1 text-sm">
1018 <PierreEntryIcon
1019 pathValue={attachment.name}
1020 kind="file"
1021 theme={ctx.resolvedTheme}
1022 />
1023 <span className="min-w-0 flex-1 truncate">{attachment.name}</span>
1024 </div>
1025 ))}
1026 </div>
1027 ) : null}
1028 {fileCardsOnly || audioOnly ? null : (
1029 <div onCopyCapture={onBodyCopyCapture}>
1030 <CollapsibleUserMessageBody
1031 text={resolvedContext.text}
1032 renderContextReference={renderContextReference}
1033 skills={ctx.skills}
1034 markdownCwd={ctx.markdownCwd}
1035 collapsible={!folded}
1036 />
1037 </div>
1038 )}
1039 </div>
1040 ) : null}
1041 {floatActions ? (
1042 <div
1043 className={cn(
1044 "absolute end-full top-1/2 me-2 hidden w-max max-w-[calc(20cqw-0.5rem)] -translate-y-1/2 @min-[37rem]:flex",
1045 ACTIONS_REVEAL_CLASS_NAME,
1046 )}
1048 {messageActions}
1049 </div>
1050 ) : null}
1051 </div>
1052 <UserMessageProjectedStatus row={row} />
1053 {!folded || expanded ? (
1054 <div
1055 className={cn(
1056 "flex w-full max-w-[80%] justify-end pe-1",
1057 floatActions && "@min-[37rem]:hidden",
1058 ACTIONS_REVEAL_CLASS_NAME,
1059 )}
1061 {messageActions}
1062 </div>
1063 ) : null}
1064 </div>
1065 );
1067​
1068const ACTIONS_REVEAL_CLASS_NAME =
1069 "items-center text-xs tabular-nums opacity-0 transition-opacity duration-200 pointer-coarse:opacity-100 focus-within:opacity-100 group-hover:opacity-100";
1070​
1071function UserMessageIntentMarker({
1072 intent,
1073 unread,
1074}: {
1075 readonly intent: "steer" | "promoted_queued_to_steer";
1076 readonly unread: boolean;
1077}) {
1078 return (
1079 <Tooltip>
1080 <TooltipTrigger
1081 render={
1082 <div
1083 className="me-1 flex items-center justify-end gap-1 text-xs leading-none text-muted-foreground"
1084 data-user-message-intent={intent}
1085 />
1088 <Redo2Icon aria-hidden="true" className="size-3" />
1089 {unread ? "Steer · waiting to be read" : "Steer"}
1090 </TooltipTrigger>
1091 <TooltipPopup side="top">
1092 {unread
1093 ? "Waiting for the agent to read this. It reads steers between steps, after a running command finishes."
1094 : intent === "promoted_queued_to_steer"
1095 ? "Originally queued, then promoted to steer the active turn"
1096 : "Steered the active turn"}
1097 </TooltipPopup>
1098 </Tooltip>
1099 );
1101​
1102function RevertUserMessageButton({
1103 turnCount,
1104 messageId,
1105}: {
1106 turnCount: number;
1107 messageId: MessageId;
1108}) {
1109 const ctx = use(TimelineRowCtx);
1110 const activity = use(TimelineRowActivityCtx);
1111​
1112 return (
1113 <Tooltip>
1114 <TooltipTrigger
1115 render={
1116 <Button
1117 type="button"
1118 size="xs"
1119 variant="ghost"
1120 disabled={activity.isRevertingCheckpoint || activity.isWorking}
1121 onClick={() => ctx.onRevertToTurnCount(turnCount, messageId)}
1122 aria-label="Edit from here"
1123 />
1126 <PencilIcon className="size-3" />
1127 </TooltipTrigger>
1128 <TooltipPopup side="top">Edit from here</TooltipPopup>
1129 </Tooltip>
1130 );
1132​
1133const COLLAPSED_USER_MESSAGE_FADE_HEIGHT_REM = 1.75;
1134​
1135const COLLAPSED_USER_MESSAGE_FADE_MASK = `linear-gradient(to bottom, black calc(100% - ${COLLAPSED_USER_MESSAGE_FADE_HEIGHT_REM}rem), transparent)`;
1136​
1137const CollapsibleUserMessageBody = memo(function CollapsibleUserMessageBody(props: {
1138 text: string;
1139 renderContextReference: (reference: ChatMarkdownContextReference) => ReactNode;
1140 skills: ReadonlyArray<ServerProviderSkill>;
1141 markdownCwd: string | undefined;
1142 collapsible?: boolean;
1143}) {
1144 const [expanded, setExpanded] = useState(false);
1145 const revealForFind = () => setExpanded(true);
1146 const revealRef = findRevealRef(revealForFind);
1147 const hasVisibleBody = props.text.trim().length > 0;
1148 const { ref: contentRef, tallEnough: canCollapse } = useTallEnoughToFold(
1149 props.collapsible !== false && hasVisibleBody,
1150 );
1151 const isCollapsed = canCollapse && !expanded;
1152 const showCollapseControl = canCollapse;
1153​
1154 return (
1155 <div>
1156 {hasVisibleBody ? (
1157 <div
1158 ref={revealRef}
1159 className={cn("relative", isCollapsed && "max-h-44 overflow-hidden")}
1160 data-user-message-body="true"
1161 data-thread-find-text="true"
1162 data-thread-find-fold={isCollapsed ? "" : undefined}
1163 data-user-message-collapsed={isCollapsed ? "true" : "false"}
1164 data-user-message-collapsible={canCollapse ? "true" : "false"}
1165 data-user-message-fade={isCollapsed ? "true" : "false"}
1166 style={
1167 isCollapsed
1168 ? {
1169 WebkitMaskImage: COLLAPSED_USER_MESSAGE_FADE_MASK,
1170 maskImage: COLLAPSED_USER_MESSAGE_FADE_MASK,
1172 : undefined
1175 <div ref={contentRef}>
1176 <UserMessageBody
1177 text={props.text}
1178 renderContextReference={props.renderContextReference}
1179 skills={props.skills}
1180 markdownCwd={props.markdownCwd}
1181 />
1182 </div>
1183 </div>
1184 ) : null}
1185 {showCollapseControl ? (
1186 <div className="mt-1.5 flex items-center justify-end gap-2" data-user-message-footer="true">
1187 {showCollapseControl ? (
1188 <Button
1189 type="button"
1190 size="xs"
1191 variant="ghost-muted"
1192 aria-expanded={expanded}
1193 data-scroll-anchor-ignore
1194 onClick={() => setExpanded((value) => !value)}
1195 className="-ml-1"
1197 {expanded ? "Show less" : "Show full message"}
1198 </Button>
1199 ) : null}
1200 </div>
1201 ) : null}
1202 </div>
1203 );
1204});
1205​
1206const USER_MESSAGE_REMARK_PLUGINS = [
1207 ...CHAT_USER_MARKDOWN_REMARK_PLUGINS,
1208 remarkEnableOrderedListNumbers,
1210​
1211const UserMessageBody = memo(function UserMessageBody(props: {
1212 text: string;
1213 renderContextReference?: (reference: ChatMarkdownContextReference) => ReactNode;
1214 skills: ReadonlyArray<ServerProviderSkill>;
1215 markdownCwd: string | undefined;
1216}) {
1217 const ctx = use(TimelineRowCtx);
1218 if (props.text.length === 0) {
1219 return null;
1221 return (
1222 <ChatMarkdown
1223 text={props.text}
1224 cwd={props.markdownCwd}
1225 threadRef={ctx.threadRef ?? undefined}
1226 skills={props.skills}
1227 className="text-message-foreground user-message-markdown"
1228 lineBreaks
1229 parseRawHtml={false}
1230 extraRemarkPlugins={USER_MESSAGE_REMARK_PLUGINS}
1231 renderContextReference={props.renderContextReference}
1232 headingLevelOffset={MESSAGE_HEADING_LEVEL}
1233 />
1234 );
1235});