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