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
5 changes: 5 additions & 0 deletions .gitignore
Original file line number Diff line number Diff line change
Expand Up @@ -19,3 +19,8 @@ test-*.js
test/browser/bundle.js
.work/
.work/

# The app's own runtime directory: attachments uploaded through a conversation,
# written into whatever project it is running against. A screenshot sent to the
# agent is data, not source, and one reached this repo before this line did.
.cc-web/
159 changes: 159 additions & 0 deletions CHANGELOG.md
Original file line number Diff line number Diff line change
@@ -1,5 +1,164 @@
# Changelog

## [5.2.0] - 2026-07-27

### Added
- **A tab you renamed stays renamed, and a reload brings you back to it.** The
name you gave a session used to live only in the page you typed it in: reload
the browser, or open the app in a second window, and every tab was back to the
name it was created with. A chosen name now belongs to the session. It comes
back after a reload, it is the same name in every window and on every device,
it reaches windows that are already open without them reloading, and it
survives the app being restarted and the session being recovered. Sessions
nobody renamed are unchanged — they still show their generated name, with the
folder name standing in for it.

The same reload also used to lose your place, dropping you on the first tab
whichever one you had been working in. It now returns you to the tab you were
last on, remembered per window, so two windows can each sit on their own
session. If that session is gone by the time you come back, the app falls back
to the first tab rather than showing you nothing.

- **The agent can ask you a question and wait for the answer.** Until now the
only thing it could put in front of you was an approval — allow or deny a tool
it was about to run. Anything else it needed to know, it had to ask in prose,
and you had to guess the wording it was hoping for.

It can now ask a proper question with the answers already written out: which
of three approaches to take, which of the four candidate files you meant, which
of the problems it found to fix first. The question appears in the conversation
where it was asked, you answer by clicking, and the agent picks up from your
answer. Questions come in both kinds — pick exactly one, or tick several and
confirm.

The card stays where it was after you answer, showing what was asked and what
you chose, so scrolling back past a decision shows the decision. If you close
the tab while one is waiting, it is still there — and still answerable — when
you come back. A question you would rather not answer can be skipped; the agent
is told so and carries on rather than sitting there blocked.

Questions are asked even in sessions running with approvals bypassed: not being
asked before it acts has never meant having your questions answered for you.

Works with Claude and with omp, each through its own handshake. kimi can reach
it too, but often prefers its own built-in question tool, which answers itself
without asking anyone — so questions there are hit and miss, and that is kimi's
behaviour rather than something this app can steer. Codex, pi and grok report
the capability as unavailable rather than offering a button that would do
nothing.
- **You can open what the agent handed off and watch it work.** A delegation
used to be one line in the agents list: a name, a status badge, a duration.
Whether it was a sub-agent reading three files or a workflow running a dozen
agents across four stages, you got the same single row and waited.

Now any entry in that list opens. A sub-agent shows its own work — the step it
is on in its own words ("Reading hello.txt"), every tool it reached for, what
each one gave back, and how many tools and tokens it has spent. It fills in
live while the agent works and stays there afterwards for reading back. A
workflow opens the same way and shows the stages it narrates as it goes.

Failures inside that work are now legible. When a step an agent took fails,
the popup shows the message it failed with, rather than the whole delegation
turning into a red badge that says only that something went wrong somewhere.

Both popups behave like the file editor — movable, resizable, expandable to
fill the window — and both stay open while you look at another panel.

### Fixed
- **A sub-agent's own steps are no longer thrown away.** The runtime has been
reporting them all along, tagged with the delegation they belong to. Nothing
read that tag, so every tool call an agent made inside its own work was filed
against an id no part of the conversation owned and silently dropped. They now
reach the delegation that made them.

### Changed
- **The conversation only shows what was actually said.** When the agent spent a
step running commands without writing anything, the transcript still drew a
row for it: an icon, a clock and a small work pill with no sentence beside
them. A long task could put half a dozen of those between one reply and the
next, and skimming the chat meant stepping over rows that said nothing.

