diff --git a/apps/android/app/src/main/java/com/codedeck/plus/core/CoreHost.kt b/apps/android/app/src/main/java/com/codedeck/plus/core/CoreHost.kt index 9b4e1ba7..e767c9f2 100644 --- a/apps/android/app/src/main/java/com/codedeck/plus/core/CoreHost.kt +++ b/apps/android/app/src/main/java/com/codedeck/plus/core/CoreHost.kt @@ -1,6 +1,8 @@ package com.codedeck.plus.core import com.codedeck.plus.platform.CoreHttpFetch +import android.util.Log +import kotlinx.coroutines.CancellationException import kotlinx.coroutines.CoroutineScope import kotlinx.coroutines.Dispatchers import kotlinx.coroutines.SupervisorJob @@ -122,7 +124,19 @@ class CoreHost( private val requests = Channel(Channel.CONFLATED) init { - scope.launch { for (request in requests) sink.value = read() } + scope.launch { + for (request in requests) { + // An FFI read failure logs and keeps the loop alive: + // subsequent requests are re-read, not silent drops. + try { + sink.value = read() + } catch (e: CancellationException) { + throw e + } catch (e: Exception) { + Log.w("codedeck", "slice read failed: ${e::class.simpleName}: ${e.message?.take(160)}") + } + } + } } fun request() { @@ -181,7 +195,22 @@ class CoreHost( /** Whether the device has a usable network — see `Connectivity`. */ fun setOnline(online: Boolean) = core.setOnline(online) - suspend fun dispatch(intent: UniffiIntent) = core.dispatch(intent) + /** + * Hand [intent] to the core. Callers fire and forget from a UI scope, so + * a refused intent (a value the core does not know) or a fault in the + * core is logged here rather than crashing the app; what the user needs + * to see of a failure arrives as a core event. + */ + suspend fun dispatch(intent: UniffiIntent) { + try { + core.dispatch(intent) + } catch (e: CancellationException) { + throw e + } catch (e: Exception) { + // The intent's kind only: its fields may hold secrets. + Log.w("codedeck", "intent ${intent::class.simpleName} refused: ${e::class.simpleName}: ${e.message?.take(160)}") + } + } /** * The phone's own Nostr id in bech32 `npub1…` form — derived by the core diff --git a/apps/android/app/src/main/java/com/codedeck/plus/platform/CoreHttpFetch.kt b/apps/android/app/src/main/java/com/codedeck/plus/platform/CoreHttpFetch.kt index 39d0703c..7c6decaf 100644 --- a/apps/android/app/src/main/java/com/codedeck/plus/platform/CoreHttpFetch.kt +++ b/apps/android/app/src/main/java/com/codedeck/plus/platform/CoreHttpFetch.kt @@ -88,7 +88,13 @@ class CoreHttpFetch : UniffiHttpFetch { * the server at all throws [UniffiHttpException.Failed]. */ private fun exchange(url: String, method: String, headers: List, body: ByteArray): UniffiHttpResponse { - val conn = URL(url).openConnection(proxy ?: Proxy.NO_PROXY) as HttpURLConnection + val conn: HttpURLConnection = try { + URL(url).openConnection(proxy ?: Proxy.NO_PROXY) as HttpURLConnection + } catch (e: IOException) { + // A malformed URL is a failure to reach the server, same as any + // other connection failure below. + throw UniffiHttpException.Failed("$method $url failed: ${e.javaClass.simpleName}: ${e.message ?: "no detail"}") + } try { conn.requestMethod = method conn.connectTimeout = CONNECT_TIMEOUT_MS diff --git a/apps/android/app/src/main/java/com/codedeck/plus/platform/Nip55Signer.kt b/apps/android/app/src/main/java/com/codedeck/plus/platform/Nip55Signer.kt index c22a250f..b81ed81d 100644 --- a/apps/android/app/src/main/java/com/codedeck/plus/platform/Nip55Signer.kt +++ b/apps/android/app/src/main/java/com/codedeck/plus/platform/Nip55Signer.kt @@ -187,9 +187,15 @@ class Nip55Signer( private fun failed(detail: String) = UniffiSignerException.Failed(detail) /** A signed event built from `unsigned` and a bare `signature`, for a - * signer that returns only the signature. The core verifies it. */ + * signer that returns only the signature. The core verifies it; an + * `unsigned` the JSON parser refuses is a signer failure like any + * other (same type `ask`/`viaActivity` throw). */ private fun withSignature(unsigned: String, signature: String): String = - JSONObject(unsigned).put("sig", signature).toString() + try { + JSONObject(unsigned).put("sig", signature).toString() + } catch (e: Exception) { + throw failed("the signer's answer is not valid JSON: ${e.javaClass.simpleName}") + } } /** diff --git a/apps/android/app/src/main/java/com/codedeck/plus/platform/StayConnectedService.kt b/apps/android/app/src/main/java/com/codedeck/plus/platform/StayConnectedService.kt index f5a7b5b6..4f75f3a9 100644 --- a/apps/android/app/src/main/java/com/codedeck/plus/platform/StayConnectedService.kt +++ b/apps/android/app/src/main/java/com/codedeck/plus/platform/StayConnectedService.kt @@ -14,6 +14,7 @@ import android.os.Binder import android.os.Build import android.os.IBinder import android.os.PowerManager +import android.util.Log import android.os.SystemClock import androidx.core.app.NotificationCompat import androidx.core.app.NotificationManagerCompat @@ -24,6 +25,7 @@ import androidx.lifecycle.ProcessLifecycleOwner import com.codedeck.plus.MainActivity import com.codedeck.plus.R import com.codedeck.plus.core.CoreHost +import kotlinx.coroutines.CancellationException import kotlinx.coroutines.CoroutineScope import kotlinx.coroutines.Dispatchers import kotlinx.coroutines.Job @@ -147,8 +149,17 @@ class StayConnectedService : Service() { coreJob = job CoroutineScope(scope.coroutineContext + job).launch(Dispatchers.IO) { // Only started once a login exists (MainActivity); an OS restart - // after the user lost theirs has nothing to run. - val core = openCore() ?: run { + // after the user lost theirs has nothing to run. A storage or + // key-vault failure during the open is the same "no core to run" + // case, not a crash to die on. + val core = try { + openCore() + } catch (e: CancellationException) { + throw e + } catch (e: Exception) { + Log.w("codedeck", "core open failed: ${e::class.simpleName}: ${e.message?.take(160)}") + null + } ?: run { withContext(Dispatchers.Main) { stopSelf() } return@launch } @@ -160,7 +171,19 @@ class StayConnectedService : Service() { core.stop() return@launch } - core.start() + // A faulting core at start must not take the process down (the + // earlier crash loop): log, stop the service (its launch was the + // only thing running the core) so a later startForegroundService + // can open a fresh one. + try { + core.start() + } catch (e: CancellationException) { + throw e + } catch (e: Exception) { + Log.w("codedeck", "core start failed: ${e::class.simpleName}: ${e.message?.take(160)}") + withContext(Dispatchers.Main) { stopSelf() } + return@launch + } // Registered only now: adding the observer replays the current // app visibility at once, which must reach a started core. withContext(Dispatchers.Main) { @@ -229,12 +252,30 @@ class StayConnectedService : Service() { // Network reachability drives the connection FSM's offline/online // transitions; the first emission reconciles the state at startup. connectivity?.let { network -> - scope.launch { network.online.collect { core.setOnline(it) } } + // A refused setOnline (a faulting core) must not kill the + // collector: later emissions still reach the FSM. + scope.launch { + network.online.collect { online -> + try { + core.setOnline(online) + } catch (e: Exception) { + Log.w("codedeck", "setOnline refused: ${e::class.simpleName}: ${e.message?.take(160)}") + } + } + } // Internet access came back without the network going down (or // on another network): redial whatever is down now, rather than // after a backoff that grew through the outage. The replayed // initial value is not news. - scope.launch { network.regained.drop(1).collect { core.setOnline(true) } } + scope.launch { + network.regained.drop(1).collect { + try { + core.setOnline(true) + } catch (e: Exception) { + Log.w("codedeck", "setOnline refused: ${e::class.simpleName}: ${e.message?.take(160)}") + } + } + } } // The stay-connected setting drives THIS service's foreground state — // the settings screen only flips the stored value. Collecting here is @@ -442,7 +483,17 @@ class StayConnectedService : Service() { .apply { setReferenceCounted(false); acquire(KEEPALIVE_WAKE_MS) } scope.launch { try { - withTimeoutOrNull(KEEPALIVE_WAKE_MS - 2_000) { core.keepalive() } + withTimeoutOrNull(KEEPALIVE_WAKE_MS - 2_000) { + try { + core.keepalive() + } catch (e: CancellationException) { + throw e + } catch (e: Exception) { + // A failed check is not worth the process: the next + // alarm probes again. + Log.w("codedeck", "keepalive failed: ${e::class.simpleName}: ${e.message?.take(160)}") + } + } } finally { if (core.settings.value?.stayConnected != false) scheduleKeepAlive() if (wakeLock.isHeld) wakeLock.release() diff --git a/apps/android/app/src/main/java/com/codedeck/plus/ui/screens/SettingsScreen.kt b/apps/android/app/src/main/java/com/codedeck/plus/ui/screens/SettingsScreen.kt index c77445f9..f38b6287 100644 --- a/apps/android/app/src/main/java/com/codedeck/plus/ui/screens/SettingsScreen.kt +++ b/apps/android/app/src/main/java/com/codedeck/plus/ui/screens/SettingsScreen.kt @@ -193,7 +193,9 @@ fun SettingsScreen( return } val machines = machinesView?.machines.orEmpty().sortedBy { it.name.lowercase() } - val npub = remember { core.identityNpub() } + // An npub that fails to derive shows as absent rather than crashing + // the whole settings hub. + val npub = remember { runCatching { core.identityNpub() }.getOrDefault("") } val signerLabel = (login as? Login.SignerApp)?.let { remember(it.packageName) { appLabel(context, it.packageName) } } pages.SaveableStateProvider(pageKey) { diff --git a/apps/android/app/src/main/java/com/codedeck/plus/ui/session/SessionScreen.kt b/apps/android/app/src/main/java/com/codedeck/plus/ui/session/SessionScreen.kt index 69fc5204..995fa6d0 100644 --- a/apps/android/app/src/main/java/com/codedeck/plus/ui/session/SessionScreen.kt +++ b/apps/android/app/src/main/java/com/codedeck/plus/ui/session/SessionScreen.kt @@ -4,6 +4,7 @@ import android.app.Activity import android.content.Intent import android.net.Uri import android.os.SystemClock +import android.util.Log import android.speech.RecognizerIntent import androidx.activity.compose.rememberLauncherForActivityResult import androidx.activity.result.PickVisualMediaRequest @@ -90,6 +91,7 @@ import kotlinx.coroutines.CancellationException import kotlinx.coroutines.Dispatchers import kotlinx.coroutines.Job import kotlinx.coroutines.delay +import kotlinx.coroutines.flow.catch import kotlinx.coroutines.flow.flowOn import kotlinx.coroutines.flow.map import kotlinx.coroutines.launch @@ -206,6 +208,11 @@ fun SessionScreen( // Every delta is applied, in order: each builds on the one before. core.transcriptFlow(machine, sessionId) .map { view -> ParsedTranscript.of(view, previous).also { previous = it } } + // One row the JSON decoders cannot parse ends this collection + // (the transcript stops updating) rather than crashing the app. + .catch { e -> + Log.w("codedeck", "transcript decode ended: ${e::class.simpleName}: ${e.message?.take(160)}") + } .flowOn(Dispatchers.Default) .collect { transcript = it } } diff --git a/apps/android/app/src/main/java/com/codedeck/plus/ui/session/SlashCommandMenu.kt b/apps/android/app/src/main/java/com/codedeck/plus/ui/session/SlashCommandMenu.kt index 614104e4..3be6cfc7 100644 --- a/apps/android/app/src/main/java/com/codedeck/plus/ui/session/SlashCommandMenu.kt +++ b/apps/android/app/src/main/java/com/codedeck/plus/ui/session/SlashCommandMenu.kt @@ -86,15 +86,22 @@ internal fun SlashCommandMenu( * receding, so each row shows why it is listed. */ internal fun highlightedName(name: String, query: String): AnnotatedString = buildAnnotatedString { val at = if (query.isEmpty()) -1 else name.lowercase().indexOf(query.lowercase()) + // Lowercasing can change a string's length (e.g. "I" -> Turkish dotless + // i on some locales), so the match on the lowercased name may extend + // past the original name's end. The match length in the ORIGINAL string + // is what bounds the highlight; fall back to no highlight when the + // bounds are unsound. + val end = (at + query.length).coerceAtMost(name.length) + val match = at >= 0 && at < end val muted = SpanStyle(color = Tokens.TextMuted) withStyle(muted) { append("/") } - if (at < 0) { + if (!match) { withStyle(SpanStyle(color = Tokens.Text)) { append(name) } return@buildAnnotatedString } withStyle(muted) { append(name.substring(0, at)) } - withStyle(SpanStyle(color = Tokens.Text, fontWeight = FontWeight.SemiBold)) { append(name.substring(at, at + query.length)) } - withStyle(muted) { append(name.substring(at + query.length)) } + withStyle(SpanStyle(color = Tokens.Text, fontWeight = FontWeight.SemiBold)) { append(name.substring(at, end)) } + withStyle(muted) { append(name.substring(end)) } } @Composable diff --git a/apps/android/app/src/main/java/com/codedeck/plus/ui/transcript/DisplayEntries.kt b/apps/android/app/src/main/java/com/codedeck/plus/ui/transcript/DisplayEntries.kt index 9e311ff7..70e5dc52 100644 --- a/apps/android/app/src/main/java/com/codedeck/plus/ui/transcript/DisplayEntries.kt +++ b/apps/android/app/src/main/java/com/codedeck/plus/ui/transcript/DisplayEntries.kt @@ -3,6 +3,7 @@ package com.codedeck.plus.ui.transcript import kotlinx.serialization.ExperimentalSerializationApi import kotlinx.serialization.SerialName import kotlinx.serialization.Serializable +import kotlinx.serialization.Transient import kotlinx.serialization.json.Json import kotlinx.serialization.json.JsonClassDiscriminator @@ -158,12 +159,22 @@ sealed class DisplayEntry { @Serializable @SerialName("userMessage") - data class UserMessage(override val seq: Long, val text: String) : DisplayEntry() + data class UserMessage(override val seq: Long, val text: String) : DisplayEntry() { + /** [text] in list-sized blocks (see [markdownBlocks]); split as the + * entry is decoded, off the main thread. */ + @Transient + val blocks: List = markdownBlocks(text) + } /** Agent markdown; `isPlan` frames it as a plan document. */ @Serializable @SerialName("agentMessage") - data class AgentMessage(override val seq: Long, val text: String, val isPlan: Boolean = false) : DisplayEntry() + data class AgentMessage(override val seq: Long, val text: String, val isPlan: Boolean = false) : DisplayEntry() { + /** [text] in list-sized blocks (see [markdownBlocks]); split as the + * entry is decoded, off the main thread. */ + @Transient + val blocks: List = markdownBlocks(text) + } /** A run of tool activity. `summary` says what it did ("Ran 3 * commands, read a file"); a lone call is its verb, with `subject` diff --git a/apps/android/app/src/main/java/com/codedeck/plus/ui/transcript/Markdown.kt b/apps/android/app/src/main/java/com/codedeck/plus/ui/transcript/Markdown.kt index cca07860..8b1e49b5 100644 --- a/apps/android/app/src/main/java/com/codedeck/plus/ui/transcript/Markdown.kt +++ b/apps/android/app/src/main/java/com/codedeck/plus/ui/transcript/Markdown.kt @@ -34,9 +34,13 @@ import com.mikepenz.markdown.model.rememberMarkdownState * renderer bump silently regressing either one. * * `immediate = true`: transcript rows are already-received text, not a - * live-typed editor buffer — synchronous parsing costs one frame on a cold - * row and, unlike the renderer's default async path (`MarkdownState` + * live-typed editor buffer, and a long message reaches this renderer one + * block of a few thousand characters at a time (`markdownBlocks`, each block + * a list item of its own), so a synchronous parse costs a fraction of a + * frame and, unlike the renderer's default async path (`MarkdownState` * introduced in 0.33.0), is deterministic for Paparazzi's static snapshots. + * Never hand it a whole long message: parsing and laying it out at once on + * the main thread is what froze the app. * * Syntax highlighting stays plain monochrome (no `-code` module, no * Rust-side span generation) — matches the TS renderer's own lazy/ diff --git a/apps/android/app/src/main/java/com/codedeck/plus/ui/transcript/MarkdownBlocks.kt b/apps/android/app/src/main/java/com/codedeck/plus/ui/transcript/MarkdownBlocks.kt new file mode 100644 index 00000000..e9aa58a7 --- /dev/null +++ b/apps/android/app/src/main/java/com/codedeck/plus/ui/transcript/MarkdownBlocks.kt @@ -0,0 +1,109 @@ +package com.codedeck.plus.ui.transcript + +/** Text up to this long is one block. */ +internal const val BLOCK_TARGET = 4_000 + +/** + * A message's markdown in blocks of about [target] characters, each a list + * item of its own: the transcript then composes, parses and lays out only the + * blocks on screen, and a streaming message re-parses only its last block. + * One huge message as a single item is parsed and measured whole on the main + * thread, which is how a long agent reply froze the app. + * + * Blocks end at a blank line outside a code fence, so each renders as it + * would within the whole. Where markdown gives no such break before twice + * [target], the block is cut at a line anyway: inside a code fence + * the fence is closed and opened again in the next block, a table's header + * is repeated, and a single line longer than the limit is cut at a space. + * Short text is returned as is. + */ +fun markdownBlocks(text: String, target: Int = BLOCK_TARGET): List { + if (text.length <= target) return listOf(text) + val hard = target * 2 + val blocks = mutableListOf() + val current = StringBuilder() + // The open fence's marker (``` or ~~~, any length) and its opening line. + var fence: String? = null + var fenceLine = "" + // A table's header and delimiter rows, while in a table. + var tableHeader: String? = null + var previous = "" + + fun flush() { + if (current.isNotBlank()) blocks += current.toString().trimEnd('\n') + current.clear() + } + fun append(line: String) { + current.append(line).append('\n') + } + + for (rawLine in text.split('\n')) { + for (line in splitLongLine(rawLine, hard)) { + val trimmed = line.trimStart() + if (fence == null) { + if (line.isBlank()) { + tableHeader = null + if (current.length >= target) { + flush() + previous = line + continue + } + } else if (current.length >= hard) { + flush() + tableHeader?.let { append(it) } + } + val marker = fenceMarker(trimmed) + if (marker != null) { + fence = marker + fenceLine = line + } else if (isTableDelimiter(trimmed) && previous.trimStart().startsWith("|")) { + tableHeader = previous + "\n" + line + } + append(line) + } else { + val open = fence!! + if (trimmed.startsWith(open) && trimmed.trimEnd().all { it == open[0] }) { + fence = null + append(line) + } else { + if (current.length >= hard) { + append(open) + flush() + append(fenceLine) + } + append(line) + } + } + previous = line + } + } + flush() + return blocks.ifEmpty { listOf(text) } +} + +/** The fence a line opens (its run of ``` or ~~~), or null. */ +private fun fenceMarker(trimmed: String): String? { + val c = trimmed.firstOrNull() ?: return null + if (c != '`' && c != '~') return null + val run = trimmed.takeWhile { it == c } + return run.takeIf { it.length >= 3 } +} + +/** A table's delimiter row: `|---|:--:|`. */ +private fun isTableDelimiter(trimmed: String): Boolean = + trimmed.startsWith("|") && trimmed.length > 2 && trimmed.all { it == '|' || it == '-' || it == ':' || it == ' ' } + +/** A line no longer than [limit], else its pieces, cut at the last space before the limit where there is one. */ +private fun splitLongLine(line: String, limit: Int): List { + if (line.length <= limit) return listOf(line) + val pieces = mutableListOf() + var rest = line + while (rest.length > limit) { + val space = rest.lastIndexOf(' ', limit) + val cut = if (space > limit / 2) space else limit + pieces += rest.substring(0, cut) + rest = rest.substring(cut).trimStart(' ') + } + if (rest.isNotEmpty()) pieces += rest + return pieces +} diff --git a/apps/android/app/src/main/java/com/codedeck/plus/ui/transcript/TranscriptList.kt b/apps/android/app/src/main/java/com/codedeck/plus/ui/transcript/TranscriptList.kt index 942f2c7d..84215b34 100644 --- a/apps/android/app/src/main/java/com/codedeck/plus/ui/transcript/TranscriptList.kt +++ b/apps/android/app/src/main/java/com/codedeck/plus/ui/transcript/TranscriptList.kt @@ -41,6 +41,7 @@ import com.codedeck.plus.ui.transcript.rows.PlanApprovalCard import com.codedeck.plus.ui.transcript.rows.QuestionCard import com.codedeck.plus.ui.transcript.rows.SyncGapRow import com.codedeck.plus.ui.transcript.rows.StatusRow +import com.codedeck.plus.ui.transcript.rows.Segment import com.codedeck.plus.ui.transcript.rows.ToolGroupRow import com.codedeck.plus.ui.transcript.rows.ToolGroupSheet import com.codedeck.plus.ui.transcript.rows.UserMessageRow @@ -121,7 +122,10 @@ private fun TranscriptListContent( visibleOutboxItems(outboxItems, machine, sessionId, displayEntries) } val showSyncGap = !contiguous && (syncState == "requested" || syncState == "syncing" || syncState == "failed") - val itemCount = displayEntries.size + visibleOutbox.size + (if (showSyncGap) 1 else 0) + (if (running) 1 else 0) + // A long message is several items, one per block, so only what is on + // screen is composed and laid out. + val rows = remember(displayEntries) { listRowsOf(displayEntries) } + val itemCount = rows.size + visibleOutbox.size + (if (showSyncGap) 1 else 0) + (if (running) 1 else 0) if (itemCount == 0) { Box(modifier.fillMaxSize(), contentAlignment = Alignment.Center) { @@ -158,7 +162,16 @@ private fun TranscriptListContent( // contentType lets the lazy list reuse a scrolled-off row's // composition only for a row of the same kind (a tool group never // gets recycled into a Markdown message and vice versa). - items(displayEntries, key = { "e${it.seq}" }, contentType = { it::class }) { entry -> + items(rows, key = { it.key }, contentType = { it.contentType }) { row -> + val entry = row.entry + if (row is ListRow.Block) { + when (entry) { + is DisplayEntry.AgentMessage -> AgentTextRow(row.text, entry.isPlan, row.segment) + is DisplayEntry.UserMessage -> UserMessageRow(row.text, row.segment) + else -> {} + } + return@items + } TranscriptRow( item = entry, machine = machine, @@ -236,6 +249,48 @@ private fun TranscriptListContent( } } +/** One item of the transcript list: an entry, or one block of a long message. */ +private sealed interface ListRow { + val entry: DisplayEntry + val key: String + val contentType: Any + + data class Entry(override val entry: DisplayEntry) : ListRow { + override val key get() = "e${entry.seq}" + override val contentType: Any get() = entry::class + } + + /** Block [index] of a message cut by `markdownBlocks`. The first keeps + * the entry's own key, so a message that grows past one block keeps its + * item (and scroll position) rather than being replaced. */ + data class Block(override val entry: DisplayEntry, val index: Int, val text: String, val segment: Segment) : ListRow { + override val key get() = if (index == 0) "e${entry.seq}" else "e${entry.seq}.$index" + override val contentType: Any get() = entry::class + } +} + +private fun listRowsOf(entries: List): List = buildList { + for (entry in entries) { + val blocks = when (entry) { + is DisplayEntry.AgentMessage -> entry.blocks + is DisplayEntry.UserMessage -> entry.blocks + else -> null + } + if (blocks == null || blocks.size == 1) { + add(ListRow.Entry(entry)) + continue + } + blocks.forEachIndexed { i, text -> + val segment = when (i) { + 0 -> Segment.First + blocks.lastIndex -> Segment.Last + else -> Segment.Middle + } + add(ListRow.Block(entry, i, text, segment)) + } + } +} + @Composable private fun TranscriptRow( item: DisplayEntry, diff --git a/apps/android/app/src/main/java/com/codedeck/plus/ui/transcript/rows/SmallRows.kt b/apps/android/app/src/main/java/com/codedeck/plus/ui/transcript/rows/SmallRows.kt index 3ee84801..e5da1d25 100644 --- a/apps/android/app/src/main/java/com/codedeck/plus/ui/transcript/rows/SmallRows.kt +++ b/apps/android/app/src/main/java/com/codedeck/plus/ui/transcript/rows/SmallRows.kt @@ -15,6 +15,15 @@ import androidx.compose.runtime.Composable import androidx.compose.ui.Alignment import androidx.compose.ui.Modifier import androidx.compose.ui.draw.clip +import androidx.compose.ui.draw.drawBehind +import androidx.compose.ui.geometry.CornerRadius +import androidx.compose.ui.geometry.Offset +import androidx.compose.ui.geometry.Size +import androidx.compose.ui.graphics.Color +import androidx.compose.ui.graphics.drawscope.Stroke +import androidx.compose.ui.graphics.drawscope.clipRect +import androidx.compose.ui.unit.Dp +import androidx.compose.foundation.layout.PaddingValues import androidx.compose.ui.text.font.FontWeight import androidx.compose.ui.text.style.TextAlign import androidx.compose.ui.unit.dp @@ -23,33 +32,88 @@ import com.codedeck.plus.ui.transcript.TranscriptMarkdown /** A message the user sent. */ @Composable -fun UserMessageRow(text: String) { +fun UserMessageRow(text: String, segment: Segment = Segment.Only) { // Your turns sit on the right, set off from the agent's full-width text. Row(Modifier.fillMaxWidth().padding(start = Tokens.Space7), horizontalArrangement = Arrangement.End) { - Column( + val bubble = if (segment == Segment.Only) { Modifier .clip(RoundedCornerShape(topStart = Tokens.RadiusXl, topEnd = Tokens.RadiusXl, bottomStart = Tokens.RadiusXl, bottomEnd = Tokens.RadiusSm)) .background(Tokens.SurfaceHover) - .padding(horizontal = Tokens.Space4, vertical = Tokens.Space3), - ) { + } else { + // A long message's blocks fill the full width, so they line up as one bubble. + Modifier.fillMaxWidth().segmentFrame(segment, Tokens.RadiusXl, fill = Tokens.SurfaceHover) + } + Column(bubble.padding(segmentInsets(segment, Tokens.Space4, Tokens.Space3))) { TranscriptMarkdown(text) } } } +/** + * Where a block of a long message sits among its blocks (see + * `markdownBlocks`): each is a list item of its own, and a framed message (a + * plan, the user's bubble) draws its part of one frame. + */ +enum class Segment { Only, First, Middle, Last } + +/** The gap the transcript list leaves between items, which a segment's frame bridges. */ +private val ItemGap = Tokens.Space2 + +/** + * A segment's part of a frame round a message cut into list items: the top + * corners on the first, the bottom ones on the last, the sides on every one, + * running through the gap to the next so the frame shows no break. + */ +private fun Modifier.segmentFrame(segment: Segment, radius: Dp, fill: Color? = null, stroke: Color? = null): Modifier = drawBehind { + val r = radius.toPx() + val gap = ItemGap.toPx() + val hasTop = segment == Segment.Only || segment == Segment.First + val hasBottom = segment == Segment.Only || segment == Segment.Last + // A round rect running past the edges this segment does not close, cut + // back to the segment plus the gap below it. + val top = if (hasTop) 0f else -(r + 1f) + val bottom = if (hasBottom) size.height else size.height + gap + r + 1f + val clipBottom = if (hasBottom) size.height else size.height + gap + clipRect(top = 0f, bottom = clipBottom) { + val corner = CornerRadius(r, r) + val at = Offset(0f, top) + val area = Size(size.width, bottom - top) + fill?.let { drawRoundRect(it, at, area, corner) } + stroke?.let { + val w = 1.dp.toPx() + drawRoundRect(it, at + Offset(w / 2, w / 2), Size(area.width - w, area.height - w), corner, style = Stroke(w)) + } + } +} + +/** A framed segment's insets: none at the edges of a cut, where the list's gap stands in for a paragraph break. */ +private fun segmentInsets(segment: Segment, horizontal: Dp, vertical: Dp): PaddingValues = PaddingValues( + start = horizontal, + end = horizontal, + top = if (segment == Segment.Only || segment == Segment.First) vertical else 0.dp, + bottom = if (segment == Segment.Only || segment == Segment.Last) vertical else 0.dp, +) + /** Agent text (markdown). `isPlan` frames it as a plan document, which stays * readable after the plan is approved: outlined in the accent rather than * filled, so it reads as a document and not as one more card. */ @Composable -fun AgentTextRow(text: String, isPlan: Boolean = false) { +fun AgentTextRow(text: String, isPlan: Boolean = false, segment: Segment = Segment.Only) { val planShape = RoundedCornerShape(Tokens.RadiusLg) + val planFrame = Tokens.Accent.copy(alpha = 0.55f) Column( Modifier .fillMaxWidth() - .let { if (isPlan) it.border(1.dp, Tokens.Accent.copy(alpha = 0.55f), planShape) else it } - .padding(if (isPlan) Tokens.Space4 else Tokens.Space1), + .let { + when { + !isPlan -> it + segment == Segment.Only -> it.border(1.dp, planFrame, planShape) + else -> it.segmentFrame(segment, Tokens.RadiusLg, stroke = planFrame) + } + } + .padding(segmentInsets(segment, if (isPlan) Tokens.Space4 else Tokens.Space1, if (isPlan) Tokens.Space4 else Tokens.Space1)), ) { - if (isPlan) { + if (isPlan && (segment == Segment.Only || segment == Segment.First)) { Text( "Plan", color = Tokens.Text, diff --git a/apps/android/app/src/test/java/com/codedeck/plus/ui/DesignSnapshotTest.kt b/apps/android/app/src/test/java/com/codedeck/plus/ui/DesignSnapshotTest.kt index a9737c8b..1d710ba9 100644 --- a/apps/android/app/src/test/java/com/codedeck/plus/ui/DesignSnapshotTest.kt +++ b/apps/android/app/src/test/java/com/codedeck/plus/ui/DesignSnapshotTest.kt @@ -29,6 +29,7 @@ import com.codedeck.plus.ui.screens.NotificationsPage import com.codedeck.plus.ui.screens.PairingBody import com.codedeck.plus.ui.screens.PluginsContent import com.codedeck.plus.ui.screens.UploadsPage +import com.codedeck.plus.ui.transcript.DisplayEntry import com.codedeck.plus.ui.screens.AccountPage import com.codedeck.plus.ui.screens.BackupPage import com.codedeck.plus.ui.screens.RestoreContent @@ -124,6 +125,25 @@ private val pages: Map Unit> = linkedMapOf( running = false, activity = null, canStopTasks = false, dispatch = {}, modifier = Modifier.background(Tokens.Bg), ) }, + "transcript_long" to { + // Long enough to be cut into blocks: the plan's frame and the user's + // bubble must still read as one each. + val steps = (1..30).joinToString("\n\n") { i -> + "### Step $i\n\nMove the reconnect logic behind the transport port, keep the backoff state in the core, " + + "and cover the new path with a deterministic test that drives the clock by hand.\n\n" + + "- Touches `crates/client-runtime/src/transport.rs`\n- Keeps the public surface unchanged" + } + val paste = (1..50).joinToString("\n\n") { "Log line $it: relay wss://relay.example.org dropped the socket after 75 s of silence." } + TranscriptList( + displayEntries = listOf( + DisplayEntry.AgentMessage(1, "# Plan: a quieter reconnect\n\n$steps", isPlan = true), + DisplayEntry.UserMessage(2, paste), + ), + outboxItems = emptyList(), machine = workstation.pubkeyHex, + sessionId = "s1", syncState = "idle", contiguous = true, respondedCards = emptySet(), planApprovalChoices = emptyMap(), + running = false, activity = null, canStopTasks = false, dispatch = {}, modifier = Modifier.background(Tokens.Bg), + ) + }, "transcript_cards" to { TranscriptList( displayEntries = DesignFixtures.waitingCards.drop(2), outboxItems = emptyList(), machine = workstation.pubkeyHex, @@ -251,6 +271,7 @@ class DesignSnapshotTest { @Test fun transcript() = paparazzi.page("transcript") @Test fun transcript_plan() = paparazzi.page("transcript_plan") @Test fun transcript_cards() = paparazzi.page("transcript_cards") + @Test fun transcript_long() = paparazzi.page("transcript_long") @Test fun new_session() = paparazzi.page("new_session") @Test fun pairing() = paparazzi.page("pairing") @Test fun settings() = paparazzi.page("settings") diff --git a/apps/android/app/src/test/java/com/codedeck/plus/ui/transcript/MarkdownBlocksTest.kt b/apps/android/app/src/test/java/com/codedeck/plus/ui/transcript/MarkdownBlocksTest.kt new file mode 100644 index 00000000..ca9762b1 --- /dev/null +++ b/apps/android/app/src/test/java/com/codedeck/plus/ui/transcript/MarkdownBlocksTest.kt @@ -0,0 +1,69 @@ +package com.codedeck.plus.ui.transcript + +import org.junit.Assert.assertEquals +import org.junit.Assert.assertTrue +import org.junit.Test + +class MarkdownBlocksTest { + private fun paragraphs(n: Int, size: Int = 300) = (1..n).joinToString("\n\n") { i -> "Paragraph $i " + "word ".repeat(size / 5) } + + @Test fun shortTextIsOneBlockAsItIs() { + val text = "# Title\n\nSome *text*.\n" + assertEquals(listOf(text), markdownBlocks(text)) + } + + @Test fun longTextBreaksAtBlankLinesAndLosesNothing() { + val text = paragraphs(60) + val blocks = markdownBlocks(text, target = 1_000) + assertTrue(blocks.size > 1) + assertTrue(blocks.all { it.length <= 2_000 }) + // Each block is whole paragraphs, and together they are the text. + assertTrue(blocks.all { it.startsWith("Paragraph ") }) + assertEquals(text, blocks.joinToString("\n\n")) + } + + @Test fun aCodeFenceIsNeverLeftOpenAcrossBlocks() { + val code = (1..400).joinToString("\n") { "let x$it = $it;" } + val text = "Intro\n\n```rust\n$code\n```\n\nAfter" + val blocks = markdownBlocks(text, target = 1_000) + assertTrue(blocks.size > 2) + for (block in blocks.filter { it.contains("let x") }) { + assertTrue(block, block.trimStart().startsWith("```rust") || block.startsWith("Intro")) + assertEquals(block, 0, block.lines().count { it.trimStart().startsWith("```") } % 2) + } + // Every line of code is still there, in order. + val shown = blocks.flatMap { it.lines() }.filter { it.startsWith("let x") } + assertEquals(code.lines(), shown) + } + + @Test fun aBlankLineInsideAFenceIsNotABreak() { + val text = "```\n" + "a\n\n".repeat(600) + "```" + val blocks = markdownBlocks(text, target = 500) + for (block in blocks) assertEquals(block, 0, block.lines().count { it.trim() == "```" } % 2) + } + + @Test fun aLongTableRepeatsItsHeader() { + val rows = (1..300).joinToString("\n") { "| row $it | value $it |" } + val text = "| Name | Value |\n|---|---|\n$rows" + val blocks = markdownBlocks(text, target = 1_000) + assertTrue(blocks.size > 1) + for (block in blocks) assertTrue(block, block.startsWith("| Name | Value |\n|---|---|\n")) + } + + @Test fun aSingleHugeLineIsCutAtSpaces() { + val text = "word ".repeat(10_000).trim() + val blocks = markdownBlocks(text, target = 1_000) + assertTrue(blocks.size > 1) + assertTrue(blocks.all { it.length <= 2_000 }) + assertEquals(text.split(' ').size, blocks.joinToString(" ").split(Regex("\\s+")).size) + } + + @Test fun aMegabyteSplitsQuickly() { + val text = paragraphs(4_000) + val start = System.nanoTime() + val blocks = markdownBlocks(text) + val ms = (System.nanoTime() - start) / 1_000_000 + assertTrue("${blocks.size} blocks", blocks.size > 200) + assertTrue("took $ms ms", ms < 1_000) + } +} diff --git a/apps/android/app/src/test/snapshots/images/com.codedeck.plus.ui_DesignSnapshotTest_transcript_long.png b/apps/android/app/src/test/snapshots/images/com.codedeck.plus.ui_DesignSnapshotTest_transcript_long.png new file mode 100644 index 00000000..d033443e Binary files /dev/null and b/apps/android/app/src/test/snapshots/images/com.codedeck.plus.ui_DesignSnapshotTest_transcript_long.png differ