feat(chat): simplify assistant message states (#1131)

This commit is contained in:
M57 authored and GitHub committed 2026-09-15 21:43:20 +03:00
1 parent e6b441cfc6
commit 984053b0e7
15 files changed
+552 -201

No files matched your search

@@ -15,7 +15,6 @@ import androidx.compose.animation.shrinkVertically
import androidx.compose.foundation.BorderStroke
import androidx.compose.foundation.ExperimentalFoundationApi
import androidx.compose.foundation.background
import androidx.compose.foundation.isSystemInDarkTheme
import androidx.compose.foundation.layout.Box
import androidx.compose.foundation.layout.Column
import androidx.compose.foundation.layout.Row
@@ -359,7 +358,6 @@ fun ChatScreen(
}
var viewingImage by rememberSaveable { mutableStateOf<ImageViewerModel?>(null) }
val snackbarHostState = remember { SnackbarHostState() }
val isDark = isSystemInDarkTheme()
val launchExternalActivity: (() -> Unit) -> Unit = { launch ->
ExternalActivityLifecycleGuard.launchExternalActivity(
acquireConnectionLease = HermesWsClient::acquireExternalActivityConnectionLease,
@@ -668,14 +666,14 @@ fun ChatScreen(
// surface since the bubble renderer was removed.
FullBleedChatList(
messages = state.messages,
streamingMessage = streamingState.streamingMessage,
streamingState = streamingState,
isAgentTyping = state.isAgentTyping,
searchState = searchState,
typingEffectEnabled = state.typingEffectEnabled,
typingEffectDelayMs = state.typingEffectDelayMs,
maxToolCallsPerTurn = state.maxToolCallsPerTurn,
isLoading = state.isLoading,
isLoadingOlder = state.isLoadingOlder,
isDark = isDark,
listState = listState,
scrollController = scrollController,
lastAnimatedMessageId = lastAnimatedMessageId,
@@ -1518,6 +1518,16 @@ class ChatViewModel(
else -> error.toString()
}
if (method == WsMethods.PROMPT_SUBMIT || method == WsMethods.SESSION_REDIRECT) {
// A prompt rejection can arrive before message.start, leaving the
// UI with only the optimistic typing state. Clear the live tail so
// a failed generation cannot leave stale dots or reasoning behind.
sealStreamingMessageIfAny()
_uiState.update { it.copy(isAgentTyping = false) }
_streamingState.update { StreamingState() }
streamingController.resetStreaming()
}
// Session resume failures go through the bounded retry (desktop
// parity) instead of a one-shot snackbar — the session may be
// mid-flush on the gateway or the WS may have just rebound, and a
@@ -456,10 +456,11 @@ object ChatWsEventReducer {
streamingState: StreamingState,
): ReducerResult {
val streaming =
streamingState.streamingMessage ?: return ReducerResult(
state = state,
streamingState = streamingState,
)
streamingState.streamingMessage
?: return ReducerResult(
state = state.copy(isAgentTyping = false),
streamingState = streamingState,
)
val reasoning =
if (streamingState.reasoningText.isNotBlank()) {
streamingState.reasoningText
@@ -578,7 +579,12 @@ object ChatWsEventReducer {
val nextTodos = parsedTodos ?: newState.todos
return ReducerResult(
state = newState.copy(messages = newState.messages + toolMessage, todos = nextTodos),
state =
newState.copy(
messages = newState.messages + toolMessage,
isAgentTyping = true,
todos = nextTodos,
),
streamingState = finalStreamingState,
effects = effects,
)
@@ -810,6 +816,7 @@ object ChatWsEventReducer {
ReducerResult(
state =
state.copy(
isAgentTyping = false,
isLoading = false,
errorMessage = "Backend error: ${event.message ?: "Unknown gateway error"}",
),
@@ -68,6 +68,8 @@ import androidx.compose.ui.platform.LocalConfiguration
import androidx.compose.ui.platform.LocalContext
import androidx.compose.ui.platform.testTag
import androidx.compose.ui.res.stringResource
import androidx.compose.ui.semantics.contentDescription
import androidx.compose.ui.semantics.semantics
import androidx.compose.ui.text.AnnotatedString
import androidx.compose.ui.text.SpanStyle
import androidx.compose.ui.text.buildAnnotatedString
@@ -746,13 +748,19 @@ fun SubagentCard(
// ── TypingIndicator ───────────────────────────────────────────────────────
/**
* Three bouncing dots shown while the assistant is typing / thinking.
* Staggered animation: 0ms, 150ms, 300ms delay per dot.
* Three subtle dots shown while the assistant is waiting to produce visible
* content. Staggered opacity/scale animation keeps the indicator lightweight
* without the distracting vertical bounce used by the old chat renderer.
*/
@Composable
fun TypingIndicator(modifier: Modifier = Modifier) {
val description = stringResource(R.string.chat_agent_status_typing)
Row(
modifier = modifier.padding(horizontal = 16.dp, vertical = 8.dp).testTag("typing_indicator"),
modifier =
modifier
.padding(horizontal = 16.dp, vertical = 8.dp)
.semantics { contentDescription = description }
.testTag("typing_indicator"),
verticalAlignment = Alignment.CenterVertically,
horizontalArrangement = Arrangement.spacedBy(4.dp),
) {
@@ -768,18 +776,18 @@ private fun TypingDot(delayMs: Int) {
val typingSpec: InfiniteRepeatableSpec<Float> =
remember(delayMs) {
infiniteRepeatable(
animation = tween(400, delayMillis = delayMs, easing = LinearEasing),
animation = tween(700, delayMillis = delayMs, easing = LinearEasing),
repeatMode = RepeatMode.Reverse,
)
}
val offset by infiniteTransition.animateFloat(
initialValue = 0f,
targetValue = -6f,
initialValue = 0.85f,
targetValue = 1f,
animationSpec = typingSpec,
label = "typing_dot_offset_$delayMs",
label = "typing_dot_scale_$delayMs",
)
val alpha by infiniteTransition.animateFloat(
initialValue = 0.4f,
initialValue = 0.45f,
targetValue = 1f,
animationSpec = typingSpec,
label = "typing_dot_alpha_$delayMs",
@@ -790,7 +798,8 @@ private fun TypingDot(delayMs: Int) {
.size(8.dp)
.clip(CircleShape)
.graphicsLayer {
this.translationY = offset
this.scaleX = offset
this.scaleY = offset
this.alpha = alpha
},
) {
@@ -0,0 +1,172 @@
package com.m57.hermescontrol.ui.chat.fullbleed
import androidx.compose.foundation.layout.Arrangement
import androidx.compose.foundation.layout.Row
import androidx.compose.foundation.layout.fillMaxWidth
import androidx.compose.foundation.layout.padding
import androidx.compose.foundation.layout.size
import androidx.compose.material3.Icon
import androidx.compose.material3.MaterialTheme
import androidx.compose.material3.Text
import androidx.compose.runtime.Composable
import androidx.compose.ui.Alignment
import androidx.compose.ui.Modifier
import androidx.compose.ui.graphics.vector.ImageVector
import androidx.compose.ui.platform.testTag
import androidx.compose.ui.res.stringResource
import androidx.compose.ui.unit.dp
import com.m57.hermescontrol.R
import com.m57.hermescontrol.ui.chat.ChatMessage
import com.m57.hermescontrol.ui.chat.MessageRole
import com.m57.hermescontrol.ui.chat.StreamingState
import com.m57.hermescontrol.ui.chat.ToolSchemaRegistry
import com.m57.hermescontrol.ui.chat.ToolStatus
import com.m57.hermescontrol.ui.chat.components.TypingIndicator
import com.m57.hermescontrol.ui.chat.isUserTurnBoundary
/** Live status for an agent turn before visible assistant prose is available. */
internal sealed interface AgentStatus {
data object Typing : AgentStatus
data object Thinking : AgentStatus
data class Tool(
val name: String?,
) : AgentStatus
}
/**
* Derives the one status that should occupy the agent turn's live tail.
*
* Tool execution wins over generic states, and visible streaming prose wins
* over waiting/reasoning states. This keeps one indicator on screen at a time
* without introducing a second generation state store.
*/
internal fun deriveAgentStatus(
isAgentTyping: Boolean,
streamingState: StreamingState,
messages: List<ChatMessage>,
): AgentStatus? {
if (!isAgentTyping) return null
val currentTurnStart = messages.indexOfLast { it.isUserTurnBoundary() }
val currentTurnMessages =
if (currentTurnStart >= 0) {
messages.drop(currentTurnStart + 1)
} else {
emptyList()
}
val activeTool =
currentTurnMessages
.asReversed()
.firstOrNull {
it.role == MessageRole.TOOL &&
it.toolStatus == ToolStatus.RUNNING
}
val hasVisibleStreamingContent = streamingState.streamingMessage?.content?.isNotBlank() == true
return when {
activeTool != null -> AgentStatus.Tool(activeTool.toolName)
hasVisibleStreamingContent -> null
streamingState.isThinking || streamingState.isReasoning -> AgentStatus.Thinking
isAgentTyping -> AgentStatus.Typing
else -> null
}
}
/** Maps existing canonical tool names to compact, user-facing activity text. */
internal fun toolStatusLabelRes(toolName: String?): Int =
when (toolName?.lowercase()) {
"web_search",
"search_files",
"session_search",
"tool_search",
"x_search",
-> R.string.chat_agent_status_searching
"read_file",
-> R.string.chat_agent_status_reading
"web_extract",
"browser_navigate",
"browser_click",
"browser_type",
"browser_scroll",
"browser_back",
"browser_press",
"browser_snapshot",
"browser_get_images",
"browser_console",
"browser_cdp",
"browser_dialog",
"browser_vision",
-> R.string.chat_agent_status_browsing
"terminal",
"execute_code",
"process",
"process_manage",
-> R.string.chat_agent_status_running_command
else -> R.string.chat_agent_status_using_tool
}
@Composable
internal fun AgentStatusIndicator(
status: AgentStatus,
modifier: Modifier = Modifier,
) {
when (status) {
AgentStatus.Typing -> {
TypingIndicator(modifier = modifier)
}
AgentStatus.Thinking -> {
StatusText(
text = stringResource(R.string.chat_agent_status_thinking),
icon = null,
modifier = modifier.testTag("agent_status_thinking"),
)
}
is AgentStatus.Tool -> {
val config = ToolSchemaRegistry.getDisplayConfig(status.name)
val labelRes = toolStatusLabelRes(status.name)
StatusText(
text = stringResource(labelRes),
icon = config.icon,
modifier = modifier.testTag("agent_status_tool"),
)
}
}
}
@Composable
private fun StatusText(
text: String,
icon: ImageVector?,
modifier: Modifier = Modifier,
) {
Row(
modifier =
modifier
.fillMaxWidth()
.padding(horizontal = 16.dp, vertical = 8.dp),
verticalAlignment = Alignment.CenterVertically,
horizontalArrangement = Arrangement.spacedBy(8.dp),
) {
if (icon != null) {
Icon(
imageVector = icon,
contentDescription = null,
modifier = Modifier.size(16.dp),
tint = MaterialTheme.colorScheme.onSurfaceVariant,
)
}
Text(
text = text,
style = MaterialTheme.typography.bodySmall,
color = MaterialTheme.colorScheme.onSurfaceVariant,
)
}
}
@@ -1,7 +1,6 @@
package com.m57.hermescontrol.ui.chat.fullbleed
import android.content.ClipData
import android.text.format.DateFormat
import androidx.compose.foundation.layout.Column
import androidx.compose.foundation.layout.Row
import androidx.compose.foundation.layout.Spacer
@@ -9,7 +8,6 @@ import androidx.compose.foundation.layout.fillMaxWidth
import androidx.compose.foundation.layout.height
import androidx.compose.foundation.layout.padding
import androidx.compose.foundation.layout.size
import androidx.compose.foundation.layout.width
import androidx.compose.foundation.text.selection.SelectionContainer
import androidx.compose.material.icons.Icons
import androidx.compose.material.icons.filled.Check
@@ -29,10 +27,8 @@ import androidx.compose.ui.Alignment
import androidx.compose.ui.Modifier
import androidx.compose.ui.platform.ClipEntry
import androidx.compose.ui.platform.LocalClipboard
import androidx.compose.ui.platform.LocalContext
import androidx.compose.ui.platform.testTag
import androidx.compose.ui.res.stringResource
import androidx.compose.ui.text.font.FontWeight
import androidx.compose.ui.unit.dp
import com.m57.hermescontrol.R
import com.m57.hermescontrol.data.model.Attachment
@@ -40,7 +36,6 @@ import com.m57.hermescontrol.ui.chat.ChatMessage
import com.m57.hermescontrol.ui.chat.ImageViewerModel
import com.m57.hermescontrol.ui.chat.InlineAttachment
import com.m57.hermescontrol.ui.chat.MarkdownText
import com.m57.hermescontrol.ui.chat.TokenEstimator
import com.m57.hermescontrol.ui.chat.components.ReasoningCard
import kotlinx.coroutines.delay
import kotlinx.coroutines.launch
@@ -50,17 +45,12 @@ import kotlinx.coroutines.launch
*
* Unlike [com.m57.hermescontrol.ui.chat.ChatBubble], agent prose renders
* directly on the background — no bubble container, no width cap — with a
* turn header (role + time) and a trailing copy affordance. User messages
* keep their bubbles; this composable is only used for ASSISTANT messages.
*
* [showTurnHeader] renders the role/time header — true only for the first
* prose entry of an agent turn so multi-message turns don't repeat it.
* trailing copy affordance. User messages keep their bubbles; this composable
* is only used for ASSISTANT messages.
*/
@Composable
internal fun FullBleedAgentMessage(
message: ChatMessage,
showTurnHeader: Boolean,
isDarkTheme: Boolean,
searchQuery: String = "",
isCurrentMatch: Boolean = false,
showReasoning: Boolean = true,
@@ -92,10 +82,6 @@ internal fun FullBleedAgentMessage(
.padding(horizontal = 16.dp)
.testTag("fullbleed_agent_message"),
) {
if (showTurnHeader) {
AssistantTurnHeader(message.timestamp)
}
if (showReasoning && message.reasoningText.isNotBlank()) {
ReasoningCard(
reasoningText = message.reasoningText,
@@ -105,10 +91,10 @@ internal fun FullBleedAgentMessage(
}
// Defense-in-depth: never render an empty prose block (blank bubble +
// lone Copy button). Blank settled rows are tool-call placeholders that
// slipped through upstream mapping; streaming keeps rendering so the
// live cursor survives until the first delta lands.
if (message.content.isNotBlank() || message.isStreaming) {
// lone Copy button). Blank rows are tool-call placeholders that slipped
// through upstream mapping; the parent list renders the live status
// indicator until the first visible delta lands.
if (message.content.isNotBlank()) {
SelectionContainer {
MarkdownText(
text = message.content,
@@ -140,7 +126,7 @@ internal fun FullBleedAgentMessage(
}
}
if (!message.isStreaming) {
if (!message.isStreaming && message.content.isNotBlank()) {
Row(
verticalAlignment = Alignment.CenterVertically,
) {
@@ -160,97 +146,7 @@ internal fun FullBleedAgentMessage(
tint = MaterialTheme.colorScheme.onSurfaceVariant,
)
}
Spacer(modifier = Modifier.width(4.dp))
val finishTime = message.finishTimestamp ?: message.timestamp
Text(
text =
com.m57.hermescontrol.ui.chat.formatTimestamp(
finishTime,
DateFormat.is24HourFormat(LocalContext.current),
),
style = MaterialTheme.typography.labelSmall,
color = MaterialTheme.colorScheme.onSurfaceVariant.copy(alpha = 0.7f),
modifier = Modifier.testTag("fullbleed_finish_time"),
)
if (message.tokenCount != null && message.tokenCount > 0) {
Spacer(modifier = Modifier.width(6.dp))
Text(
text = "•",
style = MaterialTheme.typography.labelSmall,
color = MaterialTheme.colorScheme.onSurfaceVariant.copy(alpha = 0.5f),
)
Spacer(modifier = Modifier.width(6.dp))
Text(
text =
stringResource(
R.string.chat_msg_tokens,
TokenEstimator.formatTokenCount(message.tokenCount),
),
style = MaterialTheme.typography.labelSmall,
color = MaterialTheme.colorScheme.onSurfaceVariant.copy(alpha = 0.7f),
modifier = Modifier.testTag("fullbleed_token_count"),
)
}
if (message.tps != null && message.tps > 0.0) {
Spacer(modifier = Modifier.width(6.dp))
Text(
text = "•",
style = MaterialTheme.typography.labelSmall,
color = MaterialTheme.colorScheme.onSurfaceVariant.copy(alpha = 0.5f),
)
Spacer(modifier = Modifier.width(6.dp))
Text(
text =
stringResource(
R.string.chat_msg_tps,
TokenEstimator.formatTps(message.tps),
),
style = MaterialTheme.typography.labelSmall,
color = MaterialTheme.colorScheme.onSurfaceVariant.copy(alpha = 0.7f),
modifier = Modifier.testTag("fullbleed_tps"),
)
}
}
}
}
}
/**
* Agent turn header — "Agent · <time>". Shared by the prose message and the
* hoisted reasoning block so both can lead with the assistant identity and
* timestamp.
*/
@Composable
internal fun AssistantTurnHeader(
timestamp: Long,
modifier: Modifier = Modifier,
) {
Row(
modifier = modifier.padding(bottom = 4.dp).testTag("fullbleed_agent_header"),
verticalAlignment = Alignment.CenterVertically,
) {
Text(
text = stringResource(R.string.fullbleed_role_agent),
style =
MaterialTheme.typography.labelMedium.copy(
fontWeight = FontWeight.SemiBold,
),
// Meta color (not primary): Nord's light mode reuses its
// pastel Frost accent as primary, which fails >= 3:1 on a
// light background. The header is metadata, so the dimmed
// meta token is both more correct and gate-compliant in
// every preset.
color = MaterialTheme.colorScheme.onSurfaceVariant,
)
Spacer(modifier = Modifier.width(8.dp))
Text(
text =
com.m57.hermescontrol.ui.chat.formatTimestamp(
timestamp,
DateFormat.is24HourFormat(LocalContext.current),
),
style = MaterialTheme.typography.labelSmall,
color = MaterialTheme.colorScheme.onSurfaceVariant,
)
}
}
@@ -27,6 +27,7 @@ import com.m57.hermescontrol.ui.chat.ChatSearchState
import com.m57.hermescontrol.ui.chat.ChatViewModel
import com.m57.hermescontrol.ui.chat.ClarifyUi
import com.m57.hermescontrol.ui.chat.ImageViewerModel
import com.m57.hermescontrol.ui.chat.StreamingState
import com.m57.hermescontrol.ui.chat.ToolCallDivider
import com.m57.hermescontrol.ui.chat.VaultCodePromptUi
import com.m57.hermescontrol.ui.chat.VaultSaveLoginPromptUi
@@ -43,8 +44,8 @@ import com.m57.hermescontrol.ui.common.EmptyState
/**
* The chat message list for FULL-BLEED style (issue #866) — the single chat
* surface since the bubble renderer was removed. User messages keep their
* bubble (the universal anchor), agent turns render full-bleed with a turn
* header, and tool rows / system events render as distinct compact cards.
* bubble (the universal anchor), agent turns render full-bleed, and tool rows
* / system events render as distinct compact cards.
* Spacing contract:
* - intra-turn: entries separated by 6.dp (Column padding on agent turn items)
* - inter-turn: 12.dp bottom padding after each turn's last item
@@ -52,20 +53,20 @@ import com.m57.hermescontrol.ui.common.EmptyState
* COMPOSE GOTCHA (verified): LazyColumn `item {}` content lambdas execute
* LAZILY at item-composition time, not during this DSL-building loop. Loop
* locals that are read inside item lambdas must be captured as immutable
* vals FIRST (eagerly), or every item sees the loop's final value — the
* turn header would never render and milestones would be misindexed.
* vals FIRST (eagerly), or every item sees the loop's final value and
* per-entry state can be rendered against the wrong message.
*/
@Composable
fun FullBleedChatList(
messages: List<ChatMessage>,
streamingMessage: ChatMessage?,
streamingState: StreamingState,
isAgentTyping: Boolean,
searchState: ChatSearchState,
typingEffectEnabled: Boolean,
typingEffectDelayMs: Int,
maxToolCallsPerTurn: Int? = null,
isLoading: Boolean,
isLoadingOlder: Boolean,
isDark: Boolean,
listState: androidx.compose.foundation.lazy.LazyListState,
scrollController: ChatScrollController,
lastAnimatedMessageId: String?,
@@ -89,7 +90,7 @@ fun FullBleedChatList(
openingAttachmentPath: String? = null,
onImageClick: (ImageViewerModel) -> Unit = {},
) {
if (messages.isEmpty() && !isLoading) {
if (messages.isEmpty() && !isLoading && !isAgentTyping) {
Box(
modifier = Modifier.fillMaxSize(),
contentAlignment = Alignment.Center,
@@ -102,9 +103,10 @@ fun FullBleedChatList(
} else {
val toolMilestones = remember(messages) { toolCallMilestones(messages) }
val turns =
remember(messages, streamingMessage) {
groupIntoTurnsWithStreaming(messages, streamingMessage)
remember(messages, streamingState.streamingMessage) {
groupIntoTurnsWithStreaming(messages, streamingState.streamingMessage)
}
val agentStatus = deriveAgentStatus(isAgentTyping, streamingState, messages)
// messageId → LazyColumn item index. The lazy list has EXTRA items
// vs the message list (reasoning hoists, tool rows), so search-match
// scrolling must resolve the lazy index, not use the message index.
@@ -188,7 +190,6 @@ fun FullBleedChatList(
}
is ChatTurn.Agent -> {
var firstProseSeen = false
// Reasoning hoist: the turn's reasoning renders at the
// TOP of the turn — above tool rows — so thinking
// leads, then the tool work, then the answer. The
@@ -201,9 +202,6 @@ fun FullBleedChatList(
val reasoning = turnReasoning.message
item(key = "reasoning-${reasoning.id}") {
Column(modifier = Modifier.padding(bottom = 6.dp)) {
// Lead the turn with the assistant identity
// and timestamp, then the thinking block.
AssistantTurnHeader(timestamp = reasoning.timestamp)
ReasoningCard(
reasoningText = reasoning.reasoningText,
isStreaming = reasoning.isStreaming,
@@ -215,50 +213,46 @@ fun FullBleedChatList(
when (entry) {
is AgentEntry.Prose -> {
val proseMessage = entry.message
val showTurnHeader = !firstProseSeen && turnReasoning == null
val hoistedReasoning =
turnReasoning != null &&
proseMessage.id == turnReasoning.message.id
item(key = "prose-${proseMessage.id}") {
Column(modifier = Modifier.padding(bottom = 12.dp)) {
if (proseMessage.isStreaming && typingEffectEnabled) {
StreamingFullBleedWithTypingEffect(
streaming = proseMessage,
typingDelayMs = typingEffectDelayMs,
isDark = isDark,
showTurnHeader = showTurnHeader,
showReasoning = !hoistedReasoning,
)
} else {
FullBleedAgentMessage(
message = proseMessage,
showTurnHeader = showTurnHeader,
isDarkTheme = isDark,
// Highlight only bubbles that actually contain a match —
// the rest skip the highlight scan entirely.
searchQuery =
if (searchState.isActive &&
proseMessage.id in searchState.matchedIds
) {
searchState.query
} else {
""
},
isCurrentMatch =
searchState.currentMatchId != null &&
searchState.currentMatchId == proseMessage.id,
showReasoning = !hoistedReasoning,
onOpenAttachment = viewModel::openAttachment,
onSaveAttachment = onSaveAttachment,
savingAttachmentPath = savingAttachmentPath,
openingAttachmentPath = openingAttachmentPath,
canSaveAttachment = savingAttachmentPath == null,
onImageClick = onImageClick,
)
if (proseMessage.hasVisibleAgentContent()) {
item(key = "prose-${proseMessage.id}") {
Column(modifier = Modifier.padding(bottom = 12.dp)) {
if (proseMessage.isStreaming && typingEffectEnabled) {
StreamingFullBleedWithTypingEffect(
streaming = proseMessage,
typingDelayMs = typingEffectDelayMs,
showReasoning = !hoistedReasoning,
)
} else {
FullBleedAgentMessage(
message = proseMessage,
// Highlight only bubbles that actually contain a match —
// the rest skip the highlight scan entirely.
searchQuery =
if (searchState.isActive &&
proseMessage.id in searchState.matchedIds
) {
searchState.query
} else {
""
},
isCurrentMatch =
searchState.currentMatchId != null &&
searchState.currentMatchId == proseMessage.id,
showReasoning = !hoistedReasoning,
onOpenAttachment = viewModel::openAttachment,
onSaveAttachment = onSaveAttachment,
savingAttachmentPath = savingAttachmentPath,
openingAttachmentPath = openingAttachmentPath,
canSaveAttachment = savingAttachmentPath == null,
onImageClick = onImageClick,
)
}
}
}
}
firstProseSeen = true
}
is AgentEntry.ToolRow -> {
@@ -298,6 +292,12 @@ fun FullBleedChatList(
}
}
agentStatus?.let { status ->
item(key = "agent_status") {
AgentStatusIndicator(status = status)
}
}
// Clarify bubble — rendered at the very bottom
if (clarifyRequest != null) {
item(key = "clarify_bubble") {
@@ -28,8 +28,6 @@ import kotlinx.coroutines.delay
internal fun StreamingFullBleedWithTypingEffect(
streaming: ChatMessage,
typingDelayMs: Int,
isDark: Boolean,
showTurnHeader: Boolean = true,
showReasoning: Boolean = true,
onAnimationComplete: () -> Unit = {},
) {
@@ -86,9 +84,7 @@ internal fun StreamingFullBleedWithTypingEffect(
FullBleedAgentMessage(
message = streaming.copy(content = displayText),
showTurnHeader = showTurnHeader,
showReasoning = showReasoning,
isDarkTheme = isDark,
searchQuery = "",
isCurrentMatch = false,
)
@@ -37,6 +37,9 @@ sealed interface AgentEntry {
) : AgentEntry
}
/** Empty assistant placeholders carry no user-visible prose and need no list item. */
internal fun ChatMessage.hasVisibleAgentContent(): Boolean = content.isNotBlank() || !attachments.isNullOrEmpty()
/**
* Stable content prefix the backend uses for its max-iterations runtime nudge
* (`handle_max_iterations` in run_agent.py → chat_completion_helpers.py, text
@@ -145,15 +148,16 @@ fun groupIntoTurnsWithStreaming(
* full-bleed renderer.
*
* The lazy list does NOT have one item per message: each agent turn with a
* reasoning block emits an extra `reasoning-<id>` item BEFORE its prose, and
* tool rows / system events are items too. Scrolling a search match by raw
* message index therefore lands on the WRONG item whenever reasoning or tool
* rows precede the target — the classic "match is above/below the view" bug.
* reasoning block emits an extra `reasoning-<id>` item BEFORE its prose, empty
* assistant placeholders emit no prose item, and tool rows / system events are
* items too. Scrolling a search match by raw message index therefore lands on
* the WRONG item whenever reasoning or tool rows precede the target — the
* classic "match is above/below the view" bug.
*
* Mirrors the item emission order in [FullBleedChatList] exactly:
* user turn → 1 item; agent turn → optional reasoning item, then one item per
* entry. [leadingItems] accounts for fixed items emitted before the turns
* (e.g. the `loading-older` spinner when paging).
* visible entry. [leadingItems] accounts for fixed items emitted before the
* turns (e.g. the `loading-older` spinner when paging).
*
* @return messageId → LazyColumn item index of the message's content item
* (prose items for agent messages; user items for user messages).
@@ -183,8 +187,10 @@ fun messageIdToLazyIndex(
turn.entries.forEach { entry ->
when (entry) {
is AgentEntry.Prose -> {
map[entry.message.id] = itemIndex
itemIndex++
if (entry.message.hasVisibleAgentContent()) {
map[entry.message.id] = itemIndex
itemIndex++
}
}
is AgentEntry.ToolRow -> {