-
Notifications
You must be signed in to change notification settings - Fork 3
Chat UX polish: italic, clickable links, thumbnails, streaming autoscroll #8
New issue
Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.
By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.
Already on GitHub? Sign in to your account
Changes from all commits
File filter
Filter by extension
Conversations
Jump to
Diff view
Diff view
There are no files selected for viewing
| Original file line number | Diff line number | Diff line change |
|---|---|---|
|
|
@@ -188,7 +188,7 @@ class ChatViewModel(private val core: RestGeminiCore) : ViewModel() { | |
| if (text.isBlank() && attachments.isEmpty()) return | ||
| if (sendJob?.isActive == true) return | ||
| lastUserPrompt = text | ||
| val payload = attachments.map { Attachment(it.bytes, it.mimeType) } | ||
| val payload = attachments.map { Attachment(it.bytes, it.mimeType, it.localPath) } | ||
| _pendingAttachments.value = emptyList() | ||
| sendJob = viewModelScope.launch { | ||
| _isLoading.value = true | ||
|
|
@@ -238,12 +238,15 @@ class ChatViewModel(private val core: RestGeminiCore) : ViewModel() { | |
| ?: return@runCatching null | ||
| val displayName = queryDisplayName(context, uri) | ||
| ?: "image.${mime.substringAfter('/').take(4)}" | ||
| val id = "att-${System.nanoTime()}" | ||
| val localPath = persistAttachment(context, id, bytes, mime) | ||
| PendingAttachment( | ||
| id = "att-${System.nanoTime()}", | ||
| id = id, | ||
| bytes = bytes, | ||
| mimeType = mime, | ||
| displayName = displayName.take(40), | ||
| sizeBytes = bytes.size | ||
| sizeBytes = bytes.size, | ||
| localPath = localPath | ||
| ) | ||
| }.getOrNull() | ||
| } | ||
|
|
@@ -260,6 +263,29 @@ class ChatViewModel(private val core: RestGeminiCore) : ViewModel() { | |
| }.getOrNull() | ||
| } | ||
|
|
||
| // Copy the picked image into app-owned cache so the chat bubble can show a | ||
| // thumbnail without holding an Android content:// permission that may be | ||
| // revoked, and so reloads from ChatStore can still find the file. | ||
| private fun persistAttachment( | ||
| context: Context, | ||
| id: String, | ||
| bytes: ByteArray, | ||
| mime: String | ||
| ): String? = runCatching { | ||
| val ext = when { | ||
| mime.contains("png") -> "png" | ||
| mime.contains("webp") -> "webp" | ||
| mime.contains("gif") -> "gif" | ||
|
Comment on lines
+269
to
+278
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. issue (bug_risk): Persisted attachment files under Because these files are never deleted, internal storage can grow without bound, particularly for users who send many large images. Consider using |
||
| mime.contains("heic") -> "heic" | ||
| mime.contains("heif") -> "heif" | ||
| else -> "jpg" | ||
| } | ||
| val dir = java.io.File(context.filesDir, "attachments").also { it.mkdirs() } | ||
| val file = java.io.File(dir, "$id.$ext") | ||
| file.writeBytes(bytes) | ||
| file.absolutePath | ||
| }.getOrNull() | ||
|
|
||
| private fun maybeAutoCompress() { | ||
| if (!_autoCompressEnabled.value) return | ||
| if (_compressing.value) return | ||
|
|
@@ -410,7 +436,8 @@ data class PendingAttachment( | |
| val bytes: ByteArray, | ||
| val mimeType: String, | ||
| val displayName: String, | ||
| val sizeBytes: Int | ||
| val sizeBytes: Int, | ||
| val localPath: String? = null | ||
| ) { | ||
| override fun equals(other: Any?) = other is PendingAttachment && id == other.id | ||
| override fun hashCode() = id.hashCode() | ||
|
|
||
| Original file line number | Diff line number | Diff line change |
|---|---|---|
|
|
@@ -3,6 +3,8 @@ package com.gemini.app.ui.chat | |
| import android.content.ClipData | ||
| import android.content.ClipboardManager | ||
| import android.content.Context | ||
| import android.content.Intent | ||
| import android.net.Uri | ||
| import android.widget.Toast | ||
| import androidx.compose.foundation.border | ||
| import androidx.compose.foundation.horizontalScroll | ||
|
|
@@ -16,10 +18,12 @@ import androidx.compose.foundation.layout.height | |
| import androidx.compose.foundation.layout.padding | ||
| import androidx.compose.foundation.layout.width | ||
| import androidx.compose.foundation.rememberScrollState | ||
| import androidx.compose.foundation.text.ClickableText | ||
| import androidx.compose.material.icons.Icons | ||
| import androidx.compose.material.icons.filled.ContentCopy | ||
| import androidx.compose.material3.Icon | ||
| import androidx.compose.material3.IconButton | ||
| import androidx.compose.material3.LocalTextStyle | ||
| import androidx.compose.material3.MaterialTheme | ||
| import androidx.compose.material3.Surface | ||
| import androidx.compose.material3.Text | ||
|
|
@@ -29,8 +33,10 @@ import androidx.compose.ui.Modifier | |
| import androidx.compose.ui.platform.LocalContext | ||
| import androidx.compose.ui.text.AnnotatedString | ||
| import androidx.compose.ui.text.SpanStyle | ||
| import androidx.compose.ui.text.TextStyle | ||
| import androidx.compose.ui.text.buildAnnotatedString | ||
| import androidx.compose.ui.text.font.FontFamily | ||
| import androidx.compose.ui.text.font.FontStyle | ||
| import androidx.compose.ui.text.font.FontWeight | ||
| import androidx.compose.ui.text.style.TextDecoration | ||
| import androidx.compose.ui.text.withStyle | ||
|
|
@@ -135,19 +141,19 @@ private fun renderProse(body: String, baseColor: androidx.compose.ui.graphics.Co | |
| } | ||
| } | ||
| when { | ||
| trimmed.startsWith("### ") -> Text( | ||
| trimmed.startsWith("### ") -> ProseText( | ||
| annotateInline(trimmed.removePrefix("### "), baseColor), | ||
| style = MaterialTheme.typography.titleSmall, | ||
| color = baseColor, | ||
| modifier = Modifier.padding(top = 2.dp, bottom = 1.dp) | ||
| ) | ||
| trimmed.startsWith("## ") -> Text( | ||
| trimmed.startsWith("## ") -> ProseText( | ||
| annotateInline(trimmed.removePrefix("## "), baseColor), | ||
| style = MaterialTheme.typography.titleMedium, | ||
| color = baseColor, | ||
| modifier = Modifier.padding(top = 4.dp, bottom = 2.dp) | ||
| ) | ||
| trimmed.startsWith("# ") -> Text( | ||
| trimmed.startsWith("# ") -> ProseText( | ||
| annotateInline(trimmed.removePrefix("# "), baseColor), | ||
| style = MaterialTheme.typography.titleLarge, | ||
| color = baseColor, | ||
|
|
@@ -159,7 +165,7 @@ private fun renderProse(body: String, baseColor: androidx.compose.ui.graphics.Co | |
| modifier = Modifier.width(3.dp).height(20.dp) | ||
| ) {} | ||
| Spacer(Modifier.width(8.dp)) | ||
| Text( | ||
| ProseText( | ||
| annotateInline(trimmed.removePrefix("> "), baseColor), | ||
| color = MaterialTheme.colorScheme.onSurfaceVariant, | ||
| style = MaterialTheme.typography.bodyMedium | ||
|
|
@@ -175,7 +181,7 @@ private fun renderProse(body: String, baseColor: androidx.compose.ui.graphics.Co | |
| val label = trimmed.removeRange(0, 6) | ||
| Row(verticalAlignment = Alignment.CenterVertically) { | ||
| Text(if (checked) "☑ " else "☐ ", color = baseColor) | ||
| Text( | ||
| ProseText( | ||
| annotateInline(label, baseColor), | ||
| color = baseColor, | ||
| style = MaterialTheme.typography.bodyMedium | ||
|
|
@@ -187,7 +193,7 @@ private fun renderProse(body: String, baseColor: androidx.compose.ui.graphics.Co | |
| horizontalArrangement = Arrangement.Start | ||
| ) { | ||
| Text("• ", color = baseColor) | ||
| Text( | ||
| ProseText( | ||
| annotateInline(trimmed.drop(2), baseColor), | ||
| color = baseColor, | ||
| style = MaterialTheme.typography.bodyMedium | ||
|
|
@@ -198,18 +204,18 @@ private fun renderProse(body: String, baseColor: androidx.compose.ui.graphics.Co | |
| if (m != null) { | ||
| Row { | ||
| Text("${m.groupValues[1]}. ", color = baseColor) | ||
| Text( | ||
| ProseText( | ||
| annotateInline(m.groupValues[2], baseColor), | ||
| color = baseColor, | ||
| style = MaterialTheme.typography.bodyMedium | ||
| ) | ||
| } | ||
| } else { | ||
| Text(annotateInline(raw, baseColor), color = baseColor) | ||
| ProseText(annotateInline(raw, baseColor), color = baseColor) | ||
| } | ||
| } | ||
| trimmed.isEmpty() -> Spacer(Modifier.padding(vertical = 3.dp)) | ||
| else -> Text( | ||
| else -> ProseText( | ||
| annotateInline(raw, baseColor), | ||
| color = baseColor, | ||
| style = MaterialTheme.typography.bodyMedium | ||
|
|
@@ -278,7 +284,7 @@ private fun RowScope.TableCell( | |
| header: Boolean, | ||
| last: Boolean | ||
| ) { | ||
| Text( | ||
| ProseText( | ||
| annotateInline(text, baseColor), | ||
| style = if (header) MaterialTheme.typography.labelMedium | ||
| else MaterialTheme.typography.bodySmall, | ||
|
|
@@ -289,6 +295,48 @@ private fun RowScope.TableCell( | |
| ) | ||
| } | ||
|
|
||
| // Text with clickable URL annotations (`URL_TAG`). On tap, launches ACTION_VIEW | ||
| // for the URL under the touch point. Falls back to a plain Text when the string | ||
| // has no URL annotations so regular prose doesn't eat long-press / selection | ||
| // gestures unnecessarily. | ||
| @Composable | ||
| private fun ProseText( | ||
| text: AnnotatedString, | ||
| modifier: Modifier = Modifier, | ||
| style: TextStyle = LocalTextStyle.current, | ||
| color: androidx.compose.ui.graphics.Color = androidx.compose.ui.graphics.Color.Unspecified | ||
| ) { | ||
| val hasLinks = text.getStringAnnotations(URL_TAG, 0, text.length).isNotEmpty() | ||
| if (!hasLinks) { | ||
| Text(text, modifier = modifier, style = style, color = color) | ||
| return | ||
| } | ||
| val context = LocalContext.current | ||
| val effectiveStyle = if (color == androidx.compose.ui.graphics.Color.Unspecified) style | ||
| else style.copy(color = color) | ||
| ClickableText( | ||
| text = text, | ||
| modifier = modifier, | ||
| style = effectiveStyle, | ||
| onClick = { offset -> | ||
| text.getStringAnnotations(URL_TAG, offset, offset).firstOrNull() | ||
| ?.let { ann -> openUrl(context, ann.item) } | ||
| } | ||
| ) | ||
| } | ||
|
|
||
| private fun openUrl(context: Context, url: String) { | ||
| runCatching { | ||
| val normalised = if (url.startsWith("http://") || url.startsWith("https://")) url | ||
|
Comment on lines
+328
to
+330
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. issue (bug_risk): For non-http(s) links (mailto:, tel:, custom app schemes), the normalization prepends The normalization currently assumes anything not starting with |
||
| else "https://$url" | ||
| val intent = Intent(Intent.ACTION_VIEW, Uri.parse(normalised)) | ||
| .addFlags(Intent.FLAG_ACTIVITY_NEW_TASK) | ||
| context.startActivity(intent) | ||
| }.onFailure { | ||
| Toast.makeText(context, "Cannot open link: $url", Toast.LENGTH_SHORT).show() | ||
| } | ||
| } | ||
|
|
||
| private sealed interface Segment { | ||
| data class Text(val body: String) : Segment | ||
| data class Code(val language: String?, val body: String) : Segment | ||
|
|
@@ -354,10 +402,9 @@ private fun annotateInline(line: String, baseColor: androidx.compose.ui.graphics | |
| c == '_' || (c == '*' && (i == 0 || line[i - 1] != '*')) -> { | ||
| val end = line.indexOf(c, i + 1) | ||
| if (end > i) { | ||
| withStyle( | ||
| SpanStyle(fontWeight = FontWeight.Normal, | ||
| textDecoration = TextDecoration.None) | ||
| ) { append(line.substring(i + 1, end)) } | ||
| withStyle(SpanStyle(fontStyle = FontStyle.Italic)) { | ||
| append(line.substring(i + 1, end)) | ||
| } | ||
| i = end + 1 | ||
| } else { append(c); i++ } | ||
| } | ||
|
|
@@ -366,21 +413,63 @@ private fun annotateInline(line: String, baseColor: androidx.compose.ui.graphics | |
| val paren = if (close > 0 && close + 1 < line.length && line[close + 1] == '(') | ||
| line.indexOf(')', close + 2) else -1 | ||
| if (close > 0 && paren > 0) { | ||
| val label = line.substring(i + 1, close) | ||
| val url = line.substring(close + 2, paren) | ||
| pushStringAnnotation(tag = URL_TAG, annotation = url) | ||
| withStyle( | ||
| SpanStyle( | ||
| color = baseColor, | ||
| color = MaterialThemeLink, | ||
| textDecoration = TextDecoration.Underline | ||
| ) | ||
| ) { append(line.substring(i + 1, close)) } | ||
| ) { append(label) } | ||
| pop() | ||
| i = paren + 1 | ||
| } else { append(c); i++ } | ||
| } | ||
| // Bare URLs (http://, https://). | ||
| c == 'h' && line.startsWith("http", i) && run { | ||
| val rest = line.substring(i) | ||
| rest.startsWith("http://") || rest.startsWith("https://") | ||
| } -> { | ||
| val end = findUrlEnd(line, i) | ||
| val url = line.substring(i, end) | ||
| pushStringAnnotation(tag = URL_TAG, annotation = url) | ||
| withStyle( | ||
| SpanStyle( | ||
| color = MaterialThemeLink, | ||
| textDecoration = TextDecoration.Underline | ||
| ) | ||
| ) { append(url) } | ||
| pop() | ||
| i = end | ||
| } | ||
| else -> { append(c); i++ } | ||
| } | ||
| } | ||
| } | ||
| } | ||
|
|
||
| private fun findUrlEnd(line: String, start: Int): Int { | ||
| var j = start | ||
| while (j < line.length) { | ||
| val ch = line[j] | ||
| // Break on whitespace, closing brackets, and trailing punctuation that | ||
| // is almost never part of a URL. | ||
| if (ch.isWhitespace() || ch in ")]>\"'`") break | ||
| j++ | ||
| } | ||
| // Strip trailing . , ; : ! ? — common sentence punctuation right after URLs. | ||
| while (j > start + 1 && line[j - 1] in ".,;:!?") j-- | ||
| return j | ||
| } | ||
|
|
||
| private const val URL_TAG = "URL" | ||
|
|
||
| // Link colour — kept as a top-level constant so annotateInline stays pure (no | ||
| // @Composable context needed). Matches Material 3 primary on both themes | ||
| // reasonably well; the underline decoration carries most of the affordance. | ||
| private val MaterialThemeLink = androidx.compose.ui.graphics.Color(0xFF4285F4) | ||
|
|
||
| private fun copyToClipboard(context: Context, text: String) { | ||
| val cm = context.getSystemService(Context.CLIPBOARD_SERVICE) as ClipboardManager | ||
| cm.setPrimaryClip(ClipData.newPlainText("code", text)) | ||
|
|
||
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
issue (performance): Bitmap decoding is happening synchronously on the main thread, which can cause jank for large or multiple images.
decodeThumbnail(path)is run insideremember { ... }, so both the bounds check andBitmapFactory.decodeFileexecute on the main thread during composition. Decoding several large (20MB+) images this way can stall the UI. Move decoding to a background dispatcher (e.g.produceState/LaunchedEffectwithwithContext(Dispatchers.IO)) or use an image-loading library that manages threading and caching for you.