Skip to content

feat(agent-ui): size views by their content, and stop re-teaching the state rules - #149

Merged
yellowcap merged 10 commits into
mainfrom
feat/port-agent-ui-improvements
Sep 18, 2026
Merged

yellowcap merged 10 commits into
mainfrom
feat/port-agent-ui-improvements

Conversation

@yellowcap

@yellowcap yellowcap commented Sep 17, 2026

Copy link
Copy Markdown
Member

Summary. Views now size themselves to their content instead of sitting in a
fixed 15rem box. Every tool result is in the transcript, folded, drawn the same
way whether or not the tool ships a view. The debug toggle is gone — opening
the session-state panel shows the receipts. And session-state notes stop
repeating rules that the system prompt already gives.

Stacked on #147. The base is feat/ask-user-interrupts, because these
changes edit the same JSX as the interrupt rendering. Merge #147 first and
this retargets main cleanly.

View sizing: there was no protocol gap

MCP Apps defines ui/notifications/size-changed, and ext-apps already sends
it from a ResizeObserver. Both of this runtime's hosts were discarding a
notification they already received. So this is a host-side fix. No view needs
code, and no custom message is added beside the protocol.

Every published view bundle gets this on upgrade — the example bundle here was
built last August and already emits the notification.

Verified in a browser: a one-line view asks for 72px, the example clip view for
243px. Both are clamped to 44–1000 and applied.

Client

  • A view is drawn as a sibling of the messages, not folded inside its receipt.
  • Results are folded blocks, capped at 18rem, with nested JSON parsed back.
  • One switch, not two: the state panel reveals the receipts.
  • Three dots show while a run waits. They are under a tool call that has no
    result yet, or at the end of the log while the model thinks. They hide
    while the answer streams, because the caret shows that. With reduced motion,
    the dots fade and do not move.
  • clear starts a new thread. Nothing is deleted; the old thread keeps its URL.
  • State cards lead with the key. Amber means the model wrote the value, blue
    means a tool produced it, and a legend says so once.

One bug fixed rather than ported: a call left unsettled by a failed run was
shown as running in every later run. It now reads from the running turn.

Session-state notes

The capture note said which keys were written and how to use a key. Only the
first is news. A capture is the message a long run accumulates most of.

-[state updated: a/b/c, a/b/d — pass the bare key to inspect_state to read one;
- pass @state:<key> only to a tool parameter whose schema accepts it]
+[state updated: a/b/c, a/b/d]

The overwrite clause keeps its fact (This replaces what <key> held at turn 2)
and drops its instruction. inspect_state's turn argument now documents
where a turn number comes from, and what to do when a turn has been pruned.

Tests pin both halves: the notes carry facts only, and the prompt and tool
description still state the rules.

Checks

scripts/build-js, 543 Python tests, ruff, mypy. Each client state was checked
against a real browser render.

Merge order

🤖 Generated with Claude Code

Base automatically changed from feat/ask-user-interrupts to main September 18, 2026 09:42
yellowcap and others added 9 commits September 18, 2026 10:47
Every view iframe was a fixed 15rem box. A one-line answer wasted it and a
long table was trapped inside it.

The MCP Apps protocol already carries the fix. `ui/notifications/size-changed`
is part of the spec, and `@modelcontextprotocol/ext-apps` sends it on its own
from a `ResizeObserver` on the document — so no view needs code for this, and
no custom message has to be invented beside the protocol. Both ends of the
runtime ignored it; the host end now acts on it.

`autoResize` is the SDK's own default, set explicitly here: it is the whole
reason a view's height is not a number somebody picked, and a default that
decides that much is worth being able to read in the bridge rather than
inferring it from the SDK.

Verified end to end in a browser against two built bundles: a one-line view
asks for 72px and the example clip view for 243px, both clamped and applied.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Seven changes from a fork of this client that had been running against a real
toolset, kept where they earn their room.

The frame of a `ui://` view now follows the view's own `size-changed`, clamped
between a line of text and a screenful. Views are drawn where an answer is
drawn — a sibling of the messages — rather than folded inside the receipt that
announced them, since a view *is* the tool's answer. They keep the hover link
to the call that made them.

