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 Zheng
2026-07-24 18:43:18 +08:00
committed by GitHub
parent c7465625a4
commit 3f868ac385
50 changed files with 2391 additions and 1190 deletions
@@ -10,6 +10,7 @@ import type { UserInfo } 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 { USERNAME_PATTERN } from "../../lib/semantic-id";
import { formatDateTime } from "../../lib/format";
import { useDocumentTitle } from "../../lib/use-document-title";
@@ -34,7 +35,7 @@ export function AdminUsersPage() {
setUsers((await api.adminListUsers()).users);
setListError(null);
} catch (e) {
setListError(e instanceof ApiError ? e.message : S.common.unknownError);
setListError(apiErrorText(e));
}
}, []);
@@ -62,11 +63,11 @@ export function AdminUsersPage() {
<table className="w-full text-sm">
<thead>
<tr className="border-b border-gray-100 bg-gray-50 text-left text-xs text-gray-500 dark:border-gray-800 dark:bg-gray-900/60 dark:text-gray-400">
<th className="whitespace-nowrap px-3 py-2 font-medium">{S.admin.userId}</th>
<th className="whitespace-nowrap px-3 py-2 font-medium">{S.admin.role}</th>
<th className="whitespace-nowrap px-3 py-2 font-medium">{S.admin.createdAt}</th>
<th className="whitespace-nowrap px-3 py-2 font-medium">{S.common.username}</th>
<th className="whitespace-nowrap px-3 py-2 font-medium">{S.common.role}</th>
<th className="whitespace-nowrap px-3 py-2 font-medium">{S.common.created}</th>
<th className="whitespace-nowrap px-3 py-2 text-right font-medium">
{S.admin.actions}
{S.common.actions}
</th>
</tr>
</thead>
@@ -93,7 +94,7 @@ export function AdminUsersPage() {
</Button>
{!u.isAdmin && (
<Button size="sm" variant="ghost" onClick={() => setDeleting(u)}>
{S.admin.deleteUser}
{S.common.delete}
</Button>
)}
</td>
@@ -138,33 +139,40 @@ function CreateUserDialog({
}) {
const [userId, setUserId] = useState("");
const [password, setPassword] = useState("");
const [error, setError] = useState<string | null>(null);
const [errors, setErrors] = useState<{ userId?: string; password?: string }>({});
const [busy, setBusy] = useState(false);
const clearErrors = () => setErrors((p) => (p.userId || p.password ? {} : p));
useEffect(() => {
if (!open) return;
setUserId("");
setPassword("");
setError(null);
setErrors({});
}, [open]);
const submit = async () => {
const id = userId.trim();
if (!id || !password) {
setError(S.common.requiredField);
return;
}
if (!USERNAME_PATTERN.test(id)) {
setError(S.auth.usernameHint);
const next: { userId?: string; password?: string } = {};
if (!id) next.userId = S.common.requiredField;
else if (!USERNAME_PATTERN.test(id)) next.userId = S.auth.usernameHint;
if (!password) next.password = S.common.requiredField;
if (next.userId || next.password) {
setErrors(next);
return;
}
setBusy(true);
setError(null);
setErrors({});
try {
await api.adminCreateUser({ userId: id, password });
onDone();
} catch (e) {
setError(e instanceof ApiError ? e.message : S.common.unknownError);
// Route by error code: invalid_password is about the password's strength;
// user_exists (and anything unrecognized) is about the id.
if (e instanceof ApiError && e.code === "invalid_password") {
setErrors({ password: apiErrorText(e) });
} else {
setErrors({ userId: apiErrorText(e) });
}
} finally {
setBusy(false);
}
@@ -188,18 +196,28 @@ function CreateUserDialog({
>
<div className="space-y-3">
<Input
label={S.admin.userId}
label={S.common.username}
required
size="sm"
value={userId}
onChange={(e) => setUserId(e.target.value)}
onChange={(e) => {
setUserId(e.target.value);
clearErrors();
}}
error={errors.userId}
hint={S.auth.usernameHint}
autoFocus
/>
<PasswordInput
label={S.admin.initialPassword}
required
size="sm"
value={password}
onChange={(e) => setPassword(e.target.value)}
onChange={(e) => {
setPassword(e.target.value);
clearErrors();
}}
error={errors.password}
autoComplete="new-password"
hint={S.auth.passwordHint}
/>
@@ -208,7 +226,6 @@ function CreateUserDialog({
{S.admin.defaultProjectNote(`${userId.trim()}-default_project`)}
</p>
)}
{error && <p className="text-xs text-red-600 dark:text-red-400">{error}</p>}
</div>
</Modal>
);
@@ -216,28 +233,28 @@ function CreateUserDialog({
function ResetPasswordDialog({ user, onClose }: { user: UserInfo | null; onClose: () => void }) {
const [password, setPassword] = useState("");
const [error, setError] = useState<string | null>(null);
const [passwordError, setPasswordError] = useState<string | undefined>(undefined);
const [busy, setBusy] = useState(false);
useEffect(() => {
if (!user) return;
setPassword("");
setError(null);
setPasswordError(undefined);
}, [user]);
const submit = async () => {
if (!user) return;
if (!password) {
setError(S.common.requiredField);
setPasswordError(S.common.requiredField);
return;
}
setBusy(true);
setError(null);
setPasswordError(undefined);
try {
await api.adminResetPassword(user.userId, { password });
onClose();
} catch (e) {
setError(e instanceof ApiError ? e.message : S.common.unknownError);
setPasswordError(apiErrorText(e));
} finally {
setBusy(false);
}
@@ -262,15 +279,19 @@ function ResetPasswordDialog({ user, onClose }: { user: UserInfo | null; onClose
<div className="space-y-3">
<PasswordInput
label={S.admin.initialPassword}
required
size="sm"
value={password}
onChange={(e) => setPassword(e.target.value)}
onChange={(e) => {
setPassword(e.target.value);
setPasswordError(undefined);
}}
error={passwordError}
autoComplete="new-password"
hint={S.auth.passwordHint}
autoFocus
/>
<p className="text-xs text-gray-400 dark:text-gray-500">{S.admin.resetPasswordNote}</p>
{error && <p className="text-xs text-red-600 dark:text-red-400">{error}</p>}
</div>
</Modal>
);
@@ -303,7 +324,7 @@ function DeleteUserDialog({
await api.adminDeleteUser(user.userId);
onDone();
} catch (e) {
setError(e instanceof ApiError ? e.message : S.common.unknownError);
setError(apiErrorText(e));
} finally {
setBusy(false);
}
@@ -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>
);
}
@@ -20,8 +20,8 @@ import type {
BenchmarkSummary,
} 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 { useDocumentTitle } from "../../lib/use-document-title";
import { formatDateTime, formatMoney, formatScore, humanizeDuration } from "../../lib/format";
import { agentDisplayName, useProject } from "../../state/project";
@@ -75,7 +75,7 @@ function AgentNode({
api
.listBenchmarks(projectId, agentId)
.then((data) => setBenchmarks(data.benchmarks))
.catch((e: unknown) => setError(e instanceof ApiError ? e.message : S.common.unknownError));
.catch((e: unknown) => setError(apiErrorText(e)));
}, [open, benchmarks, projectId, agentId]);
return (
@@ -147,7 +147,7 @@ function metricLabel(metric: BenchmarkMetric): string {
return metric === "score"
? S.benchmark.colScore
: metric === "cost"
? S.benchmark.colCost
? S.common.cost
: S.benchmark.colDuration;
}
@@ -399,7 +399,7 @@ function EvaluationRow({
<tr className="text-xs text-gray-500">
<th className="px-2 py-1 font-medium">{S.benchmark.colCase}</th>
<th className="px-2 py-1 font-medium">{S.benchmark.colScore}</th>
<th className="px-2 py-1 font-medium">{S.benchmark.colCost}</th>
<th className="px-2 py-1 font-medium">{S.common.cost}</th>
<th className="px-2 py-1 font-medium">{S.benchmark.colDuration}</th>
<th className="px-2 py-1 font-medium">{S.benchmark.colSession}</th>
</tr>
@@ -565,11 +565,11 @@ export function BenchmarkPage() {
<table className="w-full min-w-[600px] 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.benchmark.colTime}</th>
<th className="px-3 py-2.5">{S.common.time}</th>
<th className="px-3 py-2.5">{S.benchmark.colVersion}</th>
<th className="px-3 py-2.5">{S.benchmark.colModel}</th>
<th className="px-3 py-2.5">{S.benchmark.colScore}</th>
<th className="px-3 py-2.5">{S.benchmark.colCost}</th>
<th className="px-3 py-2.5">{S.common.cost}</th>
<th className="px-3 py-2.5">{S.benchmark.colDuration}</th>
</tr>
</thead>
+10 -6
View File
@@ -26,6 +26,7 @@ import { S } from "../../lib/strings";
import { apiErrorText } from "../../lib/api-error";
import { useDocumentTitle } from "../../lib/use-document-title";
import { formatDateTime, formatMoney, humanizeDuration, humanizeTokens } from "../../lib/format";
import { latestConversation } from "../../lib/session-grouping";
import { approvalKey } from "../../lib/omni/stream-model";
import { useTheme } from "../../state/theme";
import { useProject } from "../../state/project";
@@ -203,14 +204,17 @@ export function ChatPage() {
};
}, [draft, routeSessionId, sessionsLoading, sessions, addSession]);
// Auto-select the most recent Session when the route doesn't select one; if there are none at all, fall back to draft state (instead of auto-creating one).
// Auto-select the most recent conversation when the route doesn't select one (newest loaded
// active/schedule Session — archived rows are hidden by choice and subagent Sessions belong
// to their parent, so neither is auto-opened); if there is none, fall back to draft state
// (instead of auto-creating one).
useEffect(() => {
if (sessionsLoading || draft) return;
if (routeSessionId && sessions.some((s) => s.sessionId === routeSessionId)) return;
// A routed id missing from the paged list isn't gone until the direct lookup fails.
if (routeSessionId && probeFailedId !== routeSessionId) return;
const first = sessions[0];
navigate(first ? `/chat/${first.sessionId}` : `/chat/${DRAFT_SESSION_ID}`, { replace: true });
const last = latestConversation(sessions);
navigate(last ? `/chat/${last.sessionId}` : `/chat/${DRAFT_SESSION_ID}`, { replace: true });
}, [sessionsLoading, draft, routeSessionId, probeFailedId, sessions, navigate]);
// Sync task_state to the sidebar list badge.
@@ -574,7 +578,7 @@ export function ChatPage() {
<StatChip
icon={STAT_ICONS.cost}
value={`${formatMoney(sessionCost, currency)}${costUncosted ? " *" : ""}`}
label={`${S.chat.statCost}(${currency})${costUncosted ? ` · ${S.usage.uncostedNote}` : ""}`}
label={`${S.common.cost}(${currency})${costUncosted ? ` · ${S.usage.uncostedNote}` : ""}`}
/>
)}
<StatChip
@@ -660,7 +664,7 @@ export function ChatPage() {
</div>
<div>
<p className="text-xs font-medium text-gray-500 dark:text-gray-400">
{S.models.createdAt}
{S.common.created}
</p>
<p className="font-mono text-xs">{formatDateTime(selected.createdAt)}</p>
</div>
@@ -672,7 +676,7 @@ export function ChatPage() {
<p className="font-mono text-xs">
{S.chat.statTokens} {humanizeTokens(totalTokens)}
{sessionCost != null &&
` · ${S.chat.statCost} ${formatMoney(sessionCost, currency)}`}{" "}
` · ${S.common.cost} ${formatMoney(sessionCost, currency)}`}{" "}
· {S.chat.statElapsed} {humanizeDuration(stream.model.stats.sessionElapsedMs)}
</p>
</div>
@@ -39,7 +39,6 @@ import type {
TaskInputPart,
} 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 { useAuth } from "../../state/auth";
@@ -245,7 +244,7 @@ export function DraftView({
})
.catch((e: unknown) => {
setThinkingLevel(rollback);
toastError(e instanceof ApiError ? e.message : S.common.unknownError);
toastError(apiErrorText(e));
});
},
[projectId, agentId],
@@ -810,7 +809,7 @@ function WorkspaceSelect({
api
.listDirs(projectId, abs)
.then(setDir)
.catch((e: unknown) => setError(e instanceof ApiError ? e.message : S.common.unknownError))
.catch((e: unknown) => setError(apiErrorText(e)))
.finally(() => setLoading(false));
},
[projectId],
@@ -69,7 +69,7 @@ function MessageMeta({
{text !== undefined && (
<button
type="button"
title={copied ? S.chat.copied : S.chat.copyMessage}
title={copied ? S.common.copied : S.chat.copyMessage}
aria-label={S.chat.copyMessage}
onClick={copy}
className="rounded p-0.5 text-gray-400 transition-colors hover:bg-gray-100 hover:text-gray-600 dark:hover:bg-gray-800 dark:hover:text-gray-300"
@@ -118,7 +118,7 @@ export function TaskStatsLine({
<StatChip
icon={STAT_ICONS.cost}
value={formatMoney(cost, currency)}
label={`${S.chat.statCost}(${currency})`}
label={`${S.common.cost}(${currency})`}
/>
)}
<StatChip
@@ -130,7 +130,7 @@ export function TaskStatsLine({
)}
<button
type="button"
title={copied ? S.chat.copied : S.chat.copyReply}
title={copied ? S.common.copied : S.chat.copyReply}
aria-label={S.chat.copyReply}
onClick={copy}
className="flex h-5 w-5 items-center justify-center rounded transition-colors duration-150 hover:bg-gray-100 hover:text-gray-600 dark:hover:bg-gray-800 dark:hover:text-gray-300"
@@ -21,8 +21,11 @@ import * as api from "../../api/endpoints";
import { ApiError } from "../../api/client";
import { useAuth } from "../../state/auth";
import { S } from "../../lib/strings";
import { apiErrorText } from "../../lib/api-error";
import { formatBytes, formatDateTime } from "../../lib/format";
import { Button } from "../../components/ui/button";
import { ConfirmModal } from "../../components/ui/confirm-modal";
import { toastError, toastSuccess } from "../../components/ui/toast";
import { Dropdown } from "../../components/ui/dropdown";
import { SkeletonList } from "../../components/ui/skeleton";
import { CodeBlock } from "./code-block";
@@ -195,8 +198,11 @@ export function WorkspaceBrowser({
const [data, setData] = useState<WorkspaceFilesResponse | null>(null);
const [error, setError] = useState<string | null>(null);
const [preview, setPreview] = useState<Preview | null>(null);
const [notice, setNotice] = useState<string | null>(null);
const [uploading, setUploading] = useState(false);
/** Picked files whose names collide with the loaded listing (non-null shows the overwrite confirm). */
const [pendingUpload, setPendingUpload] = useState<{ files: File[]; clashes: string[] } | null>(
null,
);
const [reloadTick, setReloadTick] = useState(0);
const [showPath, setShowPath] = useState(false);
/** HTML / Markdown preview: rendered view (HTML via sandboxed iframe, Markdown via md-body) / source toggle. */
@@ -223,7 +229,6 @@ export function WorkspaceBrowser({
setPath("");
setPreview(null);
setData(null);
setNotice(null);
}, [session.sessionId]);
// Edge-triggered refresh on the panel's hidden -> visible transition (doesn't count the initial mount: mounting itself already fetches once).
@@ -307,11 +312,8 @@ export function WorkspaceBrowser({
void previewPath(joinPath(path, name));
};
const onUpload = (e: ChangeEvent<HTMLInputElement>) => {
const files = e.target.files;
if (!files || files.length === 0) return;
const doUpload = (files: File[]) => {
setUploading(true);
setNotice(null);
setError(null);
void (async () => {
try {
@@ -327,15 +329,26 @@ export function WorkspaceBrowser({
});
await api.uploadWorkspaceFile(session.sessionId, joinPath(path, file.name), b64);
}
setNotice(S.files.uploaded);
toastSuccess(S.files.uploaded);
setReloadTick((t) => t + 1);
} catch (err) {
setError(err instanceof ApiError ? err.message : S.common.unknownError);
toastError(apiErrorText(err));
} finally {
setUploading(false);
}
})();
};
const onUpload = (e: ChangeEvent<HTMLInputElement>) => {
const files = e.target.files ? [...e.target.files] : [];
e.target.value = "";
if (files.length === 0) return;
// Uploads overwrite same-name files: names already present in the loaded listing
// confirm first (the picker is stashed — confirm continues, cancel drops it).
const existing = new Set((data?.entries ?? []).map((entry) => entry.name));
const clashes = files.filter((f) => existing.has(f.name)).map((f) => f.name);
if (clashes.length > 0) setPendingUpload({ files, clashes });
else doUpload(files);
};
const crumbs = path === "" ? [] : path.split("/");
@@ -693,10 +706,33 @@ export function WorkspaceBrowser({
))}
</ul>
)}
{notice && (
<p className="px-3 py-2 text-xs text-emerald-600 dark:text-emerald-400">{notice}</p>
)}
</div>
{/* Upload-overwrite confirmation: same-name files in this directory get replaced. */}
<ConfirmModal
open={pendingUpload !== null}
title={S.files.overwriteTitle}
tone="primary"
confirmLabel={S.files.upload}
onClose={() => setPendingUpload(null)}
onConfirm={() => {
if (pendingUpload) doUpload(pendingUpload.files);
setPendingUpload(null);
}}
>
<div className="space-y-2">
<p className="text-sm text-gray-600 dark:text-gray-300">
{S.files.overwriteConfirm(pendingUpload?.clashes.length ?? 0)}
</p>
<ul className="max-h-40 overflow-y-auto rounded-md border border-gray-200 px-3 py-1.5 dark:border-gray-800">
{(pendingUpload?.clashes ?? []).map((name) => (
<li key={name} className="truncate py-0.5 font-mono text-xs" title={name}>
{name}
</li>
))}
</ul>
</div>
</ConfirmModal>
</div>
);
}
@@ -39,8 +39,8 @@ import type {
ModelUpdateEntry,
} 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 { useDocumentTitle } from "../../lib/use-document-title";
import { useProject } from "../../state/project";
import { useAuth } from "../../state/auth";
@@ -48,11 +48,13 @@ import { USD_TO_CNY, useTheme } from "../../state/theme";
import type { Currency } from "../../state/theme";
import { Button } from "../../components/ui/button";
import { Input } from "../../components/ui/input";
import { FieldError, FieldLabel } from "../../components/ui/field";
import { PasswordInput } from "../../components/ui/password-input";
import { Modal } from "../../components/ui/modal";
import { ConfirmModal } from "../../components/ui/confirm-modal";
import { Select } from "../../components/ui/select";
import { Switch } from "../../components/ui/switch";
import { toastError, toastSuccess } from "../../components/ui/toast";
import { toastError, toastInfo, toastSuccess } from "../../components/ui/toast";
import { Badge } from "../../components/ui/badge";
import { Chevron } from "../../components/ui/chevron";
import { GlyphIcon } from "../../components/ui/glyph-icon";
@@ -233,11 +235,6 @@ export function nextPointers(args: {
};
}
/** Field-level error text: placed directly below the offending input (the input itself is highlighted red via Input's invalid prop). */
function FieldError({ text }: { text: string }) {
return <span className="mt-1 block text-xs text-red-600 dark:text-red-400">{text}</span>;
}
/** Fields in the config dialog that can be highlighted red on error (keys match RowState field names, so they can be cleared per edit action). */
type FieldErrors = Partial<
Record<
@@ -383,7 +380,7 @@ export function ModelsPage() {
setDefaultModel(res.defaultModel);
setVisionModel(res.visionModel);
} catch (e) {
setLoadError(e instanceof ApiError ? e.message : S.common.unknownError);
setLoadError(apiErrorText(e));
}
}, [projectId]);
@@ -431,7 +428,7 @@ export function ModelsPage() {
setRows(nextRows);
if (nextDefault !== undefined) setDefaultModel(nextDefault);
if (effectiveVision !== undefined) setVisionModel(effectiveVision);
toastError(e instanceof ApiError ? e.message : S.common.unknownError);
toastError(apiErrorText(e));
return false;
} finally {
setBusy(false);
@@ -449,7 +446,7 @@ export function ModelsPage() {
if (!rows) return;
const merged = syncRowsWithCatalog(rows);
if (merged.added === 0 && merged.updated === 0) {
toastSuccess(S.models.syncUpToDate);
toastInfo(S.models.syncUpToDate);
return;
}
await persist(
@@ -484,7 +481,7 @@ export function ModelsPage() {
setSpeedResults((prev) =>
new Map(prev).set(key, {
ok: false,
message: e instanceof ApiError ? e.message : S.common.unknownError,
message: apiErrorText(e),
}),
);
}
@@ -813,6 +810,7 @@ export function ModelsPage() {
<Input
size="sm"
label={S.models.groupNameLabel}
required
value={groupName}
invalid={Boolean(groupNameError)}
onChange={(e) => {
@@ -826,7 +824,7 @@ export function ModelsPage() {
className="font-mono"
autoFocus
/>
{groupNameError && <FieldError text={groupNameError} />}
{groupNameError && <FieldError>{groupNameError}</FieldError>}
</label>
<p className="mt-2 text-xs text-gray-500 dark:text-gray-400">{S.models.addGroupDesc}</p>
</Modal>
@@ -1121,7 +1119,7 @@ function ModelDialog({
if (res.ok) toastSuccess(S.models.testOk(res.latencyMs ?? 0));
else toastError(S.models.testFailed(res.message ?? ""));
} catch (e) {
toastError(S.models.testFailed(e instanceof ApiError ? e.message : S.common.unknownError));
toastError(S.models.testFailed(apiErrorText(e)));
} finally {
setTesting(false);
}
@@ -1133,6 +1131,17 @@ function ModelDialog({
* top-level banner: it's too far from the error site, and with three price fields it's
* hard to tell which one is wrong).
*/
// base URL required-field policy: an OpenAI-protocol endpoint can't be
// inferred — required for custom / user-defined groups and entries with an explicit
// openai protocol (gateway groups already have it pre-filled); optional for entries
// auto-routed within a first-party vendor group (the client has its own official
// default endpoint). Shared by validation and the label's required "*" mark.
const openAiLike =
form.clientType.trim().toLowerCase().includes("openai") ||
form.provider === "custom" ||
providerInfo(form.provider) === undefined;
const baseUrlRequired = !preset && openAiLike;
const validated = (): RowState | null => {
const modelId = form.modelId.trim();
const ref: ModelRefDto = { provider: form.provider, modelId };
@@ -1143,16 +1152,7 @@ function ModelDialog({
else if (!sameModelRef(ref, form.original) && existingRefs.some((r) => sameModelRef(r, ref))) {
errs.modelId = S.models.modelIdExists;
}
// base URL required-field policy: an OpenAI-protocol endpoint can't be
// inferred — required for custom / user-defined groups and entries with an explicit
// openai protocol (gateway groups already have it pre-filled); optional for entries
// auto-routed within a first-party vendor group (the client has its own official
// default endpoint).
const openAiLike =
form.clientType.trim().toLowerCase().includes("openai") ||
form.provider === "custom" ||
providerInfo(form.provider) === undefined;
if (!preset && openAiLike && !baseUrl) errs.baseUrl = S.models.baseUrlRequired;
if (baseUrlRequired && !baseUrl) errs.baseUrl = S.models.baseUrlRequired;
// Under PUT full-table replace semantics, omitting pricing means deleting it: all three
// prices must be either all empty or all filled, to avoid a partial entry silently
@@ -1241,6 +1241,10 @@ function ModelDialog({
<span className="mb-1 flex items-baseline justify-between gap-2">
<span className="text-xs font-semibold text-gray-600 dark:text-gray-400">
{S.models.modelId}
{/* Required mark, hand-placed: this label row is custom (link on the right), so FieldLabel's asterisk doesn't apply. */}
<span className="ml-0.5 text-red-500 dark:text-red-400" aria-hidden>
*
</span>
</span>
<span className="flex shrink-0 items-baseline gap-2.5">
{/* The model-homepage entry lives in the dialog header (top-right button); only the "get model ids" provider link stays here. */}
@@ -1258,6 +1262,7 @@ function ModelDialog({
</span>
<Input
size="sm"
required
value={form.modelId}
disabled={!canEdit}
invalid={Boolean(fieldErrors.modelId)}
@@ -1266,7 +1271,7 @@ function ModelDialog({
autoFocus={isNew}
placeholder={S.models.modelIdHint}
/>
{fieldErrors.modelId && <FieldError text={fieldErrors.modelId} />}
{fieldErrors.modelId && <FieldError>{fieldErrors.modelId}</FieldError>}
{/* Hint when auto-routing misses (first-party provider group, adding): warns without blocking. */}
{autoRouteMiss && (
<span className="mt-1 block text-xs text-amber-600 dark:text-amber-400">
@@ -1346,10 +1351,26 @@ function ModelDialog({
<Button
variant="primary"
onClick={() => {
// Validate first: if validation fails, show it via the top-level hint right away without popping the confirm dialog.
// Validate first: if validation fails, the inline field errors show right away without popping the confirm dialog.
if (!validated()) return;
if (isNew) submit("save");
else setConfirming("save");
if (isNew || row === null) {
submit("save");
return;
}
// Nothing changed: report it instead of confirming a no-op write (the
// baseline is rebuilt exactly like the form's initial state, so a plain
// JSON compare is field-exact).
const initial: RowState = {
...row,
cacheRead: usdToInput(row.cacheRead, currency),
cacheWrite: usdToInput(row.cacheWrite, currency),
output: usdToInput(row.output, currency),
};
if (JSON.stringify(form) === JSON.stringify(initial)) {
toastInfo(S.common.noChangesToSave);
return;
}
setConfirming("save");
}}
>
{S.common.confirm}
@@ -1498,7 +1519,7 @@ function ModelDialog({
<span className="font-mono">{form.credential.apiKeyMasked}</span>
{form.credential.createdAt && (
<span className="text-gray-400">
{S.models.createdAt} {formatDateTime(form.credential.createdAt)}
{S.common.created} {formatDateTime(form.credential.createdAt)}
</span>
)}
{canEdit && (
@@ -1514,16 +1535,20 @@ function ModelDialog({
</div>
)}
{/* 2) base URL */}
{/* 2) base URL (required for custom / user-defined groups and explicit openai protocol — see
baseUrlRequired). Official-protocol entries (everything except the OpenAI-protocol path)
carry a caution: a custom endpoint must still speak the vendor's official protocol. */}
<Input
size="sm"
label={S.models.baseUrl}
required={baseUrlRequired}
value={form.baseUrl}
disabled={!canEdit}
onChange={(e) => set({ baseUrl: e.target.value })}
className="font-mono"
placeholder={preset ? S.models.baseUrlHint : "https://…"}
{...(fieldErrors.baseUrl ? { error: fieldErrors.baseUrl } : {})}
{...(!openAiLike ? { hint: S.models.baseUrlOfficialNote } : {})}
/>
{/* 3) Context window + max output tokens side by side (one row): the "Token" unit
@@ -1536,9 +1561,7 @@ function ModelDialog({
model stay under its window). */}
<div className="grid grid-cols-2 items-start gap-2">
<label className="block">
<span className="mb-1 block text-xs font-semibold text-gray-600 dark:text-gray-400">
{S.models.contextWindow}
</span>
<FieldLabel>{S.models.contextWindow}</FieldLabel>
<span className="relative block">
<Input
size="sm"
@@ -1564,12 +1587,10 @@ function ModelDialog({
{S.models.tokenUnit}
</span>
</span>
{fieldErrors.contextWindow && <FieldError text={fieldErrors.contextWindow} />}
{fieldErrors.contextWindow && <FieldError>{fieldErrors.contextWindow}</FieldError>}
</label>
<label className="block">
<span className="mb-1 block text-xs font-semibold text-gray-600 dark:text-gray-400">
{S.models.maxTokens}
</span>
<FieldLabel>{S.models.maxTokens}</FieldLabel>
<span className="relative block">
<Input
size="sm"
@@ -1587,7 +1608,7 @@ function ModelDialog({
{S.models.tokenUnit}
</span>
</span>
{fieldErrors.maxTokens && <FieldError text={fieldErrors.maxTokens} />}
{fieldErrors.maxTokens && <FieldError>{fieldErrors.maxTokens}</FieldError>}
</label>
</div>
@@ -1604,9 +1625,7 @@ function ModelDialog({
] as Array<[keyof FieldErrors & keyof RowState, string, string]>
).map(([key, label, value]) => (
<label key={key} className="block">
<span className="mb-1 block text-xs font-semibold text-gray-600 dark:text-gray-400">
{label}
</span>
<FieldLabel>{label}</FieldLabel>
<span className="relative block">
<span className="pointer-events-none absolute inset-y-0 left-2 flex items-center text-xs text-gray-400">
{CURRENCY_SYMBOL[currency]}
@@ -1624,7 +1643,7 @@ function ModelDialog({
{S.models.priceUnitShort}
</span>
</span>
{fieldErrors[key] && <FieldError text={fieldErrors[key]} />}
{fieldErrors[key] && <FieldError>{fieldErrors[key]}</FieldError>}
</label>
))}
</div>
@@ -1669,30 +1688,21 @@ function ModelDialog({
{/* Confirmation before writing config (save / set default / set as vision proxy model / remove): stacked on top of the config dialog. */}
{confirming && (
<Modal
<ConfirmModal
open
title={CONFIRM_TITLE[confirming]()}
tone={confirming === "remove" ? "danger" : "primary"}
onClose={() => setConfirming(null)}
footer={
<>
<Button onClick={() => setConfirming(null)}>{S.common.cancel}</Button>
<Button
variant={confirming === "remove" ? "danger" : "primary"}
onClick={() => {
const action = confirming;
setConfirming(null);
submit(action);
}}
>
{S.common.confirm}
</Button>
</>
}
onConfirm={() => {
const action = confirming;
setConfirming(null);
submit(action);
}}
>
<p className="text-sm text-gray-700 dark:text-gray-300">
{CONFIRM_BODY[confirming](form.displayName ?? form.modelId)}
</p>
</Modal>
</ConfirmModal>
)}
</Modal>
);
@@ -1733,6 +1743,7 @@ function GroupKeyDialog({
<Input
size="sm"
label={S.models.apiKey}
required
type="password"
value={key}
onChange={(e) => setKey(e.target.value)}
@@ -17,10 +17,12 @@
* localizedShortText), and groups have no description. Icon buttons for
* actions (copy goes into aria-label and title) —
* - Rotate "update installs" (shown only when some Agent's installed copy has
* a lower version than the library): one click reinstalls the current
* library copy on every outdated Agent (install-again-is-update semantics),
* with a single success toast; the manage-installs Modal marks outdated
* rows with an accent "更新"/"Update" button doing the same per Agent;
* a lower version than the library): opens a confirm dialog (lists each
* Agent's v_old → v_new and warns the overwriting reinstall drops local
* edits), then reinstalls the current library copy on every outdated Agent
* (install-again-is-update semantics), with a single success toast; the
* manage-installs Modal marks outdated rows with an accent "更新"/"Update"
* button doing the same per Agent (through the same confirm);
* - Paper plane "quick invoke": enters /chat/new draft mode with default_agent,
* pre-selects the skill, and pre-fills the invocation text per UI language
* (zh "使用 X 技能" / en "use the X skill", overwriting any existing draft body);
@@ -47,6 +49,7 @@ import { Button } from "../../components/ui/button";
import { Chevron } from "../../components/ui/chevron";
import { GlyphIcon } from "../../components/ui/glyph-icon";
import { Modal } from "../../components/ui/modal";
import { ConfirmModal } from "../../components/ui/confirm-modal";
import { Skeleton, SkeletonCard } from "../../components/ui/skeleton";
import { toastError, toastSuccess } from "../../components/ui/toast";
import { DRAFT_SESSION_ID } from "../chat/chat-page";
@@ -353,6 +356,25 @@ function SkillCard({
const { locale } = useLocale();
const { agents } = useProject();
const [installOpen, setInstallOpen] = useState(false);
// Agents pending an update confirmation (null = none): an update is an overwriting reinstall, so it needs a confirm + a per-agent version list before it runs.
const [pendingUpdate, setPendingUpdate] = useState<string[] | null>(null);
const [updating, setUpdating] = useState(false);
// Agent pending an uninstall confirmation (null = none): uninstalling deletes the installed files, local edits included.
const [pendingUninstall, setPendingUninstall] = useState<string | null>(null);
const confirmUpdate = async () => {
if (!pendingUpdate) return;
setUpdating(true);
await onUpdateOutdated(skill.name, pendingUpdate);
setUpdating(false);
setPendingUpdate(null);
};
/** Display name of the Agent pending uninstall (falls back to the raw id below). */
const uninstallAgent =
pendingUninstall !== null ? agents.find((a) => a.agentId === pendingUninstall) : undefined;
const uninstallAgentName = uninstallAgent ? agentDisplayName(uninstallAgent) : undefined;
let installedCount = 0;
for (const m of installed.values()) if (m.has(skill.name)) installedCount += 1;
const outdated = outdatedAgentIds(
@@ -416,7 +438,7 @@ function SkillCard({
className="h-8 w-8 shrink-0 justify-center p-0"
aria-label={`${S.skills.updateOutdated(outdated.length)} ${skill.name}`}
title={S.skills.updateOutdated(outdated.length)}
onClick={() => void onUpdateOutdated(skill.name, outdated)}
onClick={() => setPendingUpdate(outdated)}
>
<GlyphIcon d={UPDATE_ICON} size={13} />
</Button>
@@ -457,13 +479,71 @@ function SkillCard({
name={agentDisplayName(a)}
installed={installed.get(a.agentId)?.has(skill.name) ?? false}
outdated={outdated.includes(a.agentId)}
onToggle={(on) => void onToggleInstall(a.agentId, skill.name, on, skill.version)}
onUpdate={() => void onUpdateOutdated(skill.name, [a.agentId])}
onToggle={(on) => {
// Install runs directly; uninstall deletes the installed files, so it confirms first.
if (on) void onToggleInstall(a.agentId, skill.name, true, skill.version);
else setPendingUninstall(a.agentId);
}}
onUpdate={() => setPendingUpdate([a.agentId])}
/>
))}
</div>
</Modal>
)}
{pendingUpdate && (
<ConfirmModal
open
title={S.skills.updateConfirmTitle(skill.name)}
tone="primary"
confirmLabel={S.skills.updateAction}
busy={updating}
onClose={() => setPendingUpdate(null)}
onConfirm={() => void confirmUpdate()}
>
<div className="space-y-3">
<p className="text-sm text-gray-600 dark:text-gray-300">
{S.skills.updateConfirmWarning(skill.name)}
</p>
{/* Per-agent old → new version, so it's clear exactly which installs get overwritten. */}
<ul className="divide-y divide-gray-100 overflow-hidden rounded-md border border-gray-200 dark:divide-gray-800 dark:border-gray-800">
{pendingUpdate.map((agentId) => {
const oldVersion = installed.get(agentId)?.get(skill.name);
const target = agents.find((a) => a.agentId === agentId);
return (
<li
key={agentId}
className="flex items-center justify-between gap-3 px-3 py-1.5 text-xs"
>
<span className="min-w-0 truncate">
{target ? agentDisplayName(target) : agentId}
</span>
<span className="shrink-0 font-mono text-gray-500 dark:text-gray-400">
v{oldVersion ?? "?"} → v{skill.version}
</span>
</li>
);
})}
</ul>
</div>
</ConfirmModal>
)}
{pendingUninstall !== null && (
<ConfirmModal
open
title={S.skills.uninstallConfirmTitle(skill.name)}
confirmLabel={S.skills.uninstall}
onClose={() => setPendingUninstall(null)}
onConfirm={() => {
const agentId = pendingUninstall;
setPendingUninstall(null);
void onToggleInstall(agentId, skill.name, false, skill.version);
}}
>
<p className="text-sm text-gray-600 dark:text-gray-300">
{S.skills.uninstallConfirmBody(skill.name, uninstallAgentName ?? pendingUninstall)}
</p>
</ConfirmModal>
)}
</div>
);
}
@@ -31,8 +31,8 @@ import type {
TraceToolSpan,
} 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 {
cacheHitRate,
computeTps,
@@ -209,7 +209,7 @@ export function TraceFileView({
setModels(m);
})
.catch((err: unknown) => {
if (!cancelled) setError(err instanceof ApiError ? err.message : S.common.unknownError);
if (!cancelled) setError(apiErrorText(err));
});
return () => {
cancelled = true;
@@ -425,7 +425,7 @@ export function TraceFileView({
{/* Duration · cost · TPS (cost above duration, same order as the conversation page's stats row). */}
<div>
<SummaryRow
label={S.chat.statCost}
label={S.common.cost}
value={formatMoney(costOf(global.buckets), currency)}
/>
<SummaryRow
@@ -497,7 +497,7 @@ export function TraceFileView({
<StatChip
icon={STAT_ICONS.cost}
value={formatMoney(costOf(tokens), currency)}
label={`${S.chat.statCost}(${currency})`}
label={`${S.common.cost}(${currency})`}
/>
<StatChip
icon={STAT_ICONS.elapsed}
@@ -9,8 +9,8 @@ import { useEffect, useRef, useState } from "react";
import { useSearchParams } from "react-router";
import type { AgentTracesResponse } 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 { useDocumentTitle } from "../../lib/use-document-title";
import { formatBytes } from "../../lib/format";
import { agentDisplayName, useProject } from "../../state/project";
@@ -91,7 +91,7 @@ function AgentNode({
api
.getAgentTraces(projectId, agentId)
.then((data) => setGroups(flattenSessions(data)))
.catch((e: unknown) => setError(e instanceof ApiError ? e.message : S.common.unknownError));
.catch((e: unknown) => setError(apiErrorText(e)));
}, [open, groups, projectId, agentId]);
// The Session deep link is applied only once: it selects the target as
@@ -9,6 +9,7 @@
* exceptions) is a prominent rose; expected (HttpError, business 4xx) recedes into gray.
* The outer frame is provided by the caller's ChartCard (full width, below the four business charts).
*/
import { useState } from "react";
import type { UsageErrors } from "@prismshadow/penguin-server/api";
import { S } from "../../lib/strings";
import { formatDateTime } from "../../lib/format";
@@ -60,10 +61,23 @@ function Th({ children, className = "" }: { children: React.ReactNode; className
/**
* Error panel: stats + a recent-errors table (the server already takes the top N, newest first).
* The table is table-fixed with in-cell truncation: a long message doesn't break the layout, and the full text goes into title.
* The message column shows **one line per error by default** (kept compact — an error storm can
* fill the table); clicking a message expands it in place to the full text (wrapping, newlines
* preserved — the upstream detail after the code, e.g. a provider's 402 body, is what matters),
* and clicking again collapses it. The full text is also in the hover title. Cells align to the
* top so an expanded multi-line message keeps the row tidy; the table scrolls past max height.
*/
export function ErrorsPanel({ errors }: { errors: UsageErrors }) {
const { total, unexpected, topCode, recent } = errors;
// Message rows expanded to their full text (index into `recent`); one line each by default.
const [expanded, setExpanded] = useState<ReadonlySet<number>>(new Set());
const toggle = (i: number) =>
setExpanded((prev) => {
const next = new Set(prev);
if (next.has(i)) next.delete(i);
else next.add(i);
return next;
});
return (
<div>
@@ -93,7 +107,7 @@ export function ErrorsPanel({ errors }: { errors: UsageErrors }) {
<table className="w-full table-fixed text-xs">
<thead className="sticky top-0 bg-white text-left text-gray-400 dark:bg-gray-900 dark:text-gray-500">
<tr>
<Th className="w-32">{S.usage.errorsColTime}</Th>
<Th className="w-32">{S.common.time}</Th>
{/* Wide enough to fully fit the longest error code: a tool
failure's code carries the tool name (e.g. environment ·
tool_failed:exec_command), and truncating it would hide which tool failed. */}
@@ -110,21 +124,29 @@ export function ErrorsPanel({ errors }: { errors: UsageErrors }) {
key={`${e.ts}-${i}`}
className="border-t border-gray-100 dark:border-gray-800/60"
>
<td className="py-1.5 pr-2 font-mono tabular-nums text-gray-400">
<td className="py-1.5 pr-2 align-top font-mono tabular-nums text-gray-400">
{formatDateTime(e.ts)}
</td>
<td className="py-1.5 pr-2 font-mono text-gray-500 dark:text-gray-400">
<span className="block truncate" title={`${e.source} · ${e.code}`}>
<td className="py-1.5 pr-2 align-top font-mono text-gray-500 dark:text-gray-400">
<span className="block break-words">
{e.source} · {e.code}
</span>
</td>
<td className="py-1.5 pr-2">
<td className="py-1.5 pr-2 align-top">
<Badge tone={key === "unexpected" ? "red" : "gray"}>{kindLabel(key)}</Badge>
</td>
<td className="py-1.5 text-gray-500 dark:text-gray-400">
<span className="block truncate" title={e.message}>
<td className="py-1.5 align-top text-gray-500 dark:text-gray-400">
{/* One line by default; click to expand to the full message (wrapping), click again to collapse. */}
<button
type="button"
title={e.message}
onClick={() => toggle(i)}
className={`block w-full cursor-pointer text-left transition-colors hover:text-gray-700 dark:hover:text-gray-300 ${
expanded.has(i) ? "whitespace-pre-wrap break-words" : "truncate"
}`}
>
{e.message}
</span>
</button>
</td>
</tr>
);
@@ -16,8 +16,8 @@ import { useCallback, useEffect, useState } from "react";
import { useSearchParams } from "react-router";
import type { ModelRefDto, UsageBucket, UsageResponse } 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 { useDocumentTitle } from "../../lib/use-document-title";
import { formatMoney, humanizeTokens } from "../../lib/format";
import { catalogEntryFor } from "@prismshadow/penguin-core/model-catalog";
@@ -81,7 +81,7 @@ function SummaryCard({
<SummaryRow label={S.usage.requests} value={String(bucket.requests)} muted />
{/* The unpriced-records asterisk sits on the word "cost" (superscript), keeping the number clean and readable; see the footer for the explanation */}
<SummaryRow
label={S.usage.colCost}
label={S.common.cost}
value={formatMoney(bucket.cost, currency)}
sup={bucket.hasUncosted}
/>
@@ -159,7 +159,7 @@ export function UsagePage() {
});
setData(res);
} catch (e) {
setError(e instanceof ApiError ? e.message : S.common.unknownError);
setError(apiErrorText(e));
}
}, [projectId, from, to, agentFilter, modelFilter?.provider, modelFilter?.modelId]);