Skip to content

Commit 99ddafb

Browse files
authored
feat(welcome ui): smoother transition into NemoClaw chat UI (#10)
1 parent f26153c commit 99ddafb

File tree

6 files changed

+163
-26
lines changed

6 files changed

+163
-26
lines changed

brev/welcome-ui/index.html

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -107,7 +107,7 @@ <h3 class="modal__title">Install OpenClaw</h3>
107107
</div>
108108
<div class="form-field__footer">
109109
<span class="form-field__hint" id="key-hint"></span>
110-
<a class="form-field__help" href="https://build.nvidia.com/models" target="_blank" rel="noopener noreferrer">
110+
<a class="form-field__help" href="https://build.nvidia.com/settings/api-keys" target="_blank" rel="noopener noreferrer">
111111
Get a key at build.nvidia.com
112112
<svg viewBox="0 0 24 24"><path d="M18 13v6a2 2 0 0 1-2 2H5a2 2 0 0 1-2-2V8a2 2 0 0 1 2-2h6"/><polyline points="15 3 21 3 21 9"/><line x1="10" x2="21" y1="14" y2="3"/></svg>
113113
</a>

brev/welcome-ui/server.py

Lines changed: 48 additions & 5 deletions
Original file line numberDiff line numberDiff line change
@@ -24,6 +24,7 @@
2424

2525
LOG_FILE = "/tmp/nemoclaw-sandbox-create.log"
2626
BREV_ENV_ID = os.environ.get("BREV_ENV_ID", "")
27+
_detected_brev_id = ""
2728

2829
_sandbox_lock = threading.Lock()
2930
_sandbox_state = {
@@ -34,14 +35,31 @@
3435
}
3536

3637

38+
def _extract_brev_id(host: str) -> str:
39+
"""Extract the Brev environment ID from a Host header like '80810-xxx.brevlab.com'."""
40+
match = re.match(r"\d+-(.+?)\.brevlab\.com", host)
41+
return match.group(1) if match else ""
42+
43+
44+
def _maybe_detect_brev_id(host: str) -> None:
45+
"""Cache the Brev environment ID from the request Host header (idempotent)."""
46+
global _detected_brev_id
47+
if not _detected_brev_id:
48+
brev_id = _extract_brev_id(host)
49+
if brev_id:
50+
_detected_brev_id = brev_id
51+
52+
3753
def _build_openclaw_url(token: str | None) -> str:
3854
"""Build the externally reachable OpenClaw URL.
3955
4056
Uses the Cloudflare tunnel pattern from nemoclaw-start.sh when
41-
BREV_ENV_ID is available, otherwise falls back to localhost.
57+
BREV_ENV_ID is available (or detected from the request Host header),
58+
otherwise falls back to localhost.
4259
"""
43-
if BREV_ENV_ID:
44-
url = f"https://187890-{BREV_ENV_ID}.brevlab.com/"
60+
brev_id = BREV_ENV_ID or _detected_brev_id
61+
if brev_id:
62+
url = f"https://187890-{brev_id}.brevlab.com/"
4563
else:
4664
url = "http://127.0.0.1:18789/"
4765
if token:
@@ -70,6 +88,21 @@ def _read_openclaw_token() -> str | None:
7088
return None
7189

7290

91+
def _gateway_log_ready() -> bool:
92+
"""True once nemoclaw-start.sh has launched the OpenClaw gateway.
93+
94+
The startup script prints this sentinel *after* ``openclaw gateway``
95+
has been backgrounded and the auth token extracted, so its presence
96+
in the log is a reliable readiness signal — unlike a bare port check
97+
which fires as soon as the forwarding tunnel opens.
98+
"""
99+
try:
100+
with open(LOG_FILE) as f:
101+
return "OpenClaw gateway starting in background" in f.read()
102+
except FileNotFoundError:
103+
return False
104+
105+
73106
def _cleanup_existing_sandbox():
74107
"""Delete any leftover sandbox named 'nemoclaw' from a previous attempt."""
75108
try:
@@ -153,8 +186,14 @@ def _stream_output():
153186

154187
deadline = time.time() + 120
155188
while time.time() < deadline:
156-
if _port_open("127.0.0.1", 18789):
189+
if _gateway_log_ready() and _port_open("127.0.0.1", 18789):
157190
token = _read_openclaw_token()
191+
if token is None:
192+
for _ in range(5):
193+
time.sleep(1)
194+
token = _read_openclaw_token()
195+
if token is not None:
196+
break
158197
url = _build_openclaw_url(token)
159198
with _sandbox_lock:
160199
_sandbox_state["status"] = "running"
@@ -197,11 +236,13 @@ def end_headers(self):
197236
# -- Routing --------------------------------------------------------
198237

199238
def do_POST(self):
239+
_maybe_detect_brev_id(self.headers.get("Host", ""))
200240
if self.path == "/api/install-openclaw":
201241
return self._handle_install_openclaw()
202242
self.send_error(404)
203243

204244
def do_GET(self):
245+
_maybe_detect_brev_id(self.headers.get("Host", ""))
205246
if self.path == "/api/sandbox-status":
206247
return self._handle_sandbox_status()
207248
if self.path == "/api/connection-details":
@@ -240,7 +281,9 @@ def _handle_sandbox_status(self):
240281
with _sandbox_lock:
241282
state = dict(_sandbox_state)
242283

243-
if state["status"] == "creating" and _port_open("127.0.0.1", 18789):
284+
if (state["status"] == "creating"
285+
and _gateway_log_ready()
286+
and _port_open("127.0.0.1", 18789)):
244287
token = _read_openclaw_token()
245288
url = _build_openclaw_url(token)
246289
with _sandbox_lock:

sandboxes/nemoclaw/Dockerfile

Lines changed: 3 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -36,8 +36,9 @@ RUN set -e; \
3636
--bundle \
3737
--format=esm \
3838
--outfile="$UI_DIR/assets/nemoclaw-devx.js"; \
39-
sed -i 's|</head>|<link rel="stylesheet" href="./assets/nemoclaw-devx.css">\n</head>|' "$UI_DIR/index.html"; \
40-
sed -i 's|</head>|<script type="module" src="./assets/nemoclaw-devx.js"></script>\n</head>|' "$UI_DIR/index.html"; \
39+
HASH=$(md5sum "$UI_DIR/assets/nemoclaw-devx.js" | cut -c1-8); \
40+
sed -i "s|</head>|<link rel=\"stylesheet\" href=\"./assets/nemoclaw-devx.css?v=${HASH}\">\n</head>|" "$UI_DIR/index.html"; \
41+
sed -i "s|</head>|<script type=\"module\" src=\"./assets/nemoclaw-devx.js?v=${HASH}\"></script>\n</head>|" "$UI_DIR/index.html"; \
4142
npm uninstall -g esbuild
4243

4344
ENTRYPOINT ["/bin/bash"]

sandboxes/nemoclaw/nemoclaw-start.sh

Lines changed: 25 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -74,23 +74,44 @@ openclaw onboard \
7474
export NVIDIA_API_KEY=" "
7575

7676
GATEWAY_PORT=18789
77-
if [ -z "$BREV_UI_URL" ]; then
78-
BREV_UI_URL="https://${GATEWAY_PORT}0-${BREV_ENV_ID}.brevlab.com"
77+
78+
# Derive the Brev environment ID so we can build the correct gateway origin.
79+
# BREV_UI_URL (if set) points at the *welcome UI* port, not the gateway port,
80+
# so we must always compute the gateway origin separately.
81+
if [ -z "${BREV_ENV_ID:-}" ] && [ -n "${BREV_UI_URL:-}" ]; then
82+
BREV_ENV_ID=$(echo "$BREV_UI_URL" | sed -n 's|.*//[0-9]*-\([^.]*\)\.brevlab\.com.*|\1|p')
83+
fi
84+
85+
if [ -n "${BREV_ENV_ID:-}" ]; then
86+
export OPENCLAW_ORIGIN="https://${GATEWAY_PORT}0-${BREV_ENV_ID}.brevlab.com"
87+
else
88+
export OPENCLAW_ORIGIN="http://127.0.0.1:${GATEWAY_PORT}"
7989
fi
80-
export BREV_UI_URL
8190

8291
python3 -c "
8392
import json, os
8493
cfg = json.load(open(os.environ['HOME'] + '/.openclaw/openclaw.json'))
8594
cfg['gateway']['controlUi'] = {
8695
'allowInsecureAuth': True,
87-
'allowedOrigins': [os.environ['BREV_UI_URL']]
96+
'allowedOrigins': [os.environ['OPENCLAW_ORIGIN']]
8897
}
8998
json.dump(cfg, open(os.environ['HOME'] + '/.openclaw/openclaw.json', 'w'), indent=2)
9099
"
91100

92101
nohup openclaw gateway > /tmp/gateway.log 2>&1 &
93102

103+
# Auto-approve pending device pairing requests so the browser is paired
104+
# before the user notices the "pairing required" prompt in the Control UI.
105+
(
106+
_pair_deadline=$(($(date +%s) + 300))
107+
while [ "$(date +%s)" -lt "$_pair_deadline" ]; do
108+
sleep 0.5
109+
if openclaw devices approve --latest --json 2>/dev/null | grep -q '"ok"'; then
110+
echo "[auto-pair] Approved pending device pairing request."
111+
fi
112+
done
113+
) >> /tmp/gateway.log 2>&1 &
114+
94115
CONFIG_FILE="${HOME}/.openclaw/openclaw.json"
95116
token=$(grep -o '"token"\s*:\s*"[^"]*"' "${CONFIG_FILE}" 2>/dev/null | head -1 | cut -d'"' -f4 || true)
96117

sandboxes/nemoclaw/nemoclaw-ui-extension/extension/index.ts

Lines changed: 31 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -15,7 +15,7 @@ import { injectButton } from "./deploy-modal.ts";
1515
import { injectNavGroup, activateNemoPage, watchOpenClawNavClicks } from "./nav-group.ts";
1616
import { injectModelSelector, watchChatCompose } from "./model-selector.ts";
1717
import { ingestKeysFromUrl, DEFAULT_MODEL, resolveApiKey } from "./model-registry.ts";
18-
import { waitForClient, patchConfig } from "./gateway-bridge.ts";
18+
import { waitForClient, patchConfig, waitForReconnect } from "./gateway-bridge.ts";
1919

2020
function inject(): boolean {
2121
const hasButton = injectButton();
@@ -66,7 +66,37 @@ function applyIngestedKeys(): void {
6666
});
6767
}
6868

