evOo-local/apps/web/src/pages/shell/message-cards.tsx
Claude a62e09f36b
fix(web): word the main conversation preview in the user's language during onboarding
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
2026-10-04 04:17:32 +00:00

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>
);
}