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

@@ -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()
}