Those steps no longer appear in the conversation. The moment a written reply
arrives, it carries the pill for everything that led up to it — "3 commands ·
1 reasoning · 8.1s" on the sentence that came out of them — and opening it
lands on the trace at the *start* of that stretch rather than at the reply's
own last command. Nothing is hidden: the trace holds every call exactly as
before, and a trace row or a search hit that points at a suppressed step now
scrolls to the reply that speaks for it.

- **A phone gets a layout built around the conversation.** It used to be the
desktop layout at the same size: the figures you read mid-session — the cost,
the model, the state, whether approvals are bypassed — were set smaller than
the body text, the controls sat close enough together that hitting the
intended one was luck, and more than half the screen went to chrome.

Now the chat surface is one slim strip above the conversation and one row
below it. The strip says what the session is doing and what it has cost;
tapping it opens the runtime, the folder, the branch, the tokens, the context
meter and the approvals state. The row below is the message field and send,
with the attachments, the pickers, the model and the approvals readout behind
a *More*.

The bottom bar is now a set of destinations rather than a drawer of commands.
What this app does is run agent sessions, and inside one there are four places
worth being — the conversation, what the agent did about it, the files it did
that to, and a shell in the same directory — plus the other sessions. Those
are the five, it says which one you are on, and pressing one goes there. What
used to sit in those slots mixed a place to go, a thing to make, a panel to
toggle, a file to attach and a sheet of everything left over.

The verbs moved to a square button floating in the bottom right: search this
conversation, jump to a turn, display settings, new session, attach an image,
rename, reconnect, and the rest behind *More*. A control that changes where
you are and a control that does something to where you are no longer share a
row.

The conversation now has about seven tenths of a phone screen, where it had
under half, and the bar gets out of the way entirely while the on-screen
keyboard is up. Everything a finger is meant to hit is at least 44px with real
space around it, nothing carrying live information is smaller than the body
text, and every control says what it is rather than being a bare glyph with a
tooltip no touch screen can show. The same treatment reaches the trace rail,
the turn index, the model list, the more sheet, the tab switcher and the
dialogs. The desktop and tablet layouts are unchanged.

### Fixed
- **A phone no longer opens a conversation onto a panel.** Which panel is open
is a desktop preference — there the rail sits beside the transcript — but on a
phone it replaces it, so the stored setting put every conversation behind a
panel. It is session state on a phone now, and the shared preference is left
alone rather than overwritten, which would have closed the rail on the desktop
that set it open.
- **The conversation no longer spills over the live ribbon and the composer on
a short screen.** The transcript kept a fixed minimum height it could not
give up, so on a phone in landscape — or any window short enough — it grew
past the space it had and was painted over the two things below it.

### Internal
- A browser check covers the suppressed steps end to end — that they leave no
row, that the trace still holds them, and that clicking the reply's pill lands
on the first of them. The suite's virtual-time budget grew with it: a run that
outgrows the budget reports no results rather than a failure, so it now has
room over what the checks need.
- The automated browser checks run at phone viewports (portrait, keyboard-open
and landscape), with each of the phone's own disclosures and its menu open in
turn, and assert the geometry rather than the intent: target size, the space
between neighbours, type size, that the named live figures are legible and
reachable, that every control can be identified without pressing it, that no
region is drawn over another, and that the chrome takes no more than 170px
from the conversation — nothing else would have noticed that drifting back,
since every other rule is about the chrome being big enough.

Three defects in the checks themselves came out of it. They ran in an 800x600
window while mounting a 390x740 fixture, so a third of it was off-viewport
and anything that asks the viewport a question got the wrong answer. They
loaded none of the app's own stylesheets, without which every
`var(--text-2xs)` resolved to nothing and a type-size check read 16px for
text that ships at 10. And a panel animating in from `opacity: 0` was skipped
as invisible in headless Chrome, so a whole state was reported clean without
measuring anything — the menu rises without fading now, which also means it
is there under reduced motion and on a dropped first frame.

## [5.1.2] - 2026-07-26

