Changelog, dev startup, README, AgentHub 0.4.0, model catalog, and landing site (#7)
Branch-length batch covering tooling, the model layer, the Web App and the public surfaces. Highlights: - Changelog: a per-release `changelog/<version>/` tree, grouped by the surface each change touches, with a root CHANGELOG.md holding one line per release. - Dev startup: `scripts/dev-prebuild.mjs` serializes the skills+core prebuild behind a lock and keeps `pnpm install` current; `pnpm dev` runs server+web together. - AgentHub 0.3.3 -> 0.4.0: OmniMessage complete payloads carry one opaque `fidelity` object in place of item-level `signature`/`phase`, threaded verbatim through Trace, replay and resume; malformed classification adapted to the new error types. - Model layer: a model is always referenced by an explicit `(provider, model_id)` pair. The provider is never inferred, guessed or defaulted -- both the catalog inference and the unique-match config resolution are gone, and CLI, SDK, server routes and run_subagent all require the complete pair. Catalog gains the Qwen Token Plan, Qwen Pay-As-You-Go and Fireworks AI gateways, plus an expanded OpenRouter group. - Web App: catalog preset sync and per-group speed test on the Models page, positional slash commands, a markdown renderer, skill-library update reminders, and a vertically centred draft page whose upward menus size themselves to the room available. - Public surfaces: restructured READMEs, the penguin.ooo landing site and blog, refreshed benchmark results for both suites, and the demo videos playing on the landing page. Includes the fixes from a full review of the branch: 23 confirmed findings, among them a provider-inference bug that could send one vendor's API key to another vendor's endpoint, and an Escape handler that destroyed the composer's contents unrecoverably. Verified on the branch head: pnpm test (1127 passing, 7 packages), pnpm typecheck and pnpm format:check clean, Playwright e2e 14/14.
This commit is contained in:
@@ -12,7 +12,9 @@
|
||||
* logo + name display;
|
||||
* `/` opens the slash command menu (`/compact` compresses context, replacing the button; each
|
||||
* installed skill gets its own entry; pressing Enter on `/<skill_name>` toggles that skill's
|
||||
* selection and clears the input, without sending);
|
||||
* selection without sending). Matching is positional like `@`: a slash opens the menu from any
|
||||
* caret position, running a command removes just that token, and Escape only dismisses the menu —
|
||||
* the rest of the draft is never touched;
|
||||
* `@` opens the agent selection menu; once picked it becomes a fixed highlighted target chip
|
||||
* above the text body (only one allowed, picking again replaces it; removed via backspace or the
|
||||
* x button); only a leading `@` at the start of the text counts — typing or pasting text starting
|
||||
@@ -54,8 +56,9 @@ import { GlyphIcon } from "../../components/ui/glyph-icon";
|
||||
import { SkillIcon } from "../skills/skill-icon-view";
|
||||
import { ZoomableImage } from "../../components/ui/image-zoom";
|
||||
import { ProviderLogo } from "../../components/ui/provider-logo";
|
||||
import { matchesQuery, sameModelRef } from "../models/model-grouping";
|
||||
import { matchesQuery, orderModelsLikeLibrary, sameModelRef } from "../models/model-grouping";
|
||||
import { filterAgents, matchMention, splitLeadingMention } from "./agent-mentions";
|
||||
import { matchSlash, removeSlashToken } from "./slash-token";
|
||||
import {
|
||||
BOOK_ICON,
|
||||
buildSkillsMessage,
|
||||
@@ -231,7 +234,9 @@ function ModelSelect({
|
||||
const current = models.find((m) => sameModelRef(m, value));
|
||||
// Display rule matches the model page's card: display name, or falls back to the upstream id (grouping is already conveyed by the provider logo).
|
||||
const label = current ? modelLabel(current) : (value?.modelId ?? "…");
|
||||
const filtered = models.filter((m) => matchesQuery(m, query));
|
||||
// Dropdown order mirrors the model library page: provider groups in MODEL_PROVIDERS order
|
||||
// (user-defined groups after, custom last), in-group order preserved.
|
||||
const filtered = orderModelsLikeLibrary(models).filter((m) => matchesQuery(m, query));
|
||||
return (
|
||||
<Dropdown
|
||||
open={open}
|
||||
@@ -353,9 +358,11 @@ function SkillSelect({
|
||||
open={open}
|
||||
setOpen={setOpen}
|
||||
menuClass={
|
||||
// As wide as reasonably possible so descriptions stay readable; the viewport clamp
|
||||
// keeps it inside phone screens.
|
||||
direction === "down"
|
||||
? "left-0 top-full mt-1 w-72 max-w-[calc(100vw-2rem)] origin-top-left"
|
||||
: "bottom-full left-0 mb-1 w-72 max-w-[calc(100vw-2rem)] origin-bottom-left"
|
||||
? "left-0 top-full mt-1 w-[26rem] max-w-[calc(100vw-2rem)] origin-top-left"
|
||||
: "bottom-full left-0 mb-1 w-[26rem] max-w-[calc(100vw-2rem)] origin-bottom-left"
|
||||
}
|
||||
button={
|
||||
<button
|
||||
@@ -373,7 +380,7 @@ function SkillSelect({
|
||||
<GlyphIcon d={BOOK_ICON} size={14} className="shrink-0" />
|
||||
{/* When the card is narrower than @md, only the icon + badge remain (title shows the full name). */}
|
||||
<span className="hidden min-w-0 truncate @md:block">{S.chat.skillsSelect}</span>
|
||||
{/* Selected-count badge: chips no longer exist, so this is the only visible rendering of the selected state. */}
|
||||
{/* Selected-count badge (the chip row above the input mirrors the selection too). */}
|
||||
{selected.length > 0 && (
|
||||
<span className="shrink-0 rounded-full bg-gray-200/80 px-1.5 py-px font-mono text-[10px] font-semibold text-gray-700 dark:bg-gray-700/60 dark:text-gray-200">
|
||||
{selected.length}
|
||||
@@ -616,9 +623,17 @@ export function ChatInput({
|
||||
}) {
|
||||
const { locale } = useLocale();
|
||||
const [text, setText] = useState(initialText ?? "");
|
||||
/** Live text mirror for slash-command run() closures (the commands memo deliberately doesn't depend on text). */
|
||||
const textRef = useRef(text);
|
||||
textRef.current = text;
|
||||
const [images, setImages] = useState<string[]>([]);
|
||||
const [busy, setBusy] = useState(false);
|
||||
const [slashIndex, setSlashIndex] = useState(0);
|
||||
// Slash token start where Escape closed the menu (mirrors mentionDismissed: the menu stays shut for that one token).
|
||||
const [slashDismissed, setSlashDismissed] = useState<number | null>(null);
|
||||
// Anchor for the popups that open upward, and the room actually available above them.
|
||||
const anchorRef = useRef<HTMLDivElement>(null);
|
||||
const [upwardMaxH, setUpwardMaxH] = useState<number>();
|
||||
// @ handoff target (chip, fixed at the front of the input); only one allowed, picking again replaces it directly.
|
||||
const [target, setTarget] = useState<AgentSummary | null>(null);
|
||||
// Selected skills (dropdown checklist, multi-select): initial value comes from draft restore (quick-invoke pre-selection), cleared on successful send.
|
||||
@@ -654,11 +669,15 @@ export function ChatInput({
|
||||
[selectedSkills, onSkillsChange],
|
||||
);
|
||||
|
||||
/** The slash token currently under the caret (kept in a ref so command run() closures always remove the live token). */
|
||||
const slashMatchRef = useRef<ReturnType<typeof matchSlash>>(null);
|
||||
const commands = useMemo<SlashCommand[]>(() => {
|
||||
/** Clears the input after a slash command runs (setText is async: wait for the DOM value to update before measuring height). */
|
||||
/** Removes just the slash token after a command runs (the rest of the text stays; setText is async: wait for the DOM value to update before measuring height). */
|
||||
const clearInput = () => {
|
||||
setText("");
|
||||
onTextChange?.("");
|
||||
const match = slashMatchRef.current;
|
||||
const next = match ? removeSlashToken(textRef.current, match) : "";
|
||||
setText(next);
|
||||
onTextChange?.(next);
|
||||
requestAnimationFrame(autoGrow);
|
||||
};
|
||||
return [
|
||||
@@ -681,10 +700,14 @@ export function ChatInput({
|
||||
})),
|
||||
];
|
||||
}, [onCompact, onTextChange, skills, locale, toggleSkill]);
|
||||
const trimmed = text.trim();
|
||||
// Positional matching (like @ mentions): a slash opens the menu from any caret position;
|
||||
// running a command removes just the token, leaving the rest of the text intact. Doesn't
|
||||
// reopen after Escape until the caret sits on a different token.
|
||||
const slashTok = !running && !compacting ? matchSlash(text, caret) : null;
|
||||
slashMatchRef.current = slashTok;
|
||||
const slashMatches =
|
||||
text.trimStart().startsWith("/") && !running && !compacting
|
||||
? commands.filter((c) => c.cmd.startsWith(trimmed) || trimmed === "/")
|
||||
slashTok && slashTok.start !== slashDismissed
|
||||
? commands.filter((c) => c.cmd.startsWith(`/${slashTok.query}`))
|
||||
: [];
|
||||
const slashOpen = slashMatches.length > 0;
|
||||
const activeSlash = slashMatches[Math.min(slashIndex, slashMatches.length - 1)];
|
||||
@@ -696,6 +719,31 @@ export function ChatInput({
|
||||
const mentionOpen = mentionMatches.length > 0;
|
||||
const activeMention = mentionMatches[Math.min(mentionIndex, mentionMatches.length - 1)];
|
||||
|
||||
// Both menus above are drawn upward (`bottom-full`) from the composer, so their ceiling is
|
||||
// whatever ancestor clips overflow — on the draft page that's the centered scroll area, whose
|
||||
// top edge sits well below the viewport's. A static `40vh` cap can't know that distance and
|
||||
// clipped the first rows on shorter windows, so measure the real gap when a menu opens.
|
||||
useEffect(() => {
|
||||
if (!slashOpen && !mentionOpen) return;
|
||||
const measure = () => {
|
||||
const el = anchorRef.current;
|
||||
if (!el) return;
|
||||
let ceiling = 0;
|
||||
for (let p = el.parentElement; p; p = p.parentElement) {
|
||||
if (getComputedStyle(p).overflowY !== "visible") {
|
||||
ceiling = p.getBoundingClientRect().top;
|
||||
break;
|
||||
}
|
||||
}
|
||||
// Less the menu's own 6px offset from the composer, plus a little breathing room.
|
||||
const room = el.getBoundingClientRect().top - ceiling - 14;
|
||||
setUpwardMaxH(Math.max(96, Math.min(320, Math.round(room))));
|
||||
};
|
||||
measure();
|
||||
window.addEventListener("resize", measure);
|
||||
return () => window.removeEventListener("resize", measure);
|
||||
}, [slashOpen, mentionOpen]);
|
||||
|
||||
/** Auto-grow the textarea (caps at roughly 6 lines, scrolls internally beyond that). */
|
||||
const autoGrow = () => {
|
||||
const el = textareaRef.current;
|
||||
@@ -829,10 +877,10 @@ export function ChatInput({
|
||||
return;
|
||||
}
|
||||
if (e.key === "Escape") {
|
||||
setText("");
|
||||
onTextChange?.("");
|
||||
// setText is async: wait for the DOM value to update before measuring height (measuring synchronously would read the pre-clear content).
|
||||
requestAnimationFrame(autoGrow);
|
||||
// Only closes the popup, doesn't clear the input: with positional matching the `/token`
|
||||
// is part of the text body like any other word, and wiping a controlled textarea is not
|
||||
// undoable with Ctrl+Z. Reopens if the user keeps typing on another token.
|
||||
setSlashDismissed(slashTok?.start ?? null);
|
||||
return;
|
||||
}
|
||||
}
|
||||
@@ -853,7 +901,7 @@ export function ChatInput({
|
||||
return;
|
||||
}
|
||||
if (e.key === "Escape") {
|
||||
// Only closes the popup, doesn't clear the input (unlike slash: the @ prefix is part of the text body), reopens if the user keeps typing.
|
||||
// Only closes the popup, doesn't clear the input (the `@token` is part of the text body), reopens if the user keeps typing.
|
||||
setMentionDismissed(mention?.start ?? null);
|
||||
return;
|
||||
}
|
||||
@@ -909,14 +957,21 @@ export function ChatInput({
|
||||
};
|
||||
|
||||
return (
|
||||
<div className="relative">
|
||||
{/* Slash command menu (triggered by typing /; /compact plus one entry per installed skill) */}
|
||||
<div className="relative" ref={anchorRef}>
|
||||
{/* Slash command menu (triggered by typing /; /compact plus one entry per installed skill).
|
||||
Height is capped to the room measured above the composer (see upwardMaxH) with internal
|
||||
scrolling, so a long skill list never pushes the menu's top edge out of view; the active
|
||||
row keeps itself scrolled into view. */}
|
||||
{slashOpen && (
|
||||
<div className="anim-pop absolute bottom-full left-0 z-40 mb-1.5 w-80 max-w-[calc(100vw-2rem)] rounded-md border border-gray-200 bg-white py-1 shadow-lg dark:border-gray-700 dark:bg-gray-900">
|
||||
<div
|
||||
style={{ maxHeight: upwardMaxH }}
|
||||
className="anim-pop absolute bottom-full left-0 z-40 mb-1.5 w-80 max-w-[calc(100vw-2rem)] overflow-y-auto overscroll-contain rounded-md border border-gray-200 bg-white py-1 shadow-lg dark:border-gray-700 dark:bg-gray-900"
|
||||
>
|
||||
{slashMatches.map((c, i) => (
|
||||
<button
|
||||
key={c.cmd}
|
||||
type="button"
|
||||
ref={c === activeSlash ? (el) => el?.scrollIntoView({ block: "nearest" }) : undefined}
|
||||
onMouseEnter={() => setSlashIndex(i)}
|
||||
onClick={() => c.run()}
|
||||
className={`flex w-full items-center gap-2 px-3 py-1.5 text-left text-sm ${
|
||||
@@ -938,7 +993,10 @@ export function ChatInput({
|
||||
|
||||
{/* @ subagent menu (triggered by typing @; interaction matches the slash menu) */}
|
||||
{mentionOpen && (
|
||||
<div className="anim-pop absolute bottom-full left-0 z-40 mb-1.5 max-h-64 w-72 overflow-y-auto rounded-md border border-gray-200 bg-white py-1 shadow-lg dark:border-gray-700 dark:bg-gray-900">
|
||||
<div
|
||||
style={{ maxHeight: Math.min(256, upwardMaxH ?? 256) }}
|
||||
className="anim-pop absolute bottom-full left-0 z-40 mb-1.5 w-72 overflow-y-auto rounded-md border border-gray-200 bg-white py-1 shadow-lg dark:border-gray-700 dark:bg-gray-900"
|
||||
>
|
||||
{mentionMatches.map((a, i) => (
|
||||
<button
|
||||
key={a.agentId}
|
||||
@@ -1001,27 +1059,56 @@ export function ChatInput({
|
||||
card's width changes with the viewport and the Files panel squeezing it, viewport
|
||||
breakpoints wouldn't judge it accurately. */}
|
||||
<div className="@container rounded-lg border border-gray-300 bg-white px-2.5 pb-2 pt-2 transition-[border-color,box-shadow] duration-200 focus-within:border-gray-500 focus-within:ring-2 focus-within:ring-gray-400/30 dark:border-gray-700 dark:bg-gray-900 dark:focus-within:border-gray-400">
|
||||
{/* @ handoff target chip: above the text body (background highlight to distinguish it from plain text), removed via the x button or backspace at the start of the text. */}
|
||||
{target !== null && (
|
||||
{/* Chip row above the text body: the @ handoff target (fixed at the front — send-time
|
||||
@ semantics stay leading-only) followed by the selected skills, mirroring the
|
||||
agent chip's look. Remove buttons recolor the x on hover (no background wash). */}
|
||||
{(target !== null || selectedSkills.length > 0) && (
|
||||
<div className="mb-1 flex flex-wrap items-center gap-1">
|
||||
<span
|
||||
className="anim-pop flex max-w-48 items-center gap-0.5 rounded-md bg-gray-100 py-0.5 pl-2 pr-1 font-mono text-sm text-gray-800 dark:bg-gray-800 dark:text-gray-200"
|
||||
{...(target.name && target.name !== target.agentId ? { title: target.name } : {})}
|
||||
>
|
||||
<span className="truncate">@{target.agentId}</span>
|
||||
<button
|
||||
type="button"
|
||||
aria-label={S.chat.mentionRemove}
|
||||
onClick={() => {
|
||||
setTarget(null);
|
||||
onHandoffTargetChange?.(null);
|
||||
textareaRef.current?.focus();
|
||||
}}
|
||||
className="shrink-0 rounded p-0.5 text-gray-400 transition-colors duration-150 hover:bg-gray-200 hover:text-gray-700 dark:hover:bg-gray-700 dark:hover:text-gray-200"
|
||||
{target !== null && (
|
||||
<span
|
||||
className="anim-pop flex max-w-48 items-center gap-0.5 rounded-md bg-gray-100 py-0.5 pl-2 pr-1 font-mono text-sm text-gray-800 dark:bg-gray-800 dark:text-gray-200"
|
||||
{...(target.name && target.name !== target.agentId ? { title: target.name } : {})}
|
||||
>
|
||||
×
|
||||
</button>
|
||||
</span>
|
||||
<span className="truncate">@{target.agentId}</span>
|
||||
<button
|
||||
type="button"
|
||||
aria-label={S.chat.mentionRemove}
|
||||
onClick={() => {
|
||||
setTarget(null);
|
||||
onHandoffTargetChange?.(null);
|
||||
textareaRef.current?.focus();
|
||||
}}
|
||||
className="shrink-0 rounded p-0.5 text-gray-400 transition-colors duration-150 hover:text-gray-700 dark:hover:text-gray-200"
|
||||
>
|
||||
×
|
||||
</button>
|
||||
</span>
|
||||
)}
|
||||
{selectedSkills.map((name) => {
|
||||
const meta = skills.find((sk) => sk.name === name);
|
||||
return (
|
||||
<span
|
||||
key={name}
|
||||
className="anim-pop flex max-w-48 items-center gap-1 rounded-md bg-gray-100 py-0.5 pl-2 pr-1 font-mono text-sm text-gray-800 dark:bg-gray-800 dark:text-gray-200"
|
||||
{...(meta ? { title: localizedShortText(locale, meta) } : {})}
|
||||
>
|
||||
<SkillIcon
|
||||
icon={meta?.icon}
|
||||
size={13}
|
||||
className="shrink-0 text-gray-500 dark:text-gray-400"
|
||||
/>
|
||||
<span className="truncate">{name}</span>
|
||||
<button
|
||||
type="button"
|
||||
aria-label={`${S.chat.skillRemove} ${name}`}
|
||||
onClick={() => toggleSkill(name)}
|
||||
className="shrink-0 rounded p-0.5 text-gray-400 transition-colors duration-150 hover:text-gray-700 dark:hover:text-gray-200"
|
||||
>
|
||||
×
|
||||
</button>
|
||||
</span>
|
||||
);
|
||||
})}
|
||||
</div>
|
||||
)}
|
||||
|
||||
@@ -1039,9 +1126,14 @@ export function ChatInput({
|
||||
setCaret(caretNow);
|
||||
setSlashIndex(0);
|
||||
setMentionIndex(0);
|
||||
// Closing via Escape only persists for "the same @ mention": continuing to type
|
||||
// within that mention won't reopen the menu; it re-opens once the cursor is no
|
||||
// longer on that mention (deleted, moved away, or replaced by a new one).
|
||||
// Closing via Escape only persists for "the same token": continuing to type within
|
||||
// that slash command / mention won't reopen the menu; it re-opens once the cursor is
|
||||
// no longer on that token (deleted, moved away, or replaced by a new one).
|
||||
setSlashDismissed((d) => {
|
||||
if (d === null) return null;
|
||||
const m = matchSlash(value, caretNow);
|
||||
return m && m.start === d ? d : null;
|
||||
});
|
||||
setMentionDismissed((d) => {
|
||||
if (d === null) return null;
|
||||
const m = matchMention(value, caretNow);
|
||||
|
||||
Reference in New Issue
Block a user