Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
9 changes: 9 additions & 0 deletions docs/design/agent-config-section-drawers/design.md
Original file line number Diff line number Diff line change
Expand Up @@ -92,6 +92,15 @@ switch, mirroring the model warning. Tracked as the harness-gating follow-up.
- Reference (`@ag.reference`) chips in the editor — they live on the embedref branch (#4877) and
light up here when that merges.
- Optional: model remap (instead of clear) when switching harness.
- Tool draft validation depth — the per-kind `draftInvalid` only checks an inline function's name
today. Under the incoming `kind`-discriminated tool schema (`schema-driven-config-proposal.md`,
CHANGE-3) every entry must validate against its per-kind sub-schema. Tighten when that lands; the
registry's per-kind `draftInvalid`/`createSeed` get replaced wholesale (the current `tool.createSeed`
is an unused stub — creation seeds from the picker). Raised from PR #4923 review.
- Named-connection slug on a provider change — intentionally kept today (the option list is
vault-secret async, so an eager clear would wipe a valid slug mid-load). When provider becomes
first-class via `ModelSpec` (`../agent-workflows/scratch/notes-model-auth.md`, R2), re-bind or clear the slug when the
derived provider changes. Raised from PR #4923 review.

## Verification

Expand Down

Large diffs are not rendered by default.

Original file line number Diff line number Diff line change
@@ -0,0 +1,56 @@
/**
* List body for a tool / MCP / skill section: a column of {@link ItemRow}s or a muted empty state.
* Per-kind behavior comes from {@link ITEM_KINDS}, so the three sections share one render.
*/
import type {ReactNode} from "react"

import type {ConfigItemView} from "../ConfigItemDrawer"

import {ITEM_KINDS, type ItemKind} from "./itemKinds"
import {ItemRow} from "./ItemRow"

export function ConfigItemList({
kind,
items,
openEdit,
removeItem,
closeEditor,
disabled,
emptyAdd,
}: {
kind: ItemKind
items: unknown[]
openEdit: (kind: ItemKind, index: number, item: unknown, view: ConfigItemView) => void
removeItem: (kind: ItemKind, index: number) => void
closeEditor: () => void
disabled?: boolean
/** The add trigger shown in the empty state (a popover for tools, a text link otherwise). */
emptyAdd: ReactNode
}) {
const def = ITEM_KINDS[kind]
if (items.length > 0) {
return (
<div className="flex flex-col gap-2">
{items.map((item, index) => (
<ItemRow
key={`${kind}-${index}`}
descriptor={def.describe(item)}
onEdit={() => openEdit(kind, index, item, def.editView(item))}
onRemove={() => {
removeItem(kind, index)
closeEditor()
}}
// Read-only items (static `__ag__*` skills) can't be removed and open disabled.
disabled={disabled || def.isReadOnly(item)}
Comment on lines +35 to +44

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

🎯 Functional Correctness | 🟠 Major | ⚡ Quick win

Don't wire openEdit into rows that are meant to be read-only.

ConfigItemList treats disabled || def.isReadOnly(item) as if it disables the row, but ItemRow still fires onEdit on click and keyboard activation. Right now static/read-only skills only lose the trash button; they can still open the drawer and be saved back through useConfigItemDrawer.

Suggested fix
                 {items.map((item, index) => (
+                    (() => {
+                        const rowDisabled = Boolean(disabled || def.isReadOnly(item))
+                        return (
                     <ItemRow
                         key={`${kind}-${index}`}
                         descriptor={def.describe(item)}
-                        onEdit={() => openEdit(kind, index, item, def.editView(item))}
+                        onEdit={() => {
+                            if (rowDisabled) return
+                            openEdit(kind, index, item, def.editView(item))
+                        }}
                         onRemove={() => {
                             removeItem(kind, index)
                             closeEditor()
                         }}
-                        // Read-only items (static `__ag__*` skills) can't be removed and open disabled.
-                        disabled={disabled || def.isReadOnly(item)}
+                        disabled={rowDisabled}
                     />
+                        )
+                    })()
                 ))}
📝 Committable suggestion

‼️ IMPORTANT
Carefully review the code before committing. Ensure that it accurately replaces the highlighted code, contains no missing lines, and has no issues with indentation. Thoroughly test & benchmark the code to ensure it meets the requirements.

Suggested change
<ItemRow
key={`${kind}-${index}`}
descriptor={def.describe(item)}
onEdit={() => openEdit(kind, index, item, def.editView(item))}
onRemove={() => {
removeItem(kind, index)
closeEditor()
}}
// Read-only items (static `__ag__*` skills) can't be removed and open disabled.
disabled={disabled || def.isReadOnly(item)}
{items.map((item, index) => (
(() => {
const rowDisabled = Boolean(disabled || def.isReadOnly(item))
return (
<ItemRow
key={`${kind}-${index}`}
descriptor={def.describe(item)}
onEdit={() => {
if (rowDisabled) return
openEdit(kind, index, item, def.editView(item))
}}
onRemove={() => {
removeItem(kind, index)
closeEditor()
}}
disabled={rowDisabled}
/>
)
})()
))}

/>
))}
</div>
)
}
if (disabled) return null
return (
<span className="text-xs text-[var(--ag-c-97A4B0,#97a4b0)]">
{def.emptyLabel} — {emptyAdd}
</span>
)
}
Original file line number Diff line number Diff line change
@@ -0,0 +1,145 @@
/**
* The presentational rows for the agent-template config sections: a colored avatar, the generic
* tool/MCP/skill row, and the richer instructions-file row. All are dumb — they render an
* {@link ItemDescriptor} and call back on open/remove; the section owners hold the state.
*/
import {CaretRight, Trash} from "@phosphor-icons/react"
import {Tag, Typography} from "antd"

