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:
@@ -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,
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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}
|
||||
|
||||
@@ -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:
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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: {
|
||||
|
||||
@@ -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: {
|
||||
|
||||
Reference in New Issue
Block a user