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:
@@ -65,6 +65,8 @@ type AgentConfigFormProps = {
|
|||||||
onSaveActionChange?: (save: (() => void) | null) => void;
|
onSaveActionChange?: (save: (() => void) | null) => void;
|
||||||
onCancelActionChange?: (cancel: (() => void) | null) => void;
|
onCancelActionChange?: (cancel: (() => void) | null) => void;
|
||||||
hideInlineSave?: boolean;
|
hideInlineSave?: 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). */
|
/** "cards" renders each section as heading + bordered card (for settings pages). Default: "inline" (border-b dividers). */
|
||||||
sectionLayout?: "inline" | "cards";
|
sectionLayout?: "inline" | "cards";
|
||||||
} & (
|
} & (
|
||||||
@@ -473,7 +475,7 @@ export function AgentConfigForm(props: AgentConfigFormProps) {
|
|||||||
}}
|
}}
|
||||||
/>
|
/>
|
||||||
</Field>
|
</Field>
|
||||||
{isLocal && (
|
{isLocal && !props.hidePromptTemplate && (
|
||||||
<>
|
<>
|
||||||
<Field label="Prompt Template" hint={help.promptTemplate}>
|
<Field label="Prompt Template" hint={help.promptTemplate}>
|
||||||
<MarkdownEditor
|
<MarkdownEditor
|
||||||
|
|||||||
@@ -16,7 +16,9 @@ import { useBreadcrumbs } from "../context/BreadcrumbContext";
|
|||||||
import { queryKeys } from "../lib/queryKeys";
|
import { queryKeys } from "../lib/queryKeys";
|
||||||
import { AgentConfigForm } from "../components/AgentConfigForm";
|
import { AgentConfigForm } from "../components/AgentConfigForm";
|
||||||
import { PageTabBar } from "../components/PageTabBar";
|
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 { getUIAdapter, buildTranscript } from "../adapters";
|
||||||
import { StatusBadge } from "../components/StatusBadge";
|
import { StatusBadge } from "../components/StatusBadge";
|
||||||
import { agentStatusDot, agentStatusDotDefault } from "../lib/status-colors";
|
import { agentStatusDot, agentStatusDotDefault } from "../lib/status-colors";
|
||||||
@@ -187,9 +189,10 @@ function scrollToContainerBottom(container: ScrollContainer, behavior: ScrollBeh
|
|||||||
container.scrollTo({ top: container.scrollHeight, behavior });
|
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 {
|
function parseAgentDetailView(value: string | null): AgentDetailView {
|
||||||
|
if (value === "prompts") return value;
|
||||||
if (value === "configure" || value === "configuration") return "configuration";
|
if (value === "configure" || value === "configuration") return "configuration";
|
||||||
if (value === "skills") return value;
|
if (value === "skills") return value;
|
||||||
if (value === "budget") return value;
|
if (value === "budget") return value;
|
||||||
@@ -578,7 +581,9 @@ export function AgentDetail() {
|
|||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
const canonicalTab =
|
const canonicalTab =
|
||||||
activeView === "configuration"
|
activeView === "prompts"
|
||||||
|
? "prompts"
|
||||||
|
: activeView === "configuration"
|
||||||
? "configuration"
|
? "configuration"
|
||||||
: activeView === "skills"
|
: activeView === "skills"
|
||||||
? "skills"
|
? "skills"
|
||||||
@@ -699,6 +704,8 @@ export function AgentDetail() {
|
|||||||
if (urlRunId) {
|
if (urlRunId) {
|
||||||
crumbs.push({ label: "Runs", href: `/agents/${canonicalAgentRef}/runs` });
|
crumbs.push({ label: "Runs", href: `/agents/${canonicalAgentRef}/runs` });
|
||||||
crumbs.push({ label: `Run ${urlRunId.slice(0, 8)}` });
|
crumbs.push({ label: `Run ${urlRunId.slice(0, 8)}` });
|
||||||
|
} else if (activeView === "prompts") {
|
||||||
|
crumbs.push({ label: "Prompts" });
|
||||||
} else if (activeView === "configuration") {
|
} else if (activeView === "configuration") {
|
||||||
crumbs.push({ label: "Configuration" });
|
crumbs.push({ label: "Configuration" });
|
||||||
} else if (activeView === "skills") {
|
} else if (activeView === "skills") {
|
||||||
@@ -734,7 +741,7 @@ export function AgentDetail() {
|
|||||||
return <Navigate to={`/agents/${canonicalAgentRef}/dashboard`} replace />;
|
return <Navigate to={`/agents/${canonicalAgentRef}/dashboard`} replace />;
|
||||||
}
|
}
|
||||||
const isPendingApproval = agent.status === "pending_approval";
|
const isPendingApproval = agent.status === "pending_approval";
|
||||||
const showConfigActionBar = activeView === "configuration" && (configDirty || configSaving);
|
const showConfigActionBar = (activeView === "configuration" || activeView === "prompts") && (configDirty || configSaving);
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div className={cn("space-y-6", isMobile && showConfigActionBar && "pb-24")}>
|
<div className={cn("space-y-6", isMobile && showConfigActionBar && "pb-24")}>
|
||||||
@@ -861,9 +868,9 @@ export function AgentDetail() {
|
|||||||
<PageTabBar
|
<PageTabBar
|
||||||
items={[
|
items={[
|
||||||
{ value: "dashboard", label: "Dashboard" },
|
{ value: "dashboard", label: "Dashboard" },
|
||||||
{ value: "configuration", label: "Configuration" },
|
{ value: "prompts", label: "Prompts" },
|
||||||
{ value: "skills", label: "Skills" },
|
{ value: "skills", label: "Skills" },
|
||||||
{ value: "runs", label: "Runs" },
|
{ value: "configuration", label: "Configuration" },
|
||||||
{ value: "budget", label: "Budget" },
|
{ value: "budget", label: "Budget" },
|
||||||
]}
|
]}
|
||||||
value={activeView}
|
value={activeView}
|
||||||
@@ -942,6 +949,17 @@ export function AgentDetail() {
|
|||||||
/>
|
/>
|
||||||
)}
|
)}
|
||||||
|
|
||||||
|
{activeView === "prompts" && (
|
||||||
|
<PromptsTab
|
||||||
|
agent={agent}
|
||||||
|
companyId={resolvedCompanyId ?? undefined}
|
||||||
|
onDirtyChange={setConfigDirty}
|
||||||
|
onSaveActionChange={setSaveConfigAction}
|
||||||
|
onCancelActionChange={setCancelConfigAction}
|
||||||
|
onSavingChange={setConfigSaving}
|
||||||
|
/>
|
||||||
|
)}
|
||||||
|
|
||||||
{activeView === "configuration" && (
|
{activeView === "configuration" && (
|
||||||
<AgentConfigurePage
|
<AgentConfigurePage
|
||||||
agent={agent}
|
agent={agent}
|
||||||
@@ -1269,6 +1287,7 @@ function AgentConfigurePage({
|
|||||||
onSavingChange={onSavingChange}
|
onSavingChange={onSavingChange}
|
||||||
updatePermissions={updatePermissions}
|
updatePermissions={updatePermissions}
|
||||||
companyId={companyId}
|
companyId={companyId}
|
||||||
|
hidePromptTemplate
|
||||||
/>
|
/>
|
||||||
<div>
|
<div>
|
||||||
<h3 className="text-sm font-medium mb-3">API Keys</h3>
|
<h3 className="text-sm font-medium mb-3">API Keys</h3>
|
||||||
@@ -1339,6 +1358,7 @@ function ConfigurationTab({
|
|||||||
onCancelActionChange,
|
onCancelActionChange,
|
||||||
onSavingChange,
|
onSavingChange,
|
||||||
updatePermissions,
|
updatePermissions,
|
||||||
|
hidePromptTemplate,
|
||||||
}: {
|
}: {
|
||||||
agent: Agent;
|
agent: Agent;
|
||||||
companyId?: string;
|
companyId?: string;
|
||||||
@@ -1347,6 +1367,7 @@ function ConfigurationTab({
|
|||||||
onCancelActionChange: (cancel: (() => void) | null) => void;
|
onCancelActionChange: (cancel: (() => void) | null) => void;
|
||||||
onSavingChange: (saving: boolean) => void;
|
onSavingChange: (saving: boolean) => void;
|
||||||
updatePermissions: { mutate: (canCreate: boolean) => void; isPending: boolean };
|
updatePermissions: { mutate: (canCreate: boolean) => void; isPending: boolean };
|
||||||
|
hidePromptTemplate?: boolean;
|
||||||
}) {
|
}) {
|
||||||
const queryClient = useQueryClient();
|
const queryClient = useQueryClient();
|
||||||
const [awaitingRefreshAfterSave, setAwaitingRefreshAfterSave] = useState(false);
|
const [awaitingRefreshAfterSave, setAwaitingRefreshAfterSave] = useState(false);
|
||||||
@@ -1401,6 +1422,7 @@ function ConfigurationTab({
|
|||||||
onSaveActionChange={onSaveActionChange}
|
onSaveActionChange={onSaveActionChange}
|
||||||
onCancelActionChange={onCancelActionChange}
|
onCancelActionChange={onCancelActionChange}
|
||||||
hideInlineSave
|
hideInlineSave
|
||||||
|
hidePromptTemplate={hidePromptTemplate}
|
||||||
sectionLayout="cards"
|
sectionLayout="cards"
|
||||||
/>
|
/>
|
||||||
|
|
||||||
@@ -1427,6 +1449,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 SkillsTab({ agent }: { agent: Agent }) {
|
function SkillsTab({ agent }: { agent: Agent }) {
|
||||||
const instructionsPath =
|
const instructionsPath =
|
||||||
typeof agent.adapterConfig?.instructionsFilePath === "string" && agent.adapterConfig.instructionsFilePath.trim().length > 0
|
typeof agent.adapterConfig?.instructionsFilePath === "string" && agent.adapterConfig.instructionsFilePath.trim().length > 0
|
||||||
|
|||||||
Reference in New Issue
Block a user