revert(chat): remove busy-send panel and composer queue button (#1308)
This commit is contained in:
9 files changed
+2
-520
No files matched your search
@@ -10,13 +10,11 @@ import androidx.core.content.ContextCompat
|
||||
import androidx.test.ext.junit.runners.AndroidJUnit4
|
||||
import androidx.test.filters.MediumTest
|
||||
import com.m57.hermescontrol.data.model.Attachment
|
||||
import com.m57.hermescontrol.data.model.BusySendMode
|
||||
import com.m57.hermescontrol.data.ws.ConnectionStatus
|
||||
import com.m57.hermescontrol.ui.common.ActionProgressController
|
||||
import io.mockk.every
|
||||
import io.mockk.mockk
|
||||
import io.mockk.mockkStatic
|
||||
import io.mockk.verify
|
||||
import kotlinx.coroutines.CoroutineScope
|
||||
import kotlinx.coroutines.Dispatchers
|
||||
import kotlinx.coroutines.flow.MutableStateFlow
|
||||
@@ -128,37 +126,4 @@ class ChatScreenTest {
|
||||
composeTestRule.onNodeWithTag("chat_input").assertIsDisplayed()
|
||||
composeTestRule.onNodeWithTag("send_button").assertIsDisplayed()
|
||||
}
|
||||
|
||||
@Test
|
||||
fun chatScreen_primarySendUsesSavedBusyDefault() {
|
||||
val uiState =
|
||||
ChatUiState(
|
||||
connectionStatus = ConnectionStatus.CONNECTED,
|
||||
isSessionReady = true,
|
||||
busySendMode = BusySendMode.QUEUE,
|
||||
)
|
||||
val mockViewModel = mockk<ChatViewModel>(relaxed = true)
|
||||
every { mockViewModel.uiState } returns MutableStateFlow(uiState).asStateFlow()
|
||||
every { mockViewModel.streamingState } returns MutableStateFlow(StreamingState()).asStateFlow()
|
||||
every { mockViewModel.timelineState } returns MutableStateFlow(ChatTimelineState()).asStateFlow()
|
||||
every { mockViewModel.connectionOperationState } returns
|
||||
MutableStateFlow(ConnectionOperationUiState()).asStateFlow()
|
||||
every { mockViewModel.actionProgress } returns
|
||||
ActionProgressController(scope = CoroutineScope(Dispatchers.Main))
|
||||
every { mockViewModel.sendMessage(any(), any()) } returns true
|
||||
|
||||
composeTestRule.setContent {
|
||||
ChatScreen(
|
||||
onOpenDrawer = {},
|
||||
sessionId = null,
|
||||
viewModel = mockViewModel,
|
||||
)
|
||||
}
|
||||
|
||||
composeTestRule.onNodeWithTag("chat_input").performTextInput("Use my saved default")
|
||||
composeTestRule.onNodeWithTag("send_button").performClick()
|
||||
|
||||
verify(exactly = 1) { mockViewModel.sendMessage("Use my saved default", null) }
|
||||
verify(exactly = 0) { mockViewModel.sendMessage("Use my saved default", BusySendMode.CORRECT) }
|
||||
}
|
||||
}
|
||||
-153
@@ -32,7 +32,6 @@ import androidx.test.filters.MediumTest
|
||||
import androidx.test.platform.app.InstrumentationRegistry
|
||||
import com.m57.hermescontrol.R
|
||||
import com.m57.hermescontrol.data.model.Attachment
|
||||
import com.m57.hermescontrol.data.model.BusySendMode
|
||||
import com.m57.hermescontrol.data.ws.CommandCatalog
|
||||
import org.junit.Assert.assertEquals
|
||||
import org.junit.Rule
|
||||
@@ -58,158 +57,6 @@ class ComposerInteractionTest {
|
||||
private var photosTaps = 0
|
||||
private var fileTaps = 0
|
||||
|
||||
private fun setContextualComposer(
|
||||
text: String,
|
||||
isMainTurnBusy: Boolean,
|
||||
attachments: List<Attachment> = emptyList(),
|
||||
onSend: () -> Unit = {},
|
||||
onBusySend: (BusySendMode) -> Unit = {},
|
||||
isConnected: Boolean = true,
|
||||
pendingReasoningLevel: String? = null,
|
||||
) {
|
||||
composeTestRule.setContent {
|
||||
ChatInputBar(
|
||||
inputFieldValue = TextFieldValue(text),
|
||||
onInputChange = {},
|
||||
onSend = onSend,
|
||||
onBusySend = onBusySend,
|
||||
onMicTap = {},
|
||||
isListening = false,
|
||||
isAgentTyping = isMainTurnBusy,
|
||||
isMainTurnBusy = isMainTurnBusy,
|
||||
isConnected = isConnected,
|
||||
commandCatalog = CommandCatalog(),
|
||||
isSessionReady = true,
|
||||
pendingAttachments = attachments,
|
||||
pendingReasoningLevel = pendingReasoningLevel,
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
@Test
|
||||
fun idleDraft_showsRegularSendWithoutBusyActions() {
|
||||
setContextualComposer(text = "hello", isMainTurnBusy = false)
|
||||
|
||||
composeTestRule.onNodeWithTag("send_button").assertIsDisplayed()
|
||||
composeTestRule.onNodeWithTag("queue_button").assertDoesNotExist()
|
||||
}
|
||||
|
||||
@Test
|
||||
fun busyBlankDraft_hidesBusyActions() {
|
||||
setContextualComposer(text = "", isMainTurnBusy = true)
|
||||
|
||||
composeTestRule.onNodeWithTag("send_button").assertDoesNotExist()
|
||||
composeTestRule.onNodeWithTag("queue_button").assertDoesNotExist()
|
||||
}
|
||||
|
||||
@Test
|
||||
fun busyTextDraft_showsSendAndQueueAndRoutesCallbacks() {
|
||||
var regularSends = 0
|
||||
val selected = mutableListOf<BusySendMode>()
|
||||
setContextualComposer(
|
||||
text = "change course",
|
||||
isMainTurnBusy = true,
|
||||
onSend = { regularSends++ },
|
||||
onBusySend = selected::add,
|
||||
)
|
||||
|
||||
composeTestRule.onNodeWithTag("send_button").assertIsDisplayed().performClick()
|
||||
composeTestRule.onNodeWithTag("queue_button").assertIsDisplayed().performClick()
|
||||
|
||||
composeTestRule.runOnIdle {
|
||||
assertEquals(1, regularSends)
|
||||
assertEquals(listOf(BusySendMode.QUEUE), selected)
|
||||
}
|
||||
}
|
||||
|
||||
@Test
|
||||
fun busyAttachmentOnly_showsSendAndQueue() {
|
||||
val attachment = Attachment("content://document/1", "notes.txt", "text/plain")
|
||||
setContextualComposer(text = "", isMainTurnBusy = true, attachments = listOf(attachment))
|
||||
|
||||
composeTestRule.onNodeWithTag("send_button").assertIsDisplayed()
|
||||
composeTestRule.onNodeWithTag("queue_button").assertIsDisplayed()
|
||||
}
|
||||
|
||||
@Test
|
||||
fun busyDisconnectedDraft_disablesSendAndQueue() {
|
||||
setContextualComposer(text = "hello", isMainTurnBusy = true, isConnected = false)
|
||||
composeTestRule.onNodeWithTag("send_button").assertIsNotEnabled()
|
||||
composeTestRule.onNodeWithTag("queue_button").assertIsNotEnabled()
|
||||
}
|
||||
|
||||
@Test
|
||||
fun busyPendingReasoning_disablesSendAndQueue() {
|
||||
setContextualComposer(text = "hello", isMainTurnBusy = true, pendingReasoningLevel = "high")
|
||||
composeTestRule.onNodeWithTag("send_button").assertIsNotEnabled()
|
||||
composeTestRule.onNodeWithTag("queue_button").assertIsNotEnabled()
|
||||
}
|
||||
|
||||
@Test
|
||||
fun busySlashDraft_usesRegularSendWithoutBusyActions() {
|
||||
var regularSends = 0
|
||||
setContextualComposer(
|
||||
text = "/stop",
|
||||
isMainTurnBusy = true,
|
||||
onSend = { regularSends++ },
|
||||
)
|
||||
|
||||
composeTestRule.onNodeWithTag("queue_button").assertDoesNotExist()
|
||||
composeTestRule.onNodeWithTag("send_button").performClick()
|
||||
composeTestRule.runOnIdle { assertEquals(1, regularSends) }
|
||||
}
|
||||
|
||||
@Test
|
||||
fun busyBecomesIdle_hidesQueueAndPreservesTypedDraft() {
|
||||
val busy = mutableStateOf(true)
|
||||
composeTestRule.setContent {
|
||||
var input by remember { mutableStateOf(TextFieldValue("draft in progress")) }
|
||||
ChatInputBar(
|
||||
inputFieldValue = input,
|
||||
onInputChange = { input = it },
|
||||
onSend = {},
|
||||
onBusySend = {},
|
||||
onMicTap = {},
|
||||
isListening = false,
|
||||
isAgentTyping = busy.value,
|
||||
isMainTurnBusy = busy.value,
|
||||
isConnected = true,
|
||||
commandCatalog = CommandCatalog(),
|
||||
isSessionReady = true,
|
||||
)
|
||||
}
|
||||
|
||||
composeTestRule.onNodeWithTag("queue_button").assertIsDisplayed()
|
||||
composeTestRule.runOnUiThread { busy.value = false }
|
||||
composeTestRule.onNodeWithTag("queue_button").assertDoesNotExist()
|
||||
composeTestRule.onNodeWithTag("chat_input").assertTextEquals("draft in progress")
|
||||
composeTestRule.onNodeWithTag("send_button").assertIsDisplayed()
|
||||
}
|
||||
|
||||
@Test
|
||||
fun busyDraft_narrowWidthKeepsSendAndQueueAccessible() {
|
||||
composeTestRule.setContent {
|
||||
Box(Modifier.width(220.dp)) {
|
||||
ChatInputBar(
|
||||
inputFieldValue = TextFieldValue("change course"),
|
||||
onInputChange = {},
|
||||
onSend = {},
|
||||
onBusySend = {},
|
||||
onMicTap = {},
|
||||
isListening = false,
|
||||
isAgentTyping = true,
|
||||
isMainTurnBusy = true,
|
||||
isConnected = true,
|
||||
commandCatalog = CommandCatalog(),
|
||||
isSessionReady = true,
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
composeTestRule.onNodeWithTag("queue_button").assertIsDisplayed()
|
||||
composeTestRule.onNodeWithTag("send_button").assertIsDisplayed()
|
||||
}
|
||||
|
||||
/** Renders the real input bar with live text and a mic that toggles like ChatMediaLaunchers. */
|
||||
private fun setComposer(
|
||||
reasoningLevel: String? = "medium",
|
||||
|
||||
-98
@@ -1,98 +0,0 @@
|
||||
package com.m57.hermescontrol.ui.chat.components
|
||||
|
||||
import androidx.activity.ComponentActivity
|
||||
import androidx.compose.runtime.mutableStateOf
|
||||
import androidx.compose.ui.test.assertIsDisplayed
|
||||
import androidx.compose.ui.test.junit4.createAndroidComposeRule
|
||||
import androidx.compose.ui.test.onNodeWithText
|
||||
import androidx.compose.ui.test.performClick
|
||||
import androidx.test.ext.junit.runners.AndroidJUnit4
|
||||
import com.m57.hermescontrol.R
|
||||
import com.m57.hermescontrol.data.model.BusySendMode
|
||||
import com.m57.hermescontrol.ui.chat.PendingSend
|
||||
import com.m57.hermescontrol.ui.chat.PendingSendState
|
||||
import org.junit.Assert.assertEquals
|
||||
import org.junit.Rule
|
||||
import org.junit.Test
|
||||
import org.junit.runner.RunWith
|
||||
|
||||
@RunWith(AndroidJUnit4::class)
|
||||
class PendingSendPanelTest {
|
||||
@get:Rule
|
||||
val compose = createAndroidComposeRule<ComponentActivity>()
|
||||
|
||||
private fun row(state: PendingSendState) =
|
||||
PendingSend("receipt", "scope", "session", "My message", mode = BusySendMode.CORRECT, state = state)
|
||||
|
||||
@Test
|
||||
fun ordinarySendNeverShowsDeliveryPanel() {
|
||||
val state = mutableStateOf(PendingSendState.SENDING)
|
||||
compose.setContent { PendingSendPanel(listOf(row(state.value)), mainTurnBusy = false, onSendNow = {}) }
|
||||
compose.onNodeWithText("My message").assertDoesNotExist()
|
||||
compose.onNodeWithText(compose.activity.getString(R.string.chat_pending_sends)).assertDoesNotExist()
|
||||
compose.runOnIdle { state.value = PendingSendState.ACCEPTED }
|
||||
compose.onNodeWithText("My message").assertDoesNotExist()
|
||||
compose.onNodeWithText(compose.activity.getString(R.string.chat_pending_accepted)).assertDoesNotExist()
|
||||
}
|
||||
|
||||
@Test
|
||||
fun queueDisappearsWhenDispatchedWithoutAwaitingLabel() {
|
||||
val state = mutableStateOf(PendingSendState.QUEUED)
|
||||
compose.setContent { PendingSendPanel(listOf(row(state.value)), mainTurnBusy = false, onSendNow = {}) }
|
||||
compose.onNodeWithText(compose.activity.getString(R.string.chat_pending_queued)).assertIsDisplayed()
|
||||
compose.onNodeWithText(compose.activity.getString(R.string.chat_pending_send_now_warning)).assertDoesNotExist()
|
||||
compose.runOnIdle { state.value = PendingSendState.SENDING }
|
||||
compose.onNodeWithText("My message").assertDoesNotExist()
|
||||
compose.runOnIdle { state.value = PendingSendState.ACCEPTED }
|
||||
compose.onNodeWithText("My message").assertDoesNotExist()
|
||||
}
|
||||
|
||||
@Test
|
||||
fun failedAndUncertainDeliveryRemainRecoverable() {
|
||||
val state = mutableStateOf(PendingSendState.REJECTED)
|
||||
var retried: String? = null
|
||||
compose.setContent {
|
||||
PendingSendPanel(
|
||||
listOf(row(state.value)),
|
||||
mainTurnBusy = false,
|
||||
onSendNow = { retried = it },
|
||||
)
|
||||
}
|
||||
compose.onNodeWithText(compose.activity.getString(R.string.chat_pending_rejected)).assertIsDisplayed()
|
||||
compose.onNodeWithText(compose.activity.getString(R.string.chat_pending_send_now)).performClick()
|
||||
compose.runOnIdle {
|
||||
assertEquals("receipt", retried)
|
||||
state.value = PendingSendState.UNKNOWN
|
||||
}
|
||||
compose.onNodeWithText(compose.activity.getString(R.string.chat_pending_unknown)).assertIsDisplayed()
|
||||
compose.onNodeWithText(compose.activity.getString(R.string.chat_pending_send_now_warning)).assertIsDisplayed()
|
||||
}
|
||||
|
||||
@Test
|
||||
fun busyPanelWarnsAndLabelsEveryRecoverableActionAsStopAndSend() {
|
||||
val state = mutableStateOf(PendingSendState.QUEUED)
|
||||
compose.setContent {
|
||||
PendingSendPanel(
|
||||
sends = listOf(row(state.value)),
|
||||
mainTurnBusy = true,
|
||||
onSendNow = {},
|
||||
)
|
||||
}
|
||||
|
||||
listOf(
|
||||
PendingSendState.QUEUED,
|
||||
PendingSendState.PARKED,
|
||||
PendingSendState.REJECTED,
|
||||
PendingSendState.UNKNOWN,
|
||||
).forEach { pendingState ->
|
||||
compose.runOnIdle { state.value = pendingState }
|
||||
compose
|
||||
.onNodeWithText(
|
||||
compose.activity.getString(R.string.chat_pending_send_now_warning),
|
||||
).assertIsDisplayed()
|
||||
compose.onNodeWithText(compose.activity.getString(R.string.chat_busy_stop_and_send)).assertIsDisplayed()
|
||||
compose.onNodeWithText(compose.activity.getString(R.string.chat_pending_send_now)).assertDoesNotExist()
|
||||
compose.onNodeWithText(compose.activity.getString(R.string.chat_pending_send_again)).assertDoesNotExist()
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -1,17 +0,0 @@
|
||||
package com.m57.hermescontrol.ui.chat
|
||||
|
||||
/** Presentation only: retain queued messages internally for persistence and receipt matching. */
|
||||
internal fun messagesWithoutUnsentQueue(
|
||||
messages: List<ChatMessage>,
|
||||
pendingSends: List<PendingSend>,
|
||||
): List<ChatMessage> {
|
||||
val waitingIds =
|
||||
pendingSends
|
||||
.filter { it.state == PendingSendState.QUEUED || it.state == PendingSendState.PARKED }
|
||||
.map { it.id }
|
||||
.toSet()
|
||||
if (waitingIds.isEmpty()) return messages
|
||||
return messages.filterNot {
|
||||
it.role == MessageRole.USER && it.id in waitingIds && it.canonicalRestId == null
|
||||
}
|
||||
}
|
||||
@@ -87,7 +87,6 @@ import com.m57.hermescontrol.R
|
||||
import com.m57.hermescontrol.data.local.AuthManager
|
||||
import com.m57.hermescontrol.data.model.Attachment
|
||||
import com.m57.hermescontrol.data.model.AttachmentSource
|
||||
import com.m57.hermescontrol.data.model.BusySendMode
|
||||
import com.m57.hermescontrol.data.model.reasoningSupport
|
||||
import com.m57.hermescontrol.data.session.ActiveSessionHolder
|
||||
import com.m57.hermescontrol.data.ws.ConnectionStatus
|
||||
@@ -178,11 +177,7 @@ fun ChatScreen(
|
||||
// Snapshot-backed search state — read directly so only the scopes that
|
||||
// read its fields recompose on search changes (bar, matched bubbles).
|
||||
val searchState = viewModel.searchState
|
||||
val sourceMessages = timelineState.historyMessages ?: state.messages
|
||||
val displayedMessages =
|
||||
remember(sourceMessages, state.pendingSends) {
|
||||
messagesWithoutUnsentQueue(sourceMessages, state.pendingSends)
|
||||
}
|
||||
val displayedMessages = timelineState.historyMessages ?: state.messages
|
||||
val lifecycleOwner = LocalLifecycleOwner.current
|
||||
val context = LocalContext.current
|
||||
var browserAuthInFlight by rememberSaveable { mutableStateOf(false) }
|
||||
@@ -904,12 +899,6 @@ fun ChatScreen(
|
||||
},
|
||||
)
|
||||
|
||||
com.m57.hermescontrol.ui.chat.components.PendingSendPanel(
|
||||
sends = state.pendingSends,
|
||||
mainTurnBusy = state.isMainTurnBusy,
|
||||
onSendNow = viewModel::sendQueuedNow,
|
||||
)
|
||||
|
||||
ChatInputBar(
|
||||
inputFieldValue = inputFieldValue,
|
||||
onInputChange = { inputFieldValue = it },
|
||||
@@ -920,12 +909,6 @@ fun ChatScreen(
|
||||
scrollController.jumpToBottom(animated = true)
|
||||
}
|
||||
},
|
||||
onBusySend = { mode ->
|
||||
if (viewModel.sendMessage(inputFieldValue.text, mode)) {
|
||||
inputFieldValue = TextFieldValue("")
|
||||
scrollController.jumpToBottom(animated = true)
|
||||
}
|
||||
},
|
||||
onMicTap = mediaLaunchers.onMicTap,
|
||||
onMicHoldStart = mediaLaunchers.onMicHoldStart,
|
||||
onMicHoldEnd = mediaLaunchers.onMicHoldEnd,
|
||||
@@ -936,7 +919,6 @@ fun ChatScreen(
|
||||
isVoiceNoteLocked = mediaLaunchers.isVoiceNoteLocked,
|
||||
onStopGeneration = { viewModel.interruptSession() },
|
||||
isAgentTyping = state.isAgentTyping,
|
||||
isMainTurnBusy = state.isMainTurnBusy,
|
||||
canInterrupt = state.canInterrupt,
|
||||
isConnected = state.isConnected,
|
||||
isSessionReady = state.isSessionReady && !timelineState.isHistorical,
|
||||
|
||||
@@ -73,7 +73,6 @@ import androidx.compose.ui.unit.dp
|
||||
import coil3.compose.AsyncImage
|
||||
import com.m57.hermescontrol.R
|
||||
import com.m57.hermescontrol.data.model.Attachment
|
||||
import com.m57.hermescontrol.data.model.BusySendMode
|
||||
import com.m57.hermescontrol.data.model.ProfileInfo
|
||||
import com.m57.hermescontrol.data.ws.CommandBlocklist
|
||||
import com.m57.hermescontrol.data.ws.CommandCatalog
|
||||
@@ -90,11 +89,9 @@ fun ChatInputBar(
|
||||
inputFieldValue: TextFieldValue,
|
||||
onInputChange: (TextFieldValue) -> Unit,
|
||||
onSend: () -> Unit,
|
||||
onBusySend: (BusySendMode) -> Unit = {},
|
||||
onMicTap: () -> Unit,
|
||||
isListening: Boolean,
|
||||
isAgentTyping: Boolean,
|
||||
isMainTurnBusy: Boolean = false,
|
||||
canInterrupt: Boolean = false,
|
||||
isConnected: Boolean,
|
||||
commandCatalog: CommandCatalog,
|
||||
@@ -137,8 +134,6 @@ fun ChatInputBar(
|
||||
pendingReasoningLevel == null &&
|
||||
ChatInputPolicy.canSend(inputFieldValue.text, pendingAttachments, isConnected, isSessionReady)
|
||||
val hasDraft = inputFieldValue.text.isNotBlank() || pendingAttachments.isNotEmpty()
|
||||
val isSlashDraft = inputFieldValue.text.trimStart().startsWith("/")
|
||||
val showBusyActions = isMainTurnBusy && hasDraft && !isSlashDraft
|
||||
|
||||
// Attachment tray state
|
||||
var showAttachmentTray by remember { mutableStateOf(false) }
|
||||
@@ -429,10 +424,7 @@ fun ChatInputBar(
|
||||
isListening = isListening,
|
||||
canSend = canSend,
|
||||
showSend = hasDraft,
|
||||
showQueue = showBusyActions,
|
||||
onSend = onSend,
|
||||
onQueue = { onBusySend(BusySendMode.QUEUE) },
|
||||
onStopAndSend = { onBusySend(BusySendMode.INTERRUPT) },
|
||||
canInterrupt = canInterrupt,
|
||||
onStopGeneration = onStopGeneration,
|
||||
onAttachTap = { showAttachmentTray = !showAttachmentTray },
|
||||
|
||||
@@ -31,7 +31,6 @@ import androidx.compose.foundation.shape.CircleShape
|
||||
import androidx.compose.material.icons.Icons
|
||||
import androidx.compose.material.icons.automirrored.filled.Send
|
||||
import androidx.compose.material.icons.filled.Add
|
||||
import androidx.compose.material.icons.filled.AddToQueue
|
||||
import androidx.compose.material.icons.filled.Bolt
|
||||
import androidx.compose.material.icons.filled.ExpandMore
|
||||
import androidx.compose.material.icons.filled.Stop
|
||||
@@ -69,9 +68,7 @@ import androidx.compose.ui.platform.LocalDensity
|
||||
import androidx.compose.ui.platform.LocalHapticFeedback
|
||||
import androidx.compose.ui.platform.testTag
|
||||
import androidx.compose.ui.res.stringResource
|
||||
import androidx.compose.ui.semantics.CustomAccessibilityAction
|
||||
import androidx.compose.ui.semantics.contentDescription
|
||||
import androidx.compose.ui.semantics.customActions
|
||||
import androidx.compose.ui.semantics.semantics
|
||||
import androidx.compose.ui.text.font.FontWeight
|
||||
import androidx.compose.ui.text.style.TextOverflow
|
||||
@@ -113,10 +110,7 @@ fun ComposerToolbar(
|
||||
modifier: Modifier = Modifier,
|
||||
canSend: Boolean = false,
|
||||
showSend: Boolean = canSend,
|
||||
showQueue: Boolean = false,
|
||||
onSend: () -> Unit = {},
|
||||
onQueue: () -> Unit = {},
|
||||
onStopAndSend: () -> Unit = {},
|
||||
canDisableReasoning: Boolean? = null,
|
||||
supportsReasoning: Boolean? = null,
|
||||
fastMode: Boolean = false,
|
||||
@@ -562,27 +556,7 @@ fun ComposerToolbar(
|
||||
}
|
||||
}
|
||||
|
||||
AnimatedVisibility(
|
||||
visible = showQueue,
|
||||
enter = fadeIn() + scaleIn(initialScale = 0.8f),
|
||||
exit = fadeOut() + scaleOut(targetScale = 0.8f),
|
||||
) {
|
||||
FilledIconButton(
|
||||
onClick = onQueue,
|
||||
enabled = canSend,
|
||||
colors = flatIconButtonColors(palette),
|
||||
modifier = Modifier.size(48.dp).testTag("queue_button"),
|
||||
) {
|
||||
Icon(
|
||||
imageVector = Icons.Default.AddToQueue,
|
||||
contentDescription = stringResource(R.string.chat_busy_queue_action),
|
||||
modifier = Modifier.size(20.dp),
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
// Action button — send when a send is possible, mic / stop otherwise
|
||||
val stopAndSendLabel = stringResource(R.string.chat_busy_stop_and_send)
|
||||
// While a recording is locked this button finishes the voice note, so
|
||||
// it must stay enabled even when the draft alone would not send
|
||||
// (review, PR #1280).
|
||||
@@ -605,17 +579,7 @@ fun ComposerToolbar(
|
||||
} else {
|
||||
palette.action
|
||||
},
|
||||
).semantics {
|
||||
if (showSend && showQueue && canSend && !isVoiceNoteLocked) {
|
||||
customActions =
|
||||
listOf(
|
||||
CustomAccessibilityAction(stopAndSendLabel) {
|
||||
onStopAndSend()
|
||||
true
|
||||
},
|
||||
)
|
||||
}
|
||||
}.combinedClickable(
|
||||
).combinedClickable(
|
||||
enabled = actionEnabled,
|
||||
onClick = {
|
||||
when {
|
||||
@@ -631,8 +595,6 @@ fun ComposerToolbar(
|
||||
else -> onMicTap()
|
||||
}
|
||||
},
|
||||
onLongClick = if (showSend && showQueue && !isVoiceNoteLocked) onStopAndSend else null,
|
||||
onLongClickLabel = if (showSend && showQueue && !isVoiceNoteLocked) stopAndSendLabel else null,
|
||||
).testTag(
|
||||
when {
|
||||
isVoiceNoteLocked -> "voice_note_send_button"
|
||||
|
||||
@@ -1,103 +0,0 @@
|
||||
package com.m57.hermescontrol.ui.chat.components
|
||||
|
||||
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.lazy.LazyColumn
|
||||
import androidx.compose.foundation.lazy.items
|
||||
import androidx.compose.material3.MaterialTheme
|
||||
import androidx.compose.material3.Surface
|
||||
import androidx.compose.material3.Text
|
||||
import androidx.compose.material3.TextButton
|
||||
import androidx.compose.runtime.Composable
|
||||
import androidx.compose.ui.Modifier
|
||||
import androidx.compose.ui.res.stringResource
|
||||
import androidx.compose.ui.text.style.TextOverflow
|
||||
import androidx.compose.ui.unit.dp
|
||||
import com.m57.hermescontrol.R
|
||||
import com.m57.hermescontrol.ui.chat.PendingSend
|
||||
import com.m57.hermescontrol.ui.chat.PendingSendState
|
||||
|
||||
@Composable
|
||||
fun PendingSendPanel(
|
||||
sends: List<PendingSend>,
|
||||
mainTurnBusy: Boolean,
|
||||
onSendNow: (String) -> Unit,
|
||||
) {
|
||||
// Delivery receipts stay internal; only queued work and recovery need UI.
|
||||
val visibleSends = sends.filter { it.state != PendingSendState.SENDING && it.state != PendingSendState.ACCEPTED }
|
||||
if (visibleSends.isEmpty()) return
|
||||
Surface(
|
||||
modifier = Modifier.fillMaxWidth().padding(horizontal = 12.dp),
|
||||
shape = MaterialTheme.shapes.medium,
|
||||
color = MaterialTheme.colorScheme.surfaceContainer,
|
||||
) {
|
||||
Column(modifier = Modifier.padding(8.dp)) {
|
||||
Text(stringResource(R.string.chat_pending_sends), style = MaterialTheme.typography.titleSmall)
|
||||
if (mainTurnBusy || visibleSends.any { it.state == PendingSendState.UNKNOWN }) {
|
||||
Text(
|
||||
stringResource(R.string.chat_pending_send_now_warning),
|
||||
style = MaterialTheme.typography.bodySmall,
|
||||
color = MaterialTheme.colorScheme.onSurfaceVariant,
|
||||
)
|
||||
}
|
||||
LazyColumn(modifier = Modifier.heightIn(max = 170.dp)) {
|
||||
items(visibleSends, key = { it.id }) { send ->
|
||||
Row(modifier = Modifier.fillMaxWidth(), horizontalArrangement = Arrangement.SpaceBetween) {
|
||||
Column(modifier = Modifier.weight(1f)) {
|
||||
Text(
|
||||
send.text.ifBlank { stringResource(R.string.chat_pending_attachments) },
|
||||
maxLines = 2,
|
||||
overflow = TextOverflow.Ellipsis,
|
||||
)
|
||||
Text(
|
||||
text =
|
||||
when (send.state) {
|
||||
PendingSendState.QUEUED -> stringResource(R.string.chat_pending_queued)
|
||||
PendingSendState.PARKED -> stringResource(R.string.chat_pending_parked)
|
||||
PendingSendState.SENDING -> stringResource(R.string.chat_pending_sending)
|
||||
PendingSendState.ACCEPTED -> stringResource(R.string.chat_pending_accepted)
|
||||
PendingSendState.UNKNOWN -> stringResource(R.string.chat_pending_unknown)
|
||||
PendingSendState.REJECTED -> stringResource(R.string.chat_pending_rejected)
|
||||
},
|
||||
style = MaterialTheme.typography.bodySmall,
|
||||
color = MaterialTheme.colorScheme.onSurfaceVariant,
|
||||
)
|
||||
}
|
||||
if (send.state in
|
||||
setOf(
|
||||
PendingSendState.QUEUED,
|
||||
PendingSendState.PARKED,
|
||||
PendingSendState.UNKNOWN,
|
||||
PendingSendState.REJECTED,
|
||||
PendingSendState.ACCEPTED,
|
||||
)
|
||||
) {
|
||||
TextButton(onClick = { onSendNow(send.id) }) {
|
||||
Text(
|
||||
stringResource(
|
||||
if (mainTurnBusy) {
|
||||
R.string.chat_busy_stop_and_send
|
||||
} else if (send.state in
|
||||
setOf(
|
||||
PendingSendState.UNKNOWN,
|
||||
PendingSendState.ACCEPTED,
|
||||
)
|
||||
) {
|
||||
R.string.chat_pending_send_again
|
||||
} else {
|
||||
R.string.chat_pending_send_now
|
||||
},
|
||||
),
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -1,48 +0,0 @@
|
||||
package com.m57.hermescontrol.ui.chat
|
||||
|
||||
import com.m57.hermescontrol.data.model.BusySendMode
|
||||
import org.junit.Assert.assertEquals
|
||||
import org.junit.Assert.assertSame
|
||||
import org.junit.Assert.assertTrue
|
||||
import org.junit.Test
|
||||
|
||||
class ChatQueuePresentationTest {
|
||||
private val message = ChatMessage("queued-id", MessageRole.USER, "Later")
|
||||
|
||||
private fun pending(state: PendingSendState) =
|
||||
PendingSend(message.id, "scope", "session", message.content, mode = BusySendMode.QUEUE, state = state)
|
||||
|
||||
@Test
|
||||
fun queuedAndParkedMessagesRemainInternalUntilDispatch() {
|
||||
val messages = listOf(message)
|
||||
for (state in listOf(PendingSendState.QUEUED, PendingSendState.PARKED)) {
|
||||
val receipts = listOf(pending(state))
|
||||
assertTrue(messagesWithoutUnsentQueue(messages, receipts).isEmpty())
|
||||
assertEquals(listOf(message), messages)
|
||||
assertEquals(state, receipts.single().state)
|
||||
}
|
||||
}
|
||||
|
||||
@Test
|
||||
fun queuedMessageAppearsOnceWhenSendingStarts() {
|
||||
val messages = listOf(message)
|
||||
assertTrue(messagesWithoutUnsentQueue(messages, listOf(pending(PendingSendState.QUEUED))).isEmpty())
|
||||
for (state in listOf(PendingSendState.SENDING, PendingSendState.ACCEPTED)) {
|
||||
assertEquals(messages, messagesWithoutUnsentQueue(messages, listOf(pending(state))))
|
||||
}
|
||||
}
|
||||
|
||||
@Test
|
||||
fun ordinarySendAndOtherMessagesAreUnchanged() {
|
||||
val other = ChatMessage("other", MessageRole.USER, "Send now")
|
||||
val messages = listOf(other, message)
|
||||
assertSame(messages, messagesWithoutUnsentQueue(messages, emptyList()))
|
||||
assertEquals(listOf(other), messagesWithoutUnsentQueue(messages, listOf(pending(PendingSendState.QUEUED))))
|
||||
}
|
||||
|
||||
@Test
|
||||
fun canonicalHistoryIsNeverHiddenByStaleQueueReceipt() {
|
||||
val messages = listOf(message.copy(restId = "rest-session-12"))
|
||||
assertEquals(messages, messagesWithoutUnsentQueue(messages, listOf(pending(PendingSendState.QUEUED))))
|
||||
}
|
||||
}
|
||||
Reference in new issue
Block a user