Allow mid-session model switching with a persisted transcript marker

Picker stays live for the session; switches persist a model_switch notice (§17 revised).
Rebinds refused mid-turn; images become placeholders for non-vision targets at send time.
This commit is contained in:
Rohit C Prasad
2026-07-22 15:43:54 -07:00
committed by Rohit P
parent a63710ecfe
commit f1eb652d61
13 changed files with 261 additions and 56 deletions
+7 -1
View File
@@ -638,6 +638,12 @@ export function App() {
if (d.status === "max_iterations_exceeded")
setItems((p) => [...p, { kind: "notice", tone: "warn", text: "Stopped: max iterations reached." }]);
break;
case "model_changed":
// Mid-session switch (server-applied): update the header fact and drop the
// persisted marker into the live transcript (replay renders it from history).
if (d.model) setModel(d.model);
setItems((p) => [...p, { kind: "notice", tone: "info", text: d.text || "Model switched" }]);
break;
case "interrupted":
flushPartialStream();
setItems((p) => [...p, { kind: "notice", tone: "warn", text: "Interrupted." }]);
@@ -812,6 +818,7 @@ export function App() {
sessionRef.current?.setMode(m);
};
const changeModel = (m: string) => {
if (running) return; // the server refuses mid-turn rebinds — don't let the header lie
setModel(m);
sessionRef.current?.setModel(m);
};
@@ -1451,7 +1458,6 @@ export function App() {
model={model}
models={models}
modelLabels={modelLabels}
modelLocked={items.length > 0}
running={running}
connected={connected}
modelReady={modelReady}
+13 -16
View File
@@ -49,7 +49,6 @@ interface Props {
// The model is FIXED once the session has history (§17): the picker renders ONLY on a fresh
// session; after the first turn the fact lives in the topbar subtitle (§22) — no
// interactive-then-disabled control.
modelLocked?: boolean;
running: boolean;
connected: boolean;
// False when the default model's provider has no key — the composer shows a "connect a model"
@@ -461,8 +460,9 @@ export function Composer(props: Props) {
<span className="ml-auto" />
{/* model — a quiet chip on a FRESH session only; once the session has history the
fact moves up to the topbar subtitle (§17 expressed spatially). */}
{/* model — a quiet chip, now for the session's whole life (§17 rev 2026-07-22:
mid-session switching shipped, so the picker stays actionable; the topbar
subtitle still states the current model). */}
{!dictation?.recording && (needsModel ? (
<button
className="pill model-warn chip"
@@ -473,20 +473,17 @@ export function Composer(props: Props) {
<span className="pill-label">No model</span>
<span className="model-warn-ico" aria-hidden></span>
</button>
) : modelsLoaded ? (
<Dropdown value={props.model} options={modelOptions} onChange={props.onModelChange} align="right" />
) : (
!props.modelLocked &&
(modelsLoaded ? (
<Dropdown value={props.model} options={modelOptions} onChange={props.onModelChange} align="right" />
) : (
<button
className="pill chip text-faint cursor-default"
disabled
data-testid="models-loading"
title="Fetching the model list from the server"
>
<span className="pill-label">Loading models</span>
</button>
))
<button
className="pill chip text-faint cursor-default"
disabled
data-testid="models-loading"
title="Fetching the model list from the server"
>
<span className="pill-label">Loading models</span>
</button>
))}
{/* mic — immediately before send (owner call, DMG #28 walkthrough) */}
@@ -68,3 +68,17 @@ describe("itemsFromMessages notices", () => {
]);
});
});
describe("itemsFromMessages model switch", () => {
it("replays the persisted model_switch marker as an info notice", () => {
const items = itemsFromMessages([
{ role: "user", content: "hi" },
{ role: "notice", kind: "model_switch", text: "Model switched to Kimi K2.6 · Moonshot" },
] as any);
expect(items[1]).toEqual({
kind: "notice",
tone: "info",
text: "Model switched to Kimi K2.6 · Moonshot",
});
});
});
+4 -2
View File
@@ -59,13 +59,15 @@ export function itemsFromMessages(messages: ConversationMessage[]): Item[] {
});
}
} else if (m.role === "notice") {
// Persisted turn-ending marker (engine `_append_notice`): error/interrupted survive
// Persisted markers (engine `_append_notice`): error/interrupted/model-switch survive
// reload exactly like the live view rendered them. An error notice is retriable —
// the Transcript only offers the button when it's the transcript tail.
items.push(
m.kind === "interrupted"
? { kind: "notice", tone: "warn", text: "Interrupted." }
: { kind: "notice", tone: "warn", text: "Error: " + (m.text || "unknown"), retriable: true },
: m.kind === "model_switch"
? { kind: "notice", tone: "info", text: m.text || "Model switched" }
: { kind: "notice", tone: "warn", text: "Error: " + (m.text || "unknown"), retriable: true },
);
}
// system messages are omitted; tool-result messages are folded into the tool row above
+1
View File
@@ -15,6 +15,7 @@ export type EventType =
| "turn_end"
| "error"
| "interrupted"
| "model_changed"
| "turn_done";
export interface WsEvent {