69+
/**
70+
* Insert a full-screen loading overlay that covers the OpenClaw UI while the
71+
* gateway connects and auto-pairs the device. The overlay is styled via
72+
* styles.css and is automatically faded out once `data-nemoclaw-ready` is set
73+
* on <body>. We remove it from the DOM after the CSS transition completes.
74+
*/
75+
function showConnectOverlay(): void {
76+
if (document.querySelector(".nemoclaw-connect-overlay")) return;
77+
const overlay = document.createElement("div");
78+
overlay.className = "nemoclaw-connect-overlay";
79+
overlay.setAttribute("aria-live", "polite");
80+
overlay.innerHTML =
81+
'<div class="nemoclaw-connect-overlay__spinner"></div>' +
82+
'<div class="nemoclaw-connect-overlay__text">Auto-approving device pairing. Hang tight...</div>';
83+
document.body.prepend(overlay);
84+
}
85+
86+
function revealApp(): void {
87+
document.body.setAttribute("data-nemoclaw-ready", "");
88+
const overlay = document.querySelector(".nemoclaw-connect-overlay");
89+
if (overlay) {
90+
overlay.addEventListener("transitionend", () => overlay.remove(), { once: true });
91+
setTimeout(() => overlay.remove(), 600);
92+
}
93+
}
94+
6995
function bootstrap() {
96+
showConnectOverlay();
97+
98+
waitForReconnect(30_000).then(revealApp).catch(revealApp);
99+
70100
const keysIngested = ingestKeysFromUrl();
71101

72102
watchOpenClawNavClicks();

sandboxes/nemoclaw/nemoclaw-ui-extension/extension/styles.css

Lines changed: 55 additions & 13 deletions
Original file line numberDiff line numberDiff line change
@@ -597,29 +597,71 @@ main.content {
597597
}
598598

599599
/* ===========================================
600-
Model Switching — Suppress OpenClaw disconnect artifacts
600+
Initial Connection — Hide the entire app until gateway pairing completes.
601+
The OpenClaw UI uses Lit Web Components with Shadow DOM, so external
602+
CSS cannot target error elements inside the shadow root. Instead we
603+
hide the whole <openclaw-app> custom element (which lives in the
604+
light DOM) and show a branded loading overlay on top.
601605
=========================================== */
602606

603-
body.nemoclaw-switching .card.chat > .callout {
604-
display: none !important;
607+
body:not([data-nemoclaw-ready]) openclaw-app {
608+
opacity: 0 !important;
609+
pointer-events: none !important;
605610
}
606611

607-
body.nemoclaw-switching .statusDot:not(.ok) {
608-
visibility: hidden;
612+
/* Loading overlay shown while gateway connects + auto-pairs */
613+
.nemoclaw-connect-overlay {
614+
position: fixed;
615+
inset: 0;
616+
z-index: 9999;
617+
display: flex;
618+
flex-direction: column;
619+
align-items: center;
620+
justify-content: center;
621+
gap: 18px;
622+
background: var(--bg, #12141a);
623+
color: var(--text, #e4e4e7);
624+
font-family: system-ui, -apple-system, sans-serif;
625+
transition: opacity 400ms ease;
609626
}
610627

611-
body.nemoclaw-switching .topbar-status .pill .mono {
612-
visibility: hidden;
628+
:root[data-theme="light"] .nemoclaw-connect-overlay {
629+
background: var(--bg-content, #f8f8fa);
630+
color: #1a1a1a;
613631
}
614632

615-
body.nemoclaw-switching .chat-compose textarea[disabled] {
616-
opacity: 1 !important;
617-
color: var(--text, #e4e4e7) !important;
618-
cursor: text;
633+
body[data-nemoclaw-ready] .nemoclaw-connect-overlay {
634+
opacity: 0;
635+
pointer-events: none;
619636
}
620637

621-
body.nemoclaw-switching .chat-compose__actions button[disabled] {
622-
opacity: 0.6;
638+
.nemoclaw-connect-overlay__spinner {
639+
width: 32px;
640+
height: 32px;
641+
border: 3px solid rgba(118, 185, 0, 0.2);
642+
border-top-color: #76B900;
643+
border-radius: 50%;
644+
animation: nemoclaw-spin 0.8s linear infinite;
645+
}
646+
647+
.nemoclaw-connect-overlay__text {
648+
font-size: 14px;
649+
font-weight: 500;
650+
letter-spacing: -0.01em;
651+
color: var(--muted, #71717a);
652+
}
653+
654+
/* ===========================================
655+
Model Switching — Suppress OpenClaw disconnect artifacts.
656+
The OpenClaw UI renders inside Shadow DOM so we cannot target
657+
individual elements with external CSS. Instead we reduce the
658+
whole app's opacity during the brief config restart.
659+
=========================================== */
660+
661+
body.nemoclaw-switching openclaw-app {
662+
opacity: 0.4 !important;
663+
pointer-events: none !important;
664+
transition: opacity 200ms ease;
623665
}
624666

625667
/* ===========================================

0 commit comments

Comments
 (0)