Auto-threading, history pagination, streaming toggle + tool/reasoning display settings

- Auto-threading (Telegram topic-mode workflow): message.send {auto_thread}
  mints a fresh AI-named thread (instant derived title, LLM upgrade via
  channel.renamed); channel.created {auto:true}; the app jumps into the new
  thread and relocates the optimistic pending bubble.
- history frame: paged full message history for initial channel open /
  scroll-up pagination (reconstructed from the outbox log).
- Streaming on/off: gateway side (display.platforms.android.streaming) plus a
  per-device app toggle (Settings → Streaming); reasoning/model/tokens carried
  on message frames.
- Context menu: long-press (touch) / right-click (desktop) thread affordances
  via a KMP rightClick expect/actual.
- Settings → Reasoning: auto-collapse long reasoning blocks (default on).
- Tool detail: the gateway now always supplies full tool data — it forces
  verbose tool progress (full args → tool.start.args) and captures each
  completed call via the post_tool_call hook (output/duration/ok → tool.end).
  The app reveals the full call + output on expand (Truncated) and
  auto-expands cards in Everything mode.
This commit is contained in:
ARIA committed 2026-08-20 16:26:52 +02:00
1 parent e678caafdc
commit efecf2732e
24 files changed
+1115 -81

No files matched your search

