feat(core,server,web,cli): mid-run steering messages and square-bracket markers (#63)

Co-authored-by: Claude Fable 5 <noreply@anthropic.com>
This commit is contained in:
Yaowei ZhengandClaude Fable 5 authored and GitHub committed 2026-07-26 23:01:18 +08:00
1 parent abd5b13d52
commit 7584171482
81 files changed
+3139 -930

No files matched your search

+23 -175
View File
@@ -8,13 +8,32 @@
* caret, driving the agent-picker popup;
* - `filterAgents`: filters candidates by prefix (agentId or display name, case-insensitive);
* - `splitLeadingMention`: on send, parses a leading `@<agentId>`, splitting off the target
* agent from the remaining text;
* - `handoffMessage`: generates the first message for the @-mentioned agent's **new
* conversation** — an origin note (`<handoff_from>` block) carrying the source agent /
* Session / Workspace, followed by the user's text as a subsequent text part).
* agent from the remaining text.
*
* The origin **marker blocks** these flows produce and render — `[handoff_from]`,
* `[scheduled_task]`, `[model_switch_from]` — are defined in core's marker module
* (`@prismshadow/penguin-core/markers`) alongside every other message marker, and are
* re-exported below under this feature's existing names.
*/
import { buildHandoffMessage, buildModelSwitchMessage } from "@prismshadow/penguin-core/markers";
import type { AgentSummary } from "@prismshadow/penguin-server/api";
export {
parseHandoffMessage,
parseModelSwitchMessage,
parseScheduledMessage,
} from "@prismshadow/penguin-core/markers";
export type {
HandoffOrigin,
ModelSwitchOrigin,
ScheduledOrigin,
} from "@prismshadow/penguin-core/markers";
/** First message of an @-handoff new conversation (core's `[handoff_from]` origin block). */
export const handoffMessage = buildHandoffMessage;
/** First message of a `/model` switch new conversation (core's `[model_switch_from]` origin block). */
export const modelSwitchMessage = buildModelSwitchMessage;
/** Id characters allowed between `@` and the caret (matches core's id convention: letters, digits, underscore, hyphen). */
const ID_PREFIX = /^[\w-]*$/;
@@ -73,174 +92,3 @@ export function splitLeadingMention(
if (!agent) return null;
return { agent, rest: m[2]!.trimStart() };
}
/** Origin info for an @-handoff new conversation: source agent is always present; source Session is omitted while it's still a draft (not yet created). */
export interface HandoffOrigin {
agentId: string;
agentName?: string;
sessionId?: string;
sessionTitle?: string;
workspace?: string;
}
/**
* First message of an @-handoff new conversation (in English): the `<handoff_from>` block
* states that this conversation was opened by an @ mention and carries the source agent /
* Session / Workspace, so the @-mentioned agent knows its origin (e.g. defaulting to the
* source agent as its working target, or reaching source files via the Workspace path);
* the parenthetical label is omitted when the display name/title equals the id or is
* absent. When rendering the message stream, `parseHandoffMessage` collapses this into a
* one-line handoff notice — the raw text isn't shown (the model still sees it as usual).
*/
export function handoffMessage(origin: HandoffOrigin): string {
const name =
origin.agentName && origin.agentName !== origin.agentId ? ` (${origin.agentName})` : "";
const lines = [`agent: ${origin.agentId}${name}`];
if (origin.sessionId) {
const title = origin.sessionTitle ? ` (${origin.sessionTitle})` : "";
lines.push(`session: ${origin.sessionId}${title}`);
}
if (origin.workspace) lines.push(`workspace: ${origin.workspace}`);
return [
"<handoff_from>",
"This conversation was opened by @-mentioning you from another conversation; its origin is listed below and the user's message, if any, follows. When the request refers to an agent, session, or files without naming them, it means this origin.",
...lines,
"</handoff_from>",
].join("\n");
}
/**
* Inverse parse of `handoffMessage` (lets the message stream collapse the origin block into
* a handoff notice): returns origin info when the whole message is strictly one
* `<handoff_from>` block, otherwise returns null (a normal user message renders as-is).
* Field lines are parsed as `key: id (label)`; non-field lines such as the explanation
* sentence are ignored.
*/
export function parseHandoffMessage(text: string): HandoffOrigin | null {
const block = /^<handoff_from>\n([\s\S]*)\n<\/handoff_from>$/.exec(text.trim());
if (!block) return null;
const origin: HandoffOrigin = { agentId: "" };
for (const line of block[1]!.split("\n")) {
const kv = /^(agent|session|workspace): (.+)$/.exec(line);
if (!kv) continue;
const key = kv[1]!;
const value = kv[2]!;
if (key === "workspace") {
origin.workspace = value;
continue;
}
const labeled = /^([\w-]+) \((.*)\)$/.exec(value);
const id = labeled ? labeled[1]! : value;
const label = labeled?.[2];
if (key === "agent") {
origin.agentId = id;
if (label !== undefined) origin.agentName = label;
} else {
origin.sessionId = id;
if (label !== undefined) origin.sessionTitle = label;
}
}
return origin.agentId ? origin : null;
}
/**
* Origin info for a `/model` switch new conversation (modeled on HandoffOrigin): the source
* Session, the absolute path of its latest Trace file (the model reads it for the earlier
* history — nothing is injected into the new context), the shared Workspace, and the
* previous model's paired reference.
*/
export interface ModelSwitchOrigin {
sessionId: string;
sessionTitle?: string;
/** Absolute path of the source session's latest Trace file (JSONL of OmniMessage envelopes). */
tracePath?: string;
workspace?: string;
/** The source session's model reference pair (never concatenated — two separate fields). */
prevProvider?: string;
prevModelId?: string;
}
/**
* First message of a `/model` switch new conversation (in English, mirroring
* `handoffMessage`): the `<model_switch_from>` block states that this conversation continues
* an earlier one on a different model and carries the source Session / Trace path /
* Workspace / previous model pair. The earlier history is deliberately NOT injected — some
* models require thinking payloads and provider `fidelity` byte-for-byte when history is
* replayed, which cannot cross models — so the model reads the Trace file itself when it
* needs the context. When rendering, `parseModelSwitchMessage` collapses this into a
* one-line switch notice (the raw text isn't shown; the model still sees it as usual).
*/
export function modelSwitchMessage(origin: ModelSwitchOrigin): string {
const title = origin.sessionTitle ? ` (${origin.sessionTitle})` : "";
const lines = [`session: ${origin.sessionId}${title}`];
if (origin.tracePath) lines.push(`trace: ${origin.tracePath}`);
if (origin.workspace) lines.push(`workspace: ${origin.workspace}`);
if (origin.prevModelId) lines.push(`previous_model: ${origin.prevModelId}`);
if (origin.prevProvider) lines.push(`previous_provider: ${origin.prevProvider}`);
return [
"<model_switch_from>",
"The user switched models: this conversation continues an earlier conversation, now on a different model. Its origin is listed below and the user's message, if any, follows. The earlier history is NOT in your context — when you need it, read the trace file at the path below (JSONL, one message envelope per line: user/assistant text, tool calls and results).",
...lines,
"</model_switch_from>",
].join("\n");
}
/**
* Inverse parse of `modelSwitchMessage` (lets the message stream collapse the origin block
* into a switch notice): returns origin info when the whole message is strictly one
* `<model_switch_from>` block, otherwise null (a normal user message renders as-is).
* Field lines parse as `key: value`; the session line's `(title)` label is split off;
* non-field lines such as the explanation sentence are ignored.
*/
export function parseModelSwitchMessage(text: string): ModelSwitchOrigin | null {
const block = /^<model_switch_from>\n([\s\S]*)\n<\/model_switch_from>$/.exec(text.trim());
if (!block) return null;
const origin: ModelSwitchOrigin = { sessionId: "" };
for (const line of block[1]!.split("\n")) {
const kv = /^(session|trace|workspace|previous_model|previous_provider): (.+)$/.exec(line);
if (!kv) continue;
const key = kv[1]!;
const value = kv[2]!;
if (key === "session") {
const labeled = /^([\w-]+) \((.*)\)$/.exec(value);
origin.sessionId = labeled ? labeled[1]! : value;
if (labeled?.[2] !== undefined) origin.sessionTitle = labeled[2];
} else if (key === "trace") origin.tracePath = value;
else if (key === "workspace") origin.workspace = value;
else if (key === "previous_model") origin.prevModelId = value;
else origin.prevProvider = value;
}
return origin.sessionId ? origin : null;
}
/** Origin info for a scheduled-task trigger (the server's scheduledMessage `<scheduled_task>` block). */
export interface ScheduledOrigin {
/** Task name (filename minus .toml). */
name: string;
/** Trigger timestamp (ISO 8601); empty string when absent from the block. */
firedAt: string;
}
/**
* Inverse parse of the server's scheduledMessage (lets the message stream collapse the
* origin block into a scheduled-task notice): returns origin info and the remaining text
* when the message **starts with** a `<scheduled_task>` block, otherwise returns null.
* Unlike handoff, the block is followed by the task's Prompt body, which must be returned
* alongside it for normal rendering (the raw block isn't shown; the Trace page shows it
* as-is).
*/
export function parseScheduledMessage(
text: string,
): { origin: ScheduledOrigin; rest: string } | null {
const m = /^<scheduled_task>\n([\s\S]*?)\n<\/scheduled_task>/.exec(text);
if (!m) return null;
const origin: ScheduledOrigin = { name: "", firedAt: "" };
for (const line of m[1]!.split("\n")) {
const kv = /^(schedule|fired_at): (.+)$/.exec(line);
if (!kv) continue;
if (kv[1] === "schedule") origin.name = kv[2]!;
else origin.firedAt = kv[2]!;
}
if (!origin.name) return null;
return { origin, rest: text.slice(m[0].length).replace(/^\n+/, "") };
}
+428 -172
View File
@@ -31,13 +31,20 @@
* clicking a row toggles its selection without closing the menu; the button = book icon + label +
* selected-count badge, disabled while running/compacting). With skills selected, sending with an
* empty text body is allowed — the sent text automatically falls back to S.chat.skillsAutoMessage.
* When sent, the text body wraps in a `<use_skills>` block (the handoff's rest-of-message body is
* When sent, the text body wraps in a `[use_skills]` block (the handoff's rest-of-message body is
* wrapped the same way); the selection clears once sending succeeds. Quick-invoke pre-selects via
* initialSkills (read once on mount; once the installed list is ready, names not in that list are
* pruned); the slash menu also lists installed skills, and pressing Enter on `/<skill_name>`
* selects it.
* Switches to a "Stop" button while a Task is running; disabled with a reason shown while
* compacting.
* While a Task is running the input stays enabled and the toolbar keeps ONE action button:
* an empty composer shows Stop (abort), and typing turns that same button into Send, which
* follows the remembered mid-run send mode — steer (delivered between turns as a
* [user_steering] user message) or queue-as-follow-up — chosen from the toolbar's
* More-settings popover (available in draft state too, persisted in localStorage); sending is
* disabled with a reason shown while compacting.
* The toolbar is a single left/right row: settings controls left (scrolling horizontally when
* the card is too narrow), status + model + the action button right (never shrinking), so a
* phone viewport never pushes the action button off-screen.
* Renders only the card body itself: outer positioning such as bottom-docking or vertical
* centering is decided by the page.
*/
@@ -120,12 +127,11 @@ function ApprovalModeSelect({
<Dropdown
open={open}
setOpen={setOpen}
menuClass={
// w-max: width exactly wraps the longest line (no wrapping within a line), avoiding an overly wide panel.
direction === "down"
? "left-0 top-full mt-1 w-max max-w-[calc(100vw-2rem)] origin-top-left"
: "bottom-full left-0 mb-1 w-max max-w-[calc(100vw-2rem)] origin-bottom-left"
}
// w-max: width exactly wraps the longest line (no wrapping within a line), avoiding an
// overly wide panel. Placement is portal-driven (the toolbar scrolls horizontally on
// phones, which would otherwise clip the panel); only size classes belong here.
menuClass="w-max"
portal={{ direction, align: "left" }}
button={
// Button styling matches the model selector (h-8 / rounded-md / solid hover background).
<button
@@ -370,12 +376,11 @@ function ModelSelect({
<Dropdown
open={open}
setOpen={setOpen}
// right-0 docks the panel's right edge to the button, which itself sits ~4.5rem in from
// the viewport's right edge (page + card padding, gap, send button) — so the width clamp
// must reserve that anchor offset too, or the w-max panel's LEFT edge runs off-screen on
// phones (~34px off-screen at 375px with a 100vw-2rem clamp). Desktop is untouched:
// w-max stays far below the clamp there.
menuClass="right-0 top-full mt-1 w-max min-w-56 max-w-[calc(100vw-6rem)] origin-top-right"
// The panel's right edge docks to the button; portal placement then clamps both edges
// inside the viewport, so a w-max panel can no longer run off-screen on phones (it used
// to need a hand-tuned width clamp reserving the anchor offset).
menuClass="w-max min-w-56 origin-top-right"
portal={{ direction: "down", align: "right" }}
button={
<button
type="button"
@@ -462,11 +467,8 @@ function ThinkingLevelSelect({
<Dropdown
open={open}
setOpen={setOpen}
menuClass={
direction === "down"
? "right-0 top-full mt-1 w-max min-w-36 origin-top-right"
: "bottom-full right-0 mb-1 w-max min-w-36 origin-bottom-right"
}
menuClass="w-max min-w-36"
portal={{ direction, align: "right" }}
button={
<button
type="button"
@@ -526,6 +528,97 @@ function ThinkingLevelSelect({
);
}
/**
* Mid-run send mode: steer (delivered mid-run as a [user_steering] input) vs follow-up
* (queued server-side until the run ends). A remembered per-user UI preference, persisted
* the same way as the sidebar grouping mode (validated localStorage read under a
* `penguin.*` key); configurable from the More-settings popover in draft state and active
* sessions alike.
*/
type SteerMode = "steer" | "followup";
const STEER_MODE_KEY = "penguin.steerMode";
function initialSteerMode(): SteerMode {
return localStorage.getItem(STEER_MODE_KEY) === "followup" ? "followup" : "steer";
}
/** Sliders icon (24×24 line path) for the More-settings popover button. */
const SLIDERS_ICON = "M4 21v-7M4 10V3M12 21v-9M12 8V3M20 21v-5M20 12V3M1 14h6M9 8h6M17 16h6";
/**
* "More settings" popover (bottom toolbar): a compact icon button opening a small panel of
* setting rows — deliberately extensible, future toggles land here as additional rows. The
* first (currently only) row is the mid-run send mode: Steer (default) / Queue as a
* follow-up; the full explanation lives in each pill's hover title (not rendered by
* default). Never disabled — the preference is settable before and during a run.
*
* The panel renders through the shared portal layer (Dropdown's `portal`): the toolbar
* scrolls horizontally on phones, and a panel anchored inside a scrolling container would be
* clipped away — the portal also clamps both edges into the viewport, which is what the old
* hand-measured docking did.
*/
function MoreSettingsSelect({
steerMode,
onChangeSteerMode,
direction = "up",
}: {
steerMode: SteerMode;
onChangeSteerMode: (mode: SteerMode) => void;
direction?: "up" | "down";
}) {
const [open, setOpen] = useState(false);
// Compact pill (small-control sizing, sized to its label): the explanation is hover-only
// via title, per the toolbar's "full meaning on hover" convention.
const modeButton = (mode: SteerMode, label: string, hint: string) => (
<button
type="button"
title={hint}
aria-pressed={steerMode === mode}
onClick={() => onChangeSteerMode(mode)}
className={`h-6 rounded px-2 text-xs transition-colors duration-150 ${
steerMode === mode
? "bg-gray-200 font-medium text-gray-800 dark:bg-gray-700 dark:text-gray-100"
: "text-gray-500 hover:text-gray-800 dark:text-gray-400 dark:hover:text-gray-200"
}`}
>
{label}
</button>
);
return (
<Dropdown
open={open}
setOpen={setOpen}
menuClass="w-max max-w-44"
portal={{ direction, align: "left" }}
button={
<button
type="button"
aria-label={S.chat.moreSettings}
title={S.chat.moreSettings}
onClick={() => setOpen((v) => !v)}
className="flex h-8 w-8 shrink-0 items-center justify-center rounded-md text-gray-500 transition-colors duration-150 hover:bg-gray-100 hover:text-gray-800 dark:text-gray-400 dark:hover:bg-gray-800 dark:hover:text-gray-200"
>
<GlyphIcon d={SLIDERS_ICON} size={15} />
</button>
}
>
{/* Setting row: label + compact control (descriptions hover-only). New settings append as further rows. */}
<div className="px-2.5 py-1.5">
<p className="mb-1 text-xs font-medium text-gray-700 dark:text-gray-300">
{S.chat.steerModeLabel}
</p>
<div
role="group"
aria-label={S.chat.steerModeLabel}
className="flex w-max items-center gap-0.5 rounded-md border border-gray-200 p-0.5 dark:border-gray-700"
>
{modeButton("steer", S.chat.steerModeSteer, S.chat.steerModeSteerHint)}
{modeButton("followup", S.chat.steerModeFollowUp, S.chat.steerModeFollowUpHint)}
</div>
</div>
</Dropdown>
);
}
/**
* Multi-select skills dropdown (bottom toolbar, after approval mode): styled like the model
* selector — button = book icon + "Skills" label + selected-count badge (no badge at 0; when the
@@ -557,18 +650,10 @@ function SkillSelect({
<Dropdown
open={open}
setOpen={setOpen}
menuClass={
// As wide as reasonably possible so descriptions stay readable. The panel is
// left-anchored to a button ~8rem into the toolbar (icon-only card; ~17rem once the
// card is wide enough for button labels, @md), so a plain 100vw clamp still let the
// right edge overrun the viewport on phones (~92px at 375px — the search box's
// autofocus then dragged the whole page sideways); the clamp must reserve the anchor
// offset plus a margin. Desktop is untouched: the fixed 26rem width stays below both
// clamps there.
direction === "down"
? "left-0 top-full mt-1 w-[26rem] max-w-[calc(100vw-10rem)] @md:max-w-[calc(100vw-17rem)] origin-top-left"
: "bottom-full left-0 mb-1 w-[26rem] max-w-[calc(100vw-10rem)] @md:max-w-[calc(100vw-17rem)] origin-bottom-left"
}
// As wide as reasonably possible so descriptions stay readable; portal placement clamps
// it to the viewport, so the old hand-tuned anchor-offset clamps are no longer needed.
menuClass="w-[26rem]"
portal={{ direction, align: "left" }}
button={
<button
type="button"
@@ -726,7 +811,12 @@ function ContextGauge({
transform="rotate(-90 7 7)"
/>
</svg>
{unknown ? "—" : humanizeTokens(now)}/{humanizeTokens(max)}
{/* The ring alone carries the meaning on phones: the numbers hide below @md (the title
still shows the exact usage), keeping the right-hand control group inside a 320px
viewport in the running state. */}
<span className="hidden @md:inline">
{unknown ? "—" : humanizeTokens(now)}/{humanizeTokens(max)}
</span>
</span>
);
}
@@ -734,6 +824,10 @@ function ContextGauge({
export function ChatInput({
status,
onSend,
onSteer,
steeringDeliveredCount,
onQueueFollowUp,
queuedFollowUps = 0,
onStop,
onCompact,
modelRef,
@@ -766,6 +860,29 @@ export function ChatInput({
status: SessionStatus;
/** Returns whether it succeeded: on failure the input draft is kept (not cleared). */
onSend: (input: TaskInputPart[]) => Promise<boolean>;
/**
* Mid-run steering (session state only): while a Task is running, Enter/send queues the
* trimmed text for the running agent — it is delivered between turns as a standalone
* `[user_steering]` user message. `"queued"` clears the text and shows the queued hint;
* `"not_running"` (409 race with completion) makes the input fall back to its full normal
* send path; `"failed"` keeps the draft. When absent (draft state), the input stays
* send-disabled while running, as before.
*/
onSteer?: (text: string) => Promise<"queued" | "not_running" | "failed">;
/**
* Count of steering messages already visible in the message stream: the queued hint stays
* up until this increases past its value at queue time (i.e. the message was delivered).
*/
steeringDeliveredCount?: number;
/**
* Follow-up queue (session state only): posts the full input with `queueIfBusy` — a busy
* session holds it server-side and auto-sends it as an ordinary next task once the current
* run finishes. Offered as the "follow-up" choice of the mid-run mode switch; when absent,
* only steering is offered while running.
*/
onQueueFollowUp?: (input: TaskInputPart[]) => Promise<boolean>;
/** Server-reported queued follow-up count (from task_state): renders the "N queued" hint until they auto-send. */
queuedFollowUps?: number;
/**
* Used instead of onSend when an @ target is present (chip or a leading @ typed manually):
* opens a new chat for the target agent (the text body carries no @ marker, and the current
@@ -898,13 +1015,60 @@ export function ChatInput({
const running = status === "running";
const compacting = status === "compacting";
// Sending is also allowed with only an @ target (chip) or skills selected and no text: a handoff's
// first message may be just a <handoff_from> source block; with skills and empty text, the sent
// first message may be just a [handoff_from] source block; with skills and empty text, the sent
// text automatically falls back to S.chat.skillsAutoMessage (see send).
const canSend =
!running &&
!compacting &&
!busy &&
(text.trim().length > 0 || images.length > 0 || target !== null || selectedSkills.length > 0);
// Mid-run steering: while running, Enter/send queues plain text for the running agent
// (delivered between turns as a [user_steering] user message). Text only — images / skills /
// @ target stay in the draft for a later normal send (an @ target also blocks steering: a
// leading mention means a handoff, not a message to this agent).
const canSteer =
running && !busy && onSteer !== undefined && target === null && text.trim().length > 0;
// Mid-run send mode (owner directive): the user chooses between "steer" (delivered
// mid-run as a [user_steering] input) and "follow-up" (held server-side and auto-sent as
// an ordinary next task once this run finishes). Set from the More-settings popover on
// the toolbar — available in draft state and active sessions alike — and **remembered**
// across sessions/reloads (localStorage, see STEER_MODE_KEY); the running-state send
// simply follows the remembered mode.
const [steerMode, setSteerModeState] = useState<SteerMode>(initialSteerMode);
const setSteerMode = (mode: SteerMode): void => {
setSteerModeState(mode);
localStorage.setItem(STEER_MODE_KEY, mode);
};
const followUpMode = steerMode === "followup" && onQueueFollowUp !== undefined;
// A follow-up is a full normal message: the whole draft (text / images / skills / handoff)
// is eligible, same content rule as canSend.
const canFollowUp =
running &&
!busy &&
followUpMode &&
(text.trim().length > 0 || images.length > 0 || target !== null || selectedSkills.length > 0);
// The single action button's mode: while running, an **empty** composer means Stop
// (abort); as soon as there is something to send it becomes the send button (steer or
// follow-up per the remembered mode). Idle/compacting is always send.
const canMidRunSend = followUpMode ? canFollowUp : canSteer;
const midRunSendLabel = followUpMode ? S.chat.followUpSend : S.chat.steerSend;
const stopAction =
running &&
text.trim().length === 0 &&
images.length === 0 &&
target === null &&
selectedSkills.length === 0;
// Queued hint: shown after a successful steer until the message shows up in the stream
// (steeringDeliveredCount increases past the baseline captured at queue time) or the run
// stops being observable (task no longer running).
const [steerPending, setSteerPending] = useState(false);
const steerBaseline = useRef(0);
useEffect(() => {
if (!steerPending) return;
if (!running || (steeringDeliveredCount ?? 0) > steerBaseline.current) {
setSteerPending(false);
}
}, [steerPending, running, steeringDeliveredCount]);
/** Toggle a skill on/off (shared by dropdown option clicks and the slash skill command); the change callback lets the parent write it into the draft. */
const toggleSkill = useCallback(
@@ -1153,8 +1317,16 @@ export function ChatInput({
autoGrow();
};
const send = async () => {
if (!canSend) return;
/**
* The full normal send path (task / handoff), also the follow-up queue path and the
* fallback target when a steer hits the completion race: assembles the [use_skills]
* block, images and @ handoff from the whole draft; `post` decides where a non-handoff
* message goes (default: onSend; follow-up mode: onQueueFollowUp). Deliberately not
* gated on `running` — the caller decides (send() gates the normal path; the steering
* fallback calls this directly after the server said 409 not_running, when the local
* `status` may still lag behind).
*/
const sendNormal = async (post: (input: TaskInputPart[]) => Promise<boolean> = onSend) => {
const t = text.trim();
// @ target = the chip (selected via menu), or a leading `@<agentId>` typed/pasted manually
// (an @ in the middle of the text is plain text); with a target present, this becomes a
@@ -1165,14 +1337,14 @@ export function ChatInput({
const rest = lead ? lead.rest : t;
const bodyText =
selectedSkills.length > 0 && rest === "" ? S.chat.skillsAutoMessage(selectedSkills) : rest;
// With non-empty selected skills: the text body is replaced with a <use_skills> block + the text (the handoff branch wraps rest the same way).
// With non-empty selected skills: the text body is replaced with a [use_skills] block + the text (the handoff branch wraps rest the same way).
const body = buildSkillsMessage(selectedSkills, bodyText);
const input: TaskInputPart[] = [];
if (body) input.push({ type: "text", text: body });
for (const url of images) input.push({ type: "image_url", imageUrl: url });
setBusy(true);
try {
const ok = lead ? await onHandoff(lead.agent, input) : await onSend(input);
const ok = lead ? await onHandoff(lead.agent, input) : await post(input);
// Only clear the draft after a successful send: on failure (network / conflict / server error) keep the user's input and images.
if (ok) {
setText("");
@@ -1187,6 +1359,46 @@ export function ChatInput({
}
};
const send = async () => {
if (running) {
// Follow-up branch: the whole draft goes out through the normal composition path,
// but posted with queueIfBusy — the server holds it and auto-sends once this run
// finishes (an @ handoff still opens a new chat directly: the target session isn't
// the one running).
if (followUpMode) {
if (!canFollowUp) return;
await sendNormal(onQueueFollowUp!);
return;
}
// Steering branch: queue the trimmed text for the running agent; only the text is
// sent and cleared — attached images / selected skills stay for a normal send.
if (!canSteer) return;
const steerText = text.trim();
setBusy(true);
let res: "queued" | "not_running" | "failed" = "failed";
try {
res = await onSteer!(steerText);
if (res === "queued") {
// Show the "queued" hint until the steering message shows up in the stream
// (steeringDeliveredCount increases) — see the effect below.
steerBaseline.current = steeringDeliveredCount ?? 0;
setSteerPending(true);
setText("");
requestAnimationFrame(autoGrow);
}
} finally {
setBusy(false);
textareaRef.current?.focus();
}
// Completion race (server: no Task running anymore): deliver the whole draft — images,
// skills and all — through the full normal send path instead of a text-only task.
if (res === "not_running") await sendNormal();
return;
}
if (!canSend) return;
await sendNormal();
};
const onKeyDown = (e: KeyboardEvent<HTMLTextAreaElement>) => {
if (slashOpen) {
if (e.key === "ArrowDown") {
@@ -1403,6 +1615,22 @@ export function ChatInput({
</p>
)}
{/* Mid-run steering queued: lightweight hint until the steering message appears in the
stream (or the run ends). */}
{steerPending && (
<p className="anim-fade mb-1 text-xs text-gray-400 dark:text-gray-500">
{S.chat.steerQueuedIndicator}
</p>
)}
{/* Queued follow-ups (server-side, auto-sent once this run finishes): count from
task_state — survives reloads because the queue lives on the server. */}
{queuedFollowUps > 0 && (
<p className="anim-fade mb-1 text-xs text-gray-400 dark:text-gray-500">
{S.chat.followUpQueuedChip(queuedFollowUps)}
</p>
)}
{/* Unified input card: the multi-line text body occupies the top area, with all controls
collected onto a single bottom row that never shares a line with the text.
@container: the bottom toolbar row collapses based on the **card's actual width**
@@ -1496,158 +1724,186 @@ export function ChatInput({
onSelect={(e) => setCaret(e.currentTarget.selectionStart ?? 0)}
onKeyDown={onKeyDown}
onPaste={onPaste}
placeholder={narrow ? S.chat.inputPlaceholderShort : S.chat.inputPlaceholder}
placeholder={
running && followUpMode
? narrow
? S.chat.followUpPlaceholderShort
: S.chat.followUpPlaceholder
: running && onSteer
? narrow
? S.chat.steerPlaceholderShort
: S.chat.steerPlaceholder
: narrow
? S.chat.inputPlaceholderShort
: S.chat.inputPlaceholder
}
className="block max-h-44 min-h-[60px] w-full resize-none bg-transparent px-1 py-0.5 text-base leading-6 placeholder:text-gray-400 focus:outline-none disabled:cursor-not-allowed disabled:opacity-60 dark:placeholder:text-gray-500"
/>
{/* Bottom toolbar row: attachments + approval mode + help text | context usage + Model + send */}
<div className="mt-1 flex items-center gap-2 text-xs">
<label
className="flex h-8 w-8 shrink-0 cursor-pointer items-center justify-center rounded-md text-gray-400 transition-colors duration-150 hover:bg-gray-100 hover:text-gray-600 dark:hover:bg-gray-800 dark:hover:text-gray-300"
title={vision ? S.chat.imageAlt : S.chat.imagesAsPathHint}
>
<input
type="file"
accept="image/*"
multiple
className="hidden"
onChange={onPickFiles}
/>
<svg
width="17"
height="17"
viewBox="0 0 24 24"
fill="none"
stroke="currentColor"
strokeWidth="1.7"
strokeLinecap="round"
strokeLinejoin="round"
aria-hidden
className="block"
{/* Bottom toolbar row — one line, two groups: the settings controls sit left, the
status/model/action controls right (`justify-between`). The left group is the only
one allowed to give way: `min-w-0` + horizontal scroll means a crowded phone
viewport scrolls those controls instead of pushing the right group (and with it the
action button) off-screen. The right group is `shrink-0` so the action button is
always reachable. */}
<div className="mt-1 flex items-center justify-between gap-2 text-xs">
<div className="no-scrollbar flex min-w-0 flex-1 items-center gap-2 overflow-x-auto">
<label
className="flex h-8 w-8 shrink-0 cursor-pointer items-center justify-center rounded-md text-gray-400 transition-colors duration-150 hover:bg-gray-100 hover:text-gray-600 dark:hover:bg-gray-800 dark:hover:text-gray-300"
title={vision ? S.chat.imageAlt : S.chat.imagesAsPathHint}
>
<rect x="3" y="5" width="18" height="14" rx="3" />
<path d="M3 15l5-5 4 4 3-3 6 6" />
</svg>
</label>
<ApprovalModeSelect
value={approvalMode}
onChange={onChangeApprovalMode}
disabled={modeSaving}
direction={models && onChangeModel ? "down" : "up"}
/>
{/* Multi-select skills dropdown (after approval mode): selected state is conveyed via the button badge. */}
<SkillSelect
skills={skills}
selected={selectedSkills}
onToggle={toggleSkill}
disabled={running || compacting || busy}
direction={models && onChangeModel ? "down" : "up"}
/>
{/* Help text also serves as a flexible spacer: truncated first when space is short
(min-w-0 truncate, full text goes into the title); hidden entirely when the card is
narrower than @lg, leaving only the spacer to push the right-side controls to the
end of the row — long copy (in English) still won't break the card layout. */}
<span className="min-w-0 flex-1">
<span
title={`${S.chat.slashHint} · ${S.chat.mentionHint}`}
className="hidden truncate text-gray-300 @lg:block dark:text-gray-600"
>
{S.chat.slashHint} · {S.chat.mentionHint}
</span>
</span>
{/* Draft state (model still changeable = no session created yet) has no context usage to speak of: the ring isn't shown, it displays as usual once the session is created. */}
{!onChangeModel && (
<ContextGauge
now={contextNow}
unknown={contextStale}
{...(contextWindow !== undefined ? { window: contextWindow } : {})}
/>
)}
{/* Draft state: conversation-time thinking level (backed by Agent settings), docked left of the model selector. */}
{models && onChangeModel && onChangeThinkingLevel && (
<ThinkingLevelSelect
value={thinkingLevel ?? null}
onChange={onChangeThinkingLevel}
disabled={busy}
/>
)}
{/* Session state: per-turn thinking level (editable) — displays the user's pick,
else the Agent config's level (auto-follow; the parent resolves it). While
untouched nothing rides on tasks; a pick sticks for the session and is sent
with every subsequent send, never writing through to the Agent config. */}
{!onChangeModel && onChangeTurnThinkingLevel && (
<ThinkingLevelSelect
value={turnThinkingLevel ?? ""}
onChange={onChangeTurnThinkingLevel}
disabled={busy}
direction="up"
/>
)}
{/* Left of the send button: model selector in draft state; once the Session is created the model is locked, shown read-only (still with the provider logo). */}
{models && onChangeModel ? (
<ModelSelect
models={models}
value={modelRef}
{...(defaultModel !== undefined ? { defaultModel } : {})}
onChange={onChangeModel}
disabled={busy}
/>
) : (
<span
title={modelRef?.modelId ?? ""}
className="flex h-8 min-w-0 max-w-44 shrink items-center gap-1.5 px-1 text-gray-400 dark:text-gray-500"
>
{/* Read-only display in session state: both the logo and the name come from the Session DTO's paired fields (no prefix parsing). */}
<ProviderLogo
provider={modelRef?.provider ?? "custom"}
className="h-4 w-4 shrink-0"
<input
type="file"
accept="image/*"
multiple
className="hidden"
onChange={onPickFiles}
/>
<span className="hidden min-w-0 truncate @md:block">
{(() => {
const m = models?.find((x) => sameModelRef(x, modelRef));
return m ? modelLabel(m) : (modelRef?.modelId ?? "…");
})()}
</span>
</span>
)}
{running ? (
<button
type="button"
title={S.chat.stop}
aria-label={S.chat.stop}
onClick={() => void onStop()}
className="flex h-8 w-8 shrink-0 items-center justify-center rounded-md bg-red-50 text-red-600 transition-colors duration-150 hover:bg-red-100 dark:bg-red-950/60 dark:text-red-400 dark:hover:bg-red-950"
>
<svg width="14" height="14" viewBox="0 0 14 14" aria-hidden className="block">
<rect x="2" y="2" width="10" height="10" rx="2" fill="currentColor" />
</svg>
</button>
) : (
<button
type="button"
title={S.chat.send}
aria-label={S.chat.send}
disabled={!canSend}
onClick={() => void send()}
className="flex h-8 w-8 shrink-0 items-center justify-center rounded-md bg-gray-900 text-white transition-colors duration-150 hover:bg-gray-700 disabled:cursor-not-allowed disabled:bg-gray-200 disabled:text-gray-400 dark:bg-gray-100 dark:text-gray-900 dark:hover:bg-gray-300 dark:disabled:bg-gray-800 dark:disabled:text-gray-600"
>
{/* Up arrow (send) */}
<svg
width="17"
height="17"
viewBox="0 0 24 24"
fill="none"
stroke="currentColor"
strokeWidth="2"
strokeWidth="1.7"
strokeLinecap="round"
strokeLinejoin="round"
aria-hidden
className="block"
>
<path d="M12 19V5M5 12l7-7 7 7" />
<rect x="3" y="5" width="18" height="14" rx="3" />
<path d="M3 15l5-5 4 4 3-3 6 6" />
</svg>
</label>
<ApprovalModeSelect
value={approvalMode}
onChange={onChangeApprovalMode}
disabled={modeSaving}
direction={models && onChangeModel ? "down" : "up"}
/>
{/* Multi-select skills dropdown (after approval mode): selected state is conveyed via the button badge. */}
<SkillSelect
skills={skills}
selected={selectedSkills}
onToggle={toggleSkill}
disabled={running || compacting || busy}
direction={models && onChangeModel ? "down" : "up"}
/>
{/* More settings popover (extensible; currently the mid-run send mode): available in
draft state and while running alike, the choice persists across sessions. */}
<MoreSettingsSelect
steerMode={steerMode}
onChangeSteerMode={setSteerMode}
direction={models && onChangeModel ? "down" : "up"}
/>
{/* Help text: shown only when the card is wide enough (@lg); it never competes for
space on phones, where the group scrolls instead. */}
<span
title={`${S.chat.slashHint} · ${S.chat.mentionHint}`}
className="hidden min-w-0 truncate text-gray-300 @lg:block dark:text-gray-600"
>
{S.chat.slashHint} · {S.chat.mentionHint}
</span>
</div>
{/* Right group: status + model + the single action button; never shrinks. */}
<div className="flex shrink-0 items-center gap-2">
{/* Draft state (model still changeable = no session created yet) has no context usage to speak of: the ring isn't shown, it displays as usual once the session is created. */}
{!onChangeModel && (
<ContextGauge
now={contextNow}
unknown={contextStale}
{...(contextWindow !== undefined ? { window: contextWindow } : {})}
/>
)}
{/* Draft state: conversation-time thinking level (backed by Agent settings), docked left of the model selector. */}
{models && onChangeModel && onChangeThinkingLevel && (
<ThinkingLevelSelect
value={thinkingLevel ?? null}
onChange={onChangeThinkingLevel}
disabled={busy}
/>
)}
{/* Session state: per-turn thinking level (editable) — displays the user's pick,
else the Agent config's level (auto-follow; the parent resolves it). While
untouched nothing rides on tasks; a pick sticks for the session and is sent
with every subsequent send, never writing through to the Agent config. */}
{!onChangeModel && onChangeTurnThinkingLevel && (
<ThinkingLevelSelect
value={turnThinkingLevel ?? ""}
onChange={onChangeTurnThinkingLevel}
disabled={busy}
direction="up"
/>
)}
{/* Left of the send button: model selector in draft state; once the Session is created the model is locked, shown read-only (still with the provider logo). */}
{models && onChangeModel ? (
<ModelSelect
models={models}
value={modelRef}
{...(defaultModel !== undefined ? { defaultModel } : {})}
onChange={onChangeModel}
disabled={busy}
/>
) : (
<span
title={modelRef?.modelId ?? ""}
className="flex h-8 min-w-0 max-w-44 shrink items-center gap-1.5 px-1 text-gray-400 dark:text-gray-500"
>
{/* Read-only display in session state: both the logo and the name come from the Session DTO's paired fields (no prefix parsing). */}
<ProviderLogo
provider={modelRef?.provider ?? "custom"}
className="h-4 w-4 shrink-0"
/>
<span className="hidden min-w-0 truncate @md:block">
{(() => {
const m = models?.find((x) => sameModelRef(x, modelRef));
return m ? modelLabel(m) : (modelRef?.modelId ?? "…");
})()}
</span>
</span>
)}
{/* One action button, never two: while running an empty composer means "Stop"
(abort), and typing turns the very same button into "Send" — which, mid-run,
steers or queues per the remembered More-settings mode. Idle/compacting keeps
the ordinary send button (disabled while compacting via canSend). Merging the
pair keeps the running-state row within a 320px viewport. */}
<button
type="button"
title={stopAction ? S.chat.stop : running ? midRunSendLabel : S.chat.send}
aria-label={stopAction ? S.chat.stop : running ? midRunSendLabel : S.chat.send}
disabled={stopAction ? false : running ? !canMidRunSend : !canSend}
onClick={() => (stopAction ? void onStop() : void send())}
className={
stopAction
? "flex h-8 w-8 shrink-0 items-center justify-center rounded-md bg-red-50 text-red-600 transition-colors duration-150 hover:bg-red-100 dark:bg-red-950/60 dark:text-red-400 dark:hover:bg-red-950"
: "flex h-8 w-8 shrink-0 items-center justify-center rounded-md bg-gray-900 text-white transition-colors duration-150 hover:bg-gray-700 disabled:cursor-not-allowed disabled:bg-gray-200 disabled:text-gray-400 dark:bg-gray-100 dark:text-gray-900 dark:hover:bg-gray-300 dark:disabled:bg-gray-800 dark:disabled:text-gray-600"
}
>
{stopAction ? (
/* Stop (filled square) */
<svg width="14" height="14" viewBox="0 0 14 14" aria-hidden className="block">
<rect x="2" y="2" width="10" height="10" rx="2" fill="currentColor" />
</svg>
) : (
/* Up arrow (send) */
<svg
width="17"
height="17"
viewBox="0 0 24 24"
fill="none"
stroke="currentColor"
strokeWidth="2"
strokeLinecap="round"
strokeLinejoin="round"
aria-hidden
className="block"
>
<path d="M12 19V5M5 12l7-7 7 7" />
</svg>
)}
</button>
)}
</div>
</div>
</div>
</div>
+48 -2
View File
@@ -408,7 +408,7 @@ export function ChatPage() {
// /model switch (handoff-style, mirroring onHandoff exactly): opens a NEW session for the
// SAME agent on the picked model via the normal createSession API — deliberately with the
// SOURCE session's Workspace, so files the conversation refers to stay reachable — then
// posts a first task whose input starts with a <model_switch_from> source block (source
// posts a first task whose input starts with a [model_switch_from] source block (source
// session id / its latest trace path / workspace / previous model pair) followed by the
// user's remainder text and images. The earlier history is NOT injected into the new
// context (some models require thinking payloads and provider fidelity byte-for-byte on
@@ -462,7 +462,7 @@ export function ChatPage() {
// @ handoff: doesn't use the current Session — creates a new chat for the @-mentioned agent
// (approval mode carries over from the input area's current value; model/Workspace use the
// creation defaults). The first input = a <handoff_from> source block (current agent / Session
// creation defaults). The first input = a [handoff_from] source block (current agent / Session
// / Workspace info) + the user's input and images with the @ mention stripped; jumps to the new
// chat once sent.
// Returns false on failure, keeping the draft so it can be resent (deletes the empty Session that never got its first message sent).
@@ -508,6 +508,46 @@ export function ChatPage() {
await api.postAbort(selected.sessionId).catch(() => undefined);
}, [selected]);
// Follow-up queue: post the full input with queueIfBusy — a busy session holds it
// server-side and auto-sends it as an ordinary next task once this run finishes (the
// "N queued" count arrives via task_state). Succeeds either way (queued or started
// directly in the completion race), so the input area clears the draft on true.
const onQueueFollowUp = useCallback(
async (input: TaskInputPart[]): Promise<boolean> => {
if (!selected) return false;
try {
const res = await api.postTask(selected.sessionId, { input, queueIfBusy: true });
discardSessionDraft();
await syncHealedSessionId(selected.sessionId, res.sessionId);
return true;
} catch (e) {
toastError(apiErrorText(e, { modelId: selected.modelId }));
return false;
}
},
[selected, discardSessionDraft, syncHealedSessionId],
);
// Mid-run steering: the text is queued on the server and delivered between turns as a
// standalone `[user_steering]` user message (visible once it arrives over SSE / from the
// Trace). "not_running" (409) means no Task is in progress anymore (race with completion):
// the input area then falls back to its **full** normal send path — images / skills / the
// whole draft included — rather than a text-only task.
const onSteer = useCallback(
async (text: string): Promise<"queued" | "not_running" | "failed"> => {
if (!selected) return "failed";
try {
await api.postSteer(selected.sessionId, { text });
return "queued";
} catch (e) {
if (e instanceof ApiError && e.status === 409) return "not_running";
toastError(apiErrorText(e, { modelId: selected.modelId }));
return "failed";
}
},
[selected],
);
const onApprove = useCallback(
async (toolCallId: string, decision: "allow" | "deny", origin: string[]) => {
if (!selected) return;
@@ -611,6 +651,12 @@ export function ChatPage() {
<ChatInput
status={stream.taskState}
onSend={onSend}
onSteer={onSteer}
// Count of steering messages already visible in the stream: the input area keeps its
// "queued" indicator up until this count increases (i.e. the steering message arrived).
steeringDeliveredCount={stream.model.items.filter((i) => i.kind === "user_steering").length}
onQueueFollowUp={onQueueFollowUp}
queuedFollowUps={stream.queuedFollowUps}
onStop={onStop}
onCompact={onCompact}
modelRef={activeModelRef}
@@ -88,7 +88,7 @@ function saveAppliedRouteKey(field: RouteStateField, key: string): void {
/**
* 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,
* `[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[] }[] = [
@@ -258,7 +258,7 @@ 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. */
/** 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));
@@ -442,7 +442,7 @@ export function DraftView({
// @ 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.
// [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> => {
@@ -1,8 +1,8 @@
/**
* Provenance banners for conversations opened from another conversation — each collapses a
* machine-inserted source block (the raw text is never shown; the model still sees it):
* - `HandoffBanner` (`<handoff_from>`, @ delegation): "Handed off from <agent>'s chat";
* - `ModelSwitchBanner` (`<model_switch_from>`, the /model command): "switched model —
* - `HandoffBanner` (`[handoff_from]`, @ delegation): "Handed off from <agent>'s chat";
* - `ModelSwitchBanner` (`[model_switch_from]`, the /model command): "switched model —
* continued from the earlier conversation".
* When there's a source Session, the whole line is clickable and jumps back to it (the
* source Session's title goes into the title hover tooltip, taking no space in the body).
@@ -43,7 +43,7 @@ export function HandoffBanner({ origin }: { origin: HandoffOrigin }) {
}
/**
* Notice for a conversation opened by the `/model` switch (`<model_switch_from>` first
* Notice for a conversation opened by the `/model` switch (`[model_switch_from]` first
* message): a single line naming the previous model, clickable to jump back to the source
* session — the same interaction as the handoff banner's back-link.
*/
@@ -34,6 +34,10 @@ import type { StreamRenderContext } from "./message-stream";
/** Duration the "Copied" tooltip stays visible (milliseconds). */
const COPIED_MS = 1200;
/** User glyph (24×24 line path) for the mid-run steering chip. */
const USER_STEERING_ICON =
"M20 21v-2a4 4 0 0 0-4-4H8a4 4 0 0 0-4 4v2M16 7a4 4 0 1 1-8 0 4 4 0 0 1 8 0";
/**
* Message footer: timestamp + copy. **Invisible but takes up space by default** (`opacity-0`
* rather than `hidden`) — it surfaces on hovering the message list, and because the space is
@@ -143,6 +147,32 @@ export function MessageItem({ item, ctx }: { item: ChatItem; ctx: StreamRenderCo
</>
);
}
case "user_steering":
// Mid-run steering ([user_steering]-wrapped user text delivered between turns): a
// compact right-aligned user-styled chip inside the running Task's flow — visually
// lighter than a full prompt bubble, since it doesn't start a new Task (the Trace page
// still shows the raw marker text as-is).
return (
<div className="anim-msg group my-2 flex flex-col items-end">
<div className="flex max-w-[88%] items-start gap-1.5 rounded-md border border-gray-200 bg-gray-100 px-3 py-1.5 md:max-w-[75%] dark:border-gray-700 dark:bg-gray-800">
<GlyphIcon
d={USER_STEERING_ICON}
className="mt-1 shrink-0 text-gray-400 dark:text-gray-500"
/>
<p className="wrap-anywhere whitespace-pre-wrap text-sm leading-relaxed text-gray-800 dark:text-gray-100">
<span className="mr-1.5 text-xs font-semibold text-gray-500 dark:text-gray-400">
{S.chat.userSteering}
</span>
{item.text}
</p>
</div>
<MessageMeta
{...(item.atMs !== undefined ? { atMs: item.atMs } : {})}
text={item.text}
align="right"
/>
</div>
);
case "user_image":
return (
<div className="anim-msg group my-4 flex flex-col items-end">
@@ -1,5 +1,5 @@
/**
* Origin hint for a scheduled-task-triggered message: the origin block (<scheduled_task>) is
* Origin hint for a scheduled-task-triggered message: the origin block ([scheduled_task]) is
* not rendered verbatim on screen; it collapses into one line reading "Triggered by scheduled
* task '<name>'" plus a localized trigger timestamp (a static display with no navigation — task
* management lives on the Agent settings page's "Scheduled Tasks" tab). The task prompt body
+9 -40
View File
@@ -1,52 +1,21 @@
/**
* Skill invocation for the chat input area (pure logic, shared by chat-input / message-item /
* the Skill library page, and unit tests).
* The `<use_skills>` block is a globally agreed-upon format (shared by frontend, backend, and the
* core prompt template):
* Skill-invocation helpers for the chat input area (pure logic, shared by chat-input /
* message-item / the Skill library page, and unit tests).
*
* <use_skills>
* skills: name1, name2
* </use_skills>
* (blank line) body text…
*
* - `buildSkillsMessage`: prepends the source block to the body when selected skills are
* non-empty; returns the body unchanged for an empty list;
* - `parseSkillsMessage`: only recognizes a block at **the start of the message**, returning the
* skill names and the remaining body (the message stream collapses the raw block into a
* "Skill used" banner and renders the body normally; the Trace page shows it as-is);
* - `localizedText` / `skillSlashItems`: pure assembly of UI-language text lookup and slash
* skill command items.
* The `[use_skills]` marker block itself is **not** defined here: it is a globally agreed
* format shared by the frontend, the backend and the core prompt template, so its producer
* and parser live in core's marker module (`@prismshadow/penguin-core/markers`) and are
* re-exported below for this feature's existing importers. What stays local is the UI-only
* part: icon path, UI-language text selection, dropdown filtering and slash command items.
*/
import type { SkillMetadataItem } from "@prismshadow/penguin-server/api";
export { buildSkillsMessage, parseSkillsMessage } from "@prismshadow/penguin-core/markers";
/** Book icon (24×24 line path): shared across skill-related UI (nav items are inlined separately in sidebar / app-layout). */
export const BOOK_ICON =
"M2 3h6a4 4 0 0 1 4 4v14a3 3 0 0 0-3-3H2zM22 3h-6a4 4 0 0 0-4 4v14a3 3 0 0 1 3-3h7z";
/** Generates a message body with a `<use_skills>` block: an empty list omits the block; when there's no body text, only the block is returned (no trailing blank line). */
export function buildSkillsMessage(names: string[], text: string): string {
if (names.length === 0) return text;
const block = `<use_skills>\nskills: ${names.join(", ")}\n</use_skills>`;
return text ? `${block}\n\n${text}` : block;
}
/**
* Reverse parse of `buildSkillsMessage`: when the message **starts with** a `<use_skills>`
* block, returns the skill names and the remaining body; otherwise returns null (a block
* appearing mid-body is treated as plain text and not parsed). The `skills:` line is split by
* comma and whitespace-trimmed; an empty list is treated as not a source block.
*/
export function parseSkillsMessage(text: string): { skills: string[]; rest: string } | null {
const m = /^<use_skills>\nskills: ([^\n]+)\n<\/use_skills>/.exec(text);
if (!m) return null;
const skills = m[1]!
.split(",")
.map((s) => s.trim())
.filter((s) => s.length > 0);
if (skills.length === 0) return null;
return { skills, rest: text.slice(m[0].length).replace(/^\n+/, "") };
}
/**
* Picks copy based on the UI language: uses the Chinese value when locale is zh and one is
* provided, otherwise falls back to English. Shared by the skill library page's group
@@ -1,5 +1,5 @@
/**
* Source notice for a skill invocation: the `<use_skills>` block at the start of a message isn't
* Source notice for a skill invocation: the `[use_skills]` block at the start of a message isn't
* shown verbatim, it's collapsed into a single line reading "Using skills: <names>" (book icon +
* static text, no navigation — skill management lives on the skill library page); the body text
* after the block is rendered as usual by the caller.
@@ -36,6 +36,8 @@ export interface SessionStreamState {
/** True until history finishes loading. */
loading: boolean;
taskState: SessionStatus;
/** Queued follow-up count from the stream's task_state events (auto-sent once the session is idle). */
queuedFollowUps: number;
/** approvalKey(origin, toolCallId) → pending approval. */
pendingApprovals: ReadonlyMap<string, PendingApproval>;
/** Recorded when this client clicks an approval decision (marks it as "manual"). */
@@ -61,6 +63,7 @@ export function useSessionStream(
const [version, setVersion] = useState(0);
const [loading, setLoading] = useState(true);
const [taskState, setTaskState] = useState<SessionStatus>(initialStatus);
const [queuedFollowUps, setQueuedFollowUps] = useState(0);
const [error, setError] = useState<string | null>(null);
const [pendingTick, setPendingTick] = useState(0);
const onTitleRef = useRef(onSessionTitle);
@@ -111,6 +114,7 @@ export function useSessionStream(
controllerRef.current?.dispose();
controllerRef.current = null;
setTaskState("idle");
setQueuedFollowUps(0);
setLoading(false);
setError(null);
setPendingTick((t) => t + 1);
@@ -123,11 +127,13 @@ export function useSessionStream(
// First-frame placeholder: the task_state snapshot from the stream (pushed on subscribe)
// subsequently overrides it as the authoritative state.
setTaskState(initialStatus);
setQueuedFollowUps(0);
setPendingTick((t) => t + 1);
const controller = createStreamController({
loadMessages: async () => (await getMessages(sessionId)).messages,
onTaskState: setTaskState,
onQueuedFollowUps: setQueuedFollowUps,
onLoading: setLoading,
onError: setError,
onModelChange: bump,
@@ -187,6 +193,7 @@ export function useSessionStream(
version,
loading,
taskState,
queuedFollowUps,
pendingApprovals: controllerRef.current?.pendingApprovals ?? EMPTY_PENDING,
markLocalDecision,
resolveApproval,