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:
Yaowei Zheng
2026-07-21 17:43:31 +08:00
committed by GitHub
parent abf0a2f248
commit d4faee3a1e
214 changed files with 9406 additions and 1688 deletions
+135 -43
View File
@@ -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);