Files
kandev/apps/web/lib/settings/agent-display-order.ts
T
2026-08-21 22:04:17 +01:00

71 lines
2.8 KiB
TypeScript

/**
* The order agents are listed in, wherever they are listed.
*
* The backend already ranks every agent it knows about: each implementation
* declares a `DisplayOrder`, and `GET /agents/discovery` returns them sorted by
* it — Claude first, then Codex, with the dev-only mock agent last at 99. That
* ranking covers agents the scan did *not* find as well as the ones it did, so
* it is a complete order and not just an order over what happens to be
* installed on this machine.
*
* So neither surface invents an order: both rank saved agents by where the
* backend put them. The menu and the Agents page listing the same agents in
* different orders is what this module exists to prevent.
*/
export type DiscoveredAgent = { name: string; available: boolean };
export type SavedAgent = { name: string; profiles: ReadonlyArray<unknown> };
/** The built-in virtual family has no host installation to appear in discovery. */
export const DYNAMIC_AGENT_NAME = "dynamic";
/** Agents the scan currently detects, in rank order. */
export function detectedAgents<D extends DiscoveredAgent>(discovery: ReadonlyArray<D>): D[] {
return discovery.filter((agent) => agent.available);
}
/**
* Configured agents the scan no longer detects, in rank order. Empty concrete
* agent families have nothing to preserve, but the virtual Dynamic family is
* always retained so settings can create its first profile.
*
* The Agents page still renders these — via a synthetic discovery record — so a
* CLI going missing never hides the profiles configured against it.
*/
export function orphanedAgents<S extends SavedAgent>(
detected: ReadonlyArray<DiscoveredAgent>,
saved: ReadonlyArray<S>,
): S[] {
const detectedNames = new Set(detected.map((agent) => agent.name));
return saved.filter(
(agent) =>
(agent.profiles.length > 0 || agent.name === DYNAMIC_AGENT_NAME) &&
!detectedNames.has(agent.name),
);
}
/**
* `saved`, reordered by the backend's ranking.
*
* Ranks against the *whole* discovery list rather than the detected subset, so
* an agent whose CLI is missing keeps its place among the rest instead of being
* flung to the end — and so the dev-only mock agent, ranked last by the backend,
* lands last rather than in the middle of the agents you actually use.
*
* Agents the backend does not rank at all keep their saved order, after the
* ranked ones. A reorder never drops or duplicates an entry.
*/
export function orderAgentsForDisplay<S extends SavedAgent>(
discovery: ReadonlyArray<DiscoveredAgent>,
saved: ReadonlyArray<S>,
): S[] {
const rank = new Map<string, number>();
discovery.forEach((agent, index) => rank.set(agent.name, index));
const unranked = rank.size;
return [...saved].sort(
(a, b) =>
(rank.get(a.name) ?? unranked + saved.indexOf(a)) -
(rank.get(b.name) ?? unranked + saved.indexOf(b)),
);
}