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:
M57andM57 authored and GitHub committed 2026-08-15 23:28:04 +03:00
1 parent d8be45b8fc
commit 75023a2bf0
6 files changed
+89 -18

No files matched your search

@@ -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) },
@@ -625,6 +625,7 @@ fun ChatScreen(
onDismissClarify = viewModel::dismissClarify,
onSaveAttachment = onSaveAttachment,
savingAttachmentPath = pendingSavePath ?: state.savingAttachmentPath,
openingAttachmentPath = state.openingAttachmentPath,
onImageClick = { viewingImage = it },
)
@@ -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
}
}
}
@@ -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,
)
@@ -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,
)