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)
|
||||
}
|
||||
|
||||
"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,
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
Reference in new issue
Block a user