@@ -88,6 +88,7 @@ import androidx.compose.runtime.DisposableEffect
8888import androidx.compose.runtime.LaunchedEffect
8989import androidx.compose.runtime.derivedStateOf
9090import androidx.compose.runtime.getValue
91+import androidx.compose.runtime.mutableIntStateOf
9192import androidx.compose.runtime.mutableStateOf
9293import androidx.compose.runtime.remember
9394import androidx.compose.runtime.rememberCoroutineScope
@@ -108,7 +109,9 @@ import androidx.compose.ui.input.key.isShiftPressed
108109import androidx.compose.ui.input.key.key
109110import androidx.compose.ui.input.key.onPreviewKeyEvent
110111import androidx.compose.ui.input.key.type
112+import androidx.compose.ui.layout.onSizeChanged
111113import androidx.compose.ui.platform.LocalContext
114+import androidx.compose.ui.platform.LocalDensity
112115import androidx.compose.ui.platform.LocalHapticFeedback
113116import androidx.compose.ui.platform.LocalView
114117import androidx.compose.ui.platform.testTag
@@ -894,9 +897,27 @@ fun ChatScreen(
894897 }
895898 }
896899 } else {
900+ val bannerVisible = !isOnline || connectionState != EventStreamClient.ConnectionState.Connected
901+ // Measure the banner's actual height instead of hardcoding 44dp. At large
902+ // accessibility font scales the banner's labelMedium text + spinner + Retry
903+ // button exceed 44dp, so a fixed inset lets the first message hide behind it
904+ // (the same hazard FileViewScreen's overlay calls out). onSizeChanged only
905+ // fires while the banner is composed (visible), so gate the inset on
906+ // bannerVisible and fall back to 44dp until the first measurement arrives.
907+ var bannerHeightPx by remember { mutableIntStateOf(0) }
908+ val density = LocalDensity.current
909+ val topPad = if (bannerVisible) {
910+ if (bannerHeightPx > 0) {
911+ with(density) { bannerHeightPx.toDp() } + 4.dp
912+ } else {
913+ 44.dp
914+ }
915+ } else {
916+ 16.dp
917+ }
897918 ConnectionBanner(
898919 state = connectionState,
899− modifier = Modifier.align(Alignment.TopCenter),
920+ modifier = Modifier.align(Alignment.TopCenter).onSizeChanged { bannerHeightPx = it.height },
900921 isOnline = isOnline,
901922 // On a hard failure, retry by forcing an SSE reconnect (which
902923 // re-seeds from REST). refreshMessages is the right recovery path
@@ -918,9 +939,7 @@ fun ChatScreen(
918939 .widthIn(max = NetworkConfig.chatContentMaxWidthDp.dp),
919940 ) {
920941 // Reserve top space when the banner is visible so it doesn't overlap the
921− // first message / state content.
922− val bannerVisible = !isOnline || connectionState != EventStreamClient.ConnectionState.Connected
923− val topPad = if (bannerVisible) 44.dp else 16.dp
942+ // first message / state content. (topPad computed above.)
924943 if (loading && messages.isEmpty()) {
925944 val loadingLabel = stringResource(R.string.loading)
926945 // Attach the a11y label directly to the spinner (a detached zero-size Box