feat: add fake chat proving ground

This commit is contained in:
Hermes Agent
2026-07-24 09:55:55 +00:00
parent babf866ed5
commit f65d4ac1a2
6 changed files with 940 additions and 14 deletions
@@ -1,12 +1,8 @@
package cloud.molberg.hermesmobile
import android.content.res.Configuration
import androidx.compose.foundation.layout.Arrangement
import androidx.compose.foundation.layout.Column
import androidx.compose.foundation.layout.Row
import androidx.compose.foundation.layout.fillMaxSize
import androidx.compose.foundation.layout.fillMaxWidth
import androidx.compose.foundation.layout.padding
import androidx.compose.material.icons.Icons
import androidx.compose.material.icons.filled.Add
import androidx.compose.material.icons.filled.Folder
@@ -15,6 +11,7 @@ import androidx.compose.material3.Surface
import androidx.compose.runtime.Composable
import androidx.compose.ui.Modifier
import androidx.compose.ui.tooling.preview.Preview
import cloud.molberg.hermesmobile.chat.FakeInboxScreen
import cloud.molberg.hermesmobile.design.CodeCard
import cloud.molberg.hermesmobile.design.HermesMobileTheme
import cloud.molberg.hermesmobile.design.HermesTheme
@@ -46,15 +43,15 @@ fun NormalPhoneDarkPreview() {
@Composable
fun TabletWidthLightPreview() {
HermesPreviewSurface(darkTheme = false) {
Row(
Modifier
.fillMaxSize()
.padding(HermesTheme.spacing.lg),
horizontalArrangement = Arrangement.spacedBy(HermesTheme.spacing.lg)
) {
Column(Modifier.weight(0.42f)) { SampleHomeList() }
Column(Modifier.weight(0.58f)) { SampleChatThread() }
}
FakeInboxScreen(resetToken = 0, notify = {})
}
}
@Preview(name = "F2 Chat Phone", widthDp = 393, heightDp = 852, showBackground = true)
@Composable
fun F2ChatPhonePreview() {
HermesPreviewSurface(darkTheme = false) {
FakeInboxScreen(resetToken = 0, notify = {})
}
}
@@ -71,6 +71,7 @@ import cloud.molberg.hermesmobile.design.SecondaryButton
import cloud.molberg.hermesmobile.design.SectionHeader
import cloud.molberg.hermesmobile.design.TinyLabel
import cloud.molberg.hermesmobile.design.TypingIndicator
import cloud.molberg.hermesmobile.chat.FakeInboxScreen
import androidx.compose.material3.Icon
import androidx.compose.material3.IconButton
import androidx.compose.material3.NavigationBar
@@ -371,7 +372,7 @@ fun HermesNativeApp() {
label = "screen-slide"
) { active ->
when (active) {
Screen.Inbox -> InboxScreen(api, resetInboxToken)
Screen.Inbox -> FakeInboxScreen(resetInboxToken, ::notify)
Screen.Terminals -> TerminalsScreen(api, ::notify, onNewChat = {
resetInboxToken++
screen = Screen.Inbox
@@ -0,0 +1,56 @@
package cloud.molberg.hermesmobile.chat
enum class ChatRole { User, Assistant, System, Error }
enum class ChatConnectionState { Empty, Loading, Ready, Reconnecting, Error }
data class ChatContextControls(
val model: String = "Hermes default",
val effort: String = "Normal",
val toolsEnabled: Boolean = true,
val workspace: String = "/root/hermes-mobile"
)
data class ToolResultUi(
val name: String,
val status: String,
val summary: String,
val output: String,
val expanded: Boolean = false
)
sealed interface MessagePartUi {
data class Text(val text: String) : MessagePartUi
data class Code(val language: String, val code: String) : MessagePartUi
data class Tool(val result: ToolResultUi) : MessagePartUi
}
data class ChatMessageUi(
val id: String,
val role: ChatRole,
val title: String,
val parts: List<MessagePartUi>,
val streaming: Boolean = false,
val retryPrompt: String? = null
)
data class ChatSessionUi(
val id: String,
val title: String,
val subtitle: String,
val messages: List<ChatMessageUi>,
val draft: String = "",
val busy: Boolean = false
)
data class FakeChatUiState(
val sessions: List<ChatSessionUi> = emptyList(),
val selectedSessionId: String? = null,
val connectionState: ChatConnectionState = ChatConnectionState.Ready,
val context: ChatContextControls = ChatContextControls(),
val statusText: String = "Local fake chat is ready."
) {
val selectedSession: ChatSessionUi?
get() = sessions.firstOrNull { it.id == selectedSessionId }
}
@@ -0,0 +1,254 @@
package cloud.molberg.hermesmobile.chat
object FakeChatReducer {
fun seed(): FakeChatUiState {
val release = ChatSessionUi(
id = "session-release",
title = "Release checklist",
subtitle = "Streaming fake response with code and tool output",
messages = listOf(
ChatMessageUi(
id = "m-system-release",
role = ChatRole.System,
title = "System",
parts = listOf(MessagePartUi.Text("Thin-client mode. Fake local data only; upstream Hermes remains authoritative."))
),
ChatMessageUi(
id = "m-user-release",
role = ChatRole.User,
title = "You",
parts = listOf(MessagePartUi.Text("Draft a compact beta verification checklist with the Android command."))
),
ChatMessageUi(
id = "m-assistant-release",
role = ChatRole.Assistant,
title = "Hermes",
parts = listOf(
MessagePartUi.Text("Use the local Android build as the first gate, then exercise the companion flows separately."),
MessagePartUi.Code("bash", "GRADLE_USER_HOME=/root/hermes-mobile/.gradle-user ./gradlew :app:assembleDebug"),
MessagePartUi.Tool(
ToolResultUi(
name = "workspace.inspect",
status = "done",
summary = "Found Android app, companion package, and project docs.",
output = "apps/mobile/android\napps/companion\nPROJECT_PLAN.md\ndocs/ROADMAP.md"
)
)
)
)
)
)
val reconnecting = ChatSessionUi(
id = "session-reconnect",
title = "Reconnecting stream",
subtitle = "Partial output preserved",
messages = listOf(
ChatMessageUi(
id = "m-user-reconnect",
role = ChatRole.User,
title = "You",
parts = listOf(MessagePartUi.Text("Show what happens during a dropped stream."))
),
ChatMessageUi(
id = "m-assistant-reconnect",
role = ChatRole.Assistant,
title = "Hermes",
streaming = true,
parts = listOf(MessagePartUi.Text("The partial answer stays visible while the client reconnects..."))
)
),
busy = true
)
val error = ChatSessionUi(
id = "session-error",
title = "Permission denied",
subtitle = "Retry is available",
messages = listOf(
ChatMessageUi(
id = "m-user-error",
role = ChatRole.User,
title = "You",
parts = listOf(MessagePartUi.Text("Run the deployment command without asking."))
),
ChatMessageUi(
id = "m-error-error",
role = ChatRole.Error,
title = "Error",
retryPrompt = "Run the deployment command without asking.",
parts = listOf(MessagePartUi.Text("Fake policy guardrail blocked this request. Retry with safer context or clarify permissions."))
)
)
)
return FakeChatUiState(
sessions = listOf(release, reconnecting, error),
selectedSessionId = release.id
)
}
fun empty(): FakeChatUiState = FakeChatUiState(connectionState = ChatConnectionState.Empty, statusText = "No local sessions yet.")
fun loading(): FakeChatUiState = FakeChatUiState(connectionState = ChatConnectionState.Loading, statusText = "Loading local sessions...")
fun error(): FakeChatUiState = FakeChatUiState(connectionState = ChatConnectionState.Error, statusText = "Local fake data failed to load.")
fun select(state: FakeChatUiState, sessionId: String): FakeChatUiState =
state.copy(selectedSessionId = sessionId, connectionState = ChatConnectionState.Ready)
fun newSession(state: FakeChatUiState, id: String): FakeChatUiState {
val session = ChatSessionUi(
id = id,
title = "New chat",
subtitle = "Ready",
messages = emptyList()
)
return state.copy(
sessions = listOf(session) + state.sessions,
selectedSessionId = id,
connectionState = ChatConnectionState.Ready,
statusText = "New local chat started."
)
}
fun updateDraft(state: FakeChatUiState, sessionId: String, draft: String): FakeChatUiState =
updateSession(state, sessionId) { it.copy(draft = draft) }
fun updateContext(state: FakeChatUiState, context: ChatContextControls): FakeChatUiState =
state.copy(context = context, statusText = "Local context updated.")
fun reconnecting(state: FakeChatUiState): FakeChatUiState =
state.copy(connectionState = ChatConnectionState.Reconnecting, statusText = "Reconnecting to fake stream...")
fun ready(state: FakeChatUiState): FakeChatUiState =
state.copy(connectionState = ChatConnectionState.Ready, statusText = "Local fake chat is ready.")
fun send(state: FakeChatUiState, sessionId: String, prompt: String, messageId: String): FakeChatUiState {
val trimmed = prompt.trim()
if (trimmed.isBlank()) return state
val assistantId = "$messageId-assistant"
return updateSession(state, sessionId) { session ->
val user = ChatMessageUi(
id = "$messageId-user",
role = ChatRole.User,
title = "You",
parts = listOf(MessagePartUi.Text(trimmed))
)
val assistant = ChatMessageUi(
id = assistantId,
role = ChatRole.Assistant,
title = "Hermes",
streaming = true,
parts = listOf(MessagePartUi.Text("Thinking through the local fake response..."))
)
session.copy(
title = if (session.title == "New chat") trimmed.lineSequence().first().take(48) else session.title,
subtitle = "Hermes is streaming...",
messages = session.messages + user + assistant,
draft = "",
busy = true
)
}.copy(connectionState = ChatConnectionState.Ready, statusText = "Streaming fake response.")
}
fun appendStream(state: FakeChatUiState, sessionId: String, assistantId: String, text: String): FakeChatUiState =
updateMessage(state, sessionId, assistantId) { message ->
val parts = message.parts.filterIsInstance<MessagePartUi.Text>()
val current = parts.firstOrNull()?.text.orEmpty()
message.copy(parts = listOf(MessagePartUi.Text(current + text)), streaming = true)
}
fun finishStream(state: FakeChatUiState, sessionId: String, assistantId: String, prompt: String): FakeChatUiState =
updateSession(state, sessionId) { session ->
val updatedMessages = session.messages.map { message ->
if (message.id == assistantId) {
message.copy(
streaming = false,
parts = fakeAssistantParts(prompt)
)
} else {
message
}
}
session.copy(messages = updatedMessages, busy = false, subtitle = "Fake response complete.")
}.copy(connectionState = ChatConnectionState.Ready, statusText = "Fake response complete.")
fun cancel(state: FakeChatUiState, sessionId: String): FakeChatUiState =
updateSession(state, sessionId) { session ->
val updatedMessages = session.messages.map { message ->
if (message.streaming) {
message.copy(
role = ChatRole.Error,
title = "Cancelled",
streaming = false,
retryPrompt = lastUserPrompt(session),
parts = listOf(MessagePartUi.Text("Generation cancelled. Partial output was preserved before cancellation."))
)
} else {
message
}
}
session.copy(messages = updatedMessages, busy = false, subtitle = "Cancelled")
}.copy(statusText = "Generation cancelled.")
fun retry(state: FakeChatUiState, sessionId: String, messageId: String, nextMessageId: String): FakeChatUiState {
val session = state.sessions.firstOrNull { it.id == sessionId } ?: return state
val prompt = session.messages.firstOrNull { it.id == messageId }?.retryPrompt ?: return state
val cleared = updateSession(state, sessionId) { existing ->
existing.copy(messages = existing.messages.filterNot { it.id == messageId }, draft = prompt, busy = false)
}
return send(cleared, sessionId, prompt, nextMessageId)
}
fun toggleTool(state: FakeChatUiState, sessionId: String, messageId: String, toolName: String): FakeChatUiState =
updateMessage(state, sessionId, messageId) { message ->
message.copy(parts = message.parts.map { part ->
if (part is MessagePartUi.Tool && part.result.name == toolName) {
part.copy(result = part.result.copy(expanded = !part.result.expanded))
} else {
part
}
})
}
private fun updateSession(
state: FakeChatUiState,
sessionId: String,
transform: (ChatSessionUi) -> ChatSessionUi
): FakeChatUiState =
state.copy(sessions = state.sessions.map { if (it.id == sessionId) transform(it) else it })
private fun updateMessage(
state: FakeChatUiState,
sessionId: String,
messageId: String,
transform: (ChatMessageUi) -> ChatMessageUi
): FakeChatUiState =
updateSession(state, sessionId) { session ->
session.copy(messages = session.messages.map { if (it.id == messageId) transform(it) else it })
}
private fun lastUserPrompt(session: ChatSessionUi): String? =
session.messages.lastOrNull { it.role == ChatRole.User }
?.parts
?.filterIsInstance<MessagePartUi.Text>()
?.firstOrNull()
?.text
private fun fakeAssistantParts(prompt: String): List<MessagePartUi> {
val lead = "Here is a local fake response for: **${prompt.take(96)}**\n\n- Context stays on-device.\n- Streaming, retry, and tool states are UI-only in F2."
return listOf(
MessagePartUi.Text(lead),
MessagePartUi.Code(
language = "kotlin",
code = "val guardrail = \"thin client\"\nval dataSource = \"fake/local\"\ncheck(dataSource != \"upstream\")"
),
MessagePartUi.Tool(
ToolResultUi(
name = "fake.context",
status = "done",
summary = "Read model, effort, tools, and workspace controls.",
output = "model=Hermes default\neffort=Normal\ntools=true\nworkspace=/root/hermes-mobile"
)
)
)
}
}
@@ -0,0 +1,548 @@
package cloud.molberg.hermesmobile.chat
import androidx.activity.compose.BackHandler
import androidx.compose.foundation.background
import androidx.compose.foundation.clickable
import androidx.compose.foundation.horizontalScroll
import androidx.compose.foundation.layout.Arrangement
import androidx.compose.foundation.layout.Box
import androidx.compose.foundation.layout.BoxWithConstraints
import androidx.compose.foundation.layout.Column
import androidx.compose.foundation.layout.PaddingValues
import androidx.compose.foundation.layout.Row
import androidx.compose.foundation.layout.Spacer
import androidx.compose.foundation.layout.fillMaxHeight
import androidx.compose.foundation.layout.fillMaxSize
import androidx.compose.foundation.layout.fillMaxWidth
import androidx.compose.foundation.layout.imePadding
import androidx.compose.foundation.layout.padding
import androidx.compose.foundation.layout.size
import androidx.compose.foundation.layout.width
import androidx.compose.foundation.lazy.LazyColumn
import androidx.compose.foundation.lazy.items
import androidx.compose.foundation.rememberScrollState
import androidx.compose.foundation.shape.CircleShape
import androidx.compose.foundation.shape.RoundedCornerShape
import androidx.compose.foundation.text.selection.SelectionContainer
import androidx.compose.material.icons.Icons
import androidx.compose.material.icons.filled.Add
import androidx.compose.material.icons.filled.ArrowBack
import androidx.compose.material.icons.filled.ChatBubble
import androidx.compose.material.icons.filled.Close
import androidx.compose.material.icons.filled.ContentCopy
import androidx.compose.material.icons.filled.ExpandLess
import androidx.compose.material.icons.filled.ExpandMore
import androidx.compose.material.icons.filled.Refresh
import androidx.compose.material.icons.filled.Send
import androidx.compose.material.icons.filled.Stop
import androidx.compose.material.icons.filled.Tune
import androidx.compose.material3.Icon
import androidx.compose.material3.IconButton
import androidx.compose.material3.OutlinedTextField
import androidx.compose.material3.Surface
import androidx.compose.material3.Switch
import androidx.compose.material3.Text
import androidx.compose.material3.TextFieldDefaults
import androidx.compose.runtime.Composable
import androidx.compose.runtime.LaunchedEffect
import androidx.compose.runtime.getValue
import androidx.compose.runtime.mutableIntStateOf
import androidx.compose.runtime.mutableStateOf
import androidx.compose.runtime.remember
import androidx.compose.runtime.rememberCoroutineScope
import androidx.compose.runtime.setValue
import androidx.compose.ui.Alignment
import androidx.compose.ui.Modifier
import androidx.compose.ui.draw.clip
import androidx.compose.ui.platform.LocalClipboardManager
import androidx.compose.ui.text.AnnotatedString
import androidx.compose.ui.text.font.FontWeight
import androidx.compose.ui.text.style.TextOverflow
import androidx.compose.ui.unit.dp
import cloud.molberg.hermesmobile.design.EmptyState
import cloud.molberg.hermesmobile.design.FlatCard
import cloud.molberg.hermesmobile.design.HermesTheme
import cloud.molberg.hermesmobile.design.ModeChip
import cloud.molberg.hermesmobile.design.NoticeCard
import cloud.molberg.hermesmobile.design.PrimaryButton
import cloud.molberg.hermesmobile.design.ScreenList
import cloud.molberg.hermesmobile.design.SecondaryButton
import cloud.molberg.hermesmobile.design.SectionHeader
import cloud.molberg.hermesmobile.design.TinyLabel
import kotlinx.coroutines.Job
import kotlinx.coroutines.delay
import kotlinx.coroutines.launch
@Composable
fun FakeInboxScreen(resetToken: Int, notify: (String) -> Unit) {
val scope = rememberCoroutineScope()
var state by remember { mutableStateOf(FakeChatReducer.seed()) }
var nextId by remember { mutableIntStateOf(10) }
var streamJob by remember { mutableStateOf<Job?>(null) }
fun createSession() {
val id = "local-${nextId++}"
state = FakeChatReducer.newSession(state, id)
}
fun send(session: ChatSessionUi, prompt: String = session.draft) {
if (prompt.isBlank() || session.busy) return
streamJob?.cancel()
val messageId = "m-${nextId++}"
val assistantId = "$messageId-assistant"
state = FakeChatReducer.send(state, session.id, prompt, messageId)
streamJob = scope.launch {
delay(450)
state = FakeChatReducer.appendStream(state, session.id, assistantId, "\nChecking context controls...")
delay(450)
state = FakeChatReducer.appendStream(state, session.id, assistantId, "\nComposing markdown and code surfaces...")
delay(450)
state = FakeChatReducer.finishStream(state, session.id, assistantId, prompt)
}
}
fun cancel(session: ChatSessionUi) {
streamJob?.cancel()
state = FakeChatReducer.cancel(state, session.id)
}
LaunchedEffect(resetToken) {
if (resetToken > 0) createSession()
}
BoxWithConstraints(Modifier.fillMaxSize()) {
val wide = maxWidth >= 720.dp
val selected = state.selectedSession
if (!wide) {
BackHandler(selected != null) { state = state.copy(selectedSessionId = null) }
}
if (wide) {
Row(Modifier.fillMaxSize()) {
ChatSessionRail(
state = state,
modifier = Modifier
.width(304.dp)
.fillMaxHeight(),
onNew = ::createSession,
onSelect = { state = FakeChatReducer.select(state, it) },
onState = { state = it }
)
ChatConversationPane(
state = state,
session = selected,
modifier = Modifier.weight(1f),
onBack = null,
onDraft = { id, draft -> state = FakeChatReducer.updateDraft(state, id, draft) },
onSend = ::send,
onCancel = ::cancel,
onRetry = { sessionId, messageId ->
val retryId = "m-${nextId++}"
streamJob?.cancel()
state = FakeChatReducer.retry(state, sessionId, messageId, retryId)
state.selectedSession?.let { retrySession ->
val prompt = retrySession.messages.lastOrNull { it.role == ChatRole.User }
?.parts
?.filterIsInstance<MessagePartUi.Text>()
?.firstOrNull()
?.text
.orEmpty()
val assistantId = "$retryId-assistant"
streamJob = scope.launch {
delay(500)
state = FakeChatReducer.finishStream(state, sessionId, assistantId, prompt)
}
}
},
onToggleTool = { sessionId, messageId, toolName -> state = FakeChatReducer.toggleTool(state, sessionId, messageId, toolName) },
onContext = { state = FakeChatReducer.updateContext(state, it) },
notify = notify
)
}
} else if (selected == null) {
ChatSessionRail(
state = state,
modifier = Modifier.fillMaxSize(),
onNew = ::createSession,
onSelect = { state = FakeChatReducer.select(state, it) },
onState = { state = it }
)
} else {
ChatConversationPane(
state = state,
session = selected,
modifier = Modifier.fillMaxSize(),
onBack = { state = state.copy(selectedSessionId = null) },
onDraft = { id, draft -> state = FakeChatReducer.updateDraft(state, id, draft) },
onSend = ::send,
onCancel = ::cancel,
onRetry = { sessionId, messageId ->
val retryId = "m-${nextId++}"
streamJob?.cancel()
state = FakeChatReducer.retry(state, sessionId, messageId, retryId)
streamJob = scope.launch {
delay(500)
val prompt = state.selectedSession?.messages?.lastOrNull { it.role == ChatRole.User }
?.parts?.filterIsInstance<MessagePartUi.Text>()?.firstOrNull()?.text.orEmpty()
state = FakeChatReducer.finishStream(state, sessionId, "$retryId-assistant", prompt)
}
},
onToggleTool = { sessionId, messageId, toolName -> state = FakeChatReducer.toggleTool(state, sessionId, messageId, toolName) },
onContext = { state = FakeChatReducer.updateContext(state, it) },
notify = notify
)
}
}
}
@Composable
private fun ChatSessionRail(
state: FakeChatUiState,
modifier: Modifier,
onNew: () -> Unit,
onSelect: (String) -> Unit,
onState: (FakeChatUiState) -> Unit
) {
ScreenListColumn(modifier) {
PrimaryButton("New Chat", Icons.Filled.Add, Modifier.fillMaxWidth(), onClick = onNew)
ConnectionStateCard(state, onState)
SectionHeader("Conversations", if (state.sessions.isEmpty()) "No local sessions." else "${state.sessions.size} local sessions")
when (state.connectionState) {
ChatConnectionState.Loading -> NoticeCard("Loading local sessions...")
ChatConnectionState.Empty -> EmptyState()
ChatConnectionState.Error -> NoticeCard("Local fake data error. Use Retry to restore seed data.", danger = true)
else -> state.sessions.forEach { session ->
val selected = session.id == state.selectedSessionId
SessionRow(session, selected) { onSelect(session.id) }
}
}
}
}
@Composable
private fun ScreenListColumn(modifier: Modifier, content: @Composable () -> Unit) {
LazyColumn(
modifier = modifier,
contentPadding = PaddingValues(horizontal = HermesTheme.spacing.lg, vertical = HermesTheme.spacing.sm),
verticalArrangement = Arrangement.spacedBy(14.dp)
) {
item { Column(verticalArrangement = Arrangement.spacedBy(14.dp)) { content() } }
}
}
@Composable
private fun SessionRow(session: ChatSessionUi, selected: Boolean, onClick: () -> Unit) {
FlatCard(
color = if (selected) HermesTheme.colors.surface else HermesTheme.colors.surfaceRaised,
border = if (selected) HermesTheme.colors.border else androidx.compose.ui.graphics.Color.Transparent
) {
Row(
Modifier
.clickable(onClick = onClick)
.padding(12.dp),
verticalAlignment = Alignment.CenterVertically,
horizontalArrangement = Arrangement.spacedBy(10.dp)
) {
Box(
Modifier
.size(34.dp)
.clip(RoundedCornerShape(HermesTheme.shapes.iconTile))
.background(if (session.busy) HermesTheme.status.neutralPanel else HermesTheme.colors.surface),
contentAlignment = Alignment.Center
) {
Icon(Icons.Filled.ChatBubble, null, tint = HermesTheme.colors.text, modifier = Modifier.size(19.dp))
}
Column(Modifier.weight(1f), verticalArrangement = Arrangement.spacedBy(2.dp)) {
Text(session.title, color = HermesTheme.colors.text, style = HermesTheme.typography.rowTitle, maxLines = 1, overflow = TextOverflow.Ellipsis)
Text(session.subtitle, color = HermesTheme.colors.textMuted, style = HermesTheme.typography.caption, maxLines = 2, overflow = TextOverflow.Ellipsis)
}
}
}
}
@Composable
private fun ConnectionStateCard(state: FakeChatUiState, onState: (FakeChatUiState) -> Unit) {
FlatCard {
Column(Modifier.padding(12.dp), verticalArrangement = Arrangement.spacedBy(10.dp)) {
TinyLabel("State")
Text(state.statusText, color = HermesTheme.colors.text, style = HermesTheme.typography.body)
Row(horizontalArrangement = Arrangement.spacedBy(8.dp), modifier = Modifier.fillMaxWidth()) {
SecondaryButton("Empty", Icons.Filled.Close, Modifier.weight(1f)) { onState(FakeChatReducer.empty()) }
SecondaryButton("Loading", Icons.Filled.Refresh, Modifier.weight(1f)) { onState(FakeChatReducer.loading()) }
}
Row(horizontalArrangement = Arrangement.spacedBy(8.dp), modifier = Modifier.fillMaxWidth()) {
SecondaryButton("Error", Icons.Filled.Close, Modifier.weight(1f)) { onState(FakeChatReducer.error()) }
PrimaryButton("Reconnect", Icons.Filled.Tune, Modifier.weight(1f)) { onState(FakeChatReducer.reconnecting(state)) }
}
Row(horizontalArrangement = Arrangement.spacedBy(8.dp), modifier = Modifier.fillMaxWidth()) {
SecondaryButton("Retry", Icons.Filled.Refresh, Modifier.weight(1f)) { onState(FakeChatReducer.seed()) }
}
}
}
}
@Composable
private fun ChatConversationPane(
state: FakeChatUiState,
session: ChatSessionUi?,
modifier: Modifier,
onBack: (() -> Unit)?,
onDraft: (String, String) -> Unit,
onSend: (ChatSessionUi) -> Unit,
onCancel: (ChatSessionUi) -> Unit,
onRetry: (String, String) -> Unit,
onToggleTool: (String, String, String) -> Unit,
onContext: (ChatContextControls) -> Unit,
notify: (String) -> Unit
) {
if (session == null) {
ScreenList { EmptyState() }
return
}
Column(modifier) {
ConversationHeader(session, state.connectionState, onBack)
LazyColumn(
modifier = Modifier.weight(1f),
contentPadding = PaddingValues(horizontal = 16.dp, vertical = 10.dp),
verticalArrangement = Arrangement.spacedBy(12.dp)
) {
item {
ContextControlsCard(state.context, onContext)
}
if (state.connectionState == ChatConnectionState.Reconnecting) {
item { NoticeCard("Reconnecting. Partial output remains visible.") }
}
if (session.messages.isEmpty()) {
item { EmptyState() }
}
items(session.messages, key = { it.id }) { message ->
ChatMessageCard(
message = message,
onRetry = { onRetry(session.id, message.id) },
onToggleTool = { onToggleTool(session.id, message.id, it) },
notify = notify
)
}
}
ChatComposer(
session = session,
onDraft = { onDraft(session.id, it) },
onSend = { onSend(session) },
onCancel = { onCancel(session) }
)
}
}
@Composable
private fun ConversationHeader(session: ChatSessionUi, connectionState: ChatConnectionState, onBack: (() -> Unit)?) {
Row(
Modifier
.fillMaxWidth()
.padding(horizontal = 8.dp, vertical = 6.dp),
verticalAlignment = Alignment.CenterVertically,
horizontalArrangement = Arrangement.spacedBy(6.dp)
) {
if (onBack != null) {
IconButton(onClick = onBack, modifier = Modifier.size(42.dp)) {
Icon(Icons.Filled.ArrowBack, contentDescription = "Conversations", tint = HermesTheme.colors.text)
}
}
Column(Modifier.weight(1f)) {
Text(session.title, color = HermesTheme.colors.text, style = HermesTheme.typography.sectionTitle, maxLines = 1, overflow = TextOverflow.Ellipsis)
Text(
if (connectionState == ChatConnectionState.Reconnecting) "reconnecting" else session.subtitle,
color = HermesTheme.colors.textMuted,
style = HermesTheme.typography.label,
maxLines = 1,
overflow = TextOverflow.Ellipsis
)
}
}
}
@Composable
private fun ContextControlsCard(context: ChatContextControls, onContext: (ChatContextControls) -> Unit) {
FlatCard {
Column(Modifier.padding(12.dp), verticalArrangement = Arrangement.spacedBy(10.dp)) {
Row(verticalAlignment = Alignment.CenterVertically, horizontalArrangement = Arrangement.spacedBy(8.dp), modifier = Modifier.fillMaxWidth()) {
Icon(Icons.Filled.Tune, null, tint = HermesTheme.colors.textMuted, modifier = Modifier.size(18.dp))
Text("Context", color = HermesTheme.colors.text, style = HermesTheme.typography.rowTitle)
Spacer(Modifier.weight(1f))
Switch(checked = context.toolsEnabled, onCheckedChange = { onContext(context.copy(toolsEnabled = it)) })
}
Row(horizontalArrangement = Arrangement.spacedBy(8.dp), modifier = Modifier.fillMaxWidth()) {
ModeChip("Default", context.model == "Hermes default", Modifier.weight(1f)) { onContext(context.copy(model = "Hermes default")) }
ModeChip("Fast", context.model == "Hermes fast", Modifier.weight(1f)) { onContext(context.copy(model = "Hermes fast")) }
ModeChip("Deep", context.effort == "High", Modifier.weight(1f)) { onContext(context.copy(effort = if (context.effort == "High") "Normal" else "High")) }
}
Text(context.workspace, color = HermesTheme.colors.textMuted, style = HermesTheme.typography.caption, maxLines = 1, overflow = TextOverflow.Ellipsis)
}
}
}
@Composable
private fun ChatMessageCard(
message: ChatMessageUi,
onRetry: () -> Unit,
onToggleTool: (String) -> Unit,
notify: (String) -> Unit
) {
val isUser = message.role == ChatRole.User
Row(Modifier.fillMaxWidth(), horizontalArrangement = if (isUser) Arrangement.End else Arrangement.Start) {
FlatCard(
modifier = Modifier.fillMaxWidth(if (isUser) 0.86f else 0.94f),
color = when (message.role) {
ChatRole.User -> HermesTheme.colors.userBubble
ChatRole.Error -> HermesTheme.status.dangerPanel
ChatRole.System -> HermesTheme.status.neutralPanel
ChatRole.Assistant -> HermesTheme.colors.surface
}
) {
Column(Modifier.padding(12.dp), verticalArrangement = Arrangement.spacedBy(10.dp)) {
Row(verticalAlignment = Alignment.CenterVertically, horizontalArrangement = Arrangement.spacedBy(8.dp), modifier = Modifier.fillMaxWidth()) {
TinyLabel(message.title)
if (message.streaming) {
Box(Modifier.size(8.dp).clip(CircleShape).background(HermesTheme.status.neutral))
Text("streaming", color = HermesTheme.colors.textMuted, style = HermesTheme.typography.caption)
}
Spacer(Modifier.weight(1f))
if (message.retryPrompt != null) {
IconButton(onClick = onRetry, modifier = Modifier.size(38.dp)) {
Icon(Icons.Filled.Refresh, contentDescription = "Retry", tint = HermesTheme.colors.text)
}
}
}
message.parts.forEach { part ->
when (part) {
is MessagePartUi.Text -> MarkdownText(part.text)
is MessagePartUi.Code -> CodeBlockCard(part.language, part.code, notify)
is MessagePartUi.Tool -> ToolResultCard(part.result, onToggle = { onToggleTool(part.result.name) })
}
}
}
}
}
}
@Composable
private fun MarkdownText(text: String) {
SelectionContainer {
Text(
text = text.replace("**", ""),
color = HermesTheme.colors.text,
style = HermesTheme.typography.body
)
}
}
@Composable
private fun CodeBlockCard(language: String, code: String, notify: (String) -> Unit) {
val clipboard = LocalClipboardManager.current
FlatCard(color = HermesTheme.colors.surfaceRaised) {
Column(Modifier.padding(10.dp), verticalArrangement = Arrangement.spacedBy(6.dp)) {
Row(verticalAlignment = Alignment.CenterVertically, modifier = Modifier.fillMaxWidth()) {
TinyLabel(language.ifBlank { "code" })
Spacer(Modifier.weight(1f))
IconButton(
onClick = {
clipboard.setText(AnnotatedString(code))
notify("Code copied.")
},
modifier = Modifier.size(36.dp)
) {
Icon(Icons.Filled.ContentCopy, contentDescription = "Copy code", tint = HermesTheme.colors.text)
}
}
SelectionContainer {
Text(
code,
modifier = Modifier
.horizontalScroll(rememberScrollState())
.padding(bottom = 2.dp),
color = HermesTheme.colors.codeText,
style = HermesTheme.typography.code
)
}
}
}
}
@Composable
private fun ToolResultCard(result: ToolResultUi, onToggle: () -> Unit) {
FlatCard(color = HermesTheme.status.neutralPanel) {
Column(Modifier.padding(10.dp), verticalArrangement = Arrangement.spacedBy(6.dp)) {
Row(
Modifier
.fillMaxWidth()
.clickable(onClick = onToggle),
verticalAlignment = Alignment.CenterVertically,
horizontalArrangement = Arrangement.spacedBy(8.dp)
) {
TinyLabel(result.status)
Text(result.name, color = HermesTheme.colors.text, style = HermesTheme.typography.bodyStrong, maxLines = 1, overflow = TextOverflow.Ellipsis, modifier = Modifier.weight(1f))
Icon(if (result.expanded) Icons.Filled.ExpandLess else Icons.Filled.ExpandMore, contentDescription = if (result.expanded) "Collapse tool result" else "Expand tool result", tint = HermesTheme.colors.textMuted)
}
Text(result.summary, color = HermesTheme.colors.textMuted, style = HermesTheme.typography.caption)
if (result.expanded) {
SelectionContainer {
Text(
result.output,
modifier = Modifier.horizontalScroll(rememberScrollState()),
color = HermesTheme.colors.codeText,
style = HermesTheme.typography.code
)
}
}
}
}
}
@Composable
private fun ChatComposer(
session: ChatSessionUi,
onDraft: (String) -> Unit,
onSend: () -> Unit,
onCancel: () -> Unit
) {
Surface(color = HermesTheme.colors.background, tonalElevation = HermesTheme.elevation.none) {
Row(
Modifier
.fillMaxWidth()
.imePadding()
.padding(horizontal = 12.dp, vertical = 10.dp),
verticalAlignment = Alignment.Bottom,
horizontalArrangement = Arrangement.spacedBy(8.dp)
) {
OutlinedTextField(
value = session.draft,
onValueChange = onDraft,
modifier = Modifier.weight(1f),
placeholder = { Text("Message Hermes", color = HermesTheme.colors.textMuted) },
minLines = 1,
maxLines = 5,
shape = RoundedCornerShape(24.dp),
colors = TextFieldDefaults.colors(
focusedContainerColor = HermesTheme.colors.surfaceInput,
unfocusedContainerColor = HermesTheme.colors.surfaceInput,
focusedIndicatorColor = HermesTheme.colors.border,
unfocusedIndicatorColor = HermesTheme.colors.border,
cursorColor = HermesTheme.colors.text
)
)
IconButton(
onClick = if (session.busy) onCancel else onSend,
enabled = session.busy || session.draft.isNotBlank(),
modifier = Modifier
.size(52.dp)
.clip(CircleShape)
.background(if (session.busy || session.draft.isNotBlank()) HermesTheme.colors.primary else HermesTheme.colors.surface)
) {
Icon(
if (session.busy) Icons.Filled.Stop else Icons.Filled.Send,
contentDescription = if (session.busy) "Cancel" else "Send",
tint = if (session.busy || session.draft.isNotBlank()) HermesTheme.colors.onPrimary else HermesTheme.colors.textMuted
)
}
}
}
}
@@ -0,0 +1,70 @@
package cloud.molberg.hermesmobile.chat
import org.junit.Assert.assertEquals
import org.junit.Assert.assertFalse
import org.junit.Assert.assertNotNull
import org.junit.Assert.assertTrue
import org.junit.Test
class FakeChatReducerTest {
@Test
fun sendCreatesUserAndStreamingAssistantAndClearsDraft() {
val sessionId = "local-1"
val start = FakeChatReducer.updateDraft(FakeChatReducer.newSession(FakeChatReducer.empty(), sessionId), sessionId, "hello")
val result = FakeChatReducer.send(start, sessionId, "hello", "m1")
val session = result.selectedSession
assertNotNull(session)
assertEquals("", session!!.draft)
assertTrue(session.busy)
assertEquals(ChatRole.User, session.messages[0].role)
assertEquals(ChatRole.Assistant, session.messages[1].role)
assertTrue(session.messages[1].streaming)
}
@Test
fun cancelTurnsStreamingMessageIntoRetryableError() {
val sessionId = "local-1"
val sent = FakeChatReducer.send(FakeChatReducer.newSession(FakeChatReducer.empty(), sessionId), sessionId, "deploy now", "m1")
val result = FakeChatReducer.cancel(sent, sessionId)
val session = result.selectedSession!!
val last = session.messages.last()
assertFalse(session.busy)
assertEquals(ChatRole.Error, last.role)
assertEquals("deploy now", last.retryPrompt)
assertFalse(last.streaming)
}
@Test
fun retryRemovesErrorAndStartsNewStreamingAttempt() {
val sessionId = "local-1"
val cancelled = FakeChatReducer.cancel(
FakeChatReducer.send(FakeChatReducer.newSession(FakeChatReducer.empty(), sessionId), sessionId, "summarize", "m1"),
sessionId
)
val errorId = cancelled.selectedSession!!.messages.last().id
val retried = FakeChatReducer.retry(cancelled, sessionId, errorId, "m2")
val session = retried.selectedSession!!
assertTrue(session.busy)
assertEquals(3, session.messages.size)
assertEquals(ChatRole.Assistant, session.messages.last().role)
assertTrue(session.messages.last().streaming)
}
@Test
fun toggleToolExpandsMatchingToolOnly() {
val state = FakeChatReducer.seed()
val session = state.selectedSession!!
val message = session.messages.last()
val toggled = FakeChatReducer.toggleTool(state, session.id, message.id, "workspace.inspect")
val part = toggled.selectedSession!!.messages.last().parts.filterIsInstance<MessagePartUi.Tool>().single()
assertTrue(part.result.expanded)
}
}