feat(i18n): localize Settings Plugins, Account and the SSH/executors tree (#2249)

Co-authored-by: Kandev Agent <agent@kandev.dev>
This commit is contained in:
João Salavisa
2026-08-05 12:53:55 +02:00
committed by GitHub
parent 82951e3848
commit f2510dfcb5
55 changed files with 1626 additions and 430 deletions
@@ -1,6 +1,7 @@
"use client";
import { useCallback, useEffect, useMemo, useState } from "react";
import { useTranslation } from "react-i18next";
import { useRouter } from "@/lib/routing/client-router";
import { runWithNavigationBlockerBypassed } from "@/lib/routing/navigation-guard";
import { Button } from "@kandev/ui/button";
@@ -172,6 +173,7 @@ function useGitIdentityState(isRemote: boolean, profile: ExecutorProfile) {
}
export default function ProfileEditPage({ profileId }: { profileId: string }) {
const { t } = useTranslation();
const router = useRouter();
const result = useProfileFromStore(profileId);
@@ -179,9 +181,9 @@ export default function ProfileEditPage({ profileId }: { profileId: string }) {
return (
<Card>
<CardContent className="py-12 text-center">
<p className="text-muted-foreground">Profile not found</p>
<p className="text-muted-foreground">{t("executors:profileNotFound")}</p>
<Button className="mt-4 cursor-pointer" onClick={() => router.push(EXECUTORS_ROUTE)}>
Back to Executors
{t("executors:backToExecutors")}
</Button>
</CardContent>
</Card>
@@ -194,6 +196,7 @@ export default function ProfileEditPage({ profileId }: { profileId: string }) {
}
function useProfilePersistence(executor: Executor, profile: ExecutorProfile) {
const { t } = useTranslation();
const router = useRouter();
const { toast } = useToast();
const executors = useAppStore((state) => state.executors.items);
@@ -217,18 +220,22 @@ function useProfilePersistence(executor: Executor, profile: ExecutorProfile) {
try {
const updated = await updateExecutorProfile(executor.id, profile.id, data);
setSaveStatus("success");
toast({ title: "Profile saved", variant: "success" });
toast({ title: t("executors:profileSaved"), variant: "success" });
setExecutors(upsertExecutorProfile(executors, executor, updated));
window.setTimeout(() => setSaveStatus("idle"), 1500);
} catch (err) {
const message = err instanceof Error ? err.message : "Failed to save profile";
const message = err instanceof Error ? err.message : t("executors:failedToSaveProfile");
setError(message);
setSaveStatus("error");
toast({ title: "Failed to save profile", description: message, variant: "error" });
toast({
title: t("executors:failedToSaveProfile"),
description: message,
variant: "error",
});
throw err;
}
},
[executor, profile.id, executors, setExecutors, toast],
[executor, profile.id, executors, setExecutors, toast, t],
);
const remove = useCallback(
@@ -257,6 +264,7 @@ function useProfilePersistence(executor: Executor, profile: ExecutorProfile) {
}
function useProfileFormState(executor: Executor, profile: ExecutorProfile) {
const { t } = useTranslation();
const [name, setName] = useState(profile.name);
const [mcpPolicy, setMcpPolicy] = useState(profile.mcp_policy ?? "");
const [prepareScript, setPrepareScript] = useState(profile.prepare_script ?? "");
@@ -272,7 +280,7 @@ function useProfileFormState(executor: Executor, profile: ExecutorProfile) {
const flags = useRemoteExecutorFlags(executor.type);
const remoteAuth = useRemoteAuthState(profile);
const gitIdentity = useGitIdentityState(flags.isRemote, profile);
const mcpPolicyError = useMemo(() => validateMcpPolicy(mcpPolicy), [mcpPolicy]);
const mcpPolicyErrorKey = useMemo(() => validateMcpPolicy(mcpPolicy), [mcpPolicy]);
useEffect(() => {
listScriptPlaceholders()
@@ -289,8 +297,8 @@ function useProfileFormState(executor: Executor, profile: ExecutorProfile) {
}, [envVarRows, flags.isSprites, spritesSecretId]);
const prepareDesc = flags.isRemote
? "Runs inside the execution environment before the agent starts. Type {{ to see available placeholders."
: "Runs on the host machine before the agent starts.";
? t("executors:prepareScriptDescriptionRemote", { trigger: "{{" })
: t("executors:prepareScriptDescriptionLocal");
return {
name,
@@ -332,7 +340,7 @@ function useProfileFormState(executor: Executor, profile: ExecutorProfile) {
isDocker: flags.isDocker,
isSprites: flags.isSprites,
isSSH: executor.type === "ssh",
mcpPolicyError,
mcpPolicyErrorKey,
buildEnvVars,
prepareDesc,
};
@@ -478,8 +486,29 @@ function ProfileEditSections({ executor, profile, form, secrets }: ProfileEditSe
onUpdate={form.updateEnvVar}
onRemove={form.removeEnvVar}
/>
<ProfileScriptCards executor={executor} profile={profile} form={form} />
<McpPolicyCard
mcpPolicy={form.mcpPolicy}
baselinePolicy={profile.mcp_policy ?? ""}
mcpPolicyErrorKey={form.mcpPolicyErrorKey}
onPolicyChange={form.setMcpPolicy}
/>
</>
);
}
/** The prepare/cleanup script pair, split out to keep ProfileEditSections
* under the max-lines-per-function budget. */
function ProfileScriptCards({
executor,
profile,
form,
}: Omit<ProfileEditSectionsProps, "secrets">) {
const { t } = useTranslation();
return (
<>
<ScriptCard
title="Prepare Script"
title={t("executors:prepareScript")}
description={form.prepareDesc}
value={form.prepareScript}
baselineValue={profile.prepare_script ?? ""}
@@ -490,8 +519,8 @@ function ProfileEditSections({ executor, profile, form, secrets }: ProfileEditSe
/>
{form.isRemote && (
<ScriptCard
title="Cleanup Script"
description="Runs after the agent session ends for cleanup tasks."
title={t("executors:cleanupScript")}
description={t("executors:runsAfterTheAgentSessionEnds")}
value={form.cleanupScript}
baselineValue={profile.cleanup_script ?? ""}
onChange={form.setCleanupScript}
@@ -500,17 +529,12 @@ function ProfileEditSections({ executor, profile, form, secrets }: ProfileEditSe
executorType={executor.type}
/>
)}
<McpPolicyCard
mcpPolicy={form.mcpPolicy}
baselinePolicy={profile.mcp_policy ?? ""}
mcpPolicyError={form.mcpPolicyError}
onPolicyChange={form.setMcpPolicy}
/>
</>
);
}
function ProfileEditForm({ executor, profile }: { executor: Executor; profile: ExecutorProfile }) {
const { t } = useTranslation();
const router = useRouter();
const { items: secrets } = useSecrets();
const persistence = useProfilePersistence(executor, profile);
@@ -527,7 +551,7 @@ function ProfileEditForm({ executor, profile }: { executor: Executor; profile: E
data-testid="ssh-connection-settings-link"
>
<IconShieldLock className="mr-1.5 h-4 w-4" />
Connection Settings
{t("executors:connectionSettings")}
</Button>
) : undefined;
@@ -555,15 +579,15 @@ function ProfileEditForm({ executor, profile }: { executor: Executor; profile: E
setSavedRevision(submittedRevision);
};
let invalidReason: string | undefined;
if (!form.name.trim()) invalidReason = "Profile name is required.";
else if (form.mcpPolicyError) invalidReason = form.mcpPolicyError;
else if (spritesTokenMissing) invalidReason = "Sprites token is required.";
if (!form.name.trim()) invalidReason = t("executors:profileNameIsRequired");
else if (form.mcpPolicyErrorKey) invalidReason = t(form.mcpPolicyErrorKey);
else if (spritesTokenMissing) invalidReason = t("executors:spritesTokenIsRequired");
useSettingsSaveContributor({
id: `executor-profile:${profile.id}`,
revision: saveRevision,
isDirty: baselineReady && saveRevision !== savedRevision,
canSave:
baselineReady && Boolean(form.name.trim()) && !form.mcpPolicyError && !spritesTokenMissing,
baselineReady && Boolean(form.name.trim()) && !form.mcpPolicyErrorKey && !spritesTokenMissing,
invalidReason,
save: handleSave,
discard: () => undefined,
@@ -596,7 +620,7 @@ function ProfileEditForm({ executor, profile }: { executor: Executor; profile: E
<ProfileEditSections executor={executor} profile={profile} form={form} secrets={secrets} />
</fieldset>
{spritesTokenMissing && (
<p className="text-sm text-destructive">Sprites API key is required.</p>
<p className="text-sm text-destructive">{t("executors:spritesApiKeyIsRequired")}</p>
)}
{persistence.error && <p className="text-sm text-destructive">{persistence.error}</p>}
<ProfileFormActions onDelete={() => persistence.setDeleteDialogOpen(true)} />
@@ -1,42 +1,60 @@
// Registry of executor types presented in the "new executor profile" flow.
// Keep entries here (not inline in the page) so the page file stays under
// the 600-line lint cap and new types can be added without touching layout.
//
// The map keys (`local`, `worktree`, `local_docker`, `remote_docker`,
// `sprites`, `ssh`) are the persisted executor enum and the create route's
// path segment, and `executorId` is a backend row id — none of them is copy.
// Labels and descriptions travel as catalog keys and resolve at render;
// `brandLabel` carries a brand/protocol name that reads the same in every
// locale, so it is a value rather than a message.
export type ExecutorTypeInfo = {
executorId: string;
label: string;
description: string;
/** Rendered verbatim. Set on brand/protocol names only. */
brandLabel?: string;
/** Catalog key for the label. Set when `brandLabel` is not. */
labelKey?: string;
descriptionKey: string;
};
export const EXECUTOR_TYPE_MAP: Record<string, ExecutorTypeInfo> = {
local: {
executorId: "exec-local",
label: "Local",
description: "Runs agents directly in the repository folder.",
labelKey: "executors:typeLocal",
descriptionKey: "executors:descriptionLocalPc",
},
worktree: {
executorId: "exec-worktree",
label: "Worktree",
description: "Creates git worktrees for isolated agent sessions.",
labelKey: "executors:typeWorktree",
descriptionKey: "executors:descriptionWorktree",
},
local_docker: {
executorId: "exec-local-docker",
label: "Docker",
description: "Runs Docker containers on this machine.",
brandLabel: "Docker",
descriptionKey: "executors:descriptionLocalDocker",
},
remote_docker: {
executorId: "exec-remote-docker",
label: "Remote Docker",
description: "Connects to a remote Docker host.",
labelKey: "executors:remoteDocker",
descriptionKey: "executors:descriptionRemoteDocker",
},
sprites: {
executorId: "exec-sprites",
label: "Sprites.dev",
description: "Runs agents in Sprites.dev cloud sandboxes.",
brandLabel: "Sprites.dev",
descriptionKey: "executors:descriptionSprites",
},
// `CreateProfilePage` short-circuits `ssh` to `SSHCreatePage`, which renders
// its own header — this entry exists so the unknown-type guard accepts the
// route, and its label/description are never rendered.
ssh: {
executorId: "exec-ssh",
label: "SSH",
description: "Connects to a remote host over SSH and runs agentctl there.",
brandLabel: "SSH",
descriptionKey: "executors:descriptionSshCreate",
},
};
/** The label for a type card: a brand name verbatim, or the translated label. */
export function executorTypeLabel(info: ExecutorTypeInfo, t: (key: string) => string): string {
return info.brandLabel ?? (info.labelKey ? t(info.labelKey) : "");
}
@@ -1,6 +1,7 @@
"use client";
import { useCallback, useEffect, useMemo, useState } from "react";
import { useTranslation } from "react-i18next";
import { useRouter } from "@/lib/routing/client-router";
import { runWithNavigationBlockerBypassed } from "@/lib/routing/navigation-guard";
import { Badge } from "@kandev/ui/badge";
@@ -44,7 +45,7 @@ import {
import type { NetworkPolicyRule } from "@/lib/api/domains/settings-api";
import type { Executor, ExecutorType, ProfileEnvVar } from "@/lib/types/http";
import { EXECUTOR_TYPE_MAP } from "./executor-types";
import { EXECUTOR_TYPE_MAP, executorTypeLabel, type ExecutorTypeInfo } from "./executor-types";
import { SSHCreatePage } from "./ssh-create-page";
const EXECUTORS_ROUTE = "/settings/executors";
@@ -72,28 +73,22 @@ export default function CreateProfilePage({ executorType }: { executorType: stri
}
function InvalidTypeFallback() {
const { t } = useTranslation();
const router = useRouter();
return (
<Card>
<CardContent className="py-12 text-center">
<p className="text-muted-foreground">Unknown executor type</p>
<p className="text-muted-foreground">{t("executors:unknownExecutorType")}</p>
<Button className="mt-4 cursor-pointer" onClick={() => router.push(EXECUTORS_ROUTE)}>
Back to Executors
{t("executors:backToExecutors")}
</Button>
</CardContent>
</Card>
);
}
function CreateProfileHeader({
type,
label,
description,
}: {
type: string;
label: string;
description: string;
}) {
function CreateProfileHeader({ type, typeInfo }: { type: string; typeInfo: ExecutorTypeInfo }) {
const { t } = useTranslation();
const router = useRouter();
return (
<>
@@ -101,12 +96,14 @@ function CreateProfileHeader({
<div>
<div className="flex items-center gap-2">
<ExecutorTypeIcon type={type} />
<h2 className="text-2xl font-bold">New {label} Profile</h2>
<h2 className="text-2xl font-bold">
{t("executors:newTypeProfile", { type: executorTypeLabel(typeInfo, t) })}
</h2>
<Badge variant="outline" className="text-xs">
{getExecutorLabel(type)}
</Badge>
</div>
<p className="mt-1 text-sm text-muted-foreground">{description}</p>
<p className="mt-1 text-sm text-muted-foreground">{t(typeInfo.descriptionKey)}</p>
</div>
<Button
variant="outline"
@@ -114,7 +111,7 @@ function CreateProfileHeader({
onClick={() => router.push(EXECUTORS_ROUTE)}
className="cursor-pointer"
>
Back to Executors
{t("executors:backToExecutors")}
</Button>
</div>
<Separator />
@@ -280,6 +277,7 @@ function useCreateGitIdentityState(isRemote: boolean) {
}
function useCreateProfileFormState(executorType: ExecutorType) {
const { t } = useTranslation();
const [name, setName] = useState(() => (executorType === "local_docker" ? "Docker" : ""));
const [mcpPolicy, setMcpPolicy] = useState("");
const [prepareScript, setPrepareScript] = useState("");
@@ -293,7 +291,7 @@ function useCreateProfileFormState(executorType: ExecutorType) {
const [builtDockerImage, setBuiltDockerImage] = useState<DockerBuildSuccess | null>(null);
const flags = useCreateRemoteFlags(executorType);
const gitIdentity = useCreateGitIdentityState(flags.isRemote);
const mcpPolicyError = useMemo(() => validateMcpPolicy(mcpPolicy), [mcpPolicy]);
const mcpPolicyErrorKey = useMemo(() => validateMcpPolicy(mcpPolicy), [mcpPolicy]);
useEffect(() => {
listScriptPlaceholders()
@@ -323,8 +321,8 @@ function useCreateProfileFormState(executorType: ExecutorType) {
builtDockerImage?.imageTag === imageTag.trim());
const prepareDesc = flags.isRemote
? "Runs inside the execution environment before the agent starts. Type {{ to see available placeholders."
: "Runs on the host machine before the agent starts.";
? t("executors:prepareScriptDescriptionRemote", { trigger: "{{" })
: t("executors:prepareScriptDescriptionLocal");
return {
name,
@@ -364,13 +362,14 @@ function useCreateProfileFormState(executorType: ExecutorType) {
isRemote: flags.isRemote,
isDocker: flags.isDocker,
isSprites: flags.isSprites,
mcpPolicyError,
mcpPolicyErrorKey,
buildEnvVars,
prepareDesc,
};
}
function useCreateProfileSave(executorId: string) {
const { t } = useTranslation();
const router = useRouter();
const executors = useAppStore((state) => state.executors.items);
const setExecutors = useAppStore((state) => state.setExecutors);
@@ -390,13 +389,13 @@ function useCreateProfileSave(executorId: string) {
);
runWithNavigationBlockerBypassed(() => router.push(`/settings/executors/${profile.id}`));
} catch (err) {
setError(err instanceof Error ? err.message : "Failed to create profile");
setError(err instanceof Error ? err.message : t("executors:failedToCreateProfile"));
throw err;
} finally {
setSaving(false);
}
},
[executorId, executors, setExecutors, router],
[executorId, executors, setExecutors, router, t],
);
return { saving, error, handleSave };
@@ -411,6 +410,7 @@ function CreateProfileSections({
form: ReturnType<typeof useCreateProfileFormState>;
secrets: ReturnType<typeof useSecrets>["items"];
}) {
const { t } = useTranslation();
return (
<>
<ProfileDetailsCard name={form.name} baselineName="" onNameChange={form.setName} />
@@ -471,7 +471,7 @@ function CreateProfileSections({
onRemove={form.removeEnvVar}
/>
<ScriptCard
title="Prepare Script"
title={t("executors:prepareScript")}
description={form.prepareDesc}
value={form.prepareScript}
baselineValue=""
@@ -482,8 +482,8 @@ function CreateProfileSections({
/>
{form.isRemote && (
<ScriptCard
title="Cleanup Script"
description="Runs after the agent session ends for cleanup tasks."
title={t("executors:cleanupScript")}
description={t("executors:runsAfterTheAgentSessionEnds")}
value={form.cleanupScript}
baselineValue=""
onChange={form.setCleanupScript}
@@ -495,26 +495,28 @@ function CreateProfileSections({
<McpPolicyCard
mcpPolicy={form.mcpPolicy}
baselinePolicy=""
mcpPolicyError={form.mcpPolicyError}
mcpPolicyErrorKey={form.mcpPolicyErrorKey}
onPolicyChange={form.setMcpPolicy}
/>
</>
);
}
function getCreateDisabledReason(
// Returns a catalog key (or null when the form is submittable) so the reason
// resolves at render rather than at module load.
function getCreateDisabledReasonKey(
form: ReturnType<typeof useCreateProfileFormState>,
spritesTokenMissing: boolean,
saving: boolean,
) {
if (saving) return "Creating profile...";
if (!form.name.trim()) return "Enter a profile name.";
if (form.mcpPolicyError) return form.mcpPolicyError;
if (spritesTokenMissing) return "Add a Sprites API key before creating the profile.";
if (saving) return "executors:creatingProfile";
if (!form.name.trim()) return "executors:enterAProfileName";
if (form.mcpPolicyErrorKey) return form.mcpPolicyErrorKey;
if (spritesTokenMissing) return "executors:addASpritesApiKeyBeforeCreating";
if (form.isDocker) {
if (!form.imageTag.trim()) return "Enter an image tag before creating the profile.";
if (!form.dockerfile.trim()) return "Add Dockerfile content before creating the profile.";
if (!form.dockerImageBuilt) return "Build this Docker image before creating the profile.";
if (!form.imageTag.trim()) return "executors:enterAnImageTagBeforeCreating";
if (!form.dockerfile.trim()) return "executors:addDockerfileContentBeforeCreating";
if (!form.dockerImageBuilt) return "executors:buildThisDockerImageBeforeCreating";
}
return null;
}
@@ -548,37 +550,34 @@ function CreateProfileForm({
typeInfo,
}: {
executorType: ExecutorType;
typeInfo: { executorId: string; label: string; description: string };
typeInfo: ExecutorTypeInfo;
}) {
const { t } = useTranslation();
const { items: secrets } = useSecrets();
const form = useCreateProfileFormState(executorType);
const { saving, error, handleSave } = useCreateProfileSave(typeInfo.executorId);
const spritesTokenMissing = form.isSprites && !form.spritesSecretId;
const disabledReason = getCreateDisabledReason(form, spritesTokenMissing, saving);
const disabledReasonKey = getCreateDisabledReasonKey(form, spritesTokenMissing, saving);
const savePayload = buildCreateProfilePayload(form);
const saveRevision = serializeSettingsRevision(savePayload);
useSettingsSaveContributor({
id: `executor-profile:new:${typeInfo.executorId}`,
revision: saveRevision,
isDirty: true,
canSave: !disabledReason,
invalidReason: disabledReason ?? undefined,
canSave: !disabledReasonKey,
invalidReason: disabledReasonKey ? t(disabledReasonKey) : undefined,
save: () => handleSave(savePayload),
discard: () => undefined,
});
return (
<div className="space-y-8">
<CreateProfileHeader
type={executorType}
label={typeInfo.label}
description={typeInfo.description}
/>
<CreateProfileHeader type={executorType} typeInfo={typeInfo} />
<fieldset disabled={saving} className="space-y-8">
<CreateProfileSections executorType={executorType} form={form} secrets={secrets} />
</fieldset>
{spritesTokenMissing && (
<p className="text-sm text-destructive">Sprites API key is required.</p>
<p className="text-sm text-destructive">{t("executors:spritesApiKeyIsRequired")}</p>
)}
{error && <p className="text-sm text-destructive">{error}</p>}
</div>
@@ -1,6 +1,7 @@
"use client";
import { useCallback } from "react";
import { useTranslation } from "react-i18next";
import { useRouter } from "@/lib/routing/client-router";
import { Badge } from "@kandev/ui/badge";
import { Button } from "@kandev/ui/button";
@@ -80,6 +81,7 @@ export function SSHCreatePage() {
}
function SSHCreateHeader() {
const { t } = useTranslation();
const router = useRouter();
return (
<>
@@ -87,13 +89,13 @@ function SSHCreateHeader() {
<div>
<div className="flex items-center gap-2">
<IconTerminal2 className="h-5 w-5 text-muted-foreground" />
<h2 className="text-2xl font-bold">New SSH Executor</h2>
<h2 className="text-2xl font-bold">{t("executors:newSshExecutor")}</h2>
<Badge variant="outline" className="text-xs">
{getExecutorLabel("ssh")}
</Badge>
</div>
<p className="mt-1 text-sm text-muted-foreground">
Connect to a remote Linux amd64 or macOS host and run agentctl there.
{t("executors:sshCreateDescription")}
</p>
</div>
<Button
@@ -102,7 +104,7 @@ function SSHCreateHeader() {
onClick={() => router.push(EXECUTORS_ROUTE)}
className="cursor-pointer"
>
Back to Executors
{t("executors:backToExecutors")}
</Button>
</div>
<Separator />
+48 -27
View File
@@ -1,6 +1,7 @@
"use client";
import { useState, useMemo } from "react";
import { useTranslation } from "react-i18next";
import { useRouter } from "@/lib/routing/client-router";
import { IconPlus, IconTrash } from "@tabler/icons-react";
import { Badge } from "@kandev/ui/badge";
@@ -44,25 +45,42 @@ function useAllProfiles(): ProfileWithExecutor[] {
const DefaultIcon = EXECUTOR_ICON_MAP.local;
const EXECUTOR_TYPES = [
{ type: "local", label: "Local", description: "Run agents directly in the repository folder." },
// `type` is the persisted executor enum and is also the create route's path
// segment — never copy. `brandLabel` is a brand/protocol name rendered
// verbatim; every other label and description travels as a catalog key and
// resolves at render. These sentences are imperative and deliberately differ
// from the third-person ones the create and edit headers show; they are not
// twins of `executors:description*` and must not be folded into them.
type ExecutorTypeCard = {
type: string;
labelKey?: string;
brandLabel?: string;
descriptionKey: string;
};
const EXECUTOR_TYPES: readonly ExecutorTypeCard[] = [
{
type: "local",
labelKey: "executors:typeLocal",
descriptionKey: "executors:hubDescriptionLocal",
},
{
type: "worktree",
label: "Worktree",
description: "Create git worktrees for isolated agent sessions.",
labelKey: "executors:typeWorktree",
descriptionKey: "executors:hubDescriptionWorktree",
},
{
type: "local_docker",
brandLabel: "Docker",
descriptionKey: "executors:hubDescriptionDocker",
},
{ type: "local_docker", label: "Docker", description: "Run Docker containers on this machine." },
{
type: "sprites",
label: "Sprites.dev",
description: "Run agents in Sprites.dev cloud sandboxes.",
brandLabel: "Sprites.dev",
descriptionKey: "executors:hubDescriptionSprites",
},
{
type: "ssh",
label: "SSH",
description: "Connect to a remote host over SSH and run agentctl there.",
},
] as const;
{ type: "ssh", brandLabel: "SSH", descriptionKey: "executors:hubDescriptionSsh" },
];
function ExecutorIconBadge({ type }: { type: string }) {
const Icon = EXECUTOR_ICON_MAP[type] ?? DefaultIcon;
@@ -80,6 +98,9 @@ function ProfileCard({
profile: ProfileWithExecutor;
onDelete: (id: string) => void;
}) {
// Subscribes so the `getExecutorLabel` badge below re-renders on a locale
// switch; the helper resolves at call time but does not itself notify React.
useTranslation();
const router = useRouter();
return (
<Card
@@ -114,9 +135,10 @@ function CreateTypeCard({
execType,
onClick,
}: {
execType: (typeof EXECUTOR_TYPES)[number];
execType: ExecutorTypeCard;
onClick: () => void;
}) {
const { t } = useTranslation();
return (
<Card
className="cursor-pointer ring-primary/40 transition-colors hover:bg-muted/50"
@@ -125,8 +147,8 @@ function CreateTypeCard({
<CardContent className="flex items-center gap-3 p-4">
<ExecutorIconBadge type={execType.type} />
<div className="min-w-0 flex-1">
<p className="font-medium">{execType.label}</p>
<p className="text-xs text-muted-foreground">{execType.description}</p>
<p className="font-medium">{execType.brandLabel ?? t(execType.labelKey as string)}</p>
<p className="text-xs text-muted-foreground">{t(execType.descriptionKey)}</p>
</div>
<IconPlus className="h-4 w-4 shrink-0 text-muted-foreground" />
</CardContent>
@@ -147,18 +169,19 @@ function DeleteProfileDialog({
onDelete: () => void;
deleting: boolean;
}) {
const { t } = useTranslation();
return (
<Dialog open={open} onOpenChange={onOpenChange}>
<DialogContent>
<DialogHeader>
<DialogTitle>Delete Profile</DialogTitle>
<DialogTitle>{t("executors:deleteProfile")}</DialogTitle>
<DialogDescription>
Are you sure you want to delete &quot;{profileName}&quot;? This action cannot be undone.
{t("executors:deleteProfileConfirm", { name: profileName ?? "" })}
</DialogDescription>
</DialogHeader>
<DialogFooter>
<Button variant="outline" onClick={() => onOpenChange(false)} className="cursor-pointer">
Cancel
{t("common:cancel")}
</Button>
<Button
variant="destructive"
@@ -166,7 +189,7 @@ function DeleteProfileDialog({
disabled={deleting}
className="cursor-pointer"
>
{deleting ? "Deleting..." : "Delete"}
{deleting ? t("executors:deleting") : t("executors:delete")}
</Button>
</DialogFooter>
</DialogContent>
@@ -175,6 +198,7 @@ function DeleteProfileDialog({
}
export default function ExecutorsHubPage() {
const { t } = useTranslation();
const router = useRouter();
const allProfiles = useAllProfiles();
const executors = useAppStore((state) => state.executors.items);
@@ -207,15 +231,12 @@ export default function ExecutorsHubPage() {
return (
<div className="space-y-8">
<div>
<h2 className="text-2xl font-bold">Executors</h2>
<p className="mt-1 text-sm text-muted-foreground">
Executor profiles define how and where agents run. Each profile configures an execution
environment with scripts, environment variables, and MCP policies.
</p>
<h2 className="text-2xl font-bold">{t("common:executors")}</h2>
<p className="mt-1 text-sm text-muted-foreground">{t("executors:hubDescription")}</p>
</div>
<Separator />
<div className="space-y-4">
<h3 className="text-lg font-semibold">Create New Profile</h3>
<h3 className="text-lg font-semibold">{t("executors:createNewProfile")}</h3>
<div className="grid gap-3 sm:grid-cols-2 lg:grid-cols-3 xl:grid-cols-4">
{EXECUTOR_TYPES.map((execType) => (
<CreateTypeCard
@@ -228,7 +249,7 @@ export default function ExecutorsHubPage() {
</div>
{allProfiles.length > 0 && (
<div className="space-y-4">
<h3 className="text-lg font-semibold">Profiles</h3>
<h3 className="text-lg font-semibold">{t("executors:profiles")}</h3>
<div className="grid gap-3 sm:grid-cols-2 lg:grid-cols-3 xl:grid-cols-4">
{allProfiles.map((profile) => (
<ProfileCard key={profile.id} profile={profile} onDelete={setDeleteProfileId} />
@@ -1,6 +1,7 @@
"use client";
import { useCallback, useEffect, useRef, useState } from "react";
import { useTranslation } from "react-i18next";
import { useRouter } from "@/lib/routing/client-router";
import { Badge } from "@kandev/ui/badge";
import { Button } from "@kandev/ui/button";
@@ -34,27 +35,31 @@ export default function SSHExecutorPage({ executorId }: { executorId: string })
}
function SSHExecutorPageContent({ executorId }: { executorId: string }) {
const { t } = useTranslation();
const { executor, loading, error, reload } = useExecutor(executorId);
if (loading) {
return (
<Card>
<CardContent className="py-12 text-center text-sm text-muted-foreground">
Loading executor...
{t("executors:loadingExecutor")}
</CardContent>
</Card>
);
}
if (error || !executor) {
return <NotFoundCard message={error ?? "Executor not found"} />;
return <NotFoundCard message={error ?? t("executors:executorNotFound")} />;
}
if (executor.type !== "ssh") {
return <NotFoundCard message={`Executor ${executor.id} is not an SSH executor`} />;
// The executor id is an identifier the user may need to look up, so it is
// interpolated as a value rather than written into the message.
return <NotFoundCard message={t("executors:notAnSshExecutor", { id: executor.id })} />;
}
return <SSHExecutorView executor={executor} onSaved={reload} />;
}
function useExecutor(executorId: string) {
const { t } = useTranslation();
const [executor, setExecutor] = useState<LoadedExecutor | null>(null);
const [loading, setLoading] = useState(true);
const [error, setError] = useState<string | null>(null);
@@ -72,14 +77,14 @@ function useExecutor(executorId: string) {
}
} catch (e) {
if (generation === requestGeneration.current) {
setError(e instanceof Error ? e.message : "Failed to load executor");
setError(e instanceof Error ? e.message : t("executors:failedToLoadExecutor"));
}
} finally {
if (generation === requestGeneration.current) {
setLoading(false);
}
}
}, [executorId]);
}, [executorId, t]);
useEffect(() => {
void load();
@@ -92,13 +97,14 @@ function useExecutor(executorId: string) {
}
function NotFoundCard({ message }: { message: string }) {
const { t } = useTranslation();
const router = useRouter();
return (
<Card>
<CardContent className="py-12 text-center">
<p className="text-muted-foreground">{message}</p>
<Button className="mt-4 cursor-pointer" onClick={() => router.push(EXECUTORS_ROUTE)}>
Back to Executors
{t("executors:backToExecutors")}
</Button>
</CardContent>
</Card>
@@ -134,6 +140,7 @@ function SSHExecutorView({
}
function SSHExecutorHeader({ executorName }: { executorName: string }) {
const { t } = useTranslation();
const router = useRouter();
return (
<>
@@ -147,8 +154,7 @@ function SSHExecutorHeader({ executorName }: { executorName: string }) {
</Badge>
</div>
<p className="mt-1 text-sm text-muted-foreground">
Edit the connection settings or re-trust the host. Existing sessions keep their snapshot
of the previous config.
{t("executors:sshExecutorPageDescription")}
</p>
</div>
<Button
@@ -157,7 +163,7 @@ function SSHExecutorHeader({ executorName }: { executorName: string }) {
onClick={() => router.push(EXECUTORS_ROUTE)}
className="cursor-pointer"
>
Back to Executors
{t("executors:backToExecutors")}
</Button>
</div>
<Separator />
@@ -1,5 +1,6 @@
"use client";
import { useTranslation } from "react-i18next";
import { DestinationRows } from "@/components/navigation/destination-rows";
import { resolveDestinations } from "@/lib/navigation/resolve-destinations";
import { NO_WORKSPACE_CONTEXT } from "@/lib/navigation/surface-policy";
@@ -19,6 +20,7 @@ type MobilePluginNavSectionProps = {
* `MobileIntegrationsSection`.
*/
export function MobilePluginNavSection({ onNavigate }: MobilePluginNavSectionProps) {
const { t } = useTranslation();
const registry = usePluginRegistry();
// Resolved directly rather than through `useStaticDestinations`: this group's
// hrefs are static plugin paths, so it needs neither workspace context nor the
@@ -34,7 +36,7 @@ export function MobilePluginNavSection({ onNavigate }: MobilePluginNavSectionPro
return (
<div className="space-y-3" data-testid="mobile-plugin-nav-section">
<div className="text-sm font-medium">Plugins</div>
<div className="text-sm font-medium">{t("common:plugins")}</div>
<DestinationRows
destinations={destinations}
onNavigate={onNavigate}
@@ -1,9 +1,14 @@
"use client";
import { Component, type ErrorInfo, type ReactNode } from "react";
import { useTranslation } from "react-i18next";
type PluginErrorBoundaryProps = {
/** Identifies what threw in the console log, e.g. `slot "task-sidebar"` or `route "/plugins/hello"`. */
/**
* Identifies what threw in the console log, e.g. `slot "task-sidebar"` or
* `route "/plugins/hello"`. A developer diagnostic that never reaches the
* DOM — `context` is in the guard's `jsx-attributes.exclude` for that reason.
*/
context: string;
/** Rendered in place of `children` after a throw. Defaults to nothing (silent). */
fallback?: ReactNode;
@@ -42,9 +47,10 @@ export class PluginErrorBoundary extends Component<
/** Shared fallback for a plugin-owned top-level or settings route that threw during render. */
export function PluginRouteFallback() {
const { t } = useTranslation();
return (
<div className="flex flex-1 items-center justify-center p-8 text-sm text-muted-foreground">
This plugin page failed to load.
{t("plugins:pluginPageFailedToLoad")}
</div>
);
}
@@ -42,6 +42,8 @@ function PluginModalInstance({ modal }: { modal: OpenPluginModal }) {
onEscapeKeyDown={guardClose}
onInteractOutside={guardClose}
>
{/* `options.title` is supplied by the plugin author, so it is
third-party data rather than host copy. */}
{options.title && (
<DialogHeader>
<DialogTitle>{options.title}</DialogTitle>
@@ -0,0 +1,34 @@
import { describe, expect, it } from "vitest";
import { t } from "@/lib/i18n";
/**
* Byte-for-byte English for the two Account route headers, as `SETTINGS_ROUTES`
* in `src/settings-routes.tsx` rendered them before this migration. They were
* the last two inline English entries in that table.
*
* Same reasoning as `components/settings/system/system-route-copy.test.ts`:
* every gate in the repo verifies that a key *resolves*, not that it resolves
* to the sentence the route used to render, so a plausible-looking key reuse
* can rewrite shipping copy with nothing failing. This table is the check.
*/
const ROUTE_COPY: Array<{ route: string; titleKey: string; title: string; description: string }> = [
{
route: "security",
titleKey: "account:securityPageTitle",
title: "Profile & password",
description: "Change your password and review devices signed in to your account.",
},
{
route: "tokens",
titleKey: "account:tokensPageTitle",
title: "API tokens",
description: "Personal access tokens for scripts and CLIs acting as you.",
},
];
describe("account route copy", () => {
it.each(ROUTE_COPY)("renders /settings/account/$route unchanged", (entry) => {
expect(t(entry.titleKey)).toBe(entry.title);
expect(t(`account:${entry.route}PageDescription`)).toBe(entry.description);
});
});
@@ -1,6 +1,7 @@
"use client";
import { useCallback, useEffect, useState } from "react";
import { useTranslation } from "react-i18next";
import { Button } from "@kandev/ui/button";
import { Card, CardContent, CardHeader, CardTitle } from "@kandev/ui/card";
import {
@@ -18,8 +19,10 @@ import { IconCheck, IconCopy, IconKey } from "@tabler/icons-react";
import { ApiError } from "@/lib/api/client";
import { listTokens, mintToken, revokeToken, type ApiToken } from "@/lib/api/domains/auth-api";
import { copyToClipboard } from "@/lib/utils/copy-to-clipboard";
import { formatDateTime } from "@/lib/i18n/formats";
function useTokensList() {
const { t } = useTranslation();
const [tokens, setTokens] = useState<ApiToken[]>([]);
const [loaded, setLoaded] = useState(false);
const [error, setError] = useState<string | null>(null);
@@ -31,9 +34,9 @@ function useTokensList() {
setTokens(res.tokens);
setLoaded(true);
} catch (err) {
setError(err instanceof ApiError ? err.message : "Failed to load tokens.");
setError(err instanceof ApiError ? err.message : t("account:failedToLoadTokens"));
}
}, []);
}, [t]);
useEffect(() => {
void reload();
@@ -53,13 +56,12 @@ function MintTokenResult({
onCopy: () => void;
onDone: () => void;
}) {
const { t } = useTranslation();
return (
<>
<DialogHeader>
<DialogTitle>Token created</DialogTitle>
<DialogDescription>
This is shown only once — copy it now and store it securely.
</DialogDescription>
<DialogTitle>{t("account:tokenCreated")}</DialogTitle>
<DialogDescription>{t("account:tokenShownOnlyOnce")}</DialogDescription>
</DialogHeader>
<div className="flex items-center gap-2">
<Input
@@ -80,7 +82,7 @@ function MintTokenResult({
</div>
<DialogFooter>
<Button className="cursor-pointer" onClick={onDone} data-dialog-default-action>
Done
{t("account:done")}
</Button>
</DialogFooter>
</>
@@ -102,18 +104,16 @@ function MintTokenForm({
onCancel: () => void;
onSubmit: () => void;
}) {
const { t } = useTranslation();
return (
<>
<DialogHeader>
<DialogTitle>Create API token</DialogTitle>
<DialogDescription>
Grants the same access as your account to any script or CLI that uses it. Give it a name
so you can recognize it later.
</DialogDescription>
<DialogTitle>{t("account:createApiToken")}</DialogTitle>
<DialogDescription>{t("account:createApiTokenDescription")}</DialogDescription>
</DialogHeader>
<div className="flex flex-col gap-1">
<label htmlFor="api-tokens-name" className="text-xs text-muted-foreground">
Name
{t("account:name")}
</label>
<Input
id="api-tokens-name"
@@ -129,7 +129,7 @@ function MintTokenForm({
)}
<DialogFooter>
<Button variant="outline" className="cursor-pointer" onClick={onCancel}>
Cancel
{t("account:cancel")}
</Button>
<Button
className="cursor-pointer"
@@ -137,7 +137,7 @@ function MintTokenForm({
onClick={onSubmit}
data-testid="api-tokens-mint-submit"
>
{submitting ? "Creating..." : "Create token"}
{submitting ? t("account:creating") : t("account:createToken")}
</Button>
</DialogFooter>
</>
@@ -153,6 +153,7 @@ function MintTokenDialog({
onOpenChange: (open: boolean) => void;
onCreated: () => void;
}) {
const { t } = useTranslation();
const [name, setName] = useState("");
const [error, setError] = useState<string | null>(null);
const [submitting, setSubmitting] = useState(false);
@@ -174,7 +175,7 @@ function MintTokenDialog({
setRawToken(res.token);
onCreated();
} catch (err) {
setError(err instanceof ApiError ? err.message : "Could not create token.");
setError(err instanceof ApiError ? err.message : t("account:couldNotCreateToken"));
} finally {
setSubmitting(false);
}
@@ -216,6 +217,7 @@ function MintTokenDialog({
}
export function ApiTokens() {
const { t } = useTranslation();
const { tokens, loaded, error, reload } = useTokensList();
const [mintOpen, setMintOpen] = useState(false);
@@ -228,7 +230,7 @@ export function ApiTokens() {
<Card data-testid="api-tokens-card">
<CardHeader className="flex flex-row items-center justify-between gap-2 space-y-0">
<CardTitle className="text-base flex items-center gap-2">
<IconKey className="h-4 w-4" /> API tokens
<IconKey className="h-4 w-4" /> {t("account:apiTokens")}
</CardTitle>
<Button
size="sm"
@@ -236,14 +238,11 @@ export function ApiTokens() {
onClick={() => setMintOpen(true)}
data-testid="api-tokens-create"
>
New token
{t("account:newToken")}
</Button>
</CardHeader>
<CardContent className="space-y-3">
<p className="text-xs text-muted-foreground">
Personal access tokens authenticate scripts and CLIs as you. Revoke a token immediately if
it may have leaked.
</p>
<p className="text-xs text-muted-foreground">{t("account:apiTokensBlurb")}</p>
{error && (
<p className="text-xs text-destructive" data-testid="api-tokens-error">
{error}
@@ -251,28 +250,26 @@ export function ApiTokens() {
)}
{!loaded && !error && (
<div className="flex items-center gap-2 text-sm text-muted-foreground">
<Spinner className="size-4" /> Loading tokens...
<Spinner className="size-4" /> {t("account:loadingTokens")}
</div>
)}
{loaded && tokens.length > 0 && (
<Table data-testid="api-tokens-table">
<TableHeader>
<TableRow>
<TableHead>Name</TableHead>
<TableHead>Created</TableHead>
<TableHead>Last used</TableHead>
<TableHead className="text-right">Actions</TableHead>
<TableHead>{t("account:name")}</TableHead>
<TableHead>{t("account:created")}</TableHead>
<TableHead>{t("account:lastUsed")}</TableHead>
<TableHead className="text-right">{t("account:actions")}</TableHead>
</TableRow>
</TableHeader>
<TableBody>
{tokens.map((token) => (
<TableRow key={token.id} data-testid="api-tokens-row">
<TableCell className="text-xs">{token.name}</TableCell>
<TableCell className="text-xs">{formatDateTime(token.created_at)}</TableCell>
<TableCell className="text-xs">
{new Date(token.created_at).toLocaleString()}
</TableCell>
<TableCell className="text-xs">
{token.last_used_at ? new Date(token.last_used_at).toLocaleString() : "Never"}
{token.last_used_at ? formatDateTime(token.last_used_at) : t("account:never")}
</TableCell>
<TableCell className="text-right">
<Button
@@ -282,7 +279,7 @@ export function ApiTokens() {
onClick={() => void onRevoke(token.id)}
data-testid="api-tokens-revoke"
>
Revoke
{t("account:revoke")}
</Button>
</TableCell>
</TableRow>
@@ -292,7 +289,7 @@ export function ApiTokens() {
)}
{loaded && tokens.length === 0 && !error && (
<p className="text-sm text-muted-foreground" data-testid="api-tokens-empty">
No tokens yet.
{t("account:noTokensYet")}
</p>
)}
</CardContent>
@@ -1,6 +1,7 @@
"use client";
import { useCallback, useEffect, useState } from "react";
import { useTranslation } from "react-i18next";
import { Button } from "@kandev/ui/button";
import { Card, CardContent, CardHeader, CardTitle } from "@kandev/ui/card";
import { Input } from "@kandev/ui/input";
@@ -15,8 +16,10 @@ import {
revokeSession,
type AuthSession,
} from "@/lib/api/domains/auth-api";
import { formatDateTime } from "@/lib/i18n/formats";
function ChangePasswordCard() {
const { t } = useTranslation();
const [current, setCurrent] = useState("");
const [next, setNext] = useState("");
const [error, setError] = useState<string | null>(null);
@@ -34,7 +37,7 @@ function ChangePasswordCard() {
setNext("");
setSuccess(true);
} catch (err) {
setError(err instanceof ApiError ? err.message : "Could not change password.");
setError(err instanceof ApiError ? err.message : t("account:couldNotChangePassword"));
} finally {
setSubmitting(false);
}
@@ -44,14 +47,14 @@ function ChangePasswordCard() {
<Card data-testid="account-security-password-card">
<CardHeader>
<CardTitle className="text-base flex items-center gap-2">
<IconKey className="h-4 w-4" /> Password
<IconKey className="h-4 w-4" /> {t("account:password")}
</CardTitle>
</CardHeader>
<CardContent>
<form className="flex flex-col gap-3 max-w-sm" onSubmit={(e) => void onSubmit(e)}>
<div className="flex flex-col gap-1">
<label htmlFor="account-current-password" className="text-xs text-muted-foreground">
Current password
{t("account:currentPassword")}
</label>
<Input
id="account-current-password"
@@ -63,7 +66,7 @@ function ChangePasswordCard() {
</div>
<div className="flex flex-col gap-1">
<label htmlFor="account-new-password" className="text-xs text-muted-foreground">
New password
{t("account:newPassword")}
</label>
<Input
id="account-new-password"
@@ -81,7 +84,7 @@ function ChangePasswordCard() {
)}
{success && (
<p className="text-xs text-muted-foreground" data-testid="account-password-success">
Password updated.
{t("account:passwordUpdated")}
</p>
)}
<Button
@@ -90,7 +93,7 @@ function ChangePasswordCard() {
disabled={submitting}
data-testid="account-password-submit"
>
{submitting ? "Saving..." : "Change password"}
{submitting ? t("account:saving") : t("account:changePassword")}
</Button>
</form>
</CardContent>
@@ -99,6 +102,7 @@ function ChangePasswordCard() {
}
function useSessionsList() {
const { t } = useTranslation();
const [sessions, setSessions] = useState<AuthSession[]>([]);
const [loaded, setLoaded] = useState(false);
const [error, setError] = useState<string | null>(null);
@@ -110,9 +114,9 @@ function useSessionsList() {
setSessions(res.sessions);
setLoaded(true);
} catch (err) {
setError(err instanceof ApiError ? err.message : "Failed to load sessions.");
setError(err instanceof ApiError ? err.message : t("account:failedToLoadSessions"));
}
}, []);
}, [t]);
useEffect(() => {
void reload();
@@ -122,6 +126,7 @@ function useSessionsList() {
}
function SessionsCard() {
const { t } = useTranslation();
const { sessions, loaded, error, reload } = useSessionsList();
const onRevoke = async (id: string) => {
@@ -133,7 +138,7 @@ function SessionsCard() {
<Card data-testid="account-sessions-card">
<CardHeader>
<CardTitle className="text-base flex items-center gap-2">
<IconDevices className="h-4 w-4" /> Active sessions
<IconDevices className="h-4 w-4" /> {t("account:activeSessions")}
</CardTitle>
</CardHeader>
<CardContent className="space-y-3">
@@ -144,17 +149,17 @@ function SessionsCard() {
)}
{!loaded && !error && (
<div className="flex items-center gap-2 text-sm text-muted-foreground">
<Spinner className="size-4" /> Loading sessions...
<Spinner className="size-4" /> {t("account:loadingSessions")}
</div>
)}
{loaded && sessions.length > 0 && (
<Table data-testid="account-sessions-table">
<TableHeader>
<TableRow>
<TableHead>Device</TableHead>
<TableHead>IP</TableHead>
<TableHead>Last seen</TableHead>
<TableHead className="text-right">Actions</TableHead>
<TableHead>{t("account:device")}</TableHead>
<TableHead>{t("account:ipAddress")}</TableHead>
<TableHead>{t("account:lastSeen")}</TableHead>
<TableHead className="text-right">{t("account:actions")}</TableHead>
</TableRow>
</TableHeader>
<TableBody>
@@ -164,14 +169,12 @@ function SessionsCard() {
{session.user_agent}
{session.current && (
<Badge variant="default" className="ml-2 text-[10px]">
This device
{t("account:thisDevice")}
</Badge>
)}
</TableCell>
<TableCell className="text-xs">{session.ip}</TableCell>
<TableCell className="text-xs">
{new Date(session.last_seen_at).toLocaleString()}
</TableCell>
<TableCell className="text-xs">{formatDateTime(session.last_seen_at)}</TableCell>
<TableCell className="text-right">
{!session.current && (
<Button
@@ -181,7 +184,7 @@ function SessionsCard() {
onClick={() => void onRevoke(session.id)}
data-testid="account-sessions-revoke"
>
Sign out
{t("account:signOut")}
</Button>
)}
</TableCell>
@@ -0,0 +1,212 @@
import { afterAll, describe, expect, it } from "vitest";
import { activateLocale, i18n, t } from "@/lib/i18n";
import { getExecutorLabel } from "@/lib/executor-icons";
import {
EXECUTOR_TYPE_MAP,
executorTypeLabel,
} from "@/app/settings/executors/new/[type]/executor-types";
import { validateMcpPolicy } from "@/components/settings/profile-edit/mcp-policy-card";
import { getExecutorDescription } from "./executor-description";
/**
* Byte-for-byte English for the executor type labels and descriptions, as they
* rendered before this migration.
*
* This exists because the copy is duplicated across THREE surfaces that had
* already drifted from each other:
*
* - `/settings/executors` (the hub cards) uses the imperative mood
* ("Run agents directly in the repository folder.");
* - `/settings/executors/new/:type` (the create header) uses the third
* person ("Runs agents directly in the repository folder.");
* - `/settings/executors/:profileId` (the edit header) uses the third person
* too, and shares those five sentences — but its SSH sentence is a
* different one again.
*
* Folding them onto one key set would have rewritten user-visible English on
* two of the three, and every gate would still have passed. The identical
* sentences share a key; the ones that differ keep their own. This table is
* what says so after the reviewer leaves.
*
* De-duplicating the three tables is a behaviour change and belongs in its own
* PR — see the guard comment in eslint.i18n.options.mjs.
*/
const SPRITES_DEV = "Sprites.dev";
const SSH = "SSH";
const HUB_CARD_COPY: Array<{ type: string; label: string; description: string }> = [
{ type: "local", label: "Local", description: "Run agents directly in the repository folder." },
{
type: "worktree",
label: "Worktree",
description: "Create git worktrees for isolated agent sessions.",
},
{
type: "local_docker",
label: "Docker",
description: "Run Docker containers on this machine.",
},
{
type: "sprites",
label: SPRITES_DEV,
description: "Run agents in Sprites.dev cloud sandboxes.",
},
{
type: "ssh",
label: SSH,
description: "Connect to a remote host over SSH and run agentctl there.",
},
];
const HUB_CARD_KEYS: Record<string, { labelKey?: string; brand?: string; descriptionKey: string }> =
{
local: { labelKey: "executors:typeLocal", descriptionKey: "executors:hubDescriptionLocal" },
worktree: {
labelKey: "executors:typeWorktree",
descriptionKey: "executors:hubDescriptionWorktree",
},
local_docker: { brand: "Docker", descriptionKey: "executors:hubDescriptionDocker" },
sprites: { brand: SPRITES_DEV, descriptionKey: "executors:hubDescriptionSprites" },
ssh: { brand: SSH, descriptionKey: "executors:hubDescriptionSsh" },
};
describe("executors hub card copy", () => {
it.each(HUB_CARD_COPY)("renders $type unchanged", ({ type, label, description }) => {
const entry = HUB_CARD_KEYS[type];
expect(entry.brand ?? t(entry.labelKey as string)).toBe(label);
expect(t(entry.descriptionKey)).toBe(description);
});
});
describe("create-page executor type registry", () => {
const CREATE_COPY: Array<{ type: string; label: string; description: string }> = [
{
type: "local",
label: "Local",
description: "Runs agents directly in the repository folder.",
},
{
type: "worktree",
label: "Worktree",
description: "Creates git worktrees for isolated agent sessions.",
},
{
type: "local_docker",
label: "Docker",
description: "Runs Docker containers on this machine.",
},
{
type: "remote_docker",
label: "Remote Docker",
description: "Connects to a remote Docker host.",
},
{
type: "sprites",
label: SPRITES_DEV,
description: "Runs agents in Sprites.dev cloud sandboxes.",
},
{
type: "ssh",
label: SSH,
description: "Connects to a remote host over SSH and runs agentctl there.",
},
];
it.each(CREATE_COPY)("renders $type unchanged", ({ type, label, description }) => {
const info = EXECUTOR_TYPE_MAP[type];
expect(executorTypeLabel(info, t)).toBe(label);
expect(t(info.descriptionKey)).toBe(description);
});
it("keeps the persisted executor enum and backend ids out of the catalog", () => {
expect(Object.keys(EXECUTOR_TYPE_MAP).sort()).toEqual([
"local",
"local_docker",
"remote_docker",
"sprites",
"ssh",
"worktree",
]);
expect(EXECUTOR_TYPE_MAP.ssh.executorId).toBe("exec-ssh");
expect(EXECUTOR_TYPE_MAP.local_docker.executorId).toBe("exec-local-docker");
});
});
describe("getExecutorDescription", () => {
it.each([
["local_pc", "Runs agents directly in the repository folder."],
["worktree", "Creates git worktrees for isolated agent sessions."],
["local_docker", "Runs Docker containers on this machine."],
["remote_docker", "Connects to a remote Docker host."],
["sprites", "Runs agents in Sprites.dev cloud sandboxes."],
["ssh", "Runs agents on a trusted Linux amd64 or macOS host over SSH."],
] as const)("renders %s unchanged", (type, expected) => {
expect(getExecutorDescription(type)).toBe(expected);
});
it("falls back to the custom-executor sentence", () => {
expect(getExecutorDescription("something-else" as never)).toBe("Custom executor.");
});
it("differs from the create page on SSH, which is why they do not share a key", () => {
expect(getExecutorDescription("ssh")).not.toBe(t(EXECUTOR_TYPE_MAP.ssh.descriptionKey));
});
});
describe("getExecutorLabel", () => {
it("returns brand and protocol names verbatim rather than from the catalog", () => {
expect(getExecutorLabel("sprites")).toBe(SPRITES_DEV);
expect(getExecutorLabel("ssh")).toBe(SSH);
});
it("renders the translated labels unchanged", () => {
expect(getExecutorLabel("local")).toBe("Local");
expect(getExecutorLabel("worktree")).toBe("Worktree");
expect(getExecutorLabel("local_docker")).toBe("Local Docker");
expect(getExecutorLabel("remote_docker")).toBe("Remote Docker");
});
it("echoes an unmapped wire value", () => {
expect(getExecutorLabel("does-not-exist")).toBe("does-not-exist");
});
// The label used to be a module-scope constant. It resolves from the catalog
// now, so this pins that it resolves at CALL time rather than at import — the
// property every consumer's re-render depends on.
it("follows a locale switch", async () => {
const before = getExecutorLabel("local");
await activateLocale("pseudo");
expect(getExecutorLabel("local")).not.toBe(before);
expect(getExecutorLabel("local")).toBe(t("executors:typeLocal"));
// Brand names are not catalog values, so they are unaffected.
expect(getExecutorLabel("ssh")).toBe(SSH);
await activateLocale("en");
expect(getExecutorLabel("local")).toBe(before);
});
afterAll(async () => {
if (i18n.language !== "en") await activateLocale("en");
});
});
describe("validateMcpPolicy", () => {
it("returns null for empty and valid policies", () => {
expect(validateMcpPolicy(undefined)).toBeNull();
expect(validateMcpPolicy(" ")).toBeNull();
expect(validateMcpPolicy('{"allow_stdio":true}')).toBeNull();
});
it("returns a catalog key rather than English for unparseable JSON", () => {
const key = validateMcpPolicy("{oops");
expect(key).toBe("executors:invalidJson");
expect(t(key as string)).toBe("Invalid JSON");
});
it("returns a catalog key rather than English for a non-object policy", () => {
for (const raw of ["[1,2]", '"nope"', "42"]) {
const key = validateMcpPolicy(raw);
expect(key).toBe("executors:mcpPolicyMustBeAJsonObject");
expect(t(key as string)).toBe("MCP policy must be a JSON object");
}
});
});
@@ -1,11 +1,20 @@
import { t } from "@/lib/i18n";
import type { ExecutorType } from "@/lib/types/http";
/**
* Resolves at call time rather than module load, so the description follows a
* locale switch. The five non-SSH sentences are byte-identical to the ones the
* `/settings/executor/:id` route already renders, so they share its keys;
* `descriptionSsh` is this surface's own sentence and deliberately differs from
* the one `app/settings/executors/new/[type]/executor-types.ts` shows on the
* create page — see the guard comment in eslint.i18n.options.mjs.
*/
export function getExecutorDescription(type: ExecutorType): string {
if (type === "local_pc") return "Runs agents directly in the repository folder.";
if (type === "worktree") return "Creates git worktrees for isolated agent sessions.";
if (type === "local_docker") return "Runs Docker containers on this machine.";
if (type === "remote_docker") return "Connects to a remote Docker host.";
if (type === "sprites") return "Runs agents in Sprites.dev cloud sandboxes.";
if (type === "ssh") return "Runs agents on a trusted Linux amd64 or macOS host over SSH.";
return "Custom executor.";
if (type === "local_pc") return t("executors:descriptionLocalPc");
if (type === "worktree") return t("executors:descriptionWorktree");
if (type === "local_docker") return t("executors:descriptionLocalDocker");
if (type === "remote_docker") return t("executors:descriptionRemoteDocker");
if (type === "sprites") return t("executors:descriptionSprites");
if (type === "ssh") return t("executors:descriptionSsh");
return t("executors:descriptionCustom");
}
@@ -1,6 +1,7 @@
"use client";
import { useEffect, useRef, useState } from "react";
import { useTranslation } from "react-i18next";
import { Button } from "@kandev/ui/button";
import {
Dialog,
@@ -40,6 +41,7 @@ export function InstallPluginDialog({
onSubmitUrl,
onSubmitFile,
}: InstallPluginDialogProps) {
const { t } = useTranslation();
const [tab, setTab] = useState<InstallTab>("url");
const [url, setUrl] = useState("");
const [file, setFile] = useState<File | null>(null);
@@ -79,9 +81,11 @@ export function InstallPluginDialog({
<Dialog open={open} onOpenChange={onOpenChange}>
<DialogContent className="sm:max-w-lg" data-testid="install-plugin-dialog">
<DialogHeader>
<DialogTitle>Install plugin</DialogTitle>
<DialogTitle>{t("plugins:installPlugin")}</DialogTitle>
<DialogDescription>
Install a plugin package from a URL, or upload a .tar.gz file directly.
{/* `.tar.gz` is a file extension, interpolated so the
pseudo-locale cannot transliterate it. */}
{t("plugins:installPluginDescription", { extension: ".tar.gz" })}
</DialogDescription>
</DialogHeader>
@@ -92,14 +96,14 @@ export function InstallPluginDialog({
className="cursor-pointer"
data-testid="install-plugin-tab-url"
>
From URL
{t("plugins:fromUrl")}
</TabsTrigger>
<TabsTrigger
value="upload"
className="cursor-pointer"
data-testid="install-plugin-tab-upload"
>
Upload file
{t("plugins:uploadFile")}
</TabsTrigger>
</TabsList>
<TabsContent value="url" className="pt-3">
@@ -123,7 +127,7 @@ export function InstallPluginDialog({
onClick={() => onOpenChange(false)}
className="cursor-pointer"
>
Cancel
{t("plugins:cancel")}
</Button>
<Button
type="button"
@@ -132,7 +136,7 @@ export function InstallPluginDialog({
disabled={install.disabled}
className="cursor-pointer"
>
Install
{t("plugins:install")}
</Button>
</DialogFooter>
</DialogContent>
@@ -141,9 +145,10 @@ export function InstallPluginDialog({
}
function UrlTab({ url, setUrl }: { url: string; setUrl: (v: string) => void }) {
const { t } = useTranslation();
return (
<div className="space-y-1.5">
<Label htmlFor="install-plugin-url">Package URL</Label>
<Label htmlFor="install-plugin-url">{t("plugins:packageUrl")}</Label>
<Input
id="install-plugin-url"
data-testid="install-plugin-url-input"
@@ -157,6 +162,7 @@ function UrlTab({ url, setUrl }: { url: string; setUrl: (v: string) => void }) {
}
function UploadTab({ file, setFile }: { file: File | null; setFile: (file: File | null) => void }) {
const { t } = useTranslation();
const inputRef = useRef<HTMLInputElement>(null);
const [isDragging, setIsDragging] = useState(false);
@@ -202,7 +208,7 @@ function UploadTab({ file, setFile }: { file: File | null; setFile: (file: File
{file ? (
<span className="font-mono text-foreground">{file.name}</span>
) : (
"Drag and drop a .tar.gz package here, or click to browse"
t("plugins:dropzoneHint", { extension: ".tar.gz" })
)}
</span>
<input
@@ -1,13 +1,14 @@
"use client";
import { useMemo, useState } from "react";
import { Trans, useTranslation } from "react-i18next";
import { IconRefresh } from "@tabler/icons-react";
import { Button } from "@kandev/ui/button";
import { Input } from "@kandev/ui/input";
import { Select, SelectContent, SelectItem, SelectTrigger, SelectValue } from "@kandev/ui/select";
import { useMarketplace } from "@/hooks/domains/plugins/use-marketplace";
import type { CatalogQuery } from "@/lib/api/domains/marketplace-api";
import type { MarketplaceCatalog, MarketplaceEntry } from "@/lib/types/plugins";
import type { MarketplaceCatalog, MarketplaceEntry, MarketplaceSource } from "@/lib/types/plugins";
import { MarketplaceEntryRow } from "./marketplace-entry-row";
import { MarketplaceSourcesDialog } from "./marketplace-sources-dialog";
@@ -107,10 +108,11 @@ type ToolbarProps = {
};
function MarketplaceToolbar(props: ToolbarProps) {
const { t } = useTranslation();
return (
<div className="flex flex-wrap items-center gap-2">
<Input
placeholder="Search plugins…"
placeholder={t("plugins:searchPlugins")}
value={props.text}
onChange={(e) => props.onText(e.target.value)}
className="max-w-xs"
@@ -118,10 +120,10 @@ function MarketplaceToolbar(props: ToolbarProps) {
/>
<Select value={props.category} onValueChange={props.onCategory}>
<SelectTrigger className="w-40" data-testid="marketplace-category">
<SelectValue placeholder="Category" />
<SelectValue placeholder={t("plugins:category")} />
</SelectTrigger>
<SelectContent>
<SelectItem value={ALL_CATEGORIES}>All categories</SelectItem>
<SelectItem value={ALL_CATEGORIES}>{t("plugins:allCategories")}</SelectItem>
{props.categories.map((cat) => (
<SelectItem key={cat} value={cat}>
{cat}
@@ -134,15 +136,16 @@ function MarketplaceToolbar(props: ToolbarProps) {
<SelectValue />
</SelectTrigger>
<SelectContent>
<SelectItem value="stars">Most stars</SelectItem>
<SelectItem value="recent">Recently updated</SelectItem>
<SelectItem value="name">Name</SelectItem>
{/* The `value`s are the catalog API's sort tokens, not copy. */}
<SelectItem value="stars">{t("plugins:sortMostStars")}</SelectItem>
<SelectItem value="recent">{t("plugins:sortRecentlyUpdated")}</SelectItem>
<SelectItem value="name">{t("plugins:sortName")}</SelectItem>
</SelectContent>
</Select>
<div className="ml-auto flex items-center gap-2">
<Button variant="secondary" onClick={props.onRefresh} className="cursor-pointer">
<IconRefresh className="h-4 w-4" />
Refresh
{t("plugins:refresh")}
</Button>
<Button
variant="outline"
@@ -150,7 +153,7 @@ function MarketplaceToolbar(props: ToolbarProps) {
className="cursor-pointer"
data-testid="marketplace-manage-sources"
>
Sources
{t("plugins:sources")}
</Button>
</div>
</div>
@@ -166,15 +169,29 @@ function DegradedSourcesBanner({ catalog }: { catalog: MarketplaceCatalog }) {
className="rounded-lg border border-amber-500/40 bg-amber-500/10 p-3 text-sm text-amber-700 dark:text-amber-400 space-y-1"
>
{degraded.map((s) => (
<div key={s.id}>
<span className="font-medium">{s.name}</span> is unreachable
{s.error ? `: ${s.error}` : ""} — its plugins are hidden.
</div>
<DegradedSourceLine key={s.id} source={s} />
))}
</div>
);
}
/**
* The source name comes from its index.json and `detail` is the backend's own
* network diagnostic, so both are interpolated as values rather than written
* into the message.
*/
function DegradedSourceLine({ source }: { source: MarketplaceSource }) {
const detail = source.error ? `: ${source.error}` : "";
return (
<div>
<Trans i18nKey="plugins:sourceUnreachableDetail" values={{ name: source.name, detail }}>
<span className="font-medium">{source.name}</span> is unreachable
{detail} — its plugins are hidden.
</Trans>
</div>
);
}
type ListProps = {
entries: MarketplaceEntry[];
loading: boolean;
@@ -192,6 +209,7 @@ function MarketplaceList({
installingId,
onInstall,
}: ListProps) {
const { t } = useTranslation();
if (error) {
return (
<div className="rounded-lg border border-destructive/40 bg-destructive/5 p-6 text-sm text-destructive">
@@ -202,23 +220,21 @@ function MarketplaceList({
if (loading && entries.length === 0) {
return (
<div className="rounded-lg border border-dashed border-border/70 p-6 text-sm text-muted-foreground">
Loading marketplace…
{t("plugins:loadingMarketplace")}
</div>
);
}
if (entries.length === 0) {
return (
<div className="rounded-xl border border-dashed border-border/70 p-10 text-center text-sm text-muted-foreground">
{filtered
? "No plugins match your search. Try clearing the filters."
: "No plugins available yet. Check back soon, or add a marketplace source with your own."}
{filtered ? t("plugins:noPluginsMatchSearch") : t("plugins:noPluginsAvailable")}
</div>
);
}
return (
<div className="space-y-3">
<p className="text-xs text-muted-foreground">
{entries.length} {entries.length === 1 ? "plugin" : "plugins"} available
{t("plugins:pluginsAvailable", { count: entries.length })}
</p>
<div className="grid gap-3 md:grid-cols-2">
{entries.map((entry) => (
@@ -1,9 +1,11 @@
"use client";
import { useState } from "react";
import { Trans, useTranslation } from "react-i18next";
import { IconArrowUpCircle, IconCheck, IconStar } from "@tabler/icons-react";
import { Badge } from "@kandev/ui/badge";
import { Button } from "@kandev/ui/button";
import { formatNumber } from "@/lib/i18n/formats";
import type { MarketplaceEntry } from "@/lib/types/plugins";
import { PluginRepoLink } from "./plugin-repo-link";
@@ -48,9 +50,17 @@ export function MarketplaceEntryRow({ entry, busy, onInstall }: MarketplaceEntry
<div className="flex flex-wrap items-center gap-x-3 gap-y-1 text-xs text-muted-foreground">
<span className="inline-flex items-center gap-1">
<IconStar className="h-3.5 w-3.5" />
{entry.stars === null ? "—" : entry.stars.toLocaleString()}
{entry.stars === null ? "—" : formatNumber(entry.stars)}
</span>
{entry.author && <span>by {entry.author}</span>}
{/* The name, description, author and categories all come from the
catalog's index.json — third-party data, not our copy. */}
{entry.author && (
<span>
<Trans i18nKey="plugins:byAuthor" values={{ author: entry.author }}>
by {entry.author}
</Trans>
</span>
)}
{entry.categories.map((cat) => (
<Badge key={cat} variant="secondary" className="text-[10px] font-normal">
{cat}
@@ -92,6 +102,7 @@ function PluginTile({ entry }: { entry: MarketplaceEntry }) {
}
function MarketplaceEntryAction({ entry, busy, onInstall }: MarketplaceEntryRowProps) {
const { t } = useTranslation();
if (entry.install_state === "installed") {
return (
<Badge
@@ -100,7 +111,7 @@ function MarketplaceEntryAction({ entry, busy, onInstall }: MarketplaceEntryRowP
className="shrink-0 gap-1 text-muted-foreground"
>
<IconCheck className="h-3.5 w-3.5" />
Installed
{t("plugins:installed")}
</Badge>
);
}
@@ -115,7 +126,7 @@ function MarketplaceEntryAction({ entry, busy, onInstall }: MarketplaceEntryRowP
className="shrink-0 gap-1 cursor-pointer"
>
<IconArrowUpCircle className="h-4 w-4" />
{busy ? "Updating…" : "Update"}
{busy ? t("plugins:updating") : t("plugins:update")}
</Button>
);
}
@@ -128,7 +139,7 @@ function MarketplaceEntryAction({ entry, busy, onInstall }: MarketplaceEntryRowP
data-testid={`marketplace-install-${entry.id}`}
className="shrink-0 cursor-pointer"
>
{busy ? "Installing…" : "Install"}
{busy ? t("plugins:installing") : t("plugins:install")}
</Button>
);
}
@@ -1,6 +1,7 @@
"use client";
import { useState } from "react";
import { useTranslation } from "react-i18next";
import { IconTrash } from "@tabler/icons-react";
import { toast } from "@/lib/toast/sonner";
import { Button } from "@kandev/ui/button";
@@ -39,14 +40,15 @@ export function MarketplaceSourcesDialog({
onOpenChange,
onChanged,
}: MarketplaceSourcesDialogProps) {
const { t } = useTranslation();
return (
<Dialog open={open} onOpenChange={onOpenChange}>
<DialogContent>
<DialogHeader>
<DialogTitle>Marketplace sources</DialogTitle>
<DialogTitle>{t("plugins:marketplaceSources")}</DialogTitle>
<DialogDescription>
Add a team or corporate registry to browse its plugins alongside the official catalog.
Each source serves an index.json document.
{/* `index.json` is the document name a source must serve. */}
{t("plugins:marketplaceSourcesDescription", { document: "index.json" })}
</DialogDescription>
</DialogHeader>
@@ -63,6 +65,7 @@ export function MarketplaceSourcesDialog({
}
function SourceItem({ source, onChanged }: { source: MarketplaceSource; onChanged: () => void }) {
const { t } = useTranslation();
const [busy, setBusy] = useState(false);
const toggle = async (enabled: boolean) => {
@@ -71,7 +74,7 @@ function SourceItem({ source, onChanged }: { source: MarketplaceSource; onChange
await updateMarketplaceSource(source.id, { enabled });
onChanged();
} catch (err) {
toast.error(err instanceof Error ? err.message : "Failed to update source");
toast.error(err instanceof Error ? err.message : t("plugins:failedToUpdateSource"));
} finally {
setBusy(false);
}
@@ -83,7 +86,7 @@ function SourceItem({ source, onChanged }: { source: MarketplaceSource; onChange
await deleteMarketplaceSource(source.id);
onChanged();
} catch (err) {
toast.error(err instanceof Error ? err.message : "Failed to remove source");
toast.error(err instanceof Error ? err.message : t("plugins:failedToRemoveSource"));
} finally {
setBusy(false);
}
@@ -99,12 +102,12 @@ function SourceItem({ source, onChanged }: { source: MarketplaceSource; onChange
<span className="text-sm font-medium truncate">{source.name}</span>
{source.builtin && (
<Badge variant="outline" className="text-[10px]">
Official
{t("plugins:sourceOfficial")}
</Badge>
)}
{source.healthy === false && (
<Badge variant="destructive" className="text-[10px]">
Unreachable
{t("plugins:sourceUnreachable")}
</Badge>
)}
</div>
@@ -119,7 +122,7 @@ function SourceItem({ source, onChanged }: { source: MarketplaceSource; onChange
disabled={busy}
onClick={remove}
className="cursor-pointer"
aria-label={`Remove ${source.name}`}
aria-label={t("plugins:removeSource", { name: source.name })}
>
<IconTrash className="h-4 w-4" />
</Button>
@@ -130,6 +133,7 @@ function SourceItem({ source, onChanged }: { source: MarketplaceSource; onChange
}
function AddSourceForm({ onChanged }: { onChanged: () => void }) {
const { t } = useTranslation();
const [name, setName] = useState("");
const [url, setUrl] = useState("");
const [busy, setBusy] = useState(false);
@@ -143,7 +147,7 @@ function AddSourceForm({ onChanged }: { onChanged: () => void }) {
setUrl("");
onChanged();
} catch (err) {
toast.error(err instanceof Error ? err.message : "Failed to add source");
toast.error(err instanceof Error ? err.message : t("plugins:failedToAddSource"));
} finally {
setBusy(false);
}
@@ -152,7 +156,7 @@ function AddSourceForm({ onChanged }: { onChanged: () => void }) {
return (
<div className="space-y-2 border-t border-border/60 pt-3">
<Input
placeholder="Source name (e.g. Acme Internal)"
placeholder={t("plugins:sourceNamePlaceholder")}
value={name}
onChange={(e) => setName(e.target.value)}
data-testid="marketplace-add-source-name"
@@ -170,7 +174,7 @@ function AddSourceForm({ onChanged }: { onChanged: () => void }) {
className="cursor-pointer shrink-0"
data-testid="marketplace-add-source-submit"
>
Add
{t("plugins:add")}
</Button>
</div>
</div>
@@ -1,5 +1,6 @@
"use client";
import { useTranslation } from "react-i18next";
import { Input } from "@kandev/ui/input";
import { Label } from "@kandev/ui/label";
import { Select, SelectContent, SelectItem, SelectTrigger, SelectValue } from "@kandev/ui/select";
@@ -58,6 +59,8 @@ type ConfigFieldRowProps = {
};
function ConfigFieldRow({ field, value, isDirty, disabled, onChange }: ConfigFieldRowProps) {
// `field.label` and `field.description` come from the plugin's manifest —
// third-party data, not our copy.
const inputId = `plugin-config-${field.name}`;
return (
<div className="space-y-1.5" data-testid={`plugin-config-field-${field.name}`}>
@@ -88,6 +91,7 @@ function ConfigFieldControl({
disabled,
onChange,
}: ConfigFieldControlProps) {
const { t } = useTranslation();
if (field.type === "boolean") {
return (
<div>
@@ -117,7 +121,7 @@ function ConfigFieldControl({
className="max-w-md cursor-pointer"
data-settings-dirty={isDirty}
>
<SelectValue placeholder="Select..." />
<SelectValue placeholder={t("plugins:selectPlaceholder")} />
</SelectTrigger>
<SelectContent>
{!field.required && (
@@ -125,7 +129,7 @@ function ConfigFieldControl({
value={ENUM_UNSET_SENTINEL}
className="cursor-pointer text-muted-foreground"
>
Not set
{t("plugins:notSet")}
</SelectItem>
)}
{(field.enumValues ?? []).map((option) => (
@@ -176,11 +180,13 @@ function UtilityAgentSelect({
disabled,
onChange,
}: ConfigFieldControlProps) {
const { t } = useTranslation();
const { agents, loading, error } = useUtilityAgents();
const selectedID = typeof value === "string" ? value : "";
const selectedAgent = agents.find((agent) => agent.id === selectedID);
const selectedLabel = selectedAgent?.name ?? selectedUtilityAgentFallback(selectedID, loading);
const placeholder = utilityAgentPlaceholder(loading, error);
const selectedLabelKey = selectedUtilityAgentFallbackKey(selectedID, loading);
const selectedLabel = selectedAgent?.name ?? (selectedLabelKey ? t(selectedLabelKey) : undefined);
const placeholder = t(utilityAgentPlaceholderKey(loading, error));
return (
<Select
@@ -199,7 +205,7 @@ function UtilityAgentSelect({
value={UTILITY_AGENT_UNSET_SENTINEL}
className="cursor-pointer text-muted-foreground"
>
Not set
{t("plugins:notSet")}
</SelectItem>
)}
{agents.map((agent) => (
@@ -217,15 +223,20 @@ function UtilityAgentSelect({
);
}
function selectedUtilityAgentFallback(selectedID: string, loading: boolean): string | undefined {
// These two return catalog keys rather than messages: a plain function that
// returns copy is invisible to the guard, and resolving at the call site keeps
// the text following a locale switch.
function selectedUtilityAgentFallbackKey(selectedID: string, loading: boolean): string | undefined {
if (selectedID === "") return undefined;
return loading ? "Loading selected utility agent..." : "Selected utility agent unavailable";
return loading
? "plugins:loadingSelectedUtilityAgent"
: "plugins:selectedUtilityAgentUnavailable";
}
function utilityAgentPlaceholder(loading: boolean, error: Error | null): string {
if (loading) return "Loading utility agents...";
if (error) return "Utility agents unavailable";
return "Select a utility agent...";
function utilityAgentPlaceholderKey(loading: boolean, error: Error | null): string {
if (loading) return "plugins:loadingUtilityAgents";
if (error) return "plugins:utilityAgentsUnavailable";
return "plugins:selectAUtilityAgent";
}
function inputType(field: PluginConfigField): string {
@@ -1,6 +1,7 @@
"use client";
import { IconArrowLeft } from "@tabler/icons-react";
import { Trans, useTranslation } from "react-i18next";
import { Badge } from "@kandev/ui/badge";
import { Button } from "@kandev/ui/button";
import { CardContent, CardHeader, CardTitle } from "@kandev/ui/card";
@@ -83,6 +84,7 @@ type PluginDetailHeaderProps = {
};
function PluginDetailHeader({ plugin }: PluginDetailHeaderProps) {
const { t } = useTranslation();
return (
<div className="space-y-3">
<Link
@@ -90,7 +92,7 @@ function PluginDetailHeader({ plugin }: PluginDetailHeaderProps) {
className="inline-flex items-center gap-1 text-sm text-muted-foreground hover:text-foreground cursor-pointer"
>
<IconArrowLeft className="h-4 w-4" />
Plugins
{t("common:plugins")}
</Link>
<div className="flex flex-wrap items-start justify-between gap-3">
<div className="min-w-0 space-y-1">
@@ -102,7 +104,7 @@ function PluginDetailHeader({ plugin }: PluginDetailHeaderProps) {
variant="outline"
className="border-amber-500/40 bg-amber-500/10 text-amber-600 dark:text-amber-400 text-[11px]"
>
unsigned
{t("plugins:unsigned")}
</Badge>
)}
</div>
@@ -129,10 +131,11 @@ type PluginSettingsCardProps = {
};
function PluginSettingsCard({ plugin, form, busy }: PluginSettingsCardProps) {
const { t } = useTranslation();
return (
<SettingsCard isDirty={form.isDirty} data-testid="plugin-settings-card">
<CardHeader>
<CardTitle className="text-base">Settings</CardTitle>
<CardTitle className="text-base">{t("plugins:settings")}</CardTitle>
</CardHeader>
<CardContent>
<PluginSettingsBody plugin={plugin} form={form} busy={busy} />
@@ -142,10 +145,14 @@ function PluginSettingsCard({ plugin, form, busy }: PluginSettingsCardProps) {
}
function PluginSettingsBody({ plugin, form, busy }: PluginSettingsCardProps) {
const { t } = useTranslation();
if (form.fields.length === 0) {
return (
<p className="text-sm text-muted-foreground">
This plugin does not declare any settings (no <code>config_schema</code> in its manifest).
{/* `config_schema` is a manifest key — the contract, not copy. */}
<Trans i18nKey="plugins:noDeclaredSettings">
This plugin does not declare any settings (no <code>config_schema</code> in its manifest).
</Trans>
</p>
);
}
@@ -153,7 +160,7 @@ function PluginSettingsBody({ plugin, form, busy }: PluginSettingsCardProps) {
return <p className="text-sm text-destructive">{form.configError}</p>;
}
if (form.configLoading) {
return <p className="text-sm text-muted-foreground">Loading settings...</p>;
return <p className="text-sm text-muted-foreground">{t("plugins:loadingSettings")}</p>;
}
return (
<div className="space-y-4">
@@ -165,9 +172,7 @@ function PluginSettingsBody({ plugin, form, busy }: PluginSettingsCardProps) {
onChange={form.handleChange}
/>
{plugin.status === "active" && (
<p className="text-xs text-muted-foreground">
Saving restarts the plugin so the new settings take effect.
</p>
<p className="text-xs text-muted-foreground">{t("plugins:savingRestartsPlugin")}</p>
)}
</div>
);
@@ -179,6 +184,7 @@ type PluginDangerZoneProps = {
};
function PluginDangerZone({ plugin, actions }: PluginDangerZoneProps) {
const { t } = useTranslation();
const busy = actions.busyId === plugin.id;
const canEnable =
plugin.status === "disabled" || plugin.status === "registered" || plugin.status === "error";
@@ -194,7 +200,7 @@ function PluginDangerZone({ plugin, actions }: PluginDangerZoneProps) {
disabled={busy}
onClick={() => actions.handleEnable(plugin)}
>
Enable
{t("plugins:enable")}
</Button>
)}
{canDisable && (
@@ -205,7 +211,7 @@ function PluginDangerZone({ plugin, actions }: PluginDangerZoneProps) {
disabled={busy}
onClick={() => actions.handleDisable(plugin)}
>
Disable
{t("plugins:disable")}
</Button>
)}
<Button
@@ -215,13 +221,14 @@ function PluginDangerZone({ plugin, actions }: PluginDangerZoneProps) {
disabled={busy}
onClick={() => actions.openUninstall(plugin)}
>
Uninstall
{t("plugins:uninstall")}
</Button>
</div>
);
}
function PluginNotFound({ pluginId }: { pluginId: string }) {
const { t } = useTranslation();
return (
<div className="space-y-4">
<Link
@@ -229,10 +236,13 @@ function PluginNotFound({ pluginId }: { pluginId: string }) {
className="inline-flex items-center gap-1 text-sm text-muted-foreground hover:text-foreground cursor-pointer"
>
<IconArrowLeft className="h-4 w-4" />
Plugins
{t("common:plugins")}
</Link>
<div className="rounded-md border border-dashed p-6 text-sm text-muted-foreground">
No installed plugin with id <span className="font-mono">{pluginId}</span>.
{/* The plugin id is an identifier the user typed or followed, never copy. */}
<Trans i18nKey="plugins:noInstalledPluginWithId" values={{ id: pluginId }}>
No installed plugin with id <span className="font-mono">{pluginId}</span>.
</Trans>
</div>
</div>
);
@@ -1,3 +1,4 @@
import { formatDateTime } from "@/lib/i18n/formats";
import type { PluginRecord } from "@/lib/types/plugins";
/**
@@ -21,7 +22,7 @@ export function PluginErrorDiagnostic({ plugin }: { plugin: PluginRecord }) {
dateTime={plugin.last_error_at}
className="mt-1 block text-[11px] text-destructive/70"
>
{new Date(plugin.last_error_at).toLocaleString()}
{formatDateTime(plugin.last_error_at)}
</time>
)}
</div>
@@ -1,9 +1,11 @@
"use client";
import { useState } from "react";
import { useTranslation } from "react-i18next";
import { Badge } from "@kandev/ui/badge";
import { Button } from "@kandev/ui/button";
import { Card, CardContent, CardHeader, CardTitle } from "@kandev/ui/card";
import { formatDateTime } from "@/lib/i18n/formats";
import type { PluginRecord } from "@/lib/types/plugins";
/**
@@ -12,34 +14,43 @@ import type { PluginRecord } from "@/lib/types/plugins";
* rows don't surface.
*/
export function PluginManifestCard({ plugin }: { plugin: PluginRecord }) {
const { t } = useTranslation();
const [showRaw, setShowRaw] = useState(false);
return (
<Card data-testid="plugin-manifest-card">
<CardHeader className="flex flex-row items-center justify-between">
<CardTitle className="text-base">Manifest</CardTitle>
<CardTitle className="text-base">{t("plugins:manifest")}</CardTitle>
<Button
variant="ghost"
size="sm"
className="cursor-pointer"
onClick={() => setShowRaw((v) => !v)}
>
{showRaw ? "Hide raw" : "View raw"}
{showRaw ? t("plugins:hideRaw") : t("plugins:viewRaw")}
</Button>
</CardHeader>
<CardContent className="space-y-4">
<div className="grid grid-cols-1 sm:grid-cols-2 gap-x-6 gap-y-2 text-sm">
{/* Row VALUES are manifest data — the plugin id, its version, its
author — and are never translated. Only the row labels are copy. */}
<ManifestRow label="ID" value={plugin.id} mono />
<ManifestRow label="Version" value={plugin.version} mono />
<ManifestRow label="API version" value={String(plugin.api_version)} />
<ManifestRow label="Author" value={plugin.author || "—"} />
<ManifestRow label="Signed" value={plugin.signed ? "yes" : "no"} />
<ManifestRow label="Installed" value={formatInstalledAt(plugin.installed_at)} />
<ManifestRow label={t("plugins:manifestVersion")} value={plugin.version} mono />
<ManifestRow label={t("plugins:manifestApiVersion")} value={String(plugin.api_version)} />
<ManifestRow label={t("plugins:manifestAuthor")} value={plugin.author || "—"} />
<ManifestRow
label={t("plugins:manifestSigned")}
value={plugin.signed ? t("plugins:yes") : t("plugins:no")}
/>
<ManifestRow
label={t("plugins:manifestInstalled")}
value={formatInstalledAt(plugin.installed_at)}
/>
</div>
<CapabilityBadges plugin={plugin} />
<DeclarationList
label="Webhooks"
label={t("plugins:manifestWebhooks")}
items={(plugin.webhooks ?? []).map((w) => ({ key: w.key, text: w.key }))}
/>
@@ -66,6 +77,7 @@ function ManifestRow({ label, value, mono }: { label: string; value: string; mon
}
function CapabilityBadges({ plugin }: { plugin: PluginRecord }) {
const { t } = useTranslation();
const caps = plugin.capabilities ?? {};
const badges: string[] = [
...(caps.events ?? []).map((e) => `events:${e}`),
@@ -77,7 +89,9 @@ function CapabilityBadges({ plugin }: { plugin: PluginRecord }) {
if (badges.length === 0) return null;
return (
<div className="space-y-1.5">
<div className="text-sm text-muted-foreground">Capabilities</div>
{/* The badges are permission scopes (`events:*`, `read:*`) — the
contract the plugin declared, so they stay verbatim. */}
<div className="text-sm text-muted-foreground">{t("plugins:manifestCapabilities")}</div>
<div className="flex flex-wrap gap-1">
{badges.map((badge) => (
<Badge key={badge} variant="secondary" className="text-[11px] font-mono">
@@ -129,5 +143,5 @@ function manifestOnly(plugin: PluginRecord): Record<string, unknown> {
function formatInstalledAt(installedAt: string): string {
const date = new Date(installedAt);
return Number.isNaN(date.getTime()) ? installedAt : date.toLocaleString();
return Number.isNaN(date.getTime()) ? installedAt : formatDateTime(date);
}
@@ -1,4 +1,5 @@
import { IconExternalLink } from "@tabler/icons-react";
import { useTranslation } from "react-i18next";
import { cn } from "@/lib/utils";
/**
@@ -20,6 +21,7 @@ export function isHttpUrl(url: string | undefined | null): boolean {
* link markup live in one place.
*/
export function PluginRepoLink({ url, className }: { url?: string | null; className?: string }) {
const { t } = useTranslation();
if (!isHttpUrl(url)) return null;
return (
<a
@@ -33,7 +35,7 @@ export function PluginRepoLink({ url, className }: { url?: string | null; classN
)}
>
<IconExternalLink className="h-3.5 w-3.5" />
Repo
{t("plugins:repo")}
</a>
);
}
@@ -1,6 +1,7 @@
"use client";
import { IconArrowUpCircle } from "@tabler/icons-react";
import { useTranslation } from "react-i18next";
import { Badge } from "@kandev/ui/badge";
import { Button } from "@kandev/ui/button";
import { Switch } from "@kandev/ui/switch";
@@ -43,6 +44,7 @@ export function PluginRow({
onUpdate,
onSetAutoUpdate,
}: PluginRowProps) {
const { t } = useTranslation();
const canEnable =
plugin.status === "disabled" || plugin.status === "registered" || plugin.status === "error";
const canDisable = plugin.status === "active" || plugin.status === "error";
@@ -69,7 +71,7 @@ export function PluginRow({
variant="outline"
className="border-amber-500/40 bg-amber-500/10 text-amber-600 dark:text-amber-400 text-[11px]"
>
unsigned
{t("plugins:unsigned")}
</Badge>
)}
</div>
@@ -135,16 +137,17 @@ function PluginAutoUpdateRow({
busy: boolean;
onSetAutoUpdate: (plugin: PluginRecord, value: boolean | null) => void;
}) {
const { t } = useTranslation();
const isOverridden = plugin.auto_update !== null && plugin.auto_update !== undefined;
const effective = isOverridden ? (plugin.auto_update as boolean) : autoUpdateDefault;
return (
<div className="flex items-center justify-between gap-3 border-t border-border/50 pt-3">
<div className="flex items-center gap-2 text-xs text-muted-foreground">
<span>Auto-update</span>
<span>{t("plugins:autoUpdate")}</span>
{isOverridden && (
<Badge variant="outline" className="text-[11px]">
override
{t("plugins:override")}
</Badge>
)}
</div>
@@ -153,17 +156,17 @@ function PluginAutoUpdateRow({
<button
type="button"
data-testid={`plugin-auto-update-reset-${plugin.id}`}
aria-label={`Reset auto-update for ${plugin.display_name} to the default`}
aria-label={t("plugins:resetAutoUpdateFor", { name: plugin.display_name })}
className="text-xs text-muted-foreground hover:text-foreground underline-offset-2 hover:underline cursor-pointer disabled:opacity-50"
disabled={busy}
onClick={() => onSetAutoUpdate(plugin, null)}
>
Reset
{t("plugins:reset")}
</button>
)}
<Switch
data-testid={`plugin-auto-update-${plugin.id}`}
aria-label={`Auto-update for ${plugin.display_name}`}
aria-label={t("plugins:autoUpdateFor", { name: plugin.display_name })}
checked={effective}
disabled={busy}
onCheckedChange={(value) => onSetAutoUpdate(plugin, value)}
@@ -201,6 +204,7 @@ function PluginRowActions({
onUninstall,
onUpdate,
}: PluginRowActionsProps) {
const { t } = useTranslation();
return (
<div className="flex flex-wrap items-center gap-2 shrink-0">
{update && onUpdate && (
@@ -213,7 +217,7 @@ function PluginRowActions({
onClick={() => onUpdate(update)}
>
<IconArrowUpCircle className="h-4 w-4" />
{busy ? "Updating…" : `Update to v${update.version}`}
{busy ? t("plugins:updating") : t("plugins:updateToVersion", { version: update.version })}
</Button>
)}
{canEnable && (
@@ -224,7 +228,7 @@ function PluginRowActions({
disabled={busy}
onClick={() => onEnable(plugin)}
>
Enable
{t("plugins:enable")}
</Button>
)}
{canDisable && (
@@ -235,7 +239,7 @@ function PluginRowActions({
disabled={busy}
onClick={() => onDisable(plugin)}
>
Disable
{t("plugins:disable")}
</Button>
)}
<Button
@@ -245,7 +249,7 @@ function PluginRowActions({
disabled={busy}
onClick={() => onUninstall(plugin)}
>
Uninstall
{t("plugins:uninstall")}
</Button>
</div>
);
@@ -1,14 +1,17 @@
"use client";
import { useTranslation } from "react-i18next";
import { Badge } from "@kandev/ui/badge";
import type { PluginStatus } from "@/lib/types/plugins";
const STATUS_LABEL: Record<PluginStatus, string> = {
active: "Active",
error: "Error",
disabled: "Disabled",
registered: "Registered",
uninstalled: "Uninstalled",
// The map keys are the backend's wire status; only the labels are copy, and
// they travel as catalog keys so they resolve at render.
const STATUS_LABEL_KEY: Record<PluginStatus, string> = {
active: "plugins:statusActive",
error: "plugins:statusError",
disabled: "plugins:statusDisabled",
registered: "plugins:statusRegistered",
uninstalled: "plugins:statusUninstalled",
};
// green=active, red=error, gray=disabled, amber=registered, per task-20 acceptance.
@@ -21,9 +24,10 @@ const STATUS_CLASS: Record<PluginStatus, string> = {
};
export function PluginStatusBadge({ status }: { status: PluginStatus }) {
const { t } = useTranslation();
return (
<Badge variant="outline" className={STATUS_CLASS[status]}>
{STATUS_LABEL[status]}
{t(STATUS_LABEL_KEY[status])}
</Badge>
);
}
@@ -1,6 +1,7 @@
"use client";
import { useState } from "react";
import { useTranslation } from "react-i18next";
import { IconRefresh } from "@tabler/icons-react";
import { Button } from "@kandev/ui/button";
import { Switch } from "@kandev/ui/switch";
@@ -22,6 +23,7 @@ import { usePluginActions } from "./use-plugin-actions";
* flag by the page-level default export.
*/
export function PluginsSettings() {
const { t } = useTranslation();
const list = usePlugins();
const actions = usePluginActions();
const autoUpdate = useAutoUpdateSettings();
@@ -43,8 +45,8 @@ export function PluginsSettings() {
return (
<SettingsPageTemplate
title="Plugins"
description="Browse the marketplace, then install, enable, disable, and uninstall kandev plugins."
title={t("common:plugins")}
description={t("plugins:settingsDescription")}
isDirty={false}
saveStatus="idle"
onSave={() => undefined}
@@ -57,10 +59,10 @@ export function PluginsSettings() {
data-testid="plugins-tab-installed"
className="cursor-pointer"
>
Installed
{t("plugins:tabInstalled")}
</TabsTrigger>
<TabsTrigger value="browse" data-testid="plugins-tab-browse" className="cursor-pointer">
Browse
{t("plugins:tabBrowse")}
</TabsTrigger>
</TabsList>
@@ -116,12 +118,13 @@ function InstalledTab({
updatingId,
onUpdate,
}: InstalledTabProps) {
const { t } = useTranslation();
return (
<>
<GlobalAutoUpdateToggle settings={autoUpdate} />
<div className="flex items-center justify-between gap-2">
<div className="text-sm font-medium text-foreground">Installed plugins</div>
<div className="text-sm font-medium text-foreground">{t("plugins:installedPlugins")}</div>
<div className="flex items-center gap-2">
<Button
data-testid="plugins-sync-button"
@@ -131,14 +134,14 @@ function InstalledTab({
className="cursor-pointer"
>
<IconRefresh className={`h-4 w-4 ${actions.syncBusy ? "animate-spin" : ""}`} />
Sync
{t("plugins:sync")}
</Button>
<Button
data-testid="install-plugin-trigger"
onClick={actions.openInstall}
className="cursor-pointer"
>
Install plugin
{t("plugins:installPlugin")}
</Button>
</div>
</div>
@@ -178,6 +181,7 @@ function GlobalAutoUpdateToggle({
}: {
settings: ReturnType<typeof useAutoUpdateSettings>;
}) {
const { t } = useTranslation();
return (
<div className="flex items-center justify-between gap-4 rounded-lg border border-border/70 bg-background p-4">
<div className="min-w-0 space-y-1">
@@ -185,12 +189,9 @@ function GlobalAutoUpdateToggle({
htmlFor="plugins-auto-update-default"
className="text-sm font-medium text-foreground cursor-pointer"
>
Automatically update plugins
{t("plugins:autoUpdateTitle")}
</label>
<p className="text-xs text-muted-foreground">
Check the marketplace periodically and install newer versions of active plugins. Applies
to every plugin unless overridden per plugin below.
</p>
<p className="text-xs text-muted-foreground">{t("plugins:autoUpdateDescription")}</p>
</div>
<Switch
id="plugins-auto-update-default"
@@ -221,6 +222,7 @@ function PluginList({
updatingId,
onUpdate,
}: PluginListProps) {
const { t } = useTranslation();
const { items, loaded, loading, error } = list;
if (error) {
@@ -234,7 +236,7 @@ function PluginList({
if (!loaded && loading) {
return (
<div className="rounded-lg border border-dashed border-border/70 p-6 text-sm text-muted-foreground">
Loading plugins...
{t("plugins:loadingPlugins")}
</div>
);
}
@@ -242,7 +244,7 @@ function PluginList({
if (loaded && items.length === 0) {
return (
<div className="rounded-lg border border-dashed border-border/70 p-6 text-sm text-muted-foreground">
No plugins yet. Install one from the Browse tab, or upload a package.
{t("plugins:noPluginsYet")}
</div>
);
}
@@ -1,5 +1,6 @@
"use client";
import { Trans, useTranslation } from "react-i18next";
import { Button } from "@kandev/ui/button";
import {
Dialog,
@@ -24,6 +25,7 @@ export function UninstallPluginDialog({
onClose,
onConfirm,
}: UninstallPluginDialogProps) {
const { t } = useTranslation();
return (
<Dialog
open={Boolean(target)}
@@ -33,18 +35,23 @@ export function UninstallPluginDialog({
>
<DialogContent>
<DialogHeader>
<DialogTitle>Uninstall plugin</DialogTitle>
<DialogTitle>{t("plugins:uninstallPlugin")}</DialogTitle>
<DialogDescription>
This will permanently remove{" "}
<span className="font-medium text-foreground">
{target?.display_name ?? "this plugin"}
</span>{" "}
and revoke its API key. This action cannot be undone.
{/* The display name comes from the plugin's manifest, so it is
third-party data rather than our copy. */}
<Trans
i18nKey="plugins:uninstallConfirm"
values={{ name: target?.display_name ?? t("plugins:thisPlugin") }}
>
This will permanently remove{" "}
<span className="font-medium text-foreground">{target?.display_name}</span> and revoke
its API key. This action cannot be undone.
</Trans>
</DialogDescription>
</DialogHeader>
<DialogFooter>
<Button type="button" variant="outline" onClick={onClose} className="cursor-pointer">
Cancel
{t("plugins:cancel")}
</Button>
<Button
type="button"
@@ -53,7 +60,7 @@ export function UninstallPluginDialog({
disabled={busy}
className="cursor-pointer"
>
Confirm uninstall
{t("plugins:confirmUninstall")}
</Button>
</DialogFooter>
</DialogContent>
@@ -2,6 +2,9 @@
import { useState } from "react";
import { toast } from "@/lib/toast/sonner";
// Module-level `t`: these callbacks fire outside a render, so each message
// resolves when the action runs rather than at import.
import { t } from "@/lib/i18n";
import type { StoreApi } from "zustand";
import { useAppStore, useAppStoreApi } from "@/components/state-provider";
import { useTheme } from "@/components/theme/app-theme";
@@ -94,7 +97,11 @@ function useEnableDisableActions(upsertPlugin: (p: PluginRecord) => void) {
// Preserve the original Enable failure toast if the diagnostic refresh
// itself cannot reach the backend.
}
toast.error(err instanceof Error ? err.message : `Failed to enable ${plugin.display_name}`);
toast.error(
err instanceof Error
? err.message
: t("plugins:failedToEnable", { name: plugin.display_name }),
);
} finally {
setBusyId(null);
}
@@ -107,7 +114,11 @@ function useEnableDisableActions(upsertPlugin: (p: PluginRecord) => void) {
unloadPlugin(plugin.id);
upsertPlugin(withStatus(plugin, "disabled"));
} catch (err) {
toast.error(err instanceof Error ? err.message : `Failed to disable ${plugin.display_name}`);
toast.error(
err instanceof Error
? err.message
: t("plugins:failedToDisable", { name: plugin.display_name }),
);
} finally {
setBusyId(null);
}
@@ -134,7 +145,7 @@ function useAutoUpdateAction(upsertPlugin: (p: PluginRecord) => void) {
toast.error(
err instanceof Error
? err.message
: `Failed to update auto-update for ${plugin.display_name}`,
: t("plugins:failedToSetAutoUpdateFor", { name: plugin.display_name }),
);
} finally {
setAutoUpdateBusyId(null);
@@ -159,7 +170,9 @@ function useUninstallAction(removePlugin: (id: string) => void) {
setUninstallTarget(null);
} catch (err) {
toast.error(
err instanceof Error ? err.message : `Failed to uninstall ${target.display_name}`,
err instanceof Error
? err.message
: t("plugins:failedToUninstall", { name: target.display_name }),
);
} finally {
setUninstallBusy(false);
@@ -203,7 +216,7 @@ function useInstallAction(upsertPlugin: (p: PluginRecord) => void) {
if (warning) {
toast.warning(warning);
} else {
toast.success(`${plugin.display_name} installed`);
toast.success(t("plugins:pluginInstalled", { name: plugin.display_name }));
}
closeInstallDialog();
};
@@ -215,7 +228,7 @@ function useInstallAction(upsertPlugin: (p: PluginRecord) => void) {
const result = await install();
await afterInstall(result);
} catch (err) {
setInstallError(err instanceof Error ? err.message : "Failed to install plugin");
setInstallError(err instanceof Error ? err.message : t("plugins:failedToInstallPlugin"));
} finally {
setInstallBusy(false);
}
@@ -232,7 +245,7 @@ function useInstallAction(upsertPlugin: (p: PluginRecord) => void) {
const result = await installPluginFromUrl(url);
await afterInstall(result);
} catch (err) {
toast.error(err instanceof Error ? err.message : "Failed to install plugin");
toast.error(err instanceof Error ? err.message : t("plugins:failedToInstallPlugin"));
}
};
@@ -274,7 +287,7 @@ function useSyncAction(setPlugins: (plugins: PluginRecord[]) => void) {
setSyncErrors(result.errors ?? []);
toast.success(summarizeSyncResult(result));
} catch (err) {
toast.error(err instanceof Error ? err.message : "Failed to sync plugins");
toast.error(err instanceof Error ? err.message : t("plugins:failedToSyncPlugins"));
} finally {
setSyncBusy(false);
}
@@ -2,6 +2,8 @@
import { useEffect, useMemo, useState } from "react";
import { toast } from "@/lib/toast/sonner";
// Module-level `t`: these resolve when the save/load callback runs.
import { t } from "@/lib/i18n";
import { getPluginConfig, updatePluginConfig } from "@/lib/api/domains/plugins-api";
import {
SECRET_MASK,
@@ -61,7 +63,7 @@ export function usePluginConfigForm(plugin: PluginRecord | null) {
})
.catch((err) => {
if (!cancelled) {
setConfigError(err instanceof Error ? err.message : "Failed to load plugin settings");
setConfigError(err instanceof Error ? err.message : t("plugins:failedToLoadSettings"));
}
})
.finally(() => {
@@ -89,15 +91,18 @@ export function usePluginConfigForm(plugin: PluginRecord | null) {
const handleSave = async () => {
if (!pluginId) return;
if (missing.length > 0) {
toast.error(`Required: ${missing.join(", ")}`);
throw new Error(`Required: ${missing.join(", ")}`);
// The field names come from the plugin's config_schema, so only the
// "Required:" frame is copy.
const message = t("plugins:requiredFields", { fields: missing.join(", ") });
toast.error(message);
throw new Error(message);
}
setSaveStatus("loading");
try {
await updatePluginConfig(pluginId, serializeConfigValues(fields, values));
} catch (err) {
setSaveStatus("error");
toast.error(err instanceof Error ? err.message : "Failed to save plugin settings");
toast.error(err instanceof Error ? err.message : t("plugins:failedToSaveSettings"));
throw err;
}
// The config IS persisted from here on — a refetch failure (e.g. a
@@ -108,12 +113,12 @@ export function usePluginConfigForm(plugin: PluginRecord | null) {
const initial = buildInitialValues(fields, refreshed);
setValues(initial);
setInitialValues(initial);
toast.success("Plugin settings saved");
toast.success(t("plugins:settingsSaved"));
} catch {
const masked = maskSecretsIn(values, fields);
setValues(masked);
setInitialValues(masked);
toast.warning("Settings saved, but reloading them failed — refresh to confirm.");
toast.warning(t("plugins:settingsSavedReloadFailed"));
}
setSaveStatus("success");
};
@@ -127,7 +132,8 @@ export function usePluginConfigForm(plugin: PluginRecord | null) {
saveStatus,
isDirty,
canSave: missing.length === 0,
invalidReason: missing.length > 0 ? `Required: ${missing.join(", ")}` : undefined,
invalidReason:
missing.length > 0 ? t("plugins:requiredFields", { fields: missing.join(", ") }) : undefined,
revision: JSON.stringify(values),
handleChange,
handleSave,
@@ -29,15 +29,18 @@ function McpPresetButton({ label, onClick }: { label: string; onClick: () => voi
);
}
// Returns a catalog key (or null when valid) so the message resolves at render
// rather than at module load — the same contract as the local validator in
// app/settings/executor/[id]/page.tsx.
export function validateMcpPolicy(value: string | undefined): string | null {
const raw = value ?? "";
if (!raw.trim()) return null;
try {
const parsed = JSON.parse(raw);
if (!parsed || typeof parsed !== "object" || Array.isArray(parsed))
return "MCP policy must be a JSON object";
return "executors:mcpPolicyMustBeAJsonObject";
} catch {
return "Invalid JSON";
return "executors:invalidJson";
}
return null;
}
@@ -45,14 +48,14 @@ export function validateMcpPolicy(value: string | undefined): string | null {
type McpPolicyCardProps = {
mcpPolicy: string;
baselinePolicy?: string;
mcpPolicyError: string | null;
mcpPolicyErrorKey: string | null;
onPolicyChange: (value: string) => void;
};
export function McpPolicyCard({
mcpPolicy,
baselinePolicy,
mcpPolicyError,
mcpPolicyErrorKey,
onPolicyChange,
}: McpPolicyCardProps) {
const { t } = useTranslation();
@@ -84,7 +87,7 @@ export function McpPolicyCard({
rows={8}
data-settings-dirty={isDirty}
/>
{mcpPolicyError && <p className="text-xs text-destructive">{mcpPolicyError}</p>}
{mcpPolicyErrorKey && <p className="text-xs text-destructive">{t(mcpPolicyErrorKey)}</p>}
<div className="flex flex-wrap items-center gap-2">
<p className="text-xs font-medium text-muted-foreground">{t("executors:quickPresets")}</p>
<McpPresetButton
@@ -22,7 +22,7 @@ describe("executor profile dirty fields", () => {
<McpPolicyCard
mcpPolicy='{"allow_http":true}'
baselinePolicy='{"allow_http":true}'
mcpPolicyError={null}
mcpPolicyErrorKey={null}
onPolicyChange={onChange}
/>,
);
@@ -33,7 +33,7 @@ describe("executor profile dirty fields", () => {
<McpPolicyCard
mcpPolicy='{"allow_http":false}'
baselinePolicy='{"allow_http":true}'
mcpPolicyError={null}
mcpPolicyErrorKey={null}
onPolicyChange={onChange}
/>,
);
@@ -65,11 +65,13 @@ const SEGMENT_LABEL_KEYS: Record<string, string> = {
prompts: "common:prompts",
"resource-metrics": "settings:resourceMetrics",
secrets: "settings:secrets",
security: "settings:security",
shell: "common:shell",
sprites: "settings:sprites",
system: "common:system",
"task-actions": "settings:taskActions",
terminal: "settings:terminal",
tokens: "settings:tokens",
"utility-agents": "settings:utilityAgents",
"voice-mode": "settings:voiceMode",
workspace: "common:workspace",
@@ -1,6 +1,7 @@
"use client";
import { useCallback, useEffect, useRef, useState } from "react";
import { useTranslation } from "react-i18next";
import { Badge } from "@kandev/ui/badge";
import { Button } from "@kandev/ui/button";
import { CardContent, CardDescription, CardHeader, CardTitle } from "@kandev/ui/card";
@@ -53,6 +54,7 @@ function useReadinessState({
shellProp?: string;
onShellChange?: (s: string) => void | Promise<void>;
}) {
const { t } = useTranslation();
const [rows, setRows] = useState<SSHAgentReadinessRow[]>([]);
const [loading, setLoading] = useState(false);
const [error, setError] = useState<string | null>(null);
@@ -78,11 +80,11 @@ function useReadinessState({
setHasProbed(true);
} catch (e) {
if (seq !== seqRef.current) return;
setError(e instanceof Error ? e.message : "Failed to probe agents");
setError(e instanceof Error ? e.message : t("executors:sshFailedToProbeAgents"));
} finally {
if (seq === seqRef.current) setLoading(false);
}
}, [executorId, shell]);
}, [executorId, shell, t]);
// Auto-probe the shells once on mount so the dropdown has real options.
// Cheap (one SSH dial, ~1s) and fully out-of-band of agent probing.
@@ -129,11 +131,11 @@ function useReadinessState({
try {
await onShellChange(next);
} catch (e) {
toast.error(e instanceof Error ? e.message : "Failed to save shell");
toast.error(e instanceof Error ? e.message : t("executors:sshFailedToSaveShell"));
}
}
},
[onShellChange],
[onShellChange, t],
);
return {
@@ -164,6 +166,7 @@ export function SSHAgentReadinessCard({
baselineShell,
onShellChange,
}: SSHAgentReadinessCardProps) {
const { t } = useTranslation();
const state = useReadinessState({ executorId, shellProp, onShellChange });
const {
rows,
@@ -185,10 +188,11 @@ export function SSHAgentReadinessCard({
<CardHeader>
<div className="flex items-start justify-between gap-3">
<div>
<CardTitle>Available agents on this host</CardTitle>
<CardTitle>{t("executors:sshAvailableAgents")}</CardTitle>
<CardDescription>
Probes the remote {"$PATH"} for each enabled agent under the chosen login shell. Copy
the install hint and run it on the remote when an agent is missing.
{/* $PATH is a shell variable name, interpolated so the
pseudo-locale cannot transliterate it. */}
{t("executors:sshAvailableAgentsDescription", { pathVar: "$PATH" })}
</CardDescription>
</div>
<Button
@@ -200,7 +204,7 @@ export function SSHAgentReadinessCard({
className="cursor-pointer shrink-0"
>
{loading ? <IconLoader2 className="mr-1.5 h-4 w-4 animate-spin" /> : null}
{hasProbed ? "Re-probe" : "Probe agents"}
{hasProbed ? t("executors:sshReprobe") : t("executors:sshProbeAgents")}
</Button>
</div>
<ShellSelector
@@ -234,6 +238,7 @@ function ShellSelector({
onChange: (s: string) => void | Promise<void>;
isDirty: boolean;
}) {
const { t } = useTranslation();
// While probing, show a placeholder option so the dropdown can't surface
// a stale "bash" selection that we haven't confirmed exists on the host.
// After probing, render whatever the host has, plus the current value so
@@ -242,7 +247,7 @@ function ShellSelector({
return (
<div className="flex items-center gap-2 pt-3">
<Label htmlFor="ssh-readiness-shell" className="text-xs text-muted-foreground">
Login shell
{t("executors:sshLoginShell")}
</Label>
<Select value={shell} onValueChange={(v) => void onChange(v)} disabled={loading}>
<SelectTrigger
@@ -258,7 +263,7 @@ function ShellSelector({
<SelectItem key={s} value={s} className="text-xs">
{s}
{shells && !shells.includes(s) ? (
<span className="text-amber-600 ml-1">(not detected)</span>
<span className="text-amber-600 ml-1">{t("executors:sshShellNotDetected")}</span>
) : null}
</SelectItem>
))}
@@ -286,6 +291,7 @@ function ReadinessContent({
hasProbed: boolean;
rows: SSHAgentReadinessRow[];
}) {
const { t } = useTranslation();
if (error) {
return (
<p data-testid="ssh-agent-readiness-error" className="text-sm text-red-600 dark:text-red-400">
@@ -296,25 +302,26 @@ function ReadinessContent({
if (!hasProbed) {
return (
<p className="text-sm text-muted-foreground">
Click {`"Probe agents"`} to check which agents are installed on the remote.
{t("executors:sshProbeHint", { button: t("executors:sshProbeAgents") })}
</p>
);
}
if (rows.length === 0) {
return <p className="text-sm text-muted-foreground">No enabled agents to probe.</p>;
return <p className="text-sm text-muted-foreground">{t("executors:sshNoEnabledAgents")}</p>;
}
return <ReadinessTable rows={rows} />;
}
function ReadinessTable({ rows }: { rows: SSHAgentReadinessRow[] }) {
const { t } = useTranslation();
return (
<Table data-testid="ssh-agent-readiness-table">
<TableHeader>
<TableRow>
<TableHead>Agent</TableHead>
<TableHead>Binary</TableHead>
<TableHead>Status</TableHead>
<TableHead>Install hint</TableHead>
<TableHead>{t("executors:agent")}</TableHead>
<TableHead>{t("executors:sshBinary")}</TableHead>
<TableHead>{t("executors:status")}</TableHead>
<TableHead>{t("executors:sshInstallHint")}</TableHead>
</TableRow>
</TableHeader>
<TableBody>
@@ -343,40 +350,43 @@ function ReadinessRow({ row }: { row: SSHAgentReadinessRow }) {
}
function StatusBadge({ row }: { row: SSHAgentReadinessRow }) {
const { t } = useTranslation();
if (row.error) {
return (
<Badge variant="outline" className="border-amber-500/30 bg-amber-500/10 text-amber-700">
<IconX className="mr-1 h-3 w-3" /> Probe error
<IconX className="mr-1 h-3 w-3" /> {t("executors:sshProbeError")}
</Badge>
);
}
if (row.available) {
return (
<Badge variant="outline" className="border-green-500/30 bg-green-500/10 text-green-700">
<IconCheck className="mr-1 h-3 w-3" /> Installed
<IconCheck className="mr-1 h-3 w-3" /> {t("executors:sshInstalled")}
</Badge>
);
}
return (
<Badge variant="outline" className="border-red-500/30 bg-red-500/10 text-red-700">
<IconX className="mr-1 h-3 w-3" /> Missing
<IconX className="mr-1 h-3 w-3" /> {t("executors:sshMissing")}
</Badge>
);
}
function InstallHint({ hint, available }: { hint?: string; available: boolean }) {
const { t } = useTranslation();
if (available) return <span className="text-muted-foreground">—</span>;
if (!hint) return <span className="text-muted-foreground">No hint available</span>;
if (!hint)
return <span className="text-muted-foreground">{t("executors:sshNoHintAvailable")}</span>;
return (
<div className="flex items-center gap-1">
<code className="truncate">{hint}</code>
<button
type="button"
className="cursor-pointer text-muted-foreground hover:text-foreground"
aria-label="Copy install hint"
aria-label={t("executors:sshCopyInstallHint")}
onClick={() => {
void copyToClipboard(hint).then((success) => {
if (success) toast.success("Install hint copied");
if (success) toast.success(t("executors:sshInstallHintCopied"));
});
}}
>
@@ -1,6 +1,11 @@
"use client";
import { useCallback, useState } from "react";
import { useTranslation } from "react-i18next";
// Aliased so components can keep the conventional `t` from useTranslation();
// this one is for the module-scope helpers and callbacks below, which have no
// hook in scope and must resolve when they run, not at import.
import { t as translate } from "@/lib/i18n";
import { Badge } from "@kandev/ui/badge";
import { Button } from "@kandev/ui/button";
import { CardContent, CardDescription, CardHeader, CardTitle } from "@kandev/ui/card";
@@ -107,13 +112,11 @@ function initialState(initial?: Partial<SSHExecutorConfig>): SSHConnectionState
};
}
// A window.confirm argument is invisible to the guard; the module-level `t`
// resolves it when the user clicks Save.
function confirmRunningSessions(count?: number): boolean {
if (!count) return true;
return window.confirm(
`This executor has ${count} running session(s). ` +
"They will keep running on the current host. Only new sessions started " +
"after save will use the updated config. Continue?",
);
return window.confirm(translate("executors:sshConfirmRunningSessions", { count }));
}
type CoordinatedSSHSaveOptions = {
@@ -139,7 +142,7 @@ function useCoordinatedSSHSave({
revision,
isDirty: Boolean(id) && revision !== JSON.stringify(baseline),
canSave,
invalidReason: canSave ? undefined : "Test the connection and trust its fingerprint to save.",
invalidReason: canSave ? undefined : translate("executors:sshTestAndTrustToSave"),
save,
discard,
});
@@ -215,7 +218,7 @@ function useSSHConnection(props: SSHConnectionCardProps) {
testing: false,
}));
} catch (e) {
const msg = e instanceof Error ? e.message : "Failed to reach backend";
const msg = e instanceof Error ? e.message : translate("executors:sshFailedToReachBackend");
setState((prev) => ({ ...prev, error: msg, testing: false }));
}
}, [form]);
@@ -223,6 +226,8 @@ function useSSHConnection(props: SSHConnectionCardProps) {
const canSave = !!result?.success && !!result.fingerprint && trust && !resultStale && !saving;
const handleSave = useCallback(async () => {
// Unreachable while `canSave` gates the Save button; this signals the save
// coordinator rather than the user, so it is control flow, not copy.
if (!canSave || !result?.fingerprint) throw new Error("Test and trust this host before saving");
if (!confirmRunningSessions(props.runningSessionCount)) throw new SettingsSaveCancelledError();
const submitted = form;
@@ -232,7 +237,7 @@ function useSSHConnection(props: SSHConnectionCardProps) {
setBaseline(submitted);
setState((prev) => ({ ...prev, saving: false }));
} catch (e) {
const msg = e instanceof Error ? e.message : "Failed to save executor";
const msg = e instanceof Error ? e.message : translate("executors:sshFailedToSaveExecutor");
setState((prev) => ({ ...prev, saving: false, error: msg }));
throw e;
}
@@ -267,6 +272,7 @@ function useSSHConnection(props: SSHConnectionCardProps) {
}
export function SSHConnectionCard(props: SSHConnectionCardProps) {
const { t } = useTranslation();
const c = useSSHConnection(props);
return (
<SettingsCard isDirty={c.isDirty} data-testid="ssh-connection-card">
@@ -275,11 +281,9 @@ export function SSHConnectionCard(props: SSHConnectionCardProps) {
<div>
<CardTitle className="flex items-center gap-2">
<IconTerminal2 className="h-5 w-5" />
Connection
{t("executors:connection")}
</CardTitle>
<CardDescription>
Run an agent on Linux amd64 or macOS hosts you can reach over SSH.
</CardDescription>
<CardDescription>{t("executors:sshConnectionDescription")}</CardDescription>
</div>
<ConnectionBadge fingerprint={c.form.host_fingerprint} />
</div>
@@ -316,6 +320,7 @@ export function SSHConnectionCard(props: SSHConnectionCardProps) {
}
function PinnedFingerprintRow({ fingerprint }: { fingerprint: string }) {
const { t } = useTranslation();
return (
<div
data-testid="ssh-fingerprint-pinned"
@@ -323,7 +328,8 @@ function PinnedFingerprintRow({ fingerprint }: { fingerprint: string }) {
>
<IconShieldLock className="h-4 w-4 shrink-0" />
<span className="text-muted-foreground">
Pinned fingerprint:{" "}
{t("executors:sshPinnedFingerprint")}{" "}
{/* Never translated — the user compares it against their own host. */}
<code data-testid="ssh-fingerprint-pinned-value" className="font-mono">
{fingerprint}
</code>
@@ -349,6 +355,7 @@ function SSHConnectionActions({
onSave: () => void;
showSave: boolean;
}) {
const { t } = useTranslation();
return (
<div className="flex items-center gap-3">
<Button
@@ -364,7 +371,7 @@ function SSHConnectionActions({
) : (
<IconTestPipe className="mr-1.5 h-4 w-4" />
)}
Test connection
{t("executors:sshTestConnection")}
</Button>
{showSave && (
<Button
@@ -375,7 +382,7 @@ function SSHConnectionActions({
className="cursor-pointer"
>
{saving ? <IconLoader2 className="mr-1.5 h-4 w-4 animate-spin" /> : null}
Save
{t("executors:save")}
</Button>
)}
</div>
@@ -383,10 +390,11 @@ function SSHConnectionActions({
}
function ConnectionBadge({ fingerprint }: { fingerprint?: string }) {
const { t } = useTranslation();
if (!fingerprint) {
return (
<Badge data-testid="ssh-connection-badge" data-status="unverified" variant="secondary">
Unverified
{t("executors:sshUnverified")}
</Badge>
);
}
@@ -397,7 +405,7 @@ function ConnectionBadge({ fingerprint }: { fingerprint?: string }) {
variant="default"
className="bg-green-600"
>
Trusted
{t("executors:sshTrusted")}
</Badge>
);
}
@@ -444,6 +452,7 @@ function TestResultDisplay({
}
function TestResultHeader({ success, totalMs }: { success: boolean; totalMs: number }) {
const { t } = useTranslation();
return (
<div
data-testid={success ? "ssh-test-result-success" : "ssh-test-result-failure"}
@@ -454,7 +463,7 @@ function TestResultHeader({ success, totalMs }: { success: boolean; totalMs: num
) : (
<IconX className="h-4 w-4 text-red-600" />
)}
{success ? "Connection test passed" : "Connection test failed"}
{success ? t("executors:sshConnectionTestPassed") : t("executors:sshConnectionTestFailed")}
<span className="text-muted-foreground font-normal">({totalMs}ms)</span>
</div>
);
@@ -1,4 +1,5 @@
import type { ReactNode } from "react";
import { useTranslation } from "react-i18next";
import { Input } from "@kandev/ui/input";
import { Label } from "@kandev/ui/label";
import { Select, SelectContent, SelectItem, SelectTrigger, SelectValue } from "@kandev/ui/select";
@@ -26,13 +27,14 @@ function fieldIsDirty<K extends keyof SSHExecutorConfig>(
}
export function SSHConnectionForm({ form, baseline, onChange }: SSHConnectionFormProps) {
const { t } = useTranslation();
return (
<div className="grid grid-cols-1 md:grid-cols-2 gap-4">
<TextField
id="ssh-name"
testId="ssh-input-name"
label="Name"
placeholder="My VPS"
label={t("executors:name")}
placeholder={t("executors:sshNamePlaceholder")}
value={form.name}
isDirty={fieldIsDirty(form, baseline, "name", "")}
onChange={(value) => onChange("name", value)}
@@ -40,8 +42,8 @@ export function SSHConnectionForm({ form, baseline, onChange }: SSHConnectionFor
<TextField
id="ssh-host-alias"
testId="ssh-input-host-alias"
label="Host alias from ~/.ssh/config (optional)"
hint="If set, inherits HostName / Port / User / IdentityFile / ProxyJump from your config."
label={t("executors:sshHostAliasLabel", { path: "~/.ssh/config" })}
hint={t("executors:sshHostAliasHint")}
placeholder="prod"
value={form.host_alias ?? ""}
isDirty={fieldIsDirty(form, baseline, "host_alias", "")}
@@ -50,7 +52,7 @@ export function SSHConnectionForm({ form, baseline, onChange }: SSHConnectionFor
<TextField
id="ssh-host"
testId="ssh-input-host"
label="Host"
label={t("executors:host")}
placeholder="dev.example.com"
value={form.host ?? ""}
isDirty={fieldIsDirty(form, baseline, "host", "")}
@@ -59,7 +61,7 @@ export function SSHConnectionForm({ form, baseline, onChange }: SSHConnectionFor
<TextField
id="ssh-port"
testId="ssh-input-port"
label="Port"
label={t("executors:port")}
type="number"
placeholder="22"
value={String(form.port ?? 22)}
@@ -69,7 +71,7 @@ export function SSHConnectionForm({ form, baseline, onChange }: SSHConnectionFor
<TextField
id="ssh-user"
testId="ssh-input-user"
label="User"
label={t("executors:user")}
placeholder="ubuntu"
value={form.user ?? ""}
isDirty={fieldIsDirty(form, baseline, "user", "")}
@@ -84,8 +86,8 @@ export function SSHConnectionForm({ form, baseline, onChange }: SSHConnectionFor
<TextField
id="ssh-identity-file"
testId="ssh-input-identity-file"
label="Identity file path"
hint="Passphrase-protected keys must be loaded into ssh-agent first."
label={t("executors:sshIdentityFilePath")}
hint={t("executors:sshIdentityFileHint")}
placeholder="~/.ssh/id_ed25519"
value={form.identity_file ?? ""}
isDirty={fieldIsDirty(form, baseline, "identity_file", "")}
@@ -95,8 +97,8 @@ export function SSHConnectionForm({ form, baseline, onChange }: SSHConnectionFor
<TextField
id="ssh-proxy-jump"
testId="ssh-input-proxy-jump"
label="ProxyJump (optional)"
hint="Single bastion hop. Chained jumps are not supported."
label={t("executors:sshProxyJumpLabel")}
hint={t("executors:sshProxyJumpHint")}
placeholder="bastion.example.com"
value={form.proxy_jump ?? ""}
isDirty={fieldIsDirty(form, baseline, "proxy_jump", "")}
@@ -153,8 +155,9 @@ function IdentitySourceField({
isDirty: boolean;
onChange: (value: SSHIdentitySource) => void;
}) {
const { t } = useTranslation();
return (
<FieldShell id="ssh-identity-source" label="Identity source">
<FieldShell id="ssh-identity-source" label={t("executors:sshIdentitySource")}>
<Select value={value} onValueChange={(next) => onChange(next as SSHIdentitySource)}>
<SelectTrigger
id="ssh-identity-source"
@@ -168,7 +171,7 @@ function IdentitySourceField({
ssh-agent (SSH_AUTH_SOCK)
</SelectItem>
<SelectItem value="file" data-testid="ssh-input-identity-source-file">
Identity file (private key path)
{t("executors:sshIdentitySourceFile")}
</SelectItem>
</SelectContent>
</Select>
@@ -1,5 +1,7 @@
"use client";
import { Trans, useTranslation } from "react-i18next";
// Trust-confirmation block shown under the SSH test result. Disabled when the
// caller's last test ran against a different form, to keep stale fingerprints
// from being trusted for a new target.
@@ -16,26 +18,33 @@ export function FingerprintTrustBlock({
resultStale: boolean;
onTrustChange: (v: boolean) => void;
}) {
const { t } = useTranslation();
const fingerprintChanged = !!currentlyPinned && currentlyPinned !== fingerprint;
return (
<div className="mt-3 space-y-2">
<div className="text-xs">
<span className="text-muted-foreground">Host fingerprint observed: </span>
{/* The fingerprints below are never translated: the user compares them
character-for-character against what their own host reports. */}
<span className="text-muted-foreground">{t("executors:sshFingerprintObserved")} </span>
<code data-testid="ssh-fingerprint-observed" className="font-mono">
{fingerprint}
</code>
</div>
{fingerprintChanged && (
<p data-testid="ssh-fingerprint-change-warning" className="text-xs text-amber-600">
Warning: this fingerprint differs from the one currently pinned (
<code className="font-mono">{currentlyPinned}</code>). Trusting it overwrites the pinned
key. If you didn’t expect a host re-key, stop here.
<Trans
i18nKey="executors:sshFingerprintChanged"
values={{ fingerprint: currentlyPinned }}
>
Warning: this fingerprint differs from the one currently pinned (
<code className="font-mono">{currentlyPinned}</code>). Trusting it overwrites the pinned
key. If you didn’t expect a host re-key, stop here.
</Trans>
</p>
)}
{resultStale && (
<p data-testid="ssh-test-result-stale" className="text-xs text-amber-600">
You changed a connection field after this test ran. Run Test Connection again before
trusting and saving.
{t("executors:sshTestResultStale")}
</p>
)}
<label
@@ -53,8 +62,10 @@ export function FingerprintTrustBlock({
className={"mt-0.5 " + (resultStale ? "cursor-not-allowed" : "cursor-pointer")}
/>
<span>
<strong>Trust this host.</strong> I’ve verified the fingerprint above matches my server.
Future connections that report a different fingerprint will be refused.
<Trans i18nKey="executors:sshTrustThisHost">
<strong>Trust this host.</strong> I’ve verified the fingerprint above matches my server.
Future connections that report a different fingerprint will be refused.
</Trans>
</span>
</label>
</div>
@@ -1,6 +1,7 @@
"use client";
import { useCallback, useEffect, useRef, useState } from "react";
import { useTranslation } from "react-i18next";
import { Badge } from "@kandev/ui/badge";
import { Button } from "@kandev/ui/button";
import { Card, CardContent, CardDescription, CardHeader, CardTitle } from "@kandev/ui/card";
@@ -16,6 +17,7 @@ export interface SSHSessionsCardProps {
const REFRESH_INTERVAL_MS = 90_000;
export function SSHSessionsCard({ executorId }: SSHSessionsCardProps) {
const { t } = useTranslation();
const [sessions, setSessions] = useState<SSHSession[]>([]);
const [loading, setLoading] = useState(false);
const [error, setError] = useState<string | null>(null);
@@ -34,11 +36,11 @@ export function SSHSessionsCard({ executorId }: SSHSessionsCardProps) {
setSessions(rows);
} catch (e) {
if (seq !== seqRef.current) return;
setError(e instanceof Error ? e.message : "Failed to load sessions");
setError(e instanceof Error ? e.message : t("executors:sshFailedToLoadSessions"));
} finally {
if (seq === seqRef.current) setLoading(false);
}
}, [executorId]);
}, [executorId, t]);
useEffect(() => {
// Reset sequence so a previous executor's pending response can't land
@@ -59,10 +61,8 @@ export function SSHSessionsCard({ executorId }: SSHSessionsCardProps) {
<CardHeader>
<div className="flex items-center justify-between">
<div>
<CardTitle>Active sessions</CardTitle>
<CardDescription>
Sessions currently running on this SSH host. Refreshes every 90 seconds.
</CardDescription>
<CardTitle>{t("executors:sshActiveSessions")}</CardTitle>
<CardDescription>{t("executors:sshActiveSessionsDescription")}</CardDescription>
</div>
<Button
variant="outline"
@@ -73,7 +73,7 @@ export function SSHSessionsCard({ executorId }: SSHSessionsCardProps) {
className="cursor-pointer"
>
{loading ? <IconLoader2 className="mr-1.5 h-4 w-4 animate-spin" /> : null}
Refresh
{t("executors:refresh")}
</Button>
</div>
</CardHeader>
@@ -93,6 +93,7 @@ function SSHSessionsBody({
error: string | null;
sessions: SSHSession[];
}) {
const { t } = useTranslation();
if (error) {
return (
<p data-testid="ssh-sessions-error" className="text-sm text-red-600">
@@ -103,7 +104,7 @@ function SSHSessionsBody({
if (sessions.length === 0 && !loading) {
return (
<p data-testid="ssh-sessions-empty" className="text-sm text-muted-foreground">
No active sessions.
{t("executors:sshNoActiveSessions")}
</p>
);
}
@@ -112,17 +113,18 @@ function SSHSessionsBody({
}
function SSHSessionsTable({ sessions }: { sessions: SSHSession[] }) {
const { t } = useTranslation();
return (
<Table data-testid="ssh-sessions-table">
<TableHeader>
<TableRow>
<TableHead>Task</TableHead>
<TableHead>Session</TableHead>
<TableHead>Host</TableHead>
<TableHead>Remote port</TableHead>
<TableHead>Local fwd</TableHead>
<TableHead>Uptime</TableHead>
<TableHead>Status</TableHead>
<TableHead>{t("executors:task")}</TableHead>
<TableHead>{t("executors:session")}</TableHead>
<TableHead>{t("executors:host")}</TableHead>
<TableHead>{t("executors:sshRemotePort")}</TableHead>
<TableHead>{t("executors:sshLocalForward")}</TableHead>
<TableHead>{t("executors:uptime")}</TableHead>
<TableHead>{t("executors:status")}</TableHead>
</TableRow>
</TableHeader>
<TableBody>
@@ -1,6 +1,7 @@
"use client";
import { useMemo } from "react";
import { useTranslation } from "react-i18next";
import { useAppStore } from "@/components/state-provider";
import type { AppState } from "@/lib/state/store";
import type { FilterDimension } from "@/lib/state/slices/ui/sidebar-view-types";
@@ -57,6 +58,11 @@ export function repositoryOptions(repositoriesByWorkspace: ReposByWorkspace): Op
export function useFilterValueOptions(dimension: FilterDimension): Option[] {
const snapshots = useAppStore((s) => s.kanbanMulti.snapshots);
const repositoriesByWorkspace = useAppStore((s) => s.repositories.itemsByWorkspaceId);
// `executorTypeOptions` resolves its labels through `getExecutorLabel`, which
// now reads the catalog. Subscribing here — and keeping the language in the
// memo's deps — is what makes those labels follow a runtime locale switch;
// without it the memo only recomputes when store data changes.
const { i18n } = useTranslation();
return useMemo(() => {
if (dimension === "workflow") return workflowOptions(snapshots);
@@ -64,5 +70,5 @@ export function useFilterValueOptions(dimension: FilterDimension): Option[] {
if (dimension === "executorType") return executorTypeOptions(snapshots);
if (dimension === "repository") return repositoryOptions(repositoriesByWorkspace);
return [];
}, [dimension, snapshots, repositoriesByWorkspace]);
}, [dimension, snapshots, repositoriesByWorkspace, i18n.language]);
}
@@ -158,6 +158,32 @@ const SCREENS: Array<{ name: string; url: string; allow?: string[] }> = [
],
},
{ name: "settings — task actions", url: "/settings/general/task-actions" },
{ name: "settings — plugins", url: "/settings/plugins" },
// NOT YET: "settings — executors", "settings — account security",
// "settings — account tokens". All three routes' own copy is fully migrated
// and was verified by walking them under pseudo — including every dialog and
// both marketplace tabs, since a static page scan never opens a Radix portal.
// What stops them being entries here is what the fixture renders BESIDE that
// copy, and in each case an `allow` entry would be the wrong fix:
// - `/settings/executors` renders the seeded executor profiles' NAMES, and
// the e2e fixture happens to name them `Local` and `Worktree`. That is
// user data on exactly the footing as the workspace names below, so it
// needs `findUnlocalizedText` to stop treating user data as eligible —
// listing the two values would fix this fixture and leave every developer
// instance broken under different names. The hub cards' own `Docker` and
// `Sprites.dev` labels are brand nouns; they are in the guard's
// `words.exclude` but not in `ALLOWED`, which is a real sync gap in that
// list rather than something this migration should widen it to paper over.
// - Both account routes render `not found` — the router's 404 body, because
// the e2e profile does not register the auth routes, surfaced in the
// sessions/tokens error region. It is a backend diagnostic, English by
// design (docs/i18n.md, "What the backend deliberately does NOT
// translate"), and allowlisting a payload string would hide the next real
// miss in the same region.
// The `plugins` entry above has neither problem and passes with no `allow`.
// Its marketplace entries — names, descriptions, categories, source names and
// index.json URLs — come from the catalog rather than the DOM on load.
//
// NOT YET: "settings — integrations github", "… gitlab", "… jira",
// "… linear", "… sentry". Each page's
// own copy is fully migrated (verified by running this oracle against it —
+145
View File
@@ -111,6 +111,12 @@ export const noLiteralStringOptions = {
"to",
"htmlFor",
"data-.*",
// `PluginErrorBoundary`'s console-log identifier (`slot "task-sidebar"`,
// `route "/plugins/hello"`). It is written to console.error in
// componentDidCatch and never reaches the DOM, so it is a developer
// diagnostic rather than copy. The only literal `context=` values in the
// tree are that boundary's four call sites.
"context",
// Identifiers and prefixes the caller composes into ids/testids.
"id",
"k",
@@ -1200,4 +1206,143 @@ export const i18nGuardFiles = [
"app/settings/automations/page.tsx",
"app/settings/workspace/[[]id[]]/automations/**/*.tsx",
"hooks/domains/settings/use-automation-runs.ts",
// Settings → Plugins, Settings → Account, and the plural
// `app/settings/executors/` tree (the executor list, the typed create
// routes, and the SSH connection pages). Copy lives in two new namespaces,
// `plugins` and `account`, plus additions to `executors`.
//
// `components/plugins/**` is reached from OUTSIDE Settings — the app
// sidebar, the mobile menu sheet, the chat top bar, the chat input, the task
// sidebar and the kanban top bar all render `PluginSlot`, and
// `PluginModalHost` mounts at the app root. It is listed here because this
// migration owns the Plugins feature end to end, not because those routes
// are migrated; only the two strings those files own (`PluginRouteFallback`
// and the mobile nav heading) went through `t()`. Everything else they
// render — a slot component, a modal title, a nav item's label — is
// plugin-authored and is third-party data, not our copy.
//
// Nine entries hold no JSX, or none that carries copy, so `mode: "jsx-only"`
// never inspects them and the string count reported them as ZERO while they
// owned copy that reaches the user. Each records that it was read by eye,
// and only the pseudo-locale can prove it stays that way:
// - `app/settings/executors/new/[type]/executor-types.ts` — reported 0 and
// carried ELEVEN strings, the six type labels and their descriptions.
// - `lib/executor-icons.ts` and `components/settings/executor-description.ts`
// — plain functions returning copy, the shape one step out from a config
// table and just as invisible.
// - `components/settings/plugins/use-plugin-actions.ts` (eight toasts),
// `use-plugin-config-form.ts` (five toasts plus the required-fields
// reason), `lib/plugins/sync-summary.ts` (the sync toast), and the three
// `hooks/domains/plugins/*.ts` error fallbacks. All import the
// module-level `t` rather than the hook, so each resolves when its
// callback fires and stays out of the dependency array.
// - `app/settings/executors/new/[type]/ssh-config.ts` carries no copy at
// all (snake_case config mapping); it is listed to pin that.
//
// This PR also closes the residual #2218 recorded against it:
// `validateMcpPolicy` in `profile-edit/mcp-policy-card.tsx` returned English
// ("Invalid JSON", "MCP policy must be a JSON object") because its only
// callers were the un-migrated plural tree. It now returns catalog keys and
// the card's prop is `mcpPolicyErrorKey`, matching the local validator in
// `app/settings/executor/[id]/page.tsx`.
//
// `src/settings-routes.tsx` is listed for the first time. docs/i18n.md's
// "Copy that belongs to no directory" rule says the LAST area to migrate
// adds it, because allowlisting it earlier claims a completeness nobody has:
// the two Account routes were the final inline English entries in
// `SETTINGS_ROUTES`, so every entry in that table now resolves from a
// catalog. Read the entry as a claim about that FILE, not about all of
// Settings — `app/settings/automations/page.tsx`,
// `app/settings/integrations/page.tsx` and `components/settings/agent-card.tsx`
// still hold English and belong to their own migrations. They render no
// route-table copy, which is why they do not block this entry.
//
// THREE surfaces describe the same six executor types and had already
// drifted from one another: the hub cards use the imperative mood ("Run
// agents directly…"), while the create and edit headers use the third person
// ("Runs agents directly…") — and the edit header's SSH sentence differs
// from the create header's again. Sentences that are byte-identical share a
// key (the create page reuses `executors:description*`, which
// `/settings/executor/:id` already owned); the ones that differ keep their
// own. Folding them together would have rewritten shipping English on two of
// the three with every gate green.
// `components/settings/executor-copy.test.ts` pins all three tables
// byte-for-byte, and `account/account-route-copy.test.ts` does the same for
// the two route headers. De-duplicating the tables is a behaviour change and
// belongs in its own PR.
//
// Deliberately left in English, none of it copy:
// - The executor type keys `local` / `worktree` / `local_docker` /
// `remote_docker` / `sprites` / `ssh`. They are the persisted enum, the
// create route's path segment, and a `===` comparison in four places.
// The `exec-*` executorIds are backend row ids. Only the labels beside
// them are copy.
// - The brand and protocol names `Docker`, `Sprites.dev` and `SSH` when
// they ARE the whole label. They read the same in every locale (the
// guard's own `words.exclude` lists all three), and keeping them out of
// the catalog stops the pseudo-locale transliterating a name the user
// must match against their SSH config or Sprites dashboard.
// - Every SSH fingerprint and known-hosts value. `ssh-fingerprint-trust-block`
// is a security surface: the user compares those strings
// character-for-character against what their own host reports, so both
// the observed and the pinned fingerprint are interpolated as values.
// - The SSH example values `prod`, `dev.example.com`, `22`, `ubuntu`,
// `~/.ssh/id_ed25519` and `bastion.example.com`, and the
// `ssh-agent (SSH_AUTH_SOCK)` option — a program name plus the
// environment variable it reads. `My VPS` beside them IS copy and does
// go through `t()`. `~/.ssh/config`, `$PATH`, `.tar.gz` and `index.json`
// are interpolated into their sentences for the same reason.
// - The throw in `ssh-connection-card`'s `handleSave`. It is unreachable
// while `canSave` gates the button and it signals the settings save
// coordinator, not the user — the same shape docs/i18n.md records for
// the migrated GitHub and Jira settings.
// - Plugin ids, versions, manifest keys (`config_schema`), permission
// scopes (`events:*`, `read:*`, `write:*`), webhook keys, marketplace
// source URLs, and the raw manifest JSON dump. Those are the contract.
// A plugin's display name, description, categories, icon, config-field
// labels and modal titles come from its manifest or an untrusted
// index.json, so they are third-party data — interpolated as values,
// never written into a message.
// - Account data: emails, usernames, user agents, IP addresses, and every
// API token VALUE, which never passes through `t()` at all. Token names
// are user data; only the column headers and role labels are copy.
// - `PluginErrorBoundary`'s `context` prop, now in `jsx-attributes.exclude`
// above — it is a console.error identifier that never reaches the DOM.
//
// `getExecutorLabel` returns catalog text now, so a caller that does not
// subscribe to i18n renders the previous locale until something else makes it
// re-render. Every consumer this PR owns subscribes, and
// `use-filter-value-options.ts` (the task sidebar's executor filter) gained
// `i18n.language` in its memo deps for the same reason. One consumer is left:
// `lib/sidebar/apply-view.ts` builds a group label from it inside the sidebar
// view pipeline, which this PR does not own — its labels resolve correctly on
// load and lag a runtime locale switch until the view recomputes. It belongs
// to whoever migrates `lib/sidebar`.
//
// One deliberate English change, the only one in this PR: the SSH
// running-sessions confirm read "This executor has 3 running session(s)."
// The `(s)` is the inline-plural shape docs/i18n.md rejects — the plural rule
// cannot be expressed at the call site in a language with three or six forms
// — so it is now `_one`/`_other` and reads "1 running session" /
// "3 running sessions".
//
// Four dates moved off `toLocaleString()` onto `lib/i18n/formats`'
// `formatDateTime`, and the marketplace star count off `toLocaleString()`
// onto `formatNumber`, so they follow the active locale rather than the
// browser's — the same call `storage-quarantine.ts` made.
"app/settings/executors/**/*.{ts,tsx}",
"components/settings/account/**/*.{ts,tsx}",
"components/settings/executor-description.ts",
"components/settings/plugins/**/*.{ts,tsx}",
"components/settings/ssh-agent-readiness-card.tsx",
"components/settings/ssh-connection-card.tsx",
"components/settings/ssh-connection-form.tsx",
"components/settings/ssh-fingerprint-trust-block.tsx",
"components/settings/ssh-sessions-card.tsx",
"components/settings/ssh-settings.tsx",
"components/plugins/**/*.{ts,tsx}",
"hooks/domains/plugins/*.ts",
"lib/executor-icons.ts",
"lib/plugins/sync-summary.ts",
"src/settings-routes.tsx",
];
@@ -3,6 +3,7 @@
import { useEffect, useState } from "react";
import { toast } from "@/lib/toast/sonner";
import { getPluginSettings, updatePluginSettings } from "@/lib/api/domains/plugins-api";
import { t } from "@/lib/i18n";
export type AutoUpdateSettings = {
/** The instance-wide auto-update default (applies to plugins without an override). */
@@ -49,7 +50,7 @@ export function useAutoUpdateSettings(): AutoUpdateSettings {
setState(res.auto_update_default);
} catch (err) {
setState(prev); // roll back
toast.error(err instanceof Error ? err.message : "Failed to update auto-update setting");
toast.error(err instanceof Error ? err.message : t("plugins:failedToUpdateAutoUpdate"));
}
};
@@ -6,6 +6,7 @@ import {
refreshMarketplace,
type CatalogQuery,
} from "@/lib/api/domains/marketplace-api";
import { t } from "@/lib/i18n";
import type { MarketplaceCatalog } from "@/lib/types/plugins";
const EMPTY_CATALOG: MarketplaceCatalog = { plugins: [], sources: [] };
@@ -37,7 +38,7 @@ export function useMarketplace(query: CatalogQuery) {
})
.catch((err) => {
if (cancelled) return;
setError(err instanceof Error ? err.message : "Failed to load marketplace");
setError(err instanceof Error ? err.message : t("plugins:failedToLoadMarketplace"));
})
.finally(() => {
if (!cancelled) setLoading(false);
@@ -2,6 +2,7 @@
import { useEffect } from "react";
import { listPlugins } from "@/lib/api/domains/plugins-api";
import { t } from "@/lib/i18n";
import { useAppStore } from "@/components/state-provider";
/**
@@ -27,7 +28,7 @@ export function usePlugins() {
setPlugins(response);
})
.catch((err) => {
setPluginsError(err instanceof Error ? err.message : "Failed to load plugins");
setPluginsError(err instanceof Error ? err.message : t("plugins:failedToLoadPlugins"));
})
.finally(() => {
setPluginsLoading(false);
+3
View File
@@ -22,9 +22,12 @@ describe("executor icons", () => {
});
it("returns human-readable labels for known executor types", () => {
expect(getExecutorLabel("local")).toBe("Local");
expect(getExecutorLabel("worktree")).toBe("Worktree");
expect(getExecutorLabel("local_docker")).toBe("Local Docker");
expect(getExecutorLabel("remote_docker")).toBe("Remote Docker");
expect(getExecutorLabel("sprites")).toBe("Sprites.dev");
expect(getExecutorLabel("ssh")).toBe("SSH");
expect(getExecutorLabel("does-not-exist")).toBe("does-not-exist");
});
});
+23 -6
View File
@@ -10,6 +10,8 @@ import {
IconTerminal2,
} from "@tabler/icons-react";
import { t } from "@/lib/i18n";
export const EXECUTOR_ICON_MAP: Record<string, typeof IconFolder> = {
local: IconFolder,
worktree: IconFolders,
@@ -23,17 +25,32 @@ export function getExecutorIcon(type: string): typeof IconFolder {
return EXECUTOR_ICON_MAP[type] ?? IconFolder;
}
const EXECUTOR_LABEL_MAP: Record<string, string> = {
local: "Local",
worktree: "Worktree",
local_docker: "Local Docker",
remote_docker: "Remote Docker",
// Brand and protocol names read the same in every locale, so they are values
// rather than copy — the guard's `words.exclude` lists both. Keeping them out
// of the catalog also keeps the pseudo-locale from transliterating them into
// something the user cannot match against their own SSH config or Sprites
// dashboard.
const EXECUTOR_BRAND_LABEL_MAP: Record<string, string> = {
sprites: "Sprites.dev",
ssh: "SSH",
};
// Keys, not messages: `t` resolves inside getExecutorLabel at call time, so
// the label follows a locale switch. Assigning t() here would freeze it at the
// boot locale.
const EXECUTOR_LABEL_KEY_MAP: Record<string, string> = {
local: "executors:typeLocal",
worktree: "executors:typeWorktree",
local_docker: "executors:localDocker",
remote_docker: "executors:remoteDocker",
};
export function getExecutorLabel(type: string): string {
return EXECUTOR_LABEL_MAP[type] ?? type;
const brand = EXECUTOR_BRAND_LABEL_MAP[type];
if (brand) return brand;
const key = EXECUTOR_LABEL_KEY_MAP[type];
// An unmapped type echoes the raw wire value, which is an identifier.
return key ? t(key) : type;
}
/**
+9 -5
View File
@@ -5,14 +5,18 @@
* surfaced separately (an inline `plugins-sync-errors` region) — they do
* not affect this summary line.
*/
import { t } from "@/lib/i18n";
import type { SyncResult } from "@/lib/types/plugins";
export function summarizeSyncResult(result: SyncResult): string {
const parts: string[] = [];
if (result.added.length > 0) parts.push(`${result.added.length} sideloaded`);
if (result.installed.length > 0) parts.push(`${result.installed.length} installed`);
if (result.missing.length > 0) parts.push(`${result.missing.length} missing`);
if (result.added.length > 0)
parts.push(t("plugins:syncSideloaded", { count: result.added.length }));
if (result.installed.length > 0)
parts.push(t("plugins:syncInstalled", { count: result.installed.length }));
if (result.missing.length > 0)
parts.push(t("plugins:syncMissing", { count: result.missing.length }));
if (parts.length === 0) return "Everything up to date";
return `Sync: ${parts.join(", ")}`;
if (parts.length === 0) return t("plugins:syncUpToDate");
return t("plugins:syncSummary", { parts: parts.join(", ") });
}
+42
View File
@@ -0,0 +1,42 @@
{
"actions": "Actions",
"activeSessions": "Active sessions",
"apiTokens": "API tokens",
"apiTokensBlurb": "Personal access tokens authenticate scripts and CLIs as you. Revoke a token immediately if it may have leaked.",
"cancel": "Cancel",
"changePassword": "Change password",
"couldNotChangePassword": "Could not change password.",
"couldNotCreateToken": "Could not create token.",
"createApiToken": "Create API token",
"createApiTokenDescription": "Grants the same access as your account to any script or CLI that uses it. Give it a name so you can recognize it later.",
"createToken": "Create token",
"created": "Created",
"creating": "Creating...",
"currentPassword": "Current password",
"device": "Device",
"done": "Done",
"failedToLoadSessions": "Failed to load sessions.",
"failedToLoadTokens": "Failed to load tokens.",
"ipAddress": "IP",
"lastSeen": "Last seen",
"lastUsed": "Last used",
"loadingSessions": "Loading sessions...",
"loadingTokens": "Loading tokens...",
"name": "Name",
"never": "Never",
"newPassword": "New password",
"newToken": "New token",
"noTokensYet": "No tokens yet.",
"password": "Password",
"passwordUpdated": "Password updated.",
"revoke": "Revoke",
"saving": "Saving...",
"securityPageDescription": "Change your password and review devices signed in to your account.",
"securityPageTitle": "Profile & password",
"signOut": "Sign out",
"thisDevice": "This device",
"tokenCreated": "Token created",
"tokenShownOnlyOnce": "This is shown only once — copy it now and store it securely.",
"tokensPageDescription": "Personal access tokens for scripts and CLIs acting as you.",
"tokensPageTitle": "API tokens"
}
+87
View File
@@ -1,8 +1,11 @@
{
"action": "Action",
"add": "Add",
"addASpritesApiKeyBeforeCreating": "Add a Sprites API key before creating the profile.",
"addDockerfileContentBeforeCreating": "Add Dockerfile content before creating the profile.",
"addRule": "Add Rule",
"advanced": "Advanced",
"agent": "Agent",
"allow": "Allow",
"allowlistGithubPlaywright": "Allowlist GitHub + Playwright",
"apiKey": "API Key",
@@ -14,6 +17,7 @@
"buildFailedWithMessage": "Build failed: {{message}}",
"buildFailedWithStatus": "Build failed ({{status}}): {{text}}",
"buildImage": "Build Image",
"buildThisDockerImageBeforeCreating": "Build this Docker image before creating the profile.",
"building": "Building...",
"checking": "Checking...",
"chooseAnExecutorTypeToRun": "Choose an executor type to run environments on your infrastructure.",
@@ -24,21 +28,26 @@
"connected": "Connected",
"connectedActiveSprites_one": "Connected. {{count}} active sprite.",
"connectedActiveSprites_other": "Connected. {{count}} active sprites.",
"connection": "Connection",
"connectionSettings": "Connection Settings",
"connectionTestFailed": "Connection test failed",
"connectionTestPassed": "Connection test passed",
"connectsToARemoteDockerHost": "Connects to a remote Docker host. The repository will be cloned inside the container.",
"copyAuthFiles": "Copy auth files",
"createANewProfileForThis": "Create a new profile for this executor. You can configure scripts and environment variables on the profile page.",
"createExecutor": "Create Executor",
"createNewProfile": "Create New Profile",
"createNewSecret": "Create new secret...",
"createProfile": "Create Profile",
"creating": "Creating...",
"creatingProfile": "Creating profile...",
"defaultNoTls": "Default (no TLS)",
"defineNetworkAccessRulesAppliedWhen": "Define network access rules applied when a sprite is created for this profile.",
"defineTheDockerImageBuildAnd": "Define the Docker image. Build and test it here.",
"delete": "Delete",
"deleteExecutor": "Delete Executor",
"deleteProfile": "Delete Profile",
"deleteProfileConfirm": "Are you sure you want to delete \"{{name}}\"? This action cannot be undone.",
"deleting": "Deleting...",
"deny": "Deny",
"descriptionCustom": "Custom executor.",
@@ -46,6 +55,8 @@
"descriptionLocalPc": "Runs agents directly in the repository folder.",
"descriptionRemoteDocker": "Connects to a remote Docker host.",
"descriptionSprites": "Runs agents in Sprites.dev cloud sandboxes.",
"descriptionSsh": "Runs agents on a trusted Linux amd64 or macOS host over SSH.",
"descriptionSshCreate": "Connects to a remote host over SSH and runs agentctl there.",
"descriptionWorktree": "Creates git worktrees for isolated agent sessions.",
"differentConfigurationsForThisExecutorEach": "Different configurations for this executor. Each profile can have its own prepare script, environment variables, and settings.",
"disabled": "Disabled",
@@ -59,6 +70,8 @@
"eGMyApiToken": "e.g. {{example}}",
"eGProductionDevelopment": "e.g. {{example}}",
"enabled": "Enabled",
"enterAProfileName": "Enter a profile name.",
"enterAnImageTagBeforeCreating": "Enter an image tag before creating the profile.",
"envVarsConfiguredCount_one": "{{count}} configured",
"envVarsConfiguredCount_other": "{{count}} configured",
"environmentVariables": "Environment Variables",
@@ -69,6 +82,7 @@
"failedToConnectToBackend": "Failed to connect to backend",
"failedToCreateProfile": "Failed to create profile",
"failedToCreateSecret": "Failed to create secret",
"failedToLoadExecutor": "Failed to load executor",
"failedToSaveProfile": "Failed to save profile",
"filesSelected": "Files Selected",
"gitIdentity": "Git Identity",
@@ -78,6 +92,13 @@
"gitUserEmail": "Git User Email",
"gitUserName": "Git User Name",
"goToExecutors": "Go to Executors",
"host": "Host",
"hubDescription": "Executor profiles define how and where agents run. Each profile configures an execution environment with scripts, environment variables, and MCP policies.",
"hubDescriptionDocker": "Run Docker containers on this machine.",
"hubDescriptionLocal": "Run agents directly in the repository folder.",
"hubDescriptionSprites": "Run agents in Sprites.dev cloud sandboxes.",
"hubDescriptionSsh": "Connect to a remote host over SSH and run agentctl there.",
"hubDescriptionWorktree": "Create git worktrees for isolated agent sessions.",
"image": "Image",
"imageTag": "Image Tag",
"include": "Include",
@@ -88,6 +109,7 @@
"key": "Key",
"loading": "Loading...",
"loadingEditor": "Loading editor...",
"loadingExecutor": "Loading executor...",
"localDocker": "Local Docker",
"localDockerExecutor": "Local Docker Executor",
"localGitIdentityNotDetected": "Local git user.name/user.email not detected on this machine",
@@ -100,6 +122,8 @@
"name": "Name",
"networkPolicies": "Network Policies",
"newProfile": "New Profile",
"newSshExecutor": "New SSH Executor",
"newTypeProfile": "New {{type}} Profile",
"noDockerContainers": "No Docker containers.",
"noEnvironmentVariablesConfigured": "No environment variables configured.",
"noEnvironmentVariablesConfiguredAddOne": "No environment variables configured. Add one below.",
@@ -108,6 +132,7 @@
"noResponseBody": "No response body",
"noTransferableCredentialsFound": "No transferable credentials found.",
"none": "None",
"notAnSshExecutor": "Executor {{id}} is not an SSH executor",
"notConfigured": "Not Configured",
"onlyHttpSse": "Only HTTP/SSE",
"onlyStdio": "Only stdio",
@@ -117,6 +142,7 @@
"pasteYourSecretValue": "Paste your secret value...",
"pathToTlsCertificatesForThe": "Path to TLS certificates for the Docker host.",
"personalAccessTokenForCloningRepositories": "Personal access token for cloning repositories inside the container. Auto-detected from host environment if not set.",
"port": "Port",
"prepareScript": "Prepare Script",
"prepareScriptBadge": "Prepare script",
"prepareScriptDescriptionLocal": "Runs on the host machine before the agent starts.",
@@ -129,6 +155,7 @@
"profiles": "Profiles",
"provideSecret": "Provide secret",
"quickPresets": "Quick presets",
"refresh": "Refresh",
"relatedDockerContainersRemoved_one": "{{count}} related Docker container will also be removed.",
"relatedDockerContainersRemoved_other": "{{count}} related Docker containers will also be removed.",
"remoteAuthMethod": "Remote auth method",
@@ -149,8 +176,63 @@
"selectOrCreateASecret": "Select or create a secret...",
"selectSecret": "Select secret...",
"selectTheSecretContainingYourSprites": "Select the secret containing your Sprites.dev API token.",
"session": "Session",
"setACustomNameAndEmail": "Set a custom name and email for remote git commits.",
"setEnvVarViaStoredSecret": "Set <1>{{envVar}}</1> via a stored secret",
"spritesApiKeyIsRequired": "Sprites API key is required.",
"spritesTokenIsRequired": "Sprites token is required.",
"sshActiveSessions": "Active sessions",
"sshActiveSessionsDescription": "Sessions currently running on this SSH host. Refreshes every 90 seconds.",
"sshAvailableAgents": "Available agents on this host",
"sshAvailableAgentsDescription": "Probes the remote {{pathVar}} for each enabled agent under the chosen login shell. Copy the install hint and run it on the remote when an agent is missing.",
"sshBinary": "Binary",
"sshConfirmRunningSessions_one": "This executor has {{count}} running session. They will keep running on the current host. Only new sessions started after save will use the updated config. Continue?",
"sshConfirmRunningSessions_other": "This executor has {{count}} running sessions. They will keep running on the current host. Only new sessions started after save will use the updated config. Continue?",
"sshConnectionDescription": "Run an agent on Linux amd64 or macOS hosts you can reach over SSH.",
"sshConnectionTestFailed": "Connection test failed",
"sshConnectionTestPassed": "Connection test passed",
"sshCopyInstallHint": "Copy install hint",
"sshCreateDescription": "Connect to a remote Linux amd64 or macOS host and run agentctl there.",
"sshExecutorPageDescription": "Edit the connection settings or re-trust the host. Existing sessions keep their snapshot of the previous config.",
"sshFailedToLoadSessions": "Failed to load sessions",
"sshFailedToProbeAgents": "Failed to probe agents",
"sshFailedToReachBackend": "Failed to reach backend",
"sshFailedToSaveExecutor": "Failed to save executor",
"sshFailedToSaveShell": "Failed to save shell",
"sshFingerprintChanged": "Warning: this fingerprint differs from the one currently pinned (<1>{{fingerprint}}</1>). Trusting it overwrites the pinned key. If you didn’t expect a host re-key, stop here.",
"sshFingerprintObserved": "Host fingerprint observed:",
"sshHostAliasHint": "If set, inherits HostName / Port / User / IdentityFile / ProxyJump from your config.",
"sshHostAliasLabel": "Host alias from {{path}} (optional)",
"sshIdentityFileHint": "Passphrase-protected keys must be loaded into ssh-agent first.",
"sshIdentityFilePath": "Identity file path",
"sshIdentitySource": "Identity source",
"sshIdentitySourceFile": "Identity file (private key path)",
"sshInstallHint": "Install hint",
"sshInstallHintCopied": "Install hint copied",
"sshInstalled": "Installed",
"sshLocalForward": "Local fwd",
"sshLoginShell": "Login shell",
"sshMissing": "Missing",
"sshNamePlaceholder": "My VPS",
"sshNoActiveSessions": "No active sessions.",
"sshNoEnabledAgents": "No enabled agents to probe.",
"sshNoHintAvailable": "No hint available",
"sshPinnedFingerprint": "Pinned fingerprint:",
"sshProbeAgents": "Probe agents",
"sshProbeError": "Probe error",
"sshProbeHint": "Click \"{{button}}\" to check which agents are installed on the remote.",
"sshProxyJumpHint": "Single bastion hop. Chained jumps are not supported.",
"sshProxyJumpLabel": "ProxyJump (optional)",
"sshRemotePort": "Remote port",
"sshReprobe": "Re-probe",
"sshShellNotDetected": "(not detected)",
"sshTestAndTrustToSave": "Test the connection and trust its fingerprint to save.",
"sshTestConnection": "Test connection",
"sshTestResultStale": "You changed a connection field after this test ran. Run Test Connection again before trusting and saving.",
"sshTrustThisHost": "<0>Trust this host.</0> I’ve verified the fingerprint above matches my server. Future connections that report a different fingerprint will be refused.",
"sshTrusted": "Trusted",
"sshUnverified": "Unverified",
"status": "Status",
"stop": "Stop",
"success": "Success",
"task": "Task",
@@ -160,12 +242,17 @@
"tlsCertificatePath": "TLS certificate path",
"tlsVerify": "TLS verify",
"tokenConfiguredButUnableToConnect": "Token configured but unable to connect.",
"typeLocal": "Local",
"typeTokenToConfirmDeletion": "Type \"{{token}}\" to confirm deletion. This action cannot be undone.",
"typeWorktree": "Worktree",
"unknownError": "Unknown error",
"unknownExecutorType": "Unknown executor type",
"untrackedTask": "Untracked",
"uptime": "Uptime",
"useDefaults": "Use defaults",
"useLocalGitConfig": "Use local git config",
"usedByRemoteExecutorsForCommit": "Used by remote executors for commit author configuration.",
"user": "User",
"usesTheLocalDockerDaemonOn": "Uses the local Docker daemon on this machine.",
"value": "Value",
"verifyingConnection": "Verifying connection..."
+116
View File
@@ -0,0 +1,116 @@
{
"add": "Add",
"allCategories": "All categories",
"autoUpdate": "Auto-update",
"autoUpdateDescription": "Check the marketplace periodically and install newer versions of active plugins. Applies to every plugin unless overridden per plugin below.",
"autoUpdateFor": "Auto-update for {{name}}",
"autoUpdateTitle": "Automatically update plugins",
"byAuthor": "by {{author}}",
"cancel": "Cancel",
"category": "Category",
"confirmUninstall": "Confirm uninstall",
"disable": "Disable",
"dropzoneHint": "Drag and drop a {{extension}} package here, or click to browse",
"enable": "Enable",
"failedToAddSource": "Failed to add source",
"failedToDisable": "Failed to disable {{name}}",
"failedToEnable": "Failed to enable {{name}}",
"failedToInstallPlugin": "Failed to install plugin",
"failedToLoadMarketplace": "Failed to load marketplace",
"failedToLoadPlugins": "Failed to load plugins",
"failedToLoadSettings": "Failed to load plugin settings",
"failedToRemoveSource": "Failed to remove source",
"failedToSaveSettings": "Failed to save plugin settings",
"failedToSetAutoUpdateFor": "Failed to update auto-update for {{name}}",
"failedToSyncPlugins": "Failed to sync plugins",
"failedToUninstall": "Failed to uninstall {{name}}",
"failedToUpdateAutoUpdate": "Failed to update auto-update setting",
"failedToUpdateSource": "Failed to update source",
"fromUrl": "From URL",
"hideRaw": "Hide raw",
"install": "Install",
"installPlugin": "Install plugin",
"installPluginDescription": "Install a plugin package from a URL, or upload a {{extension}} file directly.",
"installed": "Installed",
"installedPlugins": "Installed plugins",
"installing": "Installing…",
"loadingMarketplace": "Loading marketplace…",
"loadingPlugins": "Loading plugins...",
"loadingSelectedUtilityAgent": "Loading selected utility agent...",
"loadingSettings": "Loading settings...",
"loadingUtilityAgents": "Loading utility agents...",
"manifest": "Manifest",
"manifestApiVersion": "API version",
"manifestAuthor": "Author",
"manifestCapabilities": "Capabilities",
"manifestInstalled": "Installed",
"manifestSigned": "Signed",
"manifestVersion": "Version",
"manifestWebhooks": "Webhooks",
"marketplaceSources": "Marketplace sources",
"marketplaceSourcesDescription": "Add a team or corporate registry to browse its plugins alongside the official catalog. Each source serves an {{document}} document.",
"no": "no",
"noDeclaredSettings": "This plugin does not declare any settings (no <1>config_schema</1> in its manifest).",
"noInstalledPluginWithId": "No installed plugin with id <1>{{id}}</1>.",
"noPluginsAvailable": "No plugins available yet. Check back soon, or add a marketplace source with your own.",
"noPluginsMatchSearch": "No plugins match your search. Try clearing the filters.",
"noPluginsYet": "No plugins yet. Install one from the Browse tab, or upload a package.",
"notSet": "Not set",
"override": "override",
"packageUrl": "Package URL",
"pluginInstalled": "{{name}} installed",
"pluginPageFailedToLoad": "This plugin page failed to load.",
"pluginsAvailable_one": "{{count}} plugin available",
"pluginsAvailable_other": "{{count}} plugins available",
"refresh": "Refresh",
"removeSource": "Remove {{name}}",
"repo": "Repo",
"requiredFields": "Required: {{fields}}",
"reset": "Reset",
"resetAutoUpdateFor": "Reset auto-update for {{name}} to the default",
"savingRestartsPlugin": "Saving restarts the plugin so the new settings take effect.",
"searchPlugins": "Search plugins…",
"selectAUtilityAgent": "Select a utility agent...",
"selectPlaceholder": "Select...",
"selectedUtilityAgentUnavailable": "Selected utility agent unavailable",
"settings": "Settings",
"settingsDescription": "Browse the marketplace, then install, enable, disable, and uninstall kandev plugins.",
"settingsSaved": "Plugin settings saved",
"settingsSavedReloadFailed": "Settings saved, but reloading them failed — refresh to confirm.",
"sortMostStars": "Most stars",
"sortName": "Name",
"sortRecentlyUpdated": "Recently updated",
"sourceNamePlaceholder": "Source name (e.g. Acme Internal)",
"sourceOfficial": "Official",
"sourceUnreachable": "Unreachable",
"sourceUnreachableDetail": "<0>{{name}}</0> is unreachable{{detail}} — its plugins are hidden.",
"sources": "Sources",
"statusActive": "Active",
"statusDisabled": "Disabled",
"statusError": "Error",
"statusRegistered": "Registered",
"statusUninstalled": "Uninstalled",
"sync": "Sync",
"syncInstalled_one": "{{count}} installed",
"syncInstalled_other": "{{count}} installed",
"syncMissing_one": "{{count}} missing",
"syncMissing_other": "{{count}} missing",
"syncSideloaded_one": "{{count}} sideloaded",
"syncSideloaded_other": "{{count}} sideloaded",
"syncSummary": "Sync: {{parts}}",
"syncUpToDate": "Everything up to date",
"tabBrowse": "Browse",
"tabInstalled": "Installed",
"thisPlugin": "this plugin",
"uninstall": "Uninstall",
"uninstallConfirm": "This will permanently remove <2>{{name}}</2> and revoke its API key. This action cannot be undone.",
"uninstallPlugin": "Uninstall plugin",
"unsigned": "unsigned",
"update": "Update",
"updateToVersion": "Update to v{{version}}",
"updating": "Updating…",
"uploadFile": "Upload file",
"utilityAgentsUnavailable": "Utility agents unavailable",
"viewRaw": "View raw",
"yes": "yes"
}
+2
View File
@@ -291,6 +291,7 @@
"searchEditors": "Search editors...",
"secrets": "Secrets",
"secretValue": "Secret value",
"security": "Security",
"selectADefaultEditor": "Select a default editor",
"selectAShell": "Select a shell",
"sendTestNotification": "Send test notification",
@@ -355,6 +356,7 @@
"thisProfileWillBeRemovedWhen": "This profile will be removed when you save.",
"thisSecret": "this secret",
"thisWillPermanentlyRemoveSecret": "This will permanently remove <2>{{name}}</2>. This action cannot be undone.",
"tokens": "Tokens",
"transcriptNavigation": "Transcript Navigation",
"tree": "Tree",
"unableToConnect": "Unable to connect.",
+42
View File
@@ -0,0 +1,42 @@
{
"actions": "Àćţĩōńś",
"activeSessions": "Àćţĩvē śēśśĩōńś",
"apiTokens": "ÀƤĨ ţōķēńś",
"apiTokensBlurb": "Ƥēŕśōńàĺ àććēśś ţōķēńś àũţĥēńţĩćàţē śćŕĩƥţś àńď ĆĹĨś àś ŷōũ. Ŕēvōķē à ţōķēń ĩḿḿēďĩàţēĺŷ ĩƒ ĩţ ḿàŷ ĥàvē ĺēàķēď.",
"cancel": "Ćàńćēĺ",
"changePassword": "Ćĥàńĝē ƥàśśŵōŕď",
"couldNotChangePassword": "Ćōũĺď ńōţ ćĥàńĝē ƥàśśŵōŕď.",
"couldNotCreateToken": "Ćōũĺď ńōţ ćŕēàţē ţōķēń.",
"createApiToken": "Ćŕēàţē ÀƤĨ ţōķēń",
"createApiTokenDescription": "Ĝŕàńţś ţĥē śàḿē àććēśś àś ŷōũŕ àććōũńţ ţō àńŷ śćŕĩƥţ ōŕ ĆĹĨ ţĥàţ ũśēś ĩţ. Ĝĩvē ĩţ à ńàḿē śō ŷōũ ćàń ŕēćōĝńĩźē ĩţ ĺàţēŕ.",
"createToken": "Ćŕēàţē ţōķēń",
"created": "Ćŕēàţēď",
"creating": "Ćŕēàţĩńĝ...",
"currentPassword": "Ćũŕŕēńţ ƥàśśŵōŕď",
"device": "Ďēvĩćē",
"done": "Ďōńē",
"failedToLoadSessions": "Ƒàĩĺēď ţō ĺōàď śēśśĩōńś.",
"failedToLoadTokens": "Ƒàĩĺēď ţō ĺōàď ţōķēńś.",
"ipAddress": "ĨƤ",
"lastSeen": "Ĺàśţ śēēń",
"lastUsed": "Ĺàśţ ũśēď",
"loadingSessions": "Ĺōàďĩńĝ śēśśĩōńś...",
"loadingTokens": "Ĺōàďĩńĝ ţōķēńś...",
"name": "Ńàḿē",
"never": "Ńēvēŕ",
"newPassword": "Ńēŵ ƥàśśŵōŕď",
"newToken": "Ńēŵ ţōķēń",
"noTokensYet": "Ńō ţōķēńś ŷēţ.",
"password": "Ƥàśśŵōŕď",
"passwordUpdated": "Ƥàśśŵōŕď ũƥďàţēď.",
"revoke": "Ŕēvōķē",
"saving": "Śàvĩńĝ...",
"securityPageDescription": "Ćĥàńĝē ŷōũŕ ƥàśśŵōŕď àńď ŕēvĩēŵ ďēvĩćēś śĩĝńēď ĩń ţō ŷōũŕ àććōũńţ.",
"securityPageTitle": "Ƥŕōƒĩĺē & ƥàśśŵōŕď",
"signOut": "Śĩĝń ōũţ",
"thisDevice": "Ţĥĩś ďēvĩćē",
"tokenCreated": "Ţōķēń ćŕēàţēď",
"tokenShownOnlyOnce": "Ţĥĩś ĩś śĥōŵń ōńĺŷ ōńćē — ćōƥŷ ĩţ ńōŵ àńď śţōŕē ĩţ śēćũŕēĺŷ.",
"tokensPageDescription": "Ƥēŕśōńàĺ àććēśś ţōķēńś ƒōŕ śćŕĩƥţś àńď ĆĹĨś àćţĩńĝ àś ŷōũ.",
"tokensPageTitle": "ÀƤĨ ţōķēńś"
}
@@ -1,8 +1,11 @@
{
"action": "Àćţĩōń",
"add": "Àďď",
"addASpritesApiKeyBeforeCreating": "Àďď à Śƥŕĩţēś ÀƤĨ ķēŷ ƀēƒōŕē ćŕēàţĩńĝ ţĥē ƥŕōƒĩĺē.",
"addDockerfileContentBeforeCreating": "Àďď Ďōćķēŕƒĩĺē ćōńţēńţ ƀēƒōŕē ćŕēàţĩńĝ ţĥē ƥŕōƒĩĺē.",
"addRule": "Àďď Ŕũĺē",
"advanced": "Àďvàńćēď",
"agent": "Àĝēńţ",
"allow": "Àĺĺōŵ",
"allowlistGithubPlaywright": "Àĺĺōŵĺĩśţ ĜĩţĤũƀ + Ƥĺàŷŵŕĩĝĥţ",
"apiKey": "ÀƤĨ Ķēŷ",
@@ -14,6 +17,7 @@
"buildFailedWithMessage": "Ɓũĩĺď ƒàĩĺēď: {{message}}",
"buildFailedWithStatus": "Ɓũĩĺď ƒàĩĺēď ({{status}}): {{text}}",
"buildImage": "Ɓũĩĺď Ĩḿàĝē",
"buildThisDockerImageBeforeCreating": "Ɓũĩĺď ţĥĩś Ďōćķēŕ ĩḿàĝē ƀēƒōŕē ćŕēàţĩńĝ ţĥē ƥŕōƒĩĺē.",
"building": "Ɓũĩĺďĩńĝ...",
"checking": "Ćĥēćķĩńĝ...",
"chooseAnExecutorTypeToRun": "Ćĥōōśē àń ēxēćũţōŕ ţŷƥē ţō ŕũń ēńvĩŕōńḿēńţś ōń ŷōũŕ ĩńƒŕàśţŕũćţũŕē.",
@@ -24,21 +28,26 @@
"connected": "Ćōńńēćţēď",
"connectedActiveSprites_one": "Ćōńńēćţēď. {{count}} àćţĩvē śƥŕĩţē.",
"connectedActiveSprites_other": "Ćōńńēćţēď. {{count}} àćţĩvē śƥŕĩţēś.",
"connection": "Ćōńńēćţĩōń",
"connectionSettings": "Ćōńńēćţĩōń Śēţţĩńĝś",
"connectionTestFailed": "Ćōńńēćţĩōń ţēśţ ƒàĩĺēď",
"connectionTestPassed": "Ćōńńēćţĩōń ţēśţ ƥàśśēď",
"connectsToARemoteDockerHost": "Ćōńńēćţś ţō à ŕēḿōţē Ďōćķēŕ ĥōśţ. Ţĥē ŕēƥōśĩţōŕŷ ŵĩĺĺ ƀē ćĺōńēď ĩńśĩďē ţĥē ćōńţàĩńēŕ.",
"copyAuthFiles": "Ćōƥŷ àũţĥ ƒĩĺēś",
"createANewProfileForThis": "Ćŕēàţē à ńēŵ ƥŕōƒĩĺē ƒōŕ ţĥĩś ēxēćũţōŕ. Ŷōũ ćàń ćōńƒĩĝũŕē śćŕĩƥţś àńď ēńvĩŕōńḿēńţ vàŕĩàƀĺēś ōń ţĥē ƥŕōƒĩĺē ƥàĝē.",
"createExecutor": "Ćŕēàţē Ēxēćũţōŕ",
"createNewProfile": "Ćŕēàţē Ńēŵ Ƥŕōƒĩĺē",
"createNewSecret": "Ćŕēàţē ńēŵ śēćŕēţ...",
"createProfile": "Ćŕēàţē Ƥŕōƒĩĺē",
"creating": "Ćŕēàţĩńĝ...",
"creatingProfile": "Ćŕēàţĩńĝ ƥŕōƒĩĺē...",
"defaultNoTls": "Ďēƒàũĺţ (ńō ŢĹŚ)",
"defineNetworkAccessRulesAppliedWhen": "Ďēƒĩńē ńēţŵōŕķ àććēśś ŕũĺēś àƥƥĺĩēď ŵĥēń à śƥŕĩţē ĩś ćŕēàţēď ƒōŕ ţĥĩś ƥŕōƒĩĺē.",
"defineTheDockerImageBuildAnd": "Ďēƒĩńē ţĥē Ďōćķēŕ ĩḿàĝē. Ɓũĩĺď àńď ţēśţ ĩţ ĥēŕē.",
"delete": "Ďēĺēţē",
"deleteExecutor": "Ďēĺēţē Ēxēćũţōŕ",
"deleteProfile": "Ďēĺēţē Ƥŕōƒĩĺē",
"deleteProfileConfirm": "Àŕē ŷōũ śũŕē ŷōũ ŵàńţ ţō ďēĺēţē \"{{name}}\"? Ţĥĩś àćţĩōń ćàńńōţ ƀē ũńďōńē.",
"deleting": "Ďēĺēţĩńĝ...",
"deny": "Ďēńŷ",
"descriptionCustom": "Ćũśţōḿ ēxēćũţōŕ.",
@@ -46,6 +55,8 @@
"descriptionLocalPc": "Ŕũńś àĝēńţś ďĩŕēćţĺŷ ĩń ţĥē ŕēƥōśĩţōŕŷ ƒōĺďēŕ.",
"descriptionRemoteDocker": "Ćōńńēćţś ţō à ŕēḿōţē Ďōćķēŕ ĥōśţ.",
"descriptionSprites": "Ŕũńś àĝēńţś ĩń Śƥŕĩţēś.ďēv ćĺōũď śàńďƀōxēś.",
"descriptionSsh": "Ŕũńś àĝēńţś ōń à ţŕũśţēď Ĺĩńũx àḿď64 ōŕ ḿàćŌŚ ĥōśţ ōvēŕ ŚŚĤ.",
"descriptionSshCreate": "Ćōńńēćţś ţō à ŕēḿōţē ĥōśţ ōvēŕ ŚŚĤ àńď ŕũńś àĝēńţćţĺ ţĥēŕē.",
"descriptionWorktree": "Ćŕēàţēś ĝĩţ ŵōŕķţŕēēś ƒōŕ ĩśōĺàţēď àĝēńţ śēśśĩōńś.",
"differentConfigurationsForThisExecutorEach": "Ďĩƒƒēŕēńţ ćōńƒĩĝũŕàţĩōńś ƒōŕ ţĥĩś ēxēćũţōŕ. Ēàćĥ ƥŕōƒĩĺē ćàń ĥàvē ĩţś ōŵń ƥŕēƥàŕē śćŕĩƥţ, ēńvĩŕōńḿēńţ vàŕĩàƀĺēś, àńď śēţţĩńĝś.",
"disabled": "Ďĩśàƀĺēď",
@@ -59,6 +70,8 @@
"eGMyApiToken": "ē.ĝ. {{example}}",
"eGProductionDevelopment": "ē.ĝ. {{example}}",
"enabled": "Ēńàƀĺēď",
"enterAProfileName": "Ēńţēŕ à ƥŕōƒĩĺē ńàḿē.",
"enterAnImageTagBeforeCreating": "Ēńţēŕ àń ĩḿàĝē ţàĝ ƀēƒōŕē ćŕēàţĩńĝ ţĥē ƥŕōƒĩĺē.",
"envVarsConfiguredCount_one": "{{count}} ćōńƒĩĝũŕēď",
"envVarsConfiguredCount_other": "{{count}} ćōńƒĩĝũŕēď",
"environmentVariables": "Ēńvĩŕōńḿēńţ Vàŕĩàƀĺēś",
@@ -69,6 +82,7 @@
"failedToConnectToBackend": "Ƒàĩĺēď ţō ćōńńēćţ ţō ƀàćķēńď",
"failedToCreateProfile": "Ƒàĩĺēď ţō ćŕēàţē ƥŕōƒĩĺē",
"failedToCreateSecret": "Ƒàĩĺēď ţō ćŕēàţē śēćŕēţ",
"failedToLoadExecutor": "Ƒàĩĺēď ţō ĺōàď ēxēćũţōŕ",
"failedToSaveProfile": "Ƒàĩĺēď ţō śàvē ƥŕōƒĩĺē",
"filesSelected": "Ƒĩĺēś Śēĺēćţēď",
"gitIdentity": "Ĝĩţ Ĩďēńţĩţŷ",
@@ -78,6 +92,13 @@
"gitUserEmail": "Ĝĩţ Ũśēŕ Ēḿàĩĺ",
"gitUserName": "Ĝĩţ Ũśēŕ Ńàḿē",
"goToExecutors": "Ĝō ţō Ēxēćũţōŕś",
"host": "Ĥōśţ",
"hubDescription": "Ēxēćũţōŕ ƥŕōƒĩĺēś ďēƒĩńē ĥōŵ àńď ŵĥēŕē àĝēńţś ŕũń. Ēàćĥ ƥŕōƒĩĺē ćōńƒĩĝũŕēś àń ēxēćũţĩōń ēńvĩŕōńḿēńţ ŵĩţĥ śćŕĩƥţś, ēńvĩŕōńḿēńţ vàŕĩàƀĺēś, àńď ḾĆƤ ƥōĺĩćĩēś.",
"hubDescriptionDocker": "Ŕũń Ďōćķēŕ ćōńţàĩńēŕś ōń ţĥĩś ḿàćĥĩńē.",
"hubDescriptionLocal": "Ŕũń àĝēńţś ďĩŕēćţĺŷ ĩń ţĥē ŕēƥōśĩţōŕŷ ƒōĺďēŕ.",
"hubDescriptionSprites": "Ŕũń àĝēńţś ĩń Śƥŕĩţēś.ďēv ćĺōũď śàńďƀōxēś.",
"hubDescriptionSsh": "Ćōńńēćţ ţō à ŕēḿōţē ĥōśţ ōvēŕ ŚŚĤ àńď ŕũń àĝēńţćţĺ ţĥēŕē.",
"hubDescriptionWorktree": "Ćŕēàţē ĝĩţ ŵōŕķţŕēēś ƒōŕ ĩśōĺàţēď àĝēńţ śēśśĩōńś.",
"image": "Ĩḿàĝē",
"imageTag": "Ĩḿàĝē Ţàĝ",
"include": "Ĩńćĺũďē",
@@ -88,6 +109,7 @@
"key": "Ķēŷ",
"loading": "Ĺōàďĩńĝ...",
"loadingEditor": "Ĺōàďĩńĝ ēďĩţōŕ...",
"loadingExecutor": "Ĺōàďĩńĝ ēxēćũţōŕ...",
"localDocker": "Ĺōćàĺ Ďōćķēŕ",
"localDockerExecutor": "Ĺōćàĺ Ďōćķēŕ Ēxēćũţōŕ",
"localGitIdentityNotDetected": "Ĺōćàĺ ĝĩţ ũśēŕ.ńàḿē/ũśēŕ.ēḿàĩĺ ńōţ ďēţēćţēď ōń ţĥĩś ḿàćĥĩńē",
@@ -100,6 +122,8 @@
"name": "Ńàḿē",
"networkPolicies": "Ńēţŵōŕķ Ƥōĺĩćĩēś",
"newProfile": "Ńēŵ Ƥŕōƒĩĺē",
"newSshExecutor": "Ńēŵ ŚŚĤ Ēxēćũţōŕ",
"newTypeProfile": "Ńēŵ {{type}} Ƥŕōƒĩĺē",
"noDockerContainers": "Ńō Ďōćķēŕ ćōńţàĩńēŕś.",
"noEnvironmentVariablesConfigured": "Ńō ēńvĩŕōńḿēńţ vàŕĩàƀĺēś ćōńƒĩĝũŕēď.",
"noEnvironmentVariablesConfiguredAddOne": "Ńō ēńvĩŕōńḿēńţ vàŕĩàƀĺēś ćōńƒĩĝũŕēď. Àďď ōńē ƀēĺōŵ.",
@@ -108,6 +132,7 @@
"noResponseBody": "Ńō ŕēśƥōńśē ƀōďŷ",
"noTransferableCredentialsFound": "Ńō ţŕàńśƒēŕàƀĺē ćŕēďēńţĩàĺś ƒōũńď.",
"none": "Ńōńē",
"notAnSshExecutor": "Ēxēćũţōŕ {{id}} ĩś ńōţ àń ŚŚĤ ēxēćũţōŕ",
"notConfigured": "Ńōţ Ćōńƒĩĝũŕēď",
"onlyHttpSse": "Ōńĺŷ ĤŢŢƤ/ŚŚĒ",
"onlyStdio": "Ōńĺŷ śţďĩō",
@@ -117,6 +142,7 @@
"pasteYourSecretValue": "Ƥàśţē ŷōũŕ śēćŕēţ vàĺũē...",
"pathToTlsCertificatesForThe": "Ƥàţĥ ţō ŢĹŚ ćēŕţĩƒĩćàţēś ƒōŕ ţĥē Ďōćķēŕ ĥōśţ.",
"personalAccessTokenForCloningRepositories": "Ƥēŕśōńàĺ àććēśś ţōķēń ƒōŕ ćĺōńĩńĝ ŕēƥōśĩţōŕĩēś ĩńśĩďē ţĥē ćōńţàĩńēŕ. Àũţō-ďēţēćţēď ƒŕōḿ ĥōśţ ēńvĩŕōńḿēńţ ĩƒ ńōţ śēţ.",
"port": "Ƥōŕţ",
"prepareScript": "Ƥŕēƥàŕē Śćŕĩƥţ",
"prepareScriptBadge": "Ƥŕēƥàŕē śćŕĩƥţ",
"prepareScriptDescriptionLocal": "Ŕũńś ōń ţĥē ĥōśţ ḿàćĥĩńē ƀēƒōŕē ţĥē àĝēńţ śţàŕţś.",
@@ -129,6 +155,7 @@
"profiles": "Ƥŕōƒĩĺēś",
"provideSecret": "Ƥŕōvĩďē śēćŕēţ",
"quickPresets": "Qũĩćķ ƥŕēśēţś",
"refresh": "Ŕēƒŕēśĥ",
"relatedDockerContainersRemoved_one": "{{count}} ŕēĺàţēď Ďōćķēŕ ćōńţàĩńēŕ ŵĩĺĺ àĺśō ƀē ŕēḿōvēď.",
"relatedDockerContainersRemoved_other": "{{count}} ŕēĺàţēď Ďōćķēŕ ćōńţàĩńēŕś ŵĩĺĺ àĺśō ƀē ŕēḿōvēď.",
"remoteAuthMethod": "Ŕēḿōţē àũţĥ ḿēţĥōď",
@@ -149,8 +176,63 @@
"selectOrCreateASecret": "Śēĺēćţ ōŕ ćŕēàţē à śēćŕēţ...",
"selectSecret": "Śēĺēćţ śēćŕēţ...",
"selectTheSecretContainingYourSprites": "Śēĺēćţ ţĥē śēćŕēţ ćōńţàĩńĩńĝ ŷōũŕ Śƥŕĩţēś.ďēv ÀƤĨ ţōķēń.",
"session": "Śēśśĩōń",
"setACustomNameAndEmail": "Śēţ à ćũśţōḿ ńàḿē àńď ēḿàĩĺ ƒōŕ ŕēḿōţē ĝĩţ ćōḿḿĩţś.",
"setEnvVarViaStoredSecret": "Śēţ <1>{{envVar}}</1> vĩà à śţōŕēď śēćŕēţ",
"spritesApiKeyIsRequired": "Śƥŕĩţēś ÀƤĨ ķēŷ ĩś ŕēqũĩŕēď.",
"spritesTokenIsRequired": "Śƥŕĩţēś ţōķēń ĩś ŕēqũĩŕēď.",
"sshActiveSessions": "Àćţĩvē śēśśĩōńś",
"sshActiveSessionsDescription": "Śēśśĩōńś ćũŕŕēńţĺŷ ŕũńńĩńĝ ōń ţĥĩś ŚŚĤ ĥōśţ. Ŕēƒŕēśĥēś ēvēŕŷ 90 śēćōńďś.",
"sshAvailableAgents": "Àvàĩĺàƀĺē àĝēńţś ōń ţĥĩś ĥōśţ",
"sshAvailableAgentsDescription": "Ƥŕōƀēś ţĥē ŕēḿōţē {{pathVar}} ƒōŕ ēàćĥ ēńàƀĺēď àĝēńţ ũńďēŕ ţĥē ćĥōśēń ĺōĝĩń śĥēĺĺ. Ćōƥŷ ţĥē ĩńśţàĺĺ ĥĩńţ àńď ŕũń ĩţ ōń ţĥē ŕēḿōţē ŵĥēń àń àĝēńţ ĩś ḿĩśśĩńĝ.",
"sshBinary": "Ɓĩńàŕŷ",
"sshConfirmRunningSessions_one": "Ţĥĩś ēxēćũţōŕ ĥàś {{count}} ŕũńńĩńĝ śēśśĩōń. Ţĥēŷ ŵĩĺĺ ķēēƥ ŕũńńĩńĝ ōń ţĥē ćũŕŕēńţ ĥōśţ. Ōńĺŷ ńēŵ śēśśĩōńś śţàŕţēď àƒţēŕ śàvē ŵĩĺĺ ũśē ţĥē ũƥďàţēď ćōńƒĩĝ. Ćōńţĩńũē?",
"sshConfirmRunningSessions_other": "Ţĥĩś ēxēćũţōŕ ĥàś {{count}} ŕũńńĩńĝ śēśśĩōńś. Ţĥēŷ ŵĩĺĺ ķēēƥ ŕũńńĩńĝ ōń ţĥē ćũŕŕēńţ ĥōśţ. Ōńĺŷ ńēŵ śēśśĩōńś śţàŕţēď àƒţēŕ śàvē ŵĩĺĺ ũśē ţĥē ũƥďàţēď ćōńƒĩĝ. Ćōńţĩńũē?",
"sshConnectionDescription": "Ŕũń àń àĝēńţ ōń Ĺĩńũx àḿď64 ōŕ ḿàćŌŚ ĥōśţś ŷōũ ćàń ŕēàćĥ ōvēŕ ŚŚĤ.",
"sshConnectionTestFailed": "Ćōńńēćţĩōń ţēśţ ƒàĩĺēď",
"sshConnectionTestPassed": "Ćōńńēćţĩōń ţēśţ ƥàśśēď",
"sshCopyInstallHint": "Ćōƥŷ ĩńśţàĺĺ ĥĩńţ",
"sshCreateDescription": "Ćōńńēćţ ţō à ŕēḿōţē Ĺĩńũx àḿď64 ōŕ ḿàćŌŚ ĥōśţ àńď ŕũń àĝēńţćţĺ ţĥēŕē.",
"sshExecutorPageDescription": "Ēďĩţ ţĥē ćōńńēćţĩōń śēţţĩńĝś ōŕ ŕē-ţŕũśţ ţĥē ĥōśţ. Ēxĩśţĩńĝ śēśśĩōńś ķēēƥ ţĥēĩŕ śńàƥśĥōţ ōƒ ţĥē ƥŕēvĩōũś ćōńƒĩĝ.",
"sshFailedToLoadSessions": "Ƒàĩĺēď ţō ĺōàď śēśśĩōńś",
"sshFailedToProbeAgents": "Ƒàĩĺēď ţō ƥŕōƀē àĝēńţś",
"sshFailedToReachBackend": "Ƒàĩĺēď ţō ŕēàćĥ ƀàćķēńď",
"sshFailedToSaveExecutor": "Ƒàĩĺēď ţō śàvē ēxēćũţōŕ",
"sshFailedToSaveShell": "Ƒàĩĺēď ţō śàvē śĥēĺĺ",
"sshFingerprintChanged": "Ŵàŕńĩńĝ: ţĥĩś ƒĩńĝēŕƥŕĩńţ ďĩƒƒēŕś ƒŕōḿ ţĥē ōńē ćũŕŕēńţĺŷ ƥĩńńēď (<1>{{fingerprint}}</1>). Ţŕũśţĩńĝ ĩţ ōvēŕŵŕĩţēś ţĥē ƥĩńńēď ķēŷ. Ĩƒ ŷōũ ďĩďń’ţ ēxƥēćţ à ĥōśţ ŕē-ķēŷ, śţōƥ ĥēŕē.",
"sshFingerprintObserved": "Ĥōśţ ƒĩńĝēŕƥŕĩńţ ōƀśēŕvēď:",
"sshHostAliasHint": "Ĩƒ śēţ, ĩńĥēŕĩţś ĤōśţŃàḿē / Ƥōŕţ / Ũśēŕ / ĨďēńţĩţŷƑĩĺē / ƤŕōxŷĴũḿƥ ƒŕōḿ ŷōũŕ ćōńƒĩĝ.",
"sshHostAliasLabel": "Ĥōśţ àĺĩàś ƒŕōḿ {{path}} (ōƥţĩōńàĺ)",
"sshIdentityFileHint": "Ƥàśśƥĥŕàśē-ƥŕōţēćţēď ķēŷś ḿũśţ ƀē ĺōàďēď ĩńţō śśĥ-àĝēńţ ƒĩŕśţ.",
"sshIdentityFilePath": "Ĩďēńţĩţŷ ƒĩĺē ƥàţĥ",
"sshIdentitySource": "Ĩďēńţĩţŷ śōũŕćē",
"sshIdentitySourceFile": "Ĩďēńţĩţŷ ƒĩĺē (ƥŕĩvàţē ķēŷ ƥàţĥ)",
"sshInstallHint": "Ĩńśţàĺĺ ĥĩńţ",
"sshInstallHintCopied": "Ĩńśţàĺĺ ĥĩńţ ćōƥĩēď",
"sshInstalled": "Ĩńśţàĺĺēď",
"sshLocalForward": "Ĺōćàĺ ƒŵď",
"sshLoginShell": "Ĺōĝĩń śĥēĺĺ",
"sshMissing": "Ḿĩśśĩńĝ",
"sshNamePlaceholder": "Ḿŷ VƤŚ",
"sshNoActiveSessions": "Ńō àćţĩvē śēśśĩōńś.",
"sshNoEnabledAgents": "Ńō ēńàƀĺēď àĝēńţś ţō ƥŕōƀē.",
"sshNoHintAvailable": "Ńō ĥĩńţ àvàĩĺàƀĺē",
"sshPinnedFingerprint": "Ƥĩńńēď ƒĩńĝēŕƥŕĩńţ:",
"sshProbeAgents": "Ƥŕōƀē àĝēńţś",
"sshProbeError": "Ƥŕōƀē ēŕŕōŕ",
"sshProbeHint": "Ćĺĩćķ \"{{button}}\" ţō ćĥēćķ ŵĥĩćĥ àĝēńţś àŕē ĩńśţàĺĺēď ōń ţĥē ŕēḿōţē.",
"sshProxyJumpHint": "Śĩńĝĺē ƀàśţĩōń ĥōƥ. Ćĥàĩńēď ĵũḿƥś àŕē ńōţ śũƥƥōŕţēď.",
"sshProxyJumpLabel": "ƤŕōxŷĴũḿƥ (ōƥţĩōńàĺ)",
"sshRemotePort": "Ŕēḿōţē ƥōŕţ",
"sshReprobe": "Ŕē-ƥŕōƀē",
"sshShellNotDetected": "(ńōţ ďēţēćţēď)",
"sshTestAndTrustToSave": "Ţēśţ ţĥē ćōńńēćţĩōń àńď ţŕũśţ ĩţś ƒĩńĝēŕƥŕĩńţ ţō śàvē.",
"sshTestConnection": "Ţēśţ ćōńńēćţĩōń",
"sshTestResultStale": "Ŷōũ ćĥàńĝēď à ćōńńēćţĩōń ƒĩēĺď àƒţēŕ ţĥĩś ţēśţ ŕàń. Ŕũń Ţēśţ Ćōńńēćţĩōń àĝàĩń ƀēƒōŕē ţŕũśţĩńĝ àńď śàvĩńĝ.",
"sshTrustThisHost": "<0>Ţŕũśţ ţĥĩś ĥōśţ.</0> Ĩ’vē vēŕĩƒĩēď ţĥē ƒĩńĝēŕƥŕĩńţ àƀōvē ḿàţćĥēś ḿŷ śēŕvēŕ. Ƒũţũŕē ćōńńēćţĩōńś ţĥàţ ŕēƥōŕţ à ďĩƒƒēŕēńţ ƒĩńĝēŕƥŕĩńţ ŵĩĺĺ ƀē ŕēƒũśēď.",
"sshTrusted": "Ţŕũśţēď",
"sshUnverified": "Ũńvēŕĩƒĩēď",
"status": "Śţàţũś",
"stop": "Śţōƥ",
"success": "Śũććēśś",
"task": "Ţàśķ",
@@ -160,12 +242,17 @@
"tlsCertificatePath": "ŢĹŚ ćēŕţĩƒĩćàţē ƥàţĥ",
"tlsVerify": "ŢĹŚ vēŕĩƒŷ",
"tokenConfiguredButUnableToConnect": "Ţōķēń ćōńƒĩĝũŕēď ƀũţ ũńàƀĺē ţō ćōńńēćţ.",
"typeLocal": "Ĺōćàĺ",
"typeTokenToConfirmDeletion": "Ţŷƥē \"{{token}}\" ţō ćōńƒĩŕḿ ďēĺēţĩōń. Ţĥĩś àćţĩōń ćàńńōţ ƀē ũńďōńē.",
"typeWorktree": "Ŵōŕķţŕēē",
"unknownError": "Ũńķńōŵń ēŕŕōŕ",
"unknownExecutorType": "Ũńķńōŵń ēxēćũţōŕ ţŷƥē",
"untrackedTask": "Ũńţŕàćķēď",
"uptime": "Ũƥţĩḿē",
"useDefaults": "Ũśē ďēƒàũĺţś",
"useLocalGitConfig": "Ũśē ĺōćàĺ ĝĩţ ćōńƒĩĝ",
"usedByRemoteExecutorsForCommit": "Ũśēď ƀŷ ŕēḿōţē ēxēćũţōŕś ƒōŕ ćōḿḿĩţ àũţĥōŕ ćōńƒĩĝũŕàţĩōń.",
"user": "Ũśēŕ",
"usesTheLocalDockerDaemonOn": "Ũśēś ţĥē ĺōćàĺ Ďōćķēŕ ďàēḿōń ōń ţĥĩś ḿàćĥĩńē.",
"value": "Vàĺũē",
"verifyingConnection": "Vēŕĩƒŷĩńĝ ćōńńēćţĩōń..."
+116
View File
@@ -0,0 +1,116 @@
{
"add": "Àďď",
"allCategories": "Àĺĺ ćàţēĝōŕĩēś",
"autoUpdate": "Àũţō-ũƥďàţē",
"autoUpdateDescription": "Ćĥēćķ ţĥē ḿàŕķēţƥĺàćē ƥēŕĩōďĩćàĺĺŷ àńď ĩńśţàĺĺ ńēŵēŕ vēŕśĩōńś ōƒ àćţĩvē ƥĺũĝĩńś. Àƥƥĺĩēś ţō ēvēŕŷ ƥĺũĝĩń ũńĺēśś ōvēŕŕĩďďēń ƥēŕ ƥĺũĝĩń ƀēĺōŵ.",
"autoUpdateFor": "Àũţō-ũƥďàţē ƒōŕ {{name}}",
"autoUpdateTitle": "Àũţōḿàţĩćàĺĺŷ ũƥďàţē ƥĺũĝĩńś",
"byAuthor": "ƀŷ {{author}}",
"cancel": "Ćàńćēĺ",
"category": "Ćàţēĝōŕŷ",
"confirmUninstall": "Ćōńƒĩŕḿ ũńĩńśţàĺĺ",
"disable": "Ďĩśàƀĺē",
"dropzoneHint": "Ďŕàĝ àńď ďŕōƥ à {{extension}} ƥàćķàĝē ĥēŕē, ōŕ ćĺĩćķ ţō ƀŕōŵśē",
"enable": "Ēńàƀĺē",
"failedToAddSource": "Ƒàĩĺēď ţō àďď śōũŕćē",
"failedToDisable": "Ƒàĩĺēď ţō ďĩśàƀĺē {{name}}",
"failedToEnable": "Ƒàĩĺēď ţō ēńàƀĺē {{name}}",
"failedToInstallPlugin": "Ƒàĩĺēď ţō ĩńśţàĺĺ ƥĺũĝĩń",
"failedToLoadMarketplace": "Ƒàĩĺēď ţō ĺōàď ḿàŕķēţƥĺàćē",
"failedToLoadPlugins": "Ƒàĩĺēď ţō ĺōàď ƥĺũĝĩńś",
"failedToLoadSettings": "Ƒàĩĺēď ţō ĺōàď ƥĺũĝĩń śēţţĩńĝś",
"failedToRemoveSource": "Ƒàĩĺēď ţō ŕēḿōvē śōũŕćē",
"failedToSaveSettings": "Ƒàĩĺēď ţō śàvē ƥĺũĝĩń śēţţĩńĝś",
"failedToSetAutoUpdateFor": "Ƒàĩĺēď ţō ũƥďàţē àũţō-ũƥďàţē ƒōŕ {{name}}",
"failedToSyncPlugins": "Ƒàĩĺēď ţō śŷńć ƥĺũĝĩńś",
"failedToUninstall": "Ƒàĩĺēď ţō ũńĩńśţàĺĺ {{name}}",
"failedToUpdateAutoUpdate": "Ƒàĩĺēď ţō ũƥďàţē àũţō-ũƥďàţē śēţţĩńĝ",
"failedToUpdateSource": "Ƒàĩĺēď ţō ũƥďàţē śōũŕćē",
"fromUrl": "Ƒŕōḿ ŨŔĹ",
"hideRaw": "Ĥĩďē ŕàŵ",
"install": "Ĩńśţàĺĺ",
"installPlugin": "Ĩńśţàĺĺ ƥĺũĝĩń",
"installPluginDescription": "Ĩńśţàĺĺ à ƥĺũĝĩń ƥàćķàĝē ƒŕōḿ à ŨŔĹ, ōŕ ũƥĺōàď à {{extension}} ƒĩĺē ďĩŕēćţĺŷ.",
"installed": "Ĩńśţàĺĺēď",
"installedPlugins": "Ĩńśţàĺĺēď ƥĺũĝĩńś",
"installing": "Ĩńśţàĺĺĩńĝ…",
"loadingMarketplace": "Ĺōàďĩńĝ ḿàŕķēţƥĺàćē…",
"loadingPlugins": "Ĺōàďĩńĝ ƥĺũĝĩńś...",
"loadingSelectedUtilityAgent": "Ĺōàďĩńĝ śēĺēćţēď ũţĩĺĩţŷ àĝēńţ...",
"loadingSettings": "Ĺōàďĩńĝ śēţţĩńĝś...",
"loadingUtilityAgents": "Ĺōàďĩńĝ ũţĩĺĩţŷ àĝēńţś...",
"manifest": "Ḿàńĩƒēśţ",
"manifestApiVersion": "ÀƤĨ vēŕśĩōń",
"manifestAuthor": "Àũţĥōŕ",
"manifestCapabilities": "Ćàƥàƀĩĺĩţĩēś",
"manifestInstalled": "Ĩńśţàĺĺēď",
"manifestSigned": "Śĩĝńēď",
"manifestVersion": "Vēŕśĩōń",
"manifestWebhooks": "Ŵēƀĥōōķś",
"marketplaceSources": "Ḿàŕķēţƥĺàćē śōũŕćēś",
"marketplaceSourcesDescription": "Àďď à ţēàḿ ōŕ ćōŕƥōŕàţē ŕēĝĩśţŕŷ ţō ƀŕōŵśē ĩţś ƥĺũĝĩńś àĺōńĝśĩďē ţĥē ōƒƒĩćĩàĺ ćàţàĺōĝ. Ēàćĥ śōũŕćē śēŕvēś àń {{document}} ďōćũḿēńţ.",
"no": "ńō",
"noDeclaredSettings": "Ţĥĩś ƥĺũĝĩń ďōēś ńōţ ďēćĺàŕē àńŷ śēţţĩńĝś (ńō <1>ćōńƒĩĝ_śćĥēḿà</1> ĩń ĩţś ḿàńĩƒēśţ).",
"noInstalledPluginWithId": "Ńō ĩńśţàĺĺēď ƥĺũĝĩń ŵĩţĥ ĩď <1>{{id}}</1>.",
"noPluginsAvailable": "Ńō ƥĺũĝĩńś àvàĩĺàƀĺē ŷēţ. Ćĥēćķ ƀàćķ śōōń, ōŕ àďď à ḿàŕķēţƥĺàćē śōũŕćē ŵĩţĥ ŷōũŕ ōŵń.",
"noPluginsMatchSearch": "Ńō ƥĺũĝĩńś ḿàţćĥ ŷōũŕ śēàŕćĥ. Ţŕŷ ćĺēàŕĩńĝ ţĥē ƒĩĺţēŕś.",
"noPluginsYet": "Ńō ƥĺũĝĩńś ŷēţ. Ĩńśţàĺĺ ōńē ƒŕōḿ ţĥē Ɓŕōŵśē ţàƀ, ōŕ ũƥĺōàď à ƥàćķàĝē.",
"notSet": "Ńōţ śēţ",
"override": "ōvēŕŕĩďē",
"packageUrl": "Ƥàćķàĝē ŨŔĹ",
"pluginInstalled": "{{name}} ĩńśţàĺĺēď",
"pluginPageFailedToLoad": "Ţĥĩś ƥĺũĝĩń ƥàĝē ƒàĩĺēď ţō ĺōàď.",
"pluginsAvailable_one": "{{count}} ƥĺũĝĩń àvàĩĺàƀĺē",
"pluginsAvailable_other": "{{count}} ƥĺũĝĩńś àvàĩĺàƀĺē",
"refresh": "Ŕēƒŕēśĥ",
"removeSource": "Ŕēḿōvē {{name}}",
"repo": "Ŕēƥō",
"requiredFields": "Ŕēqũĩŕēď: {{fields}}",
"reset": "Ŕēśēţ",
"resetAutoUpdateFor": "Ŕēśēţ àũţō-ũƥďàţē ƒōŕ {{name}} ţō ţĥē ďēƒàũĺţ",
"savingRestartsPlugin": "Śàvĩńĝ ŕēśţàŕţś ţĥē ƥĺũĝĩń śō ţĥē ńēŵ śēţţĩńĝś ţàķē ēƒƒēćţ.",
"searchPlugins": "Śēàŕćĥ ƥĺũĝĩńś…",
"selectAUtilityAgent": "Śēĺēćţ à ũţĩĺĩţŷ àĝēńţ...",
"selectPlaceholder": "Śēĺēćţ...",
"selectedUtilityAgentUnavailable": "Śēĺēćţēď ũţĩĺĩţŷ àĝēńţ ũńàvàĩĺàƀĺē",
"settings": "Śēţţĩńĝś",
"settingsDescription": "Ɓŕōŵśē ţĥē ḿàŕķēţƥĺàćē, ţĥēń ĩńśţàĺĺ, ēńàƀĺē, ďĩśàƀĺē, àńď ũńĩńśţàĺĺ ķàńďēv ƥĺũĝĩńś.",
"settingsSaved": "Ƥĺũĝĩń śēţţĩńĝś śàvēď",
"settingsSavedReloadFailed": "Śēţţĩńĝś śàvēď, ƀũţ ŕēĺōàďĩńĝ ţĥēḿ ƒàĩĺēď — ŕēƒŕēśĥ ţō ćōńƒĩŕḿ.",
"sortMostStars": "Ḿōśţ śţàŕś",
"sortName": "Ńàḿē",
"sortRecentlyUpdated": "Ŕēćēńţĺŷ ũƥďàţēď",
"sourceNamePlaceholder": "Śōũŕćē ńàḿē (ē.ĝ. Àćḿē Ĩńţēŕńàĺ)",
"sourceOfficial": "Ōƒƒĩćĩàĺ",
"sourceUnreachable": "Ũńŕēàćĥàƀĺē",
"sourceUnreachableDetail": "<0>{{name}}</0> ĩś ũńŕēàćĥàƀĺē{{detail}} — ĩţś ƥĺũĝĩńś àŕē ĥĩďďēń.",
"sources": "Śōũŕćēś",
"statusActive": "Àćţĩvē",
"statusDisabled": "Ďĩśàƀĺēď",
"statusError": "Ēŕŕōŕ",
"statusRegistered": "Ŕēĝĩśţēŕēď",
"statusUninstalled": "Ũńĩńśţàĺĺēď",
"sync": "Śŷńć",
"syncInstalled_one": "{{count}} ĩńśţàĺĺēď",
"syncInstalled_other": "{{count}} ĩńśţàĺĺēď",
"syncMissing_one": "{{count}} ḿĩśśĩńĝ",
"syncMissing_other": "{{count}} ḿĩśśĩńĝ",
"syncSideloaded_one": "{{count}} śĩďēĺōàďēď",
"syncSideloaded_other": "{{count}} śĩďēĺōàďēď",
"syncSummary": "Śŷńć: {{parts}}",
"syncUpToDate": "Ēvēŕŷţĥĩńĝ ũƥ ţō ďàţē",
"tabBrowse": "Ɓŕōŵśē",
"tabInstalled": "Ĩńśţàĺĺēď",
"thisPlugin": "ţĥĩś ƥĺũĝĩń",
"uninstall": "Ũńĩńśţàĺĺ",
"uninstallConfirm": "Ţĥĩś ŵĩĺĺ ƥēŕḿàńēńţĺŷ ŕēḿōvē <2>{{name}}</2> àńď ŕēvōķē ĩţś ÀƤĨ ķēŷ. Ţĥĩś àćţĩōń ćàńńōţ ƀē ũńďōńē.",
"uninstallPlugin": "Ũńĩńśţàĺĺ ƥĺũĝĩń",
"unsigned": "ũńśĩĝńēď",
"update": "Ũƥďàţē",
"updateToVersion": "Ũƥďàţē ţō v{{version}}",
"updating": "Ũƥďàţĩńĝ…",
"uploadFile": "Ũƥĺōàď ƒĩĺē",
"utilityAgentsUnavailable": "Ũţĩĺĩţŷ àĝēńţś ũńàvàĩĺàƀĺē",
"viewRaw": "Vĩēŵ ŕàŵ",
"yes": "ŷēś"
}
@@ -291,6 +291,7 @@
"searchEditors": "Śēàŕćĥ ēďĩţōŕś...",
"secrets": "Śēćŕēţś",
"secretValue": "Śēćŕēţ vàĺũē",
"security": "Śēćũŕĩţŷ",
"selectADefaultEditor": "Śēĺēćţ à ďēƒàũĺţ ēďĩţōŕ",
"selectAShell": "Śēĺēćţ à śĥēĺĺ",
"sendTestNotification": "Śēńď ţēśţ ńōţĩƒĩćàţĩōń",
@@ -355,6 +356,7 @@
"thisProfileWillBeRemovedWhen": "Ţĥĩś ƥŕōƒĩĺē ŵĩĺĺ ƀē ŕēḿōvēď ŵĥēń ŷōũ śàvē.",
"thisSecret": "ţĥĩś śēćŕēţ",
"thisWillPermanentlyRemoveSecret": "Ţĥĩś ŵĩĺĺ ƥēŕḿàńēńţĺŷ ŕēḿōvē <2>{{name}}</2>. Ţĥĩś àćţĩōń ćàńńōţ ƀē ũńďōńē.",
"tokens": "Ţōķēńś",
"transcriptNavigation": "Ţŕàńśćŕĩƥţ Ńàvĩĝàţĩōń",
"tree": "Ţŕēē",
"unableToConnect": "Ũńàƀĺē ţō ćōńńēćţ.",
+29 -17
View File
@@ -1,5 +1,5 @@
import { useEffect, useRef, useState, type ReactNode } from "react";
import { Trans } from "react-i18next";
import { Trans, useTranslation } from "react-i18next";
import AgentsSettingsPage from "@/app/settings/agents/page";
import AgentSetupPage from "@/app/settings/agents/[agentId]/page";
@@ -176,22 +176,8 @@ const SETTINGS_ROUTES: Record<string, RouteRenderer> = {
<UsersTable />
</SystemRouteShell>
),
"/settings/account/security": () => (
<SystemPageShell
title="Profile & password"
description="Change your password and review devices signed in to your account."
>
<SecuritySettings />
</SystemPageShell>
),
"/settings/account/tokens": () => (
<SystemPageShell
title="API tokens"
description="Personal access tokens for scripts and CLIs acting as you."
>
<ApiTokens />
</SystemPageShell>
),
"/settings/account/security": () => <AccountSecurityRoute />,
"/settings/account/tokens": () => <AccountTokensRoute />,
"/settings/system/about": () => (
<SystemRouteShell titleKey="system:navAbout" descriptionKey="system:aboutPageDescription">
<AboutCard />
@@ -418,6 +404,32 @@ function renderIntegrationSettingsRoute(section: string | null, workspaceId?: st
}
}
// Components rather than inline JSX so `t()` resolves at render — a t() call
// inside SETTINGS_ROUTES would run at module load and freeze at the boot locale.
function AccountSecurityRoute() {
const { t } = useTranslation();
return (
<SystemPageShell
title={t("account:securityPageTitle")}
description={t("account:securityPageDescription")}
>
<SecuritySettings />
</SystemPageShell>
);
}
function AccountTokensRoute() {
const { t } = useTranslation();
return (
<SystemPageShell
title={t("account:tokensPageTitle")}
description={t("account:tokensPageDescription")}
>
<ApiTokens />
</SystemPageShell>
);
}
function renderUpdatesRoute() {
return <UpdatesRoute />;
}