feat(core,cli,server,web): add goal mode — loop Tasks on one Session until an objective completes (#66)

Co-authored-by: Claude Fable 5 <noreply@anthropic.com>
This commit is contained in:
rank-Yu
2026-07-27 23:43:24 +08:00
committed by GitHub
parent e1141ca010
commit 46463bee26
61 changed files with 3411 additions and 188 deletions
+4
View File
@@ -25,6 +25,7 @@ import type {
DirListResponse,
FilesStatRequest,
FilesStatResponse,
GoalResponse,
MeResponse,
MemberAddRequest,
MemberAddResponse,
@@ -255,6 +256,9 @@ export const postTask = (sessionId: string, body: TaskCreateRequest) =>
body,
});
export const getGoal = (sessionId: string) =>
apiFetch<GoalResponse>(`/api/sessions/${encodeURIComponent(sessionId)}/goal`);
export const postApproval = (
sessionId: string,
toolCallId: string,
+11 -2
View File
@@ -33,6 +33,7 @@ export function Dropdown({
menuStyle,
className,
portal,
onEscape,
}: {
button: ReactNode;
open: boolean;
@@ -46,6 +47,12 @@ export function Dropdown({
className?: string;
/** Render the panel through a body portal at fixed viewport coordinates, escaping any clipping ancestor. */
portal?: DropdownPortal;
/**
* When provided, the window-level Escape calls this instead of setOpen(false) — for panels
* whose close-request semantics differ from a plain dismiss (e.g. an editor where outside
* click means "commit" but Escape means "cancel"). Outside clicks still call setOpen(false).
*/
onEscape?: () => void;
}) {
const ref = useRef<HTMLDivElement>(null);
const panelRef = useRef<HTMLDivElement>(null);
@@ -66,7 +73,9 @@ export function Dropdown({
setOpen(false);
};
const onKey = (e: KeyboardEvent) => {
if (e.key === "Escape") setOpen(false);
if (e.key !== "Escape") return;
if (onEscape) onEscape();
else setOpen(false);
};
window.addEventListener("mousedown", onClick);
window.addEventListener("keydown", onKey);
@@ -74,7 +83,7 @@ export function Dropdown({
window.removeEventListener("mousedown", onClick);
window.removeEventListener("keydown", onKey);
};
}, [open, setOpen]);
}, [open, setOpen, onEscape]);
// Portal mode: place the panel against the trigger's viewport rect, clamped to stay fully
// on-screen. Measured after paint so the panel's own (class-driven) size is known — the
+416 -76
View File
@@ -39,9 +39,9 @@
* 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.
* [user_steering] user message) or queue-as-follow-up — chosen from the "+" menu's settings
* row (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.
@@ -49,7 +49,7 @@
* centering is decided by the page.
*/
import { useCallback, useEffect, useMemo, useRef, useState } from "react";
import type { ChangeEvent, ClipboardEvent, KeyboardEvent } from "react";
import type { ChangeEvent, ClipboardEvent, KeyboardEvent, ReactNode } from "react";
import type {
AgentSummary,
ApprovalMode,
@@ -85,6 +85,7 @@ import {
localizedShortText,
skillSlashItems,
} from "./skill-use";
import { GOAL_ICON, UNLIMITED_BUDGET, parseBudgetInput } from "./goal-use";
const APPROVAL_MODES: ApprovalMode[] = ["always-ask", "read-only", "allow-all", "deny-all"];
@@ -532,7 +533,7 @@ 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
* `penguin.*` key); configurable from the "+" menu's settings row in draft state and active
* sessions alike.
*/
type SteerMode = "steer" | "followup";
@@ -541,40 +542,34 @@ function initialSteerMode(): SteerMode {
return localStorage.getItem(STEER_MODE_KEY) === "followup" ? "followup" : "steer";
}
/** Sliders icon (24×24 line path) for the More-settings popover button. */
/** Sliders icon (24×24 line path) for the mid-run send-mode settings row. */
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.
* The mid-run send mode row, rendered as the "+" menu's settings footer: Steer (default) /
* Queue as a follow-up, the full explanation hover-only via each pill's title (the toolbar's
* "full meaning on hover" convention). Laid out like the menu's items — leading icon, label,
* the control where an item's description sits — so the menu reads as one list. Clicking a
* pill keeps the menu open — it's a setting, not an action — and the row is never disabled:
* the preference is settable before and during a run.
*/
function MoreSettingsSelect({
function SteerModeRow({
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.
// Compact pills, no bordered wrapper: the control must not out-height an item's 16px text
// line by more than the row paddings absorb — h-5 pills inside py-1 land the row at the
// same 28px an item's text + py-1.5 does, so the menu keeps one line rhythm.
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 ${
className={`h-5 rounded px-1.5 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"
@@ -584,38 +579,20 @@ function MoreSettingsSelect({
</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 className="flex w-full items-center gap-2 px-3 py-1 text-xs">
<GlyphIcon d={SLIDERS_ICON} size={14} className="shrink-0 text-gray-400 dark:text-gray-500" />
<span className="min-w-0 flex-1 truncate text-gray-600 dark:text-gray-400">
{S.chat.steerModeLabel}
</span>
<div
role="group"
aria-label={S.chat.steerModeLabel}
className="flex shrink-0 items-center gap-0.5"
>
{modeButton("steer", S.chat.steerModeSteer, S.chat.steerModeSteerHint)}
{modeButton("followup", S.chat.steerModeFollowUp, S.chat.steerModeFollowUpHint)}
</div>
</Dropdown>
</div>
);
}
@@ -747,6 +724,92 @@ function SkillSelect({
);
}
/** One entry of the composer's "+" extension menu. */
interface PlusMenuItem {
key: string;
icon: string;
label: string;
desc: string;
/** Whether the entry is currently engaged (rendered with a check mark; clicking toggles). */
active: boolean;
/** Grayed out and inert (e.g. goal mode while a run is in progress); the menu still opens. */
disabled?: boolean;
onSelect: () => void;
}
/**
* The composer's "+" extension menu: a general-purpose entry point for input add-ons (goal
* mode today; future modes, plugins, apps, files slot in as further items) plus input
* settings (`footer`, currently the mid-run send mode row). Data-driven — the caller passes
* the item list and footer; the menu itself knows nothing about the entries. The button is
* never disabled: settings must stay reachable during a run, so unavailable *items* gray out
* individually instead.
*/
function PlusMenu({
items,
footer,
direction = "up",
}: {
items: PlusMenuItem[];
footer?: ReactNode;
direction?: "up" | "down";
}) {
const [open, setOpen] = useState(false);
return (
<Dropdown
open={open}
setOpen={setOpen}
// Placement is portal-driven like the rest of the toolbar (its scroll container would
// clip an absolutely-positioned panel); only size classes belong here.
menuClass="w-72"
portal={{ direction, align: "left" }}
button={
<button
type="button"
aria-label={S.chat.plusMenu}
title={S.chat.plusMenu}
onClick={() => setOpen(!open)}
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="M12 5v14M5 12h14" size={15} className="shrink-0" />
</button>
}
>
{items.map((item) => (
<button
key={item.key}
type="button"
aria-pressed={item.active}
disabled={item.disabled}
onClick={() => {
setOpen(false);
item.onSelect();
}}
className={`flex w-full items-center gap-2 px-3 py-1.5 text-left text-xs transition-colors duration-150 hover:bg-gray-100 disabled:cursor-not-allowed disabled:opacity-50 disabled:hover:bg-transparent dark:hover:bg-gray-800 dark:disabled:hover:bg-transparent ${
item.active
? "font-medium text-gray-900 dark:text-gray-100"
: "text-gray-600 dark:text-gray-400"
}`}
>
<GlyphIcon
d={item.icon}
size={14}
className="shrink-0 text-gray-400 dark:text-gray-500"
/>
<span className="shrink-0">{item.label}</span>
<span className="min-w-0 flex-1 truncate text-gray-400 dark:text-gray-500">
{item.desc}
</span>
<span className="w-3 shrink-0 text-center">{item.active ? "✓" : ""}</span>
</button>
))}
{footer && (
<div className="mt-1 border-t border-gray-100 pt-1 dark:border-gray-800">{footer}</div>
)}
</Dropdown>
);
}
interface SlashCommand {
cmd: string;
desc: string;
@@ -862,8 +925,12 @@ export function ChatInput({
onNewSession,
}: {
status: SessionStatus;
/** Returns whether it succeeded: on failure the input draft is kept (not cleared). */
onSend: (input: TaskInputPart[]) => Promise<boolean>;
/**
* Returns whether it succeeded: on failure the input draft is kept (not cleared).
* `goal` is non-null when goal mode is engaged: the text is the objective and the server
* loops the Session until the goal reaches a terminal state (budget -1 = unlimited).
*/
onSend: (input: TaskInputPart[], goal: { budget: number } | null) => 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
@@ -1038,32 +1105,125 @@ export function ChatInput({
const running = status === "running";
const compacting = status === "compacting";
// Goal mode (engaged via the "+" menu or /goal): the text body becomes the objective. It is
// exclusive with the @ handoff target (engaging either clears the other) and with images
// (the objective is re-injected every round as plain text); selected skills ride the
// round-1 message as a [use_skills] block, exactly like a normal send.
const [goalOn, setGoalOn] = useState(false);
const [goalBudgetText, setGoalBudgetText] = useState("");
const [goalBudgetOpen, setGoalBudgetOpen] = useState(false);
const [goalBudgetDraft, setGoalBudgetDraft] = useState("");
/** The committed budget is always valid: the popover keeps invalid edits in its local draft. */
const goalBudget = goalOn ? parseBudgetInput(goalBudgetText) : null;
const goalBudgetDraftValue = parseBudgetInput(goalBudgetDraft);
const goalBudgetDraftInvalid = goalBudgetDraftValue === null;
const goalBudgetSummary =
goalBudget !== null && goalBudget !== UNLIMITED_BUDGET
? S.chat.goalBudgetValue(humanizeTokens(goalBudget))
: S.chat.goalBudgetUnlimited;
// 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
// text automatically falls back to S.chat.skillsAutoMessage (see send).
// text automatically falls back to S.chat.skillsAutoMessage (see send). Goal mode instead
// requires a text objective and a parseable budget — and an open editor showing an invalid
// draft disables Send outright: combined with the editor refusing to close over an invalid
// draft (below), no click sequence can fire a goal with a stale committed budget.
const canSend =
!running &&
!compacting &&
!busy &&
!modelAuthDead &&
(text.trim().length > 0 || images.length > 0 || target !== null || selectedSkills.length > 0);
(goalOn
? text.trim().length > 0 &&
images.length === 0 &&
goalBudget !== null &&
!(goalBudgetOpen && goalBudgetDraftInvalid)
: text.trim().length > 0 ||
images.length > 0 ||
target !== null ||
selectedSkills.length > 0);
/**
* The budget editor is a fixed upward popover. Opening copies the committed value; closing
* commits a valid draft — so typing a budget and clicking straight onto Send keeps it (the
* Send mousedown closes the popover before the click lands). An INVALID draft refuses to
* close: silently reverting would let the very next click fire the goal with the stale
* committed budget — fix the draft or cancel with Escape (cancelGoalBudget below).
*/
const setGoalBudgetEditorOpen = useCallback(
(open: boolean) => {
if (open) {
setGoalBudgetDraft(goalBudgetText);
setGoalBudgetOpen(true);
return;
}
if (parseBudgetInput(goalBudgetDraft) === null) return;
setGoalBudgetText(goalBudgetDraft.trim());
setGoalBudgetOpen(false);
},
[goalBudgetText, goalBudgetDraft],
);
/**
* Cancel the budget editor: close WITHOUT committing (reopening re-copies the committed
* value). Wired to the Dropdown's window-level Escape, so it is genuinely
* focus-independent — including after an invalid draft refused an outside-click close and
* focus already left the chip (e.g. sits in the objective textarea).
*/
const cancelGoalBudget = useCallback(() => {
setGoalBudgetOpen(false);
textareaRef.current?.focus();
}, []);
/** Commit only valid input; Enter and the check button share this path. */
const saveGoalBudget = useCallback(() => {
if (parseBudgetInput(goalBudgetDraft) === null) return;
setGoalBudgetText(goalBudgetDraft.trim());
setGoalBudgetOpen(false);
textareaRef.current?.focus();
}, [goalBudgetDraft]);
/**
* Engage/exit goal mode; engaging clears the @ target and images (genuinely exclusive:
* a handoff opens another session, and the server rejects non-text goal input). Selected
* skills stay — they ride the round-1 message as a [use_skills] block, like a normal send.
*/
const toggleGoal = useCallback(
(on: boolean) => {
setGoalOn(on);
setGoalBudgetOpen(false);
setGoalBudgetDraft("");
if (on) {
setGoalBudgetText("");
setTarget(null);
onHandoffTargetChange?.(null);
// Images can't ride a goal (the server rejects non-text goal input): clear any already
// attached, or canSend would stay silently false with the objective looking ready.
setImages([]);
}
},
[onHandoffTargetChange],
);
// 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).
// `!goalOn`: with the goal chip engaged the text is an OBJECTIVE — steering it into a run
// that happens to be active (e.g. a schedule fired) would silently repurpose it.
const canSteer =
running &&
!busy &&
!goalOn &&
!modelAuthDead &&
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.
// an ordinary next task once this run finishes). Set from the "+" menu's settings row —
// 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);
@@ -1075,6 +1235,7 @@ export function ChatInput({
const canFollowUp =
running &&
!busy &&
!goalOn &&
!modelAuthDead &&
followUpMode &&
(text.trim().length > 0 || images.length > 0 || target !== null || selectedSkills.length > 0);
@@ -1133,6 +1294,14 @@ export function ChatInput({
void onCompact();
},
},
{
cmd: "/goal",
desc: S.chat.goalModeDesc,
run: () => {
clearInput();
toggleGoal(!goalOn);
},
},
// Model switch (active idle session only — the parent passes onSwitchModel just there;
// draft state has its own model picker). Gated on the model list being loaded: without
// it the picker would open empty. Running the command consumes the /model token (like
@@ -1159,7 +1328,17 @@ export function ChatInput({
},
})),
];
}, [onCompact, onSwitchModel, models, onTextChange, skills, locale, toggleSkill]);
}, [
onCompact,
onSwitchModel,
models,
onTextChange,
skills,
locale,
toggleSkill,
toggleGoal,
goalOn,
]);
// Positional matching (like @ mentions): a slash opens the menu from any caret position;
// running a command removes just the token, leaving the rest of the text intact. Doesn't
// reopen after Escape until the caret sits on a different token; suppressed while the
@@ -1341,6 +1520,7 @@ export function ChatInput({
el.focus();
setTarget(agent);
onHandoffTargetChange?.(agent.agentId);
setGoalOn(false); // exclusive with goal mode: picking an @ target exits it (latest wins)
setText(value);
onTextChange?.(value);
setCaret(start);
@@ -1357,8 +1537,35 @@ export function ChatInput({
* 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) => {
// `post` accepts onSend's goal parameter so onSend can be its default; the follow-up queue
// (fewer params) is assignable too. Non-goal calls always pass null.
const sendNormal = async (
post: (input: TaskInputPart[], goal: { budget: number } | null) => Promise<boolean> = onSend,
) => {
const t = text.trim();
// Goal mode: the trimmed text is the objective (no images, no @ handoff — cleared/blocked
// while the chip is on; a leading @ stays plain text). Selected skills prefix the round-1
// message as a [use_skills] block, exactly like a normal send — the server strips leading
// marker blocks when recording the objective, and rounds after the first re-inject the
// objective alone.
if (goalOn) {
setBusy(true);
try {
const ok = await onSend([{ type: "text", text: buildSkillsMessage(selectedSkills, t) }], {
budget: goalBudget!,
});
if (ok) {
setText("");
setSelectedSkills([]);
toggleGoal(false);
requestAnimationFrame(autoGrow);
}
} finally {
setBusy(false);
textareaRef.current?.focus();
}
return;
}
// @ 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
// handoff to a new chat, the current Session isn't sent to, and the text carries no @ marker.
@@ -1375,7 +1582,7 @@ export function ChatInput({
for (const url of images) input.push({ type: "image_url", imageUrl: url });
setBusy(true);
try {
const ok = lead ? await onHandoff(lead.agent, input) : await post(input);
const ok = lead ? await onHandoff(lead.agent, input) : await post(input, null);
// Only clear the draft after a successful send: on failure (network / conflict / server error) keep the user's input and images.
if (ok) {
setText("");
@@ -1496,6 +1703,9 @@ export function ChatInput({
};
const addFiles = (files: Iterable<File>) => {
// Goal mode is text-only (the objective is re-injected each round): drop image attachments
// outright — including pastes — so send never lands in a silently-disabled state.
if (goalOn) return;
for (const file of files) {
if (!file.type.startsWith("image/")) continue;
const reader = new FileReader();
@@ -1721,8 +1931,120 @@ export function ChatInput({
{/* Chip row above the text body: the @ handoff target (fixed at the front — send-time
@ semantics stay leading-only) followed by the selected skills, mirroring the
agent chip's look. Remove buttons recolor the x on hover (no background wash). */}
{(target !== null || selectedSkills.length > 0) && (
{(target !== null || selectedSkills.length > 0 || goalOn) && (
<div className="mb-1 flex flex-wrap items-center gap-1">
{/* Goal-mode chip: the budget stays compact as a value button; its editor is a
fixed upward popover so it never covers the objective textarea below. */}
{goalOn && (
<span className="anim-pop flex max-w-full items-center gap-1 rounded-md bg-gray-100 py-0.5 pl-2 pr-1 text-sm text-gray-800 dark:bg-gray-800 dark:text-gray-200">
<span className="flex shrink-0 items-center gap-1" title={S.chat.goalModeDesc}>
<GlyphIcon d={GOAL_ICON} size={13} className="text-gray-500 dark:text-gray-400" />
<span>{S.chat.goalMode}</span>
</span>
<span
aria-hidden
className="mx-0.5 h-4 w-px shrink-0 bg-gray-300 dark:bg-gray-600"
/>
<Dropdown
open={goalBudgetOpen}
setOpen={setGoalBudgetEditorOpen}
onEscape={cancelGoalBudget}
className="min-w-0"
menuClass="bottom-full left-1/2 -ml-32 mb-2 w-64 max-w-[calc(100vw-2rem)] origin-bottom"
button={
<button
type="button"
aria-label={goalBudgetSummary}
aria-expanded={goalBudgetOpen}
onClick={() => setGoalBudgetEditorOpen(!goalBudgetOpen)}
className="flex h-5 min-w-0 items-center gap-1 rounded px-1.5 text-xs text-gray-600 transition-colors duration-150 hover:bg-white/80 hover:text-gray-900 dark:text-gray-300 dark:hover:bg-gray-700 dark:hover:text-white"
>
<span className="truncate">{goalBudgetSummary}</span>
<svg
width="9"
height="9"
viewBox="0 0 12 12"
fill="none"
stroke="currentColor"
strokeWidth="1.5"
strokeLinecap="round"
strokeLinejoin="round"
className="shrink-0"
aria-hidden
>
<path d="M3 4.5l3 3 3-3" />
</svg>
</button>
}
>
<div className="px-3 py-2">
<label
htmlFor="goal-budget-input"
className="block text-xs font-medium text-gray-700 dark:text-gray-200"
>
{S.chat.goalBudgetLabel}
</label>
<div className="mt-1.5 flex items-center gap-1.5">
<input
id="goal-budget-input"
autoFocus
value={goalBudgetDraft}
onChange={(e) => setGoalBudgetDraft(e.target.value)}
onFocus={(e) => e.currentTarget.select()}
onKeyDown={(e) => {
// Escape is handled at the window level (Dropdown onEscape →
// cancelGoalBudget), so it cancels no matter where focus sits.
if (e.key === "Enter") {
e.preventDefault();
e.stopPropagation();
saveGoalBudget();
}
}}
placeholder={S.chat.goalBudgetPlaceholder}
aria-invalid={goalBudgetDraftInvalid}
aria-describedby="goal-budget-hint"
title={
goalBudgetDraftInvalid ? S.chat.goalBudgetInvalid : S.chat.goalBudgetHint
}
className={`min-w-0 flex-1 rounded-md border bg-white px-2 py-1 font-mono text-sm leading-5 placeholder:text-gray-400 focus:outline-none focus:ring-2 dark:bg-gray-950 dark:placeholder:text-gray-500 ${
goalBudgetDraftInvalid
? "border-red-400 text-red-600 focus:border-red-500 focus:ring-red-400/20 dark:border-red-500 dark:text-red-400"
: "border-gray-300 text-gray-800 focus:border-gray-500 focus:ring-gray-400/20 dark:border-gray-700 dark:text-gray-100 dark:focus:border-gray-500"
}`}
/>
<button
type="button"
aria-label={S.chat.goalBudgetSave}
title={S.chat.goalBudgetSave}
disabled={goalBudgetDraftInvalid}
onClick={saveGoalBudget}
className="flex h-7 w-7 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:opacity-35 dark:bg-gray-100 dark:text-gray-900 dark:hover:bg-white"
>
<GlyphIcon d="M5 12l4 4L19 6" size={14} />
</button>
</div>
<p
id="goal-budget-hint"
className={`mt-1.5 text-[11px] leading-4 ${
goalBudgetDraftInvalid
? "text-red-500 dark:text-red-400"
: "text-gray-400 dark:text-gray-500"
}`}
>
{goalBudgetDraftInvalid ? S.chat.goalBudgetInvalid : S.chat.goalBudgetHint}
</p>
</div>
</Dropdown>
<button
type="button"
aria-label={S.chat.goalRemove}
onClick={() => toggleGoal(false)}
className="shrink-0 rounded p-0.5 text-gray-400 transition-colors duration-150 hover:text-gray-700 dark:hover:text-gray-200"
>
×
</button>
</span>
)}
{target !== null && (
<span
className="anim-pop flex max-w-48 items-center gap-0.5 rounded-md bg-gray-100 py-0.5 pl-2 pr-1 font-mono text-sm text-gray-800 dark:bg-gray-800 dark:text-gray-200"
@@ -1832,13 +2154,20 @@ export function ChatInput({
<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}
className={`flex h-8 w-8 shrink-0 items-center justify-center rounded-md text-gray-400 transition-colors duration-150 ${
goalOn
? "cursor-not-allowed opacity-40"
: "cursor-pointer hover:bg-gray-100 hover:text-gray-600 dark:hover:bg-gray-800 dark:hover:text-gray-300"
}`}
title={
goalOn ? S.chat.goalModeDesc : vision ? S.chat.imageAlt : S.chat.imagesAsPathHint
}
>
<input
type="file"
accept="image/*"
multiple
disabled={goalOn}
className="hidden"
onChange={onPickFiles}
/>
@@ -1872,11 +2201,22 @@ export function ChatInput({
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}
{/* "+" extension menu (input add-ons; goal mode today, more entries later) plus
the input settings footer (mid-run send mode — usable while running, which is
exactly when it matters, so the button itself never disables). */}
<PlusMenu
items={[
{
key: "goal",
icon: GOAL_ICON,
label: S.chat.goalMode,
desc: S.chat.goalModeDesc,
active: goalOn,
disabled: running || compacting || busy,
onSelect: () => toggleGoal(!goalOn),
},
]}
footer={<SteerModeRow steerMode={steerMode} onChangeSteerMode={setSteerMode} />}
direction={models && onChangeModel ? "down" : "up"}
/>
{/* Help text: shown only when the card is wide enough (@lg); it never competes for
@@ -1948,9 +2288,9 @@ export function ChatInput({
)}
{/* 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. */}
steers or queues per the remembered send mode (the "+" menu's settings row).
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}
+11 -2
View File
@@ -54,6 +54,7 @@ import { MessageStream } from "./message-stream";
import type { StreamRenderContext } from "./message-stream";
import { ChatInput } from "./chat-input";
import { DraftView } from "./draft-view";
import { GoalStatusBanner } from "./goal-banner";
import { handoffMessage, modelSwitchMessage } from "./agent-mentions";
import { sameModelRef } from "../models/model-grouping";
import { providerInfo } from "@prismshadow/penguin-core/model-catalog";
@@ -486,7 +487,7 @@ export function ChatPage() {
);
const onSend = useCallback(
async (input: TaskInputPart[]): Promise<boolean> => {
async (input: TaskInputPart[], goal: { budget: number } | null): Promise<boolean> => {
if (!selected) return false;
try {
// An explicitly picked per-turn thinking level rides on each task; "" (untouched)
@@ -494,6 +495,7 @@ export function ChatPage() {
// keep taking effect mid-session until the user pins a level.
const res = await api.postTask(selected.sessionId, {
input,
...(goal ? { goal } : {}),
...(turnThinkingLevel
? { thinkingLevel: turnThinkingLevel as TaskCreateRequest["thinkingLevel"] }
: {}),
@@ -1004,7 +1006,14 @@ export function ChatPage() {
)}
</div>
<div className="shrink-0 border-t border-gray-200 bg-white px-3 pb-[calc(0.75rem+env(safe-area-inset-bottom))] pt-3 md:pb-3 dark:border-gray-800 dark:bg-gray-950">
<div className="mx-auto max-w-3xl">{input}</div>
<div className="mx-auto max-w-3xl">
{/* Goal banner docked above the composer: an in-flight goal's progress
(restored on load while still active), or the terminal state reached
during this page's lifetime. The stop button is the composer's
regular stop (one abort ends the whole goal loop). */}
{stream.goal && <GoalStatusBanner goal={stream.goal} />}
{input}
</div>
</div>
</>
)
@@ -386,7 +386,11 @@ export function DraftView({
// that did not consume the composer text (the example task), so a typed-but-unsent draft
// survives the navigation instead of being silently discarded.
const onSend = useCallback(
async (input: TaskInputPart[], keepDraft = false): Promise<boolean> => {
async (
input: TaskInputPart[],
keepDraft = false,
goal: { budget: number } | null = null,
): Promise<boolean> => {
if (!agentId || sendingRef.current) return false;
sendingRef.current = true;
setSending(true);
@@ -401,7 +405,7 @@ export function DraftView({
if (workspace.trim()) body.workspace = workspace.trim();
const created = await api.createSession(projectId, agentId, body);
createdId = created.session.sessionId;
const res = await api.postTask(createdId, { input });
const res = await api.postTask(createdId, { input, ...(goal ? { goal } : {}) });
add(created.session);
if (!keepDraft) discardDraft();
navigate(`/chat/${res.sessionId}`, { replace: true });
@@ -518,7 +522,7 @@ export function DraftView({
<ChatInput
status="idle"
onSend={onSend}
onSend={(input, goal) => onSend(input, false, goal)}
onStop={async () => undefined}
onCompact={async () => undefined}
modelRef={modelRef}
@@ -0,0 +1,73 @@
/**
* Goal-mode banners.
*
* - `GoalRoundBanner`: the per-round `[goal]`-prefixed input rendered as a REGULAR user
* message bubble (the system re-sends the user's request each round, so each round reads
* like any other message the user sent) with a "Goal · round N" notice tucked beneath
* it; the Trace page shows the raw block. A round with an empty body falls back to the
* one-line notice alone.
* - `GoalStatusBanner`: the live goal card above the composer — objective excerpt, round
* count, token usage against the budget, and the terminal state once the run ends. The
* stop control is the regular abort (one signal spans the whole goal loop server-side).
*/
import { S } from "../../lib/strings";
import { humanizeTokens } from "../../lib/format";
import { GlyphIcon } from "../../components/ui/glyph-icon";
import { GOAL_ICON, UNLIMITED_BUDGET } from "./goal-use";
import type { GoalBannerState } from "./goal-use";
export function GoalRoundBanner({ round, objective }: { round: number; objective?: string }) {
// A regular right-aligned user bubble (same classes as message-item's user_text
// rendering), with the round notice under the bubble.
if (objective !== undefined && objective !== "") {
return (
<div className="anim-msg my-4 flex flex-col items-end">
<div className="max-w-[88%] rounded-lg bg-gray-100 px-4 py-2.5 md:max-w-[75%] dark:bg-gray-800">
<p className="wrap-anywhere whitespace-pre-wrap text-base leading-relaxed text-gray-900 dark:text-gray-100">
{objective}
</p>
</div>
<p className="mt-1 flex items-center gap-1.5 px-0.5 text-xs text-gray-400 dark:text-gray-500">
<GlyphIcon d={GOAL_ICON} size={12} className="shrink-0" />
{S.chat.goalRoundBanner(round)}
</p>
</div>
);
}
return (
<p className="anim-msg my-2 flex w-fit items-center gap-2 rounded-md border border-gray-200 bg-gray-50 px-3 py-2 text-xs text-gray-600 dark:border-gray-800 dark:bg-gray-900 dark:text-gray-400">
<GlyphIcon d={GOAL_ICON} className="text-gray-400 dark:text-gray-500" />
{S.chat.goalRoundBanner(round)}
</p>
);
}
export function GoalStatusBanner({ goal }: { goal: GoalBannerState }) {
const tokens =
goal.budget > 0 && goal.budget !== UNLIMITED_BUDGET
? `${humanizeTokens(goal.used)}/${humanizeTokens(goal.budget)}`
: humanizeTokens(goal.used);
const finished = goal.status !== "active";
return (
<div className="anim-fade mb-2 flex items-center gap-2 rounded-md border border-gray-200 bg-gray-50 px-3 py-2 text-xs text-gray-600 dark:border-gray-800 dark:bg-gray-900 dark:text-gray-400">
<GlyphIcon d={GOAL_ICON} className="shrink-0 text-gray-400 dark:text-gray-500" />
<span className="min-w-0 flex-1 truncate" title={goal.objective}>
{goal.objective}
</span>
<span className="shrink-0 text-gray-400 dark:text-gray-500">
{S.chat.goalProgress(goal.rounds, tokens)}
</span>
<span
className={`shrink-0 rounded px-1.5 py-0.5 ${
finished
? goal.status === "complete"
? "bg-green-100 text-green-700 dark:bg-green-900/40 dark:text-green-400"
: "bg-amber-100 text-amber-700 dark:bg-amber-900/40 dark:text-amber-400"
: "bg-gray-200 text-gray-600 dark:bg-gray-800 dark:text-gray-300"
}`}
>
{S.chat.goalStatus[goal.status]}
</span>
</div>
);
}
@@ -0,0 +1,46 @@
/**
* Goal-mode logic for the chat UI (pure, unit-tested).
*
* The `[goal]` block is the goal loop's per-round protocol prefix (core goal-prompts.ts);
* the message stream collapses it into a round notice and renders the body after it — the
* user's original round-1 input (skill blocks and all), or the re-injected objective — like
* any other user message. The Trace page still shows the raw block. Parsing is core's
* `parseGoalMessage` (line-anchored close; see markers/goal-block.ts), re-exported so chat
* components keep a single import site.
*
* Budget input parsing mirrors the CLI's `/goal:<budget>` grammar: a positive number with an
* optional k/m suffix; an empty input means no budget (UNLIMITED_BUDGET).
*/
export { parseGoalMessage } from "@prismshadow/penguin-core/omnimessage";
export type { GoalRoundMessage } from "@prismshadow/penguin-core/omnimessage";
/** Mirrors core's UNLIMITED_BUDGET (kept local: the constant is not part of the omnimessage bundle). */
export const UNLIMITED_BUDGET = -1;
/** Bullseye/arrow icon (24×24 line path): goal-mode UI (chip, plus-menu item, banner). */
export const GOAL_ICON =
"M21 12A9 9 0 1 1 12 3M17 12A5 5 0 1 1 12 7M12 12L15 9V5L18 2V6H22L19 9H15";
/** What the goal banner shows (fed from goal_* server events, or the goal_state row on load). */
export interface GoalBannerState {
objective: string;
status: "active" | "complete" | "blocked" | "budget_limited" | "aborted";
/** Token budget; UNLIMITED_BUDGET (-1) = none. */
budget: number;
used: number;
rounds: number;
}
/**
* Parses the goal chip's budget input: `""` = no budget (UNLIMITED_BUDGET); `500k` / `2m` /
* plain positive integers; anything else is invalid (null — the send button stays disabled).
*/
export function parseBudgetInput(text: string): number | null {
const trimmed = text.trim();
if (trimmed === "") return UNLIMITED_BUDGET;
const m = /^(\d+(?:\.\d+)?)([km])?$/i.exec(trimmed);
if (!m) return null;
const scale = m[2]?.toLowerCase() === "m" ? 1_000_000 : m[2]?.toLowerCase() === "k" ? 1_000 : 1;
const value = Math.round(Number(m[1]) * scale);
return value > 0 ? value : null;
}
@@ -19,6 +19,7 @@ import { ThinkingBlock } from "./thinking-block";
import { ToolCallCard } from "./tool-call-card";
import { SubagentCard } from "./subagent-card";
import { CompactionBanner } from "./compaction-banner";
import { GoalRoundBanner } from "./goal-banner";
import { HandoffBanner, ModelSwitchBanner } from "./handoff-banner";
import { ScheduledBanner } from "./scheduled-banner";
import { SkillsBanner } from "./skills-banner";
@@ -27,6 +28,7 @@ import {
parseModelSwitchMessage,
parseScheduledMessage,
} from "./agent-mentions";
import { parseGoalMessage } from "./goal-use";
import { parseSkillsMessage } from "./skill-use";
import { TaskStatsLine } from "./task-stats-line";
import type { StreamRenderContext } from "./message-stream";
@@ -177,18 +179,33 @@ export function MessageItem({ item, ctx }: { item: ChatItem; ctx: StreamRenderCo
// Source block for a chat opened by the /model switch: collapsed into a single-line switch notice, clickable to jump back to the source conversation.
const modelSwitch = parseModelSwitchMessage(item.text);
if (modelSwitch) return <ModelSwitchBanner origin={modelSwitch} />;
// A goal round's [goal] protocol prefix: collapsed into a round notice; the body after
// it (round 1: the user's original input, skill blocks and all; later rounds: the
// objective) continues down the normal parsing chain (the Trace shows the raw block).
const goalRound = parseGoalMessage(item.text);
const afterGoal = goalRound ? goalRound.rest : item.text;
// Source block for a scheduled-task trigger: collapsed into a single-line notice, with the task's prompt body rendered as usual (verbatim on the Trace page).
const scheduled = parseScheduledMessage(item.text);
const scheduled = parseScheduledMessage(afterGoal);
// Source block for a skill invocation: parsing continues on scheduled's remaining body
// (handoff -> scheduled -> skills, blocks stripped in a chain); a match collapses into a
// (goal -> scheduled -> skills, blocks stripped in a chain); a match collapses into a
// "using skill" banner, with the body rendered as usual.
const afterScheduled = scheduled ? scheduled.rest : item.text;
const afterScheduled = scheduled ? scheduled.rest : afterGoal;
const skills = parseSkillsMessage(afterScheduled);
// Attachment row restoration: for models that don't support images, input images are
// written to disk as a path row; this pulls that out at render time and shows the actual
// image. Mirrors the vision-model path (user_text + user_image as separate messages) in
// shape: one bubble for the text, one bubble per image, styled the same as user_image.
const { text, images } = splitImageAttachments(skills ? skills.rest : afterScheduled);
// Every goal round reads like a normal user message: the body in a user bubble with
// the round notice beneath (the system IS re-sending the user's request each round).
if (goalRound) {
return (
<>
{skills && <SkillsBanner names={skills.skills} />}
<GoalRoundBanner round={goalRound.round} objective={text} />
</>
);
}
return (
<>
{scheduled && <ScheduledBanner origin={scheduled.origin} />}
@@ -16,13 +16,14 @@
* server re-sends still-pending requests.
*/
import { useCallback, useEffect, useRef, useState } from "react";
import type { SessionStatus } from "@prismshadow/penguin-server/api";
import { getMe, getMessages } from "../../api/endpoints";
import type { GoalServerEvent, SessionStatus } from "@prismshadow/penguin-server/api";
import { getGoal, getMe, getMessages } from "../../api/endpoints";
import { openSessionStream } from "../../api/sse";
import { createStreamController } from "../../lib/omni/stream-controller";
import type { PendingApproval, StreamController } from "../../lib/omni/stream-controller";
import { createStreamModel } from "../../lib/omni/stream-model";
import type { StreamModel } from "../../lib/omni/stream-model";
import type { GoalBannerState } from "./goal-use";
export type { PendingApproval } from "../../lib/omni/stream-controller";
@@ -63,6 +64,12 @@ export interface SessionStreamState {
error: string | null;
/** Re-fetch history (only meaningful after a load failure). */
retry: () => void;
/**
* Goal-banner state: an in-flight goal restored from goal_state on load (only when still
* active), then kept live by goal_* server events; terminal states reached during this
* page's lifetime stay visible until the session changes. Null = no banner.
*/
goal: GoalBannerState | null;
}
const EMPTY_PENDING: ReadonlyMap<string, PendingApproval> = new Map();
@@ -81,6 +88,32 @@ export function useSessionStream(
const [queuedFollowUps, setQueuedFollowUps] = useState(0);
const [error, setError] = useState<string | null>(null);
const [pendingTick, setPendingTick] = useState(0);
const [goal, setGoal] = useState<GoalBannerState | null>(null);
/** Fold one goal_* event into the banner state (a mid-goal join without goal_started keeps prior fields where known). */
const onGoalEvent = useCallback((ev: GoalServerEvent) => {
setGoal((prev) => {
if (ev.type === "goal_started") {
return { objective: ev.objective, status: "active", budget: ev.budget, used: 0, rounds: 0 };
}
if (ev.type === "goal_round") {
return {
objective: prev?.objective ?? "",
status: "active",
budget: ev.budget,
used: ev.used,
rounds: ev.round,
};
}
return {
objective: prev?.objective ?? "",
status: ev.outcome,
budget: prev?.budget ?? -1,
used: ev.used,
rounds: ev.rounds,
};
});
}, []);
const onTitleRef = useRef(onSessionTitle);
onTitleRef.current = onSessionTitle;
const onCreatedRef = useRef(onSessionCreated);
@@ -132,6 +165,7 @@ export function useSessionStream(
setQueuedFollowUps(0);
setLoading(false);
setError(null);
setGoal(null);
setPendingTick((t) => t + 1);
setVersion((v) => v + 1);
return;
@@ -142,9 +176,27 @@ 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);
setGoal(null);
setQueuedFollowUps(0);
setPendingTick((t) => t + 1);
// Restore an in-flight goal's banner (only when still active — a long-finished goal
// shouldn't greet every visit); live goal_* events override this snapshot. Fetched from
// the stream's onOpen (below), never before it: reading the DB before subscribing races a
// goal that finishes in that window — its goal_finished isn't replayed to a fresh
// subscription, so a stale `active` read would pin a "running" banner forever. Once
// subscribed, the DB already reflects the terminal status for anything that finished before
// we connected, and anything finishing after arrives live on the stream.
let goalFetchStale = false;
const hydrateGoal = () => {
void getGoal(sessionId)
.then((res) => {
if (goalFetchStale || !res.goal || res.goal.status !== "active") return;
setGoal((prev) => prev ?? res.goal);
})
.catch(() => undefined);
};
const controller = createStreamController({
// The whole response rides through: `live` (in-progress stream tail) lets the
// controller seed the currently streaming message after a reload (see stream-controller).
@@ -157,6 +209,7 @@ export function useSessionStream(
onPendingChange: () => setPendingTick((t) => t + 1),
onSessionTitle: (sid, title) => onTitleRef.current?.(sid, title),
onSessionCreated: () => onCreatedRef.current?.(),
onGoalEvent,
});
controllerRef.current = controller;
@@ -164,6 +217,9 @@ export function useSessionStream(
const conn = openSessionStream(sessionId, {
onOmniMessage: controller.handleOmni,
onServerEvent: controller.handleServer,
// Hydrate the goal banner only once the subscription is live (fires on first connect and
// every reconnect); the prev/active guards keep it from clobbering a live banner.
onOpen: hydrateGoal,
// EventSource can't read the status code: when the connection is judged a fatal error and
// closes, probe once with GET /api/me; if the session has expired (401), the client's
// global handler clears the user and redirects to the login page.
@@ -174,6 +230,7 @@ export function useSessionStream(
void controller.load();
return () => {
goalFetchStale = true;
controller.dispose();
conn.close();
if (rafRef.current !== null) {
@@ -226,5 +283,6 @@ export function useSessionStream(
resolveApproval,
error,
retry,
goal,
};
}
@@ -100,6 +100,8 @@ export function summarizeEvent(msg: OmniMessage): string {
return `${String(p["mode"])} (${String(p["reason"])})${p["status"] ? ` · ${String(p["status"])}` : ""}`;
case "abort":
return p["reason"] != null ? String(p["reason"]) : "";
case "goal_finished":
return `${String(p["outcome"])} · rounds=${String(p["rounds"])} · tokens=${String(p["tokens_used"])}`;
case "subagent":
return String(p["session_id"] ?? "");
default:
+14 -1
View File
@@ -31,7 +31,12 @@
*/
import { isEventMessage, isPartialPayload } from "@prismshadow/penguin-core/omnimessage";
import type { OmniMessage, ToolCallPayload } from "@prismshadow/penguin-core/omnimessage";
import type { MessagesLiveTail, ServerEvent, SessionStatus } from "@prismshadow/penguin-server/api";
import type {
GoalServerEvent,
MessagesLiveTail,
ServerEvent,
SessionStatus,
} from "@prismshadow/penguin-server/api";
import {
approvalKey,
buildDedupIndex,
@@ -85,6 +90,8 @@ export interface StreamControllerDeps {
onSessionTitle?: (sessionId: string, title: string) => void;
/** A new session has been registered (sub-sessions are pushed along the parent session's channel; used to refresh the Session list). */
onSessionCreated?: (sessionId: string) => void;
/** Goal-mode progress (goal_started / goal_round / goal_finished): drives the chat page's goal banner. */
onGoalEvent?: (ev: GoalServerEvent) => void;
/** Local clock (injectable for tests). */
now?: () => number;
}
@@ -349,6 +356,12 @@ export function createStreamController(deps: StreamControllerDeps): StreamContro
deps.onSessionCreated?.(ev.sessionId);
return;
}
// Goal progress only affects the banner (UI state, not the transcript model): forwarded
// immediately at any phase, never buffered — same treatment as session_title.
if (ev.type === "goal_started" || ev.type === "goal_round" || ev.type === "goal_finished") {
deps.onGoalEvent?.(ev);
return;
}
if (phase === "buffering") {
// task_state is reflected immediately in the input area (authoritative
// state, doesn't wait for history replay); model side effects like
+22 -2
View File
@@ -638,8 +638,6 @@ When done, open index.html in a browser and self-test once.`,
steerQueuedIndicator: "Steering queued — delivered with the next turn",
/** Label of the [user_steering] chip (a mid-run user message delivered between turns). */
userSteering: "User steering",
/** More-settings popover on the input toolbar (extensible setting rows; holds the mid-run send mode). */
moreSettings: "More settings",
/** Mid-run send-mode setting: steer (delivered mid-run) vs follow-up (queued until the run ends). */
steerModeLabel: "Mid-run send mode",
steerModeSteer: "Steer",
@@ -777,6 +775,28 @@ When done, open index.html in a browser and self-test once.`,
},
skillsBanner: (names: string[]): string =>
`Using skill${names.length === 1 ? "" : "s"}: ${names.join(", ")}`,
/** Composer "+" extension menu (currently only goal mode; more entries later) and the goal chip. */
plusMenu: "More input options",
goalMode: "Goal mode",
goalModeDesc: "Loop until the goal completes",
goalBudgetLabel: "Token budget",
goalBudgetUnlimited: "Budget unlimited",
goalBudgetValue: (value: string): string => `Budget ${value}`,
goalBudgetPlaceholder: "e.g. 500k",
goalBudgetHint: "Use a k/m suffix; leave blank for no budget limit",
goalBudgetInvalid:
"Invalid budget: use a positive number with an optional k/m suffix (500k, 2m)",
goalBudgetSave: "Save budget",
goalRemove: "Exit goal mode",
goalRoundBanner: (round: number): string => `Goal · round ${round}`,
goalProgress: (rounds: number, tokens: string): string => `round ${rounds} · tokens ${tokens}`,
goalStatus: {
active: "running",
complete: "complete",
blocked: "blocked",
budget_limited: "budget exhausted",
aborted: "interrupted",
} as Record<string, string>,
},
files: {
+21 -2
View File
@@ -624,8 +624,6 @@ Penguin 视觉风格(见 web-design 技能),深色/浅色主题(<html da
steerQueuedIndicator: "插话已排队,将随下一轮送达",
/** Label of the [user_steering] chip (a mid-run user message delivered between turns). */
userSteering: "用户插话",
/** More-settings popover on the input toolbar (extensible setting rows; holds the mid-run send mode). */
moreSettings: "更多设置",
/** Mid-run send-mode setting: steer (delivered mid-run) vs follow-up (queued until the run ends). */
steerModeLabel: "运行中发送方式",
steerModeSteer: "插话",
@@ -757,6 +755,27 @@ Penguin 视觉风格(见 web-design 技能),深色/浅色主题(<html da
archived: (n: number) => `已归档(${n})`,
},
skillsBanner: (names: string[]): string => `使用技能:${names.join("、")}`,
/** Composer "+" extension menu (currently only goal mode; more entries later) and the goal chip. */
plusMenu: "更多输入方式",
goalMode: "目标模式",
goalModeDesc: "循环运行直至目标完成",
goalBudgetLabel: "Token 预算",
goalBudgetUnlimited: "预算不限",
goalBudgetValue: (value: string): string => `预算 ${value}`,
goalBudgetPlaceholder: "例如 500k",
goalBudgetHint: "支持 k/m 后缀;留空表示预算不限",
goalBudgetInvalid: "无效预算:应为正数,可带 k/m 后缀(500k、2m)",
goalBudgetSave: "保存预算",
goalRemove: "退出目标模式",
goalRoundBanner: (round: number): string => `目标 · 第 ${round} 轮`,
goalProgress: (rounds: number, tokens: string): string => `第 ${rounds} 轮 · tokens ${tokens}`,
goalStatus: {
active: "进行中",
complete: "已完成",
blocked: "受阻",
budget_limited: "预算耗尽",
aborted: "已中断",
} as Record<string, string>,
},
files: {