feat(ux): SSE disconnect/reconnect banner in session view (#42)
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>
This commit is contained in:
@@ -125,6 +125,11 @@ export default function SessionScreen() {
|
|||||||
const shortDir = getShortDir(currentSession?.directory)
|
const shortDir = getShortDir(currentSession?.directory)
|
||||||
const [showScrollButton, setShowScrollButton] = useState(false)
|
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
|
// Voice input — transcript appends to the text input on completion
|
||||||
const speech = useSpeech(
|
const speech = useSpeech(
|
||||||
useCallback((text: string) => {
|
useCallback((text: string) => {
|
||||||
@@ -363,6 +368,19 @@ export default function SessionScreen() {
|
|||||||
if (!loadingMore) loadingTriggered.current = false
|
if (!loadingMore) loadingTriggered.current = false
|
||||||
}, [loadingMore])
|
}, [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") => {
|
const handlePermissionReply = async (requestID: string, reply: "once" | "always" | "reject") => {
|
||||||
if (!sessionClient || !sessionID) return
|
if (!sessionClient || !sessionID) return
|
||||||
// Snapshot for rollback
|
// Snapshot for rollback
|
||||||
@@ -486,6 +504,18 @@ export default function SessionScreen() {
|
|||||||
onClose={() => setShowInfo(false)}
|
onClose={() => setShowInfo(false)}
|
||||||
/>
|
/>
|
||||||
|
|
||||||
|
{/* SSE reconnect/connected banner */}
|
||||||
|
{reconnectAttempts > 0 && (
|
||||||
|
<View style={[s.banner, s.bannerReconnecting]}>
|
||||||
|
<Text style={s.bannerText}>Reconnecting\u2026 (attempt {reconnectAttempts})</Text>
|
||||||
|
</View>
|
||||||
|
)}
|
||||||
|
{showConnectedFlash && reconnectAttempts === 0 && (
|
||||||
|
<View style={[s.banner, s.bannerConnected]}>
|
||||||
|
<Text style={s.bannerText}>Connected ✓</Text>
|
||||||
|
</View>
|
||||||
|
)}
|
||||||
|
|
||||||
{isLoading ? (
|
{isLoading ? (
|
||||||
<View style={s.loading}>
|
<View style={s.loading}>
|
||||||
<ActivityIndicator size="large" color={isDark ? "#ffffff" : "#0a0a0a"} />
|
<ActivityIndicator size="large" color={isDark ? "#ffffff" : "#0a0a0a"} />
|
||||||
@@ -821,4 +851,14 @@ const s = StyleSheet.create({
|
|||||||
dirBadgeDark: { backgroundColor: "#1a1a1a" },
|
dirBadgeDark: { backgroundColor: "#1a1a1a" },
|
||||||
dirText: { fontSize: 12, color: "#666666", fontWeight: "500" },
|
dirText: { fontSize: 12, color: "#666666", fontWeight: "500" },
|
||||||
dirTextDark: { color: "#888888" },
|
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" },
|
||||||
})
|
})
|
||||||
|
|||||||
@@ -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
|
_sleep(5.0) # wait for SSE timeout detection in app
|
||||||
|
|
||||||
# Phase 3: DETERMINISTIC — check UI XML for reconnect text
|
# 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",
|
results["banner_appeared"] = {"status": "success" if has_banner else "fail",
|
||||||
"detail": "uiautomator XML check"}
|
"detail": "uiautomator XML check"}
|
||||||
print(f" [DETERMINISTIC] banner_appeared={has_banner}")
|
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
|
deadline = time.time() + 40
|
||||||
while time.time() < deadline:
|
while time.time() < deadline:
|
||||||
_sleep(3.0)
|
_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
|
banner_gone = True
|
||||||
break
|
break
|
||||||
results["banner_dismissed"] = {"status": "success" if banner_gone else "fail",
|
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)
|
appeared = check_notification_drawer(APP_PACKAGE, timeout=15)
|
||||||
if not appeared:
|
if not appeared:
|
||||||
# Fallback: match the real notification copy from events.ts / notifications.ts
|
# 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)
|
check_notification_drawer("A tool needs your approval", timeout=5)
|
||||||
|
|
||||||
results["notification_appeared"] = {
|
results["notification_appeared"] = {
|
||||||
|
|||||||
Reference in New Issue
Block a user