Four changes to the session screen, three of them Bryan's and one that
fell out of them.
**/clear is offered like any other session command.** It joins
SESSION_COMMANDS, so it suggests itself while being typed and goes out
through the command endpoint that /compact already uses -- no new path,
and the boundary pump holds it mid-turn exactly as it holds a compaction.
**A clear draws a divider, not a deletion.** `Event::Cleared` becomes a
ClearedNote row saying that everything above stays here and is no longer
sent. That sentence is the row's whole job: the reader can see the
conversation is still on screen, so without it the divider reads as
something having been thrown away, which is the one thing it is not. It
carries no counts, because nothing was measured -- a compaction's
numbers are real and there is no equivalent here to report.
Compaction and clear now share `TranscriptDivider`. They are the same
kind of mark to somebody scrolling back -- "the session no longer has
what is above this" -- and the difference belongs in the words rather
than in how they are drawn, so the styling is written once and cannot
drift.
**The five-hour usage bar sits under the session header.** It reports
the paid service's own metering for the machine this session runs on,
fetched from that machine, refreshed every minute off the backend's
cache. It is never derived from the transcript's token counts: those are
a different quantity measured differently, and a quota-shaped bar built
out of them would be a guess wearing a measurement's clothes. Not
knowing has its own appearance and its own words -- "unknown" and why --
because a bar resting at zero because a machine is unreachable reads as
plenty of headroom, which is the opposite of the truth. The window is
selected by the API's own `kind` ("session"), added to UsageWindow in
this change, rather than by matching the label a person reads.
**The token total moved from the header to the bottom right of the
transcript**, pinned above the input rather than scrolling with it. In
the header it was one item in a run of dot-separated facts about the
session and read as another of them, rather than as the running total it
is.
SessionSummary now carries the setup id, which it deliberately did not.
The stated reason was that nothing here addressed a setup and holding
both id and name invited showing the wrong one; the usage bar addresses
one, so the reason lapsed rather than being overruled, and the comment
now carries the rule that replaces it: never display it. The server has
always sent the field, so nothing changed on the wire.
ktfmt, compileDebugKotlin and lintDebug all clean.
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01VETa8afmpWaYezLCqJhDB8
189 lines
8.3 KiB
Kotlin
189 lines
8.3 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)
|
|
|
|
/**
|
|
* One choice offered in answer to a question.
|
|
*
|
|
* More than a label because the reader is deciding rather than confirming: what an option means,
|
|
* and what picking it would produce, are the things that decide it. Both are absent on a
|
|
* permission, whose Allow and Deny mean exactly what they say.
|
|
*/
|
|
data class QuestionOption(val label: String, val description: String?, val preview: String?)
|
|
|
|
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,
|
|
/** The tool call whose result carried it, or null for a person's own attachment. */
|
|
val about: String?,
|
|
) : SessionEvent()
|
|
|
|
data class Question(
|
|
val id: String,
|
|
val prompt: String,
|
|
/** A few words naming what the question is about, when the asker offered one. */
|
|
val header: String?,
|
|
val options: List<QuestionOption>,
|
|
/** Whether several options may be chosen at once. */
|
|
val multiSelect: Boolean,
|
|
/** The tool call this is permission for, or null when it is not about one. */
|
|
val about: String?,
|
|
) : SessionEvent()
|
|
|
|
/** Everything chosen for one question, in the order it was offered. */
|
|
data class Answered(val id: String, val answers: List<String>) : SessionEvent()
|
|
|
|
/**
|
|
* A message another agent sent this session.
|
|
*
|
|
* Not a [UserMessage]: nobody holding the phone said it, and drawing it in their voice would
|
|
* claim they had. It is also the explanation for a session that starts working on something
|
|
* this device never asked for.
|
|
*/
|
|
data class PeerMessage(val from: String, val text: String) : SessionEvent()
|
|
|
|
/**
|
|
* A command the session was asked to run on itself and cannot run yet.
|
|
*
|
|
* Resolved by [CommandSent] with the same id. A command that ran straight away has only that
|
|
* one, so nothing here ever draws a bubble that resolves in the same frame.
|
|
*/
|
|
data class CommandQueued(val id: String, val text: String) : SessionEvent()
|
|
|
|
/** The same command, handed to the session. */
|
|
data class CommandSent(val id: String, val text: String) : SessionEvent()
|
|
|
|
data class Status(val state: String) : SessionEvent()
|
|
|
|
/**
|
|
* What the session is set to, as the session itself reports it.
|
|
*
|
|
* Either field alone: the two are confirmed separately and by different things. Asking for a
|
|
* change is not having one, so this -- not the request -- is what the pickers show.
|
|
*/
|
|
data class Settings(val model: String?, val permissionMode: String?) : SessionEvent()
|
|
|
|
data class UsageDelta(val tokens: Long) : SessionEvent()
|
|
|
|
/**
|
|
* A compaction that finished, and how much context it recovered.
|
|
*
|
|
* The counts are nullable because the server sends them only when it was told them: a
|
|
* compaction whose size nobody measured has to be able to say so, since a zero here would read
|
|
* as "recovered nothing" and a made-up number would read as a measurement.
|
|
*/
|
|
data class Compacted(
|
|
val preTokens: Long?,
|
|
val postTokens: Long?,
|
|
/** What asked for it, in the CLI's own word; `auto` is the one worth naming. */
|
|
val trigger: String?,
|
|
) : SessionEvent()
|
|
|
|
/**
|
|
* The conversation was cleared. Everything above this is still here to read and is no longer in
|
|
* the session's context.
|
|
*
|
|
* An object rather than a class because it carries nothing: what it means is entirely its
|
|
* position in the transcript.
|
|
*/
|
|
data object Cleared : 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(
|
|
ref = body.getString("ref"),
|
|
about = body.optString("about").ifEmpty { null },
|
|
)
|
|
"question" ->
|
|
SessionEvent.Question(
|
|
id = body.getString("id"),
|
|
prompt = body.getString("prompt"),
|
|
header = body.optString("header").ifEmpty { null },
|
|
options =
|
|
body.getJSONArray("options").let { options ->
|
|
(0 until options.length()).map { at ->
|
|
val option = options.getJSONObject(at)
|
|
QuestionOption(
|
|
label = option.getString("label"),
|
|
description = option.optString("description").ifEmpty { null },
|
|
preview = option.optString("preview").ifEmpty { null },
|
|
)
|
|
}
|
|
},
|
|
multiSelect = body.optBoolean("multiSelect", false),
|
|
about = body.optString("about").ifEmpty { null },
|
|
)
|
|
"answered" ->
|
|
SessionEvent.Answered(
|
|
body.getString("id"),
|
|
body.getJSONArray("answers").let { answers ->
|
|
(0 until answers.length()).map { answers.getString(it) }
|
|
},
|
|
)
|
|
"peerMessage" ->
|
|
SessionEvent.PeerMessage(body.getString("from"), body.getString("text"))
|
|
"commandQueued" ->
|
|
SessionEvent.CommandQueued(body.getString("id"), body.getString("text"))
|
|
"commandSent" -> SessionEvent.CommandSent(body.getString("id"), body.getString("text"))
|
|
"status" -> SessionEvent.Status(body.getString("state"))
|
|
"settings" ->
|
|
SessionEvent.Settings(
|
|
model = body.optString("model").ifEmpty { null },
|
|
permissionMode = body.optString("permissionMode").ifEmpty { null },
|
|
)
|
|
"usageDelta" -> SessionEvent.UsageDelta(body.getLong("tokens"))
|
|
"compacted" ->
|
|
SessionEvent.Compacted(
|
|
preTokens = if (body.has("preTokens")) body.getLong("preTokens") else null,
|
|
postTokens = if (body.has("postTokens")) body.getLong("postTokens") else null,
|
|
trigger = body.optString("trigger").ifEmpty { null },
|
|
)
|
|
"cleared" -> SessionEvent.Cleared
|
|
"error" -> SessionEvent.Error(body.getString("message"))
|
|
else -> SessionEvent.Unknown(type)
|
|
}
|
|
return SeqEvent(seq = body.getLong("seq"), ts = body.getDouble("ts"), event = event)
|
|
}
|