feat: add Russian localization and independent STT languages
This commit is contained in:
@@ -1,4 +1,6 @@
|
||||
"use client";
|
||||
import { useI18n } from "@/components/i18n";
|
||||
|
||||
|
||||
import { useContext, useEffect, useMemo, useRef, useState } from "react";
|
||||
import { DownloadIcon } from "lucide-react";
|
||||
@@ -100,12 +102,12 @@ export function DemoOutputCard({
|
||||
messageId?: string;
|
||||
initialInverted?: boolean;
|
||||
}) {
|
||||
const { t } = useI18n();
|
||||
const result = output as DemoResult | undefined;
|
||||
if (!result || typeof result.html !== "string" || typeof result.title !== "string") {
|
||||
return (
|
||||
<div className="rounded-xl border bg-card p-4 text-sm text-muted-foreground">
|
||||
Demo completed, but the result could not be displayed.
|
||||
</div>
|
||||
{t("Demo completed, but the result could not be displayed.")}</div>
|
||||
);
|
||||
}
|
||||
const failed = result.html.length === 0;
|
||||
@@ -113,10 +115,9 @@ export function DemoOutputCard({
|
||||
return (
|
||||
<div className="rounded-xl border bg-card p-4 text-card-foreground" data-testid="demo-card-failed">
|
||||
<div className="flex items-center gap-2 text-xs font-medium uppercase tracking-wide text-muted-foreground">
|
||||
Interactive demo
|
||||
<span className="rounded bg-red-500/15 px-1.5 py-0.5 text-red-500">failed</span>
|
||||
{t("Interactive demo")}<span className="rounded bg-red-500/15 px-1.5 py-0.5 text-red-500">{t("failed")}</span>
|
||||
</div>
|
||||
<p className="mt-2 text-sm text-muted-foreground">{result.notes ?? "The demo could not be built."}</p>
|
||||
<p className="mt-2 text-sm text-muted-foreground">{result.notes ?? t("The demo could not be built.")}</p>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -136,6 +137,7 @@ function DemoFrame({
|
||||
messageId?: string;
|
||||
initialInverted?: boolean;
|
||||
}) {
|
||||
const { t } = useI18n();
|
||||
const actions = useContext(ChatActionsContext);
|
||||
const iframeRef = useRef<HTMLIFrameElement | null>(null);
|
||||
const [state, setState] = useState<DemoBridgeState>(INITIAL_DEMO_BRIDGE_STATE);
|
||||
@@ -177,22 +179,21 @@ function DemoFrame({
|
||||
<div className="flex items-center justify-between gap-2">
|
||||
<div className="flex min-w-0 items-baseline gap-2">
|
||||
<span className="shrink-0 text-xs font-medium uppercase tracking-wide text-muted-foreground">
|
||||
interactive demo
|
||||
</span>
|
||||
{t("interactive demo")}</span>
|
||||
<span className="truncate text-xs font-medium tracking-wide text-foreground">{title}</span>
|
||||
</div>
|
||||
<div className="flex shrink-0 items-center gap-1.5">
|
||||
<Toggle
|
||||
checked={inverted}
|
||||
onChange={toggleInverted}
|
||||
label="Invert demo brightness"
|
||||
title="Invert colors"
|
||||
label={t("Invert demo brightness")}
|
||||
title={t("Invert colors")}
|
||||
discIcon={<InvertDiscIcon className="size-3" />}
|
||||
/>
|
||||
<button
|
||||
type="button"
|
||||
aria-label="Download demo as HTML"
|
||||
title="Download as HTML"
|
||||
aria-label={t("Download demo as HTML")}
|
||||
title={t("Download as HTML")}
|
||||
onClick={() => downloadFile(`${slugFilename(title)}.html`, html, "text/html")}
|
||||
data-testid="demo-download"
|
||||
className="rounded-md p-1.5 text-muted-foreground transition-colors hover:bg-accent hover:text-foreground"
|
||||
@@ -205,7 +206,7 @@ function DemoFrame({
|
||||
<DemoErrorChip
|
||||
title={title}
|
||||
message={state.error}
|
||||
onSend={actions ? () => actions.sendText(`The demo "${title}" threw an error: ${state.error}`) : undefined}
|
||||
onSend={actions ? () => actions.sendText(t("The demo \"{v0}\" threw an error: {v1}", { v0: title, v1: state.error })) : undefined}
|
||||
/>
|
||||
)}
|
||||
<iframe
|
||||
@@ -245,12 +246,13 @@ export function DemoErrorChip({
|
||||
message: string;
|
||||
onSend?: () => void;
|
||||
}) {
|
||||
const { t } = useI18n();
|
||||
return (
|
||||
<div
|
||||
className="mt-2 flex flex-wrap items-center gap-2 rounded-lg bg-red-500/10 px-3 py-2 text-sm text-red-500"
|
||||
data-testid="demo-error-chip"
|
||||
>
|
||||
<span className="font-medium">This demo threw an error</span>
|
||||
<span className="font-medium">{t("This demo threw an error")}</span>
|
||||
<span className="min-w-0 flex-1 truncate font-mono text-xs" title={message}>
|
||||
{message}
|
||||
</span>
|
||||
@@ -261,8 +263,7 @@ export function DemoErrorChip({
|
||||
className="rounded-md border border-red-500/40 px-2 py-1 text-xs font-medium hover:bg-red-500/15"
|
||||
data-testid="demo-error-send"
|
||||
>
|
||||
Send to teacher
|
||||
</button>
|
||||
{t("Send to teacher")}</button>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
|
||||
@@ -1,4 +1,6 @@
|
||||
"use client";
|
||||
import { useI18n } from "@/components/i18n";
|
||||
|
||||
|
||||
import { useContext } from "react";
|
||||
import { CheckIcon, SettingsIcon, XIcon } from "lucide-react";
|
||||
@@ -27,12 +29,13 @@ const KIND_LABEL: Record<JobKind, string> = {
|
||||
};
|
||||
|
||||
export function DispatchCard({ toolCallId, kind }: { toolCallId: string; kind: JobKind }) {
|
||||
const { t } = useI18n();
|
||||
const jobs = useContext(JobsContext);
|
||||
const job = jobs.find((j) => j.toolCallId === toolCallId) ?? null;
|
||||
const label = KIND_LABEL[kind];
|
||||
|
||||
if (!job || job.status === "running") {
|
||||
return <DispatchActivityCard job={job} label={label} />;
|
||||
return <DispatchActivityCard job={job} label={t(label)} />;
|
||||
}
|
||||
// BYOK (owner, 2026-09-22): a job refused for missing models is INFO,
|
||||
// not scary-error — gray glyph + "needs model setup" + the deep-link
|
||||
@@ -41,10 +44,10 @@ export function DispatchCard({ toolCallId, kind }: { toolCallId: string; kind: J
|
||||
// whose key is gone reads as the key being needed — the deep-link
|
||||
// lands on the SetupDialog's first-key stage on its own.
|
||||
if (isModelsRefusal(job.error)) {
|
||||
return <DispatchRefusedCard label={label} line="needs model setup" button="Set up models" />;
|
||||
return <DispatchRefusedCard label={t(label)} line={t("needs model setup")} button={t("Set up models")} />;
|
||||
}
|
||||
if (isProviderKeyRefusal(job.error)) {
|
||||
return <DispatchRefusedCard label={label} line="needs a provider key" button="Add a key" />;
|
||||
return <DispatchRefusedCard label={t(label)} line={t("needs a provider key")} button={t("Add a key")} />;
|
||||
}
|
||||
const failed = job.status === "error";
|
||||
return (
|
||||
@@ -53,14 +56,15 @@ export function DispatchCard({ toolCallId, kind }: { toolCallId: string; kind: J
|
||||
data-testid="dispatch-settled"
|
||||
>
|
||||
{failed ? <XIcon className="size-3.5 text-red-500" /> : <CheckIcon className="size-3.5" />}
|
||||
<span className="font-medium uppercase tracking-wide">{label}</span>
|
||||
<span>{failed ? "failed" : "delivered"}</span>
|
||||
<span className="font-medium uppercase tracking-wide">{t(label)}</span>
|
||||
<span>{failed ? t("failed") : t("delivered")}</span>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
/** The refused card: quiet info tone — nothing red, nothing "failed". */
|
||||
function DispatchRefusedCard({ label, line, button }: { label: string; line: string; button: string }) {
|
||||
const { t } = useI18n();
|
||||
const ctx = useContext(ChatActionsContext);
|
||||
return (
|
||||
<div
|
||||
@@ -68,7 +72,7 @@ function DispatchRefusedCard({ label, line, button }: { label: string; line: str
|
||||
data-testid="dispatch-refused"
|
||||
>
|
||||
<SettingsIcon className="size-3.5" />
|
||||
<span className="font-medium uppercase tracking-wide">{label}</span>
|
||||
<span className="font-medium uppercase tracking-wide">{t(label)}</span>
|
||||
<span>{line}</span>
|
||||
{ctx && (
|
||||
<button
|
||||
@@ -85,6 +89,7 @@ function DispatchRefusedCard({ label, line, button }: { label: string; line: str
|
||||
}
|
||||
|
||||
function DispatchActivityCard({ job, label }: { job: JobRecord | null; label: string }) {
|
||||
const { t } = useI18n();
|
||||
// Elapsed from createdAt — jobs appear here only via the jobs feed (post-mount),
|
||||
// so no SSR/hydration mismatch.
|
||||
const elapsed =
|
||||
@@ -99,19 +104,18 @@ function DispatchActivityCard({ job, label }: { job: JobRecord | null; label: st
|
||||
the Braille spinner replaces the pulsing dot — one indicator
|
||||
everywhere. This is the ONLY dispatch-card change; the card's
|
||||
shape is untouched. */}
|
||||
<BrailleSpinner label={`${label} running`} />
|
||||
{label}
|
||||
{elapsed !== null && <span className="font-normal">· {elapsed}s</span>}
|
||||
<BrailleSpinner label={t("{v0} running", { v0: label })} />
|
||||
{t(label)}
|
||||
{elapsed !== null && <span className="font-normal">· {t("{v0}s", { v0: elapsed })}</span>}
|
||||
</div>
|
||||
{job?.activity && (
|
||||
<div className="mt-1 truncate text-sm text-muted-foreground" data-testid="dispatch-activity-line">
|
||||
{job.activity}
|
||||
{t(job.activity)}
|
||||
</div>
|
||||
)}
|
||||
{!job && (
|
||||
<div className="mt-1 text-sm text-muted-foreground" data-testid="dispatch-pending">
|
||||
Dispatched…
|
||||
</div>
|
||||
{t("Dispatched…")}</div>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
|
||||
@@ -13,7 +13,7 @@ export function slugFilename(title: string): string {
|
||||
.toLowerCase()
|
||||
.normalize("NFKD")
|
||||
.replace(/[\u0300-\u036f]/g, "")
|
||||
.replace(/[^a-z0-9]+/g, "-")
|
||||
.replace(/[^\p{L}\p{N}]+/gu, "-")
|
||||
.replace(/^-+|-+$/g, "")
|
||||
.slice(0, 64);
|
||||
return slug || "demo";
|
||||
|
||||
@@ -1,4 +1,6 @@
|
||||
"use client";
|
||||
import { useI18n } from "@/components/i18n";
|
||||
|
||||
|
||||
import { DispatchCard } from "@/components/artifacts/dispatch-card";
|
||||
import { MessageResponse } from "@/components/ai-elements/message";
|
||||
@@ -35,15 +37,15 @@ export function PdfJobCard({ part }: ArtifactPartProps) {
|
||||
* the same expand-on-demand idiom as research.
|
||||
*/
|
||||
export function PdfBriefCard({ output }: { output: unknown }) {
|
||||
const { t } = useI18n();
|
||||
const result = output as PdfResult | undefined;
|
||||
if (!result || typeof result.brief !== "string") {
|
||||
return (
|
||||
<div className="rounded-xl border bg-card p-4 text-sm text-muted-foreground">
|
||||
PDF reading completed, but the result could not be displayed.
|
||||
</div>
|
||||
{t("PDF reading completed, but the result could not be displayed.")}</div>
|
||||
);
|
||||
}
|
||||
const failed = result.brief.startsWith("PDF failed");
|
||||
const failed = result.brief.startsWith(t("PDF failed"));
|
||||
// Read = extract/render output entered the loop; consulted = every page
|
||||
// incl. search hits (the full audit set — the brief's citations may point
|
||||
// there). The ?? fallbacks tolerate pre-split persisted records.
|
||||
@@ -52,20 +54,19 @@ export function PdfBriefCard({ output }: { output: unknown }) {
|
||||
return (
|
||||
<div className="rounded-xl border bg-card p-4 text-card-foreground" data-testid="pdf-brief-card">
|
||||
<div className="mb-1 flex items-center gap-2 text-xs font-medium uppercase tracking-wide text-muted-foreground">
|
||||
PDF brief
|
||||
{failed && <span className="rounded bg-red-500/15 px-1.5 py-0.5 text-red-500">failed</span>}
|
||||
{t("PDF brief")}{failed && <span className="rounded bg-red-500/15 px-1.5 py-0.5 text-red-500">{t("failed")}</span>}
|
||||
</div>
|
||||
<div className="text-sm [&_p:first-child]:mt-0 [&_p:last-child]:mb-0">
|
||||
<MessageResponse>{result.brief}</MessageResponse>
|
||||
</div>
|
||||
{!failed && readList.length > 0 && (
|
||||
<div className="mt-3 border-t pt-2 text-xs text-muted-foreground">
|
||||
Pages read: {readList.join(", ")}
|
||||
{t("Pages read:")}{" "}{readList.join(", ")}
|
||||
</div>
|
||||
)}
|
||||
{!failed && consultedList.length > readList.length && (
|
||||
<div className="mt-3 border-t pt-2 text-xs text-muted-foreground">
|
||||
Pages consulted: {consultedList.join(", ")}
|
||||
{t("Pages consulted:")}{" "}{consultedList.join(", ")}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
@@ -77,27 +78,26 @@ export function PdfBriefCard({ output }: { output: unknown }) {
|
||||
* the steer card (sub-agent result delivery) renders the same artifact.
|
||||
*/
|
||||
export function ResearchBriefCard({ output }: { output: unknown }) {
|
||||
const { t } = useI18n();
|
||||
const result = output as ResearchResult | undefined;
|
||||
if (!result || typeof result.brief !== "string") {
|
||||
return (
|
||||
<div className="rounded-xl border bg-card p-4 text-sm text-muted-foreground">
|
||||
Research completed, but the result could not be displayed.
|
||||
</div>
|
||||
{t("Research completed, but the result could not be displayed.")}</div>
|
||||
);
|
||||
}
|
||||
const failed = result.brief.startsWith("Research failed");
|
||||
const failed = result.brief.startsWith(t("Research failed"));
|
||||
return (
|
||||
<div className="rounded-xl border bg-card p-4 text-card-foreground" data-testid="research-brief-card">
|
||||
<div className="mb-1 flex items-center gap-2 text-xs font-medium uppercase tracking-wide text-muted-foreground">
|
||||
Research brief
|
||||
{failed && <span className="rounded bg-red-500/15 px-1.5 py-0.5 text-red-500">failed</span>}
|
||||
{t("Research brief")}{failed && <span className="rounded bg-red-500/15 px-1.5 py-0.5 text-red-500">{t("failed")}</span>}
|
||||
</div>
|
||||
<div className="text-sm [&_p:first-child]:mt-0 [&_p:last-child]:mb-0">
|
||||
<MessageResponse>{result.brief}</MessageResponse>
|
||||
</div>
|
||||
{result.sources.length > 0 && (
|
||||
<div className="mt-3 border-t pt-2">
|
||||
<div className="mb-1 text-xs font-medium uppercase tracking-wide text-muted-foreground">Sources</div>
|
||||
<div className="mb-1 text-xs font-medium uppercase tracking-wide text-muted-foreground">{t("Sources")}</div>
|
||||
<ul className="flex flex-col gap-0.5 text-xs">
|
||||
{result.sources.map((s) => (
|
||||
<li key={s.url} className="truncate">
|
||||
|
||||
@@ -1,4 +1,6 @@
|
||||
"use client";
|
||||
import { useI18n } from "@/components/i18n";
|
||||
|
||||
|
||||
import { ChevronRight } from "lucide-react";
|
||||
import { BrailleSpinner } from "@/components/braille-spinner";
|
||||
@@ -28,6 +30,7 @@ interface StreamingToolPart {
|
||||
}
|
||||
|
||||
export function LiveToolCard({ part }: ArtifactPartProps) {
|
||||
const { t } = useI18n();
|
||||
const p = part as StreamingToolPart;
|
||||
const tool = p.type.replace(/^tool-/, "");
|
||||
const inp = (p.input ?? {}) as Record<string, unknown>;
|
||||
@@ -47,7 +50,7 @@ export function LiveToolCard({ part }: ArtifactPartProps) {
|
||||
className="group my-1 w-full max-w-2xl select-none"
|
||||
>
|
||||
<summary className="flex cursor-pointer list-none items-center gap-2 text-xs text-muted-foreground [&::-webkit-details-marker]:hidden">
|
||||
<BrailleSpinner label={`${toolActivityLabel(tool)} — live`} />
|
||||
<BrailleSpinner label={t("{v0} — live", { v0: toolActivityLabel(tool) })} />
|
||||
<Shimmer duration={1} className="text-xs">
|
||||
{toolActivityLabel(tool)}
|
||||
</Shimmer>
|
||||
@@ -55,7 +58,7 @@ export function LiveToolCard({ part }: ArtifactPartProps) {
|
||||
<ChevronRight className="size-3 shrink-0 opacity-50 transition-transform group-open:rotate-90" />
|
||||
</summary>
|
||||
<div className="mt-2 rounded-xl border bg-card p-3 text-xs">
|
||||
<div className="mb-1 font-medium uppercase tracking-wide text-muted-foreground">Input (streaming)</div>
|
||||
<div className="mb-1 font-medium uppercase tracking-wide text-muted-foreground">{t("Input (streaming)")}</div>
|
||||
<pre className="max-h-32 overflow-auto whitespace-pre-wrap break-all font-mono text-[11px] leading-relaxed">
|
||||
{hasInput ? JSON.stringify(p.input, null, 2) : "…"}
|
||||
</pre>
|
||||
|
||||
@@ -1,4 +1,6 @@
|
||||
"use client";
|
||||
import { useI18n } from "@/components/i18n";
|
||||
|
||||
|
||||
import { ClipboardCheckIcon } from "lucide-react";
|
||||
import { parseProbeDoneInput, type ProbeDoneResult } from "@/src/core";
|
||||
@@ -22,6 +24,7 @@ import { parseProbeDoneInput, type ProbeDoneResult } from "@/src/core";
|
||||
* the UI wave; this card just guarantees the data is never invisible.
|
||||
*/
|
||||
export function ProbeDoneCard({ part }: { part: { input?: unknown; output?: unknown; state?: unknown } }) {
|
||||
const { t } = useI18n();
|
||||
const output = part.output as ProbeDoneResult | undefined;
|
||||
const state = typeof part.state === "string" ? part.state : "";
|
||||
|
||||
@@ -43,14 +46,12 @@ export function ProbeDoneCard({ part }: { part: { input?: unknown; output?: unkn
|
||||
<div data-testid="probe-done-card" className="my-3 rounded-xl border bg-card/60 p-3">
|
||||
<div className="mb-2 flex items-center gap-2 text-xs font-medium uppercase tracking-wide text-muted-foreground">
|
||||
<ClipboardCheckIcon className="size-3.5 text-primary" />
|
||||
Probe concluded — your assessment
|
||||
</div>
|
||||
{t("Probe concluded — your assessment")}</div>
|
||||
{output && !output.ok ? (
|
||||
<p className="text-sm text-muted-foreground">The prober retried: {output.error}</p>
|
||||
<p className="text-sm text-muted-foreground">{t("The prober retried:")}{" "}{output.error}</p>
|
||||
) : state === "output-error" ? (
|
||||
<p className="text-sm text-muted-foreground">
|
||||
The assessment was malformed — the prober retried with a corrected one.
|
||||
</p>
|
||||
{t("The assessment was malformed — the prober retried with a corrected one.")}</p>
|
||||
) : strands ? (
|
||||
<div className="space-y-2">
|
||||
{strands.map((s, i) => (
|
||||
@@ -64,7 +65,7 @@ export function ProbeDoneCard({ part }: { part: { input?: unknown; output?: unkn
|
||||
)}
|
||||
</div>
|
||||
) : (
|
||||
<p className="text-sm text-muted-foreground">Writing the assessment…</p>
|
||||
<p className="text-sm text-muted-foreground">{t("Writing the assessment…")}</p>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
|
||||
@@ -1,4 +1,6 @@
|
||||
"use client";
|
||||
import { useI18n } from "@/components/i18n";
|
||||
|
||||
|
||||
import { CheckIcon, CircleX } from "lucide-react";
|
||||
import type { PdfResult, ResearchResult, SubagentArtifactData } from "@/src/core";
|
||||
@@ -26,6 +28,7 @@ import { VisualOutputCard } from "@/components/artifacts/visual-card";
|
||||
* render; only a failure note does.
|
||||
*/
|
||||
export function SubagentSteerCard({ message }: { message: LhViewMessage }) {
|
||||
const { t } = useI18n();
|
||||
const artifactPart = message.parts.find((p) => p.type === "data-subagent-artifact");
|
||||
const data = artifactPart?.data as SubagentArtifactData | undefined;
|
||||
// The abort steer (2026-09 dock semantics): its message-type part is
|
||||
@@ -38,8 +41,7 @@ export function SubagentSteerCard({ message }: { message: LhViewMessage }) {
|
||||
if (aborted?.data) return <AbortedBadge kind={aborted.data.kind ?? "job"} />;
|
||||
return (
|
||||
<div className="rounded-xl border bg-card p-4 text-sm text-muted-foreground">
|
||||
A sub-agent finished, but its result could not be displayed.
|
||||
</div>
|
||||
{t("A sub-agent finished, but its result could not be displayed.")}</div>
|
||||
);
|
||||
}
|
||||
switch (data.kind) {
|
||||
@@ -66,11 +68,12 @@ export function SubagentSteerCard({ message }: { message: LhViewMessage }) {
|
||||
* teacher's wake turn acknowledges the abort in the lesson itself,
|
||||
* this just marks what happened. */
|
||||
function AbortedBadge({ kind }: { kind: string }) {
|
||||
const { t } = useI18n();
|
||||
return (
|
||||
<div data-testid="job-aborted-badge" className="flex items-center gap-2 text-xs text-muted-foreground">
|
||||
<CircleX className="size-3.5" />
|
||||
<span className="font-medium uppercase tracking-wide">{kind}</span>
|
||||
<span>aborted by the learner</span>
|
||||
<span>{t("aborted by the learner")}</span>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -80,6 +83,7 @@ function AbortedBadge({ kind }: { kind: string }) {
|
||||
* the badge marks delivery, the collapsed details carries the full text
|
||||
* + consulted pages. */
|
||||
function PdfCompleteBadge({ result }: { result: PdfResult }) {
|
||||
const { t } = useI18n();
|
||||
// The honest count is READ pages (extract/render — sources v1 split);
|
||||
// the ?? is runtime tolerance for pre-split persisted steers.
|
||||
const readPages = result.pagesRead ?? result.pagesConsulted;
|
||||
@@ -88,14 +92,14 @@ function PdfCompleteBadge({ result }: { result: PdfResult }) {
|
||||
<summary className="flex cursor-pointer select-none list-none items-center gap-2 [&::-webkit-details-marker]:hidden">
|
||||
<CheckIcon className="size-3.5" />
|
||||
<span className="font-medium uppercase tracking-wide">PDF</span>
|
||||
<span>reading complete</span>
|
||||
<span>{t("reading complete")}</span>
|
||||
{readPages.length > 0 && (
|
||||
<span>
|
||||
· read {readPages.length} page{readPages.length === 1 ? "" : "s"}
|
||||
{t("· read {count} pages", { count: readPages.length })}
|
||||
</span>
|
||||
)}
|
||||
<span className="ml-1 text-xs text-muted-foreground/70 group-open:hidden">view brief</span>
|
||||
<span className="ml-1 hidden text-xs text-muted-foreground/70 group-open:inline">hide brief</span>
|
||||
<span className="ml-1 text-xs text-muted-foreground/70 group-open:hidden">{t("view brief")}</span>
|
||||
<span className="ml-1 hidden text-xs text-muted-foreground/70 group-open:inline">{t("hide brief")}</span>
|
||||
</summary>
|
||||
<div className="mt-2">
|
||||
<PdfBriefCard output={result} />
|
||||
@@ -110,15 +114,16 @@ function PdfCompleteBadge({ result }: { result: PdfResult }) {
|
||||
* affordance): full markdown + sources, one click away, no extra fetch —
|
||||
* the artifact part in the record is the single source of truth. */
|
||||
function ResearchCompleteBadge({ result }: { result: ResearchResult }) {
|
||||
const { t } = useI18n();
|
||||
return (
|
||||
<details data-testid="research-complete-badge" className="group w-full text-xs text-muted-foreground">
|
||||
<summary className="flex cursor-pointer select-none list-none items-center gap-2 [&::-webkit-details-marker]:hidden">
|
||||
<CheckIcon className="size-3.5" />
|
||||
<span className="font-medium uppercase tracking-wide">Research</span>
|
||||
<span>complete</span>
|
||||
{result.sources.length > 0 && <span>· {result.sources.length} sources</span>}
|
||||
<span className="ml-1 text-xs text-muted-foreground/70 group-open:hidden">view brief</span>
|
||||
<span className="ml-1 hidden text-xs text-muted-foreground/70 group-open:inline">hide brief</span>
|
||||
<span className="font-medium uppercase tracking-wide">{t("Research")}</span>
|
||||
<span>{t("complete")}</span>
|
||||
{result.sources.length > 0 && <span>· {t("{count} sources", { count: result.sources.length })}</span>}
|
||||
<span className="ml-1 text-xs text-muted-foreground/70 group-open:hidden">{t("view brief")}</span>
|
||||
<span className="ml-1 hidden text-xs text-muted-foreground/70 group-open:inline">{t("hide brief")}</span>
|
||||
</summary>
|
||||
<div className="mt-2">
|
||||
<ResearchBriefCard output={result} />
|
||||
|
||||
@@ -1,4 +1,6 @@
|
||||
"use client";
|
||||
import { useI18n } from "@/components/i18n";
|
||||
|
||||
|
||||
import { ChevronRight } from "lucide-react";
|
||||
import type { ArtifactPartProps } from "@/src/core";
|
||||
@@ -62,6 +64,7 @@ function outputText(output: unknown): string | null {
|
||||
}
|
||||
|
||||
export function ToolCallCard({ part }: ArtifactPartProps) {
|
||||
const { t } = useI18n();
|
||||
const p = part as ToolCallPart;
|
||||
const tool = p.type.replace(/^tool-/, "");
|
||||
const arg = headlineArg(tool, p.input);
|
||||
@@ -84,8 +87,7 @@ export function ToolCallCard({ part }: ArtifactPartProps) {
|
||||
{p.input != null && (
|
||||
<div>
|
||||
<div className="mb-1 font-medium uppercase tracking-wide text-muted-foreground">
|
||||
Input
|
||||
</div>
|
||||
{t("Input")}</div>
|
||||
<pre className="max-h-32 overflow-auto whitespace-pre-wrap break-all font-mono text-[11px] leading-relaxed">
|
||||
{JSON.stringify(p.input, null, 2)}
|
||||
</pre>
|
||||
@@ -94,24 +96,22 @@ export function ToolCallCard({ part }: ArtifactPartProps) {
|
||||
{typeof p.errorText === "string" ? (
|
||||
<div>
|
||||
<div className="mb-1 font-medium uppercase tracking-wide text-muted-foreground">
|
||||
Error
|
||||
</div>
|
||||
{t("Error")}</div>
|
||||
<pre className="whitespace-pre-wrap break-all font-mono text-[11px] text-red-500">
|
||||
{p.errorText}
|
||||
{t(p.errorText)}
|
||||
</pre>
|
||||
</div>
|
||||
) : out != null ? (
|
||||
<div>
|
||||
<div className="mb-1 font-medium uppercase tracking-wide text-muted-foreground">
|
||||
Output
|
||||
</div>
|
||||
{t("Output")}</div>
|
||||
<pre className="max-h-64 overflow-auto whitespace-pre-wrap break-all font-mono text-[11px] leading-relaxed">
|
||||
{out}
|
||||
</pre>
|
||||
</div>
|
||||
) : (
|
||||
p.state !== "output-available" && (
|
||||
<div className="text-muted-foreground italic">running…</div>
|
||||
<div className="text-muted-foreground italic">{t("running…")}</div>
|
||||
)
|
||||
)}
|
||||
</div>
|
||||
|
||||
@@ -1,4 +1,6 @@
|
||||
"use client";
|
||||
import { useI18n } from "@/components/i18n";
|
||||
|
||||
|
||||
import { useContext, useState } from "react";
|
||||
import { ChatActionsContext } from "@/components/chat";
|
||||
@@ -61,6 +63,7 @@ export function VisualOutputCard({
|
||||
messageId?: string;
|
||||
initialInverted?: boolean;
|
||||
}) {
|
||||
const { t } = useI18n();
|
||||
const result = output as VisualResult | undefined;
|
||||
const overlay = useSessionMermaid();
|
||||
const actions = useContext(ChatActionsContext);
|
||||
@@ -83,8 +86,7 @@ export function VisualOutputCard({
|
||||
if (!result || typeof result.svg !== "string") {
|
||||
return (
|
||||
<div className="rounded-xl border bg-card p-4 text-sm text-muted-foreground">
|
||||
Figure completed, but the result could not be displayed.
|
||||
</div>
|
||||
{t("Figure completed, but the result could not be displayed.")}</div>
|
||||
);
|
||||
}
|
||||
const failed = result.svg.length === 0;
|
||||
@@ -92,10 +94,9 @@ export function VisualOutputCard({
|
||||
return (
|
||||
<div className="rounded-xl border bg-card p-4 text-card-foreground" data-testid="visual-card-failed">
|
||||
<div className="flex items-center gap-2 text-xs font-medium uppercase tracking-wide text-muted-foreground">
|
||||
Figure
|
||||
<span className="rounded bg-red-500/15 px-1.5 py-0.5 text-red-500">failed</span>
|
||||
{t("Figure")}<span className="rounded bg-red-500/15 px-1.5 py-0.5 text-red-500">{t("failed")}</span>
|
||||
</div>
|
||||
<p className="mt-2 text-sm text-muted-foreground">{result.notes ?? "The figure could not be drawn."}</p>
|
||||
<p className="mt-2 text-sm text-muted-foreground">{result.notes ?? t("The figure could not be drawn.")}</p>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -130,21 +131,21 @@ export function VisualOutputCard({
|
||||
data-testid="visual-svg"
|
||||
/>
|
||||
<div className="mt-2 flex items-center justify-between gap-2">
|
||||
<div className="text-xs font-medium uppercase tracking-wide text-muted-foreground">Figure</div>
|
||||
<div className="text-xs font-medium uppercase tracking-wide text-muted-foreground">{t("Figure")}</div>
|
||||
<div className="flex items-center gap-1.5">
|
||||
{/* The invert affordance (owner round, 2026-10-07): the disc IS
|
||||
the half-filled contrast circle; the tooltip names it. */}
|
||||
<Toggle
|
||||
checked={inverted}
|
||||
onChange={toggleInverted}
|
||||
label="Invert figure brightness"
|
||||
title="Invert colors"
|
||||
label={t("Invert figure brightness")}
|
||||
title={t("Invert colors")}
|
||||
discIcon={<InvertDiscIcon className="size-3" />}
|
||||
/>
|
||||
<button
|
||||
type="button"
|
||||
aria-label="Download figure as SVG"
|
||||
title="Download as SVG"
|
||||
aria-label={t("Download figure as SVG")}
|
||||
title={t("Download as SVG")}
|
||||
onClick={() => downloadFile("figure.svg", result.svg, "image/svg+xml")}
|
||||
data-testid="visual-download"
|
||||
className="rounded-md p-1.5 text-muted-foreground transition-colors hover:bg-accent hover:text-foreground"
|
||||
@@ -154,8 +155,8 @@ export function VisualOutputCard({
|
||||
{overlay && (
|
||||
<button
|
||||
type="button"
|
||||
aria-label="Open figure fullscreen"
|
||||
title="Open fullscreen"
|
||||
aria-label={t("Open figure fullscreen")}
|
||||
title={t("Open fullscreen")}
|
||||
onClick={() => overlay.openFigure(result.svg, inverted)}
|
||||
data-testid="visual-open"
|
||||
className="rounded-md p-1.5 text-muted-foreground transition-colors hover:bg-accent hover:text-foreground"
|
||||
|
||||
Reference in New Issue
Block a user