- 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
1505 lines
57 KiB
TypeScript
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;
|
|
})}
|
|
</>
|
|
);
|
|
});
|