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
64 lines
3.0 KiB
Kotlin
64 lines
3.0 KiB
Kotlin
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)
|
|
}
|