diff --git a/apps/mobile/android/app/src/main/java/cloud/molberg/hermesmobile/HermesPreviews.kt b/apps/mobile/android/app/src/main/java/cloud/molberg/hermesmobile/HermesPreviews.kt index c2e5b85..1fb38e3 100644 --- a/apps/mobile/android/app/src/main/java/cloud/molberg/hermesmobile/HermesPreviews.kt +++ b/apps/mobile/android/app/src/main/java/cloud/molberg/hermesmobile/HermesPreviews.kt @@ -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 = {}) } } diff --git a/apps/mobile/android/app/src/main/java/cloud/molberg/hermesmobile/MainActivity.kt b/apps/mobile/android/app/src/main/java/cloud/molberg/hermesmobile/MainActivity.kt index a508949..a65f27d 100644 --- a/apps/mobile/android/app/src/main/java/cloud/molberg/hermesmobile/MainActivity.kt +++ b/apps/mobile/android/app/src/main/java/cloud/molberg/hermesmobile/MainActivity.kt @@ -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 diff --git a/apps/mobile/android/app/src/main/java/cloud/molberg/hermesmobile/chat/ChatModels.kt b/apps/mobile/android/app/src/main/java/cloud/molberg/hermesmobile/chat/ChatModels.kt new file mode 100644 index 0000000..4ebfdfe --- /dev/null +++ b/apps/mobile/android/app/src/main/java/cloud/molberg/hermesmobile/chat/ChatModels.kt @@ -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, + val streaming: Boolean = false, + val retryPrompt: String? = null +) + +data class ChatSessionUi( + val id: String, + val title: String, + val subtitle: String, + val messages: List, + val draft: String = "", + val busy: Boolean = false +) + +data class FakeChatUiState( + val sessions: List = 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 } +} + diff --git a/apps/mobile/android/app/src/main/java/cloud/molberg/hermesmobile/chat/FakeChatReducer.kt b/apps/mobile/android/app/src/main/java/cloud/molberg/hermesmobile/chat/FakeChatReducer.kt new file mode 100644 index 0000000..77e4c23 --- /dev/null +++ b/apps/mobile/android/app/src/main/java/cloud/molberg/hermesmobile/chat/FakeChatReducer.kt @@ -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() + 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() + ?.firstOrNull() + ?.text + + private fun fakeAssistantParts(prompt: String): List { + 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" + ) + ) + ) + } +} diff --git a/apps/mobile/android/app/src/main/java/cloud/molberg/hermesmobile/chat/FakeChatScreen.kt b/apps/mobile/android/app/src/main/java/cloud/molberg/hermesmobile/chat/FakeChatScreen.kt new file mode 100644 index 0000000..178de78 --- /dev/null +++ b/apps/mobile/android/app/src/main/java/cloud/molberg/hermesmobile/chat/FakeChatScreen.kt @@ -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(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() + ?.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()?.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 + ) + } + } + } +} diff --git a/apps/mobile/android/app/src/test/java/cloud/molberg/hermesmobile/chat/FakeChatReducerTest.kt b/apps/mobile/android/app/src/test/java/cloud/molberg/hermesmobile/chat/FakeChatReducerTest.kt new file mode 100644 index 0000000..0dea3f3 --- /dev/null +++ b/apps/mobile/android/app/src/test/java/cloud/molberg/hermesmobile/chat/FakeChatReducerTest.kt @@ -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().single() + + assertTrue(part.result.expanded) + } +}