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:
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,
|
||||
)
|
||||
|
||||
|
||||
Reference in new issue
Block a user