M7: polish + E2E + docs (layout pass, theming, states, e2e driver, schema, setup.md, security)

This commit is contained in:
ARIA committed 2026-08-20 12:00:13 +02:00
1 parent 0cc8b7aafe
commit bf6bf7e8bd
26 files changed
+2225 -327

No files matched your search

@@ -1,9 +1,7 @@
package iris
import androidx.compose.foundation.background
import androidx.compose.material3.MaterialTheme
import androidx.compose.material3.Surface
import androidx.compose.material3.darkColorScheme
import androidx.compose.runtime.Composable
import androidx.compose.runtime.DisposableEffect
import androidx.compose.runtime.LaunchedEffect
@@ -11,21 +9,16 @@ import androidx.compose.runtime.collectAsState
import androidx.compose.runtime.getValue
import androidx.compose.runtime.remember
import androidx.compose.ui.Modifier
import androidx.compose.ui.graphics.Color
import iris.data.SecureStore
import iris.net.GatewayClient
import iris.platform.setActiveController
import iris.state.IrisController
import iris.ui.screens.ChatScreen
import iris.ui.screens.ConnectScreen
private val IrisDark = darkColorScheme(
background = Color(0xFF1B1E28),
surface = Color(0xFF222634),
onBackground = Color(0xFFE8EAF0),
onSurface = Color(0xFFE8EAF0),
primary = Color(0xFF4F7CFF),
)
import iris.ui.screens.ConnectingScreen
import iris.ui.theme.IrisColors
import iris.ui.theme.IrisTheme
import iris.util.hostFromUrl
/**
* Root composable shared by the Android and Desktop shells.
@@ -57,8 +50,8 @@ fun IrisApp(
}
val state by controller.client.state.collectAsState()
MaterialTheme(colorScheme = IrisDark) {
Surface(modifier = Modifier.background(IrisDark.background)) {
IrisTheme {
Surface(modifier = Modifier.background(IrisColors.background)) {
val s = state
when (s) {
GatewayClient.State.Disconnected ->
@@ -70,6 +63,9 @@ fun IrisApp(
prefillToken = store.token,
initialError = "Pairing rejected: ${s.message}",
)
// M7: initial connect in flight — dedicated screen, not an empty chat.
GatewayClient.State.Connecting ->
ConnectingScreen(hostFromUrl(store.serverUrl))
else -> ChatScreen(controller)
}
}
@@ -42,6 +42,14 @@ sealed interface ChatItem {
val id: String
}
/** Delivery status of a user message (M5: read.receipt; M7: failed sends). */
enum class MsgStatus {
Pending, // optimistic, not yet acknowledged by the gateway
Sent, // gateway accepted it (echo received)
Read, // agent received and started processing it (read.receipt)
Failed, // send failed (error frame); tap the bubble to retry
}
/** A chat message (user / assistant / commentary / streaming bubble). */
data class MessageItem(
override val id: String,
@@ -49,6 +57,7 @@ data class MessageItem(
val text: String,
val ts: Long,
val pending: Boolean = false,
val status: MsgStatus = MsgStatus.Sent,
val reasoning: String? = null,
val isCommentary: Boolean = false,
val streaming: Boolean = false,
@@ -139,7 +148,7 @@ class ChatStore {
localSeq++
val id = "local_$localSeq"
updateLane(lane) {
it + MessageItem(id = id, role = ROLE_USER, text = text, ts = 0, pending = true, media = media)
it + MessageItem(id = id, role = ROLE_USER, text = text, ts = 0, pending = true, status = MsgStatus.Pending, media = media)
}
return id
}
@@ -176,6 +185,7 @@ class ChatStore {
reasoning = p.reasoning,
pending = false,
streaming = false,
status = if (cur.status == MsgStatus.Read) MsgStatus.Read else MsgStatus.Sent,
model = p.model,
tokens = p.tokens,
ts = p.ts ?: cur.ts,
@@ -382,6 +392,55 @@ class ChatStore {
if (changed) _lanes.value = map
}
/** M5: mark the user message [messageId] as read (read.receipt). */
fun markRead(messageId: String) {
val map = _lanes.value.toMutableMap()
var changed = false
for ((lane, list) in map) {
val updated = list.map { item ->
if (item is MessageItem && item.id == messageId && item.role == ROLE_USER &&
item.status != MsgStatus.Read
) {
item.copy(pending = false, status = MsgStatus.Read)
} else item
}
if (updated != list) {
map[lane] = updated
changed = true
}
}
if (changed) _lanes.value = map
}
/** M7: mark all pending user messages as failed (gateway error frame). */
fun failPending() {
val map = _lanes.value.toMutableMap()
var changed = false
for ((lane, list) in map) {
val updated = list.map { item ->
if (item is MessageItem && item.role == ROLE_USER && item.status == MsgStatus.Pending) {
item.copy(pending = false, status = MsgStatus.Failed)
} else item
}
if (updated != list) {
map[lane] = updated
changed = true
}
}
if (changed) _lanes.value = map
}
/** M7: re-arm a failed user message for a retry send. */
fun rearmForRetry(lane: String, messageId: String) {
updateLane(lane) { list ->
list.map { item ->
if (item is MessageItem && item.id == messageId && item.status == MsgStatus.Failed) {
item.copy(pending = true, status = MsgStatus.Pending)
} else item
}
}
}
fun clear() {
_lanes.value = emptyMap()
}
@@ -217,7 +217,10 @@ class GatewayClient(
}
TYPE_ERROR -> {
val err = frame.payloadAs<ErrorPayload>()
authError.complete(err?.message ?: "auth failed")
if (!authError.isCompleted) authError.complete(err?.message ?: "auth failed")
// M7: post-connect error frames are app events, not
// auth failures — let the controller react.
_events.tryEmit(frame)
}
TYPE_PONG -> Unit
else -> {
@@ -54,9 +54,11 @@ const val TYPE_MEDIA_OFFER = "media.offer"
const val TYPE_MEDIA_PULL = "media.pull"
const val TYPE_MEDIA_PULL_END = "media.pull.end"
// M5 — push / notifications
// M5 — push / notifications / read receipt / gateway status
const val TYPE_NOTIFICATION = "notification"
const val TYPE_FCM_REGISTER = "fcm.register"
const val TYPE_READ_RECEIPT = "read.receipt"
const val TYPE_STATUS = "status"
// M3 — channels / threads / search / sync
const val TYPE_CHANNEL_CREATE = "channel.create"
@@ -383,6 +385,16 @@ data class FcmRegisterPayload(
@SerialName("ntfy_topic") val ntfyTopic: String? = null,
)
// ── M5: read receipt / gateway status (server -> app) ─────────────────────
@Serializable
data class ReadReceiptPayload(
@SerialName("message_id") val messageId: String,
)
@Serializable
data class StatusPayload(val state: String)
// ── Frame builders ──────────────────────────────────────────────────────
fun helloFrame(
@@ -3,6 +3,8 @@ package iris.state
import iris.data.ChatStore
import iris.data.ChannelStore
import iris.data.MediaItem
import iris.data.MessageItem
import iris.data.MsgStatus
import iris.data.SecureStore
import iris.media.MediaCache
import iris.media.kindFromMime
@@ -16,10 +18,13 @@ import iris.protocol.MediaOfferPayload
import iris.protocol.MessagePayload
import iris.protocol.MessageStopPayload
import iris.protocol.NotificationPayload
import iris.protocol.ReadReceiptPayload
import iris.protocol.ROLE_ASSISTANT
import iris.protocol.SearchHit
import iris.protocol.SearchResultsPayload
import iris.protocol.StatusPayload
import iris.protocol.SyncDonePayload
import iris.protocol.TYPE_ERROR
import iris.protocol.TYPE_NOTIFICATION
import iris.protocol.TYPE_CHANNEL_CREATED
import iris.protocol.TYPE_CHANNEL_DELETED
@@ -31,7 +36,9 @@ import iris.protocol.TYPE_MESSAGE
import iris.protocol.TYPE_MESSAGE_START
import iris.protocol.TYPE_MESSAGE_STOP
import iris.protocol.TYPE_MESSAGE_UPDATE
import iris.protocol.TYPE_READ_RECEIPT
import iris.protocol.TYPE_SEARCH_RESULTS
import iris.protocol.TYPE_STATUS
import iris.protocol.TYPE_SYNC_DONE
import iris.protocol.TYPE_TOOL_END
import iris.protocol.TYPE_TOOL_PROGRESS
@@ -89,6 +96,10 @@ class IrisController(
private val _homeChannel = MutableStateFlow("android:default")
val homeChannel: StateFlow<String> = _homeChannel.asStateFlow()
/** Gateway health state (M5: status frame; null = never received). */
private val _gatewayStatus = MutableStateFlow<String?>(null)
val gatewayStatus: StateFlow<String?> = _gatewayStatus.asStateFlow()
// ── M3: threads toggle (per-app for now; per-channel lands later) ─────
private val _threadsEnabled = MutableStateFlow(false)
val threadsEnabled: StateFlow<Boolean> = _threadsEnabled.asStateFlow()
@@ -259,6 +270,18 @@ class IrisController(
TYPE_TYPING -> {
frame.payloadAs<TypingPayload>()?.let { _typing.value = it.on }
}
TYPE_READ_RECEIPT -> {
frame.payloadAs<ReadReceiptPayload>()?.let { chat.markRead(it.messageId) }
}
TYPE_STATUS -> {
frame.payloadAs<StatusPayload>()?.let { _gatewayStatus.value = it.state }
}
TYPE_ERROR -> {
// M7: error frames are global, not per-message — fail
// any optimistic sends still in flight so they don't
// sit at "sending…" forever.
chat.failPending()
}
else -> Unit
}
}
@@ -375,6 +398,16 @@ class IrisController(
_attachments.value = emptyList()
}
/** M7: resend a failed user message (tap on the failed bubble). */
fun retrySend(messageId: String) {
val lane = chat.currentLane.value
val (chatId, threadId) = chat.parseLane(lane)
val item = chat.lanes.value[lane]?.firstOrNull { it.id == messageId } as? MessageItem ?: return
if (item.status != MsgStatus.Failed) return
chat.rearmForRetry(lane, messageId)
client.sendMessage(chatId, item.text, threadId, item.media.map { it.mediaId })
}
// ── M4: attachments (pick -> upload -> send) ──────────────────────────
/** Stage a picked file: upload it, then keep it as a pending attachment. */
File diff suppressed because it is too large. Load diff
@@ -1,5 +1,6 @@
package iris.ui.screens
import androidx.compose.foundation.background
import androidx.compose.foundation.layout.Arrangement
import androidx.compose.foundation.layout.Box
import androidx.compose.foundation.layout.Column
@@ -9,11 +10,12 @@ import androidx.compose.foundation.layout.fillMaxWidth
import androidx.compose.foundation.layout.height
import androidx.compose.foundation.layout.padding
import androidx.compose.foundation.rememberScrollState
import androidx.compose.foundation.shape.RoundedCornerShape
import androidx.compose.foundation.text.KeyboardOptions
import androidx.compose.foundation.verticalScroll
import androidx.compose.material3.Button
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.runtime.Composable
@@ -24,10 +26,12 @@ import androidx.compose.runtime.rememberCoroutineScope
import androidx.compose.runtime.setValue
import androidx.compose.ui.Alignment
import androidx.compose.ui.Modifier
import androidx.compose.ui.draw.clip
import androidx.compose.ui.text.input.KeyboardType
import androidx.compose.ui.text.input.PasswordVisualTransformation
import androidx.compose.ui.unit.dp
import iris.state.IrisController
import iris.ui.theme.IrisColors
import kotlinx.coroutines.launch
/**
@@ -64,53 +68,61 @@ fun ConnectScreen(
)
Spacer(modifier = Modifier.height(32.dp))
OutlinedTextField(
value = url,
onValueChange = { url = it },
label = { Text("Server URL") },
placeholder = { Text("ws://192.168.1.10:8790/ws") },
singleLine = true,
keyboardOptions = KeyboardOptions(keyboardType = KeyboardType.Uri),
modifier = Modifier.fillMaxWidth(),
)
Spacer(modifier = Modifier.height(12.dp))
OutlinedTextField(
value = token,
onValueChange = { token = it },
label = { Text("Pairing token") },
placeholder = { Text("ANDROID_TOKEN (64 hex)") },
singleLine = true,
visualTransformation = PasswordVisualTransformation(),
modifier = Modifier.fillMaxWidth(),
)
Spacer(modifier = Modifier.height(24.dp))
Button(
onClick = {
if (busy) return@Button
busy = true
error = null
scope.launch {
val result = controller.connect(url.trim(), token.trim())
busy = false
if (result.isFailure) {
error = result.exceptionOrNull()?.message ?: "connection failed"
}
}
},
enabled = !busy,
modifier = Modifier.fillMaxWidth(),
Column(
modifier = Modifier
.fillMaxWidth()
.clip(RoundedCornerShape(16.dp))
.background(IrisColors.surface)
.padding(16.dp),
) {
Text(if (busy) "Testing connection…" else "Test & Connect")
}
if (error != null) {
Spacer(modifier = Modifier.height(16.dp))
Text(
error!!,
color = MaterialTheme.colorScheme.error,
style = MaterialTheme.typography.bodyMedium,
OutlinedTextField(
value = url,
onValueChange = { url = it },
label = { Text("Server URL") },
placeholder = { Text("ws://192.168.1.10:8790/ws") },
singleLine = true,
keyboardOptions = KeyboardOptions(keyboardType = KeyboardType.Uri),
modifier = Modifier.fillMaxWidth(),
)
Spacer(modifier = Modifier.height(12.dp))
OutlinedTextField(
value = token,
onValueChange = { token = it },
label = { Text("Pairing token") },
placeholder = { Text("ANDROID_TOKEN (64 hex)") },
singleLine = true,
visualTransformation = PasswordVisualTransformation(),
modifier = Modifier.fillMaxWidth(),
)
Spacer(modifier = Modifier.height(24.dp))
Button(
onClick = {
if (busy) return@Button
busy = true
error = null
scope.launch {
val result = controller.connect(url.trim(), token.trim())
busy = false
if (result.isFailure) {
error = result.exceptionOrNull()?.message ?: "connection failed"
}
}
},
enabled = !busy,
modifier = Modifier.fillMaxWidth(),
) {
Text(if (busy) "Testing connection…" else "Test & Connect")
}
if (error != null) {
Spacer(modifier = Modifier.height(16.dp))
Text(
error!!,
color = MaterialTheme.colorScheme.error,
style = MaterialTheme.typography.bodyMedium,
)
}
}
Spacer(modifier = Modifier.height(24.dp))
@@ -121,4 +133,22 @@ fun ConnectScreen(
color = MaterialTheme.colorScheme.onSurfaceVariant,
)
}
}
/** M7: shown while the initial connection is in flight (state == Connecting). */
@Composable
fun ConnectingScreen(host: String) {
Box(modifier = Modifier.fillMaxSize(), contentAlignment = Alignment.Center) {
Column(
horizontalAlignment = Alignment.CenterHorizontally,
verticalArrangement = Arrangement.spacedBy(16.dp),
) {
CircularProgressIndicator()
Text(
"Connecting to $host…",
style = MaterialTheme.typography.bodyMedium,
color = MaterialTheme.colorScheme.onSurfaceVariant,
)
}
}
}
@@ -0,0 +1,72 @@
package iris.ui.theme
import androidx.compose.material3.MaterialTheme
import androidx.compose.material3.darkColorScheme
import androidx.compose.runtime.Composable
import androidx.compose.ui.graphics.Color
/**
* Centralized palette (M7). Dark is the only theme; every screen reads colors
* from here instead of hard-coding literals.
*/
object IrisColors {
// Base
val background = Color(0xFF1B1E28)
val surface = Color(0xFF222634)
val onBackground = Color(0xFFE8EAF0)
val onSurface = Color(0xFFE8EAF0)
val primary = Color(0xFF4F7CFF)
// Text shades (brightest to dimmest)
val textBright = Color(0xFFD7DBE5)
val textTertiary = Color(0xFFC7CCD8)
val textSecondary = Color(0xFFB9C0D0)
val textMuted = Color(0xFF9AA3B5)
val textDim = Color(0xFF8A93A6)
// Bubbles
val bubbleUser = primary
val bubbleAssistant = Color(0xFF2A2E3B)
val bubbleCommentary = Color(0xFF23262F)
// Panels, chips, rows
val panel = Color(0xFF20242E)
val chip = Color(0xFF2A2E3B)
val chipSelected = Color(0xFF2A3550)
val rowSelected = Color(0xFF232838)
val reasoningPanel = Color(0xFF1E212B)
val divider = Color(0xFF2A2E3B)
// Status
val statusGrey = Color(0xFF9E9E9E)
val statusAmber = Color(0xFFFFC107)
val statusGreen = Color(0xFF4CAF50)
val statusRed = Color(0xFFF44336)
// M7: failed-send hint on the accent user bubble
val errorText = Color(0xFFFF8A80)
}
private val IrisDark = darkColorScheme(
background = IrisColors.background,
surface = IrisColors.surface,
onBackground = IrisColors.onBackground,
onSurface = IrisColors.onSurface,
onSurfaceVariant = IrisColors.textDim,
primary = IrisColors.primary,
onPrimary = Color.White,
error = IrisColors.statusRed,
)
@Composable
fun IrisTheme(content: @Composable () -> Unit) {
MaterialTheme(colorScheme = IrisDark) {
content()
}
}
/** Deterministic pastel avatar color from a channel name (M7 rail/drawer). */
fun avatarColor(name: String): Color {
val hue = ((name.hashCode() and 0x7FFFFFFF) % 360).toFloat()
return Color.hsv(hue, 0.5f, 0.75f)
}
@@ -0,0 +1,16 @@
package iris.util
/** Locale-aware day label for chat date separators (M7), e.g. "7. August". */
expect fun formatDayLabel(epochMillis: Long): String
/** Locale-aware "HH:mm" time for bubble timestamps (M7). */
expect fun formatTime(epochMillis: Long): String
/** Local calendar-day key used to detect date changes between messages (M7). */
expect fun localDayKey(epochMillis: Long): String
/** Host part of a pairing URL ("ws://host:port/ws" -> "host:port"). */
fun hostFromUrl(url: String): String {
val noScheme = url.trim().substringAfter("://")
return noScheme.substringBefore("/").ifBlank { url.trim() }
}