Live agent todo list: compact scrollable strip above the composer
Add a todo.update frame (server->app) carrying the agent's full current todo list. The gateway emits it whenever the hermes todo tool completes (the tool result is authoritative even for merge writes) and re-sends a snapshot right after hello so a reconnecting device re-learns the plan. Ephemeral: never outboxed. The app renders it as a compact strip above the composer (max 3 lines, the rest scrollable) mirroring the hermes desktop composer status stack: pending = hollow ring, in_progress = spinner, completed = green check, cancelled = struck through. It auto-scrolls to the current task whenever the active task changes, and hides itself once the list is empty or fully resolved.
This commit is contained in:
1 parent
1ff2ef380c
commit
742916903b
10 files changed
+447
-3
No files matched your search
@@ -20,9 +20,12 @@ import iris.protocol.TYPE_MESSAGE_START
|
||||
import iris.protocol.TYPE_MESSAGE_STOP
|
||||
import iris.protocol.TYPE_MESSAGE_UPDATE
|
||||
import iris.protocol.TYPE_PICKER_CHOICE
|
||||
import iris.protocol.TYPE_TODO_UPDATE
|
||||
import iris.protocol.TYPE_TOOL_END
|
||||
import iris.protocol.TYPE_TOOL_PROGRESS
|
||||
import iris.protocol.TYPE_TOOL_START
|
||||
import iris.protocol.TodoItem
|
||||
import iris.protocol.TodoUpdatePayload
|
||||
import iris.protocol.ToolEndPayload
|
||||
import iris.protocol.ToolProgressPayload
|
||||
import iris.protocol.ToolStartPayload
|
||||
@@ -140,6 +143,12 @@ class ChatStore {
|
||||
private val _currentLane = MutableStateFlow(DEFAULT_LANE)
|
||||
val currentLane: StateFlow<String> = _currentLane.asStateFlow()
|
||||
|
||||
/** The agent's live todo list per lane (todo.update; last-write-wins).
|
||||
* Ephemeral: not persisted — the gateway re-sends a snapshot when the
|
||||
* app reconnects, and the next `todo` tool call refreshes it. */
|
||||
private val _todos = MutableStateFlow<Map<String, List<TodoItem>>>(emptyMap())
|
||||
val todos: StateFlow<Map<String, List<TodoItem>>> = _todos.asStateFlow()
|
||||
|
||||
private var localSeq = 0
|
||||
|
||||
/** When false, `message.start`/`message.update` frames are ignored and each
|
||||
@@ -234,6 +243,7 @@ class ChatStore {
|
||||
TYPE_TOOL_START -> onToolStart(lane, frame)
|
||||
TYPE_TOOL_PROGRESS -> onToolProgress(lane, frame)
|
||||
TYPE_TOOL_END -> onToolEnd(lane, frame)
|
||||
TYPE_TODO_UPDATE -> onTodoUpdate(lane, frame)
|
||||
TYPE_COMMENTARY -> onCommentary(lane, frame)
|
||||
TYPE_MEDIA_OFFER -> onMediaOffer(lane, frame)
|
||||
TYPE_PICKER_CHOICE -> onPickerChoice(lane, frame)
|
||||
@@ -516,6 +526,18 @@ class ChatStore {
|
||||
}
|
||||
}
|
||||
|
||||
// ── todo.update (the agent's live todo list, last-write-wins) ─────────
|
||||
|
||||
private fun onTodoUpdate(
|
||||
lane: String,
|
||||
frame: Frame,
|
||||
) {
|
||||
val p = frame.payloadAs<TodoUpdatePayload>() ?: return
|
||||
val map = _todos.value.toMutableMap()
|
||||
if (p.todos.isEmpty()) map.remove(lane) else map[lane] = p.todos
|
||||
_todos.value = map
|
||||
}
|
||||
|
||||
// ── commentary (dimmed interim beat) ──────────────────────────────────
|
||||
|
||||
private fun onCommentary(
|
||||
|
||||
@@ -47,6 +47,11 @@ const val TYPE_MESSAGE_DELETED = "message.deleted"
|
||||
const val TYPE_TOOL_START = "tool.start"
|
||||
const val TYPE_TOOL_PROGRESS = "tool.progress"
|
||||
const val TYPE_TOOL_END = "tool.end"
|
||||
|
||||
// Agent todo list (live planning state; ephemeral, never outboxed — a
|
||||
// reconnecting device re-learns it from the snapshot after hello.ack).
|
||||
const val TYPE_TODO_UPDATE = "todo.update"
|
||||
|
||||
const val TYPE_COMMENTARY = "commentary"
|
||||
|
||||
// M4 — media (offer; upload/pull are HTTP, docs/19 §19.15)
|
||||
@@ -296,6 +301,30 @@ data class ToolEndPayload(
|
||||
@SerialName("output_preview") val outputPreview: String? = null,
|
||||
)
|
||||
|
||||
// ── todo.update (server -> app): the agent's live todo list ────────────────
|
||||
|
||||
/** One item of the agent's todo list (hermes `todo` tool). [status] is one
|
||||
* of [TODO_PENDING], [TODO_IN_PROGRESS], [TODO_COMPLETED], [TODO_CANCELLED]. */
|
||||
@Serializable
|
||||
data class TodoItem(
|
||||
val id: String,
|
||||
val content: String,
|
||||
val status: String,
|
||||
)
|
||||
|
||||
const val TODO_PENDING = "pending"
|
||||
const val TODO_IN_PROGRESS = "in_progress"
|
||||
const val TODO_COMPLETED = "completed"
|
||||
const val TODO_CANCELLED = "cancelled"
|
||||
|
||||
/** The agent's FULL current todo list for a lane (last-write-wins; the
|
||||
* gateway emits it whenever the `todo` tool completes — the tool result is
|
||||
* authoritative even for merge writes — and as a snapshot on connect). */
|
||||
@Serializable
|
||||
data class TodoUpdatePayload(
|
||||
val todos: List<TodoItem> = emptyList(),
|
||||
)
|
||||
|
||||
// ── M2: commentary frame (server -> app) ────────────────────────────────
|
||||
|
||||
@Serializable
|
||||
|
||||
@@ -56,6 +56,7 @@ import iris.protocol.TYPE_READ_RECEIPT
|
||||
import iris.protocol.TYPE_SEARCH_RESULTS
|
||||
import iris.protocol.TYPE_STATUS
|
||||
import iris.protocol.TYPE_SYNC_DONE
|
||||
import iris.protocol.TYPE_TODO_UPDATE
|
||||
import iris.protocol.TYPE_TOOL_END
|
||||
import iris.protocol.TYPE_TOOL_PROGRESS
|
||||
import iris.protocol.TYPE_TOOL_START
|
||||
@@ -482,6 +483,13 @@ class IrisController(
|
||||
if (frame.cursor == null) chat.onFrame(frame)
|
||||
}
|
||||
|
||||
TYPE_TODO_UPDATE -> {
|
||||
// The agent's live todo list (last-write-wins). Ephemeral:
|
||||
// never outboxed, so it never carries a cursor and is
|
||||
// always applied (a reconnect snapshot just re-sets it).
|
||||
chat.onFrame(frame)
|
||||
}
|
||||
|
||||
TYPE_MESSAGE,
|
||||
TYPE_MESSAGE_START,
|
||||
TYPE_MESSAGE_UPDATE,
|
||||
|
||||
@@ -44,6 +44,7 @@ import androidx.compose.foundation.text.KeyboardActions
|
||||
import androidx.compose.foundation.text.KeyboardOptions
|
||||
import androidx.compose.foundation.verticalScroll
|
||||
import androidx.compose.material.icons.Icons
|
||||
import androidx.compose.material.icons.filled.Check
|
||||
import androidx.compose.material.icons.filled.Favorite
|
||||
import androidx.compose.material.icons.filled.KeyboardArrowDown
|
||||
import androidx.compose.material.icons.filled.Settings
|
||||
@@ -101,6 +102,7 @@ import androidx.compose.ui.text.font.FontFamily
|
||||
import androidx.compose.ui.text.font.FontWeight
|
||||
import androidx.compose.ui.text.input.ImeAction
|
||||
import androidx.compose.ui.text.style.TextAlign
|
||||
import androidx.compose.ui.text.style.TextDecoration
|
||||
import androidx.compose.ui.text.style.TextOverflow
|
||||
import androidx.compose.ui.unit.Dp
|
||||
import androidx.compose.ui.unit.dp
|
||||
@@ -129,6 +131,11 @@ import iris.protocol.ROLE_ASSISTANT
|
||||
import iris.protocol.ROLE_USER
|
||||
import iris.protocol.SearchHit
|
||||
import iris.protocol.SlashCommand
|
||||
import iris.protocol.TODO_CANCELLED
|
||||
import iris.protocol.TODO_COMPLETED
|
||||
import iris.protocol.TODO_IN_PROGRESS
|
||||
import iris.protocol.TODO_PENDING
|
||||
import iris.protocol.TodoItem
|
||||
import iris.state.IrisController
|
||||
import iris.state.ToolDetail
|
||||
import iris.ui.MarkdownText
|
||||
@@ -675,6 +682,18 @@ fun ChatScreen(controller: IrisController) {
|
||||
}
|
||||
}
|
||||
|
||||
// The agent's live todo list (todo.update): a compact, scrollable
|
||||
// strip above the composer so the plan stays visible without
|
||||
// eating the chat. Hidden while the list is empty or fully
|
||||
// resolved (mirrors the hermes desktop composer status stack).
|
||||
if (!isAutomation) {
|
||||
val todosMap by controller.chat.todos.collectAsState()
|
||||
val todoList = todosMap[currentLane].orEmpty()
|
||||
if (todoList.any { it.status == TODO_PENDING || it.status == TODO_IN_PROGRESS }) {
|
||||
TodoStrip(todos = todoList)
|
||||
}
|
||||
}
|
||||
|
||||
// Pending attachments (M4; hidden in automation channels — no composer)
|
||||
if (attachments.isNotEmpty() && !isAutomation) {
|
||||
Row(
|
||||
@@ -3019,3 +3038,158 @@ private fun bestSlashScore(
|
||||
}
|
||||
return best
|
||||
}
|
||||
|
||||
// ── Agent todo strip (todo.update) ────────────────────────────────────────
|
||||
|
||||
/** Rows visible in the strip before it scrolls (a phone screen is precious). */
|
||||
private const val TODO_STRIP_VISIBLE_ROWS = 3
|
||||
|
||||
/**
|
||||
* The agent's live todo list: a compact strip above the composer (max 3
|
||||
* lines, the rest scrollable by tap-drag) so the user stays aware of the
|
||||
* plan and the current task without the chat being disrupted. Style mirrors
|
||||
* the hermes desktop composer status stack: pending = hollow ring,
|
||||
* in_progress = spinner, completed = green check, cancelled = struck
|
||||
* through. Whenever an item's status changes (or a new item appears) the
|
||||
* list auto-scrolls to it.
|
||||
*/
|
||||
@Composable
|
||||
private fun TodoStrip(todos: List<TodoItem>) {
|
||||
val rowHeight = 22.dp
|
||||
val scrollState = rememberScrollState()
|
||||
val rowHeightPx = with(LocalDensity.current) { rowHeight.toPx().roundToInt() }
|
||||
// The list the strip last rendered — the diff against the new list drives
|
||||
// the auto-scroll (first item whose status changed, or that is new).
|
||||
var prevTodos by remember { mutableStateOf<List<TodoItem>?>(null) }
|
||||
|
||||
LaunchedEffect(todos) {
|
||||
val prev = prevTodos
|
||||
prevTodos = todos
|
||||
val target =
|
||||
if (prev == null) {
|
||||
// First render: land on the current task (or the top).
|
||||
todos.indexOfFirst { it.status == TODO_IN_PROGRESS }.takeIf { it >= 0 } ?: 0
|
||||
} else {
|
||||
// Prefer the CURRENT task (in_progress) — that's what the user
|
||||
// is tracking. When a task completes the NEXT one activates in
|
||||
// the same update, so the first *changed* item is the one that
|
||||
// just finished (still visible) while the new active task sits
|
||||
// just below the fold; targeting it is what keeps the current
|
||||
// task in view. Fall back to the first changed/new item when
|
||||
// nothing is in flight (e.g. a freshly appended task).
|
||||
todos
|
||||
.indexOfFirst { it.status == TODO_IN_PROGRESS }
|
||||
.takeIf { it >= 0 }
|
||||
?: todos.indices.indexOfFirst { i -> i >= prev.size || prev[i].status != todos[i].status }
|
||||
}
|
||||
if (target in todos.indices) {
|
||||
val top = target * rowHeightPx
|
||||
val viewport = TODO_STRIP_VISIBLE_ROWS * rowHeightPx
|
||||
val maxScroll = (todos.size * rowHeightPx - viewport).coerceAtLeast(0)
|
||||
if (top < scrollState.value || top + rowHeightPx > scrollState.value + viewport) {
|
||||
scrollState.animateScrollTo(top.coerceIn(0, maxScroll))
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
val completed = todos.count { it.status == TODO_COMPLETED }
|
||||
Column(
|
||||
modifier =
|
||||
Modifier
|
||||
.fillMaxWidth()
|
||||
.padding(horizontal = 12.dp, vertical = 4.dp),
|
||||
) {
|
||||
Box(
|
||||
modifier =
|
||||
Modifier
|
||||
.fillMaxWidth()
|
||||
.clip(RoundedCornerShape(12.dp))
|
||||
.background(IrisColors.surface)
|
||||
.border(1.dp, IrisColors.divider, RoundedCornerShape(12.dp)),
|
||||
) {
|
||||
Column(modifier = Modifier.padding(horizontal = 10.dp, vertical = 6.dp)) {
|
||||
Row(verticalAlignment = Alignment.CenterVertically) {
|
||||
Text("📋", fontSize = 12.sp)
|
||||
Spacer(Modifier.width(6.dp))
|
||||
Text(
|
||||
"Tasks",
|
||||
fontSize = 12.sp,
|
||||
fontWeight = FontWeight.SemiBold,
|
||||
color = IrisColors.textSecondary,
|
||||
)
|
||||
Spacer(Modifier.weight(1f))
|
||||
Text(
|
||||
"$completed/${todos.size}",
|
||||
fontSize = 12.sp,
|
||||
color = IrisColors.textSecondary,
|
||||
)
|
||||
}
|
||||
Spacer(Modifier.height(4.dp))
|
||||
Column(
|
||||
modifier =
|
||||
Modifier
|
||||
.fillMaxWidth()
|
||||
.height(rowHeight * TODO_STRIP_VISIBLE_ROWS)
|
||||
.verticalScroll(scrollState),
|
||||
) {
|
||||
todos.forEach { item -> TodoRow(item, rowHeight) }
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/** One todo row: status glyph + single-line content (truncated). */
|
||||
@Composable
|
||||
private fun TodoRow(
|
||||
item: TodoItem,
|
||||
rowHeight: Dp,
|
||||
) {
|
||||
val resolved = item.status == TODO_COMPLETED || item.status == TODO_CANCELLED
|
||||
Row(
|
||||
modifier =
|
||||
Modifier
|
||||
.fillMaxWidth()
|
||||
.height(rowHeight),
|
||||
verticalAlignment = Alignment.CenterVertically,
|
||||
horizontalArrangement = Arrangement.spacedBy(6.dp),
|
||||
) {
|
||||
when (item.status) {
|
||||
TODO_IN_PROGRESS -> {
|
||||
CircularProgressIndicator(
|
||||
modifier = Modifier.size(12.dp),
|
||||
strokeWidth = 1.5.dp,
|
||||
color = IrisColors.primary,
|
||||
)
|
||||
}
|
||||
|
||||
TODO_COMPLETED -> {
|
||||
Icon(
|
||||
imageVector = Icons.Filled.Check,
|
||||
contentDescription = null,
|
||||
tint = IrisColors.statusGreen,
|
||||
modifier = Modifier.size(12.dp),
|
||||
)
|
||||
}
|
||||
|
||||
else -> {
|
||||
Box(
|
||||
modifier =
|
||||
Modifier
|
||||
.size(12.dp)
|
||||
.clip(CircleShape)
|
||||
.border(1.dp, IrisColors.textDim, CircleShape),
|
||||
)
|
||||
}
|
||||
}
|
||||
Text(
|
||||
text = item.content,
|
||||
fontSize = 12.sp,
|
||||
maxLines = 1,
|
||||
overflow = TextOverflow.Ellipsis,
|
||||
textDecoration = if (item.status == TODO_CANCELLED) TextDecoration.LineThrough else null,
|
||||
color = if (resolved) IrisColors.textDim else IrisColors.textBright,
|
||||
modifier = Modifier.weight(1f),
|
||||
)
|
||||
}
|
||||
}
|
||||
Reference in new issue
Block a user