/** * 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 `/` 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: ]` 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 `/` * 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 = { "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 ( 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) */} {/* 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). */} {S.chat.approvalModeNames[value] ?? value} } > {APPROVAL_MODES.map((m) => ( ))} ); } /** 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({ 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) => { 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 (
{/* Quick search (autofocused: it also owns the keyboard while the panel is up) */}
{ 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" />
{items.length === 0 &&

{emptyText}

} {items.map((item) => { const key = itemKey(item); const current = isCurrent?.(item) ?? false; return ( ); })}
{footer}
); } /** * 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 ( `${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) => ( <> {modelLabel(m)} {/* 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) && ( {S.models.freeBadge} )} {/* 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) && ( )} {sameModelRef(m, defaultModel) && ( {S.models.default} )} )} // 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: (
), } : {})} /> ); } /** * 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 ( 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" > {/* When the card is narrower than @md, only the provider logo remains (title shows the full name). */} {label} } > { onChange({ provider: m.provider, modelId: m.modelId }); setOpen(false); }} /> ); } /** * 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 ( 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) => ( <> {a.agentId} {a.name && a.name !== a.agentId && ( {a.name} )} )} /> ); } /** * 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; maxHeight: number | undefined; title: string; children: ReactNode; }) { return (
{title}
{children}
); } /** 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 ( 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" > {/* When the card is narrower than @md, only the icon remains (title shows the full state). */} {label} } > {/* Title bar: names the control (the rows themselves are just the short names). */}
{S.chat.thinkingLevel}
{SELECTABLE_THINKING_LEVELS.map((level) => ( ))}
); } /** * 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) => ( ); return (
{S.chat.steerModeLabel}
{modeButton("steer", S.chat.steerModeSteer, S.chat.steerModeSteerHint)} {modeButton("followup", S.chat.steerModeFollowUp, S.chat.steerModeFollowUpHint)}
); } /** * 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 ( { 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" > {/* When the card is narrower than @md, only the icon + badge remain (title shows the full name). */} {S.chat.skillsSelect} {/* Selected-count badge (the chip row above the input mirrors the selection too). */} {selected.length > 0 && ( {selected.length} )} } > {/* Quick search: filters by skill name and localized description */}
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" />
{skills.length === 0 ? (

{S.chat.skillsEmptyHint}

) : filtered.length === 0 ? (

{S.chat.skillsNoMatch}

) : ( filtered.map((s) => { const on = selected.includes(s.name); return ( ); }) )}
); } /** * 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 ``. */ 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 ( 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" > } > {items.map((item) => ( ))} {footer && (
{footer}
)}
); } 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 ( {/* 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. */} {unknown ? "—" : humanizeTokens(now)}/{humanizeTokens(max)} ); } /** * 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 { 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; /** * 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; /** 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; onStop: () => Promise; onCompact: () => Promise; /** 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; /** 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([]); // 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([]); 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(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(null); const [agentSwitchOpen, setAgentSwitchOpen] = useState(false); const agentSwitchRef = useRef(null); // Anchor for the popups that open upward, and the room actually available above them. const anchorRef = useRef(null); const [upwardMaxH, setUpwardMaxH] = useState(); // 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(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(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(initialSkills ?? []); // Cursor position (tracked via onChange/onSelect): the slash menu matches the token at the caret. const [caret, setCaret] = useState(0); const textareaRef = useRef(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(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>(null); const commands = useMemo(() => { /** 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: `/` 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 . 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 = 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) => { 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) => { // 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) => { 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) => { 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) => { 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) => { 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 `