evOo-local/apps/web/src/pages/shell/transcript.tsx

2191 lines
80 KiB
TypeScript
Raw Blame History

This file contains ambiguous Unicode characters

This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.

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<HTMLDivElement | null>;
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<void>;
onOpenBot: (botId: string) => void;
onOpenGroup: (groupId: string) => void;
onAnswer: (message: ThreadMessage, text: string) => Promise<void>;
onCardAct?: (message: ThreadMessage, cardId: string, act: CardAct) => Promise<void>;
onAutopilot?: (action: AutopilotAction) => Promise<void>;
onReply: (message: ThreadMessage) => void;
onQuote: (message: ThreadMessage, quote: string) => void;
onReact: (message: ThreadMessage, reaction: MessageReaction) => Promise<void>;
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<void>;
onBotChanged: () => Promise<void>;
onAddRoutine: (name: string, prompt: string) => void;
voiceReady: boolean;
speakingMessageId: string | null;
onSpeak: (message: ThreadMessage) => void;
encounterMode?: boolean;
voiceEncounterActive?: boolean;
onRestartTask?: (taskPrompt: string) => void | Promise<void>;
onSendQueuedNow?: (message: ThreadMessage) => void | Promise<void>;
onRemoveQueuedMessage?: (message: ThreadMessage) => void | Promise<void>;
}) {
const { t } = useLingui();
const [restartingPrompt, setRestartingPrompt] = useState<string | null>(null);
const [sendingQueuedId, setSendingQueuedId] = useState<string | null>(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<string | null>(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<number | null>(null);
const autoScrollTimer = useRef<number | undefined>(undefined);
const jumpButtonRef = useRef<HTMLButtonElement>(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<Record<string, boolean>>({});
const prevLatestLargeBotMessageIdRef = useRef<string | null>(null);
useEffect(() => {
if (
latestLargeBotMessageId &&
prevLatestLargeBotMessageIdRef.current &&
latestLargeBotMessageId !== prevLatestLargeBotMessageIdRef.current
) {
setExpandedOverrides({});
}
prevLatestLargeBotMessageIdRef.current = latestLargeBotMessageId;
}, [latestLargeBotMessageId]);
const [celebratingMessageId, setCelebratingMessageId] = useState<string | null>(null);
const prevRunningRef = useRef(running);
const prevBotMsgIdRef = useRef<string | null | undefined>(undefined);
const joinedMessageIds = useMemo(() => {
const ids = new Set<string>();
const seenBots = new Set<string>();
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<HTMLElement>(
"[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<HTMLElement>(
`[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<HTMLElement>("[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.
<div className="relative flex min-h-0 flex-1 overflow-hidden">
<div
ref={scrollRef}
data-testid="transcript"
// A tap at the latest message keeps following; dragging away stops it in onScroll.
onPointerDown={(event) => {
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 ? (
<button
type="button"
disabled={loadingOlder}
onClick={() => void loadOlder()}
className="self-center rounded-lg px-3 py-1.5 text-ui-body-sm text-muted-foreground hover:bg-muted hover:text-foreground/75 disabled:opacity-50"
>
{loadingOlder ? t`Loading…` : t`Load earlier messages`}
</button>
) : null}
<div
className={`mx-auto flex w-full flex-1 flex-col pb-6 ${
encounterMode ? "max-w-2xl gap-3" : "max-w-4xl gap-6 pb-8"
}`}
>
{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 (
<div key={message.id} data-message-id={message.id}>
{message.blocks.map((block, index) =>
block.kind === "event" ? (
<RoutineEventLine key={index} block={block} />
) : block.kind === "autopilot" ? (
<AutopilotLine key={index} block={block} onAction={onAutopilot} />
) : null,
)}
</div>
);
}
const messageReactions = reactionView.reactions.get(message.id);
return (
<div
key={message.id}
data-message-id={message.id}
// Only persisted messages can be reply targets; synthetic rows
// (progress:, subagent:) carry a `prefix:` id.
data-quotable={message.id.includes(":") ? undefined : ""}
className={peerReceipt ? "relative py-0.5" : "group/message relative hover:z-20"}
>
{foldedSteps ? (
<RunSteps
bot={
message.botId
? peerBot?.(message.botId)
? {
id: message.botId,
name: memberName?.(message.botId) || "Steve Jobs",
color: peerBot(message.botId)?.color,
status: peerBot(message.botId)?.status,
}
: workingBots[0]
: workingBots[0]
}
taskTitle={findPrecedingUserTask(coalescedMessages, message.id)}
steps={foldedSteps}
status={
message.id.startsWith("progress:")
? "running"
: isInterruptedStepMessage(message)
? "interrupted"
: "completed"
}
onRestart={
isInterruptedStepMessage(message) && onRestartTask
? () => {
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:") ? (
<time
dateTime={message.createdAt}
data-testid="message-hover-time"
className={cn(
"pointer-events-none absolute top-1 z-10 text-ui-label tabular-nums text-muted-foreground opacity-0 transition-opacity group-hover/message:opacity-100 group-focus-within/message:opacity-100 group-has-[[aria-expanded=true]]/message:opacity-100",
message.role === "user" ? "start-0" : "end-0",
)}
>
{new Date(message.createdAt).toLocaleTimeString(i18n.locale || "en", {
hour: "numeric",
minute: "2-digit",
})}
</time>
) : null}
<div
className={
peerReceipt
? undefined
: `relative flex ${message.role === "user" ? "justify-end" : "justify-start"}`
}
>
<div
data-testid={peerReceipt ? undefined : "message-bubble-frame"}
className={
peerReceipt
? undefined
: `relative min-w-0 ${
message.role === "user"
? encounterMode
? "w-fit max-w-[92%] md:max-w-[min(78%,calc(100%_-_3rem))]"
: "w-fit max-w-[88%] md:max-w-[min(72%,calc(100%_-_5rem))]"
: "w-full max-w-full"
}`
}
>
<MessageView
artifactTarget={artifactTarget}
onOpenArtifact={onOpenArtifact}
message={message}
foldedSteps={foldedSteps}
runStartedAt={activeTurnStartAt}
celebrating={message.id === celebratingMessageId}
speakerJoined={joinedMessageIds.has(message.id)}
canAnswer={message.id === answerableAskMessageId}
onOpenBot={onOpenBot}
onOpenGroup={onOpenGroup}
onOpenPeerMessages={onOpenPeerMessages}
onAnswer={onAnswer}
onCardAct={onCardAct}
encounterMode={encounterMode}
speakerName={
peerReceipt
? undefined
: message.role === "bot"
? (memberName?.(message.botId) ?? workingBots[0]?.name)
: undefined
}
memberName={memberName}
peerBot={peerBot}
replyPreview={
message.replyToMessageId
? messageById.get(message.replyToMessageId)
: undefined
}
replyToMessageId={message.replyToMessageId}
onJumpToMessage={onJumpToMessage}
onRefresh={onRefresh}
onBotChanged={onBotChanged}
onAddRoutine={onAddRoutine}
voiceReady={voiceReady}
speaking={speakingMessageId === message.id}
onSpeak={() => 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) ? (
<div
data-testid="message-queued"
className="mt-1 flex items-center justify-end gap-2 text-end text-ui-label text-muted-foreground"
>
<span>{t`Queued`}</span>
{onSendQueuedNow ? (
<button
type="button"
data-testid="message-send-now"
disabled={sendingQueuedId === message.id}
onClick={() => void handleSendQueuedNow(message)}
className="font-medium text-foreground underline-offset-4 transition-colors hover:underline disabled:opacity-50"
>
{t`Send now`}
</button>
) : null}
{onRemoveQueuedMessage ? (
<button
type="button"
data-testid="message-remove-queued"
disabled={removingQueuedId === message.id}
onClick={() => void handleRemoveQueued(message)}
className="flex size-6 shrink-0 items-center justify-center rounded-md text-muted-foreground transition-colors hover:bg-destructive/10 hover:text-destructive disabled:opacity-50"
aria-label={t`Remove from queue`}
title={t`Remove from queue`}
>
<X className="size-4" />
</button>
) : null}
</div>
) : null}
{!peerReceipt && messageReactions ? (
<div
data-testid="message-reactions"
className={cn(
"mt-1 flex flex-wrap gap-1",
message.role === "user" ? "justify-end" : "justify-start",
)}
>
{[...messageReactions].map(([emoji, count]) => (
<span
key={emoji}
className="rounded-full border border-border bg-muted px-2 py-0.5 text-ui-label"
>
{emoji}
{count > 1 ? ` ${count}` : ""}
</span>
))}
</div>
) : null}
{peerReceipt ? null : (
<MessageHoverActions
message={message}
side={message.role === "user" ? "end" : "start"}
onReply={onReply}
onReact={onReact}
/>
)}
</div>
</div>
</div>
);
})}
{trailingAnsweringBots.map((bot) => (
<BotAnsweringRow
key={`answering:${bot.botId || "active"}`}
botId={bot.botId || undefined}
botName={bot.name || memberName?.(bot.botId || undefined) || "evOo"}
color={bot.color}
botStatus={bot.status ?? (bot.botId ? peerBot?.(bot.botId)?.status : undefined)}
foldedSteps={activeTurnSteps}
runStartedAt={activeTurnStartAt}
onOpenBot={onOpenBot}
/>
))}
</div>
</div>
{quoteDraft ? (
<QuoteSelectionButton
range={quoteDraft.range}
onQuote={() => {
onQuote(quoteDraft.message, quoteDraft.text);
window.getSelection()?.removeAllRanges();
setQuoteDraft(null);
}}
/>
) : null}
<button
ref={jumpButtonRef}
type="button"
aria-label={t`Jump to latest`}
aria-hidden={!showJumpToLatest}
tabIndex={showJumpToLatest ? 0 : -1}
onClick={jumpToLatest}
className={`absolute bottom-4 left-1/2 z-20 grid h-9 w-9 -translate-x-1/2 place-items-center rounded-full border border-border bg-muted/95 text-foreground/75 shadow-md backdrop-blur transition-[opacity,transform,background-color] duration-200 ease-[cubic-bezier(.22,1,.36,1)] hover:bg-border motion-reduce:transition-none ${
showJumpToLatest
? "translate-y-0 opacity-100"
: "pointer-events-none translate-y-2 opacity-0"
}`}
>
<ArrowDown size={17} strokeWidth={1.8} />
</button>
{askOffscreen ? (
<button
type="button"
data-testid="needs-you-jump"
onClick={jumpToAsk}
className="absolute bottom-4 left-1/2 z-20 inline-flex h-9 -translate-x-1/2 items-center gap-1.5 rounded-full border border-border bg-muted/95 px-3.5 text-ui-body-sm font-medium text-foreground shadow-md backdrop-blur hover:bg-border"
>
<span aria-hidden="true" className="size-2 rounded-full bg-warning" />
{t`Needs you`}
</button>
) : null}
</div>
);
});
/**
* 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<HTMLButtonElement>(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(
<button
ref={buttonRef}
type="button"
data-quote-selection
data-testid="quote-selection"
onMouseDown={(event) => {
// Keep the highlight alive until the click commits the quote.
event.preventDefault();
event.stopPropagation();
}}
onClick={onQuote}
style={placement ? { top: placement.top, left: placement.left } : { visibility: "hidden" }}
className={cn(
"fixed z-50 flex -translate-x-1/2 items-center gap-1.5 rounded-full border border-border bg-background px-3 py-1.5 text-ui-body-sm font-medium text-foreground shadow-md hover:bg-muted",
placement?.above === false ? "translate-y-0" : "-translate-y-full",
)}
>
<TextQuote strokeWidth={2} className="size-icon-sm" />
{t`Quote`}
</button>,
document.body,
);
});
function MessageHoverActions({
message,
side,
onReply,
onReact,
}: {
message: ThreadMessage;
side: "start" | "end";
onReply: (message: ThreadMessage) => void;
onReact: (message: ThreadMessage, reaction: MessageReaction) => Promise<void>;
}) {
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 (
<MessageHoverMetadata pinned={moreOpen || reactionsOpen} side={side}>
<div data-testid="message-hover-actions" className="flex items-center gap-0.5">
{canReactToThreadMessage(message) ? (
<Popover open={reactionsOpen} onOpenChange={setReactionsOpen}>
<PopoverTrigger
aria-label={t`React`}
className={cn(
iconButtonClass,
"h-11 w-11 [@media(hover:hover)_and_(pointer:fine)]:h-7 [@media(hover:hover)_and_(pointer:fine)]:w-7",
)}
>
<Smile strokeWidth={1.7} className="size-icon-md" />
</PopoverTrigger>
<PopoverContent
align={side === "end" ? "end" : "start"}
className="w-auto flex-row gap-0 rounded-2xl p-1.5"
aria-label={t`Reactions`}
>
{MESSAGE_REACTIONS.map((emoji) => (
<button
key={emoji}
type="button"
aria-label={emoji}
className="grid h-11 w-11 place-items-center rounded-xl text-2xl hover:bg-accent focus-visible:outline-2 focus-visible:outline-ring"
onClick={() => {
setReactionsOpen(false);
void onReact(message, emoji);
}}
>
{emoji}
</button>
))}
</PopoverContent>
</Popover>
) : null}
<button
type="button"
aria-label={t`Reply`}
onClick={() => onReply(message)}
className={`${iconButtonClass} hidden [@media(hover:hover)_and_(pointer:fine)]:grid`}
>
<Reply strokeWidth={1.7} className="size-icon-md" />
</button>
<button
type="button"
aria-label={t`Copy`}
onClick={copyMessage}
className={`${iconButtonClass} hidden [@media(hover:hover)_and_(pointer:fine)]:grid`}
>
<Copy strokeWidth={1.7} className="size-icon-md" />
</button>
<DropdownMenu open={moreOpen} onOpenChange={setMoreOpen}>
<DropdownMenuTrigger
aria-label={t`More`}
className={cn(
iconButtonClass,
"h-11 w-11 [@media(hover:hover)_and_(pointer:fine)]:h-7 [@media(hover:hover)_and_(pointer:fine)]:w-7",
)}
>
<MoreHorizontal strokeWidth={1.7} className="size-icon-md" />
</DropdownMenuTrigger>
<DropdownMenuContent align={side === "end" ? "end" : "start"}>
<DropdownMenuItem
className="[@media(hover:hover)_and_(pointer:fine)]:hidden"
onClick={() => onReply(message)}
>
<Reply className="size-icon-md" />
<Trans>Reply</Trans>
</DropdownMenuItem>
<DropdownMenuItem onClick={copyMessage}>
<Copy strokeWidth={1.7} className="size-icon-md" />
<Trans>Copy</Trans>
</DropdownMenuItem>
</DropdownMenuContent>
</DropdownMenu>
</div>
</MessageHoverMetadata>
);
}
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<ThreadMessage["blocks"][number], { kind: "progress" | "text" }> =>
(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 (
<span
data-testid="oo-hole-entrance"
className="oo-hole-stage"
style={{ "--oo-hole-color": color } as React.CSSProperties}
>
<span aria-hidden="true" className="oo-hole-ripple" />
<span aria-hidden="true" className="oo-hole-pit" />
<span className="oo-hole-mask">
<span className="oo-hole-jumper">{children}</span>
</span>
</span>
);
}
function BotAnsweringRow({
botId,
botName,
color,
botStatus,
message,
foldedSteps,
runStartedAt,
onOpenBot,
}: {
botId?: string;
botName: string;
color?: string;
botStatus?: string;
message?: ThreadMessage;
foldedSteps?: readonly ThreadMessage[];
runStartedAt?: string;
onOpenBot: (botId: string) => void;
}) {
const { t } = useLingui();
const colorDef = useMemo(() => resolveOoColor(botId ?? "bot", color), [botId, color]);
const mountedAtRef = useRef(Date.now());
const [now, setNow] = useState(() => Date.now());
useEffect(() => {
const timer = window.setInterval(() => setNow(Date.now()), 1000);
return () => window.clearInterval(timer);
}, []);
const startMs = useMemo(() => {
if (runStartedAt) {
const parsed = Date.parse(runStartedAt);
if (!Number.isNaN(parsed)) return parsed;
}
if (message?.createdAt) {
const parsed = Date.parse(message.createdAt);
if (!Number.isNaN(parsed)) return parsed;
}
return mountedAtRef.current;
}, [runStartedAt, message?.createdAt]);
const elapsedSec = Math.max(0, Math.floor((now - startMs) / 1000));
const phase = useMemo(
() =>
resolveLiveBotPhase({
message,
foldedSteps,
botStatus,
elapsedSec,
}),
[message, foldedSteps, botStatus, elapsedSec],
);
const label =
phase.kind === "waiting"
? t`${botName} wartet auf deine Antwort`
: phase.kind === "concerned"
? t`${botName} prüft ein Problem`
: phase.kind === "speaking"
? t`${botName} antwortet`
: phase.kind === "cooking"
? t`${botName} baut & tüftelt`
: phase.kind === "reading"
? t`${botName} liest & recherchiert`
: phase.kind === "working"
? t`${botName} arbeitet`
: t`${botName} denkt nach`;
const resolvedColor = color || colorDef.hex;
return (
<div
data-testid="bot-answering-indicator"
data-phase={phase.kind}
className="my-1.5 flex items-center justify-start py-1"
>
<button
type="button"
onClick={() => botId && onOpenBot(botId)}
className="flex items-center gap-3 text-start hover:opacity-85 transition-opacity"
>
<OoHoleEntrance color={resolvedColor}>
<BotAvatar
color={resolvedColor}
identity={botId ?? "bot"}
size={58}
pose={phase.pose}
moment={phase.moment}
/>
</OoHoleEntrance>
<span className="text-ui-body font-semibold tracking-tight" dir="auto">
<Shimmer>{label}</Shimmer>
</span>
</button>
</div>
);
}
function BotTextBlockContent({
text,
streaming = false,
isLive = false,
detailsExpanded,
onToggleDetails,
}: {
text: string;
streaming?: boolean;
isLive?: boolean;
detailsExpanded: boolean;
onToggleDetails: () => void;
}) {
const { t } = useLingui();
const split = useMemo(
() =>
isLive || streaming ? { summary: text, details: null } : splitReplySummaryAndDetails(text),
[isLive, streaming, text],
);
if (!split.details) {
return (
<>
<ChatMarkdown streaming={streaming}>{text}</ChatMarkdown>
{!isLive ? <ExternalLinksBar text={text} /> : null}
</>
);
}
return (
<div className="space-y-2.5">
<ChatMarkdown>{split.summary}</ChatMarkdown>
<div
data-testid="message-collapsible-details"
data-expanded={detailsExpanded ? "true" : "false"}
className="rounded-2xl border border-border/80 bg-background/55 px-3.5 py-2.5 transition-colors"
>
<button
type="button"
data-testid="message-details-toggle"
aria-expanded={detailsExpanded}
onClick={onToggleDetails}
className="flex w-full items-center justify-between gap-2 text-start text-ui-body-sm font-medium text-muted-foreground hover:text-foreground transition-colors cursor-pointer"
>
<span className="inline-flex items-center gap-1.5">
{detailsExpanded ? (
<ChevronDown className="size-4 shrink-0" />
) : (
<ChevronRight className="size-4 shrink-0" />
)}
<span>{detailsExpanded ? t`Details ausblenden` : t`Details anzeigen`}</span>
</span>
</button>
{detailsExpanded ? (
<div
data-testid="message-details-content"
className="mt-2.5 border-t border-border/60 pt-2.5 space-y-2"
>
<ChatMarkdown>{split.details}</ChatMarkdown>
<ExternalLinksBar text={text} />
</div>
) : null}
</div>
</div>
);
}
const MessageView = memo(function MessageView({
artifactTarget,
onOpenArtifact,
canAnswer,
celebrating = false,
speakerJoined = false,
runStartedAt,
message,
foldedSteps,
onAnswer,
onCardAct,
onOpenBot,
onOpenGroup,
onOpenPeerMessages,
speakerName,
memberName,
peerBot,
replyPreview,
replyToMessageId,
onJumpToMessage,
onRefresh,
onBotChanged,
onAddRoutine,
voiceReady,
speaking,
onSpeak,
detailsExpanded = true,
onToggleDetails,
encounterMode = false,
}: {
artifactTarget: ArtifactTarget;
onOpenArtifact?: (artifact: WorkspaceArtifact) => void;
canAnswer: boolean;
celebrating?: boolean;
speakerJoined?: boolean;
runStartedAt?: string;
message: ThreadMessage;
foldedSteps?: readonly ThreadMessage[];
onAnswer: (message: ThreadMessage, text: string) => Promise<void>;
onCardAct?: (message: ThreadMessage, cardId: string, act: CardAct) => Promise<void>;
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<void>;
onBotChanged: () => Promise<void>;
onAddRoutine: (name: string, prompt: string) => void;
voiceReady: boolean;
speaking: boolean;
onSpeak: () => void;
detailsExpanded?: boolean;
onToggleDetails?: () => 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 completedMoment = useMemo<OoMoment>(() => {
const isErr =
isInterruptedStepMessage(message) ||
speakerBot?.status === "error" ||
speakerBot?.status === "failed";
if (isErr) return "concerned";
if (canAnswer) return "waiting";
if (celebrating) return "success";
const hasOffer = message.blocks.some(
(b) =>
b.kind === "choice" ||
b.kind === "skill_draft" ||
b.kind === "app_connect" ||
b.kind === "mcp_approval" ||
((b.kind === "file" || b.kind === "image") && b.present),
);
if (hasOffer) return "offering";
if (speakerJoined) return "greeting";
return "idle";
}, [message, speakerBot?.status, canAnswer, celebrating, speakerJoined]);
const imageGalleryItems = useMemo(
() =>
message.blocks
.filter(
(b): b is Extract<ThreadMessage["blocks"][number], { kind: "image" }> =>
b.kind === "image",
)
.map((b) => ({ artifactId: b.artifactId, name: b.name, mimeType: b.mimeType })),
[message.blocks],
);
const firstImageBlockIndex = useMemo(
() => message.blocks.findIndex((b) => b.kind === "image"),
[message.blocks],
);
if (isLive && (isNarration || visibleNarrationBlocks.length === 0)) {
return (
<BotAnsweringRow
botId={message.botId}
botName={effectiveSpeakerName || "evOo"}
color={speakerBot?.color || speakerColorDef.hex}
botStatus={speakerBot?.status}
message={message}
foldedSteps={foldedSteps}
runStartedAt={runStartedAt}
onOpenBot={onOpenBot}
/>
);
}
const messageContext = (
<>
{effectiveSpeakerName && message.role === "bot" ? (
isLive ? (
<BotAnsweringRow
botId={message.botId}
botName={effectiveSpeakerName}
color={speakerBot?.color || speakerColorDef.hex}
botStatus={speakerBot?.status}
message={message}
foldedSteps={foldedSteps}
runStartedAt={runStartedAt}
onOpenBot={onOpenBot}
/>
) : (
<div className="mb-2 flex items-center justify-between gap-3">
<button
type="button"
onClick={() => message.botId && onOpenBot(message.botId)}
className="flex items-center gap-2.5 text-start hover:opacity-85 transition-opacity"
>
{completedMoment === "success" ||
completedMoment === "offering" ||
completedMoment === "concerned" ||
completedMoment === "waiting" ||
speakerJoined ? (
<OoHoleEntrance color={speakerBot?.color || speakerColorDef.hex}>
<BotAvatar
color={speakerBot?.color || speakerColorDef.hex}
identity={message.botId ?? "bot"}
size={58}
moment={completedMoment}
/>
</OoHoleEntrance>
) : (
<div className="relative flex items-center justify-center size-8 rounded-full bg-foreground/[0.06] border border-border shadow-xs shrink-0">
<BotAvatar
color={speakerBot?.color || speakerColorDef.hex}
identity={message.botId ?? "bot"}
size={28}
moment="idle"
/>
</div>
)}
<div className="flex items-center gap-2">
<span
className="text-ui-body-sm font-semibold text-foreground tracking-tight"
dir="auto"
>
{effectiveSpeakerName}
</span>
{completedMoment === "success" ? (
<span className="rounded-full border border-success/30 bg-success/10 px-2 py-0.5 text-ui-label font-medium text-success">
{t`Fertig`}
</span>
) : speakerJoined ? (
<span className="rounded-full border border-primary/25 bg-primary/10 px-2 py-0.5 text-ui-label font-medium text-foreground/85">
{t`dazugeschaltet`}
</span>
) : (
<span className="rounded-full border border-border bg-muted px-2 py-0.5 text-ui-label font-medium text-muted-foreground">
Oo
</span>
)}
</div>
</button>
</div>
)
) : message.role === "user" && !encounterMode ? (
<div className="mb-1.5 flex items-center justify-end gap-2">
<span className="text-ui-body-sm font-semibold text-foreground tracking-tight">
{t`You`}
</span>
<div
data-testid="user-avatar-badge"
aria-hidden="true"
className="grid size-7 shrink-0 place-items-center rounded-full bg-primary text-ui-label font-bold text-primary-foreground shadow-xs"
>
{t`You`.slice(0, 1).toUpperCase()}
</div>
</div>
) : speakerName ? (
<div
className="mb-1.5 flex items-center gap-2 text-ui-body-sm font-semibold tracking-tight"
dir="auto"
style={{ color: speakerColorDef.light }}
>
<BotAvatar
color={speakerBot?.color ?? FALLBACK_BOT_COLOR}
identity={message.botId}
size={24}
/>
{speakerName}
</div>
) : null}
{parentJumpId ? (
<button
type="button"
data-testid="reply-parent-preview"
aria-label={t`Jump to replied message`}
onClick={() => onJumpToMessage?.(parentJumpId)}
className="mb-2 block max-w-[74%] truncate rounded-[14px] border border-border bg-background px-3 py-2 text-start text-ui-body-sm text-muted-foreground hover:border-border hover:text-foreground/75"
dir="auto"
>
{message.replyQuote
? `“${message.replyQuote}”`
: replyPreview
? previewMessageText(replyPreview)
: t`Earlier message`}
</button>
) : null}
</>
);
if (isNarration) {
if (visibleNarrationBlocks.length === 0) return null;
return (
<>
{messageContext}
<div className="flex w-fit max-w-full justify-start">
<div
data-testid="message-bot-bubble"
className="chat-bubble-bot max-w-full space-y-3 rounded-[24px] rounded-tl-md px-5 py-3.5 text-ui-body leading-[1.62] text-foreground/95"
dir="auto"
>
{visibleNarrationBlocks.map((block, i) => {
if (block.kind === "text" || block.kind === "progress") {
return (
<div key={i}>
<BotTextBlockContent
text={block.text}
streaming={block.kind === "progress"}
isLive={isLive || block.kind === "progress"}
detailsExpanded={detailsExpanded}
onToggleDetails={() => onToggleDetails?.()}
/>
</div>
);
}
return null;
})}
{!isLive && voiceReady && message.blocks.some((block) => block.kind === "text") ? (
<button
type="button"
aria-label={speaking ? t`Stop speaking` : t`Speak this reply`}
onClick={onSpeak}
className="mt-1 text-ui-body-sm font-medium text-muted-foreground hover:text-foreground transition-colors"
>
{speaking ? <Trans>Stop</Trans> : <Trans>Speak</Trans>}
</button>
) : null}
</div>
</div>
</>
);
}
return (
<>
{messageContext}
<div className="flex flex-col gap-3">
{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`;
const toColor = peerBot(block.toBotId)?.color ?? FALLBACK_BOT_COLOR;
return (
<div
key={i}
data-testid="handoff-hole-row"
className="my-2 flex items-center justify-start gap-3 rounded-2xl border border-border/70 bg-card/75 px-4 py-2.5 text-ui-body shadow-2xs"
>
<button
type="button"
onClick={() => onOpenBot(block.toBotId)}
className="flex items-center gap-3 text-start hover:opacity-85 transition-opacity"
>
<OoHoleEntrance color={toColor}>
<BotAvatar
color={toColor}
identity={block.toBotId}
size={50}
moment="greeting"
/>
</OoHoleEntrance>
<div className="min-w-0">
<div className="flex items-center gap-2">
<span className="font-semibold text-foreground" dir="auto">
{to}
</span>
<span className="rounded-full border border-primary/25 bg-primary/10 px-2 py-0.5 text-ui-label font-medium text-foreground/85">
{t`dazugeschaltet`}
</span>
<span className="text-ui-body-sm text-muted-foreground">← {from}</span>
</div>
{block.text ? (
<div className="mt-0.5 text-ui-body-sm text-muted-foreground" dir="auto">
{block.text}
</div>
) : null}
</div>
</button>
</div>
);
}
if (block.kind === "bot_message_sent") {
const peer = block.toBotName;
const peerBotId = block.toBotId;
const botInfo = peerBot(peerBotId);
return (
<SentMessageCard
key={i}
botId={peerBotId}
botName={peer}
color={botInfo?.color ?? FALLBACK_BOT_COLOR}
status={botInfo?.status}
text={block.text}
onClick={() => onOpenPeerMessages({ peerBotId, peerBotName: peer })}
/>
);
}
if (block.kind === "task") {
return (
<DelegatedTaskCard
key={i}
botId={block.assigneeBotId}
botName={block.assigneeBotName}
color={peerBot(block.assigneeBotId)?.color ?? FALLBACK_BOT_COLOR}
title={block.title}
text={block.text}
status={block.status}
onOpenBot={() => onOpenBot(block.assigneeBotId)}
/>
);
}
if (block.kind === "bot_message_received") {
const peer = block.fromBotName;
const peerBotId = block.fromBotId;
const label = t`Message from ${peer}`;
return (
<CollaborationMarker
key={i}
ariaLabel={label}
color={peerBot(peerBotId)?.color ?? FALLBACK_BOT_COLOR}
identity={peerBotId}
label={label}
onClick={() => onOpenPeerMessages({ peerBotId, peerBotName: peer })}
/>
);
}
if (block.kind === "channel_message") {
return (
<div
key={i}
className="flex items-center justify-center gap-2 py-1 text-ui-body text-muted-foreground"
>
<span>
{messageProviderLabel(block.provider, block.transport)} · {block.fromLabel}:{" "}
{block.text}
</span>
</div>
);
}
if (block.kind === "meta") {
return (
<div
key={i}
className="flex items-center justify-center gap-2 py-1 text-ui-body text-muted-foreground"
>
<span className="text-warning">◷</span>
<span>{block.text}</span>
</div>
);
}
if (block.kind === "progress") {
return null;
}
if (block.kind === "subagent") {
const running = block.status === "running";
const failed = block.status === "failed";
const subColor = resolveOoColor(block.agentId || block.name).hex;
return (
<div
key={i}
className="apple-glass-card w-[min(460px,95%)] rounded-[22px] px-5 py-4 transition-all"
>
<div className="flex items-center justify-between gap-3">
<div className="flex items-center gap-3 min-w-0">
<OoHoleEntrance color={subColor}>
<BotAvatar
color={subColor}
identity={block.agentId || block.name}
size={46}
moment={failed ? "concerned" : running ? "working" : "success"}
/>
</OoHoleEntrance>
<span
className="text-ui-body font-semibold text-foreground truncate"
dir="auto"
>
{block.name}
</span>
</div>
<span
className={`rounded-full px-3 py-0.5 text-ui-body-sm font-medium border ${
failed
? "border-destructive/30 bg-destructive/15 text-destructive"
: running
? "border-warning/30 bg-warning/15 text-warning"
: "border-success/30 bg-success/15 text-success"
}`}
style={{
animation: running ? "rkPulse 1.2s ease-in-out infinite" : undefined,
}}
>
{running ? t`subagent` : block.status}
</span>
</div>
<div className="mt-2 text-ui-body text-muted-foreground">{block.task}</div>
{block.result ? (
<div className="mt-2.5 text-ui-body-sm leading-[1.5] text-foreground/75">
<ChatMarkdown>{block.result}</ChatMarkdown>
</div>
) : null}
</div>
);
}
if (block.kind === "project_created") {
return (
<ProjectCreatedCard
key={i}
name={block.name}
memberCount={block.memberCount}
onClick={() => onOpenGroup(block.groupId)}
/>
);
}
if (block.kind === "child_bot") {
const removed = block.status === "deleted" || block.status === "archived";
const childColor =
peerBot(block.botId)?.color ?? resolveOoColor(block.botId || block.name).hex;
return (
<button
key={i}
type="button"
disabled={removed}
onClick={() => onOpenBot(block.botId)}
className="w-[min(360px,92%)] rounded-[18px] border border-border bg-muted px-[18px] py-4 text-start disabled:opacity-60"
>
<div className="flex items-center justify-between gap-3">
<div className="flex items-center gap-3 min-w-0">
{removed ? (
<BotAvatar
color={childColor}
identity={block.botId}
size={36}
moment="asleep"
/>
) : (
<OoHoleEntrance color={childColor}>
<BotAvatar
color={childColor}
identity={block.botId}
size={48}
moment="greeting"
/>
</OoHoleEntrance>
)}
<span className="text-ui-body font-medium text-foreground truncate" dir="auto">
{block.name}
</span>
</div>
<span
className={`rounded-full px-[11px] py-1 text-ui-body-sm ${
removed ? "bg-destructive/15 text-destructive" : "bg-success/15 text-success"
}`}
>
{block.status === "archived" ? (
<Trans>archived</Trans>
) : block.status === "deleted" ? (
<Trans>deleted</Trans>
) : (
<Trans>bot</Trans>
)}
</span>
</div>
<div className="mt-2 text-ui-body-sm leading-[1.5] text-foreground/75" dir="auto">
{removed
? block.status === "archived"
? t`Archived. Chat, memory, and files kept.`
: t`Removed with chat, computer, and memory.`
: block.title || t`Opened its thread.`}
</div>
</button>
);
}
if (block.kind === "choice") {
const botId = "botId" in artifactTarget ? artifactTarget.botId : message.botId;
if (!botId) return null;
return <ChoiceCard key={i} botId={botId} block={block} onBotChanged={onBotChanged} />;
}
if (block.kind === "app_connect") {
const botId = "botId" in artifactTarget ? artifactTarget.botId : message.botId;
if (!botId) return null;
return (
<div key={i} className="flex justify-start py-1">
<AppConnectCard botId={botId} block={block} />
</div>
);
}
if (block.kind === "chart") {
return (
<div key={i} className="flex justify-start">
<ChartBlockView name={block.name} spec={block.spec} data={block.data} />
</div>
);
}
if (block.kind === "mcp_approval") {
return (
<div key={i} className="flex justify-start">
<McpApprovalCard
botId={"botId" in artifactTarget ? artifactTarget.botId : message.botId}
name={block.name}
serverId={block.serverId}
transport={block.transport}
endpoint={block.endpoint}
needsOAuth={block.needsOAuth}
/>
</div>
);
}
if (block.kind === "image") {
if (i !== firstImageBlockIndex) return null;
return (
<div
key={i}
className={`flex ${message.role === "user" ? "justify-end" : "justify-start"}`}
>
<ArtifactGallery
items={imageGalleryItems}
align={message.role === "user" ? "end" : "start"}
/>
</div>
);
}
if (block.kind === "video" || block.kind === "audio") {
return (
<div
key={i}
className={`flex ${message.role === "user" ? "justify-end" : "justify-start"}`}
>
<MediaPlayer
artifactId={block.artifactId}
kind={block.kind}
name={block.name}
posterArtifactId={block.kind === "video" ? block.posterArtifactId : undefined}
width={block.kind === "video" ? block.width : undefined}
height={block.kind === "video" ? block.height : undefined}
/>
</div>
);
}
if (block.kind === "file") {
return (
<div
key={i}
className={`flex ${message.role === "user" ? "justify-end" : "justify-start"}`}
>
<ArtifactFileCard
artifactId={block.artifactId}
name={block.name}
mimeType={block.mimeType}
size={block.size}
onOpenBeside={
onOpenArtifact
? () =>
onOpenArtifact({
messageId: message.id,
artifactId: block.artifactId,
name: block.name,
mimeType: block.mimeType,
})
: undefined
}
/>
</div>
);
}
if (block.kind === "text" && message.role === "user") {
const isOptimistic = message.id.startsWith("temp-");
return (
<div key={i} className="flex w-fit max-w-full justify-end">
<div
data-testid="message-user-bubble"
className={cn(
"chat-bubble-user max-w-full whitespace-pre-wrap wrap-anywhere rounded-[24px] rounded-tr-md px-5 py-3.5 text-ui-body leading-[1.62] text-chat-user-foreground shadow-md",
isOptimistic && "opacity-80",
)}
dir="auto"
>
{block.text}
{isOptimistic ? (
<span
className="ms-2 inline-block size-1.5 rounded-full bg-current opacity-60 animate-pulse"
title="Queued"
data-testid="optimistic-queued-indicator"
/>
) : null}
</div>
</div>
);
}
if (block.kind === "text") {
return (
<div key={i} className="flex w-fit max-w-full justify-start">
<div
data-testid="message-bot-bubble"
className="chat-bubble-bot max-w-full space-y-3 rounded-[24px] rounded-tl-md px-6 py-4 text-ui-reading leading-[1.85] text-foreground/95"
dir="auto"
>
<BotTextBlockContent
text={block.text}
isLive={isLive}
detailsExpanded={detailsExpanded}
onToggleDetails={() => onToggleDetails?.()}
/>
<div className="mt-3 pt-2.5 flex items-center justify-between border-t border-border text-ui-label text-muted-foreground/75">
<div className="flex items-center gap-3">
<button
type="button"
onClick={() => {
if (navigator.clipboard) {
navigator.clipboard.writeText(block.text);
}
}}
className="inline-flex items-center gap-1.5 hover:text-foreground transition-colors cursor-pointer text-ui-body-sm font-medium"
title={t`Copy reply`}
>
<Copy size={13.5} />
<span>{t`Copy`}</span>
</button>
{voiceReady ? (
<button
type="button"
aria-label={speaking ? t`Stop speaking` : t`Speak this reply`}
onClick={onSpeak}
className="inline-flex items-center gap-1.5 hover:text-foreground transition-colors cursor-pointer text-ui-body-sm font-medium"
>
<Volume2 size={13.5} />
<span>{speaking ? t`Stop` : t`Read aloud`}</span>
</button>
) : null}
</div>
<span className="tabular-nums font-mono text-ui-label text-muted-foreground">
{new Date(message.createdAt).toLocaleTimeString([], {
hour: "2-digit",
minute: "2-digit",
})}
</span>
</div>
</div>
</div>
);
}
if (block.kind === "card" && block.sections) {
const cardId = block.id;
return (
<div key={i} className="flex justify-start">
<ThreadCard
block={block}
createdAt={message.createdAt}
onAct={onCardAct && cardId ? (act) => onCardAct(message, cardId, act) : undefined}
/>
</div>
);
}
if (block.kind === "card") {
return (
<div key={i} className="flex justify-start">
<div className="flex flex-col gap-2 rounded-[20px] bg-muted px-5 py-4">
{(block.lines ?? []).map((line) => (
<div key={line.k} className="flex items-baseline gap-2.5 text-ui-body">
<span className="text-success">✓</span>
<span className="font-semibold text-foreground">{line.k}</span>
<span className="text-muted-foreground">→</span>
<span>{line.v}</span>
</div>
))}
</div>
</div>
);
}
if (block.kind === "plan") {
return (
<div key={i} className="flex justify-start">
<PlanCard block={block} />
</div>
);
}
if (block.kind === "ask") {
return (
<AskCard
key={i}
block={block}
canAnswer={canAnswer}
onAnswer={(text) => onAnswer(message, text)}
/>
);
}
if (block.kind === "cloud_agent") return <CloudAgentCard key={i} block={block} />;
if (block.kind === "media") {
return (
<MediaCard key={i} block={block} target={artifactTarget} messageId={message.id} />
);
}
if (block.kind === "skill_draft") {
return (
<div key={i} className="flex justify-start">
<SkillDraftCard block={block} onRefresh={onRefresh} onAddRoutine={onAddRoutine} />
</div>
);
}
if (block.kind === "computer") {
return (
<div
key={i}
className="w-[340px] rounded-[18px] border border-border bg-muted px-[18px] py-4"
>
<div className="flex items-center justify-between">
<span className="text-ui-body font-medium text-foreground">
<Trans>Computer</Trans>
</span>
<span
className={
block.state === "Needs you"
? "rounded-full bg-warning/15 px-[11px] py-1 text-ui-body-sm text-warning"
: "rounded-full bg-success/15 px-[11px] py-1 text-ui-body-sm text-success"
}
>
{block.state === "Needs you" ? t`Needs you` : block.state}
</span>
</div>
<div className="my-2.5 text-ui-body-sm leading-[1.5] text-foreground/75">
<ChatMarkdown>{block.text}</ChatMarkdown>
</div>
</div>
);
}
return null;
})}
</div>
</>
);
});