2191 lines
80 KiB
TypeScript
2191 lines
80 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 { 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>
|
||
</>
|
||
);
|
||
});
|