Co-authored-by: M57 <hy4ri@users.noreply.github.com>
This commit is contained in:
1 parent
b5c4ba50cd
commit
53c8eca714
12 files changed
+487
-2
No files matched your search
@@ -163,6 +163,13 @@ object EventParser {
|
|||||||
WsEvent.ReviewSummary(text, sessionId)
|
WsEvent.ReviewSummary(text, sessionId)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
"btw.complete" -> {
|
||||||
|
val taskId = payload?.get("task_id") as? String ?: ""
|
||||||
|
val question = payload?.get("question") as? String ?: ""
|
||||||
|
val text = (payload?.get("text") as? String)?.trim() ?: ""
|
||||||
|
WsEvent.BtwComplete(taskId, question, text, sessionId)
|
||||||
|
}
|
||||||
|
|
||||||
"session.updated" -> {
|
"session.updated" -> {
|
||||||
WsEvent.SessionUpdated(payload)
|
WsEvent.SessionUpdated(payload)
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -150,6 +150,18 @@ sealed class WsEvent {
|
|||||||
val sessionId: String? = null,
|
val sessionId: String? = null,
|
||||||
) : WsEvent()
|
) : WsEvent()
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Context-aware side-question completion event (issue #1015).
|
||||||
|
* Emitted when `prompt.btw` completes answering a side question.
|
||||||
|
* Payload: `{ task_id: "...", question: "...", text: "..." }`
|
||||||
|
*/
|
||||||
|
data class BtwComplete(
|
||||||
|
val taskId: String = "",
|
||||||
|
val question: String = "",
|
||||||
|
val text: String = "",
|
||||||
|
val sessionId: String? = null,
|
||||||
|
) : WsEvent()
|
||||||
|
|
||||||
// ── Status ───────────────────────────────────────────────────────────
|
// ── Status ───────────────────────────────────────────────────────────
|
||||||
|
|
||||||
data class StatusUpdate(
|
data class StatusUpdate(
|
||||||
|
|||||||
@@ -25,6 +25,7 @@ object WsMethods {
|
|||||||
|
|
||||||
// ── Interaction ───────────────────────────────────────────────────────
|
// ── Interaction ───────────────────────────────────────────────────────
|
||||||
const val PROMPT_SUBMIT = "prompt.submit"
|
const val PROMPT_SUBMIT = "prompt.submit"
|
||||||
|
const val PROMPT_BTW = "prompt.btw"
|
||||||
const val CLARIFY_RESPOND = "clarify.respond"
|
const val CLARIFY_RESPOND = "clarify.respond"
|
||||||
const val APPROVAL_RESPOND = "approval.respond"
|
const val APPROVAL_RESPOND = "approval.respond"
|
||||||
const val SUDO_RESPOND = "sudo.respond"
|
const val SUDO_RESPOND = "sudo.respond"
|
||||||
|
|||||||
@@ -100,6 +100,7 @@ import com.m57.hermescontrol.ui.chat.components.ContextUsageChip
|
|||||||
import com.m57.hermescontrol.ui.chat.components.ReactionHeartsOverlay
|
import com.m57.hermescontrol.ui.chat.components.ReactionHeartsOverlay
|
||||||
import com.m57.hermescontrol.ui.chat.components.ReloginDialog
|
import com.m57.hermescontrol.ui.chat.components.ReloginDialog
|
||||||
import com.m57.hermescontrol.ui.chat.components.SearchBarRow
|
import com.m57.hermescontrol.ui.chat.components.SearchBarRow
|
||||||
|
import com.m57.hermescontrol.ui.chat.components.SideQuestionSheet
|
||||||
import com.m57.hermescontrol.ui.chat.components.SubagentInspectionSheet
|
import com.m57.hermescontrol.ui.chat.components.SubagentInspectionSheet
|
||||||
import com.m57.hermescontrol.ui.chat.components.TaskProgressChip
|
import com.m57.hermescontrol.ui.chat.components.TaskProgressChip
|
||||||
import com.m57.hermescontrol.ui.chat.components.rememberChatScrollController
|
import com.m57.hermescontrol.ui.chat.components.rememberChatScrollController
|
||||||
@@ -933,6 +934,13 @@ fun ChatScreen(
|
|||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
state.btwState?.let { btw ->
|
||||||
|
SideQuestionSheet(
|
||||||
|
state = btw,
|
||||||
|
onDismiss = { viewModel.dismissBtw() },
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
viewingImage?.let { image ->
|
viewingImage?.let { image ->
|
||||||
ImageViewerDialog(
|
ImageViewerDialog(
|
||||||
image = image,
|
image = image,
|
||||||
|
|||||||
@@ -353,6 +353,8 @@ data class ChatUiState(
|
|||||||
val reactionKind: String? = null,
|
val reactionKind: String? = null,
|
||||||
/** Monotonic trigger ID so consecutive same-kind reactions re-animate. */
|
/** Monotonic trigger ID so consecutive same-kind reactions re-animate. */
|
||||||
val reactionTriggerId: Long = 0L,
|
val reactionTriggerId: Long = 0L,
|
||||||
|
/** Side-question state for /btw (issue #1015). */
|
||||||
|
val btwState: BtwUiState? = null,
|
||||||
/** Subagent delegation indicators (issue #538) — transient UI state. */
|
/** Subagent delegation indicators (issue #538) — transient UI state. */
|
||||||
val subagentIndicators: List<SubagentIndicator> = emptyList(),
|
val subagentIndicators: List<SubagentIndicator> = emptyList(),
|
||||||
/** Agent todo / plan items (issue #736). */
|
/** Agent todo / plan items (issue #736). */
|
||||||
@@ -379,6 +381,17 @@ data class ClarifyUi(
|
|||||||
val clarifyId: String? = null,
|
val clarifyId: String? = null,
|
||||||
)
|
)
|
||||||
|
|
||||||
|
/**
|
||||||
|
* State for the context-aware side-question bottom sheet (issue #1015, `/btw`).
|
||||||
|
*/
|
||||||
|
data class BtwUiState(
|
||||||
|
val taskId: String? = null,
|
||||||
|
val question: String = "",
|
||||||
|
val answer: String? = null,
|
||||||
|
val isLoading: Boolean = true,
|
||||||
|
val error: String? = null,
|
||||||
|
)
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* String sent to the agent when a clarify prompt is dismissed (the Dismiss
|
* String sent to the agent when a clarify prompt is dismissed (the Dismiss
|
||||||
* button). This is a *reject* — "I'm not answering this question" — NOT an
|
* button). This is a *reject* — "I'm not answering this question" — NOT an
|
||||||
@@ -1546,6 +1559,12 @@ class ChatViewModel(
|
|||||||
// it matches the server echo and the transcript sync dedupes instead
|
// it matches the server echo and the transcript sync dedupes instead
|
||||||
// of rendering a duplicate below its answer.
|
// of rendering a duplicate below its answer.
|
||||||
val result = slashDispatcher.dispatch(command)
|
val result = slashDispatcher.dispatch(command)
|
||||||
|
|
||||||
|
if (result is SlashResult.SideQuestion) {
|
||||||
|
handleSideQuestionCommand(result.question)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
val displayContent =
|
val displayContent =
|
||||||
if (result is SlashResult.QueuePrompt) result.displayContent else command
|
if (result is SlashResult.QueuePrompt) result.displayContent else command
|
||||||
val userMsg = ChatMessage(role = MessageRole.USER, content = displayContent)
|
val userMsg = ChatMessage(role = MessageRole.USER, content = displayContent)
|
||||||
@@ -1627,6 +1646,10 @@ class ChatViewModel(
|
|||||||
handleQueueCommand(command)
|
handleQueueCommand(command)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
is SlashResult.SideQuestion -> {
|
||||||
|
handleSideQuestionCommand(result.question)
|
||||||
|
}
|
||||||
|
|
||||||
is SlashResult.RpcDispatch -> {
|
is SlashResult.RpcDispatch -> {
|
||||||
dispatchViaRpc(command)
|
dispatchViaRpc(command)
|
||||||
}
|
}
|
||||||
@@ -1651,6 +1674,75 @@ class ChatViewModel(
|
|||||||
submitPrompt(arg, queued = true)
|
submitPrompt(arg, queued = true)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// ── Side Questions via /btw (issue #1015) ─────────────────────────────
|
||||||
|
|
||||||
|
fun dismissBtw() {
|
||||||
|
_uiState.update { it.copy(btwState = null) }
|
||||||
|
}
|
||||||
|
|
||||||
|
fun submitSideQuestion(question: String) {
|
||||||
|
val trimmed = question.trim()
|
||||||
|
if (trimmed.isBlank()) {
|
||||||
|
addAssistantMessage(
|
||||||
|
"Usage: `/btw <question>` — Ask a side question about this session without mutating its history.",
|
||||||
|
)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
val sessionId = runtimeSessionId ?: _uiState.value.currentSessionId
|
||||||
|
if (sessionId.isNullOrBlank()) {
|
||||||
|
addAssistantMessage("No active session for side questions. Start a chat first.")
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
_uiState.update {
|
||||||
|
it.copy(
|
||||||
|
btwState =
|
||||||
|
BtwUiState(
|
||||||
|
question = trimmed,
|
||||||
|
isLoading = true,
|
||||||
|
),
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
viewModelScope.launch(ioDispatcher) {
|
||||||
|
try {
|
||||||
|
val rpcResult =
|
||||||
|
wsClient
|
||||||
|
.request(
|
||||||
|
WsMethods.PROMPT_BTW,
|
||||||
|
mapOf("session_id" to sessionId, "text" to trimmed),
|
||||||
|
).await()
|
||||||
|
val taskId = (rpcResult as? Map<*, *>)?.get("task_id") as? String
|
||||||
|
if (!taskId.isNullOrBlank()) {
|
||||||
|
_uiState.update { state ->
|
||||||
|
state.btwState?.let { current ->
|
||||||
|
state.copy(btwState = current.copy(taskId = taskId))
|
||||||
|
} ?: state
|
||||||
|
}
|
||||||
|
}
|
||||||
|
} catch (e: Exception) {
|
||||||
|
_uiState.update { state ->
|
||||||
|
state.btwState?.let { current ->
|
||||||
|
state.copy(
|
||||||
|
btwState =
|
||||||
|
current.copy(
|
||||||
|
isLoading = false,
|
||||||
|
error = e.message ?: "Failed to dispatch side question",
|
||||||
|
),
|
||||||
|
)
|
||||||
|
} ?: state
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private fun handleSideQuestionCommand(question: String) {
|
||||||
|
viewModelScope.launch {
|
||||||
|
slashUsageStore.recordUse("btw")
|
||||||
|
}
|
||||||
|
submitSideQuestion(question)
|
||||||
|
}
|
||||||
|
|
||||||
// ── Update from chat (issue #862) ────────────────────────────────────
|
// ── Update from chat (issue #862) ────────────────────────────────────
|
||||||
// `/update` can't travel via the slash worker (the backend handler is
|
// `/update` can't travel via the slash worker (the backend handler is
|
||||||
// interactive + session-exiting → guaranteed 45s timeout). Intercept it
|
// interactive + session-exiting → guaranteed 45s timeout). Intercept it
|
||||||
|
|||||||
@@ -71,6 +71,7 @@ object ChatWsEventReducer {
|
|||||||
is WsEvent.ToolGenerating -> event.sessionId
|
is WsEvent.ToolGenerating -> event.sessionId
|
||||||
is WsEvent.SubagentEvent -> event.sessionId
|
is WsEvent.SubagentEvent -> event.sessionId
|
||||||
is WsEvent.ReviewSummary -> event.sessionId
|
is WsEvent.ReviewSummary -> event.sessionId
|
||||||
|
is WsEvent.BtwComplete -> event.sessionId
|
||||||
is WsEvent.SessionUsage -> event.sessionId
|
is WsEvent.SessionUsage -> event.sessionId
|
||||||
else -> null
|
else -> null
|
||||||
}
|
}
|
||||||
@@ -129,6 +130,8 @@ object ChatWsEventReducer {
|
|||||||
|
|
||||||
is WsEvent.ReviewSummary -> onReviewSummary(state, streamingState, event)
|
is WsEvent.ReviewSummary -> onReviewSummary(state, streamingState, event)
|
||||||
|
|
||||||
|
is WsEvent.BtwComplete -> onBtwComplete(state, streamingState, event)
|
||||||
|
|
||||||
is WsEvent.SessionUsage -> onSessionUsage(state, streamingState, event)
|
is WsEvent.SessionUsage -> onSessionUsage(state, streamingState, event)
|
||||||
|
|
||||||
is WsEvent.RpcError -> onRpcError(state, streamingState, event)
|
is WsEvent.RpcError -> onRpcError(state, streamingState, event)
|
||||||
@@ -652,6 +655,36 @@ object ChatWsEventReducer {
|
|||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// ── BtwComplete (Side question completed) ─────────────────────────────
|
||||||
|
|
||||||
|
private fun onBtwComplete(
|
||||||
|
state: ChatUiState,
|
||||||
|
streamingState: StreamingState,
|
||||||
|
event: WsEvent.BtwComplete,
|
||||||
|
): ReducerResult {
|
||||||
|
val currentBtw = state.btwState
|
||||||
|
val updatedBtw =
|
||||||
|
if (currentBtw != null) {
|
||||||
|
currentBtw.copy(
|
||||||
|
taskId = event.taskId.ifBlank { currentBtw.taskId },
|
||||||
|
question = event.question.ifBlank { currentBtw.question },
|
||||||
|
answer = event.text,
|
||||||
|
isLoading = false,
|
||||||
|
)
|
||||||
|
} else {
|
||||||
|
BtwUiState(
|
||||||
|
taskId = event.taskId,
|
||||||
|
question = event.question,
|
||||||
|
answer = event.text,
|
||||||
|
isLoading = false,
|
||||||
|
)
|
||||||
|
}
|
||||||
|
return ReducerResult(
|
||||||
|
state = state.copy(btwState = updatedBtw),
|
||||||
|
streamingState = streamingState,
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
// ── RpcError ──────────────────────────────────────────────────────
|
// ── RpcError ──────────────────────────────────────────────────────
|
||||||
|
|
||||||
private fun onRpcError(
|
private fun onRpcError(
|
||||||
|
|||||||
@@ -61,6 +61,11 @@ class SlashCommandDispatcher {
|
|||||||
SlashResult.OpenHistory
|
SlashResult.OpenHistory
|
||||||
}
|
}
|
||||||
|
|
||||||
|
"/btw" -> {
|
||||||
|
val arg = command.split(" ", limit = 2).getOrElse(1) { "" }.trim()
|
||||||
|
SlashResult.SideQuestion(question = arg)
|
||||||
|
}
|
||||||
|
|
||||||
else -> {
|
else -> {
|
||||||
SlashResult.RpcDispatch
|
SlashResult.RpcDispatch
|
||||||
}
|
}
|
||||||
@@ -129,4 +134,13 @@ sealed class SlashResult {
|
|||||||
data class QueuePrompt(
|
data class QueuePrompt(
|
||||||
val displayContent: String,
|
val displayContent: String,
|
||||||
) : SlashResult()
|
) : SlashResult()
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Context-aware side question about the current session (issue #1015).
|
||||||
|
* Dispatched via the `prompt.btw` RPC. Answers without mutating the
|
||||||
|
* session's history or invalidating prompt caching.
|
||||||
|
*/
|
||||||
|
data class SideQuestion(
|
||||||
|
val question: String,
|
||||||
|
) : SlashResult()
|
||||||
}
|
}
|
||||||
@@ -136,8 +136,8 @@ fun ChatInputBar(
|
|||||||
// also enforced at dispatch time (issue #576, deliverable #3).
|
// also enforced at dispatch time (issue #576, deliverable #3).
|
||||||
val hiddenSlashDisplay = CommandBlocklist.UNSUPPORTED
|
val hiddenSlashDisplay = CommandBlocklist.UNSUPPORTED
|
||||||
val commandNames =
|
val commandNames =
|
||||||
commandCatalog.pairs
|
(commandCatalog.pairs.map { it[0] } + listOf("/btw", "/queue", "/fork", "/model", "/new", "/stop"))
|
||||||
.map { it[0] }
|
.distinct()
|
||||||
.filter { it.lowercase() !in hiddenSlashDisplay }
|
.filter { it.lowercase() !in hiddenSlashDisplay }
|
||||||
|
|
||||||
androidx.compose.animation.AnimatedVisibility(
|
androidx.compose.animation.AnimatedVisibility(
|
||||||
|
|||||||
@@ -0,0 +1,239 @@
|
|||||||
|
package com.m57.hermescontrol.ui.chat.components
|
||||||
|
|
||||||
|
import android.content.ClipData
|
||||||
|
import android.content.ClipboardManager
|
||||||
|
import android.content.Context
|
||||||
|
import android.widget.Toast
|
||||||
|
import androidx.compose.animation.AnimatedVisibility
|
||||||
|
import androidx.compose.foundation.BorderStroke
|
||||||
|
import androidx.compose.foundation.layout.Arrangement
|
||||||
|
import androidx.compose.foundation.layout.Column
|
||||||
|
import androidx.compose.foundation.layout.Row
|
||||||
|
import androidx.compose.foundation.layout.Spacer
|
||||||
|
import androidx.compose.foundation.layout.fillMaxWidth
|
||||||
|
import androidx.compose.foundation.layout.height
|
||||||
|
import androidx.compose.foundation.layout.padding
|
||||||
|
import androidx.compose.foundation.layout.size
|
||||||
|
import androidx.compose.foundation.layout.width
|
||||||
|
import androidx.compose.foundation.rememberScrollState
|
||||||
|
import androidx.compose.foundation.shape.RoundedCornerShape
|
||||||
|
import androidx.compose.foundation.verticalScroll
|
||||||
|
import androidx.compose.material.icons.Icons
|
||||||
|
import androidx.compose.material.icons.automirrored.filled.HelpOutline
|
||||||
|
import androidx.compose.material.icons.filled.Close
|
||||||
|
import androidx.compose.material.icons.filled.ContentCopy
|
||||||
|
import androidx.compose.material.icons.filled.ErrorOutline
|
||||||
|
import androidx.compose.material3.ExperimentalMaterial3Api
|
||||||
|
import androidx.compose.material3.Icon
|
||||||
|
import androidx.compose.material3.IconButton
|
||||||
|
import androidx.compose.material3.LinearProgressIndicator
|
||||||
|
import androidx.compose.material3.MaterialTheme
|
||||||
|
import androidx.compose.material3.ModalBottomSheet
|
||||||
|
import androidx.compose.material3.Surface
|
||||||
|
import androidx.compose.material3.Text
|
||||||
|
import androidx.compose.material3.rememberModalBottomSheetState
|
||||||
|
import androidx.compose.runtime.Composable
|
||||||
|
import androidx.compose.ui.Alignment
|
||||||
|
import androidx.compose.ui.Modifier
|
||||||
|
import androidx.compose.ui.platform.LocalContext
|
||||||
|
import androidx.compose.ui.platform.testTag
|
||||||
|
import androidx.compose.ui.text.font.FontWeight
|
||||||
|
import androidx.compose.ui.unit.dp
|
||||||
|
import androidx.compose.ui.unit.sp
|
||||||
|
import com.m57.hermescontrol.ui.chat.BtwUiState
|
||||||
|
import com.m57.hermescontrol.ui.chat.MarkdownText
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Bottom sheet displaying a context-aware side question and its answer (issue #1015, `/btw`).
|
||||||
|
* Answers from a conversation snapshot without mutating the chat transcript.
|
||||||
|
*/
|
||||||
|
@OptIn(ExperimentalMaterial3Api::class)
|
||||||
|
@Composable
|
||||||
|
fun SideQuestionSheet(
|
||||||
|
state: BtwUiState,
|
||||||
|
onDismiss: () -> Unit,
|
||||||
|
modifier: Modifier = Modifier,
|
||||||
|
) {
|
||||||
|
val sheetState = rememberModalBottomSheetState(skipPartiallyExpanded = true)
|
||||||
|
val context = LocalContext.current
|
||||||
|
|
||||||
|
ModalBottomSheet(
|
||||||
|
onDismissRequest = onDismiss,
|
||||||
|
sheetState = sheetState,
|
||||||
|
modifier = modifier.testTag("side_question_sheet"),
|
||||||
|
) {
|
||||||
|
Column(
|
||||||
|
modifier =
|
||||||
|
Modifier
|
||||||
|
.fillMaxWidth()
|
||||||
|
.padding(horizontal = 16.dp)
|
||||||
|
.padding(bottom = 24.dp)
|
||||||
|
.verticalScroll(rememberScrollState()),
|
||||||
|
) {
|
||||||
|
// Header
|
||||||
|
Row(
|
||||||
|
modifier = Modifier.fillMaxWidth(),
|
||||||
|
verticalAlignment = Alignment.CenterVertically,
|
||||||
|
horizontalArrangement = Arrangement.SpaceBetween,
|
||||||
|
) {
|
||||||
|
Row(verticalAlignment = Alignment.CenterVertically) {
|
||||||
|
Icon(
|
||||||
|
imageVector = Icons.AutoMirrored.Filled.HelpOutline,
|
||||||
|
contentDescription = null,
|
||||||
|
tint = MaterialTheme.colorScheme.primary,
|
||||||
|
modifier = Modifier.size(22.dp),
|
||||||
|
)
|
||||||
|
Spacer(modifier = Modifier.width(8.dp))
|
||||||
|
Text(
|
||||||
|
text = "Side Question",
|
||||||
|
style = MaterialTheme.typography.titleMedium,
|
||||||
|
fontWeight = FontWeight.Bold,
|
||||||
|
color = MaterialTheme.colorScheme.onSurface,
|
||||||
|
)
|
||||||
|
}
|
||||||
|
IconButton(
|
||||||
|
onClick = onDismiss,
|
||||||
|
modifier = Modifier.size(32.dp),
|
||||||
|
) {
|
||||||
|
Icon(
|
||||||
|
imageVector = Icons.Default.Close,
|
||||||
|
contentDescription = "Close",
|
||||||
|
tint = MaterialTheme.colorScheme.onSurfaceVariant,
|
||||||
|
)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
Spacer(modifier = Modifier.height(12.dp))
|
||||||
|
|
||||||
|
// Question Banner
|
||||||
|
if (state.question.isNotBlank()) {
|
||||||
|
Surface(
|
||||||
|
modifier = Modifier.fillMaxWidth(),
|
||||||
|
shape = RoundedCornerShape(12.dp),
|
||||||
|
color = MaterialTheme.colorScheme.surfaceVariant.copy(alpha = 0.6f),
|
||||||
|
) {
|
||||||
|
Column(modifier = Modifier.padding(12.dp)) {
|
||||||
|
Text(
|
||||||
|
text = "Question",
|
||||||
|
style = MaterialTheme.typography.labelSmall,
|
||||||
|
fontWeight = FontWeight.Bold,
|
||||||
|
color = MaterialTheme.colorScheme.primary,
|
||||||
|
)
|
||||||
|
Spacer(modifier = Modifier.height(4.dp))
|
||||||
|
Text(
|
||||||
|
text = state.question,
|
||||||
|
style = MaterialTheme.typography.bodyMedium,
|
||||||
|
color = MaterialTheme.colorScheme.onSurfaceVariant,
|
||||||
|
)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
Spacer(modifier = Modifier.height(12.dp))
|
||||||
|
}
|
||||||
|
|
||||||
|
// Loading state
|
||||||
|
if (state.isLoading) {
|
||||||
|
Column(
|
||||||
|
modifier =
|
||||||
|
Modifier
|
||||||
|
.fillMaxWidth()
|
||||||
|
.padding(vertical = 16.dp),
|
||||||
|
horizontalAlignment = Alignment.CenterHorizontally,
|
||||||
|
) {
|
||||||
|
LinearProgressIndicator(
|
||||||
|
modifier =
|
||||||
|
Modifier
|
||||||
|
.fillMaxWidth()
|
||||||
|
.height(4.dp),
|
||||||
|
color = MaterialTheme.colorScheme.primary,
|
||||||
|
)
|
||||||
|
Spacer(modifier = Modifier.height(12.dp))
|
||||||
|
Text(
|
||||||
|
text = "Answering from conversation snapshot...",
|
||||||
|
style = MaterialTheme.typography.bodySmall,
|
||||||
|
color = MaterialTheme.colorScheme.onSurfaceVariant,
|
||||||
|
)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Error state
|
||||||
|
if (!state.error.isNullOrBlank()) {
|
||||||
|
Surface(
|
||||||
|
modifier = Modifier.fillMaxWidth(),
|
||||||
|
shape = RoundedCornerShape(12.dp),
|
||||||
|
color = MaterialTheme.colorScheme.errorContainer.copy(alpha = 0.7f),
|
||||||
|
) {
|
||||||
|
Row(
|
||||||
|
modifier = Modifier.padding(12.dp),
|
||||||
|
verticalAlignment = Alignment.CenterVertically,
|
||||||
|
) {
|
||||||
|
Icon(
|
||||||
|
imageVector = Icons.Default.ErrorOutline,
|
||||||
|
contentDescription = null,
|
||||||
|
tint = MaterialTheme.colorScheme.error,
|
||||||
|
modifier = Modifier.size(20.dp),
|
||||||
|
)
|
||||||
|
Spacer(modifier = Modifier.width(8.dp))
|
||||||
|
Text(
|
||||||
|
text = state.error,
|
||||||
|
style = MaterialTheme.typography.bodyMedium,
|
||||||
|
color = MaterialTheme.colorScheme.onErrorContainer,
|
||||||
|
)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Answer state
|
||||||
|
val answer = state.answer
|
||||||
|
if (!answer.isNullOrBlank()) {
|
||||||
|
Surface(
|
||||||
|
modifier = Modifier.fillMaxWidth(),
|
||||||
|
shape = RoundedCornerShape(12.dp),
|
||||||
|
color = MaterialTheme.colorScheme.surface,
|
||||||
|
border =
|
||||||
|
androidx.compose.foundation.BorderStroke(
|
||||||
|
1.dp,
|
||||||
|
MaterialTheme.colorScheme.outlineVariant.copy(alpha = 0.5f),
|
||||||
|
),
|
||||||
|
) {
|
||||||
|
Column(modifier = Modifier.padding(14.dp)) {
|
||||||
|
Row(
|
||||||
|
modifier = Modifier.fillMaxWidth(),
|
||||||
|
verticalAlignment = Alignment.CenterVertically,
|
||||||
|
horizontalArrangement = Arrangement.SpaceBetween,
|
||||||
|
) {
|
||||||
|
Text(
|
||||||
|
text = "Answer",
|
||||||
|
style = MaterialTheme.typography.labelSmall,
|
||||||
|
fontWeight = FontWeight.Bold,
|
||||||
|
color = MaterialTheme.colorScheme.primary,
|
||||||
|
)
|
||||||
|
IconButton(
|
||||||
|
onClick = {
|
||||||
|
val clipboard =
|
||||||
|
context.getSystemService(
|
||||||
|
Context.CLIPBOARD_SERVICE,
|
||||||
|
) as? ClipboardManager
|
||||||
|
clipboard?.setPrimaryClip(ClipData.newPlainText("Side Question Answer", answer))
|
||||||
|
Toast.makeText(context, "Copied answer", Toast.LENGTH_SHORT).show()
|
||||||
|
},
|
||||||
|
modifier = Modifier.size(24.dp),
|
||||||
|
) {
|
||||||
|
Icon(
|
||||||
|
imageVector = Icons.Default.ContentCopy,
|
||||||
|
contentDescription = "Copy answer",
|
||||||
|
tint = MaterialTheme.colorScheme.onSurfaceVariant,
|
||||||
|
modifier = Modifier.size(16.dp),
|
||||||
|
)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
Spacer(modifier = Modifier.height(8.dp))
|
||||||
|
MarkdownText(
|
||||||
|
text = answer,
|
||||||
|
textColor = MaterialTheme.colorScheme.onSurface,
|
||||||
|
)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -478,6 +478,40 @@ class ChatViewModelTest {
|
|||||||
verify { HermesWsClient.send(WsMethods.SESSION_INTERRUPT, any(), any()) }
|
verify { HermesWsClient.send(WsMethods.SESSION_INTERRUPT, any(), any()) }
|
||||||
}
|
}
|
||||||
|
|
||||||
|
@Test
|
||||||
|
fun testSlashCommand_btw_dispatchesPromptBtw_andSetsBtwState() =
|
||||||
|
runTest {
|
||||||
|
val (viewModel, sessionId) = createViewModelWithSession()
|
||||||
|
|
||||||
|
every {
|
||||||
|
HermesWsClient.request(WsMethods.PROMPT_BTW, any(), any())
|
||||||
|
} returns
|
||||||
|
CompletableDeferred(
|
||||||
|
mapOf("task_id" to "btw_123456"),
|
||||||
|
)
|
||||||
|
|
||||||
|
viewModel.sendMessage("/btw which file was that in?")
|
||||||
|
advanceUntilIdle()
|
||||||
|
|
||||||
|
val btw = viewModel.uiState.value.btwState
|
||||||
|
assertNotNull(btw)
|
||||||
|
assertEquals("which file was that in?", btw?.question)
|
||||||
|
assertEquals("btw_123456", btw?.taskId)
|
||||||
|
assertTrue(btw?.isLoading == true)
|
||||||
|
verify {
|
||||||
|
HermesWsClient.request(
|
||||||
|
WsMethods.PROMPT_BTW,
|
||||||
|
mapOf("session_id" to sessionId, "text" to "which file was that in?"),
|
||||||
|
any(),
|
||||||
|
)
|
||||||
|
}
|
||||||
|
// Ensure transcript messages were NOT polluted with the side question
|
||||||
|
assertTrue(
|
||||||
|
viewModel.uiState.value.messages
|
||||||
|
.none { it.content.contains("which file was that in?") },
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
// ── Slash usage ranking (issue #865) ────────────────────────────────────
|
// ── Slash usage ranking (issue #865) ────────────────────────────────────
|
||||||
|
|
||||||
@Test
|
@Test
|
||||||
|
|||||||
@@ -535,6 +535,43 @@ class ChatWsEventReducerTest {
|
|||||||
assertEquals("💾 Self-improvement review: Skill 'android-ci' patched", msg.content)
|
assertEquals("💾 Self-improvement review: Skill 'android-ci' patched", msg.content)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
@Test
|
||||||
|
fun testBtwComplete_updatesBtwState() {
|
||||||
|
val state =
|
||||||
|
ChatUiState(
|
||||||
|
currentSessionId = "session-1",
|
||||||
|
btwState =
|
||||||
|
BtwUiState(
|
||||||
|
question = "what model?",
|
||||||
|
isLoading = true,
|
||||||
|
),
|
||||||
|
)
|
||||||
|
val event =
|
||||||
|
WsEvent.BtwComplete(
|
||||||
|
taskId = "btw_123456",
|
||||||
|
question = "what model?",
|
||||||
|
text = "This conversation uses Gemini 3.7.",
|
||||||
|
sessionId = "session-1",
|
||||||
|
)
|
||||||
|
|
||||||
|
val result =
|
||||||
|
ChatWsEventReducer.reduce(
|
||||||
|
state = state,
|
||||||
|
streamingState = StreamingState(),
|
||||||
|
event = event,
|
||||||
|
currentSessionId = "session-1",
|
||||||
|
)
|
||||||
|
|
||||||
|
val btw = result.state.btwState
|
||||||
|
org.junit.Assert.assertNotNull(btw)
|
||||||
|
assertEquals("btw_123456", btw?.taskId)
|
||||||
|
assertEquals("what model?", btw?.question)
|
||||||
|
assertEquals("This conversation uses Gemini 3.7.", btw?.answer)
|
||||||
|
assertEquals(false, btw?.isLoading)
|
||||||
|
// Zero messages appended to transcript!
|
||||||
|
assertEquals(0, result.state.messages.size)
|
||||||
|
}
|
||||||
|
|
||||||
// ── Issue #771: reasoning survives a tool call (regression) ────────────
|
// ── Issue #771: reasoning survives a tool call (regression) ────────────
|
||||||
//
|
//
|
||||||
// Real gateway capture for `run echo hi` (reasoning model):
|
// Real gateway capture for `run echo hi` (reasoning model):
|
||||||
|
|||||||
@@ -74,6 +74,14 @@ class SlashCommandDispatcherTest {
|
|||||||
assertEquals(SlashResult.OpenHistory, dispatcher.dispatch("/History"))
|
assertEquals(SlashResult.OpenHistory, dispatcher.dispatch("/History"))
|
||||||
}
|
}
|
||||||
|
|
||||||
|
@Test
|
||||||
|
fun `btw routes to SideQuestion`() {
|
||||||
|
assertEquals(SlashResult.SideQuestion("what model is this?"), dispatcher.dispatch("/btw what model is this?"))
|
||||||
|
assertEquals(SlashResult.SideQuestion("what model is this?"), dispatcher.dispatch("/BTW what model is this?"))
|
||||||
|
assertEquals(SlashResult.SideQuestion(""), dispatcher.dispatch("/btw"))
|
||||||
|
assertEquals(SlashResult.SideQuestion(""), dispatcher.dispatch("/btw "))
|
||||||
|
}
|
||||||
|
|
||||||
@Test
|
@Test
|
||||||
fun `NEW uppercase still routes to NewSession`() {
|
fun `NEW uppercase still routes to NewSession`() {
|
||||||
// Dispatcher lower-cases before matching, so case must not matter.
|
// Dispatcher lower-cases before matching, so case must not matter.
|
||||||
|
|||||||
Reference in new issue
Block a user