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 { OoMoment, WorkspaceArtifact } from "@rakazo/core"; import { coalesceRunAttachmentMessages, foldRunNarration, isPeerReceiptBlocks, isRoutineEventMessage, isToolActivityBlock, projectMessageReactions, resolveOoColor, splitReplySummaryAndDetails, } from "@rakazo/core"; import type { EvooPose, GroupAvatarMember } from "@rakazo/ui-web"; import { BotAvatar, cn, DropdownMenu, DropdownMenuContent, DropdownMenuItem, DropdownMenuTrigger, Popover, PopoverContent, PopoverTrigger, } from "@rakazo/ui-web"; import { ArrowDown, ChevronDown, ChevronRight, Copy, MoreHorizontal, Reply, Smile, TextQuote, Volume2, X, } 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 { Shimmer } from "../../components/ai/primitives"; 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 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, ArtifactGallery, ChartBlockView, ChoiceCard, ExternalLinksBar, McpApprovalCard, PlanCard, } from "./message-cards"; import { previewMessageText } from "./message-preview"; import { RoutineEventLine } from "./routine-event-line"; import { 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 Extract< ThreadMessage["blocks"][number], { kind: "text" | "bot_message_received" } > => b.kind === "text" || b.kind === "bot_message_received", ) .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, onSendQueuedNow, onRemoveQueuedMessage, }: { 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; onSendQueuedNow?: (message: ThreadMessage) => void | Promise; onRemoveQueuedMessage?: (message: ThreadMessage) => void | Promise; }) { const { t } = useLingui(); const [restartingPrompt, setRestartingPrompt] = useState(null); const [sendingQueuedId, setSendingQueuedId] = 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 handleSendQueuedNow = useCallback( async (message: ThreadMessage) => { if (!onSendQueuedNow || sendingQueuedId) return; setSendingQueuedId(message.id); try { await onSendQueuedNow(message); } finally { setSendingQueuedId(null); } }, [onSendQueuedNow, sendingQueuedId], ); const [removingQueuedId, setRemovingQueuedId] = useState(null); const handleRemoveQueued = useCallback( async (message: ThreadMessage) => { if (!onRemoveQueuedMessage || removingQueuedId) return; setRemovingQueuedId(message.id); try { await onRemoveQueuedMessage(message); } finally { setRemovingQueuedId(null); } }, [onRemoveQueuedMessage, removingQueuedId], ); 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 coalescedMessages = useMemo( () => coalesceRunAttachmentMessages( reactionView.visibleMessages, new Set(reactionView.reactions.keys()), ), [reactionView.visibleMessages, reactionView.reactions], ); const runNarration = useMemo(() => foldRunNarration(coalescedMessages), [coalescedMessages]); const trailingAnsweringBots = useMemo(() => { const liveProgressMessages = coalescedMessages.filter( (message) => !runNarration.hidden.has(message.id) && message.id.startsWith("progress:"), ); const hasUnattributedLiveProgress = liveProgressMessages.some((message) => !message.botId); const liveProgressBotIds = new Set( liveProgressMessages .map((message) => message.botId) .filter((id): id is string => Boolean(id)), ); if (workingBots.length > 0) { return workingBots.filter((bot, index) => { if (index === 0 && hasUnattributedLiveProgress) return false; return !bot.botId || !liveProgressBotIds.has(bot.botId); }); } if (running && liveProgressMessages.length === 0) { return [ { botId: "", name: memberName?.(undefined) || "evOo", color: FALLBACK_BOT_COLOR, }, ]; } return []; }, [memberName, coalescedMessages, runNarration.hidden, running, workingBots]); const activeTurnStartAt = useMemo(() => { const msgs = coalescedMessages; let lastUserCreatedAt: string | undefined; for (let i = msgs.length - 1; i >= 0; i--) { if (msgs[i]?.role === "user") { lastUserCreatedAt = msgs[i]?.createdAt; break; } } if (!runStartedAt) return lastUserCreatedAt; if (!lastUserCreatedAt) return runStartedAt; const runMs = Date.parse(runStartedAt); const userMs = Date.parse(lastUserCreatedAt); if (Number.isNaN(runMs)) return lastUserCreatedAt; if (Number.isNaN(userMs)) return runStartedAt; return userMs > runMs ? lastUserCreatedAt : runStartedAt; }, [coalescedMessages, runStartedAt]); const activeTurnSteps = useMemo(() => { const msgs = coalescedMessages; let lastUserIdx = -1; for (let i = msgs.length - 1; i >= 0; i--) { if (msgs[i]?.role === "user") { lastUserIdx = i; break; } } return msgs .slice(lastUserIdx + 1) .filter((m) => m.role === "bot" && !m.id.startsWith("progress:")); }, [coalescedMessages]); const latestPersistedBotMessageId = useMemo(() => { for (let i = coalescedMessages.length - 1; i >= 0; i--) { const m = coalescedMessages[i]; if ( m && m.role === "bot" && !m.id.includes(":") && !runNarration.hidden.has(m.id) && !isPeerReceiptBlocks(m.blocks) && !isRoutineEventMessage(m.blocks) ) { return m.id; } } return null; }, [coalescedMessages, runNarration.hidden]); const latestLargeBotMessageId = useMemo(() => { for (let i = coalescedMessages.length - 1; i >= 0; i--) { const m = coalescedMessages[i]; if ( m && m.role === "bot" && !m.id.startsWith("progress:") && !runNarration.hidden.has(m.id) && !isPeerReceiptBlocks(m.blocks) && !isRoutineEventMessage(m.blocks) && m.blocks.some( (b) => b.kind === "text" && splitReplySummaryAndDetails(b.text).details !== null, ) ) { return m.id; } } return null; }, [coalescedMessages, runNarration.hidden]); const [expandedOverrides, setExpandedOverrides] = useState>({}); const prevLatestLargeBotMessageIdRef = useRef(null); useEffect(() => { if ( latestLargeBotMessageId && prevLatestLargeBotMessageIdRef.current && latestLargeBotMessageId !== prevLatestLargeBotMessageIdRef.current ) { setExpandedOverrides({}); } prevLatestLargeBotMessageIdRef.current = latestLargeBotMessageId; }, [latestLargeBotMessageId]); const [celebratingMessageId, setCelebratingMessageId] = useState(null); const prevRunningRef = useRef(running); const prevBotMsgIdRef = useRef(undefined); const joinedMessageIds = useMemo(() => { const ids = new Set(); const seenBots = new Set(); let lastBotId: string | undefined; for (const m of coalescedMessages) { if (runNarration.hidden.has(m.id)) continue; if (m.role !== "bot" || !m.botId) continue; const isNewBot = !seenBots.has(m.botId); const isSpeakerSwitch = Boolean(lastBotId && lastBotId !== m.botId); if ((isNewBot && seenBots.size > 0) || isSpeakerSwitch) { ids.add(m.id); } seenBots.add(m.botId); lastBotId = m.botId; } return ids; }, [coalescedMessages, runNarration.hidden]); useEffect(() => { const wasRunning = prevRunningRef.current; const prevId = prevBotMsgIdRef.current; prevRunningRef.current = running; prevBotMsgIdRef.current = latestPersistedBotMessageId; if (prevId === undefined) return; const newReplyArrived = Boolean(latestPersistedBotMessageId) && latestPersistedBotMessageId !== prevId; const runJustFinished = wasRunning && !running && Boolean(latestPersistedBotMessageId); if (!running && (newReplyArrived || runJustFinished) && latestPersistedBotMessageId) { setCelebratingMessageId(latestPersistedBotMessageId); const timer = window.setTimeout(() => { setCelebratingMessageId((cur) => (cur === latestPersistedBotMessageId ? null : cur)); }, 3200); return () => window.clearTimeout(timer); } }, [running, latestPersistedBotMessageId]); const transcriptSubordinate = encounterMode && voiceEncounterActive; 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-x-hidden 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}
{coalescedMessages.map((message) => { if (runNarration.hidden.has(message.id)) return null; const foldedSteps = runNarration.steps.get(message.id); if ( !message.id.startsWith("progress:") && !foldedSteps?.length && !message.blocks.some((block) => !isToolActivityBlock(block)) ) return null; 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(coalescedMessages, message.id); if (task) void handleRestart(task); } : undefined } restarting={Boolean( restartingPrompt && restartingPrompt === findPrecedingUserTask(coalescedMessages, message.id), )} /> ) : null} {!peerReceipt && !message.id.startsWith("progress:") ? ( ) : null}
onSpeak(message)} detailsExpanded={ expandedOverrides[message.id] ?? message.id === latestLargeBotMessageId } onToggleDetails={() => { const currentlyExpanded = expandedOverrides[message.id] ?? message.id === latestLargeBotMessageId; setExpandedOverrides((prev) => ({ ...prev, [message.id]: !currentlyExpanded, })); }} /> {queuedIds.has(message.id) ? (
{t`Queued`} {onSendQueuedNow ? ( ) : null} {onRemoveQueuedMessage ? ( ) : null}
) : null} {!peerReceipt && messageReactions ? (
{[...messageReactions].map(([emoji, count]) => ( {emoji} {count > 1 ? ` ${count}` : ""} ))}
) : null} {peerReceipt ? null : ( )}
); })} {trailingAnsweringBots.map((bot) => ( ))}
{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
); } type LiveBotPhaseKind = | "thinking" | "reading" | "working" | "cooking" | "speaking" | "waiting" | "concerned"; function resolveLiveBotPhase({ message, foldedSteps, botStatus, elapsedSec, }: { message?: ThreadMessage; foldedSteps?: readonly ThreadMessage[]; botStatus?: string; elapsedSec: number; }): { moment: OoMoment; pose: EvooPose; kind: LiveBotPhaseKind } { if (botStatus === "error" || botStatus === "failed") { return { moment: "concerned", pose: "error", kind: "concerned" }; } if ( botStatus === "waiting_input" || botStatus === "waiting_takeover" || message?.blocks.some((b) => b.kind === "ask") ) { return { moment: "waiting", pose: "waiting", kind: "waiting" }; } // Always begin with at least 3s of thinking ("denkt nach") right after sending, // before transitioning to tool-specific phases or speaking. if (elapsedSec < 3) { return { moment: "thinking", pose: "thinking", kind: "thinking" }; } const blocks = message?.blocks ?? []; const hasActiveTool = blocks.some( (b) => b.kind === "progress" && (b.activity === true || (b.pendingToolNames && b.pendingToolNames.length > 0)), ); const streamingReplyText = blocks .filter( (b): b is Extract => (b.kind === "progress" && !b.activity) || b.kind === "text", ) .map((b) => b.text.trim()) .join(" ") .trim(); if (streamingReplyText.length > 20 && !hasActiveTool) { return { moment: "speaking", pose: "speaking", kind: "speaking" }; } const toolNames = blocks.flatMap((b) => b.kind === "progress" ? [...(b.pendingToolNames ?? []), b.activity ? b.text : ""] : [], ); const stepLabels = blocks.flatMap((b) => (b.kind === "steps" ? b.steps.map((s) => s.label) : [])); const lastFoldedStep = foldedSteps?.at(-1); const lastStepText = lastFoldedStep?.blocks .filter((b): b is { kind: "text"; text: string } => b.kind === "text") .map((b) => b.text) .join(" ") ?? ""; const signal = [...toolNames, ...stepLabels, lastStepText].join(" ").toLowerCase(); if ( /\b(write|edit|patch|create|generate|build|compile|artifact|project_created|child_bot|skill_draft|erstell|baue|entwurf)\b/.test( signal, ) || blocks.some( (b) => b.kind === "project_created" || b.kind === "child_bot" || b.kind === "skill_draft" || b.kind === "file" || b.kind === "image", ) ) { return { moment: "cooking", pose: "working", kind: "cooking" }; } if ( botStatus === "booting" || /\b(read|cat|head|tail|grep|rg|glob|find|ls|list|search|web|browser|fetch|http|url|inspect|view|lese|suche|recherch|prüfe|analysier)\b/.test( signal, ) ) { return { moment: "reading", pose: "reading", kind: "reading" }; } const hasDelegationOrToolWork = hasActiveTool || stepLabels.length > 0 || (foldedSteps && foldedSteps.length > 0) || blocks.some( (b) => b.kind === "subagent" || b.kind === "task" || b.kind === "handoff" || b.kind === "bot_message_sent" || b.kind === "cloud_agent" || b.kind === "computer", ) || /\b(bash|sh|zsh|shell|exec|run|command|terminal|git|ssh|scp|docker|pnpm|npm|test|deploy)\b/.test( signal, ); if (hasDelegationOrToolWork) { return { moment: "working", pose: "working", kind: "working" }; } // Natural progression when a run runs without explicit intermediate tool events: // 0–3s: thinking ("denkt nach") -> 4–8s: working ("arbeitet") -> 9s+: speaking ("antwortet") if (elapsedSec >= 9 || streamingReplyText.length > 0) { return { moment: "speaking", pose: "speaking", kind: "speaking" }; } if (elapsedSec >= 4) { return { moment: "working", pose: "working", kind: "working" }; } return { moment: "thinking", pose: "thinking", kind: "thinking" }; } function OoHoleEntrance({ color, children }: { color: string; children: React.ReactNode }) { return (