From aaec18238b032dc600e5006a62e66409ab01a850 Mon Sep 17 00:00:00 2001 From: ashrafchowdury Date: Fri, 4 Sep 2026 05:15:37 +0600 Subject: [PATCH 1/7] refactor(frontend): put the Advanced drawer on a section rail The three Advanced groups were stacked accordions in an 880px drawer, so auditing a configuration meant opening and closing them one at a time. Design 1a replaces them with the same section rail the tool and trigger drawers use: Permissions / Execution / Build kit, one panel at a time, in a 560px drawer. RailField gains a stacked layout, selected through a context so the sandbox, Claude and Pi permission controls keep rendering their own rows unchanged. The build-kit block drops its accordion chrome for a plain panel with the enable switch in its header. --- .../SchemaControls/AgentTemplateControl.tsx | 2 +- .../agentTemplate/BuildKitSection.tsx | 92 +++++------- .../agentTemplate/useBuildKit.tsx | 3 + .../agentTemplate/useModelHarness.tsx | 133 ++++++++++++++++-- .../src/drawers/shared/RailField.tsx | 80 ++++++++--- .../src/drawers/shared/SectionRail.tsx | 11 +- .../src/drawers/shared/index.ts | 2 +- .../entity-ui/BuildKitSection.stories.tsx | 50 +++---- 8 files changed, 253 insertions(+), 120 deletions(-) diff --git a/web/packages/agenta-entity-ui/src/DrillInView/SchemaControls/AgentTemplateControl.tsx b/web/packages/agenta-entity-ui/src/DrillInView/SchemaControls/AgentTemplateControl.tsx index ad124c888be..207c245b324 100644 --- a/web/packages/agenta-entity-ui/src/DrillInView/SchemaControls/AgentTemplateControl.tsx +++ b/web/packages/agenta-entity-ui/src/DrillInView/SchemaControls/AgentTemplateControl.tsx @@ -1222,7 +1222,7 @@ export const AgentTemplateControl = memo(function AgentTemplateControl({ onSave={saveSection} disabled={disabled || !sectionDirty} dirty={sectionDirty} - width={880} + width={mh.advancedDrawerWidth} > void /** Sandbox permission overlay, rendered read-only as `key → value` rows. */ permissions?: Record | null - /** Collapsed in the app (it is background information); stories open it. @default false */ - defaultOpen?: boolean } /** The build-kit block. Switchable tools get a switch each; the rest, and permissions, are read-only. */ @@ -60,7 +56,6 @@ export function BuildKitSection({ onToggleTool, onSetAllTools, permissions, - defaultOpen = false, }: BuildKitSectionProps) { // Per-tool switches only mean anything while the kit as a whole is on. const toolsDisabled = Boolean(disabled) || !enabled @@ -70,57 +65,45 @@ export function BuildKitSection({ // Nothing to switch means no bulk action — the button would be a dead control. const hasSwitchableTools = tools.some((tool) => tool.toggle) return ( - } - title="Playground build kit" - summary={ - - - Removed on commit +
+
+
+ Playground build kit + +
+ + These playground-only tools and permissions help the assistant build and revise + this agent. None of this is part of the published agent. - } - extra={ - - } - > - - These playground-only tools and permissions help the assistant build and revise this - agent. None of this is part of the published agent. - +
{!enabled ? (
The assistant can no longer create files, run code, or edit the agent here.
) : null} {tools.length > 0 ? ( - - Tools - - {enabledCount} of {tools.length} enabled - - {hasSwitchableTools ? ( - - ) : null} +
+
+ Tools + + {enabledCount} of {tools.length} enabled - } - > + {hasSwitchableTools ? ( + + ) : null} +
{tools.map(({key, descriptor, toggle}) => ( ))} - +
) : null} {permissions && Object.keys(permissions).length > 0 ? ( - +
+ Sandbox permissions
{Object.entries(permissions).map(([key, value]) => (
))}
- +
) : null} - +
) } diff --git a/web/packages/agenta-entity-ui/src/DrillInView/SchemaControls/agentTemplate/useBuildKit.tsx b/web/packages/agenta-entity-ui/src/DrillInView/SchemaControls/agentTemplate/useBuildKit.tsx index d5a87f81da2..0c8ef874a87 100644 --- a/web/packages/agenta-entity-ui/src/DrillInView/SchemaControls/agentTemplate/useBuildKit.tsx +++ b/web/packages/agenta-entity-ui/src/DrillInView/SchemaControls/agentTemplate/useBuildKit.tsx @@ -7,6 +7,7 @@ * revision) plus the user's build-kit state — the master on/off and the platform ops switched off * individually — and returns: * - `hasBuildKitOverlay`: whether to render the build-kit block / extend the Advanced section, + * - `buildKitEnabled`: the master on/off, for callers that flag the panel while it is live, * - `buildKitSection`: the drawer block (one tool list — platform tools with a switch each, the * Agenta-owned embeds locked on — plus sandbox permissions) under the master enable switch, * - `permissionOverrideHint`: the inline warning to show above SandboxPermissionControl when the @@ -186,6 +187,8 @@ export function useBuildKit({ return { hasBuildKitOverlay, + // The master on/off, so the Advanced rail can flag the panel while the overlay is live. + buildKitEnabled, buildKitSection, permissionOverrideHint, } diff --git a/web/packages/agenta-entity-ui/src/DrillInView/SchemaControls/agentTemplate/useModelHarness.tsx b/web/packages/agenta-entity-ui/src/DrillInView/SchemaControls/agentTemplate/useModelHarness.tsx index 3712ce51e9e..4e98c0d1a7e 100644 --- a/web/packages/agenta-entity-ui/src/DrillInView/SchemaControls/agentTemplate/useModelHarness.tsx +++ b/web/packages/agenta-entity-ui/src/DrillInView/SchemaControls/agentTemplate/useModelHarness.tsx @@ -2,7 +2,7 @@ * useModelHarness — the Model + Advanced sections (the panel's most stateful part). One * hook because the model/connection state feeds both; returns each section's summary + bodies. */ -import {useCallback, useEffect, useMemo, type ReactNode} from "react" +import {useCallback, useEffect, useMemo, useState, type ReactNode} from "react" import { customSecretsAtom, @@ -21,12 +21,13 @@ import {normalizeProviderFamily} from "@agenta/shared/utils" import {ConfigAccordionSection} from "@agenta/ui/components/presentational" import {useDrillInUI} from "@agenta/ui/drill-in" import {SelectLLMProviderBase} from "@agenta/ui/select-llm-provider" -import {Cube, ShieldCheck} from "@phosphor-icons/react" +import {Cube, ShieldCheck, Wrench} from "@phosphor-icons/react" import {atom, useAtomValue, useSetAtom} from "jotai" import {useHasChangedUnder, useRevertUnder} from "../../../drawers/shared/ChangedPathsContext" import {useFocusPaths, useHasFocusUnder} from "../../../drawers/shared/FocusPathsContext" -import {RailField} from "../../../drawers/shared/RailField" +import {FieldLayoutProvider, RailField} from "../../../drawers/shared/RailField" +import {SectionRail, type SectionRailItem} from "../../../drawers/shared/SectionRail" import {ClaudePermissionsControl} from "../ClaudePermissionsControl" import type {PickerSelection} from "../connectionPicker" import { @@ -69,6 +70,13 @@ const vaultLoadedAtom = atom((get) => Array.isArray(get(vaultSecretsQueryAtom).d // Shared with the chat composer's model palette so a hidden harness stays hidden everywhere. +/** One Advanced rail panel: its nav item, an optional header, and the controls it shows. */ +interface AdvancedPanel { + item: SectionRailItem + header?: {title: string; caption: string; extra?: ReactNode} + body: ReactNode +} + export function useModelHarness({ schema, config, @@ -377,12 +385,13 @@ export function useModelHarness({ // Playground-only "build kit" overlay (read-only) shown at the top of Advanced. It also flags // sandbox-permission keys the overlay overrides for the user's own permission control below. - const {hasBuildKitOverlay, buildKitSection, permissionOverrideHint} = useBuildKit({ - revisionId: revisionId ?? null, - sandboxPermissions: (sandbox.permissions as Record | null) ?? null, - disabled, - stateOverride: buildKitOverride, - }) + const {hasBuildKitOverlay, buildKitEnabled, buildKitSection, permissionOverrideHint} = + useBuildKit({ + revisionId: revisionId ?? null, + sandboxPermissions: (sandbox.permissions as Record | null) ?? null, + disabled, + stateOverride: buildKitOverride, + }) // Which Advanced sub-sections own an uncommitted change (see `ChangedPathsProvider`). Drives // `defaultOpen` so a drawer opened from a "something changed" indicator lands with the changed @@ -755,12 +764,110 @@ export function useModelHarness({ ) - // The stacked sections carry their own dividers; drop the trailing one on whichever section - // renders last (they're conditional, so target the last child rather than a fixed section). - const advancedDrawerBody = ( + // Rail panels: one per Advanced group, in the order the drawer nav lists them. Schema-gated, so + // a template without a group shows neither the rail item nor its panel. + const advancedPanels: AdvancedPanel[] = ( + [ + hasPermissionsGroup && { + item: { + value: "permissions", + label: "Permissions", + icon: , + status: permissionsChanged ? ("warning" as const) : undefined, + }, + header: { + title: "Permissions", + caption: "What the agent may do on its own before it must ask.", + extra: revertAction(permissionsChanged ? revertPermissions : null), + }, + body: permissionsBody, + }, + hasExecutionGroup && { + item: { + value: "execution", + label: "Execution", + icon: , + status: sandboxChanged ? ("warning" as const) : undefined, + }, + header: { + title: "Execution environment", + caption: + "Where the agent's tools and code run, and what that sandbox may touch.", + extra: revertAction(revertSandbox), + }, + body: executionBody, + }, + hasBuildKitOverlay && { + // Amber while the kit is on, for the same reason the group carried "Removed on commit": + // what this panel adds is playground-only and never reaches the committed agent. + item: { + value: "build-kit", + label: "Build kit", + icon: , + status: buildKitEnabled ? ("warning" as const) : undefined, + }, + // The block carries its own title + enable switch, so it needs no panel header. + body: buildKitSection, + }, + ] as (AdvancedPanel | false)[] + ).filter((panel): panel is AdvancedPanel => Boolean(panel)) + + // Land on the first panel that owns an uncommitted change, else the first one. + const initialAdvancedPanel = + advancedPanels.find((panel) => panel.item.status === "warning" && panel.header)?.item + .value ?? + advancedPanels[0]?.item.value ?? + "" + const [advancedPanelValue, setAdvancedPanelValue] = useState(initialAdvancedPanel) + const activeAdvancedPanel = + advancedPanels.find((panel) => panel.item.value === advancedPanelValue) ?? advancedPanels[0] + + const activeAdvancedPanelBody = ( +
+ {activeAdvancedPanel?.header ? ( +
+
+ + {activeAdvancedPanel.header.title} + + + {activeAdvancedPanel.header.caption} + +
+ {activeAdvancedPanel.header.extra} +
+ ) : null} + {activeAdvancedPanel?.body} +
+ ) + + // One panel needs no nav — a single-item rail is chrome around nothing. + const advancedRailBody = + advancedPanels.length > 1 ? ( + panel.item)} + value={activeAdvancedPanel?.item.value ?? ""} + onChange={setAdvancedPanelValue} + > + {activeAdvancedPanelBody} + + ) : ( + activeAdvancedPanelBody + ) + + // A focus filter narrows the body to the changed properties, where a nav would be noise — that + // path keeps the flat/grouped stack. Otherwise the rail is the drawer's shape. + const advancedDrawerBody = focus.active ? (
{advancedControls}
+ ) : ( + +
{advancedRailBody}
+
) return { @@ -779,5 +886,7 @@ export function useModelHarness({ hasAdvanced, advancedSummary, advancedDrawerBody, + // Rail + one panel at a time: no wider than the Model drawer. + advancedDrawerWidth: 560, } } diff --git a/web/packages/agenta-entity-ui/src/drawers/shared/RailField.tsx b/web/packages/agenta-entity-ui/src/drawers/shared/RailField.tsx index 2af8451449f..be760752aa1 100644 --- a/web/packages/agenta-entity-ui/src/drawers/shared/RailField.tsx +++ b/web/packages/agenta-entity-ui/src/drawers/shared/RailField.tsx @@ -9,7 +9,7 @@ * The content is capped at `max-w-prose` so inputs in a section panel keep a readable width instead * of stretching the full drawer. Styling uses antd semantic tokens (`--ag-color*`) only — dark-safe. */ -import type {ReactNode} from "react" +import {createContext, useContext, type ReactNode} from "react" import {cn} from "@agenta/ui/styles" import { @@ -27,6 +27,20 @@ import {ArrowCounterClockwise, Info} from "@phosphor-icons/react" import {useChangedDetail, useChangedPath, useRevertPath} from "./ChangedPathsContext" import {useIsPathVisible} from "./FocusPathsContext" +/** How a {@link RailField} lays its label out: the two-column rail rhythm, or label-above-control + * for panels too narrow to spare the 116px column. Context, like the two filters RailField reads. */ +const FieldLayoutContext = createContext<"rail" | "stacked">("rail") + +export function FieldLayoutProvider({ + layout, + children, +}: { + layout: "rail" | "stacked" + children: ReactNode +}) { + return {children} +} + export interface RailFieldProps { label: ReactNode /** Vertical alignment of the label against the content. @default "top" */ @@ -120,12 +134,53 @@ function ChangedDetail({ } export function RailField({label, align = "top", path, wide, children}: RailFieldProps) { + const layout = useContext(FieldLayoutContext) const changed = useChangedPath(path) const detail = useChangedDetail(path) const revert = useRevertPath(path) // Focus filter (see FocusPathsContext): each row self-filters on its own `path`. const visible = useIsPathVisible(path) if (!visible) return null + const stacked = layout === "stacked" + // The change shows as emphasis + a colorInfo dotted underline, so both states share one box. + const labelNode = changed ? ( + + + {/* role=button makes the Radix aria-haspopup/expanded attrs valid here. */} + + {label} + + + {/* antd Popover chrome: elevated panel with 12px inner padding. */} + + + + + ) : ( + label + ) + + // Stacked = the trigger drawers' flat field stack; `align`/`wide` are rail-only knobs. + if (stacked) { + return ( +
+ + {labelNode} + +
{children}
+
+ ) + } + return (
- {/* The label carries the change via emphasis (colorTextSecondary → colorText) plus a - colorInfo dotted underline — no marker glyph, so changed and unchanged rows share - the same box. */} - {changed ? ( - - - {/* role=button makes the Radix aria-haspopup/expanded attrs valid here. */} - - {label} - - - {/* antd Popover chrome: elevated panel with 12px inner padding. */} - - - - - ) : ( - label - )} + {labelNode}
- {item.label} + + {item.icon ? ( + + {item.icon} + + ) : null} + {item.label} + {item.status ? ( ( - } - title="Playground build kit" - summary={ - - - Removed on commit - - } - extra={} - > - - {CAPTION} - +
+
+
+ Playground build kit + +
+ + {CAPTION} + +
{!enabled ? (
{DISABLED_NOTE}
) : null} - +
+ + Platform tools + {PLATFORM_OPS.map((op) => ( ))} - - +
+
+ + Sandbox permissions +
{Object.entries(PERMISSIONS).map(([key, value]) => (
))}
- - +
+
) /** Pre-migration antd markup for the override hint. */ @@ -156,8 +154,6 @@ const BASE = { onToggleTool: () => undefined, onSetAllTools: () => undefined, permissions: PERMISSIONS, - // The app renders this collapsed; every story opens it so the body is visible/measured. - defaultOpen: true, } const Live = ({ @@ -228,13 +224,11 @@ export const PermissionsOnly: Story = { onToggleTool: () => undefined, onSetAllTools: () => undefined, permissions: PERMISSIONS, - defaultOpen: true, }, render: () => (
undefined} tools={[]} onToggleTool={() => undefined} From 7e6e578a65244aa40eeeee4bcd5de66da6b41872 Mon Sep 17 00:00:00 2001 From: ashrafchowdury Date: Fri, 4 Sep 2026 11:22:55 +0600 Subject: [PATCH 2/7] style(frontend): pare back the Advanced drawer's chrome MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Drop the header icon, the "Draft — applies on save" footer note and the Build kit rail's amber dot: each restated something the drawer already shows, and together they crowded a panel whose job is to stay quiet. Run the rail divider the full height of the body so it meets the header and footer rules instead of floating between them, and close up the space around the rail — the drawer's side inset drops to 12px (the vertical 16 stays, since that is what the divider's bleed negates), the gap to the divider to 8px, and the toggles' own side padding to 8px. The footer note came off SectionDrawer's default, so the Model drawer loses it and takes the same insets. --- .../SchemaControls/AgentTemplateControl.tsx | 1 - .../SchemaControls/SectionDrawer.tsx | 28 ++++++++----------- .../agentTemplate/useModelHarness.tsx | 18 ++++++------ .../src/drawers/shared/SectionRail.tsx | 18 ++++++++++-- 4 files changed, 35 insertions(+), 30 deletions(-) diff --git a/web/packages/agenta-entity-ui/src/DrillInView/SchemaControls/AgentTemplateControl.tsx b/web/packages/agenta-entity-ui/src/DrillInView/SchemaControls/AgentTemplateControl.tsx index 207c245b324..8a2cbf5569c 100644 --- a/web/packages/agenta-entity-ui/src/DrillInView/SchemaControls/AgentTemplateControl.tsx +++ b/web/packages/agenta-entity-ui/src/DrillInView/SchemaControls/AgentTemplateControl.tsx @@ -1217,7 +1217,6 @@ export const AgentTemplateControl = memo(function AgentTemplateControl({ } onCancel={cancelSection} onSave={saveSection} disabled={disabled || !sectionDirty} diff --git a/web/packages/agenta-entity-ui/src/DrillInView/SchemaControls/SectionDrawer.tsx b/web/packages/agenta-entity-ui/src/DrillInView/SchemaControls/SectionDrawer.tsx index adb36e11bc9..10fac79167d 100644 --- a/web/packages/agenta-entity-ui/src/DrillInView/SchemaControls/SectionDrawer.tsx +++ b/web/packages/agenta-entity-ui/src/DrillInView/SchemaControls/SectionDrawer.tsx @@ -4,7 +4,7 @@ * Right-hand drawer chrome for a whole config SECTION (Model & harness, Advanced) — as opposed to * the per-item `ConfigItemDrawer`. The accordion header opens it; the body is whatever the host * passes as children. The host owns the draft model (snapshot the config on open, restore on - * Cancel), so this is pure chrome: header (icon + title), a scrollable body, and Cancel/Save. + * Cancel), so this is pure chrome: header (title), a scrollable body, and Cancel/Save. * * Built on the shared `EnhancedDrawer`. */ @@ -24,7 +24,6 @@ export interface SectionDrawerProps { // When true, closing via scrim/X asks for confirmation instead of discarding silently. dirty?: boolean width?: number - footerNote?: ReactNode children: ReactNode } @@ -37,7 +36,6 @@ export function SectionDrawer({ disabled = false, dirty = false, width = 720, - footerNote = "Draft — applies on save", children, }: SectionDrawerProps) { const [confirmOpen, setConfirmOpen] = useState(false) @@ -65,23 +63,21 @@ export function SectionDrawer({
} footer={ -
- - {footerNote} - -
- - -
+
+ +
} // The body itself doesn't scroll — the content (a full-height flex row) gives each // panel its own overflow, so the left and right panels scroll independently. - styles={{body: {padding: 16, overflow: "hidden"}}} + // Tighter side inset than top/bottom: the rail sits right against the left edge, + // and 16px there read as a gutter. The vertical 16 is what the rail's `bleed` + // negates, so it stays put. + styles={{body: {padding: "16px 12px", overflow: "hidden"}}} > {children} diff --git a/web/packages/agenta-entity-ui/src/DrillInView/SchemaControls/agentTemplate/useModelHarness.tsx b/web/packages/agenta-entity-ui/src/DrillInView/SchemaControls/agentTemplate/useModelHarness.tsx index 4e98c0d1a7e..1a417c26f9c 100644 --- a/web/packages/agenta-entity-ui/src/DrillInView/SchemaControls/agentTemplate/useModelHarness.tsx +++ b/web/packages/agenta-entity-ui/src/DrillInView/SchemaControls/agentTemplate/useModelHarness.tsx @@ -385,13 +385,12 @@ export function useModelHarness({ // Playground-only "build kit" overlay (read-only) shown at the top of Advanced. It also flags // sandbox-permission keys the overlay overrides for the user's own permission control below. - const {hasBuildKitOverlay, buildKitEnabled, buildKitSection, permissionOverrideHint} = - useBuildKit({ - revisionId: revisionId ?? null, - sandboxPermissions: (sandbox.permissions as Record | null) ?? null, - disabled, - stateOverride: buildKitOverride, - }) + const {hasBuildKitOverlay, buildKitSection, permissionOverrideHint} = useBuildKit({ + revisionId: revisionId ?? null, + sandboxPermissions: (sandbox.permissions as Record | null) ?? null, + disabled, + stateOverride: buildKitOverride, + }) // Which Advanced sub-sections own an uncommitted change (see `ChangedPathsProvider`). Drives // `defaultOpen` so a drawer opened from a "something changed" indicator lands with the changed @@ -798,13 +797,10 @@ export function useModelHarness({ body: executionBody, }, hasBuildKitOverlay && { - // Amber while the kit is on, for the same reason the group carried "Removed on commit": - // what this panel adds is playground-only and never reaches the committed agent. item: { value: "build-kit", label: "Build kit", icon: , - status: buildKitEnabled ? ("warning" as const) : undefined, }, // The block carries its own title + enable switch, so it needs no panel header. body: buildKitSection, @@ -846,6 +842,8 @@ export function useModelHarness({ advancedPanels.length > 1 ? ( panel.item)} diff --git a/web/packages/agenta-entity-ui/src/drawers/shared/SectionRail.tsx b/web/packages/agenta-entity-ui/src/drawers/shared/SectionRail.tsx index 52a1a62f765..16e5dd8fb2f 100644 --- a/web/packages/agenta-entity-ui/src/drawers/shared/SectionRail.tsx +++ b/web/packages/agenta-entity-ui/src/drawers/shared/SectionRail.tsx @@ -40,6 +40,12 @@ export interface SectionRailProps { * internally-scrolling child. @default false (content-flow, natural height — the drawer case). */ fill?: boolean + /** + * Run the divider past the host's 16px top/bottom body padding so it meets the drawer's header + * and footer rules instead of floating between them. The padding is restored inside the panel, + * so the content sits where it did. @default false + */ + bleed?: boolean /** Right-hand content panel; separated from the rail by a left border. */ children: ReactNode } @@ -51,10 +57,11 @@ export function SectionRail({ railWidth = "w-[116px]", disabled = false, fill = false, + bleed = false, children, }: SectionRailProps) { return ( -
+
{items.map((item) => { const active = item.value === value @@ -64,7 +71,7 @@ export function SectionRail({ variant="ghost" disabled={disabled} onClick={() => onChange(item.value)} - className={`h-8 w-full rounded-md px-2.5 text-xs transition-colors ${ + className={`h-8 w-full rounded-md px-2 text-xs transition-colors ${ item.count != null || item.status ? "flex items-center justify-between" : "justify-start" @@ -99,7 +106,12 @@ export function SectionRail({ ) })}
-
+
{children}
From cf1dce95e9811384f9e590f911b753ef768402b0 Mon Sep 17 00:00:00 2001 From: ashrafchowdury Date: Fri, 4 Sep 2026 11:28:28 +0600 Subject: [PATCH 3/7] fix(frontend): give the chip inputs a real suggestion dropdown MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit ChipsInput offered its suggestions through a native , which draws an unstyled OS popup, opens on the browser's terms rather than the field's, and can't be driven from our own keyboard handling. The box also reserved 24px on the right for a caret it never drew. Build the list here instead, on the Popover + role=listbox + aria-activedescendant pattern Combobox already uses: it opens on focus, filters as you type, hides values already chipped, and takes arrows, Enter and Escape. Free text still chips on Enter — the options are suggestions, not a closed set — and the caret (with its inset) now appears only where there are suggestions to show. The input lives in the popover's ANCHOR, so every click and focus inside the field reads as an interaction OUTSIDE the layer and would dismiss the list on the tail of the very click that opened it. Guard both outside paths on whether the event came from the field, and focus the input by hand off the box chrome rather than letting the browser do it mid-gesture. Escape is swallowed while the list is open so it closes the list and not the drawer around it. Shared by the permission fields, SchemaForm and ParameterNodeEditor. --- .../components/schemaFormControls.tsx | 170 +++++++++++++++--- 1 file changed, 147 insertions(+), 23 deletions(-) diff --git a/web/packages/agenta-entity-ui/src/gatewayTool/components/schemaFormControls.tsx b/web/packages/agenta-entity-ui/src/gatewayTool/components/schemaFormControls.tsx index e6ad866e474..db7f0e91d59 100644 --- a/web/packages/agenta-entity-ui/src/gatewayTool/components/schemaFormControls.tsx +++ b/web/packages/agenta-entity-ui/src/gatewayTool/components/schemaFormControls.tsx @@ -7,7 +7,7 @@ * Every control takes plain `value`/`onChange`/`disabled` so it works both under antd * `Form.Item` cloning and as an explicitly controlled leaf. */ -import {useId, useRef, useState} from "react" +import {useEffect, useId, useMemo, useRef, useState} from "react" import {dayjs} from "@agenta/shared/utils" import { @@ -19,6 +19,9 @@ import { DropdownMenuSeparator, DropdownMenuTrigger, Input, + Popover, + PopoverAnchor, + PopoverContent, Switch, Select, SelectContent, @@ -234,8 +237,14 @@ export function MultiSelect({ } /** - * Free chip input (antd `Select mode="tags"` with `open={false}`): type + Enter adds a - * chip, Backspace on an empty input removes the last one. + * Free chip input (antd `Select mode="tags"`): type + Enter adds a chip, Backspace on an empty + * input removes the last one. + * + * `options` are SUGGESTIONS, not a closed set — the field still takes any typed value. They used + * to ride on a native ``, which renders an unstyled OS popup, opens only on some + * gestures and can't be keyboard-driven from our own markup. So the suggestion list is built here + * on the same Popover + `role=listbox` + `aria-activedescendant` pattern as `Combobox`, and looks + * like every other dropdown in the form. */ export function ChipsInput({ value, @@ -250,16 +259,50 @@ export function ChipsInput({ placeholder?: string disabled?: boolean id?: string - /** Suggested values, offered as native datalist completions (antd tags-mode `options`). */ + /** Suggested values, offered in a dropdown. Typing a value not in the list still works. */ options?: string[] }) { const [draft, setDraft] = useState("") - const listId = useId() + const [open, setOpen] = useState(false) + const [activeIndex, setActiveIndex] = useState(0) + const boxRef = useRef(null) const inputRef = useRef(null) + const listRef = useRef(null) const selected = value ?? [] - const commit = () => { - const trimmed = draft.trim() + // Stable ids so the input's aria-controls/aria-activedescendant can point at the listbox and + // the active row (WAI-ARIA combobox), the way `Combobox` does. + const rid = useId() + const listId = `${rid}-listbox` + const optionId = (index: number) => `${rid}-opt-${index}` + + // What's left to suggest: never a value already chipped, narrowed by what's typed so far. + const matches = useMemo(() => { + const taken = value ?? [] + const q = draft.trim().toLowerCase() + return (options ?? []).filter( + (o) => !taken.includes(o) && (!q || o.toLowerCase().includes(q)), + ) + }, [options, value, draft]) + + const hasSuggestions = Boolean(options?.length) + const showList = open && !disabled && matches.length > 0 + + /** Did this dismiss attempt come from the field itself (the anchor), rather than off it? */ + const fromBox = (e: {detail: {originalEvent: Event}}) => { + const target = e.detail.originalEvent.target + return target instanceof Node && Boolean(boxRef.current?.contains(target)) + } + + // A fresh query re-aims at the first row; the old index could point past the new list. + useEffect(() => setActiveIndex(0), [draft, open]) + useEffect(() => { + if (showList) + listRef.current?.querySelector("[data-active=true]")?.scrollIntoView({block: "nearest"}) + }, [activeIndex, showList]) + + const add = (raw: string) => { + const trimmed = raw.trim() setDraft("") if (!trimmed || selected.includes(trimmed)) return onChange?.([...selected, trimmed]) @@ -269,13 +312,26 @@ export function ChipsInput({ onChange?.(next.length ? next : undefined) } - return ( + const box = (
inputRef.current?.focus()} + onMouseDown={(e) => { + if (disabled) return + // Focus by hand off the box chrome (Combobox does the same): letting the browser + // do it mid-gesture opens the list on a `focus` the rest of the click then + // reads as an interaction outside, and the list shuts again on mouseup. + if (e.target !== inputRef.current) { + e.preventDefault() + inputRef.current?.focus() + } + if (hasSuggestions) setOpen(true) + }} className={cn( selectTriggerVariants({variant: "default", size: "default"}), MULTI_BOX_CLS, + // The right inset only exists to clear the caret; without suggestions there is none. + !hasSuggestions && "pr-1", "cursor-text", disabled && "cursor-not-allowed bg-disabled-bg text-disabled border-disabled-border", @@ -296,34 +352,102 @@ export function ChipsInput({ variant="ghost" disabled={disabled} aria-label={placeholder} + role={hasSuggestions ? "combobox" : undefined} + aria-expanded={hasSuggestions ? showList : undefined} + aria-controls={hasSuggestions ? listId : undefined} + aria-autocomplete={hasSuggestions ? "list" : undefined} + aria-activedescendant={showList ? optionId(activeIndex) : undefined} placeholder={selected.length === 0 ? placeholder : undefined} value={draft} - onChange={(e) => setDraft(e.target.value)} + onChange={(e) => { + setDraft(e.target.value) + if (hasSuggestions) setOpen(true) + }} + onFocus={() => hasSuggestions && setOpen(true)} onKeyDown={(e) => { - if (e.key === "Enter") { + if (hasSuggestions && (e.key === "ArrowDown" || e.key === "ArrowUp")) { + e.preventDefault() + if (!showList) return setOpen(true) + const dir = e.key === "ArrowDown" ? 1 : -1 + setActiveIndex((i) => (i + dir + matches.length) % matches.length) + } else if (e.key === "Enter") { + e.preventDefault() + add(showList ? (matches[activeIndex] ?? draft) : draft) + setOpen(false) + } else if (e.key === "Escape" && showList) { + // Swallowed, or the drawer hosting the field would close along with the list. e.preventDefault() - commit() + e.stopPropagation() + setOpen(false) } else if (e.key === "Backspace" && draft === "" && selected.length > 0) { removeAt(selected[selected.length - 1]) } }} - onBlur={commit} - list={options?.length ? listId : undefined} + onBlur={() => { + add(draft) + setOpen(false) + }} // antd's tags-mode search input is width-auto; an 80px floor pushed the caret // onto a second line as soon as the chips filled the row. className="h-6 min-w-[4px] flex-1 px-1" /> - {options?.length ? ( - - {options - .filter((o) => !selected.includes(o)) - .map((o) => ( - - ) : null} + {hasSuggestions && ( + + )}
) + + if (!hasSuggestions) return box + + return ( + + {box} + e.preventDefault()} + onCloseAutoFocus={(e) => e.preventDefault()} + // The input lives in the ANCHOR, so every click and focus inside the field reads + // as "outside the layer" to Radix and would dismiss the list the moment it opened. + onPointerDownOutside={(e) => fromBox(e) && e.preventDefault()} + onFocusOutside={(e) => fromBox(e) && e.preventDefault()} + className="w-[var(--radix-popover-trigger-width)] p-1 font-portal" + > +
+ {matches.map((o, index) => ( +
setActiveIndex(index)} + // Keeps the input focused, so `onBlur` doesn't chip the draft first. + onMouseDown={(e) => e.preventDefault()} + onClick={() => { + add(o) + setOpen(false) + inputRef.current?.focus() + }} + className="box-border flex min-h-control w-full cursor-pointer select-none items-center rounded-control-sm px-3 py-1 text-field-md data-[active=true]:bg-muted" + > + {o} +
+ ))} +
+
+
+ ) } /** From 289b4dd0403108d08b81882ac86bd8fa7d321756 Mon Sep 17 00:00:00 2001 From: ashrafchowdury Date: Fri, 4 Sep 2026 11:50:17 +0600 Subject: [PATCH 4/7] style(frontend): settle the config row title, the build-kit switch and its caption MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The item row's name was semibold at 13px, which read as a heading in a list of them and competed with the panel titles above it — drop it to normal weight. The build kit's enable switch was the default size while every per-tool switch under it is small, so the block's master control looked heavier than the rows it governs. Match them. Cut the build-kit caption from three lines to two: the heading already says "playground", and the tools list below says what the kit holds, so the caption only has to place the tools and say they never ship. ItemRow is shared, so the tools, subagents, skills and MCP lists take the lighter name too. --- .../SchemaControls/agentTemplate/BuildKitSection.tsx | 5 +++-- .../src/DrillInView/SchemaControls/agentTemplate/ItemRow.tsx | 2 +- 2 files changed, 4 insertions(+), 3 deletions(-) diff --git a/web/packages/agenta-entity-ui/src/DrillInView/SchemaControls/agentTemplate/BuildKitSection.tsx b/web/packages/agenta-entity-ui/src/DrillInView/SchemaControls/agentTemplate/BuildKitSection.tsx index 7a08703155c..07a39d99d06 100644 --- a/web/packages/agenta-entity-ui/src/DrillInView/SchemaControls/agentTemplate/BuildKitSection.tsx +++ b/web/packages/agenta-entity-ui/src/DrillInView/SchemaControls/agentTemplate/BuildKitSection.tsx @@ -70,6 +70,7 @@ export function BuildKitSection({
Playground build kit
- These playground-only tools and permissions help the assistant build and revise - this agent. None of this is part of the published agent. + Tools and permissions the assistant builds with here. None of it reaches the + published agent.
{!enabled ? ( diff --git a/web/packages/agenta-entity-ui/src/DrillInView/SchemaControls/agentTemplate/ItemRow.tsx b/web/packages/agenta-entity-ui/src/DrillInView/SchemaControls/agentTemplate/ItemRow.tsx index 4970c63182d..e834d2cb4a8 100644 --- a/web/packages/agenta-entity-ui/src/DrillInView/SchemaControls/agentTemplate/ItemRow.tsx +++ b/web/packages/agenta-entity-ui/src/DrillInView/SchemaControls/agentTemplate/ItemRow.tsx @@ -156,7 +156,7 @@ export function ItemRow({
From 8e723732a410201009ae2adce2cbdcf64db2d40c Mon Sep 17 00:00:00 2001 From: ashrafchowdury Date: Fri, 4 Sep 2026 12:11:17 +0600 Subject: [PATCH 5/7] style(frontend): widen the Advanced rail to 148px At 132px the rail's labels sat tight against their icons with the column ending well short of the panel it introduces. 16px more gives the three labels room without taking a meaningful bite out of the panel. --- .../SchemaControls/agentTemplate/useModelHarness.tsx | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/web/packages/agenta-entity-ui/src/DrillInView/SchemaControls/agentTemplate/useModelHarness.tsx b/web/packages/agenta-entity-ui/src/DrillInView/SchemaControls/agentTemplate/useModelHarness.tsx index 1a417c26f9c..3af16725f45 100644 --- a/web/packages/agenta-entity-ui/src/DrillInView/SchemaControls/agentTemplate/useModelHarness.tsx +++ b/web/packages/agenta-entity-ui/src/DrillInView/SchemaControls/agentTemplate/useModelHarness.tsx @@ -845,7 +845,7 @@ export function useModelHarness({ // The drawer body is the rail's only host, so the divider runs its full height. bleed // Wider than the default rail: these labels carry an icon as well. - railWidth="w-[132px]" + railWidth="w-[148px]" items={advancedPanels.map((panel) => panel.item)} value={activeAdvancedPanel?.item.value ?? ""} onChange={setAdvancedPanelValue} From f604a13369baf31950daba2bd37ef450872ad9c5 Mon Sep 17 00:00:00 2001 From: ashrafchowdury Date: Fri, 4 Sep 2026 12:18:40 +0600 Subject: [PATCH 6/7] fix(frontend): let the keyboard open a changed field's detail The changed-label trigger was a span with role="button", which sets the ARIA contract without making the element focusable. Keyboard users could neither open the committed-value popover nor reach its Restore button. Use a real button, stripped to the label's own type and colour, with a focus-visible ring so it shows where the focus lands. --- .../agenta-entity-ui/src/drawers/shared/RailField.tsx | 10 +++++----- 1 file changed, 5 insertions(+), 5 deletions(-) diff --git a/web/packages/agenta-entity-ui/src/drawers/shared/RailField.tsx b/web/packages/agenta-entity-ui/src/drawers/shared/RailField.tsx index be760752aa1..11ac1c39793 100644 --- a/web/packages/agenta-entity-ui/src/drawers/shared/RailField.tsx +++ b/web/packages/agenta-entity-ui/src/drawers/shared/RailField.tsx @@ -146,13 +146,13 @@ export function RailField({label, align = "top", path, wide, children}: RailFiel const labelNode = changed ? ( - {/* role=button makes the Radix aria-haspopup/expanded attrs valid here. */} - {label} - + {/* antd Popover chrome: elevated panel with 12px inner padding. */} From 7ec5fca2e6adfb754dffa8d5df573415c876cb87 Mon Sep 17 00:00:00 2001 From: ashrafchowdury Date: Fri, 4 Sep 2026 12:18:55 +0600 Subject: [PATCH 7/7] chore(frontend): cut the new comments down to one line web/AGENTS.md sets a hard one-short-line rule for in-code comments, and this branch added several multi-line blocks narrating why. Trim them to a line each, keeping only the two that record a real trap: the popover anchor Radix reads as "outside", and the drawer padding the rail's bleed negates. --- .../SchemaControls/SectionDrawer.tsx | 4 +--- .../agentTemplate/useModelHarness.tsx | 6 ++--- .../src/drawers/shared/RailField.tsx | 3 +-- .../src/drawers/shared/SectionRail.tsx | 6 +---- .../components/schemaFormControls.tsx | 23 ++++++------------- 5 files changed, 12 insertions(+), 30 deletions(-) diff --git a/web/packages/agenta-entity-ui/src/DrillInView/SchemaControls/SectionDrawer.tsx b/web/packages/agenta-entity-ui/src/DrillInView/SchemaControls/SectionDrawer.tsx index 10fac79167d..d21a3854205 100644 --- a/web/packages/agenta-entity-ui/src/DrillInView/SchemaControls/SectionDrawer.tsx +++ b/web/packages/agenta-entity-ui/src/DrillInView/SchemaControls/SectionDrawer.tsx @@ -74,9 +74,7 @@ export function SectionDrawer({ } // The body itself doesn't scroll — the content (a full-height flex row) gives each // panel its own overflow, so the left and right panels scroll independently. - // Tighter side inset than top/bottom: the rail sits right against the left edge, - // and 16px there read as a gutter. The vertical 16 is what the rail's `bleed` - // negates, so it stays put. + // Tighter at the sides; the vertical 16 is what the rail's `bleed` negates. styles={{body: {padding: "16px 12px", overflow: "hidden"}}} > {children} diff --git a/web/packages/agenta-entity-ui/src/DrillInView/SchemaControls/agentTemplate/useModelHarness.tsx b/web/packages/agenta-entity-ui/src/DrillInView/SchemaControls/agentTemplate/useModelHarness.tsx index 3af16725f45..1ef2d9f25d0 100644 --- a/web/packages/agenta-entity-ui/src/DrillInView/SchemaControls/agentTemplate/useModelHarness.tsx +++ b/web/packages/agenta-entity-ui/src/DrillInView/SchemaControls/agentTemplate/useModelHarness.tsx @@ -763,8 +763,7 @@ export function useModelHarness({ ) - // Rail panels: one per Advanced group, in the order the drawer nav lists them. Schema-gated, so - // a template without a group shows neither the rail item nor its panel. + // One rail panel per Advanced group, schema-gated: no group, no rail item and no panel. const advancedPanels: AdvancedPanel[] = ( [ hasPermissionsGroup && { @@ -856,8 +855,7 @@ export function useModelHarness({ activeAdvancedPanelBody ) - // A focus filter narrows the body to the changed properties, where a nav would be noise — that - // path keeps the flat/grouped stack. Otherwise the rail is the drawer's shape. + // Under a focus filter the body is already narrowed, so it keeps the stack over the rail. const advancedDrawerBody = focus.active ? (
{advancedControls} diff --git a/web/packages/agenta-entity-ui/src/drawers/shared/RailField.tsx b/web/packages/agenta-entity-ui/src/drawers/shared/RailField.tsx index 11ac1c39793..4349ac9ee4c 100644 --- a/web/packages/agenta-entity-ui/src/drawers/shared/RailField.tsx +++ b/web/packages/agenta-entity-ui/src/drawers/shared/RailField.tsx @@ -27,8 +27,7 @@ import {ArrowCounterClockwise, Info} from "@phosphor-icons/react" import {useChangedDetail, useChangedPath, useRevertPath} from "./ChangedPathsContext" import {useIsPathVisible} from "./FocusPathsContext" -/** How a {@link RailField} lays its label out: the two-column rail rhythm, or label-above-control - * for panels too narrow to spare the 116px column. Context, like the two filters RailField reads. */ +/** How a {@link RailField} lays its label out: two-column rail, or label above the control. */ const FieldLayoutContext = createContext<"rail" | "stacked">("rail") export function FieldLayoutProvider({ diff --git a/web/packages/agenta-entity-ui/src/drawers/shared/SectionRail.tsx b/web/packages/agenta-entity-ui/src/drawers/shared/SectionRail.tsx index 16e5dd8fb2f..c2583219dbb 100644 --- a/web/packages/agenta-entity-ui/src/drawers/shared/SectionRail.tsx +++ b/web/packages/agenta-entity-ui/src/drawers/shared/SectionRail.tsx @@ -40,11 +40,7 @@ export interface SectionRailProps { * internally-scrolling child. @default false (content-flow, natural height — the drawer case). */ fill?: boolean - /** - * Run the divider past the host's 16px top/bottom body padding so it meets the drawer's header - * and footer rules instead of floating between them. The padding is restored inside the panel, - * so the content sits where it did. @default false - */ + /** Bleed the divider past the host's 16px vertical padding, onto its rules. @default false */ bleed?: boolean /** Right-hand content panel; separated from the rail by a left border. */ children: ReactNode diff --git a/web/packages/agenta-entity-ui/src/gatewayTool/components/schemaFormControls.tsx b/web/packages/agenta-entity-ui/src/gatewayTool/components/schemaFormControls.tsx index db7f0e91d59..0e98a7e7b89 100644 --- a/web/packages/agenta-entity-ui/src/gatewayTool/components/schemaFormControls.tsx +++ b/web/packages/agenta-entity-ui/src/gatewayTool/components/schemaFormControls.tsx @@ -237,14 +237,10 @@ export function MultiSelect({ } /** - * Free chip input (antd `Select mode="tags"`): type + Enter adds a chip, Backspace on an empty - * input removes the last one. + * Free chip input (antd `Select mode="tags"`): type + Enter chips, Backspace on an empty input + * removes the last. `options` suggest; any typed value still lands. * - * `options` are SUGGESTIONS, not a closed set — the field still takes any typed value. They used - * to ride on a native ``, which renders an unstyled OS popup, opens only on some - * gestures and can't be keyboard-driven from our own markup. So the suggestion list is built here - * on the same Popover + `role=listbox` + `aria-activedescendant` pattern as `Combobox`, and looks - * like every other dropdown in the form. + * Suggestions use the `Combobox` pattern, not a `` the keyboard can't reach. */ export function ChipsInput({ value, @@ -270,8 +266,7 @@ export function ChipsInput({ const listRef = useRef(null) const selected = value ?? [] - // Stable ids so the input's aria-controls/aria-activedescendant can point at the listbox and - // the active row (WAI-ARIA combobox), the way `Combobox` does. + // Stable ids for the input's aria-controls/aria-activedescendant (WAI-ARIA combobox). const rid = useId() const listId = `${rid}-listbox` const optionId = (index: number) => `${rid}-opt-${index}` @@ -318,9 +313,7 @@ export function ChipsInput({ data-slot="chips-input" onMouseDown={(e) => { if (disabled) return - // Focus by hand off the box chrome (Combobox does the same): letting the browser - // do it mid-gesture opens the list on a `focus` the rest of the click then - // reads as an interaction outside, and the list shuts again on mouseup. + // Focus by hand: browser focus opens a list the same click dismisses. if (e.target !== inputRef.current) { e.preventDefault() inputRef.current?.focus() @@ -387,8 +380,7 @@ export function ChipsInput({ add(draft) setOpen(false) }} - // antd's tags-mode search input is width-auto; an 80px floor pushed the caret - // onto a second line as soon as the chips filled the row. + // Width-auto like antd's tags input: a min-width floor wraps the caret. className="h-6 min-w-[4px] flex-1 px-1" /> {hasSuggestions && ( @@ -411,8 +403,7 @@ export function ChipsInput({ // Focus stays in the input — the list is driven by aria-activedescendant. onOpenAutoFocus={(e) => e.preventDefault()} onCloseAutoFocus={(e) => e.preventDefault()} - // The input lives in the ANCHOR, so every click and focus inside the field reads - // as "outside the layer" to Radix and would dismiss the list the moment it opened. + // The input is in the ANCHOR, so Radix reads clicks in the field as outside. onPointerDownOutside={(e) => fromBox(e) && e.preventDefault()} onFocusOutside={(e) => fromBox(e) && e.preventDefault()} className="w-[var(--radix-popover-trigger-width)] p-1 font-portal"