Skip to content

Commit 9b01edf

Browse files
feat(webui): report failures and confirmations as toasts
- Add the shared toast: client/ui/toast-store.ts owns the queue's rules and client/ui/toast.tsx owns the card, the timers, the announcement and the host - Draw the notice against the viewport so it takes no room from the page it interrupts, and give it the client's own surface, hairline, elevation and a rail in the tone's colour - Move 141 call sites across 51 client surfaces to useWebuiToastError and useWebuiToastSuccess, and drop the inline error blocks and their announcers - Keep a field's own validation error and a state that stays in place; only an event moved - Keep the notice channel live across a modal: the data-webui-live-surface marker keeps a modal from making it inert, and a press on it no longer dismisses the layer it is drawn over - Record the decision in ADR 0030 and the toast contract in the design language - Carry the working tree's in-flight Mini App changes, which were uncommitted alongside the toast migration
1 parent 58ae03e commit 9b01edf

110 files changed

Lines changed: 3584 additions & 736 deletions

File tree

Some content is hidden

Large Commits have some content hidden by default. Use the searchbox below for content that may be hidden.

‎.plan/browser-use/complete/isolated-chromium.acceptance.json‎

Lines changed: 2 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -16,7 +16,7 @@
1616
},
1717
"observed": "a pointer click and a text input both arrived in the page the provider was driving",
1818
"result": "pass",
19-
"at": "2026-11-02T11:03:36.996Z",
19+
"at": "2026-11-03T12:09:19.985Z",
2020
"os": "linux 7.0.0-38-generic",
2121
"suite": "isolated-chromium"
2222
},
@@ -37,7 +37,7 @@
3737
},
3838
"observed": "the stale identity was refused with browser_target_stale and the page that replaced it received nothing",
3939
"result": "pass",
40-
"at": "2026-11-02T11:03:38.507Z",
40+
"at": "2026-11-03T12:09:21.505Z",
4141
"os": "linux 7.0.0-38-generic",
4242
"suite": "isolated-chromium"
4343
}

‎.plan/workspace-browser-tab/complete/workspace-browser-tab.acceptance.json‎

