feat: ByteClaw 0.2.0 – Provider-Wahl ab erster Nachricht, Markdown/Code-Blöcke mit Kopieren, Nachrichten-Aktionen, Spracheingabe, Session-Suche/Umbenennen, Verbindungsstatus, Benachrichtigungen

This commit is contained in:
OpenClaw Dev 2026-10-26 13:46:00 +01:00
parent 52b6b68117
commit ec797335a6
26 changed files with 560 additions and 30 deletions

View file

@ -31,8 +31,8 @@ android {
applicationId = "dev.byteclaw.client"
minSdk = 26
targetSdk = 35
versionCode = 1
versionName = "0.1.0"
versionCode = 2
versionName = "0.2.0"
}
signingConfigs {

View file

@ -1,6 +1,15 @@
<?xml version="1.0" encoding="utf-8"?>
<manifest xmlns:android="http://schemas.android.com/apk/res/android">
<uses-permission android:name="android.permission.INTERNET" />
<uses-permission android:name="android.permission.POST_NOTIFICATIONS" />
<queries>
<intent>
<action android:name="android.speech.RecognizerIntent" />
</intent>
<intent>
<action android:name="android.speech.action.RECOGNIZE_SPEECH" />
</intent>
</queries>
<application
android:allowBackup="false"
android:label="@string/app_name"

View file

@ -1,6 +1,12 @@
package dev.byteclaw.client
import android.Manifest
import android.content.Intent
import android.os.Build
import android.os.Bundle
import android.speech.RecognizerIntent
import androidx.activity.result.contract.ActivityResultContracts
import dev.byteclaw.client.ui.Notifier
import androidx.activity.ComponentActivity
import androidx.activity.compose.setContent
import androidx.activity.enableEdgeToEdge
@ -22,9 +28,33 @@ import dev.byteclaw.client.ui.SessionsScreen
import dev.byteclaw.client.ui.SettingsScreen
class MainActivity : ComponentActivity() {
private var voiceCallback: ((String) -> Unit)? = null
private val voice = registerForActivityResult(ActivityResultContracts.StartActivityForResult()) { r ->
val text = r.data?.getStringArrayListExtra(RecognizerIntent.EXTRA_RESULTS)?.firstOrNull()
if (!text.isNullOrBlank()) voiceCallback?.invoke(text)
voiceCallback = null
}
private val notifPermission = registerForActivityResult(ActivityResultContracts.RequestPermission()) { }
/** Voice input via the system speech recognizer (no RECORD_AUDIO permission needed in the app). */
private fun startVoice(onText: (String) -> Unit) {
val i = Intent(RecognizerIntent.ACTION_RECOGNIZE_SPEECH)
.putExtra(RecognizerIntent.EXTRA_LANGUAGE_MODEL, RecognizerIntent.LANGUAGE_MODEL_FREE_FORM)
.putExtra(RecognizerIntent.EXTRA_PROMPT, "Sprich deine Nachricht…")
voiceCallback = onText
runCatching { voice.launch(i) }.onFailure {
voiceCallback = null
android.widget.Toast.makeText(this, "Keine Spracherkennung auf diesem Gerät", android.widget.Toast.LENGTH_SHORT).show()
}
}
override fun onStart() { super.onStart(); Notifier.foreground = true }
override fun onStop() { Notifier.foreground = false; super.onStop() }
override fun onCreate(savedInstanceState: Bundle?) {
super.onCreate(savedInstanceState)
enableEdgeToEdge()
if (Build.VERSION.SDK_INT >= 33 && savedInstanceState == null) notifPermission.launch(Manifest.permission.POST_NOTIFICATIONS)
setContent {
ByteClawTheme(dark = isSystemInDarkTheme()) {
Surface {
@ -38,13 +68,15 @@ class MainActivity : ComponentActivity() {
state, onSend = vm::send, onNewChat = vm::newChat, onDismissError = vm::dismissError,
onSessions = { nav.navigate("sessions") }, onSettings = { nav.navigate("settings") }, onStop = vm::cancel,
onSessionProvider = { vm.setSessionProvider(it) },
onRegenerate = vm::regenerate, onHide = vm::hideLine,
onVoice = { cb -> startVoice(cb) },
)
}
composable("sessions") {
SessionsScreen(
state, onRefresh = { vm.refreshSessions() },
onOpen = { id -> vm.openSession(id); nav.popBackStack() },
onDelete = { id -> vm.deleteSession(id) }, onBack = { nav.popBackStack() },
onDelete = { id -> vm.deleteSession(id) }, onRename = { id, t -> vm.renameSession(id, t) }, onBack = { nav.popBackStack() },
)
}
composable("settings") {

View file

@ -53,6 +53,11 @@ class ByteClawApi(private val settings: ServerSettings) {
call(Request.Builder().url(url("/api/sessions/$sessionId")).delete().build())
}
suspend fun renameSession(sessionId: String, title: String) {
val body = json.encodeToString(RenameRequest.serializer(), RenameRequest(title)).toRequestBody(JSON)
call(Request.Builder().url(url("/api/sessions/$sessionId")).patch(body).build())
}
suspend fun providers(): ProvidersResponse =
json.decodeFromString(call(Request.Builder().url(url("/api/providers")).build()))
@ -81,11 +86,15 @@ class ByteClawApi(private val settings: ServerSettings) {
/**
* Opens a WebSocket, sends one message and emits streamed events until "done"/"error".
*/
fun stream(sessionId: String?, text: String): Flow<AgentEvent> = callbackFlow {
fun stream(sessionId: String?, text: String, provider: ActiveProvider? = null): Flow<AgentEvent> = callbackFlow {
val wsUrl = url("/api/ws")
val ws = http.newWebSocket(Request.Builder().url(wsUrl).build(), object : WebSocketListener() {
override fun onOpen(webSocket: WebSocket, response: Response) {
webSocket.send(json.encodeToString(OutgoingMessage.serializer(), OutgoingMessage(sessionId = sessionId.orEmpty(), text = text)))
webSocket.send(json.encodeToString(OutgoingMessage.serializer(), OutgoingMessage(
sessionId = sessionId.orEmpty(), text = text,
provider = provider?.provider?.takeIf { sessionId == null && it.isNotBlank() },
model = provider?.model?.takeIf { sessionId == null && provider.provider.isNotBlank() && it.isNotBlank() },
)))
}
override fun onMessage(webSocket: WebSocket, text: String) {

View file

@ -41,6 +41,9 @@ data class OutgoingMessage(
val type: String = "message",
@SerialName("session_id") val sessionId: String = "",
val text: String,
/** Only for a new session: provider/model the first answer should already use. */
val provider: String? = null,
val model: String? = null,
)
/** GET /api/info: which LLM provider preset/model the server uses (no secrets). */
@ -86,3 +89,6 @@ object ProviderPresets {
)
fun label(name: String) = names[name] ?: name
}
@Serializable
data class RenameRequest(val title: String)

View file

@ -37,6 +37,8 @@ data class UiState(
val provider: ProviderUi = ProviderUi(),
/** Provider override of the current session (null = global default). */
val sessionProvider: ActiveProvider? = null,
/** null = unknown, true/false = last health check result. */
val online: Boolean? = null,
) {
/** What the current chat effectively uses: session override, else the global active provider. */
val effectiveProvider: ActiveProvider? get() = sessionProvider ?: provider.active
@ -51,6 +53,21 @@ class ChatViewModel(app: Application) : AndroidViewModel(app) {
private fun api() = ByteClawApi(_state.value.settings)
init {
// Lightweight connection monitor: health check every 15 s while the ViewModel lives.
viewModelScope.launch {
while (true) {
if (_state.value.settings.isConfigured) {
val ok = runCatching { api().health() }.getOrDefault(false)
val was = _state.value.online
_state.update { it.copy(online = ok) }
if (ok && was == false && _state.value.provider.providers.isEmpty()) loadProviders()
}
kotlinx.coroutines.delay(if (_state.value.online == false) 5_000 else 15_000)
}
}
}
fun saveSettings(s: ServerSettings) {
store.save(s)
_state.update { it.copy(settings = store.load(), error = null) }
@ -112,23 +129,23 @@ class ChatViewModel(app: Application) : AndroidViewModel(app) {
val t = text.trim()
if (t.isEmpty() || _state.value.busy) return
_state.update { it.copy(lines = it.lines + ChatLine("user", t) + ChatLine("assistant", ""), busy = true, error = null) }
// For a new chat the picked provider travels with the first message, so the server applies it before answering.
val pending = _state.value.sessionProvider.takeIf { _state.value.sessionId == null }
val started = System.currentTimeMillis()
job = viewModelScope.launch {
api().stream(_state.value.sessionId, t).collect { ev ->
api().stream(_state.value.sessionId, t, pending).collect { ev ->
when (ev.type) {
"session" -> {
val pending = _state.value.sessionProvider.takeIf { _state.value.sessionId == null }
_state.update { it.copy(sessionId = ev.sessionId) }
// The first answer already runs with the global provider; the pick applies from the next message on.
pending?.let { p -> launch { runCatching { api().setSessionProvider(ev.sessionId, p.provider, p.model) } } }
}
"session" -> _state.update { it.copy(sessionId = ev.sessionId, online = true) }
"delta" -> appendToLast(ev.text)
"tool_call" -> _state.update {
// keep a fresh assistant bubble after the tool line for the follow-up answer
val base = if (it.lines.lastOrNull()?.let { l -> l.role == "assistant" && l.text.isEmpty() } == true) it.lines.dropLast(1) else it.lines
it.copy(lines = base + ChatLine("tool", ev.args, ev.tool) + ChatLine("assistant", ""))
}
"done" -> _state.update { s ->
s.copy(busy = false, lines = s.lines.filterNot { it.role == "assistant" && it.text.isEmpty() })
"done" -> {
_state.update { s -> s.copy(busy = false, lines = s.lines.filterNot { it.role == "assistant" && it.text.isEmpty() }) }
Notifier.replyFinished(getApplication(), ev.text.ifBlank { _state.value.lines.lastOrNull { it.role == "assistant" }?.text.orEmpty() },
System.currentTimeMillis() - started)
}
"cancelled" -> markCancelled()
"error" -> _state.update { s ->
@ -140,6 +157,23 @@ class ChatViewModel(app: Application) : AndroidViewModel(app) {
}
}
/** Sends the user question that led to the answer at [index] again (new answer is appended). */
fun regenerate(index: Int) {
val q = _state.value.lines.take(index + 1).lastOrNull { it.role == "user" }?.text ?: return
send(q)
}
/** Removes a line from the local view only (server history stays intact). */
fun hideLine(index: Int) = _state.update { s -> s.copy(lines = s.lines.filterIndexed { i, _ -> i != index }) }
fun renameSession(id: String, title: String) = viewModelScope.launch {
val t = title.trim()
if (t.isEmpty()) return@launch
runCatching { api().renameSession(id, t) }
.onSuccess { _state.update { s -> s.copy(sessions = s.sessions.map { if (it.id == id) it.copy(title = t) else it }) } }
.onFailure { e -> _state.update { it.copy(error = e.message) } }
}
/** Stop button: cancel on the server (partial answer is saved there), fall back to closing the stream. */
fun cancel() {
if (!_state.value.busy) return

View file

@ -0,0 +1,146 @@
package dev.byteclaw.client.ui
import androidx.compose.foundation.background
import androidx.compose.foundation.horizontalScroll
import androidx.compose.foundation.layout.*
import androidx.compose.foundation.rememberScrollState
import androidx.compose.foundation.shape.RoundedCornerShape
import androidx.compose.material.icons.Icons
import androidx.compose.material.icons.filled.ContentCopy
import androidx.compose.material3.*
import androidx.compose.runtime.Composable
import androidx.compose.ui.Alignment
import androidx.compose.ui.Modifier
import androidx.compose.ui.platform.LocalClipboardManager
import androidx.compose.ui.text.AnnotatedString
import androidx.compose.ui.text.SpanStyle
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.withStyle
import androidx.compose.ui.unit.dp
/** Minimal Markdown model: enough for typical LLM answers (no tables/links/images). */
sealed interface MdBlock {
data class Paragraph(val text: String) : MdBlock
data class Heading(val level: Int, val text: String) : MdBlock
data class Bullet(val marker: String, val text: String) : MdBlock
data class Code(val lang: String, val code: String) : MdBlock
data class Quote(val text: String) : MdBlock
}
object Markdown {
private val heading = Regex("^(#{1,6})\\s+(.*)$")
private val bullet = Regex("^\\s*([-*+]|\\d+[.)])\\s+(.*)$")
/** Splits text into blocks. An unclosed ``` fence (still streaming) is rendered as code anyway. */
fun parse(src: String): List<MdBlock> {
val out = mutableListOf<MdBlock>()
val para = StringBuilder()
fun flush() { if (para.isNotBlank()) out += MdBlock.Paragraph(para.toString().trim()); para.clear() }
val lines = src.replace("\r\n", "\n").split('\n')
var i = 0
while (i < lines.size) {
val l = lines[i]
val t = l.trimStart()
when {
t.startsWith("```") -> {
flush()
val lang = t.removePrefix("```").trim()
val code = StringBuilder()
i++
while (i < lines.size && !lines[i].trimStart().startsWith("```")) {
if (code.isNotEmpty()) code.append('\n')
code.append(lines[i]); i++
}
out += MdBlock.Code(lang, code.toString())
}
l.isBlank() -> flush()
heading.matches(t) -> { flush(); val m = heading.find(t)!!; out += MdBlock.Heading(m.groupValues[1].length, m.groupValues[2].trim()) }
bullet.matches(l) -> {
flush(); val m = bullet.find(l)!!
val mk = m.groupValues[1].let { if (it[0].isDigit()) it else "•" }
out += MdBlock.Bullet(mk, m.groupValues[2])
}
t.startsWith(">") -> { flush(); out += MdBlock.Quote(t.removePrefix(">").trim()) }
else -> { if (para.isNotEmpty()) para.append('\n'); para.append(l) }
}
i++
}
flush()
return out
}
/** Inline: **bold**, *italic* / _italic_, `code`. Unmatched markers stay literal. */
fun inline(text: String, codeBg: androidx.compose.ui.graphics.Color): AnnotatedString = buildAnnotatedString {
var i = 0
while (i < text.length) {
fun close(marker: String): Int = text.indexOf(marker, i + marker.length + 1)
when {
text.startsWith("`", i) && close("`") > 0 -> {
val e = close("`")
withStyle(SpanStyle(fontFamily = FontFamily.Monospace, background = codeBg)) { append(text.substring(i + 1, e)) }
i = e + 1
}
text.startsWith("**", i) && close("**") > i + 2 -> {
val e = close("**")
withStyle(SpanStyle(fontWeight = FontWeight.Bold)) { append(text.substring(i + 2, e)) }
i = e + 2
}
(text[i] == '*' || text[i] == '_') && i + 1 < text.length && !text[i + 1].isWhitespace() &&
(i == 0 || !text[i - 1].isLetterOrDigit()) && close(text[i].toString()) > i + 1 -> {
val e = close(text[i].toString())
withStyle(SpanStyle(fontStyle = FontStyle.Italic)) { append(text.substring(i + 1, e)) }
i = e + 1
}
else -> { append(text[i]); i++ }
}
}
}
}
@Composable
fun MarkdownText(text: String, modifier: Modifier = Modifier) {
val codeBg = MaterialTheme.colorScheme.surface.copy(alpha = 0.6f)
Column(modifier, verticalArrangement = Arrangement.spacedBy(6.dp)) {
Markdown.parse(text).forEach { b ->
when (b) {
is MdBlock.Paragraph -> Text(Markdown.inline(b.text, codeBg))
is MdBlock.Heading -> Text(
Markdown.inline(b.text, codeBg), fontWeight = FontWeight.Bold,
style = if (b.level <= 2) MaterialTheme.typography.titleMedium else MaterialTheme.typography.titleSmall,
)
is MdBlock.Bullet -> Row {
Text(b.marker, Modifier.width(22.dp))
Text(Markdown.inline(b.text, codeBg))
}
is MdBlock.Quote -> Text(
Markdown.inline(b.text, codeBg), fontStyle = FontStyle.Italic,
color = MaterialTheme.colorScheme.onSurfaceVariant, modifier = Modifier.padding(start = 8.dp),
)
is MdBlock.Code -> CodeBlock(b)
}
}
}
}
@Composable
private fun CodeBlock(b: MdBlock.Code) {
val clip = LocalClipboardManager.current
Column(
Modifier.fillMaxWidth().background(MaterialTheme.colorScheme.surface, RoundedCornerShape(8.dp)).padding(8.dp),
) {
Row(verticalAlignment = Alignment.CenterVertically) {
Text(b.lang.ifBlank { "code" }, style = MaterialTheme.typography.labelSmall, color = MaterialTheme.colorScheme.outline,
modifier = Modifier.weight(1f))
IconButton(onClick = { clip.setText(AnnotatedString(b.code)) }, modifier = Modifier.size(28.dp)) {
Icon(Icons.Filled.ContentCopy, "Code kopieren", Modifier.size(16.dp))
}
}
Text(
b.code, fontFamily = FontFamily.Monospace, style = MaterialTheme.typography.bodySmall,
softWrap = false, modifier = Modifier.horizontalScroll(rememberScrollState()),
)
}
}

View file

@ -0,0 +1,45 @@
package dev.byteclaw.client.ui
import android.Manifest
import android.app.NotificationChannel
import android.app.NotificationManager
import android.app.PendingIntent
import android.content.Context
import android.content.Intent
import android.content.pm.PackageManager
import android.os.Build
import androidx.core.app.NotificationCompat
import androidx.core.app.NotificationManagerCompat
import androidx.core.content.ContextCompat
import dev.byteclaw.client.MainActivity
import dev.byteclaw.client.R
/** Posts a notification when an answer finishes while the app is in the background. */
object Notifier {
@Volatile var foreground = false
private const val CHANNEL = "replies"
fun replyFinished(ctx: Context, text: String, durationMs: Long) {
if (foreground || durationMs < 3_000) return
if (Build.VERSION.SDK_INT >= 33 &&
ContextCompat.checkSelfPermission(ctx, Manifest.permission.POST_NOTIFICATIONS) != PackageManager.PERMISSION_GRANTED) return
val nm = ctx.getSystemService(NotificationManager::class.java)
if (nm.getNotificationChannel(CHANNEL) == null) {
nm.createNotificationChannel(NotificationChannel(CHANNEL, "Antworten", NotificationManager.IMPORTANCE_DEFAULT))
}
val open = PendingIntent.getActivity(
ctx, 0, Intent(ctx, MainActivity::class.java).addFlags(Intent.FLAG_ACTIVITY_SINGLE_TOP),
PendingIntent.FLAG_IMMUTABLE or PendingIntent.FLAG_UPDATE_CURRENT,
)
val preview = text.trim().take(300).ifEmpty { "Antwort ist fertig." }
val n = NotificationCompat.Builder(ctx, CHANNEL)
.setSmallIcon(R.drawable.ic_launcher_monochrome)
.setContentTitle("ByteClaw hat geantwortet")
.setContentText(preview)
.setStyle(NotificationCompat.BigTextStyle().bigText(preview))
.setContentIntent(open)
.setAutoCancel(true)
.build()
runCatching { NotificationManagerCompat.from(ctx).notify(1, n) }
}
}

View file

@ -1,7 +1,21 @@
package dev.byteclaw.client.ui
import androidx.compose.foundation.background
import androidx.compose.foundation.ExperimentalFoundationApi
import androidx.compose.foundation.clickable
import androidx.compose.foundation.combinedClickable
import androidx.compose.foundation.shape.CircleShape
import androidx.compose.material.icons.filled.ContentCopy
import androidx.compose.material.icons.filled.Edit
import androidx.compose.material.icons.filled.Mic
import androidx.compose.material.icons.filled.Refresh
import androidx.compose.material.icons.filled.Search
import androidx.compose.material.icons.filled.Share
import androidx.compose.material.icons.filled.VisibilityOff
import androidx.compose.ui.graphics.Color
import androidx.compose.ui.platform.LocalClipboardManager
import androidx.compose.ui.platform.LocalContext
import androidx.compose.ui.text.AnnotatedString
import androidx.compose.foundation.layout.*
import androidx.compose.foundation.lazy.LazyColumn
import androidx.compose.foundation.lazy.items
@ -42,6 +56,9 @@ fun ChatScreen(
onSettings: () -> Unit,
onStop: () -> Unit = {},
onSessionProvider: (String) -> Unit = {},
onRegenerate: (Int) -> Unit = {},
onHide: (Int) -> Unit = {},
onVoice: (((String) -> Unit) -> Unit)? = null,
initialInput: String = "",
initialMenuOpen: Boolean = false,
) {
@ -55,7 +72,10 @@ fun ChatScreen(
TopAppBar(
title = {
Column {
Text("ByteClaw")
Row(verticalAlignment = Alignment.CenterVertically) {
Text("ByteClaw")
ConnectionDot(state.online)
}
SessionProviderChip(state, onSessionProvider, initialMenuOpen)
}
},
@ -80,7 +100,9 @@ fun ChatScreen(
) { Text(it, Modifier.padding(12.dp)) }
}
LazyColumn(Modifier.weight(1f).fillMaxWidth(), state = listState, contentPadding = PaddingValues(12.dp)) {
itemsIndexed(state.lines) { _, line -> Bubble(line) }
itemsIndexed(state.lines) { i, line ->
Bubble(line, actionsEnabled = !state.busy, onRegenerate = { onRegenerate(i) }, onHide = { onHide(i) })
}
if (state.busy) item { LinearProgressIndicator(Modifier.fillMaxWidth().padding(top = 8.dp)) }
}
Row(Modifier.fillMaxWidth().padding(8.dp), verticalAlignment = Alignment.CenterVertically) {
@ -97,6 +119,10 @@ fun ChatScreen(
contentColor = MaterialTheme.colorScheme.onErrorContainer,
),
) { Icon(Icons.Filled.Stop, "Antwort stoppen") }
} else if (input.isBlank() && onVoice != null) {
IconButton(onClick = { onVoice { spoken -> input = spoken } }, enabled = state.settings.isConfigured) {
Icon(Icons.Filled.Mic, "Spracheingabe")
}
} else {
IconButton(
onClick = { onSend(input); input = "" },
@ -109,7 +135,23 @@ fun ChatScreen(
}
@Composable
private fun Bubble(line: ChatLine) {
private fun ConnectionDot(online: Boolean?) {
val color = when (online) {
true -> Color(0xFF2E7D32)
false -> MaterialTheme.colorScheme.error
null -> return
}
Spacer(Modifier.width(8.dp))
Box(Modifier.size(8.dp).background(color, CircleShape))
if (online == false) {
Spacer(Modifier.width(6.dp))
Text("offline – verbinde neu…", style = MaterialTheme.typography.labelSmall, color = color)
}
}
@OptIn(ExperimentalFoundationApi::class)
@Composable
private fun Bubble(line: ChatLine, actionsEnabled: Boolean = true, onRegenerate: () -> Unit = {}, onHide: () -> Unit = {}) {
val isUser = line.role == "user"
if (line.role == "tool") {
Row(Modifier.fillMaxWidth().padding(vertical = 4.dp), verticalAlignment = Alignment.CenterVertically) {
@ -120,15 +162,36 @@ private fun Bubble(line: ChatLine) {
}
return
}
var menu by remember { mutableStateOf(false) }
val clip = LocalClipboardManager.current
val ctx = LocalContext.current
Column(Modifier.fillMaxWidth().padding(vertical = 4.dp), horizontalAlignment = if (isUser) Alignment.End else Alignment.Start) {
Text(
line.text.ifEmpty { "…" },
Modifier.widthIn(max = 320.dp)
Box {
val mod = Modifier.widthIn(max = 320.dp)
.background(
if (isUser) MaterialTheme.colorScheme.primaryContainer else MaterialTheme.colorScheme.surfaceVariant,
RoundedCornerShape(14.dp),
).padding(horizontal = 12.dp, vertical = 8.dp),
)
)
.combinedClickable(onClick = {}, onLongClick = { if (line.text.isNotEmpty()) menu = true })
.padding(horizontal = 12.dp, vertical = 8.dp)
if (isUser || line.text.isEmpty()) Text(line.text.ifEmpty { "…" }, mod) else MarkdownText(line.text, mod)
DropdownMenu(expanded = menu, onDismissRequest = { menu = false }) {
DropdownMenuItem(text = { Text("Kopieren") }, leadingIcon = { Icon(Icons.Filled.ContentCopy, null) },
onClick = { clip.setText(AnnotatedString(line.text)); menu = false })
DropdownMenuItem(text = { Text("Teilen") }, leadingIcon = { Icon(Icons.Filled.Share, null) }, onClick = {
menu = false
val send = android.content.Intent(android.content.Intent.ACTION_SEND).setType("text/plain")
.putExtra(android.content.Intent.EXTRA_TEXT, line.text)
runCatching { ctx.startActivity(android.content.Intent.createChooser(send, null)) }
})
if (!isUser) {
DropdownMenuItem(text = { Text("Neu generieren") }, leadingIcon = { Icon(Icons.Filled.Refresh, null) },
enabled = actionsEnabled, onClick = { menu = false; onRegenerate() })
}
DropdownMenuItem(text = { Text("Ausblenden") }, leadingIcon = { Icon(Icons.Filled.VisibilityOff, null) },
enabled = actionsEnabled, onClick = { menu = false; onHide() })
}
}
if (line.cancelled) {
Text("⏹ abgebrochen", style = MaterialTheme.typography.labelSmall, color = MaterialTheme.colorScheme.outline,
modifier = Modifier.padding(start = 8.dp, top = 2.dp))
@ -144,26 +207,55 @@ fun SessionsScreen(
onOpen: (String) -> Unit,
onDelete: (String) -> Unit,
onBack: () -> Unit,
onRename: (String, String) -> Unit = { _, _ -> },
initialQuery: String = "",
) {
LaunchedEffect(Unit) { onRefresh() }
var query by remember { mutableStateOf(initialQuery) }
var renaming by remember { mutableStateOf<dev.byteclaw.client.data.Session?>(null) }
val shown = filterSessions(state.sessions, query)
renaming?.let { r ->
var title by remember(r.id) { mutableStateOf(r.title) }
AlertDialog(
onDismissRequest = { renaming = null },
title = { Text("Session umbenennen") },
text = { OutlinedTextField(title, { title = it }, singleLine = true, modifier = Modifier.fillMaxWidth()) },
confirmButton = { TextButton(enabled = title.isNotBlank(), onClick = { onRename(r.id, title); renaming = null }) { Text("Speichern") } },
dismissButton = { TextButton(onClick = { renaming = null }) { Text("Abbrechen") } },
)
}
Scaffold(topBar = {
TopAppBar(title = { Text("Sessions") },
navigationIcon = { IconButton(onClick = onBack) { Icon(Icons.AutoMirrored.Filled.ArrowBack, "Zurück") } })
}) { pad ->
if (state.sessions.isEmpty()) {
Box(Modifier.padding(pad).fillMaxSize(), contentAlignment = Alignment.Center) { Text(state.error ?: "Noch keine Sessions") }
Column(Modifier.padding(pad).fillMaxSize()) {
OutlinedTextField(
query, { query = it }, Modifier.fillMaxWidth().padding(horizontal = 12.dp, vertical = 4.dp),
placeholder = { Text("Sessions durchsuchen") }, singleLine = true,
leadingIcon = { Icon(Icons.Filled.Search, null) },
)
if (shown.isEmpty()) {
Box(Modifier.fillMaxSize(), contentAlignment = Alignment.Center) {
Text(state.error ?: if (query.isBlank()) "Noch keine Sessions" else "Keine Treffer")
}
}
LazyColumn(Modifier.padding(pad).fillMaxSize()) {
items(state.sessions, key = { it.id }) { s ->
LazyColumn(Modifier.fillMaxSize()) {
items(shown, key = { it.id }) { s ->
ListItem(
headlineContent = { Text(s.title.ifBlank { "(ohne Titel)" }, maxLines = 1, overflow = TextOverflow.Ellipsis) },
supportingContent = { Text(s.updatedAt.take(16).replace('T', ' ') + " UTC") },
trailingContent = { IconButton(onClick = { onDelete(s.id) }) { Icon(Icons.Filled.Delete, "Löschen") } },
trailingContent = {
Row {
IconButton(onClick = { renaming = s }) { Icon(Icons.Filled.Edit, "Umbenennen") }
IconButton(onClick = { onDelete(s.id) }) { Icon(Icons.Filled.Delete, "Löschen") }
}
},
modifier = Modifier.clickable { onOpen(s.id) },
)
HorizontalDivider()
}
}
}
}
}
@ -311,3 +403,10 @@ private fun SessionProviderChip(state: UiState, onPick: (String) -> Unit, initia
}
}
}
/** Case-insensitive title search; all words must match. */
fun filterSessions(list: List<dev.byteclaw.client.data.Session>, query: String): List<dev.byteclaw.client.data.Session> {
val words = query.trim().lowercase().split(Regex("\\s+")).filter { it.isNotEmpty() }
if (words.isEmpty()) return list
return list.filter { s -> val t = s.title.lowercase(); words.all { it in t } }
}

View file

@ -0,0 +1,52 @@
package dev.byteclaw.client
import androidx.compose.ui.graphics.Color
import dev.byteclaw.client.data.OutgoingMessage
import dev.byteclaw.client.data.Session
import dev.byteclaw.client.ui.Markdown
import dev.byteclaw.client.ui.MdBlock
import dev.byteclaw.client.ui.filterSessions
import kotlinx.serialization.json.Json
import org.junit.Assert.assertEquals
import org.junit.Assert.assertFalse
import org.junit.Assert.assertTrue
import org.junit.Test
class MarkdownTest {
@Test fun parsesBlocks() {
val b = Markdown.parse("# Titel\nText **fett**\n\n- a\n2. b\n> zitat\n```kotlin\nval x = 1\n\nprintln(x)\n```\nEnde")
assertEquals(MdBlock.Heading(1, "Titel"), b[0])
assertEquals(MdBlock.Paragraph("Text **fett**"), b[1])
assertEquals(MdBlock.Bullet("•", "a"), b[2])
assertEquals(MdBlock.Bullet("2.", "b"), b[3])
assertEquals(MdBlock.Quote("zitat"), b[4])
assertEquals(MdBlock.Code("kotlin", "val x = 1\n\nprintln(x)"), b[5])
assertEquals(MdBlock.Paragraph("Ende"), b[6])
}
@Test fun unclosedFenceWhileStreaming() {
val b = Markdown.parse("Hier:\n```py\nprint(1)")
assertEquals(MdBlock.Code("py", "print(1)"), b.last())
}
@Test fun inlineStylesStripMarkers() {
val a = Markdown.inline("a **b** *c* `d` x*y 2*3 **offen", Color.Gray)
assertEquals("a b c d x*y 2*3 **offen", a.text)
assertEquals(3, a.spanStyles.size)
}
@Test fun sessionSearch() {
val l = listOf(Session("1", "Go Datei lesen"), Session("2", "Einkauf"), Session("3", "go generics"))
assertEquals(listOf("1", "3"), filterSessions(l, " GO ").map { it.id })
assertEquals(listOf("1"), filterSessions(l, "go lesen").map { it.id })
assertEquals(3, filterSessions(l, "").size)
}
@Test fun firstMessageCarriesProvider() {
val j = Json { explicitNulls = false }
val s = j.encodeToString(OutgoingMessage.serializer(), OutgoingMessage(text = "hi", provider = "dahl", model = "m"))
assertTrue(s.contains("\"provider\":\"dahl\"") && s.contains("\"model\":\"m\""))
val s2 = j.encodeToString(OutgoingMessage.serializer(), OutgoingMessage(sessionId = "x", text = "hi"))
assertFalse(s2.contains("provider"))
}
}

View file

@ -80,6 +80,33 @@ class PreviewScreenshots(private val dark: Boolean) {
)
}
@Test fun chatMarkdown() = snap("chat_markdown") {
ChatScreen(
state = UiState(
settings = settings, sessionId = "demo", online = true,
lines = listOf(
ChatLine("user", "[DEMO] Wie lese ich in Go eine Datei zeilenweise?"),
ChatLine("assistant", "## Zeilenweise lesen\nAm einfachsten mit **bufio.Scanner**:\n\n```go\nf, _ := os.Open(\"notes.txt\")\ndefer f.Close()\ns := bufio.NewScanner(f)\nfor s.Scan() {\n fmt.Println(s.Text())\n}\n```\n- prüfe danach `s.Err()`\n- für sehr lange Zeilen *Buffer* vergrößern"),
),
),
onSend = {}, onNewChat = {}, onDismissError = {}, onSessions = {}, onSettings = {},
)
}
@Test fun sessionsSearch() = snap("sessions_search") {
SessionsScreen(
state = UiState(
settings = settings,
sessions = listOf(
Session("1", "[DEMO] Go-Datei zeilenweise lesen", "2026-10-25T03:32:00Z"),
Session("2", "[DEMO] Einkaufsliste für das Wochenende", "2026-10-24T18:05:00Z"),
Session("3", "[DEMO] Go Generics Beispiel", "2026-10-23T09:41:00Z"),
),
),
onRefresh = {}, onOpen = {}, onDelete = {}, onBack = {}, initialQuery = "go",
)
}
@Test fun sessions() = snap("sessions") {
SessionsScreen(
state = UiState(

Binary file not shown.

Before

Width:  |  Height:  |  Size: 42 KiB

After

Width:  |  Height:  |  Size: 45 KiB

Before After
Before After

Binary file not shown.

Before

Width:  |  Height:  |  Size: 42 KiB

After

Width:  |  Height:  |  Size: 45 KiB

Before After
Before After