While onboarding wrote the main conversation, its sidebar row, project rows and Heute showed the server preview: nothing for a pending card (a card has no text of its own, so the row fell back to "Ready") and the English fallback of the closing note, even for a German user. Groups now carry `previewBlock` next to `preview`: the latest message when it is an onboarding card or note, validated with the same block schemas the messages use (now named exports). The web words it with the helpers that render the card and the note, so no copy is duplicated: the question while a card waits, the answer once given, and the closing note in the user's language and clock. A dismissed card keeps the plain preview, as the conversation no longer shows it. The field is left out for every other message, and mobile keeps using the plain `preview`. The chosen-answer lookup moves from the card into onboarding-copy so the card and the preview share it. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01MRtGG9ARfqqhma3t85Ro2u
672 lines
23 KiB
TypeScript
672 lines
23 KiB
TypeScript
import { Trans, useLingui } from "@lingui/react/macro";
|
|
import type { MessageBlock } from "@rakazo/contracts";
|
|
import { CHOICE_TEXT_ANSWER_ID } from "@rakazo/contracts";
|
|
import { abortableDelay } from "@rakazo/core";
|
|
import {
|
|
Button,
|
|
Dialog,
|
|
DialogClose,
|
|
DialogContent,
|
|
DialogTitle,
|
|
InputGroup,
|
|
InputGroupAddon,
|
|
InputGroupButton,
|
|
InputGroupInput,
|
|
} from "@rakazo/ui-web";
|
|
import { ArrowUp, Check, X } from "lucide-react";
|
|
import { useEffect, useRef, useState } from "react";
|
|
import { BuiCard, SuccessPop } from "../../components/ai/primitives";
|
|
import { type ArtifactTarget, decodeArtifactBase64 } from "../../lib/artifact-open";
|
|
import { chartViewport } from "../../lib/chart-viewport";
|
|
import { connectMcpOauth } from "../../lib/mcp-connect";
|
|
import { chosenOption, localizedChoice } from "../../lib/onboarding-copy";
|
|
import { rpc } from "../../lib/rpc";
|
|
import { enableWebPush, webPushEnabled, webPushSupported } from "../../lib/web-push";
|
|
|
|
type ChoiceOption = Extract<MessageBlock, { kind: "choice" }>["options"][number];
|
|
|
|
/**
|
|
* A question card. Lettered options are the answers; an option without a letter ("Skip",
|
|
* "Not now") is the quiet way out, and a card without one can be dismissed instead. Onboarding
|
|
* cards may also take an answer in the user's own words.
|
|
*/
|
|
export function ChoiceCard({
|
|
botId,
|
|
block,
|
|
onBotChanged,
|
|
}: {
|
|
botId: string;
|
|
block: Extract<MessageBlock, { kind: "choice" }>;
|
|
onBotChanged: () => Promise<void>;
|
|
}) {
|
|
const { t } = useLingui();
|
|
const [pending, setPending] = useState(false);
|
|
const [error, setError] = useState<string | null>(null);
|
|
const [text, setText] = useState("");
|
|
const [locallyDismissed, setLocallyDismissed] = useState(false);
|
|
const dismissed = locallyDismissed || block.answerId === "_dismissed";
|
|
const answered = Boolean(block.answerId);
|
|
// What the card says, in the user's language; answers still go out by step and option id.
|
|
const card = localizedChoice(block);
|
|
const answers = card.options.filter((option) => option.letter);
|
|
const waysOut = card.options.filter((option) => !option.letter);
|
|
const testId = (option: ChoiceOption) => `${block.step ?? "focus"}-choice-${option.id}`;
|
|
|
|
async function choose(optionId: string, words?: string) {
|
|
setPending(true);
|
|
setError(null);
|
|
try {
|
|
// Tapping "Turn on" is the gesture the browser needs before it asks for permission.
|
|
const answer =
|
|
block.step === "notifications" && optionId === "on" && (await enableWebPush()) !== "enabled"
|
|
? "later"
|
|
: optionId;
|
|
await rpc.onboarding.choose({
|
|
botId,
|
|
step: block.step,
|
|
optionId: answer,
|
|
...(words ? { text: words } : {}),
|
|
// The server offers the notifications card only to a browser that can still turn push on.
|
|
canEnablePush: webPushSupported() && !(await webPushEnabled()),
|
|
});
|
|
await onBotChanged();
|
|
} catch (err) {
|
|
setError(err instanceof Error ? err.message : t`Could not save this choice`);
|
|
setPending(false);
|
|
}
|
|
}
|
|
|
|
async function dismiss() {
|
|
setPending(true);
|
|
setError(null);
|
|
try {
|
|
await rpc.onboarding.dismissFocus({ botId });
|
|
setLocallyDismissed(true);
|
|
void onBotChanged().catch(() => undefined);
|
|
} catch (err) {
|
|
setError(err instanceof Error ? err.message : t`Could not dismiss`);
|
|
setPending(false);
|
|
}
|
|
}
|
|
|
|
if (dismissed) return null;
|
|
|
|
const chosen = chosenOption(card);
|
|
|
|
return (
|
|
<div className="flex justify-start">
|
|
<BuiCard
|
|
data-testid="choice-card"
|
|
data-step={block.step}
|
|
className="relative w-[440px] max-w-full px-5 py-4"
|
|
>
|
|
{!answered && waysOut.length === 0 ? (
|
|
<Button
|
|
variant="ghost"
|
|
size="icon-sm"
|
|
aria-label={t`Dismiss`}
|
|
disabled={pending}
|
|
onClick={() => void dismiss()}
|
|
className="absolute end-2.5 top-2.5 rounded-full text-muted-foreground hover:text-foreground"
|
|
>
|
|
<X strokeWidth={1.8} className="size-icon-md" />
|
|
</Button>
|
|
) : null}
|
|
<div className="pe-8 text-ui-body font-medium text-foreground tracking-tight">
|
|
{card.question}
|
|
</div>
|
|
{block.subtitle ? (
|
|
<div className="mt-1 text-ui-body text-muted-foreground">{block.subtitle}</div>
|
|
) : null}
|
|
{answered ? (
|
|
chosen ? (
|
|
<div
|
|
data-testid="choice-answer"
|
|
className="mt-3 flex items-start gap-3 rounded-xl border border-primary/30 bg-primary/5 p-3 text-ui-body font-medium text-foreground"
|
|
>
|
|
{chosen.letter ? <ChoiceLetter letter={chosen.letter} selected /> : null}
|
|
<span className="flex-1 leading-snug" dir="auto">
|
|
{chosen.label}
|
|
</span>
|
|
<Check aria-hidden strokeWidth={2.2} className="mt-0.5 size-icon-md text-primary" />
|
|
</div>
|
|
) : null
|
|
) : (
|
|
<>
|
|
{answers.length > 0 ? (
|
|
<div className="mt-3 space-y-2">
|
|
{answers.map((option) => (
|
|
<button
|
|
key={option.id}
|
|
type="button"
|
|
data-testid={testId(option)}
|
|
aria-label={option.label}
|
|
disabled={pending}
|
|
onClick={() => void choose(option.id)}
|
|
className="tactile-press flex w-full items-start gap-3 rounded-xl border border-border bg-background p-3 text-start text-ui-body leading-snug text-foreground transition-colors hover:bg-muted disabled:opacity-60"
|
|
>
|
|
<ChoiceLetter letter={option.letter} />
|
|
<span className="flex-1">{option.label}</span>
|
|
</button>
|
|
))}
|
|
</div>
|
|
) : null}
|
|
{card.textInput ? (
|
|
<form
|
|
className="mt-3"
|
|
onSubmit={(event) => {
|
|
event.preventDefault();
|
|
const words = text.trim();
|
|
if (words) void choose(CHOICE_TEXT_ANSWER_ID, words);
|
|
}}
|
|
>
|
|
<InputGroup className="h-control-lg rounded-xl bg-background">
|
|
<InputGroupInput
|
|
value={text}
|
|
onChange={(event) => setText(event.target.value)}
|
|
placeholder={card.textInput.placeholder}
|
|
aria-label={card.question}
|
|
maxLength={200}
|
|
disabled={pending}
|
|
dir="auto"
|
|
/>
|
|
<InputGroupAddon align="inline-end">
|
|
<InputGroupButton
|
|
type="submit"
|
|
size="icon-sm"
|
|
variant="default"
|
|
aria-label={t`Send`}
|
|
disabled={pending || !text.trim()}
|
|
className="rounded-lg"
|
|
>
|
|
<ArrowUp aria-hidden />
|
|
</InputGroupButton>
|
|
</InputGroupAddon>
|
|
</InputGroup>
|
|
</form>
|
|
) : null}
|
|
{waysOut.length > 0 ? (
|
|
<div className="mt-2 flex justify-end gap-1">
|
|
{waysOut.map((option) => (
|
|
<Button
|
|
key={option.id}
|
|
variant="ghost"
|
|
size="sm"
|
|
data-testid={testId(option)}
|
|
disabled={pending}
|
|
onClick={() => void choose(option.id)}
|
|
className="text-muted-foreground hover:text-foreground"
|
|
>
|
|
{option.label}
|
|
</Button>
|
|
))}
|
|
</div>
|
|
) : null}
|
|
</>
|
|
)}
|
|
{error ? <p className="mt-2 text-ui-label text-destructive">{error}</p> : null}
|
|
</BuiCard>
|
|
</div>
|
|
);
|
|
}
|
|
|
|
function ChoiceLetter({ letter, selected = false }: { letter: string; selected?: boolean }) {
|
|
return (
|
|
<span
|
|
aria-hidden
|
|
className={`mt-0.5 grid size-6 shrink-0 place-items-center rounded-lg text-ui-body-sm font-semibold ${
|
|
selected ? "bg-primary text-primary-foreground" : "bg-muted text-foreground/80"
|
|
}`}
|
|
>
|
|
{letter}
|
|
</span>
|
|
);
|
|
}
|
|
|
|
export function AppConnectCard({
|
|
botId,
|
|
block,
|
|
}: {
|
|
botId: string;
|
|
block: Extract<MessageBlock, { kind: "app_connect" }>;
|
|
}) {
|
|
const { t } = useLingui();
|
|
const [busy, setBusy] = useState(false);
|
|
const [localStatus, setLocalStatus] = useState<"pending" | "connected">(block.status);
|
|
const [error, setError] = useState<string | null>(null);
|
|
const connectionAttempt = useRef<AbortController | null>(null);
|
|
const status = block.status === "connected" ? "connected" : localStatus;
|
|
useEffect(() => () => connectionAttempt.current?.abort(), []);
|
|
|
|
async function authorize() {
|
|
connectionAttempt.current?.abort();
|
|
const controller = new AbortController();
|
|
connectionAttempt.current = controller;
|
|
setBusy(true);
|
|
setError(null);
|
|
try {
|
|
const started = await rpc.connections.begin({
|
|
connectorId: block.connectorId,
|
|
provider: block.provider,
|
|
displayName: block.name,
|
|
});
|
|
if (started.authorizationUrl) {
|
|
window.open(started.authorizationUrl, "rakazo-app-connect", "popup,width=560,height=720");
|
|
}
|
|
for (let i = 0; i < 60; i += 1) {
|
|
if (controller.signal.aborted) return;
|
|
const row = await rpc.connections
|
|
.complete({ connectionId: started.connectionId })
|
|
.catch(() => undefined);
|
|
if (row?.status === "connected") {
|
|
if (controller.signal.aborted) return;
|
|
setLocalStatus("connected");
|
|
await rpc.onboarding
|
|
.appConnected({ botId, provider: block.provider, connectorId: block.connectorId })
|
|
.catch(() => undefined);
|
|
return;
|
|
}
|
|
await abortableDelay(2_000, controller.signal);
|
|
}
|
|
if (!controller.signal.aborted) setError(t`Authorization timed out. Please try again.`);
|
|
} catch (error) {
|
|
if (!controller.signal.aborted) {
|
|
setError(error instanceof Error ? error.message : t`Could not authorize this app`);
|
|
}
|
|
} finally {
|
|
if (connectionAttempt.current === controller) {
|
|
connectionAttempt.current = null;
|
|
setBusy(false);
|
|
}
|
|
}
|
|
}
|
|
return (
|
|
<BuiCard
|
|
role="group"
|
|
aria-label={t`${block.name} connection`}
|
|
className="w-[min(420px,80%)] px-4 py-3.5"
|
|
>
|
|
<div className="flex items-center gap-3.5">
|
|
{block.logo ? (
|
|
<img
|
|
src={block.logo}
|
|
alt=""
|
|
className="h-10 w-10 rounded-[10px] bg-white object-contain p-1"
|
|
/>
|
|
) : (
|
|
<span className="grid h-10 w-10 place-items-center rounded-[10px] bg-muted text-ui-body text-foreground">
|
|
{block.name.slice(0, 1).toUpperCase()}
|
|
</span>
|
|
)}
|
|
<span className="min-w-0 flex-1">
|
|
<span className="block text-ui-body font-medium text-foreground">{block.name}</span>
|
|
<span className="block truncate text-ui-body-sm text-muted-foreground">
|
|
{block.description}
|
|
</span>
|
|
</span>
|
|
{status === "connected" ? (
|
|
<SuccessPop label={t`Connected`} />
|
|
) : (
|
|
<Button
|
|
variant="secondary"
|
|
className="rounded-full hover:border-border hover:bg-accent hover:text-foreground"
|
|
disabled={busy}
|
|
onClick={() => void authorize()}
|
|
>
|
|
{busy ? t`Waiting…` : t`Authorize`}
|
|
</Button>
|
|
)}
|
|
</div>
|
|
{error ? <p className="mt-2 text-ui-label text-destructive">{error}</p> : null}
|
|
</BuiCard>
|
|
);
|
|
}
|
|
|
|
function ChartCanvas({
|
|
spec,
|
|
data,
|
|
width,
|
|
height,
|
|
}: {
|
|
spec: Record<string, unknown>;
|
|
data: unknown[];
|
|
width: number;
|
|
height?: number;
|
|
}) {
|
|
const { t } = useLingui();
|
|
const ref = useRef<HTMLDivElement>(null);
|
|
const [error, setError] = useState<string | null>(null);
|
|
const [meta, setMeta] = useState<{
|
|
title?: string;
|
|
swatches: { label: string; color: string }[];
|
|
}>({ swatches: [] });
|
|
useEffect(() => {
|
|
let cancelled = false;
|
|
// Plot loads lazily so threads without charts never pay for the library.
|
|
void (async () => {
|
|
try {
|
|
const { buildPlotParts } = await import("@rakazo/core/plot");
|
|
if (cancelled || !ref.current) return;
|
|
// Hover inspection by default: give the first mark a tooltip unless
|
|
// the spec already asks for one somewhere.
|
|
const marks = Array.isArray((spec as { marks?: unknown[] }).marks)
|
|
? ((spec as { marks: { options?: Record<string, unknown> }[] }).marks ?? [])
|
|
: [];
|
|
const hasTip = marks.some((mark) => mark.options && "tip" in mark.options);
|
|
const liveSpec = hasTip
|
|
? spec
|
|
: {
|
|
...spec,
|
|
marks: marks.map((mark, index) =>
|
|
index === 0 ? { ...mark, options: { ...(mark.options ?? {}), tip: true } } : mark,
|
|
),
|
|
};
|
|
const parts = buildPlotParts(liveSpec as never, data, document, { width, height });
|
|
setMeta({ title: parts.title, swatches: parts.swatches });
|
|
setError(null);
|
|
ref.current.replaceChildren(parts.plotted);
|
|
} catch (err) {
|
|
if (!cancelled) setError(err instanceof Error ? err.message : t`Could not render chart`);
|
|
}
|
|
})();
|
|
return () => {
|
|
cancelled = true;
|
|
};
|
|
}, [spec, data, width, height, t]);
|
|
if (error)
|
|
return (
|
|
<div className="text-ui-body-sm text-destructive">
|
|
<Trans>Chart failed to render: {error}</Trans>
|
|
</div>
|
|
);
|
|
return (
|
|
<div className="text-foreground/75">
|
|
{meta.title ? (
|
|
<div className="mb-1 text-[14.5px] font-semibold text-foreground">{meta.title}</div>
|
|
) : null}
|
|
{meta.swatches.length > 0 ? (
|
|
<div className="mb-2 flex flex-wrap gap-x-3 gap-y-1">
|
|
{meta.swatches.map((swatch) => (
|
|
<span
|
|
key={swatch.label}
|
|
className="flex items-center gap-1.5 text-ui-body-sm text-muted-foreground"
|
|
>
|
|
{/* Series colours are data-driven and intentionally distinct. */}
|
|
<span
|
|
className="h-[10px] w-[10px] rounded-[3px]"
|
|
style={{ background: swatch.color }}
|
|
/>
|
|
{swatch.label}
|
|
</span>
|
|
))}
|
|
</div>
|
|
) : null}
|
|
<div ref={ref} className="[&_svg]:max-w-full" />
|
|
</div>
|
|
);
|
|
}
|
|
|
|
type McpApprovalState = "pending" | "connecting" | "connected" | "dismissed";
|
|
|
|
/** Approval card for an agent-created MCP server: the user completes browser
|
|
* OAuth (or confirms no authorization is needed) without leaving the chat. */
|
|
export function McpApprovalCard({
|
|
botId,
|
|
name,
|
|
serverId,
|
|
transport,
|
|
endpoint,
|
|
needsOAuth,
|
|
}: {
|
|
botId: string | undefined;
|
|
name: string;
|
|
serverId: string;
|
|
transport: string;
|
|
endpoint: string | null;
|
|
needsOAuth: boolean;
|
|
}) {
|
|
const { t } = useLingui();
|
|
const [state, setState] = useState<McpApprovalState>("pending");
|
|
const [error, setError] = useState<string | null>(null);
|
|
|
|
async function authorize() {
|
|
if (!botId) {
|
|
setError(t`This server cannot be assigned without a bot.`);
|
|
return;
|
|
}
|
|
setState("connecting");
|
|
setError(null);
|
|
try {
|
|
if (needsOAuth) {
|
|
const result = await connectMcpOauth(serverId);
|
|
if (result === "cancelled") {
|
|
setState("pending");
|
|
return;
|
|
}
|
|
}
|
|
await rpc.mcp.assignments.approve({ botId, serverId });
|
|
setState("connected");
|
|
} catch (err) {
|
|
setError(err instanceof Error ? err.message : t`Could not approve this server`);
|
|
setState("pending");
|
|
}
|
|
}
|
|
|
|
const summary = endpoint ?? `stdio · ${transport}`;
|
|
return (
|
|
<BuiCard className="max-w-[74%] p-4">
|
|
<div className="flex items-center gap-2">
|
|
<span className="grid size-control-sm place-items-center rounded-lg bg-muted text-ui-label text-foreground">
|
|
M
|
|
</span>
|
|
<span className="text-[14.5px] font-medium text-foreground">
|
|
<Trans>Connect MCP server “{name}”</Trans>
|
|
</span>
|
|
</div>
|
|
<p className="mt-1.5 truncate text-ui-body-sm text-muted-foreground">{summary}</p>
|
|
{state === "pending" || state === "connecting" ? (
|
|
<>
|
|
<p className="mt-2 text-ui-body-sm leading-[1.5] text-foreground/75">
|
|
{needsOAuth
|
|
? t`Authorize this server so agents can use its tools. A popup opens.`
|
|
: t`Approve this server to let your agent use its tools.`}
|
|
</p>
|
|
{error ? <p className="mt-2 text-ui-label text-destructive">{error}</p> : null}
|
|
<div className="mt-3 flex gap-2">
|
|
<Button
|
|
className="rounded-full"
|
|
disabled={state === "connecting"}
|
|
onClick={() => void authorize()}
|
|
>
|
|
{state === "connecting" ? t`Connecting…` : needsOAuth ? t`Authorize` : t`Approve`}
|
|
</Button>
|
|
<Button
|
|
variant="secondary"
|
|
className="rounded-full"
|
|
onClick={() => setState("dismissed")}
|
|
>
|
|
<Trans>Not now</Trans>
|
|
</Button>
|
|
</div>
|
|
</>
|
|
) : null}
|
|
{state === "connected" ? (
|
|
<div className="mt-3">
|
|
<SuccessPop label={t`Connected. Its tools are available from your next message.`} />
|
|
</div>
|
|
) : null}
|
|
{state === "dismissed" ? (
|
|
<p className="mt-2 text-ui-body-sm text-muted-foreground">
|
|
<Trans>Dismissed. Reconnect anytime from MCP settings.</Trans>
|
|
</p>
|
|
) : null}
|
|
</BuiCard>
|
|
);
|
|
}
|
|
|
|
export function ChartBlockView({
|
|
name,
|
|
spec,
|
|
data,
|
|
}: {
|
|
name: string;
|
|
spec: Record<string, unknown>;
|
|
data: unknown[];
|
|
}) {
|
|
const { t } = useLingui();
|
|
const [expanded, setExpanded] = useState(false);
|
|
const [viewport, setViewport] = useState(() => ({
|
|
width: window.innerWidth,
|
|
height: window.innerHeight,
|
|
}));
|
|
useEffect(() => {
|
|
if (!expanded) return;
|
|
setViewport({ width: window.innerWidth, height: window.innerHeight });
|
|
const onResize = () => setViewport({ width: window.innerWidth, height: window.innerHeight });
|
|
window.addEventListener("resize", onResize);
|
|
return () => window.removeEventListener("resize", onResize);
|
|
}, [expanded]);
|
|
const expandedViewport = chartViewport(viewport.width, viewport.height);
|
|
return (
|
|
<>
|
|
<div className="apple-glass-card group relative max-w-[85%] rounded-[24px] p-5 shadow-lg transition-all">
|
|
<ChartCanvas spec={spec} data={data} width={520} />
|
|
<Button
|
|
variant="outline"
|
|
size="xs"
|
|
onClick={() => setExpanded(true)}
|
|
className="absolute end-3 top-3 opacity-0 transition-opacity group-hover:opacity-100 focus-visible:opacity-100 rounded-full"
|
|
>
|
|
<Trans>Expand</Trans>
|
|
</Button>
|
|
</div>
|
|
<Dialog open={expanded} onOpenChange={setExpanded}>
|
|
<DialogContent
|
|
showCloseButton={false}
|
|
className="max-h-[92vh] w-[min(1320px,94vw)] max-w-none overflow-auto p-8 sm:max-w-none"
|
|
>
|
|
<div className="flex items-center justify-between">
|
|
<DialogTitle className="text-ui-body-sm font-normal text-muted-foreground">
|
|
{name}
|
|
</DialogTitle>
|
|
<DialogClose
|
|
aria-label={t`Close chart`}
|
|
render={<Button variant="ghost" size="icon-sm" />}
|
|
>
|
|
<X />
|
|
</DialogClose>
|
|
</div>
|
|
<ChartCanvas
|
|
spec={spec}
|
|
data={data}
|
|
width={expandedViewport.width}
|
|
height={expandedViewport.height}
|
|
/>
|
|
</DialogContent>
|
|
</Dialog>
|
|
</>
|
|
);
|
|
}
|
|
|
|
export function ArtifactImage({
|
|
target,
|
|
artifactId,
|
|
name,
|
|
}: {
|
|
target: ArtifactTarget;
|
|
artifactId: string;
|
|
name: string;
|
|
}) {
|
|
const { t } = useLingui();
|
|
const [src, setSrc] = useState<string | null>(null);
|
|
const [open, setOpen] = useState(false);
|
|
const [visible, setVisible] = useState(false);
|
|
const container = useRef<HTMLDivElement>(null);
|
|
const targetBotId = "botId" in target ? target.botId : undefined;
|
|
const targetGroupId = "groupId" in target ? target.groupId : undefined;
|
|
|
|
useEffect(() => {
|
|
const element = container.current;
|
|
if (!element || typeof IntersectionObserver === "undefined") {
|
|
setVisible(true);
|
|
return;
|
|
}
|
|
const observer = new IntersectionObserver(
|
|
(entries) => {
|
|
if (entries.some((entry) => entry.isIntersecting)) {
|
|
setVisible(true);
|
|
observer.disconnect();
|
|
}
|
|
},
|
|
{ rootMargin: "320px" },
|
|
);
|
|
observer.observe(element);
|
|
return () => observer.disconnect();
|
|
}, []);
|
|
|
|
useEffect(() => {
|
|
if (!visible) return;
|
|
let cancelled = false;
|
|
let objectUrl: string | null = null;
|
|
setSrc(null);
|
|
void rpc.artifacts
|
|
.get(
|
|
targetBotId
|
|
? { botId: targetBotId, artifactId }
|
|
: { groupId: targetGroupId ?? "", artifactId },
|
|
)
|
|
.then((artifact) => {
|
|
const bytes = decodeArtifactBase64(artifact.contentBase64);
|
|
objectUrl = URL.createObjectURL(
|
|
new Blob([new Uint8Array(bytes)], { type: artifact.mimeType }),
|
|
);
|
|
if (cancelled) URL.revokeObjectURL(objectUrl);
|
|
else setSrc(objectUrl);
|
|
})
|
|
.catch(() => undefined);
|
|
return () => {
|
|
cancelled = true;
|
|
if (objectUrl) URL.revokeObjectURL(objectUrl);
|
|
};
|
|
}, [artifactId, targetBotId, targetGroupId, visible]);
|
|
|
|
return (
|
|
<div ref={container}>
|
|
{src ? (
|
|
<button
|
|
type="button"
|
|
onClick={() => setOpen(true)}
|
|
className="max-w-[240px] overflow-hidden rounded-[20px]"
|
|
>
|
|
<img src={src} alt={name} className="max-h-48 w-full object-cover" />
|
|
</button>
|
|
) : (
|
|
<div className="rounded-[20px] border border-border bg-muted px-4 py-3 text-ui-body text-muted-foreground">
|
|
{name}
|
|
</div>
|
|
)}
|
|
{src ? (
|
|
<Dialog open={open} onOpenChange={setOpen}>
|
|
<DialogContent
|
|
showCloseButton={false}
|
|
className="w-auto max-w-none bg-transparent p-0 ring-0 sm:max-w-none"
|
|
>
|
|
<DialogTitle className="sr-only">{name}</DialogTitle>
|
|
<DialogClose
|
|
aria-label={t`Close image preview`}
|
|
render={<Button variant="ghost" size="icon-sm" className="absolute end-2 top-2" />}
|
|
>
|
|
<X />
|
|
</DialogClose>
|
|
<img
|
|
src={src}
|
|
alt={name}
|
|
className="max-h-[85vh] max-w-[90vw] rounded-xl object-contain"
|
|
/>
|
|
</DialogContent>
|
|
</Dialog>
|
|
) : null}
|
|
</div>
|
|
);
|
|
}
|