threads: order topic chips newest-first beneath General
CI / Gateway plugin tests (push) Successful in 5m17s
CI / Kotlin tests (android host + desktop) (push) Successful in 6m58s

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
This commit is contained in:
ARIA committed 2026-08-25 11:22:00 +02:00
1 parent 83a67f6fb1
commit 573291fc1e
5 files changed
+64 -2

No files matched your search

@@ -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,
@@ -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<ChannelInfo> { 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.
@@ -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<ChannelInfo>()
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<ChannelInfo>()?.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<ChannelInfo> { it.created }.thenBy { it.name.lowercase() })
assertEquals(listOf("t_9", "t_2", "t_1", "t_0a", "t_0"), ordered.map { it.chatId })
}
}
+1 -1
View File
@@ -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."} } }
},
+2
View File
@@ -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"):