Lines changed: 14 additions & 14 deletions
Original file line numberDiff line numberDiff line change
@@ -14,7 +14,7 @@
1414
},
1515
"observed": "the run's selection points at the page the Agent opened, the runtime publishes it, and the strip's reconciliation makes it the active panel tab",
1616
"result": "pass",
17-
"at": "2026-11-02T11:02:29.156Z",
17+
"at": "2026-11-03T12:08:24.255Z",
1818
"os": "linux 7.0.0-38-generic",
1919
"suite": "workspace-browser-tab"
2020
},
@@ -33,7 +33,7 @@
3333
},
3434
"observed": "the page is at the requested url and the frame stream follows the page it opened",
3535
"result": "pass",
36-
"at": "2026-11-02T11:02:30.338Z",
36+
"at": "2026-11-03T12:08:25.431Z",
3737
"os": "linux 7.0.0-38-generic",
3838
"suite": "workspace-browser-tab"
3939
},
@@ -52,7 +52,7 @@
5252
},
5353
"observed": "the action runs on the run's selection, and a reference recorded before the page moved is refused",
5454
"result": "pass",
55-
"at": "2026-11-02T11:02:31.261Z",
55+
"at": "2026-11-03T12:08:26.295Z",
5656
"os": "linux 7.0.0-38-generic",
5757
"suite": "workspace-browser-tab"
5858
},
@@ -71,7 +71,7 @@
7171
},
7272
"observed": "the Agent's page and the person's page are different, and each shows its own picture",
7373
"result": "pass",
74-
"at": "2026-11-02T11:02:31.309Z",
74+
"at": "2026-11-03T12:08:26.353Z",
7575
"os": "linux 7.0.0-38-generic",
7676
"suite": "workspace-browser-tab"
7777
},
@@ -90,7 +90,7 @@
9090
},
9191
"observed": "the run's selection becomes the person's page, so the Agent can act on it",
9292
"result": "pass",
93-
"at": "2026-11-02T11:02:32.205Z",
93+
"at": "2026-11-03T12:08:27.246Z",
9494
"os": "linux 7.0.0-38-generic",
9595
"suite": "workspace-browser-tab"
9696
},
@@ -109,7 +109,7 @@
109109
},
110110
"observed": "the input reaches the person's page, not the Agent's",
111111
"result": "pass",
112-
"at": "2026-11-02T11:02:33.013Z",
112+
"at": "2026-11-03T12:08:28.069Z",
113113
"os": "linux 7.0.0-38-generic",
114114
"suite": "workspace-browser-tab"
115115
},
@@ -128,7 +128,7 @@
128128
},
129129
"observed": "the tab becomes that page, and the url is shown read-only in the toolbar",
130130
"result": "pass",
131-
"at": "2026-11-02T11:02:33.038Z",
131+
"at": "2026-11-03T12:08:28.090Z",
132132
"os": "linux 7.0.0-38-generic",
133133
"suite": "workspace-browser-tab"
134134
},
@@ -147,7 +147,7 @@
147147
},
148148
"observed": "the page is released, and the runtime's page set follows the strip",
149149
"result": "pass",
150-
"at": "2026-11-02T11:02:33.826Z",
150+
"at": "2026-11-03T12:08:28.869Z",
151151
"os": "linux 7.0.0-38-generic",
152152
"suite": "workspace-browser-tab"
153153
},
@@ -166,7 +166,7 @@
166166
},
167167
"observed": "the page moves through its own history, and the generation follows",
168168
"result": "pass",
169-
"at": "2026-11-02T11:02:36.500Z",
169+
"at": "2026-11-03T12:08:31.499Z",
170170
"os": "linux 7.0.0-38-generic",
171171
"suite": "workspace-browser-tab"
172172
},
@@ -185,7 +185,7 @@
185185
},
186186
"observed": "the Agent's open_tab and the person's launcher path are both refused with browser_surface_not_authorized",
187187
"result": "pass",
188-
"at": "2026-11-02T11:02:36.501Z",
188+
"at": "2026-11-03T12:08:31.500Z",
189189
"os": "linux 7.0.0-38-generic",
190190
"suite": "workspace-browser-tab"
191191
},
@@ -204,7 +204,7 @@
204204
},
205205
"observed": "every action except open_tab is refused with browser_target_unknown",
206206
"result": "pass",
207-
"at": "2026-11-02T11:02:36.519Z",
207+
"at": "2026-11-03T12:08:31.519Z",
208208
"os": "linux 7.0.0-38-generic",
209209
"suite": "workspace-browser-tab"
210210
},
@@ -223,7 +223,7 @@
223223
},
224224
"observed": "its pages are released and no page is left behind",
225225
"result": "pass",
226-
"at": "2026-11-02T11:02:37.298Z",
226+
"at": "2026-11-03T12:08:32.300Z",
227227
"os": "linux 7.0.0-38-generic",
228228
"suite": "workspace-browser-tab"
229229
},
@@ -242,7 +242,7 @@
242242
},
243243
"observed": "both are addressable, and the strip shows both",
244244
"result": "pass",
245-
"at": "2026-11-02T11:02:38.225Z",
245+
"at": "2026-11-03T12:08:33.252Z",
246246
"os": "linux 7.0.0-38-generic",
247247
"suite": "workspace-browser-tab"
248248
},
@@ -261,7 +261,7 @@
261261
},
262262
"observed": "its generation advances, and a reference recorded against the old generation is refused — on every tab, not only the first",
263263
"result": "pass",
264-
"at": "2026-11-02T11:02:39.136Z",
264+
"at": "2026-11-03T12:08:34.175Z",
265265
"os": "linux 7.0.0-38-generic",
266266
"suite": "workspace-browser-tab"
267267
}

‎packages/webui/AGENTS.md‎

Lines changed: 9 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -94,6 +94,15 @@ component records the reason at the call site and in the presentation inventory.
9494
stylesheet selector to a named class so the stylesheet stops addressing raw elements is a separate
9595
job: it makes the ratchet possible, and it does not count as adopting the component.
9696

