feat(chat): add animated GIF rendering with tap-to-play animation (#721) (#731)

* feat(chat): add animated GIF rendering with tap-to-play animation

Closes #721

* fix(#721): add missing GifImageThumbnail component and format ktlint

* fix(analytics): resolve ktlint indentation error in itemsIndexed

* feat(chat): add interactive play/pause toggle for animated GIFs

* fix(chat): control GIF playback via Animatable start and stop
This commit is contained in:
M57 authored and GitHub committed 2026-07-27 18:32:45 +03:00
1 parent c7f7d1dd45
commit 5c1492a80e
32 files changed
+370 -118

No files matched your search

@@ -2032,6 +2032,9 @@ class ChatViewModelTest {
advanceUntilIdle()
assertNotNull(vm.uiState.value.openError)
assertTrue(vm.uiState.value.openError!!.contains("missing.pdf"))
assertTrue(
vm.uiState.value.openError!!
.contains("missing.pdf"),
)
}
}
@@ -222,4 +222,26 @@ class MarkdownTextFeatureTest {
val blocks = parseBlocks(md)
assertTrue("should remain a paragraph", blocks.single() is MdBlock.Paragraph)
}
// 13. Attachment isGif check (issue #721)
@Test
fun testAttachment_isGif() {
val gifAttachment =
com.m57.hermescontrol.data.model.Attachment(
uri = "content://media/1.gif",
name = "test.gif",
mimeType = "image/gif",
)
assertTrue(gifAttachment.isImage)
assertTrue(gifAttachment.isGif)
val nonGifAttachment =
com.m57.hermescontrol.data.model.Attachment(
uri = "content://media/1.jpg",
name = "test.jpg",
mimeType = "image/jpeg",
)
assertTrue(nonGifAttachment.isImage)
assertFalse(nonGifAttachment.isGif)
}
}
@@ -191,7 +191,9 @@ class SlashCommandDispatchRpcTest {
vm.sendMessage("/help")
advanceUntilIdle()
val last = vm.uiState.value.messages.lastOrNull()
val last =
vm.uiState.value.messages
.lastOrNull()
assertEquals("⚠️ /help: session busy — /interrupt first", last?.content)
}
@@ -232,7 +234,9 @@ class SlashCommandDispatchRpcTest {
assertEquals(sessionId, params["session_id"])
// And the slash.exec output must be surfaced to the user.
val last = vm.uiState.value.messages.lastOrNull()
val last =
vm.uiState.value.messages
.lastOrNull()
assertEquals("STATUS: gateway reachable", last?.content)
}
@@ -254,7 +258,9 @@ class SlashCommandDispatchRpcTest {
vm.sendMessage("/redraw")
advanceUntilIdle()
val last = vm.uiState.value.messages.lastOrNull()
val last =
vm.uiState.value.messages
.lastOrNull()
assertTrue(
"expected a 'not supported on mobile' message, got: ${last?.content}",
last?.content?.contains("not supported on mobile") == true,
@@ -292,7 +298,9 @@ class SlashCommandDispatchRpcTest {
advanceUntilIdle()
// Both RPCs fail -> the secondary slash.exec error must surface.
val last = vm.uiState.value.messages.lastOrNull()
val last =
vm.uiState.value.messages
.lastOrNull()
assertEquals("⚠️ /status: slash worker start failed: boom", last?.content)
}
@@ -327,6 +335,11 @@ class SlashCommandDispatchRpcTest {
// must NOT append an assistant bubble — so the last message is still
// the user's own command, and only one message was added.
assertEquals(before + 1, vm.uiState.value.messages.size)
assertEquals("/status", vm.uiState.value.messages.lastOrNull()?.content)
assertEquals(
"/status",
vm.uiState.value.messages
.lastOrNull()
?.content,
)
}
}