Attach any file, take shares from other apps, and survive a backwards highlight

Attachments were images only. Now any file can be attached: from the file
chooser behind the "+" menu, or from Android's share sheet, which the app
is now in. An image still goes to the model as a picture; anything else is
stored under its own name (`<hex>-<name>`, cleaned by `safe_file_name`)
and the Claude driver ends the message with `Attached file: /abs/path`,
since the CLI reads files by path and a model cannot be shown a trace. The
user-message field is renamed `images` -> `attachments` on both sides,
with a serde alias reading the rows written before. A share arrives before
anyone has said which session it is for, so it is held in AppRoot with a
banner on the list until a session takes it; an open session takes it at
once. Unreadable shares are reported beside the composer, not thrown.

The tool card crashed the app when opened on a command holding a quoted
glob such as `-path '*/.git/*'`: highlights 1.1.0's shell lexer answers
`x '*/a/*'` with a span whose end is before its start, and AnnotatedString
refuses the range. Such spans are dropped; the library is the place for
the fix. The echo driver gains `/bash <command>` so a card with a given
command can be produced on the emulator.

ui-sandbox.sh's token salvage read the tokens block's close only at a line
start, ran past the compact `),],` the server writes, and copied `setups`
into the new config twice, which the server then refused.

Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
This commit is contained in:
irisandClaude Fable 5.1 committed 2026-09-03 08:15:10 -04:00
1 parent 4bc69e8f9c
commit 6180663f14
25 files changed
+672 -165

No files matched your search

