diff --git a/apps/web/components/task-create-dialog-footer.test.ts b/apps/web/components/task-create-dialog-footer.test.ts index 14837202eb..b0d585506f 100644 --- a/apps/web/components/task-create-dialog-footer.test.ts +++ b/apps/web/components/task-create-dialog-footer.test.ts @@ -1,6 +1,8 @@ import { describe, it, expect } from "vitest"; import { computeDisabledReason, + resolveDisabledReason, + REASON_NO_COMPATIBLE_AGENT, REASON_TITLE, REASON_REPO, REASON_BRANCH, @@ -10,6 +12,7 @@ import { REASON_DESCRIPTION, REASON_PROMPT, } from "./task-create-dialog-footer"; +import { t } from "@/lib/i18n"; import type { ButtonKind, TaskCreateDialogFooterProps } from "./task-create-dialog-footer"; const KIND_START: ButtonKind = "start-task"; @@ -125,8 +128,25 @@ describe("computeDisabledReason (start-task)", () => { }), KIND_START, ); - expect(reason).toContain("Docker (sandbox)"); - expect(reason).toContain("credentials"); + expect(reason).toBe(REASON_NO_COMPATIBLE_AGENT); + }); + + // `computeDisabledReason` is pure and returns catalog keys, so the executor + // profile name only reaches the copy once the component resolves it. + it("resolves the no-compatible-agent key with the executor profile name", () => { + const text = resolveDisabledReason(t, REASON_NO_COMPATIBLE_AGENT, "Docker (sandbox)"); + expect(text).toContain("Docker (sandbox)"); + expect(text).toContain("credentials"); + }); + + it("falls back to a generic target when no executor profile is named", () => { + expect(resolveDisabledReason(t, REASON_NO_COMPATIBLE_AGENT, null)).toContain("this executor"); + }); + + it("passes caller-supplied blocked reasons through untranslated", () => { + expect(resolveDisabledReason(t, "Uploads still in progress", null)).toBe( + "Uploads still in progress", + ); }); }); diff --git a/apps/web/components/task-create-dialog-footer.tsx b/apps/web/components/task-create-dialog-footer.tsx index 482dc529be..a5b22476d9 100644 --- a/apps/web/components/task-create-dialog-footer.tsx +++ b/apps/web/components/task-create-dialog-footer.tsx @@ -18,6 +18,7 @@ import { } from "@kandev/ui/dropdown-menu"; import { SHORTCUTS } from "@/lib/keyboard/constants"; import { KeyboardShortcutTooltip } from "@/components/keyboard-shortcut-tooltip"; +import { useTranslation } from "react-i18next"; type UpdateButtonProps = { isCreatingTask: boolean; @@ -26,6 +27,7 @@ type UpdateButtonProps = { }; function UpdateButton({ isCreatingTask, hasTitle, onUpdate }: UpdateButtonProps) { + const { t } = useTranslation(); return ( ); @@ -63,7 +65,8 @@ function StartTaskSplitButton({ onAltAction, onPlanModeAction, }: StartTaskSplitButtonProps) { - const altLabel = isEditMode ? "Update task" : "Create only"; + const { t } = useTranslation(); + const altLabel = isEditMode ? t("task:updateTask") : t("task:createOnly"); return (
@@ -80,7 +83,7 @@ function StartTaskSplitButton({ ) : ( )} - {isCreatingTask ? "Starting..." : "Start task"} + {isCreatingTask ? t("task:starting") : t("task:startTask")} @@ -102,7 +105,7 @@ function StartTaskSplitButton({ data-testid="submit-plan-mode" > - Start task in plan mode + {t("task:startTaskInPlanMode")} )} - {isEditMode ? "Update task" : "Create without starting agent"} + {isEditMode ? t("task:updateTask") : t("task:createWithoutStartingAgent")} @@ -127,7 +130,7 @@ function StartTaskSplitButton({ data-testid="mobile-plan-mode" > - Plan mode + {t("task:planMode")} )} ); @@ -264,18 +268,35 @@ function computeBaseDisabled(props: TaskCreateDialogFooterProps) { export type ButtonKind = "update" | "start-task" | "default"; -export const REASON_TITLE = "Add a task title"; -export const REASON_PROMPT = "Add a task prompt"; -export const REASON_REPO = "Select a repository"; -export const REASON_BRANCH = "Select a branch"; -export const REASON_WORKSPACE = "Select a workspace"; -export const REASON_WORKFLOW = "Select a workflow"; -export const REASON_AGENT = "Select an agent"; -export const REASON_DESCRIPTION = "Add a session description"; +// Catalog keys, not copy. `computeDisabledReason` is a pure helper with no +// access to `t`, so it returns the key and the component resolves it at render +// (the repo-wide pattern for module-scope tables). Keeping these as keys also +// preserves the identity comparisons in the unit tests. +export const REASON_TITLE = "task:reasonAddTaskTitle"; +export const REASON_PROMPT = "task:reasonAddTaskPrompt"; +export const REASON_REPO = "task:reasonSelectRepository"; +export const REASON_BRANCH = "task:reasonSelectBranch"; +export const REASON_WORKSPACE = "task:reasonSelectWorkspace"; +export const REASON_WORKFLOW = "task:reasonSelectWorkflow"; +export const REASON_AGENT = "task:reasonSelectAgent"; +export const REASON_DESCRIPTION = "task:reasonAddSessionDescription"; +export const REASON_NO_COMPATIBLE_AGENT = "task:noCompatibleAgentProfileFor"; -function noCompatibleAgentReason(executorProfileName: string | null): string { - const target = executorProfileName ? `“${executorProfileName}”` : "this executor"; - return `No compatible agent profile is configured for ${target}. Configure agent credentials in Settings → Executors.`; +/** + * Resolve what `computeDisabledReason` returned. Reasons this component owns are + * catalog keys; `submitBlockedReason` is human text supplied by the caller and + * passes through untouched. + */ +export function resolveDisabledReason( + t: (key: string, options?: Record) => string, + reason: string | null | undefined, + executorProfileName: string | null, +): string | undefined { + if (!reason) return undefined; + if (!reason.startsWith("task:")) return reason; + return t(reason, { + target: executorProfileName ? `“${executorProfileName}”` : t("task:thisExecutor"), + }); } function baseReason(props: TaskCreateDialogFooterProps): string | null { @@ -285,12 +306,12 @@ function baseReason(props: TaskCreateDialogFooterProps): string | null { if (!props.hasAllBranches) return REASON_BRANCH; if (props.isCreateMode && !props.workspaceId) return REASON_WORKSPACE; if (props.isCreateMode && !props.effectiveWorkflowId) return REASON_WORKFLOW; - if (props.noCompatibleAgent) return noCompatibleAgentReason(props.executorProfileName); + if (props.noCompatibleAgent) return REASON_NO_COMPATIBLE_AGENT; return null; } function sessionDefaultReason(props: TaskCreateDialogFooterProps): string | null { - if (props.noCompatibleAgent) return noCompatibleAgentReason(props.executorProfileName); + if (props.noCompatibleAgent) return REASON_NO_COMPATIBLE_AGENT; if (!props.agentProfileId) return REASON_AGENT; if (!props.hasDescription) return REASON_DESCRIPTION; return null; @@ -326,7 +347,7 @@ function computeFooterState(props: TaskCreateDialogFooterProps) { // Session mode previously only gated on missing agent — it ignored // noCompatibleAgent, so a user who switched executor after picking an // agent could still submit a known-incompatible combination. The reason - // text already surfaces noCompatibleAgentReason in this branch (see + // text already surfaces REASON_NO_COMPATIBLE_AGENT in this branch (see // sessionDefaultReason), so the disable gate needs to match. const sessionDisabled = !props.agentProfileId || props.noCompatibleAgent; const defaultDisabled = (props.isSessionMode ? sessionDisabled : altDisabled) || blocked; @@ -339,6 +360,7 @@ function computeFooterState(props: TaskCreateDialogFooterProps) { export const TaskCreateDialogFooter = memo(function TaskCreateDialogFooter( props: TaskCreateDialogFooterProps, ) { + const { t } = useTranslation(); const { isSessionMode, isCreateMode, @@ -372,12 +394,12 @@ export const TaskCreateDialogFooter = memo(function TaskCreateDialogFooter( disabled={isCreatingSession || isCreatingTask} className="w-full h-10 border-0 cursor-pointer sm:w-auto sm:h-7 sm:border" > - Cancel + {t("common:cancel")} {(() => { diff --git a/apps/web/components/task-create-dialog-form-body.tsx b/apps/web/components/task-create-dialog-form-body.tsx index 9dd75b79ed..3cd8f80dff 100644 --- a/apps/web/components/task-create-dialog-form-body.tsx +++ b/apps/web/components/task-create-dialog-form-body.tsx @@ -13,6 +13,7 @@ import { TaskFormInputs } from "@/components/task-create-dialog-selectors"; import { PromptResultRecovery } from "@/components/prompt-result-recovery"; import type { JiraTicket } from "@/lib/types/jira"; import type { LinearIssue } from "@/lib/types/linear"; +import { useTranslation } from "react-i18next"; type SelectorOption = { value: string; @@ -81,6 +82,7 @@ function NoCompatibleAgentState({ executorProfileName: string | null; executorProfileId: string; }) { + const { t } = useTranslation(); const target = executorProfileName ? `“${executorProfileName}”` : "this executor"; const href = executorProfileId ? `/settings/executors/${executorProfileId}` @@ -90,9 +92,9 @@ function NoCompatibleAgentState({ className="flex h-auto min-h-7 items-center justify-between gap-3 rounded-sm border border-input px-3 py-1.5 text-xs text-muted-foreground" data-testid="agent-profile-empty-state" > - No compatible agent profiles for {target}. + {t("task:noCompatibleAgentProfilesFor", { target })} - Configure credentials + {t("task:configureCredentials")}
); @@ -111,15 +113,16 @@ function AgentColumn({ executorProfileName, executorProfileId, }: AgentColumnProps) { + const { t } = useTranslation(); if (agentProfiles.length === 0 && !agentProfilesLoading) { return (
- No agents found. + {t("task:noAgentsFound")} - Add agent + {t("task:addAgent")}
); @@ -132,7 +135,7 @@ function AgentColumn({ /> ); } - const placeholder = agentProfilesLoading ? "Loading agents..." : "Select agent"; + const placeholder = agentProfilesLoading ? t("task:loadingAgents") : t("task:selectAgent"); return ( <> {workflowAgentLocked && ( -

Agent set by workflow

+

{t("task:agentSetByWorkflow")}

)} ); @@ -153,6 +156,7 @@ function AgentColumn({ export const CreateEditSelectors = memo(function CreateEditSelectors( props: CreateEditSelectorsProps, ) { + const { t } = useTranslation(); if (props.isTaskStarted) return null; const { executorProfileOptions, @@ -175,7 +179,7 @@ export const CreateEditSelectors = memo(function CreateEditSelectors( options={executorProfileOptions} value={executorProfileId} onValueChange={onExecutorProfileChange} - placeholder={executorsLoading ? "Loading profiles..." : "Select profile"} + placeholder={executorsLoading ? t("task:loadingProfiles") : t("task:selectProfile")} disabled={executorsLoading} popoverPortal /> @@ -231,13 +235,16 @@ export const SessionSelectors = memo(function SessionSelectors({ AgentSelectorComponent, ExecutorProfileSelectorComponent, }: SessionSelectorsProps) { + const { t } = useTranslation(); return (
@@ -245,7 +252,7 @@ export const SessionSelectors = memo(function SessionSelectors({ options={executorProfileOptions} value={executorProfileId} onValueChange={onExecutorProfileChange} - placeholder={executorsLoading ? "Loading profiles..." : "Select profile"} + placeholder={executorsLoading ? t("task:loadingProfiles") : t("task:selectProfile")} disabled={executorsLoading || isCreatingSession} popoverPortal /> diff --git a/apps/web/components/task-create-dialog-fresh-branch.tsx b/apps/web/components/task-create-dialog-fresh-branch.tsx index 45a942410d..d718bca19c 100644 --- a/apps/web/components/task-create-dialog-fresh-branch.tsx +++ b/apps/web/components/task-create-dialog-fresh-branch.tsx @@ -3,9 +3,8 @@ import { IconGitFork } from "@tabler/icons-react"; import { Toggle } from "@kandev/ui/toggle"; import { Tooltip, TooltipContent, TooltipTrigger } from "@kandev/ui/tooltip"; - -const FRESH_BRANCH_TOOLTIP = - "Create a new branch from the selected base. Any uncommitted changes in your local clone will be discarded; you'll be asked to confirm if there are any."; +import { useTranslation } from "react-i18next"; +import { t } from "@/lib/i18n"; export type FreshBranchToggleProps = { enabled: boolean; @@ -19,12 +18,13 @@ export type FreshBranchToggleProps = { * attach-file button in the prompt input). */ export function FreshBranchToggle({ enabled, onToggle }: FreshBranchToggleProps) { + const { t } = useTranslation(); return ( - {FRESH_BRANCH_TOOLTIP} + {t("task:freshBranchTooltip")} ); } @@ -53,8 +53,8 @@ export function computeBranchPlaceholder({ loading, optionCount, }: BranchPlaceholderArgs) { - if (lockedToCurrentBranch) return currentLocalBranch || "Uses current branch"; - if (!hasRepositorySelection) return "Select repository first"; - if (loading) return "Loading branches..."; - return optionCount > 0 ? "Select branch" : "No branches found"; + if (lockedToCurrentBranch) return currentLocalBranch || t("task:usesCurrentBranch"); + if (!hasRepositorySelection) return t("task:selectRepositoryFirst"); + if (loading) return t("task:loadingBranches2"); + return optionCount > 0 ? t("task:selectBranch") : t("task:noBranchesFound"); } diff --git a/apps/web/components/task-create-dialog-header.tsx b/apps/web/components/task-create-dialog-header.tsx index 36f02d777d..ad06c024b6 100644 --- a/apps/web/components/task-create-dialog-header.tsx +++ b/apps/web/components/task-create-dialog-header.tsx @@ -1,6 +1,7 @@ "use client"; import { DialogTitle } from "@kandev/ui/dialog"; +import { useTranslation } from "react-i18next"; export type DialogHeaderContentProps = { isCreateMode: boolean; @@ -21,8 +22,13 @@ export function DialogHeaderContent({ sessionRepoName, initialTitle, }: DialogHeaderContentProps) { + const { t } = useTranslation(); if (isCreateMode || isEditMode) { - return {isEditMode ? "Edit task" : "New task"}; + return ( + + {isEditMode ? t("common:editTask") : t("task:newTask")} + + ); } return ( @@ -33,9 +39,9 @@ export function DialogHeaderContent({ / )} - {initialTitle || "Task"} + {initialTitle || t("task:task")} / - new session + {t("task:newSession2")}
); diff --git a/apps/web/components/task-create-dialog-options.tsx b/apps/web/components/task-create-dialog-options.tsx index 1632d9443d..c584155930 100644 --- a/apps/web/components/task-create-dialog-options.tsx +++ b/apps/web/components/task-create-dialog-options.tsx @@ -18,6 +18,8 @@ import { getExecutorIcon } from "@/lib/executor-icons"; import { AgentLogo } from "@/components/agent-logo"; import { getCapabilityWarning } from "@/lib/capability-warning"; import { buildBranchKeywords } from "./task-create-dialog-pill"; +import { useTranslation } from "react-i18next"; +import { t } from "@/lib/i18n"; type OptionItem = { value: string; @@ -119,6 +121,7 @@ export function useBranchOptions(branchOptionsRaw: Branch[]) { } export function useAgentProfileOptions(agentProfiles: AgentProfileOption[]): OptionItem[] { + const { t } = useTranslation(); return useMemo(() => { // Disabled profiles stay in the store (existing sessions keep their // labels) but are never offered as a choice for new work. @@ -145,7 +148,7 @@ export function useAgentProfileOptions(agentProfiles: AgentProfileOption[]): Opt {isPassthrough && ( )} {profileLabel ? ( @@ -194,15 +197,15 @@ export function computeExecutorHint( const selectedExecutor = executors.find((e: Executor) => e.id === executorId); if (selectedExecutor?.type === "worktree") { if (repoCount > 1) { - return "A git worktree will be created for each repository in a parent folder. The agent runs in that parent folder so it can see every worktree side by side."; + return t("task:executorHintWorktreeMulti"); } - return "A git worktree will be created from the base branch."; + return t("task:executorHintWorktreeSingle"); } if (selectedExecutor?.type === "local_docker" || selectedExecutor?.type === "remote_docker") { - return "A Docker container will be created from the selected base branch and checked out on a task branch."; + return t("task:executorHintDocker"); } if (selectedExecutor?.type === "local" || selectedExecutor?.type === "local_pc") - return "The agent will run directly on the repository."; + return t("task:executorHintLocal"); return null; } diff --git a/apps/web/components/task-create-dialog-remote-repo-chip.tsx b/apps/web/components/task-create-dialog-remote-repo-chip.tsx index 9a04229240..088e90e292 100644 --- a/apps/web/components/task-create-dialog-remote-repo-chip.tsx +++ b/apps/web/components/task-create-dialog-remote-repo-chip.tsx @@ -44,6 +44,8 @@ import { looksLikeSupportedRemoteURL, looksLikeURL, } from "@/components/workspace-source-picker/remote-url"; +import { Trans, useTranslation } from "react-i18next"; +import { t } from "@/lib/i18n"; const TRUNCATE_THRESHOLD = 30; @@ -130,20 +132,21 @@ export function RemoteRepoChip({ } function RemoteResolutionError({ error, onRetry }: { error: Error; onRetry: () => void }) { + const { t } = useTranslation(); return ( - Could not resolve remote repository: {error.message} + {t("task:couldNotResolveRemoteRepository", { message: error.message })} ); @@ -299,7 +302,7 @@ function RepoTriggerIcon({ row }: { row: TaskRemoteRepoRow }) { } export function computeTriggerLabel(row: TaskRemoteRepoRow): string { - if (!row.url) return "Pick or paste a repo"; + if (!row.url) return t("task:pickOrPasteARepo"); if (row.source === "picker" && row.fullName) return row.fullName; return truncateMiddle(stripScheme(row.url), TRUNCATE_THRESHOLD); } @@ -316,6 +319,16 @@ function truncateMiddle(value: string, max: number): string { // --- Popover content --------------------------------------------------------- +function StagedRemoteUrlHint() { + return ( +
+ + Remote URL — press Enter to submit it. + +
+ ); +} + function RemoteRepoPopoverContent({ accessible, selectedRepositoryIdentities, @@ -327,6 +340,7 @@ function RemoteRepoPopoverContent({ onPick: (repo: RemoteRepository) => void; onPaste: (value: string) => void; }) { + const { t } = useTranslation(); const [value, setValue] = useState(""); const [urlError, setUrlError] = useState(null); const [activeProvider, setActiveProvider] = useState(null); @@ -338,7 +352,7 @@ function RemoteRepoPopoverContent({ const trimmed = candidate.trim(); if (!isSupportedRemoteURL(trimmed)) { if (looksLikeURL(trimmed)) { - setUrlError("Enter a GitHub, GitLab, or Azure DevOps repository URL."); + setUrlError(t("task:enterRepositoryUrl")); } return false; } @@ -382,8 +396,8 @@ function RemoteRepoPopoverContent({ const isURL = looksLikeURL(value.trim()); if (commitURL(value) || isURL) event.preventDefault(); }} - placeholder="Search repositories or paste a remote URL" - aria-label="Search repositories or paste a remote URL" + placeholder={t("task:searchRepositoriesOrPasteARemote")} + aria-label={t("task:searchRepositoriesOrPasteARemote")} aria-invalid={visibleUrlError ? true : undefined} data-testid="remote-repo-input" data-legacy-testid="remote-paste-url-input" @@ -393,12 +407,7 @@ function RemoteRepoPopoverContent({ visibleUrlError && "border-destructive focus:border-destructive", )} /> - {hasStagedURL ? ( -
- Remote URL — press Enter to submit - it. -
- ) : null} + {hasStagedURL ? : null} void; urlError: string | null; }) { + const { t } = useTranslation(); const { repos, loading, error } = accessible; return (
@@ -459,15 +469,17 @@ function PickerList({ data-testid="remote-repo-picker-loading" > - Loading repositories… + {t("task:loadingRepositories")}
) : null} {!accessible.unavailable && !loading && repos.length === 0 && !error ? ( -
No repositories found.
+
+ {t("task:noRepositoriesFound")} +
) : null} {error ? (
- Could not load repositories: {error.message} + {t("task:couldNotLoadRepositories", { message: error.message })}
) : null} {repos.map((repo) => ( @@ -493,6 +505,7 @@ function RepoOption({ alreadyAdded: boolean; onPick: (repo: RemoteRepository) => void; }) { + const { t } = useTranslation(); return ( - Remove repository + {t("task:removeRepository")} ); } diff --git a/apps/web/components/task-create-dialog-remote-repo-chips.tsx b/apps/web/components/task-create-dialog-remote-repo-chips.tsx index 974f11a92b..07e0b5e8e3 100644 --- a/apps/web/components/task-create-dialog-remote-repo-chips.tsx +++ b/apps/web/components/task-create-dialog-remote-repo-chips.tsx @@ -11,6 +11,7 @@ import { type RemoteRepoChipProps, } from "@/components/task-create-dialog-remote-repo-chip"; import { useRemoteRepositories } from "@/hooks/domains/integrations/use-remote-repositories"; +import { useTranslation } from "react-i18next"; /** * Chip row for the Remote tab. Renders one `RemoteRepoChip` per row in @@ -141,13 +142,14 @@ function makeURLChange( } function AddRowButton({ onAddRow }: { onAddRow: () => void }) { + const { t } = useTranslation(); return ( - Add another remote repository + {t("task:addAnotherRemoteRepository")} ); } diff --git a/apps/web/components/task-create-dialog-repo-chips.tsx b/apps/web/components/task-create-dialog-repo-chips.tsx index 53f388c2c5..dbea009f8f 100644 --- a/apps/web/components/task-create-dialog-repo-chips.tsx +++ b/apps/web/components/task-create-dialog-repo-chips.tsx @@ -12,6 +12,8 @@ import { SourceModeSwitch } from "@/components/task-create-dialog-source-mode"; import { WorkspaceRepoChips } from "@/components/task-create-dialog-workspace-repo-chips"; import { CreateLocalRepositorySurface } from "@/components/create-local-repository-surface"; import type { DirectLocalExecutorSelection } from "@/components/task-create-dialog-handlers"; +import { useTranslation } from "react-i18next"; +import { t } from "@/lib/i18n"; type RepoChipsRowProps = { fs: DialogFormState; @@ -205,12 +207,13 @@ function ModeBody({ onRefreshRepositories?: () => void; repositoriesRefreshing?: boolean; }) { + const { t } = useTranslation(); if (fs.noRepository) { return ( {})} - placeholder="pick a starting folder (optional)" + placeholder={t("task:pickAStartingFolderOptional")} /> ); } @@ -265,6 +268,7 @@ function FreshBranchToggle({ enabled: boolean; onToggle: (enabled: boolean) => void; }) { + const { t } = useTranslation(); return ( @@ -273,11 +277,7 @@ function FreshBranchToggle({ onClick={() => onToggle(!enabled)} data-testid="fresh-branch-toggle" aria-pressed={enabled} - aria-label={ - enabled - ? "Fork a new branch from a base (turn off to use current checkout)" - : "Fork a new branch from a base instead of using current checkout" - } + aria-label={enabled ? t("task:forkANewBranchFromA") : t("task:forkANewBranchFromA2")} className={cn( "inline-flex h-7 w-7 items-center justify-center rounded-md border border-input cursor-pointer transition-colors", enabled @@ -289,9 +289,7 @@ function FreshBranchToggle({ - {enabled - ? "Fork mode: a new branch will be created from the selected base before the agent runs. Click to turn off and use your repository's current checkout instead." - : "By default the local executor uses your repository's current checkout. Click to fork a new branch from a base instead, leaving your working tree untouched."} + {enabled ? t("task:forkModeANewBranchWill") : t("task:byDefaultTheLocalExecutorUses")} ); @@ -299,6 +297,6 @@ function FreshBranchToggle({ function computeAddHint(canAddMore: boolean, workspaceRepoCount: number): string | undefined { if (canAddMore) return undefined; - if (workspaceRepoCount === 0) return "No repositories available in this workspace"; - return "All workspace repositories are already added"; + if (workspaceRepoCount === 0) return t("task:noRepositoriesAvailableInWorkspace"); + return t("task:allWorkspaceRepositoriesAdded"); } diff --git a/apps/web/components/task-create-dialog-selectors.tsx b/apps/web/components/task-create-dialog-selectors.tsx index 3a391e08b8..6b52cf5fcd 100644 --- a/apps/web/components/task-create-dialog-selectors.tsx +++ b/apps/web/components/task-create-dialog-selectors.tsx @@ -40,6 +40,8 @@ import { cn } from "@/lib/utils"; import { clampTaskTitleInput } from "@/lib/task-title"; import { deleteAttachment, uploadAttachment } from "@/lib/api/domains/attachment-api"; import { ApiError } from "@/lib/api/client"; +import { useTranslation } from "react-i18next"; +import { t } from "@/lib/i18n"; const CURSOR_POINTER_CLASS = "cursor-pointer"; @@ -102,7 +104,7 @@ export const RepositorySelector = memo(function RepositorySelector({ searchPlaceholder={searchPlaceholder} emptyMessage={emptyMessage} disabled={disabled} - dropdownLabel="Repository" + dropdownLabel={t("task:repository2")} className={disabled ? undefined : CURSOR_POINTER_CLASS} triggerClassName={triggerClassName} testId="repository-selector" @@ -165,7 +167,7 @@ export const BranchSelector = memo(function BranchSelector({ searchPlaceholder={searchPlaceholder} emptyMessage={emptyMessage} disabled={disabled} - dropdownLabel="Base Branch" + dropdownLabel={t("task:baseBranch2")} className={disabled ? undefined : CURSOR_POINTER_CLASS} triggerClassName={triggerClassName} testId="branch-selector" @@ -195,16 +197,17 @@ export const AgentSelector = memo(function AgentSelector({ triggerClassName, popoverPortal, }: AgentSelectorProps) { + const { t } = useTranslation(); return ( (null); const hasFocusedRef = useRef(false); @@ -314,7 +320,7 @@ export const InlineTaskName = memo(function InlineTaskName({ type="text" value={value} onChange={(e) => onChange(clampTaskTitleInput(e.target.value))} - placeholder="Task name" + placeholder={t("task:taskName")} data-testid="task-title-input" className="w-full min-w-0 max-w-full border border-input bg-input/20 dark:bg-input/30 text-sm font-medium rounded-md px-3 py-2 placeholder:text-muted-foreground/70 outline-none focus-visible:border-ring transition-colors" /> @@ -394,7 +400,7 @@ function useFileAttachments( } catch (error) { updateAttachment(attachment.id, { uploadStatus: "failed", - uploadError: error instanceof ApiError ? error.message : "Upload failed", + uploadError: error instanceof ApiError ? error.message : t("task:uploadFailed"), }); } }, @@ -559,7 +565,7 @@ function toContextItems( ? ({ kind: "image" as const, id: `image:${att.id}`, - label: `Image (${formatBytes(att.size)})`, + label: t("task:imageWithSize", { bytes: formatBytes(att.size) }), attachment: att, onRemove: () => onRemove(att.id), onRetry: () => onRetry(att.id), @@ -576,13 +582,14 @@ function toContextItems( } function AttachButton({ onClick, disabled }: { onClick: () => void; disabled?: boolean }) { + const { t } = useTranslation(); return (
- Attach files + {t("task:attachFiles")}
); @@ -814,10 +821,11 @@ function HiddenFileInput({ } function DraggingOverlay({ isDragging }: { isDragging: boolean }) { + const { t } = useTranslation(); if (!isDragging) return null; return (
- Drop files here + {t("task:dropFilesHere")}
); } @@ -840,6 +848,7 @@ export const TaskFormInputs = memo(function TaskFormInputs({ linearImport, onVoiceAutoSend, }: TaskFormInputsProps) { + const { t } = useTranslation(); const { attachments, isDragging, @@ -892,8 +901,8 @@ export const TaskFormInputs = memo(function TaskFormInputs({ placeholder={ placeholder ?? (isSessionMode - ? "Describe what you want the agent to do... (@ to insert a saved prompt)" - : "Write a prompt for the agent... (@ to insert a saved prompt)") + ? t("task:describeWhatYouWantTheAgent") + : t("task:writeAPromptForTheAgent")) } value={description} onChange={handleChange} diff --git a/apps/web/components/task-create-dialog-source-mode.tsx b/apps/web/components/task-create-dialog-source-mode.tsx index 35f708b592..869a8acabd 100644 --- a/apps/web/components/task-create-dialog-source-mode.tsx +++ b/apps/web/components/task-create-dialog-source-mode.tsx @@ -1,6 +1,7 @@ "use client"; import { cn } from "@/lib/utils"; +import { useTranslation } from "react-i18next"; export type SourceMode = "workspace" | "remote" | "scratch"; @@ -37,10 +38,11 @@ export function ControlledSourceModeSwitch({ onModeChange, touchSized = false, }: ControlledSourceModeSwitchProps) { + const { t } = useTranslation(); return (
{options.map((option) => { @@ -86,6 +88,7 @@ export function SourceModeSwitch({ onToggleRemote, onToggleNoRepository, }: SourceModeSwitchProps) { + const { t } = useTranslation(); if (!onToggleRemote && !onToggleNoRepository) return null; const mode = resolveMode(useRemote, noRepository); const setMode = (next: SourceMode) => @@ -101,19 +104,25 @@ export function SourceModeSwitch({ mode={mode} onModeChange={setMode} options={[ - { value: "workspace", label: "Repo", testId: "source-mode-workspace" }, + { value: "workspace", label: t("task:repo"), testId: "source-mode-workspace" }, ...(onToggleRemote ? [ { value: "remote" as const, - label: "Remote", + label: t("task:remote"), testId: "source-mode-remote", legacyTestId: "toggle-github-url", }, ] : []), ...(onToggleNoRepository - ? [{ value: "scratch" as const, label: "None", testId: "source-mode-scratch" }] + ? [ + { + value: "scratch" as const, + label: t("task:groupNone"), + testId: "source-mode-scratch", + }, + ] : []), ]} /> diff --git a/apps/web/components/task-create-dialog-workspace-repo-chips.tsx b/apps/web/components/task-create-dialog-workspace-repo-chips.tsx index 990c05346a..ea4d95aba4 100644 --- a/apps/web/components/task-create-dialog-workspace-repo-chips.tsx +++ b/apps/web/components/task-create-dialog-workspace-repo-chips.tsx @@ -31,6 +31,8 @@ import { computeBranchDisabledReason, } from "@/components/task-create-dialog-branch-utils"; import { useRepoBranchAutoselect } from "@/components/task-create-dialog-repo-branch-autoselect"; +import { useTranslation } from "react-i18next"; +import { t } from "@/lib/i18n"; type WorkspaceRepoChipsProps = { rows: TaskRepoRow[]; @@ -88,6 +90,7 @@ export function WorkspaceRepoChips({ lastUsedBranch, userSettingsLoaded, }: WorkspaceRepoChipsProps) { + const { t } = useTranslation(); return ( <> {rows.map((row) => ( @@ -134,7 +137,7 @@ export function WorkspaceRepoChips({ type="button" onClick={onAdd} disabled={!canAddMore} - aria-label="Add repository" + aria-label={t("task:addRepository")} data-testid="add-repository" className={cn( "inline-flex items-center justify-center gap-1.5 rounded-md text-muted-foreground", @@ -149,7 +152,7 @@ export function WorkspaceRepoChips({ - {addHint ?? "Add another repository"} + {addHint ?? t("task:addAnotherRepository")} ); @@ -368,14 +371,16 @@ function computeRepoChipDisplay( const discoveredRepo = discoveredRepositories.find((r) => r.path === row.localPath); const repoLabel = workspaceRepo?.name ?? (discoveredRepo ? leafSegment(discoveredRepo.path) : ""); const repoPath = workspaceRepo?.local_path || discoveredRepo?.path || ""; - const repoTooltip = repoPath ? `Repository · ${formatUserHomePath(repoPath)}` : "Repository"; + const repoTooltip = repoPath + ? t("task:repositoryWithPath", { path: formatUserHomePath(repoPath) }) + : t("task:repository2"); return { repoLabel, repoTooltip }; } function buildCreateRepositoryAction(onSelect?: () => void): PillAction | undefined { if (!onSelect) return undefined; return { - label: "Create new repository", + label: t("task:createNewRepository"), icon: , onSelect, }; @@ -401,6 +406,7 @@ function RepoChip({ onRefreshRepositories, repositoriesRefreshing, }: RepoChipProps) { + const { t } = useTranslation(); const { repoOptions, branchOptions, branchesLoading, refreshBranches } = useRepoChipData({ row, workspaceId, @@ -436,11 +442,11 @@ function RepoChip({ } value={repoLabel} - placeholder="repository" + placeholder={t("task:repository")} options={repoOptions} onSelect={onRepositoryChange} - searchPlaceholder="Search repositories..." - emptyMessage="No repositories" + searchPlaceholder={t("task:searchRepositories")} + emptyMessage={t("task:noRepositories")} testId="repo-chip-trigger" tooltip={repoTooltip} filter={scoreRepo} @@ -464,8 +470,8 @@ function RepoChip({ branchesLoading, optionCount: branchOptions.length, })} - searchPlaceholder="Search branches..." - emptyMessage="No branches" + searchPlaceholder={t("task:searchBranches")} + emptyMessage={t("task:noBranches")} testId="branch-chip-trigger" tooltip={computeBranchTooltip(branchPrefix)} onRefresh={refreshBranches} @@ -479,20 +485,21 @@ function RepoChip({ } function RepoChipRemoveButton({ onRemove }: { onRemove: () => void }) { + const { t } = useTranslation(); return ( - Remove repository + {t("task:removeRepository")} ); } @@ -528,7 +535,7 @@ function renderDiscoveredRepoOption(path: string, alreadyAdded: boolean) { {display} - on disk + {t("task:onDisk")} {alreadyAdded ? : null} @@ -536,10 +543,11 @@ function renderDiscoveredRepoOption(path: string, alreadyAdded: boolean) { } function AlreadyAddedMarker() { + const { t } = useTranslation(); return ( diff --git a/apps/web/components/task-preview-panel.tsx b/apps/web/components/task-preview-panel.tsx index e2946c57fe..8bec87a264 100644 --- a/apps/web/components/task-preview-panel.tsx +++ b/apps/web/components/task-preview-panel.tsx @@ -6,6 +6,7 @@ import { useAppStore } from "@/components/state-provider"; import type { UseEnsureTaskSessionResult } from "@/hooks/domains/session/use-ensure-task-session"; import type { Task } from "./kanban-card"; import { PreviewSessionTabs } from "./task/preview-session-tabs"; +import { useTranslation } from "react-i18next"; interface TaskPreviewPanelProps { task: Task | null; @@ -24,6 +25,7 @@ export function TaskPreviewPanel({ onMaximize, onSessionChange, }: TaskPreviewPanelProps) { + const { t } = useTranslation(); const activeWorkspaceId = useAppStore((s) => s.workspaces.activeId); return (
{/* Header */}
-

{task?.title ?? "Task Chat"}

+

{task?.title ?? t("task:taskChat")}

{onMaximize && task && ( )}
@@ -65,7 +67,7 @@ export function TaskPreviewPanel({ /> ) : (
- Select a task to start chatting + {t("task:selectATaskToStartChatting")}
)}
diff --git a/apps/web/components/task/DockviewLayout.tsx b/apps/web/components/task/DockviewLayout.tsx index 38318597be..39967cc52a 100644 --- a/apps/web/components/task/DockviewLayout.tsx +++ b/apps/web/components/task/DockviewLayout.tsx @@ -18,6 +18,7 @@ import dynamic from "@/lib/routing/client-dynamic"; import { IconClock } from "@tabler/icons-react"; +import { useTranslation } from "react-i18next"; const OfficeDockviewLayout = dynamic( () => @@ -47,6 +48,7 @@ export function isDormant(executionId: string | null | undefined): boolean { } function DormantPanel({ kind }: { kind: DockviewLayoutKind }) { + const { t } = useTranslation(); const verb = kind === "office" ? "Routine" : "Agent"; return (
@@ -55,13 +57,10 @@ function DormantPanel({ kind }: { kind: DockviewLayoutKind }) { className="flex max-w-md flex-col items-center gap-3 rounded-lg border border-dashed border-border bg-muted/30 p-8 text-center" >
diff --git a/apps/web/components/task/TreeCancelDialog.tsx b/apps/web/components/task/TreeCancelDialog.tsx index 869b2d8d61..ad8d1cdd6b 100644 --- a/apps/web/components/task/TreeCancelDialog.tsx +++ b/apps/web/components/task/TreeCancelDialog.tsx @@ -10,6 +10,7 @@ import { AlertDialogHeader, AlertDialogTitle, } from "@kandev/ui/alert-dialog"; +import { useTranslation } from "react-i18next"; type TreeCancelDialogProps = { open: boolean; @@ -26,20 +27,20 @@ export function TreeCancelDialog({ activeRunCount, onConfirm, }: TreeCancelDialogProps) { + const { t } = useTranslation(); return ( - Cancel task tree + {t("task:cancelTaskTree")} - This will cancel {taskCount} tasks and interrupt {activeRunCount} active agent sessions. - This action can be undone with Restore. + {t("task:treeCancelDescription", { taskCount, activeRunCount })} - Keep running + {t("task:keepRunning")} - Cancel tree + {t("task:cancelTree")} diff --git a/apps/web/components/task/auth-methods-indicator.tsx b/apps/web/components/task/auth-methods-indicator.tsx index 0480655f98..ec7ffb3840 100644 --- a/apps/web/components/task/auth-methods-indicator.tsx +++ b/apps/web/components/task/auth-methods-indicator.tsx @@ -6,6 +6,7 @@ import { Button } from "@kandev/ui/button"; import { Popover, PopoverContent, PopoverTrigger } from "@kandev/ui/popover"; import { useAppStore } from "@/components/state-provider"; import { authenticateSession } from "@/lib/api/domains/session-api"; +import { useTranslation } from "react-i18next"; type AuthMethodsIndicatorProps = { sessionId: string | null; @@ -14,6 +15,7 @@ type AuthMethodsIndicatorProps = { export const AuthMethodsIndicator = memo(function AuthMethodsIndicator({ sessionId, }: AuthMethodsIndicatorProps) { + const { t } = useTranslation(); const authMethods = useAppStore((state) => { if (!sessionId) return undefined; return state.agentCapabilities.bySessionId[sessionId]?.authMethods; @@ -48,7 +50,9 @@ export const AuthMethodsIndicator = memo(function AuthMethodsIndicator({
-
Authentication
+
+ {t("task:authentication")} +
{authMethods.map((method) => (
{method.name}
@@ -70,7 +74,7 @@ export const AuthMethodsIndicator = memo(function AuthMethodsIndicator({ className="h-6 text-xs cursor-pointer" onClick={() => handleAuthenticate(method.id)} > - Login + {t("task:login")} )}
diff --git a/apps/web/components/task/base-branch-picker.tsx b/apps/web/components/task/base-branch-picker.tsx index f38d0e18dd..2db7119a9e 100644 --- a/apps/web/components/task/base-branch-picker.tsx +++ b/apps/web/components/task/base-branch-picker.tsx @@ -11,6 +11,7 @@ import { invalidateCumulativeDiffCache } from "@/hooks/domains/session/use-cumul import { updateTaskRepositoryBaseBranch } from "@/lib/api/domains/kanban-api"; import { useToast } from "@/components/toast-provider"; import { repositoryId, type Branch, type Repository } from "@/lib/types/http"; +import { useTranslation } from "react-i18next"; type ResolvedRepo = { taskRepositoryId: string; @@ -62,6 +63,7 @@ function useResolvedTaskRepo(taskId: string | null, repositoryName: string): Res * the trigger components tiny and avoids duplicated select + toast plumbing. */ function usePickerLogic(taskId: string | null, repositoryName: string, fallbackBaseBranch: string) { + const { t } = useTranslation(); const { toast } = useToast(); const [open, setOpen] = useState(false); const [isSaving, setIsSaving] = useState(false); @@ -101,8 +103,8 @@ function usePickerLogic(taskId: string | null, repositoryName: string, fallbackB if (envKey) invalidateCumulativeDiffCache(envKey); } catch (err) { toast({ - title: "Failed to change compare branch", - description: err instanceof Error ? err.message : "Unknown error", + title: t("task:failedToChangeCompareBranch"), + description: err instanceof Error ? err.message : t("task:unknownError"), variant: "error", }); } finally { @@ -134,6 +136,7 @@ function BranchList({ currentBase: string; onSelect: (name: string) => void; }) { + const { t } = useTranslation(); const [filter, setFilter] = useState(""); // Dedupe by name (local + remote variants collapse to one option) so the // list shows each branch once even when the API returns both kinds. @@ -159,7 +162,7 @@ function BranchList({ type="text" value={filter} onChange={(e) => setFilter(e.target.value)} - placeholder="Filter branches…" + placeholder={t("task:filterBranches")} data-testid="base-branch-picker-filter" className="w-full rounded border border-input bg-background px-2 py-1 text-xs outline-none focus:ring-1 focus:ring-ring" autoFocus @@ -185,16 +188,21 @@ function BranchListBody({ currentBase: string; onSelect: (name: string) => void; }) { + const { t } = useTranslation(); if (isLoadingBranches && filtered.length === 0) { return (
- Loading branches… + {t("task:loadingBranches")}
); } if (filtered.length === 0) { - return
No matching branches
; + return ( +
+ {t("task:noMatchingBranches")} +
+ ); } return ( <> diff --git a/apps/web/components/task/bottom-terminal-panel.tsx b/apps/web/components/task/bottom-terminal-panel.tsx index ebea52634c..26b1a08a21 100644 --- a/apps/web/components/task/bottom-terminal-panel.tsx +++ b/apps/web/components/task/bottom-terminal-panel.tsx @@ -7,6 +7,7 @@ import { useEnvironmentId } from "@/hooks/use-environment-session-id"; import { PassthroughTerminal } from "./passthrough-terminal"; import { Button } from "@kandev/ui/button"; import { cn } from "@/lib/utils"; +import { useTranslation } from "react-i18next"; const MIN_HEIGHT = 150; const MAX_HEIGHT_RATIO = 0.6; @@ -15,6 +16,7 @@ const STORAGE_KEY_HEIGHT = "bottom-terminal-height"; const STORAGE_KEY_OPEN = "bottom-terminal-open"; export function BottomTerminalPanel() { + const { t } = useTranslation(); const environmentId = useEnvironmentId(); const visible = useAppStore((s) => s.bottomTerminal.isOpen); const pendingCommand = useAppStore((s) => s.bottomTerminal.pendingCommand); @@ -87,7 +89,7 @@ export function BottomTerminalPanel() { /> {/* Header */}
- Terminal + {t("task:terminal")} diff --git a/apps/web/components/task/browser-panel.tsx b/apps/web/components/task/browser-panel.tsx index fb0c1106ff..8f4b9add4c 100644 --- a/apps/web/components/task/browser-panel.tsx +++ b/apps/web/components/task/browser-panel.tsx @@ -12,6 +12,7 @@ import { AnnotationsPanel } from "./inspector/annotations-panel"; import { useInspectMode } from "@/hooks/use-inspect-mode"; import { usePreviewConsoleForwarder } from "@/hooks/use-preview-console-forwarder"; import { openExternalLink } from "@/lib/desktop/external-links"; +import { useTranslation } from "react-i18next"; function BrowserPanelContent({ showIframeDelayed, @@ -26,13 +27,14 @@ function BrowserPanelContent({ iframeRef: React.RefObject; onIframeLoad: () => void; }) { + const { t } = useTranslation(); if (showIframeDelayed) { return (