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:
Yaowei ZhengandClaude Opus 4.8 authored and GitHub committed 2026-07-24 18:43:18 +08:00
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>
);
}
+60 -37
View File
@@ -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>
);
}