Raw tool results and receipts move behind a `debug` switch, off by default:
they answer "is the view lying?", which is not a question a normal turn raises.
A `clear` switch beside it starts a new thread, which is how a conversation
gets empty session state; nothing is deleted and the old thread keeps its
`?thread=` URL. The state panel starts collapsed to a spine, because it is a
reference rather than the output.

While a run is in flight a pill above the composer names the call, rotating
when several are in flight at once, so the gap between a question and its
answer is not silent. What it announces is not what it draws: a live region
carrying a name that changes every 1.8s would be read aloud every 1.8s, so the
count is what is spoken and the rotation is for the eye.

A call's arguments are drawn with nested stringified JSON parsed back, since a
provider that encodes one structured argument as text does it at any depth and
the escaping is not what the model wrote.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
A toolset ships views for some tools and not others, and with the raw
payloads behind `debug` the ones without had nothing on screen at all — only
the model's account of what the tool said, which is the one reading a reader
cannot check.

So a result whose call drew no view is now drawn in the transcript, in the
column and the position a view takes, indented as JSON where it is JSON and
left alone where it is prose. Nested stringified JSON is parsed back, for the
reason it is in a call's arguments.

Open, because it is the answer; foldable and capped, because a tool that
returned a hundred rows should not cost the reader the scrollback to get past
it. A call that *did* draw a view is unchanged — its answer is on screen in
the form the toolset built for it, and the text of it stays `debug`'s
business rather than a second copy underneath.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
The card led with a badge and a line of metadata wrapped around the key, and
then said "written by the model" once per argument. Three changes, one idea:
say each thing once, in the place a reader is already looking.

The key is now the whole of the card's first line. What was beside it becomes
a row of chips under it — new, the size, and how many turns hold a value —
because those are facts about the key rather than part of it. The tool is not
among them: a key is `<toolset>/<tool>/<field>`, so naming it there printed
it twice.

The repeated sentence goes, and the colour it was spelling out stays: amber
for a value the model wrote, blue for one that came from another key. A
legend at the top of the panel is where the words are now spent, once, and it
is written in those two colours rather than beside swatches of them — the
sample and the legend are the same object, so there is nothing to carry
across.

That leaves amber and blue meaning exactly one thing each in this panel, so
the chips take none of it and are told apart by weight instead.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Two changes that pull the same way.

`debug` is gone, and the state panel does what it did. They were one question
asked twice: the panel and the receipts describe the same thing from two ends
— what the tools exchanged without the model reading it — and nobody wants one
without the other. Closed, the page is a chat; open, it is the wire.

And a tool's result is now drawn for every call, not only for the calls whose
toolset shipped no view. Gating it on that said a result matters less when
someone wrote a view for it, which is backwards: a view is a nicer reading of
a result, not a more important one, and a toolset that wrote views for three
tools and not the fourth has not thereby ranked them. Where there is a view,
both are on screen — the drawing and the thing it was drawn from.

The block is capped at the 18rem the folded blocks elsewhere use, since every
call now draws one, and it still folds and still drags open.

The summary says "result" whether or not the content parsed as JSON. Which of
the two it is was never worth a word: it is the first thing visible in the
block underneath.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
The two colour swatches go. They named what the colours mean — blue for what
AG-UI gives any client, amber for what this runtime adds — which is a fact
about the wire rather than anything a reader of a conversation is deciding
between, and it sat in the one row where the controls are.

What is left is sorted by what it is. The thread id joins the name, because it
says what the page is showing rather than offering something to do about it,
and it keeps its own element so a long title ellipsises without taking the id
with it. The right end of the row is now buttons and nothing else, which is
what `clear` needed to be read as one.

Results are folded by default. Every call draws one, so opening them all put
the answer a screen below the question that earned it; the model's reply
already says what the result was, and this is where you go to check that reply
against the thing it is about.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
The breadcrumb carried two things: which keys a tool just wrote, and how to
use a key. Only the first is news about the call. The second — a read takes
the bare key, a handle goes only on a parameter whose schema accepts one — is
a standing rule that SESSION_STATE_PROMPT already states, and a capture is the
message a long run accumulates most of, so the note paid for that rule again
every time a tool published anything.

    [state updated: sketch-ops/sketch_area/area_km2,
     sketch-ops/sketch_area/boundary — pass the bare key to inspect_state to
     read one; pass @State:<key> only to a tool parameter whose schema accepts
     it]

