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

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

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

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

Verified on the branch head: pnpm test (1127 passing, 7 packages), pnpm typecheck and
pnpm format:check clean, Playwright e2e 14/14.
This commit is contained in:
Yaowei Zheng
2026-07-21 17:43:31 +08:00
committed by GitHub
parent abf0a2f248
commit d4faee3a1e
214 changed files with 9406 additions and 1688 deletions
+135 -43
View File
@@ -12,7 +12,9 @@
* logo + name display;
* `/` opens the slash command menu (`/compact` compresses context, replacing the button; each
* installed skill gets its own entry; pressing Enter on `/<skill_name>` toggles that skill's
* selection and clears the input, without sending);
* selection without sending). Matching is positional like `@`: a slash opens the menu from any
* caret position, running a command removes just that token, and Escape only dismisses the menu —
* the rest of the draft is never touched;
* `@` opens the agent selection menu; once picked it becomes a fixed highlighted target chip
* above the text body (only one allowed, picking again replaces it; removed via backspace or the
* x button); only a leading `@` at the start of the text counts — typing or pasting text starting
@@ -54,8 +56,9 @@ import { GlyphIcon } from "../../components/ui/glyph-icon";
import { SkillIcon } from "../skills/skill-icon-view";
import { ZoomableImage } from "../../components/ui/image-zoom";
import { ProviderLogo } from "../../components/ui/provider-logo";
import { matchesQuery, sameModelRef } from "../models/model-grouping";
import { matchesQuery, orderModelsLikeLibrary, sameModelRef } from "../models/model-grouping";
import { filterAgents, matchMention, splitLeadingMention } from "./agent-mentions";
import { matchSlash, removeSlashToken } from "./slash-token";
import {
BOOK_ICON,
buildSkillsMessage,
@@ -231,7 +234,9 @@ function ModelSelect({
const current = models.find((m) => sameModelRef(m, value));
// Display rule matches the model page's card: display name, or falls back to the upstream id (grouping is already conveyed by the provider logo).
const label = current ? modelLabel(current) : (value?.modelId ?? "…");
const filtered = models.filter((m) => matchesQuery(m, query));
// Dropdown order mirrors the model library page: provider groups in MODEL_PROVIDERS order
// (user-defined groups after, custom last), in-group order preserved.
const filtered = orderModelsLikeLibrary(models).filter((m) => matchesQuery(m, query));
return (
<Dropdown
open={open}
@@ -353,9 +358,11 @@ function SkillSelect({
open={open}
setOpen={setOpen}
menuClass={
// As wide as reasonably possible so descriptions stay readable; the viewport clamp
// keeps it inside phone screens.
direction === "down"
? "left-0 top-full mt-1 w-72 max-w-[calc(100vw-2rem)] origin-top-left"
: "bottom-full left-0 mb-1 w-72 max-w-[calc(100vw-2rem)] origin-bottom-left"
? "left-0 top-full mt-1 w-[26rem] max-w-[calc(100vw-2rem)] origin-top-left"
: "bottom-full left-0 mb-1 w-[26rem] max-w-[calc(100vw-2rem)] origin-bottom-left"
}
button={
<button
@@ -373,7 +380,7 @@ function SkillSelect({
<GlyphIcon d={BOOK_ICON} size={14} className="shrink-0" />
{/* When the card is narrower than @md, only the icon + badge remain (title shows the full name). */}
<span className="hidden min-w-0 truncate @md:block">{S.chat.skillsSelect}</span>
{/* Selected-count badge: chips no longer exist, so this is the only visible rendering of the selected state. */}
{/* Selected-count badge (the chip row above the input mirrors the selection too). */}
{selected.length > 0 && (
<span className="shrink-0 rounded-full bg-gray-200/80 px-1.5 py-px font-mono text-[10px] font-semibold text-gray-700 dark:bg-gray-700/60 dark:text-gray-200">
{selected.length}
@@ -616,9 +623,17 @@ export function ChatInput({
}) {
const { locale } = useLocale();
const [text, setText] = useState(initialText ?? "");
/** Live text mirror for slash-command run() closures (the commands memo deliberately doesn't depend on text). */
const textRef = useRef(text);
textRef.current = text;
const [images, setImages] = useState<string[]>([]);
const [busy, setBusy] = useState(false);
const [slashIndex, setSlashIndex] = useState(0);
// Slash token start where Escape closed the menu (mirrors mentionDismissed: the menu stays shut for that one token).
const [slashDismissed, setSlashDismissed] = useState<number | null>(null);
// Anchor for the popups that open upward, and the room actually available above them.
const anchorRef = useRef<HTMLDivElement>(null);
const [upwardMaxH, setUpwardMaxH] = useState<number>();
// @ handoff target (chip, fixed at the front of the input); only one allowed, picking again replaces it directly.
const [target, setTarget] = useState<AgentSummary | null>(null);
// Selected skills (dropdown checklist, multi-select): initial value comes from draft restore (quick-invoke pre-selection), cleared on successful send.
@@ -654,11 +669,15 @@ export function ChatInput({
[selectedSkills, onSkillsChange],
);
/** The slash token currently under the caret (kept in a ref so command run() closures always remove the live token). */
const slashMatchRef = useRef<ReturnType<typeof matchSlash>>(null);
const commands = useMemo<SlashCommand[]>(() => {
/** Clears the input after a slash command runs (setText is async: wait for the DOM value to update before measuring height). */
/** Removes just the slash token after a command runs (the rest of the text stays; setText is async: wait for the DOM value to update before measuring height). */
const clearInput = () => {
setText("");
onTextChange?.("");
const match = slashMatchRef.current;
const next = match ? removeSlashToken(textRef.current, match) : "";
setText(next);
onTextChange?.(next);
requestAnimationFrame(autoGrow);
};
return [
@@ -681,10 +700,14 @@ export function ChatInput({
})),
];
}, [onCompact, onTextChange, skills, locale, toggleSkill]);
const trimmed = text.trim();
// Positional matching (like @ mentions): a slash opens the menu from any caret position;
// running a command removes just the token, leaving the rest of the text intact. Doesn't
// reopen after Escape until the caret sits on a different token.
const slashTok = !running && !compacting ? matchSlash(text, caret) : null;
slashMatchRef.current = slashTok;
const slashMatches =
text.trimStart().startsWith("/") && !running && !compacting
? commands.filter((c) => c.cmd.startsWith(trimmed) || trimmed === "/")
slashTok && slashTok.start !== slashDismissed
? commands.filter((c) => c.cmd.startsWith(`/${slashTok.query}`))
: [];
const slashOpen = slashMatches.length > 0;
const activeSlash = slashMatches[Math.min(slashIndex, slashMatches.length - 1)];
@@ -696,6 +719,31 @@ export function ChatInput({
const mentionOpen = mentionMatches.length > 0;
const activeMention = mentionMatches[Math.min(mentionIndex, mentionMatches.length - 1)];
// Both menus above are drawn upward (`bottom-full`) from the composer, so their ceiling is
// whatever ancestor clips overflow — on the draft page that's the centered scroll area, whose
// top edge sits well below the viewport's. A static `40vh` cap can't know that distance and
// clipped the first rows on shorter windows, so measure the real gap when a menu opens.
useEffect(() => {
if (!slashOpen && !mentionOpen) return;
const measure = () => {
const el = anchorRef.current;
if (!el) return;
let ceiling = 0;
for (let p = el.parentElement; p; p = p.parentElement) {
if (getComputedStyle(p).overflowY !== "visible") {
ceiling = p.getBoundingClientRect().top;
break;
}
}
// Less the menu's own 6px offset from the composer, plus a little breathing room.
const room = el.getBoundingClientRect().top - ceiling - 14;
setUpwardMaxH(Math.max(96, Math.min(320, Math.round(room))));
};
measure();
window.addEventListener("resize", measure);
return () => window.removeEventListener("resize", measure);
}, [slashOpen, mentionOpen]);
/** Auto-grow the textarea (caps at roughly 6 lines, scrolls internally beyond that). */
const autoGrow = () => {
const el = textareaRef.current;
@@ -829,10 +877,10 @@ export function ChatInput({
return;
}
if (e.key === "Escape") {
setText("");
onTextChange?.("");
// setText is async: wait for the DOM value to update before measuring height (measuring synchronously would read the pre-clear content).
requestAnimationFrame(autoGrow);
// Only closes the popup, doesn't clear the input: with positional matching the `/token`
// is part of the text body like any other word, and wiping a controlled textarea is not
// undoable with Ctrl+Z. Reopens if the user keeps typing on another token.
setSlashDismissed(slashTok?.start ?? null);
return;
}
}
@@ -853,7 +901,7 @@ export function ChatInput({
return;
}
if (e.key === "Escape") {
// Only closes the popup, doesn't clear the input (unlike slash: the @ prefix is part of the text body), reopens if the user keeps typing.
// Only closes the popup, doesn't clear the input (the `@token` is part of the text body), reopens if the user keeps typing.
setMentionDismissed(mention?.start ?? null);
return;
}
@@ -909,14 +957,21 @@ export function ChatInput({
};
return (
<div className="relative">
{/* Slash command menu (triggered by typing /; /compact plus one entry per installed skill) */}
<div className="relative" ref={anchorRef}>
{/* Slash command menu (triggered by typing /; /compact plus one entry per installed skill).
Height is capped to the room measured above the composer (see upwardMaxH) with internal
scrolling, so a long skill list never pushes the menu's top edge out of view; the active
row keeps itself scrolled into view. */}
{slashOpen && (
<div className="anim-pop absolute bottom-full left-0 z-40 mb-1.5 w-80 max-w-[calc(100vw-2rem)] rounded-md border border-gray-200 bg-white py-1 shadow-lg dark:border-gray-700 dark:bg-gray-900">
<div
style={{ maxHeight: upwardMaxH }}
className="anim-pop absolute bottom-full left-0 z-40 mb-1.5 w-80 max-w-[calc(100vw-2rem)] overflow-y-auto overscroll-contain rounded-md border border-gray-200 bg-white py-1 shadow-lg dark:border-gray-700 dark:bg-gray-900"
>
{slashMatches.map((c, i) => (
<button
key={c.cmd}
type="button"
ref={c === activeSlash ? (el) => el?.scrollIntoView({ block: "nearest" }) : undefined}
onMouseEnter={() => setSlashIndex(i)}
onClick={() => c.run()}
className={`flex w-full items-center gap-2 px-3 py-1.5 text-left text-sm ${
@@ -938,7 +993,10 @@ export function ChatInput({
{/* @ subagent menu (triggered by typing @; interaction matches the slash menu) */}
{mentionOpen && (
<div className="anim-pop absolute bottom-full left-0 z-40 mb-1.5 max-h-64 w-72 overflow-y-auto rounded-md border border-gray-200 bg-white py-1 shadow-lg dark:border-gray-700 dark:bg-gray-900">
<div
style={{ maxHeight: Math.min(256, upwardMaxH ?? 256) }}
className="anim-pop absolute bottom-full left-0 z-40 mb-1.5 w-72 overflow-y-auto rounded-md border border-gray-200 bg-white py-1 shadow-lg dark:border-gray-700 dark:bg-gray-900"
>
{mentionMatches.map((a, i) => (
<button
key={a.agentId}
@@ -1001,27 +1059,56 @@ export function ChatInput({
card's width changes with the viewport and the Files panel squeezing it, viewport
breakpoints wouldn't judge it accurately. */}
<div className="@container rounded-lg border border-gray-300 bg-white px-2.5 pb-2 pt-2 transition-[border-color,box-shadow] duration-200 focus-within:border-gray-500 focus-within:ring-2 focus-within:ring-gray-400/30 dark:border-gray-700 dark:bg-gray-900 dark:focus-within:border-gray-400">
{/* @ handoff target chip: above the text body (background highlight to distinguish it from plain text), removed via the x button or backspace at the start of the text. */}
{target !== null && (
{/* Chip row above the text body: the @ handoff target (fixed at the front — send-time
@ semantics stay leading-only) followed by the selected skills, mirroring the
agent chip's look. Remove buttons recolor the x on hover (no background wash). */}
{(target !== null || selectedSkills.length > 0) && (
<div className="mb-1 flex flex-wrap items-center gap-1">
<span
className="anim-pop flex max-w-48 items-center gap-0.5 rounded-md bg-gray-100 py-0.5 pl-2 pr-1 font-mono text-sm text-gray-800 dark:bg-gray-800 dark:text-gray-200"
{...(target.name && target.name !== target.agentId ? { title: target.name } : {})}
>
<span className="truncate">@{target.agentId}</span>
<button
type="button"
aria-label={S.chat.mentionRemove}
onClick={() => {
setTarget(null);
onHandoffTargetChange?.(null);
textareaRef.current?.focus();
}}
className="shrink-0 rounded p-0.5 text-gray-400 transition-colors duration-150 hover:bg-gray-200 hover:text-gray-700 dark:hover:bg-gray-700 dark:hover:text-gray-200"
{target !== null && (
<span
className="anim-pop flex max-w-48 items-center gap-0.5 rounded-md bg-gray-100 py-0.5 pl-2 pr-1 font-mono text-sm text-gray-800 dark:bg-gray-800 dark:text-gray-200"
{...(target.name && target.name !== target.agentId ? { title: target.name } : {})}
>
×
</button>
</span>
<span className="truncate">@{target.agentId}</span>
<button
type="button"
aria-label={S.chat.mentionRemove}
onClick={() => {
setTarget(null);
onHandoffTargetChange?.(null);
textareaRef.current?.focus();
}}
className="shrink-0 rounded p-0.5 text-gray-400 transition-colors duration-150 hover:text-gray-700 dark:hover:text-gray-200"
>
×
</button>
</span>
)}
{selectedSkills.map((name) => {
const meta = skills.find((sk) => sk.name === name);
return (
<span
key={name}
className="anim-pop flex max-w-48 items-center gap-1 rounded-md bg-gray-100 py-0.5 pl-2 pr-1 font-mono text-sm text-gray-800 dark:bg-gray-800 dark:text-gray-200"
{...(meta ? { title: localizedShortText(locale, meta) } : {})}
>
<SkillIcon
icon={meta?.icon}
size={13}
className="shrink-0 text-gray-500 dark:text-gray-400"
/>
<span className="truncate">{name}</span>
<button
type="button"
aria-label={`${S.chat.skillRemove} ${name}`}
onClick={() => toggleSkill(name)}
className="shrink-0 rounded p-0.5 text-gray-400 transition-colors duration-150 hover:text-gray-700 dark:hover:text-gray-200"
>
×
</button>
</span>
);
})}
</div>
)}
@@ -1039,9 +1126,14 @@ export function ChatInput({
setCaret(caretNow);
setSlashIndex(0);
setMentionIndex(0);
// Closing via Escape only persists for "the same @ mention": continuing to type
// within that mention won't reopen the menu; it re-opens once the cursor is no
// longer on that mention (deleted, moved away, or replaced by a new one).
// Closing via Escape only persists for "the same token": continuing to type within
// that slash command / mention won't reopen the menu; it re-opens once the cursor is
// no longer on that token (deleted, moved away, or replaced by a new one).
setSlashDismissed((d) => {
if (d === null) return null;
const m = matchSlash(value, caretNow);
return m && m.start === d ? d : null;
});
setMentionDismissed((d) => {
if (d === null) return null;
const m = matchMention(value, caretNow);
+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>