Merge pull request #1431 from Hy4ri/codex/release-v2.0.2
chore: promote dev for v2.0.2
This commit is contained in:
13 files changed
+332
-14
No files matched your search
+120
-2
@@ -1,27 +1,42 @@
|
|||||||
package com.m57.hermescontrol.ui.chat.components
|
package com.m57.hermescontrol.ui.chat.components
|
||||||
|
|
||||||
|
import android.content.res.Configuration
|
||||||
|
import android.graphics.Bitmap
|
||||||
|
import androidx.activity.ComponentActivity
|
||||||
import androidx.compose.material3.MaterialTheme
|
import androidx.compose.material3.MaterialTheme
|
||||||
import androidx.compose.ui.test.assertIsDisplayed
|
import androidx.compose.ui.test.assertIsDisplayed
|
||||||
import androidx.compose.ui.test.assertIsNotEnabled
|
import androidx.compose.ui.test.assertIsNotEnabled
|
||||||
import androidx.compose.ui.test.junit4.createComposeRule
|
import androidx.compose.ui.test.hasScrollAction
|
||||||
|
import androidx.compose.ui.test.isDisplayed
|
||||||
|
import androidx.compose.ui.test.junit4.createAndroidComposeRule
|
||||||
|
import androidx.compose.ui.test.onNodeWithContentDescription
|
||||||
import androidx.compose.ui.test.onNodeWithTag
|
import androidx.compose.ui.test.onNodeWithTag
|
||||||
import androidx.compose.ui.test.onNodeWithText
|
import androidx.compose.ui.test.onNodeWithText
|
||||||
import androidx.compose.ui.test.performClick
|
import androidx.compose.ui.test.performClick
|
||||||
import androidx.compose.ui.test.performScrollTo
|
import androidx.compose.ui.test.performScrollTo
|
||||||
|
import androidx.compose.ui.test.performTouchInput
|
||||||
|
import androidx.compose.ui.test.swipeUp
|
||||||
import androidx.test.ext.junit.runners.AndroidJUnit4
|
import androidx.test.ext.junit.runners.AndroidJUnit4
|
||||||
|
import androidx.test.platform.app.InstrumentationRegistry
|
||||||
|
import com.m57.hermescontrol.data.model.Attachment
|
||||||
import com.m57.hermescontrol.data.model.BusySendMode
|
import com.m57.hermescontrol.data.model.BusySendMode
|
||||||
|
import com.m57.hermescontrol.ui.chat.ChatMessage
|
||||||
|
import com.m57.hermescontrol.ui.chat.MessageRole
|
||||||
import com.m57.hermescontrol.ui.chat.PendingSend
|
import com.m57.hermescontrol.ui.chat.PendingSend
|
||||||
import com.m57.hermescontrol.ui.chat.PendingSendState
|
import com.m57.hermescontrol.ui.chat.PendingSendState
|
||||||
|
import com.m57.hermescontrol.ui.chat.UserBubble
|
||||||
import org.junit.Assert.assertEquals
|
import org.junit.Assert.assertEquals
|
||||||
import org.junit.Assert.assertNull
|
import org.junit.Assert.assertNull
|
||||||
import org.junit.Rule
|
import org.junit.Rule
|
||||||
import org.junit.Test
|
import org.junit.Test
|
||||||
import org.junit.runner.RunWith
|
import org.junit.runner.RunWith
|
||||||
|
import java.io.File
|
||||||
|
import java.util.Locale
|
||||||
|
|
||||||
@RunWith(AndroidJUnit4::class)
|
@RunWith(AndroidJUnit4::class)
|
||||||
class PendingSendRecoveryTest {
|
class PendingSendRecoveryTest {
|
||||||
@get:Rule
|
@get:Rule
|
||||||
val compose = createComposeRule()
|
val compose = createAndroidComposeRule<ComponentActivity>()
|
||||||
|
|
||||||
private val uncertain =
|
private val uncertain =
|
||||||
PendingSend(
|
PendingSend(
|
||||||
@@ -33,6 +48,16 @@ class PendingSendRecoveryTest {
|
|||||||
state = PendingSendState.UNKNOWN,
|
state = PendingSendState.UNKNOWN,
|
||||||
)
|
)
|
||||||
|
|
||||||
|
private fun imageAttachment(): Attachment {
|
||||||
|
val context = InstrumentationRegistry.getInstrumentation().targetContext
|
||||||
|
val file = File(context.cacheDir, "photo.png")
|
||||||
|
val bitmap = Bitmap.createBitmap(32, 32, Bitmap.Config.ARGB_8888)
|
||||||
|
bitmap.eraseColor(android.graphics.Color.rgb(20, 100, 160))
|
||||||
|
file.outputStream().use { bitmap.compress(Bitmap.CompressFormat.PNG, 100, it) }
|
||||||
|
bitmap.recycle()
|
||||||
|
return Attachment(file.toURI().toString(), file.name, "image/png", file.length())
|
||||||
|
}
|
||||||
|
|
||||||
@Test
|
@Test
|
||||||
fun uncertainRetryRequiresExplicitConfirmation() {
|
fun uncertainRetryRequiresExplicitConfirmation() {
|
||||||
var retried: String? = null
|
var retried: String? = null
|
||||||
@@ -82,6 +107,99 @@ class PendingSendRecoveryTest {
|
|||||||
compose.onNodeWithTag("pending_send_recovery").assertDoesNotExist()
|
compose.onNodeWithTag("pending_send_recovery").assertDoesNotExist()
|
||||||
}
|
}
|
||||||
|
|
||||||
|
@Test
|
||||||
|
fun acceptedPromptShowsTextImageAndStatusWithoutRecoveryControl() {
|
||||||
|
val image = imageAttachment()
|
||||||
|
val accepted = uncertain.copy(state = PendingSendState.ACCEPTED, attachments = listOf(image))
|
||||||
|
compose.setContent {
|
||||||
|
MaterialTheme {
|
||||||
|
androidx.compose.foundation.layout.Column {
|
||||||
|
UserBubble(
|
||||||
|
ChatMessage(accepted.id, MessageRole.USER, accepted.text, attachments = accepted.attachments),
|
||||||
|
pendingSendState = accepted.state,
|
||||||
|
)
|
||||||
|
PendingSendRecovery(listOf(accepted), true, true, {}, {})
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
compose.onNodeWithText(accepted.text).assertIsDisplayed()
|
||||||
|
compose.waitUntil(5_000) {
|
||||||
|
compose
|
||||||
|
.onNodeWithContentDescription("photo.png")
|
||||||
|
.fetchSemanticsNode()
|
||||||
|
.boundsInRoot.height > 0
|
||||||
|
}
|
||||||
|
compose.onNodeWithContentDescription("photo.png").assertIsDisplayed()
|
||||||
|
compose.onNodeWithText("Accepted by server").assertIsDisplayed()
|
||||||
|
compose.onNodeWithTag("pending_send_recovery").assertDoesNotExist()
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test
|
||||||
|
fun recoveryIdentifiesImagesAndFilesAndCanOpenAnImage() {
|
||||||
|
val image = imageAttachment()
|
||||||
|
val file = Attachment("file:///private/note", "note.txt", "text/plain", 4)
|
||||||
|
var openedImage: String? = null
|
||||||
|
var openedFile: String? = null
|
||||||
|
compose.setContent {
|
||||||
|
MaterialTheme {
|
||||||
|
PendingSendRecovery(
|
||||||
|
listOf(uncertain.copy(attachments = listOf(image, file))),
|
||||||
|
false,
|
||||||
|
false,
|
||||||
|
{},
|
||||||
|
{},
|
||||||
|
onOpenAttachment = { openedFile = it.name },
|
||||||
|
onImageClick = { openedImage = it.name },
|
||||||
|
)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
compose.onNodeWithTag("pending_send_recovery").performClick()
|
||||||
|
compose.onNodeWithText("photo.png").assertIsDisplayed()
|
||||||
|
compose.waitUntil(5_000) {
|
||||||
|
compose
|
||||||
|
.onNodeWithContentDescription("photo.png")
|
||||||
|
.fetchSemanticsNode()
|
||||||
|
.boundsInRoot.height > 0
|
||||||
|
}
|
||||||
|
compose.onNodeWithContentDescription("photo.png").assertIsDisplayed().performClick()
|
||||||
|
compose.runOnIdle { assertEquals("photo.png", openedImage) }
|
||||||
|
// The filename shares a lazy item with the image; scrollToIndex cannot reveal its lower children.
|
||||||
|
if (!compose.onNodeWithText("note.txt").isDisplayed()) {
|
||||||
|
compose.onNode(hasScrollAction()).performTouchInput { swipeUp() }
|
||||||
|
}
|
||||||
|
compose.onNodeWithText("note.txt").assertIsDisplayed().performClick()
|
||||||
|
compose.runOnIdle { assertEquals("note.txt", openedFile) }
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test
|
||||||
|
@Suppress("DEPRECATION")
|
||||||
|
fun chineseRecoveryCountAndDismissAreLocalized() {
|
||||||
|
// Native dialog windows resolve the Activity locale, not a synthetic LocalContext.
|
||||||
|
val resources = compose.activity.resources
|
||||||
|
val original = Configuration(resources.configuration)
|
||||||
|
val chinese = Configuration(original).apply { setLocale(Locale.SIMPLIFIED_CHINESE) }
|
||||||
|
try {
|
||||||
|
compose.runOnUiThread { resources.updateConfiguration(chinese, resources.displayMetrics) }
|
||||||
|
compose.setContent {
|
||||||
|
MaterialTheme {
|
||||||
|
PendingSendRecovery(listOf(uncertain), false, false, {}, {})
|
||||||
|
}
|
||||||
|
}
|
||||||
|
compose.onNodeWithText("1 条待处理消息").assertIsDisplayed().performClick()
|
||||||
|
compose
|
||||||
|
.onNodeWithText("移除")
|
||||||
|
.performScrollTo()
|
||||||
|
.assertIsDisplayed()
|
||||||
|
.performClick()
|
||||||
|
compose
|
||||||
|
.onNodeWithText(
|
||||||
|
"要从本机队列中移除这条消息吗?这不会取消任务,也不会删除服务器已接收的消息。",
|
||||||
|
).assertIsDisplayed()
|
||||||
|
} finally {
|
||||||
|
compose.runOnUiThread { resources.updateConfiguration(original, resources.displayMetrics) }
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
@Test
|
@Test
|
||||||
fun longUncertainMessageKeepsConfirmationActionsVisible() {
|
fun longUncertainMessageKeepsConfirmationActionsVisible() {
|
||||||
compose.setContent {
|
compose.setContent {
|
||||||
|
|||||||
@@ -859,6 +859,8 @@ fun ChatScreen(
|
|||||||
mainTurnBusy = state.isMainTurnBusy,
|
mainTurnBusy = state.isMainTurnBusy,
|
||||||
onSendAgain = viewModel::sendQueuedNow,
|
onSendAgain = viewModel::sendQueuedNow,
|
||||||
onDiscard = viewModel::discardPendingSend,
|
onDiscard = viewModel::discardPendingSend,
|
||||||
|
onOpenAttachment = viewModel::openAttachment,
|
||||||
|
onImageClick = { viewingImage = it },
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -158,9 +158,12 @@ internal fun messagesWithoutUnconfirmedReceipts(
|
|||||||
val recoveryIds =
|
val recoveryIds =
|
||||||
pending
|
pending
|
||||||
.filter {
|
.filter {
|
||||||
it.state == PendingSendState.ACCEPTED || it.state == PendingSendState.UNKNOWN ||
|
it.state == PendingSendState.UNKNOWN || it.state == PendingSendState.REJECTED
|
||||||
it.state == PendingSendState.REJECTED
|
|
||||||
}.mapTo(mutableSetOf()) { it.id }
|
}.mapTo(mutableSetOf()) { it.id }
|
||||||
if (recoveryIds.isEmpty()) return messages
|
if (recoveryIds.isEmpty()) return messages
|
||||||
return messages.filterNot { it.id in recoveryIds && it.canonicalRestId == null }
|
return messages.filterNot { it.id in recoveryIds && it.canonicalRestId == null }
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/** #1427: normal submission/acceptance stays in the transcript, not in recovery. */
|
||||||
|
internal val PendingSend.needsRecovery: Boolean
|
||||||
|
get() = state != PendingSendState.SENDING && state != PendingSendState.ACCEPTED
|
||||||
@@ -543,6 +543,7 @@ class ChatViewModel(
|
|||||||
private val sessionRequestById = ConcurrentHashMap<String, SessionRequest>()
|
private val sessionRequestById = ConcurrentHashMap<String, SessionRequest>()
|
||||||
private val branchWholeRequests = ConcurrentHashMap<String, PendingBranchRequest>()
|
private val branchWholeRequests = ConcurrentHashMap<String, PendingBranchRequest>()
|
||||||
private val outgoingRequestById = ConcurrentHashMap<String, String>()
|
private val outgoingRequestById = ConcurrentHashMap<String, String>()
|
||||||
|
private val acceptedTurnEpochById = ConcurrentHashMap<String, Long>()
|
||||||
private val hardInterruptRequestById = ConcurrentHashMap<String, HardInterruptRequest>()
|
private val hardInterruptRequestById = ConcurrentHashMap<String, HardInterruptRequest>()
|
||||||
private val queuedStagingIds = ConcurrentHashMap.newKeySet<String>()
|
private val queuedStagingIds = ConcurrentHashMap.newKeySet<String>()
|
||||||
private val pendingSendReservationLock = Any()
|
private val pendingSendReservationLock = Any()
|
||||||
@@ -1702,6 +1703,17 @@ class ChatViewModel(
|
|||||||
}
|
}
|
||||||
when {
|
when {
|
||||||
accepted -> {
|
accepted -> {
|
||||||
|
// #1427: a gateway-queued prompt belongs to a future turn, not the
|
||||||
|
// completion currently on screen. Keep it live until that turn is verified.
|
||||||
|
val receipt = sendStore.all().firstOrNull { it.id == messageId }
|
||||||
|
acceptedTurnEpochById[messageId] =
|
||||||
|
if (status == "queued" ||
|
||||||
|
(!mainTurnBusy && (receipt == null || lastMainCompletionAt < receipt.createdAt))
|
||||||
|
) {
|
||||||
|
mainTurnEpoch + 1
|
||||||
|
} else {
|
||||||
|
mainTurnEpoch
|
||||||
|
}
|
||||||
// #1285: the submit ack names the row written for THIS input. Absent = unproven.
|
// #1285: the submit ack names the row written for THIS input. Absent = unproven.
|
||||||
positiveRowId(resultMap?.get("user_row_id"))?.let { rowId ->
|
positiveRowId(resultMap?.get("user_row_id"))?.let { rowId ->
|
||||||
sendStore.update(messageId) { it.copy(userRowId = rowId) }
|
sendStore.update(messageId) { it.copy(userRowId = rowId) }
|
||||||
@@ -2631,12 +2643,14 @@ class ChatViewModel(
|
|||||||
state: PendingSendState,
|
state: PendingSendState,
|
||||||
) {
|
) {
|
||||||
sendStore.update(messageId) { it.copy(state = state) }
|
sendStore.update(messageId) { it.copy(state = state) }
|
||||||
|
if (state != PendingSendState.ACCEPTED) acceptedTurnEpochById.remove(messageId)
|
||||||
publishPendingSends()
|
publishPendingSends()
|
||||||
// #1427: settled/uncertain receipts remain recoverable, but cannot freeze later sends.
|
// #1427: settled/uncertain receipts remain recoverable, but cannot freeze later sends.
|
||||||
if (state != PendingSendState.SENDING) drainPendingQueue()
|
if (state != PendingSendState.SENDING) drainPendingQueue()
|
||||||
}
|
}
|
||||||
|
|
||||||
private fun removePendingSend(messageId: String) {
|
private fun removePendingSend(messageId: String) {
|
||||||
|
acceptedTurnEpochById.remove(messageId)
|
||||||
sendStore.remove(messageId)
|
sendStore.remove(messageId)
|
||||||
viewModelScope.launch(ioDispatcher) { deleteQueuedAttachmentSnapshot(messageId) }
|
viewModelScope.launch(ioDispatcher) { deleteQueuedAttachmentSnapshot(messageId) }
|
||||||
publishPendingSends()
|
publishPendingSends()
|
||||||
@@ -5648,6 +5662,17 @@ class ChatViewModel(
|
|||||||
val requestSequence = activeHydrationRequestSequence
|
val requestSequence = activeHydrationRequestSequence
|
||||||
val valid = { isCurrentHydration(sessionId, generation, requestSequence) }
|
val valid = { isCurrentHydration(sessionId, generation, requestSequence) }
|
||||||
val useLatest = latestPaging
|
val useLatest = latestPaging
|
||||||
|
// #1427: acceptance alone is normal live UX. Only a completed turn followed by
|
||||||
|
// successful history verification can move its still-unmatched receipts into recovery.
|
||||||
|
// Capture the rows before suspending so a later prompt/retry cannot be quarantined.
|
||||||
|
val completionAt = lastMainCompletionAt
|
||||||
|
val completedTurnEpoch = mainTurnEpoch
|
||||||
|
val unverifiedAccepted =
|
||||||
|
sendStore.all().filter {
|
||||||
|
it.scope == sendScope() && it.sessionId == sessionId &&
|
||||||
|
it.state == PendingSendState.ACCEPTED && it.createdAt <= completionAt &&
|
||||||
|
acceptedTurnEpochById[it.id]?.let { epoch -> epoch <= completedTurnEpoch } == true
|
||||||
|
}
|
||||||
val nextOffset =
|
val nextOffset =
|
||||||
if (useLatest) {
|
if (useLatest) {
|
||||||
0
|
0
|
||||||
@@ -5682,6 +5707,13 @@ class ChatViewModel(
|
|||||||
)
|
)
|
||||||
} ?: return@launch
|
} ?: return@launch
|
||||||
persistHistoryPage(page, sessionId)
|
persistHistoryPage(page, sessionId)
|
||||||
|
if (valid() && !mainTurnBusy && mainTurnEpoch == completedTurnEpoch) {
|
||||||
|
unverifiedAccepted.forEach { receipt ->
|
||||||
|
if (sendStore.all().any { it == receipt }) {
|
||||||
|
markPendingSend(receipt.id, PendingSendState.UNKNOWN)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
// Never derive the older cursor from displayed rows or reset it to this latest page.
|
// Never derive the older cursor from displayed rows or reset it to this latest page.
|
||||||
// Append-only growth shifts from-end offsets toward newer rows: the next older
|
// Append-only growth shifts from-end offsets toward newer rows: the next older
|
||||||
// request may overlap, but stable IDs remove echoes without skipping any history.
|
// request may overlap, but stable IDs remove echoes without skipping any history.
|
||||||
|
|||||||
@@ -81,6 +81,7 @@ fun UserBubble(
|
|||||||
messageStatsEnabled: Boolean = false,
|
messageStatsEnabled: Boolean = false,
|
||||||
showUserMessageTokens: Boolean = true,
|
showUserMessageTokens: Boolean = true,
|
||||||
modifier: Modifier = Modifier,
|
modifier: Modifier = Modifier,
|
||||||
|
pendingSendState: PendingSendState? = null,
|
||||||
) {
|
) {
|
||||||
val screenWidth = LocalConfiguration.current.screenWidthDp.dp
|
val screenWidth = LocalConfiguration.current.screenWidthDp.dp
|
||||||
val maxBubbleWidth = screenWidth * 0.80f
|
val maxBubbleWidth = screenWidth * 0.80f
|
||||||
@@ -182,6 +183,23 @@ fun UserBubble(
|
|||||||
canSave = canSaveAttachment,
|
canSave = canSaveAttachment,
|
||||||
onImageClick = onImageClick,
|
onImageClick = onImageClick,
|
||||||
)
|
)
|
||||||
|
// #1427: keep the live text and media visible with an honest delivery label.
|
||||||
|
if (pendingSendState == PendingSendState.SENDING ||
|
||||||
|
pendingSendState == PendingSendState.ACCEPTED
|
||||||
|
) {
|
||||||
|
Text(
|
||||||
|
stringResource(
|
||||||
|
if (pendingSendState == PendingSendState.SENDING) {
|
||||||
|
R.string.chat_pending_sending
|
||||||
|
} else {
|
||||||
|
R.string.chat_send_accepted
|
||||||
|
},
|
||||||
|
),
|
||||||
|
color = userBubbleTextColor,
|
||||||
|
style = MaterialTheme.typography.labelSmall,
|
||||||
|
modifier = Modifier.testTag("user_send_status_${message.id}"),
|
||||||
|
)
|
||||||
|
}
|
||||||
if (!message.isStreaming) {
|
if (!message.isStreaming) {
|
||||||
FlowRow(
|
FlowRow(
|
||||||
modifier =
|
modifier =
|
||||||
|
|||||||
@@ -27,8 +27,12 @@ import androidx.compose.ui.res.stringResource
|
|||||||
import androidx.compose.ui.text.style.TextOverflow
|
import androidx.compose.ui.text.style.TextOverflow
|
||||||
import androidx.compose.ui.unit.dp
|
import androidx.compose.ui.unit.dp
|
||||||
import com.m57.hermescontrol.R
|
import com.m57.hermescontrol.R
|
||||||
|
import com.m57.hermescontrol.data.model.Attachment
|
||||||
|
import com.m57.hermescontrol.ui.chat.ImageViewerModel
|
||||||
|
import com.m57.hermescontrol.ui.chat.InlineAttachment
|
||||||
import com.m57.hermescontrol.ui.chat.PendingSend
|
import com.m57.hermescontrol.ui.chat.PendingSend
|
||||||
import com.m57.hermescontrol.ui.chat.PendingSendState
|
import com.m57.hermescontrol.ui.chat.PendingSendState
|
||||||
|
import com.m57.hermescontrol.ui.chat.needsRecovery
|
||||||
|
|
||||||
/** #1427: a compact recovery entry keeps uncertain delivery visible without a floating queue panel. */
|
/** #1427: a compact recovery entry keeps uncertain delivery visible without a floating queue panel. */
|
||||||
@OptIn(ExperimentalMaterial3Api::class)
|
@OptIn(ExperimentalMaterial3Api::class)
|
||||||
@@ -39,8 +43,11 @@ fun PendingSendRecovery(
|
|||||||
mainTurnBusy: Boolean,
|
mainTurnBusy: Boolean,
|
||||||
onSendAgain: (String) -> Unit,
|
onSendAgain: (String) -> Unit,
|
||||||
onDiscard: (String) -> Unit,
|
onDiscard: (String) -> Unit,
|
||||||
|
onOpenAttachment: (Attachment) -> Unit = {},
|
||||||
|
onImageClick: (ImageViewerModel) -> Unit = {},
|
||||||
) {
|
) {
|
||||||
if (sends.isEmpty()) return
|
val recoverySends = sends.filter { it.needsRecovery }
|
||||||
|
if (recoverySends.isEmpty()) return
|
||||||
var open by remember { mutableStateOf(false) }
|
var open by remember { mutableStateOf(false) }
|
||||||
var retryId by remember { mutableStateOf<String?>(null) }
|
var retryId by remember { mutableStateOf<String?>(null) }
|
||||||
var discardId by remember { mutableStateOf<String?>(null) }
|
var discardId by remember { mutableStateOf<String?>(null) }
|
||||||
@@ -53,7 +60,7 @@ fun PendingSendRecovery(
|
|||||||
},
|
},
|
||||||
modifier = Modifier.fillMaxWidth().heightIn(min = 48.dp).testTag("pending_send_recovery"),
|
modifier = Modifier.fillMaxWidth().heightIn(min = 48.dp).testTag("pending_send_recovery"),
|
||||||
) {
|
) {
|
||||||
Text(pluralStringResource(R.plurals.chat_pending_count, sends.size, sends.size))
|
Text(pluralStringResource(R.plurals.chat_pending_count, recoverySends.size, recoverySends.size))
|
||||||
}
|
}
|
||||||
if (open) {
|
if (open) {
|
||||||
ModalBottomSheet(onDismissRequest = { open = false }) {
|
ModalBottomSheet(onDismissRequest = { open = false }) {
|
||||||
@@ -63,7 +70,7 @@ fun PendingSendRecovery(
|
|||||||
modifier = Modifier.padding(horizontal = 16.dp, vertical = 8.dp),
|
modifier = Modifier.padding(horizontal = 16.dp, vertical = 8.dp),
|
||||||
)
|
)
|
||||||
LazyColumn(modifier = Modifier.fillMaxWidth().heightIn(max = 480.dp)) {
|
LazyColumn(modifier = Modifier.fillMaxWidth().heightIn(max = 480.dp)) {
|
||||||
items(sends, key = { it.id }) { send ->
|
items(recoverySends, key = { it.id }) { send ->
|
||||||
Column(
|
Column(
|
||||||
modifier = Modifier.fillMaxWidth().padding(horizontal = 16.dp, vertical = 8.dp),
|
modifier = Modifier.fillMaxWidth().padding(horizontal = 16.dp, vertical = 8.dp),
|
||||||
verticalArrangement = Arrangement.spacedBy(8.dp),
|
verticalArrangement = Arrangement.spacedBy(8.dp),
|
||||||
@@ -83,8 +90,20 @@ fun PendingSendRecovery(
|
|||||||
style = MaterialTheme.typography.bodySmall,
|
style = MaterialTheme.typography.bodySmall,
|
||||||
color = MaterialTheme.colorScheme.onSurfaceVariant,
|
color = MaterialTheme.colorScheme.onSurfaceVariant,
|
||||||
)
|
)
|
||||||
if (send.attachments.isNotEmpty()) {
|
send.attachments.forEach { attachment ->
|
||||||
Text(stringResource(R.string.chat_pending_attachments))
|
if (attachment.isImage || attachment.isAudio || attachment.isVideo) {
|
||||||
|
Text(attachment.name, style = MaterialTheme.typography.bodyMedium)
|
||||||
|
}
|
||||||
|
InlineAttachment(
|
||||||
|
attachment = attachment,
|
||||||
|
textColor = MaterialTheme.colorScheme.onSurface,
|
||||||
|
onOpen = onOpenAttachment,
|
||||||
|
onSave = {},
|
||||||
|
savingPath = null,
|
||||||
|
openingPath = null,
|
||||||
|
canSave = false,
|
||||||
|
onImageClick = onImageClick,
|
||||||
|
)
|
||||||
}
|
}
|
||||||
Row {
|
Row {
|
||||||
TextButton(
|
TextButton(
|
||||||
@@ -125,7 +144,7 @@ fun PendingSendRecovery(
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
sends.firstOrNull { it.id == retryId }?.let { send ->
|
recoverySends.firstOrNull { it.id == retryId }?.let { send ->
|
||||||
AlertDialog(
|
AlertDialog(
|
||||||
onDismissRequest = { retryId = null },
|
onDismissRequest = { retryId = null },
|
||||||
title = { Text(stringResource(R.string.chat_pending_send_again)) },
|
title = { Text(stringResource(R.string.chat_pending_send_again)) },
|
||||||
@@ -151,7 +170,7 @@ fun PendingSendRecovery(
|
|||||||
},
|
},
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
sends.firstOrNull { it.id == discardId }?.let { send ->
|
recoverySends.firstOrNull { it.id == discardId }?.let { send ->
|
||||||
AlertDialog(
|
AlertDialog(
|
||||||
onDismissRequest = { discardId = null },
|
onDismissRequest = { discardId = null },
|
||||||
title = { Text(stringResource(R.string.chat_pending_discard)) },
|
title = { Text(stringResource(R.string.chat_pending_discard)) },
|
||||||
|
|||||||
@@ -35,6 +35,7 @@ import com.m57.hermescontrol.theme.LocalChatFontScale
|
|||||||
import com.m57.hermescontrol.ui.chat.ChatMessage
|
import com.m57.hermescontrol.ui.chat.ChatMessage
|
||||||
import com.m57.hermescontrol.ui.chat.ChatSearchState
|
import com.m57.hermescontrol.ui.chat.ChatSearchState
|
||||||
import com.m57.hermescontrol.ui.chat.ImageViewerModel
|
import com.m57.hermescontrol.ui.chat.ImageViewerModel
|
||||||
|
import com.m57.hermescontrol.ui.chat.PendingSendState
|
||||||
import com.m57.hermescontrol.ui.chat.SearchTarget
|
import com.m57.hermescontrol.ui.chat.SearchTarget
|
||||||
import com.m57.hermescontrol.ui.chat.ToolCallDivider
|
import com.m57.hermescontrol.ui.chat.ToolCallDivider
|
||||||
import com.m57.hermescontrol.ui.chat.UserBubble
|
import com.m57.hermescontrol.ui.chat.UserBubble
|
||||||
@@ -311,6 +312,7 @@ fun FullBleedChatList(
|
|||||||
onImageClick = actions.onImageClick,
|
onImageClick = actions.onImageClick,
|
||||||
messageStatsEnabled = messageStatsEnabled,
|
messageStatsEnabled = messageStatsEnabled,
|
||||||
showUserMessageTokens = showUserMessageTokens,
|
showUserMessageTokens = showUserMessageTokens,
|
||||||
|
pendingSendState = transcript.pendingSendStates[userMessage.id],
|
||||||
)
|
)
|
||||||
if (userMessage.reactions.isNotEmpty()) {
|
if (userMessage.reactions.isNotEmpty()) {
|
||||||
MessageReactionChips(
|
MessageReactionChips(
|
||||||
@@ -561,6 +563,7 @@ private fun renderUserBubble(
|
|||||||
onImageClick: (ImageViewerModel) -> Unit,
|
onImageClick: (ImageViewerModel) -> Unit,
|
||||||
messageStatsEnabled: Boolean,
|
messageStatsEnabled: Boolean,
|
||||||
showUserMessageTokens: Boolean,
|
showUserMessageTokens: Boolean,
|
||||||
|
pendingSendState: PendingSendState?,
|
||||||
) {
|
) {
|
||||||
UserBubble(
|
UserBubble(
|
||||||
message = message,
|
message = message,
|
||||||
@@ -574,5 +577,6 @@ private fun renderUserBubble(
|
|||||||
onImageClick = onImageClick,
|
onImageClick = onImageClick,
|
||||||
messageStatsEnabled = messageStatsEnabled,
|
messageStatsEnabled = messageStatsEnabled,
|
||||||
showUserMessageTokens = showUserMessageTokens,
|
showUserMessageTokens = showUserMessageTokens,
|
||||||
|
pendingSendState = pendingSendState,
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
@@ -6,6 +6,7 @@ import com.m57.hermescontrol.ui.chat.ChatTimelineState
|
|||||||
import com.m57.hermescontrol.ui.chat.ChatUiState
|
import com.m57.hermescontrol.ui.chat.ChatUiState
|
||||||
import com.m57.hermescontrol.ui.chat.ClarifyUi
|
import com.m57.hermescontrol.ui.chat.ClarifyUi
|
||||||
import com.m57.hermescontrol.ui.chat.ImageViewerModel
|
import com.m57.hermescontrol.ui.chat.ImageViewerModel
|
||||||
|
import com.m57.hermescontrol.ui.chat.PendingSendState
|
||||||
import com.m57.hermescontrol.ui.chat.StreamingState
|
import com.m57.hermescontrol.ui.chat.StreamingState
|
||||||
import com.m57.hermescontrol.ui.chat.VaultCodePromptUi
|
import com.m57.hermescontrol.ui.chat.VaultCodePromptUi
|
||||||
import com.m57.hermescontrol.ui.chat.VaultSaveLoginPromptUi
|
import com.m57.hermescontrol.ui.chat.VaultSaveLoginPromptUi
|
||||||
@@ -37,6 +38,7 @@ data class TranscriptUiState(
|
|||||||
val isCompressing: Boolean,
|
val isCompressing: Boolean,
|
||||||
val compressionStatus: String?,
|
val compressionStatus: String?,
|
||||||
val speakingMessageId: String?,
|
val speakingMessageId: String?,
|
||||||
|
val pendingSendStates: Map<String, PendingSendState> = emptyMap(),
|
||||||
) {
|
) {
|
||||||
companion object {
|
companion object {
|
||||||
/** Resolve historical versus live mode at the state boundary, not in the screen call. */
|
/** Resolve historical versus live mode at the state boundary, not in the screen call. */
|
||||||
@@ -76,6 +78,8 @@ data class TranscriptUiState(
|
|||||||
isCompressing = !historical && chat.isCompressing,
|
isCompressing = !historical && chat.isCompressing,
|
||||||
compressionStatus = chat.compressionStatus.takeUnless { historical },
|
compressionStatus = chat.compressionStatus.takeUnless { historical },
|
||||||
speakingMessageId = speakingMessageId,
|
speakingMessageId = speakingMessageId,
|
||||||
|
pendingSendStates =
|
||||||
|
if (historical) emptyMap() else chat.pendingSends.associate { it.id to it.state },
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1535,6 +1535,12 @@
|
|||||||
<string name="chat_busy_override">本条消息的发送方式</string>
|
<string name="chat_busy_override">本条消息的发送方式</string>
|
||||||
<string name="chat_busy_override_once">此选择仅生效一次,不会更改保存的默认值。</string>
|
<string name="chat_busy_override_once">此选择仅生效一次,不会更改保存的默认值。</string>
|
||||||
<string name="chat_pending_sends">待发送消息</string>
|
<string name="chat_pending_sends">待发送消息</string>
|
||||||
|
<string name="chat_send_accepted">服务器已接收</string>
|
||||||
|
<plurals name="chat_pending_count">
|
||||||
|
<item quantity="other">%d 条待处理消息</item>
|
||||||
|
</plurals>
|
||||||
|
<string name="chat_pending_discard">移除</string>
|
||||||
|
<string name="chat_pending_discard_warning">要从本机队列中移除这条消息吗?这不会取消任务,也不会删除服务器已接收的消息。</string>
|
||||||
<string name="chat_pending_queued">已在本机排队</string>
|
<string name="chat_pending_queued">已在本机排队</string>
|
||||||
<string name="chat_pending_parked">停止后已暂停</string>
|
<string name="chat_pending_parked">停止后已暂停</string>
|
||||||
<string name="chat_pending_sending">正在发送…</string>
|
<string name="chat_pending_sending">正在发送…</string>
|
||||||
|
|||||||
@@ -219,6 +219,7 @@
|
|||||||
<string name="chat_busy_override">Send mode for this message</string>
|
<string name="chat_busy_override">Send mode for this message</string>
|
||||||
<string name="chat_busy_override_once">This choice applies once; the saved default stays the same.</string>
|
<string name="chat_busy_override_once">This choice applies once; the saved default stays the same.</string>
|
||||||
<string name="chat_pending_sends">Pending messages</string>
|
<string name="chat_pending_sends">Pending messages</string>
|
||||||
|
<string name="chat_send_accepted">Accepted by server</string>
|
||||||
<plurals name="chat_pending_count">
|
<plurals name="chat_pending_count">
|
||||||
<item quantity="one">%d pending message</item>
|
<item quantity="one">%d pending message</item>
|
||||||
<item quantity="other">%d pending messages</item>
|
<item quantity="other">%d pending messages</item>
|
||||||
|
|||||||
@@ -210,7 +210,7 @@ class ChatSendStoreTest {
|
|||||||
val local = ChatMessage("send-1", MessageRole.USER, "repeat", serverRowId = 10)
|
val local = ChatMessage("send-1", MessageRole.USER, "repeat", serverRowId = 10)
|
||||||
val server = ChatMessage("rest-session-20", MessageRole.USER, "repeat", serverRowId = 20)
|
val server = ChatMessage("rest-session-20", MessageRole.USER, "repeat", serverRowId = 20)
|
||||||
val reply = ChatMessage("rest-session-21", MessageRole.ASSISTANT, "done", serverRowId = 21)
|
val reply = ChatMessage("rest-session-21", MessageRole.ASSISTANT, "done", serverRowId = 21)
|
||||||
val receipt = pending("send-1", "repeat").copy(userRowId = 10)
|
val receipt = pending("send-1", "repeat", PendingSendState.UNKNOWN).copy(userRowId = 10)
|
||||||
val merged = mergeTranscriptWithLive(listOf(server, reply), listOf(local), preserveLiveIds = true)
|
val merged = mergeTranscriptWithLive(listOf(server, reply), listOf(local), preserveLiveIds = true)
|
||||||
|
|
||||||
assertEquals(
|
assertEquals(
|
||||||
@@ -228,4 +228,21 @@ class ChatSendStoreTest {
|
|||||||
|
|
||||||
assertEquals(listOf(queued, confirmed), messagesWithoutUnconfirmedReceipts(listOf(queued, confirmed), pending))
|
assertEquals(listOf(queued, confirmed), messagesWithoutUnconfirmedReceipts(listOf(queued, confirmed), pending))
|
||||||
}
|
}
|
||||||
|
|
||||||
|
@Test
|
||||||
|
fun normalSendingAndAcceptedPromptsAreNotRecoveryItems() {
|
||||||
|
val sends = PendingSendState.entries.map { pending(it.name, "prompt", it) }
|
||||||
|
|
||||||
|
assertEquals(
|
||||||
|
setOf(
|
||||||
|
PendingSendState.QUEUED,
|
||||||
|
PendingSendState.PARKED,
|
||||||
|
PendingSendState.UNKNOWN,
|
||||||
|
PendingSendState.REJECTED,
|
||||||
|
),
|
||||||
|
sends.filter { it.needsRecovery }.map { it.state }.toSet(),
|
||||||
|
)
|
||||||
|
val live = sends.filterNot { it.needsRecovery }.map { ChatMessage(it.id, MessageRole.USER, it.text) }
|
||||||
|
assertEquals(live, messagesWithoutUnconfirmedReceipts(live, sends))
|
||||||
|
}
|
||||||
}
|
}
|
||||||
@@ -10540,6 +10540,62 @@ class ChatViewModelTest {
|
|||||||
assertTrue(vm.uiState.value.canInterrupt)
|
assertTrue(vm.uiState.value.canInterrupt)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
@Test
|
||||||
|
fun normalAcceptanceKeepsPromptVisibleBeforeAndDuringItsTurn() =
|
||||||
|
runTest {
|
||||||
|
val store = ChatSendStore()
|
||||||
|
val (vm, session) = createViewModelWithSession(sendStore = store)
|
||||||
|
every { HermesWsClient.sendMessage(session, "live-prompt", any(), any()) } answers {
|
||||||
|
arg<((String) -> Unit)?>(2)?.invoke("live-submit")
|
||||||
|
"live-submit"
|
||||||
|
}
|
||||||
|
assertTrue(vm.sendMessage("live-prompt"))
|
||||||
|
advanceUntilIdle()
|
||||||
|
mockEventsFlow.emit(WsEvent.RpcResult("live-submit", mapOf("status" to "streaming", "user_row_id" to 10)))
|
||||||
|
advanceUntilIdle()
|
||||||
|
vm.syncCurrentSession()
|
||||||
|
advanceUntilIdle()
|
||||||
|
assertEquals(PendingSendState.ACCEPTED, store.all().single().state)
|
||||||
|
assertEquals(
|
||||||
|
"live-prompt",
|
||||||
|
vm.transcriptState.value.messages
|
||||||
|
.single { it.role == MessageRole.USER }
|
||||||
|
.content,
|
||||||
|
)
|
||||||
|
mockEventsFlow.emit(WsEvent.MessageStart(session))
|
||||||
|
mockEventsFlow.emit(WsEvent.MessageToken("Working", session))
|
||||||
|
advanceUntilIdle()
|
||||||
|
assertEquals(PendingSendState.ACCEPTED, store.all().single().state)
|
||||||
|
assertTrue(
|
||||||
|
vm.transcriptState.value.messages
|
||||||
|
.any { it.content == "live-prompt" },
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test
|
||||||
|
fun gatewayQueuedAcceptanceStaysVisibleThroughPreviousTurnCompletion() =
|
||||||
|
runTest {
|
||||||
|
val store = ChatSendStore()
|
||||||
|
val (vm, session) = createViewModelWithSession(sendStore = store)
|
||||||
|
mockEventsFlow.emit(WsEvent.MessageStart(session))
|
||||||
|
advanceUntilIdle()
|
||||||
|
assertTrue(vm.sendMessage("guide-next", BusySendMode.GUIDE))
|
||||||
|
advanceUntilIdle()
|
||||||
|
val requestId = sentRequestMethods.last { it.first == WsMethods.SESSION_STEER }.second
|
||||||
|
mockEventsFlow.emit(WsEvent.RpcResult(requestId, mapOf("status" to "queued", "user_row_id" to 10)))
|
||||||
|
advanceUntilIdle()
|
||||||
|
mockEventsFlow.emit(WsEvent.MessageComplete("Previous reply", session))
|
||||||
|
advanceUntilIdle()
|
||||||
|
vm.syncCurrentSession()
|
||||||
|
advanceUntilIdle()
|
||||||
|
|
||||||
|
assertEquals(PendingSendState.ACCEPTED, store.all().single().state)
|
||||||
|
assertTrue(
|
||||||
|
vm.transcriptState.value.messages
|
||||||
|
.any { it.content == "guide-next" },
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
@Test
|
@Test
|
||||||
fun changedReceiptIdentityDoesNotBlockNewPromptsOrFalselyConfirmDelivery() =
|
fun changedReceiptIdentityDoesNotBlockNewPromptsOrFalselyConfirmDelivery() =
|
||||||
runTest {
|
runTest {
|
||||||
@@ -10580,7 +10636,7 @@ class ChatViewModelTest {
|
|||||||
.last()
|
.last()
|
||||||
.content,
|
.content,
|
||||||
)
|
)
|
||||||
assertEquals(PendingSendState.ACCEPTED, store.all().single().state)
|
assertEquals(PendingSendState.UNKNOWN, store.all().single().state)
|
||||||
assertTrue(vm.uiState.value.isSessionReady)
|
assertTrue(vm.uiState.value.isSessionReady)
|
||||||
assertTrue(vm.sendMessage("probe-second"))
|
assertTrue(vm.sendMessage("probe-second"))
|
||||||
advanceUntilIdle()
|
advanceUntilIdle()
|
||||||
@@ -10592,7 +10648,7 @@ class ChatViewModelTest {
|
|||||||
assertNull(vm.uiState.value.errorMessage)
|
assertNull(vm.uiState.value.errorMessage)
|
||||||
verify(exactly = 1) { HermesWsClient.sendMessage(session, "probe-second", any(), any()) }
|
verify(exactly = 1) { HermesWsClient.sendMessage(session, "probe-second", any(), any()) }
|
||||||
verify(exactly = 1) { HermesWsClient.sendMessage(session, "probe-first", any(), any()) }
|
verify(exactly = 1) { HermesWsClient.sendMessage(session, "probe-first", any(), any()) }
|
||||||
assertEquals(PendingSendState.ACCEPTED, store.all().single { it.text == "probe-first" }.state)
|
assertEquals(PendingSendState.UNKNOWN, store.all().single { it.text == "probe-first" }.state)
|
||||||
assertEquals(
|
assertEquals(
|
||||||
1,
|
1,
|
||||||
vm.transcriptState.value.messages
|
vm.transcriptState.value.messages
|
||||||
|
|||||||
@@ -1,10 +1,14 @@
|
|||||||
package com.m57.hermescontrol.ui.chat.fullbleed
|
package com.m57.hermescontrol.ui.chat.fullbleed
|
||||||
|
|
||||||
|
import com.m57.hermescontrol.data.model.Attachment
|
||||||
|
import com.m57.hermescontrol.data.model.BusySendMode
|
||||||
import com.m57.hermescontrol.ui.chat.ChatMessage
|
import com.m57.hermescontrol.ui.chat.ChatMessage
|
||||||
import com.m57.hermescontrol.ui.chat.ChatTimelineState
|
import com.m57.hermescontrol.ui.chat.ChatTimelineState
|
||||||
import com.m57.hermescontrol.ui.chat.ChatUiState
|
import com.m57.hermescontrol.ui.chat.ChatUiState
|
||||||
import com.m57.hermescontrol.ui.chat.ClarifyUi
|
import com.m57.hermescontrol.ui.chat.ClarifyUi
|
||||||
import com.m57.hermescontrol.ui.chat.MessageRole
|
import com.m57.hermescontrol.ui.chat.MessageRole
|
||||||
|
import com.m57.hermescontrol.ui.chat.PendingSend
|
||||||
|
import com.m57.hermescontrol.ui.chat.PendingSendState
|
||||||
import com.m57.hermescontrol.ui.chat.StreamingState
|
import com.m57.hermescontrol.ui.chat.StreamingState
|
||||||
import org.junit.Assert.assertEquals
|
import org.junit.Assert.assertEquals
|
||||||
import org.junit.Assert.assertFalse
|
import org.junit.Assert.assertFalse
|
||||||
@@ -18,6 +22,40 @@ class TranscriptUiStateTest {
|
|||||||
private val historical = ChatMessage(id = "historical", role = MessageRole.USER, content = "older")
|
private val historical = ChatMessage(id = "historical", role = MessageRole.USER, content = "older")
|
||||||
private val clarify = ClarifyUi(text = "choose")
|
private val clarify = ClarifyUi(text = "choose")
|
||||||
|
|
||||||
|
@Test
|
||||||
|
fun acceptedLivePromptKeepsItsTextAndImageUntilHistoryConfirmsIt() {
|
||||||
|
val image = Attachment("file:///private/photo", "photo.png", "image/png", 3)
|
||||||
|
val prompt = live.copy(attachments = listOf(image), serverRowId = 10)
|
||||||
|
val result =
|
||||||
|
TranscriptUiState.resolve(
|
||||||
|
chat =
|
||||||
|
ChatUiState(
|
||||||
|
messages = listOf(prompt),
|
||||||
|
isAgentTyping = true,
|
||||||
|
pendingSends =
|
||||||
|
listOf(
|
||||||
|
PendingSend(
|
||||||
|
prompt.id,
|
||||||
|
"scope",
|
||||||
|
"session",
|
||||||
|
prompt.content,
|
||||||
|
attachments = listOf(image),
|
||||||
|
mode = BusySendMode.CORRECT,
|
||||||
|
state = PendingSendState.ACCEPTED,
|
||||||
|
userRowId = 10,
|
||||||
|
),
|
||||||
|
),
|
||||||
|
),
|
||||||
|
timeline = ChatTimelineState(),
|
||||||
|
streaming = StreamingState(),
|
||||||
|
savingAttachmentPath = null,
|
||||||
|
speakingMessageId = null,
|
||||||
|
)
|
||||||
|
|
||||||
|
assertEquals(listOf(prompt), result.messages)
|
||||||
|
assertEquals(listOf(image), result.messages.single().attachments)
|
||||||
|
}
|
||||||
|
|
||||||
@Test
|
@Test
|
||||||
fun `live state preserves prompts streaming and paging actions`() {
|
fun `live state preserves prompts streaming and paging actions`() {
|
||||||
val stream = StreamingState()
|
val stream = StreamingState()
|
||||||
|
|||||||
Reference in new issue
Block a user