feat: add fake chat proving ground
This commit is contained in:
+10
-13
@@ -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 }
|
||||
}
|
||||
|
||||
+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