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:
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))
|
||||
|
||||
Reference in new issue
Block a user