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:
1 parent
1d67de5f12
commit
60ec2b44a7
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()
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
Reference in new issue
Block a user