Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
12 changes: 9 additions & 3 deletions android/app/src/main/kotlin/app/hapi/companion/Navigation.kt
Original file line number Diff line number Diff line change
Expand Up @@ -306,9 +306,15 @@ fun HapiNavigation() {
ChatScreen(
viewModel = holder.viewModel,
media = remember(hubGraph, sessionId) {
ChatMedia(hubGraph.imageLoader) { imageId ->
hubGraph.generatedImageUrl(sessionId, imageId)
}
ChatMedia(
imageLoader = hubGraph.imageLoader,
generatedImageUrl = { imageId ->
hubGraph.generatedImageUrl(sessionId, imageId)
},
attachmentUrl = { attachmentId ->
hubGraph.attachmentUrl(sessionId, attachmentId)
},
)
},
onBack = { navController.popBackStack() },
onNavigateToSession = { supersededId ->
Expand Down
4 changes: 4 additions & 0 deletions android/app/src/main/kotlin/app/hapi/companion/di/HubGraph.kt
Original file line number Diff line number Diff line change
Expand Up @@ -151,6 +151,10 @@ class HubGraph(
fun scratchlistAttachmentUrl(sessionId: String, attachmentId: String): String =
"${session.hubUrl}/api/sessions/$sessionId/scratchlist/attachments/$attachmentId"

/** Absolute URL of a durable chat attachment original, for [imageLoader]. */
fun attachmentUrl(sessionId: String, attachmentId: String): String =
"${session.hubUrl}/api/sessions/$sessionId/attachments/$attachmentId/original"

/** Per-session composer drafts, keyed under this hub (process-wide DataStore). */
val chatDrafts: ChatDrafts = DataStoreChatDrafts(context.chatDraftsDataStore, hubKey = session.hubUrl)

Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -33,9 +33,10 @@ import coil.ImageLoader
data class ChatMedia(
val imageLoader: ImageLoader?,
val generatedImageUrl: (imageId: String) -> String?,
val attachmentUrl: (attachmentId: String) -> String? = { _ -> null },
)

val LocalChatMedia = staticCompositionLocalOf { ChatMedia(imageLoader = null) { null } }
val LocalChatMedia = staticCompositionLocalOf { ChatMedia(imageLoader = null, generatedImageUrl = { null }) }

/** Stable LazyColumn key. */
val VisibleChatBlock.stableId: String
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -30,7 +30,7 @@ class PreparedAttachment(
val mimeType: String,
/** The exact bytes that will upload (post-compression when applicable). */
val bytes: ByteArray,
/** Small JPEG thumbnail for the chip + wire `previewUrl`; null for non-images. */
/** Small JPEG preview for the local composer chip; null for non-images. */
val previewBytes: ByteArray? = null,
) {
val sizeBytes: Long get() = bytes.size.toLong()
Expand All @@ -46,7 +46,7 @@ data class ComposerAttachmentUi(
val filename: String,
val mimeType: String,
val sizeBytes: Long,
/** JPEG thumbnail bytes for image picks; null renders a file glyph. */
/** JPEG preview bytes for image picks; null renders a file glyph. */
val previewBytes: ByteArray?,
val status: ComposerAttachmentStatus,
)
Expand All @@ -64,9 +64,8 @@ data class ComposerAttachmentUi(
* removing a chip whose upload is still in flight lets the upload finish
* and then deletes the orphan (web `cancelledAttachmentIds` semantics).
* - [consume] converts every Ready chip into [AttachmentMetadata] for the
* send body — `previewUrl` is a small JPEG data URL
* ([AttachmentPolicy.PREVIEW_MAX_DIMENSION]) so user bubbles render
* thumbnails on every client.
* send body. New durable uploads use `attachmentId`; legacy path responses
* remain supported for older hubs.
* - **Drafts (v1 simplification)**: unlike the web (IndexedDB attachment
* drafts), attachments never persist. Leaving the chat for good discards
* un-sent chips via [discardAllDetached] after best-effort hub deletes;
Expand All @@ -89,8 +88,10 @@ class ComposerAttachments(
) {
private class Entry(
val ui: ComposerAttachmentUi,
/** Hub upload path once Ready. */
/** Legacy Hub upload path once Ready. */
val path: String? = null,
/** Opaque durable Hub attachment id once Ready. */
val attachmentId: String? = null,
/** Upload payload, retained only until the upload succeeds (retry source). */
val bytes: ByteArray? = null,
)
Expand Down Expand Up @@ -151,8 +152,18 @@ class ComposerAttachments(
removed = list.firstOrNull { it.ui.id == id }
list.filterNot { it.ui.id == id }
}
removed?.path?.let { path ->
scope.launch { runCatching { api.deleteUpload(sessionId, path) } }
removed?.let { entry ->
if (entry.path != null || entry.attachmentId != null) {
scope.launch {
runCatching {
api.deleteUpload(
sessionId,
path = entry.path,
attachmentId = entry.attachmentId,
)
}
}
}
}
}

Expand All @@ -166,7 +177,10 @@ class ComposerAttachments(
fun consume(): List<AttachmentMetadata>? {
var taken: List<Entry> = emptyList()
entries.update { list ->
taken = list.filter { it.ui.status == ComposerAttachmentStatus.Ready && it.path != null }
taken = list.filter {
it.ui.status == ComposerAttachmentStatus.Ready
&& (it.path != null || it.attachmentId != null)
}
list - taken.toSet()
}
if (taken.isEmpty()) return null
Expand All @@ -176,8 +190,11 @@ class ComposerAttachments(
filename = entry.ui.filename,
mimeType = entry.ui.mimeType,
size = entry.ui.sizeBytes,
path = entry.path!!,
previewUrl = entry.ui.previewBytes?.let { AttachmentPolicy.dataUrl("image/jpeg", it) },
path = entry.path,
attachmentId = entry.attachmentId,
previewUrl = entry.path?.let {
entry.ui.previewBytes?.let { bytes -> AttachmentPolicy.dataUrl("image/jpeg", bytes) }
},
)
}
}
Expand All @@ -195,24 +212,35 @@ class ComposerAttachments(
dropped = list
emptyList()
}
val paths = dropped.mapNotNull { it.path }
if (paths.isEmpty()) return
val references = dropped.filter { it.path != null || it.attachmentId != null }
if (references.isEmpty()) return
(detachedCleanupScope ?: GlobalScope).launch(Dispatchers.IO) {
paths.forEach { path -> runCatching { api.deleteUpload(sessionId, path) } }
references.forEach { entry ->
runCatching {
api.deleteUpload(
sessionId,
path = entry.path,
attachmentId = entry.attachmentId,
)
}
}
}
}

private fun upload(id: String, filename: String, mimeType: String, bytes: ByteArray) {
scope.launch {
val base64 = withContext(encodeDispatcher) { Base64.getEncoder().encodeToString(bytes) }
val path = try {
val uploaded = try {
val response = api.uploadFile(sessionId, filename, base64, mimeType)
if (response.success) response.path else null
if (response.success) response.path to response.attachmentId else null to null
} catch (cancellation: CancellationException) {
throw cancellation
} catch (_: Exception) {
null
null to null
}
val path = uploaded.first
val attachmentId = uploaded.second
val hasReference = path != null || attachmentId != null

var applied = false
entries.update { list ->
Expand All @@ -222,14 +250,20 @@ class ComposerAttachments(
when {
entry.ui.id != id -> entry
// Success: drop the payload bytes — only the preview stays.
path != null -> Entry(entry.ui.copy(status = ComposerAttachmentStatus.Ready), path = path)
hasReference -> Entry(
entry.ui.copy(status = ComposerAttachmentStatus.Ready),
path = path,
attachmentId = attachmentId,
)
else -> Entry(entry.ui.copy(status = ComposerAttachmentStatus.Failed), bytes = entry.bytes)
}
}
}
// Removed while uploading: the hub file just became an orphan.
if (!applied && path != null) {
runCatching { api.deleteUpload(sessionId, path) }
if (!applied && hasReference) {
runCatching {
api.deleteUpload(sessionId, path = path, attachmentId = attachmentId)
}
}
}
}
Expand Down
Original file line number Diff line number Diff line change
@@ -1,11 +1,14 @@
package app.hapi.companion.feature.chat.blocks

import androidx.compose.foundation.Image
import androidx.compose.foundation.background
import androidx.compose.foundation.clickable
import androidx.compose.foundation.layout.Arrangement
import androidx.compose.foundation.layout.Box
import androidx.compose.foundation.layout.Column
import androidx.compose.foundation.layout.Row
import androidx.compose.foundation.layout.Spacer
import androidx.compose.foundation.layout.fillMaxSize
import androidx.compose.foundation.layout.fillMaxWidth
import androidx.compose.foundation.layout.heightIn
import androidx.compose.foundation.layout.padding
Expand All @@ -18,30 +21,37 @@ import androidx.compose.material3.Surface
import androidx.compose.material3.Text
import androidx.compose.runtime.Composable
import androidx.compose.runtime.getValue
import androidx.compose.runtime.mutableStateOf
import androidx.compose.runtime.remember
import androidx.compose.runtime.setValue
import androidx.compose.ui.Alignment
import androidx.compose.ui.Modifier
import androidx.compose.ui.draw.clip
import androidx.compose.ui.graphics.Color
import androidx.compose.ui.layout.ContentScale
import androidx.compose.ui.platform.LocalConfiguration
import androidx.compose.ui.res.stringResource
import androidx.compose.ui.text.style.TextOverflow
import androidx.compose.ui.tooling.preview.Preview
import androidx.compose.ui.unit.dp
import androidx.compose.ui.unit.sp
import androidx.compose.ui.window.Dialog
import androidx.compose.ui.window.DialogProperties
import app.hapi.companion.R
import app.hapi.companion.feature.chat.LocalChatInteractions
import app.hapi.companion.feature.chat.LocalChatMedia
import app.hapi.companion.feature.chat.attachments.PreviewImage
import app.hapi.companion.feature.chat.attachments.rememberPreviewImage
import app.hapi.companion.ui.theme.HapiTheme
import app.hapi.protocol.chat.ChatAttachment
import app.hapi.protocol.chat.UserTextBlock
import coil.compose.AsyncImage

/**
* Operator prompt: right-aligned bubble (whitespace preserved — prompts are
* not rendered as markdown, matching the web user bubble), attachments as
* image thumbnails (decoded from the wire `previewUrl` data URL — both
* Android- and web-sent messages carry one, and optimistic rows do too, so
* thumbnails appear instantly on send) or filename chips, and a failed-send
* image attachments (decoded from an inline preview or fetched from the
* authenticated durable attachment endpoint) or filename chips, and a failed-send
* tap-to-retry hint (B-M3f upgrades the former chips-only rendering).
*/
@Composable
Expand Down Expand Up @@ -98,17 +108,70 @@ fun UserTextBlockView(block: UserTextBlock, modifier: Modifier = Modifier) {
}

/**
* One bubble attachment: image mimes with a decodable `previewUrl` render a
* thumbnail (web `MessageAttachments` split); everything else — plus decode
* failures — falls back to the filename chip.
* One bubble attachment: image mimes with an inline preview or durable id
* render an image; everything else — plus decode failures — falls
* back to the filename chip.
*/
@Composable
private fun AttachmentView(attachment: ChatAttachment) {
val media = LocalChatMedia.current
val isImage = attachment.mimeType.startsWith("image/")
if (!isImage || attachment.previewUrl == null) {
val hasInlinePreview = attachment.previewUrl?.startsWith("data:") == true
val remoteOriginalUrl = remember(attachment.attachmentId) {
attachment.attachmentId?.let { media.attachmentUrl(it) }
}

if (!isImage || (!hasInlinePreview && (media.imageLoader == null || remoteOriginalUrl == null))) {
AttachmentChip(attachment)
return
}

if (!hasInlinePreview) {
var viewerOpen by remember { mutableStateOf(false) }
var originalFailed by remember(attachment.attachmentId) { mutableStateOf(false) }
if (originalFailed) {
AttachmentChip(
attachment,
modifier = Modifier.clickable(enabled = remoteOriginalUrl != null) { viewerOpen = true },
)
} else {
AsyncImage(
model = remoteOriginalUrl,
imageLoader = media.imageLoader!!,
contentDescription = attachment.filename,
contentScale = ContentScale.Fit,
onError = { originalFailed = true },
modifier = Modifier
.heightIn(max = 180.dp)
.clip(RoundedCornerShape(10.dp))
.clickable(enabled = remoteOriginalUrl != null) { viewerOpen = true },
)
}
if (viewerOpen && remoteOriginalUrl != null) {
Dialog(
onDismissRequest = { viewerOpen = false },
properties = DialogProperties(usePlatformDefaultWidth = false),
) {
Box(
modifier = Modifier
.fillMaxSize()
.background(Color.Black.copy(alpha = 0.92f))
.clickable { viewerOpen = false },
contentAlignment = Alignment.Center,
) {
AsyncImage(
model = remoteOriginalUrl,
imageLoader = media.imageLoader!!,
contentDescription = attachment.filename,
contentScale = ContentScale.Fit,
modifier = Modifier.fillMaxSize().padding(8.dp),
)
}
}
}
return
}

val preview by rememberPreviewImage(attachment.previewUrl)
when (val state = preview) {
is PreviewImage.Ready -> Image(
Expand All @@ -132,10 +195,11 @@ private fun AttachmentView(attachment: ChatAttachment) {
}

@Composable
private fun AttachmentChip(attachment: ChatAttachment) {
private fun AttachmentChip(attachment: ChatAttachment, modifier: Modifier = Modifier) {
Surface(
color = MaterialTheme.colorScheme.surface.copy(alpha = 0.55f),
shape = RoundedCornerShape(8.dp),
modifier = modifier,
) {
Text(
text = "${if (attachment.mimeType.startsWith("image/")) "🖼" else "📎"} ${attachment.filename}",
Expand Down
Loading
Loading