M3: channels/threads + cron delivery + search + outbox sync

Gateway plugin:
- ChannelDirectory (SQLite): channels + threads, friendly-name resolution
- FTS5 search bridge over state.db (scope all/chat, LIKE fallback)
- Outbox (monotonic cursor, 72h retention) for reconnect catch-up
- adapter: channel.* handlers, search, sync, cron target parsing
- ws_server: M3 frame routing + hello.ack sync cursor

App (KMP):
- ChannelStore (directory cache) + lane-aware ChatStore (per chat/thread)
- GatewayClient.sendFrame; IrisController channel/search/nav actions
- ChatScreen: channel drawer, thread toggle, topic switcher, search overlay
This commit is contained in:
ARIA committed 2026-08-19 14:53:48 +02:00
1 parent 218c50d688
commit 9b511fdd28
13 files changed
+2035 -259

No files matched your search

@@ -0,0 +1,80 @@
package iris.data
import iris.protocol.ChannelDeletedPayload
import iris.protocol.ChannelInfo
import iris.protocol.ChannelListPayload
import iris.protocol.Frame
import iris.protocol.TYPE_CHANNEL_CREATED
import iris.protocol.TYPE_CHANNEL_DELETED
import iris.protocol.TYPE_CHANNEL_LIST
import iris.protocol.TYPE_CHANNEL_RENAMED
import kotlinx.coroutines.flow.MutableStateFlow
import kotlinx.coroutines.flow.StateFlow
import kotlinx.coroutines.flow.asStateFlow
/**
* Local channel-directory cache (M3). The server is authoritative; this cache
* is seeded from `hello.ack` and reconciled on `channel.*` events (merge, don't
* clobber). Holds both channels and threads; helpers split them for the UI.
*/
class ChannelStore {
private val _channels = MutableStateFlow<List<ChannelInfo>>(emptyList())
val channels: StateFlow<List<ChannelInfo>> = _channels.asStateFlow()
/** Seed / replace the whole directory (from `hello.ack` or `channel.list`). */
fun setAll(channels: List<ChannelInfo>) {
_channels.value = sorted(channels)
}
/** Reconcile a server frame into the cache. */
fun onFrame(frame: Frame) {
when (frame.type) {
TYPE_CHANNEL_CREATED, TYPE_CHANNEL_RENAMED -> upsert(frame)
TYPE_CHANNEL_DELETED -> remove(frame)
TYPE_CHANNEL_LIST -> {
frame.payloadAs<ChannelListPayload>()?.let { setAll(it.channels) }
}
else -> Unit
}
}
private fun upsert(frame: Frame) {
val info = frame.payloadAs<ChannelInfo>() ?: return
val list = _channels.value.toMutableList()
val idx = list.indexOfFirst { it.chatId == info.chatId }
if (idx >= 0) list[idx] = info else list.add(info)
_channels.value = sorted(list)
}
private fun remove(frame: Frame) {
val p = frame.payloadAs<ChannelDeletedPayload>() ?: return
_channels.value = _channels.value.filter { it.chatId != p.chatId }
}
private fun sorted(list: List<ChannelInfo>): List<ChannelInfo> =
list.sortedWith(
compareByDescending<ChannelInfo> { it.isDefault }
.thenBy { it.kind != "thread" }
.thenBy { it.name.lowercase() },
)
// ── UI helpers ────────────────────────────────────────────────────────
/** Channels (default + user channels) for the drawer/rail. */
fun channelsForDrawer(): List<ChannelInfo> =
_channels.value.filter { it.kind != "thread" }
/** Threads under a channel, for the topic switcher. */
fun threadsFor(chatId: String): List<ChannelInfo> =
_channels.value.filter { it.kind == "thread" && it.parentChatId == chatId }
fun byId(chatId: String): ChannelInfo? =
_channels.value.firstOrNull { it.chatId == chatId }
fun defaultChannel(): ChannelInfo? =
_channels.value.firstOrNull { it.isDefault } ?: _channels.value.firstOrNull()
fun clear() {
_channels.value = emptyList()
}
}
@@ -26,11 +26,14 @@ import kotlinx.serialization.json.JsonElement
import kotlin.random.Random
/**
* In-memory chat cache (M2). The server is authoritative; this is a cache that
* reconciles on server frames. M2 adds streaming bubbles (message.start/
* update/stop), structured tool cards (tool.start/progress/end), and dimmed
* commentary beats, all interleaved in chronological order as [ChatItem]s.
* SQLDelight persistence lands with the channel/message schema later.
* Lane-aware chat cache (M3). The server is authoritative; this is a cache that
* reconciles on server frames.
*
* M2 kept a single flat list (one chat). M3 introduces channels + threads, so
* messages are stored per **lane**: a lane is a `chatId` (the flat / "General"
* lane, `thread_id=null`) or `chatId:threadId` (a topic). Each lane keeps its
* own chronological list of [ChatItem]s (messages, streaming bubbles, tool
* cards, commentary). The UI views one lane at a time ([currentLane]).
*/
sealed interface ChatItem {
val id: String
@@ -65,199 +68,234 @@ data class ToolItem(
) : ChatItem
class ChatStore {
private val _items = MutableStateFlow<List<ChatItem>>(emptyList())
val items: StateFlow<List<ChatItem>> = _items.asStateFlow()
/** lane key -> chronological items. */
private val _lanes = MutableStateFlow<Map<String, List<ChatItem>>>(emptyMap())
val lanes: StateFlow<Map<String, List<ChatItem>>> = _lanes.asStateFlow()
/** The lane the UI is currently viewing. */
private val _currentLane = MutableStateFlow(DEFAULT_LANE)
val currentLane: StateFlow<String> = _currentLane.asStateFlow()
private var localSeq = 0
private var toolSeq = 0
/** Optimistic add: show the user's message immediately (pending). */
fun addPending(text: String): String {
companion object {
const val DEFAULT_LANE = "android:default"
fun randomId(prefix: String): String =
"${prefix}${Random.nextLong(1_000_000_000L, 9_999_999_999L)}"
}
// ── Lane helpers ──────────────────────────────────────────────────────
/** Lane key for a (chat, thread) pair. Uses `::` as the separator because
* chat ids already contain a single `:` (e.g. `android:chan_1`). */
fun laneKey(chatId: String, threadId: String?): String =
if (threadId.isNullOrBlank()) chatId else "$chatId::$threadId"
/** Split a lane key back into (chatId, threadId). */
fun parseLane(key: String): Pair<String, String?> {
val idx = key.indexOf("::")
return if (idx >= 0) key.substring(0, idx) to key.substring(idx + 2) else key to null
}
fun setLane(laneKey: String) {
if (laneKey.isNotBlank()) _currentLane.value = laneKey
}
private fun laneOf(frame: Frame): String? {
val chatId = frame.chatId ?: return null
return laneKey(chatId, frame.threadId)
}
private fun updateLane(lane: String, transform: (List<ChatItem>) -> List<ChatItem>) {
val map = _lanes.value.toMutableMap()
map[lane] = transform(map[lane].orEmpty())
_lanes.value = map
}
// ── Optimistic send ───────────────────────────────────────────────────
/** Optimistic add: show the user's message immediately (pending) in [lane]. */
fun addPending(text: String, lane: String): String {
localSeq++
val id = "local_$localSeq"
_items.value = _items.value +
MessageItem(id = id, role = ROLE_USER, text = text, ts = 0, pending = true)
updateLane(lane) { it + MessageItem(id = id, role = ROLE_USER, text = text, ts = 0, pending = true) }
return id
}
/** Reconcile a server frame into the cache. */
// ── Frame reconciliation ──────────────────────────────────────────────
/** Reconcile a server frame into the cache (routed by chat/thread lane). */
fun onFrame(frame: Frame) {
val lane = laneOf(frame) ?: return
when (frame.type) {
TYPE_MESSAGE -> onMessage(frame)
TYPE_MESSAGE_START -> onMessageStart(frame)
TYPE_MESSAGE_UPDATE -> onMessageUpdate(frame)
TYPE_MESSAGE_STOP -> onMessageStop(frame)
TYPE_TOOL_START -> onToolStart(frame)
TYPE_TOOL_PROGRESS -> onToolProgress(frame)
TYPE_TOOL_END -> onToolEnd(frame)
TYPE_COMMENTARY -> onCommentary(frame)
TYPE_MESSAGE -> onMessage(lane, frame)
TYPE_MESSAGE_START -> onMessageStart(lane, frame)
TYPE_MESSAGE_UPDATE -> onMessageUpdate(lane, frame)
TYPE_MESSAGE_STOP -> onMessageStop(lane, frame)
TYPE_TOOL_START -> onToolStart(lane, frame)
TYPE_TOOL_PROGRESS -> onToolProgress(lane, frame)
TYPE_TOOL_END -> onToolEnd(lane, frame)
TYPE_COMMENTARY -> onCommentary(lane, frame)
else -> Unit
}
}
// ── message (final / standalone, incl. non-streaming + user echo) ─────
private fun onMessage(frame: Frame) {
private fun onMessage(lane: String, frame: Frame) {
val p = frame.payloadAs<MessagePayload>() ?: return
val list = _items.value.toMutableList()
val byId = list.indexOfFirst { it.id == p.messageId }
if (byId >= 0) {
list[byId] = (list[byId] as? MessageItem)?.copy(
text = p.text,
reasoning = p.reasoning,
pending = false,
streaming = false,
model = p.model,
tokens = p.tokens,
ts = p.ts ?: 0,
) ?: return
_items.value = list
return
}
if (p.role == ROLE_USER) {
// Replace the matching optimistic pending bubble (server echo).
val pendingIdx = list.indexOfLast {
it is MessageItem && it.pending && it.role == ROLE_USER && it.text == p.text
}
if (pendingIdx >= 0) {
list[pendingIdx] = MessageItem(
id = p.messageId, role = p.role, text = p.text,
ts = p.ts ?: 0, reasoning = p.reasoning,
updateLane(lane) { list ->
val byId = list.indexOfFirst { it.id == p.messageId }
if (byId >= 0) {
val updated = (list[byId] as? MessageItem)?.copy(
text = p.text,
reasoning = p.reasoning,
pending = false,
streaming = false,
model = p.model,
tokens = p.tokens,
ts = p.ts ?: 0,
) ?: return@updateLane list
list.toMutableList().also { it[byId] = updated }
} else if (p.role == ROLE_USER) {
// Replace the matching optimistic pending bubble (server echo).
val pendingIdx = list.indexOfLast {
it is MessageItem && it.pending && it.role == ROLE_USER && it.text == p.text
}
if (pendingIdx >= 0) {
list.toMutableList().also {
it[pendingIdx] = MessageItem(
id = p.messageId, role = p.role, text = p.text,
ts = p.ts ?: 0, reasoning = p.reasoning,
)
}
} else {
list + MessageItem(
id = p.messageId, role = p.role, text = p.text, ts = p.ts ?: 0,
reasoning = p.reasoning, model = p.model, tokens = p.tokens,
)
}
} else {
list + MessageItem(
id = p.messageId, role = p.role, text = p.text, ts = p.ts ?: 0,
reasoning = p.reasoning, model = p.model, tokens = p.tokens,
)
_items.value = list
return
}
}
list.add(
MessageItem(
id = p.messageId, role = p.role, text = p.text, ts = p.ts ?: 0,
reasoning = p.reasoning, model = p.model, tokens = p.tokens,
),
)
_items.value = list
}
// ── message.start (open a live streaming bubble) ──────────────────────
private fun onMessageStart(frame: Frame) {
private fun onMessageStart(lane: String, frame: Frame) {
val p = frame.payloadAs<MessageStartPayload>() ?: return
val list = _items.value.toMutableList()
if (list.any { it.id == p.messageId }) return
list.add(
MessageItem(
id = p.messageId, role = p.role, text = "",
ts = 0, streaming = true,
),
)
_items.value = list
updateLane(lane) { list ->
if (list.any { it.id == p.messageId }) list
else list + MessageItem(
id = p.messageId, role = p.role, text = "", ts = 0, streaming = true,
)
}
}
// ── message.update (replace live bubble text; full snapshot) ──────────
private fun onMessageUpdate(frame: Frame) {
private fun onMessageUpdate(lane: String, frame: Frame) {
val p = frame.payloadAs<MessageUpdatePayload>() ?: return
val list = _items.value.toMutableList()
val idx = list.indexOfFirst { it.id == p.messageId }
if (idx < 0) return
val cur = list[idx] as? MessageItem ?: return
list[idx] = cur.copy(text = p.text)
_items.value = list
updateLane(lane) { list ->
val idx = list.indexOfFirst { it.id == p.messageId }
if (idx < 0) return@updateLane list
val cur = list[idx] as? MessageItem ?: return@updateLane list
list.toMutableList().also { it[idx] = cur.copy(text = p.text) }
}
}
// ── message.stop (finalize the live bubble) ───────────────────────────
private fun onMessageStop(frame: Frame) {
private fun onMessageStop(lane: String, frame: Frame) {
val p = frame.payloadAs<MessageStopPayload>() ?: return
val list = _items.value.toMutableList()
val idx = list.indexOfFirst { it.id == p.messageId }
if (idx < 0) {
// No live bubble (e.g. missed start) — materialize a final one.
list.add(
MessageItem(
updateLane(lane) { list ->
val idx = list.indexOfFirst { it.id == p.messageId }
if (idx < 0) {
// No live bubble (e.g. missed start) — materialize a final one.
list + MessageItem(
id = p.messageId, role = ROLE_ASSISTANT, text = p.finalText,
ts = p.ts ?: 0, reasoning = p.reasoning,
model = p.model, tokens = p.tokens,
),
)
_items.value = list
return
)
} else {
val cur = list[idx] as? MessageItem ?: return@updateLane list
list.toMutableList().also {
it[idx] = cur.copy(
text = p.finalText,
reasoning = p.reasoning,
model = p.model,
tokens = p.tokens,
streaming = false,
ts = p.ts ?: cur.ts,
)
}
}
}
val cur = list[idx] as? MessageItem ?: return
list[idx] = cur.copy(
text = p.finalText,
reasoning = p.reasoning,
model = p.model,
tokens = p.tokens,
streaming = false,
ts = p.ts ?: cur.ts,
)
_items.value = list
}
// ── tool.start (new tool card) ────────────────────────────────────────
private fun onToolStart(frame: Frame) {
private fun onToolStart(lane: String, frame: Frame) {
val p = frame.payloadAs<ToolStartPayload>() ?: return
toolSeq++
val id = "tool_$toolSeq"
val list = _items.value.toMutableList()
list.add(
ToolItem(
updateLane(lane) { list ->
list + ToolItem(
id = id, index = p.index, name = p.name,
preview = p.preview, args = p.args,
),
)
_items.value = list
)
}
}
// ── tool.progress (in-progress note) ──────────────────────────────────
private fun onToolProgress(frame: Frame) {
private fun onToolProgress(lane: String, frame: Frame) {
val p = frame.payloadAs<ToolProgressPayload>() ?: return
val list = _items.value.toMutableList()
val idx = list.indexOfLast { it is ToolItem && !it.done && it.index == p.index }
if (idx < 0) return
val cur = list[idx] as ToolItem
list[idx] = cur.copy(note = p.note)
_items.value = list
updateLane(lane) { list ->
val idx = list.indexOfLast { it is ToolItem && !it.done && it.index == p.index }
if (idx < 0) return@updateLane list
val cur = list[idx] as ToolItem
list.toMutableList().also { it[idx] = cur.copy(note = p.note) }
}
}
// ── tool.end (mark the tool card complete) ────────────────────────────
private fun onToolEnd(frame: Frame) {
private fun onToolEnd(lane: String, frame: Frame) {
val p = frame.payloadAs<ToolEndPayload>() ?: return
val list = _items.value.toMutableList()
val idx = list.indexOfLast { it is ToolItem && !it.done && it.index == p.index }
if (idx < 0) return
val cur = list[idx] as ToolItem
list[idx] = cur.copy(
done = true, ok = p.ok, duration = p.duration,
outputPreview = p.outputPreview,
)
_items.value = list
updateLane(lane) { list ->
val idx = list.indexOfLast { it is ToolItem && !it.done && it.index == p.index }
if (idx < 0) return@updateLane list
val cur = list[idx] as ToolItem
list.toMutableList().also {
it[idx] = cur.copy(
done = true, ok = p.ok, duration = p.duration,
outputPreview = p.outputPreview,
)
}
}
}
// ── commentary (dimmed interim beat) ──────────────────────────────────
private fun onCommentary(frame: Frame) {
private fun onCommentary(lane: String, frame: Frame) {
val p = frame.payloadAs<CommentaryPayload>() ?: return
val list = _items.value.toMutableList()
if (list.any { it.id == p.messageId }) return
list.add(
MessageItem(
updateLane(lane) { list ->
if (list.any { it.id == p.messageId }) list
else list + MessageItem(
id = p.messageId, role = ROLE_ASSISTANT, text = p.text,
ts = 0, isCommentary = true,
),
)
_items.value = list
)
}
}
fun clear() {
_items.value = emptyList()
}
companion object {
fun randomId(prefix: String): String =
"${prefix}${Random.nextLong(1_000_000_000L, 9_999_999_999L)}"
_lanes.value = emptyMap()
}
}
@@ -222,6 +222,18 @@ class GatewayClient(
ws.send(messageSendFrame(id, chatId, text, threadId).toWire())
}
/**
* Send an arbitrary frame with a fresh request id (fire-and-forget).
* The server replies (or broadcasts) a frame carrying the same id; the
* app reconciles from [events]. Returns the id used, or -1 if not connected.
*/
fun sendFrame(frame: Frame): Int {
val ws = socket ?: return -1
val id = nextRequestId++
ws.send(frame.copy(id = id).toWire())
return id
}
/** Send a ping (heartbeat). */
fun ping() {
socket?.send(pingFrame().toWire())
@@ -46,6 +46,20 @@ const val TYPE_TOOL_PROGRESS = "tool.progress"
const val TYPE_TOOL_END = "tool.end"
const val TYPE_COMMENTARY = "commentary"
// M3 — channels / threads / search / sync
const val TYPE_CHANNEL_CREATE = "channel.create"
const val TYPE_CHANNEL_RENAME = "channel.rename"
const val TYPE_CHANNEL_SET_DEFAULT = "channel.set_default"
const val TYPE_CHANNEL_DELETE = "channel.delete"
const val TYPE_CHANNEL_CREATED = "channel.created"
const val TYPE_CHANNEL_RENAMED = "channel.renamed"
const val TYPE_CHANNEL_DELETED = "channel.deleted"
const val TYPE_CHANNEL_LIST = "channel.list"
const val TYPE_SEARCH = "search"
const val TYPE_SEARCH_RESULTS = "search.results"
const val TYPE_SYNC = "sync"
const val TYPE_SYNC_DONE = "sync.done"
// ── Error codes ─────────────────────────────────────────────────────────
const val ERR_AUTH = "auth"
@@ -110,6 +124,8 @@ data class ChannelInfo(
val name: String,
val kind: String = "channel",
@SerialName("is_default") val isDefault: Boolean = false,
@SerialName("parent_chat_id") val parentChatId: String? = null,
val archived: Boolean = false,
)
@Serializable
@@ -211,6 +227,60 @@ data class ErrorPayload(val code: String, val message: String)
@Serializable
data class PingPayload(val ts: Long? = null)
// ── M3: channel directory (app -> server requests) ──────────────────────
@Serializable
data class ChannelCreatePayload(
val name: String,
val kind: String = "channel",
@SerialName("parent_chat_id") val parentChatId: String? = null,
)
@Serializable
data class ChannelRenamePayload(val name: String)
@Serializable
data class ChannelListPayload(val channels: List<ChannelInfo> = emptyList())
@Serializable
data class ChannelDeletedPayload(@SerialName("chat_id") val chatId: String)
// ── M3: search ──────────────────────────────────────────────────────────
@Serializable
data class SearchPayload(
val query: String,
val scope: String = "all",
@SerialName("chat_id") val chatId: String? = null,
@SerialName("thread_id") val threadId: String? = null,
val limit: Int? = null,
)
@Serializable
data class SearchHit(
@SerialName("message_id") val messageId: String,
@SerialName("chat_id") val chatId: String,
@SerialName("thread_id") val threadId: String? = null,
val role: String,
val snippet: String,
val ts: Long,
)
@Serializable
data class SearchResultsPayload(
val query: String,
val scope: String,
val hits: List<SearchHit> = emptyList(),
)
// ── M3: sync (reconnect catch-up) ───────────────────────────────────────
@Serializable
data class SyncPayload(val cursor: Long)
@Serializable
data class SyncDonePayload(val cursor: Long)
// ── Frame builders ──────────────────────────────────────────────────────
fun helloFrame(token: String, deviceId: String, deviceName: String): Frame =
@@ -235,4 +305,64 @@ fun messageSendFrame(id: Int, chatId: String, text: String, threadId: String? =
)
fun pingFrame(): Frame =
Frame(type = TYPE_PING, payload = IrisJson.instance.encodeToJsonElement(PingPayload.serializer(), PingPayload()))
Frame(type = TYPE_PING, payload = IrisJson.instance.encodeToJsonElement(PingPayload.serializer(), PingPayload()))
// ── M3 frame builders ───────────────────────────────────────────────────
fun channelCreateFrame(id: Int, name: String, kind: String = "channel", parentChatId: String? = null): Frame =
Frame(
id = id,
type = TYPE_CHANNEL_CREATE,
payload = IrisJson.instance.encodeToJsonElement(
ChannelCreatePayload.serializer(),
ChannelCreatePayload(name = name, kind = kind, parentChatId = parentChatId),
),
)
fun channelRenameFrame(id: Int, chatId: String, name: String): Frame =
Frame(
id = id,
type = TYPE_CHANNEL_RENAME,
chatId = chatId,
payload = IrisJson.instance.encodeToJsonElement(
ChannelRenamePayload.serializer(),
ChannelRenamePayload(name = name),
),
)
fun channelSetDefaultFrame(id: Int, chatId: String): Frame =
Frame(id = id, type = TYPE_CHANNEL_SET_DEFAULT, chatId = chatId)
fun channelDeleteFrame(id: Int, chatId: String): Frame =
Frame(id = id, type = TYPE_CHANNEL_DELETE, chatId = chatId)
fun channelListFrame(id: Int): Frame =
Frame(id = id, type = TYPE_CHANNEL_LIST)
fun searchFrame(
id: Int,
query: String,
scope: String,
chatId: String? = null,
threadId: String? = null,
): Frame =
Frame(
id = id,
type = TYPE_SEARCH,
chatId = chatId,
threadId = threadId,
payload = IrisJson.instance.encodeToJsonElement(
SearchPayload.serializer(),
SearchPayload(query = query, scope = scope, chatId = chatId, threadId = threadId),
),
)
fun syncFrame(id: Int, cursor: Long): Frame =
Frame(
id = id,
type = TYPE_SYNC,
payload = IrisJson.instance.encodeToJsonElement(
SyncPayload.serializer(),
SyncPayload(cursor = cursor),
),
)
@@ -1,18 +1,35 @@
package iris.state
import iris.data.ChatStore
import iris.data.ChannelStore
import iris.data.SecureStore
import iris.net.GatewayClient
import iris.protocol.SearchHit
import iris.protocol.SearchResultsPayload
import iris.protocol.SyncDonePayload
import iris.protocol.TYPE_CHANNEL_CREATED
import iris.protocol.TYPE_CHANNEL_DELETED
import iris.protocol.TYPE_CHANNEL_LIST
import iris.protocol.TYPE_CHANNEL_RENAMED
import iris.protocol.TYPE_COMMENTARY
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_SEARCH_RESULTS
import iris.protocol.TYPE_SYNC_DONE
import iris.protocol.TYPE_TOOL_END
import iris.protocol.TYPE_TOOL_PROGRESS
import iris.protocol.TYPE_TOOL_START
import iris.protocol.TYPE_TYPING
import iris.protocol.TypingPayload
import iris.protocol.channelCreateFrame
import iris.protocol.channelDeleteFrame
import iris.protocol.channelListFrame
import iris.protocol.channelRenameFrame
import iris.protocol.channelSetDefaultFrame
import iris.protocol.searchFrame
import iris.protocol.syncFrame
import kotlinx.coroutines.CoroutineScope
import kotlinx.coroutines.Dispatchers
import kotlinx.coroutines.SupervisorJob
@@ -22,9 +39,9 @@ import kotlinx.coroutines.flow.asStateFlow
import kotlinx.coroutines.launch
/**
* App-level controller (M2): owns the GatewayClient + ChatStore, routes
* server frames (message / streaming / tool / commentary / typing) into
* state, and exposes the actions the UI needs.
* App-level controller (M3): owns the GatewayClient + ChatStore + ChannelStore,
* routes server frames (message / streaming / tool / commentary / typing /
* channel.* / search / sync) into state, and exposes the actions the UI needs.
*/
class IrisController(
private val store: SecureStore,
@@ -34,6 +51,7 @@ class IrisController(
val client = GatewayClient(scope, store)
val chat = ChatStore()
val channels = ChannelStore()
private val _typing = MutableStateFlow(false)
val typing: StateFlow<Boolean> = _typing.asStateFlow()
@@ -54,6 +72,22 @@ class IrisController(
private val _homeChannel = MutableStateFlow("android:default")
val homeChannel: StateFlow<String> = _homeChannel.asStateFlow()
// ── M3: threads toggle (per-app for now; per-channel lands later) ─────
private val _threadsEnabled = MutableStateFlow(false)
val threadsEnabled: StateFlow<Boolean> = _threadsEnabled.asStateFlow()
fun toggleThreads() {
_threadsEnabled.value = !_threadsEnabled.value
}
// ── M3: search state ──────────────────────────────────────────────────
private val _searchResults = MutableStateFlow<List<SearchHit>>(emptyList())
val searchResults: StateFlow<List<SearchHit>> = _searchResults.asStateFlow()
private val _searching = MutableStateFlow(false)
val searching: StateFlow<Boolean> = _searching.asStateFlow()
private val _lastQuery = MutableStateFlow("")
val lastQuery: StateFlow<String> = _lastQuery.asStateFlow()
init {
scope.launch {
client.events.collect { frame ->
@@ -66,6 +100,19 @@ class IrisController(
TYPE_TOOL_PROGRESS,
TYPE_TOOL_END,
TYPE_COMMENTARY -> chat.onFrame(frame)
TYPE_CHANNEL_CREATED,
TYPE_CHANNEL_RENAMED,
TYPE_CHANNEL_DELETED,
TYPE_CHANNEL_LIST -> channels.onFrame(frame)
TYPE_SEARCH_RESULTS -> {
frame.payloadAs<SearchResultsPayload>()?.let { _searchResults.value = it.hits }
_searching.value = false
}
TYPE_SYNC_DONE -> {
// Replayed frames already flowed through [events]; the
// cursor is authoritative server-side (outbox).
frame.payloadAs<SyncDonePayload>()
}
TYPE_TYPING -> {
frame.payloadAs<TypingPayload>()?.let { _typing.value = it.on }
}
@@ -76,8 +123,12 @@ class IrisController(
scope.launch {
client.state.collect { s ->
if (s is GatewayClient.State.Connected) {
channels.setAll(s.channels)
val home = s.channels.firstOrNull { it.isDefault }?.chatId
if (home != null) _homeChannel.value = home
if (home != null) {
_homeChannel.value = home
chat.setLane(home)
}
}
}
}
@@ -85,12 +136,80 @@ class IrisController(
client.start()
}
/** Optimistic send: show immediately, then hand to the gateway. */
// ── M3: navigation (lane switching) ───────────────────────────────────
/** Switch to a channel's flat / "General" lane. */
fun openChannel(chatId: String) {
chat.setLane(chatId)
}
/** Switch to a specific thread lane under [chatId]. */
fun openThread(chatId: String, threadId: String) {
chat.setLane(chat.laneKey(chatId, threadId))
}
// ── M3: channel directory ops (server is authoritative) ───────────────
fun createChannel(name: String) {
val trimmed = name.trim()
if (trimmed.isEmpty()) return
client.sendFrame(channelCreateFrame(0, trimmed))
}
fun createThread(name: String, parentChatId: String) {
val trimmed = name.trim()
if (trimmed.isEmpty()) return
client.sendFrame(channelCreateFrame(0, trimmed, kind = "thread", parentChatId = parentChatId))
}
fun renameChannel(chatId: String, name: String) {
val trimmed = name.trim()
if (trimmed.isEmpty()) return
client.sendFrame(channelRenameFrame(0, chatId, trimmed))
}
fun setDefaultChannel(chatId: String) {
client.sendFrame(channelSetDefaultFrame(0, chatId))
}
fun deleteChannel(chatId: String) {
client.sendFrame(channelDeleteFrame(0, chatId))
}
fun refreshChannels() {
client.sendFrame(channelListFrame(0))
}
// ── M3: search ────────────────────────────────────────────────────────
fun search(query: String, scope: String = "all", chatId: String? = null, threadId: String? = null) {
val q = query.trim()
if (q.isEmpty()) return
_lastQuery.value = q
_searching.value = true
client.sendFrame(searchFrame(0, q, scope, chatId, threadId))
}
fun clearSearch() {
_searchResults.value = emptyList()
_lastQuery.value = ""
_searching.value = false
}
// ── M3: sync (reconnect catch-up) ─────────────────────────────────────
fun sync(cursor: Long) {
client.sendFrame(syncFrame(0, cursor))
}
/** Optimistic send: show immediately in the current lane, then hand to the gateway. */
fun send(text: String) {
val trimmed = text.trim()
if (trimmed.isEmpty()) return
chat.addPending(trimmed)
client.sendMessage(_homeChannel.value, trimmed)
val lane = chat.currentLane.value
val (chatId, threadId) = chat.parseLane(lane)
chat.addPending(trimmed, lane)
client.sendMessage(chatId, trimmed, threadId)
}
/** Connect-screen action: real hello test, then save + (re)connect. */
@@ -105,6 +224,7 @@ class IrisController(
fun forget() {
store.clear()
chat.clear()
channels.clear()
client.restart()
}
@@ -2,6 +2,7 @@ package iris.ui.screens
import androidx.compose.foundation.background
import androidx.compose.foundation.clickable
import androidx.compose.foundation.horizontalScroll
import androidx.compose.foundation.layout.Arrangement
import androidx.compose.foundation.layout.Box
import androidx.compose.foundation.layout.Column
@@ -18,21 +19,29 @@ import androidx.compose.foundation.layout.width
import androidx.compose.foundation.lazy.LazyColumn
import androidx.compose.foundation.lazy.items
import androidx.compose.foundation.lazy.rememberLazyListState
import androidx.compose.foundation.rememberScrollState
import androidx.compose.foundation.shape.RoundedCornerShape
import androidx.compose.foundation.text.KeyboardActions
import androidx.compose.foundation.text.KeyboardOptions
import androidx.compose.material3.AlertDialog
import androidx.compose.material3.Button
import androidx.compose.material3.CircularProgressIndicator
import androidx.compose.material3.DrawerValue
import androidx.compose.material3.IconButton
import androidx.compose.material3.MaterialTheme
import androidx.compose.material3.ModalDrawerSheet
import androidx.compose.material3.ModalNavigationDrawer
import androidx.compose.material3.OutlinedTextField
import androidx.compose.material3.Text
import androidx.compose.material3.TextButton
import androidx.compose.material3.rememberDrawerState
import androidx.compose.runtime.Composable
import androidx.compose.runtime.LaunchedEffect
import androidx.compose.runtime.collectAsState
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
@@ -45,28 +54,45 @@ import androidx.compose.ui.text.font.FontWeight
import androidx.compose.ui.text.input.ImeAction
import androidx.compose.ui.unit.dp
import androidx.compose.ui.unit.sp
import iris.data.ChannelStore
import iris.data.ChatItem
import iris.data.MessageItem
import iris.data.ToolItem
import iris.net.GatewayClient
import iris.protocol.ChannelInfo
import iris.protocol.ROLE_USER
import iris.protocol.SearchHit
import iris.state.IrisController
import iris.state.ToolDetail
import kotlinx.coroutines.launch
/**
* Chat screen (M2): message list + composer. User bubbles right (accent),
* assistant bubbles left (surface). M2 adds live streaming bubbles, a
* collapsible reasoning block above the answer, structured tool cards
* (verbosity-controlled), and dimmed commentary beats.
* Chat screen (M3): channel drawer + thread toggle + topic switcher + search,
* on top of the M2 message list / composer. Messages are shown for the current
* lane (a channel's flat lane, or a specific thread).
*/
@Composable
fun ChatScreen(controller: IrisController) {
val state by controller.client.state.collectAsState()
val items by controller.chat.items.collectAsState()
val lanes by controller.chat.lanes.collectAsState()
val currentLane by controller.chat.currentLane.collectAsState()
val items = lanes[currentLane].orEmpty()
val typing by controller.typing.collectAsState()
val toolDetail by controller.toolDetail.collectAsState()
val threadsEnabled by controller.threadsEnabled.collectAsState()
val channels by controller.channels.channels.collectAsState()
val (currentChatId, currentThreadId) = controller.chat.parseLane(currentLane)
val currentChannel = channels.firstOrNull { it.chatId == currentChatId }
val threads = channels.filter { it.kind == "thread" && it.parentChatId == currentChatId }
val listState = rememberLazyListState()
var input by remember { mutableStateOf("") }
var showSearch by remember { mutableStateOf(false) }
var showNewChannel by remember { mutableStateOf(false) }
var showNewThread by remember { mutableStateOf(false) }
val drawerState = rememberDrawerState(DrawerValue.Closed)
val drawerScope = rememberCoroutineScope()
fun doSend() {
if (input.isBlank()) return
@@ -80,87 +106,363 @@ fun ChatScreen(controller: IrisController) {
if (last >= 0) listState.animateScrollToItem(last)
}
Column(modifier = Modifier.fillMaxSize()) {
// Header
Row(
modifier = Modifier
.fillMaxWidth()
.padding(horizontal = 16.dp, vertical = 10.dp),
verticalAlignment = Alignment.CenterVertically,
) {
Text("Iris", style = MaterialTheme.typography.titleMedium, fontWeight = FontWeight.SemiBold)
Spacer(modifier = Modifier.width(10.dp))
StatusChip(state)
Spacer(modifier = Modifier.weight(1f))
// M2: cycle tool-card verbosity (full Settings screen lands in M7).
TextButton(onClick = { controller.cycleToolDetail() }) {
Text("tools: ${toolDetail.label}", fontSize = 11.sp)
ModalNavigationDrawer(
drawerState = drawerState,
gesturesEnabled = drawerState.isOpen,
drawerContent = {
ModalDrawerSheet(modifier = Modifier.width(280.dp)) {
ChannelDrawer(
channels = channels.filter { it.kind != "thread" },
currentChatId = currentChatId,
onOpen = { ch ->
controller.openChannel(ch.chatId)
drawerScope.launch { drawerState.close() }
},
onNewChannel = {
showNewChannel = true
drawerScope.launch { drawerState.close() }
},
onSetDefault = { ch -> controller.setDefaultChannel(ch.chatId) },
onDelete = { ch -> controller.deleteChannel(ch.chatId) },
)
}
},
) {
Column(modifier = Modifier.fillMaxSize()) {
// Header
Row(
modifier = Modifier
.fillMaxWidth()
.padding(horizontal = 8.dp, vertical = 6.dp),
verticalAlignment = Alignment.CenterVertically,
) {
IconButton(onClick = { drawerScope.launch { drawerState.open() } }) {
Text("☰", fontSize = 18.sp)
}
Text(
currentChannel?.name ?: "Iris",
style = MaterialTheme.typography.titleMedium,
fontWeight = FontWeight.SemiBold,
modifier = Modifier.weight(1f),
)
StatusChip(state)
Spacer(modifier = Modifier.width(4.dp))
IconButton(onClick = { showSearch = true }) {
Text("🔍", fontSize = 16.sp)
}
IconButton(onClick = { controller.toggleThreads() }) {
Text(if (threadsEnabled) "🧵✓" else "🧵", fontSize = 16.sp)
}
TextButton(onClick = { controller.cycleToolDetail() }) {
Text("tools: ${toolDetail.label}", fontSize = 11.sp)
}
}
}
// Messages
LazyColumn(
state = listState,
modifier = Modifier
.weight(1f)
.fillMaxWidth(),
contentPadding = PaddingValues(16.dp),
verticalArrangement = Arrangement.spacedBy(8.dp),
) {
if (items.isEmpty() && !typing) {
item(key = "empty") {
Box(modifier = Modifier.fillMaxWidth(), contentAlignment = Alignment.Center) {
// Topic switcher (threads on)
if (threadsEnabled) {
TopicSwitcher(
threads = threads,
currentThreadId = currentThreadId,
onGeneral = { controller.openChannel(currentChatId) },
onThread = { t -> controller.openThread(currentChatId, t.chatId) },
onNewThread = { showNewThread = true },
)
}
// Messages
LazyColumn(
state = listState,
modifier = Modifier
.weight(1f)
.fillMaxWidth(),
contentPadding = PaddingValues(16.dp),
verticalArrangement = Arrangement.spacedBy(8.dp),
) {
if (items.isEmpty() && !typing) {
item(key = "empty") {
Box(modifier = Modifier.fillMaxWidth(), contentAlignment = Alignment.Center) {
Text(
"Say hello to your agent.",
color = MaterialTheme.colorScheme.onSurfaceVariant,
)
}
}
}
items(items, key = { it.id }) { item ->
when (item) {
is MessageItem -> MessageBubble(item)
is ToolItem -> if (toolDetail != ToolDetail.NOTHING) ToolCard(item, toolDetail)
}
}
if (typing) {
item(key = "typing") {
Text(
"Say hello to your agent.",
"typing…",
style = MaterialTheme.typography.bodySmall,
color = MaterialTheme.colorScheme.onSurfaceVariant,
modifier = Modifier.padding(start = 12.dp),
)
}
}
}
items(items, key = { it.id }) { item ->
when (item) {
is MessageItem -> MessageBubble(item)
is ToolItem -> if (toolDetail != ToolDetail.NOTHING) ToolCard(item, toolDetail)
}
}
if (typing) {
item(key = "typing") {
Text(
"typing…",
style = MaterialTheme.typography.bodySmall,
color = MaterialTheme.colorScheme.onSurfaceVariant,
modifier = Modifier.padding(start = 12.dp),
)
}
}
}
// Composer
Row(
modifier = Modifier
.fillMaxWidth()
.padding(12.dp),
verticalAlignment = Alignment.Bottom,
) {
OutlinedTextField(
value = input,
onValueChange = { input = it },
// Composer
Row(
modifier = Modifier
.weight(1f)
.heightIn(min = 48.dp, max = 160.dp),
placeholder = { Text("Message") },
keyboardOptions = KeyboardOptions(imeAction = ImeAction.Send),
keyboardActions = KeyboardActions(onSend = { doSend() }),
)
Spacer(modifier = Modifier.width(8.dp))
Button(
onClick = { doSend() },
enabled = input.isNotBlank(),
.fillMaxWidth()
.padding(12.dp),
verticalAlignment = Alignment.Bottom,
) {
Text("Send")
OutlinedTextField(
value = input,
onValueChange = { input = it },
modifier = Modifier
.weight(1f)
.heightIn(min = 48.dp, max = 160.dp),
placeholder = { Text("Message") },
keyboardOptions = KeyboardOptions(imeAction = ImeAction.Send),
keyboardActions = KeyboardActions(onSend = { doSend() }),
)
Spacer(modifier = Modifier.width(8.dp))
Button(
onClick = { doSend() },
enabled = input.isNotBlank(),
) {
Text("Send")
}
}
}
}
// Search overlay
if (showSearch) {
SearchOverlay(
controller = controller,
onJump = { hit ->
if (hit.threadId.isNullOrBlank()) controller.openChannel(hit.chatId)
else controller.openThread(hit.chatId, hit.threadId)
showSearch = false
},
onDismiss = { showSearch = false },
)
}
// New channel dialog
if (showNewChannel) {
NameDialog(
title = "New channel",
onConfirm = { name ->
controller.createChannel(name)
showNewChannel = false
},
onDismiss = { showNewChannel = false },
)
}
// New thread dialog
if (showNewThread) {
NameDialog(
title = "New topic",
onConfirm = { name ->
controller.createThread(name, currentChatId)
showNewThread = false
},
onDismiss = { showNewThread = false },
)
}
}
/** Left drawer: channel list + create / set-default / delete. */
@Composable
private fun ChannelDrawer(
channels: List<ChannelInfo>,
currentChatId: String,
onOpen: (ChannelInfo) -> Unit,
onNewChannel: () -> Unit,
onSetDefault: (ChannelInfo) -> Unit,
onDelete: (ChannelInfo) -> Unit,
) {
Column(modifier = Modifier.fillMaxSize().padding(12.dp)) {
Text(
"Channels",
style = MaterialTheme.typography.titleMedium,
modifier = Modifier.padding(bottom = 8.dp),
)
LazyColumn(
verticalArrangement = Arrangement.spacedBy(4.dp),
modifier = Modifier.weight(1f),
) {
items(channels, key = { it.chatId }) { ch ->
val isCurrent = ch.chatId == currentChatId
Row(
modifier = Modifier
.fillMaxWidth()
.clip(RoundedCornerShape(8.dp))
.background(if (isCurrent) Color(0xFF2A3550) else Color.Transparent)
.clickable { onOpen(ch) }
.padding(horizontal = 10.dp, vertical = 6.dp),
verticalAlignment = Alignment.CenterVertically,
) {
Text(
ch.name + if (ch.isDefault) " ★" else "",
fontSize = 14.sp,
modifier = Modifier.weight(1f),
)
if (!ch.isDefault) {
TextButton(onClick = { onSetDefault(ch) }) {
Text("★", fontSize = 12.sp)
}
TextButton(onClick = { onDelete(ch) }) {
Text("✕", fontSize = 12.sp)
}
}
}
}
}
Button(onClick = onNewChannel, modifier = Modifier.fillMaxWidth()) {
Text("+ New channel")
}
}
}
/** Horizontal topic chips: General + threads + "new topic". */
@Composable
private fun TopicSwitcher(
threads: List<ChannelInfo>,
currentThreadId: String?,
onGeneral: () -> Unit,
onThread: (ChannelInfo) -> Unit,
onNewThread: () -> Unit,
) {
Row(
modifier = Modifier
.fillMaxWidth()
.horizontalScroll(rememberScrollState())
.padding(horizontal = 12.dp, vertical = 6.dp),
horizontalArrangement = Arrangement.spacedBy(6.dp),
) {
TopicChip("General", currentThreadId == null, onClick = onGeneral)
threads.forEach { t ->
TopicChip(t.name, currentThreadId == t.chatId, onClick = { onThread(t) })
}
TopicChip("+ topic", false, onClick = onNewThread)
}
}
@Composable
private fun TopicChip(label: String, selected: Boolean, onClick: () -> Unit) {
Box(
modifier = Modifier
.clip(RoundedCornerShape(12.dp))
.background(if (selected) Color(0xFF4F7CFF) else Color(0xFF2A2E3B))
.clickable(onClick = onClick)
.padding(horizontal = 10.dp, vertical = 5.dp),
) {
Text(label, fontSize = 12.sp, color = if (selected) Color.White else Color(0xFFC7CCD8))
}
}
/** Full-screen search overlay (M3). */
@Composable
private fun SearchOverlay(
controller: IrisController,
onJump: (SearchHit) -> Unit,
onDismiss: () -> Unit,
) {
val results by controller.searchResults.collectAsState()
val searching by controller.searching.collectAsState()
val lastQuery by controller.lastQuery.collectAsState()
var query by remember { mutableStateOf(lastQuery) }
Box(modifier = Modifier.fillMaxSize().background(Color.Black.copy(alpha = 0.5f))) {
Column(
modifier = Modifier
.fillMaxSize()
.background(MaterialTheme.colorScheme.surface)
.padding(16.dp),
) {
Row(verticalAlignment = Alignment.CenterVertically) {
Text("Search", style = MaterialTheme.typography.titleMedium, modifier = Modifier.weight(1f))
TextButton(onClick = onDismiss) { Text("close") }
}
Row(verticalAlignment = Alignment.CenterVertically) {
OutlinedTextField(
value = query,
onValueChange = { query = it },
modifier = Modifier.weight(1f),
placeholder = { Text("Search messages…") },
keyboardOptions = KeyboardOptions(imeAction = ImeAction.Search),
keyboardActions = KeyboardActions(onSearch = { controller.search(query) }),
)
Spacer(modifier = Modifier.width(8.dp))
Button(onClick = { controller.search(query) }, enabled = query.isNotBlank() && !searching) {
Text(if (searching) "…" else "search")
}
}
Spacer(modifier = Modifier.height(12.dp))
if (results.isEmpty() && !searching) {
Box(modifier = Modifier.fillMaxSize(), contentAlignment = Alignment.Center) {
Text("No results.", color = MaterialTheme.colorScheme.onSurfaceVariant)
}
} else {
LazyColumn(verticalArrangement = Arrangement.spacedBy(8.dp)) {
items(results, key = { it.messageId }) { hit ->
SearchHitRow(hit, controller.channels, onClick = { onJump(hit) })
}
}
}
}
}
}
@Composable
private fun SearchHitRow(hit: SearchHit, channels: ChannelStore, onClick: () -> Unit) {
val channel = channels.byId(hit.chatId)
val thread = hit.threadId?.let { channels.byId(it) }
Column(
modifier = Modifier
.fillMaxWidth()
.clip(RoundedCornerShape(10.dp))
.background(Color(0xFF20242E))
.clickable(onClick = onClick)
.padding(10.dp),
) {
Row(verticalAlignment = Alignment.CenterVertically) {
Text(
(channel?.name ?: hit.chatId) + (thread?.let { " / ${it.name}" } ?: ""),
fontSize = 12.sp,
fontWeight = FontWeight.Medium,
color = Color(0xFFB9C0D0),
modifier = Modifier.weight(1f),
)
Text(hit.role, fontSize = 10.sp, color = Color(0xFF8A93A6))
}
Spacer(modifier = Modifier.height(4.dp))
Text(hit.snippet, fontSize = 13.sp, color = Color(0xFFD7DBE5), maxLines = 3)
}
}
/** Simple name-entry dialog (new channel / new topic). */
@Composable
private fun NameDialog(title: String, onConfirm: (String) -> Unit, onDismiss: () -> Unit) {
var name by remember { mutableStateOf("") }
AlertDialog(
onDismissRequest = onDismiss,
title = { Text(title) },
text = {
OutlinedTextField(
value = name,
onValueChange = { name = it },
placeholder = { Text("Name") },
)
},
confirmButton = {
TextButton(onClick = { onConfirm(name) }, enabled = name.isNotBlank()) { Text("Create") }
},
dismissButton = {
TextButton(onClick = onDismiss) { Text("Cancel") }
},
)
}
@Composable