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,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") }
}
}