feat(chat): full-bleed agent replies behind a chat style setting (issue #866) (#878)

* feat(chat): ChatStyle preference type + store field + AuthManager (issue #866)

* feat(settings): chat style toggle in Appearance (issue #866)

* feat(chat): plumb ChatStyle into ChatUiState + refreshSettings (issue #866)

* feat(chat): full-bleed turn grouping (issue #866)

* feat(chat): full-bleed agent message renderer (issue #866)

* feat(chat): full-bleed compact tool rows + system events (issue #866)

* feat(chat): full-bleed chat list renderer + streaming (issue #866)

* feat(chat): wire full-bleed renderer behind chat style setting (issue #866)

* test(theme): full-bleed contrast gate across all presets (issue #866)

Gate caught NORD light primary/background < 3:1 — header role label
switched to onSurfaceVariant (meta token, correct for header + passes
in every preset; NORD's pastel Frost accent can't reach 3:1 on light).

* test(chat): full-bleed renderer coverage (issue #866)

* style(chat): ktlint formatting pass on full-bleed renderer (issue #866)

* fix(chat): eagerly capture per-item state in full-bleed list (issue #866)

LazyColumn item{} lambdas run at composition time, so loop locals read
inside them saw final values: turn header never rendered (firstProseSeen
always true at compose) and milestones misindexed. Capture showTurnHeader
+ milestone as immutable vals during the DSL loop. Caught by fresh-context
review (decompiled Compose foundation to confirm item composition timing).

* test(chat): stub AuthManager.getChatStyle in mockkObject setups (issue #866)

refreshSettings()/loadSettings() now read AuthManager.getChatStyle();
the four test files that mockkObject(AuthManager) with explicit stubs
had no stub for the new method, so mockk fell through to the real
object and threw 'AuthManager not initialized'. Full suite: 953/953
passing, same shape as pristine origin/main.

* feat(settings): move chat style toggle from Appearance to Chat section

* feat(chat): hoist reasoning above tool rows in full-bleed turns

Tool rows stream in before the answer message, so the turn rendered
tools above the reasoning card. The turn's reasoning now renders at
the top of the agent turn, with tool rows and prose underneath.

Streaming is folded into the turn model (groupIntoTurnsWithStreaming)
so the hoist works live while the answer is still streaming, and the
detached streaming item is gone (which also fixed the duplicate
turn-header quirk for streaming continuations).

* feat(chat): cap reasoning card height + long-press to copy

Expanded trace caps at 40% of the screen with internal scroll (Show
full lifts the cap); long-press anywhere copies the reasoning.

* feat(chat): blinking typing caret while streaming

* feat(chat): make full-bleed the default chat style

* feat(chat): remove thinking dots, show agent header above reasoning

---------

Co-authored-by: M57 <hy4ri@users.noreply.github.com>
This commit is contained in:
M57andM57 authored and GitHub committed 2026-08-12 00:22:07 +03:00
1 parent 758de04917
commit a59c8c6a4f
26 files changed
+1301 -52

No files matched your search

@@ -45,6 +45,35 @@ class ThemePaletteTest {
}
}
/**
* Full-bleed chat renderer gate (issue #866): agent prose renders directly
* on the screen background (HermesScaffold containerColor = background),
* so the full-bleed text pairs must hold against [ColorScheme.background]:
* - body prose (onSurface) >= 4.5:1 — primary content, WCAG AA text
* - header role label + timestamp (onSurfaceVariant) >= 3:1 — WCAG AA UI
* (The header deliberately avoids `primary`: Nord's light mode reuses its
* pastel Frost accent as primary, which cannot reach 3:1 on a light
* background.)
* Iterates ThemePreset.entries directly so EVERY shipped preset (incl.
* NORD, which the [themes] list above predates) is covered.
*/
@Test
fun fullBleedTextPairsMeetContrastInEveryShippedMode() {
ThemePreset.entries.forEach { preset ->
listOf(true, false).forEach { dark ->
val scheme = resolveColorScheme(preset, darkTheme = dark) ?: return@forEach
assertTrue(
"$preset dark=$dark onSurface/background contrast must be >= 4.5:1 (full-bleed prose)",
contrast(scheme.onSurface, scheme.background) >= 4.5f,
)
assertTrue(
"$preset dark=$dark onSurfaceVariant/background contrast must be >= 3:1 (full-bleed header)",
contrast(scheme.onSurfaceVariant, scheme.background) >= 3f,
)
}
}
}
@Test
fun amoledShipsDarkOnly() {
assertTrue("AMOLED must ship a dark scheme", AmoledTheme.darkScheme != null)
@@ -11,6 +11,7 @@ import com.m57.hermescontrol.data.ws.ConnectionStatus
import com.m57.hermescontrol.data.ws.HermesWsClient
import com.m57.hermescontrol.data.ws.WsEvent
import com.m57.hermescontrol.data.ws.WsMethods
import com.m57.hermescontrol.theme.ChatStyle
import com.m57.hermescontrol.ui.chat.fakes.FakeChatPersistenceRepository
import com.m57.hermescontrol.ui.chat.fakes.FakeSlashUsageStore
import io.mockk.coEvery
@@ -89,6 +90,7 @@ class ChatUpdateCommandTest {
every { AuthManager.getToken() } returns "test-token"
every { AuthManager.isTypingEffectEnabled() } returns true
every { AuthManager.getTypingEffectDelayMs() } returns 30
every { AuthManager.getChatStyle() } returns ChatStyle.BUBBLES
every { AuthManager.isAutoReconnect() } returns false
every { HermesWsClient.events } returns mockEventsFlow
every { HermesWsClient.connectionStatus } returns mockConnectionStatus
@@ -21,6 +21,7 @@ import com.m57.hermescontrol.data.ws.HermesWsClient
import com.m57.hermescontrol.data.ws.JsonRpcError
import com.m57.hermescontrol.data.ws.WsEvent
import com.m57.hermescontrol.data.ws.WsMethods
import com.m57.hermescontrol.theme.ChatStyle
import com.m57.hermescontrol.ui.chat.fakes.FakeChatPersistenceRepository
import com.m57.hermescontrol.ui.chat.fakes.FakeSlashUsageStore
import io.mockk.*
@@ -128,6 +129,7 @@ class ChatViewModelTest {
every { ProfileSwitchCoordinator.switched } returns mockSwitchFlow
every { AuthManager.isTypingEffectEnabled() } returns true
every { AuthManager.getTypingEffectDelayMs() } returns 30
every { AuthManager.getChatStyle() } returns ChatStyle.BUBBLES
every { AuthManager.isAutoReconnect() } returns false
every { HermesWsClient.events } returns mockEventsFlow
every { HermesWsClient.connectionStatus } returns mockConnectionStatus
@@ -2716,6 +2718,7 @@ class ChatViewModelTest {
// re-reads AuthManager live (the real regression scenario).
every { AuthManager.isTypingEffectEnabled() } returns false
every { AuthManager.getTypingEffectDelayMs() } returns 50
every { AuthManager.getChatStyle() } returns ChatStyle.BUBBLES
viewModel.refreshSettings()
advanceUntilIdle()
@@ -0,0 +1,148 @@
package com.m57.hermescontrol.ui.chat
import com.m57.hermescontrol.ui.chat.fullbleed.AgentEntry
import com.m57.hermescontrol.ui.chat.fullbleed.ChatTurn
import com.m57.hermescontrol.ui.chat.fullbleed.groupIntoTurns
import com.m57.hermescontrol.ui.chat.fullbleed.groupIntoTurnsWithStreaming
import org.junit.Assert.assertEquals
import org.junit.Test
class FullBleedTurnsTest {
private fun msg(
id: String,
role: MessageRole,
content: String = "c-$id",
toolStatus: ToolStatus? = null,
isStreaming: Boolean = false,
) = ChatMessage(id = id, role = role, content = content, toolStatus = toolStatus, isStreaming = isStreaming)
private fun entries(vararg e: AgentEntry) = ChatTurn.Agent(e.toList())
@Test
fun `empty list produces no turns`() {
assertEquals(emptyList<ChatTurn>(), groupIntoTurns(emptyList()))
}
@Test
fun `single user message is its own turn`() {
val m = msg("u1", MessageRole.USER)
assertEquals(listOf<ChatTurn>(ChatTurn.User(m)), groupIntoTurns(listOf(m)))
}
@Test
fun `single assistant message is an agent turn with prose`() {
val m = msg("a1", MessageRole.ASSISTANT)
assertEquals(listOf<ChatTurn>(entries(AgentEntry.Prose(m))), groupIntoTurns(listOf(m)))
}
@Test
fun `user then assistant-tool-assistant groups into user turn plus agent turn`() {
val u = msg("u1", MessageRole.USER)
val a1 = msg("a1", MessageRole.ASSISTANT)
val t = msg("t1", MessageRole.TOOL, toolStatus = ToolStatus.COMPLETED)
val a2 = msg("a2", MessageRole.ASSISTANT)
val result = groupIntoTurns(listOf(u, a1, t, a2))
assertEquals(
listOf(
ChatTurn.User(u),
entries(AgentEntry.Prose(a1), AgentEntry.ToolRow(t), AgentEntry.Prose(a2)),
),
result,
)
}
@Test
fun `two consecutive user messages produce two user turns`() {
val u1 = msg("u1", MessageRole.USER)
val u2 = msg("u2", MessageRole.USER)
assertEquals(listOf(ChatTurn.User(u1), ChatTurn.User(u2)), groupIntoTurns(listOf(u1, u2)))
}
@Test
fun `leading tool and system messages before any assistant become an agent turn`() {
val t = msg("t0", MessageRole.TOOL, toolStatus = ToolStatus.COMPLETED)
val s = msg("s0", MessageRole.SYSTEM)
val a = msg("a0", MessageRole.ASSISTANT)
val result = groupIntoTurns(listOf(t, s, a))
assertEquals(
listOf(entries(AgentEntry.ToolRow(t), AgentEntry.SystemEvent(s), AgentEntry.Prose(a))),
result,
)
}
@Test
fun `system event is preserved as a SystemEvent entry not prose`() {
val s = msg("s1", MessageRole.SYSTEM, content = "Self-improvement review: patched skill")
val result = groupIntoTurns(listOf(s))
assertEquals(listOf(entries(AgentEntry.SystemEvent(s))), result)
}
@Test
fun `user message closes the current agent turn`() {
val a1 = msg("a1", MessageRole.ASSISTANT)
val u = msg("u1", MessageRole.USER)
val a2 = msg("a2", MessageRole.ASSISTANT)
val result = groupIntoTurns(listOf(a1, u, a2))
assertEquals(
listOf(
entries(AgentEntry.Prose(a1)),
ChatTurn.User(u),
entries(AgentEntry.Prose(a2)),
),
result,
)
}
// ── groupIntoTurnsWithStreaming ────────────────────────────────────────
@Test
fun `null streaming message falls back to plain grouping`() {
val u = msg("u1", MessageRole.USER)
val a = msg("a1", MessageRole.ASSISTANT)
assertEquals(
groupIntoTurns(listOf(u, a)),
groupIntoTurnsWithStreaming(listOf(u, a), null),
)
}
@Test
fun `streaming prose appends to the current agent turn`() {
val u = msg("u1", MessageRole.USER)
val t = msg("t1", MessageRole.TOOL, toolStatus = ToolStatus.COMPLETED)
val s = msg("s1", MessageRole.ASSISTANT, content = "partial", isStreaming = true)
val result = groupIntoTurnsWithStreaming(listOf(u, t), s)
assertEquals(
listOf(ChatTurn.User(u), entries(AgentEntry.ToolRow(t), AgentEntry.Prose(s))),
result,
)
}
@Test
fun `streaming prose after a user turn opens a new agent turn`() {
val u = msg("u1", MessageRole.USER)
val s = msg("s1", MessageRole.ASSISTANT, content = "partial", isStreaming = true)
val result = groupIntoTurnsWithStreaming(listOf(u), s)
assertEquals(
listOf(ChatTurn.User(u), entries(AgentEntry.Prose(s))),
result,
)
}
@Test
fun `streaming message already present in messages is not duplicated`() {
val u = msg("u1", MessageRole.USER)
val s = msg("s1", MessageRole.ASSISTANT, content = "done", isStreaming = false)
// Commit race: the same id exists in both messages and streamingMessage.
val result = groupIntoTurnsWithStreaming(listOf(u, s), s.copy(isStreaming = true))
assertEquals(
listOf(ChatTurn.User(u), entries(AgentEntry.Prose(s))),
result,
)
}
@Test
fun `empty messages with streaming produce a single agent turn`() {
val s = msg("s1", MessageRole.ASSISTANT, content = "partial", isStreaming = true)
assertEquals(listOf(entries(AgentEntry.Prose(s))), groupIntoTurnsWithStreaming(emptyList(), s))
}
}
@@ -9,6 +9,7 @@ import com.m57.hermescontrol.data.ws.ConnectionStatus
import com.m57.hermescontrol.data.ws.HermesWsClient
import com.m57.hermescontrol.data.ws.WsEvent
import com.m57.hermescontrol.data.ws.WsMethods
import com.m57.hermescontrol.theme.ChatStyle
import com.m57.hermescontrol.ui.chat.fakes.FakeChatPersistenceRepository
import com.m57.hermescontrol.ui.chat.fakes.FakeSlashUsageStore
import io.mockk.every
@@ -93,6 +94,7 @@ class SlashCommandDispatchRpcTest {
every { AuthManager.getToken() } returns "test-token"
every { AuthManager.isTypingEffectEnabled() } returns true
every { AuthManager.getTypingEffectDelayMs() } returns 30
every { AuthManager.getChatStyle() } returns ChatStyle.BUBBLES
every { AuthManager.isAutoReconnect() } returns false
every { HermesWsClient.events } returns mockEventsFlow
every { HermesWsClient.connectionStatus } returns mockConnectionStatus
@@ -3,6 +3,7 @@ package com.m57.hermescontrol.ui.settings
import com.m57.hermescontrol.data.config.ConnectionProfile
import com.m57.hermescontrol.data.local.AuthManager
import com.m57.hermescontrol.data.remote.ApiClient
import com.m57.hermescontrol.theme.ChatStyle
import com.m57.hermescontrol.theme.ThemePreference
import com.m57.hermescontrol.theme.ThemePreset
import io.mockk.every
@@ -65,6 +66,7 @@ class SettingsViewModelTest {
every { AuthManager.getThemePreset() } returns ThemePreset.DEFAULT
every { AuthManager.isTypingEffectEnabled() } returns true
every { AuthManager.getTypingEffectDelayMs() } returns 30
every { AuthManager.getChatStyle() } returns ChatStyle.BUBBLES
every { AuthManager.getConnectionProfiles() } returns emptyList()
every { AuthManager.getSelectedProfileId() } answers { storedSelectedProfileId }
every { AuthManager.baseUrl() } returns "http://127.0.0.1:9119/"
@@ -119,6 +121,7 @@ class SettingsViewModelTest {
every { AuthManager.getThemePreset() } returns ThemePreset.CATPPUCCIN
every { AuthManager.isTypingEffectEnabled() } returns false
every { AuthManager.getTypingEffectDelayMs() } returns 15
every { AuthManager.getChatStyle() } returns ChatStyle.BUBBLES
every { AuthManager.getConnectionProfiles() } returns testProfiles
every { AuthManager.getAppLanguage() } returns "fr"