diff --git a/app/src/main/java/com/m57/hermescontrol/ui/chat/ChatBubble.kt b/app/src/main/java/com/m57/hermescontrol/ui/chat/ChatBubble.kt index cf8b6688..c154933b 100644 --- a/app/src/main/java/com/m57/hermescontrol/ui/chat/ChatBubble.kt +++ b/app/src/main/java/com/m57/hermescontrol/ui/chat/ChatBubble.kt @@ -92,6 +92,7 @@ fun ChatBubble( onOpenAttachment: (Attachment) -> Unit = {}, onSaveAttachment: (Attachment) -> Unit = {}, savingAttachmentPath: String? = null, + openingAttachmentPath: String? = null, canSaveAttachment: Boolean = true, onImageClick: (ImageViewerModel) -> Unit = {}, modifier: Modifier = Modifier, @@ -191,6 +192,7 @@ fun ChatBubble( onOpen = { onOpenAttachment(it) }, onSave = { onSaveAttachment(it) }, savingPath = savingAttachmentPath, + openingPath = openingAttachmentPath, canSave = canSaveAttachment, onImageClick = onImageClick, ) @@ -432,11 +434,13 @@ internal fun InlineAttachment( onOpen: (Attachment) -> Unit, onSave: (Attachment) -> Unit, savingPath: String?, + openingPath: String?, canSave: Boolean, onImageClick: (ImageViewerModel) -> Unit, ) { val attachmentPath = attachment.gatewayUrl?.let(::gatewayPathFromUrl) ?: attachment.name val isSaving = savingPath != null && savingPath == attachmentPath + val isOpening = openingPath != null && openingPath == attachmentPath val clickable = Modifier.clickable { onOpen(attachment) } if (attachment.isVideo) { var showVideoDialog by remember { mutableStateOf(false) } @@ -505,6 +509,13 @@ internal fun InlineAttachment( 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) { IconButton( onClick = { onSave(attachment) }, diff --git a/app/src/main/java/com/m57/hermescontrol/ui/chat/ChatScreen.kt b/app/src/main/java/com/m57/hermescontrol/ui/chat/ChatScreen.kt index bb835db0..ea441bf8 100644 --- a/app/src/main/java/com/m57/hermescontrol/ui/chat/ChatScreen.kt +++ b/app/src/main/java/com/m57/hermescontrol/ui/chat/ChatScreen.kt @@ -625,6 +625,7 @@ fun ChatScreen( onDismissClarify = viewModel::dismissClarify, onSaveAttachment = onSaveAttachment, savingAttachmentPath = pendingSavePath ?: state.savingAttachmentPath, + openingAttachmentPath = state.openingAttachmentPath, onImageClick = { viewingImage = it }, ) diff --git a/app/src/main/java/com/m57/hermescontrol/ui/chat/ChatViewModel.kt b/app/src/main/java/com/m57/hermescontrol/ui/chat/ChatViewModel.kt index 9f6782ff..a58bc3b6 100644 --- a/app/src/main/java/com/m57/hermescontrol/ui/chat/ChatViewModel.kt +++ b/app/src/main/java/com/m57/hermescontrol/ui/chat/ChatViewModel.kt @@ -258,6 +258,9 @@ data class ChatUiState( // Attachment feedback — surfaced as a non-blocking snackbar (issue #724) val openError: 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, // Sudo / secret prompts — surfaced as dialogs (issue #524) val sudoPrompt: SudoPromptUi? = null, @@ -3041,31 +3044,40 @@ class ChatViewModel( } // GATEWAY, or LOCAL direct-open failed → fetch/copy then open. 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") viewModelScope.launch(ioDispatcher) { - when (val result = GatewayFileClient.fetch(path, cacheDir)) { - is GatewayFileResult.Success -> { - openBytes(ctx, result.file) - } + try { + when (val result = GatewayFileClient.fetch(path, cacheDir)) { + is GatewayFileResult.Success -> { + openBytes(ctx, result.file) + } - is GatewayFileResult.NotFound -> { - showOpenError("File not found on gateway: ${attachment.name}") - } + is GatewayFileResult.NotFound -> { + showOpenError("File not found on gateway: ${attachment.name}") + } - is GatewayFileResult.Forbidden -> { - showOpenError("Access denied: ${attachment.name}") - } + is GatewayFileResult.Forbidden -> { + showOpenError("Access denied: ${attachment.name}") + } - is GatewayFileResult.TooLarge -> { - showOpenError("File too large to open: ${attachment.name}") - } + is GatewayFileResult.TooLarge -> { + showOpenError("File too large to open: ${attachment.name}") + } - is GatewayFileResult.Unauthorized -> { - showOpenError("Session expired — reconnect to open: ${attachment.name}") - } + is GatewayFileResult.Unauthorized -> { + showOpenError("Session expired — reconnect to open: ${attachment.name}") + } - is GatewayFileResult.Failure -> { - showOpenError("Could not open ${attachment.name}: ${result.throwable.message}") + is GatewayFileResult.Failure -> { + showOpenError("Could not open ${attachment.name}: ${result.throwable.message}") + } + } + } finally { + _uiState.update { + if (it.openingAttachmentPath == path) it.copy(openingAttachmentPath = null) else it } } } diff --git a/app/src/main/java/com/m57/hermescontrol/ui/chat/fullbleed/FullBleedAgentMessage.kt b/app/src/main/java/com/m57/hermescontrol/ui/chat/fullbleed/FullBleedAgentMessage.kt index 5207acc0..1145954a 100644 --- a/app/src/main/java/com/m57/hermescontrol/ui/chat/fullbleed/FullBleedAgentMessage.kt +++ b/app/src/main/java/com/m57/hermescontrol/ui/chat/fullbleed/FullBleedAgentMessage.kt @@ -66,6 +66,7 @@ internal fun FullBleedAgentMessage( onOpenAttachment: (Attachment) -> Unit = {}, onSaveAttachment: (Attachment) -> Unit = {}, savingAttachmentPath: String? = null, + openingAttachmentPath: String? = null, canSaveAttachment: Boolean = true, onImageClick: (ImageViewerModel) -> Unit = {}, modifier: Modifier = Modifier, @@ -124,6 +125,7 @@ internal fun FullBleedAgentMessage( onOpen = { onOpenAttachment(it) }, onSave = { onSaveAttachment(it) }, savingPath = savingAttachmentPath, + openingPath = openingAttachmentPath, canSave = canSaveAttachment, onImageClick = onImageClick, ) diff --git a/app/src/main/java/com/m57/hermescontrol/ui/chat/fullbleed/FullBleedChatList.kt b/app/src/main/java/com/m57/hermescontrol/ui/chat/fullbleed/FullBleedChatList.kt index c406c49a..58e8e8b9 100644 --- a/app/src/main/java/com/m57/hermescontrol/ui/chat/fullbleed/FullBleedChatList.kt +++ b/app/src/main/java/com/m57/hermescontrol/ui/chat/fullbleed/FullBleedChatList.kt @@ -66,6 +66,7 @@ fun FullBleedChatList( onDismissClarify: (() -> Unit)? = null, onSaveAttachment: (com.m57.hermescontrol.data.model.Attachment) -> Unit = {}, savingAttachmentPath: String? = null, + openingAttachmentPath: String? = null, onImageClick: (ImageViewerModel) -> Unit = {}, ) { if (messages.isEmpty() && !isLoading) { @@ -150,6 +151,7 @@ fun FullBleedChatList( onOpenAttachment = viewModel::openAttachment, onSaveAttachment = onSaveAttachment, savingAttachmentPath = savingAttachmentPath, + openingAttachmentPath = openingAttachmentPath, onImageClick = onImageClick, ) milestone?.let { count -> @@ -225,6 +227,7 @@ fun FullBleedChatList( onOpenAttachment = viewModel::openAttachment, onSaveAttachment = onSaveAttachment, savingAttachmentPath = savingAttachmentPath, + openingAttachmentPath = openingAttachmentPath, canSaveAttachment = savingAttachmentPath == null, onImageClick = onImageClick, ) @@ -300,6 +303,7 @@ private fun renderChatBubble( onOpenAttachment: (com.m57.hermescontrol.data.model.Attachment) -> Unit, onSaveAttachment: (com.m57.hermescontrol.data.model.Attachment) -> Unit, savingAttachmentPath: String?, + openingAttachmentPath: String?, onImageClick: (ImageViewerModel) -> Unit, ) { ChatBubble( @@ -309,6 +313,7 @@ private fun renderChatBubble( onOpenAttachment = onOpenAttachment, onSaveAttachment = onSaveAttachment, savingAttachmentPath = savingAttachmentPath, + openingAttachmentPath = openingAttachmentPath, canSaveAttachment = savingAttachmentPath == null, onImageClick = onImageClick, ) diff --git a/app/src/test/java/com/m57/hermescontrol/ui/chat/ChatViewModelTest.kt b/app/src/test/java/com/m57/hermescontrol/ui/chat/ChatViewModelTest.kt index 36fba782..1b96b7c2 100644 --- a/app/src/test/java/com/m57/hermescontrol/ui/chat/ChatViewModelTest.kt +++ b/app/src/test/java/com/m57/hermescontrol/ui/chat/ChatViewModelTest.kt @@ -3643,4 +3643,44 @@ class ChatViewModelTest { .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() + val fetchedPaths = mutableListOf() + 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")) + } }