refactor(chat): single owner for display kinds and REST row ids (#1364)

This commit is contained in:
m57 authored and GitHub committed 2026-09-28 20:26:13 +03:00
1 parent 9aefc5bfc7
commit 12ddef09a0
11 files changed
+107 -33

No files matched your search

@@ -66,7 +66,9 @@ internal fun canonicalMessageOrder(
id: String,
sessionId: String,
): Long? {
val prefix = "rest-$sessionId-"
val prefix =
com.m57.hermescontrol.ui.chat.RestMessageId
.sessionPrefix(sessionId)
if (!id.startsWith(prefix)) return null
val suffix = id.removePrefix(prefix)
return suffix.takeIf { it.isNotEmpty() && it.all { char -> char in '0'..'9' } }?.toLongOrNull()
@@ -84,7 +84,7 @@ class ChatClarifyDelegate(
ChatMessage(
role = MessageRole.USER,
content = displayContent,
displayKind = "clarify_response",
displayKind = DisplayKind.CLARIFY_RESPONSE,
)
uiState.update { state ->
@@ -36,7 +36,7 @@ private const val MAX_ITERATIONS_SYSTEM_MARKER =
*/
internal fun ChatMessage.isUserTurnBoundary(): Boolean =
role == MessageRole.USER &&
(displayKind == null || displayKind == "steer" || displayKind == "clarify_response") &&
(displayKind == null || displayKind == DisplayKind.STEER || displayKind == DisplayKind.CLARIFY_RESPONSE) &&
!content.startsWith(MAX_ITERATIONS_SYSTEM_MARKER)
/**
@@ -117,7 +117,7 @@ data class ChatMessage(
/** Cached REST rows already carry their canonical identity in the persisted primary key. */
internal val ChatMessage.canonicalRestId: String?
get() = restId ?: id.takeIf { it.startsWith("rest-") }
get() = restId ?: id.takeIf(RestMessageId::isRest)
/** A persisted RUNNING snapshot is not evidence of current tool activity. */
internal val ChatMessage.isToolRunning: Boolean
@@ -0,0 +1,40 @@
package com.m57.hermescontrol.ui.chat
/**
* Known `display_kind` wire values (#1336). [ChatMessage.displayKind] stays a raw
* string because the backend can add kinds; these names keep the ones the app
* interprets in one place.
*/
internal object DisplayKind {
const val STEER = "steer"
const val CLARIFY_RESPONSE = "clarify_response"
const val LOCAL_FEEDBACK = "local_feedback"
const val MODEL_SWITCH = "model_switch"
const val PERSONALITY_SWITCH = "personality_switch"
const val AUTO_CONTINUE = "auto_continue"
const val ASYNC_DELEGATION_COMPLETE = "async_delegation_complete"
const val SKILL_INVOCATION = "skill_invocation"
const val INTERNAL_NOTIFICATION = "internal_notification"
const val MAX_ITERATIONS_REACHED = "max_iterations_reached"
/** Kinds that tag real user or local rows rather than timeline markers. */
val nonMarkerKinds = setOf(STEER, CLARIFY_RESPONSE, LOCAL_FEEDBACK)
}
/**
* The single owner of the REST transcript row-id format (#1336):
* `rest-<sessionId>-<rowKey>`, where rowKey is the server row id under newest-anchored
* paging or an absolute position under legacy paging.
*/
internal object RestMessageId {
private const val PREFIX = "rest-"
fun of(
sessionId: String,
rowKey: Any,
): String = "$PREFIX$sessionId-$rowKey"
fun sessionPrefix(sessionId: String): String = "$PREFIX$sessionId-"
fun isRest(id: String): Boolean = id.startsWith(PREFIX)
}
@@ -51,9 +51,9 @@ internal fun mapServerMessages(
fun restIdAt(index: Int): String =
if (stableRowIds) {
"rest-$sessionId-${requireNotNull(messages[index].id) { "Latest transcript row has no stable id" }}"
RestMessageId.of(sessionId, requireNotNull(messages[index].id) { "Latest transcript row has no stable id" })
} else {
"rest-$sessionId-${offset + index}"
RestMessageId.of(sessionId, offset + index)
}
val wsCompletionIdByRestIndex = mutableMapOf<Int, String>()
@@ -278,7 +278,7 @@ internal fun mapServerMessages(
}
// REST echoes must not reserve a match before the richer WS copy of that tool.
val liveTools = liveMessages.filter { it.role == MessageRole.TOOL && !it.id.startsWith("rest-") }
val liveTools = liveMessages.filter { it.role == MessageRole.TOOL && !RestMessageId.isRest(it.id) }
val mappedTools = mapped.filter { it.role == MessageRole.TOOL }
val matches = matchTranscriptMessages(mappedTools, liveTools)
val toolsById = mappedTools.indices.associate { index -> mappedTools[index].id to matches[index] }
@@ -174,12 +174,12 @@ internal class TranscriptComparison(
// Rows generated only by Mobile never have a canonical REST counterpart.
// Do not let coincidentally identical server text claim their identity.
if (a.role == MessageRole.ASSISTANT &&
(a.displayKind == "local_feedback" || b.displayKind == "local_feedback")
(a.displayKind == DisplayKind.LOCAL_FEEDBACK || b.displayKind == DisplayKind.LOCAL_FEEDBACK)
) {
return false
}
if (a.role == MessageRole.USER &&
(a.displayKind == "clarify_response" || b.displayKind == "clarify_response")
(a.displayKind == DisplayKind.CLARIFY_RESPONSE || b.displayKind == DisplayKind.CLARIFY_RESPONSE)
) {
return false
}
@@ -323,8 +323,8 @@ internal fun dedupeCachedMessages(
confirmedOnly: Boolean = false,
): List<ChatMessage> {
val unique = messages.dedupeById()
val rest = unique.filter { it.id.startsWith("rest-") }
val live = unique.filterNot { it.id.startsWith("rest-") }
val rest = unique.filter { RestMessageId.isRest(it.id) }
val live = unique.filterNot { RestMessageId.isRest(it.id) }
if (rest.isEmpty() || live.isEmpty()) return unique
val matches =
matchTranscriptMessages(rest, live).mapIndexed { index, match ->
@@ -405,7 +405,7 @@ internal fun mergeCachedTranscriptPage(
)
}
match.id.startsWith("rest-") && !message.id.startsWith("rest-") -> {
RestMessageId.isRest(match.id) && !RestMessageId.isRest(message.id) -> {
message
}
@@ -463,7 +463,7 @@ internal fun mergeTranscriptWithLive(
)
}
preserveLiveIds && match != null && !match.id.startsWith("rest-") -> {
preserveLiveIds && match != null && !RestMessageId.isRest(match.id) -> {
val mergedContent =
if (match.role == MessageRole.ASSISTANT &&
ChatVerifierFooter.split(match.content) != null &&
@@ -586,8 +586,8 @@ private fun List<ChatMessage>.inTranscriptOrder(
internal fun ChatMessage.isPermanentlyLocal(): Boolean =
role == MessageRole.SYSTEM ||
(role == MessageRole.USER && (content.startsWith("/") || displayKind == "clarify_response")) ||
(role == MessageRole.ASSISTANT && displayKind == "local_feedback")
(role == MessageRole.USER && (content.startsWith("/") || displayKind == DisplayKind.CLARIFY_RESPONSE)) ||
(role == MessageRole.ASSISTANT && displayKind == DisplayKind.LOCAL_FEEDBACK)
internal fun ChatMessage.isSessionStartMarker(): Boolean =
role == MessageRole.SYSTEM && (content == "Session created" || content == "Session branched")
@@ -635,6 +635,6 @@ internal fun mergeIncrementalTranscriptPage(
internal fun serverMessageIndex(
id: String,
sessionId: String,
): Int? = id.removePrefix("rest-$sessionId-").takeIf { it != id }?.toIntOrNull()
): Int? = id.removePrefix(RestMessageId.sessionPrefix(sessionId)).takeIf { it != id }?.toIntOrNull()
internal fun List<ChatMessage>.dedupeById(): List<ChatMessage> = associateBy { it.id }.values.toList()
@@ -4136,7 +4136,7 @@ class ChatViewModel(
ChatMessage(
role = MessageRole.ASSISTANT,
content = text,
displayKind = "local_feedback",
displayKind = DisplayKind.LOCAL_FEEDBACK,
)
_uiState.update { it.copy(messages = it.messages + msg) }
@@ -4748,7 +4748,7 @@ class ChatViewModel(
)
}
if (!valid()) return@launch
val targetId = "rest-$sessionId-$rowId"
val targetId = RestMessageId.of(sessionId, rowId)
if (page.none { it.id == targetId || it.canonicalRestId == targetId }) {
_timelineState.update {
it.copy(windowErrorMessage = "The selected prompt is no longer available.")
@@ -5106,7 +5106,7 @@ class ChatViewModel(
// A mapped page can reuse a live WS message. Never overwrite its newer persisted
// version with the snapshot used for mapping; WS owns persistence of those IDs.
val pageIds = page.mapNotNull { it.canonicalRestId }.toSet()
val aliases = _uiState.value.messages.filter { it.restId in pageIds && !it.id.startsWith("rest-") }
val aliases = _uiState.value.messages.filter { it.restId in pageIds && !RestMessageId.isRest(it.id) }
withContext(historyDispatcher) {
repo.persistMessages(
page.mapNotNull { message -> message.canonicalRestId?.let { message.copy(id = it, restId = null) } },
@@ -1,6 +1,7 @@
package com.m57.hermescontrol.ui.chat.fullbleed
import com.m57.hermescontrol.ui.chat.ChatMessage
import com.m57.hermescontrol.ui.chat.DisplayKind
import com.m57.hermescontrol.ui.chat.MessageRole
import com.m57.hermescontrol.ui.chat.SearchMatch
import com.m57.hermescontrol.ui.chat.SearchTarget
@@ -59,11 +60,7 @@ internal fun ChatMessage.isSyntheticSystemRow(): Boolean =
displayKind == null &&
content.startsWith(MAX_ITERATIONS_SYSTEM_MARKER)
internal fun ChatMessage.isTimelineMarker(): Boolean =
displayKind != null &&
displayKind != "steer" &&
displayKind != "clarify_response" &&
displayKind != "local_feedback"
internal fun ChatMessage.isTimelineMarker(): Boolean = displayKind != null && displayKind !in DisplayKind.nonMarkerKinds
/**
* Split a flat message list into turns for the full-bleed renderer.
@@ -99,7 +96,7 @@ fun groupIntoTurns(messages: List<ChatMessage>): List<ChatTurn> {
message.isSyntheticSystemRow() -> {
agentEntries +=
AgentEntry.SystemEvent(message.copy(displayKind = "max_iterations_reached"))
AgentEntry.SystemEvent(message.copy(displayKind = DisplayKind.MAX_ITERATIONS_REACHED))
}
message.role == MessageRole.USER -> {
@@ -15,6 +15,7 @@ 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.DisplayKind
/**
* Friendly label resource for a `display_kind` timeline marker (issue #904).
@@ -24,13 +25,13 @@ import com.m57.hermescontrol.ui.chat.ChatMessage
*/
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
"skill_invocation" -> R.string.timeline_marker_skill_invocation
"internal_notification" -> R.string.timeline_marker_internal_notification
"max_iterations_reached" -> R.string.timeline_marker_max_iterations
DisplayKind.MODEL_SWITCH -> R.string.timeline_marker_model_switch
DisplayKind.PERSONALITY_SWITCH -> R.string.timeline_marker_personality_switch
DisplayKind.AUTO_CONTINUE -> R.string.timeline_marker_auto_continue
DisplayKind.ASYNC_DELEGATION_COMPLETE -> R.string.timeline_marker_delegation_complete
DisplayKind.SKILL_INVOCATION -> R.string.timeline_marker_skill_invocation
DisplayKind.INTERNAL_NOTIFICATION -> R.string.timeline_marker_internal_notification
DisplayKind.MAX_ITERATIONS_REACHED -> R.string.timeline_marker_max_iterations
else -> null
}
@@ -63,7 +64,7 @@ internal fun TimelineMarkerChip(
) {
val labelRes = timelineMarkerLabelRes(message.displayKind) ?: return
val text =
if (message.displayKind == "model_switch") {
if (message.displayKind == DisplayKind.MODEL_SWITCH) {
val model = markerModelFromContent(message.content)
if (model != null) {
stringResource(R.string.timeline_marker_model_switch_to, model)