/** * Rendering dispatch for a single view-model item: user prompts are * right-aligned brand bubbles (including image thumbnails), thinking collapsible blocks, text * streamed as Markdown, tool cards, subagent cards, compaction banners, abort markers, and Task * stats lines. Items have a light entrance animation. */ import { useState } from "react"; import { S } from "../../lib/strings"; import { useLocale } from "../../state/locale"; import { formatMessageTime } from "../../lib/format"; import { STAT_ICONS } from "../../lib/stat-icons"; import { splitImageAttachments } from "../../lib/attachments"; import type { ChatItem } from "../../lib/omni/stream-model"; import { Md } from "./md"; import { GlyphIcon } from "../../components/ui/glyph-icon"; import { ZoomableImage } from "../../components/ui/image-zoom"; import { MessageFilesCard } from "./message-files-card"; import { ThinkingBlock } from "./thinking-block"; import { ToolCallCard } from "./tool-call-card"; import { SubagentCard } from "./subagent-card"; import { CompactionBanner } from "./compaction-banner"; import { HandoffBanner, ModelSwitchBanner } from "./handoff-banner"; import { ScheduledBanner } from "./scheduled-banner"; import { SkillsBanner } from "./skills-banner"; import { parseHandoffMessage, parseModelSwitchMessage, parseScheduledMessage, } from "./agent-mentions"; import { parseSkillsMessage } from "./skill-use"; import { TaskStatsLine } from "./task-stats-line"; import type { StreamRenderContext } from "./message-stream"; /** Duration the "Copied" tooltip stays visible (milliseconds). */ const COPIED_MS = 1200; /** * Message footer: timestamp + copy. **Invisible but takes up space by default** (`opacity-0` * rather than `hidden`) — it surfaces on hovering the message list, and because the space is * always reserved, surfacing it never pushes content below it down (using `hidden` would cause * every item to jitter). Keyboard users can also reveal it via `focus-within` (otherwise the copy * button would be focusable but never visible). * * When `text` is omitted, only the timestamp is shown, no copy button — there's no clear meaning * to copying an image message. */ function MessageMeta({ atMs, text, align = "left", }: { atMs?: number; text?: string; align?: "left" | "right"; }) { const { locale } = useLocale(); const [copied, setCopied] = useState(false); const copy = () => { if (text === undefined) return; void navigator.clipboard.writeText(text); setCopied(true); setTimeout(() => setCopied(false), COPIED_MS); }; return (
{atMs !== undefined && ( {formatMessageTime(atMs, locale)} )} {text !== undefined && ( )}
); } export function MessageItem({ item, ctx }: { item: ChatItem; ctx: StreamRenderContext }) { switch (item.kind) { case "user_text": { // Source block for a chat created via @ handoff: collapsed into a single-line handoff notice (the raw text isn't shown), clickable to jump back to the original chat. const handoff = parseHandoffMessage(item.text); if (handoff) return ; // Source block for a chat opened by the /model switch: collapsed into a single-line switch notice, clickable to jump back to the source conversation. const modelSwitch = parseModelSwitchMessage(item.text); if (modelSwitch) return ; // Source block for a scheduled-task trigger: collapsed into a single-line notice, with the task's prompt body rendered as usual (verbatim on the Trace page). const scheduled = parseScheduledMessage(item.text); // Source block for a skill invocation: parsing continues on scheduled's remaining body // (handoff -> scheduled -> skills, blocks stripped in a chain); a match collapses into a // "using skill" banner, with the body rendered as usual. const afterScheduled = scheduled ? scheduled.rest : item.text; const skills = parseSkillsMessage(afterScheduled); // Attachment row restoration: for models that don't support images, input images are // written to disk as a path row; this pulls that out at render time and shows the actual // image. Mirrors the vision-model path (user_text + user_image as separate messages) in // shape: one bubble for the text, one bubble per image, styled the same as user_image. const { text, images } = splitImageAttachments(skills ? skills.rest : afterScheduled); return ( <> {scheduled && } {skills && } {text && (
{/* wrap-anywhere: long unbroken strings like attachment paths/long URLs wrap within the bubble on narrow (mobile) screens instead of overflowing; unlike break-words it also shrinks min-content, so a pathological token can't stretch the flex bubble itself. Normal words still only break when a token can't fit on a line. */}

{text}

)} {images.map((src, i) => (
))} ); } case "user_image": return (
); case "assistant_text": // Doesn't attach MessageMeta: this turn's reply timestamp and copy both belong to the // stats line right below it (TaskStatsLine) — that line already serves as this reply's // footer, and rendering both would pop up two copy buttons in the same spot. The stats // line's copy grabs **all** of this turn's assistant text (see collectTaskAssistant), // which is more useful than copying segment by segment. return (
{/* Re-renders the accumulated text directly while streaming (a key point of the contract implementation); memoized so settled messages skip the re-parse, and code blocks highlight once on settle (see md.tsx). */} {item.streaming && ▌} {item.stopReason && item.stopReason !== "completed" && ( [{item.stopReason}] )} {/* File summary card (Codex-style): aggregates file references in the text once streaming ends (lists only ones confirmed to exist). */} {!item.streaming && ctx.onOpenFile && ctx.statFiles && ( )}
); case "thinking": return ; case "tool_call": return ; case "subagent": return (
); case "abort": return (

{S.chat.aborted(item.reason)}

); case "reconnect": return (

{S.chat.reconnect( item.status, item.gaveUp ? "gaveUp" : item.retrying ? "retried" : "waiting", item.attempt, )}

); case "compaction": return ; case "task_stats": return ( ); } }