With the coach and notification modules merged, the onboarding answers now take effect: - the goal card creates a goal (source onboarding) through createGoal; - the rhythm card sets the main bot's check-in routines through setCoachRhythm, and the closing line names the first check-in from the routines that were saved; - the quiet-hours card updates the notification settings; - the choice card tells the server whether this browser can still turn push on, so the notifications card is offered only there, and "Turn on" subscribes before answering. The thread lock becomes NO KEY UPDATE so a rhythm routine, which references the thread, can be written while the answer holds the lock. The goal answer is capped at the coach title length. A Postgres suite walks the questions end to end: answers, skips, a dismissed card, a stale and a doubled answer, another bot's focus card, and another user's access. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01MRtGG9ARfqqhma3t85Ro2u
671 lines
23 KiB
TypeScript
671 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 { 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);
|
|
const answers = block.options.filter((option) => option.letter);
|
|
const waysOut = block.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 = block.answerText
|
|
? { id: CHOICE_TEXT_ANSWER_ID, letter: "", label: block.answerText }
|
|
: block.options.find((option) => option.id === block.answerId);
|
|
|
|
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">
|
|
{block.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}
|
|
{block.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={block.textInput.placeholder}
|
|
aria-label={block.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>
|
|
);
|
|
}
|