From 573291fc1e28974136453c24d0b90ad52784bfe5 Mon Sep 17 00:00:00 2001 From: ARIA Date: Tue, 25 Aug 2026 11:22:00 +0200 Subject: [PATCH] threads: order topic chips newest-first beneath General The gateway already stored a created timestamp per channel/thread but never sent it over the wire. Now: - protocol.py: _channel_payload() includes created (unix seconds) - Protocol.kt: ChannelInfo.created (default 0.0 for legacy gateways) - ChatScreen: topic switcher sorts threads created-desc (newest right after General, swipe new -> old), name as tie-break - frames.schema.json: document the created field - ChannelCreatedWireTest: wire deserialization + ordering tests --- .../kotlin/iris/protocol/Protocol.kt | 3 ++ .../kotlin/iris/ui/screens/ChatScreen.kt | 7 ++- .../iris/protocol/ChannelCreatedWireTest.kt | 52 +++++++++++++++++++ docs/protocol/frames.schema.json | 2 +- gateway-plugin/protocol.py | 2 + 5 files changed, 64 insertions(+), 2 deletions(-) create mode 100644 app/shared/src/commonTest/kotlin/iris/protocol/ChannelCreatedWireTest.kt diff --git a/app/shared/src/commonMain/kotlin/iris/protocol/Protocol.kt b/app/shared/src/commonMain/kotlin/iris/protocol/Protocol.kt index 88bf75f..fc9a4b6 100644 --- a/app/shared/src/commonMain/kotlin/iris/protocol/Protocol.kt +++ b/app/shared/src/commonMain/kotlin/iris/protocol/Protocol.kt @@ -150,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, + /** Unix timestamp (seconds) when the channel/thread was created; 0.0 if + * the gateway predates the field. Used to order threads newest-first. */ + val created: Double = 0.0, /** 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, diff --git a/app/shared/src/commonMain/kotlin/iris/ui/screens/ChatScreen.kt b/app/shared/src/commonMain/kotlin/iris/ui/screens/ChatScreen.kt index fe49bac..a1970b7 100644 --- a/app/shared/src/commonMain/kotlin/iris/ui/screens/ChatScreen.kt +++ b/app/shared/src/commonMain/kotlin/iris/ui/screens/ChatScreen.kt @@ -175,7 +175,12 @@ fun ChatScreen(controller: IrisController) { 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 } + // Threads newest-first (right after "General") so the user swipes from + // new to old; ties (e.g. created==0 from an old cache) fall back to name. + val threads = + channels + .filter { it.kind == "thread" && it.parentChatId == currentChatId } + .sortedWith(compareByDescending { it.created }.thenBy { it.name.lowercase() }) // M8: unread counts. Per-lane from the store; per-channel aggregated // (flat lane + all threads) for the drawer/rail badges. diff --git a/app/shared/src/commonTest/kotlin/iris/protocol/ChannelCreatedWireTest.kt b/app/shared/src/commonTest/kotlin/iris/protocol/ChannelCreatedWireTest.kt new file mode 100644 index 0000000..3517774 --- /dev/null +++ b/app/shared/src/commonTest/kotlin/iris/protocol/ChannelCreatedWireTest.kt @@ -0,0 +1,52 @@ +package iris.protocol + +import kotlin.test.Test +import kotlin.test.assertEquals + +/** Wire tests for the channel directory `created` field (thread ordering). */ +class ChannelCreatedWireTest { + @Test + fun channelInfoDeserializesCreated() { + val raw = + """ + {"v":1,"type":"channel.created","payload":{"chat_id":"t_9","name":"New topic", + "kind":"thread","parent_chat_id":"default","created":1787648374.37}} + """.trimIndent() + val frame = IrisJson.instance.decodeFromString(Frame.serializer(), raw) + val info = frame.payloadAs() + assertEquals("t_9", info?.chatId) + assertEquals(1787648374.37, info?.created) + } + + @Test + fun channelInfoDefaultsCreatedToZero() { + // Legacy gateways omit the field; the app falls back to name ordering. + val raw = + """{"v":1,"type":"channel.created","payload":{"chat_id":"t_1","name":"Old","kind":"thread"}}""" + val frame = IrisJson.instance.decodeFromString(Frame.serializer(), raw) + assertEquals(0.0, frame.payloadAs()?.created) + } + + @Test + fun threadsSortNewestFirst() { + // Mirrors the topic-switcher ordering in ChatScreen: created desc, + // name asc as the tie-break (e.g. for legacy entries with created==0). + val threads = + listOf( + ChannelInfo(chatId = "t_2", name = "Capital of Romania", kind = "thread", parentChatId = "default", created = 1787232736.0), + ChannelInfo(chatId = "t_1", name = "Capital of France", kind = "thread", parentChatId = "default", created = 1787232221.0), + ChannelInfo( + chatId = "t_9", + name = "Test file operations", + kind = "thread", + parentChatId = "default", + created = 1787422924.0, + ), + ChannelInfo(chatId = "t_0", name = "Legacy b", kind = "thread", parentChatId = "default"), + ChannelInfo(chatId = "t_0a", name = "Legacy a", kind = "thread", parentChatId = "default"), + ) + val ordered = + threads.sortedWith(compareByDescending { it.created }.thenBy { it.name.lowercase() }) + assertEquals(listOf("t_9", "t_2", "t_1", "t_0a", "t_0"), ordered.map { it.chatId }) + } +} diff --git a/docs/protocol/frames.schema.json b/docs/protocol/frames.schema.json index 8bb636a..2f8131c 100644 --- a/docs/protocol/frames.schema.json +++ b/docs/protocol/frames.schema.json @@ -97,7 +97,7 @@ }, "definitions": { "kind": { "type": "string", "enum": ["image", "audio", "video", "document", "voice"] }, - "channel": { "type": "object", "properties": { "chat_id": {"type":"string"}, "name": {"type":"string"}, "kind": {"type":"string","enum":["default","channel","thread"]}, "parent_chat_id": {"type":["string","null"]}, "is_default": {"type":"boolean"}, "archived": {"type":"boolean"}, "auto": {"type":"boolean","description":"Optional; true on channel.created for a gateway-minted auto-thread."}, "favorite": {"type":"boolean","description":"Optional; cosmetic favorite flag (sorts to the top of the list)."}, "icon": {"type":["string","null"],"description":"Optional; cosmetic icon, a base64-encoded image (PNG/JPEG). Absent/null = auto-generated letter avatar."}, "color": {"type":["string","null"],"description":"Optional; cosmetic avatar color override (#RRGGBB). Absent/null = auto-generated name-hash color."}, "automation": {"type":"boolean","description":"Optional; true when the channel is an automation channel (read-only for the user; only receives gateway-originated output such as cron jobs and webhooks). The app hides the composer and the gateway rejects message.send into it. Never set on the default channel."} } }, + "channel": { "type": "object", "properties": { "chat_id": {"type":"string"}, "name": {"type":"string"}, "kind": {"type":"string","enum":["default","channel","thread"]}, "parent_chat_id": {"type":["string","null"]}, "is_default": {"type":"boolean"}, "archived": {"type":"boolean"}, "created": {"type":"number","description":"Optional; unix timestamp (seconds) when the channel/thread was created. The app orders threads newest-first in the topic switcher."}, "auto": {"type":"boolean","description":"Optional; true on channel.created for a gateway-minted auto-thread."}, "favorite": {"type":"boolean","description":"Optional; cosmetic favorite flag (sorts to the top of the list)."}, "icon": {"type":["string","null"],"description":"Optional; cosmetic icon, a base64-encoded image (PNG/JPEG). Absent/null = auto-generated letter avatar."}, "color": {"type":["string","null"],"description":"Optional; cosmetic avatar color override (#RRGGBB). Absent/null = auto-generated name-hash color."}, "automation": {"type":"boolean","description":"Optional; true when the channel is an automation channel (read-only for the user; only receives gateway-originated output such as cron jobs and webhooks). The app hides the composer and the gateway rejects message.send into it. Never set on the default channel."} } }, "media_ref": { "type": "object", "properties": { "media_id": {"type":"string"}, "kind": { "$ref": "#/definitions/kind" }, "mime": {"type":"string"}, "size": {"type":"integer"}, "filename": {"type":"string"}, "message_id": {"type":"string","description":"Optional; set on media.offer to associate the offer with the assistant message it belongs to."} } }, "runtime": { "type": "object", "description": "Structured runtime-metadata footer (app-controlled display). The gateway ALWAYS sends it on final assistant messages; whether/what is shown is a per-app setting (Settings -> Runtime footer), NOT a hermes config. All keys optional; absent when the data is unavailable (e.g. local models have no cost).", "properties": { "model": {"type":"string","description":"Bare model id, vendor prefix dropped (gpt-5.4)."}, "context_pct": {"type":"integer","description":"Last-call context occupancy, 0-100."}, "cwd": {"type":"string","description":"Home-relative working dir (~)."}, "latency": {"type":"number","description":"Wall-clock turn duration, seconds."}, "cost": {"type":"number","description":"Turn cost, USD."} } } }, diff --git a/gateway-plugin/protocol.py b/gateway-plugin/protocol.py index 70a7117..b5822f1 100644 --- a/gateway-plugin/protocol.py +++ b/gateway-plugin/protocol.py @@ -559,6 +559,8 @@ def _channel_payload(entry: dict[str, Any]) -> dict[str, Any]: } if entry.get("parent_chat_id") is not None: payload["parent_chat_id"] = entry["parent_chat_id"] + if entry.get("created"): + payload["created"] = entry["created"] if entry.get("is_default"): payload["is_default"] = True if entry.get("archived"):