becomes

    [state updated: sketch-ops/sketch_area/area_km2,
     sketch-ops/sketch_area/boundary]

The overwrite clause stays: that a write displaced what an earlier turn left
is news about this call, and it reaches the model nowhere else.

The test that asserted the wording is replaced by two: one pinning the note to
the keys alone, and one pinning the rules to the prompt — which is the other
half of the trade, and would otherwise be the thing nobody notices going
missing.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
The note said the write displaced an earlier value *and* how to go and read
it. The first is news about this call and reaches the model nowhere else; the
second is the `turn` argument's own contract, repeated into every capture that
overwrote anything.

    [state updated: gazet/get_aoi/bbox. This replaces what gazet/get_aoi/bbox
     held at turn 2, which is still readable with inspect_state(key, turn=2)]

becomes

    [state updated: gazet/get_aoi/bbox. This replaces what gazet/get_aoi/bbox
     held at turn 2]

The turn number is the whole of what the note has to hand over, so the `turn`
argument now documents what to do with one. It names all three surfaces that
produce a number — the capture note's "replaces what <key> held at turn <n>",
a read reporting that several turns wrote the key, and a listing marked
"written in N turns" — since with the tail gone, a model holding a turn number
from any of them has this description and the prompt and nothing else. It also
says what to do when a turn is no longer retained, which is the answer a model
gets wrong by default: it answers from the current value instead.

Two tests pin the trade. One holds the note to the fact alone; the other holds
the description to naming all three sources, so trimming it cannot quietly
take the last statement of the rule with it.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
@yellowcap
yellowcap force-pushed the feat/port-agent-ui-improvements branch from 2793ede to 80181fd Compare September 18, 2026 09:47
Comment thread js/agent-ui/src/chat.tsx Outdated
…he composer

The pill above the composer is gone. While a run waits, three dots show
where the wait is: under a tool call that has no result yet, or at the end
of the log while the model has the turn. They hide while the answer streams,
because the caret already shows that. With reduced motion, the dots fade in
place instead of hopping.

The state panel legend now reads "a tool produced it" instead of "another
key", from review.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
@yellowcap
yellowcap merged commit 454eb10 into main Sep 18, 2026
4 checks passed
@yellowcap
yellowcap deleted the feat/port-agent-ui-improvements branch September 18, 2026 10:57
yellowcap pushed a commit that referenced this pull request Sep 18, 2026
🤖 I have created a release *beep* *boop*
---


##
[0.10.0](mcp-toolsets-runtime-v0.9.1...mcp-toolsets-runtime-v0.10.0)
(2026-09-18)


### ⚠ BREAKING CHANGES

* the `[web]` extra, the `mcp_agent.web` module, the `mcp-agent-web`
entry point, the packaged `McpView.jsx` element and the `mcp-agent
install-elements` command are removed. A deployment that served the
Chainlit chat should serve `mcp_agent_api.app` instead, which ships its
own web client; a host that wants Chainlit specifically can build one
against the HTTP API. `AgentSettings` also loses `chainlit_port`.

### Features

* **agent-ui:** size views by their content, and stop re-teaching the
state rules
([#149](#149))
([454eb10](454eb10))
* **agent:** let the agent ask the user with an interrupt tool and AG-UI
interrupts
([#147](#147))
([09e8c14](09e8c14))
* remove the bundled Chainlit host
([#140](#140))
([b0c0922](b0c0922))


### Documentation

* plain English, and fix what the docs get wrong
([#139](#139))
([289a6fb](289a6fb))
* **toolset:** ship a toolset-authoring skill with the package
([#138](#138))
([1e8be98](1e8be98))

---
This PR was generated with [Release
Please](https://github.com/googleapis/release-please). See
[documentation](https://github.com/googleapis/release-please#release-please).

Co-authored-by: ds-release-bot[bot] <116609932+ds-release-bot[bot]@users.noreply.github.com>
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

2 participants