/** * 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 { useEffect, 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 { splitAttachments } from "../../lib/attachments"; import type { ChatItem, ReconnectItem } 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 { SubagentChip } from "./subagent-chip"; import { CompactionBanner } from "./compaction-banner"; import { GoalRoundBanner } from "./goal-banner"; import { HandoffBanner, ModelSwitchBanner } from "./handoff-banner"; import { ScheduledBanner } from "./scheduled-banner"; import { SkillsBanner } from "./skills-banner"; import { AttachedFilesBanner } from "./attached-files-banner"; import { parseHandoffMessage, parseModelSwitchMessage, parseScheduledMessage, } from "./agent-handoff"; import { parseGoalMessage } from "./goal-use"; 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; /** User glyph (24×24 line path) for the mid-run steering chip. */ const USER_STEERING_ICON = "M20 21v-2a4 4 0 0 0-4-4H8a4 4 0 0 0-4 4v2M16 7a4 4 0 1 1-8 0 4 4 0 0 1 8 0"; /** * Message footer: timestamp + copy. At ≥sm it is **invisible but takes up space by default** * (`sm: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). Below sm the footer is * always visible — hover doesn't exist on touch screens (and Tailwind v4 scopes hover: variants * to `@media (hover: hover)`), so a hover-revealed footer would simply never appear on phones; * same treatment as the AI reply's stats footer. * * 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 && ( )}
); } /** Countdown floor: waits shorter than this keep the plain waiting text (sub-second flashes of numbers are noise). */ const COUNTDOWN_MIN_MS = 2000; /** * Reconnect hint line. The live waiting state renders a COUNTDOWN to the next attempt when * the engine announced its planned wait (request_end.retry_in_ms ≥ 2s — with the * exponential ladder a wait can reach 30s, and a static "waiting" line reads as a hang), * plus two inline controls: "retry now" (skips the remaining backoff server-side; the line * flips to "retrying" when the request_begin arrives — no optimistic state beyond * disabling the buttons) and "give up" (the ordinary session abort; the engine's * abort-during-backoff path ends the turn and re-enables the composer). Anchored on the * CLIENT arrival time of the event (skew-free), ticking every 250ms with ceil'd whole * seconds; at zero it falls back to the plain waiting text, so a stale item can never tick * forever. History safety: replay delivers the following request_begin/abort immediately, * flipping the state, so neither the countdown nor the buttons render for replayed items; * the buttons are additionally main-session-only (a subagent's backoff belongs to the * child session, which the retry-now route does not target). */ function ReconnectLine({ item, ctx }: { item: ReconnectItem; ctx: StreamRenderContext }) { const state = item.gaveUp ? "gaveUp" : item.retrying ? "retried" : "waiting"; const target = state === "waiting" && item.plannedDelayMs !== undefined && item.plannedDelayMs >= COUNTDOWN_MIN_MS && item.arrivedAtMs !== undefined ? item.arrivedAtMs + item.plannedDelayMs : null; const [now, setNow] = useState(() => Date.now()); const [acted, setActed] = useState(false); useEffect(() => { if (target === null || Date.now() >= target) return; const timer = setInterval(() => { setNow(Date.now()); if (Date.now() >= target) clearInterval(timer); // stop ticking once the wait has elapsed }, 250); return () => clearInterval(timer); }, [target]); const remainingMs = target !== null ? target - now : 0; const live = target !== null && remainingMs > 0; const seconds = live ? Math.ceil(remainingMs / 1000) : undefined; const showControls = live && ctx.origin.length === 0 && (ctx.onRetryNow !== undefined || ctx.onGiveUp !== undefined); return (

{S.chat.reconnect(item.status, state, item.attempt, seconds)} {showControls && ( {ctx.onRetryNow && ( )} {ctx.onGiveUp && ( )} )}

); } export function MessageItem({ item, ctx }: { item: ChatItem; ctx: StreamRenderContext }) { switch (item.kind) { case "user_text": { // Source block for a chat created via the /agent 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 ; // A goal round's [goal] protocol prefix: collapsed into a round notice; the body after // it (round 1: the user's original input, skill blocks and all; later rounds: the // objective) continues down the normal parsing chain (the Trace shows the raw block). const goalRound = parseGoalMessage(item.text); const afterGoal = goalRound ? goalRound.rest : item.text; // 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(afterGoal); // Source block for a skill invocation: parsing continues on scheduled's remaining body // (goal -> 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 : afterGoal; const skills = parseSkillsMessage(afterScheduled); // Attachment row restoration (last in the chain — these lines trail the body rather than // prefixing it): 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. Uploaded // files come out of the same pass and collapse into one banner naming them. const { text, images, files } = splitAttachments(skills ? skills.rest : afterScheduled); // Every goal round reads like a normal user message: the body in a user bubble with // the round notice beneath (the system IS re-sending the user's request each round) — // the objective's images included, since they ride it as path lines. if (goalRound) { return ( <> {skills && } ); } return ( <> {scheduled && } {skills && } {/* Files uploaded with this message: named above the bubble, like the other message-level notices — the bytes live in the session scratchpad, the model opens them by path (goal mode never gets here: it takes text and images only). */} {files.length > 0 && } {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_steering": { // Mid-run steering ([user_steering]-wrapped user text delivered between turns): a // compact right-aligned user-styled chip inside the running Task's flow — visually // lighter than a full prompt bubble, since it doesn't start a new Task (the Trace page // still shows the raw marker text as-is). // Images sent with the message live inside the same chip: `item.images` on a vision // model (delivered as image messages right behind the text), or restored from the // [attached image: …] path lines without one — the same two shapes a user_text bubble // handles, so both render identically here. const { text: steerText, images: steerImages } = splitAttachments(item.text); const shown = [...steerImages, ...(item.images ?? [])]; return (

{S.chat.userSteering} {steerText}

{shown.length > 0 && (
{shown.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": // Standalone child session (no run_subagent card to bind to): same full-width shortcut // bar as the bound site — the conversation itself lives in the subagents panel. return (
); case "abort": return (

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

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