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
565 lines
19 KiB
TypeScript
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>
|
|
);
|
|
}
|