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:
@@ -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) =>
|
||||
|
||||
@@ -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);
|
||||
|
||||
@@ -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))}</>;
|
||||
}
|
||||
|
||||
@@ -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>
|
||||
|
||||
@@ -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.
|
||||
|
||||
@@ -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}
|
||||
|
||||
@@ -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);
|
||||
}
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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 => {
|
||||
|
||||
Reference in New Issue
Block a user