feat: adopt side-questions via prompt.btw and /btw command in Chat (fixes #1015) (#1026)

Co-authored-by: M57 <hy4ri@users.noreply.github.com>
This commit is contained in:
M57andM57 authored and GitHub committed 2026-08-31 15:54:09 +03:00
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)
}
"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" -> {
WsEvent.SessionUpdated(payload)
}
@@ -150,6 +150,18 @@ sealed class WsEvent {
val sessionId: String? = null,
) : 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 ───────────────────────────────────────────────────────────
data class StatusUpdate(
@@ -25,6 +25,7 @@ object WsMethods {
// ── Interaction ───────────────────────────────────────────────────────
const val PROMPT_SUBMIT = "prompt.submit"
const val PROMPT_BTW = "prompt.btw"
const val CLARIFY_RESPOND = "clarify.respond"
const val APPROVAL_RESPOND = "approval.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.ReloginDialog
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.TaskProgressChip
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 ->
ImageViewerDialog(
image = image,
@@ -353,6 +353,8 @@ data class ChatUiState(
val reactionKind: String? = null,
/** Monotonic trigger ID so consecutive same-kind reactions re-animate. */
val reactionTriggerId: Long = 0L,
/** Side-question state for /btw (issue #1015). */
val btwState: BtwUiState? = null,
/** Subagent delegation indicators (issue #538) — transient UI state. */
val subagentIndicators: List<SubagentIndicator> = emptyList(),
/** Agent todo / plan items (issue #736). */
@@ -379,6 +381,17 @@ data class ClarifyUi(
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
* 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
// of rendering a duplicate below its answer.
val result = slashDispatcher.dispatch(command)
if (result is SlashResult.SideQuestion) {
handleSideQuestionCommand(result.question)
return
}
val displayContent =
if (result is SlashResult.QueuePrompt) result.displayContent else command
val userMsg = ChatMessage(role = MessageRole.USER, content = displayContent)
@@ -1627,6 +1646,10 @@ class ChatViewModel(
handleQueueCommand(command)
}
is SlashResult.SideQuestion -> {
handleSideQuestionCommand(result.question)
}
is SlashResult.RpcDispatch -> {
dispatchViaRpc(command)
}
@@ -1651,6 +1674,75 @@ class ChatViewModel(
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` can't travel via the slash worker (the backend handler is
// interactive + session-exiting → guaranteed 45s timeout). Intercept it
@@ -71,6 +71,7 @@ object ChatWsEventReducer {
is WsEvent.ToolGenerating -> event.sessionId
is WsEvent.SubagentEvent -> event.sessionId
is WsEvent.ReviewSummary -> event.sessionId
is WsEvent.BtwComplete -> event.sessionId
is WsEvent.SessionUsage -> event.sessionId
else -> null
}
@@ -129,6 +130,8 @@ object ChatWsEventReducer {
is WsEvent.ReviewSummary -> onReviewSummary(state, streamingState, event)
is WsEvent.BtwComplete -> onBtwComplete(state, streamingState, event)
is WsEvent.SessionUsage -> onSessionUsage(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 ──────────────────────────────────────────────────────
private fun onRpcError(
@@ -61,6 +61,11 @@ class SlashCommandDispatcher {
SlashResult.OpenHistory
}
"/btw" -> {
val arg = command.split(" ", limit = 2).getOrElse(1) { "" }.trim()
SlashResult.SideQuestion(question = arg)
}
else -> {
SlashResult.RpcDispatch
}
@@ -129,4 +134,13 @@ sealed class SlashResult {
data class QueuePrompt(
val displayContent: String,
) : 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).
val hiddenSlashDisplay = CommandBlocklist.UNSUPPORTED
val commandNames =
commandCatalog.pairs
.map { it[0] }
(commandCatalog.pairs.map { it[0] } + listOf("/btw", "/queue", "/fork", "/model", "/new", "/stop"))
.distinct()
.filter { it.lowercase() !in hiddenSlashDisplay }
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,
)
}
}
}
}
}
}