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:
@@ -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>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -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>
|
||||
|
||||
@@ -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]);
|
||||
|
||||
|
||||
Reference in New Issue
Block a user