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:
1 parent
abd5b13d52
commit
7584171482
81 files changed
+3139
-930
No files matched your search
@@ -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+/, "") };
|
||||
}
|
||||
@@ -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>
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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,
|
||||
|
||||
Reference in new issue
Block a user