diff --git a/app/shared/src/commonMain/kotlin/iris/ui/screens/ChatScreen.kt b/app/shared/src/commonMain/kotlin/iris/ui/screens/ChatScreen.kt index 63d5d5f..22c7a46 100644 --- a/app/shared/src/commonMain/kotlin/iris/ui/screens/ChatScreen.kt +++ b/app/shared/src/commonMain/kotlin/iris/ui/screens/ChatScreen.kt @@ -69,6 +69,7 @@ import androidx.compose.runtime.LaunchedEffect import androidx.compose.runtime.collectAsState import androidx.compose.runtime.getValue import androidx.compose.runtime.mutableFloatStateOf +import androidx.compose.runtime.mutableIntStateOf import androidx.compose.runtime.mutableStateOf import androidx.compose.runtime.remember 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.type import androidx.compose.ui.layout.ContentScale +import androidx.compose.ui.layout.onSizeChanged import androidx.compose.ui.platform.LocalClipboardManager import androidx.compose.ui.platform.LocalDensity 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) } BoxWithConstraints(modifier = Modifier.weight(1f).fillMaxWidth()) { val bubbleMaxWidth = (maxWidth.value * 0.85f).dp LazyColumn( state = listState, 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), ) { if (items.isEmpty() && !typing) { @@ -552,289 +566,309 @@ fun ChatScreen(controller: IrisController) { } } - // Back-to-bottom button: appears once the user has scrolled up - // to read history; a tap glides the view back to the latest - // message (smooth but quick). - 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()) { + // Composer plane (overlay): back-to-bottom button, attachments, banners, + // slash drawer and the input pill. The list scrolls behind it; its + // measured height is the list's bottom content padding (above). Column( modifier = Modifier + .align(Alignment.BottomCenter) .fillMaxWidth() - .padding(horizontal = 12.dp, vertical = 4.dp), - verticalArrangement = Arrangement.spacedBy(6.dp), + .onSizeChanged { overlayHeightPx = it.height }, ) { - 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) { + // Back-to-bottom button: appears once the user has scrolled up + // to read history; a tap glides the view back to the latest + // message (smooth but quick). + if (!isAtBottom && !isAutoScrolling) { + Box(modifier = Modifier.fillMaxWidth(), contentAlignment = Alignment.CenterEnd) { + val alpha = remember { Animatable(0f) } + LaunchedEffect(Unit) { alpha.animateTo(1f, tween(150)) } Box( modifier = Modifier - .fillMaxWidth() - .height(1.dp) - .background(IrisColors.divider), - ) + .padding(end = 16.dp, bottom = 8.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() - .clickable { onSlashPick(cmd) } - .padding(horizontal = 12.dp, vertical = 8.dp), - verticalAlignment = Alignment.CenterVertically, + .horizontalScroll(rememberScrollState()) + .padding(horizontal = 12.dp, vertical = 4.dp), + horizontalArrangement = Arrangement.spacedBy(6.dp), ) { - Column(modifier = Modifier.weight(1f)) { - Row(verticalAlignment = Alignment.CenterVertically) { - Text( - cmd.name, - color = IrisColors.primary, - fontWeight = FontWeight.SemiBold, - fontSize = 14.sp, + attachments.forEach { att -> + AttachmentChip(att, onRemove = { controller.removeAttachment(att.filename) }) + } + } + } + + // 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( - " ${cmd.argsHint}", - color = IrisColors.textTertiary, - fontSize = 13.sp, + cmd.description, + color = IrisColors.textSecondary, + 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). - if (selectionMode) { - SelectionToolbar( - count = selectedIds.size, - onCancel = { exitSelection() }, - 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, + // Selection toolbar (replaces the composer while messages are selected). + if (selectionMode) { + SelectionToolbar( + count = selectedIds.size, + onCancel = { exitSelection() }, + onDelete = { showDeleteConfirm = true }, ) } - } - } else if (!selectionMode) { - Row( - modifier = - Modifier - .fillMaxWidth() - .padding(horizontal = 12.dp, vertical = 8.dp) - .clip(RoundedCornerShape(28.dp)) - .background(IrisColors.surface) - .padding(horizontal = 6.dp, vertical = 4.dp), - verticalAlignment = Alignment.CenterVertically, - ) { - Row( - modifier = - Modifier - .weight(1f) - .padding(horizontal = 6.dp, vertical = 10.dp), - verticalAlignment = Alignment.CenterVertically, - ) { - Box(modifier = Modifier.weight(1f)) { - BasicTextField( - value = input, - onValueChange = { input = it }, + + // 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 - .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()) { + .clip(RoundedCornerShape(16.dp)) + .background(IrisColors.panel) + .padding(horizontal = 14.dp, vertical = 8.dp), + ) { Text( - "Message", - color = IrisColors.textDim, - style = MaterialTheme.typography.bodyLarge, - modifier = Modifier.align(Alignment.TopStart), + "⚙ Automation channel — read-only. Hermes posts cron/webhook output here; direct messages are not allowed.", + fontSize = 12.sp, + color = IrisColors.textTertiary, ) } } - IconButton(onClick = { showPicker = true }, enabled = state is GatewayClient.State.Connected) { - Text("📎", fontSize = 18.sp) + } else if (!selectionMode) { + 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) - } } }