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
@@ -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
|
||||
|
||||
Reference in new issue
Block a user