evOo-local/apps/web/src/pages/shell/message-cards.tsx
Claude ce0e7fc8db
Merge main into the light theme branch
main now carries the token-based shell and chat from #104 and #107, so
this branch takes main's version of the shell, stage, styles and roster
row files and keeps what main still lacks: the light palette, the cn()
font-size fix, the centered stage bloom, and light-weight shadows for the
selected sidebar row and the stage orb.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_016or6oqCqP8wbNafvhnbLQT
2026-10-03 14:52:41 +00:00

565 lines
19 KiB
TypeScript

import { Trans, useLingui } from "@lingui/react/macro";
import type { MessageBlock } from "@rakazo/contracts";
import { abortableDelay } from "@rakazo/core";
import { Button, Dialog, DialogClose, DialogContent, DialogTitle } from "@rakazo/ui-web";
import { 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";
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 [locallyDismissed, setLocallyDismissed] = useState(false);
const dismissed = locallyDismissed || block.answerId === "_dismissed";
async function choose(optionId: string) {
setPending(true);
setError(null);
try {
await rpc.onboarding.choose({ botId, optionId });
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;
return (
<div className="flex justify-start">
<div className="apple-glass-card relative w-[440px] max-w-full rounded-[24px] px-5 py-4.5 transition-all">
{!block.answerId ? (
<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}
<div className="mt-3.5 space-y-2.5">
{block.options
.filter((option) => !block.answerId || option.id === block.answerId)
.map((option) => (
<button
key={option.id}
type="button"
data-testid={`focus-choice-${option.id}`}
aria-label={option.label}
disabled={Boolean(block.answerId) || pending}
onClick={() => void choose(option.id)}
className={`tactile-press flex w-full items-start gap-3.5 rounded-[18px] border border-foreground/5 p-3.5 text-start text-foreground disabled:opacity-60 transition-all ${
block.answerId === option.id
? "bg-blue-500/20 border-blue-500/40 shadow-sm"
: "bg-foreground/[0.04] hover:bg-foreground/[0.08] hover:border-foreground/10"
}`}
>
<span
className={`mt-0.5 grid h-6 w-6 shrink-0 place-items-center rounded-[8px] text-ui-body-sm font-semibold ${
block.answerId === option.id
? "bg-blue-500 text-white"
: "bg-foreground/10 text-foreground/80"
}`}
>
{option.letter}
</span>
<span
className={`flex-1 text-ui-body leading-snug ${block.answerId ? "text-foreground font-medium" : "text-foreground"}`}
>
{option.label}
</span>
{block.answerId === option.id ? (
<span className="mt-0.5 text-blue-700 dark:text-blue-400 font-bold">✓</span>
) : null}
</button>
))}
</div>
{error ? <p className="mt-2 text-ui-label text-destructive">{error}</p> : null}
</div>
</div>
);
}
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>
);
}