From 32c5161934b6709dc7599e5b1163e3bebeab7598 Mon Sep 17 00:00:00 2001 From: wta <412751231@qq.com> Date: Tue, 9 Jun 2026 11:41:45 +0800 Subject: [PATCH] feat: add English and Chinese localization --- src/terminal/agent-session-adapters.ts | 10 + .../terminal/agent-session-adapters.test.ts | 4 + web-ui/src/App.tsx | 24 +- web-ui/src/components/board-card.tsx | 81 +- web-ui/src/components/board-column.tsx | 18 +- .../src/components/branch-select-dropdown.tsx | 10 +- web-ui/src/components/card-detail-view.tsx | 45 +- web-ui/src/components/clear-trash-dialog.tsx | 14 +- .../detail-panels/column-context-panel.tsx | 22 +- web-ui/src/components/language-switcher.tsx | 60 ++ .../src/components/open-workspace-button.tsx | 8 +- .../components/project-navigation-panel.tsx | 122 +-- .../components/runtime-settings-dialog.tsx | 167 ++-- .../src/components/search-select-dropdown.tsx | 24 +- .../shared/account-organization-section.tsx | 36 +- .../shared/cline-add-provider-dialog.tsx | 68 +- .../components/shared/cline-setup-section.tsx | 203 +++-- .../components/task-agent-model-picker.tsx | 29 +- web-ui/src/components/task-create-dialog.tsx | 62 +- .../src/components/task-file-image-hint.tsx | 35 + .../components/task-inline-create-card.tsx | 41 +- .../src/components/task-prompt-composer.tsx | 12 +- .../components/task-trash-warning-dialog.tsx | 38 +- web-ui/src/components/top-bar.tsx | 78 +- web-ui/src/i18n/i18n-context.tsx | 56 ++ web-ui/src/i18n/translations.ts | 799 ++++++++++++++++++ web-ui/src/main.tsx | 35 +- web-ui/src/storage/local-storage-store.ts | 1 + web-ui/src/utils/board-column-title.ts | 13 + 29 files changed, 1603 insertions(+), 512 deletions(-) create mode 100644 web-ui/src/components/language-switcher.tsx create mode 100644 web-ui/src/components/task-file-image-hint.tsx create mode 100644 web-ui/src/i18n/i18n-context.tsx create mode 100644 web-ui/src/i18n/translations.ts create mode 100644 web-ui/src/utils/board-column-title.ts diff --git a/src/terminal/agent-session-adapters.ts b/src/terminal/agent-session-adapters.ts index 75a0d856a..46702dbf1 100644 --- a/src/terminal/agent-session-adapters.ts +++ b/src/terminal/agent-session-adapters.ts @@ -127,6 +127,14 @@ function hasCliOption(args: string[], optionName: string): boolean { return false; } +function addCliOptionBeforeSubcommand(args: string[], optionName: string, subcommands: readonly string[]): void { + if (hasCliOption(args, optionName)) { + return; + } + const subcommandIndex = args.findIndex((arg) => subcommands.includes(arg)); + args.splice(subcommandIndex === -1 ? 0 : subcommandIndex, 0, optionName); +} + function getClineHookScriptPath( hooksDir: string, hookName: "Notification" | "TaskComplete" | "UserPromptSubmit" | "PreToolUse" | "PostToolUse", @@ -762,6 +770,8 @@ const codexAdapter: AgentSessionAdapter = { const hooks = resolveHookContext(input); if (hooks) { + // These hooks are generated by Kanban for this launch; bypassing hook trust prevents an interactive prompt. + addCliOptionBeforeSubcommand(codexArgs, "--dangerously-bypass-hook-trust", ["resume", "fork"]); configureCodexHooks(codexArgs); Object.assign( env, diff --git a/test/runtime/terminal/agent-session-adapters.test.ts b/test/runtime/terminal/agent-session-adapters.test.ts index 864a69ae2..9654b6a94 100644 --- a/test/runtime/terminal/agent-session-adapters.test.ts +++ b/test/runtime/terminal/agent-session-adapters.test.ts @@ -104,6 +104,7 @@ describe("prepareAgentLaunch hook strategies", () => { expect(launchCommand).toContain("hooks.PermissionRequest"); expect(getCodexConfigOverrideValues(launch.args, "features.hooks")).toEqual(["true"]); expect(getCodexConfigOverrideValues(launch.args, "features.codex_hooks")).toEqual([]); + expect(launch.args).toContain("--dangerously-bypass-hook-trust"); const hookTrustState = getCodexConfigOverrideValues(launch.args, "hooks.state"); expect(hookTrustState).toHaveLength(1); expect(hookTrustState[0]).toContain('"//config.toml:user_prompt_submit:0:0"'); @@ -620,7 +621,10 @@ describe("prepareAgentLaunch hook strategies", () => { }); const resumeIndex = launch.args.indexOf("resume"); + const hookTrustBypassIndex = launch.args.indexOf("--dangerously-bypass-hook-trust"); expect(resumeIndex).toBeGreaterThan(0); + expect(hookTrustBypassIndex).toBeGreaterThan(-1); + expect(hookTrustBypassIndex).toBeLessThan(resumeIndex); for (const key of [ "features.hooks", "hooks.state", diff --git a/web-ui/src/App.tsx b/web-ui/src/App.tsx index c9faf0397..63870c37b 100644 --- a/web-ui/src/App.tsx +++ b/web-ui/src/App.tsx @@ -55,6 +55,7 @@ import { useTaskSessions } from "@/hooks/use-task-sessions"; import { useTaskStartActions } from "@/hooks/use-task-start-actions"; import { useTerminalPanels } from "@/hooks/use-terminal-panels"; import { useWorkspaceSync } from "@/hooks/use-workspace-sync"; +import { useI18n } from "@/i18n/i18n-context"; import { LayoutCustomizationsProvider } from "@/resize/layout-customizations"; import { ResizableBottomPane } from "@/resize/resizable-bottom-pane"; import { useProjectNavigationLayout } from "@/resize/use-project-navigation-layout"; @@ -80,6 +81,7 @@ import { useTerminalThemeColors } from "@/terminal/theme-colors"; import type { BoardData } from "@/types"; export default function App(): ReactElement { + const { t } = useI18n(); const terminalThemeColors = useTerminalThemeColors(); const [board, setBoard] = useState(() => createInitialBoardData()); const [sessions, setSessions] = useState>({}); @@ -460,12 +462,12 @@ export default function App(): ReactElement { { intent: "warning", icon: "warning-sign", - message: "Workspace changed elsewhere. Synced latest state. Retry your last edit if needed.", + message: t("app.toast.workspaceConflict"), timeout: 5000, }, "workspace-state-conflict", ); - }, []); + }, [t]); useWorkspacePersistence({ board, @@ -494,8 +496,8 @@ export default function App(): ReactElement { intent: "danger", icon: "warning-sign", message: removedPath - ? `Project no longer exists and was removed: ${removedPath}` - : "Project no longer exists and was removed.", + ? t("app.toast.projectRemovedWithPath", { path: removedPath }) + : t("app.toast.projectRemoved"), timeout: 6000, }, `project-removed-${removedPath || "unknown"}`, @@ -511,7 +513,7 @@ export default function App(): ReactElement { notifyError(streamError, { key: `error:${streamError}` }); } lastStreamErrorRef.current = streamError; - }, [isRuntimeDisconnected, streamError]); + }, [isRuntimeDisconnected, streamError, t]); useEffect(() => { resetTaskEditorState(); @@ -682,10 +684,10 @@ export default function App(): ReactElement { return undefined; } if (!activeSelectedTaskWorkspaceInfo.exists) { - return selectedCard.column.id === "trash" ? "Task worktree deleted" : "Task worktree not created yet"; + return selectedCard.column.id === "trash" ? t("app.worktree.deleted") : t("app.worktree.notCreated"); } return undefined; - }, [selectedCard]); + }, [selectedCard, t]); const sidebarLayout = useProjectNavigationLayout(); const handleToggleSidebar = useCallback(() => { @@ -901,17 +903,15 @@ export default function App(): ReactElement {
-

No projects yet

-

- Add a git repository to start using Kanban. -

+

{t("app.noProjects.title")}

+

{t("app.noProjects.description")}

diff --git a/web-ui/src/components/board-card.tsx b/web-ui/src/components/board-card.tsx index 6b005e04b..020d3db6c 100644 --- a/web-ui/src/components/board-card.tsx +++ b/web-ui/src/components/board-card.tsx @@ -15,10 +15,11 @@ import { Button } from "@/components/ui/button"; import { cn } from "@/components/ui/cn"; import { Spinner } from "@/components/ui/spinner"; import { Tooltip } from "@/components/ui/tooltip"; +import { useI18n } from "@/i18n/i18n-context"; +import type { TranslationKey, TranslationValues } from "@/i18n/translations"; import type { RuntimeTaskSessionSummary } from "@/runtime/types"; import { useTaskWorkspaceSnapshotValue } from "@/stores/workspace-metadata-store"; import type { BoardCard as BoardCardModel, BoardColumnId } from "@/types"; -import { getTaskAutoReviewCancelButtonLabel } from "@/types"; import { formatPathForDisplay } from "@/utils/path-display"; import { useMeasure } from "@/utils/react-use"; import { @@ -46,10 +47,8 @@ const SESSION_ACTIVITY_COLOR = { const DESCRIPTION_COLLAPSE_LINES = 3; const DESCRIPTION_EXPANDED_MAX_LINES = 10; -const DESCRIPTION_EXPAND_LABEL = "See more"; -const DESCRIPTION_COLLAPSE_LABEL = "Less"; -const DESCRIPTION_COLLAPSE_SUFFIX = `… ${DESCRIPTION_EXPAND_LABEL}`; -const DESCRIPTION_EXPANDED_SUFFIX = `… ${DESCRIPTION_COLLAPSE_LABEL}`; + +type Translate = (key: TranslationKey, values?: TranslationValues) => string; function reconstructTaskWorktreeDisplayPath(taskId: string, workspacePath: string | null | undefined): string | null { if (!workspacePath) { @@ -141,12 +140,15 @@ function isCardCreditLimitError(summary: RuntimeTaskSessionSummary | undefined): return summary.latestHookActivity?.notificationType === "credit_limit"; } -function getCardSessionActivity(summary: RuntimeTaskSessionSummary | undefined): CardSessionActivity | null { +function getCardSessionActivity( + summary: RuntimeTaskSessionSummary | undefined, + t: Translate, +): CardSessionActivity | null { if (!summary) { return null; } if (isCardCreditLimitError(summary)) { - return { dotColor: SESSION_ACTIVITY_COLOR.warning, text: "Out of credits" }; + return { dotColor: SESSION_ACTIVITY_COLOR.warning, text: t("task.outOfCredits") }; } const hookActivity = summary.latestHookActivity; const activityText = hookActivity?.activityText?.trim(); @@ -193,19 +195,19 @@ function getCardSessionActivity(summary: RuntimeTaskSessionSummary | undefined): } else if (text.startsWith("Failed ")) { dotColor = SESSION_ACTIVITY_COLOR.error; } else if (text === "Agent active" || text === "Working on task" || text.startsWith("Resumed")) { - return { dotColor: SESSION_ACTIVITY_COLOR.thinking, text: "Thinking..." }; + return { dotColor: SESSION_ACTIVITY_COLOR.thinking, text: t("task.thinking") }; } return { dotColor, text }; } if (summary.state === "failed") { - const failedText = finalMessage ?? activityText ?? "Task failed to start"; + const failedText = finalMessage ?? activityText ?? t("task.failedToStart"); return { dotColor: SESSION_ACTIVITY_COLOR.error, text: failedText }; } if (summary.state === "awaiting_review") { - return { dotColor: SESSION_ACTIVITY_COLOR.success, text: "Waiting for review" }; + return { dotColor: SESSION_ACTIVITY_COLOR.success, text: t("task.waitingForReview") }; } if (summary.state === "running") { - return { dotColor: SESSION_ACTIVITY_COLOR.thinking, text: "Thinking..." }; + return { dotColor: SESSION_ACTIVITY_COLOR.thinking, text: t("task.thinking") }; } return null; } @@ -259,6 +261,7 @@ export function BoardCard({ workspacePath?: string | null; defaultClineModelId?: string | null; }): React.ReactElement { + const { t } = useI18n(); const [isHovered, setIsHovered] = useState(false); const [isEditingTitle, setIsEditingTitle] = useState(false); const [draftTitle, setDraftTitle] = useState(card.title); @@ -273,7 +276,7 @@ export function BoardCard({ const isTrashCard = columnId === "trash"; const isCardInteractive = !isTrashCard; const descriptionWidth = descriptionRect.width > 0 ? descriptionRect.width : descriptionWidthFallback; - const rawSessionActivity = useMemo(() => getCardSessionActivity(sessionSummary), [sessionSummary]); + const rawSessionActivity = useMemo(() => getCardSessionActivity(sessionSummary, t), [sessionSummary, t]); const lastSessionActivityRef = useRef(null); const lastSessionActivityCardIdRef = useRef(null); if (lastSessionActivityCardIdRef.current !== card.id) { @@ -292,6 +295,10 @@ export function BoardCard({ () => getTaskPromptDescription(card.prompt, displayTitle), [card.prompt, displayTitle], ); + const descriptionExpandLabel = t("task.seeMore"); + const descriptionCollapseLabel = t("task.less"); + const descriptionCollapseSuffix = `… ${descriptionExpandLabel}`; + const descriptionExpandedSuffix = `… ${descriptionCollapseLabel}`; useLayoutEffect(() => { if (descriptionRect.width > 0 || !displayDescription) { @@ -384,17 +391,17 @@ export function BoardCard({ collapsed: clampTextWithInlineSuffix(displayDescription, { maxWidthPx: descriptionWidth, maxLines: DESCRIPTION_COLLAPSE_LINES, - suffix: DESCRIPTION_COLLAPSE_SUFFIX, + suffix: descriptionCollapseSuffix, measureText: measure, }), expanded: clampTextWithInlineSuffix(displayDescription, { maxWidthPx: descriptionWidth, maxLines: DESCRIPTION_EXPANDED_MAX_LINES, - suffix: DESCRIPTION_EXPANDED_SUFFIX, + suffix: descriptionExpandedSuffix, measureText: measure, }), }; - }, [descriptionFont, descriptionWidth, displayDescription]); + }, [descriptionCollapseSuffix, descriptionExpandedSuffix, descriptionFont, descriptionWidth, displayDescription]); const isCreditLimit = isCardCreditLimitError(sessionSummary); const renderStatusMarker = () => { @@ -421,7 +428,9 @@ export function BoardCard({ ? reviewWorkspaceSnapshot.changedFiles == null ? null : { - filesLabel: `${reviewWorkspaceSnapshot.changedFiles} ${reviewWorkspaceSnapshot.changedFiles === 1 ? "file" : "files"}`, + filesLabel: `${reviewWorkspaceSnapshot.changedFiles} ${t( + reviewWorkspaceSnapshot.changedFiles === 1 ? "common.file" : "common.files", + )}`, additions: reviewWorkspaceSnapshot.additions ?? 0, deletions: reviewWorkspaceSnapshot.deletions ?? 0, } @@ -429,7 +438,11 @@ export function BoardCard({ const showReviewGitActions = columnId === "review" && (reviewWorkspaceSnapshot?.changedFiles ?? 0) > 0; const isAnyGitActionLoading = isCommitLoading || isOpenPrLoading; const cancelAutomaticActionLabel = - !isTrashCard && card.autoReviewEnabled ? getTaskAutoReviewCancelButtonLabel(card.autoReviewMode) : null; + !isTrashCard && card.autoReviewEnabled + ? card.autoReviewMode === "pr" + ? t("task.cancelAutoPr") + : t("task.cancelAutoCommit") + : null; const agentOverrideLabel = useMemo( () => (card.agentId ? (getRuntimeAgentCatalogEntry(card.agentId)?.label ?? card.agentId) : null), [card.agentId], @@ -441,15 +454,15 @@ export function BoardCard({ const explicitReasoningLabel = card.clineSettings.reasoningEffort ? formatClineReasoningEffortLabel(card.clineSettings.reasoningEffort) : !card.clineSettings.providerId && !card.clineSettings.modelId - ? "Default" + ? t("common.default") : null; if (card.clineSettings.providerId && !card.clineSettings.modelId) { - const providerLabel = `Provider: ${card.clineSettings.providerId}`; + const providerLabel = `${t("task.provider")}: ${card.clineSettings.providerId}`; return explicitReasoningLabel ? `${providerLabel} (${explicitReasoningLabel})` : providerLabel; } const effectiveModelId = card.clineSettings.modelId ?? defaultClineModelId; if (!effectiveModelId) { - return explicitReasoningLabel ? `Default model (${explicitReasoningLabel})` : null; + return explicitReasoningLabel ? `${t("task.defaultModel")} (${explicitReasoningLabel})` : null; } const modelName = resolveClineModelDisplayName(effectiveModelId); if (explicitReasoningLabel) { @@ -461,7 +474,7 @@ export function BoardCard({ reasoningEffort: inheritedReasoningEffort, showReasoningEffort: Boolean(inheritedReasoningEffort), }); - }, [card.clineSettings, defaultClineModelId]); + }, [card.clineSettings, defaultClineModelId, t]); const taskAgentSettingsLabel = useMemo(() => { const parts = [agentOverrideLabel, modelOverrideLabel].filter((value): value is string => Boolean(value)); return parts.length > 0 ? parts.join(" · ") : null; @@ -576,7 +589,7 @@ export function BoardCard({

) : isDescriptionExpanded && descriptionDisplay.collapsed.isTruncated ? ( @@ -696,14 +709,14 @@ export function BoardCard({ type="button" className="inline cursor-pointer rounded-sm text-text-tertiary hover:underline focus-visible:outline-2 focus-visible:outline-offset-2 focus-visible:outline-accent [font:inherit]" aria-expanded={isDescriptionExpanded} - aria-label="Collapse task description" + aria-label={t("task.collapseDescription")} onMouseDown={stopEvent} onClick={(event) => { stopEvent(event); setIsDescriptionExpanded(false); }} > - {DESCRIPTION_COLLAPSE_LABEL} + {descriptionCollapseLabel} ) : null} @@ -811,7 +824,7 @@ export function BoardCard({ onCommit?.(card.id); }} > - Commit + {t("task.commit")} ) : null} diff --git a/web-ui/src/components/board-column.tsx b/web-ui/src/components/board-column.tsx index 0afd24e0e..1f08ab69a 100644 --- a/web-ui/src/components/board-column.tsx +++ b/web-ui/src/components/board-column.tsx @@ -5,9 +5,11 @@ import type { MouseEvent as ReactMouseEvent, ReactNode } from "react"; import { BoardCard } from "@/components/board-card"; import { Button } from "@/components/ui/button"; import { ColumnIndicator } from "@/components/ui/column-indicator"; +import { useI18n } from "@/i18n/i18n-context"; import type { RuntimeTaskSessionSummary } from "@/runtime/types"; import { isCardDropDisabled, type ProgrammaticCardMoveInFlight } from "@/state/drag-rules"; import type { BoardCard as BoardCardModel, BoardColumnId, BoardColumn as BoardColumnModel } from "@/types"; +import { getBoardColumnTitleKey } from "@/utils/board-column-title"; export function BoardColumn({ column, @@ -70,6 +72,7 @@ export function BoardColumn({ workspacePath?: string | null; defaultClineModelId?: string | null; }): React.ReactElement { + const { t } = useI18n(); const canCreate = column.id === "backlog" && onCreateTask; const canStartAllTasks = column.id === "backlog" && onStartAllTasks; const canClearTrash = column.id === "trash" && onClearTrash; @@ -80,12 +83,13 @@ export function BoardColumn({ }); const createTaskButtonText = ( - Create task + {t("board.createTask")} (c) ); + const columnTitle = t(getBoardColumnTitleKey(column.id)); return (
- {column.title} + {columnTitle} {column.cards.length}
{canStartAllTasks ? ( @@ -115,8 +119,8 @@ export function BoardColumn({ size="sm" onClick={onStartAllTasks} disabled={column.cards.length === 0} - aria-label="Start all backlog tasks" - title={column.cards.length > 0 ? "Start all backlog tasks" : "Backlog is empty"} + aria-label={t("board.startAllBacklogTasks")} + title={column.cards.length > 0 ? t("board.startAllBacklogTasks") : t("board.backlogEmpty")} /> ) : null} {canClearTrash ? ( @@ -127,8 +131,8 @@ export function BoardColumn({ className="text-status-red hover:text-status-red" onClick={onClearTrash} disabled={column.cards.length === 0} - aria-label="Clear done" - title={column.cards.length > 0 ? "Clear done items permanently" : "Done is empty"} + aria-label={t("board.clearDone")} + title={column.cards.length > 0 ? t("board.clearDoneItems") : t("board.doneEmpty")} /> ) : null} @@ -139,7 +143,7 @@ export function BoardColumn({ {canCreate ? ( diff --git a/web-ui/src/components/detail-panels/column-context-panel.tsx b/web-ui/src/components/detail-panels/column-context-panel.tsx index 31bfae666..a57ab29c0 100644 --- a/web-ui/src/components/detail-panels/column-context-panel.tsx +++ b/web-ui/src/components/detail-panels/column-context-panel.tsx @@ -6,9 +6,11 @@ import { useCallback, useEffect, useRef, useState } from "react"; import { BoardCard } from "@/components/board-card"; import { Button } from "@/components/ui/button"; import { ColumnIndicator } from "@/components/ui/column-indicator"; +import { useI18n } from "@/i18n/i18n-context"; import type { RuntimeTaskSessionSummary } from "@/runtime/types"; import { findCardColumnId, isCardDropDisabled } from "@/state/drag-rules"; import type { BoardCard as BoardCardModel, BoardColumn, BoardColumnId, CardSelection } from "@/types"; +import { getBoardColumnTitleKey } from "@/utils/board-column-title"; function ColumnSection({ column, @@ -59,12 +61,14 @@ function ColumnSection({ workspacePath?: string | null; defaultClineModelId?: string | null; }): React.ReactElement { + const { t } = useI18n(); const [open, setOpen] = useState(defaultOpen); const canCreate = column.id === "backlog" && onCreateTask; const canStartAllTasks = column.id === "backlog" && onStartAllTasks; const canClearTrash = column.id === "trash" && onClearTrash; const cardDropType = "CARD"; const isDropDisabled = isCardDropDisabled(column.id, activeDragSourceColumnId ?? null); + const columnTitle = t(getBoardColumnTitleKey(column.id)); useEffect(() => { if (!column.cards.some((card) => card.id === selectedCardId)) { @@ -109,7 +113,7 @@ function ColumnSection({ )} - {column.title} + {columnTitle} {column.cards.length} @@ -122,8 +126,8 @@ function ColumnSection({ size="sm" onClick={onStartAllTasks} disabled={column.cards.length === 0} - aria-label="Start all backlog tasks" - title={column.cards.length > 0 ? "Start all backlog tasks" : "Backlog is empty"} + aria-label={t("board.startAllBacklogTasks")} + title={column.cards.length > 0 ? t("board.startAllBacklogTasks") : t("board.backlogEmpty")} style={{ marginRight: 4 }} /> ) : null} @@ -135,8 +139,8 @@ function ColumnSection({ className="text-status-red hover:text-status-red" onClick={onClearTrash} disabled={column.cards.length === 0} - aria-label="Clear done" - title={column.cards.length > 0 ? "Clear done items permanently" : "Done is empty"} + aria-label={t("board.clearDone")} + title={column.cards.length > 0 ? t("board.clearDoneItems") : t("board.doneEmpty")} style={{ marginRight: 4 }} /> ) : null} @@ -157,13 +161,13 @@ function ColumnSection({ {canCreate ? ( + + + + {APP_LANGUAGES.map((item) => ( + + ))} + + + + ); +} diff --git a/web-ui/src/components/open-workspace-button.tsx b/web-ui/src/components/open-workspace-button.tsx index 9a1646968..1fe8ffac6 100644 --- a/web-ui/src/components/open-workspace-button.tsx +++ b/web-ui/src/components/open-workspace-button.tsx @@ -5,6 +5,7 @@ import { useState } from "react"; import { Button } from "@/components/ui/button"; import { cn } from "@/components/ui/cn"; import { Spinner } from "@/components/ui/spinner"; +import { useI18n } from "@/i18n/i18n-context"; import type { OpenTargetId, OpenTargetOption } from "@/utils/open-targets"; function OpenTargetIcon({ option }: { option: OpenTargetOption }): React.ReactElement { @@ -40,6 +41,7 @@ export function OpenWorkspaceButton({ onOpen: () => void; onSelectOption: (optionId: OpenTargetId) => void; }): React.ReactElement { + const { t } = useI18n(); const [isPopoverOpen, setIsPopoverOpen] = useState(false); const selectedOption = options.find((option) => option.id === selectedOptionId) ?? options[0]; if (!selectedOption) { @@ -55,10 +57,10 @@ export function OpenWorkspaceButton({ icon={loading ? : } disabled={disabled} onClick={onOpen} - aria-label={`Open in ${selectedOption.label}`} + aria-label={t("openWorkspace.openIn", { target: selectedOption.label })} className="text-xs rounded-r-none kb-navbar-btn" > - Open + {t("openWorkspace.open")} @@ -67,7 +69,7 @@ export function OpenWorkspaceButton({ variant="default" icon={} disabled={disabled} - aria-label="Select open target" + aria-label={t("openWorkspace.selectTarget")} className="rounded-l-none border-l-0 kb-navbar-btn" style={{ width: 24, paddingLeft: 0, paddingRight: 0 }} /> diff --git a/web-ui/src/components/project-navigation-panel.tsx b/web-ui/src/components/project-navigation-panel.tsx index 780196cd1..a8e51f5a0 100644 --- a/web-ui/src/components/project-navigation-panel.tsx +++ b/web-ui/src/components/project-navigation-panel.tsx @@ -20,6 +20,7 @@ import { Kbd } from "@/components/ui/kbd"; import { Spinner } from "@/components/ui/spinner"; import type { FeaturebaseFeedbackState } from "@/hooks/use-featurebase-feedback-widget"; import { useIsMobile } from "@/hooks/use-is-mobile"; +import { useI18n } from "@/i18n/i18n-context"; import type { RuntimeAgentId, RuntimeClineProviderSettings, RuntimeProjectSummary } from "@/runtime/types"; import { LocalStorageKey, @@ -84,6 +85,7 @@ export function ProjectNavigationPanel({ isCollapsed: boolean; setSidebarCollapsed: (collapsed: boolean, persist?: boolean) => void; }): React.ReactElement { + const { t } = useI18n(); const sortedProjects = [...projects].sort((a, b) => a.path.localeCompare(b.path)); const shouldShowFeaturebaseFeedback = canShowFeaturebaseFeedbackButton({ selectedAgentId, @@ -217,7 +219,7 @@ export function ProjectNavigationPanel({
@@ -250,7 +252,7 @@ export function ProjectNavigationPanel({ })}
@@ -390,7 +392,7 @@ export function ProjectNavigationPanel({ disabled={removingProjectId !== null} > - Add Project + {t("project.add")} ) : null} @@ -406,7 +408,7 @@ export function ProjectNavigationPanel({
{agentSectionContent ?? (
- Select a project to use the agent. + {t("sidebar.selectProjectForAgent")}
)}
@@ -421,17 +423,16 @@ export function ProjectNavigationPanel({ }} > - Remove Project + {t("project.remove.title")}
-

{pendingProjectRemoval ? pendingProjectRemoval.name : "This project"}

+

{pendingProjectRemoval ? pendingProjectRemoval.name : t("project.remove.thisProject")}

- This will delete all project tasks ({pendingProjectTaskCount}), remove task - workspaces/worktrees, and stop any running processes for this project. + {t("project.remove.description", { count: pendingProjectTaskCount })}

-

This action cannot be undone.

+

{t("project.remove.irreversible")}

@@ -446,7 +447,7 @@ export function ProjectNavigationPanel({ } }} > - Cancel + {t("common.cancel")} @@ -466,10 +467,10 @@ export function ProjectNavigationPanel({ {isProjectRemovalPending ? ( <> - Removing... + {t("project.remove.removing")} ) : ( - "Remove Project" + t("project.remove.confirm") )} @@ -479,16 +480,16 @@ export function ProjectNavigationPanel({ ); } -const TERMINAL_AGENT_HINTS: readonly { label: string; hint: string }[] = [ - { label: "Create tasks", hint: "Ask your agent to add tasks, link them, and start working" }, - { label: "Break down work", hint: "Ask to decompose a complex feature into linked subtasks" }, - { label: "Import issues", hint: "Pull issues into task cards via GitHub CLI or Linear MCP" }, -]; - function TerminalAgentHints(): React.ReactElement { + const { t } = useI18n(); const [isDismissed, setIsDismissed] = useState( () => readLocalStorageItem(LocalStorageKey.AgentTipsDismissed) === "true", ); + const terminalAgentHints: readonly { label: string; hint: string }[] = [ + { label: t("sidebar.tip.createTasks.label"), hint: t("sidebar.tip.createTasks.hint") }, + { label: t("sidebar.tip.breakDown.label"), hint: t("sidebar.tip.breakDown.hint") }, + { label: t("sidebar.tip.importIssues.label"), hint: t("sidebar.tip.importIssues.hint") }, + ]; const dismiss = useCallback(() => { setIsDismissed(true); @@ -509,7 +510,7 @@ function TerminalAgentHints(): React.ReactElement { className="flex cursor-pointer items-center gap-1 border-none bg-transparent p-0 text-[11px] text-text-tertiary hover:text-text-secondary" > - Show tips + {t("sidebar.showTips")} ); @@ -519,19 +520,19 @@ function TerminalAgentHints(): React.ReactElement {
- Tips + {t("sidebar.tips")}
    - {TERMINAL_AGENT_HINTS.map((item) => ( + {terminalAgentHints.map((item) => (
  • @@ -551,6 +552,7 @@ function ProjectSupportFooter({ shouldShowFeaturebaseFeedback: boolean; featurebaseFeedbackState?: FeaturebaseFeedbackState; }): React.ReactElement { + const { t } = useI18n(); const isOpening = featurebaseFeedbackState?.authState === "loading"; const handleAction = () => { @@ -561,16 +563,18 @@ function ProjectSupportFooter({ } }; - const actionLabel = shouldShowFeaturebaseFeedback ? (isOpening ? "Opening..." : "Send feedback") : "Report issue"; + const actionLabel = shouldShowFeaturebaseFeedback + ? isOpening + ? t("common.opening") + : t("sidebar.sendFeedback") + : t("sidebar.reportIssue"); return (
    -

    - Kanban is in beta. Help us improve by sharing your experience. -

    +

    {t("sidebar.beta")}

    @@ -701,6 +704,7 @@ function ProjectRow({ onSelect: (id: string) => void; onRemove: (id: string) => void; }): React.ReactElement { + const { t } = useI18n(); const displayPath = formatPathForDisplay(project.path); const isRemovingProject = removingProjectId === project.id; const hasAnyProjectRemoval = removingProjectId !== null; @@ -708,29 +712,29 @@ function ProjectRow({ const taskCountBadges: TaskCountBadge[] = [ { id: "backlog", - title: "Backlog", - shortLabel: "B", + title: t("board.column.backlog"), + shortLabel: t("board.column.backlog.short"), toneClassName: "bg-text-primary/15 text-text-primary", count: project.taskCounts.backlog, }, { id: "in_progress", - title: "In Progress", - shortLabel: "IP", + title: t("board.column.inProgress"), + shortLabel: t("board.column.inProgress.short"), toneClassName: "bg-accent/20 text-accent", count: project.taskCounts.in_progress, }, { id: "review", - title: "Review", - shortLabel: "R", + title: t("board.column.review"), + shortLabel: t("board.column.review.short"), toneClassName: "bg-accent-2/20 text-accent-2", count: project.taskCounts.review, }, { id: "trash", - title: "Done", - shortLabel: "D", + title: t("board.column.done"), + shortLabel: t("board.column.done.short"), toneClassName: "bg-status-red/20 text-status-red", count: project.taskCounts.trash, }, @@ -807,7 +811,7 @@ function ProjectRow({ onClick={(e) => { e.stopPropagation(); }} - aria-label="Project actions" + aria-label={t("project.actions")} /> @@ -822,7 +826,7 @@ function ProjectRow({ className="flex items-center gap-2 rounded-sm px-2 py-1.5 text-[13px] text-status-red cursor-pointer outline-none data-[highlighted]:bg-surface-3" onSelect={() => onRemove(project.id)} > - Delete + {t("common.delete")} diff --git a/web-ui/src/components/runtime-settings-dialog.tsx b/web-ui/src/components/runtime-settings-dialog.tsx index 314e80c68..c89a5946e 100644 --- a/web-ui/src/components/runtime-settings-dialog.tsx +++ b/web-ui/src/components/runtime-settings-dialog.tsx @@ -41,6 +41,8 @@ import { TASK_GIT_BASE_REF_PROMPT_VARIABLE, type TaskGitAction } from "@/git-act import { useRuntimeSettingsClineController } from "@/hooks/use-runtime-settings-cline-controller"; import { useRuntimeSettingsClineMcpController } from "@/hooks/use-runtime-settings-cline-mcp-controller"; import { previewThemeId, readStoredThemeId, saveThemeId, THEME_GROUPS, THEMES, type ThemeId } from "@/hooks/use-theme"; +import { useI18n } from "@/i18n/i18n-context"; +import type { TranslationKey } from "@/i18n/translations"; import { useLayoutCustomizations } from "@/resize/layout-customizations"; import { openFileOnHost } from "@/runtime/runtime-config-query"; import type { @@ -85,9 +87,9 @@ function normalizeTemplateForComparison(value: string): string { return value.replaceAll("\r\n", "\n").trim(); } -const GIT_PROMPT_VARIANT_OPTIONS: Array<{ value: TaskGitAction; label: string }> = [ - { value: "commit", label: "Commit" }, - { value: "pr", label: "Make PR" }, +const GIT_PROMPT_VARIANT_OPTIONS: Array<{ value: TaskGitAction; labelKey: TranslationKey }> = [ + { value: "commit", labelKey: "settings.gitPrompts.commit" }, + { value: "pr", labelKey: "settings.gitPrompts.makePr" }, ]; export type RuntimeSettingsSection = "shortcuts"; @@ -98,16 +100,16 @@ type SettingsNavId = "general" | "cline" | "git-prompts" | "notifications" | "ap const SETTINGS_NAV_ITEMS: ReadonlyArray<{ id: SettingsNavId; - label: string; + labelKey: TranslationKey; icon: React.ReactNode; clineOnly?: boolean; }> = [ - { id: "general", label: "General", icon: }, - { id: "cline", label: "Cline", icon: , clineOnly: true }, - { id: "git-prompts", label: "Git Prompts", icon: }, - { id: "notifications", label: "Notifications", icon: }, - { id: "appearance", label: "Appearance", icon: }, - { id: "project", label: "Project", icon: }, + { id: "general", labelKey: "settings.nav.general", icon: }, + { id: "cline", labelKey: "settings.nav.cline", icon: , clineOnly: true }, + { id: "git-prompts", labelKey: "settings.nav.gitPrompts", icon: }, + { id: "notifications", labelKey: "settings.nav.notifications", icon: }, + { id: "appearance", labelKey: "settings.nav.appearance", icon: }, + { id: "project", labelKey: "settings.nav.project", icon: }, ]; function getShortcutIconOption(icon: string | undefined): RuntimeShortcutIconOption { @@ -119,11 +121,20 @@ function ShortcutIconComponent({ icon, size = 14 }: { icon: string | undefined; return ; } -function formatNotificationPermissionStatus(permission: BrowserNotificationPermission): string { +function formatNotificationPermissionStatus( + permission: BrowserNotificationPermission, + t: (key: TranslationKey) => string, +): string { if (permission === "default") { - return "not requested yet"; + return t("settings.notifications.permission.notRequested"); + } + if (permission === "granted") { + return t("settings.notifications.permission.granted"); + } + if (permission === "denied") { + return t("settings.notifications.permission.denied"); } - return permission; + return t("settings.notifications.permission.unsupported"); } function getNextShortcutLabel(shortcuts: RuntimeProjectShortcut[], baseLabel: string): string { @@ -153,6 +164,7 @@ function AgentRow({ onSelect: () => void; disabled: boolean; }): React.ReactElement { + const { t } = useI18n(); const installUrl = getRuntimeAgentCatalogEntry(agent.id)?.installUrl; const isNativeCline = agent.id === "cline"; const isInstalled = agent.installed === true; @@ -189,11 +201,11 @@ function AgentRow({ {agent.label} {!isNativeCline && isInstalled ? ( - Installed + {t("common.installed")} ) : isInstallStatusPending ? ( - Checking... + {t("common.checking")} ) : null}
    @@ -210,11 +222,11 @@ function AgentRow({ onClick={(event: React.MouseEvent) => event.stopPropagation()} className="inline-flex items-center justify-center rounded-md font-medium duration-150 cursor-default select-none h-7 px-2 text-xs bg-surface-2 border border-border text-text-primary hover:bg-surface-3 hover:border-border-bright" > - Install + {t("common.install")} ) : !isNativeCline && agent.installed === false ? ( ) : null}
    @@ -319,10 +331,11 @@ function SettingsNav({ activeId, onSelect, }: { - items: ReadonlyArray<{ id: SettingsNavId; label: string; icon: React.ReactNode }>; + items: ReadonlyArray<{ id: SettingsNavId; labelKey: TranslationKey; icon: React.ReactNode }>; activeId: SettingsNavId; onSelect: (id: SettingsNavId) => void; }): React.ReactElement { + const { t } = useI18n(); return ( @@ -364,6 +377,7 @@ export function RuntimeSettingsDialog({ onAccountSwitched?: () => void; initialSection?: RuntimeSettingsSection | null; }): React.ReactElement { + const { t } = useI18n(); const { config, isLoading, isSaving, save, refresh } = useRuntimeConfig(open, workspaceId, initialConfig); const { resetLayoutCustomizations } = useLayoutCustomizations(); const [selectedAgentId, setSelectedAgentId] = useState("claude"); @@ -400,7 +414,9 @@ export function RuntimeSettingsDialog({ const isSelectedPromptAtDefault = selectedPromptVariant === "commit" ? isCommitPromptAtDefault : isOpenPrPromptAtDefault; const selectedPromptPlaceholder = - selectedPromptVariant === "commit" ? "Commit prompt template" : "PR prompt template"; + selectedPromptVariant === "commit" + ? t("settings.gitPrompts.commitPlaceholder") + : t("settings.gitPrompts.prPlaceholder"); const bypassPermissionsCheckboxId = "runtime-settings-bypass-permissions"; const refreshNotificationPermission = useCallback(() => { setNotificationPermission(getBrowserNotificationPermission()); @@ -665,12 +681,12 @@ export function RuntimeSettingsDialog({ const handleSave = async () => { setSaveError(null); if (!config) { - setSaveError("Runtime settings are still loading. Try again in a moment."); + setSaveError(t("settings.error.runtimeLoading")); return; } const selectedAgent = displayedAgents.find((agent) => agent.id === selectedAgentId); if (!selectedAgent || selectedAgent.installed !== true) { - setSaveError("Selected agent is not installed. Install it first or choose an installed agent."); + setSaveError(t("settings.error.agentNotInstalled")); return; } const shouldRequestNotificationPermission = @@ -682,18 +698,18 @@ export function RuntimeSettingsDialog({ setNotificationPermission(nextPermission); } if (selectedAgentId === "cline" && clineSettings.providerId.trim().length === 0) { - setSaveError("Choose a Cline provider before saving."); + setSaveError(t("settings.error.chooseClineProvider")); return; } if (selectedAgentId === "cline") { const clineProviderSaveResult = await clineSettings.saveProviderSettings(); if (!clineProviderSaveResult.ok) { - setSaveError(clineProviderSaveResult.message ?? "Could not save Cline provider settings."); + setSaveError(clineProviderSaveResult.message ?? t("settings.error.saveClineProvider")); return; } const clineMcpSaveResult = await clineMcpSettings.saveMcpSettings(); if (!clineMcpSaveResult.ok) { - setSaveError(clineMcpSaveResult.message ?? "Could not save Cline MCP settings."); + setSaveError(clineMcpSaveResult.message ?? t("settings.error.saveClineMcp")); return; } } @@ -706,7 +722,7 @@ export function RuntimeSettingsDialog({ openPrPromptTemplate, }); if (!saved) { - setSaveError("Could not save runtime settings. Check runtime logs and try again."); + setSaveError(t("settings.error.saveRuntime")); return; } if (draftThemeId !== initialThemeId) { @@ -729,10 +745,10 @@ export function RuntimeSettingsDialog({ setSaveError(null); void openFileOnHost(workspaceId, filePath).catch((error) => { const message = error instanceof Error ? error.message : String(error); - setSaveError(`Could not open file on host: ${message}`); + setSaveError(t("settings.error.openFile", { message })); }); }, - [workspaceId], + [t, workspaceId], ); const handleClineSetupSaved = useCallback(() => { @@ -759,7 +775,7 @@ export function RuntimeSettingsDialog({ return ( - } /> + } />

    - General + {t("settings.nav.general")}

    - Agent + {t("settings.agent")}
    {displayedAgents.map((agent) => ( ))} - {config === null ? ( -

    Checking which CLIs are installed for this project...

    - ) : null} + {config === null ?

    {t("settings.checkingClis")}

    : null}

    - Allows agents to use tools without stopping for permission. Use at your own risk. + {t("settings.bypassPermissionsHelp")}

    @@ -821,7 +835,7 @@ export function RuntimeSettingsDialog({

    - Cline + {t("settings.nav.cline")}

    @@ -851,13 +865,11 @@ export function RuntimeSettingsDialog({

    - Git Prompts + {t("settings.nav.gitPrompts")}

    -

    - Modify the prompts sent to the agent when using Commit or Make PR on tasks in Review. -

    +

    {t("settings.gitPrompts.description")}

    {GIT_PROMPT_VARIANT_OPTIONS.map((option) => ( ))} @@ -877,7 +889,7 @@ export function RuntimeSettingsDialog({ onClick={handleResetSelectedPrompt} disabled={controlsDisabled || isSelectedPromptAtDefault} > - Reset + {t("common.reset")}