feat(chat): redesign attachment picker tray (#1130)

This commit is contained in:
M57 authored and GitHub committed 2026-09-15 21:56:06 +03:00
1 parent 984053b0e7
commit afffcd95e8
9 files changed
+417 -81

No files matched your search

@@ -3,11 +3,13 @@ package com.m57.hermescontrol.ui.chat.components
import androidx.compose.foundation.layout.Box
import androidx.compose.foundation.layout.width
import androidx.compose.runtime.Composable
import androidx.compose.runtime.CompositionLocalProvider
import androidx.compose.runtime.getValue
import androidx.compose.runtime.mutableStateOf
import androidx.compose.runtime.remember
import androidx.compose.runtime.setValue
import androidx.compose.ui.Modifier
import androidx.compose.ui.platform.LocalLayoutDirection
import androidx.compose.ui.platform.testTag
import androidx.compose.ui.test.assertIsDisplayed
import androidx.compose.ui.test.getUnclippedBoundsInRoot
@@ -20,7 +22,9 @@ import androidx.compose.ui.test.performTouchInput
import androidx.compose.ui.test.swipeLeft
import androidx.compose.ui.text.input.TextFieldValue
import androidx.compose.ui.unit.Dp
import androidx.compose.ui.unit.LayoutDirection
import androidx.compose.ui.unit.dp
import androidx.test.espresso.Espresso.pressBack
import androidx.test.ext.junit.runners.AndroidJUnit4
import androidx.test.filters.MediumTest
import com.m57.hermescontrol.data.ws.CommandCatalog
@@ -44,6 +48,9 @@ class ComposerInteractionTest {
private var sends = 0
private var modelTaps = 0
private var selectedLevel: String? = null
private var cameraTaps = 0
private var photosTaps = 0
private var fileTaps = 0
/** Renders the real input bar with live text and a mic that toggles like ChatMediaLaunchers. */
private fun setComposer(
@@ -51,44 +58,63 @@ class ComposerInteractionTest {
model: String = "openai/gpt-5.5",
composerWidth: Dp? = null,
modelAfterTap: String? = null,
layoutDirection: LayoutDirection = LayoutDirection.Ltr,
) {
composeTestRule.setContent {
var input by remember { mutableStateOf(TextFieldValue("")) }
var listening by remember { mutableStateOf(false) }
var currentModel by remember { mutableStateOf(model) }
val composer: @Composable () -> Unit = {
ChatInputBar(
inputFieldValue = input,
onInputChange = { input = it },
onSend = {
sends++
input = TextFieldValue("")
},
onMicTap = {
micTaps++
listening = !listening
},
isListening = listening,
isAgentTyping = false,
isConnected = true,
commandCatalog = CommandCatalog(),
currentSessionModel = currentModel,
reasoningLevel = reasoningLevel,
onModelTap = {
modelTaps++
modelAfterTap?.let { currentModel = it }
},
onReasoningTap = { selectedLevel = it },
)
}
if (composerWidth == null) {
composer()
} else {
Box(Modifier.width(composerWidth).testTag("composer_toolbar")) { composer() }
CompositionLocalProvider(LocalLayoutDirection provides layoutDirection) {
val composer: @Composable () -> Unit = {
ChatInputBar(
inputFieldValue = input,
onInputChange = { input = it },
onSend = {
sends++
input = TextFieldValue("")
},
onMicTap = {
micTaps++
listening = !listening
},
isListening = listening,
isAgentTyping = false,
isConnected = true,
commandCatalog = CommandCatalog(),
currentSessionModel = currentModel,
reasoningLevel = reasoningLevel,
onModelTap = {
modelTaps++
modelAfterTap?.let { currentModel = it }
},
onReasoningTap = { selectedLevel = it },
onCameraTap = { cameraTaps++ },
onImageTap = { photosTaps++ },
onFileTap = { fileTaps++ },
)
}
if (composerWidth == null) {
composer()
} else {
Box(Modifier.width(composerWidth).testTag("composer_toolbar")) { composer() }
}
}
}
}
private fun openAttachmentTray() {
composeTestRule.onNodeWithTag("attachment_button").performClick()
composeTestRule.mainClock.advanceTimeBy(300)
composeTestRule.onNodeWithTag("attachment_tray").assertIsDisplayed()
}
private fun closeAttachmentTray() {
composeTestRule.onNodeWithTag("attachment_button").performClick()
composeTestRule.mainClock.advanceTimeBy(300)
composeTestRule.onNodeWithTag("attachment_tray").assertDoesNotExist()
}
@Test
fun emptyInput_actionButtonStartsDictation() {
setComposer()
@@ -247,4 +273,74 @@ class ComposerInteractionTest {
assertEquals("model selection should still invoke the picker", 1, modelTaps)
}
}
@Test
fun attachmentButton_togglesHorizontalTray() {
setComposer()
openAttachmentTray()
composeTestRule.onNodeWithText("Camera").assertIsDisplayed()
composeTestRule.onNodeWithText("Photos").assertIsDisplayed()
composeTestRule.onNodeWithText("File").assertIsDisplayed()
closeAttachmentTray()
}
@Test
fun attachmentTray_shrinksWithinConstrainedWidth() {
setComposer(composerWidth = 180.dp)
openAttachmentTray()
composeTestRule.onNodeWithText("Camera").assertIsDisplayed()
composeTestRule.onNodeWithText("Photos").assertIsDisplayed()
composeTestRule.onNodeWithText("File").assertIsDisplayed()
}
@Test
fun attachmentTray_rtlKeepsActionsAccessible() {
setComposer(layoutDirection = LayoutDirection.Rtl)
openAttachmentTray()
composeTestRule.onNodeWithText("Camera").assertIsDisplayed()
composeTestRule.onNodeWithText("Photos").assertIsDisplayed()
composeTestRule.onNodeWithText("File").assertIsDisplayed()
}
@Test
fun attachmentTray_actionsDispatchAndCloseTray() {
setComposer()
openAttachmentTray()
composeTestRule.onNodeWithTag("attachment_action_camera").performClick()
composeTestRule.mainClock.advanceTimeBy(300)
composeTestRule.runOnIdle {
assertEquals("Camera must dispatch its callback", 1, cameraTaps)
assertEquals("Camera must not dispatch Photos", 0, photosTaps)
assertEquals("Camera must not dispatch File", 0, fileTaps)
}
composeTestRule.onNodeWithTag("attachment_tray").assertDoesNotExist()
openAttachmentTray()
composeTestRule.onNodeWithTag("attachment_action_photos").performClick()
composeTestRule.mainClock.advanceTimeBy(300)
composeTestRule.runOnIdle { assertEquals("Photos must dispatch its callback", 1, photosTaps) }
composeTestRule.onNodeWithTag("attachment_tray").assertDoesNotExist()
openAttachmentTray()
composeTestRule.onNodeWithTag("attachment_action_file").performClick()
composeTestRule.mainClock.advanceTimeBy(300)
composeTestRule.runOnIdle { assertEquals("File must dispatch its callback", 1, fileTaps) }
composeTestRule.onNodeWithTag("attachment_tray").assertDoesNotExist()
}
@Test
fun attachmentTray_dismissesOnBackPress() {
setComposer()
openAttachmentTray()
pressBack()
composeTestRule.mainClock.advanceTimeBy(300)
composeTestRule.onNodeWithTag("attachment_tray").assertDoesNotExist()
}
}
@@ -0,0 +1,229 @@
package com.m57.hermescontrol.ui.chat.components
import androidx.compose.animation.AnimatedVisibility
import androidx.compose.animation.core.MutableTransitionState
import androidx.compose.animation.core.tween
import androidx.compose.animation.fadeIn
import androidx.compose.animation.fadeOut
import androidx.compose.animation.scaleIn
import androidx.compose.animation.scaleOut
import androidx.compose.animation.slideInVertically
import androidx.compose.animation.slideOutVertically
import androidx.compose.foundation.BorderStroke
import androidx.compose.foundation.clickable
import androidx.compose.foundation.layout.Arrangement
import androidx.compose.foundation.layout.Column
import androidx.compose.foundation.layout.Row
import androidx.compose.foundation.layout.fillMaxWidth
import androidx.compose.foundation.layout.heightIn
import androidx.compose.foundation.layout.padding
import androidx.compose.foundation.layout.size
import androidx.compose.foundation.layout.width
import androidx.compose.foundation.layout.widthIn
import androidx.compose.material.icons.Icons
import androidx.compose.material.icons.automirrored.filled.InsertDriveFile
import androidx.compose.material.icons.filled.CameraAlt
import androidx.compose.material.icons.filled.PhotoLibrary
import androidx.compose.material3.Icon
import androidx.compose.material3.MaterialTheme
import androidx.compose.material3.Surface
import androidx.compose.material3.Text
import androidx.compose.runtime.Composable
import androidx.compose.runtime.LaunchedEffect
import androidx.compose.runtime.remember
import androidx.compose.ui.Alignment
import androidx.compose.ui.Modifier
import androidx.compose.ui.graphics.vector.ImageVector
import androidx.compose.ui.platform.LocalDensity
import androidx.compose.ui.platform.testTag
import androidx.compose.ui.res.stringResource
import androidx.compose.ui.semantics.Role
import androidx.compose.ui.semantics.contentDescription
import androidx.compose.ui.semantics.role
import androidx.compose.ui.semantics.semantics
import androidx.compose.ui.unit.IntOffset
import androidx.compose.ui.unit.IntRect
import androidx.compose.ui.unit.IntSize
import androidx.compose.ui.unit.LayoutDirection
import androidx.compose.ui.unit.dp
import androidx.compose.ui.window.Popup
import androidx.compose.ui.window.PopupPositionProvider
import androidx.compose.ui.window.PopupProperties
import com.m57.hermescontrol.R
/**
* Compact attachment tray that extends the composer without looking like a
* separate system menu. It is hosted in a focusable popup so outside taps and
* the system back action dismiss it naturally.
*/
@Composable
internal fun AttachmentTray(
visible: Boolean,
onDismissRequest: () -> Unit,
onCameraTap: () -> Unit,
onImageTap: () -> Unit,
onFileTap: () -> Unit,
) {
val transitionState = remember { MutableTransitionState(visible) }
LaunchedEffect(visible) {
transitionState.targetState = visible
}
if (!visible && !transitionState.currentState) {
return
}
val marginPx = with(LocalDensity.current) { 8.dp.roundToPx() }
val trayPositionProvider =
remember(marginPx) {
AttachmentTrayPositionProvider(marginPx = marginPx)
}
Popup(
popupPositionProvider = trayPositionProvider,
onDismissRequest = onDismissRequest,
properties =
PopupProperties(
focusable = visible,
dismissOnBackPress = visible,
dismissOnClickOutside = visible,
),
) {
AnimatedVisibility(
visibleState = transitionState,
enter =
fadeIn(animationSpec = tween(durationMillis = 160)) +
slideInVertically(
animationSpec = tween(durationMillis = 160),
initialOffsetY = { it / 4 },
) +
scaleIn(
animationSpec = tween(durationMillis = 160),
initialScale = 0.96f,
),
exit =
fadeOut(animationSpec = tween(durationMillis = 120)) +
slideOutVertically(
animationSpec = tween(durationMillis = 120),
targetOffsetY = { it / 4 },
) +
scaleOut(
animationSpec = tween(durationMillis = 120),
targetScale = 0.96f,
),
) {
val palette = composerPalette()
Surface(
modifier =
Modifier
.widthIn(max = 280.dp)
.testTag("attachment_tray"),
shape = MaterialTheme.shapes.large,
color = palette.card,
border = BorderStroke(width = 1.dp, color = palette.cardBorder),
) {
Row(
modifier =
Modifier
.fillMaxWidth()
.padding(horizontal = 8.dp, vertical = 8.dp),
horizontalArrangement = Arrangement.spacedBy(4.dp),
) {
AttachmentTrayAction(
icon = Icons.Filled.CameraAlt,
label = stringResource(R.string.chat_attachment_camera),
onClick = {
onDismissRequest()
onCameraTap()
},
modifier = Modifier.weight(1f).testTag("attachment_action_camera"),
)
AttachmentTrayAction(
icon = Icons.Filled.PhotoLibrary,
label = stringResource(R.string.chat_attachment_photos),
onClick = {
onDismissRequest()
onImageTap()
},
modifier = Modifier.weight(1f).testTag("attachment_action_photos"),
)
AttachmentTrayAction(
icon = Icons.AutoMirrored.Filled.InsertDriveFile,
label = stringResource(R.string.chat_attachment_file),
onClick = {
onDismissRequest()
onFileTap()
},
modifier = Modifier.weight(1f).testTag("attachment_action_file"),
)
}
}
}
}
}
@Composable
private fun AttachmentTrayAction(
icon: ImageVector,
label: String,
onClick: () -> Unit,
modifier: Modifier = Modifier,
) {
Column(
modifier =
modifier
.heightIn(min = 72.dp)
.clickable(onClick = onClick)
.semantics(mergeDescendants = true) {
contentDescription = label
role = Role.Button
}.padding(horizontal = 4.dp, vertical = 6.dp),
horizontalAlignment = Alignment.CenterHorizontally,
verticalArrangement = Arrangement.Center,
) {
Icon(
imageVector = icon,
contentDescription = null,
tint = MaterialTheme.colorScheme.primary,
modifier = Modifier.size(24.dp),
)
Text(
text = label,
style = MaterialTheme.typography.labelMedium,
color = MaterialTheme.colorScheme.onSurface,
maxLines = 2,
softWrap = true,
textAlign = androidx.compose.ui.text.style.TextAlign.Center,
)
}
}
internal class AttachmentTrayPositionProvider(
private val marginPx: Int,
) : PopupPositionProvider {
override fun calculatePosition(
anchorBounds: IntRect,
windowSize: IntSize,
layoutDirection: LayoutDirection,
popupContentSize: IntSize,
): IntOffset {
val minX = marginPx
val maxX =
(windowSize.width - popupContentSize.width - marginPx)
.coerceAtLeast(minX)
val x =
if (layoutDirection == LayoutDirection.Ltr) {
anchorBounds.left + marginPx
} else {
anchorBounds.right - popupContentSize.width - marginPx
}
val yAbove = anchorBounds.top - popupContentSize.height - marginPx
val yBelow = anchorBounds.bottom + marginPx
val maxY = (windowSize.height - popupContentSize.height).coerceAtLeast(0)
val y = if (yAbove >= 0) yAbove else yBelow
return IntOffset(
x = x.coerceIn(minX, maxX),
y = y.coerceIn(0, maxY),
)
}
}
@@ -29,7 +29,6 @@ import androidx.compose.foundation.text.BasicTextField
import androidx.compose.material.icons.Icons
import androidx.compose.material.icons.automirrored.filled.InsertDriveFile
import androidx.compose.material.icons.filled.Close
import androidx.compose.material3.DropdownMenu
import androidx.compose.material3.DropdownMenuItem
import androidx.compose.material3.Icon
import androidx.compose.material3.IconButton
@@ -57,7 +56,6 @@ import androidx.compose.ui.text.style.TextDirection
import androidx.compose.ui.text.style.TextOverflow
import androidx.compose.ui.unit.LayoutDirection
import androidx.compose.ui.unit.dp
import androidx.compose.ui.unit.sp
import coil3.compose.AsyncImage
import com.m57.hermescontrol.R
import com.m57.hermescontrol.data.model.Attachment
@@ -107,8 +105,8 @@ fun ChatInputBar(
// dropped. Slash commands were already allowed; regular prompts now are too.
val canSend = ChatInputPolicy.canSend(inputFieldValue.text, pendingAttachments, isConnected)
// Attachment menu state
var showAttachmentMenu by remember { mutableStateOf(false) }
// Attachment tray state
var showAttachmentTray by remember { mutableStateOf(false) }
val palette = composerPalette()
AnimatedVisibility(
@@ -299,7 +297,7 @@ fun ChatInputBar(
isListening = isListening,
canSend = canSend,
onSend = onSend,
onAttachTap = { showAttachmentMenu = true },
onAttachTap = { showAttachmentTray = !showAttachmentTray },
onModelTap = onModelTap,
onReasoningSelected = onReasoningTap,
onMicTap = onMicTap,
@@ -312,52 +310,13 @@ fun ChatInputBar(
onToggleFastMode = onToggleFastMode,
)
// Attachment dropdown (anchored to the attach button in ComposerToolbar)
// Shown overlaid at the toolbar level
DropdownMenu(
expanded = showAttachmentMenu,
onDismissRequest = { showAttachmentMenu = false },
) {
DropdownMenuItem(
text = { Text("Camera") },
onClick = {
showAttachmentMenu = false
onCameraTap()
},
leadingIcon = {
Text(
text = "📷",
fontSize = 18.sp,
)
},
)
DropdownMenuItem(
text = { Text("Image") },
onClick = {
showAttachmentMenu = false
onImageTap()
},
leadingIcon = {
Text(
text = "🖼️",
fontSize = 18.sp,
)
},
)
DropdownMenuItem(
text = { Text("File") },
onClick = {
showAttachmentMenu = false
onFileTap()
},
leadingIcon = {
Text(
text = "📄",
fontSize = 18.sp,
)
},
)
}
AttachmentTray(
visible = showAttachmentTray,
onDismissRequest = { showAttachmentTray = false },
onCameraTap = onCameraTap,
onImageTap = onImageTap,
onFileTap = onFileTap,
)
}
}
}
@@ -123,7 +123,7 @@ fun ComposerToolbar(
onClick = onAttachTap,
enabled = isConnected,
colors = flatIconButtonColors(palette),
modifier = Modifier.size(ControlSize),
modifier = Modifier.size(ControlSize).testTag("attachment_button"),
) {
Icon(
imageVector = Icons.Default.Add,
+3
View File
@@ -1084,6 +1084,9 @@
<string name="channels_expiry_format">ينتهي في: %1$s</string>
<string name="channels_search_placeholder">البحث في القنوات…</string>
<string name="chat_attach_file">إرفاق ملف</string>
<string name="chat_attachment_camera">الكاميرا</string>
<string name="chat_attachment_photos">الصور</string>
<string name="chat_attachment_file">ملف</string>
<string name="chat_fast_mode_label">سريع</string>
<string name="chat_fast_mode_desc">الوضع السريع</string>
<string name="chat_fast_mode_unavailable">الوضع السريع غير مدعوم لهذا النموذج</string>
+3
View File
@@ -928,6 +928,9 @@
<string name="subagent_status_cancelled">취소됨</string>
<string name="subagent_status_failed">실패</string>
<string name="subagent_duration">%1$.1f초</string>
<string name="chat_attachment_camera">카메라</string>
<string name="chat_attachment_photos">사진</string>
<string name="chat_attachment_file">파일</string>
<string name="chat_fast_mode_label">빠름</string>
<string name="chat_fast_mode_desc">빠른 모드</string>
<string name="chat_fast_mode_unavailable">이 모델에서는 빠른 모드를 사용할 수 없습니다</string>
+3
View File
@@ -1067,6 +1067,9 @@
<string name="channels_expiry_format">过期时间:%1$s</string>
<string name="channels_search_placeholder">搜索渠道…</string>
<string name="chat_attach_file">附加文件</string>
<string name="chat_attachment_camera">相机</string>
<string name="chat_attachment_photos">照片</string>
<string name="chat_attachment_file">文件</string>
<string name="chat_fast_mode_label">快速</string>
<string name="chat_fast_mode_desc">快速模式</string>
<string name="chat_fast_mode_unavailable">此模型不支持快速模式</string>
+3
View File
@@ -1244,6 +1244,9 @@
<string name="channels_expiry_format">Expires: %1$s</string>
<string name="channels_search_placeholder">Search channels…</string>
<string name="chat_attach_file">Attach file</string>
<string name="chat_attachment_camera">Camera</string>
<string name="chat_attachment_photos">Photos</string>
<string name="chat_attachment_file">File</string>
<string name="chat_fast_mode_label">Fast</string>
<string name="chat_fast_mode_desc">Fast mode</string>
<string name="chat_fast_mode_unavailable">Fast mode unavailable for this model</string>
@@ -0,0 +1,40 @@
package com.m57.hermescontrol.ui.chat.components
import androidx.compose.ui.unit.IntOffset
import androidx.compose.ui.unit.IntRect
import androidx.compose.ui.unit.IntSize
import androidx.compose.ui.unit.LayoutDirection
import org.junit.Assert.assertEquals
import org.junit.Test
class AttachmentTrayPositionProviderTest {
private val provider = AttachmentTrayPositionProvider(marginPx = 16)
private val windowSize = IntSize(width = 500, height = 1_000)
private val popupSize = IntSize(width = 280, height = 100)
@Test
fun ltr_clampsToVisibleWindowWithHorizontalMargin() {
val position =
provider.calculatePosition(
anchorBounds = IntRect(left = 400, top = 700, right = 500, bottom = 900),
windowSize = windowSize,
layoutDirection = LayoutDirection.Ltr,
popupContentSize = popupSize,
)
assertEquals(IntOffset(x = 204, y = 584), position)
}
@Test
fun rtl_clampsToVisibleWindowWithHorizontalMargin() {
val position =
provider.calculatePosition(
anchorBounds = IntRect(left = 0, top = 700, right = 100, bottom = 900),
windowSize = windowSize,
layoutDirection = LayoutDirection.Rtl,
popupContentSize = popupSize,
)
assertEquals(IntOffset(x = 16, y = 584), position)
}
}