evOo-local/apps/web/src/pages/shell/transcript.tsx
cynarAI 738811dbe7
Read the web app at 16px base size on phones (#155)
Text sizes now follow a --font-scale variable that is 1 on wider screens and
8/7 below 768px, so the 14px base text reads at 16px on phones (Safari stops
zooming into fields at that size) and every other size grows by the same
ratio. One-off pixel sizes in the shell move onto the shared text tokens so
they scale too. The stage subtitle now truncates inside the screen instead
of spilling past both edges.


Claude-Session: https://claude.ai/code/session_01PFEDgbyJm12ExL3Kn4Dk8B

Co-authored-by: Claude <noreply@anthropic.com>
2026-10-05 12:42:30 +02:00

1344 lines
51 KiB
TypeScript

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 {
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 { ActiveBotGlyph, 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 type { ArtifactTarget } from "../../lib/artifact-open";
import { copyableMessageText } from "../../lib/message-text";
import { messageProviderLabel } from "../../lib/messaging";
import { withIntakeNoteText } from "../../lib/onboarding-copy";
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 { FALLBACK_BOT_COLOR } from "./shell-types";
export const Transcript = memo(function Transcript({
scrollRef,
artifactTarget,
onOpenArtifact,
messages,
olderCursor,
loadingOlder,
answerableAskMessageId,
running,
workingBots,
onLoadOlder,
onOpenBot,
onOpenGroup,
onAnswer,
onCardAct,
onAutopilot,
onReply,
onQuote,
onReact,
onJumpToMessage,
onOpenPeerMessages,
memberName,
peerBot,
onRefresh,
onBotChanged,
onAddRoutine,
voiceReady,
speakingMessageId,
onSpeak,
activeName,
encounterMode = false,
voiceEncounterActive = false,
}: {
scrollRef: RefObject<HTMLDivElement | null>;
artifactTarget: ArtifactTarget;
onOpenArtifact?: (artifact: WorkspaceArtifact) => void;
messages: ThreadMessage[];
olderCursor: number | null;
loadingOlder: boolean;
answerableAskMessageId: string | null;
running: boolean;
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;
activeName?: string;
encounterMode?: boolean;
voiceEncounterActive?: boolean;
}) {
const { t } = useLingui();
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 transcriptSubordinate = encounterMode && voiceEncounterActive;
const workingBotName = workingBots.length === 1 ? workingBots[0]?.name : undefined;
const workingLabel =
workingBotName != null && workingBotName !== ""
? t`${workingBotName} is working`
: t`Bots are working`;
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"
onPointerDown={(event) => {
lastScrollTop.current = event.currentTarget.scrollTop;
autoScrolling.current = false;
following.current = false;
}}
onTouchStart={(event) => {
lastScrollTop.current = event.currentTarget.scrollTop;
autoScrolling.current = false;
following.current = false;
}}
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" : ""}`}
>
{encounterMode ? (
<p className="mx-auto w-full max-w-2xl px-1 text-ui-label font-medium uppercase tracking-wider text-muted-foreground/80">
{voiceEncounterActive ? <Trans>Transcript</Trans> : <Trans>Conversation record</Trans>}
</p>
) : null}
{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"
}`}
>
{reactionView.visibleMessages.length === 0 && encounterMode ? (
<p className="py-2 text-ui-body-sm text-muted-foreground">
{activeName
? t`Start the conversation — ${activeName} is on stage above.`
: t`Start the conversation.`}
</p>
) : null}
{reactionView.visibleMessages.map((message) => {
if (!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"}
>
{!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"
}`
}
>
{peerReceipt ? null : (
<MessageHoverActions
message={message}
side={message.role === "user" ? "start" : "end"}
onReply={onReply}
onReact={onReact}
/>
)}
<MessageView
artifactTarget={artifactTarget}
onOpenArtifact={onOpenArtifact}
message={message}
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)
: 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)}
/>
</div>
</div>
{!peerReceipt && messageReactions ? (
<div
data-testid="message-reactions"
className={cn(
"mt-1 flex flex-wrap gap-1",
message.role === "user" && "justify-end",
)}
>
{[...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}
</div>
);
})}
{running &&
!encounterMode &&
!messages.some(
(message) =>
message.id.startsWith("progress:") &&
message.blocks.some(
(block) =>
block.kind === "progress" && !isToolActivityBlock(block) && Boolean(block.text),
),
) ? (
<ActiveBotGlyph bots={workingBots} label={workingLabel} />
) : null}
</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" ? "start" : "end"}
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>
<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" ? "start" : "end"}>
<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>
);
}
const MessageView = memo(function MessageView({
artifactTarget,
onOpenArtifact,
canAnswer,
message,
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;
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;
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 visibleNarrationBlocks = message.blocks.filter((block) => !isToolActivityBlock(block));
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 ? (
<div className="mb-2 flex items-center justify-between">
<div className="flex items-center gap-2.5">
<div className="relative flex items-center justify-center size-7 rounded-xl bg-foreground/[0.08] border border-border shadow-xs">
<BotAvatar
color={speakerBot?.color || speakerColorDef.hex}
identity={message.botId ?? "bot"}
size={22}
moment={isLive ? "working" : "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>
<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>
</div>
<span className="text-ui-label font-mono text-muted-foreground tabular-nums">
{new Date(message.createdAt).toLocaleTimeString([], {
hour: "2-digit",
minute: "2-digit",
})}
</span>
</div>
) : effectiveSpeakerName && message.role === "bot" && encounterMode ? (
<div className="mb-1 flex items-center gap-2 text-ui-label font-medium uppercase tracking-wide text-muted-foreground">
<span dir="auto">{effectiveSpeakerName}</span>
<span aria-hidden>·</span>
<time dateTime={message.createdAt} className="font-mono tabular-nums normal-case">
{new Date(message.createdAt).toLocaleTimeString([], {
hour: "2-digit",
minute: "2-digit",
})}
</time>
</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={22}
/>
{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={
encounterMode
? "max-w-full space-y-2 border-s-2 border-emerald-500/35 ps-3 text-ui-body-sm leading-relaxed text-foreground/90"
: "chat-bubble-bot max-w-full space-y-3 rounded-[24px] 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}>
<ChatMarkdown streaming={block.kind === "progress"}>{block.text}</ChatMarkdown>
</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}
{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 (
<div
key={i}
className="flex items-center justify-center gap-2 py-1 text-ui-body text-muted-foreground"
>
<span>
↪ {to} ← {from}
</span>
<span>{block.text}</span>
</div>
);
}
if (block.kind === "bot_message_sent") {
const peer = block.toBotName;
const peerBotId = block.toBotId;
return (
<SentMessageCard
key={i}
botId={peerBotId}
botName={peer}
color={peerBot(peerBotId)?.color ?? FALLBACK_BOT_COLOR}
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}
/>
);
}
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 (
<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 rounded-[24px] px-5 py-3.5 text-ui-body leading-[1.62] text-foreground/95"
dir="auto"
>
<ChatMarkdown streaming>{block.text}</ChatMarkdown>
</div>
</div>
);
}
if (block.kind === "subagent") {
const running = block.status === "running";
const failed = block.status === "failed";
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-2.5 min-w-0">
<div
className={`relative flex h-2.5 w-2.5 shrink-0 ${running ? "animate-pulse" : ""}`}
>
<span
className={`relative inline-flex rounded-full h-2.5 w-2.5 ${failed ? "bg-red-500" : running ? "bg-amber-400" : "bg-emerald-400"}`}
></span>
</div>
<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.progress || block.result ? (
<div className="mt-2.5 text-ui-body-sm leading-[1.5] text-foreground/75">
<ChatMarkdown streaming={running}>
{block.result || block.progress || ""}
</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";
return (
<button
key={i}
type="button"
disabled={removed}
onClick={() => onOpenBot(block.botId)}
className="w-[min(340px,90%)] rounded-[18px] border border-border bg-muted px-[18px] py-4 text-start disabled:opacity-60"
>
<div className="flex items-center justify-between">
<span className="text-ui-body font-medium text-foreground" dir="auto">
{block.name}
</span>
<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") {
return (
<div
key={i}
className={`flex ${message.role === "user" ? "justify-end" : "justify-start"}`}
>
<ArtifactImage artifactId={block.artifactId} name={block.name} />
</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") {
return (
<div key={i} className="flex w-fit max-w-full justify-end">
<div
data-testid="message-user-bubble"
className={
encounterMode
? "max-w-full whitespace-pre-wrap wrap-anywhere rounded-2xl bg-chat-user px-3.5 py-2 text-ui-body-sm leading-relaxed text-chat-user-foreground"
: "chat-bubble-user max-w-full whitespace-pre-wrap wrap-anywhere rounded-[24px] px-6 py-4 text-ui-reading leading-[1.62] text-chat-user-foreground shadow-md"
}
dir="auto"
>
{block.text}
</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={
encounterMode
? "max-w-full space-y-2 border-s-2 border-emerald-500/35 ps-3 text-ui-body-sm leading-relaxed text-foreground/90"
: "chat-bubble-bot max-w-full space-y-3 rounded-[24px] px-6 py-4 text-ui-reading leading-[1.85] text-foreground/95"
}
dir="auto"
>
<ChatMarkdown>{block.text}</ChatMarkdown>
<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 === "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}
</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;
})}
</>
);
});