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:
@@ -45,6 +45,7 @@ import { Dropdown } from "../../components/ui/dropdown";
|
||||
import { PenguinLogo } from "../../components/ui/penguin-logo";
|
||||
import { toastError } from "../../components/ui/toast";
|
||||
import { ChatInput } from "./chat-input";
|
||||
import { buildSkillsMessage } from "./skill-use";
|
||||
import { clearDraft, draftKey, loadDraft, saveDraft } from "./draft-cache";
|
||||
import type { DraftCache } from "./draft-cache";
|
||||
import { handoffMessage } from "./agent-mentions";
|
||||
@@ -53,6 +54,18 @@ import { sameModelRef } from "../models/model-grouping";
|
||||
/** Coalescing window for writing body text to the cache: keystrokes are frequent, so a short batch accumulates before persisting (option changes are still written immediately). */
|
||||
const DRAFT_SAVE_DEBOUNCE_MS = 300;
|
||||
|
||||
/**
|
||||
* Example tasks on the draft screen, in display order (game card first, LoL music player,
|
||||
* then the RAG build). Copy lives in S.chat.exampleTasks[id]; skills are pinned via a
|
||||
* `<use_skills>` block — only those the selected Agent actually has installed are included,
|
||||
* so the block never references a skill the agent can't read.
|
||||
*/
|
||||
const EXAMPLE_TASKS: { id: "game" | "lol" | "rag"; skills: string[] }[] = [
|
||||
{ id: "game", skills: ["web-design"] },
|
||||
{ id: "lol", skills: ["web-design"] },
|
||||
{ id: "rag", skills: ["penguin-sdk", "web-design"] },
|
||||
];
|
||||
|
||||
export function DraftView({
|
||||
projectId,
|
||||
models,
|
||||
@@ -146,8 +159,11 @@ export function DraftView({
|
||||
// mount render would trigger ChatInput's pruning effect and wrongly clear the
|
||||
// quick-invoke preselection.
|
||||
const [agentSkills, setAgentSkills] = useState<SkillMetadataItem[]>([]);
|
||||
/** Whether the skills fetch for the current Agent has settled — the example task waits for it so its `<use_skills>` pinning doesn't silently depend on network timing. */
|
||||
const [skillsLoaded, setSkillsLoaded] = useState(false);
|
||||
useEffect(() => {
|
||||
setAgentSkills((prev) => (prev.length > 0 ? [] : prev));
|
||||
setSkillsLoaded(false);
|
||||
if (!agentId) return;
|
||||
let cancelled = false;
|
||||
api
|
||||
@@ -155,7 +171,10 @@ export function DraftView({
|
||||
.then((res) => {
|
||||
if (!cancelled) setAgentSkills(res.skills);
|
||||
})
|
||||
.catch(() => undefined);
|
||||
.catch(() => undefined)
|
||||
.finally(() => {
|
||||
if (!cancelled) setSkillsLoaded(true);
|
||||
});
|
||||
return () => {
|
||||
cancelled = true;
|
||||
};
|
||||
@@ -244,12 +263,23 @@ export function DraftView({
|
||||
setCurrentAgentId(a.agentId);
|
||||
};
|
||||
|
||||
// One in-flight guard shared by every send entry point (composer send / example task /
|
||||
// @-handoff): a second submission while one is running would create a second Session with
|
||||
// its own first task and a racing navigation. The ref is the synchronous guard; the state
|
||||
// drives disabled styling on the example button (the composer has its own busy state).
|
||||
const sendingRef = useRef(false);
|
||||
const [sending, setSending] = useState(false);
|
||||
|
||||
// First message sent: only now is the Session created (Agent / Workspace / Model / approval
|
||||
// mode are all locked in together), then the route jumps once sent; returns false on any
|
||||
// failure, so the input area keeps the draft and can resend.
|
||||
// failure, so the input area keeps the draft and can resend. `keepDraft` is set by sends
|
||||
// that did not consume the composer text (the example task), so a typed-but-unsent draft
|
||||
// survives the navigation instead of being silently discarded.
|
||||
const onSend = useCallback(
|
||||
async (input: TaskInputPart[]): Promise<boolean> => {
|
||||
if (!agentId) return false;
|
||||
async (input: TaskInputPart[], keepDraft = false): Promise<boolean> => {
|
||||
if (!agentId || sendingRef.current) return false;
|
||||
sendingRef.current = true;
|
||||
setSending(true);
|
||||
let createdId: string | null = null;
|
||||
try {
|
||||
const body: SessionCreateRequest = { approvalMode };
|
||||
@@ -263,7 +293,7 @@ export function DraftView({
|
||||
createdId = created.session.sessionId;
|
||||
const res = await api.postTask(createdId, { input });
|
||||
add(created.session);
|
||||
discardDraft();
|
||||
if (!keepDraft) discardDraft();
|
||||
navigate(`/chat/${res.sessionId}`, { replace: true });
|
||||
return true;
|
||||
} catch (e) {
|
||||
@@ -273,18 +303,45 @@ export function DraftView({
|
||||
if (createdId) void api.deleteSession(createdId).catch(() => undefined);
|
||||
toastError(apiErrorText(e, modelRef ? { modelId: modelRef.modelId } : {}));
|
||||
return false;
|
||||
} finally {
|
||||
sendingRef.current = false;
|
||||
setSending(false);
|
||||
}
|
||||
},
|
||||
[projectId, agentId, approvalMode, modelRef, workspace, add, discardDraft, navigate],
|
||||
);
|
||||
|
||||
// Example tasks: one click submits the canned prompt exactly like a hand-typed send (the
|
||||
// busy id drives the clicked card's spinner; the shared in-flight guard and all failure
|
||||
// handling live in onSend). keepDraft: an example never consumes the composer text, so a
|
||||
// typed-but-unsent draft must survive. The selected model / Workspace / approval mode apply as-is.
|
||||
const [exampleBusy, setExampleBusy] = useState<"game" | "lol" | "rag" | null>(null);
|
||||
const runExample = useCallback(
|
||||
async (task: (typeof EXAMPLE_TASKS)[number]) => {
|
||||
if (exampleBusy !== null) return;
|
||||
setExampleBusy(task.id);
|
||||
try {
|
||||
const names = task.skills.filter((n) => agentSkills.some((s) => s.name === n));
|
||||
await onSend(
|
||||
[{ type: "text", text: buildSkillsMessage(names, S.chat.exampleTasks[task.id].prompt) }],
|
||||
true,
|
||||
);
|
||||
} finally {
|
||||
setExampleBusy(null);
|
||||
}
|
||||
},
|
||||
[exampleBusy, agentSkills, onSend],
|
||||
);
|
||||
|
||||
// @ handoff: opens a new chat for the @-mentioned agent (approval mode carries over from the
|
||||
// draft's current value; model/Workspace use the creation defaults), first input =
|
||||
// <handoff_from> source block + the text and images with the @ mention stripped.
|
||||
const selectedAgent = agents.find((a) => a.agentId === agentId) ?? null;
|
||||
const onHandoff = useCallback(
|
||||
async (target: AgentSummary, input: TaskInputPart[]): Promise<boolean> => {
|
||||
if (!selectedAgent) return false;
|
||||
if (!selectedAgent || sendingRef.current) return false;
|
||||
sendingRef.current = true;
|
||||
setSending(true);
|
||||
const origin: TaskInputPart = {
|
||||
type: "text",
|
||||
text: handoffMessage({
|
||||
@@ -308,6 +365,9 @@ export function DraftView({
|
||||
apiErrorText(e, models?.defaultModel ? { modelId: models.defaultModel.modelId } : {}),
|
||||
);
|
||||
return false;
|
||||
} finally {
|
||||
sendingRef.current = false;
|
||||
setSending(false);
|
||||
}
|
||||
},
|
||||
[projectId, selectedAgent, approvalMode, add, discardDraft, navigate, models],
|
||||
@@ -319,15 +379,21 @@ export function DraftView({
|
||||
const vision = modelInfo?.vision !== false;
|
||||
|
||||
return (
|
||||
<div className="anim-fade flex min-h-0 flex-1 flex-col overflow-y-auto px-3 md:px-4">
|
||||
<div className="anim-fade flex min-h-0 flex-1 flex-col overflow-y-auto px-3 py-6 md:px-4">
|
||||
{/*
|
||||
* Vertical layout: two symmetric flex-1 spaces above and below, so **the input card lands
|
||||
* exactly on the viewport's centerline**; the brand area sits at the bottom of the upper
|
||||
* space (right above the input card). When the viewport is too short, the upper space
|
||||
* shrinks to the brand area's height, the lower space compresses to padding, and the whole
|
||||
* page falls back to natural document-flow scrolling (without clipping the top).
|
||||
* Vertical layout: everything visible — brand, input card, ownership pills, example tasks —
|
||||
* lives in ONE block between two empty flex-1 spacers, so the block is centred and the free
|
||||
* space above and below it is exactly equal. The brand deliberately sits inside that block
|
||||
* rather than in the upper spacer: keeping it in the spacer made the upper gap shorter than
|
||||
* the lower one by the brand's own height, which pushed the card up the viewport and left
|
||||
* the slash menu — it opens upward, `bottom-full` — too little room, so it clipped against
|
||||
* the top of this scroll container. When the viewport is too short the spacers collapse to
|
||||
* nothing, the container's own py-6 keeps the content off the edges, and the page falls back
|
||||
* to natural scrolling.
|
||||
*/}
|
||||
<div className="flex flex-1 flex-col justify-end pt-6">
|
||||
<div className="flex-1" />
|
||||
|
||||
<div className="mx-auto w-full max-w-3xl">
|
||||
{/* Large brand logo + brand name + subtitle (e2e tests identify the draft page by this
|
||||
heading). The asset is square-cropped and the graphic already has a bit of built-in
|
||||
padding, so a small margin is enough to sit visually close to the title. */}
|
||||
@@ -338,9 +404,7 @@ export function DraftView({
|
||||
</h1>
|
||||
<p className="mt-2 text-base text-gray-400 dark:text-gray-500">{S.chat.draftSubtitle}</p>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="mx-auto w-full max-w-3xl">
|
||||
<ChatInput
|
||||
status="idle"
|
||||
onSend={onSend}
|
||||
@@ -373,10 +437,121 @@ export function DraftView({
|
||||
<AgentSelect agents={agents} selected={selectedAgent} onSelect={selectAgent} />
|
||||
<WorkspaceSelect projectId={projectId} workspace={workspace} onChange={setWorkspace} />
|
||||
</div>
|
||||
|
||||
{/* Example tasks: one-click canned builds showing off the one-sentence → app flow,
|
||||
stacked vertically in display order on every viewport. Disabled until
|
||||
agents/models/skills are resolved (onSend would silently no-op without an Agent);
|
||||
hover only darkens the border, per the card convention. */}
|
||||
<div className="mt-6 flex flex-col items-stretch gap-2">
|
||||
{EXAMPLE_TASKS.map((task) => {
|
||||
const copy = S.chat.exampleTasks[task.id];
|
||||
return (
|
||||
<button
|
||||
key={task.id}
|
||||
type="button"
|
||||
disabled={exampleBusy !== null || sending || !skillsLoaded || !agentId || !models}
|
||||
onClick={() => void runExample(task)}
|
||||
className="group flex min-w-0 items-center gap-3 rounded-xl border border-gray-200 bg-white px-4 py-3 text-left transition-colors duration-150 hover:border-gray-300 disabled:cursor-default disabled:opacity-60 dark:border-gray-800 dark:bg-gray-900 dark:hover:border-gray-700"
|
||||
>
|
||||
{/* 24×24 line icons (gamepad / music note / sparkle), consistent with the icon convention */}
|
||||
{task.id === "lol" ? (
|
||||
<svg
|
||||
width="20"
|
||||
height="20"
|
||||
viewBox="0 0 24 24"
|
||||
fill="none"
|
||||
stroke="currentColor"
|
||||
className="shrink-0 text-brand-500 dark:text-brand-400"
|
||||
aria-hidden
|
||||
>
|
||||
<path
|
||||
d="M9 18V6l11-2v12"
|
||||
strokeWidth="1.7"
|
||||
strokeLinecap="round"
|
||||
strokeLinejoin="round"
|
||||
/>
|
||||
<circle cx="6.5" cy="18" r="2.5" strokeWidth="1.7" />
|
||||
<circle cx="17.5" cy="16" r="2.5" strokeWidth="1.7" />
|
||||
</svg>
|
||||
) : task.id === "game" ? (
|
||||
<svg
|
||||
width="20"
|
||||
height="20"
|
||||
viewBox="0 0 24 24"
|
||||
fill="none"
|
||||
stroke="currentColor"
|
||||
className="shrink-0 text-brand-500 dark:text-brand-400"
|
||||
aria-hidden
|
||||
>
|
||||
<path
|
||||
d="M6.7 6h10.6a4 4 0 0 1 3.97 3.56c.2 1.8.73 5.05.73 6.44a3 3 0 0 1-3 3c-1 0-1.5-.5-2-1l-1.4-1.4a2 2 0 0 0-1.42-.6H9.82a2 2 0 0 0-1.41.6L7 18c-.5.5-1 1-2 1a3 3 0 0 1-3-3c0-1.39.52-4.64.73-6.44A4 4 0 0 1 6.7 6z"
|
||||
strokeWidth="1.7"
|
||||
strokeLinejoin="round"
|
||||
/>
|
||||
<path
|
||||
d="M6.5 11h4M8.5 9v4M15 12h.01M18 10h.01"
|
||||
strokeWidth="1.7"
|
||||
strokeLinecap="round"
|
||||
/>
|
||||
</svg>
|
||||
) : (
|
||||
<svg
|
||||
width="20"
|
||||
height="20"
|
||||
viewBox="0 0 24 24"
|
||||
fill="none"
|
||||
stroke="currentColor"
|
||||
className="shrink-0 text-brand-500 dark:text-brand-400"
|
||||
aria-hidden
|
||||
>
|
||||
<path
|
||||
d="M12 3l1.9 5.1L19 10l-5.1 1.9L12 17l-1.9-5.1L5 10l5.1-1.9L12 3z"
|
||||
strokeWidth="1.7"
|
||||
strokeLinejoin="round"
|
||||
/>
|
||||
<path
|
||||
d="M18.5 15.5l.9 2.1 2.1.9-2.1.9-.9 2.1-.9-2.1-2.1-.9 2.1-.9.9-2.1z"
|
||||
strokeWidth="1.4"
|
||||
strokeLinejoin="round"
|
||||
/>
|
||||
</svg>
|
||||
)}
|
||||
<span className="min-w-0 flex-1">
|
||||
<span className="block truncate text-sm font-medium text-gray-900 dark:text-gray-100">
|
||||
{copy.label}
|
||||
</span>
|
||||
<span className="line-clamp-2 text-xs text-gray-500 dark:text-gray-400">
|
||||
{copy.desc}
|
||||
</span>
|
||||
</span>
|
||||
{exampleBusy === task.id ? (
|
||||
<span className="ml-1 shrink-0 text-xs text-gray-400">{S.common.loading}</span>
|
||||
) : (
|
||||
<svg
|
||||
width="16"
|
||||
height="16"
|
||||
viewBox="0 0 24 24"
|
||||
fill="none"
|
||||
stroke="currentColor"
|
||||
className="ml-1 shrink-0 text-gray-300 transition-colors duration-150 group-hover:text-gray-500 dark:text-gray-600 dark:group-hover:text-gray-400"
|
||||
aria-hidden
|
||||
>
|
||||
<path
|
||||
d="M5 12h14M13 6l6 6-6 6"
|
||||
strokeWidth="1.7"
|
||||
strokeLinecap="round"
|
||||
strokeLinejoin="round"
|
||||
/>
|
||||
</svg>
|
||||
)}
|
||||
</button>
|
||||
);
|
||||
})}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Lower symmetric space (compresses to padding at minimum height) */}
|
||||
<div className="flex-1 pb-6" />
|
||||
{/* Lower symmetric space — empty, so it matches the upper one exactly */}
|
||||
<div className="flex-1" />
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user