diff --git a/apps/desktop/src/app/DesktopApp.ts b/apps/desktop/src/app/DesktopApp.ts
index e84e6d5c730d..4d27d0dd0675 100644
--- a/apps/desktop/src/app/DesktopApp.ts
+++ b/apps/desktop/src/app/DesktopApp.ts
@@ -6,6 +6,7 @@ import * as Schema from "effect/Schema";
import * as NetService from "@t3tools/shared/Net";
import * as Crypto from "effect/Crypto";
+import { i18n } from "@t3tools/shared/i18n";
import * as ElectronApp from "../electron/ElectronApp.ts";
import * as ElectronDialog from "../electron/ElectronDialog.ts";
import * as ElectronProtocol from "../electron/ElectronProtocol.ts";
@@ -131,8 +132,8 @@ const handleFatalStartupError = Effect.fn("desktop.startup.handleFatalStartupErr
const wasQuitting = yield* Ref.getAndSet(state.quitting, true);
if (!wasQuitting) {
yield* electronDialog.showErrorBox(
- "T3 Code failed to start",
- `Stage: ${stage}\n${message}${detail}`,
+ i18n.t("desktop.startup.failedTitle"),
+ i18n.t("desktop.startup.failedDetail", { stage, message, detail }),
);
}
yield* shutdown.request;
diff --git a/apps/desktop/src/backend/DesktopBackendPool.ts b/apps/desktop/src/backend/DesktopBackendPool.ts
index 2930780d4102..2890d286e327 100644
--- a/apps/desktop/src/backend/DesktopBackendPool.ts
+++ b/apps/desktop/src/backend/DesktopBackendPool.ts
@@ -93,6 +93,8 @@ import * as FileSystem from "effect/FileSystem";
import { HttpClient } from "effect/unstable/http";
import { ChildProcessSpawner } from "effect/unstable/process";
+import { i18n } from "@t3tools/shared/i18n";
+
import * as DesktopBackendConfiguration from "./DesktopBackendConfiguration.ts";
import * as DesktopBackendManager from "./DesktopBackendManager.ts";
import * as DesktopObservability from "../app/DesktopObservability.ts";
@@ -242,8 +244,8 @@ export const layer = Layer.effect(
{ reason },
);
yield* electronDialog.showErrorBox(
- "WSL backend is still unavailable",
- `${reason}\n\nT3 Code will use the Windows backend for this launch and retry WSL the next time the app starts.`,
+ i18n.t("desktop.wsl.unavailableTitle"),
+ i18n.t("desktop.wsl.unavailableMessage", { reason }),
);
yield* appSettings.applyWslWindowsFallbackInMemory;
return true;
@@ -253,8 +255,8 @@ export const layer = Layer.effect(
reason,
});
yield* electronDialog.showErrorBox(
- "WSL backend couldn't start",
- `${reason}\n\nFalling back to the Windows backend so T3 Code can open. Re-enable the WSL backend from Settings > Connections once the WSL distro is fixed.`,
+ i18n.t("desktop.wsl.couldNotStartTitle"),
+ i18n.t("desktop.wsl.couldNotStartMessage", { reason }),
);
// Fully disable the WSL backend — both flags, matching the "Switch to
// Windows" recovery path — so the manager's next restart re-resolves the
diff --git a/apps/desktop/src/window/DesktopApplicationMenu.test.ts b/apps/desktop/src/window/DesktopApplicationMenu.test.ts
index eeb0c86f031c..5d34e2fa3e9f 100644
--- a/apps/desktop/src/window/DesktopApplicationMenu.test.ts
+++ b/apps/desktop/src/window/DesktopApplicationMenu.test.ts
@@ -7,6 +7,8 @@ import * as Option from "effect/Option";
import type * as Electron from "electron";
+import { i18n } from "@t3tools/shared/i18n";
+
import * as ElectronApp from "../electron/ElectronApp.ts";
import * as ElectronDialog from "../electron/ElectronDialog.ts";
import * as ElectronMenu from "../electron/ElectronMenu.ts";
@@ -136,12 +138,14 @@ describe("DesktopApplicationMenu", () => {
yield* configureMenu(selectedAction, applicationMenuTemplate);
const template = yield* Deferred.await(applicationMenuTemplate);
- const fileMenu = template.find((item) => item.label === "File");
+ const fileMenu = template.find((item) => item.label === i18n.t("desktop.menu.file"));
assert.isDefined(fileMenu);
if (!Array.isArray(fileMenu.submenu)) {
throw new Error("Expected File menu submenu to be an array.");
}
- const settingsItem = fileMenu.submenu.find((item) => item.label === "Settings...");
+ const settingsItem = fileMenu.submenu.find(
+ (item) => item.label === i18n.t("desktop.menu.settings"),
+ );
assert.isDefined(settingsItem);
const settingsClick = settingsItem.click;
if (typeof settingsClick !== "function") {
@@ -165,7 +169,7 @@ describe("DesktopApplicationMenu", () => {
yield* configureMenu(selectedAction, applicationMenuTemplate);
const template = yield* Deferred.await(applicationMenuTemplate);
- const viewMenu = template.find((item) => item.label === "View");
+ const viewMenu = template.find((item) => item.label === i18n.t("desktop.menu.view"));
assert.isDefined(viewMenu);
if (!Array.isArray(viewMenu.submenu)) {
throw new Error("Expected View menu submenu to be an array.");
@@ -175,7 +179,7 @@ describe("DesktopApplicationMenu", () => {
viewMenu.submenu.find((item) => item.role?.toLowerCase().includes("zoom")),
);
- const zoomIn = viewMenu.submenu.find((item) => item.label === "Zoom In");
+ const zoomIn = viewMenu.submenu.find((item) => item.label === i18n.t("desktop.menu.zoomIn"));
assert.isDefined(zoomIn);
assert.equal(zoomIn.accelerator, "CmdOrCtrl+=");
if (typeof zoomIn.click !== "function") {
diff --git a/apps/desktop/src/window/DesktopApplicationMenu.ts b/apps/desktop/src/window/DesktopApplicationMenu.ts
index bf7634981f86..d84efdc4fe56 100644
--- a/apps/desktop/src/window/DesktopApplicationMenu.ts
+++ b/apps/desktop/src/window/DesktopApplicationMenu.ts
@@ -6,6 +6,8 @@ import * as Schema from "effect/Schema";
import type * as Electron from "electron";
+import { i18n } from "@t3tools/shared/i18n";
+
import { makeComponentLogger } from "../app/DesktopObservability.ts";
import * as ElectronApp from "../electron/ElectronApp.ts";
import * as ElectronDialog from "../electron/ElectronDialog.ts";
@@ -65,17 +67,17 @@ const checkForUpdatesFromMenu = Effect.gen(function* () {
if (updateState.status === "up-to-date") {
yield* electronDialog.showMessageBox({
type: "info",
- title: "You're up to date!",
- message: `T3 Code ${updateState.currentVersion} is currently the newest version available.`,
- buttons: ["OK"],
+ title: i18n.t("desktop.update.upToDateTitle"),
+ message: i18n.t("desktop.update.upToDateMessage", { version: updateState.currentVersion }),
+ buttons: [i18n.t("action.ok")],
});
} else if (updateState.status === "error") {
yield* electronDialog.showMessageBox({
type: "warning",
- title: "Update check failed",
- message: "Could not check for updates.",
- detail: updateState.message ?? "An unknown error occurred. Please try again later.",
- buttons: ["OK"],
+ title: i18n.t("desktop.update.checkFailedTitle"),
+ message: i18n.t("desktop.update.checkFailedMessage"),
+ detail: updateState.message ?? i18n.t("desktop.update.checkFailedDetail"),
+ buttons: [i18n.t("action.ok")],
});
}
}).pipe(Effect.withSpan("desktop.menu.checkForUpdates"));
@@ -90,10 +92,10 @@ const handleCheckForUpdatesMenuClick = Effect.gen(function* () {
});
yield* electronDialog.showMessageBox({
type: "info",
- title: "Updates unavailable",
- message: "Automatic updates are not available right now.",
+ title: i18n.t("desktop.update.unavailableTitle"),
+ message: i18n.t("desktop.update.unavailableMessage"),
detail: disabledReason.value,
- buttons: ["OK"],
+ buttons: [i18n.t("action.ok")],
});
return;
}
@@ -146,12 +148,12 @@ export const make = Effect.gen(function* () {
submenu: [
{ role: "about" },
{
- label: "Check for Updates...",
+ label: i18n.t("desktop.menu.checkForUpdates"),
click: checkForUpdatesClick,
},
{ type: "separator" },
{
- label: "Settings...",
+ label: i18n.t("desktop.menu.settings"),
accelerator: "CmdOrCtrl+,",
click: settingsClick,
},
@@ -169,13 +171,13 @@ export const make = Effect.gen(function* () {
template.push(
{
- label: "File",
+ label: i18n.t("desktop.menu.file"),
submenu: [
...(environment.platform === "darwin"
? []
: [
{
- label: "Settings...",
+ label: i18n.t("desktop.menu.settings"),
accelerator: "CmdOrCtrl+,",
click: settingsClick,
},
@@ -186,7 +188,7 @@ export const make = Effect.gen(function* () {
},
{ role: "editMenu" },
{
- label: "View",
+ label: i18n.t("desktop.menu.view"),
submenu: [
{ role: "reload" },
{ role: "forceReload" },
@@ -198,15 +200,27 @@ export const make = Effect.gen(function* () {
page and the app UI appears stuck. These always zoom the main
window (see DesktopWindow.zoomMain).
*/
- { label: "Actual Size", accelerator: "CmdOrCtrl+0", click: zoomClick("reset") },
- { label: "Zoom In", accelerator: "CmdOrCtrl+=", click: zoomClick("in") },
{
- label: "Zoom In",
+ label: i18n.t("desktop.menu.actualSize"),
+ accelerator: "CmdOrCtrl+0",
+ click: zoomClick("reset"),
+ },
+ {
+ label: i18n.t("desktop.menu.zoomIn"),
+ accelerator: "CmdOrCtrl+=",
+ click: zoomClick("in"),
+ },
+ {
+ label: i18n.t("desktop.menu.zoomIn"),
accelerator: "CmdOrCtrl+Plus",
visible: false,
click: zoomClick("in"),
},
- { label: "Zoom Out", accelerator: "CmdOrCtrl+-", click: zoomClick("out") },
+ {
+ label: i18n.t("desktop.menu.zoomOut"),
+ accelerator: "CmdOrCtrl+-",
+ click: zoomClick("out"),
+ },
{ type: "separator" },
{ role: "togglefullscreen" },
],
@@ -216,7 +230,7 @@ export const make = Effect.gen(function* () {
role: "help",
submenu: [
{
- label: "Check for Updates...",
+ label: i18n.t("desktop.menu.checkForUpdates"),
click: checkForUpdatesClick,
},
],
diff --git a/apps/desktop/src/window/DesktopWindow.test.ts b/apps/desktop/src/window/DesktopWindow.test.ts
index 7bbb5c1da024..68f2959c3cf0 100644
--- a/apps/desktop/src/window/DesktopWindow.test.ts
+++ b/apps/desktop/src/window/DesktopWindow.test.ts
@@ -2,6 +2,7 @@ import * as NodeServices from "@effect/platform-node/NodeServices";
import { assert, describe, it } from "@effect/vitest";
import * as Deferred from "effect/Deferred";
import { DesktopSnapShotId } from "@t3tools/contracts";
+import { i18n } from "@t3tools/shared/i18n";
import * as Effect from "effect/Effect";
import * as Fiber from "effect/Fiber";
import * as Layer from "effect/Layer";
@@ -519,8 +520,12 @@ describe("DesktopWindow", () => {
);
const imageMenu = (yield* Queue.take(menus)).input;
assert.isUndefined(imageMenu.frame);
- const copyImage = imageMenu.template.find((item) => item.label === "Copy Image");
- const copyLink = imageMenu.template.find((item) => item.label === "Copy Link");
+ const copyImage = imageMenu.template.find(
+ (item) => item.label === i18n.t("desktop.contextMenu.copyImage"),
+ );
+ const copyLink = imageMenu.template.find(
+ (item) => item.label === i18n.t("desktop.contextMenu.copyLink"),
+ );
assert.isDefined(copyImage?.click);
assert.isDefined(copyLink?.click);
copyImage.click({} as Electron.MenuItem, undefined, {} as Electron.KeyboardEvent);
diff --git a/apps/desktop/src/window/DesktopWindow.ts b/apps/desktop/src/window/DesktopWindow.ts
index 0a966ec36e4d..6cf49c588de6 100644
--- a/apps/desktop/src/window/DesktopWindow.ts
+++ b/apps/desktop/src/window/DesktopWindow.ts
@@ -9,6 +9,7 @@ import * as Ref from "effect/Ref";
import * as Electron from "electron";
import { type DesktopSnapShotEvent, DEFAULT_CLIENT_SETTINGS } from "@t3tools/contracts";
+import { i18n } from "@t3tools/shared/i18n";
import * as DesktopAssets from "../app/DesktopAssets.ts";
import * as DesktopEnvironment from "../app/DesktopEnvironment.ts";
@@ -188,7 +189,7 @@ function buildConnectingSplashDataUrl(shouldUseDarkColors: boolean): string {
const label = shouldUseDarkColors ? "#9ca3af" : "#6b7280";
const accent = shouldUseDarkColors ? "#f8fafc" : "#1f2937";
const track = shouldUseDarkColors ? "rgba(248,250,252,0.18)" : "rgba(31,41,55,0.18)";
- const html = `
Connecting to WSL…
`;
+ const html = `${i18n.t("desktop.splash.connecting")}
`;
return `data:text/html;charset=utf-8,${encodeURIComponent(html)}`;
}
@@ -531,7 +532,10 @@ export const make = Effect.gen(function* () {
});
}
if (params.dictionarySuggestions.length === 0) {
- menuTemplate.push({ label: "No suggestions", enabled: false });
+ menuTemplate.push({
+ label: i18n.t("desktop.contextMenu.noSuggestions"),
+ enabled: false,
+ });
}
menuTemplate.push({ type: "separator" });
}
@@ -539,7 +543,7 @@ export const make = Effect.gen(function* () {
if (Option.isSome(ElectronShell.parseSafeExternalUrl(params.linkURL))) {
menuTemplate.push(
{
- label: "Copy Link",
+ label: i18n.t("desktop.contextMenu.copyLink"),
click: () => {
void runPromise(electronShell.copyText(params.linkURL));
},
@@ -550,7 +554,7 @@ export const make = Effect.gen(function* () {
if (params.mediaType === "image") {
menuTemplate.push({
- label: "Copy Image",
+ label: i18n.t("desktop.contextMenu.copyImage"),
click: () => {
if (!contents.isDestroyed()) contents.copyImageAt(params.x, params.y);
},
diff --git a/apps/web/src/components/BranchToolbarBranchSelector.tsx b/apps/web/src/components/BranchToolbarBranchSelector.tsx
index e9b5360cbc45..1578efd55998 100644
--- a/apps/web/src/components/BranchToolbarBranchSelector.tsx
+++ b/apps/web/src/components/BranchToolbarBranchSelector.tsx
@@ -23,6 +23,7 @@ import {
import { useComposerDraftStore, type DraftId } from "../composerDraftStore";
import { writeTextToClipboard } from "../hooks/useCopyToClipboard";
+import { useI18n } from "../hooks/useI18n";
import { readLocalApi } from "../localApi";
import { useOpenPrLink } from "../lib/openPullRequestLink";
import { shouldLoadNextBranchPageAfterScroll } from "../state/paginatedBranches";
@@ -97,6 +98,7 @@ export function BranchToolbarBranchSelector({
onCheckoutPullRequestRequest,
onComposerFocusRequest,
}: BranchToolbarBranchSelectorProps) {
+ const { t } = useI18n();
const startFromOriginSwitchId = useId();
const stopThreadSession = useAtomCommand(threadEnvironment.stopSession, "thread session stop");
const updateThreadMetadata = useAtomCommand(
@@ -332,37 +334,43 @@ export function BranchToolbarBranchSelector({
const [isBranchActionPending, startBranchActionTransition] = useTransition();
const totalBranchCount = branchRefState.data?.totalCount ?? 0;
const branchStatusText = isInitialBranchesLoadPending
- ? "Loading refs..."
+ ? t("branchToolbar.loadingRefs")
: isFetchingNextPage
- ? "Loading more refs..."
+ ? t("branchToolbar.loadingMoreRefs")
: hasNextPage
- ? `Showing ${refs.length} of ${totalBranchCount} refs`
+ ? t("branchToolbar.showingOf", {
+ count: refs.length,
+ total: totalBranchCount,
+ })
: null;
// ---------------------------------------------------------------------------
// Branch actions
// ---------------------------------------------------------------------------
- const copyBranchName = useCallback((branchName: string) => {
- void writeTextToClipboard(branchName, "branch name").then(
- (didCopy) => {
- if (!didCopy) return;
- toastManager.add({
- type: "success",
- title: "Branch name copied",
- description: branchName,
- });
- },
- (error: unknown) => {
- toastManager.add(
- stackedThreadToast({
- type: "error",
- title: "Failed to copy branch name",
- description: toBranchActionErrorMessage(error),
- }),
- );
- },
- );
- }, []);
+ const copyBranchName = useCallback(
+ (branchName: string) => {
+ void writeTextToClipboard(branchName, "branch name").then(
+ (didCopy) => {
+ if (!didCopy) return;
+ toastManager.add({
+ type: "success",
+ title: t("branchToolbar.branchNameCopied"),
+ description: branchName,
+ });
+ },
+ (error: unknown) => {
+ toastManager.add(
+ stackedThreadToast({
+ type: "error",
+ title: t("branchToolbar.copyBranchNameFailed"),
+ description: toBranchActionErrorMessage(error),
+ }),
+ );
+ },
+ );
+ },
+ [t],
+ );
const handleBranchContextMenu = useCallback(
(event: ReactMouseEvent, branchName: string | null) => {
@@ -372,13 +380,13 @@ export function BranchToolbarBranchSelector({
event.preventDefault();
event.stopPropagation();
const items: ContextMenuItem<"copy-branch-name">[] = [
- { id: "copy-branch-name", label: "Copy branch name", icon: "copy" },
+ { id: "copy-branch-name", label: t("branchToolbar.copyBranchName"), icon: "copy" },
];
void api.contextMenu.show(items, { x: event.clientX, y: event.clientY }).then((action) => {
if (action === "copy-branch-name") copyBranchName(branchName);
});
},
- [copyBranchName],
+ [copyBranchName, t],
);
const runBranchAction = (action: () => Promise) => {
@@ -442,7 +450,7 @@ export function BranchToolbarBranchSelector({
toastManager.add(
stackedThreadToast({
type: "error",
- title: "Failed to switch ref.",
+ title: t("branchToolbar.switchRefFailed"),
description: toBranchActionErrorMessage(squashAtomCommandFailure(checkoutResult)),
}),
);
@@ -478,7 +486,7 @@ export function BranchToolbarBranchSelector({
toastManager.add(
stackedThreadToast({
type: "error",
- title: "Failed to create and switch ref.",
+ title: t("branchToolbar.createRefFailed"),
description: toBranchActionErrorMessage(squashAtomCommandFailure(createBranchResult)),
}),
);
@@ -625,7 +633,11 @@ export function BranchToolbarBranchSelector({
// Action-oriented tooltip (the pill opens the PR), distinct from the sidebar's
// state-description tooltip.
const branchPrTooltip = branchPr
- ? `Open ${sourceControlPresentation.terminology.singular} #${branchPr.number} (${branchPr.state})`
+ ? t("branchToolbar.openPr", {
+ type: sourceControlPresentation.terminology.singular,
+ number: branchPr.number,
+ state: branchPr.state,
+ })
: "";
const openPrLink = useOpenPrLink(threadRef);
@@ -652,7 +664,9 @@ export function BranchToolbarBranchSelector({
- Checkout {sourceControlPresentation.terminology.singular}
+ {t("branchToolbar.checkout", {
+ type: sourceControlPresentation.terminology.singular,
+ })}
{prReference}
@@ -670,7 +684,7 @@ export function BranchToolbarBranchSelector({
className="pe-1.5"
onClick={() => createRef(trimmedBranchQuery)}
>
- Create new ref "{newRefName}"
+ {t("branchToolbar.createNewRef", { name: newRefName })}
);
}
@@ -681,13 +695,13 @@ export function BranchToolbarBranchSelector({
const hasSecondaryWorktree =
refName.worktreePath && activeProjectCwd && refName.worktreePath !== activeProjectCwd;
const badge = refName.current
- ? "current"
+ ? t("branchToolbar.badgeCurrent")
: hasSecondaryWorktree
- ? "worktree"
+ ? t("branchToolbar.badgeWorktree")
: refName.isRemote
- ? "remote"
+ ? t("branchToolbar.badgeRemote")
: refName.isDefault
- ? "default"
+ ? t("branchToolbar.badgeDefault")
: null;
return (
-
No refs found.
+
{t("branchToolbar.noRefs")}
@@ -867,21 +881,20 @@ export function BranchToolbarBranchSelector({
>
- Start from origin
+ {t("branchToolbar.startFromOrigin")}
onStartFromOriginChange(Boolean(checked))}
/>
}
/>
- Creates the worktree from the latest matching branch on origin instead of your local
- branch.
+ {t("branchToolbar.worktreeFromOriginHint")}
) : null}
diff --git a/apps/web/src/components/ConfirmDialogHost.tsx b/apps/web/src/components/ConfirmDialogHost.tsx
index 7babc62fd2ea..6fdd64b5a52d 100644
--- a/apps/web/src/components/ConfirmDialogHost.tsx
+++ b/apps/web/src/components/ConfirmDialogHost.tsx
@@ -1,5 +1,7 @@
import { useEffect, useSyncExternalStore } from "react";
+import type { I18n } from "@t3tools/shared/i18n";
+import { useI18n } from "../hooks/useI18n";
import {
completeConfirmDialogClose,
readConfirmDialogState,
@@ -23,7 +25,7 @@ type ConfirmationCopy = {
readonly description: string | null;
};
-function resolveConfirmDialogCopy(message: string): ConfirmationCopy {
+function resolveConfirmDialogCopy(message: string, t: I18n["t"]): ConfirmationCopy {
const normalizedMessage = message.trim();
const lines = normalizedMessage.split("\n");
const questionLineIndex = lines.findIndex((line) => line.trim().endsWith("?"));
@@ -46,12 +48,13 @@ function resolveConfirmDialogCopy(message: string): ConfirmationCopy {
}
return {
- title: "Confirm action",
- description: normalizedMessage || "This action requires your confirmation.",
+ title: t("confirm.title"),
+ description: normalizedMessage || t("confirm.defaultRequiresConfirmation"),
};
}
export function ConfirmDialogHost() {
+ const { t } = useI18n();
const state = useSyncExternalStore(
subscribeConfirmDialog,
readConfirmDialogState,
@@ -60,7 +63,7 @@ export function ConfirmDialogHost() {
useEffect(() => registerConfirmDialogHost(), []);
- const copy = resolveConfirmDialogCopy(state.status === "idle" ? "" : state.message);
+ const copy = resolveConfirmDialogCopy(state.status === "idle" ? "" : state.message, t);
const confirmVariant = state.status === "idle" ? "default" : state.variant;
const onCancel = () => respondToConfirmDialog(false);
const onConfirm = () => respondToConfirmDialog(true);
@@ -85,9 +88,11 @@ export function ConfirmDialogHost() {
) : null}
- }>Cancel
+ }>
+ {t("confirm.cancel")}
+
diff --git a/apps/web/src/components/NoActiveThreadState.tsx b/apps/web/src/components/NoActiveThreadState.tsx
index 3e7b0769d482..0efb2119d64f 100644
--- a/apps/web/src/components/NoActiveThreadState.tsx
+++ b/apps/web/src/components/NoActiveThreadState.tsx
@@ -1,19 +1,23 @@
import { Empty, EmptyDescription, EmptyHeader, EmptyTitle } from "./ui/empty";
import { SidebarInset } from "./ui/sidebar";
import { isElectron } from "../env";
+import { useI18n } from "../hooks/useI18n";
import { WorkspacePageHeader } from "./WorkspacePageHeader";
export function NoActiveThreadState() {
+ const { t } = useI18n();
return (
{isElectron ? (
- No active thread
+
+ {t("chat.empty.noActiveThread")}
+
) : (
- No active thread
+ {t("chat.empty.noActiveThread")}
)}
@@ -22,9 +26,9 @@ export function NoActiveThreadState() {
- Pick a thread to continue
+ {t("chat.empty.title")}
- Select an existing thread or create a new one to get started.
+ {t("chat.empty.description")}
diff --git a/apps/web/src/components/Sidebar.tsx b/apps/web/src/components/Sidebar.tsx
index ebc1078e672b..01b2c6930569 100644
--- a/apps/web/src/components/Sidebar.tsx
+++ b/apps/web/src/components/Sidebar.tsx
@@ -113,6 +113,7 @@ import { useHandleNewThread } from "../hooks/useHandleNewThread";
import { isCommandPaletteOpen, openCommandPalette } from "../commandPaletteBus";
import { startNewThreadFromContext } from "../lib/chatThreadActions";
import { useClientSettings } from "../hooks/useSettings";
+import { useI18n } from "../hooks/useI18n";
import { useCopyToClipboard } from "../hooks/useCopyToClipboard";
import { useLocalStorage } from "../hooks/useLocalStorage";
import { useNowMinute } from "../hooks/useNowMinute";
@@ -2087,6 +2088,8 @@ export default function Sidebar() {
const threads = useThreadShells();
const router = useRouter();
const { isMobile, setOpenMobile } = useSidebar();
+ const i18n = useI18n();
+ const { t } = i18n;
const keybindings = useAtomValue(primaryServerKeybindingsAtom);
const confirmThreadDelete = useClientSettings((s) => s.confirmThreadDelete);
const confirmThreadArchive = useClientSettings((s) => s.confirmThreadArchive);
@@ -2309,13 +2312,13 @@ export default function Sidebar() {
// while the popup search filters the same collection.
const projectScopeItems = useMemo(
() => [
- { value: "all", label: "All projects" },
+ { value: "all", label: t("sidebar.allProjects") },
...projectGroups.map((project) => ({
value: project.projectKey,
label: project.displayName,
})),
],
- [projectGroups],
+ [projectGroups, i18n.locale],
);
const projectGroupByScopeKey = useMemo(
() => new Map(projectGroups.map((project) => [project.projectKey, project] as const)),
@@ -4304,8 +4307,8 @@ export default function Sidebar() {
setActiveSearchResultIndex(0);
}}
onKeyDown={handleThreadSearchKeyDown}
- placeholder="Search"
- aria-label="Search threads"
+ placeholder={t("sidebar.search")}
+ aria-label={t("sidebar.searchThreads")}
role="combobox"
aria-autocomplete="list"
aria-expanded={isSearchingThreads && threadSearchResults.length > 0}
@@ -4423,7 +4426,7 @@ export default function Sidebar() {
)}
- {scopedProjectGroup?.displayName ?? "All projects"}
+ {scopedProjectGroup?.displayName ?? t("sidebar.allProjects")}
diff --git a/apps/web/src/components/chat/ChatComposer.tsx b/apps/web/src/components/chat/ChatComposer.tsx
index 7b2e65f8bf3e..da7f2456b733 100644
--- a/apps/web/src/components/chat/ChatComposer.tsx
+++ b/apps/web/src/components/chat/ChatComposer.tsx
@@ -56,7 +56,6 @@ import {
formatAssistantCitationForComposer,
replaceTextRange,
} from "../../composer-logic";
-import { DISCONNECTED_COMPOSER_PLACEHOLDER } from "../../composerPlaceholder";
import {
deriveComposerSendState,
getAntigravitySendBlockReason,
@@ -158,6 +157,7 @@ import {
} from "../../lib/terminalContext";
import { useComposerPathSearch } from "../../lib/composerPathSearchState";
import { type ElementContextDraft } from "../../lib/elementContext";
+import { useI18n } from "../../hooks/useI18n";
import { ComposerPendingElementContexts } from "./ComposerPendingElementContexts";
import { ComposerPendingReviewComments } from "./ComposerPendingReviewComments";
import { ComposerPreviewAnnotationCards } from "./ComposerPreviewAnnotationCards";
@@ -1481,6 +1481,7 @@ export const ChatComposer = memo(function ChatComposer(props: ChatComposerProps)
onExpandImage,
onFileOpen,
} = props;
+ const { t } = useI18n();
const activeTasksProgress = props.threadSyncPhase === null ? props.activeTasksProgress : null;
const activeTaskSteps = props.threadSyncPhase === null ? props.activeTaskSteps : null;
// ------------------------------------------------------------------
@@ -2309,7 +2310,7 @@ export const ChatComposer = memo(function ChatComposer(props: ChatComposerProps)
projectSelectionRequired ||
environmentUnavailable !== null ||
!composerSendState.hasSendableContent;
- const collapsedComposerPrimaryActionLabel = "Send message";
+ const collapsedComposerPrimaryActionLabel = t("chat.sendMessage");
const showMobilePendingAnswerActions =
isMobileViewport && !isComposerCollapsedMobile && pendingPrimaryAction !== null;
@@ -5717,21 +5718,20 @@ export const ChatComposer = memo(function ChatComposer(props: ChatComposerProps)
onPaste={onComposerPaste}
placeholder={
isComposerApprovalState
- ? (activePendingApproval?.detail ??
- "Resolve this approval request to continue")
+ ? (activePendingApproval?.detail ?? t("chat.composer.placeholder.approval"))
: activePendingProgress
? isChoiceOnlyPendingQuestion
- ? "Choose an option above"
- : "Type your own answer, or leave this blank to use the selected option"
+ ? t("chat.composer.placeholder.chooseOption")
+ : t("chat.composer.placeholder.customAnswer")
: showPlanFollowUpPrompt && activeProposedPlan
- ? "Add feedback to refine the plan, or leave this blank to implement it"
+ ? t("chat.composer.placeholder.planFeedback")
: projectSelectionRequired
- ? "Choose a project above to start a thread"
+ ? t("chat.composer.placeholder.chooseProject")
: showProviderUnavailable
- ? "Enable a provider in Settings to send a message"
+ ? t("chat.composer.placeholder.enableProvider")
: phase === "disconnected"
- ? DISCONNECTED_COMPOSER_PLACEHOLDER
- : "Ask anything, @tag files/folders, $use skills, or / for commands"
+ ? t("chat.composer.placeholder.disconnected")
+ : t("chat.composer.placeholder.default")
}
disabled={
isConnecting ||
diff --git a/apps/web/src/components/chat/ComposerPrimaryActions.test.tsx b/apps/web/src/components/chat/ComposerPrimaryActions.test.tsx
index 45ef93568cf6..4d7f4540b7fc 100644
--- a/apps/web/src/components/chat/ComposerPrimaryActions.test.tsx
+++ b/apps/web/src/components/chat/ComposerPrimaryActions.test.tsx
@@ -1,6 +1,12 @@
import { createElement } from "react";
import { renderToStaticMarkup } from "react-dom/server";
-import { afterEach, describe, expect, it, vi } from "vite-plus/test";
+import { afterAll, afterEach, beforeAll, describe, expect, it, vi } from "vite-plus/test";
+import { i18n } from "@t3tools/shared/i18n";
+
+// The global i18n defaults to zh-CN; these assertions cover button state via the
+// English aria-label, so run the file under the English catalog.
+beforeAll(() => i18n.setLocale("en"));
+afterAll(() => i18n.setLocale("zh-CN"));
const stageArtworkState = vi.hoisted(() => ({
mode: "none" as "artwork" | "none",
diff --git a/apps/web/src/components/chat/ComposerPrimaryActions.tsx b/apps/web/src/components/chat/ComposerPrimaryActions.tsx
index 91c54b75ed03..272d1de60815 100644
--- a/apps/web/src/components/chat/ComposerPrimaryActions.tsx
+++ b/apps/web/src/components/chat/ComposerPrimaryActions.tsx
@@ -1,6 +1,7 @@
import { memo, type PointerEventHandler } from "react";
import { ChevronDownIcon, ChevronLeftIcon } from "lucide-react";
import { useEnvironmentIdentificationMode } from "~/hooks/useSettings";
+import { useI18n } from "~/hooks/useI18n";
import { cn } from "~/lib/utils";
import { StageBackdropButtonArt, useSidebarStageBackdropVariant } from "../SidebarStageBackdrop";
import { Button } from "../ui/button";
@@ -82,6 +83,7 @@ export const ComposerPrimaryActions = memo(function ComposerPrimaryActions({
: undefined;
const environmentIdentificationMode = useEnvironmentIdentificationMode();
const isSendDisabled = sendDisabledReason !== null;
+ const { t } = useI18n();
const stageBackdropVariant = useSidebarStageBackdropVariant(
environmentIdentificationMode === "artwork",
);
@@ -242,12 +244,12 @@ export const ComposerPrimaryActions = memo(function ComposerPrimaryActions({
: sendDisabledReason
? sendDisabledReason
: isConnecting
- ? "Connecting"
+ ? t("chat.composer.connecting")
: isPreparingWorktree
- ? "Preparing worktree"
+ ? t("chat.composer.preparingWorktree")
: isSendBusy
- ? "Sending"
- : "Send message"
+ ? t("chat.composer.sending")
+ : t("chat.sendMessage")
}
>
{stageBackdropVariant ? (
diff --git a/apps/web/src/components/chat/ContextWindowMeter.test.tsx b/apps/web/src/components/chat/ContextWindowMeter.test.tsx
index 90617ca63af1..1c6f13a5d7a1 100644
--- a/apps/web/src/components/chat/ContextWindowMeter.test.tsx
+++ b/apps/web/src/components/chat/ContextWindowMeter.test.tsx
@@ -1,11 +1,17 @@
import { EventId, TurnId } from "@t3tools/contracts";
import type { ReactNode } from "react";
import { renderToStaticMarkup } from "react-dom/server";
-import { describe, expect, it, vi } from "vite-plus/test";
+import { afterAll, beforeAll, describe, expect, it, vi } from "vite-plus/test";
+import { i18n } from "@t3tools/shared/i18n";
import { deriveLatestContextWindowSnapshot } from "~/lib/contextWindow";
import { ContextWindowMeter } from "./ContextWindowMeter";
+// The global i18n defaults to zh-CN; assert against the English catalog so the
+// copy-based expectations stay deterministic English strings.
+beforeAll(() => i18n.setLocale("en"));
+afterAll(() => i18n.setLocale("zh-CN"));
+
vi.mock("../ui/popover", () => ({
Popover: ({ children }: { children: ReactNode }) => children,
PopoverPopup: ({ children }: { children: ReactNode }) => children,
diff --git a/apps/web/src/components/chat/ContextWindowMeter.tsx b/apps/web/src/components/chat/ContextWindowMeter.tsx
index 3e52f6e2d86d..2662d4bdc0cc 100644
--- a/apps/web/src/components/chat/ContextWindowMeter.tsx
+++ b/apps/web/src/components/chat/ContextWindowMeter.tsx
@@ -4,6 +4,7 @@ import { Popover, PopoverPopup, PopoverTrigger } from "../ui/popover";
import { formatContextWindowCompactionMessage } from "./ContextWindowMeter.logic";
import { Minimize2Icon } from "lucide-react";
import { composerFloatingLayerProps } from "./composerEventScope";
+import { useI18n } from "~/hooks/useI18n";
function formatPercentage(value: number | null): string | null {
if (value === null || !Number.isFinite(value)) {
@@ -23,6 +24,7 @@ export function ContextWindowMeter(props: {
compactDisabledReason?: string | null | undefined;
}) {
const { usage, modelDisplayName, onCompact, compactDisabled, compactDisabledReason } = props;
+ const { t } = useI18n();
const usedPercentage = formatPercentage(usage.usedPercentage);
const normalizedPercentage = Math.max(0, Math.min(100, usage.usedPercentage ?? 0));
const radius = 9.75;
@@ -93,7 +95,9 @@ export function ContextWindowMeter(props: {
>
-
Context Window
+
+ {t("chat.contextWindow.title")}
+
{usage.maxTokens !== null && usedPercentage ? (
{usedPercentage}
@@ -116,7 +120,7 @@ export function ContextWindowMeter(props: {
aria-valuemin={0}
aria-valuemax={100}
aria-valuenow={Math.round(normalizedPercentage)}
- aria-label="Context window usage"
+ aria-label={t("chat.contextWindow.usageAria")}
>
-
Total processed
+
{t("chat.contextWindow.totalProcessed")}
{formatContextWindowTokens(totalProcessedTokens)}
@@ -147,7 +151,7 @@ export function ContextWindowMeter(props: {
onClick={onCompact}
>
- Compact context
+ {t("chat.contextWindow.compact")}
{compactDisabled && compactDisabledReason ? (
diff --git a/apps/web/src/components/chat/DraftHeroHeadline.tsx b/apps/web/src/components/chat/DraftHeroHeadline.tsx
index 4a9421f2011f..cb49baccb160 100644
--- a/apps/web/src/components/chat/DraftHeroHeadline.tsx
+++ b/apps/web/src/components/chat/DraftHeroHeadline.tsx
@@ -7,6 +7,7 @@ import { useCallback, useMemo } from "react";
import { openCommandPalette } from "~/commandPaletteBus";
import { useClientSettings } from "~/hooks/useSettings";
+import { useI18n } from "~/hooks/useI18n";
import { hasExplicitComposerModelSelection } from "~/lib/chatThreadActions";
import { selectProjectGroupingSettings } from "~/logicalProject";
import {
@@ -41,6 +42,7 @@ export function DraftHeroHeadline({
}: DraftHeroHeadlineProps) {
const projects = useProjects();
const threads = useThreadShells();
+ const { t } = useI18n();
const { environments } = useEnvironments();
const primaryEnvironmentId = usePrimaryEnvironmentId();
const projectGroupingSettings = useClientSettings(selectProjectGroupingSettings);
@@ -114,12 +116,12 @@ export function DraftHeroHeadline({
}
>
- {activeProjectDisplayName ?? "Choose a project"}
+ {activeProjectDisplayName ?? t("draft.chooseProject")}
{activeProjectDisplayName ? (
@@ -187,7 +189,7 @@ export function DraftHeroHeadline({
@@ -197,18 +199,25 @@ export function DraftHeroHeadline({
onClick={openAddProject}
className="pointer-events-auto inline cursor-pointer border-muted-foreground/35 border-b border-dotted text-muted-foreground/60 transition-colors hover:border-muted-foreground/60 hover:text-muted-foreground/80 focus-visible:rounded-sm focus-visible:outline-hidden focus-visible:ring-2 focus-visible:ring-ring"
>
- {activeProjectTitle ?? "Add a project"}
+ {activeProjectTitle ?? t("draft.addAProject")}
);
return (
{hasResolvedProject ? (
- <>What should we build in {projectSelector}?>
+ <>
+ {t("draft.hero.buildInPrefix")}
+ {projectSelector}
+ {t("draft.hero.buildInSuffix")}
+ >
) : canChooseProject ? (
- <>{projectSelector} to start>
+ <>
+ {projectSelector}
+ {t("draft.hero.startSuffix")}
+ >
) : (
- <>Add a project to start>
+ <>{t("draft.hero.addProjectToStart")}>
)}
);
diff --git a/apps/web/src/components/pullRequest/PullRequestListEmptyState.test.tsx b/apps/web/src/components/pullRequest/PullRequestListEmptyState.test.tsx
index cef54e639d29..99e8ef90e983 100644
--- a/apps/web/src/components/pullRequest/PullRequestListEmptyState.test.tsx
+++ b/apps/web/src/components/pullRequest/PullRequestListEmptyState.test.tsx
@@ -1,18 +1,26 @@
/**
* Which of the four states wins, and which of them offer to ask the hosts again. The component is
- * called as a plain function and its tree read for text: the elements are walked rather than
- * invoked, so the button's own hooks never run outside a render.
+ * statically rendered and its emitted text read, so its hooks (useI18n) run inside a real render.
*/
-import { isValidElement, type ReactElement, type ReactNode } from "react";
-import { describe, expect, it } from "vite-plus/test";
+import { createElement } from "react";
+import { renderToStaticMarkup } from "react-dom/server";
+import { afterAll, beforeAll, describe, expect, it } from "vite-plus/test";
+import { i18n } from "@t3tools/shared/i18n";
import { PullRequestListEmptyState } from "./PullRequestListEmptyState";
-function textOf(node: ReactNode): string {
- if (typeof node === "string" || typeof node === "number") return String(node);
- if (Array.isArray(node)) return node.map(textOf).join(" ");
- if (!isValidElement(node)) return "";
- return textOf((node as ReactElement<{ children?: ReactNode }>).props.children);
+// The global i18n defaults to zh-CN; assert against the English catalog so the
+// copy-based expectations below stay deterministic English strings.
+beforeAll(() => i18n.setLocale("en"));
+afterAll(() => i18n.setLocale("zh-CN"));
+
+function textOf(markup: string): string {
+ // Strip tags and attributes, then collapse whitespace; the assertions below
+ // match against that plain text.
+ return markup
+ .replace(/<[^>]*>/g, " ")
+ .replace(/\s+/g, " ")
+ .trim();
}
const baseProps = {
@@ -29,7 +37,9 @@ const baseProps = {
};
function render(props: Partial): string {
- return textOf(PullRequestListEmptyState({ ...baseProps, ...props }));
+ return textOf(
+ renderToStaticMarkup(createElement(PullRequestListEmptyState, { ...baseProps, ...props })),
+ );
}
describe("PullRequestListEmptyState", () => {
diff --git a/apps/web/src/components/pullRequest/PullRequestListEmptyState.tsx b/apps/web/src/components/pullRequest/PullRequestListEmptyState.tsx
index 8c5f862700bc..158b28ab21e6 100644
--- a/apps/web/src/components/pullRequest/PullRequestListEmptyState.tsx
+++ b/apps/web/src/components/pullRequest/PullRequestListEmptyState.tsx
@@ -15,6 +15,7 @@ import { RefreshIcon } from "~/components/ui/refresh-icon";
import { PlusIcon, SearchIcon } from "lucide-react";
import { openCommandPalette } from "../../commandPaletteBus";
+import { useI18n } from "../../hooks/useI18n";
import { Button } from "../ui/button";
import { PullRequestListGhost } from "./PullRequestGhosts";
import { Empty, EmptyContent, EmptyDescription, EmptyHeader, EmptyTitle } from "../ui/empty";
@@ -97,21 +98,20 @@ export function PullRequestListEmptyState({
onLoadMore: () => void;
onRefresh: () => void;
}) {
+ const { t } = useI18n();
// Ahead of the search and the filters, because neither can produce a row until a project does.
if (!hasProjects) {
return (
- No projects in this workspace
-
- Add a project, and the pull requests from its repository appear here.
-
+ {t("prList.noProjectsTitle")}
+ {t("prList.noProjectsDescription")}
@@ -124,7 +124,9 @@ export function PullRequestListEmptyState({
return (
48 ? `${query.slice(0, 48)}…` : query}”`}
+ caption={t("prList.searchingCaption", {
+ query: query.length > 48 ? `${query.slice(0, 48)}…` : query,
+ })}
/>
);
}
@@ -136,22 +138,22 @@ export function PullRequestListEmptyState({
{/* A pasted paragraph is still a search, but it is not a title. */}
- Nothing matches “{query.length > 48 ? `${query.slice(0, 48)}…` : query}”
+ {t("prList.noMatches", {
+ query: query.length > 48 ? `${query.slice(0, 48)}…` : query,
+ })}
-
- The hosts were searched for it. Try fewer words, or search by number, author or branch.
-
+ {t("prList.noMatchesDescription")}
{/* The hosts answered this query once; a pull request opened since then would answer
differently, and nothing on screen says which of the two the reader is looking at. */}
@@ -162,22 +164,20 @@ export function PullRequestListEmptyState({
- {filtered ? "Nothing under these filters" : "No pull requests"}
+ {filtered ? t("prList.filtersEmpty") : t("prList.empty")}
- {filtered
- ? "Widen the state, involvement or project filter to see more."
- : "Pull requests from every project in this workspace appear here."}
+ {filtered ? t("prList.filtersEmptyDescription") : t("prList.allDescription")}
{canLoadMore ? (
) : null}
diff --git a/apps/web/src/components/settings/SettingsFontPreviews.tsx b/apps/web/src/components/settings/SettingsFontPreviews.tsx
index 856d85fb098a..b63f5b1d1179 100644
--- a/apps/web/src/components/settings/SettingsFontPreviews.tsx
+++ b/apps/web/src/components/settings/SettingsFontPreviews.tsx
@@ -3,7 +3,7 @@ import { useCallback, useEffect, useRef, useState } from "react";
import { ComposerPromptEditor, type ComposerPromptEditorHandle } from "../ComposerPromptEditor";
import { terminalThemeFromApp } from "../ThreadTerminalDrawer";
import { useTheme } from "../../hooks/useTheme";
-import { DISCONNECTED_COMPOSER_PLACEHOLDER } from "../../composerPlaceholder";
+import { useI18n } from "../../hooks/useI18n";
import { resolveDiffThemeName, type DiffThemeName } from "../../lib/diffRendering";
import { PREFERRED_HIGHLIGHTER } from "../../lib/syntaxHighlighting";
import { GhosttyTerminalSurface } from "~/terminal/ghostty/surface";
@@ -29,6 +29,7 @@ function noop() {}
/** A live composer editor: type in it to feel the family and size. */
export function PromptFontPreview() {
+ const { t } = useI18n();
const editorRef = useRef(null);
const [prompt, setPrompt] = useState(PROMPT_PREVIEW_TEXT);
const [cursor, setCursor] = useState(PROMPT_PREVIEW_TEXT.length);
@@ -45,7 +46,7 @@ export function PromptFontPreview() {
terminalContexts={EMPTY_TERMINAL_CONTEXTS}
skills={EMPTY_SKILLS}
disabled={false}
- placeholder={DISCONNECTED_COMPOSER_PLACEHOLDER}
+ placeholder={t("chat.composer.placeholder.disconnected")}
className="max-h-40 min-h-12"
onRemoveTerminalContext={noop}
onChange={onChange}
diff --git a/apps/web/src/components/settings/SettingsSidebarNav.tsx b/apps/web/src/components/settings/SettingsSidebarNav.tsx
index 75624792c5d7..90443c3962e5 100644
--- a/apps/web/src/components/settings/SettingsSidebarNav.tsx
+++ b/apps/web/src/components/settings/SettingsSidebarNav.tsx
@@ -24,7 +24,9 @@ import {
XIcon,
} from "lucide-react";
import { useLocation, useNavigate } from "@tanstack/react-router";
+import type { MessageKey } from "@t3tools/shared/i18n";
+import { useI18n } from "../../hooks/useI18n";
import { Button } from "../ui/button";
import { Input } from "../ui/input";
import { Kbd } from "../ui/kbd";
@@ -39,12 +41,7 @@ import {
} from "../ui/sidebar";
import { SidebarUtilityMenu } from "../sidebar/SidebarChrome";
import { scrollToSettingsTarget } from "./settingsLayout";
-import {
- searchSettings,
- SETTINGS_SECTION_LABELS,
- type SettingsPath,
- type SettingsSearchItem,
-} from "./settingsSearch";
+import { searchSettings, type SettingsPath, type SettingsSearchItem } from "./settingsSearch";
import { useAvailableSettingsSearchItems } from "./useAvailableSettingsSearchItems";
const SnapShotIcon = createLucideIcon("snap-shot", [
@@ -85,13 +82,27 @@ const SETTINGS_SECTION_ICONS: Readonly<
"/settings/archived": ArchiveIcon,
};
+/** Section-label i18n keys, in sidebar order. */
+const SETTINGS_SECTION_LABEL_KEYS: Readonly> = {
+ "/settings/general": "settings.section.general",
+ "/settings/appearance": "settings.section.appearance",
+ "/settings/projects": "settings.section.projects",
+ "/settings/keybindings": "settings.section.keybindings",
+ "/settings/snap-shot": "settings.section.snapShot",
+ "/settings/providers": "settings.section.providers",
+ "/settings/integrations": "settings.section.integrations",
+ "/settings/source-control": "settings.section.sourceControl",
+ "/settings/connections": "settings.section.connections",
+ "/settings/archived": "settings.section.archive",
+};
+
const SETTINGS_NAV_ITEMS: ReadonlyArray<{
- label: string;
+ labelKey: MessageKey;
to: SettingsPath;
icon: ComponentType<{ className?: string }>;
-}> = (Object.keys(SETTINGS_SECTION_LABELS) as SettingsPath[]).map((to) => ({
+}> = (Object.keys(SETTINGS_SECTION_LABEL_KEYS) as SettingsPath[]).map((to) => ({
to,
- label: SETTINGS_SECTION_LABELS[to],
+ labelKey: SETTINGS_SECTION_LABEL_KEYS[to],
icon: SETTINGS_SECTION_ICONS[to],
}));
@@ -102,6 +113,7 @@ function SettingsSectionIcon({ to }: { to: SettingsPath }) {
export function SettingsSidebarNav({ pathname }: { pathname: string }) {
const navigate = useNavigate();
+ const { t } = useI18n();
const currentHash = useLocation({ select: (location) => location.hash });
const { isMobile, setOpenMobile, open, setOpen } = useSidebar();
const searchInputRef = useRef(null);
@@ -245,8 +257,8 @@ export function SettingsSidebarNav({ pathname }: { pathname: string }) {
setActiveResultIndex(0);
}}
onKeyDown={handleSearchKeyDown}
- placeholder="Search"
- aria-label="Search settings"
+ placeholder={t("settings.search.placeholder")}
+ aria-label={t("settings.search.settings")}
role="combobox"
aria-autocomplete="list"
aria-expanded={isSearching && hasResults}
@@ -264,7 +276,7 @@ export function SettingsSidebarNav({ pathname }: { pathname: string }) {
size="icon-micro"
variant="ghost"
className="shrink-0 text-sidebar-muted-foreground hover:bg-sidebar-control-surface hover:text-sidebar-foreground"
- aria-label="Clear settings search"
+ aria-label={t("settings.search.clearSettings")}
onClick={() => {
clearSearch();
searchInputRef.current?.focus();
@@ -281,7 +293,7 @@ export function SettingsSidebarNav({ pathname }: { pathname: string }) {
role="status"
className="px-2 py-6 text-center text-xs text-sidebar-muted-foreground"
>
- No settings found
+ {t("settings.search.noResults")}
) : null}
{isSearching ? (
@@ -289,7 +301,7 @@ export function SettingsSidebarNav({ pathname }: { pathname: string }) {
className="ps-px"
id={hasResults ? "settings-search-results" : undefined}
role={hasResults ? "listbox" : undefined}
- aria-label={hasResults ? "Settings search results" : undefined}
+ aria-label={hasResults ? t("settings.search.results") : undefined}
>
{results.map((item, index) => (
@@ -310,7 +322,7 @@ export function SettingsSidebarNav({ pathname }: { pathname: string }) {
{item.title}
- {SETTINGS_SECTION_LABELS[item.to]}
+ {t(SETTINGS_SECTION_LABEL_KEYS[item.to])}
@@ -329,7 +341,7 @@ export function SettingsSidebarNav({ pathname }: { pathname: string }) {
onClick={() => handleSectionClick(item.to)}
>
- {item.label}
+ {t(item.labelKey)}
);
diff --git a/apps/web/src/composerPlaceholder.ts b/apps/web/src/composerPlaceholder.ts
deleted file mode 100644
index 4fa933c68d7d..000000000000
--- a/apps/web/src/composerPlaceholder.ts
+++ /dev/null
@@ -1,2 +0,0 @@
-export const DISCONNECTED_COMPOSER_PLACEHOLDER =
- "Ask for changes, send follow-ups, or attach images";
diff --git a/apps/web/src/hooks/useI18n.ts b/apps/web/src/hooks/useI18n.ts
new file mode 100644
index 000000000000..9f156e9371ff
--- /dev/null
+++ b/apps/web/src/hooks/useI18n.ts
@@ -0,0 +1,14 @@
+import { useEffect, useState } from "react";
+
+import { i18n } from "@t3tools/shared/i18n";
+
+/**
+ * Subscribe a component to locale changes so any `t()` call it makes re-renders
+ * when the language switches. Returns the global i18n instance, so both
+ * `const { t } = useI18n()` and `const i18n = useI18n()` are supported.
+ */
+export function useI18n() {
+ const [, setVersion] = useState(0);
+ useEffect(() => i18n.subscribe(() => setVersion((n) => n + 1)), []);
+ return i18n;
+}
diff --git a/docs/internals/i18n.md b/docs/internals/i18n.md
new file mode 100644
index 000000000000..09f00753b42d
--- /dev/null
+++ b/docs/internals/i18n.md
@@ -0,0 +1,73 @@
+# i18n 约定(zh-CN 特化版)
+
+本 fork 的国际化约定。核心代码见 `packages/shared/src/i18n/`,React 集成见 `apps/web/src/hooks/useI18n.ts`。
+
+## 目标
+
+**本地 Desktop 简体中文为第一目标**。Web 复用收益是附带价值,不是第一目标。因此第一版只保证 Desktop 主界面 + 关键 Electron 文案的中文,其余暂缓。
+
+## 文案分两类
+
+| 类别 | 覆盖 | 接入方式 | 语言文件 |
+| ----------------------------- | ---------------------------------------------------------------------- | -------------------------------------------------------- | ------------------------------------------------------- |
+| **共享 UI 文案** | web 与 Desktop renderer(Electron 里渲染的就是 apps/web)共用 | 在 apps/web 组件里 `const { t } = useI18n()` | `en.ts` + `zh-CN.ts` |
+| **Desktop/Electron 特有文案** | 主进程原生层:窗口标题、原生菜单、对话框、更新提示、右键菜单、启动闪屏 | 主进程直接 `import { i18n } from "@t3tools/shared/i18n"` | 同样在 `en.ts` + `zh-CN.ts`,key 用 `desktop.` 前缀区分 |
+
+## 语言文件位置
+
+- `packages/shared/src/i18n/en.ts` —— **规范字典**:key 的唯一来源(`MessageKey = keyof typeof enMessages`),缺 key 回退到它。
+- `packages/shared/src/i18n/zh-CN.ts` —— 中文,`Partial>`,某 key 可省略并透明回退 en。
+- `packages/shared/src/i18n/index.ts` —— `I18n` 类、`i18n` 单例、`createI18n`、`SUPPORTED_LOCALES`、`DEFAULT_LOCALE`。
+- 导入路径:`@t3tools/shared/i18n`(映射见 `packages/shared/package.json` `exports` 的 `"./i18n"`)。
+
+## t() 用法
+
+React 组件(web / desktop renderer):
+
+```ts
+import { useI18n } from "~/hooks/useI18n";
+
+export function SomeButton() {
+ const { t } = useI18n();
+ return ;
+}
+```
+
+非 React(桌面主进程 / node 环境):
+
+```ts
+import { i18n } from "@t3tools/shared/i18n";
+const label = i18n.t("desktop.menu.file");
+```
+
+插值:`t("greeting", { name: "T3" })`,模板里用 `{name}` 占位。
+
+## key 命名约定
+
+- **首段 = 界面域**(小写),短语段用**小驼峰**,点号分层。
+- 共享 UI 通用段:`app.*`、`action.*`、`common.*`。
+- 按界面域分:`chat.*`、`settings.*`、`topbar.*`、`confirm.*`、`prList.*`。
+- **Desktop 特有统一加 `desktop.` 前缀**:`desktop.menu.*`、`desktop.window.*`、`desktop.update.*`、`desktop.dialog.*`、`desktop.splash.*`。加前缀一是明确归属主进程,二是便于将来 tree-shake / 过滤。
+- 新增 key:先在 `en.ts` 注册(否则类型报错),再在 `zh-CN.ts` 补中文。
+
+## 默认语言与回退
+
+- 默认 `zh-CN`(`DEFAULT_LOCALE`)。
+- 查找链:**活动语言 → `en` → key 本身**。永不抛错。
+- 切换语言会通知订阅者触发 React 重渲染(`setLocale` + `subscribe`)。
+
+## 第一版范围
+
+- **做**:Desktop 主界面(apps/web 里探路的 6 个点)+ 关键 Electron 文案(窗口标题、原生应用菜单、更新对话框、右键菜单、启动失败 / WSL 回退错误框、启动闪屏)。
+- **不做**:移动端、全站文档翻译、非关键路径。
+
+## 打包 / 开发模式的加载方式(重要)
+
+语言包**不是运行时加载的 JSON**,而是**编译期静态内联的 TS 模块**(`index.ts` 直接 `import enMessages / zhCNMessages`)。整个 i18n 目录**没有 `fetch`、`fs`、动态 `import()`**。
+
+因此:
+
+- **开发模式**与**打包/生产模式**都不需要外部 locale 资源文件——字典随构建打进 web bundle 和桌面主进程 bundle。
+- Electron renderer 就是 web 的产物,天然带字典;主进程要复用 `@t3tools/shared/i18n` 也是静态链接。
+- 语言切换器(`apps/web/src/components/DevLanguageSwitcher.tsx`)用 `import.meta.env.DEV` 只在开发态显示,不进入生产。
+- 结论:现有实现**无需修复**即可在 Desktop 开发/打包模式稳定加载语言包。
diff --git a/packages/shared/package.json b/packages/shared/package.json
index 5573e0ab6b69..5d80b652cedc 100644
--- a/packages/shared/package.json
+++ b/packages/shared/package.json
@@ -278,6 +278,10 @@
"./dateTime": {
"types": "./src/dateTime.ts",
"import": "./src/dateTime.ts"
+ },
+ "./i18n": {
+ "types": "./src/i18n/index.ts",
+ "import": "./src/i18n/index.ts"
}
},
"scripts": {
diff --git a/packages/shared/src/i18n/en.ts b/packages/shared/src/i18n/en.ts
new file mode 100644
index 000000000000..cf7c59e987f6
--- /dev/null
+++ b/packages/shared/src/i18n/en.ts
@@ -0,0 +1,182 @@
+/**
+ * English messages. This is the canonical dictionary:
+ * every i18n key is typed from here, so it is the source of truth for keys.
+ * Missing keys in the active locale always fall back to this file.
+ */
+export const enMessages = {
+ "app.name": "T3 Code",
+ "action.save": "Save",
+ "action.cancel": "Cancel",
+ "settings.title": "Settings",
+ "chat.sendMessage": "Send message",
+ "chat.composer.connecting": "Connecting",
+ "chat.composer.preparingWorktree": "Preparing worktree",
+ "chat.composer.sending": "Sending",
+ "chat.composer.placeholder.approval": "Resolve this approval request to continue",
+ "chat.composer.placeholder.chooseOption": "Choose an option above",
+ "chat.composer.placeholder.customAnswer":
+ "Type your own answer, or leave this blank to use the selected option",
+ "chat.composer.placeholder.planFeedback":
+ "Add feedback to refine the plan, or leave this blank to implement it",
+ "chat.composer.placeholder.chooseProject": "Choose a project above to start a thread",
+ "chat.composer.placeholder.enableProvider": "Enable a provider in Settings to send a message",
+ "chat.composer.placeholder.disconnected": "Ask for changes, send follow-ups, or attach images",
+ "chat.composer.placeholder.default":
+ "Ask anything, @tag files/folders, $use skills, or / for commands",
+ "topbar.runOn": "Run on",
+ "confirm.title": "Confirm action",
+ "confirm.cancel": "Cancel",
+ "confirm.confirm": "Confirm",
+ "confirm.defaultRequiresConfirmation": "Please confirm to continue.",
+ "prList.empty": "No pull requests",
+ "prList.noProjectsTitle": "No projects connected",
+ "prList.noProjectsDescription": "Connect a project to start browsing pull requests.",
+ "prList.addProject": "Add project",
+ "prList.searchingCaption": "Searching “{query}”…",
+ "prList.noMatches": "No matches for “{query}”",
+ "prList.noMatchesDescription": "Try a different search or add a new pull request.",
+ "prList.clearSearch": "Clear search",
+ "prList.checking": "Checking…",
+ "prList.checkAgain": "Check again",
+ "prList.filtersEmpty": "No pull requests match the current filters",
+ "prList.filtersEmptyDescription": "Adjust the filters to see more pull requests.",
+ "prList.allDescription": "Pull requests from all connected projects appear here.",
+ "prList.loading": "Loading…",
+ "prList.loadMore": "Load more",
+ "chat.empty.noActiveThread": "No active thread",
+ "chat.empty.title": "Pick a thread to continue",
+ "chat.empty.description": "Select an existing thread or create a new one to get started.",
+ "sidebar.allProjects": "All projects",
+ "sidebar.noProjects": "No projects yet",
+ "sidebar.addProject": "Add project",
+ "sidebar.noThreads": "No threads yet",
+ "sidebar.noThreadsInProject": "No threads in {project} yet",
+ "sidebar.showMore": "Show {count} more",
+ "sidebar.pullRequests": "Pull Requests",
+ "sidebar.usage": "Usage",
+ "sidebar.back": "Back",
+ "sidebar.projects": "Projects",
+ "sidebar.search": "Search",
+ "sidebar.searchThreads": "Search threads",
+ "draft.hero.buildInPrefix": "What should we build in ",
+ "draft.hero.buildInSuffix": "?",
+ "draft.hero.startSuffix": " to start",
+ "draft.hero.addProjectToStart": "Add a project to start",
+ "draft.chooseProject": "Choose a project",
+ "draft.changeProject": "Change project",
+ "draft.addAProject": "Add a project",
+ "draft.newProject": "New project",
+ "chat.contextWindow.title": "Context Window",
+ "chat.contextWindow.totalProcessed": "Total processed",
+ "chat.contextWindow.usageAria": "Context window usage",
+ "chat.contextWindow.compact": "Compact",
+ "branchToolbar.searchRefs": "Search refs…",
+ "branchToolbar.noRefs": "No refs found.",
+ "branchToolbar.loadingRefs": "Loading refs…",
+ "branchToolbar.loadingMoreRefs": "Loading more refs…",
+ "branchToolbar.showingOf": "Showing {count} of {total} refs",
+ "branchToolbar.copyBranchName": "Copy branch name",
+ "branchToolbar.branchNameCopied": "Branch name copied",
+ "branchToolbar.copyBranchNameFailed": "Failed to copy branch name",
+ "branchToolbar.switchRefFailed": "Failed to switch ref.",
+ "branchToolbar.createRefFailed": "Failed to create and switch ref.",
+ "branchToolbar.createNewRef": 'Create new ref "{name}"',
+ "branchToolbar.checkout": "Checkout {type}",
+ "branchToolbar.startFromOrigin": "Start from origin",
+ "branchToolbar.startWorktreeFromOrigin": "Start worktree from origin",
+ "branchToolbar.worktreeFromOriginHint":
+ "Creates the worktree from the latest matching branch on origin instead of your local branch.",
+ "branchToolbar.openPr": "Open {type} #{number} ({state})",
+ "branchToolbar.badgeCurrent": "current",
+ "branchToolbar.badgeWorktree": "worktree",
+ "branchToolbar.badgeRemote": "remote",
+ "branchToolbar.badgeDefault": "default",
+ "settings.section.general": "General",
+ "settings.section.appearance": "Appearance",
+ "settings.section.keybindings": "Keybindings",
+ "settings.section.providers": "Providers",
+ "settings.section.integrations": "Integrations",
+ "settings.section.sourceControl": "Source Control",
+ "settings.section.connections": "Connections",
+ "settings.section.archive": "Archive",
+ "settings.section.projects": "Projects",
+ "settings.section.snapShot": "SnapShots",
+ "settings.section.browser": "Browser",
+ "settings.section.about": "About",
+ "settings.option.monospaceFont": "Monospace font",
+ "settings.breadcrumb.root": "Settings",
+ "settings.breadcrumb.diagnostics": "Diagnostics",
+ "settings.search.placeholder": "Search",
+ "settings.search.noResults": "No settings found",
+ "settings.search.settings": "Search settings",
+ "settings.search.clearSettings": "Clear settings search",
+ "settings.search.results": "Settings search results",
+ "settings.option.colorScheme": "Color scheme",
+ "settings.option.themes": "Themes",
+ "settings.option.contrast": "Contrast",
+ "settings.option.glassOpacity": "Glass opacity",
+ "settings.option.environmentIdentification": "Environment identification",
+ "settings.option.interfaceFont": "Interface font",
+ "settings.option.promptFont": "Prompt font",
+ "settings.option.codeFont": "Code font",
+ "settings.option.terminalFont": "Terminal font",
+ "settings.option.fontSmoothing": "Font smoothing",
+ "settings.option.wordWrap": "Word wrap",
+ "settings.option.projectGrouping": "Project grouping",
+ "settings.option.autoSettleInactiveThreads": "Auto-settle inactive threads",
+ "settings.option.autoSettleMergedThreads": "Auto-settle merged threads",
+ "settings.option.timeFormat": "Time format",
+ "settings.option.hideWhitespaceChanges": "Hide whitespace changes",
+ "settings.option.skillsInSlashMenu": "Show skills in slash menu",
+ "settings.option.providerUpdateChecks": "Provider update checks",
+ "settings.option.newThreads": "New threads",
+ "settings.option.startFromOrigin": "Start from origin",
+ "settings.option.addProjectStartsIn": "Add project starts in",
+ "settings.option.archiveConfirmation": "Archive confirmation",
+ "settings.option.deleteConfirmation": "Delete confirmation",
+ "settings.option.quitConfirmation": "Hold to quit",
+ "settings.option.textGenerationModel": "Text generation model",
+ "settings.option.diagnostics": "Diagnostics",
+ "settings.option.legacyPlanMode": "Plan mode (legacy)",
+ "settings.option.legacyTokenStreaming": "Stream token by token (legacy)",
+ "settings.option.legacySidebar": "Sidebar (legacy)",
+ "settings.option.keybindings": "Keybindings",
+ "settings.option.providers": "Providers",
+ "settings.option.agentBrowserAccess": "Agent browser access",
+ "settings.option.browserDefaultViewport": "Default browser viewport",
+ "settings.option.browserDefaultZoom": "Default browser zoom",
+ "settings.option.browserDefaultAppearance": "Default browser appearance",
+ "settings.option.browserAutoShowFloatingPreview": "Auto-show floating preview",
+ "settings.option.sourceControl": "Source control",
+ "settings.option.remoteEnvironments": "Remote environments",
+ "settings.option.archivedThreads": "Archived threads",
+ "action.ok": "OK",
+ "desktop.menu.checkForUpdates": "Check for Updates...",
+ "desktop.menu.settings": "Settings...",
+ "desktop.menu.file": "File",
+ "desktop.menu.view": "View",
+ "desktop.menu.actualSize": "Actual Size",
+ "desktop.menu.zoomIn": "Zoom In",
+ "desktop.menu.zoomOut": "Zoom Out",
+ "desktop.update.upToDateTitle": "You're up to date!",
+ "desktop.update.upToDateMessage": "T3 Code {version} is currently the newest version available.",
+ "desktop.update.checkFailedTitle": "Update check failed",
+ "desktop.update.checkFailedMessage": "Could not check for updates.",
+ "desktop.update.checkFailedDetail": "An unknown error occurred. Please try again later.",
+ "desktop.update.unavailableTitle": "Updates unavailable",
+ "desktop.update.unavailableMessage": "Automatic updates are not available right now.",
+ "desktop.contextMenu.noSuggestions": "No suggestions",
+ "desktop.contextMenu.copyLink": "Copy Link",
+ "desktop.contextMenu.copyImage": "Copy Image",
+ "desktop.startup.failedTitle": "T3 Code failed to start",
+ "desktop.startup.failedDetail": "Stage: {stage}\n{message}{detail}",
+ "desktop.wsl.unavailableTitle": "WSL backend is still unavailable",
+ "desktop.wsl.unavailableMessage":
+ "{reason}\n\nT3 Code will use the Windows backend for this launch and retry WSL the next time the app starts.",
+ "desktop.wsl.couldNotStartTitle": "WSL backend couldn't start",
+ "desktop.wsl.couldNotStartMessage":
+ "{reason}\n\nFalling back to the Windows backend so T3 Code can open. Re-enable the WSL backend from Settings > Connections once the WSL distro is fixed.",
+ "desktop.splash.connecting": "Connecting to WSL…",
+} as const;
+
+export type MessageKey = keyof typeof enMessages;
diff --git a/packages/shared/src/i18n/index.test.ts b/packages/shared/src/i18n/index.test.ts
new file mode 100644
index 000000000000..b7d1a6b40357
--- /dev/null
+++ b/packages/shared/src/i18n/index.test.ts
@@ -0,0 +1,42 @@
+import { describe, expect, it } from "vite-plus/test";
+
+import { createI18n, i18n } from "./index.js";
+
+describe("i18n", () => {
+ it("defaults to zh-CN", () => {
+ expect(i18n.locale).toBe("zh-CN");
+ expect(i18n.t("action.cancel")).toBe("取消");
+ });
+
+ it("falls back to en for keys missing in the active locale", () => {
+ // zh-CN dictionary would omit a key we only ship in English; here we
+ // simulate by building a catalog whose active locale has an empty slate.
+ const i18nEn = createI18n({ locale: "en" });
+ expect(i18nEn.t("action.save")).toBe("Save");
+ });
+
+ it("returns the key itself when no locale has it", () => {
+ const i18nEn = createI18n({ locale: "en" });
+ // @ts-expect-error – intentionally unknown key to exercise the fallback path
+ const out: string = i18nEn.t("some.missing.key");
+ expect(out).toBe("some.missing.key");
+ });
+
+ it("switches locale at runtime and notifies subscribers", () => {
+ const inst = createI18n({ locale: "zh-CN" });
+ expect(inst.t("confirm.cancel")).toBe("取消");
+
+ let notified = 0;
+ const unsubscribe = inst.subscribe(() => notified++);
+
+ inst.setLocale("en");
+ expect(inst.locale).toBe("en");
+ expect(inst.t("confirm.cancel")).toBe("Cancel");
+ expect(notified).toBe(1);
+
+ unsubscribe();
+ inst.setLocale("zh-CN");
+ expect(notified).toBe(1);
+ expect(inst.t("confirm.cancel")).toBe("取消");
+ });
+});
diff --git a/packages/shared/src/i18n/index.ts b/packages/shared/src/i18n/index.ts
new file mode 100644
index 000000000000..c1ce8af484ab
--- /dev/null
+++ b/packages/shared/src/i18n/index.ts
@@ -0,0 +1,73 @@
+import { enMessages, type MessageKey } from "./en.ts";
+import { zhCNMessages } from "./zh-CN.ts";
+
+export type { MessageKey } from "./en.ts";
+
+export type Locale = "en" | "zh-CN";
+
+export const SUPPORTED_LOCALES: readonly Locale[] = ["en", "zh-CN"] as const;
+
+/** Language shown when no locale is resolved (per project decision). */
+export const DEFAULT_LOCALE: Locale = "zh-CN";
+
+type Params = Record;
+
+type Listener = () => void;
+
+/**
+ * Lightweight, zero-dependency, framework-agnostic message catalog usable from
+ * web, desktop (which renders web), and mobile. Missing keys fall through the
+ * active catalog -> en -> the key itself, so lookups never throw.
+ *
+ * Conventions (locale files, t() usage, key naming, scope): docs/internals/i18n.md
+ */
+export class I18n {
+ private _locale: Locale;
+ private readonly listeners = new Set();
+
+ constructor(options: { locale: Locale }) {
+ this._locale = options.locale;
+ }
+
+ get locale(): Locale {
+ return this._locale;
+ }
+
+ /** Switch language and notify subscribers (used by React via useI18n). */
+ setLocale(locale: Locale): void {
+ if (locale === this._locale) return;
+ this._locale = locale;
+ for (const listener of this.listeners) listener();
+ }
+
+ /** Subscribe to future locale changes. Returns an unsubscribe function. */
+ subscribe(listener: Listener): () => void {
+ this.listeners.add(listener);
+ return () => {
+ this.listeners.delete(listener);
+ };
+ }
+
+ /**
+ * Translate a key. Lookup order: active locale, then English fallback, then
+ * the raw key. Supports `{name}` interpolation via params.
+ */
+ t = (key: MessageKey, params?: Params): string => {
+ const active = this._locale === "zh-CN" ? zhCNMessages : {};
+ let text = active[key] ?? enMessages[key] ?? key;
+ if (params) {
+ for (const [name, value] of Object.entries(params)) {
+ text = text.split(`{${name}}`).join(String(value));
+ }
+ }
+ return text;
+ };
+}
+
+/** Default instance at the project default locale (zh-CN). */
+export const i18n = new I18n({ locale: DEFAULT_LOCALE });
+
+/** Build a catalog for a specific locale. */
+export function createI18n(options: { locale?: Locale } = {}): I18n {
+ return new I18n({ locale: options.locale ?? DEFAULT_LOCALE });
+}
diff --git a/packages/shared/src/i18n/zh-CN.ts b/packages/shared/src/i18n/zh-CN.ts
new file mode 100644
index 000000000000..2957212c477e
--- /dev/null
+++ b/packages/shared/src/i18n/zh-CN.ts
@@ -0,0 +1,181 @@
+import type { MessageKey } from "./en.ts";
+
+/**
+ * zh-CN messages.
+ *
+ * `Partial` so a key may be omitted here and transparently fall back to the
+ * English dictionary at lookup time. Keys are enforced to exist in en.ts via
+ * the `MessageKey` union.
+ */
+export const zhCNMessages: Partial> = {
+ "app.name": "T3 Code",
+ "action.save": "保存",
+ "action.cancel": "取消",
+ "settings.title": "设置",
+ "chat.sendMessage": "发送消息",
+ "chat.composer.connecting": "正在连接",
+ "chat.composer.preparingWorktree": "正在准备工作树",
+ "chat.composer.sending": "正在发送",
+ "chat.composer.placeholder.approval": "解决此审批请求以继续",
+ "chat.composer.placeholder.chooseOption": "请在上方选择一个选项",
+ "chat.composer.placeholder.customAnswer": "输入你自己的答案,或留空以使用所选选项",
+ "chat.composer.placeholder.planFeedback": "添加反馈以改进方案,或留空按原方案实施",
+ "chat.composer.placeholder.chooseProject": "请在上方选择一个项目以开始会话",
+ "chat.composer.placeholder.enableProvider": "在设置中启用提供商以发送消息",
+ "chat.composer.placeholder.disconnected": "提出更改、发送跟进,或附加图片",
+ "chat.composer.placeholder.default": "随便问,@引用文件/文件夹,$使用技能,或 / 使用命令",
+ "topbar.runOn": "运行于",
+ "confirm.title": "确认操作",
+ "confirm.cancel": "取消",
+ "confirm.confirm": "确认",
+ "confirm.defaultRequiresConfirmation": "请确认后继续。",
+ "prList.empty": "没有任何拉取请求",
+ "prList.noProjectsTitle": "尚未连接项目",
+ "prList.noProjectsDescription": "连接项目后即可浏览拉取请求。",
+ "prList.addProject": "添加项目",
+ "prList.searchingCaption": "正在搜索“{query}”…",
+ "prList.noMatches": "未找到与“{query}”匹配的结果",
+ "prList.noMatchesDescription": "请尝试其他搜索词,或添加新的拉取请求。",
+ "prList.clearSearch": "清除搜索",
+ "prList.checking": "正在检查…",
+ "prList.checkAgain": "再次检查",
+ "prList.filtersEmpty": "没有符合当前筛选条件的拉取请求",
+ "prList.filtersEmptyDescription": "调整筛选条件以查看更多拉取请求。",
+ "prList.allDescription": "所有已连接项目的拉取请求都会显示在这里。",
+ "prList.loading": "正在加载…",
+ "prList.loadMore": "加载更多",
+ "chat.empty.noActiveThread": "没有活动会话",
+ "chat.empty.title": "选择一个会话继续",
+ "chat.empty.description": "选择现有会话,或新建一个会话开始。",
+ "sidebar.allProjects": "全部项目",
+ "sidebar.noProjects": "还没有项目",
+ "sidebar.addProject": "添加项目",
+ "sidebar.noThreads": "还没有会话",
+ "sidebar.noThreadsInProject": "{project} 下还没有会话",
+ "sidebar.showMore": "再显示 {count} 个",
+ "sidebar.pullRequests": "拉取请求",
+ "sidebar.usage": "用量",
+ "sidebar.back": "返回",
+ "sidebar.projects": "项目",
+ "sidebar.search": "搜索",
+ "sidebar.searchThreads": "搜索会话",
+ "draft.hero.buildInPrefix": "我们可以在 ",
+ "draft.hero.buildInSuffix": " 中构建什么?",
+ "draft.hero.startSuffix": " 以开始",
+ "draft.hero.addProjectToStart": "添加一个项目以开始",
+ "draft.chooseProject": "选择项目",
+ "draft.changeProject": "更改项目",
+ "draft.addAProject": "添加项目",
+ "draft.newProject": "新建项目",
+ "chat.contextWindow.title": "上下文窗口",
+ "chat.contextWindow.totalProcessed": "累计处理",
+ "chat.contextWindow.usageAria": "上下文窗口使用量",
+ "chat.contextWindow.compact": "压缩",
+ "branchToolbar.searchRefs": "搜索引用…",
+ "branchToolbar.noRefs": "未找到引用。",
+ "branchToolbar.loadingRefs": "正在加载引用…",
+ "branchToolbar.loadingMoreRefs": "正在加载更多引用…",
+ "branchToolbar.showingOf": "显示 {count} / {total} 个引用",
+ "branchToolbar.copyBranchName": "复制分支名",
+ "branchToolbar.branchNameCopied": "已复制分支名",
+ "branchToolbar.copyBranchNameFailed": "复制分支名失败",
+ "branchToolbar.switchRefFailed": "切换引用失败。",
+ "branchToolbar.createRefFailed": "创建并切换引用失败。",
+ "branchToolbar.createNewRef": "创建新引用“{name}”",
+ "branchToolbar.checkout": "检出 {type}",
+ "branchToolbar.startFromOrigin": "从主分支开始",
+ "branchToolbar.startWorktreeFromOrigin": "从主分支创建工作树",
+ "branchToolbar.worktreeFromOriginHint":
+ "从 origin 的最新匹配分支创建工作树,而不是你本地的分支。",
+ "branchToolbar.openPr": "打开 {type} #{number}({state})",
+ "branchToolbar.badgeCurrent": "当前",
+ "branchToolbar.badgeWorktree": "工作树",
+ "branchToolbar.badgeRemote": "远程",
+ "branchToolbar.badgeDefault": "默认",
+ "settings.section.general": "通用",
+ "settings.section.appearance": "外观",
+ "settings.section.keybindings": "快捷键",
+ "settings.section.providers": "提供商",
+ "settings.section.integrations": "集成",
+ "settings.section.sourceControl": "源代码管理",
+ "settings.section.connections": "连接",
+ "settings.section.archive": "归档",
+ "settings.section.projects": "项目",
+ "settings.section.snapShot": "快照",
+ "settings.section.browser": "浏览器",
+ "settings.section.about": "关于",
+ "settings.option.monospaceFont": "等宽字体",
+ "settings.breadcrumb.root": "设置",
+ "settings.breadcrumb.diagnostics": "诊断",
+ "settings.search.placeholder": "搜索",
+ "settings.search.noResults": "未找到相关设置",
+ "settings.search.settings": "搜索设置",
+ "settings.search.clearSettings": "清除设置搜索",
+ "settings.search.results": "设置搜索结果",
+ "settings.option.colorScheme": "配色方案",
+ "settings.option.themes": "主题",
+ "settings.option.contrast": "对比度",
+ "settings.option.glassOpacity": "玻璃透明度",
+ "settings.option.environmentIdentification": "环境标识",
+ "settings.option.interfaceFont": "界面字体",
+ "settings.option.promptFont": "提示词字体",
+ "settings.option.codeFont": "代码字体",
+ "settings.option.terminalFont": "终端字体",
+ "settings.option.fontSmoothing": "字体平滑",
+ "settings.option.wordWrap": "自动换行",
+ "settings.option.projectGrouping": "项目分组",
+ "settings.option.autoSettleInactiveThreads": "自动归档不活跃会话",
+ "settings.option.autoSettleMergedThreads": "自动归档已合并会话",
+ "settings.option.timeFormat": "时间格式",
+ "settings.option.hideWhitespaceChanges": "隐藏空白字符变更",
+ "settings.option.skillsInSlashMenu": "在斜杠菜单中显示技能",
+ "settings.option.providerUpdateChecks": "提供商更新检查",
+ "settings.option.newThreads": "新会话",
+ "settings.option.startFromOrigin": "从主分支开始",
+ "settings.option.addProjectStartsIn": "添加项目的起始目录",
+ "settings.option.archiveConfirmation": "归档确认",
+ "settings.option.deleteConfirmation": "删除确认",
+ "settings.option.quitConfirmation": "按住以退出",
+ "settings.option.textGenerationModel": "文本生成模型",
+ "settings.option.diagnostics": "诊断",
+ "settings.option.legacyPlanMode": "计划模式(旧版)",
+ "settings.option.legacyTokenStreaming": "逐字流式输出(旧版)",
+ "settings.option.legacySidebar": "侧边栏(旧版)",
+ "settings.option.keybindings": "快捷键",
+ "settings.option.providers": "提供商",
+ "settings.option.agentBrowserAccess": "代理浏览器访问",
+ "settings.option.browserDefaultViewport": "默认浏览器视口",
+ "settings.option.browserDefaultZoom": "默认浏览器缩放",
+ "settings.option.browserDefaultAppearance": "默认浏览器外观",
+ "settings.option.browserAutoShowFloatingPreview": "自动显示浮动预览",
+ "settings.option.sourceControl": "源代码管理",
+ "settings.option.remoteEnvironments": "远程环境",
+ "settings.option.archivedThreads": "已归档会话",
+ "action.ok": "确定",
+ "desktop.menu.checkForUpdates": "检查更新…",
+ "desktop.menu.settings": "设置…",
+ "desktop.menu.file": "文件",
+ "desktop.menu.view": "视图",
+ "desktop.menu.actualSize": "实际大小",
+ "desktop.menu.zoomIn": "放大",
+ "desktop.menu.zoomOut": "缩小",
+ "desktop.update.upToDateTitle": "已是最新版本!",
+ "desktop.update.upToDateMessage": "T3 Code {version} 当前已是最新可用版本。",
+ "desktop.update.checkFailedTitle": "检查更新失败",
+ "desktop.update.checkFailedMessage": "无法检查更新。",
+ "desktop.update.checkFailedDetail": "发生未知错误,请稍后重试。",
+ "desktop.update.unavailableTitle": "更新不可用",
+ "desktop.update.unavailableMessage": "当前无法进行自动更新。",
+ "desktop.contextMenu.noSuggestions": "暂无建议",
+ "desktop.contextMenu.copyLink": "复制链接",
+ "desktop.contextMenu.copyImage": "复制图片",
+ "desktop.startup.failedTitle": "T3 Code 启动失败",
+ "desktop.startup.failedDetail": "阶段:{stage}\n{message}{detail}",
+ "desktop.wsl.unavailableTitle": "WSL 后端仍不可用",
+ "desktop.wsl.unavailableMessage":
+ "{reason}\n\nT3 Code 本次启动将改用 Windows 后端,并将在下次启动时重试 WSL。",
+ "desktop.wsl.couldNotStartTitle": "WSL 后端无法启动",
+ "desktop.wsl.couldNotStartMessage":
+ "{reason}\n\n已回退到 Windows 后端以确保 T3 Code 可以打开。修复 WSL 发行版后,请在“设置 > 连接”中重新启用 WSL 后端。",
+ "desktop.splash.connecting": "正在连接 WSL…",
+};