From 5c285be9b8d4a46204341817be3a1a94819676b6 Mon Sep 17 00:00:00 2001 From: Carson Loyal Date: Thu, 30 Jul 2026 13:57:52 -0500 Subject: [PATCH] fix(mobile): pin the workspace status above the thread list MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The "Syncing threads..." status flashed in and out above the thread list, and shoved every row down and back each time it did. It was the list's ListHeaderComponent — row 0 — in both HomeScreen and ThreadNavigationSidebar. Because `hasSynchronizingShell` re-enters "synchronizing" on every websocket resubscribe and retries expected failures every 250ms, a healthy connection still produced a stream of sub-second flips, and each one mounted or unmounted a row. The status is now pinned outside the scroll view and always mounted, so only its contents change and the list never moves. Three changes make that work: - It never goes away. At rest it reports what synced and when ("Synced 2 environments at 1:20 PM") instead of going blank, so a quiet bar still answers "am I up to date?" rather than leaving the user to infer it from an absence. This needed `readyEnvironmentCount` on WorkspaceState — a boolean could not carry the count — and reuses the snapshot timestamp the model already tracked. - Busy states show a spinner rather than a static glyph, which reads as "data is moving" far more clearly. Static symbols are now only for steady states: synced, offline, error. - The busy/quiet transition is settled before rendering: it must persist 400ms to appear (longer than the 250ms retry cycle, so self-resolving blips never surface) and holds 900ms once shown. Real faults bypass both delays, since delaying those would hide a problem worth seeing. The decision is a pure function so the timing is unit-tested. On iOS the bar needs an explicit header-height offset: the list is what normally sits under the transparent nav bar, so an in-flow sibling would start at y=0 and overlap the status bar. Android's header is already an in-flow sibling and needs none. The full-page empty state keeps its inline status: with no threads on screen it is the only thing explaining why the page is blank, and there are no rows below it to reflow. Co-Authored-By: Claude Opus 5 (1M context) --- apps/mobile/src/features/home/HomeScreen.tsx | 55 +++++----- .../home/WorkspaceConnectionStatus.test.ts | 81 ++++++++++++++ .../src/features/home/WorkspaceStatusBar.tsx | 68 ++++++++++++ .../home/use-settled-workspace-status.test.ts | 56 ++++++++++ .../home/use-settled-workspace-status.ts | 100 ++++++++++++++++++ .../home/workspace-connection-status.ts | 54 ++++++++++ .../threads/ThreadNavigationSidebar.tsx | 37 +++---- apps/mobile/src/state/workspaceModel.ts | 16 ++- 8 files changed, 413 insertions(+), 54 deletions(-) create mode 100644 apps/mobile/src/features/home/WorkspaceStatusBar.tsx create mode 100644 apps/mobile/src/features/home/use-settled-workspace-status.test.ts create mode 100644 apps/mobile/src/features/home/use-settled-workspace-status.ts diff --git a/apps/mobile/src/features/home/HomeScreen.tsx b/apps/mobile/src/features/home/HomeScreen.tsx index 507a39d94a1..f3af417ce9d 100644 --- a/apps/mobile/src/features/home/HomeScreen.tsx +++ b/apps/mobile/src/features/home/HomeScreen.tsx @@ -18,12 +18,14 @@ import type { } from "@t3tools/contracts"; import { useAtomSet, useAtomValue } from "@effect/atom-react"; import { AsyncResult } from "effect/unstable/reactivity"; -import { useCallback, useEffect, useMemo, useRef, useState } from "react"; +import { useCallback, useContext, useEffect, useMemo, useRef, useState } from "react"; import { ActivityIndicator, FlatList, Platform, Pressable, View } from "react-native"; import type { SwipeableMethods } from "react-native-gesture-handler/ReanimatedSwipeable"; import { useSafeAreaInsets } from "react-native-safe-area-context"; import { useThemeColor } from "../../lib/useThemeColor"; +import { HeaderHeightContext } from "@react-navigation/elements"; + import { AppText as Text } from "../../components/AppText"; import { EmptyState } from "../../components/EmptyState"; import type { WorkspaceEnvironment, WorkspaceState } from "../../state/workspaceModel"; @@ -67,6 +69,7 @@ import { } from "./homeThreadList"; import { SwipeableScrollGateProvider, useSwipeableScrollGate } from "./thread-swipe-actions"; import { WorkspaceConnectionStatus } from "./WorkspaceConnectionStatus"; +import { WorkspaceStatusBar } from "./WorkspaceStatusBar"; import { shouldShowWorkspaceConnectionStatus } from "./workspace-connection-status"; /* ─── Types ──────────────────────────────────────────────────────────── */ @@ -187,6 +190,7 @@ function HomeTopContentSpacer() { /* ─── Main screen ────────────────────────────────────────────────────── */ export function HomeScreen(props: HomeScreenProps) { + const navigationHeaderHeight = useContext(HeaderHeightContext) ?? 0; const [groupDisplayStates, setGroupDisplayStates] = useState< ReadonlyMap >(() => new Map()); @@ -867,16 +871,6 @@ export function HomeScreen(props: HomeScreenProps) { catalogState: props.catalogState, projectCount: props.projects.length, }); - const connectionStatus = - shouldShowConnectionStatus && Platform.OS !== "ios" ? ( - - - - ) : null; - if (!hasAnyThreads) { return ( ) : null} - {shouldShowConnectionStatus && Platform.OS === "ios" ? ( + {/* Kept inline here rather than pinned: with no threads on screen + this is the only thing explaining why the page is blank, and + there are no rows below it to reflow. */} + {shouldShowConnectionStatus ? ( ) : null} - {connectionStatus} ); } - const listHeader = ( - <> - {Platform.OS === "ios" ? null : } + // Status lives in the pinned WorkspaceStatusBar below, not in here: as row 0 + // it mounted and unmounted on every flip of the (inherently bouncy) sync + // signal, flashing the status and shoving every row under it down and back. + const listHeader = <>{Platform.OS === "ios" ? null : }; - {shouldShowConnectionStatus && Platform.OS === "ios" ? ( - - - - ) : null} - + // Pinned above the list and always mounted, so state changes swap text in + // place instead of moving rows. On iOS the list is what normally sits under + // the transparent nav bar, so the bar has to clear it explicitly; Android's + // header is already an in-flow sibling and needs no offset. + const statusBar = ( + + + ); // Project scoping lives in the header filter menu (no inline chip row on @@ -990,6 +989,7 @@ export function HomeScreen(props: HomeScreenProps) { if (threadListV2Enabled) { return ( + {statusBar} - {connectionStatus} ); } return ( + {statusBar} {/* Sticky headers are deliberately not wired up: LegendList's JS sticky implementation mispositions pinned headers at mount under iOS automatic content insets (headers render one nav-inset too low until @@ -1084,7 +1084,6 @@ export function HomeScreen(props: HomeScreenProps) { } /> - {connectionStatus} ); } diff --git a/apps/mobile/src/features/home/WorkspaceConnectionStatus.test.ts b/apps/mobile/src/features/home/WorkspaceConnectionStatus.test.ts index 8c3c873cc9e..18cf72dd0fa 100644 --- a/apps/mobile/src/features/home/WorkspaceConnectionStatus.test.ts +++ b/apps/mobile/src/features/home/WorkspaceConnectionStatus.test.ts @@ -2,8 +2,10 @@ import { describe, expect, it } from "vite-plus/test"; import type { WorkspaceState } from "../../state/workspaceModel"; import { + isWorkspaceConnectionStatusBusy, shouldShowWorkspaceConnectionStatus, workspaceConnectionStatusLabel, + workspaceSyncStatusLabel, } from "./workspace-connection-status"; function workspaceState(overrides: Partial = {}): WorkspaceState { @@ -13,6 +15,7 @@ function workspaceState(overrides: Partial = {}): WorkspaceState hasLoadedShellSnapshot: true, hasPendingShellSnapshot: false, hasReadyEnvironment: true, + readyEnvironmentCount: 1, hasConnectingEnvironment: false, connectingEnvironments: [], connectionState: "connected", @@ -85,3 +88,81 @@ describe("workspace connection status", () => { expect(workspaceConnectionStatusLabel(state)).toBe("Loading threads..."); }); }); + +describe("workspace status bar label", () => { + // The bar is always on screen, so a quiet workspace still has to say + // something useful — the plain connection label falls through to + // "Not connected", which would misreport a healthy sync. + it("reports what synced and when at rest", () => { + const state = workspaceState({ + readyEnvironmentCount: 2, + latestCachedSnapshotReceivedAt: "2026-07-30T15:41:00.000Z", + }); + + expect(workspaceSyncStatusLabel(state)).toMatch(/^Synced 2 environments at .+$/); + }); + + it("singularises a lone environment", () => { + const state = workspaceState({ + readyEnvironmentCount: 1, + latestCachedSnapshotReceivedAt: "2026-07-30T15:41:00.000Z", + }); + + expect(workspaceSyncStatusLabel(state)).toMatch(/^Synced 1 environment at .+$/); + }); + + it("omits the time when no snapshot timestamp was recorded", () => { + const state = workspaceState({ readyEnvironmentCount: 3 }); + + expect(workspaceSyncStatusLabel(state)).toBe("Synced 3 environments"); + }); + + it("omits the time when the recorded timestamp is unparseable", () => { + const state = workspaceState({ + readyEnvironmentCount: 1, + latestCachedSnapshotReceivedAt: "not-a-date", + }); + + expect(workspaceSyncStatusLabel(state)).toBe("Synced 1 environment"); + }); + + it("defers to the connection label whenever there is something to report", () => { + expect(workspaceSyncStatusLabel(workspaceState({ hasPendingShellSnapshot: true }))).toBe( + "Syncing threads...", + ); + expect( + workspaceSyncStatusLabel( + workspaceState({ networkStatus: "offline", hasReadyEnvironment: false }), + ), + ).toBe("You are offline"); + }); +}); + +describe("workspace status busy-ness", () => { + // Only these get damped before rendering; everything else is a steady fact. + it("treats in-flight sync and reconnects as busy", () => { + expect(isWorkspaceConnectionStatusBusy(workspaceState({ hasPendingShellSnapshot: true }))).toBe( + true, + ); + expect( + isWorkspaceConnectionStatusBusy(workspaceState({ hasConnectingEnvironment: true })), + ).toBe(true); + }); + + it("does not treat a settled workspace as busy", () => { + expect(isWorkspaceConnectionStatusBusy(workspaceState())).toBe(false); + }); + + it("does not treat real faults as busy, so they surface immediately", () => { + expect( + isWorkspaceConnectionStatusBusy( + workspaceState({ networkStatus: "offline", hasPendingShellSnapshot: true }), + ), + ).toBe(false); + expect( + isWorkspaceConnectionStatusBusy( + workspaceState({ connectionError: "boom", hasPendingShellSnapshot: true }), + ), + ).toBe(false); + }); +}); diff --git a/apps/mobile/src/features/home/WorkspaceStatusBar.tsx b/apps/mobile/src/features/home/WorkspaceStatusBar.tsx new file mode 100644 index 00000000000..361fa5bba86 --- /dev/null +++ b/apps/mobile/src/features/home/WorkspaceStatusBar.tsx @@ -0,0 +1,68 @@ +import { ActivityIndicator, Pressable, View } from "react-native"; + +import { SymbolView, type SFSymbol } from "../../components/AppSymbol"; +import { AppText as Text } from "../../components/AppText"; +import { useThemeColor } from "../../lib/useThemeColor"; +import type { WorkspaceState } from "../../state/workspaceModel"; +import { useSettledWorkspaceStatusBusy } from "./use-settled-workspace-status"; +import { workspaceSyncStatusLabel } from "./workspace-connection-status"; + +/** Leading glyph for a non-busy status; busy states render a spinner instead. */ +function workspaceStatusSymbol(state: WorkspaceState): SFSymbol { + if (state.networkStatus === "offline") return "wifi.slash"; + if (state.connectionError !== null || !state.hasReadyEnvironment) { + return "exclamationmark.triangle"; + } + return "checkmark.circle"; +} + +/** + * Workspace connection / thread-sync status, pinned above the thread list. + * + * This deliberately sits *outside* the list's scroll view. It used to be the + * list's ListHeaderComponent — row 0 — so every sync-state flip mounted and + * unmounted a row, flashing the status and shoving every thread below it down + * and back. Pinned and always mounted, only its contents change. + * + * It also never goes away: at rest it reports what synced and when, so the bar + * answers "am I up to date?" on sight instead of only appearing mid-sync. The + * busy/quiet transition is settled first (see useSettledWorkspaceStatusBusy) + * because the underlying sync signal toggles faster than anyone can read. + */ +export function WorkspaceStatusBar(props: { + readonly state: WorkspaceState; + readonly onPress: () => void; + /** Gutter matching the surrounding list. */ + readonly className?: string; +}) { + const isBusy = useSettledWorkspaceStatusBusy(props.state); + const iconColor = useThemeColor("--color-icon-muted"); + const label = workspaceSyncStatusLabel(props.state); + + return ( + + + {isBusy ? ( + + ) : ( + + )} + + {label} + + + + + ); +} diff --git a/apps/mobile/src/features/home/use-settled-workspace-status.test.ts b/apps/mobile/src/features/home/use-settled-workspace-status.test.ts new file mode 100644 index 00000000000..2ff7123c5eb --- /dev/null +++ b/apps/mobile/src/features/home/use-settled-workspace-status.test.ts @@ -0,0 +1,56 @@ +import { describe, expect, it } from "vite-plus/test"; + +import { + planWorkspaceStatusSettlement, + STATUS_BUSY_ENTER_DELAY_MS, +} from "./use-settled-workspace-status"; + +describe("workspace status settlement", () => { + it("delays going busy instead of switching immediately", () => { + // The sub-second sync blips a healthy connection produces never survive + // this delay — which is the point; they used to strobe the indicator. + expect( + planWorkspaceStatusSettlement({ + rawBusy: true, + settledBusy: false, + holdUntil: 0, + now: 1_000, + }), + ).toEqual({ kind: "schedule", busy: true, delayMs: STATUS_BUSY_ENTER_DELAY_MS }); + }); + + it("does nothing while the shown state already matches", () => { + expect( + planWorkspaceStatusSettlement({ + rawBusy: true, + settledBusy: true, + holdUntil: 5_000, + now: 1_000, + }), + ).toEqual({ kind: "hold" }); + }); + + it("goes quiet immediately once the minimum-visible hold has elapsed", () => { + expect( + planWorkspaceStatusSettlement({ + rawBusy: false, + settledBusy: true, + holdUntil: 900, + now: 1_000, + }), + ).toEqual({ kind: "apply", busy: false }); + }); + + it("defers going quiet for the remainder of the hold", () => { + // Shown at t=1000 with a 900ms floor; a sync that resolves at t=1200 still + // leaves the busy status up for the remaining 700ms rather than blinking. + expect( + planWorkspaceStatusSettlement({ + rawBusy: false, + settledBusy: true, + holdUntil: 1_900, + now: 1_200, + }), + ).toEqual({ kind: "schedule", busy: false, delayMs: 700 }); + }); +}); diff --git a/apps/mobile/src/features/home/use-settled-workspace-status.ts b/apps/mobile/src/features/home/use-settled-workspace-status.ts new file mode 100644 index 00000000000..b031cc76b7a --- /dev/null +++ b/apps/mobile/src/features/home/use-settled-workspace-status.ts @@ -0,0 +1,100 @@ +import { useEffect, useRef, useState } from "react"; + +import type { WorkspaceState } from "../../state/workspaceModel"; +import { isWorkspaceConnectionStatusBusy } from "./workspace-connection-status"; + +/** + * How long a busy status must persist before the bar switches into it. + * + * The shell-sync signal re-enters "synchronizing" on every websocket + * resubscribe and retries expected failures every 250ms, so a healthy + * connection still produces a stream of sub-second sync blips. Longer than that + * retry cycle, so a reconnect that resolves on its own never reaches the bar. + */ +export const STATUS_BUSY_ENTER_DELAY_MS = 400; + +/** + * Once the bar is showing a busy status, keep it there at least this long. + * Without a floor, a sync that resolves right after crossing the enter delay + * would flip the text twice within a few frames — the churn this exists to + * prevent, just moved later. + */ +export const STATUS_BUSY_MIN_VISIBLE_MS = 900; + +export type StatusSettlement = + /** Adopt this busy-ness now. */ + | { readonly kind: "apply"; readonly busy: boolean } + /** Nothing to change and nothing to schedule. */ + | { readonly kind: "hold" } + /** Adopt it, but only if it is still current after `delayMs`. */ + | { readonly kind: "schedule"; readonly busy: boolean; readonly delayMs: number }; + +/** + * Pure settling decision behind useSettledWorkspaceStatusBusy. + * + * Becoming busy is delayed so blips never surface; going quiet is delayed only + * by whatever remains of the minimum-visible hold. A status that is not busy + * but still worth reporting (offline, an error) is a steady fact, so it is + * adopted immediately — suppressing it would hide a real problem. + */ +export function planWorkspaceStatusSettlement(input: { + readonly rawBusy: boolean; + readonly settledBusy: boolean; + /** Timestamp (ms) before which a shown busy status must not be cleared. */ + readonly holdUntil: number; + readonly now: number; +}): StatusSettlement { + if (input.rawBusy === input.settledBusy) { + return { kind: "hold" }; + } + + if (input.rawBusy) { + return { kind: "schedule", busy: true, delayMs: STATUS_BUSY_ENTER_DELAY_MS }; + } + + const remainingHold = input.holdUntil - input.now; + return remainingHold <= 0 + ? { kind: "apply", busy: false } + : { kind: "schedule", busy: false, delayMs: remainingHold }; +} + +/** + * Settles the raw busy signal into one stable enough to render. + * + * Rendering the raw signal is what made the old indicator flash: it toggles far + * faster than a person can read. The label text itself is derived from live + * state, so only the busy/quiet transition is damped here. + */ +export function useSettledWorkspaceStatusBusy(state: WorkspaceState): boolean { + const rawBusy = isWorkspaceConnectionStatusBusy(state); + const [settledBusy, setSettledBusy] = useState(false); + const holdUntilRef = useRef(0); + + useEffect(() => { + const settlement = planWorkspaceStatusSettlement({ + rawBusy, + settledBusy, + holdUntil: holdUntilRef.current, + now: Date.now(), + }); + + if (settlement.kind === "hold") { + return; + } + + if (settlement.kind === "apply") { + setSettledBusy(settlement.busy); + return; + } + + const timeout = setTimeout(() => { + if (settlement.busy) { + holdUntilRef.current = Date.now() + STATUS_BUSY_MIN_VISIBLE_MS; + } + setSettledBusy(settlement.busy); + }, settlement.delayMs); + return () => clearTimeout(timeout); + }, [rawBusy, settledBusy]); + + return settledBusy; +} diff --git a/apps/mobile/src/features/home/workspace-connection-status.ts b/apps/mobile/src/features/home/workspace-connection-status.ts index d8eed4383b1..486e482f33f 100644 --- a/apps/mobile/src/features/home/workspace-connection-status.ts +++ b/apps/mobile/src/features/home/workspace-connection-status.ts @@ -10,6 +10,60 @@ export function shouldShowWorkspaceConnectionStatus(state: WorkspaceState): bool ); } +/** + * Whether the status describes work in progress rather than a steady state. + * + * Only these need settling before they reach the UI: they are driven by the + * shell-sync signal, which re-enters "synchronizing" on every websocket + * resubscribe and retries expected failures every 250ms, so a healthy + * connection still emits a stream of sub-second blips. Everything else is a + * steady fact and should surface immediately. + */ +export function isWorkspaceConnectionStatusBusy(state: WorkspaceState): boolean { + return ( + state.networkStatus !== "offline" && + state.connectionError === null && + (state.hasConnectingEnvironment || + state.connectingEnvironments.length > 0 || + state.hasPendingShellSnapshot) + ); +} + +const SYNCED_AT_FORMATTER = new Intl.DateTimeFormat(undefined, { + hour: "numeric", + minute: "2-digit", +}); + +/** Clock time for the resting status, or null when the timestamp is unusable. */ +export function formatWorkspaceSyncedAt(isoTimestamp: string | null): string | null { + if (isoTimestamp === null) return null; + const parsed = Date.parse(isoTimestamp); + return Number.isNaN(parsed) ? null : SYNCED_AT_FORMATTER.format(parsed); +} + +/** + * Label for the pinned status bar, which stays mounted in every state. + * + * At rest it reports what synced and when, rather than going blank — a quiet + * bar should still answer "am I up to date?" instead of leaving the user to + * infer it from an absence. Everything else defers to the existing connection + * label, which already words each problem case. + */ +export function workspaceSyncStatusLabel(state: WorkspaceState): string { + if (shouldShowWorkspaceConnectionStatus(state)) { + return workspaceConnectionStatusLabel(state); + } + + const environments = + state.readyEnvironmentCount === 1 + ? "1 environment" + : `${state.readyEnvironmentCount} environments`; + const syncedAt = formatWorkspaceSyncedAt(state.latestCachedSnapshotReceivedAt); + // A cold start that never recorded a snapshot time still reports the + // connection count rather than inventing a timestamp. + return syncedAt === null ? `Synced ${environments}` : `Synced ${environments} at ${syncedAt}`; +} + export function workspaceConnectionStatusLabel(state: WorkspaceState): string { if (state.networkStatus === "offline") return "You are offline"; if (state.connectingEnvironments.length === 1) { diff --git a/apps/mobile/src/features/threads/ThreadNavigationSidebar.tsx b/apps/mobile/src/features/threads/ThreadNavigationSidebar.tsx index 36a86ceb1e3..30bd7a2739f 100644 --- a/apps/mobile/src/features/threads/ThreadNavigationSidebar.tsx +++ b/apps/mobile/src/features/threads/ThreadNavigationSidebar.tsx @@ -55,8 +55,7 @@ import { buildHomeProjectScopes, buildHomeThreadGroups } from "../home/homeThrea import { SwipeableScrollGateProvider, useSwipeableScrollGate } from "../home/thread-swipe-actions"; import { usePendingTaskListActions } from "../home/usePendingTaskListActions"; import { useThreadListActions } from "../home/useThreadListActions"; -import { WorkspaceConnectionStatus } from "../home/WorkspaceConnectionStatus"; -import { shouldShowWorkspaceConnectionStatus } from "../home/workspace-connection-status"; +import { WorkspaceStatusBar } from "../home/WorkspaceStatusBar"; import { SidebarHeaderActions } from "./sidebar-header-actions"; import { SidebarFilterButton } from "./sidebar-filter-button"; import { createSidebarHeaderItems } from "./sidebar-native-header-items"; @@ -550,7 +549,6 @@ function ThreadNavigationSidebarPane( threadListV2Enabled, threadListV2Layout, ]); - const showsConnectionStatus = shouldShowWorkspaceConnectionStatus(catalogState); const listMenuActions = useMemo( () => [ { @@ -1085,6 +1083,11 @@ function ThreadNavigationSidebarPane( }} /> + - - - ) : null - } + // Status is pinned above this list, not carried inside it — + // as row 0 it mounted/unmounted on every sync flip and + // reflowed the rows. ListEmptyComponent={listEmpty} /> @@ -1247,15 +1242,11 @@ function ThreadNavigationSidebarPane( /> - {showsConnectionStatus ? ( - - - - ) : null} + ); diff --git a/apps/mobile/src/state/workspaceModel.ts b/apps/mobile/src/state/workspaceModel.ts index 44c43d6c880..ada976310ac 100644 --- a/apps/mobile/src/state/workspaceModel.ts +++ b/apps/mobile/src/state/workspaceModel.ts @@ -21,6 +21,12 @@ export interface WorkspaceState { readonly hasLoadedShellSnapshot: boolean; readonly hasPendingShellSnapshot: boolean; readonly hasReadyEnvironment: boolean; + /** + * How many environments are connected right now. The status bar reports this + * at rest ("Synced 2 environments at 3:41 PM"), which a boolean can't carry. + * Zero while offline, so it stays consistent with hasReadyEnvironment. + */ + readonly readyEnvironmentCount: number; readonly hasConnectingEnvironment: boolean; readonly connectingEnvironments: ReadonlyArray; readonly connectionState: EnvironmentConnectionPhase; @@ -83,15 +89,19 @@ export function projectWorkspaceState(input: { environment.connectionState === "connecting" || environment.connectionState === "reconnecting", ); + const readyEnvironmentCount = + input.networkStatus === "offline" + ? 0 + : input.environments.filter((environment) => environment.connectionState === "connected") + .length; return { isLoadingConnections: !input.isReady, hasConnections: input.environments.length > 0, hasLoadedShellSnapshot: input.shellSummary.hasSnapshot, hasPendingShellSnapshot: input.shellSummary.hasSynchronizingShell, - hasReadyEnvironment: - input.networkStatus !== "offline" && - input.environments.some((environment) => environment.connectionState === "connected"), + hasReadyEnvironment: readyEnvironmentCount > 0, + readyEnvironmentCount, hasConnectingEnvironment: connectingEnvironments.length > 0, connectingEnvironments, connectionState: overallConnectionState(input.environments, input.networkStatus),