Files
kandev/apps/web/components/onboarding/step-agents.tsx
T
João Salavisa f09e938673 feat(i18n): localize the last batch of live surfaces before Office (#2357)
Migrates the eleven remaining files that `i18next/no-literal-string` flagged
outside `app/office`, `app/demo` and the task-create surfaces owned by #2326:
the session prepare panel, the onboarding agents step, the host system-metrics
readout, the system-health issues dialog, the shared panel search bar, the
mermaid diagram block, the settings agent card, both release-notes surfaces,
and the shared data-table pair.

Findings drop 884 -> 826 (-58) and the guard allowlist grows 442 -> 453, file
by file rather than by directory since every one of these directories still
holds un-migrated files.

Defects repaired beyond the codemod's output:

- `data-table-pagination.tsx` rendered `{count} results` as a bare JSX
  fragment, invisible to both plural gates. It is now one interpolated key
  with `_one`/`_other`, as are the health-dialog issue count and the
  release-notes "N new releases" helper.
- Two mixed literal+expression blocks were half-converted, rendering
  identically in English while being structurally untranslatable. Both were
  rebuilt as a single message.
- The codemod re-sorted `settings.json` into `localeCompare` order and
  re-encoded every non-ASCII character to `\uXXXX` under a comment claiming it
  matched the existing files. No catalog is stored escaped and only `common`
  is in that order, so both halves were reverted; all four catalogs are
  additions-only.
- Copy in default parameters and plain helpers is invisible to the lint rule,
  so `Search…`, the six prepare-panel header labels, the setup-script step name
  and error, the metric labels, `lastUpdatedText` and `buildDescription` were
  migrated by hand.

`task:preparingEnvironment` was already taken by #2326 holding "Preparing
environment" without the ellipsis this surface renders, so the prepare panel
uses `preparingEnvironmentEllipsis` rather than silently changing its copy.

Units, wire ids, the `{workspaceId}` URL token, the `"ellipsis"` page sentinel
and every mermaid theme/CSS value stay English as protocol.
2026-08-07 11:34:35 +02:00

306 lines
10 KiB
TypeScript

"use client";
import { useCallback, useState } from "react";
import {
IconAlertTriangle,
IconCheck,
IconClipboard,
IconChevronDown,
IconLoader2,
IconDownload,
IconExternalLink,
IconLock,
} from "@tabler/icons-react";
import { Collapsible, CollapsibleTrigger, CollapsibleContent } from "@kandev/ui/collapsible";
import { Tooltip, TooltipContent, TooltipTrigger } from "@kandev/ui/tooltip";
import { AgentLogo } from "@/components/agent-logo";
import { ProfileFormFields, type ProfileFormData } from "@/components/settings/profile-form-fields";
import type { AvailableAgent, ToolStatus } from "@/lib/types/http";
import { copyToClipboard } from "@/lib/utils/copy-to-clipboard";
import { Trans, useTranslation } from "react-i18next";
export type AgentSetting = {
profileId: string;
formData: ProfileFormData;
dirty: boolean;
};
function CopyButton({ text }: { text: string }) {
const [copied, setCopied] = useState(false);
const handleCopy = useCallback(async () => {
if (await copyToClipboard(text)) {
setCopied(true);
setTimeout(() => setCopied(false), 2000);
}
}, [text]);
return (
<button
type="button"
onClick={handleCopy}
className="p-0.5 cursor-pointer hover:text-foreground text-muted-foreground transition-colors shrink-0"
>
{copied ? (
<IconCheck className="h-3 w-3 text-green-500" />
) : (
<IconClipboard className="h-3 w-3" />
)}
</button>
);
}
function StatusPill({ status, error }: { status: string; error?: string }) {
const { t } = useTranslation();
switch (status) {
case "auth_required": {
const pill = (
<span className="flex items-center gap-1 text-xs text-amber-600 dark:text-amber-400">
<IconLock className="h-3.5 w-3.5" />
{t("common:noAuth")}
</span>
);
if (!error) return pill;
return (
<Tooltip>
<TooltipTrigger asChild>{pill}</TooltipTrigger>
<TooltipContent className="max-w-sm">{error}</TooltipContent>
</Tooltip>
);
}
case "not_installed":
return (
<span className="flex items-center gap-1 text-xs text-muted-foreground">
{t("common:notInstalled")}
</span>
);
case "failed": {
const pill = (
<span className="flex items-center gap-1 text-xs text-red-600 dark:text-red-400">
<IconAlertTriangle className="h-3.5 w-3.5" />
{t("common:error")}
</span>
);
if (!error) return pill;
return (
<Tooltip>
<TooltipTrigger asChild>{pill}</TooltipTrigger>
<TooltipContent className="max-w-sm">{error}</TooltipContent>
</Tooltip>
);
}
case "probing":
return (
<span className="flex items-center gap-1 text-xs text-muted-foreground">
<IconLoader2 className="h-3.5 w-3.5 animate-spin" />
{t("common:probing")}
</span>
);
case "not_configured":
return (
<span className="flex items-center gap-1 text-xs text-muted-foreground">
{t("common:pending")}
</span>
);
default:
return (
<span className="flex items-center gap-1 text-xs text-green-600 dark:text-green-400">
<IconCheck className="h-3.5 w-3.5" />
{t("common:installed")}
</span>
);
}
}
function InstalledAgentRow({
agent,
settings,
isOpen,
onToggle,
onUpdateSetting,
}: {
agent: AvailableAgent;
settings: AgentSetting | undefined;
isOpen: boolean;
onToggle: (open: boolean) => void;
onUpdateSetting: (agentName: string, formPatch: Partial<ProfileFormData>) => void;
}) {
const currentModel = settings?.formData.model || agent.model_config.default_model;
const modelName =
agent.model_config.available_models.find((m) => m.id === currentModel)?.name ?? currentModel;
const status = agent.model_config.status ?? "ok";
const showModelPill = status === "ok" && !!modelName;
return (
<Collapsible open={isOpen} onOpenChange={onToggle}>
<CollapsibleTrigger asChild>
<button
type="button"
className="flex w-full items-center gap-3 rounded-lg border p-2 text-left cursor-pointer hover:bg-muted/50 transition-colors group"
>
<AgentLogo agentName={agent.name} size={28} className="shrink-0" />
<div className="flex-1 min-w-0">
<p className="text-sm font-medium truncate">{agent.display_name}</p>
</div>
{showModelPill && (
<span className="text-[10px] px-1.5 py-0.5 rounded bg-muted text-muted-foreground font-medium truncate max-w-[120px]">
{modelName}
</span>
)}
<StatusPill status={status} error={agent.model_config.error} />
<IconChevronDown className="h-4 w-4 text-muted-foreground transition-transform group-data-[state=open]:rotate-180" />
</button>
</CollapsibleTrigger>
<CollapsibleContent>
<div className="border border-t-0 rounded-b-lg px-3 pb-3 pt-2">
{settings && (
<ProfileFormFields
variant="compact"
hideNameField
hideCustomCLIFlags
profile={settings.formData}
onChange={(patch) => onUpdateSetting(agent.name, patch)}
modelConfig={agent.model_config}
permissionSettings={agent.permission_settings ?? {}}
passthroughConfig={agent.passthrough_config ?? null}
agentName={agent.name}
/>
)}
</div>
</CollapsibleContent>
</Collapsible>
);
}
function NotInstalledItems({
agents,
tools,
showLabel,
}: {
agents: AvailableAgent[];
tools: ToolStatus[];
showLabel: boolean;
}) {
const { t } = useTranslation();
if (agents.length === 0 && tools.length === 0) return null;
return (
<>
{showLabel && (
<p className="text-xs text-muted-foreground mt-1">{t("common:notYetInstalled")}</p>
)}
{agents.map((agent) => (
<div
key={agent.name}
className="flex w-full items-center gap-3 rounded-lg border border-dashed p-2"
>
<AgentLogo agentName={agent.name} size={28} className="opacity-60 shrink-0" />
<div className="flex-1 min-w-0">
<p className="text-sm font-medium text-muted-foreground truncate">
{agent.display_name}
</p>
{agent.install_script && (
<div className="flex items-center gap-1 mt-0.5 min-w-0">
<code className="text-[10px] text-muted-foreground font-mono truncate min-w-0">
{agent.install_script}
</code>
<CopyButton text={agent.install_script} />
</div>
)}
</div>
<IconDownload className="h-3.5 w-3.5 text-muted-foreground shrink-0" />
</div>
))}
{tools.map((tool) => (
<div
key={tool.name}
className="flex w-full items-center gap-3 rounded-lg border border-dashed p-2"
>
<IconDownload className="h-7 w-7 p-1 text-muted-foreground opacity-60 shrink-0" />
<div className="flex-1 min-w-0">
<p className="text-sm font-medium text-muted-foreground truncate">
{tool.display_name}
</p>
<p className="text-[10px] text-muted-foreground break-words">{tool.description}</p>
{tool.install_script && (
<div className="flex items-center gap-1 mt-0.5 min-w-0">
<code className="text-[10px] text-muted-foreground font-mono truncate min-w-0">
{tool.install_script}
</code>
<CopyButton text={tool.install_script} />
</div>
)}
</div>
{tool.info_url && (
<a
href={tool.info_url}
target="_blank"
rel="noopener noreferrer"
className="text-muted-foreground hover:text-foreground shrink-0 cursor-pointer"
>
<IconExternalLink className="h-3.5 w-3.5" />
</a>
)}
</div>
))}
</>
);
}
export function StepAgents({
availableAgents,
tools,
agentSettings,
loading,
onUpdateSetting,
}: {
availableAgents: AvailableAgent[];
tools: ToolStatus[];
agentSettings: Record<string, AgentSetting>;
loading: boolean;
onUpdateSetting: (agentName: string, formPatch: Partial<ProfileFormData>) => void;
}) {
const { t } = useTranslation();
const [openAgent, setOpenAgent] = useState<string | null>(null);
if (loading) {
return (
<div className="flex flex-col items-center justify-center h-full gap-3 text-sm text-muted-foreground">
<IconLoader2 className="h-6 w-6 animate-spin" />
{t("common:discoveringAgents")}
</div>
);
}
const installedAgents = availableAgents.filter((a) => a.available);
const notInstalledAgents = availableAgents.filter((a) => !a.available && a.install_script);
const notInstalledTools = tools.filter((t) => !t.available);
return (
<div className="space-y-3">
<div className="grid grid-cols-1 gap-2 max-h-[320px] overflow-y-auto">
{installedAgents.map((agent) => (
<InstalledAgentRow
key={agent.name}
agent={agent}
settings={agentSettings[agent.name]}
isOpen={openAgent === agent.name}
onToggle={(isOpen) => setOpenAgent(isOpen ? agent.name : null)}
onUpdateSetting={onUpdateSetting}
/>
))}
<NotInstalledItems
agents={notInstalledAgents}
tools={notInstalledTools}
showLabel={installedAgents.length > 0}
/>
</div>
<p className="text-xs text-muted-foreground">{t("common:onboardingAgentsHelp")}</p>
<p className="text-xs text-muted-foreground">
<Trans i18nKey="common:onboardingAgentsAutoApproveWarning">
<span className="text-yellow-500 font-medium">Careful:</span> The default Agent Profiles
run with Auto Approve enabled (YOLO mode).
</Trans>
</p>
</div>
);
}