Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
74 changes: 74 additions & 0 deletions src/client/chat/controller.ts
Original file line number Diff line number Diff line change
Expand Up @@ -43,6 +43,18 @@ export interface ChatUnavailable {
canResume: boolean;
}

/**
* What actually happened when this browser last asked to change the model.
*
* Mirrors the server's own honesty about it: a typed model name is never
* validated ahead of time, so this reports what was actually possible rather
* than assuming the best case.
*/
export interface ModelSwitchResult {
applied: 'live' | 'sent' | 'pending' | 'cleared';
message: string;
}

/**
* How long a page request may go unanswered before the control comes back.
*
Expand All @@ -66,6 +78,20 @@ export class ChatController {
/** Set while the conversation is readable but has nothing running it. */
private unavailable: ChatUnavailable | null = null;

/**
* The model override this conversation is carrying, independent of what the
* runtime itself reports through the transcript.
*
* Null means "no override" — the surface then falls back to whatever the
* transcript's own `model` says, which is the runtime's default or the
* active profile. Kept here rather than folded into the transcript because
* it is a fact about the *session record*, not something any adapter emits
* as an event.
*/
private modelOverride: string | null = null;
/** What the server reported happened to the last model change requested. */
private modelResult: ModelSwitchResult | null = null;

constructor(
readonly sessionId: string,
private readonly options: ChatControllerOptions,
Expand All @@ -91,6 +117,8 @@ export class ChatController {
if (!snapshot) return true;
this.settlePage();
this.transcript.hydrate(snapshot);
this.modelOverride =
typeof message.modelOverride === 'string' ? message.modelOverride : null;
this.options.onChange?.();
return true;
}
Expand All @@ -111,6 +139,25 @@ export class ChatController {
// message rather than assumed, so the badge cannot claim a mode the
// process is not running in.
this.transcript.setBypassing(message.bypassPermissions === true);
this.modelOverride =
typeof message.modelOverride === 'string' ? message.modelOverride : null;
this.options.onChange?.();
return true;
}

case 'chat_model_result': {
const applied = (message.applied as ModelSwitchResult['applied']) || 'pending';
// Only 'live'/'cleared' mean the session is actually running this model now.
// 'sent'/'pending' are best-effort or deferred-to-next-launch — adopting the
// label for those would claim a model is active when it is not yet, or may
// never be without a relaunch.
if (applied === 'live' || applied === 'cleared') {
this.modelOverride = typeof message.model === 'string' ? message.model : null;
}
this.modelResult = {
applied,
message: String(message.message || ''),
};
this.options.onChange?.();
return true;
}
Expand Down Expand Up @@ -266,6 +313,28 @@ export class ChatController {
this.send({ type: 'chat_permission_response', requestId, optionId });
}

/** The model override in force for this conversation, or null if there is none. */
get modelOverrideValue(): string | null {
return this.modelOverride;
}

/** What happened the last time this browser asked to change the model. */
get modelFeedback(): ModelSwitchResult | null {
return this.modelResult;
}

/**
* Ask the server to switch this conversation's model, or clear the override
* with an empty string.
*
* Never validated here: the composer sends whatever was typed, and the
* server's reply — live, sent, or saved-for-next-time — is what actually
* tells the user what happened.
*/
setModel(model: string): void {
this.send({ type: 'chat_set_model', model });
}

/** Tell the server this browser wants this conversation's live events. */
subscribe(): void {
this.send({ type: 'chat_subscribe' });
Expand Down Expand Up @@ -332,6 +401,11 @@ export class ChatController {
/** Drop everything, e.g. when the session is being restarted. */
reset(): void {
this.settlePage();
// Not a claim either way: the next snapshot or chat_started carries the
// record's real override, and showing a stale one in the meantime would
// be worse than showing nothing.
this.modelOverride = null;
this.modelResult = null;
// `hydrate` clears the queue from the (absent) snapshot field, so the line
// does not survive into a session that never accepted it.
this.transcript.hydrate({
Expand Down
13 changes: 12 additions & 1 deletion src/client/shell/chat/ChatView.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -239,6 +239,10 @@ export function ChatView({
(queuedId: string) => controller.cancelQueued(queuedId),
[controller],
);
const setModel = React.useCallback(
(model: string) => controller.setModel(model),
[controller],
);
const upload = React.useCallback(
(file: File) => uploadAttachment(controller.sessionId, file),
[controller],
Expand Down Expand Up @@ -710,7 +714,14 @@ export function ChatView({
branch={branch}
turnLabel={currentTurn ? `turn ${currentTurn.index}` : undefined}
usage={transcript.usage}
model={transcript.model}
// The conversation's own override wins over whatever the
// runtime last reported, once it is confirmed live (or cleared).
// A pending/sent switch is not yet running and is surfaced via
// modelFeedback instead, so this label never claims a model the
// session isn't actually on.
model={controller.modelOverrideValue ?? transcript.model}
onSetModel={setModel}
modelFeedback={controller.modelFeedback}
bypassPermissions={bypassPermissions}
terminalOpen={terminalOpen}
/>
Expand Down
Loading
Loading