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:
2026-09-18 15:32:14 +05:30
parent 446df7b37b
commit dde4ba62c6
15 changed files with 30 additions and 53 deletions

View 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;