feat: add Russian localization and independent STT languages
CI / Typecheck, tests, lint (push) Canceled after 0s
CI / Standalone (mac-arm64) (push) Canceled after 0s
CI / Standalone (linux-x64) (push) Canceled after 0s
CI / Standalone (windows-x64) (push) Canceled after 0s

This commit is contained in:
2026-10-25 17:18:44 +03:00
parent b277ee3e0f
commit a50ca4b867
93 changed files with 3261 additions and 904 deletions
+9 -7
View File
@@ -1,4 +1,6 @@
"use client";
import { useI18n } from "@/components/i18n";
import { useEffect } from "react";
import { createPortal } from "react-dom";
@@ -60,13 +62,14 @@ export function AbortTurnBody({
* (owner, 2026-10-04): the line says so. */
busy?: boolean;
}) {
const { t } = useI18n();
const backdropDismiss = useBackdropDismiss(onClose);
const copy = COPY[mode];
return (
<div
role="dialog"
aria-modal="true"
aria-label={copy.title}
aria-label={t(copy.title)}
data-testid="abort-turn-dialog"
className="fixed inset-0 z-50 flex items-center justify-center bg-background/80 backdrop-blur-sm"
// A genuine click on the scrim cancels — the cautious default, like Esc.
@@ -77,11 +80,11 @@ export function AbortTurnBody({
onClick={(e) => e.stopPropagation()}
>
<h2 className="text-sm font-semibold text-card-foreground" data-testid="abort-turn-title">
{copy.title}
{t(copy.title)}
</h2>
<p className="mt-1.5 text-xs text-muted-foreground" data-testid="abort-turn-line">
{copy.line}
{busy && mode !== "turn" ? " The working turn stops too." : ""}
{t(copy.line)}
{busy && mode !== "turn" ? t(" The working turn stops too.") : ""}
</p>
<div className="mt-4 flex justify-end gap-2">
<button
@@ -90,8 +93,7 @@ export function AbortTurnBody({
onClick={onClose}
className="rounded-sm px-3 py-1.5 text-xs text-muted-foreground transition-colors hover:text-foreground"
>
Cancel
</button>
{t("Cancel")}</button>
<button
type="button"
data-testid="abort-turn-confirm"
@@ -99,7 +101,7 @@ export function AbortTurnBody({
// Destructive red — the app's established destructive pair.
className="rounded-sm border border-destructive/50 bg-destructive/10 px-3 py-1.5 text-xs text-destructive transition-colors hover:bg-destructive/20"
>
{copy.action}
{t(copy.action)}
</button>
</div>
</div>
+33 -29
View File
@@ -1,4 +1,7 @@
"use client";
import { translator, type Language } from "@/src/core";
import { useI18n } from "@/components/i18n";
import { createContext, useCallback, useContext, useEffect, useMemo, useRef, useState } from "react";
import { ActivityIcon, BotIcon, FileTextIcon, PenToolIcon, PlayIcon, SearchIcon, SquareIcon, WaypointsIcon } from "lucide-react";
@@ -257,12 +260,13 @@ export function toBlobs(feed: ActivityFeed): ActivityBlob[] {
}
/** Elapsed as a compact ticking string: 45s / 3m 04s / 1h 12m. */
export function formatElapsed(ms: number): string {
export function formatElapsed(ms: number, language: Language = "en"): string {
const t = translator(language);
const s = Math.max(0, Math.floor(ms / 1000));
if (s < 60) return `${s}s`;
if (s < 60) return t("{v0}s", { v0: s });
const m = Math.floor(s / 60);
if (m < 60) return `${m}m ${String(s % 60).padStart(2, "0")}s`;
return `${Math.floor(m / 60)}h ${String(m % 60).padStart(2, "0")}m`;
if (m < 60) return t("{v0}m {v1}s", { v0: m, v1: String(s % 60).padStart(2, "0") });
return t("{v0}h {v1}m", { v0: Math.floor(m / 60), v1: String(m % 60).padStart(2, "0") });
}
// ─── The section (the sidebar's bottom strip) ─────────────────────────────────
@@ -289,6 +293,7 @@ const KIND_ICON: Record<JobKind, typeof SearchIcon> = {
type AbortState = "pending" | "failed";
export function ActivitySection({ feed: feedProp }: { feed?: ActivityFeed }) {
const { t } = useI18n();
const ctx = useContext(ActivityContext);
const feed = feedProp ?? ctx.feed;
const refresh = ctx.refresh;
@@ -347,9 +352,8 @@ export function ActivitySection({ feed: feedProp }: { feed?: ActivityFeed }) {
<section data-testid="activity-section" className="border-t border-border px-3 pb-3 pt-2">
<div className="mb-1 flex items-center gap-1.5 text-xs uppercase tracking-widest text-muted-foreground/70">
<ActivityIcon className="size-3" />
Background agents
{blobs.length > 0 && (
<span className="ml-auto tabular-nums" data-testid="activity-total-cost" title="Total running cost (inferred)">
{t("Background agents")}{blobs.length > 0 && (
<span className="ml-auto tabular-nums" data-testid="activity-total-cost" title={t("Total running cost (inferred)")}>
{planCostLabel(totalCost, subscriptionLabels(blobs.map((blob) => blob.model)))}
</span>
)}
@@ -389,19 +393,20 @@ function ActivityBlobRow({
abortState: AbortState | undefined;
onAbort: () => void;
}) {
const { t, language } = useI18n();
const Icon = blob.process === "extraction" ? WaypointsIcon : (blob.jobKind && KIND_ICON[blob.jobKind]) || BotIcon;
const label =
blob.process === "extraction"
? `Extraction — ${blob.phase ?? "running"}`
? t("Extraction — {v0}", { v0: t(blob.phase ?? "running") })
: blob.jobKind === "research"
? "Researcher"
? t("Researcher")
: blob.jobKind === "demo"
? "Demo builder"
? t("Demo builder")
: blob.jobKind === "visual"
? "Figure maker"
? t("Figure maker")
: blob.jobKind === "pdf"
? "PDF reader"
: "Sub-agent";
? t("PDF reader")
: t("Sub-agent");
return (
<div
data-testid={`activity-blob-${blob.process}`}
@@ -417,8 +422,8 @@ function ActivityBlobRow({
<span className="min-w-0 flex-1">
<span className="block truncate font-medium text-card-foreground">{blob.title}</span>
{blob.activity ? (
<span className="block truncate text-muted-foreground" title={blob.activity}>
{blob.activity}
<span className="block truncate text-muted-foreground" title={t(blob.activity)}>
{t(blob.activity)}
</span>
) : (
<span className="block truncate text-muted-foreground/80">{label}</span>
@@ -426,7 +431,7 @@ function ActivityBlobRow({
</span>
<span className="flex shrink-0 items-center gap-2 tabular-nums text-muted-foreground">
<span data-testid="activity-blob-cost">{planCostLabel(blob.cost, subscriptionLabel(blob.model))}</span>
<span data-testid="activity-blob-elapsed">{formatElapsed(now - blob.startedAt)}</span>
<span data-testid="activity-blob-elapsed">{formatElapsed(now - blob.startedAt, language)}</span>
</span>
</button>
{/* The explicit END always on the blob's face (SCOPE) — the popover
@@ -437,8 +442,8 @@ function ActivityBlobRow({
data-testid="activity-abort"
onClick={onAbort}
disabled={abortState !== undefined}
title={abortState === "failed" ? "Abort failed — try again" : "End this background process"}
aria-label={`End ${label} in ${blob.title}`}
title={abortState === "failed" ? t("Abort failed — try again") : t("End this background process")}
aria-label={t("End {v0} in {v1}", { v0: label, v1: blob.title })}
className="absolute right-1 top-1/2 -translate-y-1/2 rounded-md p-1 text-muted-foreground/70 transition-colors hover:bg-destructive/10 hover:text-destructive disabled:opacity-50"
>
<SquareIcon className="size-3" />
@@ -453,47 +458,46 @@ function ActivityBlobRow({
<Icon className="size-3.5 text-muted-foreground" />
<span className="font-medium">{label}</span>
<span className="ml-auto tabular-nums text-muted-foreground">
{formatElapsed(now - blob.startedAt)}
{formatElapsed(now - blob.startedAt, language)}
</span>
</div>
<div className="flex flex-col gap-1 text-muted-foreground">
<div className="truncate text-card-foreground" title={blob.title}>
{blob.title}
</div>
{blob.activity && <div className="break-words">{blob.activity}</div>}
{blob.activity && <div className="break-words">{t(blob.activity)}</div>}
<div className="flex justify-between gap-2">
<span>Cost</span>
<span>{t("Cost")}</span>
<span className="tabular-nums text-card-foreground">
{planCostLabel(blob.cost, subscriptionLabel(blob.model))}
</span>
</div>
<div className="flex justify-between gap-2">
<span>Context</span>
<span>{t("Context")}</span>
<span className="tabular-nums text-card-foreground" data-testid="activity-blob-context">
{blob.context === null
? "—"
: `${formatTokens(blob.context.used)} / ${formatTokens(blob.context.limit)}`}
: `${formatTokens(blob.context.used, language)} / ${formatTokens(blob.context.limit, language)}`}
</span>
</div>
{blob.model && (
<div className="flex justify-between gap-2">
<span>Model</span>
<span>{t("Model")}</span>
<span className="truncate text-card-foreground">{blob.model}</span>
</div>
)}
{blob.steps !== undefined && (
<div className="flex justify-between gap-2">
<span>Steps</span>
<span>{t("Steps")}</span>
<span className="tabular-nums text-card-foreground">{blob.steps}</span>
</div>
)}
<a href={`/chat/${blob.chatId}`} className="text-primary hover:underline">
Open session →
</a>
{t("Open session →")}</a>
</div>
<div className="mt-2 text-muted-foreground/80">
{abortState === "failed" && "Abort failed — try again"}
{abortState === "pending" && "Stopping…"}
{abortState === "failed" && t("Abort failed — try again")}
{abortState === "pending" && t("Stopping…")}
</div>
</div>
)}
+13 -3
View File
@@ -1,4 +1,6 @@
"use client";
import { useI18n } from "@/components/i18n";
import { Button } from "@/components/ui/button";
import {
@@ -31,6 +33,8 @@ import {
import remarkBreaks from "remark-breaks";
import {
Streamdown,
defaultTranslations,
type StreamdownTranslations,
type PluginConfig,
} from "streamdown";
import { mdComponents } from "@/components/md-blocks";
@@ -264,11 +268,12 @@ export const MessageBranchPrevious = ({
children,
...props
}: MessageBranchPreviousProps) => {
const { t } = useI18n();
const { goToPrevious, totalBranches } = useMessageBranch();
return (
<Button
aria-label="Previous branch"
aria-label={t("Previous branch")}
disabled={totalBranches <= 1}
onClick={goToPrevious}
size="icon-sm"
@@ -287,11 +292,12 @@ export const MessageBranchNext = ({
children,
...props
}: MessageBranchNextProps) => {
const { t } = useI18n();
const { goToNext, totalBranches } = useMessageBranch();
return (
<Button
aria-label="Next branch"
aria-label={t("Next branch")}
disabled={totalBranches <= 1}
onClick={goToNext}
size="icon-sm"
@@ -310,6 +316,7 @@ export const MessageBranchPage = ({
className,
...props
}: MessageBranchPageProps) => {
const { t } = useI18n();
const { currentBranch, totalBranches } = useMessageBranch();
return (
@@ -320,7 +327,7 @@ export const MessageBranchPage = ({
)}
{...props}
>
{currentBranch + 1} of {totalBranches}
{currentBranch + 1}{" "}{t("of")}{" "}{totalBranches}
</ButtonGroupText>
);
};
@@ -357,6 +364,8 @@ export const streamdownPlugins = {
export const MessageResponse = memo(
({ className, children, ...props }: MessageResponseProps) => {
const { t } = useI18n();
const translations = Object.fromEntries(Object.entries(defaultTranslations).map(([key, text]) => [key, t(text)])) as Partial<StreamdownTranslations>;
// Legacy backslash delimiters are converted in a single linear scan;
// native dollar math passes through to Streamdown unchanged. Incomplete
// legacy spans are left for a later render when their closer arrives.
@@ -373,6 +382,7 @@ export const MessageResponse = memo(
"lh-prose size-full [&>*:first-child]:mt-0 [&>*:last-child]:mb-0",
className
)}
translations={translations}
components={mdComponents}
plugins={streamdownPlugins}
{...props}
+8 -5
View File
@@ -1,4 +1,5 @@
"use client";
import { useI18n } from "@/components/i18n";
import { useControllableState } from "@radix-ui/react-use-controllable-state";
import { blurAfterPointerClick } from "@/components/focus-hygiene";
@@ -134,20 +135,22 @@ export type ReasoningTriggerProps = ComponentProps<
for a few seconds"), bookkeeping for information we knowingly lose. The
label pairs with the user's collapsed "Your thinking" rows — one
language; motion (spinner + shimmer) stays the working signal. */
const defaultGetThinkingMessage = (isStreaming: boolean) => {
const DefaultThinkingMessage = ({ isStreaming }: { isStreaming: boolean }) => {
const { t } = useI18n();
if (isStreaming) {
return <Shimmer duration={1}>Thinking...</Shimmer>;
return <Shimmer duration={1}>{t("Thinking...")}</Shimmer>;
}
return <p>AGENT’S THINKING</p>;
return <p>{t("AGENT’S THINKING")}</p>;
};
export const ReasoningTrigger = memo(
({
className,
children,
getThinkingMessage = defaultGetThinkingMessage,
getThinkingMessage = (isStreaming) => <DefaultThinkingMessage isStreaming={isStreaming} />,
...props
}: ReasoningTriggerProps) => {
const { t } = useI18n();
const { isStreaming, isOpen } = useReasoning();
return (
@@ -175,7 +178,7 @@ export const ReasoningTrigger = memo(
the component default (h-3) — IDENTICAL to the placeholder
spinner (owner, 2026-10-02): the handoff between the two
states must be visually seamless, one size everywhere. */}
{isStreaming && <BrailleSpinner label="Thinking" />}
{isStreaming && <BrailleSpinner label={t("Thinking")} />}
{getThinkingMessage(isStreaming)}
<ChevronDownIcon
className={cn(
+19 -17
View File
@@ -1,4 +1,6 @@
"use client";
import { useI18n } from "@/components/i18n";
import { useState } from "react";
import { ConfidenceRail } from "@/components/confidence-rail";
@@ -20,12 +22,12 @@ import { parseAskInput, parseQuizInput } from "@/src/core";
* the explanation, with NO verdict chips (owner ruling 2026-09-27).
*/
export function AnsweredCard({ answer, input }: { answer: QuestionAnswerData; input?: unknown }) {
const { t } = useI18n();
const [whyOpen, setWhyOpen] = useState(false); // the tucked explanation (predictions, owner 2026-10-03)
if (answer.dismissed) {
return (
<div className="rounded-xl border bg-card p-4 text-sm text-muted-foreground">
<span className="font-medium">{answer.questionText}</span> — skipped
</div>
<span className="font-medium">{answer.questionText}</span> {" "}{t("— skipped")}</div>
);
}
@@ -51,7 +53,7 @@ export function AnsweredCard({ answer, input }: { answer: QuestionAnswerData; in
ThinkingBlock, instead of an inset inside the answer. Same muted
rail as before; the label is now plain "Your thinking". */}
{answer.readingThinking && (
<ThinkingBlock testId="reading-thinking-block" label="YOUR THINKING" text={answer.readingThinking} defaultOpen />
<ThinkingBlock testId="reading-thinking-block" label={t("YOUR THINKING")} text={answer.readingThinking} defaultOpen />
)}
{/* [&>*:last-child]:mb-0 (owner round, 2026-10-07): on ask cards the
thinking row is the last child — its my-3 stacked with the card's
@@ -66,23 +68,23 @@ export function AnsweredCard({ answer, input }: { answer: QuestionAnswerData; in
cards label Probe/Recall — the phase boundary visible in the
transcript. No `tool` on old records: kind labels as before. */}
{answer.tool === "probe"
? "Probe"
? t("Probe")
: answer.tool === "recall"
? "Recall"
? t("Recall")
: isQuiz
? answer.questionType === "predict"
? "Prediction"
: "Check"
? t("Prediction")
: t("Check")
: isExplain
? "Explain"
: "Question"}
? t("Explain")
: t("Question")}
{isQuiz &&
(answer.dontKnow ? (
<span className="rounded bg-muted px-1.5 py-0.5">didn't know</span>
<span className="rounded bg-muted px-1.5 py-0.5">{t("didn't know")}</span>
) : answer.correct ? (
<span className="rounded bg-green-500/15 px-1.5 py-0.5 text-green-500">correct</span>
<span className="rounded bg-green-500/15 px-1.5 py-0.5 text-green-500">{t("correct")}</span>
) : (
<span className="rounded bg-red-500/15 px-1.5 py-0.5 text-red-500">incorrect</span>
<span className="rounded bg-red-500/15 px-1.5 py-0.5 text-red-500">{t("incorrect")}</span>
))}
</div>
<div className="mb-3 font-medium">
@@ -176,7 +178,7 @@ export function AnsweredCard({ answer, input }: { answer: QuestionAnswerData; in
</div>
)}
{answer.dontKnow && isQuiz && (
<div className="mt-2 text-sm text-muted-foreground">Answered “I don't know”.</div>
<div className="mt-2 text-sm text-muted-foreground">{t("Answered “I don't know”.")}</div>
)}
{(answer.thinking ?? answer.note) && (
@@ -186,7 +188,7 @@ export function AnsweredCard({ answer, input }: { answer: QuestionAnswerData; in
the accent lift read too light); rail only, heights identical. */
<ThinkingBlock
testId="your-thinking"
label="YOUR THINKING"
label={t("YOUR THINKING")}
text={answer.thinking ?? answer.note ?? ""}
inCard
/>
@@ -206,11 +208,11 @@ export function AnsweredCard({ answer, input }: { answer: QuestionAnswerData; in
onClick={() => setWhyOpen((v) => !v)}
className="text-xs font-medium text-muted-foreground transition-colors hover:text-foreground"
>
{whyOpen ? "Hide why" : "Why"}
{whyOpen ? t("Hide why") : t("Why")}
</button>
{whyOpen && (
<div className="mt-1.5 rounded-lg bg-muted py-2 text-sm text-foreground/50">
<span className="font-medium text-muted-foreground">Why: </span>
<span className="font-medium text-muted-foreground">{t("Why:")}{" "}</span>
{/* The markdown wrapper must go truly inline — a block <p> after the
"Why:" label created the oversized leading gap (owner's fix list). */}
<MessageResponse className="inline [&_p]:inline [&_p]:m-0">{answer.explanation}</MessageResponse>
@@ -224,7 +226,7 @@ export function AnsweredCard({ answer, input }: { answer: QuestionAnswerData; in
the model's next reply IS the grading. */}
{isExplain && answer.committed && (
<div className="mt-3 rounded-lg bg-muted py-2 text-sm text-muted-foreground">
<span className="font-medium text-foreground">The answer: </span>
<span className="font-medium text-foreground">{t("The answer:")}{" "}</span>
<MessageResponse className="inline [&_p]:inline [&_p]:m-0">{answer.committed}</MessageResponse>
</div>
)}
+16 -15
View File
@@ -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>
);
+16 -12
View File
@@ -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>
);
+1 -1
View File
@@ -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";
+13 -13
View File
@@ -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">
+5 -2
View File
@@ -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>
+7 -6
View File
@@ -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>
);
+17 -12
View File
@@ -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} />
+8 -8
View File
@@ -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>
+13 -12
View File
@@ -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"
+5 -1
View File
@@ -1,3 +1,6 @@
"use client";
import { useI18n } from "@/components/i18n";
import { cn } from "@/lib/utils";
/**
@@ -43,12 +46,13 @@ export function BrailleSpinner({
/** The accessible name of the state this spinner announces. */
label?: string;
}) {
const { t } = useI18n();
return (
<svg
viewBox="0 0 4 8"
data-testid="braille-spinner"
role="status"
aria-label={label}
aria-label={t(label)}
className={cn("h-3 w-auto shrink-0 text-foreground", className)}
>
{FRAMES.map((frame, k) => (
+26 -26
View File
@@ -1,4 +1,7 @@
"use client";
import { countLabel } from "@/src/core";
import { useI18n } from "@/components/i18n";
/**
* THE CHAIN TABLE (chains overlay; owner refinements round 2, 2026-09-29):
@@ -143,6 +146,7 @@ export function ChainTableBody({
selectMode?: boolean;
onSelect?: () => void;
}) {
const { t, language } = useI18n();
const byId = useMemo(() => new Map(nodes.map((n) => [n.id, n])), [nodes]);
const chains = useMemo(() => chainsForSelection(nodes, storedChains), [nodes, storedChains]);
const searching = query.trim() !== "";
@@ -246,7 +250,7 @@ export function ChainTableBody({
law: later in the DOM). */}
<TopBar>
<span className="min-w-0 flex-1 truncate text-xs text-muted-foreground">
{chains.length} {chains.length === 1 ? "chain" : "chains"} · {nodes.length} {nodes.length === 1 ? "node" : "nodes"}
{countLabel(language, chains.length, "chain", "chains")} · {countLabel(language, nodes.length, "node", "nodes")}
</span>
<div className="relative shrink-0">
<SearchIcon className="absolute left-2.5 top-1/2 size-3.5 -translate-y-1/2 text-muted-foreground" />
@@ -254,8 +258,8 @@ export function ChainTableBody({
value={query}
data-testid="chain-table-search"
onChange={(e) => onQuery(e.target.value)}
placeholder="Search nodes…"
aria-label="Search nodes"
placeholder={t("Search nodes…")}
aria-label={t("Search nodes")}
spellCheck={false}
className="lh-no-drag h-8 w-56 rounded-lg border bg-background pl-8 pr-3 text-xs outline-none placeholder:text-muted-foreground focus:border-ring"
/>
@@ -264,7 +268,7 @@ export function ChainTableBody({
type="button"
data-testid="chain-table-close"
onClick={onClose}
aria-label="Close chains"
aria-label={t("Close chains")}
className="lh-no-drag flex size-8 shrink-0 items-center justify-center rounded-lg border border-border/70 bg-card/80 text-muted-foreground transition-colors hover:bg-accent hover:text-foreground"
>
<XIcon className="size-4" />
@@ -288,7 +292,7 @@ export function ChainTableBody({
aria-sort={sort.col === "name" ? (sort.dir === 1 ? "ascending" : "descending") : "none"}
className="flex flex-1 items-center gap-1 text-left transition-colors hover:text-foreground"
>
Name {dirIcon("name")}
{t("Name")}{" "}{dirIcon("name")}
</button>
<button
type="button"
@@ -297,7 +301,7 @@ export function ChainTableBody({
aria-sort={sort.col === "props" ? (sort.dir === 1 ? "ascending" : "descending") : "none"}
className="flex w-14 shrink-0 items-center justify-end gap-1 text-right transition-colors hover:text-foreground"
>
Props {dirIcon("props")}
{t("Props")}{" "}{dirIcon("props")}
</button>
<button
type="button"
@@ -306,14 +310,13 @@ export function ChainTableBody({
aria-sort={sort.col === "created" ? (sort.dir === 1 ? "ascending" : "descending") : "none"}
className="flex w-20 shrink-0 items-center justify-end gap-1 text-right transition-colors hover:text-foreground"
>
Created {dirIcon("created")}
{t("Created")}{" "}{dirIcon("created")}
</button>
</div>
{groups.length === 0 && (
<div data-testid="chain-table-nomatch" className="px-4 pt-3 text-xs text-muted-foreground">
No matching chains.
</div>
{t("No matching chains.")}</div>
)}
{groups.map((group) => {
@@ -348,15 +351,15 @@ export function ChainTableBody({
indeterminate={selectedCount > 0 && selectedCount < group.nodeIds.length}
onToggle={(next) => onToggleNodes(group.nodeIds, next)}
testid={`cluster-checkbox-${group.key}`}
label={`Select everything born in ${group.label ?? "unlabeled"}`}
label={t("Select everything born in {v0}", { v0: group.label ?? "unlabeled" })}
/>
<Expander open={open} onClick={() => onToggleCluster(group.key)} testid={`cluster-toggle-${group.key}`} label={`Expand cluster ${group.label ?? "unlabeled"}`} />
<Expander open={open} onClick={() => onToggleCluster(group.key)} testid={`cluster-toggle-${group.key}`} label={t("Expand cluster {v0}", { v0: group.label ?? "unlabeled" })} />
<span className="flex shrink-0 items-center justify-center text-muted-foreground">
{open ? <FolderOpenIcon className="size-3.5" /> : <FolderIcon className="size-3.5" />}
</span>
<span className="min-w-0 flex-1 truncate text-sm font-medium">
{group.label ?? <span className="text-muted-foreground/60">unlabeled</span>}
{group.kind === "notes" && <span className="ml-2 text-[11px] uppercase tracking-widest text-muted-foreground/60">notes</span>}
{group.label ?? <span className="text-muted-foreground/60">{t("unlabeled")}</span>}
{group.kind === "notes" && <span className="ml-2 text-[11px] uppercase tracking-widest text-muted-foreground/60">{t("notes")}</span>}
</span>
<span className="w-14 shrink-0 text-right text-xs tabular-nums text-muted-foreground">
{group.nodeIds.length}
@@ -397,9 +400,9 @@ export function ChainTableBody({
indeterminate={selectedInChain > 0 && selectedInChain < memberIds.length}
onToggle={(next) => onToggleNodes(memberIds, next)}
testid={`chain-checkbox-${chain.id}`}
label={`Select chain ${nameOf(chain.conclusion)}`}
label={t("Select chain {v0}", { v0: nameOf(chain.conclusion) })}
/>
<Expander open={chainOpen} onClick={() => onToggleChain(chain.id)} testid={`chain-toggle-${chain.id}`} label={`Expand chain ${nameOf(chain.conclusion)}`} />
<Expander open={chainOpen} onClick={() => onToggleChain(chain.id)} testid={`chain-toggle-${chain.id}`} label={t("Expand chain {v0}", { v0: nameOf(chain.conclusion) })} />
{/* The chain line: proposition names as faint-outlined
chips. THE LOGIC IS THE PAYLOAD (owner rulings,
2026-09-30): premises join with the LaTeX LAND (∧),
@@ -465,7 +468,7 @@ export function ChainTableBody({
checked={selection.has(id)}
onToggle={(next) => onToggleNodes([id], next)}
testid={`prop-checkbox-${id}`}
label={`Select node ${node.name}`}
label={t("Select node {v0}", { v0: node.name })}
/>
<span className="min-w-0 flex-1 text-xs leading-relaxed text-muted-foreground">
{node.statement}
@@ -493,8 +496,8 @@ export function ChainTableBody({
className="pointer-events-auto flex items-center gap-3 rounded-full border bg-card px-4 py-2 shadow-lg"
>
<span data-testid="chain-selected-summary" className="text-xs font-medium">
{selection.size} {selection.size === 1 ? "node" : "nodes"} →{" "}
{derived.length} {derived.length === 1 ? "chain" : "chains"}
{countLabel(language, selection.size, "node", "nodes")} →{" "}
{countLabel(language, derived.length, "chain", "chains")}
</span>
<button
type="button"
@@ -502,19 +505,17 @@ export function ChainTableBody({
onClick={onClear}
className="rounded-full px-2.5 py-1 text-xs text-muted-foreground transition-colors hover:bg-accent hover:text-foreground"
>
Clear
</button>
{t("Clear")}</button>
{selectMode ? (
<button
type="button"
data-testid="chain-practice-done"
onClick={onSelect}
aria-label={`Done picking — ${selection.size} ${selection.size === 1 ? "proposition" : "propositions"} selected`}
aria-label={t("Done picking — {v0} {v1} selected", { v0: selection.size, v1: selection.size === 1 ? "proposition" : "propositions" })}
className="flex items-center gap-1.5 rounded-full bg-primary px-3 py-1.5 text-xs font-medium text-primary-foreground transition-colors hover:bg-primary/80"
>
<DumbbellIcon aria-hidden="true" className="size-3.5" />
Done
</button>
{t("Done")}</button>
) : (
<>
{/* The danger fence (owner ruling): a divider quarantines
@@ -526,12 +527,11 @@ export function ChainTableBody({
type="button"
data-testid="chain-delete"
onClick={onDelete}
aria-label={`Delete ${selection.size} selected ${selection.size === 1 ? "node" : "nodes"}`}
aria-label={t("Delete {v0} selected {v1}", { v0: selection.size, v1: selection.size === 1 ? "node" : "nodes" })}
className="flex items-center gap-1.5 rounded-full border border-destructive/50 bg-destructive/10 px-3 py-1.5 text-xs font-medium text-destructive transition-colors hover:bg-destructive/20"
>
<Trash2Icon aria-hidden="true" className="size-3.5" />
Delete
</button>
{t("Delete")}</button>
</>
)}
</div>
+23 -25
View File
@@ -1,4 +1,6 @@
"use client";
import { useI18n } from "@/components/i18n";
import { createContext, Fragment, useCallback, useEffect, useLayoutEffect, useMemo, useRef, useState } from "react";
import { useChatSession } from "@/components/session";
@@ -225,6 +227,7 @@ export function Chat({
* only decides whether the pill rides the composer. */
initialConfigured?: boolean;
}) {
const { t } = useI18n();
// Reattach plumbing (background-turn runner wave): the hook holds the
// settled terminal's callback — a stable ref assigned below, once
// syncFromServer exists (refs, not closures: no stale capture).
@@ -1236,8 +1239,8 @@ export function Chat({
}}
onBlur={() => void commitRename(titleDraft)}
spellCheck={false}
aria-label="Session title"
title="Rename this session"
aria-label={t("Session title")}
title={t("Rename this session")}
className="lh-no-drag min-w-[8ch] max-w-[70%] shrink-0 bg-transparent font-title text-3xl tracking-tight caret-current outline-none [field-sizing:content]"
/>
{/* flex-1 spacer during rename too, so the chips stay at
@@ -1249,7 +1252,7 @@ export function Chat({
<>
<Hint
// F2 renames (registry) — surfaced on the hover.
label="Rename session"
label={t("Rename session")}
action="rename-session"
side="bottom"
>
@@ -1273,7 +1276,7 @@ export function Chat({
structure beside the phase chip — access to the full-DAG popup
at every width; the marginalia is the wide-screen glance view. */}
{planTree && (
<Hint label="Open the full plan" side="bottom">
<Hint label={t("Open the full plan")} side="bottom">
<button
type="button"
data-testid="plan-chip"
@@ -1284,8 +1287,7 @@ export function Chat({
className="lh-no-drag flex shrink-0 cursor-pointer items-center gap-1.5 rounded-full border border-border/80 px-2 py-0.5 text-xs font-medium uppercase tracking-wide text-muted-foreground transition-colors hover:border-border hover:text-foreground"
>
<WaypointsIcon className="size-3.5 text-primary" aria-hidden />
Plan
</button>
{t("Plan")}</button>
</Hint>
)}
{/* The extraction ring (owner-locked redesign, 2026-09-24): fills
@@ -1306,8 +1308,7 @@ export function Chat({
className="pointer-events-none flex shrink-0 select-none items-center gap-1 text-xs font-medium uppercase tracking-wide text-muted-foreground"
>
<DumbbellIcon className="size-3" />
Practice
</span>
{t("Practice")}</span>
) : (
<PhaseChip phase={phases.at(-1) ?? "probe"} />
)}
@@ -1321,8 +1322,8 @@ export function Chat({
<div data-session-find-root="" className={`${COLUMN} flex flex-1 flex-col gap-4 pt-4`}>
{messages.length === 0 && !busy && (
<ConversationEmptyState
title="New session"
description="The teacher will open by probing what you already know."
title={t("New session")}
description={t("The teacher will open by probing what you already know.")}
/>
)}
@@ -1373,8 +1374,7 @@ export function Chat({
className="my-4 flex items-center justify-center gap-1.5 font-ui text-xs font-medium uppercase tracking-wide text-muted-foreground"
>
<ArrowRightIcon className="size-3" />
Next problem
</div>
{t("Next problem")}</div>
);
default:
return null;
@@ -1432,7 +1432,7 @@ export function Chat({
{/* The rail GROUPS (owner round 4, 2026-10-05): the YOU heading
sits INSIDE the rail, same mono-xs register and same 1ch
rail as the thinking rows — one quote geometry. */}
<div className="font-medium uppercase tracking-wide text-muted-foreground">You</div>
<div className="font-medium uppercase tracking-wide text-muted-foreground">{t("You")}</div>
<div className="lh-prose whitespace-pre-wrap text-foreground">
{m.parts.filter((p) => p.type === "text").map((p) => p.text).join("\n")}
</div>
@@ -1465,8 +1465,7 @@ export function Chat({
) {
return (
<SystemLine key={`retry-${pi}`} testId="tool-retry">
Retrying…
</SystemLine>
{t("Retrying…")}</SystemLine>
);
}
switch (part.type) {
@@ -1562,21 +1561,20 @@ export function Chat({
BEFORE beginTurn; the settle kick runs after endTurn). */}
{compacting && (
<span className="flex items-center gap-2 font-ui text-xs font-medium uppercase tracking-wide text-muted-foreground">
<BrailleSpinner label="Compacting context" />
<Shimmer duration={1}>COMPACTING CONTEXT…</Shimmer>
<BrailleSpinner label={t("Compacting context")} />
<Shimmer duration={1}>{t("COMPACTING CONTEXT…")}</Shimmer>
</span>
)}
{(status === "submitted" || turnInFlight) && (
<span className="flex items-center gap-2 font-ui text-xs font-medium uppercase tracking-wide text-muted-foreground">
<BrailleSpinner label="Thinking" />
<BrailleSpinner label={t("Thinking")} />
<Shimmer duration={1}>
THINKING…
</Shimmer>
{t("THINKING…")}</Shimmer>
</span>
)}
{error && (
<div className="rounded-lg border border-destructive/50 bg-destructive/10 px-3 py-2 text-sm">
Something went wrong: {error.message}
{t("Something went wrong:")}{" "}{t(error.message)}
</div>
)}
</div>
@@ -1625,8 +1623,7 @@ export function Chat({
onClick={() => setSetupOpen(true)}
className="rounded-sm border border-border bg-card px-3 py-1.5 font-ui text-xs text-foreground transition-colors hover:border-muted-foreground/50 hover:bg-accent cursor-pointer"
>
Set up your models
</button>
{t("Set up your models")}</button>
</div>
)
)
@@ -1650,7 +1647,7 @@ export function Chat({
// applies.
nextProblem={nextReady}
onNext={sendNext}
placeholder={nextReady ? "Ask anything, or press Enter for the next one." : undefined}
placeholder={nextReady ? t("Ask anything, or press Enter for the next one.") : undefined}
/>
}
onSubmitAnswer={(output) => {
@@ -1889,6 +1886,7 @@ function DockOverlay({
* home and /chat/new carry, above the composer while slots are unset. */
setupCta?: React.ReactNode;
}) {
const { t } = useI18n();
const { isAtBottom, scrollToBottom, scrollRef } = useStickToBottomContext();
// Render-fresh bottom state (flicker round): the arrival block below reads
// it DURING RENDER — the same value the after-paint effect used to read a
@@ -2365,7 +2363,7 @@ function DockOverlay({
mid-question unmount), and widening past the floor fades it
back in. */}
{pending && plan && (
<Hint label="Open the full plan" side="bottom">
<Hint label={t("Open the full plan")} side="bottom">
<button
type="button"
data-testid="plan-marginalia"
+7 -4
View File
@@ -1,4 +1,6 @@
"use client";
import { useI18n } from "@/components/i18n";
import type { CompactionDetails } from "@/src/core";
@@ -19,11 +21,11 @@ export interface CompactionDividerData {
* history was just summarized; the cut divider below carries the seam.
* Inert since release polish 2026-10-06 — plain text, no affordance. */
export function CompactionMomentDivider({ data: _data }: { data: CompactionDividerData }) {
const { t } = useI18n();
return (
<div className="flex w-full items-center justify-center py-1">
<span data-testid="compaction-divider" className="px-1.5 text-xs text-muted-foreground/70">
Context compacted
</span>
{t("Context compacted")}</span>
</div>
);
}
@@ -35,12 +37,13 @@ export function CompactionMomentDivider({ data: _data }: { data: CompactionDivid
* since release polish 2026-10-06 — the token count stays, the summary
* is inspectable only in provider logs. */
export function CompactionCutDivider({ data }: { data: CompactionDividerData }) {
const kTok = data.details.tokensBefore > 0 ? ` · ${Math.round(data.details.tokensBefore / 1000)}k tok` : "";
const { t } = useI18n();
const kTok = data.details.tokensBefore > 0 ? t(" · {v0}k tok", { v0: Math.round(data.details.tokensBefore / 1000) }) : "";
return (
<div data-testid="compaction-cut-divider" className="my-4 flex w-full items-center gap-3">
<span className="h-0 flex-1 border-t-2 border-dotted border-border/50" aria-hidden />
<span className="rounded-full border border-border/80 px-2.5 py-0.5 text-xs font-medium uppercase tracking-wide text-muted-foreground">
History compacted{kTok}
{t("History compacted")}{kTok}
</span>
<span className="h-0 flex-1 border-t-2 border-dotted border-border/50" aria-hidden />
</div>
+5 -3
View File
@@ -1,4 +1,6 @@
"use client";
import { useI18n } from "@/components/i18n";
import type { ReactNode } from "react";
import { DumbbellIcon } from "lucide-react";
@@ -25,6 +27,7 @@ export function NodePickerControl({ count, onOpen, onClear }: {
onOpen?: () => void;
onClear?: () => void;
}) {
const { t } = useI18n();
return (
<div className="flex items-center gap-1.5">
<button
@@ -39,7 +42,7 @@ export function NodePickerControl({ count, onOpen, onClear }: {
}
>
<DumbbellIcon aria-hidden="true" className="size-3.5 text-muted-foreground" />
{count > 0 ? `${count} ${count === 1 ? "node" : "nodes"} selected` : "Pick nodes"}
{count > 0 ? t("{v0} {v1} selected", { v0: count, v1: count === 1 ? "node" : "nodes" }) : t("Pick nodes")}
</button>
{count > 0 && (
<button
@@ -48,8 +51,7 @@ export function NodePickerControl({ count, onOpen, onClear }: {
onClick={onClear}
className="cursor-pointer rounded-full px-2 py-1 font-ui text-xs text-muted-foreground transition-colors hover:bg-accent hover:text-foreground"
>
Clear
</button>
{t("Clear")}</button>
)}
</div>
);
+7 -5
View File
@@ -1,4 +1,6 @@
"use client";
import { useI18n } from "@/components/i18n";
import { ArrowRightIcon, CornerDownLeftIcon, SquareIcon } from "lucide-react";
import { cn } from "@/lib/utils";
@@ -75,6 +77,7 @@ export function Composer({
* empty-Enter share it. */
onNext?: () => void;
}) {
const { t } = useI18n();
const act = () => {
const action = enterAction({ busy, canSend, nextProblem });
if (action === "stop") onStop();
@@ -113,7 +116,7 @@ export function Composer({
act();
}
}}
placeholder={placeholder}
placeholder={t(placeholder)}
autoFocus={autoFocus}
rows={1}
className="field-sizing-content lh-input max-h-48 min-h-10 flex-1 resize-none bg-transparent px-2.5 py-2 outline-none placeholder:text-muted-foreground"
@@ -136,16 +139,15 @@ export function Composer({
<button
type="submit"
data-testid="next-problem-button"
aria-label="Next problem"
aria-label={t("Next problem")}
className="flex h-10 shrink-0 items-center justify-center gap-1.5 rounded-[10px] bg-green-600 px-4 font-ui text-xs font-medium uppercase tracking-wide text-white transition-colors hover:bg-green-500"
>
Next problem
<ArrowRightIcon className="size-4" />
{t("Next problem")}<ArrowRightIcon className="size-4" />
</button>
) : (
<button
type="submit"
aria-label={busy && !canSend ? "Stop" : "Send"}
aria-label={busy && !canSend ? t("Stop") : t("Send")}
disabled={!busy && !canSend}
className={cn(
"flex size-10 shrink-0 items-center justify-center rounded-[10px] transition-colors",
+13 -11
View File
@@ -1,4 +1,6 @@
"use client";
import { useI18n } from "@/components/i18n";
import { useEffect, useState } from "react";
import { createPortal } from "react-dom";
@@ -45,17 +47,18 @@ export function DeleteNodeBody({
onClose: () => void;
onRun: () => void;
}) {
const { t } = useI18n();
return (
<div
role="dialog"
aria-modal="true"
aria-label="Delete node"
aria-label={t("Delete node")}
data-testid="delete-node-dialog"
onClick={(e) => e.stopPropagation()}
className="w-[32rem] max-w-[calc(100vw-2rem)] rounded-xl border bg-card p-5 shadow-xl"
>
<h2 className="text-sm font-semibold text-destructive">
{nodes.length === 1 ? "Delete node?" : `Delete ${nodes.length} nodes?`}
{nodes.length === 1 ? t("Delete node?") : t("Delete {v0} nodes?", { v0: nodes.length })}
</h2>
{/* The node name(s) below the title in JetBrains Mono (owner,
2026-09-24): the name is the payload — mono suits it, and
@@ -74,14 +77,14 @@ export function DeleteNodeBody({
) : (
<code data-testid="node-names-code" className="font-mono text-sm text-foreground">
{nodes.slice(0, 8).map((n) => n.name).join(", ")}
{nodes.length > 8 ? `, and ${nodes.length - 8} more` : ""}
{nodes.length > 8 ? t(", and {v0} more", { v0: nodes.length - 8 }) : ""}
</code>
)}
</div>
<p className="mt-2 text-sm text-muted-foreground" data-testid="node-permanence-line">
{nodes.length === 1
? "This permanently deletes the node — this cannot be undone."
: `This permanently deletes all ${nodes.length} nodes — this cannot be undone.`}
? t("This permanently deletes the node — this cannot be undone.")
: t("This permanently deletes all {v0} nodes — this cannot be undone.", { v0: nodes.length })}
</p>
{failure && (
<div
@@ -90,7 +93,7 @@ export function DeleteNodeBody({
className="mt-3 rounded-sm border border-border/60 px-3 py-2 text-xs"
>
<span className="text-foreground">
{failure.code === "extraction_running" ? EXTRACTION_RETRY_LINE : failure.error}
{t(failure.code === "extraction_running" ? EXTRACTION_RETRY_LINE : failure.error)}
</span>
<button
type="button"
@@ -99,8 +102,7 @@ export function DeleteNodeBody({
disabled={busy}
className="ml-3 text-xs text-foreground underline-offset-2 hover:underline disabled:opacity-40"
>
Try again
</button>
{t("Try again")}</button>
</div>
)}
<div className="mt-4 flex justify-end gap-2">
@@ -111,8 +113,7 @@ export function DeleteNodeBody({
disabled={busy}
className="rounded-sm px-3 py-1.5 text-xs text-muted-foreground transition-colors hover:text-foreground disabled:opacity-40"
>
Cancel
</button>
{t("Cancel")}</button>
<button
type="button"
data-testid="node-delete-confirm"
@@ -123,7 +124,7 @@ export function DeleteNodeBody({
// refusal banner): border + text, fill on hover.
className="rounded-sm border border-destructive/50 bg-destructive/10 px-3 py-1.5 text-xs text-destructive transition-colors hover:bg-destructive/20 disabled:opacity-40"
>
{busy ? "Deleting…" : "Delete"}
{busy ? t("Deleting…") : t("Delete")}
</button>
</div>
</div>
@@ -146,6 +147,7 @@ export function DeleteNodeDialog({
* selection-side callers can prune. */
onDeleted: (nodeIds: string[], graphRevision: number) => void;
}) {
const { t } = useI18n();
const [busy, setBusy] = useState(false);
const backdropDismiss = useBackdropDismiss(() => {
if (!busy) onClose();
+13 -13
View File
@@ -1,4 +1,6 @@
"use client";
import { useI18n } from "@/components/i18n";
import { useEffect, useState } from "react";
import { createPortal } from "react-dom";
@@ -50,6 +52,7 @@ export function DeleteSessionDialog({
/** Success path — the sidebar owns navigation + list patching. */
onDeleted: (id: string, mode: ChatDeleteMode, graphRevision: number) => void;
}) {
const { t } = useI18n();
const [traces, setTraces] = useState(false);
const [busy, setBusy] = useState(false);
const backdropDismiss = useBackdropDismiss(() => {
@@ -155,16 +158,17 @@ export function DeleteSessionBody({
onClose: () => void;
onRun: () => void;
}) {
const { t } = useI18n();
return (
<div
role="dialog"
aria-modal="true"
aria-label="Delete session"
aria-label={t("Delete session")}
data-testid="delete-session-dialog"
onClick={(e) => e.stopPropagation()}
className="w-[32rem] max-w-[calc(100vw-2rem)] rounded-xl border bg-card p-5 shadow-xl"
>
<h2 className="text-sm font-semibold text-destructive">Delete session?</h2>
<h2 className="text-sm font-semibold text-destructive">{t("Delete session?")}</h2>
{/* The session name below the title in JetBrains Mono (owner,
2026-09-24): no code-block chrome — the font already says
code. Owner, 2026-10-13 polish: normal text-sm size (was
@@ -176,9 +180,7 @@ export function DeleteSessionBody({
</code>
</div>
<p className="mt-2 text-sm text-muted-foreground">
This permanently removes the session — its messages, background jobs, and extraction records. Everything
it added to the graph stays unless you check below. This cannot be undone.
</p>
{t("This permanently removes the session — its messages, background jobs, and extraction records. Everything it added to the graph stays unless you check below. This cannot be undone.")}</p>
<label
data-testid={TRACES_COPY.testid}
className="mt-4 flex cursor-pointer items-start gap-2.5 rounded-lg border border-border/60 px-4 py-3"
@@ -192,8 +194,8 @@ export function DeleteSessionBody({
className="mt-0.5 size-4 shrink-0 accent-primary"
/>
<span className="min-w-0">
<span className="block text-sm text-foreground">{TRACES_COPY.title}</span>
<span className="mt-1 block text-xs text-muted-foreground">{TRACES_COPY.body}</span>
<span className="block text-sm text-foreground">{t(TRACES_COPY.title)}</span>
<span className="mt-1 block text-xs text-muted-foreground">{t(TRACES_COPY.body)}</span>
</span>
</label>
{failure && (
@@ -202,7 +204,7 @@ export function DeleteSessionBody({
data-testid="delete-failure"
className="mt-3 rounded-sm border border-border/60 px-3 py-2 text-xs"
>
<span className="text-foreground">{failure.error}</span>
<span className="text-foreground">{t(failure.error)}</span>
<button
type="button"
data-testid="delete-retry"
@@ -210,8 +212,7 @@ export function DeleteSessionBody({
disabled={busy}
className="ml-3 text-xs text-foreground underline-offset-2 hover:underline disabled:opacity-40"
>
Try again
</button>
{t("Try again")}</button>
</div>
)}
{/* The node dialog's exact bottom row: quiet Cancel, destructive
@@ -225,8 +226,7 @@ export function DeleteSessionBody({
disabled={busy}
className="rounded-sm px-3 py-1.5 text-xs text-muted-foreground transition-colors hover:text-foreground disabled:opacity-40"
>
Cancel
</button>
{t("Cancel")}</button>
<button
type="button"
data-testid="delete-confirm"
@@ -234,7 +234,7 @@ export function DeleteSessionBody({
disabled={busy}
className="rounded-sm border border-destructive/50 bg-destructive/10 px-3 py-1.5 text-xs text-destructive transition-colors hover:bg-destructive/20 disabled:opacity-40"
>
{busy ? "Deleting…" : "Delete"}
{busy ? t("Deleting…") : t("Delete")}
</button>
</div>
</div>
+22 -17
View File
@@ -3,6 +3,8 @@
import { useCallback, useEffect, useRef, useState } from "react";
import { Loader2Icon, MicIcon } from "lucide-react";
import { useSettingsDialog } from "@/components/settings/settings-dialog";
import { dictationUrl, type Language } from "@/src/core";
import { LanguageSelect, useI18n } from "@/components/i18n";
import { Hint } from "@/components/ui/hint";
/**
@@ -86,15 +88,7 @@ import { Hint } from "@/components/ui/hint";
* endpointing=300, no interims. Silence streams too (the worklet never
* pauses), so the socket never idles — no KeepAlive needed.
*/
const DG_URL =
"wss://api.deepgram.com/v1/listen" +
"?model=nova-3" +
"&encoding=linear16" +
"&sample_rate=16000" +
"&channels=1" +
"&smart_format=true" +
"&punctuate=true" +
"&endpointing=300";
/** Meter tuning — the owner's tested values, see the file header. */
const METER_FLOOR_DB = -50;
@@ -285,6 +279,10 @@ export type Dictation = {
* question waits.
*/
export function useDictation({ append }: { append: (text: string) => void }): Dictation {
const { sttLanguage, setDictating } = useI18n();
const selectedLanguage = useRef(sttLanguage);
selectedLanguage.current = sttLanguage;
const recordingLanguage = useRef<Language>(sttLanguage);
const [phase, setPhase] = useState<DictationPhase>("idle");
const [error, setError] = useState<string | null>(null);
/** Latest RMS from the worklet — the meter samples this (dictate pattern). */
@@ -330,6 +328,7 @@ export function useDictation({ append }: { append: (text: string) => void }): Di
const cleanup = useCallback(() => {
genRef.current++;
dictationActiveFlag = false;
setDictating(false);
flush(); // the deferred transcript appears when the run ends (owner round 2)
if (safetyRef.current) {
clearTimeout(safetyRef.current);
@@ -366,7 +365,7 @@ export function useDictation({ append }: { append: (text: string) => void }): Di
// Minted JWTs authenticate via the "bearer" subprotocol — ["token",
// jwt] is rejected at the handshake (verified live; "token" is for
// raw API keys only, as in the owner's pi dictate extension).
const ws = new WebSocket(DG_URL, ["bearer", token]);
const ws = new WebSocket(dictationUrl(recordingLanguage.current), ["bearer", token]);
wsRef.current = ws;
ws.addEventListener("open", () => {
@@ -426,6 +425,8 @@ export function useDictation({ append }: { append: (text: string) => void }): Di
);
const start = useCallback(async () => {
recordingLanguage.current = selectedLanguage.current;
setDictating(true);
dictationActiveFlag = true;
setError(null);
setPhase("connecting");
@@ -622,6 +623,7 @@ export function useDictation({ append }: { append: (text: string) => void }): Di
* (full text in the tooltip), cleared on the next click.
*/
export function DictationBar({ dictation }: { dictation: Dictation }) {
const { t } = useI18n();
const { phase, error, levelRef, toggle, active, clearError } = dictation;
const { open: openSettings } = useSettingsDialog();
/** The keyless click (zero-keys wave, owner dictation trio): the errored
@@ -673,12 +675,13 @@ export function DictationBar({ dictation }: { dictation: Dictation }) {
if (!active) {
return (
<div className="flex w-full min-w-0 items-center gap-1.5">
<Hint
// Errored: the button span truncates the line — the tooltip is its
// full reveal (the keyless line KEEPS "Deepgram" — owner overrule:
// it's the Keys row label, the truth the user needs). Otherwise the
// quiet label + the live ⌘D binding.
label={error ?? "Dictate"}
label={error ? t(error) : t("Dictate")}
action={error ? undefined : "toggle-dictation"}
side="top"
>
@@ -692,16 +695,18 @@ export function DictationBar({ dictation }: { dictation: Dictation }) {
toggle();
}
}}
aria-label={error ? `Dictation failed: ${error}` : "Start dictation"}
aria-label={error ? t("Dictation failed: {v0}", { v0: error }) : t("Start dictation")}
data-testid="dictation-toggle"
// Same blurring as the floating chrome (owner, fix wave round 3),
// but lower opacity — the mic stays quieter than the model pills.
className="flex h-7 w-full items-center justify-center gap-1.5 rounded-md border border-border/50 bg-card/50 text-xs text-muted-foreground/70 shadow-lg backdrop-blur transition-colors hover:border-border hover:text-muted-foreground"
>
<MicIcon className="size-3.5" />
<span className="max-w-full truncate">{error ?? "Dictate"}</span>
<span className="max-w-full truncate">{error ? t(error) : t("Dictate")}</span>
</button>
</Hint>
<LanguageSelect field="sttLanguage" compact />
</div>
);
}
@@ -710,14 +715,14 @@ export function DictationBar({ dictation }: { dictation: Dictation }) {
<Hint
// The owner flagged this copy good ("Stop dictation (Esc)") —
// kept verbatim; only the machinery moved to the styled tooltip.
label="Stop dictation (Esc)"
label={t("Stop dictation (Esc)")}
side="top"
>
<button
type="button"
onClick={toggle}
disabled={phase === "stopping"}
aria-label="Stop dictation"
aria-label={t("Stop dictation")}
data-testid="dictation-toggle"
className="flex h-7 shrink-0 items-center gap-1.5 rounded-md bg-destructive/10 px-2 text-xs font-medium text-destructive transition-colors hover:bg-destructive/20"
>
@@ -726,13 +731,13 @@ export function DictationBar({ dictation }: { dictation: Dictation }) {
) : (
<MicIcon className="size-3.5" />
)}
Stop
</button>
{t("Stop")}</button>
</Hint>
<span className="size-1.5 shrink-0 animate-pulse rounded-full bg-destructive" aria-hidden />
{/* Fixed width while recording — nothing ever appends text beside it
(no interims, owner round 1). Cells carry no minimum width; the
COUNT adapts to the measured row space (owner round 2). */}
<LanguageSelect field="sttLanguage" compact disabled />
<div ref={meterRef} className="flex h-4 min-w-0 flex-1 items-center gap-[2px]" data-testid="dictation-meter">
{levels.slice(-cellCount).map((v, i) => (
<span
+22 -23
View File
@@ -1,4 +1,6 @@
"use client";
import { useI18n } from "@/components/i18n";
import { useCallback, useEffect, useRef, useState } from "react";
import { useActivity } from "@/components/activity-dock";
@@ -183,6 +185,7 @@ export function CoverageMenuBody({
extracting: boolean;
onExtract: () => void;
}) {
const { t, language } = useI18n();
const busy = coverage.running || extracting;
const complete = coverage.uncoveredTokens === 0 && coverage.uncoveredCount === 0;
return (
@@ -190,17 +193,15 @@ export function CoverageMenuBody({
{busy ? (
<span className="flex items-center gap-1.5 text-muted-foreground" data-testid="ctx-coverage-running">
<span className="size-1.5 animate-pulse rounded-full bg-foreground/70" aria-hidden />
extracting…
</span>
{t("extracting…")}</span>
) : complete ? (
<span className="text-muted-foreground">fully extracted</span>
<span className="text-muted-foreground">{t("fully extracted")}</span>
) : (
<span className="flex flex-col">
<span className="tabular-nums text-foreground">
{formatTokens(coverage.uncoveredTokens)} tok uncovered
</span>
{formatTokens(coverage.uncoveredTokens, language)}{" "}{t("tok uncovered")}</span>
<span className="tabular-nums text-muted-foreground">
{coverage.uncoveredCount} message{coverage.uncoveredCount === 1 ? "" : "s"} unextracted
{t("{count} messages unextracted", { count: coverage.uncoveredCount })}
</span>
</span>
)}
@@ -211,7 +212,7 @@ export function CoverageMenuBody({
disabled={busy || complete}
className="flex items-center justify-center rounded-lg border px-2 py-1 text-muted-foreground transition-colors hover:bg-accent hover:text-foreground disabled:opacity-40 disabled:hover:bg-transparent"
>
{busy ? "Running" : "Extract"}
{busy ? t("Running") : t("Extract")}
</button>
</div>
);
@@ -241,6 +242,7 @@ export function ringFill(uncoveredTokens: number, chunkTokens: number): number {
* threshold/kill-switch re-read live).
*/
export function CoverageRing({ chatId, refreshKey = 0 }: { chatId: string; refreshKey?: number }) {
const { t } = useI18n();
const [graph, setGraph] = useState<GraphRead | null>(null);
const [coverage, setCoverage] = useState<SessionCoverage | null>(null);
const [open, setOpen] = useState(false);
@@ -301,7 +303,7 @@ export function CoverageRing({ chatId, refreshKey = 0 }: { chatId: string; refre
<div ref={rootRef} className="lh-no-drag relative shrink-0">
<Hint
// Trimmed (owner, subtlety round 2026-09-25).
label="How much of this session isn't mapped yet"
label={t("How much of this session isn't mapped yet")}
side="bottom"
>
<button
@@ -359,46 +361,43 @@ export function CoverageRingGlyph({ fill, running }: { fill: number; running: bo
* covered/uncovered tokens, message coverage, the threshold, the last
* fold's age. */
export function CoverageRingDetail({ coverage, chunkTokens }: { coverage: SessionCoverage; chunkTokens: number }) {
const { t, language } = useI18n();
return (
<>
<div className="mb-2 text-xs font-medium uppercase tracking-wide text-muted-foreground">
Extraction coverage
</div>
{t("Extraction coverage")}</div>
<div className="flex flex-col gap-1 text-muted-foreground">
<div className="flex justify-between gap-2">
<span>Uncovered</span>
<span>{t("Uncovered")}</span>
<span className="tabular-nums text-card-foreground">
{formatTokens(coverage.uncoveredTokens)} tokens
</span>
{formatTokens(coverage.uncoveredTokens, language)}{" "}{t("tokens")}</span>
</div>
<div className="flex justify-between gap-2">
<span>Covered</span>
<span>{t("Covered")}</span>
<span className="tabular-nums text-card-foreground">
{formatTokens(coverage.coveredTokens)} tokens
</span>
{formatTokens(coverage.coveredTokens, language)}{" "}{t("tokens")}</span>
</div>
<div className="flex justify-between gap-2">
<span>Messages</span>
<span>{t("Messages")}</span>
<span className="tabular-nums text-card-foreground">
{coverage.coveredCount} / {coverage.totalMessages}
</span>
</div>
<div className="flex justify-between gap-2">
<span>Chunk threshold</span>
<span>{t("Chunk threshold")}</span>
<span className="tabular-nums text-card-foreground">
{formatTokens(chunkTokens)} tokens
</span>
{formatTokens(chunkTokens, language)}{" "}{t("tokens")}</span>
</div>
<div className="flex justify-between gap-2">
<span>Last extraction</span>
<span>{t("Last extraction")}</span>
<span className="tabular-nums text-card-foreground">
{coverage.lastFoldedAt ? timeAgo(coverage.lastFoldedAt) : "—"}
</span>
</div>
{coverage.running && (
<div className="flex justify-between gap-2" data-testid="coverage-ring-running">
<span>Status</span>
<span className="text-card-foreground">extracting…</span>
<span>{t("Status")}</span>
<span className="text-card-foreground">{t("extracting…")}</span>
</div>
)}
</div>
+6 -2
View File
@@ -1,4 +1,7 @@
"use client";
import { countLabel } from "@/src/core";
import { useI18n } from "@/components/i18n";
import { useState } from "react";
import { ChevronRightIcon } from "lucide-react";
@@ -21,9 +24,10 @@ export interface GraphContextData {
}
export function GraphContextCard({ data }: { data: GraphContextData }) {
const { t, language } = useI18n();
const [open, setOpen] = useState(false);
const { propositions = [], chains = [] } = data ?? {};
const noun = (n: number, one: string, many: string) => `${n} ${n === 1 ? one : many}`;
const noun = (n: number, one: string, many: string) => countLabel(language, n, one, many);
return (
<div data-testid="graph-context-card" className="my-1">
<button
@@ -33,7 +37,7 @@ export function GraphContextCard({ data }: { data: GraphContextData }) {
className="flex w-full cursor-pointer items-center gap-2 rounded-lg border border-dashed border-border/70 px-3 py-2 text-left text-xs text-muted-foreground transition-colors hover:text-foreground"
>
<ChevronRightIcon aria-hidden="true" className={cn("size-3.5 shrink-0 transition-transform", open && "rotate-90")} />
<span className="font-medium">Graph context</span>
<span className="font-medium">{t("Graph context")}</span>
<span className="min-w-0 flex-1 truncate">
{noun(propositions.length, "proposition", "propositions")}
{chains.length > 0 ? ` · ${noun(chains.length, "chain", "chains")}` : ""}
+77 -75
View File
@@ -1,4 +1,7 @@
"use client";
import { translator, countLabel, type Language } from "@/src/core";
import { useI18n } from "@/components/i18n";
/**
* The home knowledge-graph view (W4; vision §1 "Rendering (DECIDED)").
@@ -276,7 +279,8 @@ function SettingSlider({
}
/** Compact token display: 45231 → "45.2k"; absent → "—". */
function fmtTokens(n: number | undefined): string {
function fmtTokens(n: number | undefined, language: Language = "en"): string {
if (language === "ru" && n !== undefined) return new Intl.NumberFormat("ru-RU", { notation: "compact", maximumFractionDigits: 1 }).format(n);
if (n === undefined) return "—";
if (n < 1000) return String(Math.round(n));
if (n < 1_000_000) return `${(n / 1000).toFixed(1)}k`;
@@ -284,29 +288,30 @@ function fmtTokens(n: number | undefined): string {
}
/** Relative time for run rows: "now", "12m", "3h", "2d". */
function timeAgo(startedAt: number): string {
function timeAgo(startedAt: number, language: Language = "en"): string {
const t = translator(language);
const mins = Math.round((Date.now() - startedAt) / 60_000);
if (mins < 1) return "now";
if (mins < 60) return `${mins}m`;
if (mins < 1) return t("now");
if (mins < 60) return t("{v0}m", { v0: mins });
const hours = Math.round(mins / 60);
if (hours < 24) return `${hours}h`;
return `${Math.round(hours / 24)}d`;
if (hours < 24) return t("{v0}h", { v0: hours });
return t("{v0}d", { v0: Math.round(hours / 24) });
}
/** Run/stage cost at 3 decimals (pipeline pennies are real money at this
* scale); headline totals use 2. Null (unpriced model) renders "—". */
function fmtRunCost(dollars: number | null, providers: readonly string[] = []): string {
return planCostLabel(dollars, providers, (value) => `$${value.toFixed(3)}`);
function fmtRunCost(dollars: number | null, providers: readonly string[] = [], language: Language = "en"): string {
return planCostLabel(dollars, providers, (value) => language === "ru" ? `${value.toLocaleString("ru-RU", { minimumFractionDigits: 3, maximumFractionDigits: 3 })} $` : `$${value.toFixed(3)}`);
}
function graphProviders(value: { subscriptionRuns?: number; subscriptionProviders?: string[] }): string[] {
return value.subscriptionProviders ?? ((value.subscriptionRuns ?? 0) > 0 ? ["ChatGPT"] : []);
}
function graphUsageLabel(costs: Pick<GraphCostSummary, "stages">): string {
function graphUsageLabel(costs: Pick<GraphCostSummary, "stages">, language: Language = "en"): string {
const stages = [costs.stages.extraction, costs.stages.audit];
const providers = [...new Set(stages.flatMap(graphProviders))];
const priced = stages.some((stage) => stage.runs - stage.unpricedRuns - (stage.subscriptionRuns ?? 0) > 0);
const dollars = displayCostTotals(costs).totalDollars;
return planCostLabel(priced ? dollars : null, providers, (value) => `$${value.toFixed(2)}`);
return planCostLabel(priced ? dollars : null, providers, (value) => language === "ru" ? `${value.toLocaleString("ru-RU", { minimumFractionDigits: 2, maximumFractionDigits: 2 })} $` : `$${value.toFixed(2)}`);
}
/**
@@ -340,6 +345,7 @@ export function displayCostTotals(costs: Pick<GraphCostSummary, "stages">): {
/** Exported for the chains home (graph rendering off, owner
* 2026-10-06): the same bare cumulative-dollars pill + panel. */
export function GraphCostControl({ costs }: { costs: GraphCostSummary }) {
const { t, language } = useI18n();
const [open, setOpen] = useState(false);
// Click anywhere outside (or Escape) closes the panel — the root
// wraps button + panel, so the toggle itself keeps working.
@@ -349,7 +355,7 @@ export function GraphCostControl({ costs }: { costs: GraphCostSummary }) {
<div ref={rootRef} className="relative">
<Hint
// Trimmed (owner, subtlety round 2026-09-25).
label="Graph costs — click for details"
label={t("Graph costs — click for details")}
side="bottom"
>
<button
@@ -360,7 +366,7 @@ export function GraphCostControl({ costs }: { costs: GraphCostSummary }) {
open ? "text-foreground" : "text-muted-foreground hover:text-foreground"
}`}
>
{graphUsageLabel(costs)}
{graphUsageLabel(costs, language)}
</button>
</Hint>
{open && (
@@ -383,9 +389,10 @@ export function GraphCostControl({ costs }: { costs: GraphCostSummary }) {
* naming-kind runs never reach the list.
*/
function GraphCostPanel({ costs }: { costs: GraphCostSummary }) {
const { t, language } = useI18n();
const stageRows: [string, GraphCostSummary["stages"]["extraction"]][] = [
["Extraction", costs.stages.extraction],
["Audit", costs.stages.audit],
[t("Extraction"), costs.stages.extraction],
[t("Audit"), costs.stages.audit],
];
// The displayed figures come from displayCostTotals (naming excluded —
// the pill and this panel must agree).
@@ -394,12 +401,11 @@ function GraphCostPanel({ costs }: { costs: GraphCostSummary }) {
<div className="w-80 max-w-[calc(100vw-1.5rem)] rounded-xl border bg-card/95 p-3 text-xs shadow-lg backdrop-blur">
<div className="mb-2 flex items-baseline justify-between">
<span className="text-[10px] font-medium uppercase tracking-wide text-muted-foreground">
Graph pipeline costs
</span>
{t("Graph pipeline costs")}</span>
<span className="text-foreground tabular-nums">
{graphUsageLabel(costs)}
{graphUsageLabel(costs, language)}
{unpricedRuns > 0 && (
<span className="text-muted-foreground"> · {unpricedRuns} unpriced</span>
<span className="text-muted-foreground"> · {unpricedRuns}{" "}{t("unpriced")}</span>
)}
</span>
</div>
@@ -408,14 +414,13 @@ function GraphCostPanel({ costs }: { costs: GraphCostSummary }) {
<div key={label} className="flex items-baseline justify-between gap-2 text-[11px]">
<span className="shrink-0 text-muted-foreground">{label}</span>
<span className="min-w-0 text-right tabular-nums">
{fmtRunCost(stage.runs - stage.unpricedRuns - (stage.subscriptionRuns ?? 0) <= 0 ? null : stage.dollars, graphProviders(stage))}
{fmtRunCost(stage.runs - stage.unpricedRuns - (stage.subscriptionRuns ?? 0) <= 0 ? null : stage.dollars, graphProviders(stage), language)}
{stage.unpricedRuns > 0 && (stage.runs - stage.unpricedRuns - (stage.subscriptionRuns ?? 0) > 0 || (stage.subscriptionRuns ?? 0) > 0) && (
<span className="text-muted-foreground"> +{stage.unpricedRuns} unpriced</span>
<span className="text-muted-foreground"> +{stage.unpricedRuns}{" "}{t("unpriced")}</span>
)}
<span className="text-muted-foreground">
{" "}
· {fmtTokens(stage.tokens.inputTokens)} in / {fmtTokens(stage.tokens.outputTokens)} out
</span>
· {fmtTokens(stage.tokens.inputTokens, language)}{" "}{t("in /")}{" "}{fmtTokens(stage.tokens.outputTokens, language)}{" "}{t("out")}</span>
</span>
</div>
))}
@@ -424,16 +429,16 @@ function GraphCostPanel({ costs }: { costs: GraphCostSummary }) {
{costs.runs.map((run) => (
<div key={run.startedAt} className="flex items-baseline justify-between gap-2 text-[11px]">
<span className="min-w-0 flex-1 truncate text-muted-foreground">
<span className="text-foreground/70">{timeAgo(run.startedAt)}</span>{" "}
<span className="text-foreground/70">{timeAgo(run.startedAt, language)}</span>{" "}
{run.title}
{run.merged && (
<span className="text-muted-foreground">
{" "}
+{run.merged.nodes}n
+{language === "ru" ? countLabel(language, run.merged.nodes, "node", "nodes") : `${run.merged.nodes}n`}
</span>
)}
</span>
<span className="shrink-0 tabular-nums">{fmtRunCost(run.dollars, graphProviders(run))}{run.dollars === null && run.subscriptionRuns === 1 ? " +1 unpriced" : ""}</span>
<span className="shrink-0 tabular-nums">{fmtRunCost(run.dollars, graphProviders(run), language)}{run.dollars === null && run.subscriptionRuns === 1 ? t(" +1 unpriced") : ""}</span>
</div>
))}
</div>
@@ -454,6 +459,7 @@ function GraphSettingsControl({
settings: GraphRenderSettings;
changeSettings: (update: (cur: RenderSettings) => RenderSettings) => void;
}) {
const { t } = useI18n();
const [open, setOpen] = useState(false);
// Click anywhere outside (or Escape) closes the panel (2026-09-17) —
// the root wraps button + panel, so the toggle itself keeps working.
@@ -465,7 +471,7 @@ function GraphSettingsControl({
<div ref={rootRef} className="relative">
<Hint
// User language (tooltip wave) — "rendering" is internal.
label="Graph display settings"
label={t("Graph display settings")}
side="bottom"
>
<button
@@ -498,7 +504,7 @@ function GraphSettingsControl({
number — src/core/render-settings.ts). Physics keeps the
fine-grained log sliders (step 5). */}
<SettingSlider
label="Node size"
label={t("Node size")}
min={0}
max={100}
step={10}
@@ -508,7 +514,7 @@ function GraphSettingsControl({
changeSettings((cur) => ({ ...cur, nodeSize: fromLadderPosition(p, NODE_SIZE_LADDER) }))}
/>
<SettingSlider
label="Edge width"
label={t("Edge width")}
min={0}
max={100}
step={10}
@@ -518,7 +524,7 @@ function GraphSettingsControl({
changeSettings((cur) => ({ ...cur, edgeWidth: fromLadderPosition(p, EDGE_WIDTH_LADDER) }))}
/>
<SettingSlider
label="Arrowhead size"
label={t("Arrowhead size")}
min={0}
max={100}
step={10}
@@ -528,7 +534,7 @@ function GraphSettingsControl({
changeSettings((cur) => ({ ...cur, arrowLength: fromLadderPosition(p, ARROW_LENGTH_LADDER) }))}
/>
<SettingSlider
label="Label size"
label={t("Label size")}
min={0}
max={100}
step={10}
@@ -539,7 +545,7 @@ function GraphSettingsControl({
/>
{/* Display is absolute (ratio / 0.5): "1×" is the default, dead-center (owner, 2026-10-07). */}
<SettingSlider
label="Labels until zoom"
label={t("Labels until zoom")}
min={0}
max={100}
step={10}
@@ -557,10 +563,9 @@ function GraphSettingsControl({
the paint. */}
<div className="mt-1.5 flex flex-col gap-1.5 border-t pt-1.5">
<span className="text-[10px] font-medium uppercase tracking-wide text-muted-foreground">
Regions
</span>
{t("Regions")}</span>
<label className="flex cursor-pointer items-center justify-between text-[11px] text-muted-foreground">
<span>Background colors</span>
<span>{t("Background colors")}</span>
<input
type="checkbox"
checked={settings.regions.enabled}
@@ -573,7 +578,7 @@ function GraphSettingsControl({
/>
</label>
<SettingSlider
label="Dilation"
label={t("Dilation")}
disabled={!regionTuning.enabled}
min={0}
max={100}
@@ -587,7 +592,7 @@ function GraphSettingsControl({
}))}
/>
<SettingSlider
label="Blur"
label={t("Blur")}
disabled={!regionTuning.enabled}
min={0}
max={100}
@@ -602,7 +607,7 @@ function GraphSettingsControl({
}
/>
<SettingSlider
label="Edge alpha"
label={t("Edge alpha")}
disabled={!regionTuning.enabled}
min={0}
max={100}
@@ -618,7 +623,7 @@ function GraphSettingsControl({
<label
className={`flex items-center justify-between gap-2 text-[11px] text-muted-foreground${!regionTuning.enabled ? " opacity-40" : ""}`}
>
<span>Bitmap resolution</span>
<span>{t("Bitmap resolution")}</span>
<select
disabled={!regionTuning.enabled}
className="rounded-md border bg-background px-1.5 py-0.5 text-xs tabular-nums text-foreground"
@@ -643,7 +648,7 @@ function GraphSettingsControl({
their own clusterLabels toggle. Size is screen px
(constant at any zoom, unlike node labels). */}
<label className="flex cursor-pointer items-center justify-between text-[11px] text-muted-foreground">
<span>Cluster labels</span>
<span>{t("Cluster labels")}</span>
<input
type="checkbox"
checked={settings.clusterLabels}
@@ -652,7 +657,7 @@ function GraphSettingsControl({
/>
</label>
<SettingSlider
label="Cluster label size"
label={t("Cluster label size")}
disabled={!settings.clusterLabels}
min={0}
max={100}
@@ -678,10 +683,9 @@ function GraphSettingsControl({
anchor + its gray-out-able controls last. */}
<div className="mt-1.5 flex flex-col gap-1.5 border-t pt-1.5">
<span className="text-[10px] font-medium uppercase tracking-wide text-muted-foreground">
Physics
</span>
{t("Physics")}</span>
<SettingSlider
label="Node repulsion"
label={t("Node repulsion")}
min={0}
max={100}
step={5}
@@ -694,7 +698,7 @@ function GraphSettingsControl({
}))}
/>
<SettingSlider
label="Edge attraction"
label={t("Edge attraction")}
min={0}
max={100}
step={5}
@@ -707,7 +711,7 @@ function GraphSettingsControl({
}))}
/>
<SettingSlider
label="Edge length"
label={t("Edge length")}
min={0}
max={100}
step={5}
@@ -720,7 +724,7 @@ function GraphSettingsControl({
}))}
/>
<SettingSlider
label="Central pull"
label={t("Central pull")}
min={0}
max={100}
step={5}
@@ -733,12 +737,12 @@ function GraphSettingsControl({
}))}
/>
<SettingSlider
label="Cluster pull"
label={t("Cluster pull")}
min={0}
max={100}
step={5}
value={toOffSliderPosition(physicsTuning.clusterPull, CLUSTER_PULL_RANGE.min, CLUSTER_PULL_RANGE.max)}
display={physicsTuning.clusterPull <= 0 ? "off" : sizeDisplay(physicsTuning.clusterPull)}
display={physicsTuning.clusterPull <= 0 ? t("off") : sizeDisplay(physicsTuning.clusterPull)}
onChange={(p) =>
changeSettings((cur) => ({
...cur,
@@ -746,7 +750,7 @@ function GraphSettingsControl({
}))}
/>
<SettingSlider
label="Friction"
label={t("Friction")}
min={0}
max={100}
step={5}
@@ -763,8 +767,7 @@ function GraphSettingsControl({
controls at the very bottom — the grayed group reads
as a self-contained sub-section. */}
<label className="flex items-center justify-between gap-2 text-xs">
Depth anchor
<select
{t("Depth anchor")}<select
className="rounded border bg-background px-1 py-0.5 text-xs"
value={physicsTuning.depthMode}
onChange={(e) =>
@@ -774,13 +777,13 @@ function GraphSettingsControl({
}))
}
>
<option value="off">Off</option>
<option value="vertical">Vertical (up)</option>
<option value="radial">Radial (outward)</option>
<option value="off">{t("Off")}</option>
<option value="vertical">{t("Vertical (up)")}</option>
<option value="radial">{t("Radial (outward)")}</option>
</select>
</label>
<SettingSlider
label="Depth strength"
label={t("Depth strength")}
disabled={physicsTuning.depthMode === "off"}
min={0}
max={100}
@@ -794,7 +797,7 @@ function GraphSettingsControl({
}))}
/>
<SettingSlider
label="Depth spacing"
label={t("Depth spacing")}
disabled={physicsTuning.depthMode === "off"}
min={0}
max={100}
@@ -808,7 +811,7 @@ function GraphSettingsControl({
}))}
/>
<SettingSlider
label="Base ring radius"
label={t("Base ring radius")}
disabled={physicsTuning.depthMode !== "radial"}
min={0}
max={100}
@@ -866,13 +869,14 @@ export function GraphChromeRow({
* 2026-09-29). */
onOpenChains: () => void;
}) {
const { t } = useI18n();
return (
<>
{!isEmpty && (
<div className="graph-chrome-fade-in w-56 min-w-0 shrink rounded-xl border bg-card/85 px-1 py-[3px] shadow-lg backdrop-blur">
<Hint
// ⌘F focuses node search (surface-scoped registry action).
label="Search nodes"
label={t("Search nodes")}
action="find"
side="bottom"
>
@@ -882,15 +886,15 @@ export function GraphChromeRow({
ref={searchInputRef}
value={query}
onChange={(e) => onQuery(e.target.value)}
placeholder="Search nodes…"
aria-label="Search nodes"
placeholder={t("Search nodes…")}
aria-label={t("Search nodes")}
spellCheck={false}
className="h-7 w-full rounded-lg border bg-background pl-8 text-xs outline-none placeholder:text-muted-foreground focus:border-ring"
/>
</div>
</Hint>
{noMatch && (
<div className="px-1.5 pt-1 text-[11px] text-muted-foreground">No matching nodes.</div>
<div className="px-1.5 pt-1 text-[11px] text-muted-foreground">{t("No matching nodes.")}</div>
)}
</div>
)}
@@ -898,7 +902,7 @@ export function GraphChromeRow({
container (36px band), between the search and Import. */}
{!isEmpty && (
<div className="graph-chrome-fade-in shrink-0 rounded-xl border bg-card/85 px-1 py-[3px] shadow-lg backdrop-blur">
<Hint label="Every reasoning chain" side="bottom">
<Hint label={t("Every reasoning chain")} side="bottom">
<button
type="button"
onClick={onOpenChains}
@@ -906,8 +910,7 @@ export function GraphChromeRow({
className="flex h-7 items-center gap-1.5 rounded-lg px-2.5 text-xs text-muted-foreground transition-colors hover:bg-accent"
>
<ListTreeIcon className="size-3" />
Chains
</button>
{t("Chains")}</button>
</Hint>
</div>
)}
@@ -929,7 +932,7 @@ export function GraphChromeRow({
// The one button whose action isn't obvious from its label:
// the missing fact is where imports go (owner, subtlety
// round 2026-09-25).
label="Import notes into the graph"
label={t("Import notes into the graph")}
side="bottom"
>
<button
@@ -940,7 +943,7 @@ export function GraphChromeRow({
className="flex h-7 items-center gap-1.5 rounded-lg px-2.5 text-xs text-muted-foreground transition-colors hover:bg-accent disabled:opacity-50"
>
<UploadIcon className="size-3" />
{importing ? "Importing…" : "Import notes…"}
{importing ? t("Importing…") : t("Import notes…")}
</button>
</Hint>
</div>
@@ -974,6 +977,7 @@ export function GraphView({
onPracticeSelectionChange?: (nodes: { id: string; name: string; statement: string }[]) => void;
onChainSelectClose?: () => void;
}) {
const { t } = useI18n();
const containerRef = useRef<HTMLDivElement | null>(null);
const canvasRef = useRef<HTMLCanvasElement | null>(null);
const cardRef = useRef<HTMLDivElement | null>(null);
@@ -1498,10 +1502,9 @@ export function GraphView({
<div className="flex h-full w-full items-center justify-center px-4">
<div className="flex flex-col items-center gap-3 text-center text-muted-foreground">
<WaypointsIcon className="size-10 opacity-40" strokeWidth={1.25} />
<div className="text-lg font-medium text-foreground">Knowledge graph</div>
<div className="text-lg font-medium text-foreground">{t("Knowledge graph")}</div>
<p className="max-w-sm text-sm">
Start a session or import notes to build your graph.
</p>
{t("Start a session or import notes to build your graph.")}</p>
</div>
</div>
) : (
@@ -1541,8 +1544,7 @@ export function GraphView({
}}
className="flex items-center rounded-lg px-2.5 py-1.5 text-left text-muted-foreground/80 transition-colors hover:bg-accent hover:text-foreground"
>
Delete
</button>
{t("Delete")}</button>
</MenuShell>
)}
@@ -1630,9 +1632,9 @@ export function GraphView({
className="flex items-center gap-2 rounded-full border bg-card/85 px-3 py-1.5 text-xs text-muted-foreground shadow-lg backdrop-blur"
>
<Loader2Icon className="size-3.5 shrink-0 animate-spin" />
<span className="capitalize">{run.phase}</span>
<span className="capitalize">{t(run.phase)}</span>
<span className="max-w-48 truncate text-foreground">{run.title}</span>
<span className="tabular-nums">{Math.max(0, Math.round((Date.now() - run.startedAt) / 1000))}s</span>
<span className="tabular-nums">{t("{v0}s", { v0: Math.max(0, Math.round((Date.now() - run.startedAt) / 1000)) })}</span>
</div>
))}
</div>
@@ -1652,7 +1654,7 @@ export function GraphView({
<div className="mt-1 text-xs leading-relaxed text-muted-foreground">{selected.node.statement}</div>
{selected.sourceIds.length > 0 ? (
<div className="mt-3 flex flex-col gap-1">
<div className="text-xs text-muted-foreground">Sessions</div>
<div className="text-xs text-muted-foreground">{t("Sessions")}</div>
{selected.sourceIds.map((sourceId) => {
// An imported-notes file: a STATIC entry — the path,
// plain text, never a link (no session to open).
@@ -1681,7 +1683,7 @@ export function GraphView({
})}
</div>
) : (
<div className="mt-3 text-xs text-muted-foreground">No sessions recorded.</div>
<div className="mt-3 text-xs text-muted-foreground">{t("No sessions recorded.")}</div>
)}
</div>
)}
+7 -5
View File
@@ -1,4 +1,6 @@
"use client";
import { useI18n } from "@/components/i18n";
import { useCallback, useEffect, useMemo, useRef, useState } from "react";
import { useRouter } from "next/navigation";
@@ -30,6 +32,7 @@ export function Home({
* changing the setting applies on the next visit home. */
rendering?: boolean;
}) {
const { t } = useI18n();
const router = useRouter();
const [topic, setTopic] = useState("");
/* The topic MIRROR ("nothing gets lost" round, 2026-10-01 — same
@@ -236,8 +239,7 @@ export function Home({
onClick={() => setSetupOpen(true)}
className="rounded-sm border border-border bg-card px-3 py-1.5 font-ui text-xs text-foreground transition-colors hover:border-muted-foreground/50 hover:bg-accent cursor-pointer"
>
Set up your models
</button>
{t("Set up your models")}</button>
)
)}
/>
@@ -270,7 +272,7 @@ export function Home({
void start();
}
}}
placeholder={mode === "practice" ? "What do you want to practice?" : "What do you want to learn?"}
placeholder={mode === "practice" ? t("What do you want to practice?") : t("What do you want to learn?")}
autoFocus
rows={1}
className="field-sizing-content lh-input max-h-48 min-h-10 flex-1 resize-none bg-transparent px-2.5 py-2 outline-none placeholder:text-muted-foreground"
@@ -279,7 +281,7 @@ export function Home({
affordance (owner, subtlety round 2026-09-25). */}
<button
type="submit"
aria-label="Start session"
aria-label={t("Start session")}
disabled={(!topic.trim() && !dictation.active) || busy}
className="flex size-10 shrink-0 items-center justify-center rounded-[10px] bg-primary text-primary-foreground transition-colors hover:opacity-90 disabled:opacity-40"
>
@@ -309,7 +311,7 @@ export function Home({
}
>
{m === "practice" ? <DumbbellIcon aria-hidden="true" className="size-3" /> : null}
{m === "teach" ? "Teach" : "Practice"}
{m === "teach" ? t("Teach") : t("Practice")}
</button>
))}
</div>
+96
View File
@@ -0,0 +1,96 @@
"use client";
import { createContext, useCallback, useContext, useEffect, useMemo, useRef, useState, type ReactNode } from "react";
import { resolveLanguage, translator, type GeneralSettings, type Language, type Translator } from "@/src/core";
import { notifySettingsSaved, useSettingsSaved } from "./settings-signal";
type I18nValue = {
language: Language;
sttLanguage: Language;
general: GeneralSettings;
dictating: boolean;
setDictating: (active: boolean) => void;
t: Translator;
saveGeneral: (patch: Partial<GeneralSettings>) => Promise<void>;
saveLanguage: (field: "language" | "sttLanguage", value: Language | undefined) => Promise<void>;
};
const I18nContext = createContext<I18nValue>({ language: "en", sttLanguage: "en", general: {}, dictating: false, setDictating: () => {}, t: translator("en"), saveGeneral: async () => {}, saveLanguage: async () => {} });
export function useI18n(): I18nValue { return useContext(I18nContext); }
/** Initial settings and locale come from the server, so hydration preserves the first frame. */
export function I18nProvider({ children, initialGeneral = {}, system = "en" }: { children: ReactNode; initialGeneral?: GeneralSettings; system?: Language }) {
const [general, setGeneral] = useState(initialGeneral);
const [dictating, setDictating] = useState(false);
const [detected, setDetected] = useState<Language>(system);
const pending = useRef<Promise<void>>(Promise.resolve());
const sequence = useRef(0);
const latest = useRef(general);
latest.current = general;
const language = resolveLanguage(general.language, detected);
const sttLanguage = resolveLanguage(general.sttLanguage, detected);
const t = useMemo(() => translator(language), [language]);
useEffect(() => {
const detect = () => setDetected(resolveLanguage(undefined, navigator.languages));
detect();
window.addEventListener("languagechange", detect);
return () => window.removeEventListener("languagechange", detect);
}, []);
useEffect(() => {
document.documentElement.lang = language;
document.title = "Learning Harness";
const description = document.querySelector('meta[name="description"]');
if (description) description.setAttribute("content", t("A teacher agent with a memory — sessions, quizzes, and a knowledge graph of what you know."));
window.lh?.setLanguage?.(language);
}, [language, t]);
useSettingsSaved((section) => {
if (section !== "general") return;
const request = ++sequence.current;
void fetch("/api/settings").then((r) => { if (!r.ok) throw new Error("settings"); return r.json(); })
.then((body: { settings?: { general?: GeneralSettings } }) => { if (request === sequence.current) setGeneral(body.settings?.general ?? {}); }).catch(() => {});
});
// Serialize writes and re-read before replacing the section: simultaneous selectors cannot lose each other's changes.
const saveGeneral = useCallback((patch: Partial<GeneralSettings>) => {
const write = pending.current.catch(() => {}).then(async () => {
const current = await fetch("/api/settings");
if (!current.ok) throw new Error("Could not load language settings");
const body = await current.json() as { settings?: { general?: GeneralSettings } };
const next = { ...(body.settings?.general ?? latest.current) };
for (const field of ["language", "sttLanguage", "autoUpdate"] as const) {
if (!Object.hasOwn(patch, field)) continue;
if (patch[field] === undefined) delete next[field];
else Object.assign(next, { [field]: patch[field] });
}
const res = await fetch("/api/settings/general", { method: "PUT", headers: { "Content-Type": "application/json" }, body: JSON.stringify(next) });
if (!res.ok) throw new Error("Could not save language settings");
sequence.current++;
setGeneral(next);
notifySettingsSaved("general");
});
pending.current = write;
return write;
}, []);
const saveLanguage = useCallback((field: "language" | "sttLanguage", value: Language | undefined) => saveGeneral({ [field]: value }), [saveGeneral]);
const value = useMemo(() => ({ language, sttLanguage, general, dictating, setDictating, t, saveGeneral, saveLanguage }), [language, sttLanguage, general, dictating, t, saveGeneral, saveLanguage]);
return <I18nContext.Provider value={value}>{children}</I18nContext.Provider>;
}
export function LanguageSelect({ field, disabled = false, compact = false }: { field: "language" | "sttLanguage"; disabled?: boolean; compact?: boolean }) {
const { general, t, saveLanguage, sttLanguage, dictating } = useI18n();
const [saving, setSaving] = useState(false);
const [error, setError] = useState<string | null>(null);
const label = t(field === "language" ? "Interface language" : "Speech recognition language");
return <span className={compact ? "flex shrink-0 items-center gap-1" : "flex flex-col items-end gap-1"}>
<select aria-label={label} title={label} value={general[field] ?? "auto"} disabled={disabled || saving || (field === "sttLanguage" && dictating)}
className="h-7 max-w-full rounded-md border border-border bg-background px-1.5 text-xs text-foreground disabled:opacity-50"
onChange={(event) => {
const value = event.currentTarget.value;
setSaving(true); setError(null);
void saveLanguage(field, value === "auto" ? undefined : value as Language).catch((e: unknown) => setError(e instanceof Error ? e.message : "Could not save language settings")).finally(() => setSaving(false));
}}>
<option value="auto">{compact ? `${t("Auto")} · ${sttLanguage === "ru" ? "RU" : "EN"}` : t("System default")}</option>
<option value="en">English</option>
<option value="ru">Русский</option>
</select>
{error && <span role="alert" className="max-w-56 text-xs text-destructive">{t(error)}</span>}
</span>;
}
+10 -7
View File
@@ -1,4 +1,6 @@
"use client";
import { useI18n } from "@/components/i18n";
/**
* Keyboard shortcuts (shortcut wave A, 2026-09-17) — the single document-level
@@ -151,13 +153,14 @@ export function KeyboardShortcuts() {
* Until the settings milestone ships an editor, this is how shortcuts
* stay discoverable. */
function CheatSheet({ open, onClose }: { open: boolean; onClose: () => void }) {
const { t } = useI18n();
const backdropDismiss = useBackdropDismiss(onClose);
if (!open) return null;
const scopes = ["global", "chat", "graph"] as const;
const scopeLabels: Record<(typeof scopes)[number], string> = {
global: "Global",
chat: "Chat",
graph: "Graph view",
global: t("Global"),
chat: t("Chat"),
graph: t("Graph view"),
};
return (
<div
@@ -170,8 +173,8 @@ function CheatSheet({ open, onClose }: { open: boolean; onClose: () => void }) {
onClick={(e) => e.stopPropagation()}
>
<div className="mb-4 flex items-baseline justify-between">
<span className="text-sm font-medium text-card-foreground">Keyboard shortcuts</span>
<span className="text-xs text-muted-foreground">Esc to close</span>
<span className="text-sm font-medium text-card-foreground">{t("Keyboard shortcuts")}</span>
<span className="text-xs text-muted-foreground">{t("Esc to close")}</span>
</div>
{scopes.map((scope) => {
const actions = KEYBOARD_ACTIONS.filter((a) => a.scope === scope);
@@ -184,9 +187,9 @@ function CheatSheet({ open, onClose }: { open: boolean; onClose: () => void }) {
<div className="flex flex-col gap-1">
{actions.map((a) => (
<div key={a.id} className="flex items-center justify-between gap-4 text-sm">
<span className="text-muted-foreground">{a.label}</span>
<span className="text-muted-foreground">{t(a.label)}</span>
<kbd className="rounded-md border border-border bg-background px-1.5 py-0.5 font-mono text-xs text-card-foreground">
{formatBinding(bindingForAction(a.id))}
{t(formatBinding(bindingForAction(a.id)))}
</kbd>
</div>
))}
+12 -6
View File
@@ -1,4 +1,6 @@
"use client";
import { useI18n } from "@/components/i18n";
/**
* MARKDOWN BLOCKS, MELTED INTO THE MESSAGE (owner wave, 2026-09-19): the
@@ -139,6 +141,7 @@ export function codeSource(children: ReactNode): string {
* still streaming (the trailing open fence), a quiet skeleton instead of a
* doomed parse. */
function MermaidInline({ chart }: { chart: string }) {
const { t } = useI18n();
const [svg, setSvg] = useState<string | null>(null);
const [error, setError] = useState<string | null>(null);
const [attempt, setAttempt] = useState(0);
@@ -199,17 +202,17 @@ function MermaidInline({ chart }: { chart: string }) {
dangerouslySetInnerHTML={{ __html: svg ?? "" }}
/>
<div className="absolute top-0 right-0 flex items-start gap-0.5">
<CopyCorner getText={() => chart} label="Copy diagram source" />
<CopyCorner getText={() => chart} label={t("Copy diagram source")} />
{overlay && (
<Hint
// Full-screen overlay copy (tooltip wave) — "session view" was
// internal direction-speak for the same thing.
label="Open full-screen"
label={t("Open full-screen")}
side="bottom"
>
<button
type="button"
aria-label="Expand diagram"
aria-label={t("Expand diagram")}
data-testid="md-mermaid-expand"
className="flex size-6 items-center justify-center rounded-md text-muted-foreground/70 opacity-0 transition-opacity hover:bg-accent hover:text-foreground group-hover/md:opacity-100"
onClick={(e) => {
@@ -234,6 +237,7 @@ function MermaidInline({ chart }: { chart: string }) {
* mounts. The container/look is CSS-melted in globals.css
* (data-streamdown="code-block"…). */
export function MarkdownCode(props: ComponentProps<"code"> & ExtraProps) {
const { t } = useI18n();
const incomplete = useIsCodeFenceIncomplete();
const { children, className, node, ...rest } = props;
@@ -257,7 +261,7 @@ export function MarkdownCode(props: ComponentProps<"code"> & ExtraProps) {
const lineNumbers = !/\bnoLineNumbers\b/.test(meta);
return (
<div className="group/md relative" data-testid="md-code">
<CopyCorner className="absolute top-1.5 right-2 z-10" getText={() => source} label="Copy code" />
<CopyCorner className="absolute top-1.5 right-2 z-10" getText={() => source} label={t("Copy code")} />
<CodeBlock
code={source}
language={language}
@@ -374,6 +378,7 @@ export function MarkdownBlockquote(props: ComponentProps<"blockquote"> & ExtraPr
* horizontal-scroller for wide ones, with the corner copy. The copy
* serializes the REAL DOM table (the library's own extractor → Markdown). */
export function MarkdownTable(props: ComponentProps<"table"> & ExtraProps) {
const { t } = useI18n();
const { children, node, ...rest } = props;
const ref = useRef<HTMLTableElement>(null);
return (
@@ -385,7 +390,7 @@ export function MarkdownTable(props: ComponentProps<"table"> & ExtraProps) {
// cells it hovers over; the scroll container stays under it (the
// corner is fixed to the wrapper, not the scrolling table).
className="absolute top-1 right-1 z-10"
label="Copy table"
label={t("Copy table")}
getText={() => (ref.current ? tableDataToMarkdown(extractTableDataFromElement(ref.current)) : "")}
/>
<div className="overflow-x-auto">
@@ -414,6 +419,7 @@ export function MarkdownTable(props: ComponentProps<"table"> & ExtraProps) {
* in the MathML <annotation>; read from the DOM on click, no re-parse).
* Every other span passes through untouched. */
export function MathDisplay(props: ComponentProps<"span"> & ExtraProps) {
const { t } = useI18n();
const { children, className, node, ...rest } = props;
const ref = useRef<HTMLSpanElement>(null);
if (!className?.includes("katex-display")) {
@@ -434,7 +440,7 @@ export function MathDisplay(props: ComponentProps<"span"> & ExtraProps) {
<span className="group/md relative block z-0" data-testid="md-math">
<CopyCorner
className="absolute top-0 right-0 z-10"
label="Copy equation source"
label={t("Copy equation source")}
getText={() => ref.current?.querySelector("annotation")?.textContent ?? ""}
/>
<span ref={ref} className={className} {...rest}>
+8 -4
View File
@@ -1,4 +1,5 @@
"use client";
import { useI18n } from "@/components/i18n";
import { Button } from "@/components/ui/button";
import { cn } from "@/lib/utils";
@@ -15,7 +16,9 @@ export const MermaidError = ({
error,
retry,
className,
}: MermaidErrorComponentProps & { className?: string }) => (
}: MermaidErrorComponentProps & { className?: string }) => {
const { t } = useI18n();
return (
<div
className={cn(
"my-4 rounded-xl border border-destructive/40 bg-destructive/5 p-4",
@@ -24,21 +27,22 @@ export const MermaidError = ({
data-streamdown="mermaid-error"
>
<p className="text-sm font-medium text-destructive">
Diagram failed to render
{t("Diagram failed to render")}
</p>
<p className="mt-1 font-mono text-xs break-words text-muted-foreground">
{error}
</p>
<details className="mt-3">
<summary className="cursor-pointer text-xs text-muted-foreground">
Show diagram source
{t("Show diagram source")}
</summary>
<pre className="mt-2 overflow-x-auto rounded-lg bg-muted p-3 font-mono text-xs text-foreground">
{chart}
</pre>
</details>
<Button className="mt-3" onClick={retry} size="sm" variant="outline">
Retry
{t("Retry")}
</Button>
</div>
);
};
+15 -12
View File
@@ -1,4 +1,6 @@
"use client";
import { useI18n } from "@/components/i18n";
/**
* THE MINIMAL HOME (owner, 2026-10-06 — the no-graph home, take two):
@@ -24,6 +26,7 @@ import { ChainTableOverlay } from "@/components/chain-table.tsx";
import { GraphCostControl } from "@/components/graph-view.tsx";
import { NotesImportErrors, useNotesImport } from "@/components/notes-import.tsx";
import { Hint } from "@/components/ui/hint";
import { countLabel } from "@/src/core";
import type { ChatSummary, GraphCostSummary, GraphSnapshot } from "@/src/core";
/** The quiet centered summary — a pure body (props in, markup out) so
@@ -42,6 +45,7 @@ export function KnowledgeSummary({
practiceSessions: number;
importedNotes: number;
}) {
const { t, language } = useI18n();
const empty =
propositions === 0 && chains === 0 && teachSessions === 0 && practiceSessions === 0 && importedNotes === 0;
return (
@@ -52,29 +56,28 @@ export function KnowledgeSummary({
list) rides after the same parting. */}
<div className="flex flex-col gap-1">
<p className="font-ui text-sm text-muted-foreground">
{propositions} {propositions === 1 ? "proposition" : "propositions"}
{countLabel(language, propositions, "proposition", "propositions")}
</p>
<p className="font-ui text-sm text-muted-foreground">
{chains} {chains === 1 ? "chain" : "chains"}
{countLabel(language, chains, "chain", "chains")}
</p>
</div>
<div className="h-4" aria-hidden="true" />
<div className="flex flex-col gap-1">
<p className="font-ui text-sm text-muted-foreground">
{teachSessions} {teachSessions === 1 ? "teaching session" : "teaching sessions"}
{countLabel(language, teachSessions, "teaching session", "teaching sessions")}
</p>
<p className="font-ui text-sm text-muted-foreground">
{practiceSessions} {practiceSessions === 1 ? "practice session" : "practice sessions"}
{countLabel(language, practiceSessions, "practice session", "practice sessions")}
</p>
</div>
<div className="h-4" aria-hidden="true" />
<p className="font-ui text-sm text-muted-foreground">
{importedNotes} {importedNotes === 1 ? "imported note" : "imported notes"}
{countLabel(language, importedNotes, "imported note", "imported notes")}
</p>
{empty && (
<p className="pt-2 font-ui text-xs text-muted-foreground/70">
Nothing here yet — import notes to begin building the graph.
</p>
{t("Nothing here yet — import notes to begin building the graph.")}</p>
)}
</div>
);
@@ -93,6 +96,7 @@ export function MinimalHome({
onPracticeSelectionChange?: (nodes: { id: string; name: string; statement: string }[]) => void;
onChainSelectClose?: () => void;
}) {
const { t } = useI18n();
const [snapshot, setSnapshot] = useState<GraphSnapshot | null>(null);
const [costs, setCosts] = useState<GraphCostSummary | null>(null);
const [chats, setChats] = useState<ChatSummary[]>([]);
@@ -167,7 +171,7 @@ export function MinimalHome({
<div className="lh-no-drag lh-wco-floating absolute right-2 top-2 z-10 flex max-w-[calc(100%-5.5rem)] flex-col items-end gap-2">
<div className="flex min-w-0 items-start gap-2">
<div className="graph-chrome-fade-in shrink-0 rounded-xl border bg-card/85 px-1 py-[3px] shadow-lg backdrop-blur">
<Hint label="Every reasoning chain" side="bottom">
<Hint label={t("Every reasoning chain")} side="bottom">
<button
type="button"
onClick={() => setChainsOpen(true)}
@@ -175,12 +179,11 @@ export function MinimalHome({
className="flex h-7 items-center gap-1.5 rounded-lg px-2.5 text-xs text-muted-foreground transition-colors hover:bg-accent"
>
<ListTreeIcon className="size-3" />
Chains
</button>
{t("Chains")}</button>
</Hint>
</div>
<div className="shrink-0 rounded-xl border bg-card/85 px-1 py-[3px] shadow-lg backdrop-blur">
<Hint label="Import notes into the graph" side="bottom">
<Hint label={t("Import notes into the graph")} side="bottom">
<button
type="button"
disabled={importing}
@@ -189,7 +192,7 @@ export function MinimalHome({
className="flex h-7 items-center gap-1.5 rounded-lg px-2.5 text-xs text-muted-foreground transition-colors hover:bg-accent disabled:opacity-50"
>
<UploadIcon className="size-3" />
{importing ? "Importing…" : "Import notes…"}
{importing ? t("Importing…") : t("Import notes…")}
</button>
</Hint>
</div>
+13 -10
View File
@@ -1,4 +1,6 @@
"use client";
import { useI18n } from "@/components/i18n";
import { useCallback, useEffect, useRef, useState } from "react";
import type { ChatSettings, ModelCatalogEntry, ModelCatalogResponse, ModelsSettings, UserSettings } from "@/src/core";
@@ -82,6 +84,7 @@ export function catalogLimits(catalog: Pick<ModelCatalogResponse, "models">): Ma
* no-factory-fallback language: the UI never presents a default model).
*/
export function ModelControl({ chatId, onModelChange }: { chatId?: string; onModelChange?: (model: string | undefined) => void }) {
const { t } = useI18n();
const { catalog, refresh } = useModelCatalog();
const [models, setModels] = useState<ModelsSettings | undefined>();
const [session, setSession] = useState<ChatSettings | undefined>();
@@ -318,7 +321,7 @@ export function ModelControl({ chatId, onModelChange }: { chatId?: string; onMod
// Trimmed (owner, subtlety round 2026-09-25): "Model selector"
// — the long sentence was way too much. ⌘K opens this same
// menu (registry, override-aware).
label="Model selector"
label={t("Model selector")}
action="open-model-picker"
side="bottom"
>
@@ -341,7 +344,7 @@ export function ModelControl({ chatId, onModelChange }: { chatId?: string; onMod
// "Thinking" + its real shortcut via the registry (owner interlude
// 2026-09-25): ⇧⇥ cycles the level, so "Thinking (⇧⇥)" —
// rebind-live like every Hint suffix.
label="Thinking"
label={t("Thinking")}
action="cycle-thinking"
side="bottom"
>
@@ -364,7 +367,7 @@ export function ModelControl({ chatId, onModelChange }: { chatId?: string; onMod
{/* Just the level — the button reads like the model button reads
the model name (owner, stats-control round 1). */}
<span className="text-card-foreground">
{effectiveLevel === undefined ? "—" : (THINKING_LABEL[effectiveLevel] ?? effectiveLevel)}
{effectiveLevel === undefined ? "—" : t(THINKING_LABEL[effectiveLevel] ?? effectiveLevel)}
</span>
<span aria-hidden className="text-xs text-muted-foreground">▾</span>
</button>
@@ -380,7 +383,7 @@ export function ModelControl({ chatId, onModelChange }: { chatId?: string; onMod
preferredSide="up"
menuClass="flex w-[22rem] flex-col rounded-xl border bg-card p-2 text-xs shadow-xl"
>
{error && <div className="mb-1.5 text-destructive">{error}</div>}
{error && <div className="mb-1.5 text-destructive">{t(error)}</div>}
<ModelMenuList
models={catalog?.models}
effectiveId={effectiveId}
@@ -388,10 +391,10 @@ export function ModelControl({ chatId, onModelChange }: { chatId?: string; onMod
onPick={pickModel}
footer={
catalog && catalog.models.length === 0
? "No models — add a provider key in Keys"
? t("No models — add a provider key in Keys")
: chatId
? "This session's model"
: "Strong tier — the default for new sessions"
? t("This session's model")
: t("Strong tier — the default for new sessions")
}
/>
</MenuShell>
@@ -408,10 +411,10 @@ export function ModelControl({ chatId, onModelChange }: { chatId?: string; onMod
levels={catalog ? levels : []}
effectiveLevel={effectiveLevel}
onPick={(level) => apply(effectiveProvider ?? "", effectiveId ?? "", level as ThinkingLevel)}
empty={catalog ? (effectiveId ? `${prettyModel(effectiveId)} has no thinking levels.` : "Pick a model first.") : "Loading…"}
footer="Levels this model actually supports"
empty={catalog ? (effectiveId ? t("{v0} has no thinking levels.", { v0: prettyModel(effectiveId) }) : t("Pick a model first.")) : t("Loading…")}
footer={t("Levels this model actually supports")}
/>
{error && <div className="mt-1 text-destructive">{error}</div>}
{error && <div className="mt-1 text-destructive">{t(error)}</div>}
</MenuShell>
)}
</div>
+9 -6
View File
@@ -1,4 +1,6 @@
"use client";
import { useI18n } from "@/components/i18n";
import { useCallback, useEffect, useRef, useState, type ReactNode } from "react";
import { useRouter } from "next/navigation";
@@ -110,6 +112,7 @@ export function DraftShell({
* Passed as a node so the pick state stays in NewSession. */
overlay?: ReactNode;
}) {
const { t } = useI18n();
// Voice dictation (STT wave, 2026-09-17): finals append at the end of the
// draft text (via the parent's onChange — the truth lives there); the
// strip lives in the stats bar's middle slot.
@@ -172,11 +175,11 @@ export function DraftShell({
<Conversation className="h-full min-w-0 flex-1" initial="instant">
<ConversationContent className="min-h-full gap-0 p-0">
<ConversationEmptyState
title={mode === "practice" ? "New practice session" : "New session"}
title={mode === "practice" ? t("New practice session") : t("New session")}
description={
mode === "practice"
? "Pick nodes from your knowledge graph, describe what you want to practice, or bring your own problem."
: "The teacher will open by probing what you already know."
? t("Pick nodes from your knowledge graph, describe what you want to practice, or bring your own problem.")
: t("The teacher will open by probing what you already know.")
}
/>
</ConversationContent>
@@ -205,7 +208,7 @@ export function DraftShell({
// input (owner refinement round, 2026-09-24) — no lesson has
// started yet, so the composer must not read like one is in
// flight.
placeholder={mode === "practice" ? "What do you want to practice?" : "What do you want to learn?"}
placeholder={mode === "practice" ? t("What do you want to practice?") : t("What do you want to learn?")}
/>
</div>
<StatsBar
@@ -231,6 +234,7 @@ export function DraftShell({
}
export function NewSession({ initialConfigured, mode = "teach" }: { initialConfigured: boolean; mode?: "teach" | "practice" }) {
const { t } = useI18n();
const router = useRouter();
const [text, setText] = useState("");
/* The draft MIRROR ("nothing gets lost" round, 2026-10-01): the shell's
@@ -357,8 +361,7 @@ export function NewSession({ initialConfigured, mode = "teach" }: { initialConfi
onClick={() => setSetupOpen(true)}
className="rounded-sm border border-border bg-card px-3 py-1.5 font-ui text-xs text-foreground transition-colors hover:border-muted-foreground/50 hover:bg-accent cursor-pointer"
>
Set up your models
</button>
{t("Set up your models")}</button>
)
)
}
+8 -5
View File
@@ -1,4 +1,5 @@
"use client";
import { useI18n } from "@/components/i18n";
import { useRef, useState } from "react";
@@ -53,24 +54,26 @@ export async function postNotesImport(files: readonly File[]): Promise<NotesImpo
/** Only error text survives a request; selected Files stay request-local. */
export function useNotesImport(callbacks: { onStart?: () => void; onFinish?: () => void } = {}) {
const [errors, setErrors] = useState<string[]>([]);
const { t } = useI18n();
const [failures, setFailures] = useState<Array<{ file: string; message: string; detail?: string }>>([]);
const errors = failures.map((failure) => `${failure.file}: ${t(failure.message)}${failure.detail ?? ""}`);
const [importing, setImporting] = useState(false);
const pending = useRef(false);
const submit = async (files: File[]) => {
if (pending.current || files.length === 0) return;
pending.current = true;
setImporting(true);
setErrors([]);
setFailures([]);
try {
callbacks.onStart?.();
const results = await postNotesImport(files);
setErrors(results
setFailures(results
.filter((result) => !result.ok)
.sort((a, b) => a.requestIndex - b.requestIndex)
.map((r) => `${r.filePath}: ${r.error}${r.code ? `\n${r.code}${r.provider ? ` — ${r.provider}` : ""}${r.missing?.length ? ` — ${r.missing.join(", ")}` : ""}${r.slots?.length ? ` — ${r.slots.join(", ")}` : ""}` : ""}`));
.map((r) => ({ file: r.filePath, message: r.error ?? "", detail: r.code ? `\n${r.code}${r.provider ? ` — ${r.provider}` : ""}${r.missing?.length ? ` — ${r.missing.join(", ")}` : ""}${r.slots?.length ? ` — ${r.slots.join(", ")}` : ""}` : "" })));
} catch (error) {
const message = error instanceof Error ? error.message : String(error);
setErrors(files.map((file) => `${file.name}: ${message}`));
setFailures(files.map((file) => ({ file: file.name, message })));
} finally {
pending.current = false;
setImporting(false);
+8 -3
View File
@@ -1,3 +1,6 @@
"use client";
import { useI18n } from "@/components/i18n";
import type { Phase3 } from "@/src/core";
/**
@@ -39,6 +42,7 @@ const CHIP_LABEL: Record<Phase3, string> = {
* at all, so it is part of the drag surface instead of a carved-out
* target. */
export function PhaseChip({ phase }: { phase: Phase3 }) {
const { t } = useI18n();
return (
<span
data-testid="phase-chip"
@@ -46,7 +50,7 @@ export function PhaseChip({ phase }: { phase: Phase3 }) {
aria-hidden="true"
className="pointer-events-none flex shrink-0 select-none items-center text-xs font-medium uppercase tracking-wide text-muted-foreground"
>
{CHIP_LABEL[phase]}
{t(CHIP_LABEL[phase])}
</span>
);
}
@@ -60,11 +64,12 @@ export function PhaseChip({ phase }: { phase: Phase3 }) {
* after the start).
*/
export function PhaseDivider({ phase, nodeCount }: { phase: Phase3; nodeCount?: number }) {
const { t } = useI18n();
if (phase === "probe") return null;
const label =
phase === "plan" && nodeCount
? `Plan · ${nodeCount} node${nodeCount === 1 ? "" : "s"}`
: CHIP_LABEL[phase];
? t("Plan · {v0} node{v1}", { v0: nodeCount, v1: nodeCount === 1 ? "" : "s" })
: t(CHIP_LABEL[phase]);
return (
<div className="my-4 flex w-full items-center gap-3" data-testid={`phase-divider-${phase}`}>
<span className="h-0 flex-1 border-t-2 border-dotted border-border/50" aria-hidden />
+21 -17
View File
@@ -1,4 +1,6 @@
"use client";
import { useI18n } from "@/components/i18n";
import { useEffect, useId, useMemo, useRef, useState } from "react";
import { MaximizeIcon, MinusIcon, PlusIcon, XIcon } from "lucide-react";
@@ -509,6 +511,7 @@ export function PlanDiagram({
* flank's width and the band's height rather than run width-first. */
fill?: boolean;
}) {
const { t } = useI18n();
const layout = useMemo(() => blockLayout(nodes, edges), [nodes, edges]);
// Pad only the full-extent render — the plan window's override viewBox is
// a deliberate viewport crop, and the popup keeps its own fit margins.
@@ -540,7 +543,7 @@ export function PlanDiagram({
: {}),
}}
role="img"
aria-label="Plan diagram"
aria-label={t("Plan diagram")}
>
<defs>
<marker
@@ -676,15 +679,16 @@ export function PlanDiagram({
* future DAG window).
*/
export function PlanBlock({ data, onOpen }: { data: PlanBlockData; onOpen: () => void }) {
const { t } = useI18n();
const markerId = useId().replace(/[^a-zA-Z0-9-]/g, "");
const statusMap = useMemo(() => new Map(data.nodes.map((n) => [n.id, n.status] as const)), [data]);
const statusOf = (id: string): PlanNodeStatus => statusMap.get(id) ?? "upcoming";
return (
<Hint label="Open the full plan" side="bottom">
<Hint label={t("Open the full plan")} side="bottom">
<button
type="button"
data-testid="plan-block-full"
aria-label="Open the full plan"
aria-label={t("Open the full plan")}
onClick={onOpen}
className="block w-full text-left"
>
@@ -749,6 +753,7 @@ const POPUP_PAD = GLOW_REACH;
* through while the focus owns the session area.
*/
export function PlanPopup({ messages, onClose }: { messages: readonly PopupMessage[]; onClose: () => void }) {
const { t } = useI18n();
const tree = useMemo(() => latestTree(messages), [messages]);
const statuses = useMemo(
() => (tree ? derivePlanStatuses(tree, messages as Parameters<typeof derivePlanStatuses>[1]) : null),
@@ -887,19 +892,19 @@ export function PlanPopup({ messages, onClose }: { messages: readonly PopupMessa
className="absolute inset-0 z-40 flex flex-col bg-background/80 p-4 backdrop-blur-sm"
role="dialog"
aria-modal="true"
aria-label="The full plan"
aria-label={t("The full plan")}
onClick={(e) => {
if (e.target === e.currentTarget) onClose();
}}
>
<div className="mb-2 flex items-center gap-2">
<span className="text-sm font-medium">The plan</span>
<span className="text-xs text-muted-foreground">live — statuses derive from your check outcomes</span>
<Hint label="Close" binding="escape" side="left">
<span className="text-sm font-medium">{t("The plan")}</span>
<span className="text-xs text-muted-foreground">{t("live — statuses derive from your check outcomes")}</span>
<Hint label={t("Close")} binding="escape" side="left">
<button
type="button"
onClick={onClose}
aria-label="Close"
aria-label={t("Close")}
data-testid="plan-popup-close"
className="ml-auto rounded-lg p-1.5 text-muted-foreground transition-colors hover:bg-accent hover:text-foreground"
>
@@ -947,22 +952,22 @@ export function PlanPopup({ messages, onClose }: { messages: readonly PopupMessa
/>
</div>
<div className="pointer-events-none absolute inset-x-0 bottom-0 flex items-end justify-between p-2">
<span className="text-xs text-muted-foreground/60">Drag to pan · scroll to zoom</span>
<span className="text-xs text-muted-foreground/60">{t("Drag to pan · scroll to zoom")}</span>
<div className="flex gap-1">
<Hint label="Zoom out" side="bottom">
<Hint label={t("Zoom out")} side="bottom">
<button
type="button"
aria-label="Zoom out"
aria-label={t("Zoom out")}
onClick={() => stepZoom(1 / 1.25)}
className="pointer-events-auto flex size-7 items-center justify-center rounded-md border border-border/70 bg-card/80 text-muted-foreground backdrop-blur transition-colors hover:bg-accent hover:text-foreground"
>
<MinusIcon className="size-3.5" />
</button>
</Hint>
<Hint label="Fit plan to screen" side="bottom">
<Hint label={t("Fit plan to screen")} side="bottom">
<button
type="button"
aria-label="Fit plan to screen"
aria-label={t("Fit plan to screen")}
onClick={fitView}
data-testid="plan-popup-fit"
className="pointer-events-auto flex size-7 items-center justify-center rounded-md border border-border/70 bg-card/80 text-muted-foreground backdrop-blur transition-colors hover:bg-accent hover:text-foreground"
@@ -970,10 +975,10 @@ export function PlanPopup({ messages, onClose }: { messages: readonly PopupMessa
<MaximizeIcon className="size-3.5" />
</button>
</Hint>
<Hint label="Zoom in" side="bottom">
<Hint label={t("Zoom in")} side="bottom">
<button
type="button"
aria-label="Zoom in"
aria-label={t("Zoom in")}
onClick={() => stepZoom(1.25)}
className="pointer-events-auto flex size-7 items-center justify-center rounded-md border border-border/70 bg-card/80 text-muted-foreground backdrop-blur transition-colors hover:bg-accent hover:text-foreground"
>
@@ -985,8 +990,7 @@ export function PlanPopup({ messages, onClose }: { messages: readonly PopupMessa
</>
) : (
<div className="flex h-full items-center justify-center text-sm text-muted-foreground">
No plan in this session yet.
</div>
{t("No plan in this session yet.")}</div>
)}
</div>
</div>
+25 -22
View File
@@ -1,4 +1,6 @@
"use client";
import { useI18n } from "@/components/i18n";
import { MessageResponse } from "@/components/ai-elements/message";
import { cn } from "@/lib/utils";
@@ -56,6 +58,7 @@ export interface PracticeThinkingData {
}
export function ProblemCard({ part }: ArtifactPartProps) {
const { t } = useI18n();
// Always open, no chevron (owner round, 2026-10-05): the record IS the
// statement — same container + eyebrow discipline as the answered card
// and the dock (which mirrors this in Slice D). The commitment never
@@ -64,7 +67,7 @@ export function ProblemCard({ part }: ArtifactPartProps) {
const statement = typeof input.statement === "string" ? input.statement : "";
return (
<div data-testid="problem-card" className="lh-card-enter my-1 rounded-xl border bg-card p-4">
<div className="mb-1 text-xs font-medium uppercase tracking-wide text-muted-foreground">Problem</div>
<div className="mb-1 text-xs font-medium uppercase tracking-wide text-muted-foreground">{t("Problem")}</div>
<div className="lh-prose text-[15px]">
<MessageResponse>{repairMathForDisplay(statement)}</MessageResponse>
</div>
@@ -94,6 +97,7 @@ const OUTCOME_CHIP: Record<string, string> = {
};
export function ConcludeCard({ part }: ArtifactPartProps) {
const { t } = useI18n();
// The conclusion redesigned (owner round, 2026-10-05): the SOLUTION is
// the hero — the modelSolution committed at pose time (part.reveal, the
// view-seam join), shown verbatim, never re-authored. The verdict sits
@@ -111,8 +115,7 @@ export function ConcludeCard({ part }: ArtifactPartProps) {
if (outcome === "dismissed") {
return (
<div data-testid="conclude-card" className="my-1 px-1 text-sm text-muted-foreground/70">
Problem dismissed.
</div>
{t("Problem dismissed.")}</div>
);
}
@@ -122,10 +125,9 @@ export function ConcludeCard({ part }: ArtifactPartProps) {
eyebrow + the outcome in the quiz cards' EXACT chip register —
no more two same-weight headings stacked. */}
<div className="mb-1 flex items-center gap-2 text-xs font-medium uppercase tracking-wide text-muted-foreground">
Concluded
{(OUTCOME_LABEL[outcome] ?? "") !== "" && outcome !== "dismissed" && (
{t("Concluded")}{(OUTCOME_LABEL[outcome] ?? "") !== "" && outcome !== "dismissed" && (
<span className={cn("rounded px-1.5 py-0.5", OUTCOME_CHIP[outcome] ?? OUTCOME_CHIP.movedOn)}>
{OUTCOME_LABEL[outcome]}
{t(OUTCOME_LABEL[outcome] ?? outcome)}
</span>
)}
</div>
@@ -142,7 +144,7 @@ export function ConcludeCard({ part }: ArtifactPartProps) {
<>
<div className="my-3 border-t" />
<div>
<div className="mb-0.5 text-xs font-medium uppercase tracking-wide text-muted-foreground">Model solution</div>
<div className="mb-0.5 text-xs font-medium uppercase tracking-wide text-muted-foreground">{t("Model solution")}</div>
<div className="lh-prose text-[15px]">
<MessageResponse>{repairMathForDisplay(reveal)}</MessageResponse>
</div>
@@ -177,6 +179,7 @@ function MetadataPopover({
concludeInput: { outcome?: string; rung?: unknown; missClass?: unknown; retest?: unknown };
problemInput: { mode?: unknown; target?: unknown };
}) {
const { t } = useI18n();
const [open, setOpen] = useState(false);
const triggerRef = useRef<HTMLButtonElement | null>(null);
const rootRef = useRef<HTMLDivElement | null>(null);
@@ -239,8 +242,7 @@ function MetadataPopover({
aria-expanded={open}
className="flex items-center gap-1 text-xs font-medium uppercase tracking-wide text-muted-foreground transition-colors hover:text-foreground"
>
Metadata
</button>
{t("Metadata")}</button>
{open &&
anchor &&
createPortal(
@@ -256,12 +258,12 @@ function MetadataPopover({
className="z-50 w-80 max-w-[calc(100vw-1rem)] rounded-xl border bg-card p-4 text-left shadow-lg"
>
<dl className="grid grid-cols-[auto_1fr] gap-x-4 gap-y-1 text-xs">
<dt className="text-muted-foreground">Mode</dt>
<dd className="font-mono">{mode || "—"}</dd>
<dt className="text-muted-foreground">Targets</dt>
<dt className="text-muted-foreground">{t("Mode")}</dt>
<dd className="font-mono">{t(mode || "—")}</dd>
<dt className="text-muted-foreground">{t("Targets")}</dt>
<dd className="font-mono">
{targets.length === 0 ? (
"untagged"
t("untagged")
) : (
<span>
{targets.map((t, i) => (
@@ -273,14 +275,14 @@ function MetadataPopover({
</span>
)}
</dd>
<dt className="text-muted-foreground">Outcome</dt>
<dd className="font-mono">{outcome || "—"}</dd>
<dt className="text-muted-foreground">Hint rung</dt>
<dd className="font-mono">{rung || "none"}</dd>
<dt className="text-muted-foreground">Miss class</dt>
<dd className="font-mono">{miss || "none"}</dd>
<dt className="text-muted-foreground">Retest</dt>
<dd className="font-mono">{retest === undefined ? "—" : retest ? "yes" : "no"}</dd>
<dt className="text-muted-foreground">{t("Outcome")}</dt>
<dd className="font-mono">{t(outcome || "—")}</dd>
<dt className="text-muted-foreground">{t("Hint rung")}</dt>
<dd className="font-mono">{t(rung || "none")}</dd>
<dt className="text-muted-foreground">{t("Miss class")}</dt>
<dd className="font-mono">{t(miss || "none")}</dd>
<dt className="text-muted-foreground">{t("Retest")}</dt>
<dd className="font-mono">{retest === undefined ? "—" : retest ? t("yes") : t("no")}</dd>
</dl>
</div>,
document.body,
@@ -290,9 +292,10 @@ function MetadataPopover({
}
export function ThinkingCard({ data }: { data: PracticeThinkingData }) {
const { t } = useI18n();
// The unified row (owner rounds, 2026-10-05): one style, one label —
// 50%-italic quote block on expand. Legacy records' commit flag is
// deliberately unread (one lane now). A TRANSCRIPT row (owner round,
// 2026-10-07): full-width band + rail, arriving expanded.
return <ThinkingBlock testId="thinking-card" label="YOUR THINKING" text={data.text} defaultOpen />;
return <ThinkingBlock testId="thinking-card" label={t("YOUR THINKING")} text={data.text} defaultOpen />;
}
+9 -8
View File
@@ -1,4 +1,6 @@
"use client";
import { useI18n } from "@/components/i18n";
import { useCallback, useRef, useState } from "react";
import { ChevronRightIcon } from "lucide-react";
@@ -50,6 +52,7 @@ export interface PracticeDockProps {
}
export function PracticeDock({ problemCallId, input, expanded, onExpand, onSubmit, onEscape, compactionRunning = false }: PracticeDockProps) {
const { t } = useI18n();
const [thinking, setThinking] = useState("");
// Ref MIRRORS the field ("nothing gets lost", the question-dock
// discipline): a submit resuming after `await finishDictation()` reads
@@ -93,7 +96,7 @@ export function PracticeDock({ problemCallId, input, expanded, onExpand, onSubmi
className="flex w-full cursor-pointer items-center gap-2 px-4 py-3 text-left text-sm text-muted-foreground transition-colors hover:text-foreground"
>
<ChevronRightIcon className="size-4 shrink-0 rotate-90" />
<span className="font-medium">Problem</span>
<span className="font-medium">{t("Problem")}</span>
<span className="min-w-0 flex-1 truncate">{input.statement}</span>
</button>
);
@@ -107,7 +110,7 @@ export function PracticeDock({ problemCallId, input, expanded, onExpand, onSubmi
markdown + LaTeX rendered, capped by the measured column
(--dock-max-h; the old fixed 40vh cut long problems short). */}
<div className="max-h-[var(--dock-max-h,40vh)] overflow-y-auto px-4 pt-3 pb-2">
<div className="mb-1 text-xs font-medium uppercase tracking-wide text-muted-foreground">Problem</div>
<div className="mb-1 text-xs font-medium uppercase tracking-wide text-muted-foreground">{t("Problem")}</div>
<div className="lh-prose text-[15px]">
<MessageResponse>{typeof input.statement === "string" ? repairMathForDisplay(input.statement) : input.statement}</MessageResponse>
</div>
@@ -120,8 +123,7 @@ export function PracticeDock({ problemCallId, input, expanded, onExpand, onSubmi
className="m-1.5 rounded-[10px] border border-border/70 bg-background/60 transition-colors focus-within:border-primary/50"
>
<div className="px-2.5 pt-2 text-xs font-medium uppercase tracking-wide text-muted-foreground">
YOUR THINKING
</div>
{t("YOUR THINKING")}</div>
<textarea
value={thinking}
onChange={(e) => {
@@ -144,7 +146,7 @@ export function PracticeDock({ problemCallId, input, expanded, onExpand, onSubmi
submit();
}
}}
placeholder="Work here, think out loud."
placeholder={t("Work here, think out loud.")}
rows={4}
className={cn(
"field-sizing-content lh-input max-h-64 min-h-16 w-full resize-none bg-transparent px-2.5 pb-2 outline-none placeholder:text-muted-foreground",
@@ -156,7 +158,7 @@ export function PracticeDock({ problemCallId, input, expanded, onExpand, onSubmi
field 10 − p-1 (4) = button 6. */}
<div className="flex items-center justify-between gap-2 p-1">
<span aria-hidden className="pl-1.5 text-xs text-muted-foreground">
{compactionRunning ? "compacting session…" : "↵ submits · ⇧↵ newline"}
{compactionRunning ? t("compacting session…") : t("↵ submits · ⇧↵ newline")}
</span>
<Button
type="button"
@@ -165,8 +167,7 @@ export function PracticeDock({ problemCallId, input, expanded, onExpand, onSubmi
onClick={() => submit()}
className="ml-auto h-9 rounded-[6px] px-3.5 text-sm font-medium"
>
Submit thinking
</Button>
{t("Submit thinking")}</Button>
</div>
</div>
</div>
+30 -28
View File
@@ -1,4 +1,6 @@
"use client";
import { useI18n } from "@/components/i18n";
import { useCallback, useEffect, useMemo, useRef, useState } from "react";
import { ArrowDownIcon, ChevronRightIcon, PlayIcon } from "lucide-react";
@@ -105,6 +107,7 @@ export function QuestionDock({
dictationClaim = true,
compactionRunning = false,
}: QuestionDockProps) {
const { t } = useI18n();
// The probe-phase names (owner, 2026-10-09) canonicalize onto their
// teach twins for every dock concern — parse dispatch, labels, kinds:
// the probe quiz ("probe") is quiz-shaped, the probe free-recall
@@ -114,7 +117,7 @@ export function QuestionDock({
// The chip's display name carries the tool's OWN name (owner
// 2026-10-09): probe-phase questions label Probe/Recall in the
// composer; teach tools keep the standing labels.
const toolLabel = toolName === "probe" ? "Probe" : toolName === "recall" ? "Recall" : undefined;
const toolLabel = toolName === "probe" ? t("Probe") : toolName === "recall" ? t("Recall") : undefined;
// Parse, don't cast (src/core/tool-parts.ts): the model authored this
// input, and only the schema the tool declared vouches for its shape.
// The dock mounts on input-available parts (already validated at the
@@ -126,8 +129,7 @@ export function QuestionDock({
if (!parsed) {
return (
<div data-testid="malformed-question" className="rounded-xl border border-dashed bg-card/60 p-3 text-sm text-muted-foreground">
This question arrived malformed — the transcript keeps the retry.
</div>
{t("This question arrived malformed — the transcript keeps the retry.")}</div>
);
}
return (
@@ -142,7 +144,7 @@ export function QuestionDock({
label={
(canonical === "quiz" || canonical === "explain") &&
normalizeQuizType((parsed as { type?: unknown }).type) === "predict"
? "Prediction"
? t("Prediction")
: undefined
}
/>
@@ -201,6 +203,7 @@ export function QuestionDockBar({
* label Probe/Recall — the phase boundary visible in the composer. */
toolLabel?: string;
}) {
const { t } = useI18n();
if (variant === "reveal") {
return (
<button
@@ -213,11 +216,11 @@ export function QuestionDockBar({
<PlayIcon className="size-3.5" />
</span>
<span className="min-w-0 flex-1">
<span className="block text-sm font-medium">Check your understanding</span>
<span className="block text-sm font-medium">{t("Check your understanding")}</span>
<span className="block truncate text-xs text-muted-foreground">{teaserText(question)}</span>
</span>
<span className="flex shrink-0 items-center gap-1 text-xs font-medium text-primary">
Open <ChevronRightIcon className="size-3.5" />
{t("Open")}{" "}<ChevronRightIcon className="size-3.5" />
</span>
</button>
);
@@ -232,11 +235,11 @@ export function QuestionDockBar({
<span className="shrink-0 text-xs font-medium uppercase tracking-wide text-muted-foreground">
{label ??
toolLabel ??
(toolName === "quiz" ? "Check" : toolName === "explain" ? "Explain" : "Question")}
(toolName === "quiz" ? t("Check") : toolName === "explain" ? t("Explain") : t("Question"))}
</span>
<span className="min-w-0 flex-1 truncate text-muted-foreground">{teaserText(question)}</span>
<span className="flex shrink-0 items-center gap-1 text-xs font-medium">
Answer <ArrowDownIcon className="size-3.5" />
{t("Answer")}{" "}<ArrowDownIcon className="size-3.5" />
</span>
</button>
);
@@ -254,6 +257,7 @@ function QuestionDockForm({
dictationClaim = true,
compactionRunning = false,
}: Omit<QuestionDockProps, "expanded" | "onExpand" | "reveal">) {
const { t } = useI18n();
const kind = toolQuestionKind(toolName);
// Canonical QuestionData, built once per question. Quiz options are
@@ -547,17 +551,17 @@ function QuestionDockForm({
<div data-session-find-root="" className="max-h-[var(--dock-max-h,55dvh)] overflow-y-auto px-4 pt-3">
<div className="mb-1 text-xs font-medium uppercase tracking-wide text-muted-foreground">
{toolName === "probe"
? "Probe"
? t("Probe")
: toolName === "recall"
? "Recall"
? t("Recall")
: kind === "quiz"
? qd.questionType === "predict"
? "Prediction"
: "Check"
? t("Prediction")
: t("Check")
: kind === "explain"
? "Explain"
: "Question"}
{qd.mode === "multi-select" && " · select all that apply"}
? t("Explain")
: t("Question")}
{qd.mode === "multi-select" && t(" · select all that apply")}
</div>
<div className="mb-3 font-medium">
<MessageResponse>{qd.question}</MessageResponse>
@@ -644,7 +648,7 @@ function QuestionDockForm({
>
{sel.dontKnow && <span className="size-1.5 rounded-full bg-current" />}
</span>
<span>I don't know</span>
<span>{t("I don't know")}</span>
</button>
)}
@@ -710,7 +714,7 @@ function QuestionDockForm({
submit();
}
}}
placeholder="Type your own answer…"
placeholder={t("Type your own answer…")}
className="lh-input w-full min-w-0 flex-1 bg-transparent outline-none placeholder:text-muted-foreground"
onClick={(e) => e.stopPropagation()}
/>
@@ -742,8 +746,7 @@ function QuestionDockForm({
sel.dontKnow ? "text-muted-foreground/50" : "text-muted-foreground",
)}
>
Confidence
</span>
{t("Confidence")}</span>
<div
className={cn("relative h-4 min-w-0 flex-1", sel.dontKnow && "opacity-40")}
data-testid="confidence-track"
@@ -771,9 +774,9 @@ function QuestionDockForm({
step={1}
value={CONFIDENCE_STOPS[confIdx]}
disabled={sel.dontKnow}
aria-label="Confidence — the probability your selected answer is correct"
aria-valuetext={`${CONFIDENCE_STOPS[confIdx]} percent`}
title="The probability your selected answer is correct — 50% is a coin flip. Adjust with ←/→."
aria-label={t("Confidence — the probability your selected answer is correct")}
aria-valuetext={t("{v0} percent", { v0: CONFIDENCE_STOPS[confIdx] })}
title={t("The probability your selected answer is correct — 50% is a coin flip. Adjust with ←/→.")}
data-testid="confidence-slider"
onChange={(e) => {
// Snap to the nearest stop; the rendered VALUE is the stop's own
@@ -828,7 +831,7 @@ function QuestionDockForm({
className="rounded-[10px] border border-border/70 bg-background/60 transition-colors focus-within:border-primary/50"
>
<div className="px-2.5 pt-2 text-xs font-medium uppercase tracking-wide text-muted-foreground">
{qd.mode === "text" ? "Your answer" : "YOUR THINKING"}
{qd.mode === "text" ? t("Your answer") : t("YOUR THINKING")}
</div>
{qd.mode === "text" ? (
<textarea
@@ -854,7 +857,7 @@ function QuestionDockForm({
submit();
}
}}
placeholder="Type your answer…"
placeholder={t("Type your answer…")}
rows={1}
className="field-sizing-content lh-input max-h-48 min-h-10 w-full resize-none bg-transparent px-2.5 py-2 outline-none placeholder:text-muted-foreground"
/>
@@ -884,7 +887,7 @@ function QuestionDockForm({
submit();
}
}}
placeholder="Why this answer? Whatever comes to mind."
placeholder={t("Why this answer? Whatever comes to mind.")}
rows={1}
className={cn(
"field-sizing-content lh-input max-h-48 w-full resize-none bg-transparent px-2.5 py-2 outline-none placeholder:text-muted-foreground/70",
@@ -896,15 +899,14 @@ function QuestionDockForm({
)}
<div className="flex items-center justify-between gap-2 p-1">
<span aria-hidden className="pl-1.5 text-xs text-muted-foreground">
{compactionRunning ? "compacting session…" : "↵ submits · ⇧↵ newline"}
{compactionRunning ? t("compacting session…") : t("↵ submits · ⇧↵ newline")}
</span>
<Button
type="submit"
disabled={!canSubmit || compactionRunning}
className="ml-auto h-9 rounded-[6px] px-3.5 text-sm font-medium"
>
Submit answer
</Button>
{t("Submit answer")}</Button>
</div>
</div>
</form>
+5 -3
View File
@@ -1,4 +1,6 @@
"use client";
import { useI18n } from "@/components/i18n";
import { useCallback } from "react";
import { joinDictation, useDictationTarget } from "@/components/dictation";
@@ -37,6 +39,7 @@ export function ReadingThinkingField({
/** Primary dictation claim — true while the check is behind its reveal. */
claim: boolean;
}) {
const { t } = useI18n();
useDictationTarget(
useCallback(
(t: string) => {
@@ -52,8 +55,7 @@ export function ReadingThinkingField({
className="pointer-events-auto mb-2 rounded-2xl border bg-card/60 px-3 py-2 transition-colors focus-within:border-primary/50"
>
<div className="pb-1 text-xs font-medium uppercase tracking-wide text-muted-foreground">
Thinking while reading
</div>
{t("Thinking while reading")}</div>
<textarea
value={value}
onChange={(e) => onChange(e.target.value)}
@@ -94,7 +96,7 @@ export function ReadingThinkingField({
e.currentTarget.blur();
}
}}
placeholder="Whatever comes to mind as you read."
placeholder={t("Whatever comes to mind as you read.")}
rows={1}
className="field-sizing-content lh-input max-h-40 w-full resize-none bg-transparent py-0.5 outline-none placeholder:text-muted-foreground/70"
/>
+9 -6
View File
@@ -1,4 +1,6 @@
"use client";
import { useI18n } from "@/components/i18n";
import { useCallback, useEffect, useRef, useState, type RefObject } from "react";
import { ChevronDownIcon, ChevronUpIcon, SearchIcon, XIcon } from "lucide-react";
@@ -66,6 +68,7 @@ export function SessionFind({ scopeRef, scrollContextRef }: {
scopeRef: RefObject<HTMLDivElement | null>;
scrollContextRef: RefObject<SessionScrollContext | null>;
}) {
const { t } = useI18n();
const [open, setOpen] = useState(false);
const [query, setQuery] = useState("");
const [result, setResult] = useState({ index: -1, count: 0 });
@@ -163,7 +166,7 @@ export function SessionFind({ scopeRef, scrollContextRef }: {
return (
<div
role="search"
aria-label="Session Find"
aria-label={t("Session Find")}
data-testid="session-find"
className="lh-no-drag flex h-10 shrink-0 items-center gap-2 border-b border-border px-6 font-ui text-sm"
onKeyDown={(event) => {
@@ -182,8 +185,8 @@ export function SessionFind({ scopeRef, scrollContextRef }: {
<SearchIcon className="size-4 shrink-0 text-muted-foreground" aria-hidden />
<input
ref={inputRef}
aria-label="Find in session"
placeholder="Find in session…"
aria-label={t("Find in session")}
placeholder={t("Find in session…")}
value={query}
onChange={(event) => setQuery(event.target.value)}
spellCheck={false}
@@ -192,15 +195,15 @@ export function SessionFind({ scopeRef, scrollContextRef }: {
<output aria-live="polite" data-testid="session-find-count" className="shrink-0 text-xs tabular-nums text-muted-foreground">
{query ? `${result.index + 1}/${result.count}` : ""}
</output>
<button type="button" aria-label="Previous match" title="Previous match (Shift+Enter)" className={buttonClass}
<button type="button" aria-label={t("Previous match")} title={t("Previous match (Shift+Enter)")} className={buttonClass}
disabled={!result.count} onMouseDown={(event) => event.preventDefault()} onClick={() => step(-1)}>
<ChevronUpIcon className="size-4" aria-hidden />
</button>
<button type="button" aria-label="Next match" title="Next match (Enter)" className={buttonClass}
<button type="button" aria-label={t("Next match")} title={t("Next match (Enter)")} className={buttonClass}
disabled={!result.count} onMouseDown={(event) => event.preventDefault()} onClick={() => step(1)}>
<ChevronDownIcon className="size-4" aria-hidden />
</button>
<button type="button" aria-label="Close Find" title="Close Find (Esc)" className={buttonClass} onClick={close}>
<button type="button" aria-label={t("Close Find")} title={t("Close Find (Esc)")} className={buttonClass} onClick={close}>
<XIcon className="size-4" aria-hidden />
</button>
</div>
+16 -14
View File
@@ -1,4 +1,6 @@
"use client";
import { useI18n } from "@/components/i18n";
import { createContext, useContext, useEffect, useMemo, useRef, useState } from "react";
import { MaximizeIcon, MinusIcon, PlusIcon, XIcon } from "lucide-react";
@@ -83,6 +85,7 @@ export function MermaidOverlay({
inverted?: boolean;
onClose: () => void;
}) {
const { t } = useI18n();
// ── chart render (own instance, own ids); figure mode uses the raw svg ──
const [svg, setSvg] = useState<string | null>(rawSvg ?? null);
const [error, setError] = useState<string | null>(null);
@@ -218,16 +221,16 @@ export function MermaidOverlay({
className="absolute inset-0 z-40 flex flex-col bg-background/80 p-4 backdrop-blur-sm"
role="dialog"
aria-modal="true"
aria-label="Diagram focus"
aria-label={t("Diagram focus")}
>
<div className="mb-2 flex items-center gap-2">
<span className="text-sm font-medium">Diagram</span>
<span className="text-xs text-muted-foreground">from this lesson</span>
<Hint label="Close" binding="escape" side="left">
<span className="text-sm font-medium">{t("Diagram")}</span>
<span className="text-xs text-muted-foreground">{t("from this lesson")}</span>
<Hint label={t("Close")} binding="escape" side="left">
<button
type="button"
onClick={onClose}
aria-label="Close"
aria-label={t("Close")}
data-testid="mermaid-overlay-close"
className="ml-auto rounded-lg p-1.5 text-muted-foreground transition-colors hover:bg-accent hover:text-foreground"
>
@@ -287,32 +290,32 @@ export function MermaidOverlay({
/>
</div>
<div className="pointer-events-none absolute inset-x-0 bottom-0 flex items-end justify-between p-2">
<span className="text-xs text-muted-foreground/60">Drag to pan · scroll to zoom</span>
<span className="text-xs text-muted-foreground/60">{t("Drag to pan · scroll to zoom")}</span>
<div className="flex gap-1">
<Hint label="Zoom out" side="bottom">
<Hint label={t("Zoom out")} side="bottom">
<button
type="button"
aria-label="Zoom out"
aria-label={t("Zoom out")}
onClick={() => stepZoom(1 / 1.25)}
className="pointer-events-auto flex size-7 items-center justify-center rounded-md border border-border/70 bg-card/80 text-muted-foreground backdrop-blur transition-colors hover:bg-accent hover:text-foreground"
>
<MinusIcon className="size-3.5" />
</button>
</Hint>
<Hint label="Fit diagram to view" side="bottom">
<Hint label={t("Fit diagram to view")} side="bottom">
<button
type="button"
aria-label="Fit diagram to view"
aria-label={t("Fit diagram to view")}
onClick={fitView}
className="pointer-events-auto flex size-7 items-center justify-center rounded-md border border-border/70 bg-card/80 text-muted-foreground backdrop-blur transition-colors hover:bg-accent hover:text-foreground"
>
<MaximizeIcon className="size-3.5" />
</button>
</Hint>
<Hint label="Zoom in" side="bottom">
<Hint label={t("Zoom in")} side="bottom">
<button
type="button"
aria-label="Zoom in"
aria-label={t("Zoom in")}
onClick={() => stepZoom(1.25)}
className="pointer-events-auto flex size-7 items-center justify-center rounded-md border border-border/70 bg-card/80 text-muted-foreground backdrop-blur transition-colors hover:bg-accent hover:text-foreground"
>
@@ -325,8 +328,7 @@ export function MermaidOverlay({
) : (
<div className="flex h-full items-center justify-center gap-2 text-sm text-muted-foreground">
<span className="inline-block size-4 animate-spin rounded-full border border-current border-b-2" />
Rendering diagram…
</div>
{t("Rendering diagram…")}</div>
)}
</div>
</div>
+43 -41
View File
@@ -1,4 +1,6 @@
"use client";
import { useI18n } from "@/components/i18n";
/**
* The Agent section (settings-app wave): the THREE MODEL SLOTS, the tool
* toggles + the search-engine preference, and the prompt override
@@ -250,6 +252,7 @@ function MenuTrigger({
/** The no-key search state grays the whole row; the trigger follows. */
disabled?: boolean;
}) {
const { t } = useI18n();
return (
<button
type="button"
@@ -263,7 +266,7 @@ function MenuTrigger({
disabled ? "cursor-not-allowed" : "hover:border-muted-foreground/50"
} ${widthClass}`}
>
<span className="min-w-0 flex-1 truncate">{value || emptyLabel}</span>
<span className="min-w-0 flex-1 truncate">{t(value || emptyLabel)}</span>
<span aria-hidden className="text-muted-foreground">
▾
</span>
@@ -303,6 +306,7 @@ export function AgentSection({
/** Writes the whole compaction section (replace-PUT discipline). */
onCompactionChange: (next: CompactionSettings) => void;
}) {
const { t } = useI18n();
const [catalog, setCatalog] = useState<ModelCatalogEntry[] | null>(null);
/** The three tiers' resolved pins — GET /api/models `slots`. The rows'
* Default hints and thinking lists resolve from it. */
@@ -492,7 +496,7 @@ export function AgentSection({
>
<span>{label}</span>
<span className="text-muted-foreground">
{live === null ? (active && <span aria-hidden>✓</span>) : live ? <span aria-hidden>✓ key</span> : <span aria-hidden>· no key</span>}
{live === null ? (active && <span aria-hidden>✓</span>) : live ? <span aria-hidden>{t("✓ key")}</span> : <span aria-hidden>{t("· no key")}</span>}
</span>
</button>
);
@@ -527,7 +531,7 @@ export function AgentSection({
<Toggle
checked={info.enabled}
disabled={!fileExists}
label={`prompt ${info.path}`}
label={t("prompt {v0}", { v0: info.path })}
onChange={(on) => onSectionChange(composePromptSwitch(agent, info.path, on))}
/>
</span>
@@ -540,26 +544,26 @@ export function AgentSection({
}}
className="text-xs text-muted-foreground/70 underline-offset-2 hover:text-foreground hover:underline"
>
{open ? "Hide text" : "Show text"}
{open ? t("Hide text") : t("Show text")}
</button>
{open && (
<div className="mt-1 flex flex-col gap-2">
<div>
<p className="mb-1 text-xs uppercase tracking-widest text-muted-foreground/70">Source</p>
<p className="mb-1 text-xs uppercase tracking-widest text-muted-foreground/70">{t("Source")}</p>
<textarea
readOnly
value={info.sourceText}
aria-label="The original instructions"
aria-label={t("The original instructions")}
className="h-24 w-full resize-y rounded-sm border border-border bg-muted p-2 font-ui text-xs text-muted-foreground"
/>
</div>
<div>
<p className="mb-1 text-xs uppercase tracking-widest text-muted-foreground/70">Override</p>
<p className="mb-1 text-xs uppercase tracking-widest text-muted-foreground/70">{t("Override")}</p>
<textarea
value={segmentDraft[info.path] ?? info.overrideText ?? ""}
onChange={(e) => setSegmentDraft({ ...segmentDraft, [info.path]: e.target.value })}
aria-label="Your version of the instructions"
placeholder="Add your own instructions…"
aria-label={t("Your version of the instructions")}
placeholder={t("Add your own instructions…")}
className="h-24 w-full resize-y rounded-sm border border-border bg-background p-2 font-ui text-xs text-foreground outline-none"
/>
</div>
@@ -574,16 +578,14 @@ export function AgentSection({
: "cursor-not-allowed text-xs text-muted-foreground/40 underline-offset-2"
}
>
Save
</button>
{t("Save")}</button>
{fileExists && (
<button
type="button"
onClick={() => void deleteSegment(info.path)}
className="text-xs text-muted-foreground/70 underline-offset-2 hover:text-red-500 hover:underline"
>
Delete
</button>
{t("Delete")}</button>
)}
{/* The quiet inline confirmation — next to the
action, not the old path-echoing line at the
@@ -593,7 +595,7 @@ export function AgentSection({
status reads unambiguously. */}
{segmentStatus && (
<p className="text-xs text-muted-foreground/70" data-testid="segment-status">
{segmentStatus}
{t(segmentStatus)}
</p>
)}
</div>
@@ -614,7 +616,7 @@ export function AgentSection({
return (
<>
<SettingsGroup label="Models" testid="group-models">
<SettingsGroup label={t("Models")} testid="group-models">
{MODEL_SLOTS.map((slot) => {
const override = models?.[slot];
const modelId = override?.model?.id ?? "";
@@ -653,7 +655,7 @@ export function AgentSection({
<MenuTrigger
value={modelId ? prettyModel(modelId) : ""}
emptyLabel="—"
ariaLabel={`${SLOT_META[slot].label} model`}
ariaLabel={t("{v0} model", { v0: t(SLOT_META[slot].label) })}
testid={`slot-model-${slot}`}
open={thisMenuOpen === "model"}
onClick={() => setOpenMenu(thisMenuOpen === "model" ? null : { target: slot, kind: "model" })}
@@ -665,7 +667,7 @@ export function AgentSection({
// what the engine build runs). Display-only until a pick writes.
value={thinking || (chosen ? defaultOrderLevel(levels) : "")}
emptyLabel="—"
ariaLabel={`${SLOT_META[slot].label} thinking`}
ariaLabel={t("{v0} thinking", { v0: t(SLOT_META[slot].label) })}
testid={`slot-thinking-${slot}`}
open={thisMenuOpen === "thinking"}
onClick={() => setOpenMenu(thisMenuOpen === "thinking" ? null : { target: slot, kind: "thinking" })}
@@ -692,8 +694,8 @@ export function AgentSection({
}}
footer={
catalog && catalog.length === 0
? "No models — add a provider key in Keys"
: "Runs every agent of this tier"
? t("No models — add a provider key in Keys")
: t("Runs every agent of this tier")
}
/>
</MenuShell>
@@ -723,7 +725,7 @@ export function AgentSection({
})}
</SettingsGroup>
<SettingsGroup label="Tools" testid="group-tools">
<SettingsGroup label={t("Tools")} testid="group-tools">
{/* The prober's graph tools (wave 7, owner ruling 2026-09-23):
FIRST row of the group — its subject is the map, its
mechanism is agent capability, so it lives in Tools but
@@ -735,15 +737,15 @@ export function AgentSection({
const graphOn = graph?.enabled ?? true;
return (
<SettingsRow
label="Graph tools"
hint={graphOn ? "Lets the tutor consult your knowledge map between sessions" : "Graph is disabled"}
label={t("Graph tools")}
hint={graphOn ? t("Lets the tutor consult your knowledge map between sessions") : t("Graph is disabled")}
testid="graph-tools-row"
dimmed={!graphOn}
>
<Toggle
checked={agent?.graphTools?.enabled ?? true}
disabled={!graphOn}
label="graph tools"
label={t("graph tools")}
onChange={(on) => onSectionChange(composeGraphTools(agent, on))}
/>
</SettingsRow>
@@ -757,13 +759,13 @@ export function AgentSection({
const free = agent?.tools?.explain === true;
return (
<SettingsRow
label="Quizzing"
hint="How both tutors check your understanding"
label={t("Quizzing")}
hint={t("How both tutors check your understanding")}
testid="quiz-mechanics-row"
>
<div
role="radiogroup"
aria-label="Quizzing mechanics"
aria-label={t("Quizzing mechanics")}
className="flex rounded-lg border border-border/60 bg-muted/30 p-0.5"
>
{(["choice", "free"] as const).map((m) => {
@@ -781,7 +783,7 @@ export function AgentSection({
active ? "bg-background text-foreground shadow-sm" : "text-muted-foreground hover:text-foreground",
)}
>
{m === "choice" ? "Multiple choice" : "Free recall"}
{m === "choice" ? t("Multiple choice") : t("Free recall")}
</button>
);
})}
@@ -792,12 +794,12 @@ export function AgentSection({
{TOGGLE_TOOLS.map((id) => (
<SettingsRow
key={id}
label={`Tool · ${id}`}
label={t("Tool · {v0}", { v0: id })}
testid={`tool-row-${id}`}
>
<Toggle
checked={agent?.tools?.[id] ?? true}
label={`tool ${id}`}
label={t("tool {v0}", { v0: id })}
onChange={(on) => onSectionChange(composeTool(agent, id, on))}
/>
</SettingsRow>
@@ -808,7 +810,7 @@ export function AgentSection({
const open = openMenu?.target === "search";
return (
<SettingsRow
label="Search engine"
label={t("Search engine")}
hint={search?.reason ?? undefined}
testid="search-engine-row"
dimmed={!anyKey}
@@ -816,8 +818,8 @@ export function AgentSection({
<span ref={open ? menuAnchorRef : undefined} className="relative flex items-center gap-2">
<MenuTrigger
value={engine === "tavily" ? "Tavily" : engine === "brave" ? "Brave" : ""}
emptyLabel="Automatic"
ariaLabel="Search engine"
emptyLabel={t("Automatic")}
ariaLabel={t("Search engine")}
testid="search-engine-trigger"
open={open}
onClick={() => setOpenMenu(open ? null : { target: "search", kind: "engine" })}
@@ -831,7 +833,7 @@ export function AgentSection({
testid="search-engine-menu"
menuClass="flex w-44 flex-col gap-0.5 rounded-xl border border-border bg-card p-2 text-xs shadow-xl"
>
{engineOption("auto", "Automatic", engine === null, null, () => {
{engineOption("auto", t("Automatic"), engine === null, null, () => {
onSectionChange(composeSearchEngine(agent, null));
setOpenMenu(null);
})}
@@ -849,17 +851,17 @@ export function AgentSection({
})()}
</SettingsGroup>
<SettingsGroup label="Context" testid="group-context">
<SettingsGroup label={t("Context")} testid="group-context">
<SettingsRow
label="Compaction threshold"
hint={`${resolvedCompaction.thresholdTokens} tok`}
label={t("Compaction threshold")}
hint={t("{v0} tok", { v0: resolvedCompaction.thresholdTokens })}
testid="compaction-row-threshold"
>
<CommitInput
type="number"
value={String(compaction?.thresholdTokens ?? "")}
placeholder={String(resolvedCompaction.thresholdTokens)}
ariaLabel="compaction threshold"
ariaLabel={t("compaction threshold")}
testid="input-compaction-threshold"
onCommit={(v) => {
// Empty = factory default (the budget row's discipline):
@@ -882,9 +884,9 @@ export function AgentSection({
</SettingsRow>
</SettingsGroup>
<SettingsGroup label="System prompts" testid="group-prompts">
<SettingsGroup label={t("System prompts")} testid="group-prompts">
{prompts === null ? (
<p className="text-xs text-muted-foreground/70">Loading prompt segments…</p>
<p className="text-xs text-muted-foreground/70">{t("Loading prompt segments…")}</p>
) : (
<div className="flex flex-col gap-4">
{/* The owner's ruled order (2026-09-26): Teacher first,
@@ -894,10 +896,10 @@ export function AgentSection({
{sectionedSegments(prompts).map((section) => (
<div key={section.id} data-testid={`prompt-section-${section.id}`}>
<p className="mb-1 text-xs uppercase tracking-widest text-muted-foreground/70">
{section.label}
{t(section.label)}
</p>
{section.hint && (
<p className="mb-1 text-xs text-muted-foreground/60">{section.hint}</p>
<p className="mb-1 text-xs text-muted-foreground/60">{t(section.hint)}</p>
)}
<div className="flex flex-col gap-2">
{section.segments.map((info) => segmentRow(info))}
+36 -32
View File
@@ -1,4 +1,6 @@
"use client";
import { useI18n } from "@/components/i18n";
/**
* The Appearance + Graph sections (thin settings wave).
*
@@ -97,6 +99,7 @@ export function AppearanceSection({
/** PUT the whole appearance section; the page re-applies on success. */
onSectionChange: (next: AppearanceSettings) => void;
}) {
const { t, language } = useI18n();
const { appearance: resolved } = useAppearance();
const md = appearance?.mdRendering;
@@ -186,27 +189,27 @@ export function AppearanceSection({
return (
<>
<SettingsRow label="Mode" hint="Coming soon" testid="row-mode" dimmed>
<SettingsRow label={t("Mode")} hint={t("Coming soon")} testid="row-mode" dimmed>
<ThinSelect value="dark" ariaLabel="mode" onChange={() => {}} testid="select-mode" disabled>
<option value="dark">Dark</option>
<option value="dark">{t("Dark")}</option>
</ThinSelect>
</SettingsRow>
<SettingsRow
label="Background"
hint={`Empty = ${resolved.background ?? "#141414"}`}
label={t("Background")}
hint={t("Empty = {v0}", { v0: resolved.background ?? "#141414" })}
testid="row-background"
>
<CommitInput
value={appearance?.background ?? ""}
placeholder="#141414"
ariaLabel="background hex"
ariaLabel={t("background hex")}
testid="input-background"
onCommit={(v) => onSectionChange({ ...appearance, background: v === "" ? null : v })}
/>
</SettingsRow>
<SettingsRow
label="Reading font"
hint={`Empty = ${DEFAULT_READING_FONT}`}
label={t("Reading font")}
hint={t("Empty = {v0}", { v0: language === "ru" ? "Noto Sans" : DEFAULT_READING_FONT })}
testid="row-font"
>
<span ref={fontMenuOpen ? menuAnchorRef : undefined} className="relative flex items-center gap-2">
@@ -222,13 +225,13 @@ export function AppearanceSection({
const triggerButton = (
<button
type="button"
aria-label="Reading font"
aria-label={t("Reading font")}
aria-expanded={fontMenuOpen}
data-testid="font-trigger"
onClick={openFontMenu}
className="flex h-7 min-w-20 items-center gap-1 rounded-sm border border-border bg-background px-1.5 font-ui text-xs text-foreground transition-colors hover:border-muted-foreground/50"
>
<span className="min-w-0 flex-1 truncate">{appearance?.fontFamily || DEFAULT_READING_FONT}</span>
<span className="min-w-0 flex-1 truncate">{appearance?.fontFamily || (language === "ru" ? "Noto Sans" : DEFAULT_READING_FONT)}</span>
<span aria-hidden className="text-muted-foreground">
▾
</span>
@@ -237,7 +240,7 @@ export function AppearanceSection({
return fontMenuOpen ? (
triggerButton
) : (
<Hint label="Choose from your installed fonts" side="left">
<Hint label={t("Choose from your installed fonts")} side="left">
{triggerButton}
</Hint>
);
@@ -258,21 +261,21 @@ export function AppearanceSection({
? undefined
: localFonts === null
? enumEmpty
? "Common families — installed fonts could not be read"
: "Common families — your installed list needs the desktop app"
: "Your installed fonts"
? t("Common families — installed fonts could not be read")
: t("Common families — your installed list needs the desktop app")
: t("Your installed fonts")
}
/>
</MenuShell>
)}
</span>
</SettingsRow>
<SettingsRow label="Reading font size" hint={`${resolved.proseFontSize}px`} testid="row-prose-size">
<SettingsRow label={t("Reading font size")} hint={`${resolved.proseFontSize}px`} testid="row-prose-size">
<CommitInput
type="number"
value={String(appearance?.proseFontSize ?? "")}
placeholder="16"
ariaLabel="prose font size"
ariaLabel={t("prose font size")}
testid="input-prose-size"
onCommit={(v) => {
// Empty = factory default (owner, fix wave): omit the key so the record
@@ -288,19 +291,19 @@ export function AppearanceSection({
}}
/>
</SettingsRow>
<SettingsRow label="Session names in serif" testid="row-session-serif">
<SettingsRow label={t("Session names in serif")} testid="row-session-serif">
<Toggle
checked={appearance?.sessionNameSerif ?? true}
label="session names serif"
label={t("session names serif")}
onChange={(on) => onSectionChange({ ...appearance, sessionNameSerif: on })}
/>
</SettingsRow>
<SettingsRow label="Heading weight" hint={`${resolved.mdRendering.headingWeight}`} testid="row-heading-weight">
<SettingsRow label={t("Heading weight")} hint={`${resolved.mdRendering.headingWeight}`} testid="row-heading-weight">
<CommitInput
type="number"
value={String(md?.headingWeight ?? "")}
placeholder="900"
ariaLabel="heading weight"
ariaLabel={t("heading weight")}
testid="input-heading-weight"
onCommit={(v) => {
// Empty = factory default (owner, fix wave): Number("") is 0, so
@@ -321,20 +324,20 @@ export function AppearanceSection({
{/* The emphasis inks default to the REGULAR TEXT COLOR now
(appearance wave, 2026-09-26) — the empty-state labels say
so; explicit hex overrides ride unchanged. */}
<SettingsRow label="Bold color" hint="Empty = same as text" testid="row-bold-color">
<SettingsRow label={t("Bold color")} hint={t("Empty = same as text")} testid="row-bold-color">
<CommitInput
value={md?.boldColor ?? ""}
placeholder="same as text"
ariaLabel="bold color"
placeholder={t("same as text")}
ariaLabel={t("bold color")}
testid="input-bold-color"
onCommit={(v) => onSectionChange({ ...appearance, mdRendering: { ...md, boldColor: v === "" ? null : v } })}
/>
</SettingsRow>
<SettingsRow label="Italic color" hint="Empty = same as text" testid="row-italic-color">
<SettingsRow label={t("Italic color")} hint={t("Empty = same as text")} testid="row-italic-color">
<CommitInput
value={md?.italicColor ?? ""}
placeholder="same as text"
ariaLabel="italic color"
placeholder={t("same as text")}
ariaLabel={t("italic color")}
testid="input-italic-color"
onCommit={(v) => onSectionChange({ ...appearance, mdRendering: { ...md, italicColor: v === "" ? null : v } })}
/>
@@ -350,6 +353,7 @@ export function GraphSection({
graph: GraphSettings | undefined;
onSectionChange: (next: GraphSettings) => void;
}) {
const { t } = useI18n();
// Wave 7 collapse (owner ruling 2026-09-23): the section is the MASTER
// SWITCH — the embedding/clustering/labeling rungs are gone; clustering
// is the free birth-session partition derived on read, labels ARE session
@@ -370,10 +374,10 @@ export function GraphSection({
switch below arrived: off ≠ no graph, off = the graph stops
growing. The testid keeps the old name's shape (row-enabled) so
this stays one rename, not a test re-pin. */}
<SettingsRow label="Extraction" hint="Off = the graph stops growing" testid="graph-row-enabled">
<SettingsRow label={t("Extraction")} hint={t("Off = the graph stops growing")} testid="graph-row-enabled">
<Toggle
checked={graph?.enabled ?? true}
label="extraction enabled"
label={t("extraction enabled")}
onChange={(on) =>
// Compose, never replace-whole (the section PUT is, but the
// row must not drop its sibling field — fixed with the budget
@@ -386,23 +390,23 @@ export function GraphSection({
round): display-only. Off = the home shows the chains view
instead of the canvas. The ledgers, the pipeline, everything
data keeps working — that is the extraction switch above. */}
<SettingsRow label="Graph rendering" hint="Off = home shows the chains view" testid="graph-row-rendering">
<SettingsRow label={t("Graph rendering")} hint={t("Off = home shows the chains view")} testid="graph-row-rendering">
<Toggle
checked={graph?.rendering ?? true}
label="graph rendering enabled"
label={t("graph rendering enabled")}
onChange={(on) => onSectionChange({ ...graph, rendering: on })}
/>
</SettingsRow>
<SettingsRow
label="Extraction chunk budget"
hint={`${resolved.extractionChunkTokens} tok`}
label={t("Extraction chunk budget")}
hint={t("{v0} tok", { v0: resolved.extractionChunkTokens })}
testid="graph-row-chunk-tokens"
>
<CommitInput
type="number"
value={String(graph?.extractionChunkTokens ?? "")}
placeholder={String(resolved.extractionChunkTokens)}
ariaLabel="extraction chunk budget"
ariaLabel={t("extraction chunk budget")}
testid="input-chunk-tokens"
onCommit={(v) => {
// Empty = factory default (the fix-wave discipline): absence is
+41 -35
View File
@@ -1,4 +1,6 @@
"use client";
import { useI18n } from "@/components/i18n";
import { createContext, useContext, useEffect, useRef, useState } from "react";
import type { ChatGPTStatus } from "@/src/core";
@@ -41,6 +43,7 @@ const Context = createContext({
/** UI lifetime spans setup's keys → models transition. Only existing safe controls
* are used; account IDs remain control values, never displayed account labels. */
export function ChatGPTUIProvider({ children }: { children: React.ReactNode }) {
const { t } = useI18n();
const [status, setStatus] = useState<ChatGPTStatus>(empty);
const [dialog, setDialog] = useState<Dialog | null>(null);
const [busy, setBusy] = useState(false);
@@ -257,55 +260,55 @@ export function ChatGPTUIProvider({ children }: { children: React.ReactNode }) {
{dialog && <AccountDialog key={`${dialog.kind}:${dialog.kind === "manage" && !!dialog.confirm}`} onClose={close} busy={busy && operation.current !== "login"}>
<h2 className="text-sm font-semibold text-foreground">ChatGPT</h2>
{dialog.kind === "warning" ? <>
<p role="alert" className="rounded-sm border border-border px-3 py-2 text-xs text-foreground" data-testid="chatgpt-revocation-warning">{REVOCATION_WARNING}</p>
<div className="mt-4 flex justify-end"><Button variant="outline" size="sm" onClick={() => { context.dismissWarning(); showDialog(null); }}>Dismiss</Button></div>
<p role="alert" className="rounded-sm border border-border px-3 py-2 text-xs text-foreground" data-testid="chatgpt-revocation-warning">{t(REVOCATION_WARNING)}</p>
<div className="mt-4 flex justify-end"><Button variant="outline" size="sm" onClick={() => { context.dismissWarning(); showDialog(null); }}>{t("Dismiss")}</Button></div>
</> : dialog.kind === "manage" && dialog.confirm ? <div data-testid="chatgpt-disconnect-confirmation">
<p className="mt-3 text-sm font-semibold text-red-400">{CHATGPT_DISCONNECT_COPY.title}</p>
<p className="mt-3 text-xs text-foreground">{managed?.email || "ChatGPT account"}</p>
<p className="mt-3 text-xs text-muted-foreground">{CHATGPT_DISCONNECT_COPY.body}</p>
{context.error && <p role="alert" className="mt-3 text-xs text-red-400">{context.error}</p>}
<p className="mt-3 text-sm font-semibold text-red-400">{t(CHATGPT_DISCONNECT_COPY.title)}</p>
<p className="mt-3 text-xs text-foreground">{managed?.email || t("ChatGPT account")}</p>
<p className="mt-3 text-xs text-muted-foreground">{t(CHATGPT_DISCONNECT_COPY.body)}</p>
{context.error && <p role="alert" className="mt-3 text-xs text-red-400">{t(context.error)}</p>}
<div className="mt-4 flex items-center justify-end gap-2">
<Button variant="ghost" size="sm" disabled={busy} onClick={close}>Cancel</Button>
<Button variant="destructive" size="sm" data-testid="chatgpt-confirm-disconnect" disabled={busy || !managedPresent} onClick={() => bridge && managed && void control("disconnect", () => bridge.disconnect(managed.id), managed.id)}>Disconnect account</Button>
<Button variant="ghost" size="sm" disabled={busy} onClick={close}>{t("Cancel")}</Button>
<Button variant="destructive" size="sm" data-testid="chatgpt-confirm-disconnect" disabled={busy || !managedPresent} onClick={() => bridge && managed && void control("disconnect", () => bridge.disconnect(managed.id), managed.id)}>{t("Disconnect account")}</Button>
</div>
</div> : <>
{dialog.kind === "connect" ? <>
{status.accounts.length > 0 && <div className="mt-3 flex items-center gap-2 text-xs text-muted-foreground">
<select aria-label="Saved ChatGPT account" value={registration} onChange={(event) => setRegistration(event.target.value)} disabled={busy || status.pending} className="min-w-0 rounded-sm border border-border bg-background px-2 py-1.5 text-foreground">
<option value="">Add another account or workspace</option>
{status.accounts.map((a) => <option key={a.id} value={a.id}>{a.email || "ChatGPT account"}</option>)}
<select aria-label={t("Saved ChatGPT account")} value={registration} onChange={(event) => setRegistration(event.target.value)} disabled={busy || status.pending} className="min-w-0 rounded-sm border border-border bg-background px-2 py-1.5 text-foreground">
<option value="">{t("Add another account or workspace")}</option>
{status.accounts.map((a) => <option key={a.id} value={a.id}>{a.email || t("ChatGPT account")}</option>)}
</select>
{registration && <Button variant="ghost" size="xs" disabled={busy} onClick={() => context.manage(registration)}>Manage…</Button>}
{registration && <Button variant="ghost" size="xs" disabled={busy} onClick={() => context.manage(registration)}>{t("Manage…")}</Button>}
</div>}
<p className="mt-3 text-xs leading-relaxed text-muted-foreground" data-testid="chatgpt-privacy-notice">{CHATGPT_NOTICE}</p>
<p className="mt-3 text-xs leading-relaxed text-muted-foreground" data-testid="chatgpt-privacy-notice">{t(CHATGPT_NOTICE)}</p>
</> : null}
<div className={`mt-4 flex items-center gap-3 text-xs text-foreground ${busy && operation.current === "change" && status.available && !status.pending ? "[&>button:disabled]:cursor-auto" : ""}`} data-testid="chatgpt-global-background">
<Toggle label={BACKGROUND_LABEL} checked={dialog.kind === "connect" ? background : status.allowBackgroundUse} disabled={busy || status.pending || !status.available} onChange={(enabled) => {
<Toggle label={t(BACKGROUND_LABEL)} checked={dialog.kind === "connect" ? background : status.allowBackgroundUse} disabled={busy || status.pending || !status.available} onChange={(enabled) => {
if (dialog.kind === "connect") { setBackground(enabled); setBackgroundDirty(true); }
else if (bridge) void control("change", () => bridge.consent(enabled));
}} />
<span>{BACKGROUND_LABEL}<span className="mt-1 block leading-relaxed text-muted-foreground">{CHATGPT_BACKGROUND_HINT}</span></span>
<span>{t(BACKGROUND_LABEL)}<span className="mt-1 block leading-relaxed text-muted-foreground">{t(CHATGPT_BACKGROUND_HINT)}</span></span>
</div>
<div className="mt-3 flex flex-wrap items-center gap-3 text-xs text-muted-foreground">
<a href="https://openai.com/policies/privacy-policy/" target="_blank" rel="noreferrer" className="underline underline-offset-2">OpenAI privacy</a>
<a href="https://chatgpt.com/settings/usage" target="_blank" rel="noreferrer" className="underline underline-offset-2">Manage usage</a>
<a href="https://openai.com/policies/privacy-policy/" target="_blank" rel="noreferrer" className="underline underline-offset-2">{t("OpenAI privacy")}</a>
<a href="https://chatgpt.com/settings/usage" target="_blank" rel="noreferrer" className="underline underline-offset-2">{t("Manage usage")}</a>
</div>
{!status.available && <p className="mt-3 text-xs text-muted-foreground">ChatGPT sign-in is available only in the desktop app. API-key connections remain available here.</p>}
{status.pending && <p role="status" className="mt-3 text-xs text-muted-foreground">Waiting for browser sign-in…</p>}
{context.error && <p role="alert" className="mt-3 text-xs text-red-400">{context.error}</p>}
{!status.available && <p className="mt-3 text-xs text-muted-foreground">{t("ChatGPT sign-in is available only in the desktop app. API-key connections remain available here.")}</p>}
{status.pending && <p role="status" className="mt-3 text-xs text-muted-foreground">{t("Waiting for browser sign-in…")}</p>}
{context.error && <p role="alert" className="mt-3 text-xs text-red-400">{t(context.error)}</p>}
{dialog.kind === "manage" && managed && <div className="mt-4 flex flex-wrap items-center justify-between gap-3" data-testid="chatgpt-account-strip">
<div data-testid="chatgpt-managed-identity" className="flex min-w-0 items-center gap-2 text-xs text-foreground">
{status.accounts.length > 1 && managedPresent && !busy ? <select aria-label="Manage ChatGPT account" value={managed.id} onChange={(event) => context.manage(event.target.value)} className="min-w-0 rounded-sm border border-border bg-background px-2 py-1.5">
{status.accounts.map((a) => <option key={a.id} value={a.id}>{a.email || "ChatGPT account"}</option>)}
</select> : <span className="break-all">{managed.email || "ChatGPT account"}</span>}
{status.accounts.length > 1 && managedPresent && !busy ? <select aria-label={t("Manage ChatGPT account")} value={managed.id} onChange={(event) => context.manage(event.target.value)} className="min-w-0 rounded-sm border border-border bg-background px-2 py-1.5">
{status.accounts.map((a) => <option key={a.id} value={a.id}>{a.email || t("ChatGPT account")}</option>)}
</select> : <span className="break-all">{managed.email || t("ChatGPT account")}</span>}
</div>
<Button variant="ghost" size="sm" data-testid="chatgpt-add-accounts" className="cursor-pointer text-muted-foreground disabled:cursor-not-allowed" onClick={() => openConnect()} disabled={busy || status.pending || !status.available}>Add accounts</Button>
<Button variant="ghost" size="sm" data-testid="chatgpt-add-accounts" className="cursor-pointer text-muted-foreground disabled:cursor-not-allowed" onClick={() => openConnect()} disabled={busy || status.pending || !status.available}>{t("Add accounts")}</Button>
</div>}
<ConnectionFooter testid="chatgpt-account-footer" leading={dialog.kind === "manage" && <div data-testid="chatgpt-account-actions">
<Button variant="ghost" size="sm" className="cursor-pointer text-red-400 hover:bg-red-500/10 hover:text-red-300" data-testid="chatgpt-disconnect" disabled={busy || status.pending || !managedPresent} onClick={() => showDialog({ ...dialog, confirm: true })}>Disconnect</Button>
<Button variant="ghost" size="sm" className="cursor-pointer text-red-400 hover:bg-red-500/10 hover:text-red-300" data-testid="chatgpt-disconnect" disabled={busy || status.pending || !managedPresent} onClick={() => showDialog({ ...dialog, confirm: true })}>{t("Disconnect")}</Button>
</div>}>
{dialog.kind === "manage" && managedPresent && managed && !managed.connected && managed.canSignInAgain === true && status.available && <Button variant="outline" size="sm" disabled={busy || status.pending || !status.available} onClick={() => openConnect(managed.id)}>Sign in again</Button>}
<Button variant={dialog.kind === "manage" ? "outline" : "ghost"} size="sm" onClick={close} disabled={busy && operation.current !== "login"}>{dialog.kind === "connect" ? "Cancel" : "Close"}</Button>
{dialog.kind === "manage" && managedPresent && managed && !managed.connected && managed.canSignInAgain === true && status.available && <Button variant="outline" size="sm" disabled={busy || status.pending || !status.available} onClick={() => openConnect(managed.id)}>{t("Sign in again")}</Button>}
<Button variant={dialog.kind === "manage" ? "outline" : "ghost"} size="sm" onClick={close} disabled={busy && operation.current !== "login"}>{dialog.kind === "connect" ? t("Cancel") : t("Close")}</Button>
{dialog.kind === "connect" && <ContinueButton onClick={() => void login()} disabled={!status.available || busy || status.pending} testid="chatgpt-proceed" />}
</ConnectionFooter>
</>}
@@ -314,38 +317,41 @@ export function ChatGPTUIProvider({ children }: { children: React.ReactNode }) {
}
function ContinueButton({ onClick, disabled, testid = "chatgpt-login" }: { onClick: () => void; disabled: boolean; testid?: string }) {
const { t } = useI18n();
return <Button type="button" variant="outline" size="sm" data-testid={testid} onClick={onClick} disabled={disabled} className="cursor-pointer disabled:cursor-not-allowed">
{/* Official unmodified mark; existing secondary button hover/focus treatment. */}
{/* eslint-disable-next-line @next/next/no-img-element */}
<img src="/siwc/chatgpt-logo-white.svg" width="16" height="16" alt="" aria-hidden="true" />
Continue with ChatGPT
</Button>;
{t("Continue with ChatGPT")}</Button>;
}
function AccountDialog({ children, onClose, busy }: { children: React.ReactNode; onClose: () => void; busy: boolean }) {
return <ConnectionDialog label="ChatGPT account" testid="chatgpt-account-dialog" backdropTestid="chatgpt-dialog-backdrop" onClose={onClose} busy={busy}>{children}</ConnectionDialog>;
const { t } = useI18n();
return <ConnectionDialog label={t("ChatGPT account")} testid="chatgpt-account-dialog" backdropTestid="chatgpt-dialog-backdrop" onClose={onClose} busy={busy}>{children}</ConnectionDialog>;
}
/** Shared compact settings/setup row; account selection precedes the quiet action. */
export function ChatGPTConnection(_props: { setup?: boolean } = {}) {
const { t } = useI18n();
const ui = useContext(Context);
const active = ui.status.accounts.find((a) => a.id === ui.status.activeId && a.connected);
const accounts = ui.status.accounts.filter((a) => a.connected);
return <div className="flex flex-col gap-2" data-testid="chatgpt-connection">
<ConnectionRow label="ChatGPT" actionTestid={active ? undefined : "chatgpt-login"} action={active ? "Manage…" : "Connect"} disabled={!ui.status.available || ui.busy || ui.status.pending} onAction={() => active ? ui.manage(active.id) : ui.connect()}>
{active && <select aria-label="ChatGPT account" value={active.id} disabled={ui.busy || ui.status.pending} onChange={(event) => ui.select(event.target.value)} className="min-w-0 rounded-sm border border-border bg-background px-2 py-1 text-xs text-foreground">
{accounts.map((a) => <option key={a.id} value={a.id}>{a.email || "ChatGPT account"}</option>)}
<ConnectionRow label="ChatGPT" actionTestid={active ? undefined : "chatgpt-login"} action={active ? t("Manage…") : t("Connect")} disabled={!ui.status.available || ui.busy || ui.status.pending} onAction={() => active ? ui.manage(active.id) : ui.connect()}>
{active && <select aria-label={t("ChatGPT account")} value={active.id} disabled={ui.busy || ui.status.pending} onChange={(event) => ui.select(event.target.value)} className="min-w-0 rounded-sm border border-border bg-background px-2 py-1 text-xs text-foreground">
{accounts.map((a) => <option key={a.id} value={a.id}>{a.email || t("ChatGPT account")}</option>)}
</select>}
</ConnectionRow>
{ui.inlineError && <p role="alert" className="text-xs text-red-400">{ui.inlineError}</p>}
{ui.warning && <div role="alert" data-testid="chatgpt-revocation-warning" className="rounded-sm border border-border px-3 py-2 text-xs text-muted-foreground">{ui.warning}<button type="button" className="ml-2 cursor-pointer underline underline-offset-2" onClick={ui.dismissWarning}>Dismiss</button></div>}
{ui.warning && <div role="alert" data-testid="chatgpt-revocation-warning" className="rounded-sm border border-border px-3 py-2 text-xs text-muted-foreground">{ui.warning}<button type="button" className="ml-2 cursor-pointer underline underline-offset-2" onClick={ui.dismissWarning}>{t("Dismiss")}</button></div>}
</div>;
}
/** Contextual consent discovery belongs beside background model choices only. */
export function ChatGPTBackgroundAccess({ provider }: { provider: string | undefined }) {
const { t } = useI18n();
const ui = useContext(Context);
const account: Account | undefined = ui.status.accounts.find((a) => a.id === ui.status.activeId && a.connected);
if (provider !== "chatgpt" || !account || ui.status.allowBackgroundUse) return null;
return <Button type="button" size="xs" variant="ghost" className="cursor-pointer" disabled={ui.busy} onClick={() => ui.manage(account.id)}>Enable background use</Button>;
return <Button type="button" size="xs" variant="ghost" className="cursor-pointer" disabled={ui.busy} onClick={() => ui.manage(account.id)}>{t("Enable background use")}</Button>;
}
+4 -1
View File
@@ -1,14 +1,17 @@
"use client";
import { useI18n } from "@/components/i18n";
import { useEffect, useRef } from "react";
import { createPortal } from "react-dom";
import { useBackdropDismiss } from "@/components/backdrop-dismiss";
import { Button } from "@/components/ui/button";
export function ConnectionRow({ label, action, disabled, onAction, children, testid, actionTestid }: { label: string; action: string; disabled: boolean; onAction: () => void; children?: React.ReactNode; testid?: string; actionTestid?: string }) {
const { t } = useI18n();
return <div className="flex flex-wrap items-center gap-3" data-testid={testid}>
<span className="font-ui text-xs text-foreground">{label}</span>
{children}
<Button type="button" size="xs" variant={action === "Connect" ? "outline" : "ghost"} className={`cursor-pointer disabled:cursor-not-allowed ${action === "Connect" ? "" : "text-muted-foreground hover:text-foreground"}`} data-testid={actionTestid} disabled={disabled} onClick={onAction}>{action}</Button>
<Button type="button" size="xs" variant={action === t("Connect") ? "outline" : "ghost"} className={`cursor-pointer disabled:cursor-not-allowed ${action === t("Connect") ? "" : "text-muted-foreground hover:text-foreground"}`} data-testid={actionTestid} disabled={disabled} onClick={onAction}>{action}</Button>
</div>;
}
+13 -7
View File
@@ -1,4 +1,6 @@
"use client";
import { useI18n } from "@/components/i18n";
/**
* Thin settings primitives (settings-app wave): the control kit the
* settings dialog needs. Sections → groups → rows; everything SSRs clean
@@ -25,10 +27,11 @@ export function SettingsSection({
children: React.ReactNode;
testid?: string;
}) {
const { t } = useI18n();
return (
<section data-testid={testid} className="px-6 py-5">
<h2 className="text-sm uppercase tracking-widest text-foreground">{title}</h2>
{hint && <p className="mt-1 text-xs text-muted-foreground">{hint}</p>}
<h2 className="text-sm uppercase tracking-widest text-foreground">{t(title)}</h2>
{hint && <p className="mt-1 text-xs text-muted-foreground">{t(hint)}</p>}
<div className="mt-4 flex flex-col gap-3">{children}</div>
</section>
);
@@ -45,9 +48,10 @@ export function SettingsGroup({
children: React.ReactNode;
testid?: string;
}) {
const { t } = useI18n();
return (
<div data-testid={testid} className="mt-5 border-t border-border/60 pt-4 first:mt-0 first:border-t-0 first:pt-0">
<p className="mb-2 text-xs uppercase tracking-widest text-muted-foreground">{label}</p>
<p className="mb-2 text-xs uppercase tracking-widest text-muted-foreground">{t(label)}</p>
<div className="flex flex-col gap-2">{children}</div>
</div>
);
@@ -70,11 +74,12 @@ export function SettingsRow({
* control is inert (parent off, no file yet, …). */
dimmed?: boolean;
}) {
const { t } = useI18n();
return (
<div data-testid={testid} className={cn("flex items-center justify-between gap-4 py-1", dimmed && "opacity-40")}>
<span className="min-w-0">
<span className="block font-prose text-(length:--lh-prose-size)">{label}</span>
{hint && <span className="block text-xs text-muted-foreground/70">{hint}</span>}
<span className="block font-prose text-(length:--lh-prose-size)">{t(label)}</span>
{hint && <span className="block text-xs text-muted-foreground/70">{t(hint)}</span>}
</span>
{children}
</div>
@@ -102,13 +107,14 @@ export function Toggle({
/** Optional native tooltip on the switch. */
title?: string;
}) {
const { t } = useI18n();
return (
<button
type="button"
role="switch"
aria-checked={checked}
aria-label={label}
{...(title ? { title } : {})}
aria-label={t(label)}
{...(title ? { title: t(title) } : {})}
disabled={disabled}
data-testid={`toggle-${label.toLowerCase().replace(/[^a-z0-9]+/g, "-")}`}
onClick={() => onChange(!checked)}
+12 -13
View File
@@ -1,4 +1,6 @@
"use client";
import { useI18n } from "@/components/i18n";
/**
* The Keyboard section (thin settings wave): view / rebind / reset over
* core/keyboard.ts's registry. The overrides persist in localStorage
@@ -48,6 +50,7 @@ export function nextOverrides(
}
export function KeyboardSection() {
const { t } = useI18n();
const [overrides, setOverrides] = useState<Partial<Record<KeyAction, string>>>({});
const [rebinding, setRebinding] = useState<KeyAction | null>(null);
const [nonce, setNonce] = useState(0);
@@ -101,20 +104,20 @@ export function KeyboardSection() {
if (!actions.length) return null;
return (
<div key={scope} data-testid={`kbd-scope-${scope}`}>
<p className="mb-1 text-xs uppercase tracking-widest text-muted-foreground/70">{label}</p>
<p className="mb-1 text-xs uppercase tracking-widest text-muted-foreground/70">{t(label)}</p>
<div className="flex flex-col gap-2">
{actions.map((def) => {
const effective = overrides[def.id] ?? def.binding;
return (
<SettingsRow
key={def.id}
label={def.label}
hint={def.displayOnly ? "Fixed — part of this screen" : undefined}
label={t(def.label)}
hint={def.displayOnly ? t("Fixed — part of this screen") : undefined}
testid={`kbd-row-${def.id}`}
>
<span className="flex items-center gap-2">
<kbd className="rounded-sm border border-border bg-muted px-1.5 py-0.5 font-ui text-xs">
{rebinding === def.id ? "Press keys…" : formatBinding(effective)}
{rebinding === def.id ? t("Press keys…") : t(formatBinding(effective))}
</kbd>
{def.displayOnly ? null : rebinding === def.id ? (
<button
@@ -122,8 +125,7 @@ export function KeyboardSection() {
onClick={() => setRebinding(null)}
className="text-xs text-muted-foreground/70 underline-offset-2 hover:text-foreground hover:underline"
>
Cancel
</button>
{t("Cancel")}</button>
) : (
<>
<button
@@ -132,8 +134,7 @@ export function KeyboardSection() {
data-testid={`kbd-rebind-${def.id}`}
className="text-xs text-muted-foreground underline-offset-2 hover:text-foreground hover:underline"
>
Rebind
</button>
{t("Rebind")}</button>
{overridden(def.id) && (
<button
type="button"
@@ -141,8 +142,7 @@ export function KeyboardSection() {
data-testid={`kbd-reset-${def.id}`}
className="text-xs text-muted-foreground/70 underline-offset-2 hover:text-foreground hover:underline"
>
Reset
</button>
{t("Reset")}</button>
)}
</>
)}
@@ -155,15 +155,14 @@ export function KeyboardSection() {
);
})}
<div className="mt-1 flex items-center justify-between">
<span className="text-xs text-muted-foreground/70">Stored on this device (lh:keymap) — overrides only.</span>
<span className="text-xs text-muted-foreground/70">{t("Stored on this device (lh:keymap) — overrides only.")}</span>
<button
type="button"
onClick={resetAll}
data-testid="kbd-reset-all"
className="text-xs text-muted-foreground underline-offset-2 hover:text-foreground hover:underline"
>
Reset all to defaults
</button>
{t("Reset all to defaults")}</button>
</div>
</>
);
+28 -27
View File
@@ -1,4 +1,7 @@
"use client";
import { translator, type Translator } from "@/src/core";
import { useI18n } from "@/components/i18n";
/**
* The Keys section (wave-5 polish, 2026-09-21 — owner design): a MINIMAL
* key-value table. Provider left, masked key right, no description hints
@@ -98,11 +101,11 @@ export function classifyKeys(
/** The minimal status line, e.g. "…ab12 · 64 chars" (" · env" marks an
* environment-owned value — the row itself is read-only then). Pure. */
export function keyStatusLine(status: AuthKeyStatus): string {
if (!status.present || !status.masked) return "not set";
export function keyStatusLine(status: AuthKeyStatus, t: Translator = translator("en")): string {
if (!status.present || !status.masked) return t("not set");
const tail = status.masked.replace(/ \((\d+) chars\)$/, "");
const chars = status.masked.match(/\((\d+) chars\)$/)?.[1] ?? "?";
return `${tail} · ${chars}${status.source === "env" ? " · env" : ""}`;
return `${tail} · ${chars}${status.source === "env" ? t(" · env") : ""}`;
}
/** One table row: label left, tiny docs link + status right. Writable
@@ -132,6 +135,7 @@ function KeyTableRow({
onCancel: () => void;
testid: string;
}) {
const { t } = useI18n();
const envOwned = status.source === "env";
const [value, setValue] = useState("");
const [error, setError] = useState<string | null>(null);
@@ -163,8 +167,8 @@ function KeyTableRow({
onKeyDown={(e) => {
if (e.key === "Enter") void save();
}}
placeholder="Paste key…"
aria-label={`${label} key`}
placeholder={t("Paste key…")}
aria-label={t("{v0} key", { v0: label })}
data-testid={`${testid}-input`}
disabled={busy}
autoFocus
@@ -177,8 +181,7 @@ function KeyTableRow({
data-testid={`${testid}-save`}
className="shrink-0 text-xs text-foreground underline-offset-2 hover:underline disabled:text-muted-foreground/40 disabled:no-underline"
>
Save
</button>
{t("Save")}</button>
<button
type="button"
onClick={onCancel}
@@ -186,8 +189,7 @@ function KeyTableRow({
data-testid={`${testid}-cancel`}
className="shrink-0 text-xs text-muted-foreground/70 underline-offset-2 hover:text-foreground hover:underline"
>
Cancel
</button>
{t("Cancel")}</button>
{status.present && status.source === "file" && onDelete && (
<button
type="button"
@@ -196,13 +198,12 @@ function KeyTableRow({
data-testid={`${testid}-delete`}
className="shrink-0 text-xs text-muted-foreground/70 underline-offset-2 hover:text-red-500 hover:underline"
>
Delete
</button>
{t("Delete")}</button>
)}
</div>
{error && (
<p role="alert" data-testid={`${testid}-error`} className="px-1 text-xs text-red-500">
{error}
{t(error)}
</p>
)}
</div>
@@ -230,17 +231,16 @@ function KeyTableRow({
onClick={(e) => e.stopPropagation()}
className="text-[10px] text-muted-foreground/60 underline-offset-2 hover:text-foreground hover:underline"
>
key ↗
</a>
{t("key ↗")}</a>
)}
<span data-testid={`${testid}-status`} className="font-mono text-[11px] text-muted-foreground">
{keyStatusLine(status)}
{keyStatusLine(status, t)}
</span>
</span>
</button>
{error && (
<p role="alert" data-testid={`${testid}-error`} className="pl-6 text-xs text-red-500">
{error}
{t(error)}
</p>
)}
</div>
@@ -256,6 +256,7 @@ function AddProviderRow({
addable: ProviderKeyInfo[];
onPick: (p: ProviderKeyInfo) => void;
}) {
const { t } = useI18n();
const [open, setOpen] = useState(false);
const anchorRef = useRef<HTMLDivElement>(null);
@@ -290,7 +291,7 @@ function AddProviderRow({
onClick={() => setOpen((v) => !v)}
className="flex w-full items-center justify-between gap-3 rounded-sm px-1 py-1.5 text-left font-ui text-xs text-muted-foreground transition-colors hover:bg-accent/50 hover:text-foreground"
>
<span>+ Add provider key</span>
<span>{t("+ Add provider key")}</span>
{addable.length > 0 && <span className="font-mono text-[11px]">{addable.length}</span>}
</button>
{open && (
@@ -324,6 +325,7 @@ export function KeysSection({
}: {
initialRow?: string;
}) {
const { t } = useI18n();
const [statuses, setStatuses] = useState<AuthKeyStatus[] | null>(null);
const [providers, setProviders] = useState<ProviderKeyInfo[] | undefined>(undefined);
const [loading, setLoading] = useState(true);
@@ -397,7 +399,7 @@ export function KeysSection({
}
if (loading || statuses === null) {
return <p className="px-6 py-5 text-xs text-muted-foreground/70">Loading keys…</p>;
return <p className="px-6 py-5 text-xs text-muted-foreground/70">{t("Loading keys…")}</p>;
}
const { providerRows, toolRows, unknownRows, addable } = classifyKeys(statuses, providers);
@@ -413,7 +415,7 @@ export function KeysSection({
return (
<>
<SettingsGroup label="Model API keys" testid="group-provider-keys">
<SettingsGroup label={t("Model API keys")} testid="group-provider-keys">
{providerRows.map((status) => (
<KeyTableRow
key={status.id}
@@ -455,7 +457,7 @@ export function KeysSection({
</div>}
</SettingsGroup>
<SettingsGroup label="Subscriptions" testid="group-subscriptions">
<SettingsGroup label={t("Subscriptions")} testid="group-subscriptions">
<SubscriptionConnections />
</SettingsGroup>
@@ -497,6 +499,7 @@ function AddProviderEditor({
onCancel: () => void;
error: string | null;
}) {
const { t } = useI18n();
const [value, setValue] = useState("");
return (
<>
@@ -507,8 +510,8 @@ function AddProviderEditor({
onKeyDown={(e) => {
if (e.key === "Enter" && value.trim()) onSave(value);
}}
placeholder="Paste key…"
aria-label={`${provider.label} key`}
placeholder={t("Paste key…")}
aria-label={t("{v0} key", { v0: provider.label })}
data-testid={`key-${provider.id}-input`}
autoFocus
className="h-7 min-w-0 flex-1 rounded-sm border border-border bg-background px-1.5 font-mono text-xs text-foreground outline-none placeholder:text-muted-foreground/60 focus:border-muted-foreground/50"
@@ -520,19 +523,17 @@ function AddProviderEditor({
data-testid={`key-${provider.id}-save`}
className="shrink-0 text-xs text-foreground underline-offset-2 hover:underline disabled:text-muted-foreground/40 disabled:no-underline"
>
Save
</button>
{t("Save")}</button>
<button
type="button"
onClick={onCancel}
data-testid={`key-${provider.id}-cancel`}
className="shrink-0 text-xs text-muted-foreground/70 underline-offset-2 hover:text-foreground hover:underline"
>
Cancel
</button>
{t("Cancel")}</button>
{error && (
<p role="alert" className="w-full pl-0 text-xs text-red-500" data-testid={`key-${provider.id}-error`}>
{error}
{t(error)}
</p>
)}
</>
+30 -23
View File
@@ -1,4 +1,6 @@
"use client";
import { useI18n } from "@/components/i18n";
/**
* The model menu (extracted 2026-09-20 from model-picker.tsx, owner ask:
* the agent settings' role selects use the EXACT same dropdown as the
@@ -53,6 +55,7 @@ export function ModelMenuList({
/** Quiet footer note under the list (context-specific). */
footer?: string;
}) {
const { t, language } = useI18n();
const [query, setQuery] = useState("");
const [menuIdx, setMenuIdx] = useState<number | null>(null);
const searchRef = useRef<HTMLInputElement>(null);
@@ -104,12 +107,12 @@ export function ModelMenuList({
}
}
}}
placeholder="Search models…"
placeholder={t("Search models…")}
className="lh-input mb-1.5 w-full rounded-lg border bg-background px-2 py-1.5 text-foreground outline-none focus:ring-1 focus:ring-ring"
/>
{!models && <div className="px-2 py-3 text-center text-muted-foreground">Loading models…</div>}
{!models && <div className="px-2 py-3 text-center text-muted-foreground">{t("Loading models…")}</div>}
{models && results.length === 0 && (
<div className="px-2 py-3 text-center text-muted-foreground">No models match.</div>
<div className="px-2 py-3 text-center text-muted-foreground">{t("No models match.")}</div>
)}
<ul ref={listRef} data-testid="model-list" className="flex max-h-72 flex-col overflow-y-auto px-1">
{results.map((m, i) => (
@@ -125,13 +128,13 @@ export function ModelMenuList({
>
<span className="flex items-center justify-between gap-2">
<span className="truncate text-card-foreground">{m.name}</span>
<span className="shrink-0 tabular-nums text-muted-foreground" title={subscriptionLabel(`${m.provider}:${m.id}`) ? "Subscription connection" : "USD per 1M tokens in/out"}>
<span className="shrink-0 tabular-nums text-muted-foreground" title={subscriptionLabel(`${m.provider}:${m.id}`) ? t("Subscription connection") : t("USD per 1M tokens in/out")}>
{subscriptionLabel(`${m.provider}:${m.id}`) ?? priceLabel(m.cost)}
</span>
</span>
<span className="flex items-center justify-between gap-2 text-muted-foreground">
<span className="truncate">{subscriptionLabel(`${m.provider}:${m.id}`) ? `${subscriptionLabel(`${m.provider}:${m.id}`)} (subscription)` : m.provider} · {m.id}</span>
<span className="shrink-0 tabular-nums">{formatTokens(m.contextWindow)}</span>
<span className="truncate">{subscriptionLabel(`${m.provider}:${m.id}`) ? t("{v0} (subscription)", { v0: subscriptionLabel(`${m.provider}:${m.id}`) }) : m.provider} · {m.id}</span>
<span className="shrink-0 tabular-nums">{formatTokens(m.contextWindow, language)}</span>
</span>
</button>
</li>
@@ -160,22 +163,23 @@ export function ThinkingMenuList({
empty?: string;
footer?: string;
}) {
const { t } = useI18n();
return (
<>
{levels.length === 0 ? (
<div className="px-2 py-3 text-center text-muted-foreground">{empty ?? "No thinking levels."}</div>
<div className="px-2 py-3 text-center text-muted-foreground">{empty ?? t("No thinking levels.")}</div>
) : (
levels.map((level) => (
<button
key={level}
key={t(level)}
type="button"
data-testid={`thinking-option-${level}`}
data-testid={`thinking-option-${t(level)}`}
onClick={() => onPick(level)}
className={`flex items-center justify-between rounded-lg px-2 py-1.5 text-left transition-colors hover:bg-muted ${
level === effectiveLevel ? "bg-muted/60 text-card-foreground" : "text-card-foreground"
}`}
>
<span>{level}</span>
<span>{t(level)}</span>
{level === effectiveLevel && <span aria-hidden>✓</span>}
</button>
))
@@ -199,6 +203,7 @@ export function ProviderMenuList({
providers: { id: string; envVar: string; label: string }[];
onPick: (provider: { id: string; envVar: string; label: string }) => void;
}) {
const { t } = useI18n();
const [query, setQuery] = useState("");
const [menuIdx, setMenuIdx] = useState<number | null>(null);
const searchRef = useRef<HTMLInputElement>(null);
@@ -248,11 +253,11 @@ export function ProviderMenuList({
}
}
}}
placeholder="Search providers…"
placeholder={t("Search providers…")}
className="lh-input mb-1.5 w-full rounded-lg border bg-background px-2 py-1.5 text-foreground outline-none focus:ring-1 focus:ring-ring"
/>
{results.length === 0 && (
<div className="px-2 py-3 text-center text-muted-foreground">No providers match.</div>
<div className="px-2 py-3 text-center text-muted-foreground">{t("No providers match.")}</div>
)}
<ul ref={listRef} data-testid="provider-list" className="flex max-h-72 flex-col overflow-y-auto px-1">
{results.map((p, i) => (
@@ -283,7 +288,7 @@ export function ProviderMenuList({
* with the default first and a searched list drops it honestly — Enter
* can never pick a row that isn't on screen. Pure — exported for
* tests. */
export function fontMenuResults(query: string, families: string[]): Array<string | null> {
export function fontMenuResults(query: string, families: string[], defaultFamily = "Source Sans 3"): Array<string | null> {
const scored = families
.map((f) => ({ f, s: fuzzyScore(query, f) }))
.filter((r) => r.s !== null)
@@ -291,7 +296,7 @@ export function fontMenuResults(query: string, families: string[]): Array<string
.map((r) => r.f);
// The default row's searchable identity: the factory family + the
// word "default" (the suffix it renders with).
const defaultScore = fuzzyScore(query, "Source Sans 3", "default");
const defaultScore = fuzzyScore(query, defaultFamily, "default");
if (query.trim() === "" || defaultScore !== null) return [null, ...scored];
return scored;
}
@@ -300,10 +305,10 @@ export function fontMenuResults(query: string, families: string[]): Array<string
* query that matches NO family becomes the "Use '<query>'" custom pick —
* a matching family is the better pick, so the action row only exists
* when there is nothing else to pick. Exported for tests. */
export function customFamilyFor(query: string, families: string[]): string | null {
export function customFamilyFor(query: string, families: string[], defaultFamily = "Source Sans 3"): string | null {
const trimmed = query.trim();
if (trimmed === "") return null;
return fontMenuResults(query, families).length === 0 ? trimmed : null;
return fontMenuResults(query, families, defaultFamily).length === 0 ? trimmed : null;
}
/** The reading-font dropdown body — ONE search field (owner ask
@@ -329,6 +334,7 @@ export function FontMenuList({
/** Quiet footer note under the list. */
footer?: string;
}) {
const { t, language } = useI18n();
const [query, setQuery] = useState("");
const [menuIdx, setMenuIdx] = useState<number | null>(null);
const searchRef = useRef<HTMLInputElement>(null);
@@ -343,10 +349,11 @@ export function FontMenuList({
listRef.current?.querySelector(`[data-idx="${menuIdx}"]`)?.scrollIntoView({ block: "nearest" });
}, [menuIdx]);
const results = fontMenuResults(query, families ?? []);
const defaultFamily = language === "ru" ? "Noto Sans" : "Source Sans 3";
const results = fontMenuResults(query, families ?? [], defaultFamily);
// The custom-family action row: only when a typed query matches
// nothing (a matching family is the better pick). Enter picks it.
const customFamily = customFamilyFor(query, families ?? []);
const customFamily = customFamilyFor(query, families ?? [], defaultFamily);
const walkable: Array<string | null> = customFamily !== null ? ["__custom__"] : results;
return (
@@ -378,10 +385,10 @@ export function FontMenuList({
}
}
}}
placeholder="Search fonts…"
placeholder={t("Search fonts…")}
className="lh-input mb-1.5 w-full rounded-lg border bg-background px-2 py-1.5 text-foreground outline-none focus:ring-1 focus:ring-ring"
/>
{!families && <div className="px-2 py-3 text-center text-muted-foreground">Loading fonts…</div>}
{!families && <div className="px-2 py-3 text-center text-muted-foreground">{t("Loading fonts…")}</div>}
<ul ref={listRef} data-testid="font-list" className="flex max-h-72 flex-col overflow-y-auto px-1">
{families &&
results.map((family, i) => {
@@ -398,7 +405,7 @@ export function FontMenuList({
i === menuIdx ? "bg-accent/40 ring-1 ring-inset ring-border" : ""
}${effective && i !== menuIdx ? " bg-muted/60" : ""}`}
>
<span className="truncate text-card-foreground">{family ?? "Source Sans 3"}</span>
<span className="truncate text-card-foreground">{family ?? (language === "ru" ? "Noto Sans" : "Source Sans 3")}</span>
<span className="shrink-0 text-muted-foreground">{isDefault ? "default" : effective && <span aria-hidden>✓</span>}</span>
</button>
</li>
@@ -415,8 +422,8 @@ export function FontMenuList({
menuIdx === 0 ? "bg-accent/40 ring-1 ring-inset ring-border" : ""
}`}
>
<span className="truncate text-card-foreground">Use “{customFamily}”</span>
<span className="shrink-0 text-muted-foreground">custom</span>
<span className="truncate text-card-foreground">{t("Use “")}{customFamily}”</span>
<span className="shrink-0 text-muted-foreground">{t("custom")}</span>
</button>
</li>
)}
+28 -22
View File
@@ -39,6 +39,7 @@ import {
type GraphSettings,
type UserSettings,
} from "@/src/core";
import { LanguageSelect, useI18n } from "@/components/i18n";
import { useAppearance } from "./appearance.tsx";
import { SettingsSection, SettingsRow, Toggle } from "./controls.tsx";
import { AgentSection } from "./agent-section.tsx";
@@ -144,6 +145,7 @@ export function SettingsDialogBody({
* retargets follow it exactly like initialSection below. */
initialRow?: string | null;
}) {
const { t, general, saveGeneral } = useI18n();
const { onAppearanceChange } = useAppearance();
const [record, setRecord] = useState<UserSettings | null | undefined>(
undefined,
@@ -232,6 +234,10 @@ export function SettingsDialogBody({
body: object,
) => {
setError(null);
if (section === "general") {
try { await saveGeneral(body); } catch (error) { setError(error instanceof Error ? error.message : "Could not save language settings"); }
return;
}
const res = await fetch(`/api/settings/${section}`, {
method: "PUT",
headers: { "content-type": "application/json" },
@@ -260,30 +266,30 @@ export function SettingsDialogBody({
if (section === "appearance")
onAppearanceChange(body as AppearanceSettings);
},
[onAppearanceChange],
[onAppearanceChange, saveGeneral, t],
);
return (
<div
role="dialog"
aria-modal="true"
aria-label="Settings"
aria-label={t("Settings")}
data-testid="settings-dialog"
className="settings-dialog-pop flex h-[620px] max-h-[85vh] w-[920px] max-w-[90vw] flex-col overflow-hidden rounded-md border border-border bg-card shadow-xl"
onClick={(e) => e.stopPropagation()}
>
{/* Header — the app's quiet CMU title language. */}
<header className="flex h-15 shrink-0 items-center gap-3 border-b border-border px-5">
<h2 className="font-title text-2xl tracking-tight">Settings</h2>
<h2 className="font-title text-2xl tracking-tight">{t("Settings")}</h2>
<Hint
// Esc closes the dialog (its own listener) — surface it.
label="Close"
label={t("Close")}
binding="escape"
side="left"
>
<button
type="button"
aria-label="Close settings"
aria-label={t("Close settings")}
data-testid="settings-close"
className="ml-auto size-7 rounded-sm text-muted-foreground transition-colors hover:bg-accent hover:text-foreground"
onClick={onClose}
@@ -305,7 +311,7 @@ export function SettingsDialogBody({
{/* Left nav — text-only, the session-list mono language,
active = bg-accent. */}
<nav
aria-label="Settings sections"
aria-label={t("Settings sections")}
data-testid="settings-nav"
className="flex w-44 shrink-0 flex-col gap-1 border-r border-border p-2"
>
@@ -322,7 +328,7 @@ export function SettingsDialogBody({
}`}
onClick={() => setActive(s.id)}
>
{s.label}
{t(s.label)}
</button>
))}
</nav>
@@ -344,32 +350,32 @@ export function SettingsDialogBody({
className="m-4 rounded-sm border border-red-500/40 px-3 py-2 text-xs text-red-500"
data-testid="settings-error"
>
{error}
{t(error)}
</p>
)}
{record === undefined ? (
<p className="px-6 py-5 text-xs text-muted-foreground/70">
Loading settings…
</p>
{t("Loading settings…")}</p>
) : record === null ? (
<p className="px-6 py-5 text-xs text-muted-foreground/70">
Settings could not be loaded — refresh to retry.
</p>
{t("Settings could not be loaded — refresh to retry.")}</p>
) : (
<div>
{active === "general" && (
<SettingsSection
title="General"
title={t("General")}
testid="section-general"
>
<SettingsRow label={t("Interface language")} testid="row-language"><LanguageSelect field="language" /></SettingsRow>
<SettingsRow label={t("Speech recognition language")} testid="row-stt-language"><LanguageSelect field="sttLanguage" /></SettingsRow>
<SettingsRow
label="Auto-update"
label={t("Auto-update")}
hint={AUTO_UPDATE_HINT}
testid="row-auto-update"
>
<Toggle
checked={record.general?.autoUpdate ?? true}
label="auto update"
checked={general.autoUpdate ?? true}
label={t("auto update")}
onChange={(on) =>
void write("general", { autoUpdate: on })
}
@@ -378,13 +384,13 @@ export function SettingsDialogBody({
</SettingsSection>
)}
{active === "keys" && (
<SettingsSection title="Providers" testid="section-keys">
<SettingsSection title={t("Providers")} testid="section-keys">
<KeysSection initialRow={initialRow ?? undefined} />
</SettingsSection>
)}
{active === "agent" && (
<SettingsSection
title="Agent"
title={t("Agent")}
testid="section-agent"
>
<AgentSection
@@ -406,7 +412,7 @@ export function SettingsDialogBody({
)}
{active === "appearance" && (
<SettingsSection
title="Appearance"
title={t("Appearance")}
testid="section-appearance"
>
<AppearanceSection
@@ -419,7 +425,7 @@ export function SettingsDialogBody({
)}
{active === "graph" && (
<SettingsSection
title="Graph"
title={t("Graph")}
testid="section-graph"
>
<GraphSection
@@ -432,8 +438,8 @@ export function SettingsDialogBody({
)}
{active === "keyboard" && (
<SettingsSection
title="Keyboard"
hint="Stored on this device"
title={t("Keyboard")}
hint={t("Stored on this device")}
testid="section-keyboard"
>
<KeyboardSection />
@@ -1,4 +1,7 @@
"use client";
import { translator, type Translator } from "@/src/core";
import { useI18n } from "@/components/i18n";
import { createContext, useContext, useEffect, useRef, useState } from "react";
import { NATIVE_SUBSCRIPTIONS, type NativeSubscriptionProviderId, type NativeSubscriptionStatus } from "@/src/core";
import { Button } from "@/components/ui/button";
@@ -9,13 +12,14 @@ import { Toggle } from "./controls";
export const COPILOT_POLICY_NOTICE = "Pi's native Copilot sign-in may enable additional models whose policy is not yet configured. Models explicitly disabled by policy are not enabled. Organization restrictions still apply.";
export const SUBSCRIPTION_BACKGROUND_HINT = "Allow LearnHarness helper agents and automatic follow-ups to use this connection.";
export function subscriptionNotice(label: string): string { return `Your prompts, tool results, and relevant session context are sent to ${label} to run the work you select. Credentials are stored locally in an owner-only plaintext file. Provider terms, data-use policies, and plan limits apply. This connection never falls back to an API key.`; }
export function subscriptionDisconnectCopy(label: string): string { return `Disconnect ${label} from LearnHarness? This stops work using this connection and removes its saved credentials from this device. It does not cancel your subscription or revoke access at the provider. Saved model choices will remain unavailable until you reconnect or change them.`; }
export function subscriptionNotice(label: string, t: Translator = translator("en")): string { return t("Your prompts, tool results, and relevant session context are sent to {v0} to run the work you select. Credentials are stored locally in an owner-only plaintext file. Provider terms, data-use policies, and plan limits apply. This connection never falls back to an API key.", { v0: label }); }
export function subscriptionDisconnectCopy(label: string, t: Translator = translator("en")): string { return t("Disconnect {v0} from LearnHarness? This stops work using this connection and removes its saved credentials from this device. It does not cancel your subscription or revoke access at the provider. Saved model choices will remain unavailable until you reconnect or change them.", { v0: label }); }
export function emptySubscriptionStatus(providerId: NativeSubscriptionProviderId): NativeSubscriptionStatus { return { providerId, available: false, connected: false, pending: false, allowBackgroundUse: true, connectionId: null, epoch: 0, authorityRevision: 0, revision: 0 }; }
const Context = createContext({ statuses: [] as NativeSubscriptionStatus[], busy: false, open: (_providerId: NativeSubscriptionProviderId) => {} });
const CONTROL_ERROR = "The connection could not be changed. Try again.";
export function NativeSubscriptionUIProvider({ children }: { children: React.ReactNode }) {
const { t } = useI18n();
const [statuses, setStatuses] = useState<NativeSubscriptionStatus[]>([]);
const [dialog, setDialog] = useState<NativeSubscriptionProviderId | null>(null);
const [view, setView] = useState<"notice" | "connection" | "manage">("notice");
@@ -81,58 +85,60 @@ export function NativeSubscriptionUIProvider({ children }: { children: React.Rea
const bridge = typeof window === "undefined" ? undefined : window.lh?.subscriptions;
return <Context.Provider value={{ statuses, busy: !!operation, open }}>
{children}
{dialog && descriptor && status && <ConnectionDialog key={`${dialog}:${view}:${confirm}`} label={`${descriptor.label} connection`} testid="subscription-connection-dialog" backdropTestid="subscription-dialog-backdrop" busy={busy} onClose={close}>
{dialog && descriptor && status && <ConnectionDialog key={`${dialog}:${view}:${confirm}`} label={t("{v0} connection", { v0: descriptor.label })} testid="subscription-connection-dialog" backdropTestid="subscription-dialog-backdrop" busy={busy} onClose={close}>
<h2 className="text-sm font-semibold text-foreground">{descriptor.label}</h2>
{confirm ? <>
<p className="mt-3 text-sm font-semibold text-red-400">Disconnect {descriptor.label}?</p>
<p className="mt-3 text-xs leading-relaxed text-muted-foreground">{subscriptionDisconnectCopy(descriptor.label)}</p>
<p className="mt-3 text-sm font-semibold text-red-400">{t("Disconnect")}{" "}{descriptor.label}?</p>
<p className="mt-3 text-xs leading-relaxed text-muted-foreground">{subscriptionDisconnectCopy(descriptor.label, t)}</p>
</> : view === "notice" ? <>
<p className="mt-3 text-xs leading-relaxed text-muted-foreground" data-testid="subscription-privacy-notice">{subscriptionNotice(descriptor.label)}</p>
{dialog === "github-copilot" && <p className="mt-3 text-xs leading-relaxed text-muted-foreground" data-testid="copilot-policy-notice">{COPILOT_POLICY_NOTICE}</p>}
<p className="mt-3 text-xs leading-relaxed text-muted-foreground" data-testid="subscription-privacy-notice">{subscriptionNotice(descriptor.label, t)}</p>
{dialog === "github-copilot" && <p className="mt-3 text-xs leading-relaxed text-muted-foreground" data-testid="copilot-policy-notice">{t(COPILOT_POLICY_NOTICE)}</p>}
</> : <>
<div className="mt-4 flex items-center gap-3 text-xs text-foreground">
<Toggle label="Allow background use" checked={management ? status.allowBackgroundUse : background} disabled={busy || !!operation || status.pending || !status.available} onChange={(enabled) => { setBackground(enabled); if (management && bridge) void control(() => bridge.consent(dialog, enabled)); else setBackgroundDirty(true); }} />
<span>Allow background use<span className="mt-1 block leading-relaxed text-muted-foreground">{SUBSCRIPTION_BACKGROUND_HINT}</span></span>
<Toggle label={t("Allow background use")} checked={management ? status.allowBackgroundUse : background} disabled={busy || !!operation || status.pending || !status.available} onChange={(enabled) => { setBackground(enabled); if (management && bridge) void control(() => bridge.consent(dialog, enabled)); else setBackgroundDirty(true); }} />
<span>{t("Allow background use")}<span className="mt-1 block leading-relaxed text-muted-foreground">{t(SUBSCRIPTION_BACKGROUND_HINT)}</span></span>
</div>
{!status.available && <p className="mt-3 text-xs text-muted-foreground">Subscription sign-in requires the desktop app's private server connection. API-key connections remain available here.</p>}
{!status.available && <p className="mt-3 text-xs text-muted-foreground">{t("Subscription sign-in requires the desktop app's private server connection. API-key connections remain available here.")}</p>}
{view === "connection" && interaction && <div className="mt-3 space-y-3 text-xs" data-testid="subscription-login-interaction">
{interaction.progress && <p role="status" className="text-muted-foreground">{interaction.progress}</p>}
{interaction.userCode && <p>Device code: <code className="select-text font-mono">{interaction.userCode}</code></p>}
{interaction.browser && <Button variant="outline" size="sm" disabled={busy} onClick={() => bridge && void bridge.open(dialog, interaction.attemptId, interaction.browser!.id).catch(() => setError("The browser could not be opened. Try again."))}>Open browser</Button>}
{interaction.progress && <p role="status" className="text-muted-foreground">{t(interaction.progress)}</p>}
{interaction.userCode && <p>{t("Device code:")}{" "}<code className="select-text font-mono">{interaction.userCode}</code></p>}
{interaction.browser && <Button variant="outline" size="sm" disabled={busy} onClick={() => bridge && void bridge.open(dialog, interaction.attemptId, interaction.browser!.id).catch(() => setError("The browser could not be opened. Try again."))}>{t("Open browser")}</Button>}
{prompt && <form className="space-y-2" onSubmit={(event) => { event.preventDefault(); if (bridge) void bridge.respond(dialog, interaction.attemptId, prompt.id, promptValue).catch(() => setError(CONTROL_ERROR)); }}>
<label className="block" htmlFor="subscription-auth-prompt">{prompt.message}</label>
{prompt.type === "select" ? <select id="subscription-auth-prompt" value={promptValue} onChange={(event) => setPromptValue(event.target.value)} className="w-full rounded-sm border border-border bg-background px-2 py-1.5"><option value="" disabled>Select…</option>{prompt.options?.map((option) => <option key={option.id} value={option.id}>{option.label}</option>)}</select> : <input id="subscription-auth-prompt" type={prompt.type === "secret" ? "password" : "text"} autoComplete="off" value={promptValue} placeholder={prompt.placeholder} onChange={(event) => setPromptValue(event.target.value)} className="w-full rounded-sm border border-border bg-background px-2 py-1.5" />}
<Button type="submit" variant="outline" size="sm" disabled={busy || (prompt.type === "select" && !promptValue)}>Continue</Button>
<label className="block" htmlFor="subscription-auth-prompt">{t(prompt.message)}</label>
{prompt.type === "select" ? <select id="subscription-auth-prompt" value={promptValue} onChange={(event) => setPromptValue(event.target.value)} className="w-full rounded-sm border border-border bg-background px-2 py-1.5"><option value="" disabled>{t("Select…")}</option>{prompt.options?.map((option) => <option key={option.id} value={option.id}>{t(option.label)}</option>)}</select> : <input id="subscription-auth-prompt" type={prompt.type === "secret" ? "password" : "text"} autoComplete="off" value={promptValue} placeholder={prompt.placeholder ? t(prompt.placeholder) : undefined} onChange={(event) => setPromptValue(event.target.value)} className="w-full rounded-sm border border-border bg-background px-2 py-1.5" />}
<Button type="submit" variant="outline" size="sm" disabled={busy || (prompt.type === "select" && !promptValue)}>{t("Continue")}</Button>
</form>}
</div>}
</>}
{(error || status.error) && <p role="alert" className="mt-3 text-xs text-red-400">{error || status.error}</p>}
<ConnectionFooter testid="subscription-connection-footer" leading={!confirm && management && status.connectionId && !status.pending && <Button variant="ghost" size="sm" className="cursor-pointer text-red-400 hover:bg-red-500/10 hover:text-red-300" disabled={busy || !!operation} onClick={() => setConfirm(true)}>Disconnect</Button>}>
{(error || status.error) && <p role="alert" className="mt-3 text-xs text-red-400">{t(error || status.error || "")}</p>}
<ConnectionFooter testid="subscription-connection-footer" leading={!confirm && management && status.connectionId && !status.pending && <Button variant="ghost" size="sm" className="cursor-pointer text-red-400 hover:bg-red-500/10 hover:text-red-300" disabled={busy || !!operation} onClick={() => setConfirm(true)}>{t("Disconnect")}</Button>}>
{!confirm && management && status.connectionId && status.available && !status.connected && !!status.error && !status.pending && <>
{status.error && <Button variant="outline" size="sm" disabled={busy || !!operation} onClick={() => bridge && void control(() => bridge.retry(dialog))}>Retry</Button>}
<Button variant="outline" size="sm" disabled={!status.available || busy || !!operation} onClick={beginSignIn}>Sign in again</Button>
{status.error && <Button variant="outline" size="sm" disabled={busy || !!operation} onClick={() => bridge && void control(() => bridge.retry(dialog))}>{t("Retry")}</Button>}
<Button variant="outline" size="sm" disabled={!status.available || busy || !!operation} onClick={beginSignIn}>{t("Sign in again")}</Button>
</>}
<Button variant={!confirm && management ? "outline" : "ghost"} size="sm" disabled={busy} onClick={close}>{confirm || !management ? "Cancel" : "Close"}</Button>
{confirm ? <Button variant="destructive" size="sm" disabled={busy} onClick={() => bridge && void control(() => bridge.disconnect(dialog), true)}>Disconnect</Button> : view === "notice" ? <Button variant="outline" size="sm" disabled={!status.available || !!operation} onClick={() => setView("connection")}>Continue</Button> : view === "connection" && <Button variant="outline" size="sm" disabled={!status.available || !!operation || status.pending} onClick={() => void login()}>Connect</Button>}
<Button variant={!confirm && management ? "outline" : "ghost"} size="sm" disabled={busy} onClick={close}>{confirm || !management ? t("Cancel") : t("Close")}</Button>
{confirm ? <Button variant="destructive" size="sm" disabled={busy} onClick={() => bridge && void control(() => bridge.disconnect(dialog), true)}>{t("Disconnect")}</Button> : view === "notice" ? <Button variant="outline" size="sm" disabled={!status.available || !!operation} onClick={() => setView("connection")}>{t("Continue")}</Button> : view === "connection" && <Button variant="outline" size="sm" disabled={!status.available || !!operation || status.pending} onClick={() => void login()}>{t("Connect")}</Button>}
</ConnectionFooter>
</ConnectionDialog>}
</Context.Provider>;
}
export function NativeSubscriptionConnection({ providerId }: { providerId: NativeSubscriptionProviderId }) {
const { t } = useI18n();
const ui = useContext(Context), descriptor = NATIVE_SUBSCRIPTIONS.find((p) => p.providerId === providerId)!;
const status = ui.statuses.find((s) => s.providerId === providerId) ?? emptySubscriptionStatus(providerId);
return <div className="flex flex-col gap-2" data-testid={`${providerId}-connection`}>
<ConnectionRow label={descriptor.label} action={status.connectionId ? "Manage…" : "Connect"} disabled={ui.busy || !status.available || ui.statuses.some((s) => s.pending)} onAction={() => ui.open(providerId)} />
{status.error && <p role="alert" className="text-xs text-red-400">{status.error}</p>}
<ConnectionRow label={descriptor.label} action={status.connectionId ? t("Manage…") : t("Connect")} disabled={ui.busy || !status.available || ui.statuses.some((s) => s.pending)} onAction={() => ui.open(providerId)} />
{status.error && <p role="alert" className="text-xs text-red-400">{t(status.error)}</p>}
</div>;
}
export function SubscriptionConnections({ setup = false }: { setup?: boolean }) {
return <div className="space-y-3" data-testid="subscription-connections"><ChatGPTConnection setup={setup} />{NATIVE_SUBSCRIPTIONS.map((p) => <NativeSubscriptionConnection key={p.providerId} providerId={p.providerId} />)}</div>;
}
export function NativeSubscriptionBackgroundAccess({ provider }: { provider: string | undefined }) {
const { t } = useI18n();
const ui = useContext(Context), descriptor = NATIVE_SUBSCRIPTIONS.find((p) => p.logicalId === provider);
const status = ui.statuses.find((s) => s.providerId === descriptor?.providerId);
if (!descriptor || !status?.connected || status.allowBackgroundUse) return null;
return <Button type="button" size="xs" variant="ghost" disabled={ui.busy} onClick={() => ui.open(descriptor.providerId)}>Enable background use</Button>;
return <Button type="button" size="xs" variant="ghost" disabled={ui.busy} onClick={() => ui.open(descriptor.providerId)}>{t("Enable background use")}</Button>;
}
+4 -2
View File
@@ -1,4 +1,6 @@
"use client";
import { useI18n } from "@/components/i18n";
/**
* The zero-keys surfaces (zero-keys wave, 2026-09-25, owner-locked over
* Platform's 66dbea9 key-presence gate): when NO model-provider key
@@ -46,6 +48,7 @@ export function useZeroKeys(): boolean {
* chain's copy. testid "setup-cta-keys" (the models CTA keeps
* "setup-cta"; probes can tell them apart). */
export function ZeroKeysPill({ onOpen, inline = false }: { onOpen: () => void; inline?: boolean }) {
const { t } = useI18n();
const button = (
<button
type="button"
@@ -53,8 +56,7 @@ export function ZeroKeysPill({ onOpen, inline = false }: { onOpen: () => void; i
onClick={onOpen}
className="rounded-sm border border-border bg-card px-3 py-1.5 font-ui text-xs text-foreground transition-colors hover:border-muted-foreground/50 hover:bg-accent cursor-pointer"
>
Add a provider key
</button>
{t("Add a provider key")}</button>
);
return inline ? button : <div className="pointer-events-auto mb-2 flex justify-center">{button}</div>;
}
+28 -28
View File
@@ -1,4 +1,6 @@
"use client";
import { useI18n } from "@/components/i18n";
/**
* The first-run setup flow (model-slots wave, 2026-09-20, owner design
* "A — composer CTA + send interception"): chats cannot start until the
@@ -147,22 +149,22 @@ export function SetupKeysBody({
/** The MenuShell anchor — wired by the wrapper (its triggerRef). */
menuAnchor: React.RefObject<HTMLDivElement | null>;
}) {
const { t } = useI18n();
return (
<div className="flex flex-col gap-3 px-6 py-5" data-testid="setup-stage-keys">
<p className="text-xs text-muted-foreground">
Choose your AI provider and paste its API key. You’ll pick your models next.
</p>
{t("Choose your AI provider and paste its API key. You’ll pick your models next.")}</p>
<div className="flex flex-col gap-2">
<div ref={menuAnchor} className="relative max-w-96">
<button
type="button"
aria-label="Provider"
aria-label={t("Provider")}
data-testid="setup-key-provider"
aria-expanded={menuOpen}
onClick={onToggleMenu}
className="flex h-7 w-full items-center gap-1 rounded-sm border border-border bg-background px-1.5 font-ui text-xs text-foreground transition-colors hover:border-muted-foreground/50"
>
<span className="min-w-0 flex-1 truncate text-left">{provider ? provider.label : "Pick a provider…"}</span>
<span className="min-w-0 flex-1 truncate text-left">{provider ? provider.label : t("Pick a provider…")}</span>
<span aria-hidden className="text-muted-foreground">
▾
</span>
@@ -188,8 +190,8 @@ export function SetupKeysBody({
onKeyDown={(e) => {
if (e.key === "Enter" && value.trim()) onSave();
}}
placeholder="Paste key…"
aria-label={`${provider.label} key`}
placeholder={t("Paste key…")}
aria-label={t("{v0} key", { v0: provider.label })}
data-testid="setup-key-input"
disabled={saving}
autoFocus
@@ -202,17 +204,17 @@ export function SetupKeysBody({
data-testid="setup-key-save"
className="shrink-0 rounded-sm border border-border bg-background px-3 py-1.5 font-ui text-xs text-foreground transition-colors hover:border-muted-foreground/50 disabled:cursor-not-allowed disabled:opacity-40"
>
{saving ? "Saving…" : "Save key"}
{saving ? t("Saving…") : t("Save key")}
</button>
</div>
)}
{error && (
<p role="alert" data-testid="setup-key-error" className="text-xs text-red-400">
{error}
{t(error)}
</p>
)}
</div>
<div className="mt-2 border-t border-border/60 pt-4"><h3 className="mb-3 font-ui text-xs text-muted-foreground">Subscriptions</h3><SubscriptionConnections setup /></div>
<div className="mt-2 border-t border-border/60 pt-4"><h3 className="mb-3 font-ui text-xs text-muted-foreground">{t("Subscriptions")}</h3><SubscriptionConnections setup /></div>
</div>
);
}
@@ -228,6 +230,7 @@ export function SetupDialog({
onClose: () => void;
onDone: (models: ModelsSettings) => void;
}) {
const { t } = useI18n();
const backdropDismiss = useBackdropDismiss(onClose);
const [body, setBody] = useState<ModelsBody | null>(null);
const [failed, setFailed] = useState(false);
@@ -407,17 +410,17 @@ export function SetupDialog({
<div
role="dialog"
aria-modal="true"
aria-label="Set up your models"
aria-label={t("Set up your models")}
data-testid="setup-dialog"
className="settings-dialog-pop flex max-h-[85vh] w-[640px] max-w-[90vw] flex-col overflow-hidden rounded-md border border-border bg-card shadow-xl"
onClick={(e) => e.stopPropagation()}
>
<header className="flex h-15 shrink-0 items-center gap-3 border-b border-border px-5">
<h2 className="font-title text-2xl tracking-tight">Set up your models</h2>
<Hint label="Close" binding="escape" side="left">
<h2 className="font-title text-2xl tracking-tight">{t("Set up your models")}</h2>
<Hint label={t("Close")} binding="escape" side="left">
<button
type="button"
aria-label="Close setup"
aria-label={t("Close setup")}
data-testid="setup-close"
onClick={onClose}
className="ml-auto text-muted-foreground transition-colors hover:text-foreground"
@@ -431,15 +434,14 @@ export function SetupDialog({
<div className="flex flex-col px-6 py-5">
{failed ? (
<p className="text-sm text-foreground">
Model catalog could not be loaded. Check the connection and retry.
</p>
{t("Model catalog could not be loaded. Check the connection and retry.")}</p>
) : (
<SetupKeysBody
providers={keyProviders}
provider={keyProvider}
value={keyValue}
saving={keySaving}
error={keyError}
error={t(keyError)}
menuOpen={keyMenuOpen}
menuAnchor={keyTriggerRef}
onToggleMenu={() => setKeyMenuOpen((v) => !v)}
@@ -456,8 +458,7 @@ export function SetupDialog({
) : (
<div className="flex flex-col gap-4 overflow-y-auto px-6 py-5" data-testid="setup-stage-pickers">
<p className="text-xs text-muted-foreground">
Pick the model for each tier. All three are required before chats can start.
</p>
{t("Pick the model for each tier. All three are required before chats can start.")}</p>
{SLOT_ORDER.map((slot) => {
const entry = picked[slot];
const level = levels[slot];
@@ -467,29 +468,28 @@ export function SetupDialog({
return (
<div key={slot} data-testid={`setup-slot-${slot}`} className="flex flex-col gap-1.5">
<span className="flex items-center gap-1.5">
<span className="font-ui text-sm text-foreground">{SLOT_META[slot].label}</span>
<span className="font-ui text-sm text-foreground">{t(SLOT_META[slot].label)}</span>
{!body?.slots?.[slot]?.model && !picked[slot] && (
<span
data-testid={`setup-needed-${slot}`}
className="rounded-sm border border-border px-1 py-0.5 text-[10px] font-normal tracking-wide text-muted-foreground"
>
needed
</span>
{t("needed")}</span>
)}
</span>
<span className="text-xs text-muted-foreground/70">{SLOT_META[slot].roster}</span>
<span className="text-xs text-muted-foreground/70">{t(SLOT_META[slot].roster)}</span>
{slot !== "strong" && <><ChatGPTBackgroundAccess provider={entry?.provider} /><NativeSubscriptionBackgroundAccess provider={entry?.provider} /></>}
<div className="flex items-center gap-2">
<div className="relative flex-1">
<button
type="button"
aria-label={`${SLOT_META[slot].label} model`}
aria-label={t("{v0} model", { v0: SLOT_META[slot].label })}
data-testid={`setup-trigger-${slot}`}
aria-expanded={openMenu?.slot === slot && openMenu.kind === "model"}
onClick={() => setOpenMenu(openMenu?.slot === slot && openMenu.kind === "model" ? null : { slot, kind: "model" })}
className="flex h-7 min-w-40 w-full items-center gap-1 rounded-sm border border-border bg-background px-1.5 font-ui text-xs text-foreground transition-colors hover:border-muted-foreground/50"
>
<span className="min-w-0 flex-1 truncate">{entry ? prettyModel(entry.id) : "Pick a model…"}</span>
<span className="min-w-0 flex-1 truncate">{entry ? prettyModel(entry.id) : t("Pick a model…")}</span>
<span aria-hidden className="text-muted-foreground">
▾
</span>
@@ -527,7 +527,7 @@ export function SetupDialog({
}));
setOpenMenu(null);
}}
footer={catalog && catalog.length === 0 ? "No image-capable models. Add a provider key with access to a vision model." : undefined}
footer={catalog && catalog.length === 0 ? t("No image-capable models. Add a provider key with access to a vision model.") : undefined}
/>
</MenuShell>
)}
@@ -537,7 +537,7 @@ export function SetupDialog({
<div className="relative">
<button
type="button"
aria-label={`${SLOT_META[slot].label} thinking`}
aria-label={t("{v0} thinking", { v0: SLOT_META[slot].label })}
data-testid={`setup-thinking-${slot}`}
aria-expanded={openMenu?.slot === slot && openMenu.kind === "thinking"}
disabled={!entry}
@@ -582,9 +582,9 @@ export function SetupDialog({
onClick={() => void save()}
className="rounded-sm border border-border bg-background px-3 py-1.5 font-ui text-xs text-foreground transition-colors hover:border-muted-foreground/50 disabled:cursor-not-allowed disabled:opacity-40"
>
{saving ? "Saving…" : "Save models"}
{saving ? t("Saving…") : t("Save models")}
</button>
{error && <p className="text-xs text-red-400">{error}</p>}
{error && <p className="text-xs text-red-400">{t(error)}</p>}
</div>
</div>
)}
+46 -41
View File
@@ -1,4 +1,7 @@
"use client";
import { translator, type Language } from "@/src/core";
import { useI18n } from "@/components/i18n";
import { createContext, useCallback, useContext, useEffect, useMemo, useRef, useState } from "react";
import Link from "next/link";
@@ -66,6 +69,7 @@ declare global {
/** The zoom truth (owner round 7): subscribes to "lh:zoom" pushes; returns unsubscribe. */
onZoom?: (cb: (factor: number) => void) => () => void;
getZoom?: () => Promise<number>;
setLanguage?: (language: "en" | "ru") => void;
/** Auto-update channel (Wave C, 2026-10-10): "lh:update-status" pushes once a staged update exists. */
onUpdateStatus?: (cb: (status: { status: string; version: string | null }) => void) => () => void;
installUpdate?: () => void;
@@ -188,6 +192,7 @@ export function SidebarProvider({ children }: { children: React.ReactNode }) {
* The icon fades with the slot and is clipped by it as it collapses —
* no popping in either direction. */
export function SidebarToggle() {
const { t } = useI18n();
const { open, toggle } = useSidebar();
return (
<span
@@ -196,11 +201,11 @@ export function SidebarToggle() {
open ? "w-0 -mr-3" : "w-9 mr-0 lh-closed-toggle",
)}
>
<Hint label="Open sidebar" action="toggle-sidebar">
<Hint label={t("Open sidebar")} action="toggle-sidebar">
<Button
variant="ghost"
size="icon"
aria-label="Open sidebar"
aria-label={t("Open sidebar")}
aria-hidden={open}
tabIndex={open ? -1 : 0}
onClick={toggle}
@@ -239,16 +244,18 @@ function SessionStatus({ busy }: { busy: boolean }) {
* in any locale and it sorts (owner, typography round 5; the old
* M/D/YYYY was the US convention. A user-locale setting can come later
* — trivial, a format function swap). Pure — exported for tests. */
export function timeAgo(iso: string, now = new Date()): string {
export function timeAgo(iso: string, now = new Date(), language: Language = "en"): string {
const t = translator(language);
const ms = now.getTime() - new Date(iso).getTime();
if (ms < 60_000) return "just now";
if (ms < 60_000) return t("just now");
const min = Math.floor(ms / 60_000);
if (min < 60) return `${min}m ago`;
if (min < 60) return t("{v0}m ago", { v0: min });
const h = Math.floor(min / 60);
if (h < 24) return `${h}h ago`;
if (h < 24) return t("{v0}h ago", { v0: h });
const d = Math.floor(h / 24);
if (d < 7) return `${d}d ago`;
if (d < 7) return t("{v0}d ago", { v0: d });
const date = new Date(iso);
if (language === "ru") return new Intl.DateTimeFormat("ru-RU").format(date);
const mm = String(date.getMonth() + 1).padStart(2, "0");
const dd = String(date.getDate()).padStart(2, "0");
return `${date.getFullYear()}-${mm}-${dd}`;
@@ -259,14 +266,15 @@ export function timeAgo(iso: string, now = new Date()): string {
* typography round 5): it informed no decision — clutter. messageCount
* still rides the API payload, so this is a one-line flip back if it's
* ever wanted. */
function sessionMeta(chat: ChatListItem): string {
if (chat.compacting) return `Compacting context · ${timeAgo(chat.updatedAt)}`;
if (chat.pendingQuestion) return `Waiting for input · ${timeAgo(chat.updatedAt)}`;
function sessionMeta(chat: ChatListItem, language: Language = "en"): string {
const t = translator(language);
if (chat.compacting) return t("Compacting context · {v0}", { v0: timeAgo(chat.updatedAt, undefined, language) });
if (chat.pendingQuestion) return t("Waiting for input · {v0}", { v0: timeAgo(chat.updatedAt, undefined, language) });
if ((chat.kind ?? "teach") === "practice" && chat.pendingProblem) {
return `Problem waiting · ${timeAgo(chat.updatedAt)}`;
return t("Problem waiting · {v0}", { v0: timeAgo(chat.updatedAt, undefined, language) });
}
if (chat.busy) return "Working…";
return timeAgo(chat.updatedAt);
if (chat.busy) return t("Working…");
return timeAgo(chat.updatedAt, undefined, language);
}
/**
@@ -290,6 +298,7 @@ export function SessionRow({
/** Right-click opens the context menu (wave 6: Rename / Delete). */
onContextMenu?: (e: React.MouseEvent<HTMLButtonElement>) => void;
}) {
const { t, language } = useI18n();
return (
<button
type="button"
@@ -310,13 +319,13 @@ export function SessionRow({
rule, finally applied to the picker too (owner, typography
round): mono = chrome, Source Sans = human words. */}
<span className="block truncate font-prose text-(length:--lh-prose-size)">{chat.title}</span>
<span className="block text-xs text-muted-foreground">{sessionMeta(chat)}</span>
<span className="block text-xs text-muted-foreground">{sessionMeta(chat, language)}</span>
</span>
{/* The dumbbell rides the FAR RIGHT (owner round 6, 2026-10-05) —
was left of the name; right-aligned keeps the titles flush with
the teaching rows and reads cleaner. */}
{(chat.kind ?? "teach") === "practice" && (
<DumbbellIcon className="size-3.5 shrink-0 text-muted-foreground/70" aria-label="Practice session" />
<DumbbellIcon className="size-3.5 shrink-0 text-muted-foreground/70" aria-label={t("Practice session")} />
)}
</button>
);
@@ -327,6 +336,7 @@ export function SessionRow({
* "replace", the header rename's rule), Enter/blur commits, Esc discards.
* The meta line stays so the row never changes height mid-edit. */
export function RenameRow({ chat, onCommit, onCancel }: { chat: ChatListItem; onCommit: (next: string) => void; onCancel: () => void }) {
const { t, language } = useI18n();
const [draft, setDraft] = useState(chat.title);
const inputRef = useRef<HTMLInputElement | null>(null);
useEffect(() => {
@@ -362,11 +372,11 @@ export function RenameRow({ chat, onCommit, onCancel }: { chat: ChatListItem; on
onCancel();
}
}}
aria-label="Rename session"
aria-label={t("Rename session")}
data-testid="session-rename-input"
className="lh-input w-full min-w-0 truncate border-0 border-b border-border bg-transparent px-0 text-foreground outline-none"
/>
<span className="block truncate text-xs text-muted-foreground">{sessionMeta(chat)}</span>
<span className="block truncate text-xs text-muted-foreground">{sessionMeta(chat, language)}</span>
</span>
</div>
);
@@ -379,6 +389,7 @@ export type SessionKindFilter = "teach" | "all" | "practice";
const KIND_FILTER_KEY = "lh:session-kind-filter";
function SessionsSidebar() {
const { t } = useI18n();
const { open, toggle } = useSidebar();
const [chats, setChats] = useState<ChatListItem[] | null>(null);
const [query, setQuery] = useState("");
@@ -656,23 +667,23 @@ function SessionsSidebar() {
// of the logo, carrying its real shortcut: ⌘G jumps to
// the graph view (keyboard-shortcuts routes it to "/",
// the same destination as this logo).
label="Home"
label={t("Home")}
action="jump-graph"
side="right"
>
<Link
href="/"
aria-label="Home"
aria-label={t("Home")}
className="lh-no-drag flex size-9 shrink-0 items-center justify-center rounded-md transition-colors hover:bg-accent hover:text-accent-foreground dark:hover:bg-accent/50"
>
<LhMark className="size-6" />
</Link>
</Hint>
<Hint label="Close sidebar" action="toggle-sidebar">
<Hint label={t("Close sidebar")} action="toggle-sidebar">
<Button
variant="ghost"
size="icon"
aria-label="Close sidebar"
aria-label={t("Close sidebar")}
onClick={toggle}
className="lh-no-drag"
>
@@ -693,8 +704,7 @@ function SessionsSidebar() {
className="flex w-full items-center gap-1.5 rounded-md px-2 py-1.5 text-xs uppercase tracking-wider text-muted-foreground transition-colors hover:bg-accent hover:text-foreground"
>
<PlusIcon className="size-3" />
Teaching
<span className="sr-only">create a new teaching session</span>
{t("Teaching")}<span className="sr-only">{t("create a new teaching session")}</span>
</button>
<button
type="button"
@@ -703,14 +713,13 @@ function SessionsSidebar() {
className="flex w-full items-center gap-1.5 rounded-md px-2 py-1.5 text-xs uppercase tracking-wider text-muted-foreground transition-colors hover:bg-accent hover:text-foreground"
>
<DumbbellIcon className="size-3" />
Practice
<span className="sr-only">create a new practice session</span>
{t("Practice")}<span className="sr-only">{t("create a new practice session")}</span>
</button>
</div>
<div className="mx-3 mb-2 flex items-center gap-2">
<span className="text-xs uppercase tracking-widest text-muted-foreground/70">Sessions</span>
<Hint label="Search sessions" action="sessions-search" side="bottom">
<span className="text-xs uppercase tracking-widest text-muted-foreground/70">{t("Sessions")}</span>
<Hint label={t("Search sessions")} action="sessions-search" side="bottom">
<span className="relative flex-1">
<Input
ref={searchRef}
@@ -760,8 +769,8 @@ function SessionsSidebar() {
e.currentTarget.blur();
}
}}
placeholder="Search…"
aria-label="Search sessions"
placeholder={t("Search…")}
aria-label={t("Search sessions")}
className="h-6 rounded-sm border-border bg-background pl-6 text-xs placeholder:text-muted-foreground/60"
/>
<SearchIcon className="pointer-events-none absolute left-1.5 top-1/2 size-3 -translate-y-1/2 text-muted-foreground/70" />
@@ -790,17 +799,17 @@ function SessionsSidebar() {
: "text-muted-foreground hover:text-foreground",
)}
>
{f === "teach" ? "Teaching" : f === "all" ? "All" : "Practice"}
{f === "teach" ? t("Teaching") : f === "all" ? t("All") : t("Practice")}
</button>
))}
</div>
<div ref={listRef} className="flex-1 overflow-y-auto p-2">
{shown === null ? (
<div className="px-2 py-4 text-xs text-muted-foreground">Loading…</div>
<div className="px-2 py-4 text-xs text-muted-foreground">{t("Loading…")}</div>
) : shown.length === 0 ? (
<div className="px-2 py-4 text-xs text-muted-foreground">
{query.trim() ? "No sessions match." : "No sessions yet."}
{query.trim() ? t("No sessions match.") : t("No sessions yet.")}
</div>
) : (
shown.map((c, i) =>
@@ -839,7 +848,7 @@ function SessionsSidebar() {
New session — quiet, bottom-pinned, opens the centered
dialog (also mod+, in the keyboard registry). */}
<div className="px-3 pb-3 pt-1">
<Hint label="Settings" action="open-settings" side="right">
<Hint label={t("Settings")} action="open-settings" side="right">
<button
type="button"
onClick={() => openSettings()}
@@ -847,8 +856,7 @@ function SessionsSidebar() {
className="flex items-center gap-1.5 rounded-md px-2 py-1.5 text-xs uppercase tracking-wider text-muted-foreground transition-colors hover:bg-accent hover:text-foreground"
>
<SettingsIcon className="size-3" />
Settings
</button>
{t("Settings")}</button>
</Hint>
</div>
</div>
@@ -879,8 +887,7 @@ function SessionsSidebar() {
}}
className="flex items-center rounded-lg px-2.5 py-1.5 text-left text-foreground transition-colors hover:bg-accent"
>
Rename
</button>
{t("Rename")}</button>
{/* Export as Markdown (owner, release polish 2026-10-06): the
session-markdown engine's user-facing debut — same-origin
anchor download; the route's Content-Disposition names the
@@ -901,8 +908,7 @@ function SessionsSidebar() {
}}
className="flex items-center rounded-lg px-2.5 py-1.5 text-left text-foreground transition-colors hover:bg-accent"
>
Export as Markdown…
</button>
{t("Export as Markdown…")}</button>
<button
type="button"
data-testid="ctx-delete"
@@ -913,8 +919,7 @@ function SessionsSidebar() {
}}
className="flex items-center rounded-lg px-2.5 py-1.5 text-left text-muted-foreground/80 transition-colors hover:bg-accent hover:text-foreground"
>
Delete…
</button>
{t("Delete…")}</button>
<div className="my-0.5 border-t border-border/60" aria-hidden="true" />
<CoverageMenuBlock chatId={ctxChatId} />
</MenuShell>
+17 -16
View File
@@ -1,13 +1,15 @@
"use client";
import { useI18n } from "@/components/i18n";
import { useEffect, useMemo, useRef, useState, type ReactNode } from "react";
import { subscriptionLabels, subscriptionLabel, type SessionPricing, type TurnUsage } from "@/src/core";
import { subscriptionLabels, subscriptionLabel, type Language, type SessionPricing, type TurnUsage } from "@/src/core";
import { formatTokens, formatUsd, planCostLabel, prettyModel } from "@/components/usage-format";
import { ModelControl, catalogLimits, useModelCatalog } from "@/components/model-picker";
export function formatContextMeasurement(tokens: number | null, limit: number | null): string {
const numerator = tokens === null ? "?" : formatTokens(tokens);
return limit === null ? numerator : `${numerator} / ${formatTokens(limit)}`;
export function formatContextMeasurement(tokens: number | null, limit: number | null, language: Language = "en"): string {
const numerator = tokens === null ? "?" : formatTokens(tokens, language);
return limit === null ? numerator : `${numerator} / ${formatTokens(limit, language)}`;
}
/**
@@ -53,6 +55,7 @@ export function StatsBar({
* data (which renders nothing pre-chat on that surface). */
leadingSlot?: ReactNode;
}) {
const { t, language } = useI18n();
const [open, setOpen] = useState(false);
const [selectedModel, setSelectedModel] = useState<string | undefined>();
// Same dropdown discipline as the model control (owner, df4433f treatment,
@@ -84,13 +87,13 @@ export function StatsBar({
const { catalog } = useModelCatalog();
const limits = useMemo(() => (catalog ? catalogLimits(catalog) : undefined), [catalog]);
const limit = selectedModel ? limits?.get(selectedModel) ?? null : null;
const contextLabel = formatContextMeasurement(contextTokens, limit);
const contextLabel = formatContextMeasurement(contextTokens, limit, language);
const { cost = 0, unpriced = turns.length, subscription = 0 } = pricing ?? {};
// Honest cost: show dollars only when at least one turn priced;
// otherwise "—" (never a fake $0.0000).
const pricedCount = turns.length - unpriced - subscription;
const providers = pricing?.subscriptionProviders ?? subscriptionLabels(turns.map((turn) => turn.model));
const costLabel = planCostLabel(pricedCount > 0 ? cost : null, providers);
const costLabel = planCostLabel(pricedCount > 0 ? cost : null, providers, (value) => formatUsd(value, language));
return (
<div
@@ -135,23 +138,22 @@ export function StatsBar({
className="absolute bottom-full left-1/2 z-40 mb-1.5 w-72 -translate-x-1/2 rounded-xl border bg-card p-3 text-xs shadow-xl"
>
<div className="mb-2 text-xs font-medium uppercase tracking-wide text-muted-foreground">
Session stats
</div>
{t("Session stats")}</div>
<div className="flex flex-col gap-1 text-muted-foreground">
<div className="flex justify-between gap-2">
<span>Context (last call)</span>
<span>{t("Context (last call)")}</span>
<span className="tabular-nums text-card-foreground">
{contextLabel}
</span>
</div>
<div className="flex justify-between gap-2">
<span>{subscription ? "Usage" : "Total cost"}</span>
<span>{subscription ? t("Usage") : t("Total cost")}</span>
<span className="tabular-nums text-card-foreground">{costLabel}</span>
</div>
{providers.includes("ChatGPT") && <a href="https://chatgpt.com/settings/usage" target="_blank" rel="noreferrer" className="underline underline-offset-2">Manage ChatGPT usage</a>}
{providers.includes("ChatGPT") && <a href="https://chatgpt.com/settings/usage" target="_blank" rel="noreferrer" className="underline underline-offset-2">{t("Manage ChatGPT usage")}</a>}
{unpriced > 0 && (
<div className="flex justify-between gap-2">
<span>Unpriced turns</span>
<span>{t("Unpriced turns")}</span>
<span className="tabular-nums text-card-foreground">{unpriced}</span>
</div>
)}
@@ -159,8 +161,7 @@ export function StatsBar({
{turns.length > 0 && (
<>
<div className="mb-1 mt-2.5 border-t pt-2 text-xs font-medium uppercase tracking-wide text-muted-foreground">
Turns
</div>
{t("Turns")}</div>
<ul className="flex max-h-56 flex-col gap-1 overflow-y-auto">
{turns.map((turn, i) => {
const c = pricing?.turnCosts[turn.id] ?? null;
@@ -170,8 +171,8 @@ export function StatsBar({
#{i + 1} · {prettyModel(turn.model, true)}
</span>
<span className="flex shrink-0 items-center gap-2 tabular-nums text-card-foreground">
<span className="text-muted-foreground">{turn.steps.length} steps</span>
<span>{subscriptionLabel(turn.model) ?? (c === null ? "—" : formatUsd(c))}</span>
<span className="text-muted-foreground">{turn.steps.length}{" "}{t("steps")}</span>
<span>{subscriptionLabel(turn.model) ?? (c === null ? "—" : formatUsd(c, language))}</span>
</span>
</li>
);
+4 -1
View File
@@ -1,4 +1,6 @@
"use client";
import { useI18n } from "@/components/i18n";
import { useEffect, useRef, useState } from "react";
import { ChevronDownIcon } from "lucide-react";
@@ -83,6 +85,7 @@ export function ThinkingBlock({
/** On the card's own background: no band (the accent read too light). */
inCard?: boolean;
}) {
const { t } = useI18n();
const [open, setOpen] = useState(() => resolveUserThinkingOpen(defaultOpen));
// ⌘T: an explicit global set overrides every row (once set, rows re-sync).
const syncedRef = useRef(false);
@@ -123,7 +126,7 @@ export function ThinkingBlock({
aria-expanded={open}
className="flex w-full items-center gap-2 font-medium uppercase tracking-wide text-muted-foreground transition-colors hover:text-foreground"
>
{label}
{t(label)}
<ChevronDownIcon className={cn("size-4 shrink-0 transition-transform", open ? "rotate-180" : "rotate-0")} />
</button>
{open && (
+4 -1
View File
@@ -1,5 +1,7 @@
"use client"
import { useI18n } from "@/components/i18n";
import * as React from "react"
import { Tooltip, TooltipProvider, TooltipTrigger, TooltipContent } from "@/components/ui/tooltip"
import { bindingForAction, formatBinding, type KeyAction } from "@/src/core"
@@ -44,11 +46,12 @@ export function hintText(label: string, action?: KeyAction, binding?: string): s
}
export function Hint({ label, action, binding, side = "top", children }: HintProps) {
const { t } = useI18n();
return (
<TooltipProvider>
<Tooltip>
<TooltipTrigger asChild>{children}</TooltipTrigger>
<TooltipContent side={side}>{hintText(label, action, binding)}</TooltipContent>
<TooltipContent side={side}>{hintText(t(label), action, binding)}</TooltipContent>
</Tooltip>
</TooltipProvider>
)
+6 -4
View File
@@ -1,4 +1,6 @@
"use client";
import { useI18n } from "@/components/i18n";
/**
* The auto-update banner (Wave C, owner 2026-10-10: silent download +
@@ -19,6 +21,7 @@ import { useEffect, useState } from "react";
type UpdateStatus = { status: string; version: string | null };
export default function UpdateBanner() {
const { t } = useI18n();
const [ready, setReady] = useState<UpdateStatus | null>(null);
useEffect(() => {
@@ -35,16 +38,15 @@ export default function UpdateBanner() {
className="fixed right-4 bottom-4 z-50 flex items-center gap-3 rounded-lg border border-border bg-popover px-4 py-3 shadow-lg"
>
<div className="text-sm">
<span className="font-medium">LearnHarness {ready.version ?? "update"} is ready</span>
<span className="text-muted-foreground"> — restart to finish installing.</span>
<span className="font-medium">LearnHarness {ready.version ?? "update"}{" "}{t("is ready")}</span>
<span className="text-muted-foreground"> {" "}{t("— restart to finish installing.")}</span>
</div>
<button
type="button"
onClick={() => window.lh?.installUpdate?.()}
className="rounded-md bg-accent px-3 py-1.5 text-sm font-medium transition-colors hover:bg-accent/80"
>
Restart
</button>
{t("Restart")}</button>
</div>
);
}
+5 -2
View File
@@ -1,10 +1,12 @@
import { localeTag, type Language } from "@/src/core";
/**
* Money/token presentation helpers (wave-4 observability UI) — shared by the
* activity dock and the stats bar. Pure; exported for tests.
*/
/** USD for cent-scale money: >= $1 keeps two decimals, smaller keeps four. */
export function formatUsd(c: number): string {
export function formatUsd(c: number, language: Language = "en"): string {
if (language === "ru") return `${new Intl.NumberFormat(localeTag(language), { minimumFractionDigits: c >= 1 ? 2 : 4, maximumFractionDigits: c >= 1 ? 2 : 4 }).format(c)} $`;
return c >= 1 ? `$${c.toFixed(2)}` : `$${c.toFixed(4)}`;
}
@@ -14,7 +16,8 @@ export function planCostLabel(dollars: number | null, subscription: boolean | st
}
/** 38000 → "38k", 1234567 → "1.2M" — trimmed, never "38.0k". */
export function formatTokens(n: number): string {
export function formatTokens(n: number, language: Language = "en"): string {
if (language === "ru") return new Intl.NumberFormat("ru-RU", { notation: "compact", maximumFractionDigits: 1 }).format(n);
if (n >= 1e6) return `${+(n / 1e6).toFixed(1)}M`;
if (n >= 1e3) return `${+(n / 1e3).toFixed(1)}k`;
return String(n);