/** * 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 { splitImageAttachments } 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 { 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; /** 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. **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 && ( )}
); } /** 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 @ 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_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). return (

{S.chat.userSteering} {item.text}

); 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 ; case "compaction": return ; case "task_stats": return ( ); } }