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:
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()
|
||||
}
|
||||
|
||||
|
||||
Reference in new issue
Block a user