feat(i18n): localize Settings → Agents (#2193)
Co-authored-by: Kandev Agent <agent@kandev.dev> Co-authored-by: Claude Opus 5 (1M context) <noreply@anthropic.com>
This commit is contained in:
@@ -1,11 +1,13 @@
|
||||
"use client";
|
||||
|
||||
import { useEffect } from "react";
|
||||
import { useTranslation } from "react-i18next";
|
||||
import { useRouter } from "@/lib/routing/client-router";
|
||||
import { Card, CardContent } from "@kandev/ui/card";
|
||||
import { Button } from "@kandev/ui/button";
|
||||
|
||||
export default function AgentEditPage() {
|
||||
const { t } = useTranslation();
|
||||
const router = useRouter();
|
||||
|
||||
useEffect(() => {
|
||||
@@ -15,9 +17,9 @@ export default function AgentEditPage() {
|
||||
return (
|
||||
<Card>
|
||||
<CardContent className="py-12 text-center">
|
||||
<p className="text-sm text-muted-foreground">Manage agents from the main Agents page.</p>
|
||||
<p className="text-sm text-muted-foreground">{t("agents:manageAgentsFromMainPage")}</p>
|
||||
<Button className="mt-4" onClick={() => router.push("/settings/agents")}>
|
||||
Go to Agents
|
||||
{t("agents:goToAgents")}
|
||||
</Button>
|
||||
</CardContent>
|
||||
</Card>
|
||||
|
||||
@@ -17,8 +17,13 @@ import type {
|
||||
import { arePermissionsDirty, permissionsToProfilePatch } from "@/lib/agent-permissions";
|
||||
import { areCLIFlagsEqual } from "@/lib/cli-flags";
|
||||
import { areConfigOptionsEqual } from "@/lib/config-options";
|
||||
import { t } from "@/lib/i18n";
|
||||
import type { ProfileFormData } from "@/components/settings/profile-form-fields";
|
||||
|
||||
// The JSON key the MCP editor validates against — an identifier, interpolated
|
||||
// into the parse error rather than written into the catalog.
|
||||
const MCP_SERVERS_KEY = "mcpServers";
|
||||
|
||||
/**
|
||||
* Translates a ProfileFormData patch (snake_case form keys) into a
|
||||
* Partial<AgentProfile> (camelCase). Profiles in client state use the
|
||||
@@ -82,12 +87,12 @@ export const parseProfileMcpServers = (raw: string): Record<string, McpServerDef
|
||||
if (!raw.trim()) return {};
|
||||
const parsed = JSON.parse(raw) as unknown;
|
||||
if (!parsed || typeof parsed !== "object" || Array.isArray(parsed)) {
|
||||
throw new Error("MCP servers config must be a JSON object");
|
||||
throw new Error(t("agents:mcpConfigMustBeJsonObject"));
|
||||
}
|
||||
if ("mcpServers" in parsed) {
|
||||
if (MCP_SERVERS_KEY in parsed) {
|
||||
const nested = (parsed as { mcpServers?: unknown }).mcpServers;
|
||||
if (!nested || typeof nested !== "object" || Array.isArray(nested)) {
|
||||
throw new Error("mcpServers must be a JSON object");
|
||||
throw new Error(t("agents:mcpKeyMustBeJsonObject", { key: MCP_SERVERS_KEY }));
|
||||
}
|
||||
return nested as Record<string, McpServerDef>;
|
||||
}
|
||||
|
||||
@@ -1,5 +1,6 @@
|
||||
"use client";
|
||||
|
||||
import { useTranslation } from "react-i18next";
|
||||
import { IconPlus, IconTrash } from "@tabler/icons-react";
|
||||
import {
|
||||
AlertDialog,
|
||||
@@ -61,19 +62,18 @@ export function AgentHeader({
|
||||
savedAgent,
|
||||
onDelete,
|
||||
}: AgentHeaderProps) {
|
||||
const { t } = useTranslation();
|
||||
return (
|
||||
<div className="flex items-start justify-between gap-6">
|
||||
<div>
|
||||
<div className="flex flex-wrap items-center gap-2">
|
||||
<h2 className="text-2xl font-bold">{displayName}</h2>
|
||||
<span className="text-xs text-muted-foreground border border-muted-foreground/30 rounded-full px-2 py-1">
|
||||
{matchedPath ?? "Installation not detected"}
|
||||
{matchedPath ?? t("agents:installationNotDetected")}
|
||||
</span>
|
||||
</div>
|
||||
<p className="text-sm text-muted-foreground mt-1">
|
||||
{isCreateMode
|
||||
? "Create a new profile for this agent."
|
||||
: "Configure profiles and defaults for this agent."}
|
||||
{isCreateMode ? t("agents:createProfileIntro") : t("agents:configureProfilesIntro")}
|
||||
</p>
|
||||
</div>
|
||||
{savedAgent?.tui_config && onDelete && (
|
||||
@@ -81,21 +81,20 @@ export function AgentHeader({
|
||||
<AlertDialogTrigger asChild>
|
||||
<Button variant="destructive" size="sm" className="cursor-pointer">
|
||||
<IconTrash className="h-4 w-4 mr-2" />
|
||||
Delete Agent
|
||||
{t("agents:deleteAgent")}
|
||||
</Button>
|
||||
</AlertDialogTrigger>
|
||||
<AlertDialogContent>
|
||||
<AlertDialogHeader>
|
||||
<AlertDialogTitle>Delete {displayName}?</AlertDialogTitle>
|
||||
<AlertDialogDescription>
|
||||
This will permanently remove the agent and all its profiles. This action cannot be
|
||||
undone.
|
||||
</AlertDialogDescription>
|
||||
<AlertDialogTitle>
|
||||
{t("agents:deleteAgentTitle", { name: displayName })}
|
||||
</AlertDialogTitle>
|
||||
<AlertDialogDescription>{t("agents:deleteAgentDescription")}</AlertDialogDescription>
|
||||
</AlertDialogHeader>
|
||||
<AlertDialogFooter>
|
||||
<AlertDialogCancel className="cursor-pointer">Cancel</AlertDialogCancel>
|
||||
<AlertDialogCancel className="cursor-pointer">{t("common:cancel")}</AlertDialogCancel>
|
||||
<AlertDialogAction onClick={onDelete} className="cursor-pointer">
|
||||
Delete
|
||||
{t("agents:delete")}
|
||||
</AlertDialogAction>
|
||||
</AlertDialogFooter>
|
||||
</AlertDialogContent>
|
||||
@@ -221,15 +220,18 @@ export function ProfilesCard({
|
||||
onRemoveProfile,
|
||||
onToastError,
|
||||
}: ProfilesCardProps) {
|
||||
const { t } = useTranslation();
|
||||
return (
|
||||
<SettingsCard isDirty={isAgentDirty}>
|
||||
<CardHeader className="flex flex-row items-center justify-between">
|
||||
<CardTitle>
|
||||
{isCreateMode ? `Create ${displayName} Profile` : `${displayName} Profiles`}
|
||||
{isCreateMode
|
||||
? t("agents:createAgentProfileTitle", { name: displayName })
|
||||
: t("agents:agentProfilesTitle", { name: displayName })}
|
||||
</CardTitle>
|
||||
<Button size="sm" variant="outline" onClick={onAddProfile} className="cursor-pointer">
|
||||
<IconPlus className="h-4 w-4 mr-2" />
|
||||
Add profile
|
||||
{t("agents:addProfile")}
|
||||
</Button>
|
||||
</CardHeader>
|
||||
<CardContent className="space-y-4">
|
||||
|
||||
@@ -1,6 +1,7 @@
|
||||
"use client";
|
||||
|
||||
import { useEffect, useMemo, useState } from "react";
|
||||
import { useTranslation } from "react-i18next";
|
||||
import Link from "@/components/routing/app-link";
|
||||
import { useParams, useRouter, useSearchParams } from "@/lib/routing/client-router";
|
||||
import { Button } from "@kandev/ui/button";
|
||||
@@ -207,17 +208,18 @@ function useAgentSaveHandlers({
|
||||
onToastError,
|
||||
replaceRoute,
|
||||
}: AgentSaveHandlersProps) {
|
||||
const { t } = useTranslation();
|
||||
const handleSave = async () => {
|
||||
if (draftAgent.profiles.some((p) => !p.name.trim())) {
|
||||
onToastError(new Error("Profile name is required."));
|
||||
onToastError(new Error(t("agents:profileNameRequired")));
|
||||
return;
|
||||
}
|
||||
if (draftAgent.profiles.some((p) => !p.model.trim())) {
|
||||
onToastError(new Error("Model is required for all profiles."));
|
||||
onToastError(new Error(t("agents:modelRequiredForAllProfiles")));
|
||||
return;
|
||||
}
|
||||
if (hasInvalidMcpConfig) {
|
||||
onToastError(new Error("Fix invalid MCP JSON before saving."));
|
||||
onToastError(new Error(t("agents:fixInvalidMcpJson")));
|
||||
return;
|
||||
}
|
||||
setSaveStatus("loading");
|
||||
@@ -367,6 +369,7 @@ function AgentSetupForm({
|
||||
onToastError,
|
||||
isCreateMode = false,
|
||||
}: AgentSetupFormProps) {
|
||||
const { t } = useTranslation();
|
||||
const router = useRouter();
|
||||
const availableAgents = useAvailableAgents().items;
|
||||
const { upsertAgent } = useAgentStoreSync();
|
||||
@@ -417,8 +420,8 @@ function AgentSetupForm({
|
||||
};
|
||||
const profilesValid = areAgentProfilesValid(draftAgent);
|
||||
let saveInvalidReason: string | undefined;
|
||||
if (!profilesValid) saveInvalidReason = "Every profile needs a name and model.";
|
||||
else if (hasInvalidMcpConfig) saveInvalidReason = "Fix invalid MCP configuration before saving.";
|
||||
if (!profilesValid) saveInvalidReason = t("agents:everyProfileNeedsNameAndModel");
|
||||
else if (hasInvalidMcpConfig) saveInvalidReason = t("agents:fixInvalidMcpConfig");
|
||||
useSettingsSaveContributor({
|
||||
id: `agent:${draftAgent.id}`,
|
||||
revision: saveRevision.revision,
|
||||
@@ -462,6 +465,7 @@ function AgentSetupForm({
|
||||
}
|
||||
|
||||
export default function AgentSetupPage() {
|
||||
const { t } = useTranslation();
|
||||
const { toast } = useToast();
|
||||
const params = useParams();
|
||||
const searchParams = useSearchParams();
|
||||
@@ -531,9 +535,9 @@ export default function AgentSetupPage() {
|
||||
return (
|
||||
<Card>
|
||||
<CardContent className="py-12 text-center">
|
||||
<p className="text-sm text-muted-foreground">Agent not found.</p>
|
||||
<p className="text-sm text-muted-foreground">{t("agents:agentNotFound")}</p>
|
||||
<Button className="mt-4" asChild>
|
||||
<Link href="/settings/agents">Back to Agents</Link>
|
||||
<Link href="/settings/agents">{t("agents:backToAgents")}</Link>
|
||||
</Button>
|
||||
</CardContent>
|
||||
</Card>
|
||||
@@ -544,8 +548,8 @@ export default function AgentSetupPage() {
|
||||
|
||||
const handleToastError = (error: unknown) => {
|
||||
toast({
|
||||
title: "Failed to save agent",
|
||||
description: error instanceof Error ? error.message : "Request failed",
|
||||
title: t("agents:failedToSaveAgent"),
|
||||
description: error instanceof Error ? error.message : t("agents:requestFailed"),
|
||||
variant: "error",
|
||||
});
|
||||
};
|
||||
|
||||
@@ -1,5 +1,6 @@
|
||||
"use client";
|
||||
|
||||
import { useTranslation } from "react-i18next";
|
||||
import { CardContent, CardHeader, CardTitle } from "@kandev/ui/card";
|
||||
import { Label } from "@kandev/ui/label";
|
||||
import { Switch } from "@kandev/ui/switch";
|
||||
@@ -7,6 +8,10 @@ import { Textarea } from "@kandev/ui/textarea";
|
||||
import { Tooltip, TooltipContent, TooltipTrigger } from "@kandev/ui/tooltip";
|
||||
import { useSettingsSaveContributor } from "@/components/settings/settings-save-provider";
|
||||
import { SettingsCard } from "@/components/settings/settings-card";
|
||||
// `validateDraftServers` runs outside React (from an onChange handler and from
|
||||
// the parent's draft state), so it uses the module-level `t`, which resolves at
|
||||
// call time. Components in this file use the `useTranslation()` hook.
|
||||
import { t as translate } from "@/lib/i18n";
|
||||
import { useProfileMcpConfig } from "./use-profile-mcp-config";
|
||||
import type { AgentProfileMcpConfig } from "@/lib/types/http";
|
||||
|
||||
@@ -69,8 +74,24 @@ const POPULAR_SERVERS: Record<string, Record<string, unknown>> = {
|
||||
},
|
||||
};
|
||||
|
||||
const KANDEV_TOOLS_DESCRIPTION =
|
||||
"Tools: list_workspaces, list_boards, list_workflow_steps, list_tasks, create_task, update_task";
|
||||
// Tool names and MCP-server product names are identifiers, not copy: they are
|
||||
// interpolated as values so the pseudo-locale cannot turn them into something
|
||||
// the user cannot type or look up.
|
||||
const KANDEV_MCP_NAME = "Kandev MCP";
|
||||
|
||||
const KANDEV_TOOL_NAMES =
|
||||
"list_workspaces, list_boards, list_workflow_steps, list_tasks, create_task, update_task";
|
||||
|
||||
const POPULAR_SERVER_NAMES: Record<string, string> = {
|
||||
playwright: "Playwright MCP",
|
||||
"chrome-devtools": "Chrome DevTools MCP",
|
||||
context7: "Context7 MCP",
|
||||
github: "GitHub MCP",
|
||||
};
|
||||
|
||||
// The JSON key the editor validates against. Interpolated into the error
|
||||
// messages below rather than written into the catalog.
|
||||
const MCP_SERVERS_KEY = "mcpServers";
|
||||
|
||||
type PopularServerButtonProps = {
|
||||
label: string;
|
||||
@@ -129,16 +150,16 @@ function validateDraftServers(value: string): string | null {
|
||||
try {
|
||||
const parsed = JSON.parse(value);
|
||||
if (!parsed || typeof parsed !== "object" || Array.isArray(parsed)) {
|
||||
return "MCP servers config must be a JSON object";
|
||||
return translate("agents:mcpConfigMustBeJsonObject");
|
||||
}
|
||||
if ("mcpServers" in parsed) {
|
||||
if (MCP_SERVERS_KEY in parsed) {
|
||||
const nested = (parsed as { mcpServers?: unknown }).mcpServers;
|
||||
if (!nested || typeof nested !== "object" || Array.isArray(nested)) {
|
||||
return "mcpServers must be a JSON object";
|
||||
return translate("agents:mcpKeyMustBeJsonObject", { key: MCP_SERVERS_KEY });
|
||||
}
|
||||
}
|
||||
} catch {
|
||||
return "Invalid JSON";
|
||||
return translate("agents:invalidJson");
|
||||
}
|
||||
return null;
|
||||
}
|
||||
@@ -150,10 +171,11 @@ function PassthroughMcpInjectionHint({
|
||||
cliPassthrough?: boolean;
|
||||
mcpInjection?: string;
|
||||
}) {
|
||||
const { t } = useTranslation();
|
||||
if (!cliPassthrough || !mcpInjection) return null;
|
||||
return (
|
||||
<p className="text-xs text-muted-foreground">
|
||||
In CLI passthrough mode, kandev injects these MCP servers via {mcpInjection}.
|
||||
{t("agents:mcpPassthroughInjectionHint", { mechanism: mcpInjection })}
|
||||
</p>
|
||||
);
|
||||
}
|
||||
@@ -183,6 +205,7 @@ function McpServersEditor({
|
||||
onDraftStateChange,
|
||||
handleMcpServersChange,
|
||||
}: McpServersEditorProps) {
|
||||
const { t } = useTranslation();
|
||||
const handleApplyServer = (label: string) => {
|
||||
applyPopularServerToJson(
|
||||
currentServers,
|
||||
@@ -201,7 +224,7 @@ function McpServersEditor({
|
||||
|
||||
return (
|
||||
<div className="space-y-2">
|
||||
<Label htmlFor={`mcp-servers-${profileId}`}>MCP servers (JSON)</Label>
|
||||
<Label htmlFor={`mcp-servers-${profileId}`}>{t("agents:mcpServersJson")}</Label>
|
||||
<Textarea
|
||||
id={`mcp-servers-${profileId}`}
|
||||
className="min-h-[200px] font-mono text-xs"
|
||||
@@ -217,43 +240,32 @@ function McpServersEditor({
|
||||
data-settings-dirty={isDirty}
|
||||
data-testid={`mcp-servers-${profileId}`}
|
||||
/>
|
||||
<p className="text-xs text-muted-foreground">
|
||||
MCP definitions are stored in the database and resolved per executor at runtime. This does
|
||||
not override your local agent config.
|
||||
</p>
|
||||
<p className="text-xs text-muted-foreground">{t("agents:mcpServersHelp")}</p>
|
||||
<PassthroughMcpInjectionHint cliPassthrough={cliPassthrough} mcpInjection={mcpInjection} />
|
||||
<p className="text-xs font-medium text-muted-foreground">Built-in</p>
|
||||
<p className="text-xs font-medium text-muted-foreground">{t("agents:builtIn")}</p>
|
||||
<div className="flex flex-wrap gap-2 mb-2">
|
||||
<Tooltip>
|
||||
<TooltipTrigger asChild>
|
||||
<span className="text-xs rounded-full border border-primary/50 bg-primary/10 px-2 py-1 text-primary">
|
||||
✓ Kandev MCP
|
||||
{t("agents:kandevMcpChip", { name: KANDEV_MCP_NAME })}
|
||||
</span>
|
||||
</TooltipTrigger>
|
||||
<TooltipContent side="bottom" className="max-w-[320px] text-xs">
|
||||
<p className="font-medium mb-1">Automatically available</p>
|
||||
<p>{KANDEV_TOOLS_DESCRIPTION}</p>
|
||||
<p className="font-medium mb-1">{t("agents:automaticallyAvailable")}</p>
|
||||
<p>{t("agents:kandevMcpTools", { tools: KANDEV_TOOL_NAMES })}</p>
|
||||
</TooltipContent>
|
||||
</Tooltip>
|
||||
</div>
|
||||
<p className="text-xs font-medium text-muted-foreground">Popular servers</p>
|
||||
<p className="text-xs font-medium text-muted-foreground">{t("agents:popularServers")}</p>
|
||||
<div className="flex flex-wrap gap-2">
|
||||
<PopularServerButton
|
||||
label="playwright"
|
||||
displayName="Playwright MCP"
|
||||
onApply={handleApplyServer}
|
||||
/>
|
||||
<PopularServerButton
|
||||
label="chrome-devtools"
|
||||
displayName="Chrome DevTools MCP"
|
||||
onApply={handleApplyServer}
|
||||
/>
|
||||
<PopularServerButton
|
||||
label="context7"
|
||||
displayName="Context7 MCP"
|
||||
onApply={handleApplyServer}
|
||||
/>
|
||||
<PopularServerButton label="github" displayName="GitHub MCP" onApply={handleApplyServer} />
|
||||
{Object.keys(POPULAR_SERVERS).map((label) => (
|
||||
<PopularServerButton
|
||||
key={label}
|
||||
label={label}
|
||||
displayName={POPULAR_SERVER_NAMES[label] ?? label}
|
||||
onApply={handleApplyServer}
|
||||
/>
|
||||
))}
|
||||
</div>
|
||||
{currentError && <p className="text-sm text-destructive">{currentError}</p>}
|
||||
</div>
|
||||
@@ -320,6 +332,7 @@ function McpEnableToggle({
|
||||
onDraftStateChange,
|
||||
setMcpEnabled,
|
||||
}: McpEnableToggleProps) {
|
||||
const { t } = useTranslation();
|
||||
return (
|
||||
<div
|
||||
className="flex items-center justify-between rounded-md border p-3"
|
||||
@@ -328,10 +341,8 @@ function McpEnableToggle({
|
||||
data-testid="mcp-enabled-row"
|
||||
>
|
||||
<div className="space-y-1">
|
||||
<Label>Enable MCP</Label>
|
||||
<p className="text-xs text-muted-foreground">
|
||||
Allow this profile to use MCP servers during sessions.
|
||||
</p>
|
||||
<Label>{t("agents:enableMcp")}</Label>
|
||||
<p className="text-xs text-muted-foreground">{t("agents:enableMcpDescription")}</p>
|
||||
</div>
|
||||
<Switch
|
||||
checked={currentEnabled}
|
||||
@@ -357,12 +368,11 @@ function McpProfileHint({
|
||||
isDraft: boolean;
|
||||
isEditableProfile: boolean;
|
||||
}) {
|
||||
const { t } = useTranslation();
|
||||
if (isEditableProfile) return null;
|
||||
return (
|
||||
<p className="text-xs text-muted-foreground">
|
||||
{isDraft
|
||||
? "MCP config will be applied after the profile is saved."
|
||||
: "Save this profile to configure MCP servers."}
|
||||
{isDraft ? t("agents:mcpDraftHint") : t("agents:mcpSaveProfileHint")}
|
||||
</p>
|
||||
);
|
||||
}
|
||||
@@ -377,6 +387,7 @@ export function ProfileMcpConfigCard({
|
||||
onDraftStateChange,
|
||||
onToastError,
|
||||
}: ProfileMcpConfigCardProps) {
|
||||
const { t } = useTranslation();
|
||||
const {
|
||||
mcpEnabled,
|
||||
mcpServers,
|
||||
@@ -416,7 +427,7 @@ export function ProfileMcpConfigCard({
|
||||
return (
|
||||
<SettingsCard isDirty={state.currentDirty}>
|
||||
<CardHeader>
|
||||
<CardTitle>MCP Configuration</CardTitle>
|
||||
<CardTitle>{t("agents:mcpConfiguration")}</CardTitle>
|
||||
</CardHeader>
|
||||
<CardContent className="space-y-4">
|
||||
<McpProfileHint isDraft={state.isDraft} isEditableProfile={state.isEditableProfile} />
|
||||
|
||||
+45
@@ -70,4 +70,49 @@ describe("CommandPreviewCard", () => {
|
||||
expect(screen.queryByText("agent --stale")).not.toBeTruthy();
|
||||
});
|
||||
});
|
||||
|
||||
// The `{prompt}` hint is a <Trans>: its <0> index addresses the JSX children
|
||||
// positionally, so a prettier reflow can silently reassemble the sentence
|
||||
// into fragments without failing anything. Assert the whole reconstructed
|
||||
// sentence, and that the token itself is never translated or re-interpolated.
|
||||
it("renders the {prompt} hint as one sentence with the token intact", async () => {
|
||||
previewAgentCommandActionMock.mockImplementation(async () => response("agent run {prompt}"));
|
||||
|
||||
render(
|
||||
<CommandPreviewCard
|
||||
agentName="claude"
|
||||
model="claude-sonnet-4-5"
|
||||
permissionSettings={{}}
|
||||
cliPassthrough={false}
|
||||
cliFlags={[]}
|
||||
commandPrefix=""
|
||||
/>,
|
||||
);
|
||||
|
||||
const hint = await screen.findByText(
|
||||
(_content, element) =>
|
||||
element?.tagName === "P" &&
|
||||
element.textContent ===
|
||||
"{prompt} will be replaced with your task description or follow-up message.",
|
||||
);
|
||||
expect(hint.querySelector("code")?.textContent).toBe("{prompt}");
|
||||
});
|
||||
|
||||
it("omits the {prompt} hint in CLI passthrough mode", async () => {
|
||||
previewAgentCommandActionMock.mockImplementation(async () => response("agent"));
|
||||
|
||||
render(
|
||||
<CommandPreviewCard
|
||||
agentName="claude"
|
||||
model="claude-sonnet-4-5"
|
||||
permissionSettings={{}}
|
||||
cliPassthrough
|
||||
cliFlags={[]}
|
||||
commandPrefix=""
|
||||
/>,
|
||||
);
|
||||
|
||||
await waitFor(() => expect(screen.getByText("agent")).toBeTruthy());
|
||||
expect(screen.queryByText(/will be replaced with your task description/)).toBeNull();
|
||||
});
|
||||
});
|
||||
|
||||
+18
-9
@@ -1,6 +1,7 @@
|
||||
"use client";
|
||||
|
||||
import { useEffect, useState, useMemo, useRef } from "react";
|
||||
import { Trans, useTranslation } from "react-i18next";
|
||||
import { IconCopy, IconCheck, IconTerminal2 } from "@tabler/icons-react";
|
||||
import { Card, CardContent, CardHeader, CardTitle } from "@kandev/ui/card";
|
||||
import { Button } from "@kandev/ui/button";
|
||||
@@ -20,6 +21,11 @@ type CommandPreviewCardProps = {
|
||||
secrets?: { id: string; name: string }[];
|
||||
};
|
||||
|
||||
// The placeholder the backend substitutes into the launch command. A token the
|
||||
// user reads verbatim in the preview, so it is interpolated as a value rather
|
||||
// than written into the catalog.
|
||||
const PROMPT_TOKEN = "{prompt}";
|
||||
|
||||
/**
|
||||
* POSIX-style single-quote escaping: wrap in single quotes and escape
|
||||
* embedded single quotes via `'\''`. Used so the previewed env-var prefix
|
||||
@@ -65,9 +71,10 @@ function CommandPreviewError({ error }: { error: string }) {
|
||||
}
|
||||
|
||||
function CommandPreviewEmpty() {
|
||||
const { t } = useTranslation();
|
||||
return (
|
||||
<div className="rounded-md border border-muted p-4">
|
||||
<p className="text-sm text-muted-foreground">No command preview available.</p>
|
||||
<p className="text-sm text-muted-foreground">{t("agents:noCommandPreview")}</p>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -79,6 +86,7 @@ type CommandPreviewContentProps = {
|
||||
};
|
||||
|
||||
function CommandPreviewContent({ preview, cliPassthrough, envPrefix }: CommandPreviewContentProps) {
|
||||
const { t } = useTranslation();
|
||||
const [copied, setCopied] = useState(false);
|
||||
const displayCommand = `${envPrefix}${preview.command_string ?? ""}`;
|
||||
|
||||
@@ -102,7 +110,7 @@ function CommandPreviewContent({ preview, cliPassthrough, envPrefix }: CommandPr
|
||||
size="sm"
|
||||
className="absolute right-2 top-2"
|
||||
onClick={handleCopy}
|
||||
title="Copy to clipboard"
|
||||
title={t("agents:copyToClipboard")}
|
||||
>
|
||||
{copied ? (
|
||||
<IconCheck className="h-4 w-4 text-green-500" />
|
||||
@@ -114,8 +122,10 @@ function CommandPreviewContent({ preview, cliPassthrough, envPrefix }: CommandPr
|
||||
|
||||
{!cliPassthrough && (
|
||||
<p className="text-xs text-muted-foreground">
|
||||
<code className="rounded bg-muted px-1 py-0.5">{"{prompt}"}</code> will be replaced with
|
||||
your task description or follow-up message.
|
||||
<Trans i18nKey="agents:promptTokenHint" values={{ token: PROMPT_TOKEN }}>
|
||||
<code className="rounded bg-muted px-1 py-0.5">{PROMPT_TOKEN}</code> will be replaced
|
||||
with your task description or follow-up message.
|
||||
</Trans>
|
||||
</p>
|
||||
)}
|
||||
</>
|
||||
@@ -132,6 +142,7 @@ export function CommandPreviewCard({
|
||||
envVars,
|
||||
secrets,
|
||||
}: CommandPreviewCardProps) {
|
||||
const { t } = useTranslation();
|
||||
const envPrefix = useMemo(() => buildEnvPrefix(envVars, secrets), [envVars, secrets]);
|
||||
const [preview, setPreview] = useState<CommandPreviewResponse | null>(null);
|
||||
const [loading, setLoading] = useState(false);
|
||||
@@ -166,7 +177,7 @@ export function CommandPreviewCard({
|
||||
setError(null);
|
||||
} catch (err) {
|
||||
if (requestId !== latestRequestId.current) return;
|
||||
setError(err instanceof Error ? err.message : "Failed to load command preview");
|
||||
setError(err instanceof Error ? err.message : t("agents:failedToLoadCommandPreview"));
|
||||
setPreview(null);
|
||||
} finally {
|
||||
if (requestId === latestRequestId.current) setLoading(false);
|
||||
@@ -182,13 +193,11 @@ export function CommandPreviewCard({
|
||||
<CardHeader>
|
||||
<CardTitle className="flex items-center gap-2">
|
||||
<IconTerminal2 className="h-5 w-5" />
|
||||
<span>Command Preview</span>
|
||||
<span>{t("agents:commandPreview")}</span>
|
||||
</CardTitle>
|
||||
</CardHeader>
|
||||
<CardContent className="space-y-4">
|
||||
<p className="text-xs text-muted-foreground">
|
||||
The CLI command that will be executed based on the current settings.
|
||||
</p>
|
||||
<p className="text-xs text-muted-foreground">{t("agents:commandPreviewHelp")}</p>
|
||||
|
||||
{loading && <CommandPreviewLoading />}
|
||||
{error && <CommandPreviewError error={error} />}
|
||||
|
||||
@@ -5,6 +5,7 @@ import {
|
||||
getAgentProfileMcpConfigAction,
|
||||
updateAgentProfileMcpConfigAction,
|
||||
} from "@/app/actions/agents";
|
||||
import { t } from "@/lib/i18n";
|
||||
import type { AgentProfileMcpConfig, McpServerDef } from "@/lib/types/http";
|
||||
|
||||
type McpStatus = "idle" | "loading" | "success" | "error";
|
||||
@@ -31,6 +32,9 @@ type UseProfileMcpConfigResult = {
|
||||
};
|
||||
|
||||
const EMPTY_EXAMPLE = '{\n "mcpServers": {}\n}';
|
||||
// The JSON key the editor validates against — an identifier, interpolated into
|
||||
// the error messages below rather than written into the catalog.
|
||||
const MCP_SERVERS_KEY = "mcpServers";
|
||||
const isEmptyExample = (value: string) => value.trim() === EMPTY_EXAMPLE.trim();
|
||||
|
||||
type McpStateSetters = {
|
||||
@@ -51,12 +55,12 @@ function serializeServers(config: AgentProfileMcpConfig | null): string {
|
||||
|
||||
function normalizeServers(value: unknown): Record<string, McpServerDef> {
|
||||
if (!value || typeof value !== "object" || Array.isArray(value)) {
|
||||
throw new Error("MCP servers config must be a JSON object");
|
||||
throw new Error(t("agents:mcpConfigMustBeJsonObject"));
|
||||
}
|
||||
if ("mcpServers" in value) {
|
||||
if (MCP_SERVERS_KEY in value) {
|
||||
const nested = (value as { mcpServers?: unknown }).mcpServers;
|
||||
if (!nested || typeof nested !== "object" || Array.isArray(nested)) {
|
||||
throw new Error("mcpServers must be a JSON object");
|
||||
throw new Error(t("agents:mcpKeyMustBeJsonObject", { key: MCP_SERVERS_KEY }));
|
||||
}
|
||||
return nested as Record<string, McpServerDef>;
|
||||
}
|
||||
@@ -113,7 +117,9 @@ function useFetchConfig(
|
||||
})
|
||||
.catch((error) => {
|
||||
if (!active) return;
|
||||
setters.setMcpError(error instanceof Error ? error.message : "Failed to load MCP config");
|
||||
setters.setMcpError(
|
||||
error instanceof Error ? error.message : t("agents:failedToLoadMcpConfig"),
|
||||
);
|
||||
setters.setMcpStatus("error");
|
||||
});
|
||||
|
||||
@@ -196,7 +202,7 @@ export function useProfileMcpConfig({
|
||||
normalizeServers(parsed);
|
||||
setMcpError(null);
|
||||
} catch {
|
||||
setMcpError("Invalid JSON");
|
||||
setMcpError(t("agents:invalidJson"));
|
||||
}
|
||||
};
|
||||
|
||||
@@ -213,7 +219,7 @@ export function useProfileMcpConfig({
|
||||
servers = normalizeServers(parsed);
|
||||
} catch (error) {
|
||||
setMcpStatus("error");
|
||||
setMcpError(error instanceof Error ? error.message : "Invalid MCP config");
|
||||
setMcpError(error instanceof Error ? error.message : t("agents:invalidMcpConfig"));
|
||||
throw error;
|
||||
}
|
||||
|
||||
|
||||
@@ -1,6 +1,7 @@
|
||||
"use client";
|
||||
|
||||
import { useCallback, useEffect, useMemo, useState } from "react";
|
||||
import { useTranslation } from "react-i18next";
|
||||
import Link from "@/components/routing/app-link";
|
||||
import {
|
||||
IconAlertTriangle,
|
||||
@@ -66,13 +67,14 @@ function CopyButton({
|
||||
copiedValue: string | null;
|
||||
onCopy: (text: string) => void;
|
||||
}) {
|
||||
const { t } = useTranslation();
|
||||
const isCopied = copiedValue === text;
|
||||
return (
|
||||
<Button
|
||||
variant="ghost"
|
||||
size="sm"
|
||||
className="h-7 w-7 p-0 cursor-pointer shrink-0"
|
||||
aria-label={isCopied ? "Copied" : "Copy install command"}
|
||||
aria-label={isCopied ? t("agents:copied") : t("agents:copyInstallCommand")}
|
||||
onClick={() => onCopy(text)}
|
||||
>
|
||||
{isCopied ? (
|
||||
@@ -123,6 +125,7 @@ function ToolInstallCard({
|
||||
copiedValue: string | null;
|
||||
onCopy: (text: string) => void;
|
||||
}) {
|
||||
const { t } = useTranslation();
|
||||
return (
|
||||
<Card className="border-dashed">
|
||||
<CardContent className="py-4 flex flex-col gap-2">
|
||||
@@ -132,7 +135,7 @@ function ToolInstallCard({
|
||||
{tool.available && (
|
||||
<span className="flex items-center gap-1 text-xs text-green-600 dark:text-green-400">
|
||||
<IconCheck className="h-3.5 w-3.5" />
|
||||
Installed
|
||||
{t("agents:installed")}
|
||||
</span>
|
||||
)}
|
||||
</div>
|
||||
@@ -211,13 +214,12 @@ function InstalledAgentsHeader({
|
||||
onOpenTuiDialog: () => void;
|
||||
onRescan: () => void;
|
||||
}) {
|
||||
const { t } = useTranslation();
|
||||
return (
|
||||
<div className="flex flex-col gap-4 sm:flex-row sm:items-start sm:justify-between">
|
||||
<div>
|
||||
<h3 className="text-lg font-semibold">Installed Agents</h3>
|
||||
<p className="text-sm text-muted-foreground">
|
||||
Agents detected on this machine are ready to configure.
|
||||
</p>
|
||||
<h3 className="text-lg font-semibold">{t("agents:installedAgents")}</h3>
|
||||
<p className="text-sm text-muted-foreground">{t("agents:installedAgentsDescription")}</p>
|
||||
</div>
|
||||
<div className="flex w-full flex-wrap gap-2 sm:w-auto">
|
||||
<Button
|
||||
@@ -228,11 +230,11 @@ function InstalledAgentsHeader({
|
||||
data-testid="open-host-shell"
|
||||
>
|
||||
<IconTerminal2 className="h-4 w-4 mr-2" />
|
||||
Terminal
|
||||
{t("agents:terminal")}
|
||||
</Button>
|
||||
<Button variant="outline" size="sm" onClick={onOpenTuiDialog} className="cursor-pointer">
|
||||
<IconPlus className="h-4 w-4 mr-2" />
|
||||
Add TUI Agent
|
||||
{t("agents:addTuiAgent")}
|
||||
</Button>
|
||||
<Button
|
||||
variant="outline"
|
||||
@@ -246,7 +248,7 @@ function InstalledAgentsHeader({
|
||||
) : (
|
||||
<IconRefresh className="h-4 w-4 mr-2" />
|
||||
)}
|
||||
Rescan
|
||||
{t("agents:rescan")}
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
@@ -268,6 +270,7 @@ function InstalledAgentsSection({
|
||||
setTuiDialogOpen,
|
||||
handleRescan,
|
||||
}: InstalledAgentsSectionProps) {
|
||||
const { t } = useTranslation();
|
||||
const [shellOpen, setShellOpen] = useState(false);
|
||||
|
||||
return (
|
||||
@@ -293,11 +296,11 @@ function InstalledAgentsSection({
|
||||
<CardContent className="py-8 text-center">
|
||||
<div className="flex items-center justify-center gap-2 text-sm text-muted-foreground">
|
||||
{discoveryLoading ? (
|
||||
<span>Scanning for installed agents...</span>
|
||||
<span>{t("agents:scanningForInstalledAgents")}</span>
|
||||
) : (
|
||||
<>
|
||||
<IconAlertTriangle className="h-4 w-4" />
|
||||
No installed agents were detected. Install one below, then click Rescan.
|
||||
{t("agents:noInstalledAgentsDetected")}
|
||||
</>
|
||||
)}
|
||||
</div>
|
||||
@@ -341,18 +344,16 @@ function SuggestInstallSection({
|
||||
installJobs: Record<string, InstallJob>;
|
||||
onInstall: (name: string) => void;
|
||||
}) {
|
||||
const notInstalledTools = tools.filter((t) => !t.available);
|
||||
const { t } = useTranslation();
|
||||
const notInstalledTools = tools.filter((tool) => !tool.available);
|
||||
if (notInstalledAgents.length === 0 && notInstalledTools.length === 0) return null;
|
||||
|
||||
return (
|
||||
<div className="space-y-4">
|
||||
<Separator />
|
||||
<div>
|
||||
<h3 className="text-lg font-semibold">Available to Install</h3>
|
||||
<p className="text-sm text-muted-foreground">
|
||||
Click Install to run the agent's install script on the kandev host. Progress streams
|
||||
live; you can start multiple installs in parallel.
|
||||
</p>
|
||||
<h3 className="text-lg font-semibold">{t("agents:availableToInstall")}</h3>
|
||||
<p className="text-sm text-muted-foreground">{t("agents:availableToInstallDescription")}</p>
|
||||
</div>
|
||||
|
||||
<div className="grid gap-4 sm:grid-cols-2 lg:grid-cols-3 xl:grid-cols-4 2xl:grid-cols-5">
|
||||
@@ -379,6 +380,7 @@ type AgentProfilesSectionProps = {
|
||||
};
|
||||
|
||||
function AgentProfilesSection({ savedAgents }: AgentProfilesSectionProps) {
|
||||
const { t } = useTranslation();
|
||||
if (!savedAgents.some((agent: Agent) => agent.profiles.length > 0)) {
|
||||
return null;
|
||||
}
|
||||
@@ -387,8 +389,8 @@ function AgentProfilesSection({ savedAgents }: AgentProfilesSectionProps) {
|
||||
<div className="space-y-4">
|
||||
<Separator />
|
||||
<div>
|
||||
<h3 className="text-lg font-semibold">Agent Profiles</h3>
|
||||
<p className="text-sm text-muted-foreground">Manage existing profiles by agent.</p>
|
||||
<h3 className="text-lg font-semibold">{t("agents:agentProfiles")}</h3>
|
||||
<p className="text-sm text-muted-foreground">{t("agents:agentProfilesDescription")}</p>
|
||||
</div>
|
||||
|
||||
<div className="space-y-2">
|
||||
@@ -586,14 +588,13 @@ export default function AgentsSettingsPage() {
|
||||
startUpdate,
|
||||
} = useAgentPageState();
|
||||
const { copiedValue, copy } = useCopyCommand();
|
||||
const { t } = useTranslation();
|
||||
|
||||
return (
|
||||
<div className="space-y-8">
|
||||
<div>
|
||||
<h2 className="text-2xl font-bold">Agents</h2>
|
||||
<p className="text-sm text-muted-foreground mt-1">
|
||||
Discover installed agents, install new ones, and manage their profiles.
|
||||
</p>
|
||||
<h2 className="text-2xl font-bold">{t("common:agents")}</h2>
|
||||
<p className="text-sm text-muted-foreground mt-1">{t("agents:pageDescription")}</p>
|
||||
</div>
|
||||
|
||||
<Separator />
|
||||
|
||||
@@ -1,5 +1,6 @@
|
||||
"use client";
|
||||
|
||||
import { useTranslation } from "react-i18next";
|
||||
import { IconRobot } from "@tabler/icons-react";
|
||||
import { AgentLogo } from "@/components/agent-logo";
|
||||
import { useAppStore } from "@/components/state-provider";
|
||||
@@ -15,12 +16,13 @@ type AgentsGroupProps = {
|
||||
};
|
||||
|
||||
export function AgentsGroup({ pathname, expanded, onToggle }: AgentsGroupProps) {
|
||||
const { t } = useTranslation();
|
||||
const agents = useAppStore((s) => s.settingsAgents.items);
|
||||
useAvailableAgents();
|
||||
|
||||
return (
|
||||
<SettingsGroup
|
||||
label="Agents"
|
||||
label={t("common:agents")}
|
||||
icon={IconRobot}
|
||||
href={ROOT_HREF}
|
||||
isActive={pathname === ROOT_HREF}
|
||||
|
||||
@@ -1,6 +1,7 @@
|
||||
"use client";
|
||||
|
||||
import { memo, useEffect, useRef, useState } from "react";
|
||||
import { useTranslation } from "react-i18next";
|
||||
import { IconCheck, IconChevronDown, IconChevronLeft, IconChevronRight } from "@tabler/icons-react";
|
||||
|
||||
import { cn } from "@/lib/utils";
|
||||
@@ -246,11 +247,12 @@ function ConfigOptionSubSelector({
|
||||
onBack: () => void;
|
||||
onChange?: (configId: string, value: string) => void;
|
||||
}) {
|
||||
const { t } = useTranslation();
|
||||
return (
|
||||
<div className="flex min-h-0 flex-col gap-2">
|
||||
<button
|
||||
type="button"
|
||||
aria-label={`Back to model settings from ${option.name}`}
|
||||
aria-label={t("agents:backToModelSettings", { name: option.name })}
|
||||
autoFocus
|
||||
className="flex min-h-9 w-full cursor-pointer items-center gap-2 rounded-md px-2 text-left text-xs/relaxed hover:bg-muted focus-visible:ring-2 focus-visible:ring-ring/35 focus-visible:outline-none"
|
||||
onClick={onBack}
|
||||
@@ -336,6 +338,7 @@ function ModelConfigSelectorContent({
|
||||
onConfigBack,
|
||||
onConfigChange,
|
||||
}: ModelConfigSelectorContentProps) {
|
||||
const { t } = useTranslation();
|
||||
const pendingFocusConfigId = useRef<string | null>(null);
|
||||
const triggerRefs = useRef<Record<string, HTMLButtonElement | null>>({});
|
||||
const showModelFilter = modelOptions.length > 5;
|
||||
@@ -368,10 +371,10 @@ function ModelConfigSelectorContent({
|
||||
return (
|
||||
<>
|
||||
<Command>
|
||||
{showModelFilter && <CommandInput placeholder="Filter models..." className="h-8" />}
|
||||
{showModelFilter && <CommandInput placeholder={t("agents:filterModels")} className="h-8" />}
|
||||
<CommandList className="max-h-60">
|
||||
<CommandEmpty>No models found.</CommandEmpty>
|
||||
<CommandGroup heading="Model">
|
||||
<CommandEmpty>{t("agents:noModelsFound")}</CommandEmpty>
|
||||
<CommandGroup heading={t("agents:modelHeading")}>
|
||||
{modelOptions.map((model) => (
|
||||
<ModelRow
|
||||
key={model.id}
|
||||
|
||||
@@ -0,0 +1,72 @@
|
||||
import { afterEach, describe, expect, it, vi } from "vitest";
|
||||
import { cleanup, fireEvent, render, screen, waitFor } from "@testing-library/react";
|
||||
import { AddTUIAgentDialog } from "./add-tui-agent-dialog";
|
||||
|
||||
afterEach(cleanup);
|
||||
|
||||
function renderDialog(onSubmit = vi.fn()) {
|
||||
return render(<AddTUIAgentDialog open onOpenChange={vi.fn()} onSubmit={onSubmit} />);
|
||||
}
|
||||
|
||||
describe("AddTUIAgentDialog", () => {
|
||||
// The command help is a <Trans>: its <0> index addresses the JSX children
|
||||
// positionally, so a prettier reflow can silently reassemble the sentence
|
||||
// into fragments without failing anything.
|
||||
it("renders the command help as one sentence with the {{model}} token intact", () => {
|
||||
renderDialog();
|
||||
|
||||
const hint = screen.getByText(
|
||||
(_content, element) =>
|
||||
element?.tagName === "P" &&
|
||||
element.textContent ===
|
||||
"Binary name looked up on PATH. Use {{model}} to insert the model value.",
|
||||
);
|
||||
expect(hint.querySelector("code")?.textContent).toBe("{{model}}");
|
||||
});
|
||||
|
||||
// `{{model}}` is a substitution token the user types verbatim. It is passed
|
||||
// through `t()` as an interpolation *value*, so i18next must not treat it as
|
||||
// a placeholder of its own and blank it out.
|
||||
it("keeps the {{model}} token in the command placeholder", () => {
|
||||
renderDialog();
|
||||
|
||||
expect(screen.getByLabelText("Command").getAttribute("placeholder")).toBe(
|
||||
"e.g. superclaude --yolo --model {{model}}",
|
||||
);
|
||||
});
|
||||
|
||||
it("rejects an empty display name without calling onSubmit", () => {
|
||||
const onSubmit = vi.fn();
|
||||
renderDialog(onSubmit);
|
||||
|
||||
fireEvent.click(screen.getByText("Create"));
|
||||
|
||||
expect(screen.getByText("Display name is required")).toBeTruthy();
|
||||
expect(onSubmit).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it("rejects an empty command without calling onSubmit", () => {
|
||||
const onSubmit = vi.fn();
|
||||
renderDialog(onSubmit);
|
||||
|
||||
fireEvent.change(screen.getByLabelText("Display Name"), { target: { value: "superclaude" } });
|
||||
fireEvent.click(screen.getByText("Create"));
|
||||
|
||||
expect(screen.getByText("Command is required")).toBeTruthy();
|
||||
expect(onSubmit).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
// A non-Error rejection has no `.message`, so the handler falls back to the
|
||||
// catalog string. Nothing else exercises that branch.
|
||||
it("surfaces the translated fallback when onSubmit rejects with a non-Error", async () => {
|
||||
const onSubmit = vi.fn().mockRejectedValue("boom");
|
||||
renderDialog(onSubmit);
|
||||
|
||||
fireEvent.change(screen.getByLabelText("Display Name"), { target: { value: "superclaude" } });
|
||||
fireEvent.change(screen.getByLabelText("Command"), { target: { value: "superclaude" } });
|
||||
fireEvent.click(screen.getByText("Create"));
|
||||
|
||||
await waitFor(() => expect(screen.getByText("Failed to create agent")).toBeTruthy());
|
||||
expect(onSubmit).toHaveBeenCalledOnce();
|
||||
});
|
||||
});
|
||||
@@ -1,6 +1,8 @@
|
||||
"use client";
|
||||
|
||||
import { useState } from "react";
|
||||
import { Trans, useTranslation } from "react-i18next";
|
||||
import { t as translate } from "@/lib/i18n";
|
||||
import { Button } from "@kandev/ui/button";
|
||||
import {
|
||||
Dialog,
|
||||
@@ -15,6 +17,11 @@ import { Label } from "@kandev/ui/label";
|
||||
|
||||
type TUIAgentFormData = { display_name: string; model?: string; command: string };
|
||||
|
||||
// The placeholder kandev substitutes into the TUI command. The user types it
|
||||
// verbatim, so it is interpolated as a value rather than written into the
|
||||
// catalog. Same for the example command and model names.
|
||||
const MODEL_TOKEN = "{{model}}";
|
||||
|
||||
type AddTUIAgentDialogProps = {
|
||||
open: boolean;
|
||||
onOpenChange: (open: boolean) => void;
|
||||
@@ -44,11 +51,11 @@ function useDialogHandlers({
|
||||
}: DialogHandlersParams) {
|
||||
const handleSubmit = async () => {
|
||||
if (!displayName.trim()) {
|
||||
setError("Display name is required");
|
||||
setError(translate("agents:displayNameRequired"));
|
||||
return;
|
||||
}
|
||||
if (!command.trim()) {
|
||||
setError("Command is required");
|
||||
setError(translate("agents:commandRequired"));
|
||||
return;
|
||||
}
|
||||
setError(null);
|
||||
@@ -62,7 +69,7 @@ function useDialogHandlers({
|
||||
reset();
|
||||
onOpenChange(false);
|
||||
} catch (err) {
|
||||
setError(err instanceof Error ? err.message : "Failed to create agent");
|
||||
setError(err instanceof Error ? err.message : translate("agents:failedToCreateAgent"));
|
||||
} finally {
|
||||
setLoading(false);
|
||||
}
|
||||
@@ -77,6 +84,7 @@ function useDialogHandlers({
|
||||
}
|
||||
|
||||
export function AddTUIAgentDialog({ open, onOpenChange, onSubmit }: AddTUIAgentDialogProps) {
|
||||
const { t } = useTranslation();
|
||||
const [displayName, setDisplayName] = useState("");
|
||||
const [model, setModel] = useState("");
|
||||
const [command, setCommand] = useState("");
|
||||
@@ -106,56 +114,53 @@ export function AddTUIAgentDialog({ open, onOpenChange, onSubmit }: AddTUIAgentD
|
||||
<Dialog open={open} onOpenChange={handleOpenChange}>
|
||||
<DialogContent className="sm:max-w-md">
|
||||
<DialogHeader>
|
||||
<DialogTitle>Add TUI Agent</DialogTitle>
|
||||
<DialogDescription>
|
||||
Register a CLI tool as a TUI agent. It will appear in the agent list with a default
|
||||
passthrough profile.
|
||||
</DialogDescription>
|
||||
<DialogTitle>{t("agents:addTuiAgent")}</DialogTitle>
|
||||
<DialogDescription>{t("agents:addTuiAgentDescription")}</DialogDescription>
|
||||
</DialogHeader>
|
||||
<div className="space-y-4">
|
||||
<div className="space-y-2">
|
||||
<Label htmlFor="tui-display-name">Display Name</Label>
|
||||
<Label htmlFor="tui-display-name">{t("agents:displayName")}</Label>
|
||||
<Input
|
||||
id="tui-display-name"
|
||||
placeholder="e.g. superclaude"
|
||||
placeholder={t("agents:exampleValue", { example: "superclaude" })}
|
||||
value={displayName}
|
||||
onChange={(e) => setDisplayName(e.target.value)}
|
||||
/>
|
||||
</div>
|
||||
<div className="space-y-2">
|
||||
<Label htmlFor="tui-model">Model</Label>
|
||||
<Label htmlFor="tui-model">{t("agents:model")}</Label>
|
||||
<Input
|
||||
id="tui-model"
|
||||
placeholder="e.g. best"
|
||||
placeholder={t("agents:exampleValue", { example: "best" })}
|
||||
value={model}
|
||||
onChange={(e) => setModel(e.target.value)}
|
||||
/>
|
||||
<p className="text-xs text-muted-foreground">
|
||||
Profile label shown in the agent selector dropdown
|
||||
</p>
|
||||
<p className="text-xs text-muted-foreground">{t("agents:tuiModelHelp")}</p>
|
||||
</div>
|
||||
<div className="space-y-2">
|
||||
<Label htmlFor="tui-command">Command</Label>
|
||||
<Label htmlFor="tui-command">{t("agents:command")}</Label>
|
||||
<Input
|
||||
id="tui-command"
|
||||
placeholder="e.g. superclaude --yolo --model {{model}}"
|
||||
placeholder={t("agents:exampleValue", {
|
||||
example: `superclaude --yolo --model ${MODEL_TOKEN}`,
|
||||
})}
|
||||
value={command}
|
||||
onChange={(e) => setCommand(e.target.value)}
|
||||
/>
|
||||
<p className="text-xs text-muted-foreground">
|
||||
Binary name looked up on PATH. Use{" "}
|
||||
<code className="rounded bg-muted px-1 py-0.5">{"{{model}}"}</code> to insert the
|
||||
model value.
|
||||
<Trans i18nKey="agents:tuiCommandHelp" values={{ token: MODEL_TOKEN }}>
|
||||
<code className="rounded bg-muted px-1 py-0.5" />
|
||||
</Trans>
|
||||
</p>
|
||||
</div>
|
||||
{error && <p className="text-sm text-destructive">{error}</p>}
|
||||
</div>
|
||||
<DialogFooter>
|
||||
<Button variant="outline" onClick={() => onOpenChange(false)} className="cursor-pointer">
|
||||
Cancel
|
||||
{t("common:cancel")}
|
||||
</Button>
|
||||
<Button onClick={handleSubmit} disabled={loading} className="cursor-pointer">
|
||||
{loading ? "Creating..." : "Create"}
|
||||
{loading ? t("agents:creating") : t("agents:create")}
|
||||
</Button>
|
||||
</DialogFooter>
|
||||
</DialogContent>
|
||||
|
||||
@@ -1,6 +1,7 @@
|
||||
"use client";
|
||||
|
||||
import { useCallback } from "react";
|
||||
import { useTranslation } from "react-i18next";
|
||||
import { startAgentLogin } from "@/lib/api";
|
||||
import { PtyTerminalDialog, type StartPtySession } from "@/components/settings/pty-terminal-dialog";
|
||||
|
||||
@@ -30,6 +31,7 @@ export function AgentLoginDialog({
|
||||
command,
|
||||
onLoginSuccess,
|
||||
}: Props) {
|
||||
const { t } = useTranslation();
|
||||
const startSession: StartPtySession = useCallback(
|
||||
(size, options) => startAgentLogin(agentName, size, options),
|
||||
[agentName],
|
||||
@@ -39,7 +41,7 @@ export function AgentLoginDialog({
|
||||
<PtyTerminalDialog
|
||||
open={open}
|
||||
onOpenChange={onOpenChange}
|
||||
title={`Sign in: ${agentName}`}
|
||||
title={t("agents:signInToAgent", { name: agentName })}
|
||||
description={description}
|
||||
command={command}
|
||||
testIdPrefix="agent-login"
|
||||
|
||||
@@ -119,6 +119,31 @@ describe("AgentProfileDeleteConflictDialog", () => {
|
||||
expect(screen.queryByText(/Delete Anyway/)).toBeNull();
|
||||
});
|
||||
|
||||
// The watcher `kind` is the wire enum and its label is a catalog key, so a
|
||||
// renamed key or a dropped entry must fail here rather than silently render
|
||||
// the raw enum value to the user.
|
||||
it("resolves every known watcher kind to its label, and echoes an unknown one", () => {
|
||||
renderConflictDialog({
|
||||
activeSessions: [],
|
||||
watchers: [
|
||||
{ id: "w1", kind: "linear", label: "team ENG" },
|
||||
{ id: "w2", kind: "jira", label: "project = ENG" },
|
||||
{ id: "w3", kind: "github_issue", label: "kdlbs/kandev" },
|
||||
{ id: "w4", kind: "github_review", label: "kdlbs/kandev PRs" },
|
||||
// A kind the frontend does not know yet — the raw value is echoed
|
||||
// rather than rendering an empty label.
|
||||
{ id: "w5", kind: "sentry" as never, label: "proj/backend" },
|
||||
],
|
||||
routingTiers: [],
|
||||
});
|
||||
|
||||
expect(screen.getByText(/Linear:/)).toBeTruthy();
|
||||
expect(screen.getByText(/Jira:/)).toBeTruthy();
|
||||
expect(screen.getByText(/GitHub Issues:/)).toBeTruthy();
|
||||
expect(screen.getByText(/GitHub PR Reviews:/)).toBeTruthy();
|
||||
expect(screen.getByText(/sentry:/)).toBeTruthy();
|
||||
});
|
||||
|
||||
it("does not render the dialog when conflict is null", () => {
|
||||
renderConflictDialog(null);
|
||||
|
||||
|
||||
@@ -1,5 +1,7 @@
|
||||
"use client";
|
||||
|
||||
import { Trans, useTranslation } from "react-i18next";
|
||||
import type { TFunction } from "i18next";
|
||||
import {
|
||||
AlertDialog,
|
||||
AlertDialogAction,
|
||||
@@ -17,11 +19,13 @@ import type {
|
||||
WatcherReference,
|
||||
} from "@/lib/types/agent-profile-errors";
|
||||
|
||||
const WATCHER_KIND_LABELS: Record<WatcherReference["kind"], string> = {
|
||||
linear: "Linear",
|
||||
jira: "Jira",
|
||||
github_issue: "GitHub Issues",
|
||||
github_review: "GitHub PR Reviews",
|
||||
// The watcher `kind` values are the wire enum and are never translated; only
|
||||
// their labels are copy, so they travel as catalog keys and resolve at render.
|
||||
const WATCHER_KIND_LABEL_KEYS: Record<WatcherReference["kind"], string> = {
|
||||
linear: "agents:watcherKindLinear",
|
||||
jira: "agents:watcherKindJira",
|
||||
github_issue: "agents:watcherKindGithubIssue",
|
||||
github_review: "agents:watcherKindGithubReview",
|
||||
};
|
||||
|
||||
type AgentProfileDeleteConfirmDialogProps = {
|
||||
@@ -35,22 +39,23 @@ export function AgentProfileDeleteConfirmDialog({
|
||||
onOpenChange,
|
||||
onConfirm,
|
||||
}: AgentProfileDeleteConfirmDialogProps) {
|
||||
const { t } = useTranslation();
|
||||
return (
|
||||
<AlertDialog open={open} onOpenChange={onOpenChange}>
|
||||
<AlertDialogContent>
|
||||
<AlertDialogHeader>
|
||||
<AlertDialogTitle>Delete agent profile?</AlertDialogTitle>
|
||||
<AlertDialogTitle>{t("agents:deleteAgentProfileTitle")}</AlertDialogTitle>
|
||||
<AlertDialogDescription>
|
||||
This will permanently delete this profile. This action cannot be undone.
|
||||
{t("agents:deleteAgentProfileDescription")}
|
||||
</AlertDialogDescription>
|
||||
</AlertDialogHeader>
|
||||
<AlertDialogFooter>
|
||||
<AlertDialogCancel className="cursor-pointer">Cancel</AlertDialogCancel>
|
||||
<AlertDialogCancel className="cursor-pointer">{t("common:cancel")}</AlertDialogCancel>
|
||||
<AlertDialogAction
|
||||
onClick={onConfirm}
|
||||
className="cursor-pointer bg-destructive text-destructive-foreground hover:bg-destructive/90"
|
||||
>
|
||||
Delete
|
||||
{t("agents:delete")}
|
||||
</AlertDialogAction>
|
||||
</AlertDialogFooter>
|
||||
</AlertDialogContent>
|
||||
@@ -78,6 +83,7 @@ export function AgentProfileDeleteConflictDialog({
|
||||
onOpenChange,
|
||||
onConfirm,
|
||||
}: AgentProfileDeleteConflictDialogProps) {
|
||||
const { t } = useTranslation();
|
||||
const tasks = conflict?.activeSessions.filter((s) => !s.is_ephemeral) ?? [];
|
||||
const quickChats = conflict?.activeSessions.filter((s) => s.is_ephemeral) ?? [];
|
||||
const watchers = conflict?.watchers ?? [];
|
||||
@@ -92,16 +98,22 @@ export function AgentProfileDeleteConflictDialog({
|
||||
<AlertDialogContent>
|
||||
<AlertDialogHeader>
|
||||
<AlertDialogTitle>
|
||||
{hasHardBlockers ? "Cannot delete agent profile" : "Delete agent profile?"}
|
||||
{hasHardBlockers
|
||||
? t("agents:cannotDeleteAgentProfile")
|
||||
: t("agents:deleteAgentProfileTitle")}
|
||||
</AlertDialogTitle>
|
||||
<AlertDialogDescription asChild>
|
||||
<div>
|
||||
<p>This profile is currently in use. Deleting it will affect the following:</p>
|
||||
<SessionConflictSection title="Tasks:" sessions={tasks} fallback="Untitled task" />
|
||||
<p>{t("agents:profileInUseIntro")}</p>
|
||||
<SessionConflictSection
|
||||
title="Quick Chats:"
|
||||
title={t("agents:conflictTasksTitle")}
|
||||
sessions={tasks}
|
||||
fallback={t("agents:untitledTask")}
|
||||
/>
|
||||
<SessionConflictSection
|
||||
title={t("agents:conflictQuickChatsTitle")}
|
||||
sessions={quickChats}
|
||||
fallback="Untitled quick chat"
|
||||
fallback={t("agents:untitledQuickChat")}
|
||||
/>
|
||||
<WatcherConflictSection watchersByKind={watchersByKind} />
|
||||
<RoutingTierConflictSection
|
||||
@@ -110,26 +122,21 @@ export function AgentProfileDeleteConflictDialog({
|
||||
providerLabels={new Map(providers.map((p) => [p.id, p.name]))}
|
||||
/>
|
||||
{hasHardBlockers ? (
|
||||
<p className="mt-2">
|
||||
Change these workspace tier mappings before deleting this profile.
|
||||
</p>
|
||||
<p className="mt-2">{t("agents:changeTierMappingsFirst")}</p>
|
||||
) : (
|
||||
<p className="mt-2">
|
||||
These sessions will no longer be able to use this profile and the listed watchers
|
||||
will be disabled. This action cannot be undone.
|
||||
</p>
|
||||
<p className="mt-2">{t("agents:deleteAnywayConsequences")}</p>
|
||||
)}
|
||||
</div>
|
||||
</AlertDialogDescription>
|
||||
</AlertDialogHeader>
|
||||
<AlertDialogFooter>
|
||||
<AlertDialogCancel className="cursor-pointer">Cancel</AlertDialogCancel>
|
||||
<AlertDialogCancel className="cursor-pointer">{t("common:cancel")}</AlertDialogCancel>
|
||||
{hasHardBlockers ? null : (
|
||||
<AlertDialogAction
|
||||
onClick={onConfirm}
|
||||
className="cursor-pointer bg-destructive text-destructive-foreground hover:bg-destructive/90"
|
||||
>
|
||||
Delete Anyway
|
||||
{t("agents:deleteAnyway")}
|
||||
</AlertDialogAction>
|
||||
)}
|
||||
</AlertDialogFooter>
|
||||
@@ -167,16 +174,17 @@ function WatcherConflictSection({
|
||||
}: {
|
||||
watchersByKind: Record<string, WatcherReference[]>;
|
||||
}) {
|
||||
const { t } = useTranslation();
|
||||
const entries = Object.entries(watchersByKind);
|
||||
if (entries.length === 0) return null;
|
||||
return (
|
||||
<div className="mt-2">
|
||||
<p className="font-medium text-sm">Watchers (will be disabled):</p>
|
||||
<p className="font-medium text-sm">{t("agents:conflictWatchersTitle")}</p>
|
||||
<ul className="list-disc list-inside mt-1 space-y-0.5">
|
||||
{entries.map(([kind, items]) => (
|
||||
<li key={kind} className="text-sm">
|
||||
<span className="font-medium">
|
||||
{WATCHER_KIND_LABELS[kind as WatcherReference["kind"]] ?? kind}:
|
||||
{watcherKindLabel(t, kind as WatcherReference["kind"])}:
|
||||
</span>{" "}
|
||||
{items.map((w) => w.label || w.id).join(", ")}
|
||||
</li>
|
||||
@@ -195,16 +203,24 @@ function RoutingTierConflictSection({
|
||||
workspaceLabels: Map<string, string>;
|
||||
providerLabels: Map<string, string>;
|
||||
}) {
|
||||
const { t } = useTranslation();
|
||||
if (routingTiers.length === 0) return null;
|
||||
return (
|
||||
<div className="mt-2">
|
||||
<p className="font-medium text-sm">Workspace tier mappings:</p>
|
||||
<p className="font-medium text-sm">{t("agents:conflictTierMappingsTitle")}</p>
|
||||
<ul className="list-disc list-inside mt-1 space-y-0.5">
|
||||
{routingTiers.map((ref) => (
|
||||
<li key={`${ref.workspace_id}-${ref.provider_id}-${ref.tier}`} className="text-sm">
|
||||
<span className="font-medium">{formatRoutingTier(ref.tier)}</span> tier in{" "}
|
||||
{formatLookupLabel(workspaceLabels, ref.workspace_id)} for{" "}
|
||||
{formatLookupLabel(providerLabels, ref.provider_id)}
|
||||
<Trans
|
||||
i18nKey="agents:conflictTierMappingRow"
|
||||
values={{
|
||||
tier: formatRoutingTier(ref.tier),
|
||||
workspace: formatLookupLabel(workspaceLabels, ref.workspace_id),
|
||||
provider: formatLookupLabel(providerLabels, ref.provider_id),
|
||||
}}
|
||||
>
|
||||
<span className="font-medium" />
|
||||
</Trans>
|
||||
</li>
|
||||
))}
|
||||
</ul>
|
||||
@@ -212,6 +228,19 @@ function RoutingTierConflictSection({
|
||||
);
|
||||
}
|
||||
|
||||
/**
|
||||
* The watcher `kind` is the wire enum; only its label is copy. An unknown kind
|
||||
* echoes the raw value rather than inventing one.
|
||||
*/
|
||||
function watcherKindLabel(t: TFunction, kind: WatcherReference["kind"]): string {
|
||||
const key = WATCHER_KIND_LABEL_KEYS[kind];
|
||||
return key ? t(key) : kind;
|
||||
}
|
||||
|
||||
/**
|
||||
* `tier` is a routing wire value (`fast`, `heavy`, …), not copy — it is
|
||||
* title-cased for display and never translated.
|
||||
*/
|
||||
function formatRoutingTier(tier: string): string {
|
||||
return tier.charAt(0).toUpperCase() + tier.slice(1);
|
||||
}
|
||||
|
||||
@@ -0,0 +1,229 @@
|
||||
"use client";
|
||||
|
||||
/**
|
||||
* Draft state, save/delete actions and store synchronisation for the agent
|
||||
* profile editor. Split out of `agent-profile-page.tsx` so that file stays
|
||||
* presentation-only and under the 600-line cap.
|
||||
*
|
||||
* This module holds no JSX, so `i18next/no-literal-string` never inspects it —
|
||||
* its toast copy is guarded only by the pseudo-locale.
|
||||
*/
|
||||
|
||||
import { useMemo, useState } from "react";
|
||||
import { areCLIFlagsEqual } from "@/lib/cli-flags";
|
||||
import { areConfigOptionsEqual } from "@/lib/config-options";
|
||||
import { arePermissionsDirty, permissionsToProfilePatch } from "@/lib/agent-permissions";
|
||||
import { deleteAgentProfileAction, updateAgentProfileAction } from "@/app/actions/agents";
|
||||
import { useAppStore } from "@/components/state-provider";
|
||||
import type { useToast } from "@/components/toast-provider";
|
||||
import type { AgentProfileDeleteConflict } from "@/components/settings/agent-profile-delete-dialog";
|
||||
import { areEnvVarsEqual } from "@/components/settings/profile-edit/profile-env-vars-section";
|
||||
import { t as translate } from "@/lib/i18n";
|
||||
import type { Agent, AgentProfile, PermissionSetting } from "@/lib/types/http";
|
||||
|
||||
export type SaveStatus = "idle" | "loading" | "success" | "error";
|
||||
|
||||
export function useSyncAgentsToStore() {
|
||||
const setSettingsAgents = useAppStore((state) => state.setSettingsAgents);
|
||||
const setAgentProfiles = useAppStore((state) => state.setAgentProfiles);
|
||||
return (nextAgents: Agent[]) => {
|
||||
setSettingsAgents(nextAgents);
|
||||
setAgentProfiles(
|
||||
nextAgents.flatMap((agentItem) =>
|
||||
agentItem.profiles.map((agentProfile) => ({
|
||||
id: agentProfile.id,
|
||||
label: `${agentProfile.agentDisplayName ?? ""} • ${agentProfile.name}`,
|
||||
agent_id: agentItem.id,
|
||||
agent_name: agentItem.name,
|
||||
cli_passthrough: agentProfile.cliPassthrough ?? false,
|
||||
})),
|
||||
),
|
||||
);
|
||||
};
|
||||
}
|
||||
|
||||
export function useProfileEditorState(
|
||||
profile: AgentProfile,
|
||||
permissionSettings: Record<string, PermissionSetting>,
|
||||
) {
|
||||
const [draft, setDraft] = useState<AgentProfile>({ ...profile });
|
||||
const [savedProfile, setSavedProfile] = useState<AgentProfile>(profile);
|
||||
const [saveStatus, setSaveStatus] = useState<"idle" | "loading" | "success" | "error">("idle");
|
||||
|
||||
const isDirty = useMemo(
|
||||
() =>
|
||||
draft.name !== savedProfile.name ||
|
||||
draft.model !== savedProfile.model ||
|
||||
(draft.mode ?? "") !== (savedProfile.mode ?? "") ||
|
||||
!areConfigOptionsEqual(draft.configOptions, savedProfile.configOptions) ||
|
||||
arePermissionsDirty(draft, savedProfile, permissionSettings) ||
|
||||
draft.cliPassthrough !== savedProfile.cliPassthrough ||
|
||||
!areCLIFlagsEqual(draft.cliFlags ?? [], savedProfile.cliFlags ?? []) ||
|
||||
(draft.commandPrefix ?? "") !== (savedProfile.commandPrefix ?? "") ||
|
||||
!areEnvVarsEqual(draft.envVars, savedProfile.envVars),
|
||||
[draft, savedProfile, permissionSettings],
|
||||
);
|
||||
|
||||
return { draft, setDraft, savedProfile, setSavedProfile, saveStatus, setSaveStatus, isDirty };
|
||||
}
|
||||
|
||||
export function errorMessage(error: unknown): string {
|
||||
return error instanceof Error ? error.message : translate("agents:requestFailed");
|
||||
}
|
||||
|
||||
type ProfileEditorActionsOptions = {
|
||||
agent: Agent;
|
||||
draft: AgentProfile;
|
||||
setSavedProfile: (p: AgentProfile) => void;
|
||||
setDraft: React.Dispatch<React.SetStateAction<AgentProfile>>;
|
||||
setSaveStatus: (s: SaveStatus) => void;
|
||||
settingsAgents: Agent[];
|
||||
syncAgentsToStore: (agents: Agent[]) => void;
|
||||
toast: ReturnType<typeof useToast>["toast"];
|
||||
};
|
||||
|
||||
export function useProfileSave({
|
||||
agent,
|
||||
draft,
|
||||
setSavedProfile,
|
||||
setDraft,
|
||||
setSaveStatus,
|
||||
settingsAgents,
|
||||
syncAgentsToStore,
|
||||
toast,
|
||||
}: ProfileEditorActionsOptions) {
|
||||
return async () => {
|
||||
if (!draft.name.trim()) {
|
||||
toast({
|
||||
title: translate("agents:profileNameRequiredTitle"),
|
||||
description: translate("agents:profileNameRequiredDescription"),
|
||||
variant: "error",
|
||||
});
|
||||
return;
|
||||
}
|
||||
// Model is optional — an empty profile model means "use the agent's
|
||||
// default", which is applied through ACP session model selection at session start.
|
||||
setSaveStatus("loading");
|
||||
try {
|
||||
const updated = await updateAgentProfileAction(draft.id, {
|
||||
name: draft.name,
|
||||
model: draft.model,
|
||||
mode: draft.mode,
|
||||
config_options: draft.configOptions ?? {},
|
||||
...permissionsToProfilePatch(draft),
|
||||
cli_passthrough: draft.cliPassthrough,
|
||||
cli_flags: draft.cliFlags,
|
||||
command_prefix: draft.commandPrefix ?? "",
|
||||
env_vars: draft.envVars ?? [],
|
||||
});
|
||||
setSavedProfile(updated);
|
||||
setDraft((current) => preserveNewerProfileDraft(current, draft, updated));
|
||||
const nextAgents = settingsAgents.map((agentItem: Agent) =>
|
||||
agentItem.id === agent.id
|
||||
? {
|
||||
...agentItem,
|
||||
profiles: agentItem.profiles.map((p: AgentProfile) =>
|
||||
p.id === updated.id ? updated : p,
|
||||
),
|
||||
}
|
||||
: agentItem,
|
||||
);
|
||||
syncAgentsToStore(nextAgents);
|
||||
setSaveStatus("success");
|
||||
} catch (error) {
|
||||
setSaveStatus("error");
|
||||
toast({
|
||||
title: translate("agents:failedToSaveProfile"),
|
||||
description: errorMessage(error),
|
||||
variant: "error",
|
||||
});
|
||||
throw error;
|
||||
}
|
||||
};
|
||||
}
|
||||
|
||||
export function preserveNewerProfileDraft(
|
||||
current: AgentProfile,
|
||||
submitted: AgentProfile,
|
||||
saved: AgentProfile,
|
||||
): AgentProfile {
|
||||
return current === submitted ? saved : current;
|
||||
}
|
||||
|
||||
export function useProfileDelete(
|
||||
agent: Agent,
|
||||
draft: AgentProfile,
|
||||
settingsAgents: Agent[],
|
||||
syncAgentsToStore: (agents: Agent[]) => void,
|
||||
toast: ReturnType<typeof useToast>["toast"],
|
||||
) {
|
||||
const [showDeleteConfirm, setShowDeleteConfirm] = useState(false);
|
||||
const [conflict, setConflict] = useState<AgentProfileDeleteConflict | null>(null);
|
||||
|
||||
const removeProfileFromStore = () => {
|
||||
const nextAgents = settingsAgents.map((agentItem: Agent) =>
|
||||
agentItem.id === agent.id
|
||||
? {
|
||||
...agentItem,
|
||||
profiles: agentItem.profiles.filter((p: AgentProfile) => p.id !== draft.id),
|
||||
}
|
||||
: agentItem,
|
||||
);
|
||||
syncAgentsToStore(nextAgents);
|
||||
window.location.assign("/settings/agents");
|
||||
};
|
||||
|
||||
const requestDelete = () => {
|
||||
setShowDeleteConfirm(true);
|
||||
};
|
||||
|
||||
const handleDeleteProfile = async () => {
|
||||
setShowDeleteConfirm(false);
|
||||
const result = await deleteAgentProfileAction(draft.id);
|
||||
if (result.status === "ok") {
|
||||
removeProfileFromStore();
|
||||
} else if (result.status === "conflict") {
|
||||
setConflict({
|
||||
activeSessions: result.activeSessions,
|
||||
watchers: result.watchers,
|
||||
routingTiers: result.routingTiers,
|
||||
});
|
||||
} else {
|
||||
toast({
|
||||
title: translate("agents:failedToDeleteProfile"),
|
||||
description: result.message,
|
||||
variant: "error",
|
||||
});
|
||||
}
|
||||
};
|
||||
|
||||
const handleForceDelete = async () => {
|
||||
const result = await deleteAgentProfileAction(draft.id, true);
|
||||
setConflict(null);
|
||||
if (result.status === "ok") {
|
||||
removeProfileFromStore();
|
||||
} else if (result.status === "conflict") {
|
||||
setConflict({
|
||||
activeSessions: result.activeSessions,
|
||||
watchers: result.watchers,
|
||||
routingTiers: result.routingTiers,
|
||||
});
|
||||
} else if (result.status === "error") {
|
||||
toast({
|
||||
title: translate("agents:failedToDeleteProfile"),
|
||||
description: result.message,
|
||||
variant: "error",
|
||||
});
|
||||
}
|
||||
};
|
||||
|
||||
return {
|
||||
requestDelete,
|
||||
showDeleteConfirm,
|
||||
setShowDeleteConfirm,
|
||||
handleDeleteProfile,
|
||||
conflict,
|
||||
setConflict,
|
||||
handleForceDelete,
|
||||
};
|
||||
}
|
||||
@@ -1,11 +1,10 @@
|
||||
"use client";
|
||||
|
||||
import { useCallback, useMemo, useState } from "react";
|
||||
import { useCallback } from "react";
|
||||
import { useTranslation } from "react-i18next";
|
||||
import Link from "@/components/routing/app-link";
|
||||
import { useParams } from "@/lib/routing/client-router";
|
||||
import { IconTrash } from "@tabler/icons-react";
|
||||
import { areCLIFlagsEqual } from "@/lib/cli-flags";
|
||||
import { areConfigOptionsEqual } from "@/lib/config-options";
|
||||
import { Badge } from "@kandev/ui/badge";
|
||||
import { Button } from "@kandev/ui/button";
|
||||
import { Card, CardContent, CardHeader, CardTitle } from "@kandev/ui/card";
|
||||
@@ -14,13 +13,8 @@ import { useToast } from "@/components/toast-provider";
|
||||
import { useSettingsSaveContributor } from "@/components/settings/settings-save-provider";
|
||||
import { SettingsCard } from "@/components/settings/settings-card";
|
||||
import { ProfileFormFields, type ProfileFormData } from "@/components/settings/profile-form-fields";
|
||||
import {
|
||||
arePermissionsDirty,
|
||||
permissionsToProfilePatch,
|
||||
profilePermissionValues,
|
||||
} from "@/lib/agent-permissions";
|
||||
import { profilePermissionValues } from "@/lib/agent-permissions";
|
||||
import { toAgentProfilePatch } from "@/app/settings/agents/[agentId]/agent-save-helpers";
|
||||
import { deleteAgentProfileAction, updateAgentProfileAction } from "@/app/actions/agents";
|
||||
import {
|
||||
AgentProfileDeleteConfirmDialog,
|
||||
AgentProfileDeleteConflictDialog,
|
||||
@@ -30,12 +24,20 @@ import {
|
||||
ProfileEnvVarsSection,
|
||||
areEnvVarsEqual,
|
||||
} from "@/components/settings/profile-edit/profile-env-vars-section";
|
||||
import {
|
||||
errorMessage,
|
||||
useProfileDelete,
|
||||
useProfileEditorState,
|
||||
useProfileSave,
|
||||
useSyncAgentsToStore,
|
||||
} from "@/components/settings/agent-profile-page-state";
|
||||
import { CustomCLIFlagsCard } from "@/components/settings/cli-flags-field";
|
||||
|
||||
export {
|
||||
ProfileEnvVarsEditor,
|
||||
ProfileEnvVarsSection,
|
||||
} from "@/components/settings/profile-edit/profile-env-vars-section";
|
||||
export { preserveNewerProfileDraft } from "@/components/settings/agent-profile-page-state";
|
||||
import { useSecrets } from "@/hooks/domains/settings/use-secrets";
|
||||
import type {
|
||||
Agent,
|
||||
@@ -60,8 +62,6 @@ type ProfileEditorProps = {
|
||||
initialMcpConfig?: AgentProfileMcpConfig | null;
|
||||
};
|
||||
|
||||
type SaveStatus = "idle" | "loading" | "success" | "error";
|
||||
|
||||
type ProfileEditorHeaderProps = {
|
||||
agentName: string;
|
||||
agentDisplayName: string;
|
||||
@@ -73,6 +73,7 @@ function ProfileEditorHeader({
|
||||
agentDisplayName,
|
||||
savedProfileName,
|
||||
}: ProfileEditorHeaderProps) {
|
||||
const { t } = useTranslation();
|
||||
return (
|
||||
<div className="flex items-start justify-between">
|
||||
<div>
|
||||
@@ -80,7 +81,9 @@ function ProfileEditorHeader({
|
||||
<AgentLogo agentName={agentName} size={28} className="shrink-0" />
|
||||
{agentDisplayName} • {savedProfileName}
|
||||
</h2>
|
||||
<p className="text-sm text-muted-foreground mt-1">{agentDisplayName} profile settings</p>
|
||||
<p className="text-sm text-muted-foreground mt-1">
|
||||
{t("agents:agentProfileSettings", { name: agentDisplayName })}
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
@@ -91,19 +94,20 @@ type DeleteProfileCardProps = {
|
||||
};
|
||||
|
||||
function DeleteProfileCard({ onDelete }: DeleteProfileCardProps) {
|
||||
const { t } = useTranslation();
|
||||
return (
|
||||
<Card className="border-destructive">
|
||||
<CardHeader>
|
||||
<CardTitle className="text-destructive">Delete profile</CardTitle>
|
||||
<CardTitle className="text-destructive">{t("agents:deleteProfile")}</CardTitle>
|
||||
</CardHeader>
|
||||
<CardContent className="flex items-center justify-between">
|
||||
<div>
|
||||
<p className="text-sm font-medium">Remove this profile</p>
|
||||
<p className="text-xs text-muted-foreground">This action cannot be undone.</p>
|
||||
<p className="text-sm font-medium">{t("agents:removeThisProfile")}</p>
|
||||
<p className="text-xs text-muted-foreground">{t("agents:actionCannotBeUndone")}</p>
|
||||
</div>
|
||||
<Button variant="destructive" onClick={onDelete}>
|
||||
<IconTrash className="h-4 w-4 mr-2" />
|
||||
Delete
|
||||
{t("agents:delete")}
|
||||
</Button>
|
||||
</CardContent>
|
||||
</Card>
|
||||
@@ -131,6 +135,7 @@ function ProfileSettingsCard({
|
||||
permissionSettings,
|
||||
passthroughConfig,
|
||||
}: ProfileSettingsCardProps) {
|
||||
const { t } = useTranslation();
|
||||
const handleFormChange = (patch: Partial<ProfileFormData>) => {
|
||||
onDraftChange(toAgentProfilePatch(patch));
|
||||
};
|
||||
@@ -141,7 +146,7 @@ function ProfileSettingsCard({
|
||||
<SettingsCard isDirty={isDirty}>
|
||||
<CardHeader>
|
||||
<CardTitle className="flex items-center gap-2">
|
||||
<span>Profile settings</span>
|
||||
<span>{t("agents:profileSettings")}</span>
|
||||
{agent.supports_mcp && <Badge variant="secondary">MCP</Badge>}
|
||||
</CardTitle>
|
||||
</CardHeader>
|
||||
@@ -182,205 +187,6 @@ function ProfileSettingsCard({
|
||||
);
|
||||
}
|
||||
|
||||
function useSyncAgentsToStore() {
|
||||
const setSettingsAgents = useAppStore((state) => state.setSettingsAgents);
|
||||
const setAgentProfiles = useAppStore((state) => state.setAgentProfiles);
|
||||
return (nextAgents: Agent[]) => {
|
||||
setSettingsAgents(nextAgents);
|
||||
setAgentProfiles(
|
||||
nextAgents.flatMap((agentItem) =>
|
||||
agentItem.profiles.map((agentProfile) => ({
|
||||
id: agentProfile.id,
|
||||
label: `${agentProfile.agentDisplayName ?? ""} • ${agentProfile.name}`,
|
||||
agent_id: agentItem.id,
|
||||
agent_name: agentItem.name,
|
||||
cli_passthrough: agentProfile.cliPassthrough ?? false,
|
||||
})),
|
||||
),
|
||||
);
|
||||
};
|
||||
}
|
||||
|
||||
function useProfileEditorState(
|
||||
profile: AgentProfile,
|
||||
permissionSettings: Record<string, PermissionSetting>,
|
||||
) {
|
||||
const [draft, setDraft] = useState<AgentProfile>({ ...profile });
|
||||
const [savedProfile, setSavedProfile] = useState<AgentProfile>(profile);
|
||||
const [saveStatus, setSaveStatus] = useState<"idle" | "loading" | "success" | "error">("idle");
|
||||
|
||||
const isDirty = useMemo(
|
||||
() =>
|
||||
draft.name !== savedProfile.name ||
|
||||
draft.model !== savedProfile.model ||
|
||||
(draft.mode ?? "") !== (savedProfile.mode ?? "") ||
|
||||
!areConfigOptionsEqual(draft.configOptions, savedProfile.configOptions) ||
|
||||
arePermissionsDirty(draft, savedProfile, permissionSettings) ||
|
||||
draft.cliPassthrough !== savedProfile.cliPassthrough ||
|
||||
!areCLIFlagsEqual(draft.cliFlags ?? [], savedProfile.cliFlags ?? []) ||
|
||||
(draft.commandPrefix ?? "") !== (savedProfile.commandPrefix ?? "") ||
|
||||
!areEnvVarsEqual(draft.envVars, savedProfile.envVars),
|
||||
[draft, savedProfile, permissionSettings],
|
||||
);
|
||||
|
||||
return { draft, setDraft, savedProfile, setSavedProfile, saveStatus, setSaveStatus, isDirty };
|
||||
}
|
||||
|
||||
const FALLBACK_ERROR = "Request failed";
|
||||
|
||||
function errorMessage(error: unknown): string {
|
||||
return error instanceof Error ? error.message : FALLBACK_ERROR;
|
||||
}
|
||||
|
||||
type ProfileEditorActionsOptions = {
|
||||
agent: Agent;
|
||||
draft: AgentProfile;
|
||||
setSavedProfile: (p: AgentProfile) => void;
|
||||
setDraft: React.Dispatch<React.SetStateAction<AgentProfile>>;
|
||||
setSaveStatus: (s: SaveStatus) => void;
|
||||
settingsAgents: Agent[];
|
||||
syncAgentsToStore: (agents: Agent[]) => void;
|
||||
toast: ReturnType<typeof useToast>["toast"];
|
||||
};
|
||||
|
||||
function useProfileSave({
|
||||
agent,
|
||||
draft,
|
||||
setSavedProfile,
|
||||
setDraft,
|
||||
setSaveStatus,
|
||||
settingsAgents,
|
||||
syncAgentsToStore,
|
||||
toast,
|
||||
}: ProfileEditorActionsOptions) {
|
||||
return async () => {
|
||||
if (!draft.name.trim()) {
|
||||
toast({
|
||||
title: "Profile name is required",
|
||||
description: "Please enter a profile name before saving.",
|
||||
variant: "error",
|
||||
});
|
||||
return;
|
||||
}
|
||||
// Model is optional — an empty profile model means "use the agent's
|
||||
// default", which is applied through ACP session model selection at session start.
|
||||
setSaveStatus("loading");
|
||||
try {
|
||||
const updated = await updateAgentProfileAction(draft.id, {
|
||||
name: draft.name,
|
||||
model: draft.model,
|
||||
mode: draft.mode,
|
||||
config_options: draft.configOptions ?? {},
|
||||
...permissionsToProfilePatch(draft),
|
||||
cli_passthrough: draft.cliPassthrough,
|
||||
cli_flags: draft.cliFlags,
|
||||
command_prefix: draft.commandPrefix ?? "",
|
||||
env_vars: draft.envVars ?? [],
|
||||
});
|
||||
setSavedProfile(updated);
|
||||
setDraft((current) => preserveNewerProfileDraft(current, draft, updated));
|
||||
const nextAgents = settingsAgents.map((agentItem: Agent) =>
|
||||
agentItem.id === agent.id
|
||||
? {
|
||||
...agentItem,
|
||||
profiles: agentItem.profiles.map((p: AgentProfile) =>
|
||||
p.id === updated.id ? updated : p,
|
||||
),
|
||||
}
|
||||
: agentItem,
|
||||
);
|
||||
syncAgentsToStore(nextAgents);
|
||||
setSaveStatus("success");
|
||||
} catch (error) {
|
||||
setSaveStatus("error");
|
||||
toast({
|
||||
title: "Failed to save profile",
|
||||
description: errorMessage(error),
|
||||
variant: "error",
|
||||
});
|
||||
throw error;
|
||||
}
|
||||
};
|
||||
}
|
||||
|
||||
export function preserveNewerProfileDraft(
|
||||
current: AgentProfile,
|
||||
submitted: AgentProfile,
|
||||
saved: AgentProfile,
|
||||
): AgentProfile {
|
||||
return current === submitted ? saved : current;
|
||||
}
|
||||
|
||||
function useProfileDelete(
|
||||
agent: Agent,
|
||||
draft: AgentProfile,
|
||||
settingsAgents: Agent[],
|
||||
syncAgentsToStore: (agents: Agent[]) => void,
|
||||
toast: ReturnType<typeof useToast>["toast"],
|
||||
) {
|
||||
const [showDeleteConfirm, setShowDeleteConfirm] = useState(false);
|
||||
const [conflict, setConflict] = useState<AgentProfileDeleteConflict | null>(null);
|
||||
|
||||
const removeProfileFromStore = () => {
|
||||
const nextAgents = settingsAgents.map((agentItem: Agent) =>
|
||||
agentItem.id === agent.id
|
||||
? {
|
||||
...agentItem,
|
||||
profiles: agentItem.profiles.filter((p: AgentProfile) => p.id !== draft.id),
|
||||
}
|
||||
: agentItem,
|
||||
);
|
||||
syncAgentsToStore(nextAgents);
|
||||
window.location.assign("/settings/agents");
|
||||
};
|
||||
|
||||
const requestDelete = () => {
|
||||
setShowDeleteConfirm(true);
|
||||
};
|
||||
|
||||
const handleDeleteProfile = async () => {
|
||||
setShowDeleteConfirm(false);
|
||||
const result = await deleteAgentProfileAction(draft.id);
|
||||
if (result.status === "ok") {
|
||||
removeProfileFromStore();
|
||||
} else if (result.status === "conflict") {
|
||||
setConflict({
|
||||
activeSessions: result.activeSessions,
|
||||
watchers: result.watchers,
|
||||
routingTiers: result.routingTiers,
|
||||
});
|
||||
} else {
|
||||
toast({ title: "Failed to delete profile", description: result.message, variant: "error" });
|
||||
}
|
||||
};
|
||||
|
||||
const handleForceDelete = async () => {
|
||||
const result = await deleteAgentProfileAction(draft.id, true);
|
||||
setConflict(null);
|
||||
if (result.status === "ok") {
|
||||
removeProfileFromStore();
|
||||
} else if (result.status === "conflict") {
|
||||
setConflict({
|
||||
activeSessions: result.activeSessions,
|
||||
watchers: result.watchers,
|
||||
routingTiers: result.routingTiers,
|
||||
});
|
||||
} else if (result.status === "error") {
|
||||
toast({ title: "Failed to delete profile", description: result.message, variant: "error" });
|
||||
}
|
||||
};
|
||||
|
||||
return {
|
||||
requestDelete,
|
||||
showDeleteConfirm,
|
||||
setShowDeleteConfirm,
|
||||
handleDeleteProfile,
|
||||
conflict,
|
||||
setConflict,
|
||||
handleForceDelete,
|
||||
};
|
||||
}
|
||||
|
||||
type ProfileDeleteDialogsProps = {
|
||||
showDeleteConfirm: boolean;
|
||||
setShowDeleteConfirm: (open: boolean) => void;
|
||||
@@ -503,6 +309,7 @@ function ProfileEditor({
|
||||
passthroughConfig,
|
||||
initialMcpConfig,
|
||||
}: ProfileEditorProps) {
|
||||
const { t } = useTranslation();
|
||||
const { toast } = useToast();
|
||||
const settingsAgents = useAppStore((state) => state.settingsAgents.items);
|
||||
const syncAgentsToStore = useSyncAgentsToStore();
|
||||
@@ -538,7 +345,7 @@ function ProfileEditor({
|
||||
revision: JSON.stringify(draft),
|
||||
isDirty,
|
||||
canSave: Boolean(draft.name.trim()),
|
||||
invalidReason: draft.name.trim() ? undefined : "Profile name is required.",
|
||||
invalidReason: draft.name.trim() ? undefined : t("agents:profileNameRequired"),
|
||||
save: handleSave,
|
||||
discard: () => setDraft(savedProfile),
|
||||
});
|
||||
@@ -575,7 +382,7 @@ function ProfileEditor({
|
||||
initialMcpConfig={initialMcpConfig}
|
||||
onToastError={(error) =>
|
||||
toast({
|
||||
title: "Failed to save MCP config",
|
||||
title: t("agents:failedToSaveMcpConfig"),
|
||||
description: errorMessage(error),
|
||||
variant: "error",
|
||||
})
|
||||
@@ -601,6 +408,7 @@ type AgentProfilePageClientProps = {
|
||||
};
|
||||
|
||||
export function AgentProfilePage({ initialMcpConfig }: AgentProfilePageClientProps) {
|
||||
const { t } = useTranslation();
|
||||
const params = useParams();
|
||||
const agentParam = Array.isArray(params.agentId) ? params.agentId[0] : params.agentId;
|
||||
const profileParam = Array.isArray(params.profileId) ? params.profileId[0] : params.profileId;
|
||||
@@ -613,9 +421,9 @@ export function AgentProfilePage({ initialMcpConfig }: AgentProfilePageClientPro
|
||||
return (
|
||||
<Card>
|
||||
<CardContent className="py-12 text-center">
|
||||
<p className="text-sm text-muted-foreground">Profile not found.</p>
|
||||
<p className="text-sm text-muted-foreground">{t("agents:profileNotFound")}</p>
|
||||
<Button className="mt-4" asChild>
|
||||
<Link href="/settings/agents">Back to Agents</Link>
|
||||
<Link href="/settings/agents">{t("agents:backToAgents")}</Link>
|
||||
</Button>
|
||||
</CardContent>
|
||||
</Card>
|
||||
|
||||
@@ -1,5 +1,7 @@
|
||||
"use client";
|
||||
|
||||
import { useTranslation } from "react-i18next";
|
||||
import type { TFunction } from "i18next";
|
||||
import { IconAlertTriangle, IconLoader2, IconRefresh } from "@tabler/icons-react";
|
||||
import { Button } from "@kandev/ui/button";
|
||||
import {
|
||||
@@ -28,6 +30,8 @@ import type { AgentUpdateJob, AgentUpdatePreview, InstallJob } from "@/lib/api";
|
||||
import type { RuntimeUpdate } from "@/lib/types/http";
|
||||
import { useAgentUpdateDialogState } from "./use-agent-update-dialog-state";
|
||||
|
||||
const UPDATE_AGENT_KEY = "agents:updateAgent";
|
||||
|
||||
const ACTIVE_UPDATE_STATUSES = new Set<AgentUpdateJob["status"]>([
|
||||
"queued",
|
||||
"resolving",
|
||||
@@ -35,22 +39,22 @@ const ACTIVE_UPDATE_STATUSES = new Set<AgentUpdateJob["status"]>([
|
||||
"refreshing",
|
||||
]);
|
||||
|
||||
function updatePhase(status: AgentUpdateJob["status"] | undefined): string | null {
|
||||
switch (status) {
|
||||
case "queued":
|
||||
return "Update queued…";
|
||||
case "resolving":
|
||||
return "Checking latest version…";
|
||||
case "updating":
|
||||
return "Updating runtime…";
|
||||
case "refreshing":
|
||||
return "Refreshing agent capabilities…";
|
||||
default:
|
||||
return null;
|
||||
}
|
||||
// The job `status` values are the wire enum; only the phase labels are copy, so
|
||||
// they travel as catalog keys and resolve at render.
|
||||
const UPDATE_PHASE_KEYS: Partial<Record<AgentUpdateJob["status"], string>> = {
|
||||
queued: "agents:updatePhaseQueued",
|
||||
resolving: "agents:updatePhaseResolving",
|
||||
updating: "agents:updatePhaseUpdating",
|
||||
refreshing: "agents:updatePhaseRefreshing",
|
||||
};
|
||||
|
||||
function updatePhase(t: TFunction, status: AgentUpdateJob["status"] | undefined): string | null {
|
||||
const key = status ? UPDATE_PHASE_KEYS[status] : undefined;
|
||||
return key ? t(key) : null;
|
||||
}
|
||||
|
||||
function UpdateResult({ agentName, job }: { agentName: string; job?: AgentUpdateJob }) {
|
||||
const { t } = useTranslation();
|
||||
if (!job) return null;
|
||||
const { versionsMatch } = resolveRuntimeVersionPair(null, job);
|
||||
if (job.status === "succeeded" && job.refresh_error) {
|
||||
@@ -60,7 +64,7 @@ function UpdateResult({ agentName, job }: { agentName: string; job?: AgentUpdate
|
||||
role="alert"
|
||||
data-testid={`agent-update-result-${agentName}`}
|
||||
>
|
||||
Runtime updated, but capabilities could not be refreshed: {job.refresh_error}
|
||||
{t("agents:runtimeUpdatedRefreshFailed", { error: job.refresh_error })}
|
||||
</p>
|
||||
);
|
||||
}
|
||||
@@ -71,7 +75,7 @@ function UpdateResult({ agentName, job }: { agentName: string; job?: AgentUpdate
|
||||
role="status"
|
||||
data-testid={`agent-update-result-${agentName}`}
|
||||
>
|
||||
{versionsMatch ? "Runtime already up to date." : "Runtime updated successfully."}
|
||||
{versionsMatch ? t("agents:runtimeAlreadyUpToDate") : t("agents:runtimeUpdatedSuccess")}
|
||||
</p>
|
||||
);
|
||||
}
|
||||
@@ -82,7 +86,7 @@ function UpdateResult({ agentName, job }: { agentName: string; job?: AgentUpdate
|
||||
role="alert"
|
||||
data-testid={`agent-update-result-${agentName}`}
|
||||
>
|
||||
{job.error || "The runtime update failed."}
|
||||
{job.error || t("agents:runtimeUpdateFailed")}
|
||||
</p>
|
||||
);
|
||||
}
|
||||
@@ -106,17 +110,18 @@ function RuntimeVersionSummary({
|
||||
preview: AgentUpdatePreview;
|
||||
job?: AgentUpdateJob;
|
||||
}) {
|
||||
const { t } = useTranslation();
|
||||
const { currentVersion, targetVersion, versionsMatch } = resolveRuntimeVersionPair(preview, job);
|
||||
|
||||
return (
|
||||
<div className="space-y-1">
|
||||
<p className="font-medium">Runtime version</p>
|
||||
<p className="font-medium">{t("agents:runtimeVersion")}</p>
|
||||
<p className="break-words font-mono text-sm">
|
||||
{versionsMatch ? currentVersion : `${currentVersion} → ${targetVersion}`}
|
||||
</p>
|
||||
{versionsMatch && (
|
||||
<p className="text-sm text-muted-foreground" role="status">
|
||||
Up to date
|
||||
{t("agents:upToDate")}
|
||||
</p>
|
||||
)}
|
||||
</div>
|
||||
@@ -132,7 +137,8 @@ function UpdateBody({
|
||||
job,
|
||||
onRetryPreview,
|
||||
}: UpdateBodyProps) {
|
||||
const phase = updatePhase(job?.status);
|
||||
const { t } = useTranslation();
|
||||
const phase = updatePhase(t, job?.status);
|
||||
return (
|
||||
<div
|
||||
className="max-h-[calc(80dvh-10rem)] min-h-0 space-y-4 overflow-y-auto overscroll-contain px-4 py-3 text-xs/relaxed"
|
||||
@@ -141,7 +147,7 @@ function UpdateBody({
|
||||
{loading && (
|
||||
<p className="flex items-center gap-2 text-muted-foreground" role="status">
|
||||
<IconLoader2 className="size-4 animate-spin" />
|
||||
Checking the latest runtime version…
|
||||
{t("agents:checkingLatestRuntimeVersion")}
|
||||
</p>
|
||||
)}
|
||||
{previewError && (
|
||||
@@ -154,7 +160,7 @@ function UpdateBody({
|
||||
<span>{previewError}</span>
|
||||
</p>
|
||||
<Button type="button" variant="outline" size="sm" onClick={onRetryPreview}>
|
||||
Retry version check
|
||||
{t("agents:retryVersionCheck")}
|
||||
</Button>
|
||||
</div>
|
||||
)}
|
||||
@@ -166,7 +172,7 @@ function UpdateBody({
|
||||
>
|
||||
<p className="flex items-start gap-2 text-destructive">
|
||||
<IconAlertTriangle className="mt-0.5 size-4 shrink-0" />
|
||||
<span>Unable to start update: {approveError}</span>
|
||||
<span>{t("agents:unableToStartUpdate", { error: approveError })}</span>
|
||||
</p>
|
||||
</div>
|
||||
)}
|
||||
@@ -174,17 +180,11 @@ function UpdateBody({
|
||||
<>
|
||||
<RuntimeVersionSummary preview={preview} job={job} />
|
||||
<div className="space-y-1 text-muted-foreground">
|
||||
<p>
|
||||
This updates the managed runtime on this Kandev host, then refreshes its models and
|
||||
capabilities.
|
||||
</p>
|
||||
<p>
|
||||
Active sessions keep running; only later probes and launches use the refreshed
|
||||
runtime.
|
||||
</p>
|
||||
<p>{t("agents:runtimeUpdateExplainer")}</p>
|
||||
<p>{t("agents:runtimeUpdateSessionsNote")}</p>
|
||||
</div>
|
||||
<div className="space-y-1">
|
||||
<p className="font-medium">Command that will run</p>
|
||||
<p className="font-medium">{t("agents:commandThatWillRun")}</p>
|
||||
<pre className="whitespace-pre-wrap break-all rounded-md bg-muted p-3 font-mono text-xs text-muted-foreground">
|
||||
{preview.command_string}
|
||||
</pre>
|
||||
@@ -239,6 +239,7 @@ function UpdateFooter({
|
||||
onClose,
|
||||
mobile,
|
||||
}: UpdateFooterProps) {
|
||||
const { t } = useTranslation();
|
||||
const updateInFlight = Boolean(job && ACTIVE_UPDATE_STATUSES.has(job.status));
|
||||
const canRetry = job?.status === "failed";
|
||||
const canApprove = canApproveAgentRuntimeUpdate({
|
||||
@@ -254,7 +255,7 @@ function UpdateFooter({
|
||||
const content = (
|
||||
<>
|
||||
<Button type="button" variant="outline" onClick={onClose}>
|
||||
{job?.status === "succeeded" ? "Done" : "Cancel"}
|
||||
{job?.status === "succeeded" ? t("agents:done") : t("common:cancel")}
|
||||
</Button>
|
||||
{showApprove && (
|
||||
<Button
|
||||
@@ -264,7 +265,7 @@ function UpdateFooter({
|
||||
data-testid={`agent-update-confirm-${agentName}`}
|
||||
>
|
||||
{starting && <IconLoader2 className="mr-2 size-4 animate-spin" />}
|
||||
{canRetry ? "Retry update" : "Approve update"}
|
||||
{canRetry ? t("agents:retryUpdate") : t("agents:approveUpdate")}
|
||||
</Button>
|
||||
)}
|
||||
</>
|
||||
@@ -290,6 +291,7 @@ function UpdateTrigger({
|
||||
installInFlight: boolean;
|
||||
onOpen: () => void;
|
||||
}) {
|
||||
const { t } = useTranslation();
|
||||
return (
|
||||
<Tooltip>
|
||||
<TooltipTrigger asChild>
|
||||
@@ -299,7 +301,7 @@ function UpdateTrigger({
|
||||
variant="ghost"
|
||||
size="icon"
|
||||
className="h-11 w-11 cursor-pointer active:scale-95 sm:h-7 sm:w-7"
|
||||
aria-label={`Update ${displayName}`}
|
||||
aria-label={t(UPDATE_AGENT_KEY, { name: displayName })}
|
||||
disabled={installInFlight}
|
||||
onClick={onOpen}
|
||||
data-testid={`agent-update-trigger-${agentName}`}
|
||||
@@ -309,7 +311,9 @@ function UpdateTrigger({
|
||||
</span>
|
||||
</TooltipTrigger>
|
||||
<TooltipContent>
|
||||
{installInFlight ? "Agent installation is in progress" : `Update ${displayName}`}
|
||||
{installInFlight
|
||||
? t("agents:agentInstallationInProgress")
|
||||
: t(UPDATE_AGENT_KEY, { name: displayName })}
|
||||
</TooltipContent>
|
||||
</Tooltip>
|
||||
);
|
||||
@@ -332,6 +336,7 @@ export function AgentRuntimeUpdateControl({
|
||||
onPreview: (agentName: string) => Promise<AgentUpdatePreview>;
|
||||
onUpdate: (agentName: string) => Promise<AgentUpdateJob>;
|
||||
}) {
|
||||
const { t } = useTranslation();
|
||||
const { isMobile } = useResponsiveBreakpoint();
|
||||
const {
|
||||
activeJob,
|
||||
@@ -388,10 +393,8 @@ export function AgentRuntimeUpdateControl({
|
||||
<Drawer open={open} onOpenChange={handleOpenChange}>
|
||||
<DrawerContent className="max-h-[80dvh]" data-testid={`agent-update-drawer-${agentName}`}>
|
||||
<DrawerHeader className="shrink-0 text-left">
|
||||
<DrawerTitle>Update {displayName}</DrawerTitle>
|
||||
<DrawerDescription>
|
||||
Review the update before Kandev changes this host runtime.
|
||||
</DrawerDescription>
|
||||
<DrawerTitle>{t(UPDATE_AGENT_KEY, { name: displayName })}</DrawerTitle>
|
||||
<DrawerDescription>{t("agents:reviewUpdateBeforeApplying")}</DrawerDescription>
|
||||
</DrawerHeader>
|
||||
{body}
|
||||
{footer(true)}
|
||||
@@ -404,10 +407,8 @@ export function AgentRuntimeUpdateControl({
|
||||
data-testid={`agent-update-dialog-${agentName}`}
|
||||
>
|
||||
<DialogHeader className="px-4 pt-4">
|
||||
<DialogTitle>Update {displayName}</DialogTitle>
|
||||
<DialogDescription>
|
||||
Review the update before Kandev changes this host runtime.
|
||||
</DialogDescription>
|
||||
<DialogTitle>{t(UPDATE_AGENT_KEY, { name: displayName })}</DialogTitle>
|
||||
<DialogDescription>{t("agents:reviewUpdateBeforeApplying")}</DialogDescription>
|
||||
</DialogHeader>
|
||||
{body}
|
||||
{footer()}
|
||||
|
||||
@@ -1,6 +1,7 @@
|
||||
"use client";
|
||||
|
||||
import { useId, useMemo, useState } from "react";
|
||||
import { useTranslation } from "react-i18next";
|
||||
import { IconPlus, IconTrash } from "@tabler/icons-react";
|
||||
import { Button } from "@kandev/ui/button";
|
||||
import { CardContent, CardDescription, CardHeader, CardTitle } from "@kandev/ui/card";
|
||||
@@ -18,6 +19,9 @@ import { SettingsCard } from "@/components/settings/settings-card";
|
||||
* have to think about quoting, and re-concatenate on save. Description
|
||||
* is preserved through the round-trip but no longer shown in the UI.
|
||||
*/
|
||||
// An example flag name — code the user types verbatim, never translated.
|
||||
const FLAG_PLACEHOLDER = "--my-flag";
|
||||
|
||||
type CustomFlagRow = {
|
||||
flag: string;
|
||||
value: string;
|
||||
@@ -208,6 +212,7 @@ function CuratedFlagsSection({
|
||||
onToggle: (setting: CuratedSetting, enabled: boolean) => void;
|
||||
compact: boolean;
|
||||
}) {
|
||||
const { t } = useTranslation();
|
||||
const labelCls = compact ? "text-xs" : undefined;
|
||||
const switchSize = compact ? ("sm" as const) : ("default" as const);
|
||||
return (
|
||||
@@ -231,7 +236,11 @@ function CuratedFlagsSection({
|
||||
checked={enabled}
|
||||
onCheckedChange={(checked) => onToggle(setting, checked)}
|
||||
data-testid={`cli-flag-curated-enabled-${setting.key}`}
|
||||
aria-label={`${enabled ? "Disable" : "Enable"} ${setting.label}`}
|
||||
aria-label={
|
||||
enabled
|
||||
? t("agents:disableSetting", { name: setting.label })
|
||||
: t("agents:enableSetting", { name: setting.label })
|
||||
}
|
||||
/>
|
||||
</div>
|
||||
);
|
||||
@@ -251,11 +260,12 @@ function CustomFlagsSection({
|
||||
onRemove: (index: number) => void;
|
||||
onAdd: (next: CLIFlag) => void;
|
||||
}) {
|
||||
const { t } = useTranslation();
|
||||
return (
|
||||
<div className="space-y-3">
|
||||
{customFlags.length === 0 ? (
|
||||
<p className="text-xs italic text-muted-foreground" data-testid="cli-flags-empty">
|
||||
No CLI flags configured. Add one below.
|
||||
{t("agents:noCliFlagsConfigured")}
|
||||
</p>
|
||||
) : (
|
||||
<ul className="space-y-2" data-testid="cli-flags-list">
|
||||
@@ -286,6 +296,7 @@ function CLIFlagRow({
|
||||
onUpdateRow: (index: number, next: CLIFlag) => void;
|
||||
onRemove: (index: number) => void;
|
||||
}) {
|
||||
const { t } = useTranslation();
|
||||
const row = flagToRow(flag);
|
||||
const update = (patch: Partial<CustomFlagRow>) => {
|
||||
onUpdateRow(index, rowToFlag({ ...row, ...patch }));
|
||||
@@ -295,14 +306,14 @@ function CLIFlagRow({
|
||||
<Input
|
||||
value={row.flag}
|
||||
onChange={(e) => update({ flag: e.target.value })}
|
||||
placeholder="--my-flag"
|
||||
placeholder={FLAG_PLACEHOLDER}
|
||||
className="flex-[2] font-mono text-xs"
|
||||
data-testid={`cli-flag-flag-${index}`}
|
||||
/>
|
||||
<Input
|
||||
value={row.value}
|
||||
onChange={(e) => update({ value: e.target.value })}
|
||||
placeholder="value (optional)"
|
||||
placeholder={t("agents:valueOptional")}
|
||||
className="flex-[3] font-mono text-xs"
|
||||
data-testid={`cli-flag-value-${index}`}
|
||||
/>
|
||||
@@ -310,7 +321,11 @@ function CLIFlagRow({
|
||||
checked={row.enabled}
|
||||
onCheckedChange={(checked) => update({ enabled: checked })}
|
||||
data-testid={`cli-flag-enabled-${index}`}
|
||||
aria-label={`${row.enabled ? "Disable" : "Enable"} ${row.flag}`}
|
||||
aria-label={
|
||||
row.enabled
|
||||
? t("agents:disableSetting", { name: row.flag })
|
||||
: t("agents:enableSetting", { name: row.flag })
|
||||
}
|
||||
/>
|
||||
<Button
|
||||
type="button"
|
||||
@@ -319,7 +334,7 @@ function CLIFlagRow({
|
||||
onClick={() => onRemove(index)}
|
||||
className="h-8 w-8 shrink-0 cursor-pointer"
|
||||
data-testid={`cli-flag-remove-${index}`}
|
||||
aria-label={`Remove ${row.flag || "flag"}`}
|
||||
aria-label={t("agents:removeCliFlag", { name: row.flag || t("agents:flag") })}
|
||||
>
|
||||
<IconTrash className="h-3.5 w-3.5 text-muted-foreground" />
|
||||
</Button>
|
||||
@@ -328,6 +343,7 @@ function CLIFlagRow({
|
||||
}
|
||||
|
||||
function CLIFlagsAddForm({ onAdd }: { onAdd: (next: CLIFlag) => void }) {
|
||||
const { t } = useTranslation();
|
||||
const uid = useId();
|
||||
const flagId = `${uid}-flag`;
|
||||
const valueId = `${uid}-value`;
|
||||
@@ -350,13 +366,13 @@ function CLIFlagsAddForm({ onAdd }: { onAdd: (next: CLIFlag) => void }) {
|
||||
<div className="flex flex-col gap-2 sm:flex-row sm:items-end">
|
||||
<div className="flex-[2] space-y-1">
|
||||
<Label className="text-xs" htmlFor={flagId}>
|
||||
Flag
|
||||
{t("agents:flag")}
|
||||
</Label>
|
||||
<Input
|
||||
id={flagId}
|
||||
value={newFlag}
|
||||
onChange={(e) => setNewFlag(e.target.value)}
|
||||
placeholder="--my-flag"
|
||||
placeholder={FLAG_PLACEHOLDER}
|
||||
className="font-mono text-xs"
|
||||
data-testid="cli-flag-new-flag-input"
|
||||
onKeyDown={onEnter}
|
||||
@@ -364,13 +380,13 @@ function CLIFlagsAddForm({ onAdd }: { onAdd: (next: CLIFlag) => void }) {
|
||||
</div>
|
||||
<div className="flex-[3] space-y-1">
|
||||
<Label className="text-xs" htmlFor={valueId}>
|
||||
Value (optional)
|
||||
{t("agents:valueOptionalLabel")}
|
||||
</Label>
|
||||
<Input
|
||||
id={valueId}
|
||||
value={newValue}
|
||||
onChange={(e) => setNewValue(e.target.value)}
|
||||
placeholder="value"
|
||||
placeholder={t("agents:valuePlaceholder")}
|
||||
className="font-mono text-xs"
|
||||
data-testid="cli-flag-new-value-input"
|
||||
onKeyDown={onEnter}
|
||||
@@ -386,7 +402,7 @@ function CLIFlagsAddForm({ onAdd }: { onAdd: (next: CLIFlag) => void }) {
|
||||
data-testid="cli-flag-add-button"
|
||||
>
|
||||
<IconPlus className="h-3.5 w-3.5 mr-1" />
|
||||
Add
|
||||
{t("agents:add")}
|
||||
</Button>
|
||||
</div>
|
||||
);
|
||||
@@ -411,6 +427,7 @@ export function CustomCLIFlagsCard({
|
||||
onChange,
|
||||
permissionSettings,
|
||||
}: CustomCLIFlagsCardProps) {
|
||||
const { t } = useTranslation();
|
||||
const curatedFlagTexts = useMemo(
|
||||
() => new Set(extractCuratedSettings(permissionSettings).map((s) => s.flag)),
|
||||
[permissionSettings],
|
||||
@@ -437,14 +454,15 @@ export function CustomCLIFlagsCard({
|
||||
<CardHeader>
|
||||
<div className="flex items-center justify-between gap-3">
|
||||
<div>
|
||||
<CardTitle>Agent CLI flags</CardTitle>
|
||||
<CardDescription>
|
||||
Flags passed to the agent CLI on launch. Only enabled entries are applied.
|
||||
</CardDescription>
|
||||
<CardTitle>{t("agents:agentCliFlags")}</CardTitle>
|
||||
<CardDescription>{t("agents:agentCliFlagsDescription")}</CardDescription>
|
||||
</div>
|
||||
{customFlags.length > 0 && (
|
||||
<span className="text-[10px] text-muted-foreground" data-testid="cli-flags-count">
|
||||
{enabledCount} of {customFlags.length} enabled
|
||||
{t("agents:cliFlagsEnabledCount", {
|
||||
count: customFlags.length,
|
||||
enabled: enabledCount,
|
||||
})}
|
||||
</span>
|
||||
)}
|
||||
</div>
|
||||
|
||||
@@ -76,4 +76,24 @@ describe("CommandPrefixField", () => {
|
||||
fireEvent.change(screen.getByTestId("command-prefix-input"), { target: { value: "" } });
|
||||
expect(onChange).toHaveBeenCalledWith({ command_prefix: "" });
|
||||
});
|
||||
|
||||
// The help text is a <Trans>: its <0> index addresses the JSX children
|
||||
// positionally, so a prettier reflow can silently reassemble the sentence
|
||||
// into fragments without failing anything.
|
||||
it("renders the help text as one sentence with the example launcher intact", () => {
|
||||
render(
|
||||
<CommandPrefixField profile={formData()} baselineProfile={formData()} onChange={vi.fn()} />,
|
||||
);
|
||||
|
||||
const help = screen.getByText(
|
||||
(_content, element) =>
|
||||
element?.tagName === "P" &&
|
||||
element.textContent ===
|
||||
"Tokens prepended to the agent launch command, so it runs under a sandbox launcher " +
|
||||
"(e.g. greywall --). The value is shell-tokenised. Leave empty to run the agent " +
|
||||
"directly. Applies to ACP sessions only \u2014 it has no effect when the profile " +
|
||||
"uses TUI passthrough.",
|
||||
);
|
||||
expect(help.querySelector("code")?.textContent).toBe(PREFIX);
|
||||
});
|
||||
});
|
||||
|
||||
@@ -1,9 +1,15 @@
|
||||
"use client";
|
||||
|
||||
import { Trans, useTranslation } from "react-i18next";
|
||||
import { Input } from "@kandev/ui/input";
|
||||
import { Label } from "@kandev/ui/label";
|
||||
import type { ProfileFormData } from "@/components/settings/profile-form-fields";
|
||||
|
||||
// An example sandbox launcher invocation — a shell fragment the user types
|
||||
// verbatim, so it is interpolated as a value rather than written into the
|
||||
// catalog.
|
||||
const EXAMPLE_PREFIX = "greywall --";
|
||||
|
||||
export function CommandPrefixField({
|
||||
profile,
|
||||
baselineProfile,
|
||||
@@ -13,6 +19,7 @@ export function CommandPrefixField({
|
||||
baselineProfile?: ProfileFormData;
|
||||
onChange: (patch: Partial<ProfileFormData>) => void;
|
||||
}) {
|
||||
const { t } = useTranslation();
|
||||
return (
|
||||
<div
|
||||
className="space-y-2"
|
||||
@@ -22,19 +29,18 @@ export function CommandPrefixField({
|
||||
}
|
||||
data-settings-dirty-level="container"
|
||||
>
|
||||
<Label htmlFor="profile-command-prefix">Command prefix</Label>
|
||||
<Label htmlFor="profile-command-prefix">{t("agents:commandPrefix")}</Label>
|
||||
<Input
|
||||
id="profile-command-prefix"
|
||||
data-testid="command-prefix-input"
|
||||
value={profile.command_prefix ?? ""}
|
||||
onChange={(event) => onChange({ command_prefix: event.target.value })}
|
||||
placeholder="e.g. greywall --"
|
||||
placeholder={t("agents:exampleValue", { example: EXAMPLE_PREFIX })}
|
||||
/>
|
||||
<p className="text-xs text-muted-foreground">
|
||||
Tokens prepended to the agent launch command, so it runs under a sandbox launcher (e.g.{" "}
|
||||
<code>greywall --</code>). The value is shell-tokenised. Leave empty to run the agent
|
||||
directly. Applies to ACP sessions only — it has no effect when the profile uses TUI
|
||||
passthrough.
|
||||
<Trans i18nKey="agents:commandPrefixHelp" values={{ example: EXAMPLE_PREFIX }}>
|
||||
<code />
|
||||
</Trans>
|
||||
</p>
|
||||
</div>
|
||||
);
|
||||
|
||||
@@ -1,5 +1,6 @@
|
||||
"use client";
|
||||
|
||||
import { useTranslation } from "react-i18next";
|
||||
import { startHostShell } from "@/lib/api";
|
||||
import { PtyTerminalDialog } from "@/components/settings/pty-terminal-dialog";
|
||||
|
||||
@@ -20,12 +21,13 @@ type Props = {
|
||||
* Used for ad-hoc setup commands that don't fit the install/login flows.
|
||||
*/
|
||||
export function HostShellDialog({ open, onOpenChange, onClose, initialInput }: Props) {
|
||||
const { t } = useTranslation();
|
||||
return (
|
||||
<PtyTerminalDialog
|
||||
open={open}
|
||||
onOpenChange={onOpenChange}
|
||||
title="Host terminal"
|
||||
description="Runs a shell on the kandev host. Use it to install or configure agents manually."
|
||||
title={t("agents:hostTerminal")}
|
||||
description={t("agents:hostTerminalDescription")}
|
||||
testIdPrefix="host-shell"
|
||||
startSession={startHostShell}
|
||||
onDone={onClose}
|
||||
|
||||
@@ -1,5 +1,7 @@
|
||||
"use client";
|
||||
|
||||
import { useTranslation } from "react-i18next";
|
||||
import type { TFunction } from "i18next";
|
||||
import { IconDownload, IconLoader2 } from "@tabler/icons-react";
|
||||
import { Button } from "@kandev/ui/button";
|
||||
import { Card, CardContent } from "@kandev/ui/card";
|
||||
@@ -9,19 +11,27 @@ import type { AvailableAgent } from "@/lib/types/http";
|
||||
|
||||
type InstallStatus = InstallJob["status"] | "idle";
|
||||
|
||||
function installButtonContent(status: InstallStatus): {
|
||||
/**
|
||||
* The install-job `status` values are the wire enum; only the button labels are
|
||||
* copy. The rule never inspects this function (`mode: "jsx-only"` skips a
|
||||
* non-JSX body), so the labels are resolved through `t` at render.
|
||||
*/
|
||||
function installButtonContent(
|
||||
t: TFunction,
|
||||
status: InstallStatus,
|
||||
): {
|
||||
icon: "spinner" | "download";
|
||||
label: string;
|
||||
} {
|
||||
switch (status) {
|
||||
case "queued":
|
||||
return { icon: "spinner", label: "Queued…" };
|
||||
return { icon: "spinner", label: t("agents:installQueued") };
|
||||
case "running":
|
||||
return { icon: "spinner", label: "Installing…" };
|
||||
return { icon: "spinner", label: t("agents:installing") };
|
||||
case "failed":
|
||||
return { icon: "download", label: "Retry" };
|
||||
return { icon: "download", label: t("agents:retry") };
|
||||
default:
|
||||
return { icon: "download", label: "Install" };
|
||||
return { icon: "download", label: t("agents:install") };
|
||||
}
|
||||
}
|
||||
|
||||
@@ -34,8 +44,9 @@ function InstallButton({
|
||||
status: InstallStatus;
|
||||
onInstall: (name: string) => void;
|
||||
}) {
|
||||
const { t } = useTranslation();
|
||||
const isInFlight = status === "queued" || status === "running";
|
||||
const btn = installButtonContent(status);
|
||||
const btn = installButtonContent(t, status);
|
||||
return (
|
||||
<Button
|
||||
size="sm"
|
||||
|
||||
@@ -1,6 +1,7 @@
|
||||
"use client";
|
||||
|
||||
import { useState } from "react";
|
||||
import { useTranslation } from "react-i18next";
|
||||
import Link from "@/components/routing/app-link";
|
||||
import { IconLoader2, IconLock, IconSettings } from "@tabler/icons-react";
|
||||
import { Badge } from "@kandev/ui/badge";
|
||||
@@ -50,13 +51,14 @@ function InstalledAgentIdentity({
|
||||
loginAvailable: boolean;
|
||||
onAuthClick: () => void;
|
||||
}) {
|
||||
const { t } = useTranslation();
|
||||
return (
|
||||
<div className="space-y-1">
|
||||
<div className="flex min-w-0 flex-wrap items-center gap-2">
|
||||
<AgentLogo agentName={agent.name} size={20} className="shrink-0" />
|
||||
<h4 className="min-w-0 truncate font-medium">{displayName}</h4>
|
||||
{agent.supports_mcp && <Badge variant="secondary">MCP</Badge>}
|
||||
{configured && <Badge variant="outline">Configured</Badge>}
|
||||
{configured && <Badge variant="outline">{t("agents:configured")}</Badge>}
|
||||
<div className="ml-auto flex shrink-0 items-center gap-1">
|
||||
{probing && (
|
||||
<Tooltip>
|
||||
@@ -64,12 +66,12 @@ function InstalledAgentIdentity({
|
||||
<span
|
||||
data-testid={`probing-icon-${agent.name}`}
|
||||
className="flex items-center text-muted-foreground cursor-help"
|
||||
aria-label="Checking authentication"
|
||||
aria-label={t("agents:checkingAuthentication")}
|
||||
>
|
||||
<IconLoader2 className="h-3.5 w-3.5 animate-spin" />
|
||||
</span>
|
||||
</TooltipTrigger>
|
||||
<TooltipContent>Checking agent capabilities and authentication...</TooltipContent>
|
||||
<TooltipContent>{t("agents:checkingCapabilitiesAndAuth")}</TooltipContent>
|
||||
</Tooltip>
|
||||
)}
|
||||
{authRequired && (
|
||||
@@ -80,15 +82,15 @@ function InstalledAgentIdentity({
|
||||
onClick={onAuthClick}
|
||||
data-testid={`auth-icon-${agent.name}`}
|
||||
className="flex items-center gap-1 rounded-md px-1.5 py-0.5 text-xs text-amber-500 cursor-pointer hover:bg-amber-500/10"
|
||||
aria-label="Authentication required"
|
||||
aria-label={t("agents:authenticationRequired")}
|
||||
>
|
||||
<IconLock className="h-3.5 w-3.5" />
|
||||
</button>
|
||||
</TooltipTrigger>
|
||||
<TooltipContent>
|
||||
{loginAvailable
|
||||
? "Authentication required - click to open login terminal"
|
||||
: "Authentication required - click to open a shell and sign in"}
|
||||
? t("agents:authRequiredOpenLoginTerminal")
|
||||
: t("agents:authRequiredOpenShell")}
|
||||
</TooltipContent>
|
||||
</Tooltip>
|
||||
)}
|
||||
@@ -98,7 +100,7 @@ function InstalledAgentIdentity({
|
||||
className="text-xs text-muted-foreground line-clamp-2 min-h-[2rem]"
|
||||
title={agent.matched_path ?? undefined}
|
||||
>
|
||||
{agent.matched_path ? `Detected at ${agent.matched_path}` : ""}
|
||||
{agent.matched_path ? t("agents:detectedAt", { path: agent.matched_path }) : ""}
|
||||
</p>
|
||||
</div>
|
||||
);
|
||||
@@ -122,6 +124,7 @@ export function InstalledAgentCard({
|
||||
onUpdate,
|
||||
onAuthComplete,
|
||||
}: Props) {
|
||||
const { t } = useTranslation();
|
||||
const configured = Boolean(savedAgent && savedAgent.profiles.length > 0);
|
||||
const hasAgentRecord = Boolean(savedAgent);
|
||||
const [loginOpen, setLoginOpen] = useState(false);
|
||||
@@ -164,7 +167,7 @@ export function InstalledAgentCard({
|
||||
}
|
||||
>
|
||||
<IconSettings className="mr-2 h-4 w-4" />
|
||||
{hasAgentRecord ? "Create new profile" : "Setup Profile"}
|
||||
{hasAgentRecord ? t("agents:createNewProfile") : t("agents:setupProfile")}
|
||||
</Link>
|
||||
</Button>
|
||||
{runtimeUpdate?.supported && onPreview && onUpdate && (
|
||||
|
||||
@@ -1,6 +1,7 @@
|
||||
"use client";
|
||||
|
||||
import { useState } from "react";
|
||||
import { useTranslation } from "react-i18next";
|
||||
import { IconSelector } from "@tabler/icons-react";
|
||||
import { Button } from "@kandev/ui/button";
|
||||
import {
|
||||
@@ -27,6 +28,7 @@ type ModeComboboxProps = {
|
||||
* trigger text (which happens with Radix Select).
|
||||
*/
|
||||
export function ModeCombobox({ value, onChange, modes, currentModeId }: ModeComboboxProps) {
|
||||
const { t } = useTranslation();
|
||||
const [open, setOpen] = useState(false);
|
||||
const selected = value || currentModeId || modes[0]?.id || "";
|
||||
const activeMode = modes.find((m) => m.id === selected);
|
||||
@@ -43,7 +45,7 @@ export function ModeCombobox({ value, onChange, modes, currentModeId }: ModeComb
|
||||
<span className="flex items-center gap-2 truncate">
|
||||
{activeMode?.name ?? selected}
|
||||
{activeMode?.id === currentModeId && (
|
||||
<span className="text-muted-foreground">(default)</span>
|
||||
<span className="text-muted-foreground">{t("agents:defaultSuffix")}</span>
|
||||
)}
|
||||
</span>
|
||||
<IconSelector className="ml-2 h-4 w-4 shrink-0 opacity-50" />
|
||||
@@ -55,12 +57,12 @@ export function ModeCombobox({ value, onChange, modes, currentModeId }: ModeComb
|
||||
onWheel={(e) => e.stopPropagation()}
|
||||
>
|
||||
<Command>
|
||||
<CommandInput placeholder="Search modes..." />
|
||||
<CommandInput placeholder={t("agents:searchModes")} />
|
||||
<CommandList
|
||||
className="max-h-[min(60vh,24rem)] overflow-y-auto overscroll-contain"
|
||||
onWheel={(e) => e.stopPropagation()}
|
||||
>
|
||||
<CommandEmpty>No mode found.</CommandEmpty>
|
||||
<CommandEmpty>{t("agents:noModeFound")}</CommandEmpty>
|
||||
<CommandGroup>
|
||||
{modes.map((m) => (
|
||||
<CommandItem
|
||||
@@ -77,7 +79,9 @@ export function ModeCombobox({ value, onChange, modes, currentModeId }: ModeComb
|
||||
<div className="flex items-center gap-2 truncate">
|
||||
<span className="truncate">{m.name}</span>
|
||||
{m.id === currentModeId && (
|
||||
<span className="text-muted-foreground text-xs">(default)</span>
|
||||
<span className="text-muted-foreground text-xs">
|
||||
{t("agents:defaultSuffix")}
|
||||
</span>
|
||||
)}
|
||||
</div>
|
||||
{m.description && m.description !== m.name && (
|
||||
|
||||
@@ -0,0 +1,41 @@
|
||||
import { afterEach, describe, expect, it } from "vitest";
|
||||
import { cleanup, fireEvent, render, screen } from "@testing-library/react";
|
||||
import { CommandsButton } from "./profile-capability-helpers";
|
||||
import type { CommandEntry } from "@/lib/types/http";
|
||||
|
||||
afterEach(cleanup);
|
||||
|
||||
const COMMANDS: CommandEntry[] = [
|
||||
{ name: "init", description: "Bootstrap the repo" },
|
||||
{ name: "review", description: "" },
|
||||
];
|
||||
|
||||
describe("CommandsButton", () => {
|
||||
it("renders nothing when the agent advertises no commands", () => {
|
||||
const { container } = render(<CommandsButton commands={[]} />);
|
||||
expect(container.firstChild).toBeNull();
|
||||
});
|
||||
|
||||
it("counts the advertised commands on the trigger", () => {
|
||||
render(<CommandsButton commands={COMMANDS} />);
|
||||
expect(screen.getByTestId("profile-commands-button").textContent).toContain(
|
||||
"Available commands (2)",
|
||||
);
|
||||
});
|
||||
|
||||
// The dialog description is a <Trans>: its <0> index addresses the JSX
|
||||
// children positionally, so a prettier reflow can silently reassemble the
|
||||
// sentence into fragments without failing anything. The `/init` example is a
|
||||
// command the user types verbatim and is interpolated as a value.
|
||||
it("renders the dialog description as one sentence with the example intact", () => {
|
||||
render(<CommandsButton commands={COMMANDS} />);
|
||||
fireEvent.click(screen.getByTestId("profile-commands-button"));
|
||||
|
||||
const description = screen.getByText(
|
||||
(_content, element) =>
|
||||
element?.textContent === "Type these during a session chat to invoke them - e.g. /init." &&
|
||||
Boolean(element.querySelector("code")),
|
||||
);
|
||||
expect(description.querySelector("code")?.textContent).toBe("/init");
|
||||
});
|
||||
});
|
||||
@@ -1,3 +1,4 @@
|
||||
import { Trans, useTranslation } from "react-i18next";
|
||||
import { IconTerminal2 } from "@tabler/icons-react";
|
||||
import { Button } from "@kandev/ui/button";
|
||||
import {
|
||||
@@ -11,7 +12,11 @@ import {
|
||||
import type { CommandEntry, ModeEntry } from "@/lib/types/http";
|
||||
import type { ProfileFormData } from "./profile-form-fields";
|
||||
|
||||
// An example slash command the user types verbatim — an identifier, not copy.
|
||||
const EXAMPLE_COMMAND = "/init";
|
||||
|
||||
export function CommandsButton({ commands }: { commands: CommandEntry[] }) {
|
||||
const { t } = useTranslation();
|
||||
if (commands.length === 0) return null;
|
||||
return (
|
||||
<Dialog>
|
||||
@@ -24,14 +29,19 @@ export function CommandsButton({ commands }: { commands: CommandEntry[] }) {
|
||||
data-testid="profile-commands-button"
|
||||
>
|
||||
<IconTerminal2 className="mr-2 h-4 w-4" />
|
||||
Available commands ({commands.length})
|
||||
{t("agents:availableCommandsCount", { count: commands.length })}
|
||||
</Button>
|
||||
</DialogTrigger>
|
||||
<DialogContent className="max-w-lg">
|
||||
<DialogHeader>
|
||||
<DialogTitle>Available slash commands</DialogTitle>
|
||||
<DialogTitle>{t("agents:availableSlashCommands")}</DialogTitle>
|
||||
<DialogDescription>
|
||||
Type these during a session chat to invoke them - e.g. <code>/init</code>.
|
||||
<Trans
|
||||
i18nKey="agents:availableSlashCommandsHelp"
|
||||
values={{ example: EXAMPLE_COMMAND }}
|
||||
>
|
||||
<code />
|
||||
</Trans>
|
||||
</DialogDescription>
|
||||
</DialogHeader>
|
||||
<div className="max-h-[60vh] overflow-y-auto space-y-2">
|
||||
|
||||
@@ -1,6 +1,8 @@
|
||||
"use client";
|
||||
|
||||
import { useId } from "react";
|
||||
import { useTranslation } from "react-i18next";
|
||||
import type { TFunction } from "i18next";
|
||||
import { IconAlertCircle, IconAlertTriangle, IconRefresh } from "@tabler/icons-react";
|
||||
import { NoAuthPanel, ProbingPanel } from "@/components/settings/profile-status-panels";
|
||||
import { Button } from "@kandev/ui/button";
|
||||
@@ -245,23 +247,23 @@ function PermissionToggles({
|
||||
);
|
||||
}
|
||||
|
||||
function capabilityStatusMessage(status: ModelConfig["status"]): string | null {
|
||||
switch (status) {
|
||||
case "probing":
|
||||
return "Checking agent capabilities…";
|
||||
case "auth_required":
|
||||
return "Authentication required. Run the agent CLI in your terminal to authenticate, then refresh.";
|
||||
case "not_installed":
|
||||
return "Agent CLI not installed.";
|
||||
case "failed":
|
||||
return "Probe failed. Check agent logs for details.";
|
||||
default:
|
||||
return null;
|
||||
}
|
||||
// The probe `status` values are the wire enum; only the messages are copy, so
|
||||
// they travel as catalog keys and resolve at render.
|
||||
const CAPABILITY_STATUS_KEYS: Record<string, string> = {
|
||||
probing: "agents:capabilityProbing",
|
||||
auth_required: "agents:capabilityAuthRequired",
|
||||
not_installed: "agents:capabilityNotInstalled",
|
||||
failed: "agents:capabilityProbeFailed",
|
||||
};
|
||||
|
||||
function capabilityStatusMessage(t: TFunction, status: ModelConfig["status"]): string | null {
|
||||
const key = status ? CAPABILITY_STATUS_KEYS[status] : undefined;
|
||||
return key ? t(key) : null;
|
||||
}
|
||||
|
||||
function CapabilityStatusMessage({ status }: { status: ModelConfig["status"] }) {
|
||||
const msg = capabilityStatusMessage(status);
|
||||
const { t } = useTranslation();
|
||||
const msg = capabilityStatusMessage(t, status);
|
||||
if (!msg) return null;
|
||||
return (
|
||||
<p
|
||||
@@ -283,6 +285,7 @@ function RefreshCapabilitiesButton({
|
||||
isLoading: boolean;
|
||||
error: string | null;
|
||||
}) {
|
||||
const { t } = useTranslation();
|
||||
return (
|
||||
<div className="flex items-center gap-2">
|
||||
<Tooltip>
|
||||
@@ -299,7 +302,7 @@ function RefreshCapabilitiesButton({
|
||||
</Button>
|
||||
</TooltipTrigger>
|
||||
<TooltipContent>
|
||||
<p>Refresh agent capabilities (models + modes)</p>
|
||||
<p>{t("agents:refreshCapabilitiesTooltip")}</p>
|
||||
</TooltipContent>
|
||||
</Tooltip>
|
||||
{error && (
|
||||
@@ -310,7 +313,7 @@ function RefreshCapabilitiesButton({
|
||||
</div>
|
||||
</TooltipTrigger>
|
||||
<TooltipContent>
|
||||
<p className="max-w-xs">Failed to refresh: {error}</p>
|
||||
<p className="max-w-xs">{t("agents:failedToRefresh", { error })}</p>
|
||||
</TooltipContent>
|
||||
</Tooltip>
|
||||
)}
|
||||
@@ -331,6 +334,7 @@ function ModelPicker({
|
||||
configOptions: SelectConfigOption[];
|
||||
onChange: (patch: Partial<ProfileFormData>) => void;
|
||||
}) {
|
||||
const { t } = useTranslation();
|
||||
const modelConfig = configOptions.find(isModelConfigOption);
|
||||
const modelOptions = modelConfig
|
||||
? configOptionToModelOptions(modelConfig)
|
||||
@@ -358,8 +362,8 @@ function ModelPicker({
|
||||
onConfigChange={(configId, value) =>
|
||||
onChange({ config_options: { ...(profile.config_options ?? {}), [configId]: value } })
|
||||
}
|
||||
placeholder="Select a model..."
|
||||
ariaLabel="Profile start model settings"
|
||||
placeholder={t("agents:selectAModel")}
|
||||
ariaLabel={t("agents:profileStartModelSettings")}
|
||||
/>
|
||||
);
|
||||
}
|
||||
@@ -433,6 +437,7 @@ function CapabilitiesRow({
|
||||
agentName,
|
||||
baselineProfile,
|
||||
}: CapabilitiesRowProps) {
|
||||
const { t } = useTranslation();
|
||||
const hasModes = modes.length > 0;
|
||||
const configOptions = modelConfigOptions(modelConfig);
|
||||
const activeMode = findActiveMode(modes, profile.mode, currentModeId);
|
||||
@@ -442,7 +447,7 @@ function CapabilitiesRow({
|
||||
if (isLoading && models.length === 0) {
|
||||
return (
|
||||
<div className={gapCls}>
|
||||
<Label className={labelCls}>Start model</Label>
|
||||
<Label className={labelCls}>{t("agents:startModel")}</Label>
|
||||
<Skeleton className="h-7 w-full" />
|
||||
</div>
|
||||
);
|
||||
@@ -472,7 +477,7 @@ function CapabilitiesRow({
|
||||
data-settings-dirty={profileModelIsDirty(profile, baselineProfile)}
|
||||
data-settings-dirty-level="container"
|
||||
>
|
||||
<Label className={labelCls}>Start model</Label>
|
||||
<Label className={labelCls}>{t("agents:startModel")}</Label>
|
||||
<ModelPicker
|
||||
profile={profile}
|
||||
models={models}
|
||||
@@ -488,7 +493,7 @@ function CapabilitiesRow({
|
||||
data-settings-dirty={profileModeIsDirty(profile, baselineProfile)}
|
||||
data-settings-dirty-level="container"
|
||||
>
|
||||
<Label className={labelCls}>Start mode</Label>
|
||||
<Label className={labelCls}>{t("agents:startMode")}</Label>
|
||||
<ModePicker
|
||||
profile={profile}
|
||||
modes={modes}
|
||||
@@ -521,21 +526,22 @@ function NameField({
|
||||
onRemove?: () => void;
|
||||
baselineName?: string;
|
||||
}) {
|
||||
const { t } = useTranslation();
|
||||
return (
|
||||
<div className="flex items-center justify-between gap-4">
|
||||
<div className="flex-1 space-y-2">
|
||||
<Label>Profile name</Label>
|
||||
<Label>{t("agents:profileName")}</Label>
|
||||
<Input
|
||||
data-testid="profile-name-input"
|
||||
value={profile.name}
|
||||
onChange={(event) => onChange({ name: event.target.value })}
|
||||
placeholder="Default profile"
|
||||
placeholder={t("agents:defaultProfile")}
|
||||
data-settings-dirty={baselineName !== undefined && profile.name !== baselineName}
|
||||
/>
|
||||
</div>
|
||||
{canRemove && onRemove && (
|
||||
<Button size="sm" variant="ghost" className="cursor-pointer" onClick={onRemove}>
|
||||
Remove
|
||||
{t("agents:remove")}
|
||||
</Button>
|
||||
)}
|
||||
</div>
|
||||
|
||||
@@ -1,6 +1,8 @@
|
||||
"use client";
|
||||
|
||||
import { useState } from "react";
|
||||
import { useTranslation } from "react-i18next";
|
||||
import type { TFunction } from "i18next";
|
||||
import {
|
||||
IconAlertCircle,
|
||||
IconLoader2,
|
||||
@@ -21,6 +23,7 @@ import { HostShellDialog } from "@/components/settings/host-shell-dialog";
|
||||
* users don't mistake the in-flight probe for a failed install.
|
||||
*/
|
||||
export function ProbingPanel() {
|
||||
const { t } = useTranslation();
|
||||
return (
|
||||
<div
|
||||
data-testid="profile-probing-panel"
|
||||
@@ -28,21 +31,22 @@ export function ProbingPanel() {
|
||||
className="flex items-center gap-3 rounded-md border border-muted bg-muted/40 p-3"
|
||||
>
|
||||
<IconLoader2 className="h-4 w-4 shrink-0 animate-spin text-muted-foreground" />
|
||||
<p className="text-sm text-muted-foreground">
|
||||
Probing agent capabilities… this usually takes a few seconds.
|
||||
</p>
|
||||
<p className="text-sm text-muted-foreground">{t("agents:probingCapabilities")}</p>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
function noAuthHint({ isAuth, canLogin }: { isAuth: boolean; canLogin: boolean }): React.ReactNode {
|
||||
function noAuthHint(
|
||||
t: TFunction,
|
||||
{ isAuth, canLogin }: { isAuth: boolean; canLogin: boolean },
|
||||
): React.ReactNode {
|
||||
if (!isAuth) {
|
||||
return "Install the agent CLI, then click Refresh.";
|
||||
return t("agents:noAuthHintNotInstalled");
|
||||
}
|
||||
if (canLogin) {
|
||||
return "Click Open terminal to sign in, then refresh.";
|
||||
return t("agents:noAuthHintLogin");
|
||||
}
|
||||
return "Click Open terminal to launch a shell, run the agent's sign-in command, then refresh.";
|
||||
return t("agents:noAuthHintShell");
|
||||
}
|
||||
|
||||
function NoAuthActions({
|
||||
@@ -56,6 +60,7 @@ function NoAuthActions({
|
||||
onRefresh: () => Promise<void>;
|
||||
isLoading: boolean;
|
||||
}) {
|
||||
const { t } = useTranslation();
|
||||
return (
|
||||
<div className="flex shrink-0 items-center gap-2">
|
||||
{showTerminal && (
|
||||
@@ -68,7 +73,7 @@ function NoAuthActions({
|
||||
data-testid="profile-no-auth-open-terminal"
|
||||
>
|
||||
<IconTerminal2 className="mr-2 h-4 w-4" />
|
||||
Open terminal
|
||||
{t("agents:openTerminal")}
|
||||
</Button>
|
||||
)}
|
||||
<Button
|
||||
@@ -81,7 +86,7 @@ function NoAuthActions({
|
||||
data-testid="profile-no-auth-refresh"
|
||||
>
|
||||
<IconRefresh className={`mr-2 h-4 w-4 ${isLoading ? "animate-spin" : ""}`} />
|
||||
Refresh
|
||||
{t("agents:refresh")}
|
||||
</Button>
|
||||
</div>
|
||||
);
|
||||
@@ -102,9 +107,10 @@ export function NoAuthPanel({
|
||||
error: string | null;
|
||||
rawError: string | null;
|
||||
}) {
|
||||
const { t } = useTranslation();
|
||||
const isAuth = status === "auth_required";
|
||||
const Icon = isAuth ? IconLock : IconPackageOff;
|
||||
const title = isAuth ? "No auth - login required" : "Not installed";
|
||||
const title = isAuth ? t("agents:noAuthLoginRequired") : t("agents:notInstalled");
|
||||
const detail = error || rawError;
|
||||
const [loginOpen, setLoginOpen] = useState(false);
|
||||
const [shellOpen, setShellOpen] = useState(false);
|
||||
@@ -118,7 +124,7 @@ export function NoAuthPanel({
|
||||
)?.login_command;
|
||||
const canLogin = isAuth && Boolean(loginCommand);
|
||||
const showTerminal = isAuth;
|
||||
const hint = noAuthHint({ isAuth, canLogin });
|
||||
const hint = noAuthHint(t, { isAuth, canLogin });
|
||||
const handleOpenTerminal = () => {
|
||||
if (canLogin) setLoginOpen(true);
|
||||
else setShellOpen(true);
|
||||
@@ -143,7 +149,7 @@ export function NoAuthPanel({
|
||||
data-testid="profile-no-auth-details"
|
||||
>
|
||||
<IconAlertCircle className="h-3 w-3" />
|
||||
details
|
||||
{t("agents:details")}
|
||||
</button>
|
||||
</TooltipTrigger>
|
||||
<TooltipContent className="max-w-md">
|
||||
|
||||
@@ -1,6 +1,10 @@
|
||||
"use client";
|
||||
|
||||
import { useEffect, useRef, useState } from "react";
|
||||
import { useTranslation } from "react-i18next";
|
||||
// The WebSocket error handler runs outside React, so it uses the module-level
|
||||
// `t`, which resolves at call time. Components in this file use the hook.
|
||||
import { t as translate } from "@/lib/i18n";
|
||||
import { Terminal } from "@xterm/xterm";
|
||||
import { FitAddon } from "@xterm/addon-fit";
|
||||
import { WebLinksAddon } from "@xterm/addon-web-links";
|
||||
@@ -146,7 +150,7 @@ function openSessionWebSocket(
|
||||
ws.binaryType = "arraybuffer";
|
||||
ws.onmessage = makeWsMessageHandler(term, setters);
|
||||
ws.onerror = () => {
|
||||
setters.setError("Connection error");
|
||||
setters.setError(translate("agents:ptyConnectionError"));
|
||||
setters.setStatus("error");
|
||||
};
|
||||
if (initialInput) {
|
||||
@@ -256,6 +260,7 @@ function PtySessionView({
|
||||
const fitRef = useRef<FitAddon | null>(null);
|
||||
const wsRef = useRef<WebSocket | null>(null);
|
||||
const sessionIDRef = useRef<string | null>(null);
|
||||
const { t } = useTranslation();
|
||||
const [error, setError] = useState<string | null>(null);
|
||||
const [status, setStatus] = useState<SessionStatus>("connecting");
|
||||
const [exitCode, setExitCode] = useState<number | null>(null);
|
||||
@@ -283,11 +288,13 @@ function PtySessionView({
|
||||
className="h-[420px] rounded-md bg-[#0b0b0c] p-2 overflow-hidden"
|
||||
/>
|
||||
{status === "connecting" && (
|
||||
<p className="text-xs text-muted-foreground">Starting session…</p>
|
||||
<p className="text-xs text-muted-foreground">{t("agents:startingSession")}</p>
|
||||
)}
|
||||
{status === "exited" && (
|
||||
<p className="text-xs text-muted-foreground">
|
||||
Session ended{exitCode != null ? ` (exit ${exitCode})` : ""}.
|
||||
{exitCode != null
|
||||
? t("agents:sessionEndedWithCode", { code: exitCode })
|
||||
: t("agents:sessionEnded")}
|
||||
</p>
|
||||
)}
|
||||
{status === "error" && error && <p className="text-xs text-destructive">{error}</p>}
|
||||
@@ -298,7 +305,7 @@ function PtySessionView({
|
||||
className="cursor-pointer"
|
||||
data-testid={`${testIdPrefix ?? "pty"}-done`}
|
||||
>
|
||||
Done
|
||||
{t("agents:done")}
|
||||
</Button>
|
||||
</DialogFooter>
|
||||
</>
|
||||
|
||||
@@ -454,4 +454,70 @@ export const i18nGuardFiles = [
|
||||
// values so the pseudo-locale cannot turn them into dead pointers.
|
||||
"app/settings/integrations/slack/**/*.{ts,tsx}",
|
||||
"components/slack/**/*.{ts,tsx}",
|
||||
// Settings → Agents: the agents list, the per-agent setup page, the agent
|
||||
// profile editor, and the `/settings/agent/:id` redirect. The whole
|
||||
// `app/settings/agents` tree is migrated, plus the agent half of
|
||||
// `components/settings` — the loose `agent-*` / profile / CLI-flag files those
|
||||
// three routes render, and the shared model picker and PTY dialog they reach.
|
||||
//
|
||||
// Deliberately absent, each reached only from a different surface:
|
||||
// `components/settings/model-combobox.tsx` (the task-side CLI profile editor
|
||||
// and Utility Agents), `components/settings/agent-card.tsx` (Office), and
|
||||
// `components/settings/inference-agent-status.tsx` (Utility Agents). The
|
||||
// string counter listed all three for this route; walking the import closure
|
||||
// of the four pages shows none of them is in it.
|
||||
//
|
||||
// Also absent: `components/settings/profile-edit/**` (`env-vars-card.tsx` and
|
||||
// `profile-env-vars-section.tsx`). The profile editor does render the env-vars
|
||||
// card, but that directory is the *executor* profile tree and belongs to the
|
||||
// sibling migration; its strings are the only plain English left on the agent
|
||||
// profile page under the pseudo-locale.
|
||||
//
|
||||
// `agent-save-helpers.ts`, `use-profile-mcp-config.ts`,
|
||||
// `use-agent-profile-settings.ts`, `use-agent-update-dialog-state.ts` and
|
||||
// `agent-profile-page-state.ts` hold no JSX, so `mode: "jsx-only"` never
|
||||
// inspects them; the entries record that they are migrated (the first two
|
||||
// carry MCP parse errors and the last the profile save/delete toasts, all of
|
||||
// which reach the user) and only the pseudo-locale can prove it stays that
|
||||
// way. Same for the WebSocket error in `pty-terminal-dialog.tsx`, which is
|
||||
// set from a non-JSX handler.
|
||||
//
|
||||
// Deliberately left in English, each an identifier the user must read or type
|
||||
// verbatim, all interpolated as values so the pseudo-locale cannot turn them
|
||||
// into dead pointers: the `{prompt}` and `{{model}}` substitution tokens, the
|
||||
// `--my-flag` / `greywall --` / `/init` / `superclaude` examples, the
|
||||
// `mcpServers` JSON key, the MCP server product names (`Playwright MCP`,
|
||||
// `Chrome DevTools MCP`, `Context7 MCP`, `GitHub MCP`) and the Kandev MCP tool
|
||||
// list, and the whole of the assembled command preview. Wire values stay wire
|
||||
// values: the install/update job `status` unions, the capability-probe
|
||||
// `status`, the routing `tier`, the permission `apply_method`, the watcher
|
||||
// `kind`, and every model/mode `id` — only their labels are copy, and those
|
||||
// travel as catalog keys and resolve at render.
|
||||
"app/settings/agent/**/*.{ts,tsx}",
|
||||
"app/settings/agents/**/*.{ts,tsx}",
|
||||
"components/app-sidebar/sections/settings/agents-group.tsx",
|
||||
"components/settings/add-tui-agent-dialog.tsx",
|
||||
"components/settings/agent-login-dialog.tsx",
|
||||
"components/settings/agent-profile-delete-dialog.tsx",
|
||||
"components/settings/agent-profile-page-state.ts",
|
||||
"components/settings/agent-profile-page.tsx",
|
||||
"components/settings/agent-runtime-update-control.tsx",
|
||||
"components/settings/cli-flags-field.tsx",
|
||||
"components/settings/command-prefix-field.tsx",
|
||||
"components/settings/install-agent-card.tsx",
|
||||
"components/settings/installed-agent-card.tsx",
|
||||
"components/settings/mode-combobox.tsx",
|
||||
"components/settings/profile-capability-helpers.tsx",
|
||||
"components/settings/profile-form-fields.tsx",
|
||||
"components/settings/profile-status-panels.tsx",
|
||||
"components/settings/use-agent-update-dialog-state.ts",
|
||||
// Shared with surfaces that are not migrated. The guard is per-file, so the
|
||||
// task-side CLI profile editor, the onboarding dialog, Utility Agents, the
|
||||
// chat model selector and the session tabs are all unaffected.
|
||||
// `host-shell-dialog.tsx` is also opened from a chat action message;
|
||||
// `pty-terminal-dialog.tsx` is reached only through it and the agent login
|
||||
// dialog, both of which are migrated here.
|
||||
"components/model-config-selector.tsx",
|
||||
"components/settings/host-shell-dialog.tsx",
|
||||
"components/settings/pty-terminal-dialog.tsx",
|
||||
];
|
||||
|
||||
@@ -0,0 +1,193 @@
|
||||
{
|
||||
"actionCannotBeUndone": "This action cannot be undone.",
|
||||
"add": "Add",
|
||||
"addProfile": "Add profile",
|
||||
"addTuiAgent": "Add TUI Agent",
|
||||
"addTuiAgentDescription": "Register a CLI tool as a TUI agent. It will appear in the agent list with a default passthrough profile.",
|
||||
"agentCliFlags": "Agent CLI flags",
|
||||
"agentCliFlagsDescription": "Flags passed to the agent CLI on launch. Only enabled entries are applied.",
|
||||
"agentInstallationInProgress": "Agent installation is in progress",
|
||||
"agentNotFound": "Agent not found.",
|
||||
"agentProfiles": "Agent Profiles",
|
||||
"agentProfilesDescription": "Manage existing profiles by agent.",
|
||||
"agentProfileSettings": "{{name}} profile settings",
|
||||
"agentProfilesTitle": "{{name}} Profiles",
|
||||
"approveUpdate": "Approve update",
|
||||
"authenticationRequired": "Authentication required",
|
||||
"authRequiredOpenLoginTerminal": "Authentication required - click to open login terminal",
|
||||
"authRequiredOpenShell": "Authentication required - click to open a shell and sign in",
|
||||
"automaticallyAvailable": "Automatically available",
|
||||
"availableCommandsCount_one": "Available commands ({{count}})",
|
||||
"availableCommandsCount_other": "Available commands ({{count}})",
|
||||
"availableSlashCommands": "Available slash commands",
|
||||
"availableSlashCommandsHelp": "Type these during a session chat to invoke them - e.g. <0>{{example}}</0>.",
|
||||
"availableToInstall": "Available to Install",
|
||||
"availableToInstallDescription": "Click Install to run the agent's install script on the kandev host. Progress streams live; you can start multiple installs in parallel.",
|
||||
"backToAgents": "Back to Agents",
|
||||
"backToModelSettings": "Back to model settings from {{name}}",
|
||||
"builtIn": "Built-in",
|
||||
"cannotDeleteAgentProfile": "Cannot delete agent profile",
|
||||
"changeTierMappingsFirst": "Change these workspace tier mappings before deleting this profile.",
|
||||
"capabilityAuthRequired": "Authentication required. Run the agent CLI in your terminal to authenticate, then refresh.",
|
||||
"capabilityNotInstalled": "Agent CLI not installed.",
|
||||
"capabilityProbeFailed": "Probe failed. Check agent logs for details.",
|
||||
"capabilityProbing": "Checking agent capabilities…",
|
||||
"checkingAuthentication": "Checking authentication",
|
||||
"checkingCapabilitiesAndAuth": "Checking agent capabilities and authentication...",
|
||||
"checkingLatestRuntimeVersion": "Checking the latest runtime version…",
|
||||
"cliFlagsEnabledCount_one": "{{enabled}} of {{count}} enabled",
|
||||
"cliFlagsEnabledCount_other": "{{enabled}} of {{count}} enabled",
|
||||
"command": "Command",
|
||||
"commandPrefix": "Command prefix",
|
||||
"commandPrefixHelp": "Tokens prepended to the agent launch command, so it runs under a sandbox launcher (e.g. <0>{{example}}</0>). The value is shell-tokenised. Leave empty to run the agent directly. Applies to ACP sessions only — it has no effect when the profile uses TUI passthrough.",
|
||||
"commandPreview": "Command Preview",
|
||||
"commandPreviewHelp": "The CLI command that will be executed based on the current settings.",
|
||||
"commandRequired": "Command is required",
|
||||
"commandThatWillRun": "Command that will run",
|
||||
"configured": "Configured",
|
||||
"configureProfilesIntro": "Configure profiles and defaults for this agent.",
|
||||
"conflictQuickChatsTitle": "Quick Chats:",
|
||||
"conflictTasksTitle": "Tasks:",
|
||||
"conflictTierMappingRow": "<0>{{tier}}</0> tier in {{workspace}} for {{provider}}",
|
||||
"conflictTierMappingsTitle": "Workspace tier mappings:",
|
||||
"conflictWatchersTitle": "Watchers (will be disabled):",
|
||||
"copied": "Copied",
|
||||
"copyInstallCommand": "Copy install command",
|
||||
"copyToClipboard": "Copy to clipboard",
|
||||
"create": "Create",
|
||||
"createAgentProfileTitle": "Create {{name}} Profile",
|
||||
"createNewProfile": "Create new profile",
|
||||
"createProfileIntro": "Create a new profile for this agent.",
|
||||
"creating": "Creating...",
|
||||
"defaultProfile": "Default profile",
|
||||
"defaultSuffix": "(default)",
|
||||
"delete": "Delete",
|
||||
"deleteAgent": "Delete Agent",
|
||||
"deleteAgentDescription": "This will permanently remove the agent and all its profiles. This action cannot be undone.",
|
||||
"deleteAgentProfileDescription": "This will permanently delete this profile. This action cannot be undone.",
|
||||
"deleteAgentProfileTitle": "Delete agent profile?",
|
||||
"deleteAgentTitle": "Delete {{name}}?",
|
||||
"deleteAnyway": "Delete Anyway",
|
||||
"deleteAnywayConsequences": "These sessions will no longer be able to use this profile and the listed watchers will be disabled. This action cannot be undone.",
|
||||
"deleteProfile": "Delete profile",
|
||||
"details": "details",
|
||||
"detectedAt": "Detected at {{path}}",
|
||||
"disableSetting": "Disable {{name}}",
|
||||
"displayName": "Display Name",
|
||||
"displayNameRequired": "Display name is required",
|
||||
"done": "Done",
|
||||
"enableMcp": "Enable MCP",
|
||||
"enableMcpDescription": "Allow this profile to use MCP servers during sessions.",
|
||||
"enableSetting": "Enable {{name}}",
|
||||
"everyProfileNeedsNameAndModel": "Every profile needs a name and model.",
|
||||
"exampleValue": "e.g. {{example}}",
|
||||
"failedToCreateAgent": "Failed to create agent",
|
||||
"failedToDeleteProfile": "Failed to delete profile",
|
||||
"failedToLoadCommandPreview": "Failed to load command preview",
|
||||
"failedToLoadMcpConfig": "Failed to load MCP config",
|
||||
"failedToRefresh": "Failed to refresh: {{error}}",
|
||||
"failedToSaveAgent": "Failed to save agent",
|
||||
"failedToSaveMcpConfig": "Failed to save MCP config",
|
||||
"failedToSaveProfile": "Failed to save profile",
|
||||
"filterModels": "Filter models...",
|
||||
"fixInvalidMcpConfig": "Fix invalid MCP configuration before saving.",
|
||||
"fixInvalidMcpJson": "Fix invalid MCP JSON before saving.",
|
||||
"flag": "Flag",
|
||||
"goToAgents": "Go to Agents",
|
||||
"hostTerminal": "Host terminal",
|
||||
"hostTerminalDescription": "Runs a shell on the kandev host. Use it to install or configure agents manually.",
|
||||
"install": "Install",
|
||||
"installationNotDetected": "Installation not detected",
|
||||
"installed": "Installed",
|
||||
"installedAgents": "Installed Agents",
|
||||
"installedAgentsDescription": "Agents detected on this machine are ready to configure.",
|
||||
"installing": "Installing…",
|
||||
"installQueued": "Queued…",
|
||||
"invalidJson": "Invalid JSON",
|
||||
"invalidMcpConfig": "Invalid MCP config",
|
||||
"kandevMcpChip": "✓ {{name}}",
|
||||
"kandevMcpTools": "Tools: {{tools}}",
|
||||
"manageAgentsFromMainPage": "Manage agents from the main Agents page.",
|
||||
"mcpConfigMustBeJsonObject": "MCP servers config must be a JSON object",
|
||||
"mcpConfiguration": "MCP Configuration",
|
||||
"mcpDraftHint": "MCP config will be applied after the profile is saved.",
|
||||
"mcpKeyMustBeJsonObject": "{{key}} must be a JSON object",
|
||||
"mcpPassthroughInjectionHint": "In CLI passthrough mode, kandev injects these MCP servers via {{mechanism}}.",
|
||||
"mcpSaveProfileHint": "Save this profile to configure MCP servers.",
|
||||
"mcpServersHelp": "MCP definitions are stored in the database and resolved per executor at runtime. This does not override your local agent config.",
|
||||
"mcpServersJson": "MCP servers (JSON)",
|
||||
"model": "Model",
|
||||
"modelHeading": "Model",
|
||||
"modelRequiredForAllProfiles": "Model is required for all profiles.",
|
||||
"noAuthHintLogin": "Click Open terminal to sign in, then refresh.",
|
||||
"noAuthHintNotInstalled": "Install the agent CLI, then click Refresh.",
|
||||
"noAuthHintShell": "Click Open terminal to launch a shell, run the agent's sign-in command, then refresh.",
|
||||
"noAuthLoginRequired": "No auth - login required",
|
||||
"noCliFlagsConfigured": "No CLI flags configured. Add one below.",
|
||||
"noCommandPreview": "No command preview available.",
|
||||
"noInstalledAgentsDetected": "No installed agents were detected. Install one below, then click Rescan.",
|
||||
"noModeFound": "No mode found.",
|
||||
"noModelsFound": "No models found.",
|
||||
"notInstalled": "Not installed",
|
||||
"openTerminal": "Open terminal",
|
||||
"pageDescription": "Discover installed agents, install new ones, and manage their profiles.",
|
||||
"popularServers": "Popular servers",
|
||||
"probingCapabilities": "Probing agent capabilities… this usually takes a few seconds.",
|
||||
"profileInUseIntro": "This profile is currently in use. Deleting it will affect the following:",
|
||||
"profileName": "Profile name",
|
||||
"profileNameRequired": "Profile name is required.",
|
||||
"profileNameRequiredDescription": "Please enter a profile name before saving.",
|
||||
"profileNameRequiredTitle": "Profile name is required",
|
||||
"profileNotFound": "Profile not found.",
|
||||
"profileSettings": "Profile settings",
|
||||
"profileStartModelSettings": "Profile start model settings",
|
||||
"ptyConnectionError": "Connection error",
|
||||
"promptTokenHint": "<0>{{token}}</0> will be replaced with your task description or follow-up message.",
|
||||
"refresh": "Refresh",
|
||||
"refreshCapabilitiesTooltip": "Refresh agent capabilities (models + modes)",
|
||||
"remove": "Remove",
|
||||
"removeCliFlag": "Remove {{name}}",
|
||||
"removeThisProfile": "Remove this profile",
|
||||
"requestFailed": "Request failed",
|
||||
"rescan": "Rescan",
|
||||
"retry": "Retry",
|
||||
"retryUpdate": "Retry update",
|
||||
"retryVersionCheck": "Retry version check",
|
||||
"reviewUpdateBeforeApplying": "Review the update before Kandev changes this host runtime.",
|
||||
"runtimeAlreadyUpToDate": "Runtime already up to date.",
|
||||
"runtimeUpdatedRefreshFailed": "Runtime updated, but capabilities could not be refreshed: {{error}}",
|
||||
"runtimeUpdatedSuccess": "Runtime updated successfully.",
|
||||
"runtimeUpdateExplainer": "This updates the managed runtime on this Kandev host, then refreshes its models and capabilities.",
|
||||
"runtimeUpdateFailed": "The runtime update failed.",
|
||||
"runtimeUpdateSessionsNote": "Active sessions keep running; only later probes and launches use the refreshed runtime.",
|
||||
"runtimeVersion": "Runtime version",
|
||||
"scanningForInstalledAgents": "Scanning for installed agents...",
|
||||
"searchModes": "Search modes...",
|
||||
"selectAModel": "Select a model...",
|
||||
"sessionEnded": "Session ended.",
|
||||
"sessionEndedWithCode": "Session ended (exit {{code}}).",
|
||||
"setupProfile": "Setup Profile",
|
||||
"signInToAgent": "Sign in: {{name}}",
|
||||
"startingSession": "Starting session…",
|
||||
"startMode": "Start mode",
|
||||
"startModel": "Start model",
|
||||
"terminal": "Terminal",
|
||||
"tuiCommandHelp": "Binary name looked up on PATH. Use <0>{{token}}</0> to insert the model value.",
|
||||
"tuiModelHelp": "Profile label shown in the agent selector dropdown",
|
||||
"unableToStartUpdate": "Unable to start update: {{error}}",
|
||||
"untitledQuickChat": "Untitled quick chat",
|
||||
"untitledTask": "Untitled task",
|
||||
"updateAgent": "Update {{name}}",
|
||||
"updatePhaseQueued": "Update queued…",
|
||||
"updatePhaseRefreshing": "Refreshing agent capabilities…",
|
||||
"updatePhaseResolving": "Checking latest version…",
|
||||
"updatePhaseUpdating": "Updating runtime…",
|
||||
"upToDate": "Up to date",
|
||||
"valueOptional": "value (optional)",
|
||||
"valueOptionalLabel": "Value (optional)",
|
||||
"valuePlaceholder": "value",
|
||||
"watcherKindGithubIssue": "GitHub Issues",
|
||||
"watcherKindGithubReview": "GitHub PR Reviews",
|
||||
"watcherKindJira": "Jira",
|
||||
"watcherKindLinear": "Linear"
|
||||
}
|
||||
@@ -0,0 +1,193 @@
|
||||
{
|
||||
"actionCannotBeUndone": "Ţĥĩś àćţĩōń ćàńńōţ ƀē ũńďōńē.",
|
||||
"add": "Àďď",
|
||||
"addProfile": "Àďď ƥŕōƒĩĺē",
|
||||
"addTuiAgent": "Àďď ŢŨĨ Àĝēńţ",
|
||||
"addTuiAgentDescription": "Ŕēĝĩśţēŕ à ĆĹĨ ţōōĺ àś à ŢŨĨ àĝēńţ. Ĩţ ŵĩĺĺ àƥƥēàŕ ĩń ţĥē àĝēńţ ĺĩśţ ŵĩţĥ à ďēƒàũĺţ ƥàśśţĥŕōũĝĥ ƥŕōƒĩĺē.",
|
||||
"agentCliFlags": "Àĝēńţ ĆĹĨ ƒĺàĝś",
|
||||
"agentCliFlagsDescription": "Ƒĺàĝś ƥàśśēď ţō ţĥē àĝēńţ ĆĹĨ ōń ĺàũńćĥ. Ōńĺŷ ēńàƀĺēď ēńţŕĩēś àŕē àƥƥĺĩēď.",
|
||||
"agentInstallationInProgress": "Àĝēńţ ĩńśţàĺĺàţĩōń ĩś ĩń ƥŕōĝŕēśś",
|
||||
"agentNotFound": "Àĝēńţ ńōţ ƒōũńď.",
|
||||
"agentProfiles": "Àĝēńţ Ƥŕōƒĩĺēś",
|
||||
"agentProfilesDescription": "Ḿàńàĝē ēxĩśţĩńĝ ƥŕōƒĩĺēś ƀŷ àĝēńţ.",
|
||||
"agentProfileSettings": "{{name}} ƥŕōƒĩĺē śēţţĩńĝś",
|
||||
"agentProfilesTitle": "{{name}} Ƥŕōƒĩĺēś",
|
||||
"approveUpdate": "Àƥƥŕōvē ũƥďàţē",
|
||||
"authenticationRequired": "Àũţĥēńţĩćàţĩōń ŕēqũĩŕēď",
|
||||
"authRequiredOpenLoginTerminal": "Àũţĥēńţĩćàţĩōń ŕēqũĩŕēď - ćĺĩćķ ţō ōƥēń ĺōĝĩń ţēŕḿĩńàĺ",
|
||||
"authRequiredOpenShell": "Àũţĥēńţĩćàţĩōń ŕēqũĩŕēď - ćĺĩćķ ţō ōƥēń à śĥēĺĺ àńď śĩĝń ĩń",
|
||||
"automaticallyAvailable": "Àũţōḿàţĩćàĺĺŷ àvàĩĺàƀĺē",
|
||||
"availableCommandsCount_one": "Àvàĩĺàƀĺē ćōḿḿàńďś ({{count}})",
|
||||
"availableCommandsCount_other": "Àvàĩĺàƀĺē ćōḿḿàńďś ({{count}})",
|
||||
"availableSlashCommands": "Àvàĩĺàƀĺē śĺàśĥ ćōḿḿàńďś",
|
||||
"availableSlashCommandsHelp": "Ţŷƥē ţĥēśē ďũŕĩńĝ à śēśśĩōń ćĥàţ ţō ĩńvōķē ţĥēḿ - ē.ĝ. <0>{{example}}</0>.",
|
||||
"availableToInstall": "Àvàĩĺàƀĺē ţō Ĩńśţàĺĺ",
|
||||
"availableToInstallDescription": "Ćĺĩćķ Ĩńśţàĺĺ ţō ŕũń ţĥē àĝēńţ'ś ĩńśţàĺĺ śćŕĩƥţ ōń ţĥē ķàńďēv ĥōśţ. Ƥŕōĝŕēśś śţŕēàḿś ĺĩvē; ŷōũ ćàń śţàŕţ ḿũĺţĩƥĺē ĩńśţàĺĺś ĩń ƥàŕàĺĺēĺ.",
|
||||
"backToAgents": "Ɓàćķ ţō Àĝēńţś",
|
||||
"backToModelSettings": "Ɓàćķ ţō ḿōďēĺ śēţţĩńĝś ƒŕōḿ {{name}}",
|
||||
"builtIn": "Ɓũĩĺţ-ĩń",
|
||||
"cannotDeleteAgentProfile": "Ćàńńōţ ďēĺēţē àĝēńţ ƥŕōƒĩĺē",
|
||||
"changeTierMappingsFirst": "Ćĥàńĝē ţĥēśē ŵōŕķśƥàćē ţĩēŕ ḿàƥƥĩńĝś ƀēƒōŕē ďēĺēţĩńĝ ţĥĩś ƥŕōƒĩĺē.",
|
||||
"capabilityAuthRequired": "Àũţĥēńţĩćàţĩōń ŕēqũĩŕēď. Ŕũń ţĥē àĝēńţ ĆĹĨ ĩń ŷōũŕ ţēŕḿĩńàĺ ţō àũţĥēńţĩćàţē, ţĥēń ŕēƒŕēśĥ.",
|
||||
"capabilityNotInstalled": "Àĝēńţ ĆĹĨ ńōţ ĩńśţàĺĺēď.",
|
||||
"capabilityProbeFailed": "Ƥŕōƀē ƒàĩĺēď. Ćĥēćķ àĝēńţ ĺōĝś ƒōŕ ďēţàĩĺś.",
|
||||
"capabilityProbing": "Ćĥēćķĩńĝ àĝēńţ ćàƥàƀĩĺĩţĩēś…",
|
||||
"checkingAuthentication": "Ćĥēćķĩńĝ àũţĥēńţĩćàţĩōń",
|
||||
"checkingCapabilitiesAndAuth": "Ćĥēćķĩńĝ àĝēńţ ćàƥàƀĩĺĩţĩēś àńď àũţĥēńţĩćàţĩōń...",
|
||||
"checkingLatestRuntimeVersion": "Ćĥēćķĩńĝ ţĥē ĺàţēśţ ŕũńţĩḿē vēŕśĩōń…",
|
||||
"cliFlagsEnabledCount_one": "{{enabled}} ōƒ {{count}} ēńàƀĺēď",
|
||||
"cliFlagsEnabledCount_other": "{{enabled}} ōƒ {{count}} ēńàƀĺēď",
|
||||
"command": "Ćōḿḿàńď",
|
||||
"commandPrefix": "Ćōḿḿàńď ƥŕēƒĩx",
|
||||
"commandPrefixHelp": "Ţōķēńś ƥŕēƥēńďēď ţō ţĥē àĝēńţ ĺàũńćĥ ćōḿḿàńď, śō ĩţ ŕũńś ũńďēŕ à śàńďƀōx ĺàũńćĥēŕ (ē.ĝ. <0>{{example}}</0>). Ţĥē vàĺũē ĩś śĥēĺĺ-ţōķēńĩśēď. Ĺēàvē ēḿƥţŷ ţō ŕũń ţĥē àĝēńţ ďĩŕēćţĺŷ. Àƥƥĺĩēś ţō ÀĆƤ śēśśĩōńś ōńĺŷ — ĩţ ĥàś ńō ēƒƒēćţ ŵĥēń ţĥē ƥŕōƒĩĺē ũśēś ŢŨĨ ƥàśśţĥŕōũĝĥ.",
|
||||
"commandPreview": "Ćōḿḿàńď Ƥŕēvĩēŵ",
|
||||
"commandPreviewHelp": "Ţĥē ĆĹĨ ćōḿḿàńď ţĥàţ ŵĩĺĺ ƀē ēxēćũţēď ƀàśēď ōń ţĥē ćũŕŕēńţ śēţţĩńĝś.",
|
||||
"commandRequired": "Ćōḿḿàńď ĩś ŕēqũĩŕēď",
|
||||
"commandThatWillRun": "Ćōḿḿàńď ţĥàţ ŵĩĺĺ ŕũń",
|
||||
"configured": "Ćōńƒĩĝũŕēď",
|
||||
"configureProfilesIntro": "Ćōńƒĩĝũŕē ƥŕōƒĩĺēś àńď ďēƒàũĺţś ƒōŕ ţĥĩś àĝēńţ.",
|
||||
"conflictQuickChatsTitle": "Qũĩćķ Ćĥàţś:",
|
||||
"conflictTasksTitle": "Ţàśķś:",
|
||||
"conflictTierMappingRow": "<0>{{tier}}</0> ţĩēŕ ĩń {{workspace}} ƒōŕ {{provider}}",
|
||||
"conflictTierMappingsTitle": "Ŵōŕķśƥàćē ţĩēŕ ḿàƥƥĩńĝś:",
|
||||
"conflictWatchersTitle": "Ŵàţćĥēŕś (ŵĩĺĺ ƀē ďĩśàƀĺēď):",
|
||||
"copied": "Ćōƥĩēď",
|
||||
"copyInstallCommand": "Ćōƥŷ ĩńśţàĺĺ ćōḿḿàńď",
|
||||
"copyToClipboard": "Ćōƥŷ ţō ćĺĩƥƀōàŕď",
|
||||
"create": "Ćŕēàţē",
|
||||
"createAgentProfileTitle": "Ćŕēàţē {{name}} Ƥŕōƒĩĺē",
|
||||
"createNewProfile": "Ćŕēàţē ńēŵ ƥŕōƒĩĺē",
|
||||
"createProfileIntro": "Ćŕēàţē à ńēŵ ƥŕōƒĩĺē ƒōŕ ţĥĩś àĝēńţ.",
|
||||
"creating": "Ćŕēàţĩńĝ...",
|
||||
"defaultProfile": "Ďēƒàũĺţ ƥŕōƒĩĺē",
|
||||
"defaultSuffix": "(ďēƒàũĺţ)",
|
||||
"delete": "Ďēĺēţē",
|
||||
"deleteAgent": "Ďēĺēţē Àĝēńţ",
|
||||
"deleteAgentDescription": "Ţĥĩś ŵĩĺĺ ƥēŕḿàńēńţĺŷ ŕēḿōvē ţĥē àĝēńţ àńď àĺĺ ĩţś ƥŕōƒĩĺēś. Ţĥĩś àćţĩōń ćàńńōţ ƀē ũńďōńē.",
|
||||
"deleteAgentProfileDescription": "Ţĥĩś ŵĩĺĺ ƥēŕḿàńēńţĺŷ ďēĺēţē ţĥĩś ƥŕōƒĩĺē. Ţĥĩś àćţĩōń ćàńńōţ ƀē ũńďōńē.",
|
||||
"deleteAgentProfileTitle": "Ďēĺēţē àĝēńţ ƥŕōƒĩĺē?",
|
||||
"deleteAgentTitle": "Ďēĺēţē {{name}}?",
|
||||
"deleteAnyway": "Ďēĺēţē Àńŷŵàŷ",
|
||||
"deleteAnywayConsequences": "Ţĥēśē śēśśĩōńś ŵĩĺĺ ńō ĺōńĝēŕ ƀē àƀĺē ţō ũśē ţĥĩś ƥŕōƒĩĺē àńď ţĥē ĺĩśţēď ŵàţćĥēŕś ŵĩĺĺ ƀē ďĩśàƀĺēď. Ţĥĩś àćţĩōń ćàńńōţ ƀē ũńďōńē.",
|
||||
"deleteProfile": "Ďēĺēţē ƥŕōƒĩĺē",
|
||||
"details": "ďēţàĩĺś",
|
||||
"detectedAt": "Ďēţēćţēď àţ {{path}}",
|
||||
"disableSetting": "Ďĩśàƀĺē {{name}}",
|
||||
"displayName": "Ďĩśƥĺàŷ Ńàḿē",
|
||||
"displayNameRequired": "Ďĩśƥĺàŷ ńàḿē ĩś ŕēqũĩŕēď",
|
||||
"done": "Ďōńē",
|
||||
"enableMcp": "Ēńàƀĺē ḾĆƤ",
|
||||
"enableMcpDescription": "Àĺĺōŵ ţĥĩś ƥŕōƒĩĺē ţō ũśē ḾĆƤ śēŕvēŕś ďũŕĩńĝ śēśśĩōńś.",
|
||||
"enableSetting": "Ēńàƀĺē {{name}}",
|
||||
"everyProfileNeedsNameAndModel": "Ēvēŕŷ ƥŕōƒĩĺē ńēēďś à ńàḿē àńď ḿōďēĺ.",
|
||||
"exampleValue": "ē.ĝ. {{example}}",
|
||||
"failedToCreateAgent": "Ƒàĩĺēď ţō ćŕēàţē àĝēńţ",
|
||||
"failedToDeleteProfile": "Ƒàĩĺēď ţō ďēĺēţē ƥŕōƒĩĺē",
|
||||
"failedToLoadCommandPreview": "Ƒàĩĺēď ţō ĺōàď ćōḿḿàńď ƥŕēvĩēŵ",
|
||||
"failedToLoadMcpConfig": "Ƒàĩĺēď ţō ĺōàď ḾĆƤ ćōńƒĩĝ",
|
||||
"failedToRefresh": "Ƒàĩĺēď ţō ŕēƒŕēśĥ: {{error}}",
|
||||
"failedToSaveAgent": "Ƒàĩĺēď ţō śàvē àĝēńţ",
|
||||
"failedToSaveMcpConfig": "Ƒàĩĺēď ţō śàvē ḾĆƤ ćōńƒĩĝ",
|
||||
"failedToSaveProfile": "Ƒàĩĺēď ţō śàvē ƥŕōƒĩĺē",
|
||||
"filterModels": "Ƒĩĺţēŕ ḿōďēĺś...",
|
||||
"fixInvalidMcpConfig": "Ƒĩx ĩńvàĺĩď ḾĆƤ ćōńƒĩĝũŕàţĩōń ƀēƒōŕē śàvĩńĝ.",
|
||||
"fixInvalidMcpJson": "Ƒĩx ĩńvàĺĩď ḾĆƤ ĴŚŌŃ ƀēƒōŕē śàvĩńĝ.",
|
||||
"flag": "Ƒĺàĝ",
|
||||
"goToAgents": "Ĝō ţō Àĝēńţś",
|
||||
"hostTerminal": "Ĥōśţ ţēŕḿĩńàĺ",
|
||||
"hostTerminalDescription": "Ŕũńś à śĥēĺĺ ōń ţĥē ķàńďēv ĥōśţ. Ũśē ĩţ ţō ĩńśţàĺĺ ōŕ ćōńƒĩĝũŕē àĝēńţś ḿàńũàĺĺŷ.",
|
||||
"install": "Ĩńśţàĺĺ",
|
||||
"installationNotDetected": "Ĩńśţàĺĺàţĩōń ńōţ ďēţēćţēď",
|
||||
"installed": "Ĩńśţàĺĺēď",
|
||||
"installedAgents": "Ĩńśţàĺĺēď Àĝēńţś",
|
||||
"installedAgentsDescription": "Àĝēńţś ďēţēćţēď ōń ţĥĩś ḿàćĥĩńē àŕē ŕēàďŷ ţō ćōńƒĩĝũŕē.",
|
||||
"installing": "Ĩńśţàĺĺĩńĝ…",
|
||||
"installQueued": "Qũēũēď…",
|
||||
"invalidJson": "Ĩńvàĺĩď ĴŚŌŃ",
|
||||
"invalidMcpConfig": "Ĩńvàĺĩď ḾĆƤ ćōńƒĩĝ",
|
||||
"kandevMcpChip": "✓ {{name}}",
|
||||
"kandevMcpTools": "Ţōōĺś: {{tools}}",
|
||||
"manageAgentsFromMainPage": "Ḿàńàĝē àĝēńţś ƒŕōḿ ţĥē ḿàĩń Àĝēńţś ƥàĝē.",
|
||||
"mcpConfigMustBeJsonObject": "ḾĆƤ śēŕvēŕś ćōńƒĩĝ ḿũśţ ƀē à ĴŚŌŃ ōƀĵēćţ",
|
||||
"mcpConfiguration": "ḾĆƤ Ćōńƒĩĝũŕàţĩōń",
|
||||
"mcpDraftHint": "ḾĆƤ ćōńƒĩĝ ŵĩĺĺ ƀē àƥƥĺĩēď àƒţēŕ ţĥē ƥŕōƒĩĺē ĩś śàvēď.",
|
||||
"mcpKeyMustBeJsonObject": "{{key}} ḿũśţ ƀē à ĴŚŌŃ ōƀĵēćţ",
|
||||
"mcpPassthroughInjectionHint": "Ĩń ĆĹĨ ƥàśśţĥŕōũĝĥ ḿōďē, ķàńďēv ĩńĵēćţś ţĥēśē ḾĆƤ śēŕvēŕś vĩà {{mechanism}}.",
|
||||
"mcpSaveProfileHint": "Śàvē ţĥĩś ƥŕōƒĩĺē ţō ćōńƒĩĝũŕē ḾĆƤ śēŕvēŕś.",
|
||||
"mcpServersHelp": "ḾĆƤ ďēƒĩńĩţĩōńś àŕē śţōŕēď ĩń ţĥē ďàţàƀàśē àńď ŕēśōĺvēď ƥēŕ ēxēćũţōŕ àţ ŕũńţĩḿē. Ţĥĩś ďōēś ńōţ ōvēŕŕĩďē ŷōũŕ ĺōćàĺ àĝēńţ ćōńƒĩĝ.",
|
||||
"mcpServersJson": "ḾĆƤ śēŕvēŕś (ĴŚŌŃ)",
|
||||
"model": "Ḿōďēĺ",
|
||||
"modelHeading": "Ḿōďēĺ",
|
||||
"modelRequiredForAllProfiles": "Ḿōďēĺ ĩś ŕēqũĩŕēď ƒōŕ àĺĺ ƥŕōƒĩĺēś.",
|
||||
"noAuthHintLogin": "Ćĺĩćķ Ōƥēń ţēŕḿĩńàĺ ţō śĩĝń ĩń, ţĥēń ŕēƒŕēśĥ.",
|
||||
"noAuthHintNotInstalled": "Ĩńśţàĺĺ ţĥē àĝēńţ ĆĹĨ, ţĥēń ćĺĩćķ Ŕēƒŕēśĥ.",
|
||||
"noAuthHintShell": "Ćĺĩćķ Ōƥēń ţēŕḿĩńàĺ ţō ĺàũńćĥ à śĥēĺĺ, ŕũń ţĥē àĝēńţ'ś śĩĝń-ĩń ćōḿḿàńď, ţĥēń ŕēƒŕēśĥ.",
|
||||
"noAuthLoginRequired": "Ńō àũţĥ - ĺōĝĩń ŕēqũĩŕēď",
|
||||
"noCliFlagsConfigured": "Ńō ĆĹĨ ƒĺàĝś ćōńƒĩĝũŕēď. Àďď ōńē ƀēĺōŵ.",
|
||||
"noCommandPreview": "Ńō ćōḿḿàńď ƥŕēvĩēŵ àvàĩĺàƀĺē.",
|
||||
"noInstalledAgentsDetected": "Ńō ĩńśţàĺĺēď àĝēńţś ŵēŕē ďēţēćţēď. Ĩńśţàĺĺ ōńē ƀēĺōŵ, ţĥēń ćĺĩćķ Ŕēśćàń.",
|
||||
"noModeFound": "Ńō ḿōďē ƒōũńď.",
|
||||
"noModelsFound": "Ńō ḿōďēĺś ƒōũńď.",
|
||||
"notInstalled": "Ńōţ ĩńśţàĺĺēď",
|
||||
"openTerminal": "Ōƥēń ţēŕḿĩńàĺ",
|
||||
"pageDescription": "Ďĩśćōvēŕ ĩńśţàĺĺēď àĝēńţś, ĩńśţàĺĺ ńēŵ ōńēś, àńď ḿàńàĝē ţĥēĩŕ ƥŕōƒĩĺēś.",
|
||||
"popularServers": "Ƥōƥũĺàŕ śēŕvēŕś",
|
||||
"probingCapabilities": "Ƥŕōƀĩńĝ àĝēńţ ćàƥàƀĩĺĩţĩēś… ţĥĩś ũśũàĺĺŷ ţàķēś à ƒēŵ śēćōńďś.",
|
||||
"profileInUseIntro": "Ţĥĩś ƥŕōƒĩĺē ĩś ćũŕŕēńţĺŷ ĩń ũśē. Ďēĺēţĩńĝ ĩţ ŵĩĺĺ àƒƒēćţ ţĥē ƒōĺĺōŵĩńĝ:",
|
||||
"profileName": "Ƥŕōƒĩĺē ńàḿē",
|
||||
"profileNameRequired": "Ƥŕōƒĩĺē ńàḿē ĩś ŕēqũĩŕēď.",
|
||||
"profileNameRequiredDescription": "Ƥĺēàśē ēńţēŕ à ƥŕōƒĩĺē ńàḿē ƀēƒōŕē śàvĩńĝ.",
|
||||
"profileNameRequiredTitle": "Ƥŕōƒĩĺē ńàḿē ĩś ŕēqũĩŕēď",
|
||||
"profileNotFound": "Ƥŕōƒĩĺē ńōţ ƒōũńď.",
|
||||
"profileSettings": "Ƥŕōƒĩĺē śēţţĩńĝś",
|
||||
"profileStartModelSettings": "Ƥŕōƒĩĺē śţàŕţ ḿōďēĺ śēţţĩńĝś",
|
||||
"ptyConnectionError": "Ćōńńēćţĩōń ēŕŕōŕ",
|
||||
"promptTokenHint": "<0>{{token}}</0> ŵĩĺĺ ƀē ŕēƥĺàćēď ŵĩţĥ ŷōũŕ ţàśķ ďēśćŕĩƥţĩōń ōŕ ƒōĺĺōŵ-ũƥ ḿēśśàĝē.",
|
||||
"refresh": "Ŕēƒŕēśĥ",
|
||||
"refreshCapabilitiesTooltip": "Ŕēƒŕēśĥ àĝēńţ ćàƥàƀĩĺĩţĩēś (ḿōďēĺś + ḿōďēś)",
|
||||
"remove": "Ŕēḿōvē",
|
||||
"removeCliFlag": "Ŕēḿōvē {{name}}",
|
||||
"removeThisProfile": "Ŕēḿōvē ţĥĩś ƥŕōƒĩĺē",
|
||||
"requestFailed": "Ŕēqũēśţ ƒàĩĺēď",
|
||||
"rescan": "Ŕēśćàń",
|
||||
"retry": "Ŕēţŕŷ",
|
||||
"retryUpdate": "Ŕēţŕŷ ũƥďàţē",
|
||||
"retryVersionCheck": "Ŕēţŕŷ vēŕśĩōń ćĥēćķ",
|
||||
"reviewUpdateBeforeApplying": "Ŕēvĩēŵ ţĥē ũƥďàţē ƀēƒōŕē Ķàńďēv ćĥàńĝēś ţĥĩś ĥōśţ ŕũńţĩḿē.",
|
||||
"runtimeAlreadyUpToDate": "Ŕũńţĩḿē àĺŕēàďŷ ũƥ ţō ďàţē.",
|
||||
"runtimeUpdatedRefreshFailed": "Ŕũńţĩḿē ũƥďàţēď, ƀũţ ćàƥàƀĩĺĩţĩēś ćōũĺď ńōţ ƀē ŕēƒŕēśĥēď: {{error}}",
|
||||
"runtimeUpdatedSuccess": "Ŕũńţĩḿē ũƥďàţēď śũććēśśƒũĺĺŷ.",
|
||||
"runtimeUpdateExplainer": "Ţĥĩś ũƥďàţēś ţĥē ḿàńàĝēď ŕũńţĩḿē ōń ţĥĩś Ķàńďēv ĥōśţ, ţĥēń ŕēƒŕēśĥēś ĩţś ḿōďēĺś àńď ćàƥàƀĩĺĩţĩēś.",
|
||||
"runtimeUpdateFailed": "Ţĥē ŕũńţĩḿē ũƥďàţē ƒàĩĺēď.",
|
||||
"runtimeUpdateSessionsNote": "Àćţĩvē śēśśĩōńś ķēēƥ ŕũńńĩńĝ; ōńĺŷ ĺàţēŕ ƥŕōƀēś àńď ĺàũńćĥēś ũśē ţĥē ŕēƒŕēśĥēď ŕũńţĩḿē.",
|
||||
"runtimeVersion": "Ŕũńţĩḿē vēŕśĩōń",
|
||||
"scanningForInstalledAgents": "Śćàńńĩńĝ ƒōŕ ĩńśţàĺĺēď àĝēńţś...",
|
||||
"searchModes": "Śēàŕćĥ ḿōďēś...",
|
||||
"selectAModel": "Śēĺēćţ à ḿōďēĺ...",
|
||||
"sessionEnded": "Śēśśĩōń ēńďēď.",
|
||||
"sessionEndedWithCode": "Śēśśĩōń ēńďēď (ēxĩţ {{code}}).",
|
||||
"setupProfile": "Śēţũƥ Ƥŕōƒĩĺē",
|
||||
"signInToAgent": "Śĩĝń ĩń: {{name}}",
|
||||
"startingSession": "Śţàŕţĩńĝ śēśśĩōń…",
|
||||
"startMode": "Śţàŕţ ḿōďē",
|
||||
"startModel": "Śţàŕţ ḿōďēĺ",
|
||||
"terminal": "Ţēŕḿĩńàĺ",
|
||||
"tuiCommandHelp": "Ɓĩńàŕŷ ńàḿē ĺōōķēď ũƥ ōń ƤÀŢĤ. Ũśē <0>{{token}}</0> ţō ĩńśēŕţ ţĥē ḿōďēĺ vàĺũē.",
|
||||
"tuiModelHelp": "Ƥŕōƒĩĺē ĺàƀēĺ śĥōŵń ĩń ţĥē àĝēńţ śēĺēćţōŕ ďŕōƥďōŵń",
|
||||
"unableToStartUpdate": "Ũńàƀĺē ţō śţàŕţ ũƥďàţē: {{error}}",
|
||||
"untitledQuickChat": "Ũńţĩţĺēď qũĩćķ ćĥàţ",
|
||||
"untitledTask": "Ũńţĩţĺēď ţàśķ",
|
||||
"updateAgent": "Ũƥďàţē {{name}}",
|
||||
"updatePhaseQueued": "Ũƥďàţē qũēũēď…",
|
||||
"updatePhaseRefreshing": "Ŕēƒŕēśĥĩńĝ àĝēńţ ćàƥàƀĩĺĩţĩēś…",
|
||||
"updatePhaseResolving": "Ćĥēćķĩńĝ ĺàţēśţ vēŕśĩōń…",
|
||||
"updatePhaseUpdating": "Ũƥďàţĩńĝ ŕũńţĩḿē…",
|
||||
"upToDate": "Ũƥ ţō ďàţē",
|
||||
"valueOptional": "vàĺũē (ōƥţĩōńàĺ)",
|
||||
"valueOptionalLabel": "Vàĺũē (ōƥţĩōńàĺ)",
|
||||
"valuePlaceholder": "vàĺũē",
|
||||
"watcherKindGithubIssue": "ĜĩţĤũƀ Ĩśśũēś",
|
||||
"watcherKindGithubReview": "ĜĩţĤũƀ ƤŔ Ŕēvĩēŵś",
|
||||
"watcherKindJira": "Ĵĩŕà",
|
||||
"watcherKindLinear": "Ĺĩńēàŕ"
|
||||
}
|
||||
Reference in New Issue
Block a user