/**
* 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 (
);
}
/** 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 (
);
}
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 (
);
}
}