@@ -88,6 +88,7 @@ import androidx.compose.runtime.DisposableEffect
88import androidx.compose.runtime.LaunchedEffect
88import androidx.compose.runtime.LaunchedEffect
89import androidx.compose.runtime.derivedStateOf
89import androidx.compose.runtime.derivedStateOf
90import androidx.compose.runtime.getValue
90import androidx.compose.runtime.getValue
91+import androidx.compose.runtime.mutableIntStateOf
91import androidx.compose.runtime.mutableStateOf
92import androidx.compose.runtime.mutableStateOf
92import androidx.compose.runtime.remember
93import androidx.compose.runtime.remember
93import androidx.compose.runtime.rememberCoroutineScope
94import androidx.compose.runtime.rememberCoroutineScope
@@ -108,7 +109,9 @@ import androidx.compose.ui.input.key.isShiftPressed
108import androidx.compose.ui.input.key.key
109import androidx.compose.ui.input.key.key
109import androidx.compose.ui.input.key.onPreviewKeyEvent
110import androidx.compose.ui.input.key.onPreviewKeyEvent
110import androidx.compose.ui.input.key.type
111import androidx.compose.ui.input.key.type
112+import androidx.compose.ui.layout.onSizeChanged
111import androidx.compose.ui.platform.LocalContext
113import androidx.compose.ui.platform.LocalContext
114+import androidx.compose.ui.platform.LocalDensity
112import androidx.compose.ui.platform.LocalHapticFeedback
115import androidx.compose.ui.platform.LocalHapticFeedback
113import androidx.compose.ui.platform.LocalView
116import androidx.compose.ui.platform.LocalView
114import androidx.compose.ui.platform.testTag
117import androidx.compose.ui.platform.testTag
@@ -894,9 +897,27 @@ fun ChatScreen(
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
897 ConnectionBanner(
918 ConnectionBanner(
898 state = connectionState,
919 state = connectionState,
899− modifier = Modifier.align(Alignment.TopCenter),
920+ modifier = Modifier.align(Alignment.TopCenter).onSizeChanged { bannerHeightPx = it.height },
900 isOnline = isOnline,
921 isOnline = isOnline,
901 // On a hard failure, retry by forcing an SSE reconnect (which
922 // On a hard failure, retry by forcing an SSE reconnect (which
902 // re-seeds from REST). refreshMessages is the right recovery path
923 // re-seeds from REST). refreshMessages is the right recovery path
@@ -918,9 +939,7 @@ fun ChatScreen(
918 .widthIn(max = NetworkConfig.chatContentMaxWidthDp.dp),
939 .widthIn(max = NetworkConfig.chatContentMaxWidthDp.dp),
920 // Reserve top space when the banner is visible so it doesn't overlap the
941 // Reserve top space when the banner is visible so it doesn't overlap the
921− // first message / state content.
942+ // first message / state content. (topPad computed above.)
922− val bannerVisible = !isOnline || connectionState != EventStreamClient.ConnectionState.Connected
923− val topPad = if (bannerVisible) 44.dp else 16.dp
924 if (loading && messages.isEmpty()) {
943 if (loading && messages.isEmpty()) {
925 val loadingLabel = stringResource(R.string.loading)
944 val loadingLabel = stringResource(R.string.loading)
926 // Attach the a11y label directly to the spinner (a detached zero-size Box
945 // Attach the a11y label directly to the spinner (a detached zero-size Box