diff --git a/apps/web/components/editors/codemirror/codemirror-code-block.tsx b/apps/web/components/editors/codemirror/codemirror-code-block.tsx index 9d56743d4b..7739686359 100644 --- a/apps/web/components/editors/codemirror/codemirror-code-block.tsx +++ b/apps/web/components/editors/codemirror/codemirror-code-block.tsx @@ -8,6 +8,7 @@ import { IconCheck, IconCopy } from "@tabler/icons-react"; import { cn } from "@/lib/utils"; import { getCodeMirrorExtension } from "@/lib/languages"; import { useCopyToClipboard } from "@/hooks/use-copy-to-clipboard"; +import { useTranslation } from "react-i18next"; type CodeBlockProps = { children: React.ReactNode; @@ -21,6 +22,7 @@ const getLanguageExtension = (language?: string) => { }; export function CodeMirrorCodeBlock({ children, className }: CodeBlockProps) { + const { t } = useTranslation(); const { copied, copy } = useCopyToClipboard(); const { theme, systemTheme } = useTheme(); const effectiveTheme = theme === "system" ? systemTheme : theme; @@ -52,7 +54,7 @@ export function CodeMirrorCodeBlock({ children, className }: CodeBlockProps) { "opacity-0 group-hover/code-block:opacity-100", "cursor-pointer", )} - title="Copy code" + title={t("editors:copyCode")} > {copied ? ( diff --git a/apps/web/components/editors/codemirror/codemirror-code-editor.tsx b/apps/web/components/editors/codemirror/codemirror-code-editor.tsx index 1d1a80a64d..1224087e42 100644 --- a/apps/web/components/editors/codemirror/codemirror-code-editor.tsx +++ b/apps/web/components/editors/codemirror/codemirror-code-editor.tsx @@ -33,6 +33,7 @@ import { revealCodeMirrorCursor, revealPendingCodeMirrorCursor, } from "./codemirror-cursor-navigation"; +import { useTranslation } from "react-i18next"; const SAVE_SHORTCUT = typeof navigator !== "undefined" && navigator.platform.includes("Mac") ? "\u2318" : "Ctrl"; @@ -67,14 +68,13 @@ function CodeMirrorCommentBadge({ sessionId?: string; commentCount: number; }) { + const { t } = useTranslation(); if (!enableComments || !sessionId || commentCount <= 0) return null; return (
- - {commentCount} comment{commentCount > 1 ? "s" : ""} - + {t("editors:commentCount", { count: commentCount })}
); } @@ -86,6 +86,7 @@ function CodeMirrorWrapButton({ wrapEnabled: boolean; onToggleWrap: () => void; }) { + const { t } = useTranslation(); return ( @@ -102,7 +103,9 @@ function CodeMirrorWrapButton({ )} - {wrapEnabled ? "Disable word wrap" : "Enable word wrap"} + + {wrapEnabled ? t("editors:disableWordWrap") : t("editors:enableWordWrap")} + ); } @@ -114,6 +117,7 @@ function CodeMirrorReloadButton({ hasRemoteUpdate?: boolean; onReloadFromAgent?: () => void; }) { + const { t } = useTranslation(); if (!hasRemoteUpdate || !onReloadFromAgent) return null; return ( @@ -126,15 +130,16 @@ function CodeMirrorReloadButton({ onClick={onReloadFromAgent} > - Reload + {t("editors:reload")} - Apply latest agent changes to this file + {t("editors:applyLatestAgentChangesToFile")} ); } function CodeMirrorDeleteButton({ onDelete }: { onDelete?: () => void }) { + const { t } = useTranslation(); if (!onDelete) return null; return ( @@ -149,7 +154,7 @@ function CodeMirrorDeleteButton({ onDelete }: { onDelete?: () => void }) { - Delete file + {t("editors:deleteFile")} ); } @@ -163,6 +168,7 @@ function CodeMirrorSaveButton({ isSaving: boolean; onSave: () => void; }) { + const { t } = useTranslation(); return ( - Preview markdown + {t("editors:previewMarkdown")} ); } @@ -297,6 +304,7 @@ function CodeMirrorToolbar({ type CodeMirrorEditorState = ReturnType; function CodeMirrorOverlays({ state }: { state: CodeMirrorEditorState }) { + const { t } = useTranslation(); return ( <> {state.floatingButtonPos && !state.textSelection && ( @@ -309,7 +317,7 @@ function CodeMirrorOverlays({ state }: { state: CodeMirrorEditorState }) { onClick={state.handleFloatingButtonClick} > - Comment + {t("editors:comment")} )} {state.textSelection && ( diff --git a/apps/web/components/editors/codemirror/use-codemirror-editor-state.ts b/apps/web/components/editors/codemirror/use-codemirror-editor-state.ts index 181d2fbecd..971880db2b 100644 --- a/apps/web/components/editors/codemirror/use-codemirror-editor-state.ts +++ b/apps/web/components/editors/codemirror/use-codemirror-editor-state.ts @@ -12,6 +12,9 @@ import type { DiffComment } from "@/lib/diff/types"; import { computeLineDiffStats } from "@/lib/diff"; import { useToast } from "@/components/toast-provider"; import { useCommandPanelOpen } from "@/lib/commands/command-registry"; +import { useTranslation } from "react-i18next"; +// Resolved inside `toDOM()`, which is a CodeMirror gutter callback with no hook scope. +import { t as moduleT } from "@/lib/i18n"; // --------------------------------------------------------------------------- // Types @@ -70,7 +73,7 @@ class CommentGutterMarker extends GutterMarker { toDOM() { const marker = document.createElement("div"); marker.className = "cm-comment-gutter-marker"; - marker.title = `${this.lineComments.length} comment${this.lineComments.length > 1 ? "s" : ""} - click to view`; + marker.title = moduleT("editors:commentMarkerTitle", { count: this.lineComments.length }); if (this.isFirstLine) { const svg = document.createElementNS("http://www.w3.org/2000/svg", "svg"); svg.setAttribute("width", "12"); @@ -198,6 +201,7 @@ type UseCodeMirrorEditorStateOpts = { // eslint-disable-next-line max-lines-per-function export function useCodeMirrorEditorState(opts: UseCodeMirrorEditorStateOpts) { + const { t } = useTranslation(); const { path, originalContent, @@ -469,8 +473,8 @@ export function useCodeMirrorEditorState(opts: UseCodeMirrorEditorStateOpts) { const comment = createCommentFromSelection(annotation); if (comment) { toast({ - title: "Comment added", - description: "Your comment will be sent with your next message.", + title: t("editors:commentAdded"), + description: t("editors:commentWillBeSentWithNextMessage"), }); } }, @@ -484,13 +488,13 @@ export function useCodeMirrorEditorState(opts: UseCodeMirrorEditorStateOpts) { try { const { queued } = await runComment(comment); toast({ - title: "Comment sent", - description: queued ? "Queued for the agent." : "Sent to the agent.", + title: t("editors:commentSent"), + description: queued ? t("editors:queuedForTheAgent") : t("editors:sentToTheAgent"), }); } catch { toast({ - title: "Failed to send comment", - description: "Please try again.", + title: t("editors:failedToSendComment"), + description: t("editors:pleaseTryAgain"), variant: "error", }); } @@ -512,7 +516,7 @@ export function useCodeMirrorEditorState(opts: UseCodeMirrorEditorStateOpts) { const nextComments = view.comments.filter((comment) => comment.id !== commentId); return nextComments.length > 0 ? { ...view, comments: nextComments } : null; }); - toast({ title: "Comment deleted" }); + toast({ title: t("editors:commentDeleted") }); }, [sessionId, removeComment, toast], ); @@ -529,7 +533,7 @@ export function useCodeMirrorEditorState(opts: UseCodeMirrorEditorStateOpts) { ), }; }); - toast({ title: "Comment updated" }); + toast({ title: t("editors:commentUpdated") }); }, [toast, updateComment], ); diff --git a/apps/web/components/editors/file-actions-dropdown.tsx b/apps/web/components/editors/file-actions-dropdown.tsx index 9f6cdf7184..e72ec22bdd 100644 --- a/apps/web/components/editors/file-actions-dropdown.tsx +++ b/apps/web/components/editors/file-actions-dropdown.tsx @@ -17,6 +17,7 @@ import { useOpenSessionFolder } from "@/hooks/use-open-session-folder"; import { useAppStore } from "@/components/state-provider"; import type { EditorOption } from "@/lib/types/http"; import { copyToClipboard } from "@/lib/utils/copy-to-clipboard"; +import { useTranslation } from "react-i18next"; type FileActionsDropdownProps = { /** File path to open / copy */ @@ -104,6 +105,7 @@ function useFileActions({ } export function FileActionsMenuItems(props: FileActionsMenuItemsProps) { + const { t } = useTranslation(); const { defaultEditorId, enabledEditors, handleCopyPath, handleOpenFolder, handleOpenInEditor } = useFileActions(props); @@ -118,23 +120,25 @@ export function FileActionsMenuItems(props: FileActionsMenuItemsProps) { {editor.name} {editor.id === defaultEditorId && ( - default + + {t("editors:default")} + )} ))} {enabledEditors.length === 0 && ( - No editors configured + {t("editors:noEditorsConfigured")} )} - Copy path + {t("editors:copyPath")} - Open folder + {t("editors:openFolder")} ); @@ -146,6 +150,7 @@ export function FileActionsDropdown({ size = "xs", onCopied, }: FileActionsDropdownProps) { + const { t } = useTranslation(); const btnClass = fileActionsButtonClass(size); const iconClass = size === "xs" ? "h-3.5 w-3.5" : "h-4 w-4"; @@ -159,7 +164,7 @@ export function FileActionsDropdown({ - Open with... + {t("editors:openWith")} diff --git a/apps/web/components/editors/monaco/diff-viewer-context-menu.tsx b/apps/web/components/editors/monaco/diff-viewer-context-menu.tsx index 295fc9e4dd..2a6f65007d 100644 --- a/apps/web/components/editors/monaco/diff-viewer-context-menu.tsx +++ b/apps/web/components/editors/monaco/diff-viewer-context-menu.tsx @@ -1,5 +1,6 @@ import { cn } from "@/lib/utils"; import { copyToClipboard } from "@/lib/utils/copy-to-clipboard"; +import { useTranslation } from "react-i18next"; export type ContextMenuState = { x: number; @@ -34,6 +35,7 @@ export function DiffViewerContextMenu({ onRevert, filePath, }: DiffViewerContextMenuProps) { + const { t } = useTranslation(); return (
e.stopPropagation()} > {contextMenu.isChangedLine && ( )} {onRevert && ( @@ -70,7 +72,7 @@ export function DiffViewerContextMenu({ onClose(); }} > - Revert all changes + {t("editors:revertAllChanges")} )} diff --git a/apps/web/components/editors/monaco/diff-viewer-toolbar.tsx b/apps/web/components/editors/monaco/diff-viewer-toolbar.tsx index 23a7b7aeb2..8539a2222a 100644 --- a/apps/web/components/editors/monaco/diff-viewer-toolbar.tsx +++ b/apps/web/components/editors/monaco/diff-viewer-toolbar.tsx @@ -15,6 +15,7 @@ import type { FileDiffData } from "@/lib/diff/types"; import type { ViewMode } from "@/hooks/use-global-view-mode"; import { ExternalVcsFileLink } from "@/components/editors/external-vcs-file-link"; import { useResponsiveBreakpoint } from "@/hooks/use-responsive-breakpoint"; +import { useTranslation } from "react-i18next"; interface DiffViewerToolbarProps { data: FileDiffData; @@ -57,6 +58,7 @@ function DiffViewerToggleButtons({ | "globalViewMode" | "setGlobalViewMode" >) { + const { t } = useTranslation(); return ( <> @@ -74,7 +76,9 @@ function DiffViewerToggleButtons({ )} - {foldUnchanged ? "Show all lines" : "Fold unchanged lines"} + + {foldUnchanged ? t("editors:showAllLines") : t("editors:foldUnchangedLines")} + @@ -87,7 +91,7 @@ function DiffViewerToggleButtons({ - Toggle word wrap + {t("editors:toggleWordWrap")} @@ -105,7 +109,9 @@ function DiffViewerToggleButtons({ - {globalViewMode === "split" ? "Switch to unified view" : "Switch to split view"} + {globalViewMode === "split" + ? t("editors:switchToUnifiedView") + : t("editors:switchToSplitView")} @@ -132,6 +138,7 @@ function DiffViewerActions({ publishedBranch, baseBranch, }: DiffViewerActionsProps) { + const { t } = useTranslation(); const { isMobile } = useResponsiveBreakpoint(); return (
@@ -153,7 +160,7 @@ function DiffViewerActions({ - Copy diff + {t("editors:copyDiff")} - Revert changes + {t("editors:revertChanges")} )} {onOpenFile && ( @@ -190,7 +197,7 @@ function DiffViewerActions({ - Edit + {t("common:edit")} )}
diff --git a/apps/web/components/editors/monaco/monaco-code-block.tsx b/apps/web/components/editors/monaco/monaco-code-block.tsx index d7c68b4a33..8222c94c32 100644 --- a/apps/web/components/editors/monaco/monaco-code-block.tsx +++ b/apps/web/components/editors/monaco/monaco-code-block.tsx @@ -8,6 +8,7 @@ import { getMonacoLanguageFromName } from "@/lib/editor/language-map"; import { EDITOR_FONT_FAMILY } from "@/lib/theme/editor-theme"; import { useCopyToClipboard } from "@/hooks/use-copy-to-clipboard"; import { initMonacoThemes } from "./monaco-init"; +import { useTranslation } from "react-i18next"; initMonacoThemes(); @@ -17,6 +18,7 @@ type MonacoCodeBlockProps = { }; export function MonacoCodeBlock({ children, className }: MonacoCodeBlockProps) { + const { t } = useTranslation(); const { copied, copy } = useCopyToClipboard(); const { resolvedTheme } = useTheme(); @@ -39,7 +41,7 @@ export function MonacoCodeBlock({ children, className }: MonacoCodeBlockProps) { "opacity-0 group-hover/code-block:opacity-100", "cursor-pointer", )} - title="Copy code" + title={t("editors:copyCode")} > {copied ? ( diff --git a/apps/web/components/editors/monaco/monaco-code-editor.tsx b/apps/web/components/editors/monaco/monaco-code-editor.tsx index 458315454f..d9fc714dfb 100644 --- a/apps/web/components/editors/monaco/monaco-code-editor.tsx +++ b/apps/web/components/editors/monaco/monaco-code-editor.tsx @@ -16,6 +16,7 @@ import { useMonacoEditorComments } from "./use-monaco-editor-state"; import { useMonacoEditorLsp, useMonacoDiffDecorations } from "./use-monaco-editor-lsp"; import { useMonacoWalkthroughRange } from "./use-monaco-walkthrough-range"; import { initMonacoThemes } from "./monaco-init"; +import { useTranslation } from "react-i18next"; initMonacoThemes(); @@ -172,7 +173,17 @@ function useMonacoCodeEditorSetup(props: MonacoCodeEditorProps) { return { contentRef, wrapperRef, language, state, lsp, diffStats, options }; } +function EditorLoading() { + const { t } = useTranslation(); + return ( +
+ {t("editors:loadingEditor")} +
+ ); +} + export function MonacoCodeEditor(props: MonacoCodeEditorProps) { + const { t } = useTranslation(); const { path, content, @@ -235,11 +246,7 @@ export function MonacoCodeEditor(props: MonacoCodeEditorProps) { onMount={state.handleEditorDidMount} keepCurrentModel options={options} - loading={ -
- Loading editor... -
- } + loading={} /> {walkthroughRange ? (
- Comment + {t("editors:comment")} )}
diff --git a/apps/web/components/editors/monaco/monaco-diff-viewer.tsx b/apps/web/components/editors/monaco/monaco-diff-viewer.tsx index 27ce72569d..1971bcee0e 100644 --- a/apps/web/components/editors/monaco/monaco-diff-viewer.tsx +++ b/apps/web/components/editors/monaco/monaco-diff-viewer.tsx @@ -17,6 +17,7 @@ import { useDiffViewerComments } from "./use-diff-viewer-comments"; import { useGlobalFolding } from "./use-global-folding"; import { resolveDiffContent, buildDiffEditorOptions } from "./diff-viewer-helpers"; import { initMonacoThemes } from "./monaco-init"; +import { useTranslation } from "react-i18next"; initMonacoThemes(); @@ -145,6 +146,7 @@ function useMonacoDiffViewerState(props: MonacoDiffViewerProps) { } export function MonacoDiffViewer(props: MonacoDiffViewerProps) { + const { t } = useTranslation(); const { className, compact = false, hideHeader = false, onOpenFile, onRevert } = props; const state = useMonacoDiffViewerState(props); const { wrapperRef, hasDiff, contextMenu, setContextMenu } = state; @@ -159,7 +161,7 @@ export function MonacoDiffViewer(props: MonacoDiffViewerProps) { className, )} > - No diff available + {t("editors:noDiffAvailable")}
); } @@ -205,7 +207,7 @@ export function MonacoDiffViewer(props: MonacoDiffViewerProps) { options={state.options} loading={
- Loading diff... + {t("editors:loadingDiff")}
} /> diff --git a/apps/web/components/editors/monaco/monaco-editor-toolbar.tsx b/apps/web/components/editors/monaco/monaco-editor-toolbar.tsx index d44e2d26d2..115c2ef5c4 100644 --- a/apps/web/components/editors/monaco/monaco-editor-toolbar.tsx +++ b/apps/web/components/editors/monaco/monaco-editor-toolbar.tsx @@ -25,6 +25,7 @@ import { PanelHeaderBarSplit } from "@/components/task/panel-primitives"; import { LspStatusButton } from "@/components/editors/lsp-status-button"; import type { LspStatus } from "@/lib/lsp/lsp-client-manager"; import type { LspProgressSnapshot } from "@/lib/lsp/lsp-progress"; +import { useTranslation } from "react-i18next"; const SAVE_SHORTCUT = typeof navigator !== "undefined" && navigator.platform.includes("Mac") ? "\u2318" : "Ctrl"; @@ -38,6 +39,7 @@ function SaveButton({ isSaving: boolean; onSave: () => void; }) { + const { t } = useTranslation(); return ( - {isVisible ? "Hide diff indicators" : "Show diff indicators"} + + {isVisible ? t("editors:hideDiffIndicators") : t("editors:showDiffIndicators")} + ); } @@ -135,13 +139,14 @@ function WrapButton({ wrapEnabled: boolean; onToggleWrap: () => void; }) { + const { t } = useTranslation(); const wrapClass = `h-8 w-8 p-0 cursor-pointer ${wrapEnabled ? "text-foreground" : "text-muted-foreground"}`; const wrapIcon = wrapEnabled ? ( ) : ( ); - const wrapLabel = wrapEnabled ? "Disable word wrap" : "Enable word wrap"; + const wrapLabel = wrapEnabled ? t("editors:disableWordWrap") : t("editors:enableWordWrap"); return ( @@ -162,6 +167,7 @@ function ReloadFromAgentButton({ hasRemoteUpdate?: boolean; onReloadFromAgent?: () => void; }) { + const { t } = useTranslation(); if (!hasRemoteUpdate || !onReloadFromAgent) return null; return ( @@ -174,15 +180,16 @@ function ReloadFromAgentButton({ onClick={onReloadFromAgent} > - Reload + {t("editors:reload")} - Apply latest agent changes to this file + {t("editors:applyLatestAgentChangesToFile")} ); } function DeleteButton({ onDelete }: { onDelete?: () => void }) { + const { t } = useTranslation(); if (!onDelete) return null; return ( @@ -197,12 +204,13 @@ function DeleteButton({ onDelete }: { onDelete?: () => void }) { - Delete file + {t("editors:deleteFile")} ); } function MarkdownPreviewButton({ onTogglePreview }: { onTogglePreview: () => void }) { + const { t } = useTranslation(); return ( @@ -216,7 +224,7 @@ function MarkdownPreviewButton({ onTogglePreview }: { onTogglePreview: () => voi - Preview markdown + {t("editors:previewMarkdown")} ); } diff --git a/apps/web/components/editors/monaco/use-monaco-editor-state.ts b/apps/web/components/editors/monaco/use-monaco-editor-state.ts index 1270104de7..be370c9310 100644 --- a/apps/web/components/editors/monaco/use-monaco-editor-state.ts +++ b/apps/web/components/editors/monaco/use-monaco-editor-state.ts @@ -11,6 +11,7 @@ import { useCommandPanelOpen } from "@/lib/commands/command-registry"; import { useGutterComments } from "@/hooks/use-gutter-comments"; import { consumePendingCursorPosition } from "@/hooks/use-file-editors"; import type { DiffComment } from "@/lib/diff/types"; +import { useTranslation } from "react-i18next"; export type FormZoneRange = { startLine: number; @@ -44,6 +45,7 @@ type UseMonacoEditorStateOpts = { // eslint-disable-next-line max-lines-per-function export function useMonacoEditorComments(opts: UseMonacoEditorStateOpts) { + const { t } = useTranslation(); const { path, repo, enableComments, sessionId, wrapperRef, onChange, onSave, contentRef } = opts; const [wrapEnabled, setWrapEnabled] = useState(true); @@ -377,8 +379,8 @@ export function useMonacoEditorComments(opts: UseMonacoEditorStateOpts) { const comment = createCommentFromForm(annotation); if (comment) { toast({ - title: "Comment added", - description: "Your comment will be sent with your next message.", + title: t("editors:commentAdded"), + description: t("editors:commentWillBeSentWithNextMessage"), }); } }, @@ -392,13 +394,13 @@ export function useMonacoEditorComments(opts: UseMonacoEditorStateOpts) { try { const { queued } = await runComment(comment); toast({ - title: "Comment sent", - description: queued ? "Queued for the agent." : "Sent to the agent.", + title: t("editors:commentSent"), + description: queued ? t("editors:queuedForTheAgent") : t("editors:sentToTheAgent"), }); } catch { toast({ - title: "Failed to send comment", - description: "Please try again.", + title: t("editors:failedToSendComment"), + description: t("editors:pleaseTryAgain"), variant: "error", }); } @@ -412,13 +414,13 @@ export function useMonacoEditorComments(opts: UseMonacoEditorStateOpts) { try { const { queued } = await runComment(comment); toast({ - title: "Comment sent", - description: queued ? "Queued for the agent." : "Sent to the agent.", + title: t("editors:commentSent"), + description: queued ? t("editors:queuedForTheAgent") : t("editors:sentToTheAgent"), }); } catch { toast({ - title: "Failed to send comment", - description: "Please try again.", + title: t("editors:failedToSendComment"), + description: t("editors:pleaseTryAgain"), variant: "error", }); } @@ -430,7 +432,7 @@ export function useMonacoEditorComments(opts: UseMonacoEditorStateOpts) { (commentId: string) => { if (!sessionId) return; removeComment(commentId); - toast({ title: "Comment deleted" }); + toast({ title: t("editors:commentDeleted") }); }, [sessionId, removeComment, toast], ); @@ -439,7 +441,7 @@ export function useMonacoEditorComments(opts: UseMonacoEditorStateOpts) { (commentId: string, annotation: string) => { updateComment(commentId, { text: annotation }); setEditingComment(null); - toast({ title: "Comment updated" }); + toast({ title: t("editors:commentUpdated") }); }, [updateComment, setEditingComment, toast], ); diff --git a/apps/web/components/editors/shiki/shiki-code-block.tsx b/apps/web/components/editors/shiki/shiki-code-block.tsx index 0bc041c91f..f87e59b8e7 100644 --- a/apps/web/components/editors/shiki/shiki-code-block.tsx +++ b/apps/web/components/editors/shiki/shiki-code-block.tsx @@ -6,6 +6,7 @@ import { IconCheck, IconCopy } from "@tabler/icons-react"; import { cn } from "@/lib/utils"; import { useCopyToClipboard } from "@/hooks/use-copy-to-clipboard"; import { highlightCode } from "./shiki-highlighter"; +import { useTranslation } from "react-i18next"; type ShikiCodeBlockProps = { children: React.ReactNode; @@ -13,6 +14,7 @@ type ShikiCodeBlockProps = { }; export function ShikiCodeBlock({ children, className }: ShikiCodeBlockProps) { + const { t } = useTranslation(); const { copied, copy } = useCopyToClipboard(); const { theme, systemTheme } = useTheme(); const effectiveTheme = theme === "system" ? systemTheme : theme; @@ -42,7 +44,7 @@ export function ShikiCodeBlock({ children, className }: ShikiCodeBlockProps) { "opacity-0 group-hover/code-block:opacity-100", "cursor-pointer", )} - title="Copy code" + title={t("editors:copyCode")} > {copied ? ( diff --git a/apps/web/components/editors/tiptap/plan-bubble-menu.tsx b/apps/web/components/editors/tiptap/plan-bubble-menu.tsx index a78eb08f45..3bf29ba084 100644 --- a/apps/web/components/editors/tiptap/plan-bubble-menu.tsx +++ b/apps/web/components/editors/tiptap/plan-bubble-menu.tsx @@ -16,6 +16,7 @@ import { } from "@tabler/icons-react"; import { cn } from "@/lib/utils"; import type { TextSelection } from "./tiptap-plan-editor"; +import { useTranslation } from "react-i18next"; type PlanBubbleMenuProps = { editor: Editor; @@ -59,6 +60,7 @@ function LinkInput({ onSubmit: (url: string) => void; onCancel: () => void; }) { + const { t } = useTranslation(); const [value, setValue] = useState(""); return (
@@ -70,7 +72,7 @@ function LinkInput({ if (e.key === "Enter") onSubmit(value); if (e.key === "Escape") onCancel(); }} - placeholder="Paste link..." + placeholder={t("editors:pasteLink")} className="text-xs bg-transparent outline-none px-2 py-1 w-48" autoFocus /> @@ -79,7 +81,7 @@ function LinkInput({ className="text-xs px-2 py-1 rounded hover:bg-muted cursor-pointer" onClick={() => onSubmit(value)} > - Apply + {t("editors:apply")}
); @@ -94,48 +96,49 @@ function FormatToolbar({ onLinkClick: () => void; onComment?: () => void; }) { + const { t } = useTranslation(); return (
editor.chain().focus().toggleBold().run()} /> editor.chain().focus().toggleItalic().run()} /> editor.chain().focus().toggleUnderline().run()} /> editor.chain().focus().toggleStrike().run()} /> editor.chain().focus().toggleCode().run()} /> editor.chain().focus().toggleHighlight().run()} /> @@ -144,7 +147,7 @@ function FormatToolbar({ = { + "editors:slashHeading1": "Heading 1", + "editors:slashBulletList": "Bullet List", + "editors:slashCodeBlock": "Code Block", + "editors:slashTable": "Table", + }; + return ((key: string) => { + const en = labels[key] ?? key; + return locale === "en" ? en : `[${en}]`; + }) as unknown as TFunction; +} + +describe("buildPlanSlashCommands", () => { + it("resolves labels through the passed t", () => { + const commands = buildPlanSlashCommands(fakeT("en")); + expect(commands.find((c) => c.id === "heading1")?.label).toBe("Heading 1"); + expect(commands.find((c) => c.id === "table")?.label).toBe("Table"); + }); + + it("re-resolves copy when called again with a different locale", () => { + const before = buildPlanSlashCommands(fakeT("en")); + const after = buildPlanSlashCommands(fakeT("xx")); + + expect(before.find((c) => c.id === "heading1")?.label).toBe("Heading 1"); + expect(after.find((c) => c.id === "heading1")?.label).toBe("[Heading 1]"); + }); + + it("keeps ids and category grouping keys stable across locales", () => { + const before = buildPlanSlashCommands(fakeT("en")); + const after = buildPlanSlashCommands(fakeT("xx")); + + expect(after.map((c) => c.id)).toEqual(before.map((c) => c.id)); + expect(after.map((c) => c.category)).toEqual(before.map((c) => c.category)); + expect(new Set(after.map((c) => c.category))).toEqual(new Set(["text", "lists", "blocks"])); + }); +}); + +describe("filterPlanSlashCommands", () => { + it("returns every command for an empty query", () => { + const commands = buildPlanSlashCommands(fakeT("en")); + expect(filterPlanSlashCommands(commands, "")).toHaveLength(commands.length); + }); + + it("matches on the translated label", () => { + const commands = buildPlanSlashCommands(fakeT("en")); + const matched = filterPlanSlashCommands(commands, "bullet"); + expect(matched.map((c) => c.id)).toEqual(["bulletList"]); + }); + + it("matches on the stable id even when the label is translated away", () => { + const commands = buildPlanSlashCommands(fakeT("xx")); + // The label is now "[Code Block]", so this only matches via `id`. + const matched = filterPlanSlashCommands(commands, "codeblock"); + expect(matched.map((c) => c.id)).toEqual(["codeBlock"]); + }); + + it("is case-insensitive", () => { + const commands = buildPlanSlashCommands(fakeT("en")); + expect(filterPlanSlashCommands(commands, "TABLE").map((c) => c.id)).toEqual(["table"]); + }); + + it("returns nothing when no command matches", () => { + const commands = buildPlanSlashCommands(fakeT("en")); + expect(filterPlanSlashCommands(commands, "nonexistent")).toEqual([]); + }); +}); diff --git a/apps/web/components/editors/tiptap/plan-slash-commands.ts b/apps/web/components/editors/tiptap/plan-slash-commands.ts index 4aacb5b87f..a4615cc595 100644 --- a/apps/web/components/editors/tiptap/plan-slash-commands.ts +++ b/apps/web/components/editors/tiptap/plan-slash-commands.ts @@ -17,110 +17,120 @@ import { IconMinus, } from "@tabler/icons-react"; import type { MenuState } from "@/components/task/chat/tiptap-suggestion"; +import type { TFunction } from "i18next"; // ── Types ──────────────────────────────────────────────────────────── +/** + * Stable grouping id, not display copy: it is the `Map` key the menu groups by. + * `plan-slash-menu.tsx` resolves it to a translated heading at render. + */ +export type PlanSlashCategory = "text" | "lists" | "blocks"; + export type PlanSlashCommand = { id: string; label: string; description: string; icon: Icon; - category: string; + category: PlanSlashCategory; action: (editor: Editor, range: Range) => void; }; // ── Command definitions ────────────────────────────────────────────── -const PLAN_SLASH_COMMANDS: PlanSlashCommand[] = [ - // Text +const textCommands = (t: TFunction): PlanSlashCommand[] => [ { id: "heading1", - label: "Heading 1", - description: "Large heading", + label: t("editors:slashHeading1"), + description: t("editors:slashHeading1Description"), icon: IconH1, - category: "Text", + category: "text", action: (editor, range) => { editor.chain().focus().deleteRange(range).toggleHeading({ level: 1 }).run(); }, }, { id: "heading2", - label: "Heading 2", - description: "Medium heading", + label: t("editors:slashHeading2"), + description: t("editors:slashHeading2Description"), icon: IconH2, - category: "Text", + category: "text", action: (editor, range) => { editor.chain().focus().deleteRange(range).toggleHeading({ level: 2 }).run(); }, }, { id: "heading3", - label: "Heading 3", - description: "Small heading", + label: t("editors:slashHeading3"), + description: t("editors:slashHeading3Description"), icon: IconH3, - category: "Text", + category: "text", action: (editor, range) => { editor.chain().focus().deleteRange(range).toggleHeading({ level: 3 }).run(); }, }, - // Lists +]; + +const listCommands = (t: TFunction): PlanSlashCommand[] => [ { id: "bulletList", - label: "Bullet List", - description: "Unordered list", + label: t("editors:slashBulletList"), + description: t("editors:slashBulletListDescription"), icon: IconList, - category: "Lists", + category: "lists", action: (editor, range) => { editor.chain().focus().deleteRange(range).toggleBulletList().run(); }, }, { id: "numberedList", - label: "Numbered List", - description: "Ordered list", + label: t("editors:slashNumberedList"), + description: t("editors:slashNumberedListDescription"), icon: IconListNumbers, - category: "Lists", + category: "lists", action: (editor, range) => { editor.chain().focus().deleteRange(range).toggleOrderedList().run(); }, }, { id: "taskList", - label: "Task List", - description: "Checklist items", + label: t("editors:slashTaskList"), + description: t("editors:slashTaskListDescription"), icon: IconListCheck, - category: "Lists", + category: "lists", action: (editor, range) => { editor.chain().focus().deleteRange(range).toggleTaskList().run(); }, }, - // Blocks +]; + +const blockCommands = (t: TFunction): PlanSlashCommand[] => [ { id: "codeBlock", - label: "Code Block", - description: "Fenced code", + label: t("editors:slashCodeBlock"), + description: t("editors:slashCodeBlockDescription"), icon: IconCode, - category: "Blocks", + category: "blocks", action: (editor, range) => { editor.chain().focus().deleteRange(range).toggleCodeBlock().run(); }, }, { id: "blockquote", - label: "Blockquote", - description: "Quote text", + label: t("editors:slashBlockquote"), + description: t("editors:slashBlockquoteDescription"), icon: IconQuote, - category: "Blocks", + category: "blocks", action: (editor, range) => { editor.chain().focus().deleteRange(range).toggleBlockquote().run(); }, }, { id: "table", - label: "Table", - description: "3x3 table", + label: t("editors:slashTable"), + description: t("editors:slashTableDescription"), icon: IconTable, - category: "Blocks", + category: "blocks", action: (editor, range) => { editor .chain() @@ -132,16 +142,37 @@ const PLAN_SLASH_COMMANDS: PlanSlashCommand[] = [ }, { id: "horizontalRule", - label: "Divider", - description: "Horizontal line", + label: t("editors:slashDivider"), + description: t("editors:slashDividerDescription"), icon: IconMinus, - category: "Blocks", + category: "blocks", action: (editor, range) => { editor.chain().focus().deleteRange(range).setHorizontalRule().run(); }, }, ]; +/** Exported for unit testing — see `plan-slash-commands.test.ts`. */ +export const buildPlanSlashCommands = (t: TFunction): PlanSlashCommand[] => [ + ...textCommands(t), + ...listCommands(t), + ...blockCommands(t), +]; + +/** + * Matches on the translated label and on the stable `id`, so the English command + * name keeps working as a query under a non-English locale. + * + * Exported for unit testing — see `plan-slash-commands.test.ts`. + */ +export function filterPlanSlashCommands(commands: PlanSlashCommand[], query: string) { + if (!query) return commands; + const lq = query.toLowerCase(); + return commands.filter( + (cmd) => cmd.label.toLowerCase().includes(lq) || cmd.id.toLowerCase().includes(lq), + ); +} + // ── Empty state ────────────────────────────────────────────────────── const EMPTY_STATE: MenuState = { @@ -157,6 +188,7 @@ const EMPTY_STATE: MenuState = { const PlanSlashPluginKey = new PluginKey("planSlashCommands"); export function createPlanSlashExtension( + getT: () => TFunction, setMenuState: (state: MenuState) => void, onKeyDown: (event: KeyboardEvent) => boolean, ) { @@ -172,13 +204,10 @@ export function createPlanSlashExtension( allowSpaces: false, startOfLine: true, - items: ({ query }) => { - if (!query) return PLAN_SLASH_COMMANDS; - const lq = query.toLowerCase(); - return PLAN_SLASH_COMMANDS.filter( - (cmd) => cmd.label.toLowerCase().includes(lq) || cmd.id.toLowerCase().includes(lq), - ); - }, + items: ({ query }) => + // Resolved per call, so the menu follows a locale switch without the + // extension itself having to be rebuilt. + filterPlanSlashCommands(buildPlanSlashCommands(getT()), query), command: ({ editor, range, props: cmd }) => { cmd.action(editor, range); diff --git a/apps/web/components/editors/tiptap/plan-slash-menu.tsx b/apps/web/components/editors/tiptap/plan-slash-menu.tsx index 8bd634b5d4..f284ebd420 100644 --- a/apps/web/components/editors/tiptap/plan-slash-menu.tsx +++ b/apps/web/components/editors/tiptap/plan-slash-menu.tsx @@ -3,7 +3,18 @@ import { createElement } from "react"; import { PopupMenu, PopupMenuItem, useMenuItemRefs } from "@/components/task/chat/popup-menu"; import type { MenuState } from "@/components/task/chat/tiptap-suggestion"; -import type { PlanSlashCommand } from "./plan-slash-commands"; +import type { PlanSlashCategory, PlanSlashCommand } from "./plan-slash-commands"; +import { useTranslation } from "react-i18next"; + +/** + * Category ids are stable grouping keys, so the heading copy is resolved here at + * render rather than stored on the command. Holds keys, never `t()` results. + */ +const CATEGORY_LABEL_KEYS: Record = { + text: "editors:slashCategoryText", + lists: "editors:slashCategoryLists", + blocks: "editors:slashCategoryBlocks", +}; type PlanSlashMenuProps = { menuState: MenuState; @@ -12,12 +23,13 @@ type PlanSlashMenuProps = { }; export function PlanSlashMenu({ menuState, selectedIndex, setSelectedIndex }: PlanSlashMenuProps) { + const { t } = useTranslation(); const { setItemRef } = useMenuItemRefs(selectedIndex); if (!menuState.isOpen || menuState.items.length === 0) return null; // Group items by category - const grouped = new Map(); + const grouped = new Map(); let idx = 0; for (const item of menuState.items) { const existing = grouped.get(item.category); @@ -34,7 +46,7 @@ export function PlanSlashMenu({ menuState, selectedIndex, setSelectedIndex }: Pl isOpen={menuState.isOpen} position={null} clientRect={menuState.clientRect} - title="Insert block" + title={t("editors:insertBlock")} selectedIndex={selectedIndex} onClose={() => menuState.command?.(menuState.items[0]!)} hasItems={menuState.items.length > 0} @@ -44,7 +56,7 @@ export function PlanSlashMenu({ menuState, selectedIndex, setSelectedIndex }: Pl
- {category} + {t(CATEGORY_LABEL_KEYS[category])}
{group.items.map((item) => { diff --git a/apps/web/components/editors/tiptap/tiptap-mermaid-extension.ts b/apps/web/components/editors/tiptap/tiptap-mermaid-extension.ts index 3e62319f69..df26bc3f0f 100644 --- a/apps/web/components/editors/tiptap/tiptap-mermaid-extension.ts +++ b/apps/web/components/editors/tiptap/tiptap-mermaid-extension.ts @@ -8,6 +8,8 @@ import type { Node as PmNode } from "@tiptap/pm/model"; import type { NodeView } from "@tiptap/pm/view"; import CodeBlockLowlight from "@tiptap/extension-code-block-lowlight"; import mermaid from "mermaid"; +// Resolved inside the node view, which is a ProseMirror callback with no hook scope. +import { t } from "@/lib/i18n"; import { DEFAULT_SCALE, SCALE_STEP, @@ -180,7 +182,7 @@ class MermaidNodeView implements NodeView { codeToggle.type = "button"; codeToggle.className = "mermaid-zoom-btn"; codeToggle.innerHTML = CODE_ICON_SVG; - codeToggle.title = "Toggle code"; + codeToggle.title = t("editors:toggleCode"); codeToggle.addEventListener("click", () => this.toggleCode()); this.toolbar.append( diff --git a/apps/web/components/editors/tiptap/tiptap-plan-editor.tsx b/apps/web/components/editors/tiptap/tiptap-plan-editor.tsx index d3b3d42df4..cc7bb3db16 100644 --- a/apps/web/components/editors/tiptap/tiptap-plan-editor.tsx +++ b/apps/web/components/editors/tiptap/tiptap-plan-editor.tsx @@ -32,6 +32,7 @@ import { PlanDragHandle } from "./plan-drag-handle"; import type { MenuState } from "@/components/task/chat/tiptap-suggestion"; import { DOMParser as PmDOMParser } from "@tiptap/pm/model"; import type { Editor } from "@tiptap/core"; +import { useTranslation } from "react-i18next"; export type { CommentForEditor }; @@ -196,6 +197,7 @@ function handleSlashKeyDown( /** Slash command menu state and keyboard navigation. */ function useSlashMenu() { + const { t } = useTranslation(); const [menuState, setMenuState] = useState>(EMPTY_SLASH_STATE); const [selectedIndex, setSelectedIndex] = useState(0); const selectedIndexRef = useRef(0); @@ -221,10 +223,19 @@ function useSlashMenu() { // Stable callback passed to extension — never changes, delegates via ref const stableKeyDown = useCallback((event: KeyboardEvent) => keyDownRef.current(event), []); + // Same ref treatment for `t`: keeping it out of the extension's identity stops + // a locale switch from rebuilding the ProseMirror plugin mid-edit, and the + // command list is rebuilt per `items()` call, so the menu still re-resolves. + const tRef = useRef(t); + useLayoutEffect(() => { + tRef.current = t; + }); + const stableGetT = useCallback(() => tRef.current, []); + /* eslint-disable react-hooks/refs -- stableKeyDown reads ref for deferred access, not during render */ const extension = useMemo( - () => createPlanSlashExtension(setMenuState, stableKeyDown), - [stableKeyDown], + () => createPlanSlashExtension(stableGetT, setMenuState, stableKeyDown), + [stableGetT, stableKeyDown], ); /* eslint-enable react-hooks/refs */ @@ -283,16 +294,17 @@ type PlanEditorState = { /** Hook encapsulating TipTap editor setup, extensions, and lifecycle effects. */ function usePlanEditor(props: TipTapPlanEditorProps): PlanEditorState { + const { t } = useTranslation(); const { value, onChange, - placeholder = "Start typing...", onSelectionChange, comments = [], onCommentClick, onCommentDeleted, onEditorReady, } = props; + const placeholder = props.placeholder ?? t("editors:startTyping"); const editorRef = useRef>(null); const onChangeRef = useRef(onChange); @@ -370,6 +382,7 @@ function usePlanEditor(props: TipTapPlanEditorProps): PlanEditorState { } export function TipTapPlanEditor(props: TipTapPlanEditorProps) { + const { t } = useTranslation(); const { resolvedTheme } = useTheme(); const wrapperRef = useRef(null); const { editor, editorRef, onSelectionChangeRef, onCommentClickRef, isReady, slash } = @@ -406,7 +419,7 @@ export function TipTapPlanEditor(props: TipTapPlanEditorProps) { /> {!isReady && (
- Loading editor... + {t("editors:loadingEditor")}
)}
diff --git a/apps/web/components/quick-chat/configuration-chat-toggle.tsx b/apps/web/components/quick-chat/configuration-chat-toggle.tsx index 565ec1e4d3..cc9a65a2d5 100644 --- a/apps/web/components/quick-chat/configuration-chat-toggle.tsx +++ b/apps/web/components/quick-chat/configuration-chat-toggle.tsx @@ -1,6 +1,7 @@ "use client"; import { Switch } from "@kandev/ui/switch"; +import { useTranslation } from "react-i18next"; export function ConfigurationChatToggle({ checked, @@ -11,6 +12,7 @@ export function ConfigurationChatToggle({ disabled?: boolean; onCheckedChange: (checked: boolean) => void; }) { + const { t } = useTranslation(); return (

- Configuration chat + {t("chat:configurationChat")}

-

- Let the agent update Kandev settings, workflows, agent profiles, and MCP configuration. -

+

{t("chat:configurationChatDescription")}

@@ -74,7 +76,7 @@ function QuickChatClarificationSection({
- Clarification needed + {t("chat:clarificationNeeded")}