Files
krow_talent_app/src/components/agents/AgentConfigure.tsx
Aravind dde4ba62c6 refactor(ts-migration): Phase 11 batch 6 — agent and skill components
The fifteen files under `src/components/agents/` (including `skills/`)
and `src/components/skills/`. 15/15 erase byte-identically; bundle hash
unchanged.

Renaming raised 43 errors and 29 of them came from one thing: these
files carry 24 `/** @param {any} props */` annotations, one per
component, and JSDoc stops applying at the extension boundary. Without
them TypeScript infers every destructured prop as required, so a call
site passing seven of nine props is an error — which is how a file that
declared its props `any` ended up with fourteen complaints about missing
`className`. Restoring the author's own declaration as `: any` is not
blanket typing; it is the annotation that was already there, in the only
form that still works.

`Workspace` in `AgentCanvas` was the one component in that file its
author left without the hatch. It now matches its siblings.

The rest were five separate things:

  - `React.isValidElement(children)` no longer narrows enough to read
    `children.props.id`: React 19 types `ReactElement`'s props as
    `unknown`. `isValidElement<any>` says what the `cloneElement` call
    beneath it has always assumed. The migration plan predicted this
    site by name.
  - `useSkillSections(page, placement)` is called with one argument by
    `UiEditingProvider`, which its doc comment explicitly permits —
    "called with no placement it returns every section on the page". The
    parameter simply lacked its optional marker. The marker erases, so
    the emitted signature is unchanged.
  - `new Date(b.at) - new Date(a.at)` is valueOf coercion, which
    JavaScript performs and TypeScript refuses to describe. Cast rather
    than rewritten to `.getTime()`: that would change the emitted code,
    and this comparison orders the list.
  - `Object.values<any>` on a tally, the same inference gap as earlier
    batches, which also fixed a `ReactNode` complaint downstream of it.

All fifteen are linted under their new extensions, checked by name.

Measured against `446df7b`:

  typecheck   6 errors, down from 9; no new error anywhere
  lint        exit 0, 0 errors, 289 warnings, 15/15 linted by name
  npm test    1684/1691, the same 7 failures verbatim
  build       exit 0, identical bundle hash 74d17e2d…
  type erasure  72/72 byte-identical across Phase 11 so far

No baseline artifact touched.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01HBG1wnuRfJKCstGB8Fekr8
2026-09-18 15:32:14 +05:30

865 lines
37 KiB
TypeScript

