feat: reorganize agent detail tabs and add Prompts tab
Rearrange tabs to: Dashboard, Prompts, Skills, Configuration, Budget. Move Prompt Template out of Configuration into a dedicated Prompts tab with its own save/cancel flow and dirty tracking. Co-Authored-By: Paperclip <noreply@paperclip.ing>
This commit is contained in:
@@ -69,6 +69,8 @@ type AgentConfigFormProps = {
|
||||
showAdapterTestEnvironmentButton?: boolean;
|
||||
showCreateRunPolicySection?: boolean;
|
||||
hideInstructionsFile?: boolean;
|
||||
/** Hide the prompt template field from the Identity section (used when it's shown in a separate Prompts tab). */
|
||||
hidePromptTemplate?: boolean;
|
||||
/** "cards" renders each section as heading + bordered card (for settings pages). Default: "inline" (border-b dividers). */
|
||||
sectionLayout?: "inline" | "cards";
|
||||
} & (
|
||||
@@ -483,7 +485,7 @@ export function AgentConfigForm(props: AgentConfigFormProps) {
|
||||
}}
|
||||
/>
|
||||
</Field>
|
||||
{isLocal && (
|
||||
{isLocal && !props.hidePromptTemplate && (
|
||||
<>
|
||||
<Field label="Prompt Template" hint={help.promptTemplate}>
|
||||
<MarkdownEditor
|
||||
|
||||
@@ -17,7 +17,9 @@ import { useBreadcrumbs } from "../context/BreadcrumbContext";
|
||||
import { queryKeys } from "../lib/queryKeys";
|
||||
import { AgentConfigForm } from "../components/AgentConfigForm";
|
||||
import { PageTabBar } from "../components/PageTabBar";
|
||||
import { adapterLabels, roleLabels } from "../components/agent-config-primitives";
|
||||
import { adapterLabels, roleLabels, help } from "../components/agent-config-primitives";
|
||||
import { MarkdownEditor } from "../components/MarkdownEditor";
|
||||
import { assetsApi } from "../api/assets";
|
||||
import { getUIAdapter, buildTranscript } from "../adapters";
|
||||
import { StatusBadge } from "../components/StatusBadge";
|
||||
import { agentStatusDot, agentStatusDotDefault } from "../lib/status-colors";
|
||||
@@ -190,9 +192,10 @@ function scrollToContainerBottom(container: ScrollContainer, behavior: ScrollBeh
|
||||
container.scrollTo({ top: container.scrollHeight, behavior });
|
||||
}
|
||||
|
||||
type AgentDetailView = "dashboard" | "configuration" | "skills" | "runs" | "budget";
|
||||
type AgentDetailView = "dashboard" | "prompts" | "configuration" | "skills" | "runs" | "budget";
|
||||
|
||||
function parseAgentDetailView(value: string | null): AgentDetailView {
|
||||
if (value === "prompts") return value;
|
||||
if (value === "configure" || value === "configuration") return "configuration";
|
||||
if (value === "skills") return "skills";
|
||||
if (value === "budget") return "budget";
|
||||
@@ -584,15 +587,17 @@ export function AgentDetail() {
|
||||
return;
|
||||
}
|
||||
const canonicalTab =
|
||||
activeView === "configuration"
|
||||
? "configuration"
|
||||
: activeView === "skills"
|
||||
? "skills"
|
||||
: activeView === "runs"
|
||||
? "runs"
|
||||
: activeView === "budget"
|
||||
? "budget"
|
||||
: "dashboard";
|
||||
activeView === "prompts"
|
||||
? "prompts"
|
||||
: activeView === "configuration"
|
||||
? "configuration"
|
||||
: activeView === "skills"
|
||||
? "skills"
|
||||
: activeView === "runs"
|
||||
? "runs"
|
||||
: activeView === "budget"
|
||||
? "budget"
|
||||
: "dashboard";
|
||||
if (routeAgentRef !== canonicalAgentRef || urlTab !== canonicalTab) {
|
||||
navigate(`/agents/${canonicalAgentRef}/${canonicalTab}`, { replace: true });
|
||||
return;
|
||||
@@ -705,6 +710,8 @@ export function AgentDetail() {
|
||||
if (urlRunId) {
|
||||
crumbs.push({ label: "Runs", href: `/agents/${canonicalAgentRef}/runs` });
|
||||
crumbs.push({ label: `Run ${urlRunId.slice(0, 8)}` });
|
||||
} else if (activeView === "prompts") {
|
||||
crumbs.push({ label: "Prompts" });
|
||||
} else if (activeView === "configuration") {
|
||||
crumbs.push({ label: "Configuration" });
|
||||
} else if (activeView === "skills") {
|
||||
@@ -740,7 +747,7 @@ export function AgentDetail() {
|
||||
return <Navigate to={`/agents/${canonicalAgentRef}/dashboard`} replace />;
|
||||
}
|
||||
const isPendingApproval = agent.status === "pending_approval";
|
||||
const showConfigActionBar = activeView === "configuration" && (configDirty || configSaving);
|
||||
const showConfigActionBar = (activeView === "configuration" || activeView === "prompts") && (configDirty || configSaving);
|
||||
|
||||
return (
|
||||
<div className={cn("space-y-6", isMobile && showConfigActionBar && "pb-24")}>
|
||||
@@ -867,9 +874,9 @@ export function AgentDetail() {
|
||||
<PageTabBar
|
||||
items={[
|
||||
{ value: "dashboard", label: "Dashboard" },
|
||||
{ value: "configuration", label: "Configuration" },
|
||||
{ value: "prompts", label: "Prompts" },
|
||||
{ value: "skills", label: "Skills" },
|
||||
{ value: "runs", label: "Runs" },
|
||||
{ value: "configuration", label: "Configuration" },
|
||||
{ value: "budget", label: "Budget" },
|
||||
]}
|
||||
value={activeView}
|
||||
@@ -953,6 +960,17 @@ export function AgentDetail() {
|
||||
/>
|
||||
)}
|
||||
|
||||
{activeView === "prompts" && (
|
||||
<PromptsTab
|
||||
agent={agent}
|
||||
companyId={resolvedCompanyId ?? undefined}
|
||||
onDirtyChange={setConfigDirty}
|
||||
onSaveActionChange={setSaveConfigAction}
|
||||
onCancelActionChange={setCancelConfigAction}
|
||||
onSavingChange={setConfigSaving}
|
||||
/>
|
||||
)}
|
||||
|
||||
{activeView === "configuration" && (
|
||||
<AgentConfigurePage
|
||||
agent={agent}
|
||||
@@ -1281,6 +1299,7 @@ function AgentConfigurePage({
|
||||
onSavingChange={onSavingChange}
|
||||
updatePermissions={updatePermissions}
|
||||
companyId={companyId}
|
||||
hidePromptTemplate
|
||||
/>
|
||||
<div>
|
||||
<h3 className="text-sm font-medium mb-3">API Keys</h3>
|
||||
@@ -1351,6 +1370,7 @@ function ConfigurationTab({
|
||||
onCancelActionChange,
|
||||
onSavingChange,
|
||||
updatePermissions,
|
||||
hidePromptTemplate,
|
||||
}: {
|
||||
agent: Agent;
|
||||
companyId?: string;
|
||||
@@ -1359,6 +1379,7 @@ function ConfigurationTab({
|
||||
onCancelActionChange: (cancel: (() => void) | null) => void;
|
||||
onSavingChange: (saving: boolean) => void;
|
||||
updatePermissions: { mutate: (canCreate: boolean) => void; isPending: boolean };
|
||||
hidePromptTemplate?: boolean;
|
||||
}) {
|
||||
const queryClient = useQueryClient();
|
||||
const [awaitingRefreshAfterSave, setAwaitingRefreshAfterSave] = useState(false);
|
||||
@@ -1412,6 +1433,7 @@ function ConfigurationTab({
|
||||
onSaveActionChange={onSaveActionChange}
|
||||
onCancelActionChange={onCancelActionChange}
|
||||
hideInlineSave
|
||||
hidePromptTemplate={hidePromptTemplate}
|
||||
sectionLayout="cards"
|
||||
/>
|
||||
|
||||
@@ -1438,6 +1460,119 @@ function ConfigurationTab({
|
||||
);
|
||||
}
|
||||
|
||||
/* ---- Prompts Tab ---- */
|
||||
|
||||
function PromptsTab({
|
||||
agent,
|
||||
companyId,
|
||||
onDirtyChange,
|
||||
onSaveActionChange,
|
||||
onCancelActionChange,
|
||||
onSavingChange,
|
||||
}: {
|
||||
agent: Agent;
|
||||
companyId?: string;
|
||||
onDirtyChange: (dirty: boolean) => void;
|
||||
onSaveActionChange: (save: (() => void) | null) => void;
|
||||
onCancelActionChange: (cancel: (() => void) | null) => void;
|
||||
onSavingChange: (saving: boolean) => void;
|
||||
}) {
|
||||
const queryClient = useQueryClient();
|
||||
const { selectedCompanyId } = useCompany();
|
||||
const [draft, setDraft] = useState<string | null>(null);
|
||||
const [awaitingRefresh, setAwaitingRefresh] = useState(false);
|
||||
const lastAgentRef = useRef(agent);
|
||||
|
||||
const currentValue = String(agent.adapterConfig?.promptTemplate ?? "");
|
||||
const displayValue = draft ?? currentValue;
|
||||
const isDirty = draft !== null && draft !== currentValue;
|
||||
|
||||
const isLocal =
|
||||
agent.adapterType === "claude_local" ||
|
||||
agent.adapterType === "codex_local" ||
|
||||
agent.adapterType === "opencode_local" ||
|
||||
agent.adapterType === "pi_local" ||
|
||||
agent.adapterType === "hermes_local" ||
|
||||
agent.adapterType === "cursor";
|
||||
|
||||
const updateAgent = useMutation({
|
||||
mutationFn: (data: Record<string, unknown>) => agentsApi.update(agent.id, data, companyId),
|
||||
onMutate: () => setAwaitingRefresh(true),
|
||||
onSuccess: () => {
|
||||
queryClient.invalidateQueries({ queryKey: queryKeys.agents.detail(agent.id) });
|
||||
queryClient.invalidateQueries({ queryKey: queryKeys.agents.detail(agent.urlKey) });
|
||||
},
|
||||
onError: () => setAwaitingRefresh(false),
|
||||
});
|
||||
|
||||
const uploadMarkdownImage = useMutation({
|
||||
mutationFn: async ({ file, namespace }: { file: File; namespace: string }) => {
|
||||
if (!selectedCompanyId) throw new Error("Select a company to upload images");
|
||||
return assetsApi.uploadImage(selectedCompanyId, file, namespace);
|
||||
},
|
||||
});
|
||||
|
||||
useEffect(() => {
|
||||
if (awaitingRefresh && agent !== lastAgentRef.current) {
|
||||
setAwaitingRefresh(false);
|
||||
setDraft(null);
|
||||
}
|
||||
lastAgentRef.current = agent;
|
||||
}, [agent, awaitingRefresh]);
|
||||
|
||||
const isSaving = updateAgent.isPending || awaitingRefresh;
|
||||
|
||||
useEffect(() => { onSavingChange(isSaving); }, [onSavingChange, isSaving]);
|
||||
useEffect(() => { onDirtyChange(isDirty); }, [onDirtyChange, isDirty]);
|
||||
|
||||
useEffect(() => {
|
||||
onSaveActionChange(isDirty ? () => {
|
||||
updateAgent.mutate({ adapterConfig: { promptTemplate: draft } });
|
||||
} : null);
|
||||
}, [onSaveActionChange, isDirty, draft, updateAgent]);
|
||||
|
||||
useEffect(() => {
|
||||
onCancelActionChange(isDirty ? () => setDraft(null) : null);
|
||||
}, [onCancelActionChange, isDirty]);
|
||||
|
||||
if (!isLocal) {
|
||||
return (
|
||||
<div className="max-w-3xl">
|
||||
<p className="text-sm text-muted-foreground">
|
||||
Prompt templates are only available for local adapters.
|
||||
</p>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
return (
|
||||
<div className="max-w-3xl space-y-4">
|
||||
<div>
|
||||
<h3 className="text-sm font-medium mb-3">Prompt Template</h3>
|
||||
<div className="border border-border rounded-lg p-4 space-y-3">
|
||||
<p className="text-sm text-muted-foreground">
|
||||
{help.promptTemplate}
|
||||
</p>
|
||||
<MarkdownEditor
|
||||
value={displayValue}
|
||||
onChange={(v) => setDraft(v ?? "")}
|
||||
placeholder="You are agent {{ agent.name }}. Your role is {{ agent.role }}..."
|
||||
contentClassName="min-h-[88px] text-sm font-mono"
|
||||
imageUploadHandler={async (file) => {
|
||||
const namespace = `agents/${agent.id}/prompt-template`;
|
||||
const asset = await uploadMarkdownImage.mutateAsync({ file, namespace });
|
||||
return asset.contentPath;
|
||||
}}
|
||||
/>
|
||||
<div className="rounded-md border border-amber-500/25 bg-amber-500/10 px-3 py-2 text-xs text-amber-100">
|
||||
Prompt template is replayed on every heartbeat. Keep it compact and dynamic to avoid recurring token cost and cache churn.
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
function AgentSkillsTab({
|
||||
agent,
|
||||
companyId,
|
||||
|
||||
Reference in New Issue
Block a user