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:
Yaowei Zheng
2026-07-26 16:31:51 +08:00
committed by GitHub
parent 816a823b75
commit 1e23452f48
58 changed files with 1064 additions and 336 deletions
+277 -124
View File
@@ -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