Changelog, dev startup, README, AgentHub 0.4.0, model catalog, and landing site (#7)

Branch-length batch covering tooling, the model layer, the Web App and the public
surfaces. Highlights:

- Changelog: a per-release `changelog/<version>/` tree, grouped by the surface each
  change touches, with a root CHANGELOG.md holding one line per release.
- Dev startup: `scripts/dev-prebuild.mjs` serializes the skills+core prebuild behind a
  lock and keeps `pnpm install` current; `pnpm dev` runs server+web together.
- AgentHub 0.3.3 -> 0.4.0: OmniMessage complete payloads carry one opaque `fidelity`
  object in place of item-level `signature`/`phase`, threaded verbatim through Trace,
  replay and resume; malformed classification adapted to the new error types.
- Model layer: a model is always referenced by an explicit `(provider, model_id)` pair.
  The provider is never inferred, guessed or defaulted -- both the catalog inference and
  the unique-match config resolution are gone, and CLI, SDK, server routes and
  run_subagent all require the complete pair. Catalog gains the Qwen Token Plan, Qwen
  Pay-As-You-Go and Fireworks AI gateways, plus an expanded OpenRouter group.
- Web App: catalog preset sync and per-group speed test on the Models page, positional
  slash commands, a markdown renderer, skill-library update reminders, and a vertically
  centred draft page whose upward menus size themselves to the room available.
- Public surfaces: restructured READMEs, the penguin.ooo landing site and blog, refreshed
  benchmark results for both suites, and the demo videos playing on the landing page.

Includes the fixes from a full review of the branch: 23 confirmed findings, among them a
provider-inference bug that could send one vendor's API key to another vendor's endpoint,
and an Escape handler that destroyed the composer's contents unrecoverably.

Verified on the branch head: pnpm test (1127 passing, 7 packages), pnpm typecheck and
pnpm format:check clean, Playwright e2e 14/14.
This commit is contained in:
Yaowei Zheng
2026-07-21 17:43:31 +08:00
committed by GitHub
parent abf0a2f248
commit d4faee3a1e
214 changed files with 9406 additions and 1688 deletions
+5 -1
View File
@@ -302,7 +302,7 @@ export const getUsage = (
to?: string;
groupBy: UsageGroupBy;
agentId?: string;
/** Model filters are given as a pair (only takes effect when both provider and modelId are supplied). */
/** Model filter is always a whole pair — both fields or neither; a model is never referenced by id alone. */
provider?: string;
modelId?: string;
},
@@ -330,6 +330,10 @@ export const listWorkspaceFiles = (sessionId: string, path: string) =>
export const workspaceFileUrl = (sessionId: string, path: string, download = false): string =>
`/api/sessions/${sessionId}/files/content?path=${encodeURIComponent(path)}${download ? "&download=1" : ""}`;
/** Sandboxed top-level preview URL (open an html file in a new tab): real content type under a CSP sandbox, see the server route. */
export const workspaceFilePreviewUrl = (sessionId: string, path: string): string =>
`/api/sessions/${sessionId}/files/content?path=${encodeURIComponent(path)}&preview=1`;
export const uploadWorkspaceFile = (sessionId: string, path: string, dataBase64: string) =>
apiFetch<void>(`/api/sessions/${sessionId}/files/content`, {
method: "PUT",
@@ -73,6 +73,7 @@ export function ChangePasswordDialog({ open, onClose }: { open: boolean; onClose
value={oldPassword}
onChange={(e) => setOldPassword(e.target.value)}
autoComplete="current-password"
hint={S.account.oldPasswordHint}
autoFocus
/>
<PasswordInput
@@ -46,6 +46,8 @@ export function PasswordInput({
type="button"
aria-label={toggleLabel}
title={toggleLabel}
// Skip in the tab order: Tab should move between fields, not land on the reveal toggle.
tabIndex={-1}
onClick={() => setVisible((v) => !v)}
className={`absolute inset-y-0 right-0 flex items-center justify-center text-gray-400 transition-colors duration-150 hover:text-gray-600 dark:hover:text-gray-300 ${
size === "sm" ? "w-8" : "w-10"
@@ -3,10 +3,12 @@
* light and dark themes).
*
* Anthropic / OpenAI / Google Gemini / DeepSeek / Moonshot AI / OpenRouter /
* SiliconFlow use each vendor's brand mark (for recognition purposes, not under
* trademark license); Z.AI uses a simplified geometric approximation of its
* branded glyph (not an exact reproduction of the trademark); unknown vendors and
* custom models use a generic cube. All are pure paths, no external image assets.
* SiliconFlow / Qwen Token Plan / Qwen Pay-As-You-Go / Fireworks AI use each
* vendor's brand mark (for recognition purposes, not under trademark license;
* Qwen's official gradient wordmark is flattened to currentColor monochrome);
* Z.AI uses a simplified geometric approximation of its branded glyph (not an
* exact reproduction of the trademark); unknown vendors and custom models use
* a generic cube. All are pure paths, no external image assets.
*/
import type { ReactNode } from "react";
@@ -18,6 +20,25 @@ interface Glyph {
path: ReactNode;
}
/**
* The official Qwen emblem (the wordmark's icon, lettering dropped), gradient fills
* flattened to currentColor; coordinates rounded to 2dp from the brand SVG. Shared by the
* Qwen Token Plan and Qwen Pay-As-You-Go groups.
*/
const QWEN_GLYPH: Glyph = {
viewBox: "-0.76 -0.47 28.6 28.6",
path: (
<>
<path d="M26.59,15.92L22.82,13.75C22.92,13.59,22.97,13.4,22.97,13.21L22.97,12.05C22.97,11.66,22.76,11.31,22.43,11.12L21.42,10.54C21.09,10.34,20.68,10.34,20.35,10.54L17.26,12.32C16.93,12.51,16.73,12.86,16.73,13.25L16.73,14.41C16.73,14.79,16.93,15.15,17.26,15.34L24.51,19.53C24.84,19.72,25.25,19.72,25.58,19.53L26.59,18.94C26.92,18.75,27.13,18.4,27.13,18.01L27.13,16.85C27.13,16.47,26.92,16.11,26.59,15.92L26.59,15.92Z" />
<path d="M21.88,3.58L18.11,5.75C18.02,5.59,17.89,5.46,17.72,5.36L16.71,4.78C16.38,4.59,15.97,4.59,15.64,4.78L14.63,5.36C14.3,5.55,14.09,5.9,14.09,6.29L14.09,9.86C14.09,10.24,14.3,10.59,14.63,10.78L15.64,11.37C15.97,11.56,16.38,11.56,16.71,11.37L23.96,7.18C24.29,6.99,24.5,6.64,24.5,6.25L24.5,5.09C24.5,4.71,24.29,4.35,23.96,4.16L22.95,3.58C22.62,3.39,22.21,3.39,21.88,3.58L21.88,3.58Z" />
<path d="M8.83,1.48L8.83,5.83C8.65,5.83,8.46,5.88,8.3,5.98L7.29,6.56C6.95,6.75,6.75,7.11,6.75,7.49L6.75,8.65C6.75,9.04,6.95,9.39,7.29,9.58L10.37,11.37C10.71,11.56,11.11,11.56,11.45,11.37L12.45,10.78C12.79,10.59,12.99,10.24,12.99,9.85L12.99,1.48C12.99,1.1,12.79,0.75,12.45,0.56L11.45,-0.03C11.11,-0.22,10.71,-0.22,10.37,-0.03L9.37,0.56C9.03,0.75,8.83,1.1,8.83,1.48L8.83,1.48Z" />
<path d="M0.5,11.74L4.26,13.91C4.17,14.07,4.12,14.26,4.12,14.45L4.12,15.61C4.12,16,4.32,16.35,4.65,16.54L5.66,17.13C6,17.32,6.4,17.32,6.73,17.13L9.82,15.34C10.15,15.15,10.36,14.8,10.36,14.41L10.36,13.25C10.36,12.87,10.15,12.51,9.82,12.32L2.57,8.14C2.24,7.94,1.84,7.94,1.5,8.14L0.5,8.72C0.16,8.91,-0.04,9.26,-0.04,9.65L-0.04,10.81C-0.04,11.19,0.16,11.55,0.5,11.74Z" />
<path d="M5.21,24.08L8.97,21.91C9.07,22.07,9.2,22.21,9.37,22.3L10.38,22.88C10.71,23.08,11.11,23.08,11.45,22.88L12.46,22.3C12.79,22.11,12.99,21.76,12.99,21.37L12.99,17.81C12.99,17.42,12.79,17.07,12.46,16.88L11.45,16.29C11.11,16.1,10.71,16.1,10.38,16.29L3.13,20.48C2.8,20.67,2.59,21.02,2.59,21.41L2.59,22.57C2.59,22.95,2.8,23.31,3.13,23.5L4.14,24.08C4.47,24.28,4.88,24.28,5.21,24.08L5.21,24.08Z" />
<path d="M18.26,26.18L18.26,21.83C18.44,21.83,18.63,21.78,18.79,21.68L19.8,21.1C20.13,20.91,20.34,20.56,20.34,20.17L20.34,19.01C20.34,18.62,20.13,18.27,19.8,18.08L16.71,16.3C16.38,16.1,15.97,16.1,15.64,16.3L14.63,16.88C14.3,17.07,14.1,17.42,14.1,17.81L14.1,26.18C14.1,26.56,14.3,26.91,14.63,27.11L15.64,27.69C15.97,27.88,16.38,27.88,16.71,27.69L17.72,27.11C18.05,26.91,18.26,26.56,18.26,26.18L18.26,26.18Z" />
</>
),
};
const GLYPHS: Record<string, Glyph> = {
anthropic: {
path: (
@@ -60,6 +81,19 @@ const GLYPHS: Record<string, Glyph> = {
<path d="M50.7172 0H27.6622C26.3877 0 25.3586 1.03397 25.3586 2.30358V9.21911C25.3586 10.4935 24.3294 11.5227 23.055 11.5227H2.30357C1.02914 11.5227 0 12.5567 0 13.8263V23.0502C0 24.3246 1.03395 25.3538 2.30357 25.3538H25.3586C26.633 25.3538 27.6622 24.3198 27.6622 23.0502V16.1347C27.6622 14.8602 28.6913 13.8311 29.9657 13.8311H50.7172C51.9916 13.8311 53.0207 12.7971 53.0207 11.5275V2.30358C53.0207 1.02916 51.9868 0 50.7172 0Z" />
),
},
"qwen-token-plan": QWEN_GLYPH,
"qwen-pay-as-you-go": QWEN_GLYPH,
fireworks: {
// The official Fireworks AI burst mark (three strokes of the wordmark's icon).
viewBox: "0 0 638 315",
path: (
<>
<path d="M318.563 221.755C300.863 221.755 284.979 211.247 278.206 194.978L196.549 0H244.342L318.842 178.361L393.273 0H441.066L358.92 195.048C352.112 211.247 336.263 221.755 318.563 221.755Z" />
<path d="M425.111 314.933C407.481 314.933 391.667 304.494 384.824 288.366C377.947 272.097 381.507 253.524 393.936 240.921L542.657 90.2803L561.229 134.094L425.076 271.748L619.147 270.666L637.72 314.479L425.146 315.003L425.076 314.933H425.111Z" />
<path d="M0 314.408L18.5727 270.595L212.643 271.677L76.525 133.988L95.0977 90.1748L243.819 240.816C256.247 253.384 259.843 272.026 252.93 288.26C246.088 304.424 230.203 314.827 212.643 314.827L0.0698221 314.339L0 314.408Z" />
</>
),
},
custom: {
stroke: true,
path: (
@@ -53,6 +53,9 @@ const CARD_ICONS = {
vaultKeys: "M15.5 7.5l3 3L22 7l-3-3M21 2l-9.6 9.6M13 15.5a5.5 5.5 0 1 1-11 0 5.5 5.5 0 0 1 11 0z",
/** Schedule count (alarm clock: dial + hands + twin bells, distinct from the plain clock face used for "last modified") */
schedules: "M12 21a7 7 0 1 0 0-14 7 7 0 0 0 0 14zm0-10v3l2 1.5M5 3L2.5 5.5M19 3l2.5 2.5",
/** Installed skill count (open book, same family as the skill library) */
skills:
"M12 6.5C10.5 5 8 4.5 4 5v12c4-.5 6.5 0 8 1.5 1.5-1.5 4-2 8-1.5V5c-4-.5-6.5 0-8 1.5zm0 0V18",
/** Usage (bar chart, same as sidebar "Usage Center") */
usage: "M4 20V10m6 10V4m6 16v-7m4 7H2",
/** Traces (eye line icon: observability; follows text color, no fill) */
@@ -245,6 +248,13 @@ export function AgentsPage() {
<GlyphIcon d={CARD_ICONS.schedules} size={12} />
{a.scheduleCount}
</span>
<span
className="inline-flex min-w-[2.25rem] shrink-0 items-center gap-1 tabular-nums"
title={S.skills.skillCount(a.skillCount)}
>
<GlyphIcon d={CARD_ICONS.skills} size={12} />
{a.skillCount}
</span>
<span
className="inline-flex shrink-0 items-center gap-1"
title={`${S.agent.updatedAt} ${a.updatedAt ? formatDateTime(a.updatedAt) : "—"}`}
@@ -5,13 +5,14 @@
* Readable by any member; toggle/edit/delete are owner-only — PUT has whole-file
* replace semantics, so toggling also resends every field and only flips `enabled`.
* startAt/endAt use datetime-local inputs (local timezone), converted to ISO 8601 on
* submit; the "new Session each run" mode can also pick a Model (a provider + modelId
* pair; defaults to the Project default; mutual exclusivity with sessionId is
* validated server-side).
* submit; the "new Session each run" mode can also pick a Model — always a complete
* (provider, modelId) pair, since provider is never inferred; omitting it entirely falls
* back to the Project default. Mutual exclusivity with sessionId is validated server-side.
*/
import { useCallback, useEffect, useState } from "react";
import type {
ModelInfo,
ModelRefDto,
ScheduleItem,
SchedulesResponse,
ScheduleStatus,
@@ -49,37 +50,35 @@ function toLocalInput(iso: string | undefined): string {
return `${d.getFullYear()}-${pad(d.getMonth() + 1)}-${pad(d.getDate())}T${pad(d.getHours())}:${pad(d.getMinutes())}`;
}
/**
* The model selection value in the form: a paired reference. provider may be
* omitted — when a hand-written TOML only specifies model_id, it's resolved by
* unique match at parse time; editing never fills in a provider on its own.
*/
interface ScheduleModelRef {
provider?: string;
modelId: string;
}
/**
* Model reference ↔ native select option value: encoded as a JSON array
* ([provider ?? null, modelId]), used only as a transient DOM-value serialization —
* ([provider, modelId]), used only as a transient DOM-value serialization —
* it never enters storage or requests; the persisted/submitted data still keeps
* provider and modelId as two separate fields (no concatenation anywhere in the
* pipeline). "" means the Project default.
*/
const modelOptionValue = (ref: ScheduleModelRef): string =>
JSON.stringify([ref.provider ?? null, ref.modelId]);
const modelOptionValue = (ref: ModelRefDto): string => JSON.stringify([ref.provider, ref.modelId]);
const parseModelOption = (v: string): ScheduleModelRef | null => {
const parseModelOption = (v: string): ModelRefDto | null => {
if (!v) return null;
const [provider, modelId] = JSON.parse(v) as [string | null, string];
return provider === null ? { modelId } : { provider, modelId };
const [provider, modelId] = JSON.parse(v) as [string, string];
return { provider, modelId };
};
/** Display label for a model option: upstream id + provider name (shown side by side, not a composite id). */
const modelOptionLabel = (ref: ScheduleModelRef): string =>
ref.provider === undefined
? ref.modelId
: `${ref.modelId} · ${providerInfo(ref.provider)?.label ?? ref.provider}`;
const modelOptionLabel = (ref: ModelRefDto): string =>
`${ref.modelId} · ${providerInfo(ref.provider)?.label ?? ref.provider}`;
/**
* Stored schedule fields → a model reference. A reference is always the complete
* (provider, model_id) pair, since provider is never inferred: the DTO types the two
* fields independently, so this guard is what keeps the form and the upsert body from
* ever assembling half a reference. A file that sets only one half is rejected by the
* server when parsed (it surfaces under invalidFiles, never as a listed row), so in
* practice this returns null only when the schedule uses the Project's default model.
*/
const itemModelRef = (item: Pick<ScheduleItem, "provider" | "modelId">): ModelRefDto | null =>
item.modelId && item.provider ? { provider: item.provider, modelId: item.modelId } : null;
/** Modal form state (shared by create/edit): non-null editing means editing that task (name locked). */
interface FormState {
@@ -94,8 +93,8 @@ interface FormState {
target: "new" | "session";
sessionId: string;
workspace: string;
/** Model for the new-Session mode (null = Project default, modelId/provider omitted). */
model: ScheduleModelRef | null;
/** Model for the new-Session mode (null = Project default, provider and modelId both omitted). */
model: ModelRefDto | null;
}
const EMPTY_FORM: FormState = {
@@ -191,10 +190,7 @@ export function SchedulesTab({ agentId }: { agentId: string }) {
? { workspace: form.workspace.trim() }
: {}),
...(form.target === "new" && form.model
? {
modelId: form.model.modelId,
...(form.model.provider !== undefined ? { provider: form.model.provider } : {}),
}
? { modelId: form.model.modelId, provider: form.model.provider }
: {}),
};
setBusy(true);
@@ -218,6 +214,7 @@ export function SchedulesTab({ agentId }: { agentId: string }) {
setBusy(true);
setError(null);
setNotice(null);
const model = itemModelRef(item);
try {
await api.updateSchedule(projectId, agentId, item.name, {
prompt: item.prompt,
@@ -227,9 +224,8 @@ export function SchedulesTab({ agentId }: { agentId: string }) {
...(item.endAt !== undefined ? { endAt: item.endAt } : {}),
...(item.sessionId !== undefined ? { sessionId: item.sessionId } : {}),
...(item.workspace !== undefined ? { workspace: item.workspace } : {}),
// Model reference is resent as a pair (a hand-written file with provider omitted stays omitted).
...(item.modelId !== undefined ? { modelId: item.modelId } : {}),
...(item.provider !== undefined ? { provider: item.provider } : {}),
// Model reference is resent as a whole pair or not at all — never half of one.
...(model ? { modelId: model.modelId, provider: model.provider } : {}),
});
setNotice(S.common.saved);
await load();
@@ -240,7 +236,7 @@ export function SchedulesTab({ agentId }: { agentId: string }) {
}
};
/** Edit: prefill this row into the modal form (submits via PUT; model reference prefilled as a pair, provider not auto-filled when absent). */
/** Edit: prefill this row into the modal form (submits via PUT; the model reference is prefilled only as a complete pair). */
const startEdit = (item: ScheduleItem) => {
openForm({
editing: item.name,
@@ -253,13 +249,7 @@ export function SchedulesTab({ agentId }: { agentId: string }) {
target: item.sessionId ? "session" : "new",
sessionId: item.sessionId ?? "",
workspace: item.workspace ?? "",
model:
item.modelId !== undefined
? {
modelId: item.modelId,
...(item.provider !== undefined ? { provider: item.provider } : {}),
}
: null,
model: itemModelRef(item),
});
};
@@ -494,9 +484,9 @@ export function SchedulesTab({ agentId }: { agentId: string }) {
onChange={(e) => set({ model: parseModelOption(e.target.value) })}
>
<option value="">{S.schedule.modelDefault}</option>
{/* When the prefilled reference is no longer in the model config (including a
hand-written file missing only provider), add an extra option so it isn't
displayed as "Project default". */}
{/* When the prefilled pair is no longer in the model config (the entry was
renamed or deleted), add an extra option so it isn't displayed as
"Project default". */}
{form.model &&
!models.some(
(m) =>
+135 -43
View File
@@ -12,7 +12,9 @@
* logo + name display;
* `/` opens the slash command menu (`/compact` compresses context, replacing the button; each
* installed skill gets its own entry; pressing Enter on `/<skill_name>` toggles that skill's
* selection and clears the input, without sending);
* selection without sending). Matching is positional like `@`: a slash opens the menu from any
* caret position, running a command removes just that token, and Escape only dismisses the menu —
* the rest of the draft is never touched;
* `@` opens the agent selection menu; once picked it becomes a fixed highlighted target chip
* above the text body (only one allowed, picking again replaces it; removed via backspace or the
* x button); only a leading `@` at the start of the text counts — typing or pasting text starting
@@ -54,8 +56,9 @@ import { GlyphIcon } from "../../components/ui/glyph-icon";
import { SkillIcon } from "../skills/skill-icon-view";
import { ZoomableImage } from "../../components/ui/image-zoom";
import { ProviderLogo } from "../../components/ui/provider-logo";
import { matchesQuery, sameModelRef } from "../models/model-grouping";
import { matchesQuery, orderModelsLikeLibrary, sameModelRef } from "../models/model-grouping";
import { filterAgents, matchMention, splitLeadingMention } from "./agent-mentions";
import { matchSlash, removeSlashToken } from "./slash-token";
import {
BOOK_ICON,
buildSkillsMessage,
@@ -231,7 +234,9 @@ function ModelSelect({
const current = models.find((m) => sameModelRef(m, value));
// Display rule matches the model page's card: display name, or falls back to the upstream id (grouping is already conveyed by the provider logo).
const label = current ? modelLabel(current) : (value?.modelId ?? "…");
const filtered = models.filter((m) => matchesQuery(m, query));
// Dropdown order mirrors the model library page: provider groups in MODEL_PROVIDERS order
// (user-defined groups after, custom last), in-group order preserved.
const filtered = orderModelsLikeLibrary(models).filter((m) => matchesQuery(m, query));
return (
<Dropdown
open={open}
@@ -353,9 +358,11 @@ function SkillSelect({
open={open}
setOpen={setOpen}
menuClass={
// As wide as reasonably possible so descriptions stay readable; the viewport clamp
// keeps it inside phone screens.
direction === "down"
? "left-0 top-full mt-1 w-72 max-w-[calc(100vw-2rem)] origin-top-left"
: "bottom-full left-0 mb-1 w-72 max-w-[calc(100vw-2rem)] origin-bottom-left"
? "left-0 top-full mt-1 w-[26rem] max-w-[calc(100vw-2rem)] origin-top-left"
: "bottom-full left-0 mb-1 w-[26rem] max-w-[calc(100vw-2rem)] origin-bottom-left"
}
button={
<button
@@ -373,7 +380,7 @@ function SkillSelect({
<GlyphIcon d={BOOK_ICON} size={14} className="shrink-0" />
{/* When the card is narrower than @md, only the icon + badge remain (title shows the full name). */}
<span className="hidden min-w-0 truncate @md:block">{S.chat.skillsSelect}</span>
{/* Selected-count badge: chips no longer exist, so this is the only visible rendering of the selected state. */}
{/* Selected-count badge (the chip row above the input mirrors the selection too). */}
{selected.length > 0 && (
<span className="shrink-0 rounded-full bg-gray-200/80 px-1.5 py-px font-mono text-[10px] font-semibold text-gray-700 dark:bg-gray-700/60 dark:text-gray-200">
{selected.length}
@@ -616,9 +623,17 @@ export function ChatInput({
}) {
const { locale } = useLocale();
const [text, setText] = useState(initialText ?? "");
/** Live text mirror for slash-command run() closures (the commands memo deliberately doesn't depend on text). */
const textRef = useRef(text);
textRef.current = text;
const [images, setImages] = useState<string[]>([]);
const [busy, setBusy] = useState(false);
const [slashIndex, setSlashIndex] = useState(0);
// Slash token start where Escape closed the menu (mirrors mentionDismissed: the menu stays shut for that one token).
const [slashDismissed, setSlashDismissed] = useState<number | null>(null);
// Anchor for the popups that open upward, and the room actually available above them.
const anchorRef = useRef<HTMLDivElement>(null);
const [upwardMaxH, setUpwardMaxH] = useState<number>();
// @ handoff target (chip, fixed at the front of the input); only one allowed, picking again replaces it directly.
const [target, setTarget] = useState<AgentSummary | null>(null);
// Selected skills (dropdown checklist, multi-select): initial value comes from draft restore (quick-invoke pre-selection), cleared on successful send.
@@ -654,11 +669,15 @@ export function ChatInput({
[selectedSkills, onSkillsChange],
);
/** The slash token currently under the caret (kept in a ref so command run() closures always remove the live token). */
const slashMatchRef = useRef<ReturnType<typeof matchSlash>>(null);
const commands = useMemo<SlashCommand[]>(() => {
/** Clears the input after a slash command runs (setText is async: wait for the DOM value to update before measuring height). */
/** Removes just the slash token after a command runs (the rest of the text stays; setText is async: wait for the DOM value to update before measuring height). */
const clearInput = () => {
setText("");
onTextChange?.("");
const match = slashMatchRef.current;
const next = match ? removeSlashToken(textRef.current, match) : "";
setText(next);
onTextChange?.(next);
requestAnimationFrame(autoGrow);
};
return [
@@ -681,10 +700,14 @@ export function ChatInput({
})),
];
}, [onCompact, onTextChange, skills, locale, toggleSkill]);
const trimmed = text.trim();
// Positional matching (like @ mentions): a slash opens the menu from any caret position;
// running a command removes just the token, leaving the rest of the text intact. Doesn't
// reopen after Escape until the caret sits on a different token.
const slashTok = !running && !compacting ? matchSlash(text, caret) : null;
slashMatchRef.current = slashTok;
const slashMatches =
text.trimStart().startsWith("/") && !running && !compacting
? commands.filter((c) => c.cmd.startsWith(trimmed) || trimmed === "/")
slashTok && slashTok.start !== slashDismissed
? commands.filter((c) => c.cmd.startsWith(`/${slashTok.query}`))
: [];
const slashOpen = slashMatches.length > 0;
const activeSlash = slashMatches[Math.min(slashIndex, slashMatches.length - 1)];
@@ -696,6 +719,31 @@ export function ChatInput({
const mentionOpen = mentionMatches.length > 0;
const activeMention = mentionMatches[Math.min(mentionIndex, mentionMatches.length - 1)];
// Both menus above are drawn upward (`bottom-full`) from the composer, so their ceiling is
// whatever ancestor clips overflow — on the draft page that's the centered scroll area, whose
// top edge sits well below the viewport's. A static `40vh` cap can't know that distance and
// clipped the first rows on shorter windows, so measure the real gap when a menu opens.
useEffect(() => {
if (!slashOpen && !mentionOpen) return;
const measure = () => {
const el = anchorRef.current;
if (!el) return;
let ceiling = 0;
for (let p = el.parentElement; p; p = p.parentElement) {
if (getComputedStyle(p).overflowY !== "visible") {
ceiling = p.getBoundingClientRect().top;
break;
}
}
// Less the menu's own 6px offset from the composer, plus a little breathing room.
const room = el.getBoundingClientRect().top - ceiling - 14;
setUpwardMaxH(Math.max(96, Math.min(320, Math.round(room))));
};
measure();
window.addEventListener("resize", measure);
return () => window.removeEventListener("resize", measure);
}, [slashOpen, mentionOpen]);
/** Auto-grow the textarea (caps at roughly 6 lines, scrolls internally beyond that). */
const autoGrow = () => {
const el = textareaRef.current;
@@ -829,10 +877,10 @@ export function ChatInput({
return;
}
if (e.key === "Escape") {
setText("");
onTextChange?.("");
// setText is async: wait for the DOM value to update before measuring height (measuring synchronously would read the pre-clear content).
requestAnimationFrame(autoGrow);
// Only closes the popup, doesn't clear the input: with positional matching the `/token`
// is part of the text body like any other word, and wiping a controlled textarea is not
// undoable with Ctrl+Z. Reopens if the user keeps typing on another token.
setSlashDismissed(slashTok?.start ?? null);
return;
}
}
@@ -853,7 +901,7 @@ export function ChatInput({
return;
}
if (e.key === "Escape") {
// Only closes the popup, doesn't clear the input (unlike slash: the @ prefix is part of the text body), reopens if the user keeps typing.
// Only closes the popup, doesn't clear the input (the `@token` is part of the text body), reopens if the user keeps typing.
setMentionDismissed(mention?.start ?? null);
return;
}
@@ -909,14 +957,21 @@ export function ChatInput({
};
return (
<div className="relative">
{/* Slash command menu (triggered by typing /; /compact plus one entry per installed skill) */}
<div className="relative" ref={anchorRef}>
{/* Slash command menu (triggered by typing /; /compact plus one entry per installed skill).
Height is capped to the room measured above the composer (see upwardMaxH) with internal
scrolling, so a long skill list never pushes the menu's top edge out of view; the active
row keeps itself scrolled into view. */}
{slashOpen && (
<div className="anim-pop absolute bottom-full left-0 z-40 mb-1.5 w-80 max-w-[calc(100vw-2rem)] rounded-md border border-gray-200 bg-white py-1 shadow-lg dark:border-gray-700 dark:bg-gray-900">
<div
style={{ maxHeight: upwardMaxH }}
className="anim-pop absolute bottom-full left-0 z-40 mb-1.5 w-80 max-w-[calc(100vw-2rem)] overflow-y-auto overscroll-contain rounded-md border border-gray-200 bg-white py-1 shadow-lg dark:border-gray-700 dark:bg-gray-900"
>
{slashMatches.map((c, i) => (
<button
key={c.cmd}
type="button"
ref={c === activeSlash ? (el) => el?.scrollIntoView({ block: "nearest" }) : undefined}
onMouseEnter={() => setSlashIndex(i)}
onClick={() => c.run()}
className={`flex w-full items-center gap-2 px-3 py-1.5 text-left text-sm ${
@@ -938,7 +993,10 @@ export function ChatInput({
{/* @ subagent menu (triggered by typing @; interaction matches the slash menu) */}
{mentionOpen && (
<div className="anim-pop absolute bottom-full left-0 z-40 mb-1.5 max-h-64 w-72 overflow-y-auto rounded-md border border-gray-200 bg-white py-1 shadow-lg dark:border-gray-700 dark:bg-gray-900">
<div
style={{ maxHeight: Math.min(256, upwardMaxH ?? 256) }}
className="anim-pop absolute bottom-full left-0 z-40 mb-1.5 w-72 overflow-y-auto rounded-md border border-gray-200 bg-white py-1 shadow-lg dark:border-gray-700 dark:bg-gray-900"
>
{mentionMatches.map((a, i) => (
<button
key={a.agentId}
@@ -1001,27 +1059,56 @@ export function ChatInput({
card's width changes with the viewport and the Files panel squeezing it, viewport
breakpoints wouldn't judge it accurately. */}
<div className="@container rounded-lg border border-gray-300 bg-white px-2.5 pb-2 pt-2 transition-[border-color,box-shadow] duration-200 focus-within:border-gray-500 focus-within:ring-2 focus-within:ring-gray-400/30 dark:border-gray-700 dark:bg-gray-900 dark:focus-within:border-gray-400">
{/* @ handoff target chip: above the text body (background highlight to distinguish it from plain text), removed via the x button or backspace at the start of the text. */}
{target !== null && (
{/* Chip row above the text body: the @ handoff target (fixed at the front — send-time
@ semantics stay leading-only) followed by the selected skills, mirroring the
agent chip's look. Remove buttons recolor the x on hover (no background wash). */}
{(target !== null || selectedSkills.length > 0) && (
<div className="mb-1 flex flex-wrap items-center gap-1">
<span
className="anim-pop flex max-w-48 items-center gap-0.5 rounded-md bg-gray-100 py-0.5 pl-2 pr-1 font-mono text-sm text-gray-800 dark:bg-gray-800 dark:text-gray-200"
{...(target.name && target.name !== target.agentId ? { title: target.name } : {})}
>
<span className="truncate">@{target.agentId}</span>
<button
type="button"
aria-label={S.chat.mentionRemove}
onClick={() => {
setTarget(null);
onHandoffTargetChange?.(null);
textareaRef.current?.focus();
}}
className="shrink-0 rounded p-0.5 text-gray-400 transition-colors duration-150 hover:bg-gray-200 hover:text-gray-700 dark:hover:bg-gray-700 dark:hover:text-gray-200"
{target !== null && (
<span
className="anim-pop flex max-w-48 items-center gap-0.5 rounded-md bg-gray-100 py-0.5 pl-2 pr-1 font-mono text-sm text-gray-800 dark:bg-gray-800 dark:text-gray-200"
{...(target.name && target.name !== target.agentId ? { title: target.name } : {})}
>
×
</button>
</span>
<span className="truncate">@{target.agentId}</span>
<button
type="button"
aria-label={S.chat.mentionRemove}
onClick={() => {
setTarget(null);
onHandoffTargetChange?.(null);
textareaRef.current?.focus();
}}
className="shrink-0 rounded p-0.5 text-gray-400 transition-colors duration-150 hover:text-gray-700 dark:hover:text-gray-200"
>
×
</button>
</span>
)}
{selectedSkills.map((name) => {
const meta = skills.find((sk) => sk.name === name);
return (
<span
key={name}
className="anim-pop flex max-w-48 items-center gap-1 rounded-md bg-gray-100 py-0.5 pl-2 pr-1 font-mono text-sm text-gray-800 dark:bg-gray-800 dark:text-gray-200"
{...(meta ? { title: localizedShortText(locale, meta) } : {})}
>
<SkillIcon
icon={meta?.icon}
size={13}
className="shrink-0 text-gray-500 dark:text-gray-400"
/>
<span className="truncate">{name}</span>
<button
type="button"
aria-label={`${S.chat.skillRemove} ${name}`}
onClick={() => toggleSkill(name)}
className="shrink-0 rounded p-0.5 text-gray-400 transition-colors duration-150 hover:text-gray-700 dark:hover:text-gray-200"
>
×
</button>
</span>
);
})}
</div>
)}
@@ -1039,9 +1126,14 @@ export function ChatInput({
setCaret(caretNow);
setSlashIndex(0);
setMentionIndex(0);
// Closing via Escape only persists for "the same @ mention": continuing to type
// within that mention won't reopen the menu; it re-opens once the cursor is no
// longer on that mention (deleted, moved away, or replaced by a new one).
// Closing via Escape only persists for "the same token": continuing to type within
// that slash command / mention won't reopen the menu; it re-opens once the cursor is
// no longer on that token (deleted, moved away, or replaced by a new one).
setSlashDismissed((d) => {
if (d === null) return null;
const m = matchSlash(value, caretNow);
return m && m.start === d ? d : null;
});
setMentionDismissed((d) => {
if (d === null) return null;
const m = matchMention(value, caretNow);
+192 -17
View File
@@ -45,6 +45,7 @@ import { Dropdown } from "../../components/ui/dropdown";
import { PenguinLogo } from "../../components/ui/penguin-logo";
import { toastError } from "../../components/ui/toast";
import { ChatInput } from "./chat-input";
import { buildSkillsMessage } from "./skill-use";
import { clearDraft, draftKey, loadDraft, saveDraft } from "./draft-cache";
import type { DraftCache } from "./draft-cache";
import { handoffMessage } from "./agent-mentions";
@@ -53,6 +54,18 @@ import { sameModelRef } from "../models/model-grouping";
/** Coalescing window for writing body text to the cache: keystrokes are frequent, so a short batch accumulates before persisting (option changes are still written immediately). */
const DRAFT_SAVE_DEBOUNCE_MS = 300;
/**
* Example tasks on the draft screen, in display order (game card first, LoL music player,
* then the RAG build). Copy lives in S.chat.exampleTasks[id]; skills are pinned via a
* `<use_skills>` block — only those the selected Agent actually has installed are included,
* so the block never references a skill the agent can't read.
*/
const EXAMPLE_TASKS: { id: "game" | "lol" | "rag"; skills: string[] }[] = [
{ id: "game", skills: ["web-design"] },
{ id: "lol", skills: ["web-design"] },
{ id: "rag", skills: ["penguin-sdk", "web-design"] },
];
export function DraftView({
projectId,
models,
@@ -146,8 +159,11 @@ export function DraftView({
// mount render would trigger ChatInput's pruning effect and wrongly clear the
// quick-invoke preselection.
const [agentSkills, setAgentSkills] = useState<SkillMetadataItem[]>([]);
/** Whether the skills fetch for the current Agent has settled — the example task waits for it so its `<use_skills>` pinning doesn't silently depend on network timing. */
const [skillsLoaded, setSkillsLoaded] = useState(false);
useEffect(() => {
setAgentSkills((prev) => (prev.length > 0 ? [] : prev));
setSkillsLoaded(false);
if (!agentId) return;
let cancelled = false;
api
@@ -155,7 +171,10 @@ export function DraftView({
.then((res) => {
if (!cancelled) setAgentSkills(res.skills);
})
.catch(() => undefined);
.catch(() => undefined)
.finally(() => {
if (!cancelled) setSkillsLoaded(true);
});
return () => {
cancelled = true;
};
@@ -244,12 +263,23 @@ export function DraftView({
setCurrentAgentId(a.agentId);
};
// One in-flight guard shared by every send entry point (composer send / example task /
// @-handoff): a second submission while one is running would create a second Session with
// its own first task and a racing navigation. The ref is the synchronous guard; the state
// drives disabled styling on the example button (the composer has its own busy state).
const sendingRef = useRef(false);
const [sending, setSending] = useState(false);
// First message sent: only now is the Session created (Agent / Workspace / Model / approval
// mode are all locked in together), then the route jumps once sent; returns false on any
// failure, so the input area keeps the draft and can resend.
// failure, so the input area keeps the draft and can resend. `keepDraft` is set by sends
// that did not consume the composer text (the example task), so a typed-but-unsent draft
// survives the navigation instead of being silently discarded.
const onSend = useCallback(
async (input: TaskInputPart[]): Promise<boolean> => {
if (!agentId) return false;
async (input: TaskInputPart[], keepDraft = false): Promise<boolean> => {
if (!agentId || sendingRef.current) return false;
sendingRef.current = true;
setSending(true);
let createdId: string | null = null;
try {
const body: SessionCreateRequest = { approvalMode };
@@ -263,7 +293,7 @@ export function DraftView({
createdId = created.session.sessionId;
const res = await api.postTask(createdId, { input });
add(created.session);
discardDraft();
if (!keepDraft) discardDraft();
navigate(`/chat/${res.sessionId}`, { replace: true });
return true;
} catch (e) {
@@ -273,18 +303,45 @@ export function DraftView({
if (createdId) void api.deleteSession(createdId).catch(() => undefined);
toastError(apiErrorText(e, modelRef ? { modelId: modelRef.modelId } : {}));
return false;
} finally {
sendingRef.current = false;
setSending(false);
}
},
[projectId, agentId, approvalMode, modelRef, workspace, add, discardDraft, navigate],
);
// Example tasks: one click submits the canned prompt exactly like a hand-typed send (the
// busy id drives the clicked card's spinner; the shared in-flight guard and all failure
// handling live in onSend). keepDraft: an example never consumes the composer text, so a
// typed-but-unsent draft must survive. The selected model / Workspace / approval mode apply as-is.
const [exampleBusy, setExampleBusy] = useState<"game" | "lol" | "rag" | null>(null);
const runExample = useCallback(
async (task: (typeof EXAMPLE_TASKS)[number]) => {
if (exampleBusy !== null) return;
setExampleBusy(task.id);
try {
const names = task.skills.filter((n) => agentSkills.some((s) => s.name === n));
await onSend(
[{ type: "text", text: buildSkillsMessage(names, S.chat.exampleTasks[task.id].prompt) }],
true,
);
} finally {
setExampleBusy(null);
}
},
[exampleBusy, agentSkills, onSend],
);
// @ handoff: opens a new chat for the @-mentioned agent (approval mode carries over from the
// draft's current value; model/Workspace use the creation defaults), first input =
// <handoff_from> source block + the text and images with the @ mention stripped.
const selectedAgent = agents.find((a) => a.agentId === agentId) ?? null;
const onHandoff = useCallback(
async (target: AgentSummary, input: TaskInputPart[]): Promise<boolean> => {
if (!selectedAgent) return false;
if (!selectedAgent || sendingRef.current) return false;
sendingRef.current = true;
setSending(true);
const origin: TaskInputPart = {
type: "text",
text: handoffMessage({
@@ -308,6 +365,9 @@ export function DraftView({
apiErrorText(e, models?.defaultModel ? { modelId: models.defaultModel.modelId } : {}),
);
return false;
} finally {
sendingRef.current = false;
setSending(false);
}
},
[projectId, selectedAgent, approvalMode, add, discardDraft, navigate, models],
@@ -319,15 +379,21 @@ export function DraftView({
const vision = modelInfo?.vision !== false;
return (
<div className="anim-fade flex min-h-0 flex-1 flex-col overflow-y-auto px-3 md:px-4">
<div className="anim-fade flex min-h-0 flex-1 flex-col overflow-y-auto px-3 py-6 md:px-4">
{/*
* Vertical layout: two symmetric flex-1 spaces above and below, so **the input card lands
* exactly on the viewport's centerline**; the brand area sits at the bottom of the upper
* space (right above the input card). When the viewport is too short, the upper space
* shrinks to the brand area's height, the lower space compresses to padding, and the whole
* page falls back to natural document-flow scrolling (without clipping the top).
* Vertical layout: everything visible — brand, input card, ownership pills, example tasks —
* lives in ONE block between two empty flex-1 spacers, so the block is centred and the free
* space above and below it is exactly equal. The brand deliberately sits inside that block
* rather than in the upper spacer: keeping it in the spacer made the upper gap shorter than
* the lower one by the brand's own height, which pushed the card up the viewport and left
* the slash menu — it opens upward, `bottom-full` — too little room, so it clipped against
* the top of this scroll container. When the viewport is too short the spacers collapse to
* nothing, the container's own py-6 keeps the content off the edges, and the page falls back
* to natural scrolling.
*/}
<div className="flex flex-1 flex-col justify-end pt-6">
<div className="flex-1" />
<div className="mx-auto w-full max-w-3xl">
{/* Large brand logo + brand name + subtitle (e2e tests identify the draft page by this
heading). The asset is square-cropped and the graphic already has a bit of built-in
padding, so a small margin is enough to sit visually close to the title. */}
@@ -338,9 +404,7 @@ export function DraftView({
</h1>
<p className="mt-2 text-base text-gray-400 dark:text-gray-500">{S.chat.draftSubtitle}</p>
</div>
</div>
<div className="mx-auto w-full max-w-3xl">
<ChatInput
status="idle"
onSend={onSend}
@@ -373,10 +437,121 @@ export function DraftView({
<AgentSelect agents={agents} selected={selectedAgent} onSelect={selectAgent} />
<WorkspaceSelect projectId={projectId} workspace={workspace} onChange={setWorkspace} />
</div>
{/* Example tasks: one-click canned builds showing off the one-sentence → app flow,
stacked vertically in display order on every viewport. Disabled until
agents/models/skills are resolved (onSend would silently no-op without an Agent);
hover only darkens the border, per the card convention. */}
<div className="mt-6 flex flex-col items-stretch gap-2">
{EXAMPLE_TASKS.map((task) => {
const copy = S.chat.exampleTasks[task.id];
return (
<button
key={task.id}
type="button"
disabled={exampleBusy !== null || sending || !skillsLoaded || !agentId || !models}
onClick={() => void runExample(task)}
className="group flex min-w-0 items-center gap-3 rounded-xl border border-gray-200 bg-white px-4 py-3 text-left transition-colors duration-150 hover:border-gray-300 disabled:cursor-default disabled:opacity-60 dark:border-gray-800 dark:bg-gray-900 dark:hover:border-gray-700"
>
{/* 24×24 line icons (gamepad / music note / sparkle), consistent with the icon convention */}
{task.id === "lol" ? (
<svg
width="20"
height="20"
viewBox="0 0 24 24"
fill="none"
stroke="currentColor"
className="shrink-0 text-brand-500 dark:text-brand-400"
aria-hidden
>
<path
d="M9 18V6l11-2v12"
strokeWidth="1.7"
strokeLinecap="round"
strokeLinejoin="round"
/>
<circle cx="6.5" cy="18" r="2.5" strokeWidth="1.7" />
<circle cx="17.5" cy="16" r="2.5" strokeWidth="1.7" />
</svg>
) : task.id === "game" ? (
<svg
width="20"
height="20"
viewBox="0 0 24 24"
fill="none"
stroke="currentColor"
className="shrink-0 text-brand-500 dark:text-brand-400"
aria-hidden
>
<path
d="M6.7 6h10.6a4 4 0 0 1 3.97 3.56c.2 1.8.73 5.05.73 6.44a3 3 0 0 1-3 3c-1 0-1.5-.5-2-1l-1.4-1.4a2 2 0 0 0-1.42-.6H9.82a2 2 0 0 0-1.41.6L7 18c-.5.5-1 1-2 1a3 3 0 0 1-3-3c0-1.39.52-4.64.73-6.44A4 4 0 0 1 6.7 6z"
strokeWidth="1.7"
strokeLinejoin="round"
/>
<path
d="M6.5 11h4M8.5 9v4M15 12h.01M18 10h.01"
strokeWidth="1.7"
strokeLinecap="round"
/>
</svg>
) : (
<svg
width="20"
height="20"
viewBox="0 0 24 24"
fill="none"
stroke="currentColor"
className="shrink-0 text-brand-500 dark:text-brand-400"
aria-hidden
>
<path
d="M12 3l1.9 5.1L19 10l-5.1 1.9L12 17l-1.9-5.1L5 10l5.1-1.9L12 3z"
strokeWidth="1.7"
strokeLinejoin="round"
/>
<path
d="M18.5 15.5l.9 2.1 2.1.9-2.1.9-.9 2.1-.9-2.1-2.1-.9 2.1-.9.9-2.1z"
strokeWidth="1.4"
strokeLinejoin="round"
/>
</svg>
)}
<span className="min-w-0 flex-1">
<span className="block truncate text-sm font-medium text-gray-900 dark:text-gray-100">
{copy.label}
</span>
<span className="line-clamp-2 text-xs text-gray-500 dark:text-gray-400">
{copy.desc}
</span>
</span>
{exampleBusy === task.id ? (
<span className="ml-1 shrink-0 text-xs text-gray-400">{S.common.loading}</span>
) : (
<svg
width="16"
height="16"
viewBox="0 0 24 24"
fill="none"
stroke="currentColor"
className="ml-1 shrink-0 text-gray-300 transition-colors duration-150 group-hover:text-gray-500 dark:text-gray-600 dark:group-hover:text-gray-400"
aria-hidden
>
<path
d="M5 12h14M13 6l6 6-6 6"
strokeWidth="1.7"
strokeLinecap="round"
strokeLinejoin="round"
/>
</svg>
)}
</button>
);
})}
</div>
</div>
{/* Lower symmetric space (compresses to padding at minimum height) */}
<div className="flex-1 pb-6" />
{/* Lower symmetric space — empty, so it matches the upper one exactly */}
<div className="flex-1" />
</div>
);
}
@@ -1,12 +1,13 @@
/**
* Live duration (inline display on running thinking/tool cards): ticks every second. sinceMs
* Live duration (inline display on running thinking/tool cards): ticks every second, showing
* whole seconds only — decimals appear only on the settled value once the item finishes. sinceMs
* comes from the server-side message timestamp and may drift from the local clock; negative
* values are shown as 0; a pulsing ellipsis is shown when missing.
* `offsetMs` is the already-settled duration of a prior segment (e.g. a tool call's argument
* generation phase), added on top of the live segment as it ticks.
*/
import { useEffect, useState } from "react";
import { humanizeDuration } from "../../lib/format";
import { humanizeDurationLive } from "../../lib/format";
export function LiveDuration({ sinceMs, offsetMs = 0 }: { sinceMs?: number; offsetMs?: number }) {
const [now, setNow] = useState(() => Date.now());
@@ -15,5 +16,5 @@ export function LiveDuration({ sinceMs, offsetMs = 0 }: { sinceMs?: number; offs
return () => clearInterval(id);
}, []);
if (sinceMs === undefined) return <span className="animate-pulse">…</span>;
return <>{humanizeDuration(Math.max(0, offsetMs) + Math.max(0, now - sinceMs))}</>;
return <>{humanizeDurationLive(Math.max(0, offsetMs) + Math.max(0, now - sinceMs))}</>;
}
+76
View File
@@ -0,0 +1,76 @@
/**
* Markdown body memoized by text identity. The stream model mutates chat items in place and
* only reassigns `text` when a delta arrives, so every settled message keeps the same string
* instance across the per-frame version bumps — the shallow prop compare then skips its entire
* micromark → mdast → React re-parse, and only the actively-streaming message re-renders.
* Without this, each animation frame during a stream re-parsed the WHOLE transcript (O(n²)
* over a long reply), which visibly froze the UI while large code blocks streamed in.
*
* Fenced code blocks render through CodeBlock (language chrome + copy button + Shiki
* highlight). `streaming` disables highlighting while deltas are still arriving —
* re-tokenizing a growing block every frame is O(n²) main-thread cost — and the settle
* re-render (new string instance, streaming=false) highlights each block exactly once.
* Inline code keeps the default rendering (`.md-body code` styling).
*/
import { isValidElement, memo } from "react";
import type { ReactNode } from "react";
import ReactMarkdown from "react-markdown";
import type { Components } from "react-markdown";
import remarkGfm from "remark-gfm";
import { CodeBlock } from "./code-block";
/** Flatten a react-markdown code element's children to plain text (string or string array in practice). */
function codeText(children: unknown): string {
if (typeof children === "string") return children;
if (Array.isArray(children)) return children.filter((c) => typeof c === "string").join("");
return "";
}
/** Fenced-block adapter: unwraps the <pre><code class="language-x"> pair react-markdown emits into CodeBlock. */
function MdPre({ children, streaming }: { children?: ReactNode; streaming: boolean }) {
if (isValidElement(children)) {
const props = children.props as { className?: string; children?: unknown };
const language = /language-([\w+-]+)/.exec(props.className ?? "")?.[1] ?? "";
return (
<CodeBlock
language={language}
code={codeText(props.children).replace(/\n$/, "")}
highlight={!streaming}
/>
);
}
return <pre>{children}</pre>;
}
/**
* The two `components` maps, built once at module scope instead of inline per render.
* react-markdown uses `components.pre` as the element **type**, so a fresh arrow each render is
* a new type on every commit: React unmounts and remounts every code block, dropping the user's
* text selection and resetting each block's Copy-button state — ~8 times a second while a reply
* streams, including for blocks that closed long ago. `streaming` is the only thing the adapter
* closes over, so one frozen map per value is enough; the single flip between them happens on
* the settle render, which re-parses the message anyway.
*/
const STREAMING_COMPONENTS: Components = {
pre: (props) => <MdPre streaming>{props.children}</MdPre>,
};
const SETTLED_COMPONENTS: Components = {
pre: (props) => <MdPre streaming={false}>{props.children}</MdPre>,
};
export const Md = memo(function Md({
text,
streaming = false,
}: {
text: string;
streaming?: boolean;
}) {
return (
<ReactMarkdown
remarkPlugins={[remarkGfm]}
components={streaming ? STREAMING_COMPONENTS : SETTLED_COMPONENTS}
>
{text}
</ReactMarkdown>
);
});
@@ -5,14 +5,13 @@
* stats lines. Items have a light entrance animation.
*/
import { useState } from "react";
import ReactMarkdown from "react-markdown";
import remarkGfm from "remark-gfm";
import { S } from "../../lib/strings";
import { useLocale } from "../../state/locale";
import { formatMessageTime } from "../../lib/format";
import { STAT_ICONS } from "../../lib/stat-icons";
import { splitImageAttachments } from "../../lib/attachments";
import type { ChatItem } from "../../lib/omni/stream-model";
import { Md } from "./md";
import { GlyphIcon } from "../../components/ui/glyph-icon";
import { ZoomableImage } from "../../components/ui/image-zoom";
import { MessageFilesCard } from "./message-files-card";
@@ -158,8 +157,8 @@ export function MessageItem({ item, ctx }: { item: ChatItem; ctx: StreamRenderCo
// which is more useful than copying segment by segment.
return (
<div className="md-body anim-msg my-3 text-base leading-relaxed text-gray-800 dark:text-gray-100">
{/* Re-renders the accumulated text directly while streaming (a key point of the contract implementation). */}
<ReactMarkdown remarkPlugins={[remarkGfm]}>{item.text}</ReactMarkdown>
{/* Re-renders the accumulated text directly while streaming (a key point of the contract implementation); memoized so settled messages skip the re-parse, and code blocks highlight once on settle (see md.tsx). */}
<Md text={item.text} streaming={item.streaming} />
{item.streaming && <span className="animate-pulse text-gray-400">▌</span>}
{item.stopReason && item.stopReason !== "completed" && (
<span className="ml-1 font-mono text-xs text-gray-400">[{item.stopReason}]</span>
+154 -24
View File
@@ -5,7 +5,7 @@
* StreamRenderContext threads the pending-approval map and approval callback down to tool
* cards at any nesting depth.
*/
import { useEffect, useRef } from "react";
import { useEffect, useLayoutEffect, useRef, useState } from "react";
import type { ReactNode } from "react";
import { S } from "../../lib/strings";
import type { ChatItem } from "../../lib/omni/stream-model";
@@ -144,45 +144,175 @@ export function MessageStream({
// An upward-swipe intent immediately exits auto-follow; scrolling back near the bottom resumes it — see stream-follow.ts (#75) for the exact rule.
const followRef = useRef<StreamFollow | null>(null);
const follow = (followRef.current ??= createStreamFollow());
// Back-to-bottom button visibility (React state — the follow object mutates outside React):
// shown only when follow is off AND there is actually content below the fold. The second
// condition matters: a wheel-up flick over a list that already fits the viewport exits follow
// (stream-follow.ts keeps that rule deliberately) but must not surface a "jump to latest"
// button with nothing to jump to. Synced after every event that can change either input
// (wheel-up at the very top fires no scroll event, so syncing on scroll alone is not enough)
// and in the version effect (content growing while unstuck fires no scroll event either).
const [showJump, setShowJump] = useState(false);
/**
* Animated-return phase (back-to-bottom clicked, glide in flight). The glide is a
* self-driven rAF loop rather than native scrollTo({behavior:"smooth"}) for two
* live-tested reasons: (1) re-aiming a native smooth scroll on every stream commit resets
* its easing, capping descent below a fast stream's growth rate — the return then never
* arrives; (2) an in-flight native smooth scroll cannot be interrupted, so a wheel-up
* cancel would keep dragging the user downward for hundreds of ms. The rAF loop chases the
* LIVE bottom each frame with a velocity floor above any realistic growth rate, and
* cancelling it stops the descent within a frame. While returning, the button stays hidden
* and the stick snap must not fire (an instant snap on the next commit would kill the glide).
*/
const returningRef = useRef(false);
const returnRafRef = useRef<number | null>(null);
const cancelReturn = () => {
if (returnRafRef.current !== null) {
cancelAnimationFrame(returnRafRef.current);
returnRafRef.current = null;
}
returningRef.current = false;
};
useEffect(() => () => cancelReturn(), []);
/** Local previous scrollTop, only for detecting the user fighting the return animation upward (the follow object keeps its own). */
const lastTopRef = useRef<number | null>(null);
const syncJump = () => {
const el = scrollRef.current;
setShowJump(
!returningRef.current &&
!follow.stick &&
el !== null &&
el.scrollHeight - el.scrollTop - el.clientHeight > 1,
);
};
const onScroll = () => {
const el = scrollRef.current;
if (!el) return;
const prevTop = lastTopRef.current;
lastTopRef.current = el.scrollTop;
// Scrollbar-drag / keyboard scroll upward during the return cancels it (wheel/touch cancel in their own handlers).
if (returningRef.current && prevTop !== null && el.scrollTop < prevTop - 1) {
cancelReturn();
}
follow.scrolled({
scrollTop: el.scrollTop,
scrollHeight: el.scrollHeight,
clientHeight: el.clientHeight,
});
syncJump();
};
useEffect(() => {
// Layout effect (not useEffect): the stick-to-bottom snap must land before paint, otherwise
// fast streams show the bottom edge "catching up" by the growth of each commit. Suppressed
// during the animated return — the glide owns the scroll position until it arrives.
useLayoutEffect(() => {
const el = scrollRef.current;
if (el && follow.stick) el.scrollTop = el.scrollHeight;
if (el && follow.stick && !returningRef.current) el.scrollTop = el.scrollHeight;
syncJump();
// syncJump is recreated per render; the effect intentionally keys on stream growth only.
// eslint-disable-next-line react-hooks/exhaustive-deps
}, [version, follow]);
/** Back-to-bottom: glide down to the live bottom (reduced motion gets an instant jump); follow re-engages on arrival. */
const jumpToLatest = () => {
const el = scrollRef.current;
if (!el) return;
if (window.matchMedia("(prefers-reduced-motion: reduce)").matches) {
follow.resume();
el.scrollTop = el.scrollHeight;
syncJump();
return;
}
cancelReturn();
returningRef.current = true;
// Far away: teleport to three viewports above the bottom first, then glide the rest —
// bounds the animation to well under a second regardless of how far the user scrolled.
const far = el.scrollHeight - el.clientHeight - el.scrollTop;
if (far > 3 * el.clientHeight) el.scrollTop = el.scrollHeight - 4 * el.clientHeight;
let last = performance.now();
const stepFrame = (now: number) => {
returnRafRef.current = null;
const live = scrollRef.current;
if (!live || !returningRef.current) return;
const dt = Math.min(0.05, Math.max(0.001, (now - last) / 1000));
last = now;
const remaining = live.scrollHeight - live.clientHeight - live.scrollTop;
if (remaining <= 1) {
returningRef.current = false;
follow.resume();
live.scrollTop = live.scrollHeight;
syncJump();
return;
}
// Proportional ease-out toward the LIVE bottom, with a time-based velocity floor
// (3200px/s) that outruns any realistic streaming growth so the glide always lands.
const step = Math.max(remaining * Math.min(1, dt * 10), 3200 * dt);
live.scrollTop += Math.min(step, remaining);
returnRafRef.current = requestAnimationFrame(stepFrame);
};
returnRafRef.current = requestAnimationFrame(stepFrame);
syncJump();
};
return (
<div
ref={scrollRef}
onScroll={onScroll}
onWheel={(e) => follow.wheel(e.deltaY)}
onTouchStart={(e) => {
const t = e.touches[0];
if (t) follow.touchStart(t.clientY);
}}
onTouchMove={(e) => {
const t = e.touches[0];
if (t) follow.touchMove(t.clientY);
}}
onTouchEnd={() => follow.touchEnd()}
className="anim-fade h-full overflow-y-auto px-4 py-4 md:px-6"
>
<div className="mx-auto max-w-3xl">
{items.length === 0 ? (
<EmptyState title={S.chat.emptyStream} />
) : (
<MessageItems items={items} ctx={ctx} />
)}
<div className="relative h-full min-h-0">
<div
ref={scrollRef}
onScroll={onScroll}
onWheel={(e) => {
follow.wheel(e.deltaY);
if (e.deltaY < 0) cancelReturn(); // user takes over: the glide stops within a frame
syncJump();
}}
onTouchStart={(e) => {
const t = e.touches[0];
if (t) follow.touchStart(t.clientY);
}}
onTouchMove={(e) => {
const t = e.touches[0];
if (t) follow.touchMove(t.clientY);
if (returningRef.current) cancelReturn(); // touching the list mid-return = taking control
syncJump();
}}
onTouchEnd={() => follow.touchEnd()}
className="anim-fade h-full overflow-y-auto px-4 py-4 md:px-6"
>
<div className="mx-auto max-w-3xl">
{items.length === 0 ? (
<EmptyState title={S.chat.emptyStream} />
) : (
<MessageItems items={items} ctx={ctx} />
)}
</div>
</div>
{/* Back-to-bottom (shows once the user scrolls away from content below the fold): floats
just above the composer; clicking returns to the bottom and re-enters follow, so the
view keeps tracking the live stream. */}
{showJump && (
<button
type="button"
aria-label={S.chat.jumpToLatest}
title={S.chat.jumpToLatest}
onClick={jumpToLatest}
className="anim-pop absolute bottom-3 left-1/2 z-10 -translate-x-1/2 rounded-full border border-gray-300 bg-white p-1.5 text-gray-500 shadow-sm transition-colors duration-150 hover:bg-gray-50 hover:text-gray-900 dark:border-gray-700 dark:bg-gray-900 dark:text-gray-400 dark:hover:bg-gray-800 dark:hover:text-gray-100"
>
<svg
width="16"
height="16"
viewBox="0 0 24 24"
fill="none"
stroke="currentColor"
aria-hidden
>
<path
d="M12 5v14M6 13l6 6 6-6"
strokeWidth="1.7"
strokeLinecap="round"
strokeLinejoin="round"
/>
</svg>
</button>
)}
</div>
);
}
@@ -0,0 +1,42 @@
/**
* Positional slash-command matching for the chat input (pure logic, unit-tested):
* like @ mentions, a `/` opens the command menu from ANY caret position — it must sit at
* the start of the text or be preceded by whitespace (so URLs and paths like `a/b` never
* trigger it), with only command characters between the `/` and the caret. Running a
* command removes just the `start..end` token, leaving the rest of the text intact.
*/
/** Command characters allowed between `/` and the caret (command names and skill names: letters, digits, underscore, hyphen). */
const CMD_PREFIX = /^[\w-]*$/;
/** The slash token currently being typed: `start` is the index of `/`, `query` is the text between `/` and the caret (no leading slash), `end` extends over the same token to the right of the caret. */
export interface SlashMatch {
start: number;
end: number;
query: string;
}
/** Finds the slash command currently being typed at the caret; returns null if none. */
export function matchSlash(text: string, caret: number): SlashMatch | null {
const before = text.slice(0, caret);
const at = before.lastIndexOf("/");
if (at < 0) return null;
if (at > 0 && !/\s/.test(before[at - 1]!)) return null;
const query = before.slice(at + 1);
if (!CMD_PREFIX.test(query)) return null;
const rest = /^[\w-]*/.exec(text.slice(caret))![0];
return { start: at, end: caret + rest.length, query };
}
/** Removes the matched token from the text (what a slash command's run leaves behind); collapses a doubled space at the seam. */
export function removeSlashToken(text: string, match: SlashMatch): string {
const before = text.slice(0, match.start);
const after = text.slice(match.end);
// Only the seam is touched: the token used to separate its two neighbours, so dropping it would
// leave `a b` — one of those two spaces goes. The rest of the draft stays byte-for-byte (a
// global collapse would silently reflow pasted code, YAML, aligned tables and indentation).
const joined =
before.endsWith(" ") && after.startsWith(" ") ? before + after.slice(1) : before + after;
// The token was the entire content (possibly padded with whitespace): leave a truly empty input.
return /^\s*$/.test(joined) ? "" : joined;
}
@@ -35,6 +35,8 @@ export interface StreamFollow {
touchEnd(): void;
/** scroll event (user scrolling and programmatic stick-to-bottom share this path): moving up exits; otherwise nearing the bottom resumes; the first event initializes from position. */
scrolled(m: ScrollMetrics): void;
/** Explicit re-entry (the back-to-bottom button): resumes follow immediately — the caller scrolls to the bottom right after, and the resulting scroll event sees a bottom position, keeping it stuck. */
resume(): void;
}
export function createStreamFollow(): StreamFollow {
@@ -72,5 +74,11 @@ export function createStreamFollow(): StreamFollow {
}
if (dist < 80) stick = true;
},
resume() {
stick = true;
// Forget the last position: the caller jumps to the bottom right after, and that large
// downward scroll must not be judged against a stale historical scrollTop.
lastTop = null;
},
};
}
@@ -4,8 +4,6 @@
* style and set of running-state icons (in progress / done / failed) as tool cards.
*/
import { useState } from "react";
import ReactMarkdown from "react-markdown";
import remarkGfm from "remark-gfm";
import { S } from "../../lib/strings";
import { humanizeDuration } from "../../lib/format";
import type { ThinkingItem } from "../../lib/omni/stream-model";
@@ -13,6 +11,7 @@ import { Chevron } from "../../components/ui/chevron";
import { StatusIcon } from "../../components/ui/status-icon";
import type { RunState } from "../../components/ui/status-icon";
import { LiveDuration } from "./live-duration";
import { Md } from "./md";
export function ThinkingBlock({ item }: { item: ThinkingItem }) {
const [open, setOpen] = useState(false);
@@ -47,7 +46,7 @@ export function ThinkingBlock({ item }: { item: ThinkingItem }) {
</button>
{open && (
<div className="md-body anim-fade mx-3 mb-2 rounded-md bg-gray-50 px-3 py-2 text-sm leading-relaxed text-gray-600 dark:bg-gray-900/60 dark:text-gray-400">
<ReactMarkdown remarkPlugins={[remarkGfm]}>{item.thinking}</ReactMarkdown>
<Md text={item.thinking} streaming={item.streaming} />
</div>
)}
</div>
@@ -26,6 +26,9 @@ import type { StreamModel } from "../../lib/omni/stream-model";
export type { PendingApproval } from "../../lib/omni/stream-controller";
/** Minimum spacing between version commits: below one frame at 8fps, invisible as staleness, but ~8× fewer full re-parses of the growing message during a fast large-code stream. */
const BUMP_MIN_INTERVAL_MS = 120;
export interface SessionStreamState {
/** View model (updated in place; version bump triggers re-render). */
model: StreamModel;
@@ -70,13 +73,33 @@ export function useSessionStream(
const placeholderRef = useRef<StreamModel | null>(null);
if (placeholderRef.current === null) placeholderRef.current = createStreamModel();
const rafRef = useRef<number | null>(null);
const throttleRef = useRef<number | null>(null);
const lastBumpAtRef = useRef(0);
// Coalesce high-frequency deltas: multiple pushes within one frame trigger only a single re-render.
// Coalesce high-frequency deltas: multiple pushes within one frame trigger only a single
// re-render, and commits are additionally spaced ≥BUMP_MIN_INTERVAL_MS apart. Every commit
// re-renders (and re-parses) the currently streaming message at its full accumulated length,
// so per-frame commits during a large streamed reply go O(n²) and freeze the main thread —
// the interval caps that at a bounded, invisible staleness. Inside the interval a single
// trailing timer is armed (the flush): the final deltas always land even if no further push
// ever arrives.
const bump = useCallback(() => {
if (rafRef.current !== null) return;
if (rafRef.current !== null || throttleRef.current !== null) return;
const commit = () => {
lastBumpAtRef.current = Date.now();
setVersion((v) => v + 1);
};
const wait = lastBumpAtRef.current + BUMP_MIN_INTERVAL_MS - Date.now();
if (wait > 0) {
throttleRef.current = window.setTimeout(() => {
throttleRef.current = null;
commit();
}, wait);
return;
}
rafRef.current = requestAnimationFrame(() => {
rafRef.current = null;
setVersion((v) => v + 1);
commit();
});
}, []);
@@ -134,6 +157,10 @@ export function useSessionStream(
cancelAnimationFrame(rafRef.current);
rafRef.current = null;
}
if (throttleRef.current !== null) {
window.clearTimeout(throttleRef.current);
throttleRef.current = null;
}
};
// initialStatus only serves as the first-frame placeholder; it doesn't rebuild the connection
// on parent re-renders.
+27 -7
View File
@@ -24,6 +24,7 @@ import { Chevron } from "../../components/ui/chevron";
import { StatusIcon } from "../../components/ui/status-icon";
import { approvalKey } from "../../lib/omni/stream-model";
import type { ChatItem } from "../../lib/omni/stream-model";
import { LiveDuration } from "./live-duration";
import { MessageItem } from "./message-item";
import type { StreamRenderContext } from "./message-stream";
import { summarizeWork } from "./work-summary";
@@ -62,8 +63,11 @@ export function WorkGroup({
/** Whether this group is the last segment of the message stream (current turn still in progress): decides the default expanded/collapsed state. */
isLast: boolean;
}) {
// Whether any item is in flight right now — also the only window in which the group's span is
// still growing, which the duration display below depends on.
const itemsRunning = items.some((it) => itemActive(it, ctx));
// Last segment + Task running = the model might still call another tool → show Running (even if there's no active item right now).
const active = (isLast && ctx.taskRunning) || items.some((it) => itemActive(it, ctx));
const active = (isLast && ctx.taskRunning) || itemsRunning;
const pending = hasPendingApproval(items, ctx);
const [open, setOpen] = useState(isLast);
const userToggled = useRef(false);
@@ -79,7 +83,7 @@ export function WorkGroup({
// A pending approval must stay actionable: expand the group body regardless of collapsed state (the approval row lives inside it).
const shown = open || pending;
const { steps, durationMs } = summarizeWork(items);
const { steps, durationMs, startMs } = summarizeWork(items);
return (
<div className="anim-msg my-2 overflow-hidden rounded-md border border-gray-200 bg-white dark:border-gray-800 dark:bg-gray-900">
@@ -106,11 +110,27 @@ export function WorkGroup({
{S.chat.workGroupSteps(steps)}
</span>
)}
{durationMs > 0 && (
<span className="shrink-0 font-mono text-xs text-gray-400">
{humanizeDuration(durationMs)}
</span>
)}
{/* Both states show the same quantity: the summarizeWork span (earliest item start →
latest item end). That's the canonical definition here — it's what work-summary.ts
documents, and unlike a group-open→now wall clock it is reconstructible from the
stored timestamps, so reloading the transcript reproduces the same number. While an
item is in flight its end isn't known yet, so the tick extends the span to *now*
(whole seconds); with nothing in flight the value freezes at the computed span, with
decimals — the same "don't tick through a wait we don't count" idiom as a tool card
parked on an approval. Ticking on while the group merely stays Running (the model
thinking between steps, or streaming its answer) would climb past the span and then
snap backwards the moment the group settles. */}
{itemsRunning
? startMs !== undefined && (
<span className="shrink-0 font-mono text-xs text-gray-400">
<LiveDuration sinceMs={startMs} />
</span>
)
: durationMs > 0 && (
<span className="shrink-0 font-mono text-xs text-gray-400">
{humanizeDuration(durationMs)}
</span>
)}
{pending && !shown && (
<span className="shrink-0 rounded bg-amber-100 px-1 text-[10px] font-medium text-amber-700 dark:bg-amber-950/50 dark:text-amber-300">
{S.chat.approvalWaiting}
+40 -32
View File
@@ -2,38 +2,54 @@
* Summary for the "Reasoning & Tools" group header (pure logic, unit-testable): step count only
* counts **tool calls** (thinking doesn't count as a step).
*
* Duration is computed as the **union of time intervals**: overlapping time from parallel tool
* calls is counted only once, presenting the group's wall-clock working time rather than the sum
* of each item's duration (naive per-item summation would report 15-way parallel work spanning
* 9 minutes as 99 minutes). Intervals follow the same settlement convention as each item's
* durationMs (see settleToolDuration in stream-model.ts): thinking is [startedAtMs, +durationMs];
* a tool's durationMs = argument-generation segment + execution segment (approval wait time is
* excluded), and the two segments are not adjacent on the timeline, so they must be split back
* into two intervals using the same formula: [argStartedAtMs, +generation segment] and
* [approvalAtMs ?? callStartedAtMs, +execution segment] — computing the whole span from the
* execution start point would shift the generation segment into the execution period, producing
* false overlap with parallel tools. Gaps between intervals (waiting for the model's next step)
* are not counted; a segment missing a start point can't be checked for overlap and falls back to
* plain summation.
* Duration is the group's **real wall-clock span**: earliest segment start → latest segment end,
* including everything in between — approval waits, gaps while the model decides its next step,
* parallel work counted once. The header answers "how long did this group take", not "how much
* compute happened inside it" (per-item cards carry the fine-grained settled durations).
* Segment endpoints follow the same settlement convention as each item's durationMs (see
* settleToolDuration in stream-model.ts): thinking is [startedAtMs, +durationMs]; a tool's
* durationMs = argument-generation segment + execution segment (the approval wait between them
* is excluded from the item's own duration, but lands inside the group span), so the endpoints
* are [argStartedAtMs, +generation segment] and [approvalAtMs ?? callStartedAtMs, +execution
* segment]. Segments missing a start point can't be placed on the timeline and fall back to
* plain summation on top of the span. `startMs` is the earliest placed start — the anchor the
* group header ticks from while an item is still in flight (once nothing is running the header
* freezes at the computed span, so the displayed number never jumps backwards).
*/
import type { ChatItem } from "../../lib/omni/stream-model";
export function summarizeWork(items: ChatItem[]): { steps: number; durationMs: number } {
export function summarizeWork(items: ChatItem[]): {
steps: number;
durationMs: number;
startMs?: number;
} {
let steps = 0;
let durationMs = 0;
const intervals: [number, number][] = [];
let fallbackMs = 0;
let minStart: number | undefined;
let maxEnd: number | undefined;
/** Group open = the earliest start stamp of ANY item, settled or not — a still-streaming first item must already anchor the live tick. */
const seen = (startMs: number | undefined) => {
if (startMs !== undefined && (minStart === undefined || startMs < minStart)) minStart = startMs;
};
const add = (startMs: number | undefined, spanMs: number) => {
if (spanMs <= 0) return;
if (startMs === undefined) durationMs += spanMs;
else intervals.push([startMs, startMs + spanMs]);
if (startMs === undefined) {
fallbackMs += spanMs;
return;
}
seen(startMs);
const end = startMs + spanMs;
if (maxEnd === undefined || end > maxEnd) maxEnd = end;
};
for (const it of items) {
if (it.kind === "thinking") {
seen(it.startedAtMs);
if (it.durationMs !== undefined) add(it.startedAtMs, it.durationMs);
continue;
}
if (it.kind !== "tool_call") continue;
steps += 1;
seen(it.argStartedAtMs ?? it.callStartedAtMs ?? it.approvalAtMs);
if (it.durationMs === undefined) continue;
const genMs =
it.argStartedAtMs !== undefined && it.callStartedAtMs !== undefined
@@ -42,18 +58,10 @@ export function summarizeWork(items: ChatItem[]): { steps: number; durationMs: n
add(it.argStartedAtMs, genMs);
add(it.approvalAtMs ?? it.callStartedAtMs, it.durationMs - genMs);
}
intervals.sort((a, b) => a[0] - b[0]);
let curStart: number | null = null;
let curEnd = 0;
for (const [start, end] of intervals) {
if (curStart === null || start > curEnd) {
if (curStart !== null) durationMs += curEnd - curStart;
curStart = start;
curEnd = end;
} else if (end > curEnd) {
curEnd = end;
}
}
if (curStart !== null) durationMs += curEnd - curStart;
return { steps, durationMs };
const spanMs = minStart !== undefined && maxEnd !== undefined ? maxEnd - minStart : 0;
return {
steps,
durationMs: spanMs + fallbackMs,
...(minStart !== undefined ? { startMs: minStart } : {}),
};
}
@@ -401,10 +401,21 @@ export function WorkspaceBrowser({
))}
</div>
)}
{/* Ghost style, matching the toolbar's upload label (text-xs, transparent until hover) — the bordered secondary look stood out from every neighbor. */}
{/\.html?$/i.test(preview.name) && (
<a
href={api.workspaceFilePreviewUrl(session.sessionId, preview.path)}
target="_blank"
rel="noopener noreferrer"
className="inline-flex shrink-0 items-center rounded-md border border-transparent bg-transparent px-2.5 py-1 text-xs font-medium text-gray-600 transition-colors duration-150 hover:bg-gray-100 hover:text-gray-900 dark:text-gray-300 dark:hover:bg-gray-800 dark:hover:text-gray-100"
>
{S.files.openInNewTab}
</a>
)}
<a
href={api.workspaceFileUrl(session.sessionId, preview.path, true)}
download={preview.name}
className="inline-flex shrink-0 items-center rounded-md border border-gray-300 bg-white px-2.5 py-1 text-sm font-medium transition-colors duration-150 hover:bg-gray-50 dark:border-gray-700 dark:bg-gray-900 dark:hover:bg-gray-800"
className="inline-flex shrink-0 items-center rounded-md border border-transparent bg-transparent px-2.5 py-1 text-xs font-medium text-gray-600 transition-colors duration-150 hover:bg-gray-100 hover:text-gray-900 dark:text-gray-300 dark:hover:bg-gray-800 dark:hover:text-gray-100"
>
{S.files.download}
</a>
@@ -0,0 +1,74 @@
/**
* One-click sync of the Project model table with the built-in catalog ("sync presets" next
* to the search box): union semantics — catalog entries not configured locally are added;
* entries present on both sides are reset to the catalog's fields (context window, pricing,
* protocol, base URL, vision — the catalog wins wherever the two differ, including removing
* pricing the catalog doesn't carry); locally added models (including user-defined groups)
* are kept untouched. Credentials are never touched: merged rows carry no apiKey input (the
* PUT keeps the stored key) and existing rows keep their credential display state.
*/
import { presetModelEntries } from "@prismshadow/penguin-core/model-catalog";
import type { RowState } from "./models-page";
type PresetEntry = ReturnType<typeof presetModelEntries>[number];
/** The catalog-owned fields of a row, in RowState's string-typed form (mirrors toRow). */
function presetFields(p: PresetEntry) {
return {
vision: p.vision !== false,
contextWindow: p.context_window !== undefined ? String(p.context_window) : "",
clientType: p.client_type ?? "",
cacheRead: p.pricing ? String(p.pricing.cache_read) : "",
cacheWrite: p.pricing ? String(p.pricing.cache_write) : "",
output: p.pricing ? String(p.pricing.output) : "",
baseUrl: p.base_url ?? "",
};
}
/** A brand-new row for a catalog entry not configured locally (original: null -> added on PUT). */
function presetToRow(p: PresetEntry): RowState {
return {
provider: p.provider,
modelId: p.model_id,
original: null,
...presetFields(p),
originalBaseUrl: "",
apiKeyInput: "",
clearApiKey: false,
};
}
/**
* Merges the current rows with the built-in catalog. Existing rows keep their identity,
* credential state, and list position (fields are updated in place); catalog-only entries
* are appended in catalog order. Returns the merged rows plus added/updated counts for the
* success toast (updated counts only rows whose catalog-owned fields actually changed).
*/
export function syncRowsWithCatalog(
rows: RowState[],
preset: PresetEntry[] = presetModelEntries(),
): { rows: RowState[]; added: number; updated: number } {
const key = (provider: string, modelId: string) => `${provider}\0${modelId}`;
const index = new Map(rows.map((r, i) => [key(r.provider, r.modelId), i]));
const next = [...rows];
let added = 0;
let updated = 0;
for (const p of preset) {
const i = index.get(key(p.provider, p.model_id));
if (i === undefined) {
next.push(presetToRow(p));
added += 1;
continue;
}
const row = next[i]!;
const fields = presetFields(p);
const changed = (Object.keys(fields) as (keyof typeof fields)[]).some(
(k) => row[k] !== fields[k],
);
if (changed) {
next[i] = { ...row, ...fields };
updated += 1;
}
}
return { rows: next, added, updated };
}
@@ -88,3 +88,13 @@ export function groupModelRows<T extends ModelRowLike>(
(g) => g.rows.length > 0 || (!searching && g.provider.id === "custom"),
);
}
/**
* Flattens the library grouping into one ordered list (the chat model dropdown uses this):
* rows ordered exactly as the model page shows them — built-in provider groups in
* MODEL_PROVIDERS order, then user-defined groups, custom last; in-group row order
* preserved.
*/
export function orderModelsLikeLibrary<T extends ModelRowLike>(rows: T[]): T[] {
return groupModelRows(rows, "").flatMap((g) => g.rows);
}
+244 -27
View File
@@ -18,12 +18,13 @@
* the protocol follows group semantics: a first-party vendor group doesn't persist
* client_type (AgentHub auto-routes by upstream id, with env fallback resolved live from the
* id), while custom / user-defined groups / gateways use a fixed OpenAI protocol, and
* gateways (OpenRouter / SiliconFlow) additionally pre-fill their endpoint base URL; the
* "get model id / API key" external links sit next to the corresponding input's label
* (shown in both add and edit dialogs). The group list ends with an "add group" action
* (user-defined groups share custom's semantics; the group appears once the first model
* saves successfully — groups are carried by the model entry's provider field, not
* persisted separately).
* gateways (OpenRouter / SiliconFlow / Qwen Token Plan) additionally pre-fill their endpoint
* base URL; the "get model id / API key" external links sit next to the corresponding
* input's label (shown in both add and edit dialogs). The group list ends with an "add
* group" action (user-defined groups share custom's semantics; the group appears once the
* first model saves successfully — groups are carried by the model entry's provider field,
* not persisted separately). The header also holds an owner-only "sync presets" action next
* to the search box (union-merge with the built-in catalog, see catalog-sync.ts).
*
* Saving does a PUT full-table replace (models not present are deleted; an empty apiKey
* means keep the existing value); only the owner can edit.
@@ -42,15 +43,18 @@ import { ApiError } from "../../api/client";
import { S } from "../../lib/strings";
import { useDocumentTitle } from "../../lib/use-document-title";
import { useProject } from "../../state/project";
import { useAuth } from "../../state/auth";
import { USD_TO_CNY, useTheme } from "../../state/theme";
import type { Currency } from "../../state/theme";
import { Button } from "../../components/ui/button";
import { Input } from "../../components/ui/input";
import { PasswordInput } from "../../components/ui/password-input";
import { Modal } from "../../components/ui/modal";
import { Select } from "../../components/ui/select";
import { toastError, toastSuccess } from "../../components/ui/toast";
import { Badge } from "../../components/ui/badge";
import { Chevron } from "../../components/ui/chevron";
import { GlyphIcon } from "../../components/ui/glyph-icon";
import { ProviderLogo } from "../../components/ui/provider-logo";
import { SkeletonList } from "../../components/ui/skeleton";
import { EmptyState } from "../../components/ui/empty-state";
@@ -58,11 +62,16 @@ import { formatDateTime, humanizeTokens } from "../../lib/format";
import {
MODEL_PROVIDERS,
catalogEntryFor,
modelHomepageUrl,
providerInfo,
resolveModelEnv,
} from "@prismshadow/penguin-core/model-catalog";
import type { ModelProviderInfo } from "@prismshadow/penguin-core/model-catalog";
import { groupModelRows, sameModelRef, userProviderInfo } from "./model-grouping";
import { draftKey, loadDraft, saveDraft } from "../chat/draft-cache";
import { syncRowsWithCatalog } from "./catalog-sync";
import { tpsTone, ttftTone } from "./speed-test";
import type { SpeedResult, SpeedTone } from "./speed-test";
/** Display currency follows the user setting (pricing is always stored in USD/million tokens; conversion happens only for display and input). */
const CURRENCY_SYMBOL: Record<Currency, string> = { USD: "$", CNY: "¥" };
@@ -104,6 +113,26 @@ function inputToUsd(inputStr: string, currency: Currency): string {
return currency === "CNY" ? trimNum(n / USD_TO_CNY) : trimNum(n);
}
/** Group-header action glyphs (24x24 line paths): add, bulk key, gauge for speed test. */
const PLUS_ICON = "M12 5v14M5 12h14";
const KEY_ICON =
"M21 2l-2 2m-7.61 7.61a5.5 5.5 0 1 1-7.778 7.778 5.5 5.5 0 0 1 7.777-7.777zm0 0L15.5 7.5m0 0l3 3L22 7l-3-3m-3.5 3.5L19 4";
/** Speed-test glyphs (24x24 line paths): gauge for the group action, clock = TTFT, zap = TPS. */
const GAUGE_ICON = "M12 14l3.5-3.5M20.49 17A10 10 0 1 0 3.5 17";
const CLOCK_ICON = "M12 22a10 10 0 1 0 0-20 10 10 0 0 0 0 20ZM12 7v5l3.5 2";
const ZAP_ICON = "M13 2 3 14h9l-1 8 10-12h-9l1-8Z";
/** Metric tone -> text color classes for the card speed badges. */
const TONE_CLASS: Record<SpeedTone, string> = {
green: "text-green-600 dark:text-green-400",
yellow: "text-amber-600 dark:text-amber-400",
red: "text-red-600 dark:text-red-400",
};
/** In-page key for one model's speed result. */
const speedKey = (provider: string, modelId: string) => `${provider}\u0000${modelId}`;
/** Default context window (tokens) for custom models when left unset. */
const CUSTOM_CONTEXT_DEFAULT = 128000;
@@ -299,6 +328,13 @@ export function ModelsPage() {
const { currentProject } = useProject();
const projectId = currentProject?.projectId ?? null;
const isOwner = currentProject?.role === "owner";
const userId = useAuth().user?.userId ?? null;
/** Per-model speed results (in-memory, reset on every project switch; "pending" while that model's turn is running). */
const [speedResults, setSpeedResults] = useState<Map<string, SpeedResult | "pending">>(new Map());
/** Group whose speed-test confirmation dialog is open (provider id). */
const [speedFor, setSpeedFor] = useState<string | null>(null);
/** Group currently being speed-tested (provider id); tests run strictly one model at a time. */
const [speedRunning, setSpeedRunning] = useState<string | null>(null);
const [rows, setRows] = useState<RowState[] | null>(null);
const [defaultModel, setDefaultModel] = useState<ModelRefDto | undefined>(undefined);
@@ -327,6 +363,10 @@ export function ModelsPage() {
if (!projectId) return;
setRows(null);
setLoadError(null);
// Speed results are keyed by (provider, model_id) only, so another Project's identically
// named model would inherit a timing measured against a different endpoint and key —
// drop them along with the rows they annotate whenever the active Project changes.
setSpeedResults(new Map());
try {
const res = await api.getModels(projectId);
setRows(res.models.map(toRow));
@@ -368,6 +408,13 @@ export function ModelsPage() {
setRows(res.models.map(toRow));
setDefaultModel(res.defaultModel);
setVisionModel(res.visionModel);
// Default model changed: drop the stored draft's model selection so the draft chat
// follows the new default (a stored pick would otherwise pin the old model forever).
if (userId && res.defaultModel && !sameModelRef(res.defaultModel, defaultModel)) {
const key = draftKey(userId, projectId);
const draft = loadDraft(key);
if (draft.modelRef) saveDraft(key, { ...draft, modelRef: undefined });
}
toastSuccess(successText ?? S.common.saved);
return true;
} catch (e) {
@@ -383,6 +430,60 @@ export function ModelsPage() {
const groups = useMemo(() => (rows ? groupModelRows(rows, query) : []), [rows, query]);
/**
* "Sync presets": merge the built-in catalog into the current table (union; the catalog
* wins on differing preset entries, local additions and API keys stay untouched — see
* catalog-sync.ts). No-op with a toast when everything is already up to date.
*/
const syncPresets = async () => {
if (!rows) return;
const merged = syncRowsWithCatalog(rows);
if (merged.added === 0 && merged.updated === 0) {
toastSuccess(S.models.syncUpToDate);
return;
}
await persist(
merged.rows,
defaultModel,
visionModel,
S.models.syncDone(merged.added, merged.updated),
);
};
/**
* Group speed test: one real request per model, strictly sequential (concurrent probes
* trip provider rate limits), each result written to the card as it lands. The
* confirmation dialog (speedFor) has already warned about quota by the time this runs.
*/
const runSpeedTest = async (providerId: string) => {
if (!projectId || !rows) return;
const targets = rows.filter((r) => r.provider === providerId);
setSpeedRunning(providerId);
try {
for (const row of targets) {
const key = speedKey(row.provider, row.modelId);
setSpeedResults((prev) => new Map(prev).set(key, "pending"));
try {
const res = await api.testModel(projectId, {
provider: row.provider,
modelId: row.modelId,
speed: true,
});
setSpeedResults((prev) => new Map(prev).set(key, res));
} catch (e) {
setSpeedResults((prev) =>
new Map(prev).set(key, {
ok: false,
message: e instanceof ApiError ? e.message : S.common.unknownError,
}),
);
}
}
} finally {
setSpeedRunning(null);
}
};
/**
* "Add group" confirm: a valid name that doesn't conflict with a built-in group or an
* existing provider proceeds directly to that group's add-model dialog — groups are
@@ -426,9 +527,9 @@ export function ModelsPage() {
<p className="text-xs text-gray-500 dark:text-gray-400">{S.models.readOnlyHint}</p>
)}
</div>
{/* The header only holds search (add-model entry points live in each group header);
on narrow screens (flex-wrap wraps it to its own line) the search box shrinks
flexibly, fixed width at >=sm. */}
{/* The header holds search plus the owner-only "sync presets" action (add-model
entry points live in each group header); on narrow screens (flex-wrap wraps it
to its own line) the search box shrinks flexibly, fixed width at >=sm. */}
<div className="flex min-w-0 max-w-full grow items-center gap-2 sm:grow-0">
<div className="min-w-0 flex-1 sm:w-56 sm:flex-none">
<Input
@@ -438,6 +539,16 @@ export function ModelsPage() {
placeholder={S.models.searchPlaceholder}
/>
</div>
{isOwner && (
<Button
size="sm"
onClick={() => void syncPresets()}
disabled={busy || rows === null}
title={S.models.syncCatalogHint}
>
{S.models.syncCatalog}
</Button>
)}
</div>
</div>
@@ -498,6 +609,7 @@ export function ModelsPage() {
disabled={busy}
onClick={() => setAddingTo(group.provider.id)}
>
<GlyphIcon d={PLUS_ICON} size={13} />
{S.models.addToGroup}
</Button>
</span>
@@ -514,10 +626,34 @@ export function ModelsPage() {
disabled={busy}
onClick={() => setGroupKeyFor(group.provider.id)}
>
<GlyphIcon d={KEY_ICON} size={13} />
{S.models.groupApiKey}
</Button>
</span>
)}
{isOwner && (
<Button
size="sm"
variant="ghost"
className="shrink-0"
disabled={busy || speedRunning !== null}
aria-label={`${S.models.speedTest} ${group.provider.label}`}
title={
speedRunning === group.provider.id
? S.models.speedPending
: S.models.speedTest
}
onClick={() => setSpeedFor(group.provider.id)}
>
<GlyphIcon d={GAUGE_ICON} size={13} />
{/* Phone width: icon only (the header can't fit three labeled actions at 390px). */}
<span className="hidden sm:inline">
{speedRunning === group.provider.id
? S.models.speedPending
: S.models.speedTest}
</span>
</Button>
)}
{group.provider.apiKeyUrl && (
// Not enough room on phone width for all group-level actions: collapse
// it the same way as "bulk configure key", keeping the add entry and
@@ -568,6 +704,7 @@ export function ModelsPage() {
currency={currency}
isDefault={sameModelRef(rowRef(row), defaultModel)}
isVisionModel={sameModelRef(rowRef(row), visionModel)}
speed={speedResults.get(speedKey(row.provider, row.modelId))}
onOpen={() => setEditing(rowRef(row))}
/>
))
@@ -627,6 +764,26 @@ export function ModelsPage() {
/>
)}
{speedFor !== null && (
<Modal open title={S.models.speedTestTitle} onClose={() => setSpeedFor(null)}>
<p className="text-sm text-gray-600 dark:text-gray-300">
{S.models.speedTestConfirm(rows?.filter((r) => r.provider === speedFor).length ?? 0)}
</p>
<div className="mt-4 flex justify-end gap-2">
<Button onClick={() => setSpeedFor(null)}>{S.common.cancel}</Button>
<Button
variant="primary"
onClick={() => {
const id = speedFor;
setSpeedFor(null);
if (id) void runSpeedTest(id);
}}
>
{S.models.speedTestStart}
</Button>
</div>
</Modal>
)}
{addGroupOpen && (
<Modal
open
@@ -718,18 +875,25 @@ export function ModelsPage() {
// Model card
// ---------------------------------------------------------------------------
/** Card: display name + upstream id + status badges; context / pricing / key status folded into one line of small text. The whole card is clickable. */
/**
* Card: display name + upstream id + status badges; context / pricing / key status folded
* into one line of small text; group speed-test results (TTFT / TPS, tone-colored) ride the
* title row's right edge. The whole card is clickable (the model homepage link lives in the
* config dialog).
*/
function ModelCard({
row,
currency,
isDefault,
isVisionModel,
speed,
onOpen,
}: {
row: RowState;
currency: Currency;
isDefault: boolean;
isVisionModel: boolean;
speed?: SpeedResult | "pending";
onOpen: () => void;
}) {
const priced = row.cacheRead || row.cacheWrite || row.output;
@@ -747,6 +911,40 @@ function ModelCard({
: S.models.noKey,
].filter((v): v is string => v !== null);
const speedBadges =
speed === "pending" ? (
<span className="shrink-0 text-[11px] text-gray-400">{S.models.speedPending}</span>
) : speed ? (
speed.ok ? (
<span className="flex shrink-0 items-center gap-1.5 text-[11px] font-medium">
{speed.ttftMs !== undefined && (
<span
className={`flex items-center gap-0.5 ${TONE_CLASS[ttftTone(speed.ttftMs)]}`}
title={S.models.ttftTitle}
>
<GlyphIcon d={CLOCK_ICON} size={11} />
{Math.round(speed.ttftMs)}ms
</span>
)}
{speed.tps !== undefined && (
<span
className={`flex items-center gap-0.5 ${TONE_CLASS[tpsTone(speed.tps)]}`}
title={S.models.tpsTitle}
>
<GlyphIcon d={ZAP_ICON} size={11} />
{speed.tps} tok/s
</span>
)}
</span>
) : (
<span
className="shrink-0 text-[11px] font-medium text-red-600 dark:text-red-400"
title={speed.message}
>
{S.models.speedFailed}
</span>
)
) : null;
return (
<button
type="button"
@@ -767,8 +965,13 @@ function ModelCard({
{row.modelId}
</span>
)}
<span className="truncate text-[11px] text-gray-400 dark:text-gray-500">
{meta.join(" · ")}
{/* Meta line: the truncating text takes the flexible space; speed badges keep their own
non-shrinking slot on the right so the numbers never wrap or get pushed out. */}
<span className="flex w-full items-center gap-1.5">
<span className="min-w-0 flex-1 truncate text-[11px] text-gray-400 dark:text-gray-500">
{meta.join(" · ")}
</span>
{speedBadges}
</span>
</button>
);
@@ -1020,16 +1223,29 @@ function ModelDialog({
<span className="text-xs font-semibold text-gray-600 dark:text-gray-400">
{S.models.modelId}
</span>
{dialogProvider?.modelsUrl && (
<a
href={dialogProvider.modelsUrl}
target="_blank"
rel="noreferrer noopener"
className="shrink-0 text-xs text-brand-600 underline-offset-2 hover:underline dark:text-brand-300"
>
{S.models.getModelIds} ↗
</a>
)}
<span className="flex shrink-0 items-baseline gap-2.5">
{/* Model homepage (the model's own page; gateway groups have per-model URLs) — only for existing rows, whose identity is settled. */}
{row && modelHomepageUrl(row.provider, row.modelId) && (
<a
href={modelHomepageUrl(row.provider, row.modelId)}
target="_blank"
rel="noreferrer noopener"
className="shrink-0 text-xs text-brand-600 underline-offset-2 hover:underline dark:text-brand-300"
>
{S.models.homepage} ↗
</a>
)}
{dialogProvider?.modelsUrl && (
<a
href={dialogProvider.modelsUrl}
target="_blank"
rel="noreferrer noopener"
className="shrink-0 text-xs text-brand-600 underline-offset-2 hover:underline dark:text-brand-300"
>
{S.models.getModelIds} ↗
</a>
)}
</span>
</span>
<Input
size="sm"
@@ -1210,8 +1426,10 @@ function ModelDialog({
</div>
)}
{/* 1) API key — the most commonly used, placed first in the field section; "get API key" link next to the label. */}
<label className="block">
{/* 1) API key — the most commonly used, placed first in the field section; "get API key"
link next to the label. PasswordInput carries its own show/hide toggle and brings its
own <label> wrapper, so this outer container is a <div> (a nested <label> is invalid). */}
<div className="block">
<span className="mb-1 flex items-baseline justify-between gap-2">
<span className="text-xs font-semibold text-gray-600 dark:text-gray-400">
{S.models.apiKey}
@@ -1227,9 +1445,8 @@ function ModelDialog({
</a>
)}
</span>
<Input
<PasswordInput
size="sm"
type="password"
value={form.apiKeyInput}
disabled={!canEdit}
onChange={(e) => set({ apiKeyInput: e.target.value, clearApiKey: false })}
@@ -1238,7 +1455,7 @@ function ModelDialog({
autoFocus={!isNew}
placeholder={apiKeyHint}
/>
</label>
</div>
{envNote && <p className="text-xs text-gray-400 dark:text-gray-500">{envNote}</p>}
{form.credential?.apiKeyMasked && !form.apiKeyInput && (
<div className="flex flex-wrap items-center gap-x-3 gap-y-1 text-xs text-gray-500 dark:text-gray-400">
@@ -0,0 +1,25 @@
/**
* Group speed test support: the per-model result shape and the color thresholds for the
* card badges. Tones grade each metric independently — green is good, yellow middling,
* red poor; thresholds are deliberately coarse (a card badge, not a benchmark).
*/
export interface SpeedResult {
ok: boolean;
/** Time to first streamed content, ms. */
ttftMs?: number;
/** Output tokens per second over the streaming window. */
tps?: number;
message?: string;
}
export type SpeedTone = "green" | "yellow" | "red";
/** TTFT quality: under 1s green, up to 3s yellow, beyond red. */
export function ttftTone(ms: number): SpeedTone {
return ms < 1000 ? "green" : ms <= 3000 ? "yellow" : "red";
}
/** TPS quality: 40+ tok/s green, 15+ yellow, below red. */
export function tpsTone(tps: number): SpeedTone {
return tps >= 40 ? "green" : tps >= 15 ? "yellow" : "red";
}
@@ -8,6 +8,40 @@ import { GlyphIcon } from "../../components/ui/glyph-icon";
import { BOOK_ICON } from "../chat/skill-use";
import { sanitizeSkillIcon } from "./skill-icon";
/**
* Per-skill icon colors (soft tinted tile + colored line-art stroke, light/dark pairs).
* Icons are stroke=currentColor line art, so the text color paints them; a curated palette
* replaces the old theme-accent tile, which rendered every skill in one monochrome block.
* Deterministic: name-hash into the palette (user-created skills get a stable color too),
* with a few semantic overrides for built-ins where a hue plainly fits.
*/
const SKILL_TILE_COLORS = [
"bg-sky-50 text-sky-600 dark:bg-sky-950/60 dark:text-sky-400",
"bg-emerald-50 text-emerald-600 dark:bg-emerald-950/60 dark:text-emerald-400",
"bg-violet-50 text-violet-600 dark:bg-violet-950/60 dark:text-violet-400",
"bg-amber-50 text-amber-600 dark:bg-amber-950/60 dark:text-amber-400",
"bg-rose-50 text-rose-600 dark:bg-rose-950/60 dark:text-rose-400",
"bg-cyan-50 text-cyan-600 dark:bg-cyan-950/60 dark:text-cyan-400",
"bg-indigo-50 text-indigo-600 dark:bg-indigo-950/60 dark:text-indigo-400",
"bg-teal-50 text-teal-600 dark:bg-teal-950/60 dark:text-teal-400",
] as const;
/** Semantic hue overrides (palette indices) — e.g. the firecrawl flame is amber, not whatever the hash lands on. */
const SKILL_COLOR_OVERRIDES: Record<string, number> = {
firecrawl: 3,
"data-analysis": 1,
"web-design": 2,
"penguin-sdk": 0,
};
export function skillTileColor(name: string): string {
const override = SKILL_COLOR_OVERRIDES[name];
if (override !== undefined) return SKILL_TILE_COLORS[override]!;
let h = 0;
for (let i = 0; i < name.length; i++) h = (h * 31 + name.charCodeAt(i)) >>> 0;
return SKILL_TILE_COLORS[h % SKILL_TILE_COLORS.length]!;
}
export function SkillIcon({
icon,
size = 20,
+164 -68
View File
@@ -1,18 +1,26 @@
/**
* Skill library page: the @prismshadow/penguin-skills
* Skill library, shown sectioned by skill group. Group styling matches the model
* config page — the group header (group name + skill count, no icon) is
* collapsible, highlights on hover, and animates height on expand/collapse;
* expanded by default. Cards within a group form a grid (up to three per row on
* wide screens, generously sized). Each card = an enlarged skill icon spanning
* two rows (rounded border + light background; DTO icon = the raw icon.svg from
* the catalog, rendered inline once it passes sanitize, otherwise falls back to
* a default book icon) + a name (monospace) and short description on the right,
* one line each (single-line truncation, falling back to the full description
* when missing) + a metadata line (version · semantic update time · usage count
* "used by N Agents"); group and card copy follow the UI language (localizedText /
* localizedShortText), and groups have no description. Two **icon buttons** for
* Skill library, shown sectioned by skill group. Groups are borderless — the
* group header (group name + skill count, no icon) is collapsible, highlights
* on hover, and animates height on expand/collapse; expanded by default. Cards
* within a group form a grid, generously sized: two per row from the sm
* breakpoint up, one per row on narrow screens. Each card = a rounded icon
* tile centered against the two text rows (its color comes from
* skillTileColor — a per-skill palette hashed from the name, which replaced
* the theme-accent tile that painted every skill the same; DTO icon = the raw
* icon.svg from the catalog, rendered inline once it passes sanitize,
* otherwise falls back to a default book icon) + a name (monospace) and short
* description on the right, one line each (single-line truncation, falling
* back to the full description when missing) + a metadata line below both
* (version · semantic update time · usage count "used by N Agents");
* group and card copy follow the UI language (localizedText /
* localizedShortText), and groups have no description. Icon buttons for
* actions (copy goes into aria-label and title) —
* - Rotate "update installs" (shown only when some Agent's installed copy has
* a lower version than the library): one click reinstalls the current
* library copy on every outdated Agent (install-again-is-update semantics),
* with a single success toast; the manage-installs Modal marks outdated
* rows with an accent "更新"/"Update" button doing the same per Agent;
* - Paper plane "quick invoke": enters /chat/new draft mode with default_agent,
* pre-selects the skill, and pre-fills the invocation text per UI language
* (zh "使用 X 技能" / en "use the X skill", overwriting any existing draft body);
@@ -44,15 +52,35 @@ import { toastError, toastSuccess } from "../../components/ui/toast";
import { DRAFT_SESSION_ID } from "../chat/chat-page";
import { draftKey, loadDraft, saveDraft } from "../chat/draft-cache";
import { localizedShortText, localizedText } from "../chat/skill-use";
import { SkillIcon } from "./skill-icon-view";
import { SkillIcon, skillTileColor } from "./skill-icon-view";
/** agentId → set of installed skill names (in-page install-state snapshot, rewritten in place by optimistic updates). */
type InstalledMap = ReadonlyMap<string, ReadonlySet<string>>;
/** agentId → (installed skill name → installed copy's version); in-page install-state snapshot, rewritten in place by optimistic updates. */
export type InstalledMap = ReadonlyMap<string, ReadonlyMap<string, number>>;
/** "Quick invoke" button icon (paper plane, 24×24 line path; button shows only the icon, copy goes into aria/title). */
const SEND_ICON = "M22 2 11 13M22 2 15 22 11 13 2 9 22 2";
/** "Manage installs" button icon (download into tray, 24×24 line path). */
const INSTALL_ICON = "M21 15v4a2 2 0 0 1-2 2H5a2 2 0 0 1-2-2v-4M7 10l5 5 5-5M12 15V3";
/** "Update installs" button icon (rotate-cw, 24×24 line path). */
const UPDATE_ICON = "M23 4v6h-6M20.49 15a9 9 0 1 1-2.12-9.36L23 10";
/**
* Agents whose installed copy of `name` is older than the library's version (the update
* reminder's data source): not-installed Agents never count, and a locally *newer* copy
* (e.g. a hand-edited install) doesn't either — reminders fire only on a strictly lower
* version.
*/
export function outdatedAgentIds(
agentIds: readonly string[],
installed: InstalledMap,
name: string,
libraryVersion: number,
): string[] {
return agentIds.filter((agentId) => {
const v = installed.get(agentId)?.get(name);
return v !== undefined && v < libraryVersion;
});
}
export function SkillsPage() {
useDocumentTitle(S.nav.skills);
@@ -102,9 +130,9 @@ export function SkillsPage() {
ids.map(async (agentId) => {
try {
const res = await api.getAgentSkills(projectId, agentId);
return [agentId, new Set(res.skills.map((s) => s.name))] as const;
return [agentId, new Map(res.skills.map((s) => [s.name, s.version]))] as const;
} catch {
return [agentId, new Set<string>()] as const;
return [agentId, new Map<string, number>()] as const;
}
}),
).then((entries) => {
@@ -115,8 +143,8 @@ export function SkillsPage() {
// regresses the UI.
if (!cancelled)
setInstalled((prev) => {
const next = new Map<string, ReadonlySet<string>>(entries);
for (const [agentId, set] of prev) next.set(agentId, set);
const next = new Map<string, ReadonlyMap<string, number>>(entries);
for (const [agentId, m] of prev) next.set(agentId, m);
return next;
});
});
@@ -125,28 +153,34 @@ export function SkillsPage() {
};
}, [projectId, agentIdsKey]);
/** Rewrite one Agent's install state in place (shared by optimistic updates and failure rollback). */
const setAgentSkill = (agentId: string, name: string, on: boolean) =>
/** Rewrite one Agent's install state in place (shared by optimistic updates and failure rollback); `version` is the entry's value when setting. */
const setAgentSkill = (agentId: string, name: string, version: number | undefined) =>
setInstalled((prev) => {
const next = new Map(prev);
const set = new Set(next.get(agentId) ?? []);
if (on) set.add(name);
else set.delete(name);
next.set(agentId, set);
const m = new Map(next.get(agentId) ?? []);
if (version !== undefined) m.set(name, version);
else m.delete(name);
next.set(agentId, m);
return next;
});
/** Calibrate one Agent from an install response's list (concurrent install/uninstall also converges to the server's truth). */
const calibrateAgent = (agentId: string, skills: { name: string; version: number }[]) =>
setInstalled((prev) =>
new Map(prev).set(agentId, new Map(skills.map((s) => [s.name, s.version]))),
);
/** Check to install / uncheck to uninstall (any member can do this): optimistic update, a confirmation toast on success, rollback plus a toast on failure. */
const toggleInstall = async (agentId: string, name: string, on: boolean) => {
const toggleInstall = async (agentId: string, name: string, on: boolean, version: number) => {
if (!projectId) return;
setAgentSkill(agentId, name, on);
const prevVersion = installed.get(agentId)?.get(name);
setAgentSkill(agentId, name, on ? version : undefined);
const target = agents.find((a) => a.agentId === agentId);
const agentName = target ? agentDisplayName(target) : agentId;
try {
if (on) {
// On a successful install, calibrate the whole Agent from the list the response carries (concurrent install/uninstall also converges to the server's truth).
const res = await api.installAgentSkills(projectId, agentId, [name]);
setInstalled((prev) => new Map(prev).set(agentId, new Set(res.skills.map((s) => s.name))));
calibrateAgent(agentId, res.skills);
toastSuccess(S.skills.installedToast(name, agentName));
} else {
await api.removeAgentSkill(projectId, agentId, name);
@@ -158,11 +192,29 @@ export function SkillsPage() {
// or erroring (otherwise the checkbox would be stuck permanently
// checked whenever this page's snapshot is stale).
if (!on && e instanceof ApiError && e.status === 404) return;
setAgentSkill(agentId, name, !on);
setAgentSkill(agentId, name, prevVersion);
toastError(apiErrorText(e));
}
};
/**
* Update reminder action: reinstall the current library copy on every outdated Agent
* (install-again-is-update semantics). One success toast for the whole batch; on partial
* failure the succeeded Agents keep their calibrated state and the first error is toasted.
*/
const updateOutdated = async (name: string, agentIds: string[]) => {
if (!projectId || agentIds.length === 0) return;
const results = await Promise.allSettled(
agentIds.map(async (agentId) => {
const res = await api.installAgentSkills(projectId, agentId, [name]);
calibrateAgent(agentId, res.skills);
}),
);
const failed = results.find((r): r is PromiseRejectedResult => r.status === "rejected");
if (!failed) toastSuccess(S.skills.updatedToast(name, agentIds.length));
else toastError(apiErrorText(failed.reason));
};
/**
* Quick invoke: pre-selects the skill in the draft cache (the `skills`
* field, used by ChatInput as its initial selection on mount), pre-fills
@@ -204,7 +256,7 @@ export function SkillsPage() {
</Button>
</div>
) : groups === null ? (
<div className="mt-6 grid gap-2.5 md:grid-cols-2 xl:grid-cols-3">
<div className="mt-6 grid gap-2.5 sm:grid-cols-2">
{Array.from({ length: 4 }, (_, i) => (
<SkeletonCard key={i} className="p-4">
<Skeleton className="h-4 w-32" />
@@ -220,7 +272,7 @@ export function SkillsPage() {
return (
<section
key={group.id}
className="overflow-hidden rounded-md border border-gray-200 bg-white dark:border-gray-800 dark:bg-gray-900"
className="overflow-hidden rounded-md bg-white dark:bg-gray-900"
>
{/* Group header (styled like the model page's provider groups): group name +
skill count (no icon, no description); the whole row toggles
@@ -257,9 +309,9 @@ export function SkillsPage() {
>
{/* inert while collapsed: cards at zero height shouldn't still be Tab-focusable or clickable. */}
<div className="overflow-hidden" inert={!open}>
{/* Up to three cards per row, generously sized (3 columns ≥xl, 2 columns ≥md, 1 column on narrow screens). */}
{/* Generously sized cards: 2 columns ≥sm, 1 column on narrow screens. */}
<div
className={`grid gap-2.5 border-t border-gray-200 p-2.5 transition-opacity duration-200 md:grid-cols-2 xl:grid-cols-3 dark:border-gray-800 ${open ? "opacity-100" : "opacity-0"}`}
className={`grid gap-2.5 p-2.5 transition-opacity duration-200 sm:grid-cols-2 ${open ? "opacity-100" : "opacity-0"}`}
>
{group.skills.map((skill) => (
<SkillCard
@@ -268,6 +320,7 @@ export function SkillsPage() {
installed={installed}
onQuickInvoke={quickInvoke}
onToggleInstall={toggleInstall}
onUpdateOutdated={updateOutdated}
/>
))}
</div>
@@ -283,23 +336,31 @@ export function SkillsPage() {
);
}
/** A single skill card: metadata display (including a semantic metadata line) + quick invoke + "manage installs" Modal. */
/** A single skill card: metadata display (including a semantic metadata line) + update reminder + quick invoke + "manage installs" Modal. */
function SkillCard({
skill,
installed,
onQuickInvoke,
onToggleInstall,
onUpdateOutdated,
}: {
skill: SkillMetadataItem;
installed: InstalledMap;
onQuickInvoke: (name: string) => void;
onToggleInstall: (agentId: string, name: string, on: boolean) => Promise<void>;
onToggleInstall: (agentId: string, name: string, on: boolean, version: number) => Promise<void>;
onUpdateOutdated: (name: string, agentIds: string[]) => Promise<void>;
}) {
const { locale } = useLocale();
const { agents } = useProject();
const [installOpen, setInstallOpen] = useState(false);
let installedCount = 0;
for (const set of installed.values()) if (set.has(skill.name)) installedCount += 1;
for (const m of installed.values()) if (m.has(skill.name)) installedCount += 1;
const outdated = outdatedAgentIds(
agents.map((a) => a.agentId),
installed,
skill.name,
skill.version,
);
// Short description takes priority, falling back to the full description
// when missing (per UI language); title carries the full description for hover reading.
@@ -315,37 +376,54 @@ function SkillCard({
.filter((v): v is string => v !== null)
.join(" · ");
return (
<div className="flex h-full flex-col rounded-md border border-gray-200 bg-white p-4 dark:border-gray-800 dark:bg-gray-900">
{/* Header: an enlarged skill icon spanning two rows (rounded border + light background), with the name and short description on one line each to the right. */}
<div className="flex items-center gap-3">
<span className="flex h-12 w-12 shrink-0 items-center justify-center rounded-lg border border-gray-200 bg-gray-50 text-gray-600 dark:border-gray-700 dark:bg-gray-800/60 dark:text-gray-300">
<SkillIcon icon={skill.icon} size={26} />
</span>
<div className="min-w-0 flex-1">
<span className="block truncate font-mono text-[13px] font-semibold" title={skill.name}>
{skill.name}
</span>
{/* Short description truncates to one line (full description goes into title for hover reading). */}
<p
className="mt-0.5 truncate text-xs leading-5 text-gray-500 dark:text-gray-400"
title={fullDescription}
<div className="flex h-full items-center gap-3 rounded-md p-4 transition-colors hover:bg-gray-100/70 dark:hover:bg-gray-800/60">
<div className="min-w-0 flex-1">
{/* Header: the skill icon centered across the two text rows (rounded tile in the skill's
own palette color — see skillTileColor; deliberately a bit smaller than the two rows),
with the name and short description on one line each to the right. */}
<div className="flex items-center gap-3">
<span
className={`flex h-9 w-9 shrink-0 items-center justify-center rounded-lg ${skillTileColor(skill.name)}`}
>
{description}
</p>
<SkillIcon icon={skill.icon} size={20} />
</span>
<div className="min-w-0 flex-1">
<span className="block truncate font-mono text-[13px] font-semibold" title={skill.name}>
{skill.name}
</span>
{/* Short description truncates to one line (full description goes into title for hover reading). */}
<p
className="mt-0.5 truncate text-xs leading-5 text-gray-500 dark:text-gray-400"
title={fullDescription}
>
{description}
</p>
</div>
</div>
</div>
{/* Footer row: metadata on the left (e.g. `v1 · 今天更新 · N 个 Agent 在用`) +
icon buttons on the right (copy goes into aria-label and title), pinned to the card's bottom with mt-auto. */}
<div className="mt-auto flex items-center gap-1.5 pt-3">
<span
className="min-w-0 flex-1 truncate text-[11px] text-gray-400 dark:text-gray-500"
title={meta}
>
{/* Metadata line under the header (e.g. `v1 · updated today · used by N Agents`). */}
<p className="mt-2.5 truncate text-[11px] text-gray-400 dark:text-gray-500" title={meta}>
{meta}
</span>
</p>
</div>
{/* Actions: equal-square light icon buttons in a single row, vertically centered at the
card's right edge (copy goes into aria-label and title). */}
<div className="flex shrink-0 items-center justify-center gap-1.5">
{/* Light (secondary): an update nudge, not the card's primary action. */}
{outdated.length > 0 && (
<Button
size="sm"
variant="secondary"
className="h-8 w-8 shrink-0 justify-center p-0"
aria-label={`${S.skills.updateOutdated(outdated.length)} ${skill.name}`}
title={S.skills.updateOutdated(outdated.length)}
onClick={() => void onUpdateOutdated(skill.name, outdated)}
>
<GlyphIcon d={UPDATE_ICON} size={13} />
</Button>
)}
<Button
size="sm"
className="shrink-0 px-1.5"
className="h-8 w-8 shrink-0 justify-center p-0"
aria-label={`${S.skills.quickInvoke} ${skill.name}`}
title={S.skills.quickInvoke}
onClick={() => onQuickInvoke(skill.name)}
@@ -354,8 +432,7 @@ function SkillCard({
</Button>
<Button
size="sm"
variant="primary"
className="shrink-0 px-1.5"
className="h-8 w-8 shrink-0 justify-center p-0"
aria-label={`${S.skills.manageInstall} ${skill.name}`}
title={S.skills.manageInstall}
onClick={() => setInstallOpen(true)}
@@ -379,7 +456,9 @@ function SkillCard({
agentId={a.agentId}
name={agentDisplayName(a)}
installed={installed.get(a.agentId)?.has(skill.name) ?? false}
onToggle={(on) => void onToggleInstall(a.agentId, skill.name, on)}
outdated={outdated.includes(a.agentId)}
onToggle={(on) => void onToggleInstall(a.agentId, skill.name, on, skill.version)}
onUpdate={() => void onUpdateOutdated(skill.name, [a.agentId])}
/>
))}
</div>
@@ -392,19 +471,25 @@ function SkillCard({
/**
* One Agent row in the "manage installs" Modal: not-installed shows
* "安装"/"Install"; installed shows "已安装"/"Installed", switching to
* "卸载"/"Uninstall" on hover (same button, click to uninstall). Both
* install and uninstall go through optimistic updates (toggleInstall), rolling back on failure.
* "卸载"/"Uninstall" on hover (same button, click to uninstall); an installed
* copy older than the library additionally shows an accent "更新"/"Update"
* button (reinstall = update). Install and uninstall go through optimistic
* updates (toggleInstall), rolling back on failure.
*/
function InstallRow({
agentId,
name,
installed,
outdated,
onToggle,
onUpdate,
}: {
agentId: string;
name: string;
installed: boolean;
outdated: boolean;
onToggle: (on: boolean) => void;
onUpdate: () => void;
}) {
return (
<div className="flex items-center gap-2 rounded-md px-1.5 py-1.5 transition-colors duration-150 hover:bg-gray-50 dark:hover:bg-gray-800/60">
@@ -412,6 +497,17 @@ function InstallRow({
<span className="min-w-0 flex-1 truncate text-sm" title={agentId}>
{name}
</span>
{installed && outdated && (
<Button
size="sm"
variant="secondary"
className="shrink-0"
aria-label={`${S.skills.updateAction} ${agentId}`}
onClick={onUpdate}
>
{S.skills.updateAction}
</Button>
)}
{installed ? (
// group: on hover the button's copy switches "已安装"/"Installed" → "卸载"/"Uninstall" (the same button carries the uninstall action).
<Button
@@ -219,16 +219,18 @@ export function TraceFileView({
}, [projectId, agentId, sessionId, index]);
// Pricing for the session's Model (main session only; sub-session Tokens
// live in their own Trace and aren't part of this file):
// session_meta carries a paired reference (provider + model_id), matched against model config by that pair.
// live in their own Trace and aren't part of this file): session_meta carries a paired
// reference (provider + model_id), matched against model config by that whole pair.
// A Trace whose session_meta has no provider is legacy data (core refuses to resume it
// for the same reason) — the model_id alone identifies nothing, since the same id can
// exist under several providers at different prices, so no pricing is shown rather than
// an arbitrary first match.
const pricing = useMemo(() => {
const meta = events.find((m) => m.type === "session_meta");
const ref = meta ? (meta.payload as { model_id?: string; provider?: string }) : undefined;
if (!ref?.model_id) return undefined;
return models?.models.find(
(m) =>
m.modelId === ref.model_id && (ref.provider === undefined || m.provider === ref.provider),
)?.pricing;
if (!ref?.model_id || !ref.provider) return undefined;
return models?.models.find((m) => m.modelId === ref.model_id && m.provider === ref.provider)
?.pricing;
}, [events, models]);
const costOf = (b: Buckets): number | null => {
+11
View File
@@ -31,6 +31,17 @@ export function humanizeDuration(ms: number): string {
return `${m}m${Math.round(s % 60)}s`;
}
/**
* Duration format for a still-running timer: whole seconds only (`0s`, `7s`, `1m3s`),
* counting up (floor, never showing a second early). Decimals are reserved for settled
* durations (humanizeDuration above) — a live timer showing tenths reads as jitter.
*/
export function humanizeDurationLive(ms: number): string {
const s = Math.max(0, Math.floor(ms / 1000));
if (s < 60) return `${s}s`;
return `${Math.floor(s / 60)}m${s % 60}s`;
}
/** Add an explicit sign to a delta string: non-negative gets `+`, negative already has `-` (negative when context shrinks after compaction). */
export function signedDelta(formatted: string): string {
return formatted.startsWith("-") ? formatted : `+${formatted}`;
+21 -6
View File
@@ -176,15 +176,25 @@ export function createStreamController(deps: StreamControllerDeps): StreamContro
// disconnected shouldn't leave a lingering button; the server will
// resend still-pending approval_request events on the same connection afterward, naturally rebuilding it.
clearPending();
// Swap in a new model, injecting the shared localDecisions: approvals decided on this end are still labeled "manual" after the rebuild.
model = createStreamModel(localDecisions);
await load(epoch);
// Atomic swap — deliberately NOT `onLoading(true)`. The fresh model (shared localDecisions
// injected, so approvals decided on this end stay labeled "manual") is handed to load(), which
// makes it the visible model only once history is back in hand. Until then the OLD transcript
// stays mounted and on screen: a mid-stream resync never blanks the conversation nor unmounts
// the composer. (Flipping loading=true here drove the consumer to swap both the message list and
// the input for a skeleton, losing scroll position, expanded tool cards, and composer
// focus/draft.) Deltas arriving during the refetch are buffered and replayed on swap; the brief
// no-new-text pause is invisible next to a full teardown.
await load(epoch, createStreamModel(localDecisions));
};
const load = async (currentEpoch: number): Promise<void> => {
const load = async (currentEpoch: number, freshModel?: StreamModel): Promise<void> => {
try {
const messages = await deps.loadMessages();
if (disposed || currentEpoch !== epoch) return;
// Rebuild path: make the freshly-built model visible only now, atomically — the old model
// stayed on screen throughout the refetch above (see rebuild). Initial load / retry pass no
// freshModel and keep operating on the current model.
if (freshModel) model = freshModel;
const target = model;
pushMessages(target, messages, now());
const dedup = buildDedupIndex(messages, 100);
@@ -235,11 +245,16 @@ export function createStreamController(deps: StreamControllerDeps): StreamContro
},
retry: async () => {
if (disposed || !failed) return;
// On failure the model was never written to and the buffer kept accumulating: keep both, just refetch history and replay again.
// Retry always rebuilds into a FRESH model. After an initial-load failure the model was
// never written, but after a failed *resync* rebuild the OLD populated model is deliberately
// left in place (so the transcript didn't blank during the refetch) — pushing the refetched
// history straight onto it would duplicate the entire conversation (pushMessages appends with
// no id-dedup). load() swaps the fresh model in only once the refetch succeeds, then replays
// the still-accumulating buffer into it; localDecisions carry over via the shared set.
deps.onError(null);
deps.onLoading(true);
epoch += 1;
await load(epoch);
await load(epoch, createStreamModel(localDecisions));
},
handleOmni: (msg) => {
if (disposed) return;
+83 -2
View File
@@ -80,12 +80,13 @@ export const en: Strings = {
logout: "Sign out",
admin: "Admin",
defaultAdminNote:
"First run: sign in as the built-in admin (admin / admin123), then change the password soon",
"First run: sign in as the built-in admin (admin / penguin-2026), then change the password soon",
},
account: {
changePassword: "Change password",
oldPassword: "Current password",
oldPasswordHint: "The built-in admin's default initial password is penguin-2026",
newPassword: "New password",
confirmPassword: "Confirm new password",
passwordMismatch: "New passwords do not match",
@@ -286,6 +287,22 @@ export const en: Strings = {
groupEmptyHint: "No models in this group yet; use “Add model” to create one",
searchPlaceholder: "Search models: id / name / provider",
noSearchResults: "No matching models",
syncCatalog: "Sync presets",
syncCatalogHint:
"Update preset models from the built-in catalog: add missing entries and reset differing ones to the catalog's fields; locally added models and API keys are left untouched",
syncDone: (added: number, updated: number) =>
`Presets synced: ${added} added, ${updated} updated`,
syncUpToDate: "Presets are already up to date",
homepage: "Model page",
speedTest: "Speed test",
speedTestTitle: "Speed test",
speedTestConfirm: (n: number): string =>
`This sends one real request to each of the ${n} models in this group, one at a time, to measure time-to-first-token (TTFT) and output rate (TPS). It consumes a small amount of API quota. Continue?`,
speedTestStart: "Start",
speedPending: "Testing…",
speedFailed: "Test failed",
ttftTitle: "Time to first token (TTFT)",
tpsTitle: "Output rate (TPS)",
modelCount: (n: number): string => `${n} model${n === 1 ? "" : "s"}`,
modelId: "Model ID",
modelIdHint: "The upstream API model id, e.g. gpt-5.5",
@@ -373,7 +390,7 @@ export const en: Strings = {
},
vault: {
desc: "Environment variables owned by this Agent (stored in agent_state/.vault.toml), injected into the environment of its shell commands (exec_command); key names are shared with the model, values never enter the model context. Subagents use their own vaults and do not inherit this one.",
desc: "Environment variables owned by this Agent (stored in agent_state/.vault.toml), injected into the environment of its shell commands (exec_command); key names are shared with the model, values never enter the model context. Subagents use their own vaults and do not inherit this one. Saved changes take effect from the next task (a task already running is unaffected).",
key: "Name",
value: "Value",
valueMasked: "Value (masked)",
@@ -449,6 +466,10 @@ export const en: Strings = {
usedByAgents: (n: number): string =>
n === 0 ? "not used yet" : n === 1 ? "used by 1 agent" : `used by ${n} agents`,
installedToast: (skill: string, agent: string): string => `Installed ${skill} to ${agent}`,
updateOutdated: (n: number): string => `Update available: update ${n} Agent install(s)`,
updateAction: "Update",
updatedToast: (skill: string, n: number): string =>
`Updated ${skill} to the latest version (${n} Agent(s))`,
uninstalledToast: (skill: string, agent: string): string =>
`Uninstalled ${skill} from ${agent}`,
},
@@ -464,6 +485,63 @@ export const en: Strings = {
workspaceClear: "Use auto temp directory instead",
workspaceDirInvalid: "Directory does not exist or is inaccessible; reverted",
draftSubtitle: "The self-evolving agent that excels at AI development tasks",
exampleTasks: {
game: {
label: "Example: 2D penguin sled game",
desc: "A cute Antarctic penguin sleds over rocks, easy start with a gentle difficulty ramp — a 2D pure-frontend mini game",
prompt:
"Build a cute Antarctic penguin sledding 2D game: press Space to jump over the rocks " +
"coming up on the ice; start easy and forgiving, with sled speed and obstacle density " +
"ramping up smoothly and gradually over time (no sudden spikes), live scoring, and " +
"hitting a rock ending the run with one-click restart. " +
"A 2D side-scroller with a cute cartoon look, pure frontend (a single HTML file is " +
"fine), styled per the web-design skill. " +
"When done, test it in a browser once, confirm the first few seconds are easy to " +
"clear, and tell me how to open it and how to play.",
},
lol: {
label: "Example: League of Legends music player",
desc: "Worlds anthems on the SoundCloud Widget API — a single file that opens from file://",
prompt: `Build a League of Legends Worlds anthem player with the SoundCloud Widget API (see https://developers.soundcloud.com/docs/api/html5-widget): a single index.html that works when opened from file://.
## Technical constraints
- Use the SC.Widget JS API (widget.load / widget.toggle / widget.setVolume / widget.seekTo), loading https://w.soundcloud.com/player/api.js
- The iframe must stay visible (180px tall), with visual=true color=f0b90b single_active=true
- Include ONLY these 8 tracks confirmed playable (oEmbed-verified); do not add tracks that are not oEmbed-verified:
- Warriors (S4) — soundcloud.com/leagueoflegends/warriors
- Worlds Collide (S5) — soundcloud.com/leagueoflegends/worlds-collide
- Legends Never Die (S7) — soundcloud.com/leagueoflegends/legends-never-die
- Phoenix (S9) — soundcloud.com/leagueoflegends/phoenix
- Burn It All Down (S11) — soundcloud.com/leagueoflegends/burn-it-all-down
- GODS (S13) — soundcloud.com/leagueoflegends/gods
- Heavy Is The Crown (S14) — soundcloud.com/linkinpark/heavy-is-the-crown
- Sacrifice (S15) — soundcloud.com/leagueoflegends/sacrifice
## Layout
- Left 260px sticky sidebar: the track list (S4/S5/… badge + emoji + title + year); clicking highlights with a gold border and switches tracks via SC.Widget.load() with auto_play
- Right main area: hero title + a desktop clock (80px monospace gold HH:MM:SS, refreshed every second, blinking colons) + a mood tag
- Player card: the SoundCloud iframe + a custom control bar (⏮ ▶/⏸ ⏭ + track info + a volume slider; clicking the speaker icon toggles mute)
- Mood-wave section: 15 gold animated bars, re-randomized on every track switch
- Keyboard shortcuts: Space play/pause, ← → previous/next, ↑ ↓ volume
## Design
Penguin visual style (see the web-design skill), dark/light themes via <html data-theme>, dark by default, remembered in localStorage. Responsive: on phones the sidebar becomes a horizontally scrolling top bar.
When done, open index.html in a browser and self-test once.`,
},
rag: {
label: "Example: build a Claude Code docs expert",
desc: "Collect the claude-code-docs repo into a conversational RAG knowledge app with source citations",
prompt:
"Collect the docs from https://github.com/ericbuess/claude-code-docs and build a RAG knowledge app: " +
"clone the repo and prepare the corpus, then build a retrieval index; " +
"the app acts as a Claude Code configuration expert, answering Claude Code questions " +
"with retrieval-augmented replies and clickable citations that reveal the matched " +
"original text chunk and link to the real documents; " +
"give it a beautiful web chat UI following the web-design skill, with a few example questions in the empty state. " +
"When done, run the app, verify one streamed answer yourself, and tell me how to access it.",
},
},
sessionList: "Sessions",
defaultSessionTitle: "New chat",
model: "Model",
@@ -487,6 +565,7 @@ export const en: Strings = {
statusRunning: "Running",
statusCompacting: "Compacting",
pendingApprovals: (n: number) => `${n} pending approval${n > 1 ? "s" : ""}`,
jumpToLatest: "Jump to latest",
inputPlaceholder: "Type a message. Enter to send, Shift+Enter for newline, paste images",
inputPlaceholderShort: "Type a message…",
send: "Send",
@@ -547,6 +626,7 @@ export const en: Strings = {
mentionHint: "@ to handoff to another agent",
mentionRemove: "Remove @ target",
skillsSelect: "Skills",
skillRemove: "Remove skill",
skillsSearchPlaceholder: "Search skills",
skillsNoMatch: "No matching skills",
skillsEmptyHint: "No skills installed yet — add some from the skill library",
@@ -598,6 +678,7 @@ export const en: Strings = {
title: "Files",
upload: "Upload",
download: "Download",
openInNewTab: "Open in new tab",
refresh: "Refresh",
root: "Workspace root",
empty: "Empty directory",
+85 -2
View File
@@ -80,12 +80,13 @@ export const zh = {
login: "登录",
logout: "登出",
admin: "管理员",
defaultAdminNote: "首次使用请以内置管理员登录:admin / admin123,登录后请尽快修改密码",
defaultAdminNote: "首次使用请以内置管理员登录:admin / penguin-2026,登录后请尽快修改密码",
},
account: {
changePassword: "修改密码",
oldPassword: "当前密码",
oldPasswordHint: "内置管理员的默认初始密码为 penguin-2026",
newPassword: "新密码",
confirmPassword: "确认新密码",
passwordMismatch: "两次输入的新密码不一致",
@@ -262,6 +263,21 @@ export const zh = {
groupEmptyHint: "该分组暂无模型,点「新增模型」添加",
searchPlaceholder: "搜索模型:id / 名称 / 厂商",
noSearchResults: "没有匹配的模型",
syncCatalog: "同步预置",
syncCatalogHint:
"用内置目录更新预置模型:新增缺失条目、以目录字段为准刷新差异;本地新增模型与 API key 保持不变",
syncDone: (added: number, updated: number) => `预置模型已同步:新增 ${added}、更新 ${updated}`,
syncUpToDate: "预置模型已是最新",
homepage: "模型主页",
speedTest: "测速",
speedTestTitle: "分组测速",
speedTestConfirm: (n: number): string =>
`将对该分组的 ${n} 个模型逐个发起一次真实请求,测量首 token 延迟(TTFT)与输出速率(TPS),会消耗少量 API 额度。是否继续?`,
speedTestStart: "开始测速",
speedPending: "测速中…",
speedFailed: "测速失败",
ttftTitle: "首 token 延迟(TTFT)",
tpsTitle: "输出速率(TPS)",
modelCount: (n: number): string => `${n} 个模型`,
modelId: "模型 ID",
modelIdHint: "上游 API 使用的模型 id,如 gpt-5.5",
@@ -351,7 +367,7 @@ export const zh = {
},
vault: {
desc: "本 Agent 专属的环境变量(存于 agent_state/.vault.toml):键值对注入其 shell 命令(exec_command)的子进程环境;键名会告知模型,值不进入模型上下文。子 Agent 使用各自的保险柜,不继承。",
desc: "本 Agent 专属的环境变量(存于 agent_state/.vault.toml):键值对注入其 shell 命令(exec_command)的子进程环境;键名会告知模型,值不进入模型上下文。子 Agent 使用各自的保险柜,不继承。保存后自下一个任务起生效(进行中的任务不受影响)。",
key: "键名",
value: "值",
valueMasked: "值(掩码)",
@@ -429,6 +445,10 @@ export const zh = {
usedByAgents: (n: number): string => (n === 0 ? "未被使用" : `${n} 个 Agent 在用`),
/** Top toast shown on successful install / uninstall. */
installedToast: (skill: string, agent: string): string => `已将 ${skill} 安装到 ${agent}`,
updateOutdated: (n: number): string => `有新版本:更新 ${n} 个 Agent 的安装`,
updateAction: "更新",
updatedToast: (skill: string, n: number): string =>
`已将 ${skill} 更新到最新版(${n} 个 Agent)`,
uninstalledToast: (skill: string, agent: string): string => `已从 ${agent} 卸载 ${skill}`,
},
@@ -443,6 +463,66 @@ export const zh = {
workspaceClear: "改用自动临时目录",
workspaceDirInvalid: "目录不存在或无法访问,已回退",
draftSubtitle: "最擅长 AI 开发任务的自进化 Agent",
/**
* Example task cards on the draft screen: one click auto-submits the canned prompt (game
* card first, RAG card below/after it). These are the FULL working prompts — the README and
* landing page show a condensed one-sentence version of the RAG example for reading, and
* the cards' own desc lines stay short, but what actually gets submitted stays detailed:
* build quality depends on it.
*/
exampleTasks: {
game: {
label: "示例:2D 企鹅雪橇越野小游戏",
desc: "可爱南极企鹅滑雪橇跳石头,难度由易到难的 2D 纯前端小游戏",
prompt:
"做一个可爱的南极企鹅滑雪橇越野 2D 小游戏:按空格键起跳,跃过冰面上迎面而来的石头;" +
"开局要足够简单、上手无压力,滑行速度与障碍密度随时间平滑、循序渐进地上升,避免突然变难," +
"实时计分,撞上石头即结束并可一键重新开始。" +
"2D 横版画面、可爱卡通风,纯前端实现(单个 HTML 文件即可),界面遵循 web-design 技能。" +
"完成后在浏览器里自测一次,确认开局能轻松玩过几秒,并告诉我怎么打开和怎么玩。",
},
lol: {
label: "示例:英雄联盟音乐播放器",
desc: "用 SoundCloud Widget API 播放历届 Worlds 主题曲,单文件即开即用",
prompt: `用 SoundCloud Widget API(见 https://developers.soundcloud.com/docs/api/html5-widget)做一个英雄联盟 Worlds 主题曲播放器,单文件 index.html,file:// 打开即用。
## 技术约束
- 使用 SC.Widget JS API(widget.load / widget.toggle / widget.setVolume / widget.seekTo),引入 https://w.soundcloud.com/player/api.js
- iframe 必须可见(180px 高),visual=true color=f0b90b single_active=true
- 仅包含以下 8 首已确认可播曲目(oEmbed 验证通过),不要添加未经 oEmbed 验证的曲目:
- Warriors (S4) — soundcloud.com/leagueoflegends/warriors
- Worlds Collide (S5) — soundcloud.com/leagueoflegends/worlds-collide
- Legends Never Die (S7) — soundcloud.com/leagueoflegends/legends-never-die
- Phoenix (S9) — soundcloud.com/leagueoflegends/phoenix
- Burn It All Down (S11) — soundcloud.com/leagueoflegends/burn-it-all-down
- GODS (S13) — soundcloud.com/leagueoflegends/gods
- Heavy Is The Crown (S14) — soundcloud.com/linkinpark/heavy-is-the-crown
- Sacrifice (S15) — soundcloud.com/leagueoflegends/sacrifice
## 布局
- 左侧 260px 粘性侧边栏:曲目列表(S4/S5/… 标签 + emoji + 曲名 + 年份),点击高亮金色边框,SC.Widget.load() 切歌 + auto_play
- 右侧主区域:Hero 标题 + 桌面时钟(80px 等宽金色 HH:MM:SS,每秒刷新,冒号闪烁)+ 心情标签
- 播放器卡片:SoundCloud iframe + 自定义控制栏(⏮ ▶/⏸ ⏭ + 曲目信息 + 音量滑块,点击喇叭图标静音切换)
- 心情波动区:15 根金色动画柱,切歌时重新随机生成
- 键盘快捷键:空格播放暂停、← → 切歌、↑ ↓ 调音量
## 设计
Penguin 视觉风格(见 web-design 技能),深色/浅色主题(<html data-theme>),默认深色,localStorage 记忆。响应式:手机端侧边栏变为顶部横向滚动。
完成后在浏览器打开 index.html 自测一次。`,
},
rag: {
label: "示例:构建 Claude Code 文档专家",
desc: "收集 claude-code-docs 仓库,生成可对话、带来源引用的 RAG 知识应用",
prompt:
"收集 https://github.com/ericbuess/claude-code-docs 的文档,构建一个 RAG 知识应用:" +
"克隆仓库并整理语料,建立检索索引;应用化身 Claude Code 配置专家," +
"检索增强回答 Claude Code 相关问题并标注可点击的来源引用——" +
"引用要能展示命中的原文片段,并链接到真实文档;" +
"按 web-design 技能提供美观的 Web 聊天界面,空态展示几个示例问题。" +
"完成后运行应用、自测一个问题验证流式回答,并告诉我访问方式。",
},
},
sessionList: "Session",
defaultSessionTitle: "新对话",
model: "Model",
@@ -466,6 +546,7 @@ export const zh = {
statusRunning: "运行中",
statusCompacting: "压缩中",
pendingApprovals: (n: number) => `${n} 个待审批`,
jumpToLatest: "回到最新消息",
inputPlaceholder: "输入消息,Enter 发送,Shift+Enter 换行,可粘贴图片",
inputPlaceholderShort: "输入消息…",
send: "发送",
@@ -527,6 +608,7 @@ export const zh = {
mentionRemove: "移除 @ 目标",
/** Skill multi-select dropdown (input toolbar): button text, search box, empty state, and no-match hint. */
skillsSelect: "技能",
skillRemove: "移除技能",
skillsSearchPlaceholder: "搜索技能",
skillsNoMatch: "没有匹配的技能",
skillsEmptyHint: "暂无已装技能,去技能库添加",
@@ -574,6 +656,7 @@ export const zh = {
title: "文件",
upload: "上传",
download: "下载",
openInNewTab: "新页面打开",
refresh: "刷新",
root: "根目录",
empty: "空目录",
+6
View File
@@ -359,6 +359,12 @@
}
.md-body pre {
@apply overflow-x-auto rounded-lg border border-gray-200 bg-gray-50 p-3 text-sm dark:border-gray-800 dark:bg-gray-900;
/* Off-screen code blocks skip layout/paint: with several settled 200KB+ replies in the
transcript, their <pre> layout dominated every streaming commit (profiled ~90% native
layout) and froze the chat while large code streamed in. auto keeps the measured size for
stable scrollbar geometry; on-screen blocks render normally and stay find-in-page searchable. */
content-visibility: auto;
contain-intrinsic-size: auto 400px;
}
.md-body pre code {
background: transparent;