From 5427abdb5e5117d6cce340284e40c6211f4293a2 Mon Sep 17 00:00:00 2001
From: Dennis V <2119348+dzianisv@users.noreply.github.com>
Date: Tue, 23 Jun 2026 16:31:58 +0000
Subject: [PATCH] feat(ux): SSE disconnect/reconnect banner in session view
(#42)
MIME-Version: 1.0
Content-Type: text/plain; charset=UTF-8
Content-Transfer-Encoding: 8bit
Shows amber 'Reconnecting… (attempt N)' banner when SSE is down.
Shows brief green 'Connected ✓' flash on reconnect (useRef transition
to avoid atomic state reset bug where lastDisconnectAt resets with
reconnectAttempts in the same set() call).
Banner disappears automatically when SSE is stable.
Updates CUA scenario to check for both ASCII and Unicode ellipsis.
Closes #42
Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com>
---
app/session/[id].tsx | 40 ++++++++++++++++++++++++++++++++++++
scripts/android-cua-smoke.py | 11 +++++++---
2 files changed, 48 insertions(+), 3 deletions(-)
diff --git a/app/session/[id].tsx b/app/session/[id].tsx
index 41a3c78..def808c 100644
--- a/app/session/[id].tsx
+++ b/app/session/[id].tsx
@@ -125,6 +125,11 @@ export default function SessionScreen() {
const shortDir = getShortDir(currentSession?.directory)
const [showScrollButton, setShowScrollButton] = useState(false)
+ // SSE reconnect banner
+ const reconnectAttempts = useEvents((s) => s.reconnectAttempts)
+ const [showConnectedFlash, setShowConnectedFlash] = useState(false)
+ const prevReconnecting = useRef(false)
+
// Voice input — transcript appends to the text input on completion
const speech = useSpeech(
useCallback((text: string) => {
@@ -363,6 +368,19 @@ export default function SessionScreen() {
if (!loadingMore) loadingTriggered.current = false
}, [loadingMore])
+ // Detect reconnecting → stable transition for the "Connected ✓" flash.
+ // reconnectAttempts and lastDisconnectAt reset in the same set() call, so we
+ // can't use lastDisconnectAt alone; a useRef tracks the prior reconnecting state.
+ useEffect(() => {
+ const isReconnecting = reconnectAttempts > 0
+ if (prevReconnecting.current && !isReconnecting) {
+ setShowConnectedFlash(true)
+ const t = setTimeout(() => setShowConnectedFlash(false), 2000)
+ return () => clearTimeout(t)
+ }
+ prevReconnecting.current = isReconnecting
+ }, [reconnectAttempts])
+
const handlePermissionReply = async (requestID: string, reply: "once" | "always" | "reject") => {
if (!sessionClient || !sessionID) return
// Snapshot for rollback
@@ -486,6 +504,18 @@ export default function SessionScreen() {
onClose={() => setShowInfo(false)}
/>
+ {/* SSE reconnect/connected banner */}
+ {reconnectAttempts > 0 && (
+
+ Reconnecting\u2026 (attempt {reconnectAttempts})
+
+ )}
+ {showConnectedFlash && reconnectAttempts === 0 && (
+
+ Connected ✓
+
+ )}
+
{isLoading ? (
@@ -821,4 +851,14 @@ const s = StyleSheet.create({
dirBadgeDark: { backgroundColor: "#1a1a1a" },
dirText: { fontSize: 12, color: "#666666", fontWeight: "500" },
dirTextDark: { color: "#888888" },
+
+ // SSE reconnect/connected banner
+ banner: {
+ paddingHorizontal: 16,
+ paddingVertical: 6,
+ alignItems: "center",
+ },
+ bannerReconnecting: { backgroundColor: "#92400e" },
+ bannerConnected: { backgroundColor: "#065f46" },
+ bannerText: { color: "#ffffff", fontSize: 13, fontWeight: "500" },
})
diff --git a/scripts/android-cua-smoke.py b/scripts/android-cua-smoke.py
index 5de42ad..88369cf 100755
--- a/scripts/android-cua-smoke.py
+++ b/scripts/android-cua-smoke.py
@@ -1029,7 +1029,12 @@ def run_scenario_sse_disconnect_banner(opencode_url: str, model: str, include_ui
_sleep(5.0) # wait for SSE timeout detection in app
# Phase 3: DETERMINISTIC — check UI XML for reconnect text
- has_banner = check_ui_text("Reconnecting") or check_ui_text("reconnect") or check_ui_text("offline")
+ has_banner = (
+ check_ui_text("Reconnecting") or
+ check_ui_text("Reconnecting\u2026") or
+ check_ui_text("reconnect") or
+ check_ui_text("offline")
+ )
results["banner_appeared"] = {"status": "success" if has_banner else "fail",
"detail": "uiautomator XML check"}
print(f" [DETERMINISTIC] banner_appeared={has_banner}")
@@ -1057,7 +1062,7 @@ def run_scenario_sse_disconnect_banner(opencode_url: str, model: str, include_ui
deadline = time.time() + 40
while time.time() < deadline:
_sleep(3.0)
- if not (check_ui_text("Reconnecting") or check_ui_text("reconnect") or check_ui_text("offline")):
+ if not (check_ui_text("Reconnecting") or check_ui_text("Reconnecting\u2026") or check_ui_text("reconnect") or check_ui_text("offline")):
banner_gone = True
break
results["banner_dismissed"] = {"status": "success" if banner_gone else "fail",
@@ -1144,7 +1149,7 @@ def run_scenario_backgrounded_permission_notification(opencode_url: str, model:
appeared = check_notification_drawer(APP_PACKAGE, timeout=15)
if not appeared:
# Fallback: match the real notification copy from events.ts / notifications.ts
- appeared = check_notification_drawer("Permission requested", timeout=5) or \
+ appeared = check_notification_drawer("Agent needs approval", timeout=5) or \
check_notification_drawer("A tool needs your approval", timeout=5)
results["notification_appeared"] = {