diff --git a/web/oss/src/components/AgentChatSlice/AgentChatPanel.tsx b/web/oss/src/components/AgentChatSlice/AgentChatPanel.tsx index fb3a82cd8d..33ab045590 100644 --- a/web/oss/src/components/AgentChatSlice/AgentChatPanel.tsx +++ b/web/oss/src/components/AgentChatSlice/AgentChatPanel.tsx @@ -5,7 +5,7 @@ import {useChat} from "@ai-sdk/react" import {Attachments, Bubble, Sender} from "@ant-design/x" import {ArrowDown, Paperclip} from "@phosphor-icons/react" import {DefaultChatTransport, type UIMessage} from "ai" -import {Alert, Button, Modal, Tabs, Tag, Tooltip} from "antd" +import {Button, Modal, Tabs, Tag, Tooltip} from "antd" import type {UploadFile} from "antd" import {useAtomValue, useSetAtom, useStore} from "jotai" @@ -14,17 +14,18 @@ import {messageText, sideEffectingToolsInRange} from "./assets/rewind" import AgentMessage from "./components/AgentMessage" import SessionHistoryMenu from "./components/SessionHistoryMenu" import SessionTabLabel from "./components/SessionTabLabel" +import {useChatScopeKey} from "./state/scope" import { type AgentChatSession, - activeSessionIdAtom, - addSessionAtom, - closeSessionAtom, + activeSessionIdAtomFamily, + addSessionAtomFamily, + closeSessionAtomFamily, persistSessionMessagesAtom, - renameSessionAtom, + renameSessionAtomFamily, sessionFirstUserTextAtomFamily, sessionMessagesAtom, - sessionsListAtom, - setActiveSessionAtom, + sessionsListAtomFamily, + setActiveSessionAtomFamily, } from "./state/sessions" /** A stream error/abort is already surfaced via `useChat`'s `onError` + the in-chat `error` @@ -45,6 +46,57 @@ const ignoreStreamRejection = () => {} * - DT4 autoscroll: stick to bottom while streaming; pause when scrolled up; "jump to latest". * - DT5 a11y: the message log is an aria-live region; controls are keyboard-operable. */ + +/** A settled assistant turn with no content at all — no answer, reasoning, tool, file, or + * source part. Mirrors AgentMessage's `!hasContent`; used to collapse a run of "no response" + * bubbles (e.g. repeated failed runs) down to the first one. */ +const isEmptyAssistantTurn = (m: UIMessage): boolean => + m.role === "assistant" && + !m.parts.some( + (p) => + (p.type === "text" && Boolean((p as {text?: string}).text?.trim())) || + (p.type === "reasoning" && Boolean((p as {text?: string}).text?.trim())) || + p.type === "file" || + p.type === "source-url" || + p.type.startsWith("tool-") || + p.type === "dynamic-tool", + ) + +interface ParsedRunError { + message: string + code?: number +} + +/** + * Best-effort human reason from a useChat stream error. The server may hand us a clean string + * ("Agent run failed: …") or a JSON envelope (`{status:{code,message,…}}` / `{message}`) — pull + * the message out of either and drop the stacktrace / docs-url noise so it reads cleanly inline. + */ +const parseAgentRunError = (err: unknown): ParsedRunError => { + const raw = + err instanceof Error ? err.message : typeof err === "string" ? err : String(err ?? "") + const fallback = raw.trim() || "The agent run failed." + try { + const obj = JSON.parse(raw) as Record + const status = (obj?.status && typeof obj.status === "object" ? obj.status : obj) as Record< + string, + unknown + > + const message = + typeof status?.message === "string" + ? status.message + : typeof obj?.message === "string" + ? (obj.message as string) + : null + if (message) { + return {message, code: typeof status?.code === "number" ? status.code : undefined} + } + } catch { + // raw isn't JSON — it's already the human message. + } + return {message: fallback} +} + const AgentConversation = ({entityId, sessionId}: {entityId: string; sessionId: string}) => { const store = useStore() const persistMessages = useSetAtom(persistSessionMessagesAtom) @@ -109,6 +161,39 @@ const AgentConversation = ({entityId, sessionId}: {entityId: string; sessionId: const busy = status === "submitted" || status === "streaming" + // Surface a stream failure inline: stamp the parsed error onto the failing assistant turn so + // it renders as a red error bubble with the real reason (and persists with the session via the + // effect below), instead of a transient top banner + a generic "no response". FE-only — it + // uses the error useChat already has; the backend doesn't need to attach it to the trace. + useEffect(() => { + if (!error) return + const parsed = parseAgentRunError(error) + setMessages((prev) => { + const last = prev.length > 0 ? prev[prev.length - 1] : undefined + const existing = (last?.metadata as {runError?: {message?: string}} | undefined) + ?.runError + if (last?.role === "assistant") { + if (existing?.message === parsed.message) return prev // already stamped + const next = [...prev] + next[next.length - 1] = { + ...last, + metadata: {...(last.metadata as object | undefined), runError: parsed}, + } + return next + } + // No trailing assistant turn (failed before one existed) — add a minimal carrier. + return [ + ...prev, + { + id: `run-error-${crypto.randomUUID()}`, + role: "assistant", + parts: [], + metadata: {runError: parsed}, + } as (typeof prev)[number], + ] + }) + }, [error, setMessages]) + // Persist the conversation whenever its stream settles (skip mid-stream). useEffect(() => { if (status === "streaming") return @@ -219,10 +304,8 @@ const AgentConversation = ({entityId, sessionId}: {entityId: string; sessionId: return (
- {error && ( - - )} - + {/* Stream errors are surfaced inline on the failing turn (red error bubble with the + real reason), stamped in the effect above — no separate top-level banner. */}
{ @@ -247,6 +330,9 @@ const AgentConversation = ({entityId, sessionId}: {entityId: string; sessionId: isStreaming={busy && index === messages.length - 1} onRewind={() => handleRewind(message)} onApprovalResponse={addToolApprovalResponse} + precededByEmptyAssistant={ + index > 0 && isEmptyAssistantTurn(messages[index - 1]) + } /> {stoppedIds.has(message.id) && (
@@ -387,12 +473,13 @@ const TabLabel = ({ } const AgentChatPanel = ({entityId}: {entityId: string}) => { - const sessions = useAtomValue(sessionsListAtom) - const rawActiveId = useAtomValue(activeSessionIdAtom) - const addSession = useSetAtom(addSessionAtom) - const closeSession = useSetAtom(closeSessionAtom) - const renameSession = useSetAtom(renameSessionAtom) - const setActiveSession = useSetAtom(setActiveSessionAtom) + const scope = useChatScopeKey() + const sessions = useAtomValue(sessionsListAtomFamily(scope)) + const rawActiveId = useAtomValue(activeSessionIdAtomFamily(scope)) + const addSession = useSetAtom(addSessionAtomFamily(scope)) + const closeSession = useSetAtom(closeSessionAtomFamily(scope)) + const renameSession = useSetAtom(renameSessionAtomFamily(scope)) + const setActiveSession = useSetAtom(setActiveSessionAtomFamily(scope)) // Always keep at least one tab. Re-arms when the list drains without double-firing // under StrictMode. diff --git a/web/oss/src/components/AgentChatSlice/assets/trace.ts b/web/oss/src/components/AgentChatSlice/assets/trace.ts index abda9430f2..a8811f4573 100644 --- a/web/oss/src/components/AgentChatSlice/assets/trace.ts +++ b/web/oss/src/components/AgentChatSlice/assets/trace.ts @@ -33,6 +33,18 @@ export const getMessageTraceId = (message: UIMessage): string | undefined => { return tracePart.data.traceId || parseTraceIdFromUrl(tracePart.data.url) } +/** + * A run failure stamped onto an assistant turn's metadata (FE-side, when the stream errors — + * see AgentChatPanel). The backend doesn't always record the error on the trace, but useChat + * surfaces it; persisting it here lets the failed turn render the real reason inline (a red + * error bubble) instead of a generic "no response", and survives a reload with the session. + */ +export const getMessageRunError = (message: UIMessage): string | undefined => { + const runError = (message.metadata as {runError?: {message?: string}} | undefined)?.runError + const msg = runError?.message + return typeof msg === "string" && msg.trim() ? msg : undefined +} + /** Token/cost fields in `ExecutionMetricsDisplay`'s shape. */ export interface MessageUsageMetrics { promptTokens?: number diff --git a/web/oss/src/components/AgentChatSlice/assets/transport.ts b/web/oss/src/components/AgentChatSlice/assets/transport.ts index 238f9f929f..eaae2ecc9e 100644 --- a/web/oss/src/components/AgentChatSlice/assets/transport.ts +++ b/web/oss/src/components/AgentChatSlice/assets/transport.ts @@ -99,7 +99,13 @@ async function requestMeta(track: AgentChatTrack, appId?: string | null) { // `Accept: text/event-stream` makes the agent `/messages` endpoint serve the v6 SSE // stream useChat consumes; without it the endpoint negotiates down to batch JSON // (the AI-SDK transport sets no Accept), which useChat can't render. - const headers: Record = {Accept: "text/event-stream"} + // `x-ag-messages-format` declares the request body's message format (AI-SDK / Vercel + // UIMessages) so `/messages` picks the right adapter; "vercel" matches the backend's + // VERCEL_MESSAGE_PROTOCOL identity (sdk/agents/adapters/vercel/routing.py). + const headers: Record = { + Accept: "text/event-stream", + "x-ag-messages-format": "vercel", + } if (jwt) headers.Authorization = `Bearer ${jwt}` const projectId = getDefaultStore().get(projectIdAtom) || undefined const api = withQuery(trackApi(track), { diff --git a/web/oss/src/components/AgentChatSlice/components/AgentMessage.tsx b/web/oss/src/components/AgentChatSlice/components/AgentMessage.tsx index 3d4950e72a..99c511ac62 100644 --- a/web/oss/src/components/AgentChatSlice/components/AgentMessage.tsx +++ b/web/oss/src/components/AgentChatSlice/components/AgentMessage.tsx @@ -21,7 +21,12 @@ import {openTraceDrawerAtom} from "@/oss/components/SharedDrawers/TraceDrawer/st import {fileKind, filePartName} from "../assets/files" import Markdown from "../assets/markdown" -import {getMessageTraceId, getMessageUsage, type MessageUsageMetrics} from "../assets/trace" +import { + getMessageRunError, + getMessageTraceId, + getMessageUsage, + type MessageUsageMetrics, +} from "../assets/trace" import ToolPart from "./ToolPart" @@ -45,6 +50,9 @@ interface AgentMessageProps { isStreaming?: boolean onRewind: () => void onApprovalResponse: (args: {id: string; approved: boolean}) => void + /** The previous turn was also an empty (content-less) assistant turn. Used to collapse a + * run of "no response" bubbles down to the first one. */ + precededByEmptyAssistant?: boolean } const isToolPart = (type: string) => type.startsWith("tool-") || type === "dynamic-tool" @@ -99,6 +107,48 @@ const ReasoningPart = ({text, streaming}: {text: string; streaming: boolean}) => ) } +/** + * Failed-run body: the icon + "The agent run failed" + the reason. The reason is clamped to a few + * lines by default so a long message (or a stacktrace that slipped past parsing) can't drown the + * chat; when it's long, a "Show more" toggle expands it into a scrollable, whitespace-preserving + * block so it stays readable. + */ +const RunErrorBody = ({text}: {text: string}) => { + const [expanded, setExpanded] = useState(false) + const isLong = text.length > 220 || text.includes("\n") + + return ( +
+ +
+ The agent run failed + {expanded ? ( +
+                        {text}
+                    
+ ) : ( + + {text} + + )} + {isLong && ( + + )} +
+
+ ) +} + const avatarFor = (isUser: boolean) => ( : } /> ) @@ -114,6 +164,7 @@ const AgentMessage = ({ isStreaming = false, onRewind, onApprovalResponse, + precededByEmptyAssistant = false, }: AgentMessageProps) => { const openTraceDrawer = useSetAtom(openTraceDrawerAtom) const isUser = message.role === "user" @@ -123,6 +174,14 @@ const AgentMessage = ({ // A failed run (e.g. a quota error the runner swallowed into an empty turn) lands as an // error on the trace; read it so the bubble can render as a failure. const traceError = useAtomValue(traceDataSummaryAtomFamily(traceId ?? null)).error + // A failure can reach us two ways: recorded on the trace (backend), or stamped onto the turn + // FE-side from the useChat stream error (AgentChatPanel). Prefer whichever is present. + const runError = getMessageRunError(message) + const errorText = traceError || runError + // Surface a settled-turn error even when the model emitted partial output before the + // stream died — not only when the turn is answer-less. (`isError` stays answer-less-only + // so the *whole* bubble only turns red when there's nothing else to show.) + const showError = !isStreaming && !!errorText const fullText = message.parts .filter((p) => p.type === "text") .map((p) => (p as {text: string}).text) @@ -154,7 +213,12 @@ const AgentMessage = ({ const noResponse = !isUser && !isStreaming && !hasAnswer // A settled no-answer turn whose trace recorded an error → render the bubble itself as a // failure (red), with the message inline — not a nested alert box. - const isError = noResponse && !!traceError + const isError = noResponse && showError + + // #3: collapse a run of empty "no response" turns to just the first. A turn with ANY content + // (answer or reasoning) and any error turn (isError, which shows the real failure) always + // render; only a truly-empty, non-error turn that follows another empty turn is hidden. + if (noResponse && !showError && !hasContent && precededByEmptyAssistant) return null // Only the message being generated shows the loading state, and only until it has content. if (!isUser && isStreaming && !hasContent) { @@ -261,17 +325,22 @@ const AgentMessage = ({ ) // Failed run: the whole bubble reads as the error (red), message inline — no nested box. - const errorBody = ( -
- -
- The agent run failed - {traceError} -
-
- ) + // RunErrorBody truncates a long reason so it can't drown the chat (expand to read it all). + const errorBody = - const body = isError ? errorBody : defaultBody + // Partial output then failure: show the content AND the error. Answer-less failure: the + // whole bubble is the error. Otherwise: just the content. + const body = + showError && !isError ? ( +
+ {defaultBody} + {errorBody} +
+ ) : isError ? ( + errorBody + ) : ( + defaultBody + ) // Control toolbar — an X `Actions` row that FLOATS over the bubble's bottom edge. It is // absolutely positioned (out of flow), so it adds no height: bubbles sit tight with no diff --git a/web/oss/src/components/AgentChatSlice/components/SessionHistoryMenu.tsx b/web/oss/src/components/AgentChatSlice/components/SessionHistoryMenu.tsx index d241a2d94f..6cd4f00032 100644 --- a/web/oss/src/components/AgentChatSlice/components/SessionHistoryMenu.tsx +++ b/web/oss/src/components/AgentChatSlice/components/SessionHistoryMenu.tsx @@ -4,12 +4,13 @@ import {ClockCounterClockwise, Trash} from "@phosphor-icons/react" import {Button, Empty, Popover, Tag, Tooltip, Typography} from "antd" import {useAtomValue, useSetAtom} from "jotai" +import {useChatScopeKey} from "../state/scope" import { - deleteSessionAtom, + deleteSessionAtomFamily, firstUserText, - openSessionAtom, - openSessionIdsAtom, - sessionHistoryAtom, + openSessionAtomFamily, + openSessionIdsAtomFamily, + sessionHistoryAtomFamily, sessionMessagesAtom, } from "../state/sessions" @@ -34,11 +35,12 @@ const timeAgo = (ts?: number): string => { * permanently (tab + history + messages). */ const SessionHistoryList = ({onPicked}: {onPicked: () => void}) => { - const history = useAtomValue(sessionHistoryAtom) - const openIds = useAtomValue(openSessionIdsAtom) + const scope = useChatScopeKey() + const history = useAtomValue(sessionHistoryAtomFamily(scope)) + const openIds = useAtomValue(openSessionIdsAtomFamily(scope)) const allMessages = useAtomValue(sessionMessagesAtom) - const openSession = useSetAtom(openSessionAtom) - const deleteSession = useSetAtom(deleteSessionAtom) + const openSession = useSetAtom(openSessionAtomFamily(scope)) + const deleteSession = useSetAtom(deleteSessionAtomFamily(scope)) if (history.length === 0) { return ( diff --git a/web/oss/src/components/AgentChatSlice/index.tsx b/web/oss/src/components/AgentChatSlice/index.tsx index dbd23e806a..9df1275149 100644 --- a/web/oss/src/components/AgentChatSlice/index.tsx +++ b/web/oss/src/components/AgentChatSlice/index.tsx @@ -11,16 +11,17 @@ import {loadSessionMessages} from "./assets/loadSession" import AgentChatConversation from "./components/AgentChatConversation" import SessionHistoryMenu from "./components/SessionHistoryMenu" import SessionTabLabel from "./components/SessionTabLabel" +import {useChatScopeKey} from "./state/scope" import { - activeSessionIdAtom, - addSessionAtom, - adoptSessionAtom, - closeSessionAtom, - renameSessionAtom, + activeSessionIdAtomFamily, + addSessionAtomFamily, + adoptSessionAtomFamily, + closeSessionAtomFamily, + renameSessionAtomFamily, sessionLabel, sessionMessagesAtom, - sessionsListAtom, - setActiveSessionAtom, + sessionsListAtomFamily, + setActiveSessionAtomFamily, } from "./state/sessions" const {Text, Title} = Typography @@ -49,14 +50,15 @@ const AgentChatSlice = () => { const store = useStore() const router = useRouter() - const sessions = useAtomValue(sessionsListAtom) - const rawActiveId = useAtomValue(activeSessionIdAtom) + const scope = useChatScopeKey() + const sessions = useAtomValue(sessionsListAtomFamily(scope)) + const rawActiveId = useAtomValue(activeSessionIdAtomFamily(scope)) const allMessages = useAtomValue(sessionMessagesAtom) - const addSession = useSetAtom(addSessionAtom) - const adoptSession = useSetAtom(adoptSessionAtom) - const closeSession = useSetAtom(closeSessionAtom) - const renameSession = useSetAtom(renameSessionAtom) - const setActiveSession = useSetAtom(setActiveSessionAtom) + const addSession = useSetAtom(addSessionAtomFamily(scope)) + const adoptSession = useSetAtom(adoptSessionAtomFamily(scope)) + const closeSession = useSetAtom(closeSessionAtomFamily(scope)) + const renameSession = useSetAtom(renameSessionAtomFamily(scope)) + const setActiveSession = useSetAtom(setActiveSessionAtomFamily(scope)) // Open-from-observability: a `?session=` deep link (from a trace / session drawer) // opens that session as a tab. Hydrate its messages first — from localStorage if this diff --git a/web/oss/src/components/AgentChatSlice/state/scope.tsx b/web/oss/src/components/AgentChatSlice/state/scope.tsx new file mode 100644 index 0000000000..4b6ce3dccc --- /dev/null +++ b/web/oss/src/components/AgentChatSlice/state/scope.tsx @@ -0,0 +1,40 @@ +import {createContext, useContext, type ReactNode} from "react" + +import {useAtomValue} from "jotai" + +import {defaultScopeKeyAtom} from "./sessions" + +/** + * Scope key for the agent chat session state. Distinct surfaces that mount `AgentChatPanel` + * concurrently — the main playground vs the create/edit drawer that overlays it — must use + * different scope keys, or they share tabs/history (a drawer would inherit and overwrite the + * playground's conversations). The provider lets a surface override the default app scope; + * every consumer reads the effective key via `useChatScopeKey()`. + */ +const AgentChatScopeContext = createContext(null) + +export function AgentChatScopeProvider({ + scopeKey, + children, +}: { + scopeKey: string + children: ReactNode +}) { + return ( + {children} + ) +} + +/** The effective chat scope key: a surface override when provided, else the app scope. */ +export function useChatScopeKey(): string { + const override = useContext(AgentChatScopeContext) + const fallback = useAtomValue(defaultScopeKeyAtom) + return override ?? fallback +} + +/** + * Drawer scope key for an entity. Prefixed so it never collides with an app scope (app keys are + * bare UUIDs or `__global__`); a pre-creation drawer with no entity id yet falls back to `new`. + */ +export const drawerScopeKey = (entityId: string | null | undefined): string => + `drawer:${entityId || "new"}` diff --git a/web/oss/src/components/AgentChatSlice/state/sessions.ts b/web/oss/src/components/AgentChatSlice/state/sessions.ts index 1a2b9ba369..37dbe0c5d9 100644 --- a/web/oss/src/components/AgentChatSlice/state/sessions.ts +++ b/web/oss/src/components/AgentChatSlice/state/sessions.ts @@ -10,13 +10,19 @@ import {routerAppIdAtom} from "@/oss/state/app/atoms/fetcher" * conversations as top-level dynamic tabs (no side rail); this holds the session history, which * tabs are open, the active tab, and each session's persisted messages. * - * Two distinct concerns, both app-scoped (the playground is app-scoped, like - * `selectedVariantsByAppAtom`): - * - HISTORY (`sessionsByAppAtom`): every session ever created for the app. A closed tab stays + * Everything is keyed by a **scope key** — a string that isolates one mount surface's sessions + * from another's. The main playground uses the app scope (`routerAppId`, or `__global__` off an + * app page); the create/edit drawer uses its own `drawer:` scope so it never inherits + * or overwrites the playground's tabs/history (the drawer mounts OVER the playground, so both + * surfaces are live at once and a single global "current scope" would clobber). Consumers read + * their scope from `useChatScopeKey()` (see ./scope) and pass it to the families below. + * + * Two distinct concerns, both scope-keyed: + * - HISTORY (`sessionsByAppAtom`): every session ever created for the scope. A closed tab stays * here so it can be reopened from the history picker; only an explicit delete removes it. * - OPEN TABS (`openIdsByAppAtom`): which history sessions are currently shown as tabs, in tab * order. Closing a tab drops its id here but keeps the session (and its messages). - * Messages are keyed by the globally-unique session id, so they need no app dimension. + * Messages are keyed by the globally-unique session id, so they need no scope dimension. * * Persistence: everything is `atomWithStorage`, so history, tabs, and conversations survive a * reload. NOTE: attachments are stored inline as `data:` URLs (see `assets/files.ts`); a @@ -31,19 +37,24 @@ export interface AgentChatSession { createdAt?: number } -const GLOBAL_APP_KEY = "__global__" +export const GLOBAL_APP_KEY = "__global__" -const appKeyAtom = atom((get) => get(routerAppIdAtom) || GLOBAL_APP_KEY) +/** + * Default scope key when a surface provides no override: the current app (or `__global__` off an + * app page). Kept as the bare app id (no prefix) so sessions persisted before scoping was + * introduced still resolve under the same storage key. + */ +export const defaultScopeKeyAtom = atom((get) => get(routerAppIdAtom) || GLOBAL_APP_KEY) -// One source of truth per concern, keyed by app id. Scoped accessors below derive the -// current app's slice (mirrors the playground's `selectedVariantsByAppAtom` pattern). +// One source of truth per concern, keyed by scope key. Scoped accessors below derive a single +// scope's slice (mirrors the playground's `selectedVariantsByAppAtom` pattern). // // `getOnInit: true` — read localStorage synchronously on init. Without it the atom starts as // the empty default `{}` on every mount and only hydrates afterwards, so the "seed one tab" // effect sees an empty list in that window and creates a stray session on every reload/HMR. const STORAGE_OPTS = {getOnInit: true} as const -/** Full per-app session history (open AND closed). */ +/** Full per-scope session history (open AND closed). */ const sessionsByAppAtom = atomWithStorage>( "agenta:agent-chat:sessions", {}, @@ -52,9 +63,9 @@ const sessionsByAppAtom = atomWithStorage>( ) /** - * Which sessions are open as tabs, per app, in tab order. + * Which sessions are open as tabs, per scope, in tab order. * - * Migration: before this atom is ever written for an app, the open set defaults to the whole + * Migration: before this atom is ever written for a scope, the open set defaults to the whole * history — every pre-upgrade session was an open tab (see `currentOpenIds`). Once any tab op * writes an explicit list, that list is authoritative. */ @@ -72,7 +83,8 @@ const activeByAppAtom = atomWithStorage>( STORAGE_OPTS, ) -/** Persisted messages per session id. Written when a conversation's stream settles. */ +/** Persisted messages per session id. Written when a conversation's stream settles. Session ids + * are globally unique, so this store has no scope dimension. */ export const sessionMessagesAtom = atomWithStorage>( "agenta:agent-chat:messages", {}, @@ -80,7 +92,7 @@ export const sessionMessagesAtom = atomWithStorage>( STORAGE_OPTS, ) -/** Open tab ids for an app, with the pre-upgrade fallback (everything open). Pure read helper +/** Open tab ids for a scope, with the pre-upgrade fallback (everything open). Pure read helper * for the writers below — never mutates. */ const currentOpenIds = (get: Getter, key: string): string[] => { const explicit = get(openIdsByAppAtom)[key] @@ -88,133 +100,143 @@ const currentOpenIds = (get: Getter, key: string): string[] => { return (get(sessionsByAppAtom)[key] ?? []).map((s) => s.id) } -/** All sessions for the current app (history), newest first. Backs the history picker. */ -export const sessionHistoryAtom = atom((get) => { - const list = get(sessionsByAppAtom)[get(appKeyAtom)] ?? [] - // Newest first; pre-upgrade sessions (no createdAt) sort last, preserving their order. - return [...list].sort((a, b) => (b.createdAt ?? 0) - (a.createdAt ?? 0)) -}) - -/** Open tab ids for the current app, in tab order (with the migration fallback). */ -const openIdsAtom = atom((get) => currentOpenIds(get, get(appKeyAtom))) +/** All sessions for a scope (history), newest first. Backs the history picker. */ +export const sessionHistoryAtomFamily = atomFamily((key: string) => + atom((get) => { + const list = get(sessionsByAppAtom)[key] ?? [] + // Newest first; pre-upgrade sessions (no createdAt) sort last, preserving their order. + return [...list].sort((a, b) => (b.createdAt ?? 0) - (a.createdAt ?? 0)) + }), +) -/** Sessions shown as tabs, in tab order. */ -export const sessionsListAtom = atom((get) => { - const byId = new Map( - (get(sessionsByAppAtom)[get(appKeyAtom)] ?? []).map((s) => [s.id, s] as const), - ) - return get(openIdsAtom) - .map((id) => byId.get(id)) - .filter((s): s is AgentChatSession => Boolean(s)) -}) +/** Sessions shown as tabs for a scope, in tab order. */ +export const sessionsListAtomFamily = atomFamily((key: string) => + atom((get) => { + const byId = new Map((get(sessionsByAppAtom)[key] ?? []).map((s) => [s.id, s] as const)) + return currentOpenIds(get, key) + .map((id) => byId.get(id)) + .filter((s): s is AgentChatSession => Boolean(s)) + }), +) -/** Active session id for the current app (may be stale if that tab was closed — the UI - * falls back to the first open tab when this id isn't in the open list). */ -export const activeSessionIdAtom = atom((get) => get(activeByAppAtom)[get(appKeyAtom)] ?? "") +/** Active session id for a scope (may be stale if that tab was closed — the UI falls back to the + * first open tab when this id isn't in the open list). */ +export const activeSessionIdAtomFamily = atomFamily((key: string) => + atom((get) => get(activeByAppAtom)[key] ?? ""), +) -/** Set of currently-open session ids (used to label the history picker). */ -export const openSessionIdsAtom = atom((get) => new Set(get(openIdsAtom))) +/** Set of currently-open session ids for a scope (used to label the history picker). */ +export const openSessionIdsAtomFamily = atomFamily((key: string) => + atom((get) => new Set(currentOpenIds(get, key))), +) /** Create a session and make it the active open tab. Returns the new id. */ -export const addSessionAtom = atom(null, (get, set) => { - const key = get(appKeyAtom) - const id = generateId() - // Read open ids BEFORE mutating history, else the fallback would re-count the new id. - const open = currentOpenIds(get, key) - const all = get(sessionsByAppAtom) - set(sessionsByAppAtom, {...all, [key]: [...(all[key] ?? []), {id, createdAt: Date.now()}]}) - set(openIdsByAppAtom, {...get(openIdsByAppAtom), [key]: [...open, id]}) - set(activeByAppAtom, {...get(activeByAppAtom), [key]: id}) - return id -}) +export const addSessionAtomFamily = atomFamily((key: string) => + atom(null, (get, set) => { + const id = generateId() + // Read open ids BEFORE mutating history, else the fallback would re-count the new id. + const open = currentOpenIds(get, key) + const all = get(sessionsByAppAtom) + set(sessionsByAppAtom, { + ...all, + [key]: [...(all[key] ?? []), {id, createdAt: Date.now()}], + }) + set(openIdsByAppAtom, {...get(openIdsByAppAtom), [key]: [...open, id]}) + set(activeByAppAtom, {...get(activeByAppAtom), [key]: id}) + return id + }), +) /** Close a tab: drop it from the open list (KEEP the session + messages so it can be reopened * from the history picker) and re-point the active tab to a neighbour if it was the one closed. */ -export const closeSessionAtom = atom(null, (get, set, id: string) => { - const key = get(appKeyAtom) - const open = currentOpenIds(get, key) - const nextOpen = open.filter((x) => x !== id) - set(openIdsByAppAtom, {...get(openIdsByAppAtom), [key]: nextOpen}) +export const closeSessionAtomFamily = atomFamily((key: string) => + atom(null, (get, set, id: string) => { + const open = currentOpenIds(get, key) + const nextOpen = open.filter((x) => x !== id) + set(openIdsByAppAtom, {...get(openIdsByAppAtom), [key]: nextOpen}) - const active = get(activeByAppAtom) - if (active[key] === id) { - const closedIdx = open.indexOf(id) - const neighbour = nextOpen[Math.min(closedIdx, nextOpen.length - 1)] ?? "" - set(activeByAppAtom, {...active, [key]: neighbour}) - } -}) + const active = get(activeByAppAtom) + if (active[key] === id) { + const closedIdx = open.indexOf(id) + const neighbour = nextOpen[Math.min(closedIdx, nextOpen.length - 1)] ?? "" + set(activeByAppAtom, {...active, [key]: neighbour}) + } + }), +) /** Reopen a session as a tab (or just focus it if already open) and make it active. */ -export const openSessionAtom = atom(null, (get, set, id: string) => { - const key = get(appKeyAtom) - const open = currentOpenIds(get, key) - if (!open.includes(id)) { - set(openIdsByAppAtom, {...get(openIdsByAppAtom), [key]: [...open, id]}) - } - set(activeByAppAtom, {...get(activeByAppAtom), [key]: id}) -}) +export const openSessionAtomFamily = atomFamily((key: string) => + atom(null, (get, set, id: string) => { + const open = currentOpenIds(get, key) + if (!open.includes(id)) { + set(openIdsByAppAtom, {...get(openIdsByAppAtom), [key]: [...open, id]}) + } + set(activeByAppAtom, {...get(activeByAppAtom), [key]: id}) + }), +) /** * Ensure a session with `id` exists in history, is open, and is active — used when opening a * session from a deep link / observability trace. Creates the history entry if it's unknown to * this browser (its messages come from `sessionMessagesAtom`, hydrated locally or server-side). */ -export const adoptSessionAtom = atom( - null, - (get, set, {id, title}: {id: string; title?: string}) => { - const key = get(appKeyAtom) +export const adoptSessionAtomFamily = atomFamily((key: string) => + atom(null, (get, set, {id, title}: {id: string; title?: string}) => { const all = get(sessionsByAppAtom) const list = all[key] ?? [] if (!list.some((s) => s.id === id)) { - set(sessionsByAppAtom, {...all, [key]: [...list, {id, title, createdAt: Date.now()}]}) + set(sessionsByAppAtom, { + ...all, + [key]: [...list, {id, title, createdAt: Date.now()}], + }) } const open = currentOpenIds(get, key) if (!open.includes(id)) { set(openIdsByAppAtom, {...get(openIdsByAppAtom), [key]: [...open, id]}) } set(activeByAppAtom, {...get(activeByAppAtom), [key]: id}) - }, + }), ) /** Permanently delete a session: drop it from history, the open tabs, and its messages. */ -export const deleteSessionAtom = atom(null, (get, set, id: string) => { - const key = get(appKeyAtom) - const all = get(sessionsByAppAtom) - set(sessionsByAppAtom, {...all, [key]: (all[key] ?? []).filter((s) => s.id !== id)}) +export const deleteSessionAtomFamily = atomFamily((key: string) => + atom(null, (get, set, id: string) => { + const all = get(sessionsByAppAtom) + set(sessionsByAppAtom, {...all, [key]: (all[key] ?? []).filter((s) => s.id !== id)}) - const open = currentOpenIds(get, key) - if (open.includes(id)) { - set(openIdsByAppAtom, {...get(openIdsByAppAtom), [key]: open.filter((x) => x !== id)}) - } + const open = currentOpenIds(get, key) + if (open.includes(id)) { + set(openIdsByAppAtom, {...get(openIdsByAppAtom), [key]: open.filter((x) => x !== id)}) + } - const active = get(activeByAppAtom) - if (active[key] === id) { - set(activeByAppAtom, {...active, [key]: open.filter((x) => x !== id)[0] ?? ""}) - } + const active = get(activeByAppAtom) + if (active[key] === id) { + set(activeByAppAtom, {...active, [key]: open.filter((x) => x !== id)[0] ?? ""}) + } - const messages = {...get(sessionMessagesAtom)} - if (id in messages) { - delete messages[id] - set(sessionMessagesAtom, messages) - } -}) + const messages = {...get(sessionMessagesAtom)} + if (id in messages) { + delete messages[id] + set(sessionMessagesAtom, messages) + } + }), +) -export const renameSessionAtom = atom( - null, - (get, set, {id, title}: {id: string; title: string}) => { - const key = get(appKeyAtom) +export const renameSessionAtomFamily = atomFamily((key: string) => + atom(null, (get, set, {id, title}: {id: string; title: string}) => { const all = get(sessionsByAppAtom) const list = (all[key] ?? []).map((s) => s.id === id ? {...s, title: title.trim() || undefined} : s, ) set(sessionsByAppAtom, {...all, [key]: list}) - }, + }), ) -export const setActiveSessionAtom = atom(null, (get, set, id: string) => { - const key = get(appKeyAtom) - set(activeByAppAtom, {...get(activeByAppAtom), [key]: id}) -}) +export const setActiveSessionAtomFamily = atomFamily((key: string) => + atom(null, (get, set, id: string) => { + set(activeByAppAtom, {...get(activeByAppAtom), [key]: id}) + }), +) /** Write a session's messages to the persisted store (called when its stream settles). */ export const persistSessionMessagesAtom = atom( diff --git a/web/oss/src/components/Playground/Components/Menus/PlaygroundVariantHeaderMenu/index.tsx b/web/oss/src/components/Playground/Components/Menus/PlaygroundVariantHeaderMenu/index.tsx index 5189f9640f..07873091ed 100644 --- a/web/oss/src/components/Playground/Components/Menus/PlaygroundVariantHeaderMenu/index.tsx +++ b/web/oss/src/components/Playground/Components/Menus/PlaygroundVariantHeaderMenu/index.tsx @@ -1,10 +1,11 @@ import {useCallback, useMemo} from "react" import {workflowMolecule} from "@agenta/entities/workflow" -import {playgroundController} from "@agenta/playground" +import {agentConfigLayoutAtom, AGENT_CONFIG_LAYOUTS} from "@agenta/entity-ui/drill-in" +import {playgroundController, isAgentModeAtomFamily} from "@agenta/playground" import {message} from "@agenta/ui/app-message" import {MoreOutlined} from "@ant-design/icons" -import {ArrowCounterClockwise, Trash} from "@phosphor-icons/react" +import {ArrowCounterClockwise, Check, Trash} from "@phosphor-icons/react" import {Button, Dropdown, MenuProps} from "antd" import {useAtomValue, useSetAtom} from "jotai" @@ -19,6 +20,12 @@ const PlaygroundVariantHeaderMenu: React.FC = const selectedVariants = useAtomValue(playgroundController.selectors.entityIds()) const removeVariantFromSelection = useSetAtom(playgroundController.actions.removeEntity) const isDirty = useAtomValue(workflowMolecule.selectors.isDirty(variantId || "")) + // Agent config panels get a layout selector (accordion/tabs/cards) in this menu; the panel + // reads the same persisted atom. Detect agent mode by the agent_config schema marker (the same + // robust signal the left panel uses), not the backend is_agent flag. Non-agent variants hide it. + const isAgent = useAtomValue(isAgentModeAtomFamily(variantId || "")) + const layout = useAtomValue(agentConfigLayoutAtom) + const setLayout = useSetAtom(agentConfigLayoutAtom) const closePanelDisabled = useMemo(() => { return selectedVariants.length === 1 && selectedVariants.includes(variantId) @@ -42,6 +49,30 @@ const PlaygroundVariantHeaderMenu: React.FC = const items: MenuProps["items"] = useMemo( () => [ + ...(isAgent + ? [ + { + key: "view", + type: "group" as const, + label: "View", + children: AGENT_CONFIG_LAYOUTS.map((option) => ({ + key: `view-${option.value}`, + label: option.label, + icon: + layout === option.value ? ( + + ) : ( + + ), + onClick: (e: {domEvent: {stopPropagation: () => void}}) => { + e.domEvent.stopPropagation() + setLayout(option.value) + }, + })), + }, + {type: "divider" as const}, + ] + : []), { key: "revert", label: "Revert Changes", @@ -70,7 +101,16 @@ const PlaygroundVariantHeaderMenu: React.FC = }, }, ], - [handleClosePanel, closePanelDisabled, variantId, handleDiscardDraft, isDirty], + [ + handleClosePanel, + closePanelDisabled, + variantId, + handleDiscardDraft, + isDirty, + isAgent, + layout, + setLayout, + ], ) return ( diff --git a/web/oss/src/components/Playground/Playground.tsx b/web/oss/src/components/Playground/Playground.tsx index 5a7bb9f29b..4068407090 100644 --- a/web/oss/src/components/Playground/Playground.tsx +++ b/web/oss/src/components/Playground/Playground.tsx @@ -88,7 +88,7 @@ const Playground: FC = () => { return ( -
+
diff --git a/web/oss/src/components/PlaygroundRouter/index.tsx b/web/oss/src/components/PlaygroundRouter/index.tsx index 4abb241beb..e565179863 100644 --- a/web/oss/src/components/PlaygroundRouter/index.tsx +++ b/web/oss/src/components/PlaygroundRouter/index.tsx @@ -11,7 +11,7 @@ import {currentWorkflowContextAtom} from "@/oss/state/workflow" const PlaygroundLoadingShell = () => { return ( -
+
diff --git a/web/oss/src/components/WorkflowRevisionDrawerWrapper/index.tsx b/web/oss/src/components/WorkflowRevisionDrawerWrapper/index.tsx index a95cc2d431..9884ad4c28 100644 --- a/web/oss/src/components/WorkflowRevisionDrawerWrapper/index.tsx +++ b/web/oss/src/components/WorkflowRevisionDrawerWrapper/index.tsx @@ -70,6 +70,7 @@ import {queryClientAtom} from "jotai-tanstack-query" import dynamic from "next/dynamic" import {useRouter} from "next/router" +import {AgentChatScopeProvider, drawerScopeKey} from "@/oss/components/AgentChatSlice/state/scope" import OSSdrillInUIProvider from "@/oss/components/DrillInView/OSSdrillInUIProvider" import SimpleSharedEditor from "@/oss/components/EditorViews/SimpleSharedEditor" import { @@ -99,6 +100,24 @@ const PlaygroundMainView = dynamic( {ssr: false}, ) +// Agent generation arm, same surface the full playground injects. Without this the app drawer +// renders nothing for an agent entity (the generations panel does `AgentGenerationPanel ?? null`), +// so a freshly-created agent can't be invoked from the create/edit drawer the way chat and +// completion can. Lazy — pulls in the AI SDK only when an agent workflow is open. +const AgentChatPanel = dynamic(() => import("@/oss/components/AgentChatSlice/AgentChatPanel"), { + ssr: false, +}) + +// Drawer agent chat runs in its OWN session scope so it never inherits or overwrites the main +// playground's tabs/history. The drawer mounts over the playground, so both AgentChatPanels are +// live at once; a shared (app) scope would have them share conversations. See +// AgentChatSlice/state/scope. +const ScopedDrawerAgentChat = (props: {entityId: string}) => ( + + + +) + const TestsetDropdown = dynamic( () => import("@/oss/components/Playground/Components/TestsetDropdown"), {ssr: false}, @@ -175,6 +194,9 @@ const DrawerAppPlayground = memo(({entityId}: {entityId: string}) => { SimpleSharedEditor, SharedGenerationResultUtils, TestcaseEditor: PlaygroundTestcaseEditor, + // Agent entities render the agent-chat surface here too, so the create/edit drawer + // can invoke an agent the same way it invokes chat/completion. + AgentGenerationPanel: ScopedDrawerAgentChat, }) as unknown as PlaygroundUIProviders, [], ) diff --git a/web/packages/agenta-entities/src/workflow/state/store.ts b/web/packages/agenta-entities/src/workflow/state/store.ts index b4e27e005a..32f3e05ac8 100644 --- a/web/packages/agenta-entities/src/workflow/state/store.ts +++ b/web/packages/agenta-entities/src/workflow/state/store.ts @@ -1044,16 +1044,33 @@ export const workflowInspectAtomFamily = atomFamily((revisionId: string) => const revisionQuery = get(workflowQueryAtomFamily(revisionId)) const serverData = revisionQuery.data ?? null + // A builtin-agent DRAFT (not yet committed, so it has no server data) still needs inspect. + // The agent service publishes `harness_capabilities` (the provider + model catalog) per + // SERVICE, not per committed revision — `inspectWorkflow` keys on uri + serviceUrl, never a + // revision id. `invocationUrl` already derives the draft's builtin uri/url from the LOCAL + // entity (which is why a draft agent can be invoked before creation); mirror that here so the + // model picker resolves from inspect pre-creation instead of showing an empty catalog. + // Scoped to agents: a non-agent draft keeps its server-only behavior unchanged. + const localEntity = serverData ? null : get(workflowBaseEntityAtomFamily(revisionId)) + const localIsAgent = + localEntity?.data?.uri === AGENT_BUILTIN_URI || (localEntity?.flags?.is_agent ?? false) + const localData = localIsAgent ? localEntity : null + // Use stored URI, or derive one from builtin service URL pattern - const storedUri = serverData?.data?.uri ?? null - const storedUrl = serverData?.data?.url ?? null + const storedUri = serverData?.data?.uri ?? localData?.data?.uri ?? null + const storedUrl = serverData?.data?.url ?? localData?.data?.url ?? null const derivedServiceType = storedUri ? null : resolveServiceTypeFromUrl(storedUrl) const uri = storedUri ?? (derivedServiceType ? buildWorkflowUri(derivedServiceType) : null) // Service URL: prefer stored url, fall back to building from URI const serviceUrl = storedUrl ?? buildServiceUrlFromUri(uri) - // Skip inspect when the revision has no service endpoint (has_url: false) - const hasUrl = serverData?.flags?.has_url ?? true + // Skip inspect when the revision has no service endpoint (has_url: false). A builtin-agent + // DRAFT reports has_url: false (nothing is deployed yet), but its service URL is always + // derivable from the builtin uri (same URL `invocationUrl` invokes), so the capability + // catalog is still reachable — don't let the deploy-state flag gate it. + const hasUrl = + serverData?.flags?.has_url ?? + (localIsAgent ? true : (localData?.flags?.has_url ?? true)) // Skip inspect when the revision already carries all schemas inline. // The merge step (workflowEntityAtomFamily) gives server schemas diff --git a/web/packages/agenta-entity-ui/package.json b/web/packages/agenta-entity-ui/package.json index fe30c9997a..65d257e1f3 100644 --- a/web/packages/agenta-entity-ui/package.json +++ b/web/packages/agenta-entity-ui/package.json @@ -32,6 +32,7 @@ "@agenta/ui": "workspace:../agenta-ui", "@phosphor-icons/react": "^2.1.10", "clsx": "^2.1.1", + "fflate": "0.4.8", "js-yaml": "^4.1.1", "lodash": "^4.17.23", "lucide-react": "^0.479.0", diff --git a/web/packages/agenta-entity-ui/src/DrillInView/SchemaControls/AgentConfigControl.tsx b/web/packages/agenta-entity-ui/src/DrillInView/SchemaControls/AgentConfigControl.tsx index e53e86594a..502b1efb6a 100644 --- a/web/packages/agenta-entity-ui/src/DrillInView/SchemaControls/AgentConfigControl.tsx +++ b/web/packages/agenta-entity-ui/src/DrillInView/SchemaControls/AgentConfigControl.tsx @@ -1,32 +1,52 @@ /** * AgentConfigControl * - * One composite control for the whole agent config, dispatched from - * `x-ag-type: "agent_config"` / `x-ag-type-ref: "agent_config"` (see SchemaPropertyRenderer). - * It reuses the existing controls rather than inventing new ones: the model selector - * (GroupedChoiceControl), the tool picker (ToolSelectorPopover + ToolItemControl), the MCP - * server editor (McpServerItemControl), the skill editor (SkillConfigControl), enum selects - * (harness, sandbox, permission policy), and a textarea (agents_md). The field shape is the - * `agent_config` catalog type generated - * from the SDK model (AgentConfigSchema in agenta.sdk.utils.types); the agent service ships a - * thin `x-ag-type-ref` the playground resolves and reads back (services/oss/src/agent). + * The agent playground's left config panel. It renders the whole agent config as a set + * of collapsible accordion sections (Model & harness, Instructions, Tools, MCP servers, + * Advanced), built on the reusable {@link ConfigAccordionSection} primitive so the same + * pattern can roll out to other config surfaces. + * + * Dispatched from `x-ag-type: "agent_config"` / `x-ag-type-ref: "agent_config"` (see + * SchemaPropertyRenderer). It reuses the existing schema controls rather than inventing + * new ones: the model selector (GroupedChoiceControl), the tool picker (ToolSelectorPopover + * + ToolItemControl), the MCP server editor (McpServerItemControl), enum selects (harness, + * sandbox, permission policy), and a textarea (agents_md). The field shape is the + * `agent_config` catalog type generated from the SDK model (AgentConfigSchema in + * agenta.sdk.utils.types); the agent service ships a thin `x-ag-type-ref` the playground + * resolves and reads back (services/oss/src/agent). + * + * Sections are schema-driven: each renders only when its field exists in the resolved + * schema, so the panel tracks the backend contract instead of hard-coding fields. */ import {useCallback, useEffect, useMemo, useState} from "react" import {vaultSecretsQueryAtom} from "@agenta/entities/secret" import type {SchemaProperty} from "@agenta/entities/shared" import {harnessCapabilitiesAtomFamily} from "@agenta/entities/workflow" -import {LabeledField} from "@agenta/ui/components/presentational" +import {ConfigAccordionSection, LabeledField} from "@agenta/ui/components/presentational" import {useDrillInUI} from "@agenta/ui/drill-in" import {SelectLLMProviderBase} from "@agenta/ui/select-llm-provider" import {cn} from "@agenta/ui/styles" -import {CaretDown, CaretRight, Plus} from "@phosphor-icons/react" -import {Button, Segmented, Select, Switch, Typography} from "antd" +import { + CaretRight, + Cpu, + FileText, + GraduationCap, + Plugs, + Plus, + SlidersHorizontal, + Trash, + Wrench, +} from "@phosphor-icons/react" +import {Button, Select, Switch, Tabs, Tag, Tooltip, Typography} from "antd" import {useAtomValue} from "jotai" import {useOptionalDrillIn} from "../components/MoleculeDrillInContext" +import {agentConfigLayoutAtom} from "./agentConfigLayout" import {ClaudePermissionsControl} from "./ClaudePermissionsControl" +import {CodeEditor} from "./CodeEditor" +import {ConfigItemDrawer, type ConfigItemView} from "./ConfigItemDrawer" import { allowedConnectionModes, buildModelOptionGroups, @@ -41,13 +61,15 @@ import { } from "./connectionUtils" import {EnumSelectControl} from "./EnumSelectControl" import {GroupedChoiceControl} from "./GroupedChoiceControl" -import {McpServerItemControl} from "./McpServerItemControl" +import {HarnessSelectControl} from "./HarnessSelectControl" +import {JsonObjectEditor} from "./JsonObjectEditor" +import {MarkdownEditor} from "./MarkdownEditor" +import {McpServerFormView} from "./McpServerFormView" import {SandboxPermissionControl} from "./SandboxPermissionControl" -import {isPlatformSkill, SkillConfigControl} from "./SkillConfigControl" -import {TextInputControl} from "./TextInputControl" -import {ToolItemControl} from "./ToolItemControl" +import {SkillFormView} from "./SkillFormView" +import {ToolFormView} from "./ToolFormView" import {ToolSelectorPopover, type ToolSelectionMeta} from "./ToolSelectorPopover" -import {type ToolObj} from "./toolUtils" +import {parseGatewayFunctionName, type ToolObj} from "./toolUtils" export interface AgentConfigControlProps { schema?: SchemaProperty | null @@ -88,6 +110,299 @@ function isBuiltinPayloadMatch(tool: unknown, payload: ToolObj): boolean { ) } +/** + * Best-effort display label for an enum value, used in collapsed section summaries. + * Reads `x-model-metadata` titles and `anyOf`/`oneOf` const titles, falling back to the + * raw value so a summary is always shown. + */ +function enumLabel(schema: SchemaProperty | undefined, value: unknown): string | null { + if (value == null || value === "") return null + const v = String(value) + const s = schema as Record | undefined + const meta = s?.["x-model-metadata"] as Record | undefined + if (meta?.[v]?.name) return meta[v]!.name as string + const variants = (s?.anyOf ?? s?.oneOf) as {const?: unknown; title?: string}[] | undefined + const hit = variants?.find((o) => o?.const === value) + if (hit?.title) return hit.title + return v +} + +const countSummary = (n: number, noun: string): string => + n > 0 ? `${n} ${noun}${n === 1 ? "" : "s"}` : "None" + +/** Whether a tool has an editable OpenAI-style `function` (vs a bare builtin `type`). */ +function isFunctionTool(tool: unknown): boolean { + if (!tool || typeof tool !== "object") return false + const fn = (tool as Record).function + return Boolean(fn && typeof fn === "object") +} + +/** How a config-item row presents itself: avatar, name + description, and type tags. */ +interface ItemDescriptor { + /** Primary label (rendered monospace). */ + name: string + /** Secondary description line. */ + description?: string + /** Avatar monogram, used when no `icon` is given. */ + mono: string + /** Avatar background colour. */ + color: string + /** Avatar icon (overrides the monogram). */ + icon?: React.ReactNode + /** Type tags shown on the right of a row (e.g. "built-in", "definition", "gmail"). */ + tags: string[] + /** Type label for the drawer header badge (e.g. "definition", "MCP server"). */ + typeLabel: string + /** antd Tag colour for the header badge. */ + typeColor?: string + /** One-line type description shown as the drawer subtitle. */ + subtitle: string +} + +/** Two-char monogram, title-cased ("gmail" -> "Gm", "zendesk" -> "Ze"). */ +function monogram(value: string): string { + return (value.charAt(0).toUpperCase() + (value.charAt(1) ?? "")).trim() || "?" +} + +/** Deep-clone a config item so drawer edits don't alias the committed config object. */ +function cloneItem(item: unknown): Record { + if (!item || typeof item !== "object") return {} + return JSON.parse(JSON.stringify(item)) as Record +} + +/** Classify a tool into its row avatar / name / description / type tags. */ +function describeTool(tool: unknown): ItemDescriptor { + const t = (tool ?? {}) as Record + const fn = t.function as Record | undefined + const fnName = typeof fn?.name === "string" ? (fn.name as string) : undefined + const description = typeof fn?.description === "string" ? (fn.description as string) : undefined + + // Third-party / gateway tool: tools__provider__integration__action__connection. + const gateway = fnName ? parseGatewayFunctionName(fnName) : null + if (gateway) { + return { + name: gateway.action, + description, + mono: monogram(gateway.integration), + color: "#1c2c3d", + tags: [gateway.integration], + typeLabel: "third-party", + subtitle: `Connected app tool · ${gateway.integration}`, + } + } + + // Built-in / provider tool: a bare `type` with no editable `function`. + if (!fn || typeof fn !== "object") { + const typeValue = + typeof t.type === "string" && t.type !== "function" + ? (t.type as string) + : Object.keys(t).find((k) => k !== "type" && k !== "function") + return { + name: typeValue ?? "Built-in tool", + mono: "io", + color: "#0d9488", + tags: ["built-in"], + typeLabel: "built-in", + typeColor: "cyan", + subtitle: "Provider built-in tool", + } + } + + // Function definition (custom inline tool). + return { + name: fnName ?? "Tool", + description, + mono: "{}", + color: "#7c3aed", + tags: ["definition"], + typeLabel: "definition", + typeColor: "purple", + subtitle: "Schema-only · executed by your app", + } +} + +/** Classify an MCP server into its row avatar / name / description / tags. */ +function describeMcp(server: unknown): ItemDescriptor { + const s = (server ?? {}) as Record + const transport = s.transport === "http" ? "http" : "stdio" + const name = typeof s.name === "string" && s.name ? (s.name as string) : "MCP server" + const detailField = transport === "http" ? s.url : s.command + return { + name, + description: typeof detailField === "string" ? detailField : undefined, + mono: "", + color: "#2563eb", + icon: , + tags: [transport], + typeLabel: "MCP server", + typeColor: "cyan", + subtitle: "Model Context Protocol server", + } +} + +/** + * A skills entry is either an inline SKILL.md package or an `@ag.embed` reference (which the + * backend inlines). Embed refs carry the marker at the top level and must round-trip intact, so + * they're edited JSON-only rather than through the structured form. + */ +function isEmbedRefSkill(skill: unknown): boolean { + return Boolean( + skill && typeof skill === "object" && "@ag.embed" in (skill as Record), + ) +} + +/** Classify a skill into its row avatar / name / description / type tags. */ +function asObj(value: unknown): Record | undefined { + return value && typeof value === "object" && !Array.isArray(value) + ? (value as Record) + : undefined +} + +/** The reserved slug namespace for platform-owned skills (mirrors the backend `_agenta.*`). */ +const PLATFORM_SKILL_SLUG_PREFIX = "_agenta." + +/** The slug an `@ag.embed` entry points at (a `workflow` or pinned `workflow_revision` reference). */ +function platformEmbedSlug(skill: Record): string | undefined { + const refs = asObj(asObj(skill["@ag.embed"])?.["@ag.references"]) + if (!refs) return undefined + const slug = asObj(refs.workflow)?.slug ?? asObj(refs.workflow_revision)?.slug + return typeof slug === "string" ? slug : undefined +} + +/** A pinned revision's version, when the embed references a `workflow_revision`. */ +function embedRevisionVersion(skill: Record): string | undefined { + const refs = asObj(asObj(skill["@ag.embed"])?.["@ag.references"]) + const version = asObj(refs?.workflow_revision)?.version + return typeof version === "string" ? version : undefined +} + +/** + * Whether a skill entry is platform-owned and so read-only for the author. The reliable client-side + * signal is the reserved `_agenta.` slug prefix on the embed's referenced workflow (or pinned + * revision); a resolved object carrying `flags.is_platform === true` counts too. + */ +function isPlatformSkill(skill: unknown): boolean { + const s = asObj(skill) + if (!s) return false + const slug = platformEmbedSlug(s) + if (slug && slug.startsWith(PLATFORM_SKILL_SLUG_PREFIX)) return true + return asObj(s.flags)?.is_platform === true +} + +function describeSkill(skill: unknown): ItemDescriptor { + const s = (skill ?? {}) as Record + if (isPlatformSkill(s)) { + const slug = platformEmbedSlug(s) + const version = embedRevisionVersion(s) + return { + name: slug ?? "Platform skill", + mono: "sk", + color: "#6b7280", + tags: version ? ["platform", `v${version}`] : ["platform"], + typeLabel: "platform skill", + subtitle: "Provided by Agenta — read-only", + } + } + if (isEmbedRefSkill(s)) { + return { + name: "Skill reference", + mono: "sk", + color: "#b45309", + tags: ["@ag.embed"], + typeLabel: "@ag.embed", + typeColor: "blue", + subtitle: "Referenced skill — inlined by the backend", + } + } + return { + name: typeof s.name === "string" && s.name ? (s.name as string) : "Skill", + description: typeof s.description === "string" ? (s.description as string) : undefined, + mono: "sk", + color: "#b45309", + tags: ["skill"], + typeLabel: "skill", + typeColor: "gold", + subtitle: "Inline SKILL.md package", + } +} + +/** Colored avatar square (icon or monogram) at the start of a config-item row. */ +function ItemAvatar({descriptor}: {descriptor: ItemDescriptor}) { + return ( + + {descriptor.icon ?? descriptor.mono} + + ) +} + +/** + * A config-item row (a tool or MCP server): type avatar, name + description, type tags, and a + * chevron. The whole row opens the item drawer; remove appears on hover. + */ +function ItemRow({ + descriptor, + onEdit, + onRemove, + disabled, +}: { + descriptor: ItemDescriptor + onEdit: () => void + onRemove?: () => void + disabled?: boolean +}) { + return ( +
{ + if (e.key === "Enter" || e.key === " ") { + e.preventDefault() + onEdit() + } + }} + className="group flex cursor-pointer items-center gap-2.5 rounded border border-solid border-[var(--ag-c-EAEFF5,#eaeff5)] px-3 py-2 transition-colors hover:border-[var(--ag-c-97A4B0,#97a4b0)]" + > + +
+
{descriptor.name}
+ {descriptor.description ? ( + + {descriptor.description} + + ) : null} +
+
+ {descriptor.tags.map((tag) => ( + + {tag} + + ))} + {onRemove && !disabled ? ( + + ) : null} + +
+
+ ) +} + export function AgentConfigControl({ schema, value, @@ -96,8 +411,41 @@ export function AgentConfigControl({ disabled, className, }: AgentConfigControlProps) { - const {EditorProvider, SharedEditor, gatewayTools} = useDrillInUI() + const {gatewayTools} = useDrillInUI() const config = (value ?? {}) as Record + + // The item-config drawer (tools / MCP servers). Edits happen on a local `draft`; they only + // apply to the config on Save. So creating an item never pollutes the config until confirmed, + // and editing an existing item can be cancelled cleanly (Cancel/X discards the draft). + const [editing, setEditing] = useState<{ + kind: "tool" | "mcp" | "skill" + mode: "create" | "edit" + index: number + } | null>(null) + const [draft, setDraft] = useState>({}) + const [drawerView, setDrawerView] = useState("form") + const openCreate = useCallback( + (kind: "tool" | "mcp" | "skill", seed: Record, view: ConfigItemView) => { + setDraft(seed) + setDrawerView(view) + setEditing({kind, mode: "create", index: -1}) + }, + [], + ) + const openEdit = useCallback( + (kind: "tool" | "mcp" | "skill", index: number, item: unknown, view: ConfigItemView) => { + setDraft(cloneItem(item)) + setDrawerView(view) + setEditing({kind, mode: "edit", index}) + }, + [], + ) + const closeEditor = useCallback(() => setEditing(null), []) + + // How the config sections are laid out: stacked accordion (default), tabs, or cards. + // Layout is a global, persisted preference set from the variant header menu (see + // agentConfigLayout); the panel only reads it. + const layout = useAtomValue(agentConfigLayoutAtom) const props = (schema?.properties ?? {}) as Record // Update a single field of the agent config, leaving the rest intact. @@ -106,6 +454,18 @@ export function AgentConfigControl({ [config, onChange], ) + // Model + credential connection (the ModelRef). `config.model` is ALWAYS a structured ModelRef + // (the harness-filtered picker only ever produces one); a legacy bare string is read for + // display. composeModelValue carries through extra keys (e.g. `params`) so a form edit never + // silently drops them. The picker is harness-filtered: selecting a model sets BOTH the model id + // and its provider, fed by the `/inspect` capability map below. + const harnessValue = typeof config.harness === "string" ? config.harness : null + // Pi (`pi_core`/`pi_agenta`) never gates tool use (`permissions: false`); a permission + // policy is meaningless for it, so the field is hidden for Pi. Only Claude honors it. + const isPiHarness = harnessValue === "pi_core" || harnessValue === "pi_agenta" + const modelId = useMemo(() => modelIdFromConfig(config.model), [config.model]) + const connection = useMemo(() => connectionFromConfig(config.model), [config.model]) + // Per-harness capability map from the `/inspect` response meta, keyed by the open revision. // Null when inspect hasn't resolved or the agent didn't publish it (older agents / standalone), // in which case the connectionUtils helpers fall back permissively. @@ -115,34 +475,25 @@ export function AgentConfigControl({ useMemo(() => harnessCapabilitiesAtomFamily(revisionId ?? ""), [revisionId]), ) - // The project's stored connections (read-only) for the connection picker. The transformed - // vault list surfaces custom-provider connections as {type, name, provider}; the resolver - // matches a named connection by that name (the slug). + // The project's stored connections (read-only) for the connection picker. The transformed vault + // list surfaces custom-provider connections as {type, name, provider}; the resolver matches a + // named connection by that name (the slug). const vaultQuery = useAtomValue(vaultSecretsQueryAtom) const vaultSecrets = useMemo( () => (Array.isArray(vaultQuery.data) ? (vaultQuery.data as VaultConnectionEntry[]) : []), [vaultQuery.data], ) - // Model + credential connection (the ModelRef). `config.model` is ALWAYS a structured ModelRef - // (the picker only ever produces one); a legacy bare string is read for display. The picker is - // harness-filtered: selecting a model sets BOTH the model id and its provider. - const harness = typeof config.harness === "string" ? config.harness : null - // Pi (`pi_core`/`pi_agenta`) never gates tool use (`permissions: false`); a permission - // policy is meaningless for it, so the field is hidden for Pi. Only Claude honors it. - const isPiHarness = harness === "pi_core" || harness === "pi_agenta" - const modelId = useMemo(() => modelIdFromConfig(config.model), [config.model]) - const connection = useMemo(() => connectionFromConfig(config.model), [config.model]) const modeOptions = useMemo( - () => allowedConnectionModes(capabilities, harness), - [capabilities, harness], + () => allowedConnectionModes(capabilities, harnessValue), + [capabilities, harnessValue], ) // Harness-filtered model options, built straight from inspect meta. Empty when the harness // publishes none (older agent / standalone) — fall back to the schema's full catalog picker. const modelGroups = useMemo( - () => buildModelOptionGroups(capabilities, harness), - [capabilities, harness], + () => buildModelOptionGroups(capabilities, harnessValue), + [capabilities, harnessValue], ) const hasInspectModels = modelGroups.length > 0 @@ -162,7 +513,7 @@ export function AgentConfigControl({ nextProvider = patch.provider } else if (patch.modelId !== undefined) { nextProvider = - providerForModel(capabilities, harness, nextModelId) ?? connection.provider + providerForModel(capabilities, harnessValue, nextModelId) ?? connection.provider } else { nextProvider = connection.provider } @@ -173,33 +524,43 @@ export function AgentConfigControl({ provider: nextProvider, mode: patch.mode !== undefined ? patch.mode : connection.mode, slug: patch.slug !== undefined ? patch.slug : connection.slug, - // Carry through extra ModelRef keys (params, ...) the form does not edit. existing: config.model, }), ) }, - [setField, modelId, connection, config.model, capabilities, harness], + [setField, modelId, connection, config.model, capabilities, harnessValue], ) // On harness switch, clear a model the new harness can't reach (rather than sending an // unsupported model). Permissive when the new harness publishes no models. useEffect(() => { - if (!harness || !modelId) return - if (!harnessAllowsModel(capabilities, harness, modelId)) { - writeModel({modelId: null, provider: null}) + if (!harnessValue || !modelId) return + if (!harnessAllowsModel(capabilities, harnessValue, modelId)) { + // Drop the stale named connection too, not just the model id/provider. + writeModel({modelId: null, provider: null, slug: null}) } // Only react to harness/capabilities changes, not every model edit. - }, [harness, capabilities]) + }, [harnessValue, capabilities]) + + // Also reset a connection mode the new harness no longer allows. Guarded on a non-empty + // option set so a harness that publishes no modes stays permissive (and we never loop). + // Slug validity is intentionally NOT normalized here: connectionOptions is vault-secret + // async, so an empty set during load would wrongly clear a valid slug. + useEffect(() => { + if (modeOptions.length > 0 && !modeOptions.includes(connection.mode)) { + writeModel({mode: modeOptions[0], slug: null}) + } + }, [connection.mode, modeOptions, writeModel]) // Named connections selectable for the chosen provider under this harness (Agenta-managed). const connectionOptions = useMemo( - () => namedConnectionOptions(vaultSecrets, capabilities, harness, connection.provider), - [vaultSecrets, capabilities, harness, connection.provider], + () => namedConnectionOptions(vaultSecrets, capabilities, harnessValue, connection.provider), + [vaultSecrets, capabilities, harnessValue, connection.provider], ) // Raw-JSON escape hatch for the whole `config.model` value (collapsed by default). const [showModelJson, setShowModelJson] = useState(false) - const [modelJsonText, setModelJsonText] = useState(() => + const [modelJsonText, setModelJsonText] = useState(() => JSON.stringify(config.model ?? "", null, 2), ) const handleModelJsonChange = useCallback( @@ -220,6 +581,54 @@ export function AgentConfigControl({ }, [config.model], ) + // Keep the open JSON buffer in sync when `config.model` changes from OUTSIDE the editor + // (the model picker or the authentication cards). Guard with a structural compare so we + // only resync on external changes — when the buffer already represents `config.model` + // (the user is typing here) we skip, so we never reformat mid-edit or fight the cursor. + useEffect(() => { + if (!showModelJson) return + let bufferValue: unknown + try { + bufferValue = modelJsonText ? JSON.parse(modelJsonText) : "" + } catch { + return // invalid in-progress JSON — leave the user's text untouched + } + if (JSON.stringify(bufferValue) !== JSON.stringify(config.model ?? "")) { + setModelJsonText(JSON.stringify(config.model ?? "", null, 2)) + } + }, [config.model, showModelJson, modelJsonText]) + + // Claude permissions (Layer 1, Claude-only): the Claude harness's own permission knobs, kept in + // the neutral `harness_kwargs.claude.permissions` bag. Shown in Advanced only for the Claude + // harness; writes preserve any other harness_kwargs slices. + const harnessKwargs = useMemo( + () => + config.harness_kwargs && typeof config.harness_kwargs === "object" + ? (config.harness_kwargs as Record) + : {}, + [config.harness_kwargs], + ) + const claudePermissions = useMemo(() => { + const claude = + harnessKwargs.claude && typeof harnessKwargs.claude === "object" + ? (harnessKwargs.claude as Record) + : undefined + const perms = claude?.permissions + return perms && typeof perms === "object" ? (perms as Record) : null + }, [harnessKwargs]) + const setClaudePermissions = useCallback( + (next: Record) => { + const claude = + harnessKwargs.claude && typeof harnessKwargs.claude === "object" + ? (harnessKwargs.claude as Record) + : {} + setField("harness_kwargs", { + ...harnessKwargs, + claude: {...claude, permissions: next}, + }) + }, + [harnessKwargs, setField], + ) // Tools live as a flat array on the agent config (the same tool-object shape the // prompt control uses, so the backend resolver parses them identically). @@ -243,18 +652,16 @@ export function AgentConfigControl({ }, } : tool + // A custom (inline function) tool starts blank — edit it in a create drawer and only + // append on Save, so a half-filled tool never lands in the config. Builtin/gateway + // tools arrive complete (and gateway is multi-select), so add those straight away. + if (meta?.source === "custom") { + openCreate("tool", next as Record, "form") + return + } setTools([...tools, next]) }, - [tools, setTools], - ) - - const handleToolChange = useCallback( - (index: number, next: ToolObj) => { - const updated = [...tools] - updated[index] = next - setTools(updated) - }, - [tools, setTools], + [tools, setTools, openCreate], ) const handleToolDelete = useCallback( @@ -297,90 +704,81 @@ export function AgentConfigControl({ (next: unknown[]) => setField("mcp_servers", next), [setField], ) - const handleAddMcpServer = useCallback( - () => setMcpServers([...mcpServers, {name: "", transport: "stdio", command: "", args: []}]), - [mcpServers, setMcpServers], - ) - const handleMcpServerChange = useCallback( - (index: number, next: Record) => { - const updated = [...mcpServers] - updated[index] = next - setMcpServers(updated) - }, - [mcpServers, setMcpServers], - ) + const handleAddMcpServer = useCallback(() => { + openCreate("mcp", {name: "", transport: "stdio", command: "", args: []}, "form") + }, [openCreate]) const handleMcpServerDelete = useCallback( (index: number) => setMcpServers(mcpServers.filter((_, i) => i !== index)), [mcpServers, setMcpServers], ) - // Skills are a sibling of tools/mcp_servers: a flat array on the agent config. Each entry is - // either an inline SKILL.md package (name + description + body + optional files/flags) or an - // `@ag.embed` reference the backend inlines into that same shape. Both are edited as JSON the - // backend resolver parses identically; an embed entry round-trips intact (see SkillConfigControl). + // Skills are a sibling of tools/MCP: a flat array on the agent config. Each entry is an inline + // SKILL.md package (name + description + body + files + flags) or an `@ag.embed` reference the + // backend inlines — the `skill_config` catalog type (SkillConfigSchema in the SDK). const skills = useMemo( () => (Array.isArray(config.skills) ? (config.skills as unknown[]) : []), [config.skills], ) const setSkills = useCallback((next: unknown[]) => setField("skills", next), [setField]) - const handleAddSkill = useCallback( - () => setSkills([...skills, {name: "", description: "", body: ""}]), - [skills, setSkills], - ) - const handleSkillChange = useCallback( - (index: number, next: Record) => { - const updated = [...skills] - updated[index] = next - setSkills(updated) - }, - [skills, setSkills], - ) + const handleAddSkill = useCallback(() => { + openCreate("skill", {name: "", description: "", body: ""}, "form") + }, [openCreate]) const handleSkillDelete = useCallback( (index: number) => setSkills(skills.filter((_, i) => i !== index)), [skills, setSkills], ) - // Layer 2: the sandbox security boundary (`sandbox_permission`). Applies to every harness. - // Stored as a nested object; an unset value stays null until the author changes something. - const sandboxPermission = useMemo( - () => - config.sandbox_permission && typeof config.sandbox_permission === "object" - ? (config.sandbox_permission as Record) - : null, - [config.sandbox_permission], - ) + // Apply the drawer's draft to the config: append (create) or replace at index (edit). + const commitDraft = useCallback(() => { + if (!editing) return + if (editing.kind === "tool") { + const next = [...tools] + if (editing.mode === "create") next.push(draft) + else next[editing.index] = draft + setTools(next) + } else if (editing.kind === "mcp") { + const next = [...mcpServers] + if (editing.mode === "create") next.push(draft) + else next[editing.index] = draft + setMcpServers(next) + } else { + const next = [...skills] + if (editing.mode === "create") next.push(draft) + else next[editing.index] = draft + setSkills(next) + } + setEditing(null) + }, [editing, draft, tools, mcpServers, skills, setTools, setMcpServers, setSkills]) - // Layer 1 (Claude-only): the Claude harness's own permission knobs, persisted into the neutral - // `harness_kwargs.claude.permissions` bag. Hidden when the harness is not Claude. - const harnessKwargs = useMemo( - () => - config.harness_kwargs && typeof config.harness_kwargs === "object" - ? (config.harness_kwargs as Record) - : {}, - [config.harness_kwargs], - ) - const claudePermissions = useMemo(() => { - const claude = harnessKwargs.claude - const claudeObj = - claude && typeof claude === "object" ? (claude as Record) : undefined - const perms = claudeObj?.permissions - return perms && typeof perms === "object" ? (perms as Record) : null - }, [harnessKwargs]) - // Write `harness_kwargs.claude.permissions`, preserving any other harness_kwargs slices. - const setClaudePermissions = useCallback( - (next: Record) => { - const claude = - harnessKwargs.claude && typeof harnessKwargs.claude === "object" - ? (harnessKwargs.claude as Record) - : {} - setField("harness_kwargs", { - ...harnessKwargs, - claude: {...claude, permissions: next}, - }) - }, - [harnessKwargs, setField], - ) - const [showClaudeAdvanced, setShowClaudeAdvanced] = useState(false) + // Block Save until the draft has the minimum it needs to be a valid item (a name). `@ag.embed` + // skill references carry no name and are always valid (they round-trip as-is). + // JSON-view parse validity from the open drawer's JsonObjectEditor; blocks Save while the + // raw JSON is invalid. The editor keeps invalid text local and stops emitting onChange, so + // without this Save would silently commit the last valid draft. + const [jsonInvalid, setJsonInvalid] = useState(false) + // Reset when the open item changes — each editor is keyed/remounts and starts valid. + useEffect(() => { + setJsonInvalid(false) + }, [editing]) + + const draftInvalid = useMemo(() => { + if (!editing) return true + if (editing.kind === "mcp") { + // A server needs a launch target too, not just a name: stdio → command, http → url. + const name = String(draft.name ?? "").trim() + const transport = draft.transport === "http" ? "http" : "stdio" + const target = + transport === "http" + ? String(draft.url ?? "").trim() + : String(draft.command ?? "").trim() + return !name || !target + } + if (editing.kind === "skill") + return !isEmbedRefSkill(draft) && !String(draft.name ?? "").trim() + const fn = draft.function as Record | undefined + if (fn && typeof fn === "object") return !String(fn.name ?? "").trim() + return false + }, [editing, draft]) // ``agents_md`` is the catalog-schema field; ``instructions`` is read as a fallback so an // already-stored agent config (the legacy key) still populates the editor. @@ -389,343 +787,619 @@ export function AgentConfigControl({ (config.instructions as string | null | undefined) ?? null - return ( -
- setField("agents_md", v)} - description={props.agents_md?.description as string | undefined} - withTooltip={withTooltip} - disabled={disabled} - multiline - /> - - {/* Unified provider + model picker. When the agent's `/inspect` publishes a - harness-filtered model list we render from it (selecting a model sets both the model - id and its provider). Otherwise we fall back to the schema's full catalog picker. */} - {hasInspectModels ? ( - - writeModel({modelId: (v as string) ?? null})} - disabled={disabled} - placeholder="Select a model..." - className="w-full" - size="small" - /> - - ) : ( - writeModel({modelId: v})} - withTooltip={withTooltip} - disabled={disabled} - /> - )} + const modelSummary = + [enumLabel(props.harness, config.harness), enumLabel(props.model, modelId)] + .filter(Boolean) + .join(" · ") || undefined - {/* Authentication: Agenta-managed (a vault connection) vs self-managed (the harness - uses its own login; Agenta injects nothing). Maps to connection.mode. */} -
- Authentication + const hasInstructions = Boolean(props.agents_md) + const hasModelOrHarness = Boolean(props.model || props.harness) + const hasTools = Boolean(props.tools) + const hasMcp = Boolean(props.mcp_servers) + const hasSkills = Boolean(props.skills) + const hasClaudePermissions = harnessValue === "claude" + const hasAdvanced = Boolean( + props.sandbox || + props.permission_policy || + props.sandbox_permission || + hasClaudePermissions, + ) - - block - size="small" - value={connection.mode} - onChange={(v) => writeModel({mode: v})} - disabled={disabled} - options={modeOptions.map((m) => ({ - value: m, - label: m === "agenta" ? "Agenta-managed" : "Self-managed", - }))} - /> + // Shared props for the tool picker, so the in-body popover and the header quick-add trigger + // drive the same add flow. + const toolSelectorProps = { + onAddTool: handleAddTool, + onRemoveTool: handleRemoveToolByName, + onRemoveBuiltinTool: handleRemoveBuiltinTool, + selectedToolNames, + selectedTools: tools as ToolObj[], + existingToolCount: tools.length, + gatewayTools, + } - {connection.mode === "agenta" ? ( - - - value={connection.slug ?? "__default__"} - onChange={(v) => - writeModel({slug: v === "__default__" ? null : (v ?? null)}) - } - options={[ - {value: "__default__", label: "Project default"}, - ...connectionOptions.map((o) => ({value: o.value, label: o.label})), - ]} + // A compact "+" affordance for a section header, so an item can be added without first + // expanding the section. Rendered in the header's `extra` slot (which stops propagation, so it + // never toggles the section). + const headerAddButton = (label: string, onClick: () => void) => ( + +