feat: render display_kind timeline markers as chips, not user bubbles (#904) (#909)

Co-authored-by: M57 <hy4ri@users.noreply.github.com>
This commit is contained in:
M57andM57 authored and GitHub committed 2026-08-15 14:06:51 +03:00
1 parent 85087c70c7
commit 4be1e965f2
16 files changed
+438 -11

No files matched your search

@@ -40,4 +40,6 @@ data class ChatMessageEntity(
val toolStatus: String? = null,
@ColumnInfo(name = "is_streaming")
val isStreaming: Boolean = false,
@ColumnInfo(name = "display_kind")
val displayKind: String? = null,
)
@@ -34,6 +34,7 @@ fun ChatMessageEntity.toUiModel(): ChatMessage =
"FAILED" -> ToolStatus.FAILED
else -> null
},
displayKind = displayKind,
)
fun ChatMessage.toEntity(sessionId: String): ChatMessageEntity =
@@ -48,4 +49,5 @@ fun ChatMessage.toEntity(sessionId: String): ChatMessageEntity =
toolCallId = toolCallId,
toolStatus = toolStatus?.name,
isStreaming = isStreaming,
displayKind = displayKind,
)
@@ -11,7 +11,7 @@ import java.io.File
@Database(
entities = [ChatMessageEntity::class],
version = 5,
version = 6,
exportSchema = true,
)
abstract class HermesDatabase : RoomDatabase() {
@@ -68,13 +68,30 @@ abstract class HermesDatabase : RoomDatabase() {
}
}
val migration5to6 =
object : Migration(5, 6) {
override fun migrate(db: SupportSQLiteDatabase) {
// Issue #904: timeline markers (display_kind) keep
// their tag through the Room cache so a cached
// marker never degrades back into a user bubble.
db.execSQL(
"ALTER TABLE `chat_messages` ADD COLUMN `display_kind` TEXT",
)
}
}
instance ?: Room
.databaseBuilder(
context.applicationContext,
HermesDatabase::class.java,
"hermes_control.db",
).openHelperFactory(factory)
.addMigrations(migration2to3, migration3to4, migration4to5)
.addMigrations(
migration2to3,
migration3to4,
migration4to5,
migration5to6,
)
.fallbackToDestructiveMigration(false)
.build()
.also { instance = it }
@@ -39,6 +39,15 @@ data class SessionMessage(
val reasoning: JsonElement? = null,
val reasoning_text: JsonElement? = null,
val tool_call_id: String? = null,
/**
* Timeline-marker tag (backend NS-656 lineage, issue #904): markers like
* `model_switch` / `personality_switch` / `auto_continue` ride as
* role=user rows so strict providers accept them mid-conversation, but
* they are NOT user turns. Nullable/additive — old backends omit it.
*/
val display_kind: String? = null,
/** Display-only metadata for the marker (e.g. delegation result counts). */
val display_metadata: JsonElement? = null,
) {
val timestampText: String?
get() = (timestamp as? JsonPrimitive)?.content
@@ -59,6 +59,13 @@ data class ChatMessage(
* Transient — not persisted to SQLite.
*/
val progressPreview: String? = null,
/**
* Timeline-marker tag carried from the backend's `display_kind`
* (issue #904): `model_switch` / `personality_switch` / `auto_continue`
* ride as role=user rows but are NOT user input — the chat list renders
* them as centered timeline chips instead of user bubbles.
*/
val displayKind: String? = null,
)
/**
@@ -2919,6 +2919,7 @@ class ChatViewModel(
attachments = attachments,
timestamp = timestamp,
isStreaming = false,
displayKind = msg.display_kind,
),
)
}
@@ -256,10 +256,17 @@ fun FullBleedChatList(
val sysMessage = entry.message
item(key = "sys-${sysMessage.id}") {
Column(modifier = Modifier.padding(bottom = 6.dp)) {
FullBleedSystemEvent(
message = sysMessage,
onRespondApproval = viewModel::respondToApproval,
)
if (sysMessage.displayKind != null) {
// Timeline marker (issue #904):
// model/personality switches and
// auto-continues render as a chip.
TimelineMarkerChip(message = sysMessage)
} else {
FullBleedSystemEvent(
message = sysMessage,
onRespondApproval = viewModel::respondToApproval,
)
}
}
}
entryIndex++
@@ -46,15 +46,20 @@ fun groupIntoTurns(messages: List<ChatMessage>): List<ChatTurn> {
}
messages.forEach { message ->
when (message.role) {
MessageRole.USER -> {
when {
// Timeline markers (display_kind) ride as role=user rows but are
// NOT user turns — group them as system-style timeline entries so
// they render as centered chips, not fake user bubbles (issue #904).
message.displayKind != null -> agentEntries += AgentEntry.SystemEvent(message)
message.role == MessageRole.USER -> {
flushAgent()
turns += ChatTurn.User(message)
}
MessageRole.ASSISTANT -> agentEntries += AgentEntry.Prose(message)
MessageRole.TOOL -> agentEntries += AgentEntry.ToolRow(message)
MessageRole.SYSTEM -> agentEntries += AgentEntry.SystemEvent(message)
MessageRole.ASSISTANT == message.role -> agentEntries += AgentEntry.Prose(message)
MessageRole.TOOL == message.role -> agentEntries += AgentEntry.ToolRow(message)
MessageRole.SYSTEM == message.role -> agentEntries += AgentEntry.SystemEvent(message)
}
}
flushAgent()
@@ -0,0 +1,91 @@
package com.m57.hermescontrol.ui.chat.fullbleed
import androidx.compose.foundation.layout.Box
import androidx.compose.foundation.layout.fillMaxWidth
import androidx.compose.foundation.layout.padding
import androidx.compose.foundation.shape.RoundedCornerShape
import androidx.compose.material3.MaterialTheme
import androidx.compose.material3.Surface
import androidx.compose.material3.Text
import androidx.compose.runtime.Composable
import androidx.compose.ui.Alignment
import androidx.compose.ui.Modifier
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
/**
* Friendly label resource for a `display_kind` timeline marker (issue #904).
* Returns `null` for kinds we don't have a label for — the caller falls back
* to the generic system-event rendering (raw content) instead of inventing
* copy for a marker we don't understand.
*/
internal fun timelineMarkerLabelRes(kind: String?): Int? =
when (kind) {
"model_switch" -> R.string.timeline_marker_model_switch
"personality_switch" -> R.string.timeline_marker_personality_switch
"auto_continue" -> R.string.timeline_marker_auto_continue
"async_delegation_complete" -> R.string.timeline_marker_delegation_complete
else -> null
}
/**
* Model name parsed from a model-switch marker's content, e.g.
* `[System: The active model for this chat has changed to gpt-5 via provider
* openai. ...]` → `gpt-5`. The marker text is model-facing scaffolding — the
* chip shows the human-meaningful name instead of the whole block. Interior
* dots are kept (`gpt-4.5`); a sentence-ending dot is stripped.
*/
internal fun markerModelFromContent(content: String): String? {
val raw = MARKER_MODEL_REGEX.find(content)?.groupValues?.getOrNull(1) ?: return null
val model = raw.removeSuffix(".").trim()
return model.takeIf { it.isNotBlank() }
}
private val MARKER_MODEL_REGEX = Regex("""changed to ([A-Za-z0-9_.\-]+)""")
/**
* Centered timeline chip for a `display_kind` marker (issue #904): model
* switches, personality switches and auto-continues render as a small
* divider-style chip instead of a fake user bubble. Known kinds get friendly
* copy; the model-switch chip names the model when the marker content
* carries it.
*/
@Composable
internal fun TimelineMarkerChip(
message: ChatMessage,
modifier: Modifier = Modifier,
) {
val labelRes = timelineMarkerLabelRes(message.displayKind) ?: return
val text =
if (message.displayKind == "model_switch") {
val model = markerModelFromContent(message.content)
if (model != null) {
stringResource(R.string.timeline_marker_model_switch_to, model)
} else {
stringResource(R.string.timeline_marker_model_switch)
}
} else {
stringResource(labelRes)
}
Box(
modifier = modifier.fillMaxWidth(),
contentAlignment = Alignment.Center,
) {
Surface(
shape = RoundedCornerShape(50),
color = MaterialTheme.colorScheme.surfaceContainerHigh,
modifier = Modifier.testTag("timeline_marker"),
) {
Text(
text = text,
style = MaterialTheme.typography.labelSmall,
color = MaterialTheme.colorScheme.onSurfaceVariant,
modifier = Modifier.padding(horizontal = 10.dp, vertical = 4.dp),
)
}
}
}