diff --git a/app/src/androidTest/java/com/m57/hermescontrol/ui/chat/components/ComposerInteractionTest.kt b/app/src/androidTest/java/com/m57/hermescontrol/ui/chat/components/ComposerInteractionTest.kt
index 546b767d..19530c0d 100644
--- a/app/src/androidTest/java/com/m57/hermescontrol/ui/chat/components/ComposerInteractionTest.kt
+++ b/app/src/androidTest/java/com/m57/hermescontrol/ui/chat/components/ComposerInteractionTest.kt
@@ -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()
+ }
}
diff --git a/app/src/main/java/com/m57/hermescontrol/ui/chat/components/AttachmentTray.kt b/app/src/main/java/com/m57/hermescontrol/ui/chat/components/AttachmentTray.kt
new file mode 100644
index 00000000..60eb3d0d
--- /dev/null
+++ b/app/src/main/java/com/m57/hermescontrol/ui/chat/components/AttachmentTray.kt
@@ -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),
+ )
+ }
+}
diff --git a/app/src/main/java/com/m57/hermescontrol/ui/chat/components/ChatComposer.kt b/app/src/main/java/com/m57/hermescontrol/ui/chat/components/ChatComposer.kt
index 03e9589f..c3d1a9b0 100644
--- a/app/src/main/java/com/m57/hermescontrol/ui/chat/components/ChatComposer.kt
+++ b/app/src/main/java/com/m57/hermescontrol/ui/chat/components/ChatComposer.kt
@@ -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,
+ )
}
}
}
diff --git a/app/src/main/java/com/m57/hermescontrol/ui/chat/components/ComposerToolbar.kt b/app/src/main/java/com/m57/hermescontrol/ui/chat/components/ComposerToolbar.kt
index 1d0a88a6..aab2a2f4 100644
--- a/app/src/main/java/com/m57/hermescontrol/ui/chat/components/ComposerToolbar.kt
+++ b/app/src/main/java/com/m57/hermescontrol/ui/chat/components/ComposerToolbar.kt
@@ -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,
diff --git a/app/src/main/res/values-ar/strings.xml b/app/src/main/res/values-ar/strings.xml
index 58f81a01..65666052 100644
--- a/app/src/main/res/values-ar/strings.xml
+++ b/app/src/main/res/values-ar/strings.xml
@@ -1084,6 +1084,9 @@
ينتهي في: %1$s
البحث في القنوات…
إرفاق ملف
+الكاميرا
+الصور
+ملف
سريع
الوضع السريع
الوضع السريع غير مدعوم لهذا النموذج
diff --git a/app/src/main/res/values-ko/strings.xml b/app/src/main/res/values-ko/strings.xml
index 4086ba14..1b60650f 100644
--- a/app/src/main/res/values-ko/strings.xml
+++ b/app/src/main/res/values-ko/strings.xml
@@ -928,6 +928,9 @@
취소됨
실패
%1$.1f초
+ 카메라
+ 사진
+ 파일
빠름
빠른 모드
이 모델에서는 빠른 모드를 사용할 수 없습니다
diff --git a/app/src/main/res/values-zh/strings.xml b/app/src/main/res/values-zh/strings.xml
index 93c06bd3..7f2a0873 100644
--- a/app/src/main/res/values-zh/strings.xml
+++ b/app/src/main/res/values-zh/strings.xml
@@ -1067,6 +1067,9 @@
过期时间:%1$s
搜索渠道…
附加文件
+ 相机
+ 照片
+ 文件
快速
快速模式
此模型不支持快速模式
diff --git a/app/src/main/res/values/strings.xml b/app/src/main/res/values/strings.xml
index f3d89cdd..d7308260 100644
--- a/app/src/main/res/values/strings.xml
+++ b/app/src/main/res/values/strings.xml
@@ -1244,6 +1244,9 @@
Expires: %1$s
Search channels…
Attach file
+ Camera
+ Photos
+ File
Fast
Fast mode
Fast mode unavailable for this model
diff --git a/app/src/test/java/com/m57/hermescontrol/ui/chat/components/AttachmentTrayPositionProviderTest.kt b/app/src/test/java/com/m57/hermescontrol/ui/chat/components/AttachmentTrayPositionProviderTest.kt
new file mode 100644
index 00000000..65956dc2
--- /dev/null
+++ b/app/src/test/java/com/m57/hermescontrol/ui/chat/components/AttachmentTrayPositionProviderTest.kt
@@ -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)
+ }
+}