import { i18n } from "@lingui/core"; import { Trans, useLingui } from "@lingui/react/macro"; import { ChatMarkdown } from "@rakazo/chat-ui/web"; import type { CardAct, MessageReaction, ThreadMessage } from "@rakazo/contracts"; import { canReactToThreadMessage, MESSAGE_REACTIONS } from "@rakazo/contracts"; import type { WorkspaceArtifact } from "@rakazo/core"; import { foldRunNarration, isPeerReceiptBlocks, isRoutineEventMessage, isToolActivityBlock, projectMessageReactions, resolveOoColor, } from "@rakazo/core"; import type { GroupAvatarMember } from "@rakazo/ui-web"; import { BotAvatar, cn, DropdownMenu, DropdownMenuContent, DropdownMenuItem, DropdownMenuTrigger, Popover, PopoverContent, PopoverTrigger, } from "@rakazo/ui-web"; import { ArrowDown, Copy, MoreHorizontal, Reply, Smile, TextQuote, Volume2 } from "lucide-react"; import type { RefObject } from "react"; import { memo, useCallback, useEffect, useLayoutEffect, useMemo, useRef, useState } from "react"; import { createPortal } from "react-dom"; import { ArtifactFileCard } from "../../components/ArtifactFileCard"; import { AskCard } from "../../components/AskCard"; import { CollaborationMarker } from "../../components/ai/CollaborationMarker"; import { DelegatedTaskCard, SentMessageCard } from "../../components/ai/DelegatedTaskCard"; import { ProjectCreatedCard } from "../../components/ai/ProjectCreatedCard"; import { CloudAgentCard } from "../../components/CloudAgentCard"; import { MessageHoverMetadata } from "../../components/MessageHoverMetadata"; import { MediaCard } from "../../components/media/MediaCard"; import { MediaPlayer } from "../../components/media/MediaPlayer"; import { ThreadCard } from "../../components/ThreadCard"; import { SkillDraftCard } from "../../components/teach/SkillDraftCard"; import { rosterDoingVerb } from "../../lib/agent-activity"; import type { ArtifactTarget } from "../../lib/artifact-open"; import { copyableMessageText } from "../../lib/message-text"; import { messageProviderLabel } from "../../lib/messaging"; import { withIntakeNoteText } from "../../lib/onboarding-copy"; import { queuedFollowUps } from "../../lib/queued-follow-ups"; import { quoteDraftForSelection } from "../../lib/quote-selection"; import { transcriptIsNearEnd, transcriptMovedDown } from "../../lib/transcript-scroll"; import type { AutopilotAction } from "./autopilot-line"; import { AutopilotLine } from "./autopilot-line"; import { AppConnectCard, ArtifactImage, ChartBlockView, ChoiceCard, McpApprovalCard, } from "./message-cards"; import { previewMessageText } from "./message-preview"; import { RoutineEventLine } from "./routine-event-line"; import { JobProgressCard, RunSteps } from "./run-steps"; import { FALLBACK_BOT_COLOR } from "./shell-types"; import { isInterruptedStepMessage } from "./transcript-status"; function findPrecedingUserTask( messages: readonly ThreadMessage[], beforeMessageId: string, ): string | null { const index = messages.findIndex((m) => m.id === beforeMessageId); const start = index >= 0 ? index - 1 : messages.length - 1; let fallback: string | null = null; for (let i = start; i >= 0; i--) { const m = messages[i]; if (m?.role === "user") { const text = m.blocks .filter((b): b is { kind: "text"; text: string } => b.kind === "text") .map((b) => b.text) .join(" ") .trim(); if (!text) continue; if (!fallback) fallback = text; const isPunctuationOrFiller = /^[?!.,:;]+$/.test(text) || (text.length <= 4 && /^(ok|ja|nein|yes|no|hi|hey)$/i.test(text)); if (!isPunctuationOrFiller) { return text; } } } return fallback; } export const Transcript = memo(function Transcript({ scrollRef, artifactTarget, onOpenArtifact, messages, olderCursor, loadingOlder, answerableAskMessageId, running, runStartedAt, workingBots, onLoadOlder, onOpenBot, onOpenGroup, onAnswer, onCardAct, onAutopilot, onReply, onQuote, onReact, onJumpToMessage, onOpenPeerMessages, memberName, peerBot, onRefresh, onBotChanged, onAddRoutine, voiceReady, speakingMessageId, onSpeak, encounterMode = false, voiceEncounterActive = false, onRestartTask, }: { scrollRef: RefObject; artifactTarget: ArtifactTarget; onOpenArtifact?: (artifact: WorkspaceArtifact) => void; messages: ThreadMessage[]; olderCursor: number | null; loadingOlder: boolean; answerableAskMessageId: string | null; running: boolean; runStartedAt?: string; workingBots: GroupAvatarMember[]; onLoadOlder: () => void | Promise; onOpenBot: (botId: string) => void; onOpenGroup: (groupId: string) => void; onAnswer: (message: ThreadMessage, text: string) => Promise; onCardAct?: (message: ThreadMessage, cardId: string, act: CardAct) => Promise; onAutopilot?: (action: AutopilotAction) => Promise; onReply: (message: ThreadMessage) => void; onQuote: (message: ThreadMessage, quote: string) => void; onReact: (message: ThreadMessage, reaction: MessageReaction) => Promise; onJumpToMessage: (messageId: string) => void; onOpenPeerMessages: (peer: { peerBotId: string; peerBotName: string }) => void; memberName?: (botId: string | undefined) => string | undefined; peerBot: (botId: string) => { color: string; status?: string } | undefined; onRefresh: () => Promise; onBotChanged: () => Promise; onAddRoutine: (name: string, prompt: string) => void; voiceReady: boolean; speakingMessageId: string | null; onSpeak: (message: ThreadMessage) => void; encounterMode?: boolean; voiceEncounterActive?: boolean; onRestartTask?: (taskPrompt: string) => void | Promise; }) { const { t } = useLingui(); const [restartingPrompt, setRestartingPrompt] = useState(null); const handleRestart = useCallback( async (prompt: string) => { if (!onRestartTask || !prompt.trim() || restartingPrompt) return; setRestartingPrompt(prompt); try { await onRestartTask(prompt); } finally { setRestartingPrompt(null); } }, [onRestartTask, restartingPrompt], ); const [atEnd, setAtEnd] = useState(true); const following = useRef(true); const autoScrolling = useRef(false); const lastScrollTop = useRef(null); const autoScrollTimer = useRef(undefined); const jumpButtonRef = useRef(null); const messageById = useMemo( () => new Map(messages.map((message) => [message.id, message])), [messages], ); const reactionView = useMemo( () => projectMessageReactions(messages.map(withIntakeNoteText)), // Onboarding notes are worded in the UI language. [messages, i18n.locale], ); const queuedIds = useMemo( () => new Set( (running ? queuedFollowUps(messages, runStartedAt, reactionView.reactions) : []).map( (message) => message.id, ), ), [messages, running, runStartedAt, reactionView.reactions], ); const runNarration = useMemo( () => foldRunNarration(reactionView.visibleMessages), [reactionView.visibleMessages], ); const transcriptSubordinate = encounterMode && voiceEncounterActive; const workingBotName = workingBots.length === 1 ? workingBots[0]?.name : undefined; const primaryRunStatus = workingBots[0]?.status; const statusVerb = rosterDoingVerb(primaryRunStatus); const workingLabel = statusVerb ?? (workingBotName != null && workingBotName !== "" ? t`${workingBotName} is working` : t`Bots are working`); const lastUserTask = useMemo(() => { let fallback: string | null = null; for (let i = messages.length - 1; i >= 0; i--) { const m = messages[i]; if (m?.role === "user") { const text = m.blocks .filter((b): b is { kind: "text"; text: string } => b.kind === "text") .map((b) => b.text) .join(" ") .trim(); if (!text) continue; if (!fallback) fallback = text; const isPunctuationOrFiller = /^[?!.,:;]+$/.test(text) || (text.length <= 4 && /^(ok|ja|nein|yes|no|hi|hey)$/i.test(text)); if (!isPunctuationOrFiller) { return text; } } } return fallback; }, [messages]); const [quoteDraft, setQuoteDraft] = useState<{ message: ThreadMessage; text: string; range: Range; } | null>(null); const selectingWithMouse = useRef(false); const evaluateSelection = useCallback(() => { const selection = window.getSelection(); if (!selection || selection.isCollapsed || selection.rangeCount === 0) { setQuoteDraft(null); return; } const range = selection.getRangeAt(0); const rowOf = (node: Node) => (node instanceof Element ? node : node.parentElement)?.closest( "[data-message-id]", ) ?? null; const draft = quoteDraftForSelection( { startRow: rowOf(range.startContainer), endRow: rowOf(range.endContainer), text: selection.toString(), }, messageById, ); setQuoteDraft(draft ? { ...draft, range } : null); }, [messageById]); // Keyboard and assistive-tech selections never reach a mouseup, so the pill // lifecycle listens on selectionchange; the mouse flag keeps it hidden while // a drag is still in flight. useEffect(() => { const onMouseDown = (event: MouseEvent) => { selectingWithMouse.current = true; if ((event.target as Element | null)?.closest?.("[data-quote-selection]")) return; setQuoteDraft(null); }; const onMouseUp = () => { selectingWithMouse.current = false; evaluateSelection(); }; const onSelectionChange = () => { if (!selectingWithMouse.current) evaluateSelection(); }; const onKey = (event: KeyboardEvent) => { if (event.key === "Escape") setQuoteDraft(null); }; document.addEventListener("mousedown", onMouseDown, true); document.addEventListener("mouseup", onMouseUp, true); document.addEventListener("selectionchange", onSelectionChange); document.addEventListener("keydown", onKey); return () => { document.removeEventListener("mousedown", onMouseDown, true); document.removeEventListener("mouseup", onMouseUp, true); document.removeEventListener("selectionchange", onSelectionChange); document.removeEventListener("keydown", onKey); }; }, [evaluateSelection]); const snapToEnd = useCallback(() => { const element = scrollRef.current; if (!element) return; following.current = true; autoScrolling.current = false; setAtEnd(true); element.scrollTo({ top: element.scrollHeight, behavior: "auto" }); }, [scrollRef]); const jumpToLatest = useCallback(() => { const element = scrollRef.current; if (!element) return; const reducedMotion = window.matchMedia("(prefers-reduced-motion: reduce)").matches; following.current = true; autoScrolling.current = !reducedMotion; setAtEnd(true); element.scrollTo({ top: element.scrollHeight, behavior: reducedMotion ? "auto" : "smooth", }); window.clearTimeout(autoScrollTimer.current); // Fallback only: onScroll clears autoScrolling once near-end is reached. autoScrollTimer.current = window.setTimeout( () => { autoScrolling.current = false; }, reducedMotion ? 0 : 2_000, ); }, [scrollRef]); useLayoutEffect(() => { if (following.current) snapToEnd(); }, [messages, running, snapToEnd]); // A question the bot waits on can scroll out of view; offer a way back to it instead of the arrow. const [askOffscreen, setAskOffscreen] = useState(false); const findAsk = useCallback( () => answerableAskMessageId ? (scrollRef.current?.querySelector( `[data-message-id="${CSS.escape(answerableAskMessageId)}"]`, ) ?? null) : null, [answerableAskMessageId, scrollRef], ); useEffect(() => { const root = scrollRef.current; const element = findAsk(); if (!root || !element || typeof IntersectionObserver === "undefined") { setAskOffscreen(false); return; } const observer = new IntersectionObserver( (entries) => setAskOffscreen(!entries.some((entry) => entry.isIntersecting)), { root, threshold: 0.2 }, ); observer.observe(element); return () => observer.disconnect(); }, [findAsk, messages, scrollRef]); const jumpToAsk = useCallback(() => { const element = findAsk(); if (!element) return; const reducedMotion = window.matchMedia("(prefers-reduced-motion: reduce)").matches; following.current = false; element.scrollIntoView({ behavior: reducedMotion ? "auto" : "smooth", block: "center" }); element .querySelector("[data-ask-card] input, [data-ask-card] button") ?.focus({ preventScroll: true }); }, [findAsk]); const showJumpToLatest = !atEnd && !askOffscreen; useLayoutEffect(() => { const button = jumpButtonRef.current; if (atEnd && button && document.activeElement === button) { button.blur(); } }, [atEnd]); const loadOlder = useCallback(() => { const wasFollowing = following.current; // Prepend must not race the messages-driven snap-to-end follow path. following.current = false; autoScrolling.current = false; const pending = onLoadOlder(); if (!pending) return; return Promise.resolve(pending).catch((error) => { const element = scrollRef.current; if (wasFollowing && element && transcriptIsNearEnd(element)) { following.current = true; setAtEnd(true); } throw error; }); }, [onLoadOlder, scrollRef]); useEffect( () => () => { window.clearTimeout(autoScrollTimer.current); }, [], ); return ( // Clip the scroller's padding when the column leaves no room, e.g. beside the main inbox.
{ lastScrollTop.current = event.currentTarget.scrollTop; autoScrolling.current = false; following.current = transcriptIsNearEnd(event.currentTarget); }} onTouchStart={(event) => { lastScrollTop.current = event.currentTarget.scrollTop; autoScrolling.current = false; following.current = transcriptIsNearEnd(event.currentTarget); }} onWheel={(event) => { if (event.deltaY < 0) { lastScrollTop.current = event.currentTarget.scrollTop; autoScrolling.current = false; following.current = false; } }} onScroll={(event) => { const scrolledDown = transcriptMovedDown( lastScrollTop.current, event.currentTarget.scrollTop, ); lastScrollTop.current = event.currentTarget.scrollTop; const nearEnd = transcriptIsNearEnd(event.currentTarget); setAtEnd(nearEnd); if (nearEnd) { if (scrolledDown) following.current = true; if (autoScrolling.current) { autoScrolling.current = false; window.clearTimeout(autoScrollTimer.current); } } else if (!autoScrolling.current) { following.current = false; } }} className={`rk-scroll flex min-h-0 flex-1 flex-col overflow-y-auto transition-opacity duration-300 ${ encounterMode ? "gap-2 px-3 py-3 md:px-6 md:py-4" : "gap-3.5 px-3.5 py-4 md:px-8 md:py-6" } ${transcriptSubordinate ? "opacity-75" : ""}`} > {olderCursor != null ? ( ) : null}
{reactionView.visibleMessages.map((message) => { if (!message.blocks.some((block) => !isToolActivityBlock(block))) return null; if (runNarration.hidden.has(message.id)) return null; const foldedSteps = runNarration.steps.get(message.id); const peerReceipt = isPeerReceiptBlocks(message.blocks); if (isRoutineEventMessage(message.blocks)) { return (
{message.blocks.map((block, index) => block.kind === "event" ? ( ) : block.kind === "autopilot" ? ( ) : null, )}
); } const messageReactions = reactionView.reactions.get(message.id); return (
{foldedSteps ? ( { const task = findPrecedingUserTask( reactionView.visibleMessages, message.id, ); if (task) void handleRestart(task); } : undefined } restarting={Boolean( restartingPrompt && restartingPrompt === findPrecedingUserTask(reactionView.visibleMessages, message.id), )} /> ) : null} {!peerReceipt && !message.id.startsWith("progress:") ? ( ) : null}
onSpeak(message)} /> {queuedIds.has(message.id) ? (

{t`Queued`}

) : null} {!peerReceipt && messageReactions ? (
{[...messageReactions].map(([emoji, count]) => ( {emoji} {count > 1 ? ` ${count}` : ""} ))}
) : null} {peerReceipt ? null : ( )}
); })} {running && !encounterMode ? (
) : null}
{quoteDraft ? ( { onQuote(quoteDraft.message, quoteDraft.text); window.getSelection()?.removeAllRanges(); setQuoteDraft(null); }} /> ) : null} {askOffscreen ? ( ) : null}
); }); /** * Floating Quote action anchored to the selection's bounding rect. Measures * itself after mount so it can flip below the selection when there is no room * above and stay clamped inside the viewport; re-anchors on scroll/resize. */ const QuoteSelectionButton = memo(function QuoteSelectionButton({ range, onQuote, }: { range: Range; onQuote: () => void; }) { const { t } = useLingui(); const buttonRef = useRef(null); const [placement, setPlacement] = useState<{ top: number; left: number; above: boolean; } | null>(null); useLayoutEffect(() => { const update = () => { if (range.collapsed || !document.contains(range.commonAncestorContainer)) { setPlacement(null); return; } const rect = range.getBoundingClientRect(); const width = buttonRef.current?.offsetWidth ?? 0; const height = buttonRef.current?.offsetHeight ?? 0; const above = rect.top >= height + 8; setPlacement({ top: above ? rect.top - 8 : rect.bottom + 8, left: Math.min( Math.max(rect.left + rect.width / 2, width / 2 + 8), window.innerWidth - width / 2 - 8, ), above, }); }; update(); window.addEventListener("resize", update); // Scroll doesn't bubble — listen on the capture phase to catch any scroller. window.addEventListener("scroll", update, { capture: true, passive: true }); return () => { window.removeEventListener("resize", update); window.removeEventListener("scroll", update, true); }; }, [range]); return createPortal( , document.body, ); }); function MessageHoverActions({ message, side, onReply, onReact, }: { message: ThreadMessage; side: "start" | "end"; onReply: (message: ThreadMessage) => void; onReact: (message: ThreadMessage, reaction: MessageReaction) => Promise; }) { const { t } = useLingui(); const [moreOpen, setMoreOpen] = useState(false); const [reactionsOpen, setReactionsOpen] = useState(false); // Streaming progress bubbles keep hover free for selection / stop clicks. if (message.id.startsWith("progress:")) return null; function copyMessage() { const text = copyableMessageText(message); if (!text || !navigator.clipboard) return; void navigator.clipboard.writeText(text).catch(() => undefined); } const iconButtonClass = "grid size-control-sm place-items-center text-muted-foreground transition-colors hover:text-foreground"; return (
{canReactToThreadMessage(message) ? ( {MESSAGE_REACTIONS.map((emoji) => ( ))} ) : null} onReply(message)} > Reply Copy
); } const MessageView = memo(function MessageView({ artifactTarget, onOpenArtifact, canAnswer, message, foldedSteps, onAnswer, onCardAct, onOpenBot, onOpenGroup, onOpenPeerMessages, speakerName, memberName, peerBot, replyPreview, replyToMessageId, onJumpToMessage, onRefresh, onBotChanged, onAddRoutine, voiceReady, speaking, onSpeak, encounterMode = false, }: { artifactTarget: ArtifactTarget; onOpenArtifact?: (artifact: WorkspaceArtifact) => void; canAnswer: boolean; message: ThreadMessage; foldedSteps?: readonly ThreadMessage[]; onAnswer: (message: ThreadMessage, text: string) => Promise; onCardAct?: (message: ThreadMessage, cardId: string, act: CardAct) => Promise; onOpenBot: (botId: string) => void; onOpenGroup: (groupId: string) => void; onOpenPeerMessages: (peer: { peerBotId: string; peerBotName: string }) => void; speakerName?: string; memberName?: (botId: string | undefined) => string | undefined; peerBot: (botId: string) => { color: string; status?: string } | undefined; replyPreview?: ThreadMessage; replyToMessageId?: string; onJumpToMessage?: (messageId: string) => void; onRefresh: () => Promise; onBotChanged: () => Promise; onAddRoutine: (name: string, prompt: string) => void; voiceReady: boolean; speaking: boolean; onSpeak: () => void; encounterMode?: boolean; }) { const { t } = useLingui(); const isNarration = message.role === "bot" && message.blocks.length > 0 && message.blocks.every( (block) => block.kind === "text" || block.kind === "progress" || block.kind === "steps", ); const isLive = message.id.startsWith("progress:"); const hasSteps = Boolean(foldedSteps && foldedSteps.length > 0) || message.blocks.some((b) => b.kind === "steps"); const visibleNarrationBlocks = message.blocks .filter((block) => !isToolActivityBlock(block)) .filter((block) => { // Discard broken, truncated text tokens like "Let" or "Ke" when steps exist on this message if (hasSteps && block.kind === "text") { const trimmed = block.text.trim(); if (trimmed.length <= 3 && !/[.!?:]/.test(trimmed)) { return false; } } return true; }); const parentJumpId = replyPreview?.id ?? replyToMessageId; const speakerBot = message.botId ? peerBot?.(message.botId) : undefined; const speakerColorDef = useMemo( () => resolveOoColor(message.botId ?? "bot", speakerBot?.color), [message.botId, speakerBot?.color], ); const effectiveSpeakerName = speakerName || (message.role === "bot" ? "evOo" : null); const messageContext = ( <> {effectiveSpeakerName && message.role === "bot" && !encounterMode ? (
{effectiveSpeakerName} Oo
{new Date(message.createdAt).toLocaleTimeString([], { hour: "2-digit", minute: "2-digit", })}
) : effectiveSpeakerName && message.role === "bot" && encounterMode ? (
{effectiveSpeakerName} ·
) : speakerName ? (
{speakerName}
) : null} {parentJumpId ? ( ) : null} ); if (isNarration) { if (visibleNarrationBlocks.length === 0) return null; return ( <> {messageContext}
{visibleNarrationBlocks.map((block, i) => { if (block.kind === "text" || block.kind === "progress") { return (
{block.text}
); } return null; })} {!isLive && voiceReady && message.blocks.some((block) => block.kind === "text") ? ( ) : null}
); } return ( <> {messageContext} {message.blocks.map((block, i) => { if (isToolActivityBlock(block)) return null; if (block.kind === "handoff") { const from = memberName?.(block.fromBotId) ?? t`bot`; const to = memberName?.(block.toBotId) ?? t`bot`; return (
↪ {to} ← {from} {block.text}
); } if (block.kind === "bot_message_sent") { const peer = block.toBotName; const peerBotId = block.toBotId; const botInfo = peerBot(peerBotId); return ( onOpenPeerMessages({ peerBotId, peerBotName: peer })} /> ); } if (block.kind === "task") { return ( onOpenBot(block.assigneeBotId)} /> ); } if (block.kind === "bot_message_received") { const peer = block.fromBotName; const peerBotId = block.fromBotId; const label = t`Message from ${peer}`; return ( onOpenPeerMessages({ peerBotId, peerBotName: peer })} /> ); } if (block.kind === "channel_message") { return (
{messageProviderLabel(block.provider, block.transport)} · {block.fromLabel}:{" "} {block.text}
); } if (block.kind === "meta") { return (
◷ {block.text}
); } if (block.kind === "progress") { return (
{block.text}
); } if (block.kind === "subagent") { const running = block.status === "running"; const failed = block.status === "failed"; return (
{block.name}
{running ? t`subagent` : block.status}
{block.task}
{block.progress || block.result ? (
{block.result || block.progress || ""}
) : null}
); } if (block.kind === "project_created") { return ( onOpenGroup(block.groupId)} /> ); } if (block.kind === "child_bot") { const removed = block.status === "deleted" || block.status === "archived"; return ( ); } if (block.kind === "choice") { const botId = "botId" in artifactTarget ? artifactTarget.botId : message.botId; if (!botId) return null; return ; } if (block.kind === "app_connect") { const botId = "botId" in artifactTarget ? artifactTarget.botId : message.botId; if (!botId) return null; return (
); } if (block.kind === "chart") { return (
); } if (block.kind === "mcp_approval") { return (
); } if (block.kind === "image") { return (
); } if (block.kind === "video" || block.kind === "audio") { return (
); } if (block.kind === "file") { return (
onOpenArtifact({ messageId: message.id, artifactId: block.artifactId, name: block.name, mimeType: block.mimeType, }) : undefined } />
); } if (block.kind === "text" && message.role === "user") { const isOptimistic = message.id.startsWith("temp-"); return (
{block.text} {isOptimistic ? ( ) : null}
); } if (block.kind === "text") { return (
{block.text}
{voiceReady ? ( ) : null}
{new Date(message.createdAt).toLocaleTimeString([], { hour: "2-digit", minute: "2-digit", })}
); } if (block.kind === "card" && block.sections) { const cardId = block.id; return (
onCardAct(message, cardId, act) : undefined} />
); } if (block.kind === "card") { return (
{(block.lines ?? []).map((line) => (
✓ {line.k} → {line.v}
))}
); } if (block.kind === "ask") { return ( onAnswer(message, text)} /> ); } if (block.kind === "cloud_agent") return ; if (block.kind === "media") { return ; } if (block.kind === "skill_draft") { return (
); } if (block.kind === "computer") { return (
Computer {block.state === "Needs you" ? t`Needs you` : block.state}
{block.text}
); } return null; })} ); });