Handoff-style /model switch command and per-turn thinking level (#64)
Co-authored-by: Claude Fable 5 <noreply@anthropic.com>
This commit is contained in:
@@ -226,45 +226,37 @@ 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";
|
||||
|
||||
/**
|
||||
* Model selector (draft state only; docked to the left of the send button): both the button and
|
||||
* candidate items show the provider logo. The menu opens **downward** — the draft card is
|
||||
* vertically centered with room below; a top quick-search box (reusing the model page's rule:
|
||||
* filters by id / display name / provider name), with the candidate list capped by an internal
|
||||
* scroll (max-h-56) so it never overflows the browser's viewport height no matter how many models
|
||||
* there are. On narrow screens only the logo remains (name hidden); list items mark the project
|
||||
* default.
|
||||
* 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.
|
||||
* 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 ModelSelect({
|
||||
function ModelMenuList({
|
||||
models,
|
||||
value,
|
||||
defaultModel,
|
||||
onChange,
|
||||
disabled,
|
||||
onPick,
|
||||
}: {
|
||||
models: ModelInfo[];
|
||||
/** Currently selected (provider, modelId) pair; null = not yet chosen. */
|
||||
value: ModelRefDto | null;
|
||||
defaultModel?: ModelRefDto;
|
||||
onChange: (ref: ModelRefDto) => void;
|
||||
disabled: boolean;
|
||||
onPick: (m: ModelInfo) => void;
|
||||
}) {
|
||||
const [open, setOpen] = useState(false);
|
||||
const [query, setQuery] = useState("");
|
||||
// Expanded "show all" state: collapses back to key-configured models on each open.
|
||||
// Expanded "show all" state: collapses back to key-configured models on each open (remount).
|
||||
const [showAll, setShowAll] = 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 ?? "…");
|
||||
// Dropdown order mirrors the model library page: provider groups in MODEL_PROVIDERS order
|
||||
// (user-defined groups after, custom last), in-group order preserved. By default the list
|
||||
// keeps only key-configured models (selected/default always included; lists everything when
|
||||
// no model has a key); the query filters what's visible.
|
||||
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
|
||||
@@ -272,57 +264,7 @@ function ModelSelect({
|
||||
: visibleChatModels(models, { showAll: true, query, selected: value, defaultModel }).length -
|
||||
visible.length;
|
||||
return (
|
||||
<Dropdown
|
||||
open={open}
|
||||
setOpen={setOpen}
|
||||
// right-0 docks the panel's right edge to the button, which itself sits ~4.5rem in from
|
||||
// the viewport's right edge (page + card padding, gap, send button) — so the width clamp
|
||||
// must reserve that anchor offset too, or the w-max panel's LEFT edge runs off-screen on
|
||||
// phones (~34px off-screen at 375px with a 100vw-2rem clamp). Desktop is untouched:
|
||||
// w-max stays far below the clamp there.
|
||||
menuClass="right-0 top-full mt-1 w-max min-w-56 max-w-[calc(100vw-6rem)] origin-top-right"
|
||||
button={
|
||||
<button
|
||||
type="button"
|
||||
title={`${S.chat.chooseModel}:${label}`}
|
||||
aria-label={S.chat.chooseModel}
|
||||
disabled={disabled || models.length === 0}
|
||||
onClick={() => {
|
||||
const next = !open;
|
||||
setOpen(next);
|
||||
if (next) {
|
||||
// Each open starts from the unsearched, collapsed (configured-only) list.
|
||||
setQuery("");
|
||||
setShowAll(false);
|
||||
}
|
||||
}}
|
||||
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>
|
||||
}
|
||||
>
|
||||
<>
|
||||
{/* Quick search: supports model id / display name / provider name */}
|
||||
<div className="border-b border-gray-100 px-2 pb-1.5 pt-0.5 dark:border-gray-800">
|
||||
<input
|
||||
@@ -342,10 +284,7 @@ function ModelSelect({
|
||||
<button
|
||||
key={`${m.provider}:${m.modelId}`}
|
||||
type="button"
|
||||
onClick={() => {
|
||||
onChange({ provider: m.provider, modelId: m.modelId });
|
||||
setOpen(false);
|
||||
}}
|
||||
onClick={() => onPick(m)}
|
||||
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 ${
|
||||
sameModelRef(m, value)
|
||||
? "font-medium text-gray-900 dark:text-gray-100"
|
||||
@@ -398,6 +337,88 @@ function ModelSelect({
|
||||
</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}
|
||||
// right-0 docks the panel's right edge to the button, which itself sits ~4.5rem in from
|
||||
// the viewport's right edge (page + card padding, gap, send button) — so the width clamp
|
||||
// must reserve that anchor offset too, or the w-max panel's LEFT edge runs off-screen on
|
||||
// phones (~34px off-screen at 375px with a 100vw-2rem clamp). Desktop is untouched:
|
||||
// w-max stays far below the clamp there.
|
||||
menuClass="right-0 top-full mt-1 w-max min-w-56 max-w-[calc(100vw-6rem)] origin-top-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>
|
||||
);
|
||||
}
|
||||
@@ -406,25 +427,33 @@ function ModelSelect({
|
||||
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 (draft state only, 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 — llmConfig is assembled once per session, so
|
||||
* the level applies to the session created on first send and becomes the Agent's new default
|
||||
* (switch-becomes-default). Per review: a title bar names the control, and the menu lists
|
||||
* the selectable levels with short names only (no descriptions, no "default" row, and no
|
||||
* "none" — many models cannot disable thinking); an Agent without an explicit override shows
|
||||
* an em dash until a level is picked, and a stored legacy "none" still displays via the
|
||||
* label table (just never offered).
|
||||
* 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",
|
||||
}: {
|
||||
/** Current level ("" = no override yet); null = the Agent config is still loading. */
|
||||
/** 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 =
|
||||
@@ -433,7 +462,11 @@ function ThinkingLevelSelect({
|
||||
<Dropdown
|
||||
open={open}
|
||||
setOpen={setOpen}
|
||||
menuClass="right-0 top-full mt-1 w-36 origin-top-right"
|
||||
menuClass={
|
||||
direction === "down"
|
||||
? "right-0 top-full mt-1 w-max min-w-36 origin-top-right"
|
||||
: "bottom-full right-0 mb-1 w-max min-w-36 origin-bottom-right"
|
||||
}
|
||||
button={
|
||||
<button
|
||||
type="button"
|
||||
@@ -465,7 +498,7 @@ function ThinkingLevelSelect({
|
||||
</button>
|
||||
}
|
||||
>
|
||||
{/* Title bar: names the control (the rows themselves are just the five short names). */}
|
||||
{/* 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>
|
||||
@@ -706,10 +739,12 @@ export function ChatInput({
|
||||
modelRef,
|
||||
models,
|
||||
onChangeModel,
|
||||
onSwitchModel,
|
||||
defaultModel,
|
||||
thinkingLevel,
|
||||
onChangeThinkingLevel,
|
||||
sessionThinkingLevel,
|
||||
turnThinkingLevel,
|
||||
onChangeTurnThinkingLevel,
|
||||
contextWindow,
|
||||
contextNow,
|
||||
contextStale = false,
|
||||
@@ -749,6 +784,14 @@ export function ChatInput({
|
||||
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; any text remaining
|
||||
* after the command token 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;
|
||||
/**
|
||||
@@ -764,11 +807,17 @@ export function ChatInput({
|
||||
*/
|
||||
onChangeThinkingLevel?: (level: string) => void;
|
||||
/**
|
||||
* Session state: the session's fixed thinking level (captured from session_meta on replay;
|
||||
* llmConfig is assembled once per session, so it cannot change mid-session). Rendered as a
|
||||
* read-only tag next to the locked model; null/undefined = unknown (nothing shown).
|
||||
* 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).
|
||||
*/
|
||||
sessionThinkingLevel?: string | null;
|
||||
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). */
|
||||
@@ -824,6 +873,13 @@ export function ChatInput({
|
||||
const [slashIndex, setSlashIndex] = useState(0);
|
||||
// Slash token start where Escape closed the menu (mirrors mentionDismissed: the menu stays shut for that one token).
|
||||
const [slashDismissed, setSlashDismissed] = useState<number | null>(null);
|
||||
// /model switch picker (session state): opened by the /model command. The command consumes
|
||||
// its slash token immediately (same as /compact), so closing the 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 on a successful pick).
|
||||
const [modelSwitchOpen, setModelSwitchOpen] = useState(false);
|
||||
const modelSwitchRef = 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>();
|
||||
@@ -882,6 +938,22 @@ export function ChatInput({
|
||||
void onCompact();
|
||||
},
|
||||
},
|
||||
// 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);
|
||||
},
|
||||
},
|
||||
]
|
||||
: []),
|
||||
// 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,
|
||||
@@ -892,11 +964,12 @@ export function ChatInput({
|
||||
},
|
||||
})),
|
||||
];
|
||||
}, [onCompact, onTextChange, skills, locale, toggleSkill]);
|
||||
}, [onCompact, onSwitchModel, models, onTextChange, skills, locale, toggleSkill]);
|
||||
// Positional matching (like @ mentions): a slash opens the menu from any caret position;
|
||||
// running a command removes just the token, leaving the rest of the text intact. Doesn't
|
||||
// reopen after Escape until the caret sits on a different token.
|
||||
const slashTok = !running && !compacting ? matchSlash(text, caret) : null;
|
||||
// reopen after Escape until the caret sits on a different token; suppressed while the
|
||||
// /model picker is open (its trigger token is still in the text).
|
||||
const slashTok = !running && !compacting && !modelSwitchOpen ? matchSlash(text, caret) : null;
|
||||
slashMatchRef.current = slashTok;
|
||||
const slashMatches =
|
||||
slashTok && slashTok.start !== slashDismissed
|
||||
@@ -905,19 +978,81 @@ export function ChatInput({
|
||||
const slashOpen = slashMatches.length > 0;
|
||||
const activeSlash = slashMatches[Math.min(slashIndex, slashMatches.length - 1)];
|
||||
|
||||
// @ subagent menu: the `@prefix` currently being typed at the cursor drives candidate filtering (slash menu takes priority; doesn't reopen after Escape).
|
||||
const mention = !running && !compacting && !slashOpen ? matchMention(text, caret) : null;
|
||||
// @ subagent menu: the `@prefix` currently being typed at the cursor drives candidate
|
||||
// filtering (the slash menu and the /model picker take priority; doesn't reopen after Escape).
|
||||
const mention =
|
||||
!running && !compacting && !slashOpen && !modelSwitchOpen ? matchMention(text, caret) : null;
|
||||
const mentionMatches =
|
||||
mention && mention.start !== mentionDismissed ? filterAgents(agents, mention.query) : [];
|
||||
const mentionOpen = mentionMatches.length > 0;
|
||||
const activeMention = mentionMatches[Math.min(mentionIndex, mentionMatches.length - 1)];
|
||||
|
||||
// Both menus above are drawn upward (`bottom-full`) from the composer, so their ceiling is
|
||||
// Close the /model picker on click-outside / Escape (same convention as Dropdown; the
|
||||
// panel has no trigger button of its own, so the handling lives here).
|
||||
useEffect(() => {
|
||||
if (!modelSwitchOpen) return;
|
||||
// globalThis.* event types: the React ones imported above would shadow the DOM ones here.
|
||||
const onClick = (e: globalThis.MouseEvent) => {
|
||||
if (modelSwitchRef.current && !modelSwitchRef.current.contains(e.target as Node)) {
|
||||
setModelSwitchOpen(false);
|
||||
}
|
||||
};
|
||||
const onKey = (e: globalThis.KeyboardEvent) => {
|
||||
if (e.key === "Escape") setModelSwitchOpen(false);
|
||||
};
|
||||
window.addEventListener("mousedown", onClick);
|
||||
window.addEventListener("keydown", onKey);
|
||||
return () => {
|
||||
window.removeEventListener("mousedown", onClick);
|
||||
window.removeEventListener("keydown", onKey);
|
||||
};
|
||||
}, [modelSwitchOpen]);
|
||||
|
||||
/**
|
||||
* /model pick: the CURRENT model is a no-op (close only), and the run state is re-checked
|
||||
* — the picker may have survived a status flip (a task/compaction starting while it was
|
||||
* open). Otherwise the pick opens a new session on the chosen model via onSwitchModel,
|
||||
* with the first-task input assembled **like a normal send**: the remaining draft text
|
||||
* (wrapped with the selected skills; an interface-language auto-line when empty — same
|
||||
* convention as the skills auto message) plus the attached images. On failure the draft
|
||||
* is kept so the user can retry.
|
||||
*/
|
||||
const pickSwitchModel = async (m: ModelInfo) => {
|
||||
setModelSwitchOpen(false);
|
||||
if (!onSwitchModel || busy || running || compacting) return;
|
||||
if (sameModelRef(m, modelRef)) return;
|
||||
const rest = textRef.current.trim();
|
||||
const bodyText =
|
||||
rest ||
|
||||
(selectedSkills.length > 0
|
||||
? S.chat.skillsAutoMessage(selectedSkills)
|
||||
: S.chat.modelSwitchAutoMessage);
|
||||
const body = buildSkillsMessage(selectedSkills, bodyText);
|
||||
const input: TaskInputPart[] = [{ type: "text", text: body }];
|
||||
for (const url of images) input.push({ type: "image_url", imageUrl: url });
|
||||
setBusy(true);
|
||||
try {
|
||||
const ok = await onSwitchModel({ provider: m.provider, modelId: m.modelId }, input);
|
||||
if (ok) {
|
||||
// Consumed into the new session's first task (the parent has already discarded the
|
||||
// draft cache — no change callbacks here, same as send()).
|
||||
setText("");
|
||||
setImages([]);
|
||||
setSelectedSkills([]);
|
||||
requestAnimationFrame(autoGrow);
|
||||
}
|
||||
} finally {
|
||||
setBusy(false);
|
||||
textareaRef.current?.focus();
|
||||
}
|
||||
};
|
||||
|
||||
// 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 && !mentionOpen) return;
|
||||
if (!slashOpen && !mentionOpen && !modelSwitchOpen) return;
|
||||
const measure = () => {
|
||||
const el = anchorRef.current;
|
||||
if (!el) return;
|
||||
@@ -935,7 +1070,7 @@ export function ChatInput({
|
||||
measure();
|
||||
window.addEventListener("resize", measure);
|
||||
return () => window.removeEventListener("resize", measure);
|
||||
}, [slashOpen, mentionOpen]);
|
||||
}, [slashOpen, mentionOpen, modelSwitchOpen]);
|
||||
|
||||
/** Auto-grow the textarea (caps at roughly 6 lines, scrolls internally beyond that). */
|
||||
const autoGrow = () => {
|
||||
@@ -1184,6 +1319,29 @@ export function ChatInput({
|
||||
</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. */}
|
||||
{modelSwitchOpen && models && (
|
||||
<div
|
||||
ref={modelSwitchRef}
|
||||
style={{ maxHeight: upwardMaxH }}
|
||||
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">
|
||||
{S.chat.switchModelTitle}
|
||||
</div>
|
||||
<ModelMenuList
|
||||
models={models}
|
||||
value={modelRef}
|
||||
{...(defaultModel !== undefined ? { defaultModel } : {})}
|
||||
onPick={(m) => void pickSwitchModel(m)}
|
||||
/>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* @ subagent menu (triggered by typing @; interaction matches the slash menu) */}
|
||||
{mentionOpen && (
|
||||
<div
|
||||
@@ -1413,23 +1571,18 @@ export function ChatInput({
|
||||
disabled={busy}
|
||||
/>
|
||||
)}
|
||||
{/* Session state: the session's fixed thinking level (from session_meta), read-only next
|
||||
to the locked model; hidden when it isn't one of the five levels (e.g. "default"). */}
|
||||
{!onChangeModel &&
|
||||
(() => {
|
||||
const label = thinkingLevelLabel(S.chat.thinkingLevelNames, sessionThinkingLevel);
|
||||
return (
|
||||
label && (
|
||||
<span
|
||||
title={`${S.chat.thinkingLevel}:${label}`}
|
||||
className="hidden h-8 shrink-0 items-center gap-1 px-1 text-xs text-gray-400 @md:flex dark:text-gray-500"
|
||||
>
|
||||
<GlyphIcon d={SPARK_ICON} size={13} className="shrink-0" />
|
||||
{label}
|
||||
</span>
|
||||
)
|
||||
);
|
||||
})()}
|
||||
{/* 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
|
||||
|
||||
Reference in New Issue
Block a user