Channels: default-only threading + automation (read-only) channels — threading (topic switcher, Ctrl+T, auto-threading) is now only active on the default channel, and the gateway ignores auto_thread for other channels; new channel.set_automation frame marks a channel read-only for cron/webhook output (app hides the composer behind a notice, gateway rejects message.send, default channel cannot be marked, flag syncs to all devices via the full-entry channel.renamed response, gear badge in the list); header pill drops the 'Bot' subtitle and shows the chat_id on automation channels (tap to copy for cron delivery targeting); also fixes a pre-existing stray brace that made frames.schema.json invalid JSON

This commit is contained in:
ARIA committed 2026-08-21 09:39:32 +02:00
1 parent d7cd59b685
commit 10e9565e2e
9 files changed
+256 -37

No files matched your search

@@ -71,6 +71,7 @@ const val TYPE_CHANNEL_RENAME = "channel.rename"
const val TYPE_CHANNEL_SET_DEFAULT = "channel.set_default"
const val TYPE_CHANNEL_FAVORITE = "channel.favorite"
const val TYPE_CHANNEL_ICON = "channel.icon"
const val TYPE_CHANNEL_SET_AUTOMATION = "channel.set_automation"
const val TYPE_CHANNEL_DELETE = "channel.delete"
const val TYPE_CHANNEL_CREATED = "channel.created"
const val TYPE_CHANNEL_RENAMED = "channel.renamed"
@@ -168,6 +169,10 @@ data class ChannelInfo(
/** Cosmetic avatar color override ("#RRGGBB"), or null for the
* auto-generated name-hash color. */
val color: String? = null,
/** Automation channel: read-only for the user. It only receives
* gateway-originated output (cron jobs, webhooks); the app hides the
* composer and the gateway rejects direct sends into it. */
val automation: Boolean = false,
)
@Serializable
@@ -339,6 +344,9 @@ data class ChannelRenamePayload(val name: String)
@Serializable
data class ChannelFavoritePayload(val on: Boolean)
@Serializable
data class ChannelSetAutomationPayload(val on: Boolean)
@Serializable
data class ChannelIconPayload(
/** base64-encoded image, or null to clear the icon. */
@@ -540,6 +548,17 @@ fun channelFavoriteFrame(id: Int, chatId: String, on: Boolean): Frame =
),
)
fun channelSetAutomationFrame(id: Int, chatId: String, on: Boolean): Frame =
Frame(
id = id,
type = TYPE_CHANNEL_SET_AUTOMATION,
chatId = chatId,
payload = IrisJson.instance.encodeToJsonElement(
ChannelSetAutomationPayload.serializer(),
ChannelSetAutomationPayload(on = on),
),
)
fun channelIconFrame(id: Int, chatId: String, icon: String?, color: String?): Frame =
Frame(
id = id,
@@ -59,6 +59,7 @@ import iris.protocol.channelFavoriteFrame
import iris.protocol.channelIconFrame
import iris.protocol.channelListFrame
import iris.protocol.channelRenameFrame
import iris.protocol.channelSetAutomationFrame
import iris.protocol.channelSetDefaultFrame
import iris.protocol.historyFrame
import iris.protocol.messageDeleteFrame
@@ -553,6 +554,12 @@ class IrisController(
client.sendFrame(channelFavoriteFrame(0, chatId, on))
}
/** Mark the channel as automation (read-only; cron/webhook output only).
* The default channel cannot be marked (the server enforces this too). */
fun setChannelAutomation(chatId: String, on: Boolean) {
client.sendFrame(channelSetAutomationFrame(0, chatId, on))
}
/** Set the channel's cosmetic icon (base64 image) and/or avatar color.
* Pass null to clear a field. */
fun setChannelIcon(chatId: String, icon: String?, color: String?) {
@@ -618,16 +625,21 @@ class IrisController(
)
}
chat.addPending(trimmed, lane, media)
client.sendMessage(chatId, trimmed, threadId, refs, autoThread = wantsAutoThread(trimmed, threadId))
client.sendMessage(chatId, trimmed, threadId, refs, autoThread = wantsAutoThread(trimmed, threadId, chatId))
_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("/")
/** Auto-threading (Settings → "Threads", docs/06 §6.3): a message in the
* default channel's flat lane gets its own fresh thread, AI-named by the
* gateway (Telegram topic-mode workflow). Threading is only active on
* the default channel; other channels stay flat. Slash commands and
* media-only sends stay in the flat lane. */
private fun wantsAutoThread(text: String, threadId: String?, chatId: String): Boolean =
_threadsEnabled.value &&
threadId == null &&
text.isNotEmpty() &&
!text.startsWith("/") &&
channels.byId(chatId)?.isDefault == true
/** M7: resend a failed user message (tap on the failed bubble). */
fun retrySend(messageId: String) {
@@ -641,7 +653,7 @@ class IrisController(
item.text,
threadId,
item.media.map { it.mediaId },
autoThread = wantsAutoThread(item.text, threadId),
autoThread = wantsAutoThread(item.text, threadId, chatId),
)
}
@@ -44,6 +44,7 @@ import androidx.compose.foundation.verticalScroll
import androidx.compose.material.icons.Icons
import androidx.compose.material.icons.filled.Favorite
import androidx.compose.material.icons.filled.KeyboardArrowDown
import androidx.compose.material.icons.filled.Settings
import androidx.compose.material.icons.filled.Star
import androidx.compose.material3.AlertDialog
import androidx.compose.material3.Button
@@ -264,7 +265,11 @@ fun ChatScreen(controller: IrisController) {
showSearch || showPicker || showPalette || showNewChannel || showNewThread || showSettings
when {
ctrlOrMeta && e.key == Key.N -> { showNewChannel = true; true }
ctrlOrMeta && e.key == Key.T -> { showNewThread = true; true }
// Threads are only active on the default channel.
ctrlOrMeta && e.key == Key.T -> {
if (currentChannel?.isDefault == true) showNewThread = true
true
}
ctrlOrMeta && e.key == Key.F -> { showSearch = true; true }
ctrlOrMeta && e.key == Key.K -> { showPalette = true; true }
e.key == Key.Escape && overlayOpen -> {
@@ -304,6 +309,11 @@ fun ChatScreen(controller: IrisController) {
selectedIds = setOf(id)
}
// Automation channels are read-only for the user (cron/webhook output
// only): the composer is replaced by a notice, and the gateway rejects
// direct sends into them anyway.
val isAutomation = currentChannel?.automation == true
fun toggleSelect(id: String) {
selectedIds = if (id in selectedIds) selectedIds - id else selectedIds + id
if (selectedIds.isEmpty()) selectionMode = false
@@ -335,7 +345,11 @@ fun ChatScreen(controller: IrisController) {
Text("☰", fontSize = 18.sp)
}
}
TitlePill(currentChannel, Modifier.weight(1f))
val headerClipboard = LocalClipboardManager.current
TitlePill(currentChannel, Modifier.weight(1f)) { id ->
headerClipboard.setText(AnnotatedString(id))
toastMessage = "Copied $id"
}
Spacer(modifier = Modifier.width(6.dp))
StatusBubble(state) { toastMessage = statusToastText(state) }
Spacer(modifier = Modifier.width(4.dp))
@@ -358,8 +372,9 @@ fun ChatScreen(controller: IrisController) {
}
}
// Topic switcher (threads on)
if (threadsEnabled) {
// Topic switcher (threads on; the default channel only — other channels
// don't use threads).
if (threadsEnabled && currentChannel?.isDefault == true) {
TopicSwitcher(
threads = threads,
currentThreadId = currentThreadId,
@@ -469,8 +484,8 @@ fun ChatScreen(controller: IrisController) {
}
}
// Pending attachments (M4)
if (attachments.isNotEmpty()) {
// Pending attachments (M4; hidden in automation channels — no composer)
if (attachments.isNotEmpty() && !isAutomation) {
Row(
modifier = Modifier
.fillMaxWidth()
@@ -552,7 +567,28 @@ fun ChatScreen(controller: IrisController) {
isConnected && (input.isNotBlank() || attachments.any { it.mediaRef != null && it.error == null })
val layoutDensity = LocalDensity.current.density
var textHeightPx by remember { mutableFloatStateOf(0f) }
if (!selectionMode) Row(
if (isAutomation) {
// Read-only notice in place of the composer.
Box(
modifier = Modifier
.fillMaxWidth()
.padding(horizontal = 12.dp, vertical = 8.dp),
contentAlignment = Alignment.Center,
) {
Box(
modifier = Modifier
.clip(RoundedCornerShape(16.dp))
.background(IrisColors.panel)
.padding(horizontal = 14.dp, vertical = 8.dp),
) {
Text(
"⚙ Automation channel — read-only. Hermes posts cron/webhook output here; direct messages are not allowed.",
fontSize = 12.sp,
color = IrisColors.textTertiary,
)
}
}
} else if (!selectionMode) Row(
modifier = Modifier
.fillMaxWidth()
.padding(horizontal = 12.dp, vertical = 8.dp)
@@ -687,6 +723,7 @@ fun ChatScreen(controller: IrisController) {
onRename = { ch -> channelToRename = ch },
onToggleFavorite = { ch -> controller.setChannelFavorite(ch.chatId, !ch.favorite) },
onEditIcon = { ch -> channelToEditIcon = ch },
onToggleAutomation = { ch -> controller.setChannelAutomation(ch.chatId, !ch.automation) },
onDelete = { ch -> channelToDelete = ch },
onOpenSettings = { showSettings = true },
)
@@ -734,6 +771,10 @@ fun ChatScreen(controller: IrisController) {
channelToEditIcon = ch
drawerScope.launch { drawerState.close() }
},
onToggleAutomation = { ch ->
controller.setChannelAutomation(ch.chatId, !ch.automation)
drawerScope.launch { drawerState.close() }
},
onDelete = { ch ->
channelToDelete = ch
drawerScope.launch { drawerState.close() }
@@ -973,14 +1014,30 @@ private fun SystemMessage(msg: MessageItem) {
}
}
/** M7: header title pill — channel avatar + channel name + "Bot" subtitle. */
/** M7: header title pill — channel avatar + channel name. Automation
* channels show their chat_id as a subtitle (tap the pill to copy it) so
* the user can target them for cron delivery (`deliver="android:<chat_id>"`)
* without asking the agent which channel is in use. */
@Composable
private fun TitlePill(channel: ChannelInfo?, modifier: Modifier = Modifier) {
private fun TitlePill(
channel: ChannelInfo?,
modifier: Modifier = Modifier,
onCopyChatId: (String) -> Unit = {},
) {
val channelName = channel?.name ?: "Iris"
val chatId = channel?.chatId
val isAutomation = channel?.automation == true
Box(
modifier = modifier
.clip(RoundedCornerShape(16.dp))
.background(IrisColors.surface)
.then(
if (isAutomation) {
Modifier.clickable { chatId?.let(onCopyChatId) }
} else {
Modifier
}
)
.padding(start = 8.dp, end = 12.dp, top = 4.dp, bottom = 4.dp),
) {
Row(verticalAlignment = Alignment.CenterVertically) {
@@ -995,11 +1052,15 @@ private fun TitlePill(channel: ChannelInfo?, modifier: Modifier = Modifier) {
maxLines = 1,
overflow = TextOverflow.Ellipsis,
)
Text(
"Bot",
style = MaterialTheme.typography.bodySmall,
color = IrisColors.textDim,
)
if (isAutomation && chatId != null) {
Text(
chatId,
style = MaterialTheme.typography.bodySmall,
color = IrisColors.textDim,
maxLines = 1,
overflow = TextOverflow.Ellipsis,
)
}
}
}
}
@@ -1087,7 +1148,8 @@ private fun IconImage(base64: String, size: Dp) {
/**
* Trailing badges for a channel row: a star marks the default (home) channel,
* a heart marks a favorited channel. A channel that is both shows both.
* a heart marks a favorited channel, a gear marks an automation channel
* (read-only; cron/webhook output only).
*/
@Composable
private fun ChannelBadges(ch: ChannelInfo) {
@@ -1109,12 +1171,22 @@ private fun ChannelBadges(ch: ChannelInfo) {
tint = IrisColors.textMuted,
)
}
if (ch.automation) {
Spacer(modifier = Modifier.width(4.dp))
Icon(
imageVector = Icons.Filled.Settings,
contentDescription = "Automation channel (read-only)",
modifier = Modifier.size(14.dp),
tint = IrisColors.textMuted,
)
}
}
/**
* Long-press / right-click context menu for a channel row: rename, favorite
* (state-aware), icon (state-aware), delete. The default channel cannot be
* deleted (the server enforces this too), so its "Delete" item is hidden.
* (state-aware), icon (state-aware), automation (state-aware), delete. The
* default channel cannot be deleted or marked automation (the server enforces
* both), so those items are hidden for it.
*/
@Composable
private fun ChannelContextMenu(
@@ -1124,6 +1196,7 @@ private fun ChannelContextMenu(
onRename: () -> Unit,
onToggleFavorite: () -> Unit,
onEditIcon: () -> Unit,
onToggleAutomation: () -> Unit,
onDelete: () -> Unit,
) {
val hasIcon = ch.icon != null || ch.color != null
@@ -1138,6 +1211,10 @@ private fun ChannelContextMenu(
onClick = { onDismiss(); onEditIcon() },
)
if (!ch.isDefault) {
DropdownMenuItem(
text = { Text(if (ch.automation) "Unmark automation" else "Mark as automation") },
onClick = { onDismiss(); onToggleAutomation() },
)
DropdownMenuItem(text = { Text("Delete") }, onClick = { onDismiss(); onDelete() })
}
}
@@ -1236,6 +1313,7 @@ private fun ChannelDrawer(
onRename: (ChannelInfo) -> Unit,
onToggleFavorite: (ChannelInfo) -> Unit,
onEditIcon: (ChannelInfo) -> Unit,
onToggleAutomation: (ChannelInfo) -> Unit,
onDelete: (ChannelInfo) -> Unit,
onOpenSettings: () -> Unit,
) {
@@ -1280,6 +1358,7 @@ private fun ChannelDrawer(
onRename = { onRename(ch) },
onToggleFavorite = { onToggleFavorite(ch) },
onEditIcon = { onEditIcon(ch) },
onToggleAutomation = { onToggleAutomation(ch) },
onDelete = { onDelete(ch) },
)
}
@@ -1313,6 +1392,7 @@ private fun ChannelRail(
onRename: (ChannelInfo) -> Unit,
onToggleFavorite: (ChannelInfo) -> Unit,
onEditIcon: (ChannelInfo) -> Unit,
onToggleAutomation: (ChannelInfo) -> Unit,
onDelete: (ChannelInfo) -> Unit,
onOpenSettings: () -> Unit,
) {
@@ -1401,6 +1481,7 @@ private fun ChannelRail(
onRename = { onRename(ch) },
onToggleFavorite = { onToggleFavorite(ch) },
onEditIcon = { onEditIcon(ch) },
onToggleAutomation = { onToggleAutomation(ch) },
onDelete = { onDelete(ch) },
)
}
@@ -1448,6 +1529,7 @@ private fun InspectorPane(
InfoRow("Name", currentChannel?.name ?: "—")
InfoRow("ID", currentChannel?.chatId ?: "—")
InfoRow("Default", if (currentChannel?.isDefault == true) "yes" else "no")
InfoRow("Automation", if (currentChannel?.automation == true) "yes" else "no")
InfoRow("Threads", threads.size.toString())
Spacer(modifier = Modifier.height(8.dp))
Text("Last reply", style = MaterialTheme.typography.titleSmall, modifier = Modifier.padding(bottom = 4.dp))