import * as React from 'react';
import {
BookOpen, Boxes, Brain, FileText, Globe, IdCard, Layers, LayoutGrid, MessageSquare, Plus,
SlidersHorizontal, Trash2, Wrench, X,
} from 'lucide-react';
import {
Alert, Button, Field, Input, SegmentedToggle, Select, SelectContent, SelectItem, SelectTrigger,
SelectValue, Switch, Textarea,
} from '@/components/ds';
import { DOMAIN_SURFACES, surfaceFor } from '@/lib/skills/surfaces';
import { getSkillsForPage } from '@/lib/skills/registry';
import { AGENT_ICONS, KNOWLEDGE_KINDS, REASONING_MODES } from '@/lib/agents/vocabulary';
import {
AgentPreview, Collapse, DocField, DocSection, GroupHead, IconPicker, ItemRow, Rail, ScopeChip,
SettingRow, Workspace,
} from './AgentCanvas';
/**
* The agent configuration workspace.
*
* Four sections of one document — Identity, Instructions, Capabilities,
* Behavior — with a rail beside them that lists the same four and what is
* inside each. The rail is the page's table of contents and its scroll
* position; it is never a second source of state, which is why every value it
* shows is read from `fields` at render rather than tracked.
*
* Every control writes a *field*. The caller composes frontmatter from those
* fields at save time, which is what keeps Markdown the storage format while a
* recruiter never meets it.
*/
const SECTION_IDS = ['identity', 'instructions', 'capabilities', 'behavior'];
/**
* Which section the reader is in.
*
* Observed rather than set on click, so scrolling the document moves the rail
* too — a table of contents that only updates when you use it is a menu, not a
* position.
*
* Read from the sections' own top edges against a marker just below the sticky
* header, rather than from intersection ratios. A ratio-based spy cannot
* resolve the case this document actually has: Capabilities is long enough that
* it is still crossing the reading band when Behavior arrives, so "the first
* one intersecting" stays on Capabilities for the rest of the page. The last
* section gets the same exemption for the same reason in reverse — the document
* runs out of scroll before its top reaches the marker, so at the end of the
* page it is simply what is being read.
*/
function useActiveSection(ids) {
const [active, setActive] = React.useState(ids[0]);
React.useEffect(() => {
if (typeof window === 'undefined') return undefined;
let frame = 0;
const measure = () => {
frame = 0;
const marker = 140;
let current = ids[0];
for (const id of ids) {
const top = document.getElementById(id)?.getBoundingClientRect().top;
if (top !== undefined && top <= marker) current = id;
}
/* Only when the document genuinely scrolls. With every section closed it
fits on one screen, and "the end of the page" is then also its start —
which would light the last section while the reader is looking at the
first. */
const doc = document.documentElement;
const scrolls = doc.scrollHeight > window.innerHeight + 8;
const atBottom = window.innerHeight + window.scrollY >= doc.scrollHeight - 4;
if (scrolls && atBottom) current = ids[ids.length - 1];
setActive(current);
};
const schedule = () => {
if (!frame) frame = requestAnimationFrame(measure);
};
measure();
window.addEventListener('scroll', schedule, { passive: true });
window.addEventListener('resize', schedule);
return () => {
if (frame) cancelAnimationFrame(frame);
window.removeEventListener('scroll', schedule);
window.removeEventListener('resize', schedule);
};
}, [ids]);
return [active, setActive];
}
export function AgentConfigure({
fields, agents, customSkills = [], onChange, onOpenSkills, scopeLocked = false,
/* Attaching and detaching a skill goes through the caller's existing write
path, NOT through `set`. Every other control here writes a draft that Save
persists; a skill persists immediately, on purpose — the whole point of the
catalog is that the agent gains the capability there and then. Two write
paths for one field would mean the Configure screen and the catalog
disagreed about when a skill takes effect. */
onToggleSkill = null, pendingSkill = null,
/* The tools this deployment registers, from GET /api/v1/tools. Passed in
rather than fetched here so this component stays a form over `fields`. */
toolCatalogue = [],
}: any) {
/* Sections are open by default: this is a document, and one that greets its
author with four closed headers hides the thing they came to write. Closing
is for focus, not for the initial reading. */
const [open, setOpen] = React.useState(
() => Object.fromEntries(SECTION_IDS.map((id) => [id, true]))
);
const [setting, setSetting] = React.useState(/** @type {string | null} */ (null));
const [active, setActive] = useActiveSection(SECTION_IDS);
/**
* The skills these pages offer, and the ones not yet attached.
*
* Derived at render from `fields.pages` rather than held in state, for the
* same reason the rail is: a second copy of a list that is already on screen
* is a second thing to keep correct, and the one that drifts is always the
* copy.
*
* Deduplicated by id because an agent covering several pages will be offered
* the same skill by each of them.
*/
const availableSkills = React.useMemo(() => {
const seen = new Map();
for (const page of fields.pages) {
for (const skill of getSkillsForPage(page, { customSources: customSkills })) {
if (!seen.has(skill.id)) seen.set(skill.id, skill);
}
}
return [...seen.values()].sort((a, b) => a.name.localeCompare(b.name));
}, [fields.pages, customSkills]);
const attachableSkills = React.useMemo(
() => availableSkills.filter((sk) => !fields.skills.includes(sk.id)),
[availableSkills, fields.skills]
);
const set = (patch) => onChange({ ...fields, ...patch });
const instructionWords = React.useMemo(
() => (fields.instructions.trim() ? fields.instructions.trim().split(/\s+/).length : 0),
[fields.instructions]
);
const availableSubagents = React.useMemo(
() => agents.filter((a) => a.id !== fields.id && a.status === 'published'),
[agents, fields.id]
);
const reasoning = REASONING_MODES.find((m) => m.id === fields.reasoning);
const pageLabel = (p) => surfaceFor(p)?.label || p;
const coveredLabels = fields.pages.map(pageLabel);
const starterCount = fields.starters.filter((s) => s.label.trim()).length;
/**
* Going somewhere.
*
* A closed section is still addressable — the rail lists what is inside it,
* and following one of those leaves has to arrive at the field rather than at
* a header that happens to be shut. Opening first, scrolling on the next
* frame, so the target has a height by the time it is scrolled to.
*/
const goTo = React.useCallback((sectionId, itemId) => {
setOpen((current) => (current[sectionId] ? current : { ...current, [sectionId]: true }));
if (itemId && itemId.startsWith('behavior-')) setSetting(itemId);
setActive(sectionId);
requestAnimationFrame(() => {
requestAnimationFrame(() => {
const target = document.getElementById(itemId || sectionId)
|| document.getElementById(sectionId);
target?.scrollIntoView({ behavior: 'smooth', block: 'start' });
});
});
}, [setActive]);
const toggleSection = (id) => setOpen((current) => ({ ...current, [id]: !current[id] }));
const toggleSetting = (id) => setSetting((current) => (current === id ? null : id));
/* The rail's model. Derived from the same fields the document renders, so a
count on the rail cannot disagree with the list under it. */
const sections = [
{
id: 'identity',
icon: IdCard,
label: 'Identity',
meta: '',
items: [
{ id: 'identity-name', label: 'Name' },
{ id: 'identity-description', label: 'Description' },
{ id: 'identity-icon', label: 'Icon' },
{ id: 'identity-trigger', label: 'When to use' },
],
},
{
id: 'instructions',
icon: FileText,
label: 'Instructions',
meta: instructionWords ? `${instructionWords}w` : '',
items: [{ id: 'instructions-editor', label: 'Editor' }],
},
{
id: 'capabilities',
icon: Boxes,
label: 'Capabilities',
meta: `${fields.pages.length + fields.skills.length + fields.knowledge.length}`,
items: [
{ id: 'capabilities-pages', label: 'Pages', meta: fields.pages.length },
{ id: 'capabilities-skills', label: 'Skills', meta: fields.skills.length },
{ id: 'capabilities-knowledge', label: 'Knowledge', meta: fields.knowledge.length },
],
},
{
id: 'behavior',
icon: SlidersHorizontal,
label: 'Behavior',
meta: reasoning?.label || '',
items: [
{ id: 'behavior-reasoning', label: 'Reasoning', meta: reasoning?.label },
{ id: 'behavior-starters', label: 'Starters', meta: starterCount },
{ id: 'behavior-web', label: 'Web search', meta: fields.webSearch ? 'On' : 'Off' },
{ id: 'behavior-subagents', label: 'Subagents', meta: fields.subagents.length },
],
},
];
return (
<>
<Workspace rail={<Rail sections={sections} activeId={active} onSelect={goTo} />}>
{/* ── Identity ───────────────────────────────────────────────────── */}
<DocSection
id="identity"
icon={IdCard}
title="Identity"
description="How this agent is presented to people using Owliver."
open={open.identity}
onToggle={() => toggleSection('identity')}
>
<div className="grid grid-cols-1 lg:grid-cols-12 gap-4 items-start">
<div className="lg:col-span-7 xl:col-span-7 space-y-3">
<DocField id="identity-name" label="Name">
<Input
value={fields.name}
placeholder="e.g. Positions Agent"
onChange={(e) => set({ name: e.target.value })}
className="w-full"
/>
</DocField>
<DocField
id="identity-description"
label="Description"
hint="One line, shown under the name when choosing an agent."
>
<Input
value={fields.description}
placeholder="e.g. Open roles — what they need and which are at risk."
onChange={(e) => set({ description: e.target.value })}
className="w-full"
/>
</DocField>
<DocField
id="identity-trigger"
label="When to use it"
hint="Helps a reader pick the right agent. Never matched against questions."
>
<Textarea
value={fields.trigger}
rows={3}
placeholder="e.g. Use on Positions, for open roles and applicant flow."
onChange={(e) => set({ trigger: e.target.value })}
className="w-full resize-y"
/>
</DocField>
</div>
<div className="lg:col-span-5 xl:col-span-5 space-y-3">
<AgentPreview
name={fields.name}
description={fields.description}
icon={fields.icon}
/>
<div id="identity-icon" className="scroll-mt-28 rounded-xl border border-border bg-surface-subtle/50 p-3">
<p className="mb-1.5 text-body-sm font-medium text-ink-2">Icon</p>
<IconPicker
icons={AGENT_ICONS}
value={fields.icon}
onChange={(icon) => set({ icon })}
/>
</div>
</div>
</div>
</DocSection>
{/* ── Instructions ───────────────────────────────────────────────── */}
<DocSection
id="instructions"
icon={FileText}
title="Instructions"
description="How this agent should answer, what it is responsible for, and what it should say when it cannot help."
meta={`${instructionWords} word${instructionWords === 1 ? '' : 's'}`}
open={open.instructions}
onToggle={() => toggleSection('instructions')}
emphasis
>
<div
id="instructions-editor"
className={`w-full scroll-mt-28 overflow-hidden rounded-xl border border-border
bg-surface transition-all duration-base ease-out
hover:border-border focus-within:border-krow-blue/50
focus-within:ring-2 focus-within:ring-krow-blue/15`}
>
<Textarea
value={fields.instructions}
rows={8}
placeholder={'Answer from the records this page holds.\n\nState a figure only where a skill has read it. When a reading needs a position and none is open, ask which rather than choosing one.'}
onChange={(e) => set({ instructions: e.target.value })}
className={`min-h-[11rem] resize-y rounded-none border-0 bg-transparent px-4 py-3
text-body-md leading-relaxed focus-visible:ring-0`}
/>
<div className="flex items-center justify-between gap-3 border-t border-border bg-surface-subtle px-4 py-2">
<span className="text-caption tabular-nums text-ink-4">
{instructionWords} word{instructionWords === 1 ? '' : 's'}
</span>
<span className="text-caption text-ink-4">Saved with the agent definition</span>
</div>
</div>
</DocSection>
{/* ── Capabilities ───────────────────────────────────────────────── */}
<DocSection
id="capabilities"
icon={Boxes}
title="Capabilities"
description="What this agent can reach and use when it answers."
meta={`${fields.pages.length} page${fields.pages.length === 1 ? '' : 's'} · ${fields.knowledge.length} knowledge`}
open={open.capabilities}
onToggle={() => toggleSection('capabilities')}
>
<div className="divide-y divide-border/60">
{/* Pages ── the boundary. */}
<div className="pb-4">
<GroupHead
id="capabilities-pages"
icon={LayoutGrid}
title="Pages it covers"
count={fields.pages.length}
/>
<p className="pb-2 text-caption leading-relaxed text-ink-3">
{coveredLabels.length
? <>Answers on <span className="font-medium text-ink-1">{coveredLabels.join(', ')}</span>. Anywhere else it is shown as constrained and declines.</>
: 'No page selected — this agent has nowhere to answer yet.'}
</p>
{/**
* Scope, not a page picker.
*
* This was every id in the vocabulary — eighteen chips including
* Settings, Workspace, Skills and Skill Configure — so the
* Activity Agent could be pointed at the workspace configuration
* screen. That is not a choice anyone should be offered: those
* surfaces hold no workforce records (they declare
* `placements: []`, which is what `DOMAIN_SURFACES` reads), and an
* agent aimed at one has nothing to answer from.
*
* A shipped agent is a *registered domain agent* — its scope is
* part of the definition Krow ships, and the skills it may carry
* are derived from it. So it is stated rather than edited. An
* agent this workspace authored still chooses, from the pages
* that are actually product domains.
*/}
{scopeLocked ? (
<div className="flex flex-wrap gap-1.5">
{fields.pages.map((page) => (
<span
key={page}
className="rounded-full border border-krow-blue/40 bg-krow-blue-tint px-2.5 py-1 text-caption font-medium text-krow-blue"
>
{pageLabel(page)}
</span>
))}
{!fields.pages.length && (
<span className="text-body-sm text-ink-3">No surface registered.</span>
)}
</div>
) : (
<div className="flex flex-wrap gap-1.5">
{/* Domain surfaces, plus anything this definition already
declares — so an agent written by hand against a
configuration surface keeps it rather than losing it
silently on the next save. */}
{[...new Set([...DOMAIN_SURFACES, ...fields.pages])].map((page) => (
<ScopeChip
key={page}
active={fields.pages.includes(page)}
onClick={() => set({
pages: fields.pages.includes(page)
? fields.pages.filter((p) => p !== page)
: [...fields.pages, page],
})}
>
{pageLabel(page)}
</ScopeChip>
))}
</div>
)}
<p className="mt-2 text-caption leading-relaxed text-ink-4">
{scopeLocked
? 'This agent ships with Krow, and its surface is part of that definition. Its scope decides which capabilities it can carry.'
: 'A page decides which skills exist there. An agent chooses among them — it can narrow that list, never widen it.'}
</p>
</div>
{/* Skills ── what it can actually do.
The section this screen was missing. Skills were attachable from
the catalog and invisible here, on the one page that lists
everything else an agent carries — so the reading was that this
agent had none.
Availability is bounded by the pages above, which is the rule the
copy under Pages already states: a page decides which skills
exist there, and an agent chooses among them. It can narrow that
list, never widen it. */}
<div className="pt-4">
<GroupHead
id="capabilities-skills"
icon={Boxes}
title="Skills"
count={fields.skills.length}
action={onOpenSkills && (
<Button variant="ghost" size="sm" onClick={onOpenSkills}>
Browse catalog
</Button>
)}
/>
{!fields.pages.length ? (
<p className="mt-2 text-body-sm text-ink-3">
Choose a page first. Skills belong to pages, so there are none to offer until
this agent has somewhere to answer.
</p>
) : (
<div className="mt-2 space-y-3">
{fields.skills.length > 0 && (
<ul className="divide-y divide-border">
{fields.skills.map((id) => {
const skill = availableSkills.find((sk) => sk.id === id);
return (
<ItemRow
key={id}
icon={Boxes}
title={skill?.name || id}
detail={skill?.description}
/* A skill the pages no longer offer is still
attached and still listed — silently dropping it
would edit the definition behind the author's
back. Flagged instead, so removing it is their
decision. */
warning={skill ? null : 'Not available on the pages above.'}
removeLabel={`Remove ${skill?.name || id}`}
onRemove={onToggleSkill ? () => onToggleSkill(id) : undefined}
/>
);
})}
</ul>
)}
{!fields.skills.length && (
<p className="text-body-sm text-ink-3">
No skills attached. This agent answers from the page&apos;s own reader only.
</p>
)}
<Select
value=""
disabled={!onToggleSkill || Boolean(pendingSkill)}
onValueChange={(id) => !fields.skills.includes(id) && onToggleSkill?.(id)}
>
<SelectTrigger className="w-full sm:w-72">
<SelectValue placeholder={
pendingSkill ? 'Saving...' : 'Add a skill...'
} />
</SelectTrigger>
<SelectContent>
{attachableSkills.map((sk) => (
<SelectItem key={sk.id} value={sk.id}>{sk.name}</SelectItem>
))}
</SelectContent>
</Select>
<p className="text-caption leading-relaxed text-ink-4">
{attachableSkills.length
? 'Attaching a skill takes effect immediately — it does not wait for Save.'
: 'Every skill these pages offer is already attached.'}
</p>
</div>
)}
</div>
{/* Tools ── what it can actually do.
Skills are guidance the model reads; tools are the calls it may
make. An agent with skills and no tools discusses the work and
looks nothing up, which is what every agent authored here was
before this section existed — the editor had no field for it and
the parser dropped `tools:` on the way in.
The catalogue is served by the backend rather than listed here,
so a tool renamed or withdrawn cannot leave a stale option in
this form and an agent built from one that no longer exists. */}
<div className="pt-4">
<GroupHead
id="capabilities-tools"
icon={Wrench}
title="Tools"
count={fields.tools.length}
/>
<div className="mt-2 space-y-3">
{fields.tools.length > 0 && (
<ul className="divide-y divide-border">
{fields.tools.map((name) => {
const tool = toolCatalogue.find((t) => t.name === name);
return (
<ItemRow
key={name}
icon={Wrench}
title={tool?.name || name}
detail={tool?.description}
/* A tool this deployment no longer registers is kept
and flagged rather than dropped: the backend now
refuses to save a definition naming one, so the
author needs to see which. */
warning={tool
? (tool.effect === 'write'
? 'Proposes changes — a person is asked to approve each one.'
: null)
: 'Not available on this deployment.'}
removeLabel={`Remove ${name}`}
onRemove={() => set({ tools: fields.tools.filter((t) => t !== name) })}
/>
);
})}
</ul>
)}
{!fields.tools.length && (
<p className="text-body-sm text-ink-3">
No tools attached. This agent can discuss its subject but cannot look
anything up.
</p>
)}
<Select
value=""
onValueChange={(name) =>
!fields.tools.includes(name) && set({ tools: [...fields.tools, name] })}
>
<SelectTrigger className="w-full sm:w-72">
<SelectValue placeholder={
toolCatalogue.length ? 'Add a tool...' : 'Loading tools...'
} />
</SelectTrigger>
<SelectContent>
{toolCatalogue
.filter((t) => !fields.tools.includes(t.name))
.map((t) => (
<SelectItem key={t.name} value={t.name}>
{t.name}{t.effect === 'write' ? ' — writes' : ''}
</SelectItem>
))}
</SelectContent>
</Select>
<p className="text-caption leading-relaxed text-ink-4">
A tool marked <em>writes</em> can propose a change. Nothing is written until
somebody approves it, and the agent can only reach what you could reach
yourself.
</p>
</div>
</div>
{/* Knowledge ── what it has been told. */}
<div className="pt-4">
<GroupHead
id="capabilities-knowledge"
icon={BookOpen}
title="Knowledge"
count={fields.knowledge.length}
action={(
<Button
variant="outline"
size="sm"
onClick={() => set({
knowledge: [
...fields.knowledge,
{ id: '', label: '', kind: 'note', body: '', url: '' },
],
})}
>
<Plus className="h-3.5 w-3.5" /> Add knowledge
</Button>
)}
/>
{fields.knowledge.length ? (
<div className="space-y-2.5">
{fields.knowledge.map((entry, i) => (
<div
key={entry.id || i}
className="animate-fade-in space-y-2.5 rounded-xl border border-border bg-surface-subtle p-3"
>
<div className="grid gap-2.5 sm:grid-cols-[minmax(0,1fr)_9rem]">
<Field label="Title">
<Input
value={entry.label}
placeholder="e.g. Overtime policy"
onChange={(e) => {
const next = [...fields.knowledge];
next[i] = { ...entry, label: e.target.value };
set({ knowledge: next });
}}
/>
</Field>
<Field label="Kind">
<Select
value={entry.kind}
onValueChange={(kind) => {
const next = [...fields.knowledge];
next[i] = { ...entry, kind };
set({ knowledge: next });
}}
>
<SelectTrigger><SelectValue /></SelectTrigger>
<SelectContent>
{KNOWLEDGE_KINDS.map((k) => (
<SelectItem key={k} value={k} className="capitalize">
{k.replace('-', ' ')}
</SelectItem>
))}
</SelectContent>
</Select>
</Field>
</div>
{entry.kind === 'link' ? (
<Field label="Link">
<Input
value={entry.url}
placeholder="https://…"
onChange={(e) => {
const next = [...fields.knowledge];
next[i] = { ...entry, url: e.target.value };
set({ knowledge: next });
}}
/>
</Field>
) : (
<Field
label="What it says"
hint="Quoted back with its source named. Nothing is invented from it."
>
<Textarea
value={entry.body}
rows={3}
onChange={(e) => {
const next = [...fields.knowledge];
next[i] = { ...entry, body: e.target.value };
set({ knowledge: next });
}}
/>
</Field>
)}
<Button
variant="ghost"
size="sm"
onClick={() => set({
knowledge: fields.knowledge.filter((_, x) => x !== i),
})}
>
<Trash2 className="h-3.5 w-3.5" /> Remove
</Button>
</div>
))}
</div>
) : (
<p className="text-body-sm text-ink-3">
No knowledge attached. Nothing is invented — this agent answers from skills only.
</p>
)}
</div>
</div>
</DocSection>
{/* ── Behavior ───────────────────────────────────────────────────── */}
<DocSection
id="behavior"
icon={SlidersHorizontal}
title="Behavior"
description="How this agent works when it answers."
meta={reasoning?.label}
open={open.behavior}
onToggle={() => toggleSection('behavior')}
last
>
<div>
<SettingRow
id="behavior-reasoning"
icon={Brain}
title="Reasoning"
description={reasoning?.summary || 'How much work an answer is worth.'}
control={(
<SegmentedToggle
size="sm"
ariaLabel="Reasoning depth"
value={fields.reasoning}
onChange={(id) => set({ reasoning: id })}
options={REASONING_MODES.map((m) => ({ value: m.id, label: m.label }))}
/>
)}
/>
<SettingRow
id="behavior-starters"
icon={MessageSquare}
title="Conversation starters"
value={starterCount ? `${starterCount} configured` : 'None'}
description="Questions offered as chips when this agent opens."
open={setting === 'behavior-starters'}
onToggle={() => toggleSetting('behavior-starters')}
>
<div className="space-y-2">
{fields.starters.map((starter, i) => (
<div key={i} className="flex items-center gap-2">
<Input
value={starter.label}
placeholder="e.g. Which roles are at risk?"
className="max-w-[30rem]"
onChange={(e) => {
const next = [...fields.starters];
next[i] = { label: e.target.value, prompt: e.target.value };
set({ starters: next });
}}
/>
<Button
variant="ghost"
size="icon"
aria-label="Remove starter"
onClick={() => set({ starters: fields.starters.filter((_, x) => x !== i) })}
>
<X className="h-3.5 w-3.5" aria-hidden="true" />
</Button>
</div>
))}
<Button
variant="outline"
size="sm"
onClick={() => set({ starters: [...fields.starters, { label: '', prompt: '' }] })}
>
<Plus className="h-3.5 w-3.5" /> Add starter
</Button>
</div>
</SettingRow>
<div id="behavior-web" className="scroll-mt-28">
<SettingRow
icon={Globe}
title="Web search"
value={fields.webSearch ? 'On' : 'Off'}
description="Whether this agent may look outside the workspace."
control={(
<Switch
checked={fields.webSearch}
onCheckedChange={(webSearch) => set({ webSearch })}
aria-label="Allow web search"
/>
)}
/>
<Collapse open={fields.webSearch}>
<div className="pb-4">
<Alert tone="warning" title="No search provider is configured">
This deployment has no web search connected, so answers say plainly that
nothing outside the workspace was consulted.
</Alert>
</div>
</Collapse>
</div>
<SettingRow
id="behavior-subagents"
icon={Layers}
title="Subagents"
value={fields.subagents.length ? `${fields.subagents.length} configured` : 'None'}
description="Other agents this one may hand a question to and report back from. Most need none."
open={setting === 'behavior-subagents'}
onToggle={() => toggleSetting('behavior-subagents')}
last
>
<div className="space-y-3">
{fields.subagents.length > 0 && (
<ul className="divide-y divide-border">
{fields.subagents.map((id) => {
const sub = agents.find((a) => a.id === id);
return (
<ItemRow
key={id}
icon={Layers}
title={sub?.name || id}
detail={sub?.description}
warning={sub ? null : 'No agent with this id is registered.'}
removeLabel={`Remove ${sub?.name || id}`}
onRemove={() => set({
subagents: fields.subagents.filter((x) => x !== id),
})}
/>
);
})}
</ul>
)}
<Select
value=""
onValueChange={(id) => !fields.subagents.includes(id)
&& set({ subagents: [...fields.subagents, id] })}
>
<SelectTrigger className="w-full sm:w-72">
<SelectValue placeholder="Add a subagent..." />
</SelectTrigger>
<SelectContent>
{availableSubagents
.filter((a) => !fields.subagents.includes(a.id))
.map((a) => (
<SelectItem key={a.id} value={a.id}>{a.name}</SelectItem>
))}
</SelectContent>
</Select>
<p className="text-caption leading-relaxed text-ink-4">
A subagent runs as you, with the same access you have and out of the same
budget as the question that reached it — so delegating cannot read anything
you could not read yourself, and cannot buy more time by asking again.
</p>
</div>
</SettingRow>
</div>
</DocSection>
</Workspace>
</>
);
}
export default AgentConfigure;