Compare commits

...
15 Commits
Author SHA1 Message Date
sorokin.oleg.edu d43ffe4f8a chore: prepare 0.6.0 Linux and Windows release 2026-10-26 23:43:56 +03:00
sorokin.oleg.edu ecbd2b0bb9 feat: add OpenRouter dictation and speech recognition settings 2026-10-26 23:42:44 +03:00
sorokin.oleg.edu 168cad7470 chore: prepare 0.5.2 Linux and Windows release 2026-10-26 02:14:27 +03:00
sorokin.oleg.edu ce28d95bcf feat: add preferred STT languages and fix model settings layout 2026-10-26 02:14:27 +03:00
sorokin.oleg.edu 70977bad84 chore: synchronize 0.5.1 lockfile and release examples 2026-10-26 00:58:43 +03:00
sorokin.oleg.edu fdd63b27a9 chore: prepare 0.5.1 Linux and Windows release 2026-10-26 00:58:26 +03:00
sorokin.oleg.edu bdd7d5e866 feat: add multilingual dictation and simplify chat STT picker 2026-10-26 00:58:25 +03:00
sorokin.oleg.edu 1c6ae6b2e2 test: await completed sign-in in account lifecycle checks 2026-10-25 23:04:31 +03:00
sorokin.oleg.edu d2d9fcd09e chore: prepare 0.5.0 Linux and Windows release 2026-10-25 23:00:45 +03:00
sorokin.oleg.edu a65867fc39 fix: align quiz mechanics across panel widths 2026-10-25 21:40:02 +03:00
sorokin.oleg.edu 8c564946ef feat: refine language controls and add per-tier ChatGPT Fast mode 2026-10-25 21:19:48 +03:00
sorokin.oleg.edu f8ce0f21ef docs: document verified Windows 0.4.0 release 2026-10-25 18:44:21 +03:00
sorokin.oleg.edu 2da091d6e9 fix: replace inherited updater provider in Gitea packages 2026-10-25 17:41:43 +03:00
sorokin.oleg.edu 674d4bd8c6 chore: prepare 0.4.0 Linux release on Gitea 2026-10-25 17:33:37 +03:00
sorokin.oleg.edu a50ca4b867 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
2026-10-25 17:18:44 +03:00
126 changed files with 5615 additions and 972 deletions
+59
View File
@@ -0,0 +1,59 @@
name: Linux desktop release
# Manual until a native Linux runner is registered. The current 0.6.0
# packages are built locally using the identical gates documented below.
on:
workflow_dispatch:
inputs:
tag:
description: Existing stable version tag to package (for example v0.6.0)
required: true
type: string
permissions:
contents: read
releases: write
concurrency:
group: gitea-release-${{ inputs.tag }}
cancel-in-progress: false
jobs:
linux:
runs-on: ubuntu-24.04
timeout-minutes: 60
env:
NEXT_TELEMETRY_DISABLED: "1"
steps:
- uses: https://github.com/actions/checkout@v7
with:
ref: ${{ inputs.tag }}
persist-credentials: false
- uses: https://github.com/actions/setup-node@v7
with:
node-version: "26"
cache: npm
- name: Tag must match package.json
env:
RELEASE_TAG: ${{ inputs.tag }}
run: node --input-type=module -e 'import fs from "node:fs"; if (process.env.RELEASE_TAG !== `v${JSON.parse(fs.readFileSync("package.json")).version}`) process.exit(1)'
- run: npm ci
- run: npm run typecheck
- run: npm test
- run: npm run lint
- name: Native PDF and SVG renderers
run: npx electron --import tsx --test tests/pdf-native-render.test.ts tests/pdf-ops.test.ts tests/svg-native-render.test.ts tests/browser-service.test.ts
env:
NODE_ENV: test
ELECTRON_RUN_AS_NODE: "1"
- name: Build, smoke, package, and verify Linux installers
run: npm run desktop:release:linux
- name: Verify the packaged provider and demo runtimes
run: |
node scripts/smoke-provider-runtime.mjs --standalone dist/linux-unpacked/resources/server/standalone
node scripts/smoke-provider-runtime.mjs --standalone dist/linux-unpacked/resources/server/standalone --runtime dist/linux-unpacked/learnharness
xvfb-run -a node scripts/smoke-demo-runtime.mjs --standalone dist/linux-unpacked/resources/server/standalone --runtime dist/linux-unpacked/learnharness --browser
- name: Upload and verify the draft (never publish)
run: node scripts/gitea-release.mjs upload dist
env:
GITEA_TOKEN: ${{ secrets.GITEA_TOKEN }}
+2 -1
View File
@@ -4,7 +4,8 @@ data/
*.log *.log
.DS_Store .DS_Store
*.tsbuildinfo *.tsbuildinfo
.env.local .env
.env.*
.memory/ .memory/
SCOPE.md SCOPE.md
# raw real-data import sources (user directive 2026-09-13: never commit) # raw real-data import sources (user directive 2026-09-13: never commit)
+1 -1
View File
@@ -48,7 +48,7 @@ Future teaching sessions can read the graph instead of starting from scratch. Pr
- **Windows (x64):** run the installer. It is currently unsigned; Windows may show an “unknown publisher” or SmartScreen warning. - **Windows (x64):** run the installer. It is currently unsigned; Windows may show an “unknown publisher” or SmartScreen warning.
- **Linux (x64):** make the AppImage executable and open it, or install the Debian/Ubuntu `.deb`. - **Linux (x64):** make the AppImage executable and open it, or install the Debian/Ubuntu `.deb`.
Add your provider API key and choose your models, then start a session with what you want to understand. LearnHarness supports 30+ provider options, including OpenAI, Anthropic, Google, DeepSeek, and OpenRouter. Optional dictation and web search use separate service keys. Add your provider API key and choose your models, then start a session with what you want to understand. LearnHarness supports 30+ provider options, including OpenAI, Anthropic, Google, DeepSeek, and OpenRouter. Optional web search uses a separate service key. Dictation supports direct Deepgram Nova-3 streaming or MAI-Transcribe-2 using your OpenRouter API key; choose the speech recognition model in Settings → Speech recognition.
All three platforms support in-app updates. All three platforms support in-app updates.
+4 -4
View File
@@ -11,14 +11,14 @@
* markers are invisible by the engine's law). * markers are invisible by the engine's law).
*/ */
import { sessionMarkdown } from "@/src/core"; import { sessionMarkdown } from "@/src/core";
import { getChat } from "@/src/server"; import { getChat, applicationLanguage } from "@/src/server";
/** Filename-safe title (own module scope on purpose: tiny + pure). */ /** Filename-safe title (own module scope on purpose: tiny + pure). */
function slugify(title: string): string { function slugify(title: string): string {
const slug = title const slug = title
.toLowerCase() .toLowerCase()
.normalize("NFKD") .normalize("NFKD")
.replace(/[^a-z0-9]+/g, "-") .replace(/[^\p{L}\p{N}]+/gu, "-")
.replace(/^-+|-+$/g, ""); .replace(/^-+|-+$/g, "");
return slug.length > 0 ? slug : "session"; return slug.length > 0 ? slug : "session";
} }
@@ -27,11 +27,11 @@ export async function GET(_req: Request, ctx: { params: Promise<{ id: string }>
const { id } = await ctx.params; const { id } = await ctx.params;
const record = getChat(id); const record = getChat(id);
if (!record) return Response.json({ error: "Unknown chat" }, { status: 404 }); if (!record) return Response.json({ error: "Unknown chat" }, { status: 404 });
const markdown = sessionMarkdown(record.messages, { title: record.title, anchors: false }); const markdown = sessionMarkdown(record.messages, { title: record.title, anchors: false, language: applicationLanguage(_req.headers.get("accept-language")) });
return new Response(markdown, { return new Response(markdown, {
headers: { headers: {
"content-type": "text/markdown; charset=utf-8", "content-type": "text/markdown; charset=utf-8",
"content-disposition": `attachment; filename="${slugify(record.title)}.md"`, "content-disposition": `attachment; filename="${/^[a-z0-9-]+$/.test(slugify(record.title)) ? slugify(record.title) : "session"}.md"; filename*=UTF-8''${encodeURIComponent(slugify(record.title))}.md`,
}, },
}); });
} }
+2 -2
View File
@@ -13,7 +13,7 @@
* healthy. The dock's chips + graph refetch track progress live through * healthy. The dock's chips + graph refetch track progress live through
* /api/graph/status, as with session runs. * /api/graph/status, as with session runs.
*/ */
import { extraction, NotesImportError } from "@/src/server"; import { extraction, NotesImportError, applicationLanguage } from "@/src/server";
import { import {
ModelsNotConfiguredError, ModelsNotConfiguredError,
PROVIDER_KEY_MISSING_CODE, PROVIDER_KEY_MISSING_CODE,
@@ -56,7 +56,7 @@ export async function POST(request: Request) {
const { file, requestIndex } = next; const { file, requestIndex } = next;
try { try {
const text = await file.text(); const text = await file.text();
const stats = await extraction.importNotes({ filePath: file.name, text }); const stats = await extraction.importNotes({ filePath: file.name, text, language: applicationLanguage(request.headers.get("accept-language")) });
results.push({ filePath: file.name, requestIndex, outcome: "completed", ok: true, stats }); results.push({ filePath: file.name, requestIndex, outcome: "completed", ok: true, stats });
} catch (err) { } catch (err) {
if (err instanceof ModelsNotConfiguredError) { if (err instanceof ModelsNotConfiguredError) {
+12
View File
@@ -0,0 +1,12 @@
import { keyValue, openRouterTranscription } from "@/src/server";
export const runtime = "nodejs";
/** Check availability before recording; the token stays on the server. */
export function GET() {
return Response.json({ configured: Boolean(keyValue("OPENROUTER_API_KEY")) }, { headers: { "Cache-Control": "no-store" } });
}
export async function POST(request: Request) {
return openRouterTranscription(request);
}
+3
View File
@@ -598,3 +598,6 @@ body {
outline: 1px solid var(--border); outline: 1px solid var(--border);
border-radius: 0.25rem; border-radius: 0.25rem;
} }
/* Source Sans 3 lacks Cyrillic; Russian prose uses the bundled Noto Sans subset. */
html[lang="ru"] { --font-sans3: var(--font-russian-prose); }
+22 -9
View File
@@ -1,5 +1,9 @@
import { headers } from "next/headers";
import { readSettingsRecord } from "@/src/server";
import { resolveLanguage, systemLanguage, translate } from "@/src/core";
import { I18nProvider } from "@/components/i18n";
import type { Metadata } from "next"; import type { Metadata } from "next";
import { JetBrains_Mono, Source_Sans_3 } from "next/font/google"; import { JetBrains_Mono, Source_Sans_3, Noto_Sans } from "next/font/google";
import "katex/dist/katex.min.css"; import "katex/dist/katex.min.css";
import "./globals.css"; import "./globals.css";
import { AppShell, SidebarProvider } from "@/components/sidebar"; import { AppShell, SidebarProvider } from "@/components/sidebar";
@@ -10,21 +14,29 @@ import UpdateBanner from "@/components/update-banner";
// Chrome = JetBrains Mono, teaching prose = Source Sans 3, titles = CMU // Chrome = JetBrains Mono, teaching prose = Source Sans 3, titles = CMU
// Serif (the bundled lh-cmu @font-face in globals.css — no CDN). // Serif (the bundled lh-cmu @font-face in globals.css — no CDN).
const ui = JetBrains_Mono({ subsets: ["latin"], variable: "--font-jetbrains" }); const ui = JetBrains_Mono({ subsets: ["latin", "cyrillic"], variable: "--font-jetbrains" });
const prose = Source_Sans_3({ subsets: ["latin"], variable: "--font-sans3" }); const prose = Source_Sans_3({ subsets: ["latin"], variable: "--font-sans3" });
export const metadata: Metadata = { const russianProse = Noto_Sans({ subsets: ["latin", "cyrillic"], variable: "--font-russian-prose" });
title: "Learning Harness",
description: "A teacher agent with a memory — sessions, quizzes, and a knowledge graph of what you know.",
};
export default function RootLayout({ children }: { children: React.ReactNode }) { export async function generateMetadata(): Promise<Metadata> {
const language = resolveLanguage(readSettingsRecord()?.general?.language, (await headers()).get("accept-language"));
return {
title: "Learning Harness",
description: translate(language, "A teacher agent with a memory — sessions, quizzes, and a knowledge graph of what you know."),
};
}
export default async function RootLayout({ children }: { children: React.ReactNode }) {
const general = readSettingsRecord()?.general ?? {};
const system = systemLanguage((await headers()).get("accept-language"));
const language = resolveLanguage(general.language, system);
return ( return (
<html lang="en" className={`dark ${ui.variable} ${prose.variable}`}> <html lang={language} className={`dark ${ui.variable} ${prose.variable} ${russianProse.variable}`}>
<body className="min-h-dvh antialiased"> <body className="min-h-dvh antialiased">
{/* The notification channel rides OUTSIDE everything: sidebar, dock, {/* The notification channel rides OUTSIDE everything: sidebar, dock,
and pages all subscribe as the polls retire (push S3, 2026-10-02). */} and pages all subscribe as the polls retire (push S3, 2026-10-02). */}
<ServerEventsProvider> <I18nProvider initialGeneral={general} system={system}><ServerEventsProvider>
<AppearanceProvider> <AppearanceProvider>
<SidebarProvider> <SidebarProvider>
<ActivityProvider> <ActivityProvider>
@@ -35,6 +47,7 @@ export default function RootLayout({ children }: { children: React.ReactNode })
</ServerEventsProvider> </ServerEventsProvider>
{/* Auto-update banner (Wave C): fixed card, Electron-only pushes. */} {/* Auto-update banner (Wave C): fixed card, Electron-only pushes. */}
<UpdateBanner /> <UpdateBanner />
</I18nProvider>
</body> </body>
</html> </html>
); );
+9 -7
View File
@@ -1,4 +1,6 @@
"use client"; "use client";
import { useI18n } from "@/components/i18n";
import { useEffect } from "react"; import { useEffect } from "react";
import { createPortal } from "react-dom"; import { createPortal } from "react-dom";
@@ -60,13 +62,14 @@ export function AbortTurnBody({
* (owner, 2026-10-04): the line says so. */ * (owner, 2026-10-04): the line says so. */
busy?: boolean; busy?: boolean;
}) { }) {
const { t } = useI18n();
const backdropDismiss = useBackdropDismiss(onClose); const backdropDismiss = useBackdropDismiss(onClose);
const copy = COPY[mode]; const copy = COPY[mode];
return ( return (
<div <div
role="dialog" role="dialog"
aria-modal="true" aria-modal="true"
aria-label={copy.title} aria-label={t(copy.title)}
data-testid="abort-turn-dialog" data-testid="abort-turn-dialog"
className="fixed inset-0 z-50 flex items-center justify-center bg-background/80 backdrop-blur-sm" 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. // A genuine click on the scrim cancels — the cautious default, like Esc.
@@ -77,11 +80,11 @@ export function AbortTurnBody({
onClick={(e) => e.stopPropagation()} onClick={(e) => e.stopPropagation()}
> >
<h2 className="text-sm font-semibold text-card-foreground" data-testid="abort-turn-title"> <h2 className="text-sm font-semibold text-card-foreground" data-testid="abort-turn-title">
{copy.title} {t(copy.title)}
</h2> </h2>
<p className="mt-1.5 text-xs text-muted-foreground" data-testid="abort-turn-line"> <p className="mt-1.5 text-xs text-muted-foreground" data-testid="abort-turn-line">
{copy.line} {t(copy.line)}
{busy && mode !== "turn" ? " The working turn stops too." : ""} {busy && mode !== "turn" ? t(" The working turn stops too.") : ""}
</p> </p>
<div className="mt-4 flex justify-end gap-2"> <div className="mt-4 flex justify-end gap-2">
<button <button
@@ -90,8 +93,7 @@ export function AbortTurnBody({
onClick={onClose} onClick={onClose}
className="rounded-sm px-3 py-1.5 text-xs text-muted-foreground transition-colors hover:text-foreground" className="rounded-sm px-3 py-1.5 text-xs text-muted-foreground transition-colors hover:text-foreground"
> >
Cancel {t("Cancel")}</button>
</button>
<button <button
type="button" type="button"
data-testid="abort-turn-confirm" data-testid="abort-turn-confirm"
@@ -99,7 +101,7 @@ export function AbortTurnBody({
// Destructive red — the app's established destructive pair. // 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" 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> </button>
</div> </div>
</div> </div>
+33 -29
View File
@@ -1,4 +1,7 @@
"use client"; "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 { createContext, useCallback, useContext, useEffect, useMemo, useRef, useState } from "react";
import { ActivityIcon, BotIcon, FileTextIcon, PenToolIcon, PlayIcon, SearchIcon, SquareIcon, WaypointsIcon } from "lucide-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. */ /** 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)); 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); const m = Math.floor(s / 60);
if (m < 60) return `${m}m ${String(s % 60).padStart(2, "0")}s`; if (m < 60) return t("{v0}m {v1}s", { v0: m, v1: String(s % 60).padStart(2, "0") });
return `${Math.floor(m / 60)}h ${String(m % 60).padStart(2, "0")}m`; 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) ───────────────────────────────── // ─── The section (the sidebar's bottom strip) ─────────────────────────────────
@@ -289,6 +293,7 @@ const KIND_ICON: Record<JobKind, typeof SearchIcon> = {
type AbortState = "pending" | "failed"; type AbortState = "pending" | "failed";
export function ActivitySection({ feed: feedProp }: { feed?: ActivityFeed }) { export function ActivitySection({ feed: feedProp }: { feed?: ActivityFeed }) {
const { t } = useI18n();
const ctx = useContext(ActivityContext); const ctx = useContext(ActivityContext);
const feed = feedProp ?? ctx.feed; const feed = feedProp ?? ctx.feed;
const refresh = ctx.refresh; 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"> <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"> <div className="mb-1 flex items-center gap-1.5 text-xs uppercase tracking-widest text-muted-foreground/70">
<ActivityIcon className="size-3" /> <ActivityIcon className="size-3" />
Background agents {t("Background agents")}{blobs.length > 0 && (
{blobs.length > 0 && ( <span className="ml-auto tabular-nums" data-testid="activity-total-cost" title={t("Total running cost (inferred)")}>
<span className="ml-auto tabular-nums" data-testid="activity-total-cost" title="Total running cost (inferred)">
{planCostLabel(totalCost, subscriptionLabels(blobs.map((blob) => blob.model)))} {planCostLabel(totalCost, subscriptionLabels(blobs.map((blob) => blob.model)))}
</span> </span>
)} )}
@@ -389,19 +393,20 @@ function ActivityBlobRow({
abortState: AbortState | undefined; abortState: AbortState | undefined;
onAbort: () => void; onAbort: () => void;
}) { }) {
const { t, language } = useI18n();
const Icon = blob.process === "extraction" ? WaypointsIcon : (blob.jobKind && KIND_ICON[blob.jobKind]) || BotIcon; const Icon = blob.process === "extraction" ? WaypointsIcon : (blob.jobKind && KIND_ICON[blob.jobKind]) || BotIcon;
const label = const label =
blob.process === "extraction" blob.process === "extraction"
? `Extraction — ${blob.phase ?? "running"}` ? t("Extraction — {v0}", { v0: t(blob.phase ?? "running") })
: blob.jobKind === "research" : blob.jobKind === "research"
? "Researcher" ? t("Researcher")
: blob.jobKind === "demo" : blob.jobKind === "demo"
? "Demo builder" ? t("Demo builder")
: blob.jobKind === "visual" : blob.jobKind === "visual"
? "Figure maker" ? t("Figure maker")
: blob.jobKind === "pdf" : blob.jobKind === "pdf"
? "PDF reader" ? t("PDF reader")
: "Sub-agent"; : t("Sub-agent");
return ( return (
<div <div
data-testid={`activity-blob-${blob.process}`} data-testid={`activity-blob-${blob.process}`}
@@ -417,8 +422,8 @@ function ActivityBlobRow({
<span className="min-w-0 flex-1"> <span className="min-w-0 flex-1">
<span className="block truncate font-medium text-card-foreground">{blob.title}</span> <span className="block truncate font-medium text-card-foreground">{blob.title}</span>
{blob.activity ? ( {blob.activity ? (
<span className="block truncate text-muted-foreground" title={blob.activity}> <span className="block truncate text-muted-foreground" title={t(blob.activity)}>
{blob.activity} {t(blob.activity)}
</span> </span>
) : ( ) : (
<span className="block truncate text-muted-foreground/80">{label}</span> <span className="block truncate text-muted-foreground/80">{label}</span>
@@ -426,7 +431,7 @@ function ActivityBlobRow({
</span> </span>
<span className="flex shrink-0 items-center gap-2 tabular-nums text-muted-foreground"> <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-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> </span>
</button> </button>
{/* The explicit END always on the blob's face (SCOPE) — the popover {/* The explicit END always on the blob's face (SCOPE) — the popover
@@ -437,8 +442,8 @@ function ActivityBlobRow({
data-testid="activity-abort" data-testid="activity-abort"
onClick={onAbort} onClick={onAbort}
disabled={abortState !== undefined} disabled={abortState !== undefined}
title={abortState === "failed" ? "Abort failed — try again" : "End this background process"} title={abortState === "failed" ? t("Abort failed — try again") : t("End this background process")}
aria-label={`End ${label} in ${blob.title}`} 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" 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" /> <SquareIcon className="size-3" />
@@ -453,47 +458,46 @@ function ActivityBlobRow({
<Icon className="size-3.5 text-muted-foreground" /> <Icon className="size-3.5 text-muted-foreground" />
<span className="font-medium">{label}</span> <span className="font-medium">{label}</span>
<span className="ml-auto tabular-nums text-muted-foreground"> <span className="ml-auto tabular-nums text-muted-foreground">
{formatElapsed(now - blob.startedAt)} {formatElapsed(now - blob.startedAt, language)}
</span> </span>
</div> </div>
<div className="flex flex-col gap-1 text-muted-foreground"> <div className="flex flex-col gap-1 text-muted-foreground">
<div className="truncate text-card-foreground" title={blob.title}> <div className="truncate text-card-foreground" title={blob.title}>
{blob.title} {blob.title}
</div> </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"> <div className="flex justify-between gap-2">
<span>Cost</span> <span>{t("Cost")}</span>
<span className="tabular-nums text-card-foreground"> <span className="tabular-nums text-card-foreground">
{planCostLabel(blob.cost, subscriptionLabel(blob.model))} {planCostLabel(blob.cost, subscriptionLabel(blob.model))}
</span> </span>
</div> </div>
<div className="flex justify-between gap-2"> <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"> <span className="tabular-nums text-card-foreground" data-testid="activity-blob-context">
{blob.context === null {blob.context === null
? "—" ? "—"
: `${formatTokens(blob.context.used)} / ${formatTokens(blob.context.limit)}`} : `${formatTokens(blob.context.used, language)} / ${formatTokens(blob.context.limit, language)}`}
</span> </span>
</div> </div>
{blob.model && ( {blob.model && (
<div className="flex justify-between gap-2"> <div className="flex justify-between gap-2">
<span>Model</span> <span>{t("Model")}</span>
<span className="truncate text-card-foreground">{blob.model}</span> <span className="truncate text-card-foreground">{blob.model}</span>
</div> </div>
)} )}
{blob.steps !== undefined && ( {blob.steps !== undefined && (
<div className="flex justify-between gap-2"> <div className="flex justify-between gap-2">
<span>Steps</span> <span>{t("Steps")}</span>
<span className="tabular-nums text-card-foreground">{blob.steps}</span> <span className="tabular-nums text-card-foreground">{blob.steps}</span>
</div> </div>
)} )}
<a href={`/chat/${blob.chatId}`} className="text-primary hover:underline"> <a href={`/chat/${blob.chatId}`} className="text-primary hover:underline">
Open session → {t("Open session →")}</a>
</a>
</div> </div>
<div className="mt-2 text-muted-foreground/80"> <div className="mt-2 text-muted-foreground/80">
{abortState === "failed" && "Abort failed — try again"} {abortState === "failed" && t("Abort failed — try again")}
{abortState === "pending" && "Stopping…"} {abortState === "pending" && t("Stopping…")}
</div> </div>
</div> </div>
)} )}
+13 -3
View File
@@ -1,4 +1,6 @@
"use client"; "use client";
import { useI18n } from "@/components/i18n";
import { Button } from "@/components/ui/button"; import { Button } from "@/components/ui/button";
import { import {
@@ -31,6 +33,8 @@ import {
import remarkBreaks from "remark-breaks"; import remarkBreaks from "remark-breaks";
import { import {
Streamdown, Streamdown,
defaultTranslations,
type StreamdownTranslations,
type PluginConfig, type PluginConfig,
} from "streamdown"; } from "streamdown";
import { mdComponents } from "@/components/md-blocks"; import { mdComponents } from "@/components/md-blocks";
@@ -264,11 +268,12 @@ export const MessageBranchPrevious = ({
children, children,
...props ...props
}: MessageBranchPreviousProps) => { }: MessageBranchPreviousProps) => {
const { t } = useI18n();
const { goToPrevious, totalBranches } = useMessageBranch(); const { goToPrevious, totalBranches } = useMessageBranch();
return ( return (
<Button <Button
aria-label="Previous branch" aria-label={t("Previous branch")}
disabled={totalBranches <= 1} disabled={totalBranches <= 1}
onClick={goToPrevious} onClick={goToPrevious}
size="icon-sm" size="icon-sm"
@@ -287,11 +292,12 @@ export const MessageBranchNext = ({
children, children,
...props ...props
}: MessageBranchNextProps) => { }: MessageBranchNextProps) => {
const { t } = useI18n();
const { goToNext, totalBranches } = useMessageBranch(); const { goToNext, totalBranches } = useMessageBranch();
return ( return (
<Button <Button
aria-label="Next branch" aria-label={t("Next branch")}
disabled={totalBranches <= 1} disabled={totalBranches <= 1}
onClick={goToNext} onClick={goToNext}
size="icon-sm" size="icon-sm"
@@ -310,6 +316,7 @@ export const MessageBranchPage = ({
className, className,
...props ...props
}: MessageBranchPageProps) => { }: MessageBranchPageProps) => {
const { t } = useI18n();
const { currentBranch, totalBranches } = useMessageBranch(); const { currentBranch, totalBranches } = useMessageBranch();
return ( return (
@@ -320,7 +327,7 @@ export const MessageBranchPage = ({
)} )}
{...props} {...props}
> >
{currentBranch + 1} of {totalBranches} {currentBranch + 1}{" "}{t("of")}{" "}{totalBranches}
</ButtonGroupText> </ButtonGroupText>
); );
}; };
@@ -357,6 +364,8 @@ export const streamdownPlugins = {
export const MessageResponse = memo( export const MessageResponse = memo(
({ className, children, ...props }: MessageResponseProps) => { ({ 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; // Legacy backslash delimiters are converted in a single linear scan;
// native dollar math passes through to Streamdown unchanged. Incomplete // native dollar math passes through to Streamdown unchanged. Incomplete
// legacy spans are left for a later render when their closer arrives. // 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", "lh-prose size-full [&>*:first-child]:mt-0 [&>*:last-child]:mb-0",
className className
)} )}
translations={translations}
components={mdComponents} components={mdComponents}
plugins={streamdownPlugins} plugins={streamdownPlugins}
{...props} {...props}
+8 -5
View File
@@ -1,4 +1,5 @@
"use client"; "use client";
import { useI18n } from "@/components/i18n";
import { useControllableState } from "@radix-ui/react-use-controllable-state"; import { useControllableState } from "@radix-ui/react-use-controllable-state";
import { blurAfterPointerClick } from "@/components/focus-hygiene"; 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 for a few seconds"), bookkeeping for information we knowingly lose. The
label pairs with the user's collapsed "Your thinking" rows — one label pairs with the user's collapsed "Your thinking" rows — one
language; motion (spinner + shimmer) stays the working signal. */ language; motion (spinner + shimmer) stays the working signal. */
const defaultGetThinkingMessage = (isStreaming: boolean) => { const DefaultThinkingMessage = ({ isStreaming }: { isStreaming: boolean }) => {
const { t } = useI18n();
if (isStreaming) { 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( export const ReasoningTrigger = memo(
({ ({
className, className,
children, children,
getThinkingMessage = defaultGetThinkingMessage, getThinkingMessage = (isStreaming) => <DefaultThinkingMessage isStreaming={isStreaming} />,
...props ...props
}: ReasoningTriggerProps) => { }: ReasoningTriggerProps) => {
const { t } = useI18n();
const { isStreaming, isOpen } = useReasoning(); const { isStreaming, isOpen } = useReasoning();
return ( return (
@@ -175,7 +178,7 @@ export const ReasoningTrigger = memo(
the component default (h-3) — IDENTICAL to the placeholder the component default (h-3) — IDENTICAL to the placeholder
spinner (owner, 2026-10-02): the handoff between the two spinner (owner, 2026-10-02): the handoff between the two
states must be visually seamless, one size everywhere. */} states must be visually seamless, one size everywhere. */}
{isStreaming && <BrailleSpinner label="Thinking" />} {isStreaming && <BrailleSpinner label={t("Thinking")} />}
{getThinkingMessage(isStreaming)} {getThinkingMessage(isStreaming)}
<ChevronDownIcon <ChevronDownIcon
className={cn( className={cn(
+19 -17
View File
@@ -1,4 +1,6 @@
"use client"; "use client";
import { useI18n } from "@/components/i18n";
import { useState } from "react"; import { useState } from "react";
import { ConfidenceRail } from "@/components/confidence-rail"; 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). * the explanation, with NO verdict chips (owner ruling 2026-09-27).
*/ */
export function AnsweredCard({ answer, input }: { answer: QuestionAnswerData; input?: unknown }) { 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) const [whyOpen, setWhyOpen] = useState(false); // the tucked explanation (predictions, owner 2026-10-03)
if (answer.dismissed) { if (answer.dismissed) {
return ( return (
<div className="rounded-xl border bg-card p-4 text-sm text-muted-foreground"> <div className="rounded-xl border bg-card p-4 text-sm text-muted-foreground">
<span className="font-medium">{answer.questionText}</span> — skipped <span className="font-medium">{answer.questionText}</span> {" "}{t("— skipped")}</div>
</div>
); );
} }
@@ -51,7 +53,7 @@ export function AnsweredCard({ answer, input }: { answer: QuestionAnswerData; in
ThinkingBlock, instead of an inset inside the answer. Same muted ThinkingBlock, instead of an inset inside the answer. Same muted
rail as before; the label is now plain "Your thinking". */} rail as before; the label is now plain "Your thinking". */}
{answer.readingThinking && ( {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 {/* [&>*: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 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 cards label Probe/Recall — the phase boundary visible in the
transcript. No `tool` on old records: kind labels as before. */} transcript. No `tool` on old records: kind labels as before. */}
{answer.tool === "probe" {answer.tool === "probe"
? "Probe" ? t("Probe")
: answer.tool === "recall" : answer.tool === "recall"
? "Recall" ? t("Recall")
: isQuiz : isQuiz
? answer.questionType === "predict" ? answer.questionType === "predict"
? "Prediction" ? t("Prediction")
: "Check" : t("Check")
: isExplain : isExplain
? "Explain" ? t("Explain")
: "Question"} : t("Question")}
{isQuiz && {isQuiz &&
(answer.dontKnow ? ( (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 ? ( ) : 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>
<div className="mb-3 font-medium"> <div className="mb-3 font-medium">
@@ -176,7 +178,7 @@ export function AnsweredCard({ answer, input }: { answer: QuestionAnswerData; in
</div> </div>
)} )}
{answer.dontKnow && isQuiz && ( {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) && ( {(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. */ the accent lift read too light); rail only, heights identical. */
<ThinkingBlock <ThinkingBlock
testId="your-thinking" testId="your-thinking"
label="YOUR THINKING" label={t("YOUR THINKING")}
text={answer.thinking ?? answer.note ?? ""} text={answer.thinking ?? answer.note ?? ""}
inCard inCard
/> />
@@ -206,11 +208,11 @@ export function AnsweredCard({ answer, input }: { answer: QuestionAnswerData; in
onClick={() => setWhyOpen((v) => !v)} onClick={() => setWhyOpen((v) => !v)}
className="text-xs font-medium text-muted-foreground transition-colors hover:text-foreground" className="text-xs font-medium text-muted-foreground transition-colors hover:text-foreground"
> >
{whyOpen ? "Hide why" : "Why"} {whyOpen ? t("Hide why") : t("Why")}
</button> </button>
{whyOpen && ( {whyOpen && (
<div className="mt-1.5 rounded-lg bg-muted py-2 text-sm text-foreground/50"> <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 {/* The markdown wrapper must go truly inline — a block <p> after the
"Why:" label created the oversized leading gap (owner's fix list). */} "Why:" label created the oversized leading gap (owner's fix list). */}
<MessageResponse className="inline [&_p]:inline [&_p]:m-0">{answer.explanation}</MessageResponse> <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. */} the model's next reply IS the grading. */}
{isExplain && answer.committed && ( {isExplain && answer.committed && (
<div className="mt-3 rounded-lg bg-muted py-2 text-sm text-muted-foreground"> <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> <MessageResponse className="inline [&_p]:inline [&_p]:m-0">{answer.committed}</MessageResponse>
</div> </div>
)} )}
+16 -15
View File
@@ -1,4 +1,6 @@
"use client"; "use client";
import { useI18n } from "@/components/i18n";
import { useContext, useEffect, useMemo, useRef, useState } from "react"; import { useContext, useEffect, useMemo, useRef, useState } from "react";
import { DownloadIcon } from "lucide-react"; import { DownloadIcon } from "lucide-react";
@@ -100,12 +102,12 @@ export function DemoOutputCard({
messageId?: string; messageId?: string;
initialInverted?: boolean; initialInverted?: boolean;
}) { }) {
const { t } = useI18n();
const result = output as DemoResult | undefined; const result = output as DemoResult | undefined;
if (!result || typeof result.html !== "string" || typeof result.title !== "string") { if (!result || typeof result.html !== "string" || typeof result.title !== "string") {
return ( return (
<div className="rounded-xl border bg-card p-4 text-sm text-muted-foreground"> <div className="rounded-xl border bg-card p-4 text-sm text-muted-foreground">
Demo completed, but the result could not be displayed. {t("Demo completed, but the result could not be displayed.")}</div>
</div>
); );
} }
const failed = result.html.length === 0; const failed = result.html.length === 0;
@@ -113,10 +115,9 @@ export function DemoOutputCard({
return ( return (
<div className="rounded-xl border bg-card p-4 text-card-foreground" data-testid="demo-card-failed"> <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"> <div className="flex items-center gap-2 text-xs font-medium uppercase tracking-wide text-muted-foreground">
Interactive demo {t("Interactive demo")}<span className="rounded bg-red-500/15 px-1.5 py-0.5 text-red-500">{t("failed")}</span>
<span className="rounded bg-red-500/15 px-1.5 py-0.5 text-red-500">failed</span>
</div> </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> </div>
); );
} }
@@ -136,6 +137,7 @@ function DemoFrame({
messageId?: string; messageId?: string;
initialInverted?: boolean; initialInverted?: boolean;
}) { }) {
const { t } = useI18n();
const actions = useContext(ChatActionsContext); const actions = useContext(ChatActionsContext);
const iframeRef = useRef<HTMLIFrameElement | null>(null); const iframeRef = useRef<HTMLIFrameElement | null>(null);
const [state, setState] = useState<DemoBridgeState>(INITIAL_DEMO_BRIDGE_STATE); 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 items-center justify-between gap-2">
<div className="flex min-w-0 items-baseline 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"> <span className="shrink-0 text-xs font-medium uppercase tracking-wide text-muted-foreground">
interactive demo {t("interactive demo")}</span>
</span>
<span className="truncate text-xs font-medium tracking-wide text-foreground">{title}</span> <span className="truncate text-xs font-medium tracking-wide text-foreground">{title}</span>
</div> </div>
<div className="flex shrink-0 items-center gap-1.5"> <div className="flex shrink-0 items-center gap-1.5">
<Toggle <Toggle
checked={inverted} checked={inverted}
onChange={toggleInverted} onChange={toggleInverted}
label="Invert demo brightness" label={t("Invert demo brightness")}
title="Invert colors" title={t("Invert colors")}
discIcon={<InvertDiscIcon className="size-3" />} discIcon={<InvertDiscIcon className="size-3" />}
/> />
<button <button
type="button" type="button"
aria-label="Download demo as HTML" aria-label={t("Download demo as HTML")}
title="Download as HTML" title={t("Download as HTML")}
onClick={() => downloadFile(`${slugFilename(title)}.html`, html, "text/html")} onClick={() => downloadFile(`${slugFilename(title)}.html`, html, "text/html")}
data-testid="demo-download" data-testid="demo-download"
className="rounded-md p-1.5 text-muted-foreground transition-colors hover:bg-accent hover:text-foreground" className="rounded-md p-1.5 text-muted-foreground transition-colors hover:bg-accent hover:text-foreground"
@@ -205,7 +206,7 @@ function DemoFrame({
<DemoErrorChip <DemoErrorChip
title={title} title={title}
message={state.error} 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 <iframe
@@ -245,12 +246,13 @@ export function DemoErrorChip({
message: string; message: string;
onSend?: () => void; onSend?: () => void;
}) { }) {
const { t } = useI18n();
return ( return (
<div <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" 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" 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}> <span className="min-w-0 flex-1 truncate font-mono text-xs" title={message}>
{message} {message}
</span> </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" 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" data-testid="demo-error-send"
> >
Send to teacher {t("Send to teacher")}</button>
</button>
)} )}
</div> </div>
); );
+16 -12
View File
@@ -1,4 +1,6 @@
"use client"; "use client";
import { useI18n } from "@/components/i18n";
import { useContext } from "react"; import { useContext } from "react";
import { CheckIcon, SettingsIcon, XIcon } from "lucide-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 }) { export function DispatchCard({ toolCallId, kind }: { toolCallId: string; kind: JobKind }) {
const { t } = useI18n();
const jobs = useContext(JobsContext); const jobs = useContext(JobsContext);
const job = jobs.find((j) => j.toolCallId === toolCallId) ?? null; const job = jobs.find((j) => j.toolCallId === toolCallId) ?? null;
const label = KIND_LABEL[kind]; const label = KIND_LABEL[kind];
if (!job || job.status === "running") { 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, // BYOK (owner, 2026-09-22): a job refused for missing models is INFO,
// not scary-error — gray glyph + "needs model setup" + the deep-link // 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 // whose key is gone reads as the key being needed — the deep-link
// lands on the SetupDialog's first-key stage on its own. // lands on the SetupDialog's first-key stage on its own.
if (isModelsRefusal(job.error)) { 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)) { 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"; const failed = job.status === "error";
return ( return (
@@ -53,14 +56,15 @@ export function DispatchCard({ toolCallId, kind }: { toolCallId: string; kind: J
data-testid="dispatch-settled" data-testid="dispatch-settled"
> >
{failed ? <XIcon className="size-3.5 text-red-500" /> : <CheckIcon className="size-3.5" />} {failed ? <XIcon className="size-3.5 text-red-500" /> : <CheckIcon className="size-3.5" />}
<span className="font-medium uppercase tracking-wide">{label}</span> <span className="font-medium uppercase tracking-wide">{t(label)}</span>
<span>{failed ? "failed" : "delivered"}</span> <span>{failed ? t("failed") : t("delivered")}</span>
</div> </div>
); );
} }
/** The refused card: quiet info tone — nothing red, nothing "failed". */ /** The refused card: quiet info tone — nothing red, nothing "failed". */
function DispatchRefusedCard({ label, line, button }: { label: string; line: string; button: string }) { function DispatchRefusedCard({ label, line, button }: { label: string; line: string; button: string }) {
const { t } = useI18n();
const ctx = useContext(ChatActionsContext); const ctx = useContext(ChatActionsContext);
return ( return (
<div <div
@@ -68,7 +72,7 @@ function DispatchRefusedCard({ label, line, button }: { label: string; line: str
data-testid="dispatch-refused" data-testid="dispatch-refused"
> >
<SettingsIcon className="size-3.5" /> <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> <span>{line}</span>
{ctx && ( {ctx && (
<button <button
@@ -85,6 +89,7 @@ function DispatchRefusedCard({ label, line, button }: { label: string; line: str
} }
function DispatchActivityCard({ job, label }: { job: JobRecord | null; label: string }) { 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), // Elapsed from createdAt — jobs appear here only via the jobs feed (post-mount),
// so no SSR/hydration mismatch. // so no SSR/hydration mismatch.
const elapsed = const elapsed =
@@ -99,19 +104,18 @@ function DispatchActivityCard({ job, label }: { job: JobRecord | null; label: st
the Braille spinner replaces the pulsing dot — one indicator the Braille spinner replaces the pulsing dot — one indicator
everywhere. This is the ONLY dispatch-card change; the card's everywhere. This is the ONLY dispatch-card change; the card's
shape is untouched. */} shape is untouched. */}
<BrailleSpinner label={`${label} running`} /> <BrailleSpinner label={t("{v0} running", { v0: label })} />
{label} {t(label)}
{elapsed !== null && <span className="font-normal">· {elapsed}s</span>} {elapsed !== null && <span className="font-normal">· {t("{v0}s", { v0: elapsed })}</span>}
</div> </div>
{job?.activity && ( {job?.activity && (
<div className="mt-1 truncate text-sm text-muted-foreground" data-testid="dispatch-activity-line"> <div className="mt-1 truncate text-sm text-muted-foreground" data-testid="dispatch-activity-line">
{job.activity} {t(job.activity)}
</div> </div>
)} )}
{!job && ( {!job && (
<div className="mt-1 text-sm text-muted-foreground" data-testid="dispatch-pending"> <div className="mt-1 text-sm text-muted-foreground" data-testid="dispatch-pending">
Dispatched… {t("Dispatched…")}</div>
</div>
)} )}
</div> </div>
); );
+1 -1
View File
@@ -13,7 +13,7 @@ export function slugFilename(title: string): string {
.toLowerCase() .toLowerCase()
.normalize("NFKD") .normalize("NFKD")
.replace(/[\u0300-\u036f]/g, "") .replace(/[\u0300-\u036f]/g, "")
.replace(/[^a-z0-9]+/g, "-") .replace(/[^\p{L}\p{N}]+/gu, "-")
.replace(/^-+|-+$/g, "") .replace(/^-+|-+$/g, "")
.slice(0, 64); .slice(0, 64);
return slug || "demo"; return slug || "demo";
+13 -13
View File
@@ -1,4 +1,6 @@
"use client"; "use client";
import { useI18n } from "@/components/i18n";
import { DispatchCard } from "@/components/artifacts/dispatch-card"; import { DispatchCard } from "@/components/artifacts/dispatch-card";
import { MessageResponse } from "@/components/ai-elements/message"; import { MessageResponse } from "@/components/ai-elements/message";
@@ -35,15 +37,15 @@ export function PdfJobCard({ part }: ArtifactPartProps) {
* the same expand-on-demand idiom as research. * the same expand-on-demand idiom as research.
*/ */
export function PdfBriefCard({ output }: { output: unknown }) { export function PdfBriefCard({ output }: { output: unknown }) {
const { t } = useI18n();
const result = output as PdfResult | undefined; const result = output as PdfResult | undefined;
if (!result || typeof result.brief !== "string") { if (!result || typeof result.brief !== "string") {
return ( return (
<div className="rounded-xl border bg-card p-4 text-sm text-muted-foreground"> <div className="rounded-xl border bg-card p-4 text-sm text-muted-foreground">
PDF reading completed, but the result could not be displayed. {t("PDF reading completed, but the result could not be displayed.")}</div>
</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 // Read = extract/render output entered the loop; consulted = every page
// incl. search hits (the full audit set — the brief's citations may point // incl. search hits (the full audit set — the brief's citations may point
// there). The ?? fallbacks tolerate pre-split persisted records. // there). The ?? fallbacks tolerate pre-split persisted records.
@@ -52,20 +54,19 @@ export function PdfBriefCard({ output }: { output: unknown }) {
return ( return (
<div className="rounded-xl border bg-card p-4 text-card-foreground" data-testid="pdf-brief-card"> <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"> <div className="mb-1 flex items-center gap-2 text-xs font-medium uppercase tracking-wide text-muted-foreground">
PDF brief {t("PDF brief")}{failed && <span className="rounded bg-red-500/15 px-1.5 py-0.5 text-red-500">{t("failed")}</span>}
{failed && <span className="rounded bg-red-500/15 px-1.5 py-0.5 text-red-500">failed</span>}
</div> </div>
<div className="text-sm [&_p:first-child]:mt-0 [&_p:last-child]:mb-0"> <div className="text-sm [&_p:first-child]:mt-0 [&_p:last-child]:mb-0">
<MessageResponse>{result.brief}</MessageResponse> <MessageResponse>{result.brief}</MessageResponse>
</div> </div>
{!failed && readList.length > 0 && ( {!failed && readList.length > 0 && (
<div className="mt-3 border-t pt-2 text-xs text-muted-foreground"> <div className="mt-3 border-t pt-2 text-xs text-muted-foreground">
Pages read: {readList.join(", ")} {t("Pages read:")}{" "}{readList.join(", ")}
</div> </div>
)} )}
{!failed && consultedList.length > readList.length && ( {!failed && consultedList.length > readList.length && (
<div className="mt-3 border-t pt-2 text-xs text-muted-foreground"> <div className="mt-3 border-t pt-2 text-xs text-muted-foreground">
Pages consulted: {consultedList.join(", ")} {t("Pages consulted:")}{" "}{consultedList.join(", ")}
</div> </div>
)} )}
</div> </div>
@@ -77,27 +78,26 @@ export function PdfBriefCard({ output }: { output: unknown }) {
* the steer card (sub-agent result delivery) renders the same artifact. * the steer card (sub-agent result delivery) renders the same artifact.
*/ */
export function ResearchBriefCard({ output }: { output: unknown }) { export function ResearchBriefCard({ output }: { output: unknown }) {
const { t } = useI18n();
const result = output as ResearchResult | undefined; const result = output as ResearchResult | undefined;
if (!result || typeof result.brief !== "string") { if (!result || typeof result.brief !== "string") {
return ( return (
<div className="rounded-xl border bg-card p-4 text-sm text-muted-foreground"> <div className="rounded-xl border bg-card p-4 text-sm text-muted-foreground">
Research completed, but the result could not be displayed. {t("Research completed, but the result could not be displayed.")}</div>
</div>
); );
} }
const failed = result.brief.startsWith("Research failed"); const failed = result.brief.startsWith(t("Research failed"));
return ( return (
<div className="rounded-xl border bg-card p-4 text-card-foreground" data-testid="research-brief-card"> <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"> <div className="mb-1 flex items-center gap-2 text-xs font-medium uppercase tracking-wide text-muted-foreground">
Research brief {t("Research brief")}{failed && <span className="rounded bg-red-500/15 px-1.5 py-0.5 text-red-500">{t("failed")}</span>}
{failed && <span className="rounded bg-red-500/15 px-1.5 py-0.5 text-red-500">failed</span>}
</div> </div>
<div className="text-sm [&_p:first-child]:mt-0 [&_p:last-child]:mb-0"> <div className="text-sm [&_p:first-child]:mt-0 [&_p:last-child]:mb-0">
<MessageResponse>{result.brief}</MessageResponse> <MessageResponse>{result.brief}</MessageResponse>
</div> </div>
{result.sources.length > 0 && ( {result.sources.length > 0 && (
<div className="mt-3 border-t pt-2"> <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"> <ul className="flex flex-col gap-0.5 text-xs">
{result.sources.map((s) => ( {result.sources.map((s) => (
<li key={s.url} className="truncate"> <li key={s.url} className="truncate">
+5 -2
View File
@@ -1,4 +1,6 @@
"use client"; "use client";
import { useI18n } from "@/components/i18n";
import { ChevronRight } from "lucide-react"; import { ChevronRight } from "lucide-react";
import { BrailleSpinner } from "@/components/braille-spinner"; import { BrailleSpinner } from "@/components/braille-spinner";
@@ -28,6 +30,7 @@ interface StreamingToolPart {
} }
export function LiveToolCard({ part }: ArtifactPartProps) { export function LiveToolCard({ part }: ArtifactPartProps) {
const { t } = useI18n();
const p = part as StreamingToolPart; const p = part as StreamingToolPart;
const tool = p.type.replace(/^tool-/, ""); const tool = p.type.replace(/^tool-/, "");
const inp = (p.input ?? {}) as Record<string, unknown>; 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" 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"> <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"> <Shimmer duration={1} className="text-xs">
{toolActivityLabel(tool)} {toolActivityLabel(tool)}
</Shimmer> </Shimmer>
@@ -55,7 +58,7 @@ export function LiveToolCard({ part }: ArtifactPartProps) {
<ChevronRight className="size-3 shrink-0 opacity-50 transition-transform group-open:rotate-90" /> <ChevronRight className="size-3 shrink-0 opacity-50 transition-transform group-open:rotate-90" />
</summary> </summary>
<div className="mt-2 rounded-xl border bg-card p-3 text-xs"> <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"> <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) : "…"} {hasInput ? JSON.stringify(p.input, null, 2) : "…"}
</pre> </pre>
+7 -6
View File
@@ -1,4 +1,6 @@
"use client"; "use client";
import { useI18n } from "@/components/i18n";
import { ClipboardCheckIcon } from "lucide-react"; import { ClipboardCheckIcon } from "lucide-react";
import { parseProbeDoneInput, type ProbeDoneResult } from "@/src/core"; 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. * the UI wave; this card just guarantees the data is never invisible.
*/ */
export function ProbeDoneCard({ part }: { part: { input?: unknown; output?: unknown; state?: unknown } }) { export function ProbeDoneCard({ part }: { part: { input?: unknown; output?: unknown; state?: unknown } }) {
const { t } = useI18n();
const output = part.output as ProbeDoneResult | undefined; const output = part.output as ProbeDoneResult | undefined;
const state = typeof part.state === "string" ? part.state : ""; 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 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"> <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" /> <ClipboardCheckIcon className="size-3.5 text-primary" />
Probe concluded — your assessment {t("Probe concluded — your assessment")}</div>
</div>
{output && !output.ok ? ( {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" ? ( ) : state === "output-error" ? (
<p className="text-sm text-muted-foreground"> <p className="text-sm text-muted-foreground">
The assessment was malformed — the prober retried with a corrected one. {t("The assessment was malformed — the prober retried with a corrected one.")}</p>
</p>
) : strands ? ( ) : strands ? (
<div className="space-y-2"> <div className="space-y-2">
{strands.map((s, i) => ( {strands.map((s, i) => (
@@ -64,7 +65,7 @@ export function ProbeDoneCard({ part }: { part: { input?: unknown; output?: unkn
)} )}
</div> </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> </div>
); );
+17 -12
View File
@@ -1,4 +1,6 @@
"use client"; "use client";
import { useI18n } from "@/components/i18n";
import { CheckIcon, CircleX } from "lucide-react"; import { CheckIcon, CircleX } from "lucide-react";
import type { PdfResult, ResearchResult, SubagentArtifactData } from "@/src/core"; 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. * render; only a failure note does.
*/ */
export function SubagentSteerCard({ message }: { message: LhViewMessage }) { export function SubagentSteerCard({ message }: { message: LhViewMessage }) {
const { t } = useI18n();
const artifactPart = message.parts.find((p) => p.type === "data-subagent-artifact"); const artifactPart = message.parts.find((p) => p.type === "data-subagent-artifact");
const data = artifactPart?.data as SubagentArtifactData | undefined; const data = artifactPart?.data as SubagentArtifactData | undefined;
// The abort steer (2026-09 dock semantics): its message-type part is // 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"} />; if (aborted?.data) return <AbortedBadge kind={aborted.data.kind ?? "job"} />;
return ( return (
<div className="rounded-xl border bg-card p-4 text-sm text-muted-foreground"> <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. {t("A sub-agent finished, but its result could not be displayed.")}</div>
</div>
); );
} }
switch (data.kind) { switch (data.kind) {
@@ -66,11 +68,12 @@ export function SubagentSteerCard({ message }: { message: LhViewMessage }) {
* teacher's wake turn acknowledges the abort in the lesson itself, * teacher's wake turn acknowledges the abort in the lesson itself,
* this just marks what happened. */ * this just marks what happened. */
function AbortedBadge({ kind }: { kind: string }) { function AbortedBadge({ kind }: { kind: string }) {
const { t } = useI18n();
return ( return (
<div data-testid="job-aborted-badge" className="flex items-center gap-2 text-xs text-muted-foreground"> <div data-testid="job-aborted-badge" className="flex items-center gap-2 text-xs text-muted-foreground">
<CircleX className="size-3.5" /> <CircleX className="size-3.5" />
<span className="font-medium uppercase tracking-wide">{kind}</span> <span className="font-medium uppercase tracking-wide">{kind}</span>
<span>aborted by the learner</span> <span>{t("aborted by the learner")}</span>
</div> </div>
); );
} }
@@ -80,6 +83,7 @@ function AbortedBadge({ kind }: { kind: string }) {
* the badge marks delivery, the collapsed details carries the full text * the badge marks delivery, the collapsed details carries the full text
* + consulted pages. */ * + consulted pages. */
function PdfCompleteBadge({ result }: { result: PdfResult }) { function PdfCompleteBadge({ result }: { result: PdfResult }) {
const { t } = useI18n();
// The honest count is READ pages (extract/render — sources v1 split); // The honest count is READ pages (extract/render — sources v1 split);
// the ?? is runtime tolerance for pre-split persisted steers. // the ?? is runtime tolerance for pre-split persisted steers.
const readPages = result.pagesRead ?? result.pagesConsulted; 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"> <summary className="flex cursor-pointer select-none list-none items-center gap-2 [&::-webkit-details-marker]:hidden">
<CheckIcon className="size-3.5" /> <CheckIcon className="size-3.5" />
<span className="font-medium uppercase tracking-wide">PDF</span> <span className="font-medium uppercase tracking-wide">PDF</span>
<span>reading complete</span> <span>{t("reading complete")}</span>
{readPages.length > 0 && ( {readPages.length > 0 && (
<span> <span>
· read {readPages.length} page{readPages.length === 1 ? "" : "s"} {t("· read {count} pages", { count: readPages.length })}
</span> </span>
)} )}
<span className="ml-1 text-xs text-muted-foreground/70 group-open:hidden">view 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">hide brief</span> <span className="ml-1 hidden text-xs text-muted-foreground/70 group-open:inline">{t("hide brief")}</span>
</summary> </summary>
<div className="mt-2"> <div className="mt-2">
<PdfBriefCard output={result} /> <PdfBriefCard output={result} />
@@ -110,15 +114,16 @@ function PdfCompleteBadge({ result }: { result: PdfResult }) {
* affordance): full markdown + sources, one click away, no extra fetch — * affordance): full markdown + sources, one click away, no extra fetch —
* the artifact part in the record is the single source of truth. */ * the artifact part in the record is the single source of truth. */
function ResearchCompleteBadge({ result }: { result: ResearchResult }) { function ResearchCompleteBadge({ result }: { result: ResearchResult }) {
const { t } = useI18n();
return ( return (
<details data-testid="research-complete-badge" className="group w-full text-xs text-muted-foreground"> <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"> <summary className="flex cursor-pointer select-none list-none items-center gap-2 [&::-webkit-details-marker]:hidden">
<CheckIcon className="size-3.5" /> <CheckIcon className="size-3.5" />
<span className="font-medium uppercase tracking-wide">Research</span> <span className="font-medium uppercase tracking-wide">{t("Research")}</span>
<span>complete</span> <span>{t("complete")}</span>
{result.sources.length > 0 && <span>· {result.sources.length} sources</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">view 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">hide brief</span> <span className="ml-1 hidden text-xs text-muted-foreground/70 group-open:inline">{t("hide brief")}</span>
</summary> </summary>
<div className="mt-2"> <div className="mt-2">
<ResearchBriefCard output={result} /> <ResearchBriefCard output={result} />
+8 -8
View File
@@ -1,4 +1,6 @@
"use client"; "use client";
import { useI18n } from "@/components/i18n";
import { ChevronRight } from "lucide-react"; import { ChevronRight } from "lucide-react";
import type { ArtifactPartProps } from "@/src/core"; import type { ArtifactPartProps } from "@/src/core";
@@ -62,6 +64,7 @@ function outputText(output: unknown): string | null {
} }
export function ToolCallCard({ part }: ArtifactPartProps) { export function ToolCallCard({ part }: ArtifactPartProps) {
const { t } = useI18n();
const p = part as ToolCallPart; const p = part as ToolCallPart;
const tool = p.type.replace(/^tool-/, ""); const tool = p.type.replace(/^tool-/, "");
const arg = headlineArg(tool, p.input); const arg = headlineArg(tool, p.input);
@@ -84,8 +87,7 @@ export function ToolCallCard({ part }: ArtifactPartProps) {
{p.input != null && ( {p.input != null && (
<div> <div>
<div className="mb-1 font-medium uppercase tracking-wide text-muted-foreground"> <div className="mb-1 font-medium uppercase tracking-wide text-muted-foreground">
Input {t("Input")}</div>
</div>
<pre className="max-h-32 overflow-auto whitespace-pre-wrap break-all font-mono text-[11px] leading-relaxed"> <pre className="max-h-32 overflow-auto whitespace-pre-wrap break-all font-mono text-[11px] leading-relaxed">
{JSON.stringify(p.input, null, 2)} {JSON.stringify(p.input, null, 2)}
</pre> </pre>
@@ -94,24 +96,22 @@ export function ToolCallCard({ part }: ArtifactPartProps) {
{typeof p.errorText === "string" ? ( {typeof p.errorText === "string" ? (
<div> <div>
<div className="mb-1 font-medium uppercase tracking-wide text-muted-foreground"> <div className="mb-1 font-medium uppercase tracking-wide text-muted-foreground">
Error {t("Error")}</div>
</div>
<pre className="whitespace-pre-wrap break-all font-mono text-[11px] text-red-500"> <pre className="whitespace-pre-wrap break-all font-mono text-[11px] text-red-500">
{p.errorText} {t(p.errorText)}
</pre> </pre>
</div> </div>
) : out != null ? ( ) : out != null ? (
<div> <div>
<div className="mb-1 font-medium uppercase tracking-wide text-muted-foreground"> <div className="mb-1 font-medium uppercase tracking-wide text-muted-foreground">
Output {t("Output")}</div>
</div>
<pre className="max-h-64 overflow-auto whitespace-pre-wrap break-all font-mono text-[11px] leading-relaxed"> <pre className="max-h-64 overflow-auto whitespace-pre-wrap break-all font-mono text-[11px] leading-relaxed">
{out} {out}
</pre> </pre>
</div> </div>
) : ( ) : (
p.state !== "output-available" && ( p.state !== "output-available" && (
<div className="text-muted-foreground italic">running…</div> <div className="text-muted-foreground italic">{t("running…")}</div>
) )
)} )}
</div> </div>
+13 -12
View File
@@ -1,4 +1,6 @@
"use client"; "use client";
import { useI18n } from "@/components/i18n";
import { useContext, useState } from "react"; import { useContext, useState } from "react";
import { ChatActionsContext } from "@/components/chat"; import { ChatActionsContext } from "@/components/chat";
@@ -61,6 +63,7 @@ export function VisualOutputCard({
messageId?: string; messageId?: string;
initialInverted?: boolean; initialInverted?: boolean;
}) { }) {
const { t } = useI18n();
const result = output as VisualResult | undefined; const result = output as VisualResult | undefined;
const overlay = useSessionMermaid(); const overlay = useSessionMermaid();
const actions = useContext(ChatActionsContext); const actions = useContext(ChatActionsContext);
@@ -83,8 +86,7 @@ export function VisualOutputCard({
if (!result || typeof result.svg !== "string") { if (!result || typeof result.svg !== "string") {
return ( return (
<div className="rounded-xl border bg-card p-4 text-sm text-muted-foreground"> <div className="rounded-xl border bg-card p-4 text-sm text-muted-foreground">
Figure completed, but the result could not be displayed. {t("Figure completed, but the result could not be displayed.")}</div>
</div>
); );
} }
const failed = result.svg.length === 0; const failed = result.svg.length === 0;
@@ -92,10 +94,9 @@ export function VisualOutputCard({
return ( return (
<div className="rounded-xl border bg-card p-4 text-card-foreground" data-testid="visual-card-failed"> <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"> <div className="flex items-center gap-2 text-xs font-medium uppercase tracking-wide text-muted-foreground">
Figure {t("Figure")}<span className="rounded bg-red-500/15 px-1.5 py-0.5 text-red-500">{t("failed")}</span>
<span className="rounded bg-red-500/15 px-1.5 py-0.5 text-red-500">failed</span>
</div> </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> </div>
); );
} }
@@ -130,21 +131,21 @@ export function VisualOutputCard({
data-testid="visual-svg" data-testid="visual-svg"
/> />
<div className="mt-2 flex items-center justify-between gap-2"> <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"> <div className="flex items-center gap-1.5">
{/* The invert affordance (owner round, 2026-10-07): the disc IS {/* The invert affordance (owner round, 2026-10-07): the disc IS
the half-filled contrast circle; the tooltip names it. */} the half-filled contrast circle; the tooltip names it. */}
<Toggle <Toggle
checked={inverted} checked={inverted}
onChange={toggleInverted} onChange={toggleInverted}
label="Invert figure brightness" label={t("Invert figure brightness")}
title="Invert colors" title={t("Invert colors")}
discIcon={<InvertDiscIcon className="size-3" />} discIcon={<InvertDiscIcon className="size-3" />}
/> />
<button <button
type="button" type="button"
aria-label="Download figure as SVG" aria-label={t("Download figure as SVG")}
title="Download as SVG" title={t("Download as SVG")}
onClick={() => downloadFile("figure.svg", result.svg, "image/svg+xml")} onClick={() => downloadFile("figure.svg", result.svg, "image/svg+xml")}
data-testid="visual-download" data-testid="visual-download"
className="rounded-md p-1.5 text-muted-foreground transition-colors hover:bg-accent hover:text-foreground" 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 && ( {overlay && (
<button <button
type="button" type="button"
aria-label="Open figure fullscreen" aria-label={t("Open figure fullscreen")}
title="Open fullscreen" title={t("Open fullscreen")}
onClick={() => overlay.openFigure(result.svg, inverted)} onClick={() => overlay.openFigure(result.svg, inverted)}
data-testid="visual-open" data-testid="visual-open"
className="rounded-md p-1.5 text-muted-foreground transition-colors hover:bg-accent hover:text-foreground" 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"; import { cn } from "@/lib/utils";
/** /**
@@ -43,12 +46,13 @@ export function BrailleSpinner({
/** The accessible name of the state this spinner announces. */ /** The accessible name of the state this spinner announces. */
label?: string; label?: string;
}) { }) {
const { t } = useI18n();
return ( return (
<svg <svg
viewBox="0 0 4 8" viewBox="0 0 4 8"
data-testid="braille-spinner" data-testid="braille-spinner"
role="status" role="status"
aria-label={label} aria-label={t(label)}
className={cn("h-3 w-auto shrink-0 text-foreground", className)} className={cn("h-3 w-auto shrink-0 text-foreground", className)}
> >
{FRAMES.map((frame, k) => ( {FRAMES.map((frame, k) => (
+26 -26
View File
@@ -1,4 +1,7 @@
"use client"; "use client";
import { countLabel } from "@/src/core";
import { useI18n } from "@/components/i18n";
/** /**
* THE CHAIN TABLE (chains overlay; owner refinements round 2, 2026-09-29): * THE CHAIN TABLE (chains overlay; owner refinements round 2, 2026-09-29):
@@ -143,6 +146,7 @@ export function ChainTableBody({
selectMode?: boolean; selectMode?: boolean;
onSelect?: () => void; onSelect?: () => void;
}) { }) {
const { t, language } = useI18n();
const byId = useMemo(() => new Map(nodes.map((n) => [n.id, n])), [nodes]); const byId = useMemo(() => new Map(nodes.map((n) => [n.id, n])), [nodes]);
const chains = useMemo(() => chainsForSelection(nodes, storedChains), [nodes, storedChains]); const chains = useMemo(() => chainsForSelection(nodes, storedChains), [nodes, storedChains]);
const searching = query.trim() !== ""; const searching = query.trim() !== "";
@@ -246,7 +250,7 @@ export function ChainTableBody({
law: later in the DOM). */} law: later in the DOM). */}
<TopBar> <TopBar>
<span className="min-w-0 flex-1 truncate text-xs text-muted-foreground"> <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> </span>
<div className="relative shrink-0"> <div className="relative shrink-0">
<SearchIcon className="absolute left-2.5 top-1/2 size-3.5 -translate-y-1/2 text-muted-foreground" /> <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} value={query}
data-testid="chain-table-search" data-testid="chain-table-search"
onChange={(e) => onQuery(e.target.value)} onChange={(e) => onQuery(e.target.value)}
placeholder="Search nodes…" placeholder={t("Search nodes…")}
aria-label="Search nodes" aria-label={t("Search nodes")}
spellCheck={false} 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" 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" type="button"
data-testid="chain-table-close" data-testid="chain-table-close"
onClick={onClose} 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" 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" /> <XIcon className="size-4" />
@@ -288,7 +292,7 @@ export function ChainTableBody({
aria-sort={sort.col === "name" ? (sort.dir === 1 ? "ascending" : "descending") : "none"} 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" className="flex flex-1 items-center gap-1 text-left transition-colors hover:text-foreground"
> >
Name {dirIcon("name")} {t("Name")}{" "}{dirIcon("name")}
</button> </button>
<button <button
type="button" type="button"
@@ -297,7 +301,7 @@ export function ChainTableBody({
aria-sort={sort.col === "props" ? (sort.dir === 1 ? "ascending" : "descending") : "none"} 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" 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>
<button <button
type="button" type="button"
@@ -306,14 +310,13 @@ export function ChainTableBody({
aria-sort={sort.col === "created" ? (sort.dir === 1 ? "ascending" : "descending") : "none"} 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" 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> </button>
</div> </div>
{groups.length === 0 && ( {groups.length === 0 && (
<div data-testid="chain-table-nomatch" className="px-4 pt-3 text-xs text-muted-foreground"> <div data-testid="chain-table-nomatch" className="px-4 pt-3 text-xs text-muted-foreground">
No matching chains. {t("No matching chains.")}</div>
</div>
)} )}
{groups.map((group) => { {groups.map((group) => {
@@ -348,15 +351,15 @@ export function ChainTableBody({
indeterminate={selectedCount > 0 && selectedCount < group.nodeIds.length} indeterminate={selectedCount > 0 && selectedCount < group.nodeIds.length}
onToggle={(next) => onToggleNodes(group.nodeIds, next)} onToggle={(next) => onToggleNodes(group.nodeIds, next)}
testid={`cluster-checkbox-${group.key}`} 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"> <span className="flex shrink-0 items-center justify-center text-muted-foreground">
{open ? <FolderOpenIcon className="size-3.5" /> : <FolderIcon className="size-3.5" />} {open ? <FolderOpenIcon className="size-3.5" /> : <FolderIcon className="size-3.5" />}
</span> </span>
<span className="min-w-0 flex-1 truncate text-sm font-medium"> <span className="min-w-0 flex-1 truncate text-sm font-medium">
{group.label ?? <span className="text-muted-foreground/60">unlabeled</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">notes</span>} {group.kind === "notes" && <span className="ml-2 text-[11px] uppercase tracking-widest text-muted-foreground/60">{t("notes")}</span>}
</span> </span>
<span className="w-14 shrink-0 text-right text-xs tabular-nums text-muted-foreground"> <span className="w-14 shrink-0 text-right text-xs tabular-nums text-muted-foreground">
{group.nodeIds.length} {group.nodeIds.length}
@@ -397,9 +400,9 @@ export function ChainTableBody({
indeterminate={selectedInChain > 0 && selectedInChain < memberIds.length} indeterminate={selectedInChain > 0 && selectedInChain < memberIds.length}
onToggle={(next) => onToggleNodes(memberIds, next)} onToggle={(next) => onToggleNodes(memberIds, next)}
testid={`chain-checkbox-${chain.id}`} 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 {/* The chain line: proposition names as faint-outlined
chips. THE LOGIC IS THE PAYLOAD (owner rulings, chips. THE LOGIC IS THE PAYLOAD (owner rulings,
2026-09-30): premises join with the LaTeX LAND (∧), 2026-09-30): premises join with the LaTeX LAND (∧),
@@ -465,7 +468,7 @@ export function ChainTableBody({
checked={selection.has(id)} checked={selection.has(id)}
onToggle={(next) => onToggleNodes([id], next)} onToggle={(next) => onToggleNodes([id], next)}
testid={`prop-checkbox-${id}`} 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"> <span className="min-w-0 flex-1 text-xs leading-relaxed text-muted-foreground">
{node.statement} {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" 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"> <span data-testid="chain-selected-summary" className="text-xs font-medium">
{selection.size} {selection.size === 1 ? "node" : "nodes"} →{" "} {countLabel(language, selection.size, "node", "nodes")} →{" "}
{derived.length} {derived.length === 1 ? "chain" : "chains"} {countLabel(language, derived.length, "chain", "chains")}
</span> </span>
<button <button
type="button" type="button"
@@ -502,19 +505,17 @@ export function ChainTableBody({
onClick={onClear} onClick={onClear}
className="rounded-full px-2.5 py-1 text-xs text-muted-foreground transition-colors hover:bg-accent hover:text-foreground" className="rounded-full px-2.5 py-1 text-xs text-muted-foreground transition-colors hover:bg-accent hover:text-foreground"
> >
Clear {t("Clear")}</button>
</button>
{selectMode ? ( {selectMode ? (
<button <button
type="button" type="button"
data-testid="chain-practice-done" data-testid="chain-practice-done"
onClick={onSelect} 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" 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" /> <DumbbellIcon aria-hidden="true" className="size-3.5" />
Done {t("Done")}</button>
</button>
) : ( ) : (
<> <>
{/* The danger fence (owner ruling): a divider quarantines {/* The danger fence (owner ruling): a divider quarantines
@@ -526,12 +527,11 @@ export function ChainTableBody({
type="button" type="button"
data-testid="chain-delete" data-testid="chain-delete"
onClick={onDelete} 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" 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" /> <Trash2Icon aria-hidden="true" className="size-3.5" />
Delete {t("Delete")}</button>
</button>
</> </>
)} )}
</div> </div>
+23 -25
View File
@@ -1,4 +1,6 @@
"use client"; "use client";
import { useI18n } from "@/components/i18n";
import { createContext, Fragment, useCallback, useEffect, useLayoutEffect, useMemo, useRef, useState } from "react"; import { createContext, Fragment, useCallback, useEffect, useLayoutEffect, useMemo, useRef, useState } from "react";
import { useChatSession } from "@/components/session"; import { useChatSession } from "@/components/session";
@@ -225,6 +227,7 @@ export function Chat({
* only decides whether the pill rides the composer. */ * only decides whether the pill rides the composer. */
initialConfigured?: boolean; initialConfigured?: boolean;
}) { }) {
const { t } = useI18n();
// Reattach plumbing (background-turn runner wave): the hook holds the // Reattach plumbing (background-turn runner wave): the hook holds the
// settled terminal's callback — a stable ref assigned below, once // settled terminal's callback — a stable ref assigned below, once
// syncFromServer exists (refs, not closures: no stale capture). // syncFromServer exists (refs, not closures: no stale capture).
@@ -1236,8 +1239,8 @@ export function Chat({
}} }}
onBlur={() => void commitRename(titleDraft)} onBlur={() => void commitRename(titleDraft)}
spellCheck={false} spellCheck={false}
aria-label="Session title" aria-label={t("Session title")}
title="Rename this session" 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]" 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 {/* flex-1 spacer during rename too, so the chips stay at
@@ -1249,7 +1252,7 @@ export function Chat({
<> <>
<Hint <Hint
// F2 renames (registry) — surfaced on the hover. // F2 renames (registry) — surfaced on the hover.
label="Rename session" label={t("Rename session")}
action="rename-session" action="rename-session"
side="bottom" side="bottom"
> >
@@ -1273,7 +1276,7 @@ export function Chat({
structure beside the phase chip — access to the full-DAG popup structure beside the phase chip — access to the full-DAG popup
at every width; the marginalia is the wide-screen glance view. */} at every width; the marginalia is the wide-screen glance view. */}
{planTree && ( {planTree && (
<Hint label="Open the full plan" side="bottom"> <Hint label={t("Open the full plan")} side="bottom">
<button <button
type="button" type="button"
data-testid="plan-chip" 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" 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 /> <WaypointsIcon className="size-3.5 text-primary" aria-hidden />
Plan {t("Plan")}</button>
</button>
</Hint> </Hint>
)} )}
{/* The extraction ring (owner-locked redesign, 2026-09-24): fills {/* 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" 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" /> <DumbbellIcon className="size-3" />
Practice {t("Practice")}</span>
</span>
) : ( ) : (
<PhaseChip phase={phases.at(-1) ?? "probe"} /> <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`}> <div data-session-find-root="" className={`${COLUMN} flex flex-1 flex-col gap-4 pt-4`}>
{messages.length === 0 && !busy && ( {messages.length === 0 && !busy && (
<ConversationEmptyState <ConversationEmptyState
title="New session" title={t("New session")}
description="The teacher will open by probing what you already know." 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" 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" /> <ArrowRightIcon className="size-3" />
Next problem {t("Next problem")}</div>
</div>
); );
default: default:
return null; return null;
@@ -1432,7 +1432,7 @@ export function Chat({
{/* The rail GROUPS (owner round 4, 2026-10-05): the YOU heading {/* The rail GROUPS (owner round 4, 2026-10-05): the YOU heading
sits INSIDE the rail, same mono-xs register and same 1ch sits INSIDE the rail, same mono-xs register and same 1ch
rail as the thinking rows — one quote geometry. */} 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"> <div className="lh-prose whitespace-pre-wrap text-foreground">
{m.parts.filter((p) => p.type === "text").map((p) => p.text).join("\n")} {m.parts.filter((p) => p.type === "text").map((p) => p.text).join("\n")}
</div> </div>
@@ -1465,8 +1465,7 @@ export function Chat({
) { ) {
return ( return (
<SystemLine key={`retry-${pi}`} testId="tool-retry"> <SystemLine key={`retry-${pi}`} testId="tool-retry">
Retrying… {t("Retrying…")}</SystemLine>
</SystemLine>
); );
} }
switch (part.type) { switch (part.type) {
@@ -1562,21 +1561,20 @@ export function Chat({
BEFORE beginTurn; the settle kick runs after endTurn). */} BEFORE beginTurn; the settle kick runs after endTurn). */}
{compacting && ( {compacting && (
<span className="flex items-center gap-2 font-ui text-xs font-medium uppercase tracking-wide text-muted-foreground"> <span className="flex items-center gap-2 font-ui text-xs font-medium uppercase tracking-wide text-muted-foreground">
<BrailleSpinner label="Compacting context" /> <BrailleSpinner label={t("Compacting context")} />
<Shimmer duration={1}>COMPACTING CONTEXT…</Shimmer> <Shimmer duration={1}>{t("COMPACTING CONTEXT…")}</Shimmer>
</span> </span>
)} )}
{(status === "submitted" || turnInFlight) && ( {(status === "submitted" || turnInFlight) && (
<span className="flex items-center gap-2 font-ui text-xs font-medium uppercase tracking-wide text-muted-foreground"> <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}> <Shimmer duration={1}>
THINKING… {t("THINKING…")}</Shimmer>
</Shimmer>
</span> </span>
)} )}
{error && ( {error && (
<div className="rounded-lg border border-destructive/50 bg-destructive/10 px-3 py-2 text-sm"> <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>
)} )}
</div> </div>
@@ -1625,8 +1623,7 @@ export function Chat({
onClick={() => setSetupOpen(true)} 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" 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 {t("Set up your models")}</button>
</button>
</div> </div>
) )
) )
@@ -1650,7 +1647,7 @@ export function Chat({
// applies. // applies.
nextProblem={nextReady} nextProblem={nextReady}
onNext={sendNext} 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) => { onSubmitAnswer={(output) => {
@@ -1889,6 +1886,7 @@ function DockOverlay({
* home and /chat/new carry, above the composer while slots are unset. */ * home and /chat/new carry, above the composer while slots are unset. */
setupCta?: React.ReactNode; setupCta?: React.ReactNode;
}) { }) {
const { t } = useI18n();
const { isAtBottom, scrollToBottom, scrollRef } = useStickToBottomContext(); const { isAtBottom, scrollToBottom, scrollRef } = useStickToBottomContext();
// Render-fresh bottom state (flicker round): the arrival block below reads // 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 // 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 mid-question unmount), and widening past the floor fades it
back in. */} back in. */}
{pending && plan && ( {pending && plan && (
<Hint label="Open the full plan" side="bottom"> <Hint label={t("Open the full plan")} side="bottom">
<button <button
type="button" type="button"
data-testid="plan-marginalia" data-testid="plan-marginalia"
+7 -4
View File
@@ -1,4 +1,6 @@
"use client"; "use client";
import { useI18n } from "@/components/i18n";
import type { CompactionDetails } from "@/src/core"; import type { CompactionDetails } from "@/src/core";
@@ -19,11 +21,11 @@ export interface CompactionDividerData {
* history was just summarized; the cut divider below carries the seam. * history was just summarized; the cut divider below carries the seam.
* Inert since release polish 2026-10-06 — plain text, no affordance. */ * Inert since release polish 2026-10-06 — plain text, no affordance. */
export function CompactionMomentDivider({ data: _data }: { data: CompactionDividerData }) { export function CompactionMomentDivider({ data: _data }: { data: CompactionDividerData }) {
const { t } = useI18n();
return ( return (
<div className="flex w-full items-center justify-center py-1"> <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"> <span data-testid="compaction-divider" className="px-1.5 text-xs text-muted-foreground/70">
Context compacted {t("Context compacted")}</span>
</span>
</div> </div>
); );
} }
@@ -35,12 +37,13 @@ export function CompactionMomentDivider({ data: _data }: { data: CompactionDivid
* since release polish 2026-10-06 — the token count stays, the summary * since release polish 2026-10-06 — the token count stays, the summary
* is inspectable only in provider logs. */ * is inspectable only in provider logs. */
export function CompactionCutDivider({ data }: { data: CompactionDividerData }) { 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 ( return (
<div data-testid="compaction-cut-divider" className="my-4 flex w-full items-center gap-3"> <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="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"> <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>
<span className="h-0 flex-1 border-t-2 border-dotted border-border/50" aria-hidden /> <span className="h-0 flex-1 border-t-2 border-dotted border-border/50" aria-hidden />
</div> </div>
+5 -3
View File
@@ -1,4 +1,6 @@
"use client"; "use client";
import { useI18n } from "@/components/i18n";
import type { ReactNode } from "react"; import type { ReactNode } from "react";
import { DumbbellIcon } from "lucide-react"; import { DumbbellIcon } from "lucide-react";
@@ -25,6 +27,7 @@ export function NodePickerControl({ count, onOpen, onClear }: {
onOpen?: () => void; onOpen?: () => void;
onClear?: () => void; onClear?: () => void;
}) { }) {
const { t } = useI18n();
return ( return (
<div className="flex items-center gap-1.5"> <div className="flex items-center gap-1.5">
<button <button
@@ -39,7 +42,7 @@ export function NodePickerControl({ count, onOpen, onClear }: {
} }
> >
<DumbbellIcon aria-hidden="true" className="size-3.5 text-muted-foreground" /> <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> </button>
{count > 0 && ( {count > 0 && (
<button <button
@@ -48,8 +51,7 @@ export function NodePickerControl({ count, onOpen, onClear }: {
onClick={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" 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 {t("Clear")}</button>
</button>
)} )}
</div> </div>
); );
+7 -5
View File
@@ -1,4 +1,6 @@
"use client"; "use client";
import { useI18n } from "@/components/i18n";
import { ArrowRightIcon, CornerDownLeftIcon, SquareIcon } from "lucide-react"; import { ArrowRightIcon, CornerDownLeftIcon, SquareIcon } from "lucide-react";
import { cn } from "@/lib/utils"; import { cn } from "@/lib/utils";
@@ -75,6 +77,7 @@ export function Composer({
* empty-Enter share it. */ * empty-Enter share it. */
onNext?: () => void; onNext?: () => void;
}) { }) {
const { t } = useI18n();
const act = () => { const act = () => {
const action = enterAction({ busy, canSend, nextProblem }); const action = enterAction({ busy, canSend, nextProblem });
if (action === "stop") onStop(); if (action === "stop") onStop();
@@ -113,7 +116,7 @@ export function Composer({
act(); act();
} }
}} }}
placeholder={placeholder} placeholder={t(placeholder)}
autoFocus={autoFocus} autoFocus={autoFocus}
rows={1} 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" 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 <button
type="submit" type="submit"
data-testid="next-problem-button" 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" 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 {t("Next problem")}<ArrowRightIcon className="size-4" />
<ArrowRightIcon className="size-4" />
</button> </button>
) : ( ) : (
<button <button
type="submit" type="submit"
aria-label={busy && !canSend ? "Stop" : "Send"} aria-label={busy && !canSend ? t("Stop") : t("Send")}
disabled={!busy && !canSend} disabled={!busy && !canSend}
className={cn( className={cn(
"flex size-10 shrink-0 items-center justify-center rounded-[10px] transition-colors", "flex size-10 shrink-0 items-center justify-center rounded-[10px] transition-colors",
+13 -11
View File
@@ -1,4 +1,6 @@
"use client"; "use client";
import { useI18n } from "@/components/i18n";
import { useEffect, useState } from "react"; import { useEffect, useState } from "react";
import { createPortal } from "react-dom"; import { createPortal } from "react-dom";
@@ -45,17 +47,18 @@ export function DeleteNodeBody({
onClose: () => void; onClose: () => void;
onRun: () => void; onRun: () => void;
}) { }) {
const { t } = useI18n();
return ( return (
<div <div
role="dialog" role="dialog"
aria-modal="true" aria-modal="true"
aria-label="Delete node" aria-label={t("Delete node")}
data-testid="delete-node-dialog" data-testid="delete-node-dialog"
onClick={(e) => e.stopPropagation()} onClick={(e) => e.stopPropagation()}
className="w-[32rem] max-w-[calc(100vw-2rem)] rounded-xl border bg-card p-5 shadow-xl" 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"> <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> </h2>
{/* The node name(s) below the title in JetBrains Mono (owner, {/* The node name(s) below the title in JetBrains Mono (owner,
2026-09-24): the name is the payload — mono suits it, and 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"> <code data-testid="node-names-code" className="font-mono text-sm text-foreground">
{nodes.slice(0, 8).map((n) => n.name).join(", ")} {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> </code>
)} )}
</div> </div>
<p className="mt-2 text-sm text-muted-foreground" data-testid="node-permanence-line"> <p className="mt-2 text-sm text-muted-foreground" data-testid="node-permanence-line">
{nodes.length === 1 {nodes.length === 1
? "This permanently deletes the node — this cannot be undone." ? t("This permanently deletes the node — this cannot be undone.")
: `This permanently deletes all ${nodes.length} nodes — this cannot be undone.`} : t("This permanently deletes all {v0} nodes — this cannot be undone.", { v0: nodes.length })}
</p> </p>
{failure && ( {failure && (
<div <div
@@ -90,7 +93,7 @@ export function DeleteNodeBody({
className="mt-3 rounded-sm border border-border/60 px-3 py-2 text-xs" className="mt-3 rounded-sm border border-border/60 px-3 py-2 text-xs"
> >
<span className="text-foreground"> <span className="text-foreground">
{failure.code === "extraction_running" ? EXTRACTION_RETRY_LINE : failure.error} {t(failure.code === "extraction_running" ? EXTRACTION_RETRY_LINE : failure.error)}
</span> </span>
<button <button
type="button" type="button"
@@ -99,8 +102,7 @@ export function DeleteNodeBody({
disabled={busy} disabled={busy}
className="ml-3 text-xs text-foreground underline-offset-2 hover:underline disabled:opacity-40" className="ml-3 text-xs text-foreground underline-offset-2 hover:underline disabled:opacity-40"
> >
Try again {t("Try again")}</button>
</button>
</div> </div>
)} )}
<div className="mt-4 flex justify-end gap-2"> <div className="mt-4 flex justify-end gap-2">
@@ -111,8 +113,7 @@ export function DeleteNodeBody({
disabled={busy} disabled={busy}
className="rounded-sm px-3 py-1.5 text-xs text-muted-foreground transition-colors hover:text-foreground disabled:opacity-40" className="rounded-sm px-3 py-1.5 text-xs text-muted-foreground transition-colors hover:text-foreground disabled:opacity-40"
> >
Cancel {t("Cancel")}</button>
</button>
<button <button
type="button" type="button"
data-testid="node-delete-confirm" data-testid="node-delete-confirm"
@@ -123,7 +124,7 @@ export function DeleteNodeBody({
// refusal banner): border + text, fill on hover. // 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" 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> </button>
</div> </div>
</div> </div>
@@ -146,6 +147,7 @@ export function DeleteNodeDialog({
* selection-side callers can prune. */ * selection-side callers can prune. */
onDeleted: (nodeIds: string[], graphRevision: number) => void; onDeleted: (nodeIds: string[], graphRevision: number) => void;
}) { }) {
const { t } = useI18n();
const [busy, setBusy] = useState(false); const [busy, setBusy] = useState(false);
const backdropDismiss = useBackdropDismiss(() => { const backdropDismiss = useBackdropDismiss(() => {
if (!busy) onClose(); if (!busy) onClose();
+13 -13
View File
@@ -1,4 +1,6 @@
"use client"; "use client";
import { useI18n } from "@/components/i18n";
import { useEffect, useState } from "react"; import { useEffect, useState } from "react";
import { createPortal } from "react-dom"; import { createPortal } from "react-dom";
@@ -50,6 +52,7 @@ export function DeleteSessionDialog({
/** Success path — the sidebar owns navigation + list patching. */ /** Success path — the sidebar owns navigation + list patching. */
onDeleted: (id: string, mode: ChatDeleteMode, graphRevision: number) => void; onDeleted: (id: string, mode: ChatDeleteMode, graphRevision: number) => void;
}) { }) {
const { t } = useI18n();
const [traces, setTraces] = useState(false); const [traces, setTraces] = useState(false);
const [busy, setBusy] = useState(false); const [busy, setBusy] = useState(false);
const backdropDismiss = useBackdropDismiss(() => { const backdropDismiss = useBackdropDismiss(() => {
@@ -155,16 +158,17 @@ export function DeleteSessionBody({
onClose: () => void; onClose: () => void;
onRun: () => void; onRun: () => void;
}) { }) {
const { t } = useI18n();
return ( return (
<div <div
role="dialog" role="dialog"
aria-modal="true" aria-modal="true"
aria-label="Delete session" aria-label={t("Delete session")}
data-testid="delete-session-dialog" data-testid="delete-session-dialog"
onClick={(e) => e.stopPropagation()} onClick={(e) => e.stopPropagation()}
className="w-[32rem] max-w-[calc(100vw-2rem)] rounded-xl border bg-card p-5 shadow-xl" 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, {/* The session name below the title in JetBrains Mono (owner,
2026-09-24): no code-block chrome — the font already says 2026-09-24): no code-block chrome — the font already says
code. Owner, 2026-10-13 polish: normal text-sm size (was code. Owner, 2026-10-13 polish: normal text-sm size (was
@@ -176,9 +180,7 @@ export function DeleteSessionBody({
</code> </code>
</div> </div>
<p className="mt-2 text-sm text-muted-foreground"> <p className="mt-2 text-sm text-muted-foreground">
This permanently removes the session — its messages, background jobs, and extraction records. Everything {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>
it added to the graph stays unless you check below. This cannot be undone.
</p>
<label <label
data-testid={TRACES_COPY.testid} 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" 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" className="mt-0.5 size-4 shrink-0 accent-primary"
/> />
<span className="min-w-0"> <span className="min-w-0">
<span className="block text-sm text-foreground">{TRACES_COPY.title}</span> <span className="block text-sm text-foreground">{t(TRACES_COPY.title)}</span>
<span className="mt-1 block text-xs text-muted-foreground">{TRACES_COPY.body}</span> <span className="mt-1 block text-xs text-muted-foreground">{t(TRACES_COPY.body)}</span>
</span> </span>
</label> </label>
{failure && ( {failure && (
@@ -202,7 +204,7 @@ export function DeleteSessionBody({
data-testid="delete-failure" data-testid="delete-failure"
className="mt-3 rounded-sm border border-border/60 px-3 py-2 text-xs" 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 <button
type="button" type="button"
data-testid="delete-retry" data-testid="delete-retry"
@@ -210,8 +212,7 @@ export function DeleteSessionBody({
disabled={busy} disabled={busy}
className="ml-3 text-xs text-foreground underline-offset-2 hover:underline disabled:opacity-40" className="ml-3 text-xs text-foreground underline-offset-2 hover:underline disabled:opacity-40"
> >
Try again {t("Try again")}</button>
</button>
</div> </div>
)} )}
{/* The node dialog's exact bottom row: quiet Cancel, destructive {/* The node dialog's exact bottom row: quiet Cancel, destructive
@@ -225,8 +226,7 @@ export function DeleteSessionBody({
disabled={busy} disabled={busy}
className="rounded-sm px-3 py-1.5 text-xs text-muted-foreground transition-colors hover:text-foreground disabled:opacity-40" className="rounded-sm px-3 py-1.5 text-xs text-muted-foreground transition-colors hover:text-foreground disabled:opacity-40"
> >
Cancel {t("Cancel")}</button>
</button>
<button <button
type="button" type="button"
data-testid="delete-confirm" data-testid="delete-confirm"
@@ -234,7 +234,7 @@ export function DeleteSessionBody({
disabled={busy} 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" 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> </button>
</div> </div>
</div> </div>
+134 -30
View File
@@ -3,12 +3,17 @@
import { useCallback, useEffect, useRef, useState } from "react"; import { useCallback, useEffect, useRef, useState } from "react";
import { Loader2Icon, MicIcon } from "lucide-react"; import { Loader2Icon, MicIcon } from "lucide-react";
import { useSettingsDialog } from "@/components/settings/settings-dialog"; import { useSettingsDialog } from "@/components/settings/settings-dialog";
import { DEFAULT_STT_MODEL, OPENROUTER_STT_MODEL, STT_MAX_AUDIO_BYTES, WAV_HEADER_BYTES, dictationUrl, dictationWav, type SttModel, type SttLanguage } from "@/src/core";
import { LanguageSelect, useI18n } from "@/components/i18n";
import { Hint } from "@/components/ui/hint"; import { Hint } from "@/components/ui/hint";
/** /**
* Voice dictation — the cloud engine (STT wave 1, 2026-09-17). * Voice dictation — direct Deepgram streaming or whole-recording OpenRouter STT.
* MAI-Transcribe-2 records locally, drains the final PCM frame on Stop, then
* sends one WAV through /api/stt/transcribe. That route holds the API key.
* Both engines buffer text and append once when dictation finishes.
* *
* Architecture (docs/research/stt.md): the renderer captures mic audio in * Deepgram architecture: the renderer captures mic audio in
* an AudioWorklet (public/worklets/pcm-capture.js → linear16 16kHz mono), * an AudioWorklet (public/worklets/pcm-capture.js → linear16 16kHz mono),
* opens the Deepgram streaming WebSocket DIRECTLY (audio never loops * opens the Deepgram streaming WebSocket DIRECTLY (audio never loops
* through our server) with a short-lived token minted by /api/stt/token * through our server) with a short-lived token minted by /api/stt/token
@@ -86,15 +91,7 @@ import { Hint } from "@/components/ui/hint";
* endpointing=300, no interims. Silence streams too (the worklet never * endpointing=300, no interims. Silence streams too (the worklet never
* pauses), so the socket never idles — no KeepAlive needed. * 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. */ /** Meter tuning — the owner's tested values, see the file header. */
const METER_FLOOR_DB = -50; const METER_FLOOR_DB = -50;
@@ -113,6 +110,7 @@ export const DEEPGRAM_KEY_ID = "DEEPGRAM_API_KEY";
* instead. Exported for pins; the errored idle button's click recognizes * instead. Exported for pins; the errored idle button's click recognizes
* it and deep-links Settings → Keys → the Deepgram row. */ * it and deep-links Settings → Keys → the Deepgram row. */
export const DICTATION_KEYLESS = "Dictation needs a Deepgram key"; export const DICTATION_KEYLESS = "Dictation needs a Deepgram key";
export const OPENROUTER_DICTATION_KEYLESS = "Dictation needs an OpenRouter key";
/** Mint a fresh short-lived token (the route holds the real key /** Mint a fresh short-lived token (the route holds the real key
* server-side). Module-level: the start path's warm cache and the * server-side). Module-level: the start path's warm cache and the
@@ -245,7 +243,7 @@ export function useDictationTarget(append: (text: string) => void, claimed: bool
}, [append, claimed]); }, [append, claimed]);
} }
export type DictationPhase = "idle" | "connecting" | "recording" | "stopping"; export type DictationPhase = "idle" | "connecting" | "recording" | "stopping" | "transcribing";
export type Dictation = { export type Dictation = {
phase: DictationPhase; phase: DictationPhase;
@@ -285,6 +283,18 @@ export type Dictation = {
* question waits. * question waits.
*/ */
export function useDictation({ append }: { append: (text: string) => void }): Dictation { export function useDictation({ append }: { append: (text: string) => void }): Dictation {
const { sttLanguage, general, setDictating } = useI18n();
const selectedUrl = useRef("");
selectedUrl.current = dictationUrl(sttLanguage, general.sttPreferredLanguages);
const recordingUrl = useRef(selectedUrl.current);
const selected = useRef({ model: general.sttModel ?? DEFAULT_STT_MODEL, language: sttLanguage });
selected.current = { model: general.sttModel ?? DEFAULT_STT_MODEL, language: sttLanguage };
const recordingConfig = useRef<{ model: SttModel; language: SttLanguage }>(selected.current);
const audioFrames = useRef<Uint8Array[]>([]);
const audioBytes = useRef(0);
const upload = useRef<AbortController | null>(null);
const drain = useRef<(() => void) | null>(null);
const stopping = useRef(false);
const [phase, setPhase] = useState<DictationPhase>("idle"); const [phase, setPhase] = useState<DictationPhase>("idle");
const [error, setError] = useState<string | null>(null); const [error, setError] = useState<string | null>(null);
/** Latest RMS from the worklet — the meter samples this (dictate pattern). */ /** Latest RMS from the worklet — the meter samples this (dictate pattern). */
@@ -305,7 +315,7 @@ export function useDictation({ append }: { append: (text: string) => void }): Di
/** Frames captured before the socket is OPEN (mitigation A): flushed in /** Frames captured before the socket is OPEN (mitigation A): flushed in
* order on open; capped at 256 frames (≈32s) with the oldest dropped; * order on open; capped at 256 frames (≈32s) with the oldest dropped;
* cleared at teardown so a dead run's audio can't leak into the next. */ * cleared at teardown so a dead run's audio can't leak into the next. */
const pendingRef = useRef<ArrayBuffer[]>([]); const pendingRef = useRef<Array<ArrayBuffer | Int16Array>>([]);
const genRef = useRef(0); // stale-handler guard (dictate pattern) const genRef = useRef(0); // stale-handler guard (dictate pattern)
const safetyRef = useRef<ReturnType<typeof setTimeout> | null>(null); const safetyRef = useRef<ReturnType<typeof setTimeout> | null>(null);
const reconnectsRef = useRef(0); const reconnectsRef = useRef(0);
@@ -329,7 +339,15 @@ export function useDictation({ append }: { append: (text: string) => void }): Di
/** Full teardown of one run's machinery. Idempotent; generation-guarded by callers. */ /** Full teardown of one run's machinery. Idempotent; generation-guarded by callers. */
const cleanup = useCallback(() => { const cleanup = useCallback(() => {
genRef.current++; genRef.current++;
upload.current?.abort();
upload.current = null;
drain.current?.();
drain.current = null;
audioFrames.current = [];
audioBytes.current = 0;
stopping.current = false;
dictationActiveFlag = false; dictationActiveFlag = false;
setDictating(false);
flush(); // the deferred transcript appears when the run ends (owner round 2) flush(); // the deferred transcript appears when the run ends (owner round 2)
if (safetyRef.current) { if (safetyRef.current) {
clearTimeout(safetyRef.current); clearTimeout(safetyRef.current);
@@ -357,7 +375,7 @@ export function useDictation({ append }: { append: (text: string) => void }): Di
for (const w of waitersRef.current.splice(0)) w(); for (const w of waitersRef.current.splice(0)) w();
// Pre-mint for the next run (mitigation D) — best-effort; a keyless // Pre-mint for the next run (mitigation D) — best-effort; a keyless
// page just doesn't warm. // page just doesn't warm.
warmTokens.refresh(); if (selected.current.model === DEFAULT_STT_MODEL) warmTokens.refresh();
}, []); }, []);
/** Open the Deepgram socket and wire its messages. `gen` guards staleness. */ /** Open the Deepgram socket and wire its messages. `gen` guards staleness. */
@@ -366,7 +384,7 @@ export function useDictation({ append }: { append: (text: string) => void }): Di
// Minted JWTs authenticate via the "bearer" subprotocol — ["token", // Minted JWTs authenticate via the "bearer" subprotocol — ["token",
// jwt] is rejected at the handshake (verified live; "token" is for // jwt] is rejected at the handshake (verified live; "token" is for
// raw API keys only, as in the owner's pi dictate extension). // raw API keys only, as in the owner's pi dictate extension).
const ws = new WebSocket(DG_URL, ["bearer", token]); const ws = new WebSocket(recordingUrl.current, ["bearer", token]);
wsRef.current = ws; wsRef.current = ws;
ws.addEventListener("open", () => { ws.addEventListener("open", () => {
@@ -426,6 +444,14 @@ export function useDictation({ append }: { append: (text: string) => void }): Di
); );
const start = useCallback(async () => { const start = useCallback(async () => {
// Pin the complete configuration once; reconnects must retain both language and hints.
recordingUrl.current = selectedUrl.current;
recordingConfig.current = { ...selected.current };
const batch = recordingConfig.current.model === OPENROUTER_STT_MODEL;
audioFrames.current = [];
audioBytes.current = 0;
stopping.current = false;
setDictating(true);
dictationActiveFlag = true; dictationActiveFlag = true;
setError(null); setError(null);
setPhase("connecting"); setPhase("connecting");
@@ -440,7 +466,17 @@ export function useDictation({ append }: { append: (text: string) => void }): Di
// the token runs concurrently with it. 127.0.0.1 is a secure // the token runs concurrently with it. 127.0.0.1 is a secure
// context, so getUserMedia works without TLS; macOS TCC prompts on // context, so getUserMedia works without TLS; macOS TCC prompts on
// first use. // first use.
const tokenPromise = warmTokens.mint(); const controller = new AbortController();
upload.current = controller;
const tokenPromise = batch ? fetch("/api/stt/transcribe", { signal: controller.signal }).then(async (response) => {
if (!response.ok) throw new Error("OpenRouter transcription is unavailable");
const status = await response.json() as { configured?: boolean };
if (!status.configured) throw new Error(OPENROUTER_DICTATION_KEYLESS);
return "";
}).catch((error: unknown) => {
if (error instanceof Error && error.message === OPENROUTER_DICTATION_KEYLESS) throw error;
throw new Error("OpenRouter transcription is unavailable");
}) : warmTokens.mint();
tokenPromise.catch(() => {}); // surfaced below; no unhandled rejection tokenPromise.catch(() => {}); // surfaced below; no unhandled rejection
const micPromise = navigator.mediaDevices.getUserMedia({ const micPromise = navigator.mediaDevices.getUserMedia({
audio: { channelCount: 1, echoCancellation: true, noiseSuppression: true, autoGainControl: true }, audio: { channelCount: 1, echoCancellation: true, noiseSuppression: true, autoGainControl: true },
@@ -464,10 +500,26 @@ export function useDictation({ append }: { append: (text: string) => void }): Di
const worklet = new AudioWorkletNode(ctx, "pcm-capture"); const worklet = new AudioWorkletNode(ctx, "pcm-capture");
workletRef.current = worklet; workletRef.current = worklet;
worklet.port.onmessage = (e) => { worklet.port.onmessage = (e) => {
const msg = e.data as { type: string; rms?: number; buffer?: ArrayBuffer }; if (gen !== genRef.current) return;
const msg = e.data as { type: string; rms?: number; buffer?: ArrayBuffer | Int16Array };
if (msg.type === "level") { if (msg.type === "level") {
levelRef.current = msg.rms ?? 0; // never reset to zero (dictate pattern) levelRef.current = msg.rms ?? 0; // never reset to zero (dictate pattern)
} else if (msg.type === "flushed") {
drain.current?.();
drain.current = null;
} else if (msg.type === "audio") { } else if (msg.type === "audio") {
if (batch) {
const raw = msg.buffer!;
const bytes = raw instanceof ArrayBuffer ? new Uint8Array(raw) : new Uint8Array(raw.buffer, raw.byteOffset, raw.byteLength);
if (audioBytes.current + bytes.byteLength + WAV_HEADER_BYTES > STT_MAX_AUDIO_BYTES) {
setError("Recording exceeds OpenRouter's 25 MB audio limit");
cleanup();
return;
}
audioFrames.current.push(bytes);
audioBytes.current += bytes.byteLength;
return;
}
const ws = wsRef.current; const ws = wsRef.current;
if (ws && ws.readyState === WebSocket.OPEN) ws.send(msg.buffer!); if (ws && ws.readyState === WebSocket.OPEN) ws.send(msg.buffer!);
// Mitigation A: not OPEN yet (handshake or reconnect gap) — // Mitigation A: not OPEN yet (handshake or reconnect gap) —
@@ -509,12 +561,15 @@ export function useDictation({ append }: { append: (text: string) => void }): Di
if (gen !== genRef.current) return; if (gen !== genRef.current) return;
// Mitigation B: the handshake fires the moment the graph is live, // Mitigation B: the handshake fires the moment the graph is live,
// overlapping any remaining token wait — mic-gated, never before it. // overlapping any remaining token wait — mic-gated, never before it.
openSocket(gen, token); if (batch) { if (!stopping.current) setPhase("recording"); }
else openSocket(gen, token);
}, [openSocket, cleanup]); }, [openSocket, cleanup]);
const stop = useCallback(() => { const stop = useCallback(() => {
const gen = genRef.current; const gen = genRef.current;
if (phase !== "recording" && phase !== "connecting") return; if (phase !== "recording" && phase !== "connecting") return;
if (stopping.current) return;
stopping.current = true;
setPhase("stopping"); setPhase("stopping");
// Mic dies FIRST — no more audio enqueues (dictate pattern). The // Mic dies FIRST — no more audio enqueues (dictate pattern). The
@@ -523,6 +578,43 @@ export function useDictation({ append }: { append: (text: string) => void }): Di
streamRef.current = null; streamRef.current = null;
sourceRef.current?.disconnect(); sourceRef.current?.disconnect();
sourceRef.current = null; sourceRef.current = null;
if (recordingConfig.current.model === OPENROUTER_STT_MODEL) {
const worklet = workletRef.current;
if (!worklet) { cleanup(); return; }
void (async () => {
try {
// Source is disconnected; drain its last partial PCM frame before wrapping the WAV.
await new Promise<void>((resolve, reject) => {
const timer = setTimeout(() => { drain.current = null; reject(new Error("Could not finish microphone capture")); }, 2000);
drain.current = () => { clearTimeout(timer); resolve(); };
worklet.port.postMessage({ type: "flush" });
});
if (gen !== genRef.current) return;
worklet.disconnect();
workletRef.current = null;
if (audioBytes.current === 0) { cleanup(); return; }
setPhase("transcribing");
const wav = dictationWav(audioFrames.current);
audioFrames.current = [];
const params = new URLSearchParams(recordingConfig.current);
const response = await fetch(`/api/stt/transcribe?${params}`, {
method: "POST", headers: { "Content-Type": "audio/wav" }, body: wav,
signal: AbortSignal.any([upload.current!.signal, AbortSignal.timeout(75_000)]),
});
const body = await response.json() as { text?: string; message?: string };
if (gen !== genRef.current) return;
if (!response.ok) throw new Error(body.message ?? "OpenRouter transcription is unavailable");
if (typeof body.text !== "string") throw new Error("OpenRouter returned an invalid transcript");
if (body.text.trim()) bufferRef.current.push(body.text.trim());
cleanup();
} catch (error) {
if (gen !== genRef.current) return;
setError(error instanceof Error && error.name === "TimeoutError" ? "OpenRouter transcription timed out" : error instanceof Error && error.name === "Error" ? error.message : "OpenRouter transcription is unavailable");
cleanup();
}
})();
return;
}
workletRef.current?.disconnect(); workletRef.current?.disconnect();
workletRef.current = null; workletRef.current = null;
@@ -585,8 +677,13 @@ export function useDictation({ append }: { append: (text: string) => void }): Di
// page owns ONE hook instance, and only the mounted page's listener is // page owns ONE hook instance, and only the mounted page's listener is
// attached. // attached.
useEffect(() => { useEffect(() => {
warmTokens.refresh(); // pre-mint at mount (mitigation D) — silent if keyless if ((general.sttModel ?? DEFAULT_STT_MODEL) === DEFAULT_STT_MODEL) warmTokens.refresh();
}, []); }, [general.sttModel]);
useEffect(() => () => {
// Navigation cancels a pending upload and releases the microphone without inserting text.
bufferRef.current = [];
cleanup();
}, [cleanup]);
useEffect(() => { useEffect(() => {
window.addEventListener("lh:kbd-toggle-dictation", toggle); window.addEventListener("lh:kbd-toggle-dictation", toggle);
return () => window.removeEventListener("lh:kbd-toggle-dictation", toggle); return () => window.removeEventListener("lh:kbd-toggle-dictation", toggle);
@@ -622,15 +719,15 @@ export function useDictation({ append }: { append: (text: string) => void }): Di
* (full text in the tooltip), cleared on the next click. * (full text in the tooltip), cleared on the next click.
*/ */
export function DictationBar({ dictation }: { dictation: Dictation }) { export function DictationBar({ dictation }: { dictation: Dictation }) {
const { t } = useI18n();
const { phase, error, levelRef, toggle, active, clearError } = dictation; const { phase, error, levelRef, toggle, active, clearError } = dictation;
const { open: openSettings } = useSettingsDialog(); const { open: openSettings } = useSettingsDialog();
/** The keyless click (zero-keys wave, owner dictation trio): the errored /** The keyless click (zero-keys wave, owner dictation trio): the errored
* idle button deep-links Settings → Keys → the Deepgram row and clears * idle button deep-links Settings → Keys → the Deepgram row and clears
* the error — the surface is clean when the user returns. Any other * the error — the surface is clean when the user returns. Any other
* error keeps the old behavior (click = retry, which clears). */ * error keeps the old behavior (click = retry, which clears). */
const keyless = error === DICTATION_KEYLESS; const keyless = error === DICTATION_KEYLESS || error === OPENROUTER_DICTATION_KEYLESS;
const [levels, setLevels] = useState<number[]>(() => new Array(METER_CELLS).fill(0)); const [levels, setLevels] = useState<number[]>(() => new Array(METER_CELLS).fill(0));
const recording = phase === "recording" || phase === "stopping";
/** Adaptive cell count (owner round 2): derived from the meter's /** Adaptive cell count (owner round 2): derived from the meter's
* measured width — narrow rows show fewer bars instead of * measured width — narrow rows show fewer bars instead of
* overflowing under neighbors. */ * overflowing under neighbors. */
@@ -673,12 +770,13 @@ export function DictationBar({ dictation }: { dictation: Dictation }) {
if (!active) { if (!active) {
return ( return (
<div className="flex w-full min-w-0 items-center gap-1.5">
<Hint <Hint
// Errored: the button span truncates the line — the tooltip is its // Errored: the button span truncates the line — the tooltip is its
// full reveal (the keyless line KEEPS "Deepgram" — owner overrule: // full reveal (the keyless line KEEPS "Deepgram" — owner overrule:
// it's the Keys row label, the truth the user needs). Otherwise the // it's the Keys row label, the truth the user needs). Otherwise the
// quiet label + the live ⌘D binding. // quiet label + the live ⌘D binding.
label={error ?? "Dictate"} label={error ? t(error) : t("Dictate")}
action={error ? undefined : "toggle-dictation"} action={error ? undefined : "toggle-dictation"}
side="top" side="top"
> >
@@ -687,37 +785,43 @@ export function DictationBar({ dictation }: { dictation: Dictation }) {
onClick={() => { onClick={() => {
if (keyless) { if (keyless) {
clearError(); clearError();
openSettings("keys", DEEPGRAM_KEY_ID); openSettings("keys", error === OPENROUTER_DICTATION_KEYLESS ? "OPENROUTER_API_KEY" : DEEPGRAM_KEY_ID);
} else { } else {
toggle(); toggle();
} }
}} }}
aria-label={error ? `Dictation failed: ${error}` : "Start dictation"} aria-label={error ? t("Dictation failed: {v0}", { v0: t(error) }) : t("Start dictation")}
data-testid="dictation-toggle" data-testid="dictation-toggle"
// Same blurring as the floating chrome (owner, fix wave round 3), // Same blurring as the floating chrome (owner, fix wave round 3),
// but lower opacity — the mic stays quieter than the model pills. // 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" 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" /> <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> </button>
</Hint> </Hint>
<LanguageSelect field="sttLanguage" compact />
</div>
); );
} }
if (phase === "transcribing") return <div className="flex h-7 w-full min-w-0 items-center gap-2 text-xs text-muted-foreground" role="status" data-testid="dictation-transcribing">
<Loader2Icon className="size-3.5 shrink-0 animate-spin" /><span className="min-w-0 flex-1 truncate">{t("Transcribing…")}</span>
<button type="button" onClick={dictation.cancel} className="shrink-0 rounded-sm border border-border px-1.5 py-1 hover:bg-muted">{t("Cancel")}</button>
</div>;
return ( return (
<div className="flex w-full min-w-0 items-center gap-2" data-testid="dictation-bar"> <div className="flex w-full min-w-0 items-center gap-2" data-testid="dictation-bar">
<Hint <Hint
// The owner flagged this copy good ("Stop dictation (Esc)") — // The owner flagged this copy good ("Stop dictation (Esc)") —
// kept verbatim; only the machinery moved to the styled tooltip. // kept verbatim; only the machinery moved to the styled tooltip.
label="Stop dictation (Esc)" label={t("Stop dictation (Esc)")}
side="top" side="top"
> >
<button <button
type="button" type="button"
onClick={toggle} onClick={toggle}
disabled={phase === "stopping"} disabled={phase === "stopping"}
aria-label="Stop dictation" aria-label={t("Stop dictation")}
data-testid="dictation-toggle" 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" 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 +830,13 @@ export function DictationBar({ dictation }: { dictation: Dictation }) {
) : ( ) : (
<MicIcon className="size-3.5" /> <MicIcon className="size-3.5" />
)} )}
Stop {t("Stop")}</button>
</button>
</Hint> </Hint>
<span className="size-1.5 shrink-0 animate-pulse rounded-full bg-destructive" aria-hidden /> <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 {/* Fixed width while recording — nothing ever appends text beside it
(no interims, owner round 1). Cells carry no minimum width; the (no interims, owner round 1). Cells carry no minimum width; the
COUNT adapts to the measured row space (owner round 2). */} 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"> <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) => ( {levels.slice(-cellCount).map((v, i) => (
<span <span
+22 -23
View File
@@ -1,4 +1,6 @@
"use client"; "use client";
import { useI18n } from "@/components/i18n";
import { useCallback, useEffect, useRef, useState } from "react"; import { useCallback, useEffect, useRef, useState } from "react";
import { useActivity } from "@/components/activity-dock"; import { useActivity } from "@/components/activity-dock";
@@ -183,6 +185,7 @@ export function CoverageMenuBody({
extracting: boolean; extracting: boolean;
onExtract: () => void; onExtract: () => void;
}) { }) {
const { t, language } = useI18n();
const busy = coverage.running || extracting; const busy = coverage.running || extracting;
const complete = coverage.uncoveredTokens === 0 && coverage.uncoveredCount === 0; const complete = coverage.uncoveredTokens === 0 && coverage.uncoveredCount === 0;
return ( return (
@@ -190,17 +193,15 @@ export function CoverageMenuBody({
{busy ? ( {busy ? (
<span className="flex items-center gap-1.5 text-muted-foreground" data-testid="ctx-coverage-running"> <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 /> <span className="size-1.5 animate-pulse rounded-full bg-foreground/70" aria-hidden />
extracting… {t("extracting…")}</span>
</span>
) : complete ? ( ) : 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="flex flex-col">
<span className="tabular-nums text-foreground"> <span className="tabular-nums text-foreground">
{formatTokens(coverage.uncoveredTokens)} tok uncovered {formatTokens(coverage.uncoveredTokens, language)}{" "}{t("tok uncovered")}</span>
</span>
<span className="tabular-nums text-muted-foreground"> <span className="tabular-nums text-muted-foreground">
{coverage.uncoveredCount} message{coverage.uncoveredCount === 1 ? "" : "s"} unextracted {t("{count} messages unextracted", { count: coverage.uncoveredCount })}
</span> </span>
</span> </span>
)} )}
@@ -211,7 +212,7 @@ export function CoverageMenuBody({
disabled={busy || complete} 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" 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> </button>
</div> </div>
); );
@@ -241,6 +242,7 @@ export function ringFill(uncoveredTokens: number, chunkTokens: number): number {
* threshold/kill-switch re-read live). * threshold/kill-switch re-read live).
*/ */
export function CoverageRing({ chatId, refreshKey = 0 }: { chatId: string; refreshKey?: number }) { export function CoverageRing({ chatId, refreshKey = 0 }: { chatId: string; refreshKey?: number }) {
const { t } = useI18n();
const [graph, setGraph] = useState<GraphRead | null>(null); const [graph, setGraph] = useState<GraphRead | null>(null);
const [coverage, setCoverage] = useState<SessionCoverage | null>(null); const [coverage, setCoverage] = useState<SessionCoverage | null>(null);
const [open, setOpen] = useState(false); 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"> <div ref={rootRef} className="lh-no-drag relative shrink-0">
<Hint <Hint
// Trimmed (owner, subtlety round 2026-09-25). // 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" side="bottom"
> >
<button <button
@@ -359,46 +361,43 @@ export function CoverageRingGlyph({ fill, running }: { fill: number; running: bo
* covered/uncovered tokens, message coverage, the threshold, the last * covered/uncovered tokens, message coverage, the threshold, the last
* fold's age. */ * fold's age. */
export function CoverageRingDetail({ coverage, chunkTokens }: { coverage: SessionCoverage; chunkTokens: number }) { export function CoverageRingDetail({ coverage, chunkTokens }: { coverage: SessionCoverage; chunkTokens: number }) {
const { t, language } = useI18n();
return ( return (
<> <>
<div className="mb-2 text-xs font-medium uppercase tracking-wide text-muted-foreground"> <div className="mb-2 text-xs font-medium uppercase tracking-wide text-muted-foreground">
Extraction coverage {t("Extraction coverage")}</div>
</div>
<div className="flex flex-col gap-1 text-muted-foreground"> <div className="flex flex-col gap-1 text-muted-foreground">
<div className="flex justify-between gap-2"> <div className="flex justify-between gap-2">
<span>Uncovered</span> <span>{t("Uncovered")}</span>
<span className="tabular-nums text-card-foreground"> <span className="tabular-nums text-card-foreground">
{formatTokens(coverage.uncoveredTokens)} tokens {formatTokens(coverage.uncoveredTokens, language)}{" "}{t("tokens")}</span>
</span>
</div> </div>
<div className="flex justify-between gap-2"> <div className="flex justify-between gap-2">
<span>Covered</span> <span>{t("Covered")}</span>
<span className="tabular-nums text-card-foreground"> <span className="tabular-nums text-card-foreground">
{formatTokens(coverage.coveredTokens)} tokens {formatTokens(coverage.coveredTokens, language)}{" "}{t("tokens")}</span>
</span>
</div> </div>
<div className="flex justify-between gap-2"> <div className="flex justify-between gap-2">
<span>Messages</span> <span>{t("Messages")}</span>
<span className="tabular-nums text-card-foreground"> <span className="tabular-nums text-card-foreground">
{coverage.coveredCount} / {coverage.totalMessages} {coverage.coveredCount} / {coverage.totalMessages}
</span> </span>
</div> </div>
<div className="flex justify-between gap-2"> <div className="flex justify-between gap-2">
<span>Chunk threshold</span> <span>{t("Chunk threshold")}</span>
<span className="tabular-nums text-card-foreground"> <span className="tabular-nums text-card-foreground">
{formatTokens(chunkTokens)} tokens {formatTokens(chunkTokens, language)}{" "}{t("tokens")}</span>
</span>
</div> </div>
<div className="flex justify-between gap-2"> <div className="flex justify-between gap-2">
<span>Last extraction</span> <span>{t("Last extraction")}</span>
<span className="tabular-nums text-card-foreground"> <span className="tabular-nums text-card-foreground">
{coverage.lastFoldedAt ? timeAgo(coverage.lastFoldedAt) : "—"} {coverage.lastFoldedAt ? timeAgo(coverage.lastFoldedAt) : "—"}
</span> </span>
</div> </div>
{coverage.running && ( {coverage.running && (
<div className="flex justify-between gap-2" data-testid="coverage-ring-running"> <div className="flex justify-between gap-2" data-testid="coverage-ring-running">
<span>Status</span> <span>{t("Status")}</span>
<span className="text-card-foreground">extracting…</span> <span className="text-card-foreground">{t("extracting…")}</span>
</div> </div>
)} )}
</div> </div>
+6 -2
View File
@@ -1,4 +1,7 @@
"use client"; "use client";
import { countLabel } from "@/src/core";
import { useI18n } from "@/components/i18n";
import { useState } from "react"; import { useState } from "react";
import { ChevronRightIcon } from "lucide-react"; import { ChevronRightIcon } from "lucide-react";
@@ -21,9 +24,10 @@ export interface GraphContextData {
} }
export function GraphContextCard({ data }: { data: GraphContextData }) { export function GraphContextCard({ data }: { data: GraphContextData }) {
const { t, language } = useI18n();
const [open, setOpen] = useState(false); const [open, setOpen] = useState(false);
const { propositions = [], chains = [] } = data ?? {}; 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 ( return (
<div data-testid="graph-context-card" className="my-1"> <div data-testid="graph-context-card" className="my-1">
<button <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" 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")} /> <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"> <span className="min-w-0 flex-1 truncate">
{noun(propositions.length, "proposition", "propositions")} {noun(propositions.length, "proposition", "propositions")}
{chains.length > 0 ? ` · ${noun(chains.length, "chain", "chains")}` : ""} {chains.length > 0 ? ` · ${noun(chains.length, "chain", "chains")}` : ""}
+77 -75
View File
@@ -1,4 +1,7 @@
"use client"; "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)"). * The home knowledge-graph view (W4; vision §1 "Rendering (DECIDED)").
@@ -276,7 +279,8 @@ function SettingSlider({
} }
/** Compact token display: 45231 → "45.2k"; absent → "—". */ /** 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 === undefined) return "—";
if (n < 1000) return String(Math.round(n)); if (n < 1000) return String(Math.round(n));
if (n < 1_000_000) return `${(n / 1000).toFixed(1)}k`; 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". */ /** 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); const mins = Math.round((Date.now() - startedAt) / 60_000);
if (mins < 1) return "now"; if (mins < 1) return t("now");
if (mins < 60) return `${mins}m`; if (mins < 60) return t("{v0}m", { v0: mins });
const hours = Math.round(mins / 60); const hours = Math.round(mins / 60);
if (hours < 24) return `${hours}h`; if (hours < 24) return t("{v0}h", { v0: hours });
return `${Math.round(hours / 24)}d`; return t("{v0}d", { v0: Math.round(hours / 24) });
} }
/** Run/stage cost at 3 decimals (pipeline pennies are real money at this /** Run/stage cost at 3 decimals (pipeline pennies are real money at this
* scale); headline totals use 2. Null (unpriced model) renders "—". */ * scale); headline totals use 2. Null (unpriced model) renders "—". */
function fmtRunCost(dollars: number | null, providers: readonly string[] = []): string { function fmtRunCost(dollars: number | null, providers: readonly string[] = [], language: Language = "en"): string {
return planCostLabel(dollars, providers, (value) => `$${value.toFixed(3)}`); 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[] { function graphProviders(value: { subscriptionRuns?: number; subscriptionProviders?: string[] }): string[] {
return value.subscriptionProviders ?? ((value.subscriptionRuns ?? 0) > 0 ? ["ChatGPT"] : []); 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 stages = [costs.stages.extraction, costs.stages.audit];
const providers = [...new Set(stages.flatMap(graphProviders))]; const providers = [...new Set(stages.flatMap(graphProviders))];
const priced = stages.some((stage) => stage.runs - stage.unpricedRuns - (stage.subscriptionRuns ?? 0) > 0); const priced = stages.some((stage) => stage.runs - stage.unpricedRuns - (stage.subscriptionRuns ?? 0) > 0);
const dollars = displayCostTotals(costs).totalDollars; 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 /** Exported for the chains home (graph rendering off, owner
* 2026-10-06): the same bare cumulative-dollars pill + panel. */ * 2026-10-06): the same bare cumulative-dollars pill + panel. */
export function GraphCostControl({ costs }: { costs: GraphCostSummary }) { export function GraphCostControl({ costs }: { costs: GraphCostSummary }) {
const { t, language } = useI18n();
const [open, setOpen] = useState(false); const [open, setOpen] = useState(false);
// Click anywhere outside (or Escape) closes the panel — the root // Click anywhere outside (or Escape) closes the panel — the root
// wraps button + panel, so the toggle itself keeps working. // wraps button + panel, so the toggle itself keeps working.
@@ -349,7 +355,7 @@ export function GraphCostControl({ costs }: { costs: GraphCostSummary }) {
<div ref={rootRef} className="relative"> <div ref={rootRef} className="relative">
<Hint <Hint
// Trimmed (owner, subtlety round 2026-09-25). // Trimmed (owner, subtlety round 2026-09-25).
label="Graph costs — click for details" label={t("Graph costs — click for details")}
side="bottom" side="bottom"
> >
<button <button
@@ -360,7 +366,7 @@ export function GraphCostControl({ costs }: { costs: GraphCostSummary }) {
open ? "text-foreground" : "text-muted-foreground hover:text-foreground" open ? "text-foreground" : "text-muted-foreground hover:text-foreground"
}`} }`}
> >
{graphUsageLabel(costs)} {graphUsageLabel(costs, language)}
</button> </button>
</Hint> </Hint>
{open && ( {open && (
@@ -383,9 +389,10 @@ export function GraphCostControl({ costs }: { costs: GraphCostSummary }) {
* naming-kind runs never reach the list. * naming-kind runs never reach the list.
*/ */
function GraphCostPanel({ costs }: { costs: GraphCostSummary }) { function GraphCostPanel({ costs }: { costs: GraphCostSummary }) {
const { t, language } = useI18n();
const stageRows: [string, GraphCostSummary["stages"]["extraction"]][] = [ const stageRows: [string, GraphCostSummary["stages"]["extraction"]][] = [
["Extraction", costs.stages.extraction], [t("Extraction"), costs.stages.extraction],
["Audit", costs.stages.audit], [t("Audit"), costs.stages.audit],
]; ];
// The displayed figures come from displayCostTotals (naming excluded — // The displayed figures come from displayCostTotals (naming excluded —
// the pill and this panel must agree). // 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="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"> <div className="mb-2 flex items-baseline justify-between">
<span className="text-[10px] font-medium uppercase tracking-wide text-muted-foreground"> <span className="text-[10px] font-medium uppercase tracking-wide text-muted-foreground">
Graph pipeline costs {t("Graph pipeline costs")}</span>
</span>
<span className="text-foreground tabular-nums"> <span className="text-foreground tabular-nums">
{graphUsageLabel(costs)} {graphUsageLabel(costs, language)}
{unpricedRuns > 0 && ( {unpricedRuns > 0 && (
<span className="text-muted-foreground"> · {unpricedRuns} unpriced</span> <span className="text-muted-foreground"> · {unpricedRuns}{" "}{t("unpriced")}</span>
)} )}
</span> </span>
</div> </div>
@@ -408,14 +414,13 @@ function GraphCostPanel({ costs }: { costs: GraphCostSummary }) {
<div key={label} className="flex items-baseline justify-between gap-2 text-[11px]"> <div key={label} className="flex items-baseline justify-between gap-2 text-[11px]">
<span className="shrink-0 text-muted-foreground">{label}</span> <span className="shrink-0 text-muted-foreground">{label}</span>
<span className="min-w-0 text-right tabular-nums"> <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) && ( {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"> <span className="text-muted-foreground">
{" "} {" "}
· {fmtTokens(stage.tokens.inputTokens)} in / {fmtTokens(stage.tokens.outputTokens)} out · {fmtTokens(stage.tokens.inputTokens, language)}{" "}{t("in /")}{" "}{fmtTokens(stage.tokens.outputTokens, language)}{" "}{t("out")}</span>
</span>
</span> </span>
</div> </div>
))} ))}
@@ -424,16 +429,16 @@ function GraphCostPanel({ costs }: { costs: GraphCostSummary }) {
{costs.runs.map((run) => ( {costs.runs.map((run) => (
<div key={run.startedAt} className="flex items-baseline justify-between gap-2 text-[11px]"> <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="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.title}
{run.merged && ( {run.merged && (
<span className="text-muted-foreground"> <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> </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>
))} ))}
</div> </div>
@@ -454,6 +459,7 @@ function GraphSettingsControl({
settings: GraphRenderSettings; settings: GraphRenderSettings;
changeSettings: (update: (cur: RenderSettings) => RenderSettings) => void; changeSettings: (update: (cur: RenderSettings) => RenderSettings) => void;
}) { }) {
const { t } = useI18n();
const [open, setOpen] = useState(false); const [open, setOpen] = useState(false);
// Click anywhere outside (or Escape) closes the panel (2026-09-17) — // Click anywhere outside (or Escape) closes the panel (2026-09-17) —
// the root wraps button + panel, so the toggle itself keeps working. // the root wraps button + panel, so the toggle itself keeps working.
@@ -465,7 +471,7 @@ function GraphSettingsControl({
<div ref={rootRef} className="relative"> <div ref={rootRef} className="relative">
<Hint <Hint
// User language (tooltip wave) — "rendering" is internal. // User language (tooltip wave) — "rendering" is internal.
label="Graph display settings" label={t("Graph display settings")}
side="bottom" side="bottom"
> >
<button <button
@@ -498,7 +504,7 @@ function GraphSettingsControl({
number — src/core/render-settings.ts). Physics keeps the number — src/core/render-settings.ts). Physics keeps the
fine-grained log sliders (step 5). */} fine-grained log sliders (step 5). */}
<SettingSlider <SettingSlider
label="Node size" label={t("Node size")}
min={0} min={0}
max={100} max={100}
step={10} step={10}
@@ -508,7 +514,7 @@ function GraphSettingsControl({
changeSettings((cur) => ({ ...cur, nodeSize: fromLadderPosition(p, NODE_SIZE_LADDER) }))} changeSettings((cur) => ({ ...cur, nodeSize: fromLadderPosition(p, NODE_SIZE_LADDER) }))}
/> />
<SettingSlider <SettingSlider
label="Edge width" label={t("Edge width")}
min={0} min={0}
max={100} max={100}
step={10} step={10}
@@ -518,7 +524,7 @@ function GraphSettingsControl({
changeSettings((cur) => ({ ...cur, edgeWidth: fromLadderPosition(p, EDGE_WIDTH_LADDER) }))} changeSettings((cur) => ({ ...cur, edgeWidth: fromLadderPosition(p, EDGE_WIDTH_LADDER) }))}
/> />
<SettingSlider <SettingSlider
label="Arrowhead size" label={t("Arrowhead size")}
min={0} min={0}
max={100} max={100}
step={10} step={10}
@@ -528,7 +534,7 @@ function GraphSettingsControl({
changeSettings((cur) => ({ ...cur, arrowLength: fromLadderPosition(p, ARROW_LENGTH_LADDER) }))} changeSettings((cur) => ({ ...cur, arrowLength: fromLadderPosition(p, ARROW_LENGTH_LADDER) }))}
/> />
<SettingSlider <SettingSlider
label="Label size" label={t("Label size")}
min={0} min={0}
max={100} max={100}
step={10} step={10}
@@ -539,7 +545,7 @@ function GraphSettingsControl({
/> />
{/* Display is absolute (ratio / 0.5): "1×" is the default, dead-center (owner, 2026-10-07). */} {/* Display is absolute (ratio / 0.5): "1×" is the default, dead-center (owner, 2026-10-07). */}
<SettingSlider <SettingSlider
label="Labels until zoom" label={t("Labels until zoom")}
min={0} min={0}
max={100} max={100}
step={10} step={10}
@@ -557,10 +563,9 @@ function GraphSettingsControl({
the paint. */} the paint. */}
<div className="mt-1.5 flex flex-col gap-1.5 border-t pt-1.5"> <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"> <span className="text-[10px] font-medium uppercase tracking-wide text-muted-foreground">
Regions {t("Regions")}</span>
</span>
<label className="flex cursor-pointer items-center justify-between text-[11px] text-muted-foreground"> <label className="flex cursor-pointer items-center justify-between text-[11px] text-muted-foreground">
<span>Background colors</span> <span>{t("Background colors")}</span>
<input <input
type="checkbox" type="checkbox"
checked={settings.regions.enabled} checked={settings.regions.enabled}
@@ -573,7 +578,7 @@ function GraphSettingsControl({
/> />
</label> </label>
<SettingSlider <SettingSlider
label="Dilation" label={t("Dilation")}
disabled={!regionTuning.enabled} disabled={!regionTuning.enabled}
min={0} min={0}
max={100} max={100}
@@ -587,7 +592,7 @@ function GraphSettingsControl({
}))} }))}
/> />
<SettingSlider <SettingSlider
label="Blur" label={t("Blur")}
disabled={!regionTuning.enabled} disabled={!regionTuning.enabled}
min={0} min={0}
max={100} max={100}
@@ -602,7 +607,7 @@ function GraphSettingsControl({
} }
/> />
<SettingSlider <SettingSlider
label="Edge alpha" label={t("Edge alpha")}
disabled={!regionTuning.enabled} disabled={!regionTuning.enabled}
min={0} min={0}
max={100} max={100}
@@ -618,7 +623,7 @@ function GraphSettingsControl({
<label <label
className={`flex items-center justify-between gap-2 text-[11px] text-muted-foreground${!regionTuning.enabled ? " opacity-40" : ""}`} 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 <select
disabled={!regionTuning.enabled} disabled={!regionTuning.enabled}
className="rounded-md border bg-background px-1.5 py-0.5 text-xs tabular-nums text-foreground" 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 their own clusterLabels toggle. Size is screen px
(constant at any zoom, unlike node labels). */} (constant at any zoom, unlike node labels). */}
<label className="flex cursor-pointer items-center justify-between text-[11px] text-muted-foreground"> <label className="flex cursor-pointer items-center justify-between text-[11px] text-muted-foreground">
<span>Cluster labels</span> <span>{t("Cluster labels")}</span>
<input <input
type="checkbox" type="checkbox"
checked={settings.clusterLabels} checked={settings.clusterLabels}
@@ -652,7 +657,7 @@ function GraphSettingsControl({
/> />
</label> </label>
<SettingSlider <SettingSlider
label="Cluster label size" label={t("Cluster label size")}
disabled={!settings.clusterLabels} disabled={!settings.clusterLabels}
min={0} min={0}
max={100} max={100}
@@ -678,10 +683,9 @@ function GraphSettingsControl({
anchor + its gray-out-able controls last. */} anchor + its gray-out-able controls last. */}
<div className="mt-1.5 flex flex-col gap-1.5 border-t pt-1.5"> <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"> <span className="text-[10px] font-medium uppercase tracking-wide text-muted-foreground">
Physics {t("Physics")}</span>
</span>
<SettingSlider <SettingSlider
label="Node repulsion" label={t("Node repulsion")}
min={0} min={0}
max={100} max={100}
step={5} step={5}
@@ -694,7 +698,7 @@ function GraphSettingsControl({
}))} }))}
/> />
<SettingSlider <SettingSlider
label="Edge attraction" label={t("Edge attraction")}
min={0} min={0}
max={100} max={100}
step={5} step={5}
@@ -707,7 +711,7 @@ function GraphSettingsControl({
}))} }))}
/> />
<SettingSlider <SettingSlider
label="Edge length" label={t("Edge length")}
min={0} min={0}
max={100} max={100}
step={5} step={5}
@@ -720,7 +724,7 @@ function GraphSettingsControl({
}))} }))}
/> />
<SettingSlider <SettingSlider
label="Central pull" label={t("Central pull")}
min={0} min={0}
max={100} max={100}
step={5} step={5}
@@ -733,12 +737,12 @@ function GraphSettingsControl({
}))} }))}
/> />
<SettingSlider <SettingSlider
label="Cluster pull" label={t("Cluster pull")}
min={0} min={0}
max={100} max={100}
step={5} step={5}
value={toOffSliderPosition(physicsTuning.clusterPull, CLUSTER_PULL_RANGE.min, CLUSTER_PULL_RANGE.max)} 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) => onChange={(p) =>
changeSettings((cur) => ({ changeSettings((cur) => ({
...cur, ...cur,
@@ -746,7 +750,7 @@ function GraphSettingsControl({
}))} }))}
/> />
<SettingSlider <SettingSlider
label="Friction" label={t("Friction")}
min={0} min={0}
max={100} max={100}
step={5} step={5}
@@ -763,8 +767,7 @@ function GraphSettingsControl({
controls at the very bottom — the grayed group reads controls at the very bottom — the grayed group reads
as a self-contained sub-section. */} as a self-contained sub-section. */}
<label className="flex items-center justify-between gap-2 text-xs"> <label className="flex items-center justify-between gap-2 text-xs">
Depth anchor {t("Depth anchor")}<select
<select
className="rounded border bg-background px-1 py-0.5 text-xs" className="rounded border bg-background px-1 py-0.5 text-xs"
value={physicsTuning.depthMode} value={physicsTuning.depthMode}
onChange={(e) => onChange={(e) =>
@@ -774,13 +777,13 @@ function GraphSettingsControl({
})) }))
} }
> >
<option value="off">Off</option> <option value="off">{t("Off")}</option>
<option value="vertical">Vertical (up)</option> <option value="vertical">{t("Vertical (up)")}</option>
<option value="radial">Radial (outward)</option> <option value="radial">{t("Radial (outward)")}</option>
</select> </select>
</label> </label>
<SettingSlider <SettingSlider
label="Depth strength" label={t("Depth strength")}
disabled={physicsTuning.depthMode === "off"} disabled={physicsTuning.depthMode === "off"}
min={0} min={0}
max={100} max={100}
@@ -794,7 +797,7 @@ function GraphSettingsControl({
}))} }))}
/> />
<SettingSlider <SettingSlider
label="Depth spacing" label={t("Depth spacing")}
disabled={physicsTuning.depthMode === "off"} disabled={physicsTuning.depthMode === "off"}
min={0} min={0}
max={100} max={100}
@@ -808,7 +811,7 @@ function GraphSettingsControl({
}))} }))}
/> />
<SettingSlider <SettingSlider
label="Base ring radius" label={t("Base ring radius")}
disabled={physicsTuning.depthMode !== "radial"} disabled={physicsTuning.depthMode !== "radial"}
min={0} min={0}
max={100} max={100}
@@ -866,13 +869,14 @@ export function GraphChromeRow({
* 2026-09-29). */ * 2026-09-29). */
onOpenChains: () => void; onOpenChains: () => void;
}) { }) {
const { t } = useI18n();
return ( return (
<> <>
{!isEmpty && ( {!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"> <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 <Hint
// ⌘F focuses node search (surface-scoped registry action). // ⌘F focuses node search (surface-scoped registry action).
label="Search nodes" label={t("Search nodes")}
action="find" action="find"
side="bottom" side="bottom"
> >
@@ -882,15 +886,15 @@ export function GraphChromeRow({
ref={searchInputRef} ref={searchInputRef}
value={query} value={query}
onChange={(e) => onQuery(e.target.value)} onChange={(e) => onQuery(e.target.value)}
placeholder="Search nodes…" placeholder={t("Search nodes…")}
aria-label="Search nodes" aria-label={t("Search nodes")}
spellCheck={false} 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" className="h-7 w-full rounded-lg border bg-background pl-8 text-xs outline-none placeholder:text-muted-foreground focus:border-ring"
/> />
</div> </div>
</Hint> </Hint>
{noMatch && ( {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> </div>
)} )}
@@ -898,7 +902,7 @@ export function GraphChromeRow({
container (36px band), between the search and Import. */} container (36px band), between the search and Import. */}
{!isEmpty && ( {!isEmpty && (
<div className="graph-chrome-fade-in shrink-0 rounded-xl border bg-card/85 px-1 py-[3px] shadow-lg backdrop-blur"> <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 <button
type="button" type="button"
onClick={onOpenChains} 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" 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" /> <ListTreeIcon className="size-3" />
Chains {t("Chains")}</button>
</button>
</Hint> </Hint>
</div> </div>
)} )}
@@ -929,7 +932,7 @@ export function GraphChromeRow({
// The one button whose action isn't obvious from its label: // The one button whose action isn't obvious from its label:
// the missing fact is where imports go (owner, subtlety // the missing fact is where imports go (owner, subtlety
// round 2026-09-25). // round 2026-09-25).
label="Import notes into the graph" label={t("Import notes into the graph")}
side="bottom" side="bottom"
> >
<button <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" 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" /> <UploadIcon className="size-3" />
{importing ? "Importing…" : "Import notes…"} {importing ? t("Importing…") : t("Import notes…")}
</button> </button>
</Hint> </Hint>
</div> </div>
@@ -974,6 +977,7 @@ export function GraphView({
onPracticeSelectionChange?: (nodes: { id: string; name: string; statement: string }[]) => void; onPracticeSelectionChange?: (nodes: { id: string; name: string; statement: string }[]) => void;
onChainSelectClose?: () => void; onChainSelectClose?: () => void;
}) { }) {
const { t } = useI18n();
const containerRef = useRef<HTMLDivElement | null>(null); const containerRef = useRef<HTMLDivElement | null>(null);
const canvasRef = useRef<HTMLCanvasElement | null>(null); const canvasRef = useRef<HTMLCanvasElement | null>(null);
const cardRef = useRef<HTMLDivElement | 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 h-full w-full items-center justify-center px-4">
<div className="flex flex-col items-center gap-3 text-center text-muted-foreground"> <div className="flex flex-col items-center gap-3 text-center text-muted-foreground">
<WaypointsIcon className="size-10 opacity-40" strokeWidth={1.25} /> <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"> <p className="max-w-sm text-sm">
Start a session or import notes to build your graph. {t("Start a session or import notes to build your graph.")}</p>
</p>
</div> </div>
</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" 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 {t("Delete")}</button>
</button>
</MenuShell> </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" 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" /> <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="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>
))} ))}
</div> </div>
@@ -1652,7 +1654,7 @@ export function GraphView({
<div className="mt-1 text-xs leading-relaxed text-muted-foreground">{selected.node.statement}</div> <div className="mt-1 text-xs leading-relaxed text-muted-foreground">{selected.node.statement}</div>
{selected.sourceIds.length > 0 ? ( {selected.sourceIds.length > 0 ? (
<div className="mt-3 flex flex-col gap-1"> <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) => { {selected.sourceIds.map((sourceId) => {
// An imported-notes file: a STATIC entry — the path, // An imported-notes file: a STATIC entry — the path,
// plain text, never a link (no session to open). // plain text, never a link (no session to open).
@@ -1681,7 +1683,7 @@ export function GraphView({
})} })}
</div> </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> </div>
)} )}
+7 -5
View File
@@ -1,4 +1,6 @@
"use client"; "use client";
import { useI18n } from "@/components/i18n";
import { useCallback, useEffect, useMemo, useRef, useState } from "react"; import { useCallback, useEffect, useMemo, useRef, useState } from "react";
import { useRouter } from "next/navigation"; import { useRouter } from "next/navigation";
@@ -30,6 +32,7 @@ export function Home({
* changing the setting applies on the next visit home. */ * changing the setting applies on the next visit home. */
rendering?: boolean; rendering?: boolean;
}) { }) {
const { t } = useI18n();
const router = useRouter(); const router = useRouter();
const [topic, setTopic] = useState(""); const [topic, setTopic] = useState("");
/* The topic MIRROR ("nothing gets lost" round, 2026-10-01 — same /* The topic MIRROR ("nothing gets lost" round, 2026-10-01 — same
@@ -236,8 +239,7 @@ export function Home({
onClick={() => setSetupOpen(true)} 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" 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 {t("Set up your models")}</button>
</button>
) )
)} )}
/> />
@@ -270,7 +272,7 @@ export function Home({
void start(); 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 autoFocus
rows={1} 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" 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). */} affordance (owner, subtlety round 2026-09-25). */}
<button <button
type="submit" type="submit"
aria-label="Start session" aria-label={t("Start session")}
disabled={(!topic.trim() && !dictation.active) || busy} 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" 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 === "practice" ? <DumbbellIcon aria-hidden="true" className="size-3" /> : null}
{m === "teach" ? "Teach" : "Practice"} {m === "teach" ? t("Teach") : t("Practice")}
</button> </button>
))} ))}
</div> </div>
+212
View File
@@ -0,0 +1,212 @@
"use client";
import { createContext, useCallback, useContext, useEffect, useId, useMemo, useRef, useState, type ReactNode } from "react";
import { DEFAULT_STT_MODEL, OPENROUTER_STT_MODEL, STT_MODELS, resolveLanguage, resolveSttLanguage, translator, type GeneralSettings, type Language, type SttLanguage, type SttModel, type Translator } from "@/src/core";
import { notifySettingsSaved, useSettingsSaved } from "./settings-signal";
import { MENU_CLOSE_EVENT, MENU_OPEN_EVENT, MenuShell } from "./menu-shell";
import { CheckIcon } from "lucide-react";
import { DropdownMenu } from "radix-ui";
type LanguageField = "language" | "sttLanguage";
type I18nValue = {
language: Language;
sttLanguage: SttLanguage;
general: GeneralSettings;
dictating: boolean;
setDictating: (active: boolean) => void;
t: Translator;
saveGeneral: (patch: Partial<GeneralSettings>) => Promise<void>;
saveLanguage: <Field extends LanguageField>(field: Field, value: GeneralSettings[Field]) => 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 = resolveSttLanguage(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", "sttModel", "sttPreferredLanguages", "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 extends LanguageField,>(field: Field, value: GeneralSettings[Field]) => 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(props: { field: LanguageField; disabled?: boolean; compact?: boolean }) {
return <GeneralSelect {...props} />;
}
export function SttModelSelect() { return <GeneralSelect field="sttModel" />; }
function GeneralSelect({ field, disabled = false, compact = false }: { field: LanguageField | "sttModel"; disabled?: boolean; compact?: boolean }) {
const { general, t, saveGeneral, sttLanguage, dictating } = useI18n();
const [saving, setSaving] = useState(false);
const [error, setError] = useState<string | null>(null);
const [open, setOpen] = useState(false);
const trigger = useRef<HTMLButtonElement>(null);
const menuId = useId();
const locked = disabled || saving || (field !== "language" && dictating);
const menuOpen = open && !locked;
const compactStt = compact && field === "sttLanguage";
const saved = general[field] ?? (field === "sttModel" ? DEFAULT_STT_MODEL : "auto");
const selected = compactStt && saved === "auto" ? sttLanguage : saved;
const label = t(field === "language" ? "Interface language" : field === "sttModel" ? "Speech recognition model" : "Speech recognition language");
const testid = field === "sttModel" ? "stt-model-select" : `language-select-${field}${compact ? "-compact" : ""}`;
const options: readonly { value: string; label: string }[] = field === "sttModel" ? STT_MODELS.map((model) => ({ value: model.id, label: model.name })) : [
...(!compactStt ? [{ value: "auto", label: t("System default") } as const] : []),
{ value: "en", label: "English" },
{ value: "ru", label: "Русский" },
...(field === "sttLanguage" ? [{ value: "multi", label: t("Multilingual") } as const] : []),
] as const;
useEffect(() => { if (locked) setOpen(false); }, [locked]);
useEffect(() => {
if (!menuOpen) return;
const outside = (event: PointerEvent) => {
const target = event.target as Node;
if (!trigger.current?.contains(target) && !document.getElementById(menuId)?.contains(target)) setOpen(false);
};
const escape = (event: KeyboardEvent) => {
if (event.key !== "Escape") return;
event.preventDefault();
event.stopPropagation();
setOpen(false);
trigger.current?.focus();
};
document.addEventListener("pointerdown", outside);
// Claim Escape before the settings dialog or dictation's outer layer.
document.addEventListener("keydown", escape, true);
return () => {
document.removeEventListener("pointerdown", outside);
document.removeEventListener("keydown", escape, true);
};
}, [menuOpen, menuId]);
const pick = (value: string) => {
if (locked) return;
setOpen(false);
trigger.current?.focus();
if (value === saved) return;
setSaving(true); setError(null);
const patch = field === "sttModel" ? { sttModel: value as SttModel } : { [field]: value === "auto" ? undefined : value };
void saveGeneral(patch).catch((e: unknown) => setError(e instanceof Error ? e.message : "Could not save language settings")).finally(() => setSaving(false));
};
return <span className={compact ? "flex shrink-0 items-center gap-1" : "flex max-w-full shrink-0 flex-col items-end gap-1"}>
<button ref={trigger} type="button" aria-label={label} title={label} aria-haspopup="menu" aria-expanded={menuOpen} aria-controls={menuOpen ? menuId : undefined} disabled={locked} data-testid={testid}
className={`flex h-7 max-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 disabled:cursor-not-allowed disabled:opacity-50 ${compact ? "w-24" : field === "sttModel" ? "w-64" : "w-36"}`}
onClick={() => setOpen(!menuOpen)}
onKeyDown={(event) => {
if (event.key !== "ArrowDown" && event.key !== "ArrowUp") return;
event.preventDefault(); event.stopPropagation(); setOpen(true);
}}>
<span className="min-w-0 flex-1 truncate text-left">{compact && selected === "multi" ? t("Multi") : options.find((option) => option.value === selected)?.label}</span>
<span aria-hidden className="text-muted-foreground">▾</span>
</button>
{menuOpen && <MenuShell anchorRef={trigger} testid={`${testid}-menu`} preferredSide={compact ? "up" : "down"} menuClass={`flex ${field === "sttModel" ? "w-72" : "w-44"} flex-col gap-0.5 rounded-xl border border-border bg-card p-2 text-xs shadow-xl`}>
<div id={menuId} role="menu" aria-label={label} className="flex flex-col gap-0.5">
{options.map((option) => <button key={option.value} type="button" role="menuitemradio" aria-checked={selected === option.value} onClick={() => pick(option.value)}
className={`flex items-center justify-between rounded-lg px-2 py-1.5 text-left transition-colors hover:bg-muted ${selected === option.value ? "bg-muted/60 text-card-foreground" : "text-card-foreground"}`}>
<span>{option.label}</span>{selected === option.value && <span aria-hidden>✓</span>}
</button>)}
</div>
</MenuShell>}
{error && <span role="alert" className="max-w-56 text-xs text-destructive">{t(error)}</span>}
</span>;
}
/** Preferred languages guide multilingual recognition; they are saved independently of its mode. */
export function SttPreferredLanguages() {
const { general, sttLanguage, dictating, saveGeneral, t } = useI18n();
const [saving, setSaving] = useState(false);
const [error, setError] = useState<string | null>(null);
const [open, setOpen] = useState(false);
const busy = useRef(false);
const preferred = general.sttPreferredLanguages ?? [];
const unavailable = sttLanguage !== "multi" || general.sttModel === OPENROUTER_STT_MODEL || dictating;
const menuOpen = open && !unavailable;
const summary = (["en", "ru"] as const).filter((language) => preferred.includes(language)).map((language) => language === "en" ? "English" : "Русский").join(", ") || t("No preference");
useEffect(() => { if (unavailable) setOpen(false); }, [unavailable]);
useEffect(() => {
if (!menuOpen) return;
window.dispatchEvent(new CustomEvent(MENU_OPEN_EVENT));
return () => { window.dispatchEvent(new CustomEvent(MENU_CLOSE_EVENT)); };
}, [menuOpen]);
const toggle = (language: Language) => {
if (unavailable || busy.current) return;
const next = (["en", "ru"] as const).filter((code) => code === language ? !preferred.includes(code) : preferred.includes(code));
busy.current = true;
setSaving(true); setError(null);
void saveGeneral({ sttPreferredLanguages: next })
.catch(() => setError("Could not save preferred languages"))
.finally(() => { busy.current = false; setSaving(false); });
};
return <span className="flex max-w-full shrink-0 flex-col items-end gap-1">
<DropdownMenu.Root open={menuOpen} onOpenChange={setOpen} modal={false}>
<DropdownMenu.Trigger asChild>
<button type="button" data-testid="stt-preferred-select" aria-label={t("Preferred languages")} title={summary} disabled={unavailable || saving}
className={`flex h-7 w-44 max-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 ${unavailable ? "disabled:cursor-not-allowed disabled:opacity-50" : "disabled:cursor-default"}`}
onKeyDown={(event) => { if (event.key === "ArrowUp" && !unavailable && !saving) { event.preventDefault(); event.stopPropagation(); setOpen(true); } }}>
<span className="min-w-0 flex-1 truncate text-left">{summary}</span><span aria-hidden className="text-muted-foreground">▾</span>
</button>
</DropdownMenu.Trigger>
<DropdownMenu.Portal>
<DropdownMenu.Content data-testid="stt-preferred-menu" data-lh-menu="" aria-label={t("Preferred languages")} side="bottom" align="end" sideOffset={4} collisionPadding={8} loop
className="z-60 flex w-44 max-h-[min(60vh,var(--radix-dropdown-menu-content-available-height))] flex-col gap-0.5 overflow-y-auto rounded-xl border border-border bg-card p-2 text-xs shadow-xl outline-none"
onKeyDown={(event) => event.stopPropagation()}
onEscapeKeyDown={(event) => event.stopPropagation()}>
{(["en", "ru"] as const).map((language) => <DropdownMenu.CheckboxItem key={language} data-testid={`stt-preferred-${language}`} checked={preferred.includes(language)} disabled={saving}
onCheckedChange={() => toggle(language)} onSelect={(event) => event.preventDefault()}
className={`flex cursor-default select-none items-center gap-2 rounded-lg px-2 py-1.5 text-left text-card-foreground outline-none transition-colors data-highlighted:bg-muted ${preferred.includes(language) ? "bg-muted/60" : ""}`}>
<span aria-hidden className="flex size-3.5 shrink-0 items-center justify-center rounded-xs border border-border"><DropdownMenu.ItemIndicator><CheckIcon className="size-3" /></DropdownMenu.ItemIndicator></span>
{language === "en" ? "English" : "Русский"}
</DropdownMenu.CheckboxItem>)}
</DropdownMenu.Content>
</DropdownMenu.Portal>
</DropdownMenu.Root>
{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"; "use client";
import { useI18n } from "@/components/i18n";
/** /**
* Keyboard shortcuts (shortcut wave A, 2026-09-17) — the single document-level * 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 * Until the settings milestone ships an editor, this is how shortcuts
* stay discoverable. */ * stay discoverable. */
function CheatSheet({ open, onClose }: { open: boolean; onClose: () => void }) { function CheatSheet({ open, onClose }: { open: boolean; onClose: () => void }) {
const { t } = useI18n();
const backdropDismiss = useBackdropDismiss(onClose); const backdropDismiss = useBackdropDismiss(onClose);
if (!open) return null; if (!open) return null;
const scopes = ["global", "chat", "graph"] as const; const scopes = ["global", "chat", "graph"] as const;
const scopeLabels: Record<(typeof scopes)[number], string> = { const scopeLabels: Record<(typeof scopes)[number], string> = {
global: "Global", global: t("Global"),
chat: "Chat", chat: t("Chat"),
graph: "Graph view", graph: t("Graph view"),
}; };
return ( return (
<div <div
@@ -170,8 +173,8 @@ function CheatSheet({ open, onClose }: { open: boolean; onClose: () => void }) {
onClick={(e) => e.stopPropagation()} onClick={(e) => e.stopPropagation()}
> >
<div className="mb-4 flex items-baseline justify-between"> <div className="mb-4 flex items-baseline justify-between">
<span className="text-sm font-medium text-card-foreground">Keyboard shortcuts</span> <span className="text-sm font-medium text-card-foreground">{t("Keyboard shortcuts")}</span>
<span className="text-xs text-muted-foreground">Esc to close</span> <span className="text-xs text-muted-foreground">{t("Esc to close")}</span>
</div> </div>
{scopes.map((scope) => { {scopes.map((scope) => {
const actions = KEYBOARD_ACTIONS.filter((a) => a.scope === 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"> <div className="flex flex-col gap-1">
{actions.map((a) => ( {actions.map((a) => (
<div key={a.id} className="flex items-center justify-between gap-4 text-sm"> <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"> <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> </kbd>
</div> </div>
))} ))}
+12 -6
View File
@@ -1,4 +1,6 @@
"use client"; "use client";
import { useI18n } from "@/components/i18n";
/** /**
* MARKDOWN BLOCKS, MELTED INTO THE MESSAGE (owner wave, 2026-09-19): the * 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 * still streaming (the trailing open fence), a quiet skeleton instead of a
* doomed parse. */ * doomed parse. */
function MermaidInline({ chart }: { chart: string }) { function MermaidInline({ chart }: { chart: string }) {
const { t } = useI18n();
const [svg, setSvg] = useState<string | null>(null); const [svg, setSvg] = useState<string | null>(null);
const [error, setError] = useState<string | null>(null); const [error, setError] = useState<string | null>(null);
const [attempt, setAttempt] = useState(0); const [attempt, setAttempt] = useState(0);
@@ -199,17 +202,17 @@ function MermaidInline({ chart }: { chart: string }) {
dangerouslySetInnerHTML={{ __html: svg ?? "" }} dangerouslySetInnerHTML={{ __html: svg ?? "" }}
/> />
<div className="absolute top-0 right-0 flex items-start gap-0.5"> <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 && ( {overlay && (
<Hint <Hint
// Full-screen overlay copy (tooltip wave) — "session view" was // Full-screen overlay copy (tooltip wave) — "session view" was
// internal direction-speak for the same thing. // internal direction-speak for the same thing.
label="Open full-screen" label={t("Open full-screen")}
side="bottom" side="bottom"
> >
<button <button
type="button" type="button"
aria-label="Expand diagram" aria-label={t("Expand diagram")}
data-testid="md-mermaid-expand" 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" 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) => { onClick={(e) => {
@@ -234,6 +237,7 @@ function MermaidInline({ chart }: { chart: string }) {
* mounts. The container/look is CSS-melted in globals.css * mounts. The container/look is CSS-melted in globals.css
* (data-streamdown="code-block"…). */ * (data-streamdown="code-block"…). */
export function MarkdownCode(props: ComponentProps<"code"> & ExtraProps) { export function MarkdownCode(props: ComponentProps<"code"> & ExtraProps) {
const { t } = useI18n();
const incomplete = useIsCodeFenceIncomplete(); const incomplete = useIsCodeFenceIncomplete();
const { children, className, node, ...rest } = props; const { children, className, node, ...rest } = props;
@@ -257,7 +261,7 @@ export function MarkdownCode(props: ComponentProps<"code"> & ExtraProps) {
const lineNumbers = !/\bnoLineNumbers\b/.test(meta); const lineNumbers = !/\bnoLineNumbers\b/.test(meta);
return ( return (
<div className="group/md relative" data-testid="md-code"> <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 <CodeBlock
code={source} code={source}
language={language} language={language}
@@ -374,6 +378,7 @@ export function MarkdownBlockquote(props: ComponentProps<"blockquote"> & ExtraPr
* horizontal-scroller for wide ones, with the corner copy. The copy * horizontal-scroller for wide ones, with the corner copy. The copy
* serializes the REAL DOM table (the library's own extractor → Markdown). */ * serializes the REAL DOM table (the library's own extractor → Markdown). */
export function MarkdownTable(props: ComponentProps<"table"> & ExtraProps) { export function MarkdownTable(props: ComponentProps<"table"> & ExtraProps) {
const { t } = useI18n();
const { children, node, ...rest } = props; const { children, node, ...rest } = props;
const ref = useRef<HTMLTableElement>(null); const ref = useRef<HTMLTableElement>(null);
return ( return (
@@ -385,7 +390,7 @@ export function MarkdownTable(props: ComponentProps<"table"> & ExtraProps) {
// cells it hovers over; the scroll container stays under it (the // cells it hovers over; the scroll container stays under it (the
// corner is fixed to the wrapper, not the scrolling table). // corner is fixed to the wrapper, not the scrolling table).
className="absolute top-1 right-1 z-10" className="absolute top-1 right-1 z-10"
label="Copy table" label={t("Copy table")}
getText={() => (ref.current ? tableDataToMarkdown(extractTableDataFromElement(ref.current)) : "")} getText={() => (ref.current ? tableDataToMarkdown(extractTableDataFromElement(ref.current)) : "")}
/> />
<div className="overflow-x-auto"> <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). * in the MathML <annotation>; read from the DOM on click, no re-parse).
* Every other span passes through untouched. */ * Every other span passes through untouched. */
export function MathDisplay(props: ComponentProps<"span"> & ExtraProps) { export function MathDisplay(props: ComponentProps<"span"> & ExtraProps) {
const { t } = useI18n();
const { children, className, node, ...rest } = props; const { children, className, node, ...rest } = props;
const ref = useRef<HTMLSpanElement>(null); const ref = useRef<HTMLSpanElement>(null);
if (!className?.includes("katex-display")) { 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"> <span className="group/md relative block z-0" data-testid="md-math">
<CopyCorner <CopyCorner
className="absolute top-0 right-0 z-10" className="absolute top-0 right-0 z-10"
label="Copy equation source" label={t("Copy equation source")}
getText={() => ref.current?.querySelector("annotation")?.textContent ?? ""} getText={() => ref.current?.querySelector("annotation")?.textContent ?? ""}
/> />
<span ref={ref} className={className} {...rest}> <span ref={ref} className={className} {...rest}>
+8 -4
View File
@@ -1,4 +1,5 @@
"use client"; "use client";
import { useI18n } from "@/components/i18n";
import { Button } from "@/components/ui/button"; import { Button } from "@/components/ui/button";
import { cn } from "@/lib/utils"; import { cn } from "@/lib/utils";
@@ -15,7 +16,9 @@ export const MermaidError = ({
error, error,
retry, retry,
className, className,
}: MermaidErrorComponentProps & { className?: string }) => ( }: MermaidErrorComponentProps & { className?: string }) => {
const { t } = useI18n();
return (
<div <div
className={cn( className={cn(
"my-4 rounded-xl border border-destructive/40 bg-destructive/5 p-4", "my-4 rounded-xl border border-destructive/40 bg-destructive/5 p-4",
@@ -24,21 +27,22 @@ export const MermaidError = ({
data-streamdown="mermaid-error" data-streamdown="mermaid-error"
> >
<p className="text-sm font-medium text-destructive"> <p className="text-sm font-medium text-destructive">
Diagram failed to render {t("Diagram failed to render")}
</p> </p>
<p className="mt-1 font-mono text-xs break-words text-muted-foreground"> <p className="mt-1 font-mono text-xs break-words text-muted-foreground">
{error} {error}
</p> </p>
<details className="mt-3"> <details className="mt-3">
<summary className="cursor-pointer text-xs text-muted-foreground"> <summary className="cursor-pointer text-xs text-muted-foreground">
Show diagram source {t("Show diagram source")}
</summary> </summary>
<pre className="mt-2 overflow-x-auto rounded-lg bg-muted p-3 font-mono text-xs text-foreground"> <pre className="mt-2 overflow-x-auto rounded-lg bg-muted p-3 font-mono text-xs text-foreground">
{chart} {chart}
</pre> </pre>
</details> </details>
<Button className="mt-3" onClick={retry} size="sm" variant="outline"> <Button className="mt-3" onClick={retry} size="sm" variant="outline">
Retry {t("Retry")}
</Button> </Button>
</div> </div>
); );
};
+15 -12
View File
@@ -1,4 +1,6 @@
"use client"; "use client";
import { useI18n } from "@/components/i18n";
/** /**
* THE MINIMAL HOME (owner, 2026-10-06 — the no-graph home, take two): * 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 { GraphCostControl } from "@/components/graph-view.tsx";
import { NotesImportErrors, useNotesImport } from "@/components/notes-import.tsx"; import { NotesImportErrors, useNotesImport } from "@/components/notes-import.tsx";
import { Hint } from "@/components/ui/hint"; import { Hint } from "@/components/ui/hint";
import { countLabel } from "@/src/core";
import type { ChatSummary, GraphCostSummary, GraphSnapshot } from "@/src/core"; import type { ChatSummary, GraphCostSummary, GraphSnapshot } from "@/src/core";
/** The quiet centered summary — a pure body (props in, markup out) so /** The quiet centered summary — a pure body (props in, markup out) so
@@ -42,6 +45,7 @@ export function KnowledgeSummary({
practiceSessions: number; practiceSessions: number;
importedNotes: number; importedNotes: number;
}) { }) {
const { t, language } = useI18n();
const empty = const empty =
propositions === 0 && chains === 0 && teachSessions === 0 && practiceSessions === 0 && importedNotes === 0; propositions === 0 && chains === 0 && teachSessions === 0 && practiceSessions === 0 && importedNotes === 0;
return ( return (
@@ -52,29 +56,28 @@ export function KnowledgeSummary({
list) rides after the same parting. */} list) rides after the same parting. */}
<div className="flex flex-col gap-1"> <div className="flex flex-col gap-1">
<p className="font-ui text-sm text-muted-foreground"> <p className="font-ui text-sm text-muted-foreground">
{propositions} {propositions === 1 ? "proposition" : "propositions"} {countLabel(language, propositions, "proposition", "propositions")}
</p> </p>
<p className="font-ui text-sm text-muted-foreground"> <p className="font-ui text-sm text-muted-foreground">
{chains} {chains === 1 ? "chain" : "chains"} {countLabel(language, chains, "chain", "chains")}
</p> </p>
</div> </div>
<div className="h-4" aria-hidden="true" /> <div className="h-4" aria-hidden="true" />
<div className="flex flex-col gap-1"> <div className="flex flex-col gap-1">
<p className="font-ui text-sm text-muted-foreground"> <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>
<p className="font-ui text-sm text-muted-foreground"> <p className="font-ui text-sm text-muted-foreground">
{practiceSessions} {practiceSessions === 1 ? "practice session" : "practice sessions"} {countLabel(language, practiceSessions, "practice session", "practice sessions")}
</p> </p>
</div> </div>
<div className="h-4" aria-hidden="true" /> <div className="h-4" aria-hidden="true" />
<p className="font-ui text-sm text-muted-foreground"> <p className="font-ui text-sm text-muted-foreground">
{importedNotes} {importedNotes === 1 ? "imported note" : "imported notes"} {countLabel(language, importedNotes, "imported note", "imported notes")}
</p> </p>
{empty && ( {empty && (
<p className="pt-2 font-ui text-xs text-muted-foreground/70"> <p className="pt-2 font-ui text-xs text-muted-foreground/70">
Nothing here yet — import notes to begin building the graph. {t("Nothing here yet — import notes to begin building the graph.")}</p>
</p>
)} )}
</div> </div>
); );
@@ -93,6 +96,7 @@ export function MinimalHome({
onPracticeSelectionChange?: (nodes: { id: string; name: string; statement: string }[]) => void; onPracticeSelectionChange?: (nodes: { id: string; name: string; statement: string }[]) => void;
onChainSelectClose?: () => void; onChainSelectClose?: () => void;
}) { }) {
const { t } = useI18n();
const [snapshot, setSnapshot] = useState<GraphSnapshot | null>(null); const [snapshot, setSnapshot] = useState<GraphSnapshot | null>(null);
const [costs, setCosts] = useState<GraphCostSummary | null>(null); const [costs, setCosts] = useState<GraphCostSummary | null>(null);
const [chats, setChats] = useState<ChatSummary[]>([]); 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="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="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"> <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 <button
type="button" type="button"
onClick={() => setChainsOpen(true)} 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" 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" /> <ListTreeIcon className="size-3" />
Chains {t("Chains")}</button>
</button>
</Hint> </Hint>
</div> </div>
<div className="shrink-0 rounded-xl border bg-card/85 px-1 py-[3px] shadow-lg backdrop-blur"> <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 <button
type="button" type="button"
disabled={importing} 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" 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" /> <UploadIcon className="size-3" />
{importing ? "Importing…" : "Import notes…"} {importing ? t("Importing…") : t("Import notes…")}
</button> </button>
</Hint> </Hint>
</div> </div>
+13 -10
View File
@@ -1,4 +1,6 @@
"use client"; "use client";
import { useI18n } from "@/components/i18n";
import { useCallback, useEffect, useRef, useState } from "react"; import { useCallback, useEffect, useRef, useState } from "react";
import type { ChatSettings, ModelCatalogEntry, ModelCatalogResponse, ModelsSettings, UserSettings } from "@/src/core"; 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). * no-factory-fallback language: the UI never presents a default model).
*/ */
export function ModelControl({ chatId, onModelChange }: { chatId?: string; onModelChange?: (model: string | undefined) => void }) { export function ModelControl({ chatId, onModelChange }: { chatId?: string; onModelChange?: (model: string | undefined) => void }) {
const { t } = useI18n();
const { catalog, refresh } = useModelCatalog(); const { catalog, refresh } = useModelCatalog();
const [models, setModels] = useState<ModelsSettings | undefined>(); const [models, setModels] = useState<ModelsSettings | undefined>();
const [session, setSession] = useState<ChatSettings | 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" // Trimmed (owner, subtlety round 2026-09-25): "Model selector"
// — the long sentence was way too much. ⌘K opens this same // — the long sentence was way too much. ⌘K opens this same
// menu (registry, override-aware). // menu (registry, override-aware).
label="Model selector" label={t("Model selector")}
action="open-model-picker" action="open-model-picker"
side="bottom" side="bottom"
> >
@@ -341,7 +344,7 @@ export function ModelControl({ chatId, onModelChange }: { chatId?: string; onMod
// "Thinking" + its real shortcut via the registry (owner interlude // "Thinking" + its real shortcut via the registry (owner interlude
// 2026-09-25): ⇧⇥ cycles the level, so "Thinking (⇧⇥)" — // 2026-09-25): ⇧⇥ cycles the level, so "Thinking (⇧⇥)" —
// rebind-live like every Hint suffix. // rebind-live like every Hint suffix.
label="Thinking" label={t("Thinking")}
action="cycle-thinking" action="cycle-thinking"
side="bottom" 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 {/* Just the level — the button reads like the model button reads
the model name (owner, stats-control round 1). */} the model name (owner, stats-control round 1). */}
<span className="text-card-foreground"> <span className="text-card-foreground">
{effectiveLevel === undefined ? "—" : (THINKING_LABEL[effectiveLevel] ?? effectiveLevel)} {effectiveLevel === undefined ? "—" : t(THINKING_LABEL[effectiveLevel] ?? effectiveLevel)}
</span> </span>
<span aria-hidden className="text-xs text-muted-foreground">▾</span> <span aria-hidden className="text-xs text-muted-foreground">▾</span>
</button> </button>
@@ -380,7 +383,7 @@ export function ModelControl({ chatId, onModelChange }: { chatId?: string; onMod
preferredSide="up" preferredSide="up"
menuClass="flex w-[22rem] flex-col rounded-xl border bg-card p-2 text-xs shadow-xl" 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 <ModelMenuList
models={catalog?.models} models={catalog?.models}
effectiveId={effectiveId} effectiveId={effectiveId}
@@ -388,10 +391,10 @@ export function ModelControl({ chatId, onModelChange }: { chatId?: string; onMod
onPick={pickModel} onPick={pickModel}
footer={ footer={
catalog && catalog.models.length === 0 catalog && catalog.models.length === 0
? "No models — add a provider key in Keys" ? t("No models — add a provider key in Keys")
: chatId : chatId
? "This session's model" ? t("This session's model")
: "Strong tier — the default for new sessions" : t("Strong tier — the default for new sessions")
} }
/> />
</MenuShell> </MenuShell>
@@ -408,10 +411,10 @@ export function ModelControl({ chatId, onModelChange }: { chatId?: string; onMod
levels={catalog ? levels : []} levels={catalog ? levels : []}
effectiveLevel={effectiveLevel} effectiveLevel={effectiveLevel}
onPick={(level) => apply(effectiveProvider ?? "", effectiveId ?? "", level as ThinkingLevel)} onPick={(level) => apply(effectiveProvider ?? "", effectiveId ?? "", level as ThinkingLevel)}
empty={catalog ? (effectiveId ? `${prettyModel(effectiveId)} has no thinking levels.` : "Pick a model first.") : "Loading…"} empty={catalog ? (effectiveId ? t("{v0} has no thinking levels.", { v0: prettyModel(effectiveId) }) : t("Pick a model first.")) : t("Loading…")}
footer="Levels this model actually supports" 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> </MenuShell>
)} )}
</div> </div>
+9 -6
View File
@@ -1,4 +1,6 @@
"use client"; "use client";
import { useI18n } from "@/components/i18n";
import { useCallback, useEffect, useRef, useState, type ReactNode } from "react"; import { useCallback, useEffect, useRef, useState, type ReactNode } from "react";
import { useRouter } from "next/navigation"; import { useRouter } from "next/navigation";
@@ -110,6 +112,7 @@ export function DraftShell({
* Passed as a node so the pick state stays in NewSession. */ * Passed as a node so the pick state stays in NewSession. */
overlay?: ReactNode; overlay?: ReactNode;
}) { }) {
const { t } = useI18n();
// Voice dictation (STT wave, 2026-09-17): finals append at the end of the // 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 // draft text (via the parent's onChange — the truth lives there); the
// strip lives in the stats bar's middle slot. // 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"> <Conversation className="h-full min-w-0 flex-1" initial="instant">
<ConversationContent className="min-h-full gap-0 p-0"> <ConversationContent className="min-h-full gap-0 p-0">
<ConversationEmptyState <ConversationEmptyState
title={mode === "practice" ? "New practice session" : "New session"} title={mode === "practice" ? t("New practice session") : t("New session")}
description={ description={
mode === "practice" mode === "practice"
? "Pick nodes from your knowledge graph, describe what you want to practice, or bring your own problem." ? t("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("The teacher will open by probing what you already know.")
} }
/> />
</ConversationContent> </ConversationContent>
@@ -205,7 +208,7 @@ export function DraftShell({
// input (owner refinement round, 2026-09-24) — no lesson has // input (owner refinement round, 2026-09-24) — no lesson has
// started yet, so the composer must not read like one is in // started yet, so the composer must not read like one is in
// flight. // 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> </div>
<StatsBar <StatsBar
@@ -231,6 +234,7 @@ export function DraftShell({
} }
export function NewSession({ initialConfigured, mode = "teach" }: { initialConfigured: boolean; mode?: "teach" | "practice" }) { export function NewSession({ initialConfigured, mode = "teach" }: { initialConfigured: boolean; mode?: "teach" | "practice" }) {
const { t } = useI18n();
const router = useRouter(); const router = useRouter();
const [text, setText] = useState(""); const [text, setText] = useState("");
/* The draft MIRROR ("nothing gets lost" round, 2026-10-01): the shell's /* 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)} 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" 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 {t("Set up your models")}</button>
</button>
) )
) )
} }
+8 -5
View File
@@ -1,4 +1,5 @@
"use client"; "use client";
import { useI18n } from "@/components/i18n";
import { useRef, useState } from "react"; 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. */ /** Only error text survives a request; selected Files stay request-local. */
export function useNotesImport(callbacks: { onStart?: () => void; onFinish?: () => void } = {}) { 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 [importing, setImporting] = useState(false);
const pending = useRef(false); const pending = useRef(false);
const submit = async (files: File[]) => { const submit = async (files: File[]) => {
if (pending.current || files.length === 0) return; if (pending.current || files.length === 0) return;
pending.current = true; pending.current = true;
setImporting(true); setImporting(true);
setErrors([]); setFailures([]);
try { try {
callbacks.onStart?.(); callbacks.onStart?.();
const results = await postNotesImport(files); const results = await postNotesImport(files);
setErrors(results setFailures(results
.filter((result) => !result.ok) .filter((result) => !result.ok)
.sort((a, b) => a.requestIndex - b.requestIndex) .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) { } catch (error) {
const message = error instanceof Error ? error.message : String(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 { } finally {
pending.current = false; pending.current = false;
setImporting(false); setImporting(false);
+8 -3
View File
@@ -1,3 +1,6 @@
"use client";
import { useI18n } from "@/components/i18n";
import type { Phase3 } from "@/src/core"; 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 * at all, so it is part of the drag surface instead of a carved-out
* target. */ * target. */
export function PhaseChip({ phase }: { phase: Phase3 }) { export function PhaseChip({ phase }: { phase: Phase3 }) {
const { t } = useI18n();
return ( return (
<span <span
data-testid="phase-chip" data-testid="phase-chip"
@@ -46,7 +50,7 @@ export function PhaseChip({ phase }: { phase: Phase3 }) {
aria-hidden="true" aria-hidden="true"
className="pointer-events-none flex shrink-0 select-none items-center text-xs font-medium uppercase tracking-wide text-muted-foreground" 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> </span>
); );
} }
@@ -60,11 +64,12 @@ export function PhaseChip({ phase }: { phase: Phase3 }) {
* after the start). * after the start).
*/ */
export function PhaseDivider({ phase, nodeCount }: { phase: Phase3; nodeCount?: number }) { export function PhaseDivider({ phase, nodeCount }: { phase: Phase3; nodeCount?: number }) {
const { t } = useI18n();
if (phase === "probe") return null; if (phase === "probe") return null;
const label = const label =
phase === "plan" && nodeCount phase === "plan" && nodeCount
? `Plan · ${nodeCount} node${nodeCount === 1 ? "" : "s"}` ? t("Plan · {v0} node{v1}", { v0: nodeCount, v1: nodeCount === 1 ? "" : "s" })
: CHIP_LABEL[phase]; : t(CHIP_LABEL[phase]);
return ( return (
<div className="my-4 flex w-full items-center gap-3" data-testid={`phase-divider-${phase}`}> <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 /> <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"; "use client";
import { useI18n } from "@/components/i18n";
import { useEffect, useId, useMemo, useRef, useState } from "react"; import { useEffect, useId, useMemo, useRef, useState } from "react";
import { MaximizeIcon, MinusIcon, PlusIcon, XIcon } from "lucide-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. */ * flank's width and the band's height rather than run width-first. */
fill?: boolean; fill?: boolean;
}) { }) {
const { t } = useI18n();
const layout = useMemo(() => blockLayout(nodes, edges), [nodes, edges]); const layout = useMemo(() => blockLayout(nodes, edges), [nodes, edges]);
// Pad only the full-extent render — the plan window's override viewBox is // 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. // a deliberate viewport crop, and the popup keeps its own fit margins.
@@ -540,7 +543,7 @@ export function PlanDiagram({
: {}), : {}),
}} }}
role="img" role="img"
aria-label="Plan diagram" aria-label={t("Plan diagram")}
> >
<defs> <defs>
<marker <marker
@@ -676,15 +679,16 @@ export function PlanDiagram({
* future DAG window). * future DAG window).
*/ */
export function PlanBlock({ data, onOpen }: { data: PlanBlockData; onOpen: () => void }) { export function PlanBlock({ data, onOpen }: { data: PlanBlockData; onOpen: () => void }) {
const { t } = useI18n();
const markerId = useId().replace(/[^a-zA-Z0-9-]/g, ""); 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 statusMap = useMemo(() => new Map(data.nodes.map((n) => [n.id, n.status] as const)), [data]);
const statusOf = (id: string): PlanNodeStatus => statusMap.get(id) ?? "upcoming"; const statusOf = (id: string): PlanNodeStatus => statusMap.get(id) ?? "upcoming";
return ( return (
<Hint label="Open the full plan" side="bottom"> <Hint label={t("Open the full plan")} side="bottom">
<button <button
type="button" type="button"
data-testid="plan-block-full" data-testid="plan-block-full"
aria-label="Open the full plan" aria-label={t("Open the full plan")}
onClick={onOpen} onClick={onOpen}
className="block w-full text-left" className="block w-full text-left"
> >
@@ -749,6 +753,7 @@ const POPUP_PAD = GLOW_REACH;
* through while the focus owns the session area. * through while the focus owns the session area.
*/ */
export function PlanPopup({ messages, onClose }: { messages: readonly PopupMessage[]; onClose: () => void }) { export function PlanPopup({ messages, onClose }: { messages: readonly PopupMessage[]; onClose: () => void }) {
const { t } = useI18n();
const tree = useMemo(() => latestTree(messages), [messages]); const tree = useMemo(() => latestTree(messages), [messages]);
const statuses = useMemo( const statuses = useMemo(
() => (tree ? derivePlanStatuses(tree, messages as Parameters<typeof derivePlanStatuses>[1]) : null), () => (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" className="absolute inset-0 z-40 flex flex-col bg-background/80 p-4 backdrop-blur-sm"
role="dialog" role="dialog"
aria-modal="true" aria-modal="true"
aria-label="The full plan" aria-label={t("The full plan")}
onClick={(e) => { onClick={(e) => {
if (e.target === e.currentTarget) onClose(); if (e.target === e.currentTarget) onClose();
}} }}
> >
<div className="mb-2 flex items-center gap-2"> <div className="mb-2 flex items-center gap-2">
<span className="text-sm font-medium">The plan</span> <span className="text-sm font-medium">{t("The plan")}</span>
<span className="text-xs text-muted-foreground">live — statuses derive from your check outcomes</span> <span className="text-xs text-muted-foreground">{t("live — statuses derive from your check outcomes")}</span>
<Hint label="Close" binding="escape" side="left"> <Hint label={t("Close")} binding="escape" side="left">
<button <button
type="button" type="button"
onClick={onClose} onClick={onClose}
aria-label="Close" aria-label={t("Close")}
data-testid="plan-popup-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" 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>
<div className="pointer-events-none absolute inset-x-0 bottom-0 flex items-end justify-between p-2"> <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"> <div className="flex gap-1">
<Hint label="Zoom out" side="bottom"> <Hint label={t("Zoom out")} side="bottom">
<button <button
type="button" type="button"
aria-label="Zoom out" aria-label={t("Zoom out")}
onClick={() => stepZoom(1 / 1.25)} 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" 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" /> <MinusIcon className="size-3.5" />
</button> </button>
</Hint> </Hint>
<Hint label="Fit plan to screen" side="bottom"> <Hint label={t("Fit plan to screen")} side="bottom">
<button <button
type="button" type="button"
aria-label="Fit plan to screen" aria-label={t("Fit plan to screen")}
onClick={fitView} onClick={fitView}
data-testid="plan-popup-fit" 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" 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" /> <MaximizeIcon className="size-3.5" />
</button> </button>
</Hint> </Hint>
<Hint label="Zoom in" side="bottom"> <Hint label={t("Zoom in")} side="bottom">
<button <button
type="button" type="button"
aria-label="Zoom in" aria-label={t("Zoom in")}
onClick={() => stepZoom(1.25)} 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" 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"> <div className="flex h-full items-center justify-center text-sm text-muted-foreground">
No plan in this session yet. {t("No plan in this session yet.")}</div>
</div>
)} )}
</div> </div>
</div> </div>
+25 -22
View File
@@ -1,4 +1,6 @@
"use client"; "use client";
import { useI18n } from "@/components/i18n";
import { MessageResponse } from "@/components/ai-elements/message"; import { MessageResponse } from "@/components/ai-elements/message";
import { cn } from "@/lib/utils"; import { cn } from "@/lib/utils";
@@ -56,6 +58,7 @@ export interface PracticeThinkingData {
} }
export function ProblemCard({ part }: ArtifactPartProps) { export function ProblemCard({ part }: ArtifactPartProps) {
const { t } = useI18n();
// Always open, no chevron (owner round, 2026-10-05): the record IS the // Always open, no chevron (owner round, 2026-10-05): the record IS the
// statement — same container + eyebrow discipline as the answered card // statement — same container + eyebrow discipline as the answered card
// and the dock (which mirrors this in Slice D). The commitment never // 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 : ""; const statement = typeof input.statement === "string" ? input.statement : "";
return ( return (
<div data-testid="problem-card" className="lh-card-enter my-1 rounded-xl border bg-card p-4"> <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]"> <div className="lh-prose text-[15px]">
<MessageResponse>{repairMathForDisplay(statement)}</MessageResponse> <MessageResponse>{repairMathForDisplay(statement)}</MessageResponse>
</div> </div>
@@ -94,6 +97,7 @@ const OUTCOME_CHIP: Record<string, string> = {
}; };
export function ConcludeCard({ part }: ArtifactPartProps) { export function ConcludeCard({ part }: ArtifactPartProps) {
const { t } = useI18n();
// The conclusion redesigned (owner round, 2026-10-05): the SOLUTION is // The conclusion redesigned (owner round, 2026-10-05): the SOLUTION is
// the hero — the modelSolution committed at pose time (part.reveal, the // the hero — the modelSolution committed at pose time (part.reveal, the
// view-seam join), shown verbatim, never re-authored. The verdict sits // view-seam join), shown verbatim, never re-authored. The verdict sits
@@ -111,8 +115,7 @@ export function ConcludeCard({ part }: ArtifactPartProps) {
if (outcome === "dismissed") { if (outcome === "dismissed") {
return ( return (
<div data-testid="conclude-card" className="my-1 px-1 text-sm text-muted-foreground/70"> <div data-testid="conclude-card" className="my-1 px-1 text-sm text-muted-foreground/70">
Problem dismissed. {t("Problem dismissed.")}</div>
</div>
); );
} }
@@ -122,10 +125,9 @@ export function ConcludeCard({ part }: ArtifactPartProps) {
eyebrow + the outcome in the quiz cards' EXACT chip register — eyebrow + the outcome in the quiz cards' EXACT chip register —
no more two same-weight headings stacked. */} 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"> <div className="mb-1 flex items-center gap-2 text-xs font-medium uppercase tracking-wide text-muted-foreground">
Concluded {t("Concluded")}{(OUTCOME_LABEL[outcome] ?? "") !== "" && outcome !== "dismissed" && (
{(OUTCOME_LABEL[outcome] ?? "") !== "" && outcome !== "dismissed" && (
<span className={cn("rounded px-1.5 py-0.5", OUTCOME_CHIP[outcome] ?? OUTCOME_CHIP.movedOn)}> <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> </span>
)} )}
</div> </div>
@@ -142,7 +144,7 @@ export function ConcludeCard({ part }: ArtifactPartProps) {
<> <>
<div className="my-3 border-t" /> <div className="my-3 border-t" />
<div> <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]"> <div className="lh-prose text-[15px]">
<MessageResponse>{repairMathForDisplay(reveal)}</MessageResponse> <MessageResponse>{repairMathForDisplay(reveal)}</MessageResponse>
</div> </div>
@@ -177,6 +179,7 @@ function MetadataPopover({
concludeInput: { outcome?: string; rung?: unknown; missClass?: unknown; retest?: unknown }; concludeInput: { outcome?: string; rung?: unknown; missClass?: unknown; retest?: unknown };
problemInput: { mode?: unknown; target?: unknown }; problemInput: { mode?: unknown; target?: unknown };
}) { }) {
const { t } = useI18n();
const [open, setOpen] = useState(false); const [open, setOpen] = useState(false);
const triggerRef = useRef<HTMLButtonElement | null>(null); const triggerRef = useRef<HTMLButtonElement | null>(null);
const rootRef = useRef<HTMLDivElement | null>(null); const rootRef = useRef<HTMLDivElement | null>(null);
@@ -239,8 +242,7 @@ function MetadataPopover({
aria-expanded={open} aria-expanded={open}
className="flex items-center gap-1 text-xs font-medium uppercase tracking-wide text-muted-foreground transition-colors hover:text-foreground" className="flex items-center gap-1 text-xs font-medium uppercase tracking-wide text-muted-foreground transition-colors hover:text-foreground"
> >
Metadata {t("Metadata")}</button>
</button>
{open && {open &&
anchor && anchor &&
createPortal( 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" 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"> <dl className="grid grid-cols-[auto_1fr] gap-x-4 gap-y-1 text-xs">
<dt className="text-muted-foreground">Mode</dt> <dt className="text-muted-foreground">{t("Mode")}</dt>
<dd className="font-mono">{mode || "—"}</dd> <dd className="font-mono">{t(mode || "—")}</dd>
<dt className="text-muted-foreground">Targets</dt> <dt className="text-muted-foreground">{t("Targets")}</dt>
<dd className="font-mono"> <dd className="font-mono">
{targets.length === 0 ? ( {targets.length === 0 ? (
"untagged" t("untagged")
) : ( ) : (
<span> <span>
{targets.map((t, i) => ( {targets.map((t, i) => (
@@ -273,14 +275,14 @@ function MetadataPopover({
</span> </span>
)} )}
</dd> </dd>
<dt className="text-muted-foreground">Outcome</dt> <dt className="text-muted-foreground">{t("Outcome")}</dt>
<dd className="font-mono">{outcome || "—"}</dd> <dd className="font-mono">{t(outcome || "—")}</dd>
<dt className="text-muted-foreground">Hint rung</dt> <dt className="text-muted-foreground">{t("Hint rung")}</dt>
<dd className="font-mono">{rung || "none"}</dd> <dd className="font-mono">{t(rung || "none")}</dd>
<dt className="text-muted-foreground">Miss class</dt> <dt className="text-muted-foreground">{t("Miss class")}</dt>
<dd className="font-mono">{miss || "none"}</dd> <dd className="font-mono">{t(miss || "none")}</dd>
<dt className="text-muted-foreground">Retest</dt> <dt className="text-muted-foreground">{t("Retest")}</dt>
<dd className="font-mono">{retest === undefined ? "—" : retest ? "yes" : "no"}</dd> <dd className="font-mono">{retest === undefined ? "—" : retest ? t("yes") : t("no")}</dd>
</dl> </dl>
</div>, </div>,
document.body, document.body,
@@ -290,9 +292,10 @@ function MetadataPopover({
} }
export function ThinkingCard({ data }: { data: PracticeThinkingData }) { export function ThinkingCard({ data }: { data: PracticeThinkingData }) {
const { t } = useI18n();
// The unified row (owner rounds, 2026-10-05): one style, one label — // The unified row (owner rounds, 2026-10-05): one style, one label —
// 50%-italic quote block on expand. Legacy records' commit flag is // 50%-italic quote block on expand. Legacy records' commit flag is
// deliberately unread (one lane now). A TRANSCRIPT row (owner round, // deliberately unread (one lane now). A TRANSCRIPT row (owner round,
// 2026-10-07): full-width band + rail, arriving expanded. // 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"; "use client";
import { useI18n } from "@/components/i18n";
import { useCallback, useRef, useState } from "react"; import { useCallback, useRef, useState } from "react";
import { ChevronRightIcon } from "lucide-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) { export function PracticeDock({ problemCallId, input, expanded, onExpand, onSubmit, onEscape, compactionRunning = false }: PracticeDockProps) {
const { t } = useI18n();
const [thinking, setThinking] = useState(""); const [thinking, setThinking] = useState("");
// Ref MIRRORS the field ("nothing gets lost", the question-dock // Ref MIRRORS the field ("nothing gets lost", the question-dock
// discipline): a submit resuming after `await finishDictation()` reads // 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" 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" /> <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> <span className="min-w-0 flex-1 truncate">{input.statement}</span>
</button> </button>
); );
@@ -107,7 +110,7 @@ export function PracticeDock({ problemCallId, input, expanded, onExpand, onSubmi
markdown + LaTeX rendered, capped by the measured column markdown + LaTeX rendered, capped by the measured column
(--dock-max-h; the old fixed 40vh cut long problems short). */} (--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="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]"> <div className="lh-prose text-[15px]">
<MessageResponse>{typeof input.statement === "string" ? repairMathForDisplay(input.statement) : input.statement}</MessageResponse> <MessageResponse>{typeof input.statement === "string" ? repairMathForDisplay(input.statement) : input.statement}</MessageResponse>
</div> </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" 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"> <div className="px-2.5 pt-2 text-xs font-medium uppercase tracking-wide text-muted-foreground">
YOUR THINKING {t("YOUR THINKING")}</div>
</div>
<textarea <textarea
value={thinking} value={thinking}
onChange={(e) => { onChange={(e) => {
@@ -144,7 +146,7 @@ export function PracticeDock({ problemCallId, input, expanded, onExpand, onSubmi
submit(); submit();
} }
}} }}
placeholder="Work here, think out loud." placeholder={t("Work here, think out loud.")}
rows={4} rows={4}
className={cn( 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", "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. */} field 10 − p-1 (4) = button 6. */}
<div className="flex items-center justify-between gap-2 p-1"> <div className="flex items-center justify-between gap-2 p-1">
<span aria-hidden className="pl-1.5 text-xs text-muted-foreground"> <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> </span>
<Button <Button
type="button" type="button"
@@ -165,8 +167,7 @@ export function PracticeDock({ problemCallId, input, expanded, onExpand, onSubmi
onClick={() => submit()} onClick={() => submit()}
className="ml-auto h-9 rounded-[6px] px-3.5 text-sm font-medium" className="ml-auto h-9 rounded-[6px] px-3.5 text-sm font-medium"
> >
Submit thinking {t("Submit thinking")}</Button>
</Button>
</div> </div>
</div> </div>
</div> </div>
+30 -28
View File
@@ -1,4 +1,6 @@
"use client"; "use client";
import { useI18n } from "@/components/i18n";
import { useCallback, useEffect, useMemo, useRef, useState } from "react"; import { useCallback, useEffect, useMemo, useRef, useState } from "react";
import { ArrowDownIcon, ChevronRightIcon, PlayIcon } from "lucide-react"; import { ArrowDownIcon, ChevronRightIcon, PlayIcon } from "lucide-react";
@@ -105,6 +107,7 @@ export function QuestionDock({
dictationClaim = true, dictationClaim = true,
compactionRunning = false, compactionRunning = false,
}: QuestionDockProps) { }: QuestionDockProps) {
const { t } = useI18n();
// The probe-phase names (owner, 2026-10-09) canonicalize onto their // The probe-phase names (owner, 2026-10-09) canonicalize onto their
// teach twins for every dock concern — parse dispatch, labels, kinds: // teach twins for every dock concern — parse dispatch, labels, kinds:
// the probe quiz ("probe") is quiz-shaped, the probe free-recall // 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 // The chip's display name carries the tool's OWN name (owner
// 2026-10-09): probe-phase questions label Probe/Recall in the // 2026-10-09): probe-phase questions label Probe/Recall in the
// composer; teach tools keep the standing labels. // 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 // 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. // input, and only the schema the tool declared vouches for its shape.
// The dock mounts on input-available parts (already validated at the // The dock mounts on input-available parts (already validated at the
@@ -126,8 +129,7 @@ export function QuestionDock({
if (!parsed) { if (!parsed) {
return ( return (
<div data-testid="malformed-question" className="rounded-xl border border-dashed bg-card/60 p-3 text-sm text-muted-foreground"> <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. {t("This question arrived malformed — the transcript keeps the retry.")}</div>
</div>
); );
} }
return ( return (
@@ -142,7 +144,7 @@ export function QuestionDock({
label={ label={
(canonical === "quiz" || canonical === "explain") && (canonical === "quiz" || canonical === "explain") &&
normalizeQuizType((parsed as { type?: unknown }).type) === "predict" normalizeQuizType((parsed as { type?: unknown }).type) === "predict"
? "Prediction" ? t("Prediction")
: undefined : undefined
} }
/> />
@@ -201,6 +203,7 @@ export function QuestionDockBar({
* label Probe/Recall — the phase boundary visible in the composer. */ * label Probe/Recall — the phase boundary visible in the composer. */
toolLabel?: string; toolLabel?: string;
}) { }) {
const { t } = useI18n();
if (variant === "reveal") { if (variant === "reveal") {
return ( return (
<button <button
@@ -213,11 +216,11 @@ export function QuestionDockBar({
<PlayIcon className="size-3.5" /> <PlayIcon className="size-3.5" />
</span> </span>
<span className="min-w-0 flex-1"> <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 className="block truncate text-xs text-muted-foreground">{teaserText(question)}</span>
</span> </span>
<span className="flex shrink-0 items-center gap-1 text-xs font-medium text-primary"> <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> </span>
</button> </button>
); );
@@ -232,11 +235,11 @@ export function QuestionDockBar({
<span className="shrink-0 text-xs font-medium uppercase tracking-wide text-muted-foreground"> <span className="shrink-0 text-xs font-medium uppercase tracking-wide text-muted-foreground">
{label ?? {label ??
toolLabel ?? toolLabel ??
(toolName === "quiz" ? "Check" : toolName === "explain" ? "Explain" : "Question")} (toolName === "quiz" ? t("Check") : toolName === "explain" ? t("Explain") : t("Question"))}
</span> </span>
<span className="min-w-0 flex-1 truncate text-muted-foreground">{teaserText(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"> <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> </span>
</button> </button>
); );
@@ -254,6 +257,7 @@ function QuestionDockForm({
dictationClaim = true, dictationClaim = true,
compactionRunning = false, compactionRunning = false,
}: Omit<QuestionDockProps, "expanded" | "onExpand" | "reveal">) { }: Omit<QuestionDockProps, "expanded" | "onExpand" | "reveal">) {
const { t } = useI18n();
const kind = toolQuestionKind(toolName); const kind = toolQuestionKind(toolName);
// Canonical QuestionData, built once per question. Quiz options are // 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 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"> <div className="mb-1 text-xs font-medium uppercase tracking-wide text-muted-foreground">
{toolName === "probe" {toolName === "probe"
? "Probe" ? t("Probe")
: toolName === "recall" : toolName === "recall"
? "Recall" ? t("Recall")
: kind === "quiz" : kind === "quiz"
? qd.questionType === "predict" ? qd.questionType === "predict"
? "Prediction" ? t("Prediction")
: "Check" : t("Check")
: kind === "explain" : kind === "explain"
? "Explain" ? t("Explain")
: "Question"} : t("Question")}
{qd.mode === "multi-select" && " · select all that apply"} {qd.mode === "multi-select" && t(" · select all that apply")}
</div> </div>
<div className="mb-3 font-medium"> <div className="mb-3 font-medium">
<MessageResponse>{qd.question}</MessageResponse> <MessageResponse>{qd.question}</MessageResponse>
@@ -644,7 +648,7 @@ function QuestionDockForm({
> >
{sel.dontKnow && <span className="size-1.5 rounded-full bg-current" />} {sel.dontKnow && <span className="size-1.5 rounded-full bg-current" />}
</span> </span>
<span>I don't know</span> <span>{t("I don't know")}</span>
</button> </button>
)} )}
@@ -710,7 +714,7 @@ function QuestionDockForm({
submit(); 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" className="lh-input w-full min-w-0 flex-1 bg-transparent outline-none placeholder:text-muted-foreground"
onClick={(e) => e.stopPropagation()} onClick={(e) => e.stopPropagation()}
/> />
@@ -742,8 +746,7 @@ function QuestionDockForm({
sel.dontKnow ? "text-muted-foreground/50" : "text-muted-foreground", sel.dontKnow ? "text-muted-foreground/50" : "text-muted-foreground",
)} )}
> >
Confidence {t("Confidence")}</span>
</span>
<div <div
className={cn("relative h-4 min-w-0 flex-1", sel.dontKnow && "opacity-40")} className={cn("relative h-4 min-w-0 flex-1", sel.dontKnow && "opacity-40")}
data-testid="confidence-track" data-testid="confidence-track"
@@ -771,9 +774,9 @@ function QuestionDockForm({
step={1} step={1}
value={CONFIDENCE_STOPS[confIdx]} value={CONFIDENCE_STOPS[confIdx]}
disabled={sel.dontKnow} disabled={sel.dontKnow}
aria-label="Confidence — the probability your selected answer is correct" aria-label={t("Confidence — the probability your selected answer is correct")}
aria-valuetext={`${CONFIDENCE_STOPS[confIdx]} percent`} aria-valuetext={t("{v0} percent", { v0: CONFIDENCE_STOPS[confIdx] })}
title="The probability your selected answer is correct — 50% is a coin flip. Adjust with ←/→." title={t("The probability your selected answer is correct — 50% is a coin flip. Adjust with ←/→.")}
data-testid="confidence-slider" data-testid="confidence-slider"
onChange={(e) => { onChange={(e) => {
// Snap to the nearest stop; the rendered VALUE is the stop's own // 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" 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"> <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> </div>
{qd.mode === "text" ? ( {qd.mode === "text" ? (
<textarea <textarea
@@ -854,7 +857,7 @@ function QuestionDockForm({
submit(); submit();
} }
}} }}
placeholder="Type your answer…" placeholder={t("Type your answer…")}
rows={1} 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" 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(); submit();
} }
}} }}
placeholder="Why this answer? Whatever comes to mind." placeholder={t("Why this answer? Whatever comes to mind.")}
rows={1} rows={1}
className={cn( 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", "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"> <div className="flex items-center justify-between gap-2 p-1">
<span aria-hidden className="pl-1.5 text-xs text-muted-foreground"> <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> </span>
<Button <Button
type="submit" type="submit"
disabled={!canSubmit || compactionRunning} disabled={!canSubmit || compactionRunning}
className="ml-auto h-9 rounded-[6px] px-3.5 text-sm font-medium" className="ml-auto h-9 rounded-[6px] px-3.5 text-sm font-medium"
> >
Submit answer {t("Submit answer")}</Button>
</Button>
</div> </div>
</div> </div>
</form> </form>
+5 -3
View File
@@ -1,4 +1,6 @@
"use client"; "use client";
import { useI18n } from "@/components/i18n";
import { useCallback } from "react"; import { useCallback } from "react";
import { joinDictation, useDictationTarget } from "@/components/dictation"; import { joinDictation, useDictationTarget } from "@/components/dictation";
@@ -37,6 +39,7 @@ export function ReadingThinkingField({
/** Primary dictation claim — true while the check is behind its reveal. */ /** Primary dictation claim — true while the check is behind its reveal. */
claim: boolean; claim: boolean;
}) { }) {
const { t } = useI18n();
useDictationTarget( useDictationTarget(
useCallback( useCallback(
(t: string) => { (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" 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"> <div className="pb-1 text-xs font-medium uppercase tracking-wide text-muted-foreground">
Thinking while reading {t("Thinking while reading")}</div>
</div>
<textarea <textarea
value={value} value={value}
onChange={(e) => onChange(e.target.value)} onChange={(e) => onChange(e.target.value)}
@@ -94,7 +96,7 @@ export function ReadingThinkingField({
e.currentTarget.blur(); e.currentTarget.blur();
} }
}} }}
placeholder="Whatever comes to mind as you read." placeholder={t("Whatever comes to mind as you read.")}
rows={1} 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" 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"; "use client";
import { useI18n } from "@/components/i18n";
import { useCallback, useEffect, useRef, useState, type RefObject } from "react"; import { useCallback, useEffect, useRef, useState, type RefObject } from "react";
import { ChevronDownIcon, ChevronUpIcon, SearchIcon, XIcon } from "lucide-react"; import { ChevronDownIcon, ChevronUpIcon, SearchIcon, XIcon } from "lucide-react";
@@ -66,6 +68,7 @@ export function SessionFind({ scopeRef, scrollContextRef }: {
scopeRef: RefObject<HTMLDivElement | null>; scopeRef: RefObject<HTMLDivElement | null>;
scrollContextRef: RefObject<SessionScrollContext | null>; scrollContextRef: RefObject<SessionScrollContext | null>;
}) { }) {
const { t } = useI18n();
const [open, setOpen] = useState(false); const [open, setOpen] = useState(false);
const [query, setQuery] = useState(""); const [query, setQuery] = useState("");
const [result, setResult] = useState({ index: -1, count: 0 }); const [result, setResult] = useState({ index: -1, count: 0 });
@@ -163,7 +166,7 @@ export function SessionFind({ scopeRef, scrollContextRef }: {
return ( return (
<div <div
role="search" role="search"
aria-label="Session Find" aria-label={t("Session Find")}
data-testid="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" 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) => { onKeyDown={(event) => {
@@ -182,8 +185,8 @@ export function SessionFind({ scopeRef, scrollContextRef }: {
<SearchIcon className="size-4 shrink-0 text-muted-foreground" aria-hidden /> <SearchIcon className="size-4 shrink-0 text-muted-foreground" aria-hidden />
<input <input
ref={inputRef} ref={inputRef}
aria-label="Find in session" aria-label={t("Find in session")}
placeholder="Find in session…" placeholder={t("Find in session…")}
value={query} value={query}
onChange={(event) => setQuery(event.target.value)} onChange={(event) => setQuery(event.target.value)}
spellCheck={false} 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"> <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}` : ""} {query ? `${result.index + 1}/${result.count}` : ""}
</output> </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)}> disabled={!result.count} onMouseDown={(event) => event.preventDefault()} onClick={() => step(-1)}>
<ChevronUpIcon className="size-4" aria-hidden /> <ChevronUpIcon className="size-4" aria-hidden />
</button> </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)}> disabled={!result.count} onMouseDown={(event) => event.preventDefault()} onClick={() => step(1)}>
<ChevronDownIcon className="size-4" aria-hidden /> <ChevronDownIcon className="size-4" aria-hidden />
</button> </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 /> <XIcon className="size-4" aria-hidden />
</button> </button>
</div> </div>
+16 -14
View File
@@ -1,4 +1,6 @@
"use client"; "use client";
import { useI18n } from "@/components/i18n";
import { createContext, useContext, useEffect, useMemo, useRef, useState } from "react"; import { createContext, useContext, useEffect, useMemo, useRef, useState } from "react";
import { MaximizeIcon, MinusIcon, PlusIcon, XIcon } from "lucide-react"; import { MaximizeIcon, MinusIcon, PlusIcon, XIcon } from "lucide-react";
@@ -83,6 +85,7 @@ export function MermaidOverlay({
inverted?: boolean; inverted?: boolean;
onClose: () => void; onClose: () => void;
}) { }) {
const { t } = useI18n();
// ── chart render (own instance, own ids); figure mode uses the raw svg ── // ── chart render (own instance, own ids); figure mode uses the raw svg ──
const [svg, setSvg] = useState<string | null>(rawSvg ?? null); const [svg, setSvg] = useState<string | null>(rawSvg ?? null);
const [error, setError] = useState<string | null>(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" className="absolute inset-0 z-40 flex flex-col bg-background/80 p-4 backdrop-blur-sm"
role="dialog" role="dialog"
aria-modal="true" aria-modal="true"
aria-label="Diagram focus" aria-label={t("Diagram focus")}
> >
<div className="mb-2 flex items-center gap-2"> <div className="mb-2 flex items-center gap-2">
<span className="text-sm font-medium">Diagram</span> <span className="text-sm font-medium">{t("Diagram")}</span>
<span className="text-xs text-muted-foreground">from this lesson</span> <span className="text-xs text-muted-foreground">{t("from this lesson")}</span>
<Hint label="Close" binding="escape" side="left"> <Hint label={t("Close")} binding="escape" side="left">
<button <button
type="button" type="button"
onClick={onClose} onClick={onClose}
aria-label="Close" aria-label={t("Close")}
data-testid="mermaid-overlay-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" 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>
<div className="pointer-events-none absolute inset-x-0 bottom-0 flex items-end justify-between p-2"> <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"> <div className="flex gap-1">
<Hint label="Zoom out" side="bottom"> <Hint label={t("Zoom out")} side="bottom">
<button <button
type="button" type="button"
aria-label="Zoom out" aria-label={t("Zoom out")}
onClick={() => stepZoom(1 / 1.25)} 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" 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" /> <MinusIcon className="size-3.5" />
</button> </button>
</Hint> </Hint>
<Hint label="Fit diagram to view" side="bottom"> <Hint label={t("Fit diagram to view")} side="bottom">
<button <button
type="button" type="button"
aria-label="Fit diagram to view" aria-label={t("Fit diagram to view")}
onClick={fitView} 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" 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" /> <MaximizeIcon className="size-3.5" />
</button> </button>
</Hint> </Hint>
<Hint label="Zoom in" side="bottom"> <Hint label={t("Zoom in")} side="bottom">
<button <button
type="button" type="button"
aria-label="Zoom in" aria-label={t("Zoom in")}
onClick={() => stepZoom(1.25)} 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" 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"> <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" /> <span className="inline-block size-4 animate-spin rounded-full border border-current border-b-2" />
Rendering diagram… {t("Rendering diagram…")}</div>
</div>
)} )}
</div> </div>
</div> </div>
+111 -69
View File
@@ -1,4 +1,6 @@
"use client"; "use client";
import { useI18n } from "@/components/i18n";
/** /**
* The Agent section (settings-app wave): the THREE MODEL SLOTS, the tool * The Agent section (settings-app wave): the THREE MODEL SLOTS, the tool
* toggles + the search-engine preference, and the prompt override * toggles + the search-engine preference, and the prompt override
@@ -70,6 +72,8 @@ import {
import type { ThinkingLevel } from "@earendil-works/pi-agent-core"; import type { ThinkingLevel } from "@earendil-works/pi-agent-core";
import { prettyModel } from "@/components/usage-format"; import { prettyModel } from "@/components/usage-format";
import { cn } from "@/lib/utils"; import { cn } from "@/lib/utils";
import { ZapIcon } from "lucide-react";
import { Hint } from "@/components/ui/hint";
import { SettingsGroup, SettingsRow, Toggle, CommitInput } from "./controls.tsx"; import { SettingsGroup, SettingsRow, Toggle, CommitInput } from "./controls.tsx";
import { ModelMenuList, ThinkingMenuList } from "./model-menu"; import { ModelMenuList, ThinkingMenuList } from "./model-menu";
import { ChatGPTBackgroundAccess } from "./chatgpt-connection"; import { ChatGPTBackgroundAccess } from "./chatgpt-connection";
@@ -150,13 +154,22 @@ export function composeSlotModel(
delete next[slot]; delete next[slot];
return next; return next;
} }
const model = provider && id ? { provider, id } : prev?.model;
next[slot] = { next[slot] = {
...(provider && id ? { model: { provider, id } } : prev?.model ? { model: prev.model } : {}), ...(model ? { model } : {}),
...(thinking ? { thinkingLevel: thinking as ThinkingLevel } : {}), ...(thinking ? { thinkingLevel: thinking as ThinkingLevel } : {}),
...(model?.provider === "chatgpt" && prev?.fastMode !== undefined ? { fastMode: prev.fastMode } : {}),
}; };
return next; return next;
} }
/** Speed belongs to the tier, independently of its reasoning budget. */
export function composeSlotFast(models: ModelsSettings | undefined, slot: ModelSlotName, enabled: boolean): ModelsSettings {
const current = models?.[slot];
if (current?.model?.provider !== "chatgpt") return { ...models };
return { ...models, [slot]: { ...current, fastMode: enabled } };
}
/** Compose: the search-engine preference. null = the explicit pin is /** Compose: the search-engine preference. null = the explicit pin is
* REMOVED (auto-activation takes over — the schema's resting state). * REMOVED (auto-activation takes over — the schema's resting state).
* Pure — exported for tests. */ * Pure — exported for tests. */
@@ -250,20 +263,22 @@ function MenuTrigger({
/** The no-key search state grays the whole row; the trigger follows. */ /** The no-key search state grays the whole row; the trigger follows. */
disabled?: boolean; disabled?: boolean;
}) { }) {
const { t } = useI18n();
return ( return (
<button <button
type="button" type="button"
aria-label={ariaLabel} aria-label={ariaLabel}
aria-expanded={open} aria-expanded={open}
disabled={disabled}
data-testid={testid} data-testid={testid}
onClick={() => { onClick={() => {
if (!disabled) onClick(); if (!disabled) onClick();
}} }}
className={`flex h-7 items-center gap-1 rounded-sm border border-border bg-background px-1.5 font-ui text-xs text-foreground transition-colors ${ className={`flex h-7 max-w-full items-center gap-1 rounded-sm border border-border bg-background px-1.5 font-ui text-xs text-foreground transition-colors ${
disabled ? "cursor-not-allowed" : "hover:border-muted-foreground/50" disabled ? "cursor-not-allowed" : "hover:border-muted-foreground/50"
} ${widthClass}`} } ${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 aria-hidden className="text-muted-foreground">
▾ ▾
</span> </span>
@@ -299,11 +314,21 @@ export function AgentSection({
/** Writes the composed WHOLE agent section (replace-PUT discipline). */ /** Writes the composed WHOLE agent section (replace-PUT discipline). */
onSectionChange: (next: AgentSettings) => void; onSectionChange: (next: AgentSettings) => void;
/** Writes the composed models section (slots — replace-PUT discipline). */ /** Writes the composed models section (slots — replace-PUT discipline). */
onModelsChange: (next: ModelsSettings) => void; onModelsChange: (next: ModelsSettings) => void | Promise<void>;
/** Writes the whole compaction section (replace-PUT discipline). */ /** Writes the whole compaction section (replace-PUT discipline). */
onCompactionChange: (next: CompactionSettings) => void; onCompactionChange: (next: CompactionSettings) => void;
}) { }) {
const { t } = useI18n();
const [catalog, setCatalog] = useState<ModelCatalogEntry[] | null>(null); const [catalog, setCatalog] = useState<ModelCatalogEntry[] | null>(null);
const [savingModels, setSavingModels] = useState(false);
const [modelsError, setModelsError] = useState<string | null>(null);
const updateModels = async (next: ModelsSettings) => {
if (savingModels) return;
setSavingModels(true); setModelsError(null);
try { await onModelsChange(next); }
catch { setModelsError("Could not save model settings"); }
finally { setSavingModels(false); }
};
/** The three tiers' resolved pins — GET /api/models `slots`. The rows' /** The three tiers' resolved pins — GET /api/models `slots`. The rows'
* Default hints and thinking lists resolve from it. */ * Default hints and thinking lists resolve from it. */
const [slots, setSlots] = useState<ModelsBody["slots"] | null>(null); const [slots, setSlots] = useState<ModelsBody["slots"] | null>(null);
@@ -492,7 +517,7 @@ export function AgentSection({
> >
<span>{label}</span> <span>{label}</span>
<span className="text-muted-foreground"> <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> </span>
</button> </button>
); );
@@ -527,7 +552,7 @@ export function AgentSection({
<Toggle <Toggle
checked={info.enabled} checked={info.enabled}
disabled={!fileExists} disabled={!fileExists}
label={`prompt ${info.path}`} label={t("prompt {v0}", { v0: info.path })}
onChange={(on) => onSectionChange(composePromptSwitch(agent, info.path, on))} onChange={(on) => onSectionChange(composePromptSwitch(agent, info.path, on))}
/> />
</span> </span>
@@ -540,26 +565,26 @@ export function AgentSection({
}} }}
className="text-xs text-muted-foreground/70 underline-offset-2 hover:text-foreground hover:underline" 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> </button>
{open && ( {open && (
<div className="mt-1 flex flex-col gap-2"> <div className="mt-1 flex flex-col gap-2">
<div> <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 <textarea
readOnly readOnly
value={info.sourceText} 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" 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>
<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 <textarea
value={segmentDraft[info.path] ?? info.overrideText ?? ""} value={segmentDraft[info.path] ?? info.overrideText ?? ""}
onChange={(e) => setSegmentDraft({ ...segmentDraft, [info.path]: e.target.value })} onChange={(e) => setSegmentDraft({ ...segmentDraft, [info.path]: e.target.value })}
aria-label="Your version of the instructions" aria-label={t("Your version of the instructions")}
placeholder="Add your own 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" 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> </div>
@@ -574,16 +599,14 @@ export function AgentSection({
: "cursor-not-allowed text-xs text-muted-foreground/40 underline-offset-2" : "cursor-not-allowed text-xs text-muted-foreground/40 underline-offset-2"
} }
> >
Save {t("Save")}</button>
</button>
{fileExists && ( {fileExists && (
<button <button
type="button" type="button"
onClick={() => void deleteSegment(info.path)} onClick={() => void deleteSegment(info.path)}
className="text-xs text-muted-foreground/70 underline-offset-2 hover:text-red-500 hover:underline" className="text-xs text-muted-foreground/70 underline-offset-2 hover:text-red-500 hover:underline"
> >
Delete {t("Delete")}</button>
</button>
)} )}
{/* The quiet inline confirmation — next to the {/* The quiet inline confirmation — next to the
action, not the old path-echoing line at the action, not the old path-echoing line at the
@@ -593,7 +616,7 @@ export function AgentSection({
status reads unambiguously. */} status reads unambiguously. */}
{segmentStatus && ( {segmentStatus && (
<p className="text-xs text-muted-foreground/70" data-testid="segment-status"> <p className="text-xs text-muted-foreground/70" data-testid="segment-status">
{segmentStatus} {t(segmentStatus)}
</p> </p>
)} )}
</div> </div>
@@ -614,7 +637,8 @@ export function AgentSection({
return ( return (
<> <>
<SettingsGroup label="Models" testid="group-models"> <SettingsGroup label={t("Models")} testid="group-models">
{modelsError && <p role="alert" className="text-xs text-destructive">{t(modelsError)}</p>}
{MODEL_SLOTS.map((slot) => { {MODEL_SLOTS.map((slot) => {
const override = models?.[slot]; const override = models?.[slot];
const modelId = override?.model?.id ?? ""; const modelId = override?.model?.id ?? "";
@@ -642,35 +666,52 @@ export function AgentSection({
label={SLOT_META[slot].label} label={SLOT_META[slot].label}
hint={SLOT_META[slot].roster} hint={SLOT_META[slot].roster}
testid={`slot-row-${slot}`} testid={`slot-row-${slot}`}
className="flex-wrap items-start gap-y-2 [&>span:first-child]:flex-[1_1_12rem]"
> >
{/* The anchor for the outside-click contract is the {/* The anchor for the outside-click contract is the
span holding BOTH triggers of the open menu. */} span holding the tier's controls. */}
<span <span
ref={thisMenuOpen ? menuAnchorRef : undefined} ref={thisMenuOpen ? menuAnchorRef : undefined}
className="relative flex items-center gap-2" className="relative flex w-68 max-w-full shrink-0 flex-col items-start gap-2"
> >
<span data-testid={`slot-controls-${slot}`} className="grid w-full grid-cols-[minmax(0,1fr)_5rem_auto] items-center gap-2">
<MenuTrigger
value={modelId ? prettyModel(modelId) : ""}
emptyLabel="—"
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" })}
widthClass="w-full min-w-0"
disabled={savingModels}
/>
<MenuTrigger
// Legacy display (chunk 2 item 4): no stored level + a known
// model → the RESOLVED level (the ordering's first supported —
// what the engine build runs). Display-only until a pick writes.
value={thinking || (chosen ? defaultOrderLevel(levels) : "")}
emptyLabel="—"
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" })}
widthClass="w-20"
disabled={savingModels}
/>
<Hint label={t(provider === "chatgpt" ? "Fast mode uses more ChatGPT allowance." : "Fast mode is available only for ChatGPT models.")}>
<span className="inline-flex">
<button type="button" data-testid={`slot-fast-${slot}`} aria-label={t("{v0} fast mode", { v0: t(SLOT_META[slot].label) })}
aria-pressed={provider === "chatgpt" && override?.fastMode === true} disabled={provider !== "chatgpt" || !chosen || savingModels}
onClick={() => { setOpenMenu(null); void updateModels(composeSlotFast(models, slot, !override?.fastMode)); }}
className={cn("flex h-7 items-center gap-1 rounded-sm border border-border px-1.5 font-ui text-xs transition-colors disabled:opacity-40",
provider === "chatgpt" && chosen ? "disabled:cursor-default" : "disabled:cursor-not-allowed",
provider === "chatgpt" && override?.fastMode ? "bg-muted text-foreground" : "bg-background text-muted-foreground hover:border-muted-foreground/50")}>
<ZapIcon className="size-3.5" aria-hidden /><span>{t("Fast")}</span>
</button>
</span>
</Hint>
</span>
{slot !== "strong" && <><ChatGPTBackgroundAccess provider={coord?.provider} /><NativeSubscriptionBackgroundAccess provider={coord?.provider} /></>} {slot !== "strong" && <><ChatGPTBackgroundAccess provider={coord?.provider} /><NativeSubscriptionBackgroundAccess provider={coord?.provider} /></>}
<MenuTrigger
value={modelId ? prettyModel(modelId) : ""}
emptyLabel="—"
ariaLabel={`${SLOT_META[slot].label} model`}
testid={`slot-model-${slot}`}
open={thisMenuOpen === "model"}
onClick={() => setOpenMenu(thisMenuOpen === "model" ? null : { target: slot, kind: "model" })}
widthClass="min-w-20"
/>
<MenuTrigger
// Legacy display (chunk 2 item 4): no stored level + a known
// model → the RESOLVED level (the ordering's first supported —
// what the engine build runs). Display-only until a pick writes.
value={thinking || (chosen ? defaultOrderLevel(levels) : "")}
emptyLabel="—"
ariaLabel={`${SLOT_META[slot].label} thinking`}
testid={`slot-thinking-${slot}`}
open={thisMenuOpen === "thinking"}
onClick={() => setOpenMenu(thisMenuOpen === "thinking" ? null : { target: slot, kind: "thinking" })}
widthClass="w-20"
/>
{thisMenuOpen === "model" && ( {thisMenuOpen === "model" && (
<MenuShell <MenuShell
preferredSide="down" preferredSide="down"
@@ -687,13 +728,13 @@ export function AgentSection({
// fresh pick writes a level — the stored one rides as-is // fresh pick writes a level — the stored one rides as-is
// (the write clamps server-side), else the ordering's level. // (the write clamps server-side), else the ordering's level.
const next = populateLevel(thinking || undefined, entry.supportedLevels); const next = populateLevel(thinking || undefined, entry.supportedLevels);
onModelsChange(composeSlotModel(models, slot, entry.provider, entry.id, next)); void updateModels(composeSlotModel(models, slot, entry.provider, entry.id, next));
setOpenMenu(null); setOpenMenu(null);
}} }}
footer={ footer={
catalog && catalog.length === 0 catalog && catalog.length === 0
? "No models — add a provider key in Keys" ? t("No models — add a provider key in Keys")
: "Runs every agent of this tier" : t("Runs every agent of this tier")
} }
/> />
</MenuShell> </MenuShell>
@@ -711,7 +752,7 @@ export function AgentSection({
// the same one the trigger shows. // the same one the trigger shows.
effectiveLevel={thinking || (chosen ? defaultOrderLevel(levels) : undefined)} effectiveLevel={thinking || (chosen ? defaultOrderLevel(levels) : undefined)}
onPick={(level) => { onPick={(level) => {
onModelsChange(composeSlotModel(models, slot, provider, modelId, level)); void updateModels(composeSlotModel(models, slot, provider, modelId, level));
setOpenMenu(null); setOpenMenu(null);
}} }}
/> />
@@ -723,7 +764,7 @@ export function AgentSection({
})} })}
</SettingsGroup> </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): {/* The prober's graph tools (wave 7, owner ruling 2026-09-23):
FIRST row of the group — its subject is the map, its FIRST row of the group — its subject is the map, its
mechanism is agent capability, so it lives in Tools but mechanism is agent capability, so it lives in Tools but
@@ -735,15 +776,15 @@ export function AgentSection({
const graphOn = graph?.enabled ?? true; const graphOn = graph?.enabled ?? true;
return ( return (
<SettingsRow <SettingsRow
label="Graph tools" label={t("Graph tools")}
hint={graphOn ? "Lets the tutor consult your knowledge map between sessions" : "Graph is disabled"} hint={graphOn ? t("Lets the tutor consult your knowledge map between sessions") : t("Graph is disabled")}
testid="graph-tools-row" testid="graph-tools-row"
dimmed={!graphOn} dimmed={!graphOn}
> >
<Toggle <Toggle
checked={agent?.graphTools?.enabled ?? true} checked={agent?.graphTools?.enabled ?? true}
disabled={!graphOn} disabled={!graphOn}
label="graph tools" label={t("graph tools")}
onChange={(on) => onSectionChange(composeGraphTools(agent, on))} onChange={(on) => onSectionChange(composeGraphTools(agent, on))}
/> />
</SettingsRow> </SettingsRow>
@@ -757,14 +798,15 @@ export function AgentSection({
const free = agent?.tools?.explain === true; const free = agent?.tools?.explain === true;
return ( return (
<SettingsRow <SettingsRow
label="Quizzing" label={t("Quizzing")}
hint="How both tutors check your understanding" hint={t("How both tutors check your understanding")}
testid="quiz-mechanics-row" testid="quiz-mechanics-row"
className="flex-wrap gap-y-2 [&>span]:flex-[1_1_12rem]"
> >
<div <div
role="radiogroup" 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" className="flex max-w-full shrink-0 rounded-lg border border-border/60 bg-muted/30 p-0.5"
> >
{(["choice", "free"] as const).map((m) => { {(["choice", "free"] as const).map((m) => {
const active = m === "free" ? free : !free; const active = m === "free" ? free : !free;
@@ -777,11 +819,11 @@ export function AgentSection({
data-testid={`quiz-mechanics-${m}`} data-testid={`quiz-mechanics-${m}`}
onClick={() => onSectionChange(composeQuizMechanics(agent, m))} onClick={() => onSectionChange(composeQuizMechanics(agent, m))}
className={cn( className={cn(
"h-8 rounded-md px-3 text-sm font-medium transition-colors", "min-h-8 min-w-0 rounded-md px-3 py-1 text-sm leading-snug font-medium break-words transition-colors",
active ? "bg-background text-foreground shadow-sm" : "text-muted-foreground hover:text-foreground", 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> </button>
); );
})} })}
@@ -792,12 +834,12 @@ export function AgentSection({
{TOGGLE_TOOLS.map((id) => ( {TOGGLE_TOOLS.map((id) => (
<SettingsRow <SettingsRow
key={id} key={id}
label={`Tool · ${id}`} label={t("Tool · {v0}", { v0: id })}
testid={`tool-row-${id}`} testid={`tool-row-${id}`}
> >
<Toggle <Toggle
checked={agent?.tools?.[id] ?? true} checked={agent?.tools?.[id] ?? true}
label={`tool ${id}`} label={t("tool {v0}", { v0: id })}
onChange={(on) => onSectionChange(composeTool(agent, id, on))} onChange={(on) => onSectionChange(composeTool(agent, id, on))}
/> />
</SettingsRow> </SettingsRow>
@@ -808,7 +850,7 @@ export function AgentSection({
const open = openMenu?.target === "search"; const open = openMenu?.target === "search";
return ( return (
<SettingsRow <SettingsRow
label="Search engine" label={t("Search engine")}
hint={search?.reason ?? undefined} hint={search?.reason ?? undefined}
testid="search-engine-row" testid="search-engine-row"
dimmed={!anyKey} dimmed={!anyKey}
@@ -816,8 +858,8 @@ export function AgentSection({
<span ref={open ? menuAnchorRef : undefined} className="relative flex items-center gap-2"> <span ref={open ? menuAnchorRef : undefined} className="relative flex items-center gap-2">
<MenuTrigger <MenuTrigger
value={engine === "tavily" ? "Tavily" : engine === "brave" ? "Brave" : ""} value={engine === "tavily" ? "Tavily" : engine === "brave" ? "Brave" : ""}
emptyLabel="Automatic" emptyLabel={t("Automatic")}
ariaLabel="Search engine" ariaLabel={t("Search engine")}
testid="search-engine-trigger" testid="search-engine-trigger"
open={open} open={open}
onClick={() => setOpenMenu(open ? null : { target: "search", kind: "engine" })} onClick={() => setOpenMenu(open ? null : { target: "search", kind: "engine" })}
@@ -831,7 +873,7 @@ export function AgentSection({
testid="search-engine-menu" 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" 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)); onSectionChange(composeSearchEngine(agent, null));
setOpenMenu(null); setOpenMenu(null);
})} })}
@@ -849,17 +891,17 @@ export function AgentSection({
})()} })()}
</SettingsGroup> </SettingsGroup>
<SettingsGroup label="Context" testid="group-context"> <SettingsGroup label={t("Context")} testid="group-context">
<SettingsRow <SettingsRow
label="Compaction threshold" label={t("Compaction threshold")}
hint={`${resolvedCompaction.thresholdTokens} tok`} hint={t("{v0} tok", { v0: resolvedCompaction.thresholdTokens })}
testid="compaction-row-threshold" testid="compaction-row-threshold"
> >
<CommitInput <CommitInput
type="number" type="number"
value={String(compaction?.thresholdTokens ?? "")} value={String(compaction?.thresholdTokens ?? "")}
placeholder={String(resolvedCompaction.thresholdTokens)} placeholder={String(resolvedCompaction.thresholdTokens)}
ariaLabel="compaction threshold" ariaLabel={t("compaction threshold")}
testid="input-compaction-threshold" testid="input-compaction-threshold"
onCommit={(v) => { onCommit={(v) => {
// Empty = factory default (the budget row's discipline): // Empty = factory default (the budget row's discipline):
@@ -882,9 +924,9 @@ export function AgentSection({
</SettingsRow> </SettingsRow>
</SettingsGroup> </SettingsGroup>
<SettingsGroup label="System prompts" testid="group-prompts"> <SettingsGroup label={t("System prompts")} testid="group-prompts">
{prompts === null ? ( {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"> <div className="flex flex-col gap-4">
{/* The owner's ruled order (2026-09-26): Teacher first, {/* The owner's ruled order (2026-09-26): Teacher first,
@@ -894,10 +936,10 @@ export function AgentSection({
{sectionedSegments(prompts).map((section) => ( {sectionedSegments(prompts).map((section) => (
<div key={section.id} data-testid={`prompt-section-${section.id}`}> <div key={section.id} data-testid={`prompt-section-${section.id}`}>
<p className="mb-1 text-xs uppercase tracking-widest text-muted-foreground/70"> <p className="mb-1 text-xs uppercase tracking-widest text-muted-foreground/70">
{section.label} {t(section.label)}
</p> </p>
{section.hint && ( {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"> <div className="flex flex-col gap-2">
{section.segments.map((info) => segmentRow(info))} {section.segments.map((info) => segmentRow(info))}
+36 -32
View File
@@ -1,4 +1,6 @@
"use client"; "use client";
import { useI18n } from "@/components/i18n";
/** /**
* The Appearance + Graph sections (thin settings wave). * 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. */ /** PUT the whole appearance section; the page re-applies on success. */
onSectionChange: (next: AppearanceSettings) => void; onSectionChange: (next: AppearanceSettings) => void;
}) { }) {
const { t, language } = useI18n();
const { appearance: resolved } = useAppearance(); const { appearance: resolved } = useAppearance();
const md = appearance?.mdRendering; const md = appearance?.mdRendering;
@@ -186,27 +189,27 @@ export function AppearanceSection({
return ( 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> <ThinSelect value="dark" ariaLabel="mode" onChange={() => {}} testid="select-mode" disabled>
<option value="dark">Dark</option> <option value="dark">{t("Dark")}</option>
</ThinSelect> </ThinSelect>
</SettingsRow> </SettingsRow>
<SettingsRow <SettingsRow
label="Background" label={t("Background")}
hint={`Empty = ${resolved.background ?? "#141414"}`} hint={t("Empty = {v0}", { v0: resolved.background ?? "#141414" })}
testid="row-background" testid="row-background"
> >
<CommitInput <CommitInput
value={appearance?.background ?? ""} value={appearance?.background ?? ""}
placeholder="#141414" placeholder="#141414"
ariaLabel="background hex" ariaLabel={t("background hex")}
testid="input-background" testid="input-background"
onCommit={(v) => onSectionChange({ ...appearance, background: v === "" ? null : v })} onCommit={(v) => onSectionChange({ ...appearance, background: v === "" ? null : v })}
/> />
</SettingsRow> </SettingsRow>
<SettingsRow <SettingsRow
label="Reading font" label={t("Reading font")}
hint={`Empty = ${DEFAULT_READING_FONT}`} hint={t("Empty = {v0}", { v0: language === "ru" ? "Noto Sans" : DEFAULT_READING_FONT })}
testid="row-font" testid="row-font"
> >
<span ref={fontMenuOpen ? menuAnchorRef : undefined} className="relative flex items-center gap-2"> <span ref={fontMenuOpen ? menuAnchorRef : undefined} className="relative flex items-center gap-2">
@@ -222,13 +225,13 @@ export function AppearanceSection({
const triggerButton = ( const triggerButton = (
<button <button
type="button" type="button"
aria-label="Reading font" aria-label={t("Reading font")}
aria-expanded={fontMenuOpen} aria-expanded={fontMenuOpen}
data-testid="font-trigger" data-testid="font-trigger"
onClick={openFontMenu} 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" 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 aria-hidden className="text-muted-foreground">
▾ ▾
</span> </span>
@@ -237,7 +240,7 @@ export function AppearanceSection({
return fontMenuOpen ? ( return fontMenuOpen ? (
triggerButton triggerButton
) : ( ) : (
<Hint label="Choose from your installed fonts" side="left"> <Hint label={t("Choose from your installed fonts")} side="left">
{triggerButton} {triggerButton}
</Hint> </Hint>
); );
@@ -258,21 +261,21 @@ export function AppearanceSection({
? undefined ? undefined
: localFonts === null : localFonts === null
? enumEmpty ? enumEmpty
? "Common families — installed fonts could not be read" ? t("Common families — installed fonts could not be read")
: "Common families — your installed list needs the desktop app" : t("Common families — your installed list needs the desktop app")
: "Your installed fonts" : t("Your installed fonts")
} }
/> />
</MenuShell> </MenuShell>
)} )}
</span> </span>
</SettingsRow> </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 <CommitInput
type="number" type="number"
value={String(appearance?.proseFontSize ?? "")} value={String(appearance?.proseFontSize ?? "")}
placeholder="16" placeholder="16"
ariaLabel="prose font size" ariaLabel={t("prose font size")}
testid="input-prose-size" testid="input-prose-size"
onCommit={(v) => { onCommit={(v) => {
// Empty = factory default (owner, fix wave): omit the key so the record // Empty = factory default (owner, fix wave): omit the key so the record
@@ -288,19 +291,19 @@ export function AppearanceSection({
}} }}
/> />
</SettingsRow> </SettingsRow>
<SettingsRow label="Session names in serif" testid="row-session-serif"> <SettingsRow label={t("Session names in serif")} testid="row-session-serif">
<Toggle <Toggle
checked={appearance?.sessionNameSerif ?? true} checked={appearance?.sessionNameSerif ?? true}
label="session names serif" label={t("session names serif")}
onChange={(on) => onSectionChange({ ...appearance, sessionNameSerif: on })} onChange={(on) => onSectionChange({ ...appearance, sessionNameSerif: on })}
/> />
</SettingsRow> </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 <CommitInput
type="number" type="number"
value={String(md?.headingWeight ?? "")} value={String(md?.headingWeight ?? "")}
placeholder="900" placeholder="900"
ariaLabel="heading weight" ariaLabel={t("heading weight")}
testid="input-heading-weight" testid="input-heading-weight"
onCommit={(v) => { onCommit={(v) => {
// Empty = factory default (owner, fix wave): Number("") is 0, so // 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 {/* The emphasis inks default to the REGULAR TEXT COLOR now
(appearance wave, 2026-09-26) — the empty-state labels say (appearance wave, 2026-09-26) — the empty-state labels say
so; explicit hex overrides ride unchanged. */} 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 <CommitInput
value={md?.boldColor ?? ""} value={md?.boldColor ?? ""}
placeholder="same as text" placeholder={t("same as text")}
ariaLabel="bold color" ariaLabel={t("bold color")}
testid="input-bold-color" testid="input-bold-color"
onCommit={(v) => onSectionChange({ ...appearance, mdRendering: { ...md, boldColor: v === "" ? null : v } })} onCommit={(v) => onSectionChange({ ...appearance, mdRendering: { ...md, boldColor: v === "" ? null : v } })}
/> />
</SettingsRow> </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 <CommitInput
value={md?.italicColor ?? ""} value={md?.italicColor ?? ""}
placeholder="same as text" placeholder={t("same as text")}
ariaLabel="italic color" ariaLabel={t("italic color")}
testid="input-italic-color" testid="input-italic-color"
onCommit={(v) => onSectionChange({ ...appearance, mdRendering: { ...md, italicColor: v === "" ? null : v } })} onCommit={(v) => onSectionChange({ ...appearance, mdRendering: { ...md, italicColor: v === "" ? null : v } })}
/> />
@@ -350,6 +353,7 @@ export function GraphSection({
graph: GraphSettings | undefined; graph: GraphSettings | undefined;
onSectionChange: (next: GraphSettings) => void; onSectionChange: (next: GraphSettings) => void;
}) { }) {
const { t } = useI18n();
// Wave 7 collapse (owner ruling 2026-09-23): the section is the MASTER // Wave 7 collapse (owner ruling 2026-09-23): the section is the MASTER
// SWITCH — the embedding/clustering/labeling rungs are gone; clustering // SWITCH — the embedding/clustering/labeling rungs are gone; clustering
// is the free birth-session partition derived on read, labels ARE session // 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 switch below arrived: off ≠ no graph, off = the graph stops
growing. The testid keeps the old name's shape (row-enabled) so growing. The testid keeps the old name's shape (row-enabled) so
this stays one rename, not a test re-pin. */} 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 <Toggle
checked={graph?.enabled ?? true} checked={graph?.enabled ?? true}
label="extraction enabled" label={t("extraction enabled")}
onChange={(on) => onChange={(on) =>
// Compose, never replace-whole (the section PUT is, but the // Compose, never replace-whole (the section PUT is, but the
// row must not drop its sibling field — fixed with the budget // 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 round): display-only. Off = the home shows the chains view
instead of the canvas. The ledgers, the pipeline, everything instead of the canvas. The ledgers, the pipeline, everything
data keeps working — that is the extraction switch above. */} 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 <Toggle
checked={graph?.rendering ?? true} checked={graph?.rendering ?? true}
label="graph rendering enabled" label={t("graph rendering enabled")}
onChange={(on) => onSectionChange({ ...graph, rendering: on })} onChange={(on) => onSectionChange({ ...graph, rendering: on })}
/> />
</SettingsRow> </SettingsRow>
<SettingsRow <SettingsRow
label="Extraction chunk budget" label={t("Extraction chunk budget")}
hint={`${resolved.extractionChunkTokens} tok`} hint={t("{v0} tok", { v0: resolved.extractionChunkTokens })}
testid="graph-row-chunk-tokens" testid="graph-row-chunk-tokens"
> >
<CommitInput <CommitInput
type="number" type="number"
value={String(graph?.extractionChunkTokens ?? "")} value={String(graph?.extractionChunkTokens ?? "")}
placeholder={String(resolved.extractionChunkTokens)} placeholder={String(resolved.extractionChunkTokens)}
ariaLabel="extraction chunk budget" ariaLabel={t("extraction chunk budget")}
testid="input-chunk-tokens" testid="input-chunk-tokens"
onCommit={(v) => { onCommit={(v) => {
// Empty = factory default (the fix-wave discipline): absence is // Empty = factory default (the fix-wave discipline): absence is
+41 -35
View File
@@ -1,4 +1,6 @@
"use client"; "use client";
import { useI18n } from "@/components/i18n";
import { createContext, useContext, useEffect, useRef, useState } from "react"; import { createContext, useContext, useEffect, useRef, useState } from "react";
import type { ChatGPTStatus } from "@/src/core"; 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 /** UI lifetime spans setup's keys → models transition. Only existing safe controls
* are used; account IDs remain control values, never displayed account labels. */ * are used; account IDs remain control values, never displayed account labels. */
export function ChatGPTUIProvider({ children }: { children: React.ReactNode }) { export function ChatGPTUIProvider({ children }: { children: React.ReactNode }) {
const { t } = useI18n();
const [status, setStatus] = useState<ChatGPTStatus>(empty); const [status, setStatus] = useState<ChatGPTStatus>(empty);
const [dialog, setDialog] = useState<Dialog | null>(null); const [dialog, setDialog] = useState<Dialog | null>(null);
const [busy, setBusy] = useState(false); 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"}> {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> <h2 className="text-sm font-semibold text-foreground">ChatGPT</h2>
{dialog.kind === "warning" ? <> {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> <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); }}>Dismiss</Button></div> <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"> </> : 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-sm font-semibold text-red-400">{t(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-foreground">{managed?.email || t("ChatGPT account")}</p>
<p className="mt-3 text-xs text-muted-foreground">{CHATGPT_DISCONNECT_COPY.body}</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">{context.error}</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"> <div className="mt-4 flex items-center justify-end gap-2">
<Button variant="ghost" size="sm" disabled={busy} onClick={close}>Cancel</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)}>Disconnect account</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>
</div> : <> </div> : <>
{dialog.kind === "connect" ? <> {dialog.kind === "connect" ? <>
{status.accounts.length > 0 && <div className="mt-3 flex items-center gap-2 text-xs text-muted-foreground"> {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"> <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="">Add another account or workspace</option> <option value="">{t("Add another account or workspace")}</option>
{status.accounts.map((a) => <option key={a.id} value={a.id}>{a.email || "ChatGPT account"}</option>)} {status.accounts.map((a) => <option key={a.id} value={a.id}>{a.email || t("ChatGPT account")}</option>)}
</select> </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>} </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} </> : 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"> <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); } if (dialog.kind === "connect") { setBackground(enabled); setBackgroundDirty(true); }
else if (bridge) void control("change", () => bridge.consent(enabled)); 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>
<div className="mt-3 flex flex-wrap items-center gap-3 text-xs text-muted-foreground"> <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://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">Manage usage</a> <a href="https://chatgpt.com/settings/usage" target="_blank" rel="noreferrer" className="underline underline-offset-2">{t("Manage usage")}</a>
</div> </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.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">Waiting for browser sign-in…</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">{context.error}</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"> {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"> <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.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 || "ChatGPT account"}</option>)} {status.accounts.map((a) => <option key={a.id} value={a.id}>{a.email || t("ChatGPT account")}</option>)}
</select> : <span className="break-all">{managed.email || "ChatGPT account"}</span>} </select> : <span className="break-all">{managed.email || t("ChatGPT account")}</span>}
</div> </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>} </div>}
<ConnectionFooter testid="chatgpt-account-footer" leading={dialog.kind === "manage" && <div data-testid="chatgpt-account-actions"> <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>}> </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>} {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" ? "Cancel" : "Close"}</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" />} {dialog.kind === "connect" && <ContinueButton onClick={() => void login()} disabled={!status.available || busy || status.pending} testid="chatgpt-proceed" />}
</ConnectionFooter> </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 }) { 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"> 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. */} {/* Official unmodified mark; existing secondary button hover/focus treatment. */}
{/* eslint-disable-next-line @next/next/no-img-element */} {/* eslint-disable-next-line @next/next/no-img-element */}
<img src="/siwc/chatgpt-logo-white.svg" width="16" height="16" alt="" aria-hidden="true" /> <img src="/siwc/chatgpt-logo-white.svg" width="16" height="16" alt="" aria-hidden="true" />
Continue with ChatGPT {t("Continue with ChatGPT")}</Button>;
</Button>;
} }
function AccountDialog({ children, onClose, busy }: { children: React.ReactNode; onClose: () => void; busy: boolean }) { 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. */ /** Shared compact settings/setup row; account selection precedes the quiet action. */
export function ChatGPTConnection(_props: { setup?: boolean } = {}) { export function ChatGPTConnection(_props: { setup?: boolean } = {}) {
const { t } = useI18n();
const ui = useContext(Context); const ui = useContext(Context);
const active = ui.status.accounts.find((a) => a.id === ui.status.activeId && a.connected); const active = ui.status.accounts.find((a) => a.id === ui.status.activeId && a.connected);
const accounts = ui.status.accounts.filter((a) => a.connected); const accounts = ui.status.accounts.filter((a) => a.connected);
return <div className="flex flex-col gap-2" data-testid="chatgpt-connection"> 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()}> <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="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"> {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 || "ChatGPT account"}</option>)} {accounts.map((a) => <option key={a.id} value={a.id}>{a.email || t("ChatGPT account")}</option>)}
</select>} </select>}
</ConnectionRow> </ConnectionRow>
{ui.inlineError && <p role="alert" className="text-xs text-red-400">{ui.inlineError}</p>} {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>; </div>;
} }
/** Contextual consent discovery belongs beside background model choices only. */ /** Contextual consent discovery belongs beside background model choices only. */
export function ChatGPTBackgroundAccess({ provider }: { provider: string | undefined }) { export function ChatGPTBackgroundAccess({ provider }: { provider: string | undefined }) {
const { t } = useI18n();
const ui = useContext(Context); const ui = useContext(Context);
const account: Account | undefined = ui.status.accounts.find((a) => a.id === ui.status.activeId && a.connected); 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; 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"; "use client";
import { useI18n } from "@/components/i18n";
import { useEffect, useRef } from "react"; import { useEffect, useRef } from "react";
import { createPortal } from "react-dom"; import { createPortal } from "react-dom";
import { useBackdropDismiss } from "@/components/backdrop-dismiss"; import { useBackdropDismiss } from "@/components/backdrop-dismiss";
import { Button } from "@/components/ui/button"; 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 }) { 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}> return <div className="flex flex-wrap items-center gap-3" data-testid={testid}>
<span className="font-ui text-xs text-foreground">{label}</span> <span className="font-ui text-xs text-foreground">{label}</span>
{children} {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>; </div>;
} }
+17 -9
View File
@@ -1,4 +1,6 @@
"use client"; "use client";
import { useI18n } from "@/components/i18n";
/** /**
* Thin settings primitives (settings-app wave): the control kit the * Thin settings primitives (settings-app wave): the control kit the
* settings dialog needs. Sections → groups → rows; everything SSRs clean * settings dialog needs. Sections → groups → rows; everything SSRs clean
@@ -25,10 +27,11 @@ export function SettingsSection({
children: React.ReactNode; children: React.ReactNode;
testid?: string; testid?: string;
}) { }) {
const { t } = useI18n();
return ( return (
<section data-testid={testid} className="px-6 py-5"> <section data-testid={testid} className="px-6 py-5">
<h2 className="text-sm uppercase tracking-widest text-foreground">{title}</h2> <h2 className="text-sm uppercase tracking-widest text-foreground">{t(title)}</h2>
{hint && <p className="mt-1 text-xs text-muted-foreground">{hint}</p>} {hint && <p className="mt-1 text-xs text-muted-foreground">{t(hint)}</p>}
<div className="mt-4 flex flex-col gap-3">{children}</div> <div className="mt-4 flex flex-col gap-3">{children}</div>
</section> </section>
); );
@@ -45,9 +48,10 @@ export function SettingsGroup({
children: React.ReactNode; children: React.ReactNode;
testid?: string; testid?: string;
}) { }) {
const { t } = useI18n();
return ( 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"> <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 className="flex flex-col gap-2">{children}</div>
</div> </div>
); );
@@ -61,6 +65,7 @@ export function SettingsRow({
children, children,
testid, testid,
dimmed, dimmed,
className,
}: { }: {
label: string; label: string;
hint?: string; hint?: string;
@@ -69,12 +74,14 @@ export function SettingsRow({
/** Dependency graying, graph-view style: the whole row dims when the /** Dependency graying, graph-view style: the whole row dims when the
* control is inert (parent off, no file yet, …). */ * control is inert (parent off, no file yet, …). */
dimmed?: boolean; dimmed?: boolean;
className?: string;
}) { }) {
const { t } = useI18n();
return ( return (
<div data-testid={testid} className={cn("flex items-center justify-between gap-4 py-1", dimmed && "opacity-40")}> <div data-testid={testid} className={cn("flex items-center justify-between gap-4 py-1", dimmed && "opacity-40", className)}>
<span className="min-w-0"> <span className="min-w-0">
<span className="block font-prose text-(length:--lh-prose-size)">{label}</span> <span className="block font-prose text-(length:--lh-prose-size)">{t(label)}</span>
{hint && <span className="block text-xs text-muted-foreground/70">{hint}</span>} {hint && <span className="block text-xs text-muted-foreground/70">{t(hint)}</span>}
</span> </span>
{children} {children}
</div> </div>
@@ -102,13 +109,14 @@ export function Toggle({
/** Optional native tooltip on the switch. */ /** Optional native tooltip on the switch. */
title?: string; title?: string;
}) { }) {
const { t } = useI18n();
return ( return (
<button <button
type="button" type="button"
role="switch" role="switch"
aria-checked={checked} aria-checked={checked}
aria-label={label} aria-label={t(label)}
{...(title ? { title } : {})} {...(title ? { title: t(title) } : {})}
disabled={disabled} disabled={disabled}
data-testid={`toggle-${label.toLowerCase().replace(/[^a-z0-9]+/g, "-")}`} data-testid={`toggle-${label.toLowerCase().replace(/[^a-z0-9]+/g, "-")}`}
onClick={() => onChange(!checked)} onClick={() => onChange(!checked)}
@@ -231,4 +239,4 @@ export function CommitInput({
className="h-7 w-40 rounded-sm border border-border bg-background px-2 font-ui text-xs text-foreground outline-none placeholder:text-muted-foreground/60" className="h-7 w-40 rounded-sm border border-border bg-background px-2 font-ui text-xs text-foreground outline-none placeholder:text-muted-foreground/60"
/> />
); );
} }
+12 -13
View File
@@ -1,4 +1,6 @@
"use client"; "use client";
import { useI18n } from "@/components/i18n";
/** /**
* The Keyboard section (thin settings wave): view / rebind / reset over * The Keyboard section (thin settings wave): view / rebind / reset over
* core/keyboard.ts's registry. The overrides persist in localStorage * core/keyboard.ts's registry. The overrides persist in localStorage
@@ -48,6 +50,7 @@ export function nextOverrides(
} }
export function KeyboardSection() { export function KeyboardSection() {
const { t } = useI18n();
const [overrides, setOverrides] = useState<Partial<Record<KeyAction, string>>>({}); const [overrides, setOverrides] = useState<Partial<Record<KeyAction, string>>>({});
const [rebinding, setRebinding] = useState<KeyAction | null>(null); const [rebinding, setRebinding] = useState<KeyAction | null>(null);
const [nonce, setNonce] = useState(0); const [nonce, setNonce] = useState(0);
@@ -101,20 +104,20 @@ export function KeyboardSection() {
if (!actions.length) return null; if (!actions.length) return null;
return ( return (
<div key={scope} data-testid={`kbd-scope-${scope}`}> <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"> <div className="flex flex-col gap-2">
{actions.map((def) => { {actions.map((def) => {
const effective = overrides[def.id] ?? def.binding; const effective = overrides[def.id] ?? def.binding;
return ( return (
<SettingsRow <SettingsRow
key={def.id} key={def.id}
label={def.label} label={t(def.label)}
hint={def.displayOnly ? "Fixed — part of this screen" : undefined} hint={def.displayOnly ? t("Fixed — part of this screen") : undefined}
testid={`kbd-row-${def.id}`} testid={`kbd-row-${def.id}`}
> >
<span className="flex items-center gap-2"> <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"> <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> </kbd>
{def.displayOnly ? null : rebinding === def.id ? ( {def.displayOnly ? null : rebinding === def.id ? (
<button <button
@@ -122,8 +125,7 @@ export function KeyboardSection() {
onClick={() => setRebinding(null)} onClick={() => setRebinding(null)}
className="text-xs text-muted-foreground/70 underline-offset-2 hover:text-foreground hover:underline" className="text-xs text-muted-foreground/70 underline-offset-2 hover:text-foreground hover:underline"
> >
Cancel {t("Cancel")}</button>
</button>
) : ( ) : (
<> <>
<button <button
@@ -132,8 +134,7 @@ export function KeyboardSection() {
data-testid={`kbd-rebind-${def.id}`} data-testid={`kbd-rebind-${def.id}`}
className="text-xs text-muted-foreground underline-offset-2 hover:text-foreground hover:underline" className="text-xs text-muted-foreground underline-offset-2 hover:text-foreground hover:underline"
> >
Rebind {t("Rebind")}</button>
</button>
{overridden(def.id) && ( {overridden(def.id) && (
<button <button
type="button" type="button"
@@ -141,8 +142,7 @@ export function KeyboardSection() {
data-testid={`kbd-reset-${def.id}`} data-testid={`kbd-reset-${def.id}`}
className="text-xs text-muted-foreground/70 underline-offset-2 hover:text-foreground hover:underline" className="text-xs text-muted-foreground/70 underline-offset-2 hover:text-foreground hover:underline"
> >
Reset {t("Reset")}</button>
</button>
)} )}
</> </>
)} )}
@@ -155,15 +155,14 @@ export function KeyboardSection() {
); );
})} })}
<div className="mt-1 flex items-center justify-between"> <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 <button
type="button" type="button"
onClick={resetAll} onClick={resetAll}
data-testid="kbd-reset-all" data-testid="kbd-reset-all"
className="text-xs text-muted-foreground underline-offset-2 hover:text-foreground hover:underline" className="text-xs text-muted-foreground underline-offset-2 hover:text-foreground hover:underline"
> >
Reset all to defaults {t("Reset all to defaults")}</button>
</button>
</div> </div>
</> </>
); );
+39 -27
View File
@@ -1,4 +1,7 @@
"use client"; "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 * The Keys section (wave-5 polish, 2026-09-21 — owner design): a MINIMAL
* key-value table. Provider left, masked key right, no description hints * 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 /** The minimal status line, e.g. "…ab12 · 64 chars" (" · env" marks an
* environment-owned value — the row itself is read-only then). Pure. */ * environment-owned value — the row itself is read-only then). Pure. */
export function keyStatusLine(status: AuthKeyStatus): string { export function keyStatusLine(status: AuthKeyStatus, t: Translator = translator("en")): string {
if (!status.present || !status.masked) return "not set"; if (!status.present || !status.masked) return t("not set");
const tail = status.masked.replace(/ \((\d+) chars\)$/, ""); const tail = status.masked.replace(/ \((\d+) chars\)$/, "");
const chars = status.masked.match(/\((\d+) chars\)$/)?.[1] ?? "?"; 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 /** One table row: label left, tiny docs link + status right. Writable
@@ -132,6 +135,7 @@ function KeyTableRow({
onCancel: () => void; onCancel: () => void;
testid: string; testid: string;
}) { }) {
const { t } = useI18n();
const envOwned = status.source === "env"; const envOwned = status.source === "env";
const [value, setValue] = useState(""); const [value, setValue] = useState("");
const [error, setError] = useState<string | null>(null); const [error, setError] = useState<string | null>(null);
@@ -163,8 +167,8 @@ function KeyTableRow({
onKeyDown={(e) => { onKeyDown={(e) => {
if (e.key === "Enter") void save(); if (e.key === "Enter") void save();
}} }}
placeholder="Paste key…" placeholder={t("Paste key…")}
aria-label={`${label} key`} aria-label={t("{v0} key", { v0: label })}
data-testid={`${testid}-input`} data-testid={`${testid}-input`}
disabled={busy} disabled={busy}
autoFocus autoFocus
@@ -177,8 +181,7 @@ function KeyTableRow({
data-testid={`${testid}-save`} data-testid={`${testid}-save`}
className="shrink-0 text-xs text-foreground underline-offset-2 hover:underline disabled:text-muted-foreground/40 disabled:no-underline" className="shrink-0 text-xs text-foreground underline-offset-2 hover:underline disabled:text-muted-foreground/40 disabled:no-underline"
> >
Save {t("Save")}</button>
</button>
<button <button
type="button" type="button"
onClick={onCancel} onClick={onCancel}
@@ -186,8 +189,7 @@ function KeyTableRow({
data-testid={`${testid}-cancel`} data-testid={`${testid}-cancel`}
className="shrink-0 text-xs text-muted-foreground/70 underline-offset-2 hover:text-foreground hover:underline" className="shrink-0 text-xs text-muted-foreground/70 underline-offset-2 hover:text-foreground hover:underline"
> >
Cancel {t("Cancel")}</button>
</button>
{status.present && status.source === "file" && onDelete && ( {status.present && status.source === "file" && onDelete && (
<button <button
type="button" type="button"
@@ -196,13 +198,12 @@ function KeyTableRow({
data-testid={`${testid}-delete`} data-testid={`${testid}-delete`}
className="shrink-0 text-xs text-muted-foreground/70 underline-offset-2 hover:text-red-500 hover:underline" className="shrink-0 text-xs text-muted-foreground/70 underline-offset-2 hover:text-red-500 hover:underline"
> >
Delete {t("Delete")}</button>
</button>
)} )}
</div> </div>
{error && ( {error && (
<p role="alert" data-testid={`${testid}-error`} className="px-1 text-xs text-red-500"> <p role="alert" data-testid={`${testid}-error`} className="px-1 text-xs text-red-500">
{error} {t(error)}
</p> </p>
)} )}
</div> </div>
@@ -230,17 +231,16 @@ function KeyTableRow({
onClick={(e) => e.stopPropagation()} onClick={(e) => e.stopPropagation()}
className="text-[10px] text-muted-foreground/60 underline-offset-2 hover:text-foreground hover:underline" className="text-[10px] text-muted-foreground/60 underline-offset-2 hover:text-foreground hover:underline"
> >
key ↗ {t("key ↗")}</a>
</a>
)} )}
<span data-testid={`${testid}-status`} className="font-mono text-[11px] text-muted-foreground"> <span data-testid={`${testid}-status`} className="font-mono text-[11px] text-muted-foreground">
{keyStatusLine(status)} {keyStatusLine(status, t)}
</span> </span>
</span> </span>
</button> </button>
{error && ( {error && (
<p role="alert" data-testid={`${testid}-error`} className="pl-6 text-xs text-red-500"> <p role="alert" data-testid={`${testid}-error`} className="pl-6 text-xs text-red-500">
{error} {t(error)}
</p> </p>
)} )}
</div> </div>
@@ -256,6 +256,7 @@ function AddProviderRow({
addable: ProviderKeyInfo[]; addable: ProviderKeyInfo[];
onPick: (p: ProviderKeyInfo) => void; onPick: (p: ProviderKeyInfo) => void;
}) { }) {
const { t } = useI18n();
const [open, setOpen] = useState(false); const [open, setOpen] = useState(false);
const anchorRef = useRef<HTMLDivElement>(null); const anchorRef = useRef<HTMLDivElement>(null);
@@ -290,7 +291,7 @@ function AddProviderRow({
onClick={() => setOpen((v) => !v)} 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" 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>} {addable.length > 0 && <span className="font-mono text-[11px]">{addable.length}</span>}
</button> </button>
{open && ( {open && (
@@ -324,6 +325,7 @@ export function KeysSection({
}: { }: {
initialRow?: string; initialRow?: string;
}) { }) {
const { t } = useI18n();
const [statuses, setStatuses] = useState<AuthKeyStatus[] | null>(null); const [statuses, setStatuses] = useState<AuthKeyStatus[] | null>(null);
const [providers, setProviders] = useState<ProviderKeyInfo[] | undefined>(undefined); const [providers, setProviders] = useState<ProviderKeyInfo[] | undefined>(undefined);
const [loading, setLoading] = useState(true); const [loading, setLoading] = useState(true);
@@ -333,6 +335,7 @@ export function KeysSection({
/** The paste editor for a freshly chosen Add-provider target. */ /** The paste editor for a freshly chosen Add-provider target. */
const [adding, setAdding] = useState<ProviderKeyInfo | null>(null); const [adding, setAdding] = useState<ProviderKeyInfo | null>(null);
const [addError, setAddError] = useState<string | null>(null); const [addError, setAddError] = useState<string | null>(null);
const followedProvider = useRef<string | null>(null);
// Escape folds any open editor (unfolded row or the Add-provider paste) // Escape folds any open editor (unfolded row or the Add-provider paste)
// WITHOUT closing the dialog. Claimed at document CAPTURE phase — the // WITHOUT closing the dialog. Claimed at document CAPTURE phase — the
@@ -370,7 +373,17 @@ export function KeysSection({
// named row; a fresh mount seeds it via the useState initializer. // named row; a fresh mount seeds it via the useState initializer.
useEffect(() => { useEffect(() => {
if (initialRow !== undefined) setOpenRow(initialRow); if (initialRow !== undefined) setOpenRow(initialRow);
followedProvider.current = null;
}, [initialRow]); }, [initialRow]);
// A missing model-provider key has no table row yet. Deep-link into the
// existing Add-provider editor instead (for example, OpenRouter STT setup).
useEffect(() => {
if (!initialRow || !statuses || !providers || followedProvider.current === initialRow) return;
followedProvider.current = initialRow;
if (statuses.some((status) => status.id === initialRow)) return;
const provider = providers.find((provider) => provider.envVar === initialRow);
if (provider) { setAdding(provider); setAddError(null); setOpenRow(null); }
}, [initialRow, statuses, providers]);
async function saveKey(id: string, value: string): Promise<string | null> { async function saveKey(id: string, value: string): Promise<string | null> {
const res = await fetch(`/api/keys/${id}`, { const res = await fetch(`/api/keys/${id}`, {
@@ -397,7 +410,7 @@ export function KeysSection({
} }
if (loading || statuses === null) { 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); const { providerRows, toolRows, unknownRows, addable } = classifyKeys(statuses, providers);
@@ -413,7 +426,7 @@ export function KeysSection({
return ( return (
<> <>
<SettingsGroup label="Model API keys" testid="group-provider-keys"> <SettingsGroup label={t("Model API keys")} testid="group-provider-keys">
{providerRows.map((status) => ( {providerRows.map((status) => (
<KeyTableRow <KeyTableRow
key={status.id} key={status.id}
@@ -455,7 +468,7 @@ export function KeysSection({
</div>} </div>}
</SettingsGroup> </SettingsGroup>
<SettingsGroup label="Subscriptions" testid="group-subscriptions"> <SettingsGroup label={t("Subscriptions")} testid="group-subscriptions">
<SubscriptionConnections /> <SubscriptionConnections />
</SettingsGroup> </SettingsGroup>
@@ -497,6 +510,7 @@ function AddProviderEditor({
onCancel: () => void; onCancel: () => void;
error: string | null; error: string | null;
}) { }) {
const { t } = useI18n();
const [value, setValue] = useState(""); const [value, setValue] = useState("");
return ( return (
<> <>
@@ -507,8 +521,8 @@ function AddProviderEditor({
onKeyDown={(e) => { onKeyDown={(e) => {
if (e.key === "Enter" && value.trim()) onSave(value); if (e.key === "Enter" && value.trim()) onSave(value);
}} }}
placeholder="Paste key…" placeholder={t("Paste key…")}
aria-label={`${provider.label} key`} aria-label={t("{v0} key", { v0: provider.label })}
data-testid={`key-${provider.id}-input`} data-testid={`key-${provider.id}-input`}
autoFocus 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" 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 +534,17 @@ function AddProviderEditor({
data-testid={`key-${provider.id}-save`} 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" className="shrink-0 text-xs text-foreground underline-offset-2 hover:underline disabled:text-muted-foreground/40 disabled:no-underline"
> >
Save {t("Save")}</button>
</button>
<button <button
type="button" type="button"
onClick={onCancel} onClick={onCancel}
data-testid={`key-${provider.id}-cancel`} data-testid={`key-${provider.id}-cancel`}
className="shrink-0 text-xs text-muted-foreground/70 underline-offset-2 hover:text-foreground hover:underline" className="shrink-0 text-xs text-muted-foreground/70 underline-offset-2 hover:text-foreground hover:underline"
> >
Cancel {t("Cancel")}</button>
</button>
{error && ( {error && (
<p role="alert" className="w-full pl-0 text-xs text-red-500" data-testid={`key-${provider.id}-error`}> <p role="alert" className="w-full pl-0 text-xs text-red-500" data-testid={`key-${provider.id}-error`}>
{error} {t(error)}
</p> </p>
)} )}
</> </>
+30 -23
View File
@@ -1,4 +1,6 @@
"use client"; "use client";
import { useI18n } from "@/components/i18n";
/** /**
* The model menu (extracted 2026-09-20 from model-picker.tsx, owner ask: * 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 * 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). */ /** Quiet footer note under the list (context-specific). */
footer?: string; footer?: string;
}) { }) {
const { t, language } = useI18n();
const [query, setQuery] = useState(""); const [query, setQuery] = useState("");
const [menuIdx, setMenuIdx] = useState<number | null>(null); const [menuIdx, setMenuIdx] = useState<number | null>(null);
const searchRef = useRef<HTMLInputElement>(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" 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 && ( {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"> <ul ref={listRef} data-testid="model-list" className="flex max-h-72 flex-col overflow-y-auto px-1">
{results.map((m, i) => ( {results.map((m, i) => (
@@ -125,13 +128,13 @@ export function ModelMenuList({
> >
<span className="flex items-center justify-between gap-2"> <span className="flex items-center justify-between gap-2">
<span className="truncate text-card-foreground">{m.name}</span> <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)} {subscriptionLabel(`${m.provider}:${m.id}`) ?? priceLabel(m.cost)}
</span> </span>
</span> </span>
<span className="flex items-center justify-between gap-2 text-muted-foreground"> <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="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)}</span> <span className="shrink-0 tabular-nums">{formatTokens(m.contextWindow, language)}</span>
</span> </span>
</button> </button>
</li> </li>
@@ -160,22 +163,23 @@ export function ThinkingMenuList({
empty?: string; empty?: string;
footer?: string; footer?: string;
}) { }) {
const { t } = useI18n();
return ( return (
<> <>
{levels.length === 0 ? ( {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) => ( levels.map((level) => (
<button <button
key={level} key={t(level)}
type="button" type="button"
data-testid={`thinking-option-${level}`} data-testid={`thinking-option-${t(level)}`}
onClick={() => onPick(level)} onClick={() => onPick(level)}
className={`flex items-center justify-between rounded-lg px-2 py-1.5 text-left transition-colors hover:bg-muted ${ 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" level === effectiveLevel ? "bg-muted/60 text-card-foreground" : "text-card-foreground"
}`} }`}
> >
<span>{level}</span> <span>{t(level)}</span>
{level === effectiveLevel && <span aria-hidden>✓</span>} {level === effectiveLevel && <span aria-hidden>✓</span>}
</button> </button>
)) ))
@@ -199,6 +203,7 @@ export function ProviderMenuList({
providers: { id: string; envVar: string; label: string }[]; providers: { id: string; envVar: string; label: string }[];
onPick: (provider: { id: string; envVar: string; label: string }) => void; onPick: (provider: { id: string; envVar: string; label: string }) => void;
}) { }) {
const { t } = useI18n();
const [query, setQuery] = useState(""); const [query, setQuery] = useState("");
const [menuIdx, setMenuIdx] = useState<number | null>(null); const [menuIdx, setMenuIdx] = useState<number | null>(null);
const searchRef = useRef<HTMLInputElement>(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" 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 && ( {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"> <ul ref={listRef} data-testid="provider-list" className="flex max-h-72 flex-col overflow-y-auto px-1">
{results.map((p, i) => ( {results.map((p, i) => (
@@ -283,7 +288,7 @@ export function ProviderMenuList({
* with the default first and a searched list drops it honestly — Enter * 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 * can never pick a row that isn't on screen. Pure — exported for
* tests. */ * 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 const scored = families
.map((f) => ({ f, s: fuzzyScore(query, f) })) .map((f) => ({ f, s: fuzzyScore(query, f) }))
.filter((r) => r.s !== null) .filter((r) => r.s !== null)
@@ -291,7 +296,7 @@ export function fontMenuResults(query: string, families: string[]): Array<string
.map((r) => r.f); .map((r) => r.f);
// The default row's searchable identity: the factory family + the // The default row's searchable identity: the factory family + the
// word "default" (the suffix it renders with). // 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]; if (query.trim() === "" || defaultScore !== null) return [null, ...scored];
return 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 — * query that matches NO family becomes the "Use '<query>'" custom pick —
* a matching family is the better pick, so the action row only exists * a matching family is the better pick, so the action row only exists
* when there is nothing else to pick. Exported for tests. */ * 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(); const trimmed = query.trim();
if (trimmed === "") return null; 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 /** The reading-font dropdown body — ONE search field (owner ask
@@ -329,6 +334,7 @@ export function FontMenuList({
/** Quiet footer note under the list. */ /** Quiet footer note under the list. */
footer?: string; footer?: string;
}) { }) {
const { t, language } = useI18n();
const [query, setQuery] = useState(""); const [query, setQuery] = useState("");
const [menuIdx, setMenuIdx] = useState<number | null>(null); const [menuIdx, setMenuIdx] = useState<number | null>(null);
const searchRef = useRef<HTMLInputElement>(null); const searchRef = useRef<HTMLInputElement>(null);
@@ -343,10 +349,11 @@ export function FontMenuList({
listRef.current?.querySelector(`[data-idx="${menuIdx}"]`)?.scrollIntoView({ block: "nearest" }); listRef.current?.querySelector(`[data-idx="${menuIdx}"]`)?.scrollIntoView({ block: "nearest" });
}, [menuIdx]); }, [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 // The custom-family action row: only when a typed query matches
// nothing (a matching family is the better pick). Enter picks it. // 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; const walkable: Array<string | null> = customFamily !== null ? ["__custom__"] : results;
return ( 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" 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"> <ul ref={listRef} data-testid="font-list" className="flex max-h-72 flex-col overflow-y-auto px-1">
{families && {families &&
results.map((family, i) => { results.map((family, i) => {
@@ -398,7 +405,7 @@ export function FontMenuList({
i === menuIdx ? "bg-accent/40 ring-1 ring-inset ring-border" : "" i === menuIdx ? "bg-accent/40 ring-1 ring-inset ring-border" : ""
}${effective && i !== menuIdx ? " bg-muted/60" : ""}`} }${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> <span className="shrink-0 text-muted-foreground">{isDefault ? "default" : effective && <span aria-hidden>✓</span>}</span>
</button> </button>
</li> </li>
@@ -415,8 +422,8 @@ export function FontMenuList({
menuIdx === 0 ? "bg-accent/40 ring-1 ring-inset ring-border" : "" menuIdx === 0 ? "bg-accent/40 ring-1 ring-inset ring-border" : ""
}`} }`}
> >
<span className="truncate text-card-foreground">Use “{customFamily}”</span> <span className="truncate text-card-foreground">{t("Use “")}{customFamily}”</span>
<span className="shrink-0 text-muted-foreground">custom</span> <span className="shrink-0 text-muted-foreground">{t("custom")}</span>
</button> </button>
</li> </li>
)} )}
+38 -23
View File
@@ -39,6 +39,7 @@ import {
type GraphSettings, type GraphSettings,
type UserSettings, type UserSettings,
} from "@/src/core"; } from "@/src/core";
import { LanguageSelect, SttModelSelect, SttPreferredLanguages, useI18n } from "@/components/i18n";
import { useAppearance } from "./appearance.tsx"; import { useAppearance } from "./appearance.tsx";
import { SettingsSection, SettingsRow, Toggle } from "./controls.tsx"; import { SettingsSection, SettingsRow, Toggle } from "./controls.tsx";
import { AgentSection } from "./agent-section.tsx"; import { AgentSection } from "./agent-section.tsx";
@@ -61,6 +62,7 @@ const SECTIONS = [
{ id: "appearance", label: "Appearance" }, { id: "appearance", label: "Appearance" },
{ id: "graph", label: "Graph" }, { id: "graph", label: "Graph" },
{ id: "keyboard", label: "Keyboard", hint: "Stored on this device" }, { id: "keyboard", label: "Keyboard", hint: "Stored on this device" },
{ id: "stt", label: "Speech recognition" },
{ id: "general", label: "General" }, { id: "general", label: "General" },
] as const; ] as const;
type SectionId = (typeof SECTIONS)[number]["id"]; type SectionId = (typeof SECTIONS)[number]["id"];
@@ -144,6 +146,7 @@ export function SettingsDialogBody({
* retargets follow it exactly like initialSection below. */ * retargets follow it exactly like initialSection below. */
initialRow?: string | null; initialRow?: string | null;
}) { }) {
const { t, general, saveGeneral } = useI18n();
const { onAppearanceChange } = useAppearance(); const { onAppearanceChange } = useAppearance();
const [record, setRecord] = useState<UserSettings | null | undefined>( const [record, setRecord] = useState<UserSettings | null | undefined>(
undefined, undefined,
@@ -232,6 +235,10 @@ export function SettingsDialogBody({
body: object, body: object,
) => { ) => {
setError(null); 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}`, { const res = await fetch(`/api/settings/${section}`, {
method: "PUT", method: "PUT",
headers: { "content-type": "application/json" }, headers: { "content-type": "application/json" },
@@ -260,30 +267,30 @@ export function SettingsDialogBody({
if (section === "appearance") if (section === "appearance")
onAppearanceChange(body as AppearanceSettings); onAppearanceChange(body as AppearanceSettings);
}, },
[onAppearanceChange], [onAppearanceChange, saveGeneral, t],
); );
return ( return (
<div <div
role="dialog" role="dialog"
aria-modal="true" aria-modal="true"
aria-label="Settings" aria-label={t("Settings")}
data-testid="settings-dialog" 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" 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()} onClick={(e) => e.stopPropagation()}
> >
{/* Header — the app's quiet CMU title language. */} {/* 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"> <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 <Hint
// Esc closes the dialog (its own listener) — surface it. // Esc closes the dialog (its own listener) — surface it.
label="Close" label={t("Close")}
binding="escape" binding="escape"
side="left" side="left"
> >
<button <button
type="button" type="button"
aria-label="Close settings" aria-label={t("Close settings")}
data-testid="settings-close" data-testid="settings-close"
className="ml-auto size-7 rounded-sm text-muted-foreground transition-colors hover:bg-accent hover:text-foreground" className="ml-auto size-7 rounded-sm text-muted-foreground transition-colors hover:bg-accent hover:text-foreground"
onClick={onClose} onClick={onClose}
@@ -305,7 +312,7 @@ export function SettingsDialogBody({
{/* Left nav — text-only, the session-list mono language, {/* Left nav — text-only, the session-list mono language,
active = bg-accent. */} active = bg-accent. */}
<nav <nav
aria-label="Settings sections" aria-label={t("Settings sections")}
data-testid="settings-nav" data-testid="settings-nav"
className="flex w-44 shrink-0 flex-col gap-1 border-r border-border p-2" className="flex w-44 shrink-0 flex-col gap-1 border-r border-border p-2"
> >
@@ -322,7 +329,7 @@ export function SettingsDialogBody({
}`} }`}
onClick={() => setActive(s.id)} onClick={() => setActive(s.id)}
> >
{s.label} {t(s.label)}
</button> </button>
))} ))}
</nav> </nav>
@@ -344,32 +351,31 @@ export function SettingsDialogBody({
className="m-4 rounded-sm border border-red-500/40 px-3 py-2 text-xs text-red-500" className="m-4 rounded-sm border border-red-500/40 px-3 py-2 text-xs text-red-500"
data-testid="settings-error" data-testid="settings-error"
> >
{error} {t(error)}
</p> </p>
)} )}
{record === undefined ? ( {record === undefined ? (
<p className="px-6 py-5 text-xs text-muted-foreground/70"> <p className="px-6 py-5 text-xs text-muted-foreground/70">
Loading settings… {t("Loading settings…")}</p>
</p>
) : record === null ? ( ) : record === null ? (
<p className="px-6 py-5 text-xs text-muted-foreground/70"> <p className="px-6 py-5 text-xs text-muted-foreground/70">
Settings could not be loaded — refresh to retry. {t("Settings could not be loaded — refresh to retry.")}</p>
</p>
) : ( ) : (
<div> <div>
{active === "general" && ( {active === "general" && (
<SettingsSection <SettingsSection
title="General" title={t("General")}
testid="section-general" testid="section-general"
> >
<SettingsRow label={t("Interface language")} testid="row-language"><LanguageSelect field="language" /></SettingsRow>
<SettingsRow <SettingsRow
label="Auto-update" label={t("Auto-update")}
hint={AUTO_UPDATE_HINT} hint={AUTO_UPDATE_HINT}
testid="row-auto-update" testid="row-auto-update"
> >
<Toggle <Toggle
checked={record.general?.autoUpdate ?? true} checked={general.autoUpdate ?? true}
label="auto update" label={t("auto update")}
onChange={(on) => onChange={(on) =>
void write("general", { autoUpdate: on }) void write("general", { autoUpdate: on })
} }
@@ -377,14 +383,23 @@ export function SettingsDialogBody({
</SettingsRow> </SettingsRow>
</SettingsSection> </SettingsSection>
)} )}
{active === "stt" && (
<SettingsSection title={t("Speech recognition")} testid="section-stt">
<SettingsRow label={t("Speech recognition model")} testid="row-stt-model" className="flex-wrap items-start gap-y-2 [&>span:first-child]:flex-[1_1_12rem]"><SttModelSelect /></SettingsRow>
<SettingsRow label={t("Speech recognition language")} testid="row-stt-language"><LanguageSelect field="sttLanguage" /></SettingsRow>
<SettingsRow label={t("Preferred languages")} testid="row-stt-preferred-languages">
<SttPreferredLanguages />
</SettingsRow>
</SettingsSection>
)}
{active === "keys" && ( {active === "keys" && (
<SettingsSection title="Providers" testid="section-keys"> <SettingsSection title={t("Providers")} testid="section-keys">
<KeysSection initialRow={initialRow ?? undefined} /> <KeysSection initialRow={initialRow ?? undefined} />
</SettingsSection> </SettingsSection>
)} )}
{active === "agent" && ( {active === "agent" && (
<SettingsSection <SettingsSection
title="Agent" title={t("Agent")}
testid="section-agent" testid="section-agent"
> >
<AgentSection <AgentSection
@@ -393,7 +408,7 @@ export function SettingsDialogBody({
graph={record.graph} graph={record.graph}
compaction={record.compaction} compaction={record.compaction}
onModelsChange={(next: ModelsSettings) => onModelsChange={(next: ModelsSettings) =>
void write("models", next) write("models", next)
} }
onSectionChange={(next: AgentSettings) => onSectionChange={(next: AgentSettings) =>
void write("agent", next) void write("agent", next)
@@ -406,7 +421,7 @@ export function SettingsDialogBody({
)} )}
{active === "appearance" && ( {active === "appearance" && (
<SettingsSection <SettingsSection
title="Appearance" title={t("Appearance")}
testid="section-appearance" testid="section-appearance"
> >
<AppearanceSection <AppearanceSection
@@ -419,7 +434,7 @@ export function SettingsDialogBody({
)} )}
{active === "graph" && ( {active === "graph" && (
<SettingsSection <SettingsSection
title="Graph" title={t("Graph")}
testid="section-graph" testid="section-graph"
> >
<GraphSection <GraphSection
@@ -432,8 +447,8 @@ export function SettingsDialogBody({
)} )}
{active === "keyboard" && ( {active === "keyboard" && (
<SettingsSection <SettingsSection
title="Keyboard" title={t("Keyboard")}
hint="Stored on this device" hint={t("Stored on this device")}
testid="section-keyboard" testid="section-keyboard"
> >
<KeyboardSection /> <KeyboardSection />
@@ -1,4 +1,7 @@
"use client"; "use client";
import { translator, type Translator } from "@/src/core";
import { useI18n } from "@/components/i18n";
import { createContext, useContext, useEffect, useRef, useState } from "react"; import { createContext, useContext, useEffect, useRef, useState } from "react";
import { NATIVE_SUBSCRIPTIONS, type NativeSubscriptionProviderId, type NativeSubscriptionStatus } from "@/src/core"; import { NATIVE_SUBSCRIPTIONS, type NativeSubscriptionProviderId, type NativeSubscriptionStatus } from "@/src/core";
import { Button } from "@/components/ui/button"; 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 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 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 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): 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 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 }; } 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 Context = createContext({ statuses: [] as NativeSubscriptionStatus[], busy: false, open: (_providerId: NativeSubscriptionProviderId) => {} });
const CONTROL_ERROR = "The connection could not be changed. Try again."; const CONTROL_ERROR = "The connection could not be changed. Try again.";
export function NativeSubscriptionUIProvider({ children }: { children: React.ReactNode }) { export function NativeSubscriptionUIProvider({ children }: { children: React.ReactNode }) {
const { t } = useI18n();
const [statuses, setStatuses] = useState<NativeSubscriptionStatus[]>([]); const [statuses, setStatuses] = useState<NativeSubscriptionStatus[]>([]);
const [dialog, setDialog] = useState<NativeSubscriptionProviderId | null>(null); const [dialog, setDialog] = useState<NativeSubscriptionProviderId | null>(null);
const [view, setView] = useState<"notice" | "connection" | "manage">("notice"); 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; const bridge = typeof window === "undefined" ? undefined : window.lh?.subscriptions;
return <Context.Provider value={{ statuses, busy: !!operation, open }}> return <Context.Provider value={{ statuses, busy: !!operation, open }}>
{children} {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> <h2 className="text-sm font-semibold text-foreground">{descriptor.label}</h2>
{confirm ? <> {confirm ? <>
<p className="mt-3 text-sm font-semibold text-red-400">Disconnect {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)}</p> <p className="mt-3 text-xs leading-relaxed text-muted-foreground">{subscriptionDisconnectCopy(descriptor.label, t)}</p>
</> : view === "notice" ? <> </> : view === "notice" ? <>
<p className="mt-3 text-xs leading-relaxed text-muted-foreground" data-testid="subscription-privacy-notice">{subscriptionNotice(descriptor.label)}</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">{COPILOT_POLICY_NOTICE}</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"> <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); }} /> <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>Allow background use<span className="mt-1 block leading-relaxed text-muted-foreground">{SUBSCRIPTION_BACKGROUND_HINT}</span></span> <span>{t("Allow background use")}<span className="mt-1 block leading-relaxed text-muted-foreground">{t(SUBSCRIPTION_BACKGROUND_HINT)}</span></span>
</div> </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"> {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.progress && <p role="status" className="text-muted-foreground">{t(interaction.progress)}</p>}
{interaction.userCode && <p>Device code: <code className="select-text font-mono">{interaction.userCode}</code></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."))}>Open browser</Button>} {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)); }}> {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> <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>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" />} {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)}>Continue</Button> <Button type="submit" variant="outline" size="sm" disabled={busy || (prompt.type === "select" && !promptValue)}>{t("Continue")}</Button>
</form>} </form>}
</div>} </div>}
</>} </>}
{(error || status.error) && <p role="alert" className="mt-3 text-xs text-red-400">{error || status.error}</p>} {(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)}>Disconnect</Button>}> <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 && <> {!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>} {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}>Sign in again</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> <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)}>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>} {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> </ConnectionFooter>
</ConnectionDialog>} </ConnectionDialog>}
</Context.Provider>; </Context.Provider>;
} }
export function NativeSubscriptionConnection({ providerId }: { providerId: NativeSubscriptionProviderId }) { export function NativeSubscriptionConnection({ providerId }: { providerId: NativeSubscriptionProviderId }) {
const { t } = useI18n();
const ui = useContext(Context), descriptor = NATIVE_SUBSCRIPTIONS.find((p) => p.providerId === providerId)!; const ui = useContext(Context), descriptor = NATIVE_SUBSCRIPTIONS.find((p) => p.providerId === providerId)!;
const status = ui.statuses.find((s) => s.providerId === providerId) ?? emptySubscriptionStatus(providerId); const status = ui.statuses.find((s) => s.providerId === providerId) ?? emptySubscriptionStatus(providerId);
return <div className="flex flex-col gap-2" data-testid={`${providerId}-connection`}> 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)} /> <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">{status.error}</p>} {status.error && <p role="alert" className="text-xs text-red-400">{t(status.error)}</p>}
</div>; </div>;
} }
export function SubscriptionConnections({ setup = false }: { setup?: boolean }) { 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>; 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 }) { export function NativeSubscriptionBackgroundAccess({ provider }: { provider: string | undefined }) {
const { t } = useI18n();
const ui = useContext(Context), descriptor = NATIVE_SUBSCRIPTIONS.find((p) => p.logicalId === provider); const ui = useContext(Context), descriptor = NATIVE_SUBSCRIPTIONS.find((p) => p.logicalId === provider);
const status = ui.statuses.find((s) => s.providerId === descriptor?.providerId); const status = ui.statuses.find((s) => s.providerId === descriptor?.providerId);
if (!descriptor || !status?.connected || status.allowBackgroundUse) return null; 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"; "use client";
import { useI18n } from "@/components/i18n";
/** /**
* The zero-keys surfaces (zero-keys wave, 2026-09-25, owner-locked over * The zero-keys surfaces (zero-keys wave, 2026-09-25, owner-locked over
* Platform's 66dbea9 key-presence gate): when NO model-provider key * 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 * chain's copy. testid "setup-cta-keys" (the models CTA keeps
* "setup-cta"; probes can tell them apart). */ * "setup-cta"; probes can tell them apart). */
export function ZeroKeysPill({ onOpen, inline = false }: { onOpen: () => void; inline?: boolean }) { export function ZeroKeysPill({ onOpen, inline = false }: { onOpen: () => void; inline?: boolean }) {
const { t } = useI18n();
const button = ( const button = (
<button <button
type="button" type="button"
@@ -53,8 +56,7 @@ export function ZeroKeysPill({ onOpen, inline = false }: { onOpen: () => void; i
onClick={onOpen} 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" 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 {t("Add a provider key")}</button>
</button>
); );
return inline ? button : <div className="pointer-events-auto mb-2 flex justify-center">{button}</div>; 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"; "use client";
import { useI18n } from "@/components/i18n";
/** /**
* The first-run setup flow (model-slots wave, 2026-09-20, owner design * The first-run setup flow (model-slots wave, 2026-09-20, owner design
* "A — composer CTA + send interception"): chats cannot start until the * "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). */ /** The MenuShell anchor — wired by the wrapper (its triggerRef). */
menuAnchor: React.RefObject<HTMLDivElement | null>; menuAnchor: React.RefObject<HTMLDivElement | null>;
}) { }) {
const { t } = useI18n();
return ( return (
<div className="flex flex-col gap-3 px-6 py-5" data-testid="setup-stage-keys"> <div className="flex flex-col gap-3 px-6 py-5" data-testid="setup-stage-keys">
<p className="text-xs text-muted-foreground"> <p className="text-xs text-muted-foreground">
Choose your AI provider and paste its API key. You’ll pick your models next. {t("Choose your AI provider and paste its API key. You’ll pick your models next.")}</p>
</p>
<div className="flex flex-col gap-2"> <div className="flex flex-col gap-2">
<div ref={menuAnchor} className="relative max-w-96"> <div ref={menuAnchor} className="relative max-w-96">
<button <button
type="button" type="button"
aria-label="Provider" aria-label={t("Provider")}
data-testid="setup-key-provider" data-testid="setup-key-provider"
aria-expanded={menuOpen} aria-expanded={menuOpen}
onClick={onToggleMenu} 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" 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 aria-hidden className="text-muted-foreground">
▾ ▾
</span> </span>
@@ -188,8 +190,8 @@ export function SetupKeysBody({
onKeyDown={(e) => { onKeyDown={(e) => {
if (e.key === "Enter" && value.trim()) onSave(); if (e.key === "Enter" && value.trim()) onSave();
}} }}
placeholder="Paste key…" placeholder={t("Paste key…")}
aria-label={`${provider.label} key`} aria-label={t("{v0} key", { v0: provider.label })}
data-testid="setup-key-input" data-testid="setup-key-input"
disabled={saving} disabled={saving}
autoFocus autoFocus
@@ -202,17 +204,17 @@ export function SetupKeysBody({
data-testid="setup-key-save" 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" 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> </button>
</div> </div>
)} )}
{error && ( {error && (
<p role="alert" data-testid="setup-key-error" className="text-xs text-red-400"> <p role="alert" data-testid="setup-key-error" className="text-xs text-red-400">
{error} {t(error)}
</p> </p>
)} )}
</div> </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> </div>
); );
} }
@@ -228,6 +230,7 @@ export function SetupDialog({
onClose: () => void; onClose: () => void;
onDone: (models: ModelsSettings) => void; onDone: (models: ModelsSettings) => void;
}) { }) {
const { t } = useI18n();
const backdropDismiss = useBackdropDismiss(onClose); const backdropDismiss = useBackdropDismiss(onClose);
const [body, setBody] = useState<ModelsBody | null>(null); const [body, setBody] = useState<ModelsBody | null>(null);
const [failed, setFailed] = useState(false); const [failed, setFailed] = useState(false);
@@ -407,17 +410,17 @@ export function SetupDialog({
<div <div
role="dialog" role="dialog"
aria-modal="true" aria-modal="true"
aria-label="Set up your models" aria-label={t("Set up your models")}
data-testid="setup-dialog" 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" 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()} onClick={(e) => e.stopPropagation()}
> >
<header className="flex h-15 shrink-0 items-center gap-3 border-b border-border px-5"> <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> <h2 className="font-title text-2xl tracking-tight">{t("Set up your models")}</h2>
<Hint label="Close" binding="escape" side="left"> <Hint label={t("Close")} binding="escape" side="left">
<button <button
type="button" type="button"
aria-label="Close setup" aria-label={t("Close setup")}
data-testid="setup-close" data-testid="setup-close"
onClick={onClose} onClick={onClose}
className="ml-auto text-muted-foreground transition-colors hover:text-foreground" 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"> <div className="flex flex-col px-6 py-5">
{failed ? ( {failed ? (
<p className="text-sm text-foreground"> <p className="text-sm text-foreground">
Model catalog could not be loaded. Check the connection and retry. {t("Model catalog could not be loaded. Check the connection and retry.")}</p>
</p>
) : ( ) : (
<SetupKeysBody <SetupKeysBody
providers={keyProviders} providers={keyProviders}
provider={keyProvider} provider={keyProvider}
value={keyValue} value={keyValue}
saving={keySaving} saving={keySaving}
error={keyError} error={t(keyError)}
menuOpen={keyMenuOpen} menuOpen={keyMenuOpen}
menuAnchor={keyTriggerRef} menuAnchor={keyTriggerRef}
onToggleMenu={() => setKeyMenuOpen((v) => !v)} 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"> <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"> <p className="text-xs text-muted-foreground">
Pick the model for each tier. All three are required before chats can start. {t("Pick the model for each tier. All three are required before chats can start.")}</p>
</p>
{SLOT_ORDER.map((slot) => { {SLOT_ORDER.map((slot) => {
const entry = picked[slot]; const entry = picked[slot];
const level = levels[slot]; const level = levels[slot];
@@ -467,29 +468,28 @@ export function SetupDialog({
return ( return (
<div key={slot} data-testid={`setup-slot-${slot}`} className="flex flex-col gap-1.5"> <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="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] && ( {!body?.slots?.[slot]?.model && !picked[slot] && (
<span <span
data-testid={`setup-needed-${slot}`} 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" className="rounded-sm border border-border px-1 py-0.5 text-[10px] font-normal tracking-wide text-muted-foreground"
> >
needed {t("needed")}</span>
</span>
)} )}
</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} /></>} {slot !== "strong" && <><ChatGPTBackgroundAccess provider={entry?.provider} /><NativeSubscriptionBackgroundAccess provider={entry?.provider} /></>}
<div className="flex items-center gap-2"> <div className="flex items-center gap-2">
<div className="relative flex-1"> <div className="relative flex-1">
<button <button
type="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}`} data-testid={`setup-trigger-${slot}`}
aria-expanded={openMenu?.slot === slot && openMenu.kind === "model"} aria-expanded={openMenu?.slot === slot && openMenu.kind === "model"}
onClick={() => setOpenMenu(openMenu?.slot === slot && openMenu.kind === "model" ? null : { slot, 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" 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 aria-hidden className="text-muted-foreground">
▾ ▾
</span> </span>
@@ -527,7 +527,7 @@ export function SetupDialog({
})); }));
setOpenMenu(null); 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> </MenuShell>
)} )}
@@ -537,7 +537,7 @@ export function SetupDialog({
<div className="relative"> <div className="relative">
<button <button
type="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}`} data-testid={`setup-thinking-${slot}`}
aria-expanded={openMenu?.slot === slot && openMenu.kind === "thinking"} aria-expanded={openMenu?.slot === slot && openMenu.kind === "thinking"}
disabled={!entry} disabled={!entry}
@@ -582,9 +582,9 @@ export function SetupDialog({
onClick={() => void save()} 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" 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> </button>
{error && <p className="text-xs text-red-400">{error}</p>} {error && <p className="text-xs text-red-400">{t(error)}</p>}
</div> </div>
</div> </div>
)} )}
+46 -41
View File
@@ -1,4 +1,7 @@
"use client"; "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 { createContext, useCallback, useContext, useEffect, useMemo, useRef, useState } from "react";
import Link from "next/link"; import Link from "next/link";
@@ -66,6 +69,7 @@ declare global {
/** The zoom truth (owner round 7): subscribes to "lh:zoom" pushes; returns unsubscribe. */ /** The zoom truth (owner round 7): subscribes to "lh:zoom" pushes; returns unsubscribe. */
onZoom?: (cb: (factor: number) => void) => () => void; onZoom?: (cb: (factor: number) => void) => () => void;
getZoom?: () => Promise<number>; 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. */ /** 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; onUpdateStatus?: (cb: (status: { status: string; version: string | null }) => void) => () => void;
installUpdate?: () => 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 — * The icon fades with the slot and is clipped by it as it collapses —
* no popping in either direction. */ * no popping in either direction. */
export function SidebarToggle() { export function SidebarToggle() {
const { t } = useI18n();
const { open, toggle } = useSidebar(); const { open, toggle } = useSidebar();
return ( return (
<span <span
@@ -196,11 +201,11 @@ export function SidebarToggle() {
open ? "w-0 -mr-3" : "w-9 mr-0 lh-closed-toggle", 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 <Button
variant="ghost" variant="ghost"
size="icon" size="icon"
aria-label="Open sidebar" aria-label={t("Open sidebar")}
aria-hidden={open} aria-hidden={open}
tabIndex={open ? -1 : 0} tabIndex={open ? -1 : 0}
onClick={toggle} onClick={toggle}
@@ -239,16 +244,18 @@ function SessionStatus({ busy }: { busy: boolean }) {
* in any locale and it sorts (owner, typography round 5; the old * 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 * M/D/YYYY was the US convention. A user-locale setting can come later
* — trivial, a format function swap). Pure — exported for tests. */ * — 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(); 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); 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); 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); 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); 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 mm = String(date.getMonth() + 1).padStart(2, "0");
const dd = String(date.getDate()).padStart(2, "0"); const dd = String(date.getDate()).padStart(2, "0");
return `${date.getFullYear()}-${mm}-${dd}`; 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 * 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 * still rides the API payload, so this is a one-line flip back if it's
* ever wanted. */ * ever wanted. */
function sessionMeta(chat: ChatListItem): string { function sessionMeta(chat: ChatListItem, language: Language = "en"): string {
if (chat.compacting) return `Compacting context · ${timeAgo(chat.updatedAt)}`; const t = translator(language);
if (chat.pendingQuestion) return `Waiting for input · ${timeAgo(chat.updatedAt)}`; 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) { 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…"; if (chat.busy) return t("Working…");
return timeAgo(chat.updatedAt); return timeAgo(chat.updatedAt, undefined, language);
} }
/** /**
@@ -290,6 +298,7 @@ export function SessionRow({
/** Right-click opens the context menu (wave 6: Rename / Delete). */ /** Right-click opens the context menu (wave 6: Rename / Delete). */
onContextMenu?: (e: React.MouseEvent<HTMLButtonElement>) => void; onContextMenu?: (e: React.MouseEvent<HTMLButtonElement>) => void;
}) { }) {
const { t, language } = useI18n();
return ( return (
<button <button
type="button" type="button"
@@ -310,13 +319,13 @@ export function SessionRow({
rule, finally applied to the picker too (owner, typography rule, finally applied to the picker too (owner, typography
round): mono = chrome, Source Sans = human words. */} round): mono = chrome, Source Sans = human words. */}
<span className="block truncate font-prose text-(length:--lh-prose-size)">{chat.title}</span> <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> </span>
{/* The dumbbell rides the FAR RIGHT (owner round 6, 2026-10-05) — {/* The dumbbell rides the FAR RIGHT (owner round 6, 2026-10-05) —
was left of the name; right-aligned keeps the titles flush with was left of the name; right-aligned keeps the titles flush with
the teaching rows and reads cleaner. */} the teaching rows and reads cleaner. */}
{(chat.kind ?? "teach") === "practice" && ( {(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> </button>
); );
@@ -327,6 +336,7 @@ export function SessionRow({
* "replace", the header rename's rule), Enter/blur commits, Esc discards. * "replace", the header rename's rule), Enter/blur commits, Esc discards.
* The meta line stays so the row never changes height mid-edit. */ * 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 }) { 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 [draft, setDraft] = useState(chat.title);
const inputRef = useRef<HTMLInputElement | null>(null); const inputRef = useRef<HTMLInputElement | null>(null);
useEffect(() => { useEffect(() => {
@@ -362,11 +372,11 @@ export function RenameRow({ chat, onCommit, onCancel }: { chat: ChatListItem; on
onCancel(); onCancel();
} }
}} }}
aria-label="Rename session" aria-label={t("Rename session")}
data-testid="session-rename-input" 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" 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> </span>
</div> </div>
); );
@@ -379,6 +389,7 @@ export type SessionKindFilter = "teach" | "all" | "practice";
const KIND_FILTER_KEY = "lh:session-kind-filter"; const KIND_FILTER_KEY = "lh:session-kind-filter";
function SessionsSidebar() { function SessionsSidebar() {
const { t } = useI18n();
const { open, toggle } = useSidebar(); const { open, toggle } = useSidebar();
const [chats, setChats] = useState<ChatListItem[] | null>(null); const [chats, setChats] = useState<ChatListItem[] | null>(null);
const [query, setQuery] = useState(""); const [query, setQuery] = useState("");
@@ -656,23 +667,23 @@ function SessionsSidebar() {
// of the logo, carrying its real shortcut: ⌘G jumps to // of the logo, carrying its real shortcut: ⌘G jumps to
// the graph view (keyboard-shortcuts routes it to "/", // the graph view (keyboard-shortcuts routes it to "/",
// the same destination as this logo). // the same destination as this logo).
label="Home" label={t("Home")}
action="jump-graph" action="jump-graph"
side="right" side="right"
> >
<Link <Link
href="/" 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" 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" /> <LhMark className="size-6" />
</Link> </Link>
</Hint> </Hint>
<Hint label="Close sidebar" action="toggle-sidebar"> <Hint label={t("Close sidebar")} action="toggle-sidebar">
<Button <Button
variant="ghost" variant="ghost"
size="icon" size="icon"
aria-label="Close sidebar" aria-label={t("Close sidebar")}
onClick={toggle} onClick={toggle}
className="lh-no-drag" 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" 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" /> <PlusIcon className="size-3" />
Teaching {t("Teaching")}<span className="sr-only">{t("create a new teaching session")}</span>
<span className="sr-only">create a new teaching session</span>
</button> </button>
<button <button
type="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" 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" /> <DumbbellIcon className="size-3" />
Practice {t("Practice")}<span className="sr-only">{t("create a new practice session")}</span>
<span className="sr-only">create a new practice session</span>
</button> </button>
</div> </div>
<div className="mx-3 mb-2 flex items-center gap-2"> <div className="mx-3 mb-2 flex items-center gap-2">
<span className="text-xs uppercase tracking-widest text-muted-foreground/70">Sessions</span> <span className="text-xs uppercase tracking-widest text-muted-foreground/70">{t("Sessions")}</span>
<Hint label="Search sessions" action="sessions-search" side="bottom"> <Hint label={t("Search sessions")} action="sessions-search" side="bottom">
<span className="relative flex-1"> <span className="relative flex-1">
<Input <Input
ref={searchRef} ref={searchRef}
@@ -760,8 +769,8 @@ function SessionsSidebar() {
e.currentTarget.blur(); e.currentTarget.blur();
} }
}} }}
placeholder="Search…" placeholder={t("Search…")}
aria-label="Search sessions" aria-label={t("Search sessions")}
className="h-6 rounded-sm border-border bg-background pl-6 text-xs placeholder:text-muted-foreground/60" 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" /> <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", : "text-muted-foreground hover:text-foreground",
)} )}
> >
{f === "teach" ? "Teaching" : f === "all" ? "All" : "Practice"} {f === "teach" ? t("Teaching") : f === "all" ? t("All") : t("Practice")}
</button> </button>
))} ))}
</div> </div>
<div ref={listRef} className="flex-1 overflow-y-auto p-2"> <div ref={listRef} className="flex-1 overflow-y-auto p-2">
{shown === null ? ( {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 ? ( ) : shown.length === 0 ? (
<div className="px-2 py-4 text-xs text-muted-foreground"> <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> </div>
) : ( ) : (
shown.map((c, i) => shown.map((c, i) =>
@@ -839,7 +848,7 @@ function SessionsSidebar() {
New session — quiet, bottom-pinned, opens the centered New session — quiet, bottom-pinned, opens the centered
dialog (also mod+, in the keyboard registry). */} dialog (also mod+, in the keyboard registry). */}
<div className="px-3 pb-3 pt-1"> <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 <button
type="button" type="button"
onClick={() => openSettings()} 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" 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" /> <SettingsIcon className="size-3" />
Settings {t("Settings")}</button>
</button>
</Hint> </Hint>
</div> </div>
</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" className="flex items-center rounded-lg px-2.5 py-1.5 text-left text-foreground transition-colors hover:bg-accent"
> >
Rename {t("Rename")}</button>
</button>
{/* Export as Markdown (owner, release polish 2026-10-06): the {/* Export as Markdown (owner, release polish 2026-10-06): the
session-markdown engine's user-facing debut — same-origin session-markdown engine's user-facing debut — same-origin
anchor download; the route's Content-Disposition names the 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" className="flex items-center rounded-lg px-2.5 py-1.5 text-left text-foreground transition-colors hover:bg-accent"
> >
Export as Markdown… {t("Export as Markdown…")}</button>
</button>
<button <button
type="button" type="button"
data-testid="ctx-delete" 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" 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… {t("Delete…")}</button>
</button>
<div className="my-0.5 border-t border-border/60" aria-hidden="true" /> <div className="my-0.5 border-t border-border/60" aria-hidden="true" />
<CoverageMenuBlock chatId={ctxChatId} /> <CoverageMenuBlock chatId={ctxChatId} />
</MenuShell> </MenuShell>
+17 -16
View File
@@ -1,13 +1,15 @@
"use client"; "use client";
import { useI18n } from "@/components/i18n";
import { useEffect, useMemo, useRef, useState, type ReactNode } from "react"; 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 { formatTokens, formatUsd, planCostLabel, prettyModel } from "@/components/usage-format";
import { ModelControl, catalogLimits, useModelCatalog } from "@/components/model-picker"; import { ModelControl, catalogLimits, useModelCatalog } from "@/components/model-picker";
export function formatContextMeasurement(tokens: number | null, limit: number | null): string { export function formatContextMeasurement(tokens: number | null, limit: number | null, language: Language = "en"): string {
const numerator = tokens === null ? "?" : formatTokens(tokens); const numerator = tokens === null ? "?" : formatTokens(tokens, language);
return limit === null ? numerator : `${numerator} / ${formatTokens(limit)}`; return limit === null ? numerator : `${numerator} / ${formatTokens(limit, language)}`;
} }
/** /**
@@ -53,6 +55,7 @@ export function StatsBar({
* data (which renders nothing pre-chat on that surface). */ * data (which renders nothing pre-chat on that surface). */
leadingSlot?: ReactNode; leadingSlot?: ReactNode;
}) { }) {
const { t, language } = useI18n();
const [open, setOpen] = useState(false); const [open, setOpen] = useState(false);
const [selectedModel, setSelectedModel] = useState<string | undefined>(); const [selectedModel, setSelectedModel] = useState<string | undefined>();
// Same dropdown discipline as the model control (owner, df4433f treatment, // Same dropdown discipline as the model control (owner, df4433f treatment,
@@ -84,13 +87,13 @@ export function StatsBar({
const { catalog } = useModelCatalog(); const { catalog } = useModelCatalog();
const limits = useMemo(() => (catalog ? catalogLimits(catalog) : undefined), [catalog]); const limits = useMemo(() => (catalog ? catalogLimits(catalog) : undefined), [catalog]);
const limit = selectedModel ? limits?.get(selectedModel) ?? null : null; 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 ?? {}; const { cost = 0, unpriced = turns.length, subscription = 0 } = pricing ?? {};
// Honest cost: show dollars only when at least one turn priced; // Honest cost: show dollars only when at least one turn priced;
// otherwise "—" (never a fake $0.0000). // otherwise "—" (never a fake $0.0000).
const pricedCount = turns.length - unpriced - subscription; const pricedCount = turns.length - unpriced - subscription;
const providers = pricing?.subscriptionProviders ?? subscriptionLabels(turns.map((turn) => turn.model)); 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 ( return (
<div <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" 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"> <div className="mb-2 text-xs font-medium uppercase tracking-wide text-muted-foreground">
Session stats {t("Session stats")}</div>
</div>
<div className="flex flex-col gap-1 text-muted-foreground"> <div className="flex flex-col gap-1 text-muted-foreground">
<div className="flex justify-between gap-2"> <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"> <span className="tabular-nums text-card-foreground">
{contextLabel} {contextLabel}
</span> </span>
</div> </div>
<div className="flex justify-between gap-2"> <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> <span className="tabular-nums text-card-foreground">{costLabel}</span>
</div> </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 && ( {unpriced > 0 && (
<div className="flex justify-between gap-2"> <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> <span className="tabular-nums text-card-foreground">{unpriced}</span>
</div> </div>
)} )}
@@ -159,8 +161,7 @@ export function StatsBar({
{turns.length > 0 && ( {turns.length > 0 && (
<> <>
<div className="mb-1 mt-2.5 border-t pt-2 text-xs font-medium uppercase tracking-wide text-muted-foreground"> <div className="mb-1 mt-2.5 border-t pt-2 text-xs font-medium uppercase tracking-wide text-muted-foreground">
Turns {t("Turns")}</div>
</div>
<ul className="flex max-h-56 flex-col gap-1 overflow-y-auto"> <ul className="flex max-h-56 flex-col gap-1 overflow-y-auto">
{turns.map((turn, i) => { {turns.map((turn, i) => {
const c = pricing?.turnCosts[turn.id] ?? null; const c = pricing?.turnCosts[turn.id] ?? null;
@@ -170,8 +171,8 @@ export function StatsBar({
#{i + 1} · {prettyModel(turn.model, true)} #{i + 1} · {prettyModel(turn.model, true)}
</span> </span>
<span className="flex shrink-0 items-center gap-2 tabular-nums text-card-foreground"> <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 className="text-muted-foreground">{turn.steps.length}{" "}{t("steps")}</span>
<span>{subscriptionLabel(turn.model) ?? (c === null ? "—" : formatUsd(c))}</span> <span>{subscriptionLabel(turn.model) ?? (c === null ? "—" : formatUsd(c, language))}</span>
</span> </span>
</li> </li>
); );
+4 -1
View File
@@ -1,4 +1,6 @@
"use client"; "use client";
import { useI18n } from "@/components/i18n";
import { useEffect, useRef, useState } from "react"; import { useEffect, useRef, useState } from "react";
import { ChevronDownIcon } from "lucide-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). */ /** On the card's own background: no band (the accent read too light). */
inCard?: boolean; inCard?: boolean;
}) { }) {
const { t } = useI18n();
const [open, setOpen] = useState(() => resolveUserThinkingOpen(defaultOpen)); const [open, setOpen] = useState(() => resolveUserThinkingOpen(defaultOpen));
// ⌘T: an explicit global set overrides every row (once set, rows re-sync). // ⌘T: an explicit global set overrides every row (once set, rows re-sync).
const syncedRef = useRef(false); const syncedRef = useRef(false);
@@ -123,7 +126,7 @@ export function ThinkingBlock({
aria-expanded={open} aria-expanded={open}
className="flex w-full items-center gap-2 font-medium uppercase tracking-wide text-muted-foreground transition-colors hover:text-foreground" 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")} /> <ChevronDownIcon className={cn("size-4 shrink-0 transition-transform", open ? "rotate-180" : "rotate-0")} />
</button> </button>
{open && ( {open && (
+4 -1
View File
@@ -1,5 +1,7 @@
"use client" "use client"
import { useI18n } from "@/components/i18n";
import * as React from "react" import * as React from "react"
import { Tooltip, TooltipProvider, TooltipTrigger, TooltipContent } from "@/components/ui/tooltip" import { Tooltip, TooltipProvider, TooltipTrigger, TooltipContent } from "@/components/ui/tooltip"
import { bindingForAction, formatBinding, type KeyAction } from "@/src/core" 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) { export function Hint({ label, action, binding, side = "top", children }: HintProps) {
const { t } = useI18n();
return ( return (
<TooltipProvider> <TooltipProvider>
<Tooltip> <Tooltip>
<TooltipTrigger asChild>{children}</TooltipTrigger> <TooltipTrigger asChild>{children}</TooltipTrigger>
<TooltipContent side={side}>{hintText(label, action, binding)}</TooltipContent> <TooltipContent side={side}>{hintText(t(label), action, binding)}</TooltipContent>
</Tooltip> </Tooltip>
</TooltipProvider> </TooltipProvider>
) )
+6 -4
View File
@@ -1,4 +1,6 @@
"use client"; "use client";
import { useI18n } from "@/components/i18n";
/** /**
* The auto-update banner (Wave C, owner 2026-10-10: silent download + * 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 }; type UpdateStatus = { status: string; version: string | null };
export default function UpdateBanner() { export default function UpdateBanner() {
const { t } = useI18n();
const [ready, setReady] = useState<UpdateStatus | null>(null); const [ready, setReady] = useState<UpdateStatus | null>(null);
useEffect(() => { 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" 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"> <div className="text-sm">
<span className="font-medium">LearnHarness {ready.version ?? "update"} is ready</span> <span className="font-medium">LearnHarness {ready.version ?? "update"}{" "}{t("is ready")}</span>
<span className="text-muted-foreground"> — restart to finish installing.</span> <span className="text-muted-foreground"> {" "}{t("— restart to finish installing.")}</span>
</div> </div>
<button <button
type="button" type="button"
onClick={() => window.lh?.installUpdate?.()} onClick={() => window.lh?.installUpdate?.()}
className="rounded-md bg-accent px-3 py-1.5 text-sm font-medium transition-colors hover:bg-accent/80" className="rounded-md bg-accent px-3 py-1.5 text-sm font-medium transition-colors hover:bg-accent/80"
> >
Restart {t("Restart")}</button>
</button>
</div> </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 * Money/token presentation helpers (wave-4 observability UI) — shared by the
* activity dock and the stats bar. Pure; exported for tests. * activity dock and the stats bar. Pure; exported for tests.
*/ */
/** USD for cent-scale money: >= $1 keeps two decimals, smaller keeps four. */ /** 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)}`; 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". */ /** 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 >= 1e6) return `${+(n / 1e6).toFixed(1)}M`;
if (n >= 1e3) return `${+(n / 1e3).toFixed(1)}k`; if (n >= 1e3) return `${+(n / 1e3).toFixed(1)}k`;
return String(n); return String(n);
+3
View File
@@ -1,5 +1,8 @@
# Desktop builds and releases # Desktop builds and releases
For this fork’s Linux and Windows 0.6.0 release on Gitea, see [Gitea releases](gitea-release.md).
The three-platform GitHub process below documents the upstream reference.
## Platforms ## Platforms
| Platform | Build runner | Packages | In-app update | | Platform | Build runner | Packages | In-app update |
+156
View File
@@ -0,0 +1,156 @@
# Gitea releases for this fork
The release destination is
https://server2.sorokin-oleg-edu.xyz/replicated/learnharness.
Version **0.6.0 targets Linux x64 and Windows x64**, with each platform built
and verified on its native operating system. The upstream GitHub workflow and three-platform packaging
configuration remain available as reference in `.github/workflows/release.yml`
and `electron-builder.yml`.
Official fork packages use `electron-builder.gitea.yml`, which inherits the
upstream packaging and replaces its update origin with:
```
https://server2.sorokin-oleg-edu.xyz/replicated/learnharness/releases/download/latest
```
Gitea's `latest` alias resolves the most recent published, non-prerelease release.
Drafts do not become updates. The repository and published downloads must be
public; no API credential is embedded in the application.
## Preparing Linux packages
Use Node **26**, matching upstream's release gates. Build from a clean checkout
of the intended release commit. Keep `.env*`, local keys, and application data out
of the build directory. Next can copy loaded environment files after tracing;
`build-server.mjs` refuses a payload containing them. A clean worktree avoids
loading these files in the first place.
```sh
npm ci
npm run typecheck
npm test
npm run lint
ELECTRON_RUN_AS_NODE=1 npx electron --import tsx --test tests/pdf-native-render.test.ts tests/pdf-ops.test.ts tests/svg-native-render.test.ts tests/browser-service.test.ts
npm run desktop:release:linux
```
The release script builds the standalone server, initializes providers offline,
curates the payload, boots it under Electron's bundled Node, packages AppImage
and Debian installers, checks the packaged Gitea updater configuration, creates SHA-256 checksums, and verifies update metadata
against the actual installer bytes.
Run the packaged-runtime gates too:
```sh
node scripts/smoke-provider-runtime.mjs --standalone dist/linux-unpacked/resources/server/standalone
node scripts/smoke-provider-runtime.mjs --standalone dist/linux-unpacked/resources/server/standalone --runtime dist/linux-unpacked/learnharness
xvfb-run -a node scripts/smoke-demo-runtime.mjs --standalone dist/linux-unpacked/resources/server/standalone --runtime dist/linux-unpacked/learnharness --browser
```
The Linux portion of 0.6.0 has these attachments:
- `LearnHarness-0.6.0-x86_64.AppImage`
- `LearnHarness-0.6.0-amd64.deb`
- `latest-linux.yml`
- `SHA256SUMS`
## Preparing Windows packages
Use a clean checkout of the same `v0.6.0` tag on Windows x64 with Node 26 and Git
for Windows. Keep build tools and logs outside the checkout. The Windows
release checkout is `~/Documents/Gitea/learnharness-release-0.6.0`, linked to the
existing repository in `~/Documents/Gitea/learnharness`.
In PowerShell, stop after any failed command:
```powershell
npm.cmd ci
$env:NODE_ENV = 'test'
$env:ELECTRON_RUN_AS_NODE = '1'
node node_modules/electron/cli.js --import tsx --test tests/pdf-native-render.test.ts tests/pdf-ops.test.ts tests/svg-native-render.test.ts tests/browser-service.test.ts
Remove-Item Env:ELECTRON_RUN_AS_NODE
node --import tsx --test tests/i18n.test.tsx
Remove-Item Env:NODE_ENV
node node_modules/next/dist/bin/next build
node scripts/smoke-provider-runtime.mjs
node scripts/build-server.mjs
node scripts/smoke-server.mjs --electron
node node_modules/electron-builder/out/cli/cli.js --config electron-builder.gitea.yml --win --x64 --publish never
node scripts/gitea-release.mjs verify-package dist/win-unpacked
node scripts/smoke-provider-runtime.mjs --standalone dist/win-unpacked/resources/server/standalone
node scripts/smoke-provider-runtime.mjs --standalone dist/win-unpacked/resources/server/standalone --runtime dist/win-unpacked/LearnHarness.exe
node scripts/smoke-demo-runtime.mjs --standalone dist/win-unpacked/resources/server/standalone --runtime dist/win-unpacked/LearnHarness.exe --browser
```
Windows contributes three attachments:
- `LearnHarness-0.6.0-x64-setup.exe`
- `LearnHarness-0.6.0-x64-setup.exe.blockmap`
- `latest.yml`
The combined release has **seven attachments**, including one `SHA256SUMS`
covering all six other files. Verify that `latest.yml` names the Windows
installer exactly, reports version `0.6.0`, and matches the installer's actual
size and SHA-512. Its gzip blockmap must cover the installer and match every
block checksum. Confirm the packaged `resources/app-update.yml` uses the Gitea
generic provider. Windows builds follow upstream's unsigned, per-user installer
configuration.
## Preparing a Linux-only Gitea draft
Commit and push the release source, then push its exact annotated tag:
```sh
git tag -a v0.6.0 -m 'LearnHarness 0.6.0'
git push gitea main v0.6.0
```
Supply `GITEA_TOKEN` only to the upload command. A token file may remain outside
the clean build checkout:
```sh
node --env-file=/path/to/private/release.env scripts/gitea-release.mjs upload dist
```
The tool requires the tag to exist in Gitea, creates or reuses one draft at that
tag, uploads missing assets, and downloads every attachment to verify its actual
SHA-256 against the local verified build. Existing mismatched attachments and
published releases are refused. It never publishes or changes repository
visibility. Release notes come from `docs/release-0.6.0.md`.
This uploader and its remote verifier enforce a **Linux-only** inventory. They
will refuse a combined Linux/Windows draft. For the combined 0.6.0 release, add
the verified Windows attachments to the existing draft and replace `SHA256SUMS`
with the combined manifest. Keep the Linux installers and feed unchanged. Check
all seven attachment names and sizes against the verified local builds. For
the owner-approved 0.6.0 release, local installer/feed/blockmap validation and
the combined checksum manifest are completed before upload. Confirm successful
upload responses and publish immediately afterward; do not download attachments
again or run post-upload hash checks. Do not publish until both platforms'
local checks pass.
A separate verification can be repeated without changing the draft:
```sh
node --env-file=/path/to/private/release.env scripts/gitea-release.mjs verify-remote dist
```
Review the verified draft before publication. Only after publication will the
public `latest` feed and in-app automatic updates expose this version.
## Gitea Actions
`.gitea/workflows/release.yml` provides the same Linux gates through a manual
workflow with an existing release tag as input. It requires a native Linux x64
runner labeled `ubuntu-24.04`, the label used by upstream's Linux release job,
plus the native Electron dependencies and `xvfb-run`. There were no runners
registered when 0.6.0 was prepared, so its packages are built locally. A tag push
does not queue a job that cannot run.
The workflow uses the repository's ephemeral `GITEA_TOKEN` with release-write
permission only in the final upload step. It checks out the requested tag without
persisting credentials and leaves the result as a verified draft. Public pull
requests cannot trigger this publishing workflow. This workflow remains
Linux-only; the combined 0.6.0 draft was assembled from local native builds.
Windows has its own standalone payload and smoke checks, as described above.
+54
View File
@@ -0,0 +1,54 @@
# Language support
LearnHarness supports English and Russian. Settings → General contains independent
interface and speech recognition language selectors. The microphone also has a
speech language selector. Each offers System default, English, and Русский.
System default uses the preferred browser language (the operating system's
preferred language in the desktop app); Russian locales select Russian and other
locales select English. Explicit choices persist in `settings.json` as
`general.language` and `general.sttLanguage`. Removing a field restores automatic
selection. Changing one preference preserves the other general settings.
The interface changes immediately, including accessibility labels and the desktop
menu. New AI teaching, questions, plans, practice, graph descriptions, research,
figures, and demos use the interface language. Existing conversations and imported
source material retain their authored text. A session retains the language of its
latest foreground turn so automatic follow-ups can use the same language after a
restart; an explicit app language preference takes precedence.
Speech recognition uses Deepgram Nova-3 with an explicit `language=en` or
`language=ru`. A recording captures the selected language when it starts and keeps
that language through reconnections. The selector is disabled while recording;
changes elsewhere take effect on the next recording. Audio capture, deferred
transcript insertion, stopping, and cancellation retain their existing behavior.
## Maintaining translations
The pure localization functions live in `src/core/i18n.ts` and are exported through
the core barrel. English and Russian catalogs are in `src/core/locales/en.json` and
`ru.json`. English source phrases are message keys. Add every new app-owned message
to both catalogs, use named interpolation parameters, and use plural entries with
`Intl.PluralRules` for counts. Russian entries may use `one`, `few`, `many`, and
`other` forms. Keep code, provider/model names, identifiers, and user-authored text
out of translation lookups.
React components use `useI18n()` from `components/i18n.tsx`. The root provider gets
server-resolved initial settings and locale to preserve hydration. General-setting
writes share a serialized read-modify-write path; other surfaces use the existing
settings-saved signal. Server and agent code receive an explicit language rather
than depending on a mutable global translation state.
Some existing protocol errors arrive preformatted in English. Localization can
recognize cataloged application templates at the display boundary, preserving the
canonical error values used for classification and any third-party diagnostics.
Raw technical instructions, code, and user overrides remain editable source data.
The desktop shell has its own small catalog in `electron/language.cjs`, packaged
with the shell. Its narrow `lh:language` channel accepts only `en` and `ru` from a
trusted application window. Russian prose uses Noto Sans because Source Sans 3
has no Cyrillic subset. Explicit font choices continue to take precedence.
`tests/i18n.test.tsx` covers preference validation, automatic selection, independent
STT requests, catalog parity and interpolation, Russian plural forms, SSR locale
isolation, translated dialogs and exports, agent instructions, and native labels.
+59
View File
@@ -0,0 +1,59 @@
# LearnHarness 0.4.0
## English
LearnHarness now supports English and Russian throughout the interface and new
AI learning content. Switch languages in **Settings → General**. The interface
language and speech recognition language are independent and persist across
restarts; System default follows the browser or operating system, using English
for unsupported languages.
- Russian translations cover lessons, practice, the knowledge graph, setup,
settings, dialogs, tooltips, accessibility labels, notifications, exports,
desktop menus, and application error messages.
- New teaching, questions, plans, practice, graph descriptions, research,
diagrams, and interactive demos follow the interface language.
- Choose English or Russian speech recognition next to the microphone or in
General settings. A recording retains its language through reconnections;
changing the preference affects the next recording.
- Russian text uses a Cyrillic-capable reading font, correct plural forms,
localized numbers, and filenames that preserve Cyrillic characters.
- Language changes preserve other settings. Existing conversations and imported
learning material retain their original text.
This Gitea release includes **Linux x64** AppImage and Debian packages and a
**Windows x64** installer. Both platforms were built and checked on their native
operating systems. Automatic updates use this fork's public Gitea releases.
The Windows installer is unsigned, matching upstream's Windows packaging.
To use live dictation, configure a Deepgram key under **Settings → Providers**.
To start AI sessions, connect an AI provider and select the required model tiers.
## Русский
LearnHarness теперь поддерживает английский и русский языки во всём интерфейсе
и в новых учебных материалах, создаваемых ИИ. Язык можно выбрать в разделе
**Настройки → Общие**. Язык интерфейса и язык распознавания речи выбираются
независимо и сохраняются после перезапуска. В режиме «Язык системы» используется
язык браузера или операционной системы; для неподдерживаемых языков — английский.
- Переведены занятия, практика, граф знаний, первоначальная настройка,
параметры приложения, диалоги, подсказки, подписи для программ чтения с экрана,
уведомления, экспорт, меню настольного приложения и сообщения об ошибках.
- Новые объяснения, вопросы, планы, практические задачи, описания в графе,
исследования, диаграммы и интерактивные демонстрации используют язык интерфейса.
- Английский или русский язык распознавания речи можно выбрать рядом с микрофоном
или в общих настройках. Запись сохраняет выбранный язык при восстановлении
соединения; изменение настройки применяется к следующей записи.
- Для русского текста добавлен шрифт с кириллицей, правильные формы множественного
числа, локализованное отображение чисел и поддержка кириллицы в именах файлов.
- Смена языка сохраняет остальные настройки. Существующие разговоры и
импортированные учебные материалы остаются на исходном языке.
Этот выпуск в Gitea включает пакеты AppImage и Debian для **Linux x64** и
установщик для **Windows x64**. Сборка и проверка каждой версии выполнены в её
операционной системе. Автоматические обновления используют публичные выпуски
этого форка в Gitea. Установщик Windows не подписан — как и в исходном проекте.
Для диктовки добавьте ключ Deepgram в разделе **Настройки → Провайдеры**.
Для занятий с ИИ подключите провайдера и выберите модели для всех нужных уровней.
+62
View File
@@ -0,0 +1,62 @@
# LearnHarness 0.5.0
## English
This release adds ChatGPT Fast mode and improves the language controls and
Russian settings layout introduced in 0.4.0.
- Interface-language and speech-recognition selectors in **Settings → General**,
plus the quick selector beside the microphone, now use the app's styled menus.
The menus include selected-item checkmarks and keyboard navigation; Escape
closes the menu and restores focus to its trigger. Language preferences stay
independent, and STT selection remains locked while recording.
- **Quizzing** keeps its full Russian labels. The label and segmented control
share a row when space permits. Narrower panels stack the control beneath the
hint with matching left alignment and enough height for wrapped text.
- **Settings → Agent** now has a **⚡ Fast** toggle for each Strong, Cheap, and
Vision model tier. Fast mode is **off by default** and initially available only
for **ChatGPT** connections. It requests priority processing and may consume
more ChatGPT plan allowance; availability depends on the model and account.
- Fast preferences take effect on the **next request**, including existing
ChatGPT conversations and newly started background work in the corresponding
tier. The selected model and reasoning effort are preserved. Selecting another
provider turns Fast off for that tier; other providers show it as unavailable.
- Unsupported Fast requests produce actionable, localized errors. Connections
keep their existing account, background-consent, and no-API-key-fallback rules.
Packages: **Linux x64** AppImage and Debian, and a **Windows x64** installer.
Both platforms use this fork's public Gitea releases for automatic updates.
Windows packaging remains unsigned, matching upstream.
## Русский
В этом выпуске добавлен быстрый режим ChatGPT и улучшены языковые настройки
и их отображение на русском языке, появившиеся в версии 0.4.0.
- Выбор языка интерфейса и распознавания речи в разделе **Настройки → Общие**,
а также быстрый выбор языка рядом с микрофоном теперь используют меню в стиле
приложения. Выбранный пункт отмечен галочкой, поддерживается управление
клавиатурой. Escape закрывает меню и возвращает фокус на кнопку. Языковые
настройки остаются независимыми; язык распознавания нельзя менять во время
записи.
- В разделе **Проверка понимания** сохранены полные русские подписи. Если места
достаточно, заголовок и переключатель расположены в одной строке. В узких
панелях переключатель размещается под пояснением с выравниванием по левому
краю; высота кнопок учитывает перенос текста.
- В разделе **Настройки → Агент** появился переключатель **⚡ Быстро** для каждого
уровня моделей: основной, экономичной и с поддержкой изображений. Быстрый
режим **отключён по умолчанию** и пока доступен только для подключений
**ChatGPT**. Он запрашивает приоритетную обработку и может быстрее расходовать
лимит ChatGPT. Доступность зависит от модели и учётной записи.
- Настройка применяется к **следующему запросу**, в том числе в существующих
занятиях с ChatGPT и в новых фоновых задачах соответствующего уровня.
Выбранная модель и уровень рассуждений сохраняются. При выборе другого
провайдера быстрый режим для этого уровня отключается; для остальных
провайдеров переключатель недоступен.
- Если быстрый режим не поддерживается, приложение показывает понятную ошибку
на выбранном языке. Сохраняются выбранная учётная запись, разрешения на
фоновую работу и запрет автоматического перехода на API-ключ.
Пакеты: AppImage и Debian для **Linux x64**, установщик для **Windows x64**.
Обе версии используют публичные выпуски этого форка в Gitea для автоматических
обновлений. Установщик Windows не подписан, как и в исходном проекте.
+53
View File
@@ -0,0 +1,53 @@
# LearnHarness 0.5.1
## English
This release adds multilingual speech recognition and simplifies the STT picker
beside the microphone.
- **Settings → General → Speech recognition language** now includes
**Multilingual**. It uses Deepgram Nova-3's `language=multi` mode for speech
that switches between supported languages, including Russian and English.
The preference is saved and applies to the next recording, including any
reconnects during that recording.
- The chat STT picker now offers **English**, **Русский**, and **Multilingual**.
The compact button displays **Multi** when multilingual recognition is selected.
The **Auto · LANG** entry has been removed from this picker.
- **System default** remains available in General settings. When it is active,
the chat picker displays the resolved English or Russian language. Opening
the picker preserves System default; choosing a language saves that explicit
preference, even when it matches the current system language.
- Interface and STT languages remain independently selectable. Existing
preferences and system defaults are preserved, and both STT controls remain
locked while recording. The menus retain keyboard navigation and selected-item
checkmarks.
Packages: **Linux x64** AppImage and Debian, and a **Windows x64** installer.
Both platforms use this fork's public Gitea releases for automatic updates.
Windows packaging remains unsigned, matching the previous release.
## Русский
В этом выпуске добавлено многоязычное распознавание речи и упрощён выбор языка
рядом с микрофоном.
- В разделе **Настройки → Общие → Язык распознавания речи** появился вариант
**Несколько языков**. Он включает режим `language=multi` в Deepgram Nova-3
для речи с переключением между поддерживаемыми языками, в том числе русским
и английским. Выбор сохраняется и применяется к следующей записи, включая
повторные подключения во время этой записи.
- В меню языка распознавания рядом с микрофоном теперь доступны **English**,
**Русский** и **Несколько языков**. При многоязычном распознавании компактная
кнопка показывает **Мульти**. Пункт **Авто · LANG** из этого меню удалён.
- В общих настройках по-прежнему доступен вариант **Язык системы**. Если он
выбран, кнопка рядом с микрофоном показывает фактически используемый язык:
английский или русский. Открытие меню не меняет настройку. Выбор языка
сохраняет его явно, даже если он совпадает с текущим языком системы.
- Язык интерфейса и язык распознавания остаются независимыми. Сохраняются
прежние предпочтения и выбор языка системы. Оба переключателя языка
распознавания заблокированы во время записи. Меню поддерживают управление
клавиатурой и отмечают выбранный пункт галочкой.
Пакеты: AppImage и Debian для **Linux x64**, установщик для **Windows x64**.
Обе версии используют публичные выпуски этого форка в Gitea для автоматических
обновлений. Установщик Windows не подписан, как и в предыдущем выпуске.
+56
View File
@@ -0,0 +1,56 @@
# LearnHarness 0.5.2
## English
This release adds preferred speech-recognition languages and fixes the model
settings layout.
- **Settings → General → Preferred languages** offers **English** and **Русский**
checkboxes below the label and explanation. Select both with **Multilingual**
to guide recognition toward Russian and English in the same recording.
Preferred languages are hints, so other supported languages may still be
recognized. Leaving both unchecked keeps automatic detection.
- Preferences are saved independently of interface language and STT mode.
They remain saved when a single-language mode is selected, but are sent only
with Multilingual. The controls lock while saving or recording, and a failed
save preserves the previous choice and shows a localized error.
- Each recording keeps its language and preferred-language hints through any
reconnects. Later preference changes apply to the next recording.
- **Settings → Agent** now gives model-tier descriptions a flexible text column.
Model, reasoning, and Fast controls stay together and align across tiers.
Narrow panels place the complete control group below the description,
aligned left. Long model names keep their existing ellipsis, and background-use
permission buttons occupy a separate line.
Packages: **Linux x64** AppImage and Debian, and a **Windows x64** installer.
Both platforms use this fork's public Gitea releases for automatic updates.
Windows packaging remains unsigned, matching previous releases.
## Русский
В этом выпуске добавлен выбор предпочитаемых языков распознавания речи
и исправлено расположение элементов в настройках моделей.
- В разделе **Настройки → Общие → Предпочитаемые языки** появились флажки
**English** и **Русский**, расположенные под заголовком и пояснением.
Выберите оба в режиме **Несколько языков**, чтобы отдавать приоритет русскому
и английскому в одной записи. Это подсказки для распознавания: другие
поддерживаемые языки тоже могут быть распознаны. Если оба флажка сняты,
используется автоматическое определение языка.
- Предпочтения сохраняются независимо от языка интерфейса и режима распознавания.
При выборе одного языка они сохраняются, но отправляются только в режиме
«Несколько языков». Флажки заблокированы во время сохранения и записи.
Если сохранить настройку не удалось, прежний выбор сохраняется, а приложение
показывает ошибку на выбранном языке.
- Язык и подсказки фиксируются на время записи, включая повторные подключения.
Последующие изменения применяются к следующей записи.
- В разделе **Настройки → Агент** описания уровней моделей занимают гибкую
текстовую колонку. Выбор модели, уровня рассуждения и быстрый режим остаются
вместе и выровнены между уровнями. В узких панелях вся группа располагается
под описанием с выравниванием по левому краю. Длинные названия моделей
по-прежнему сокращаются многоточием, а кнопки разрешения фоновой работы
размещаются на отдельной строке.
Пакеты: AppImage и Debian для **Linux x64**, установщик для **Windows x64**.
Обе версии используют публичные выпуски этого форка в Gitea для автоматических
обновлений. Установщик Windows не подписан, как и в предыдущих выпусках.
+63
View File
@@ -0,0 +1,63 @@
# LearnHarness 0.6.0
## English
This release adds OpenRouter speech recognition and a dedicated speech-recognition
settings page, with cleaner controls in English and Russian.
- **OpenRouter STT:** choose **MAI-Transcribe-2 · OpenRouter** and add your
OpenRouter API key under **Settings → Providers**. The app records locally,
sends the whole recording after **Stop**, and shows **Transcribing…** until
the complete transcript is ready. Audio is not split into speech segments.
Cancelling aborts the request; submitting an answer or message waits for
transcription. The permanent API key stays on the server and audio is not
saved to disk. Recordings are limited to 25 MB, about 13 minutes.
- **Settings → Speech recognition** now contains the model, recognition language,
and preferred languages. The two model choices use the same format:
**Nova-3 · Deepgram** and **MAI-Transcribe-2 · OpenRouter**. General settings
contains only interface language and automatic updates.
- **Preferred languages** uses a styled checkbox dropdown aligned on the right.
It stays open while selecting English and Russian, shows **No preference**
when neither is selected, and no longer dims or flashes while saving.
These hints remain available for Deepgram Multilingual. MAI uses automatic
language detection for Multilingual; its API does not expose a RU + EN hint
list. Saved hints are retained when switching models.
- On Linux, clicking an available **Fast** button keeps the normal cursor while
the setting is saved.
Existing settings remain compatible and direct Deepgram Nova-3 remains the
default. Linux x64 packages include AppImage and Debian; Windows x64 has an
installer. Both platforms use this fork's public Gitea releases for automatic
updates. The Windows installer remains unsigned, as in previous releases.
## Русский
В этом выпуске добавлено распознавание речи через OpenRouter и отдельный раздел
его настроек. Элементы управления обновлены на русском и английском языках.
- **Распознавание через OpenRouter:** выберите **MAI-Transcribe-2 · OpenRouter**
и добавьте API-ключ OpenRouter в разделе **Настройки → Провайдеры**. Приложение
записывает речь локально, отправляет всю запись после остановки и показывает
**Распознавание…** до получения полного текста. Речь не разбивается на
отдельные фрагменты. Отмена прерывает запрос, а отправка ответа или сообщения
дожидается завершения распознавания. Постоянный API-ключ остаётся на сервере,
аудио не сохраняется на диск. Максимальный размер записи — 25 МБ, примерно
13 минут.
- Новый раздел **Настройки → Распознавание речи** содержит выбор модели, языка
распознавания и предпочитаемых языков. Названия моделей оформлены одинаково:
**Nova-3 · Deepgram** и **MAI-Transcribe-2 · OpenRouter**. В разделе «Общие»
остались только язык интерфейса и автоматические обновления.
- **Предпочитаемые языки** выбираются в оформленном в стиле приложения
выпадающем меню с флажками справа. Меню остаётся открытым при выборе русского
и английского. Если оба флажка сняты, отображается **Без предпочтений**.
Во время сохранения меню больше не тускнеет и не мигает. Подсказки работают
в многоязычном режиме Deepgram. MAI определяет языки автоматически: его API
не предоставляет списка подсказок RU + EN. Сохранённые предпочтения остаются
при переключении моделей.
- В Linux нажатие доступной кнопки **Быстро** больше не меняет курсор на время
сохранения настройки.
Прежние настройки совместимы; по умолчанию по-прежнему используется Deepgram
Nova-3 напрямую. Для Linux x64 доступны AppImage и Debian, для Windows x64 —
установщик. Обе версии получают автоматические обновления из публичных выпусков
этого форка в Gitea. Установщик Windows не подписан, как и в предыдущих выпусках.
+18
View File
@@ -0,0 +1,18 @@
# Speech recognition
Settings → Speech recognition contains the model, recognition language and preferred-language controls. The model picker offers two choices:
- **Nova-3 · Deepgram** (the default): streams microphone audio directly to Deepgram using a short-lived token. Add a Deepgram API key under Providers.
- **MAI-Transcribe-2 · OpenRouter**: records locally in memory and sends the complete recording in one request after Stop. Add an OpenRouter API key under Providers; the existing key also works if OpenRouter is already configured for chat. The permanent key stays on the server.
Both insert the transcript after recording finishes. OpenRouter displays **Transcribing…** while waiting. Sending a chat message or answer during recording waits for transcription before continuing. Cancel or Escape discards dictation; cancelling during transcription aborts the request and ignores late results. Navigating away releases the microphone and cancels pending transcription.
The interface language, recognition language, and STT model are saved independently. Model and language changes apply to the next recording, and their controls lock during recording/transcription. Existing settings continue to use Deepgram until another model is selected.
English and Russian select a single recognition language. **Multilingual** uses automatic language detection in MAI, including mixed-language speech. Its documented API accepts only one forced language and has no preferred-language list; saved RU/EN hints remain available for Deepgram and are disabled when MAI is selected. They are not sent as keyword hints or an ignored prompt.
OpenRouter audio is a lossless WAV container around the same 16 kHz mono PCM capture used for Deepgram. Stop drains the microphone's final partial frame before sending it. There is no speech splitting, silence removal, automatic provider fallback, or automatic transcription retry. The inline audio limit is 25 MB (about 13 minutes at this format); exceeding it stops recording with an error. Provider processing can time out for long recordings even within the size limit. Audio is not saved to disk.
The renderer sends WAV bytes to `POST /api/stt/transcribe?model=microsoft/mai-transcribe-2&language=en|ru|multi`. The server validates the model, language, WAV format and body size, reads `OPENROUTER_API_KEY` through the normal key store, and posts base64 audio to OpenRouter's dedicated transcription endpoint. Provider diagnostics are replaced with localized app errors, without returning credentials or audio. Offline tests mock all provider calls.
References: [OpenRouter transcription API](https://openrouter.ai/docs/guides/overview/multimodal/stt), [MAI-Transcribe-2](https://openrouter.ai/microsoft/mai-transcribe-2), [Microsoft language and model options](https://learn.microsoft.com/en-us/azure/ai-services/speech-service/mai-transcribe).
+8
View File
@@ -0,0 +1,8 @@
# Official packages for this fork. Upstream packaging stays in electron-builder.yml.
extends: ./electron-builder.yml
publish:
# Use an object: electron-builder concatenates inherited publish arrays.
provider: generic
# Gitea resolves the latest published, non-prerelease release here.
# Drafts remain private and do not become an update before publication.
url: https://server2.sorokin-oleg-edu.xyz/replicated/learnharness/releases/download/latest
+13
View File
@@ -0,0 +1,13 @@
/** Native-shell catalog. Kept with the shell so packaged builds do not load server sources. */
const ru = {
"Check for Updates…": "Проверить обновления…", "Edit": "Правка", "View": "Вид", "Window": "Окно",
"Actual Size": "Исходный масштаб", "Zoom In": "Увеличить масштаб", "Zoom Out": "Уменьшить масштаб",
"About LearnHarness": "О LearnHarness", "Hide LearnHarness": "Скрыть LearnHarness", "Hide Others": "Скрыть остальные", "Quit LearnHarness": "Выйти из LearnHarness",
"Undo": "Отменить", "Redo": "Повторить", "Cut": "Вырезать", "Copy": "Копировать", "Paste": "Вставить", "Select All": "Выбрать всё",
"Reload": "Перезагрузить", "Developer Tools": "Инструменты разработчика", "Toggle Full Screen": "Полноэкранный режим", "Minimize": "Свернуть", "Close": "Закрыть",
"You're up to date.": "Установлена последняя версия.", "Couldn't check for updates.": "Не удалось проверить обновления.",
"LearnHarness {version} is the latest version.": "LearnHarness {version} — последняя версия.", "OK": "ОК",
};
function resolveLanguage(preference, languages = []) { return preference === "ru" || preference !== "en" && /^ru(?:-|_|$)/i.test(languages[0] || "") ? "ru" : "en"; }
function translate(language, text, params = {}) { return (language === "ru" ? ru[text] || text : text).replace(/\{(\w+)\}/g, (match, key) => Object.hasOwn(params, key) ? String(params[key]) : match); }
module.exports = { resolveLanguage, translate };
+34 -16
View File
@@ -214,6 +214,7 @@ function startServer(port) {
PORT: String(port), PORT: String(port),
HOSTNAME: "127.0.0.1", // the server defaults to 0.0.0.0 — never HOSTNAME: "127.0.0.1", // the server defaults to 0.0.0.0 — never
LH_DATA_DIR: DATA_DIR(), LH_DATA_DIR: DATA_DIR(),
LH_SYSTEM_LANGUAGE: app.getPreferredSystemLanguages()[0] || "en",
// The auth/keys store — the child reads AND writes this file itself // The auth/keys store — the child reads AND writes this file itself
// (no env injection anymore: a spawn-time copy would shadow every // (no env injection anymore: a spawn-time copy would shadow every
// settings-page key change until restart; env wins over file). // settings-page key change until restart; env wins over file).
@@ -456,11 +457,11 @@ function checkForUpdates({ manual = false } = {}) {
// update-downloaded. Manual checks additionally answer directly. // update-downloaded. Manual checks additionally answer directly.
if (!manual) return; if (!manual) return;
const version = result?.updateInfo?.version; const version = result?.updateInfo?.version;
if (!version) dialog.showMessageBox({ type: "info", message: "You're up to date.", detail: `LearnHarness ${app.getVersion()} is the latest version.` }); if (!version) dialog.showMessageBox({ type: "info", message: nt("You're up to date."), detail: nt("LearnHarness {version} is the latest version.", { version: app.getVersion() }), buttons: [nt("OK")] });
}) })
.catch((err) => { .catch((err) => {
log(`update check failed: ${err?.message ?? err}`); log(`update check failed: ${err?.message ?? err}`);
if (manual) dialog.showMessageBox({ type: "warning", message: "Couldn't check for updates.", detail: String(err?.message ?? err) }); if (manual) dialog.showMessageBox({ type: "warning", message: nt("Couldn't check for updates."), buttons: [nt("OK")], detail: String(err?.message ?? err) });
}); });
} }
@@ -524,53 +525,63 @@ function applyZoom(win, factor) {
/** macOS needs an application menu with the Edit roles — without one, /** macOS needs an application menu with the Edit roles — without one,
* Cmd+C/V/A do nothing in the renderer. Everything else stays minimal. */ * Cmd+C/V/A do nothing in the renderer. Everything else stays minimal. */
const { resolveLanguage: resolveShellLanguage, translate: translateShell } = require("./language.cjs");
let shellLanguage;
function nativeLanguage() {
if (shellLanguage) return shellLanguage;
let preference;
try { preference = JSON.parse(fs.readFileSync(path.join(DATA_DIR(), "settings.json"), "utf8")).general?.language; } catch {}
return resolveShellLanguage(preference, app.getPreferredSystemLanguages());
}
function nt(text, params) { return translateShell(nativeLanguage(), text, params); }
function installMenu() { function installMenu() {
const template = [ const template = [
{ {
label: app.name, label: app.name,
submenu: [ submenu: [
{ role: "about" }, { role: "about", label: nt("About LearnHarness") },
// Manual check (Wave C): answers via native dialog — no renderer // Manual check (Wave C): answers via native dialog — no renderer
// round-trip. The 24h automatic checks stay silent. // round-trip. The 24h automatic checks stay silent.
{ label: "Check for Updates…", click: () => checkForUpdates({ manual: true }) }, { label: nt("Check for Updates…"), click: () => checkForUpdates({ manual: true }) },
{ type: "separator" }, { type: "separator" },
{ role: "hide" }, { role: "hide", label: nt("Hide LearnHarness") },
{ role: "hideOthers" }, { role: "hideOthers", label: nt("Hide Others") },
{ type: "separator" }, { type: "separator" },
{ role: "quit" }, { role: "quit", label: nt("Quit LearnHarness") },
], ],
}, },
{ label: "Edit", submenu: [{ role: "undo" }, { role: "redo" }, { type: "separator" }, { role: "cut" }, { role: "copy" }, { role: "paste" }, { role: "selectAll" }] }, { label: nt("Edit"), submenu: [{ role: "undo", label: nt("Undo") }, { role: "redo", label: nt("Redo") }, { type: "separator" }, { role: "cut", label: nt("Cut") }, { role: "copy", label: nt("Copy") }, { role: "paste", label: nt("Paste") }, { role: "selectAll", label: nt("Select All") }] },
{ {
label: "View", label: nt("View"),
submenu: [ submenu: [
{ role: "reload" }, { role: "reload", label: nt("Reload") },
{ role: "toggleDevTools" }, { role: "toggleDevTools", label: nt("Developer Tools") },
{ type: "separator" }, { type: "separator" },
// OWNED zoom (see applyZoom): the roles change zoomLevel // OWNED zoom (see applyZoom): the roles change zoomLevel
// silently; these broadcast the factor and re-seat the // silently; these broadcast the factor and re-seat the
// traffic lights. The renderer's --lh-zoom depends on // traffic lights. The renderer's --lh-zoom depends on
// hearing every change. // hearing every change.
{ {
label: "Actual Size", label: nt("Actual Size"),
accelerator: "CommandOrControl+0", accelerator: "CommandOrControl+0",
click: () => applyZoom(BrowserWindow.getAllWindows()[0], 1), click: () => applyZoom(BrowserWindow.getAllWindows()[0], 1),
}, },
{ {
label: "Zoom In", label: nt("Zoom In"),
accelerator: "CommandOrControl+Plus", accelerator: "CommandOrControl+Plus",
click: () => zoomStep(BrowserWindow.getAllWindows()[0], +1), click: () => zoomStep(BrowserWindow.getAllWindows()[0], +1),
}, },
{ {
label: "Zoom Out", label: nt("Zoom Out"),
accelerator: "CommandOrControl+-", accelerator: "CommandOrControl+-",
click: () => zoomStep(BrowserWindow.getAllWindows()[0], -1), click: () => zoomStep(BrowserWindow.getAllWindows()[0], -1),
}, },
{ type: "separator" }, { type: "separator" },
{ role: "togglefullscreen" }, { role: "togglefullscreen", label: nt("Toggle Full Screen") },
], ],
}, },
{ role: "windowMenu" }, { label: nt("Window"), submenu: [{ role: "minimize", label: nt("Minimize") }, { role: "close", label: nt("Close") }] },
]; ];
Menu.setApplicationMenu(Menu.buildFromTemplate(template)); Menu.setApplicationMenu(Menu.buildFromTemplate(template));
} }
@@ -647,6 +658,13 @@ async function main() {
}); });
installMenu(); installMenu();
ipcMain.on("lh:language", (event, language) => {
if (!trustedAppWindows.has(BrowserWindow.fromWebContents(event.sender)) || !trustedSender(event, new URL(url).origin, BrowserWindow)) return;
if (language !== "en" && language !== "ru") return;
if (shellLanguage === language) return;
shellLanguage = language;
installMenu();
});
// Auto-update (Wave C): wire the updater before the window so the // Auto-update (Wave C): wire the updater before the window so the
// banner channel is live from first paint; the first check and the 24h // banner channel is live from first paint; the first check and the 24h
+1
View File
@@ -29,6 +29,7 @@ const { contextBridge, ipcRenderer } = require("electron");
contextBridge.exposeInMainWorld("lh", { contextBridge.exposeInMainWorld("lh", {
platform: process.platform, platform: process.platform,
setLanguage: (language) => ipcRenderer.send("lh:language", language),
chatgpt: { chatgpt: {
status: () => ipcRenderer.invoke("lh:chatgpt:control", { op: "status" }), status: () => ipcRenderer.invoke("lh:chatgpt:control", { op: "status" }),
login: (id) => ipcRenderer.invoke("lh:chatgpt:control", { op: "login", id }), login: (id) => ipcRenderer.invoke("lh:chatgpt:control", { op: "login", id }),
+2 -2
View File
@@ -1,12 +1,12 @@
{ {
"name": "learnharness", "name": "learnharness",
"version": "0.3.7", "version": "0.6.0",
"lockfileVersion": 3, "lockfileVersion": 3,
"requires": true, "requires": true,
"packages": { "packages": {
"": { "": {
"name": "learnharness", "name": "learnharness",
"version": "0.3.7", "version": "0.6.0",
"license": "AGPL-3.0-only", "license": "AGPL-3.0-only",
"dependencies": { "dependencies": {
"@earendil-works/pi-agent-core": "1.4.0", "@earendil-works/pi-agent-core": "1.4.0",
+2 -1
View File
@@ -1,6 +1,6 @@
{ {
"name": "learnharness", "name": "learnharness",
"version": "0.3.7", "version": "0.6.0",
"license": "AGPL-3.0-only", "license": "AGPL-3.0-only",
"author": { "author": {
"name": "Amos Blomqvist", "name": "Amos Blomqvist",
@@ -19,6 +19,7 @@
"test": "NODE_ENV=test tsx --test tests/*.test.ts tests/*.test.tsx", "test": "NODE_ENV=test tsx --test tests/*.test.ts tests/*.test.tsx",
"lint": "eslint .", "lint": "eslint .",
"typecheck": "tsc --noEmit", "typecheck": "tsc --noEmit",
"desktop:release:linux": "rm -rf dist .next/standalone && npm run build && node scripts/smoke-provider-runtime.mjs && node scripts/build-server.mjs && node scripts/smoke-server.mjs --electron && electron-builder --config electron-builder.gitea.yml --linux --x64 --publish never && node scripts/gitea-release.mjs verify-package dist/linux-unpacked && node scripts/gitea-release.mjs checksums dist && node scripts/gitea-release.mjs verify dist",
"desktop:dev": "concurrently -k \"next dev\" \"LH_DEV_URL=http://localhost:3000 electron .\"", "desktop:dev": "concurrently -k \"next dev\" \"LH_DEV_URL=http://localhost:3000 electron .\"",
"dev:fresh": "bash scripts/dev-fresh.sh", "dev:fresh": "bash scripts/dev-fresh.sh",
"desktop:dist": "rm -rf dist .next/standalone && next build && node scripts/smoke-provider-runtime.mjs && node scripts/build-server.mjs && node scripts/smoke-server.mjs && DEVELOPER_DIR=${DEVELOPER_DIR:-/Applications/Xcode.app/Contents/Developer} APPLE_KEYCHAIN_PROFILE=LH_NOTARY APPLE_TEAM_ID=JS6XN22HY6 electron-builder --mac --arm64" "desktop:dist": "rm -rf dist .next/standalone && next build && node scripts/smoke-provider-runtime.mjs && node scripts/build-server.mjs && node scripts/smoke-server.mjs && DEVELOPER_DIR=${DEVELOPER_DIR:-/Applications/Xcode.app/Contents/Developer} APPLE_KEYCHAIN_PROFILE=LH_NOTARY APPLE_TEAM_ID=JS6XN22HY6 electron-builder --mac --arm64"
+10
View File
@@ -27,6 +27,16 @@ class PcmCaptureProcessor extends AudioWorkletProcessor {
super(); super();
this.frame = new Int16Array(2048); this.frame = new Int16Array(2048);
this.count = 0; this.count = 0;
// Whole-recording STT must include the final, partially filled frame.
this.port.onmessage = (event) => {
if (event.data?.type !== "flush") return;
if (this.count > 0) {
const tail = this.frame.slice(0, this.count);
this.port.postMessage({ type: "audio", buffer: tail }, [tail.buffer]);
this.count = 0;
}
this.port.postMessage({ type: "flushed" });
};
} }
process(inputs) { process(inputs) {
+168
View File
@@ -0,0 +1,168 @@
/** Prepare and verify this fork's Linux release; publication is always a separate human action. */
import assert from "node:assert/strict";
import { createHash } from "node:crypto";
import fs from "node:fs";
import fsp from "node:fs/promises";
import { createRequire } from "node:module";
import os from "node:os";
import path from "node:path";
import { Readable } from "node:stream";
import { pipeline } from "node:stream/promises";
import { pathToFileURL } from "node:url";
const require = createRequire(import.meta.url);
const yaml = require("js-yaml");
const ROOT = path.resolve(import.meta.dirname, "..");
export const SERVER = "https://server2.sorokin-oleg-edu.xyz";
export const REPOSITORY = "replicated/learnharness";
export const UPDATE_URL = `${SERVER}/${REPOSITORY}/releases/download/latest`;
const API = `${SERVER}/api/v1/repos/${REPOSITORY}`;
export function expectedLinuxAssets(version) {
assert.match(version, /^\d+\.\d+\.\d+$/, "release version must be a stable semver");
return [`LearnHarness-${version}-x86_64.AppImage`, `LearnHarness-${version}-amd64.deb`, "latest-linux.yml", "SHA256SUMS"];
}
export async function fileDigest(file, algorithm, encoding = "hex") {
const hash = createHash(algorithm);
for await (const chunk of fs.createReadStream(file)) hash.update(chunk);
return hash.digest(encoding);
}
export async function writeChecksums(directory, version) {
const names = expectedLinuxAssets(version).filter((name) => name !== "SHA256SUMS");
const lines = [];
for (const name of names) lines.push(`${await fileDigest(path.join(directory, name), "sha256")} ${name}`);
await fsp.writeFile(path.join(directory, "SHA256SUMS"), lines.join("\n") + "\n");
}
export async function verifyPackagedUpdater(directory) {
const config = yaml.load(await fsp.readFile(path.join(directory, "resources", "app-update.yml"), "utf8"));
assert.equal(config.provider, "generic", "packaged updater must use the Gitea generic provider");
assert.equal(config.url, UPDATE_URL, "packaged updater must use the public Gitea release feed");
assert(!config.owner && !config.repo, "packaged updater must not retain an upstream GitHub destination");
}
export async function verifyLocal(directory, version) {
const names = expectedLinuxAssets(version);
const inventory = new Map();
for (const name of names) {
const file = path.join(directory, name);
const stat = await fsp.lstat(file);
assert(stat.isFile() && stat.size > 0, `${name} must be a nonempty regular file`);
inventory.set(name, { name, size: stat.size, sha256: await fileDigest(file, "sha256") });
}
for (const [name, magic] of [[names[0], Buffer.from([0x7f, 0x45, 0x4c, 0x46])], [names[1], Buffer.from("!<arch>\n")]]) {
const handle = await fsp.open(path.join(directory, name), "r");
try { const header = Buffer.alloc(magic.length); await handle.read(header, 0, header.length, 0); assert(header.equals(magic), `${name} has an invalid installer header`); } finally { await handle.close(); }
}
const feed = yaml.load(await fsp.readFile(path.join(directory, "latest-linux.yml"), "utf8"));
assert.equal(feed?.version, version, "update metadata version differs from the package");
assert(Array.isArray(feed.files), "update metadata must contain files");
const installers = names.filter((name) => /\.(AppImage|deb)$/.test(name));
assert.deepEqual(feed.files.map((entry) => entry.url).sort(), [...installers].sort(), "update feed must reference exactly the Linux installers by filename");
for (const entry of feed.files) {
assert.equal(entry.size, inventory.get(entry.url)?.size, `${entry.url} update size differs`);
assert.equal(entry.sha512, await fileDigest(path.join(directory, entry.url), "sha512", "base64"), `${entry.url} update checksum differs`);
}
assert.equal(feed.path, feed.files[0].url, "legacy updater path differs");
assert.equal(feed.sha512, feed.files[0].sha512, "legacy updater checksum differs");
const checksums = (await fsp.readFile(path.join(directory, "SHA256SUMS"), "utf8")).trim().split("\n");
assert.deepEqual(checksums, names.filter((name) => name !== "SHA256SUMS").map((name) => `${inventory.get(name).sha256} ${name}`), "SHA256SUMS does not match the release bytes");
return [...inventory.values()];
}
/** The downloaded bytes, not only API-reported sizes, must agree with the local verified package. */
export async function verifyRemote(release, localInventory, headers = {}, request = fetch) {
assert.equal(release.draft, true, "only a draft may be prepared or verified by this tool");
assert.equal(release.prerelease, false, "release must be stable");
assert.deepEqual(release.assets.map((asset) => asset.name).sort(), localInventory.map((asset) => asset.name).sort(), "remote draft has missing, duplicate, or unexpected assets");
const temporary = await fsp.mkdtemp(path.join(os.tmpdir(), "lh-release-verify-"));
try {
for (const expected of localInventory) {
const asset = release.assets.find((entry) => entry.name === expected.name);
assert.equal(asset.size, expected.size, `${expected.name} remote size differs`);
const url = new URL(asset.browser_download_url);
assert.equal(url.origin, SERVER, "release attachment points outside Gitea");
const response = await request(url, { headers, signal: AbortSignal.timeout(300_000) });
assert(response.ok && response.body, `cannot download ${expected.name}: HTTP ${response.status}`);
const file = path.join(temporary, expected.name);
await pipeline(Readable.fromWeb(response.body), fs.createWriteStream(file));
assert.equal(await fileDigest(file, "sha256"), expected.sha256, `${expected.name} remote bytes differ`);
}
} finally { await fsp.rm(temporary, { recursive: true, force: true }); }
}
function authorization() {
const token = process.env.GITEA_TOKEN || process.env.GITEA_ACCESS_TOKEN || process.env.GITEA_API_TOKEN;
assert(token, "GITEA_TOKEN is required for Gitea API operations");
return { Authorization: `token ${token}` };
}
async function api(endpoint, options = {}) {
const headers = { ...authorization(), ...options.headers };
const response = await fetch(`${API}${endpoint}`, { ...options, headers, signal: AbortSignal.timeout(300_000) });
if (!response.ok) throw new Error(`Gitea ${options.method ?? "GET"} ${endpoint}: HTTP ${response.status}`);
return response.status === 204 ? null : response.json();
}
export async function selectDraft(tag) {
const releases = [];
for (let page = 1; ; page++) {
const batch = await api(`/releases?limit=50&page=${page}`);
assert(Array.isArray(batch), "release list response is invalid");
releases.push(...batch);
if (batch.length < 50) break;
}
const matches = releases.filter((release) => release.tag_name === tag || release.tag_name === tag.slice(1));
assert(matches.length <= 1, `ambiguous releases for ${tag}`);
if (matches.length) {
assert.equal(matches[0].tag_name, tag, "use the exact versioned tag");
assert.equal(matches[0].draft, true, "refusing to modify an already published release");
}
return matches[0] ?? null;
}
async function upload(directory, version) {
const inventory = await verifyLocal(directory, version);
const tag = `v${version}`;
// The tag must already exist remotely; never create a release from a moving branch.
const tagged = await api(`/tags/${encodeURIComponent(tag)}`);
assert(tagged.commit?.sha, "release tag does not resolve to a commit");
let release = await selectDraft(tag);
const notes = await fsp.readFile(path.join(ROOT, "docs", `release-${version}.md`), "utf8");
const data = { tag_name: tag, target_commitish: tagged.commit.sha, name: `LearnHarness ${version}`, body: notes, draft: true, prerelease: false };
release = await api(release ? `/releases/${release.id}` : "/releases", { method: release ? "PATCH" : "POST", headers: { "Content-Type": "application/json" }, body: JSON.stringify(data) });
for (const expected of inventory) {
const existing = (release.assets ?? []).find((asset) => asset.name === expected.name);
if (existing) {
assert.equal(existing.size, expected.size, `${expected.name} already exists with different bytes; review the draft before replacing it`);
// Verify the bytes below, including an existing same-size attachment.
continue;
}
const bytes = await fsp.readFile(path.join(directory, expected.name));
const form = new FormData();
form.set("attachment", new Blob([bytes]), expected.name);
await api(`/releases/${release.id}/assets?name=${encodeURIComponent(expected.name)}`, { method: "POST", body: form });
console.log(`uploaded ${expected.name}`);
}
release = await api(`/releases/${release.id}`);
await verifyRemote(release, inventory, authorization());
console.log(`Verified draft: ${release.html_url}`);
console.log("Publication remains a separate review step.");
}
async function main() {
const [command, directory = "dist"] = process.argv.slice(2);
const { version } = JSON.parse(await fsp.readFile(path.join(ROOT, "package.json"), "utf8"));
if (command === "checksums") await writeChecksums(directory, version);
else if (command === "verify-package") await verifyPackagedUpdater(directory);
else if (command === "verify") console.log(JSON.stringify(await verifyLocal(directory, version), null, 2));
else if (command === "upload") await upload(directory, version);
else if (command === "verify-remote") {
const inventory = await verifyLocal(directory, version);
const release = await selectDraft(`v${version}`);
assert(release, "draft not found");
await verifyRemote(release, inventory, authorization());
console.log(`Verified draft: ${release.html_url}`);
} else throw new Error("Usage: node scripts/gitea-release.mjs checksums|verify|upload|verify-remote [dist-directory], or verify-package [unpacked-app-directory]");
}
if (process.argv[1] && pathToFileURL(path.resolve(process.argv[1])).href === import.meta.url) {
main().catch((error) => { console.error(error.message); process.exitCode = 1; });
}
+11 -5
View File
@@ -12,6 +12,7 @@ export function chatGPTStreamError(message: string): string {
if (/expired|invalid_grant|401|invalid_user/.test(message)) return "Your ChatGPT session expired. Reconnect in Providers. No API-key fallback was used."; if (/expired|invalid_grant|401|invalid_user/.test(message)) return "Your ChatGPT session expired. Reconnect in Providers. No API-key fallback was used.";
if (/background|Background/.test(message)) return "ChatGPT background use is off in this app. Enable Allow background use for all ChatGPT accounts in Providers to run this work. No API-key fallback was used."; if (/background|Background/.test(message)) return "ChatGPT background use is off in this app. Enable Allow background use for all ChatGPT accounts in Providers to run this work. No API-key fallback was used.";
if (/eligible|403/.test(message)) return "This ChatGPT account or workspace does not permit this request. Check ChatGPT settings; reconnecting may not change eligibility."; if (/eligible|403/.test(message)) return "This ChatGPT account or workspace does not permit this request. Check ChatGPT settings; reconnecting may not change eligibility.";
if (/service[_ -]?tier|priority processing|fast mode/i.test(message)) return "Fast mode is unavailable for this ChatGPT model or account. Turn off Fast mode or choose another model.";
if (/cancel|abort|closed|disconnect/i.test(message)) return "ChatGPT request cancelled or connection closed. No API-key fallback was used."; if (/cancel|abort|closed|disconnect/i.test(message)) return "ChatGPT request cancelled or connection closed. No API-key fallback was used.";
if (/catalog|model/.test(message)) return "The selected model is unavailable for this ChatGPT account. Refresh Providers and explicitly choose an available model. No API-key fallback was used."; if (/catalog|model/.test(message)) return "The selected model is unavailable for this ChatGPT account. Refresh Providers and explicitly choose an available model. No API-key fallback was used.";
if (/incomplete|terminal|interrupted|stop reason/.test(message)) return "ChatGPT returned an incomplete response. Retry when ready; no API-key fallback was used."; if (/incomplete|terminal|interrupted|stop reason/.test(message)) return "ChatGPT returned an incomplete response. Retry when ready; no API-key fallback was used.";
@@ -19,9 +20,10 @@ export function chatGPTStreamError(message: string): string {
} }
/** Restrict the body at the final payload seam, even if caller samplingParams add fields. */ /** Restrict the body at the final payload seam, even if caller samplingParams add fields. */
export function chatGPTPayload(raw: unknown, selectedModelId: string): Record<string, unknown> { export function chatGPTPayload(raw: unknown, selectedModelId: string, fastMode = false): Record<string, unknown> {
const payload = raw as Record<string, unknown>; const payload = raw as Record<string, unknown>;
const out: Record<string, unknown> = { model: selectedModelId, store: false, stream: true }; const out: Record<string, unknown> = { model: selectedModelId, store: false, stream: true };
if (fastMode) out.service_tier = "priority";
for (const key of ["instructions", "reasoning", "include", "text", "tool_choice", "parallel_tool_calls", "prompt_cache_key"]) { for (const key of ["instructions", "reasoning", "include", "text", "tool_choice", "parallel_tool_calls", "prompt_cache_key"]) {
if (payload[key] !== undefined) out[key] = payload[key]; if (payload[key] !== undefined) out[key] = payload[key];
} }
@@ -44,7 +46,7 @@ export function chatGPTHistory(context: TranscriptContext, logicalModel: Model<A
function empty(model: Model<Api>): AssistantMessage { function empty(model: Model<Api>): AssistantMessage {
return { role: "assistant", api: model.api, provider: "chatgpt", model: model.id, content: [], stopReason: "error", timestamp: Date.now(), usage: { input: 0, output: 0, cacheRead: 0, cacheWrite: 0, totalTokens: 0, cost: { input: 0, output: 0, cacheRead: 0, cacheWrite: 0, total: 0 } } }; return { role: "assistant", api: model.api, provider: "chatgpt", model: model.id, content: [], stopReason: "error", timestamp: Date.now(), usage: { input: 0, output: 0, cacheRead: 0, cacheWrite: 0, totalTokens: 0, cost: { input: 0, output: 0, cacheRead: 0, cacheWrite: 0, total: 0 } } };
} }
export function chatGPTStream(background: () => boolean, lifecycle?: ChatGPTDispatchLifecycle): StreamFunction<Api, SimpleStreamOptions> { export function chatGPTStream(background: () => boolean, lifecycle?: ChatGPTDispatchLifecycle, fastMode = false): StreamFunction<Api, SimpleStreamOptions> {
return (logicalModel, context, options) => { return (logicalModel, context, options) => {
const output = new AssistantMessageEventStream(); const output = new AssistantMessageEventStream();
const purpose = background(); const purpose = background();
@@ -83,14 +85,18 @@ export function chatGPTStream(background: () => boolean, lifecycle?: ChatGPTDisp
}, },
onPayload: async (raw, model) => { onPayload: async (raw, model) => {
const replacement = await options?.onPayload?.(raw, model); const replacement = await options?.onPayload?.(raw, model);
const payload = chatGPTPayload(replacement === undefined ? raw : replacement, logicalModel.id); const payload = chatGPTPayload(replacement === undefined ? raw : replacement, logicalModel.id, fastMode);
check(); check();
return payload; return payload;
}, },
onProviderStreamEvent: async (raw, model) => { onProviderStreamEvent: async (raw, model) => {
const event = raw as { type?: string; code?: string; error?: { code?: string }; response?: { error?: { code?: string }; status?: string } }; const event = raw as { type?: string; code?: string; param?: string; error?: { code?: string; param?: string }; response?: { error?: { code?: string; param?: string }; status?: string } };
if (event.type === "response.completed" && event.response?.status === "completed" && !event.response?.error) completed = true; if (event.type === "response.completed" && event.response?.status === "completed" && !event.response?.error) completed = true;
if (["response.incomplete", "response.failed", "error"].includes(event.type ?? "") || event.response?.error) failure = event.code || event.error?.code || event.response?.error?.code || "incomplete terminal response"; if (["response.incomplete", "response.failed", "error"].includes(event.type ?? "") || event.response?.error) {
const code = event.code || event.error?.code || event.response?.error?.code || "incomplete terminal response";
failure = [event.param, event.error?.param, event.response?.error?.param].includes("service_tier")
? `${code} service_tier` : code;
}
await options?.onProviderStreamEvent?.(raw, model); await options?.onProviderStreamEvent?.(raw, model);
}, },
}); });
+2 -1
View File
@@ -80,6 +80,7 @@ export interface ResolvedModelSpec {
provider: string; provider: string;
id: string; id: string;
thinkingLevel?: ThinkingLevel; thinkingLevel?: ThinkingLevel;
fastMode?: boolean;
} }
/** The first entry of the baked default order the model supports (owner, /** The first entry of the baked default order the model supports (owner,
@@ -119,7 +120,7 @@ export function piEngineForSpec(spec: ResolvedModelSpec, background: boolean | (
// the pin honest even when config or settings drift off the map. // the pin honest even when config or settings drift off the map.
streamFn: (requestModel, context, options) => { streamFn: (requestModel, context, options) => {
const purpose = typeof background === "function" ? background : () => background; const purpose = typeof background === "function" ? background : () => background;
const stream = spec.provider === "chatgpt" ? chatGPTStream(purpose, lifecycle) : nativeId ? nativeSubscriptionStream(models.getProvider(nativeId)!, purpose, lifecycle) : models.streamSimple.bind(models); const stream = spec.provider === "chatgpt" ? chatGPTStream(purpose, lifecycle, spec.fastMode === true) : nativeId ? nativeSubscriptionStream(models.getProvider(nativeId)!, purpose, lifecycle) : models.streamSimple.bind(models);
return stream(requestModel, context, { ...options, reasoning: level === "off" ? undefined : level }); return stream(requestModel, context, { ...options, reasoning: level === "off" ? undefined : level });
}, },
}; };
+3 -1
View File
@@ -1,3 +1,4 @@
import { learningLanguageInstruction, type Language } from "../../core/i18n.ts";
import { Type } from "typebox"; import { Type } from "typebox";
import type { AgentTool } from "@earendil-works/pi-agent-core"; import type { AgentTool } from "@earendil-works/pi-agent-core";
import type { Usage } from "@earendil-works/pi-ai"; import type { Usage } from "@earendil-works/pi-ai";
@@ -31,6 +32,7 @@ export interface DemoBuilderInput {
} }
export interface DemoBuilderOptions { export interface DemoBuilderOptions {
language?: Language;
/** Test seam: scripted engine; production uses the demoBuilder role. */ /** Test seam: scripted engine; production uses the demoBuilder role. */
engine?: PiEngine; engine?: PiEngine;
headless?: HeadlessApi; headless?: HeadlessApi;
@@ -207,7 +209,7 @@ export async function runDemoBuilder(input: DemoBuilderInput, opts: DemoBuilderO
const result = await runSubagent(Object.values(createDemoBuilderTools(state, headless, opts.onActivity)), { const result = await runSubagent(Object.values(createDemoBuilderTools(state, headless, opts.onActivity)), {
role: "demoBuilder", role: "demoBuilder",
engine: opts.engine, engine: opts.engine,
systemPrompt: INSTRUCTIONS, systemPrompt: `${INSTRUCTIONS}\n\n${learningLanguageInstruction(opts.language ?? "en")}`,
prompt, prompt,
maxTurns: opts.maxSteps ?? 24, maxTurns: opts.maxSteps ?? 24,
abortSignal: opts.abortSignal, abortSignal: opts.abortSignal,
+11 -4
View File
@@ -1,3 +1,4 @@
import { learningLanguageInstruction, type Language } from "../../core/i18n.ts";
import * as crypto from "node:crypto"; import * as crypto from "node:crypto";
import type { Usage } from "@earendil-works/pi-ai"; import type { Usage } from "@earendil-works/pi-ai";
import type { AgentTool } from "@earendil-works/pi-agent-core"; import type { AgentTool } from "@earendil-works/pi-agent-core";
@@ -471,6 +472,7 @@ function runExtractor(
batchIds: ReadonlySet<string>, batchIds: ReadonlySet<string>,
signal: AbortSignal | undefined, signal: AbortSignal | undefined,
onStep: (usage: Usage, model: string, step: number, finishReason: string, tools: string) => void, onStep: (usage: Usage, model: string, step: number, finishReason: string, tools: string) => void,
language: Language = "en",
): Promise<SessionFindings> { ): Promise<SessionFindings> {
// Chains in (the wire), chains out (the store) — no translation seam // Chains in (the wire), chains out (the store) — no translation seam
// since chains-primary. Validation runs at the submit seam (error-back) // since chains-primary. Validation runs at the submit seam (error-back)
@@ -486,7 +488,7 @@ function runExtractor(
parameters: sessionFindingsSchema, parameters: sessionFindingsSchema,
fill: fillFindings, fill: fillFindings,
validate: (value) => validateSessionAnchors(value, batchIds) ?? validateFindingsChains(value, existingIds), validate: (value) => validateSessionAnchors(value, batchIds) ?? validateFindingsChains(value, existingIds),
systemPrompt: EXTRACTOR_INSTRUCTIONS, systemPrompt: `${EXTRACTOR_INSTRUCTIONS}\n\n${learningLanguageInstruction(language)}`,
prompt, prompt,
engine, engine,
signal, signal,
@@ -507,6 +509,7 @@ function runNotesExtractor(
existingIds: ReadonlySet<string>, existingIds: ReadonlySet<string>,
signal: AbortSignal | undefined, signal: AbortSignal | undefined,
onStep: (usage: Usage, model: string, step: number, finishReason: string, tools: string) => void, onStep: (usage: Usage, model: string, step: number, finishReason: string, tools: string) => void,
language: Language = "en",
): Promise<NotesFindings> { ): Promise<NotesFindings> {
return runSubmitAgent<FindingsSubmit>({ return runSubmitAgent<FindingsSubmit>({
role: "extractor", role: "extractor",
@@ -517,7 +520,7 @@ function runNotesExtractor(
parameters: findingsSchema, parameters: findingsSchema,
fill: fillNotesFindings, fill: fillNotesFindings,
validate: (value) => validateFindingsChains(value, existingIds), validate: (value) => validateFindingsChains(value, existingIds),
systemPrompt: NOTES_EXTRACTOR_INSTRUCTIONS, systemPrompt: `${NOTES_EXTRACTOR_INSTRUCTIONS}\n\n${learningLanguageInstruction(language)}`,
prompt, prompt,
engine, engine,
signal, signal,
@@ -725,6 +728,7 @@ export interface ExtractionModels {
} }
export interface ExtractionPipelineDeps extends GraphDeps, ExtractionModels { export interface ExtractionPipelineDeps extends GraphDeps, ExtractionModels {
languageForSource?: (sourceId: string) => Language;
/** The chunk budget — the transcript-token share of the extractor's /** The chunk budget — the transcript-token share of the extractor's
* context; batches fill to it (the trigger fires on the same number, * context; batches fill to it (the trigger fires on the same number,
* read fresh per turn by the composition root). OPTIONAL with the * read fresh per turn by the composition root). OPTIONAL with the
@@ -735,6 +739,7 @@ export interface ExtractionPipelineDeps extends GraphDeps, ExtractionModels {
} }
export interface NotesImportInput { export interface NotesImportInput {
language?: Language;
/** The source's file path — its identity (deterministic src id) AND its /** The source's file path — its identity (deterministic src id) AND its
* provenance label in the detail card. */ * provenance label in the detail card. */
filePath: string; filePath: string;
@@ -940,6 +945,7 @@ export function createExtractionPipeline(deps: ExtractionPipelineDeps): Extracti
recordStep(jobKey, "extractor", callUsage(usage), model); recordStep(jobKey, "extractor", callUsage(usage), model);
console.info(`[extraction] extractor step ${step}: ${finishReason}, tools: ${tools}`); console.info(`[extraction] extractor step ${step}: ${finishReason}, tools: ${tools}`);
}, },
deps.languageForSource?.(sourceId) ?? chatStore.getChat(sourceId)?.language ?? "en",
); );
console.info(`[extraction ${jobKey}] extractor done in ${Math.round(performance.now() - t0)}ms`); console.info(`[extraction ${jobKey}] extractor done in ${Math.round(performance.now() - t0)}ms`);
return findings; return findings;
@@ -1262,7 +1268,7 @@ export function createExtractionPipeline(deps: ExtractionPipelineDeps): Extracti
async function notesExtract( async function notesExtract(
jobKey: string, jobKey: string,
input: { id: string; filePath: string; text: string; part?: { i: number; n: number } }, input: { id: string; filePath: string; text: string; language?: Language; part?: { i: number; n: number } },
): Promise<NotesFindings> { ): Promise<NotesFindings> {
// jobKey (per-chunk jobs, owner 2026-09-28): the registry key — // jobKey (per-chunk jobs, owner 2026-09-28): the registry key —
// "src#i" for chunk jobs — so the abort signal reaches the call. // "src#i" for chunk jobs — so the abort signal reaches the call.
@@ -1290,6 +1296,7 @@ export function createExtractionPipeline(deps: ExtractionPipelineDeps): Extracti
recordStep(jobKey, "extractor", callUsage(usage), model); recordStep(jobKey, "extractor", callUsage(usage), model);
console.info(`[extraction] extractor step ${step}: ${finishReason}, tools: ${tools}`); console.info(`[extraction] extractor step ${step}: ${finishReason}, tools: ${tools}`);
}, },
input.language ?? deps.languageForSource?.(input.id) ?? "en",
); );
console.info(`[extraction ${jobKey}] notes extractor done in ${Math.round(performance.now() - t0)}ms`); console.info(`[extraction ${jobKey}] notes extractor done in ${Math.round(performance.now() - t0)}ms`);
return findings; return findings;
@@ -1298,7 +1305,7 @@ export function createExtractionPipeline(deps: ExtractionPipelineDeps): Extracti
// The notes auditor is the SAME identity-linker as the chat one (one // The notes auditor is the SAME identity-linker as the chat one (one
// schema now that attribution is gone) — audit() above, verbatim. // schema now that attribution is gone) — audit() above, verbatim.
async function runNotesExtraction(input: { id: string; filePath: string; text: string; importedAt: string }): Promise<EmissionStats> { async function runNotesExtraction(input: { id: string; filePath: string; text: string; importedAt: string; language?: Language }): Promise<EmissionStats> {
const { id } = input; const { id } = input;
// importNotes owns the source exclusion, including metadata and cleanup. // importNotes owns the source exclusion, including metadata and cleanup.
const t0 = performance.now(); const t0 = performance.now();
+3 -1
View File
@@ -1,3 +1,4 @@
import { learningLanguageInstruction, type Language } from "../../core/i18n.ts";
import { randomUUID } from "node:crypto"; import { randomUUID } from "node:crypto";
import type { AssistantMessage, Message, ToolResultMessage, Usage, Tool } from "@earendil-works/pi-ai"; import type { AssistantMessage, Message, ToolResultMessage, Usage, Tool } from "@earendil-works/pi-ai";
import { normalizeContext } from "@earendil-works/pi-ai"; import { normalizeContext } from "@earendil-works/pi-ai";
@@ -62,6 +63,7 @@ const RENDER_BATCH_CAP = 6;
const EXTRACT_CAP = 24_000; const EXTRACT_CAP = 24_000;
export interface PdfReaderOptions { export interface PdfReaderOptions {
language?: Language;
engine?: PiEngine; engine?: PiEngine;
abortSignal?: AbortSignal; abortSignal?: AbortSignal;
onUsage?: (usage: Usage) => void; onUsage?: (usage: Usage) => void;
@@ -206,7 +208,7 @@ export async function runPdfReaderAgent(
for (let iteration = 0; iteration < maxIterations; iteration++) { for (let iteration = 0; iteration < maxIterations; iteration++) {
const result = await streamOnce( const result = await streamOnce(
engine, engine,
INSTRUCTIONS, `${INSTRUCTIONS}\n\n${learningLanguageInstruction(opts.language ?? "en")}`,
messages, messages,
toolDeclarations, toolDeclarations,
sessionId, sessionId,
+3 -1
View File
@@ -1,3 +1,4 @@
import { learningLanguageInstruction, type Language } from "../../core/i18n.ts";
import type { AgentTool } from "@earendil-works/pi-agent-core"; import type { AgentTool } from "@earendil-works/pi-agent-core";
import type { Usage } from "@earendil-works/pi-ai"; import type { Usage } from "@earendil-works/pi-ai";
import { createWebTools } from "../tools/web.ts"; import { createWebTools } from "../tools/web.ts";
@@ -68,6 +69,7 @@ Numbered findings with inline source citations:
What couldn't be answered. Suggested next steps.`; What couldn't be answered. Suggested next steps.`;
export interface ResearcherRunOptions { export interface ResearcherRunOptions {
language?: Language;
engine?: PiEngine; engine?: PiEngine;
/** The search seam (search port wave, 2026-09-20): resolves the LIVE /** The search seam (search port wave, 2026-09-20): resolves the LIVE
* provider per call, null = honestly unavailable (the web_search tool * provider per call, null = honestly unavailable (the web_search tool
@@ -98,7 +100,7 @@ export async function runResearcherAgent(prompt: string, opts: ResearcherRunOpti
role: "researcher", role: "researcher",
prompt, prompt,
engine: opts.engine, engine: opts.engine,
systemPrompt: INSTRUCTIONS, systemPrompt: `${INSTRUCTIONS}\n\n${learningLanguageInstruction(opts.language ?? "en")}`,
maxTurns: 12, maxTurns: 12,
abortSignal: opts.abortSignal, abortSignal: opts.abortSignal,
onUsage: opts.onUsage, onUsage: opts.onUsage,
+6
View File
@@ -1,3 +1,4 @@
import type { Language } from "../../core/i18n.ts";
import type { PiEngine } from "../models.ts"; import type { PiEngine } from "../models.ts";
import type { SearchProvider } from "../../core/search.ts"; import type { SearchProvider } from "../../core/search.ts";
import { import {
@@ -64,6 +65,7 @@ export const pdfModelOverride: { current?: PiEngine } = {};
type JobToolInput = ResearchToolInput | DemoToolInput | VisualToolInput | PdfToolInput; type JobToolInput = ResearchToolInput | DemoToolInput | VisualToolInput | PdfToolInput;
export interface JobRunnerDeps { export interface JobRunnerDeps {
languageForChat?: (chatId: string) => Language;
chatStore: ChatStore; chatStore: ChatStore;
jobStore: JobStore; jobStore: JobStore;
/** /**
@@ -364,6 +366,7 @@ export function createJobRunner(deps: JobRunnerDeps): JobRunner {
jobStore.updateJob(jobId, { activity: "researching" }); jobStore.updateJob(jobId, { activity: "researching" });
const brief = await runResearcherAgent(prompt, { const brief = await runResearcherAgent(prompt, {
engine: model, engine: model,
language: deps.languageForChat?.(job.chatId) ?? chatStore.getChat(job.chatId)?.language ?? "en",
search: deps.searchProvider, search: deps.searchProvider,
pdfDir, pdfDir,
abortSignal: loop.abortSignal, abortSignal: loop.abortSignal,
@@ -417,6 +420,7 @@ export function createJobRunner(deps: JobRunnerDeps): JobRunner {
{ title, brief }, { title, brief },
{ {
engine: model, engine: model,
language: deps.languageForChat?.(job.chatId) ?? chatStore.getChat(job.chatId)?.language ?? "en",
abortSignal: loop.abortSignal, abortSignal: loop.abortSignal,
chatId: loop.chatId, chatId: loop.chatId,
onUsage: (usage) => loop.onUsage(piUsageToStep(usage)), onUsage: (usage) => loop.onUsage(piUsageToStep(usage)),
@@ -453,6 +457,7 @@ export function createJobRunner(deps: JobRunnerDeps): JobRunner {
{ brief }, { brief },
{ {
engine: model, engine: model,
language: deps.languageForChat?.(job.chatId) ?? chatStore.getChat(job.chatId)?.language ?? "en",
abortSignal: loop.abortSignal, abortSignal: loop.abortSignal,
chatId: loop.chatId, chatId: loop.chatId,
onUsage: (usage) => loop.onUsage(piUsageToStep(usage)), onUsage: (usage) => loop.onUsage(piUsageToStep(usage)),
@@ -492,6 +497,7 @@ export function createJobRunner(deps: JobRunnerDeps): JobRunner {
{ path, question }, { path, question },
{ {
engine: model, engine: model,
language: deps.languageForChat?.(job.chatId) ?? chatStore.getChat(job.chatId)?.language ?? "en",
abortSignal: loop.abortSignal, abortSignal: loop.abortSignal,
chatId: loop.chatId, chatId: loop.chatId,
onUsage: (usage) => loop.onUsage(piUsageToStep(usage)), onUsage: (usage) => loop.onUsage(piUsageToStep(usage)),
+3 -1
View File
@@ -1,3 +1,4 @@
import { learningLanguageInstruction, type Language } from "../../core/i18n.ts";
import { randomUUID } from "node:crypto"; import { randomUUID } from "node:crypto";
import type { AssistantMessage, Message, Tool, ToolResultMessage, Usage } from "@earendil-works/pi-ai"; import type { AssistantMessage, Message, Tool, ToolResultMessage, Usage } from "@earendil-works/pi-ai";
import { normalizeContext } from "@earendil-works/pi-ai"; import { normalizeContext } from "@earendil-works/pi-ai";
@@ -35,6 +36,7 @@ export interface VisualMakerInput {
} }
export interface VisualMakerOptions { export interface VisualMakerOptions {
language?: Language;
/** Test seam: scripted engine; production uses the visualMaker role. */ /** Test seam: scripted engine; production uses the visualMaker role. */
engine?: PiEngine; engine?: PiEngine;
headless?: HeadlessApi; headless?: HeadlessApi;
@@ -163,7 +165,7 @@ export async function runVisualMaker(input: VisualMakerInput, opts: VisualMakerO
// loop owns tool execution and the image-in-the-loop injection). // loop owns tool execution and the image-in-the-loop injection).
const result = await streamOnce( const result = await streamOnce(
engine, engine,
INSTRUCTIONS, `${INSTRUCTIONS}\n\n${learningLanguageInstruction(opts.language ?? "en")}`,
messages, messages,
toolDeclarations, toolDeclarations,
sessionId, sessionId,
+4 -1
View File
@@ -1,3 +1,4 @@
import { learningLanguageInstruction, type Language } from "../core/i18n.ts";
import { Agent } from "@earendil-works/pi-agent-core"; import { Agent } from "@earendil-works/pi-agent-core";
import type { AgentEvent, AgentMessage, AgentTool } from "@earendil-works/pi-agent-core"; import type { AgentEvent, AgentMessage, AgentTool } from "@earendil-works/pi-agent-core";
import { invalidInputResult, questionResult } from "./tool-execute.ts"; import { invalidInputResult, questionResult } from "./tool-execute.ts";
@@ -97,6 +98,7 @@ export type StepUsageCollector = (usage: Usage) => void;
/** Per-request construction options (the handler wires all of these). */ /** Per-request construction options (the handler wires all of these). */
export interface AgentBuildOptions { export interface AgentBuildOptions {
language?: Language;
/** Model + stream function — tests inject a scripted faux engine. */ /** Model + stream function — tests inject a scripted faux engine. */
engine?: PiEngine; engine?: PiEngine;
callPurpose?: { background: boolean }; callPurpose?: { background: boolean };
@@ -175,9 +177,10 @@ function baseAgentOptions(kind: AgentKind, opts: AgentBuildOptions, tools: Agent
// ("practice" is not a ModelRole) honest about that. // ("practice" is not a ModelRole) honest about that.
const engine = opts.engine ?? piEngineFor(kind as "prober" | "teacher"); const engine = opts.engine ?? piEngineFor(kind as "prober" | "teacher");
const stopper = makeQuestionStopper(); const stopper = makeQuestionStopper();
const systemPrompt = opts.promptSwitches const basePrompt = opts.promptSwitches
? composeInstructions(kind, opts.promptSwitches) ? composeInstructions(kind, opts.promptSwitches)
: composedInstructions[kind]!; : composedInstructions[kind]!;
const systemPrompt = `${basePrompt}\n\n${learningLanguageInstruction(opts.language ?? "en")}`;
// Debug recorder (owner, 2026-10-04): raw provider events when // Debug recorder (owner, 2026-10-04): raw provider events when
// LH_PROVIDER_EVENTS=1 and the caller handed a chatId; undefined is // LH_PROVIDER_EVENTS=1 and the caller handed a chatId; undefined is
// the normal no-observer path (tests, env-off). // the normal no-observer path (tests, env-off).
+2
View File
@@ -28,6 +28,8 @@ import type { EmissionStats } from "./graph.ts";
export type ChatKind = "teach" | "practice"; export type ChatKind = "teach" | "practice";
export interface ChatRecord { export interface ChatRecord {
/** Effective language from the latest foreground turn, retained for automatic follow-ups. */
language?: "en" | "ru";
id: string; id: string;
title: string; title: string;
createdAt: string; createdAt: string;
+77
View File
@@ -0,0 +1,77 @@
/** Pure localization shared by browser, server, exports, and agent configuration. */
import en from "./locales/en.json";
import ru from "./locales/ru.json";
export type Language = "en" | "ru";
export type SttLanguage = Language | "multi";
export type TranslationParams = Record<string, string | number | null | undefined>;
export type Translator = (message: string | null | undefined, params?: TranslationParams) => string;
export function isLanguage(value: unknown): value is Language { return value === "en" || value === "ru"; }
export function isSttLanguage(value: unknown): value is SttLanguage { return isLanguage(value) || value === "multi"; }
/** The preferred system language wins; unsupported preferences resolve to English. */
export function systemLanguage(languages: string | readonly string[] | null | undefined): Language {
const first = typeof languages === "string"
? languages.split(",").map((part, index) => {
const [tag = "", ...options] = part.trim().split(";");
const q = options.find((option) => option.trim().startsWith("q="));
return { tag, q: q ? Number(q.trim().slice(2)) : 1, index };
}).filter((item) => Number.isFinite(item.q) && item.q > 0).sort((a, b) => b.q - a.q || a.index - b.index)[0]?.tag
: languages?.[0];
return /^ru(?:-|_|$)/i.test(first ?? "") ? "ru" : "en";
}
export function resolveLanguage(preference: unknown, system?: string | readonly string[] | null): Language {
return isLanguage(preference) ? preference : systemLanguage(system);
}
export function resolveSttLanguage(preference: unknown, system?: string | readonly string[] | null): SttLanguage {
return isSttLanguage(preference) ? preference : systemLanguage(system);
}
export function localeTag(language: Language): string { return language === "ru" ? "ru-RU" : "en-US"; }
type Entry = string | Partial<Record<Intl.LDMLPluralRule, string>>;
const catalog: Record<string, Entry> = ru;
// Some protocol errors arrive as preformatted English. Match only cataloged
// app-authored templates; arbitrary provider diagnostics remain untouched.
const canonicalTemplates = Object.keys(catalog).filter((key) => /\{v\d+\}/.test(key) && key.replace(/\{\w+\}/g, "").replace(/[^a-zA-Z]/g, "").length >= 8).map((key) => {
const names: string[] = [];
const pattern = key.split(/(\{v\d+\})/g).map((part) => {
if (/^\{v\d+\}$/.test(part)) { names.push(part.slice(1, -1)); return "([\\s\\S]*?)"; }
return part.replace(/[.*+?^${}()|[\]\\]/g, "\\$&");
}).join("");
return { key, names, regex: new RegExp(`^${pattern}$`) };
});
export function translate(language: Language, message: string | null | undefined, params: TranslationParams = {}): string {
if (message == null) return "";
if (language === "ru" && message.startsWith("{")) {
try { const body = JSON.parse(message) as { error?: unknown }; if (typeof body.error === "string") return translate(language, body.error, params); } catch {}
}
const entry = language === "ru" ? catalog[message] : (en as Record<string, Entry>)[message];
if (language === "ru" && entry === undefined && Object.keys(params).length === 0) {
for (const template of canonicalTemplates) {
const match = template.regex.exec(message);
if (match) return translate(language, template.key, Object.fromEntries(template.names.map((name, index) => [name, match[index + 1] ?? ""])));
}
}
const text = typeof entry === "string" ? entry : entry
? entry[new Intl.PluralRules(localeTag(language)).select(Number(params.count ?? 0))] ?? entry.other ?? message
: message;
return text.replace(/\{(\w+)\}/g, (match, key: string) => Object.hasOwn(params, key) ? String(params[key] ?? "") : match);
}
export function translator(language: Language): Translator { return (message, params) => translate(language, message, params); }
export function learningLanguageInstruction(language: Language): string {
return language === "ru"
? "LANGUAGE: Use Russian for all learner-facing content: teaching, questions and answer choices, plans and titles, practice statements, hints, verdicts and solutions, research summaries, knowledge-graph propositions, and all text in generated diagrams, figures and interactive demos. Use natural Russian and address the learner as вы. Preserve code, mathematical notation, tool names, schema keys, identifiers, proper names and quoted source text. Follow an explicit learner request for a different language or a language-learning exercise when necessary."
: "LANGUAGE: Use English for all learner-facing content, including teaching, questions and choices, plans, titles, practice, research summaries, knowledge-graph propositions, and text in generated diagrams, figures and demos. Preserve code, notation, identifiers, proper names and quoted source text. Follow an explicit learner request for a different language or a language-learning exercise when necessary.";
}
/** Streaming configuration: the language is pinned once at recording start, including reconnects. */
export function dictationUrl(language: SttLanguage, preferredLanguages: readonly Language[] = []): string {
const params = new URLSearchParams({ model: "nova-3", language, encoding: "linear16", sample_rate: "16000", channels: "1", smart_format: "true", punctuate: "true", endpointing: "300" });
if (language === "multi") {
for (const preferred of new Set(preferredLanguages)) if (isLanguage(preferred)) params.append("language_hint", preferred);
}
return `wss://api.deepgram.com/v1/listen?${params}`;
}
/** Count labels with natural Russian inflections; English keeps its original singular/plural wording. */
export function countLabel(language: Language, count: number, singular: string, plural: string): string {
return translate(language, `{count} ${language === "ru" ? plural : count === 1 ? singular : plural}`, { count });
}
+3
View File
@@ -79,3 +79,6 @@ export * from "./coverage.ts";
// ONE session→markdown formatter — the extractor's view and the user-facing // ONE session→markdown formatter — the extractor's view and the user-facing
// export share it. Pure, browser-safe. // export share it. Pure, browser-safe.
export * from "./session-markdown.ts"; export * from "./session-markdown.ts";
export * from "./i18n.ts";
export * from "./stt.ts";
+870
View File
@@ -0,0 +1,870 @@
{
"Abort this turn?": "Abort this turn?",
"The partial answer stays in the transcript.": "The partial answer stays in the transcript.",
"Abort": "Abort",
"Dismiss this question?": "Dismiss this question?",
"Skipped — the teacher moves on without it.": "Skipped — the teacher moves on without it.",
"Dismiss": "Dismiss",
"Discard this problem?": "Discard this problem?",
"The problem is dropped — no solution is revealed.": "The problem is dropped — no solution is revealed.",
"Discard": "Discard",
" The working turn stops too.": " The working turn stops too.",
"Cancel": "Cancel",
"needs model setup": "needs model setup",
"needs a provider key": "needs a provider key",
"{v0}s": "{v0}s",
"{v0}m {v1}s": "{v0}m {v1}s",
"{v0}h {v1}m": "{v0}h {v1}m",
"Background agents": "Background agents",
"Total running cost (inferred)": "Total running cost (inferred)",
"Extraction — {v0}": "Extraction — {v0}",
"Researcher": "Researcher",
"Demo builder": "Demo builder",
"Figure maker": "Figure maker",
"PDF reader": "PDF reader",
"Sub-agent": "Sub-agent",
"Abort failed — try again": "Abort failed — try again",
"End this background process": "End this background process",
"End {v0} in {v1}": "End {v0} in {v1}",
"Cost": "Cost",
"Context": "Context",
"Model": "Model",
"Steps": "Steps",
"Open session →": "Open session →",
"No messages yet": "No messages yet",
"Start a conversation to see messages here": "Start a conversation to see messages here",
"Previous branch": "Previous branch",
"Next branch": "Next branch",
"Thinking...": "Thinking...",
"AGENT’S THINKING": "AGENT’S THINKING",
"Thinking": "Thinking",
"— skipped": "— skipped",
"YOUR THINKING": "YOUR THINKING",
"Probe": "Probe",
"Recall": "Recall",
"Prediction": "Prediction",
"Check": "Check",
"Explain": "Explain",
"Question": "Question",
"didn't know": "didn't know",
"Answered “I don't know”.": "Answered “I don't know”.",
"Hide why": "Hide why",
"Why": "Why",
"Why:": "Why:",
"The answer:": "The answer:",
"unknown error": "unknown error",
"Demo completed, but the result could not be displayed.": "Demo completed, but the result could not be displayed.",
"Interactive demo": "Interactive demo",
"interactive demo": "interactive demo",
"Invert demo brightness": "Invert demo brightness",
"Invert colors": "Invert colors",
"Download demo as HTML": "Download demo as HTML",
"Download as HTML": "Download as HTML",
"The demo \"{v0}\" threw an error: {v1}": "The demo \"{v0}\" threw an error: {v1}",
"This demo threw an error": "This demo threw an error",
"Send to teacher": "Send to teacher",
"Research agent": "Research agent",
"Demo agent": "Demo agent",
"Figure agent": "Figure agent",
"Set up models": "Set up models",
"Add a key": "Add a key",
"{v0} running": "{v0} running",
"Dispatched…": "Dispatched…",
"PDF reading completed, but the result could not be displayed.": "PDF reading completed, but the result could not be displayed.",
"PDF failed": "PDF failed",
"PDF brief": "PDF brief",
"Pages read:": "Pages read:",
"Pages consulted:": "Pages consulted:",
"Research completed, but the result could not be displayed.": "Research completed, but the result could not be displayed.",
"Research failed": "Research failed",
"Research brief": "Research brief",
"Sources": "Sources",
"{v0} — live": "{v0} — live",
"Input (streaming)": "Input (streaming)",
"Probe concluded — your assessment": "Probe concluded — your assessment",
"The prober retried:": "The prober retried:",
"The assessment was malformed — the prober retried with a corrected one.": "The assessment was malformed — the prober retried with a corrected one.",
"Writing the assessment…": "Writing the assessment…",
"A sub-agent finished, but its result could not be displayed.": "A sub-agent finished, but its result could not be displayed.",
"aborted by the learner": "aborted by the learner",
"reading complete": "reading complete",
"· read": "· read",
"view brief": "view brief",
"hide brief": "hide brief",
"Research": "Research",
"{v0}\n[{v1} image{v2} attached to the model]": "{v0}\n[{v1} image{v2} attached to the model]",
"Input": "Input",
"Error": "Error",
"Output": "Output",
"running…": "running…",
"Figure completed, but the result could not be displayed.": "Figure completed, but the result could not be displayed.",
"Figure": "Figure",
"Invert figure brightness": "Invert figure brightness",
"Download figure as SVG": "Download figure as SVG",
"Download as SVG": "Download as SVG",
"Open figure fullscreen": "Open figure fullscreen",
"Open fullscreen": "Open fullscreen",
"Working": "Working",
"{v0}ms": "{v0}ms",
"Search nodes…": "Search nodes…",
"Search nodes": "Search nodes",
"Close chains": "Close chains",
"Name": "Name",
"Props": "Props",
"Created": "Created",
"No matching chains.": "No matching chains.",
"Select everything born in {v0}": "Select everything born in {v0}",
"Expand cluster {v0}": "Expand cluster {v0}",
"Select chain {v0}": "Select chain {v0}",
"Expand chain {v0}": "Expand chain {v0}",
"Select node {v0}": "Select node {v0}",
"Clear": "Clear",
"Done picking — {v0} {v1} selected": "Done picking — {v0} {v1} selected",
"Done": "Done",
"Delete {v0} selected {v1}": "Delete {v0} selected {v1}",
"Delete": "Delete",
"rename failed: {v0}": "rename failed: {v0}",
"rename failed:": "rename failed:",
"Session title": "Session title",
"Rename this session": "Rename this session",
"Rename session": "Rename session",
"Open the full plan": "Open the full plan",
"Plan": "Plan",
"Practice": "Practice",
"New session": "New session",
"The teacher will open by probing what you already know.": "The teacher will open by probing what you already know.",
"Next problem": "Next problem",
"You": "You",
"Retrying…": "Retrying…",
"Compacting context": "Compacting context",
"COMPACTING CONTEXT…": "COMPACTING CONTEXT…",
"THINKING…": "THINKING…",
"Something went wrong:": "Something went wrong:",
"Set up your models": "Set up your models",
"Ask anything, or press Enter for the next one.": "Ask anything, or press Enter for the next one.",
"Context compacted": "Context compacted",
" · {v0}k tok": " · {v0}k tok",
"History compacted": "History compacted",
"{v0} {v1} selected": "{v0} {v1} selected",
"Pick nodes": "Pick nodes",
"Ask, answer, or steer the lesson…": "Ask, answer, or steer the lesson…",
"Stop": "Stop",
"Send": "Send",
"An extraction is running — try again when the activity dock drains.": "An extraction is running — try again when the activity dock drains.",
"Delete node": "Delete node",
"Delete node?": "Delete node?",
"Delete {v0} nodes?": "Delete {v0} nodes?",
", and {v0} more": ", and {v0} more",
"This permanently deletes the node — this cannot be undone.": "This permanently deletes the node — this cannot be undone.",
"This permanently deletes all {v0} nodes — this cannot be undone.": "This permanently deletes all {v0} nodes — this cannot be undone.",
"Try again": "Try again",
"Deleting…": "Deleting…",
"Delete failed — network error. Try again.": "Delete failed — network error. Try again.",
"Also remove its graph traces": "Also remove its graph traces",
"This session’s topics, links, and events leave the graph too. Topics other sessions or imported notes also cover survive; topics only this session created are removed entirely.": "This session’s topics, links, and events leave the graph too. Topics other sessions or imported notes also cover survive; topics only this session created are removed entirely.",
"Delete session": "Delete session",
"Delete session?": "Delete session?",
"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.": "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.",
"Dictation needs a Deepgram key": "Dictation needs a Deepgram key",
"Deepgram reconnect failed": "Deepgram reconnect failed",
"Deepgram connection lost ({v0})": "Deepgram connection lost ({v0})",
"Microphone permission denied": "Microphone permission denied",
"mic unavailable": "mic unavailable",
"token unavailable": "token unavailable",
"Dictation failed: {v0}": "Dictation failed: {v0}",
"Start dictation": "Start dictation",
"Stop dictation (Esc)": "Stop dictation (Esc)",
"Stop dictation": "Stop dictation",
"extracting…": "extracting…",
"fully extracted": "fully extracted",
"tok uncovered": "tok uncovered",
"Running": "Running",
"Extract": "Extract",
"How much of this session isn't mapped yet": "How much of this session isn't mapped yet",
"Extraction coverage": "Extraction coverage",
"Uncovered": "Uncovered",
"Covered": "Covered",
"Messages": "Messages",
"Chunk threshold": "Chunk threshold",
"Last extraction": "Last extraction",
"Status": "Status",
"Graph context": "Graph context",
"{v0}k": "{v0}k",
"{v0}M": "{v0}M",
"{v0}m": "{v0}m",
"{v0}h": "{v0}h",
"{v0}d": "{v0}d",
"Graph costs — click for details": "Graph costs — click for details",
"Extraction": "Extraction",
"Audit": "Audit",
"Graph pipeline costs": "Graph pipeline costs",
"in /": "in /",
" +1 unpriced": " +1 unpriced",
"Graph display settings": "Graph display settings",
"Node size": "Node size",
"Edge width": "Edge width",
"Arrowhead size": "Arrowhead size",
"Label size": "Label size",
"Labels until zoom": "Labels until zoom",
"Regions": "Regions",
"Background colors": "Background colors",
"Dilation": "Dilation",
"Blur": "Blur",
"Edge alpha": "Edge alpha",
"Bitmap resolution": "Bitmap resolution",
"Cluster labels": "Cluster labels",
"Cluster label size": "Cluster label size",
"Physics": "Physics",
"Node repulsion": "Node repulsion",
"Edge attraction": "Edge attraction",
"Edge length": "Edge length",
"Central pull": "Central pull",
"Cluster pull": "Cluster pull",
"Friction": "Friction",
"Depth anchor": "Depth anchor",
"Off": "Off",
"Vertical (up)": "Vertical (up)",
"Radial (outward)": "Radial (outward)",
"Depth strength": "Depth strength",
"Depth spacing": "Depth spacing",
"Base ring radius": "Base ring radius",
"No matching nodes.": "No matching nodes.",
"Every reasoning chain": "Every reasoning chain",
"Chains": "Chains",
"Import notes into the graph": "Import notes into the graph",
"Importing…": "Importing…",
"Import notes…": "Import notes…",
"Knowledge graph": "Knowledge graph",
"Start a session or import notes to build your graph.": "Start a session or import notes to build your graph.",
"Sessions": "Sessions",
"No sessions recorded.": "No sessions recorded.",
"What do you want to practice?": "What do you want to practice?",
"What do you want to learn?": "What do you want to learn?",
"Start session": "Start session",
"Teach": "Teach",
"A teacher agent with a memory — sessions, quizzes, and a knowledge graph of what you know.": "A teacher agent with a memory — sessions, quizzes, and a knowledge graph of what you know.",
"Could not load language settings": "Could not load language settings",
"Could not save language settings": "Could not save language settings",
"Interface language": "Interface language",
"Speech recognition language": "Speech recognition language",
"Auto": "Auto",
"System default": "System default",
"Multilingual": "Multilingual",
"Multi": "Multi",
"Preferred languages": "Preferred languages",
"Could not save preferred languages": "Could not save preferred languages",
"sttPreferredLanguages must be an array of \"en\" and \"ru\"": "sttPreferredLanguages must be an array of \"en\" and \"ru\"",
"Global": "Global",
"Chat": "Chat",
"Graph view": "Graph view",
"Keyboard shortcuts": "Keyboard shortcuts",
"Esc to close": "Esc to close",
"Copy diagram source": "Copy diagram source",
"Open full-screen": "Open full-screen",
"Expand diagram": "Expand diagram",
"Copy code": "Copy code",
"Copy table": "Copy table",
"Copy equation source": "Copy equation source",
"Diagram failed to render": "Diagram failed to render",
"Show diagram source": "Show diagram source",
"Retry": "Retry",
"teaching session": "teaching session",
"teaching sessions": "teaching sessions",
"practice session": "practice session",
"practice sessions": "practice sessions",
"imported note": "imported note",
"imported notes": "imported notes",
"Nothing here yet — import notes to begin building the graph.": "Nothing here yet — import notes to begin building the graph.",
"catalog request failed: {v0}": "catalog request failed: {v0}",
"Could not reach the server — try again.": "Could not reach the server — try again.",
"Model selector": "Model selector",
"No models — add a provider key in Keys": "No models — add a provider key in Keys",
"This session's model": "This session's model",
"Strong tier — the default for new sessions": "Strong tier — the default for new sessions",
"{v0} has no thinking levels.": "{v0} has no thinking levels.",
"Pick a model first.": "Pick a model first.",
"Loading…": "Loading…",
"Levels this model actually supports": "Levels this model actually supports",
"New practice session": "New practice session",
"Pick nodes from your knowledge graph, describe what you want to practice, or bring your own problem.": "Pick nodes from your knowledge graph, describe what you want to practice, or bring your own problem.",
"Invalid import response. Please retry the file.": "Invalid import response. Please retry the file.",
"Import request failed (HTTP {v0}){v1}. Please retry the file.": "Import request failed (HTTP {v0}){v1}. Please retry the file.",
"Probing": "Probing",
"Planning": "Planning",
"Teaching": "Teaching",
"Plan · {v0} node{v1}": "Plan · {v0} node{v1}",
"Plan diagram": "Plan diagram",
"The full plan": "The full plan",
"The plan": "The plan",
"live — statuses derive from your check outcomes": "live — statuses derive from your check outcomes",
"Close": "Close",
"Drag to pan · scroll to zoom": "Drag to pan · scroll to zoom",
"Zoom out": "Zoom out",
"Fit plan to screen": "Fit plan to screen",
"Zoom in": "Zoom in",
"No plan in this session yet.": "No plan in this session yet.",
"Problem": "Problem",
"Solved": "Solved",
"Solved with hints": "Solved with hints",
"Moved on": "Moved on",
"Surrendered": "Surrendered",
"Dismissed": "Dismissed",
"Problem dismissed.": "Problem dismissed.",
"Concluded": "Concluded",
"Model solution": "Model solution",
"Metadata": "Metadata",
"Mode": "Mode",
"Targets": "Targets",
"Outcome": "Outcome",
"Hint rung": "Hint rung",
"Miss class": "Miss class",
"Retest": "Retest",
"Work here, think out loud.": "Work here, think out loud.",
"compacting session…": "compacting session…",
"↵ submits · ⇧↵ newline": "↵ submits · ⇧↵ newline",
"Submit thinking": "Submit thinking",
"This question arrived malformed — the transcript keeps the retry.": "This question arrived malformed — the transcript keeps the retry.",
"Check your understanding": "Check your understanding",
"Open": "Open",
"Answer": "Answer",
"I don't know": "I don't know",
"Type your own answer…": "Type your own answer…",
"Confidence": "Confidence",
"Confidence — the probability your selected answer is correct": "Confidence — the probability your selected answer is correct",
"{v0} percent": "{v0} percent",
"The probability your selected answer is correct — 50% is a coin flip. Adjust with ←/→.": "The probability your selected answer is correct — 50% is a coin flip. Adjust with ←/→.",
"Your answer": "Your answer",
"Type your answer…": "Type your answer…",
"Why this answer? Whatever comes to mind.": "Why this answer? Whatever comes to mind.",
"Submit answer": "Submit answer",
"Thinking while reading": "Thinking while reading",
"Whatever comes to mind as you read.": "Whatever comes to mind as you read.",
"Aborted": "Aborted",
"The turn stream body is empty.": "The turn stream body is empty.",
"A turn is already running — try again in a moment.": "A turn is already running — try again in a moment.",
"The dismissal failed ({v0}).": "The dismissal failed ({v0}).",
"Session Find": "Session Find",
"Find in session": "Find in session",
"Find in session…": "Find in session…",
"Previous match": "Previous match",
"Previous match (Shift+Enter)": "Previous match (Shift+Enter)",
"Next match": "Next match",
"Next match (Enter)": "Next match (Enter)",
"Close Find": "Close Find",
"Close Find (Esc)": "Close Find (Esc)",
"Diagram focus": "Diagram focus",
"Diagram": "Diagram",
"from this lesson": "from this lesson",
"Fit diagram to view": "Fit diagram to view",
"Rendering diagram…": "Rendering diagram…",
"Strong": "Strong",
"The main model for your sessions": "The main model for your sessions",
"Cheap": "Cheap",
"The assistants that research, build demos, and grade your work": "The assistants that research, build demos, and grade your work",
"Vision": "Vision",
"The assistants that draw and read PDFs": "The assistants that draw and read PDFs",
"Your version is in use": "Your version is in use",
"Teacher": "Teacher",
"Prober": "Prober",
"Shared": "Shared",
"Used by both agents": "Used by both agents",
"Catalog unavailable": "Catalog unavailable",
"Save failed: {v0}": "Save failed: {v0}",
"Saved": "Saved",
"Delete failed: {v0}": "Delete failed: {v0}",
"Deleted": "Deleted",
"✓ key": "✓ key",
"· no key": "· no key",
"prompt {v0}": "prompt {v0}",
"Hide text": "Hide text",
"Show text": "Show text",
"Source": "Source",
"The original instructions": "The original instructions",
"Override": "Override",
"Your version of the instructions": "Your version of the instructions",
"Add your own instructions…": "Add your own instructions…",
"Save": "Save",
"Models": "Models",
"{v0} model": "{v0} model",
"{v0} thinking": "{v0} thinking",
"Runs every agent of this tier": "Runs every agent of this tier",
"Tools": "Tools",
"Graph tools": "Graph tools",
"Lets the tutor consult your knowledge map between sessions": "Lets the tutor consult your knowledge map between sessions",
"Graph is disabled": "Graph is disabled",
"graph tools": "graph tools",
"Quizzing": "Quizzing",
"How both tutors check your understanding": "How both tutors check your understanding",
"Quizzing mechanics": "Quizzing mechanics",
"Multiple choice": "Multiple choice",
"Free recall": "Free recall",
"Tool · {v0}": "Tool · {v0}",
"tool {v0}": "tool {v0}",
"Search engine": "Search engine",
"Automatic": "Automatic",
"Compaction threshold": "Compaction threshold",
"{v0} tok": "{v0} tok",
"compaction threshold": "compaction threshold",
"System prompts": "System prompts",
"Loading prompt segments…": "Loading prompt segments…",
"Coming soon": "Coming soon",
"Dark": "Dark",
"Background": "Background",
"Empty = {v0}": "Empty = {v0}",
"background hex": "background hex",
"Reading font": "Reading font",
"Choose from your installed fonts": "Choose from your installed fonts",
"Common families — installed fonts could not be read": "Common families — installed fonts could not be read",
"Common families — your installed list needs the desktop app": "Common families — your installed list needs the desktop app",
"Your installed fonts": "Your installed fonts",
"Reading font size": "Reading font size",
"prose font size": "prose font size",
"Session names in serif": "Session names in serif",
"session names serif": "session names serif",
"Heading weight": "Heading weight",
"heading weight": "heading weight",
"Bold color": "Bold color",
"Empty = same as text": "Empty = same as text",
"same as text": "same as text",
"bold color": "bold color",
"Italic color": "Italic color",
"italic color": "italic color",
"Off = the graph stops growing": "Off = the graph stops growing",
"extraction enabled": "extraction enabled",
"Graph rendering": "Graph rendering",
"Off = home shows the chains view": "Off = home shows the chains view",
"graph rendering enabled": "graph rendering enabled",
"Extraction chunk budget": "Extraction chunk budget",
"extraction chunk budget": "extraction chunk budget",
"LearnHarness stores your ChatGPT account details and credentials in an unencrypted local file on this device. When this connection is used, relevant conversation context, learning material, and tool results are sent directly to OpenAI. ChatGPT plan limits apply; any additional credits are authorized and managed in ChatGPT, not LearnHarness.": "LearnHarness stores your ChatGPT account details and credentials in an unencrypted local file on this device. When this connection is used, relevant conversation context, learning material, and tool results are sent directly to OpenAI. ChatGPT plan limits apply; any additional credits are authorized and managed in ChatGPT, not LearnHarness.",
"Background work counts toward your ChatGPT plan usage.": "Background work counts toward your ChatGPT plan usage.",
"Allow background use for all ChatGPT accounts": "Allow background use for all ChatGPT accounts",
"Account disconnected locally. Remote revocation could not be confirmed.": "Account disconnected locally. Remote revocation could not be confirmed.",
"Disconnect account?": "Disconnect account?",
"This removes the saved account and its credentials from this device.": "This removes the saved account and its credentials from this device.",
"The ChatGPT connection could not be changed. Try again.": "The ChatGPT connection could not be changed. Try again.",
"The desktop connection is unavailable. Retry when the app is ready.": "The desktop connection is unavailable. Retry when the app is ready.",
"Disconnect account": "Disconnect account",
"Saved ChatGPT account": "Saved ChatGPT account",
"Add another account or workspace": "Add another account or workspace",
"Manage…": "Manage…",
"OpenAI privacy": "OpenAI privacy",
"Manage usage": "Manage usage",
"ChatGPT sign-in is available only in the desktop app. API-key connections remain available here.": "ChatGPT sign-in is available only in the desktop app. API-key connections remain available here.",
"Waiting for browser sign-in…": "Waiting for browser sign-in…",
"Manage ChatGPT account": "Manage ChatGPT account",
"Add accounts": "Add accounts",
"Disconnect": "Disconnect",
"Sign in again": "Sign in again",
"Continue with ChatGPT": "Continue with ChatGPT",
"ChatGPT account": "ChatGPT account",
"Connect": "Connect",
"Enable background use": "Enable background use",
"Graph": "Graph",
"Fixed — part of this screen": "Fixed — part of this screen",
"Press keys…": "Press keys…",
"Rebind": "Rebind",
"Reset": "Reset",
"Stored on this device (lh:keymap) — overrides only.": "Stored on this device (lh:keymap) — overrides only.",
"Reset all to defaults": "Reset all to defaults",
"not set": "not set",
" · env": " · env",
"Paste key…": "Paste key…",
"{v0} key": "{v0} key",
"key ↗": "key ↗",
"+ Add provider key": "+ Add provider key",
"Loading keys…": "Loading keys…",
"Model API keys": "Model API keys",
"Subscriptions": "Subscriptions",
"Dictation": "Dictation",
"Search": "Search",
"Search models…": "Search models…",
"Loading models…": "Loading models…",
"No models match.": "No models match.",
"Subscription connection": "Subscription connection",
"USD per 1M tokens in/out": "USD per 1M tokens in/out",
"{v0} (subscription)": "{v0} (subscription)",
"Search providers…": "Search providers…",
"No providers match.": "No providers match.",
"Search fonts…": "Search fonts…",
"Loading fonts…": "Loading fonts…",
"Use “": "Use “",
"Providers": "Providers",
"Agent": "Agent",
"Appearance": "Appearance",
"Keyboard": "Keyboard",
"Stored on this device": "Stored on this device",
"General": "General",
"Automatic updates arrive in an upcoming release": "Automatic updates arrive in an upcoming release",
"status {v0}": "status {v0}",
"Settings": "Settings",
"Close settings": "Close settings",
"Settings sections": "Settings sections",
"Loading settings…": "Loading settings…",
"Settings could not be loaded — refresh to retry.": "Settings could not be loaded — refresh to retry.",
"Auto-update": "Auto-update",
"auto update": "auto update",
"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.": "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.",
"Allow LearnHarness helper agents and automatic follow-ups to use this connection.": "Allow LearnHarness helper agents and automatic follow-ups to use this connection.",
"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.": "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.",
"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.": "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.",
"The connection could not be changed. Try again.": "The connection could not be changed. Try again.",
"Sign-in did not complete. Try connecting again.": "Sign-in did not complete. Try connecting again.",
"{v0} connection": "{v0} connection",
"Allow background use": "Allow background use",
"Subscription sign-in requires the desktop app's private server connection. API-key connections remain available here.": "Subscription sign-in requires the desktop app's private server connection. API-key connections remain available here.",
"Device code:": "Device code:",
"The browser could not be opened. Try again.": "The browser could not be opened. Try again.",
"Open browser": "Open browser",
"Select…": "Select…",
"Continue": "Continue",
"Add a provider key": "Add a provider key",
"Prober, teacher": "Prober, teacher",
"Researcher, demo builder, extractor, auditor": "Researcher, demo builder, extractor, auditor",
"Visual maker + PDF reader": "Visual maker + PDF reader",
"Choose your AI provider and paste its API key. You’ll pick your models next.": "Choose your AI provider and paste its API key. You’ll pick your models next.",
"Provider": "Provider",
"Pick a provider…": "Pick a provider…",
"Saving…": "Saving…",
"Save key": "Save key",
"Save failed. Check your connection and try again.": "Save failed. Check your connection and try again.",
"Close setup": "Close setup",
"Model catalog could not be loaded. Check the connection and retry.": "Model catalog could not be loaded. Check the connection and retry.",
"Pick the model for each tier. All three are required before chats can start.": "Pick the model for each tier. All three are required before chats can start.",
"Pick a model…": "Pick a model…",
"No image-capable models. Add a provider key with access to a vision model.": "No image-capable models. Add a provider key with access to a vision model.",
"Save models": "Save models",
"Open sidebar": "Open sidebar",
"just now": "just now",
"{v0}m ago": "{v0}m ago",
"{v0}h ago": "{v0}h ago",
"{v0}d ago": "{v0}d ago",
"Compacting context · {v0}": "Compacting context · {v0}",
"Waiting for input · {v0}": "Waiting for input · {v0}",
"Problem waiting · {v0}": "Problem waiting · {v0}",
"Working…": "Working…",
"Practice session": "Practice session",
"Home": "Home",
"Close sidebar": "Close sidebar",
"create a new teaching session": "create a new teaching session",
"create a new practice session": "create a new practice session",
"Search sessions": "Search sessions",
"Search…": "Search…",
"All": "All",
"No sessions match.": "No sessions match.",
"No sessions yet.": "No sessions yet.",
"Rename": "Rename",
"Export as Markdown…": "Export as Markdown…",
"Delete…": "Delete…",
"Session stats": "Session stats",
"Context (last call)": "Context (last call)",
"Usage": "Usage",
"Total cost": "Total cost",
"Manage ChatGPT usage": "Manage ChatGPT usage",
"Unpriced turns": "Unpriced turns",
"Turns": "Turns",
"is ready": "is ready",
"— restart to finish installing.": "— restart to finish installing.",
"Restart": "Restart",
"Toggle dictation": "Toggle dictation",
"Open settings": "Open settings",
"Cycle thinking level": "Cycle thinking level",
"Toggle sessions sidebar": "Toggle sessions sidebar",
"Toggle all your thinking": "Toggle all your thinking",
"Find": "Find",
"Shortcut cheat sheet": "Shortcut cheat sheet",
"Question · focus thinking ↔ rows": "Question · focus thinking ↔ rows",
"Question · next row": "Question · next row",
"Question · previous row": "Question · previous row",
"Question · pick row": "Question · pick row",
"Question · submit": "Question · submit",
"Space": "Space",
"Speech-to-text key for dictation — needs ≥ Member permission in the Deepgram console to mint tokens.": "Speech-to-text key for dictation — needs ≥ Member permission in the Deepgram console to mint tokens.",
"Web-search engine for the researcher sub-agent (AI-native search API). Setting the key activates it — no engine toggle needed.": "Web-search engine for the researcher sub-agent (AI-native search API). Setting the key activates it — no engine toggle needed.",
"Web-search engine for the researcher sub-agent — the alternative to Tavily. Setting the key activates it.": "Web-search engine for the researcher sub-agent — the alternative to Tavily. Setting the key activates it.",
"{v0} must be at least {v1} characters with no whitespace": "{v0} must be at least {v1} characters with no whitespace",
"{v0} must start with \"tvly-\" (Tavily's key format)": "{v0} must start with \"tvly-\" (Tavily's key format)",
"unknown key \"{v0}\" (supported: {v1})": "unknown key \"{v0}\" (supported: {v1})",
"{v0} must be at least 12 characters with no whitespace": "{v0} must be at least 12 characters with no whitespace",
"Dictate": "Dictate",
"of": "of",
"correct": "correct",
"incorrect": "incorrect",
"failed": "failed",
"complete": "complete",
"sources": "sources",
"unlabeled": "unlabeled",
"notes": "notes",
"tokens": "tokens",
"unpriced": "unpriced",
"out": "out",
"steps": "steps",
"custom": "custom",
"needed": "needed",
"now": "now",
"starting": "starting",
"done": "done",
"queued": "queued",
"running": "running",
"extracting": "extracting",
"auditing": "auditing",
"merging": "merging",
"delivery failed": "delivery failed",
"waiting": "waiting",
"waiting for a slot": "waiting for a slot",
"building demo": "building demo",
"rendering figure": "rendering figure",
"reading PDF": "reading PDF",
"final verification": "final verification",
"off": "off",
"minimal": "minimal",
"low": "low",
"medium": "medium",
"high": "high",
"xhigh": "xhigh",
"max": "max",
"No thinking levels.": "No thinking levels.",
"Learner": "Learner",
"Quiz": "Quiz",
"Recall check": "Recall check",
"(no question text)": "(no question text)",
"The learner skipped this check.": "The learner skipped this check.",
"The learner did not know.": "The learner did not know.",
"The learner answered correctly.": "The learner answered correctly.",
"The learner answered incorrectly.": "The learner answered incorrectly.",
"The learner answered in their own words: {answer}": "The learner answered in their own words: {answer}",
"The learner answered in his own words: {answer}": "The learner answered in his own words: {answer}",
"Their thinking: {thinking}": "Their thinking: {thinking}",
"his thinking: {thinking}": "his thinking: {thinking}",
"(no statement)": "(no statement)",
"The learner skipped this problem.": "The learner skipped this problem.",
"The learner answered: {answer}": "The learner answered: {answer}",
"The learner chose: {answer}": "The learner chose: {answer}",
"The learner skipped this question.": "The learner skipped this question.",
"Q:": "Q:",
"Tool": "Tool",
"{tool} result delivered": "{tool} result delivered",
"No model configured — set up your models first": "No model configured — set up your models first",
"No {v0} key is set — add one in Settings → Keys": "No {v0} key is set — add one in Settings → Keys",
"Unknown chat": "Unknown chat",
"Invalid JSON body": "Invalid JSON body",
"Failed to fetch": "Failed to fetch",
"Network error": "Network error",
"Network error. Try again.": "Network error. Try again.",
"Failed to attach to the running turn.": "Failed to attach to the running turn.",
"The send failed ({v0}).": "The send failed ({v0}).",
"The steer failed ({v0}).": "The steer failed ({v0}).",
"Save failed ({v0})": "Save failed ({v0})",
"Delete failed ({v0})": "Delete failed ({v0})",
"settings rejected ({v0})": "settings rejected ({v0})",
"A turn is already running for this session": "A turn is already running for this session",
"This file is already being imported. Try again when it finishes.": "This file is already being imported. Try again when it finishes.",
"Notes import did not complete": "Notes import did not complete",
"Deepgram grant returned no token": "Deepgram grant returned no token",
"Deepgram unreachable": "Deepgram unreachable",
"Deepgram token grant failed ({v0}): {v1}": "Deepgram token grant failed ({v0}): {v1}",
"token route {v0}": "token route {v0}",
"No files": "No files",
"Empty title": "Empty title",
"Title must not be empty": "Title must not be empty",
"Copied": "Copied",
"Copy": "Copy",
"Download": "Download",
"No results": "No results",
"Select": "Select",
"Previous": "Previous",
"Next": "Next",
"Untitled session": "Untitled session",
"{count} messages unextracted": {
"one": "{count} message unextracted",
"other": "{count} messages unextracted"
},
"· read {count} pages": {
"one": "· read {count} page",
"other": "· read {count} pages"
},
"{count} sources": {
"one": "{count} source",
"other": "{count} sources"
},
"{count} propositions": "{count} propositions",
"{count} chains": "{count} chains",
"{count} teaching sessions": "{count} teaching sessions",
"{count} practice sessions": "{count} practice sessions",
"{count} imported notes": "{count} imported notes",
"Copy Code": "Copy Code",
"Download file": "Download file",
"Download diagram": "Download diagram",
"Download diagram as SVG": "Download diagram as SVG",
"Download diagram as PNG": "Download diagram as PNG",
"Download diagram as MMD": "Download diagram as MMD",
"View fullscreen": "View fullscreen",
"Exit fullscreen": "Exit fullscreen",
"Copy table as Markdown": "Copy table as Markdown",
"Copy table as CSV": "Copy table as CSV",
"Copy table as TSV": "Copy table as TSV",
"Download table": "Download table",
"Download table as CSV": "Download table as CSV",
"Download table as Markdown": "Download table as Markdown",
"Image not available": "Image not available",
"Download image": "Download image",
"Open external link?": "Open external link?",
"You're about to visit an external website.": "You're about to visit an external website.",
"Copy link": "Copy link",
"Open link": "Open link",
"Other": "Other",
"Light": "Light",
"delivered": "delivered",
"No such session": "No such session",
"A turn is running in this session — try again when it finishes": "A turn is running in this session — try again when it finishes",
"Background jobs are still running in this session — try again when they finish": "Background jobs are still running in this session — try again when they finish",
"The session's extraction is still winding down — try again in a moment": "The session's extraction is still winding down — try again in a moment",
"Not found": "Not found",
"title must be a non-empty string": "title must be a non-empty string",
"unknown key \"{v0}\"": "unknown key \"{v0}\"",
"body must be JSON": "body must be JSON",
"body must be {value: string}": "body must be {value: string}",
"model must be {provider, id}": "model must be {provider, id}",
"thinkingLevel must be a string": "thinkingLevel must be a string",
"autoUpdate must be a boolean": "autoUpdate must be a boolean",
"{v0} must be \"en\" or \"ru\"": "{v0} must be \"en\" or \"ru\"",
"sttLanguage must be \"en\", \"ru\", or \"multi\"": "sttLanguage must be \"en\", \"ru\", or \"multi\"",
"{v0} must be an object": "{v0} must be an object",
"{v0}.model must be {provider, id}": "{v0}.model must be {provider, id}",
"{v0}.thinkingLevel must be a string": "{v0}.thinkingLevel must be a string",
"{v0}.{v1} must be a boolean": "{v0}.{v1} must be a boolean",
"models must be an object": "models must be an object",
"unknown role \"{v0}\" (supported: {v1})": "unknown role \"{v0}\" (supported: {v1})",
"unknown tool \"{v0}\" (supported: {v1})": "unknown tool \"{v0}\" (supported: {v1})",
"search must be an object": "search must be an object",
"search.engine must be \"tavily\" or \"brave\"": "search.engine must be \"tavily\" or \"brave\"",
"graphTools must be an object": "graphTools must be an object",
"graphTools.enabled must be a boolean": "graphTools.enabled must be a boolean",
"{v0} must be a #hex color or null": "{v0} must be a #hex color or null",
"mode must be \"dark\" or \"light\"": "mode must be \"dark\" or \"light\"",
"fontFamily must be a non-empty string or null": "fontFamily must be a non-empty string or null",
"proseFontSize must be an integer between 12 and 24": "proseFontSize must be an integer between 12 and 24",
"sessionNameSerif must be a boolean": "sessionNameSerif must be a boolean",
"mdRendering must be an object": "mdRendering must be an object",
"mdRendering.headingWeight must be an integer between 100 and 900": "mdRendering.headingWeight must be an integer between 100 and 900",
"enabled must be a boolean": "enabled must be a boolean",
"rendering must be a boolean": "rendering must be a boolean",
"extractionChunkTokens must be a number": "extractionChunkTokens must be a number",
"extractionChunkTokens must be at least {v0} (smaller batches waste the per-run graph overhead)": "extractionChunkTokens must be at least {v0} (smaller batches waste the per-run graph overhead)",
"unknown slot \"{v0}\" (supported: {v1})": "unknown slot \"{v0}\" (supported: {v1})",
"slot \"{v0}\" must set a model (thinkingLevel alone cannot run — absent slot = unset)": "slot \"{v0}\" must set a model (thinkingLevel alone cannot run — absent slot = unset)",
"thresholdTokens must be a number": "thresholdTokens must be a number",
"thresholdTokens must be at least {v0} (compaction keeps a ~20k-token recent tail — smaller budgets would churn it)": "thresholdTokens must be at least {v0} (compaction keeps a ~20k-token recent tail — smaller budgets would churn it)",
"The tool call failed.": "The tool call failed.",
"Aborted by learner": "Aborted by learner",
"starting research": "starting research",
"starting demo": "starting demo",
"starting figure": "starting figure",
"building visual": "building visual",
"render_svg: inspecting final render": "render_svg: inspecting final render",
"render_svg: inspecting render": "render_svg: inspecting render",
"render_svg: render error": "render_svg: render error",
"edit_svg: applied": "edit_svg: applied",
"web_search: \"{v0}\"": "web_search: \"{v0}\"",
"web_fetch: {v0}": "web_fetch: {v0}",
"Stopping…": "Stopping…",
"The demo could not be built.": "The demo could not be built.",
"The figure could not be drawn.": "The figure could not be drawn.",
"untagged": "untagged",
"yes": "yes",
"no": "no",
"none": "none",
" · select all that apply": " · select all that apply",
"recall": "recall",
"discrimination": "discrimination",
"composition": "composition",
"solved": "solved",
"solvedWithHints": "solvedWithHints",
"movedOn": "movedOn",
"surrendered": "surrendered",
"dismissed": "dismissed",
"cue": "cue",
"execution": "execution",
"careless": "careless",
"structural": "structural",
"pending": "pending",
"consent": "consent",
"proposition": "proposition",
"propositions": "propositions",
"node": "node",
"nodes": "nodes",
"chain": "chain",
"chains": "chains",
" · {v0} sources": " · {v0} sources",
"{v0}s elapsed": "{v0}s elapsed",
"{count} nodes": "{count} nodes",
"The saved connection could not be read or written safely. Reopen the app or reconnect after resolving the credential-file problem.": "The saved connection could not be read or written safely. Reopen the app or reconnect after resolving the credential-file problem.",
"The connection could not be refreshed. Retry or reconnect in Providers.": "The connection could not be refreshed. Retry or reconnect in Providers.",
"Sign-in cancelled.": "Sign-in cancelled.",
"This subscription connection is unavailable. Reconnect or explicitly choose another connection.": "This subscription connection is unavailable. Reconnect or explicitly choose another connection.",
"Background use is off for this connection. Automatic follow-up remains deferred in its queue.": "Background use is off for this connection. Automatic follow-up remains deferred in its queue.",
"The connection or model catalog changed. Select an available model and retry.": "The connection or model catalog changed. Select an available model and retry.",
"This sign-in action is no longer available.": "This sign-in action is no longer available.",
"ChatGPT sign-in cancelled.": "ChatGPT sign-in cancelled.",
"ChatGPT sign-in timed out. Try again.": "ChatGPT sign-in timed out. Try again.",
"ChatGPT credentials could not be read or saved. Check local file access, then reconnect before making requests.": "ChatGPT credentials could not be read or saved. Check local file access, then reconnect before making requests.",
"ChatGPT sign-in could not be verified. Select the intended saved account and try again.": "ChatGPT sign-in could not be verified. Select the intended saved account and try again.",
"ChatGPT plan permission is missing. Reconnect and authorize ChatGPT plan use.": "ChatGPT plan permission is missing. Reconnect and authorize ChatGPT plan use.",
"This ChatGPT connection is disconnected. Reconnect in Providers.": "This ChatGPT connection is disconnected. Reconnect in Providers.",
"ChatGPT background use is off in this app. Enable Allow background use for all ChatGPT accounts in Providers, or explicitly select an API-key model for that workload.": "ChatGPT background use is off in this app. Enable Allow background use for all ChatGPT accounts in Providers, or explicitly select an API-key model for that workload.",
"The ChatGPT account model catalog is unavailable. Retry the connection; no API-key fallback was used.": "The ChatGPT account model catalog is unavailable. Retry the connection; no API-key fallback was used.",
"The ChatGPT session expired. Reconnect in Providers.": "The ChatGPT session expired. Reconnect in Providers.",
"ChatGPT could not be reached. Check your connection and try again.": "ChatGPT could not be reached. Check your connection and try again.",
"A ChatGPT sign-in is already pending.": "A ChatGPT sign-in is already pending.",
"ChatGPT sign-in is unavailable in browser-only mode.": "ChatGPT sign-in is unavailable in browser-only mode.",
"ChatGPT sign-in failed. Try again; no API-key fallback was used.": "ChatGPT sign-in failed. Try again; no API-key fallback was used.",
"The selected model is unavailable.": "The selected model is unavailable.",
"ChatGPT plan usage limit reached. Manage usage at https://chatgpt.com/settings/usage. No API-key fallback was used.": "ChatGPT plan usage limit reached. Manage usage at https://chatgpt.com/settings/usage. No API-key fallback was used.",
"ChatGPT plan permission is missing. Reconnect in Providers and authorize plan use. No API-key fallback was used.": "ChatGPT plan permission is missing. Reconnect in Providers and authorize plan use. No API-key fallback was used.",
"Your ChatGPT session expired. Reconnect in Providers. No API-key fallback was used.": "Your ChatGPT session expired. Reconnect in Providers. No API-key fallback was used.",
"ChatGPT background use is off in this app. Enable Allow background use for all ChatGPT accounts in Providers to run this work. No API-key fallback was used.": "ChatGPT background use is off in this app. Enable Allow background use for all ChatGPT accounts in Providers to run this work. No API-key fallback was used.",
"This ChatGPT account or workspace does not permit this request. Check ChatGPT settings; reconnecting may not change eligibility.": "This ChatGPT account or workspace does not permit this request. Check ChatGPT settings; reconnecting may not change eligibility.",
"ChatGPT request cancelled or connection closed. No API-key fallback was used.": "ChatGPT request cancelled or connection closed. No API-key fallback was used.",
"The selected model is unavailable for this ChatGPT account. Refresh Providers and explicitly choose an available model. No API-key fallback was used.": "The selected model is unavailable for this ChatGPT account. Refresh Providers and explicitly choose an available model. No API-key fallback was used.",
"ChatGPT returned an incomplete response. Retry when ready; no API-key fallback was used.": "ChatGPT returned an incomplete response. Retry when ready; no API-key fallback was used.",
"ChatGPT request failed. Check your connection and ChatGPT usage, then retry. No API-key fallback was used.": "ChatGPT request failed. Check your connection and ChatGPT usage, then retry. No API-key fallback was used.",
"{v0} background use is off in this app. Enable it in Providers to run this work. No API-key fallback was used.": "{v0} background use is off in this app. Enable it in Providers to run this work. No API-key fallback was used.",
"{v0} usage limit reached. Check your provider account and try again later. No API-key fallback was used.": "{v0} usage limit reached. Check your provider account and try again later. No API-key fallback was used.",
"{v0} authorization could not be refreshed. Retry or reconnect in Providers. No API-key fallback was used.": "{v0} authorization could not be refreshed. Retry or reconnect in Providers. No API-key fallback was used.",
"{v0} request cancelled or connection closed. No API-key fallback was used.": "{v0} request cancelled or connection closed. No API-key fallback was used.",
"The selected {v0} model or connection is unavailable. Refresh Providers and explicitly choose an available model. No API-key fallback was used.": "The selected {v0} model or connection is unavailable. Refresh Providers and explicitly choose an available model. No API-key fallback was used.",
"{v0} request failed. Check the connection, account access and provider limits, then retry. No API-key fallback was used.": "{v0} request failed. Check the connection, account access and provider limits, then retry. No API-key fallback was used.",
"GitHub Enterprise URL/domain (blank for github.com)": "GitHub Enterprise URL/domain (blank for github.com)",
"Enabling models...": "Enabling models...",
"Waiting for authorization...": "Waiting for authorization...",
"no search engine configured — set a Tavily or Brave search API key": "no search engine configured — set a Tavily or Brave search API key",
"search engine is set to \"{v0}\" but its API key is missing — set the {v1} key": "search engine is set to \"{v0}\" but its API key is missing — set the {v1} key",
"search engine: {v0} (chosen)": "search engine: {v0} (chosen)",
"search engine: {v0} (auto)": "search engine: {v0} (auto)",
"update": "update",
"Fast": "Fast",
"{v0} fast mode": "{v0} fast mode",
"Fast mode uses more ChatGPT allowance.": "Fast mode uses more ChatGPT allowance.",
"Fast mode is available only for ChatGPT models.": "Fast mode is available only for ChatGPT models.",
"Could not save model settings": "Could not save model settings",
"Fast mode is unavailable for this ChatGPT model or account. Turn off Fast mode or choose another model.": "Fast mode is unavailable for this ChatGPT model or account. Turn off Fast mode or choose another model.",
"Speech recognition model": "Speech recognition model",
"Unknown speech recognition model": "Unknown speech recognition model",
"Invalid speech recognition language": "Invalid speech recognition language",
"Invalid dictation audio": "Invalid dictation audio",
"Recording exceeds OpenRouter's 25 MB audio limit": "Recording exceeds OpenRouter's 25 MB audio limit",
"Could not read dictation audio": "Could not read dictation audio",
"Transcription cancelled": "Transcription cancelled",
"Dictation needs an OpenRouter key": "Dictation needs an OpenRouter key",
"OpenRouter rejected the API key": "OpenRouter rejected the API key",
"OpenRouter credits are insufficient": "OpenRouter credits are insufficient",
"OpenRouter rate limit reached; try again later": "OpenRouter rate limit reached; try again later",
"OpenRouter transcription failed ({v0})": "OpenRouter transcription failed ({v0})",
"OpenRouter returned an invalid transcript": "OpenRouter returned an invalid transcript",
"OpenRouter transcription timed out": "OpenRouter transcription timed out",
"OpenRouter transcription is unavailable": "OpenRouter transcription is unavailable",
"Could not finish microphone capture": "Could not finish microphone capture",
"Transcribing…": "Transcribing…",
"Speech recognition": "Speech recognition",
"No preference": "No preference"
}
+906
View File
@@ -0,0 +1,906 @@
{
"Abort this turn?": "Прервать ответ?",
"The partial answer stays in the transcript.": "Частичный ответ останется в истории занятия.",
"Abort": "Прервать",
"Dismiss this question?": "Пропустить вопрос?",
"Skipped — the teacher moves on without it.": "Вопрос будет пропущен — преподаватель продолжит без ответа на него.",
"Dismiss": "Пропустить",
"Discard this problem?": "Отменить задачу?",
"The problem is dropped — no solution is revealed.": "Задача будет отменена, решение не будет показано.",
"Discard": "Отменить задачу",
" The working turn stops too.": " Текущий ответ тоже будет прерван.",
"Cancel": "Отмена",
"needs model setup": "нужно настроить модели",
"needs a provider key": "нужен ключ провайдера",
"{v0}s": "{v0} с",
"{v0}m {v1}s": "{v0} мин {v1} с",
"{v0}h {v1}m": "{v0} ч {v1} мин",
"Background agents": "Фоновые агенты",
"Total running cost (inferred)": "Общая текущая стоимость (оценка)",
"Extraction — {v0}": "Извлечение знаний — {v0}",
"Researcher": "Исследователь",
"Demo builder": "Создатель демонстраций",
"Figure maker": "Создатель иллюстраций",
"PDF reader": "Чтение PDF",
"Sub-agent": "Вспомогательный агент",
"Abort failed — try again": "Не удалось прервать — повторите попытку",
"End this background process": "Завершить фоновый процесс",
"End {v0} in {v1}": "Завершить «{v0}» в занятии «{v1}»",
"Cost": "Стоимость",
"Context": "Контекст",
"Model": "Модель",
"Steps": "Шаги",
"Open session →": "Открыть занятие →",
"No messages yet": "Сообщений пока нет",
"Start a conversation to see messages here": "Начните разговор, чтобы здесь появились сообщения",
"Previous branch": "Предыдущая ветка",
"Next branch": "Следующая ветка",
"Thinking...": "Размышляю…",
"AGENT’S THINKING": "РАССУЖДЕНИЯ АГЕНТА",
"Thinking": "Рассуждения",
"— skipped": "— пропущено",
"YOUR THINKING": "ВАШИ РАССУЖДЕНИЯ",
"Probe": "Диагностика",
"Recall": "Воспроизведение по памяти",
"Prediction": "Предсказание",
"Check": "Проверка",
"Explain": "Объяснение",
"Question": "Вопрос",
"didn't know": "не знаю",
"Answered “I don't know”.": "Вы ответили «Не знаю».",
"Hide why": "Скрыть объяснение",
"Why": "Почему",
"Why:": "Почему:",
"The answer:": "Ответ:",
"unknown error": "неизвестная ошибка",
"Demo completed, but the result could not be displayed.": "Демонстрация готова, но отобразить её не удалось.",
"Interactive demo": "Интерактивная демонстрация",
"interactive demo": "интерактивная демонстрация",
"Invert demo brightness": "Инвертировать яркость демонстрации",
"Invert colors": "Инвертировать цвета",
"Download demo as HTML": "Скачать демонстрацию в формате HTML",
"Download as HTML": "Скачать HTML",
"The demo \"{v0}\" threw an error: {v1}": "В демонстрации «{v0}» возникла ошибка: {v1}",
"This demo threw an error": "В демонстрации возникла ошибка",
"Send to teacher": "Отправить преподавателю",
"Research agent": "Агент-исследователь",
"Demo agent": "Агент демонстраций",
"Figure agent": "Агент иллюстраций",
"Set up models": "Настроить модели",
"Add a key": "Добавить ключ",
"{v0} running": "{v0}: выполняется",
"Dispatched…": "Задача отправлена…",
"PDF reading completed, but the result could not be displayed.": "Чтение PDF завершено, но отобразить результат не удалось.",
"PDF failed": "Ошибка чтения PDF",
"PDF brief": "Обзор PDF",
"Pages read:": "Прочитанные страницы:",
"Pages consulted:": "Использованные страницы:",
"Research completed, but the result could not be displayed.": "Исследование завершено, но отобразить результат не удалось.",
"Research failed": "Ошибка исследования",
"Research brief": "Обзор исследования",
"Sources": "Источники",
"{v0} — live": "{v0} — выполняется",
"Input (streaming)": "Входные данные (поступают)",
"Probe concluded — your assessment": "Диагностика завершена — ваша оценка",
"The prober retried:": "Агент диагностики повторил попытку:",
"The assessment was malformed — the prober retried with a corrected one.": "Оценка содержала ошибки формата — агент диагностики подготовил исправленный вариант.",
"Writing the assessment…": "Готовлю оценку…",
"A sub-agent finished, but its result could not be displayed.": "Вспомогательный агент завершил работу, но отобразить результат не удалось.",
"aborted by the learner": "прервано пользователем",
"reading complete": "чтение завершено",
"· read": "· прочитано",
"view brief": "показать обзор",
"hide brief": "скрыть обзор",
"Research": "Исследование",
"{v0}\n[{v1} image{v2} attached to the model]": "{v0}\n[Изображений, приложенных для модели: {v1}]",
"Input": "Входные данные",
"Error": "Ошибка",
"Output": "Результат",
"running…": "выполняется…",
"Figure completed, but the result could not be displayed.": "Иллюстрация готова, но отобразить её не удалось.",
"Figure": "Иллюстрация",
"Invert figure brightness": "Инвертировать яркость иллюстрации",
"Download figure as SVG": "Скачать иллюстрацию в формате SVG",
"Download as SVG": "Скачать SVG",
"Open figure fullscreen": "Открыть иллюстрацию на весь экран",
"Open fullscreen": "На весь экран",
"Working": "Выполняется",
"{v0}ms": "{v0} мс",
"Search nodes…": "Поиск узлов…",
"Search nodes": "Поиск узлов",
"Close chains": "Закрыть цепочки",
"Name": "Название",
"Props": "Утверждения",
"Created": "Создано",
"No matching chains.": "Подходящих цепочек нет.",
"Select everything born in {v0}": "Выбрать всё, созданное в занятии «{v0}»",
"Expand cluster {v0}": "Развернуть группу «{v0}»",
"Select chain {v0}": "Выбрать цепочку «{v0}»",
"Expand chain {v0}": "Развернуть цепочку «{v0}»",
"Select node {v0}": "Выбрать узел «{v0}»",
"Clear": "Очистить",
"Done picking — {v0} {v1} selected": "Завершить выбор — выбрано узлов: {v0}",
"Done": "Готово",
"Delete {v0} selected {v1}": "Удалить выбранные узлы ({v0})",
"Delete": "Удалить",
"rename failed: {v0}": "не удалось переименовать: {v0}",
"rename failed:": "не удалось переименовать:",
"Session title": "Название занятия",
"Rename this session": "Переименовать занятие",
"Rename session": "Переименовать занятие",
"Open the full plan": "Открыть полный план",
"Plan": "План",
"Practice": "Практика",
"New session": "Новое занятие",
"The teacher will open by probing what you already know.": "Преподаватель начнёт с проверки того, что вы уже знаете.",
"Next problem": "Следующая задача",
"You": "Вы",
"Retrying…": "Повторная попытка…",
"Compacting context": "Сжатие контекста",
"COMPACTING CONTEXT…": "СЖАТИЕ КОНТЕКСТА…",
"THINKING…": "РАЗМЫШЛЯЮ…",
"Something went wrong:": "Произошла ошибка:",
"Set up your models": "Настройте модели",
"Ask anything, or press Enter for the next one.": "Задайте вопрос или нажмите Enter для следующей задачи.",
"Context compacted": "Контекст сжат",
" · {v0}k tok": " · {v0} тыс. токенов",
"History compacted": "История сжата",
"{v0} {v1} selected": "Выбрано узлов: {v0}",
"Pick nodes": "Выбрать узлы",
"Ask, answer, or steer the lesson…": "Спросите, ответьте или задайте направление занятия…",
"Stop": "Остановить",
"Send": "Отправить",
"An extraction is running — try again when the activity dock drains.": "Идёт извлечение знаний — дождитесь завершения процессов на панели активности.",
"Delete node": "Удалить узел",
"Delete node?": "Удалить узел?",
"Delete {v0} nodes?": "Удалить узлы ({v0})?",
", and {v0} more": ", и ещё {v0}",
"This permanently deletes the node — this cannot be undone.": "Узел будет удалён безвозвратно. Отменить это действие нельзя.",
"This permanently deletes all {v0} nodes — this cannot be undone.": "Все выбранные узлы ({v0}) будут удалены безвозвратно. Отменить это действие нельзя.",
"Try again": "Повторить попытку",
"Deleting…": "Удаление…",
"Delete failed — network error. Try again.": "Не удалось удалить: ошибка сети. Повторите попытку.",
"Also remove its graph traces": "Также удалить следы занятия из графа",
"This session’s topics, links, and events leave the graph too. Topics other sessions or imported notes also cover survive; topics only this session created are removed entirely.": "Темы, связи и события этого занятия тоже будут удалены из графа. Темы, встречающиеся в других занятиях или импортированных заметках, сохранятся; темы, созданные только этим занятием, будут удалены полностью.",
"Delete session": "Удалить занятие",
"Delete session?": "Удалить занятие?",
"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.": "Занятие будет удалено безвозвратно вместе с сообщениями, фоновыми задачами и записями об извлечении знаний. Всё, что оно добавило в граф, сохранится, если не отметить пункт ниже. Отменить это действие нельзя.",
"Dictation needs a Deepgram key": "Для диктовки нужен ключ Deepgram",
"Deepgram reconnect failed": "Не удалось восстановить соединение с Deepgram",
"Deepgram connection lost ({v0})": "Соединение с Deepgram потеряно ({v0})",
"Microphone permission denied": "Доступ к микрофону запрещён",
"mic unavailable": "микрофон недоступен",
"token unavailable": "токен недоступен",
"Dictation failed: {v0}": "Ошибка диктовки: {v0}",
"Start dictation": "Начать диктовку",
"Stop dictation (Esc)": "Остановить диктовку (Esc)",
"Stop dictation": "Остановить диктовку",
"extracting…": "извлечение знаний…",
"fully extracted": "всё извлечено",
"tok uncovered": "токенов не обработано",
"Running": "Выполняется",
"Extract": "Извлечь знания",
"How much of this session isn't mapped yet": "Какая часть занятия ещё не отражена в графе",
"Extraction coverage": "Полнота извлечения знаний",
"Uncovered": "Не обработано",
"Covered": "Обработано",
"Messages": "Сообщения",
"Chunk threshold": "Порог фрагмента",
"Last extraction": "Последнее извлечение",
"Status": "Состояние",
"Graph context": "Контекст графа",
"{v0}k": "{v0} тыс.",
"{v0}M": "{v0} млн",
"{v0}m": "{v0} мин",
"{v0}h": "{v0} ч",
"{v0}d": "{v0} дн.",
"Graph costs — click for details": "Стоимость графа — нажмите для подробностей",
"Extraction": "Извлечение знаний",
"Audit": "Проверка",
"Graph pipeline costs": "Затраты на обработку графа",
"in /": "вход /",
" +1 unpriced": " +1 без цены",
"Graph display settings": "Настройки отображения графа",
"Node size": "Размер узлов",
"Edge width": "Толщина связей",
"Arrowhead size": "Размер стрелок",
"Label size": "Размер подписей",
"Labels until zoom": "Порог масштаба для подписей",
"Regions": "Области",
"Background colors": "Цвета фона",
"Dilation": "Расширение",
"Blur": "Размытие",
"Edge alpha": "Непрозрачность границ",
"Bitmap resolution": "Разрешение изображения",
"Cluster labels": "Подписи групп",
"Cluster label size": "Размер подписей групп",
"Physics": "Физика",
"Node repulsion": "Отталкивание узлов",
"Edge attraction": "Притяжение связей",
"Edge length": "Длина связей",
"Central pull": "Притяжение к центру",
"Cluster pull": "Притяжение к группе",
"Friction": "Трение",
"Depth anchor": "Ориентация по глубине",
"Off": "Выкл.",
"Vertical (up)": "По вертикали (вверх)",
"Radial (outward)": "По радиусу (наружу)",
"Depth strength": "Сила разделения по глубине",
"Depth spacing": "Расстояние по глубине",
"Base ring radius": "Радиус базового кольца",
"No matching nodes.": "Подходящих узлов нет.",
"Every reasoning chain": "Все цепочки рассуждений",
"Chains": "Цепочки",
"Import notes into the graph": "Импортировать заметки в граф",
"Importing…": "Импорт…",
"Import notes…": "Импортировать заметки…",
"Knowledge graph": "Граф знаний",
"Start a session or import notes to build your graph.": "Начните занятие или импортируйте заметки, чтобы построить граф.",
"Sessions": "Занятия",
"No sessions recorded.": "Занятий пока нет.",
"What do you want to practice?": "Что вы хотите попрактиковать?",
"What do you want to learn?": "Что вы хотите изучить?",
"Start session": "Начать занятие",
"Teach": "Обучение",
"A teacher agent with a memory — sessions, quizzes, and a knowledge graph of what you know.": "Преподаватель с памятью: занятия, проверочные вопросы и граф ваших знаний.",
"Could not load language settings": "Не удалось загрузить языковые настройки",
"Could not save language settings": "Не удалось сохранить языковые настройки",
"Interface language": "Язык интерфейса",
"Speech recognition language": "Язык распознавания речи",
"Auto": "Авто",
"System default": "Язык системы",
"Multilingual": "Несколько языков",
"Multi": "Мульти",
"Preferred languages": "Предпочитаемые языки",
"Could not save preferred languages": "Не удалось сохранить предпочитаемые языки",
"sttPreferredLanguages must be an array of \"en\" and \"ru\"": "Предпочитаемые языки должны быть списком кодов «en» и «ru»",
"Global": "Общие",
"Chat": "Занятие",
"Graph view": "Граф",
"Keyboard shortcuts": "Сочетания клавиш",
"Esc to close": "Esc — закрыть",
"Copy diagram source": "Копировать исходный код диаграммы",
"Open full-screen": "Открыть на весь экран",
"Expand diagram": "Развернуть диаграмму",
"Copy code": "Копировать код",
"Copy table": "Копировать таблицу",
"Copy equation source": "Копировать исходный код формулы",
"Diagram failed to render": "Не удалось отобразить диаграмму",
"Show diagram source": "Показать исходный код диаграммы",
"Retry": "Повторить",
"teaching session": "учебное занятие",
"teaching sessions": "учебные занятия",
"practice session": "практическое занятие",
"practice sessions": "практические занятия",
"imported note": "импортированная заметка",
"imported notes": "импортированные заметки",
"Nothing here yet — import notes to begin building the graph.": "Здесь пока пусто — импортируйте заметки, чтобы начать строить граф.",
"catalog request failed: {v0}": "не удалось загрузить каталог: {v0}",
"Could not reach the server — try again.": "Не удалось связаться с сервером — повторите попытку.",
"Model selector": "Выбор модели",
"No models — add a provider key in Keys": "Моделей нет — добавьте ключ провайдера в настройках провайдеров",
"This session's model": "Модель этого занятия",
"Strong tier — the default for new sessions": "Основная модель — по умолчанию для новых занятий",
"{v0} has no thinking levels.": "Модель {v0} не поддерживает уровни рассуждения.",
"Pick a model first.": "Сначала выберите модель.",
"Loading…": "Загрузка…",
"Levels this model actually supports": "Уровни, поддерживаемые этой моделью",
"New practice session": "Новое практическое занятие",
"Pick nodes from your knowledge graph, describe what you want to practice, or bring your own problem.": "Выберите узлы графа знаний, опишите, что хотите попрактиковать, или предложите свою задачу.",
"Invalid import response. Please retry the file.": "Некорректный ответ на запрос импорта. Попробуйте импортировать файл ещё раз.",
"Import request failed (HTTP {v0}){v1}. Please retry the file.": "Ошибка импорта (HTTP {v0}){v1}. Попробуйте импортировать файл ещё раз.",
"Probing": "Диагностика",
"Planning": "Планирование",
"Teaching": "Обучение",
"Plan · {v0} node{v1}": "План · узлов: {v0}",
"Plan diagram": "Диаграмма плана",
"The full plan": "Полный план",
"The plan": "План",
"live — statuses derive from your check outcomes": "обновляется — состояния определяются результатами ваших проверок",
"Close": "Закрыть",
"Drag to pan · scroll to zoom": "Перетаскивание — перемещение · прокрутка — масштаб",
"Zoom out": "Уменьшить масштаб",
"Fit plan to screen": "Вписать план в экран",
"Zoom in": "Увеличить масштаб",
"No plan in this session yet.": "В этом занятии пока нет плана.",
"Problem": "Задача",
"Solved": "Решено",
"Solved with hints": "Решено с подсказками",
"Moved on": "Переход дальше",
"Surrendered": "Решение запрошено",
"Dismissed": "Пропущено",
"Problem dismissed.": "Задача пропущена.",
"Concluded": "Завершено",
"Model solution": "Образец решения",
"Metadata": "Метаданные",
"Mode": "Режим",
"Targets": "Цели",
"Outcome": "Результат",
"Hint rung": "Уровень подсказки",
"Miss class": "Тип ошибки",
"Retest": "Повторная проверка",
"Work here, think out loud.": "Решайте здесь и записывайте ход мыслей.",
"compacting session…": "сжатие контекста занятия…",
"↵ submits · ⇧↵ newline": "↵ — отправить · ⇧↵ — новая строка",
"Submit thinking": "Отправить рассуждения",
"This question arrived malformed — the transcript keeps the retry.": "Вопрос поступил с ошибкой формата — повторная попытка сохранена в истории.",
"Check your understanding": "Проверьте понимание",
"Open": "Открыть",
"Answer": "Ответить",
"I don't know": "Не знаю",
"Type your own answer…": "Введите свой вариант ответа…",
"Confidence": "Уверенность",
"Confidence — the probability your selected answer is correct": "Уверенность — вероятность того, что выбранный ответ верен",
"{v0} percent": "{v0} процентов",
"The probability your selected answer is correct — 50% is a coin flip. Adjust with ←/→.": "Вероятность того, что выбранный ответ верен. 50% — как подбросить монету. Меняйте значение клавишами ←/→.",
"Your answer": "Ваш ответ",
"Type your answer…": "Введите ответ…",
"Why this answer? Whatever comes to mind.": "Почему вы так ответили? Запишите, что приходит в голову.",
"Submit answer": "Отправить ответ",
"Thinking while reading": "Размышления при чтении",
"Whatever comes to mind as you read.": "Записывайте всё, что приходит в голову во время чтения.",
"Aborted": "Прервано",
"The turn stream body is empty.": "Поток ответа пуст.",
"A turn is already running — try again in a moment.": "Ответ уже формируется — попробуйте чуть позже.",
"The dismissal failed ({v0}).": "Не удалось пропустить ({v0}).",
"Session Find": "Поиск по занятию",
"Find in session": "Найти в занятии",
"Find in session…": "Найти в занятии…",
"Previous match": "Предыдущее совпадение",
"Previous match (Shift+Enter)": "Предыдущее совпадение (Shift+Enter)",
"Next match": "Следующее совпадение",
"Next match (Enter)": "Следующее совпадение (Enter)",
"Close Find": "Закрыть поиск",
"Close Find (Esc)": "Закрыть поиск (Esc)",
"Diagram focus": "Просмотр диаграммы",
"Diagram": "Диаграмма",
"from this lesson": "из этого занятия",
"Fit diagram to view": "Вписать диаграмму в окно",
"Rendering diagram…": "Построение диаграммы…",
"Strong": "Основная",
"The main model for your sessions": "Основная модель для ваших занятий",
"Cheap": "Экономичная",
"The assistants that research, build demos, and grade your work": "Помощники для исследований, демонстраций и проверки вашей работы",
"Vision": "С поддержкой изображений",
"The assistants that draw and read PDFs": "Помощники для создания иллюстраций и чтения PDF",
"Your version is in use": "Используется ваша версия",
"Teacher": "Преподаватель",
"Prober": "Агент диагностики",
"Shared": "Общие",
"Used by both agents": "Используется обоими агентами",
"Catalog unavailable": "Каталог недоступен",
"Save failed: {v0}": "Не удалось сохранить: {v0}",
"Saved": "Сохранено",
"Delete failed: {v0}": "Не удалось удалить: {v0}",
"Deleted": "Удалено",
"✓ key": "✓ ключ есть",
"· no key": "· нет ключа",
"prompt {v0}": "инструкции {v0}",
"Hide text": "Скрыть текст",
"Show text": "Показать текст",
"Source": "Исходный текст",
"The original instructions": "Исходные инструкции",
"Override": "Своя версия",
"Your version of the instructions": "Ваша версия инструкций",
"Add your own instructions…": "Добавьте свои инструкции…",
"Save": "Сохранить",
"Models": "Модели",
"{v0} model": "Модель: {v0}",
"{v0} thinking": "Уровень рассуждения: {v0}",
"Runs every agent of this tier": "Используется всеми агентами этого уровня",
"Tools": "Инструменты",
"Graph tools": "Инструменты графа",
"Lets the tutor consult your knowledge map between sessions": "Позволяет преподавателю обращаться к вашей карте знаний между занятиями",
"Graph is disabled": "Граф отключён",
"graph tools": "инструменты графа",
"Quizzing": "Проверка понимания",
"How both tutors check your understanding": "Как оба преподавателя проверяют ваше понимание",
"Quizzing mechanics": "Механика проверок",
"Multiple choice": "Выбор варианта",
"Free recall": "Воспроизведение без подсказок",
"Tool · {v0}": "Инструмент · {v0}",
"tool {v0}": "инструмент {v0}",
"Search engine": "Поисковая система",
"Automatic": "Автоматически",
"Compaction threshold": "Порог сжатия контекста",
"{v0} tok": "{v0} токенов",
"compaction threshold": "порог сжатия контекста",
"System prompts": "Системные инструкции",
"Loading prompt segments…": "Загрузка разделов инструкций…",
"Coming soon": "Скоро появится",
"Dark": "Тёмная",
"Background": "Фон",
"Empty = {v0}": "Пусто = {v0}",
"background hex": "цвет фона в HEX",
"Reading font": "Шрифт текста",
"Choose from your installed fonts": "Выберите из установленных шрифтов",
"Common families — installed fonts could not be read": "Стандартные шрифты — не удалось прочитать список установленных",
"Common families — your installed list needs the desktop app": "Стандартные шрифты — список установленных доступен в настольном приложении",
"Your installed fonts": "Установленные шрифты",
"Reading font size": "Размер текста",
"prose font size": "размер текста",
"Session names in serif": "Названия занятий шрифтом с засечками",
"session names serif": "названия занятий с засечками",
"Heading weight": "Насыщенность заголовков",
"heading weight": "насыщенность заголовков",
"Bold color": "Цвет жирного текста",
"Empty = same as text": "Пусто = цвет основного текста",
"same as text": "цвет основного текста",
"bold color": "цвет жирного текста",
"Italic color": "Цвет курсива",
"italic color": "цвет курсива",
"Off = the graph stops growing": "Выкл. — граф перестанет пополняться",
"extraction enabled": "извлечение знаний включено",
"Graph rendering": "Отображение графа",
"Off = home shows the chains view": "Выкл. — на главной отображаются цепочки",
"graph rendering enabled": "отображение графа включено",
"Extraction chunk budget": "Размер фрагмента для извлечения знаний",
"extraction chunk budget": "размер фрагмента для извлечения знаний",
"LearnHarness stores your ChatGPT account details and credentials in an unencrypted local file on this device. When this connection is used, relevant conversation context, learning material, and tool results are sent directly to OpenAI. ChatGPT plan limits apply; any additional credits are authorized and managed in ChatGPT, not LearnHarness.": "LearnHarness сохраняет данные вашей учётной записи ChatGPT и учётные данные для входа в незашифрованном локальном файле на этом устройстве. При использовании подключения соответствующий контекст разговора, учебные материалы и результаты инструментов отправляются напрямую в OpenAI. Действуют ограничения тарифа ChatGPT; разрешения на использование дополнительных кредитов и управление ими выполняются в ChatGPT.",
"Background work counts toward your ChatGPT plan usage.": "Фоновая работа расходует лимиты вашего тарифа ChatGPT.",
"Allow background use for all ChatGPT accounts": "Разрешить фоновое использование для всех учётных записей ChatGPT",
"Account disconnected locally. Remote revocation could not be confirmed.": "Учётная запись отключена на этом устройстве. Подтвердить отзыв доступа на стороне сервиса не удалось.",
"Disconnect account?": "Отключить учётную запись?",
"This removes the saved account and its credentials from this device.": "Сохранённая учётная запись и данные для входа будут удалены с этого устройства.",
"The ChatGPT connection could not be changed. Try again.": "Не удалось изменить подключение ChatGPT. Повторите попытку.",
"The desktop connection is unavailable. Retry when the app is ready.": "Подключение настольного приложения недоступно. Повторите попытку, когда приложение будет готово.",
"Disconnect account": "Отключить учётную запись",
"Saved ChatGPT account": "Сохранённая учётная запись ChatGPT",
"Add another account or workspace": "Добавить другую учётную запись или рабочее пространство",
"Manage…": "Управление…",
"OpenAI privacy": "Конфиденциальность OpenAI",
"Manage usage": "Использование лимитов",
"ChatGPT sign-in is available only in the desktop app. API-key connections remain available here.": "Вход в ChatGPT доступен только в настольном приложении. Подключение через API-ключ доступно и здесь.",
"Waiting for browser sign-in…": "Ожидание входа через браузер…",
"Manage ChatGPT account": "Управление учётной записью ChatGPT",
"Add accounts": "Добавить учётные записи",
"Disconnect": "Отключить",
"Sign in again": "Войти снова",
"Continue with ChatGPT": "Продолжить с ChatGPT",
"ChatGPT account": "Учётная запись ChatGPT",
"Connect": "Подключить",
"Enable background use": "Разрешить фоновое использование",
"Graph": "Граф",
"Fixed — part of this screen": "Не изменяется — часть этого экрана",
"Press keys…": "Нажмите клавиши…",
"Rebind": "Изменить сочетание",
"Reset": "Сбросить",
"Stored on this device (lh:keymap) — overrides only.": "На этом устройстве (lh:keymap) хранятся только изменённые сочетания.",
"Reset all to defaults": "Сбросить все сочетания",
"not set": "не задан",
" · env": " · из окружения",
"Paste key…": "Вставьте ключ…",
"{v0} key": "Ключ {v0}",
"key ↗": "ключ ↗",
"+ Add provider key": "+ Добавить ключ провайдера",
"Loading keys…": "Загрузка ключей…",
"Model API keys": "API-ключи моделей",
"Subscriptions": "Подписки",
"Dictation": "Диктовка",
"Search": "Поиск",
"Search models…": "Поиск моделей…",
"Loading models…": "Загрузка моделей…",
"No models match.": "Подходящих моделей нет.",
"Subscription connection": "Подключение по подписке",
"USD per 1M tokens in/out": "USD за 1 млн входных/выходных токенов",
"{v0} (subscription)": "{v0} (подписка)",
"Search providers…": "Поиск провайдеров…",
"No providers match.": "Подходящих провайдеров нет.",
"Search fonts…": "Поиск шрифтов…",
"Loading fonts…": "Загрузка шрифтов…",
"Use “": "Использовать «",
"Providers": "Провайдеры",
"Agent": "Агент",
"Appearance": "Оформление",
"Keyboard": "Клавиатура",
"Stored on this device": "Хранится на этом устройстве",
"General": "Общие",
"Automatic updates arrive in an upcoming release": "Автоматические обновления появятся в одном из следующих выпусков",
"status {v0}": "статус {v0}",
"Settings": "Настройки",
"Close settings": "Закрыть настройки",
"Settings sections": "Разделы настроек",
"Loading settings…": "Загрузка настроек…",
"Settings could not be loaded — refresh to retry.": "Не удалось загрузить настройки — обновите страницу и повторите попытку.",
"Auto-update": "Автообновление",
"auto update": "автообновление",
"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.": "Вход в Copilot через Pi может открыть доступ к дополнительным моделям, для которых политика ещё не настроена. Модели, явно отключённые политикой, не включаются. Ограничения организации продолжают действовать.",
"Allow LearnHarness helper agents and automatic follow-ups to use this connection.": "Разрешить вспомогательным агентам LearnHarness и автоматическим продолжениям использовать это подключение.",
"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}. Данные для входа хранятся локально в текстовом файле, доступном только владельцу. Действуют условия провайдера, политика использования данных и ограничения тарифа. Это подключение никогда не переключается на API-ключ.",
"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} от LearnHarness? Работа через это подключение будет остановлена, а сохранённые данные для входа удалены с устройства. Подписка не отменится, и доступ на стороне провайдера не будет отозван. Сохранённые модели будут недоступны, пока вы не восстановите подключение или не выберете другие.",
"The connection could not be changed. Try again.": "Не удалось изменить подключение. Повторите попытку.",
"Sign-in did not complete. Try connecting again.": "Вход не завершён. Попробуйте подключиться снова.",
"{v0} connection": "Подключение {v0}",
"Allow background use": "Разрешить фоновое использование",
"Subscription sign-in requires the desktop app's private server connection. API-key connections remain available here.": "Вход по подписке требует защищённого подключения к серверу настольного приложения. Подключение через API-ключ доступно и здесь.",
"Device code:": "Код устройства:",
"The browser could not be opened. Try again.": "Не удалось открыть браузер. Повторите попытку.",
"Open browser": "Открыть браузер",
"Select…": "Выберите…",
"Continue": "Продолжить",
"Add a provider key": "Добавить ключ провайдера",
"Prober, teacher": "Агент диагностики, преподаватель",
"Researcher, demo builder, extractor, auditor": "Исследователь, создатель демонстраций, извлечение знаний, проверка",
"Visual maker + PDF reader": "Создание иллюстраций и чтение PDF",
"Choose your AI provider and paste its API key. You’ll pick your models next.": "Выберите провайдера ИИ и вставьте его API-ключ. Затем вы выберете модели.",
"Provider": "Провайдер",
"Pick a provider…": "Выберите провайдера…",
"Saving…": "Сохранение…",
"Save key": "Сохранить ключ",
"Save failed. Check your connection and try again.": "Не удалось сохранить. Проверьте подключение и повторите попытку.",
"Close setup": "Закрыть настройку",
"Model catalog could not be loaded. Check the connection and retry.": "Не удалось загрузить каталог моделей. Проверьте подключение и повторите попытку.",
"Pick the model for each tier. All three are required before chats can start.": "Выберите модель для каждого уровня. Все три нужны, чтобы начать занятие.",
"Pick a model…": "Выберите модель…",
"No image-capable models. Add a provider key with access to a vision model.": "Нет моделей с поддержкой изображений. Добавьте ключ провайдера с доступом к такой модели.",
"Save models": "Сохранить модели",
"Open sidebar": "Открыть боковую панель",
"just now": "только что",
"{v0}m ago": "{v0} мин назад",
"{v0}h ago": "{v0} ч назад",
"{v0}d ago": "{v0} дн. назад",
"Compacting context · {v0}": "Сжатие контекста · {v0}",
"Waiting for input · {v0}": "Ожидание ввода · {v0}",
"Problem waiting · {v0}": "Ожидание решения · {v0}",
"Working…": "Выполняется…",
"Practice session": "Практическое занятие",
"Home": "Главная",
"Close sidebar": "Закрыть боковую панель",
"create a new teaching session": "создать учебное занятие",
"create a new practice session": "создать практическое занятие",
"Search sessions": "Поиск занятий",
"Search…": "Поиск…",
"All": "Все",
"No sessions match.": "Подходящих занятий нет.",
"No sessions yet.": "Занятий пока нет.",
"Rename": "Переименовать",
"Export as Markdown…": "Экспорт в Markdown…",
"Delete…": "Удалить…",
"Session stats": "Статистика занятия",
"Context (last call)": "Контекст (последний запрос)",
"Usage": "Использование",
"Total cost": "Общая стоимость",
"Manage ChatGPT usage": "Лимиты ChatGPT",
"Unpriced turns": "Запросы без цены",
"Turns": "Запросы",
"is ready": "готова к установке",
"— restart to finish installing.": "— перезапустите приложение, чтобы завершить установку.",
"Restart": "Перезапустить",
"Toggle dictation": "Включить или остановить диктовку",
"Open settings": "Открыть настройки",
"Cycle thinking level": "Следующий уровень рассуждения",
"Toggle sessions sidebar": "Показать или скрыть список занятий",
"Toggle all your thinking": "Показать или скрыть все ваши рассуждения",
"Find": "Найти",
"Shortcut cheat sheet": "Справка по сочетаниям клавиш",
"Question · focus thinking ↔ rows": "Вопрос · переключить фокус между рассуждениями и вариантами",
"Question · next row": "Вопрос · следующий вариант",
"Question · previous row": "Вопрос · предыдущий вариант",
"Question · pick row": "Вопрос · выбрать вариант",
"Question · submit": "Вопрос · отправить ответ",
"Space": "Пробел",
"Speech-to-text key for dictation — needs ≥ Member permission in the Deepgram console to mint tokens.": "Ключ распознавания речи для диктовки. Для выпуска токенов нужны права уровня Member или выше в консоли Deepgram.",
"Web-search engine for the researcher sub-agent (AI-native search API). Setting the key activates it — no engine toggle needed.": "Поисковая система для агента-исследователя (поисковый API для ИИ). Добавление ключа включает её автоматически.",
"Web-search engine for the researcher sub-agent — the alternative to Tavily. Setting the key activates it.": "Поисковая система для агента-исследователя — альтернатива Tavily. Добавление ключа включает её автоматически.",
"{v0} must be at least {v1} characters with no whitespace": "Ключ {v0} должен содержать не менее {v1} символов без пробелов",
"{v0} must start with \"tvly-\" (Tavily's key format)": "Ключ {v0} должен начинаться с «tvly-» (формат ключей Tavily)",
"unknown key \"{v0}\" (supported: {v1})": "Неизвестный ключ «{v0}» (поддерживаются: {v1})",
"{v0} must be at least 12 characters with no whitespace": "Ключ {v0} должен содержать не менее 12 символов без пробелов",
"Dictate": "Диктовать",
"of": "из",
"correct": "верно",
"incorrect": "неверно",
"failed": "ошибка",
"complete": "завершено",
"sources": "источников",
"unlabeled": "без названия",
"notes": "заметки",
"tokens": "токенов",
"unpriced": "цена неизвестна",
"out": "выход",
"steps": "шагов",
"custom": "свой вариант",
"needed": "нужно выбрать",
"now": "сейчас",
"starting": "запуск",
"done": "готово",
"queued": "в очереди",
"running": "выполняется",
"extracting": "извлечение знаний",
"auditing": "проверка",
"merging": "объединение",
"delivery failed": "ошибка доставки",
"waiting": "ожидание",
"waiting for a slot": "ожидание свободного места",
"building demo": "создание демонстрации",
"rendering figure": "создание иллюстрации",
"reading PDF": "чтение PDF",
"final verification": "финальная проверка",
"off": "выкл.",
"minimal": "минимальный",
"low": "низкий",
"medium": "средний",
"high": "высокий",
"xhigh": "очень высокий",
"max": "максимальный",
"No thinking levels.": "Уровни рассуждения недоступны.",
"Learner": "Учащийся",
"Quiz": "Проверка",
"Recall check": "Проверка воспроизведения по памяти",
"(no question text)": "(текст вопроса отсутствует)",
"The learner skipped this check.": "Проверка пропущена.",
"The learner did not know.": "Ответ «Не знаю».",
"The learner answered correctly.": "Учащийся ответил верно.",
"The learner answered incorrectly.": "Учащийся ответил неверно.",
"The learner answered in their own words: {answer}": "Ответ своими словами: {answer}",
"The learner answered in his own words: {answer}": "Ответ своими словами: {answer}",
"Their thinking: {thinking}": "Ход рассуждений: {thinking}",
"his thinking: {thinking}": "Ход рассуждений: {thinking}",
"(no statement)": "(условие отсутствует)",
"The learner skipped this problem.": "Задача пропущена.",
"The learner answered: {answer}": "Ответ: {answer}",
"The learner chose: {answer}": "Выбранные варианты: {answer}",
"The learner skipped this question.": "Вопрос пропущен.",
"Q:": "Вопрос:",
"Tool": "Инструмент",
"{tool} result delivered": "Результат инструмента {tool} получен",
"No model configured — set up your models first": "Модель не выбрана — сначала настройте модели",
"No {v0} key is set — add one in Settings → Keys": "Ключ {v0} не задан — добавьте его в разделе Настройки → Провайдеры",
"Unknown chat": "Занятие не найдено",
"Invalid JSON body": "Некорректное тело JSON",
"Failed to fetch": "Не удалось выполнить запрос",
"Network error": "Ошибка сети",
"Network error. Try again.": "Ошибка сети. Повторите попытку.",
"Failed to attach to the running turn.": "Не удалось подключиться к текущему ответу.",
"The send failed ({v0}).": "Не удалось отправить ({v0}).",
"The steer failed ({v0}).": "Не удалось передать указание ({v0}).",
"Save failed ({v0})": "Не удалось сохранить ({v0})",
"Delete failed ({v0})": "Не удалось удалить ({v0})",
"settings rejected ({v0})": "Настройки отклонены ({v0})",
"A turn is already running for this session": "Ответ для этого занятия уже формируется",
"This file is already being imported. Try again when it finishes.": "Этот файл уже импортируется. Дождитесь завершения и повторите попытку.",
"Notes import did not complete": "Импорт заметок не завершён",
"Deepgram grant returned no token": "Deepgram не вернул токен",
"Deepgram unreachable": "Deepgram недоступен",
"Deepgram token grant failed ({v0}): {v1}": "Не удалось получить токен Deepgram ({v0}): {v1}",
"token route {v0}": "Ошибка получения токена ({v0})",
"No files": "Файлы не выбраны",
"Empty title": "Название не может быть пустым",
"Title must not be empty": "Название не может быть пустым",
"Copied": "Скопировано",
"Copy": "Копировать",
"Download": "Скачать",
"No results": "Ничего не найдено",
"Select": "Выбрать",
"Previous": "Назад",
"Next": "Вперёд",
"Untitled session": "Занятие без названия",
"{count} messages unextracted": {
"one": "{count} сообщение не обработано",
"few": "{count} сообщения не обработаны",
"many": "{count} сообщений не обработано",
"other": "{count} сообщения не обработано"
},
"· read {count} pages": {
"one": "· прочитана {count} страница",
"few": "· прочитаны {count} страницы",
"many": "· прочитано {count} страниц",
"other": "· прочитано {count} страницы"
},
"{count} sources": {
"one": "{count} источник",
"few": "{count} источника",
"many": "{count} источников",
"other": "{count} источника"
},
"{count} propositions": {
"one": "{count} утверждение",
"few": "{count} утверждения",
"many": "{count} утверждений",
"other": "{count} утверждения"
},
"{count} chains": {
"one": "{count} цепочка",
"few": "{count} цепочки",
"many": "{count} цепочек",
"other": "{count} цепочки"
},
"{count} teaching sessions": {
"one": "{count} учебное занятие",
"few": "{count} учебных занятия",
"many": "{count} учебных занятий",
"other": "{count} учебного занятия"
},
"{count} practice sessions": {
"one": "{count} практическое занятие",
"few": "{count} практических занятия",
"many": "{count} практических занятий",
"other": "{count} практического занятия"
},
"{count} imported notes": {
"one": "{count} импортированная заметка",
"few": "{count} импортированные заметки",
"many": "{count} импортированных заметок",
"other": "{count} импортированной заметки"
},
"Copy Code": "Копировать код",
"Download file": "Скачать файл",
"Download diagram": "Скачать диаграмму",
"Download diagram as SVG": "Скачать диаграмму в формате SVG",
"Download diagram as PNG": "Скачать диаграмму в формате PNG",
"Download diagram as MMD": "Скачать диаграмму в формате MMD",
"View fullscreen": "Открыть на весь экран",
"Exit fullscreen": "Выйти из полноэкранного режима",
"Copy table as Markdown": "Копировать таблицу в формате Markdown",
"Copy table as CSV": "Копировать таблицу в формате CSV",
"Copy table as TSV": "Копировать таблицу в формате TSV",
"Download table": "Скачать таблицу",
"Download table as CSV": "Скачать таблицу в формате CSV",
"Download table as Markdown": "Скачать таблицу в формате Markdown",
"Image not available": "Изображение недоступно",
"Download image": "Скачать изображение",
"Open external link?": "Открыть внешнюю ссылку?",
"You're about to visit an external website.": "Вы собираетесь перейти на внешний сайт.",
"Copy link": "Копировать ссылку",
"Open link": "Открыть ссылку",
"Other": "Другой вариант",
"Light": "Светлая",
"delivered": "доставлено",
"No such session": "Такого занятия нет",
"A turn is running in this session — try again when it finishes": "В этом занятии формируется ответ — дождитесь завершения и повторите попытку",
"Background jobs are still running in this session — try again when they finish": "В этом занятии ещё выполняются фоновые задачи — дождитесь их завершения",
"The session's extraction is still winding down — try again in a moment": "Извлечение знаний из занятия ещё завершается — попробуйте чуть позже",
"Not found": "Не найдено",
"title must be a non-empty string": "Название должно быть непустой строкой",
"unknown key \"{v0}\"": "Неизвестный ключ «{v0}»",
"body must be JSON": "Тело запроса должно быть в формате JSON",
"body must be {value: string}": "Тело запроса должно содержать строковое поле value",
"model must be {provider, id}": "Модель должна содержать поля provider и id",
"thinkingLevel must be a string": "Уровень рассуждения должен быть строкой",
"autoUpdate must be a boolean": "Параметр автообновления должен быть логическим значением",
"{v0} must be \"en\" or \"ru\"": "Параметр {v0} должен иметь значение «en» или «ru»",
"sttLanguage must be \"en\", \"ru\", or \"multi\"": "Параметр sttLanguage должен иметь значение «en», «ru» или «multi»",
"{v0} must be an object": "Параметр {v0} должен быть объектом",
"{v0}.model must be {provider, id}": "Поле {v0}.model должно содержать provider и id",
"{v0}.thinkingLevel must be a string": "Поле {v0}.thinkingLevel должно быть строкой",
"{v0}.{v1} must be a boolean": "Поле {v0}.{v1} должно быть логическим значением",
"models must be an object": "Поле models должно быть объектом",
"unknown role \"{v0}\" (supported: {v1})": "Неизвестная роль «{v0}» (поддерживаются: {v1})",
"unknown tool \"{v0}\" (supported: {v1})": "Неизвестный инструмент «{v0}» (поддерживаются: {v1})",
"search must be an object": "Поле search должно быть объектом",
"search.engine must be \"tavily\" or \"brave\"": "Поисковая система должна быть «tavily» или «brave»",
"graphTools must be an object": "Поле graphTools должно быть объектом",
"graphTools.enabled must be a boolean": "Поле graphTools.enabled должно быть логическим значением",
"{v0} must be a #hex color or null": "Поле {v0} должно содержать цвет в формате #hex или null",
"mode must be \"dark\" or \"light\"": "Режим должен быть «dark» или «light»",
"fontFamily must be a non-empty string or null": "Название шрифта должно быть непустой строкой или null",
"proseFontSize must be an integer between 12 and 24": "Размер текста должен быть целым числом от 12 до 24",
"sessionNameSerif must be a boolean": "Параметр шрифта названий занятий должен быть логическим значением",
"mdRendering must be an object": "Поле mdRendering должно быть объектом",
"mdRendering.headingWeight must be an integer between 100 and 900": "Насыщенность заголовков должна быть целым числом от 100 до 900",
"enabled must be a boolean": "Поле enabled должно быть логическим значением",
"rendering must be a boolean": "Поле rendering должно быть логическим значением",
"extractionChunkTokens must be a number": "Размер фрагмента для извлечения должен быть числом",
"extractionChunkTokens must be at least {v0} (smaller batches waste the per-run graph overhead)": "Размер фрагмента для извлечения должен быть не менее {v0} токенов (при меньшем размере накладные расходы слишком велики)",
"unknown slot \"{v0}\" (supported: {v1})": "Неизвестный уровень «{v0}» (поддерживаются: {v1})",
"slot \"{v0}\" must set a model (thinkingLevel alone cannot run — absent slot = unset)": "Для уровня «{v0}» необходимо выбрать модель: одного уровня рассуждения недостаточно",
"thresholdTokens must be a number": "Порог сжатия контекста должен быть числом",
"thresholdTokens must be at least {v0} (compaction keeps a ~20k-token recent tail — smaller budgets would churn it)": "Порог сжатия должен быть не менее {v0} токенов: сжатие сохраняет около 20 тысяч последних токенов",
"The tool call failed.": "Вызов инструмента завершился ошибкой.",
"Aborted by learner": "Прервано пользователем",
"starting research": "запуск исследования",
"starting demo": "запуск демонстрации",
"starting figure": "запуск создания иллюстрации",
"building visual": "создание иллюстрации",
"render_svg: inspecting final render": "render_svg: проверка итогового изображения",
"render_svg: inspecting render": "render_svg: проверка изображения",
"render_svg: render error": "render_svg: ошибка отрисовки",
"edit_svg: applied": "edit_svg: изменения применены",
"web_search: \"{v0}\"": "Поиск в интернете: «{v0}»",
"web_fetch: {v0}": "Загрузка страницы: {v0}",
"Stopping…": "Остановка…",
"The demo could not be built.": "Не удалось создать демонстрацию.",
"The figure could not be drawn.": "Не удалось создать иллюстрацию.",
"untagged": "без привязки",
"yes": "да",
"no": "нет",
"none": "нет",
" · select all that apply": " · выберите все подходящие варианты",
"recall": "воспроизведение по памяти",
"discrimination": "различение",
"composition": "составление решения",
"solved": "решено",
"solvedWithHints": "решено с подсказками",
"movedOn": "переход дальше",
"surrendered": "решение запрошено",
"dismissed": "пропущено",
"cue": "не хватает подсказки",
"execution": "ошибка выполнения",
"careless": "невнимательность",
"structural": "ошибка в понимании структуры",
"pending": "ожидание",
"consent": "подтверждение",
"proposition": "утверждение",
"propositions": "утверждения",
"node": "узел",
"nodes": "узлы",
"chain": "цепочка",
"chains": "цепочки",
" · {v0} sources": " · источников: {v0}",
"{v0}s elapsed": "прошло {v0} с",
"{count} nodes": {
"one": "{count} узел",
"few": "{count} узла",
"many": "{count} узлов",
"other": "{count} узла"
},
"The saved connection could not be read or written safely. Reopen the app or reconnect after resolving the credential-file problem.": "Не удалось безопасно прочитать или сохранить подключение. Устраните проблему с файлом учётных данных, затем откройте приложение заново или подключитесь повторно.",
"The connection could not be refreshed. Retry or reconnect in Providers.": "Не удалось обновить подключение. Повторите попытку или подключитесь заново в разделе «Провайдеры».",
"Sign-in cancelled.": "Вход отменён.",
"This subscription connection is unavailable. Reconnect or explicitly choose another connection.": "Это подключение по подписке недоступно. Подключитесь заново или выберите другое подключение.",
"Background use is off for this connection. Automatic follow-up remains deferred in its queue.": "Фоновое использование этого подключения отключено. Автоматическое продолжение остаётся в очереди.",
"The connection or model catalog changed. Select an available model and retry.": "Подключение или каталог моделей изменились. Выберите доступную модель и повторите попытку.",
"This sign-in action is no longer available.": "Это действие для входа больше недоступно.",
"ChatGPT sign-in cancelled.": "Вход в ChatGPT отменён.",
"ChatGPT sign-in timed out. Try again.": "Время ожидания входа в ChatGPT истекло. Повторите попытку.",
"ChatGPT credentials could not be read or saved. Check local file access, then reconnect before making requests.": "Не удалось прочитать или сохранить данные для входа в ChatGPT. Проверьте доступ к локальным файлам и подключитесь заново, прежде чем отправлять запросы.",
"ChatGPT sign-in could not be verified. Select the intended saved account and try again.": "Не удалось подтвердить вход в ChatGPT. Выберите нужную сохранённую учётную запись и повторите попытку.",
"ChatGPT plan permission is missing. Reconnect and authorize ChatGPT plan use.": "Нет разрешения на использование тарифа ChatGPT. Подключитесь заново и предоставьте разрешение.",
"This ChatGPT connection is disconnected. Reconnect in Providers.": "Подключение ChatGPT отключено. Подключитесь заново в разделе «Провайдеры».",
"ChatGPT background use is off in this app. Enable Allow background use for all ChatGPT accounts in Providers, or explicitly select an API-key model for that workload.": "Фоновое использование ChatGPT в приложении отключено. В разделе «Провайдеры» включите «Разрешить фоновое использование для всех учётных записей ChatGPT» или выберите для этой задачи модель с API-ключом.",
"The ChatGPT account model catalog is unavailable. Retry the connection; no API-key fallback was used.": "Каталог моделей учётной записи ChatGPT недоступен. Повторите подключение. Переключения на API-ключ не было.",
"The ChatGPT session expired. Reconnect in Providers.": "Сеанс ChatGPT истёк. Подключитесь заново в разделе «Провайдеры».",
"ChatGPT could not be reached. Check your connection and try again.": "Не удалось связаться с ChatGPT. Проверьте подключение и повторите попытку.",
"A ChatGPT sign-in is already pending.": "Вход в ChatGPT уже выполняется.",
"ChatGPT sign-in is unavailable in browser-only mode.": "Вход в ChatGPT недоступен в браузерной версии.",
"ChatGPT sign-in failed. Try again; no API-key fallback was used.": "Не удалось войти в ChatGPT. Повторите попытку. Переключения на API-ключ не было.",
"The selected model is unavailable.": "Выбранная модель недоступна.",
"ChatGPT plan usage limit reached. Manage usage at https://chatgpt.com/settings/usage. No API-key fallback was used.": "Достигнут лимит тарифа ChatGPT. Управление лимитами: https://chatgpt.com/settings/usage. Переключения на API-ключ не было.",
"ChatGPT plan permission is missing. Reconnect in Providers and authorize plan use. No API-key fallback was used.": "Нет разрешения на использование тарифа ChatGPT. Подключитесь заново в разделе «Провайдеры» и предоставьте разрешение. Переключения на API-ключ не было.",
"Your ChatGPT session expired. Reconnect in Providers. No API-key fallback was used.": "Сеанс ChatGPT истёк. Подключитесь заново в разделе «Провайдеры». Переключения на API-ключ не было.",
"ChatGPT background use is off in this app. Enable Allow background use for all ChatGPT accounts in Providers to run this work. No API-key fallback was used.": "Фоновое использование ChatGPT отключено. Для выполнения этой работы включите «Разрешить фоновое использование для всех учётных записей ChatGPT» в разделе «Провайдеры». Переключения на API-ключ не было.",
"This ChatGPT account or workspace does not permit this request. Check ChatGPT settings; reconnecting may not change eligibility.": "Эта учётная запись или рабочее пространство ChatGPT не допускает такой запрос. Проверьте настройки ChatGPT: повторное подключение может не изменить доступ.",
"ChatGPT request cancelled or connection closed. No API-key fallback was used.": "Запрос ChatGPT отменён или подключение закрыто. Переключения на API-ключ не было.",
"The selected model is unavailable for this ChatGPT account. Refresh Providers and explicitly choose an available model. No API-key fallback was used.": "Выбранная модель недоступна для этой учётной записи ChatGPT. Обновите раздел «Провайдеры» и выберите доступную модель. Переключения на API-ключ не было.",
"ChatGPT returned an incomplete response. Retry when ready; no API-key fallback was used.": "ChatGPT вернул неполный ответ. Повторите попытку, когда будете готовы. Переключения на API-ключ не было.",
"ChatGPT request failed. Check your connection and ChatGPT usage, then retry. No API-key fallback was used.": "Запрос ChatGPT завершился ошибкой. Проверьте подключение и лимиты ChatGPT, затем повторите попытку. Переключения на API-ключ не было.",
"{v0} background use is off in this app. Enable it in Providers to run this work. No API-key fallback was used.": "Фоновое использование {v0} отключено. Для выполнения этой работы включите его в разделе «Провайдеры». Переключения на API-ключ не было.",
"{v0} usage limit reached. Check your provider account and try again later. No API-key fallback was used.": "Достигнут лимит использования {v0}. Проверьте учётную запись провайдера и повторите попытку позже. Переключения на API-ключ не было.",
"{v0} authorization could not be refreshed. Retry or reconnect in Providers. No API-key fallback was used.": "Не удалось обновить авторизацию {v0}. Повторите попытку или подключитесь заново в разделе «Провайдеры». Переключения на API-ключ не было.",
"{v0} request cancelled or connection closed. No API-key fallback was used.": "Запрос {v0} отменён или подключение закрыто. Переключения на API-ключ не было.",
"The selected {v0} model or connection is unavailable. Refresh Providers and explicitly choose an available model. No API-key fallback was used.": "Выбранная модель или подключение {v0} недоступны. Обновите раздел «Провайдеры» и выберите доступную модель. Переключения на API-ключ не было.",
"{v0} request failed. Check the connection, account access and provider limits, then retry. No API-key fallback was used.": "Запрос {v0} завершился ошибкой. Проверьте подключение, доступ к учётной записи и лимиты провайдера, затем повторите попытку. Переключения на API-ключ не было.",
"GitHub Enterprise URL/domain (blank for github.com)": "URL или домен GitHub Enterprise (оставьте пустым для github.com)",
"Enabling models...": "Подключение моделей…",
"Waiting for authorization...": "Ожидание авторизации…",
"no search engine configured — set a Tavily or Brave search API key": "Поисковая система не настроена — добавьте API-ключ Tavily или Brave Search",
"search engine is set to \"{v0}\" but its API key is missing — set the {v1} key": "Выбрана поисковая система «{v0}», но её API-ключ отсутствует — добавьте ключ {v1}",
"search engine: {v0} (chosen)": "Поисковая система: {v0} (выбрана вручную)",
"search engine: {v0} (auto)": "Поисковая система: {v0} (автоматически)",
"update": "обновление",
"Fast": "Быстро",
"{v0} fast mode": "Быстрый режим: {v0}",
"Fast mode uses more ChatGPT allowance.": "Быстрый режим быстрее расходует лимит ChatGPT.",
"Fast mode is available only for ChatGPT models.": "Быстрый режим доступен только для моделей ChatGPT.",
"Could not save model settings": "Не удалось сохранить настройки моделей",
"Fast mode is unavailable for this ChatGPT model or account. Turn off Fast mode or choose another model.": "Быстрый режим недоступен для этой модели или учётной записи ChatGPT. Отключите быстрый режим или выберите другую модель.",
"Speech recognition model": "Модель распознавания речи",
"Unknown speech recognition model": "Неизвестная модель распознавания речи",
"Invalid speech recognition language": "Недопустимый язык распознавания речи",
"Invalid dictation audio": "Некорректная аудиозапись",
"Recording exceeds OpenRouter's 25 MB audio limit": "Запись превышает ограничение OpenRouter в 25 МБ",
"Could not read dictation audio": "Не удалось прочитать аудиозапись",
"Transcription cancelled": "Распознавание отменено",
"Dictation needs an OpenRouter key": "Для диктовки нужен ключ OpenRouter",
"OpenRouter rejected the API key": "OpenRouter отклонил API-ключ",
"OpenRouter credits are insufficient": "Недостаточно средств на балансе OpenRouter",
"OpenRouter rate limit reached; try again later": "Превышен лимит запросов OpenRouter. Попробуйте позже",
"OpenRouter transcription failed ({v0})": "Ошибка распознавания OpenRouter ({v0})",
"OpenRouter returned an invalid transcript": "OpenRouter вернул некорректный результат распознавания",
"OpenRouter transcription timed out": "Время ожидания распознавания OpenRouter истекло",
"OpenRouter transcription is unavailable": "Распознавание через OpenRouter недоступно",
"Could not finish microphone capture": "Не удалось завершить запись с микрофона",
"Transcribing…": "Распознавание…",
"Speech recognition": "Распознавание речи",
"No preference": "Без предпочтений"
}

Some files were not shown because too many files have changed in this diff Show More