97+
**A failure and a confirmation are a toast.** Something that has already happened — an action that
98+
failed, a write that landed, a read that did not come back — is said through `useWebuiToastError`,
99+
`useWebuiToastSuccess` or `useWebuiToast().show` (`client/ui/toast.tsx`), never written into the page:
100+
the toast is drawn over the client and takes no room from it. Two things stay where they are: a
101+
field's own validation error, which `WebuiField` binds to its control with `aria-describedby`, and a
102+
state that stays true until the fact changes. The notice crosses a modal: the host carries
103+
`data-webui-live-surface`, so a modal never makes it inert, silences its announcement, or closes
104+
when the reader answers it. ADR 0030 states the rule and the reasons.
105+
97106
**A command that is still running is the control's state.** Pass `loading` (and `loadingLabel`) to
98107
`WebuiButton`, `WebuiIconButton` or `ToggleSwitch`, or give the control a promise-returning `onClick`
99108
and let `useFeedbackActionLoading` follow it; do not disable the control by hand or draw a local

‎packages/webui/docs/README.md‎

Lines changed: 4 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -104,7 +104,10 @@ the same time the shared database decides which host acts on a given slot — th
104104
needs no change. [ADR 0028](adr/0028-the-webui-has-one-shared-presentation-layer.md) establishes the
105105
shared presentation layer and its motion foundations; [ADR 0029](adr/0029-page-surfaces-share-layout-skeleton-and-motion-contracts.md)
106106
defines the page, skeleton and shared motion contracts for route pages, dialogs, drawers and
107-
repeated semantic elements. A new decision takes the next free number.
107+
repeated semantic elements. ADR 0030 records the client's one way of saying something that has
108+
already happened: the shared toast, which reports a failure, a confirmation or a failed read over the
109+
client instead of in the page, while a field's own validation error and a state that stays keep their
110+
place. A new decision takes the next free number.
108111

109112
| Retired | Was | Why it is not in the log | Where the live facts are |
110113
| --- | --- | --- | --- |

‎packages/webui/docs/adr/0020-webui-miniapp-view-and-runtime-seams.md‎

Lines changed: 20 additions & 19 deletions
Original file line numberDiff line numberDiff line change
@@ -62,25 +62,26 @@ hold two credentials instead of one.
6262

6363
- **Native window semantics.** The electron app's detached presentation is a separate operating
6464
system window with always-on-top, click-through, transparent, frameless and skip-taskbar options
65-
and persisted window bounds. A browser tab cannot express any of them. The WebUI keeps the part
66-
that a browser can honour — the Mini App alone on screen, and a tab the user can drag to a second
67-
display — and drops the native window options.
68-
- **The page-side window handle.** Dropped from the contract rather than emulated, because the
69-
reason is stronger than "the browser cannot". The newer generation adds a third part to the
70-
bridge namespace — `window.mcode.miniapp.window` with `getState`, `update` and `focus`, a manifest
71-
`surface.window` whitelist of size, `frame`, `transparent`, `alwaysOnTop`, `clickThrough` and
72-
`skipTaskbar`, and a Chromium argv surface id — and that block is the page asking for its own
73-
native window, bounds and visibility. Beyond the browser's inability to express any of it, the
74-
block grants the page a say in *how it is presented*, and presentation is this client's decision.
75-
A Mini App appears in its own tab because the client opened one, never because the page asked.
65+
and persisted window bounds. A browser tab cannot express those effects. The WebUI keeps the
66+
Mini App in the current tab and supports a browser-only floating presentation: the same iframe is
67+
positioned over the WebUI page, bounded by that page's viewport, and remains in the same document.
68+
It is not an operating system window and does not inherit native window options.
69+
- **The page-side window handle.** The newer Desktop generation's native-window contract is not
70+
imported as-is. Its `surface.window` manifest options and Chromium argv surface id describe native
71+
window effects and remain unsupported here. The WebUI provides its own browser-side window
72+
channel for `inline` and `window` presentation, viewport bounds and focus. A page can request the
73+
floating layout, but the host retains ownership of the presentation and clamps the requested
74+
geometry to the browser viewport. Its browser-side `transparent` patch clears the iframe surface
75+
and asks the injected page bridge to clear the Mini App document's `html` and `body` backgrounds;
76+
native manifest window options remain unsupported.
7677
- **Host-to-page injection.** The electron app can inject into an embedded page: the agent cursor
7778
overlay and the comment bridge. A cross-origin Mini App page cannot be reached that way, so a
7879
Mini App page does not get them.
79-
- **Moving one live page between presentations.** The electron app moves the same
80-
`WebContents` into a window and back into the File Panel, so the page is one instance with one
81-
state. A browser tab is a separate document. A Mini App shown in two places is two documents:
82-
server-side state is shared, in-page state is not. The two presentations are therefore two client
83-
documents, and the service keeps the record of which one is live.
80+
- **Moving one live page between layouts.** Inline and floating browser layouts use the same iframe
81+
and the same page document. The shell owns that view outside the workspace panel so closing the
82+
panel, changing workspace tabs, navigating to another WebUI page or selecting another session
83+
does not release the floating frame. The service still owns the Mini App presentation lease; the
84+
browser shell only retains its viewport-level placement.
8485

