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:
@@ -12,7 +12,9 @@
|
||||
* logo + name display;
|
||||
* `/` opens the slash command menu (`/compact` compresses context, replacing the button; each
|
||||
* installed skill gets its own entry; pressing Enter on `/<skill_name>` toggles that skill's
|
||||
* selection and clears the input, without sending);
|
||||
* selection without sending). Matching is positional like `@`: a slash opens the menu from any
|
||||
* caret position, running a command removes just that token, and Escape only dismisses the menu —
|
||||
* the rest of the draft is never touched;
|
||||
* `@` opens the agent selection menu; once picked it becomes a fixed highlighted target chip
|
||||
* above the text body (only one allowed, picking again replaces it; removed via backspace or the
|
||||
* x button); only a leading `@` at the start of the text counts — typing or pasting text starting
|
||||
@@ -54,8 +56,9 @@ import { GlyphIcon } from "../../components/ui/glyph-icon";
|
||||
import { SkillIcon } from "../skills/skill-icon-view";
|
||||
import { ZoomableImage } from "../../components/ui/image-zoom";
|
||||
import { ProviderLogo } from "../../components/ui/provider-logo";
|
||||
import { matchesQuery, sameModelRef } from "../models/model-grouping";
|
||||
import { matchesQuery, orderModelsLikeLibrary, sameModelRef } from "../models/model-grouping";
|
||||
import { filterAgents, matchMention, splitLeadingMention } from "./agent-mentions";
|
||||
import { matchSlash, removeSlashToken } from "./slash-token";
|
||||
import {
|
||||
BOOK_ICON,
|
||||
buildSkillsMessage,
|
||||
@@ -231,7 +234,9 @@ function ModelSelect({
|
||||
const current = models.find((m) => sameModelRef(m, value));
|
||||
// Display rule matches the model page's card: display name, or falls back to the upstream id (grouping is already conveyed by the provider logo).
|
||||
const label = current ? modelLabel(current) : (value?.modelId ?? "…");
|
||||
const filtered = models.filter((m) => matchesQuery(m, query));
|
||||
// Dropdown order mirrors the model library page: provider groups in MODEL_PROVIDERS order
|
||||
// (user-defined groups after, custom last), in-group order preserved.
|
||||
const filtered = orderModelsLikeLibrary(models).filter((m) => matchesQuery(m, query));
|
||||
return (
|
||||
<Dropdown
|
||||
open={open}
|
||||
@@ -353,9 +358,11 @@ function SkillSelect({
|
||||
open={open}
|
||||
setOpen={setOpen}
|
||||
menuClass={
|
||||
// As wide as reasonably possible so descriptions stay readable; the viewport clamp
|
||||
// keeps it inside phone screens.
|
||||
direction === "down"
|
||||
? "left-0 top-full mt-1 w-72 max-w-[calc(100vw-2rem)] origin-top-left"
|
||||
: "bottom-full left-0 mb-1 w-72 max-w-[calc(100vw-2rem)] origin-bottom-left"
|
||||
? "left-0 top-full mt-1 w-[26rem] max-w-[calc(100vw-2rem)] origin-top-left"
|
||||
: "bottom-full left-0 mb-1 w-[26rem] max-w-[calc(100vw-2rem)] origin-bottom-left"
|
||||
}
|
||||
button={
|
||||
<button
|
||||
@@ -373,7 +380,7 @@ function SkillSelect({
|
||||
<GlyphIcon d={BOOK_ICON} size={14} className="shrink-0" />
|
||||
{/* When the card is narrower than @md, only the icon + badge remain (title shows the full name). */}
|
||||
<span className="hidden min-w-0 truncate @md:block">{S.chat.skillsSelect}</span>
|
||||
{/* Selected-count badge: chips no longer exist, so this is the only visible rendering of the selected state. */}
|
||||
{/* Selected-count badge (the chip row above the input mirrors the selection too). */}
|
||||
{selected.length > 0 && (
|
||||
<span className="shrink-0 rounded-full bg-gray-200/80 px-1.5 py-px font-mono text-[10px] font-semibold text-gray-700 dark:bg-gray-700/60 dark:text-gray-200">
|
||||
{selected.length}
|
||||
@@ -616,9 +623,17 @@ export function ChatInput({
|
||||
}) {
|
||||
const { locale } = useLocale();
|
||||
const [text, setText] = useState(initialText ?? "");
|
||||
/** Live text mirror for slash-command run() closures (the commands memo deliberately doesn't depend on text). */
|
||||
const textRef = useRef(text);
|
||||
textRef.current = text;
|
||||
const [images, setImages] = useState<string[]>([]);
|
||||
const [busy, setBusy] = useState(false);
|
||||
const [slashIndex, setSlashIndex] = useState(0);
|
||||
// Slash token start where Escape closed the menu (mirrors mentionDismissed: the menu stays shut for that one token).
|
||||
const [slashDismissed, setSlashDismissed] = useState<number | null>(null);
|
||||
// Anchor for the popups that open upward, and the room actually available above them.
|
||||
const anchorRef = useRef<HTMLDivElement>(null);
|
||||
const [upwardMaxH, setUpwardMaxH] = useState<number>();
|
||||
// @ handoff target (chip, fixed at the front of the input); only one allowed, picking again replaces it directly.
|
||||
const [target, setTarget] = useState<AgentSummary | null>(null);
|
||||
// Selected skills (dropdown checklist, multi-select): initial value comes from draft restore (quick-invoke pre-selection), cleared on successful send.
|
||||
@@ -654,11 +669,15 @@ export function ChatInput({
|
||||
[selectedSkills, onSkillsChange],
|
||||
);
|
||||
|
||||
/** The slash token currently under the caret (kept in a ref so command run() closures always remove the live token). */
|
||||
const slashMatchRef = useRef<ReturnType<typeof matchSlash>>(null);
|
||||
const commands = useMemo<SlashCommand[]>(() => {
|
||||
/** Clears the input after a slash command runs (setText is async: wait for the DOM value to update before measuring height). */
|
||||
/** Removes just the slash token after a command runs (the rest of the text stays; setText is async: wait for the DOM value to update before measuring height). */
|
||||
const clearInput = () => {
|
||||
setText("");
|
||||
onTextChange?.("");
|
||||
const match = slashMatchRef.current;
|
||||
const next = match ? removeSlashToken(textRef.current, match) : "";
|
||||
setText(next);
|
||||
onTextChange?.(next);
|
||||
requestAnimationFrame(autoGrow);
|
||||
};
|
||||
return [
|
||||
@@ -681,10 +700,14 @@ export function ChatInput({
|
||||
})),
|
||||
];
|
||||
}, [onCompact, onTextChange, skills, locale, toggleSkill]);
|
||||
const trimmed = text.trim();
|
||||
// Positional matching (like @ mentions): a slash opens the menu from any caret position;
|
||||
// running a command removes just the token, leaving the rest of the text intact. Doesn't
|
||||
// reopen after Escape until the caret sits on a different token.
|
||||
const slashTok = !running && !compacting ? matchSlash(text, caret) : null;
|
||||
slashMatchRef.current = slashTok;
|
||||
const slashMatches =
|
||||
text.trimStart().startsWith("/") && !running && !compacting
|
||||
? commands.filter((c) => c.cmd.startsWith(trimmed) || trimmed === "/")
|
||||
slashTok && slashTok.start !== slashDismissed
|
||||
? commands.filter((c) => c.cmd.startsWith(`/${slashTok.query}`))
|
||||
: [];
|
||||
const slashOpen = slashMatches.length > 0;
|
||||
const activeSlash = slashMatches[Math.min(slashIndex, slashMatches.length - 1)];
|
||||
@@ -696,6 +719,31 @@ export function ChatInput({
|
||||
const mentionOpen = mentionMatches.length > 0;
|
||||
const activeMention = mentionMatches[Math.min(mentionIndex, mentionMatches.length - 1)];
|
||||
|
||||
// Both menus above are drawn upward (`bottom-full`) from the composer, so their ceiling is
|
||||
// whatever ancestor clips overflow — on the draft page that's the centered scroll area, whose
|
||||
// top edge sits well below the viewport's. A static `40vh` cap can't know that distance and
|
||||
// clipped the first rows on shorter windows, so measure the real gap when a menu opens.
|
||||
useEffect(() => {
|
||||
if (!slashOpen && !mentionOpen) return;
|
||||
const measure = () => {
|
||||
const el = anchorRef.current;
|
||||
if (!el) return;
|
||||
let ceiling = 0;
|
||||
for (let p = el.parentElement; p; p = p.parentElement) {
|
||||
if (getComputedStyle(p).overflowY !== "visible") {
|
||||
ceiling = p.getBoundingClientRect().top;
|
||||
break;
|
||||
}
|
||||
}
|
||||
// Less the menu's own 6px offset from the composer, plus a little breathing room.
|
||||
const room = el.getBoundingClientRect().top - ceiling - 14;
|
||||
setUpwardMaxH(Math.max(96, Math.min(320, Math.round(room))));
|
||||
};
|
||||
measure();
|
||||
window.addEventListener("resize", measure);
|
||||
return () => window.removeEventListener("resize", measure);
|
||||
}, [slashOpen, mentionOpen]);
|
||||
|
||||
/** Auto-grow the textarea (caps at roughly 6 lines, scrolls internally beyond that). */
|
||||
const autoGrow = () => {
|
||||
const el = textareaRef.current;
|
||||
@@ -829,10 +877,10 @@ export function ChatInput({
|
||||
return;
|
||||
}
|
||||
if (e.key === "Escape") {
|
||||
setText("");
|
||||
onTextChange?.("");
|
||||
// setText is async: wait for the DOM value to update before measuring height (measuring synchronously would read the pre-clear content).
|
||||
requestAnimationFrame(autoGrow);
|
||||
// Only closes the popup, doesn't clear the input: with positional matching the `/token`
|
||||
// is part of the text body like any other word, and wiping a controlled textarea is not
|
||||
// undoable with Ctrl+Z. Reopens if the user keeps typing on another token.
|
||||
setSlashDismissed(slashTok?.start ?? null);
|
||||
return;
|
||||
}
|
||||
}
|
||||
@@ -853,7 +901,7 @@ export function ChatInput({
|
||||
return;
|
||||
}
|
||||
if (e.key === "Escape") {
|
||||
// Only closes the popup, doesn't clear the input (unlike slash: the @ prefix is part of the text body), reopens if the user keeps typing.
|
||||
// Only closes the popup, doesn't clear the input (the `@token` is part of the text body), reopens if the user keeps typing.
|
||||
setMentionDismissed(mention?.start ?? null);
|
||||
return;
|
||||
}
|
||||
@@ -909,14 +957,21 @@ export function ChatInput({
|
||||
};
|
||||
|
||||
return (
|
||||
<div className="relative">
|
||||
{/* Slash command menu (triggered by typing /; /compact plus one entry per installed skill) */}
|
||||
<div className="relative" ref={anchorRef}>
|
||||
{/* Slash command menu (triggered by typing /; /compact plus one entry per installed skill).
|
||||
Height is capped to the room measured above the composer (see upwardMaxH) with internal
|
||||
scrolling, so a long skill list never pushes the menu's top edge out of view; the active
|
||||
row keeps itself scrolled into view. */}
|
||||
{slashOpen && (
|
||||
<div className="anim-pop absolute bottom-full left-0 z-40 mb-1.5 w-80 max-w-[calc(100vw-2rem)] rounded-md border border-gray-200 bg-white py-1 shadow-lg dark:border-gray-700 dark:bg-gray-900">
|
||||
<div
|
||||
style={{ maxHeight: upwardMaxH }}
|
||||
className="anim-pop absolute bottom-full left-0 z-40 mb-1.5 w-80 max-w-[calc(100vw-2rem)] overflow-y-auto overscroll-contain rounded-md border border-gray-200 bg-white py-1 shadow-lg dark:border-gray-700 dark:bg-gray-900"
|
||||
>
|
||||
{slashMatches.map((c, i) => (
|
||||
<button
|
||||
key={c.cmd}
|
||||
type="button"
|
||||
ref={c === activeSlash ? (el) => el?.scrollIntoView({ block: "nearest" }) : undefined}
|
||||
onMouseEnter={() => setSlashIndex(i)}
|
||||
onClick={() => c.run()}
|
||||
className={`flex w-full items-center gap-2 px-3 py-1.5 text-left text-sm ${
|
||||
@@ -938,7 +993,10 @@ export function ChatInput({
|
||||
|
||||
{/* @ subagent menu (triggered by typing @; interaction matches the slash menu) */}
|
||||
{mentionOpen && (
|
||||
<div className="anim-pop absolute bottom-full left-0 z-40 mb-1.5 max-h-64 w-72 overflow-y-auto rounded-md border border-gray-200 bg-white py-1 shadow-lg dark:border-gray-700 dark:bg-gray-900">
|
||||
<div
|
||||
style={{ maxHeight: Math.min(256, upwardMaxH ?? 256) }}
|
||||
className="anim-pop absolute bottom-full left-0 z-40 mb-1.5 w-72 overflow-y-auto rounded-md border border-gray-200 bg-white py-1 shadow-lg dark:border-gray-700 dark:bg-gray-900"
|
||||
>
|
||||
{mentionMatches.map((a, i) => (
|
||||
<button
|
||||
key={a.agentId}
|
||||
@@ -1001,27 +1059,56 @@ export function ChatInput({
|
||||
card's width changes with the viewport and the Files panel squeezing it, viewport
|
||||
breakpoints wouldn't judge it accurately. */}
|
||||
<div className="@container rounded-lg border border-gray-300 bg-white px-2.5 pb-2 pt-2 transition-[border-color,box-shadow] duration-200 focus-within:border-gray-500 focus-within:ring-2 focus-within:ring-gray-400/30 dark:border-gray-700 dark:bg-gray-900 dark:focus-within:border-gray-400">
|
||||
{/* @ handoff target chip: above the text body (background highlight to distinguish it from plain text), removed via the x button or backspace at the start of the text. */}
|
||||
{target !== null && (
|
||||
{/* Chip row above the text body: the @ handoff target (fixed at the front — send-time
|
||||
@ semantics stay leading-only) followed by the selected skills, mirroring the
|
||||
agent chip's look. Remove buttons recolor the x on hover (no background wash). */}
|
||||
{(target !== null || selectedSkills.length > 0) && (
|
||||
<div className="mb-1 flex flex-wrap items-center gap-1">
|
||||
<span
|
||||
className="anim-pop flex max-w-48 items-center gap-0.5 rounded-md bg-gray-100 py-0.5 pl-2 pr-1 font-mono text-sm text-gray-800 dark:bg-gray-800 dark:text-gray-200"
|
||||
{...(target.name && target.name !== target.agentId ? { title: target.name } : {})}
|
||||
>
|
||||
<span className="truncate">@{target.agentId}</span>
|
||||
<button
|
||||
type="button"
|
||||
aria-label={S.chat.mentionRemove}
|
||||
onClick={() => {
|
||||
setTarget(null);
|
||||
onHandoffTargetChange?.(null);
|
||||
textareaRef.current?.focus();
|
||||
}}
|
||||
className="shrink-0 rounded p-0.5 text-gray-400 transition-colors duration-150 hover:bg-gray-200 hover:text-gray-700 dark:hover:bg-gray-700 dark:hover:text-gray-200"
|
||||
{target !== null && (
|
||||
<span
|
||||
className="anim-pop flex max-w-48 items-center gap-0.5 rounded-md bg-gray-100 py-0.5 pl-2 pr-1 font-mono text-sm text-gray-800 dark:bg-gray-800 dark:text-gray-200"
|
||||
{...(target.name && target.name !== target.agentId ? { title: target.name } : {})}
|
||||
>
|
||||
×
|
||||
</button>
|
||||
</span>
|
||||
<span className="truncate">@{target.agentId}</span>
|
||||
<button
|
||||
type="button"
|
||||
aria-label={S.chat.mentionRemove}
|
||||
onClick={() => {
|
||||
setTarget(null);
|
||||
onHandoffTargetChange?.(null);
|
||||
textareaRef.current?.focus();
|
||||
}}
|
||||
className="shrink-0 rounded p-0.5 text-gray-400 transition-colors duration-150 hover:text-gray-700 dark:hover:text-gray-200"
|
||||
>
|
||||
×
|
||||
</button>
|
||||
</span>
|
||||
)}
|
||||
{selectedSkills.map((name) => {
|
||||
const meta = skills.find((sk) => sk.name === name);
|
||||
return (
|
||||
<span
|
||||
key={name}
|
||||
className="anim-pop flex max-w-48 items-center gap-1 rounded-md bg-gray-100 py-0.5 pl-2 pr-1 font-mono text-sm text-gray-800 dark:bg-gray-800 dark:text-gray-200"
|
||||
{...(meta ? { title: localizedShortText(locale, meta) } : {})}
|
||||
>
|
||||
<SkillIcon
|
||||
icon={meta?.icon}
|
||||
size={13}
|
||||
className="shrink-0 text-gray-500 dark:text-gray-400"
|
||||
/>
|
||||
<span className="truncate">{name}</span>
|
||||
<button
|
||||
type="button"
|
||||
aria-label={`${S.chat.skillRemove} ${name}`}
|
||||
onClick={() => toggleSkill(name)}
|
||||
className="shrink-0 rounded p-0.5 text-gray-400 transition-colors duration-150 hover:text-gray-700 dark:hover:text-gray-200"
|
||||
>
|
||||
×
|
||||
</button>
|
||||
</span>
|
||||
);
|
||||
})}
|
||||
</div>
|
||||
)}
|
||||
|
||||
@@ -1039,9 +1126,14 @@ export function ChatInput({
|
||||
setCaret(caretNow);
|
||||
setSlashIndex(0);
|
||||
setMentionIndex(0);
|
||||
// Closing via Escape only persists for "the same @ mention": continuing to type
|
||||
// within that mention won't reopen the menu; it re-opens once the cursor is no
|
||||
// longer on that mention (deleted, moved away, or replaced by a new one).
|
||||
// Closing via Escape only persists for "the same token": continuing to type within
|
||||
// that slash command / mention won't reopen the menu; it re-opens once the cursor is
|
||||
// no longer on that token (deleted, moved away, or replaced by a new one).
|
||||
setSlashDismissed((d) => {
|
||||
if (d === null) return null;
|
||||
const m = matchSlash(value, caretNow);
|
||||
return m && m.start === d ? d : null;
|
||||
});
|
||||
setMentionDismissed((d) => {
|
||||
if (d === null) return null;
|
||||
const m = matchMention(value, caretNow);
|
||||
|
||||
@@ -45,6 +45,7 @@ import { Dropdown } from "../../components/ui/dropdown";
|
||||
import { PenguinLogo } from "../../components/ui/penguin-logo";
|
||||
import { toastError } from "../../components/ui/toast";
|
||||
import { ChatInput } from "./chat-input";
|
||||
import { buildSkillsMessage } from "./skill-use";
|
||||
import { clearDraft, draftKey, loadDraft, saveDraft } from "./draft-cache";
|
||||
import type { DraftCache } from "./draft-cache";
|
||||
import { handoffMessage } from "./agent-mentions";
|
||||
@@ -53,6 +54,18 @@ import { sameModelRef } from "../models/model-grouping";
|
||||
/** Coalescing window for writing body text to the cache: keystrokes are frequent, so a short batch accumulates before persisting (option changes are still written immediately). */
|
||||
const DRAFT_SAVE_DEBOUNCE_MS = 300;
|
||||
|
||||
/**
|
||||
* Example tasks on the draft screen, in display order (game card first, LoL music player,
|
||||
* then the RAG build). Copy lives in S.chat.exampleTasks[id]; skills are pinned via a
|
||||
* `<use_skills>` block — only those the selected Agent actually has installed are included,
|
||||
* so the block never references a skill the agent can't read.
|
||||
*/
|
||||
const EXAMPLE_TASKS: { id: "game" | "lol" | "rag"; skills: string[] }[] = [
|
||||
{ id: "game", skills: ["web-design"] },
|
||||
{ id: "lol", skills: ["web-design"] },
|
||||
{ id: "rag", skills: ["penguin-sdk", "web-design"] },
|
||||
];
|
||||
|
||||
export function DraftView({
|
||||
projectId,
|
||||
models,
|
||||
@@ -146,8 +159,11 @@ export function DraftView({
|
||||
// mount render would trigger ChatInput's pruning effect and wrongly clear the
|
||||
// quick-invoke preselection.
|
||||
const [agentSkills, setAgentSkills] = useState<SkillMetadataItem[]>([]);
|
||||
/** Whether the skills fetch for the current Agent has settled — the example task waits for it so its `<use_skills>` pinning doesn't silently depend on network timing. */
|
||||
const [skillsLoaded, setSkillsLoaded] = useState(false);
|
||||
useEffect(() => {
|
||||
setAgentSkills((prev) => (prev.length > 0 ? [] : prev));
|
||||
setSkillsLoaded(false);
|
||||
if (!agentId) return;
|
||||
let cancelled = false;
|
||||
api
|
||||
@@ -155,7 +171,10 @@ export function DraftView({
|
||||
.then((res) => {
|
||||
if (!cancelled) setAgentSkills(res.skills);
|
||||
})
|
||||
.catch(() => undefined);
|
||||
.catch(() => undefined)
|
||||
.finally(() => {
|
||||
if (!cancelled) setSkillsLoaded(true);
|
||||
});
|
||||
return () => {
|
||||
cancelled = true;
|
||||
};
|
||||
@@ -244,12 +263,23 @@ export function DraftView({
|
||||
setCurrentAgentId(a.agentId);
|
||||
};
|
||||
|
||||
// One in-flight guard shared by every send entry point (composer send / example task /
|
||||
// @-handoff): a second submission while one is running would create a second Session with
|
||||
// its own first task and a racing navigation. The ref is the synchronous guard; the state
|
||||
// drives disabled styling on the example button (the composer has its own busy state).
|
||||
const sendingRef = useRef(false);
|
||||
const [sending, setSending] = useState(false);
|
||||
|
||||
// First message sent: only now is the Session created (Agent / Workspace / Model / approval
|
||||
// mode are all locked in together), then the route jumps once sent; returns false on any
|
||||
// failure, so the input area keeps the draft and can resend.
|
||||
// failure, so the input area keeps the draft and can resend. `keepDraft` is set by sends
|
||||
// that did not consume the composer text (the example task), so a typed-but-unsent draft
|
||||
// survives the navigation instead of being silently discarded.
|
||||
const onSend = useCallback(
|
||||
async (input: TaskInputPart[]): Promise<boolean> => {
|
||||
if (!agentId) return false;
|
||||
async (input: TaskInputPart[], keepDraft = false): Promise<boolean> => {
|
||||
if (!agentId || sendingRef.current) return false;
|
||||
sendingRef.current = true;
|
||||
setSending(true);
|
||||
let createdId: string | null = null;
|
||||
try {
|
||||
const body: SessionCreateRequest = { approvalMode };
|
||||
@@ -263,7 +293,7 @@ export function DraftView({
|
||||
createdId = created.session.sessionId;
|
||||
const res = await api.postTask(createdId, { input });
|
||||
add(created.session);
|
||||
discardDraft();
|
||||
if (!keepDraft) discardDraft();
|
||||
navigate(`/chat/${res.sessionId}`, { replace: true });
|
||||
return true;
|
||||
} catch (e) {
|
||||
@@ -273,18 +303,45 @@ export function DraftView({
|
||||
if (createdId) void api.deleteSession(createdId).catch(() => undefined);
|
||||
toastError(apiErrorText(e, modelRef ? { modelId: modelRef.modelId } : {}));
|
||||
return false;
|
||||
} finally {
|
||||
sendingRef.current = false;
|
||||
setSending(false);
|
||||
}
|
||||
},
|
||||
[projectId, agentId, approvalMode, modelRef, workspace, add, discardDraft, navigate],
|
||||
);
|
||||
|
||||
// Example tasks: one click submits the canned prompt exactly like a hand-typed send (the
|
||||
// busy id drives the clicked card's spinner; the shared in-flight guard and all failure
|
||||
// handling live in onSend). keepDraft: an example never consumes the composer text, so a
|
||||
// typed-but-unsent draft must survive. The selected model / Workspace / approval mode apply as-is.
|
||||
const [exampleBusy, setExampleBusy] = useState<"game" | "lol" | "rag" | null>(null);
|
||||
const runExample = useCallback(
|
||||
async (task: (typeof EXAMPLE_TASKS)[number]) => {
|
||||
if (exampleBusy !== null) return;
|
||||
setExampleBusy(task.id);
|
||||
try {
|
||||
const names = task.skills.filter((n) => agentSkills.some((s) => s.name === n));
|
||||
await onSend(
|
||||
[{ type: "text", text: buildSkillsMessage(names, S.chat.exampleTasks[task.id].prompt) }],
|
||||
true,
|
||||
);
|
||||
} finally {
|
||||
setExampleBusy(null);
|
||||
}
|
||||
},
|
||||
[exampleBusy, agentSkills, onSend],
|
||||
);
|
||||
|
||||
// @ handoff: opens a new chat for the @-mentioned agent (approval mode carries over from the
|
||||
// draft's current value; model/Workspace use the creation defaults), first input =
|
||||
// <handoff_from> source block + the text and images with the @ mention stripped.
|
||||
const selectedAgent = agents.find((a) => a.agentId === agentId) ?? null;
|
||||
const onHandoff = useCallback(
|
||||
async (target: AgentSummary, input: TaskInputPart[]): Promise<boolean> => {
|
||||
if (!selectedAgent) return false;
|
||||
if (!selectedAgent || sendingRef.current) return false;
|
||||
sendingRef.current = true;
|
||||
setSending(true);
|
||||
const origin: TaskInputPart = {
|
||||
type: "text",
|
||||
text: handoffMessage({
|
||||
@@ -308,6 +365,9 @@ export function DraftView({
|
||||
apiErrorText(e, models?.defaultModel ? { modelId: models.defaultModel.modelId } : {}),
|
||||
);
|
||||
return false;
|
||||
} finally {
|
||||
sendingRef.current = false;
|
||||
setSending(false);
|
||||
}
|
||||
},
|
||||
[projectId, selectedAgent, approvalMode, add, discardDraft, navigate, models],
|
||||
@@ -319,15 +379,21 @@ export function DraftView({
|
||||
const vision = modelInfo?.vision !== false;
|
||||
|
||||
return (
|
||||
<div className="anim-fade flex min-h-0 flex-1 flex-col overflow-y-auto px-3 md:px-4">
|
||||
<div className="anim-fade flex min-h-0 flex-1 flex-col overflow-y-auto px-3 py-6 md:px-4">
|
||||
{/*
|
||||
* Vertical layout: two symmetric flex-1 spaces above and below, so **the input card lands
|
||||
* exactly on the viewport's centerline**; the brand area sits at the bottom of the upper
|
||||
* space (right above the input card). When the viewport is too short, the upper space
|
||||
* shrinks to the brand area's height, the lower space compresses to padding, and the whole
|
||||
* page falls back to natural document-flow scrolling (without clipping the top).
|
||||
* Vertical layout: everything visible — brand, input card, ownership pills, example tasks —
|
||||
* lives in ONE block between two empty flex-1 spacers, so the block is centred and the free
|
||||
* space above and below it is exactly equal. The brand deliberately sits inside that block
|
||||
* rather than in the upper spacer: keeping it in the spacer made the upper gap shorter than
|
||||
* the lower one by the brand's own height, which pushed the card up the viewport and left
|
||||
* the slash menu — it opens upward, `bottom-full` — too little room, so it clipped against
|
||||
* the top of this scroll container. When the viewport is too short the spacers collapse to
|
||||
* nothing, the container's own py-6 keeps the content off the edges, and the page falls back
|
||||
* to natural scrolling.
|
||||
*/}
|
||||
<div className="flex flex-1 flex-col justify-end pt-6">
|
||||
<div className="flex-1" />
|
||||
|
||||
<div className="mx-auto w-full max-w-3xl">
|
||||
{/* Large brand logo + brand name + subtitle (e2e tests identify the draft page by this
|
||||
heading). The asset is square-cropped and the graphic already has a bit of built-in
|
||||
padding, so a small margin is enough to sit visually close to the title. */}
|
||||
@@ -338,9 +404,7 @@ export function DraftView({
|
||||
</h1>
|
||||
<p className="mt-2 text-base text-gray-400 dark:text-gray-500">{S.chat.draftSubtitle}</p>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="mx-auto w-full max-w-3xl">
|
||||
<ChatInput
|
||||
status="idle"
|
||||
onSend={onSend}
|
||||
@@ -373,10 +437,121 @@ export function DraftView({
|
||||
<AgentSelect agents={agents} selected={selectedAgent} onSelect={selectAgent} />
|
||||
<WorkspaceSelect projectId={projectId} workspace={workspace} onChange={setWorkspace} />
|
||||
</div>
|
||||
|
||||
{/* Example tasks: one-click canned builds showing off the one-sentence → app flow,
|
||||
stacked vertically in display order on every viewport. Disabled until
|
||||
agents/models/skills are resolved (onSend would silently no-op without an Agent);
|
||||
hover only darkens the border, per the card convention. */}
|
||||
<div className="mt-6 flex flex-col items-stretch gap-2">
|
||||
{EXAMPLE_TASKS.map((task) => {
|
||||
const copy = S.chat.exampleTasks[task.id];
|
||||
return (
|
||||
<button
|
||||
key={task.id}
|
||||
type="button"
|
||||
disabled={exampleBusy !== null || sending || !skillsLoaded || !agentId || !models}
|
||||
onClick={() => void runExample(task)}
|
||||
className="group flex min-w-0 items-center gap-3 rounded-xl border border-gray-200 bg-white px-4 py-3 text-left transition-colors duration-150 hover:border-gray-300 disabled:cursor-default disabled:opacity-60 dark:border-gray-800 dark:bg-gray-900 dark:hover:border-gray-700"
|
||||
>
|
||||
{/* 24×24 line icons (gamepad / music note / sparkle), consistent with the icon convention */}
|
||||
{task.id === "lol" ? (
|
||||
<svg
|
||||
width="20"
|
||||
height="20"
|
||||
viewBox="0 0 24 24"
|
||||
fill="none"
|
||||
stroke="currentColor"
|
||||
className="shrink-0 text-brand-500 dark:text-brand-400"
|
||||
aria-hidden
|
||||
>
|
||||
<path
|
||||
d="M9 18V6l11-2v12"
|
||||
strokeWidth="1.7"
|
||||
strokeLinecap="round"
|
||||
strokeLinejoin="round"
|
||||
/>
|
||||
<circle cx="6.5" cy="18" r="2.5" strokeWidth="1.7" />
|
||||
<circle cx="17.5" cy="16" r="2.5" strokeWidth="1.7" />
|
||||
</svg>
|
||||
) : task.id === "game" ? (
|
||||
<svg
|
||||
width="20"
|
||||
height="20"
|
||||
viewBox="0 0 24 24"
|
||||
fill="none"
|
||||
stroke="currentColor"
|
||||
className="shrink-0 text-brand-500 dark:text-brand-400"
|
||||
aria-hidden
|
||||
>
|
||||
<path
|
||||
d="M6.7 6h10.6a4 4 0 0 1 3.97 3.56c.2 1.8.73 5.05.73 6.44a3 3 0 0 1-3 3c-1 0-1.5-.5-2-1l-1.4-1.4a2 2 0 0 0-1.42-.6H9.82a2 2 0 0 0-1.41.6L7 18c-.5.5-1 1-2 1a3 3 0 0 1-3-3c0-1.39.52-4.64.73-6.44A4 4 0 0 1 6.7 6z"
|
||||
strokeWidth="1.7"
|
||||
strokeLinejoin="round"
|
||||
/>
|
||||
<path
|
||||
d="M6.5 11h4M8.5 9v4M15 12h.01M18 10h.01"
|
||||
strokeWidth="1.7"
|
||||
strokeLinecap="round"
|
||||
/>
|
||||
</svg>
|
||||
) : (
|
||||
<svg
|
||||
width="20"
|
||||
height="20"
|
||||
viewBox="0 0 24 24"
|
||||
fill="none"
|
||||
stroke="currentColor"
|
||||
className="shrink-0 text-brand-500 dark:text-brand-400"
|
||||
aria-hidden
|
||||
>
|
||||
<path
|
||||
d="M12 3l1.9 5.1L19 10l-5.1 1.9L12 17l-1.9-5.1L5 10l5.1-1.9L12 3z"
|
||||
strokeWidth="1.7"
|
||||
strokeLinejoin="round"
|
||||
/>
|
||||
<path
|
||||
d="M18.5 15.5l.9 2.1 2.1.9-2.1.9-.9 2.1-.9-2.1-2.1-.9 2.1-.9.9-2.1z"
|
||||
strokeWidth="1.4"
|
||||
strokeLinejoin="round"
|
||||
/>
|
||||
</svg>
|
||||
)}
|
||||
<span className="min-w-0 flex-1">
|
||||
<span className="block truncate text-sm font-medium text-gray-900 dark:text-gray-100">
|
||||
{copy.label}
|
||||
</span>
|
||||
<span className="line-clamp-2 text-xs text-gray-500 dark:text-gray-400">
|
||||
{copy.desc}
|
||||
</span>
|
||||
</span>
|
||||
{exampleBusy === task.id ? (
|
||||
<span className="ml-1 shrink-0 text-xs text-gray-400">{S.common.loading}</span>
|
||||
) : (
|
||||
<svg
|
||||
width="16"
|
||||
height="16"
|
||||
viewBox="0 0 24 24"
|
||||
fill="none"
|
||||
stroke="currentColor"
|
||||
className="ml-1 shrink-0 text-gray-300 transition-colors duration-150 group-hover:text-gray-500 dark:text-gray-600 dark:group-hover:text-gray-400"
|
||||
aria-hidden
|
||||
>
|
||||
<path
|
||||
d="M5 12h14M13 6l6 6-6 6"
|
||||
strokeWidth="1.7"
|
||||
strokeLinecap="round"
|
||||
strokeLinejoin="round"
|
||||
/>
|
||||
</svg>
|
||||
)}
|
||||
</button>
|
||||
);
|
||||
})}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Lower symmetric space (compresses to padding at minimum height) */}
|
||||
<div className="flex-1 pb-6" />
|
||||
{/* Lower symmetric space — empty, so it matches the upper one exactly */}
|
||||
<div className="flex-1" />
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -1,12 +1,13 @@
|
||||
/**
|
||||
* Live duration (inline display on running thinking/tool cards): ticks every second. sinceMs
|
||||
* Live duration (inline display on running thinking/tool cards): ticks every second, showing
|
||||
* whole seconds only — decimals appear only on the settled value once the item finishes. sinceMs
|
||||
* comes from the server-side message timestamp and may drift from the local clock; negative
|
||||
* values are shown as 0; a pulsing ellipsis is shown when missing.
|
||||
* `offsetMs` is the already-settled duration of a prior segment (e.g. a tool call's argument
|
||||
* generation phase), added on top of the live segment as it ticks.
|
||||
*/
|
||||
import { useEffect, useState } from "react";
|
||||
import { humanizeDuration } from "../../lib/format";
|
||||
import { humanizeDurationLive } from "../../lib/format";
|
||||
|
||||
export function LiveDuration({ sinceMs, offsetMs = 0 }: { sinceMs?: number; offsetMs?: number }) {
|
||||
const [now, setNow] = useState(() => Date.now());
|
||||
@@ -15,5 +16,5 @@ export function LiveDuration({ sinceMs, offsetMs = 0 }: { sinceMs?: number; offs
|
||||
return () => clearInterval(id);
|
||||
}, []);
|
||||
if (sinceMs === undefined) return <span className="animate-pulse">…</span>;
|
||||
return <>{humanizeDuration(Math.max(0, offsetMs) + Math.max(0, now - sinceMs))}</>;
|
||||
return <>{humanizeDurationLive(Math.max(0, offsetMs) + Math.max(0, now - sinceMs))}</>;
|
||||
}
|
||||
|
||||
@@ -0,0 +1,76 @@
|
||||
/**
|
||||
* Markdown body memoized by text identity. The stream model mutates chat items in place and
|
||||
* only reassigns `text` when a delta arrives, so every settled message keeps the same string
|
||||
* instance across the per-frame version bumps — the shallow prop compare then skips its entire
|
||||
* micromark → mdast → React re-parse, and only the actively-streaming message re-renders.
|
||||
* Without this, each animation frame during a stream re-parsed the WHOLE transcript (O(n²)
|
||||
* over a long reply), which visibly froze the UI while large code blocks streamed in.
|
||||
*
|
||||
* Fenced code blocks render through CodeBlock (language chrome + copy button + Shiki
|
||||
* highlight). `streaming` disables highlighting while deltas are still arriving —
|
||||
* re-tokenizing a growing block every frame is O(n²) main-thread cost — and the settle
|
||||
* re-render (new string instance, streaming=false) highlights each block exactly once.
|
||||
* Inline code keeps the default rendering (`.md-body code` styling).
|
||||
*/
|
||||
import { isValidElement, memo } from "react";
|
||||
import type { ReactNode } from "react";
|
||||
import ReactMarkdown from "react-markdown";
|
||||
import type { Components } from "react-markdown";
|
||||
import remarkGfm from "remark-gfm";
|
||||
import { CodeBlock } from "./code-block";
|
||||
|
||||
/** Flatten a react-markdown code element's children to plain text (string or string array in practice). */
|
||||
function codeText(children: unknown): string {
|
||||
if (typeof children === "string") return children;
|
||||
if (Array.isArray(children)) return children.filter((c) => typeof c === "string").join("");
|
||||
return "";
|
||||
}
|
||||
|
||||
/** Fenced-block adapter: unwraps the <pre><code class="language-x"> pair react-markdown emits into CodeBlock. */
|
||||
function MdPre({ children, streaming }: { children?: ReactNode; streaming: boolean }) {
|
||||
if (isValidElement(children)) {
|
||||
const props = children.props as { className?: string; children?: unknown };
|
||||
const language = /language-([\w+-]+)/.exec(props.className ?? "")?.[1] ?? "";
|
||||
return (
|
||||
<CodeBlock
|
||||
language={language}
|
||||
code={codeText(props.children).replace(/\n$/, "")}
|
||||
highlight={!streaming}
|
||||
/>
|
||||
);
|
||||
}
|
||||
return <pre>{children}</pre>;
|
||||
}
|
||||
|
||||
/**
|
||||
* The two `components` maps, built once at module scope instead of inline per render.
|
||||
* react-markdown uses `components.pre` as the element **type**, so a fresh arrow each render is
|
||||
* a new type on every commit: React unmounts and remounts every code block, dropping the user's
|
||||
* text selection and resetting each block's Copy-button state — ~8 times a second while a reply
|
||||
* streams, including for blocks that closed long ago. `streaming` is the only thing the adapter
|
||||
* closes over, so one frozen map per value is enough; the single flip between them happens on
|
||||
* the settle render, which re-parses the message anyway.
|
||||
*/
|
||||
const STREAMING_COMPONENTS: Components = {
|
||||
pre: (props) => <MdPre streaming>{props.children}</MdPre>,
|
||||
};
|
||||
const SETTLED_COMPONENTS: Components = {
|
||||
pre: (props) => <MdPre streaming={false}>{props.children}</MdPre>,
|
||||
};
|
||||
|
||||
export const Md = memo(function Md({
|
||||
text,
|
||||
streaming = false,
|
||||
}: {
|
||||
text: string;
|
||||
streaming?: boolean;
|
||||
}) {
|
||||
return (
|
||||
<ReactMarkdown
|
||||
remarkPlugins={[remarkGfm]}
|
||||
components={streaming ? STREAMING_COMPONENTS : SETTLED_COMPONENTS}
|
||||
>
|
||||
{text}
|
||||
</ReactMarkdown>
|
||||
);
|
||||
});
|
||||
@@ -5,14 +5,13 @@
|
||||
* stats lines. Items have a light entrance animation.
|
||||
*/
|
||||
import { useState } from "react";
|
||||
import ReactMarkdown from "react-markdown";
|
||||
import remarkGfm from "remark-gfm";
|
||||
import { S } from "../../lib/strings";
|
||||
import { useLocale } from "../../state/locale";
|
||||
import { formatMessageTime } from "../../lib/format";
|
||||
import { STAT_ICONS } from "../../lib/stat-icons";
|
||||
import { splitImageAttachments } from "../../lib/attachments";
|
||||
import type { ChatItem } from "../../lib/omni/stream-model";
|
||||
import { Md } from "./md";
|
||||
import { GlyphIcon } from "../../components/ui/glyph-icon";
|
||||
import { ZoomableImage } from "../../components/ui/image-zoom";
|
||||
import { MessageFilesCard } from "./message-files-card";
|
||||
@@ -158,8 +157,8 @@ export function MessageItem({ item, ctx }: { item: ChatItem; ctx: StreamRenderCo
|
||||
// which is more useful than copying segment by segment.
|
||||
return (
|
||||
<div className="md-body anim-msg my-3 text-base leading-relaxed text-gray-800 dark:text-gray-100">
|
||||
{/* Re-renders the accumulated text directly while streaming (a key point of the contract implementation). */}
|
||||
<ReactMarkdown remarkPlugins={[remarkGfm]}>{item.text}</ReactMarkdown>
|
||||
{/* Re-renders the accumulated text directly while streaming (a key point of the contract implementation); memoized so settled messages skip the re-parse, and code blocks highlight once on settle (see md.tsx). */}
|
||||
<Md text={item.text} streaming={item.streaming} />
|
||||
{item.streaming && <span className="animate-pulse text-gray-400">▌</span>}
|
||||
{item.stopReason && item.stopReason !== "completed" && (
|
||||
<span className="ml-1 font-mono text-xs text-gray-400">[{item.stopReason}]</span>
|
||||
|
||||
@@ -5,7 +5,7 @@
|
||||
* StreamRenderContext threads the pending-approval map and approval callback down to tool
|
||||
* cards at any nesting depth.
|
||||
*/
|
||||
import { useEffect, useRef } from "react";
|
||||
import { useEffect, useLayoutEffect, useRef, useState } from "react";
|
||||
import type { ReactNode } from "react";
|
||||
import { S } from "../../lib/strings";
|
||||
import type { ChatItem } from "../../lib/omni/stream-model";
|
||||
@@ -144,45 +144,175 @@ export function MessageStream({
|
||||
// An upward-swipe intent immediately exits auto-follow; scrolling back near the bottom resumes it — see stream-follow.ts (#75) for the exact rule.
|
||||
const followRef = useRef<StreamFollow | null>(null);
|
||||
const follow = (followRef.current ??= createStreamFollow());
|
||||
// Back-to-bottom button visibility (React state — the follow object mutates outside React):
|
||||
// shown only when follow is off AND there is actually content below the fold. The second
|
||||
// condition matters: a wheel-up flick over a list that already fits the viewport exits follow
|
||||
// (stream-follow.ts keeps that rule deliberately) but must not surface a "jump to latest"
|
||||
// button with nothing to jump to. Synced after every event that can change either input
|
||||
// (wheel-up at the very top fires no scroll event, so syncing on scroll alone is not enough)
|
||||
// and in the version effect (content growing while unstuck fires no scroll event either).
|
||||
const [showJump, setShowJump] = useState(false);
|
||||
/**
|
||||
* Animated-return phase (back-to-bottom clicked, glide in flight). The glide is a
|
||||
* self-driven rAF loop rather than native scrollTo({behavior:"smooth"}) for two
|
||||
* live-tested reasons: (1) re-aiming a native smooth scroll on every stream commit resets
|
||||
* its easing, capping descent below a fast stream's growth rate — the return then never
|
||||
* arrives; (2) an in-flight native smooth scroll cannot be interrupted, so a wheel-up
|
||||
* cancel would keep dragging the user downward for hundreds of ms. The rAF loop chases the
|
||||
* LIVE bottom each frame with a velocity floor above any realistic growth rate, and
|
||||
* cancelling it stops the descent within a frame. While returning, the button stays hidden
|
||||
* and the stick snap must not fire (an instant snap on the next commit would kill the glide).
|
||||
*/
|
||||
const returningRef = useRef(false);
|
||||
const returnRafRef = useRef<number | null>(null);
|
||||
const cancelReturn = () => {
|
||||
if (returnRafRef.current !== null) {
|
||||
cancelAnimationFrame(returnRafRef.current);
|
||||
returnRafRef.current = null;
|
||||
}
|
||||
returningRef.current = false;
|
||||
};
|
||||
useEffect(() => () => cancelReturn(), []);
|
||||
/** Local previous scrollTop, only for detecting the user fighting the return animation upward (the follow object keeps its own). */
|
||||
const lastTopRef = useRef<number | null>(null);
|
||||
const syncJump = () => {
|
||||
const el = scrollRef.current;
|
||||
setShowJump(
|
||||
!returningRef.current &&
|
||||
!follow.stick &&
|
||||
el !== null &&
|
||||
el.scrollHeight - el.scrollTop - el.clientHeight > 1,
|
||||
);
|
||||
};
|
||||
|
||||
const onScroll = () => {
|
||||
const el = scrollRef.current;
|
||||
if (!el) return;
|
||||
const prevTop = lastTopRef.current;
|
||||
lastTopRef.current = el.scrollTop;
|
||||
// Scrollbar-drag / keyboard scroll upward during the return cancels it (wheel/touch cancel in their own handlers).
|
||||
if (returningRef.current && prevTop !== null && el.scrollTop < prevTop - 1) {
|
||||
cancelReturn();
|
||||
}
|
||||
follow.scrolled({
|
||||
scrollTop: el.scrollTop,
|
||||
scrollHeight: el.scrollHeight,
|
||||
clientHeight: el.clientHeight,
|
||||
});
|
||||
syncJump();
|
||||
};
|
||||
|
||||
useEffect(() => {
|
||||
// Layout effect (not useEffect): the stick-to-bottom snap must land before paint, otherwise
|
||||
// fast streams show the bottom edge "catching up" by the growth of each commit. Suppressed
|
||||
// during the animated return — the glide owns the scroll position until it arrives.
|
||||
useLayoutEffect(() => {
|
||||
const el = scrollRef.current;
|
||||
if (el && follow.stick) el.scrollTop = el.scrollHeight;
|
||||
if (el && follow.stick && !returningRef.current) el.scrollTop = el.scrollHeight;
|
||||
syncJump();
|
||||
// syncJump is recreated per render; the effect intentionally keys on stream growth only.
|
||||
// eslint-disable-next-line react-hooks/exhaustive-deps
|
||||
}, [version, follow]);
|
||||
|
||||
/** Back-to-bottom: glide down to the live bottom (reduced motion gets an instant jump); follow re-engages on arrival. */
|
||||
const jumpToLatest = () => {
|
||||
const el = scrollRef.current;
|
||||
if (!el) return;
|
||||
if (window.matchMedia("(prefers-reduced-motion: reduce)").matches) {
|
||||
follow.resume();
|
||||
el.scrollTop = el.scrollHeight;
|
||||
syncJump();
|
||||
return;
|
||||
}
|
||||
cancelReturn();
|
||||
returningRef.current = true;
|
||||
// Far away: teleport to three viewports above the bottom first, then glide the rest —
|
||||
// bounds the animation to well under a second regardless of how far the user scrolled.
|
||||
const far = el.scrollHeight - el.clientHeight - el.scrollTop;
|
||||
if (far > 3 * el.clientHeight) el.scrollTop = el.scrollHeight - 4 * el.clientHeight;
|
||||
let last = performance.now();
|
||||
const stepFrame = (now: number) => {
|
||||
returnRafRef.current = null;
|
||||
const live = scrollRef.current;
|
||||
if (!live || !returningRef.current) return;
|
||||
const dt = Math.min(0.05, Math.max(0.001, (now - last) / 1000));
|
||||
last = now;
|
||||
const remaining = live.scrollHeight - live.clientHeight - live.scrollTop;
|
||||
if (remaining <= 1) {
|
||||
returningRef.current = false;
|
||||
follow.resume();
|
||||
live.scrollTop = live.scrollHeight;
|
||||
syncJump();
|
||||
return;
|
||||
}
|
||||
// Proportional ease-out toward the LIVE bottom, with a time-based velocity floor
|
||||
// (3200px/s) that outruns any realistic streaming growth so the glide always lands.
|
||||
const step = Math.max(remaining * Math.min(1, dt * 10), 3200 * dt);
|
||||
live.scrollTop += Math.min(step, remaining);
|
||||
returnRafRef.current = requestAnimationFrame(stepFrame);
|
||||
};
|
||||
returnRafRef.current = requestAnimationFrame(stepFrame);
|
||||
syncJump();
|
||||
};
|
||||
|
||||
return (
|
||||
<div
|
||||
ref={scrollRef}
|
||||
onScroll={onScroll}
|
||||
onWheel={(e) => follow.wheel(e.deltaY)}
|
||||
onTouchStart={(e) => {
|
||||
const t = e.touches[0];
|
||||
if (t) follow.touchStart(t.clientY);
|
||||
}}
|
||||
onTouchMove={(e) => {
|
||||
const t = e.touches[0];
|
||||
if (t) follow.touchMove(t.clientY);
|
||||
}}
|
||||
onTouchEnd={() => follow.touchEnd()}
|
||||
className="anim-fade h-full overflow-y-auto px-4 py-4 md:px-6"
|
||||
>
|
||||
<div className="mx-auto max-w-3xl">
|
||||
{items.length === 0 ? (
|
||||
<EmptyState title={S.chat.emptyStream} />
|
||||
) : (
|
||||
<MessageItems items={items} ctx={ctx} />
|
||||
)}
|
||||
<div className="relative h-full min-h-0">
|
||||
<div
|
||||
ref={scrollRef}
|
||||
onScroll={onScroll}
|
||||
onWheel={(e) => {
|
||||
follow.wheel(e.deltaY);
|
||||
if (e.deltaY < 0) cancelReturn(); // user takes over: the glide stops within a frame
|
||||
syncJump();
|
||||
}}
|
||||
onTouchStart={(e) => {
|
||||
const t = e.touches[0];
|
||||
if (t) follow.touchStart(t.clientY);
|
||||
}}
|
||||
onTouchMove={(e) => {
|
||||
const t = e.touches[0];
|
||||
if (t) follow.touchMove(t.clientY);
|
||||
if (returningRef.current) cancelReturn(); // touching the list mid-return = taking control
|
||||
syncJump();
|
||||
}}
|
||||
onTouchEnd={() => follow.touchEnd()}
|
||||
className="anim-fade h-full overflow-y-auto px-4 py-4 md:px-6"
|
||||
>
|
||||
<div className="mx-auto max-w-3xl">
|
||||
{items.length === 0 ? (
|
||||
<EmptyState title={S.chat.emptyStream} />
|
||||
) : (
|
||||
<MessageItems items={items} ctx={ctx} />
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
{/* Back-to-bottom (shows once the user scrolls away from content below the fold): floats
|
||||
just above the composer; clicking returns to the bottom and re-enters follow, so the
|
||||
view keeps tracking the live stream. */}
|
||||
{showJump && (
|
||||
<button
|
||||
type="button"
|
||||
aria-label={S.chat.jumpToLatest}
|
||||
title={S.chat.jumpToLatest}
|
||||
onClick={jumpToLatest}
|
||||
className="anim-pop absolute bottom-3 left-1/2 z-10 -translate-x-1/2 rounded-full border border-gray-300 bg-white p-1.5 text-gray-500 shadow-sm transition-colors duration-150 hover:bg-gray-50 hover:text-gray-900 dark:border-gray-700 dark:bg-gray-900 dark:text-gray-400 dark:hover:bg-gray-800 dark:hover:text-gray-100"
|
||||
>
|
||||
<svg
|
||||
width="16"
|
||||
height="16"
|
||||
viewBox="0 0 24 24"
|
||||
fill="none"
|
||||
stroke="currentColor"
|
||||
aria-hidden
|
||||
>
|
||||
<path
|
||||
d="M12 5v14M6 13l6 6 6-6"
|
||||
strokeWidth="1.7"
|
||||
strokeLinecap="round"
|
||||
strokeLinejoin="round"
|
||||
/>
|
||||
</svg>
|
||||
</button>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -0,0 +1,42 @@
|
||||
/**
|
||||
* Positional slash-command matching for the chat input (pure logic, unit-tested):
|
||||
* like @ mentions, a `/` opens the command menu from ANY caret position — it must sit at
|
||||
* the start of the text or be preceded by whitespace (so URLs and paths like `a/b` never
|
||||
* trigger it), with only command characters between the `/` and the caret. Running a
|
||||
* command removes just the `start..end` token, leaving the rest of the text intact.
|
||||
*/
|
||||
|
||||
/** Command characters allowed between `/` and the caret (command names and skill names: letters, digits, underscore, hyphen). */
|
||||
const CMD_PREFIX = /^[\w-]*$/;
|
||||
|
||||
/** The slash token currently being typed: `start` is the index of `/`, `query` is the text between `/` and the caret (no leading slash), `end` extends over the same token to the right of the caret. */
|
||||
export interface SlashMatch {
|
||||
start: number;
|
||||
end: number;
|
||||
query: string;
|
||||
}
|
||||
|
||||
/** Finds the slash command currently being typed at the caret; returns null if none. */
|
||||
export function matchSlash(text: string, caret: number): SlashMatch | null {
|
||||
const before = text.slice(0, caret);
|
||||
const at = before.lastIndexOf("/");
|
||||
if (at < 0) return null;
|
||||
if (at > 0 && !/\s/.test(before[at - 1]!)) return null;
|
||||
const query = before.slice(at + 1);
|
||||
if (!CMD_PREFIX.test(query)) return null;
|
||||
const rest = /^[\w-]*/.exec(text.slice(caret))![0];
|
||||
return { start: at, end: caret + rest.length, query };
|
||||
}
|
||||
|
||||
/** Removes the matched token from the text (what a slash command's run leaves behind); collapses a doubled space at the seam. */
|
||||
export function removeSlashToken(text: string, match: SlashMatch): string {
|
||||
const before = text.slice(0, match.start);
|
||||
const after = text.slice(match.end);
|
||||
// Only the seam is touched: the token used to separate its two neighbours, so dropping it would
|
||||
// leave `a b` — one of those two spaces goes. The rest of the draft stays byte-for-byte (a
|
||||
// global collapse would silently reflow pasted code, YAML, aligned tables and indentation).
|
||||
const joined =
|
||||
before.endsWith(" ") && after.startsWith(" ") ? before + after.slice(1) : before + after;
|
||||
// The token was the entire content (possibly padded with whitespace): leave a truly empty input.
|
||||
return /^\s*$/.test(joined) ? "" : joined;
|
||||
}
|
||||
@@ -35,6 +35,8 @@ export interface StreamFollow {
|
||||
touchEnd(): void;
|
||||
/** scroll event (user scrolling and programmatic stick-to-bottom share this path): moving up exits; otherwise nearing the bottom resumes; the first event initializes from position. */
|
||||
scrolled(m: ScrollMetrics): void;
|
||||
/** Explicit re-entry (the back-to-bottom button): resumes follow immediately — the caller scrolls to the bottom right after, and the resulting scroll event sees a bottom position, keeping it stuck. */
|
||||
resume(): void;
|
||||
}
|
||||
|
||||
export function createStreamFollow(): StreamFollow {
|
||||
@@ -72,5 +74,11 @@ export function createStreamFollow(): StreamFollow {
|
||||
}
|
||||
if (dist < 80) stick = true;
|
||||
},
|
||||
resume() {
|
||||
stick = true;
|
||||
// Forget the last position: the caller jumps to the bottom right after, and that large
|
||||
// downward scroll must not be judged against a stale historical scrollTop.
|
||||
lastTop = null;
|
||||
},
|
||||
};
|
||||
}
|
||||
|
||||
@@ -4,8 +4,6 @@
|
||||
* style and set of running-state icons (in progress / done / failed) as tool cards.
|
||||
*/
|
||||
import { useState } from "react";
|
||||
import ReactMarkdown from "react-markdown";
|
||||
import remarkGfm from "remark-gfm";
|
||||
import { S } from "../../lib/strings";
|
||||
import { humanizeDuration } from "../../lib/format";
|
||||
import type { ThinkingItem } from "../../lib/omni/stream-model";
|
||||
@@ -13,6 +11,7 @@ import { Chevron } from "../../components/ui/chevron";
|
||||
import { StatusIcon } from "../../components/ui/status-icon";
|
||||
import type { RunState } from "../../components/ui/status-icon";
|
||||
import { LiveDuration } from "./live-duration";
|
||||
import { Md } from "./md";
|
||||
|
||||
export function ThinkingBlock({ item }: { item: ThinkingItem }) {
|
||||
const [open, setOpen] = useState(false);
|
||||
@@ -47,7 +46,7 @@ export function ThinkingBlock({ item }: { item: ThinkingItem }) {
|
||||
</button>
|
||||
{open && (
|
||||
<div className="md-body anim-fade mx-3 mb-2 rounded-md bg-gray-50 px-3 py-2 text-sm leading-relaxed text-gray-600 dark:bg-gray-900/60 dark:text-gray-400">
|
||||
<ReactMarkdown remarkPlugins={[remarkGfm]}>{item.thinking}</ReactMarkdown>
|
||||
<Md text={item.thinking} streaming={item.streaming} />
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
|
||||
@@ -26,6 +26,9 @@ import type { StreamModel } from "../../lib/omni/stream-model";
|
||||
|
||||
export type { PendingApproval } from "../../lib/omni/stream-controller";
|
||||
|
||||
/** Minimum spacing between version commits: below one frame at 8fps, invisible as staleness, but ~8× fewer full re-parses of the growing message during a fast large-code stream. */
|
||||
const BUMP_MIN_INTERVAL_MS = 120;
|
||||
|
||||
export interface SessionStreamState {
|
||||
/** View model (updated in place; version bump triggers re-render). */
|
||||
model: StreamModel;
|
||||
@@ -70,13 +73,33 @@ export function useSessionStream(
|
||||
const placeholderRef = useRef<StreamModel | null>(null);
|
||||
if (placeholderRef.current === null) placeholderRef.current = createStreamModel();
|
||||
const rafRef = useRef<number | null>(null);
|
||||
const throttleRef = useRef<number | null>(null);
|
||||
const lastBumpAtRef = useRef(0);
|
||||
|
||||
// Coalesce high-frequency deltas: multiple pushes within one frame trigger only a single re-render.
|
||||
// Coalesce high-frequency deltas: multiple pushes within one frame trigger only a single
|
||||
// re-render, and commits are additionally spaced ≥BUMP_MIN_INTERVAL_MS apart. Every commit
|
||||
// re-renders (and re-parses) the currently streaming message at its full accumulated length,
|
||||
// so per-frame commits during a large streamed reply go O(n²) and freeze the main thread —
|
||||
// the interval caps that at a bounded, invisible staleness. Inside the interval a single
|
||||
// trailing timer is armed (the flush): the final deltas always land even if no further push
|
||||
// ever arrives.
|
||||
const bump = useCallback(() => {
|
||||
if (rafRef.current !== null) return;
|
||||
if (rafRef.current !== null || throttleRef.current !== null) return;
|
||||
const commit = () => {
|
||||
lastBumpAtRef.current = Date.now();
|
||||
setVersion((v) => v + 1);
|
||||
};
|
||||
const wait = lastBumpAtRef.current + BUMP_MIN_INTERVAL_MS - Date.now();
|
||||
if (wait > 0) {
|
||||
throttleRef.current = window.setTimeout(() => {
|
||||
throttleRef.current = null;
|
||||
commit();
|
||||
}, wait);
|
||||
return;
|
||||
}
|
||||
rafRef.current = requestAnimationFrame(() => {
|
||||
rafRef.current = null;
|
||||
setVersion((v) => v + 1);
|
||||
commit();
|
||||
});
|
||||
}, []);
|
||||
|
||||
@@ -134,6 +157,10 @@ export function useSessionStream(
|
||||
cancelAnimationFrame(rafRef.current);
|
||||
rafRef.current = null;
|
||||
}
|
||||
if (throttleRef.current !== null) {
|
||||
window.clearTimeout(throttleRef.current);
|
||||
throttleRef.current = null;
|
||||
}
|
||||
};
|
||||
// initialStatus only serves as the first-frame placeholder; it doesn't rebuild the connection
|
||||
// on parent re-renders.
|
||||
|
||||
@@ -24,6 +24,7 @@ import { Chevron } from "../../components/ui/chevron";
|
||||
import { StatusIcon } from "../../components/ui/status-icon";
|
||||
import { approvalKey } from "../../lib/omni/stream-model";
|
||||
import type { ChatItem } from "../../lib/omni/stream-model";
|
||||
import { LiveDuration } from "./live-duration";
|
||||
import { MessageItem } from "./message-item";
|
||||
import type { StreamRenderContext } from "./message-stream";
|
||||
import { summarizeWork } from "./work-summary";
|
||||
@@ -62,8 +63,11 @@ export function WorkGroup({
|
||||
/** Whether this group is the last segment of the message stream (current turn still in progress): decides the default expanded/collapsed state. */
|
||||
isLast: boolean;
|
||||
}) {
|
||||
// Whether any item is in flight right now — also the only window in which the group's span is
|
||||
// still growing, which the duration display below depends on.
|
||||
const itemsRunning = items.some((it) => itemActive(it, ctx));
|
||||
// Last segment + Task running = the model might still call another tool → show Running (even if there's no active item right now).
|
||||
const active = (isLast && ctx.taskRunning) || items.some((it) => itemActive(it, ctx));
|
||||
const active = (isLast && ctx.taskRunning) || itemsRunning;
|
||||
const pending = hasPendingApproval(items, ctx);
|
||||
const [open, setOpen] = useState(isLast);
|
||||
const userToggled = useRef(false);
|
||||
@@ -79,7 +83,7 @@ export function WorkGroup({
|
||||
|
||||
// A pending approval must stay actionable: expand the group body regardless of collapsed state (the approval row lives inside it).
|
||||
const shown = open || pending;
|
||||
const { steps, durationMs } = summarizeWork(items);
|
||||
const { steps, durationMs, startMs } = summarizeWork(items);
|
||||
|
||||
return (
|
||||
<div className="anim-msg my-2 overflow-hidden rounded-md border border-gray-200 bg-white dark:border-gray-800 dark:bg-gray-900">
|
||||
@@ -106,11 +110,27 @@ export function WorkGroup({
|
||||
{S.chat.workGroupSteps(steps)}
|
||||
</span>
|
||||
)}
|
||||
{durationMs > 0 && (
|
||||
<span className="shrink-0 font-mono text-xs text-gray-400">
|
||||
{humanizeDuration(durationMs)}
|
||||
</span>
|
||||
)}
|
||||
{/* Both states show the same quantity: the summarizeWork span (earliest item start →
|
||||
latest item end). That's the canonical definition here — it's what work-summary.ts
|
||||
documents, and unlike a group-open→now wall clock it is reconstructible from the
|
||||
stored timestamps, so reloading the transcript reproduces the same number. While an
|
||||
item is in flight its end isn't known yet, so the tick extends the span to *now*
|
||||
(whole seconds); with nothing in flight the value freezes at the computed span, with
|
||||
decimals — the same "don't tick through a wait we don't count" idiom as a tool card
|
||||
parked on an approval. Ticking on while the group merely stays Running (the model
|
||||
thinking between steps, or streaming its answer) would climb past the span and then
|
||||
snap backwards the moment the group settles. */}
|
||||
{itemsRunning
|
||||
? startMs !== undefined && (
|
||||
<span className="shrink-0 font-mono text-xs text-gray-400">
|
||||
<LiveDuration sinceMs={startMs} />
|
||||
</span>
|
||||
)
|
||||
: durationMs > 0 && (
|
||||
<span className="shrink-0 font-mono text-xs text-gray-400">
|
||||
{humanizeDuration(durationMs)}
|
||||
</span>
|
||||
)}
|
||||
{pending && !shown && (
|
||||
<span className="shrink-0 rounded bg-amber-100 px-1 text-[10px] font-medium text-amber-700 dark:bg-amber-950/50 dark:text-amber-300">
|
||||
{S.chat.approvalWaiting}
|
||||
|
||||
@@ -2,38 +2,54 @@
|
||||
* Summary for the "Reasoning & Tools" group header (pure logic, unit-testable): step count only
|
||||
* counts **tool calls** (thinking doesn't count as a step).
|
||||
*
|
||||
* Duration is computed as the **union of time intervals**: overlapping time from parallel tool
|
||||
* calls is counted only once, presenting the group's wall-clock working time rather than the sum
|
||||
* of each item's duration (naive per-item summation would report 15-way parallel work spanning
|
||||
* 9 minutes as 99 minutes). Intervals follow the same settlement convention as each item's
|
||||
* durationMs (see settleToolDuration in stream-model.ts): thinking is [startedAtMs, +durationMs];
|
||||
* a tool's durationMs = argument-generation segment + execution segment (approval wait time is
|
||||
* excluded), and the two segments are not adjacent on the timeline, so they must be split back
|
||||
* into two intervals using the same formula: [argStartedAtMs, +generation segment] and
|
||||
* [approvalAtMs ?? callStartedAtMs, +execution segment] — computing the whole span from the
|
||||
* execution start point would shift the generation segment into the execution period, producing
|
||||
* false overlap with parallel tools. Gaps between intervals (waiting for the model's next step)
|
||||
* are not counted; a segment missing a start point can't be checked for overlap and falls back to
|
||||
* plain summation.
|
||||
* Duration is the group's **real wall-clock span**: earliest segment start → latest segment end,
|
||||
* including everything in between — approval waits, gaps while the model decides its next step,
|
||||
* parallel work counted once. The header answers "how long did this group take", not "how much
|
||||
* compute happened inside it" (per-item cards carry the fine-grained settled durations).
|
||||
* Segment endpoints follow the same settlement convention as each item's durationMs (see
|
||||
* settleToolDuration in stream-model.ts): thinking is [startedAtMs, +durationMs]; a tool's
|
||||
* durationMs = argument-generation segment + execution segment (the approval wait between them
|
||||
* is excluded from the item's own duration, but lands inside the group span), so the endpoints
|
||||
* are [argStartedAtMs, +generation segment] and [approvalAtMs ?? callStartedAtMs, +execution
|
||||
* segment]. Segments missing a start point can't be placed on the timeline and fall back to
|
||||
* plain summation on top of the span. `startMs` is the earliest placed start — the anchor the
|
||||
* group header ticks from while an item is still in flight (once nothing is running the header
|
||||
* freezes at the computed span, so the displayed number never jumps backwards).
|
||||
*/
|
||||
import type { ChatItem } from "../../lib/omni/stream-model";
|
||||
|
||||
export function summarizeWork(items: ChatItem[]): { steps: number; durationMs: number } {
|
||||
export function summarizeWork(items: ChatItem[]): {
|
||||
steps: number;
|
||||
durationMs: number;
|
||||
startMs?: number;
|
||||
} {
|
||||
let steps = 0;
|
||||
let durationMs = 0;
|
||||
const intervals: [number, number][] = [];
|
||||
let fallbackMs = 0;
|
||||
let minStart: number | undefined;
|
||||
let maxEnd: number | undefined;
|
||||
/** Group open = the earliest start stamp of ANY item, settled or not — a still-streaming first item must already anchor the live tick. */
|
||||
const seen = (startMs: number | undefined) => {
|
||||
if (startMs !== undefined && (minStart === undefined || startMs < minStart)) minStart = startMs;
|
||||
};
|
||||
const add = (startMs: number | undefined, spanMs: number) => {
|
||||
if (spanMs <= 0) return;
|
||||
if (startMs === undefined) durationMs += spanMs;
|
||||
else intervals.push([startMs, startMs + spanMs]);
|
||||
if (startMs === undefined) {
|
||||
fallbackMs += spanMs;
|
||||
return;
|
||||
}
|
||||
seen(startMs);
|
||||
const end = startMs + spanMs;
|
||||
if (maxEnd === undefined || end > maxEnd) maxEnd = end;
|
||||
};
|
||||
for (const it of items) {
|
||||
if (it.kind === "thinking") {
|
||||
seen(it.startedAtMs);
|
||||
if (it.durationMs !== undefined) add(it.startedAtMs, it.durationMs);
|
||||
continue;
|
||||
}
|
||||
if (it.kind !== "tool_call") continue;
|
||||
steps += 1;
|
||||
seen(it.argStartedAtMs ?? it.callStartedAtMs ?? it.approvalAtMs);
|
||||
if (it.durationMs === undefined) continue;
|
||||
const genMs =
|
||||
it.argStartedAtMs !== undefined && it.callStartedAtMs !== undefined
|
||||
@@ -42,18 +58,10 @@ export function summarizeWork(items: ChatItem[]): { steps: number; durationMs: n
|
||||
add(it.argStartedAtMs, genMs);
|
||||
add(it.approvalAtMs ?? it.callStartedAtMs, it.durationMs - genMs);
|
||||
}
|
||||
intervals.sort((a, b) => a[0] - b[0]);
|
||||
let curStart: number | null = null;
|
||||
let curEnd = 0;
|
||||
for (const [start, end] of intervals) {
|
||||
if (curStart === null || start > curEnd) {
|
||||
if (curStart !== null) durationMs += curEnd - curStart;
|
||||
curStart = start;
|
||||
curEnd = end;
|
||||
} else if (end > curEnd) {
|
||||
curEnd = end;
|
||||
}
|
||||
}
|
||||
if (curStart !== null) durationMs += curEnd - curStart;
|
||||
return { steps, durationMs };
|
||||
const spanMs = minStart !== undefined && maxEnd !== undefined ? maxEnd - minStart : 0;
|
||||
return {
|
||||
steps,
|
||||
durationMs: spanMs + fallbackMs,
|
||||
...(minStart !== undefined ? { startMs: minStart } : {}),
|
||||
};
|
||||
}
|
||||
|
||||
@@ -401,10 +401,21 @@ export function WorkspaceBrowser({
|
||||
))}
|
||||
</div>
|
||||
)}
|
||||
{/* Ghost style, matching the toolbar's upload label (text-xs, transparent until hover) — the bordered secondary look stood out from every neighbor. */}
|
||||
{/\.html?$/i.test(preview.name) && (
|
||||
<a
|
||||
href={api.workspaceFilePreviewUrl(session.sessionId, preview.path)}
|
||||
target="_blank"
|
||||
rel="noopener noreferrer"
|
||||
className="inline-flex shrink-0 items-center rounded-md border border-transparent bg-transparent px-2.5 py-1 text-xs font-medium text-gray-600 transition-colors duration-150 hover:bg-gray-100 hover:text-gray-900 dark:text-gray-300 dark:hover:bg-gray-800 dark:hover:text-gray-100"
|
||||
>
|
||||
{S.files.openInNewTab}
|
||||
</a>
|
||||
)}
|
||||
<a
|
||||
href={api.workspaceFileUrl(session.sessionId, preview.path, true)}
|
||||
download={preview.name}
|
||||
className="inline-flex shrink-0 items-center rounded-md border border-gray-300 bg-white px-2.5 py-1 text-sm font-medium transition-colors duration-150 hover:bg-gray-50 dark:border-gray-700 dark:bg-gray-900 dark:hover:bg-gray-800"
|
||||
className="inline-flex shrink-0 items-center rounded-md border border-transparent bg-transparent px-2.5 py-1 text-xs font-medium text-gray-600 transition-colors duration-150 hover:bg-gray-100 hover:text-gray-900 dark:text-gray-300 dark:hover:bg-gray-800 dark:hover:text-gray-100"
|
||||
>
|
||||
{S.files.download}
|
||||
</a>
|
||||
|
||||
Reference in New Issue
Block a user