Initialize repository with harness code and assets
Initial import of all source code, config, and README assets: the packages workspace (cli, core, server, web, docs, landing, skills), build scripts, tooling config, and CI workflows. Includes the data-layout revision made on this branch: the local data root defaults to ~/.penguin/data (PENGUIN_HOME still overrides; the installer keeps its binaries in ~/.penguin), and every Agent lives under <project>/agents/<agent>/ — path helpers, the three agent-enumeration scans, the system prompt, built-in Skills, tests and docs all follow the new layout. Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_018ihk8iQuo3kv2aPjAYEPuR
This commit is contained in:
@@ -0,0 +1,49 @@
|
||||
/**
|
||||
* Session activity sparkline (Agents list card, GitHub-repo-Pulse-graph style): a
|
||||
* polyline + faint fill of daily active Session counts over the last N days,
|
||||
* normalized to the max value within the window — shows only relative ups and downs,
|
||||
* no scale/ticks. All-zero / empty data renders a flat baseline at the bottom. Pure
|
||||
* SVG, no dependencies; color follows the light/dark theme.
|
||||
*/
|
||||
|
||||
const W = 100;
|
||||
const H = 30;
|
||||
const PAD = 2;
|
||||
|
||||
export function ActivitySparkline({
|
||||
data,
|
||||
label,
|
||||
className = "",
|
||||
}: {
|
||||
data: number[];
|
||||
label: string;
|
||||
className?: string;
|
||||
}) {
|
||||
const max = Math.max(1, ...data);
|
||||
const step = data.length > 1 ? (W - 2 * PAD) / (data.length - 1) : 0;
|
||||
const pts = data.map((v, i) => [PAD + i * step, H - PAD - (v / max) * (H - 2 * PAD)] as const);
|
||||
const baseline = `${PAD},${H - PAD} ${W - PAD},${H - PAD}`;
|
||||
const line = pts.length > 1 ? pts.map(([x, y]) => `${x},${y}`).join(" ") : baseline;
|
||||
const area = pts.length > 1 ? `${line} ${W - PAD},${H - PAD} ${PAD},${H - PAD}` : null;
|
||||
return (
|
||||
<svg
|
||||
width={W}
|
||||
height={H}
|
||||
viewBox={`0 0 ${W} ${H}`}
|
||||
role="img"
|
||||
aria-label={label}
|
||||
className={`text-emerald-600 dark:text-emerald-500 ${className}`}
|
||||
>
|
||||
<title>{label}</title>
|
||||
{area !== null && <polygon points={area} fill="currentColor" fillOpacity="0.12" />}
|
||||
<polyline
|
||||
points={line}
|
||||
fill="none"
|
||||
stroke="currentColor"
|
||||
strokeWidth="1.5"
|
||||
strokeLinecap="round"
|
||||
strokeLinejoin="round"
|
||||
/>
|
||||
</svg>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,736 @@
|
||||
/**
|
||||
* Agent settings page: six tabs —
|
||||
* Overview (name/description/State path/active count/State version + snapshot
|
||||
* export-import), Prompt (AGENTS.md and system_prompt editors + placeholder
|
||||
* reference), Runtime (max_turns, model.*, compaction.*), Tools (editable built-in
|
||||
* tools table, MCP Server read-only JSON), Vault (vault-tab.tsx), Schedule
|
||||
* (schedules-tab.tsx).
|
||||
* Save = PUT config (sends only the changed keys; YAML comments are preserved
|
||||
* server-side).
|
||||
*/
|
||||
import { useCallback, useEffect, useRef, useState } from "react";
|
||||
import type { ChangeEvent } from "react";
|
||||
import { useNavigate, useParams } from "react-router";
|
||||
import type {
|
||||
AgentConfigResponse,
|
||||
AgentConfigUpdateRequest,
|
||||
AgentCompactionConfigDto,
|
||||
AgentModelConfigDto,
|
||||
} from "@prismshadow/penguin-server/api";
|
||||
import type { ToolDefinitionConfig, ToolPermission } from "@prismshadow/penguin-core/interfaces";
|
||||
import * as api from "../../api/endpoints";
|
||||
import { ApiError } from "../../api/client";
|
||||
import { S } from "../../lib/strings";
|
||||
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 { Input, Textarea } from "../../components/ui/input";
|
||||
import { OptionMenu, type OptionMenuChoice } from "../../components/ui/option-menu";
|
||||
import { Modal } from "../../components/ui/modal";
|
||||
import { Skeleton } from "../../components/ui/skeleton";
|
||||
import { VaultTab } from "./vault-tab";
|
||||
import { SchedulesTab } from "./schedules-tab";
|
||||
|
||||
type TabKey = "overview" | "prompt" | "runtime" | "tools" | "vault" | "schedules";
|
||||
|
||||
/** "" represents not overridden (falls back to the current config), corresponding to the defaultValue placeholder row. */
|
||||
function withDefaultOption(
|
||||
entries: ReadonlyArray<readonly [string, string]>,
|
||||
): ReadonlyArray<OptionMenuChoice<string>> {
|
||||
return entries.map(([value, description]) => ({
|
||||
value,
|
||||
triggerLabel: value || S.agent.defaultValue,
|
||||
label: value || S.agent.defaultValue,
|
||||
description,
|
||||
}));
|
||||
}
|
||||
|
||||
/** Numeric input's string state → number (empty/invalid = undefined, meaning no change). */
|
||||
function parseNum(s: string): number | undefined {
|
||||
const trimmed = s.trim();
|
||||
if (!trimmed) return undefined;
|
||||
const n = Number(trimmed);
|
||||
return Number.isFinite(n) ? n : undefined;
|
||||
}
|
||||
|
||||
function numToStr(n: number | undefined): string {
|
||||
return n === undefined ? "" : String(n);
|
||||
}
|
||||
|
||||
export function AgentSettingsPage() {
|
||||
// Read inside the component: after a language switch remount, this picks up the current dictionary.
|
||||
const TABS = [
|
||||
{ key: "overview", label: S.agent.tabOverview },
|
||||
{ key: "prompt", label: S.agent.tabPrompt },
|
||||
{ key: "runtime", label: S.agent.tabRuntime },
|
||||
{ key: "tools", label: S.agent.tabTools },
|
||||
{ key: "vault", label: S.agent.tabVault },
|
||||
{ key: "schedules", label: S.agent.tabSchedules },
|
||||
] as const;
|
||||
const navigate = useNavigate();
|
||||
const params = useParams<{ agentId: string }>();
|
||||
const agentId = params.agentId ?? "";
|
||||
useDocumentTitle(S.agent.settings);
|
||||
const { currentProject, reloadAgents } = useProject();
|
||||
const projectId = currentProject?.projectId ?? null;
|
||||
|
||||
const [data, setData] = useState<AgentConfigResponse | null>(null);
|
||||
const [tab, setTab] = useState<TabKey>("overview");
|
||||
const [error, setError] = useState<string | null>(null);
|
||||
const [notice, setNotice] = useState<string | null>(null);
|
||||
|
||||
const load = useCallback(() => {
|
||||
if (!projectId || !agentId) return;
|
||||
setData(null);
|
||||
setError(null);
|
||||
api
|
||||
.getAgentConfig(projectId, agentId)
|
||||
.then(setData)
|
||||
.catch((e: unknown) => setError(e instanceof ApiError ? e.message : S.common.unknownError));
|
||||
}, [projectId, agentId]);
|
||||
|
||||
useEffect(() => {
|
||||
load();
|
||||
}, [load]);
|
||||
|
||||
/** 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));
|
||||
load();
|
||||
void reloadAgents();
|
||||
},
|
||||
[load, reloadAgents],
|
||||
);
|
||||
|
||||
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);
|
||||
// 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);
|
||||
}
|
||||
},
|
||||
[projectId, agentId, reloadAgents],
|
||||
);
|
||||
|
||||
if (!projectId) return null;
|
||||
if (error && !data) {
|
||||
return <p className="p-6 text-sm text-red-600 dark:text-red-400">{error}</p>;
|
||||
}
|
||||
if (!data) {
|
||||
return (
|
||||
<div className="space-y-3 p-6">
|
||||
<Skeleton className="h-6 w-64" />
|
||||
<Skeleton className="h-32 w-full" />
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
return (
|
||||
<div className="no-scrollbar h-full overflow-y-auto p-4 md:p-6">
|
||||
<div className="mx-auto max-w-3xl">
|
||||
<Button
|
||||
variant="ghost"
|
||||
size="sm"
|
||||
onClick={() => navigate("/agents")}
|
||||
className="-ml-2 mb-3 text-gray-500 dark:text-gray-400"
|
||||
>
|
||||
<svg
|
||||
width="14"
|
||||
height="14"
|
||||
viewBox="0 0 24 24"
|
||||
fill="none"
|
||||
stroke="currentColor"
|
||||
strokeWidth="1.8"
|
||||
strokeLinecap="round"
|
||||
strokeLinejoin="round"
|
||||
aria-hidden
|
||||
>
|
||||
<path d="M15 18l-6-6 6-6" />
|
||||
<path d="M9 12h12" />
|
||||
</svg>
|
||||
{S.agent.backToList}
|
||||
</Button>
|
||||
<h1 className="mb-1 text-xl font-semibold">{data.config.name ?? agentId}</h1>
|
||||
<p className="mb-4 font-mono text-xs text-gray-400">{agentId}</p>
|
||||
<Tabs items={TABS} active={tab} onChange={setTab} />
|
||||
<div className="py-4">
|
||||
{tab === "overview" && (
|
||||
<OverviewTab data={data} agentId={agentId} onSave={save} onImported={onImported} />
|
||||
)}
|
||||
{tab === "prompt" && <PromptTab data={data} onSave={save} />}
|
||||
{tab === "runtime" && <RuntimeTab data={data} onSave={save} />}
|
||||
{tab === "tools" && <ToolsTab data={data} onSave={save} />}
|
||||
{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>
|
||||
);
|
||||
}
|
||||
|
||||
type SaveFn = (update: AgentConfigUpdateRequest) => Promise<void>;
|
||||
|
||||
/** <a download>/<label> version of the button look (matches Button secondary sm; the Button component only renders <button>). */
|
||||
const TRANSFER_BUTTON_CLASS =
|
||||
"inline-flex cursor-pointer items-center justify-center gap-1 rounded-md border border-gray-300 " +
|
||||
"bg-white px-2.5 py-1 text-xs font-medium text-gray-800 transition-colors duration-150 " +
|
||||
"hover:bg-gray-50 focus-within:ring-2 focus-within:ring-gray-400/30 " +
|
||||
"dark:border-gray-700 dark:bg-gray-900 dark:text-gray-200 dark:hover:bg-gray-800";
|
||||
|
||||
function OverviewTab({
|
||||
data,
|
||||
agentId,
|
||||
onSave,
|
||||
onImported,
|
||||
}: {
|
||||
data: AgentConfigResponse;
|
||||
agentId: string;
|
||||
onSave: SaveFn;
|
||||
onImported: (version: number) => void;
|
||||
}) {
|
||||
const { currentProject } = useProject();
|
||||
const projectId = currentProject?.projectId ?? null;
|
||||
const isOwner = currentProject?.role === "owner";
|
||||
const [name, setName] = useState(data.config.name ?? "");
|
||||
const [description, setDescription] = useState(data.config.description ?? "");
|
||||
const [importing, setImporting] = useState(false);
|
||||
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 submit = () => {
|
||||
const config: NonNullable<AgentConfigUpdateRequest["config"]> = {};
|
||||
if (name.trim() !== (data.config.name ?? "")) config.name = name.trim();
|
||||
if (description.trim() !== (data.config.description ?? "")) {
|
||||
config.description = description.trim();
|
||||
}
|
||||
if (Object.keys(config).length === 0) return;
|
||||
void onSave({ config });
|
||||
};
|
||||
|
||||
const runImport = async (dataBase64: string, confirm: boolean) => {
|
||||
if (!projectId) return;
|
||||
setImporting(true);
|
||||
setImportError(null);
|
||||
try {
|
||||
const res = await api.importAgent(projectId, agentId, {
|
||||
dataBase64,
|
||||
...(confirm ? { confirm: true } : {}),
|
||||
});
|
||||
setConflict(null);
|
||||
onImported(res.version);
|
||||
} catch (e) {
|
||||
if (e instanceof ApiError && e.status === 409 && e.code === "version_conflict") {
|
||||
setConflict(dataBase64); // resend with confirm: true after confirming
|
||||
} else {
|
||||
setConflict(null);
|
||||
setImportError(e instanceof ApiError ? e.message : S.common.unknownError);
|
||||
}
|
||||
} finally {
|
||||
setImporting(false);
|
||||
}
|
||||
};
|
||||
|
||||
const onPickFile = (e: ChangeEvent<HTMLInputElement>) => {
|
||||
const file = e.target.files?.[0];
|
||||
e.target.value = "";
|
||||
if (!file) return;
|
||||
setImportError(null);
|
||||
const reader = new FileReader();
|
||||
reader.onload = () => {
|
||||
const url = reader.result as string;
|
||||
void runImport(url.slice(url.indexOf(",") + 1), false); // strip the data:...;base64, prefix
|
||||
};
|
||||
reader.onerror = () => setImportError(S.common.unknownError);
|
||||
reader.readAsDataURL(file);
|
||||
};
|
||||
|
||||
return (
|
||||
<div className="space-y-4">
|
||||
<Input
|
||||
size="sm"
|
||||
label={S.agent.name}
|
||||
value={name}
|
||||
onChange={(e) => setName(e.target.value)}
|
||||
/>
|
||||
<Textarea
|
||||
label={S.agent.description}
|
||||
size="sm"
|
||||
rows={3}
|
||||
value={description}
|
||||
onChange={(e) => setDescription(e.target.value)}
|
||||
/>
|
||||
<div>
|
||||
<p className="mb-1 text-xs font-medium text-gray-500">{S.agent.stateDir}</p>
|
||||
<p className="break-all font-mono text-xs text-gray-500 dark:text-gray-400">
|
||||
{data.stateDir}
|
||||
</p>
|
||||
</div>
|
||||
<div>
|
||||
<p className="mb-1 text-xs font-medium text-gray-500">{S.agent.activeSessions}</p>
|
||||
<p className="text-sm">{data.activeSessionCount}</p>
|
||||
</div>
|
||||
<div>
|
||||
<p className="mb-1 text-xs font-medium text-gray-500">{S.agent.stateVersion}</p>
|
||||
<p className="font-mono text-sm">v{data.config.version}</p>
|
||||
</div>
|
||||
|
||||
{/* Snapshot export / import: export is available to any member; import overwrites the entire Agent State, visible only to owners. */}
|
||||
<div>
|
||||
<p className="mb-1 text-xs font-medium text-gray-500">{S.agent.transferTitle}</p>
|
||||
<p className="mb-2 text-xs text-gray-500 dark:text-gray-400">{S.agent.transferDesc}</p>
|
||||
<div className="flex flex-wrap items-center gap-2">
|
||||
{projectId && (
|
||||
<a
|
||||
href={api.agentExportUrl(projectId, agentId)}
|
||||
download
|
||||
className={TRANSFER_BUTTON_CLASS}
|
||||
>
|
||||
{S.agent.exportSnapshot}
|
||||
</a>
|
||||
)}
|
||||
{isOwner && (
|
||||
<label
|
||||
className={`${TRANSFER_BUTTON_CLASS} ${importing ? "pointer-events-none opacity-60" : ""}`}
|
||||
>
|
||||
{/* sr-only rather than hidden: keeps it keyboard-Tab-focusable (same as the workspace-browser upload). */}
|
||||
<input
|
||||
type="file"
|
||||
accept=".tar.gz,.tgz"
|
||||
className="sr-only"
|
||||
disabled={importing}
|
||||
onChange={onPickFile}
|
||||
/>
|
||||
{importing ? S.agent.importing : S.agent.importSnapshot}
|
||||
</label>
|
||||
)}
|
||||
</div>
|
||||
{importError && (
|
||||
<p className="mt-1.5 text-xs text-red-600 dark:text-red-400">{importError}</p>
|
||||
)}
|
||||
</div>
|
||||
|
||||
<Button size="sm" variant="primary" onClick={submit}>
|
||||
{S.common.save}
|
||||
</Button>
|
||||
|
||||
{/* Version conflict confirmation: resend the same package with confirm: true after confirming. */}
|
||||
<Modal
|
||||
open={conflict !== null}
|
||||
title={S.agent.importConflictTitle}
|
||||
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>
|
||||
</>
|
||||
}
|
||||
>
|
||||
<p className="text-sm text-gray-600 dark:text-gray-300">{S.agent.importConflictBody}</p>
|
||||
</Modal>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
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 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);
|
||||
};
|
||||
|
||||
/**
|
||||
* Quickly insert a placeholder at the system_prompt cursor position (appends to the
|
||||
* end when unfocused). Prefers execCommand insertText — it writes to the browser's
|
||||
* undo stack (undoable with Ctrl/⌘+Z) and fires an input event, which the
|
||||
* controlled onChange syncs into state; falls back to directly mutating state when
|
||||
* unsupported (no undo).
|
||||
*/
|
||||
const insertPlaceholder = (ph: string) => {
|
||||
const el = promptRef.current;
|
||||
if (el) {
|
||||
el.focus();
|
||||
// execCommand is deprecated but still the only available way to preserve the textarea's native undo stack.
|
||||
const inserted = document.execCommand?.("insertText", false, ph);
|
||||
if (inserted) return; // onChange will update state from e.target.value
|
||||
}
|
||||
const start = el ? el.selectionStart : systemPrompt.length;
|
||||
const end = el ? el.selectionEnd : systemPrompt.length;
|
||||
setSystemPrompt(systemPrompt.slice(0, start) + ph + systemPrompt.slice(end));
|
||||
requestAnimationFrame(() => {
|
||||
if (!el) return;
|
||||
el.focus();
|
||||
const caret = start + ph.length;
|
||||
el.setSelectionRange(caret, caret);
|
||||
});
|
||||
};
|
||||
|
||||
return (
|
||||
<div className="space-y-4">
|
||||
<Textarea
|
||||
label={S.agent.agentsMd}
|
||||
mono
|
||||
size="sm"
|
||||
rows={14}
|
||||
value={agentsMd}
|
||||
onChange={(e) => setAgentsMd(e.target.value)}
|
||||
/>
|
||||
<Textarea
|
||||
ref={promptRef}
|
||||
label={S.agent.systemPrompt}
|
||||
mono
|
||||
size="sm"
|
||||
rows={12}
|
||||
value={systemPrompt}
|
||||
onChange={(e) => setSystemPrompt(e.target.value)}
|
||||
/>
|
||||
<div className="rounded-md border border-gray-200 bg-gray-50 p-3 dark:border-gray-800 dark:bg-gray-900">
|
||||
<p className="mb-2 text-xs font-semibold text-gray-500">{S.agent.placeholdersTitle}</p>
|
||||
<ul className="space-y-1">
|
||||
{S.agent.placeholders.map(([ph, desc]) => (
|
||||
<li key={ph} className="flex items-center gap-3 text-xs">
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => insertPlaceholder(ph)}
|
||||
title={S.agent.insertPlaceholder}
|
||||
className="shrink-0 rounded border border-gray-200 bg-white px-1.5 py-0.5 font-mono font-semibold text-gray-800 transition-colors duration-150 hover:border-gray-400 hover:bg-gray-100 dark:border-gray-700 dark:bg-gray-800 dark:text-gray-200 dark:hover:border-gray-500 dark:hover:bg-gray-700"
|
||||
>
|
||||
{ph}
|
||||
</button>
|
||||
<span className="text-gray-500 dark:text-gray-400">{desc}</span>
|
||||
</li>
|
||||
))}
|
||||
</ul>
|
||||
</div>
|
||||
<Button size="sm" variant="primary" onClick={submit}>
|
||||
{S.common.save}
|
||||
</Button>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
function RuntimeTab({ data, onSave }: { data: AgentConfigResponse; onSave: SaveFn }) {
|
||||
const cfg = data.config;
|
||||
const [maxTurns, setMaxTurns] = useState(numToStr(cfg.maxTurns));
|
||||
const [maxTokens, setMaxTokens] = useState(numToStr(cfg.model?.maxTokens));
|
||||
const [thinkingLevel, setThinkingLevel] = useState(cfg.model?.thinkingLevel ?? "");
|
||||
const [timeoutMs, setTimeoutMs] = useState(numToStr(cfg.model?.timeoutMs));
|
||||
const [maxContextLength, setMaxContextLength] = useState(
|
||||
numToStr(cfg.compaction?.maxContextLength),
|
||||
);
|
||||
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 submit = () => {
|
||||
setLocalError(null);
|
||||
const config: NonNullable<AgentConfigUpdateRequest["config"]> = {};
|
||||
|
||||
const mt = parseNum(maxTurns);
|
||||
if (mt !== undefined && mt !== cfg.maxTurns) {
|
||||
if (mt <= 0 && mt !== -1) {
|
||||
setLocalError(S.agent.maxTurnsInvalid);
|
||||
return;
|
||||
}
|
||||
config.maxTurns = mt;
|
||||
}
|
||||
|
||||
const model: AgentModelConfigDto = {};
|
||||
const tok = parseNum(maxTokens);
|
||||
if (tok !== undefined && tok !== cfg.model?.maxTokens) model.maxTokens = tok;
|
||||
if (thinkingLevel && thinkingLevel !== (cfg.model?.thinkingLevel ?? "")) {
|
||||
model.thinkingLevel = thinkingLevel as AgentModelConfigDto["thinkingLevel"];
|
||||
}
|
||||
const tmo = parseNum(timeoutMs);
|
||||
if (tmo !== undefined && tmo !== cfg.model?.timeoutMs) {
|
||||
if (tmo <= 0 && tmo !== -1) {
|
||||
setLocalError(S.agent.timeoutInvalid);
|
||||
return;
|
||||
}
|
||||
model.timeoutMs = tmo;
|
||||
}
|
||||
if (Object.keys(model).length > 0) config.model = model;
|
||||
|
||||
const compaction: AgentCompactionConfigDto = {};
|
||||
const mcl = parseNum(maxContextLength);
|
||||
if (mcl !== undefined && mcl !== cfg.compaction?.maxContextLength) {
|
||||
compaction.maxContextLength = mcl;
|
||||
}
|
||||
const mst = parseNum(maxSessionTurns);
|
||||
if (mst !== undefined && mst !== cfg.compaction?.maxSessionTurns) {
|
||||
compaction.maxSessionTurns = mst;
|
||||
}
|
||||
if (mode && mode !== (cfg.compaction?.mode ?? "")) {
|
||||
compaction.mode = mode as AgentCompactionConfigDto["mode"];
|
||||
}
|
||||
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 });
|
||||
};
|
||||
|
||||
// S is reassigned on language switch (live binding), so read it during render rather than hoisting to a module-level constant.
|
||||
const thinkingLevelOptions = withDefaultOption(S.agent.thinkingLevelOptions);
|
||||
const compactionModeOptions = withDefaultOption(S.agent.compactionModeOptions);
|
||||
|
||||
return (
|
||||
<div className="space-y-4">
|
||||
<div className="overflow-hidden rounded-md border border-gray-200 bg-white dark:border-gray-800 dark:bg-gray-900">
|
||||
<div className="p-3">
|
||||
<div className="grid grid-cols-1 gap-3 md:grid-cols-2">
|
||||
<Input
|
||||
label={S.agent.maxTurns}
|
||||
size="sm"
|
||||
value={maxTurns}
|
||||
onChange={(e) => setMaxTurns(e.target.value)}
|
||||
inputMode="numeric"
|
||||
className="font-mono"
|
||||
/>
|
||||
<Input
|
||||
label={S.agent.maxTokens}
|
||||
size="sm"
|
||||
value={maxTokens}
|
||||
onChange={(e) => setMaxTokens(e.target.value)}
|
||||
inputMode="numeric"
|
||||
className="font-mono"
|
||||
/>
|
||||
<OptionMenu
|
||||
label={S.agent.thinkingLevel}
|
||||
fullWidth
|
||||
size="sm"
|
||||
value={thinkingLevel}
|
||||
onChange={setThinkingLevel}
|
||||
options={thinkingLevelOptions}
|
||||
/>
|
||||
<Input
|
||||
label={S.agent.timeoutMs}
|
||||
hint={S.agent.timeoutMsHint}
|
||||
size="sm"
|
||||
value={timeoutMs}
|
||||
onChange={(e) => setTimeoutMs(e.target.value)}
|
||||
inputMode="numeric"
|
||||
className="font-mono"
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="border-t border-b border-gray-200 bg-gray-50/80 px-3 py-2 text-xs font-semibold text-gray-500 dark:border-gray-800 dark:bg-gray-900">
|
||||
{S.agent.compaction}
|
||||
</div>
|
||||
<div className="p-3">
|
||||
<div className="grid grid-cols-1 gap-3 md:grid-cols-3">
|
||||
<Input
|
||||
label={S.agent.maxContextLength}
|
||||
hint={S.agent.maxContextLengthHint}
|
||||
size="sm"
|
||||
value={maxContextLength}
|
||||
onChange={(e) => setMaxContextLength(e.target.value)}
|
||||
inputMode="numeric"
|
||||
className="font-mono"
|
||||
/>
|
||||
<Input
|
||||
label={S.agent.maxSessionTurns}
|
||||
hint={S.agent.maxSessionTurnsHint}
|
||||
size="sm"
|
||||
value={maxSessionTurns}
|
||||
onChange={(e) => setMaxSessionTurns(e.target.value)}
|
||||
inputMode="numeric"
|
||||
className="font-mono"
|
||||
/>
|
||||
<OptionMenu
|
||||
label={S.agent.compactionMode}
|
||||
fullWidth
|
||||
size="sm"
|
||||
value={mode}
|
||||
onChange={setMode}
|
||||
options={compactionModeOptions}
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="border-t border-b border-gray-200 bg-gray-50/80 px-3 py-2 text-xs font-semibold text-gray-500 dark:border-gray-800 dark:bg-gray-900">
|
||||
{S.agent.compactionPrompt}
|
||||
</div>
|
||||
<div className="p-3">
|
||||
<Textarea
|
||||
aria-label={S.agent.compactionPrompt}
|
||||
mono
|
||||
size="sm"
|
||||
rows={4}
|
||||
value={prompt}
|
||||
onChange={(e) => setPrompt(e.target.value)}
|
||||
/>
|
||||
</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>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
/** Local edit state for a tool row: numeric columns use string state (can be cleared then re-entered; empty string = clear the override and revert to default). */
|
||||
interface ToolRowState {
|
||||
base: ToolDefinitionConfig;
|
||||
timeoutMs: string;
|
||||
maxOutputLength: string;
|
||||
}
|
||||
|
||||
function ToolsTab({ data, onSave }: { data: AgentConfigResponse; onSave: SaveFn }) {
|
||||
// S is reassigned on language switch (live binding), so read it during render rather than hoisting to a module-level constant.
|
||||
const permissionOptions: ReadonlyArray<OptionMenuChoice<ToolPermission>> = [
|
||||
{
|
||||
value: "r",
|
||||
triggerLabel: "r",
|
||||
label: S.agent.permissionReadLabel,
|
||||
description: S.agent.permissionReadDescription,
|
||||
},
|
||||
{
|
||||
value: "rw",
|
||||
triggerLabel: "rw",
|
||||
label: S.agent.permissionReadWriteLabel,
|
||||
description: S.agent.permissionReadWriteDescription,
|
||||
},
|
||||
];
|
||||
const [rows, setRows] = useState<ToolRowState[]>(() =>
|
||||
data.config.toolsBuiltin.map((t) => ({
|
||||
base: { ...t },
|
||||
timeoutMs: numToStr(t.timeoutMs),
|
||||
maxOutputLength: numToStr(t.maxOutputLength),
|
||||
})),
|
||||
);
|
||||
const [localError, setLocalError] = useState<string | null>(null);
|
||||
|
||||
const update = (index: number, patch: Partial<ToolRowState>) => {
|
||||
setRows((prev) => prev.map((r, i) => (i === index ? { ...r, ...patch } : r)));
|
||||
};
|
||||
|
||||
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 tools: ToolDefinitionConfig[] = [];
|
||||
for (const row of rows) {
|
||||
const tool: ToolDefinitionConfig = { ...row.base };
|
||||
delete tool.timeoutMs;
|
||||
delete tool.maxOutputLength;
|
||||
const timeout = row.timeoutMs.trim();
|
||||
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;
|
||||
}
|
||||
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;
|
||||
}
|
||||
tools.push(tool);
|
||||
}
|
||||
void onSave({ config: { toolsBuiltin: tools } });
|
||||
};
|
||||
|
||||
return (
|
||||
<div className="space-y-4">
|
||||
<div className="overflow-x-auto overflow-y-clip rounded-md border border-gray-200 bg-white dark:border-gray-800 dark:bg-gray-900">
|
||||
<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.agent.toolPermission}</th>
|
||||
<th className="px-3 py-2">{S.agent.toolTimeout}</th>
|
||||
<th className="px-3 py-2">{S.agent.toolMaxOutput}</th>
|
||||
</tr>
|
||||
</thead>
|
||||
<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">
|
||||
<OptionMenu
|
||||
mono
|
||||
size="sm"
|
||||
aria-label={S.agent.toolPermission}
|
||||
placeholder={S.agent.defaultValue}
|
||||
options={permissionOptions}
|
||||
value={row.base.permission}
|
||||
onChange={(v) => update(i, { base: { ...row.base, permission: v } })}
|
||||
/>
|
||||
</td>
|
||||
<td className="px-3 py-2">
|
||||
<Input
|
||||
size="sm"
|
||||
value={row.timeoutMs}
|
||||
inputMode="numeric"
|
||||
className="font-mono"
|
||||
onChange={(e) => update(i, { timeoutMs: e.target.value })}
|
||||
/>
|
||||
</td>
|
||||
<td className="px-3 py-2">
|
||||
<Input
|
||||
size="sm"
|
||||
value={row.maxOutputLength}
|
||||
inputMode="numeric"
|
||||
className="font-mono"
|
||||
onChange={(e) => update(i, { maxOutputLength: e.target.value })}
|
||||
/>
|
||||
</td>
|
||||
</tr>
|
||||
))}
|
||||
</tbody>
|
||||
</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">
|
||||
{JSON.stringify(data.config.mcpServers, null, 2)}
|
||||
</pre>
|
||||
</div>
|
||||
|
||||
<Button size="sm" variant="primary" onClick={submit}>
|
||||
{S.common.save}
|
||||
</Button>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,415 @@
|
||||
/**
|
||||
* Agents list page: entry point for creating,
|
||||
* deleting, and editing Agents. Laid out as GitHub-repo-list-style single-column compact rows:
|
||||
* one horizontal band of "info | 30-day activity sparkline | button group" per row.
|
||||
* Info column has three lines: title line (small avatar + bold name + agentId); single-line
|
||||
* truncated description; and a stats line — icon + number only (Session count / tool count) plus
|
||||
* relative time (today/yesterday/n days ago), with meaning folded into the hover title.
|
||||
* Buttons sit to the right of the sparkline: "New Chat" (draft state, same as sidebar group
|
||||
* header) and "Settings" (goes to settings page) show text labels; "Usage" / "Traces" (deep link
|
||||
* via ?agentId= to the usage center / trace observability; traces use an eye line icon =
|
||||
* observability) and "Delete" (with confirmation; built-in Agents show a non-interactive light
|
||||
* gray placeholder with an undeletable tooltip) are square icon buttons (tooltip shows the full
|
||||
* name); "Create Agent" only fills in name + description.
|
||||
*/
|
||||
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 { SEMANTIC_ID_PATTERN } from "../../lib/semantic-id";
|
||||
import { formatDateTime, formatRelativeDays } from "../../lib/format";
|
||||
import { useDocumentTitle } from "../../lib/use-document-title";
|
||||
import { useLocale } from "../../state/locale";
|
||||
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 { Badge } from "../../components/ui/badge";
|
||||
import { Skeleton, SkeletonCard } from "../../components/ui/skeleton";
|
||||
import { EmptyState } from "../../components/ui/empty-state";
|
||||
import { AgentAvatar } from "../../components/ui/agent-avatar";
|
||||
import { GlyphIcon } from "../../components/ui/glyph-icon";
|
||||
import { STAT_ICONS } from "../../lib/stat-icons";
|
||||
import { DRAFT_SESSION_ID } from "../chat/chat-page";
|
||||
import { ActivitySparkline } from "./activity-sparkline";
|
||||
|
||||
/** Built-in Agent shipped with every Project (default_agent only; the server also rejects deletion, so no delete entry point is shown here). */
|
||||
const BUILTIN_AGENT_IDS = new Set(["default_agent"]);
|
||||
|
||||
/** Card button icons (24x24 line path, rendered via GlyphIcon). */
|
||||
const CARD_ICONS = {
|
||||
/** New chat (plus sign) */
|
||||
newChat: "M12 5v14M5 12h14",
|
||||
/** Settings (gear, same as sidebar user menu) */
|
||||
settings:
|
||||
"M10.3 4.3a2 2 0 0 1 3.4 0l.5.8a2 2 0 0 0 1.8 1l1-.1a2 2 0 0 1 1.7 3l-.5.8a2 2 0 0 0 0 2l.5.8a2 2 0 0 1-1.7 3l-1-.1a2 2 0 0 0-1.8 1l-.5.8a2 2 0 0 1-3.4 0l-.5-.8a2 2 0 0 0-1.8-1l-1 .1a2 2 0 0 1-1.7-3l.5-.8a2 2 0 0 0 0-2l-.5-.8a2 2 0 0 1 1.7-3l1 .1a2 2 0 0 0 1.8-1zM12 10a2 2 0 1 0 0 4 2 2 0 0 0 0-4z",
|
||||
/** Delete (trash can) */
|
||||
trash:
|
||||
"M4 7h16M9 7V5a1 1 0 0 1 1-1h4a1 1 0 0 1 1 1v2m3 0l-1 13a2 2 0 0 1-2 2H9a2 2 0 0 1-2-2L6 7m4 4v6m4-6v6",
|
||||
/** Total session count (chat bubble) */
|
||||
sessions: "M8 10h8M8 14h5M21 12a9 9 0 1 1-4-7.5",
|
||||
/** Vault key count (key: bow + teeth) */
|
||||
vaultKeys: "M15.5 7.5l3 3L22 7l-3-3M21 2l-9.6 9.6M13 15.5a5.5 5.5 0 1 1-11 0 5.5 5.5 0 0 1 11 0z",
|
||||
/** Schedule count (alarm clock: dial + hands + twin bells, distinct from the plain clock face used for "last modified") */
|
||||
schedules: "M12 21a7 7 0 1 0 0-14 7 7 0 0 0 0 14zm0-10v3l2 1.5M5 3L2.5 5.5M19 3l2.5 2.5",
|
||||
/** Usage (bar chart, same as sidebar "Usage Center") */
|
||||
usage: "M4 20V10m6 10V4m6 16v-7m4 7H2",
|
||||
/** Traces (eye line icon: observability; follows text color, no fill) */
|
||||
traces: "M2 12s3.6-7 10-7 10 7 10 7-3.6 7-10 7-10-7-10-7zM12 9a3 3 0 1 0 0 6 3 3 0 0 0 0-6z",
|
||||
} as const;
|
||||
|
||||
export function AgentsPage() {
|
||||
const navigate = useNavigate();
|
||||
useDocumentTitle(S.nav.agents);
|
||||
const { locale } = useLocale();
|
||||
const { currentProject, agents, agentsLoading, reloadAgents, setCurrentAgentId } = useProject();
|
||||
const [createOpen, setCreateOpen] = useState(false);
|
||||
const [agentId, setAgentId] = useState("");
|
||||
const [name, setName] = useState("");
|
||||
const [description, setDescription] = useState("");
|
||||
const [error, setError] = useState<string | null>(null);
|
||||
const [busy, setBusy] = useState(false);
|
||||
|
||||
/** Open the create dialog: don't keep the previous draft, always start from an empty form. */
|
||||
const openCreate = () => {
|
||||
setAgentId("");
|
||||
setName("");
|
||||
setDescription("");
|
||||
setError(null);
|
||||
setCreateOpen(true);
|
||||
};
|
||||
/** Agent pending delete confirmation (null = none). */
|
||||
const [deleting, setDeleting] = useState<{ agentId: string; name: string } | null>(null);
|
||||
const [deleteError, setDeleteError] = useState<string | null>(null);
|
||||
|
||||
const projectId = currentProject?.projectId;
|
||||
|
||||
const create = async () => {
|
||||
if (!projectId) return;
|
||||
const id = agentId.trim();
|
||||
if (!id) {
|
||||
setError(S.common.requiredField);
|
||||
return;
|
||||
}
|
||||
if (!SEMANTIC_ID_PATTERN.test(id)) {
|
||||
setError(S.agent.idHint);
|
||||
return;
|
||||
}
|
||||
setBusy(true);
|
||||
setError(null);
|
||||
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 };
|
||||
if (name.trim()) body.name = name.trim();
|
||||
if (description.trim()) body.description = description.trim();
|
||||
const res = await api.createAgent(projectId, body);
|
||||
setCreateOpen(false);
|
||||
await reloadAgents();
|
||||
setCurrentAgentId(res.agent.agentId);
|
||||
navigate(`/agents/${res.agent.agentId}`);
|
||||
} catch (e) {
|
||||
setError(e instanceof ApiError ? e.message : S.common.unknownError);
|
||||
} finally {
|
||||
setBusy(false);
|
||||
}
|
||||
};
|
||||
|
||||
/**
|
||||
* "New Chat": enters draft state (same as sidebar group header) — the Session is only
|
||||
* actually created when the first message is sent. agentId travels via route state: when the
|
||||
* draft view restores from cache it prefers the cached agentId, but the route state explicitly
|
||||
* overrides it, ensuring that clicking "New Chat" on a given card always lands on that Agent
|
||||
* rather than the previous one from the cache.
|
||||
*/
|
||||
const newChat = (agentId: string) => {
|
||||
setCurrentAgentId(agentId);
|
||||
navigate(`/chat/${DRAFT_SESSION_ID}`, { state: { agentId } });
|
||||
};
|
||||
|
||||
const doDelete = async () => {
|
||||
if (!projectId || !deleting) return;
|
||||
setBusy(true);
|
||||
setDeleteError(null);
|
||||
try {
|
||||
await api.deleteAgent(projectId, deleting.agentId);
|
||||
setDeleting(null);
|
||||
await reloadAgents();
|
||||
} catch (e) {
|
||||
setDeleteError(e instanceof ApiError ? e.message : S.common.unknownError);
|
||||
} finally {
|
||||
setBusy(false);
|
||||
}
|
||||
};
|
||||
|
||||
return (
|
||||
<div className="h-full overflow-y-auto p-4 md:p-6">
|
||||
<div className="mx-auto max-w-5xl">
|
||||
<div className="mb-4 flex items-center justify-between gap-2">
|
||||
<h1 className="text-xl font-semibold">{S.agent.listTitle}</h1>
|
||||
<Button variant="primary" onClick={openCreate}>
|
||||
{S.agent.create}
|
||||
</Button>
|
||||
</div>
|
||||
|
||||
{agentsLoading ? (
|
||||
/* Same single-column row styling as the real list (space-y-3 + px-5 py-4), with a
|
||||
three-line info column plus sparkline/button-group placeholders, so no layout shift
|
||||
occurs once the skeleton disappears */
|
||||
<div className="space-y-3">
|
||||
{Array.from({ length: 4 }, (_, i) => (
|
||||
<SkeletonCard
|
||||
key={i}
|
||||
className="flex flex-wrap items-center gap-x-6 gap-y-2 px-5 py-4"
|
||||
>
|
||||
<div className="min-w-[14rem] flex-1">
|
||||
<Skeleton className="h-[18px] w-40" />
|
||||
<Skeleton className="mt-1.5 h-4 w-2/3" />
|
||||
<Skeleton className="mt-1.5 h-4 w-48" />
|
||||
</div>
|
||||
<Skeleton className="hidden h-9 w-40 md:block" />
|
||||
<Skeleton className="h-8 w-52" />
|
||||
</SkeletonCard>
|
||||
))}
|
||||
</div>
|
||||
) : agents.length === 0 ? (
|
||||
<EmptyState title={S.common.none} />
|
||||
) : (
|
||||
/* GitHub-repo-list-style single column: separate cards with row spacing; each row is
|
||||
one horizontal band of "info | sparkline | button group", with the info column
|
||||
compressed to two lines of text (name line + combined description/stats line) to
|
||||
minimize row height */
|
||||
<div className="space-y-3">
|
||||
{agents.map((a) => {
|
||||
const builtin = BUILTIN_AGENT_IDS.has(a.agentId);
|
||||
return (
|
||||
<div
|
||||
key={a.agentId}
|
||||
className="flex flex-wrap items-center gap-x-6 gap-y-2 rounded-md border border-gray-200 bg-white px-5 py-4 dark:border-gray-800 dark:bg-gray-900"
|
||||
>
|
||||
{/* Info column: once it can't fit within 14rem, everything after it
|
||||
(sparkline/buttons) wraps as a whole. The avatar counts as the first line
|
||||
(same line as the name); description/stats share the same left edge as the
|
||||
avatar (the column's left edge) */}
|
||||
<div className="min-w-[14rem] flex-1">
|
||||
{/* Title line: small avatar + name + agentId + active badge */}
|
||||
<div className="flex items-center gap-2">
|
||||
<AgentAvatar id={a.agentId} size={18} className="shrink-0 rounded" />
|
||||
{/* min-w-0: flex children don't shrink below their content by default; needed here to truncate overly long names */}
|
||||
<span className="min-w-0 truncate text-base font-bold">
|
||||
{agentDisplayName(a)}
|
||||
</span>
|
||||
<span className="hidden shrink-0 font-mono text-xs text-gray-400 md:inline dark:text-gray-500">
|
||||
{a.agentId}
|
||||
</span>
|
||||
<Badge tone="gray">v{a.version}</Badge>
|
||||
{a.activeSessionCount > 0 && (
|
||||
<Badge tone="brand">
|
||||
{S.agent.activeSessions} {a.activeSessionCount}
|
||||
</Badge>
|
||||
)}
|
||||
</div>
|
||||
{/* Description truncated to one line (an empty description still takes up a line, keeping card heights equal) */}
|
||||
<p className="mt-1.5 min-h-4 truncate text-xs text-gray-500 dark:text-gray-400">
|
||||
{a.description ?? ""}
|
||||
</p>
|
||||
{/* Stats on their own line: same color/font size as the description; each
|
||||
reserves a minimum width so they align vertically across cards; meaning
|
||||
folded into the hover title */}
|
||||
<div className="mt-1.5 flex items-center gap-x-2.5 text-xs text-gray-500 dark:text-gray-400">
|
||||
<span
|
||||
className="inline-flex min-w-[2.25rem] shrink-0 items-center gap-1 tabular-nums"
|
||||
title={S.agent.sessionCount(a.sessionCount)}
|
||||
>
|
||||
<GlyphIcon d={CARD_ICONS.sessions} size={12} />
|
||||
{a.sessionCount}
|
||||
</span>
|
||||
<span
|
||||
className="inline-flex min-w-[2.25rem] shrink-0 items-center gap-1 tabular-nums"
|
||||
title={S.agent.toolCount(a.toolCount)}
|
||||
>
|
||||
<GlyphIcon d={STAT_ICONS.toolCalls} size={12} />
|
||||
{a.toolCount}
|
||||
</span>
|
||||
<span
|
||||
className="inline-flex min-w-[2.25rem] shrink-0 items-center gap-1 tabular-nums"
|
||||
title={S.agent.vaultKeyCount(a.vaultKeyCount)}
|
||||
>
|
||||
<GlyphIcon d={CARD_ICONS.vaultKeys} size={12} />
|
||||
{a.vaultKeyCount}
|
||||
</span>
|
||||
<span
|
||||
className="inline-flex min-w-[2.25rem] shrink-0 items-center gap-1 tabular-nums"
|
||||
title={S.agent.scheduleCount(a.scheduleCount)}
|
||||
>
|
||||
<GlyphIcon d={CARD_ICONS.schedules} size={12} />
|
||||
{a.scheduleCount}
|
||||
</span>
|
||||
<span
|
||||
className="inline-flex shrink-0 items-center gap-1"
|
||||
title={`${S.agent.updatedAt} ${a.updatedAt ? formatDateTime(a.updatedAt) : "—"}`}
|
||||
>
|
||||
<GlyphIcon d={STAT_ICONS.elapsed} size={12} />
|
||||
{a.updatedAt ? formatRelativeDays(a.updatedAt, locale) : "—"}
|
||||
</span>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Session activity sparkline (hidden on narrow screens first, giving the horizontal space back to content and buttons) */}
|
||||
<ActivitySparkline
|
||||
data={a.sessionActivity}
|
||||
label={S.agent.activity(a.sessionActivity.length || 30)}
|
||||
className="hidden shrink-0 md:block"
|
||||
/>
|
||||
|
||||
{/* Button group to the right of the sparkline: "New Chat" shows text, the rest are square icon buttons (tooltip shows the full name) */}
|
||||
<div className="flex shrink-0 items-center gap-2">
|
||||
<Button size="sm" variant="primary" onClick={() => newChat(a.agentId)}>
|
||||
<GlyphIcon d={CARD_ICONS.newChat} />
|
||||
{S.chat.newSessionMenu}
|
||||
</Button>
|
||||
<Button
|
||||
size="sm"
|
||||
onClick={() => {
|
||||
setCurrentAgentId(a.agentId);
|
||||
navigate(`/agents/${a.agentId}`);
|
||||
}}
|
||||
>
|
||||
<GlyphIcon
|
||||
d={CARD_ICONS.settings}
|
||||
className="text-gray-600 dark:text-gray-300"
|
||||
/>
|
||||
{S.common.settings}
|
||||
</Button>
|
||||
<Button
|
||||
size="icon"
|
||||
title={S.nav.usage}
|
||||
aria-label={S.nav.usage}
|
||||
onClick={() => navigate(`/usage?agentId=${encodeURIComponent(a.agentId)}`)}
|
||||
>
|
||||
<GlyphIcon
|
||||
d={CARD_ICONS.usage}
|
||||
size={15}
|
||||
className="text-gray-600 dark:text-gray-300"
|
||||
/>
|
||||
</Button>
|
||||
<Button
|
||||
size="icon"
|
||||
title={S.nav.traces}
|
||||
aria-label={S.nav.traces}
|
||||
onClick={() => navigate(`/traces?agentId=${encodeURIComponent(a.agentId)}`)}
|
||||
>
|
||||
<GlyphIcon
|
||||
d={CARD_ICONS.traces}
|
||||
size={15}
|
||||
className="text-gray-600 dark:text-gray-300"
|
||||
/>
|
||||
</Button>
|
||||
{/* Built-in Agents can't be deleted: shown as a non-button light gray
|
||||
placeholder (no border/background, no hover response, disabled cursor,
|
||||
explained via tooltip); the transparent border keeps the same box size as
|
||||
an icon button so column widths stay consistent across cards */}
|
||||
{builtin ? (
|
||||
<span
|
||||
role="img"
|
||||
title={S.agent.builtinUndeletable}
|
||||
aria-label={S.agent.builtinUndeletable}
|
||||
className="inline-flex cursor-not-allowed items-center justify-center rounded-md border border-transparent p-1.5 text-gray-300 dark:text-gray-600"
|
||||
>
|
||||
<GlyphIcon d={CARD_ICONS.trash} size={15} />
|
||||
</span>
|
||||
) : (
|
||||
<Button
|
||||
size="icon"
|
||||
variant="danger"
|
||||
title={S.agent.deleteAgent}
|
||||
aria-label={S.agent.deleteAgent}
|
||||
onClick={() =>
|
||||
setDeleting({ agentId: a.agentId, name: agentDisplayName(a) })
|
||||
}
|
||||
>
|
||||
<GlyphIcon d={CARD_ICONS.trash} size={15} />
|
||||
</Button>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
})}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
|
||||
<Modal
|
||||
open={createOpen}
|
||||
title={S.agent.createTitle}
|
||||
onClose={() => setCreateOpen(false)}
|
||||
footer={
|
||||
<>
|
||||
<Button onClick={() => setCreateOpen(false)}>{S.common.cancel}</Button>
|
||||
<Button variant="primary" disabled={busy} onClick={() => void create()}>
|
||||
{S.common.create}
|
||||
</Button>
|
||||
</>
|
||||
}
|
||||
>
|
||||
<div className="space-y-3">
|
||||
<Input
|
||||
label={S.agent.id}
|
||||
size="sm"
|
||||
value={agentId}
|
||||
onChange={(e) => setAgentId(e.target.value)}
|
||||
hint={S.agent.idHint}
|
||||
autoFocus
|
||||
/>
|
||||
<Input
|
||||
label={S.agent.name}
|
||||
size="sm"
|
||||
value={name}
|
||||
onChange={(e) => setName(e.target.value)}
|
||||
hint={S.agent.nameHint}
|
||||
/>
|
||||
<Textarea
|
||||
label={S.agent.description}
|
||||
size="sm"
|
||||
rows={3}
|
||||
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
|
||||
open={deleting !== null}
|
||||
title={S.agent.deleteAgent}
|
||||
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>
|
||||
</>
|
||||
}
|
||||
>
|
||||
<p className="text-sm text-gray-600 dark:text-gray-300">
|
||||
{deleting ? S.agent.deleteConfirm(deleting.name) : ""}
|
||||
</p>
|
||||
{deleteError && (
|
||||
<p className="mt-2 text-xs text-red-600 dark:text-red-400">{deleteError}</p>
|
||||
)}
|
||||
</Modal>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,572 @@
|
||||
/**
|
||||
* Agent settings page "Schedule" tab: a table view over
|
||||
* agent_state/schedule/*.toml (status badge derived from run state; "next / last
|
||||
* fired" shown as two stacked rows) plus a shared create/edit modal form.
|
||||
* Readable by any member; toggle/edit/delete are owner-only — PUT has whole-file
|
||||
* replace semantics, so toggling also resends every field and only flips `enabled`.
|
||||
* startAt/endAt use datetime-local inputs (local timezone), converted to ISO 8601 on
|
||||
* submit; the "new Session each run" mode can also pick a Model (a provider + modelId
|
||||
* pair; defaults to the Project default; mutual exclusivity with sessionId is
|
||||
* validated server-side).
|
||||
*/
|
||||
import { useCallback, useEffect, useState } from "react";
|
||||
import type {
|
||||
ModelInfo,
|
||||
ScheduleItem,
|
||||
SchedulesResponse,
|
||||
ScheduleStatus,
|
||||
ScheduleUpsertRequest,
|
||||
} from "@prismshadow/penguin-server/api";
|
||||
import * as api from "../../api/endpoints";
|
||||
import { ApiError } from "../../api/client";
|
||||
import { S } from "../../lib/strings";
|
||||
import { formatDateTime } from "../../lib/format";
|
||||
import { useProject } from "../../state/project";
|
||||
import { providerInfo } from "@prismshadow/penguin-core/model-catalog";
|
||||
import { Badge, type BadgeTone } from "../../components/ui/badge";
|
||||
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 { SkeletonList } from "../../components/ui/skeleton";
|
||||
|
||||
/** Display status → badge tone. */
|
||||
const STATUS_TONE: Record<ScheduleStatus, BadgeTone> = {
|
||||
active: "green",
|
||||
disabled: "gray",
|
||||
expired: "amber",
|
||||
done: "brand",
|
||||
missed: "amber",
|
||||
invalid: "red",
|
||||
};
|
||||
|
||||
/** ISO → datetime-local input value (local timezone, minute precision); returns "" when missing/invalid. */
|
||||
function toLocalInput(iso: string | undefined): string {
|
||||
if (!iso) return "";
|
||||
const d = new Date(iso);
|
||||
if (Number.isNaN(d.getTime())) return "";
|
||||
const pad = (n: number) => (n < 10 ? `0${n}` : `${n}`);
|
||||
return `${d.getFullYear()}-${pad(d.getMonth() + 1)}-${pad(d.getDate())}T${pad(d.getHours())}:${pad(d.getMinutes())}`;
|
||||
}
|
||||
|
||||
/**
|
||||
* The model selection value in the form: a paired reference. provider may be
|
||||
* omitted — when a hand-written TOML only specifies model_id, it's resolved by
|
||||
* unique match at parse time; editing never fills in a provider on its own.
|
||||
*/
|
||||
interface ScheduleModelRef {
|
||||
provider?: string;
|
||||
modelId: string;
|
||||
}
|
||||
|
||||
/**
|
||||
* Model reference ↔ native select option value: encoded as a JSON array
|
||||
* ([provider ?? null, modelId]), used only as a transient DOM-value serialization —
|
||||
* it never enters storage or requests; the persisted/submitted data still keeps
|
||||
* provider and modelId as two separate fields (no concatenation anywhere in the
|
||||
* pipeline). "" means the Project default.
|
||||
*/
|
||||
const modelOptionValue = (ref: ScheduleModelRef): string =>
|
||||
JSON.stringify([ref.provider ?? null, ref.modelId]);
|
||||
|
||||
const parseModelOption = (v: string): ScheduleModelRef | null => {
|
||||
if (!v) return null;
|
||||
const [provider, modelId] = JSON.parse(v) as [string | null, string];
|
||||
return provider === null ? { modelId } : { provider, modelId };
|
||||
};
|
||||
|
||||
/** Display label for a model option: upstream id + provider name (shown side by side, not a composite id). */
|
||||
const modelOptionLabel = (ref: ScheduleModelRef): string =>
|
||||
ref.provider === undefined
|
||||
? ref.modelId
|
||||
: `${ref.modelId} · ${providerInfo(ref.provider)?.label ?? ref.provider}`;
|
||||
|
||||
/** Modal form state (shared by create/edit): non-null editing means editing that task (name locked). */
|
||||
interface FormState {
|
||||
editing: string | null;
|
||||
name: string;
|
||||
prompt: string;
|
||||
enabled: boolean;
|
||||
/** datetime-local input value (converted to ISO on submit). */
|
||||
startAt: string;
|
||||
endAt: string;
|
||||
period: string;
|
||||
target: "new" | "session";
|
||||
sessionId: string;
|
||||
workspace: string;
|
||||
/** Model for the new-Session mode (null = Project default, modelId/provider omitted). */
|
||||
model: ScheduleModelRef | null;
|
||||
}
|
||||
|
||||
const EMPTY_FORM: FormState = {
|
||||
editing: null,
|
||||
name: "",
|
||||
prompt: "",
|
||||
enabled: true,
|
||||
startAt: "",
|
||||
endAt: "",
|
||||
period: "",
|
||||
target: "new",
|
||||
sessionId: "",
|
||||
workspace: "",
|
||||
model: null,
|
||||
};
|
||||
|
||||
export function SchedulesTab({ agentId }: { agentId: string }) {
|
||||
const { currentProject } = useProject();
|
||||
const projectId = currentProject?.projectId ?? null;
|
||||
const isOwner = currentProject?.role === "owner";
|
||||
|
||||
const [data, setData] = useState<SchedulesResponse | null>(null);
|
||||
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);
|
||||
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);
|
||||
// Model dropdown data (needed only for owners); load failure doesn't block the form — falling back to "Project default" is fine.
|
||||
const [models, setModels] = useState<ModelInfo[]>([]);
|
||||
|
||||
const load = useCallback(async () => {
|
||||
if (!projectId || !agentId) return;
|
||||
setData(null);
|
||||
setError(null);
|
||||
try {
|
||||
setData(await api.listSchedules(projectId, agentId));
|
||||
} catch (e) {
|
||||
setError(e instanceof ApiError ? e.message : S.common.unknownError);
|
||||
}
|
||||
}, [projectId, agentId]);
|
||||
|
||||
useEffect(() => {
|
||||
void load();
|
||||
}, [load]);
|
||||
|
||||
useEffect(() => {
|
||||
if (!projectId || !isOwner) return;
|
||||
api
|
||||
.getModels(projectId)
|
||||
.then((res) => setModels(res.models))
|
||||
.catch(() => setModels([]));
|
||||
}, [projectId, isOwner]);
|
||||
|
||||
const set = (patch: Partial<FormState>) =>
|
||||
setForm((prev) => (prev === null ? prev : { ...prev, ...patch }));
|
||||
|
||||
const openForm = (next: FormState) => {
|
||||
setFormError(null);
|
||||
setForm(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);
|
||||
return;
|
||||
}
|
||||
// Empty-string keys are always omitted; target is one of two choices — sessionId is
|
||||
// sent only when binding to a Session, and workspace plus the model reference
|
||||
// (modelId + provider pair) only when creating a new Session.
|
||||
const body: ScheduleUpsertRequest = {
|
||||
prompt: form.prompt,
|
||||
enabled: form.enabled,
|
||||
startAt: new Date(form.startAt).toISOString(),
|
||||
...(form.period.trim() ? { period: form.period.trim() } : {}),
|
||||
...(form.endAt ? { endAt: new Date(form.endAt).toISOString() } : {}),
|
||||
...(form.target === "session" && form.sessionId.trim()
|
||||
? { sessionId: form.sessionId.trim() }
|
||||
: {}),
|
||||
...(form.target === "new" && form.workspace.trim()
|
||||
? { workspace: form.workspace.trim() }
|
||||
: {}),
|
||||
...(form.target === "new" && form.model
|
||||
? {
|
||||
modelId: form.model.modelId,
|
||||
...(form.model.provider !== undefined ? { provider: form.model.provider } : {}),
|
||||
}
|
||||
: {}),
|
||||
};
|
||||
setBusy(true);
|
||||
try {
|
||||
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);
|
||||
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);
|
||||
} finally {
|
||||
setBusy(false);
|
||||
}
|
||||
};
|
||||
|
||||
/** Toggle: whole-file-replace semantics — resend original fields, only flip enabled. */
|
||||
const toggle = async (item: ScheduleItem) => {
|
||||
if (!projectId) return;
|
||||
setBusy(true);
|
||||
setError(null);
|
||||
setNotice(null);
|
||||
try {
|
||||
await api.updateSchedule(projectId, agentId, item.name, {
|
||||
prompt: item.prompt,
|
||||
enabled: !item.enabled,
|
||||
startAt: item.startAt,
|
||||
...(item.period !== undefined ? { period: item.period } : {}),
|
||||
...(item.endAt !== undefined ? { endAt: item.endAt } : {}),
|
||||
...(item.sessionId !== undefined ? { sessionId: item.sessionId } : {}),
|
||||
...(item.workspace !== undefined ? { workspace: item.workspace } : {}),
|
||||
// Model reference is resent as a pair (a hand-written file with provider omitted stays omitted).
|
||||
...(item.modelId !== undefined ? { modelId: item.modelId } : {}),
|
||||
...(item.provider !== undefined ? { provider: item.provider } : {}),
|
||||
});
|
||||
setNotice(S.common.saved);
|
||||
await load();
|
||||
} catch (e) {
|
||||
setError(e instanceof ApiError ? e.message : S.common.unknownError);
|
||||
} finally {
|
||||
setBusy(false);
|
||||
}
|
||||
};
|
||||
|
||||
/** Edit: prefill this row into the modal form (submits via PUT; model reference prefilled as a pair, provider not auto-filled when absent). */
|
||||
const startEdit = (item: ScheduleItem) => {
|
||||
openForm({
|
||||
editing: item.name,
|
||||
name: item.name,
|
||||
prompt: item.prompt,
|
||||
enabled: item.enabled,
|
||||
startAt: toLocalInput(item.startAt),
|
||||
endAt: toLocalInput(item.endAt),
|
||||
period: item.period ?? "",
|
||||
target: item.sessionId ? "session" : "new",
|
||||
sessionId: item.sessionId ?? "",
|
||||
workspace: item.workspace ?? "",
|
||||
model:
|
||||
item.modelId !== undefined
|
||||
? {
|
||||
modelId: item.modelId,
|
||||
...(item.provider !== undefined ? { provider: item.provider } : {}),
|
||||
}
|
||||
: null,
|
||||
});
|
||||
};
|
||||
|
||||
/** Confirm modal's "Confirm": closes the modal after deletion; if the deleted task is currently being edited, close the form too. */
|
||||
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);
|
||||
} finally {
|
||||
setBusy(false);
|
||||
setDeleting(null);
|
||||
}
|
||||
};
|
||||
|
||||
if (!projectId) return null;
|
||||
|
||||
const schedules = data?.schedules ?? [];
|
||||
const invalidFiles = data?.invalidFiles ?? [];
|
||||
|
||||
return (
|
||||
<div className="space-y-4">
|
||||
<div>
|
||||
<p className="text-xs text-gray-500 dark:text-gray-400">{S.schedule.desc}</p>
|
||||
{!isOwner && (
|
||||
<p className="mt-1 text-xs text-gray-500 dark:text-gray-400">{S.schedule.readOnlyHint}</p>
|
||||
)}
|
||||
</div>
|
||||
|
||||
{data === null ? (
|
||||
<SkeletonList rows={4} />
|
||||
) : schedules.length === 0 ? (
|
||||
// Plain-text empty state (settings area doesn't use the penguin-icon EmptyState, keeps the same gray level as the table area).
|
||||
<p className="py-2 text-xs text-gray-400 dark:text-gray-500">{S.schedule.empty}</p>
|
||||
) : (
|
||||
<div className="overflow-x-auto overflow-y-clip rounded-md border border-gray-200 bg-white dark:border-gray-800 dark:bg-gray-900">
|
||||
<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.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>
|
||||
<th className="px-3 py-2.5">{S.schedule.colFireTimes}</th>
|
||||
<th className="px-3 py-2.5">{S.schedule.colQueued}</th>
|
||||
{isOwner && <th className="px-3 py-2.5" />}
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody>
|
||||
{schedules.map((item) => (
|
||||
<tr
|
||||
key={item.name}
|
||||
className="border-b border-gray-100 transition-colors duration-150 last:border-b-0 hover:bg-gray-50 dark:border-gray-800/60 dark:hover:bg-gray-800/40"
|
||||
>
|
||||
<td className="px-3 py-2 font-mono text-xs">{item.name}</td>
|
||||
<td className="px-3 py-2">
|
||||
{/* invalid reason is folded into the hover title. */}
|
||||
<span title={item.invalidReason}>
|
||||
<Badge tone={STATUS_TONE[item.status]}>
|
||||
{S.schedule.statusNames[item.status] ?? item.status}
|
||||
</Badge>
|
||||
</span>
|
||||
</td>
|
||||
<td className="px-3 py-2 text-xs text-gray-500 dark:text-gray-400">
|
||||
{item.period !== undefined ? (
|
||||
<span className="font-mono">{item.period}</span>
|
||||
) : (
|
||||
S.schedule.once
|
||||
)}
|
||||
</td>
|
||||
<td
|
||||
className="max-w-36 truncate px-3 py-2 text-xs text-gray-500 dark:text-gray-400"
|
||||
title={item.sessionId}
|
||||
>
|
||||
{item.sessionId !== undefined ? (
|
||||
<span className="font-mono">{item.sessionId}</span>
|
||||
) : (
|
||||
S.schedule.newSession
|
||||
)}
|
||||
</td>
|
||||
{/* Top row: next fire time; bottom row: last fired time (both show — when absent). */}
|
||||
<td className="px-3 py-2 text-xs">
|
||||
<span className="block text-gray-600 dark:text-gray-300">
|
||||
{item.nextFireAt ? formatDateTime(item.nextFireAt) : "—"}
|
||||
</span>
|
||||
<span className="block text-gray-400 dark:text-gray-500">
|
||||
{item.lastFiredAt ? formatDateTime(item.lastFiredAt) : "—"}
|
||||
</span>
|
||||
</td>
|
||||
<td className="px-3 py-2">
|
||||
{item.queued && <Badge tone="brand">{S.schedule.queued}</Badge>}
|
||||
</td>
|
||||
{isOwner && (
|
||||
<td className="whitespace-nowrap px-3 py-2 text-right">
|
||||
<Button
|
||||
size="sm"
|
||||
variant="ghost"
|
||||
disabled={busy}
|
||||
onClick={() => void toggle(item)}
|
||||
>
|
||||
{item.enabled ? S.schedule.disable : S.schedule.enable}
|
||||
</Button>
|
||||
<Button
|
||||
size="sm"
|
||||
variant="ghost"
|
||||
disabled={busy}
|
||||
onClick={() => startEdit(item)}
|
||||
>
|
||||
{S.common.edit}
|
||||
</Button>
|
||||
<Button
|
||||
size="sm"
|
||||
variant="ghost"
|
||||
disabled={busy}
|
||||
onClick={() => setDeleting(item.name)}
|
||||
>
|
||||
{S.common.delete}
|
||||
</Button>
|
||||
</td>
|
||||
)}
|
||||
</tr>
|
||||
))}
|
||||
</tbody>
|
||||
</table>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{invalidFiles.length > 0 && (
|
||||
<div className="text-xs text-red-600 dark:text-red-400">
|
||||
<p className="font-medium">{S.schedule.invalidFiles}</p>
|
||||
<ul className="mt-0.5 space-y-0.5 font-mono">
|
||||
{invalidFiles.map((f) => (
|
||||
<li key={f.name}>
|
||||
{f.name}: {f.error}
|
||||
</li>
|
||||
))}
|
||||
</ul>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* Create entry point (owner): the form lives in a modal; the inline "Edit" button reuses the same modal. */}
|
||||
{isOwner && data !== null && (
|
||||
<Button size="sm" variant="primary" disabled={busy} onClick={() => openForm(EMPTY_FORM)}>
|
||||
{S.schedule.addTitle}
|
||||
</Button>
|
||||
)}
|
||||
|
||||
{/* Shared create/edit modal form. */}
|
||||
<Modal
|
||||
open={form !== null}
|
||||
title={form?.editing != null ? S.schedule.editTitle(form.editing) : S.schedule.addTitle}
|
||||
onClose={() => setForm(null)}
|
||||
widthClass="sm:max-w-lg"
|
||||
footer={
|
||||
<>
|
||||
<Button onClick={() => setForm(null)}>{S.common.cancel}</Button>
|
||||
<Button variant="primary" disabled={busy} onClick={() => void submit()}>
|
||||
{form?.editing != null ? S.common.save : S.common.create}
|
||||
</Button>
|
||||
</>
|
||||
}
|
||||
>
|
||||
{form !== null && (
|
||||
<div className="space-y-3">
|
||||
<div className="grid grid-cols-1 gap-3 md:grid-cols-2">
|
||||
<Input
|
||||
size="sm"
|
||||
label={S.schedule.name}
|
||||
hint={S.schedule.nameHint}
|
||||
value={form.name}
|
||||
disabled={form.editing !== null}
|
||||
onChange={(e) => set({ name: e.target.value })}
|
||||
className="font-mono"
|
||||
placeholder="daily_report"
|
||||
autoComplete="off"
|
||||
/>
|
||||
<Input
|
||||
size="sm"
|
||||
label={S.schedule.period}
|
||||
value={form.period}
|
||||
onChange={(e) => set({ period: e.target.value })}
|
||||
className="font-mono"
|
||||
placeholder={S.schedule.periodPlaceholder}
|
||||
autoComplete="off"
|
||||
/>
|
||||
<Input
|
||||
size="sm"
|
||||
label={S.schedule.startAt}
|
||||
type="datetime-local"
|
||||
value={form.startAt}
|
||||
onChange={(e) => set({ startAt: e.target.value })}
|
||||
className="font-mono"
|
||||
/>
|
||||
<Input
|
||||
size="sm"
|
||||
label={S.schedule.endAt}
|
||||
type="datetime-local"
|
||||
value={form.endAt}
|
||||
onChange={(e) => set({ endAt: e.target.value })}
|
||||
className="font-mono"
|
||||
/>
|
||||
<Select
|
||||
size="sm"
|
||||
label={S.schedule.target}
|
||||
value={form.target}
|
||||
onChange={(e) => set({ target: e.target.value as FormState["target"] })}
|
||||
>
|
||||
<option value="new">{S.schedule.targetNew}</option>
|
||||
<option value="session">{S.schedule.targetSession}</option>
|
||||
</Select>
|
||||
{form.target === "session" ? (
|
||||
<Input
|
||||
size="sm"
|
||||
label={S.schedule.sessionId}
|
||||
value={form.sessionId}
|
||||
onChange={(e) => set({ sessionId: e.target.value })}
|
||||
className="font-mono"
|
||||
autoComplete="off"
|
||||
/>
|
||||
) : (
|
||||
<>
|
||||
<Select
|
||||
size="sm"
|
||||
label={S.schedule.model}
|
||||
value={form.model ? modelOptionValue(form.model) : ""}
|
||||
onChange={(e) => set({ model: parseModelOption(e.target.value) })}
|
||||
>
|
||||
<option value="">{S.schedule.modelDefault}</option>
|
||||
{/* When the prefilled reference is no longer in the model config (including a
|
||||
hand-written file missing only provider), add an extra option so it isn't
|
||||
displayed as "Project default". */}
|
||||
{form.model &&
|
||||
!models.some(
|
||||
(m) =>
|
||||
m.modelId === form.model!.modelId && m.provider === form.model!.provider,
|
||||
) && (
|
||||
<option value={modelOptionValue(form.model)}>
|
||||
{modelOptionLabel(form.model)}
|
||||
</option>
|
||||
)}
|
||||
{models.map((m) => (
|
||||
<option
|
||||
key={`${m.provider}:${m.modelId}`}
|
||||
value={modelOptionValue({ provider: m.provider, modelId: m.modelId })}
|
||||
>
|
||||
{modelOptionLabel({ provider: m.provider, modelId: m.modelId })}
|
||||
</option>
|
||||
))}
|
||||
</Select>
|
||||
<Input
|
||||
size="sm"
|
||||
label={S.schedule.workspace}
|
||||
value={form.workspace}
|
||||
onChange={(e) => set({ workspace: e.target.value })}
|
||||
className="font-mono"
|
||||
autoComplete="off"
|
||||
/>
|
||||
</>
|
||||
)}
|
||||
</div>
|
||||
<Textarea
|
||||
label={S.schedule.prompt}
|
||||
size="sm"
|
||||
rows={4}
|
||||
value={form.prompt}
|
||||
onChange={(e) => set({ prompt: e.target.value })}
|
||||
/>
|
||||
<label className="flex items-center gap-1.5 text-xs text-gray-600 dark:text-gray-300">
|
||||
<input
|
||||
type="checkbox"
|
||||
checked={form.enabled}
|
||||
onChange={(e) => set({ enabled: e.target.checked })}
|
||||
/>
|
||||
{S.schedule.enabled}
|
||||
</label>
|
||||
{formError && <p className="text-xs text-red-600 dark:text-red-400">{formError}</p>}
|
||||
</div>
|
||||
)}
|
||||
</Modal>
|
||||
|
||||
{/* Delete confirmation (same pattern as Vault / Agent deletion: Modal + cancel/danger-confirm). */}
|
||||
<Modal
|
||||
open={deleting !== null}
|
||||
title={S.schedule.deleteTitle}
|
||||
onClose={() => setDeleting(null)}
|
||||
footer={
|
||||
<>
|
||||
<Button onClick={() => setDeleting(null)}>{S.common.cancel}</Button>
|
||||
<Button variant="danger" disabled={busy} onClick={() => void confirmRemove()}>
|
||||
{S.common.confirm}
|
||||
</Button>
|
||||
</>
|
||||
}
|
||||
>
|
||||
<p className="text-sm text-gray-600 dark:text-gray-300">
|
||||
{deleting !== null ? S.schedule.deleteConfirm(deleting) : ""}
|
||||
</p>
|
||||
</Modal>
|
||||
|
||||
{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>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,248 @@
|
||||
/**
|
||||
* Agent settings page "Vault" tab: an Agent-level key-value vault
|
||||
* (agent_state/.vault.toml) — a table (key, masked value, delete) plus an "Add" modal
|
||||
* (key + value, value uses a password field). Saving goes through PUT with
|
||||
* whole-table replace semantics: keys absent from the body are deleted, and
|
||||
* resending only the key name means keep the original value (plaintext never comes
|
||||
* back to the frontend); only owners can edit, members are read-only.
|
||||
* The key name is injected into the Agent's system prompt to inform the model; the
|
||||
* value is injected only into the exec_command subprocess environment, never into
|
||||
* the model context.
|
||||
*/
|
||||
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 { 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 { SkeletonList } from "../../components/ui/skeleton";
|
||||
|
||||
/** Vault key naming rule (consistent with core/server): shell environment variable name. */
|
||||
const VAULT_KEY_PATTERN = /^[A-Za-z_][A-Za-z0-9_]*$/;
|
||||
|
||||
export function VaultTab({ agentId }: { agentId: string }) {
|
||||
const { currentProject } = useProject();
|
||||
const projectId = currentProject?.projectId ?? null;
|
||||
const isOwner = currentProject?.role === "owner";
|
||||
|
||||
const [entries, setEntries] = useState<VaultEntryInfo[] | null>(null);
|
||||
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).
|
||||
const [adding, setAdding] = useState(false);
|
||||
const [keyInput, setKeyInput] = useState("");
|
||||
const [valueInput, setValueInput] = useState("");
|
||||
const [addError, setAddError] = useState<string | null>(null);
|
||||
// Key pending deletion confirmation (non-null shows the confirm modal).
|
||||
const [deleting, setDeleting] = useState<string | null>(null);
|
||||
|
||||
const load = useCallback(async () => {
|
||||
if (!projectId || !agentId) return;
|
||||
setEntries(null);
|
||||
setError(null);
|
||||
try {
|
||||
const res = await api.getVault(projectId, agentId);
|
||||
setEntries(res.entries);
|
||||
} catch (e) {
|
||||
setError(e instanceof ApiError ? e.message : S.common.unknownError);
|
||||
}
|
||||
}, [projectId, agentId]);
|
||||
|
||||
useEffect(() => {
|
||||
void load();
|
||||
}, [load]);
|
||||
|
||||
/** Persist a change immediately (add / delete): returns null on success, an error message on failure — the caller decides whether it lands inside the modal or at the tab level. */
|
||||
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);
|
||||
return null;
|
||||
} catch (e) {
|
||||
return e instanceof ApiError ? e.message : S.common.unknownError;
|
||||
} finally {
|
||||
setBusy(false);
|
||||
}
|
||||
};
|
||||
|
||||
/** Keep existing keys (resending only the key name = keep the original value), excluding excludeKey. */
|
||||
const keepEntries = (excludeKey?: string) =>
|
||||
(entries ?? [])
|
||||
.filter((e) => e.key !== excludeKey)
|
||||
.map((e): VaultUpdateRequest["entries"][number] => ({ key: e.key }));
|
||||
|
||||
/** Open the add modal (reset form and error state). */
|
||||
const openAdd = () => {
|
||||
setKeyInput("");
|
||||
setValueInput("");
|
||||
setAddError(null);
|
||||
setAdding(true);
|
||||
};
|
||||
|
||||
const addEntry = async () => {
|
||||
const key = keyInput.trim();
|
||||
if (!key) {
|
||||
setAddError(S.common.requiredField);
|
||||
return;
|
||||
}
|
||||
if (!VAULT_KEY_PATTERN.test(key)) {
|
||||
setAddError(S.vault.keyInvalid);
|
||||
return;
|
||||
}
|
||||
if (!valueInput) {
|
||||
setAddError(S.vault.valueRequired);
|
||||
return;
|
||||
}
|
||||
setAddError(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);
|
||||
return;
|
||||
}
|
||||
setAdding(false);
|
||||
};
|
||||
|
||||
/** Confirm modal's "Confirm": closes the modal after deletion (on failure, the error lands on the tab's error line). */
|
||||
const confirmRemove = async () => {
|
||||
if (deleting === null) return;
|
||||
setError(null);
|
||||
const err = await persist({ entries: keepEntries(deleting) });
|
||||
if (err !== null) setError(err);
|
||||
setDeleting(null);
|
||||
};
|
||||
|
||||
if (!projectId) return null;
|
||||
|
||||
return (
|
||||
<div className="space-y-4">
|
||||
<div>
|
||||
<p className="text-xs text-gray-500 dark:text-gray-400">{S.vault.desc}</p>
|
||||
{!isOwner && (
|
||||
<p className="mt-1 text-xs text-gray-500 dark:text-gray-400">{S.vault.readOnlyHint}</p>
|
||||
)}
|
||||
</div>
|
||||
|
||||
{entries === null ? (
|
||||
<SkeletonList rows={4} />
|
||||
) : entries.length === 0 ? (
|
||||
// Plain-text empty state (settings area doesn't use the penguin-icon EmptyState, keeps the same gray level as the table area).
|
||||
<p className="py-2 text-xs text-gray-400 dark:text-gray-500">{S.vault.empty}</p>
|
||||
) : (
|
||||
<div className="overflow-x-auto overflow-y-clip rounded-md border border-gray-200 bg-white dark:border-gray-800 dark:bg-gray-900">
|
||||
<table className="w-full min-w-[420px] 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.vault.key}</th>
|
||||
<th className="px-3 py-2.5">{S.vault.valueMasked}</th>
|
||||
<th className="px-3 py-2.5" />
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody>
|
||||
{entries.map((entry) => (
|
||||
<tr
|
||||
key={entry.key}
|
||||
className="border-b border-gray-100 transition-colors duration-150 last:border-b-0 hover:bg-gray-50 dark:border-gray-800/60 dark:hover:bg-gray-800/40"
|
||||
>
|
||||
<td className="px-3 py-2 font-mono text-xs">{entry.key}</td>
|
||||
<td className="px-3 py-2 font-mono text-xs text-gray-500 dark:text-gray-400">
|
||||
{entry.valueMasked}
|
||||
</td>
|
||||
<td className="px-3 py-2 text-right">
|
||||
{isOwner && (
|
||||
<Button
|
||||
size="sm"
|
||||
variant="ghost"
|
||||
disabled={busy}
|
||||
onClick={() => setDeleting(entry.key)}
|
||||
>
|
||||
{S.vault.remove}
|
||||
</Button>
|
||||
)}
|
||||
</td>
|
||||
</tr>
|
||||
))}
|
||||
</tbody>
|
||||
</table>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* Add entry point (owner): the form lives in a modal; submitting the same key name overwrites the original value. */}
|
||||
{isOwner && entries !== null && (
|
||||
<Button size="sm" variant="primary" disabled={busy} onClick={openAdd}>
|
||||
{S.vault.add}
|
||||
</Button>
|
||||
)}
|
||||
|
||||
<Modal
|
||||
open={adding}
|
||||
title={S.vault.addTitle}
|
||||
onClose={() => setAdding(false)}
|
||||
footer={
|
||||
<>
|
||||
<Button onClick={() => setAdding(false)}>{S.common.cancel}</Button>
|
||||
<Button variant="primary" disabled={busy} onClick={() => void addEntry()}>
|
||||
{S.vault.add}
|
||||
</Button>
|
||||
</>
|
||||
}
|
||||
>
|
||||
<div className="space-y-3">
|
||||
<Input
|
||||
size="sm"
|
||||
label={S.vault.key}
|
||||
hint={S.vault.keyHint}
|
||||
value={keyInput}
|
||||
onChange={(e) => setKeyInput(e.target.value)}
|
||||
className="font-mono"
|
||||
placeholder="OPENAI_API_KEY"
|
||||
autoComplete="off"
|
||||
/>
|
||||
<PasswordInput
|
||||
size="sm"
|
||||
label={S.vault.value}
|
||||
value={valueInput}
|
||||
onChange={(e) => setValueInput(e.target.value)}
|
||||
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
|
||||
open={deleting !== null}
|
||||
title={S.vault.deleteTitle}
|
||||
onClose={() => setDeleting(null)}
|
||||
footer={
|
||||
<>
|
||||
<Button onClick={() => setDeleting(null)}>{S.common.cancel}</Button>
|
||||
<Button variant="danger" disabled={busy} onClick={() => void confirmRemove()}>
|
||||
{S.common.confirm}
|
||||
</Button>
|
||||
</>
|
||||
}
|
||||
>
|
||||
<p className="text-sm text-gray-600 dark:text-gray-300">
|
||||
{deleting !== null ? S.vault.deleteConfirm(deleting) : ""}
|
||||
</p>
|
||||
</Modal>
|
||||
|
||||
{error && <p className="text-xs text-red-600 dark:text-red-400">{error}</p>}
|
||||
{notice && <p className="text-xs text-emerald-600 dark:text-emerald-400">{notice}</p>}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
Reference in New Issue
Block a user