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:
@@ -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). */
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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 ?? "")],
|
||||
];
|
||||
|
||||
Reference in New Issue
Block a user