Phase 1 app: session list, session screen, spawn, QR enrollment over pinned TLS

Compose app mirroring local-updater's stack (single :androidApp module,
pinned CA, HttpURLConnection transport) plus what this app needs on top:
a bearer token sealed with an Android Keystore AES-GCM key, an
aiapp://enroll intent filter so scanning the server's terminal QR with
the stock camera enrolls the phone with no QR library, an SSE client
that resumes by transcript cursor, and a transcript renderer folding the
common event model into user bubbles, streaming text, collapsible tool
cards, and answerable question cards.

Verified on the tdep emulator against the real server: enrollment deep
link, list, spawn, streamed echo turn, question answer round trip, tool
card expansion, adjustResize keyboard behavior. Build is warning-clean
(compose.* accessors replaced with direct dependencies).

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_017xn8nHw1tw1R6PtiY1eEtw
This commit is contained in:
irisandClaude Fable 5 committed 2026-08-24 21:07:02 -04:00
1 parent 967fc814ab
commit 213bc72b64
24 files changed
+2086

No files matched your search

@@ -0,0 +1,157 @@
package com.example.aiapp
import org.json.JSONArray
import org.json.JSONObject
import java.io.IOException
import java.net.HttpURLConnection
import java.net.URL
// The REST half of the backend's surface (see server/src/routes.rs for the
// table); the SSE half is EventStream.kt. All blocking network calls --
// invoke from a background dispatcher. Each throws ApiException on failure,
// carrying the server's own explanation where it sent one, since those
// messages are written to be read on this screen.
private const val CONNECT_TIMEOUT_MS = 5000
class ApiException(message: String, cause: Throwable? = null) : Exception(message, cause)
/**
* Runs one request against the backend, with the pinned TLS setup, the
* bearer token, and the failure translation every call needs. [readBody]
* gets the connected, already-status-checked connection to read from.
*
* @param readTimeoutMs how long to wait on the response body. The SSE
* stream doesn't come through here -- an event stream has no bounded
* read time (see EventStream.kt).
*/
fun <T> requestFromServer(
settings: ServerSettings,
path: String,
method: String = "GET",
jsonBody: String? = null,
readTimeoutMs: Int = 5000,
readBody: (HttpURLConnection) -> T,
): T {
val connection = URL("${settings.baseUrl}$path").openConnection() as HttpURLConnection
try {
connection.applyPinnedTls()
connection.requestMethod = method
connection.connectTimeout = CONNECT_TIMEOUT_MS
connection.readTimeout = readTimeoutMs
connection.setRequestProperty("Authorization", "Bearer ${settings.token}")
if (jsonBody != null) {
connection.doOutput = true
connection.setRequestProperty("Content-Type", "application/json")
connection.outputStream.use { it.write(jsonBody.encodeToByteArray()) }
}
if (connection.responseCode !in 200..299) {
val detail = connection.errorStream?.bufferedReader()?.readText()?.trim()
throw ApiException(
when {
connection.responseCode == 401 ->
"The server rejected this device's token. Re-enroll by scanning " +
"the server's QR (or rotate with --rotate-token and scan the new one)."
detail.isNullOrEmpty() ->
"Server returned HTTP ${connection.responseCode} for $path"
else -> detail
},
)
}
return readBody(connection)
} catch (e: ApiException) {
throw e
} catch (e: IOException) {
// Surfacing the real exception (rather than one canned message for
// every failure mode) is what lets this be diagnosed on a device
// with no logcat access.
throw ApiException(
"Couldn't reach the server at ${settings.baseUrl} " +
"(${e::class.simpleName}: ${e.message}) -- is ai-server running, and is " +
"this device able to reach that address (WireGuard up)?",
e,
)
} catch (e: Exception) {
throw ApiException(
"Reached ${settings.baseUrl}$path but couldn't read its response " +
"(${e::class.simpleName}: ${e.message})",
e,
)
} finally {
connection.disconnect()
}
}
// One row of GET /sessions.
data class SessionSummary(
val id: String,
val kind: String,
val title: String,
val host: String?,
val model: String?,
val status: String,
val lastActivity: Double,
)
fun fetchSessions(settings: ServerSettings): List<SessionSummary> =
requestFromServer(settings, "/sessions") { connection ->
val sessions = JSONArray(connection.inputStream.bufferedReader().readText())
(0 until sessions.length()).map { i ->
val session = sessions.getJSONObject(i)
SessionSummary(
id = session.getString("id"),
kind = session.getString("kind"),
title = session.getString("title"),
host = session.optString("host").ifEmpty { null },
model = session.optString("model").ifEmpty { null },
status = session.getString("status"),
lastActivity = session.getDouble("lastActivity"),
)
}
}
/** Spawns a session and returns it as the list would show it. */
fun spawnSession(settings: ServerSettings, kind: String, title: String): SessionSummary =
requestFromServer(
settings,
"/sessions",
method = "POST",
jsonBody = JSONObject().put("kind", kind).put("title", title).toString(),
) { connection ->
val session = JSONObject(connection.inputStream.bufferedReader().readText())
SessionSummary(
id = session.getString("id"),
kind = session.getString("kind"),
title = session.getString("title"),
host = session.optString("host").ifEmpty { null },
model = session.optString("model").ifEmpty { null },
status = session.getString("status"),
lastActivity = session.getDouble("lastActivity"),
)
}
fun sendMessage(settings: ServerSettings, sessionId: String, text: String) {
requestFromServer(
settings,
"/sessions/$sessionId/message",
method = "POST",
jsonBody = JSONObject().put("text", text).toString(),
) {}
}
fun answerQuestion(settings: ServerSettings, sessionId: String, questionId: String, answer: String) {
requestFromServer(
settings,
"/sessions/$sessionId/answer",
method = "POST",
jsonBody = JSONObject().put("questionId", questionId).put("answer", answer).toString(),
) {}
}
fun interruptSession(settings: ServerSettings, sessionId: String) {
requestFromServer(settings, "/sessions/$sessionId/interrupt", method = "POST") {}
}
fun deleteSession(settings: ServerSettings, sessionId: String) {
requestFromServer(settings, "/sessions/$sessionId", method = "DELETE") {}
}
@@ -0,0 +1,97 @@
package com.example.aiapp
import androidx.activity.compose.BackHandler
import androidx.compose.runtime.Composable
import androidx.compose.runtime.getValue
import androidx.compose.runtime.mutableStateOf
import androidx.compose.runtime.remember
import androidx.compose.runtime.setValue
import androidx.compose.ui.platform.LocalContext
/**
* One `when` rather than a navigation library: four screens, with the list
* as the root and the back button the only other way between them.
*/
private sealed class Screen {
data object SessionList : Screen()
data class Session(val summary: SessionSummary) : Screen()
data object Spawn : Screen()
data object Settings : Screen()
}
/**
* [settingsVersion] bumps when enrollment lands via an `aiapp://` intent
* (see MainActivity), re-reading the stored settings -- a plain `remember`
* would keep serving the pre-enrollment null.
*/
@Composable
fun AppRoot(settingsVersion: Int) {
val context = LocalContext.current
var settings by remember(settingsVersion) { mutableStateOf(loadServerSettings(context)) }
var screen by remember { mutableStateOf<Screen>(Screen.SessionList) }
// Bumped whenever another screen changes something the list shows, so
// returning to it refetches instead of showing a stale list.
var reloadToken by remember { mutableStateOf(0) }
val current = settings
if (current == null) {
// Not enrolled yet: settings is the only usable screen. The QR
// path lands in MainActivity and recomposes from the top.
SettingsScreen(
existing = null,
onSaved = { saved ->
settings = saved
screen = Screen.SessionList
},
onBack = null,
)
return
}
when (val here = screen) {
is Screen.SessionList -> SessionListScreen(
settings = current,
reloadToken = reloadToken,
onOpen = { screen = Screen.Session(it) },
onSpawn = { screen = Screen.Spawn },
onSettings = { screen = Screen.Settings },
)
is Screen.Session -> {
BackHandler {
reloadToken++
screen = Screen.SessionList
}
SessionScreen(
settings = current,
summary = here.summary,
onBack = {
reloadToken++
screen = Screen.SessionList
},
)
}
is Screen.Spawn -> {
BackHandler { screen = Screen.SessionList }
SpawnScreen(
settings = current,
onSpawned = { spawned ->
reloadToken++
screen = Screen.Session(spawned)
},
onBack = { screen = Screen.SessionList },
)
}
is Screen.Settings -> {
BackHandler { screen = Screen.SessionList }
SettingsScreen(
existing = current,
onSaved = { saved ->
settings = saved
reloadToken++
screen = Screen.SessionList
},
onBack = { screen = Screen.SessionList },
)
}
}
}
@@ -0,0 +1,80 @@
package com.example.aiapp
import java.io.IOException
import java.net.HttpURLConnection
import java.net.URL
/**
* The SSE half of the API: one long-lived GET per open session screen,
* replaying the transcript after a cursor and then following it live.
*
* Blocking -- run() occupies its thread until the stream ends. [close]
* (from any thread) is the cancellation path: it disconnects the socket,
* which unblocks the read; run() then returns instead of throwing, so a
* deliberate close doesn't surface as a connection error. The caller owns
* reconnecting (with the last seq it saw as the new cursor) -- see
* SessionScreen.
*/
class EventStream(private val settings: ServerSettings, private val sessionId: String) {
@Volatile private var connection: HttpURLConnection? = null
@Volatile private var closed = false
fun close() {
closed = true
connection?.disconnect()
}
/** Streams events after [after] into [onEvent] until the stream drops. */
fun run(after: Long, onEvent: (SeqEvent) -> Unit) {
val connection =
URL("${settings.baseUrl}/sessions/$sessionId/events?after=$after").openConnection()
as HttpURLConnection
this.connection = connection
try {
connection.applyPinnedTls()
connection.connectTimeout = 5000
// No read timeout: between events there is nothing to read for
// as long as the session is idle; the server's keep-alives and
// a dead socket erroring out are the liveness story.
connection.readTimeout = 0
connection.setRequestProperty("Authorization", "Bearer ${settings.token}")
connection.setRequestProperty("Accept", "text/event-stream")
if (connection.responseCode != 200) {
val detail = connection.errorStream?.bufferedReader()?.readText()?.trim()
throw ApiException(detail ?: "HTTP ${connection.responseCode} for the event stream")
}
val reader = connection.inputStream.bufferedReader()
// SSE framing: `data:` lines accumulate until a blank line ends
// the event. `id:` (the seq) is also inside the JSON payload,
// so only data lines matter; comment lines (keep-alives) start
// with ':' and are skipped.
val data = StringBuilder()
while (true) {
val line = reader.readLine() ?: break
when {
line.isEmpty() -> {
if (data.isNotEmpty()) {
onEvent(parseSeqEvent(data.toString()))
data.clear()
}
}
line.startsWith("data:") -> data.append(line.removePrefix("data:").trim())
else -> {} // id:, event:, comments -- nothing to do
}
}
} catch (e: ApiException) {
throw e
} catch (e: IOException) {
if (!closed) {
throw ApiException(
"Lost the event stream (${e::class.simpleName}: ${e.message})",
e,
)
}
} finally {
connection.disconnect()
this.connection = null
}
}
}
@@ -0,0 +1,63 @@
package com.example.aiapp
import org.json.JSONObject
// The common event model, mirrored from server/src/session/driver.rs --
// the app renders purely from this stream (replayed from the transcript by
// cursor, then live), so there is no separate "load history" shape to keep
// in sync with it.
/** One transcript line: the event plus its resume cursor and time. */
data class SeqEvent(val seq: Long, val ts: Double, val event: SessionEvent)
sealed class SessionEvent {
data class UserMessage(val text: String) : SessionEvent()
data class AssistantText(val delta: String) : SessionEvent()
data class ToolStart(val id: String, val tool: String, val input: String) : SessionEvent()
data class ToolUpdate(val id: String, val output: String) : SessionEvent()
data class ToolEnd(val id: String, val output: String) : SessionEvent()
data class Image(val ref: String) : SessionEvent()
data class Question(val id: String, val prompt: String, val options: List<String>) : SessionEvent()
data class Answered(val id: String, val answer: String) : SessionEvent()
data class Status(val state: String) : SessionEvent()
data class UsageDelta(val tokens: Long) : SessionEvent()
data class Error(val message: String) : SessionEvent()
/**
* An event type this app build doesn't know -- a newer server. Kept
* (not thrown) so one new event kind degrades to a placeholder row
* instead of killing the stream.
*/
data class Unknown(val type: String) : SessionEvent()
}
fun parseSeqEvent(json: String): SeqEvent {
val body = JSONObject(json)
val event = when (val type = body.getString("type")) {
"userMessage" -> SessionEvent.UserMessage(body.getString("text"))
"assistantText" -> SessionEvent.AssistantText(body.getString("delta"))
"toolStart" -> SessionEvent.ToolStart(
id = body.getString("id"),
tool = body.getString("tool"),
// Kept as raw JSON text: the input shape is the tool's own
// business, and the UI only ever shows it verbatim.
input = body.get("input").toString(),
)
"toolUpdate" -> SessionEvent.ToolUpdate(body.getString("id"), body.getString("output"))
"toolEnd" -> SessionEvent.ToolEnd(body.getString("id"), body.getString("output"))
"image" -> SessionEvent.Image(body.getString("ref"))
"question" -> SessionEvent.Question(
id = body.getString("id"),
prompt = body.getString("prompt"),
options = body.getJSONArray("options").let { options ->
(0 until options.length()).map { options.getString(it) }
},
)
"answered" -> SessionEvent.Answered(body.getString("id"), body.getString("answer"))
"status" -> SessionEvent.Status(body.getString("state"))
"usageDelta" -> SessionEvent.UsageDelta(body.getLong("tokens"))
"error" -> SessionEvent.Error(body.getString("message"))
else -> SessionEvent.Unknown(type)
}
return SeqEvent(seq = body.getLong("seq"), ts = body.getDouble("ts"), event = event)
}
@@ -0,0 +1,81 @@
package com.example.aiapp
import android.Manifest
import android.content.Intent
import android.os.Build
import android.os.Bundle
import android.widget.Toast
import androidx.activity.ComponentActivity
import androidx.activity.compose.setContent
import androidx.activity.enableEdgeToEdge
import androidx.activity.result.contract.ActivityResultContracts
import androidx.compose.foundation.layout.Box
import androidx.compose.foundation.layout.fillMaxSize
import androidx.compose.foundation.layout.imePadding
import androidx.compose.foundation.layout.statusBarsPadding
import androidx.compose.material3.MaterialTheme
import androidx.compose.material3.Surface
import androidx.compose.runtime.getValue
import androidx.compose.runtime.mutableStateOf
import androidx.compose.runtime.setValue
import androidx.compose.ui.Modifier
import androidx.core.view.WindowCompat
class MainActivity : ComponentActivity() {
// Bumped whenever enrollment lands via an aiapp:// intent so the
// composition below re-reads the stored settings.
private var settingsVersion by mutableStateOf(0)
// Registered up front since permission launchers must be registered
// before the activity reaches STARTED.
private val requestLocalNetworkPermission =
registerForActivityResult(ActivityResultContracts.RequestPermission()) {}
override fun onCreate(savedInstanceState: Bundle?) {
super.onCreate(savedInstanceState)
// Transparent status bar on every version; the Surface below paints
// through underneath it and content insets itself. Same reasoning
// as local-updater's MainActivity.
enableEdgeToEdge()
WindowCompat.getInsetsController(window, window.decorView).isAppearanceLightStatusBars = true
// Android 17+ silently drops local-network traffic without this;
// requested up front because a denial is invisible at the socket
// layer (it just times out).
if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.CINNAMON_BUN) {
requestLocalNetworkPermission.launch(Manifest.permission.ACCESS_LOCAL_NETWORK)
}
handleEnrollment(intent)
setContent {
MaterialTheme {
Surface(modifier = Modifier.fillMaxSize()) {
Box(modifier = Modifier.fillMaxSize().statusBarsPadding().imePadding()) {
AppRoot(settingsVersion)
}
}
}
}
}
// launchMode="singleTop": an enrollment scan while the app is open
// lands here rather than in a second activity instance.
override fun onNewIntent(intent: Intent) {
super.onNewIntent(intent)
handleEnrollment(intent)
}
private fun handleEnrollment(intent: Intent?) {
val uri = intent?.data ?: return
val settings = parseEnrollmentUri(uri)
if (settings == null) {
Toast.makeText(this, "Not a valid enrollment code", Toast.LENGTH_LONG).show()
return
}
saveServerSettings(this, settings)
settingsVersion++
Toast.makeText(this, "Enrolled with ${settings.baseUrl}", Toast.LENGTH_LONG).show()
}
}
@@ -0,0 +1,68 @@
package com.example.aiapp
import java.io.ByteArrayInputStream
import java.net.HttpURLConnection
import java.security.KeyStore
import java.security.cert.CertificateFactory
import javax.net.ssl.HttpsURLConnection
import javax.net.ssl.SSLContext
import javax.net.ssl.SSLSocketFactory
import javax.net.ssl.TrustManagerFactory
import javax.net.ssl.X509TrustManager
/**
* PEM-encoded dev CA certificate this repo's `gen-dev-cert.sh` generates --
* the sole trust anchor for every request this app makes. The server's TLS
* listener presents a leaf certificate signed by this CA. Everything behind
* that listener *is* remote code execution on the backend, so this app
* trusts exactly this CA and nothing else -- not even the system store.
*
* Regenerating that CA means updating this to match; its SHA-256
* fingerprint is printed by the script and saved to `certs/ca-sha256.txt`.
* The QR enrollment deliberately does not carry the CA: trust lives here in
* the APK, so photographing the terminal leaks only the (rotatable) token.
*/
const val PINNED_CA_PEM = """-----BEGIN CERTIFICATE-----
MIIBrjCCAVWgAwIBAgIUGomDFgIrkwxX9504lixc8kM83I0wCgYIKoZIzj0EAwIw
LTETMBEGA1UECgwKYWktYXBwIGRldjEWMBQGA1UEAwwNYWktYXBwIGRldiBDQTAe
Fw0yNjA4MjUwMDQ5NDBaFw0zNjA4MjIwMDQ5NDBaMC0xEzARBgNVBAoMCmFpLWFw
cCBkZXYxFjAUBgNVBAMMDWFpLWFwcCBkZXYgQ0EwWTATBgcqhkjOPQIBBggqhkjO
PQMBBwNCAARW8deDZhiVxUDo1TyGMIpOpvu45vei8Vd5rWFNgSOl80h8TQ8/v8fI
tcacAGiPK0OUDOPb6iSaSMS8QEtfPB8+o1MwUTAdBgNVHQ4EFgQUFnEtKeV8et8Z
O7/ihnMOckS45qwwHwYDVR0jBBgwFoAUFnEtKeV8et8ZO7/ihnMOckS45qwwDwYD
VR0TAQH/BAUwAwEB/zAKBggqhkjOPQQDAgNHADBEAiBppx+X09AjEJ8X24KxHYaX
4NRE+8OlxqJCrkuAM7tLagIgND6JvA5K0WjlfZxomla45C5Vd91j2jdIPmM+UkEa
Wb4=
-----END CERTIFICATE-----
"""
/**
* Trusts only [PINNED_CA_PEM], not the device's system trust store, so a
* real CA-issued cert for some other host wouldn't be accepted either.
* Built once and cached -- every SSE reconnect would otherwise redo the
* KeyStore/TrustManager setup from scratch.
*/
val pinnedSslSocketFactory: SSLSocketFactory by lazy {
val caCert = CertificateFactory.getInstance("X.509")
.generateCertificate(ByteArrayInputStream(PINNED_CA_PEM.encodeToByteArray()))
val keyStore = KeyStore.getInstance(KeyStore.getDefaultType()).apply {
load(null, null)
setCertificateEntry("ai-app-dev-ca", caCert)
}
val trustManager = TrustManagerFactory
.getInstance(TrustManagerFactory.getDefaultAlgorithm())
.apply { init(keyStore) }
.trustManagers
.filterIsInstance<X509TrustManager>()
.first()
SSLContext.getInstance("TLS").apply {
init(null, arrayOf(trustManager), null)
}.socketFactory
}
/** Every request this app makes goes through this -- there is no unpinned path. */
fun HttpURLConnection.applyPinnedTls() {
if (this is HttpsURLConnection) {
sslSocketFactory = pinnedSslSocketFactory
}
}
@@ -0,0 +1,118 @@
package com.example.aiapp
import android.content.Context
import android.net.Uri
import android.security.keystore.KeyGenParameterSpec
import android.security.keystore.KeyProperties
import android.util.Base64
import java.security.KeyStore
import javax.crypto.Cipher
import javax.crypto.KeyGenerator
import javax.crypto.SecretKey
import javax.crypto.spec.GCMParameterSpec
/**
* Where the backend is and how to authenticate to it. Absent until the
* phone is enrolled -- by scanning the server's terminal QR (an
* `aiapp://enroll` URI the camera app hands to MainActivity) or by typing
* the fields into the settings screen.
*/
data class ServerSettings(val host: String, val port: Int, val token: String) {
val baseUrl: String get() = "https://$host:$port"
}
private const val PREFS_NAME = "server"
private const val KEY_HOST = "host"
private const val KEY_PORT = "port"
private const val KEY_TOKEN = "token"
fun loadServerSettings(context: Context): ServerSettings? {
val prefs = context.getSharedPreferences(PREFS_NAME, Context.MODE_PRIVATE)
val host = prefs.getString(KEY_HOST, null) ?: return null
val port = prefs.getInt(KEY_PORT, 0)
val sealed = prefs.getString(KEY_TOKEN, null) ?: return null
val token = unseal(sealed) ?: return null
if (port == 0 || token.isEmpty()) return null
return ServerSettings(host, port, token)
}
fun saveServerSettings(context: Context, settings: ServerSettings) {
context.getSharedPreferences(PREFS_NAME, Context.MODE_PRIVATE)
.edit()
.putString(KEY_HOST, settings.host)
.putInt(KEY_PORT, settings.port)
.putString(KEY_TOKEN, seal(settings.token))
.apply()
}
/**
* Parses the enrollment URI the server's QR carries:
* `aiapp://enroll?host=10.66.0.1&port=8443&token=...`. Null if any part is
* missing -- a malformed scan shouldn't clobber a working enrollment.
*/
fun parseEnrollmentUri(uri: Uri): ServerSettings? {
if (uri.scheme != "aiapp" || uri.host != "enroll") return null
val host = uri.getQueryParameter("host") ?: return null
val port = uri.getQueryParameter("port")?.toIntOrNull() ?: return null
val token = uri.getQueryParameter("token") ?: return null
if (host.isEmpty() || token.isEmpty()) return null
return ServerSettings(host, port, token)
}
// ---------------------------------------------------------------------------
// Token sealing. The token is the credential for remote code execution on
// the backend, so it is stored AES-GCM-encrypted under an Android Keystore
// key (hardware-backed where the device has it) rather than in plain
// preferences. Hand-rolled (~40 lines) instead of Jetpack's
// EncryptedSharedPreferences because that library is deprecated with no
// drop-in successor -- Google's own guidance is now "use Keystore directly".
private const val KEYSTORE = "AndroidKeyStore"
private const val KEY_ALIAS = "aiapp-token-key"
private const val GCM_TAG_BITS = 128
private fun tokenKey(): SecretKey {
val keyStore = KeyStore.getInstance(KEYSTORE).apply { load(null) }
(keyStore.getKey(KEY_ALIAS, null) as? SecretKey)?.let { return it }
val generator = KeyGenerator.getInstance(KeyProperties.KEY_ALGORITHM_AES, KEYSTORE)
generator.init(
KeyGenParameterSpec.Builder(
KEY_ALIAS,
KeyProperties.PURPOSE_ENCRYPT or KeyProperties.PURPOSE_DECRYPT,
)
.setBlockModes(KeyProperties.BLOCK_MODE_GCM)
.setEncryptionPaddings(KeyProperties.ENCRYPTION_PADDING_NONE)
.build(),
)
return generator.generateKey()
}
/** iv:ciphertext, both base64 -- the stored form of the token. */
private fun seal(token: String): String {
val cipher = Cipher.getInstance("AES/GCM/NoPadding")
cipher.init(Cipher.ENCRYPT_MODE, tokenKey())
val ciphertext = cipher.doFinal(token.encodeToByteArray())
return Base64.encodeToString(cipher.iv, Base64.NO_WRAP) + ":" +
Base64.encodeToString(ciphertext, Base64.NO_WRAP)
}
/**
* Null on any failure -- e.g. the Keystore key was lost to a device reset
* or the app's data was restored onto another device, where the key never
* travels. The caller treats that as "not enrolled"; re-scanning the QR
* (or `--rotate-token`) is the recovery, so failing soft here is right.
*/
private fun unseal(sealed: String): String? = try {
val (ivB64, dataB64) = sealed.split(":", limit = 2).let {
if (it.size != 2) return null else it[0] to it[1]
}
val cipher = Cipher.getInstance("AES/GCM/NoPadding")
cipher.init(
Cipher.DECRYPT_MODE,
tokenKey(),
GCMParameterSpec(GCM_TAG_BITS, Base64.decode(ivB64, Base64.NO_WRAP)),
)
cipher.doFinal(Base64.decode(dataB64, Base64.NO_WRAP)).decodeToString()
} catch (_: Exception) {
null
}
@@ -0,0 +1,219 @@
package com.example.aiapp
import androidx.compose.foundation.ExperimentalFoundationApi
import androidx.compose.foundation.combinedClickable
import androidx.compose.foundation.layout.Box
import androidx.compose.foundation.layout.Column
import androidx.compose.foundation.layout.Row
import androidx.compose.foundation.layout.Spacer
import androidx.compose.foundation.layout.fillMaxSize
import androidx.compose.foundation.layout.fillMaxWidth
import androidx.compose.foundation.layout.height
import androidx.compose.foundation.layout.padding
import androidx.compose.foundation.layout.width
import androidx.compose.foundation.lazy.LazyColumn
import androidx.compose.foundation.lazy.items
import androidx.compose.material3.AlertDialog
import androidx.compose.material3.Card
import androidx.compose.material3.CircularProgressIndicator
import androidx.compose.material3.FloatingActionButton
import androidx.compose.material3.MaterialTheme
import androidx.compose.material3.Text
import androidx.compose.material3.TextButton
import androidx.compose.runtime.Composable
import androidx.compose.runtime.LaunchedEffect
import androidx.compose.runtime.getValue
import androidx.compose.runtime.mutableStateOf
import androidx.compose.runtime.remember
import androidx.compose.runtime.rememberCoroutineScope
import androidx.compose.runtime.setValue
import androidx.compose.ui.Alignment
import androidx.compose.ui.Modifier
import androidx.compose.ui.graphics.Color
import androidx.compose.ui.unit.dp
import kotlinx.coroutines.Dispatchers
import kotlinx.coroutines.launch
import kotlinx.coroutines.withContext
// Status colors, keyed by the wire strings in Events.kt. Light theme only,
// as in local-updater.
private val AWAITING_COLOR = Color(0xFFB26A00)
private val RUNNING_COLOR = Color(0xFF2E7D32)
private sealed class ListState {
data object Loading : ListState()
data class Loaded(val sessions: List<SessionSummary>) : ListState()
data class Error(val message: String) : ListState()
}
/**
* The session list -- the app's root screen. Sessions awaiting an answer
* sort to the top: that's the "your turn" inbox.
*/
@Composable
fun SessionListScreen(
settings: ServerSettings,
reloadToken: Int,
onOpen: (SessionSummary) -> Unit,
onSpawn: () -> Unit,
onSettings: () -> Unit,
) {
val scope = rememberCoroutineScope()
var listState by remember { mutableStateOf<ListState>(ListState.Loading) }
var confirmingDelete by remember { mutableStateOf<SessionSummary?>(null) }
fun refresh() {
listState = ListState.Loading
scope.launch {
listState = try {
withContext(Dispatchers.IO) { ListState.Loaded(fetchSessions(settings)) }
} catch (e: ApiException) {
ListState.Error(e.message ?: "Unknown error")
}
}
}
LaunchedEffect(reloadToken) { refresh() }
Box(Modifier.fillMaxSize()) {
Column(Modifier.fillMaxSize().padding(16.dp)) {
Row(verticalAlignment = Alignment.CenterVertically, modifier = Modifier.fillMaxWidth()) {
Text(
"AI Sessions",
style = MaterialTheme.typography.headlineSmall,
modifier = Modifier.weight(1f),
)
TextButton(onClick = onSettings) { Text("Settings") }
TextButton(onClick = { refresh() }) { Text("Refresh") }
}
Spacer(Modifier.height(16.dp))
when (val state = listState) {
is ListState.Loading -> CircularProgressIndicator()
is ListState.Error -> Text(
"Couldn't reach the server: ${state.message}",
color = MaterialTheme.colorScheme.error,
)
is ListState.Loaded -> {
if (state.sessions.isEmpty()) {
Text(
"No sessions. Tap + to spawn one.",
style = MaterialTheme.typography.bodyMedium,
color = MaterialTheme.colorScheme.onSurfaceVariant,
)
}
// Awaiting-answer first (the point of the screen), then
// most recently active.
val ordered = state.sessions.sortedWith(
compareByDescending<SessionSummary> { it.status == "awaitingInput" }
.thenByDescending { it.lastActivity },
)
LazyColumn {
items(ordered, key = { it.id }) { session ->
SessionCard(
session = session,
onOpen = { onOpen(session) },
onLongPress = { confirmingDelete = session },
)
Spacer(Modifier.height(12.dp))
}
}
}
}
}
FloatingActionButton(
onClick = onSpawn,
modifier = Modifier.align(Alignment.BottomEnd).padding(24.dp),
) { Text("+", style = MaterialTheme.typography.headlineMedium) }
}
confirmingDelete?.let { session ->
AlertDialog(
onDismissRequest = { confirmingDelete = null },
title = { Text("Delete \"${session.title}\"?") },
text = { Text("Kills the process and deletes its transcript. This can't be undone.") },
confirmButton = {
TextButton(onClick = {
confirmingDelete = null
scope.launch {
try {
withContext(Dispatchers.IO) { deleteSession(settings, session.id) }
refresh()
} catch (e: ApiException) {
listState = ListState.Error(e.message ?: "Delete failed")
}
}
}) { Text("Delete") }
},
dismissButton = {
TextButton(onClick = { confirmingDelete = null }) { Text("Cancel") }
},
)
}
}
@OptIn(ExperimentalFoundationApi::class)
@Composable
private fun SessionCard(
session: SessionSummary,
onOpen: () -> Unit,
onLongPress: () -> Unit,
) {
Card(
Modifier.fillMaxWidth().combinedClickable(onClick = onOpen, onLongClick = onLongPress),
) {
Column(Modifier.padding(16.dp)) {
Row(verticalAlignment = Alignment.CenterVertically, modifier = Modifier.fillMaxWidth()) {
Text(
session.title,
style = MaterialTheme.typography.titleMedium,
modifier = Modifier.weight(1f),
)
StatusText(session.status)
}
Spacer(Modifier.height(4.dp))
Row(modifier = Modifier.fillMaxWidth()) {
Text(
listOfNotNull(session.kind, session.host, session.model).joinToString(" · "),
style = MaterialTheme.typography.bodySmall,
color = MaterialTheme.colorScheme.onSurfaceVariant,
modifier = Modifier.weight(1f),
)
Text(
relativeTime(session.lastActivity),
style = MaterialTheme.typography.bodySmall,
color = MaterialTheme.colorScheme.onSurfaceVariant,
)
}
}
}
}
@Composable
fun StatusText(status: String) {
val (label, color) = when (status) {
"awaitingInput" -> "your turn" to AWAITING_COLOR
"running" -> "running" to RUNNING_COLOR
"compacting" -> "compacting" to RUNNING_COLOR
"exited" -> "exited" to MaterialTheme.colorScheme.onSurfaceVariant
else -> status to MaterialTheme.colorScheme.onSurfaceVariant
}
Row(verticalAlignment = Alignment.CenterVertically) {
if (status == "running" || status == "compacting") {
CircularProgressIndicator(modifier = Modifier.width(14.dp).height(14.dp), strokeWidth = 2.dp)
Spacer(Modifier.width(6.dp))
}
Text(label, style = MaterialTheme.typography.labelLarge, color = color)
}
}
fun relativeTime(epochSeconds: Double): String {
val seconds = (System.currentTimeMillis() / 1000.0 - epochSeconds).toLong()
return when {
seconds < 60 -> "just now"
seconds < 3600 -> "${seconds / 60}m ago"
seconds < 86400 -> "${seconds / 3600}h ago"
else -> "${seconds / 86400}d ago"
}
}
@@ -0,0 +1,349 @@
package com.example.aiapp
import androidx.compose.foundation.clickable
import androidx.compose.foundation.layout.Arrangement
import androidx.compose.foundation.layout.Box
import androidx.compose.foundation.layout.Column
import androidx.compose.foundation.layout.Row
import androidx.compose.foundation.layout.Spacer
import androidx.compose.foundation.layout.fillMaxSize
import androidx.compose.foundation.layout.fillMaxWidth
import androidx.compose.foundation.layout.height
import androidx.compose.foundation.layout.padding
import androidx.compose.foundation.layout.width
import androidx.compose.foundation.lazy.LazyColumn
import androidx.compose.foundation.lazy.itemsIndexed
import androidx.compose.foundation.lazy.rememberLazyListState
import androidx.compose.material3.Button
import androidx.compose.material3.Card
import androidx.compose.material3.CardDefaults
import androidx.compose.material3.CircularProgressIndicator
import androidx.compose.material3.MaterialTheme
import androidx.compose.material3.OutlinedButton
import androidx.compose.material3.OutlinedTextField
import androidx.compose.material3.Text
import androidx.compose.material3.TextButton
import androidx.compose.runtime.Composable
import androidx.compose.runtime.DisposableEffect
import androidx.compose.runtime.LaunchedEffect
import androidx.compose.runtime.getValue
import androidx.compose.runtime.mutableStateOf
import androidx.compose.runtime.remember
import androidx.compose.runtime.rememberCoroutineScope
import androidx.compose.runtime.setValue
import androidx.compose.ui.Alignment
import androidx.compose.ui.Modifier
import androidx.compose.ui.unit.dp
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.
*/
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,
val input: String,
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()
/** Placeholder row for events this build can't render (images, newer kinds). */
data class Note(val text: String) : TranscriptItem()
}
fun foldEvent(items: List<TranscriptItem>, event: SessionEvent): List<TranscriptItem> =
when (event) {
is SessionEvent.UserMessage -> items + TranscriptItem.UserMsg(event.text)
is SessionEvent.AssistantText -> {
// Deltas accumulate into the message they're streaming.
val last = items.lastOrNull()
if (last is TranscriptItem.AssistantMsg) {
items.dropLast(1) + last.copy(text = last.text + event.delta)
} else {
items + TranscriptItem.AssistantMsg(event.delta)
}
}
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.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
}
is SessionEvent.Error -> items + TranscriptItem.ErrorMsg(event.message)
is SessionEvent.Image -> items + TranscriptItem.Note("[image ${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
}
private fun updateTool(
items: List<TranscriptItem>,
id: String,
change: (TranscriptItem.ToolRun) -> TranscriptItem.ToolRun,
): List<TranscriptItem> = items.map {
if (it is TranscriptItem.ToolRun && it.id == id) change(it) else it
}
@Composable
fun SessionScreen(settings: ServerSettings, summary: SessionSummary, onBack: () -> Unit) {
val scope = rememberCoroutineScope()
var items by remember { mutableStateOf(listOf<TranscriptItem>()) }
var status by remember { mutableStateOf(summary.status) }
var totalTokens by remember { mutableStateOf(0L) }
var streamError by remember { mutableStateOf<String?>(null) }
var actionError by remember { mutableStateOf<String?>(null) }
var input by remember { mutableStateOf("") }
var expandedTools by remember { mutableStateOf(setOf<String>()) }
// The resume cursor, written from the stream's IO thread.
val lastSeq = remember { AtomicLong(0) }
val activeStream = remember { AtomicReference<EventStream?>(null) }
val listState = rememberLazyListState()
fun apply(entry: SeqEvent) {
lastSeq.set(entry.seq)
when (val event = entry.event) {
is SessionEvent.Status -> status = event.state
is SessionEvent.UsageDelta -> totalTokens += event.tokens
else -> items = foldEvent(items, event)
}
}
// The stream lifecycle: connect, follow, and on any drop reconnect
// from the cursor -- so a flaky link (or a backend restart) costs
// nothing but the gap's latency.
LaunchedEffect(summary.id) {
while (true) {
val stream = EventStream(settings, summary.id)
activeStream.set(stream)
try {
withContext(Dispatchers.IO) {
stream.run(lastSeq.get()) { entry ->
apply(entry)
streamError = null
}
}
} catch (e: ApiException) {
streamError = e.message
} finally {
stream.close()
}
delay(RECONNECT_DELAY_MS)
}
}
// 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() }
}
LaunchedEffect(items.size) {
if (items.isNotEmpty()) listState.animateScrollToItem(items.size - 1)
}
fun act(action: () -> Unit) {
scope.launch {
try {
withContext(Dispatchers.IO) { action() }
actionError = null
} catch (e: ApiException) {
actionError = e.message
}
}
}
fun send() {
val text = input.trim()
if (text.isEmpty()) return
input = ""
act { sendMessage(settings, summary.id, text) }
}
Column(Modifier.fillMaxSize()) {
Row(
verticalAlignment = Alignment.CenterVertically,
modifier = Modifier.fillMaxWidth().padding(horizontal = 8.dp, vertical = 4.dp),
) {
TextButton(onClick = onBack) { Text("Back") }
Column(Modifier.weight(1f)) {
Text(summary.title, style = MaterialTheme.typography.titleMedium)
Text(
listOfNotNull(
summary.kind,
summary.model,
if (totalTokens > 0) "$totalTokens tok" else null,
).joinToString(" · "),
style = MaterialTheme.typography.bodySmall,
color = MaterialTheme.colorScheme.onSurfaceVariant,
)
}
StatusText(status)
}
(streamError ?: actionError)?.let { message ->
Text(
message,
color = MaterialTheme.colorScheme.error,
style = MaterialTheme.typography.bodySmall,
modifier = Modifier.padding(horizontal = 16.dp, vertical = 4.dp),
)
}
LazyColumn(
state = listState,
modifier = Modifier.weight(1f).fillMaxWidth(),
contentPadding = androidx.compose.foundation.layout.PaddingValues(16.dp),
verticalArrangement = Arrangement.spacedBy(8.dp),
) {
itemsIndexed(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.Note -> Text(
item.text,
style = MaterialTheme.typography.bodySmall,
color = MaterialTheme.colorScheme.onSurfaceVariant,
)
}
}
}
// Always enabled -- a send while the session is running becomes a
// steering message injected at the next tool boundary, which is
// the point of the whole app.
Row(
verticalAlignment = Alignment.CenterVertically,
modifier = Modifier.fillMaxWidth().padding(8.dp),
) {
OutlinedTextField(
value = input,
onValueChange = { input = it },
modifier = Modifier.weight(1f),
placeholder = { Text("Message") },
maxLines = 4,
)
Spacer(Modifier.width(8.dp))
if (status == "running" || status == "compacting") {
OutlinedButton(onClick = { act { interruptSession(settings, summary.id) } }) {
Text("Stop")
}
Spacer(Modifier.width(8.dp))
}
Button(onClick = { send() }) { Text("Send") }
}
}
}
@Composable
private fun UserBubble(text: String) {
Box(Modifier.fillMaxWidth()) {
Card(
colors = CardDefaults.cardColors(
containerColor = MaterialTheme.colorScheme.primaryContainer,
),
modifier = Modifier.align(Alignment.CenterEnd).padding(start = 48.dp),
) {
Text(text, modifier = Modifier.padding(12.dp))
}
}
}
/**
* 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))
if (!tool.done) {
CircularProgressIndicator(
modifier = Modifier.width(16.dp).height(16.dp),
strokeWidth = 2.dp,
)
}
}
if (expanded) {
Spacer(Modifier.height(8.dp))
Text("Input", style = MaterialTheme.typography.labelSmall)
Text(tool.input, style = MaterialTheme.typography.bodySmall)
if (tool.output.isNotEmpty()) {
Spacer(Modifier.height(8.dp))
Text("Output", style = MaterialTheme.typography.labelSmall)
Text(tool.output, style = MaterialTheme.typography.bodySmall)
}
}
}
}
}
/**
* 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) {
Card(Modifier.fillMaxWidth()) {
Column(Modifier.padding(12.dp)) {
Text(question.prompt, style = MaterialTheme.typography.bodyLarge)
Spacer(Modifier.height(8.dp))
if (question.answer != null) {
Text(
"Answered: ${question.answer}",
style = MaterialTheme.typography.labelLarge,
color = MaterialTheme.colorScheme.onSurfaceVariant,
)
} else {
Row(horizontalArrangement = Arrangement.spacedBy(8.dp)) {
question.options.forEach { option ->
OutlinedButton(onClick = { onAnswer(option) }) { Text(option) }
}
}
}
}
}
}
@@ -0,0 +1,110 @@
package com.example.aiapp
import androidx.compose.foundation.layout.Column
import androidx.compose.foundation.layout.Row
import androidx.compose.foundation.layout.Spacer
import androidx.compose.foundation.layout.fillMaxSize
import androidx.compose.foundation.layout.fillMaxWidth
import androidx.compose.foundation.layout.height
import androidx.compose.foundation.layout.padding
import androidx.compose.material3.Button
import androidx.compose.material3.MaterialTheme
import androidx.compose.material3.OutlinedTextField
import androidx.compose.material3.Text
import androidx.compose.material3.TextButton
import androidx.compose.runtime.Composable
import androidx.compose.runtime.getValue
import androidx.compose.runtime.mutableStateOf
import androidx.compose.runtime.remember
import androidx.compose.runtime.setValue
import androidx.compose.ui.Alignment
import androidx.compose.ui.Modifier
import androidx.compose.ui.platform.LocalContext
import androidx.compose.ui.unit.dp
/**
* Server address and token. The normal path is scanning the server's
* terminal QR (which lands in MainActivity and never shows this screen);
* these fields are the fallback for typing the same three values by hand.
* [onBack] is null on first run, when there is nothing to go back to.
*/
@Composable
fun SettingsScreen(
existing: ServerSettings?,
onSaved: (ServerSettings) -> Unit,
onBack: (() -> Unit)?,
) {
val context = LocalContext.current
var host by remember { mutableStateOf(existing?.host ?: "10.66.0.1") }
var port by remember { mutableStateOf((existing?.port ?: 8443).toString()) }
// Never pre-filled from the stored token: this screen shouldn't be a
// way to read the credential back off the device.
var token by remember { mutableStateOf("") }
var error by remember { mutableStateOf<String?>(null) }
Column(Modifier.fillMaxSize().padding(16.dp)) {
Row(verticalAlignment = Alignment.CenterVertically, modifier = Modifier.fillMaxWidth()) {
Text(
"Server",
style = MaterialTheme.typography.headlineSmall,
modifier = Modifier.weight(1f),
)
if (onBack != null) {
TextButton(onClick = onBack) { Text("Back") }
}
}
Spacer(Modifier.height(8.dp))
Text(
"The easy way: run ai-server on the backend and scan the QR it prints " +
"with the phone's camera. Or type the same values here.",
style = MaterialTheme.typography.bodyMedium,
color = MaterialTheme.colorScheme.onSurfaceVariant,
)
Spacer(Modifier.height(16.dp))
OutlinedTextField(
value = host,
onValueChange = { host = it },
label = { Text("Host") },
singleLine = true,
modifier = Modifier.fillMaxWidth(),
)
Spacer(Modifier.height(8.dp))
OutlinedTextField(
value = port,
onValueChange = { port = it },
label = { Text("Port") },
singleLine = true,
modifier = Modifier.fillMaxWidth(),
)
Spacer(Modifier.height(8.dp))
OutlinedTextField(
value = token,
onValueChange = { token = it },
label = { Text(if (existing != null) "Token (unchanged if left blank)" else "Token") },
singleLine = true,
modifier = Modifier.fillMaxWidth(),
)
Spacer(Modifier.height(24.dp))
error?.let {
Text(it, color = MaterialTheme.colorScheme.error)
Spacer(Modifier.height(8.dp))
}
Button(onClick = {
val portNumber = port.trim().toIntOrNull()
val effectiveToken = token.trim().ifEmpty { existing?.token ?: "" }
when {
host.isBlank() -> error = "Host is required"
portNumber == null || portNumber !in 1..65535 -> error = "Port must be 1-65535"
effectiveToken.isEmpty() -> error = "Token is required -- scan the server's QR or paste it"
else -> {
val settings = ServerSettings(host.trim(), portNumber, effectiveToken)
saveServerSettings(context, settings)
onSaved(settings)
}
}
}) { Text("Save") }
}
}
@@ -0,0 +1,105 @@
package com.example.aiapp
import androidx.compose.foundation.layout.Column
import androidx.compose.foundation.layout.Row
import androidx.compose.foundation.layout.Spacer
import androidx.compose.foundation.layout.fillMaxSize
import androidx.compose.foundation.layout.fillMaxWidth
import androidx.compose.foundation.layout.height
import androidx.compose.foundation.layout.padding
import androidx.compose.material3.Button
import androidx.compose.material3.FilterChip
import androidx.compose.material3.MaterialTheme
import androidx.compose.material3.OutlinedTextField
import androidx.compose.material3.Text
import androidx.compose.material3.TextButton
import androidx.compose.runtime.Composable
import androidx.compose.runtime.getValue
import androidx.compose.runtime.mutableStateOf
import androidx.compose.runtime.remember
import androidx.compose.runtime.rememberCoroutineScope
import androidx.compose.runtime.setValue
import androidx.compose.ui.Alignment
import androidx.compose.ui.Modifier
import androidx.compose.ui.unit.dp
import kotlinx.coroutines.Dispatchers
import kotlinx.coroutines.launch
import kotlinx.coroutines.withContext
// The session kinds this build can spawn. Phase 2 adds "claude" (with
// model / working directory / permission-mode fields), phase 4 "pi" --
// extending this list and the per-kind fields, not adding a parallel
// screen.
private val KINDS = listOf("echo")
/** The spawn screen: kind, title, go. */
@Composable
fun SpawnScreen(
settings: ServerSettings,
onSpawned: (SessionSummary) -> Unit,
onBack: () -> Unit,
) {
val scope = rememberCoroutineScope()
var kind by remember { mutableStateOf(KINDS.first()) }
var title by remember { mutableStateOf("") }
var busy by remember { mutableStateOf(false) }
var error by remember { mutableStateOf<String?>(null) }
Column(Modifier.fillMaxSize().padding(16.dp)) {
Row(verticalAlignment = Alignment.CenterVertically, modifier = Modifier.fillMaxWidth()) {
Text(
"New session",
style = MaterialTheme.typography.headlineSmall,
modifier = Modifier.weight(1f),
)
TextButton(onClick = onBack) { Text("Cancel") }
}
Spacer(Modifier.height(16.dp))
Text("Kind", style = MaterialTheme.typography.labelLarge)
Row {
KINDS.forEach { candidate ->
FilterChip(
selected = kind == candidate,
onClick = { kind = candidate },
label = { Text(candidate) },
)
Spacer(Modifier.height(0.dp))
}
}
Spacer(Modifier.height(16.dp))
OutlinedTextField(
value = title,
onValueChange = { title = it },
label = { Text("Title") },
placeholder = { Text("Echo session") },
singleLine = true,
modifier = Modifier.fillMaxWidth(),
)
Spacer(Modifier.height(24.dp))
error?.let {
Text(it, color = MaterialTheme.colorScheme.error)
Spacer(Modifier.height(8.dp))
}
Button(
onClick = {
busy = true
scope.launch {
try {
val spawned = withContext(Dispatchers.IO) {
spawnSession(settings, kind, title.trim())
}
onSpawned(spawned)
} catch (e: ApiException) {
error = e.message
busy = false
}
}
},
enabled = !busy,
) { Text(if (busy) "Spawning..." else "Spawn") }
}
}