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
This commit is contained in:
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 })
|
||||
}
|
||||
}
|
||||
@@ -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."} } }
|
||||
},
|
||||
|
||||
@@ -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"):
|
||||
|
||||
Reference in new issue
Block a user