Skip to content

Commit 494931d

Browse files
merge: bring Mini App hosting to current webui baseline
# Conflicts: # packages/webui/src/client/features/workspace/ui/WorkspacePanels.tsx
2 parents ff1ded8 + ba946c0 commit 494931d

54 files changed

Lines changed: 1686 additions & 300 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.

‎.gitleaks.toml‎

Lines changed: 7 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -19,6 +19,13 @@ paths = ['''(^|/)packages/(browser-extension/test/protocol\.test\.ts|webui/test/
1919
regexTarget = "secret"
2020
regexes = ['''^00112233445566778899aabbccddeeff$''']
2121

22+
[[rules.allowlists]]
23+
description = "WebUI shortcut storage key name, not a credential"
24+
condition = "AND"
25+
paths = ['''(^|/)packages/webui/src/client/projection/common/shortcuts\.ts$''']
26+
regexTarget = "match"
27+
regexes = ['''^SHORTCUT_STORAGE_KEY = "webui-shortcuts\.v1"$''']
28+
2229
[[rules.allowlists]]
2330
description = "Documented keyboard shortcut"
2431
condition = "AND"

‎packages/webui/docs/README.md‎

Lines changed: 4 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -90,7 +90,10 @@ is the sole decision record for Mini App hosting. ADR 0021 records the model pic
9090
with two states, and the cascade it replaces. ADR 0024 records the client-side workspace boundary
9191
for a diff body: a diff entry outside the workspace keeps its name and its counts and loses its
9292
content, the boundary is lexical, and a diff whose workspace root the client does not know is not
93-
withheld. A new decision takes the next free number.
93+
withheld. ADR 0026 records the composer's keyboard bindings as the settings page stores them: one
94+
canonical notation, the settings page owns the change, the composer reads the stored binding when
95+
a key is pressed, and only the actions that already have keyboard meaning are bindable. A new
96+
decision takes the next free number.
9497

9598
| Retired | Was | Why it is not in the log | Where the live facts are |
9699
| --- | --- | --- | --- |

‎packages/webui/docs/adr/0022-webui-workspace-panel-is-a-browser-tab-strip.md‎

Lines changed: 3 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -119,8 +119,9 @@ owns rather than a thing the Agent drives.
119119
surface could not be a tab's content; a launcher can. The `browser` kind is not one of the four:
120120
it is deleted, and the tab it used to be is replaced by the web tabs.
121121
- **The URL box belongs to the new tab page.** There is no persistent address bar. A web tab carries
122-
a toolbar instead: back, forward, reload, and the URL shown read-only. A person who wants another
123-
URL opens a new tab and types it there.
122+
a toolbar instead: back, forward, reload, home to return to the new tab page in the same tab, and
123+
the URL shown read-only. Back and forward are disabled when the page has no entry in that
124+
direction. A person who wants another URL opens a new tab and types it there.
124125
- **Every web tab goes through the same switch.** The person's launcher path reads the same surface
125126
switch as the Agent's path, and a tab on a surface whose switch is off does not open.
126127
- **A web tab is titled by the page and glyphed by default.** The title is the one the provider
Lines changed: 77 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,77 @@
1+
# The composer follows the bindings the settings page stores
2+
3+
**Status:** Accepted and implemented.
4+
**Date:** 2026-10-26.
5+
6+
The settings page's **快捷键** tab was a label with no panel behind it: `shortcuts` was the one
7+
preference entry declared `disabled: true` next to 语音 and 连接, and the composer decided what
8+
Enter meant in code — `resolveWebuiComposerEnterAction` answered "submit" or "newline" from the
9+
modifier flags alone. A user could not see that rule, could not change it, and a page that lists
10+
actions it cannot bind is a page that lies.
11+
12+
The settings page now **lists the composer's keyboard actions, shows the key each one is bound to,
13+
and changes the binding**. Two actions are bindable, because two actions have keyboard meaning
14+
today:
15+
16+
| Action | Built-in binding |
17+
| --- | --- |
18+
| 发送消息 — send the message | `Enter` |
19+
| 换行 — break the line | `Shift+Enter` |
20+
21+
## What a binding is
22+
23+
A binding is **one canonical string**: the modifiers in a fixed order, then the base key —
24+
`Control+Shift+Enter`. The same string is what the browser stores, what the page shows and what the
25+
composer matches against, so a binding cannot mean one thing on the page and another in the
26+
composer. The notation, the matching, the conflict rule and the fallback to the built-in bindings
27+
live in one module (`client/projection/common/shortcuts.ts`) that both sides read, because both sides
28+
have to agree on what a binding is.
29+
30+
The string is not a keymap. It is the smallest representation that lets a stored preference and a
31+
key press be compared without either side inventing its own vocabulary.
32+
33+
## Who owns the change, and who reads it
34+
35+
The **settings page owns the change**: it is the only surface that rebinds an action, and it hands
36+
the result to the settings owner, which persists it under `webui-shortcuts.v1` — the same habit as
37+
`webui-theme`, `mavis-locale` and `file_line_wrap`, and a key of its own so it cannot collide with
38+
the language preference.
39+
40+
The **composer reads it when a key is pressed**, not when it renders. The settings page and the
41+
composer are open at the same time, and a binding changed in the page has to be the binding the
42+
next key press obeys; a value read at press time is that, and a value held in component state would
43+
be whatever it was when the composer last rendered.
44+
45+
The composer therefore keeps no shortcut state of its own. What it owns is the rule: a press that
46+
names the send binding submits, a press that names the newline binding breaks the line, and a press
47+
that names neither is left to the browser — which is why a key the user unbound stops doing the
48+
composer's business and starts doing the textarea's.
49+
50+
## What the page refuses
51+
52+
- **A modifier on its own.** A binding is a combination; capturing `Shift` alone would leave a
53+
shortcut that fires whenever the user holds a key down.
54+
- **A combination another action already holds.** The page says which action holds it and keeps
55+
both bindings as they were.
56+
57+
## What this is not
58+
59+
- **Not a keybinding framework.** Two actions, one notation, one page. The voice, connection and
60+
personalization tabs stay disabled, and nothing here changes the server or the runtime.
61+
- **Not a new binding for stopping a generation.** 停止生成 is a button and nothing else, and the
62+
page says so. `Escape` is already spoken for by the menus it closes — the mention menu, the slash
63+
menu, the permission menu, goal mode and plan mode all claim it — so a shortcut for it would
64+
fight them rather than serve the user.
65+
- **Not the language pack.** This page's strings are hard-coded Chinese, like the rest of the
66+
settings page on this baseline. Where the settings page's language pack lands, these strings move
67+
with the others; the two changes touch the same tab table and nothing else.
68+
69+
## Consequences
70+
71+
- The composer no longer hard-codes what Enter means. `resolveWebuiComposerEnterAction` is retired:
72+
the rule it encoded is the default configuration, expressed as the same values the page shows.
73+
- The built-in behaviour is unchanged. With nothing stored, `Enter` sends and `Shift+Enter` breaks
74+
the line, and a press that names neither binding is left to the browser — which is what the
75+
retired rule did with every modified Enter.
76+
- A newline bound to a key the browser does not break the line on (`Control+J`, say) is broken by
77+
the composer itself, because a binding the user can set is a binding the user can rely on.

‎packages/webui/docs/browser-use/01-capability-matrix.md‎

Lines changed: 2 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -443,7 +443,7 @@ that closes it. It is a gap, not a denial.
443443
| The page a person opened lands in the tab that asked for it | **implemented** | the launcher dispatches `request-web-page` before the call, so a `watchBrowserWebTabs` snapshot that lands mid-call finds the tab that asked; `open-web-tab` binds the page to the named tab, and to a launcher that is waiting when the command names none. Either order, one page, one tab — and the tab is not activated, so a person who looked elsewhere is not moved. `packages/webui/src/client/features/workspace/projection/workspace-panel-state.ts` |
444444
| A refusal keeps the launcher, and says why | **implemented** | the runtime's own message is carried on the launcher's `request.error` and rendered under the URL box, so the box stays and the person can type something else. Nothing about the launcher's refusal is worded differently from the Agent's |
445445
| The URL box exists only on the new tab page | **implemented** | the launcher renders exactly one input, and a web tab renders none: the browser business's host shows the page's URL in the tab's toolbar as text, not as a field. `packages/webui/src/client/features/browser/ui/BrowserPanelHost.public.tsx` |
446-
| A web tab carries back, forward, reload and the URL | **implemented** | the toolbar is rendered by the browser business's **published host** — ADR 0022's component table already gave it that home ("binds a tab to a target, shows the picture and the toolbar") — and it is drawn over the one thing the strip hands it: the tab's target, and the url the runtime published with it. The strip owns the row of tabs; a workspace component drawing a foreign business's page chrome would invert the dependency rule. `BrowserPanelHost.public.tsx` |
446+
| A web tab carries back, forward, reload, home and the URL | **implemented** | the toolbar is rendered by the browser business's **published host** — ADR 0022's component table already gave it that home ("binds a tab to a target, shows the picture and the toolbar") — and it is drawn over the one thing the strip hands it: the tab's target, the url and navigation-history availability the runtime publishes with it. Back and forward are disabled when no entry exists in that direction; home replaces the current tab's content with its launcher. The strip owns the row of tabs; a workspace component drawing a foreign business's page chrome would invert the dependency rule. `BrowserPanelHost.public.tsx` |
447447
| The toolbar's three presses go through the existing command path, and name the page | **implemented** | one request per press on the browser family's own `sendBrowserCommand` operation — no second operation, no second route. `WebuiBrowserCommandRequest` gained an optional `target`, and the handler routes a named target to `WebuiBrowserPort.sendCommandToTarget`. The target is what makes the press mean the page in front of the person: an absent target still resolves the surface's selection (a run's, or the panel's own cell), which is a page the tab may not be showing. `packages/webui/src/server/operation/browser.ts`, `packages/webui/src/server/operation/handlers/browser.ts`, `packages/webui/src/runtime/browser/browser-service.ts` |
448448
| A toolbar press that cannot be honoured says so, and leaves the page alone | **implemented** | the refusal travels out with the runtime's own code and message: `browser_target_stale` for a reference recorded before a move, `browser_capability_denied` when the page's history has nothing to go to. The service checks the surface switch and the command gate first, so a refusal names the switch rather than the page. The client helper does not catch: `browser-web-tab-toolbar.test.tsx` |
449449
| Back, forward and reload move a real page | **implemented** | `packages/webui/test/integration/isolated-chromium-two-pages.test.ts`, "the web tab's toolbar, on a real page": three presses through the client's own helper, the operation's validator and handler, the service and a real isolated Chromium. Observed: `back` lands on the page it came from, `forward` on the one it left, `reload` re-reads the page it is on, the generation advances with each move so the pre-move reference is refused with `browser_target_stale` while the tab's current reference still works, and a `back` with nothing behind it is refused with `browser_capability_denied` with the page untouched |
@@ -602,4 +602,4 @@ the thing that changes — which is what #207 does.
602602
when it is refreshed, is not decided.
603603
- **The bridged surfaces have no tab model at all.** Everything measured here is the isolated
604604
Chromium. The extension bridge exposes one visible tab per browser, so a tab strip over it is a
605-
different question and was not asked.
605+
different question and was not asked.

‎packages/webui/docs/client/composer.md‎

Lines changed: 8 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -13,6 +13,7 @@ needs its own behavioural evidence — browser or assembled-application, per
1313
| Queue mirror | One selected destination with a generation, not a per-session mirror map; home display gates preserved | Refresh view interest without recreating the owner | Enqueue / delete / read commit only to an eligible destination generation; settlement obligations survive a selection change |
1414
| Retry | Last `{session, message}` retained, shown only for the matching session and refusal, never retargeted by adoption | Retained | Retry enters the ordinary send/enqueue workflow; goals and commands create no turn-retry record |
1515
| Mention and slash | Mention retained until an explicit command; slash derived from draft and caret | Recreate caret, index and listeners | Selection/dismiss controls the range; no universal switch or submit reset |
16+
| Send and newline keys | The bindable keys are the stored bindings, not a rule in the component; the settings page owns the change | The stored binding is read when a key is pressed, so a rebinding takes effect without a remount | 发送消息 submits and 换行 breaks the line; a press that names neither binding is left to the browser; an open IME composition belongs to the composition |
1617

1718
## Where the pieces live
1819

@@ -29,3 +30,10 @@ needs its own behavioural evidence — browser or assembled-application, per
2930
React-side store binding.
3031
- The submission path itself (creation, admission, send-or-queue) is
3132
[`submission.md`](submission.md).
33+
- The keyboard bindings live in the settings business: the page is
34+
`client/features/settings/ui/ShortcutsSettings.tsx` with its own model in
35+
`client/features/settings/projection/settings-shortcuts.ts`, and the notation both the page and the
36+
composer read is `client/projection/common/shortcuts.ts`. The composer keeps no binding state —
37+
it reads the stored binding when a key is pressed. ADR
38+
[`../adr/0026-the-composer-follows-the-bindings-the-settings-page-stores.md`](../adr/0026-the-composer-follows-the-bindings-the-settings-page-stores.md)
39+
records why.

‎packages/webui/docs/client/ownership.md‎

Lines changed: 2 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -133,7 +133,8 @@ proposing to move this state to the server.
133133
writes panel state through `stateFor(sessionId)` and `dispatch(sessionId, …)`, so a tab opened in
134134
one session is not in another's. A tab is a stable slot whose content can be replaced in place, and
135135
the URL box exists only inside the new tab page — a web tab carries a toolbar (back, forward,
136-
reload, the URL read-only) instead. The run's selection and the tab the person is looking at are two
136+
reload, home to its launcher, and the URL read-only) instead. Back and forward are disabled when
137+
their corresponding history entry is absent. The run's selection and the tab the person is looking at are two
137138
cells with one writer each, with one bootstrap: a person who opens a web tab while the run has no
138139
selection gives the run that page, decided by the runtime at the moment the page appears
139140
(`WebuiBrowserTabOpener` — `runtime/browser/browser-service.ts` — `openWebTab`). A tab is a stable

‎packages/webui/src/client/features/browser/ui/BrowserPanel.public.tsx‎

Lines changed: 1 addition & 19 deletions
Original file line numberDiff line numberDiff line change
@@ -68,17 +68,11 @@ import { webuiBrowserPicturePoint } from "../application/browser-commands.js";
6868
const PANEL_COPY = {
6969
title: "浏览器",
7070
frameLabel: "浏览器画面",
71-
frameEmpty: "还没有画面。选择一个标签页,或等待浏览器推送第一帧。",
72-
frameNoTarget: "没有选中的标签页。",
73-
frameUnrenderable: "收到了一帧无法显示的画面数据,已忽略。",
7471
inputLabel: "浏览器输入区",
7572
unauthorized: "隔离浏览器未授权。请在设置的「浏览器」页中打开对应的浏览器开关。",
7673
absent: "当前运行时没有浏览器能力,浏览器面板不可用。",
7774
notReady: "浏览器能力尚未就绪(例如扩展未连接),面板暂时无法显示页面。",
7875
unknown: "当前运行时没有报告浏览器能力,面板按未授权处理。",
79-
changed: (generation: number) =>
80-
`标签页已更新到第 ${generation} 代,之前的输入不再有效。`,
81-
dropped: (count: number) => `已丢弃 ${count} 帧过期画面。`,
8276
} as const;
8377

8478
export interface BrowserPanelProps {
@@ -392,16 +386,6 @@ export function BrowserPanel({
392386
</p>
393387
) : null}
394388

395-
{state?.targetChange ? (
396-
<p className="webui-browser-notice" role="alert" data-testid="browser-panel-target-changed">
397-
{PANEL_COPY.changed(state.targetChange.target.generation)}
398-
</p>
399-
) : null}
400-
{state?.droppedFrameCount ? (
401-
<p className="webui-browser-notice" role="status" data-testid="browser-panel-dropped-frames">
402-
{PANEL_COPY.dropped(state.droppedFrameCount)}
403-
</p>
404-
) : null}
405389
{state?.error ? (
406390
<p
407391
className="webui-browser-notice is-error"
@@ -446,9 +430,7 @@ export function BrowserPanel({
446430
onKeyDown={onKeyDown}
447431
/>
448432
) : (
449-
<p className="webui-browser-empty" role="status" data-testid="browser-panel-frame-empty">
450-
{frame ? PANEL_COPY.frameUnrenderable : viewed ? PANEL_COPY.frameEmpty : PANEL_COPY.frameNoTarget}
451-
</p>
433+
<div data-testid="browser-panel-frame-empty" aria-hidden="true" />
452434
)}
453435
</div>
454436
</section>

0 commit comments

Comments
 (0)