Integrate Launcher hosts into sync status
Move the Launcher Host access control into Agent Zero's existing sync-status cluster, render one computer glyph per gateway, and remove the tooltip, expanding label, and animation. Reuse the standard WebUI radius tokens and cover the new extension surface and compact indicator contract with focused tests and DOX updates.
Alessandro committed
Jul 15, 2026 at 14:38 UTC
8fed806a1f9df4380aaf29dbf61e9e370e6d5871
7 files changed
+65
-59
plugins/_a0_connector/AGENTS.md
+7
-5
@@ -39,14 +39,16 @@
39
- Launcher gateway scopes expose file reading and writing separately. File
40
writing depends on reading, and Code execution depends on file writing. Keep
41
older gateway declarations without `file_write` read/write compatible.
42
-- The `chat-top-end` Launcher gateway extension renders only when the user agent
42
+- The `sync-status-end` Launcher gateway extension renders only when the user agent
43
includes `A0-Launcher/`. It may show status, master/scope controls,
44
preparation errors, and Emergency disconnect; standard browser sessions must
45
- not expose it. Keep its trigger icon-only at rest and reveal the status label
46
- on hover, keyboard focus, or while open. Connected and disconnected states
47
- share the computer glyph; the adjacent status dot communicates connectivity.
45
+ not expose it. Keep its trigger inside the existing sync indicator, render one
46
+ computer glyph per connected Launcher gateway, and retain one muted glyph
47
+ while disconnected so the control remains reachable. Do not add a native
48
+ tooltip, visible status label, count badge, or animated expansion.
49
Keep the open popover above the compact right-canvas rail and report control
49
- failures through the shared notification system.
50
+ failures through the shared notification system. Use the standard WebUI
51
+ border-radius tokens for its controls and popover.
52
- File operation results may arrive as chunked JSON/base64
53
`connector_file_op_result` frames; resolve the pending file operation only
54
after all chunks for the `op_id` are assembled.
plugins/_a0_connector/extensions/webui/sync-status-end/launcher-gateway.html
renamed
+23
-33
@@ -16,15 +16,19 @@
16
type="button"
17
class="launcher-gateway-trigger"
18
:class="`is-${$store.launcherGateway.state}${$store.launcherGateway.open ? ' is-open' : ''}`"
19
- :title="`Host access: ${$store.launcherGateway.stateLabel}`"
19
:aria-label="`Host access: ${$store.launcherGateway.stateLabel}`"
20
aria-haspopup="dialog"
21
:aria-expanded="$store.launcherGateway.open.toString()"
22
@click.stop="$store.launcherGateway.open = !$store.launcherGateway.open"
23
>
25
- <span class="launcher-gateway-dot" aria-hidden="true"></span>
26
- <span class="material-symbols-outlined" aria-hidden="true" x-text="$store.launcherGateway.stateIcon"></span>
27
- <span class="launcher-gateway-trigger-label" x-text="$store.launcherGateway.stateLabel"></span>
24
+ <template x-for="(gateway, index) in ($store.launcherGateway.status?.gateways || [])" :key="gateway.id || index">
25
+ <span class="material-symbols-outlined launcher-gateway-host-icon" aria-hidden="true">computer</span>
26
+ </template>
27
+ <span
28
+ class="material-symbols-outlined launcher-gateway-host-icon is-placeholder"
29
+ x-show="!($store.launcherGateway.status?.gateways || []).length"
30
+ aria-hidden="true"
31
+ >computer</span>
32
</button>
33
34
<section
@@ -115,35 +119,24 @@
119
120
<style>
121
#time-date-container:has(.launcher-gateway-trigger.is-open) { z-index: 3500; }
118
- .launcher-gateway-root { position: relative; }
122
+ .launcher-gateway-root { position: relative; display: inline-flex; align-items: center; }
123
.launcher-gateway-trigger {
120
- display: inline-flex; align-items: center; gap: 0; min-height: 2rem;
121
- padding: .25rem .55rem; border: 1px solid var(--color-border); border-radius: 999px;
122
- color: var(--color-text); background: color-mix(in srgb, var(--color-panel) 92%, transparent);
123
- cursor: pointer; font: inherit; font-size: .75rem;
124
- }
125
- .launcher-gateway-trigger .material-symbols-outlined { font-size: 1rem; }
126
- .launcher-gateway-trigger-label {
127
- max-width: 0; margin-left: 0; overflow: hidden; opacity: 0; white-space: nowrap;
128
- transition: max-width 180ms cubic-bezier(.4, 0, .2, 1),
129
- margin-left 180ms cubic-bezier(.4, 0, .2, 1),
130
- opacity 120ms ease-out;
124
+ display: inline-flex; align-items: center; gap: .12rem; min-height: 1.4rem;
125
+ padding: .08rem; border: 0; border-radius: var(--border-radius-sm);
126
+ color: var(--color-text-secondary); background: transparent; cursor: pointer;
127
}
132
- .launcher-gateway-root:hover .launcher-gateway-trigger-label,
133
- .launcher-gateway-trigger:focus-visible .launcher-gateway-trigger-label,
134
- .launcher-gateway-trigger.is-open .launcher-gateway-trigger-label {
135
- max-width: 8rem; margin-left: .35rem; opacity: 1;
136
- }
137
- .launcher-gateway-dot { width: .45rem; height: .45rem; margin-right: .35rem; border-radius: 50%; background: #7b8491; }
138
- .launcher-gateway-trigger.is-connected .launcher-gateway-dot { background: #45b86b; }
139
- .launcher-gateway-trigger.is-connecting .launcher-gateway-dot { background: #5b9cff; }
140
- .launcher-gateway-trigger.is-paused .launcher-gateway-dot { background: #d6a43d; }
141
- .launcher-gateway-trigger.is-needs_action .launcher-gateway-dot,
142
- .launcher-gateway-trigger.is-multiple_hosts .launcher-gateway-dot { background: #e49a35; }
143
- .launcher-gateway-trigger.is-error .launcher-gateway-dot { background: #db5b61; }
128
+ .launcher-gateway-trigger:focus-visible { outline: 2px solid var(--color-primary); outline-offset: 2px; }
129
+ .launcher-gateway-host-icon { font-size: 1rem; }
130
+ .launcher-gateway-trigger.is-connected .launcher-gateway-host-icon { color: #00c340; }
131
+ .launcher-gateway-trigger.is-connecting .launcher-gateway-host-icon { color: #5b9cff; }
132
+ .launcher-gateway-trigger.is-paused .launcher-gateway-host-icon { color: #d6a43d; }
133
+ .launcher-gateway-trigger.is-needs_action .launcher-gateway-host-icon,
134
+ .launcher-gateway-trigger.is-multiple_hosts .launcher-gateway-host-icon { color: #e49a35; }
135
+ .launcher-gateway-trigger.is-error .launcher-gateway-host-icon { color: #db5b61; }
136
+ .launcher-gateway-host-icon.is-placeholder { opacity: .55; }
137
.launcher-gateway-popover {
138
position: absolute; top: calc(100% + .45rem); right: 0; z-index: 1200; width: min(20rem, calc(100vw - 2rem));
146
- padding: .8rem; border: 1px solid var(--color-border); border-radius: .7rem;
139
+ padding: .8rem; border: 1px solid var(--color-border); border-radius: var(--border-radius);
140
background: var(--color-panel); box-shadow: 0 16px 42px rgba(0, 0, 0, .24);
141
}
142
.launcher-gateway-header { display: flex; justify-content: space-between; gap: .75rem; align-items: flex-start; }
@@ -164,9 +157,6 @@
157
.launcher-gateway-notices p { display: flex; gap: .35rem; margin: 0; color: #d79b35; font-size: .75rem; line-height: 1.3; }
158
.launcher-gateway-notices .material-symbols-outlined { font-size: .95rem; }
159
.launcher-gateway-empty { margin: .7rem 0 0; color: var(--color-text-secondary); font-size: .78rem; }
167
- .launcher-gateway-disconnect { width: 100%; margin-top: .75rem; padding: .48rem .65rem; border: 1px solid color-mix(in srgb, #db5b61 55%, var(--color-border)); border-radius: .45rem; background: transparent; color: #db5b61; cursor: pointer; }
160
+ .launcher-gateway-disconnect { width: 100%; margin-top: .75rem; padding: .48rem .65rem; border: 1px solid color-mix(in srgb, #db5b61 55%, var(--color-border)); border-radius: var(--border-radius-sm); background: transparent; color: #db5b61; cursor: pointer; }
161
.launcher-gateway-disconnect:disabled { opacity: .55; cursor: default; }
169
- @media (prefers-reduced-motion: reduce) {
170
- .launcher-gateway-trigger-label { transition: none; }
171
- }
162
</style>
plugins/_a0_connector/webui/launcher-gateway-store.js
-13
@@ -37,19 +37,6 @@ const model = {
37
return labels[this.state] || "Disconnected";
38
},
39
40
- get stateIcon() {
41
- const icons = {
42
- connecting: "sync",
43
- connected: "computer",
44
- paused: "pause_circle",
45
- needs_action: "warning",
46
- error: "error",
47
- multiple_hosts: "devices",
48
- disconnected: "computer",
49
- };
50
- return icons[this.state] || "computer";
51
- },
52
-
40
get hostLabel() {
41
return this.gateway?.host_label || "Launcher host";
42
},
tests/test_a0_connector_launcher_gateway.py
+24
@@ -57,6 +57,30 @@ def test_launcher_gateway_control_errors_use_webui_notifications() -> None:
57
assert 'void toastFrontendError(' in source
58
59
60
+def test_launcher_gateway_indicator_joins_sync_status_without_visual_noise() -> None:
61
+ root = Path(__file__).parents[1]
62
+ source = (
63
+ root
64
+ / "plugins"
65
+ / "_a0_connector"
66
+ / "extensions"
67
+ / "webui"
68
+ / "sync-status-end"
69
+ / "launcher-gateway.html"
70
+ ).read_text(encoding="utf-8")
71
+ sync_status = (root / "webui" / "components" / "sync" / "sync-status.html").read_text(
72
+ encoding="utf-8"
73
+ )
74
+
75
+ assert '<x-extension id="sync-status-end"></x-extension>' in sync_status
76
+ assert ":title=" not in sync_status
77
+ assert 'x-for="(gateway, index)' in source
78
+ assert "launcher-gateway-trigger-label" not in source
79
+ assert "launcher-gateway-dot" not in source
80
+ assert ":title=" not in source
81
+ assert "border-radius: var(--border-radius);" in source
82
+
83
+
84
def test_launcher_gateway_is_fallback_after_context_bound_cli() -> None:
85
context_id = f"ctx-{uuid.uuid4()}"
86
cli_sid = _sid("cli")
tests/test_webui_extension_surfaces.py
+1
@@ -52,6 +52,7 @@ SURFACE_SCENARIOS: list[tuple[str, str]] = [
52
("chat-input-bottom-actions-end", "webui/components/chat/input/bottom-actions-bar.html"),
53
("chat-top-start", "webui/components/chat/top-section/chat-top.html"),
54
("chat-top-end", "webui/components/chat/top-section/chat-top.html"),
55
+ ("sync-status-end", "webui/components/sync/sync-status.html"),
56
("welcome-screen-start", "webui/components/welcome/welcome-screen.html"),
57
("welcome-screen-end", "webui/components/welcome/welcome-screen.html"),
58
("welcome-actions-start", "webui/components/welcome/welcome-screen.html"),
webui/components/sync/AGENTS.md
+4
-1
@@ -7,12 +7,15 @@
7
## Ownership
8
9
- `sync-store.js` owns sync status state.
10
-- `sync-status.html` owns sync indicator markup.
10
+- `sync-status.html` owns sync indicator markup and the `sync-status-end`
11
+ extension point for connection-related controls.
12
13
## Local Contracts
14
15
- Keep sync state compatible with WebSocket state-sync events.
16
- Avoid noisy user-facing alerts for transient sync state unless existing UX expects them.
17
+- Keep the compact status cluster free of native title tooltips; interactive
18
+ extensions must provide accessible names directly.
19
20
## Work Guidance
21
webui/components/sync/sync-status.html
+6
-7
@@ -10,12 +10,17 @@
10
.status-icon {
11
display: inline-flex;
12
align-items: center;
13
+ gap: 0.1rem;
14
}
15
16
.status-icon svg {
17
pointer-events: none;
18
}
19
20
+ .status-icon > x-extension {
21
+ display: contents;
22
+ }
23
+
24
.pending-ring {
25
animation: pendingPulse 1.2s infinite ease-in-out;
26
}
@@ -45,13 +50,6 @@
50
<div
51
class="status-icon"
52
x-create="$store.sync.init()"
48
- :title="$store.sync.mode === 'HEALTHY'
49
- ? 'Connected (push sync healthy)'
50
- : $store.sync.mode === 'HANDSHAKE_PENDING'
51
- ? 'Connecting (waiting for state handshake)'
52
- : $store.sync.mode === 'DEGRADED'
53
- ? 'Degraded (polling fallback)'
54
- : 'Disconnected (waiting to reconnect)'"
53
>
54
<svg viewBox="0 0 30 30" width="20" height="20" aria-label="sync status">
55
<!-- HEALTHY (filled circle) -->
@@ -68,6 +66,7 @@
66
<circle x-show="$store.sync.mode === 'DISCONNECTED'" cx="15" cy="15" r="12"
67
fill="none" stroke="#e40138" stroke-width="3" />
68
</svg>
69
+ <x-extension id="sync-status-end"></x-extension>
70
</div>
71
</template>
72
</div>