From 3853df4e51ccec999b305390892488687b7a34f9 Mon Sep 17 00:00:00 2001 From: dnviti Date: Sun, 26 Jul 2026 15:09:11 +0200 Subject: [PATCH 1/6] chore: bump version to 5.1.2 --- CHANGELOG.md | 5 +++++ package-lock.json | 4 ++-- package.json | 2 +- 3 files changed, 8 insertions(+), 3 deletions(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index bed5c6e..a59c676 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -1,5 +1,10 @@ # Changelog +## [5.1.2] - 2026-07-26 + +### Notes +- Version bump only; no functional changes. + ## [5.1.1] - 2026-07-26 ### Fixed diff --git a/package-lock.json b/package-lock.json index e60b590..8132bf1 100644 --- a/package-lock.json +++ b/package-lock.json @@ -1,12 +1,12 @@ { "name": "code-agents-webcli", - "version": "5.1.1", + "version": "5.1.2", "lockfileVersion": 3, "requires": true, "packages": { "": { "name": "code-agents-webcli", - "version": "5.1.1", + "version": "5.1.2", "license": "MIT", "dependencies": { "@lydell/node-pty": "1.2.0-beta.14", diff --git a/package.json b/package.json index ba60531..18f6b98 100644 --- a/package.json +++ b/package.json @@ -1,6 +1,6 @@ { "name": "code-agents-webcli", - "version": "5.1.1", + "version": "5.1.2", "description": "Multiuser web CLI for Claude Code, Codex, and terminal sessions", "main": "dist/server/index.js", "bin": { From bdfeb40804c8e367c8fb42cc31ac9364a54f484b Mon Sep 17 00:00:00 2001 From: Daniele Viti Date: Sun, 26 Jul 2026 17:20:37 +0200 Subject: [PATCH 2/6] feat(chat): let users override a conversation's model, independent of profile default (#39) Adds a per-conversation model override so any chat session can switch or type a custom model at any time, regardless of whether the underlying runtime already supports it. Persisted on the session (mirrors the chatBypassPermissions pattern), applied via a three-tier fallback (live adapter.setModel -> best-effort /model slash command -> saved for next launch), and always beats the runtime profile's default for that conversation only. Composer's model chip becomes an always-enabled combobox with free-text entry and honest per-outcome feedback, replacing the old dead-end disabled state. Closes #38 Co-authored-by: Claude Sonnet 5 --- src/client/chat/controller.ts | 74 +++++++++++ src/client/shell/chat/ChatView.tsx | 13 +- src/client/shell/chat/Composer.tsx | 179 ++++++++++++++++++++------- src/server/chat/manager.ts | 7 ++ src/server/chat/session.ts | 15 +++ src/server/services/database.ts | 5 + src/server/services/session-store.ts | 19 ++- src/server/types.ts | 14 +++ src/server/websocket/messages.ts | 135 +++++++++++++++++++- test/chat-composer.test.js | 47 +++++++ test/chat-model-controller.test.js | 70 +++++++++++ test/chat-wiring.test.js | 142 ++++++++++++++++++++- test/session-store.test.js | 42 +++++++ 13 files changed, 710 insertions(+), 52 deletions(-) create mode 100644 test/chat-model-controller.test.js diff --git a/src/client/chat/controller.ts b/src/client/chat/controller.ts index 73eea92..ff6a231 100644 --- a/src/client/chat/controller.ts +++ b/src/client/chat/controller.ts @@ -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. * @@ -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, @@ -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; } @@ -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; } @@ -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' }); @@ -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({ diff --git a/src/client/shell/chat/ChatView.tsx b/src/client/shell/chat/ChatView.tsx index ee31aed..71c64d0 100644 --- a/src/client/shell/chat/ChatView.tsx +++ b/src/client/shell/chat/ChatView.tsx @@ -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], @@ -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} /> diff --git a/src/client/shell/chat/Composer.tsx b/src/client/shell/chat/Composer.tsx index e8dfe95..999b8db 100644 --- a/src/client/shell/chat/Composer.tsx +++ b/src/client/shell/chat/Composer.tsx @@ -79,6 +79,14 @@ export interface ComposerProps { * entry of the menu as the current value is right only by accident. */ model?: string; + /** + * Change this conversation's model, independent of the runtime's own + * default. Always available — see ModelChip — regardless of whether the + * runtime advertises a model list or a live switch. + */ + onSetModel?: (model: string) => void; + /** What the server reported about the last `onSetModel` call, for the chip. */ + modelFeedback?: { applied: 'live' | 'sent' | 'pending' | 'cleared'; message: string } | null; /** Drives what the permission chip reports. */ bypassPermissions?: boolean; /** Changes what the hint row says about who owns Return. */ @@ -160,6 +168,8 @@ export function Composer({ turnLabel, usage, model, + onSetModel, + modelFeedback, bypassPermissions = false, terminalOpen = false, }: ComposerProps) { @@ -762,9 +772,8 @@ export function Composer({ onSend(`/model ${value}`, [])} + feedback={modelFeedback} + onPick={(value) => onSetModel?.(value)} /> @@ -1020,38 +1029,43 @@ function Chip({ } /** - * The model, and the one honest way to change it. + * The model, and the one always-honest way to change it. * - * There is no protocol message for "switch model" — every runtime that supports - * it does so through its own slash command. So the picker is live exactly when - * the runtime advertises both a model list and a `/model` command, and sends - * that command as an ordinary turn. Anything else would be a control that looks - * like it worked. + * There is no protocol message every runtime answers to for "switch model" — + * some can rewrite it live, most only take it through their own `/model` + * command or at spawn — so this control never claims to know in advance which + * of those a pick will get. It always accepts a choice, from the list when the + * runtime has one and from free text regardless, sends it, and reports back + * whatever the server says actually happened. A disabled control that told the + * user to start a new session was a worse answer than trying and being honest + * about the result. */ function ModelChip({ current, models, - commands, - disabled, onPick, + feedback, }: { /** What the session reported it is running, when it reported anything. */ current: string | undefined; models: ModelChoice[] | undefined; - commands: SlashCommand[]; - disabled: boolean; onPick: (value: string) => void; -}): React.JSX.Element | null { + /** What the server said happened to the last pick made here. */ + feedback?: { applied: 'live' | 'sent' | 'pending' | 'cleared'; message: string } | null; +}): React.JSX.Element { const [open, setOpen] = React.useState(false); + const [customValue, setCustomValue] = React.useState(''); const ref = React.useRef(null); - const switchable = Boolean(models && models.length) && commands.some((c) => c.name === 'model'); + const inputRef = React.useRef(null); // The session's own model wins. `models` is a menu in whatever order the // runtime listed it, and its first entry is the current one only by accident. const matched = models?.find((m) => m.value === current || m.name === current); - const label = matched?.name ?? current ?? models?.[0]?.name; + const label = matched?.name ?? current ?? 'model'; React.useEffect(() => { if (!open) return; + setCustomValue(''); + inputRef.current?.focus(); const onPointerDown = (event: MouseEvent): void => { if (!ref.current?.contains(event.target as Node)) setOpen(false); }; @@ -1066,29 +1080,25 @@ function ModelChip({ }; }, [open]); - // Nothing to report and nothing to offer. - if (!label) return null; + const pick = (value: string): void => { + setOpen(false); + onPick(value); + }; - if (!switchable) { - return ( - - {label} - - ); - } + const submitCustom = (): void => { + const value = customValue.trim(); + if (!value) return; + pick(value); + }; return (
+
+ + {(models ?? []).map((choice) => ( + ))} + + {!models?.length ? ( +
+ This runtime hasn't listed models — type one above. +
+ ) : null} + + ) : null} + + {!open && feedback ? ( +
+ {feedback.message}
) : null} diff --git a/src/server/chat/manager.ts b/src/server/chat/manager.ts index 8629052..0ab04a2 100644 --- a/src/server/chat/manager.ts +++ b/src/server/chat/manager.ts @@ -207,6 +207,13 @@ export class ChatSessionManager { await this.sessions.get(sessionId)?.interrupt(); } + /** Switch a live session's model. False when nothing is running, or the adapter cannot. */ + async setModel(sessionId: string, model: string): Promise { + const session = this.sessions.get(sessionId); + if (!session) return false; + return session.setModel(model); + } + /** Drop a turn that was typed ahead and has not run yet. */ cancelQueued(sessionId: string, queuedId: string): boolean { return this.sessions.get(sessionId)?.cancelQueued(queuedId) ?? false; diff --git a/src/server/chat/session.ts b/src/server/chat/session.ts index 0ce29d0..bf826b9 100644 --- a/src/server/chat/session.ts +++ b/src/server/chat/session.ts @@ -643,6 +643,21 @@ export class ChatSession { }); } + /** + * Switch the live process to a different model, for the adapters that can. + * + * Only Grok exposes this today — its model is a per-invocation flag it can + * rewrite for the next turn without a restart. Every other adapter's model + * is fixed at spawn, so this reports it could not and the caller falls back + * to the runtime's own `/model` command (best-effort) or to persisting the + * choice for the next session. + */ + async setModel(model: string): Promise { + if (!this.adapter?.alive || !this.adapter.setModel) return false; + await this.adapter.setModel(model); + return true; + } + snapshot(): Promise { return this.deps.store.snapshot(this.ref).then((snapshot) => ({ ...snapshot, diff --git a/src/server/services/database.ts b/src/server/services/database.ts index c453fc2..1bde8e1 100644 --- a/src/server/services/database.ts +++ b/src/server/services/database.ts @@ -384,6 +384,11 @@ export class AppDatabase { // permission on because a column was absent is not a mistake to leave // available. INTEGER because SQLite has no boolean. this.addColumnIfMissing('runtime_sessions', 'chat_bypass_permissions', 'INTEGER'); + + // The model this conversation overrides its runtime/profile default with. + // Nullable and null-by-default: every row written before this column + // existed has no override recorded, which is exactly what a null means. + this.addColumnIfMissing('runtime_sessions', 'chat_model_override', 'TEXT'); } /** diff --git a/src/server/services/session-store.ts b/src/server/services/session-store.ts index b1daceb..3a1eb44 100644 --- a/src/server/services/session-store.ts +++ b/src/server/services/session-store.ts @@ -35,6 +35,7 @@ interface RuntimeSessionRow { native_chat_session_id: string | null; owner_session_id: string | null; chat_bypass_permissions: number | null; + chat_model_override: string | null; } export class SessionStore { @@ -72,7 +73,8 @@ export class SessionStore { surface, native_chat_session_id, owner_session_id, - chat_bypass_permissions + chat_bypass_permissions, + chat_model_override ) VALUES ( @id, @@ -94,7 +96,8 @@ export class SessionStore { @surface, @native_chat_session_id, @owner_session_id, - @chat_bypass_permissions + @chat_bypass_permissions, + @chat_model_override ) `); @@ -157,6 +160,10 @@ export class SessionStore { // restart brings it back rather than quietly dropping to manual — and so // the header can state the mode of a conversation with nothing running. chat_bypass_permissions: session.chatBypassPermissions === true ? 1 : null, + // The conversation-scoped model override, if the user has set one. + // Persisted so a restart still prefers it over the profile default the + // next time a session starts for this conversation. + chat_model_override: session.chatModelOverride || null, })); replaceAll(rows); @@ -193,7 +200,8 @@ export class SessionStore { surface, native_chat_session_id, owner_session_id, - chat_bypass_permissions + chat_bypass_permissions, + chat_model_override FROM runtime_sessions ORDER BY created_at ASC `) @@ -238,6 +246,11 @@ export class SessionStore { // written before this column existed carries — reads as "asks first", // so a missing answer can never grant a standing permission. chatBypassPermissions: row.chat_bypass_permissions === 1 ? true : undefined, + // A stored empty string never happens — the write side always writes + // null for "no override" — but an empty string reading as "no + // override" rather than a call to switch to nothing is the safe + // direction regardless. + chatModelOverride: row.chat_model_override || undefined, }); } diff --git a/src/server/types.ts b/src/server/types.ts index 9280808..dde1320 100644 --- a/src/server/types.ts +++ b/src/server/types.ts @@ -105,6 +105,20 @@ export interface SessionRecord { * standing permission can never be inherited by another conversation. */ chatBypassPermissions?: boolean; + /** + * The model this conversation runs with, independent of the runtime's own + * default or the active profile. + * + * Set from a free-typed choice in the composer, not validated against + * anything: the acceptance test for a model name is whether the runtime + * accepts it, not whether it looks plausible before it is tried. Scoped to + * this record only — it is never written back as a profile or personal + * default, so it cannot leak into another conversation's launch. + * + * Absent means "no override", which reads as the profile default exactly + * like every row written before this existed. + */ + chatModelOverride?: string; terminalOptions: TerminalOptions | null; stopRequested: boolean; /** Identifies the current PTY run so late callbacks from a previous run are ignored. */ diff --git a/src/server/websocket/messages.ts b/src/server/websocket/messages.ts index 32a03f9..8892e03 100644 --- a/src/server/websocket/messages.ts +++ b/src/server/websocket/messages.ts @@ -88,6 +88,8 @@ export interface ChatManagerLike { snapshot(record: SessionRecord): Promise; send(sessionId: string, turn: { text: string; attachments?: unknown[] }): Promise; interrupt(sessionId: string): Promise; + /** Switch a live session's model. False when nothing is running, or the adapter cannot. */ + setModel(sessionId: string, model: string): Promise; cancelQueued(sessionId: string, queuedId: string): boolean; respondPermission(sessionId: string, requestId: string, optionId: string): boolean; stop(sessionId: string): Promise; @@ -118,6 +120,8 @@ interface IncomingMessage { agentKind?: string; /** Identifies one turn waiting in the send-ahead queue. */ queuedId?: string; + /** A conversation-scoped model to switch to, or null/empty to clear the override. */ + model?: string | null; } export class MessageProcessor { @@ -199,6 +203,10 @@ export class MessageProcessor { await this.handleChatInterrupt(wsInfo, data); break; + case 'chat_set_model': + await this.handleChatSetModel(wsInfo, data); + break; + case 'chat_queue_cancel': this.handleChatQueueCancel(wsInfo, data); break; @@ -466,7 +474,9 @@ export class MessageProcessor { // // Profile values are deliberately *not* read from `options`: everything in // safeOptions came from the browser, and the whole point of the profile is - // that it is server-side configuration the client cannot forge. + // that it is server-side configuration the client cannot forge. (`model` + // is the one exception below: a conversation's own override is allowed to + // beat it, but only for that conversation, never written back to the profile.) const profile = this.deps.resolveRuntimeProfile(agentKind, session.workingDir); if (profile) { if (profile.model) safeOptions.model = profile.model; @@ -474,6 +484,12 @@ export class MessageProcessor { if (profile.env && Object.keys(profile.env).length) safeOptions.env = profile.env; console.log(`Applying runtime profile "${profile.profileName}" to ${agentKind}`); } + // A model chosen for this conversation beats the profile default, but only + // for this one launch: it is never written back as a new profile or + // personal default. + if (session.chatModelOverride) { + safeOptions.model = session.chatModelOverride; + } // The scrollback recorder is born on the first output byte, before any // resize message can arrive, so the geometry the run starts at has to be @@ -975,7 +991,9 @@ export class MessageProcessor { const chat = await manager.start(session, { runtime: agentKind, workingDir: session.workingDir, - model: profile?.model, + // Conversation-scoped override beats the profile default, for this + // launch only — see chat_set_model. + model: session.chatModelOverride || profile?.model, extraArgs: profile?.extraArgs, env: profile?.env, bypassPermissions, @@ -1012,6 +1030,7 @@ export class MessageProcessor { workingDir: session.workingDir, capabilities: chat.currentCapabilities, bypassPermissions: chat.bypassing, + modelOverride: session.chatModelOverride || null, }, this.deps.claudeSessions, this.deps.webSocketConnections, @@ -1054,7 +1073,12 @@ export class MessageProcessor { try { const snapshot = await manager.snapshot(session); - sendToWebSocket(wsInfo.ws, { type: 'chat_snapshot', sessionId, snapshot }); + sendToWebSocket(wsInfo.ws, { + type: 'chat_snapshot', + sessionId, + snapshot, + modelOverride: session.chatModelOverride || null, + }); } catch (error: unknown) { const message = error instanceof Error ? error.message : String(error); sendToWebSocket(wsInfo.ws, { @@ -1137,6 +1161,111 @@ export class MessageProcessor { await manager.interrupt(session.id).catch(() => undefined); } + /** + * Change the model for one conversation, independent of the runtime's own + * default or the active profile. + * + * Never pre-validated: what a typed name actually does is only known by + * trying it, so this always persists the choice and then reports what + * happened, in order of how good an answer it is — live, best-effort sent as + * a turn, or merely saved for the next launch. It is deliberately never + * written back as a profile or personal default; the override belongs to + * this conversation and nothing else. + */ + private async handleChatSetModel( + wsInfo: WebSocketInfo, + data: IncomingMessage, + ): Promise { + const session = this.chatSessionFor(wsInfo, data.sessionId); + if (!session) return; + + const raw = typeof data.model === 'string' ? data.model.trim() : ''; + const model = raw || undefined; + session.chatModelOverride = model; + await this.deps.saveSessionsToDisk(); + + if (!model) { + sendToWebSocket(wsInfo.ws, { + type: 'chat_model_result', + sessionId: session.id, + model: null, + applied: 'cleared', + message: 'Cleared the model override. The next session for this conversation will use the runtime default.', + }); + return; + } + + const manager = this.deps.chatManager; + if (!manager) { + sendToWebSocket(wsInfo.ws, { + type: 'chat_model_result', + sessionId: session.id, + model, + applied: 'pending', + message: `Saved. ${model} will be used the next time a session starts for this conversation.`, + }); + return; + } + + try { + const applied = await manager.setModel(session.id, model); + if (applied) { + sendToWebSocket(wsInfo.ws, { + type: 'chat_model_result', + sessionId: session.id, + model, + applied: 'live', + message: `Switched to ${model} for this conversation.`, + }); + return; + } + } catch (error: unknown) { + const message = error instanceof Error ? error.message : String(error); + sendToWebSocket(wsInfo.ws, { + type: 'chat_model_result', + sessionId: session.id, + model, + applied: 'pending', + message: `Saved, but switching live failed (${message}). ${model} will be used the next time a session starts.`, + }); + return; + } + + // The adapter cannot switch live. If the runtime advertises its own + // `/model` command, best-effort send it as an ordinary turn — the same + // mechanism the old switchable picker used — and say so honestly: the + // CLI's own reply is the real confirmation, not this message. + const snapshot = await manager.snapshot(session).catch(() => null) as + | { live?: boolean; capabilities?: { commands?: { name: string }[] } } + | null; + const live = snapshot?.live === true; + const hasModelCommand = snapshot?.capabilities?.commands?.some((c) => c.name === 'model') ?? false; + + if (live && hasModelCommand) { + try { + await manager.send(session.id, { text: `/model ${model}` }); + sendToWebSocket(wsInfo.ws, { + type: 'chat_model_result', + sessionId: session.id, + model, + applied: 'sent', + message: `Sent "/model ${model}" to the session — check the transcript to confirm it took.`, + }); + return; + } catch { + // Falls through to the saved-for-next-time answer below. + } + } + + sendToWebSocket(wsInfo.ws, { + type: 'chat_model_result', + sessionId: session.id, + model, + applied: 'pending', + message: `Saved. This runtime cannot change model mid-session — ${model} will be used the next time a new session starts for this conversation.`, + }); + } + /** * Withdraw a turn the user typed ahead. * diff --git a/test/chat-composer.test.js b/test/chat-composer.test.js index 9457c25..c3a1b46 100644 --- a/test/chat-composer.test.js +++ b/test/chat-composer.test.js @@ -205,6 +205,53 @@ describe('Composer', function () { assert.ok(/]*disabled=""/.test(html), 'the textarea itself must go inert'); }); + // ModelChip's own open/closed state is a plain click-toggled useState, unlike + // the slash-command popup above (whose "open" is derived from the draft, so + // static markup can render it open). A static render can only ever show it + // closed, so these assert what is true of every runtime regardless of + // whether it has a model list, a live switch, or neither — the always-on + // control and its labelling — rather than the popup's own contents. + describe('the model control', function () { + it('is always present and never disabled, even when the whole composer is', function () { + const html = render({ disabled: true, capabilities: caps({}) }); + assert.ok(html.includes('aria-label="Change model"'), 'a runtime with no model list or /model command still gets a control'); + assert.ok( + !/aria-label="Change model"[^>]*disabled/.test(html), + 'the model control must not be disabled by the composer being disabled — that is exactly the case where it saves for next session', + ); + }); + + it('labels the conversation’s own model over the menu’s first entry', function () { + const models = [ + { value: 'a', name: 'Model A' }, + { value: 'b', name: 'Model B' }, + ]; + const html = render({ model: 'b', capabilities: caps({ models }) }); + assert.ok(html.includes('>Model B<'), 'the session’s actual model is shown, not the first item in the menu'); + }); + + it('falls back to whatever was typed when it matches nothing in the menu', function () { + const html = render({ model: 'a-custom-name', capabilities: caps({}) }); + assert.ok(html.includes('>a-custom-name<'), 'a free-typed override with no matching menu entry is still the label'); + }); + + it('surfaces the server’s feedback after a pick, in each of the three shapes', function () { + const live = render({ model: 'grok-3-fast', modelFeedback: { applied: 'live', message: 'Switched to grok-3-fast for this conversation.' } }); + assert.ok(live.includes('Switched to grok-3-fast for this conversation.')); + + const sent = render({ model: 'claude-opus', modelFeedback: { applied: 'sent', message: 'Sent "/model claude-opus" to the session — check the transcript to confirm it took.' } }); + assert.ok(sent.includes('check the transcript to confirm it took')); + + const pending = render({ model: 'some-model', modelFeedback: { applied: 'pending', message: 'Saved. some-model will be used the next time a new session starts for this conversation.' } }); + assert.ok(pending.includes('will be used the next time a new session starts')); + }); + + it('shows nothing extra when there is no feedback yet', function () { + const html = render({ capabilities: caps({}) }); + assert.ok(!html.includes('role="status"'), 'no stray feedback region before anything has been picked'); + }); + }); + it('survives an empty draft and a very long one', function () { const empty = render({ draft: '' }); assert.ok(empty.length > 0); diff --git a/test/chat-model-controller.test.js b/test/chat-model-controller.test.js new file mode 100644 index 0000000..f6f7180 --- /dev/null +++ b/test/chat-model-controller.test.js @@ -0,0 +1,70 @@ +const assert = require('assert'); +const fs = require('fs'); +const os = require('os'); +const path = require('path'); + +// A `chat_model_result` of 'pending'/'sent' means the switch is not actually +// running yet — only 'live'/'cleared' confirm the session is on the named +// model. The label the UI shows must track only the confirmed state: adopting +// it on 'pending' would tell the user a model is active before it ever ran, +// and possibly forever if the conversation is never relaunched. + +const ROOT = path.join(__dirname, '..'); + +let mod; +let bundle; + +before(function () { + this.timeout(60000); + const contents = [ + `export { ChatController } from ${JSON.stringify(path.join(ROOT, 'src/client/chat/controller'))};`, + ].join('\n'); + + bundle = path.join(os.tmpdir(), `chat-model-controller-${process.pid}.js`); + require('esbuild').buildSync({ + stdin: { contents, resolveDir: ROOT, loader: 'ts', sourcefile: 'chat-model-controller.ts' }, + bundle: true, + outfile: bundle, + format: 'cjs', + platform: 'node', + target: ['node20'], + logLevel: 'silent', + }); + mod = require(bundle); +}); + +after(function () { + if (bundle) fs.rmSync(bundle, { force: true }); +}); + +function controller() { + return new mod.ChatController('s1', { send: () => {} }); +} + +describe('the model override label', function () { + it('adopts the switched model once the server confirms it is live', function () { + const c = controller(); + c.handle({ type: 'chat_model_result', sessionId: 's1', model: 'grok-3-fast', applied: 'live', message: 'ok' }); + assert.strictEqual(c.modelOverrideValue, 'grok-3-fast'); + }); + + it('does not adopt the label while a switch is only pending the next session', function () { + const c = controller(); + c.handle({ type: 'chat_model_result', sessionId: 's1', model: 'some-model', applied: 'pending', message: 'saved' }); + assert.strictEqual(c.modelOverrideValue, null, 'not running this model yet, so the label must not claim it is'); + assert.strictEqual(c.modelFeedback.applied, 'pending', 'the banner still reports what happened'); + }); + + it('does not adopt the label for a best-effort slash command still awaiting confirmation', function () { + const c = controller(); + c.handle({ type: 'chat_model_result', sessionId: 's1', model: 'claude-opus', applied: 'sent', message: 'sent' }); + assert.strictEqual(c.modelOverrideValue, null); + }); + + it('adopts null immediately when the override is cleared', function () { + const c = controller(); + c.handle({ type: 'chat_model_result', sessionId: 's1', model: 'grok-3-fast', applied: 'live', message: 'ok' }); + c.handle({ type: 'chat_model_result', sessionId: 's1', model: null, applied: 'cleared', message: 'cleared' }); + assert.strictEqual(c.modelOverrideValue, null); + }); +}); diff --git a/test/chat-wiring.test.js b/test/chat-wiring.test.js index c6e65eb..ad92737 100644 --- a/test/chat-wiring.test.js +++ b/test/chat-wiring.test.js @@ -38,10 +38,14 @@ function createSessionRecord(params = {}) { /** A chat manager that records what it was asked to do. */ function createChatManager(overrides = {}) { - const calls = { start: [], send: [], interrupt: [], permission: [], page: [], cancelQueued: [] }; + const calls = { start: [], send: [], interrupt: [], permission: [], page: [], cancelQueued: [], setModel: [] }; return { calls, has: () => false, + async setModel(sessionId, model) { + calls.setModel.push({ sessionId, model }); + return false; + }, async start(record, options) { calls.start.push({ record, options }); return { @@ -231,6 +235,27 @@ describe('chat wiring', function () { }); }); + describe('startRuntime (PTY)', function () { + it('lets a saved model override outrank the profile default on this launch too', async function () { + const { processor, session } = build({ surface: undefined }); + session.chatModelOverride = 'saved-override'; + processor.deps.resolveRuntimeProfile = () => ({ profileName: 'p', model: 'profile-default' }); + + const startCalls = []; + processor.deps.getRuntimeBridge = () => ({ + startSession: async (sessionId, options) => { + startCalls.push(options); + return { pid: 1 }; + }, + }); + + await processor.startRuntime('ws-1', 'claude', {}); + + assert.strictEqual(startCalls.length, 1, 'the bridge was not asked to start'); + assert.strictEqual(startCalls[0].model, 'saved-override'); + }); + }); + // The approval mode is part of how the user set a conversation up, so it has // to outlive the process serving it. It used to live only on the live // ChatSession: reconnecting to a conversation whose agent had gone, restarting @@ -404,6 +429,121 @@ describe('chat wiring', function () { }); }); + describe('chat_set_model', function () { + it('applies live when the adapter can switch without a restart', async function () { + const { processor, chatManager, session, sent } = build( + { surface: 'chat' }, + { setModel: async (sessionId, model) => { chatManager.calls.setModel.push({ sessionId, model }); return true; } }, + ); + + await processor.handleMessage('ws-1', { type: 'chat_set_model', model: 'grok-3-fast' }); + + assert.deepStrictEqual(chatManager.calls.setModel[0], { sessionId: 'session-1', model: 'grok-3-fast' }); + assert.strictEqual(session.chatModelOverride, 'grok-3-fast', 'the override is persisted regardless of how it applied'); + const result = lastOfType(sent, 'chat_model_result'); + assert.strictEqual(result.applied, 'live'); + assert.strictEqual(result.model, 'grok-3-fast'); + }); + + it('falls back to a best-effort slash command when the runtime advertises /model but cannot switch live', async function () { + const { processor, chatManager, session, sent } = build( + { surface: 'chat' }, + { + async snapshot(record) { + return { + sessionId: record.id, + runtime: 'claude', + messages: [], + state: 'idle', + capabilities: { commands: [{ name: 'model' }] }, + pendingPermissions: [], + firstSeq: 0, + cursor: 0, + live: true, + bypassPermissions: false, + }; + }, + }, + ); + + await processor.handleMessage('ws-1', { type: 'chat_set_model', model: 'claude-opus' }); + + assert.strictEqual(chatManager.calls.send.length, 1, 'the slash command must be sent as a turn'); + assert.strictEqual(chatManager.calls.send[0].turn.text, '/model claude-opus'); + assert.strictEqual(session.chatModelOverride, 'claude-opus'); + const result = lastOfType(sent, 'chat_model_result'); + assert.strictEqual(result.applied, 'sent'); + }); + + it('saves for the next session when nothing live can take the change', async function () { + const { processor, session, sent } = build({ surface: 'chat' }); + + await processor.handleMessage('ws-1', { type: 'chat_set_model', model: 'some-custom-model' }); + + assert.strictEqual(session.chatModelOverride, 'some-custom-model', 'still persisted for next launch'); + const result = lastOfType(sent, 'chat_model_result'); + assert.strictEqual(result.applied, 'pending'); + assert.match(result.message, /next time/i); + }); + + it('clears the override rather than treating an empty string as a model', async function () { + const { processor, session, sent } = build({ surface: 'chat' }); + session.chatModelOverride = 'previously-set'; + + await processor.handleMessage('ws-1', { type: 'chat_set_model', model: '' }); + + assert.strictEqual(session.chatModelOverride, undefined); + const result = lastOfType(sent, 'chat_model_result'); + assert.strictEqual(result.applied, 'cleared'); + assert.strictEqual(result.model, null); + }); + + it('lets a saved override outrank the profile default on the next launch', async function () { + const { processor, chatManager, session } = build( + { surface: 'chat' }, + {}, + ); + session.chatModelOverride = 'saved-override'; + processor.deps.resolveRuntimeProfile = () => ({ profileName: 'p', model: 'profile-default' }); + + await processor.startChat('ws-1', 'claude', {}); + + assert.strictEqual(chatManager.calls.start[0].options.model, 'saved-override'); + }); + + it('will not set a model override for a session belonging to another user', async function () { + const { processor, chatManager, session } = build({ surface: 'chat', ownerUserId: 999 }); + + await processor.handleMessage('ws-1', { type: 'chat_set_model', model: 'sneaky-model' }); + + assert.strictEqual(chatManager.calls.setModel.length, 0); + assert.strictEqual(session.chatModelOverride, undefined); + }); + + it('reports pending rather than live when the adapter’s live switch throws', async function () { + const { processor, session, sent } = build( + { surface: 'chat' }, + { setModel: async () => { throw new Error('adapter rejected the switch'); } }, + ); + + await processor.handleMessage('ws-1', { type: 'chat_set_model', model: 'grok-3-fast' }); + + assert.strictEqual(session.chatModelOverride, 'grok-3-fast', 'still saved for next launch despite the failed live attempt'); + const result = lastOfType(sent, 'chat_model_result'); + assert.strictEqual(result.applied, 'pending'); + assert.match(result.message, /adapter rejected the switch|next time/i); + }); + + it('falls back to the profile default on the next launch when no override was ever saved', async function () { + const { processor, chatManager } = build({ surface: 'chat' }, {}); + processor.deps.resolveRuntimeProfile = () => ({ profileName: 'p', model: 'profile-default' }); + + await processor.startChat('ws-1', 'claude', {}); + + assert.strictEqual(chatManager.calls.start[0].options.model, 'profile-default'); + }); + }); + describe('rejoining', function () { it('hands a chat session its conversation, and says which surface it is', async function () { const { processor, sent } = build({ surface: 'chat' }); diff --git a/test/session-store.test.js b/test/session-store.test.js index f6ca90f..8e04608 100644 --- a/test/session-store.test.js +++ b/test/session-store.test.js @@ -140,6 +140,48 @@ describe('SessionStore', function() { assert.strictEqual(loaded.get('manual').chatBypassPermissions, undefined); }); + it('remembers a conversation-scoped model override', async function () { + // The override is per conversation, not a new default: a restart must + // bring back exactly the choice made for this record and nothing for + // any other, and a record that never set one must read as "no override" + // rather than an empty string. + await sessionStore.saveSessions(new Map([ + ['custom', createSessionRecord({ + id: 'custom', + ownerUserId, + surface: 'chat', + chatModelOverride: 'grok-3-fast', + })], + ['default', createSessionRecord({ id: 'default', ownerUserId, surface: 'chat' })], + ])); + sessionStore.database.close(); + sessionStore = new SessionStore({ dataDir: tempDir }); + + const loaded = await sessionStore.loadSessions(); + assert.strictEqual(loaded.get('custom').chatModelOverride, 'grok-3-fast'); + assert.strictEqual(loaded.get('default').chatModelOverride, undefined); + }); + + it('adds the model-override column to a database that predates it', async function () { + await sessionStore.saveSessions(new Map([ + ['old', createSessionRecord({ id: 'old', ownerUserId, surface: 'chat' })], + ])); + sessionStore.database.raw.exec( + 'ALTER TABLE runtime_sessions DROP COLUMN chat_model_override', + ); + sessionStore.database.close(); + + sessionStore = new SessionStore({ dataDir: tempDir }); + const loaded = await sessionStore.loadSessions(); + + assert.strictEqual(loaded.size, 1, 'the upgrade must not cost the user their sessions'); + assert.strictEqual(loaded.get('old').chatModelOverride, undefined); + + sessionStore.database.close(); + sessionStore = new SessionStore({ dataDir: tempDir }); + assert.strictEqual((await sessionStore.loadSessions()).size, 1); + }); + it('adds the approval-mode column to a database that predates it', async function () { // Every install being upgraded has a session table written by an older // build. The column is added on boot rather than by a migration file, so From 2d0a6e364fb9b1b514ca87ee1d792f0297d25768 Mon Sep 17 00:00:00 2001 From: Daniele Viti Date: Sun, 26 Jul 2026 17:21:03 +0200 Subject: [PATCH 3/6] feat(chat): collapsible turn sections, auto-folded on a new turn (#34) (#40) A long conversation used to be one unbroken wall of fully-expanded turns. Each turn's strip now discloses/hides its own body; only the newest turn opens by default, a turn the user has explicitly opened or closed stays that way across new turns, and jumping to a turn via the rail or search force-opens it. Expand-all/collapse-all lives in the turn index header. Co-authored-by: Claude Sonnet 5 --- src/client/chat/turns.ts | 29 +++++++++ src/client/shell/chat/ChatView.tsx | 83 +++++++++++++++++++++--- src/client/shell/chat/MessageList.tsx | 93 +++++++++++++++++---------- src/client/shell/chat/TurnIndex.tsx | 66 ++++++++++++++++--- src/client/shell/chat/TurnStrip.tsx | 56 +++++++++++++++- test/chat-turns.test.js | 16 +++++ test/chat-view.test.js | 31 +++++++++ 7 files changed, 324 insertions(+), 50 deletions(-) diff --git a/src/client/chat/turns.ts b/src/client/chat/turns.ts index cfd0822..96e95ea 100644 --- a/src/client/chat/turns.ts +++ b/src/client/chat/turns.ts @@ -21,6 +21,14 @@ import { compactCount, formatDuration } from './tool-meta.js'; export type TurnStatus = 'done' | 'running' | 'failed' | 'waiting'; +/** One glyph per status, shared by every surface that shows a turn's outcome. */ +export const STATUS_GLYPH: Record = { + done: { icon: 'check', color: 'var(--success)', word: 'done' }, + failed: { icon: 'circle-x', color: 'var(--destructive)', word: 'failed' }, + waiting: { icon: 'shield', color: 'var(--warning)', word: 'waiting for you' }, + running: { icon: 'loader-circle', color: 'var(--info)', spin: true, word: 'running' }, +}; + export interface TurnSummary { /** Id of the message that opened the turn — the user's, where there is one. */ id: string; @@ -159,6 +167,27 @@ export function turnOf(messageId: string, turns: TurnSummary[]): TurnSummary | u return turns.find((turn) => turn.messageIds.includes(messageId)); } +/** + * Whether a turn's contents should be shown, folded history vs. the one in + * progress. + * + * The default is "only the newest turn is open" — an unset entry in + * `overrides` reads as that default rather than as closed, which is what + * makes a brand-new turn open itself and everything before it fold without + * either one needing its own entry written first. An override, once made, + * wins regardless of which turn is newest — that persistence is what stops + * the next turn starting from slamming shut something the user deliberately + * opened. + */ +export function isTurnOpen( + turnId: string, + lastTurnId: string, + overrides: ReadonlyMap, +): boolean { + const override = overrides.get(turnId); + return override === undefined ? turnId === lastTurnId : override; +} + export interface TurnMeta { tools: string; reasoning: string; diff --git a/src/client/shell/chat/ChatView.tsx b/src/client/shell/chat/ChatView.tsx index 71c64d0..77f4afd 100644 --- a/src/client/shell/chat/ChatView.tsx +++ b/src/client/shell/chat/ChatView.tsx @@ -4,7 +4,7 @@ import { uploadAttachment } from '../../chat/attachments-api.js'; import { ChatController, type ChatUnavailable } from '../../chat/controller.js'; import { fetchStatus, findFiles } from '../../chat/workspace-api.js'; import { activityEvents } from '../../chat/activity.js'; -import { groupTurns, turnOf, type TurnSummary } from '../../chat/turns.js'; +import { groupTurns, isTurnOpen, turnOf, type TurnSummary } from '../../chat/turns.js'; import type { ChatPanelId, ChatViewSettings } from '../../chat/view-settings.js'; import { DEFAULT_CHAT_VIEW, @@ -153,6 +153,51 @@ export function ChatView({ ? selectedTurnId : lastTurnId; + // A turn's fold state, once the user has set it explicitly. Unset, a turn + // reads as open exactly when it is the newest one — see isTurnOpen — which + // is what makes a fresh turn open itself and everything before it fold + // without this ever growing an entry for turns nobody has touched. + const [openOverrides, setOpenOverrides] = React.useState>(new Map()); + const openTurnIds = React.useMemo(() => { + const ids = new Set(); + for (const turn of turns) { + if (isTurnOpen(turn.id, lastTurnId, openOverrides)) ids.add(turn.id); + } + return ids; + }, [turns, lastTurnId, openOverrides]); + + const toggleTurn = React.useCallback( + (turnId: string) => { + setOpenOverrides((prev) => { + const next = new Map(prev); + next.set(turnId, !isTurnOpen(turnId, lastTurnId, prev)); + return next; + }); + }, + [lastTurnId], + ); + + // Idempotent, so a jump into an already-open turn does not thrash the map. + const openTurn = React.useCallback( + (turnId: string) => { + setOpenOverrides((prev) => { + if (isTurnOpen(turnId, lastTurnId, prev)) return prev; + const next = new Map(prev); + next.set(turnId, true); + return next; + }); + }, + [lastTurnId], + ); + + const expandAllTurns = React.useCallback(() => { + setOpenOverrides(() => new Map(turns.map((turn) => [turn.id, true]))); + }, [turns]); + + const collapseAllTurns = React.useCallback(() => { + setOpenOverrides(() => new Map(turns.map((turn) => [turn.id, false]))); + }, [turns]); + const [searchOpen, setSearchOpen] = React.useState(false); // Paired with a counter, not held as a bare id: clicking the same work pill // twice has to scroll the rail twice, and a string that did not change would @@ -252,10 +297,17 @@ export function ChatView({ [controller], ); - const selectTurn = React.useCallback((id: string) => { - setSelectedTurnId(id); - list.current?.scrollToTurn(id); - }, []); + const selectTurn = React.useCallback( + (id: string) => { + setSelectedTurnId(id); + // The strip itself is always mounted, open or not, so this can scroll + // immediately — only a jump *inside* a turn's body needs to wait for it + // to open first. See revealMessage. + openTurn(id); + list.current?.scrollToTurn(id); + }, + [openTurn], + ); const jumpLatest = React.useCallback(() => { setSelectedTurnId(null); @@ -288,9 +340,18 @@ export function ChatView({ [openRail, transcript, view.showThinking, view.showToolCalls], ); - const revealMessage = React.useCallback((messageId: string) => { - list.current?.scrollToMessage(messageId); - }, []); + const revealMessage = React.useCallback( + (messageId: string) => { + const turn = turnOf(messageId, turns); + if (turn) openTurn(turn.id); + // A turn that was just opened has not painted yet — its bubbles have no + // layout box to scroll to until the next tick. A turn already open + // scrolls one tick later than it strictly needs to, which nothing on + // screen can tell apart from immediate. + window.setTimeout(() => list.current?.scrollToMessage(messageId), 0); + }, + [turns, openTurn], + ); const copyTurn = React.useCallback( (turnId: string) => { @@ -497,6 +558,8 @@ export function ChatView({ onSelect={selectTurn} onJumpLatest={jumpLatest} collapsed={indexCollapsed} + onExpandAll={expandAllTurns} + onCollapseAll={collapseAllTurns} /> ) : null} @@ -542,6 +605,8 @@ export function ChatView({ transcript={transcript} turns={turns} currentTurnId={currentTurnId} + openTurnIds={openTurnIds} + onToggleTurn={toggleTurn} onLoadMore={loadMore} onShowWork={showWork} onEditTurn={seedDraft} @@ -765,6 +830,8 @@ export function ChatView({ setIndexSheet(false); jumpLatest(); }} + onExpandAll={expandAllTurns} + onCollapseAll={collapseAllTurns} /> + ); +} + function TurnRow({ turn, active, diff --git a/src/client/shell/chat/TurnStrip.tsx b/src/client/shell/chat/TurnStrip.tsx index bbfdff8..fecfbe1 100644 --- a/src/client/shell/chat/TurnStrip.tsx +++ b/src/client/shell/chat/TurnStrip.tsx @@ -1,6 +1,6 @@ import * as React from 'react'; import { Icon } from '../../ui/relay/Icon.js'; -import { formatTurnMeta, turnTime, type TurnSummary } from '../../chat/turns.js'; +import { formatTurnMeta, turnTime, STATUS_GLYPH, type TurnSummary } from '../../chat/turns.js'; /** * The 28px rule that opens a turn, and everything that turn cost. @@ -25,6 +25,11 @@ export interface TurnStripProps { onBranch?: () => void; /** Set while the copy has just landed, so the glyph can acknowledge it. */ copied?: boolean; + /** Whether the turn's body is shown below this strip. */ + open: boolean; + onToggleOpen(): void; + /** Id of the element this strip discloses, for `aria-controls`. */ + bodyId?: string; } export function TurnStrip({ @@ -34,10 +39,14 @@ export function TurnStrip({ onCopy, onBranch, copied = false, + open, + onToggleOpen, + bodyId, }: TurnStripProps): React.JSX.Element { const past = variant === 'past'; const meta = formatTurnMeta(turn); const time = turnTime(turn); + const glyph = STATUS_GLYPH[turn.status] || STATUS_GLYPH.done; return (
+ + ) : null} + {/* Only shown while the body it names is hidden — this is the entire + reason a collapsed strip is still worth reading rather than just a + number. */} + {!open ? ( + <> + + {turn.label} + + ) : null} + ]*aria-expanded="false"|aria-expanded="false"[^>]*aria-label="Expand turn 1"/; + assert.ok(closedToggle.test(html), 'turn 1 must read as collapsed'); + assert.ok(/id="turn-body-m1"[^>]*hidden=""/.test(html), 'turn 1 body must be hidden'); + assert.ok(html.includes('run the tests'), 'a collapsed turn must still show what was asked'); + + // The newest turn opens by default and its body is not hidden. + const openToggle = /aria-label="Collapse turn 2"[^>]*aria-expanded="true"|aria-expanded="true"[^>]*aria-label="Collapse turn 2"/; + assert.ok(openToggle.test(html), 'turn 2 must read as open'); + assert.ok(!/id="turn-body-m3"[^>]*hidden=""/.test(html), 'the current turn must not be hidden'); + assert.ok(html.includes('deployed'), 'the open turn must show its content'); + + // The strip stays mounted — and its actions reachable — for a folded turn. + assert.ok(/aria-label="Copy this turn as Markdown"/.test(html), 'copy control must survive folding'); + }); + it('pins a pending approval outside the scroller and announces it', function () { const controller = controllerWith({ state: 'awaiting_permission', From fdd98870a9a2d18d91647ac6211a3cca59da410e Mon Sep 17 00:00:00 2001 From: Daniele Viti Date: Sun, 26 Jul 2026 17:21:37 +0200 Subject: [PATCH 4/6] fix(chat): show Claude's slash commands from the moment a session opens (#41) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Claude's CLI does not report `slash_commands` until it has processed a first turn's `system/init`, which only arrives after the first message is written to its stdin. The chat session spawns the process as soon as a chat is opened, well before any message is sent, so the command menu and its composer button stayed empty — looking broken — until a throwaway message unlocked them. The adapter now advertises a static baseline built from this app's own table of Claude's built-in commands as soon as it starts, so the menu and button are populated immediately. The real `init` list — including any project or plugin commands — still arrives with the first turn and replaces this baseline outright, exactly as before. ACP runtimes (kimi, omp) already report commands during their handshake, before any message is sent, so they were never affected. codex, grok and pi have no command support at all; the composer already hides the button entirely rather than showing an empty menu, which is the honest behavior for a runtime that offers none. Closes #30 Co-authored-by: Claude Sonnet 5 --- src/server/chat/adapters/claude.ts | 7 ++++++- src/shared/slash-commands.ts | 15 +++++++++++++++ test/chat-claude.test.js | 20 +++++++++++++++++++- 3 files changed, 40 insertions(+), 2 deletions(-) diff --git a/src/server/chat/adapters/claude.ts b/src/server/chat/adapters/claude.ts index 4eb482d..828866a 100644 --- a/src/server/chat/adapters/claude.ts +++ b/src/server/chat/adapters/claude.ts @@ -1,7 +1,7 @@ import { randomUUID } from 'node:crypto'; import { readFileSync } from 'node:fs'; import { BaseChatAdapter } from '../adapter.js'; -import { describeSlashCommand } from '../../../shared/slash-commands.js'; +import { defaultSlashCommands, describeSlashCommand } from '../../../shared/slash-commands.js'; import { ChatAttachment, ChatBlock, @@ -48,6 +48,11 @@ export class ClaudeChatAdapter extends BaseChatAdapter { usage: true, cost: true, plan: false, + // The real list only arrives with the first turn's `init` (see + // handleInit below); until then this is what makes the command menu and + // its composer button available from the moment the session opens, + // rather than staying empty until a message has already been sent. + commands: defaultSlashCommands(), }; /** Session id we generated for a fresh launch, before init echoes it back. */ diff --git a/src/shared/slash-commands.ts b/src/shared/slash-commands.ts index 6cb2ac5..550dd6c 100644 --- a/src/shared/slash-commands.ts +++ b/src/shared/slash-commands.ts @@ -48,6 +48,21 @@ export function describeSlashCommand(name: string): string | undefined { return BUILT_IN[String(name || '').trim().toLowerCase().replace(/^\//, '')]; } +/** + * The built-ins, as a menu the picker can show before a runtime has said + * anything about itself. + * + * Claude does not report `slash_commands` until it has processed a first + * turn (see claude.ts), which used to mean a brand-new session showed no + * command menu at all until one message had already been sent. This table is + * this app's own knowledge of what a fresh Claude session accepts, not + * something Claude told it — a project or plugin command still only appears + * once the real `init` arrives and replaces this list outright. + */ +export function defaultSlashCommands(): { name: string; description: string }[] { + return Object.entries(BUILT_IN).map(([name, description]) => ({ name, description })); +} + /** * Commands that empty the conversation. * diff --git a/test/chat-claude.test.js b/test/chat-claude.test.js index 28543fc..7e2e70f 100644 --- a/test/chat-claude.test.js +++ b/test/chat-claude.test.js @@ -295,7 +295,8 @@ describe('claude chat adapter', function () { describe('capabilities', function () { it('advertises streaming/thinking/toolCalls/resume/interrupt/attachments but not diffs or permissions', function () { const { adapter } = makeAdapter(); - assert.deepStrictEqual(adapter.capabilities, { + const { commands, ...rest } = adapter.capabilities; + assert.deepStrictEqual(rest, { streaming: true, thinking: true, toolCalls: true, @@ -310,5 +311,22 @@ describe('claude chat adapter', function () { plan: false, }); }); + + it('advertises a baseline command list before the runtime has said anything', function () { + const { adapter } = makeAdapter(); + assert.ok(adapter.capabilities.commands.length > 0); + assert.ok(adapter.capabilities.commands.some((c) => c.name === 'resume')); + assert.ok(adapter.capabilities.commands.every((c) => c.description)); + }); + }); + + describe('before the first turn', function () { + it('a freshly constructed adapter already has a non-empty command list, so the menu never starts empty', function () { + const { adapter } = makeAdapter(); + // No handleMessage call at all here: this is the state the session sees + // the instant the process is spawned, before Claude has said a word. + assert.ok(Array.isArray(adapter.capabilities.commands)); + assert.ok(adapter.capabilities.commands.length > 0); + }); }); }); From 70d26cf40e60c25ef776c1865b91ebdba1e78534 Mon Sep 17 00:00:00 2001 From: Daniele Viti Date: Sun, 26 Jul 2026 17:22:16 +0200 Subject: [PATCH 5/6] fix(chat): make /clear and /new actually reset the conversation (#47) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit `/clear` and `/new` forwarded their text to the still-running agent process like any other message, then only added a cosmetic marker that hid prior messages in the UI. The next message sent it right back into the same process, so the agent kept whatever context it already had — the reset never really happened, only its display did. Route clearing commands through the same restart path a manual "start fresh" relaunch already uses: stop the live adapter and start a new one with no resume id, so the next turn talks to a process that was never handed the prior conversation. This also fixes the id persisted for later reconnects, so a rejoin can't resurrect it either. Fixes #43 Co-authored-by: Claude Sonnet 5 --- src/server/chat/session.ts | 45 +++++++--- test/chat-clear-reset.test.js | 161 ++++++++++++++++++++++++++++++++++ 2 files changed, 195 insertions(+), 11 deletions(-) create mode 100644 test/chat-clear-reset.test.js diff --git a/src/server/chat/session.ts b/src/server/chat/session.ts index bf826b9..64f0c07 100644 --- a/src/server/chat/session.ts +++ b/src/server/chat/session.ts @@ -150,6 +150,8 @@ export class ChatSession { private nativeSessionId: string | null = null; private startedAt = 0; private cwd = ''; + /** The options this session was last launched with, kept for `/clear` and `/new`. */ + private lastStartOptions: ChatSessionStartOptions | null = null; /** * True between resuming a conversation and the first thing the user says in it. @@ -224,6 +226,7 @@ export class ChatSession { throw new Error(`${options.runtime} has no chat adapter`); } + this.lastStartOptions = options; this.runtime = options.runtime; this.cwd = options.workingDir; this.bypass = Boolean(options.bypassPermissions); @@ -544,20 +547,40 @@ export class ChatSession { }); } this.ingest({ t: 'msg_end', msgId: messageId }); - this.setState('thinking'); - - await this.adapter.send(turn); - // After the runtime has accepted it, not before: if the send throws, the - // conversation is still there and clearing the window would have thrown - // away a transcript for a command that never ran. - // - // The marker goes in the durable log like everything else, so a browser - // that rejoins later replays the clear rather than being handed back the - // messages this was supposed to remove. + // `/clear` and `/new` promise a conversation the agent has never seen + // before, not one that only looks that way. Forwarding the text to the + // still-alive process would just add "/clear" to its own context — the + // process would still remember everything said before it. A real reset + // means a new process with no resume id, the same thing a manual "start + // fresh" relaunch already does. if (isClearingCommand(turn.text)) { - this.ingest({ t: 'marker', kind: 'cleared' }); + await this.restart(); + return; } + + this.setState('thinking'); + await this.adapter.send(turn); + } + + /** + * Stop the running adapter and start a brand new one with no resume id, in + * place, without tearing down the `ChatSession` itself. + * + * The marker that tells a rejoining browser to stop paging back past this + * point is emitted by `start()` itself (`startFresh`), so this only has to + * get a fresh process running — the same path a manual "start fresh" + * relaunch takes, just triggered from inside a live conversation instead of + * from the recovery banner. + */ + private async restart(): Promise { + const options = this.lastStartOptions; + if (!options) return; + await this.stop(); + // Stale until the new process's own `init` event reports its id — cleared + // up front so nothing reads the old conversation's id in the meantime. + this.nativeSessionId = null; + await this.start({ ...options, resumeSessionId: undefined, startFresh: true }); } async interrupt(): Promise { diff --git a/test/chat-clear-reset.test.js b/test/chat-clear-reset.test.js new file mode 100644 index 0000000..bf2e5d7 --- /dev/null +++ b/test/chat-clear-reset.test.js @@ -0,0 +1,161 @@ +const assert = require('assert'); +const fs = require('fs'); +const os = require('os'); +const path = require('path'); + +const { ChatSession } = require('../dist/server/chat/session.js'); + +// Issue #43: `/clear` and `/new` looked like they reset the conversation — +// the transcript went blank — but the text was sent to the still-running +// agent process like any other message, so the very next turn brought the +// old context right back. A real reset has to stop the process that +// remembers and start a new one; these pin that down at the point the bug +// lived, `ChatSession.send`, without spawning a real CLI. + +function memoryStore() { + const events = []; + return { + events, + append(_ref, batch) { + events.push(...batch); + }, + async stat() { + return { firstSeq: 1, cursor: events.length }; + }, + async read() { + return { events: [], firstSeq: 1, from: 1, cursor: events.length }; + }, + }; +} + +function fakeAdapter(sendCalls) { + return { + alive: true, + async send(turn) { + sendCalls.push(turn); + }, + async interrupt() {}, + respondPermission() {}, + async stop() {}, + }; +} + +function session() { + const store = memoryStore(); + const s = new ChatSession( + { id: 's1', ownerUserId: 7 }, + { + store, + socketDir: fs.mkdtempSync(path.join(os.tmpdir(), 'clear-reset-')), + hookScript: path.join(__dirname, '..', 'does-not-exist.js'), + broadcast: () => {}, + resolveCommand: () => 'claude', + }, + ); + const sendCalls = []; + s.adapter = fakeAdapter(sendCalls); + s.state = 'idle'; + // Stands in for whatever `start()` was last called with — set directly + // because these tests stub the adapter rather than spawning a real one. + s.lastStartOptions = { runtime: 'claude', workingDir: '/tmp' }; + return { s, store, sendCalls }; +} + +describe('/clear and /new actually reset the conversation', function () { + it('never forwards the clearing command to the live adapter', async function () { + const { s, sendCalls } = session(); + s.start = async () => { + s.adapter = fakeAdapter([]); + s.state = 'idle'; + }; + + await s.send({ text: '/clear' }); + + assert.deepStrictEqual( + sendCalls, + [], + 'the process that already holds the old context must never see this turn', + ); + }); + + it('stops the running adapter and starts a fresh one with no resume id', async function () { + const { s } = session(); + const startCalls = []; + const stopCalls = []; + const originalAdapter = s.adapter; + originalAdapter.stop = async () => stopCalls.push('stopped'); + s.start = async (options) => { + startCalls.push(options); + s.adapter = fakeAdapter([]); + s.state = 'idle'; + }; + + await s.send({ text: '/new' }); + + assert.strictEqual(stopCalls.length, 1, 'the old process must be torn down, not reused'); + assert.strictEqual(startCalls.length, 1); + assert.strictEqual(startCalls[0].runtime, 'claude'); + assert.strictEqual(startCalls[0].resumeSessionId, undefined, 'a resume would hand the new process the old context back'); + assert.strictEqual(startCalls[0].startFresh, true); + }); + + it('is case-insensitive and ignores arguments, matching isClearingCommand', async function () { + const { s, sendCalls } = session(); + let started = 0; + s.start = async () => { + started += 1; + s.adapter = fakeAdapter([]); + s.state = 'idle'; + }; + + await s.send({ text: '/CLEAR now please' }); + + assert.strictEqual(started, 1); + assert.deepStrictEqual(sendCalls, []); + }); + + it('still records the user turn in the transcript before resetting', async function () { + const { s, store } = session(); + s.start = async () => { + s.adapter = fakeAdapter([]); + s.state = 'idle'; + }; + + await s.send({ text: '/clear' }); + + const texts = store.events + .filter((e) => e.t === 'block_start') + .map((e) => e.block.text); + assert.deepStrictEqual(texts, ['/clear']); + }); + + it('clears the stale native session id before the new process reports its own', async function () { + const { s } = session(); + s.ingest({ t: 'session', nativeSessionId: 'native-old', capabilities: {} }); + assert.strictEqual(s.nativeId, 'native-old'); + + s.start = async () => { + // The new process has not announced itself yet. + s.adapter = fakeAdapter([]); + s.state = 'idle'; + }; + + await s.send({ text: '/new' }); + + assert.strictEqual(s.nativeId, null, 'must not still point at the pre-clear conversation'); + }); + + it('leaves an ordinary message alone', async function () { + const { s, sendCalls } = session(); + let started = 0; + s.start = async () => { + started += 1; + }; + + await s.send({ text: 'clear the table, please' }); + + assert.strictEqual(started, 0, 'not a slash command, so no restart'); + assert.strictEqual(sendCalls.length, 1); + assert.strictEqual(sendCalls[0].text, 'clear the table, please'); + }); +}); From dc6ddf085b44acb48f7c7b5a532d36ec0699b76b Mon Sep 17 00:00:00 2001 From: Daniele Viti Date: Sun, 26 Jul 2026 18:25:08 +0200 Subject: [PATCH 6/6] fix(chat): show the answer as it streams, and keep a model override across /clear (#49) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Five defects introduced by the four PRs already merged onto the 5.1.2 branch. Each one's own tests passed; all but one lived where two of them met. The chat stopped rendering. MessageList looked messages up in a map memoised on the messages array, but the transcript appends to that array in place, so its identity never changes: the map was built once at mount and every message after it resolved to nothing. The strips above kept ticking while the bodies stayed empty, and reloading "fixed" it because reloading replaces the array. Keyed on the transcript version instead, which is the cadence the neighbouring memo already used — the list still does not re-render per token. A model override no longer survives /clear. The restart replays the options the session was launched with, and the model is the one thing in them that can change while the session is alive, so the conversation quietly went back to the model it opened with after the browser had been told the switch applied. The choice now reaches those options too, by either door: the picker, and a /model typed straight into the composer, which was forwarded untouched and hit the same reversion. A collapsed turn's title escaped its bar. It set neither a size nor nowrap while every sibling in that fixed-height row sets both, so it inherited the page default and wrapped — and without nowrap the ellipsis was inert. An override could not be undone. The server had a "cleared" path, message and all, that nothing in the UI could reach: the text field refuses to submit empty and every listed model carries a name. A typo therefore stayed in force for every later launch of that conversation. Model names are now capped and stripped of control characters, which would otherwise ride into the best-effort /model turn as extra lines. The browser checks gated nothing: npm test runs only the unit suite, and the runner exited 0 when Chrome was absent. With the rendering bug restored, npm test passes 1357/1357 while the browser check fails. They now run in CI and refuse to skip there. Their fixture also grew a second turn, without which no turn is ever collapsed and the broken title was never rendered at all. Every fix has a test confirmed to fail against the unfixed code. Co-authored-by: Claude Opus 5 --- .github/workflows/ci.yml | 7 ++ CHANGELOG.md | 28 +++++- src/client/shell/chat/Composer.tsx | 28 ++++++ src/client/shell/chat/MessageList.tsx | 9 +- src/client/shell/chat/TurnStrip.tsx | 21 ++++- src/server/chat/manager.ts | 5 ++ src/server/chat/session.ts | 19 +++++ src/server/websocket/messages.ts | 56 +++++++++++- test/browser/checks.ts | 117 +++++++++++++++++++++++++- test/browser/run.js | 8 ++ test/chat-clear-reset.test.js | 47 +++++++++++ test/chat-wiring.test.js | 77 ++++++++++++++++- 12 files changed, 413 insertions(+), 9 deletions(-) diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index a328c26..cf99140 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -42,6 +42,13 @@ jobs: - name: Test run: npm test + # Whole classes of defect here are invisible to the unit suite because + # only a layout engine can answer them: whether a bar wraps, whether an + # answer actually reaches the screen while it streams. The runner image + # ships Chrome, and the runner refuses to skip when CI is set. + - name: Browser checks + run: npm run test:browser + install: name: Verify the documented install runs-on: ubuntu-latest diff --git a/CHANGELOG.md b/CHANGELOG.md index a59c676..b475705 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -2,8 +2,32 @@ ## [5.1.2] - 2026-07-26 -### Notes -- Version bump only; no functional changes. +### Added +- **A conversation can run a different model from the one the profile picks.** + The model shown beside the composer is now something to click: choose one the + runtime has listed, or type a name it has not, and that choice belongs to that + one conversation — never written back as a profile or personal default, and + never inherited by the next conversation. Where the runtime can change model + without restarting, it changes immediately; where it cannot, the choice is + saved and used the next time that conversation starts, and the reply says + which of the two happened rather than claiming success either way. The same + menu offers the way back to the runtime's own default. +- **Turns fold away.** Each turn's header can be collapsed to hide everything + under it, and a turn folds on its own once the next one begins, so a long + session reads as a list of what was asked rather than an endless scroll. The + turn index can open or close them all at once, and jumping to a turn from the + index opens it. A folded turn still says what it was about, and its copy and + branch actions keep working while it is shut. + +### Fixed +- **The runtime's own slash commands are there from the moment a conversation + opens.** They used to appear only after the first message had been sent, + because nothing knew what the agent supported until the agent had spoken. +- **`/clear` and `/new` really do start over.** The transcript went blank, but + the text was handed to the agent like any other message, so the process kept + the whole conversation in mind and the next answer brought it all back. They + now restart the agent on a genuinely empty conversation, which is what they + had appeared to do. ## [5.1.1] - 2026-07-26 diff --git a/src/client/shell/chat/Composer.tsx b/src/client/shell/chat/Composer.tsx index 999b8db..30f4c73 100644 --- a/src/client/shell/chat/Composer.tsx +++ b/src/client/shell/chat/Composer.tsx @@ -1231,6 +1231,34 @@ function ModelChip({ This runtime hasn't listed models — type one above.
) : null} + + {/* The way back. Picking a model is one click; without this, undoing + that choice was impossible — the text field refuses to submit + empty and every entry above carries a name — so a conversation + could be moved off its profile default but never returned to it, + and a typo stayed in force for every later launch. */} + ) : null} diff --git a/src/client/shell/chat/MessageList.tsx b/src/client/shell/chat/MessageList.tsx index b939517..b3367e7 100644 --- a/src/client/shell/chat/MessageList.tsx +++ b/src/client/shell/chat/MessageList.tsx @@ -248,11 +248,18 @@ export const MessageList = React.forwardRef // Looked up by id rather than iterated in transcript order: a turn's own // messages are already listed on it, and rendering turn-by-turn (below) is // what lets a collapsed turn hide its whole body as one unit. + // + // Keyed on `version`, not on `messages`: the transcript appends to its + // array in place, so its identity never changes and a `[messages]` map + // would be built once at mount and then never see another message. Every + // later id would miss and render nothing — a turn whose body stays empty + // while its strip keeps counting. const messageById = React.useMemo(() => { const map = new Map(); for (const message of messages) map.set(message.id, message); return map; - }, [messages]); + // eslint-disable-next-line react-hooks/exhaustive-deps + }, [messages, version]); const lastTurnId = turns.length ? turns[turns.length - 1].id : undefined; diff --git a/src/client/shell/chat/TurnStrip.tsx b/src/client/shell/chat/TurnStrip.tsx index fecfbe1..1fa782a 100644 --- a/src/client/shell/chat/TurnStrip.tsx +++ b/src/client/shell/chat/TurnStrip.tsx @@ -147,7 +147,7 @@ export function TurnStrip({ > - {turn.label} + {turn.label} ) : null} @@ -188,8 +188,21 @@ export function TurnStrip({ ); } -/** A meta item that gives up its own width before the row wraps. */ -function Shrinkable({ children }: { children: React.ReactNode }): React.JSX.Element { +/** + * Text that gives up its width before anything else in the bar does. + * + * `nowrap` is what makes the ellipsis work at all: without it the text wraps + * instead of being cut, and since the bar is a fixed height the second line + * simply leaves it. The other users of this sit inside a span that already + * sets both that and a size; the turn label does not, so it says so itself. + */ +function Shrinkable({ + children, + fontSize, +}: { + children: React.ReactNode; + fontSize?: string | number; +}): React.JSX.Element { return ( {children} diff --git a/src/server/chat/manager.ts b/src/server/chat/manager.ts index 0ab04a2..adfa038 100644 --- a/src/server/chat/manager.ts +++ b/src/server/chat/manager.ts @@ -214,6 +214,11 @@ export class ChatSessionManager { return session.setModel(model); } + /** Carry a new model into the options an in-place `/clear` restart replays. */ + rememberModel(sessionId: string, model: string | undefined): void { + this.sessions.get(sessionId)?.rememberModel(model); + } + /** Drop a turn that was typed ahead and has not run yet. */ cancelQueued(sessionId: string, queuedId: string): boolean { return this.sessions.get(sessionId)?.cancelQueued(queuedId) ?? false; diff --git a/src/server/chat/session.ts b/src/server/chat/session.ts index 64f0c07..1037451 100644 --- a/src/server/chat/session.ts +++ b/src/server/chat/session.ts @@ -681,6 +681,25 @@ export class ChatSession { return true; } + /** + * Record the model an in-place restart must launch with. + * + * `restart()` replays the options this session was last started with, and + * those were resolved once, at launch. Everything in them is fixed for the + * life of the conversation except the model, which `chat_set_model` can + * change underneath them — so without this a `/clear` would quietly + * reinstate the model the conversation happened to open with, discarding a + * choice the browser has already been told was applied. + * + * Takes the effective model rather than the override, so clearing an + * override lands on the profile default here exactly as it would on a fresh + * launch. + */ + rememberModel(model: string | undefined): void { + if (!this.lastStartOptions) return; + this.lastStartOptions = { ...this.lastStartOptions, model }; + } + snapshot(): Promise { return this.deps.store.snapshot(this.ref).then((snapshot) => ({ ...snapshot, diff --git a/src/server/websocket/messages.ts b/src/server/websocket/messages.ts index 8892e03..94624e9 100644 --- a/src/server/websocket/messages.ts +++ b/src/server/websocket/messages.ts @@ -15,6 +15,29 @@ import { sendToWebSocket, broadcastChat, broadcastToSession } from './handler.js import { chatUnavailableReason, isChatRuntime } from '../../shared/chat-runtimes.js'; import { ChatNotRunningError } from '../chat/session.js'; +/** + * The longest model name worth storing. Real ones are far shorter; this only + * has to stop an unbounded string from being persisted and then handed to a + * spawn on every future launch of the conversation. + */ +const MAX_MODEL_NAME = 200; + +/** + * Tidy a typed model name into something safe to keep. + * + * Names are never validated against a list — a runtime knows its own models + * and new ones appear without us — so this only removes what can't belong in + * one: control characters, which would otherwise ride into the best-effort + * `/model ` turn as extra lines, and unbounded length. + */ +function normaliseModelName(raw: string): string | undefined { + const stripped = raw.replace(/[\u0000-\u001f\u007f-\u009f\u2028\u2029]+/g, ' ').trim(); + // Sliced by code point, not by code unit: cutting mid-character would store + // half a surrogate pair. + const cleaned = [...stripped].slice(0, MAX_MODEL_NAME).join('').trim(); + return cleaned || undefined; +} + export interface MessageProcessorDeps { dev: boolean; claudeSessions: Map; @@ -90,6 +113,8 @@ export interface ChatManagerLike { interrupt(sessionId: string): Promise; /** Switch a live session's model. False when nothing is running, or the adapter cannot. */ setModel(sessionId: string, model: string): Promise; + /** Carry a new model into the options an in-place `/clear` restart replays. */ + rememberModel(sessionId: string, model: string | undefined): void; cancelQueued(sessionId: string, queuedId: string): boolean; respondPermission(sessionId: string, requestId: string, optionId: string): boolean; stop(sessionId: string): Promise; @@ -1121,6 +1146,23 @@ export class MessageProcessor { const text = typeof data.text === 'string' ? data.text : ''; if (!text.trim() && !(data.attachments || []).length) return; + // The runtime's own `/model` reaches the same decision by the other door, + // so it has to leave the same trace. Without this the command is forwarded + // untouched, the conversation really does change model, and then the next + // `/clear` restarts it on the model it opened with — the same silent + // reversion the model picker was fixed for. Recorded, then forwarded + // unchanged: the runtime still runs its own command, and whether it + // accepted the name is still its answer to give, not ours. + const typedModel = /^\/model[ \t]+(\S.*)$/.exec(text.trim()); + if (typedModel) { + const model = normaliseModelName(typedModel[1]); + if (model) { + session.chatModelOverride = model; + await this.deps.saveSessionsToDisk(); + manager.rememberModel(session.id, model); + } + } + try { await manager.send(session.id, { text, @@ -1180,10 +1222,22 @@ export class MessageProcessor { if (!session) return; const raw = typeof data.model === 'string' ? data.model.trim() : ''; - const model = raw || undefined; + const model = raw ? normaliseModelName(raw) : undefined; session.chatModelOverride = model; await this.deps.saveSessionsToDisk(); + // A live session keeps the options it was launched with so that `/clear` + // can restart the process in place. The model is the one thing in there + // this handler can change, so it has to be carried across too — otherwise + // the next `/clear` reinstates the model the conversation opened with, + // after the browser has already been told the switch was applied. Resolved + // the way a launch resolves it, so clearing lands on the profile default. + const profile = this.deps.resolveRuntimeProfile( + session.agent as AgentKind, + session.workingDir, + ); + this.deps.chatManager?.rememberModel(session.id, model || profile?.model); + if (!model) { sendToWebSocket(wsInfo.ws, { type: 'chat_model_result', diff --git a/test/browser/checks.ts b/test/browser/checks.ts index d110f5a..3160a6a 100644 --- a/test/browser/checks.ts +++ b/test/browser/checks.ts @@ -225,6 +225,7 @@ async function run(): Promise { await checkATallDialogStaysOnScreen(); await checkTheComposerShrinksWithTheWorkspaceRail(); await checkTheFixedBarsNeverWrap(); + await checkALiveAnswerAppearsAsItStreams(); const pre = document.createElement('pre'); pre.id = 'results'; @@ -517,7 +518,7 @@ async function checkTheFixedBarsNeverWrap(): Promise { messages: [ { id: 'u1', seq: 1, turnId: 't1', role: 'user', ts: Date.now(), - blocks: [{ kind: 'text', text: 'a question long enough to need the whole column for itself' }], + blocks: [{ kind: 'text', text: 'please refactor the authentication middleware so that it validates the bearer token against the new issuer, then update every call site that still assumes the old claim shape, and make sure the integration tests cover the expired-token path as well as the malformed-header path' }], }, { id: 'a1', seq: 2, turnId: 't1', role: 'assistant', ts: Date.now(), @@ -531,6 +532,14 @@ async function checkTheFixedBarsNeverWrap(): Promise { ], usage: { inputTokens: 123456, outputTokens: 98765, costUsd: 1234.5678 }, }, + { + id: 'u2', seq: 3, turnId: 't2', role: 'user', ts: Date.now(), + blocks: [{ kind: 'text', text: 'and now a second turn, so the first one folds' }], + }, + { + id: 'a2', seq: 4, turnId: 't2', role: 'assistant', ts: Date.now(), + blocks: [{ kind: 'text', text: 'done' }], + }, ], pendingPermissions: [], queued: [], firstSeq: 1, replayFrom: 1, cursor: 2, live: true, bypassPermissions: true, @@ -611,6 +620,112 @@ async function checkTheFixedBarsNeverWrap(): Promise { host.remove(); } +/** + * The chat has to show the answer while it is being written. + * + * Every layer under this was already covered — the reducer applies the events, + * the transcript bumps its version, the strips re-render — and the chat still + * went blank in 5.1.2, because the list looked its messages up in a map that + * was built once and never rebuilt. Nothing short of mounting the real view + * and streaming into it catches that, so this asserts the only thing that + * actually matters: the words reach the screen. + */ +async function checkALiveAnswerAppearsAsItStreams(): Promise { + const host = document.createElement('div'); + host.style.cssText = 'width:1280px;height:760px;position:absolute;top:0;left:0;display:flex'; + document.body.appendChild(host); + + const controller = new ChatController('live-check', { send: () => {} }); + controller.handle({ + type: 'chat_snapshot', + sessionId: 'live-check', + snapshot: { + sessionId: 'live-check', runtime: 'claude', state: 'idle', + capabilities: { + streaming: true, thinking: true, toolCalls: true, diffs: true, permissions: true, + interrupt: true, resume: true, fork: false, attachments: true, usage: true, + cost: true, plan: true, commands: [], + }, + messages: [ + { + id: 'u1', seq: 1, turnId: 't1', role: 'user', ts: Date.now(), + blocks: [{ kind: 'text', text: 'the question already in the transcript' }], + }, + ], + pendingPermissions: [], queued: [], firstSeq: 1, replayFrom: 1, cursor: 1, + live: true, bypassPermissions: false, + }, + } as never); + + const root = createRoot(host); + root.render( + React.createElement(ChatView, { + controller, + runtime: 'claude', + runtimeLabel: 'Claude Code', + workingDir: '/home/dev/project', + branch: 'main', + view: { ...DEFAULT_CHAT_VIEW }, + onViewChange: () => {}, + } as never), + ); + await wait(400); + + // Exactly what arrives from the socket while an answer is being written: + // a new turn opens, then text lands in it a delta at a time. + const event = (payload: unknown): void => + controller.handle({ type: 'chat_event', sessionId: 'live-check', event: payload } as never); + + // Read from the rendered messages only. The turn strip repeats the opening + // question as its label, so asserting against the whole subtree would pass + // on the strip alone while the body it names is empty — which is the exact + // bug being guarded against. + const bodyText = (): string => + Array.from(host.querySelectorAll('[data-message-id]')) + .map((node) => node.textContent || '') + .join(' '); + + event({ t: 'msg_start', id: 'u2', seq: 2, turnId: 't2', role: 'user', ts: Date.now() }); + event({ t: 'block_start', msgId: 'u2', index: 0, block: { kind: 'text', text: '' } }); + event({ t: 'block_delta', msgId: 'u2', index: 0, text: 'a question typed while the app is open' }); + event({ t: 'msg_end', msgId: 'u2' }); + await wait(250); + + check( + 'a message sent while the app is open shows up', + bodyText().includes('a question typed while the app is open'), + 'the user\'s own turn never rendered', + ); + + event({ t: 'msg_start', id: 'a2', seq: 3, turnId: 't2', role: 'assistant', ts: Date.now() }); + event({ t: 'block_start', msgId: 'a2', index: 0, block: { kind: 'text', text: '' } }); + await wait(120); + event({ t: 'block_delta', msgId: 'a2', index: 0, text: 'the answer as it ' }); + await wait(120); + event({ t: 'block_delta', msgId: 'a2', index: 0, text: 'is being written' }); + await wait(250); + + const text = bodyText(); + check( + 'the answer is on screen before it has finished', + text.includes('the answer as it is being written'), + text.includes('the answer as it') + ? 'the first delta rendered but later ones did not' + : 'the streaming turn body stayed empty', + ); + + // And the transcript really did receive it — so a failure above is the view + // not rendering, not the events being wrong. + check( + 'the transcript itself holds the streamed message', + controller.transcript.messages.some((m: { id: string }) => m.id === 'a2'), + `ids=${controller.transcript.messages.map((m: { id: string }) => m.id).join(',')}`, + ); + + root.unmount(); + host.remove(); +} + run().catch((error: unknown) => { const pre = document.createElement('pre'); pre.id = 'results'; diff --git a/test/browser/run.js b/test/browser/run.js index fdc050c..0c8172c 100755 --- a/test/browser/run.js +++ b/test/browser/run.js @@ -11,6 +11,14 @@ const chrome = ['google-chrome', 'google-chrome-stable', 'chromium', 'chromium-b }); if (!chrome) { + // Skipping is a convenience for a machine that happens to have no browser, + // never for CI: these checks are the only thing covering defects that a + // layout engine has to be running to see, and a silent skip there would + // report a green build for a suite that never ran. + if (process.env.CI) { + console.error('No Chrome/Chromium on PATH. CI must run the browser checks, not skip them.'); + process.exit(1); + } console.log('Skipping browser checks: no Chrome/Chromium on PATH.'); process.exit(0); } diff --git a/test/chat-clear-reset.test.js b/test/chat-clear-reset.test.js index bf2e5d7..3180aa6 100644 --- a/test/chat-clear-reset.test.js +++ b/test/chat-clear-reset.test.js @@ -158,4 +158,51 @@ describe('/clear and /new actually reset the conversation', function () { assert.strictEqual(sendCalls.length, 1); assert.strictEqual(sendCalls[0].text, 'clear the table, please'); }); + + // The restart replays the options the session was launched with, and the + // model is the one thing in them that can change while the session is alive. + // Both features shipped in 5.1.2 and each one's own tests passed: the + // override was lost only where they met. + it('restarts with the model the conversation was moved to, not the one it opened with', async function () { + const { s } = session(); + s.lastStartOptions = { runtime: 'claude', workingDir: '/tmp', model: 'opened-with' }; + + // What `chat_set_model` does once the choice is persisted, whether or not + // the live adapter could take it. + s.rememberModel('moved-to'); + + let startedWith = null; + s.start = async (options) => { + startedWith = options; + s.adapter = fakeAdapter([]); + s.state = 'idle'; + }; + + await s.send({ text: '/clear' }); + + assert.strictEqual( + startedWith.model, + 'moved-to', + 'the fresh process must run the model the browser was told was applied', + ); + }); + + it('restarts on the profile default once the override is cleared', async function () { + const { s } = session(); + s.lastStartOptions = { runtime: 'claude', workingDir: '/tmp', model: 'an-override' }; + + // Clearing resolves to the profile default, so that is what arrives here. + s.rememberModel('profile-default'); + + let startedWith = null; + s.start = async (options) => { + startedWith = options; + s.adapter = fakeAdapter([]); + s.state = 'idle'; + }; + + await s.send({ text: '/clear' }); + + assert.strictEqual(startedWith.model, 'profile-default'); + }); }); diff --git a/test/chat-wiring.test.js b/test/chat-wiring.test.js index ad92737..9cd290c 100644 --- a/test/chat-wiring.test.js +++ b/test/chat-wiring.test.js @@ -38,7 +38,10 @@ function createSessionRecord(params = {}) { /** A chat manager that records what it was asked to do. */ function createChatManager(overrides = {}) { - const calls = { start: [], send: [], interrupt: [], permission: [], page: [], cancelQueued: [], setModel: [] }; + const calls = { + start: [], send: [], interrupt: [], permission: [], page: [], cancelQueued: [], + setModel: [], rememberModel: [], + }; return { calls, has: () => false, @@ -46,6 +49,9 @@ function createChatManager(overrides = {}) { calls.setModel.push({ sessionId, model }); return false; }, + rememberModel(sessionId, model) { + calls.rememberModel.push({ sessionId, model }); + }, async start(record, options) { calls.start.push({ record, options }); return { @@ -498,6 +504,75 @@ describe('chat wiring', function () { assert.strictEqual(result.model, null); }); + // A live session holds the options `/clear` will relaunch from, and the + // model is the only one this handler can change. Without carrying it over, + // the next `/clear` reinstated the model the conversation opened with. + it('carries the new model into the options a /clear restart replays', async function () { + const { processor, chatManager } = build({ surface: 'chat' }); + + await processor.handleMessage('ws-1', { type: 'chat_set_model', model: 'some-custom-model' }); + + assert.deepStrictEqual(chatManager.calls.rememberModel[0], { + sessionId: 'session-1', + model: 'some-custom-model', + }); + }); + + it('carries the profile default across when the override is cleared', async function () { + const { processor, chatManager } = build({ surface: 'chat' }); + processor.deps.resolveRuntimeProfile = () => ({ profileName: 'p', model: 'profile-default' }); + + await processor.handleMessage('ws-1', { type: 'chat_set_model', model: '' }); + + // The value itself, not just that something was carried: clearing has to + // land where a fresh launch would land, which is the profile's model and + // not the runtime's own default. + assert.deepStrictEqual(chatManager.calls.rememberModel[0], { + sessionId: 'session-1', + model: 'profile-default', + }); + }); + + // The same choice by the other door. Forwarding it untouched left the + // record unaware, so the next /clear restarted on the original model. + it('records a /model typed straight into the composer, and still forwards it', async function () { + const { processor, chatManager, session } = build({ surface: 'chat' }); + + await processor.handleMessage('ws-1', { type: 'chat_send', text: '/model haiku-3' }); + + assert.strictEqual(session.chatModelOverride, 'haiku-3'); + assert.deepStrictEqual(chatManager.calls.rememberModel[0], { + sessionId: 'session-1', + model: 'haiku-3', + }); + assert.strictEqual( + chatManager.calls.send[0].turn.text, + '/model haiku-3', + 'the runtime still has to receive its own command', + ); + }); + + it('leaves an ordinary message that merely mentions /model alone', async function () { + const { processor, chatManager, session } = build({ surface: 'chat' }); + + await processor.handleMessage('ws-1', { type: 'chat_send', text: 'what does /model do?' }); + + assert.strictEqual(session.chatModelOverride, undefined); + assert.strictEqual(chatManager.calls.rememberModel.length, 0); + }); + + it('strips control characters and caps the length of a typed model name', async function () { + const { processor, session } = build({ surface: 'chat' }); + + await processor.handleMessage('ws-1', { + type: 'chat_set_model', + model: `sneaky\nrm -rf /${'x'.repeat(400)}`, + }); + + assert.ok(!session.chatModelOverride.includes('\n'), 'a newline would become a second line of the /model turn'); + assert.ok(session.chatModelOverride.length <= 200, `stored ${session.chatModelOverride.length} characters`); + }); + it('lets a saved override outrank the profile default on the next launch', async function () { const { processor, chatManager, session } = build( { surface: 'chat' },