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
29 changes: 29 additions & 0 deletions CHANGELOG.md
Original file line number Diff line number Diff line change
@@ -1,5 +1,34 @@
# Changelog

## [5.1.2] - 2026-07-26

### 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

### Fixed
Expand Down
4 changes: 2 additions & 2 deletions package-lock.json

Some generated files are not rendered by default. Learn more about how customized files appear on GitHub.

2 changes: 1 addition & 1 deletion package.json
Original file line number Diff line number Diff line change
@@ -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": {
Expand Down
74 changes: 74 additions & 0 deletions src/client/chat/controller.ts
Original file line number Diff line number Diff line change
Expand Up @@ -43,6 +43,18 @@ export interface ChatUnavailable {
canResume: boolean;
}

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

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

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

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

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

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

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

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

/** Tell the server this browser wants this conversation's live events. */
subscribe(): void {
this.send({ type: 'chat_subscribe' });
Expand Down Expand Up @@ -332,6 +401,11 @@ export class ChatController {
/** Drop everything, e.g. when the session is being restarted. */
reset(): void {
this.settlePage();
// Not a claim either way: the next snapshot or chat_started carries the
// record's real override, and showing a stale one in the meantime would
// be worse than showing nothing.
this.modelOverride = null;
this.modelResult = null;
// `hydrate` clears the queue from the (absent) snapshot field, so the line
// does not survive into a session that never accepted it.
this.transcript.hydrate({
Expand Down
29 changes: 29 additions & 0 deletions src/client/chat/turns.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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<TurnStatus, { icon: string; color: string; spin?: boolean; word: string }> = {
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;
Expand Down Expand Up @@ -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<string, boolean>,
): boolean {
const override = overrides.get(turnId);
return override === undefined ? turnId === lastTurnId : override;
}

export interface TurnMeta {
tools: string;
reasoning: string;
Expand Down
96 changes: 87 additions & 9 deletions src/client/shell/chat/ChatView.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -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,
Expand Down Expand Up @@ -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<Map<string, boolean>>(new Map());
const openTurnIds = React.useMemo(() => {
const ids = new Set<string>();
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
Expand Down Expand Up @@ -239,6 +284,10 @@ export function ChatView({
(queuedId: string) => controller.cancelQueued(queuedId),
[controller],
);
const setModel = React.useCallback(
(model: string) => controller.setModel(model),
[controller],
);
const upload = React.useCallback(
(file: File) => uploadAttachment(controller.sessionId, file),
[controller],
Expand All @@ -248,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);
Expand Down Expand Up @@ -284,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) => {
Expand Down Expand Up @@ -493,6 +558,8 @@ export function ChatView({
onSelect={selectTurn}
onJumpLatest={jumpLatest}
collapsed={indexCollapsed}
onExpandAll={expandAllTurns}
onCollapseAll={collapseAllTurns}
/>
) : null}

Expand Down Expand Up @@ -538,6 +605,8 @@ export function ChatView({
transcript={transcript}
turns={turns}
currentTurnId={currentTurnId}
openTurnIds={openTurnIds}
onToggleTurn={toggleTurn}
onLoadMore={loadMore}
onShowWork={showWork}
onEditTurn={seedDraft}
Expand Down Expand Up @@ -710,7 +779,14 @@ export function ChatView({
branch={branch}
turnLabel={currentTurn ? `turn ${currentTurn.index}` : undefined}
usage={transcript.usage}
model={transcript.model}
// The conversation's own override wins over whatever the
// runtime last reported, once it is confirmed live (or cleared).
// A pending/sent switch is not yet running and is surfaced via
// modelFeedback instead, so this label never claims a model the
// session isn't actually on.
model={controller.modelOverrideValue ?? transcript.model}
onSetModel={setModel}
modelFeedback={controller.modelFeedback}
bypassPermissions={bypassPermissions}
terminalOpen={terminalOpen}
/>
Expand Down Expand Up @@ -754,6 +830,8 @@ export function ChatView({
setIndexSheet(false);
jumpLatest();
}}
onExpandAll={expandAllTurns}
onCollapseAll={collapseAllTurns}
/>
</div>
<button
Expand Down
Loading
Loading