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
@@ -53,6 +53,9 @@ const CARD_ICONS = {
vaultKeys: "M15.5 7.5l3 3L22 7l-3-3M21 2l-9.6 9.6M13 15.5a5.5 5.5 0 1 1-11 0 5.5 5.5 0 0 1 11 0z",
/** Schedule count (alarm clock: dial + hands + twin bells, distinct from the plain clock face used for "last modified") */
schedules: "M12 21a7 7 0 1 0 0-14 7 7 0 0 0 0 14zm0-10v3l2 1.5M5 3L2.5 5.5M19 3l2.5 2.5",
/** Installed skill count (open book, same family as the skill library) */
skills:
"M12 6.5C10.5 5 8 4.5 4 5v12c4-.5 6.5 0 8 1.5 1.5-1.5 4-2 8-1.5V5c-4-.5-6.5 0-8 1.5zm0 0V18",
/** Usage (bar chart, same as sidebar "Usage Center") */
usage: "M4 20V10m6 10V4m6 16v-7m4 7H2",
/** Traces (eye line icon: observability; follows text color, no fill) */
@@ -245,6 +248,13 @@ export function AgentsPage() {
<GlyphIcon d={CARD_ICONS.schedules} size={12} />
{a.scheduleCount}
</span>
<span
className="inline-flex min-w-[2.25rem] shrink-0 items-center gap-1 tabular-nums"
title={S.skills.skillCount(a.skillCount)}
>
<GlyphIcon d={CARD_ICONS.skills} size={12} />
{a.skillCount}
</span>
<span
className="inline-flex shrink-0 items-center gap-1"
title={`${S.agent.updatedAt} ${a.updatedAt ? formatDateTime(a.updatedAt) : "—"}`}
@@ -5,13 +5,14 @@
* Readable by any member; toggle/edit/delete are owner-only — PUT has whole-file
* replace semantics, so toggling also resends every field and only flips `enabled`.
* startAt/endAt use datetime-local inputs (local timezone), converted to ISO 8601 on
* submit; the "new Session each run" mode can also pick a Model (a provider + modelId
* pair; defaults to the Project default; mutual exclusivity with sessionId is
* validated server-side).
* submit; the "new Session each run" mode can also pick a Model — always a complete
* (provider, modelId) pair, since provider is never inferred; omitting it entirely falls
* back to the Project default. Mutual exclusivity with sessionId is validated server-side.
*/
import { useCallback, useEffect, useState } from "react";
import type {
ModelInfo,
ModelRefDto,
ScheduleItem,
SchedulesResponse,
ScheduleStatus,
@@ -49,37 +50,35 @@ function toLocalInput(iso: string | undefined): string {
return `${d.getFullYear()}-${pad(d.getMonth() + 1)}-${pad(d.getDate())}T${pad(d.getHours())}:${pad(d.getMinutes())}`;
}
/**
* The model selection value in the form: a paired reference. provider may be
* omitted — when a hand-written TOML only specifies model_id, it's resolved by
* unique match at parse time; editing never fills in a provider on its own.
*/
interface ScheduleModelRef {
provider?: string;
modelId: string;
}
/**
* Model reference ↔ native select option value: encoded as a JSON array
* ([provider ?? null, modelId]), used only as a transient DOM-value serialization —
* ([provider, modelId]), used only as a transient DOM-value serialization —
* it never enters storage or requests; the persisted/submitted data still keeps
* provider and modelId as two separate fields (no concatenation anywhere in the
* pipeline). "" means the Project default.
*/
const modelOptionValue = (ref: ScheduleModelRef): string =>
JSON.stringify([ref.provider ?? null, ref.modelId]);
const modelOptionValue = (ref: ModelRefDto): string => JSON.stringify([ref.provider, ref.modelId]);
const parseModelOption = (v: string): ScheduleModelRef | null => {
const parseModelOption = (v: string): ModelRefDto | null => {
if (!v) return null;
const [provider, modelId] = JSON.parse(v) as [string | null, string];
return provider === null ? { modelId } : { provider, modelId };
const [provider, modelId] = JSON.parse(v) as [string, string];
return { provider, modelId };
};
/** Display label for a model option: upstream id + provider name (shown side by side, not a composite id). */
const modelOptionLabel = (ref: ScheduleModelRef): string =>
ref.provider === undefined
? ref.modelId
: `${ref.modelId} · ${providerInfo(ref.provider)?.label ?? ref.provider}`;
const modelOptionLabel = (ref: ModelRefDto): string =>
`${ref.modelId} · ${providerInfo(ref.provider)?.label ?? ref.provider}`;
/**
* Stored schedule fields → a model reference. A reference is always the complete
* (provider, model_id) pair, since provider is never inferred: the DTO types the two
* fields independently, so this guard is what keeps the form and the upsert body from
* ever assembling half a reference. A file that sets only one half is rejected by the
* server when parsed (it surfaces under invalidFiles, never as a listed row), so in
* practice this returns null only when the schedule uses the Project's default model.
*/
const itemModelRef = (item: Pick<ScheduleItem, "provider" | "modelId">): ModelRefDto | null =>
item.modelId && item.provider ? { provider: item.provider, modelId: item.modelId } : null;
/** Modal form state (shared by create/edit): non-null editing means editing that task (name locked). */
interface FormState {
@@ -94,8 +93,8 @@ interface FormState {
target: "new" | "session";
sessionId: string;
workspace: string;
/** Model for the new-Session mode (null = Project default, modelId/provider omitted). */
model: ScheduleModelRef | null;
/** Model for the new-Session mode (null = Project default, provider and modelId both omitted). */
model: ModelRefDto | null;
}
const EMPTY_FORM: FormState = {
@@ -191,10 +190,7 @@ export function SchedulesTab({ agentId }: { agentId: string }) {
? { workspace: form.workspace.trim() }
: {}),
...(form.target === "new" && form.model
? {
modelId: form.model.modelId,
...(form.model.provider !== undefined ? { provider: form.model.provider } : {}),
}
? { modelId: form.model.modelId, provider: form.model.provider }
: {}),
};
setBusy(true);
@@ -218,6 +214,7 @@ export function SchedulesTab({ agentId }: { agentId: string }) {
setBusy(true);
setError(null);
setNotice(null);
const model = itemModelRef(item);
try {
await api.updateSchedule(projectId, agentId, item.name, {
prompt: item.prompt,
@@ -227,9 +224,8 @@ export function SchedulesTab({ agentId }: { agentId: string }) {
...(item.endAt !== undefined ? { endAt: item.endAt } : {}),
...(item.sessionId !== undefined ? { sessionId: item.sessionId } : {}),
...(item.workspace !== undefined ? { workspace: item.workspace } : {}),
// Model reference is resent as a pair (a hand-written file with provider omitted stays omitted).
...(item.modelId !== undefined ? { modelId: item.modelId } : {}),
...(item.provider !== undefined ? { provider: item.provider } : {}),
// Model reference is resent as a whole pair or not at all — never half of one.
...(model ? { modelId: model.modelId, provider: model.provider } : {}),
});
setNotice(S.common.saved);
await load();
@@ -240,7 +236,7 @@ export function SchedulesTab({ agentId }: { agentId: string }) {
}
};
/** Edit: prefill this row into the modal form (submits via PUT; model reference prefilled as a pair, provider not auto-filled when absent). */
/** Edit: prefill this row into the modal form (submits via PUT; the model reference is prefilled only as a complete pair). */
const startEdit = (item: ScheduleItem) => {
openForm({
editing: item.name,
@@ -253,13 +249,7 @@ export function SchedulesTab({ agentId }: { agentId: string }) {
target: item.sessionId ? "session" : "new",
sessionId: item.sessionId ?? "",
workspace: item.workspace ?? "",
model:
item.modelId !== undefined
? {
modelId: item.modelId,
...(item.provider !== undefined ? { provider: item.provider } : {}),
}
: null,
model: itemModelRef(item),
});
};
@@ -494,9 +484,9 @@ export function SchedulesTab({ agentId }: { agentId: string }) {
onChange={(e) => set({ model: parseModelOption(e.target.value) })}
>
<option value="">{S.schedule.modelDefault}</option>
{/* When the prefilled reference is no longer in the model config (including a
hand-written file missing only provider), add an extra option so it isn't
displayed as "Project default". */}
{/* When the prefilled pair is no longer in the model config (the entry was
renamed or deleted), add an extra option so it isn't displayed as
"Project default". */}
{form.model &&
!models.some(
(m) =>
+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);
+192 -17
View File
@@ -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>
);
}
@@ -1,12 +1,13 @@
/**
* Live duration (inline display on running thinking/tool cards): ticks every second. sinceMs
* Live duration (inline display on running thinking/tool cards): ticks every second, showing
* whole seconds only — decimals appear only on the settled value once the item finishes. sinceMs
* comes from the server-side message timestamp and may drift from the local clock; negative
* values are shown as 0; a pulsing ellipsis is shown when missing.
* `offsetMs` is the already-settled duration of a prior segment (e.g. a tool call's argument
* generation phase), added on top of the live segment as it ticks.
*/
import { useEffect, useState } from "react";
import { humanizeDuration } from "../../lib/format";
import { humanizeDurationLive } from "../../lib/format";
export function LiveDuration({ sinceMs, offsetMs = 0 }: { sinceMs?: number; offsetMs?: number }) {
const [now, setNow] = useState(() => Date.now());
@@ -15,5 +16,5 @@ export function LiveDuration({ sinceMs, offsetMs = 0 }: { sinceMs?: number; offs
return () => clearInterval(id);
}, []);
if (sinceMs === undefined) return <span className="animate-pulse">…</span>;
return <>{humanizeDuration(Math.max(0, offsetMs) + Math.max(0, now - sinceMs))}</>;
return <>{humanizeDurationLive(Math.max(0, offsetMs) + Math.max(0, now - sinceMs))}</>;
}
+76
View File
@@ -0,0 +1,76 @@
/**
* Markdown body memoized by text identity. The stream model mutates chat items in place and
* only reassigns `text` when a delta arrives, so every settled message keeps the same string
* instance across the per-frame version bumps — the shallow prop compare then skips its entire
* micromark → mdast → React re-parse, and only the actively-streaming message re-renders.
* Without this, each animation frame during a stream re-parsed the WHOLE transcript (O(n²)
* over a long reply), which visibly froze the UI while large code blocks streamed in.
*
* Fenced code blocks render through CodeBlock (language chrome + copy button + Shiki
* highlight). `streaming` disables highlighting while deltas are still arriving —
* re-tokenizing a growing block every frame is O(n²) main-thread cost — and the settle
* re-render (new string instance, streaming=false) highlights each block exactly once.
* Inline code keeps the default rendering (`.md-body code` styling).
*/
import { isValidElement, memo } from "react";
import type { ReactNode } from "react";
import ReactMarkdown from "react-markdown";
import type { Components } from "react-markdown";
import remarkGfm from "remark-gfm";
import { CodeBlock } from "./code-block";
/** Flatten a react-markdown code element's children to plain text (string or string array in practice). */
function codeText(children: unknown): string {
if (typeof children === "string") return children;
if (Array.isArray(children)) return children.filter((c) => typeof c === "string").join("");
return "";
}
/** Fenced-block adapter: unwraps the <pre><code class="language-x"> pair react-markdown emits into CodeBlock. */
function MdPre({ children, streaming }: { children?: ReactNode; streaming: boolean }) {
if (isValidElement(children)) {
const props = children.props as { className?: string; children?: unknown };
const language = /language-([\w+-]+)/.exec(props.className ?? "")?.[1] ?? "";
return (
<CodeBlock
language={language}
code={codeText(props.children).replace(/\n$/, "")}
highlight={!streaming}
/>
);
}
return <pre>{children}</pre>;
}
/**
* The two `components` maps, built once at module scope instead of inline per render.
* react-markdown uses `components.pre` as the element **type**, so a fresh arrow each render is
* a new type on every commit: React unmounts and remounts every code block, dropping the user's
* text selection and resetting each block's Copy-button state — ~8 times a second while a reply
* streams, including for blocks that closed long ago. `streaming` is the only thing the adapter
* closes over, so one frozen map per value is enough; the single flip between them happens on
* the settle render, which re-parses the message anyway.
*/
const STREAMING_COMPONENTS: Components = {
pre: (props) => <MdPre streaming>{props.children}</MdPre>,
};
const SETTLED_COMPONENTS: Components = {
pre: (props) => <MdPre streaming={false}>{props.children}</MdPre>,
};
export const Md = memo(function Md({
text,
streaming = false,
}: {
text: string;
streaming?: boolean;
}) {
return (
<ReactMarkdown
remarkPlugins={[remarkGfm]}
components={streaming ? STREAMING_COMPONENTS : SETTLED_COMPONENTS}
>
{text}
</ReactMarkdown>
);
});
@@ -5,14 +5,13 @@
* stats lines. Items have a light entrance animation.
*/
import { useState } from "react";
import ReactMarkdown from "react-markdown";
import remarkGfm from "remark-gfm";
import { S } from "../../lib/strings";
import { useLocale } from "../../state/locale";
import { formatMessageTime } from "../../lib/format";
import { STAT_ICONS } from "../../lib/stat-icons";
import { splitImageAttachments } from "../../lib/attachments";
import type { ChatItem } from "../../lib/omni/stream-model";
import { Md } from "./md";
import { GlyphIcon } from "../../components/ui/glyph-icon";
import { ZoomableImage } from "../../components/ui/image-zoom";
import { MessageFilesCard } from "./message-files-card";
@@ -158,8 +157,8 @@ export function MessageItem({ item, ctx }: { item: ChatItem; ctx: StreamRenderCo
// which is more useful than copying segment by segment.
return (
<div className="md-body anim-msg my-3 text-base leading-relaxed text-gray-800 dark:text-gray-100">
{/* Re-renders the accumulated text directly while streaming (a key point of the contract implementation). */}
<ReactMarkdown remarkPlugins={[remarkGfm]}>{item.text}</ReactMarkdown>
{/* Re-renders the accumulated text directly while streaming (a key point of the contract implementation); memoized so settled messages skip the re-parse, and code blocks highlight once on settle (see md.tsx). */}
<Md text={item.text} streaming={item.streaming} />
{item.streaming && <span className="animate-pulse text-gray-400">▌</span>}
{item.stopReason && item.stopReason !== "completed" && (
<span className="ml-1 font-mono text-xs text-gray-400">[{item.stopReason}]</span>
+154 -24
View File
@@ -5,7 +5,7 @@
* StreamRenderContext threads the pending-approval map and approval callback down to tool
* cards at any nesting depth.
*/
import { useEffect, useRef } from "react";
import { useEffect, useLayoutEffect, useRef, useState } from "react";
import type { ReactNode } from "react";
import { S } from "../../lib/strings";
import type { ChatItem } from "../../lib/omni/stream-model";
@@ -144,45 +144,175 @@ export function MessageStream({
// An upward-swipe intent immediately exits auto-follow; scrolling back near the bottom resumes it — see stream-follow.ts (#75) for the exact rule.
const followRef = useRef<StreamFollow | null>(null);
const follow = (followRef.current ??= createStreamFollow());
// Back-to-bottom button visibility (React state — the follow object mutates outside React):
// shown only when follow is off AND there is actually content below the fold. The second
// condition matters: a wheel-up flick over a list that already fits the viewport exits follow
// (stream-follow.ts keeps that rule deliberately) but must not surface a "jump to latest"
// button with nothing to jump to. Synced after every event that can change either input
// (wheel-up at the very top fires no scroll event, so syncing on scroll alone is not enough)
// and in the version effect (content growing while unstuck fires no scroll event either).
const [showJump, setShowJump] = useState(false);
/**
* Animated-return phase (back-to-bottom clicked, glide in flight). The glide is a
* self-driven rAF loop rather than native scrollTo({behavior:"smooth"}) for two
* live-tested reasons: (1) re-aiming a native smooth scroll on every stream commit resets
* its easing, capping descent below a fast stream's growth rate — the return then never
* arrives; (2) an in-flight native smooth scroll cannot be interrupted, so a wheel-up
* cancel would keep dragging the user downward for hundreds of ms. The rAF loop chases the
* LIVE bottom each frame with a velocity floor above any realistic growth rate, and
* cancelling it stops the descent within a frame. While returning, the button stays hidden
* and the stick snap must not fire (an instant snap on the next commit would kill the glide).
*/
const returningRef = useRef(false);
const returnRafRef = useRef<number | null>(null);
const cancelReturn = () => {
if (returnRafRef.current !== null) {
cancelAnimationFrame(returnRafRef.current);
returnRafRef.current = null;
}
returningRef.current = false;
};
useEffect(() => () => cancelReturn(), []);
/** Local previous scrollTop, only for detecting the user fighting the return animation upward (the follow object keeps its own). */
const lastTopRef = useRef<number | null>(null);
const syncJump = () => {
const el = scrollRef.current;
setShowJump(
!returningRef.current &&
!follow.stick &&
el !== null &&
el.scrollHeight - el.scrollTop - el.clientHeight > 1,
);
};
const onScroll = () => {
const el = scrollRef.current;
if (!el) return;
const prevTop = lastTopRef.current;
lastTopRef.current = el.scrollTop;
// Scrollbar-drag / keyboard scroll upward during the return cancels it (wheel/touch cancel in their own handlers).
if (returningRef.current && prevTop !== null && el.scrollTop < prevTop - 1) {
cancelReturn();
}
follow.scrolled({
scrollTop: el.scrollTop,
scrollHeight: el.scrollHeight,
clientHeight: el.clientHeight,
});
syncJump();
};
useEffect(() => {
// Layout effect (not useEffect): the stick-to-bottom snap must land before paint, otherwise
// fast streams show the bottom edge "catching up" by the growth of each commit. Suppressed
// during the animated return — the glide owns the scroll position until it arrives.
useLayoutEffect(() => {
const el = scrollRef.current;
if (el && follow.stick) el.scrollTop = el.scrollHeight;
if (el && follow.stick && !returningRef.current) el.scrollTop = el.scrollHeight;
syncJump();
// syncJump is recreated per render; the effect intentionally keys on stream growth only.
// eslint-disable-next-line react-hooks/exhaustive-deps
}, [version, follow]);
/** Back-to-bottom: glide down to the live bottom (reduced motion gets an instant jump); follow re-engages on arrival. */
const jumpToLatest = () => {
const el = scrollRef.current;
if (!el) return;
if (window.matchMedia("(prefers-reduced-motion: reduce)").matches) {
follow.resume();
el.scrollTop = el.scrollHeight;
syncJump();
return;
}
cancelReturn();
returningRef.current = true;
// Far away: teleport to three viewports above the bottom first, then glide the rest —
// bounds the animation to well under a second regardless of how far the user scrolled.
const far = el.scrollHeight - el.clientHeight - el.scrollTop;
if (far > 3 * el.clientHeight) el.scrollTop = el.scrollHeight - 4 * el.clientHeight;
let last = performance.now();
const stepFrame = (now: number) => {
returnRafRef.current = null;
const live = scrollRef.current;
if (!live || !returningRef.current) return;
const dt = Math.min(0.05, Math.max(0.001, (now - last) / 1000));
last = now;
const remaining = live.scrollHeight - live.clientHeight - live.scrollTop;
if (remaining <= 1) {
returningRef.current = false;
follow.resume();
live.scrollTop = live.scrollHeight;
syncJump();
return;
}
// Proportional ease-out toward the LIVE bottom, with a time-based velocity floor
// (3200px/s) that outruns any realistic streaming growth so the glide always lands.
const step = Math.max(remaining * Math.min(1, dt * 10), 3200 * dt);
live.scrollTop += Math.min(step, remaining);
returnRafRef.current = requestAnimationFrame(stepFrame);
};
returnRafRef.current = requestAnimationFrame(stepFrame);
syncJump();
};
return (
<div
ref={scrollRef}
onScroll={onScroll}
onWheel={(e) => follow.wheel(e.deltaY)}
onTouchStart={(e) => {
const t = e.touches[0];
if (t) follow.touchStart(t.clientY);
}}
onTouchMove={(e) => {
const t = e.touches[0];
if (t) follow.touchMove(t.clientY);
}}
onTouchEnd={() => follow.touchEnd()}
className="anim-fade h-full overflow-y-auto px-4 py-4 md:px-6"
>
<div className="mx-auto max-w-3xl">
{items.length === 0 ? (
<EmptyState title={S.chat.emptyStream} />
) : (
<MessageItems items={items} ctx={ctx} />
)}
<div className="relative h-full min-h-0">
<div
ref={scrollRef}
onScroll={onScroll}
onWheel={(e) => {
follow.wheel(e.deltaY);
if (e.deltaY < 0) cancelReturn(); // user takes over: the glide stops within a frame
syncJump();
}}
onTouchStart={(e) => {
const t = e.touches[0];
if (t) follow.touchStart(t.clientY);
}}
onTouchMove={(e) => {
const t = e.touches[0];
if (t) follow.touchMove(t.clientY);
if (returningRef.current) cancelReturn(); // touching the list mid-return = taking control
syncJump();
}}
onTouchEnd={() => follow.touchEnd()}
className="anim-fade h-full overflow-y-auto px-4 py-4 md:px-6"
>
<div className="mx-auto max-w-3xl">
{items.length === 0 ? (
<EmptyState title={S.chat.emptyStream} />
) : (
<MessageItems items={items} ctx={ctx} />
)}
</div>
</div>
{/* Back-to-bottom (shows once the user scrolls away from content below the fold): floats
just above the composer; clicking returns to the bottom and re-enters follow, so the
view keeps tracking the live stream. */}
{showJump && (
<button
type="button"
aria-label={S.chat.jumpToLatest}
title={S.chat.jumpToLatest}
onClick={jumpToLatest}
className="anim-pop absolute bottom-3 left-1/2 z-10 -translate-x-1/2 rounded-full border border-gray-300 bg-white p-1.5 text-gray-500 shadow-sm transition-colors duration-150 hover:bg-gray-50 hover:text-gray-900 dark:border-gray-700 dark:bg-gray-900 dark:text-gray-400 dark:hover:bg-gray-800 dark:hover:text-gray-100"
>
<svg
width="16"
height="16"
viewBox="0 0 24 24"
fill="none"
stroke="currentColor"
aria-hidden
>
<path
d="M12 5v14M6 13l6 6 6-6"
strokeWidth="1.7"
strokeLinecap="round"
strokeLinejoin="round"
/>
</svg>
</button>
)}
</div>
);
}
@@ -0,0 +1,42 @@
/**
* Positional slash-command matching for the chat input (pure logic, unit-tested):
* like @ mentions, a `/` opens the command menu from ANY caret position — it must sit at
* the start of the text or be preceded by whitespace (so URLs and paths like `a/b` never
* trigger it), with only command characters between the `/` and the caret. Running a
* command removes just the `start..end` token, leaving the rest of the text intact.
*/
/** Command characters allowed between `/` and the caret (command names and skill names: letters, digits, underscore, hyphen). */
const CMD_PREFIX = /^[\w-]*$/;
/** The slash token currently being typed: `start` is the index of `/`, `query` is the text between `/` and the caret (no leading slash), `end` extends over the same token to the right of the caret. */
export interface SlashMatch {
start: number;
end: number;
query: string;
}
/** Finds the slash command currently being typed at the caret; returns null if none. */
export function matchSlash(text: string, caret: number): SlashMatch | null {
const before = text.slice(0, caret);
const at = before.lastIndexOf("/");
if (at < 0) return null;
if (at > 0 && !/\s/.test(before[at - 1]!)) return null;
const query = before.slice(at + 1);
if (!CMD_PREFIX.test(query)) return null;
const rest = /^[\w-]*/.exec(text.slice(caret))![0];
return { start: at, end: caret + rest.length, query };
}
/** Removes the matched token from the text (what a slash command's run leaves behind); collapses a doubled space at the seam. */
export function removeSlashToken(text: string, match: SlashMatch): string {
const before = text.slice(0, match.start);
const after = text.slice(match.end);
// Only the seam is touched: the token used to separate its two neighbours, so dropping it would
// leave `a b` — one of those two spaces goes. The rest of the draft stays byte-for-byte (a
// global collapse would silently reflow pasted code, YAML, aligned tables and indentation).
const joined =
before.endsWith(" ") && after.startsWith(" ") ? before + after.slice(1) : before + after;
// The token was the entire content (possibly padded with whitespace): leave a truly empty input.
return /^\s*$/.test(joined) ? "" : joined;
}
@@ -35,6 +35,8 @@ export interface StreamFollow {
touchEnd(): void;
/** scroll event (user scrolling and programmatic stick-to-bottom share this path): moving up exits; otherwise nearing the bottom resumes; the first event initializes from position. */
scrolled(m: ScrollMetrics): void;
/** Explicit re-entry (the back-to-bottom button): resumes follow immediately — the caller scrolls to the bottom right after, and the resulting scroll event sees a bottom position, keeping it stuck. */
resume(): void;
}
export function createStreamFollow(): StreamFollow {
@@ -72,5 +74,11 @@ export function createStreamFollow(): StreamFollow {
}
if (dist < 80) stick = true;
},
resume() {
stick = true;
// Forget the last position: the caller jumps to the bottom right after, and that large
// downward scroll must not be judged against a stale historical scrollTop.
lastTop = null;
},
};
}
@@ -4,8 +4,6 @@
* style and set of running-state icons (in progress / done / failed) as tool cards.
*/
import { useState } from "react";
import ReactMarkdown from "react-markdown";
import remarkGfm from "remark-gfm";
import { S } from "../../lib/strings";
import { humanizeDuration } from "../../lib/format";
import type { ThinkingItem } from "../../lib/omni/stream-model";
@@ -13,6 +11,7 @@ import { Chevron } from "../../components/ui/chevron";
import { StatusIcon } from "../../components/ui/status-icon";
import type { RunState } from "../../components/ui/status-icon";
import { LiveDuration } from "./live-duration";
import { Md } from "./md";
export function ThinkingBlock({ item }: { item: ThinkingItem }) {
const [open, setOpen] = useState(false);
@@ -47,7 +46,7 @@ export function ThinkingBlock({ item }: { item: ThinkingItem }) {
</button>
{open && (
<div className="md-body anim-fade mx-3 mb-2 rounded-md bg-gray-50 px-3 py-2 text-sm leading-relaxed text-gray-600 dark:bg-gray-900/60 dark:text-gray-400">
<ReactMarkdown remarkPlugins={[remarkGfm]}>{item.thinking}</ReactMarkdown>
<Md text={item.thinking} streaming={item.streaming} />
</div>
)}
</div>
@@ -26,6 +26,9 @@ import type { StreamModel } from "../../lib/omni/stream-model";
export type { PendingApproval } from "../../lib/omni/stream-controller";
/** Minimum spacing between version commits: below one frame at 8fps, invisible as staleness, but ~8× fewer full re-parses of the growing message during a fast large-code stream. */
const BUMP_MIN_INTERVAL_MS = 120;
export interface SessionStreamState {
/** View model (updated in place; version bump triggers re-render). */
model: StreamModel;
@@ -70,13 +73,33 @@ export function useSessionStream(
const placeholderRef = useRef<StreamModel | null>(null);
if (placeholderRef.current === null) placeholderRef.current = createStreamModel();
const rafRef = useRef<number | null>(null);
const throttleRef = useRef<number | null>(null);
const lastBumpAtRef = useRef(0);
// Coalesce high-frequency deltas: multiple pushes within one frame trigger only a single re-render.
// Coalesce high-frequency deltas: multiple pushes within one frame trigger only a single
// re-render, and commits are additionally spaced ≥BUMP_MIN_INTERVAL_MS apart. Every commit
// re-renders (and re-parses) the currently streaming message at its full accumulated length,
// so per-frame commits during a large streamed reply go O(n²) and freeze the main thread —
// the interval caps that at a bounded, invisible staleness. Inside the interval a single
// trailing timer is armed (the flush): the final deltas always land even if no further push
// ever arrives.
const bump = useCallback(() => {
if (rafRef.current !== null) return;
if (rafRef.current !== null || throttleRef.current !== null) return;
const commit = () => {
lastBumpAtRef.current = Date.now();
setVersion((v) => v + 1);
};
const wait = lastBumpAtRef.current + BUMP_MIN_INTERVAL_MS - Date.now();
if (wait > 0) {
throttleRef.current = window.setTimeout(() => {
throttleRef.current = null;
commit();
}, wait);
return;
}
rafRef.current = requestAnimationFrame(() => {
rafRef.current = null;
setVersion((v) => v + 1);
commit();
});
}, []);
@@ -134,6 +157,10 @@ export function useSessionStream(
cancelAnimationFrame(rafRef.current);
rafRef.current = null;
}
if (throttleRef.current !== null) {
window.clearTimeout(throttleRef.current);
throttleRef.current = null;
}
};
// initialStatus only serves as the first-frame placeholder; it doesn't rebuild the connection
// on parent re-renders.
+27 -7
View File
@@ -24,6 +24,7 @@ import { Chevron } from "../../components/ui/chevron";
import { StatusIcon } from "../../components/ui/status-icon";
import { approvalKey } from "../../lib/omni/stream-model";
import type { ChatItem } from "../../lib/omni/stream-model";
import { LiveDuration } from "./live-duration";
import { MessageItem } from "./message-item";
import type { StreamRenderContext } from "./message-stream";
import { summarizeWork } from "./work-summary";
@@ -62,8 +63,11 @@ export function WorkGroup({
/** Whether this group is the last segment of the message stream (current turn still in progress): decides the default expanded/collapsed state. */
isLast: boolean;
}) {
// Whether any item is in flight right now — also the only window in which the group's span is
// still growing, which the duration display below depends on.
const itemsRunning = items.some((it) => itemActive(it, ctx));
// Last segment + Task running = the model might still call another tool → show Running (even if there's no active item right now).
const active = (isLast && ctx.taskRunning) || items.some((it) => itemActive(it, ctx));
const active = (isLast && ctx.taskRunning) || itemsRunning;
const pending = hasPendingApproval(items, ctx);
const [open, setOpen] = useState(isLast);
const userToggled = useRef(false);
@@ -79,7 +83,7 @@ export function WorkGroup({
// A pending approval must stay actionable: expand the group body regardless of collapsed state (the approval row lives inside it).
const shown = open || pending;
const { steps, durationMs } = summarizeWork(items);
const { steps, durationMs, startMs } = summarizeWork(items);
return (
<div className="anim-msg my-2 overflow-hidden rounded-md border border-gray-200 bg-white dark:border-gray-800 dark:bg-gray-900">
@@ -106,11 +110,27 @@ export function WorkGroup({
{S.chat.workGroupSteps(steps)}
</span>
)}
{durationMs > 0 && (
<span className="shrink-0 font-mono text-xs text-gray-400">
{humanizeDuration(durationMs)}
</span>
)}
{/* Both states show the same quantity: the summarizeWork span (earliest item start →
latest item end). That's the canonical definition here — it's what work-summary.ts
documents, and unlike a group-open→now wall clock it is reconstructible from the
stored timestamps, so reloading the transcript reproduces the same number. While an
item is in flight its end isn't known yet, so the tick extends the span to *now*
(whole seconds); with nothing in flight the value freezes at the computed span, with
decimals — the same "don't tick through a wait we don't count" idiom as a tool card
parked on an approval. Ticking on while the group merely stays Running (the model
thinking between steps, or streaming its answer) would climb past the span and then
snap backwards the moment the group settles. */}
{itemsRunning
? startMs !== undefined && (
<span className="shrink-0 font-mono text-xs text-gray-400">
<LiveDuration sinceMs={startMs} />
</span>
)
: durationMs > 0 && (
<span className="shrink-0 font-mono text-xs text-gray-400">
{humanizeDuration(durationMs)}
</span>
)}
{pending && !shown && (
<span className="shrink-0 rounded bg-amber-100 px-1 text-[10px] font-medium text-amber-700 dark:bg-amber-950/50 dark:text-amber-300">
{S.chat.approvalWaiting}
+40 -32
View File
@@ -2,38 +2,54 @@
* Summary for the "Reasoning & Tools" group header (pure logic, unit-testable): step count only
* counts **tool calls** (thinking doesn't count as a step).
*
* Duration is computed as the **union of time intervals**: overlapping time from parallel tool
* calls is counted only once, presenting the group's wall-clock working time rather than the sum
* of each item's duration (naive per-item summation would report 15-way parallel work spanning
* 9 minutes as 99 minutes). Intervals follow the same settlement convention as each item's
* durationMs (see settleToolDuration in stream-model.ts): thinking is [startedAtMs, +durationMs];
* a tool's durationMs = argument-generation segment + execution segment (approval wait time is
* excluded), and the two segments are not adjacent on the timeline, so they must be split back
* into two intervals using the same formula: [argStartedAtMs, +generation segment] and
* [approvalAtMs ?? callStartedAtMs, +execution segment] — computing the whole span from the
* execution start point would shift the generation segment into the execution period, producing
* false overlap with parallel tools. Gaps between intervals (waiting for the model's next step)
* are not counted; a segment missing a start point can't be checked for overlap and falls back to
* plain summation.
* Duration is the group's **real wall-clock span**: earliest segment start → latest segment end,
* including everything in between — approval waits, gaps while the model decides its next step,
* parallel work counted once. The header answers "how long did this group take", not "how much
* compute happened inside it" (per-item cards carry the fine-grained settled durations).
* Segment endpoints follow the same settlement convention as each item's durationMs (see
* settleToolDuration in stream-model.ts): thinking is [startedAtMs, +durationMs]; a tool's
* durationMs = argument-generation segment + execution segment (the approval wait between them
* is excluded from the item's own duration, but lands inside the group span), so the endpoints
* are [argStartedAtMs, +generation segment] and [approvalAtMs ?? callStartedAtMs, +execution
* segment]. Segments missing a start point can't be placed on the timeline and fall back to
* plain summation on top of the span. `startMs` is the earliest placed start — the anchor the
* group header ticks from while an item is still in flight (once nothing is running the header
* freezes at the computed span, so the displayed number never jumps backwards).
*/
import type { ChatItem } from "../../lib/omni/stream-model";
export function summarizeWork(items: ChatItem[]): { steps: number; durationMs: number } {
export function summarizeWork(items: ChatItem[]): {
steps: number;
durationMs: number;
startMs?: number;
} {
let steps = 0;
let durationMs = 0;
const intervals: [number, number][] = [];
let fallbackMs = 0;
let minStart: number | undefined;
let maxEnd: number | undefined;
/** Group open = the earliest start stamp of ANY item, settled or not — a still-streaming first item must already anchor the live tick. */
const seen = (startMs: number | undefined) => {
if (startMs !== undefined && (minStart === undefined || startMs < minStart)) minStart = startMs;
};
const add = (startMs: number | undefined, spanMs: number) => {
if (spanMs <= 0) return;
if (startMs === undefined) durationMs += spanMs;
else intervals.push([startMs, startMs + spanMs]);
if (startMs === undefined) {
fallbackMs += spanMs;
return;
}
seen(startMs);
const end = startMs + spanMs;
if (maxEnd === undefined || end > maxEnd) maxEnd = end;
};
for (const it of items) {
if (it.kind === "thinking") {
seen(it.startedAtMs);
if (it.durationMs !== undefined) add(it.startedAtMs, it.durationMs);
continue;
}
if (it.kind !== "tool_call") continue;
steps += 1;
seen(it.argStartedAtMs ?? it.callStartedAtMs ?? it.approvalAtMs);
if (it.durationMs === undefined) continue;
const genMs =
it.argStartedAtMs !== undefined && it.callStartedAtMs !== undefined
@@ -42,18 +58,10 @@ export function summarizeWork(items: ChatItem[]): { steps: number; durationMs: n
add(it.argStartedAtMs, genMs);
add(it.approvalAtMs ?? it.callStartedAtMs, it.durationMs - genMs);
}
intervals.sort((a, b) => a[0] - b[0]);
let curStart: number | null = null;
let curEnd = 0;
for (const [start, end] of intervals) {
if (curStart === null || start > curEnd) {
if (curStart !== null) durationMs += curEnd - curStart;
curStart = start;
curEnd = end;
} else if (end > curEnd) {
curEnd = end;
}
}
if (curStart !== null) durationMs += curEnd - curStart;
return { steps, durationMs };
const spanMs = minStart !== undefined && maxEnd !== undefined ? maxEnd - minStart : 0;
return {
steps,
durationMs: spanMs + fallbackMs,
...(minStart !== undefined ? { startMs: minStart } : {}),
};
}
@@ -401,10 +401,21 @@ export function WorkspaceBrowser({
))}
</div>
)}
{/* Ghost style, matching the toolbar's upload label (text-xs, transparent until hover) — the bordered secondary look stood out from every neighbor. */}
{/\.html?$/i.test(preview.name) && (
<a
href={api.workspaceFilePreviewUrl(session.sessionId, preview.path)}
target="_blank"
rel="noopener noreferrer"
className="inline-flex shrink-0 items-center rounded-md border border-transparent bg-transparent px-2.5 py-1 text-xs font-medium text-gray-600 transition-colors duration-150 hover:bg-gray-100 hover:text-gray-900 dark:text-gray-300 dark:hover:bg-gray-800 dark:hover:text-gray-100"
>
{S.files.openInNewTab}
</a>
)}
<a
href={api.workspaceFileUrl(session.sessionId, preview.path, true)}
download={preview.name}
className="inline-flex shrink-0 items-center rounded-md border border-gray-300 bg-white px-2.5 py-1 text-sm font-medium transition-colors duration-150 hover:bg-gray-50 dark:border-gray-700 dark:bg-gray-900 dark:hover:bg-gray-800"
className="inline-flex shrink-0 items-center rounded-md border border-transparent bg-transparent px-2.5 py-1 text-xs font-medium text-gray-600 transition-colors duration-150 hover:bg-gray-100 hover:text-gray-900 dark:text-gray-300 dark:hover:bg-gray-800 dark:hover:text-gray-100"
>
{S.files.download}
</a>
@@ -0,0 +1,74 @@
/**
* One-click sync of the Project model table with the built-in catalog ("sync presets" next
* to the search box): union semantics — catalog entries not configured locally are added;
* entries present on both sides are reset to the catalog's fields (context window, pricing,
* protocol, base URL, vision — the catalog wins wherever the two differ, including removing
* pricing the catalog doesn't carry); locally added models (including user-defined groups)
* are kept untouched. Credentials are never touched: merged rows carry no apiKey input (the
* PUT keeps the stored key) and existing rows keep their credential display state.
*/
import { presetModelEntries } from "@prismshadow/penguin-core/model-catalog";
import type { RowState } from "./models-page";
type PresetEntry = ReturnType<typeof presetModelEntries>[number];
/** The catalog-owned fields of a row, in RowState's string-typed form (mirrors toRow). */
function presetFields(p: PresetEntry) {
return {
vision: p.vision !== false,
contextWindow: p.context_window !== undefined ? String(p.context_window) : "",
clientType: p.client_type ?? "",
cacheRead: p.pricing ? String(p.pricing.cache_read) : "",
cacheWrite: p.pricing ? String(p.pricing.cache_write) : "",
output: p.pricing ? String(p.pricing.output) : "",
baseUrl: p.base_url ?? "",
};
}
/** A brand-new row for a catalog entry not configured locally (original: null -> added on PUT). */
function presetToRow(p: PresetEntry): RowState {
return {
provider: p.provider,
modelId: p.model_id,
original: null,
...presetFields(p),
originalBaseUrl: "",
apiKeyInput: "",
clearApiKey: false,
};
}
/**
* Merges the current rows with the built-in catalog. Existing rows keep their identity,
* credential state, and list position (fields are updated in place); catalog-only entries
* are appended in catalog order. Returns the merged rows plus added/updated counts for the
* success toast (updated counts only rows whose catalog-owned fields actually changed).
*/
export function syncRowsWithCatalog(
rows: RowState[],
preset: PresetEntry[] = presetModelEntries(),
): { rows: RowState[]; added: number; updated: number } {
const key = (provider: string, modelId: string) => `${provider}\0${modelId}`;
const index = new Map(rows.map((r, i) => [key(r.provider, r.modelId), i]));
const next = [...rows];
let added = 0;
let updated = 0;
for (const p of preset) {
const i = index.get(key(p.provider, p.model_id));
if (i === undefined) {
next.push(presetToRow(p));
added += 1;
continue;
}
const row = next[i]!;
const fields = presetFields(p);
const changed = (Object.keys(fields) as (keyof typeof fields)[]).some(
(k) => row[k] !== fields[k],
);
if (changed) {
next[i] = { ...row, ...fields };
updated += 1;
}
}
return { rows: next, added, updated };
}
@@ -88,3 +88,13 @@ export function groupModelRows<T extends ModelRowLike>(
(g) => g.rows.length > 0 || (!searching && g.provider.id === "custom"),
);
}
/**
* Flattens the library grouping into one ordered list (the chat model dropdown uses this):
* rows ordered exactly as the model page shows them — built-in provider groups in
* MODEL_PROVIDERS order, then user-defined groups, custom last; in-group row order
* preserved.
*/
export function orderModelsLikeLibrary<T extends ModelRowLike>(rows: T[]): T[] {
return groupModelRows(rows, "").flatMap((g) => g.rows);
}
+244 -27
View File
@@ -18,12 +18,13 @@
* the protocol follows group semantics: a first-party vendor group doesn't persist
* client_type (AgentHub auto-routes by upstream id, with env fallback resolved live from the
* id), while custom / user-defined groups / gateways use a fixed OpenAI protocol, and
* gateways (OpenRouter / SiliconFlow) additionally pre-fill their endpoint base URL; the
* "get model id / API key" external links sit next to the corresponding input's label
* (shown in both add and edit dialogs). The group list ends with an "add group" action
* (user-defined groups share custom's semantics; the group appears once the first model
* saves successfully — groups are carried by the model entry's provider field, not
* persisted separately).
* gateways (OpenRouter / SiliconFlow / Qwen Token Plan) additionally pre-fill their endpoint
* base URL; the "get model id / API key" external links sit next to the corresponding
* input's label (shown in both add and edit dialogs). The group list ends with an "add
* group" action (user-defined groups share custom's semantics; the group appears once the
* first model saves successfully — groups are carried by the model entry's provider field,
* not persisted separately). The header also holds an owner-only "sync presets" action next
* to the search box (union-merge with the built-in catalog, see catalog-sync.ts).
*
* Saving does a PUT full-table replace (models not present are deleted; an empty apiKey
* means keep the existing value); only the owner can edit.
@@ -42,15 +43,18 @@ import { ApiError } from "../../api/client";
import { S } from "../../lib/strings";
import { useDocumentTitle } from "../../lib/use-document-title";
import { useProject } from "../../state/project";
import { useAuth } from "../../state/auth";
import { USD_TO_CNY, useTheme } from "../../state/theme";
import type { Currency } from "../../state/theme";
import { Button } from "../../components/ui/button";
import { Input } from "../../components/ui/input";
import { PasswordInput } from "../../components/ui/password-input";
import { Modal } from "../../components/ui/modal";
import { Select } from "../../components/ui/select";
import { toastError, toastSuccess } from "../../components/ui/toast";
import { Badge } from "../../components/ui/badge";
import { Chevron } from "../../components/ui/chevron";
import { GlyphIcon } from "../../components/ui/glyph-icon";
import { ProviderLogo } from "../../components/ui/provider-logo";
import { SkeletonList } from "../../components/ui/skeleton";
import { EmptyState } from "../../components/ui/empty-state";
@@ -58,11 +62,16 @@ import { formatDateTime, humanizeTokens } from "../../lib/format";
import {
MODEL_PROVIDERS,
catalogEntryFor,
modelHomepageUrl,
providerInfo,
resolveModelEnv,
} from "@prismshadow/penguin-core/model-catalog";
import type { ModelProviderInfo } from "@prismshadow/penguin-core/model-catalog";
import { groupModelRows, sameModelRef, userProviderInfo } from "./model-grouping";
import { draftKey, loadDraft, saveDraft } from "../chat/draft-cache";
import { syncRowsWithCatalog } from "./catalog-sync";
import { tpsTone, ttftTone } from "./speed-test";
import type { SpeedResult, SpeedTone } from "./speed-test";
/** Display currency follows the user setting (pricing is always stored in USD/million tokens; conversion happens only for display and input). */
const CURRENCY_SYMBOL: Record<Currency, string> = { USD: "$", CNY: "¥" };
@@ -104,6 +113,26 @@ function inputToUsd(inputStr: string, currency: Currency): string {
return currency === "CNY" ? trimNum(n / USD_TO_CNY) : trimNum(n);
}
/** Group-header action glyphs (24x24 line paths): add, bulk key, gauge for speed test. */
const PLUS_ICON = "M12 5v14M5 12h14";
const KEY_ICON =
"M21 2l-2 2m-7.61 7.61a5.5 5.5 0 1 1-7.778 7.778 5.5 5.5 0 0 1 7.777-7.777zm0 0L15.5 7.5m0 0l3 3L22 7l-3-3m-3.5 3.5L19 4";
/** Speed-test glyphs (24x24 line paths): gauge for the group action, clock = TTFT, zap = TPS. */
const GAUGE_ICON = "M12 14l3.5-3.5M20.49 17A10 10 0 1 0 3.5 17";
const CLOCK_ICON = "M12 22a10 10 0 1 0 0-20 10 10 0 0 0 0 20ZM12 7v5l3.5 2";
const ZAP_ICON = "M13 2 3 14h9l-1 8 10-12h-9l1-8Z";
/** Metric tone -> text color classes for the card speed badges. */
const TONE_CLASS: Record<SpeedTone, string> = {
green: "text-green-600 dark:text-green-400",
yellow: "text-amber-600 dark:text-amber-400",
red: "text-red-600 dark:text-red-400",
};
/** In-page key for one model's speed result. */
const speedKey = (provider: string, modelId: string) => `${provider}\u0000${modelId}`;
/** Default context window (tokens) for custom models when left unset. */
const CUSTOM_CONTEXT_DEFAULT = 128000;
@@ -299,6 +328,13 @@ export function ModelsPage() {
const { currentProject } = useProject();
const projectId = currentProject?.projectId ?? null;
const isOwner = currentProject?.role === "owner";
const userId = useAuth().user?.userId ?? null;
/** Per-model speed results (in-memory, reset on every project switch; "pending" while that model's turn is running). */
const [speedResults, setSpeedResults] = useState<Map<string, SpeedResult | "pending">>(new Map());
/** Group whose speed-test confirmation dialog is open (provider id). */
const [speedFor, setSpeedFor] = useState<string | null>(null);
/** Group currently being speed-tested (provider id); tests run strictly one model at a time. */
const [speedRunning, setSpeedRunning] = useState<string | null>(null);
const [rows, setRows] = useState<RowState[] | null>(null);
const [defaultModel, setDefaultModel] = useState<ModelRefDto | undefined>(undefined);
@@ -327,6 +363,10 @@ export function ModelsPage() {
if (!projectId) return;
setRows(null);
setLoadError(null);
// Speed results are keyed by (provider, model_id) only, so another Project's identically
// named model would inherit a timing measured against a different endpoint and key —
// drop them along with the rows they annotate whenever the active Project changes.
setSpeedResults(new Map());
try {
const res = await api.getModels(projectId);
setRows(res.models.map(toRow));
@@ -368,6 +408,13 @@ export function ModelsPage() {
setRows(res.models.map(toRow));
setDefaultModel(res.defaultModel);
setVisionModel(res.visionModel);
// Default model changed: drop the stored draft's model selection so the draft chat
// follows the new default (a stored pick would otherwise pin the old model forever).
if (userId && res.defaultModel && !sameModelRef(res.defaultModel, defaultModel)) {
const key = draftKey(userId, projectId);
const draft = loadDraft(key);
if (draft.modelRef) saveDraft(key, { ...draft, modelRef: undefined });
}
toastSuccess(successText ?? S.common.saved);
return true;
} catch (e) {
@@ -383,6 +430,60 @@ export function ModelsPage() {
const groups = useMemo(() => (rows ? groupModelRows(rows, query) : []), [rows, query]);
/**
* "Sync presets": merge the built-in catalog into the current table (union; the catalog
* wins on differing preset entries, local additions and API keys stay untouched — see
* catalog-sync.ts). No-op with a toast when everything is already up to date.
*/
const syncPresets = async () => {
if (!rows) return;
const merged = syncRowsWithCatalog(rows);
if (merged.added === 0 && merged.updated === 0) {
toastSuccess(S.models.syncUpToDate);
return;
}
await persist(
merged.rows,
defaultModel,
visionModel,
S.models.syncDone(merged.added, merged.updated),
);
};
/**
* Group speed test: one real request per model, strictly sequential (concurrent probes
* trip provider rate limits), each result written to the card as it lands. The
* confirmation dialog (speedFor) has already warned about quota by the time this runs.
*/
const runSpeedTest = async (providerId: string) => {
if (!projectId || !rows) return;
const targets = rows.filter((r) => r.provider === providerId);
setSpeedRunning(providerId);
try {
for (const row of targets) {
const key = speedKey(row.provider, row.modelId);
setSpeedResults((prev) => new Map(prev).set(key, "pending"));
try {
const res = await api.testModel(projectId, {
provider: row.provider,
modelId: row.modelId,
speed: true,
});
setSpeedResults((prev) => new Map(prev).set(key, res));
} catch (e) {
setSpeedResults((prev) =>
new Map(prev).set(key, {
ok: false,
message: e instanceof ApiError ? e.message : S.common.unknownError,
}),
);
}
}
} finally {
setSpeedRunning(null);
}
};
/**
* "Add group" confirm: a valid name that doesn't conflict with a built-in group or an
* existing provider proceeds directly to that group's add-model dialog — groups are
@@ -426,9 +527,9 @@ export function ModelsPage() {
<p className="text-xs text-gray-500 dark:text-gray-400">{S.models.readOnlyHint}</p>
)}
</div>
{/* The header only holds search (add-model entry points live in each group header);
on narrow screens (flex-wrap wraps it to its own line) the search box shrinks
flexibly, fixed width at >=sm. */}
{/* The header holds search plus the owner-only "sync presets" action (add-model
entry points live in each group header); on narrow screens (flex-wrap wraps it
to its own line) the search box shrinks flexibly, fixed width at >=sm. */}
<div className="flex min-w-0 max-w-full grow items-center gap-2 sm:grow-0">
<div className="min-w-0 flex-1 sm:w-56 sm:flex-none">
<Input
@@ -438,6 +539,16 @@ export function ModelsPage() {
placeholder={S.models.searchPlaceholder}
/>
</div>
{isOwner && (
<Button
size="sm"
onClick={() => void syncPresets()}
disabled={busy || rows === null}
title={S.models.syncCatalogHint}
>
{S.models.syncCatalog}
</Button>
)}
</div>
</div>
@@ -498,6 +609,7 @@ export function ModelsPage() {
disabled={busy}
onClick={() => setAddingTo(group.provider.id)}
>
<GlyphIcon d={PLUS_ICON} size={13} />
{S.models.addToGroup}
</Button>
</span>
@@ -514,10 +626,34 @@ export function ModelsPage() {
disabled={busy}
onClick={() => setGroupKeyFor(group.provider.id)}
>
<GlyphIcon d={KEY_ICON} size={13} />
{S.models.groupApiKey}
</Button>
</span>
)}
{isOwner && (
<Button
size="sm"
variant="ghost"
className="shrink-0"
disabled={busy || speedRunning !== null}
aria-label={`${S.models.speedTest} ${group.provider.label}`}
title={
speedRunning === group.provider.id
? S.models.speedPending
: S.models.speedTest
}
onClick={() => setSpeedFor(group.provider.id)}
>
<GlyphIcon d={GAUGE_ICON} size={13} />
{/* Phone width: icon only (the header can't fit three labeled actions at 390px). */}
<span className="hidden sm:inline">
{speedRunning === group.provider.id
? S.models.speedPending
: S.models.speedTest}
</span>
</Button>
)}
{group.provider.apiKeyUrl && (
// Not enough room on phone width for all group-level actions: collapse
// it the same way as "bulk configure key", keeping the add entry and
@@ -568,6 +704,7 @@ export function ModelsPage() {
currency={currency}
isDefault={sameModelRef(rowRef(row), defaultModel)}
isVisionModel={sameModelRef(rowRef(row), visionModel)}
speed={speedResults.get(speedKey(row.provider, row.modelId))}
onOpen={() => setEditing(rowRef(row))}
/>
))
@@ -627,6 +764,26 @@ export function ModelsPage() {
/>
)}
{speedFor !== null && (
<Modal open title={S.models.speedTestTitle} onClose={() => setSpeedFor(null)}>
<p className="text-sm text-gray-600 dark:text-gray-300">
{S.models.speedTestConfirm(rows?.filter((r) => r.provider === speedFor).length ?? 0)}
</p>
<div className="mt-4 flex justify-end gap-2">
<Button onClick={() => setSpeedFor(null)}>{S.common.cancel}</Button>
<Button
variant="primary"
onClick={() => {
const id = speedFor;
setSpeedFor(null);
if (id) void runSpeedTest(id);
}}
>
{S.models.speedTestStart}
</Button>
</div>
</Modal>
)}
{addGroupOpen && (
<Modal
open
@@ -718,18 +875,25 @@ export function ModelsPage() {
// Model card
// ---------------------------------------------------------------------------
/** Card: display name + upstream id + status badges; context / pricing / key status folded into one line of small text. The whole card is clickable. */
/**
* Card: display name + upstream id + status badges; context / pricing / key status folded
* into one line of small text; group speed-test results (TTFT / TPS, tone-colored) ride the
* title row's right edge. The whole card is clickable (the model homepage link lives in the
* config dialog).
*/
function ModelCard({
row,
currency,
isDefault,
isVisionModel,
speed,
onOpen,
}: {
row: RowState;
currency: Currency;
isDefault: boolean;
isVisionModel: boolean;
speed?: SpeedResult | "pending";
onOpen: () => void;
}) {
const priced = row.cacheRead || row.cacheWrite || row.output;
@@ -747,6 +911,40 @@ function ModelCard({
: S.models.noKey,
].filter((v): v is string => v !== null);
const speedBadges =
speed === "pending" ? (
<span className="shrink-0 text-[11px] text-gray-400">{S.models.speedPending}</span>
) : speed ? (
speed.ok ? (
<span className="flex shrink-0 items-center gap-1.5 text-[11px] font-medium">
{speed.ttftMs !== undefined && (
<span
className={`flex items-center gap-0.5 ${TONE_CLASS[ttftTone(speed.ttftMs)]}`}
title={S.models.ttftTitle}
>
<GlyphIcon d={CLOCK_ICON} size={11} />
{Math.round(speed.ttftMs)}ms
</span>
)}
{speed.tps !== undefined && (
<span
className={`flex items-center gap-0.5 ${TONE_CLASS[tpsTone(speed.tps)]}`}
title={S.models.tpsTitle}
>
<GlyphIcon d={ZAP_ICON} size={11} />
{speed.tps} tok/s
</span>
)}
</span>
) : (
<span
className="shrink-0 text-[11px] font-medium text-red-600 dark:text-red-400"
title={speed.message}
>
{S.models.speedFailed}
</span>
)
) : null;
return (
<button
type="button"
@@ -767,8 +965,13 @@ function ModelCard({
{row.modelId}
</span>
)}
<span className="truncate text-[11px] text-gray-400 dark:text-gray-500">
{meta.join(" · ")}
{/* Meta line: the truncating text takes the flexible space; speed badges keep their own
non-shrinking slot on the right so the numbers never wrap or get pushed out. */}
<span className="flex w-full items-center gap-1.5">
<span className="min-w-0 flex-1 truncate text-[11px] text-gray-400 dark:text-gray-500">
{meta.join(" · ")}
</span>
{speedBadges}
</span>
</button>
);
@@ -1020,16 +1223,29 @@ function ModelDialog({
<span className="text-xs font-semibold text-gray-600 dark:text-gray-400">
{S.models.modelId}
</span>
{dialogProvider?.modelsUrl && (
<a
href={dialogProvider.modelsUrl}
target="_blank"
rel="noreferrer noopener"
className="shrink-0 text-xs text-brand-600 underline-offset-2 hover:underline dark:text-brand-300"
>
{S.models.getModelIds} ↗
</a>
)}
<span className="flex shrink-0 items-baseline gap-2.5">
{/* Model homepage (the model's own page; gateway groups have per-model URLs) — only for existing rows, whose identity is settled. */}
{row && modelHomepageUrl(row.provider, row.modelId) && (
<a
href={modelHomepageUrl(row.provider, row.modelId)}
target="_blank"
rel="noreferrer noopener"
className="shrink-0 text-xs text-brand-600 underline-offset-2 hover:underline dark:text-brand-300"
>
{S.models.homepage} ↗
</a>
)}
{dialogProvider?.modelsUrl && (
<a
href={dialogProvider.modelsUrl}
target="_blank"
rel="noreferrer noopener"
className="shrink-0 text-xs text-brand-600 underline-offset-2 hover:underline dark:text-brand-300"
>
{S.models.getModelIds} ↗
</a>
)}
</span>
</span>
<Input
size="sm"
@@ -1210,8 +1426,10 @@ function ModelDialog({
</div>
)}
{/* 1) API key — the most commonly used, placed first in the field section; "get API key" link next to the label. */}
<label className="block">
{/* 1) API key — the most commonly used, placed first in the field section; "get API key"
link next to the label. PasswordInput carries its own show/hide toggle and brings its
own <label> wrapper, so this outer container is a <div> (a nested <label> is invalid). */}
<div className="block">
<span className="mb-1 flex items-baseline justify-between gap-2">
<span className="text-xs font-semibold text-gray-600 dark:text-gray-400">
{S.models.apiKey}
@@ -1227,9 +1445,8 @@ function ModelDialog({
</a>
)}
</span>
<Input
<PasswordInput
size="sm"
type="password"
value={form.apiKeyInput}
disabled={!canEdit}
onChange={(e) => set({ apiKeyInput: e.target.value, clearApiKey: false })}
@@ -1238,7 +1455,7 @@ function ModelDialog({
autoFocus={!isNew}
placeholder={apiKeyHint}
/>
</label>
</div>
{envNote && <p className="text-xs text-gray-400 dark:text-gray-500">{envNote}</p>}
{form.credential?.apiKeyMasked && !form.apiKeyInput && (
<div className="flex flex-wrap items-center gap-x-3 gap-y-1 text-xs text-gray-500 dark:text-gray-400">
@@ -0,0 +1,25 @@
/**
* Group speed test support: the per-model result shape and the color thresholds for the
* card badges. Tones grade each metric independently — green is good, yellow middling,
* red poor; thresholds are deliberately coarse (a card badge, not a benchmark).
*/
export interface SpeedResult {
ok: boolean;
/** Time to first streamed content, ms. */
ttftMs?: number;
/** Output tokens per second over the streaming window. */
tps?: number;
message?: string;
}
export type SpeedTone = "green" | "yellow" | "red";
/** TTFT quality: under 1s green, up to 3s yellow, beyond red. */
export function ttftTone(ms: number): SpeedTone {
return ms < 1000 ? "green" : ms <= 3000 ? "yellow" : "red";
}
/** TPS quality: 40+ tok/s green, 15+ yellow, below red. */
export function tpsTone(tps: number): SpeedTone {
return tps >= 40 ? "green" : tps >= 15 ? "yellow" : "red";
}
@@ -8,6 +8,40 @@ import { GlyphIcon } from "../../components/ui/glyph-icon";
import { BOOK_ICON } from "../chat/skill-use";
import { sanitizeSkillIcon } from "./skill-icon";
/**
* Per-skill icon colors (soft tinted tile + colored line-art stroke, light/dark pairs).
* Icons are stroke=currentColor line art, so the text color paints them; a curated palette
* replaces the old theme-accent tile, which rendered every skill in one monochrome block.
* Deterministic: name-hash into the palette (user-created skills get a stable color too),
* with a few semantic overrides for built-ins where a hue plainly fits.
*/
const SKILL_TILE_COLORS = [
"bg-sky-50 text-sky-600 dark:bg-sky-950/60 dark:text-sky-400",
"bg-emerald-50 text-emerald-600 dark:bg-emerald-950/60 dark:text-emerald-400",
"bg-violet-50 text-violet-600 dark:bg-violet-950/60 dark:text-violet-400",
"bg-amber-50 text-amber-600 dark:bg-amber-950/60 dark:text-amber-400",
"bg-rose-50 text-rose-600 dark:bg-rose-950/60 dark:text-rose-400",
"bg-cyan-50 text-cyan-600 dark:bg-cyan-950/60 dark:text-cyan-400",
"bg-indigo-50 text-indigo-600 dark:bg-indigo-950/60 dark:text-indigo-400",
"bg-teal-50 text-teal-600 dark:bg-teal-950/60 dark:text-teal-400",
] as const;
/** Semantic hue overrides (palette indices) — e.g. the firecrawl flame is amber, not whatever the hash lands on. */
const SKILL_COLOR_OVERRIDES: Record<string, number> = {
firecrawl: 3,
"data-analysis": 1,
"web-design": 2,
"penguin-sdk": 0,
};
export function skillTileColor(name: string): string {
const override = SKILL_COLOR_OVERRIDES[name];
if (override !== undefined) return SKILL_TILE_COLORS[override]!;
let h = 0;
for (let i = 0; i < name.length; i++) h = (h * 31 + name.charCodeAt(i)) >>> 0;
return SKILL_TILE_COLORS[h % SKILL_TILE_COLORS.length]!;
}
export function SkillIcon({
icon,
size = 20,
+164 -68
View File
@@ -1,18 +1,26 @@
/**
* Skill library page: the @prismshadow/penguin-skills
* Skill library, shown sectioned by skill group. Group styling matches the model
* config page — the group header (group name + skill count, no icon) is
* collapsible, highlights on hover, and animates height on expand/collapse;
* expanded by default. Cards within a group form a grid (up to three per row on
* wide screens, generously sized). Each card = an enlarged skill icon spanning
* two rows (rounded border + light background; DTO icon = the raw icon.svg from
* the catalog, rendered inline once it passes sanitize, otherwise falls back to
* a default book icon) + a name (monospace) and short description on the right,
* one line each (single-line truncation, falling back to the full description
* when missing) + a metadata line (version · semantic update time · usage count
* "used by N Agents"); group and card copy follow the UI language (localizedText /
* localizedShortText), and groups have no description. Two **icon buttons** for
* Skill library, shown sectioned by skill group. Groups are borderless — the
* group header (group name + skill count, no icon) is collapsible, highlights
* on hover, and animates height on expand/collapse; expanded by default. Cards
* within a group form a grid, generously sized: two per row from the sm
* breakpoint up, one per row on narrow screens. Each card = a rounded icon
* tile centered against the two text rows (its color comes from
* skillTileColor — a per-skill palette hashed from the name, which replaced
* the theme-accent tile that painted every skill the same; DTO icon = the raw
* icon.svg from the catalog, rendered inline once it passes sanitize,
* otherwise falls back to a default book icon) + a name (monospace) and short
* description on the right, one line each (single-line truncation, falling
* back to the full description when missing) + a metadata line below both
* (version · semantic update time · usage count "used by N Agents");
* group and card copy follow the UI language (localizedText /
* localizedShortText), and groups have no description. Icon buttons for
* actions (copy goes into aria-label and title) —
* - Rotate "update installs" (shown only when some Agent's installed copy has
* a lower version than the library): one click reinstalls the current
* library copy on every outdated Agent (install-again-is-update semantics),
* with a single success toast; the manage-installs Modal marks outdated
* rows with an accent "更新"/"Update" button doing the same per Agent;
* - Paper plane "quick invoke": enters /chat/new draft mode with default_agent,
* pre-selects the skill, and pre-fills the invocation text per UI language
* (zh "使用 X 技能" / en "use the X skill", overwriting any existing draft body);
@@ -44,15 +52,35 @@ import { toastError, toastSuccess } from "../../components/ui/toast";
import { DRAFT_SESSION_ID } from "../chat/chat-page";
import { draftKey, loadDraft, saveDraft } from "../chat/draft-cache";
import { localizedShortText, localizedText } from "../chat/skill-use";
import { SkillIcon } from "./skill-icon-view";
import { SkillIcon, skillTileColor } from "./skill-icon-view";
/** agentId → set of installed skill names (in-page install-state snapshot, rewritten in place by optimistic updates). */
type InstalledMap = ReadonlyMap<string, ReadonlySet<string>>;
/** agentId → (installed skill name → installed copy's version); in-page install-state snapshot, rewritten in place by optimistic updates. */
export type InstalledMap = ReadonlyMap<string, ReadonlyMap<string, number>>;
/** "Quick invoke" button icon (paper plane, 24×24 line path; button shows only the icon, copy goes into aria/title). */
const SEND_ICON = "M22 2 11 13M22 2 15 22 11 13 2 9 22 2";
/** "Manage installs" button icon (download into tray, 24×24 line path). */
const INSTALL_ICON = "M21 15v4a2 2 0 0 1-2 2H5a2 2 0 0 1-2-2v-4M7 10l5 5 5-5M12 15V3";
/** "Update installs" button icon (rotate-cw, 24×24 line path). */
const UPDATE_ICON = "M23 4v6h-6M20.49 15a9 9 0 1 1-2.12-9.36L23 10";
/**
* Agents whose installed copy of `name` is older than the library's version (the update
* reminder's data source): not-installed Agents never count, and a locally *newer* copy
* (e.g. a hand-edited install) doesn't either — reminders fire only on a strictly lower
* version.
*/
export function outdatedAgentIds(
agentIds: readonly string[],
installed: InstalledMap,
name: string,
libraryVersion: number,
): string[] {
return agentIds.filter((agentId) => {
const v = installed.get(agentId)?.get(name);
return v !== undefined && v < libraryVersion;
});
}
export function SkillsPage() {
useDocumentTitle(S.nav.skills);
@@ -102,9 +130,9 @@ export function SkillsPage() {
ids.map(async (agentId) => {
try {
const res = await api.getAgentSkills(projectId, agentId);
return [agentId, new Set(res.skills.map((s) => s.name))] as const;
return [agentId, new Map(res.skills.map((s) => [s.name, s.version]))] as const;
} catch {
return [agentId, new Set<string>()] as const;
return [agentId, new Map<string, number>()] as const;
}
}),
).then((entries) => {
@@ -115,8 +143,8 @@ export function SkillsPage() {
// regresses the UI.
if (!cancelled)
setInstalled((prev) => {
const next = new Map<string, ReadonlySet<string>>(entries);
for (const [agentId, set] of prev) next.set(agentId, set);
const next = new Map<string, ReadonlyMap<string, number>>(entries);
for (const [agentId, m] of prev) next.set(agentId, m);
return next;
});
});
@@ -125,28 +153,34 @@ export function SkillsPage() {
};
}, [projectId, agentIdsKey]);
/** Rewrite one Agent's install state in place (shared by optimistic updates and failure rollback). */
const setAgentSkill = (agentId: string, name: string, on: boolean) =>
/** Rewrite one Agent's install state in place (shared by optimistic updates and failure rollback); `version` is the entry's value when setting. */
const setAgentSkill = (agentId: string, name: string, version: number | undefined) =>
setInstalled((prev) => {
const next = new Map(prev);
const set = new Set(next.get(agentId) ?? []);
if (on) set.add(name);
else set.delete(name);
next.set(agentId, set);
const m = new Map(next.get(agentId) ?? []);
if (version !== undefined) m.set(name, version);
else m.delete(name);
next.set(agentId, m);
return next;
});
/** Calibrate one Agent from an install response's list (concurrent install/uninstall also converges to the server's truth). */
const calibrateAgent = (agentId: string, skills: { name: string; version: number }[]) =>
setInstalled((prev) =>
new Map(prev).set(agentId, new Map(skills.map((s) => [s.name, s.version]))),
);
/** Check to install / uncheck to uninstall (any member can do this): optimistic update, a confirmation toast on success, rollback plus a toast on failure. */
const toggleInstall = async (agentId: string, name: string, on: boolean) => {
const toggleInstall = async (agentId: string, name: string, on: boolean, version: number) => {
if (!projectId) return;
setAgentSkill(agentId, name, on);
const prevVersion = installed.get(agentId)?.get(name);
setAgentSkill(agentId, name, on ? version : undefined);
const target = agents.find((a) => a.agentId === agentId);
const agentName = target ? agentDisplayName(target) : agentId;
try {
if (on) {
// On a successful install, calibrate the whole Agent from the list the response carries (concurrent install/uninstall also converges to the server's truth).
const res = await api.installAgentSkills(projectId, agentId, [name]);
setInstalled((prev) => new Map(prev).set(agentId, new Set(res.skills.map((s) => s.name))));
calibrateAgent(agentId, res.skills);
toastSuccess(S.skills.installedToast(name, agentName));
} else {
await api.removeAgentSkill(projectId, agentId, name);
@@ -158,11 +192,29 @@ export function SkillsPage() {
// or erroring (otherwise the checkbox would be stuck permanently
// checked whenever this page's snapshot is stale).
if (!on && e instanceof ApiError && e.status === 404) return;
setAgentSkill(agentId, name, !on);
setAgentSkill(agentId, name, prevVersion);
toastError(apiErrorText(e));
}
};
/**
* Update reminder action: reinstall the current library copy on every outdated Agent
* (install-again-is-update semantics). One success toast for the whole batch; on partial
* failure the succeeded Agents keep their calibrated state and the first error is toasted.
*/
const updateOutdated = async (name: string, agentIds: string[]) => {
if (!projectId || agentIds.length === 0) return;
const results = await Promise.allSettled(
agentIds.map(async (agentId) => {
const res = await api.installAgentSkills(projectId, agentId, [name]);
calibrateAgent(agentId, res.skills);
}),
);
const failed = results.find((r): r is PromiseRejectedResult => r.status === "rejected");
if (!failed) toastSuccess(S.skills.updatedToast(name, agentIds.length));
else toastError(apiErrorText(failed.reason));
};
/**
* Quick invoke: pre-selects the skill in the draft cache (the `skills`
* field, used by ChatInput as its initial selection on mount), pre-fills
@@ -204,7 +256,7 @@ export function SkillsPage() {
</Button>
</div>
) : groups === null ? (
<div className="mt-6 grid gap-2.5 md:grid-cols-2 xl:grid-cols-3">
<div className="mt-6 grid gap-2.5 sm:grid-cols-2">
{Array.from({ length: 4 }, (_, i) => (
<SkeletonCard key={i} className="p-4">
<Skeleton className="h-4 w-32" />
@@ -220,7 +272,7 @@ export function SkillsPage() {
return (
<section
key={group.id}
className="overflow-hidden rounded-md border border-gray-200 bg-white dark:border-gray-800 dark:bg-gray-900"
className="overflow-hidden rounded-md bg-white dark:bg-gray-900"
>
{/* Group header (styled like the model page's provider groups): group name +
skill count (no icon, no description); the whole row toggles
@@ -257,9 +309,9 @@ export function SkillsPage() {
>
{/* inert while collapsed: cards at zero height shouldn't still be Tab-focusable or clickable. */}
<div className="overflow-hidden" inert={!open}>
{/* Up to three cards per row, generously sized (3 columns ≥xl, 2 columns ≥md, 1 column on narrow screens). */}
{/* Generously sized cards: 2 columns ≥sm, 1 column on narrow screens. */}
<div
className={`grid gap-2.5 border-t border-gray-200 p-2.5 transition-opacity duration-200 md:grid-cols-2 xl:grid-cols-3 dark:border-gray-800 ${open ? "opacity-100" : "opacity-0"}`}
className={`grid gap-2.5 p-2.5 transition-opacity duration-200 sm:grid-cols-2 ${open ? "opacity-100" : "opacity-0"}`}
>
{group.skills.map((skill) => (
<SkillCard
@@ -268,6 +320,7 @@ export function SkillsPage() {
installed={installed}
onQuickInvoke={quickInvoke}
onToggleInstall={toggleInstall}
onUpdateOutdated={updateOutdated}
/>
))}
</div>
@@ -283,23 +336,31 @@ export function SkillsPage() {
);
}
/** A single skill card: metadata display (including a semantic metadata line) + quick invoke + "manage installs" Modal. */
/** A single skill card: metadata display (including a semantic metadata line) + update reminder + quick invoke + "manage installs" Modal. */
function SkillCard({
skill,
installed,
onQuickInvoke,
onToggleInstall,
onUpdateOutdated,
}: {
skill: SkillMetadataItem;
installed: InstalledMap;
onQuickInvoke: (name: string) => void;
onToggleInstall: (agentId: string, name: string, on: boolean) => Promise<void>;
onToggleInstall: (agentId: string, name: string, on: boolean, version: number) => Promise<void>;
onUpdateOutdated: (name: string, agentIds: string[]) => Promise<void>;
}) {
const { locale } = useLocale();
const { agents } = useProject();
const [installOpen, setInstallOpen] = useState(false);
let installedCount = 0;
for (const set of installed.values()) if (set.has(skill.name)) installedCount += 1;
for (const m of installed.values()) if (m.has(skill.name)) installedCount += 1;
const outdated = outdatedAgentIds(
agents.map((a) => a.agentId),
installed,
skill.name,
skill.version,
);
// Short description takes priority, falling back to the full description
// when missing (per UI language); title carries the full description for hover reading.
@@ -315,37 +376,54 @@ function SkillCard({
.filter((v): v is string => v !== null)
.join(" · ");
return (
<div className="flex h-full flex-col rounded-md border border-gray-200 bg-white p-4 dark:border-gray-800 dark:bg-gray-900">
{/* Header: an enlarged skill icon spanning two rows (rounded border + light background), with the name and short description on one line each to the right. */}
<div className="flex items-center gap-3">
<span className="flex h-12 w-12 shrink-0 items-center justify-center rounded-lg border border-gray-200 bg-gray-50 text-gray-600 dark:border-gray-700 dark:bg-gray-800/60 dark:text-gray-300">
<SkillIcon icon={skill.icon} size={26} />
</span>
<div className="min-w-0 flex-1">
<span className="block truncate font-mono text-[13px] font-semibold" title={skill.name}>
{skill.name}
</span>
{/* Short description truncates to one line (full description goes into title for hover reading). */}
<p
className="mt-0.5 truncate text-xs leading-5 text-gray-500 dark:text-gray-400"
title={fullDescription}
<div className="flex h-full items-center gap-3 rounded-md p-4 transition-colors hover:bg-gray-100/70 dark:hover:bg-gray-800/60">
<div className="min-w-0 flex-1">
{/* Header: the skill icon centered across the two text rows (rounded tile in the skill's
own palette color — see skillTileColor; deliberately a bit smaller than the two rows),
with the name and short description on one line each to the right. */}
<div className="flex items-center gap-3">
<span
className={`flex h-9 w-9 shrink-0 items-center justify-center rounded-lg ${skillTileColor(skill.name)}`}
>
{description}
</p>
<SkillIcon icon={skill.icon} size={20} />
</span>
<div className="min-w-0 flex-1">
<span className="block truncate font-mono text-[13px] font-semibold" title={skill.name}>
{skill.name}
</span>
{/* Short description truncates to one line (full description goes into title for hover reading). */}
<p
className="mt-0.5 truncate text-xs leading-5 text-gray-500 dark:text-gray-400"
title={fullDescription}
>
{description}
</p>
</div>
</div>
</div>
{/* Footer row: metadata on the left (e.g. `v1 · 今天更新 · N 个 Agent 在用`) +
icon buttons on the right (copy goes into aria-label and title), pinned to the card's bottom with mt-auto. */}
<div className="mt-auto flex items-center gap-1.5 pt-3">
<span
className="min-w-0 flex-1 truncate text-[11px] text-gray-400 dark:text-gray-500"
title={meta}
>
{/* Metadata line under the header (e.g. `v1 · updated today · used by N Agents`). */}
<p className="mt-2.5 truncate text-[11px] text-gray-400 dark:text-gray-500" title={meta}>
{meta}
</span>
</p>
</div>
{/* Actions: equal-square light icon buttons in a single row, vertically centered at the
card's right edge (copy goes into aria-label and title). */}
<div className="flex shrink-0 items-center justify-center gap-1.5">
{/* Light (secondary): an update nudge, not the card's primary action. */}
{outdated.length > 0 && (
<Button
size="sm"
variant="secondary"
className="h-8 w-8 shrink-0 justify-center p-0"
aria-label={`${S.skills.updateOutdated(outdated.length)} ${skill.name}`}
title={S.skills.updateOutdated(outdated.length)}
onClick={() => void onUpdateOutdated(skill.name, outdated)}
>
<GlyphIcon d={UPDATE_ICON} size={13} />
</Button>
)}
<Button
size="sm"
className="shrink-0 px-1.5"
className="h-8 w-8 shrink-0 justify-center p-0"
aria-label={`${S.skills.quickInvoke} ${skill.name}`}
title={S.skills.quickInvoke}
onClick={() => onQuickInvoke(skill.name)}
@@ -354,8 +432,7 @@ function SkillCard({
</Button>
<Button
size="sm"
variant="primary"
className="shrink-0 px-1.5"
className="h-8 w-8 shrink-0 justify-center p-0"
aria-label={`${S.skills.manageInstall} ${skill.name}`}
title={S.skills.manageInstall}
onClick={() => setInstallOpen(true)}
@@ -379,7 +456,9 @@ function SkillCard({
agentId={a.agentId}
name={agentDisplayName(a)}
installed={installed.get(a.agentId)?.has(skill.name) ?? false}
onToggle={(on) => void onToggleInstall(a.agentId, skill.name, on)}
outdated={outdated.includes(a.agentId)}
onToggle={(on) => void onToggleInstall(a.agentId, skill.name, on, skill.version)}
onUpdate={() => void onUpdateOutdated(skill.name, [a.agentId])}
/>
))}
</div>
@@ -392,19 +471,25 @@ function SkillCard({
/**
* One Agent row in the "manage installs" Modal: not-installed shows
* "安装"/"Install"; installed shows "已安装"/"Installed", switching to
* "卸载"/"Uninstall" on hover (same button, click to uninstall). Both
* install and uninstall go through optimistic updates (toggleInstall), rolling back on failure.
* "卸载"/"Uninstall" on hover (same button, click to uninstall); an installed
* copy older than the library additionally shows an accent "更新"/"Update"
* button (reinstall = update). Install and uninstall go through optimistic
* updates (toggleInstall), rolling back on failure.
*/
function InstallRow({
agentId,
name,
installed,
outdated,
onToggle,
onUpdate,
}: {
agentId: string;
name: string;
installed: boolean;
outdated: boolean;
onToggle: (on: boolean) => void;
onUpdate: () => void;
}) {
return (
<div className="flex items-center gap-2 rounded-md px-1.5 py-1.5 transition-colors duration-150 hover:bg-gray-50 dark:hover:bg-gray-800/60">
@@ -412,6 +497,17 @@ function InstallRow({
<span className="min-w-0 flex-1 truncate text-sm" title={agentId}>
{name}
</span>
{installed && outdated && (
<Button
size="sm"
variant="secondary"
className="shrink-0"
aria-label={`${S.skills.updateAction} ${agentId}`}
onClick={onUpdate}
>
{S.skills.updateAction}
</Button>
)}
{installed ? (
// group: on hover the button's copy switches "已安装"/"Installed" → "卸载"/"Uninstall" (the same button carries the uninstall action).
<Button
@@ -219,16 +219,18 @@ export function TraceFileView({
}, [projectId, agentId, sessionId, index]);
// Pricing for the session's Model (main session only; sub-session Tokens
// live in their own Trace and aren't part of this file):
// session_meta carries a paired reference (provider + model_id), matched against model config by that pair.
// live in their own Trace and aren't part of this file): session_meta carries a paired
// reference (provider + model_id), matched against model config by that whole pair.
// A Trace whose session_meta has no provider is legacy data (core refuses to resume it
// for the same reason) — the model_id alone identifies nothing, since the same id can
// exist under several providers at different prices, so no pricing is shown rather than
// an arbitrary first match.
const pricing = useMemo(() => {
const meta = events.find((m) => m.type === "session_meta");
const ref = meta ? (meta.payload as { model_id?: string; provider?: string }) : undefined;
if (!ref?.model_id) return undefined;
return models?.models.find(
(m) =>
m.modelId === ref.model_id && (ref.provider === undefined || m.provider === ref.provider),
)?.pricing;
if (!ref?.model_id || !ref.provider) return undefined;
return models?.models.find((m) => m.modelId === ref.model_id && m.provider === ref.provider)
?.pricing;
}, [events, models]);
const costOf = (b: Buckets): number | null => {