8586
## The contract baseline
8687

@@ -251,9 +252,9 @@ insufficient one.
251252
This repository now has the Agent gateway, session ports, application facade and child-side
252253
`context.agent` client. The manifest accepts independent `session.run` and `session.read` grants.
253254
`session.read` currently lists visible user conversations and reads a session summary (including its
254-
latest assistant text); the published observer API is still not implemented. `surface.window` is
255-
accepted for package compatibility but remains ignored because a browser page cannot move the same
256-
live document into a native Mini App window.
255+
latest assistant text); the published observer API is still not implemented. The manifest's
256+
`surface.window` block remains compatibility-only: it does not create a native window or enable its
257+
window flags. The WebUI's browser-side window channel separately supports an in-tab floating iframe.
257258

258259
The connector's wire has to move with it, and the reason is not a preference. In this repository the
259260
host connector *is* the child's stdout: the child's stdout is the request stream and its stdin the
Lines changed: 137 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,137 @@
1+
# The client reports failures and confirmations as toasts
2+
3+
**Status:** Accepted.
4+
**Date:** 2026-11-03.
5+
6+
ADR 0028 established one shared presentation layer, and its feedback family
7+
(`client/ui/feedback.tsx`) owns the states a surface can be in: empty, error,
8+
skeleton, progress, busy, announcement. That family draws a state *in place*,
9+
because a state is a fact about the surface it belongs to. This decision adds the
10+
other kind of message: one that has already happened, is said once, and is gone
11+
again — a failure of an action, a confirmation that a write landed, a read that
12+
did not come back. Those are events, not states, and the client was drawing them
13+
as states.
14+
15+
## What changed
16+
17+
Before this decision the client wrote those events into the page. A failed save
18+
put a red paragraph under the form and left it there until the next attempt. A
19+
successful save put a green paragraph under the field and left it there until the
20+
user edited the field again. A failed read replaced the panel's content with a red
21+
paragraph. Every one of them spent layout on a moment that was already over: the
22+
page grew, the content below the notice moved, and the notice outlived the thing
23+
it was about.
24+
25+
The shared toast (`client/ui/toast.tsx`) is now the one way the client says
26+
something that has already happened. The notice is drawn against the viewport,
27+
over the page, and takes no room from it.
28+
29+
## What a toast is
30+
31+
A toast is a card with a tone, a message, an optional description, an optional
32+
action, and a lifetime. It arrives, it is read, and it leaves. The client says
33+
through it:
34+
35+
- an action that failed — "保存失败", "复制失败";
36+
- a write that landed — "已保存", "已复制", "远程连接已保存";
37+
- a read that did not come back — "读取定时任务失败。".
38+
39+
`client/ui/toast-store.ts` owns the decisions and is free of React: the
40+
vocabulary of a notice, the lifetime each tone is worth, the rule that says which
41+
two notices are the same notice, the cap on a visible stack, and the queue's
42+
transitions. `client/ui/toast.tsx` owns the card, the timers, the announcement
43+
and the host.
44+
45+
## What a toast is not
46+
47+
Two kinds of error stay where they are, and this decision does not move them.
48+
49+
**A field's own error.** A validation message belongs to the control it
50+
describes: `WebuiField` connects the message to the control with `aria-describedby`
51+
and marks the control `aria-invalid`. A toast is not addressable by a control and
52+
disappears on its own, so a field error moved into one would be lost to the user
53+
who is still typing in the field it is about.
54+
55+
**A state that stays.** A quota banner, a goal banner, the notice about a
56+
session deleted on another device, a warning inside a message body: these state a
57+
fact that is true until the fact changes. They are states, they belong to the
58+
surface that owns them, and they are drawn in place.
59+
60+
**A read that failed** is different from both, and this decision moves it. When
61+
the content a surface was going to show could not be read, the surface says so
62+
through a toast and then shows what it can — usually its content path with
63+
nothing in it, which is the same thing the user sees before the read began. The
64+
notice is transient because the condition is: a read that fails once and succeeds
65+
on the next attempt has no business owning a permanent block of layout. The
66+
surface's own empty state, where it has one, still describes what is absent.
67+
68+
## The queue's rules
69+
70+
- **Tone.** `info`, `success`, `warning`, `error`. The tone decides the colour,
71+
the glyph and how loudly the notice is announced. An error interrupts; the
72+
others wait their turn.
73+
- **Lifetime.** A confirmation is read at a glance and goes quickly; a failure
74+
carries a sentence the user may read twice and stays longer. A notice that
75+
offers an action waits for the answer — it does not time out — because a retry
76+
the user has not reached yet must not disappear.
77+
- **Repetition.** A notice that is said twice is said once: the second saying
78+
refreshes the first in place and restarts its countdown. Two notices are the
79+
same notice when their tone and their words are the same.
80+
- **The stack.** A handful of toasts are on screen at once, and the oldest leaves
81+
when the stack grows past that. A toast that stacks without a bound is a second
82+
page of content, and the user ends up reading the oldest message while the
83+
newest one is the only one that matters.
84+
85+
## Announcement
86+
87+
The card that carries a notice announces it: an error is an `alert`, the rest
88+
are a `status`. There is one announcer per notice, and the migration removed the
89+
inline announcer that used to sit next to it — a notice said twice is a notice
90+
heard twice.
91+
92+
## The queue and the surfaces that raise notices
93+
94+
The client mounts one queue per document, at the composition root, and the host
95+
that draws it is a sibling of the application. A surface rendered on its own —
96+
a server render, an isolated preview — has no queue to raise a notice into.
97+
That is a wiring mistake, and it is reported: the notice is raised into a
98+
remembered record and the client is told once that there was nowhere to draw
99+
it. It is not the reason a surface fails to render.
100+
101+
## Overlay status
102+
103+
A toast is not a managed overlay. It is not dismissed by Escape, it never takes
104+
focus on its own, and it does not register with the overlay coordinator. A layer that
105+
answered a dismissal would be a layer the user had to dismiss, and a toast the
106+
user has to dismiss is a dialog.
107+
108+
## The notice crosses a modal
109+
110+
A modal makes the page behind it inert, so that a keyboard reader and a screen
111+
reader stay inside it. The notice channel is not that page: it is where the
112+
failure a modal reports is said. Left inside the modal's exclusive zone, the
113+
notice would be silenced exactly when it mattered — an inert subtree is also
114+
gone from the accessibility tree, so the `alert` would never be heard — and
115+
the action it carries would be unreachable.
116+
117+
The client's answer is a marker: `data-webui-live-surface`. A surface that
118+
carries it stays live across a modal. The platform layer keeps such a surface
119+
out of the modal's exclusive zone: it is not made inert, focus may rest on it,
120+
and it joins the modal's tab ring. The overlay listener treats a press on it as
121+
a press on the notice rather than a press on the page behind, so answering a
122+
notice's retry does not close the dialog that reported the failure.
123+
124+
The toast host carries the marker. It is the only surface that does today; the
125+
marker is a contract rather than a toast feature, so the next surface that has
126+
to cross a modal can carry it too.
127+
128+
## Adoption
129+
130+
The contract is adopted by rendering it: `useWebuiToastError` and
131+
`useWebuiToastSuccess` say a message once, when it appears, and leave the layout
132+
alone. A caller keeps the state it holds — the state is what a retry and a
133+
re-render need — and stops drawing it.
134+
135+
Completion is not proved by a gate. The presentation inventory records that the
136+
contract exists and that the surfaces using it were reviewed; the coverage of
137+
the migration is a source review, and the appearance is a browser check.

0 commit comments

Comments
 (0)