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
This commit is contained in:
262
src/components/agents/skills/BoardSkillList.tsx
Normal file
262
src/components/agents/skills/BoardSkillList.tsx
Normal file
@@ -0,0 +1,262 @@
|
||||
import * as React from 'react';
|
||||
import { LayoutTemplate, Minus, Pencil, Plus, SearchX } from 'lucide-react';
|
||||
import { Link } from 'react-router-dom';
|
||||
import { cn } from '@/lib/utils';
|
||||
import { Alert, Button, SearchInput, Switch } from '@/components/ds';
|
||||
|
||||
/**
|
||||
* Board skills, under the agent that stands beside them.
|
||||
*
|
||||
* The second half of one Skills section — the same heading, a tab away from the
|
||||
* Owliver catalog — so nobody has to decide between two top-level destinations
|
||||
* ever again.
|
||||
*
|
||||
* **Two controls, because there are two different questions.** This card used to
|
||||
* offer only the workspace switch, and said so at length: a Board skill was not
|
||||
* carried by an agent, because `SkillSurface` read the page and the account's
|
||||
* `disabledSkills` and never looked at `agent.skills`. That was true, and it is
|
||||
* not any more — `useSkillSections` now asks `agentPermitsSkill`, so an id
|
||||
* written into `agent.skills` is honoured on the page.
|
||||
*
|
||||
* So both questions are asked here, and neither is dressed up as the other:
|
||||
*
|
||||
* - **Active** is the workspace switch. Off means off for everyone, on every
|
||||
* page, for every agent. It is the account's `disabledSkills`.
|
||||
* - **Add to agent** is ownership. It writes this skill's id into
|
||||
* `agent.skills`, through the same handler the Owliver catalog uses.
|
||||
*
|
||||
* Ownership is opt-in and that is what makes the two safe together: a skill no
|
||||
* agent claims still draws wherever its `pages:` say, exactly as before. The
|
||||
* first agent to claim it is what narrows it — which is why attaching is worth
|
||||
* saying out loud on the card rather than leaving as a silent side effect.
|
||||
*/
|
||||
|
||||
/** One Board skill: what it draws, where, and whether it is switched on. */
|
||||
function BoardCard({ entry, enabled, onToggle, onSurfaces, busy, attached = false, onAttach = null }) {
|
||||
const switchId = `board-${entry.id}`;
|
||||
|
||||
return (
|
||||
<li className="min-w-0 h-full">
|
||||
<article
|
||||
className={cn(
|
||||
`group relative flex h-full min-w-0 flex-col justify-between rounded-2xl border bg-surface p-5 text-left
|
||||
shadow-xs transition-all duration-300 ease-out hover:-translate-y-1 hover:shadow-lg overflow-hidden`,
|
||||
enabled ? 'border-krow-blue/40 hover:border-krow-blue' : 'border-border/90 hover:border-krow-blue/40'
|
||||
)}
|
||||
>
|
||||
{/* Soft Background Highlight on Hover */}
|
||||
<div
|
||||
className={cn(
|
||||
'absolute inset-x-0 top-0 h-20 bg-gradient-to-b from-krow-blue-tint/40 to-transparent pointer-events-none transition-opacity duration-300',
|
||||
enabled ? 'opacity-70' : 'opacity-0 group-hover:opacity-100'
|
||||
)}
|
||||
/>
|
||||
|
||||
<div className="relative z-10">
|
||||
<div className="flex items-center justify-between gap-2">
|
||||
<div className="flex items-center gap-2 min-w-0">
|
||||
<span
|
||||
className={cn(
|
||||
'flex h-6 w-6 shrink-0 items-center justify-center rounded-lg border shadow-2xs transition-colors',
|
||||
enabled
|
||||
? 'border-krow-blue/30 bg-krow-blue-tint text-krow-blue'
|
||||
: 'border-border bg-surface-subtle text-ink-3 group-hover:text-krow-blue group-hover:border-krow-blue/30 group-hover:bg-krow-blue-tint/50'
|
||||
)}
|
||||
>
|
||||
<LayoutTemplate className="h-3.5 w-3.5" />
|
||||
</span>
|
||||
<span className="truncate text-[11px] font-bold uppercase tracking-wider text-ink-3">
|
||||
Board Skill
|
||||
</span>
|
||||
</div>
|
||||
|
||||
<div className="flex shrink-0 items-center gap-2">
|
||||
<span className={cn('text-[11px] font-semibold', enabled ? 'text-emerald-600' : 'text-ink-4')}>
|
||||
{enabled ? 'Active' : 'Disabled'}
|
||||
</span>
|
||||
<Switch
|
||||
id={switchId}
|
||||
checked={enabled}
|
||||
disabled={busy}
|
||||
onCheckedChange={(next) => onToggle(entry.id, next)}
|
||||
aria-label={`${entry.name} — ${enabled ? 'on' : 'off'} for this workspace`}
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<h3 className="font-heading text-body-lg font-bold leading-snug text-ink-1 group-hover:text-krow-blue transition-colors mt-3.5">
|
||||
{entry.name}
|
||||
</h3>
|
||||
|
||||
{entry.description && (
|
||||
<p className="mt-1.5 text-body-sm leading-relaxed text-ink-3 line-clamp-2 min-h-[2.5rem]">
|
||||
{entry.description}
|
||||
</p>
|
||||
)}
|
||||
|
||||
{/* What it actually draws. */}
|
||||
{entry.sections.length > 0 && (
|
||||
<ul className="mt-3 space-y-1.5">
|
||||
{entry.sections.map((section, i) => (
|
||||
<li
|
||||
key={section.id || `${section.page}-${i}`}
|
||||
className="flex flex-wrap items-center gap-x-1.5 gap-y-0.5 rounded-md bg-surface-subtle px-2 py-1 text-[11px] text-ink-3 border border-border/60"
|
||||
>
|
||||
<span className="font-medium text-ink-2">{section.title}</span>
|
||||
<span className="text-ink-4">·</span>
|
||||
<span>{section.typeLabel}</span>
|
||||
<span className="text-ink-4">·</span>
|
||||
<span className={cn(onSurfaces.has(section.page) ? 'font-semibold text-krow-blue' : 'text-ink-3')}>
|
||||
{section.pageLabel}
|
||||
</span>
|
||||
<span className="text-ink-4">/ {section.placementLabel}</span>
|
||||
</li>
|
||||
))}
|
||||
</ul>
|
||||
)}
|
||||
</div>
|
||||
|
||||
{/* Ownership, separate from the workspace switch above it. The button
|
||||
writes `agent.skills` through the same handler the Owliver catalog
|
||||
uses — there is no second assignment path and nothing new stored. */}
|
||||
{onAttach && (
|
||||
<div className="relative z-10 mt-3 flex items-center justify-between gap-2 rounded-lg border border-border/60 bg-surface-subtle px-2.5 py-2">
|
||||
<span className="min-w-0 text-caption text-ink-3">
|
||||
{attached
|
||||
? 'Owned by this agent'
|
||||
: 'Not owned by any agent on this page'}
|
||||
</span>
|
||||
<Button
|
||||
size="xs"
|
||||
variant={attached ? 'outline' : 'default'}
|
||||
shape="rounded"
|
||||
disabled={busy}
|
||||
onClick={() => onAttach(entry.id)}
|
||||
aria-label={`${attached ? 'Remove' : 'Add'} ${entry.name} ${attached ? 'from' : 'to'} this agent`}
|
||||
>
|
||||
{attached
|
||||
? <><Minus className="mr-1 h-3.5 w-3.5" aria-hidden="true" />Remove</>
|
||||
: <><Plus className="mr-1 h-3.5 w-3.5" aria-hidden="true" />Add to agent</>}
|
||||
</Button>
|
||||
</div>
|
||||
)}
|
||||
|
||||
<div className="relative z-10 mt-4 flex items-center justify-between gap-2 border-t border-border/60 pt-3 font-medium">
|
||||
<span className="text-caption text-ink-4">
|
||||
{entry.surfaces.some((s) => onSurfaces.has(s.id))
|
||||
? 'Active on this agent\'s pages'
|
||||
: 'Draws across workspace'}
|
||||
</span>
|
||||
<Link
|
||||
to={`/admin/workspace/skills/${entry.id}`}
|
||||
className="inline-flex items-center gap-1 text-caption font-semibold text-krow-blue hover:text-krow-blue-dark transition-colors"
|
||||
>
|
||||
<Pencil className="h-3 w-3" aria-hidden="true" /> Edit
|
||||
</Link>
|
||||
</div>
|
||||
</article>
|
||||
</li>
|
||||
);
|
||||
}
|
||||
|
||||
export function BoardSkillList({
|
||||
entries, agentPages = [], disabledIds, onToggle, busy = false, query, onQueryChange, total,
|
||||
attachedIds = null, onAttach = null,
|
||||
}: any) {
|
||||
const searchId = React.useId();
|
||||
const onSurfaces = React.useMemo(() => new Set(agentPages), [agentPages]);
|
||||
|
||||
/* The ones that meet this agent first */
|
||||
const ordered = React.useMemo(() => {
|
||||
const here = [];
|
||||
const elsewhere = [];
|
||||
for (const e of entries) {
|
||||
(e.surfaces.some((s) => onSurfaces.has(s.id)) ? here : elsewhere).push(e);
|
||||
}
|
||||
return [...here, ...elsewhere];
|
||||
}, [entries, onSurfaces]);
|
||||
|
||||
return (
|
||||
<section aria-label="Board skills" className="flex min-w-0 flex-col gap-4">
|
||||
<Alert tone="info" title="Two switches, two different questions">
|
||||
<strong>Active</strong> is workspace-wide: off means off on every page, for every
|
||||
agent. <strong>Add to agent</strong> is ownership — once any agent owns a board
|
||||
skill, it draws only where an owning agent is answering. A skill no agent owns
|
||||
keeps drawing wherever its pages say, as before.
|
||||
</Alert>
|
||||
|
||||
{total > 0 && (
|
||||
<div>
|
||||
<label htmlFor={searchId} className="sr-only">Search board skills</label>
|
||||
<SearchInput
|
||||
id={searchId}
|
||||
value={query}
|
||||
onChange={onQueryChange}
|
||||
placeholder="Search board skills, sections or pages..."
|
||||
debounce={0}
|
||||
/>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{total > 0 && (
|
||||
<div className="flex flex-wrap items-center justify-between gap-x-3 gap-y-1.5 pt-1">
|
||||
<p className="text-xs font-medium text-ink-3" aria-live="polite">
|
||||
<span className="font-semibold text-ink-1">{ordered.length}</span> of {total} board skill{total === 1 ? '' : 's'}
|
||||
</p>
|
||||
<Link
|
||||
to="/admin/workspace/skills/new"
|
||||
className={`inline-flex items-center gap-1.5 rounded-lg border border-border/80 bg-surface px-3 py-1.5
|
||||
text-xs font-semibold text-krow-blue shadow-2xs transition-all
|
||||
hover:bg-krow-blue-tint/60 hover:border-krow-blue/40 focus-visible:outline-none
|
||||
focus-visible:ring-2 focus-visible:ring-krow-blue/40`}
|
||||
>
|
||||
<Plus className="h-3.5 w-3.5" aria-hidden="true" /> New board skill
|
||||
</Link>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{ordered.length > 0 ? (
|
||||
<ul className="grid grid-cols-1 sm:grid-cols-2 xl:grid-cols-3 gap-4.5">
|
||||
{ordered.map((entry) => (
|
||||
<BoardCard
|
||||
key={entry.id}
|
||||
entry={entry}
|
||||
enabled={!disabledIds.has(entry.id)}
|
||||
onToggle={onToggle}
|
||||
onSurfaces={onSurfaces}
|
||||
busy={busy}
|
||||
attached={Boolean(attachedIds?.has(entry.id))}
|
||||
onAttach={onAttach}
|
||||
/>
|
||||
))}
|
||||
</ul>
|
||||
) : (
|
||||
<div className="rounded-2xl border border-dashed border-border bg-surface-subtle/40 px-4 py-12 text-center">
|
||||
{total > 0 ? (
|
||||
<>
|
||||
<SearchX className="mx-auto h-6 w-6 text-ink-4" aria-hidden="true" />
|
||||
<p className="mt-2.5 text-sm font-semibold text-ink-1">No board skills match that</p>
|
||||
<p className="mt-1 text-xs text-ink-3">Try a different word.</p>
|
||||
</>
|
||||
) : (
|
||||
<>
|
||||
<LayoutTemplate className="mx-auto h-6 w-6 text-ink-4" aria-hidden="true" />
|
||||
<p className="mt-2.5 text-sm font-semibold text-ink-1">No board skills yet</p>
|
||||
<p className="mx-auto mt-1 max-w-[46ch] text-xs leading-relaxed text-ink-3">
|
||||
A board skill declares a section, where it sits on a page, and what it reads.
|
||||
Add one and the page it names renders it.
|
||||
</p>
|
||||
<Button asChild variant="outline" size="sm" className="mt-3.5">
|
||||
<Link to="/admin/workspace/skills/new">
|
||||
<Plus className="h-3.5 w-3.5" /> Add a board skill
|
||||
</Link>
|
||||
</Button>
|
||||
</>
|
||||
)}
|
||||
</div>
|
||||
)}
|
||||
</section>
|
||||
);
|
||||
}
|
||||
|
||||
export default BoardSkillList;
|
||||
Reference in New Issue
Block a user