feat: add fake chat proving ground
This commit is contained in:
@@ -1,12 +1,8 @@
|
|||||||
package cloud.molberg.hermesmobile
|
package cloud.molberg.hermesmobile
|
||||||
|
|
||||||
import android.content.res.Configuration
|
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.fillMaxSize
|
||||||
import androidx.compose.foundation.layout.fillMaxWidth
|
import androidx.compose.foundation.layout.fillMaxWidth
|
||||||
import androidx.compose.foundation.layout.padding
|
|
||||||
import androidx.compose.material.icons.Icons
|
import androidx.compose.material.icons.Icons
|
||||||
import androidx.compose.material.icons.filled.Add
|
import androidx.compose.material.icons.filled.Add
|
||||||
import androidx.compose.material.icons.filled.Folder
|
import androidx.compose.material.icons.filled.Folder
|
||||||
@@ -15,6 +11,7 @@ import androidx.compose.material3.Surface
|
|||||||
import androidx.compose.runtime.Composable
|
import androidx.compose.runtime.Composable
|
||||||
import androidx.compose.ui.Modifier
|
import androidx.compose.ui.Modifier
|
||||||
import androidx.compose.ui.tooling.preview.Preview
|
import androidx.compose.ui.tooling.preview.Preview
|
||||||
|
import cloud.molberg.hermesmobile.chat.FakeInboxScreen
|
||||||
import cloud.molberg.hermesmobile.design.CodeCard
|
import cloud.molberg.hermesmobile.design.CodeCard
|
||||||
import cloud.molberg.hermesmobile.design.HermesMobileTheme
|
import cloud.molberg.hermesmobile.design.HermesMobileTheme
|
||||||
import cloud.molberg.hermesmobile.design.HermesTheme
|
import cloud.molberg.hermesmobile.design.HermesTheme
|
||||||
@@ -46,15 +43,15 @@ fun NormalPhoneDarkPreview() {
|
|||||||
@Composable
|
@Composable
|
||||||
fun TabletWidthLightPreview() {
|
fun TabletWidthLightPreview() {
|
||||||
HermesPreviewSurface(darkTheme = false) {
|
HermesPreviewSurface(darkTheme = false) {
|
||||||
Row(
|
FakeInboxScreen(resetToken = 0, notify = {})
|
||||||
Modifier
|
|
||||||
.fillMaxSize()
|
|
||||||
.padding(HermesTheme.spacing.lg),
|
|
||||||
horizontalArrangement = Arrangement.spacedBy(HermesTheme.spacing.lg)
|
|
||||||
) {
|
|
||||||
Column(Modifier.weight(0.42f)) { SampleHomeList() }
|
|
||||||
Column(Modifier.weight(0.58f)) { SampleChatThread() }
|
|
||||||
}
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
@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.SectionHeader
|
||||||
import cloud.molberg.hermesmobile.design.TinyLabel
|
import cloud.molberg.hermesmobile.design.TinyLabel
|
||||||
import cloud.molberg.hermesmobile.design.TypingIndicator
|
import cloud.molberg.hermesmobile.design.TypingIndicator
|
||||||
|
import cloud.molberg.hermesmobile.chat.FakeInboxScreen
|
||||||
import androidx.compose.material3.Icon
|
import androidx.compose.material3.Icon
|
||||||
import androidx.compose.material3.IconButton
|
import androidx.compose.material3.IconButton
|
||||||
import androidx.compose.material3.NavigationBar
|
import androidx.compose.material3.NavigationBar
|
||||||
@@ -371,7 +372,7 @@ fun HermesNativeApp() {
|
|||||||
label = "screen-slide"
|
label = "screen-slide"
|
||||||
) { active ->
|
) { active ->
|
||||||
when (active) {
|
when (active) {
|
||||||
Screen.Inbox -> InboxScreen(api, resetInboxToken)
|
Screen.Inbox -> FakeInboxScreen(resetInboxToken, ::notify)
|
||||||
Screen.Terminals -> TerminalsScreen(api, ::notify, onNewChat = {
|
Screen.Terminals -> TerminalsScreen(api, ::notify, onNewChat = {
|
||||||
resetInboxToken++
|
resetInboxToken++
|
||||||
screen = Screen.Inbox
|
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 }
|
||||||
|
}
|
||||||
|
|
||||||
+254
@@ -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"
|
||||||
|
)
|
||||||
|
)
|
||||||
|
)
|
||||||
|
}
|
||||||
|
}
|
||||||
+548
@@ -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
|
||||||
|
)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
+70
@@ -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)
|
||||||
|
}
|
||||||
|
}
|
||||||
Reference in New Issue
Block a user