feat: show a loading indicator on gateway attachments while they open (#917)
Tapping a gateway attachment gave zero feedback while the file downloaded (a 100MB file could sit there silently for tens of seconds). Mirror the save spinner: ChatUiState gains openingAttachmentPath, set when openAttachment starts the download and cleared in a finally, and the attachment card renders a small CircularProgressIndicator while its path is being fetched. Wired through FullBleedChatList, ChatBubble and FullBleedAgentMessage. Co-authored-by: M57 <hy4ri@users.noreply.github.com>
This commit is contained in:
@@ -92,6 +92,7 @@ fun ChatBubble(
|
|||||||
onOpenAttachment: (Attachment) -> Unit = {},
|
onOpenAttachment: (Attachment) -> Unit = {},
|
||||||
onSaveAttachment: (Attachment) -> Unit = {},
|
onSaveAttachment: (Attachment) -> Unit = {},
|
||||||
savingAttachmentPath: String? = null,
|
savingAttachmentPath: String? = null,
|
||||||
|
openingAttachmentPath: String? = null,
|
||||||
canSaveAttachment: Boolean = true,
|
canSaveAttachment: Boolean = true,
|
||||||
onImageClick: (ImageViewerModel) -> Unit = {},
|
onImageClick: (ImageViewerModel) -> Unit = {},
|
||||||
modifier: Modifier = Modifier,
|
modifier: Modifier = Modifier,
|
||||||
@@ -191,6 +192,7 @@ fun ChatBubble(
|
|||||||
onOpen = { onOpenAttachment(it) },
|
onOpen = { onOpenAttachment(it) },
|
||||||
onSave = { onSaveAttachment(it) },
|
onSave = { onSaveAttachment(it) },
|
||||||
savingPath = savingAttachmentPath,
|
savingPath = savingAttachmentPath,
|
||||||
|
openingPath = openingAttachmentPath,
|
||||||
canSave = canSaveAttachment,
|
canSave = canSaveAttachment,
|
||||||
onImageClick = onImageClick,
|
onImageClick = onImageClick,
|
||||||
)
|
)
|
||||||
@@ -432,11 +434,13 @@ internal fun InlineAttachment(
|
|||||||
onOpen: (Attachment) -> Unit,
|
onOpen: (Attachment) -> Unit,
|
||||||
onSave: (Attachment) -> Unit,
|
onSave: (Attachment) -> Unit,
|
||||||
savingPath: String?,
|
savingPath: String?,
|
||||||
|
openingPath: String?,
|
||||||
canSave: Boolean,
|
canSave: Boolean,
|
||||||
onImageClick: (ImageViewerModel) -> Unit,
|
onImageClick: (ImageViewerModel) -> Unit,
|
||||||
) {
|
) {
|
||||||
val attachmentPath = attachment.gatewayUrl?.let(::gatewayPathFromUrl) ?: attachment.name
|
val attachmentPath = attachment.gatewayUrl?.let(::gatewayPathFromUrl) ?: attachment.name
|
||||||
val isSaving = savingPath != null && savingPath == attachmentPath
|
val isSaving = savingPath != null && savingPath == attachmentPath
|
||||||
|
val isOpening = openingPath != null && openingPath == attachmentPath
|
||||||
val clickable = Modifier.clickable { onOpen(attachment) }
|
val clickable = Modifier.clickable { onOpen(attachment) }
|
||||||
if (attachment.isVideo) {
|
if (attachment.isVideo) {
|
||||||
var showVideoDialog by remember { mutableStateOf(false) }
|
var showVideoDialog by remember { mutableStateOf(false) }
|
||||||
@@ -505,6 +509,13 @@ internal fun InlineAttachment(
|
|||||||
style = MaterialTheme.typography.labelSmall,
|
style = MaterialTheme.typography.labelSmall,
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
if (isOpening) {
|
||||||
|
CircularProgressIndicator(
|
||||||
|
modifier = Modifier.size(20.dp),
|
||||||
|
strokeWidth = 2.dp,
|
||||||
|
)
|
||||||
|
Spacer(modifier = Modifier.width(10.dp))
|
||||||
|
}
|
||||||
if (attachment.gatewayUrl != null) {
|
if (attachment.gatewayUrl != null) {
|
||||||
IconButton(
|
IconButton(
|
||||||
onClick = { onSave(attachment) },
|
onClick = { onSave(attachment) },
|
||||||
|
|||||||
@@ -625,6 +625,7 @@ fun ChatScreen(
|
|||||||
onDismissClarify = viewModel::dismissClarify,
|
onDismissClarify = viewModel::dismissClarify,
|
||||||
onSaveAttachment = onSaveAttachment,
|
onSaveAttachment = onSaveAttachment,
|
||||||
savingAttachmentPath = pendingSavePath ?: state.savingAttachmentPath,
|
savingAttachmentPath = pendingSavePath ?: state.savingAttachmentPath,
|
||||||
|
openingAttachmentPath = state.openingAttachmentPath,
|
||||||
onImageClick = { viewingImage = it },
|
onImageClick = { viewingImage = it },
|
||||||
)
|
)
|
||||||
|
|
||||||
|
|||||||
@@ -258,6 +258,9 @@ data class ChatUiState(
|
|||||||
// Attachment feedback — surfaced as a non-blocking snackbar (issue #724)
|
// Attachment feedback — surfaced as a non-blocking snackbar (issue #724)
|
||||||
val openError: String? = null,
|
val openError: String? = null,
|
||||||
val savingAttachmentPath: String? = null,
|
val savingAttachmentPath: String? = null,
|
||||||
|
// Gateway file currently being downloaded for open — drives the inline
|
||||||
|
// loading indicator on the attachment card (issue #913 follow-up).
|
||||||
|
val openingAttachmentPath: String? = null,
|
||||||
val clarifyRequest: ClarifyUi? = null,
|
val clarifyRequest: ClarifyUi? = null,
|
||||||
// Sudo / secret prompts — surfaced as dialogs (issue #524)
|
// Sudo / secret prompts — surfaced as dialogs (issue #524)
|
||||||
val sudoPrompt: SudoPromptUi? = null,
|
val sudoPrompt: SudoPromptUi? = null,
|
||||||
@@ -3041,31 +3044,40 @@ class ChatViewModel(
|
|||||||
}
|
}
|
||||||
// GATEWAY, or LOCAL direct-open failed → fetch/copy then open.
|
// GATEWAY, or LOCAL direct-open failed → fetch/copy then open.
|
||||||
val path = gatewayPathFor(attachment)
|
val path = gatewayPathFor(attachment)
|
||||||
|
// Show a loading indicator on the attachment card while the file
|
||||||
|
// streams down (mirrors the save spinner; cleared in all outcomes).
|
||||||
|
_uiState.update { it.copy(openingAttachmentPath = path) }
|
||||||
val cacheDir = java.io.File(ctx.cacheDir, "gateway_files")
|
val cacheDir = java.io.File(ctx.cacheDir, "gateway_files")
|
||||||
viewModelScope.launch(ioDispatcher) {
|
viewModelScope.launch(ioDispatcher) {
|
||||||
when (val result = GatewayFileClient.fetch(path, cacheDir)) {
|
try {
|
||||||
is GatewayFileResult.Success -> {
|
when (val result = GatewayFileClient.fetch(path, cacheDir)) {
|
||||||
openBytes(ctx, result.file)
|
is GatewayFileResult.Success -> {
|
||||||
}
|
openBytes(ctx, result.file)
|
||||||
|
}
|
||||||
|
|
||||||
is GatewayFileResult.NotFound -> {
|
is GatewayFileResult.NotFound -> {
|
||||||
showOpenError("File not found on gateway: ${attachment.name}")
|
showOpenError("File not found on gateway: ${attachment.name}")
|
||||||
}
|
}
|
||||||
|
|
||||||
is GatewayFileResult.Forbidden -> {
|
is GatewayFileResult.Forbidden -> {
|
||||||
showOpenError("Access denied: ${attachment.name}")
|
showOpenError("Access denied: ${attachment.name}")
|
||||||
}
|
}
|
||||||
|
|
||||||
is GatewayFileResult.TooLarge -> {
|
is GatewayFileResult.TooLarge -> {
|
||||||
showOpenError("File too large to open: ${attachment.name}")
|
showOpenError("File too large to open: ${attachment.name}")
|
||||||
}
|
}
|
||||||
|
|
||||||
is GatewayFileResult.Unauthorized -> {
|
is GatewayFileResult.Unauthorized -> {
|
||||||
showOpenError("Session expired — reconnect to open: ${attachment.name}")
|
showOpenError("Session expired — reconnect to open: ${attachment.name}")
|
||||||
}
|
}
|
||||||
|
|
||||||
is GatewayFileResult.Failure -> {
|
is GatewayFileResult.Failure -> {
|
||||||
showOpenError("Could not open ${attachment.name}: ${result.throwable.message}")
|
showOpenError("Could not open ${attachment.name}: ${result.throwable.message}")
|
||||||
|
}
|
||||||
|
}
|
||||||
|
} finally {
|
||||||
|
_uiState.update {
|
||||||
|
if (it.openingAttachmentPath == path) it.copy(openingAttachmentPath = null) else it
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -66,6 +66,7 @@ internal fun FullBleedAgentMessage(
|
|||||||
onOpenAttachment: (Attachment) -> Unit = {},
|
onOpenAttachment: (Attachment) -> Unit = {},
|
||||||
onSaveAttachment: (Attachment) -> Unit = {},
|
onSaveAttachment: (Attachment) -> Unit = {},
|
||||||
savingAttachmentPath: String? = null,
|
savingAttachmentPath: String? = null,
|
||||||
|
openingAttachmentPath: String? = null,
|
||||||
canSaveAttachment: Boolean = true,
|
canSaveAttachment: Boolean = true,
|
||||||
onImageClick: (ImageViewerModel) -> Unit = {},
|
onImageClick: (ImageViewerModel) -> Unit = {},
|
||||||
modifier: Modifier = Modifier,
|
modifier: Modifier = Modifier,
|
||||||
@@ -124,6 +125,7 @@ internal fun FullBleedAgentMessage(
|
|||||||
onOpen = { onOpenAttachment(it) },
|
onOpen = { onOpenAttachment(it) },
|
||||||
onSave = { onSaveAttachment(it) },
|
onSave = { onSaveAttachment(it) },
|
||||||
savingPath = savingAttachmentPath,
|
savingPath = savingAttachmentPath,
|
||||||
|
openingPath = openingAttachmentPath,
|
||||||
canSave = canSaveAttachment,
|
canSave = canSaveAttachment,
|
||||||
onImageClick = onImageClick,
|
onImageClick = onImageClick,
|
||||||
)
|
)
|
||||||
|
|||||||
@@ -66,6 +66,7 @@ fun FullBleedChatList(
|
|||||||
onDismissClarify: (() -> Unit)? = null,
|
onDismissClarify: (() -> Unit)? = null,
|
||||||
onSaveAttachment: (com.m57.hermescontrol.data.model.Attachment) -> Unit = {},
|
onSaveAttachment: (com.m57.hermescontrol.data.model.Attachment) -> Unit = {},
|
||||||
savingAttachmentPath: String? = null,
|
savingAttachmentPath: String? = null,
|
||||||
|
openingAttachmentPath: String? = null,
|
||||||
onImageClick: (ImageViewerModel) -> Unit = {},
|
onImageClick: (ImageViewerModel) -> Unit = {},
|
||||||
) {
|
) {
|
||||||
if (messages.isEmpty() && !isLoading) {
|
if (messages.isEmpty() && !isLoading) {
|
||||||
@@ -150,6 +151,7 @@ fun FullBleedChatList(
|
|||||||
onOpenAttachment = viewModel::openAttachment,
|
onOpenAttachment = viewModel::openAttachment,
|
||||||
onSaveAttachment = onSaveAttachment,
|
onSaveAttachment = onSaveAttachment,
|
||||||
savingAttachmentPath = savingAttachmentPath,
|
savingAttachmentPath = savingAttachmentPath,
|
||||||
|
openingAttachmentPath = openingAttachmentPath,
|
||||||
onImageClick = onImageClick,
|
onImageClick = onImageClick,
|
||||||
)
|
)
|
||||||
milestone?.let { count ->
|
milestone?.let { count ->
|
||||||
@@ -225,6 +227,7 @@ fun FullBleedChatList(
|
|||||||
onOpenAttachment = viewModel::openAttachment,
|
onOpenAttachment = viewModel::openAttachment,
|
||||||
onSaveAttachment = onSaveAttachment,
|
onSaveAttachment = onSaveAttachment,
|
||||||
savingAttachmentPath = savingAttachmentPath,
|
savingAttachmentPath = savingAttachmentPath,
|
||||||
|
openingAttachmentPath = openingAttachmentPath,
|
||||||
canSaveAttachment = savingAttachmentPath == null,
|
canSaveAttachment = savingAttachmentPath == null,
|
||||||
onImageClick = onImageClick,
|
onImageClick = onImageClick,
|
||||||
)
|
)
|
||||||
@@ -300,6 +303,7 @@ private fun renderChatBubble(
|
|||||||
onOpenAttachment: (com.m57.hermescontrol.data.model.Attachment) -> Unit,
|
onOpenAttachment: (com.m57.hermescontrol.data.model.Attachment) -> Unit,
|
||||||
onSaveAttachment: (com.m57.hermescontrol.data.model.Attachment) -> Unit,
|
onSaveAttachment: (com.m57.hermescontrol.data.model.Attachment) -> Unit,
|
||||||
savingAttachmentPath: String?,
|
savingAttachmentPath: String?,
|
||||||
|
openingAttachmentPath: String?,
|
||||||
onImageClick: (ImageViewerModel) -> Unit,
|
onImageClick: (ImageViewerModel) -> Unit,
|
||||||
) {
|
) {
|
||||||
ChatBubble(
|
ChatBubble(
|
||||||
@@ -309,6 +313,7 @@ private fun renderChatBubble(
|
|||||||
onOpenAttachment = onOpenAttachment,
|
onOpenAttachment = onOpenAttachment,
|
||||||
onSaveAttachment = onSaveAttachment,
|
onSaveAttachment = onSaveAttachment,
|
||||||
savingAttachmentPath = savingAttachmentPath,
|
savingAttachmentPath = savingAttachmentPath,
|
||||||
|
openingAttachmentPath = openingAttachmentPath,
|
||||||
canSaveAttachment = savingAttachmentPath == null,
|
canSaveAttachment = savingAttachmentPath == null,
|
||||||
onImageClick = onImageClick,
|
onImageClick = onImageClick,
|
||||||
)
|
)
|
||||||
|
|||||||
@@ -3643,4 +3643,44 @@ class ChatViewModelTest {
|
|||||||
.contains("missing.pdf"),
|
.contains("missing.pdf"),
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
@Test
|
||||||
|
fun `openAttachment shows opening state while fetch is in flight and clears it after`() =
|
||||||
|
runTest {
|
||||||
|
mockkObject(GatewayFileClient)
|
||||||
|
val cacheDir =
|
||||||
|
java.io.File(
|
||||||
|
System.getProperty("java.io.tmpdir"),
|
||||||
|
"gw_open_${System.nanoTime()}",
|
||||||
|
).apply { mkdirs() }
|
||||||
|
every { app.cacheDir } returns cacheDir
|
||||||
|
every { app.applicationContext } returns app
|
||||||
|
every { app.getApplicationContext() } returns app
|
||||||
|
val firstResult = CompletableDeferred<GatewayFileResult>()
|
||||||
|
val fetchedPaths = mutableListOf<String>()
|
||||||
|
coEvery { GatewayFileClient.fetch(capture(fetchedPaths), any()) } coAnswers { firstResult.await() }
|
||||||
|
|
||||||
|
val vm = createViewModel()
|
||||||
|
val attachment =
|
||||||
|
Attachment(
|
||||||
|
uri = "gateway:/tmp/big.pdf",
|
||||||
|
name = "big.pdf",
|
||||||
|
mimeType = "application/pdf",
|
||||||
|
source = AttachmentSource.GATEWAY,
|
||||||
|
)
|
||||||
|
|
||||||
|
vm.openAttachment(attachment)
|
||||||
|
runCurrent()
|
||||||
|
|
||||||
|
// Indicator visible while the download is in flight…
|
||||||
|
assertEquals(listOf("/tmp/big.pdf"), fetchedPaths)
|
||||||
|
assertEquals("/tmp/big.pdf", vm.uiState.value.openingAttachmentPath)
|
||||||
|
|
||||||
|
firstResult.complete(GatewayFileResult.NotFound)
|
||||||
|
advanceUntilIdle()
|
||||||
|
|
||||||
|
// …and cleared in all outcomes.
|
||||||
|
assertNull(vm.uiState.value.openingAttachmentPath)
|
||||||
|
assertTrue(vm.uiState.value.openError.orEmpty().contains("big.pdf"))
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
Reference in New Issue
Block a user