Text sizes now follow a --font-scale variable that is 1 on wider screens and 8/7 below 768px, so the 14px base text reads at 16px on phones (Safari stops zooming into fields at that size) and every other size grows by the same ratio. One-off pixel sizes in the shell move onto the shared text tokens so they scale too. The stage subtitle now truncates inside the screen instead of spilling past both edges. Claude-Session: https://claude.ai/code/session_01PFEDgbyJm12ExL3Kn4Dk8B Co-authored-by: Claude <noreply@anthropic.com>
1344 lines
51 KiB
TypeScript
1344 lines
51 KiB
TypeScript
import { i18n } from "@lingui/core";
|
|
import { Trans, useLingui } from "@lingui/react/macro";
|
|
import { ChatMarkdown } from "@rakazo/chat-ui/web";
|
|
import type { CardAct, MessageReaction, ThreadMessage } from "@rakazo/contracts";
|
|
import { canReactToThreadMessage, MESSAGE_REACTIONS } from "@rakazo/contracts";
|
|
import type { WorkspaceArtifact } from "@rakazo/core";
|
|
import {
|
|
isPeerReceiptBlocks,
|
|
isRoutineEventMessage,
|
|
isToolActivityBlock,
|
|
projectMessageReactions,
|
|
resolveOoColor,
|
|
} from "@rakazo/core";
|
|
import type { GroupAvatarMember } from "@rakazo/ui-web";
|
|
import {
|
|
BotAvatar,
|
|
cn,
|
|
DropdownMenu,
|
|
DropdownMenuContent,
|
|
DropdownMenuItem,
|
|
DropdownMenuTrigger,
|
|
Popover,
|
|
PopoverContent,
|
|
PopoverTrigger,
|
|
} from "@rakazo/ui-web";
|
|
import { ArrowDown, Copy, MoreHorizontal, Reply, Smile, TextQuote, Volume2 } from "lucide-react";
|
|
import type { RefObject } from "react";
|
|
import { memo, useCallback, useEffect, useLayoutEffect, useMemo, useRef, useState } from "react";
|
|
import { createPortal } from "react-dom";
|
|
import { ArtifactFileCard } from "../../components/ArtifactFileCard";
|
|
import { AskCard } from "../../components/AskCard";
|
|
import { ActiveBotGlyph, CollaborationMarker } from "../../components/ai/CollaborationMarker";
|
|
import { DelegatedTaskCard, SentMessageCard } from "../../components/ai/DelegatedTaskCard";
|
|
import { ProjectCreatedCard } from "../../components/ai/ProjectCreatedCard";
|
|
import { CloudAgentCard } from "../../components/CloudAgentCard";
|
|
import { MessageHoverMetadata } from "../../components/MessageHoverMetadata";
|
|
import { MediaCard } from "../../components/media/MediaCard";
|
|
import { MediaPlayer } from "../../components/media/MediaPlayer";
|
|
import { ThreadCard } from "../../components/ThreadCard";
|
|
import { SkillDraftCard } from "../../components/teach/SkillDraftCard";
|
|
import type { ArtifactTarget } from "../../lib/artifact-open";
|
|
import { copyableMessageText } from "../../lib/message-text";
|
|
import { messageProviderLabel } from "../../lib/messaging";
|
|
import { withIntakeNoteText } from "../../lib/onboarding-copy";
|
|
import { quoteDraftForSelection } from "../../lib/quote-selection";
|
|
import { transcriptIsNearEnd, transcriptMovedDown } from "../../lib/transcript-scroll";
|
|
import type { AutopilotAction } from "./autopilot-line";
|
|
import { AutopilotLine } from "./autopilot-line";
|
|
import {
|
|
AppConnectCard,
|
|
ArtifactImage,
|
|
ChartBlockView,
|
|
ChoiceCard,
|
|
McpApprovalCard,
|
|
} from "./message-cards";
|
|
import { previewMessageText } from "./message-preview";
|
|
import { RoutineEventLine } from "./routine-event-line";
|
|
import { FALLBACK_BOT_COLOR } from "./shell-types";
|
|
|
|
export const Transcript = memo(function Transcript({
|
|
scrollRef,
|
|
artifactTarget,
|
|
onOpenArtifact,
|
|
messages,
|
|
olderCursor,
|
|
loadingOlder,
|
|
answerableAskMessageId,
|
|
running,
|
|
workingBots,
|
|
onLoadOlder,
|
|
onOpenBot,
|
|
onOpenGroup,
|
|
onAnswer,
|
|
onCardAct,
|
|
onAutopilot,
|
|
onReply,
|
|
onQuote,
|
|
onReact,
|
|
onJumpToMessage,
|
|
onOpenPeerMessages,
|
|
memberName,
|
|
peerBot,
|
|
onRefresh,
|
|
onBotChanged,
|
|
onAddRoutine,
|
|
voiceReady,
|
|
speakingMessageId,
|
|
onSpeak,
|
|
activeName,
|
|
encounterMode = false,
|
|
voiceEncounterActive = false,
|
|
}: {
|
|
scrollRef: RefObject<HTMLDivElement | null>;
|
|
artifactTarget: ArtifactTarget;
|
|
onOpenArtifact?: (artifact: WorkspaceArtifact) => void;
|
|
messages: ThreadMessage[];
|
|
olderCursor: number | null;
|
|
loadingOlder: boolean;
|
|
answerableAskMessageId: string | null;
|
|
running: boolean;
|
|
workingBots: GroupAvatarMember[];
|
|
onLoadOlder: () => void | Promise<void>;
|
|
onOpenBot: (botId: string) => void;
|
|
onOpenGroup: (groupId: string) => void;
|
|
onAnswer: (message: ThreadMessage, text: string) => Promise<void>;
|
|
onCardAct?: (message: ThreadMessage, cardId: string, act: CardAct) => Promise<void>;
|
|
onAutopilot?: (action: AutopilotAction) => Promise<void>;
|
|
onReply: (message: ThreadMessage) => void;
|
|
onQuote: (message: ThreadMessage, quote: string) => void;
|
|
onReact: (message: ThreadMessage, reaction: MessageReaction) => Promise<void>;
|
|
onJumpToMessage: (messageId: string) => void;
|
|
onOpenPeerMessages: (peer: { peerBotId: string; peerBotName: string }) => void;
|
|
memberName?: (botId: string | undefined) => string | undefined;
|
|
peerBot: (botId: string) => { color: string; status?: string } | undefined;
|
|
onRefresh: () => Promise<void>;
|
|
onBotChanged: () => Promise<void>;
|
|
onAddRoutine: (name: string, prompt: string) => void;
|
|
voiceReady: boolean;
|
|
speakingMessageId: string | null;
|
|
onSpeak: (message: ThreadMessage) => void;
|
|
activeName?: string;
|
|
encounterMode?: boolean;
|
|
voiceEncounterActive?: boolean;
|
|
}) {
|
|
const { t } = useLingui();
|
|
const [atEnd, setAtEnd] = useState(true);
|
|
const following = useRef(true);
|
|
const autoScrolling = useRef(false);
|
|
const lastScrollTop = useRef<number | null>(null);
|
|
const autoScrollTimer = useRef<number | undefined>(undefined);
|
|
const jumpButtonRef = useRef<HTMLButtonElement>(null);
|
|
const messageById = useMemo(
|
|
() => new Map(messages.map((message) => [message.id, message])),
|
|
[messages],
|
|
);
|
|
const reactionView = useMemo(
|
|
() => projectMessageReactions(messages.map(withIntakeNoteText)),
|
|
// Onboarding notes are worded in the UI language.
|
|
[messages, i18n.locale],
|
|
);
|
|
const transcriptSubordinate = encounterMode && voiceEncounterActive;
|
|
const workingBotName = workingBots.length === 1 ? workingBots[0]?.name : undefined;
|
|
const workingLabel =
|
|
workingBotName != null && workingBotName !== ""
|
|
? t`${workingBotName} is working`
|
|
: t`Bots are working`;
|
|
const [quoteDraft, setQuoteDraft] = useState<{
|
|
message: ThreadMessage;
|
|
text: string;
|
|
range: Range;
|
|
} | null>(null);
|
|
const selectingWithMouse = useRef(false);
|
|
|
|
const evaluateSelection = useCallback(() => {
|
|
const selection = window.getSelection();
|
|
if (!selection || selection.isCollapsed || selection.rangeCount === 0) {
|
|
setQuoteDraft(null);
|
|
return;
|
|
}
|
|
const range = selection.getRangeAt(0);
|
|
const rowOf = (node: Node) =>
|
|
(node instanceof Element ? node : node.parentElement)?.closest<HTMLElement>(
|
|
"[data-message-id]",
|
|
) ?? null;
|
|
const draft = quoteDraftForSelection(
|
|
{
|
|
startRow: rowOf(range.startContainer),
|
|
endRow: rowOf(range.endContainer),
|
|
text: selection.toString(),
|
|
},
|
|
messageById,
|
|
);
|
|
setQuoteDraft(draft ? { ...draft, range } : null);
|
|
}, [messageById]);
|
|
|
|
// Keyboard and assistive-tech selections never reach a mouseup, so the pill
|
|
// lifecycle listens on selectionchange; the mouse flag keeps it hidden while
|
|
// a drag is still in flight.
|
|
useEffect(() => {
|
|
const onMouseDown = (event: MouseEvent) => {
|
|
selectingWithMouse.current = true;
|
|
if ((event.target as Element | null)?.closest?.("[data-quote-selection]")) return;
|
|
setQuoteDraft(null);
|
|
};
|
|
const onMouseUp = () => {
|
|
selectingWithMouse.current = false;
|
|
evaluateSelection();
|
|
};
|
|
const onSelectionChange = () => {
|
|
if (!selectingWithMouse.current) evaluateSelection();
|
|
};
|
|
const onKey = (event: KeyboardEvent) => {
|
|
if (event.key === "Escape") setQuoteDraft(null);
|
|
};
|
|
document.addEventListener("mousedown", onMouseDown, true);
|
|
document.addEventListener("mouseup", onMouseUp, true);
|
|
document.addEventListener("selectionchange", onSelectionChange);
|
|
document.addEventListener("keydown", onKey);
|
|
return () => {
|
|
document.removeEventListener("mousedown", onMouseDown, true);
|
|
document.removeEventListener("mouseup", onMouseUp, true);
|
|
document.removeEventListener("selectionchange", onSelectionChange);
|
|
document.removeEventListener("keydown", onKey);
|
|
};
|
|
}, [evaluateSelection]);
|
|
const snapToEnd = useCallback(() => {
|
|
const element = scrollRef.current;
|
|
if (!element) return;
|
|
following.current = true;
|
|
autoScrolling.current = false;
|
|
setAtEnd(true);
|
|
element.scrollTo({ top: element.scrollHeight, behavior: "auto" });
|
|
}, [scrollRef]);
|
|
|
|
const jumpToLatest = useCallback(() => {
|
|
const element = scrollRef.current;
|
|
if (!element) return;
|
|
const reducedMotion = window.matchMedia("(prefers-reduced-motion: reduce)").matches;
|
|
following.current = true;
|
|
autoScrolling.current = !reducedMotion;
|
|
setAtEnd(true);
|
|
element.scrollTo({
|
|
top: element.scrollHeight,
|
|
behavior: reducedMotion ? "auto" : "smooth",
|
|
});
|
|
window.clearTimeout(autoScrollTimer.current);
|
|
// Fallback only: onScroll clears autoScrolling once near-end is reached.
|
|
autoScrollTimer.current = window.setTimeout(
|
|
() => {
|
|
autoScrolling.current = false;
|
|
},
|
|
reducedMotion ? 0 : 2_000,
|
|
);
|
|
}, [scrollRef]);
|
|
|
|
useLayoutEffect(() => {
|
|
if (following.current) snapToEnd();
|
|
}, [messages, running, snapToEnd]);
|
|
|
|
// A question the bot waits on can scroll out of view; offer a way back to it instead of the arrow.
|
|
const [askOffscreen, setAskOffscreen] = useState(false);
|
|
const findAsk = useCallback(
|
|
() =>
|
|
answerableAskMessageId
|
|
? (scrollRef.current?.querySelector<HTMLElement>(
|
|
`[data-message-id="${CSS.escape(answerableAskMessageId)}"]`,
|
|
) ?? null)
|
|
: null,
|
|
[answerableAskMessageId, scrollRef],
|
|
);
|
|
useEffect(() => {
|
|
const root = scrollRef.current;
|
|
const element = findAsk();
|
|
if (!root || !element || typeof IntersectionObserver === "undefined") {
|
|
setAskOffscreen(false);
|
|
return;
|
|
}
|
|
const observer = new IntersectionObserver(
|
|
(entries) => setAskOffscreen(!entries.some((entry) => entry.isIntersecting)),
|
|
{ root, threshold: 0.2 },
|
|
);
|
|
observer.observe(element);
|
|
return () => observer.disconnect();
|
|
}, [findAsk, messages, scrollRef]);
|
|
const jumpToAsk = useCallback(() => {
|
|
const element = findAsk();
|
|
if (!element) return;
|
|
const reducedMotion = window.matchMedia("(prefers-reduced-motion: reduce)").matches;
|
|
following.current = false;
|
|
element.scrollIntoView({ behavior: reducedMotion ? "auto" : "smooth", block: "center" });
|
|
element
|
|
.querySelector<HTMLElement>("[data-ask-card] input, [data-ask-card] button")
|
|
?.focus({ preventScroll: true });
|
|
}, [findAsk]);
|
|
const showJumpToLatest = !atEnd && !askOffscreen;
|
|
|
|
useLayoutEffect(() => {
|
|
const button = jumpButtonRef.current;
|
|
if (atEnd && button && document.activeElement === button) {
|
|
button.blur();
|
|
}
|
|
}, [atEnd]);
|
|
|
|
const loadOlder = useCallback(() => {
|
|
const wasFollowing = following.current;
|
|
// Prepend must not race the messages-driven snap-to-end follow path.
|
|
following.current = false;
|
|
autoScrolling.current = false;
|
|
const pending = onLoadOlder();
|
|
if (!pending) return;
|
|
return Promise.resolve(pending).catch((error) => {
|
|
const element = scrollRef.current;
|
|
if (wasFollowing && element && transcriptIsNearEnd(element)) {
|
|
following.current = true;
|
|
setAtEnd(true);
|
|
}
|
|
throw error;
|
|
});
|
|
}, [onLoadOlder, scrollRef]);
|
|
|
|
useEffect(
|
|
() => () => {
|
|
window.clearTimeout(autoScrollTimer.current);
|
|
},
|
|
[],
|
|
);
|
|
|
|
return (
|
|
// Clip the scroller's padding when the column leaves no room, e.g. beside the main inbox.
|
|
<div className="relative flex min-h-0 flex-1 overflow-hidden">
|
|
<div
|
|
ref={scrollRef}
|
|
data-testid="transcript"
|
|
onPointerDown={(event) => {
|
|
lastScrollTop.current = event.currentTarget.scrollTop;
|
|
autoScrolling.current = false;
|
|
following.current = false;
|
|
}}
|
|
onTouchStart={(event) => {
|
|
lastScrollTop.current = event.currentTarget.scrollTop;
|
|
autoScrolling.current = false;
|
|
following.current = false;
|
|
}}
|
|
onWheel={(event) => {
|
|
if (event.deltaY < 0) {
|
|
lastScrollTop.current = event.currentTarget.scrollTop;
|
|
autoScrolling.current = false;
|
|
following.current = false;
|
|
}
|
|
}}
|
|
onScroll={(event) => {
|
|
const scrolledDown = transcriptMovedDown(
|
|
lastScrollTop.current,
|
|
event.currentTarget.scrollTop,
|
|
);
|
|
lastScrollTop.current = event.currentTarget.scrollTop;
|
|
const nearEnd = transcriptIsNearEnd(event.currentTarget);
|
|
setAtEnd(nearEnd);
|
|
if (nearEnd) {
|
|
if (scrolledDown) following.current = true;
|
|
if (autoScrolling.current) {
|
|
autoScrolling.current = false;
|
|
window.clearTimeout(autoScrollTimer.current);
|
|
}
|
|
} else if (!autoScrolling.current) {
|
|
following.current = false;
|
|
}
|
|
}}
|
|
className={`rk-scroll flex min-h-0 flex-1 flex-col overflow-y-auto transition-opacity duration-300 ${
|
|
encounterMode ? "gap-2 px-3 py-3 md:px-6 md:py-4" : "gap-3.5 px-3.5 py-4 md:px-8 md:py-6"
|
|
} ${transcriptSubordinate ? "opacity-75" : ""}`}
|
|
>
|
|
{encounterMode ? (
|
|
<p className="mx-auto w-full max-w-2xl px-1 text-ui-label font-medium uppercase tracking-wider text-muted-foreground/80">
|
|
{voiceEncounterActive ? <Trans>Transcript</Trans> : <Trans>Conversation record</Trans>}
|
|
</p>
|
|
) : null}
|
|
{olderCursor != null ? (
|
|
<button
|
|
type="button"
|
|
disabled={loadingOlder}
|
|
onClick={() => void loadOlder()}
|
|
className="self-center rounded-lg px-3 py-1.5 text-ui-body-sm text-muted-foreground hover:bg-muted hover:text-foreground/75 disabled:opacity-50"
|
|
>
|
|
{loadingOlder ? t`Loading…` : t`Load earlier messages`}
|
|
</button>
|
|
) : null}
|
|
<div
|
|
className={`mx-auto flex w-full flex-1 flex-col pb-6 ${
|
|
encounterMode ? "max-w-2xl gap-3" : "max-w-4xl gap-6 pb-8"
|
|
}`}
|
|
>
|
|
{reactionView.visibleMessages.length === 0 && encounterMode ? (
|
|
<p className="py-2 text-ui-body-sm text-muted-foreground">
|
|
{activeName
|
|
? t`Start the conversation — ${activeName} is on stage above.`
|
|
: t`Start the conversation.`}
|
|
</p>
|
|
) : null}
|
|
{reactionView.visibleMessages.map((message) => {
|
|
if (!message.blocks.some((block) => !isToolActivityBlock(block))) return null;
|
|
const peerReceipt = isPeerReceiptBlocks(message.blocks);
|
|
if (isRoutineEventMessage(message.blocks)) {
|
|
return (
|
|
<div key={message.id} data-message-id={message.id}>
|
|
{message.blocks.map((block, index) =>
|
|
block.kind === "event" ? (
|
|
<RoutineEventLine key={index} block={block} />
|
|
) : block.kind === "autopilot" ? (
|
|
<AutopilotLine key={index} block={block} onAction={onAutopilot} />
|
|
) : null,
|
|
)}
|
|
</div>
|
|
);
|
|
}
|
|
const messageReactions = reactionView.reactions.get(message.id);
|
|
return (
|
|
<div
|
|
key={message.id}
|
|
data-message-id={message.id}
|
|
// Only persisted messages can be reply targets; synthetic rows
|
|
// (progress:, subagent:) carry a `prefix:` id.
|
|
data-quotable={message.id.includes(":") ? undefined : ""}
|
|
className={peerReceipt ? "relative py-0.5" : "group/message relative hover:z-20"}
|
|
>
|
|
{!peerReceipt && !message.id.startsWith("progress:") ? (
|
|
<time
|
|
dateTime={message.createdAt}
|
|
data-testid="message-hover-time"
|
|
className={cn(
|
|
"pointer-events-none absolute top-1 z-10 text-ui-label tabular-nums text-muted-foreground opacity-0 transition-opacity group-hover/message:opacity-100 group-focus-within/message:opacity-100 group-has-[[aria-expanded=true]]/message:opacity-100",
|
|
message.role === "user" ? "start-0" : "end-0",
|
|
)}
|
|
>
|
|
{new Date(message.createdAt).toLocaleTimeString(i18n.locale || "en", {
|
|
hour: "numeric",
|
|
minute: "2-digit",
|
|
})}
|
|
</time>
|
|
) : null}
|
|
<div
|
|
className={
|
|
peerReceipt
|
|
? undefined
|
|
: `relative flex ${message.role === "user" ? "justify-end" : "justify-start"}`
|
|
}
|
|
>
|
|
<div
|
|
data-testid={peerReceipt ? undefined : "message-bubble-frame"}
|
|
className={
|
|
peerReceipt
|
|
? undefined
|
|
: `relative min-w-0 ${
|
|
message.role === "user"
|
|
? encounterMode
|
|
? "w-fit max-w-[92%] md:max-w-[min(78%,calc(100%_-_3rem))]"
|
|
: "w-fit max-w-[88%] md:max-w-[min(72%,calc(100%_-_5rem))]"
|
|
: "w-full max-w-full"
|
|
}`
|
|
}
|
|
>
|
|
{peerReceipt ? null : (
|
|
<MessageHoverActions
|
|
message={message}
|
|
side={message.role === "user" ? "start" : "end"}
|
|
onReply={onReply}
|
|
onReact={onReact}
|
|
/>
|
|
)}
|
|
<MessageView
|
|
artifactTarget={artifactTarget}
|
|
onOpenArtifact={onOpenArtifact}
|
|
message={message}
|
|
canAnswer={message.id === answerableAskMessageId}
|
|
onOpenBot={onOpenBot}
|
|
onOpenGroup={onOpenGroup}
|
|
onOpenPeerMessages={onOpenPeerMessages}
|
|
onAnswer={onAnswer}
|
|
onCardAct={onCardAct}
|
|
encounterMode={encounterMode}
|
|
speakerName={
|
|
peerReceipt
|
|
? undefined
|
|
: message.role === "bot"
|
|
? memberName?.(message.botId)
|
|
: undefined
|
|
}
|
|
memberName={memberName}
|
|
peerBot={peerBot}
|
|
replyPreview={
|
|
message.replyToMessageId
|
|
? messageById.get(message.replyToMessageId)
|
|
: undefined
|
|
}
|
|
replyToMessageId={message.replyToMessageId}
|
|
onJumpToMessage={onJumpToMessage}
|
|
onRefresh={onRefresh}
|
|
onBotChanged={onBotChanged}
|
|
onAddRoutine={onAddRoutine}
|
|
voiceReady={voiceReady}
|
|
speaking={speakingMessageId === message.id}
|
|
onSpeak={() => onSpeak(message)}
|
|
/>
|
|
</div>
|
|
</div>
|
|
{!peerReceipt && messageReactions ? (
|
|
<div
|
|
data-testid="message-reactions"
|
|
className={cn(
|
|
"mt-1 flex flex-wrap gap-1",
|
|
message.role === "user" && "justify-end",
|
|
)}
|
|
>
|
|
{[...messageReactions].map(([emoji, count]) => (
|
|
<span
|
|
key={emoji}
|
|
className="rounded-full border border-border bg-muted px-2 py-0.5 text-ui-label"
|
|
>
|
|
{emoji}
|
|
{count > 1 ? ` ${count}` : ""}
|
|
</span>
|
|
))}
|
|
</div>
|
|
) : null}
|
|
</div>
|
|
);
|
|
})}
|
|
{running &&
|
|
!encounterMode &&
|
|
!messages.some(
|
|
(message) =>
|
|
message.id.startsWith("progress:") &&
|
|
message.blocks.some(
|
|
(block) =>
|
|
block.kind === "progress" && !isToolActivityBlock(block) && Boolean(block.text),
|
|
),
|
|
) ? (
|
|
<ActiveBotGlyph bots={workingBots} label={workingLabel} />
|
|
) : null}
|
|
</div>
|
|
</div>
|
|
{quoteDraft ? (
|
|
<QuoteSelectionButton
|
|
range={quoteDraft.range}
|
|
onQuote={() => {
|
|
onQuote(quoteDraft.message, quoteDraft.text);
|
|
window.getSelection()?.removeAllRanges();
|
|
setQuoteDraft(null);
|
|
}}
|
|
/>
|
|
) : null}
|
|
<button
|
|
ref={jumpButtonRef}
|
|
type="button"
|
|
aria-label={t`Jump to latest`}
|
|
aria-hidden={!showJumpToLatest}
|
|
tabIndex={showJumpToLatest ? 0 : -1}
|
|
onClick={jumpToLatest}
|
|
className={`absolute bottom-4 left-1/2 z-20 grid h-9 w-9 -translate-x-1/2 place-items-center rounded-full border border-border bg-muted/95 text-foreground/75 shadow-md backdrop-blur transition-[opacity,transform,background-color] duration-200 ease-[cubic-bezier(.22,1,.36,1)] hover:bg-border motion-reduce:transition-none ${
|
|
showJumpToLatest
|
|
? "translate-y-0 opacity-100"
|
|
: "pointer-events-none translate-y-2 opacity-0"
|
|
}`}
|
|
>
|
|
<ArrowDown size={17} strokeWidth={1.8} />
|
|
</button>
|
|
{askOffscreen ? (
|
|
<button
|
|
type="button"
|
|
data-testid="needs-you-jump"
|
|
onClick={jumpToAsk}
|
|
className="absolute bottom-4 left-1/2 z-20 inline-flex h-9 -translate-x-1/2 items-center gap-1.5 rounded-full border border-border bg-muted/95 px-3.5 text-ui-body-sm font-medium text-foreground shadow-md backdrop-blur hover:bg-border"
|
|
>
|
|
<span aria-hidden="true" className="size-2 rounded-full bg-warning" />
|
|
{t`Needs you`}
|
|
</button>
|
|
) : null}
|
|
</div>
|
|
);
|
|
});
|
|
|
|
/**
|
|
* Floating Quote action anchored to the selection's bounding rect. Measures
|
|
* itself after mount so it can flip below the selection when there is no room
|
|
* above and stay clamped inside the viewport; re-anchors on scroll/resize.
|
|
*/
|
|
const QuoteSelectionButton = memo(function QuoteSelectionButton({
|
|
range,
|
|
onQuote,
|
|
}: {
|
|
range: Range;
|
|
onQuote: () => void;
|
|
}) {
|
|
const { t } = useLingui();
|
|
const buttonRef = useRef<HTMLButtonElement>(null);
|
|
const [placement, setPlacement] = useState<{
|
|
top: number;
|
|
left: number;
|
|
above: boolean;
|
|
} | null>(null);
|
|
|
|
useLayoutEffect(() => {
|
|
const update = () => {
|
|
if (range.collapsed || !document.contains(range.commonAncestorContainer)) {
|
|
setPlacement(null);
|
|
return;
|
|
}
|
|
const rect = range.getBoundingClientRect();
|
|
const width = buttonRef.current?.offsetWidth ?? 0;
|
|
const height = buttonRef.current?.offsetHeight ?? 0;
|
|
const above = rect.top >= height + 8;
|
|
setPlacement({
|
|
top: above ? rect.top - 8 : rect.bottom + 8,
|
|
left: Math.min(
|
|
Math.max(rect.left + rect.width / 2, width / 2 + 8),
|
|
window.innerWidth - width / 2 - 8,
|
|
),
|
|
above,
|
|
});
|
|
};
|
|
update();
|
|
window.addEventListener("resize", update);
|
|
// Scroll doesn't bubble — listen on the capture phase to catch any scroller.
|
|
window.addEventListener("scroll", update, { capture: true, passive: true });
|
|
return () => {
|
|
window.removeEventListener("resize", update);
|
|
window.removeEventListener("scroll", update, true);
|
|
};
|
|
}, [range]);
|
|
|
|
return createPortal(
|
|
<button
|
|
ref={buttonRef}
|
|
type="button"
|
|
data-quote-selection
|
|
data-testid="quote-selection"
|
|
onMouseDown={(event) => {
|
|
// Keep the highlight alive until the click commits the quote.
|
|
event.preventDefault();
|
|
event.stopPropagation();
|
|
}}
|
|
onClick={onQuote}
|
|
style={placement ? { top: placement.top, left: placement.left } : { visibility: "hidden" }}
|
|
className={cn(
|
|
"fixed z-50 flex -translate-x-1/2 items-center gap-1.5 rounded-full border border-border bg-background px-3 py-1.5 text-ui-body-sm font-medium text-foreground shadow-md hover:bg-muted",
|
|
placement?.above === false ? "translate-y-0" : "-translate-y-full",
|
|
)}
|
|
>
|
|
<TextQuote strokeWidth={2} className="size-icon-sm" />
|
|
{t`Quote`}
|
|
</button>,
|
|
document.body,
|
|
);
|
|
});
|
|
|
|
function MessageHoverActions({
|
|
message,
|
|
side,
|
|
onReply,
|
|
onReact,
|
|
}: {
|
|
message: ThreadMessage;
|
|
side: "start" | "end";
|
|
onReply: (message: ThreadMessage) => void;
|
|
onReact: (message: ThreadMessage, reaction: MessageReaction) => Promise<void>;
|
|
}) {
|
|
const { t } = useLingui();
|
|
const [moreOpen, setMoreOpen] = useState(false);
|
|
const [reactionsOpen, setReactionsOpen] = useState(false);
|
|
|
|
// Streaming progress bubbles keep hover free for selection / stop clicks.
|
|
if (message.id.startsWith("progress:")) return null;
|
|
|
|
function copyMessage() {
|
|
const text = copyableMessageText(message);
|
|
if (!text || !navigator.clipboard) return;
|
|
void navigator.clipboard.writeText(text).catch(() => undefined);
|
|
}
|
|
|
|
const iconButtonClass =
|
|
"grid size-control-sm place-items-center text-muted-foreground transition-colors hover:text-foreground";
|
|
|
|
return (
|
|
<MessageHoverMetadata pinned={moreOpen || reactionsOpen} side={side}>
|
|
<div data-testid="message-hover-actions" className="flex items-center gap-0.5">
|
|
{canReactToThreadMessage(message) ? (
|
|
<Popover open={reactionsOpen} onOpenChange={setReactionsOpen}>
|
|
<PopoverTrigger
|
|
aria-label={t`React`}
|
|
className={cn(
|
|
iconButtonClass,
|
|
"h-11 w-11 [@media(hover:hover)_and_(pointer:fine)]:h-7 [@media(hover:hover)_and_(pointer:fine)]:w-7",
|
|
)}
|
|
>
|
|
<Smile strokeWidth={1.7} className="size-icon-md" />
|
|
</PopoverTrigger>
|
|
<PopoverContent
|
|
align={side === "end" ? "start" : "end"}
|
|
className="w-auto flex-row gap-0 rounded-2xl p-1.5"
|
|
aria-label={t`Reactions`}
|
|
>
|
|
{MESSAGE_REACTIONS.map((emoji) => (
|
|
<button
|
|
key={emoji}
|
|
type="button"
|
|
aria-label={emoji}
|
|
className="grid h-11 w-11 place-items-center rounded-xl text-2xl hover:bg-accent focus-visible:outline-2 focus-visible:outline-ring"
|
|
onClick={() => {
|
|
setReactionsOpen(false);
|
|
void onReact(message, emoji);
|
|
}}
|
|
>
|
|
{emoji}
|
|
</button>
|
|
))}
|
|
</PopoverContent>
|
|
</Popover>
|
|
) : null}
|
|
<button
|
|
type="button"
|
|
aria-label={t`Reply`}
|
|
onClick={() => onReply(message)}
|
|
className={`${iconButtonClass} hidden [@media(hover:hover)_and_(pointer:fine)]:grid`}
|
|
>
|
|
<Reply strokeWidth={1.7} className="size-icon-md" />
|
|
</button>
|
|
<DropdownMenu open={moreOpen} onOpenChange={setMoreOpen}>
|
|
<DropdownMenuTrigger
|
|
aria-label={t`More`}
|
|
className={cn(
|
|
iconButtonClass,
|
|
"h-11 w-11 [@media(hover:hover)_and_(pointer:fine)]:h-7 [@media(hover:hover)_and_(pointer:fine)]:w-7",
|
|
)}
|
|
>
|
|
<MoreHorizontal strokeWidth={1.7} className="size-icon-md" />
|
|
</DropdownMenuTrigger>
|
|
<DropdownMenuContent align={side === "end" ? "start" : "end"}>
|
|
<DropdownMenuItem
|
|
className="[@media(hover:hover)_and_(pointer:fine)]:hidden"
|
|
onClick={() => onReply(message)}
|
|
>
|
|
<Reply className="size-icon-md" />
|
|
<Trans>Reply</Trans>
|
|
</DropdownMenuItem>
|
|
<DropdownMenuItem onClick={copyMessage}>
|
|
<Copy strokeWidth={1.7} className="size-icon-md" />
|
|
<Trans>Copy</Trans>
|
|
</DropdownMenuItem>
|
|
</DropdownMenuContent>
|
|
</DropdownMenu>
|
|
</div>
|
|
</MessageHoverMetadata>
|
|
);
|
|
}
|
|
|
|
const MessageView = memo(function MessageView({
|
|
artifactTarget,
|
|
onOpenArtifact,
|
|
canAnswer,
|
|
message,
|
|
onAnswer,
|
|
onCardAct,
|
|
onOpenBot,
|
|
onOpenGroup,
|
|
onOpenPeerMessages,
|
|
speakerName,
|
|
memberName,
|
|
peerBot,
|
|
replyPreview,
|
|
replyToMessageId,
|
|
onJumpToMessage,
|
|
onRefresh,
|
|
onBotChanged,
|
|
onAddRoutine,
|
|
voiceReady,
|
|
speaking,
|
|
onSpeak,
|
|
encounterMode = false,
|
|
}: {
|
|
artifactTarget: ArtifactTarget;
|
|
onOpenArtifact?: (artifact: WorkspaceArtifact) => void;
|
|
canAnswer: boolean;
|
|
message: ThreadMessage;
|
|
onAnswer: (message: ThreadMessage, text: string) => Promise<void>;
|
|
onCardAct?: (message: ThreadMessage, cardId: string, act: CardAct) => Promise<void>;
|
|
onOpenBot: (botId: string) => void;
|
|
onOpenGroup: (groupId: string) => void;
|
|
onOpenPeerMessages: (peer: { peerBotId: string; peerBotName: string }) => void;
|
|
speakerName?: string;
|
|
memberName?: (botId: string | undefined) => string | undefined;
|
|
peerBot: (botId: string) => { color: string; status?: string } | undefined;
|
|
replyPreview?: ThreadMessage;
|
|
replyToMessageId?: string;
|
|
onJumpToMessage?: (messageId: string) => void;
|
|
onRefresh: () => Promise<void>;
|
|
onBotChanged: () => Promise<void>;
|
|
onAddRoutine: (name: string, prompt: string) => void;
|
|
voiceReady: boolean;
|
|
speaking: boolean;
|
|
onSpeak: () => void;
|
|
encounterMode?: boolean;
|
|
}) {
|
|
const { t } = useLingui();
|
|
const isNarration =
|
|
message.role === "bot" &&
|
|
message.blocks.length > 0 &&
|
|
message.blocks.every(
|
|
(block) => block.kind === "text" || block.kind === "progress" || block.kind === "steps",
|
|
);
|
|
const isLive = message.id.startsWith("progress:");
|
|
const visibleNarrationBlocks = message.blocks.filter((block) => !isToolActivityBlock(block));
|
|
const parentJumpId = replyPreview?.id ?? replyToMessageId;
|
|
const speakerBot = message.botId ? peerBot?.(message.botId) : undefined;
|
|
const speakerColorDef = useMemo(
|
|
() => resolveOoColor(message.botId ?? "bot", speakerBot?.color),
|
|
[message.botId, speakerBot?.color],
|
|
);
|
|
const effectiveSpeakerName = speakerName || (message.role === "bot" ? "evOo" : null);
|
|
const messageContext = (
|
|
<>
|
|
{effectiveSpeakerName && message.role === "bot" && !encounterMode ? (
|
|
<div className="mb-2 flex items-center justify-between">
|
|
<div className="flex items-center gap-2.5">
|
|
<div className="relative flex items-center justify-center size-7 rounded-xl bg-foreground/[0.08] border border-border shadow-xs">
|
|
<BotAvatar
|
|
color={speakerBot?.color || speakerColorDef.hex}
|
|
identity={message.botId ?? "bot"}
|
|
size={22}
|
|
moment={isLive ? "working" : "idle"}
|
|
/>
|
|
</div>
|
|
<div className="flex items-center gap-2">
|
|
<span
|
|
className="text-ui-body-sm font-semibold text-foreground tracking-tight"
|
|
dir="auto"
|
|
>
|
|
{effectiveSpeakerName}
|
|
</span>
|
|
<span className="rounded-full border border-border bg-muted px-2 py-0.5 text-ui-label font-medium text-muted-foreground">
|
|
Oo
|
|
</span>
|
|
</div>
|
|
</div>
|
|
<span className="text-ui-label font-mono text-muted-foreground tabular-nums">
|
|
{new Date(message.createdAt).toLocaleTimeString([], {
|
|
hour: "2-digit",
|
|
minute: "2-digit",
|
|
})}
|
|
</span>
|
|
</div>
|
|
) : effectiveSpeakerName && message.role === "bot" && encounterMode ? (
|
|
<div className="mb-1 flex items-center gap-2 text-ui-label font-medium uppercase tracking-wide text-muted-foreground">
|
|
<span dir="auto">{effectiveSpeakerName}</span>
|
|
<span aria-hidden>·</span>
|
|
<time dateTime={message.createdAt} className="font-mono tabular-nums normal-case">
|
|
{new Date(message.createdAt).toLocaleTimeString([], {
|
|
hour: "2-digit",
|
|
minute: "2-digit",
|
|
})}
|
|
</time>
|
|
</div>
|
|
) : speakerName ? (
|
|
<div
|
|
className="mb-1.5 flex items-center gap-2 text-ui-body-sm font-semibold tracking-tight"
|
|
dir="auto"
|
|
style={{ color: speakerColorDef.light }}
|
|
>
|
|
<BotAvatar
|
|
color={speakerBot?.color ?? FALLBACK_BOT_COLOR}
|
|
identity={message.botId}
|
|
size={22}
|
|
/>
|
|
{speakerName}
|
|
</div>
|
|
) : null}
|
|
{parentJumpId ? (
|
|
<button
|
|
type="button"
|
|
data-testid="reply-parent-preview"
|
|
aria-label={t`Jump to replied message`}
|
|
onClick={() => onJumpToMessage?.(parentJumpId)}
|
|
className="mb-2 block max-w-[74%] truncate rounded-[14px] border border-border bg-background px-3 py-2 text-start text-ui-body-sm text-muted-foreground hover:border-border hover:text-foreground/75"
|
|
dir="auto"
|
|
>
|
|
{message.replyQuote
|
|
? `“${message.replyQuote}”`
|
|
: replyPreview
|
|
? previewMessageText(replyPreview)
|
|
: t`Earlier message`}
|
|
</button>
|
|
) : null}
|
|
</>
|
|
);
|
|
if (isNarration) {
|
|
if (visibleNarrationBlocks.length === 0) return null;
|
|
return (
|
|
<>
|
|
{messageContext}
|
|
<div className="flex w-fit max-w-full justify-start">
|
|
<div
|
|
data-testid="message-bot-bubble"
|
|
className={
|
|
encounterMode
|
|
? "max-w-full space-y-2 border-s-2 border-emerald-500/35 ps-3 text-ui-body-sm leading-relaxed text-foreground/90"
|
|
: "chat-bubble-bot max-w-full space-y-3 rounded-[24px] px-5 py-3.5 text-ui-body leading-[1.62] text-foreground/95"
|
|
}
|
|
dir="auto"
|
|
>
|
|
{visibleNarrationBlocks.map((block, i) => {
|
|
if (block.kind === "text" || block.kind === "progress") {
|
|
return (
|
|
<div key={i}>
|
|
<ChatMarkdown streaming={block.kind === "progress"}>{block.text}</ChatMarkdown>
|
|
</div>
|
|
);
|
|
}
|
|
return null;
|
|
})}
|
|
{!isLive && voiceReady && message.blocks.some((block) => block.kind === "text") ? (
|
|
<button
|
|
type="button"
|
|
aria-label={speaking ? t`Stop speaking` : t`Speak this reply`}
|
|
onClick={onSpeak}
|
|
className="mt-1 text-ui-body-sm font-medium text-muted-foreground hover:text-foreground transition-colors"
|
|
>
|
|
{speaking ? <Trans>Stop</Trans> : <Trans>Speak</Trans>}
|
|
</button>
|
|
) : null}
|
|
</div>
|
|
</div>
|
|
</>
|
|
);
|
|
}
|
|
return (
|
|
<>
|
|
{messageContext}
|
|
{message.blocks.map((block, i) => {
|
|
if (isToolActivityBlock(block)) return null;
|
|
if (block.kind === "handoff") {
|
|
const from = memberName?.(block.fromBotId) ?? t`bot`;
|
|
const to = memberName?.(block.toBotId) ?? t`bot`;
|
|
return (
|
|
<div
|
|
key={i}
|
|
className="flex items-center justify-center gap-2 py-1 text-ui-body text-muted-foreground"
|
|
>
|
|
<span>
|
|
↪ {to} ← {from}
|
|
</span>
|
|
<span>{block.text}</span>
|
|
</div>
|
|
);
|
|
}
|
|
if (block.kind === "bot_message_sent") {
|
|
const peer = block.toBotName;
|
|
const peerBotId = block.toBotId;
|
|
return (
|
|
<SentMessageCard
|
|
key={i}
|
|
botId={peerBotId}
|
|
botName={peer}
|
|
color={peerBot(peerBotId)?.color ?? FALLBACK_BOT_COLOR}
|
|
text={block.text}
|
|
onClick={() => onOpenPeerMessages({ peerBotId, peerBotName: peer })}
|
|
/>
|
|
);
|
|
}
|
|
if (block.kind === "task") {
|
|
return (
|
|
<DelegatedTaskCard
|
|
key={i}
|
|
botId={block.assigneeBotId}
|
|
botName={block.assigneeBotName}
|
|
color={peerBot(block.assigneeBotId)?.color ?? FALLBACK_BOT_COLOR}
|
|
title={block.title}
|
|
text={block.text}
|
|
status={block.status}
|
|
/>
|
|
);
|
|
}
|
|
if (block.kind === "bot_message_received") {
|
|
const peer = block.fromBotName;
|
|
const peerBotId = block.fromBotId;
|
|
const label = t`Message from ${peer}`;
|
|
return (
|
|
<CollaborationMarker
|
|
key={i}
|
|
ariaLabel={label}
|
|
color={peerBot(peerBotId)?.color ?? FALLBACK_BOT_COLOR}
|
|
identity={peerBotId}
|
|
label={label}
|
|
onClick={() => onOpenPeerMessages({ peerBotId, peerBotName: peer })}
|
|
/>
|
|
);
|
|
}
|
|
if (block.kind === "channel_message") {
|
|
return (
|
|
<div
|
|
key={i}
|
|
className="flex items-center justify-center gap-2 py-1 text-ui-body text-muted-foreground"
|
|
>
|
|
<span>
|
|
{messageProviderLabel(block.provider, block.transport)} · {block.fromLabel}:{" "}
|
|
{block.text}
|
|
</span>
|
|
</div>
|
|
);
|
|
}
|
|
if (block.kind === "meta") {
|
|
return (
|
|
<div
|
|
key={i}
|
|
className="flex items-center justify-center gap-2 py-1 text-ui-body text-muted-foreground"
|
|
>
|
|
<span className="text-warning">◷</span>
|
|
<span>{block.text}</span>
|
|
</div>
|
|
);
|
|
}
|
|
if (block.kind === "progress") {
|
|
return (
|
|
<div key={i} className="flex w-fit max-w-full justify-start">
|
|
<div
|
|
data-testid="message-bot-bubble"
|
|
className="chat-bubble-bot max-w-full rounded-[24px] px-5 py-3.5 text-ui-body leading-[1.62] text-foreground/95"
|
|
dir="auto"
|
|
>
|
|
<ChatMarkdown streaming>{block.text}</ChatMarkdown>
|
|
</div>
|
|
</div>
|
|
);
|
|
}
|
|
if (block.kind === "subagent") {
|
|
const running = block.status === "running";
|
|
const failed = block.status === "failed";
|
|
return (
|
|
<div
|
|
key={i}
|
|
className="apple-glass-card w-[min(460px,95%)] rounded-[22px] px-5 py-4 transition-all"
|
|
>
|
|
<div className="flex items-center justify-between gap-3">
|
|
<div className="flex items-center gap-2.5 min-w-0">
|
|
<div
|
|
className={`relative flex h-2.5 w-2.5 shrink-0 ${running ? "animate-pulse" : ""}`}
|
|
>
|
|
<span
|
|
className={`relative inline-flex rounded-full h-2.5 w-2.5 ${failed ? "bg-red-500" : running ? "bg-amber-400" : "bg-emerald-400"}`}
|
|
></span>
|
|
</div>
|
|
<span className="text-ui-body font-semibold text-foreground truncate" dir="auto">
|
|
{block.name}
|
|
</span>
|
|
</div>
|
|
<span
|
|
className={`rounded-full px-3 py-0.5 text-ui-body-sm font-medium border ${
|
|
failed
|
|
? "border-destructive/30 bg-destructive/15 text-destructive"
|
|
: running
|
|
? "border-warning/30 bg-warning/15 text-warning"
|
|
: "border-success/30 bg-success/15 text-success"
|
|
}`}
|
|
style={{
|
|
animation: running ? "rkPulse 1.2s ease-in-out infinite" : undefined,
|
|
}}
|
|
>
|
|
{running ? t`subagent` : block.status}
|
|
</span>
|
|
</div>
|
|
<div className="mt-2 text-ui-body text-muted-foreground">{block.task}</div>
|
|
{block.progress || block.result ? (
|
|
<div className="mt-2.5 text-ui-body-sm leading-[1.5] text-foreground/75">
|
|
<ChatMarkdown streaming={running}>
|
|
{block.result || block.progress || ""}
|
|
</ChatMarkdown>
|
|
</div>
|
|
) : null}
|
|
</div>
|
|
);
|
|
}
|
|
if (block.kind === "project_created") {
|
|
return (
|
|
<ProjectCreatedCard
|
|
key={i}
|
|
name={block.name}
|
|
memberCount={block.memberCount}
|
|
onClick={() => onOpenGroup(block.groupId)}
|
|
/>
|
|
);
|
|
}
|
|
if (block.kind === "child_bot") {
|
|
const removed = block.status === "deleted" || block.status === "archived";
|
|
return (
|
|
<button
|
|
key={i}
|
|
type="button"
|
|
disabled={removed}
|
|
onClick={() => onOpenBot(block.botId)}
|
|
className="w-[min(340px,90%)] rounded-[18px] border border-border bg-muted px-[18px] py-4 text-start disabled:opacity-60"
|
|
>
|
|
<div className="flex items-center justify-between">
|
|
<span className="text-ui-body font-medium text-foreground" dir="auto">
|
|
{block.name}
|
|
</span>
|
|
<span
|
|
className={`rounded-full px-[11px] py-1 text-ui-body-sm ${
|
|
removed ? "bg-destructive/15 text-destructive" : "bg-success/15 text-success"
|
|
}`}
|
|
>
|
|
{block.status === "archived" ? (
|
|
<Trans>archived</Trans>
|
|
) : block.status === "deleted" ? (
|
|
<Trans>deleted</Trans>
|
|
) : (
|
|
<Trans>bot</Trans>
|
|
)}
|
|
</span>
|
|
</div>
|
|
<div className="mt-2 text-ui-body-sm leading-[1.5] text-foreground/75" dir="auto">
|
|
{removed
|
|
? block.status === "archived"
|
|
? t`Archived. Chat, memory, and files kept.`
|
|
: t`Removed with chat, computer, and memory.`
|
|
: block.title || t`Opened its thread.`}
|
|
</div>
|
|
</button>
|
|
);
|
|
}
|
|
if (block.kind === "choice") {
|
|
const botId = "botId" in artifactTarget ? artifactTarget.botId : message.botId;
|
|
if (!botId) return null;
|
|
return <ChoiceCard key={i} botId={botId} block={block} onBotChanged={onBotChanged} />;
|
|
}
|
|
if (block.kind === "app_connect") {
|
|
const botId = "botId" in artifactTarget ? artifactTarget.botId : message.botId;
|
|
if (!botId) return null;
|
|
return (
|
|
<div key={i} className="flex justify-start py-1">
|
|
<AppConnectCard botId={botId} block={block} />
|
|
</div>
|
|
);
|
|
}
|
|
if (block.kind === "chart") {
|
|
return (
|
|
<div key={i} className="flex justify-start">
|
|
<ChartBlockView name={block.name} spec={block.spec} data={block.data} />
|
|
</div>
|
|
);
|
|
}
|
|
if (block.kind === "mcp_approval") {
|
|
return (
|
|
<div key={i} className="flex justify-start">
|
|
<McpApprovalCard
|
|
botId={"botId" in artifactTarget ? artifactTarget.botId : message.botId}
|
|
name={block.name}
|
|
serverId={block.serverId}
|
|
transport={block.transport}
|
|
endpoint={block.endpoint}
|
|
needsOAuth={block.needsOAuth}
|
|
/>
|
|
</div>
|
|
);
|
|
}
|
|
if (block.kind === "image") {
|
|
return (
|
|
<div
|
|
key={i}
|
|
className={`flex ${message.role === "user" ? "justify-end" : "justify-start"}`}
|
|
>
|
|
<ArtifactImage artifactId={block.artifactId} name={block.name} />
|
|
</div>
|
|
);
|
|
}
|
|
if (block.kind === "video" || block.kind === "audio") {
|
|
return (
|
|
<div
|
|
key={i}
|
|
className={`flex ${message.role === "user" ? "justify-end" : "justify-start"}`}
|
|
>
|
|
<MediaPlayer
|
|
artifactId={block.artifactId}
|
|
kind={block.kind}
|
|
name={block.name}
|
|
posterArtifactId={block.kind === "video" ? block.posterArtifactId : undefined}
|
|
width={block.kind === "video" ? block.width : undefined}
|
|
height={block.kind === "video" ? block.height : undefined}
|
|
/>
|
|
</div>
|
|
);
|
|
}
|
|
if (block.kind === "file") {
|
|
return (
|
|
<div
|
|
key={i}
|
|
className={`flex ${message.role === "user" ? "justify-end" : "justify-start"}`}
|
|
>
|
|
<ArtifactFileCard
|
|
artifactId={block.artifactId}
|
|
name={block.name}
|
|
mimeType={block.mimeType}
|
|
size={block.size}
|
|
onOpenBeside={
|
|
onOpenArtifact
|
|
? () =>
|
|
onOpenArtifact({
|
|
messageId: message.id,
|
|
artifactId: block.artifactId,
|
|
name: block.name,
|
|
mimeType: block.mimeType,
|
|
})
|
|
: undefined
|
|
}
|
|
/>
|
|
</div>
|
|
);
|
|
}
|
|
if (block.kind === "text" && message.role === "user") {
|
|
return (
|
|
<div key={i} className="flex w-fit max-w-full justify-end">
|
|
<div
|
|
data-testid="message-user-bubble"
|
|
className={
|
|
encounterMode
|
|
? "max-w-full whitespace-pre-wrap wrap-anywhere rounded-2xl bg-chat-user px-3.5 py-2 text-ui-body-sm leading-relaxed text-chat-user-foreground"
|
|
: "chat-bubble-user max-w-full whitespace-pre-wrap wrap-anywhere rounded-[24px] px-6 py-4 text-ui-reading leading-[1.62] text-chat-user-foreground shadow-md"
|
|
}
|
|
dir="auto"
|
|
>
|
|
{block.text}
|
|
</div>
|
|
</div>
|
|
);
|
|
}
|
|
if (block.kind === "text") {
|
|
return (
|
|
<div key={i} className="flex w-fit max-w-full justify-start">
|
|
<div
|
|
data-testid="message-bot-bubble"
|
|
className={
|
|
encounterMode
|
|
? "max-w-full space-y-2 border-s-2 border-emerald-500/35 ps-3 text-ui-body-sm leading-relaxed text-foreground/90"
|
|
: "chat-bubble-bot max-w-full space-y-3 rounded-[24px] px-6 py-4 text-ui-reading leading-[1.85] text-foreground/95"
|
|
}
|
|
dir="auto"
|
|
>
|
|
<ChatMarkdown>{block.text}</ChatMarkdown>
|
|
<div className="mt-3 pt-2.5 flex items-center justify-between border-t border-border text-ui-label text-muted-foreground/75">
|
|
<div className="flex items-center gap-3">
|
|
<button
|
|
type="button"
|
|
onClick={() => {
|
|
if (navigator.clipboard) {
|
|
navigator.clipboard.writeText(block.text);
|
|
}
|
|
}}
|
|
className="inline-flex items-center gap-1.5 hover:text-foreground transition-colors cursor-pointer text-ui-body-sm font-medium"
|
|
title={t`Copy reply`}
|
|
>
|
|
<Copy size={13.5} />
|
|
<span>{t`Copy`}</span>
|
|
</button>
|
|
{voiceReady ? (
|
|
<button
|
|
type="button"
|
|
aria-label={speaking ? t`Stop speaking` : t`Speak this reply`}
|
|
onClick={onSpeak}
|
|
className="inline-flex items-center gap-1.5 hover:text-foreground transition-colors cursor-pointer text-ui-body-sm font-medium"
|
|
>
|
|
<Volume2 size={13.5} />
|
|
<span>{speaking ? t`Stop` : t`Read aloud`}</span>
|
|
</button>
|
|
) : null}
|
|
</div>
|
|
<span className="tabular-nums font-mono text-ui-label text-muted-foreground">
|
|
{new Date(message.createdAt).toLocaleTimeString([], {
|
|
hour: "2-digit",
|
|
minute: "2-digit",
|
|
})}
|
|
</span>
|
|
</div>
|
|
</div>
|
|
</div>
|
|
);
|
|
}
|
|
if (block.kind === "card" && block.sections) {
|
|
const cardId = block.id;
|
|
return (
|
|
<div key={i} className="flex justify-start">
|
|
<ThreadCard
|
|
block={block}
|
|
createdAt={message.createdAt}
|
|
onAct={onCardAct && cardId ? (act) => onCardAct(message, cardId, act) : undefined}
|
|
/>
|
|
</div>
|
|
);
|
|
}
|
|
if (block.kind === "card") {
|
|
return (
|
|
<div key={i} className="flex justify-start">
|
|
<div className="flex flex-col gap-2 rounded-[20px] bg-muted px-5 py-4">
|
|
{(block.lines ?? []).map((line) => (
|
|
<div key={line.k} className="flex items-baseline gap-2.5 text-ui-body">
|
|
<span className="text-success">✓</span>
|
|
<span className="font-semibold text-foreground">{line.k}</span>
|
|
<span className="text-muted-foreground">→</span>
|
|
<span>{line.v}</span>
|
|
</div>
|
|
))}
|
|
</div>
|
|
</div>
|
|
);
|
|
}
|
|
if (block.kind === "ask") {
|
|
return (
|
|
<AskCard
|
|
key={i}
|
|
block={block}
|
|
canAnswer={canAnswer}
|
|
onAnswer={(text) => onAnswer(message, text)}
|
|
/>
|
|
);
|
|
}
|
|
if (block.kind === "cloud_agent") return <CloudAgentCard key={i} block={block} />;
|
|
if (block.kind === "media") {
|
|
return <MediaCard key={i} block={block} target={artifactTarget} messageId={message.id} />;
|
|
}
|
|
if (block.kind === "skill_draft") {
|
|
return (
|
|
<div key={i} className="flex justify-start">
|
|
<SkillDraftCard block={block} onRefresh={onRefresh} onAddRoutine={onAddRoutine} />
|
|
</div>
|
|
);
|
|
}
|
|
if (block.kind === "computer") {
|
|
return (
|
|
<div
|
|
key={i}
|
|
className="w-[340px] rounded-[18px] border border-border bg-muted px-[18px] py-4"
|
|
>
|
|
<div className="flex items-center justify-between">
|
|
<span className="text-ui-body font-medium text-foreground">
|
|
<Trans>Computer</Trans>
|
|
</span>
|
|
<span
|
|
className={
|
|
block.state === "Needs you"
|
|
? "rounded-full bg-warning/15 px-[11px] py-1 text-ui-body-sm text-warning"
|
|
: "rounded-full bg-success/15 px-[11px] py-1 text-ui-body-sm text-success"
|
|
}
|
|
>
|
|
{block.state}
|
|
</span>
|
|
</div>
|
|
<div className="my-2.5 text-ui-body-sm leading-[1.5] text-foreground/75">
|
|
<ChatMarkdown>{block.text}</ChatMarkdown>
|
|
</div>
|
|
</div>
|
|
);
|
|
}
|
|
return null;
|
|
})}
|
|
</>
|
|
);
|
|
});
|