feat(group-chat): support dynamic BiDi Arabic input and RTL message bubbles

- Align GroupChatComposer with ChatComposer (PR #1047):
  - Dynamic BiDi input detection via BidiUtils.resolveLayoutDirection
  - Docks cursor to the right edge and sets TextDirection.Rtl for Arabic
  - Preserves LTR outer surface and row to keep send/stop button pinned in place
- User message bubbles: dynamic RTL layout direction and TextDirection.ContentOrRtl
- Bot messages: dynamic TextDirection for bot display name, active speaker thinking row, and tool labels
- Tool code/commands preserved as LTR monospace with BiDi-safe output text

Co-authored-by: Luna <luna.m57@agentmail.to>
This commit is contained in:
M57
2026-09-04 15:38:36 +03:00
co-authored by Luna
parent 2d0d749cbe
commit fd011ac78d
@@ -87,6 +87,7 @@ import androidx.compose.ui.text.font.FontWeight
import androidx.compose.ui.text.input.ImeAction import androidx.compose.ui.text.input.ImeAction
import androidx.compose.ui.text.input.TextFieldValue import androidx.compose.ui.text.input.TextFieldValue
import androidx.compose.ui.text.style.TextAlign import androidx.compose.ui.text.style.TextAlign
import androidx.compose.ui.text.style.TextDirection
import androidx.compose.ui.text.style.TextOverflow import androidx.compose.ui.text.style.TextOverflow
import androidx.compose.ui.unit.Density import androidx.compose.ui.unit.Density
import androidx.compose.ui.unit.LayoutDirection import androidx.compose.ui.unit.LayoutDirection
@@ -274,6 +275,8 @@ fun GroupChatScreen(
state.activeSpeaker?.let { speaker -> state.activeSpeaker?.let { speaker ->
item(key = "active_speaker") { item(key = "active_speaker") {
val thinkingText = stringResource(R.string.group_chat_thinking, speaker)
val isThinkingRtl = remember(thinkingText) { BidiUtils.isRtlText(thinkingText) }
Row( Row(
modifier = Modifier.padding(start = 8.dp, top = 4.dp), modifier = Modifier.padding(start = 8.dp, top = 4.dp),
verticalAlignment = Alignment.CenterVertically, verticalAlignment = Alignment.CenterVertically,
@@ -284,8 +287,16 @@ fun GroupChatScreen(
) )
Spacer(modifier = Modifier.width(8.dp)) Spacer(modifier = Modifier.width(8.dp))
Text( Text(
text = stringResource(R.string.group_chat_thinking, speaker), text = thinkingText,
style = MaterialTheme.typography.bodySmall, style =
MaterialTheme.typography.bodySmall.copy(
textDirection =
if (isThinkingRtl) {
TextDirection.ContentOrRtl
} else {
TextDirection.ContentOrLtr
},
),
color = MaterialTheme.colorScheme.primary, color = MaterialTheme.colorScheme.primary,
) )
} }
@@ -409,124 +420,131 @@ fun GroupChatScreen(
} }
// ── COMPOSER (uses TextFieldValue + embedded Send button matching ChatComposer) ── // ── COMPOSER (uses TextFieldValue + embedded Send button matching ChatComposer) ──
Surface( val ambientLayoutDirection = LocalLayoutDirection.current
modifier = val inputLayoutDirection =
Modifier remember(inputFieldValue.text, ambientLayoutDirection) {
.fillMaxWidth() BidiUtils.resolveLayoutDirection(inputFieldValue.text, fallback = ambientLayoutDirection)
.padding(horizontal = 12.dp, vertical = 4.dp), }
shape = RoundedCornerShape(20.dp), val isInputRtl = inputLayoutDirection == LayoutDirection.Rtl
border =
BorderStroke( CompositionLocalProvider(LocalLayoutDirection provides inputLayoutDirection) {
width = if (isFocused) 2.dp else 1.dp, BasicTextField(
color = value = inputFieldValue,
if (isFocused) { onValueChange = { inputFieldValue = it },
MaterialTheme.colorScheme.primary
} else {
MaterialTheme.colorScheme.outline.copy(alpha = 0.5f)
},
),
color = MaterialTheme.colorScheme.surface,
) {
Row(
modifier = modifier =
Modifier Modifier
.fillMaxWidth() .fillMaxWidth()
.padding(start = 14.dp, end = 4.dp, top = 6.dp, bottom = 6.dp), .padding(horizontal = 12.dp, vertical = 4.dp)
verticalAlignment = Alignment.CenterVertically, .onFocusChanged { isFocused = it.isFocused }
) { .testTag("group_chat_input"),
val isInputRtl = remember(inputFieldValue.text) { BidiUtils.isRtlText(inputFieldValue.text) } textStyle =
val ambientLayoutDirection = LocalLayoutDirection.current MaterialTheme.typography.bodyMedium.copy(
val inputLayoutDirection = color = MaterialTheme.colorScheme.onSurface,
remember(inputFieldValue.text, ambientLayoutDirection) { textAlign = if (isInputRtl) TextAlign.Right else TextAlign.Left,
if (inputFieldValue.text.isBlank()) { textDirection = if (isInputRtl) TextDirection.Rtl else TextDirection.Ltr,
ambientLayoutDirection ),
} else if (isInputRtl) { singleLine = false,
LayoutDirection.Rtl maxLines = 4,
} else { cursorBrush = SolidColor(MaterialTheme.colorScheme.primary),
LayoutDirection.Ltr keyboardOptions = KeyboardOptions(imeAction = ImeAction.Send),
keyboardActions = KeyboardActions(onSend = { handleSend() }),
decorationBox = { innerTextField ->
CompositionLocalProvider(LocalLayoutDirection provides ambientLayoutDirection) {
Surface(
shape = RoundedCornerShape(20.dp),
border =
BorderStroke(
width = if (isFocused) 2.dp else 1.dp,
color =
if (isFocused) {
MaterialTheme.colorScheme.primary
} else {
MaterialTheme.colorScheme.outline.copy(alpha = 0.5f)
},
),
color = MaterialTheme.colorScheme.surface,
modifier = Modifier.fillMaxWidth(),
) {
Row(
modifier =
Modifier
.fillMaxWidth()
.padding(start = 14.dp, end = 4.dp, top = 6.dp, bottom = 6.dp),
verticalAlignment = Alignment.CenterVertically,
) {
Box(
modifier = Modifier.weight(1f),
contentAlignment =
if (isInputRtl) {
Alignment.CenterEnd
} else {
Alignment.CenterStart
},
) {
CompositionLocalProvider(LocalLayoutDirection provides inputLayoutDirection) {
if (inputFieldValue.text.isEmpty()) {
Text(
text =
stringResource(
R.string.group_chat_composer_hint,
state.groupName.ifBlank { groupName },
),
style = MaterialTheme.typography.bodyMedium,
color =
MaterialTheme.colorScheme.onSurfaceVariant.copy(
alpha = 0.6f,
),
textAlign = if (isInputRtl) TextAlign.Right else TextAlign.Left,
modifier = Modifier.fillMaxWidth(),
)
}
innerTextField()
}
}
Spacer(modifier = Modifier.width(4.dp))
if (state.activeSpeaker != null) {
IconButton(
onClick = { viewModel.stopGeneration() },
colors =
IconButtonDefaults.filledTonalIconButtonColors(
containerColor = MaterialTheme.colorScheme.errorContainer,
contentColor = MaterialTheme.colorScheme.onErrorContainer,
),
modifier =
Modifier
.size(36.dp)
.testTag("group_chat_stop_button"),
) {
Icon(
imageVector = Icons.Filled.Stop,
contentDescription = stringResource(R.string.group_chat_action_stop),
modifier = Modifier.size(18.dp),
)
}
} else {
IconButton(
onClick = handleSend,
enabled = inputFieldValue.text.isNotBlank(),
colors = IconButtonDefaults.filledTonalIconButtonColors(),
modifier =
Modifier
.size(36.dp)
.testTag("group_chat_send_button"),
) {
Icon(
imageVector = Icons.AutoMirrored.Filled.Send,
contentDescription = "Send",
modifier = Modifier.size(18.dp),
)
}
}
}
} }
} }
},
Box( )
modifier = Modifier.weight(1f),
contentAlignment = if (isInputRtl) Alignment.CenterEnd else Alignment.CenterStart,
) {
if (inputFieldValue.text.isEmpty()) {
Text(
text =
stringResource(
R.string.group_chat_composer_hint,
state.groupName.ifBlank { groupName },
),
style = MaterialTheme.typography.bodyMedium,
color = MaterialTheme.colorScheme.onSurfaceVariant.copy(alpha = 0.6f),
textAlign = if (isInputRtl) TextAlign.Right else TextAlign.Left,
modifier = Modifier.fillMaxWidth(),
)
}
CompositionLocalProvider(LocalLayoutDirection provides inputLayoutDirection) {
BasicTextField(
value = inputFieldValue,
onValueChange = { inputFieldValue = it },
modifier =
Modifier
.fillMaxWidth()
.heightIn(min = 24.dp, max = 120.dp)
.onFocusChanged { isFocused = it.isFocused }
.testTag("group_chat_input"),
textStyle =
MaterialTheme.typography.bodyMedium.copy(
color = MaterialTheme.colorScheme.onSurface,
textAlign = if (isInputRtl) TextAlign.Right else TextAlign.Left,
),
singleLine = false,
maxLines = 4,
cursorBrush = SolidColor(MaterialTheme.colorScheme.primary),
keyboardOptions = KeyboardOptions(imeAction = ImeAction.Send),
keyboardActions = KeyboardActions(onSend = { handleSend() }),
)
}
}
Spacer(modifier = Modifier.width(4.dp))
if (state.activeSpeaker != null) {
IconButton(
onClick = { viewModel.stopGeneration() },
colors =
IconButtonDefaults.filledTonalIconButtonColors(
containerColor = MaterialTheme.colorScheme.errorContainer,
contentColor = MaterialTheme.colorScheme.onErrorContainer,
),
modifier =
Modifier
.size(36.dp)
.testTag("group_chat_stop_button"),
) {
Icon(
imageVector = Icons.Filled.Stop,
contentDescription = stringResource(R.string.group_chat_action_stop),
modifier = Modifier.size(18.dp),
)
}
} else {
IconButton(
onClick = handleSend,
enabled = inputFieldValue.text.isNotBlank(),
colors = IconButtonDefaults.filledTonalIconButtonColors(),
modifier =
Modifier
.size(36.dp)
.testTag("group_chat_send_button"),
) {
Icon(
imageVector = Icons.AutoMirrored.Filled.Send,
contentDescription = "Send",
modifier = Modifier.size(18.dp),
)
}
}
}
} }
} }
} }
@@ -865,6 +883,8 @@ private fun GroupMessageCard(
message.text message.text
} }
} }
val isSystemRtl = remember(displayText) { BidiUtils.isRtlText(displayText) }
val systemDirection = if (isSystemRtl) LayoutDirection.Rtl else LocalLayoutDirection.current
Box( Box(
modifier = modifier.fillMaxWidth().padding(vertical = 4.dp), modifier = modifier.fillMaxWidth().padding(vertical = 4.dp),
contentAlignment = Alignment.Center, contentAlignment = Alignment.Center,
@@ -874,28 +894,40 @@ private fun GroupMessageCard(
color = MaterialTheme.colorScheme.surfaceVariant.copy(alpha = if (message.isPass) 0.5f else 0.6f), color = MaterialTheme.colorScheme.surfaceVariant.copy(alpha = if (message.isPass) 0.5f else 0.6f),
border = BorderStroke(1.dp, MaterialTheme.colorScheme.outlineVariant.copy(alpha = 0.4f)), border = BorderStroke(1.dp, MaterialTheme.colorScheme.outlineVariant.copy(alpha = 0.4f)),
) { ) {
Row( CompositionLocalProvider(LocalLayoutDirection provides systemDirection) {
modifier = Modifier.padding(horizontal = 10.dp, vertical = 4.dp), Row(
verticalAlignment = Alignment.CenterVertically, modifier = Modifier.padding(horizontal = 10.dp, vertical = 4.dp),
horizontalArrangement = Arrangement.spacedBy(4.dp), verticalAlignment = Alignment.CenterVertically,
) { horizontalArrangement = Arrangement.spacedBy(4.dp),
if (message.isPass) { ) {
Icon( if (message.isPass) {
imageVector = Icons.Filled.FastForward, Icon(
contentDescription = null, imageVector = Icons.Filled.FastForward,
modifier = Modifier.size(12.dp), contentDescription = null,
tint = MaterialTheme.colorScheme.onSurfaceVariant.copy(alpha = 0.7f), modifier = Modifier.size(12.dp),
tint = MaterialTheme.colorScheme.onSurfaceVariant.copy(alpha = 0.7f),
)
}
Text(
text = displayText,
style =
MaterialTheme.typography.labelSmall.copy(
textDirection =
if (isSystemRtl) {
TextDirection.ContentOrRtl
} else {
TextDirection.ContentOrLtr
},
),
color = MaterialTheme.colorScheme.onSurfaceVariant,
) )
} }
Text(
text = displayText,
style = MaterialTheme.typography.labelSmall,
color = MaterialTheme.colorScheme.onSurfaceVariant,
)
} }
} }
} }
} else if (message.isUser) { } else if (message.isUser) {
val isUserRtl = remember(message.text) { BidiUtils.isRtlText(message.text) }
val userBubbleDirection = if (isUserRtl) LayoutDirection.Rtl else LocalLayoutDirection.current
Row( Row(
modifier = modifier.fillMaxWidth(), modifier = modifier.fillMaxWidth(),
horizontalArrangement = Arrangement.End, horizontalArrangement = Arrangement.End,
@@ -905,52 +937,63 @@ private fun GroupMessageCard(
color = MaterialTheme.colorScheme.primary, color = MaterialTheme.colorScheme.primary,
modifier = Modifier.fillMaxWidth(0.85f), modifier = Modifier.fillMaxWidth(0.85f),
) { ) {
Column(modifier = Modifier.padding(12.dp)) { CompositionLocalProvider(LocalLayoutDirection provides userBubbleDirection) {
SelectionContainer { Column(modifier = Modifier.padding(12.dp)) {
Text( SelectionContainer {
text = message.text, Text(
style = MaterialTheme.typography.bodyMedium, text = message.text,
color = MaterialTheme.colorScheme.onPrimary, style =
) MaterialTheme.typography.bodyMedium.copy(
} textDirection =
Spacer(modifier = Modifier.height(4.dp)) if (isUserRtl) {
Row( TextDirection.ContentOrRtl
modifier = Modifier.align(Alignment.End), } else {
verticalAlignment = Alignment.CenterVertically, TextDirection.ContentOrLtr
) { },
IconButton( ),
onClick = { color = MaterialTheme.colorScheme.onPrimary,
scope.launch { )
clipboard.setClipEntry( }
ClipEntry(ClipData.newPlainText(null, message.text)), Spacer(modifier = Modifier.height(4.dp))
) Row(
} modifier = Modifier.align(Alignment.End),
copied = true verticalAlignment = Alignment.CenterVertically,
},
modifier = Modifier.size(20.dp),
) { ) {
Icon( IconButton(
imageVector = if (copied) Icons.Filled.Check else Icons.Filled.ContentCopy, onClick = {
contentDescription = stringResource(R.string.content_desc_copy), scope.launch {
modifier = Modifier.size(12.dp), clipboard.setClipEntry(
tint = MaterialTheme.colorScheme.onPrimary.copy(alpha = 0.7f), ClipEntry(ClipData.newPlainText(null, message.text)),
)
}
copied = true
},
modifier = Modifier.size(20.dp),
) {
Icon(
imageVector = if (copied) Icons.Filled.Check else Icons.Filled.ContentCopy,
contentDescription = stringResource(R.string.content_desc_copy),
modifier = Modifier.size(12.dp),
tint = MaterialTheme.colorScheme.onPrimary.copy(alpha = 0.7f),
)
}
Spacer(modifier = Modifier.width(2.dp))
Text(
text =
formatTimestamp(
message.timestamp,
DateFormat.is24HourFormat(LocalContext.current),
),
color = MaterialTheme.colorScheme.onPrimary.copy(alpha = 0.7f),
style = MaterialTheme.typography.labelSmall,
) )
} }
Spacer(modifier = Modifier.width(2.dp))
Text(
text =
formatTimestamp(
message.timestamp,
DateFormat.is24HourFormat(LocalContext.current),
),
color = MaterialTheme.colorScheme.onPrimary.copy(alpha = 0.7f),
style = MaterialTheme.typography.labelSmall,
)
} }
} }
} }
} }
} else { } else {
val isBotNameRtl = remember(message.senderDisplayName) { BidiUtils.isRtlText(message.senderDisplayName) }
Row( Row(
modifier = modifier.fillMaxWidth(), modifier = modifier.fillMaxWidth(),
horizontalArrangement = Arrangement.Start, horizontalArrangement = Arrangement.Start,
@@ -970,7 +1013,15 @@ private fun GroupMessageCard(
Column(modifier = Modifier.padding(12.dp)) { Column(modifier = Modifier.padding(12.dp)) {
Text( Text(
text = message.senderDisplayName, text = message.senderDisplayName,
style = MaterialTheme.typography.labelMedium, style =
MaterialTheme.typography.labelMedium.copy(
textDirection =
if (isBotNameRtl) {
TextDirection.ContentOrRtl
} else {
TextDirection.ContentOrLtr
},
),
fontWeight = FontWeight.Bold, fontWeight = FontWeight.Bold,
color = MaterialTheme.colorScheme.primary, color = MaterialTheme.colorScheme.primary,
) )
@@ -1112,9 +1163,13 @@ private fun GroupChatToolChip(
} else { } else {
tool.name tool.name
} }
val isLabelRtl = remember(label) { BidiUtils.isRtlText(label) }
Text( Text(
text = label, text = label,
style = MaterialTheme.typography.labelSmall, style =
MaterialTheme.typography.labelSmall.copy(
textDirection = if (isLabelRtl) TextDirection.ContentOrRtl else TextDirection.ContentOrLtr,
),
color = MaterialTheme.colorScheme.onSurface, color = MaterialTheme.colorScheme.onSurface,
maxLines = 1, maxLines = 1,
overflow = TextOverflow.Ellipsis, overflow = TextOverflow.Ellipsis,
@@ -1160,6 +1215,7 @@ private fun GroupChatToolChip(
style = style =
MaterialTheme.typography.bodySmall.copy( MaterialTheme.typography.bodySmall.copy(
fontFamily = FontFamily.Monospace, fontFamily = FontFamily.Monospace,
textDirection = TextDirection.Ltr,
), ),
color = MaterialTheme.colorScheme.onSurfaceVariant, color = MaterialTheme.colorScheme.onSurfaceVariant,
modifier = Modifier.padding(horizontal = 6.dp, vertical = 4.dp), modifier = Modifier.padding(horizontal = 6.dp, vertical = 4.dp),
@@ -1175,6 +1231,7 @@ private fun GroupChatToolChip(
tool.exitCode == 0 -> stringResource(R.string.group_chat_tool_no_output_success) tool.exitCode == 0 -> stringResource(R.string.group_chat_tool_no_output_success)
else -> stringResource(R.string.group_chat_tool_no_output) else -> stringResource(R.string.group_chat_tool_no_output)
} }
val isOutputRtl = remember(outputText) { BidiUtils.isRtlText(outputText) }
Surface( Surface(
shape = RoundedCornerShape(4.dp), shape = RoundedCornerShape(4.dp),
@@ -1187,6 +1244,12 @@ private fun GroupChatToolChip(
style = style =
MaterialTheme.typography.bodySmall.copy( MaterialTheme.typography.bodySmall.copy(
fontFamily = FontFamily.Monospace, fontFamily = FontFamily.Monospace,
textDirection =
if (isOutputRtl) {
TextDirection.ContentOrRtl
} else {
TextDirection.Ltr
},
), ),
color = color =
if (tool.isError) { if (tool.isError) {