android: shrink composer pill; float it over the message list
CI / Gateway plugin tests (push) Successful in 4m59s
CI / Kotlin tests (android host + desktop) (push) Successful in 6m50s

- reduce composer size (padding, corner radius, send button 40->36dp)
- move composer plane (back-to-bottom button, attachments, banners,
  slash drawer, input pill) into a bottom overlay so messages scroll
  behind it; overlay height drives the list's bottom content padding
This commit is contained in:
ARIA committed 2026-08-22 20:36:33 +02:00
1 parent 7f0bdcbbc1
commit 8fba00ba7f
1 file changed
+286 -252
@@ -69,6 +69,7 @@ import androidx.compose.runtime.LaunchedEffect
import androidx.compose.runtime.collectAsState import androidx.compose.runtime.collectAsState
import androidx.compose.runtime.getValue import androidx.compose.runtime.getValue
import androidx.compose.runtime.mutableFloatStateOf import androidx.compose.runtime.mutableFloatStateOf
import androidx.compose.runtime.mutableIntStateOf
import androidx.compose.runtime.mutableStateOf import androidx.compose.runtime.mutableStateOf
import androidx.compose.runtime.remember import androidx.compose.runtime.remember
import androidx.compose.runtime.rememberCoroutineScope import androidx.compose.runtime.rememberCoroutineScope
@@ -91,6 +92,7 @@ import androidx.compose.ui.input.key.key
import androidx.compose.ui.input.key.onPreviewKeyEvent import androidx.compose.ui.input.key.onPreviewKeyEvent
import androidx.compose.ui.input.key.type import androidx.compose.ui.input.key.type
import androidx.compose.ui.layout.ContentScale import androidx.compose.ui.layout.ContentScale
import androidx.compose.ui.layout.onSizeChanged
import androidx.compose.ui.platform.LocalClipboardManager import androidx.compose.ui.platform.LocalClipboardManager
import androidx.compose.ui.platform.LocalDensity import androidx.compose.ui.platform.LocalDensity
import androidx.compose.ui.platform.LocalFocusManager import androidx.compose.ui.platform.LocalFocusManager
@@ -474,14 +476,26 @@ fun ChatScreen(controller: IrisController) {
) )
} }
// Messages (M7: interleaved date separators; bubbles capped at 85% width) // Messages (M7: interleaved date separators; bubbles capped at 85% width).
// The composer plane (back-to-bottom button, attachments, banners, slash
// drawer, input pill) is an OVERLAY at the bottom of this area: the list
// scrolls behind it, so the pill floats over the wallpaper instead of
// sitting in a hard-edged gap. The overlay's measured height becomes the
// list's bottom content padding, keeping the newest message visible above it.
var overlayHeightPx by remember { mutableIntStateOf(0) }
val rows = remember(items) { buildChatRows(items) } val rows = remember(items) { buildChatRows(items) }
BoxWithConstraints(modifier = Modifier.weight(1f).fillMaxWidth()) { BoxWithConstraints(modifier = Modifier.weight(1f).fillMaxWidth()) {
val bubbleMaxWidth = (maxWidth.value * 0.85f).dp val bubbleMaxWidth = (maxWidth.value * 0.85f).dp
LazyColumn( LazyColumn(
state = listState, state = listState,
modifier = Modifier.fillMaxSize(), modifier = Modifier.fillMaxSize(),
contentPadding = PaddingValues(16.dp), contentPadding =
PaddingValues(
start = 16.dp,
top = 16.dp,
end = 16.dp,
bottom = (overlayHeightPx / LocalDensity.current.density).dp + 16.dp,
),
verticalArrangement = Arrangement.spacedBy(8.dp), verticalArrangement = Arrangement.spacedBy(8.dp),
) { ) {
if (items.isEmpty() && !typing) { if (items.isEmpty() && !typing) {
@@ -552,289 +566,309 @@ fun ChatScreen(controller: IrisController) {
} }
} }
// Back-to-bottom button: appears once the user has scrolled up // Composer plane (overlay): back-to-bottom button, attachments, banners,
// to read history; a tap glides the view back to the latest // slash drawer and the input pill. The list scrolls behind it; its
// message (smooth but quick). // measured height is the list's bottom content padding (above).
if (!isAtBottom && !isAutoScrolling) {
val alpha = remember { Animatable(0f) }
LaunchedEffect(Unit) { alpha.animateTo(1f, tween(150)) }
Box(
modifier =
Modifier
.align(Alignment.BottomEnd)
.padding(end = 16.dp, bottom = 12.dp)
.alpha(alpha.value)
.size(40.dp)
.clip(CircleShape)
.background(IrisColors.surface)
.border(1.dp, IrisColors.divider, CircleShape)
.clickable {
drawerScope.launch { scrollToBottom(animate = true) }
},
contentAlignment = Alignment.Center,
) {
Icon(
imageVector = Icons.Filled.KeyboardArrowDown,
contentDescription = "Scroll to latest",
tint = IrisColors.textSecondary,
modifier = Modifier.size(22.dp),
)
}
}
}
// Pending attachments (M4; hidden in automation channels — no composer)
if (attachments.isNotEmpty() && !isAutomation) {
Row(
modifier =
Modifier
.fillMaxWidth()
.horizontalScroll(rememberScrollState())
.padding(horizontal = 12.dp, vertical = 4.dp),
horizontalArrangement = Arrangement.spacedBy(6.dp),
) {
attachments.forEach { att ->
AttachmentChip(att, onRemove = { controller.removeAttachment(att.filename) })
}
}
}
// M5: notification banners (above the composer)
if (banners.isNotEmpty()) {
Column( Column(
modifier = modifier =
Modifier Modifier
.align(Alignment.BottomCenter)
.fillMaxWidth() .fillMaxWidth()
.padding(horizontal = 12.dp, vertical = 4.dp), .onSizeChanged { overlayHeightPx = it.height },
verticalArrangement = Arrangement.spacedBy(6.dp),
) { ) {
banners.forEach { b -> // Back-to-bottom button: appears once the user has scrolled up
NotificationBanner( // to read history; a tap glides the view back to the latest
banner = b, // message (smooth but quick).
onOpen = { if (!isAtBottom && !isAutoScrolling) {
controller.openDeepLink(b.chatId, b.threadId) Box(modifier = Modifier.fillMaxWidth(), contentAlignment = Alignment.CenterEnd) {
controller.dismissBanner(b.id) val alpha = remember { Animatable(0f) }
}, LaunchedEffect(Unit) { alpha.animateTo(1f, tween(150)) }
onDismiss = { controller.dismissBanner(b.id) },
)
}
}
}
// M7: connection-state banner (persistent, above the composer; no dismiss).
val gatewayStatus by controller.gatewayStatus.collectAsState()
val connBanner =
when {
state is GatewayClient.State.Connecting -> {
"Connecting to gateway…"
}
state is GatewayClient.State.Reconnecting -> {
"Reconnecting to gateway… messages will sync automatically when the connection is back."
}
// Note: gatewayStatus never becomes "restarting" — that
// state posts a chat notice immediately (IrisController,
// TYPE_STATUS) instead of showing a banner.
gatewayStatus == "degraded" -> {
"Gateway reports a degraded state — replies may be slow or unavailable."
}
else -> {
null
}
}
if (connBanner != null) {
Box(
modifier =
Modifier
.fillMaxWidth()
.padding(horizontal = 12.dp, vertical = 4.dp),
contentAlignment = Alignment.Center,
) {
Box(
modifier =
Modifier
.clip(RoundedCornerShape(16.dp))
.background(IrisColors.panel)
.padding(horizontal = 14.dp, vertical = 8.dp),
) {
Text(connBanner, fontSize = 12.sp, color = IrisColors.textTertiary)
}
}
}
// Slash-command drawer: rolls up over the composer while the input is a
// bare "/…". Fuzzy-matched against the gateway catalog; tapping a
// row sends the command (and closes the drawer).
AnimatedVisibility(
visible = slashDrawerOpen && !isAutomation && !selectionMode,
enter = fadeIn(tween(120)) + slideInVertically(tween(160)) { it / 3 },
exit = fadeOut(tween(100)) + slideOutVertically(tween(120)) { it / 3 },
) {
Column(
modifier =
Modifier
.fillMaxWidth()
.padding(horizontal = 12.dp)
.clip(RoundedCornerShape(16.dp))
.background(IrisColors.panel)
.border(1.dp, IrisColors.divider, RoundedCornerShape(16.dp)),
) {
slashMatches.take(SLASH_DRAWER_MAX).forEachIndexed { index, cmd ->
if (index > 0) {
Box( Box(
modifier = modifier =
Modifier Modifier
.fillMaxWidth() .padding(end = 16.dp, bottom = 8.dp)
.height(1.dp) .alpha(alpha.value)
.background(IrisColors.divider), .size(40.dp)
) .clip(CircleShape)
.background(IrisColors.surface)
.border(1.dp, IrisColors.divider, CircleShape)
.clickable {
drawerScope.launch { scrollToBottom(animate = true) }
},
contentAlignment = Alignment.Center,
) {
Icon(
imageVector = Icons.Filled.KeyboardArrowDown,
contentDescription = "Scroll to latest",
tint = IrisColors.textSecondary,
modifier = Modifier.size(22.dp),
)
}
} }
}
// Pending attachments (M4; hidden in automation channels — no composer)
if (attachments.isNotEmpty() && !isAutomation) {
Row( Row(
modifier = modifier =
Modifier Modifier
.fillMaxWidth() .fillMaxWidth()
.clickable { onSlashPick(cmd) } .horizontalScroll(rememberScrollState())
.padding(horizontal = 12.dp, vertical = 8.dp), .padding(horizontal = 12.dp, vertical = 4.dp),
verticalAlignment = Alignment.CenterVertically, horizontalArrangement = Arrangement.spacedBy(6.dp),
) { ) {
Column(modifier = Modifier.weight(1f)) { attachments.forEach { att ->
Row(verticalAlignment = Alignment.CenterVertically) { AttachmentChip(att, onRemove = { controller.removeAttachment(att.filename) })
Text( }
cmd.name, }
color = IrisColors.primary, }
fontWeight = FontWeight.SemiBold,
fontSize = 14.sp, // M5: notification banners (above the composer)
if (banners.isNotEmpty()) {
Column(
modifier =
Modifier
.fillMaxWidth()
.padding(horizontal = 12.dp, vertical = 4.dp),
verticalArrangement = Arrangement.spacedBy(6.dp),
) {
banners.forEach { b ->
NotificationBanner(
banner = b,
onOpen = {
controller.openDeepLink(b.chatId, b.threadId)
controller.dismissBanner(b.id)
},
onDismiss = { controller.dismissBanner(b.id) },
)
}
}
}
// M7: connection-state banner (persistent, above the composer; no dismiss).
val gatewayStatus by controller.gatewayStatus.collectAsState()
val connBanner =
when {
state is GatewayClient.State.Connecting -> {
"Connecting to gateway…"
}
state is GatewayClient.State.Reconnecting -> {
"Reconnecting to gateway… messages will sync automatically when the connection is back."
}
// Note: gatewayStatus never becomes "restarting" — that
// state posts a chat notice immediately (IrisController,
// TYPE_STATUS) instead of showing a banner.
gatewayStatus == "degraded" -> {
"Gateway reports a degraded state — replies may be slow or unavailable."
}
else -> {
null
}
}
if (connBanner != null) {
Box(
modifier =
Modifier
.fillMaxWidth()
.padding(horizontal = 12.dp, vertical = 4.dp),
contentAlignment = Alignment.Center,
) {
Box(
modifier =
Modifier
.clip(RoundedCornerShape(16.dp))
.background(IrisColors.panel)
.padding(horizontal = 14.dp, vertical = 8.dp),
) {
Text(connBanner, fontSize = 12.sp, color = IrisColors.textTertiary)
}
}
}
// Slash-command drawer: rolls up over the composer while the input is a
// bare "/…". Fuzzy-matched against the gateway catalog; tapping a
// row sends the command (and closes the drawer).
AnimatedVisibility(
visible = slashDrawerOpen && !isAutomation && !selectionMode,
enter = fadeIn(tween(120)) + slideInVertically(tween(160)) { it / 3 },
exit = fadeOut(tween(100)) + slideOutVertically(tween(120)) { it / 3 },
) {
Column(
modifier =
Modifier
.fillMaxWidth()
.padding(horizontal = 12.dp)
.clip(RoundedCornerShape(16.dp))
.background(IrisColors.panel)
.border(1.dp, IrisColors.divider, RoundedCornerShape(16.dp)),
) {
slashMatches.take(SLASH_DRAWER_MAX).forEachIndexed { index, cmd ->
if (index > 0) {
Box(
modifier =
Modifier
.fillMaxWidth()
.height(1.dp)
.background(IrisColors.divider),
) )
if (cmd.argsHint.isNotEmpty()) { }
Row(
modifier =
Modifier
.fillMaxWidth()
.clickable { onSlashPick(cmd) }
.padding(horizontal = 12.dp, vertical = 8.dp),
verticalAlignment = Alignment.CenterVertically,
) {
Column(modifier = Modifier.weight(1f)) {
Row(verticalAlignment = Alignment.CenterVertically) {
Text(
cmd.name,
color = IrisColors.primary,
fontWeight = FontWeight.SemiBold,
fontSize = 14.sp,
)
if (cmd.argsHint.isNotEmpty()) {
Text(
" ${cmd.argsHint}",
color = IrisColors.textTertiary,
fontSize = 13.sp,
)
}
}
Text( Text(
" ${cmd.argsHint}", cmd.description,
color = IrisColors.textTertiary, color = IrisColors.textSecondary,
fontSize = 13.sp, fontSize = 12.sp,
maxLines = 1,
overflow = TextOverflow.Ellipsis,
) )
} }
} }
Text(
cmd.description,
color = IrisColors.textSecondary,
fontSize = 12.sp,
maxLines = 1,
overflow = TextOverflow.Ellipsis,
)
} }
} }
} }
}
}
// Selection toolbar (replaces the composer while messages are selected). // Selection toolbar (replaces the composer while messages are selected).
if (selectionMode) { if (selectionMode) {
SelectionToolbar( SelectionToolbar(
count = selectedIds.size, count = selectedIds.size,
onCancel = { exitSelection() }, onCancel = { exitSelection() },
onDelete = { showDeleteConfirm = true }, onDelete = { showDeleteConfirm = true },
)
}
// Composer (M7: rounded pill + accent circular send button). Sending is
// NOT gated on a live connection: an offline send stays queued
// (Pending) and is auto-resent on the next (re)connect (see
// IrisController.onSendResult / reconcileFailedSends). The
// automation lane stays read-only regardless.
// docs/19 §19.15 (v2): media works over the HTTP leg too, so
// attachments are sendable in both connected states (uploading
// still needs a live connection — the picker stays gated).
val canSend = input.isNotBlank() || attachments.any { it.mediaRef != null && it.error == null }
val layoutDensity = LocalDensity.current.density
var textHeightPx by remember { mutableFloatStateOf(0f) }
if (isAutomation) {
// Read-only notice in place of the composer.
Box(
modifier =
Modifier
.fillMaxWidth()
.padding(horizontal = 12.dp, vertical = 8.dp),
contentAlignment = Alignment.Center,
) {
Box(
modifier =
Modifier
.clip(RoundedCornerShape(16.dp))
.background(IrisColors.panel)
.padding(horizontal = 14.dp, vertical = 8.dp),
) {
Text(
"⚙ Automation channel — read-only. Hermes posts cron/webhook output here; direct messages are not allowed.",
fontSize = 12.sp,
color = IrisColors.textTertiary,
) )
} }
}
} else if (!selectionMode) { // Composer (M7: rounded pill + accent circular send button). Sending is
Row( // NOT gated on a live connection: an offline send stays queued
modifier = // (Pending) and is auto-resent on the next (re)connect (see
Modifier // IrisController.onSendResult / reconcileFailedSends). The
.fillMaxWidth() // automation lane stays read-only regardless.
.padding(horizontal = 12.dp, vertical = 8.dp) // docs/19 §19.15 (v2): media works over the HTTP leg too, so
.clip(RoundedCornerShape(28.dp)) // attachments are sendable in both connected states (uploading
.background(IrisColors.surface) // still needs a live connection — the picker stays gated).
.padding(horizontal = 6.dp, vertical = 4.dp), val canSend = input.isNotBlank() || attachments.any { it.mediaRef != null && it.error == null }
verticalAlignment = Alignment.CenterVertically, val layoutDensity = LocalDensity.current.density
) { var textHeightPx by remember { mutableFloatStateOf(0f) }
Row( if (isAutomation) {
modifier = // Read-only notice in place of the composer.
Modifier Box(
.weight(1f) modifier =
.padding(horizontal = 6.dp, vertical = 10.dp), Modifier
verticalAlignment = Alignment.CenterVertically, .fillMaxWidth()
) { .padding(horizontal = 12.dp, vertical = 8.dp),
Box(modifier = Modifier.weight(1f)) { contentAlignment = Alignment.Center,
BasicTextField( ) {
value = input, Box(
onValueChange = { input = it },
modifier = modifier =
Modifier Modifier
.fillMaxWidth() .clip(RoundedCornerShape(16.dp))
.heightIn(max = if (textHeightPx > 0f) (textHeightPx / layoutDensity).dp else Dp.Unspecified) .background(IrisColors.panel)
.focusRequester(composerFocusRequester) .padding(horizontal = 14.dp, vertical = 8.dp),
.onFocusChanged { composerFocused = it.isFocused }, ) {
textStyle = MaterialTheme.typography.bodyLarge.copy(color = IrisColors.onSurface),
keyboardOptions = KeyboardOptions(imeAction = ImeAction.Send),
keyboardActions = KeyboardActions(onSend = { doSend() }),
onTextLayout = { result ->
if (result.lineCount > 0) {
val totalHeight = result.getLineBottom(result.lineCount - 1)
val lineHeight = totalHeight / result.lineCount
textHeightPx = minOf(totalHeight, lineHeight * 5f)
}
},
)
if (input.isEmpty()) {
Text( Text(
"Message", "⚙ Automation channel — read-only. Hermes posts cron/webhook output here; direct messages are not allowed.",
color = IrisColors.textDim, fontSize = 12.sp,
style = MaterialTheme.typography.bodyLarge, color = IrisColors.textTertiary,
modifier = Modifier.align(Alignment.TopStart),
) )
} }
} }
IconButton(onClick = { showPicker = true }, enabled = state is GatewayClient.State.Connected) { } else if (!selectionMode) {
Text("📎", fontSize = 18.sp) Row(
modifier =
Modifier
.fillMaxWidth()
.padding(horizontal = 12.dp, vertical = 6.dp)
.clip(RoundedCornerShape(24.dp))
.background(IrisColors.surface)
.padding(horizontal = 6.dp, vertical = 2.dp),
verticalAlignment = Alignment.CenterVertically,
) {
Row(
modifier =
Modifier
.weight(1f)
.padding(horizontal = 6.dp, vertical = 6.dp),
verticalAlignment = Alignment.CenterVertically,
) {
Box(modifier = Modifier.weight(1f)) {
BasicTextField(
value = input,
onValueChange = { input = it },
modifier =
Modifier
.fillMaxWidth()
.heightIn(
max =
if (textHeightPx >
0f
) {
(textHeightPx / layoutDensity).dp
} else {
Dp.Unspecified
},
).focusRequester(composerFocusRequester)
.onFocusChanged { composerFocused = it.isFocused },
textStyle = MaterialTheme.typography.bodyLarge.copy(color = IrisColors.onSurface),
keyboardOptions = KeyboardOptions(imeAction = ImeAction.Send),
keyboardActions = KeyboardActions(onSend = { doSend() }),
onTextLayout = { result ->
if (result.lineCount > 0) {
val totalHeight = result.getLineBottom(result.lineCount - 1)
val lineHeight = totalHeight / result.lineCount
textHeightPx = minOf(totalHeight, lineHeight * 5f)
}
},
)
if (input.isEmpty()) {
Text(
"Message",
color = IrisColors.textDim,
style = MaterialTheme.typography.bodyLarge,
modifier = Modifier.align(Alignment.TopStart),
)
}
}
IconButton(onClick = { showPicker = true }, enabled = state is GatewayClient.State.Connected) {
Text("📎", fontSize = 18.sp)
}
}
Box(
modifier =
Modifier
.size(36.dp)
.clip(CircleShape)
.background(if (canSend) IrisColors.primary else IrisColors.primary.copy(alpha = 0.35f))
.clickable(enabled = canSend) { doSend() },
contentAlignment = Alignment.Center,
) {
Text("➤", color = Color.White, fontSize = 14.sp)
}
} }
} }
Box(
modifier =
Modifier
.size(40.dp)
.clip(CircleShape)
.background(if (canSend) IrisColors.primary else IrisColors.primary.copy(alpha = 0.35f))
.clickable(enabled = canSend) { doSend() },
contentAlignment = Alignment.Center,
) {
Text("➤", color = Color.White, fontSize = 16.sp)
}
} }
} }