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

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
7 changes: 7 additions & 0 deletions .github/workflows/ci.yml
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down
28 changes: 26 additions & 2 deletions CHANGELOG.md
Original file line number Diff line number Diff line change
Expand Up @@ -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

Expand Down
28 changes: 28 additions & 0 deletions src/client/shell/chat/Composer.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -1231,6 +1231,34 @@ function ModelChip({
This runtime hasn't listed models — type one above.
</div>
) : 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. */}
<button
type="button"
role="option"
aria-selected={false}
onClick={() => pick('')}
style={{
width: '100%',
marginTop: 2,
padding: '5px 8px',
background: 'transparent',
border: 0,
borderTop: '1px solid var(--border)',
borderRadius: 0,
color: 'var(--muted-foreground)',
font: 'inherit',
fontSize: 'var(--text-xs)',
textAlign: 'left',
cursor: 'pointer',
}}
>
Use the default for this runtime
</button>
</div>
) : null}

Expand Down
9 changes: 8 additions & 1 deletion src/client/shell/chat/MessageList.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -248,11 +248,18 @@ export const MessageList = React.forwardRef<MessageListHandle, MessageListProps>
// 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<string, (typeof messages)[number]>();
for (const message of messages) map.set(message.id, message);
return map;
}, [messages]);
// eslint-disable-next-line react-hooks/exhaustive-deps
}, [messages, version]);
Comment on lines 260 to +262

const lastTurnId = turns.length ? turns[turns.length - 1].id : undefined;

Expand Down
21 changes: 18 additions & 3 deletions src/client/shell/chat/TurnStrip.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -147,7 +147,7 @@ export function TurnStrip({
>
<Icon name={glyph.icon} size={11} />
</span>
<Shrinkable>{turn.label}</Shrinkable>
<Shrinkable fontSize="var(--text-xs)">{turn.label}</Shrinkable>
</>
) : null}

Expand Down Expand Up @@ -188,15 +188,30 @@ 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 (
<span
style={{
flex: '0 1 auto',
minWidth: 0,
overflow: 'hidden',
textOverflow: 'ellipsis',
whiteSpace: 'nowrap',
fontSize,
}}
>
{children}
Expand Down
5 changes: 5 additions & 0 deletions src/server/chat/manager.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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;
Expand Down
19 changes: 19 additions & 0 deletions src/server/chat/session.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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<ChatSnapshot> {
return this.deps.store.snapshot(this.ref).then((snapshot) => ({
...snapshot,
Expand Down
56 changes: 55 additions & 1 deletion src/server/websocket/messages.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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 <name>` 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<string, SessionRecord>;
Expand Down Expand Up @@ -90,6 +113,8 @@ export interface ChatManagerLike {
interrupt(sessionId: string): Promise<void>;
/** Switch a live session's model. False when nothing is running, or the adapter cannot. */
setModel(sessionId: string, model: string): Promise<boolean>;
/** 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<void>;
Expand Down Expand Up @@ -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,
Expand Down Expand Up @@ -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',
Expand Down
Loading
Loading