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
@@ -143,6 +143,76 @@ export function parseHandoffMessage(text: string): HandoffOrigin | null {
return origin.agentId ? origin : null;
}
/**
* Origin info for a `/model` switch new conversation (modeled on HandoffOrigin): the source
* Session, the absolute path of its latest Trace file (the model reads it for the earlier
* history — nothing is injected into the new context), the shared Workspace, and the
* previous model's paired reference.
*/
export interface ModelSwitchOrigin {
sessionId: string;
sessionTitle?: string;
/** Absolute path of the source session's latest Trace file (JSONL of OmniMessage envelopes). */
tracePath?: string;
workspace?: string;
/** The source session's model reference pair (never concatenated — two separate fields). */
prevProvider?: string;
prevModelId?: string;
}
/**
* First message of a `/model` switch new conversation (in English, mirroring
* `handoffMessage`): the `<model_switch_from>` block states that this conversation continues
* an earlier one on a different model and carries the source Session / Trace path /
* Workspace / previous model pair. The earlier history is deliberately NOT injected — some
* models require thinking payloads and provider `fidelity` byte-for-byte when history is
* replayed, which cannot cross models — so the model reads the Trace file itself when it
* needs the context. When rendering, `parseModelSwitchMessage` collapses this into a
* one-line switch notice (the raw text isn't shown; the model still sees it as usual).
*/
export function modelSwitchMessage(origin: ModelSwitchOrigin): string {
const title = origin.sessionTitle ? ` (${origin.sessionTitle})` : "";
const lines = [`session: ${origin.sessionId}${title}`];
if (origin.tracePath) lines.push(`trace: ${origin.tracePath}`);
if (origin.workspace) lines.push(`workspace: ${origin.workspace}`);
if (origin.prevModelId) lines.push(`previous_model: ${origin.prevModelId}`);
if (origin.prevProvider) lines.push(`previous_provider: ${origin.prevProvider}`);
return [
"<model_switch_from>",
"The user switched models: this conversation continues an earlier conversation, now on a different model. Its origin is listed below and the user's message, if any, follows. The earlier history is NOT in your context — when you need it, read the trace file at the path below (JSONL, one message envelope per line: user/assistant text, tool calls and results).",
...lines,
"</model_switch_from>",
].join("\n");
}
/**
* Inverse parse of `modelSwitchMessage` (lets the message stream collapse the origin block
* into a switch notice): returns origin info when the whole message is strictly one
* `<model_switch_from>` block, otherwise null (a normal user message renders as-is).
* Field lines parse as `key: value`; the session line's `(title)` label is split off;
* non-field lines such as the explanation sentence are ignored.
*/
export function parseModelSwitchMessage(text: string): ModelSwitchOrigin | null {
const block = /^<model_switch_from>\n([\s\S]*)\n<\/model_switch_from>$/.exec(text.trim());
if (!block) return null;
const origin: ModelSwitchOrigin = { sessionId: "" };
for (const line of block[1]!.split("\n")) {
const kv = /^(session|trace|workspace|previous_model|previous_provider): (.+)$/.exec(line);
if (!kv) continue;
const key = kv[1]!;
const value = kv[2]!;
if (key === "session") {
const labeled = /^([\w-]+) \((.*)\)$/.exec(value);
origin.sessionId = labeled ? labeled[1]! : value;
if (labeled?.[2] !== undefined) origin.sessionTitle = labeled[2];
} else if (key === "trace") origin.tracePath = value;
else if (key === "workspace") origin.workspace = value;
else if (key === "previous_model") origin.prevModelId = value;
else origin.prevProvider = value;
}
return origin.sessionId ? origin : null;
}
/** Origin info for a scheduled-task trigger (the server's scheduledMessage `<scheduled_task>` block). */
export interface ScheduledOrigin {
/** Task name (filename minus .toml). */
+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
+108 -9
View File
@@ -16,8 +16,10 @@ import { useNavigate, useParams } from "react-router";
import type {
AgentSummary,
ApprovalMode,
ModelRefDto,
ModelsResponse,
SkillMetadataItem,
TaskCreateRequest,
TaskInputPart,
} from "@prismshadow/penguin-server/api";
import * as api from "../../api/endpoints";
@@ -42,7 +44,7 @@ import { MessageStream } from "./message-stream";
import type { StreamRenderContext } from "./message-stream";
import { ChatInput } from "./chat-input";
import { DraftView } from "./draft-view";
import { handoffMessage } from "./agent-mentions";
import { handoffMessage, modelSwitchMessage } from "./agent-mentions";
import { sameModelRef } from "../models/model-grouping";
import { providerInfo } from "@prismshadow/penguin-core/model-catalog";
import { FilesPanel } from "./files-panel";
@@ -118,6 +120,12 @@ export function ChatPage() {
const [infoOpen, setInfoOpen] = useState(false);
const [modeSaving, setModeSaving] = useState(false);
const [models, setModels] = useState<ModelsResponse | null>(null);
// Per-turn thinking level, local per-session UI state: "" = untouched — the picker then
// displays the Agent config's level and postTask omits thinkingLevel (auto-follow: the
// server/core fallback applies, so mid-session Agent-config edits keep taking effect).
// Once the user picks a level it sticks for the session and rides on every subsequent
// postTask. Never written through to the Agent config (that behavior stays draft-only).
const [turnThinkingLevel, setTurnThinkingLevel] = useState("");
const routeSessionId = params.sessionId ?? null;
const filesPanel = useFilesPanel(routeSessionId);
@@ -183,6 +191,27 @@ export function ChatPage() {
};
}, [projectId, selectedAgentId]);
// The session Agent's configured thinking level ("" = unset/loading), via the same
// agent-config endpoint the draft picker uses: the in-session picker DISPLAYS this while
// the user hasn't picked a level (auto-follow — sending still omits the level until
// touched, see turnThinkingLevel). Refetched when the session's Agent changes; a failed
// fetch leaves it unset (the picker then shows an em dash until picked).
const [agentThinkingLevel, setAgentThinkingLevel] = useState("");
useEffect(() => {
setAgentThinkingLevel("");
if (!projectId || !selectedAgentId) return;
let cancelled = false;
api
.getAgentConfig(projectId, selectedAgentId)
.then((res) => {
if (!cancelled) setAgentThinkingLevel(res.config.model?.thinkingLevel ?? "");
})
.catch(() => undefined);
return () => {
cancelled = true;
};
}, [projectId, selectedAgentId]);
// The Session list is paged: a deep-linked Session (old bookmark, cross-page jump) may sit
// beyond the loaded pages. Look it up directly and insert it before the auto-select effect
// below concludes it doesn't exist; only a failed probe releases that redirect.
@@ -240,11 +269,13 @@ export function ChatPage() {
// concurrent mounts only issues one files/stat call.
const statCacheRef = useRef(new Map<string, boolean | Promise<boolean>>());
// Session switch: resets the cost and the file-card existence cache, avoiding stale data from
// the previous Session (Files panel state resets itself keyed on sessionId inside use-files-panel).
// Session switch: resets the cost, the file-card existence cache, and the per-turn thinking
// level (it's per-session UI state), avoiding stale data from the previous Session (Files
// panel state resets itself keyed on sessionId inside use-files-panel).
useEffect(() => {
setSessionCost(null);
setCostUncosted(false);
setTurnThinkingLevel("");
statCacheRef.current = new Map();
}, [routeSessionId]);
@@ -353,7 +384,15 @@ export function ChatPage() {
async (input: TaskInputPart[]): Promise<boolean> => {
if (!selected) return false;
try {
const res = await api.postTask(selected.sessionId, { input });
// An explicitly picked per-turn thinking level rides on each task; "" (untouched)
// sends nothing — the server/core falls back to the Agent config, so config edits
// keep taking effect mid-session until the user pins a level.
const res = await api.postTask(selected.sessionId, {
input,
...(turnThinkingLevel
? { thinkingLevel: turnThinkingLevel as TaskCreateRequest["thinkingLevel"] }
: {}),
});
discardSessionDraft();
await syncHealedSessionId(selected.sessionId, res.sessionId);
return true;
@@ -363,7 +402,62 @@ export function ChatPage() {
return false;
}
},
[selected, discardSessionDraft, syncHealedSessionId],
[selected, turnThinkingLevel, discardSessionDraft, syncHealedSessionId],
);
// /model switch (handoff-style, mirroring onHandoff exactly): opens a NEW session for the
// SAME agent on the picked model via the normal createSession API — deliberately with the
// SOURCE session's Workspace, so files the conversation refers to stay reachable — then
// posts a first task whose input starts with a <model_switch_from> source block (source
// session id / its latest trace path / workspace / previous model pair) followed by the
// user's remainder text and images. The earlier history is NOT injected into the new
// context (some models require thinking payloads and provider fidelity byte-for-byte on
// history replay, which cannot cross models); the model reads the source trace file itself
// when it needs the context. Returns false on failure, keeping the draft so it can be
// resent (the empty session that never got its first message is deleted, like handoff).
const onSwitchModel = useCallback(
async (ref: ModelRefDto, input: TaskInputPart[]): Promise<boolean> => {
if (!projectId || !selected) return false;
// The source's latest trace file path comes from the single-session GET (list rows
// don't carry it); best-effort — a brand-new source has no trace, the block then
// simply omits the line.
const tracePath = await api
.getSession(selected.sessionId)
.then((res) => res.session.tracePath)
.catch(() => undefined);
const origin: TaskInputPart = {
type: "text",
text: modelSwitchMessage({
sessionId: selected.sessionId,
...(selected.title !== undefined ? { sessionTitle: selected.title } : {}),
...(tracePath !== undefined ? { tracePath } : {}),
workspace: selected.workspace,
prevProvider: selected.provider,
prevModelId: selected.modelId,
}),
};
let createdId: string | null = null;
try {
const created = await api.createSession(projectId, selected.agentId, {
provider: ref.provider,
modelId: ref.modelId,
workspace: selected.workspace,
approvalMode: selected.approvalMode,
});
createdId = created.session.sessionId;
const res = await api.postTask(createdId, { input: [origin, ...input] });
addSession(created.session);
// The remainder text has been carried into the new chat: discard the source session's input draft along with it.
discardSessionDraft();
navigate(`/chat/${res.sessionId}`);
return true;
} catch (e) {
if (createdId) void api.deleteSession(createdId).catch(() => undefined);
toastError(apiErrorText(e, { modelId: ref.modelId }));
return false;
}
},
[projectId, selected, addSession, discardSessionDraft, navigate],
);
// @ handoff: doesn't use the current Session — creates a new chat for the @-mentioned agent
@@ -510,9 +604,9 @@ export function ChatPage() {
selected !== null && !stream.loading && !stream.error && stream.model.items.length === 0;
// Input area in session state: Agent / Workspace / Model are already locked by the Session
// (the model selector isn't rendered; models is only used to look up the locked model's
// provider logo and display name for a read-only display) — only approval mode can still be
// changed (saved immediately on change).
// (the model selector isn't rendered; models feeds the locked model's read-only display and
// the /model switch picker) — approval mode and the per-turn thinking level stay editable;
// /model forks the conversation onto another model.
const input = selected && (
<ChatInput
status={stream.taskState}
@@ -521,7 +615,12 @@ export function ChatPage() {
onCompact={onCompact}
modelRef={activeModelRef}
{...(models !== null ? { models: models.models } : {})}
sessionThinkingLevel={stream.model.thinkingLevel}
{...(models?.defaultModel !== undefined ? { defaultModel: models.defaultModel } : {})}
onSwitchModel={onSwitchModel}
// Display value: the user's pick for this session, else the Agent config's level
// (auto-follow while untouched; the send path uses the raw pick — see onSend).
turnThinkingLevel={turnThinkingLevel || agentThinkingLevel}
onChangeTurnThinkingLevel={setTurnThinkingLevel}
{...(contextWindow !== undefined ? { contextWindow } : {})}
contextNow={stream.model.stats.contextNow}
contextStale={stream.model.stats.contextStale}
@@ -1,12 +1,15 @@
/**
* Handoff notice for a chat created via @ delegation: the source block (<handoff_from>) isn't
* shown verbatim, it's collapsed into a single line reading "Handed off from <agent>'s chat";
* when there's a source Session, the whole line is clickable and jumps back to the original chat
* (the source Session's title goes into the title hover tooltip, taking no space in the body).
* Provenance banners for conversations opened from another conversation — each collapses a
* machine-inserted source block (the raw text is never shown; the model still sees it):
* - `HandoffBanner` (`<handoff_from>`, @ delegation): "Handed off from <agent>'s chat";
* - `ModelSwitchBanner` (`<model_switch_from>`, the /model command): "switched model —
* continued from the earlier conversation".
* When there's a source Session, the whole line is clickable and jumps back to it (the
* source Session's title goes into the title hover tooltip, taking no space in the body).
*/
import { useNavigate } from "react-router";
import { S } from "../../lib/strings";
import type { HandoffOrigin } from "./agent-mentions";
import type { HandoffOrigin, ModelSwitchOrigin } from "./agent-mentions";
/** Display name of the source agent: `displayName (@id)` when the display name differs from the id, otherwise just `@id`. */
function agentLabel(origin: HandoffOrigin): string {
@@ -15,20 +18,21 @@ function agentLabel(origin: HandoffOrigin): string {
: `@${origin.agentId}`;
}
const bannerFrame =
"anim-msg my-2 flex w-fit items-center gap-2 rounded-md border border-gray-200 bg-gray-50 px-3 py-2 text-xs text-gray-600 dark:border-gray-800 dark:bg-gray-900 dark:text-gray-400";
export function HandoffBanner({ origin }: { origin: HandoffOrigin }) {
const navigate = useNavigate();
const text = S.chat.handoffFrom(agentLabel(origin));
const frame =
"anim-msg my-2 flex w-fit items-center gap-2 rounded-md border border-gray-200 bg-gray-50 px-3 py-2 text-xs text-gray-600 dark:border-gray-800 dark:bg-gray-900 dark:text-gray-400";
// A handoff initiated from draft state has no source Session: only the origin is shown, with nowhere to jump to.
if (!origin.sessionId) return <p className={frame}>{text}</p>;
if (!origin.sessionId) return <p className={bannerFrame}>{text}</p>;
const sessionId = origin.sessionId;
return (
<button
type="button"
title={S.chat.handoffBack(origin.sessionTitle)}
onClick={() => navigate(`/chat/${sessionId}`)}
className={`${frame} transition-colors hover:bg-gray-100 hover:text-gray-800 dark:hover:bg-gray-800 dark:hover:text-gray-200`}
className={`${bannerFrame} transition-colors hover:bg-gray-100 hover:text-gray-800 dark:hover:bg-gray-800 dark:hover:text-gray-200`}
>
{text}
<span aria-hidden className="text-gray-400 dark:text-gray-500">
@@ -37,3 +41,26 @@ export function HandoffBanner({ origin }: { origin: HandoffOrigin }) {
</button>
);
}
/**
* Notice for a conversation opened by the `/model` switch (`<model_switch_from>` first
* message): a single line naming the previous model, clickable to jump back to the source
* session — the same interaction as the handoff banner's back-link.
*/
export function ModelSwitchBanner({ origin }: { origin: ModelSwitchOrigin }) {
const navigate = useNavigate();
const sessionId = origin.sessionId;
return (
<button
type="button"
title={S.chat.handoffBack(origin.sessionTitle)}
onClick={() => navigate(`/chat/${sessionId}`)}
className={`${bannerFrame} transition-colors hover:bg-gray-100 hover:text-gray-800 dark:hover:bg-gray-800 dark:hover:text-gray-200`}
>
{S.chat.modelSwitchFrom(origin.prevModelId)}
<span aria-hidden className="text-gray-400 dark:text-gray-500">
→
</span>
</button>
);
}
@@ -19,10 +19,14 @@ import { ThinkingBlock } from "./thinking-block";
import { ToolCallCard } from "./tool-call-card";
import { SubagentCard } from "./subagent-card";
import { CompactionBanner } from "./compaction-banner";
import { HandoffBanner } from "./handoff-banner";
import { HandoffBanner, ModelSwitchBanner } from "./handoff-banner";
import { ScheduledBanner } from "./scheduled-banner";
import { SkillsBanner } from "./skills-banner";
import { parseHandoffMessage, parseScheduledMessage } from "./agent-mentions";
import {
parseHandoffMessage,
parseModelSwitchMessage,
parseScheduledMessage,
} from "./agent-mentions";
import { parseSkillsMessage } from "./skill-use";
import { TaskStatsLine } from "./task-stats-line";
import type { StreamRenderContext } from "./message-stream";
@@ -87,6 +91,9 @@ export function MessageItem({ item, ctx }: { item: ChatItem; ctx: StreamRenderCo
// Source block for a chat created via @ handoff: collapsed into a single-line handoff notice (the raw text isn't shown), clickable to jump back to the original chat.
const handoff = parseHandoffMessage(item.text);
if (handoff) return <HandoffBanner origin={handoff} />;
// Source block for a chat opened by the /model switch: collapsed into a single-line switch notice, clickable to jump back to the source conversation.
const modelSwitch = parseModelSwitchMessage(item.text);
if (modelSwitch) return <ModelSwitchBanner origin={modelSwitch} />;
// Source block for a scheduled-task trigger: collapsed into a single-line notice, with the task's prompt body rendered as usual (verbatim on the Trace page).
const scheduled = parseScheduledMessage(item.text);
// Source block for a skill invocation: parsing continues on scheduled's remaining body
@@ -1,14 +1,22 @@
/**
* Pure logic for the conversation-time thinking-level picker (chat draft view).
* Pure logic for the conversation-time thinking-level pickers (chat draft + active session).
*
* The picker is backed by the **Agent settings** (`system_config.model.thinking_level`):
* it shows the selected Agent's current level and writes a picked level straight through
* to the Agent config, so the session created on first send — which reads systemConfig
* fresh — runs with it, and it becomes the Agent's new default (switch-becomes-default).
* Per review: the menu lists the levels with short names only (no descriptions, no
* "default" row) under a title bar naming the control — and it does **not** offer "none"
* Two variants share these levels and labels, and both list only concrete levels — there is
* no "follow config" row anywhere:
* - Draft view: backed by the **Agent settings** (`system_config.model.thinking_level`) —
* shows the selected Agent's current level and writes a picked level straight through to
* the Agent config, so the session created on first send runs with it and it becomes the
* Agent's new default (switch-becomes-default).
* - Active session: the level is a **per-turn override**. The picker's displayed value
* initializes to the Agent config's level and auto-follows it while the user hasn't
* picked (internally the "" state remains as "untouched": tasks then omit the level, so
* the server/core fallback applies and config edits keep taking effect); an explicit pick
* sticks for the session and rides on every subsequent task as `thinkingLevel`, never
* writing through to the Agent config.
* Per review: the menus list the levels with short names only (no descriptions, no
* "default" row) under a title bar naming the control — and they do **not** offer "none"
* (many models cannot disable thinking): "none" stays a valid stored/wire value, so a
* legacy config or session that carries it still displays via the label table below.
* legacy config or trace that carries it still displays via the label table below.
*/
/** All five stored/wire levels, for display lookup (mirrors core's ThinkingLevelName). */
@@ -162,7 +162,12 @@ function SessionMetaBody({ p }: { p: Record<string, unknown> }) {
["source", String(p.source ?? "")],
["model_id", String(p.model_id ?? "")],
["context_window", String(p.model_context_window ?? "")],
["thinking_level", String(p.thinking_level ?? "")],
// Legacy metas only: current traces no longer record a thinking level (it became a
// per-turn Task parameter), but old traces still carry the field — keep showing it
// for them (loose read) instead of losing the display.
...(typeof p.thinking_level === "string" && p.thinking_level
? ([["thinking_level", p.thinking_level]] as Array<[string, string]>)
: []),
["agent_state", String(p.agent_state ?? "")],
["workspace", String(p.workspace ?? "")],
];