@@ -2,6 +2,7 @@ package com.example.aiapp
import android.content.Context
import android.content.pm.ApplicationInfo
import android.net.Uri
import android.os.Build
import android.os.SystemClock
import android.util.Log
@@ -228,7 +229,15 @@ private fun Modifier.holdTopEdge(key: Any, held: TopEdgeHold, hold: (Int) -> Uni
// self-correction needs it.
@OptIn(ExperimentalLayoutApi::class)
@Composable
fun SessionScreen(settings: ServerSettings, summary: SessionSummary, onBack: () -> Unit) {
fun SessionScreen(
settings: ServerSettings,
summary: SessionSummary,
onBack: () -> Unit,
/** What another app shared in while this session is the one open; see [ShareRequest]. */
share: ShareRequest? = null,
/** Said once [share] has been attached here, so it is not attached again. */
onShareTaken: () -> Unit = {},
) {
DebugStats.count("session screen recomposed")
val scope = rememberCoroutineScope()
val topEdgeHeld = remember { TopEdgeHold() }
@@ -410,7 +419,7 @@ fun SessionScreen(settings: ServerSettings, summary: SessionSummary, onBack: ()
// Waiting, then read. Matched by id: the same message sent twice is two
// bubbles, and clearing by text would take away whichever matched first.
if (event is SessionEvent.MessageQueued) {
queued = queued + QueuedMessage(event.id, event.text, event.images)
queued = queued + QueuedMessage(event.id, event.text, event.attachments)
}
if (event is SessionEvent.UserMessage) {
queued = queued.filterNot { it.id == event.id }
@@ -1076,34 +1085,45 @@ fun SessionScreen(settings: ServerSettings, summary: SessionSummary, onBack: ()
act { sendMessage(settings, summary.id, text, attachments) }
}
// The system photo picker; the image uploads as soon as it's chosen,
// so Send only has ids to reference.
val pickImage =
rememberLauncherForActivityResult(ActivityResultContracts.PickVisualMedia()) { uri ->
if (uri != null) {
scope.launch {
try {
val id =
withContext(Dispatchers.IO) {
// Shrunk to what this session's provider takes before it is
// uploaded, so a twelve-megapixel photo does not cross the tunnel
// to be rejected at the far end -- see `uploadPickedImage`.
uploadPickedImage(
context,
settings,
summary.id,
uri,
summary.maxImageEdge,
)
}
pendingAttachments = pendingAttachments + id
actionError = null
} catch (e: ApiException) {
actionError = e.message
// One path for everything attached, however it arrived: the photo picker, the file chooser
// or another app's share sheet. It uploads as soon as it is chosen, so Send only has ids to
// reference.
fun attach(uri: Uri) {
scope.launch {
try {
val id =
withContext(Dispatchers.IO) {
// An image is shrunk to what this session's provider takes before it is
// uploaded, so a twelve-megapixel photo does not cross the tunnel to be
// rejected at the far end; a file goes whole -- see `uploadPicked`.
uploadPicked(context, settings, summary.id, uri, summary.maxImageEdge)
}
}
pendingAttachments = pendingAttachments + id
actionError = null
} catch (e: ApiException) {
actionError = e.message
}
}
}
val pickImage =
rememberLauncherForActivityResult(ActivityResultContracts.PickVisualMedia()) { uri ->
uri?.let(::attach)
}
val pickFile =
rememberLauncherForActivityResult(ActivityResultContracts.OpenDocument()) { uri ->
uri?.let(::attach)
}
// What another app shared in, attached the moment this screen has it. Taken off the request
// first, so a recomposition or a return to this screen cannot attach it a second time.
LaunchedEffect(share) {
val incoming = share ?: return@LaunchedEffect
onShareTaken()
incoming.uris.forEach(::attach)
incoming.text?.let { shared ->
input = if (input.isBlank()) shared else input + "\n" + shared
saveDraft(context, summary.id, input)
}
}
// One poll for this machine's limits, read by the two things that show them: the bar under
// the header, and the colour of the button that opens the dialog.
@@ -1333,7 +1353,7 @@ fun SessionScreen(settings: ServerSettings, summary: SessionSummary, onBack: ()
settings = settings,
sessionId = summary.id,
text = waiting.text,
images = waiting.images,
attachments = waiting.attachments,
onOpenImage = ::openImage,
pending = true,
refusal = waiting.refusal,
@@ -1470,7 +1490,7 @@ fun SessionScreen(settings: ServerSettings, summary: SessionSummary, onBack: ()
settings = settings,
sessionId = summary.id,
text = item.text,
images = item.images,
attachments = item.attachments,
onOpenImage = ::openImage,
)
is TranscriptItem.AssistantMsg ->
@@ -1727,17 +1747,38 @@ fun SessionScreen(settings: ServerSettings, summary: SessionSummary, onBack: ()
verticalAlignment = Alignment.CenterVertically,
modifier = Modifier.fillMaxWidth(),
) {
TextButton(
onClick = {
pickImage.launch(
PickVisualMediaRequest(
ActivityResultContracts.PickVisualMedia.ImageOnly
)
// Photo or file, asked here rather than by two buttons: the row is full,
// and attaching is one action whichever picker answers it.
var attaching by remember { mutableStateOf(false) }
Box {
// Just "+". The count it used to carry was standing in for showing them.
TextButton(onClick = { attaching = true }) { Text("+") }
DropdownMenu(
expanded = attaching,
onDismissRequest = { attaching = false },
// See PickerButton: without this the menu opens a status bar's
// height away from the button in an edge-to-edge activity.
properties = PopupProperties(clippingEnabled = false),
) {
DropdownMenuItem(
text = { Text("Photo") },
onClick = {
attaching = false
pickImage.launch(
PickVisualMediaRequest(
ActivityResultContracts.PickVisualMedia.ImageOnly
)
)
},
)
DropdownMenuItem(
text = { Text("File") },
onClick = {
attaching = false
pickFile.launch(arrayOf("*/*"))
},
)
}
) {
// Just "+" now. The count was standing in for showing them.
Text("+")
}
// The settings share what is left after the actions have
// taken what they need. A Row hands out intrinsic widths in
@@ -1929,9 +1970,9 @@ private fun UserChunkRow(
Text(unit.text, color = MaterialTheme.colorScheme.onPrimaryContainer)
// The same arrangement [UserBubble] gives them: under the words, on the last slice
// because that is the bubble's bottom.
unit.images.forEachIndexed { index, ref ->
unit.attachments.forEachIndexed { index, ref ->
if (index > 0 || unit.text.isNotEmpty()) Spacer(Modifier.height(4.dp))
SessionImage(settings, sessionId, ref, onOpenImage)
Attachment(settings, sessionId, ref, onOpenImage)
}
}
}
@@ -1957,7 +1998,7 @@ private fun UserBubble(
settings: ServerSettings,
sessionId: String,
text: String,
images: List<String> = emptyList(),
attachments: List<String> = emptyList(),
onOpenImage: (String) -> Unit,
pending: Boolean = false,
refusal: String? = null,
@@ -2003,9 +2044,9 @@ private fun UserBubble(
// Under the words: what somebody wrote is what the bubble is, and the picture is
// what they attached to it. It also keeps the first line of every bubble at the
// same place down the transcript, whether or not there is an image in it.
images.forEachIndexed { index, ref ->
attachments.forEachIndexed { index, ref ->
if (index > 0 || text.isNotEmpty()) Spacer(Modifier.height(4.dp))
SessionImage(settings, sessionId, ref, onOpenImage)
Attachment(settings, sessionId, ref, onOpenImage)
}
refusal?.let {
Spacer(Modifier.height(6.dp))
@@ -2030,7 +2071,7 @@ private fun UserBubble(
private data class QueuedMessage(
val id: String,
val text: String,
val images: List<String>,
val attachments: List<String>,
val refusal: String? = null,
)