import {describeInstruction, type ItemDescriptor} from "./itemDescriptors"

/** Colored avatar square (icon or monogram) at the start of a config-item row. */
export function ItemAvatar({descriptor}: {descriptor: ItemDescriptor}) {
return (
<span
className="flex h-7 w-7 shrink-0 items-center justify-center rounded text-[10px] font-semibold leading-none text-white"
style={{background: descriptor.color}}
>
{descriptor.icon ?? descriptor.mono}
</span>
)
}

/**
* 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.
*/
export function ItemRow({
descriptor,
onEdit,
onRemove,
disabled,
}: {
descriptor: ItemDescriptor
onEdit: () => void
onRemove?: () => void
disabled?: boolean
}) {
return (
<div
role="button"
tabIndex={0}
onClick={onEdit}
onKeyDown={(e) => {
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)]"
>
<ItemAvatar descriptor={descriptor} />
<div className="min-w-0 flex-1">
<div className="truncate font-mono text-xs font-medium">{descriptor.name}</div>
{descriptor.description ? (
<Typography.Text
type="secondary"
className="block truncate text-xs leading-tight"
>
{descriptor.description}
</Typography.Text>
) : null}
</div>
<div className="flex shrink-0 items-center gap-1.5">
{descriptor.tags.map((tag) => (
<Tag key={tag} className="m-0 text-[11px]">
{tag}
</Tag>
))}
{onRemove && !disabled ? (
<button
type="button"
aria-label="Remove"
onClick={(e) => {
e.stopPropagation()
onRemove()
}}
className="flex cursor-pointer items-center border-0 bg-transparent p-0 text-[var(--ag-c-97A4B0,#97a4b0)] opacity-0 transition-opacity hover:text-[var(--ag-c-FF4D4F,#ff4d4f)] group-hover:opacity-100"
>
<Trash size={14} />
</button>
Comment on lines +39 to +80

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

🎯 Functional Correctness | 🟠 Major | ⚡ Quick win

Disabled/read-only rows still open, and keyboarding the trash button can also fire edit.

ConfigItemList passes disabled specifically to prevent opening read-only rows, but this container still calls onEdit on click/Enter/Space unconditionally. Because the key handler is on the parent, pressing Enter/Space while the nested remove button has focus can also bubble up and open the editor.

Suggested fix
 export function ItemRow({
@@
 }) {
+    const handleOpen = () => {
+        if (!disabled) onEdit()
+    }
+
     return (
         <div
             role="button"
-            tabIndex={0}
-            onClick={onEdit}
+            tabIndex={disabled ? -1 : 0}
+            aria-disabled={disabled || undefined}
+            onClick={handleOpen}
             onKeyDown={(e) => {
+                if (e.currentTarget !== e.target || disabled) return
                 if (e.key === "Enter" || e.key === " ") {
                     e.preventDefault()
-                    onEdit()
+                    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`)]"
+            className="group flex 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`)] data-[disabled=true]:cursor-default"
+            data-disabled={disabled || undefined}
         >
📝 Committable suggestion

‼️ IMPORTANT
Carefully review the code before committing. Ensure that it accurately replaces the highlighted code, contains no missing lines, and has no issues with indentation. Thoroughly test & benchmark the code to ensure it meets the requirements.

Suggested change
<div
role="button"
tabIndex={0}
onClick={onEdit}
onKeyDown={(e) => {
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)]"
>
<ItemAvatar descriptor={descriptor} />
<div className="min-w-0 flex-1">
<div className="truncate font-mono text-xs font-medium">{descriptor.name}</div>
{descriptor.description ? (
<Typography.Text
type="secondary"
className="block truncate text-xs leading-tight"
>
{descriptor.description}
</Typography.Text>
) : null}
</div>
<div className="flex shrink-0 items-center gap-1.5">
{descriptor.tags.map((tag) => (
<Tag key={tag} className="m-0 text-[11px]">
{tag}
</Tag>
))}
{onRemove && !disabled ? (
<button
type="button"
aria-label="Remove"
onClick={(e) => {
e.stopPropagation()
onRemove()
}}
className="flex cursor-pointer items-center border-0 bg-transparent p-0 text-[var(--ag-c-97A4B0,#97a4b0)] opacity-0 transition-opacity hover:text-[var(--ag-c-FF4D4F,#ff4d4f)] group-hover:opacity-100"
>
<Trash size={14} />
</button>
const handleOpen = () => {
if (!disabled) onEdit()
}
<div
role="button"
tabIndex={disabled ? -1 : 0}
aria-disabled={disabled || undefined}
onClick={handleOpen}
onKeyDown={(e) => {
if (e.currentTarget !== e.target || disabled) return
if (e.key === "Enter" || e.key === " ") {
e.preventDefault()
onEdit()
}
}}
className="group flex 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`)] data-[disabled=true]:cursor-default"
data-disabled={disabled || undefined}
>

) : null}
<CaretRight size={14} className="text-[var(--ag-c-97A4B0,#97a4b0)]" />
</div>
</div>
)
}

/**
* An instructions markdown file row. Avatar + filename + a 2-line preview of the (markdown-stripped)
* content, clamped with an ellipsis. The whole row opens the editor drawer for the full content —
* there is no inline expand, so it reads the same as the tool / MCP rows.
*/
export function InstructionsFileRow({
filename,
content,
onOpen,
}: {
filename: string
content: string
onOpen: () => void
}) {
const descriptor = describeInstruction(filename, content)
const wordCount = content.trim().split(/\s+/).filter(Boolean).length
const meta =
wordCount > 0
? `Markdown · ${wordCount} word${wordCount === 1 ? "" : "s"}`
: "Markdown · empty"
return (
<div
role="button"
tabIndex={0}
onClick={onOpen}
onKeyDown={(e) => {
if (e.key === "Enter" || e.key === " ") {
e.preventDefault()
onOpen()
}
}}
className="group flex cursor-pointer items-start gap-3 rounded-lg border border-solid border-[var(--ag-c-EAEFF5,#eaeff5)] px-3 py-2.5 transition-colors hover:border-[var(--ag-c-97A4B0,#97a4b0)]"
>
<ItemAvatar descriptor={descriptor} />
<div className="min-w-0 flex-1">
{/* Identity row: filename (color inherits → theme-correct) + a muted meta, so the
name/type/size reads separately from the content preview below. */}
<div className="flex items-baseline gap-2">
<span className="truncate font-mono text-[13px] font-medium leading-tight">
{filename}
</span>
<Typography.Text type="secondary" className="shrink-0 text-[11px]">
{meta}
</Typography.Text>
</div>
{/* `descriptor.description` is the stripped-markdown preview (or "Empty file");
clamp to 2 lines so long instructions get a real "…" rather than a hard cut. */}
<Typography.Text
type="secondary"
className="mt-1 line-clamp-2 text-xs leading-snug"
>
{descriptor.description}
</Typography.Text>
</div>
<CaretRight size={15} className="mt-1 shrink-0 text-[var(--ag-c-97A4B0,#97a4b0)]" />
</div>
)
}
Original file line number Diff line number Diff line change
@@ -0,0 +1,32 @@
/**
* Small, pure helpers shared across the agent-template config sections. No React, no state — kept
* separate so the section components and the orchestrator can reuse them (and so they're testable).
*/
import type {SchemaProperty} from "@agenta/entities/shared"

/**
* 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.
*/
export function enumLabel(schema: SchemaProperty | undefined, value: unknown): string | null {
if (value == null || value === "") return null
const v = String(value)
const s = schema as Record<string, unknown> | undefined
const meta = s?.["x-model-metadata"] as Record<string, {name?: string}> | 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
}

/** "3 tools" / "1 server" / "None" — the count line shown in a collapsed section header. */
export const countSummary = (n: number, noun: string): string =>
n > 0 ? `${n} ${noun}${n === 1 ? "" : "s"}` : "None"

/** Deep-clone a config item so drawer edits don't alias the committed config object. */
export function cloneItem(item: unknown): Record<string, unknown> {
if (!item || typeof item !== "object") return {}
return JSON.parse(JSON.stringify(item)) as Record<string, unknown>
}
Loading
Loading