evOo-local/apps/web/src/pages/shell/transcript.tsx
Cynar 95d786ef2c fix(web): restore bot bubble accent color and user bubble border after refactor regression
- Add --bot-accent CSS variable injection from speakerColorDef.hex to all bot bubbles
- Restore colored left border accent on bot bubbles via CSS custom property
- Improve bot bubble background with subtle accent tint (88% card + 12% accent)
- Add colored right border accent to user bubbles
- Add subtle accent halo shadow to bot bubbles for better visual hierarchy
2026-10-23 13:28:24 +00:00

1505 lines
57 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 {
foldRunNarration,
isPeerReceiptBlocks,
isRoutineEventMessage,
isToolActivityBlock,
projectMessageReactions,
resolveOoColor,
} from "@rakazo/core";
import type { GroupAvatarMember } from "@rakazo/ui-web";
import {
BotAvatar,
cn,
DropdownMenu,
DropdownMenuContent,
DropdownMenuItem,
DropdownMenuTrigger,
Popover,
PopoverContent,
PopoverTrigger,
} from "@rakazo/ui-web";
import { ArrowDown, Copy, MoreHorizontal, Reply, Smile, TextQuote, Volume2 } from "lucide-react";
import type { RefObject } from "react";
import { memo, useCallback, useEffect, useLayoutEffect, useMemo, useRef, useState } from "react";
import { createPortal } from "react-dom";
import { ArtifactFileCard } from "../../components/ArtifactFileCard";
import { AskCard } from "../../components/AskCard";
import { CollaborationMarker } from "../../components/ai/CollaborationMarker";
import { DelegatedTaskCard, SentMessageCard } from "../../components/ai/DelegatedTaskCard";
import { ProjectCreatedCard } from "../../components/ai/ProjectCreatedCard";
import { CloudAgentCard } from "../../components/CloudAgentCard";
import { MessageHoverMetadata } from "../../components/MessageHoverMetadata";
import { MediaCard } from "../../components/media/MediaCard";
import { MediaPlayer } from "../../components/media/MediaPlayer";
import { ThreadCard } from "../../components/ThreadCard";
import { SkillDraftCard } from "../../components/teach/SkillDraftCard";
import { rosterDoingVerb } from "../../lib/agent-activity";
import type { ArtifactTarget } from "../../lib/artifact-open";
import { copyableMessageText } from "../../lib/message-text";
import { messageProviderLabel } from "../../lib/messaging";
import { withIntakeNoteText } from "../../lib/onboarding-copy";
import { queuedFollowUps } from "../../lib/queued-follow-ups";
import { quoteDraftForSelection } from "../../lib/quote-selection";
import { transcriptIsNearEnd, transcriptMovedDown } from "../../lib/transcript-scroll";
import type { AutopilotAction } from "./autopilot-line";
import { AutopilotLine } from "./autopilot-line";
import {
AppConnectCard,
ArtifactImage,
ChartBlockView,
ChoiceCard,
McpApprovalCard,
} from "./message-cards";
import { previewMessageText } from "./message-preview";
import { RoutineEventLine } from "./routine-event-line";
import { JobProgressCard, RunSteps } from "./run-steps";
import { FALLBACK_BOT_COLOR } from "./shell-types";
import { isInterruptedStepMessage } from "./transcript-status";
function findPrecedingUserTask(
messages: readonly ThreadMessage[],
beforeMessageId: string,
): string | null {
const index = messages.findIndex((m) => m.id === beforeMessageId);
const start = index >= 0 ? index - 1 : messages.length - 1;
let fallback: string | null = null;
for (let i = start; i >= 0; i--) {
const m = messages[i];
if (m?.role === "user") {
const text = m.blocks
.filter((b): b is { kind: "text"; text: string } => b.kind === "text")
.map((b) => b.text)
.join(" ")
.trim();
if (!text) continue;
if (!fallback) fallback = text;
const isPunctuationOrFiller =
/^[?!.,:;]+$/.test(text) ||
(text.length <= 4 && /^(ok|ja|nein|yes|no|hi|hey)$/i.test(text));
if (!isPunctuationOrFiller) {
return text;
}
}
}
return fallback;
}
export const Transcript = memo(function Transcript({
scrollRef,
artifactTarget,
onOpenArtifact,
messages,
olderCursor,
loadingOlder,
answerableAskMessageId,
running,
runStartedAt,
workingBots,
onLoadOlder,
onOpenBot,
onOpenGroup,
onAnswer,
onCardAct,
onAutopilot,
onReply,
onQuote,
onReact,
onJumpToMessage,
onOpenPeerMessages,
memberName,
peerBot,
onRefresh,
onBotChanged,
onAddRoutine,
voiceReady,
speakingMessageId,
onSpeak,
encounterMode = false,
voiceEncounterActive = false,
onRestartTask,
}: {
scrollRef: RefObject<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>;
}) {
const { t } = useLingui();
const [restartingPrompt, setRestartingPrompt] = 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 [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 runNarration = useMemo(
() => foldRunNarration(reactionView.visibleMessages),
[reactionView.visibleMessages],
);
const transcriptSubordinate = encounterMode && voiceEncounterActive;
const workingBotName = workingBots.length === 1 ? workingBots[0]?.name : undefined;
const primaryRunStatus = workingBots[0]?.status;
const statusVerb = rosterDoingVerb(primaryRunStatus);
const workingLabel =
statusVerb ??
(workingBotName != null && workingBotName !== ""
? t`${workingBotName} is working`
: t`Bots are working`);
const lastUserTask = useMemo(() => {
let fallback: string | null = null;
for (let i = messages.length - 1; i >= 0; i--) {
const m = messages[i];
if (m?.role === "user") {
const text = m.blocks
.filter((b): b is { kind: "text"; text: string } => b.kind === "text")
.map((b) => b.text)
.join(" ")
.trim();
if (!text) continue;
if (!fallback) fallback = text;
const isPunctuationOrFiller =
/^[?!.,:;]+$/.test(text) ||
(text.length <= 4 && /^(ok|ja|nein|yes|no|hi|hey)$/i.test(text));
if (!isPunctuationOrFiller) {
return text;
}
}
}
return fallback;
}, [messages]);
const [quoteDraft, setQuoteDraft] = useState<{
message: ThreadMessage;
text: string;
range: Range;
} | null>(null);
const selectingWithMouse = useRef(false);
const evaluateSelection = useCallback(() => {
const selection = window.getSelection();
if (!selection || selection.isCollapsed || selection.rangeCount === 0) {
setQuoteDraft(null);
return;
}
const range = selection.getRangeAt(0);
const rowOf = (node: Node) =>
(node instanceof Element ? node : node.parentElement)?.closest<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-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"
}`}
>
{reactionView.visibleMessages.map((message) => {
if (!message.blocks.some((block) => !isToolActivityBlock(block))) return null;
if (runNarration.hidden.has(message.id)) return null;
const foldedSteps = runNarration.steps.get(message.id);
const peerReceipt = isPeerReceiptBlocks(message.blocks);
if (isRoutineEventMessage(message.blocks)) {
return (
<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(reactionView.visibleMessages, message.id)}
steps={foldedSteps}
status={isInterruptedStepMessage(message) ? "interrupted" : "completed"}
onRestart={
isInterruptedStepMessage(message) && onRestartTask
? () => {
const task = findPrecedingUserTask(
reactionView.visibleMessages,
message.id,
);
if (task) void handleRestart(task);
}
: undefined
}
restarting={Boolean(
restartingPrompt &&
restartingPrompt ===
findPrecedingUserTask(reactionView.visibleMessages, message.id),
)}
/>
) : null}
{!peerReceipt && !message.id.startsWith("progress:") ? (
<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}
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)}
/>
{queuedIds.has(message.id) ? (
<p
data-testid="message-queued"
className="mt-1 text-end text-ui-label text-muted-foreground"
>
{t`Queued`}
</p>
) : 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>
);
})}
{running && !encounterMode ? (
<div className="mt-2.5">
<JobProgressCard
bot={workingBots[0]}
taskTitle={lastUserTask}
running={true}
status="running"
activeAction={workingLabel}
/>
</div>
) : 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" ? "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>
);
}
const MessageView = memo(function MessageView({
artifactTarget,
onOpenArtifact,
canAnswer,
message,
foldedSteps,
onAnswer,
onCardAct,
onOpenBot,
onOpenGroup,
onOpenPeerMessages,
speakerName,
memberName,
peerBot,
replyPreview,
replyToMessageId,
onJumpToMessage,
onRefresh,
onBotChanged,
onAddRoutine,
voiceReady,
speaking,
onSpeak,
encounterMode = false,
}: {
artifactTarget: ArtifactTarget;
onOpenArtifact?: (artifact: WorkspaceArtifact) => void;
canAnswer: boolean;
message: ThreadMessage;
foldedSteps?: readonly ThreadMessage[];
onAnswer: (message: ThreadMessage, text: string) => Promise<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 hasSteps =
Boolean(foldedSteps && foldedSteps.length > 0) ||
message.blocks.some((b) => b.kind === "steps");
const visibleNarrationBlocks = message.blocks
.filter((block) => !isToolActivityBlock(block))
.filter((block) => {
// Discard broken, truncated text tokens like "Let" or "Ke" when steps exist on this message
if (hasSteps && block.kind === "text") {
const trimmed = block.text.trim();
if (trimmed.length <= 3 && !/[.!?:]/.test(trimmed)) {
return false;
}
}
return true;
});
const parentJumpId = replyPreview?.id ?? replyToMessageId;
const speakerBot = message.botId ? peerBot?.(message.botId) : undefined;
const speakerColorDef = useMemo(
() => resolveOoColor(message.botId ?? "bot", speakerBot?.color),
[message.botId, speakerBot?.color],
);
const effectiveSpeakerName = speakerName || (message.role === "bot" ? "evOo" : null);
const messageContext = (
<>
{effectiveSpeakerName && message.role === "bot" && !encounterMode ? (
<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-success/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"
}
style={{ ["--bot-accent" as string]: speakerColorDef.hex }}
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;
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 (
<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"
style={{ ["--bot-accent" as string]: speakerColorDef.hex }}
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-destructive" : running ? "bg-warning" : "bg-success"}`}
></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") {
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(
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",
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={
encounterMode
? "max-w-full space-y-2 border-s-2 border-success/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"
}
style={{ ["--bot-accent" as string]: speakerColorDef.hex }}
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 === "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;
})}
</>
);
});