From 22d71a2f650fafc557b7b0e3424c6eff8f640ed1 Mon Sep 17 00:00:00 2001 From: Code UX Date: Tue, 14 Jul 2026 01:32:15 +0000 Subject: [PATCH 1/2] feat(task T15): implement via codex --- dashboard/src/v2/AgentsPage.tsx | 137 ++--- .../agents/AgentAvatarCustomizer.tsx | 50 +- .../agents/AgentAvatarExpressionPicker.tsx | 10 +- .../v2/components/agents/AgentAvatarScene.tsx | 10 +- .../v2/components/agents/AgentAvatarStage.tsx | 26 +- .../components/agents/AgentKnowledgePanel.tsx | 27 +- .../components/agents/AgentMcpManageModal.tsx | 90 +-- .../agents/AgentMemoryConfigPanel.tsx | 94 +-- .../agents/AgentPresetDetailPanel.tsx | 142 ++--- .../agents/AgentPresetEditorPanel.tsx | 264 ++++----- .../agents/AgentPresetShowcaseCard.tsx | 21 +- .../src/v2/components/agents/AgentsHero.tsx | 19 +- .../agents/BaseAgentUpdateNotice.tsx | 23 +- .../components/agents/InstructionFileCard.tsx | 7 +- .../agents/InstructionFileEditorPanel.tsx | 49 +- .../agents/LazyAgentAvatarScene.tsx | 12 +- .../agents/PersistentSkillStorageChip.tsx | 29 +- .../__tests__/AgentAvatarCustomizer.test.tsx | 15 +- .../__tests__/AgentMcpManageModal.test.tsx | 19 +- .../__tests__/AgentMemoryConfigPanel.test.tsx | 23 +- .../__tests__/AgentPresetEditorPanel.test.tsx | 27 +- .../InstructionFileEditorPanel.test.tsx | 14 +- .../agents/__tests__/render-with-i18n.tsx | 10 + dashboard/src/v2/i18n/messages/agents.ts | 535 ++++++++++++++++++ dashboard/src/v2/lib/agent-avatar.ts | 22 + dashboard/src/v2/lib/agent-mcp-display.ts | 7 +- .../src/v2/lib/agent-response-effects.ts | 23 +- dashboard/src/v2/lib/agent-scene-tools.ts | 15 + .../src/v2/lib/instruction-file-display.ts | 16 +- dashboard/src/v2/lib/token-estimate.ts | 10 +- .../pages/__tests__/AgentsPage.push.test.tsx | 19 +- .../dashboard-internationalization.md | 6 + docs/dashboard/internationalization.md | 8 +- .../v2/agent-avatar-scene-lazy.test.tsx | 12 +- .../v2/agent-response-effects.test.ts | 3 +- tests/dashboard/v2/agents-page.test.tsx | 49 +- .../persistent-skill-storage-hover.test.tsx | 14 +- 37 files changed, 1373 insertions(+), 484 deletions(-) create mode 100644 dashboard/src/v2/components/agents/__tests__/render-with-i18n.tsx create mode 100644 dashboard/src/v2/i18n/messages/agents.ts diff --git a/dashboard/src/v2/AgentsPage.tsx b/dashboard/src/v2/AgentsPage.tsx index f8b04e96c7..2c081d85e7 100644 --- a/dashboard/src/v2/AgentsPage.tsx +++ b/dashboard/src/v2/AgentsPage.tsx @@ -35,6 +35,9 @@ import { InstructionFileCard } from "./components/agents/InstructionFileCard.js" import { InstructionFileEditorPanel } from "./components/agents/InstructionFileEditorPanel.js"; import { PageContainer } from "./components/layout/PageContainer.js"; import { SectionDivider } from "./components/ui/SectionDivider.js"; +import { useDashboardI18n } from "./i18n/index.js"; +import type { DashboardMessageVariables, DashboardTextMessageKey } from "./i18n/index.js"; +import { agentsMessages } from "./i18n/messages/agents.js"; /* ── Roster summary stat ── */ type RosterStatProps = { @@ -98,6 +101,10 @@ type PageActionFeedback = { /* ── Main Page ── */ export const AgentsPage: FunctionComponent = () => { + const { formatNumber, translate, translatePlural } = useDashboardI18n(); + const t = (key: DashboardTextMessageKey, variables?: DashboardMessageVariables): string => ( + translate(agentsMessages, key, variables) + ); const contentRef = useRef(null); const pushButtonRef = useRef(null); const pushPickerRef = useRef(null); @@ -297,7 +304,7 @@ export const AgentsPage: FunctionComponent = () => { const handleCreate = async (): Promise => { if (!selectedProject) return; try { - setActionFeedback({ tone: "pending", message: "Creating agent preset..." }); + setActionFeedback({ tone: "pending", message: t("creatingPreset") }); const created = await createAgentPreset(selectedProject.id, { name: `Agent ${presets.length + 1}`, instructionMarkdown: "", @@ -308,26 +315,26 @@ export const AgentsPage: FunctionComponent = () => { setSelectedPresetId(created.id); setIsEditing(true); setError(null); - setActionFeedback({ tone: "success", message: "Agent preset created. Complete the required fields, then save." }); + setActionFeedback({ tone: "success", message: t("presetCreated") }); } catch (e) { const message = e instanceof Error ? e.message : String(e); setError(message); - setActionFeedback({ tone: "error", message: `Agent creation failed: ${message}`, retry: () => void handleCreate() }); + setActionFeedback({ tone: "error", message: t("creationFailed", { error: message }), retry: () => void handleCreate() }); } }; const handleImport = async (presetId: string): Promise => { setImportingId(presetId); - setActionFeedback({ tone: "pending", message: "Importing preset from markdown..." }); + setActionFeedback({ tone: "pending", message: t("importingPreset") }); try { const updated = await importAgentPresetFromMarkdown(presetId); setPresets((cur) => cur.map((p) => (p.id === updated.id ? updated : p))); setError(null); - setActionFeedback({ tone: "success", message: "Agent preset imported from markdown." }); + setActionFeedback({ tone: "success", message: t("presetImported") }); } catch (e) { const message = e instanceof Error ? e.message : String(e); setError(message); - setActionFeedback({ tone: "error", message: `Import failed: ${message}`, retry: () => void handleImport(presetId) }); + setActionFeedback({ tone: "error", message: t("importFailed", { error: message }), retry: () => void handleImport(presetId) }); } finally { setImportingId(null); } @@ -337,16 +344,16 @@ export const AgentsPage: FunctionComponent = () => { if (!selectedProject || !projectFileSavingEnabled) return; const preferredPresetId = selectedPresetId; setPullingFromFiles(true); - setActionFeedback({ tone: "pending", message: "Pulling agent presets from project files..." }); + setActionFeedback({ tone: "pending", message: t("pullingPresets") }); try { await pullAgentPresetsFromMarkdown(selectedProject.id); await refreshPresets(preferredPresetId); setError(null); - setActionFeedback({ tone: "success", message: "Agent presets pulled from project files." }); + setActionFeedback({ tone: "success", message: t("presetsPulled") }); } catch (e) { const message = e instanceof Error ? e.message : String(e); setError(message); - setActionFeedback({ tone: "error", message: `Pull failed: ${message}`, retry: () => void handlePullFromFiles() }); + setActionFeedback({ tone: "error", message: t("pullFailed", { error: message }), retry: () => void handlePullFromFiles() }); } finally { setPullingFromFiles(false); } @@ -356,16 +363,16 @@ export const AgentsPage: FunctionComponent = () => { if (!selectedProject || !projectFileSavingEnabled) return; const preferredPresetId = selectedPresetId; setPushingToFiles(true); - setActionFeedback({ tone: "pending", message: "Pushing agent presets to project files..." }); + setActionFeedback({ tone: "pending", message: t("pushingPresets") }); try { await pushAgentPresetsToMarkdown(selectedProject.id); await refreshPresets(preferredPresetId); setError(null); - setActionFeedback({ tone: "success", message: "Agent presets pushed to project files." }); + setActionFeedback({ tone: "success", message: t("presetsPushed") }); } catch (e) { const message = e instanceof Error ? e.message : String(e); setError(message); - setActionFeedback({ tone: "error", message: `Push failed: ${message}`, retry: () => void handlePushToFiles() }); + setActionFeedback({ tone: "error", message: t("pushFailed", { error: message }), retry: () => void handlePushToFiles() }); } finally { setPushingToFiles(false); } @@ -375,16 +382,16 @@ export const AgentsPage: FunctionComponent = () => { if (!projectFileSavingEnabled) return; const preferredPresetId = selectedPresetId; setExportingId(presetId); - setActionFeedback({ tone: "pending", message: "Pushing agent preset to project file..." }); + setActionFeedback({ tone: "pending", message: t("pushingPreset") }); try { await exportAgentPresetToMarkdown(presetId); await refreshPresets(preferredPresetId); setError(null); - setActionFeedback({ tone: "success", message: "Agent preset pushed to project file." }); + setActionFeedback({ tone: "success", message: t("presetPushed") }); } catch (e) { const message = e instanceof Error ? e.message : String(e); setError(message); - setActionFeedback({ tone: "error", message: `Push failed: ${message}`, retry: () => void handlePushPresetToFile(presetId) }); + setActionFeedback({ tone: "error", message: t("pushFailed", { error: message }), retry: () => void handlePushPresetToFile(presetId) }); } finally { setExportingId(null); } @@ -417,7 +424,7 @@ export const AgentsPage: FunctionComponent = () => { } else if (pushMode === "commit_and_push") { if (!result.pushedBranch) { setPushResult(null); - setError("Agent presets were committed locally, but no remote origin is configured for this repository."); + setError(t("noRemote")); } else { setPushResult({ mode: pushMode, @@ -428,7 +435,7 @@ export const AgentsPage: FunctionComponent = () => { } } else if (!result.pullRequestUrl) { setPushResult(null); - setError("Agent presets were committed locally, but no pull request URL was returned."); + setError(t("noPullRequestUrl")); } else { setPushResult({ mode: pushMode, @@ -448,17 +455,17 @@ export const AgentsPage: FunctionComponent = () => { const handleSave = async (presetId: string, next: Parameters[1]): Promise => { setSavingId(presetId); - setActionFeedback({ tone: "pending", message: "Saving agent preset..." }); + setActionFeedback({ tone: "pending", message: t("savingPreset") }); try { const updated = await updateAgentPreset(presetId, next); setPresets((cur) => cur.map((p) => (p.id === updated.id ? updated : p))); setIsEditing(false); setError(null); - setActionFeedback({ tone: "success", message: "Agent preset saved." }); + setActionFeedback({ tone: "success", message: t("presetSaved") }); } catch (e) { const message = e instanceof Error ? e.message : String(e); setError(message); - setActionFeedback({ tone: "error", message: `Save failed: ${message}`, retry: () => void handleSave(presetId, next) }); + setActionFeedback({ tone: "error", message: t("saveFailed", { error: message }), retry: () => void handleSave(presetId, next) }); } finally { setSavingId(null); } @@ -466,7 +473,7 @@ export const AgentsPage: FunctionComponent = () => { const handleDelete = async (presetId: string): Promise => { setDeletingId(presetId); - setActionFeedback({ tone: "pending", message: "Deleting agent preset..." }); + setActionFeedback({ tone: "pending", message: t("deletingPreset") }); try { await deleteAgentPreset(presetId); setPresets((cur) => { @@ -478,11 +485,11 @@ export const AgentsPage: FunctionComponent = () => { return next; }); setError(null); - setActionFeedback({ tone: "success", message: "Agent preset deleted." }); + setActionFeedback({ tone: "success", message: t("presetDeleted") }); } catch (e) { const message = e instanceof Error ? e.message : String(e); setError(message); - setActionFeedback({ tone: "error", message: `Delete failed: ${message}`, retry: () => void handleDelete(presetId) }); + setActionFeedback({ tone: "error", message: t("deleteFailed", { error: message }), retry: () => void handleDelete(presetId) }); } finally { setDeletingId(null); } @@ -492,9 +499,9 @@ export const AgentsPage: FunctionComponent = () => { const projectId = selectedProject?.id; if (!projectId || projectId !== notice.projectId || updatingBaseAgentRole) return; - const roleLabel = notice.role === "planning_agent" ? "Planning agent" : "Project manager"; + const roleLabel = notice.role === "planning_agent" ? t("planningAgent") : t("projectManager"); setUpdatingBaseAgentRole(notice.role); - setActionFeedback({ tone: "pending", message: `Updating ${notice.selectedAgentName} with AI...` }); + setActionFeedback({ tone: "pending", message: t("updatingAgentWithAi", { name: notice.selectedAgentName }) }); try { await applyBaseAgentUpdate(projectId, notice.role); if (selectedProjectIdRef.current !== projectId) return; @@ -510,14 +517,14 @@ export const AgentsPage: FunctionComponent = () => { setError(null); setActionFeedback({ tone: "success", - message: `${roleLabel} compatibility instructions updated. Custom behavior and instructions were preserved.`, + message: t("baseUpdateSuccess", { role: roleLabel }), }); } catch (updateError) { if (selectedProjectIdRef.current !== projectId) return; const message = updateError instanceof Error ? updateError.message : String(updateError); setActionFeedback({ tone: "error", - message: `${roleLabel} update failed: ${message}`, + message: t("baseUpdateFailed", { role: roleLabel, error: message }), retry: () => void handleBaseAgentUpdate(notice), }); } finally { @@ -674,17 +681,17 @@ export const AgentsPage: FunctionComponent = () => { if (pushResult.mode === "commit_only") { return pushResult.committed - ? "Agent presets were committed locally." - : "No agent preset changes were available to commit."; + ? t("committedLocally") + : t("nothingToCommit"); } if (pushResult.mode === "commit_and_push") { - return pushResult.pushedBranch ? `Pushed agent presets to ${pushResult.pushedBranch}.` : null; + return pushResult.pushedBranch ? t("pushedToBranch", { branch: pushResult.pushedBranch }) : null; } return pushResult.pullRequestUrl ? ( <> - Opened a pull request at{" "} + {t("openedPullRequestAt")} {" "} { . ) : null; - }, [pushResult]); + }, [pushResult, translate]); return ( - + { ) : ( )} - {pushing ? "Pushing..." : "Push Agents"} + {pushing ? t("pushing") : t("pushAgents")} {pushPickerOpen && (
- Push Agents + {t("pushAgents")}

- Choose where to send the current .code-ux/agents changes. + {t("pushDialogBody")}

{([ - { value: "commit_only", label: "Commit locally", description: "Create a local commit only." }, - { value: "commit_and_push", label: "Push to branch", description: "Commit, then push the branch to origin." }, - { value: "pull_request", label: "Open pull request", description: "Commit, push, and open a PR." }, + { value: "commit_only", label: t("commitLocally"), description: t("commitLocallyBody") }, + { value: "commit_and_push", label: t("pushToBranch"), description: t("pushToBranchBody") }, + { value: "pull_request", label: t("openPullRequest"), description: t("openPullRequestBody") }, ] as const).map((option) => (
@@ -831,11 +838,11 @@ export const AgentsPage: FunctionComponent = () => { {/* Roster summary strip — only when project is loaded */} {selectedProject && presets.length > 0 && ( -
- - - 0 ? "amber" : "slate"} icon={AlertTriangle} /> - +
+ + + 0 ? "amber" : "slate"} icon={AlertTriangle} /> +
)} @@ -878,7 +885,7 @@ export const AgentsPage: FunctionComponent = () => { onClick={actionFeedback.retry} className="rounded-full border border-current/25 px-3 py-1 text-[10px] font-bold uppercase tracking-[0.14em] transition-colors hover:bg-current/10 focus:outline-none focus-visible:ring-2 focus-visible:ring-current/30" > - Retry + {t("retry")} )} @@ -891,12 +898,12 @@ export const AgentsPage: FunctionComponent = () => { className="flex min-h-[3rem] items-center gap-3 rounded-2xl border border-black/[0.06] bg-white/45 px-5 py-3 text-sm font-medium text-slate-500 backdrop-blur-md dark:border-white/[0.06] dark:bg-white/[0.025] dark:text-slate-400" >
@@ -60,9 +64,9 @@ function PartPicker({ transitionTimingFunction: INTERACTION_CSS_VARIABLES.selectionMovement.ease, }} > - {opt.label} + {getAgentAvatarOptionLabel(opt.label, locale)} - {selected ? "Selected" : "Option"} + {translate(agentsMessages, selected ? "selected" : "option")} ); @@ -86,6 +90,7 @@ function ColorSwatchPicker({ onChange: (id: string) => void; disabled?: boolean; }) { + const { locale, translate } = useDashboardI18n(); const selectedOpt = options.find((o) => o.id === value); return (
@@ -95,7 +100,7 @@ function ColorSwatchPicker({ {selectedOpt && ( - {selectedOpt.label} + {getAgentAvatarOptionLabel(selectedOpt.label, locale)} )}
@@ -108,8 +113,8 @@ function ColorSwatchPicker({ type="button" disabled={disabled} onClick={() => onChange(opt.id)} - title={opt.label} - aria-label={opt.label} + title={getAgentAvatarOptionLabel(opt.label, locale)} + aria-label={getAgentAvatarOptionLabel(opt.label, locale)} aria-describedby={selected ? `${label}-${opt.id}-selected` : undefined} aria-pressed={selected} className={`group relative h-8 w-8 rounded-full shadow-sm transition-all focus:outline-none focus-visible:ring-2 focus-visible:ring-signal-500/30 disabled:cursor-not-allowed disabled:opacity-50 ${ @@ -126,7 +131,7 @@ function ColorSwatchPicker({ )} {selected && ( - Selected {opt.label} + {translate(agentsMessages, "selected")} {getAgentAvatarOptionLabel(opt.label, locale)} )} @@ -173,6 +178,7 @@ export function AgentAvatarCustomizer({ className = "", disabled = false, }: AgentAvatarCustomizerProps) { + const { translate } = useDashboardI18n(); const [isRandomizing, setIsRandomizing] = useState(false); const handleRandomize = () => { setIsRandomizing(true); @@ -189,17 +195,17 @@ export function AgentAvatarCustomizer({

- Tweak parts and colors — the portrait updates live. + {translate(agentsMessages, "avatarCustomizerHint")}

{disabled - ? "Avatar controls are disabled while this agent is saving." + ? translate(agentsMessages, "avatarControlsDisabled") : isRandomizing - ? "Avatar randomized. Save Agent to keep it." - : "Selected parts are labeled and update the live portrait immediately."} + ? translate(agentsMessages, "avatarRandomized") + : translate(agentsMessages, "avatarSelectionHint")}
- - handleField("chassis", id)} disabled={disabled} /> - handleField("eyes", id)} disabled={disabled} /> - handleField("antenna", id)} disabled={disabled} /> - handleField("headphones", id)} disabled={disabled} /> - handleField("wings", id)} disabled={disabled} /> + + handleField("chassis", id)} disabled={disabled} /> + handleField("eyes", id)} disabled={disabled} /> + handleField("antenna", id)} disabled={disabled} /> + handleField("headphones", id)} disabled={disabled} /> + handleField("wings", id)} disabled={disabled} /> - - handleField("baseColor", id)} disabled={disabled} /> - handleField("accent", id)} disabled={disabled} /> - handleField("visorColor", id)} disabled={disabled} /> + + handleField("baseColor", id)} disabled={disabled} /> + handleField("accent", id)} disabled={disabled} /> + handleField("visorColor", id)} disabled={disabled} />
); diff --git a/dashboard/src/v2/components/agents/AgentAvatarExpressionPicker.tsx b/dashboard/src/v2/components/agents/AgentAvatarExpressionPicker.tsx index 58b3d17aa6..c8e6b0092a 100644 --- a/dashboard/src/v2/components/agents/AgentAvatarExpressionPicker.tsx +++ b/dashboard/src/v2/components/agents/AgentAvatarExpressionPicker.tsx @@ -3,6 +3,8 @@ import { Smile } from "lucide-preact"; import type { AgentAvatarExpression } from "../../lib/agent-avatar.js"; import { AGENT_AVATAR_EXPRESSIONS } from "../../lib/agent-avatar.js"; import { EXPRESSION_META } from "./AgentAvatarStage.js"; +import { useDashboardI18n } from "../../i18n/index.js"; +import { agentsMessages } from "../../i18n/messages/agents.js"; interface AgentAvatarExpressionPickerProps { value: AgentAvatarExpression; @@ -17,10 +19,14 @@ export function AgentAvatarExpressionPicker({ className = "", disabled = false, }: AgentAvatarExpressionPickerProps) { + const { translate } = useDashboardI18n(); + const expressionLabel = (expression: AgentAvatarExpression): string => translate(agentsMessages, ({ + happy: "expressionHappy", sad: "expressionSad", angry: "expressionAngry", sleepy: "expressionSleepy", bored: "expressionBored", hyped: "expressionHyped", shake_head: "expressionShakeHead", nod: "expressionNod", curious: "expressionCurious", thinking: "expressionThinking", excited: "expressionExcited", laughing: "expressionLaughing", surprised: "expressionSurprised", wink: "expressionWink", dance: "expressionDance", proud: "expressionProud", + } as const)[expression]); return (
- Expression + {translate(agentsMessages, "expression")}
{AGENT_AVATAR_EXPRESSIONS.map((expression) => { @@ -40,7 +46,7 @@ export function AgentAvatarExpressionPicker({ }`} > - {meta.label} + {expressionLabel(expression)} ); })} diff --git a/dashboard/src/v2/components/agents/AgentAvatarScene.tsx b/dashboard/src/v2/components/agents/AgentAvatarScene.tsx index e76c1d19c6..a8e0198cb1 100644 --- a/dashboard/src/v2/components/agents/AgentAvatarScene.tsx +++ b/dashboard/src/v2/components/agents/AgentAvatarScene.tsx @@ -57,12 +57,15 @@ import { extrudeLogoPath, type LogoShapeFrame } from "../../lib/logo-shapes.js"; import { AgentAvatarSvg } from "./AgentAvatarSvg.js"; import { AGENT_SCENE_TOOL_CATALOG, + getAgentSceneToolLabel, getToolMotionPose, type AgentSceneTool, type ToolAnimationRef, type ToolGeometryBlueprint, type ToolMaterialRole, } from "../../lib/agent-scene-tools.js"; +import { useDashboardI18n } from "../../i18n/index.js"; +import { agentsMessages } from "../../i18n/messages/agents.js"; export type { AgentSceneTool } from "../../lib/agent-scene-tools.js"; @@ -990,6 +993,7 @@ export function AgentAvatarScene({ pointerTracking = "hover", tool = null, }: AgentAvatarSceneProps) { + const { locale, translate } = useDashboardI18n(); const mountRef = useRef(null); const [webglError, setWebglError] = useState(false); const [isReducedMotion, setIsReducedMotion] = useState(() => { @@ -1545,7 +1549,9 @@ export function AgentAvatarScene({ data-testid="agent-avatar-fallback" data-tool={tool ?? undefined} role="img" - aria-label={tool ? `Agent avatar preview working with ${AGENT_SCENE_TOOL_CATALOG[tool].label}` : "Agent avatar preview"} + aria-label={tool + ? translate(agentsMessages, "avatarPreviewWithTool", { tool: getAgentSceneToolLabel(tool, locale) }) + : translate(agentsMessages, "avatarPreview")} > {tool && ( @@ -1553,7 +1559,7 @@ export function AgentAvatarScene({ className="absolute bottom-3 right-3 rounded-full border border-signal-500/30 bg-white/90 px-2.5 py-1 text-[10px] font-bold uppercase tracking-[0.12em] text-signal-700 shadow-sm dark:bg-void-900/90 dark:text-signal-300" data-testid="agent-avatar-static-tool" > - {AGENT_SCENE_TOOL_CATALOG[tool].label} + {getAgentSceneToolLabel(tool, locale)} )}
diff --git a/dashboard/src/v2/components/agents/AgentAvatarStage.tsx b/dashboard/src/v2/components/agents/AgentAvatarStage.tsx index 11f72d627c..601b84bf53 100644 --- a/dashboard/src/v2/components/agents/AgentAvatarStage.tsx +++ b/dashboard/src/v2/components/agents/AgentAvatarStage.tsx @@ -5,6 +5,8 @@ import type { AgentAvatarExpression } from "../../lib/agent-avatar.js"; import { LazyAgentAvatarScene } from "./LazyAgentAvatarScene.js"; import { SHOWCASE_EXPRESSIONS } from "../../lib/agent-avatar.js"; import { useReducedMotion } from "../../hooks/use-reduced-motion.js"; +import { useDashboardI18n } from "../../i18n/index.js"; +import { agentsMessages } from "../../i18n/messages/agents.js"; /* ── Expression icon + label map (single source of truth) ── */ export const EXPRESSION_META: Record = { @@ -41,9 +43,18 @@ export const AgentAvatarStage: FunctionComponent<{ fallbackMode = false, disabled = false, }) => { + const { translate } = useDashboardI18n(); const reducedMotion = useReducedMotion(); const shouldFallback = fallbackMode || reducedMotion; + const expressionLabels: Record = { + happy: translate(agentsMessages, "expressionHappy"), + sad: translate(agentsMessages, "expressionSad"), + angry: translate(agentsMessages, "expressionAngry"), + bored: translate(agentsMessages, "expressionBored"), + hyped: translate(agentsMessages, "expressionHyped"), + }; const activeMeta = EXPRESSION_META[expression]; + const activeLabel = expressionLabels[expression] ?? expression; return (
- Bot expression changed to {activeMeta.label} + {translate(agentsMessages, "expressionChanged", { expression: activeLabel })} - Shuffle + {translate(agentsMessages, "shuffle")} )} @@ -93,14 +104,15 @@ export const AgentAvatarStage: FunctionComponent<{
{SHOWCASE_EXPRESSIONS.map((expr) => { const meta = EXPRESSION_META[expr]; + const label = expressionLabels[expr] ?? meta.label; const isActive = expression === expr; return (
{activeMeta && ( - {activeMeta.label} + {activeLabel} )}
diff --git a/dashboard/src/v2/components/agents/AgentKnowledgePanel.tsx b/dashboard/src/v2/components/agents/AgentKnowledgePanel.tsx index 518e0bf23b..b616510fe6 100644 --- a/dashboard/src/v2/components/agents/AgentKnowledgePanel.tsx +++ b/dashboard/src/v2/components/agents/AgentKnowledgePanel.tsx @@ -7,6 +7,8 @@ import { setAgentKnowledgeSubscriptions, type KnowledgeDocument, } from "../../lib/knowledge-api.js"; +import { useDashboardI18n } from "../../i18n/index.js"; +import { agentsMessages } from "../../i18n/messages/agents.js"; /** * Per-agent knowledge subscription manager. Lets an agent subscribe to documents from the project's @@ -19,6 +21,7 @@ export const AgentKnowledgePanel: FunctionComponent<{ disabled?: boolean; onSubscriptionsChanged?: (documentIds: string[]) => void; }> = ({ agentPresetId, projectId, disabled, onSubscriptionsChanged }) => { + const { formatNumber, translate, translatePlural } = useDashboardI18n(); const [documents, setDocuments] = useState([]); const [selected, setSelected] = useState>(new Set()); const [query, setQuery] = useState(""); @@ -52,12 +55,12 @@ export const AgentKnowledgePanel: FunctionComponent<{ setSelected(new Set(persisted)); onSubscriptionsChanged?.(persisted); } catch (err) { - setError(err instanceof Error ? err.message : "Failed to update subscription"); + setError(err instanceof Error ? err.message : translate(agentsMessages, "failedSubscription")); setSelected(selected); } finally { setSavingId(null); } - }, [agentPresetId, onSubscriptionsChanged, selected]); + }, [agentPresetId, onSubscriptionsChanged, selected, translate]); const toggle = useCallback(async (documentId: string) => { const next = new Set(selected); @@ -110,9 +113,9 @@ export const AgentKnowledgePanel: FunctionComponent<{ return (
); @@ -121,8 +124,8 @@ export const AgentKnowledgePanel: FunctionComponent<{ return (
- {selectedCount} subscribed · {documents.length} in library - {selectedCount > 0 && ~{manifestTokens} tok manifest} + {translate(agentsMessages, "knowledgeSummary", { selected: formatNumber(selectedCount), total: formatNumber(documents.length) })} + {selectedCount > 0 && {translate(agentsMessages, "manifestTokens", { count: formatNumber(manifestTokens) })}}
@@ -132,7 +135,7 @@ export const AgentKnowledgePanel: FunctionComponent<{ type="search" value={query} onInput={(event) => setQuery(event.currentTarget.value)} - placeholder="Search knowledge" + placeholder={translate(agentsMessages, "searchKnowledge")} className="w-full rounded-xl border border-black/[0.06] bg-white/50 py-2 pl-8 pr-3 text-[12px] font-medium text-slate-700 outline-none transition-colors placeholder:text-slate-400 focus:border-signal-500/40 dark:border-white/[0.06] dark:bg-white/[0.03] dark:text-slate-200 dark:placeholder:text-slate-500" />
@@ -144,7 +147,7 @@ export const AgentKnowledgePanel: FunctionComponent<{ className="inline-flex items-center gap-1.5 rounded-xl border border-black/[0.06] bg-white/45 px-2.5 py-2 text-[11px] font-bold text-slate-500 transition-colors hover:bg-white hover:text-slate-800 disabled:cursor-not-allowed disabled:opacity-50 dark:border-white/[0.06] dark:bg-white/[0.03] dark:text-slate-300 dark:hover:bg-white/[0.07] dark:hover:text-white" > {savingId === "__bulk__" ? : } - Select all + {translate(agentsMessages, "selectAll")}
@@ -163,7 +166,7 @@ export const AgentKnowledgePanel: FunctionComponent<{
{filteredDocuments.length === 0 ? (
- No matching knowledge documents. + {translate(agentsMessages, "noMatchingKnowledge")}
) : filteredDocuments.map((doc) => { const isSelected = selected.has(doc.id); @@ -188,7 +191,9 @@ export const AgentKnowledgePanel: FunctionComponent<{ {doc.summary &&
{doc.summary}
}
- {isReady ? `${doc.chunkCount} chunks` : doc.status === "error" ? "error" : "embedding…"} + {isReady + ? translatePlural(agentsMessages, "chunkCount", doc.chunkCount) + : translate(agentsMessages, doc.status === "error" ? "errorStatus" : "embedding")} ); diff --git a/dashboard/src/v2/components/agents/AgentMcpManageModal.tsx b/dashboard/src/v2/components/agents/AgentMcpManageModal.tsx index 62707c3028..c3fbea04a7 100644 --- a/dashboard/src/v2/components/agents/AgentMcpManageModal.tsx +++ b/dashboard/src/v2/components/agents/AgentMcpManageModal.tsx @@ -9,12 +9,14 @@ import { codeUxAgentMcpAccessWithoutScheduler, isSchedulerOnlyAgentMcpAccess, } from "../../lib/agent-mcp-display.js"; +import { useDashboardI18n } from "../../i18n/index.js"; +import { agentsMessages } from "../../i18n/messages/agents.js"; -const CATEGORY_META: Record = { - orchestration: { label: "Orchestration", description: "Projects, sprints, and tasks", icon: Boxes }, - agents_memory: { label: "Agents & Memory", description: "Agent presets and project memory", icon: BrainCircuit }, - platform: { label: "Platform", description: "Settings, previews, and telemetry", icon: SlidersHorizontal }, - advanced: { label: "Advanced", description: "Deprecated and low-level tools", icon: Wrench }, +const CATEGORY_META: Record = { + orchestration: { labelKey: "orchestration", descriptionKey: "orchestrationBody", icon: Boxes }, + agents_memory: { labelKey: "agentsMemory", descriptionKey: "agentsMemoryBody", icon: BrainCircuit }, + platform: { labelKey: "platform", descriptionKey: "platformBody", icon: SlidersHorizontal }, + advanced: { labelKey: "advanced", descriptionKey: "advancedBody", icon: Wrench }, }; const CATEGORY_ORDER: McpToolCategory[] = ["orchestration", "agents_memory", "platform", "advanced"]; @@ -34,8 +36,9 @@ export const AgentMcpManagePanel: FunctionComponent<{ isDashboardReplyAgent?: boolean; disabled?: boolean; }> = ({ onClose, value, onChange, availableServers, isDashboardReplyAgent = false, disabled }) => { + const { formatNumber, translate } = useDashboardI18n(); const [statusMessage, setStatusMessage] = useState( - "MCP access changes are pending until the agent is saved." + translate(agentsMessages, "mcpPending") ); const toolEnabledByName = useMemo(() => { const map = new Map(); @@ -52,8 +55,8 @@ export const AgentMcpManagePanel: FunctionComponent<{ if (disabled) return; if (enabled) { setStatusMessage(isDashboardReplyAgent - ? "Code UX MCP and scheduler enabled for dashboard chat. Save Agent to persist this access change." - : "Risk-gated Code UX access enabled with scheduler off. Save Agent only after reviewing this capability." + ? translate(agentsMessages, "mcpDashboardEnabled") + : translate(agentsMessages, "mcpRiskEnabled") ); onChange(isDashboardReplyAgent ? codeUxAgentMcpAccess(value.linkedServerIds) @@ -61,15 +64,15 @@ export const AgentMcpManagePanel: FunctionComponent<{ ); return; } - setStatusMessage("Code UX tools disabled. Save Agent to persist this access change."); + setStatusMessage(translate(agentsMessages, "mcpCodeUxDisabled")); onChange({ ...value, codeUxEnabled: false, codeUxToolToggles: [] }); }; const setTool = (name: string, enabled: boolean): void => { if (disabled) return; setStatusMessage(!isDashboardReplyAgent && enabled - ? `Risk-gated ${name} access enabled for a non-chat agent. Save Agent only after reviewing this capability.` - : `${name} ${enabled ? "enabled" : "disabled"}. Save Agent to persist tool access.` + ? translate(agentsMessages, "mcpRiskToolEnabled", { name }) + : translate(agentsMessages, "mcpToolChanged", { name, state: translate(agentsMessages, enabled ? "stateEnabled" : "stateDisabled") }) ); onChange({ ...value, codeUxToolToggles: buildToolToggles((candidate) => (candidate === name ? enabled : isToolEnabled(candidate))) }); }; @@ -78,8 +81,8 @@ export const AgentMcpManagePanel: FunctionComponent<{ if (disabled) return; const names = new Set(TOOL_DEFINITIONS.filter((def) => def.category === category).map((def) => def.name)); setStatusMessage(!isDashboardReplyAgent && enabled - ? `Risk-gated ${CATEGORY_META[category].label} tools enabled for a non-chat agent. Save Agent only after reviewing these capabilities.` - : `${CATEGORY_META[category].label} tools ${enabled ? "enabled" : "disabled"}. Save Agent to persist tool access.` + ? translate(agentsMessages, "mcpRiskCategoryEnabled", { category: translate(agentsMessages, CATEGORY_META[category].labelKey) }) + : translate(agentsMessages, "mcpCategoryChanged", { category: translate(agentsMessages, CATEGORY_META[category].labelKey), state: translate(agentsMessages, enabled ? "stateEnabled" : "stateDisabled") }) ); onChange({ ...value, codeUxToolToggles: buildToolToggles((candidate) => (names.has(candidate as never) ? enabled : isToolEnabled(candidate))) }); }; @@ -89,10 +92,13 @@ export const AgentMcpManagePanel: FunctionComponent<{ if (disabled) return; const server = availableServers.find((entry) => entry.id === id); if (server?.enabled === false && linked) { - setStatusMessage(`${server.label || server.name} is off in Settings. Enable it there before linking this agent.`); + setStatusMessage(translate(agentsMessages, "serverOffLink", { name: server.label || server.name })); return; } - setStatusMessage(`${server?.label || server?.name || "Server"} ${linked ? "linked" : "unlinked"}. Save Agent to persist MCP server access.`); + setStatusMessage(translate(agentsMessages, "serverLinkChanged", { + name: server?.label || server?.name || "Server", + state: translate(agentsMessages, linked ? "stateLinked" : "stateUnlinked"), + })); onChange({ ...value, linkedServerIds: linked @@ -114,17 +120,17 @@ export const AgentMcpManagePanel: FunctionComponent<{
- MCP Access + {translate(agentsMessages, "mcpAccess")}

- Connected Servers + {translate(agentsMessages, "connectedServers")}

diff --git a/dashboard/src/v2/components/agents/AgentMemoryConfigPanel.tsx b/dashboard/src/v2/components/agents/AgentMemoryConfigPanel.tsx index 7ba0dd25ef..690611e217 100644 --- a/dashboard/src/v2/components/agents/AgentMemoryConfigPanel.tsx +++ b/dashboard/src/v2/components/agents/AgentMemoryConfigPanel.tsx @@ -3,6 +3,8 @@ import { useMemo, useState, useEffect } from "preact/hooks"; import { BrainCircuit, Check, ChevronDown, ChevronUp, X } from "lucide-preact"; import { DEFAULT_AGENT_MEMORY_CONFIG, MEMORY_CATEGORIES, type AgentMemoryConfig, type MemoryCategory } from "../../memory-types.js"; import { INTERACTION_CSS_VARIABLES } from "../../lib/motion/tokens.js"; +import { useDashboardI18n } from "../../i18n/index.js"; +import { agentsMessages } from "../../i18n/messages/agents.js"; export interface AgentMemoryConfigPanelProps { onClose: () => void; @@ -11,30 +13,21 @@ export interface AgentMemoryConfigPanelProps { disabled?: boolean; } -const TIER_OPTIONS: Array<{ value: AgentMemoryConfig["tier"]; label: string }> = [ - { value: "short_term", label: "Short Term" }, - { value: "both", label: "Both" }, - { value: "long_term", label: "Long Term" }, +const TIER_OPTIONS: Array<{ value: AgentMemoryConfig["tier"]; labelKey: "shortTerm" | "both" | "longTerm" }> = [ + { value: "short_term", labelKey: "shortTerm" }, + { value: "both", labelKey: "both" }, + { value: "long_term", labelKey: "longTerm" }, ]; const MAX_STRENGTH = 1; const MIN_STRENGTH = 0; const STRENGTH_STEP = 0.05; -const toTitleCase = (value: string): string => - value - .split(/[\s_-]+/g) - .filter(Boolean) - .map((part) => part.charAt(0).toUpperCase() + part.slice(1)) - .join(" "); - const clampStrength = (value: number): number => { if (Number.isNaN(value)) return 0; return Math.min(MAX_STRENGTH, Math.max(MIN_STRENGTH, value)); }; -const formatPercent = (value: number): string => `${Math.round(clampStrength(value) * 100)}%`; - const areStrengthsEqual = (left: number, right: number): boolean => Math.abs(left - right) < 1e-9; const formatMemoryCount = (value: number): string => (value === 0 ? "" : String(value)); @@ -86,6 +79,21 @@ export const AgentMemoryConfigPanel: FunctionComponent { + const { formatNumber, translate } = useDashboardI18n(); + const formatPercent = (strength: number): string => formatNumber(clampStrength(strength), { + style: "percent", + maximumFractionDigits: 0, + }); + const categoryLabel = (category: MemoryCategory): string => translate(agentsMessages, ({ + architecture: "categoryArchitecture", + codebase: "categoryCodebase", + context: "categoryContext", + preferences: "categoryPreferences", + patterns: "categoryPatterns", + decision: "categoryDecision", + error: "categoryError", + learning: "categoryLearning", + } as const)[category]); const [showOverrides, setShowOverrides] = useState(false); useEffect(() => { if (disabled) setShowOverrides(false); @@ -159,10 +167,10 @@ export const AgentMemoryConfigPanel: FunctionComponent
- Agents & Memory + {translate(agentsMessages, "agentsMemory")}

- Memory Injection + {translate(agentsMessages, "memoryInjection")}

@@ -171,7 +179,7 @@ export const AgentMemoryConfigPanel: FunctionComponent @@ -183,7 +191,7 @@ export const AgentMemoryConfigPanel: FunctionComponent - Defaults + {translate(agentsMessages, "defaults")} @@ -198,16 +206,16 @@ export const AgentMemoryConfigPanel: FunctionComponent - {disabled ? "Memory filters are locked while the agent is saving." : "Memory filter changes are pending until the agent is saved."} + {translate(agentsMessages, disabled ? "memoryLocked" : "memoryPending")}

- Tier + {translate(agentsMessages, "tier")}

- Choose whether this agent receives short-term, long-term, or both memory scopes. + {translate(agentsMessages, "tierBody")}

@@ -231,8 +239,8 @@ export const AgentMemoryConfigPanel: FunctionComponent - {option.label} - {active && selected} + {translate(agentsMessages, option.labelKey)} + {active && {translate(agentsMessages, "selected")}} ); })} @@ -243,10 +251,10 @@ export const AgentMemoryConfigPanel: FunctionComponent

- Categories + {translate(agentsMessages, "categories")}

- Empty means all categories are included. + {translate(agentsMessages, "categoriesBody")}

{MEMORY_CATEGORIES.map((category) => { const selected = isCategoryEnabled(value.categories, category); - const label = toTitleCase(category); + const label = categoryLabel(category); return ( ); @@ -297,10 +305,10 @@ export const AgentMemoryConfigPanel: FunctionComponent

- Minimum Strength + {translate(agentsMessages, "minimumStrength")}

- 0% means no minimum. + {translate(agentsMessages, "noMinimum")}

@@ -319,7 +327,7 @@ export const AgentMemoryConfigPanel: FunctionComponent
{formatPercent(value.minStrength)} @@ -334,7 +342,7 @@ export const AgentMemoryConfigPanel: FunctionComponent setMinStrength(Number((event.currentTarget as HTMLInputElement).value))} - aria-label="Minimum strength" + aria-label={translate(agentsMessages, "minimumStrength")} className="w-full accent-signal-500" />
@@ -343,7 +351,7 @@ export const AgentMemoryConfigPanel: FunctionComponent {visibleCategories.map((category) => { const override = value.minStrengthPerCategory[category] ?? value.minStrength; - const label = toTitleCase(category); + const label = categoryLabel(category); const inputId = `agent-memory-min-strength-${category}`; return (
setCategoryStrength(category, Number((event.currentTarget as HTMLInputElement).value))} - aria-label={`${label} minimum strength`} + aria-label={translate(agentsMessages, "categoryMinimumStrength", { category: label })} className="w-full accent-signal-500" />
@@ -376,7 +384,7 @@ export const AgentMemoryConfigPanel: FunctionComponent - No categories selected for injection. + {translate(agentsMessages, "noCategories")} )} @@ -387,17 +395,17 @@ export const AgentMemoryConfigPanel: FunctionComponent

- Max Memories + {translate(agentsMessages, "maxMemories")}

- Use 0 for unlimited. + {translate(agentsMessages, "zeroUnlimited")}

diff --git a/dashboard/src/v2/components/agents/AgentPresetDetailPanel.tsx b/dashboard/src/v2/components/agents/AgentPresetDetailPanel.tsx index e9807fe6b2..a76bbe233d 100644 --- a/dashboard/src/v2/components/agents/AgentPresetDetailPanel.tsx +++ b/dashboard/src/v2/components/agents/AgentPresetDetailPanel.tsx @@ -26,6 +26,9 @@ import { MARKDOWN_PROSE_CLASS } from "../ui/MarkdownEditorField.js"; import { estimateTokens, formatTokenCount } from "../../lib/token-estimate.js"; import { renderMarkdown } from "../../../lib/markdown.js"; import { PersistentSkillStorageChip } from "./PersistentSkillStorageChip.js"; +import { useDashboardI18n } from "../../i18n/index.js"; +import { agentsMessages } from "../../i18n/messages/agents.js"; +import type { DashboardTextMessageKey } from "../../i18n/index.js"; const INSTRUCTION_EXCERPT_CHARS = 320; const INSTRUCTION_EXCERPT_LINES = 6; @@ -39,11 +42,11 @@ export interface AgentUsageSummary { totalCostCents: number; } -function formatCost(cents: number): string { - if (cents <= 0) return "$0"; +function formatCost(cents: number, locale: "en" | "de"): string { + if (cents <= 0) return new Intl.NumberFormat(locale, { style: "currency", currency: "USD", maximumFractionDigits: 0 }).format(0); const dollars = cents / 100; - if (dollars < 0.01) return "<$0.01"; - return new Intl.NumberFormat("en-US", { + if (dollars < 0.01) return `<${new Intl.NumberFormat(locale, { style: "currency", currency: "USD", minimumFractionDigits: 2 }).format(0.01)}`; + return new Intl.NumberFormat(locale, { style: "currency", currency: "USD", minimumFractionDigits: dollars >= 10 ? 2 : 3, @@ -51,15 +54,11 @@ function formatCost(cents: number): string { }).format(dollars); } -function formatCount(value: number): string { - return new Intl.NumberFormat("en-US", { maximumFractionDigits: 0 }).format(value); -} - -function formatSuccessRate(summary?: AgentUsageSummary | null): string { - if (!summary) return "No runs"; +function formatSuccessRate(summary: AgentUsageSummary | null | undefined, locale: "en" | "de", noRuns: string, running: string): string { + if (!summary) return noRuns; const finished = summary.completedCount + summary.failedCount; - if (finished === 0) return summary.runningCount > 0 ? "Running" : "No runs"; - return `${Math.round((summary.completedCount / finished) * 100)}%`; + if (finished === 0) return summary.runningCount > 0 ? running : noRuns; + return new Intl.NumberFormat(locale, { style: "percent", maximumFractionDigits: 0 }).format(summary.completedCount / finished); } function makeExcerpt(raw: string): { excerpt: string; truncated: boolean } { @@ -96,12 +95,6 @@ const syncStatusDisplay = (preset: AgentPreset) => { } }; -const formatContainerRootMode = (value: boolean | null | undefined): string => { - if (value === true) return "Force root"; - if (value === false) return "Force non-root"; - return "Inherits setting"; -}; - /* ── Quick-fact stat tile ── */ const StatTile: FunctionComponent<{ label: string; @@ -127,6 +120,7 @@ const StatTile: FunctionComponent<{ /* ── Knowledge subscriptions summary (read-only) ── */ const AgentKnowledgeSummary: FunctionComponent<{ preset: AgentPreset }> = ({ preset }) => { + const { formatNumber, translate } = useDashboardI18n(); const [docs, setDocs] = useState(null); useEffect(() => { @@ -147,7 +141,7 @@ const AgentKnowledgeSummary: FunctionComponent<{ preset: AgentPreset }> = ({ pre return (
- +
{docs.map((doc) => ( { + const { formatNumber, locale, translate, translatePlural } = useDashboardI18n(); const panelRef = useRef(null); const deleteButtonRef = useRef(null); const [activeExpression, setActiveExpression] = useState("happy"); @@ -228,9 +223,18 @@ export const AgentPresetDetailPanel: FunctionComponent<{ const [deleteConfirmOpen, setDeleteConfirmOpen] = useState(false); const accentHex = getAccentHex(preset.avatarConfig?.accent); const sync = syncStatusDisplay(preset); + const localizedSyncLabel = translate(agentsMessages, { + "Out of Sync": "outOfSync", + "Source Missing": "sourceMissing", + Project: "project", + Default: "default", + Home: "home", + "Database Only": "databaseOnly", + }[sync.label] as "outOfSync" | "sourceMissing" | "project" | "default" | "home" | "databaseOnly"); const selectedProvider = providerOptions.find((option) => option.value === preset.providerConfigId) || null; const mcpTags = resolveAgentMcpTags(preset.mcpAccess, availableMcpServers, { effectiveCodeUxEnabled: isDashboardReplyAgent, + locale, }); const visibleMcpTags = mcpTags.slice(0, 6); const hiddenMcpTagCount = mcpTags.length - visibleMcpTags.length; @@ -238,7 +242,19 @@ export const AgentPresetDetailPanel: FunctionComponent<{ .map((storageId) => availableSkillStorages.find((storage) => storage.id === storageId) ?? null) .filter((storage): storage is SkillStorageRecord => Boolean(storage)); const persistentSkillsActive = Boolean(preset.persistentSkillStorage?.enabled && attachedSkillStorages.length > 0); - const containerRootModeLabel = formatContainerRootMode(preset.containerRunAsRoot); + const containerRootModeLabel = translate(agentsMessages, preset.containerRunAsRoot === true + ? "forceRoot" + : preset.containerRunAsRoot === false ? "forceNonRoot" : "inheritsSetting"); + const routeTagLabel = (tag: string): string => { + const keys: Record> = { + Planning: "routePlanning", "Coding Roster": "routeCodingRoster", Coding: "routeCoding", + "CI Fix": "routeCiFix", "Merge Conflict": "routeMergeConflict", "Dashboard Reply": "routeDashboardReply", + "Clarification Reply": "routeClarificationReply", "QA Task": "routeQaTask", "QA Sprint": "routeQaSprint", + "QA No PR": "routeQaNoPr", + }; + const key = keys[tag]; + return key ? translate(agentsMessages, key) : tag; + }; useLayoutEffect(() => { if (!panelRef.current) return; @@ -297,7 +313,7 @@ export const AgentPresetDetailPanel: FunctionComponent<{ - Agent Profile + {translate(agentsMessages, "agentProfile")}

{preset.name} @@ -314,7 +330,7 @@ export const AgentPresetDetailPanel: FunctionComponent<{ className="inline-flex shrink-0 items-center gap-2 rounded-full bg-signal-500 px-5 py-2.5 text-sm font-bold text-white shadow-lg shadow-signal-500/15 transition-all hover:scale-[1.03] hover:bg-signal-400 hover:shadow-signal-500/25 focus:outline-none focus-visible:ring-2 focus-visible:ring-signal-500/30 focus-visible:ring-offset-2 dark:text-void-900" > - Edit + {translate(agentsMessages, "edit")}

@@ -326,66 +342,66 @@ export const AgentPresetDetailPanel: FunctionComponent<{ className="inline-flex items-center rounded-full px-2.5 py-1 text-[10px] font-bold uppercase tracking-[0.12em]" style={{ backgroundColor: `${accentHex}10`, color: accentHex }} > - {tag} + {routeTagLabel(tag)} ))} {routeTags.length === 0 && ( - No assigned routes + {translate(agentsMessages, "noAssignedRoutes")} )} {preset.syncStatus === "out_of_sync" && } - {sync.label} + {localizedSyncLabel}
{/* Quick facts */}
: } /> } /> } accent={mcpTags.length > 0} /> } accent={preset.containerRunAsRoot === true} /> } /> } accent={(usageSummary?.totalCostCents ?? 0) > 0} /> } /> } accent={(usageSummary?.completedCount ?? 0) > 0} /> @@ -397,7 +413,7 @@ export const AgentPresetDetailPanel: FunctionComponent<{ {/* Connected MCPs */}
- +
{visibleMcpTags.map((tag) => ( - No MCP servers + {translate(agentsMessages, "noMcpServers")} )}
@@ -430,20 +446,20 @@ export const AgentPresetDetailPanel: FunctionComponent<{ {/* Persistent skills */}
- +
- Persistent skill retrieval is separate from memory and knowledge documents. + {translate(agentsMessages, "persistentSkillsSeparation")}
- {persistentSkillsActive ? "Enabled" : "Default off"} + {translate(agentsMessages, persistentSkillsActive ? "enabled" : "defaultOff")}
{attachedSkillStorages.length === 0 ? ( - No storage attached + {translate(agentsMessages, "noStorageAttached")} ) : attachedSkillStorages.map((storage) => ( @@ -456,10 +472,10 @@ export const AgentPresetDetailPanel: FunctionComponent<{
- ~{formatTokenCount(instructionTokens)} tok + ~{formatTokenCount(instructionTokens, locale)} {translate(agentsMessages, "tokens")} ) : undefined} /> @@ -479,12 +495,12 @@ export const AgentPresetDetailPanel: FunctionComponent<{ {instructionExpanded ? ( <> - Show less + {translate(agentsMessages, "showLess")} ) : ( <> - Show full ({preset.instructionMarkdown.length.toLocaleString()} chars) + {translate(agentsMessages, "showFullCharacters", { count: formatNumber(preset.instructionMarkdown.length) })} )} @@ -492,7 +508,7 @@ export const AgentPresetDetailPanel: FunctionComponent<{
) : (
- No instructions provided. + {translate(agentsMessages, "noInstructions")}
)}
@@ -502,11 +518,11 @@ export const AgentPresetDetailPanel: FunctionComponent<{
- ~{formatTokenCount(memoryTokens)} tok + ~{formatTokenCount(memoryTokens, locale)} {translate(agentsMessages, "tokens")} )} /> @@ -524,12 +540,12 @@ export const AgentPresetDetailPanel: FunctionComponent<{ {memoryExpanded ? ( <> - Show less + {translate(agentsMessages, "showLess")} ) : ( <> - Show full ({preset.memoryTemplateMarkdown.length.toLocaleString()} chars) + {translate(agentsMessages, "showFullCharacters", { count: formatNumber(preset.memoryTemplateMarkdown.length) })} )} @@ -544,7 +560,7 @@ export const AgentPresetDetailPanel: FunctionComponent<{
- Markdown Source + {translate(agentsMessages, "markdownSource")}
{preset.sourcePath}
@@ -560,7 +576,7 @@ export const AgentPresetDetailPanel: FunctionComponent<{ className="inline-flex items-center gap-2 rounded-full border border-signal-500/20 bg-signal-500/8 px-4 py-2 text-[10px] font-bold uppercase tracking-[0.14em] text-signal-600 transition-colors hover:bg-signal-500/15 disabled:cursor-not-allowed disabled:opacity-50 dark:text-signal-400 focus:outline-none focus-visible:ring-2 focus-visible:ring-signal-500/30" > {importing ? : } - Import + {translate(agentsMessages, "import")} )} {preset.id} @@ -591,10 +607,10 @@ export const AgentPresetDetailPanel: FunctionComponent<{ { diff --git a/dashboard/src/v2/components/agents/AgentPresetEditorPanel.tsx b/dashboard/src/v2/components/agents/AgentPresetEditorPanel.tsx index 3175cee5bb..4913203ed3 100644 --- a/dashboard/src/v2/components/agents/AgentPresetEditorPanel.tsx +++ b/dashboard/src/v2/components/agents/AgentPresetEditorPanel.tsx @@ -41,6 +41,9 @@ import { getAccentHex, generateRandomAgentAvatar } from "../../lib/agent-avatar. import { defaultAgentMcpAccess, normalizeAgentMcpAccess } from "../../lib/agent-mcp-display.js"; import { estimateTokens, formatTokenCount } from "../../lib/token-estimate.js"; import { PersistentSkillStorageChip } from "./PersistentSkillStorageChip.js"; +import { useDashboardI18n } from "../../i18n/index.js"; +import type { DashboardMessageVariables, DashboardTextMessageKey } from "../../i18n/index.js"; +import { agentsMessages } from "../../i18n/messages/agents.js"; /* ───────────────────────────────────────────────────────── * Validation rules @@ -73,25 +76,25 @@ function validate({ instruction: string; memoryEnabled: boolean; memory: string; -}): FormErrors { +}, localize: (key: DashboardTextMessageKey, variables?: DashboardMessageVariables) => string, formatNumber: (value: number) => string): FormErrors { const errors: FormErrors = {}; const trimmedName = name.trim(); if (!trimmedName) { - errors.name = "Name is required"; + errors.name = localize("nameRequired"); } else if (trimmedName.length > NAME_MAX) { - errors.name = `Name must be ${NAME_MAX} characters or fewer`; + errors.name = localize("nameTooLong", { limit: formatNumber(NAME_MAX) }); } if (description.trim().length > DESCRIPTION_MAX) { - errors.description = `Description must be ${DESCRIPTION_MAX} characters or fewer`; + errors.description = localize("descriptionTooLong", { limit: formatNumber(DESCRIPTION_MAX) }); } if (instruction.length > INSTRUCTION_SOFT_MAX * 1.5) { - errors.instruction = `Instructions exceed safe limit (${instruction.length.toLocaleString()} / ${(INSTRUCTION_SOFT_MAX * 1.5).toLocaleString()})`; + errors.instruction = localize("instructionsTooLong", { current: formatNumber(instruction.length), limit: formatNumber(INSTRUCTION_SOFT_MAX * 1.5) }); } if (memoryEnabled && memory.trim().length === 0) { - errors.memory = "Provide an override or disable the toggle"; + errors.memory = localize("memoryOverrideRequired"); } return errors; @@ -111,27 +114,21 @@ const fromContainerRootMode = (value: ContainerRootMode): boolean | null => { const CONTAINER_ROOT_MODE_OPTIONS: Array<{ value: ContainerRootMode; - label: string; - hint: string; - ariaLabel: string; + labelKey: "inherit" | "forceNonRoot" | "forceRoot"; + hintKey: "inheritRootHint" | "forceNonRootHint" | "forceRootHint"; + ariaLabelKey: "inheritRootAria" | "forceNonRootAria" | "forceRootAria"; }> = [ { value: "inherit", - label: "Inherit", - hint: "Use the scoped Docker Runtime setting.", - ariaLabel: "Inherit global Docker root setting", + labelKey: "inherit", hintKey: "inheritRootHint", ariaLabelKey: "inheritRootAria", }, { value: "non_root", - label: "Force non-root", - hint: "Keep this agent on the default safer posture.", - ariaLabel: "Force Docker non-root for this agent", + labelKey: "forceNonRoot", hintKey: "forceNonRootHint", ariaLabelKey: "forceNonRootAria", }, { value: "root", - label: "Force root", - hint: "Only for tools that need package-manager or OS-level writes.", - ariaLabel: "Force Docker root for this agent", + labelKey: "forceRoot", hintKey: "forceRootHint", ariaLabelKey: "forceRootAria", }, ]; @@ -216,31 +213,36 @@ const formatMemoryStrength = (value: number): string => { return value.toFixed(2).replace(/\.?0+$/, ""); }; -const formatMemoryConfigSummary = (config: AgentMemoryConfig): string => { +const formatMemoryConfigSummary = ( + config: AgentMemoryConfig, + localize: (key: DashboardTextMessageKey, variables?: DashboardMessageVariables) => string, + pluralize: (key: "categoryCount", count: number) => string, + formatNumber: (value: number) => string, +): string => { const tierLabel = config.tier === "both" - ? "Both tiers" + ? localize("bothTiers") : config.tier === "short_term" - ? "Short term" - : "Long term"; + ? localize("shortTerm") + : localize("longTerm"); const categoryLabel = config.categories.length === 0 - ? "All categories" - : `${config.categories.length} category${config.categories.length === 1 ? "" : "s"}`; + ? localize("allCategories") + : pluralize("categoryCount", config.categories.length); const parts = [tierLabel, categoryLabel]; if (config.minStrength > 0) { - parts.push(`min ${formatMemoryStrength(config.minStrength)}`); + parts.push(localize("minStrengthSummary", { value: formatMemoryStrength(config.minStrength) })); } if (config.maxShortTerm > 0) { - parts.push(`${config.maxShortTerm} short-term`); + parts.push(localize("shortTermCount", { count: formatNumber(config.maxShortTerm) })); } if (config.maxLongTerm > 0) { - parts.push(`${config.maxLongTerm} long-term`); + parts.push(localize("longTermCount", { count: formatNumber(config.maxLongTerm) })); } return parts.join(" · "); @@ -263,6 +265,10 @@ export const AgentPresetEditorPanel: FunctionComponent<{ onSave: (id: string, updates: Partial) => void; onCancel: () => void; }> = ({ preset, saving, defaultMemoryInstruction = "", providerOptions = [], availableMcpServers = [], availableSkillStorages = [], isDashboardReplyAgent = false, onSave, onCancel }) => { + const { formatDate, formatNumber, locale, translate, translatePlural } = useDashboardI18n(); + const t = useCallback((key: DashboardTextMessageKey, variables?: DashboardMessageVariables): string => ( + translate(agentsMessages, key, variables) + ), [translate]); const panelRef = useRef(null); const nameRef = useRef(null); const descriptionRef = useRef(null); @@ -299,7 +305,7 @@ export const AgentPresetEditorPanel: FunctionComponent<{ const [knowledgeDirty, setKnowledgeDirty] = useState(false); const [actionStatus, setActionStatus] = useState({ tone: "neutral", - message: "Validation runs after fields are edited or Save Agent is pressed.", + message: t("validationHint"), }); const setMcpAccessNormalized = (next: AgentMcpAccessConfig): void => setMcpAccess(normalizeAgentMcpAccess(next)); @@ -309,7 +315,7 @@ export const AgentPresetEditorPanel: FunctionComponent<{ const handleRandomizeAvatar = (): void => { const seed = Date.now().toString(36) + Math.random().toString(36).substring(2); setAvatarConfig(generateRandomAgentAvatar(seed)); - setActionStatus({ tone: "success", message: "Avatar randomized. Save Agent to keep the new appearance." }); + setActionStatus({ tone: "success", message: t("avatarRandomizedSave") }); }; /* Reset when preset switches */ @@ -330,8 +336,8 @@ export const AgentPresetEditorPanel: FunctionComponent<{ setShowMemoryPanel(false); setTouched({}); setKnowledgeDirty(false); - setActionStatus({ tone: "neutral", message: "Validation runs after fields are edited or Save Agent is pressed." }); - }, [preset.id]); + setActionStatus({ tone: "neutral", message: t("validationHint") }); + }, [preset.id, t]); /* Entry animation */ useLayoutEffect(() => { @@ -348,8 +354,8 @@ export const AgentPresetEditorPanel: FunctionComponent<{ instruction: instructionMarkdown, memoryEnabled: memoryOverrideEnabled, memory: memoryMarkdown, - }), - [name, description, instructionMarkdown, memoryOverrideEnabled, memoryMarkdown] + }, t, (value) => formatNumber(value)), + [name, description, instructionMarkdown, memoryOverrideEnabled, memoryMarkdown, t, formatNumber] ); const hasErrors = Object.keys(errors).length > 0; @@ -414,14 +420,14 @@ export const AgentPresetEditorPanel: FunctionComponent<{ setTouched({ name: true, description: true, instruction: true, memory: true }); if (hasErrors) { focusFirstInvalidField(errors); - setActionStatus({ tone: "error", message: "Fix the highlighted fields, then retry Save Agent." }); + setActionStatus({ tone: "error", message: t("fixHighlighted") }); return; } if (!isDirty) { - setActionStatus({ tone: "neutral", message: "No changes to save." }); + setActionStatus({ tone: "neutral", message: t("noChangesToSave") }); return; } - setActionStatus({ tone: "pending", message: "Saving agent changes..." }); + setActionStatus({ tone: "pending", message: t("savingAgentChanges") }); onSave(preset.id, { name: name.trim(), description: description.trim(), @@ -477,7 +483,12 @@ export const AgentPresetEditorPanel: FunctionComponent<{ const memoryIsDefault = memoryMarkdown.trim() === defaultMemoryInstruction.trim() && defaultMemoryInstruction.trim().length > 0; - const memoryConfigSummary = useMemo(() => formatMemoryConfigSummary(memoryConfig), [memoryConfig]); + const memoryConfigSummary = useMemo(() => formatMemoryConfigSummary( + memoryConfig, + t, + (_key, count) => translatePlural(agentsMessages, "categoryCount", count), + (value) => formatNumber(value), + ), [memoryConfig, t, translatePlural, formatNumber]); const instructionLength = instructionMarkdown.length; const instructionOver = instructionLength > INSTRUCTION_SOFT_MAX; @@ -488,8 +499,8 @@ export const AgentPresetEditorPanel: FunctionComponent<{ const mcpItems = useMemo(() => ([ { id: "code_ux", - label: isDashboardReplyAgent && !mcpAccess.codeUxEnabled ? "Code UX · Runtime" : "Code UX", - active: isDashboardReplyAgent || mcpAccess.codeUxEnabled, + label: isDashboardReplyAgent && !mcpAccess.codeUxEnabled ? `Code UX · ${t("runtime")}` : "Code UX", + active: mcpAccess.codeUxEnabled, kind: "code_ux" as const, }, ...availableMcpServers.map((server) => ({ @@ -498,7 +509,7 @@ export const AgentPresetEditorPanel: FunctionComponent<{ active: mcpAccess.linkedServerIds.includes(server.id), kind: "custom" as const, })), - ]), [mcpAccess, availableMcpServers, isDashboardReplyAgent]); + ]), [mcpAccess, availableMcpServers, isDashboardReplyAgent, t]); const visibleMcpItems = mcpItems.slice(0, 5); const hiddenMcpCount = mcpItems.length - visibleMcpItems.length; const activeMcpCount = mcpItems.filter((item) => item.active).length; @@ -510,14 +521,14 @@ export const AgentPresetEditorPanel: FunctionComponent<{ setActionStatus({ tone: isDashboardReplyAgent ? "neutral" : "error", message: isDashboardReplyAgent - ? "Review Code UX MCP and scheduler access before enabling it for the dashboard reply agent." - : "Code UX access is risk-gated for non-chat agents. Review the MCP manager warning before enabling it.", + ? t("reviewDashboardMcp") + : t("reviewRiskMcp"), }); return; } setActionStatus({ tone: "success", - message: `${item.label} ${item.active ? "disabled" : "enabled"} for this agent. Save Agent to persist MCP access.`, + message: t("mcpItemChanged", { name: item.label, state: t(item.active ? "stateDisabled" : "stateEnabled") }), }); if (item.kind === "code_ux") { setMcpAccessNormalized({ ...mcpAccess, codeUxEnabled: !item.active }); @@ -544,7 +555,7 @@ export const AgentPresetEditorPanel: FunctionComponent<{ ref={panelRef} onSubmit={handleSubmit} noValidate - aria-label={`Edit ${preset.name}`} + aria-label={t("editAgentAria", { name: preset.name })} className="relative flex flex-col overflow-hidden rounded-[1.9rem] border border-black/[0.06] bg-white/70 shadow-[0_2px_20px_rgba(0,0,0,0.04)] backdrop-blur-2xl dark:border-white/[0.06] dark:bg-void-800/60 dark:shadow-[0_4px_24px_rgba(0,0,0,0.2)]" > @@ -554,33 +565,33 @@ export const AgentPresetEditorPanel: FunctionComponent<{
- Editing Agent + {t("editingAgent")} {isDirty && !saving && ( - Unsaved + {t("unsaved")} )} {!isDirty && !saving && ( - Saved + {t("saved")} )} {saving && ( - Saving + {t("saving")} )}

- {name.trim() || "Unnamed Agent"} + {name.trim() || t("unnamedAgent")}

- {isMac ? "⌘S" : "Ctrl+S"} to save · Esc to cancel + {t("shortcutSaveCancel", { saveShortcut: isMac ? "⌘S" : "Ctrl+S" })}
{submitDisabled && !saving && (

- {hasErrors ? "Fix errors to save" : "No changes"} + {t(hasErrors ? "fixErrorsToSave" : "noChanges")}

)}
{/* Row 1 — profile identity + live appearance customizer */}
- + @@ -652,7 +663,7 @@ export const AgentPresetEditorPanel: FunctionComponent<{ value={name} onInput={(event) => setName(event.currentTarget.value)} onBlur={() => setTouched((t) => ({ ...t, name: true }))} - placeholder="e.g. Planning Agent" + placeholder={t("namePlaceholder")} maxLength={NAME_MAX + 20} autoComplete="off" aria-required="true" @@ -668,10 +679,10 @@ export const AgentPresetEditorPanel: FunctionComponent<{ @@ -681,7 +692,7 @@ export const AgentPresetEditorPanel: FunctionComponent<{ value={description} onInput={(event) => setDescription(event.currentTarget.value)} onBlur={() => setTouched((t) => ({ ...t, description: true }))} - placeholder="e.g. Frontend specialist for Preact, Tailwind, responsive UI, and accessibility work." + placeholder={t("descriptionPlaceholder")} rows={3} maxLength={DESCRIPTION_MAX + 60} aria-invalid={touched.description && !!errors.description} @@ -695,12 +706,12 @@ export const AgentPresetEditorPanel: FunctionComponent<{ - + { setAvatarConfig(next); - setActionStatus({ tone: "success", message: "Avatar option changed. Save Agent to persist appearance." }); + setActionStatus({ tone: "success", message: t("avatarChangedSave") }); }} disabled={saving} /> @@ -708,13 +719,13 @@ export const AgentPresetEditorPanel: FunctionComponent<{
{/* Row 2 — behavior (full width for long prompts) */} - + @@ -725,15 +736,15 @@ export const AgentPresetEditorPanel: FunctionComponent<{ value={instructionMarkdown} onChange={setInstructionMarkdown} onBlur={() => setTouched((t) => ({ ...t, instruction: true }))} - placeholder={"You are a planning specialist. Decompose user goals into clear, testable subtasks…"} + placeholder={t("instructionsPlaceholder")} minRows={8} minHeightClass="min-h-[14rem]" invalid={touched.instruction && !!errors.instruction} ariaErrorId={touched.instruction && errors.instruction ? "agent-instructions-error" : undefined} - emptyPreviewHint="No instructions yet — switch to Write to compose the system prompt." + emptyPreviewHint={t("noInstructionsPreview")} toolbarNote={instructionOver ? ( - Long + {t("longPrompt")} ) : undefined} /> @@ -741,7 +752,7 @@ export const AgentPresetEditorPanel: FunctionComponent<{ {instructionOver && !errors.instruction && (

- {instructionLength.toLocaleString()} characters exceeds the recommended {INSTRUCTION_SOFT_MAX.toLocaleString()} — long prompts increase latency and cost. + {t("instructionLengthWarning", { count: formatNumber(instructionLength), limit: formatNumber(INSTRUCTION_SOFT_MAX) })}

)}
@@ -756,16 +767,16 @@ export const AgentPresetEditorPanel: FunctionComponent<{
- Memory Template Override + {t("memoryTemplateOverride")}

- Replace the project default with a bespoke memory prompt for this agent only. + {t("memoryTemplateHelper")}

- {memoryMarkdown.length.toLocaleString()} chars · ~{formatTokenCount(memoryTokens)} tok + {t("characterTokenCount", { characters: formatNumber(memoryMarkdown.length), tokens: formatTokenCount(memoryTokens, locale) })} {memoryIsDefault && ( - Default + {t("default")} )} {!memoryIsDefault && defaultMemoryInstruction.trim().length > 0 && ( @@ -808,13 +819,13 @@ export const AgentPresetEditorPanel: FunctionComponent<{ type="button" onClick={() => { useDefaultMemory(); - setActionStatus({ tone: "success", message: "Memory template reset to the project default. Save Agent to persist it." }); + setActionStatus({ tone: "success", message: t("memoryResetSave") }); }} disabled={saving} className="inline-flex items-center gap-1 rounded-md border border-black/[0.06] bg-white/60 px-2 py-0.5 text-[9px] font-bold uppercase tracking-[0.14em] text-slate-500 transition-colors hover:bg-white hover:text-slate-900 dark:border-white/[0.06] dark:bg-white/[0.04] dark:text-slate-400 dark:hover:bg-white/[0.08] dark:hover:text-white" > - Reset to default + {t("resetToDefault")} )}
@@ -826,12 +837,12 @@ export const AgentPresetEditorPanel: FunctionComponent<{ value={memoryMarkdown} onChange={setMemoryMarkdown} onBlur={() => setTouched((t) => ({ ...t, memory: true }))} - placeholder="Override the default memory prompt template for this agent." + placeholder={t("memoryOverridePlaceholder")} minRows={5} minHeightClass="min-h-[10rem]" invalid={touched.memory && !!errors.memory} ariaErrorId={touched.memory && errors.memory ? "agent-memory-error" : undefined} - emptyPreviewHint="No override yet — switch to Write to compose the memory template." + emptyPreviewHint={t("noMemoryPreview")} />
{touched.memory && errors.memory && ( @@ -851,10 +862,10 @@ export const AgentPresetEditorPanel: FunctionComponent<{
- Memory Injection Filters + {t("memoryInjectionFilters")}

- Control which memories are injected into this agent's prompts. + {t("memoryFiltersBody")}

@@ -874,7 +885,7 @@ export const AgentPresetEditorPanel: FunctionComponent<{ value={memoryConfig} onChange={(next) => { setMemoryConfig(next); - setActionStatus({ tone: "success", message: "Memory filters updated. Save Agent to persist them." }); + setActionStatus({ tone: "success", message: t("memoryFiltersSave") }); }} onClose={() => setShowMemoryPanel(false)} disabled={saving} @@ -888,7 +899,7 @@ export const AgentPresetEditorPanel: FunctionComponent<{ className="inline-flex items-center gap-1.5 rounded-full border border-black/[0.08] bg-white/60 px-3 py-1.5 text-[10px] font-bold uppercase tracking-[0.14em] text-slate-600 transition-colors hover:bg-white hover:text-slate-900 focus:outline-none focus-visible:ring-2 focus-visible:ring-signal-500/30 disabled:cursor-not-allowed disabled:opacity-50 dark:border-white/[0.08] dark:bg-white/[0.04] dark:text-slate-300 dark:hover:bg-white/[0.08] dark:hover:text-white" > - Manage Memory + {t("manageMemory")} @@ -901,25 +912,25 @@ export const AgentPresetEditorPanel: FunctionComponent<{
- +
- Persistent skill retrieval + {t("persistentSkillRetrieval")}

- Attach durable skill storages to this agent. Retrieval is disabled until storage is attached and this opt-in is enabled. + {t("persistentSkillBody")}

- {persistentSkillsActive ? "Enabled" : "Default off"} + {t(persistentSkillsActive ? "enabled" : "defaultOff")}
diff --git a/dashboard/src/v2/components/agents/InstructionFileCard.tsx b/dashboard/src/v2/components/agents/InstructionFileCard.tsx index d44f154391..f86b197791 100644 --- a/dashboard/src/v2/components/agents/InstructionFileCard.tsx +++ b/dashboard/src/v2/components/agents/InstructionFileCard.tsx @@ -6,12 +6,15 @@ import type { InstructionFileSummary } from "../../lib/instruction-file-api.js"; import { ProviderBrandIcon } from "../providers/ProviderBrandIcon.js"; import { getInstructionAccentHex, formatBytes } from "../../lib/instruction-file-display.js"; import { INTERACTION_CSS_VARIABLES } from "../../lib/motion/tokens.js"; +import { useDashboardI18n } from "../../i18n/index.js"; +import { agentsMessages } from "../../i18n/messages/agents.js"; export const InstructionFileCard: FunctionComponent<{ file: InstructionFileSummary; isSelected: boolean; onClick: () => void; }> = ({ file, isSelected, onClick }) => { + const { locale, translate } = useDashboardI18n(); const cardRef = useRef(null); const accentHex = getInstructionAccentHex(file.providerId); @@ -97,11 +100,11 @@ export const InstructionFileCard: FunctionComponent<{ : "border-black/[0.06] bg-black/[0.03] text-slate-400 dark:border-white/[0.06] dark:bg-white/[0.03] dark:text-slate-500" }`} > - {file.exists ? formatBytes(file.size) : (<>New)} + {file.exists ? formatBytes(file.size, locale) : (<>{translate(agentsMessages, "new")})} {isSelected && ( - Selected + {translate(agentsMessages, "selected")} )} diff --git a/dashboard/src/v2/components/agents/InstructionFileEditorPanel.tsx b/dashboard/src/v2/components/agents/InstructionFileEditorPanel.tsx index 251949cef1..091074515e 100644 --- a/dashboard/src/v2/components/agents/InstructionFileEditorPanel.tsx +++ b/dashboard/src/v2/components/agents/InstructionFileEditorPanel.tsx @@ -13,6 +13,8 @@ import { MARKDOWN_PROSE_CLASS } from "../ui/MarkdownEditorField.js"; import { getInstructionAccentHex, formatBytes } from "../../lib/instruction-file-display.js"; import { estimateTokens, formatTokenCount } from "../../lib/token-estimate.js"; import { renderMarkdown } from "../../../lib/markdown.js"; +import { useDashboardI18n } from "../../i18n/index.js"; +import { agentsMessages } from "../../i18n/messages/agents.js"; const isMac = typeof navigator !== "undefined" && /Mac|iPhone|iPad/i.test(navigator.platform); @@ -29,6 +31,7 @@ export const InstructionFileEditorPanel: FunctionComponent<{ file: InstructionFileSummary; onSaved: (updated: InstructionFileContent) => void; }> = ({ projectId, file, onSaved }) => { + const { formatNumber, locale, translate } = useDashboardI18n(); const panelRef = useRef(null); const textareaRef = useRef(null); const accentHex = getInstructionAccentHex(file.providerId); @@ -44,7 +47,9 @@ export const InstructionFileEditorPanel: FunctionComponent<{ const [touched, setTouched] = useState(false); const dirty = content !== loadedContent; - const validationError = touched && content.trim().length === 0 ? "Instruction file content is required before saving." : null; + const validationError = touched && content.trim().length === 0 + ? translate(agentsMessages, "instructionRequired") + : null; /* Load file content whenever the selected file changes */ useEffect(() => { @@ -84,7 +89,7 @@ export const InstructionFileEditorPanel: FunctionComponent<{ if (content.trim().length === 0) { setMode("write"); window.setTimeout(() => textareaRef.current?.focus(), 0); - setError("Instruction file content is required. Add guidance or use the starter template, then retry Save."); + setError(translate(agentsMessages, "instructionRequiredRetry")); return; } setSaving(true); @@ -101,7 +106,7 @@ export const InstructionFileEditorPanel: FunctionComponent<{ } finally { setSaving(false); } - }, [saving, dirty, projectId, file.id, content, onSaved]); + }, [saving, dirty, projectId, file.id, content, onSaved, translate]); /* Cmd/Ctrl+S to save */ useEffect(() => { @@ -130,14 +135,14 @@ export const InstructionFileEditorPanel: FunctionComponent<{ const previewHtml = useMemo(() => renderMarkdown(content), [content]); const status = saving - ? { cls: "border-signal-500/30 bg-signal-500/10 text-signal-600 dark:text-signal-400", label: "Saving", icon: } + ? { cls: "border-signal-500/30 bg-signal-500/10 text-signal-600 dark:text-signal-400", label: translate(agentsMessages, "saving"), icon: } : error - ? { cls: "border-status-red/20 bg-status-red/[0.08] text-status-red", label: "Needs retry", icon: } + ? { cls: "border-status-red/20 bg-status-red/[0.08] text-status-red", label: translate(agentsMessages, "needsRetry"), icon: } : dirty - ? { cls: "border-amber-400/30 bg-amber-400/10 text-amber-600 dark:text-amber-400", label: "Unsaved", icon: } + ? { cls: "border-amber-400/30 bg-amber-400/10 text-amber-600 dark:text-amber-400", label: translate(agentsMessages, "unsaved"), icon: } : justSaved - ? { cls: "border-signal-500/30 bg-signal-500/10 text-signal-600 dark:text-signal-400", label: "Saved", icon: } - : { cls: "border-black/[0.06] bg-white/60 text-slate-500 dark:border-white/[0.06] dark:bg-white/[0.03] dark:text-slate-400", label: exists ? "In sync" : "Not created", icon: }; + ? { cls: "border-signal-500/30 bg-signal-500/10 text-signal-600 dark:text-signal-400", label: translate(agentsMessages, "saved"), icon: } + : { cls: "border-black/[0.06] bg-white/60 text-slate-500 dark:border-white/[0.06] dark:bg-white/[0.03] dark:text-slate-400", label: translate(agentsMessages, exists ? "inSync" : "notCreated"), icon: }; return (
- {value === "write" ? "Write" : "Preview"} + {translate(agentsMessages, value === "write" ? "write" : "preview")} ); })} @@ -209,18 +214,18 @@ export const InstructionFileEditorPanel: FunctionComponent<{ type="button" onClick={() => { const previousFocus = document.activeElement as HTMLElement | null; - if (window.confirm("Revert unsaved instruction file edits? This restores the last saved content.")) { + if (window.confirm(translate(agentsMessages, "revertConfirm"))) { setContent(loadedContent); setTouched(false); setError(null); } window.setTimeout(() => previousFocus?.focus(), 0); }} - title="Revert changes" + title={translate(agentsMessages, "revertChanges")} className="inline-flex items-center gap-2 rounded-full border border-black/[0.08] bg-white/40 px-4 py-2.5 text-[12px] font-bold uppercase tracking-[0.12em] text-slate-600 backdrop-blur-md transition-colors hover:bg-white/70 hover:text-slate-900 dark:border-white/[0.08] dark:bg-white/[0.03] dark:text-slate-300 dark:hover:bg-white/[0.06] dark:hover:text-white focus:outline-none focus-visible:ring-2 focus-visible:ring-signal-500/30" > - Revert + {translate(agentsMessages, "revertChanges")} )} @@ -233,12 +238,12 @@ export const InstructionFileEditorPanel: FunctionComponent<{ className="inline-flex items-center gap-2 rounded-full bg-signal-500 px-5 py-2.5 text-[12px] font-bold uppercase tracking-[0.12em] text-white dark:text-void-900 shadow-[0_0_24px_rgba(0,224,160,0.28)] transition-all hover:scale-[1.03] hover:bg-signal-400 hover:shadow-[0_0_32px_rgba(0,224,160,0.36)] focus:outline-none focus-visible:ring-2 focus-visible:ring-signal-500/30 disabled:cursor-not-allowed disabled:bg-slate-200 disabled:text-slate-400 disabled:shadow-none disabled:hover:scale-100 dark:disabled:bg-white/[0.05] dark:disabled:text-slate-500" > {saving ? : } - Save + {translate(agentsMessages, "save")}
{dirty && !saving && ( - Unsaved changes + {translate(agentsMessages, "unsavedChanges")} )} @@ -253,23 +258,23 @@ export const InstructionFileEditorPanel: FunctionComponent<{ )} {!error && (
- {saving ? "Saving instruction file..." : dirty ? "Unsaved instruction edits. Save to write the file." : "Instruction file is saved."} + {translate(agentsMessages, saving ? "savingInstructionFile" : dirty ? "unsavedInstructionEdits" : "instructionFileSaved")}
)} {/* Meta strip */}
- {content.length.toLocaleString()} chars + {translate(agentsMessages, "characterCount", { count: formatNumber(content.length) })} · - ~{formatTokenCount(tokens)} tok + ~{formatTokenCount(tokens, locale)} {translate(agentsMessages, "tokens")} {exists && ( <> · - {formatBytes(file.size)} on disk + {translate(agentsMessages, "bytesOnDisk", { size: formatBytes(file.size, locale) })} )} - {isMac ? "⌘S" : "Ctrl+S"} to save + {translate(agentsMessages, "shortcutToSave", { shortcut: isMac ? "⌘S" : "Ctrl+S" })}
@@ -285,7 +290,7 @@ export const InstructionFileEditorPanel: FunctionComponent<{ spellcheck={false} aria-invalid={!!validationError} aria-errormessage={validationError ? "instruction-file-content-error" : undefined} - placeholder={`# ${file.label}\n\nWrite the instructions agents should follow in this project…`} + placeholder={translate(agentsMessages, "instructionPlaceholder", { label: file.label })} className="block h-[60vh] min-h-[420px] w-full resize-y rounded-2xl border border-black/[0.05] bg-white/40 px-5 py-4 font-mono text-[13px] leading-relaxed text-slate-900 shadow-sm outline-none backdrop-blur-md transition-all placeholder-slate-400 focus:border-signal-500 focus:ring-4 focus:ring-signal-500/10 dark:border-white/[0.07] dark:bg-white/[0.03] dark:text-white dark:placeholder-slate-600 dark:focus:ring-signal-500/15" /> {validationError && ( @@ -302,7 +307,7 @@ export const InstructionFileEditorPanel: FunctionComponent<{ className="pointer-events-auto inline-flex items-center gap-2 rounded-full border border-signal-500/25 bg-white/85 px-4 py-2 text-[11px] font-bold uppercase tracking-[0.12em] text-signal-600 shadow-sm backdrop-blur-md transition-all hover:scale-[1.03] hover:bg-signal-500/10 dark:bg-void-800/85 dark:text-signal-400 focus:outline-none focus-visible:ring-2 focus-visible:ring-signal-500/30" > - Insert starter template + {translate(agentsMessages, "insertStarterTemplate")} )} @@ -315,7 +320,7 @@ export const InstructionFileEditorPanel: FunctionComponent<{ ) : (
-

Nothing to preview yet.

+

{translate(agentsMessages, "nothingToPreview")}

)} diff --git a/dashboard/src/v2/components/agents/LazyAgentAvatarScene.tsx b/dashboard/src/v2/components/agents/LazyAgentAvatarScene.tsx index abb4e04e71..002fcde487 100644 --- a/dashboard/src/v2/components/agents/LazyAgentAvatarScene.tsx +++ b/dashboard/src/v2/components/agents/LazyAgentAvatarScene.tsx @@ -5,11 +5,13 @@ import type { AgentAvatarConfig } from "../../types.js"; import type { AgentAvatarExpression } from "../../lib/agent-avatar.js"; import type { AgentResponseAnimation } from "../../../../../src/contracts/connection-chat-types.js"; import { - AGENT_SCENE_TOOL_CATALOG, + getAgentSceneToolLabel, type AgentSceneTool, } from "../../lib/agent-scene-tools.js"; import { useReducedMotion } from "../../hooks/use-reduced-motion.js"; import { AgentAvatarSvg } from "./AgentAvatarSvg.js"; +import { useDashboardI18n } from "../../i18n/index.js"; +import { agentsMessages } from "../../i18n/messages/agents.js"; const AgentAvatarScene = lazy(() => import("./AgentAvatarScene.js").then((module) => ({ default: module.AgentAvatarScene, @@ -32,6 +34,8 @@ function AgentAvatarSceneFallback({ tool, className = "h-full w-full", }: Pick) { + const { locale, translate } = useDashboardI18n(); + const toolLabel = tool ? getAgentSceneToolLabel(tool, locale) : null; return (
{tool && ( @@ -47,7 +53,7 @@ function AgentAvatarSceneFallback({ className="absolute bottom-3 right-3 rounded-full border border-signal-500/30 bg-white/90 px-2.5 py-1 text-[10px] font-bold uppercase tracking-[0.12em] text-signal-700 shadow-sm dark:bg-void-900/90 dark:text-signal-300" data-testid="agent-avatar-static-tool" > - {AGENT_SCENE_TOOL_CATALOG[tool].label} + {toolLabel} )}
diff --git a/dashboard/src/v2/components/agents/PersistentSkillStorageChip.tsx b/dashboard/src/v2/components/agents/PersistentSkillStorageChip.tsx index 48dcf4a32e..c7cadd2321 100644 --- a/dashboard/src/v2/components/agents/PersistentSkillStorageChip.tsx +++ b/dashboard/src/v2/components/agents/PersistentSkillStorageChip.tsx @@ -8,6 +8,8 @@ import type { import type { SkillStorageRecord } from "../../types.js"; import { fetchSkillStorageContents } from "../../lib/agent-preset-api.js"; import { Tooltip } from "../ui/Tooltip.js"; +import { useDashboardI18n } from "../../i18n/index.js"; +import { agentsMessages } from "../../i18n/messages/agents.js"; const MAX_VISIBLE_SKILLS = 4; const MAX_VISIBLE_TAGS = 3; @@ -31,6 +33,7 @@ const truncatePreview = (value: string): { text: string; truncated: boolean } => }; const SkillSummary: FunctionComponent<{ skill: SkillStorageContentSummary }> = ({ skill }) => { + const { translate, translatePlural } = useDashboardI18n(); const preview = truncatePreview(skill.contentPreview); const visibleTags = skill.tags.slice(0, MAX_VISIBLE_TAGS); const hiddenTagCount = skill.tags.length - visibleTags.length; @@ -46,7 +49,7 @@ const SkillSummary: FunctionComponent<{ skill: SkillStorageContentSummary }> = (

) : null} {visibleTags.length > 0 ? ( -
+
{visibleTags.map((tag) => ( = ( ))} {hiddenTagCount > 0 ? ( - +{hiddenTagCount} tags + {translatePlural(agentsMessages, "hiddenTags", hiddenTagCount)} ) : null}
) : null} @@ -66,7 +69,7 @@ const SkillSummary: FunctionComponent<{ skill: SkillStorageContentSummary }> = (

{preview.text}

{preview.truncated ? ( - Preview truncated + {translate(agentsMessages, "previewTruncated")} ) : null}
@@ -79,11 +82,12 @@ const StorageDisclosure: FunctionComponent<{ storage: SkillStorageRecord; state: LoadState; }> = ({ storage, state }) => { + const { translate, translatePlural } = useDashboardI18n(); if (state.status === "idle" || state.status === "loading") { return (
); } @@ -93,10 +97,10 @@ const StorageDisclosure: FunctionComponent<{

- Hover or focus the chip again, or press Enter while it is focused, to retry. + {translate(agentsMessages, "retryStorageBody")}

); @@ -115,17 +119,17 @@ const StorageDisclosure: FunctionComponent<{ {storage.name}

- {contents.storage.description || storage.description || "No storage description."} + {contents.storage.description || storage.description || translate(agentsMessages, "noStorageDescription")}

- {skillCount} {contents.skills.length === 1 && !contents.truncated ? "skill" : "skills"} + {contents.truncated ? `${skillCount} ` : ""}{translatePlural(agentsMessages, "skillCount", contents.skills.length).replace(`${contents.skills.length} `, contents.truncated ? "" : `${contents.skills.length} `)} {visibleSkills.length === 0 ? (
- No skills saved in this storage. + {translate(agentsMessages, "noSkills")}
) : (
    @@ -135,12 +139,12 @@ const StorageDisclosure: FunctionComponent<{ {hiddenLoadedCount > 0 ? (

    - {hiddenLoadedCount} more loaded skill{hiddenLoadedCount === 1 ? "" : "s"} hidden from this preview. + {translatePlural(agentsMessages, "hiddenSkills", hiddenLoadedCount)}

    ) : null} {contents.truncated ? (

    - More skills are available beyond this bounded response. + {translate(agentsMessages, "moreSkills")}

    ) : null} @@ -152,6 +156,7 @@ export const PersistentSkillStorageChip: FunctionComponent<{ attached?: boolean; className?: string; }> = ({ storage, attached = true, className = "" }) => { + const { translate } = useDashboardI18n(); const [state, setState] = useState({ status: "idle" }); const requestInFlight = useRef(false); const storageKey = `${storage.projectId}:${storage.id}`; @@ -215,7 +220,7 @@ export const PersistentSkillStorageChip: FunctionComponent<{ onPointerEnter={loadContents} onFocusCapture={loadContents} onClick={loadContents} - aria-label={`Inspect attached skill storage ${storage.name}`} + aria-label={translate(agentsMessages, "inspectStorage", { name: storage.name })} title={storage.name} > {state.status === "loading" ? ( diff --git a/dashboard/src/v2/components/agents/__tests__/AgentAvatarCustomizer.test.tsx b/dashboard/src/v2/components/agents/__tests__/AgentAvatarCustomizer.test.tsx index 4ffb8fb87e..62bb828f9a 100644 --- a/dashboard/src/v2/components/agents/__tests__/AgentAvatarCustomizer.test.tsx +++ b/dashboard/src/v2/components/agents/__tests__/AgentAvatarCustomizer.test.tsx @@ -1,9 +1,11 @@ /** @vitest-environment jsdom */ import { h } from "preact"; -import { cleanup, render, fireEvent, screen } from "@testing-library/preact"; +import { cleanup, fireEvent, screen } from "@testing-library/preact"; +import userEvent from "@testing-library/user-event"; import "@testing-library/jest-dom/vitest"; import { afterEach, describe, expect, test, vi } from "vitest"; import { AgentAvatarCustomizer } from "../AgentAvatarCustomizer.js"; +import { renderWithI18n, renderWithI18n as render } from "./render-with-i18n.js"; import type { AgentAvatarConfig } from "../../../types.js"; const config: AgentAvatarConfig = { @@ -43,4 +45,15 @@ describe("AgentAvatarCustomizer", () => { expect(screen.getByText("Avatar randomized. Save Agent to keep it.")).toBeInTheDocument(); expect(onChange).toHaveBeenCalledTimes(1); }); + + test("supports German keyboard controls without changing avatar configuration values", async () => { + const onChange = vi.fn(); + renderWithI18n(, "de"); + + expect(screen.getByText("Gehäuse")).toBeInTheDocument(); + const option = screen.getByRole("button", { name: /Klassisch/ }); + option.focus(); + await userEvent.keyboard("{Enter}"); + expect(onChange).toHaveBeenCalledWith(expect.objectContaining({ chassis: "classic" })); + }); }); diff --git a/dashboard/src/v2/components/agents/__tests__/AgentMcpManageModal.test.tsx b/dashboard/src/v2/components/agents/__tests__/AgentMcpManageModal.test.tsx index 11dee6b6e4..6b42f8e167 100644 --- a/dashboard/src/v2/components/agents/__tests__/AgentMcpManageModal.test.tsx +++ b/dashboard/src/v2/components/agents/__tests__/AgentMcpManageModal.test.tsx @@ -1,9 +1,10 @@ /** @vitest-environment jsdom */ import { h } from "preact"; -import { cleanup, render, fireEvent, screen } from "@testing-library/preact"; +import { cleanup, fireEvent, screen } from "@testing-library/preact"; import "@testing-library/jest-dom/vitest"; import { afterEach, describe, expect, test, vi } from "vitest"; import { AgentMcpManagePanel } from "../AgentMcpManageModal.js"; +import { renderWithI18n, renderWithI18n as render } from "./render-with-i18n.js"; import type { AgentMcpAccessConfig, CustomMcpServer } from "../../../types.js"; import { TOOL_DEFINITIONS } from "../../../../../../src/contracts/mcp-tool-definitions.js"; import { codeUxAgentMcpAccess, schedulerOnlyAgentMcpAccess } from "../../../lib/agent-mcp-display.js"; @@ -69,6 +70,22 @@ describe("AgentMcpManagePanel", () => { expect(onChange).toHaveBeenCalledWith(expect.objectContaining({ linkedServerIds: ["server_enabled"] })); }); + test("localizes German MCP controls while preserving server names", () => { + renderWithI18n( + , + "de", + ); + + expect(screen.getByText("MCP-Zugriff")).toBeInTheDocument(); + expect(screen.getByText("Enabled Server")).toBeInTheDocument(); + expect(screen.getByRole("button", { name: "Fertig" })).toBeInTheDocument(); + }); + test("starts from default-deny and enables non-dashboard Code UX access with scheduler off", () => { const onChange = vi.fn(); render( diff --git a/dashboard/src/v2/components/agents/__tests__/AgentMemoryConfigPanel.test.tsx b/dashboard/src/v2/components/agents/__tests__/AgentMemoryConfigPanel.test.tsx index 64ed485253..490ed2fff9 100644 --- a/dashboard/src/v2/components/agents/__tests__/AgentMemoryConfigPanel.test.tsx +++ b/dashboard/src/v2/components/agents/__tests__/AgentMemoryConfigPanel.test.tsx @@ -1,10 +1,11 @@ /** @vitest-environment jsdom */ import { h } from "preact"; import { useState } from "preact/hooks"; -import { render, fireEvent, screen } from "@testing-library/preact"; +import { fireEvent, screen } from "@testing-library/preact"; import "@testing-library/jest-dom/vitest"; import { describe, expect, test, vi, beforeEach, afterEach } from "vitest"; import { AgentMemoryConfigPanel } from "../AgentMemoryConfigPanel.js"; +import { renderWithI18n, renderWithI18n as render } from "./render-with-i18n.js"; import { DEFAULT_AGENT_MEMORY_CONFIG, type AgentMemoryConfig } from "../../../memory-types.js"; function renderHarness(initialValue: AgentMemoryConfig = DEFAULT_AGENT_MEMORY_CONFIG) { @@ -56,7 +57,7 @@ describe("AgentMemoryConfigPanel", () => { expect(screen.getByRole("button", { name: "Short Term" })).toBeInTheDocument(); expect(screen.getByRole("button", { name: /Both/ })).toBeInTheDocument(); expect(screen.getByRole("button", { name: "Long Term" })).toBeInTheDocument(); - expect(screen.getByRole("button", { name: "Select All" })).toBeInTheDocument(); + expect(screen.getByRole("button", { name: "Select all" })).toBeInTheDocument(); for (const label of ["Architecture", "Codebase", "Context", "Preferences", "Patterns", "Decision", "Error", "Learning"]) { expect(screen.getByRole("button", { name: new RegExp(label) })).toBeInTheDocument(); @@ -66,11 +67,27 @@ describe("AgentMemoryConfigPanel", () => { expect(screen.getAllByText("Selected").length).toBeGreaterThanOrEqual(8); expect(screen.getByText("Memory filter changes are pending until the agent is saved.")).toBeInTheDocument(); - expect(screen.getByLabelText("Minimum strength")).toBeInTheDocument(); + expect(screen.getByLabelText("Minimum Strength")).toBeInTheDocument(); expect(screen.getByLabelText("Max Short Term")).toHaveAttribute("placeholder", "Unlimited"); expect(screen.getByLabelText("Max Long Term")).toHaveAttribute("placeholder", "Unlimited"); }); + test("localizes German memory controls while retaining the configured tier value", () => { + const onChange = vi.fn(); + renderWithI18n( + , + "de", + ); + + expect(screen.getByText("Speichereinbindung")).toBeInTheDocument(); + expect(screen.getByRole("button", { name: /Langzeit/ })).toHaveAttribute("aria-pressed", "true"); + expect(screen.getByText("Änderungen an Speicherfiltern bleiben ausstehend, bis der Agent gespeichert wird.")).toBeInTheDocument(); + }); + test("collapses back to an empty category filter when all categories are enabled", () => { renderHarness({ ...DEFAULT_AGENT_MEMORY_CONFIG, diff --git a/dashboard/src/v2/components/agents/__tests__/AgentPresetEditorPanel.test.tsx b/dashboard/src/v2/components/agents/__tests__/AgentPresetEditorPanel.test.tsx index cf69709013..23b8f6a901 100644 --- a/dashboard/src/v2/components/agents/__tests__/AgentPresetEditorPanel.test.tsx +++ b/dashboard/src/v2/components/agents/__tests__/AgentPresetEditorPanel.test.tsx @@ -2,10 +2,11 @@ /// import { h } from "preact"; import { describe, expect, afterEach, vi, it } from "vitest"; -import { cleanup, fireEvent, render, screen, waitFor } from "@testing-library/preact"; +import { cleanup, fireEvent, screen, waitFor } from "@testing-library/preact"; import userEvent from "@testing-library/user-event"; import * as matchers from "@testing-library/jest-dom/matchers"; import { AgentPresetEditorPanel } from "../AgentPresetEditorPanel.js"; +import { renderWithI18n, renderWithI18n as render } from "./render-with-i18n.js"; import { DEFAULT_AGENT_MEMORY_CONFIG, type AgentMemoryConfig } from "../../../memory-types.js"; import type { AgentPreset } from "../../../types.js"; import * as knowledgeApi from "../../../lib/knowledge-api.js"; @@ -302,6 +303,26 @@ describe("AgentPresetEditorPanel", () => { expect(nameInput).toHaveFocus(); }); + it("localizes German validation while preserving authored instructions", async () => { + const instructionMarkdown = "# Preserve me\n\nReturn provider output verbatim."; + renderWithI18n( + , + "de", + ); + + expect(screen.getByLabelText("agent-instructions")).toHaveValue(instructionMarkdown); + expect(screen.getByRole("button", { name: "Agent speichern" })).toBeInTheDocument(); + const nameInput = screen.getByLabelText(/Agentenname/); + fireEvent.input(nameInput, { target: { value: "" } }); + fireEvent.submit(screen.getByRole("form")); + expect(await screen.findByText("Name ist erforderlich")).toBeInTheDocument(); + }); + it("shows stable pending feedback when saving changed preset fields", async () => { const onSave = vi.fn(); render(); @@ -342,7 +363,7 @@ describe("AgentPresetEditorPanel", () => { const longTermButton = await screen.findByRole("button", { name: "Long Term" }); fireEvent.click(longTermButton); - expect(screen.getByText("Long term · All categories")).toBeInTheDocument(); + expect(screen.getByText("Long Term · All categories")).toBeInTheDocument(); const saveButton = screen.getByRole("button", { name: "Save Agent" }); expect(saveButton).toBeEnabled(); @@ -450,7 +471,7 @@ describe("AgentPresetEditorPanel", () => { /> ); - fireEvent.click(screen.getByRole("button", { name: /Code UX\s+Disabled/i })); + fireEvent.click(screen.getByRole("button", { name: /Code UX.*Disabled/i })); expect(await screen.findByText("Review Code UX MCP and scheduler access before enabling it for the dashboard reply agent.")).toBeInTheDocument(); expect(await screen.findByTestId("mcp-manage-panel")).toHaveTextContent("Dashboard reply Code UX scheduler access"); diff --git a/dashboard/src/v2/components/agents/__tests__/InstructionFileEditorPanel.test.tsx b/dashboard/src/v2/components/agents/__tests__/InstructionFileEditorPanel.test.tsx index c5782c3d50..c26007d7a1 100644 --- a/dashboard/src/v2/components/agents/__tests__/InstructionFileEditorPanel.test.tsx +++ b/dashboard/src/v2/components/agents/__tests__/InstructionFileEditorPanel.test.tsx @@ -1,9 +1,10 @@ /** @vitest-environment jsdom */ import { h } from "preact"; -import { cleanup, render, fireEvent, screen, waitFor } from "@testing-library/preact"; +import { cleanup, fireEvent, screen, waitFor } from "@testing-library/preact"; import "@testing-library/jest-dom/vitest"; import { afterEach, describe, expect, test, vi } from "vitest"; import { InstructionFileEditorPanel } from "../InstructionFileEditorPanel.js"; +import { renderWithI18n, renderWithI18n as render } from "./render-with-i18n.js"; import * as instructionApi from "../../../lib/instruction-file-api.js"; vi.mock("gsap", () => ({ @@ -57,4 +58,15 @@ describe("InstructionFileEditorPanel", () => { await waitFor(() => expect(textarea).toHaveFocus()); expect(saveSpy).not.toHaveBeenCalled(); }); + + test("localizes German file chrome while preserving Markdown verbatim", async () => { + const content = "# Keep this heading\n\nDo not translate this guidance."; + vi.spyOn(instructionApi, "fetchInstructionFile").mockResolvedValue({ ...file, content }); + + renderWithI18n(, "de"); + + expect(await screen.findByText("Anweisungsdatei ist gespeichert.")).toBeInTheDocument(); + expect(screen.getByRole("textbox")).toHaveValue(content); + expect(screen.getByRole("button", { name: "Speichern" })).toBeInTheDocument(); + }); }); diff --git a/dashboard/src/v2/components/agents/__tests__/render-with-i18n.tsx b/dashboard/src/v2/components/agents/__tests__/render-with-i18n.tsx new file mode 100644 index 0000000000..631c1c8d50 --- /dev/null +++ b/dashboard/src/v2/components/agents/__tests__/render-with-i18n.tsx @@ -0,0 +1,10 @@ +import { h, type ComponentChildren, type VNode } from "preact"; +import { render, type RenderResult } from "@testing-library/preact"; +import { DashboardI18nProvider } from "../../../i18n/index.js"; +import type { DashboardLocale } from "../../../i18n/index.js"; + +export const renderWithI18n = (ui: VNode, locale: DashboardLocale = "en"): RenderResult => render(ui, { + wrapper: ({ children }: { children: ComponentChildren }) => ( + {children} + ), +}); diff --git a/dashboard/src/v2/i18n/messages/agents.ts b/dashboard/src/v2/i18n/messages/agents.ts new file mode 100644 index 0000000000..d6a2095422 --- /dev/null +++ b/dashboard/src/v2/i18n/messages/agents.ts @@ -0,0 +1,535 @@ +import { defineDashboardMessages } from "../locales.js"; + +/** Dashboard-authored copy for the lazy-loaded Agents route. */ +export const agentsMessages = defineDashboardMessages({ + en: { + agents: "Agents", + agentsSubtitle: "Design, configure, and synchronize the specialists that power your projects.", + agentWorkshop: "Agent Workshop", + yourWorkforce: "Your Workforce", + heroSubtitle: "Design, customize, and deploy AI specialists. Each agent ships with a distinct personality, an expressive avatar, and operator-grade system instructions.", + newAgent: "New Agent", + active: "Active", + syncedCount: "{count} synced", + selectProject: "Select a project to manage its agent roster.", + loadingProject: "Loading project…", + loadingAgents: "Loading agents…", + createAgent: "Create Agent", + createFirstAgent: "Create First Agent", + createYourFirstAgent: "Create your first agent", + pullFromFiles: "Pull from Files", + pulling: "Pulling…", + pushToFiles: "Push to Files", + pushing: "Pushing…", + pushAgents: "Push Agents", + push: "Push", + retry: "Retry", + close: "Close", + cancel: "Cancel", + save: "Save", + edit: "Edit", + delete: "Delete", + deleting: "Deleting…", + import: "Import", + importing: "Importing…", + export: "Export", + roster: "Roster", + rosterSummary: "Roster Summary", + totalAgents: "Total Agents", + synced: "Synced", + drift: "Drift", + databaseOnly: "Database Only", + local: "Local", + missing: "Missing", + outOfSync: "Out of Sync", + sourceMissing: "Source Missing", + project: "Project", + default: "Default", + home: "Home", + refreshing: "Refreshing…", + agentCount: { one: "{count} Agent", other: "{count} Agents" }, + instructionFiles: "Instruction Files", + pickProjectTitle: "Pick A Project To Begin", + pickProjectBody: "Choose a project from the top navigation and your roster of agents will load here.", + quietWorkshopTitle: "The Workshop Is Quiet", + quietWorkshopBody: "Spin up your first specialist. Give it a name, a personality, an avatar — and operator-grade system instructions.", + selectAgentOrFile: "Select an agent or an instruction file from the left to view and edit it.", + mirroringEnabled: "Markdown mirroring enabled — saving writes a companion file under .code-ux/agents.", + mirroringDisabled: "Markdown mirroring disabled — edits stay in the database only.", + creatingPreset: "Creating agent preset…", + presetCreated: "Agent preset created. Complete the required fields, then save.", + creationFailed: "Agent creation failed: {error}", + importingPreset: "Importing preset from markdown…", + presetImported: "Agent preset imported from markdown.", + importFailed: "Import failed: {error}", + pullingPresets: "Pulling agent presets from project files…", + presetsPulled: "Agent presets pulled from project files.", + pullFailed: "Pull failed: {error}", + pushingPresets: "Pushing agent presets to project files…", + presetsPushed: "Agent presets pushed to project files.", + pushingPreset: "Pushing agent preset to project file…", + presetPushed: "Agent preset pushed to project file.", + pushFailed: "Push failed: {error}", + savingPreset: "Saving agent preset...", + presetSaved: "Agent preset saved.", + saveFailed: "Save failed: {error}", + deletingPreset: "Deleting agent preset…", + presetDeleted: "Agent preset deleted.", + deleteFailed: "Delete failed: {error}", + noRemote: "Agent presets were committed locally, but no remote origin is configured for this repository.", + noPullRequestUrl: "Agent presets were committed locally, but no pull request URL was returned.", + committedLocally: "Agent presets were committed locally.", + nothingToCommit: "No agent preset changes were available to commit.", + pushedToBranch: "Pushed agent presets to {branch}.", + openedPullRequestAt: "Opened a pull request at", + pushDialogBody: "Choose where to send the current .code-ux/agents changes.", + commitLocally: "Commit locally", + commitLocallyBody: "Create a local commit only.", + pushToBranch: "Push to branch", + pushToBranchBody: "Commit, then push the branch to origin.", + openPullRequest: "Open pull request", + openPullRequestBody: "Commit, push, and open a PR.", + branchName: "Branch name", + checkingBaseUpdates: "Checking for base-agent updates...", + baseAgentUpdates: "Base-agent updates", + planningAgent: "Planning agent", + projectManager: "Project manager", + updatingAgentWithAi: "Updating {name} with AI...", + baseUpdateSuccess: "{role} compatibility instructions updated. Custom behavior and instructions were preserved.", + baseUpdateFailed: "{role} update failed: {error}", + baseUpdateAvailable: "base update available", + baseUpdateBody: "{name} can receive the latest {role} compatibility instructions without replacing custom behavior.", + baseAlternateRouteReason: "{name} is assigned to the {role} route and must be updated.", + baseCustomizedReason: "{name} has customized {role} instructions and must be updated.", + baseUpdateExplanation: "Updating invokes an agent to compare both base files and apply only important system-compatibility instructions. Your main prompt, custom instructions, and behavior are preserved.", + updateWithAi: "Update with AI", + updating: "Updating...", + updateAgentWithAi: "Update {name} with AI", + profile: "Profile", + editingAgent: "Editing Agent", + identity: "Identity", + appearance: "Appearance", + customize: "Customize", + behavior: "Behavior", + systemPromptMemory: "System Prompt & Memory", + persistentSkills: "Persistent Skills", + storageAttachments: "Storage Attachments", + runtime: "Runtime", + execution: "Execution", + agentName: "Agent Name", + agentNameHelper: "Shown across the dashboard, sprints, and worker logs.", + namePlaceholder: "e.g. Planning Agent", + shortDescription: "Short Description", + descriptionHelper: "Used by the Planning agent when it chooses the best coding specialist for each task.", + descriptionPlaceholder: "e.g. Frontend specialist for Preact, Tailwind, responsive UI, and accessibility work.", + systemInstructions: "System Instructions", + instructionsHelper: "Markdown is supported. This becomes the system prompt prepended to every conversation.", + instructionsPlaceholder: "You are a planning specialist. Decompose user goals into clear, testable subtasks…", + noInstructionsPreview: "No instructions yet — switch to Write to compose the system prompt.", + memoryTemplateOverride: "Memory Template Override", + enableMemoryTemplateOverride: "Enable Memory Template Override", + memoryTemplateHelper: "Override the default worker-learnings template for this agent only.", + memoryTemplateMarkdown: "Memory Template Markdown", + resetToDefault: "Reset to default", + memoryInjectionFilters: "Memory Injection Filters", + memoryFiltersBody: "Control which memories are injected into this agent's prompts.", + manageMemory: "Manage Memory", + memoryOverridePlaceholder: "Override the default memory prompt template for this agent.", + noMemoryPreview: "No override yet — switch to Write to compose the memory template.", + resetToProjectDefault: "Reset to project default", + memoryFilters: "Memory Filters", + configure: "Configure", + enabled: "Enabled", + disabled: "Disabled", + stateEnabled: "enabled", + stateDisabled: "disabled", + stateLinked: "linked", + stateUnlinked: "unlinked", + defaultOff: "Default off", + enablePersistentSkills: "Enable persistent skill retrieval", + persistentSkillRetrieval: "Persistent skill retrieval", + persistentSkillBody: "Attach durable skill storages to this agent. Retrieval is disabled until storage is attached and this opt-in is enabled.", + noSkillStorages: "No project skill storages are available. Create one in Settings, Agents.", + grounding: "Grounding", + knowledgeBase: "Knowledge Base", + knowledgeSubscriptionsBody: "Subscribe this agent to documents from the shared library. Subscribed docs appear in the agent's manifest, and it retrieves passages on demand via search_knowledge.", + routing: "Routing", + providerModel: "Provider & Model", + optionalProvider: "Optional Agent Provider", + optionalProviderBody: "Used only when a route is set to the Agent strategy. Blank agents inherit that route's primary instance.", + providerInstance: "Provider Instance", + providerInstanceHelper: "Leave unset to inherit route, worker, or global defaults.", + inheritRouteDefault: "Inherit route default", + paused: "paused", + defaultModel: "default model", + modelOverride: "Model Override", + modelOverrideHelper: "Optional. Leave blank to use the selected provider instance model.", + inherited: "Inherited", + dockerRootMode: "Docker Root Mode", + dockerRootBody: "Root mode is off by default. Force root only for tools that require package-manager or OS-level writes inside Docker.", + agentDockerRootMode: "Agent Docker root mode", + inherit: "Inherit", + inheritRootHint: "Use the scoped Docker Runtime setting.", + inheritRootAria: "Inherit global Docker root setting", + forceNonRootHint: "Keep this agent on the default safer posture.", + forceNonRootAria: "Force Docker non-root for this agent", + forceRootHint: "Only for tools that need package-manager or OS-level writes.", + forceRootAria: "Force Docker root for this agent", + dockerRootChanged: "Docker root mode changed. Save Agent to persist the runtime posture.", + tools: "Tools", + connectedMcps: "Connected MCPs", + mcpServers: "MCP Servers", + manage: "Manage", + mcpActiveSummary: "Tap to link or unlink. {count} active. Use Manage to configure Code UX tools.", + mcpItemChanged: "{name} {state} for this agent. Save Agent to persist MCP access.", + reviewDashboardMcp: "Review Code UX MCP and scheduler access before enabling it for the dashboard reply agent.", + reviewRiskMcp: "Code UX access is risk-gated for non-chat agents. Review the MCP manager warning before enabling it.", + moreCount: "+{count} more", + createdAt: "Created {date}", + discardChangesTitle: "Discard unsaved changes?", + discardChangesBody: "Your edits to this agent will be lost. This action can't be undone.", + discard: "Discard", + keepEditing: "Keep editing", + editAgentAria: "Edit {name}", + bothTiers: "Both tiers", + allCategories: "All categories", + categoryCount: { one: "{count} category", other: "{count} categories" }, + minStrengthSummary: "min {value}", + shortTermCount: "{count} short-term", + longTermCount: "{count} long-term", + saveAgent: "Save Agent", + savingAgent: "Saving Agent…", + cancelEditing: "Cancel editing", + unsavedChanges: "Unsaved changes", + unnamedAgent: "Unnamed Agent", + shortcutSaveCancel: "{saveShortcut} to save · Esc to cancel", + fixErrorsToSave: "Fix errors to save", + noChanges: "No changes", + noChangesToSave: "No changes to save.", + validationHint: "Validation runs after fields are edited or Save Agent is pressed.", + fixHighlighted: "Fix the highlighted fields, then retry Save Agent.", + savingAgentChanges: "Saving agent changes...", + avatarRandomizedSave: "Avatar randomized. Save Agent to keep the new appearance.", + avatarChangedSave: "Avatar option changed. Save Agent to persist appearance.", + memoryResetSave: "Memory template reset to the project default. Save Agent to persist it.", + memoryFiltersSave: "Memory filters updated. Save Agent to persist them.", + nameRequired: "Name is required", + nameTooLong: "Name must be {limit} characters or fewer", + descriptionTooLong: "Description must be {limit} characters or fewer", + instructionsTooLong: "Instructions exceed safe limit ({current} / {limit})", + memoryOverrideRequired: "Provide an override or disable the toggle", + characterCount: "{count} chars", + characterTokenCount: "{characters} chars · ~{tokens} tok", + instructionLengthWarning: "{count} characters exceeds the recommended {limit} — long prompts increase latency and cost.", + longPrompt: "Long", + write: "Write", + preview: "Preview", + form: "Form", + palette: "Palette", + chassis: "Chassis", + eyes: "Eyes", + antenna: "Antenna", + headphones: "Headphones", + aura: "Aura", + baseColor: "Base Color", + accentColor: "Accent Color", + visorColor: "Visor Color", + selected: "Selected", + option: "Option", + randomize: "Randomize", + randomizeAppearance: "Randomize appearance", + shuffle: "Shuffle", + avatarDisabledTitle: "Avatar controls are disabled while saving", + avatarRandomizeTitle: "Randomize avatar appearance", + avatarCustomizerHint: "Tweak parts and colors — the portrait updates live.", + avatarControlsDisabled: "Avatar controls are disabled while this agent is saving.", + avatarRandomized: "Avatar randomized. Save Agent to keep it.", + avatarSelectionHint: "Selected parts are labeled and update the live portrait immediately.", + expressionChanged: "Bot expression changed to {expression}", + expressionHappy: "Happy", + expressionSad: "Sad", + expressionAngry: "Angry", + expressionBored: "Bored", + expressionHyped: "Hyped", + expression: "Expression", + expressionSleepy: "Sleepy", expressionShakeHead: "Shake head", expressionNod: "Nod", expressionCurious: "Curious", expressionThinking: "Thinking", expressionExcited: "Excited", expressionLaughing: "Laughing", expressionSurprised: "Surprised", expressionWink: "Wink", expressionDance: "Dance", expressionProud: "Proud", + avatarPreview: "Agent avatar preview", + avatarPreviewWithTool: "Agent avatar preview working with {tool}", + toolScrewdriver: "Power screwdriver", + toolJackhammer: "Jackhammer", + toolWrench: "Open-end wrench", + toolHammer: "Claw hammer", + toolTorch: "Welding torch", + mcpAccess: "MCP Access", + connectedServers: "Connected Servers", + mcpPending: "MCP access changes are pending until the agent is saved.", + mcpDashboardEnabled: "Code UX MCP and scheduler enabled for dashboard chat. Save Agent to persist this access change.", + mcpRiskEnabled: "Risk-gated Code UX access enabled with scheduler off. Save Agent only after reviewing this capability.", + mcpCodeUxDisabled: "Code UX tools disabled. Save Agent to persist this access change.", + mcpRiskToolEnabled: "Risk-gated {name} access enabled for a non-chat agent. Save Agent only after reviewing this capability.", + mcpToolChanged: "{name} {state}. Save Agent to persist tool access.", + mcpRiskCategoryEnabled: "Risk-gated {category} tools enabled for a non-chat agent. Save Agent only after reviewing these capabilities.", + mcpCategoryChanged: "{category} tools {state}. Save Agent to persist tool access.", + serverOffLink: "{name} is off in Settings. Enable it there before linking this agent.", + serverLinkChanged: "{name} {state}. Save Agent to persist MCP server access.", + mcpLocked: "MCP controls are disabled while this agent is saving.", + codeUxBuiltIn: "Code UX (built-in)", + toolsEnabledCount: "{enabled}/{total} tools enabled", + disabledForAgent: "Disabled for this agent", + enableCodeUx: "Enable Code UX for this agent", + codeUxDisabledDashboardNote: "Code UX built-in tools are disabled in this saved preset, but dashboard chat turns receive the Code UX MCP surface plus scheduler at runtime.", + codeUxDisabledRiskNote: "Code UX built-in tools are disabled by default for this agent. Enabling them is risk-gated because non-chat agents can affect runtime state.", + schedulerOnlyDashboardNote: "This saved preset is scheduler-only, but dashboard chat runtime will still attach the full Code UX MCP surface plus scheduler.", + schedulerOnlyRiskNote: "Scheduler-only is active for a non-chat agent. Scheduler is off by default for non-dashboard agents; keep this only when the agent must create its own wakeups or task reruns.", + dashboardCodeUxNote: "Dashboard chat receives Code UX MCP plus scheduler. Review each category before saving preset changes.", + nonChatCodeUxNote: "This non-chat agent has Code UX tools enabled. Scheduler stays off by default unless explicitly enabled below.", + allEnabled: "All enabled", + someDisabled: "Some disabled", + enableAllCategoryTools: "Enable all {category} tools", + enableTool: "Enable {name}", + builtInTools: "Built-in tools", + customMcpServers: "Custom MCP servers", + noCustomServers: "No custom MCP servers configured. Add them in Settings → MCP, then link them here.", + linkedServerSummary: "{linked}/{total} linked", + offInSettings: "Off in settings", + linked: "Linked", + unlinked: "Unlinked", + linkServer: "Link {name}", + done: "Done", + orchestration: "Orchestration", + orchestrationBody: "Projects, sprints, and tasks", + agentsMemory: "Agents & Memory", + agentsMemoryBody: "Agent presets and project memory", + platform: "Platform", + platformBody: "Settings, previews, and telemetry", + advanced: "Advanced", + advancedBody: "Deprecated and low-level tools", + memoryInjection: "Memory Injection", + defaults: "Defaults", + memoryLocked: "Memory filters are locked while the agent is saving.", + memoryPending: "Memory filter changes are pending until the agent is saved.", + tier: "Tier", + tierBody: "Choose whether this agent receives short-term, long-term, or both memory scopes.", + both: "Both", + shortTerm: "Short Term", + longTerm: "Long Term", + categories: "Categories", + categoriesBody: "Empty means all categories are included.", + categoryArchitecture: "Architecture", + categoryCodebase: "Codebase", + categoryContext: "Context", + categoryPreferences: "Preferences", + categoryPatterns: "Patterns", + categoryDecision: "Decision", + categoryError: "Error", + categoryLearning: "Learning", + selectAll: "Select all", + unselectAll: "Unselect all", + off: "Off", + minimumStrength: "Minimum Strength", + noMinimum: "0% means no minimum.", + perCategoryOverrides: "Per-category overrides", + globalMinimum: "Global minimum", + categoryMinimumStrength: "{category} minimum strength", + noCategories: "No categories selected for injection.", + maxMemories: "Max Memories", + zeroUnlimited: "Use 0 for unlimited.", + maxShortTerm: "Max Short Term", + maxLongTerm: "Max Long Term", + unlimited: "Unlimited", + knowledgeBaseCount: "Knowledge Base · {count}", + searchKnowledge: "Search knowledge", + noMatchingKnowledge: "No matching knowledge documents.", + failedSubscription: "Failed to update subscription", + knowledgeEmpty: "The knowledge library is empty.", + addKnowledgeDocuments: "Add documents on the Knowledge page", + knowledgeSummary: "{selected} subscribed · {total} in library", + manifestTokens: "~{count} tok manifest", + chunkCount: { one: "{count} chunk", other: "{count} chunks" }, + errorStatus: "error", + embedding: "embedding…", + loadingStorage: "Loading storage contents…", + loadStorageFailed: "Couldn’t load storage contents", + retryStorageBody: "Hover or focus the chip again, or press Enter while it is focused, to retry.", + noStorageDescription: "No storage description.", + skillCount: { one: "{count} skill", other: "{count} skills" }, + noSkills: "No skills saved in this storage.", + hiddenSkills: { one: "{count} more loaded skill hidden from this preview.", other: "{count} more loaded skills hidden from this preview." }, + moreSkills: "More skills are available beyond this bounded response.", + tagsFor: "Tags for {name}", + hiddenTags: { one: "+{count} tag", other: "+{count} tags" }, + previewTruncated: "Preview truncated", + inspectStorage: "Inspect attached skill storage {name}", + new: "New", + empty: "Empty", + none: "None", + instructionRequired: "Instruction file content is required before saving.", + instructionRequiredRetry: "Instruction file content is required. Add guidance or use the starter template, then retry Save.", + saving: "Saving", + needsRetry: "Needs retry", + unsaved: "Unsaved", + saved: "Saved", + inSync: "In sync", + notCreated: "Not created", + revertChanges: "Revert changes", + revertConfirm: "Revert unsaved instruction file edits? This restores the last saved content.", + savingInstructionFile: "Saving instruction file…", + unsavedInstructionEdits: "Unsaved instruction edits. Save to write the file.", + instructionFileSaved: "Instruction file is saved.", + bytesOnDisk: "{size} on disk", + shortcutToSave: "{shortcut} to save", + instructionPlaceholder: "# {label}\n\nWrite the instructions agents should follow in this project…", + insertStarterTemplate: "Insert starter template", + nothingToPreview: "Nothing to preview yet.", + usage: "Usage", + agentProfile: "Agent Profile", + noAssignedRoutes: "No assigned routes", + provider: "Provider", + routeDefault: "Route default", + model: "Model", + providerDefault: "Provider default", + serverCount: { one: "{count} server", other: "{count} servers" }, + dockerRoot: "Docker Root", + systemPrompt: "System Prompt", + totalUsage: "Total Usage", + loading: "Loading", + noMcpServers: "No MCP servers", + persistentSkillsSeparation: "Persistent skill retrieval is separate from memory and knowledge documents.", + noStorageAttached: "No storage attached", + showLess: "Show less", + showFullCharacters: "Show full ({count} chars)", + noInstructions: "No instructions provided.", + markdownSource: "Markdown Source", + pushToFile: "Push to file", + deletePresetTitle: "Delete this agent preset?", + deletePresetBody: "This removes the preset from the dashboard and cannot be undone from this screen. Export or sync first if you need a recoverable copy.", + deletePreset: "Delete preset", + keepPreset: "Keep preset", + routePlanning: "Planning", + routeCodingRoster: "Coding Roster", + routeCoding: "Coding", + routeCiFix: "CI Fix", + routeMergeConflict: "Merge Conflict", + routeDashboardReply: "Dashboard Reply", + routeClarificationReply: "Clarification Reply", + routeQaTask: "QA Task", + routeQaSprint: "QA Sprint", + routeQaNoPr: "QA No PR", + noRuns: "No runs", + running: "Running", + runs: "Runs", + completed: "Completed", + failed: "Failed", + tokens: "Tokens", + cost: "Cost", + loadingUsage: "Loading usage…", + forceRoot: "Force root", + forceNonRoot: "Force non-root", + inheritsSetting: "Inherits setting", + updatedAt: "Updated {date}", + feelingEmotion: "Feeling {emotion}.", + }, + de: { + expression: "Ausdruck", + expressionSleepy: "Schläfrig", expressionShakeHead: "Kopfschütteln", expressionNod: "Nicken", expressionCurious: "Neugierig", expressionThinking: "Nachdenklich", expressionExcited: "Aufgeregt", expressionLaughing: "Lachend", expressionSurprised: "Überrascht", expressionWink: "Zwinkern", expressionDance: "Tanzen", expressionProud: "Stolz", + none: "Keine", + agentProfile: "Agentenprofil", + noAssignedRoutes: "Keine zugewiesenen Routen", + provider: "Anbieter", + routeDefault: "Routenstandard", + model: "Modell", + providerDefault: "Anbieterstandard", + serverCount: { one: "{count} Server", other: "{count} Server" }, + dockerRoot: "Docker-Root", + systemPrompt: "Systemprompt", + totalUsage: "Gesamtnutzung", + loading: "Wird geladen", + noMcpServers: "Keine MCP-Server", + persistentSkillsSeparation: "Der Abruf dauerhafter Fähigkeiten ist von Speicher und Wissensdokumenten getrennt.", + noStorageAttached: "Kein Speicher zugeordnet", + showLess: "Weniger anzeigen", + showFullCharacters: "Vollständig anzeigen ({count} Zeichen)", + noInstructions: "Keine Anweisungen vorhanden.", + markdownSource: "Markdown-Quelle", + pushToFile: "In Datei schreiben", + deletePresetTitle: "Diese Agentenvorlage löschen?", + deletePresetBody: "Dadurch wird die Vorlage aus dem Dashboard entfernt. Dies kann hier nicht rückgängig gemacht werden. Exportieren oder synchronisieren Sie sie zuerst, wenn Sie eine wiederherstellbare Kopie benötigen.", + deletePreset: "Vorlage löschen", + keepPreset: "Vorlage behalten", + routePlanning: "Planung", + routeCodingRoster: "Programmierteam", + routeCoding: "Programmierung", + routeCiFix: "CI-Reparatur", + routeMergeConflict: "Merge-Konflikt", + routeDashboardReply: "Dashboard-Antwort", + routeClarificationReply: "Klärungsantwort", + routeQaTask: "QA-Aufgabe", + routeQaSprint: "QA-Sprint", + routeQaNoPr: "QA ohne PR", + editingAgent: "Agent wird bearbeitet", + memoryTemplateMarkdown: "Markdown-Speichervorlage", + resetToDefault: "Auf Standard zurücksetzen", + memoryInjectionFilters: "Filter für Speichereinbindung", + memoryFiltersBody: "Steuern Sie, welche Erinnerungen in die Prompts dieses Agenten eingefügt werden.", + manageMemory: "Speicher verwalten", + persistentSkillRetrieval: "Abruf dauerhafter Fähigkeiten", + persistentSkillBody: "Ordnen Sie diesem Agenten dauerhafte Fähigkeitsspeicher zu. Der Abruf bleibt deaktiviert, bis ein Speicher zugeordnet und diese Option aktiviert ist.", + noSkillStorages: "Keine Projekt-Fähigkeitsspeicher verfügbar. Erstellen Sie einen unter Einstellungen, Agenten.", + grounding: "Fundierung", + knowledgeBase: "Wissensbasis", + knowledgeSubscriptionsBody: "Abonnieren Sie für diesen Agenten Dokumente aus der gemeinsamen Bibliothek. Abonnierte Dokumente erscheinen im Manifest; Passagen werden bei Bedarf über search_knowledge abgerufen.", + routing: "Routing", + providerModel: "Anbieter & Modell", + optionalProvider: "Optionaler Agentenanbieter", + optionalProviderBody: "Wird nur verwendet, wenn eine Route die Agentenstrategie nutzt. Ohne Auswahl erben Agenten die primäre Instanz der Route.", + providerInstance: "Anbieterinstanz", + providerInstanceHelper: "Nicht festlegen, um die Standardwerte der Route, des Workers oder des Systems zu übernehmen.", + inheritRouteDefault: "Routenstandard übernehmen", + paused: "pausiert", + defaultModel: "Standardmodell", + modelOverride: "Modell überschreiben", + modelOverrideHelper: "Optional. Leer lassen, um das Modell der gewählten Anbieterinstanz zu verwenden.", + inherited: "Übernommen", + dockerRootMode: "Docker-Root-Modus", + dockerRootBody: "Der Root-Modus ist standardmäßig aus. Erzwingen Sie Root nur für Tools, die Paketmanager- oder Betriebssystemschreibzugriffe in Docker benötigen.", + agentDockerRootMode: "Docker-Root-Modus des Agenten", + inherit: "Übernehmen", + inheritRootHint: "Bereichsbezogene Docker-Laufzeiteinstellung verwenden.", + inheritRootAria: "Globale Docker-Root-Einstellung übernehmen", + forceNonRootHint: "Diesen Agenten in der sichereren Standardhaltung belassen.", + forceNonRootAria: "Docker-Nicht-Root für diesen Agenten erzwingen", + forceRootHint: "Nur für Tools, die Paketmanager- oder Betriebssystemschreibzugriffe benötigen.", + forceRootAria: "Docker-Root für diesen Agenten erzwingen", + dockerRootChanged: "Docker-Root-Modus geändert. Speichern Sie den Agenten, um die Laufzeithaltung zu übernehmen.", + tools: "Tools", + connectedMcps: "Verbundene MCPs", + mcpServers: "MCP-Server", + manage: "Verwalten", + mcpActiveSummary: "Zum Verknüpfen oder Trennen auswählen. {count} aktiv. Mit Verwalten konfigurieren Sie Code-UX-Tools.", + mcpItemChanged: "{name} für diesen Agenten {state}. Speichern Sie den Agenten, um den MCP-Zugriff zu übernehmen.", + reviewDashboardMcp: "Prüfen Sie den Code-UX-MCP- und Zeitplanungszugriff, bevor Sie ihn f��r den Dashboard-Antwortagenten aktivieren.", + reviewRiskMcp: "Code-UX-Zugriff ist für Nicht-Chat-Agenten risikobegrenzt. Prüfen Sie vor der Aktivierung die Warnung in der MCP-Verwaltung.", + moreCount: "+{count} weitere", + createdAt: "Erstellt am {date}", + discardChangesTitle: "Ungespeicherte Änderungen verwerfen?", + discardChangesBody: "Ihre Änderungen an diesem Agenten gehen verloren. Dies kann nicht rückgängig gemacht werden.", + discard: "Verwerfen", + keepEditing: "Weiter bearbeiten", + editAgentAria: "{name} bearbeiten", + bothTiers: "Beide Stufen", + allCategories: "Alle Kategorien", + categoryCount: { one: "{count} Kategorie", other: "{count} Kategorien" }, + minStrengthSummary: "min. {value}", + shortTermCount: "{count} Kurzzeit", + longTermCount: "{count} Langzeit", + agents: "Agenten", agentsSubtitle: "Spezialisten für Ihre Projekte entwerfen, konfigurieren und synchronisieren.", agentWorkshop: "Agentenwerkstatt", yourWorkforce: "Ihre Belegschaft", heroSubtitle: "Entwerfen, individualisieren und verwenden Sie KI-Spezialisten. Jeder Agent besitzt eine eigene Persönlichkeit, einen ausdrucksstarken Avatar und professionelle Systemanweisungen.", newAgent: "Neuer Agent", active: "Aktiv", syncedCount: "{count} synchronisiert", selectProject: "Wählen Sie ein Projekt aus, um dessen Agentenliste zu verwalten.", loadingProject: "Projekt wird geladen…", loadingAgents: "Agenten werden geladen…", createAgent: "Agent erstellen", createFirstAgent: "Ersten Agenten erstellen", createYourFirstAgent: "Erstellen Sie Ihren ersten Agenten", pullFromFiles: "Aus Dateien laden", pulling: "Wird geladen…", pushToFiles: "In Dateien schreiben", pushing: "Wird übertragen…", pushAgents: "Agenten übertragen", push: "Übertragen", retry: "Erneut versuchen", close: "Schließen", cancel: "Abbrechen", save: "Speichern", edit: "Bearbeiten", delete: "Löschen", deleting: "Wird gelöscht…", import: "Importieren", importing: "Wird importiert…", export: "Exportieren", roster: "Agentenliste", rosterSummary: "Zusammenfassung der Agentenliste", totalAgents: "Agenten insgesamt", synced: "Synchronisiert", drift: "Abweichungen", databaseOnly: "Nur Datenbank", local: "Lokal", missing: "Fehlt", outOfSync: "Nicht synchron", sourceMissing: "Quelle fehlt", project: "Projekt", default: "Standard", home: "Benutzerverzeichnis", refreshing: "Wird aktualisiert…", agentCount: { one: "{count} Agent", other: "{count} Agenten" }, instructionFiles: "Anweisungsdateien", pickProjectTitle: "Projekt zum Starten auswählen", pickProjectBody: "Wählen Sie oben ein Projekt aus. Die Agentenliste wird dann hier geladen.", quietWorkshopTitle: "Die Werkstatt ist still", quietWorkshopBody: "Erstellen Sie Ihren ersten Spezialisten – mit Namen, Persönlichkeit, Avatar und professionellen Systemanweisungen.", selectAgentOrFile: "Wählen Sie links einen Agenten oder eine Anweisungsdatei aus, um sie anzuzeigen und zu bearbeiten.", mirroringEnabled: "Markdown-Spiegelung aktiviert – beim Speichern wird eine Begleitdatei unter .code-ux/agents geschrieben.", mirroringDisabled: "Markdown-Spiegelung deaktiviert – Änderungen bleiben nur in der Datenbank.", + creatingPreset: "Agentenvorlage wird erstellt…", presetCreated: "Agentenvorlage erstellt. Füllen Sie die Pflichtfelder aus und speichern Sie anschließend.", creationFailed: "Agent konnte nicht erstellt werden: {error}", importingPreset: "Vorlage wird aus Markdown importiert…", presetImported: "Agentenvorlage aus Markdown importiert.", importFailed: "Import fehlgeschlagen: {error}", pullingPresets: "Agentenvorlagen werden aus Projektdateien geladen…", presetsPulled: "Agentenvorlagen aus Projektdateien geladen.", pullFailed: "Laden fehlgeschlagen: {error}", pushingPresets: "Agentenvorlagen werden in Projektdateien geschrieben…", presetsPushed: "Agentenvorlagen in Projektdateien geschrieben.", pushingPreset: "Agentenvorlage wird in die Projektdatei geschrieben…", presetPushed: "Agentenvorlage in die Projektdatei geschrieben.", pushFailed: "Übertragung fehlgeschlagen: {error}", savingPreset: "Agentenvorlage wird gespeichert…", presetSaved: "Agentenvorlage gespeichert.", saveFailed: "Speichern fehlgeschlagen: {error}", deletingPreset: "Agentenvorlage wird gelöscht…", presetDeleted: "Agentenvorlage gelöscht.", deleteFailed: "Löschen fehlgeschlagen: {error}", noRemote: "Die Agentenvorlagen wurden lokal committet, aber für dieses Repository ist kein Remote-Ursprung konfiguriert.", noPullRequestUrl: "Die Agentenvorlagen wurden lokal committet, aber es wurde keine Pull-Request-URL zurückgegeben.", committedLocally: "Agentenvorlagen wurden lokal committet.", nothingToCommit: "Es waren keine Änderungen an Agentenvorlagen zum Committen vorhanden.", pushedToBranch: "Agentenvorlagen wurden nach {branch} übertragen.", openedPullRequestAt: "Pull Request geöffnet unter", pushDialogBody: "Wählen Sie, wohin die aktuellen Änderungen unter .code-ux/agents gesendet werden sollen.", commitLocally: "Lokal committen", commitLocallyBody: "Nur einen lokalen Commit erstellen.", pushToBranch: "In Branch übertragen", pushToBranchBody: "Committen und den Branch anschließend zum Ursprung übertragen.", openPullRequest: "Pull Request öffnen", openPullRequestBody: "Committen, übertragen und einen PR öffnen.", branchName: "Branchname", + checkingBaseUpdates: "Aktualisierungen für Basisagenten werden geprüft…", baseAgentUpdates: "Aktualisierungen für Basisagenten", planningAgent: "Planungsagent", projectManager: "Projektmanager", updatingAgentWithAi: "{name} wird mit KI aktualisiert…", baseUpdateSuccess: "Kompatibilitätsanweisungen für {role} aktualisiert. Benutzerdefiniertes Verhalten und Anweisungen wurden beibehalten.", baseUpdateFailed: "Aktualisierung für {role} fehlgeschlagen: {error}", baseUpdateAvailable: "Basisaktualisierung verfügbar", baseUpdateBody: "{name} kann die neuesten Kompatibilitätsanweisungen für {role} erhalten, ohne benutzerdefiniertes Verhalten zu ersetzen.", baseAlternateRouteReason: "{name} ist der Route {role} zugewiesen und muss aktualisiert werden.", baseCustomizedReason: "{name} besitzt angepasste Anweisungen für {role} und muss aktualisiert werden.", baseUpdateExplanation: "Beim Aktualisieren vergleicht ein Agent beide Basisdateien und übernimmt nur wichtige Anweisungen zur Systemkompatibilität. Hauptprompt, benutzerdefinierte Anweisungen und Verhalten bleiben erhalten.", updateWithAi: "Mit KI aktualisieren", updating: "Wird aktualisiert…", updateAgentWithAi: "{name} mit KI aktualisieren", + profile: "Profil", identity: "Identität", appearance: "Erscheinungsbild", customize: "Anpassen", behavior: "Verhalten", systemPromptMemory: "Systemprompt & Speicher", persistentSkills: "Dauerhafte Fähigkeiten", storageAttachments: "Speicherzuordnungen", runtime: "Laufzeit", execution: "Ausführung", agentName: "Agentenname", agentNameHelper: "Wird im Dashboard, in Sprints und Worker-Protokollen angezeigt.", namePlaceholder: "z. B. Planungsagent", shortDescription: "Kurzbeschreibung", descriptionHelper: "Wird vom Planungsagenten verwendet, um für jede Aufgabe den besten Programmierungsspezialisten auszuwählen.", descriptionPlaceholder: "z. B. Frontend-Spezialist für Preact, Tailwind, responsive Oberflächen und Barrierefreiheit.", systemInstructions: "Systemanweisungen", instructionsHelper: "Markdown wird unterstützt. Dies wird zum Systemprompt, der jeder Unterhaltung vorangestellt wird.", instructionsPlaceholder: "Sie sind ein Planungsspezialist. Zerlegen Sie Ziele in klare, testbare Teilaufgaben…", noInstructionsPreview: "Noch keine Anweisungen – wechseln Sie zu Schreiben, um den Systemprompt zu verfassen.", memoryTemplateOverride: "Speichervorlage überschreiben", enableMemoryTemplateOverride: "Überschreiben der Speichervorlage aktivieren", memoryTemplateHelper: "Überschreibt nur für diesen Agenten die Standardvorlage für Worker-Erkenntnisse.", memoryOverridePlaceholder: "Standard-Speicherpromptvorlage für diesen Agenten überschreiben.", noMemoryPreview: "Noch keine Überschreibung – wechseln Sie zu Schreiben, um die Speichervorlage zu verfassen.", resetToProjectDefault: "Auf Projektstandard zurücksetzen", memoryFilters: "Speicherfilter", configure: "Konfigurieren", enabled: "Aktiviert", disabled: "Deaktiviert", stateEnabled: "aktiviert", stateDisabled: "deaktiviert", stateLinked: "verknüpft", stateUnlinked: "nicht verknüpft", defaultOff: "Standardmäßig aus", enablePersistentSkills: "Abruf dauerhafter Fähigkeiten aktivieren", saveAgent: "Agent speichern", savingAgent: "Agent wird gespeichert…", cancelEditing: "Bearbeitung abbrechen", unsavedChanges: "Ungespeicherte Änderungen", unnamedAgent: "Unbenannter Agent", shortcutSaveCancel: "{saveShortcut} zum Speichern · Esc zum Abbrechen", fixErrorsToSave: "Fehler vor dem Speichern beheben", noChanges: "Keine Änderungen", noChangesToSave: "Keine Änderungen zum Speichern.", validationHint: "Die Validierung erfolgt nach der Bearbeitung von Feldern oder nach Auswahl von „Agent speichern“.", fixHighlighted: "Beheben Sie die markierten Felder und versuchen Sie erneut, den Agenten zu speichern.", savingAgentChanges: "Agentenänderungen werden gespeichert…", avatarRandomizedSave: "Avatar zufällig angepasst. Speichern Sie den Agenten, um das neue Erscheinungsbild zu behalten.", avatarChangedSave: "Avataroption geändert. Speichern Sie den Agenten, um das Erscheinungsbild zu übernehmen.", memoryResetSave: "Speichervorlage auf den Projektstandard zurückgesetzt. Speichern Sie den Agenten, um dies zu übernehmen.", memoryFiltersSave: "Speicherfilter aktualisiert. Speichern Sie den Agenten, um sie zu übernehmen.", nameRequired: "Name ist erforderlich", nameTooLong: "Der Name darf höchstens {limit} Zeichen lang sein", descriptionTooLong: "Die Beschreibung darf höchstens {limit} Zeichen lang sein", instructionsTooLong: "Anweisungen überschreiten das sichere Limit ({current} / {limit})", memoryOverrideRequired: "Geben Sie eine Überschreibung an oder deaktivieren Sie den Schalter", characterCount: "{count} Zeichen", characterTokenCount: "{characters} Zeichen · ~{tokens} Token", instructionLengthWarning: "{count} Zeichen überschreiten die empfohlenen {limit} – lange Prompts erhöhen Latenz und Kosten.", longPrompt: "Lang", write: "Schreiben", preview: "Vorschau", + form: "Form", palette: "Palette", chassis: "Gehäuse", eyes: "Augen", antenna: "Antenne", headphones: "Kopfhörer", aura: "Aura", baseColor: "Grundfarbe", accentColor: "Akzentfarbe", visorColor: "Visierfarbe", selected: "Ausgewählt", option: "Option", randomize: "Zufällig", randomizeAppearance: "Erscheinungsbild zufällig ändern", shuffle: "Mischen", avatarDisabledTitle: "Avatarsteuerung ist während des Speicherns deaktiviert", avatarRandomizeTitle: "Avatar zufällig gestalten", avatarCustomizerHint: "Passen Sie Teile und Farben an – das Porträt wird sofort aktualisiert.", avatarControlsDisabled: "Die Avatarsteuerung ist deaktiviert, während dieser Agent gespeichert wird.", avatarRandomized: "Avatar zufällig angepasst. Speichern Sie den Agenten, um ihn zu behalten.", avatarSelectionHint: "Ausgewählte Teile sind beschriftet und aktualisieren das Porträt sofort.", expressionChanged: "Bot-Ausdruck geändert zu {expression}", expressionHappy: "Fröhlich", expressionSad: "Traurig", expressionAngry: "Wütend", expressionBored: "Gelangweilt", expressionHyped: "Begeistert", avatarPreview: "Vorschau des Agentenavatars", avatarPreviewWithTool: "Vorschau des Agentenavatars bei der Arbeit mit {tool}", toolScrewdriver: "Akkuschrauber", toolJackhammer: "Presslufthammer", toolWrench: "Maulschlüssel", toolHammer: "Klauenhammer", toolTorch: "Schweißbrenner", + mcpAccess: "MCP-Zugriff", connectedServers: "Verbundene Server", mcpPending: "MCP-Zugriffsänderungen bleiben ausstehend, bis der Agent gespeichert wird.", mcpDashboardEnabled: "Code UX MCP und Zeitplanung für den Dashboard-Chat aktiviert. Speichern Sie den Agenten, um diese Zugriffsänderung zu übernehmen.", mcpRiskEnabled: "Risikobegrenzter Code-UX-Zugriff bei ausgeschalteter Zeitplanung aktiviert. Speichern Sie den Agenten erst nach Prüfung dieser Funktion.", mcpCodeUxDisabled: "Code-UX-Tools deaktiviert. Speichern Sie den Agenten, um diese Zugriffsänderung zu übernehmen.", mcpRiskToolEnabled: "Risikobegrenzter Zugriff auf {name} für einen Nicht-Chat-Agenten aktiviert. Speichern Sie den Agenten erst nach Prüfung dieser Funktion.", mcpToolChanged: "{name} {state}. Speichern Sie den Agenten, um den Toolzugriff zu übernehmen.", mcpRiskCategoryEnabled: "Risikobegrenzte Tools der Kategorie {category} für einen Nicht-Chat-Agenten aktiviert. Speichern Sie den Agenten erst nach Prüfung dieser Funktionen.", mcpCategoryChanged: "Tools der Kategorie {category} {state}. Speichern Sie den Agenten, um den Toolzugriff zu übernehmen.", serverOffLink: "{name} ist in den Einstellungen ausgeschaltet. Aktivieren Sie ihn dort, bevor Sie diesen Agenten verknüpfen.", serverLinkChanged: "{name} {state}. Speichern Sie den Agenten, um den MCP-Serverzugriff zu übernehmen.", mcpLocked: "MCP-Steuerelemente sind deaktiviert, während dieser Agent gespeichert wird.", codeUxBuiltIn: "Code UX (integriert)", toolsEnabledCount: "{enabled}/{total} Tools aktiviert", disabledForAgent: "Für diesen Agenten deaktiviert", enableCodeUx: "Code UX für diesen Agenten aktivieren", codeUxDisabledDashboardNote: "Integrierte Code-UX-Tools sind in dieser gespeicherten Vorlage deaktiviert, Dashboard-Chat-Aufrufe erhalten zur Laufzeit jedoch die Code-UX-MCP-Oberfläche samt Zeitplanung.", codeUxDisabledRiskNote: "Integrierte Code-UX-Tools sind für diesen Agenten standardmäßig deaktiviert. Ihre Aktivierung ist risikobegrenzt, weil Nicht-Chat-Agenten den Laufzeitzustand beeinflussen können.", schedulerOnlyDashboardNote: "Diese gespeicherte Vorlage enthält nur die Zeitplanung; die Dashboard-Chat-Laufzeit fügt dennoch die vollständige Code-UX-MCP-Oberfläche samt Zeitplanung hinzu.", schedulerOnlyRiskNote: "Für einen Nicht-Chat-Agenten ist nur die Zeitplanung aktiv. Sie ist für Nicht-Dashboard-Agenten standardmäßig ausgeschaltet; behalten Sie dies nur bei, wenn der Agent eigene Weckvorgänge oder Aufgabenwiederholungen erstellen muss.", dashboardCodeUxNote: "Der Dashboard-Chat erhält Code UX MCP samt Zeitplanung. Prüfen Sie jede Kategorie, bevor Sie Vorlagenänderungen speichern.", nonChatCodeUxNote: "Für diesen Nicht-Chat-Agenten sind Code-UX-Tools aktiviert. Die Zeitplanung bleibt standardmäßig aus, sofern sie unten nicht ausdrücklich aktiviert wird.", allEnabled: "Alle aktiviert", someDisabled: "Teilweise deaktiviert", enableAllCategoryTools: "Alle Tools der Kategorie {category} aktivieren", enableTool: "{name} aktivieren", builtInTools: "Integrierte Tools", customMcpServers: "Benutzerdefinierte MCP-Server", noCustomServers: "Keine benutzerdefinierten MCP-Server konfiguriert. Fügen Sie sie unter Einstellungen → MCP hinzu und verknüpfen Sie sie anschließend hier.", linkedServerSummary: "{linked}/{total} verknüpft", offInSettings: "In Einstellungen aus", linked: "Verknüpft", unlinked: "Nicht verknüpft", linkServer: "{name} verknüpfen", done: "Fertig", orchestration: "Orchestrierung", orchestrationBody: "Projekte, Sprints und Aufgaben", agentsMemory: "Agenten & Speicher", agentsMemoryBody: "Agentenvorlagen und Projektspeicher", platform: "Plattform", platformBody: "Einstellungen, Vorschauen und Telemetrie", advanced: "Erweitert", advancedBody: "Veraltete und systemnahe Tools", + memoryInjection: "Speichereinbindung", defaults: "Standardwerte", memoryLocked: "Speicherfilter sind gesperrt, während der Agent gespeichert wird.", memoryPending: "Änderungen an Speicherfiltern bleiben ausstehend, bis der Agent gespeichert wird.", tier: "Stufe", tierBody: "Wählen Sie, ob dieser Agent Kurzzeit-, Langzeit- oder beide Speicherbereiche erhält.", both: "Beide", shortTerm: "Kurzzeit", longTerm: "Langzeit", categories: "Kategorien", categoriesBody: "Leer bedeutet, dass alle Kategorien enthalten sind.", categoryArchitecture: "Architektur", categoryCodebase: "Codebasis", categoryContext: "Kontext", categoryPreferences: "Präferenzen", categoryPatterns: "Muster", categoryDecision: "Entscheidung", categoryError: "Fehler", categoryLearning: "Erkenntnis", selectAll: "Alle auswählen", unselectAll: "Alle abwählen", off: "Aus", minimumStrength: "Mindeststärke", noMinimum: "0 % bedeutet kein Minimum.", perCategoryOverrides: "Überschreibungen je Kategorie", globalMinimum: "Globales Minimum", categoryMinimumStrength: "Mindeststärke für {category}", noCategories: "Keine Kategorien für die Einbindung ausgewählt.", maxMemories: "Maximale Erinnerungen", zeroUnlimited: "0 bedeutet unbegrenzt.", maxShortTerm: "Max. Kurzzeit", maxLongTerm: "Max. Langzeit", unlimited: "Unbegrenzt", + knowledgeBaseCount: "Wissensbasis · {count}", searchKnowledge: "Wissen durchsuchen", noMatchingKnowledge: "Keine passenden Wissensdokumente.", failedSubscription: "Abonnement konnte nicht aktualisiert werden", knowledgeEmpty: "Die Wissensbibliothek ist leer.", addKnowledgeDocuments: "Dokumente auf der Wissensseite hinzufügen", knowledgeSummary: "{selected} abonniert · {total} in der Bibliothek", manifestTokens: "~{count} Token im Manifest", chunkCount: { one: "{count} Abschnitt", other: "{count} Abschnitte" }, errorStatus: "Fehler", embedding: "Einbettung…", loadingStorage: "Speicherinhalte werden geladen…", loadStorageFailed: "Speicherinhalte konnten nicht geladen werden", retryStorageBody: "Bewegen Sie den Zeiger erneut über den Chip, fokussieren Sie ihn oder drücken Sie im Fokus die Eingabetaste, um es erneut zu versuchen.", noStorageDescription: "Keine Speicherbeschreibung.", skillCount: { one: "{count} Fähigkeit", other: "{count} Fähigkeiten" }, noSkills: "In diesem Speicher sind keine Fähigkeiten gespeichert.", hiddenSkills: { one: "{count} weitere geladene Fähigkeit ist in dieser Vorschau ausgeblendet.", other: "{count} weitere geladene Fähigkeiten sind in dieser Vorschau ausgeblendet." }, moreSkills: "Weitere Fähigkeiten sind außerhalb dieser begrenzten Antwort verfügbar.", tagsFor: "Tags für {name}", hiddenTags: { one: "+{count} Tag", other: "+{count} Tags" }, previewTruncated: "Vorschau gekürzt", inspectStorage: "Zugeordneten Fähigkeitsspeicher {name} prüfen", + new: "Neu", empty: "Leer", instructionRequired: "Vor dem Speichern ist Inhalt für die Anweisungsdatei erforderlich.", instructionRequiredRetry: "Inhalt für die Anweisungsdatei ist erforderlich. Fügen Sie Hinweise hinzu oder verwenden Sie die Startvorlage und versuchen Sie erneut zu speichern.", saving: "Wird gespeichert", needsRetry: "Erneuter Versuch nötig", unsaved: "Ungespeichert", saved: "Gespeichert", inSync: "Synchron", notCreated: "Nicht erstellt", revertChanges: "Änderungen verwerfen", revertConfirm: "Ungespeicherte Änderungen an der Anweisungsdatei verwerfen? Dadurch wird der zuletzt gespeicherte Inhalt wiederhergestellt.", savingInstructionFile: "Anweisungsdatei wird gespeichert…", unsavedInstructionEdits: "Ungespeicherte Änderungen an Anweisungen. Speichern Sie, um die Datei zu schreiben.", instructionFileSaved: "Anweisungsdatei ist gespeichert.", bytesOnDisk: "{size} auf Datenträger", shortcutToSave: "{shortcut} zum Speichern", instructionPlaceholder: "# {label}\n\nSchreiben Sie die Anweisungen, denen Agenten in diesem Projekt folgen sollen…", insertStarterTemplate: "Startvorlage einfügen", nothingToPreview: "Noch keine Vorschau verfügbar.", usage: "Nutzung", noRuns: "Keine Läufe", running: "Läuft", runs: "Läufe", completed: "Abgeschlossen", failed: "Fehlgeschlagen", tokens: "Token", cost: "Kosten", loadingUsage: "Nutzung wird geladen…", forceRoot: "Root erzwingen", forceNonRoot: "Nicht-Root erzwingen", inheritsSetting: "Einstellung wird übernommen", updatedAt: "Aktualisiert am {date}", feelingEmotion: "Stimmung: {emotion}.", + }, +}); diff --git a/dashboard/src/v2/lib/agent-avatar.ts b/dashboard/src/v2/lib/agent-avatar.ts index a3a0fbcacf..0552889704 100644 --- a/dashboard/src/v2/lib/agent-avatar.ts +++ b/dashboard/src/v2/lib/agent-avatar.ts @@ -1,4 +1,5 @@ import type { AgentAvatarConfig } from "../types.js"; +import type { DashboardLocale } from "../i18n/index.js"; /* ════════════════════════════════════════════════════════════════════════ * Avatar expressions @@ -184,6 +185,27 @@ export const ROBOT_BASE_COLOR_OPTIONS = [ { id: "plum", label: "Plum Noir", hex: "#1F0F2A" }, ] as const; +const GERMAN_AVATAR_OPTION_LABELS: Readonly> = { + Classic: "Klassisch", Square: "Eckig", Tall: "Hoch", Pebble: "Kiesel", Soft: "Weich", + "Smile Arcs": "Lächelbögen", Visor: "Visier", "Single Lens": "Einzellinse", Pixel: "Pixel", Heart: "Herz", + Jewel: "Juwel", Bunny: "Hasenohren", Beacon: "Leuchtfeuer", Signal: "Signal", None: "Keine", + Clean: "Schlicht", Pulse: "Puls", "Jade Dust": "Jadestaub", Halo: "Halo", Orbit: "Umlaufbahn", + Bumper: "Stoßfänger", Studio: "Studio", Earbuds: "Ohrhörer", "Halo Loop": "Halo-Ring", "Wing Fins": "Flügelfinnen", + "Signal Jade": "Signaljade", "Ember Amber": "Glutbernstein", "Cosmic Violet": "Kosmisches Violett", + "Warm Coral": "Warmes Korall", "Sky Blue": "Himmelblau", "Neon Fuchsia": "Neonfuchsia", + "Forest Emerald": "Waldsmaragd", "Luxe Gold": "Luxusgold", Crimson: "Karminrot", Lavender: "Lavendel", + "Electric Cyan": "Elektrisches Cyan", "Rose Quartz": "Rosenquarz", Noir: "Noir", Pearl: "Perlmutt", + Void: "Leere", Ice: "Eis", Sapphire: "Saphir", Ruby: "Rubin", "Royal Violet": "Königsviolett", + Forest: "Wald", Bronze: "Bronze", "Vivid Amber": "Leuchtendes Bernstein", Lilac: "Flieder", + Ivory: "Elfenbein", Cream: "Creme", Arctic: "Arktis", Sage: "Salbei", "Rose Dust": "Rosenstaub", + Onyx: "Onyx", Graphite: "Graphit", Charcoal: "Anthrazit", Midnight: "Mitternacht", "Deep Navy": "Tiefes Marineblau", + "Plum Noir": "Pflaumen-Noir", +}; + +export const getAgentAvatarOptionLabel = (label: string, locale: DashboardLocale): string => ( + locale === "de" ? GERMAN_AVATAR_OPTION_LABELS[label] ?? label : label +); + export type RobotChassis = typeof ROBOT_CHASSIS_OPTIONS[number]["id"]; export type RobotEyes = typeof ROBOT_EYE_OPTIONS[number]["id"]; export type RobotAntenna = typeof ROBOT_ANTENNA_OPTIONS[number]["id"]; diff --git a/dashboard/src/v2/lib/agent-mcp-display.ts b/dashboard/src/v2/lib/agent-mcp-display.ts index 57af230906..0869d77b81 100644 --- a/dashboard/src/v2/lib/agent-mcp-display.ts +++ b/dashboard/src/v2/lib/agent-mcp-display.ts @@ -1,5 +1,8 @@ import type { AgentMcpAccessConfig, CustomMcpServer } from "../types.js"; import { TOOL_DEFINITIONS } from "../../../../src/contracts/mcp-tool-definitions.js"; +import type { DashboardLocale } from "../i18n/index.js"; +import { translateDashboardMessage } from "../i18n/index.js"; +import { agentsMessages } from "../i18n/messages/agents.js"; export interface AgentMcpTag { id: string; @@ -81,14 +84,14 @@ export const normalizeAgentMcpAccess = (access: AgentMcpAccessConfig): AgentMcpA export const resolveAgentMcpTags = ( access: AgentMcpAccessConfig | undefined, availableServers: CustomMcpServer[], - options: { effectiveCodeUxEnabled?: boolean } = {}, + options: { effectiveCodeUxEnabled?: boolean; locale?: DashboardLocale } = {}, ): AgentMcpTag[] => { const tags: AgentMcpTag[] = []; if (access?.codeUxEnabled === true || options.effectiveCodeUxEnabled === true) { tags.push({ id: CODE_UX_TAG_ID, label: options.effectiveCodeUxEnabled === true && access?.codeUxEnabled !== true - ? "Code UX · Runtime" + ? `Code UX · ${translateDashboardMessage(agentsMessages, options.locale ?? "en", "runtime")}` : "Code UX", kind: "code_ux", }); diff --git a/dashboard/src/v2/lib/agent-response-effects.ts b/dashboard/src/v2/lib/agent-response-effects.ts index 76a795e285..6a7c9eebfb 100644 --- a/dashboard/src/v2/lib/agent-response-effects.ts +++ b/dashboard/src/v2/lib/agent-response-effects.ts @@ -6,6 +6,9 @@ import { AGENT_RESPONSE_EMOTIONS, type AgentResponseEffect, } from "../../../../src/contracts/connection-chat-types.js"; +import type { DashboardLocale } from "../i18n/index.js"; +import { translateDashboardMessage } from "../i18n/index.js"; +import { agentsMessages } from "../i18n/messages/agents.js"; const supportedEmotions = new Set(AGENT_RESPONSE_EMOTIONS); const supportedAnimations = new Set(AGENT_RESPONSE_ANIMATIONS); @@ -88,7 +91,23 @@ export function resolveAgentResponseEffect(metadata: unknown, markdown: string): return metadataEffect ?? extractAgentResponseEffect(markdown).effect; } -export function getAgentResponseEffectCaption(effect: AgentResponseEffect): string { +export function getAgentResponseEffectCaption(effect: AgentResponseEffect, locale: DashboardLocale = "en"): string { if (effect.caption) return effect.caption; - return `Feeling ${effect.emotion}.`; + const emotionKey = { + happy: "expressionHappy", + sad: "expressionSad", + angry: "expressionAngry", + sleepy: "expressionSleepy", + bored: "expressionBored", + curious: "expressionCurious", + thinking: "expressionThinking", + excited: "expressionExcited", + surprised: "expressionSurprised", + proud: "expressionProud", + } as const; + return translateDashboardMessage(agentsMessages, locale, "feelingEmotion", { + emotion: locale === "en" + ? effect.emotion + : translateDashboardMessage(agentsMessages, locale, emotionKey[effect.emotion]), + }); } diff --git a/dashboard/src/v2/lib/agent-scene-tools.ts b/dashboard/src/v2/lib/agent-scene-tools.ts index e84fcaffd3..1586158950 100644 --- a/dashboard/src/v2/lib/agent-scene-tools.ts +++ b/dashboard/src/v2/lib/agent-scene-tools.ts @@ -1,3 +1,7 @@ +import type { DashboardLocale } from "../i18n/index.js"; +import { translateDashboardMessage } from "../i18n/index.js"; +import { agentsMessages } from "../i18n/messages/agents.js"; + export const AGENT_SCENE_TOOL_IDS = [ "screwdriver", "jackhammer", @@ -147,6 +151,17 @@ export const AGENT_SCENE_TOOL_CATALOG: Readonly { + const key = { + screwdriver: "toolScrewdriver", + jackhammer: "toolJackhammer", + wrench: "toolWrench", + hammer: "toolHammer", + torch: "toolTorch", + } as const; + return translateDashboardMessage(agentsMessages, locale, key[tool]); +}; + export interface ToolMotionPose { readonly scale: number; readonly yOffset: number; diff --git a/dashboard/src/v2/lib/instruction-file-display.ts b/dashboard/src/v2/lib/instruction-file-display.ts index 6e3eadd55f..509ce4c70c 100644 --- a/dashboard/src/v2/lib/instruction-file-display.ts +++ b/dashboard/src/v2/lib/instruction-file-display.ts @@ -1,4 +1,8 @@ /** Brand-tinted accent per instruction file, keyed by associated provider. */ +import type { DashboardLocale } from "../i18n/index.js"; +import { translateDashboardMessage } from "../i18n/index.js"; +import { agentsMessages } from "../i18n/messages/agents.js"; + const PROVIDER_ACCENT: Record = { codex: "#10A37F", // OpenAI green "claude-code": "#D97757", // Claude clay @@ -12,9 +16,11 @@ const FALLBACK_ACCENT = "#00E0A0"; // signal jade export const getInstructionAccentHex = (providerId?: string): string => (providerId && PROVIDER_ACCENT[providerId]) || FALLBACK_ACCENT; -export const formatBytes = (bytes: number): string => { - if (!bytes || bytes <= 0) return "Empty"; - if (bytes < 1024) return `${bytes} B`; - if (bytes < 1024 * 1024) return `${(bytes / 1024).toFixed(bytes < 10 * 1024 ? 1 : 0)} KB`; - return `${(bytes / (1024 * 1024)).toFixed(1)} MB`; +export const formatBytes = (bytes: number, locale: DashboardLocale = "en"): string => { + if (!bytes || bytes <= 0) return translateDashboardMessage(agentsMessages, locale, "empty"); + if (bytes < 1024) return `${new Intl.NumberFormat(locale).format(bytes)} B`; + if (bytes < 1024 * 1024) { + return `${new Intl.NumberFormat(locale, { maximumFractionDigits: bytes < 10 * 1024 ? 1 : 0 }).format(bytes / 1024)} KB`; + } + return `${new Intl.NumberFormat(locale, { minimumFractionDigits: 1, maximumFractionDigits: 1 }).format(bytes / (1024 * 1024))} MB`; }; diff --git a/dashboard/src/v2/lib/token-estimate.ts b/dashboard/src/v2/lib/token-estimate.ts index 8f00d789af..e3416c7337 100644 --- a/dashboard/src/v2/lib/token-estimate.ts +++ b/dashboard/src/v2/lib/token-estimate.ts @@ -1,13 +1,13 @@ const TOKENS_PER_CHAR = 1 / 4; -const TOKEN_COUNT_FORMATTER = new Intl.NumberFormat("en-US"); +import type { DashboardLocale } from "../i18n/index.js"; export function estimateTokens(text: string | null | undefined): number { if (!text) return 0; return Math.ceil(text.length * TOKENS_PER_CHAR); } -export function formatTokenCount(tokens: number): string { - if (tokens >= 1_000_000) return `${(tokens / 1_000_000).toFixed(2)}M`; - if (tokens >= 1_000) return `${(tokens / 1_000).toFixed(1)}k`; - return TOKEN_COUNT_FORMATTER.format(tokens); +export function formatTokenCount(tokens: number, locale: DashboardLocale = "en"): string { + if (tokens >= 1_000_000) return `${new Intl.NumberFormat(locale, { maximumFractionDigits: 2 }).format(tokens / 1_000_000)}M`; + if (tokens >= 1_000) return `${new Intl.NumberFormat(locale, { minimumFractionDigits: 1, maximumFractionDigits: 1 }).format(tokens / 1_000)}k`; + return new Intl.NumberFormat(locale).format(tokens); } diff --git a/dashboard/src/v2/pages/__tests__/AgentsPage.push.test.tsx b/dashboard/src/v2/pages/__tests__/AgentsPage.push.test.tsx index 02820ed343..6663f254a6 100644 --- a/dashboard/src/v2/pages/__tests__/AgentsPage.push.test.tsx +++ b/dashboard/src/v2/pages/__tests__/AgentsPage.push.test.tsx @@ -4,8 +4,9 @@ import { afterEach, describe, expect, it, vi } from "vitest"; import { cleanup, render, screen, waitFor } from "@testing-library/preact"; import userEvent from "@testing-library/user-event"; import * as matchers from "@testing-library/jest-dom/matchers"; -import { createContext } from "preact"; +import { createContext, type ComponentChildren } from "preact"; import { AgentsPage } from "../../AgentsPage.js"; +import { DashboardI18nProvider } from "../../i18n/index.js"; import { useProjectData } from "../../context/project-data.js"; import { useProjectEffectiveSettings } from "../../hooks/use-project-effective-settings.js"; import { fetchAgentPresets, pushAgentPresetsToRepository } from "../../lib/agent-preset-api.js"; @@ -122,7 +123,13 @@ describe("AgentsPage push flow", () => { } as any, ]); - render(); + render(, { + wrapper: ({ children }: { children: ComponentChildren }) => ( + + {children} + + ), + }); await screen.findByRole("button", { name: "Push Agents" }); @@ -130,7 +137,7 @@ describe("AgentsPage push flow", () => { expect(screen.getByRole("dialog", { name: "Push Agents" })).toBeInTheDocument(); mockedPushAgentPresetsToRepository.mockResolvedValueOnce({ committed: false }); - await user.click(screen.getByLabelText("Commit locally")); + await user.click(screen.getByRole("radio", { name: /Commit locally/ })); await user.click(screen.getByRole("button", { name: "Push" })); await waitFor(() => { @@ -142,7 +149,7 @@ describe("AgentsPage push flow", () => { expect(await screen.findByText("No agent preset changes were available to commit.")).toBeInTheDocument(); await user.click(screen.getByRole("button", { name: "Push Agents" })); - await user.click(screen.getByLabelText("Push to branch")); + await user.click(screen.getByRole("radio", { name: /Push to branch/ })); await user.clear(screen.getByLabelText("Branch name")); await user.type(screen.getByLabelText("Branch name"), "feature/agents"); mockedPushAgentPresetsToRepository.mockResolvedValueOnce({ @@ -160,7 +167,7 @@ describe("AgentsPage push flow", () => { expect(await screen.findByText("Pushed agent presets to feature/agents.")).toBeInTheDocument(); await user.click(screen.getByRole("button", { name: "Push Agents" })); - await user.click(screen.getByLabelText("Open pull request")); + await user.click(screen.getByRole("radio", { name: /Open pull request/ })); await user.clear(screen.getByLabelText("Branch name")); await user.type(screen.getByLabelText("Branch name"), "feature/agents-pr"); mockedPushAgentPresetsToRepository.mockResolvedValueOnce({ @@ -179,7 +186,7 @@ describe("AgentsPage push flow", () => { expect(await screen.findByRole("link", { name: "https://example.com/acme/repo/pull/7" })).toHaveAttribute("href", "https://example.com/acme/repo/pull/7"); await user.click(screen.getByRole("button", { name: "Push Agents" })); - await user.click(screen.getByLabelText("Push to branch")); + await user.click(screen.getByRole("radio", { name: /Push to branch/ })); mockedPushAgentPresetsToRepository.mockResolvedValueOnce({ committed: true }); await user.click(screen.getByRole("button", { name: "Push" })); diff --git a/docs-web/architecture/dashboard-internationalization.md b/docs-web/architecture/dashboard-internationalization.md index 90b33a0c7a..b9f02b8a42 100644 --- a/docs-web/architecture/dashboard-internationalization.md +++ b/docs-web/architecture/dashboard-internationalization.md @@ -34,3 +34,9 @@ const { translate, translatePlural, formatNumber } = useDashboardI18n(); English and German must declare exactly the same top-level keys. Interpolation treats replacement values as literal text, plural messages require an `other` form, and locale-aware formatting delegates to the browser's native `Intl` implementation. Keep each catalog with its owning feature and import it only where the feature is loaded. Translate dashboard-authored interface copy only. Never translate provider output, API responses, stored instructions, project data, runtime diagnostics, or user-authored content. + +## Agents route boundary + +The lazy `/agents` route imports its own `messages/agents.ts` catalog. English and German cover the roster, preset detail/editor, validation, avatar customization, instruction files, memory and MCP configuration, repository push feedback, compatibility updates, loading and empty states, and accessibility labels. Dates, counts, token estimates, byte sizes, and plural messages use the active locale. + +Agent-authored and runtime data stays byte-for-byte outside translation: preset names and labels, system instructions, memory templates, Markdown files, MCP server/tool names, storage names, provider/model names, repository and invocation output, and API errors. Persisted identifiers and configuration values are likewise unchanged; the route translates only their presentation. diff --git a/docs/dashboard/internationalization.md b/docs/dashboard/internationalization.md index c0047eca70..77551a4a55 100644 --- a/docs/dashboard/internationalization.md +++ b/docs/dashboard/internationalization.md @@ -51,6 +51,12 @@ The initial application bundle translates only root-owned shell copy: the skip l Localization applies only to dashboard-authored interface copy. API responses, provider output, stored instructions, project and sprint data, runtime diagnostics, and all other user-authored content must remain unchanged. +### Agents route + +The `/agents` route owns `messages/agents.ts`. Its English and German catalog covers roster controls, preset details and editing, validation, avatar controls, instruction files, memory filters, MCP access, repository push feedback, compatibility-update notices, empty/loading/error states, and accessible labels. Dates, counts, token estimates, file sizes, and plurals use the active locale's native formatters. + +The localization boundary is intentionally strict. Preset names and labels, system instructions, memory templates, Markdown file contents, MCP server and tool names, storage names, provider/model names, invocation and repository output, and API error messages pass through verbatim. Stable configuration identifiers—such as avatar part values, memory tiers, MCP tool IDs, and sync states—also remain unchanged; only their dashboard presentation is localized. + ## Verification -Foundation coverage is in `tests/dashboard/v2/i18n-foundation.test.tsx`. It exercises startup defaults, stored German restoration, live switching, invalid and unavailable storage, cross-tab events, interpolation, plural rules, all formatter families, and HTML `lang` synchronization. +Foundation coverage is in `tests/dashboard/v2/i18n-foundation.test.tsx`. It exercises startup defaults, stored German restoration, live switching, invalid and unavailable storage, cross-tab events, interpolation, plural rules, all formatter families, and HTML `lang` synchronization. Agents coverage additionally verifies German route chrome and validation while asserting that authored instructions, imported Markdown, server labels, and persisted configuration values are not translated. diff --git a/tests/dashboard/v2/agent-avatar-scene-lazy.test.tsx b/tests/dashboard/v2/agent-avatar-scene-lazy.test.tsx index 604fffdfad..600301591b 100644 --- a/tests/dashboard/v2/agent-avatar-scene-lazy.test.tsx +++ b/tests/dashboard/v2/agent-avatar-scene-lazy.test.tsx @@ -1,7 +1,7 @@ /** @vitest-environment happy-dom */ /** @jsx h */ import { h } from "preact"; -import { cleanup, render, screen, waitFor } from "@testing-library/preact"; +import { cleanup, render as baseRender, screen, waitFor } from "@testing-library/preact"; import * as matchers from "@testing-library/jest-dom/matchers"; import { afterEach, beforeEach, describe, expect, it, vi } from "vitest"; @@ -26,6 +26,16 @@ vi.mock("../../../dashboard/src/v2/components/agents/AgentAvatarScene.js", () => }); import { LazyAgentAvatarScene } from "../../../dashboard/src/v2/components/agents/LazyAgentAvatarScene.js"; +import { DashboardI18nProvider } from "../../../dashboard/src/v2/i18n/index.js"; + +const render: typeof baseRender = (ui, options) => baseRender(ui, { + ...options, + wrapper: ({ children }) => ( + + {children} + + ), +}); type IntersectionHandler = IntersectionObserverCallback; diff --git a/tests/dashboard/v2/agent-response-effects.test.ts b/tests/dashboard/v2/agent-response-effects.test.ts index bb444e3d8a..c14ea14f61 100644 --- a/tests/dashboard/v2/agent-response-effects.test.ts +++ b/tests/dashboard/v2/agent-response-effects.test.ts @@ -67,6 +67,7 @@ describe("agent response effects", () => { const resolved = resolveAgentResponseEffect({ agentEffect: validEffect }, fence); expect(resolved?.emotion).toBe("proud"); expect(getAgentResponseEffectCaption({ emotion: "curious", animation: "wink", durationMs: 900 })).toBe("Feeling curious."); + expect(getAgentResponseEffectCaption({ emotion: "curious", animation: "wink", durationMs: 900 }, "de")).toBe("Stimmung: Neugierig."); + expect(getAgentResponseEffectCaption({ emotion: "curious", animation: "wink", durationMs: 900, caption: "Keep this caption" }, "de")).toBe("Keep this caption"); }); }); - diff --git a/tests/dashboard/v2/agents-page.test.tsx b/tests/dashboard/v2/agents-page.test.tsx index 04f450b7b1..2b141fada0 100644 --- a/tests/dashboard/v2/agents-page.test.tsx +++ b/tests/dashboard/v2/agents-page.test.tsx @@ -24,6 +24,7 @@ import * as agentPresetApi from "../../../dashboard/src/v2/lib/agent-preset-api. import * as settingsApi from "../../../dashboard/src/v2/lib/settings-api.js"; import { ProjectDataProvider } from "../../../dashboard/src/v2/context/project-data.js"; import { AgentsPage } from "../../../dashboard/src/v2/AgentsPage.js"; +import { DashboardI18nProvider, type DashboardLocale } from "../../../dashboard/src/v2/i18n/index.js"; import { clearEffectiveSettingsCacheForTests } from "../../../dashboard/src/v2/hooks/use-project-effective-settings.js"; import { DEFAULT_DASHBOARD_SETTINGS } from "../../../src/repositories/settings-defaults.js"; @@ -151,7 +152,8 @@ vi.mock("../../../dashboard/src/v2/components/agents/AgentPresetDetailPanel.js", disabled: props.pushingToFile || !props.canPushToFile, onClick: () => props.onPushToFile(props.preset.id), }, props.pushingToFile ? "Pushing to file" : "Push to file"), - h("button", { onClick: props.onEdit }, "Edit Agent") + h("button", { onClick: props.onEdit }, "Edit Agent"), + h("button", { onClick: () => props.onDelete(props.preset.id) }, "Delete Agent") ) }; }); @@ -348,11 +350,18 @@ describe("AgentsPage", () => { } }); - const renderPage = async () => { + const renderPage = async (locale: DashboardLocale = "en") => { const res = render( - + , + { + wrapper: ({ children }) => ( + + {children} + + ), + }, ); await act(async () => { await new Promise((r) => setTimeout(r, 10)); @@ -524,7 +533,7 @@ describe("AgentsPage", () => { expect(screen.getByText("Review Agent")).toBeInTheDocument(); // Both cards in the list should be visible - const cards = screen.getAllByRole("button", { name: /Planning Agent|Review Agent/i }); + const cards = screen.getAllByTestId("showcase-card"); expect(cards).toHaveLength(2); // Detail panel for "Planning Agent" (the first one) should be visible @@ -537,6 +546,38 @@ describe("AgentsPage", () => { }); }); + it("localizes German route chrome while preserving agent-authored content", async () => { + await renderPage("de"); + + expect(await screen.findByRole("region", { name: "Agenten" })).toBeInTheDocument(); + expect(screen.getByText("Agenten insgesamt")).toBeInTheDocument(); + expect(screen.getAllByText("Planning Agent").length).toBeGreaterThan(0); + expect(screen.getByText("Do some planning")).toBeInTheDocument(); + }); + + it("reports German creation and deletion feedback without localizing preset names", async () => { + vi.mocked(agentPresetApi.createAgentPreset).mockResolvedValue({ + ...mockPresets[0], + id: "agent-new", + name: "Agent 3", + } as any); + vi.mocked(agentPresetApi.deleteAgentPreset).mockResolvedValue(undefined); + + await renderPage("de"); + fireEvent.click(screen.getByText("New Agent")); + + expect(await screen.findByText("Agentenvorlage erstellt. Füllen Sie die Pflichtfelder aus und speichern Sie anschließend.")).toBeInTheDocument(); + expect(agentPresetApi.createAgentPreset).toHaveBeenCalledWith("project-1", expect.objectContaining({ name: "Agent 3" })); + + cleanup(); + mockPresets = [mockPresets[0], mockPresets[1]]; + await renderPage("de"); + fireEvent.click(await screen.findByRole("button", { name: "Delete Agent" })); + + expect(await screen.findByText("Agentenvorlage gelöscht.")).toBeInTheDocument(); + expect(agentPresetApi.deleteAgentPreset).toHaveBeenCalledWith("agent-1"); + }); + it("shows route assignment tags from effective project settings", async () => { const effective = createEffectiveSettings(); effective.settings.agents.routing.planning.agentPresetId = "agent-1"; diff --git a/tests/dashboard/v2/persistent-skill-storage-hover.test.tsx b/tests/dashboard/v2/persistent-skill-storage-hover.test.tsx index 62ead6991a..48cdba072c 100644 --- a/tests/dashboard/v2/persistent-skill-storage-hover.test.tsx +++ b/tests/dashboard/v2/persistent-skill-storage-hover.test.tsx @@ -1,13 +1,23 @@ /** @vitest-environment jsdom */ /// import { h } from "preact"; -import { cleanup, fireEvent, render, screen, waitFor } from "@testing-library/preact"; +import { cleanup, fireEvent, render as baseRender, screen, waitFor } from "@testing-library/preact"; import * as matchers from "@testing-library/jest-dom/matchers"; import { afterEach, describe, expect, it, vi } from "vitest"; import type { SkillStorageContentsResponse } from "../../../src/contracts/skill-types.js"; import type { SkillStorageRecord } from "../../../dashboard/src/v2/types.js"; import { fetchSkillStorageContents } from "../../../dashboard/src/v2/lib/agent-preset-api.js"; import { PersistentSkillStorageChip } from "../../../dashboard/src/v2/components/agents/PersistentSkillStorageChip.js"; +import { DashboardI18nProvider } from "../../../dashboard/src/v2/i18n/index.js"; + +const render: typeof baseRender = (ui, options) => baseRender(ui, { + ...options, + wrapper: ({ children }) => ( + + {children} + + ), +}); expect.extend(matchers); @@ -79,7 +89,7 @@ describe("PersistentSkillStorageChip", () => { expect(fetchSkillStorageContents).toHaveBeenCalledWith("project-test", "storage-shared"); expect(screen.getByText("6+ skills")).toBeInTheDocument(); expect(screen.getByText("Preview truncated")).toBeInTheDocument(); - expect(screen.getByText("+1 tags")).toBeInTheDocument(); + expect(screen.getByText("+1 tag")).toBeInTheDocument(); expect(screen.getByText("2 more loaded skills hidden from this preview.")).toBeInTheDocument(); expect(screen.getByText("More skills are available beyond this bounded response.")).toBeInTheDocument(); expect(screen.queryByText("Skill 5")).not.toBeInTheDocument(); From 1ee8b0e11dce17f6edf346d29b88d6bf28c62fd6 Mon Sep 17 00:00:00 2001 From: Code UX Date: Tue, 14 Jul 2026 03:17:07 +0000 Subject: [PATCH 2/2] feat(task T15): implement via codex --- dashboard/src/v2/AgentsPage.tsx | 10 +++++----- .../v2/components/agents/AgentKnowledgePanel.tsx | 2 +- .../components/agents/AgentPresetDetailPanel.tsx | 10 +++++++--- .../components/agents/AgentPresetEditorPanel.tsx | 13 +++++-------- .../components/agents/AgentPresetShowcaseCard.tsx | 4 ++-- dashboard/src/v2/components/agents/AgentsHero.tsx | 2 +- .../agents/PersistentSkillStorageChip.tsx | 14 ++++++++------ .../__tests__/AgentPresetEditorPanel.test.tsx | 6 +++++- dashboard/src/v2/i18n/locales.ts | 2 +- dashboard/src/v2/i18n/messages/agents.ts | 2 +- tests/dashboard/v2/i18n-foundation.test.tsx | 3 +++ 11 files changed, 39 insertions(+), 29 deletions(-) diff --git a/dashboard/src/v2/AgentsPage.tsx b/dashboard/src/v2/AgentsPage.tsx index 2c081d85e7..6a7abc887c 100644 --- a/dashboard/src/v2/AgentsPage.tsx +++ b/dashboard/src/v2/AgentsPage.tsx @@ -42,7 +42,7 @@ import { agentsMessages } from "./i18n/messages/agents.js"; /* ── Roster summary stat ── */ type RosterStatProps = { label: string; - value: number; + value: string; accent: "signal" | "amber" | "rose" | "slate"; icon: typeof Bot; }; @@ -839,10 +839,10 @@ export const AgentsPage: FunctionComponent = () => { {/* Roster summary strip — only when project is loaded */} {selectedProject && presets.length > 0 && (
    - - - 0 ? "amber" : "slate"} icon={AlertTriangle} /> - + + + 0 ? "amber" : "slate"} icon={AlertTriangle} /> +
    )} diff --git a/dashboard/src/v2/components/agents/AgentKnowledgePanel.tsx b/dashboard/src/v2/components/agents/AgentKnowledgePanel.tsx index b616510fe6..fe56181e83 100644 --- a/dashboard/src/v2/components/agents/AgentKnowledgePanel.tsx +++ b/dashboard/src/v2/components/agents/AgentKnowledgePanel.tsx @@ -192,7 +192,7 @@ export const AgentKnowledgePanel: FunctionComponent<{ {isReady - ? translatePlural(agentsMessages, "chunkCount", doc.chunkCount) + ? translatePlural(agentsMessages, "chunkCount", doc.chunkCount, { count: formatNumber(doc.chunkCount) }) : translate(agentsMessages, doc.status === "error" ? "errorStatus" : "embedding")} diff --git a/dashboard/src/v2/components/agents/AgentPresetDetailPanel.tsx b/dashboard/src/v2/components/agents/AgentPresetDetailPanel.tsx index a76bbe233d..e065cfb43c 100644 --- a/dashboard/src/v2/components/agents/AgentPresetDetailPanel.tsx +++ b/dashboard/src/v2/components/agents/AgentPresetDetailPanel.tsx @@ -373,7 +373,9 @@ export const AgentPresetDetailPanel: FunctionComponent<{ /> } accent={mcpTags.length > 0} /> @@ -385,7 +387,9 @@ export const AgentPresetDetailPanel: FunctionComponent<{ /> } /> ))} {hiddenMcpTagCount > 0 && ( - +{hiddenMcpTagCount} + +{formatNumber(hiddenMcpTagCount)} )} {mcpTags.length === 0 && ( diff --git a/dashboard/src/v2/components/agents/AgentPresetEditorPanel.tsx b/dashboard/src/v2/components/agents/AgentPresetEditorPanel.tsx index 4913203ed3..243f5dfb74 100644 --- a/dashboard/src/v2/components/agents/AgentPresetEditorPanel.tsx +++ b/dashboard/src/v2/components/agents/AgentPresetEditorPanel.tsx @@ -208,16 +208,11 @@ const SectionCard: FunctionComponent<{
); -const formatMemoryStrength = (value: number): string => { - if (value === 0) return "0"; - return value.toFixed(2).replace(/\.?0+$/, ""); -}; - const formatMemoryConfigSummary = ( config: AgentMemoryConfig, localize: (key: DashboardTextMessageKey, variables?: DashboardMessageVariables) => string, pluralize: (key: "categoryCount", count: number) => string, - formatNumber: (value: number) => string, + formatNumber: (value: number, options?: Intl.NumberFormatOptions) => string, ): string => { const tierLabel = config.tier === "both" @@ -234,7 +229,9 @@ const formatMemoryConfigSummary = ( const parts = [tierLabel, categoryLabel]; if (config.minStrength > 0) { - parts.push(localize("minStrengthSummary", { value: formatMemoryStrength(config.minStrength) })); + parts.push(localize("minStrengthSummary", { + value: formatNumber(config.minStrength, { maximumFractionDigits: 2 }), + })); } if (config.maxShortTerm > 0) { @@ -486,7 +483,7 @@ export const AgentPresetEditorPanel: FunctionComponent<{ const memoryConfigSummary = useMemo(() => formatMemoryConfigSummary( memoryConfig, t, - (_key, count) => translatePlural(agentsMessages, "categoryCount", count), + (_key, count) => translatePlural(agentsMessages, "categoryCount", count, { count: formatNumber(count) }), (value) => formatNumber(value), ), [memoryConfig, t, translatePlural, formatNumber]); diff --git a/dashboard/src/v2/components/agents/AgentPresetShowcaseCard.tsx b/dashboard/src/v2/components/agents/AgentPresetShowcaseCard.tsx index 4f39ef3983..a678fc5ef8 100644 --- a/dashboard/src/v2/components/agents/AgentPresetShowcaseCard.tsx +++ b/dashboard/src/v2/components/agents/AgentPresetShowcaseCard.tsx @@ -29,7 +29,7 @@ export const AgentPresetShowcaseCard: FunctionComponent<{ isSelected: boolean; onClick: () => void; }> = ({ preset, routeTags, isSelected, onClick }) => { - const { translate } = useDashboardI18n(); + const { formatNumber, translate } = useDashboardI18n(); const cardRef = useRef(null); const accentHex = getAccentHex(preset.avatarConfig?.accent); const badge = syncBadge(preset); @@ -111,7 +111,7 @@ export const AgentPresetShowcaseCard: FunctionComponent<{ ))} {routeTags.length > 2 && ( - +{routeTags.length - 2} + +{formatNumber(routeTags.length - 2)} )} {badge.icon && } diff --git a/dashboard/src/v2/components/agents/AgentsHero.tsx b/dashboard/src/v2/components/agents/AgentsHero.tsx index 807c928ec2..85b76087dd 100644 --- a/dashboard/src/v2/components/agents/AgentsHero.tsx +++ b/dashboard/src/v2/components/agents/AgentsHero.tsx @@ -101,7 +101,7 @@ export const AgentsHero: FunctionComponent<{
- {total} + {formatNumber(total)} {translate(agentsMessages, "active")} diff --git a/dashboard/src/v2/components/agents/PersistentSkillStorageChip.tsx b/dashboard/src/v2/components/agents/PersistentSkillStorageChip.tsx index c7cadd2321..5500264ce3 100644 --- a/dashboard/src/v2/components/agents/PersistentSkillStorageChip.tsx +++ b/dashboard/src/v2/components/agents/PersistentSkillStorageChip.tsx @@ -33,7 +33,7 @@ const truncatePreview = (value: string): { text: string; truncated: boolean } => }; const SkillSummary: FunctionComponent<{ skill: SkillStorageContentSummary }> = ({ skill }) => { - const { translate, translatePlural } = useDashboardI18n(); + const { formatNumber, translate, translatePlural } = useDashboardI18n(); const preview = truncatePreview(skill.contentPreview); const visibleTags = skill.tags.slice(0, MAX_VISIBLE_TAGS); const hiddenTagCount = skill.tags.length - visibleTags.length; @@ -60,7 +60,9 @@ const SkillSummary: FunctionComponent<{ skill: SkillStorageContentSummary }> = ( ))} {hiddenTagCount > 0 ? ( - {translatePlural(agentsMessages, "hiddenTags", hiddenTagCount)} + + {translatePlural(agentsMessages, "hiddenTags", hiddenTagCount, { count: formatNumber(hiddenTagCount) })} + ) : null}
) : null} @@ -82,7 +84,7 @@ const StorageDisclosure: FunctionComponent<{ storage: SkillStorageRecord; state: LoadState; }> = ({ storage, state }) => { - const { translate, translatePlural } = useDashboardI18n(); + const { formatNumber, translate, translatePlural } = useDashboardI18n(); if (state.status === "idle" || state.status === "loading") { return (
@@ -109,7 +111,7 @@ const StorageDisclosure: FunctionComponent<{ const { contents } = state; const visibleSkills = contents.skills.slice(0, MAX_VISIBLE_SKILLS); const hiddenLoadedCount = contents.skills.length - visibleSkills.length; - const skillCount = `${contents.skills.length}${contents.truncated ? "+" : ""}`; + const skillCount = `${formatNumber(contents.skills.length)}${contents.truncated ? "+" : ""}`; return (
@@ -123,7 +125,7 @@ const StorageDisclosure: FunctionComponent<{

- {contents.truncated ? `${skillCount} ` : ""}{translatePlural(agentsMessages, "skillCount", contents.skills.length).replace(`${contents.skills.length} `, contents.truncated ? "" : `${contents.skills.length} `)} + {translatePlural(agentsMessages, "skillCount", contents.skills.length, { count: skillCount })}
@@ -139,7 +141,7 @@ const StorageDisclosure: FunctionComponent<{ {hiddenLoadedCount > 0 ? (

- {translatePlural(agentsMessages, "hiddenSkills", hiddenLoadedCount)} + {translatePlural(agentsMessages, "hiddenSkills", hiddenLoadedCount, { count: formatNumber(hiddenLoadedCount) })}

) : null} {contents.truncated ? ( diff --git a/dashboard/src/v2/components/agents/__tests__/AgentPresetEditorPanel.test.tsx b/dashboard/src/v2/components/agents/__tests__/AgentPresetEditorPanel.test.tsx index 23b8f6a901..126d3bcb1b 100644 --- a/dashboard/src/v2/components/agents/__tests__/AgentPresetEditorPanel.test.tsx +++ b/dashboard/src/v2/components/agents/__tests__/AgentPresetEditorPanel.test.tsx @@ -307,7 +307,10 @@ describe("AgentPresetEditorPanel", () => { const instructionMarkdown = "# Preserve me\n\nReturn provider output verbatim."; renderWithI18n( { expect(screen.getByLabelText("agent-instructions")).toHaveValue(instructionMarkdown); expect(screen.getByRole("button", { name: "Agent speichern" })).toBeInTheDocument(); + expect(screen.getByText(/min\. 0,25/)).toBeInTheDocument(); const nameInput = screen.getByLabelText(/Agentenname/); fireEvent.input(nameInput, { target: { value: "" } }); fireEvent.submit(screen.getByRole("form")); diff --git a/dashboard/src/v2/i18n/locales.ts b/dashboard/src/v2/i18n/locales.ts index 1edfd83529..2fff031e67 100644 --- a/dashboard/src/v2/i18n/locales.ts +++ b/dashboard/src/v2/i18n/locales.ts @@ -108,5 +108,5 @@ export const translateDashboardPlural = < : localizedMessage; const pluralCategory = new Intl.PluralRules(locale, options).select(count); const template = messages[pluralCategory] ?? messages.other; - return interpolateDashboardMessage(template, { ...variables, count }); + return interpolateDashboardMessage(template, { count, ...variables }); }; diff --git a/dashboard/src/v2/i18n/messages/agents.ts b/dashboard/src/v2/i18n/messages/agents.ts index d6a2095422..e7f8d2be2c 100644 --- a/dashboard/src/v2/i18n/messages/agents.ts +++ b/dashboard/src/v2/i18n/messages/agents.ts @@ -507,7 +507,7 @@ export const agentsMessages = defineDashboardMessages({ manage: "Verwalten", mcpActiveSummary: "Zum Verknüpfen oder Trennen auswählen. {count} aktiv. Mit Verwalten konfigurieren Sie Code-UX-Tools.", mcpItemChanged: "{name} für diesen Agenten {state}. Speichern Sie den Agenten, um den MCP-Zugriff zu übernehmen.", - reviewDashboardMcp: "Prüfen Sie den Code-UX-MCP- und Zeitplanungszugriff, bevor Sie ihn f��r den Dashboard-Antwortagenten aktivieren.", + reviewDashboardMcp: "Prüfen Sie den Code-UX-MCP- und Zeitplanungszugriff, bevor Sie ihn für den Dashboard-Antwortagenten aktivieren.", reviewRiskMcp: "Code-UX-Zugriff ist für Nicht-Chat-Agenten risikobegrenzt. Prüfen Sie vor der Aktivierung die Warnung in der MCP-Verwaltung.", moreCount: "+{count} weitere", createdAt: "Erstellt am {date}", diff --git a/tests/dashboard/v2/i18n-foundation.test.tsx b/tests/dashboard/v2/i18n-foundation.test.tsx index 8104daeefe..2b668f485e 100644 --- a/tests/dashboard/v2/i18n-foundation.test.tsx +++ b/tests/dashboard/v2/i18n-foundation.test.tsx @@ -196,6 +196,9 @@ describe("dashboard i18n foundation", () => { expect(translateDashboardPlural(featureMessages, "en", "itemCount", 1)).toBe("1 item"); expect(translateDashboardPlural(featureMessages, "en", "itemCount", 2)).toBe("2 items"); expect(translateDashboardPlural(featureMessages, "de", "itemCount", 2)).toBe("2 Einträge"); + expect(translateDashboardPlural(featureMessages, "de", "itemCount", 1234, { + count: new Intl.NumberFormat("de").format(1234), + })).toBe("1.234 Einträge"); }); it("formats numbers, dates, times, relative times, and lists with the selected locale", () => {