@@ -105,6 +105,14 @@ class AndroidSecureStore(context: Context) : SecureStore {
get() = prefs.getString(KEY_TOOL_DETAIL, "truncated").orEmpty().ifBlank { "truncated" }
set(value) = prefs.edit().putString(KEY_TOOL_DETAIL, value).apply()
override var streamingEnabled: Boolean
get() = prefs.getBoolean(KEY_STREAMING_ENABLED, true)
set(value) = prefs.edit().putBoolean(KEY_STREAMING_ENABLED, value).apply()
override var reasoningAutoCollapse: Boolean
get() = prefs.getBoolean(KEY_REASONING_AUTO_COLLAPSE, true)
set(value) = prefs.edit().putBoolean(KEY_REASONING_AUTO_COLLAPSE, value).apply()
override fun savePairing(url: String, token: String) {
serverUrl = url
this.token = token
@@ -126,5 +134,7 @@ class AndroidSecureStore(context: Context) : SecureStore {
const val KEY_NTFY_SERVER = "ntfy_server"
const val KEY_THREADS_ENABLED = "threads_enabled"
const val KEY_TOOL_DETAIL = "tool_detail"
const val KEY_STREAMING_ENABLED = "streaming_enabled"
const val KEY_REASONING_AUTO_COLLAPSE = "reasoning_auto_collapse"
}
}
@@ -0,0 +1,5 @@
package iris.ui
import androidx.compose.ui.Modifier
actual fun Modifier.rightClick(onRightClick: () -> Unit): Modifier = this
@@ -15,10 +15,8 @@ import iris.platform.setActiveController
import iris.state.IrisController
import iris.ui.screens.ChatScreen
import iris.ui.screens.ConnectScreen
import iris.ui.screens.ConnectingScreen
import iris.ui.theme.IrisColors
import iris.ui.theme.IrisTheme
import iris.util.hostFromUrl
/**
* Root composable shared by the Android and Desktop shells.
@@ -63,9 +61,9 @@ fun IrisApp(
prefillToken = store.token,
initialError = "Pairing rejected: ${s.message}",
)
// M7: initial connect in flight — dedicated screen, not an empty chat.
GatewayClient.State.Connecting ->
ConnectingScreen(hostFromUrl(store.serverUrl))
// Connecting / Reconnecting / Connected all render the chat; the header
// StatusChip + connection banner show the link state without
// blocking the view (M7's full-screen spinner is gone).
else -> ChatScreen(controller)
}
}
@@ -106,6 +106,12 @@ class ChatStore {
private var localSeq = 0
private var toolSeq = 0
/** When false, `message.start`/`message.update` frames are ignored and each
* reply materializes as a single final message on `message.stop`
* (Settings → "Streaming"). The gateway keeps streaming (other devices
* may want it); this is a per-device display preference. */
var streamingEnabled: Boolean = true
companion object {
const val DEFAULT_LANE = "android:default"
@@ -176,6 +182,10 @@ class ChatStore {
private fun onMessage(lane: String, frame: Frame) {
val p = frame.payloadAs<MessagePayload>() ?: return
// Auto-threaded send: the gateway created a fresh thread for this
// message; the optimistic bubble is still in the parent flat lane.
// Relocate it so the echo (below) lands in the thread lane.
if (p.role == ROLE_USER) relocatePendingToThreadLane(lane, p)
updateLane(lane) { list ->
val byId = list.indexOfFirst { it.id == p.messageId }
if (byId >= 0) {
@@ -222,6 +232,27 @@ class ChatStore {
}
}
/**
* Remove the matching optimistic user bubble from the parent flat lane of
* [lane] (an auto-created thread). The gateway moved the message into the
* fresh thread, so the pending bubble must follow it; the echo then replaces
* it in the thread lane. No-op when [lane] is not a thread lane or no
* pending bubble with the same text exists in the flat lane.
*/
private fun relocatePendingToThreadLane(lane: String, p: MessagePayload) {
val (chatId, threadId) = parseLane(lane)
if (threadId == null) return
val flatLane = chatId
val map = _lanes.value.toMutableMap()
val flatList = map[flatLane].orEmpty()
val idx = flatList.indexOfLast {
it is MessageItem && it.pending && it.role == ROLE_USER && it.text == p.text
}
if (idx < 0) return
map[flatLane] = flatList.toMutableList().also { it.removeAt(idx) }
_lanes.value = map
}
/** Merge server media refs into existing items, keeping local paths. */
private fun mergeMedia(existing: List<MediaItem>, incoming: List<MediaRef>): List<MediaItem> {
if (incoming.isEmpty()) return existing
@@ -239,6 +270,7 @@ class ChatStore {
// ── message.start (open a live streaming bubble) ──────────────────────
private fun onMessageStart(lane: String, frame: Frame) {
if (!streamingEnabled) return
val p = frame.payloadAs<MessageStartPayload>() ?: return
updateLane(lane) { list ->
if (list.any { it.id == p.messageId }) list
@@ -441,6 +473,27 @@ class ChatStore {
}
}
/**
* Load a history page into [lane] (oldest → newest). The history is the
* authoritative full list of final messages for the lane; it replaces the
* lane's final messages and preserves non-final items (tool cards, live
* streaming bubbles, commentary) that are not part of the history. Used to
* restore the view on first open of a chat / after a process death, where
* the in-memory store is empty and the `sync` delta does not cover older
* messages.
*/
fun loadHistory(lane: String, messages: List<MessageItem>) {
updateLane(lane) { list ->
val historyIds = messages.map { it.id }.toSet()
val preserved = list.filter { item ->
item !is MessageItem || item.id !in historyIds
}
(messages + preserved).sortedBy { item ->
(item as? MessageItem)?.ts?.takeIf { it > 0 } ?: Long.MAX_VALUE
}
}
}
fun clear() {
_lanes.value = emptyMap()
}
@@ -36,6 +36,12 @@ interface SecureStore {
/** UI setting: tool verbosity — "everything" / "truncated" / "nothing". */
var toolDetail: String
/** UI setting: stream assistant replies live (token by token). */
var streamingEnabled: Boolean
/** UI setting: auto-collapse long reasoning blocks in the chat view. */
var reasoningAutoCollapse: Boolean
fun savePairing(url: String, token: String)
fun clear()
}
@@ -92,6 +92,10 @@ class GatewayClient(
private var socket: WebSocket? = null
private var nextRequestId = 1
private var attempt = 0
// True once a connection has been established this session; reset by
// start(). Drives Connecting (first dial) vs Reconnecting (redial after a
// drop) so the UI can show the right status without a blocking screen.
private var hasConnected = false
private var lastLiveness: TimeMark = TimeSource.Monotonic.markNow()
private val pending = mutableMapOf<Int, CompletableDeferred<Frame>>()
@@ -118,6 +122,7 @@ class GatewayClient(
fun start() {
if (connectJob?.isActive == true) return
attempt = 0
hasConnected = false
connectJob = scope.launch { connectLoop() }
}
@@ -144,7 +149,7 @@ class GatewayClient(
_state.value = State.Disconnected
return
}
_state.value = if (attempt == 0) State.Connecting else State.Reconnecting
_state.value = if (hasConnected) State.Reconnecting else State.Connecting
val dial = dial(url, token)
when (val result = dial.result) {
is DialResult.AuthFailed -> {
@@ -153,6 +158,7 @@ class GatewayClient(
return
}
DialResult.Connected -> {
hasConnected = true
attempt = 0
lastLiveness = TimeSource.Monotonic.markNow()
dial.closed.await()
@@ -289,16 +295,19 @@ class GatewayClient(
// ── Outbound ──────────────────────────────────────────────────────────
/** Send a text message (fire-and-forget; the server echoes it back).
* M4: [mediaRefs] reference completed uploads (media.upload.ack refs). */
* M4: [mediaRefs] reference completed uploads (media.upload.ack refs).
* [autoThread] asks the gateway to mint a fresh thread for the message
* (auto-threading, docs/06 §6.3). */
fun sendMessage(
chatId: String,
text: String,
threadId: String? = null,
mediaRefs: List<String> = emptyList(),
autoThread: Boolean = false,
) {
val ws = socket ?: return
val id = nextRequestId++
ws.send(messageSendFrame(id, chatId, text, threadId, mediaRefs).toWire())
ws.send(messageSendFrame(id, chatId, text, threadId, mediaRefs, autoThread).toWire())
}
// ── M4: media upload / pull ───────────────────────────────────────────
@@ -73,6 +73,7 @@ const val TYPE_SEARCH = "search"
const val TYPE_SEARCH_RESULTS = "search.results"
const val TYPE_SYNC = "sync"
const val TYPE_SYNC_DONE = "sync.done"
const val TYPE_HISTORY = "history"
// ── Error codes ─────────────────────────────────────────────────────────
@@ -149,6 +150,9 @@ data class ChannelInfo(
@SerialName("is_default") val isDefault: Boolean = false,
@SerialName("parent_chat_id") val parentChatId: String? = null,
val archived: Boolean = false,
/** Gateway minted this thread for an incoming message (auto-threading);
* the name is a derived title, upgraded by the LLM via channel.renamed. */
val auto: Boolean = false,
)
@Serializable
@@ -289,6 +293,9 @@ data class MessageSendPayload(
val text: String,
@SerialName("reply_to") val replyTo: String? = null,
@SerialName("media_refs") val mediaRefs: List<String> = emptyList(),
/** Ask the gateway to mint a fresh thread for this message (auto-
* threading; only honored in a channel's flat lane, docs/06 §6.3). */
@SerialName("auto_thread") val autoThread: Boolean = false,
)
// ── typing / error / ping ───────────────────────────────────────────────
@@ -356,6 +363,28 @@ data class SyncPayload(val cursor: Long)
@Serializable
data class SyncDonePayload(val cursor: Long)
// ── history (full message history for a chat/thread) ────────────────────
/** A final message in a history page (user echo / assistant reply). */
@Serializable
data class HistoryMessage(
@SerialName("message_id") val messageId: String,
val role: String,
val text: String,
val reasoning: String? = null,
val model: String? = null,
val tokens: Int? = null,
val ts: Long? = null,
val media: List<MediaRef> = emptyList(),
)
@Serializable
data class HistoryPayload(
val messages: List<HistoryMessage> = emptyList(),
@SerialName("has_more") val hasMore: Boolean = false,
@SerialName("oldest_message_id") val oldestMessageId: String? = null,
)
// ── M5: push / notifications ────────────────────────────────────────────
/** Notification kinds (mirror of protocol.NOTIF_*). */
@@ -424,6 +453,7 @@ fun messageSendFrame(
text: String,
threadId: String? = null,
mediaRefs: List<String> = emptyList(),
autoThread: Boolean = false,
): Frame =
Frame(
id = id,
@@ -432,7 +462,7 @@ fun messageSendFrame(
threadId = threadId,
payload = IrisJson.instance.encodeToJsonElement(
MessageSendPayload.serializer(),
MessageSendPayload(text = text, mediaRefs = mediaRefs),
MessageSendPayload(text = text, mediaRefs = mediaRefs, autoThread = autoThread),
),
)
@@ -499,6 +529,27 @@ fun syncFrame(id: Int, cursor: Long): Frame =
),
)
/** Request a page of full message history for a chat/thread (initial open /
* restart restore). [beforeMessageId] returns the page older than that id
* (null = newest page). */
fun historyFrame(
id: Int,
chatId: String,
threadId: String? = null,
beforeMessageId: String? = null,
limit: Int = 50,
): Frame =
Frame(
id = id,
type = TYPE_HISTORY,
chatId = chatId,
threadId = threadId,
payload = buildJsonObject {
if (beforeMessageId != null) put("before_message_id", beforeMessageId)
put("limit", limit)
},
)
// ── M4 frame builders ────────────────────────────────────────────────────
fun mediaUploadStartFrame(
@@ -13,7 +13,11 @@ import iris.platform.PickedFile
import iris.platform.isAppForeground
import iris.platform.mediaCacheBaseDir
import iris.platform.postSystemNotification
import iris.protocol.ChannelDeletedPayload
import iris.protocol.ChannelInfo
import iris.protocol.HIGH_PRIORITY_NOTIF_KINDS
import iris.protocol.HistoryMessage
import iris.protocol.HistoryPayload
import iris.protocol.MediaOfferPayload
import iris.protocol.MessagePayload
import iris.protocol.MessageStopPayload
@@ -25,6 +29,7 @@ import iris.protocol.SearchResultsPayload
import iris.protocol.StatusPayload
import iris.protocol.SyncDonePayload
import iris.protocol.TYPE_ERROR
import iris.protocol.TYPE_HISTORY
import iris.protocol.TYPE_NOTIFICATION
import iris.protocol.TYPE_CHANNEL_CREATED
import iris.protocol.TYPE_CHANNEL_DELETED
@@ -50,6 +55,7 @@ import iris.protocol.channelDeleteFrame
import iris.protocol.channelListFrame
import iris.protocol.channelRenameFrame
import iris.protocol.channelSetDefaultFrame
import iris.protocol.historyFrame
import iris.protocol.searchFrame
import iris.protocol.syncFrame
import kotlinx.coroutines.CoroutineScope
@@ -104,6 +110,12 @@ class IrisController(
private val _homeChannel = MutableStateFlow("android:default")
val homeChannel: StateFlow<String> = _homeChannel.asStateFlow()
/** Lanes whose full history has been loaded this session (in-memory; reset
* on a process death, which is exactly when a reload is needed). The
* `sync` delta can seed a lane with recent frames without it being opened,
* so "lane is empty" is not a reliable first-open signal. */
private val historyLoaded = mutableSetOf<String>()
/** Gateway health state (M5: status frame; null = never received). */
private val _gatewayStatus = MutableStateFlow<String?>(null)
val gatewayStatus: StateFlow<String?> = _gatewayStatus.asStateFlow()
@@ -118,6 +130,30 @@ class IrisController(
store.threadsEnabled = _threadsEnabled.value
}
// ── Streaming toggle (Settings → "Streaming") ──────────────────────────
// Persisted. When off, the app ignores message.start/update frames and
// shows each reply as a single final message (per-device display
// preference, like tool verbosity — the gateway keeps streaming).
private val _streamingEnabled = MutableStateFlow(store.streamingEnabled)
val streamingEnabled: StateFlow<Boolean> = _streamingEnabled.asStateFlow()
fun toggleStreaming() {
_streamingEnabled.value = !_streamingEnabled.value
store.streamingEnabled = _streamingEnabled.value
chat.streamingEnabled = _streamingEnabled.value
}
// ── Reasoning auto-collapse (Settings → "Reasoning") ───────────────────
// Persisted. When on, long reasoning blocks start collapsed (short ones
// stay expanded); when off, all reasoning blocks start expanded.
private val _reasoningAutoCollapse = MutableStateFlow(store.reasoningAutoCollapse)
val reasoningAutoCollapse: StateFlow<Boolean> = _reasoningAutoCollapse.asStateFlow()
fun toggleReasoningAutoCollapse() {
_reasoningAutoCollapse.value = !_reasoningAutoCollapse.value
store.reasoningAutoCollapse = _reasoningAutoCollapse.value
}
// ── M3: search state ──────────────────────────────────────────────────
private val _searchResults = MutableStateFlow<List<SearchHit>>(emptyList())
val searchResults: StateFlow<List<SearchHit>> = _searchResults.asStateFlow()
@@ -215,6 +251,7 @@ class IrisController(
}
init {
chat.streamingEnabled = _streamingEnabled.value
scope.launch {
client.events.collect { frame ->
when (frame.type) {
@@ -255,7 +292,38 @@ class IrisController(
TYPE_CHANNEL_CREATED,
TYPE_CHANNEL_RENAMED,
TYPE_CHANNEL_DELETED,
TYPE_CHANNEL_LIST -> channels.onFrame(frame)
TYPE_CHANNEL_LIST -> {
channels.onFrame(frame)
// Auto-created thread (Settings → "Threads"): the
// gateway minted it for a message in the flat lane we
// are viewing — jump into it; the user's message and
// the reply land there.
if (frame.type == TYPE_CHANNEL_CREATED) {
frame.payloadAs<ChannelInfo>()?.let { info ->
if (info.kind == "thread" && info.auto && info.parentChatId != null) {
val (curChat, curThread) = chat.parseLane(chat.currentLane.value)
if (curThread == null && curChat == info.parentChatId) {
val lane = chat.laneKey(info.parentChatId, info.chatId)
chat.setLane(lane)
historyLoaded.add(lane)
}
}
}
}
// A thread/channel we were viewing got deleted — fall back
// to a valid lane (the thread's parent, or the home channel).
if (frame.type == TYPE_CHANNEL_DELETED) {
frame.payloadAs<ChannelDeletedPayload>()?.let { p ->
val (curChat, curThread) = chat.parseLane(chat.currentLane.value)
when {
curThread == p.chatId -> openChannel(curChat)
curChat == p.chatId ->
channels.defaultChannel()?.let { openChannel(it.chatId) }
else -> Unit
}
}
}
}
TYPE_SEARCH_RESULTS -> {
frame.payloadAs<SearchResultsPayload>()?.let { _searchResults.value = it.hits }
_searching.value = false
@@ -265,6 +333,22 @@ class IrisController(
// cursor is authoritative server-side (outbox).
frame.payloadAs<SyncDonePayload>()?.let { store.syncCursor = it.cursor }
}
TYPE_HISTORY -> {
// Full message history for a chat/thread (initial open /
// restart restore). `sync` only replays the outbox delta
// since the saved cursor, so after a process death the
// in-memory store is empty and older messages are not in
// the delta — history loads the full list.
frame.payloadAs<HistoryPayload>()?.let { p ->
val (chatId, threadId) =
(frame.chatId ?: return@let) to frame.threadId
val lane = chat.laneKey(chatId, threadId)
chat.loadHistory(
lane,
p.messages.map { it.toMessageItem() },
)
}
}
TYPE_NOTIFICATION -> {
frame.payloadAs<NotificationPayload>()?.let { p ->
pushBanner(p.kind, p.title, p.body, p.chatId, p.threadId)
@@ -304,6 +388,12 @@ class IrisController(
if (home != null) {
_homeChannel.value = home
chat.setLane(home)
// Restore the home lane after a process death: the
// in-memory store is empty and the `sync` delta does not
// cover messages older than the saved cursor, so load
// the full history. Skipped on a plain reconnect (the
// lane's history was already loaded this session).
loadHistory(home)
}
// M5: remember the ntfy server for the listener service.
if (s.caps.pushNtfyServer.isNotBlank()) {
@@ -324,14 +414,19 @@ class IrisController(
// ── M3: navigation (lane switching) ───────────────────────────────────
/** Switch to a channel's flat / "General" lane. */
/** Switch to a channel's flat / "General" lane. Loads the full history on
* first open this session so the view is populated, not just the `sync`
* delta. */
fun openChannel(chatId: String) {
chat.setLane(chatId)
loadHistory(chatId)
}
/** Switch to a specific thread lane under [chatId]. */
/** Switch to a specific thread lane under [chatId]. Loads the full history
* on first open this session. */
fun openThread(chatId: String, threadId: String) {
chat.setLane(chat.laneKey(chatId, threadId))
loadHistory(chatId, threadId)
}
// ── M3: channel directory ops (server is authoritative) ───────────────
@@ -388,6 +483,19 @@ class IrisController(
client.sendFrame(syncFrame(0, cursor))
}
/** Request the full message history for a chat/thread (newest page).
* Populates the lane via the `history` response; used on first open of a
* chat and after a process death (restart) to restore the view, since the
* `sync` delta does not cover messages older than the saved cursor. */
fun loadHistory(chatId: String, threadId: String? = null) {
val lane = chat.laneKey(chatId, threadId)
if (lane in historyLoaded) return
historyLoaded.add(lane)
// Newest page, sized to restore a full working view on restart / first
// open (older pages are reachable via scroll-up pagination).
client.sendFrame(historyFrame(0, chatId, threadId, limit = 200))
}
/** Optimistic send: show immediately in the current lane, then hand to the gateway.
* M4: [attachments] (uploaded) are attached via media_refs. */
fun send(text: String, attachments: List<PendingAttachment> = emptyList()) {
@@ -404,10 +512,17 @@ class IrisController(
)
}
chat.addPending(trimmed, lane, media)
client.sendMessage(chatId, trimmed, threadId, refs)
client.sendMessage(chatId, trimmed, threadId, refs, autoThread = wantsAutoThread(trimmed, threadId))
_attachments.value = emptyList()
}
/** Auto-threading (Settings → "Threads", docs/06 §6.3): a message in a
* channel's flat lane gets its own fresh thread, AI-named by the gateway
* (Telegram topic-mode workflow). Slash commands and media-only sends
* stay in the flat lane. */
private fun wantsAutoThread(text: String, threadId: String?): Boolean =
_threadsEnabled.value && threadId == null && text.isNotEmpty() && !text.startsWith("/")
/** M7: resend a failed user message (tap on the failed bubble). */
fun retrySend(messageId: String) {
val lane = chat.currentLane.value
@@ -415,7 +530,13 @@ class IrisController(
val item = chat.lanes.value[lane]?.firstOrNull { it.id == messageId } as? MessageItem ?: return
if (item.status != MsgStatus.Failed) return
chat.rearmForRetry(lane, messageId)
client.sendMessage(chatId, item.text, threadId, item.media.map { it.mediaId })
client.sendMessage(
chatId,
item.text,
threadId,
item.media.map { it.mediaId },
autoThread = wantsAutoThread(item.text, threadId),
)
}
// ── M4: attachments (pick -> upload -> send) ──────────────────────────
@@ -492,6 +613,21 @@ class IrisController(
}
}
/** Convert a history message (server) into a chat [MessageItem]. */
private fun HistoryMessage.toMessageItem(): MessageItem =
MessageItem(
id = messageId,
role = role,
text = text,
ts = ts ?: 0,
reasoning = reasoning,
model = model,
tokens = tokens,
media = media.map {
MediaItem(mediaId = it.mediaId, kind = it.kind, mime = it.mime, size = it.size, filename = it.filename)
},
)
/** How much tool detail to show (Settings → "Tool detail"). */
enum class ToolDetail {
EVERYTHING, // name + full args (collapsible) + output preview
@@ -0,0 +1,10 @@
package iris.ui
import androidx.compose.ui.Modifier
/**
* Opens [onRightClick] on a right (secondary) mouse-button press. Desktop-only
* behavior; on touch platforms this is a no-op (the context menu is opened via
* long-press instead).
*/
expect fun Modifier.rightClick(onRightClick: () -> Unit): Modifier
@@ -1,7 +1,9 @@
package iris.ui.screens
import androidx.compose.foundation.ExperimentalFoundationApi
import androidx.compose.foundation.background
import androidx.compose.foundation.clickable
import androidx.compose.foundation.combinedClickable
import androidx.compose.foundation.focusable
import androidx.compose.foundation.horizontalScroll
import androidx.compose.foundation.layout.Arrangement
@@ -102,6 +104,7 @@ import iris.protocol.ROLE_USER
import iris.protocol.SearchHit
import iris.state.IrisController
import iris.state.ToolDetail
import iris.ui.rightClick
import iris.ui.theme.IrisColors
import iris.ui.theme.avatarColor
import iris.util.formatDayLabel
@@ -123,6 +126,7 @@ fun ChatScreen(controller: IrisController) {
val typing by controller.typing.collectAsState()
val toolDetail by controller.toolDetail.collectAsState()
val threadsEnabled by controller.threadsEnabled.collectAsState()
val reasoningAutoCollapse by controller.reasoningAutoCollapse.collectAsState()
val channels by controller.channels.channels.collectAsState()
val (currentChatId, currentThreadId) = controller.chat.parseLane(currentLane)
@@ -152,6 +156,9 @@ fun ChatScreen(controller: IrisController) {
val nonThreadChannels = channels.filter { it.kind != "thread" }
fun doSend() {
// No-op while the socket is down (sendMessage drops silently); the send
// button is disabled in that state, this guards the IME "Send" action.
if (state !is GatewayClient.State.Connected) return
val ready = attachments.filter { it.mediaRef != null && it.error == null }
if (input.isBlank() && ready.isEmpty()) return
val text = input
@@ -211,6 +218,9 @@ fun ChatScreen(controller: IrisController) {
var showOverflow by remember { mutableStateOf(false) }
var showRename by remember { mutableStateOf(false) }
var showForget by remember { mutableStateOf(false) }
// Topic context menu (long-press / right-click): rename / delete a thread.
var renameThread by remember { mutableStateOf<ChannelInfo?>(null) }
var deleteThread by remember { mutableStateOf<ChannelInfo?>(null) }
Column(modifier = Modifier.fillMaxSize()) {
// Header (M7: avatar + two-line title pill, reference look). Wide
// panes keep everything on one row; narrow phones move the utility
@@ -316,6 +326,8 @@ fun ChatScreen(controller: IrisController) {
onGeneral = { controller.openChannel(currentChatId) },
onThread = { t -> controller.openThread(currentChatId, t.chatId) },
onNewThread = { showNewThread = true },
onRenameThread = { renameThread = it },
onDeleteThread = { deleteThread = it },
)
}
@@ -346,6 +358,7 @@ fun ChatScreen(controller: IrisController) {
is MessageItem -> MessageBubble(
msg = item,
maxWidth = bubbleMaxWidth,
reasoningAutoCollapse = reasoningAutoCollapse,
onRetry = { controller.retrySend(item.id) },
)
is ToolItem -> if (toolDetail != ToolDetail.NOTHING) ToolCard(item, toolDetail)
@@ -404,6 +417,8 @@ fun ChatScreen(controller: IrisController) {
// M7: connection-state banner (persistent, above the composer; no dismiss).
val gatewayStatus by controller.gatewayStatus.collectAsState()
val connBanner = when {
state is GatewayClient.State.Connecting ->
"Connecting to gateway…"
state is GatewayClient.State.Reconnecting ->
"Reconnecting to gateway… messages will sync automatically when the connection is back."
gatewayStatus == "restarting" -> "Gateway is restarting…"
@@ -429,8 +444,12 @@ fun ChatScreen(controller: IrisController) {
}
}
// Composer (M7: rounded pill + accent circular send button)
val canSend = input.isNotBlank() || attachments.any { it.mediaRef != null && it.error == null }
// Composer (M7: rounded pill + accent circular send button). Sending is gated
// on a live socket: sendMessage is a no-op while disconnected, so an
// ungated send would show a pending bubble that never resolves.
val isConnected = state is GatewayClient.State.Connected
val canSend =
isConnected && (input.isNotBlank() || attachments.any { it.mediaRef != null && it.error == null })
val layoutDensity = LocalDensity.current.density
var textHeightPx by remember { mutableFloatStateOf(0f) }
Row(
@@ -524,6 +543,38 @@ fun ChatScreen(controller: IrisController) {
},
)
}
// Topic context menu: rename a thread.
renameThread?.let { t ->
NameDialog(
title = "Rename topic",
onConfirm = { name ->
controller.renameChannel(t.chatId, name)
renameThread = null
},
onDismiss = { renameThread = null },
initialName = t.name,
confirmLabel = "Rename",
)
}
// Topic context menu: delete a thread (and its messages from the chat).
deleteThread?.let { t ->
AlertDialog(
onDismissRequest = { deleteThread = null },
title = { Text("Delete topic?") },
text = {
Text("Delete \"${t.name}\"? It will be removed from this channel.")
},
confirmButton = {
TextButton(onClick = {
deleteThread = null
controller.deleteChannel(t.chatId)
}) { Text("Delete") }
},
dismissButton = {
TextButton(onClick = { deleteThread = null }) { Text("Cancel") }
},
)
}
}
}
@@ -657,6 +708,7 @@ fun ChatScreen(controller: IrisController) {
"new-thread" to "New topic (Ctrl+T)",
"search" to "Search (Ctrl+F)",
"toggle-threads" to "Toggle threads",
"toggle-reasoning-collapse" to "Toggle reasoning auto-collapse",
"cycle-tool-detail" to "Cycle tool verbosity",
"toggle-inspector" to "Toggle inspector",
),
@@ -666,6 +718,7 @@ fun ChatScreen(controller: IrisController) {
"new-thread" -> showNewThread = true
"search" -> showSearch = true
"toggle-threads" -> controller.toggleThreads()
"toggle-reasoning-collapse" -> controller.toggleReasoningAutoCollapse()
"cycle-tool-detail" -> controller.cycleToolDetail()
"toggle-inspector" -> showInspector = !showInspector
}
@@ -1098,6 +1151,8 @@ private fun TopicSwitcher(
onGeneral: () -> Unit,
onThread: (ChannelInfo) -> Unit,
onNewThread: () -> Unit,
onRenameThread: (ChannelInfo) -> Unit,
onDeleteThread: (ChannelInfo) -> Unit,
) {
Row(
modifier = Modifier
@@ -1108,22 +1163,74 @@ private fun TopicSwitcher(
) {
TopicChip("General", currentThreadId == null, onClick = onGeneral)
threads.forEach { t ->
TopicChip(t.name, currentThreadId == t.chatId, onClick = { onThread(t) })
TopicChip(
label = t.name,
selected = currentThreadId == t.chatId,
onClick = { onThread(t) },
onRename = { onRenameThread(t) },
onDelete = { onDeleteThread(t) },
)
}
TopicChip("+ topic", false, onClick = onNewThread)
}
}
/**
* A topic chip. When [onRename]/[onDelete] are provided the chip also opens a
* context menu — long-press on touch, right-click on a desktop mouse — with
* "Rename" and "Delete" actions.
*/
@OptIn(ExperimentalFoundationApi::class)
@Composable
internal fun TopicChip(label: String, selected: Boolean, onClick: () -> Unit) {
internal fun TopicChip(
label: String,
selected: Boolean,
onClick: () -> Unit,
onRename: (() -> Unit)? = null,
onDelete: (() -> Unit)? = null,
) {
var menuOpen by remember { mutableStateOf(false) }
val hasMenu = onRename != null || onDelete != null
Box(
modifier = Modifier
.clip(RoundedCornerShape(12.dp))
.background(if (selected) IrisColors.primary else IrisColors.chip)
.clickable(onClick = onClick)
.then(
if (hasMenu) {
// Tap (both platforms) + long-press (touch / held mouse) +
// right-click (desktop mouse, no-op on touch).
Modifier
.combinedClickable(onClick = onClick, onLongClick = { menuOpen = true })
.rightClick { menuOpen = true }
} else {
Modifier.clickable(onClick = onClick)
}
)
.padding(horizontal = 10.dp, vertical = 5.dp),
) {
Text(label, fontSize = 12.sp, color = if (selected) Color.White else IrisColors.textTertiary)
if (hasMenu) {
DropdownMenu(expanded = menuOpen, onDismissRequest = { menuOpen = false }) {
onRename?.let {
DropdownMenuItem(
text = { Text("Rename") },
onClick = {
menuOpen = false
it()
},
)
}
onDelete?.let {
DropdownMenuItem(
text = { Text("Delete") },
onClick = {
menuOpen = false
it()
},
)
}
}
}
}
}
@@ -1312,7 +1419,7 @@ private fun NotificationBanner(
}
@Composable
private fun MessageBubble(msg: MessageItem, maxWidth: Dp, onRetry: () -> Unit) {
private fun MessageBubble(msg: MessageItem, maxWidth: Dp, reasoningAutoCollapse: Boolean, onRetry: () -> Unit) {
val isUser = msg.role == ROLE_USER
val isCommentary = msg.isCommentary
val bubbleColor = when {
@@ -1344,7 +1451,7 @@ private fun MessageBubble(msg: MessageItem, maxWidth: Dp, onRetry: () -> Unit) {
) {
// Reasoning block above the answer (assistant, non-commentary).
if (!isUser && !isCommentary && !msg.reasoning.isNullOrBlank()) {
ReasoningBlock(msg.reasoning!!)
ReasoningBlock(msg.reasoning!!, reasoningAutoCollapse)
Spacer(modifier = Modifier.height(6.dp))
}
if (isUser) {
@@ -1559,13 +1666,15 @@ private fun AttachmentChip(att: IrisController.PendingAttachment, onRemove: () -
/**
* Collapsible reasoning panel (M2): header "💭 Reasoning", monospace body,
* copy button. Collapsed by default when long; tap to toggle.
* copy button. With [autoCollapse] on (Settings → "Reasoning"), long blocks
* start collapsed and short ones expanded; with it off, all blocks start
* expanded. Tap to toggle.
*/
@Composable
private fun ReasoningBlock(reasoning: String) {
private fun ReasoningBlock(reasoning: String, autoCollapse: Boolean) {
val clipboard = LocalClipboardManager.current
var expanded by remember {
mutableStateOf(reasoning.length <= 240)
var expanded by remember(autoCollapse) {
mutableStateOf(!autoCollapse || reasoning.length <= 240)
}
var copied by remember { mutableStateOf(false) }
Column(
@@ -1615,7 +1724,9 @@ private fun ReasoningBlock(reasoning: String) {
*/
@Composable
private fun ToolCard(tool: ToolItem, detail: ToolDetail) {
var expanded by remember { mutableStateOf(false) }
// Everything starts expanded (show the full call + output); Truncated
// starts collapsed and reveals them on tap.
var expanded by remember(detail) { mutableStateOf(detail == ToolDetail.EVERYTHING) }
val status = when {
!tool.done -> null
tool.ok -> "✓"
@@ -1670,8 +1781,8 @@ private fun ToolCard(tool: ToolItem, detail: ToolDetail) {
maxLines = if (expanded) Int.MAX_VALUE else 1,
)
}
// Full args + output preview (Everything, expanded).
if (detail == ToolDetail.EVERYTHING && expanded) {
// Full args + output (revealed on expand in both Truncated and Everything).
if (expanded) {
tool.args?.let {
Spacer(modifier = Modifier.height(4.dp))
Text(
@@ -1,8 +1,6 @@
package iris.ui.screens
import androidx.compose.foundation.background
import androidx.compose.foundation.layout.Arrangement
import androidx.compose.foundation.layout.Box
import androidx.compose.foundation.layout.Column
import androidx.compose.foundation.layout.Spacer
import androidx.compose.foundation.layout.fillMaxSize
@@ -14,7 +12,6 @@ import androidx.compose.foundation.shape.RoundedCornerShape
import androidx.compose.foundation.text.KeyboardOptions
import androidx.compose.foundation.verticalScroll
import androidx.compose.material3.Button
import androidx.compose.material3.CircularProgressIndicator
import androidx.compose.material3.MaterialTheme
import androidx.compose.material3.OutlinedTextField
import androidx.compose.material3.Text
@@ -135,20 +132,3 @@ fun ConnectScreen(
}
}
/** M7: shown while the initial connection is in flight (state == Connecting). */
@Composable
fun ConnectingScreen(host: String) {
Box(modifier = Modifier.fillMaxSize(), contentAlignment = Alignment.Center) {
Column(
horizontalAlignment = Alignment.CenterHorizontally,
verticalArrangement = Arrangement.spacedBy(16.dp),
) {
CircularProgressIndicator()
Text(
"Connecting to $host…",
style = MaterialTheme.typography.bodyMedium,
color = MaterialTheme.colorScheme.onSurfaceVariant,
)
}
}
}
@@ -41,6 +41,8 @@ import iris.ui.theme.IrisColors
@Composable
fun SettingsScreen(controller: IrisController, onBack: () -> Unit) {
val threadsEnabled by controller.threadsEnabled.collectAsState()
val streamingEnabled by controller.streamingEnabled.collectAsState()
val reasoningAutoCollapse by controller.reasoningAutoCollapse.collectAsState()
val toolDetail by controller.toolDetail.collectAsState()
Box(modifier = Modifier.fillMaxSize().background(IrisColors.background)) {
@@ -74,7 +76,7 @@ fun SettingsScreen(controller: IrisController, onBack: () -> Unit) {
Column(modifier = Modifier.weight(1f)) {
Text("🧵 Threads", fontSize = 14.sp)
Text(
"Show topics in the chat view",
"Show topics; new messages in General start their own topic (AI-named)",
fontSize = 12.sp,
color = IrisColors.textDim,
)
@@ -85,6 +87,44 @@ fun SettingsScreen(controller: IrisController, onBack: () -> Unit) {
)
}
}
SettingsCard {
Row(
verticalAlignment = Alignment.CenterVertically,
modifier = Modifier.fillMaxWidth(),
) {
Column(modifier = Modifier.weight(1f)) {
Text("⚡ Streaming", fontSize = 14.sp)
Text(
"Show replies as they are generated",
fontSize = 12.sp,
color = IrisColors.textDim,
)
}
Switch(
checked = streamingEnabled,
onCheckedChange = { controller.toggleStreaming() },
)
}
}
SettingsCard {
Row(
verticalAlignment = Alignment.CenterVertically,
modifier = Modifier.fillMaxWidth(),
) {
Column(modifier = Modifier.weight(1f)) {
Text("🧠 Reasoning", fontSize = 14.sp)
Text(
"Auto-collapse long reasoning blocks",
fontSize = 12.sp,
color = IrisColors.textDim,
)
}
Switch(
checked = reasoningAutoCollapse,
onCheckedChange = { controller.toggleReasoningAutoCollapse() },
)
}
}
Text(
"Tools",
@@ -36,6 +36,8 @@ class DesktopSecureStore : SecureStore {
val ntfyServer: String = "",
val threadsEnabled: Boolean = false,
val toolDetail: String = "truncated",
val streamingEnabled: Boolean = true,
val reasoningAutoCollapse: Boolean = true,
)
init {
@@ -149,6 +151,20 @@ class DesktopSecureStore : SecureStore {
save(d.copy(toolDetail = value))
}
override var streamingEnabled: Boolean
get() = load().streamingEnabled
set(value) {
val d = load()
save(d.copy(streamingEnabled = value))
}
override var reasoningAutoCollapse: Boolean
get() = load().reasoningAutoCollapse
set(value) {
val d = load()
save(d.copy(reasoningAutoCollapse = value))
}
override fun savePairing(url: String, token: String) {
val d = load()
save(d.copy(serverUrl = url.trim()))
@@ -0,0 +1,20 @@
package iris.ui
import androidx.compose.ui.ExperimentalComposeUiApi
import androidx.compose.ui.Modifier
import androidx.compose.ui.input.pointer.PointerButton
import androidx.compose.ui.input.pointer.PointerEventType
import androidx.compose.ui.input.pointer.pointerInput
@OptIn(ExperimentalComposeUiApi::class)
actual fun Modifier.rightClick(onRightClick: () -> Unit): Modifier =
pointerInput(Unit) {
awaitPointerEventScope {
while (true) {
val event = awaitPointerEvent()
if (event.type == PointerEventType.Press && event.button == PointerButton.Secondary) {
onRightClick()
}
}
}
}