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({ > 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} >