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

This commit is contained in:
2026-10-25 17:18:44 +03:00
parent b277ee3e0f
commit a50ca4b867
93 changed files with 3261 additions and 904 deletions
+33 -29
View File
@@ -1,4 +1,7 @@
"use client";
import { translator, type Language } from "@/src/core";
import { useI18n } from "@/components/i18n";
import { createContext, useCallback, useContext, useEffect, useMemo, useRef, useState } from "react";
import { ActivityIcon, BotIcon, FileTextIcon, PenToolIcon, PlayIcon, SearchIcon, SquareIcon, WaypointsIcon } from "lucide-react";
@@ -257,12 +260,13 @@ export function toBlobs(feed: ActivityFeed): ActivityBlob[] {
}
/** Elapsed as a compact ticking string: 45s / 3m 04s / 1h 12m. */
export function formatElapsed(ms: number): string {
export function formatElapsed(ms: number, language: Language = "en"): string {
const t = translator(language);
const s = Math.max(0, Math.floor(ms / 1000));
if (s < 60) return `${s}s`;
if (s < 60) return t("{v0}s", { v0: s });
const m = Math.floor(s / 60);
if (m < 60) return `${m}m ${String(s % 60).padStart(2, "0")}s`;
return `${Math.floor(m / 60)}h ${String(m % 60).padStart(2, "0")}m`;
if (m < 60) return t("{v0}m {v1}s", { v0: m, v1: String(s % 60).padStart(2, "0") });
return t("{v0}h {v1}m", { v0: Math.floor(m / 60), v1: String(m % 60).padStart(2, "0") });
}
// ─── The section (the sidebar's bottom strip) ─────────────────────────────────
@@ -289,6 +293,7 @@ const KIND_ICON: Record<JobKind, typeof SearchIcon> = {
type AbortState = "pending" | "failed";
export function ActivitySection({ feed: feedProp }: { feed?: ActivityFeed }) {
const { t } = useI18n();
const ctx = useContext(ActivityContext);
const feed = feedProp ?? ctx.feed;
const refresh = ctx.refresh;
@@ -347,9 +352,8 @@ export function ActivitySection({ feed: feedProp }: { feed?: ActivityFeed }) {
<section data-testid="activity-section" className="border-t border-border px-3 pb-3 pt-2">
<div className="mb-1 flex items-center gap-1.5 text-xs uppercase tracking-widest text-muted-foreground/70">
<ActivityIcon className="size-3" />
Background agents
{blobs.length > 0 && (
<span className="ml-auto tabular-nums" data-testid="activity-total-cost" title="Total running cost (inferred)">
{t("Background agents")}{blobs.length > 0 && (
<span className="ml-auto tabular-nums" data-testid="activity-total-cost" title={t("Total running cost (inferred)")}>
{planCostLabel(totalCost, subscriptionLabels(blobs.map((blob) => blob.model)))}
</span>
)}
@@ -389,19 +393,20 @@ function ActivityBlobRow({
abortState: AbortState | undefined;
onAbort: () => void;
}) {
const { t, language } = useI18n();
const Icon = blob.process === "extraction" ? WaypointsIcon : (blob.jobKind && KIND_ICON[blob.jobKind]) || BotIcon;
const label =
blob.process === "extraction"
? `Extraction — ${blob.phase ?? "running"}`
? t("Extraction — {v0}", { v0: t(blob.phase ?? "running") })
: blob.jobKind === "research"
? "Researcher"
? t("Researcher")
: blob.jobKind === "demo"
? "Demo builder"
? t("Demo builder")
: blob.jobKind === "visual"
? "Figure maker"
? t("Figure maker")
: blob.jobKind === "pdf"
? "PDF reader"
: "Sub-agent";
? t("PDF reader")
: t("Sub-agent");
return (
<div
data-testid={`activity-blob-${blob.process}`}
@@ -417,8 +422,8 @@ function ActivityBlobRow({
<span className="min-w-0 flex-1">
<span className="block truncate font-medium text-card-foreground">{blob.title}</span>
{blob.activity ? (
<span className="block truncate text-muted-foreground" title={blob.activity}>
{blob.activity}
<span className="block truncate text-muted-foreground" title={t(blob.activity)}>
{t(blob.activity)}
</span>
) : (
<span className="block truncate text-muted-foreground/80">{label}</span>
@@ -426,7 +431,7 @@ function ActivityBlobRow({
</span>
<span className="flex shrink-0 items-center gap-2 tabular-nums text-muted-foreground">
<span data-testid="activity-blob-cost">{planCostLabel(blob.cost, subscriptionLabel(blob.model))}</span>
<span data-testid="activity-blob-elapsed">{formatElapsed(now - blob.startedAt)}</span>
<span data-testid="activity-blob-elapsed">{formatElapsed(now - blob.startedAt, language)}</span>
</span>
</button>
{/* The explicit END always on the blob's face (SCOPE) — the popover
@@ -437,8 +442,8 @@ function ActivityBlobRow({
data-testid="activity-abort"
onClick={onAbort}
disabled={abortState !== undefined}
title={abortState === "failed" ? "Abort failed — try again" : "End this background process"}
aria-label={`End ${label} in ${blob.title}`}
title={abortState === "failed" ? t("Abort failed — try again") : t("End this background process")}
aria-label={t("End {v0} in {v1}", { v0: label, v1: blob.title })}
className="absolute right-1 top-1/2 -translate-y-1/2 rounded-md p-1 text-muted-foreground/70 transition-colors hover:bg-destructive/10 hover:text-destructive disabled:opacity-50"
>
<SquareIcon className="size-3" />
@@ -453,47 +458,46 @@ function ActivityBlobRow({
<Icon className="size-3.5 text-muted-foreground" />
<span className="font-medium">{label}</span>
<span className="ml-auto tabular-nums text-muted-foreground">
{formatElapsed(now - blob.startedAt)}
{formatElapsed(now - blob.startedAt, language)}
</span>
</div>
<div className="flex flex-col gap-1 text-muted-foreground">
<div className="truncate text-card-foreground" title={blob.title}>
{blob.title}
</div>
{blob.activity && <div className="break-words">{blob.activity}</div>}
{blob.activity && <div className="break-words">{t(blob.activity)}</div>}
<div className="flex justify-between gap-2">
<span>Cost</span>
<span>{t("Cost")}</span>
<span className="tabular-nums text-card-foreground">
{planCostLabel(blob.cost, subscriptionLabel(blob.model))}
</span>
</div>
<div className="flex justify-between gap-2">
<span>Context</span>
<span>{t("Context")}</span>
<span className="tabular-nums text-card-foreground" data-testid="activity-blob-context">
{blob.context === null
? "—"
: `${formatTokens(blob.context.used)} / ${formatTokens(blob.context.limit)}`}
: `${formatTokens(blob.context.used, language)} / ${formatTokens(blob.context.limit, language)}`}
</span>
</div>
{blob.model && (
<div className="flex justify-between gap-2">
<span>Model</span>
<span>{t("Model")}</span>
<span className="truncate text-card-foreground">{blob.model}</span>
</div>
)}
{blob.steps !== undefined && (
<div className="flex justify-between gap-2">
<span>Steps</span>
<span>{t("Steps")}</span>
<span className="tabular-nums text-card-foreground">{blob.steps}</span>
</div>
)}
<a href={`/chat/${blob.chatId}`} className="text-primary hover:underline">
Open session →
</a>
{t("Open session →")}</a>
</div>
<div className="mt-2 text-muted-foreground/80">
{abortState === "failed" && "Abort failed — try again"}
{abortState === "pending" && "Stopping…"}
{abortState === "failed" && t("Abort failed — try again")}
{abortState === "pending" && t("Stopping…")}
</div>
</div>
)}