refactor(web): unify form controls, one notification rule, dedupe i18n copy (#54)
Co-authored-by: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
This commit is contained in:
1 parent
c7465625a4
commit
3f868ac385
50 files changed
+2391
-1190
No files matched your search
@@ -21,13 +21,15 @@ import type { ToolDefinitionConfig, ToolPermission } from "@prismshadow/penguin-
|
||||
import * as api from "../../api/endpoints";
|
||||
import { ApiError } from "../../api/client";
|
||||
import { S } from "../../lib/strings";
|
||||
import { apiErrorText } from "../../lib/api-error";
|
||||
import { useDocumentTitle } from "../../lib/use-document-title";
|
||||
import { useProject } from "../../state/project";
|
||||
import { Tabs } from "../../components/ui/tabs";
|
||||
import { Button } from "../../components/ui/button";
|
||||
import { toastError, toastInfo, toastSuccess } from "../../components/ui/toast";
|
||||
import { Input, Textarea } from "../../components/ui/input";
|
||||
import { OptionMenu, type OptionMenuChoice } from "../../components/ui/option-menu";
|
||||
import { Modal } from "../../components/ui/modal";
|
||||
import { ConfirmModal, useSaveConfirm } from "../../components/ui/confirm-modal";
|
||||
import { Skeleton } from "../../components/ui/skeleton";
|
||||
import { VaultTab } from "./vault-tab";
|
||||
import { SchedulesTab } from "./schedules-tab";
|
||||
@@ -56,50 +58,6 @@ export function optionRows(
|
||||
}));
|
||||
}
|
||||
|
||||
/**
|
||||
* OptionMenu whose pick can be explicitly rewound: the runtime menus no longer offer an
|
||||
* inherit row, so without this an accidental pick could not be backed out before saving.
|
||||
* The reset link (top-right of the label line, visible only while a value is picked) sets
|
||||
* the LOCAL edit state back to "" — the save guard skips empty values, nothing is written.
|
||||
* The unset trigger shows the (default) placeholder, matching the tools-table permission menu.
|
||||
*/
|
||||
function ResettableOptionMenu({
|
||||
label,
|
||||
value,
|
||||
onChange,
|
||||
options,
|
||||
}: {
|
||||
label: string;
|
||||
value: string;
|
||||
onChange: (value: string) => void;
|
||||
options: ReadonlyArray<OptionMenuChoice<string>>;
|
||||
}) {
|
||||
return (
|
||||
<div className="relative">
|
||||
<OptionMenu
|
||||
label={label}
|
||||
fullWidth
|
||||
size="sm"
|
||||
placeholder={S.agent.defaultValue}
|
||||
value={value}
|
||||
onChange={onChange}
|
||||
options={options}
|
||||
/>
|
||||
{value !== "" && (
|
||||
<button
|
||||
type="button"
|
||||
title={S.agent.resetToDefault}
|
||||
aria-label={S.agent.resetToDefault}
|
||||
onClick={() => onChange("")}
|
||||
className="absolute right-0 top-0 text-xs text-gray-400 transition-colors duration-150 hover:text-gray-700 dark:hover:text-gray-300"
|
||||
>
|
||||
{S.agent.resetToDefault}
|
||||
</button>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
/** Numeric input's string state → number (empty/invalid = undefined, meaning no change). */
|
||||
function parseNum(s: string): number | undefined {
|
||||
const trimmed = s.trim();
|
||||
@@ -131,8 +89,8 @@ export function AgentSettingsPage() {
|
||||
|
||||
const [data, setData] = useState<AgentConfigResponse | null>(null);
|
||||
const [tab, setTab] = useState<TabKey>("overview");
|
||||
// Only the initial config load failure renders inline (the page can't show without it); saves/imports report via toast.
|
||||
const [error, setError] = useState<string | null>(null);
|
||||
const [notice, setNotice] = useState<string | null>(null);
|
||||
|
||||
const load = useCallback(() => {
|
||||
if (!projectId || !agentId) return;
|
||||
@@ -141,7 +99,7 @@ export function AgentSettingsPage() {
|
||||
api
|
||||
.getAgentConfig(projectId, agentId)
|
||||
.then(setData)
|
||||
.catch((e: unknown) => setError(e instanceof ApiError ? e.message : S.common.unknownError));
|
||||
.catch((e: unknown) => setError(apiErrorText(e)));
|
||||
}, [projectId, agentId]);
|
||||
|
||||
useEffect(() => {
|
||||
@@ -151,7 +109,7 @@ export function AgentSettingsPage() {
|
||||
/** Snapshot import succeeded: show the new version and reload the whole config (import overwrites the entire Agent State, so every tab's data needs a refresh). */
|
||||
const onImported = useCallback(
|
||||
(version: number) => {
|
||||
setNotice(S.agent.importDone(version));
|
||||
toastSuccess(S.agent.importDone(version));
|
||||
load();
|
||||
void reloadAgents();
|
||||
},
|
||||
@@ -161,18 +119,16 @@ export function AgentSettingsPage() {
|
||||
const save = useCallback(
|
||||
async (update: AgentConfigUpdateRequest) => {
|
||||
if (!projectId || !agentId) return;
|
||||
setError(null);
|
||||
setNotice(null);
|
||||
try {
|
||||
const res = await api.putAgentConfig(projectId, agentId, update);
|
||||
setData(res);
|
||||
setNotice(S.common.saved);
|
||||
toastSuccess(S.common.saved);
|
||||
// Name/description changes affect the breadcrumb and list display.
|
||||
if (update.config?.name !== undefined || update.config?.description !== undefined) {
|
||||
void reloadAgents();
|
||||
}
|
||||
} catch (e) {
|
||||
setError(e instanceof ApiError ? e.message : S.common.unknownError);
|
||||
toastError(apiErrorText(e));
|
||||
}
|
||||
},
|
||||
[projectId, agentId, reloadAgents],
|
||||
@@ -229,8 +185,6 @@ export function AgentSettingsPage() {
|
||||
{tab === "vault" && <VaultTab agentId={agentId} />}
|
||||
{tab === "schedules" && <SchedulesTab agentId={agentId} />}
|
||||
</div>
|
||||
{error && <p className="text-xs text-red-600 dark:text-red-400">{error}</p>}
|
||||
{notice && <p className="text-xs text-emerald-600 dark:text-emerald-400">{notice}</p>}
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
@@ -265,6 +219,7 @@ function OverviewTab({
|
||||
const [importError, setImportError] = useState<string | null>(null);
|
||||
// base64 of the snapshot package pending confirmation for a version conflict (409 version_conflict); non-null shows the confirm modal.
|
||||
const [conflict, setConflict] = useState<string | null>(null);
|
||||
const { requestSave, element: saveConfirm } = useSaveConfirm();
|
||||
|
||||
const submit = () => {
|
||||
const config: NonNullable<AgentConfigUpdateRequest["config"]> = {};
|
||||
@@ -272,8 +227,11 @@ function OverviewTab({
|
||||
if (description.trim() !== (data.config.description ?? "")) {
|
||||
config.description = description.trim();
|
||||
}
|
||||
if (Object.keys(config).length === 0) return;
|
||||
void onSave({ config });
|
||||
if (Object.keys(config).length === 0) {
|
||||
toastInfo(S.common.noChangesToSave);
|
||||
return;
|
||||
}
|
||||
requestSave(() => void onSave({ config }));
|
||||
};
|
||||
|
||||
const runImport = async (dataBase64: string, confirm: boolean) => {
|
||||
@@ -292,7 +250,7 @@ function OverviewTab({
|
||||
setConflict(dataBase64); // resend with confirm: true after confirming
|
||||
} else {
|
||||
setConflict(null);
|
||||
setImportError(e instanceof ApiError ? e.message : S.common.unknownError);
|
||||
setImportError(apiErrorText(e));
|
||||
}
|
||||
} finally {
|
||||
setImporting(false);
|
||||
@@ -317,7 +275,7 @@ function OverviewTab({
|
||||
<div className="space-y-4">
|
||||
<Input
|
||||
size="sm"
|
||||
label={S.agent.name}
|
||||
label={S.common.name}
|
||||
value={name}
|
||||
onChange={(e) => setName(e.target.value)}
|
||||
/>
|
||||
@@ -381,29 +339,20 @@ function OverviewTab({
|
||||
<Button size="sm" variant="primary" onClick={submit}>
|
||||
{S.common.save}
|
||||
</Button>
|
||||
{saveConfirm}
|
||||
|
||||
{/* Version conflict confirmation: resend the same package with confirm: true after confirming. */}
|
||||
<Modal
|
||||
<ConfirmModal
|
||||
open={conflict !== null}
|
||||
title={S.agent.importConflictTitle}
|
||||
busy={importing}
|
||||
onClose={() => setConflict(null)}
|
||||
footer={
|
||||
<>
|
||||
<Button onClick={() => setConflict(null)}>{S.common.cancel}</Button>
|
||||
<Button
|
||||
variant="danger"
|
||||
disabled={importing}
|
||||
onClick={() => {
|
||||
if (conflict !== null) void runImport(conflict, true);
|
||||
}}
|
||||
>
|
||||
{S.common.confirm}
|
||||
</Button>
|
||||
</>
|
||||
}
|
||||
onConfirm={() => {
|
||||
if (conflict !== null) void runImport(conflict, true);
|
||||
}}
|
||||
>
|
||||
<p className="text-sm text-gray-600 dark:text-gray-300">{S.agent.importConflictBody}</p>
|
||||
</Modal>
|
||||
</ConfirmModal>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -412,13 +361,17 @@ function PromptTab({ data, onSave }: { data: AgentConfigResponse; onSave: SaveFn
|
||||
const [agentsMd, setAgentsMd] = useState(data.agentsMd);
|
||||
const [systemPrompt, setSystemPrompt] = useState(data.config.systemPrompt);
|
||||
const promptRef = useRef<HTMLTextAreaElement>(null);
|
||||
const { requestSave, element: saveConfirm } = useSaveConfirm();
|
||||
|
||||
const submit = () => {
|
||||
const update: AgentConfigUpdateRequest = {};
|
||||
if (agentsMd !== data.agentsMd) update.agentsMd = agentsMd;
|
||||
if (systemPrompt !== data.config.systemPrompt) update.config = { systemPrompt };
|
||||
if (update.agentsMd === undefined && update.config === undefined) return;
|
||||
void onSave(update);
|
||||
if (update.agentsMd === undefined && update.config === undefined) {
|
||||
toastInfo(S.common.noChangesToSave);
|
||||
return;
|
||||
}
|
||||
requestSave(() => void onSave(update));
|
||||
};
|
||||
|
||||
/**
|
||||
@@ -487,6 +440,7 @@ function PromptTab({ data, onSave }: { data: AgentConfigResponse; onSave: SaveFn
|
||||
<Button size="sm" variant="primary" onClick={submit}>
|
||||
{S.common.save}
|
||||
</Button>
|
||||
{saveConfirm}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -503,16 +457,18 @@ function RuntimeTab({ data, onSave }: { data: AgentConfigResponse; onSave: SaveF
|
||||
const [maxSessionTurns, setMaxSessionTurns] = useState(numToStr(cfg.compaction?.maxSessionTurns));
|
||||
const [mode, setMode] = useState(cfg.compaction?.mode ?? "");
|
||||
const [prompt, setPrompt] = useState(cfg.compaction?.prompt ?? "");
|
||||
const [localError, setLocalError] = useState<string | null>(null);
|
||||
const [fieldErrors, setFieldErrors] = useState<{ maxTurns?: string; timeoutMs?: string }>({});
|
||||
const clearFieldErrors = () => setFieldErrors((p) => (p.maxTurns || p.timeoutMs ? {} : p));
|
||||
const { requestSave, element: saveConfirm } = useSaveConfirm();
|
||||
|
||||
const submit = () => {
|
||||
setLocalError(null);
|
||||
setFieldErrors({});
|
||||
const config: NonNullable<AgentConfigUpdateRequest["config"]> = {};
|
||||
|
||||
const mt = parseNum(maxTurns);
|
||||
if (mt !== undefined && mt !== cfg.maxTurns) {
|
||||
if (mt <= 0 && mt !== -1) {
|
||||
setLocalError(S.agent.maxTurnsInvalid);
|
||||
setFieldErrors({ maxTurns: S.agent.maxTurnsInvalid });
|
||||
return;
|
||||
}
|
||||
config.maxTurns = mt;
|
||||
@@ -527,7 +483,7 @@ function RuntimeTab({ data, onSave }: { data: AgentConfigResponse; onSave: SaveF
|
||||
const tmo = parseNum(timeoutMs);
|
||||
if (tmo !== undefined && tmo !== cfg.model?.timeoutMs) {
|
||||
if (tmo <= 0 && tmo !== -1) {
|
||||
setLocalError(S.agent.timeoutInvalid);
|
||||
setFieldErrors({ timeoutMs: S.agent.timeoutInvalid });
|
||||
return;
|
||||
}
|
||||
model.timeoutMs = tmo;
|
||||
@@ -549,8 +505,11 @@ function RuntimeTab({ data, onSave }: { data: AgentConfigResponse; onSave: SaveF
|
||||
if (prompt !== (cfg.compaction?.prompt ?? "")) compaction.prompt = prompt;
|
||||
if (Object.keys(compaction).length > 0) config.compaction = compaction;
|
||||
|
||||
if (Object.keys(config).length === 0) return;
|
||||
void onSave({ config });
|
||||
if (Object.keys(config).length === 0) {
|
||||
toastInfo(S.common.noChangesToSave);
|
||||
return;
|
||||
}
|
||||
requestSave(() => void onSave({ config }));
|
||||
};
|
||||
|
||||
// S is reassigned on language switch (live binding), so read it during render rather than hoisting to a module-level constant.
|
||||
@@ -576,7 +535,11 @@ function RuntimeTab({ data, onSave }: { data: AgentConfigResponse; onSave: SaveF
|
||||
label={S.agent.maxTurns}
|
||||
size="sm"
|
||||
value={maxTurns}
|
||||
onChange={(e) => setMaxTurns(e.target.value)}
|
||||
error={fieldErrors.maxTurns}
|
||||
onChange={(e) => {
|
||||
setMaxTurns(e.target.value);
|
||||
clearFieldErrors();
|
||||
}}
|
||||
inputMode="numeric"
|
||||
className="font-mono"
|
||||
/>
|
||||
@@ -588,8 +551,11 @@ function RuntimeTab({ data, onSave }: { data: AgentConfigResponse; onSave: SaveF
|
||||
inputMode="numeric"
|
||||
className="font-mono"
|
||||
/>
|
||||
<ResettableOptionMenu
|
||||
<OptionMenu
|
||||
label={S.agent.thinkingLevel}
|
||||
fullWidth
|
||||
size="sm"
|
||||
placeholder={S.agent.defaultValue}
|
||||
value={thinkingLevel}
|
||||
onChange={setThinkingLevel}
|
||||
options={thinkingLevelOptions}
|
||||
@@ -599,7 +565,11 @@ function RuntimeTab({ data, onSave }: { data: AgentConfigResponse; onSave: SaveF
|
||||
hint={S.agent.timeoutMsHint}
|
||||
size="sm"
|
||||
value={timeoutMs}
|
||||
onChange={(e) => setTimeoutMs(e.target.value)}
|
||||
error={fieldErrors.timeoutMs}
|
||||
onChange={(e) => {
|
||||
setTimeoutMs(e.target.value);
|
||||
clearFieldErrors();
|
||||
}}
|
||||
inputMode="numeric"
|
||||
className="font-mono"
|
||||
/>
|
||||
@@ -629,8 +599,11 @@ function RuntimeTab({ data, onSave }: { data: AgentConfigResponse; onSave: SaveF
|
||||
inputMode="numeric"
|
||||
className="font-mono"
|
||||
/>
|
||||
<ResettableOptionMenu
|
||||
<OptionMenu
|
||||
label={S.agent.compactionMode}
|
||||
fullWidth
|
||||
size="sm"
|
||||
placeholder={S.agent.defaultValue}
|
||||
value={mode}
|
||||
onChange={setMode}
|
||||
options={compactionModeOptions}
|
||||
@@ -653,10 +626,10 @@ function RuntimeTab({ data, onSave }: { data: AgentConfigResponse; onSave: SaveF
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{localError && <p className="text-xs text-red-600 dark:text-red-400">{localError}</p>}
|
||||
<Button size="sm" variant="primary" onClick={submit}>
|
||||
{S.common.save}
|
||||
</Button>
|
||||
{saveConfirm}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -691,17 +664,28 @@ function ToolsTab({ data, onSave }: { data: AgentConfigResponse; onSave: SaveFn
|
||||
maxOutputLength: numToStr(t.maxOutputLength),
|
||||
})),
|
||||
);
|
||||
const [localError, setLocalError] = useState<string | null>(null);
|
||||
// Per-cell validation errors, keyed `${rowIndex}-${column}`, shown red under the offending numeric input.
|
||||
const [fieldErrors, setFieldErrors] = useState<Record<string, string>>({});
|
||||
const { requestSave, element: saveConfirm } = useSaveConfirm();
|
||||
|
||||
const update = (index: number, patch: Partial<ToolRowState>) => {
|
||||
setRows((prev) => prev.map((r, i) => (i === index ? { ...r, ...patch } : r)));
|
||||
setFieldErrors((p) => {
|
||||
const kt = `${index}-timeoutMs`;
|
||||
const km = `${index}-maxOutputLength`;
|
||||
if (!p[kt] && !p[km]) return p;
|
||||
const next = { ...p };
|
||||
delete next[kt];
|
||||
delete next[km];
|
||||
return next;
|
||||
});
|
||||
};
|
||||
|
||||
const submit = () => {
|
||||
setLocalError(null);
|
||||
// toolsBuiltin is submitted as a full table: an empty string omits that key (revert to default); non-empty values are validated per the server's rules.
|
||||
const errs: Record<string, string> = {};
|
||||
const tools: ToolDefinitionConfig[] = [];
|
||||
for (const row of rows) {
|
||||
for (const [i, row] of rows.entries()) {
|
||||
const tool: ToolDefinitionConfig = { ...row.base };
|
||||
delete tool.timeoutMs;
|
||||
delete tool.maxOutputLength;
|
||||
@@ -709,23 +693,41 @@ function ToolsTab({ data, onSave }: { data: AgentConfigResponse; onSave: SaveFn
|
||||
if (timeout) {
|
||||
const n = Number(timeout);
|
||||
if (!Number.isInteger(n) || (n <= 0 && n !== -1)) {
|
||||
setLocalError(S.agent.toolFieldInvalid(row.base.name, "timeoutMs"));
|
||||
return;
|
||||
}
|
||||
tool.timeoutMs = n;
|
||||
errs[`${i}-timeoutMs`] = S.agent.toolFieldInvalid(row.base.name, "timeoutMs");
|
||||
} else tool.timeoutMs = n;
|
||||
}
|
||||
const maxOutput = row.maxOutputLength.trim();
|
||||
if (maxOutput) {
|
||||
const n = Number(maxOutput);
|
||||
if (!Number.isInteger(n) || (n <= 0 && n !== -1)) {
|
||||
setLocalError(S.agent.toolFieldInvalid(row.base.name, "maxOutputLength"));
|
||||
return;
|
||||
}
|
||||
tool.maxOutputLength = n;
|
||||
errs[`${i}-maxOutputLength`] = S.agent.toolFieldInvalid(row.base.name, "maxOutputLength");
|
||||
} else tool.maxOutputLength = n;
|
||||
}
|
||||
tools.push(tool);
|
||||
}
|
||||
void onSave({ config: { toolsBuiltin: tools } });
|
||||
if (Object.keys(errs).length > 0) {
|
||||
setFieldErrors(errs);
|
||||
return;
|
||||
}
|
||||
setFieldErrors({});
|
||||
// The table is submitted whole, so compare the editable columns against the loaded
|
||||
// config to detect a no-op save (row order is stable — both sides map the same list).
|
||||
const orig = data.config.toolsBuiltin;
|
||||
const dirty =
|
||||
tools.length !== orig.length ||
|
||||
tools.some((t, i) => {
|
||||
const o = orig[i]!;
|
||||
return (
|
||||
t.permission !== o.permission ||
|
||||
t.timeoutMs !== o.timeoutMs ||
|
||||
t.maxOutputLength !== o.maxOutputLength
|
||||
);
|
||||
});
|
||||
if (!dirty) {
|
||||
toastInfo(S.common.noChangesToSave);
|
||||
return;
|
||||
}
|
||||
requestSave(() => void onSave({ config: { toolsBuiltin: tools } }));
|
||||
};
|
||||
|
||||
return (
|
||||
@@ -734,7 +736,7 @@ function ToolsTab({ data, onSave }: { data: AgentConfigResponse; onSave: SaveFn
|
||||
<table className="w-full min-w-[520px] text-left text-sm">
|
||||
<thead>
|
||||
<tr className="border-b border-gray-200 bg-gray-50/80 text-xs text-gray-500 dark:border-gray-800 dark:bg-gray-900">
|
||||
<th className="px-3 py-2">{S.agent.toolName}</th>
|
||||
<th className="px-3 py-2">{S.common.name}</th>
|
||||
<th className="px-3 py-2">{S.agent.toolPermission}</th>
|
||||
<th className="px-3 py-2">{S.agent.toolTimeout}</th>
|
||||
<th className="px-3 py-2">{S.agent.toolMaxOutput}</th>
|
||||
@@ -743,8 +745,8 @@ function ToolsTab({ data, onSave }: { data: AgentConfigResponse; onSave: SaveFn
|
||||
<tbody>
|
||||
{rows.map((row, i) => (
|
||||
<tr key={row.base.name} className="border-b border-gray-100 dark:border-gray-800/60">
|
||||
<td className="px-3 py-2 font-mono text-xs">{row.base.name}</td>
|
||||
<td className="px-3 py-2">
|
||||
<td className="px-3 py-2 align-top font-mono text-xs">{row.base.name}</td>
|
||||
<td className="px-3 py-2 align-top">
|
||||
<OptionMenu
|
||||
mono
|
||||
size="sm"
|
||||
@@ -755,19 +757,21 @@ function ToolsTab({ data, onSave }: { data: AgentConfigResponse; onSave: SaveFn
|
||||
onChange={(v) => update(i, { base: { ...row.base, permission: v } })}
|
||||
/>
|
||||
</td>
|
||||
<td className="px-3 py-2">
|
||||
<td className="px-3 py-2 align-top">
|
||||
<Input
|
||||
size="sm"
|
||||
value={row.timeoutMs}
|
||||
error={fieldErrors[`${i}-timeoutMs`]}
|
||||
inputMode="numeric"
|
||||
className="font-mono"
|
||||
onChange={(e) => update(i, { timeoutMs: e.target.value })}
|
||||
/>
|
||||
</td>
|
||||
<td className="px-3 py-2">
|
||||
<td className="px-3 py-2 align-top">
|
||||
<Input
|
||||
size="sm"
|
||||
value={row.maxOutputLength}
|
||||
error={fieldErrors[`${i}-maxOutputLength`]}
|
||||
inputMode="numeric"
|
||||
className="font-mono"
|
||||
onChange={(e) => update(i, { maxOutputLength: e.target.value })}
|
||||
@@ -779,8 +783,6 @@ function ToolsTab({ data, onSave }: { data: AgentConfigResponse; onSave: SaveFn
|
||||
</table>
|
||||
</div>
|
||||
|
||||
{localError && <p className="text-xs text-red-600 dark:text-red-400">{localError}</p>}
|
||||
|
||||
<div>
|
||||
<p className="mb-1 text-xs font-medium text-gray-500">{S.agent.mcpServers}</p>
|
||||
<pre className="max-h-64 overflow-auto rounded-md border border-gray-200 bg-gray-50 p-3 text-xs dark:border-gray-800 dark:bg-gray-900">
|
||||
@@ -791,6 +793,7 @@ function ToolsTab({ data, onSave }: { data: AgentConfigResponse; onSave: SaveFn
|
||||
<Button size="sm" variant="primary" onClick={submit}>
|
||||
{S.common.save}
|
||||
</Button>
|
||||
{saveConfirm}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -15,8 +15,8 @@
|
||||
import { useState } from "react";
|
||||
import { useNavigate } from "react-router";
|
||||
import * as api from "../../api/endpoints";
|
||||
import { ApiError } from "../../api/client";
|
||||
import { S } from "../../lib/strings";
|
||||
import { apiErrorText } from "../../lib/api-error";
|
||||
import { SEMANTIC_ID_PATTERN } from "../../lib/semantic-id";
|
||||
import { formatDateTime, formatRelativeDays } from "../../lib/format";
|
||||
import { useDocumentTitle } from "../../lib/use-document-title";
|
||||
@@ -25,6 +25,7 @@ import { agentDisplayName, useProject } from "../../state/project";
|
||||
import { Button } from "../../components/ui/button";
|
||||
import { Input, Textarea } from "../../components/ui/input";
|
||||
import { Modal } from "../../components/ui/modal";
|
||||
import { ConfirmModal } from "../../components/ui/confirm-modal";
|
||||
import { Badge } from "../../components/ui/badge";
|
||||
import { Skeleton, SkeletonCard } from "../../components/ui/skeleton";
|
||||
import { EmptyState } from "../../components/ui/empty-state";
|
||||
@@ -71,7 +72,8 @@ export function AgentsPage() {
|
||||
const [agentId, setAgentId] = useState("");
|
||||
const [name, setName] = useState("");
|
||||
const [description, setDescription] = useState("");
|
||||
const [error, setError] = useState<string | null>(null);
|
||||
// The id is the only validated create field; format problems and the server's duplicate-id rejection land beside it.
|
||||
const [idError, setIdError] = useState<string | undefined>(undefined);
|
||||
const [busy, setBusy] = useState(false);
|
||||
|
||||
/** Open the create dialog: don't keep the previous draft, always start from an empty form. */
|
||||
@@ -79,7 +81,7 @@ export function AgentsPage() {
|
||||
setAgentId("");
|
||||
setName("");
|
||||
setDescription("");
|
||||
setError(null);
|
||||
setIdError(undefined);
|
||||
setCreateOpen(true);
|
||||
};
|
||||
/** Agent pending delete confirmation (null = none). */
|
||||
@@ -92,15 +94,15 @@ export function AgentsPage() {
|
||||
if (!projectId) return;
|
||||
const id = agentId.trim();
|
||||
if (!id) {
|
||||
setError(S.common.requiredField);
|
||||
setIdError(S.common.requiredField);
|
||||
return;
|
||||
}
|
||||
if (!SEMANTIC_ID_PATTERN.test(id)) {
|
||||
setError(S.agent.idHint);
|
||||
setIdError(S.agent.idHint);
|
||||
return;
|
||||
}
|
||||
setBusy(true);
|
||||
setError(null);
|
||||
setIdError(undefined);
|
||||
try {
|
||||
// Name defaults to the id (leave blank to let the server fill it in from the id).
|
||||
const body: { agentId: string; name?: string; description?: string } = { agentId: id };
|
||||
@@ -112,7 +114,7 @@ export function AgentsPage() {
|
||||
setCurrentAgentId(res.agent.agentId);
|
||||
navigate(`/agents/${res.agent.agentId}`);
|
||||
} catch (e) {
|
||||
setError(e instanceof ApiError ? e.message : S.common.unknownError);
|
||||
setIdError(apiErrorText(e));
|
||||
} finally {
|
||||
setBusy(false);
|
||||
}
|
||||
@@ -139,7 +141,7 @@ export function AgentsPage() {
|
||||
setDeleting(null);
|
||||
await reloadAgents();
|
||||
} catch (e) {
|
||||
setDeleteError(e instanceof ApiError ? e.message : S.common.unknownError);
|
||||
setDeleteError(apiErrorText(e));
|
||||
} finally {
|
||||
setBusy(false);
|
||||
}
|
||||
@@ -370,14 +372,19 @@ export function AgentsPage() {
|
||||
<div className="space-y-3">
|
||||
<Input
|
||||
label={S.agent.id}
|
||||
required
|
||||
size="sm"
|
||||
value={agentId}
|
||||
onChange={(e) => setAgentId(e.target.value)}
|
||||
onChange={(e) => {
|
||||
setAgentId(e.target.value);
|
||||
setIdError(undefined);
|
||||
}}
|
||||
error={idError}
|
||||
hint={S.agent.idHint}
|
||||
autoFocus
|
||||
/>
|
||||
<Input
|
||||
label={S.agent.name}
|
||||
label={S.common.name}
|
||||
size="sm"
|
||||
value={name}
|
||||
onChange={(e) => setName(e.target.value)}
|
||||
@@ -390,33 +397,19 @@ export function AgentsPage() {
|
||||
value={description}
|
||||
onChange={(e) => setDescription(e.target.value)}
|
||||
/>
|
||||
{error && <p className="text-xs text-red-600 dark:text-red-400">{error}</p>}
|
||||
</div>
|
||||
</Modal>
|
||||
|
||||
{/* Delete confirmation */}
|
||||
<Modal
|
||||
{/* Delete confirmation (shared ConfirmModal) */}
|
||||
<ConfirmModal
|
||||
open={deleting !== null}
|
||||
title={S.agent.deleteAgent}
|
||||
busy={busy}
|
||||
onClose={() => {
|
||||
setDeleting(null);
|
||||
setDeleteError(null);
|
||||
}}
|
||||
footer={
|
||||
<>
|
||||
<Button
|
||||
onClick={() => {
|
||||
setDeleting(null);
|
||||
setDeleteError(null);
|
||||
}}
|
||||
>
|
||||
{S.common.cancel}
|
||||
</Button>
|
||||
<Button variant="danger" disabled={busy} onClick={() => void doDelete()}>
|
||||
{S.common.confirm}
|
||||
</Button>
|
||||
</>
|
||||
}
|
||||
onConfirm={() => void doDelete()}
|
||||
>
|
||||
<p className="text-sm text-gray-600 dark:text-gray-300">
|
||||
{deleting ? S.agent.deleteConfirm(deleting.name) : ""}
|
||||
@@ -424,7 +417,7 @@ export function AgentsPage() {
|
||||
{deleteError && (
|
||||
<p className="mt-2 text-xs text-red-600 dark:text-red-400">{deleteError}</p>
|
||||
)}
|
||||
</Modal>
|
||||
</ConfirmModal>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -19,8 +19,8 @@ import type {
|
||||
ScheduleUpsertRequest,
|
||||
} from "@prismshadow/penguin-server/api";
|
||||
import * as api from "../../api/endpoints";
|
||||
import { ApiError } from "../../api/client";
|
||||
import { S } from "../../lib/strings";
|
||||
import { apiErrorText } from "../../lib/api-error";
|
||||
import { formatDateTime } from "../../lib/format";
|
||||
import { useProject } from "../../state/project";
|
||||
import { providerInfo } from "@prismshadow/penguin-core/model-catalog";
|
||||
@@ -29,7 +29,9 @@ import { Button } from "../../components/ui/button";
|
||||
import { Input, Textarea } from "../../components/ui/input";
|
||||
import { Select } from "../../components/ui/select";
|
||||
import { Modal } from "../../components/ui/modal";
|
||||
import { ConfirmModal } from "../../components/ui/confirm-modal";
|
||||
import { SkeletonList } from "../../components/ui/skeleton";
|
||||
import { toastError, toastInfo, toastSuccess } from "../../components/ui/toast";
|
||||
|
||||
/** Display status → badge tone. */
|
||||
const STATUS_TONE: Record<ScheduleStatus, BadgeTone> = {
|
||||
@@ -117,11 +119,20 @@ export function SchedulesTab({ agentId }: { agentId: string }) {
|
||||
const isOwner = currentProject?.role === "owner";
|
||||
|
||||
const [data, setData] = useState<SchedulesResponse | null>(null);
|
||||
// Tab-level error is only the initial list load failure; row/edit actions report via toast.
|
||||
const [error, setError] = useState<string | null>(null);
|
||||
const [notice, setNotice] = useState<string | null>(null);
|
||||
const [busy, setBusy] = useState(false);
|
||||
// Modal form: non-null means open (EMPTY_FORM for create / prefilled row for edit).
|
||||
const [form, setForm] = useState<FormState | null>(null);
|
||||
// The form as opened — an edit submit with nothing changed reports "no changes" instead of rewriting the file.
|
||||
const [initialForm, setInitialForm] = useState<FormState | null>(null);
|
||||
// Per-field required errors sit next to their input; formError holds a submit rejection that isn't attributable to one field.
|
||||
const [fieldErrors, setFieldErrors] = useState<{
|
||||
name?: string;
|
||||
prompt?: string;
|
||||
startAt?: string;
|
||||
sessionId?: string;
|
||||
}>({});
|
||||
const [formError, setFormError] = useState<string | null>(null);
|
||||
// Name of the task pending deletion confirmation (non-null shows the confirm modal).
|
||||
const [deleting, setDeleting] = useState<string | null>(null);
|
||||
@@ -135,7 +146,7 @@ export function SchedulesTab({ agentId }: { agentId: string }) {
|
||||
try {
|
||||
setData(await api.listSchedules(projectId, agentId));
|
||||
} catch (e) {
|
||||
setError(e instanceof ApiError ? e.message : S.common.unknownError);
|
||||
setError(apiErrorText(e));
|
||||
}
|
||||
}, [projectId, agentId]);
|
||||
|
||||
@@ -151,27 +162,39 @@ export function SchedulesTab({ agentId }: { agentId: string }) {
|
||||
.catch(() => setModels([]));
|
||||
}, [projectId, isOwner]);
|
||||
|
||||
const set = (patch: Partial<FormState>) =>
|
||||
const set = (patch: Partial<FormState>) => {
|
||||
setFieldErrors((p) => (p.name || p.prompt || p.startAt || p.sessionId ? {} : p));
|
||||
setFormError((p) => (p ? null : p));
|
||||
setForm((prev) => (prev === null ? prev : { ...prev, ...patch }));
|
||||
};
|
||||
|
||||
const openForm = (next: FormState) => {
|
||||
setFieldErrors({});
|
||||
setFormError(null);
|
||||
setForm(next);
|
||||
setInitialForm(next);
|
||||
};
|
||||
|
||||
const submit = async () => {
|
||||
if (!projectId || form === null) return;
|
||||
setFormError(null);
|
||||
setNotice(null);
|
||||
const name = form.editing ?? form.name.trim();
|
||||
// sessionId is required in bind-to-Session mode — leaving it blank would silently downgrade to "new Session", changing the user's intended choice.
|
||||
if (
|
||||
!name ||
|
||||
!form.prompt.trim() ||
|
||||
!form.startAt ||
|
||||
(form.target === "session" && !form.sessionId.trim())
|
||||
) {
|
||||
setFormError(S.common.requiredField);
|
||||
const next: { name?: string; prompt?: string; startAt?: string; sessionId?: string } = {};
|
||||
if (!name) next.name = S.common.requiredField;
|
||||
if (!form.prompt.trim()) next.prompt = S.common.requiredField;
|
||||
if (!form.startAt) next.startAt = S.common.requiredField;
|
||||
if (form.target === "session" && !form.sessionId.trim())
|
||||
next.sessionId = S.common.requiredField;
|
||||
if (next.name || next.prompt || next.startAt || next.sessionId) {
|
||||
setFieldErrors(next);
|
||||
return;
|
||||
}
|
||||
setFieldErrors({});
|
||||
// Editing with nothing changed: report it instead of rewriting the same file (both
|
||||
// sides are FormState built by openForm, so a field-wise JSON compare is exact).
|
||||
if (form.editing !== null && JSON.stringify(form) === JSON.stringify(initialForm)) {
|
||||
toastInfo(S.common.noChangesToSave);
|
||||
return;
|
||||
}
|
||||
// Empty-string keys are always omitted; target is one of two choices — sessionId is
|
||||
@@ -198,11 +221,11 @@ export function SchedulesTab({ agentId }: { agentId: string }) {
|
||||
if (form.editing !== null) await api.updateSchedule(projectId, agentId, form.editing, body);
|
||||
else await api.createSchedule(projectId, agentId, { name, ...body });
|
||||
setForm(null);
|
||||
setNotice(S.common.saved);
|
||||
toastSuccess(S.common.saved);
|
||||
await load();
|
||||
} catch (e) {
|
||||
// Errors like 400 (validated with the same rules as hand-written files) land under the modal form.
|
||||
setFormError(e instanceof ApiError ? e.message : S.common.unknownError);
|
||||
// A 400 (validated with the same rules as hand-written files) isn't tied to one field — show it under the modal form.
|
||||
setFormError(apiErrorText(e));
|
||||
} finally {
|
||||
setBusy(false);
|
||||
}
|
||||
@@ -212,8 +235,6 @@ export function SchedulesTab({ agentId }: { agentId: string }) {
|
||||
const toggle = async (item: ScheduleItem) => {
|
||||
if (!projectId) return;
|
||||
setBusy(true);
|
||||
setError(null);
|
||||
setNotice(null);
|
||||
const model = itemModelRef(item);
|
||||
try {
|
||||
await api.updateSchedule(projectId, agentId, item.name, {
|
||||
@@ -227,10 +248,10 @@ export function SchedulesTab({ agentId }: { agentId: string }) {
|
||||
// Model reference is resent as a whole pair or not at all — never half of one.
|
||||
...(model ? { modelId: model.modelId, provider: model.provider } : {}),
|
||||
});
|
||||
setNotice(S.common.saved);
|
||||
toastSuccess(S.common.saved);
|
||||
await load();
|
||||
} catch (e) {
|
||||
setError(e instanceof ApiError ? e.message : S.common.unknownError);
|
||||
toastError(apiErrorText(e));
|
||||
} finally {
|
||||
setBusy(false);
|
||||
}
|
||||
@@ -257,14 +278,12 @@ export function SchedulesTab({ agentId }: { agentId: string }) {
|
||||
const confirmRemove = async () => {
|
||||
if (!projectId || deleting === null) return;
|
||||
setBusy(true);
|
||||
setError(null);
|
||||
setNotice(null);
|
||||
try {
|
||||
await api.deleteSchedule(projectId, agentId, deleting);
|
||||
if (form?.editing === deleting) setForm(null);
|
||||
await load();
|
||||
} catch (e) {
|
||||
setError(e instanceof ApiError ? e.message : S.common.unknownError);
|
||||
toastError(apiErrorText(e));
|
||||
} finally {
|
||||
setBusy(false);
|
||||
setDeleting(null);
|
||||
@@ -295,7 +314,7 @@ export function SchedulesTab({ agentId }: { agentId: string }) {
|
||||
<table className="w-full min-w-[640px] text-left text-sm">
|
||||
<thead>
|
||||
<tr className="border-b border-gray-200 bg-gray-50/80 text-xs text-gray-500 dark:border-gray-800 dark:bg-gray-900">
|
||||
<th className="px-3 py-2.5">{S.schedule.colName}</th>
|
||||
<th className="px-3 py-2.5">{S.common.name}</th>
|
||||
<th className="px-3 py-2.5">{S.schedule.colStatus}</th>
|
||||
<th className="px-3 py-2.5">{S.schedule.colPeriod}</th>
|
||||
<th className="px-3 py-2.5">{S.schedule.colTarget}</th>
|
||||
@@ -423,8 +442,10 @@ export function SchedulesTab({ agentId }: { agentId: string }) {
|
||||
<div className="grid grid-cols-1 gap-3 md:grid-cols-2">
|
||||
<Input
|
||||
size="sm"
|
||||
label={S.schedule.name}
|
||||
label={S.common.name}
|
||||
required
|
||||
hint={S.schedule.nameHint}
|
||||
error={fieldErrors.name}
|
||||
value={form.name}
|
||||
disabled={form.editing !== null}
|
||||
onChange={(e) => set({ name: e.target.value })}
|
||||
@@ -444,7 +465,9 @@ export function SchedulesTab({ agentId }: { agentId: string }) {
|
||||
<Input
|
||||
size="sm"
|
||||
label={S.schedule.startAt}
|
||||
required
|
||||
type="datetime-local"
|
||||
error={fieldErrors.startAt}
|
||||
value={form.startAt}
|
||||
onChange={(e) => set({ startAt: e.target.value })}
|
||||
className="font-mono"
|
||||
@@ -470,6 +493,8 @@ export function SchedulesTab({ agentId }: { agentId: string }) {
|
||||
<Input
|
||||
size="sm"
|
||||
label={S.schedule.sessionId}
|
||||
required
|
||||
error={fieldErrors.sessionId}
|
||||
value={form.sessionId}
|
||||
onChange={(e) => set({ sessionId: e.target.value })}
|
||||
className="font-mono"
|
||||
@@ -518,8 +543,10 @@ export function SchedulesTab({ agentId }: { agentId: string }) {
|
||||
</div>
|
||||
<Textarea
|
||||
label={S.schedule.prompt}
|
||||
required
|
||||
size="sm"
|
||||
rows={4}
|
||||
error={fieldErrors.prompt}
|
||||
value={form.prompt}
|
||||
onChange={(e) => set({ prompt: e.target.value })}
|
||||
/>
|
||||
@@ -536,27 +563,20 @@ export function SchedulesTab({ agentId }: { agentId: string }) {
|
||||
)}
|
||||
</Modal>
|
||||
|
||||
{/* Delete confirmation (same pattern as Vault / Agent deletion: Modal + cancel/danger-confirm). */}
|
||||
<Modal
|
||||
{/* Delete confirmation (shared ConfirmModal, same pattern as Vault / Agent deletion). */}
|
||||
<ConfirmModal
|
||||
open={deleting !== null}
|
||||
title={S.schedule.deleteTitle}
|
||||
busy={busy}
|
||||
onClose={() => setDeleting(null)}
|
||||
footer={
|
||||
<>
|
||||
<Button onClick={() => setDeleting(null)}>{S.common.cancel}</Button>
|
||||
<Button variant="danger" disabled={busy} onClick={() => void confirmRemove()}>
|
||||
{S.common.confirm}
|
||||
</Button>
|
||||
</>
|
||||
}
|
||||
onConfirm={() => void confirmRemove()}
|
||||
>
|
||||
<p className="text-sm text-gray-600 dark:text-gray-300">
|
||||
{deleting !== null ? S.schedule.deleteConfirm(deleting) : ""}
|
||||
</p>
|
||||
</Modal>
|
||||
</ConfirmModal>
|
||||
|
||||
{error && <p className="text-xs text-red-600 dark:text-red-400">{error}</p>}
|
||||
{notice && <p className="text-xs text-emerald-600 dark:text-emerald-400">{notice}</p>}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -12,14 +12,16 @@
|
||||
import { useCallback, useEffect, useState } from "react";
|
||||
import type { VaultEntryInfo, VaultUpdateRequest } from "@prismshadow/penguin-server/api";
|
||||
import * as api from "../../api/endpoints";
|
||||
import { ApiError } from "../../api/client";
|
||||
import { S } from "../../lib/strings";
|
||||
import { apiErrorText } from "../../lib/api-error";
|
||||
import { useProject } from "../../state/project";
|
||||
import { Button } from "../../components/ui/button";
|
||||
import { Input } from "../../components/ui/input";
|
||||
import { PasswordInput } from "../../components/ui/password-input";
|
||||
import { Modal } from "../../components/ui/modal";
|
||||
import { ConfirmModal } from "../../components/ui/confirm-modal";
|
||||
import { SkeletonList } from "../../components/ui/skeleton";
|
||||
import { toastError, toastSuccess } from "../../components/ui/toast";
|
||||
|
||||
/** Vault key naming rule (consistent with core/server): shell environment variable name. */
|
||||
const VAULT_KEY_PATTERN = /^[A-Za-z_][A-Za-z0-9_]*$/;
|
||||
@@ -30,16 +32,19 @@ export function VaultTab({ agentId }: { agentId: string }) {
|
||||
const isOwner = currentProject?.role === "owner";
|
||||
|
||||
const [entries, setEntries] = useState<VaultEntryInfo[] | null>(null);
|
||||
// Tab-level error is only the initial load failure; saves/deletes report via toast.
|
||||
const [error, setError] = useState<string | null>(null);
|
||||
const [notice, setNotice] = useState<string | null>(null);
|
||||
const [busy, setBusy] = useState(false);
|
||||
// Add modal: both form state and errors travel with the modal (tab-level error would be hidden behind the modal).
|
||||
// Add modal: form state and per-field errors travel with the modal (a tab-level error would be hidden behind it).
|
||||
const [adding, setAdding] = useState(false);
|
||||
const [keyInput, setKeyInput] = useState("");
|
||||
const [valueInput, setValueInput] = useState("");
|
||||
const [addError, setAddError] = useState<string | null>(null);
|
||||
const [addErrors, setAddErrors] = useState<{ key?: string; value?: string }>({});
|
||||
const clearAddErrors = () => setAddErrors((p) => (p.key || p.value ? {} : p));
|
||||
// Key pending deletion confirmation (non-null shows the confirm modal).
|
||||
const [deleting, setDeleting] = useState<string | null>(null);
|
||||
// Existing key pending overwrite confirmation (adding a key that's already configured replaces its value).
|
||||
const [overwriting, setOverwriting] = useState<string | null>(null);
|
||||
|
||||
const load = useCallback(async () => {
|
||||
if (!projectId || !agentId) return;
|
||||
@@ -49,7 +54,7 @@ export function VaultTab({ agentId }: { agentId: string }) {
|
||||
const res = await api.getVault(projectId, agentId);
|
||||
setEntries(res.entries);
|
||||
} catch (e) {
|
||||
setError(e instanceof ApiError ? e.message : S.common.unknownError);
|
||||
setError(apiErrorText(e));
|
||||
}
|
||||
}, [projectId, agentId]);
|
||||
|
||||
@@ -61,14 +66,13 @@ export function VaultTab({ agentId }: { agentId: string }) {
|
||||
const persist = async (body: VaultUpdateRequest): Promise<string | null> => {
|
||||
if (!projectId || !agentId) return S.common.unknownError;
|
||||
setBusy(true);
|
||||
setNotice(null);
|
||||
try {
|
||||
const res = await api.putVault(projectId, agentId, body);
|
||||
setEntries(res.entries);
|
||||
setNotice(S.common.saved);
|
||||
toastSuccess(S.common.saved);
|
||||
return null;
|
||||
} catch (e) {
|
||||
return e instanceof ApiError ? e.message : S.common.unknownError;
|
||||
return apiErrorText(e);
|
||||
} finally {
|
||||
setBusy(false);
|
||||
}
|
||||
@@ -84,40 +88,42 @@ export function VaultTab({ agentId }: { agentId: string }) {
|
||||
const openAdd = () => {
|
||||
setKeyInput("");
|
||||
setValueInput("");
|
||||
setAddError(null);
|
||||
setAddErrors({});
|
||||
setAdding(true);
|
||||
};
|
||||
|
||||
const addEntry = async () => {
|
||||
const key = keyInput.trim();
|
||||
if (!key) {
|
||||
setAddError(S.common.requiredField);
|
||||
const next: { key?: string; value?: string } = {};
|
||||
if (!key) next.key = S.common.requiredField;
|
||||
else if (!VAULT_KEY_PATTERN.test(key)) next.key = S.vault.keyInvalid;
|
||||
if (!valueInput) next.value = S.vault.valueRequired;
|
||||
if (next.key || next.value) {
|
||||
setAddErrors(next);
|
||||
return;
|
||||
}
|
||||
if (!VAULT_KEY_PATTERN.test(key)) {
|
||||
setAddError(S.vault.keyInvalid);
|
||||
setAddErrors({});
|
||||
// Submitting an already-configured key overwrites its value (unrecoverable): confirm first.
|
||||
if (overwriting !== key && (entries ?? []).some((e) => e.key === key)) {
|
||||
setOverwriting(key);
|
||||
return;
|
||||
}
|
||||
if (!valueInput) {
|
||||
setAddError(S.vault.valueRequired);
|
||||
return;
|
||||
}
|
||||
setAddError(null);
|
||||
setOverwriting(null);
|
||||
// Upsert by same key name: don't resend the existing entry too, to avoid a 400 from PUT's duplicate-key validation.
|
||||
const err = await persist({ entries: [...keepEntries(key), { key, value: valueInput }] });
|
||||
if (err !== null) {
|
||||
setAddError(err);
|
||||
// Server rejection (e.g. duplicate key) — surface it on the key field.
|
||||
setAddErrors({ key: err });
|
||||
return;
|
||||
}
|
||||
setAdding(false);
|
||||
};
|
||||
|
||||
/** Confirm modal's "Confirm": closes the modal after deletion (on failure, the error lands on the tab's error line). */
|
||||
/** Confirm modal's "Confirm": closes the modal after deletion; a failure pops a toast. */
|
||||
const confirmRemove = async () => {
|
||||
if (deleting === null) return;
|
||||
setError(null);
|
||||
const err = await persist({ entries: keepEntries(deleting) });
|
||||
if (err !== null) setError(err);
|
||||
if (err !== null) toastError(err);
|
||||
setDeleting(null);
|
||||
};
|
||||
|
||||
@@ -200,9 +206,14 @@ export function VaultTab({ agentId }: { agentId: string }) {
|
||||
<Input
|
||||
size="sm"
|
||||
label={S.vault.key}
|
||||
required
|
||||
hint={S.vault.keyHint}
|
||||
error={addErrors.key}
|
||||
value={keyInput}
|
||||
onChange={(e) => setKeyInput(e.target.value)}
|
||||
onChange={(e) => {
|
||||
setKeyInput(e.target.value);
|
||||
clearAddErrors();
|
||||
}}
|
||||
className="font-mono"
|
||||
placeholder="OPENAI_API_KEY"
|
||||
autoComplete="off"
|
||||
@@ -210,39 +221,51 @@ export function VaultTab({ agentId }: { agentId: string }) {
|
||||
<PasswordInput
|
||||
size="sm"
|
||||
label={S.vault.value}
|
||||
required
|
||||
error={addErrors.value}
|
||||
value={valueInput}
|
||||
onChange={(e) => setValueInput(e.target.value)}
|
||||
onChange={(e) => {
|
||||
setValueInput(e.target.value);
|
||||
clearAddErrors();
|
||||
}}
|
||||
className="font-mono"
|
||||
autoComplete="off"
|
||||
onKeyDown={(e) => {
|
||||
if (e.key === "Enter" && !busy) void addEntry();
|
||||
}}
|
||||
/>
|
||||
{addError && <p className="text-xs text-red-600 dark:text-red-400">{addError}</p>}
|
||||
</div>
|
||||
</Modal>
|
||||
|
||||
{/* Delete confirmation (same pattern as Agent / Session deletion: Modal + cancel/danger-confirm). */}
|
||||
<Modal
|
||||
{/* Overwrite confirmation: the add modal stays underneath, so cancel returns to the form. */}
|
||||
<ConfirmModal
|
||||
open={overwriting !== null}
|
||||
title={S.vault.overwriteTitle}
|
||||
tone="primary"
|
||||
confirmLabel={S.common.save}
|
||||
busy={busy}
|
||||
onClose={() => setOverwriting(null)}
|
||||
onConfirm={() => void addEntry()}
|
||||
>
|
||||
<p className="text-sm text-gray-600 dark:text-gray-300">
|
||||
{overwriting !== null ? S.vault.overwriteConfirm(overwriting) : ""}
|
||||
</p>
|
||||
</ConfirmModal>
|
||||
|
||||
{/* Delete confirmation (shared ConfirmModal, same pattern as Agent / Session deletion). */}
|
||||
<ConfirmModal
|
||||
open={deleting !== null}
|
||||
title={S.vault.deleteTitle}
|
||||
busy={busy}
|
||||
onClose={() => setDeleting(null)}
|
||||
footer={
|
||||
<>
|
||||
<Button onClick={() => setDeleting(null)}>{S.common.cancel}</Button>
|
||||
<Button variant="danger" disabled={busy} onClick={() => void confirmRemove()}>
|
||||
{S.common.confirm}
|
||||
</Button>
|
||||
</>
|
||||
}
|
||||
onConfirm={() => void confirmRemove()}
|
||||
>
|
||||
<p className="text-sm text-gray-600 dark:text-gray-300">
|
||||
{deleting !== null ? S.vault.deleteConfirm(deleting) : ""}
|
||||
</p>
|
||||
</Modal>
|
||||
</ConfirmModal>
|
||||
|
||||
{error && <p className="text-xs text-red-600 dark:text-red-400">{error}</p>}
|
||||
{notice && <p className="text-xs text-emerald-600 dark:text-emerald-400">{notice}</p>}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
Reference in new issue
Block a user