### Added
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.2",
"version": "5.2.0",
"description": "Multiuser web CLI for Claude Code, Codex, and terminal sessions",
"main": "dist/server/index.js",
"bin": {
Expand Down
7 changes: 5 additions & 2 deletions src/client/app.ts
Original file line number Diff line number Diff line change
Expand Up @@ -259,8 +259,11 @@ export class App {
this.splitContainer.setupDropZones();

if (this.sessionTabManager.tabs.size > 0) {
const firstTabId = this.sessionTabManager.tabs.keys().next().value;
await this.sessionTabManager.switchToTab(firstTabId!);
// The tab this browser was last on, or the first one if that session is
// gone — not always the first one, which sent every reload back to the
// start of the strip.
const initialTabId = this.sessionTabManager.initialTabId();
if (initialTabId) await this.sessionTabManager.switchToTab(initialTabId);
hideOverlay();
} else {
hideOverlay();
Expand Down
12 changes: 12 additions & 0 deletions src/client/chat/controller.ts
Original file line number Diff line number Diff line change
Expand Up @@ -309,6 +309,17 @@ export class ChatController {
this.send({ type: 'chat_queue_cancel', queuedId });
}

/**
* Answer a multiple-choice question the model asked.
*
* `skipped` is explicit rather than inferred from an empty list: "I picked
* none of these" and "I do not want to answer" reach the model as different
* sentences, and the agent is blocked either way until one of them arrives.
*/
answerQuestion(requestId: string, optionIds: string[], skipped = false): void {
this.send({ type: 'chat_question_answer', requestId, optionIds, skipped });
}

respondPermission(requestId: string, optionId: string): void {
this.send({ type: 'chat_permission_response', requestId, optionId });
}
Expand Down Expand Up @@ -415,6 +426,7 @@ export class ChatController {
state: 'starting',
capabilities: NO_CHAT_CAPABILITIES,
pendingPermissions: [],
pendingQuestions: [],
firstSeq: 0,
replayFrom: 0,
cursor: 0,
Expand Down
23 changes: 23 additions & 0 deletions src/client/chat/transcript.ts
Original file line number Diff line number Diff line change
Expand Up @@ -28,6 +28,7 @@ import {
PermissionRequest,
PlanItem,
QueuedTurn,
QuestionRequest,
NO_CHAT_CAPABILITIES,
} from '../../shared/chat-events.js';
import {
Expand Down Expand Up @@ -161,6 +162,7 @@ export class ChatTranscript {
usage: snapshot.usage || {},
plan: snapshot.plan || [],
pendingPermissions: snapshot.pendingPermissions || [],
pendingQuestions: snapshot.pendingQuestions || [],
firstSeq: snapshot.firstSeq,
cursor: snapshot.cursor,
});
Expand Down Expand Up @@ -371,6 +373,27 @@ export class ChatTranscript {
return this.state.pendingPermissions;
}

/** Questions the model asked that nobody has answered yet. */
get pendingQuestions(): QuestionRequest[] {
return this.state.pendingQuestions;
}

/**
* The question waiting on the given tool call, if that call is the one asking.
*
* How a card drawn from a tool block finds out it is still live: the block is
* in the transcript either way, and this is the difference between a set of
* buttons and a record of what was already decided.
*/
questionFor(toolId: string): QuestionRequest | undefined {
return this.state.pendingQuestions.find((pending) => pending.toolId === toolId);
}

/** Which options were picked for the question that call asked, once answered. */
answerFor(toolId: string): string[] | undefined {
return this.state.answeredQuestions[toolId];
}

get cursor(): number {
return this.state.cursor;
}
Expand Down
3 changes: 2 additions & 1 deletion src/client/chat/turns.ts
Original file line number Diff line number Diff line change
Expand Up @@ -109,7 +109,8 @@ function summarise(
}
}

const status: TurnStatus = isLast && chatState === 'awaiting_permission'
const status: TurnStatus = isLast
&& (chatState === 'awaiting_permission' || chatState === 'awaiting_answer')
? 'waiting'
: isLast && (streaming || chatState === 'thinking' || chatState === 'running' || chatState === 'starting')
? 'running'
Expand Down
Loading
Loading