1d23a7acaf
Co-authored-by: Claude Opus 5 (1M context) <noreply@anthropic.com>
2797 lines
127 KiB
TypeScript
2797 lines
127 KiB
TypeScript
/**
|
||
* Chat input area: a unified input card —
|
||
* a multi-line textarea occupies the card's top area (auto-grows; Enter sends, Shift+Enter
|
||
* inserts a newline, image paste supported), with all controls collected onto a **single bottom
|
||
* row** that never shares a line with the text body: attachments + approval mode (saved
|
||
* immediately on change) + help text (to the right of approval mode) | context usage (ring
|
||
* indicator) + Model + send (up arrow);
|
||
* In draft state (Session not yet created), when models/onChangeModel are supplied, the model
|
||
* selector sits to the left of the send button (provider logo + name, popup opens **downward**,
|
||
* with a top quick-search box, an internal scroll cap to avoid overflowing the screen, and a
|
||
* configured-key-first list with a bottom "show all" row — see ModelSelect) — once
|
||
* the Session is created the model is locked, and the same spot switches to a read-only
|
||
* logo + name display;
|
||
* Draft state also renders a thinking-level picker left of the model selector (backed by the
|
||
* Agent settings: picking a level writes through to the Agent config and applies to the session
|
||
* created on first send); in session state the level is fixed (llmConfig is assembled once per
|
||
* session), shown as a read-only tag from session_meta;
|
||
* `/` opens the slash command menu (`/compact` compresses context, replacing the button; each
|
||
* installed skill gets its own entry; pressing Enter on `/<skill_name>` toggles that skill's
|
||
* selection without sending). Matching is positional: a slash opens the menu from any caret
|
||
* position, running a command removes just that token, and Escape only dismisses the menu —
|
||
* the rest of the draft is never touched;
|
||
* `/agent` and `/model` are the two **switch** commands, both offered in an active Session
|
||
* only — a draft has no conversation to switch, and picks its Agent and model in the draft
|
||
* page's own selectors. Both are staged rather than immediate: running one consumes its token
|
||
* and opens a picker (agents / models), and the pick becomes a highlighted chip above the text
|
||
* body instead of switching on the spot. The user
|
||
* keeps typing; **Enter/Send** performs the switch — an agent chip hands the conversation off to
|
||
* a new chat for that agent (the current Session is not sent to), a model chip forks this
|
||
* conversation onto the picked model. A model fork additionally waits for this Session to be
|
||
* idle (it branches off a Trace that a run or a compaction is still appending to) and says so
|
||
* above the composer rather than just disabling Send. With an empty text body the default
|
||
* auto-message is filled in. Only one chip at a time (picking either clears the other, picking
|
||
* the model already in use clears the staging, and both are exclusive with goal mode); a chip is
|
||
* removed via backspace at the start of the text or its x button, and both are cached with the
|
||
* draft so they survive a session switch or reload along with the text they belong to;
|
||
* The "+" menu carries the input add-ons: image upload, file attachment (any type, several at a
|
||
* time — they ride the task request as base64 data URLs, and the server writes them into the
|
||
* session scratchpad and appends an `[attached file: <path>]` line to the message, so the model
|
||
* opens them by path), and goal mode; selected files show as removable chips above the text
|
||
* body, next to the image thumbnails, and — like images — an attachments-only message is
|
||
* sendable with no text at all.
|
||
* The bottom toolbar provides a searchable multi-select skills dropdown (styled like the model
|
||
* selector: a top search box filtering by name and localized description, plus a checklist;
|
||
* clicking a row toggles its selection without closing the menu; the button = book icon + label +
|
||
* selected-count badge, disabled while running/compacting). With skills selected, sending with an
|
||
* empty text body is allowed — the sent text automatically falls back to S.chat.skillsAutoMessage.
|
||
* When sent, the text body wraps in a `[use_skills]` block (the handoff's rest-of-message body is
|
||
* wrapped the same way); the selection clears once sending succeeds. Quick-invoke pre-selects via
|
||
* initialSkills (read once on mount; once the installed list is ready, names not in that list are
|
||
* pruned); the slash menu also lists installed skills, and pressing Enter on `/<skill_name>`
|
||
* selects it.
|
||
* 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 "+" 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.
|
||
* Renders only the card body itself: outer positioning such as bottom-docking or vertical
|
||
* centering is decided by the page.
|
||
*/
|
||
import { useCallback, useEffect, useLayoutEffect, useMemo, useRef, useState } from "react";
|
||
import type { ChangeEvent, ClipboardEvent, KeyboardEvent, ReactNode, RefObject } from "react";
|
||
import type {
|
||
AgentSummary,
|
||
ApprovalMode,
|
||
ModelInfo,
|
||
ModelRefDto,
|
||
SessionStatus,
|
||
SkillMetadataItem,
|
||
TaskInputPart,
|
||
} from "@prismshadow/penguin-server/api";
|
||
import { S } from "../../lib/strings";
|
||
import { formatBytes, humanizeTokens } from "../../lib/format";
|
||
import { resolveContextWindow } from "../../lib/context";
|
||
import { useLocale } from "../../state/locale";
|
||
import { agentDisplayName } from "../../state/project";
|
||
import { AgentAvatar } from "../../components/ui/agent-avatar";
|
||
import { Dropdown } from "../../components/ui/dropdown";
|
||
import { GlyphIcon } from "../../components/ui/glyph-icon";
|
||
import { noAutofill } from "../../components/ui/input";
|
||
import { toastError } from "../../components/ui/toast";
|
||
import { SkillIcon } from "../skills/skill-icon-view";
|
||
import { ZoomableImage } from "../../components/ui/image-zoom";
|
||
import { ProviderLogo } from "../../components/ui/provider-logo";
|
||
import { Badge } from "../../components/ui/badge";
|
||
import {
|
||
hasConfiguredKey,
|
||
isFreeModel,
|
||
sameModelRef,
|
||
visibleChatModels,
|
||
} from "../models/model-grouping";
|
||
import { filterAgents, stagedSendRoute } from "./agent-handoff";
|
||
import { matchSlash, removeSlashToken } from "./slash-token";
|
||
import { SELECTABLE_THINKING_LEVELS, thinkingLevelLabel } from "./thinking-level";
|
||
import {
|
||
BOOK_ICON,
|
||
buildSkillsMessage,
|
||
filterSkills,
|
||
localizedShortText,
|
||
skillSlashItems,
|
||
} from "./skill-use";
|
||
import { GOAL_ICON, UNLIMITED_BUDGET, parseBudgetInput } from "./goal-use";
|
||
import { PAPERCLIP_ICON } from "./attached-files-banner";
|
||
|
||
const APPROVAL_MODES: ApprovalMode[] = ["always-ask", "read-only", "allow-all", "deny-all"];
|
||
|
||
/**
|
||
* Illustrative icon for each approval mode (24x24 line art, grayscale via currentColor, no
|
||
* color-coding): allow-all uses a warning triangle — it permits everything at the user's own
|
||
* risk, the shape hints at it visually without rendering tension through color; deny-all is a
|
||
* no-entry sign, read-only is an eye, always-ask is a question-mark circle.
|
||
*/
|
||
const APPROVAL_MODE_ICONS: Record<ApprovalMode, string> = {
|
||
"allow-all":
|
||
"M10.29 3.86 1.82 18a2 2 0 0 0 1.71 3h16.94a2 2 0 0 0 1.71-3L13.71 3.86a2 2 0 0 0-3.42 0zM12 9v4m0 4h.01",
|
||
"deny-all": "M12 21a9 9 0 1 0 0-18 9 9 0 0 0 0 18zM5.64 5.64l12.72 12.72",
|
||
"read-only":
|
||
"M2 12s3.5-7 10-7 10 7 10 7-3.5 7-10 7-10-7-10-7zM15 12a3 3 0 1 1-6 0 3 3 0 0 1 6 0z",
|
||
"always-ask":
|
||
"M12 21a9 9 0 1 0 0-18 9 9 0 0 0 0 18zM9.1 9a3 3 0 0 1 5.83 1c0 2-3 3-3 3m.07 4h.01",
|
||
};
|
||
|
||
/**
|
||
* Approval mode selector (custom-drawn dropdown, not the browser's native select): small,
|
||
* grayscale.
|
||
* Popup direction depends on context: for the draft card, vertically centered with room below
|
||
* -> opens downward; for the chat input area docked at the bottom of the screen, where opening
|
||
* downward would overflow the viewport with nowhere to scroll -> opens upward.
|
||
*/
|
||
function ApprovalModeSelect({
|
||
value,
|
||
onChange,
|
||
disabled,
|
||
direction = "up",
|
||
}: {
|
||
value: ApprovalMode;
|
||
onChange: (mode: ApprovalMode) => void;
|
||
disabled: boolean;
|
||
direction?: "up" | "down";
|
||
}) {
|
||
const [open, setOpen] = useState(false);
|
||
return (
|
||
<Dropdown
|
||
open={open}
|
||
setOpen={setOpen}
|
||
// w-max: width exactly wraps the longest line (no wrapping within a line), avoiding an
|
||
// overly wide panel. Placement is portal-driven (the toolbar scrolls horizontally on
|
||
// phones, which would otherwise clip the panel); only size classes belong here.
|
||
menuClass="w-max"
|
||
portal={{ direction, align: "left" }}
|
||
button={
|
||
// Button styling matches the model selector (h-8 / rounded-md / solid hover background).
|
||
<button
|
||
type="button"
|
||
aria-label={S.chat.approvalMode}
|
||
title={`${S.chat.approvalMode}:${S.chat.approvalModeNames[value] ?? value}`}
|
||
disabled={disabled}
|
||
onClick={() => setOpen((v) => !v)}
|
||
className="flex h-8 max-w-44 shrink-0 items-center gap-1.5 rounded-md px-2 text-xs text-gray-500 transition-colors duration-150 hover:bg-gray-100 hover:text-gray-800 disabled:cursor-not-allowed disabled:opacity-50 dark:text-gray-400 dark:hover:bg-gray-800 dark:hover:text-gray-200"
|
||
>
|
||
{/* Icon changes with the current mode (allow-all = warning triangle, grayscale, no color-coding) */}
|
||
<svg
|
||
width="13"
|
||
height="13"
|
||
viewBox="0 0 24 24"
|
||
fill="none"
|
||
stroke="currentColor"
|
||
strokeWidth="1.7"
|
||
strokeLinecap="round"
|
||
strokeLinejoin="round"
|
||
aria-hidden
|
||
className="shrink-0"
|
||
>
|
||
<path d={APPROVAL_MODE_ICONS[value]} />
|
||
</svg>
|
||
{/* Button shows only the description (the mode id is spelled out in the menu); when the card is narrower than @md, only the icon remains (title shows the full name). */}
|
||
<span className="hidden min-w-0 truncate @md:block">
|
||
{S.chat.approvalModeNames[value] ?? value}
|
||
</span>
|
||
<svg
|
||
width="10"
|
||
height="10"
|
||
viewBox="0 0 12 12"
|
||
fill="none"
|
||
stroke="currentColor"
|
||
className="shrink-0"
|
||
aria-hidden
|
||
>
|
||
<path
|
||
d="M3 4.5l3 3 3-3"
|
||
strokeWidth="1.5"
|
||
strokeLinecap="round"
|
||
strokeLinejoin="round"
|
||
/>
|
||
</svg>
|
||
</button>
|
||
}
|
||
>
|
||
{APPROVAL_MODES.map((m) => (
|
||
<button
|
||
key={m}
|
||
type="button"
|
||
onClick={() => {
|
||
onChange(m);
|
||
setOpen(false);
|
||
}}
|
||
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 dark:hover:bg-gray-800 ${
|
||
m === value
|
||
? "font-medium text-gray-900 dark:text-gray-100"
|
||
: "text-gray-600 dark:text-gray-400"
|
||
}`}
|
||
>
|
||
<svg
|
||
width="13"
|
||
height="13"
|
||
viewBox="0 0 24 24"
|
||
fill="none"
|
||
stroke="currentColor"
|
||
strokeWidth="1.7"
|
||
strokeLinecap="round"
|
||
strokeLinejoin="round"
|
||
aria-hidden
|
||
className="shrink-0 text-gray-400 dark:text-gray-500"
|
||
>
|
||
<path d={APPROVAL_MODE_ICONS[m]} />
|
||
</svg>
|
||
{/* Description first, mode id after (copy in strings); single line, no wrapping, selected checkmark at line end. */}
|
||
<span className="min-w-0 flex-1 truncate whitespace-nowrap">
|
||
{S.chat.approvalModes[m] ?? m}
|
||
</span>
|
||
<span className="w-3 shrink-0 text-center">{m === value ? "✓" : ""}</span>
|
||
</button>
|
||
))}
|
||
</Dropdown>
|
||
);
|
||
}
|
||
|
||
/** Display label for a model: the display name, or falls back to the upstream id (model_id is the raw field, no prefix parsing). */
|
||
function modelLabel(m: ModelInfo): string {
|
||
return m.displayName ?? m.modelId;
|
||
}
|
||
|
||
/**
|
||
* "No key" marker for the model dropdown's key-less rows: a key struck through by a prohibition
|
||
* slash (24x24 line art, grayscale via currentColor, matching the approval-mode icon style).
|
||
*/
|
||
const NO_KEY_ICON =
|
||
"M21 2l-2 2m-7.61 7.61a5.5 5.5 0 1 1-7.778 7.778 5.5 5.5 0 0 1 7.777-7.777zm0 0L15.5 7.5m0 0l3 3L22 7l-3-3m-3.5 3.5L19 4M2 2l20 20";
|
||
|
||
/**
|
||
* Candidate panel shared by every picker in this file (the model dropdown / `/model` switch
|
||
* picker and the `/agent` handoff picker): the search box, the internal scroll cap, the row
|
||
* chrome, the keyboard navigation and the "current entry" marker slot all live here, so the
|
||
* two pickers can differ only in what a row *contains* (provider logo vs Agent avatar) and in
|
||
* what they hang below the list (`footer`, e.g. the model list's "show all" expander).
|
||
*
|
||
* Keyboard navigation deliberately starts with **no** row highlighted: the search box is
|
||
* autofocused, and pre-highlighting a row would repaint a panel that has looked the same since
|
||
* before this control existed. ArrowDown/ArrowUp begin the navigation, and Enter/Tab commits —
|
||
* the highlighted row if there is one, otherwise the top match, which is what makes "type a few
|
||
* letters, press Enter" work. Escape is NOT handled here: each host closes its own panel at the
|
||
* window level (an IME-safe handler for the switch pickers, Dropdown's for the model dropdown).
|
||
*/
|
||
function PickerList<T>({
|
||
items,
|
||
itemKey,
|
||
isCurrent,
|
||
query,
|
||
onQueryChange,
|
||
searchPlaceholder,
|
||
emptyText,
|
||
onPick,
|
||
renderRow,
|
||
footer,
|
||
}: {
|
||
items: T[];
|
||
/** Stable React key AND identity for the highlighted row. */
|
||
itemKey: (item: T) => string;
|
||
/** Marks the entry already in effect (the session's model / its Agent): renders the ✓ slot and the emphasized row style. */
|
||
isCurrent?: (item: T) => boolean;
|
||
query: string;
|
||
onQueryChange: (query: string) => void;
|
||
searchPlaceholder: string;
|
||
/** Shown in place of the list when the query matches nothing. */
|
||
emptyText: string;
|
||
onPick: (item: T) => void;
|
||
/** The row's own content, left of the ✓ slot. */
|
||
renderRow: (item: T) => ReactNode;
|
||
/** Pinned below the scroll area (mirroring the search box above it). */
|
||
footer?: ReactNode;
|
||
}) {
|
||
// -1 = nothing highlighted yet (see the note above); reset whenever the candidate set changes.
|
||
const [active, setActive] = useState(-1);
|
||
const activeKey = active >= 0 && active < items.length ? itemKey(items[active]!) : null;
|
||
const onKeyDown = (e: KeyboardEvent<HTMLDivElement>) => {
|
||
if (items.length === 0) return;
|
||
if (e.key === "ArrowDown") {
|
||
e.preventDefault();
|
||
setActive((i) => (i + 1) % items.length);
|
||
return;
|
||
}
|
||
if (e.key === "ArrowUp") {
|
||
e.preventDefault();
|
||
setActive((i) => (i <= 0 ? items.length - 1 : i - 1));
|
||
return;
|
||
}
|
||
// Same guard as the composer's own Enter handling: an IME commit must not be read as a pick.
|
||
if (((e.key === "Enter" && !e.shiftKey) || e.key === "Tab") && !e.nativeEvent.isComposing) {
|
||
e.preventDefault();
|
||
onPick(items[active >= 0 ? active : 0]!);
|
||
}
|
||
};
|
||
return (
|
||
<div className="contents" onKeyDown={onKeyDown}>
|
||
{/* Quick search (autofocused: it also owns the keyboard while the panel is up) */}
|
||
<div className="border-b border-gray-100 px-2 pb-1.5 pt-0.5 dark:border-gray-800">
|
||
<input
|
||
autoFocus
|
||
value={query}
|
||
onChange={(e) => {
|
||
onQueryChange(e.target.value);
|
||
setActive(-1);
|
||
}}
|
||
placeholder={searchPlaceholder}
|
||
aria-label={searchPlaceholder}
|
||
{...noAutofill}
|
||
className="w-full rounded border border-transparent bg-transparent px-1 py-0.5 text-xs text-gray-700 placeholder:text-gray-400 focus:outline-none dark:text-gray-200 dark:placeholder:text-gray-500"
|
||
/>
|
||
</div>
|
||
<div className="max-h-56 overflow-y-auto">
|
||
{items.length === 0 && <p className="px-3 py-1.5 text-xs text-gray-400">{emptyText}</p>}
|
||
{items.map((item) => {
|
||
const key = itemKey(item);
|
||
const current = isCurrent?.(item) ?? false;
|
||
return (
|
||
<button
|
||
key={key}
|
||
type="button"
|
||
ref={key === activeKey ? (el) => el?.scrollIntoView({ block: "nearest" }) : undefined}
|
||
onClick={() => onPick(item)}
|
||
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 dark:hover:bg-gray-800 ${
|
||
current
|
||
? "font-medium text-gray-900 dark:text-gray-100"
|
||
: "text-gray-600 dark:text-gray-400"
|
||
}${key === activeKey ? " bg-gray-100 dark:bg-gray-800" : ""}`}
|
||
>
|
||
{renderRow(item)}
|
||
<span className="w-3 shrink-0 text-center text-xs">{current ? "✓" : ""}</span>
|
||
</button>
|
||
);
|
||
})}
|
||
</div>
|
||
{footer}
|
||
</div>
|
||
);
|
||
}
|
||
|
||
/**
|
||
* Model candidate panel (search box + grouped list + "show all" expander) shared by the
|
||
* draft-state ModelSelect dropdown and the in-session `/model` switch picker. Search and
|
||
* expanded state are internal and reset by remount (both hosts only render the panel while
|
||
* open); the list is capped by an internal scroll (max-h-56) so it never overflows the
|
||
* viewport no matter how many models there are.
|
||
* Dropdown order mirrors the model library page (visibleChatModels): a top quick-search box
|
||
* (the model page's rule — filters by id / display name / provider name); by default only
|
||
* models with a configured API key are listed (stored masked key — the same standard as the
|
||
* model page's key status; `envKey` is merely the NAME of a fallback env var and doesn't
|
||
* count), with the selected and the default model always visible even without a key; a muted
|
||
* bottom row reveals the remaining key-less models (marked by a struck-through key icon, with
|
||
* the "no key" text in its title) without closing the menu or changing the selection — when
|
||
* no model has a key at all, everything is listed directly. Rows carry the provider logo, the
|
||
* light-yellow "Free" badge for zero-cost models (same as the model library card), the
|
||
* project-default marker, and the selected checkmark.
|
||
*/
|
||
function ModelMenuList({
|
||
models,
|
||
value,
|
||
defaultModel,
|
||
onPick,
|
||
}: {
|
||
models: ModelInfo[];
|
||
/** Currently selected (provider, modelId) pair; null = not yet chosen. */
|
||
value: ModelRefDto | null;
|
||
defaultModel?: ModelRefDto;
|
||
onPick: (m: ModelInfo) => void;
|
||
}) {
|
||
const [query, setQuery] = useState("");
|
||
// Expanded "show all" state: collapses back to key-configured models on each open (remount).
|
||
const [showAll, setShowAll] = useState(false);
|
||
const visible = visibleChatModels(models, { showAll, query, selected: value, defaultModel });
|
||
// How many models the key filter hides under the current query (0 when expanded): drives the bottom "show all" row.
|
||
const hiddenCount = showAll
|
||
? 0
|
||
: visibleChatModels(models, { showAll: true, query, selected: value, defaultModel }).length -
|
||
visible.length;
|
||
return (
|
||
<PickerList
|
||
items={visible}
|
||
itemKey={(m) => `${m.provider}:${m.modelId}`}
|
||
isCurrent={(m) => sameModelRef(m, value)}
|
||
query={query}
|
||
onQueryChange={setQuery}
|
||
// Quick search: supports model id / display name / provider name
|
||
searchPlaceholder={S.models.searchPlaceholder}
|
||
emptyText={S.models.noSearchResults}
|
||
onPick={onPick}
|
||
renderRow={(m) => (
|
||
<>
|
||
<ProviderLogo provider={m.provider} className="h-4 w-4 shrink-0" />
|
||
<span className="min-w-0 flex-1 truncate">{modelLabel(m)}</span>
|
||
{/* Zero-cost rows (all three price buckets 0): same light-yellow "Free" badge as
|
||
the model library card, so free models stand out while picking. */}
|
||
{isFreeModel(m.pricing) && (
|
||
<span className="shrink-0">
|
||
<Badge tone="yellow">{S.models.freeBadge}</Badge>
|
||
</span>
|
||
)}
|
||
{/* Key-less rows (visible via show-all / selected / default / no-key-at-all) carry a
|
||
struck-through key icon (the "no key" text lives in the title/aria-label). */}
|
||
{!hasConfiguredKey(m) && (
|
||
<span
|
||
role="img"
|
||
title={S.models.noKey}
|
||
aria-label={S.models.noKey}
|
||
className="shrink-0 text-gray-400 dark:text-gray-500"
|
||
>
|
||
<GlyphIcon d={NO_KEY_ICON} size={13} />
|
||
</span>
|
||
)}
|
||
{sameModelRef(m, defaultModel) && (
|
||
<span className="shrink-0 text-xs text-gray-400 dark:text-gray-500">
|
||
{S.models.default}
|
||
</span>
|
||
)}
|
||
</>
|
||
)}
|
||
// Bottom expander row (pinned below the scroll area, mirroring the search box on top):
|
||
// reveals the models hidden by the configured-key filter in place — the menu stays open
|
||
// and the selection is untouched.
|
||
{...(hiddenCount > 0
|
||
? {
|
||
footer: (
|
||
<div className="border-t border-gray-100 dark:border-gray-800">
|
||
<button
|
||
type="button"
|
||
onClick={() => setShowAll(true)}
|
||
className="flex w-full items-center gap-2 px-3 py-1.5 text-left text-xs text-gray-400 transition-colors duration-150 hover:bg-gray-100 hover:text-gray-600 dark:text-gray-500 dark:hover:bg-gray-800 dark:hover:text-gray-300"
|
||
>
|
||
{S.models.showModelsWithoutKey(hiddenCount)}
|
||
</button>
|
||
</div>
|
||
),
|
||
}
|
||
: {})}
|
||
/>
|
||
);
|
||
}
|
||
|
||
/**
|
||
* Model selector (draft state only; docked to the left of the send button): the button shows
|
||
* the provider logo + name (logo only when the card is narrower than @md; the title carries
|
||
* the full name), and the menu opens **downward** — the draft card is vertically centered
|
||
* with room below. The candidate list itself is the shared ModelMenuList panel (search,
|
||
* key-configured-first grouping, Free badge, "show all" expander — documented there).
|
||
*/
|
||
function ModelSelect({
|
||
models,
|
||
value,
|
||
defaultModel,
|
||
onChange,
|
||
disabled,
|
||
}: {
|
||
models: ModelInfo[];
|
||
/** Currently selected (provider, modelId) pair; null = not yet chosen. */
|
||
value: ModelRefDto | null;
|
||
defaultModel?: ModelRefDto;
|
||
onChange: (ref: ModelRefDto) => void;
|
||
disabled: boolean;
|
||
}) {
|
||
const [open, setOpen] = useState(false);
|
||
const current = models.find((m) => sameModelRef(m, value));
|
||
// Display rule matches the model page's card: display name, or falls back to the upstream id (grouping is already conveyed by the provider logo).
|
||
const label = current ? modelLabel(current) : (value?.modelId ?? "…");
|
||
return (
|
||
<Dropdown
|
||
open={open}
|
||
setOpen={setOpen}
|
||
// The panel's right edge docks to the button; portal placement then clamps both edges
|
||
// inside the viewport, so a w-max panel can no longer run off-screen on phones (it used
|
||
// to need a hand-tuned width clamp reserving the anchor offset).
|
||
menuClass="w-max min-w-56 origin-top-right"
|
||
portal={{ direction: "down", align: "right" }}
|
||
button={
|
||
<button
|
||
type="button"
|
||
title={`${S.chat.chooseModel}:${label}`}
|
||
aria-label={S.chat.chooseModel}
|
||
disabled={disabled || models.length === 0}
|
||
onClick={() => setOpen(!open)}
|
||
className="flex h-8 max-w-44 shrink-0 items-center gap-1.5 rounded-md px-2 text-xs text-gray-500 transition-colors duration-150 hover:bg-gray-100 hover:text-gray-800 disabled:cursor-not-allowed disabled:opacity-50 dark:text-gray-400 dark:hover:bg-gray-800 dark:hover:text-gray-200"
|
||
>
|
||
<ProviderLogo
|
||
provider={current?.provider ?? value?.provider ?? "custom"}
|
||
className="h-4 w-4 shrink-0"
|
||
/>
|
||
{/* When the card is narrower than @md, only the provider logo remains (title shows the full name). */}
|
||
<span className="hidden min-w-0 truncate @md:block">{label}</span>
|
||
<svg
|
||
width="10"
|
||
height="10"
|
||
viewBox="0 0 12 12"
|
||
fill="none"
|
||
stroke="currentColor"
|
||
className="shrink-0"
|
||
aria-hidden
|
||
>
|
||
<path
|
||
d="M3 4.5l3 3 3-3"
|
||
strokeWidth="1.5"
|
||
strokeLinecap="round"
|
||
strokeLinejoin="round"
|
||
/>
|
||
</svg>
|
||
</button>
|
||
}
|
||
>
|
||
<ModelMenuList
|
||
models={models}
|
||
value={value}
|
||
{...(defaultModel !== undefined ? { defaultModel } : {})}
|
||
onPick={(m) => {
|
||
onChange({ provider: m.provider, modelId: m.modelId });
|
||
setOpen(false);
|
||
}}
|
||
/>
|
||
</Dropdown>
|
||
);
|
||
}
|
||
|
||
/**
|
||
* Agent candidate panel for the `/agent` switch picker — the agent-side counterpart of
|
||
* ModelMenuList, and now literally the same panel (PickerList: search, scroll cap, keyboard
|
||
* navigation, current-entry marker). Only the row differs: the Agent avatar (the same identity
|
||
* tile the draft Agent picker uses), the agentId in monospace — the id is what identifies an
|
||
* Agent everywhere else in the app — and the display name after it when it differs. The
|
||
* conversation's own Agent is marked like the model list marks the session's model; picking it
|
||
* is still a real action (a fresh conversation with the same Agent), not a no-op.
|
||
*/
|
||
function AgentMenuList({
|
||
agents,
|
||
currentAgentId,
|
||
onPick,
|
||
}: {
|
||
agents: AgentSummary[];
|
||
/** The Agent this conversation already belongs to (marked ✓); undefined while it is unknown. */
|
||
currentAgentId?: string;
|
||
onPick: (agent: AgentSummary) => void;
|
||
}) {
|
||
const [query, setQuery] = useState("");
|
||
return (
|
||
<PickerList
|
||
items={filterAgents(agents, query)}
|
||
itemKey={(a) => a.agentId}
|
||
isCurrent={(a) => a.agentId === currentAgentId}
|
||
query={query}
|
||
onQueryChange={setQuery}
|
||
// Quick search: supports agentId / display name
|
||
searchPlaceholder={S.chat.agentSearchPlaceholder}
|
||
emptyText={S.chat.agentsNoMatch}
|
||
onPick={onPick}
|
||
renderRow={(a) => (
|
||
<>
|
||
<AgentAvatar
|
||
id={a.agentId}
|
||
name={agentDisplayName(a)}
|
||
size={16}
|
||
className="shrink-0 rounded"
|
||
/>
|
||
<span className="shrink-0 font-mono text-gray-800 dark:text-gray-200">{a.agentId}</span>
|
||
{a.name && a.name !== a.agentId && (
|
||
<span className="min-w-0 flex-1 truncate text-gray-400 dark:text-gray-500">
|
||
{a.name}
|
||
</span>
|
||
)}
|
||
</>
|
||
)}
|
||
/>
|
||
);
|
||
}
|
||
|
||
/**
|
||
* Popup frame shared by the two `/` switch pickers (`/model`, `/agent`): the upward-opening
|
||
* panel and its title bar. It opens upward from the composer and is height-capped to the room
|
||
* actually measured above it (see upwardMaxH), so it can never render off-screen; the panel has
|
||
* no trigger button of its own, so dismissal (click-outside / Escape) is handled by the host.
|
||
*/
|
||
function SwitchPickerPanel({
|
||
panelRef,
|
||
maxHeight,
|
||
title,
|
||
children,
|
||
}: {
|
||
panelRef: RefObject<HTMLDivElement | null>;
|
||
maxHeight: number | undefined;
|
||
title: string;
|
||
children: ReactNode;
|
||
}) {
|
||
return (
|
||
<div
|
||
ref={panelRef}
|
||
style={{ maxHeight }}
|
||
className="anim-pop absolute bottom-full left-0 z-40 mb-1.5 flex w-80 max-w-[calc(100vw-2rem)] flex-col overflow-hidden rounded-md border border-gray-200 bg-white py-1 shadow-lg dark:border-gray-700 dark:bg-gray-900"
|
||
>
|
||
<div className="border-b border-gray-100 px-3 pb-1.5 pt-0.5 text-xs font-semibold text-gray-500 dark:border-gray-800 dark:text-gray-400">
|
||
{title}
|
||
</div>
|
||
{children}
|
||
</div>
|
||
);
|
||
}
|
||
|
||
/** Spark glyph for the thinking-level picker (24x24 line path, consistent with the toolbar icon set). */
|
||
const SPARK_ICON = "M12 3l1.9 5.1L19 10l-5.1 1.9L12 17l-1.9-5.1L5 10l5.1-1.9L12 3z";
|
||
|
||
/**
|
||
* Conversation-time thinking-level picker, used in two places. Both variants list only the
|
||
* concrete levels (per review: a title bar names the control; short names only, no
|
||
* descriptions, no "default"/"follow" row, and no "none" — many models cannot disable
|
||
* thinking; a stored legacy "none" still displays via the label table, just never offered):
|
||
* - Draft state (docked left of the model selector): shows the **selected Agent's** current
|
||
* `model.thinking_level` and writes a picked level straight through to the Agent settings —
|
||
* it applies to the session created on first send and becomes the Agent's new default
|
||
* (switch-becomes-default). An Agent without an explicit override shows an em dash until a
|
||
* level is picked.
|
||
* - Active session: the level is a **per-turn parameter** sent with each task. The displayed
|
||
* value initializes to the Agent config's level and auto-follows it while the user hasn't
|
||
* picked (the parent resolves the display value and keeps omitting the level from tasks
|
||
* until touched); an explicit pick sticks for the session and rides on every subsequent
|
||
* send, never writing through to the Agent config.
|
||
*/
|
||
function ThinkingLevelSelect({
|
||
value,
|
||
onChange,
|
||
disabled,
|
||
direction = "down",
|
||
}: {
|
||
/** Level to display and mark selected ("" = none to show yet); null = the Agent config is still loading (draft). */
|
||
value: string | null;
|
||
onChange: (level: string) => void;
|
||
disabled: boolean;
|
||
/** Popup direction: down for the draft card (room below), up for the bottom-docked session composer. */
|
||
direction?: "down" | "up";
|
||
}) {
|
||
const [open, setOpen] = useState(false);
|
||
const label =
|
||
value === null ? "…" : (thinkingLevelLabel(S.chat.thinkingLevelNames, value) ?? "—");
|
||
return (
|
||
<Dropdown
|
||
open={open}
|
||
setOpen={setOpen}
|
||
menuClass="w-max min-w-36"
|
||
portal={{ direction, align: "right" }}
|
||
button={
|
||
<button
|
||
type="button"
|
||
title={`${S.chat.thinkingLevel}:${label}`}
|
||
aria-label={S.chat.thinkingLevel}
|
||
disabled={disabled || value === null}
|
||
onClick={() => setOpen(!open)}
|
||
className="flex h-8 max-w-36 shrink-0 items-center gap-1.5 rounded-md px-2 text-xs text-gray-500 transition-colors duration-150 hover:bg-gray-100 hover:text-gray-800 disabled:cursor-not-allowed disabled:opacity-50 dark:text-gray-400 dark:hover:bg-gray-800 dark:hover:text-gray-200"
|
||
>
|
||
<GlyphIcon d={SPARK_ICON} size={14} className="shrink-0" />
|
||
{/* When the card is narrower than @md, only the icon remains (title shows the full state). */}
|
||
<span className="hidden min-w-0 truncate @md:block">{label}</span>
|
||
<svg
|
||
width="10"
|
||
height="10"
|
||
viewBox="0 0 12 12"
|
||
fill="none"
|
||
stroke="currentColor"
|
||
className="shrink-0"
|
||
aria-hidden
|
||
>
|
||
<path
|
||
d="M3 4.5l3 3 3-3"
|
||
strokeWidth="1.5"
|
||
strokeLinecap="round"
|
||
strokeLinejoin="round"
|
||
/>
|
||
</svg>
|
||
</button>
|
||
}
|
||
>
|
||
{/* Title bar: names the control (the rows themselves are just the short names). */}
|
||
<div className="border-b border-gray-100 px-3 pb-1.5 pt-0.5 text-xs font-semibold text-gray-500 dark:border-gray-800 dark:text-gray-400">
|
||
{S.chat.thinkingLevel}
|
||
</div>
|
||
{SELECTABLE_THINKING_LEVELS.map((level) => (
|
||
<button
|
||
key={level}
|
||
type="button"
|
||
onClick={() => {
|
||
onChange(level);
|
||
setOpen(false);
|
||
}}
|
||
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 dark:hover:bg-gray-800 ${
|
||
level === value
|
||
? "font-medium text-gray-900 dark:text-gray-100"
|
||
: "text-gray-600 dark:text-gray-400"
|
||
}`}
|
||
>
|
||
<span className="min-w-0 flex-1 truncate">
|
||
{S.chat.thinkingLevelNames[level] ?? level}
|
||
</span>
|
||
<span className="w-3 shrink-0 text-center">{level === value ? "✓" : ""}</span>
|
||
</button>
|
||
))}
|
||
</Dropdown>
|
||
);
|
||
}
|
||
|
||
/**
|
||
* 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 "+" menu's settings row in draft state and active
|
||
* sessions alike.
|
||
*/
|
||
type SteerMode = "steer" | "followup";
|
||
const STEER_MODE_KEY = "penguin.steerMode";
|
||
function initialSteerMode(): SteerMode {
|
||
return localStorage.getItem(STEER_MODE_KEY) === "followup" ? "followup" : "steer";
|
||
}
|
||
|
||
/** Sliders icon (24×24 line path) for the mid-run send-mode settings row. */
|
||
const SLIDERS_ICON = "M4 21v-7M4 10V3M12 21v-9M12 8V3M20 21v-5M20 12V3M1 14h6M9 8h6M17 16h6";
|
||
|
||
/**
|
||
* 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 SteerModeRow({
|
||
steerMode,
|
||
onChangeSteerMode,
|
||
}: {
|
||
steerMode: SteerMode;
|
||
onChangeSteerMode: (mode: SteerMode) => void;
|
||
}) {
|
||
// 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-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"
|
||
}`}
|
||
>
|
||
{label}
|
||
</button>
|
||
);
|
||
return (
|
||
<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>
|
||
</div>
|
||
);
|
||
}
|
||
|
||
/**
|
||
* Multi-select skills dropdown (bottom toolbar, after approval mode): styled like the model
|
||
* selector — button = book icon + "Skills" label + selected-count badge (no badge at 0; when the
|
||
* card is narrower than @md the label hides, leaving just icon + badge); menu = top search box
|
||
* (filters by name and localized description) + option rows (name in monospace + truncated
|
||
* description + selected checkmark). Multi-select semantics: clicking a row toggles its
|
||
* selection and **the menu stays open**; closes on Escape / click outside (built into Dropdown).
|
||
* Shows empty-state copy when no skills are installed (prompting to add some from the skill
|
||
* library). Popup direction depends on context (same as the approval mode selector).
|
||
*/
|
||
function SkillSelect({
|
||
skills,
|
||
selected,
|
||
onToggle,
|
||
disabled,
|
||
direction = "up",
|
||
}: {
|
||
skills: SkillMetadataItem[];
|
||
selected: string[];
|
||
onToggle: (name: string) => void;
|
||
disabled: boolean;
|
||
direction?: "up" | "down";
|
||
}) {
|
||
const { locale } = useLocale();
|
||
const [open, setOpen] = useState(false);
|
||
const [query, setQuery] = useState("");
|
||
const filtered = filterSkills(skills, locale, query);
|
||
return (
|
||
<Dropdown
|
||
open={open}
|
||
setOpen={setOpen}
|
||
// As wide as reasonably possible so descriptions stay readable; portal placement clamps
|
||
// it to the viewport, so the old hand-tuned anchor-offset clamps are no longer needed.
|
||
menuClass="w-[26rem]"
|
||
portal={{ direction, align: "left" }}
|
||
button={
|
||
<button
|
||
type="button"
|
||
aria-label={S.chat.skillsSelect}
|
||
title={S.chat.skillsSelect}
|
||
disabled={disabled}
|
||
onClick={() => {
|
||
const next = !open;
|
||
setOpen(next);
|
||
if (next) setQuery(""); // Always start from the full list each time it opens
|
||
}}
|
||
className="flex h-8 max-w-44 shrink-0 items-center gap-1.5 rounded-md px-2 text-xs text-gray-500 transition-colors duration-150 hover:bg-gray-100 hover:text-gray-800 disabled:cursor-not-allowed disabled:opacity-50 dark:text-gray-400 dark:hover:bg-gray-800 dark:hover:text-gray-200"
|
||
>
|
||
<GlyphIcon d={BOOK_ICON} size={14} className="shrink-0" />
|
||
{/* When the card is narrower than @md, only the icon + badge remain (title shows the full name). */}
|
||
<span className="hidden min-w-0 truncate @md:block">{S.chat.skillsSelect}</span>
|
||
{/* Selected-count badge (the chip row above the input mirrors the selection too). */}
|
||
{selected.length > 0 && (
|
||
<span className="shrink-0 rounded-full bg-gray-200/80 px-1.5 py-px font-mono text-[10px] font-semibold text-gray-700 dark:bg-gray-700/60 dark:text-gray-200">
|
||
{selected.length}
|
||
</span>
|
||
)}
|
||
<svg
|
||
width="10"
|
||
height="10"
|
||
viewBox="0 0 12 12"
|
||
fill="none"
|
||
stroke="currentColor"
|
||
className="shrink-0"
|
||
aria-hidden
|
||
>
|
||
<path
|
||
d="M3 4.5l3 3 3-3"
|
||
strokeWidth="1.5"
|
||
strokeLinecap="round"
|
||
strokeLinejoin="round"
|
||
/>
|
||
</svg>
|
||
</button>
|
||
}
|
||
>
|
||
{/* Quick search: filters by skill name and localized description */}
|
||
<div className="border-b border-gray-100 px-2 pb-1.5 pt-0.5 dark:border-gray-800">
|
||
<input
|
||
autoFocus
|
||
value={query}
|
||
onChange={(e) => setQuery(e.target.value)}
|
||
placeholder={S.chat.skillsSearchPlaceholder}
|
||
aria-label={S.chat.skillsSearchPlaceholder}
|
||
{...noAutofill}
|
||
className="w-full rounded border border-transparent bg-transparent px-1 py-0.5 text-xs text-gray-700 placeholder:text-gray-400 focus:outline-none dark:text-gray-200 dark:placeholder:text-gray-500"
|
||
/>
|
||
</div>
|
||
<div className="max-h-56 overflow-y-auto">
|
||
{skills.length === 0 ? (
|
||
<p className="px-3 py-1.5 text-xs text-gray-400">{S.chat.skillsEmptyHint}</p>
|
||
) : filtered.length === 0 ? (
|
||
<p className="px-3 py-1.5 text-xs text-gray-400">{S.chat.skillsNoMatch}</p>
|
||
) : (
|
||
filtered.map((s) => {
|
||
const on = selected.includes(s.name);
|
||
return (
|
||
<button
|
||
key={s.name}
|
||
type="button"
|
||
aria-pressed={on}
|
||
onClick={() => onToggle(s.name)}
|
||
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 dark:hover:bg-gray-800 ${
|
||
on
|
||
? "font-medium text-gray-900 dark:text-gray-100"
|
||
: "text-gray-600 dark:text-gray-400"
|
||
}`}
|
||
>
|
||
{/* Each skill's custom icon (icon.svg, sanitized and inlined; falls back to the book icon if missing). */}
|
||
<SkillIcon
|
||
icon={s.icon}
|
||
size={14}
|
||
className="shrink-0 text-gray-400 dark:text-gray-500"
|
||
/>
|
||
<span className="shrink-0 font-mono">{s.name}</span>
|
||
{/* Prefers the short description (falls back to the full description if missing), per the UI language. */}
|
||
<span className="min-w-0 flex-1 truncate text-gray-400 dark:text-gray-500">
|
||
{localizedShortText(locale, s)}
|
||
</span>
|
||
<span className="w-3 shrink-0 text-center">{on ? "✓" : ""}</span>
|
||
</button>
|
||
);
|
||
})
|
||
)}
|
||
</div>
|
||
</Dropdown>
|
||
);
|
||
}
|
||
|
||
/**
|
||
* Picture glyph (24×24 line path) for the "+" menu's image-upload entry: the framing rectangle
|
||
* and the mountain line as two subpaths of one `d`, since GlyphIcon renders a single `<path>`.
|
||
*/
|
||
const IMAGE_ICON =
|
||
"M6 5h12a3 3 0 0 1 3 3v8a3 3 0 0 1-3 3H6a3 3 0 0 1-3-3V8a3 3 0 0 1 3-3zM3 15l5-5 4 4 3-3 6 6";
|
||
|
||
/** 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;
|
||
run: () => void;
|
||
}
|
||
|
||
/**
|
||
* Context usage: a **single-color** ring indicator (only conveys total usage, no bucketing) +
|
||
* `used/window` (amber above 80%, red above 95%). When the model has no `context_window`
|
||
* configured, resolveContextWindow falls back to 128000 and the ring is drawn as usual — the
|
||
* usage ratio always has a reference point, instead of degrading into a lone number when config
|
||
* is missing.
|
||
*
|
||
* `unknown` (after a successful compaction, before the next regular Request reports usage): draws
|
||
* an empty ring, value shown as `—`. **Must not be drawn as 0** — that would claim the context
|
||
* has been cleared, while the summary itself still occupies tokens; at this point we simply
|
||
* haven't measured yet, not measured to be zero.
|
||
*/
|
||
function ContextGauge({
|
||
now,
|
||
window: win,
|
||
unknown = false,
|
||
}: {
|
||
now: number;
|
||
window?: number;
|
||
unknown?: boolean;
|
||
}) {
|
||
const max = resolveContextWindow(win);
|
||
const pct = unknown ? 0 : Math.min(1, now / max);
|
||
const color =
|
||
unknown || pct <= 0.8
|
||
? "text-gray-400 dark:text-gray-500"
|
||
: pct > 0.95
|
||
? "text-red-500"
|
||
: "text-amber-500";
|
||
const R = 5;
|
||
const C = 2 * Math.PI * R;
|
||
return (
|
||
<span
|
||
title={unknown ? S.chat.contextUnknown : `${S.chat.contextUsage} ${Math.round(pct * 100)}%`}
|
||
className={`flex shrink-0 items-center gap-1 font-mono ${color}`}
|
||
>
|
||
<svg width="14" height="14" viewBox="0 0 14 14" aria-hidden className="block shrink-0">
|
||
<circle
|
||
cx="7"
|
||
cy="7"
|
||
r={R}
|
||
fill="none"
|
||
stroke="currentColor"
|
||
strokeOpacity="0.25"
|
||
strokeWidth="2"
|
||
/>
|
||
<circle
|
||
cx="7"
|
||
cy="7"
|
||
r={R}
|
||
fill="none"
|
||
stroke="currentColor"
|
||
strokeWidth="2"
|
||
strokeLinecap="round"
|
||
strokeDasharray={`${C * pct} ${C}`}
|
||
transform="rotate(-90 7 7)"
|
||
/>
|
||
</svg>
|
||
{/* The ring alone carries the meaning on phones: the numbers hide below @md (the title
|
||
still shows the exact usage), keeping the right-hand control group inside a 320px
|
||
viewport in the running state. */}
|
||
<span className="hidden @md:inline">
|
||
{unknown ? "—" : humanizeTokens(now)}/{humanizeTokens(max)}
|
||
</span>
|
||
</span>
|
||
);
|
||
}
|
||
|
||
/**
|
||
* One file attachment staged in the composer. `dataUrl` is the base64 `data:` URL sent as the
|
||
* task input's `file` part; `name` / `size` only feed the chip (the server decides the name the
|
||
* file actually gets on disk).
|
||
*/
|
||
interface Attachment {
|
||
name: string;
|
||
size: number;
|
||
dataUrl: string;
|
||
}
|
||
|
||
/** Mirrors the server's per-file attachment cap (services/task-attachments.ts), so an oversize pick is refused here instead of costing an upload and a 413. */
|
||
const MAX_ATTACHMENT_BYTES = 10 * 1024 * 1024;
|
||
|
||
/** Reads one file as a base64 data URL; resolves to null on a read error rather than rejecting, so one unreadable file cannot drop the rest of the batch. */
|
||
function readDataUrl(file: File): Promise<string | null> {
|
||
return new Promise((resolve) => {
|
||
const reader = new FileReader();
|
||
reader.onload = () => resolve(typeof reader.result === "string" ? reader.result : null);
|
||
reader.onerror = () => resolve(null);
|
||
reader.readAsDataURL(file);
|
||
});
|
||
}
|
||
|
||
/**
|
||
* Appends the draft's attachments to a task input — images first (in pick order), then files.
|
||
* One place, because every send path submits the same draft: the normal send, the follow-up
|
||
* queue, the @ handoff and the `/model` switch.
|
||
*/
|
||
function appendAttachmentParts(
|
||
input: TaskInputPart[],
|
||
images: string[],
|
||
attachments: Attachment[],
|
||
): void {
|
||
for (const url of images) input.push({ type: "image_url", imageUrl: url });
|
||
for (const file of attachments) {
|
||
input.push({ type: "file", fileName: file.name, dataUrl: file.dataUrl });
|
||
}
|
||
}
|
||
|
||
export function ChatInput({
|
||
status,
|
||
onSend,
|
||
onSteer,
|
||
steeringDeliveredCount,
|
||
onQueueFollowUp,
|
||
queuedFollowUps = 0,
|
||
onStop,
|
||
onCompact,
|
||
modelRef,
|
||
models,
|
||
onChangeModel,
|
||
onSwitchModel,
|
||
defaultModel,
|
||
thinkingLevel,
|
||
onChangeThinkingLevel,
|
||
turnThinkingLevel,
|
||
onChangeTurnThinkingLevel,
|
||
contextWindow,
|
||
contextNow,
|
||
contextStale = false,
|
||
vision,
|
||
approvalMode,
|
||
onChangeApprovalMode,
|
||
modeSaving,
|
||
autoFocus,
|
||
agents,
|
||
currentAgentId,
|
||
skills,
|
||
initialSkills,
|
||
onSkillsChange,
|
||
onHandoff,
|
||
initialText,
|
||
onTextChange,
|
||
initialHandoffTargetId,
|
||
onHandoffTargetChange,
|
||
initialPendingModelRef,
|
||
onPendingModelChange,
|
||
modelAuthDead = false,
|
||
onOpenModels,
|
||
onRetryModelAuth,
|
||
onNewSession,
|
||
}: {
|
||
status: SessionStatus;
|
||
/**
|
||
* 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
|
||
* `[user_steering]` user message. `"queued"` clears the text and shows the queued hint;
|
||
* `"not_running"` (409 race with completion) makes the input fall back to its full normal
|
||
* send path; `"failed"` keeps the draft. When absent (draft state), the input stays
|
||
* send-disabled while running, as before.
|
||
*/
|
||
onSteer?: (text: string) => Promise<"queued" | "not_running" | "failed">;
|
||
/**
|
||
* Count of steering messages already visible in the message stream: the queued hint stays
|
||
* up until this increases past its value at queue time (i.e. the message was delivered).
|
||
*/
|
||
steeringDeliveredCount?: number;
|
||
/**
|
||
* Follow-up queue (session state only): posts the full input with `queueIfBusy` — a busy
|
||
* session holds it server-side and auto-sends it as an ordinary next task once the current
|
||
* run finishes. Offered as the "follow-up" choice of the mid-run mode switch; when absent,
|
||
* only steering is offered while running.
|
||
*/
|
||
onQueueFollowUp?: (input: TaskInputPart[]) => Promise<boolean>;
|
||
/** Server-reported queued follow-up count (from task_state): renders the "N queued" hint until they auto-send. */
|
||
queuedFollowUps?: number;
|
||
/**
|
||
* Used instead of onSend when an `/agent` target chip is staged: opens a new chat for the
|
||
* target agent (the current Session receives no message). Returns whether it succeeded
|
||
* (draft kept on failure). Supplied for an active Session only — a draft has no conversation
|
||
* to hand over, so `/agent` is not offered there (same gating as `/model`'s onSwitchModel).
|
||
*/
|
||
onHandoff?: (target: AgentSummary, input: TaskInputPart[]) => Promise<boolean>;
|
||
onStop: () => Promise<void>;
|
||
onCompact: () => Promise<void>;
|
||
/** Currently selected model reference ((provider, modelId) is the unique key); null = not yet chosen. */
|
||
modelRef: ModelRefDto | null;
|
||
/**
|
||
* Candidate model list: when supplied together with onChangeModel, renders the model selector
|
||
* to the left of the send button (draft state); when only models is supplied (session state),
|
||
* it's used to look up the locked model's display name (read-only display).
|
||
*/
|
||
models?: ModelInfo[];
|
||
/** Changes the selected model in draft state; no longer passed once the Session is created and the model is locked. */
|
||
onChangeModel?: (ref: ModelRefDto) => void;
|
||
/**
|
||
* Session state: model switch via the `/model` command — forks the session onto the picked
|
||
* model (a NEW session carrying this conversation) and navigates there; the draft written
|
||
* after the pick is posted as the new session's first task. Returns whether it succeeded
|
||
* (draft kept on failure). Only passed for an active session (the command is additionally
|
||
* gated on not running/compacting); picking the current model is a no-op.
|
||
*/
|
||
onSwitchModel?: (ref: ModelRefDto, input: TaskInputPart[]) => Promise<boolean>;
|
||
/** Project default model (marked "default" on the selector's candidate item). */
|
||
defaultModel?: ModelRefDto;
|
||
/**
|
||
* Draft state: the selected Agent's current thinking level ("" = no override / provider
|
||
* default; null while the Agent config is loading — the picker renders disabled). Supplied
|
||
* together with onChangeThinkingLevel; without the callback the picker isn't rendered.
|
||
*/
|
||
thinkingLevel?: string | null;
|
||
/**
|
||
* Draft state: writes the picked level straight through to the Agent settings (the parent
|
||
* persists it via the agent-config API; the session created on first send picks it up and it
|
||
* becomes the Agent's new default).
|
||
*/
|
||
onChangeThinkingLevel?: (level: string) => void;
|
||
/**
|
||
* Session state: the per-turn thinking level to DISPLAY — the parent resolves it as "the
|
||
* user's pick for this session, else the Agent config's level" ("" = neither known yet),
|
||
* so the picker auto-follows the config until touched. The send path is the parent's own
|
||
* state: while untouched nothing is sent with tasks (the server/core fallback applies and
|
||
* mid-session Agent-config edits keep taking effect); an explicit pick sticks and is sent
|
||
* with every subsequent task, never writing through to the Agent config (that behavior
|
||
* stays draft-only).
|
||
*/
|
||
turnThinkingLevel?: string;
|
||
/** Session state: pins the per-turn thinking level for this session; also enables the editable picker. */
|
||
onChangeTurnThinkingLevel?: (level: string) => void;
|
||
/** Model's context window (from models config; when not configured, the ring's cap falls back to 128000 via resolveContextWindow). */
|
||
contextWindow?: number;
|
||
/** Current context usage (total of the most recent main-session Request). */
|
||
contextNow: number;
|
||
/** After a successful compaction, before the next regular Request reports usage: usage is **unknown** (not 0); the ring is drawn empty and the value shown as `—`. */
|
||
contextStale?: boolean;
|
||
/** Whether the current model supports image input (models config's vision; assumed supported by default). */
|
||
vision: boolean;
|
||
approvalMode: ApprovalMode;
|
||
onChangeApprovalMode: (mode: ApprovalMode) => void;
|
||
modeSaving: boolean;
|
||
autoFocus?: boolean;
|
||
/** Agent list of the current Project: the `/agent` command's candidates (without any, the command isn't offered). */
|
||
agents: AgentSummary[];
|
||
/** The Agent this composer already belongs to (the Session's, or the draft's selection): marked as the current entry in the `/agent` picker. */
|
||
currentAgentId?: string;
|
||
/**
|
||
* Skills installed on the current Agent (in session state, fetched by chat-page keyed on the
|
||
* Session's Agent; in draft state, fetched by draft-view keyed on the selected Agent; a failed
|
||
* fetch is treated as no skills): candidates for the bottom toolbar's skills dropdown, and the
|
||
* same source feeds the slash menu's skill command entries. When the Agent changes, the parent
|
||
* clears this first before refetching, and the selection clears along with it (doesn't linger
|
||
* across Agents).
|
||
*/
|
||
skills: SkillMetadataItem[];
|
||
/**
|
||
* Initially selected skill names (draft restore; the skill library page's quick-invoke writes
|
||
* this into the draft cache): read once on mount; once the installed list is ready, names not
|
||
* in that list are pruned.
|
||
*/
|
||
initialSkills?: string[];
|
||
/** Callback when selected skills change (check/prune; the clear after a successful send does not call back, same as onTextChange). */
|
||
onSkillsChange?: (names: string[]) => void;
|
||
/** Draft's initial text (restored on mount; paired with onTextChange for draft auto-caching). */
|
||
initialText?: string;
|
||
/**
|
||
* Callback when the user edits the text body (including paths that rewrite the text such as
|
||
* running a slash command); the clear after a successful send does **not** call back — at
|
||
* that point the parent has already cleared the draft cache entirely, and calling back would
|
||
* resurrect it.
|
||
*/
|
||
onTextChange?: (text: string) => void;
|
||
/** Draft restore: the agentId of the staged handoff target (resolved once agents are ready; discarded if stale). */
|
||
initialHandoffTargetId?: string;
|
||
/** Callback when the staged handoff target changes (picked/removed; the clear after a successful send does not call back, same as onTextChange). */
|
||
onHandoffTargetChange?: (agentId: string | null) => void;
|
||
/**
|
||
* Draft restore: the staged `/model` switch target (resolved once models are ready; discarded
|
||
* when that model is gone or is the one this session already runs). Cached for the same
|
||
* reason as the handoff target — the composer text survives an unmount, so its chip must too,
|
||
* or Enter would post the message to the current session on the old model.
|
||
*/
|
||
initialPendingModelRef?: ModelRefDto;
|
||
/** Callback when the staged model switch changes (picked/removed; the clear after a successful send does not call back, same as onTextChange). */
|
||
onPendingModelChange?: (ref: ModelRefDto | null) => void;
|
||
/**
|
||
* Session state: the Session's model credentials failed authentication (abort with
|
||
* status "auth") and the Project's credentials have not been updated since (the parent
|
||
* computes the time gate — see isModelAuthDead). Recoverable: only the model reference
|
||
* is fixed at creation, credentials come from the current Project config — so the
|
||
* composer disables and grays itself with a notice pointing at the Models page (primary),
|
||
* a Retry affordance, and a New Session escape. Updating the key auto-unlocks (live via
|
||
* the credentials_updated event, and across reloads via the time gate).
|
||
*/
|
||
modelAuthDead?: boolean;
|
||
/** Navigates to the Models page (where the credential is actually fixed); renders the notice's primary button when supplied. */
|
||
onOpenModels?: () => void;
|
||
/**
|
||
* Clears the auth-dead state and re-enables the composer for another attempt (the state
|
||
* re-arms on the next auth failure). The escape hatch for credential changes the
|
||
* timestamps miss (e.g. edited outside the UI).
|
||
*/
|
||
onRetryModelAuth?: () => void;
|
||
/** Navigates to a fresh draft (`/chat/new`); renders the notice's "New Session" button when supplied. */
|
||
onNewSession?: () => void;
|
||
}) {
|
||
const { locale } = useLocale();
|
||
const [text, setText] = useState(initialText ?? "");
|
||
/** Live text mirror for slash-command run() closures (the commands memo deliberately doesn't depend on text). */
|
||
const textRef = useRef(text);
|
||
textRef.current = text;
|
||
const [images, setImages] = useState<string[]>([]);
|
||
// File attachments picked from the "+" menu (any type): held as base64 data URLs, exactly
|
||
// like images — a draft has no Session yet, so there is nothing to upload them to ahead of
|
||
// time; they travel with the task request and the server files them into the scratchpad.
|
||
const [attachments, setAttachments] = useState<Attachment[]>([]);
|
||
const [busy, setBusy] = useState(false);
|
||
const [slashIndex, setSlashIndex] = useState(0);
|
||
// Slash token start where Escape closed the menu: it stays shut for that one token.
|
||
const [slashDismissed, setSlashDismissed] = useState<number | null>(null);
|
||
// Switch pickers (opened by /model — session state — and /agent). Each command consumes its
|
||
// slash token immediately (same as /compact), so closing a picker — Escape, click outside, or
|
||
// the picked-current-model no-op — can never re-open the slash menu, and there is no stale
|
||
// token range to recompute at pick time; whatever text remains is the draft (and becomes the
|
||
// new session's first message once the staged switch is sent).
|
||
const [modelSwitchOpen, setModelSwitchOpen] = useState(false);
|
||
const modelSwitchRef = useRef<HTMLDivElement>(null);
|
||
const [agentSwitchOpen, setAgentSwitchOpen] = useState(false);
|
||
const agentSwitchRef = useRef<HTMLDivElement>(null);
|
||
// Anchor for the popups that open upward, and the room actually available above them.
|
||
const anchorRef = useRef<HTMLDivElement>(null);
|
||
const [upwardMaxH, setUpwardMaxH] = useState<number>();
|
||
// Staged handoff target from /agent (chip, fixed at the front of the input); only one allowed, picking again replaces it directly.
|
||
const [target, setTarget] = useState<AgentSummary | null>(null);
|
||
// Staged model switch from /model (chip too), cached in the draft exactly like the handoff
|
||
// target: the composer's text is cached and this component is keyed by session id, so a chip
|
||
// kept only in component state would disappear on a session switch while the text it belongs
|
||
// to came back — and Enter would then post that text to the current session on the old model.
|
||
const [pendingModel, setPendingModel] = useState<ModelInfo | null>(null);
|
||
// Selected skills (dropdown checklist, multi-select): initial value comes from draft restore (quick-invoke pre-selection), cleared on successful send.
|
||
const [selectedSkills, setSelectedSkills] = useState<string[]>(initialSkills ?? []);
|
||
// Cursor position (tracked via onChange/onSelect): the slash menu matches the token at the caret.
|
||
const [caret, setCaret] = useState(0);
|
||
const textareaRef = useRef<HTMLTextAreaElement>(null);
|
||
// Short placeholder on narrow screens: a long hint would wrap and get clipped in a single-line textarea.
|
||
const [narrow] = useState(() => window.matchMedia("(max-width: 767px)").matches);
|
||
|
||
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 a staged /agent or /model switch (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;
|
||
/**
|
||
* Where a send with a staged chip would go — and, for a `/model` fork, whether it may go at
|
||
* all right now (see stagedSendRoute): a fork branches a NEW session off this session's
|
||
* Trace, so it waits for the session to be idle. Both the eligibility below and the send path
|
||
* read this one value, so the button and what the button does can't disagree.
|
||
*/
|
||
const stagedRoute = stagedSendRoute({
|
||
handoffTarget: target !== null,
|
||
pendingModel: pendingModel !== null,
|
||
canSwitchModel: onSwitchModel !== undefined,
|
||
sessionBusy: running || compacting,
|
||
});
|
||
// Sending is also allowed with no text at all: attachments (images or files), a staged switch
|
||
// chip (/agent or /model) and selected skills each carry a message on their own — a handoff's
|
||
// first message may be just a [handoff_from] source block, and the empty-text fallbacks fill in
|
||
// the rest (S.chat.skillsAutoMessage with skills selected, S.chat.modelSwitchAutoMessage for a
|
||
// staged model switch — see sendNormal). 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 &&
|
||
(goalOn
|
||
? text.trim().length > 0 &&
|
||
images.length === 0 &&
|
||
attachments.length === 0 &&
|
||
goalBudget !== null &&
|
||
!(goalBudgetOpen && goalBudgetDraftInvalid)
|
||
: text.trim().length > 0 ||
|
||
images.length > 0 ||
|
||
attachments.length > 0 ||
|
||
target !== null ||
|
||
pendingModel !== 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 any staged switch chip and every attachment
|
||
* (genuinely exclusive: a handoff or a model switch 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);
|
||
setPendingModel(null);
|
||
onPendingModelChange?.(null);
|
||
// Attachments 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([]);
|
||
setAttachments([]);
|
||
}
|
||
},
|
||
[onHandoffTargetChange, onPendingModelChange],
|
||
);
|
||
|
||
// 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 — attachments /
|
||
// skills / a staged switch stay in the draft for a later normal send (a staged /agent or
|
||
// /model chip also blocks steering: the text belongs to the conversation the switch is about
|
||
// to open, not to the agent running here).
|
||
// `!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 &&
|
||
pendingModel === 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 "+" 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);
|
||
localStorage.setItem(STEER_MODE_KEY, mode);
|
||
};
|
||
const followUpMode = steerMode === "followup" && onQueueFollowUp !== undefined;
|
||
// A follow-up is a full normal message: the whole draft (text / attachments / skills / a
|
||
// staged switch) is eligible, same content rule as canSend.
|
||
// `stagedRoute !== "blocked"`: a staged model fork is never eligible mid-run — the follow-up
|
||
// path composes the whole draft and then hands it to onSwitchModel rather than to the queue,
|
||
// so without this gate Enter would fork off a Trace that is still being written.
|
||
const canFollowUp =
|
||
running &&
|
||
!busy &&
|
||
!goalOn &&
|
||
!modelAuthDead &&
|
||
followUpMode &&
|
||
stagedRoute !== "blocked" &&
|
||
(text.trim().length > 0 ||
|
||
images.length > 0 ||
|
||
attachments.length > 0 ||
|
||
target !== null ||
|
||
pendingModel !== null ||
|
||
selectedSkills.length > 0);
|
||
// The single action button's mode: while running, an **empty** composer means Stop
|
||
// (abort); as soon as there is something to send it becomes the send button (steer or
|
||
// follow-up per the remembered mode). Idle/compacting is always send.
|
||
const canMidRunSend = followUpMode ? canFollowUp : canSteer;
|
||
const midRunSendLabel = followUpMode ? S.chat.followUpSend : S.chat.steerSend;
|
||
const stopAction =
|
||
running &&
|
||
text.trim().length === 0 &&
|
||
images.length === 0 &&
|
||
attachments.length === 0 &&
|
||
target === null &&
|
||
pendingModel === null &&
|
||
selectedSkills.length === 0;
|
||
// Queued hint: shown after a successful steer until the message shows up in the stream
|
||
// (steeringDeliveredCount increases past the baseline captured at queue time) or the run
|
||
// stops being observable (task no longer running).
|
||
const [steerPending, setSteerPending] = useState(false);
|
||
const steerBaseline = useRef(0);
|
||
useEffect(() => {
|
||
if (!steerPending) return;
|
||
if (!running || (steeringDeliveredCount ?? 0) > steerBaseline.current) {
|
||
setSteerPending(false);
|
||
}
|
||
}, [steerPending, running, steeringDeliveredCount]);
|
||
|
||
/** Toggle a skill on/off (shared by dropdown option clicks and the slash skill command); the change callback lets the parent write it into the draft. */
|
||
const toggleSkill = useCallback(
|
||
(name: string) => {
|
||
const next = selectedSkills.includes(name)
|
||
? selectedSkills.filter((n) => n !== name)
|
||
: [...selectedSkills, name];
|
||
setSelectedSkills(next);
|
||
onSkillsChange?.(next);
|
||
},
|
||
[selectedSkills, onSkillsChange],
|
||
);
|
||
|
||
/** The slash token currently under the caret (kept in a ref so command run() closures always remove the live token). */
|
||
const slashMatchRef = useRef<ReturnType<typeof matchSlash>>(null);
|
||
const commands = useMemo<SlashCommand[]>(() => {
|
||
/** Removes just the slash token after a command runs (the rest of the text stays; the height re-measures itself off the new value, see the autoGrow layout effect). */
|
||
const clearInput = () => {
|
||
const match = slashMatchRef.current;
|
||
const next = match ? removeSlashToken(textRef.current, match) : "";
|
||
setText(next);
|
||
onTextChange?.(next);
|
||
};
|
||
return [
|
||
{
|
||
cmd: "/compact",
|
||
desc: S.chat.compact,
|
||
run: () => {
|
||
clearInput();
|
||
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
|
||
// /compact) and opens the picker; the rest of the draft stays.
|
||
...(onSwitchModel && models && models.length > 0
|
||
? [
|
||
{
|
||
cmd: "/model",
|
||
desc: S.chat.switchModel,
|
||
run: () => {
|
||
clearInput();
|
||
setModelSwitchOpen(true);
|
||
},
|
||
},
|
||
]
|
||
: []),
|
||
// Agent handoff: same shape as /model — the command consumes its token and opens the
|
||
// agent picker, whose pick is staged as a chip and only acted on at send time. Gated the
|
||
// same way too: the parent passes onHandoff for an active Session only, because a draft
|
||
// has nothing to hand over (and already picks its Agent in the draft page's own
|
||
// selector). Candidates must exist, or the picker would open empty.
|
||
...(onHandoff && agents.length > 0
|
||
? [
|
||
{
|
||
cmd: "/agent",
|
||
desc: S.chat.switchAgent,
|
||
run: () => {
|
||
clearInput();
|
||
setAgentSwitchOpen(true);
|
||
},
|
||
},
|
||
]
|
||
: []),
|
||
// Each installed skill gets its own entry: `/<skill_name>` toggles that skill's selection (without sending), description follows the UI language.
|
||
...skillSlashItems(skills, locale).map((s) => ({
|
||
cmd: s.cmd,
|
||
desc: s.desc,
|
||
run: () => {
|
||
clearInput();
|
||
toggleSkill(s.name);
|
||
},
|
||
})),
|
||
];
|
||
}, [
|
||
onCompact,
|
||
onSwitchModel,
|
||
models,
|
||
agents,
|
||
onTextChange,
|
||
skills,
|
||
locale,
|
||
toggleSkill,
|
||
toggleGoal,
|
||
goalOn,
|
||
]);
|
||
// Positional matching: 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 a switch picker is open (the
|
||
// picker took over the interaction, and its own search box owns the keyboard).
|
||
const slashTok =
|
||
!running && !compacting && !modelSwitchOpen && !agentSwitchOpen
|
||
? matchSlash(text, caret)
|
||
: null;
|
||
slashMatchRef.current = slashTok;
|
||
const slashMatches =
|
||
slashTok && slashTok.start !== slashDismissed
|
||
? commands.filter((c) => c.cmd.startsWith(`/${slashTok.query}`))
|
||
: [];
|
||
const slashOpen = slashMatches.length > 0;
|
||
const activeSlash = slashMatches[Math.min(slashIndex, slashMatches.length - 1)];
|
||
|
||
// Close a switch picker on click-outside / Escape (same convention as Dropdown; these panels
|
||
// have no trigger button of their own, so the handling lives here). Only one can be open at a
|
||
// time — the slash menu that opens them is suppressed while either is up.
|
||
useEffect(() => {
|
||
if (!modelSwitchOpen && !agentSwitchOpen) return;
|
||
// Dismissing the panel puts the caret back where the user was typing: the picker's search
|
||
// box stole the focus when it opened, and without this it would be left on <body>.
|
||
const closeAll = () => {
|
||
setModelSwitchOpen(false);
|
||
setAgentSwitchOpen(false);
|
||
textareaRef.current?.focus();
|
||
};
|
||
// globalThis.* event types: the React ones imported above would shadow the DOM ones here.
|
||
const onClick = (e: globalThis.MouseEvent) => {
|
||
const panel = modelSwitchOpen ? modelSwitchRef.current : agentSwitchRef.current;
|
||
if (panel && !panel.contains(e.target as Node)) closeAll();
|
||
};
|
||
const onKey = (e: globalThis.KeyboardEvent) => {
|
||
// `isComposing`: Escape while an IME candidate list is up means "drop the candidates",
|
||
// not "close the picker". Closing there would be unrecoverable — the command already
|
||
// consumed its `/agent` / `/model` token, so the user's remaining draft is all they have
|
||
// and the picker is the only way back to the pick they were making.
|
||
if (e.key === "Escape" && !e.isComposing) closeAll();
|
||
};
|
||
window.addEventListener("mousedown", onClick);
|
||
window.addEventListener("keydown", onKey);
|
||
return () => {
|
||
window.removeEventListener("mousedown", onClick);
|
||
window.removeEventListener("keydown", onKey);
|
||
};
|
||
}, [modelSwitchOpen, agentSwitchOpen]);
|
||
|
||
/** Stage a model as the /model chip (null = drop it), keeping the draft cache in step. */
|
||
const stageModel = (m: ModelInfo | null) => {
|
||
setPendingModel(m);
|
||
onPendingModelChange?.(m ? { provider: m.provider, modelId: m.modelId } : null);
|
||
};
|
||
|
||
/**
|
||
* /model pick: **stages** the model as a chip instead of switching on the spot — the user
|
||
* keeps typing and Enter/Send performs the fork (see sendNormal), so the message that opens
|
||
* the new session is the one they meant to write. Picking the CURRENT model **clears** the
|
||
* staging: forking a session onto the model it already runs is nothing but a lost
|
||
* conversation, so that pick can only mean "never mind, stay here" — leaving an earlier pick
|
||
* armed would fork onto it on the next Enter, the opposite of what was just asked for.
|
||
* Exclusive with a staged handoff target and with goal mode (the latest pick wins).
|
||
*/
|
||
const pickSwitchModel = (m: ModelInfo) => {
|
||
setModelSwitchOpen(false);
|
||
textareaRef.current?.focus();
|
||
if (sameModelRef(m, modelRef)) {
|
||
stageModel(null);
|
||
return;
|
||
}
|
||
stageModel(m);
|
||
setTarget(null);
|
||
onHandoffTargetChange?.(null);
|
||
setGoalOn(false);
|
||
};
|
||
|
||
/**
|
||
* /agent pick: stages the target agent as the handoff chip — nothing is sent yet, and the
|
||
* draft text is left alone (Enter/Send hands it to the new chat; an empty body still opens
|
||
* one, carrying just the [handoff_from] block). Exclusive with a staged model switch and with
|
||
* goal mode, exactly like the model pick above; the target is cached in the draft so the chip
|
||
* survives a reload.
|
||
*/
|
||
const pickHandoffTarget = (agent: AgentSummary) => {
|
||
setAgentSwitchOpen(false);
|
||
setTarget(agent);
|
||
onHandoffTargetChange?.(agent.agentId);
|
||
stageModel(null);
|
||
setGoalOn(false);
|
||
textareaRef.current?.focus();
|
||
};
|
||
|
||
/** Drop whichever switch chip is staged (the chips' x buttons, and Backspace at the start of the text). */
|
||
const clearSwitchTarget = () => {
|
||
if (target !== null) {
|
||
setTarget(null);
|
||
onHandoffTargetChange?.(null);
|
||
}
|
||
stageModel(null);
|
||
};
|
||
|
||
// The menus above are drawn upward (`bottom-full`) from the composer, so their ceiling is
|
||
// whatever ancestor clips overflow — on the draft page that's the centered scroll area, whose
|
||
// top edge sits well below the viewport's. A static `40vh` cap can't know that distance and
|
||
// clipped the first rows on shorter windows, so measure the real gap when a menu opens.
|
||
useEffect(() => {
|
||
if (!slashOpen && !modelSwitchOpen && !agentSwitchOpen) return;
|
||
const measure = () => {
|
||
const el = anchorRef.current;
|
||
if (!el) return;
|
||
let ceiling = 0;
|
||
for (let p = el.parentElement; p; p = p.parentElement) {
|
||
if (getComputedStyle(p).overflowY !== "visible") {
|
||
ceiling = p.getBoundingClientRect().top;
|
||
break;
|
||
}
|
||
}
|
||
// Less the menu's own 6px offset from the composer, plus a little breathing room.
|
||
const room = el.getBoundingClientRect().top - ceiling - 14;
|
||
setUpwardMaxH(Math.max(96, Math.min(320, Math.round(room))));
|
||
};
|
||
measure();
|
||
window.addEventListener("resize", measure);
|
||
return () => window.removeEventListener("resize", measure);
|
||
}, [slashOpen, modelSwitchOpen, agentSwitchOpen]);
|
||
|
||
/** Auto-grow the textarea (caps at roughly 6 lines, scrolls internally beyond that). */
|
||
const autoGrow = () => {
|
||
const el = textareaRef.current;
|
||
if (!el) return;
|
||
el.style.height = "auto";
|
||
el.style.height = `${Math.min(el.scrollHeight, 176)}px`;
|
||
};
|
||
|
||
/**
|
||
* The height follows the **rendered** value, measured in a layout effect — the single place
|
||
* that sizes the box, covering mount (a restored multi-line draft), typing, and the clear
|
||
* after a send alike.
|
||
*
|
||
* It must be a layout effect keyed on `text` rather than a call next to each `setText`: the
|
||
* textarea is controlled, so after `setText("")` the DOM still holds the old text until React
|
||
* commits. A `requestAnimationFrame` scheduled alongside the state update races that commit
|
||
* and, when it wins, measures the old multi-line content and re-pins the tall height — with
|
||
* nothing left to measure again, the composer stayed expanded after every send. A layout
|
||
* effect runs after the commit by construction, and before paint, so the box never flashes.
|
||
*/
|
||
useLayoutEffect(autoGrow, [text]);
|
||
|
||
// Cursor placement on mount: move it to the end of a restored draft (by default the browser
|
||
// places the cursor at the start when focusing a textarea that already has content), so typing
|
||
// continues the text naturally, and sync the caret state to match (the slash menu matches the
|
||
// token at the cursor).
|
||
useEffect(() => {
|
||
const el = textareaRef.current;
|
||
if (el && el.value.length > 0) {
|
||
const end = el.value.length;
|
||
el.setSelectionRange(end, end);
|
||
el.scrollTop = el.scrollHeight;
|
||
setCaret(end);
|
||
}
|
||
// eslint-disable-next-line react-hooks/exhaustive-deps
|
||
}, []);
|
||
|
||
// Installed skills change (Agent switch triggers a refetch: parent clears first, then
|
||
// updates): the selection keeps only skills that are still available — the tick where it's
|
||
// cleared wipes the whole selection, so nothing lingers across Agents. The first tick on mount
|
||
// is skipped: at that point the installed list hasn't been fetched yet (skills is empty), and
|
||
// pruning would wrongly clear initialSkills (quick-invoke pre-selection); prune only once the
|
||
// list is ready for the first time (the parent's clear uses functional setState to preserve
|
||
// reference identity, so an empty-to-empty clear doesn't trigger this effect).
|
||
const skillsPruneReady = useRef(false);
|
||
useEffect(() => {
|
||
if (!skillsPruneReady.current) {
|
||
skillsPruneReady.current = true;
|
||
if (skills.length === 0) return;
|
||
}
|
||
const next = selectedSkills.filter((n) => skills.some((s) => s.name === n));
|
||
if (next.length === selectedSkills.length) return;
|
||
setSelectedSkills(next);
|
||
onSkillsChange?.(next);
|
||
}, [skills, selectedSkills, onSkillsChange]);
|
||
|
||
/**
|
||
* The two chips are restored from the draft cache by two effects that fire whenever their own
|
||
* list finishes loading — and `agents` and `models` are separate fetches, so either can land
|
||
* first, possibly after the user has already staged something by hand. `staged` is what keeps
|
||
* that from painting two chips at once (which sendNormal would silently resolve in favour of
|
||
* the handoff): a restore only fills an EMPTY slot. When the user has staged a chip or turned
|
||
* goal mode on in the meantime, that live intent is newer than the cached one and wins — the
|
||
* restore is dropped, not merely deferred, exactly as one pick drops the other.
|
||
*
|
||
* Only one of the two can be cached at a time anyway (each pick clears the other's cache
|
||
* entry), so in the ordinary case this changes nothing.
|
||
*/
|
||
const staged = target !== null || pendingModel !== null || goalOn;
|
||
|
||
// Restore the cached handoff target: resolved once by id when agents becomes ready for
|
||
// the first time (discarded if stale); a chip the user manually removes afterward is not restored again.
|
||
const handoffRestored = useRef(false);
|
||
useEffect(() => {
|
||
if (handoffRestored.current || !initialHandoffTargetId || agents.length === 0) return;
|
||
handoffRestored.current = true;
|
||
if (staged) return;
|
||
const restored = agents.find((a) => a.agentId === initialHandoffTargetId);
|
||
if (restored) setTarget(restored);
|
||
}, [agents, initialHandoffTargetId, staged]);
|
||
|
||
// Restore the cached /model switch target, mirroring the handoff restore above: resolved once
|
||
// against the model list when it first becomes ready. Dropped when that model is no longer
|
||
// configured, or when it is the model this session already runs on (the cache outlived a
|
||
// fork), since staging either would leave a chip that can only lose the conversation.
|
||
const pendingModelRestored = useRef(false);
|
||
useEffect(() => {
|
||
if (pendingModelRestored.current || !initialPendingModelRef || !models || models.length === 0) {
|
||
return;
|
||
}
|
||
pendingModelRestored.current = true;
|
||
if (staged || !onSwitchModel) return;
|
||
const restored = models.find((m) => sameModelRef(m, initialPendingModelRef));
|
||
if (restored && !sameModelRef(restored, modelRef)) setPendingModel(restored);
|
||
}, [models, initialPendingModelRef, modelRef, onSwitchModel, staged]);
|
||
|
||
/**
|
||
* The full normal send path (task / handoff / model switch), also the follow-up queue path
|
||
* and the fallback target when a steer hits the completion race: assembles the [use_skills]
|
||
* block, the attachments (images and files) and the staged switch from the whole draft; `post`
|
||
* decides where a message that switches nothing goes (default: onSend; follow-up mode:
|
||
* onQueueFollowUp). Deliberately not gated on `running` — the caller decides (send() gates the
|
||
* normal path; the steering fallback calls this directly after the server said 409
|
||
* not_running, when the local `status` may still lag behind).
|
||
*/
|
||
// `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 staged switch — both are
|
||
// cleared when the chip goes on). 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) {
|
||
// Objective only: attachments were already cleared when goal mode engaged (and blocked
|
||
// from being added since), so there is nothing to carry here.
|
||
setBusy(true);
|
||
try {
|
||
const ok = await onSend([{ type: "text", text: buildSkillsMessage(selectedSkills, t) }], {
|
||
budget: goalBudget!,
|
||
});
|
||
if (ok) {
|
||
setText("");
|
||
setSelectedSkills([]);
|
||
toggleGoal(false);
|
||
}
|
||
} finally {
|
||
setBusy(false);
|
||
textareaRef.current?.focus();
|
||
}
|
||
return;
|
||
}
|
||
// A staged switch chip (from /agent or /model) redirects the send away from the current
|
||
// Session: an agent target hands the draft to a NEW chat for that agent, a model target
|
||
// forks this conversation onto that model. The two are mutually exclusive by construction
|
||
// (picking either clears the other); the model chip only exists where onSwitchModel does.
|
||
// "blocked" = a staged fork while this Session is running or compacting: canSend/canFollowUp
|
||
// already refuse, but this path is deliberately not gated on run state (the steering
|
||
// completion race calls it directly), so refuse here too rather than fall through to `post`
|
||
// — that would deliver the message to the very Session the user was switching away from.
|
||
if (stagedRoute === "blocked") return;
|
||
const switchModel = stagedRoute === "model" ? pendingModel : null;
|
||
// Empty text body: fall back to an auto-line rather than sending nothing — the localized
|
||
// skills invocation when skills are selected, otherwise the model-switch line for a staged
|
||
// switch. A handoff needs no fallback: its first message may legitimately be nothing but
|
||
// the [handoff_from] source block.
|
||
const bodyText =
|
||
t !== ""
|
||
? t
|
||
: selectedSkills.length > 0
|
||
? S.chat.skillsAutoMessage(selectedSkills)
|
||
: switchModel
|
||
? S.chat.modelSwitchAutoMessage
|
||
: t;
|
||
// With non-empty selected skills: the text body is replaced with a [use_skills] block + the text (every branch wraps its body the same way).
|
||
const body = buildSkillsMessage(selectedSkills, bodyText);
|
||
const input: TaskInputPart[] = [];
|
||
if (body) input.push({ type: "text", text: body });
|
||
appendAttachmentParts(input, images, attachments);
|
||
setBusy(true);
|
||
try {
|
||
const ok = target
|
||
? await onHandoff!(target, input)
|
||
: switchModel
|
||
? await onSwitchModel!(
|
||
{ provider: switchModel.provider, modelId: switchModel.modelId },
|
||
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 attachments.
|
||
if (ok) {
|
||
setText("");
|
||
setImages([]);
|
||
setAttachments([]);
|
||
setTarget(null);
|
||
setPendingModel(null);
|
||
setSelectedSkills([]);
|
||
}
|
||
} finally {
|
||
setBusy(false);
|
||
textareaRef.current?.focus();
|
||
}
|
||
};
|
||
|
||
const send = async () => {
|
||
if (running) {
|
||
// Follow-up branch: the whole draft goes out through the normal composition path,
|
||
// but posted with queueIfBusy — the server holds it and auto-sends once this run
|
||
// finishes (a staged switch still opens its new chat directly: neither the handoff
|
||
// target nor the model fork is the session that is running).
|
||
if (followUpMode) {
|
||
if (!canFollowUp) return;
|
||
await sendNormal(onQueueFollowUp!);
|
||
return;
|
||
}
|
||
// Steering branch: queue the trimmed text for the running agent; only the text is
|
||
// sent and cleared — attached images / selected skills stay for a normal send (a
|
||
// staged switch chip blocks this branch outright, see canSteer).
|
||
if (!canSteer) return;
|
||
const steerText = text.trim();
|
||
setBusy(true);
|
||
let res: "queued" | "not_running" | "failed" = "failed";
|
||
try {
|
||
res = await onSteer!(steerText);
|
||
if (res === "queued") {
|
||
// Show the "queued" hint until the steering message shows up in the stream
|
||
// (steeringDeliveredCount increases) — see the effect below.
|
||
steerBaseline.current = steeringDeliveredCount ?? 0;
|
||
setSteerPending(true);
|
||
setText("");
|
||
}
|
||
} finally {
|
||
setBusy(false);
|
||
textareaRef.current?.focus();
|
||
}
|
||
// Completion race (server: no Task running anymore): deliver the whole draft — images,
|
||
// skills and all — through the full normal send path instead of a text-only task.
|
||
if (res === "not_running") await sendNormal();
|
||
return;
|
||
}
|
||
if (!canSend) return;
|
||
await sendNormal();
|
||
};
|
||
|
||
const onKeyDown = (e: KeyboardEvent<HTMLTextAreaElement>) => {
|
||
if (slashOpen) {
|
||
if (e.key === "ArrowDown") {
|
||
e.preventDefault();
|
||
setSlashIndex((i) => (i + 1) % slashMatches.length);
|
||
return;
|
||
}
|
||
if (e.key === "ArrowUp") {
|
||
e.preventDefault();
|
||
setSlashIndex((i) => (i - 1 + slashMatches.length) % slashMatches.length);
|
||
return;
|
||
}
|
||
if (((e.key === "Enter" && !e.shiftKey) || e.key === "Tab") && !e.nativeEvent.isComposing) {
|
||
e.preventDefault();
|
||
activeSlash?.run();
|
||
return;
|
||
}
|
||
if (e.key === "Escape") {
|
||
// Only closes the popup, doesn't clear the input: with positional matching the `/token`
|
||
// is part of the text body like any other word, and wiping a controlled textarea is not
|
||
// undoable with Ctrl+Z. Reopens if the user keeps typing on another token.
|
||
setSlashDismissed(slashTok?.start ?? null);
|
||
return;
|
||
}
|
||
}
|
||
// Backspace at the start of the text: removes the staged switch chip (consistent with common chip-input interaction).
|
||
if (
|
||
e.key === "Backspace" &&
|
||
(target !== null || pendingModel !== null) &&
|
||
e.currentTarget.selectionStart === 0 &&
|
||
e.currentTarget.selectionEnd === 0
|
||
) {
|
||
e.preventDefault();
|
||
clearSwitchTarget();
|
||
return;
|
||
}
|
||
if (e.key === "Enter" && !e.shiftKey && !e.nativeEvent.isComposing) {
|
||
e.preventDefault();
|
||
void send();
|
||
}
|
||
};
|
||
|
||
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();
|
||
reader.onload = () => {
|
||
if (typeof reader.result === "string") {
|
||
setImages((prev) => [...prev, reader.result as string]);
|
||
}
|
||
};
|
||
reader.readAsDataURL(file);
|
||
}
|
||
};
|
||
|
||
const onPaste = (e: ClipboardEvent<HTMLTextAreaElement>) => {
|
||
const files: File[] = [];
|
||
for (const item of e.clipboardData.items) {
|
||
if (item.kind === "file") {
|
||
const file = item.getAsFile();
|
||
if (file) files.push(file);
|
||
}
|
||
}
|
||
if (files.length > 0) {
|
||
e.preventDefault();
|
||
addFiles(files);
|
||
}
|
||
};
|
||
|
||
const onPickFiles = (e: ChangeEvent<HTMLInputElement>) => {
|
||
if (e.target.files) addFiles(e.target.files);
|
||
e.target.value = "";
|
||
};
|
||
|
||
/**
|
||
* File attachments (any type, no `accept` filter): read as base64 data URLs, the same
|
||
* transport images use — a draft has no Session to upload to yet. The name and size come
|
||
* from the File itself and only feed the chip; the server decides the on-disk name.
|
||
*
|
||
* Oversize files are rejected from `File.size` before anything is read, the same way trace
|
||
* import does it (traces-page.tsx): base64-encoding a rejected file in the tab first would
|
||
* cost the user a freeze and a 33%-larger upload to earn the same 413.
|
||
*
|
||
* The whole batch is read before any of it is staged, so the chips — and therefore the
|
||
* `[attached file: …]` lines the message ends up with — follow the order the files were
|
||
* picked in, not the order the reads happened to finish in.
|
||
*/
|
||
const addAttachments = (files: Iterable<File>) => {
|
||
if (goalOn) return; // goal input is text-only, same rule as images
|
||
const picked: File[] = [];
|
||
for (const file of files) {
|
||
if (file.size > MAX_ATTACHMENT_BYTES) {
|
||
toastError(S.chat.attachmentTooLarge(file.name));
|
||
continue;
|
||
}
|
||
picked.push(file);
|
||
}
|
||
if (picked.length === 0) return;
|
||
void Promise.all(picked.map(readDataUrl)).then((urls) => {
|
||
const staged = picked.flatMap((file, i) =>
|
||
urls[i] ? [{ name: file.name, size: file.size, dataUrl: urls[i]! }] : [],
|
||
);
|
||
if (staged.length > 0) setAttachments((prev) => [...prev, ...staged]);
|
||
});
|
||
};
|
||
|
||
const onPickAttachments = (e: ChangeEvent<HTMLInputElement>) => {
|
||
if (e.target.files) addAttachments(e.target.files);
|
||
e.target.value = "";
|
||
};
|
||
/**
|
||
* The image picker moved into the "+" menu, so the file input can no longer be a `<label>`
|
||
* wrapper: the menu unmounts its items on select. It lives outside the menu instead and the
|
||
* entry clicks it — still inside the click's user-activation window, so the dialog opens.
|
||
* The file-attachment picker below works the same way.
|
||
*/
|
||
const imageInputRef = useRef<HTMLInputElement>(null);
|
||
const attachmentInputRef = useRef<HTMLInputElement>(null);
|
||
|
||
return (
|
||
<div className="relative" ref={anchorRef}>
|
||
{/* Slash command menu (triggered by typing /; /compact plus one entry per installed skill).
|
||
Height is capped to the room measured above the composer (see upwardMaxH) with internal
|
||
scrolling, so a long skill list never pushes the menu's top edge out of view; the active
|
||
row keeps itself scrolled into view. */}
|
||
{slashOpen && (
|
||
<div
|
||
style={{ maxHeight: upwardMaxH }}
|
||
className="anim-pop absolute bottom-full left-0 z-40 mb-1.5 w-80 max-w-[calc(100vw-2rem)] overflow-y-auto overscroll-contain rounded-md border border-gray-200 bg-white py-1 shadow-lg dark:border-gray-700 dark:bg-gray-900"
|
||
>
|
||
{slashMatches.map((c, i) => (
|
||
<button
|
||
key={c.cmd}
|
||
type="button"
|
||
ref={c === activeSlash ? (el) => el?.scrollIntoView({ block: "nearest" }) : undefined}
|
||
onMouseEnter={() => setSlashIndex(i)}
|
||
onClick={() => c.run()}
|
||
className={`flex w-full items-center gap-2 px-3 py-1.5 text-left text-sm ${
|
||
c === activeSlash ? "bg-gray-100 dark:bg-gray-800" : ""
|
||
}`}
|
||
>
|
||
<span className="shrink-0 font-mono text-gray-800 dark:text-gray-200">{c.cmd}</span>
|
||
{/* Overly long descriptions (skill descriptions) are truncated: full text goes into the title. */}
|
||
<span
|
||
title={c.desc}
|
||
className="min-w-0 flex-1 truncate text-xs text-gray-500 dark:text-gray-400"
|
||
>
|
||
{c.desc}
|
||
</span>
|
||
</button>
|
||
))}
|
||
</div>
|
||
)}
|
||
|
||
{/* /model switch picker (session state): reuses the draft model dropdown's list —
|
||
search + configured-key-first grouping + "show all"; the current model is marked and
|
||
picking it is a no-op. The /model token was already consumed when the command ran,
|
||
so cancelling (Escape / click outside) keeps the remaining draft and cannot re-open
|
||
the slash menu. A pick only stages the chip below — the switch happens on send. */}
|
||
{modelSwitchOpen && models && (
|
||
<SwitchPickerPanel
|
||
panelRef={modelSwitchRef}
|
||
maxHeight={upwardMaxH}
|
||
title={S.chat.switchModelTitle}
|
||
>
|
||
<ModelMenuList
|
||
models={models}
|
||
value={modelRef}
|
||
{...(defaultModel !== undefined ? { defaultModel } : {})}
|
||
onPick={pickSwitchModel}
|
||
/>
|
||
</SwitchPickerPanel>
|
||
)}
|
||
|
||
{/* /agent handoff picker: the same panel as the /model one above (title bar + search box
|
||
+ capped list + keyboard navigation), and the same staged semantics — the pick becomes
|
||
the target chip, and sending is what hands the conversation over. */}
|
||
{agentSwitchOpen && (
|
||
<SwitchPickerPanel
|
||
panelRef={agentSwitchRef}
|
||
maxHeight={upwardMaxH}
|
||
title={S.chat.switchAgentTitle}
|
||
>
|
||
<AgentMenuList
|
||
agents={agents}
|
||
{...(currentAgentId !== undefined ? { currentAgentId } : {})}
|
||
onPick={pickHandoffTarget}
|
||
/>
|
||
</SwitchPickerPanel>
|
||
)}
|
||
|
||
{images.length > 0 && (
|
||
<div className="mb-2 flex flex-wrap gap-2">
|
||
{images.map((url, i) => (
|
||
<div key={i} className="anim-pop relative">
|
||
<ZoomableImage
|
||
src={url}
|
||
alt={S.chat.imageAlt}
|
||
className="h-16 w-16 rounded-md border border-gray-200 object-cover dark:border-gray-700"
|
||
/>
|
||
<button
|
||
type="button"
|
||
aria-label={S.chat.removeImage}
|
||
onClick={() => setImages((prev) => prev.filter((_, j) => j !== i))}
|
||
className="absolute -right-1.5 -top-1.5 flex h-5 w-5 items-center justify-center rounded-full bg-gray-700 text-[10px] text-white transition-colors duration-150 hover:bg-gray-900"
|
||
>
|
||
×
|
||
</button>
|
||
</div>
|
||
))}
|
||
</div>
|
||
)}
|
||
|
||
{/* Attached files, right below the image thumbnails: one removable chip each (name +
|
||
size), since there is nothing to preview. The name is the picked file's — the server
|
||
sanitizes it when writing to the scratchpad, and the message's banner then shows the
|
||
on-disk name. */}
|
||
{attachments.length > 0 && (
|
||
<div className="mb-2 flex flex-wrap gap-2">
|
||
{attachments.map((file, i) => (
|
||
<span
|
||
key={i}
|
||
title={file.name}
|
||
className="anim-pop flex max-w-56 items-center gap-1.5 rounded-md border border-gray-200 bg-gray-50 py-1 pl-2 pr-1 text-xs text-gray-700 dark:border-gray-700 dark:bg-gray-800 dark:text-gray-200"
|
||
>
|
||
<GlyphIcon
|
||
d={PAPERCLIP_ICON}
|
||
size={13}
|
||
className="shrink-0 text-gray-400 dark:text-gray-500"
|
||
/>
|
||
<span className="min-w-0 truncate">{file.name}</span>
|
||
<span className="shrink-0 font-mono text-[10px] text-gray-400 dark:text-gray-500">
|
||
{formatBytes(file.size)}
|
||
</span>
|
||
<button
|
||
type="button"
|
||
aria-label={`${S.chat.removeFile} ${file.name}`}
|
||
onClick={() => setAttachments((prev) => prev.filter((_, j) => j !== i))}
|
||
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>
|
||
))}
|
||
</div>
|
||
)}
|
||
|
||
{/* When the model doesn't support viewing images directly: images still upload as usual,
|
||
and on send the server writes them to the session's scratchpad and appends the file
|
||
path into the message text (the model views them via describe_image). A small note is
|
||
shown while images are attached. */}
|
||
{!vision && images.length > 0 && (
|
||
<p className="anim-fade mb-1 text-xs text-gray-400 dark:text-gray-500">
|
||
{S.chat.imagesAsPathHint}
|
||
</p>
|
||
)}
|
||
|
||
{/* A staged /model fork that has to wait for this Session to go idle (a run started from
|
||
outside the composer, or a compaction): the Send button is disabled either way, and
|
||
this is the line that says why — the chip stays staged and goes out on the next Enter
|
||
once the Session settles. */}
|
||
{stagedRoute === "blocked" && (
|
||
<p className="anim-fade mb-1 text-xs text-gray-400 dark:text-gray-500">
|
||
{S.chat.modelSwitchBusyHint}
|
||
</p>
|
||
)}
|
||
|
||
{/* Mid-run steering queued: lightweight hint until the steering message appears in the
|
||
stream (or the run ends). */}
|
||
{steerPending && (
|
||
<p className="anim-fade mb-1 text-xs text-gray-400 dark:text-gray-500">
|
||
{S.chat.steerQueuedIndicator}
|
||
</p>
|
||
)}
|
||
|
||
{/* Queued follow-ups (server-side, auto-sent once this run finishes): count from
|
||
task_state — survives reloads because the queue lives on the server. */}
|
||
{queuedFollowUps > 0 && (
|
||
<p className="anim-fade mb-1 text-xs text-gray-400 dark:text-gray-500">
|
||
{S.chat.followUpQueuedChip(queuedFollowUps)}
|
||
</p>
|
||
)}
|
||
|
||
{/* Auth-dead session (model credentials failed): slim notice above the composer, same
|
||
width — the abort line in the stream keeps the raw reason, this adds the "what now"
|
||
guidance. Recoverable: primary CTA opens the Models page (fixing the key there
|
||
auto-unlocks the session), Retry clears the state for another attempt (re-arms on
|
||
the next auth failure), New Session stays as the escape. The composer below is
|
||
disabled and hazed. */}
|
||
{modelAuthDead && (
|
||
<div className="anim-fade mb-1.5 flex flex-wrap items-center justify-between gap-x-3 gap-y-1.5 rounded-md border border-gray-200 bg-gray-50 px-3 py-2 text-xs text-gray-600 dark:border-gray-700 dark:bg-gray-800/60 dark:text-gray-300">
|
||
<span className="min-w-0 flex-1 basis-56">{S.chat.modelAuthDead}</span>
|
||
<span className="flex shrink-0 flex-wrap items-center gap-1.5">
|
||
{onOpenModels && (
|
||
<button
|
||
type="button"
|
||
onClick={onOpenModels}
|
||
className="shrink-0 rounded-md bg-gray-900 px-2.5 py-1 font-medium text-white transition-colors duration-150 hover:bg-gray-700 dark:bg-gray-100 dark:text-gray-900 dark:hover:bg-gray-300"
|
||
>
|
||
{S.chat.modelAuthDeadOpenModels}
|
||
</button>
|
||
)}
|
||
{onRetryModelAuth && (
|
||
<button
|
||
type="button"
|
||
onClick={onRetryModelAuth}
|
||
className="shrink-0 rounded-md border border-gray-300 bg-white px-2.5 py-1 font-medium text-gray-700 transition-colors duration-150 hover:bg-gray-100 dark:border-gray-600 dark:bg-gray-900 dark:text-gray-200 dark:hover:bg-gray-700"
|
||
>
|
||
{S.chat.modelAuthDeadRetry}
|
||
</button>
|
||
)}
|
||
{onNewSession && (
|
||
<button
|
||
type="button"
|
||
onClick={onNewSession}
|
||
className="shrink-0 rounded-md border border-gray-300 bg-white px-2.5 py-1 font-medium text-gray-700 transition-colors duration-150 hover:bg-gray-100 dark:border-gray-600 dark:bg-gray-900 dark:text-gray-200 dark:hover:bg-gray-700"
|
||
>
|
||
{S.chat.modelAuthDeadCta}
|
||
</button>
|
||
)}
|
||
</span>
|
||
</div>
|
||
)}
|
||
|
||
{/* Unified input card: the multi-line text body occupies the top area, with all controls
|
||
collected onto a single bottom row that never shares a line with the text.
|
||
@container: the bottom toolbar row collapses based on the **card's actual width**
|
||
(help text/button text visibility uses @md/@lg container breakpoints) — because the
|
||
card's width changes with the viewport and the Files panel squeezing it, viewport
|
||
breakpoints wouldn't judge it accurately. */}
|
||
<div
|
||
// Auth-dead haze deliberately keeps pointer events and text selection: the
|
||
// textarea's `disabled` already blocks editing, and the stuck draft must stay
|
||
// selectable/copyable — a long message that failed to send is exactly what the
|
||
// user wants to copy back out.
|
||
className={`@container rounded-lg border border-gray-300 bg-white px-2.5 pb-2 pt-2 transition-[border-color,box-shadow] duration-200 focus-within:border-gray-500 focus-within:ring-2 focus-within:ring-gray-400/30 dark:border-gray-700 dark:bg-gray-900 dark:focus-within:border-gray-400${
|
||
modelAuthDead ? " opacity-50 grayscale" : ""
|
||
}`}
|
||
>
|
||
{/* Chip row above the text body: the staged switch target (an /agent handoff or a
|
||
/model fork — never both) followed by the selected skills, all sharing the same
|
||
chip look. Remove buttons recolor the x on hover (no background wash). */}
|
||
{(target !== null || pendingModel !== 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"
|
||
{...noAutofill}
|
||
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>
|
||
)}
|
||
{/* Staged /agent handoff target: the Agent avatar (the identity tile used
|
||
everywhere Agents are picked) + its id, so the chip reads as "this goes to that
|
||
Agent" without spelling the sentence out. */}
|
||
{target !== null && (
|
||
<span
|
||
title={S.chat.handoffTargetTitle(agentDisplayName(target))}
|
||
className="anim-pop flex max-w-48 items-center gap-1 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"
|
||
>
|
||
<AgentAvatar
|
||
id={target.agentId}
|
||
name={agentDisplayName(target)}
|
||
size={13}
|
||
className="shrink-0 rounded-sm"
|
||
/>
|
||
<span className="truncate">{target.agentId}</span>
|
||
<button
|
||
type="button"
|
||
aria-label={S.chat.handoffRemove}
|
||
onClick={() => {
|
||
setTarget(null);
|
||
onHandoffTargetChange?.(null);
|
||
textareaRef.current?.focus();
|
||
}}
|
||
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>
|
||
)}
|
||
{/* Staged /model switch: provider logo + model name, matching the composer's own
|
||
model display; sending forks the conversation onto it. */}
|
||
{pendingModel !== null && (
|
||
<span
|
||
title={S.chat.modelSwitchTargetTitle(modelLabel(pendingModel))}
|
||
className="anim-pop flex max-w-48 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"
|
||
>
|
||
<ProviderLogo provider={pendingModel.provider} className="h-3.5 w-3.5 shrink-0" />
|
||
<span className="truncate">{modelLabel(pendingModel)}</span>
|
||
<button
|
||
type="button"
|
||
aria-label={S.chat.modelSwitchRemove}
|
||
onClick={() => {
|
||
stageModel(null);
|
||
textareaRef.current?.focus();
|
||
}}
|
||
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>
|
||
)}
|
||
{selectedSkills.map((name) => {
|
||
const meta = skills.find((sk) => sk.name === name);
|
||
return (
|
||
<span
|
||
key={name}
|
||
className="anim-pop flex max-w-48 items-center gap-1 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"
|
||
{...(meta ? { title: localizedShortText(locale, meta) } : {})}
|
||
>
|
||
<SkillIcon
|
||
icon={meta?.icon}
|
||
size={13}
|
||
className="shrink-0 text-gray-500 dark:text-gray-400"
|
||
/>
|
||
<span className="truncate">{name}</span>
|
||
<button
|
||
type="button"
|
||
aria-label={`${S.chat.skillRemove} ${name}`}
|
||
onClick={() => toggleSkill(name)}
|
||
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>
|
||
);
|
||
})}
|
||
</div>
|
||
)}
|
||
|
||
{/* Multi-line input area (defaults to 2 lines, auto-grows, scrolls internally beyond the cap) */}
|
||
<textarea
|
||
ref={textareaRef}
|
||
rows={2}
|
||
value={text}
|
||
autoFocus={autoFocus}
|
||
onChange={(e) => {
|
||
const value = e.target.value;
|
||
const caretNow = e.target.selectionStart ?? value.length;
|
||
setText(value);
|
||
onTextChange?.(value);
|
||
setCaret(caretNow);
|
||
setSlashIndex(0);
|
||
// Closing via Escape only persists for "the same token": continuing to type within
|
||
// that slash command won't reopen the menu; it re-opens once the cursor is no longer
|
||
// on that token (deleted, moved away, or replaced by a new one).
|
||
setSlashDismissed((d) => {
|
||
if (d === null) return null;
|
||
const m = matchSlash(value, caretNow);
|
||
return m && m.start === d ? d : null;
|
||
});
|
||
}}
|
||
// Cursor movement (arrow keys/click) syncs to caret: the slash menu matches the token at the cursor.
|
||
onSelect={(e) => setCaret(e.currentTarget.selectionStart ?? 0)}
|
||
onKeyDown={onKeyDown}
|
||
onPaste={onPaste}
|
||
disabled={modelAuthDead}
|
||
placeholder={
|
||
modelAuthDead
|
||
? S.chat.modelAuthDeadPlaceholder
|
||
: running && followUpMode
|
||
? narrow
|
||
? S.chat.followUpPlaceholderShort
|
||
: S.chat.followUpPlaceholder
|
||
: running && onSteer
|
||
? narrow
|
||
? S.chat.steerPlaceholderShort
|
||
: S.chat.steerPlaceholder
|
||
: narrow
|
||
? S.chat.inputPlaceholderShort
|
||
: S.chat.inputPlaceholder
|
||
}
|
||
className="block max-h-44 min-h-[60px] w-full resize-none bg-transparent px-1 py-0.5 text-base leading-6 placeholder:text-gray-400 focus:outline-none disabled:cursor-not-allowed disabled:opacity-60 dark:placeholder:text-gray-500"
|
||
/>
|
||
|
||
{/* Bottom toolbar row — one line, two groups: the settings controls sit left, the
|
||
status/model/action controls right (`justify-between`). The left group is the only
|
||
one allowed to give way: `min-w-0` + horizontal scroll means a crowded phone
|
||
viewport scrolls those controls instead of pushing the right group (and with it the
|
||
action button) off-screen. The right group is `shrink-0` so the action button is
|
||
always reachable. */}
|
||
<div className="mt-1 flex items-center justify-between gap-2 text-xs">
|
||
<div className="no-scrollbar flex min-w-0 flex-1 items-center gap-2 overflow-x-auto">
|
||
{/* The image picker's actual input: kept mounted here (outside the menu, which
|
||
unmounts its items on select) and driven by the menu entry below. */}
|
||
<input
|
||
ref={imageInputRef}
|
||
type="file"
|
||
accept="image/*"
|
||
multiple
|
||
disabled={goalOn}
|
||
className="hidden"
|
||
onChange={onPickFiles}
|
||
/>
|
||
{/* The file picker's actual input, same arrangement as the image one above; no
|
||
`accept` — an attachment can be any type, the model reads it from disk. */}
|
||
<input
|
||
ref={attachmentInputRef}
|
||
type="file"
|
||
multiple
|
||
disabled={goalOn}
|
||
className="hidden"
|
||
onChange={onPickAttachments}
|
||
/>
|
||
{/* "+" extension menu, leading the row: input add-ons (image upload, file
|
||
attachment, goal mode) plus the input settings footer (mid-run send mode —
|
||
usable while running, which is exactly when it matters, so the button itself
|
||
never disables). The uploads live in here rather than as their own toolbar
|
||
buttons: one 8x8 slot instead of three, which is the difference between the
|
||
phone row scrolling and not. */}
|
||
<PlusMenu
|
||
items={[
|
||
{
|
||
key: "image",
|
||
icon: IMAGE_ICON,
|
||
label: S.chat.uploadImage,
|
||
// Without vision the images still send — as scratchpad file paths — so the
|
||
// entry stays usable and the hint explains what will happen instead.
|
||
desc: vision ? S.chat.uploadImageDesc : S.chat.imagesAsPathHint,
|
||
active: images.length > 0,
|
||
// Goal mode is text-only (the objective is re-injected each round).
|
||
disabled: goalOn,
|
||
onSelect: () => imageInputRef.current?.click(),
|
||
},
|
||
{
|
||
key: "file",
|
||
icon: PAPERCLIP_ICON,
|
||
label: S.chat.uploadFile,
|
||
// The description doubles as the explanation of where the file ends up:
|
||
// it is filed into the session scratchpad and reached by path, never
|
||
// inlined into the conversation.
|
||
desc: S.chat.uploadFileDesc,
|
||
active: attachments.length > 0,
|
||
// Same rule as images: goal input is text-only.
|
||
disabled: goalOn,
|
||
onSelect: () => attachmentInputRef.current?.click(),
|
||
},
|
||
{
|
||
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"}
|
||
/>
|
||
<ApprovalModeSelect
|
||
value={approvalMode}
|
||
onChange={onChangeApprovalMode}
|
||
disabled={modeSaving}
|
||
direction={models && onChangeModel ? "down" : "up"}
|
||
/>
|
||
{/* Multi-select skills dropdown (after approval mode): selected state is conveyed via the button badge. */}
|
||
<SkillSelect
|
||
skills={skills}
|
||
selected={selectedSkills}
|
||
onToggle={toggleSkill}
|
||
disabled={running || compacting || busy}
|
||
direction={models && onChangeModel ? "down" : "up"}
|
||
/>
|
||
{/* Help text: shown only when the card is wide enough (@lg); it never competes for
|
||
space on phones, where the group scrolls instead. */}
|
||
<span
|
||
title={S.chat.slashHint}
|
||
className="hidden min-w-0 truncate text-gray-300 @lg:block dark:text-gray-600"
|
||
>
|
||
{S.chat.slashHint}
|
||
</span>
|
||
</div>
|
||
|
||
{/* Right group: status + model + the single action button; never shrinks. */}
|
||
<div className="flex shrink-0 items-center gap-2">
|
||
{/* Draft state (model still changeable = no session created yet) has no context usage to speak of: the ring isn't shown, it displays as usual once the session is created. */}
|
||
{!onChangeModel && (
|
||
<ContextGauge
|
||
now={contextNow}
|
||
unknown={contextStale}
|
||
{...(contextWindow !== undefined ? { window: contextWindow } : {})}
|
||
/>
|
||
)}
|
||
{/* Draft state: conversation-time thinking level (backed by Agent settings), docked left of the model selector. */}
|
||
{models && onChangeModel && onChangeThinkingLevel && (
|
||
<ThinkingLevelSelect
|
||
value={thinkingLevel ?? null}
|
||
onChange={onChangeThinkingLevel}
|
||
disabled={busy}
|
||
/>
|
||
)}
|
||
{/* Session state: per-turn thinking level (editable) — displays the user's pick,
|
||
else the Agent config's level (auto-follow; the parent resolves it). While
|
||
untouched nothing rides on tasks; a pick sticks for the session and is sent
|
||
with every subsequent send, never writing through to the Agent config. */}
|
||
{!onChangeModel && onChangeTurnThinkingLevel && (
|
||
<ThinkingLevelSelect
|
||
value={turnThinkingLevel ?? ""}
|
||
onChange={onChangeTurnThinkingLevel}
|
||
disabled={busy}
|
||
direction="up"
|
||
/>
|
||
)}
|
||
{/* Left of the send button: model selector in draft state; once the Session is created the model is locked, shown read-only (still with the provider logo). */}
|
||
{models && onChangeModel ? (
|
||
<ModelSelect
|
||
models={models}
|
||
value={modelRef}
|
||
{...(defaultModel !== undefined ? { defaultModel } : {})}
|
||
onChange={onChangeModel}
|
||
disabled={busy}
|
||
/>
|
||
) : (
|
||
<span
|
||
title={modelRef?.modelId ?? ""}
|
||
className="flex h-8 min-w-0 max-w-44 shrink items-center gap-1.5 px-1 text-gray-400 dark:text-gray-500"
|
||
>
|
||
{/* Read-only display in session state: both the logo and the name come from the Session DTO's paired fields (no prefix parsing). */}
|
||
<ProviderLogo
|
||
provider={modelRef?.provider ?? "custom"}
|
||
className="h-4 w-4 shrink-0"
|
||
/>
|
||
<span className="hidden min-w-0 truncate @md:block">
|
||
{(() => {
|
||
const m = models?.find((x) => sameModelRef(x, modelRef));
|
||
return m ? modelLabel(m) : (modelRef?.modelId ?? "…");
|
||
})()}
|
||
</span>
|
||
</span>
|
||
)}
|
||
{/* One action button, never two: while running an empty composer means "Stop"
|
||
(abort), and typing turns the very same button into "Send" — which, mid-run,
|
||
steers or queues per the remembered 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}
|
||
aria-label={stopAction ? S.chat.stop : running ? midRunSendLabel : S.chat.send}
|
||
disabled={stopAction ? false : running ? !canMidRunSend : !canSend}
|
||
onClick={() => (stopAction ? void onStop() : void send())}
|
||
className={
|
||
stopAction
|
||
? "flex h-8 w-8 shrink-0 items-center justify-center rounded-md bg-red-50 text-red-600 transition-colors duration-150 hover:bg-red-100 dark:bg-red-950/60 dark:text-red-400 dark:hover:bg-red-950"
|
||
: "flex h-8 w-8 shrink-0 items-center justify-center rounded-md bg-gray-900 text-white transition-colors duration-150 hover:bg-gray-700 disabled:cursor-not-allowed disabled:bg-gray-200 disabled:text-gray-400 dark:bg-gray-100 dark:text-gray-900 dark:hover:bg-gray-300 dark:disabled:bg-gray-800 dark:disabled:text-gray-600"
|
||
}
|
||
>
|
||
{stopAction ? (
|
||
/* Stop (filled square) */
|
||
<svg width="14" height="14" viewBox="0 0 14 14" aria-hidden className="block">
|
||
<rect x="2" y="2" width="10" height="10" rx="2" fill="currentColor" />
|
||
</svg>
|
||
) : (
|
||
/* Up arrow (send) */
|
||
<svg
|
||
width="17"
|
||
height="17"
|
||
viewBox="0 0 24 24"
|
||
fill="none"
|
||
stroke="currentColor"
|
||
strokeWidth="2"
|
||
strokeLinecap="round"
|
||
strokeLinejoin="round"
|
||
aria-hidden
|
||
className="block"
|
||
>
|
||
<path d="M12 19V5M5 12l7-7 7 7" />
|
||
</svg>
|
||
)}
|
||
</button>
|
||
</div>
|
||
</div>
|
||
</div>
|
||
</div>
|
||
);
|
||
}
|