Take ktfmt's defaults for the Kotlin half
The server half went to rustfmt's defaults earlier today; this is the same move for the app, and the same reasoning. Kotlin ships no formatter with the Gradle build, so the question was which to adopt: ktfmt is Kotlin-org owned now (it moved from facebook/ktfmt), is a formatter rather than a configurable linter, and has essentially nothing to tune -- which is what rule 27 is asking for. ktlint's .editorconfig surface is the thing that rule warns against, and detekt is static analysis, whose job Android Lint already does here. One setting, and it is a choice between the tool's own two styles rather than a tuning: kotlinLangStyle() is the 4-space one, which is what this code already was. The 2-space default would have reindented every file to say nothing. ./gradlew :androidApp:ktfmtFormat to apply ./gradlew :androidApp:ktfmtCheck to verify Formatting only. The one thing worth checking by hand was the generated PEM constant, since a leading newline there costs Android's CertificateFactory its preamble sniff and fails at runtime nowhere near the cause: ktfmt moved `.trimMargin()` onto its own line and left the template alone, and the regenerated constant still starts at the opening quotes. Verified after: ktfmtCheck, compileDebugKotlin and lintDebug all pass, and the APK builds. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_017xn8nHw1tw1R6PtiY1eEtw
This commit is contained in:
1 parent
dde5042b12
commit
0c13090d70
16 files changed
+585
-496
No files matched your search
@@ -44,24 +44,25 @@ import androidx.compose.ui.graphics.ImageBitmap
|
||||
import androidx.compose.ui.graphics.asImageBitmap
|
||||
import androidx.compose.ui.platform.LocalContext
|
||||
import androidx.compose.ui.unit.dp
|
||||
import java.util.concurrent.atomic.AtomicLong
|
||||
import java.util.concurrent.atomic.AtomicReference
|
||||
import kotlinx.coroutines.Dispatchers
|
||||
import kotlinx.coroutines.delay
|
||||
import kotlinx.coroutines.launch
|
||||
import kotlinx.coroutines.withContext
|
||||
import java.util.concurrent.atomic.AtomicLong
|
||||
import java.util.concurrent.atomic.AtomicReference
|
||||
|
||||
private const val RECONNECT_DELAY_MS = 1500L
|
||||
|
||||
/**
|
||||
* What the transcript renders: the event stream folded into displayable
|
||||
* rows (see [foldEvent]). The stream is the only data source -- opening
|
||||
* this screen replays from seq 0, and a reconnect resumes from the last
|
||||
* seq seen, so there is no separate history fetch to drift from it.
|
||||
* What the transcript renders: the event stream folded into displayable rows (see [foldEvent]). The
|
||||
* stream is the only data source -- opening this screen replays from seq 0, and a reconnect resumes
|
||||
* from the last seq seen, so there is no separate history fetch to drift from it.
|
||||
*/
|
||||
sealed class TranscriptItem {
|
||||
data class UserMsg(val text: String) : TranscriptItem()
|
||||
|
||||
data class AssistantMsg(val text: String) : TranscriptItem()
|
||||
|
||||
data class ToolRun(
|
||||
val id: String,
|
||||
val tool: String,
|
||||
@@ -69,15 +70,19 @@ sealed class TranscriptItem {
|
||||
val output: String,
|
||||
val done: Boolean,
|
||||
) : TranscriptItem()
|
||||
|
||||
data class QuestionCard(
|
||||
val id: String,
|
||||
val prompt: String,
|
||||
val options: List<String>,
|
||||
val answer: String?,
|
||||
) : TranscriptItem()
|
||||
|
||||
data class ErrorMsg(val message: String) : TranscriptItem()
|
||||
|
||||
/** An image by server-side ref, fetched from the session's files route. */
|
||||
data class ImageItem(val ref: String) : TranscriptItem()
|
||||
|
||||
/** Placeholder row for events this build can't render (newer kinds). */
|
||||
data class Note(val text: String) : TranscriptItem()
|
||||
}
|
||||
@@ -96,20 +101,24 @@ fun foldEvent(items: List<TranscriptItem>, event: SessionEvent): List<Transcript
|
||||
}
|
||||
is SessionEvent.ToolStart ->
|
||||
items + TranscriptItem.ToolRun(event.id, event.tool, event.input, "", done = false)
|
||||
is SessionEvent.ToolUpdate ->
|
||||
updateTool(items, event.id) { it.copy(output = event.output) }
|
||||
is SessionEvent.ToolUpdate -> updateTool(items, event.id) { it.copy(output = event.output) }
|
||||
is SessionEvent.ToolEnd ->
|
||||
updateTool(items, event.id) { it.copy(output = event.output, done = true) }
|
||||
is SessionEvent.Question ->
|
||||
items + TranscriptItem.QuestionCard(event.id, event.prompt, event.options, answer = null)
|
||||
is SessionEvent.Answered -> items.map {
|
||||
if (it is TranscriptItem.QuestionCard && it.id == event.id) it.copy(answer = event.answer) else it
|
||||
}
|
||||
items +
|
||||
TranscriptItem.QuestionCard(event.id, event.prompt, event.options, answer = null)
|
||||
is SessionEvent.Answered ->
|
||||
items.map {
|
||||
if (it is TranscriptItem.QuestionCard && it.id == event.id)
|
||||
it.copy(answer = event.answer)
|
||||
else it
|
||||
}
|
||||
is SessionEvent.Error -> items + TranscriptItem.ErrorMsg(event.message)
|
||||
is SessionEvent.Image -> items + TranscriptItem.ImageItem(event.ref)
|
||||
is SessionEvent.Unknown -> items + TranscriptItem.Note("[${event.type}]")
|
||||
// Screen-level state, not transcript rows -- see SessionScreen.
|
||||
is SessionEvent.Status, is SessionEvent.UsageDelta -> items
|
||||
is SessionEvent.Status,
|
||||
is SessionEvent.UsageDelta -> items
|
||||
}
|
||||
|
||||
private fun updateTool(
|
||||
@@ -171,9 +180,7 @@ fun SessionScreen(settings: ServerSettings, summary: SessionSummary, onBack: ()
|
||||
}
|
||||
// Coroutine cancellation can't interrupt a blocking socket read;
|
||||
// closing the stream is what unblocks it when this screen goes away.
|
||||
DisposableEffect(summary.id) {
|
||||
onDispose { activeStream.get()?.close() }
|
||||
}
|
||||
DisposableEffect(summary.id) { onDispose { activeStream.get()?.close() } }
|
||||
|
||||
LaunchedEffect(items.size) {
|
||||
if (items.isNotEmpty()) listState.animateScrollToItem(items.size - 1)
|
||||
@@ -201,27 +208,28 @@ fun SessionScreen(settings: ServerSettings, summary: SessionSummary, onBack: ()
|
||||
|
||||
// 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) {
|
||||
val bytes = context.contentResolver.openInputStream(uri)
|
||||
?.use { it.readBytes() }
|
||||
?: throw ApiException("couldn't read the picked image")
|
||||
val mime = context.contentResolver.getType(uri) ?: "image/jpeg"
|
||||
uploadAttachment(settings, summary.id, bytes, mime)
|
||||
val pickImage =
|
||||
rememberLauncherForActivityResult(ActivityResultContracts.PickVisualMedia()) { uri ->
|
||||
if (uri != null) {
|
||||
scope.launch {
|
||||
try {
|
||||
val id =
|
||||
withContext(Dispatchers.IO) {
|
||||
val bytes =
|
||||
context.contentResolver.openInputStream(uri)?.use {
|
||||
it.readBytes()
|
||||
} ?: throw ApiException("couldn't read the picked image")
|
||||
val mime = context.contentResolver.getType(uri) ?: "image/jpeg"
|
||||
uploadAttachment(settings, summary.id, bytes, mime)
|
||||
}
|
||||
pendingAttachments = pendingAttachments + id
|
||||
actionError = null
|
||||
} catch (e: ApiException) {
|
||||
actionError = e.message
|
||||
}
|
||||
pendingAttachments = pendingAttachments + id
|
||||
actionError = null
|
||||
} catch (e: ApiException) {
|
||||
actionError = e.message
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
Column(Modifier.fillMaxSize()) {
|
||||
Row(
|
||||
@@ -233,11 +241,12 @@ fun SessionScreen(settings: ServerSettings, summary: SessionSummary, onBack: ()
|
||||
Text(summary.title, style = MaterialTheme.typography.titleMedium)
|
||||
Text(
|
||||
listOfNotNull(
|
||||
summary.provider,
|
||||
summary.host?.let { "on $it" },
|
||||
summary.model,
|
||||
if (totalTokens > 0) "$totalTokens tok" else null,
|
||||
).joinToString(" · "),
|
||||
summary.provider,
|
||||
summary.host?.let { "on $it" },
|
||||
summary.model,
|
||||
if (totalTokens > 0) "$totalTokens tok" else null,
|
||||
)
|
||||
.joinToString(" · "),
|
||||
style = MaterialTheme.typography.bodySmall,
|
||||
color = MaterialTheme.colorScheme.onSurfaceVariant,
|
||||
)
|
||||
@@ -263,30 +272,35 @@ fun SessionScreen(settings: ServerSettings, summary: SessionSummary, onBack: ()
|
||||
items(items) { item ->
|
||||
when (item) {
|
||||
is TranscriptItem.UserMsg -> UserBubble(item.text)
|
||||
is TranscriptItem.AssistantMsg -> Text(item.text, style = MaterialTheme.typography.bodyLarge)
|
||||
is TranscriptItem.ToolRun -> ToolCard(
|
||||
tool = item,
|
||||
expanded = item.id in expandedTools,
|
||||
onToggle = {
|
||||
expandedTools =
|
||||
if (item.id in expandedTools) expandedTools - item.id
|
||||
else expandedTools + item.id
|
||||
},
|
||||
)
|
||||
is TranscriptItem.QuestionCard -> QuestionRow(item) { answer ->
|
||||
act { answerQuestion(settings, summary.id, item.id, answer) }
|
||||
}
|
||||
is TranscriptItem.ErrorMsg -> Text(
|
||||
item.message,
|
||||
color = MaterialTheme.colorScheme.error,
|
||||
style = MaterialTheme.typography.bodyMedium,
|
||||
)
|
||||
is TranscriptItem.AssistantMsg ->
|
||||
Text(item.text, style = MaterialTheme.typography.bodyLarge)
|
||||
is TranscriptItem.ToolRun ->
|
||||
ToolCard(
|
||||
tool = item,
|
||||
expanded = item.id in expandedTools,
|
||||
onToggle = {
|
||||
expandedTools =
|
||||
if (item.id in expandedTools) expandedTools - item.id
|
||||
else expandedTools + item.id
|
||||
},
|
||||
)
|
||||
is TranscriptItem.QuestionCard ->
|
||||
QuestionRow(item) { answer ->
|
||||
act { answerQuestion(settings, summary.id, item.id, answer) }
|
||||
}
|
||||
is TranscriptItem.ErrorMsg ->
|
||||
Text(
|
||||
item.message,
|
||||
color = MaterialTheme.colorScheme.error,
|
||||
style = MaterialTheme.typography.bodyMedium,
|
||||
)
|
||||
is TranscriptItem.ImageItem -> SessionImage(settings, summary.id, item.ref)
|
||||
is TranscriptItem.Note -> Text(
|
||||
item.text,
|
||||
style = MaterialTheme.typography.bodySmall,
|
||||
color = MaterialTheme.colorScheme.onSurfaceVariant,
|
||||
)
|
||||
is TranscriptItem.Note ->
|
||||
Text(
|
||||
item.text,
|
||||
style = MaterialTheme.typography.bodySmall,
|
||||
color = MaterialTheme.colorScheme.onSurfaceVariant,
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -298,18 +312,22 @@ fun SessionScreen(settings: ServerSettings, summary: SessionSummary, onBack: ()
|
||||
verticalAlignment = Alignment.CenterVertically,
|
||||
modifier = Modifier.fillMaxWidth().padding(8.dp),
|
||||
) {
|
||||
TextButton(onClick = {
|
||||
pickImage.launch(
|
||||
PickVisualMediaRequest(ActivityResultContracts.PickVisualMedia.ImageOnly),
|
||||
)
|
||||
}) {
|
||||
TextButton(
|
||||
onClick = {
|
||||
pickImage.launch(
|
||||
PickVisualMediaRequest(ActivityResultContracts.PickVisualMedia.ImageOnly)
|
||||
)
|
||||
}
|
||||
) {
|
||||
Text(if (pendingAttachments.isEmpty()) "+" else "+${pendingAttachments.size}")
|
||||
}
|
||||
OutlinedTextField(
|
||||
value = input,
|
||||
onValueChange = { input = it },
|
||||
modifier = Modifier.weight(1f),
|
||||
placeholder = { Text(if (pendingAttachments.isEmpty()) "Message" else "Message (+image)") },
|
||||
placeholder = {
|
||||
Text(if (pendingAttachments.isEmpty()) "Message" else "Message (+image)")
|
||||
},
|
||||
maxLines = 4,
|
||||
)
|
||||
Spacer(Modifier.width(8.dp))
|
||||
@@ -325,9 +343,8 @@ fun SessionScreen(settings: ServerSettings, summary: SessionSummary, onBack: ()
|
||||
}
|
||||
|
||||
/**
|
||||
* An inline transcript image, fetched (authenticated, pinned) from the
|
||||
* session's files route. The bitmap is remembered per ref, so scrolling
|
||||
* doesn't refetch.
|
||||
* An inline transcript image, fetched (authenticated, pinned) from the session's files route. The
|
||||
* bitmap is remembered per ref, so scrolling doesn't refetch.
|
||||
*/
|
||||
@Composable
|
||||
private fun SessionImage(settings: ServerSettings, sessionId: String, ref: String) {
|
||||
@@ -343,16 +360,18 @@ private fun SessionImage(settings: ServerSettings, sessionId: String, ref: Strin
|
||||
}
|
||||
}
|
||||
when (val image = bitmap) {
|
||||
null -> Text(
|
||||
if (failed) "[image $ref unavailable]" else "[loading image…]",
|
||||
style = MaterialTheme.typography.bodySmall,
|
||||
color = MaterialTheme.colorScheme.onSurfaceVariant,
|
||||
)
|
||||
else -> Image(
|
||||
bitmap = image,
|
||||
contentDescription = "session image",
|
||||
modifier = Modifier.fillMaxWidth(),
|
||||
)
|
||||
null ->
|
||||
Text(
|
||||
if (failed) "[image $ref unavailable]" else "[loading image…]",
|
||||
style = MaterialTheme.typography.bodySmall,
|
||||
color = MaterialTheme.colorScheme.onSurfaceVariant,
|
||||
)
|
||||
else ->
|
||||
Image(
|
||||
bitmap = image,
|
||||
contentDescription = "session image",
|
||||
modifier = Modifier.fillMaxWidth(),
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
@@ -360,9 +379,10 @@ private fun SessionImage(settings: ServerSettings, sessionId: String, ref: Strin
|
||||
private fun UserBubble(text: String) {
|
||||
Box(Modifier.fillMaxWidth()) {
|
||||
Card(
|
||||
colors = CardDefaults.cardColors(
|
||||
containerColor = MaterialTheme.colorScheme.primaryContainer,
|
||||
),
|
||||
colors =
|
||||
CardDefaults.cardColors(
|
||||
containerColor = MaterialTheme.colorScheme.primaryContainer
|
||||
),
|
||||
modifier = Modifier.align(Alignment.CenterEnd).padding(start = 48.dp),
|
||||
) {
|
||||
Text(text, modifier = Modifier.padding(12.dp))
|
||||
@@ -371,16 +391,19 @@ private fun UserBubble(text: String) {
|
||||
}
|
||||
|
||||
/**
|
||||
* Collapsed by default: name plus a spinner while running, expandable to
|
||||
* the input and output. The spinner-while-unfinished is exactly "ToolStart
|
||||
* with no matching ToolEnd yet".
|
||||
* Collapsed by default: name plus a spinner while running, expandable to the input and output. The
|
||||
* spinner-while-unfinished is exactly "ToolStart with no matching ToolEnd yet".
|
||||
*/
|
||||
@Composable
|
||||
private fun ToolCard(tool: TranscriptItem.ToolRun, expanded: Boolean, onToggle: () -> Unit) {
|
||||
Card(Modifier.fillMaxWidth().clickable(onClick = onToggle)) {
|
||||
Column(Modifier.padding(12.dp)) {
|
||||
Row(verticalAlignment = Alignment.CenterVertically) {
|
||||
Text(tool.tool, style = MaterialTheme.typography.titleSmall, modifier = Modifier.weight(1f))
|
||||
Text(
|
||||
tool.tool,
|
||||
style = MaterialTheme.typography.titleSmall,
|
||||
modifier = Modifier.weight(1f),
|
||||
)
|
||||
if (!tool.done) {
|
||||
CircularProgressIndicator(
|
||||
modifier = Modifier.width(16.dp).height(16.dp),
|
||||
@@ -403,9 +426,9 @@ private fun ToolCard(tool: TranscriptItem.ToolRun, expanded: Boolean, onToggle:
|
||||
}
|
||||
|
||||
/**
|
||||
* A question (or permission request -- same shape) inline in the
|
||||
* transcript. Option buttons until answered; then the chosen answer, which
|
||||
* the `answered` event also resolves on every other connected device.
|
||||
* A question (or permission request -- same shape) inline in the transcript. Option buttons until
|
||||
* answered; then the chosen answer, which the `answered` event also resolves on every other
|
||||
* connected device.
|
||||
*/
|
||||
@Composable
|
||||
private fun QuestionRow(question: TranscriptItem.QuestionCard, onAnswer: (String) -> Unit) {
|
||||
|
||||
Reference in new issue
Block a user