diff --git a/.idea/appInsightsSettings.xml b/.idea/appInsightsSettings.xml new file mode 100644 index 0000000..e762975 --- /dev/null +++ b/.idea/appInsightsSettings.xml @@ -0,0 +1,18 @@ + + + + + + \ No newline at end of file diff --git a/.idea/misc.xml b/.idea/misc.xml index 1d95b08..cd515c8 100644 --- a/.idea/misc.xml +++ b/.idea/misc.xml @@ -1,7 +1,7 @@ - + diff --git a/.idea/vcs.xml b/.idea/vcs.xml new file mode 100644 index 0000000..94a25f7 --- /dev/null +++ b/.idea/vcs.xml @@ -0,0 +1,6 @@ + + + + + + \ No newline at end of file diff --git a/app/google-services.json b/app/google-services.json new file mode 100644 index 0000000..2c7c9ce Binary files /dev/null and b/app/google-services.json differ diff --git a/app/src/androidTest/java/gg/roxy/chatFullscreen/components/ChatComposerTest.kt b/app/src/androidTest/java/gg/roxy/chatFullscreen/components/ChatComposerTest.kt new file mode 100644 index 0000000..65c9420 --- /dev/null +++ b/app/src/androidTest/java/gg/roxy/chatFullscreen/components/ChatComposerTest.kt @@ -0,0 +1,58 @@ +package gg.roxy.chatFullscreen.components + +import androidx.compose.runtime.getValue +import androidx.compose.runtime.mutableStateOf +import androidx.compose.runtime.remember +import androidx.compose.runtime.setValue +import androidx.compose.ui.test.assertIsNotEnabled +import androidx.compose.ui.test.junit4.createComposeRule +import androidx.compose.ui.test.onNodeWithContentDescription +import androidx.compose.ui.test.performClick +import androidx.compose.ui.test.performImeAction +import gg.roxy.shared.styles.RoxyTheme +import org.junit.Assert.assertEquals +import org.junit.Rule +import org.junit.Test + +class ChatComposerTest { + @get:Rule + val composeRule = createComposeRule() + + @Test + fun offlineComposerBlocksBothButtonAndKeyboardSubmission() { + var sends = 0 + composeRule.setContent { + RoxyTheme { + ChatComposer("Saved draft", {}, { sends++ }, canSubmit = false) + } + } + composeRule.onNodeWithContentDescription("Send").assertIsNotEnabled() + composeRule.onNodeWithContentDescription("Message Roxy").performImeAction() + composeRule.onNodeWithContentDescription("Attach image or file").assertDoesNotExist() + composeRule.runOnIdle { assertEquals(0, sends) } + } + + @Test + fun stopReplacesSendAndDisablesWhileWaitingForTheHost() { + var stops = 0 + composeRule.setContent { + var stopping by remember { mutableStateOf(false) } + RoxyTheme { + ChatComposer( + text = "Next draft", + onTextChange = {}, + onSubmit = { error("A running turn must not submit another prompt") }, + canSubmit = false, + showStop = true, + canStop = !stopping, + isStopping = stopping, + onStop = { stops++; stopping = true }, + ) + } + } + composeRule.onNodeWithContentDescription("Send").assertDoesNotExist() + composeRule.onNodeWithContentDescription("Stop").performClick() + composeRule.onNodeWithContentDescription("Stopping").assertIsNotEnabled() + composeRule.runOnIdle { assertEquals(1, stops) } + } +} diff --git a/app/src/androidTest/java/gg/roxy/shared/components/PinInputTest.kt b/app/src/androidTest/java/gg/roxy/shared/components/PinInputTest.kt new file mode 100644 index 0000000..1187b1e --- /dev/null +++ b/app/src/androidTest/java/gg/roxy/shared/components/PinInputTest.kt @@ -0,0 +1,49 @@ +package gg.roxy.shared.components + +import androidx.compose.runtime.getValue +import androidx.compose.runtime.mutableStateOf +import androidx.compose.runtime.remember +import androidx.compose.runtime.setValue +import androidx.compose.ui.input.key.Key +import androidx.compose.ui.test.hasSetTextAction +import androidx.compose.ui.test.junit4.createComposeRule +import androidx.compose.ui.test.ExperimentalTestApi +import androidx.compose.ui.test.performKeyInput +import androidx.compose.ui.test.performTextInput +import androidx.compose.ui.test.pressKey +import gg.roxy.shared.styles.RoxyTheme +import org.junit.Assert.assertEquals +import org.junit.Rule +import org.junit.Test + +class PinInputTest { + @get:Rule + val composeRule = createComposeRule() + + @OptIn(ExperimentalTestApi::class) + @Test + fun rejectedInputDoesNotRequireAnExtraBackspace() { + var currentPin = "" + composeRule.setContent { + RoxyTheme(darkTheme = true) { + var pin by remember { mutableStateOf("") } + currentPin = pin + PinInput( + value = pin, + onValueChange = { pin = it }, + ) + } + } + + val input = composeRule.onNode(hasSetTextAction()) + input.performTextInput("abc1234567") + composeRule.runOnIdle { + assertEquals("123456", currentPin) + } + + input.performKeyInput { pressKey(Key.Backspace) } + composeRule.runOnIdle { + assertEquals("12345", currentPin) + } + } +} diff --git a/app/src/main/java/gg/roxy/MainActivity.kt b/app/src/main/java/gg/roxy/MainActivity.kt index 27e719c..cf6c941 100644 --- a/app/src/main/java/gg/roxy/MainActivity.kt +++ b/app/src/main/java/gg/roxy/MainActivity.kt @@ -37,6 +37,8 @@ class MainActivity : ComponentActivity() { onBackFromChat = viewModel::showMainScreen, onComposerChange = viewModel::updateComposer, onComposerSubmit = viewModel::submitComposer, + onReconnect = viewModel::reconnectRemote, + onStop = viewModel::stopTurn, onToolCallClick = viewModel::toggleToolCall, onAddNewComputer = viewModel::showConnectDialog, onScanQrCode = ::startQrScanner, diff --git a/app/src/main/java/gg/roxy/RoxyApp.kt b/app/src/main/java/gg/roxy/RoxyApp.kt index 04e69fa..b5ff4a9 100644 --- a/app/src/main/java/gg/roxy/RoxyApp.kt +++ b/app/src/main/java/gg/roxy/RoxyApp.kt @@ -25,6 +25,8 @@ fun RoxyApp( onDisconnectComputer: () -> Unit = {}, initialToken: String = "", initialPin: String = "", + onReconnect: () -> Unit = {}, + onStop: () -> Unit = {}, ) { when (uiState.destination) { RoxyDestination.Main -> MainFullScreen( @@ -43,6 +45,8 @@ fun RoxyApp( ) RoxyDestination.Chat -> ChatFullScreen( + onReconnect = onReconnect, + onStop = onStop, uiState = uiState.chat, onBackClick = onBackFromChat, onComposerChange = onComposerChange, diff --git a/app/src/main/java/gg/roxy/chatFullscreen/businessLogic/ChatFullScreenUiState.kt b/app/src/main/java/gg/roxy/chatFullscreen/businessLogic/ChatFullScreenUiState.kt index 5748297..dd0f20a 100644 --- a/app/src/main/java/gg/roxy/chatFullscreen/businessLogic/ChatFullScreenUiState.kt +++ b/app/src/main/java/gg/roxy/chatFullscreen/businessLogic/ChatFullScreenUiState.kt @@ -65,4 +65,16 @@ data class ChatFullScreenUiState( val composerText: String = "", val isRunning: Boolean = false, val isSyncing: Boolean = false, -) + val isConnected: Boolean = false, + val isConnecting: Boolean = false, + val errorMessage: String? = null, + val isSessionReady: Boolean = false, + val queuedPromptCount: Int = 0, + val isAwaitingResponse: Boolean = false, + val isMobileTurn: Boolean = false, + val isStopping: Boolean = false, +) { + val canSubmit: Boolean get() = isConnected && isSessionReady && !isSyncing && + !isRunning && !isAwaitingResponse && !isStopping && queuedPromptCount == 0 && composerText.isNotBlank() + val canStop: Boolean get() = isConnected && isSessionReady && isRunning && isMobileTurn && !isStopping +} diff --git a/app/src/main/java/gg/roxy/chatFullscreen/components/ChatComposer.kt b/app/src/main/java/gg/roxy/chatFullscreen/components/ChatComposer.kt index c89d583..0a2dc92 100644 --- a/app/src/main/java/gg/roxy/chatFullscreen/components/ChatComposer.kt +++ b/app/src/main/java/gg/roxy/chatFullscreen/components/ChatComposer.kt @@ -10,15 +10,13 @@ import androidx.compose.foundation.layout.height import androidx.compose.foundation.layout.heightIn import androidx.compose.foundation.layout.padding import androidx.compose.foundation.layout.size -import androidx.compose.foundation.layout.width -import androidx.compose.foundation.shape.CircleShape import androidx.compose.foundation.shape.RoundedCornerShape import androidx.compose.foundation.text.BasicTextField import androidx.compose.foundation.text.KeyboardActions import androidx.compose.foundation.text.KeyboardOptions import androidx.compose.material.icons.Icons -import androidx.compose.material.icons.rounded.Add import androidx.compose.material.icons.rounded.ArrowUpward +import androidx.compose.material.icons.rounded.Stop import androidx.compose.material3.Icon import androidx.compose.material3.MaterialTheme import androidx.compose.material3.Surface @@ -40,6 +38,11 @@ fun ChatComposer( onTextChange: (String) -> Unit, onSubmit: () -> Unit, modifier: Modifier = Modifier, + canSubmit: Boolean = true, + showStop: Boolean = false, + canStop: Boolean = false, + isStopping: Boolean = false, + onStop: () -> Unit = {}, ) { val colors = MaterialTheme.roxyColors @@ -65,12 +68,12 @@ fun ChatComposer( textStyle = MaterialTheme.typography.bodyMedium.copy(color = colors.text), cursorBrush = SolidColor(colors.accent), keyboardOptions = KeyboardOptions(imeAction = ImeAction.Send), - keyboardActions = KeyboardActions(onSend = { if (text.isNotBlank()) onSubmit() }), + keyboardActions = KeyboardActions(onSend = { if (canSubmit && text.isNotBlank()) onSubmit() }), decorationBox = { innerTextField -> Box { if (text.isEmpty()) { Text( - text = "Ask Roxy anything... (paste or drop images)", + text = "Ask Roxy anything...", style = MaterialTheme.typography.bodyMedium, color = colors.textMuted, ) @@ -87,26 +90,6 @@ fun ChatComposer( modifier = Modifier.fillMaxWidth(), verticalAlignment = Alignment.CenterVertically, ) { - // Attach button (+) - Surface( - onClick = { /* Attach image or file */ }, - shape = CircleShape, - color = colors.elevated, - border = BorderStroke(1.dp, colors.edge), - modifier = Modifier.size(30.dp), - ) { - Box(contentAlignment = Alignment.Center) { - Icon( - imageVector = Icons.Rounded.Add, - contentDescription = "Attach image or file", - modifier = Modifier.size(16.dp), - tint = colors.textMuted, - ) - } - } - - Spacer(Modifier.width(8.dp)) - Text( text = "Uses this session's desktop model", style = MaterialTheme.typography.labelSmall, @@ -114,22 +97,21 @@ fun ChatComposer( modifier = Modifier.weight(1f).padding(end = 8.dp), ) - // Send Button with clean default theme (White when active) - val isSendActive = text.isNotBlank() + val isActionEnabled = if (showStop) canStop else canSubmit && text.isNotBlank() Surface( - onClick = onSubmit, - enabled = isSendActive, + onClick = if (showStop) onStop else onSubmit, + enabled = isActionEnabled, modifier = Modifier.size(34.dp), shape = RoundedCornerShape(10.dp), - color = if (isSendActive) Color.White else colors.white.copy(alpha = 0.25f), + color = if (isActionEnabled) Color.White else colors.white.copy(alpha = 0.25f), contentColor = Color.Black, ) { Box(contentAlignment = Alignment.Center) { Icon( - imageVector = Icons.Rounded.ArrowUpward, - contentDescription = "Send", + imageVector = if (showStop) Icons.Rounded.Stop else Icons.Rounded.ArrowUpward, + contentDescription = if (showStop) { if (isStopping) "Stopping" else "Stop" } else "Send", modifier = Modifier.size(18.dp), - tint = if (isSendActive) Color.Black else colors.textSubtle, + tint = if (isActionEnabled) Color.Black else colors.textSubtle, ) } } diff --git a/app/src/main/java/gg/roxy/chatFullscreen/components/ChatFullScreen.kt b/app/src/main/java/gg/roxy/chatFullscreen/components/ChatFullScreen.kt index 600c1dc..e825af2 100644 --- a/app/src/main/java/gg/roxy/chatFullscreen/components/ChatFullScreen.kt +++ b/app/src/main/java/gg/roxy/chatFullscreen/components/ChatFullScreen.kt @@ -18,11 +18,14 @@ import androidx.compose.foundation.layout.size import androidx.compose.foundation.layout.width import androidx.compose.foundation.layout.widthIn import androidx.compose.foundation.lazy.LazyColumn -import androidx.compose.foundation.lazy.rememberLazyListState +import androidx.compose.foundation.lazy.LazyListState +import androidx.compose.foundation.lazy.items import androidx.compose.foundation.shape.CircleShape import androidx.compose.material.icons.Icons import androidx.compose.material.icons.automirrored.rounded.ArrowBack import androidx.compose.material.icons.rounded.Folder +import androidx.compose.material3.Button +import androidx.compose.material3.ButtonDefaults import androidx.compose.material3.CircularProgressIndicator import androidx.compose.material3.HorizontalDivider import androidx.compose.material3.Icon @@ -31,9 +34,14 @@ import androidx.compose.material3.MaterialTheme import androidx.compose.material3.Surface import androidx.compose.material3.Text import androidx.compose.runtime.Composable +import androidx.compose.runtime.Immutable import androidx.compose.runtime.LaunchedEffect -import androidx.compose.runtime.snapshotFlow -import androidx.compose.runtime.withFrameNanos +import androidx.compose.runtime.derivedStateOf +import androidx.compose.runtime.getValue +import androidx.compose.runtime.mutableStateOf +import androidx.compose.runtime.remember +import androidx.compose.runtime.saveable.rememberSaveable +import androidx.compose.runtime.setValue import androidx.compose.ui.Alignment import androidx.compose.ui.Modifier import androidx.compose.ui.text.font.FontWeight @@ -43,13 +51,89 @@ import androidx.compose.ui.unit.dp import gg.roxy.chatFullscreen.businessLogic.ChatFullScreenUiState import gg.roxy.chatFullscreen.businessLogic.ChatMessageUiModel import gg.roxy.chatFullscreen.businessLogic.ChatPartUiModel -import gg.roxy.chatFullscreen.businessLogic.ToolCallStatus -import gg.roxy.chatFullscreen.businessLogic.ToolCallType import gg.roxy.chatFullscreen.businessLogic.ToolCallUiModel import gg.roxy.shared.styles.RoxyTheme import gg.roxy.shared.styles.roxyColors -import kotlinx.coroutines.flow.filter -import kotlinx.coroutines.flow.first + +/** + * One entry of the transcript. Messages are flattened into rows up front so the + * list has a single, stable index space: the newest row is always index 0, which + * is what pins the viewport to the bottom of the conversation. + */ +@Immutable +internal sealed interface ChatRow { + val key: String + + @Immutable + data class UserMessage(override val key: String, val text: String) : ChatRow + + @Immutable + data class Markdown(override val key: String, val text: String) : ChatRow + + @Immutable + data class Reasoning(val part: ChatPartUiModel.Reasoning) : ChatRow { + override val key: String get() = part.id + } + + @Immutable + data class Tool(val tool: ToolCallUiModel) : ChatRow { + override val key: String get() = tool.id + } + + @Immutable + data class OrphanTools(val tools: List) : ChatRow { + override val key: String get() = "orphan-tool-calls" + } +} + +/** + * Flattens the transcript into newest-first order, ready for [LazyColumn]'s `reverseLayout`. + * + * This is rebuilt when streaming changes the messages list. That is still kept in the UI layer: + * these rows are list presentation state, and moving the same O(n) walk into the ViewModel would + * couple business state to composable row shapes without reducing the work. Stable keys and value + * equality keep unchanged rows from recomposing; if this ever profiles as allocation pressure, the + * right fix is an incremental row cache keyed by message/part ids here. + */ +internal fun buildChatRows( + messages: List, + toolCalls: List, +): List { + val rows = mutableListOf() + val renderedToolIds = if (toolCalls.isEmpty()) null else mutableSetOf() + + messages.forEach { message -> + when { + message.isUser -> rows += ChatRow.UserMessage(message.id, message.text) + // Assistant turn: walk parts in chronological order, exactly like desktop. + message.parts.isNotEmpty() -> message.parts.forEach { part -> + rows += when (part) { + is ChatPartUiModel.Text -> ChatRow.Markdown(part.id, part.text) + is ChatPartUiModel.Reasoning -> ChatRow.Reasoning(part) + is ChatPartUiModel.Tool -> { + renderedToolIds?.add(part.tool.id) + ChatRow.Tool(part.tool) + } + } + } + message.text.isNotBlank() -> rows += ChatRow.Markdown(message.id, message.text) + } + } + + // Fallback for tools not associated with an existing message part. They have + // no chronological anchor, so they sit at the oldest end of the transcript. + // Appending them would instead make them permanently the newest row, and + // because their key is constant the newest key would never change again: an + // incoming message would not trigger the pin, and sending would scroll to + // the tool pile rather than the sent message. + renderedToolIds?.let { ids -> + val orphanTools = toolCalls.filterNot { it.id in ids } + if (orphanTools.isNotEmpty()) rows.add(0, ChatRow.OrphanTools(orphanTools)) + } + + rows.reverse() + return rows +} @Composable fun ChatFullScreen( @@ -59,10 +143,53 @@ fun ChatFullScreen( onComposerSubmit: () -> Unit, onToolCallClick: (String) -> Unit, modifier: Modifier = Modifier, + onReconnect: () -> Unit = {}, + onStop: () -> Unit = {}, ) { val colors = MaterialTheme.roxyColors BackHandler(onBack = onBackClick) + val rows = remember(uiState.messages, uiState.toolCalls) { + buildChatRows(uiState.messages, uiState.toolCalls) + } + val isSessionEmpty = uiState.messages.isEmpty() && uiState.toolCalls.isEmpty() + + val listState = rememberSaveable(saver = LazyListState.Saver) { LazyListState() } + + // The list is reversed, so the anchor item is the newest row. A small + // threshold still counts as "at the bottom": a one-pixel offset after a fling + // or nested scroll should not disable the chat's follow-tail behaviour. + val isNearNewestRow by remember(listState) { + derivedStateOf { + val viewportHeight = listState.layoutInfo.viewportEndOffset - listState.layoutInfo.viewportStartOffset + val followThreshold = (viewportHeight / 4).coerceAtLeast(1) + listState.firstVisibleItemIndex == 0 && listState.firstVisibleItemScrollOffset < followThreshold + } + } + var hasUnseenNewestRow by rememberSaveable { mutableStateOf(false) } + + // Growing the newest row needs no scrolling at all -- it is the anchor, so + // streamed markdown and tool output expand upwards while the bottom edge + // stays put. Only an insertion has to be handled: rows carry stable keys, so + // the anchor would otherwise follow the previously newest row and leave the + // incoming one laid out below the viewport. + val newestRowKey = rows.firstOrNull { it !is ChatRow.OrphanTools }?.key + LaunchedEffect(newestRowKey) { + // Effects run before this frame's measure pass, so isNearNewestRow still + // describes the layout as it was before the row arrived: a user who had + // scrolled up into history is left alone and gets an explicit affordance + // instead of a forced jump. + if (isNearNewestRow) { + listState.requestScrollToItem(0) + hasUnseenNewestRow = false + } else if (newestRowKey != null) { + hasUnseenNewestRow = true + } + } + LaunchedEffect(isNearNewestRow) { + if (isNearNewestRow) hasUnseenNewestRow = false + } + Column( modifier = modifier .fillMaxSize() @@ -74,193 +201,125 @@ fun ChatFullScreen( sessionTitle = uiState.sessionTitle, projectName = uiState.projectName, isRunning = uiState.isRunning, + isStopping = uiState.isStopping, isSyncing = uiState.isSyncing, onBackClick = onBackClick, ) HorizontalDivider(color = colors.border) + ChatStatusBanner(uiState, onReconnect) - val listState = rememberLazyListState() - - // Opening a session must always land at the bottom. Keyed on the session - // rather than on the message list, because a snapshot that arrives after - // the cached content is structurally equal and would not retrigger it. - LaunchedEffect(uiState.sessionTitle, uiState.projectName) { - // Content streams in over several layout passes (markdown and code - // blocks resize once measured), so keep re-pinning until the total - // extent stops moving instead of scrolling on the first pass only. - snapshotFlow { listState.layoutInfo.totalItemsCount } - .filter { it > 0 } - .first() - - var previous: Pair? = null - repeat(MAX_SETTLE_FRAMES) { - val info = listState.layoutInfo - listState.scrollToItem(info.totalItemsCount - 1, scrollOffset = Int.MAX_VALUE) - - val last = listState.layoutInfo.visibleItemsInfo.lastOrNull() - val current = listState.layoutInfo.totalItemsCount to - ((last?.offset ?: 0) + (last?.size ?: 0)) - if (current == previous) return@LaunchedEffect - previous = current - withFrameNanos { } - } - } - - // While streaming, follow the tail only if the user has not scrolled away. - LaunchedEffect(uiState.messages, uiState.toolCalls) { - // Read before suspending: layoutInfo still describes the pre-update - // layout, so this answers "was the user pinned to the bottom before - // this change?". Reading it after the new content is laid out would - // always report false and disable autoscroll entirely. - if (listState.canScrollForward) return@LaunchedEffect - - val itemCount = snapshotFlow { listState.layoutInfo.totalItemsCount } - .filter { it > 0 } - .first() - // Large offset lands at the bottom of the last item even when it is - // taller than the viewport. - listState.scrollToItem(itemCount - 1, scrollOffset = Int.MAX_VALUE) - } - - LazyColumn( - state = listState, - modifier = Modifier - .weight(1f) - .fillMaxWidth(), - contentPadding = PaddingValues(start = 20.dp, top = 28.dp, end = 20.dp, bottom = 24.dp), - verticalArrangement = Arrangement.spacedBy(16.dp), - horizontalAlignment = Alignment.CenterHorizontally, - ) { - if (uiState.isSyncing && uiState.messages.isEmpty() && uiState.toolCalls.isEmpty()) { - item(key = "syncing-indicator") { - Box( - modifier = Modifier - .fillMaxWidth() - .padding(top = 48.dp), - contentAlignment = Alignment.Center, - ) { - Column( - horizontalAlignment = Alignment.CenterHorizontally, - verticalArrangement = Arrangement.spacedBy(12.dp), - ) { - CircularProgressIndicator( - modifier = Modifier.size(28.dp), - color = colors.accent, - strokeWidth = 2.5.dp, - ) - Text( - text = "Syncing with desktop...", - style = MaterialTheme.typography.bodyMedium, - color = colors.textMuted, - ) - } - } - } - } else if (uiState.messages.isEmpty() && uiState.toolCalls.isEmpty()) { - item(key = "empty-session") { - Box( - modifier = Modifier - .fillMaxWidth() - .padding(top = 48.dp), - contentAlignment = Alignment.Center, - ) { - Text( - text = "No messages yet. Send a prompt to get started.", - style = MaterialTheme.typography.bodyMedium, - color = colors.textMuted, + if (isSessionEmpty) { + Box( + modifier = Modifier + .weight(1f) + .fillMaxWidth(), + contentAlignment = Alignment.Center, + ) { + Column( + horizontalAlignment = Alignment.CenterHorizontally, + verticalArrangement = Arrangement.spacedBy(12.dp), + ) { + if (uiState.isSyncing) { + CircularProgressIndicator( + modifier = Modifier.size(28.dp), + color = colors.accent, + strokeWidth = 2.5.dp, ) } + Text( + text = if (uiState.isSyncing) { + "Syncing with desktop..." + } else { + "No messages yet. Send a prompt to get started." + }, + style = MaterialTheme.typography.bodyMedium, + color = colors.textMuted, + ) } - } else { - uiState.messages.forEach { message -> - if (message.isUser) { - item(key = message.id) { - Box( - modifier = Modifier - .widthIn(max = 720.dp) - .fillMaxWidth(), - contentAlignment = Alignment.CenterEnd, + } + } else { + Box( + modifier = Modifier + .weight(1f) + .fillMaxWidth(), + ) { + LazyColumn( + state = listState, + modifier = Modifier.fillMaxSize(), + // Paints row 0 against the bottom edge and anchors scrolling + // there, which is what keeps the newest content on screen. + reverseLayout = true, + contentPadding = PaddingValues(start = 20.dp, top = 28.dp, end = 20.dp, bottom = 24.dp), + // Alignment.Bottom parks a transcript shorter than the viewport + // on the composer rather than under the header. + verticalArrangement = Arrangement.spacedBy(16.dp, Alignment.Bottom), + horizontalAlignment = Alignment.CenterHorizontally, + ) { + items(rows, key = { it.key }, contentType = { it::class }) { row -> + val rowModifier = Modifier + .widthIn(max = 720.dp) + .fillMaxWidth() + when (row) { + is ChatRow.UserMessage -> Box( + modifier = rowModifier, + contentAlignment = Alignment.CenterEnd, + ) { + Surface( + shape = MaterialTheme.shapes.large, + color = colors.surface2, + border = BorderStroke(1.dp, colors.edge), ) { - Surface( - shape = MaterialTheme.shapes.large, - color = colors.surface2, - border = BorderStroke(1.dp, colors.edge), - ) { - Text( - text = message.text, - modifier = Modifier.padding(horizontal = 14.dp, vertical = 10.dp), - style = MaterialTheme.typography.bodyLarge, - color = colors.text, - ) - } - } - } - } else { - // Assistant message: walk parts in chronological order, exactly like desktop - if (message.parts.isNotEmpty()) { - message.parts.forEach { part -> - when (part) { - is ChatPartUiModel.Text -> { - item(key = part.id) { - MarkdownText( - markdown = part.text, - modifier = Modifier - .widthIn(max = 720.dp) - .fillMaxWidth(), - ) - } - } - is ChatPartUiModel.Reasoning -> { - item(key = part.id) { - ReasoningCard( - reasoning = part, - modifier = Modifier - .widthIn(max = 720.dp) - .fillMaxWidth(), - ) - } - } - is ChatPartUiModel.Tool -> { - item(key = part.id) { - ToolCallCard( - toolCall = part.tool, - onClick = { onToolCallClick(part.tool.id) }, - modifier = Modifier - .widthIn(max = 720.dp) - .fillMaxWidth(), - ) - } - } - } - } - } else if (message.text.isNotBlank()) { - item(key = message.id) { - MarkdownText( - markdown = message.text, - modifier = Modifier - .widthIn(max = 720.dp) - .fillMaxWidth(), + Text( + text = row.text, + modifier = Modifier.padding(horizontal = 14.dp, vertical = 10.dp), + style = MaterialTheme.typography.bodyLarge, + color = colors.text, ) } } - } - } - // Fallback for tools not associated with an existing message part - val inPartsToolIds = uiState.messages.flatMap { it.parts }.filterIsInstance().map { it.tool.id }.toSet() - val orphanTools = uiState.toolCalls.filterNot { it.id in inPartsToolIds } - if (orphanTools.isNotEmpty()) { - item(key = "orphan-tool-calls") { - ToolCallStack( - toolCalls = orphanTools, + is ChatRow.Markdown -> MarkdownText( + markdown = row.text, + modifier = rowModifier, + ) + + is ChatRow.Reasoning -> ReasoningCard( + reasoning = row.part, + modifier = rowModifier, + ) + + is ChatRow.Tool -> ToolCallCard( + toolCall = row.tool, + onClick = { onToolCallClick(row.tool.id) }, + modifier = rowModifier, + ) + + is ChatRow.OrphanTools -> ToolCallStack( + toolCalls = row.tools, onToolCallClick = onToolCallClick, - modifier = Modifier - .widthIn(max = 720.dp) - .fillMaxWidth(), + modifier = rowModifier, ) } } } + if (hasUnseenNewestRow && !isNearNewestRow) { + Button( + onClick = { + listState.requestScrollToItem(0) + hasUnseenNewestRow = false + }, + modifier = Modifier + .align(Alignment.BottomCenter) + .padding(bottom = 16.dp), + colors = ButtonDefaults.buttonColors( + containerColor = colors.accent, + contentColor = colors.bg, + ), + ) { + Text("Latest") + } + } + } } Box( @@ -272,8 +331,21 @@ fun ChatFullScreen( ) { ChatComposer( text = uiState.composerText, + canSubmit = uiState.canSubmit, + showStop = uiState.isRunning && uiState.isMobileTurn, + canStop = uiState.canStop, + isStopping = uiState.isStopping, + onStop = onStop, onTextChange = onComposerChange, - onSubmit = onComposerSubmit, + onSubmit = { + onComposerSubmit() + // User-sent messages are explicit navigation to the live + // edge, even if the user was reading history. This is the + // only forced jump; incoming messages use the follow-tail + // policy above. + listState.requestScrollToItem(0) + hasUnseenNewestRow = false + }, modifier = Modifier.widthIn(max = 720.dp), ) } @@ -285,6 +357,7 @@ fun ChatHeader( sessionTitle: String, projectName: String, isRunning: Boolean = false, + isStopping: Boolean = false, isSyncing: Boolean = false, onBackClick: () -> Unit, modifier: Modifier = Modifier, @@ -340,7 +413,7 @@ fun ChatHeader( ) Spacer(Modifier.width(8.dp)) Text( - text = "Thinking...", + text = if (isStopping) "Stopping..." else "Thinking...", style = MaterialTheme.typography.labelSmall, color = colors.accent, ) @@ -367,9 +440,6 @@ fun ChatHeader( } } -/** Upper bound on layout passes waited for when pinning a freshly opened chat. */ -private const val MAX_SETTLE_FRAMES = 10 - private val ChatPreviewState = ChatFullScreenUiState( sessionTitle = "Remote Session", projectName = "roxy-android", diff --git a/app/src/main/java/gg/roxy/chatFullscreen/components/ChatStatusBanner.kt b/app/src/main/java/gg/roxy/chatFullscreen/components/ChatStatusBanner.kt new file mode 100644 index 0000000..b9acc91 --- /dev/null +++ b/app/src/main/java/gg/roxy/chatFullscreen/components/ChatStatusBanner.kt @@ -0,0 +1,44 @@ +package gg.roxy.chatFullscreen.components + +import androidx.compose.foundation.layout.Column +import androidx.compose.foundation.layout.fillMaxWidth +import androidx.compose.foundation.layout.padding +import androidx.compose.material3.MaterialTheme +import androidx.compose.material3.Surface +import androidx.compose.material3.Text +import androidx.compose.material3.TextButton +import androidx.compose.runtime.Composable +import androidx.compose.ui.Modifier +import androidx.compose.ui.semantics.LiveRegionMode +import androidx.compose.ui.semantics.liveRegion +import androidx.compose.ui.semantics.semantics +import androidx.compose.ui.unit.dp +import gg.roxy.chatFullscreen.businessLogic.ChatFullScreenUiState +import gg.roxy.shared.styles.roxyColors + +@Composable +fun ChatStatusBanner(state: ChatFullScreenUiState, onReconnect: () -> Unit) { + val message = when { + state.isConnecting -> "Reconnecting to your PC..." + state.errorMessage != null -> state.errorMessage + !state.isConnected -> "Your PC is disconnected. Your draft is saved." + state.queuedPromptCount > 0 -> "Your PC has ${state.queuedPromptCount} queued message(s). Wait for them to finish." + state.isAwaitingResponse -> "Waiting for your PC to confirm the message..." + !state.isSessionReady -> "Refreshing this session..." + else -> return + } + val colors = MaterialTheme.roxyColors + Surface(color = colors.surface2, modifier = Modifier.fillMaxWidth()) { + Column(Modifier.padding(horizontal = 16.dp, vertical = 8.dp)) { + Text( + text = message, + style = MaterialTheme.typography.bodySmall, + color = colors.text, + modifier = Modifier.semantics { liveRegion = LiveRegionMode.Polite }, + ) + if (!state.isConnecting && (!state.isConnected || state.errorMessage != null)) { + TextButton(onClick = onReconnect) { Text(if (state.isConnected) "Refresh session" else "Reconnect") } + } + } + } +} diff --git a/app/src/main/java/gg/roxy/mainFullscreen/components/ConnectComputerDialog.kt b/app/src/main/java/gg/roxy/mainFullscreen/components/ConnectComputerDialog.kt index db8321a..b2e149c 100644 --- a/app/src/main/java/gg/roxy/mainFullscreen/components/ConnectComputerDialog.kt +++ b/app/src/main/java/gg/roxy/mainFullscreen/components/ConnectComputerDialog.kt @@ -4,6 +4,7 @@ import androidx.compose.foundation.BorderStroke import androidx.compose.foundation.layout.Arrangement import androidx.compose.foundation.layout.Box 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.fillMaxWidth @@ -17,7 +18,6 @@ import androidx.compose.foundation.text.KeyboardOptions import androidx.compose.material.icons.Icons import androidx.compose.material.icons.rounded.CheckCircle import androidx.compose.material.icons.rounded.Computer -import androidx.compose.material.icons.rounded.Key import androidx.compose.material.icons.rounded.Link import androidx.compose.material.icons.rounded.QrCodeScanner import androidx.compose.material3.Button @@ -32,19 +32,23 @@ import androidx.compose.material3.OutlinedTextFieldDefaults import androidx.compose.material3.Surface import androidx.compose.material3.Text import androidx.compose.runtime.Composable +import androidx.compose.runtime.LaunchedEffect import androidx.compose.runtime.getValue import androidx.compose.runtime.mutableStateOf import androidx.compose.runtime.remember import androidx.compose.runtime.setValue import androidx.compose.ui.Alignment import androidx.compose.ui.Modifier +import androidx.compose.ui.focus.FocusRequester +import androidx.compose.ui.focus.focusRequester import androidx.compose.ui.platform.LocalSoftwareKeyboardController import androidx.compose.ui.text.font.FontWeight import androidx.compose.ui.text.input.ImeAction -import androidx.compose.ui.text.input.KeyboardType import androidx.compose.ui.unit.dp import androidx.compose.ui.unit.sp import androidx.compose.ui.window.Dialog +import gg.roxy.shared.PAIRING_PIN_LENGTH +import gg.roxy.shared.components.PinInput import gg.roxy.shared.styles.RoxyMonoFontFamily import gg.roxy.shared.styles.roxyColors @@ -64,8 +68,22 @@ fun ConnectComputerDialog( var tokenInput by remember(initialTokenOrUrl) { mutableStateOf(initialTokenOrUrl) } var pinInput by remember(initialPin) { mutableStateOf(initialPin) } val keyboardController = LocalSoftwareKeyboardController.current + val pinFocusRequester = remember { FocusRequester() } - val canConnect = tokenInput.isNotBlank() && pinInput.trim().length == 6 && !isConnecting + // connectionError is a single field shared with QR scanning, so it also + // carries failures that say nothing about the PIN. Stop marking the cells + // once the PIN is edited: the banner still shows the message, but the + // cells stop claiming the digits are at fault. + var pinEditedSinceError by remember(errorMessage) { mutableStateOf(false) } + val isPinError = errorMessage != null && !pinEditedSinceError + + val canConnect = tokenInput.isNotBlank() && pinInput.trim().length == PAIRING_PIN_LENGTH && !isConnecting + + LaunchedEffect(initialTokenOrUrl, initialPin) { + if (initialTokenOrUrl.isNotBlank() && initialPin.length < PAIRING_PIN_LENGTH) { + pinFocusRequester.requestFocus() + } + } Dialog(onDismissRequest = onDismiss) { Surface( @@ -229,13 +247,14 @@ fun ConnectComputerDialog( unfocusedTextColor = colors.text, ), keyboardOptions = KeyboardOptions(imeAction = ImeAction.Next), + keyboardActions = KeyboardActions(onNext = { pinFocusRequester.requestFocus() }), ) } // Input: PIN Column(verticalArrangement = Arrangement.spacedBy(5.dp)) { Text( - text = "6-DIGIT PIN", + text = "$PAIRING_PIN_LENGTH-DIGIT PIN", style = MaterialTheme.typography.labelSmall.copy( fontFamily = RoxyMonoFontFamily, letterSpacing = 1.1.sp, @@ -243,39 +262,17 @@ fun ConnectComputerDialog( ), color = colors.textSubtle, ) - OutlinedTextField( + PinInput( value = pinInput, - onValueChange = { if (it.length <= 6) pinInput = it }, - modifier = Modifier.fillMaxWidth(), - placeholder = { - Text( - "e.g. 123456", - style = MaterialTheme.typography.bodySmall, - color = colors.textSubtle, - ) + onValueChange = { + pinInput = it + pinEditedSinceError = true }, - leadingIcon = { - Icon( - imageVector = Icons.Rounded.Key, - contentDescription = null, - tint = colors.textMuted, - modifier = Modifier.size(18.dp), - ) - }, - singleLine = true, - shape = RoundedCornerShape(12.dp), - colors = OutlinedTextFieldDefaults.colors( - focusedBorderColor = colors.edgeStrong, - unfocusedBorderColor = colors.edge, - focusedContainerColor = colors.surface2, - unfocusedContainerColor = colors.surface2, - focusedTextColor = colors.text, - unfocusedTextColor = colors.text, - ), - keyboardOptions = KeyboardOptions( - keyboardType = KeyboardType.Number, - imeAction = ImeAction.Done, - ), + modifier = Modifier + .fillMaxWidth() + .focusRequester(pinFocusRequester), + enabled = !isConnecting, + isError = isPinError, keyboardActions = KeyboardActions( onDone = { keyboardController?.hide() @@ -329,6 +326,7 @@ fun ConnectComputerDialog( enabled = canConnect, modifier = Modifier.weight(1f), shape = RoundedCornerShape(12.dp), + contentPadding = PaddingValues(horizontal = 10.dp, vertical = 8.dp), colors = ButtonDefaults.buttonColors( containerColor = colors.accent, contentColor = colors.bg, @@ -338,14 +336,14 @@ fun ConnectComputerDialog( ) { if (isConnecting) { CircularProgressIndicator( - modifier = Modifier.size(16.dp), + modifier = Modifier.size(14.dp), strokeWidth = 2.dp, - color = colors.bg, + color = colors.textSubtle, ) Spacer(Modifier.width(8.dp)) - Text("Connecting...") + Text("Connecting", maxLines = 1) } else { - Text("Connect") + Text("Connect", maxLines = 1) } } } diff --git a/app/src/main/java/gg/roxy/shared/PairingConstants.kt b/app/src/main/java/gg/roxy/shared/PairingConstants.kt new file mode 100644 index 0000000..e28fc11 --- /dev/null +++ b/app/src/main/java/gg/roxy/shared/PairingConstants.kt @@ -0,0 +1,3 @@ +package gg.roxy.shared + +const val PAIRING_PIN_LENGTH = 6 diff --git a/app/src/main/java/gg/roxy/shared/businessLogic/RoxyAppViewModel.kt b/app/src/main/java/gg/roxy/shared/businessLogic/RoxyAppViewModel.kt index 3378144..193aeb9 100644 --- a/app/src/main/java/gg/roxy/shared/businessLogic/RoxyAppViewModel.kt +++ b/app/src/main/java/gg/roxy/shared/businessLogic/RoxyAppViewModel.kt @@ -14,6 +14,7 @@ import gg.roxy.mainFullscreen.businessLogic.ComputerUiModel import gg.roxy.mainFullscreen.businessLogic.MainFullScreenUiState import gg.roxy.mainFullscreen.businessLogic.ProjectUiModel import gg.roxy.mainFullscreen.businessLogic.SessionUiModel +import gg.roxy.shared.PAIRING_PIN_LENGTH import gg.roxy.shared.data.RemoteConnectionState import gg.roxy.shared.data.RemoteEvent import gg.roxy.shared.data.RemoteSessionInfo @@ -23,6 +24,8 @@ import gg.roxy.shared.data.RemoteWorkspaceUtils import java.util.UUID import javax.inject.Inject import kotlinx.coroutines.CoroutineScope +import kotlinx.coroutines.Job +import kotlinx.coroutines.delay import kotlinx.coroutines.flow.MutableStateFlow import kotlinx.coroutines.flow.StateFlow import kotlinx.coroutines.flow.asStateFlow @@ -46,6 +49,7 @@ class RoxyAppViewModel( private val remoteClient: RemoteWorkspaceClient, private val storage: RemoteStorage, private val externalScope: CoroutineScope? = null, + private val operationTimeoutMs: Long = 15_000, ) : ViewModel() { @Inject @@ -61,11 +65,23 @@ class RoxyAppViewModel( val uiState: StateFlow = _uiState.asStateFlow() private var activeSessionId: String? = null + private var pairingToken: String? = storage.savedToken + private val snapshotsReceived = mutableSetOf() + private val turnsReceived = mutableSetOf() + private var syncTimeoutJob: Job? = null + private val responseTimeoutJobs = mutableMapOf() + private val stopTimeoutJobs = mutableMapOf() private data class SessionChatCache( val messages: List = emptyList(), val toolCalls: List = emptyList(), val isRunning: Boolean = false, + val draft: String = "", + val pendingPrompt: ChatMessageUiModel? = null, + val queuedPromptCount: Int = 0, + val errorMessage: String? = null, + val isMobileTurn: Boolean = false, + val isStopping: Boolean = false, ) private val sessionCache = mutableMapOf() @@ -82,10 +98,21 @@ class RoxyAppViewModel( private fun observeRemote() { scope.launch { remoteClient.connectionState.collect { connectionState -> + if (connectionState !is RemoteConnectionState.Connected) { + snapshotsReceived.clear() + turnsReceived.clear() + syncTimeoutJob?.cancel() + responseTimeoutJobs.values.forEach { it.cancel() } + responseTimeoutJobs.clear() + stopTimeoutJobs.values.forEach { it.cancel() } + stopTimeoutJobs.clear() + sessionCache.replaceAll { _, cached -> cached.copy(isStopping = false) } + } _uiState.update { state -> when (connectionState) { is RemoteConnectionState.Connecting -> { state.copy( + chat = state.chat.copy(isConnected = false, isConnecting = true, isSessionReady = false, errorMessage = null), main = state.main.copy( isConnecting = true, connectionError = null, @@ -104,6 +131,7 @@ class RoxyAppViewModel( isConnected = true, ) state.copy( + chat = state.chat.copy(isConnected = true, isConnecting = false, errorMessage = null), main = state.main.copy( selectedComputer = pc, computers = listOf(pc), @@ -116,6 +144,15 @@ class RoxyAppViewModel( } is RemoteConnectionState.Error -> { state.copy( + chat = state.chat.copy( + isConnected = false, + isSessionReady = false, + isConnecting = false, + isRunning = false, + isStopping = false, + isSyncing = false, + errorMessage = connectionState.message, + ), main = state.main.copy( isConnecting = false, connectionError = connectionState.message, @@ -127,8 +164,6 @@ class RoxyAppViewModel( ) } is RemoteConnectionState.Disconnected -> { - sessionCache.clear() - activeSessionId = null val emptyPc = ComputerUiModel( id = "none", name = "No computer connected", @@ -136,24 +171,30 @@ class RoxyAppViewModel( isConnected = false, ) state.copy( - destination = RoxyDestination.Main, main = state.main.copy( isConnecting = false, selectedComputer = emptyPc, computers = emptyList(), - projects = emptyList(), ), chat = state.chat.copy( - sessionTitle = "", - projectName = "", - messages = emptyList(), - toolCalls = emptyList(), + isConnected = false, + isSessionReady = false, + isConnecting = false, + isRunning = false, + isStopping = false, isSyncing = false, + errorMessage = if (activeSessionId != null) "Connection lost. Reconnect before sending another message." else null, ), ) } } } + if (connectionState is RemoteConnectionState.Connected) { + activeSessionId?.let { sessionId -> + beginSessionSync(sessionId) + remoteClient.switchSession(sessionId) + } + } } } @@ -173,6 +214,7 @@ class RoxyAppViewModel( } } is RemoteEvent.SnapshotReceived -> { + snapshotsReceived.add(event.sessionId) val current = sessionCache[event.sessionId] ?: SessionChatCache() val allTools = if (event.tools.isNotEmpty()) { event.tools @@ -182,6 +224,7 @@ class RoxyAppViewModel( sessionCache[event.sessionId] = current.copy( messages = event.messages, toolCalls = allTools, + pendingPrompt = null, ) if (activeSessionId == null || activeSessionId == event.sessionId) { @@ -194,45 +237,24 @@ class RoxyAppViewModel( messages = event.messages, toolCalls = allTools, isSyncing = false, + isSessionReady = isSessionReady(event.sessionId) && state.chat.errorMessage == null, + isAwaitingResponse = false, + isMobileTurn = current.isMobileTurn, ) ) } } } - is RemoteEvent.TextDelta -> { - val current = sessionCache[event.sessionId] ?: SessionChatCache() - val cachedMessages = current.messages.toMutableList() - if (cachedMessages.isEmpty() || cachedMessages.last().isUser) { - cachedMessages.add( - ChatMessageUiModel( - id = UUID.randomUUID().toString(), - text = event.chunk, - isUser = false, - parts = listOf(ChatPartUiModel.Text(id = UUID.randomUUID().toString(), text = event.chunk)), - ) - ) - } else { - val last = cachedMessages.last() - val parts = last.parts.toMutableList() - val lastPart = parts.lastOrNull() - if (lastPart is ChatPartUiModel.Text) { - parts[parts.lastIndex] = lastPart.copy(text = lastPart.text + event.chunk) - } else { - parts.add(ChatPartUiModel.Text(id = UUID.randomUUID().toString(), text = event.chunk)) - } - cachedMessages[cachedMessages.lastIndex] = last.copy( - text = last.text + event.chunk, - parts = parts, - ) - } - sessionCache[event.sessionId] = current.copy(messages = cachedMessages) - - if (activeSessionId == null || activeSessionId == event.sessionId) { - _uiState.update { state -> - state.copy(chat = state.chat.copy(messages = cachedMessages)) - } - } - } + is RemoteEvent.TextDelta -> appendStreamingText( + sessionId = event.sessionId, + chunk = event.chunk, + kind = StreamingTextKind.Text, + ) + is RemoteEvent.ReasoningDelta -> appendStreamingText( + sessionId = event.sessionId, + chunk = event.chunk, + kind = StreamingTextKind.Reasoning, + ) is RemoteEvent.ToolStarted -> { val type = if (event.tool.lowercase() in listOf("read", "write", "edit", "glob", "grep", "file", "read_file", "write_file", "list", "list_dir")) { ToolCallType.File @@ -362,8 +384,19 @@ class RoxyAppViewModel( } } is RemoteEvent.TurnChanged -> { + turnsReceived.add(event.sessionId) val current = sessionCache[event.sessionId] ?: SessionChatCache() val currentMessages = current.messages.toMutableList() + val confirmsPendingPrompt = event.isRunning && current.pendingPrompt != null && + (event.userText == current.pendingPrompt.text || + (event.userText == null && current.queuedPromptCount == 0)) + if (event.isRunning && current.pendingPrompt != null && current.queuedPromptCount == 0 && event.userText == null) { + currentMessages.add(current.pendingPrompt) + } + if (confirmsPendingPrompt) { + responseTimeoutJobs.remove(event.sessionId)?.cancel() + } + if (!event.isRunning) stopTimeoutJobs.remove(event.sessionId)?.cancel() if (event.userText != null && (currentMessages.isEmpty() || currentMessages.last().text != event.userText)) { currentMessages.add( ChatMessageUiModel( @@ -374,24 +407,39 @@ class RoxyAppViewModel( ) } - if (event.inFlightTools.isNotEmpty() || event.inFlightText != null) { - val inFlightParts = mutableListOf() - event.inFlightTools.forEach { tool -> - inFlightParts.add(ChatPartUiModel.Tool(tool)) + if (event.inFlightParts.isNotEmpty() || event.inFlightTools.isNotEmpty() || event.inFlightText != null) { + // Row keys have to be derived from the message that owns the + // turn. A fresh id per event would rebuild streaming rows on + // every chunk, discarding layout state and making the list + // treat the update as a removal plus an insertion. + val isNewTurn = currentMessages.isEmpty() || currentMessages.last().isUser + val turnId = if (isNewTurn) { + UUID.randomUUID().toString() + } else { + currentMessages.last().id } - if (event.inFlightText != null) { - inFlightParts.add( - ChatPartUiModel.Text( - id = UUID.randomUUID().toString(), - text = event.inFlightText, - ) - ) + + val inFlightParts = if (event.inFlightParts.isNotEmpty()) { + event.inFlightParts.map { part -> + when (part) { + is ChatPartUiModel.Text -> part.copy(id = "$turnId-text-${sourcePartIndex(part.id)}") + is ChatPartUiModel.Reasoning -> part.copy(id = "$turnId-reasoning-${sourcePartIndex(part.id)}") + is ChatPartUiModel.Tool -> part + } + } + } else { + buildList { + event.inFlightTools.forEach { tool -> add(ChatPartUiModel.Tool(tool)) } + event.inFlightText?.let { text -> + add(ChatPartUiModel.Text(id = "$turnId-text-0", text = text)) + } + } } - if (currentMessages.isEmpty() || currentMessages.last().isUser) { + if (isNewTurn) { currentMessages.add( ChatMessageUiModel( - id = UUID.randomUUID().toString(), + id = turnId, isUser = false, parts = inFlightParts, ) @@ -413,6 +461,9 @@ class RoxyAppViewModel( isRunning = event.isRunning, messages = currentMessages, toolCalls = currentTools, + pendingPrompt = if (confirmsPendingPrompt) null else current.pendingPrompt, + isMobileTurn = event.isRunning && (confirmsPendingPrompt || (current.isMobileTurn && event.userText == null)), + isStopping = event.isRunning && current.isStopping, ) if (activeSessionId == null || activeSessionId == event.sessionId) { @@ -422,18 +473,129 @@ class RoxyAppViewModel( isRunning = event.isRunning, messages = currentMessages, toolCalls = currentTools, + isSessionReady = isSessionReady(event.sessionId) && state.chat.errorMessage == null, + isAwaitingResponse = sessionCache[event.sessionId]?.pendingPrompt != null, + isMobileTurn = sessionCache[event.sessionId]?.isMobileTurn ?: false, + isStopping = sessionCache[event.sessionId]?.isStopping ?: false, ) ) } + // The host persists the final reply (including provider errors) + // before sending idle. Fetch it once to reconcile the stream. + if (!event.isRunning && current.isRunning && current.isMobileTurn && + current.errorMessage == null && _uiState.value.chat.isConnected && activeSessionId == event.sessionId) { + beginSessionSync(event.sessionId) + remoteClient.switchSession(event.sessionId) + } } } is RemoteEvent.ErrorReceived -> { + val sessionId = activeSessionId + sessionId?.let { + val current = sessionCache[it] ?: SessionChatCache() + sessionCache[it] = current.copy(errorMessage = event.message, pendingPrompt = null) + responseTimeoutJobs.remove(it)?.cancel() + } _uiState.update { state -> state.copy( - main = state.main.copy(connectionError = event.message) + main = state.main.copy(connectionError = event.message), + chat = state.chat.copy(errorMessage = event.message, isSessionReady = false, isSyncing = false, isAwaitingResponse = false), ) } } + is RemoteEvent.QueueChanged -> { + val current = sessionCache[event.sessionId] ?: SessionChatCache() + sessionCache[event.sessionId] = current.copy(queuedPromptCount = event.count) + if (event.count > 0) responseTimeoutJobs.remove(event.sessionId)?.cancel() + if (activeSessionId == event.sessionId) { + _uiState.update { it.copy(chat = it.chat.copy(queuedPromptCount = event.count)) } + } + } + } + } + + private enum class StreamingTextKind(val idSegment: String) { + Text("text"), + Reasoning("reasoning"), + } + + private fun appendStreamingText(sessionId: String, chunk: String, kind: StreamingTextKind) { + val current = sessionCache[sessionId] ?: SessionChatCache() + val messages = current.messages.toMutableList() + + if (messages.isEmpty() || messages.last().isUser) { + val messageId = UUID.randomUUID().toString() + messages.add( + ChatMessageUiModel( + id = messageId, + text = chunk, + isUser = false, + parts = listOf(createStreamingPart(messageId, 0, chunk, kind)), + ) + ) + } else { + val lastMessage = messages.last() + val parts = lastMessage.parts.toMutableList() + val lastPart = parts.lastOrNull() + val extendsLastPart = when (kind) { + StreamingTextKind.Text -> lastPart is ChatPartUiModel.Text + StreamingTextKind.Reasoning -> lastPart is ChatPartUiModel.Reasoning + } + + if (extendsLastPart) { + parts[parts.lastIndex] = when (lastPart) { + is ChatPartUiModel.Text -> lastPart.copy(text = lastPart.text + chunk) + is ChatPartUiModel.Reasoning -> lastPart.copy(text = lastPart.text + chunk) + else -> error("Streaming text can only extend text parts") + } + } else { + parts.add(createStreamingPart(lastMessage.id, parts.size, chunk, kind)) + } + + val separator = if (lastMessage.text.isNotEmpty() && !extendsLastPart) "\n\n" else "" + messages[messages.lastIndex] = lastMessage.copy( + text = lastMessage.text + separator + chunk, + parts = parts, + ) + } + + sessionCache[sessionId] = current.copy(messages = messages) + if (activeSessionId == null || activeSessionId == sessionId) { + _uiState.update { state -> state.copy(chat = state.chat.copy(messages = messages)) } + } + } + + private fun createStreamingPart( + messageId: String, + index: Int, + text: String, + kind: StreamingTextKind, + ): ChatPartUiModel = when (kind) { + StreamingTextKind.Text -> ChatPartUiModel.Text( + id = "$messageId-${kind.idSegment}-$index", + text = text, + ) + StreamingTextKind.Reasoning -> ChatPartUiModel.Reasoning( + id = "$messageId-${kind.idSegment}-$index", + text = text, + ) + } + + private fun isSessionReady(sessionId: String): Boolean = + sessionId in snapshotsReceived && sessionId in turnsReceived + + private fun beginSessionSync(sessionId: String) { + snapshotsReceived.remove(sessionId) + turnsReceived.remove(sessionId) + val cached = sessionCache[sessionId] ?: SessionChatCache() + sessionCache[sessionId] = cached.copy(errorMessage = null) + _uiState.update { it.copy(chat = it.chat.copy(isSyncing = true, isSessionReady = false, errorMessage = null)) } + syncTimeoutJob?.cancel() + syncTimeoutJob = scope.launch { + delay(operationTimeoutMs) + if (activeSessionId == sessionId && !isSessionReady(sessionId)) { + _uiState.update { it.copy(chat = it.chat.copy(isSyncing = false, errorMessage = "Could not refresh this session. Try refreshing again.")) } + } } } @@ -508,7 +670,7 @@ class RoxyAppViewModel( return } - if (parsed.pin?.length == 6) { + if (parsed.pin?.length == PAIRING_PIN_LENGTH) { _uiState.update { state -> state.copy( main = state.main.copy( @@ -528,7 +690,7 @@ class RoxyAppViewModel( isConnectingDialogVisible = true, prefilledToken = parsed.token, prefilledPin = "", - qrFeedbackMessage = "QR code scanned! Enter the 6-digit PIN shown on your PC.", + qrFeedbackMessage = "QR code scanned! Enter the $PAIRING_PIN_LENGTH-digit PIN shown on your PC.", connectionError = null, ) ) @@ -547,12 +709,48 @@ class RoxyAppViewModel( } fun connectRemote(tokenOrUrl: String, pin: String) { + val token = RemoteWorkspaceUtils.extractGuestToken(tokenOrUrl) + if (token.isBlank() || pin.trim().length != PAIRING_PIN_LENGTH) { + remoteClient.connect(tokenOrUrl, pin) + return + } + if (token != pairingToken) { + sessionCache.clear() + activeSessionId = null + snapshotsReceived.clear() + turnsReceived.clear() + _uiState.update { it.copy( + destination = RoxyDestination.Main, + main = it.main.copy(projects = emptyList()), + chat = initialUiState().chat, + ) } + } + pairingToken = token remoteClient.connect(tokenOrUrl, pin) } + fun reconnectRemote() { + if (_uiState.value.chat.isConnecting) return + if (remoteClient.connectionState.value is RemoteConnectionState.Connected && activeSessionId != null) { + val sessionId = activeSessionId!! + beginSessionSync(sessionId) + remoteClient.switchSession(sessionId) + return + } + val token = storage.savedToken + val pin = storage.savedPin + if (token.isNullOrBlank() || pin.isNullOrBlank()) { + showMainScreen() + showConnectDialog() + return + } + connectRemote(token, pin) + } + fun disconnectRemote() { sessionCache.clear() activeSessionId = null + pairingToken = null storage.clear() remoteClient.disconnect() _uiState.update { state -> @@ -571,13 +769,7 @@ class RoxyAppViewModel( isConnecting = false, connectionError = null, ), - chat = state.chat.copy( - sessionTitle = "", - projectName = "", - messages = emptyList(), - toolCalls = emptyList(), - isSyncing = false, - ), + chat = initialUiState().chat, ) } } @@ -602,8 +794,9 @@ class RoxyAppViewModel( } fun openSession(sessionId: String) { + if (_uiState.value.main.projects.none { project -> project.sessions.any { it.id == sessionId } }) return activeSessionId = sessionId - remoteClient.switchSession(sessionId) + beginSessionSync(sessionId) val cached = sessionCache[sessionId] val hasCache = cached != null && (cached.messages.isNotEmpty() || cached.toolCalls.isNotEmpty()) @@ -629,14 +822,21 @@ class RoxyAppViewModel( chat = state.chat.copy( sessionTitle = session.title, projectName = project.name, - composerText = "", + composerText = cached?.draft ?: "", + errorMessage = cached?.errorMessage, messages = cached?.messages ?: emptyList(), toolCalls = cached?.toolCalls ?: emptyList(), isRunning = cached?.isRunning ?: false, isSyncing = !hasCache, + isSessionReady = false, + queuedPromptCount = cached?.queuedPromptCount ?: 0, + isAwaitingResponse = cached?.pendingPrompt != null, + isMobileTurn = cached?.isMobileTurn ?: false, + isStopping = cached?.isStopping ?: false, ), ) } + remoteClient.switchSession(sessionId) } fun showMainScreen() { @@ -644,14 +844,27 @@ class RoxyAppViewModel( } fun updateComposer(text: String) { + activeSessionId?.let { sessionId -> + val cached = sessionCache[sessionId] ?: SessionChatCache() + sessionCache[sessionId] = cached.copy(draft = text) + } _uiState.update { state -> state.copy(chat = state.chat.copy(composerText = text)) } } fun submitComposer() { - val currentText = _uiState.value.chat.composerText.trim() - if (currentText.isBlank()) return + val chat = _uiState.value.chat + val activeId = activeSessionId ?: return + if (!chat.canSubmit || remoteClient.connectionState.value !is RemoteConnectionState.Connected) return + val currentText = chat.composerText.trim() + if (!remoteClient.sendPrompt(currentText)) { + _uiState.update { it.copy(chat = it.chat.copy( + isSessionReady = false, + errorMessage = "Message was not sent. Your draft is saved. Refresh or reconnect before trying again.", + )) } + return + } val userMessage = ChatMessageUiModel( id = UUID.randomUUID().toString(), @@ -659,27 +872,56 @@ class RoxyAppViewModel( isUser = true, ) - val activeId = activeSessionId - _uiState.update { state -> state.copy( chat = state.chat.copy( composerText = "", - messages = state.chat.messages + userMessage, - isRunning = true, + isAwaitingResponse = true, + errorMessage = null, ) ) } - if (activeId != null) { - val cached = sessionCache[activeId] ?: SessionChatCache() - sessionCache[activeId] = cached.copy( - messages = cached.messages + userMessage, - isRunning = true, - ) + val cached = sessionCache[activeId] ?: SessionChatCache() + sessionCache[activeId] = cached.copy( + pendingPrompt = userMessage, + draft = "", + ) + responseTimeoutJobs[activeId] = scope.launch { + delay(operationTimeoutMs) + if (sessionCache[activeId]?.pendingPrompt?.id == userMessage.id) { + val message = "Your PC has not confirmed this message. Refresh the session before sending it again." + sessionCache[activeId] = sessionCache.getValue(activeId).copy(errorMessage = message) + if (activeSessionId == activeId) { + _uiState.update { it.copy(chat = it.chat.copy(errorMessage = message, isSessionReady = false)) } + } + } } + } - remoteClient.sendPrompt(currentText) + fun stopTurn() { + val sessionId = activeSessionId ?: return + if (!_uiState.value.chat.canStop || remoteClient.connectionState.value !is RemoteConnectionState.Connected) return + if (!remoteClient.abort()) { + _uiState.update { it.copy(chat = it.chat.copy( + errorMessage = "Could not send Stop. Refresh the session to check its status.", + isSessionReady = false, + )) } + return + } + val cached = sessionCache[sessionId] ?: return + sessionCache[sessionId] = cached.copy(isStopping = true) + _uiState.update { it.copy(chat = it.chat.copy(isStopping = true)) } + stopTimeoutJobs[sessionId] = scope.launch { + delay(operationTimeoutMs) + if (sessionCache[sessionId]?.isStopping == true) { + val message = "Your PC has not confirmed Stop. Refresh the session to check its status." + sessionCache[sessionId] = sessionCache.getValue(sessionId).copy(isStopping = false, errorMessage = message) + if (activeSessionId == sessionId) { + _uiState.update { it.copy(chat = it.chat.copy(isStopping = false, isSessionReady = false, errorMessage = message)) } + } + } + } } fun toggleToolCall(toolCallId: String) { @@ -739,6 +981,8 @@ class RoxyAppViewModel( fun getInitialPin(): String = storage.savedPin ?: "" } +private fun sourcePartIndex(partId: String): String = partId.substringAfterLast('-', "0") + private fun initialUiState(): RoxyAppUiState { val computer = ComputerUiModel( id = "none", diff --git a/app/src/main/java/gg/roxy/shared/components/PinInput.kt b/app/src/main/java/gg/roxy/shared/components/PinInput.kt new file mode 100644 index 0000000..cbe6e24 --- /dev/null +++ b/app/src/main/java/gg/roxy/shared/components/PinInput.kt @@ -0,0 +1,249 @@ +package gg.roxy.shared.components + +import androidx.compose.animation.core.RepeatMode +import androidx.compose.animation.core.animateFloat +import androidx.compose.animation.core.infiniteRepeatable +import androidx.compose.animation.core.rememberInfiniteTransition +import androidx.compose.animation.core.tween +import androidx.compose.animation.animateColorAsState +import androidx.compose.foundation.background +import androidx.compose.foundation.border +import androidx.compose.foundation.layout.Arrangement +import androidx.compose.foundation.layout.Box +import androidx.compose.foundation.layout.Row +import androidx.compose.foundation.layout.fillMaxWidth +import androidx.compose.foundation.layout.height +import androidx.compose.foundation.layout.width +import androidx.compose.foundation.interaction.MutableInteractionSource +import androidx.compose.foundation.interaction.collectIsFocusedAsState +import androidx.compose.foundation.shape.RoundedCornerShape +import androidx.compose.foundation.text.BasicTextField +import androidx.compose.foundation.text.KeyboardActions +import androidx.compose.foundation.text.KeyboardOptions +import androidx.compose.runtime.Composable +import androidx.compose.runtime.getValue +import androidx.compose.runtime.mutableStateOf +import androidx.compose.runtime.remember +import androidx.compose.runtime.setValue +import androidx.compose.ui.Alignment +import androidx.compose.ui.Modifier +import androidx.compose.ui.draw.alpha +import androidx.compose.ui.draw.clip +import androidx.compose.ui.graphics.Color +import androidx.compose.ui.graphics.SolidColor +import androidx.compose.ui.semantics.password +import androidx.compose.ui.semantics.semantics +import androidx.compose.ui.text.TextRange +import androidx.compose.ui.text.font.FontWeight +import androidx.compose.ui.text.input.ImeAction +import androidx.compose.ui.text.input.KeyboardType +import androidx.compose.ui.text.input.TextFieldValue +import androidx.compose.ui.text.style.TextAlign +import androidx.compose.ui.tooling.preview.Preview +import androidx.compose.ui.unit.dp +import androidx.compose.ui.unit.sp +import androidx.compose.material3.MaterialTheme +import androidx.compose.material3.Text +import gg.roxy.shared.PAIRING_PIN_LENGTH +import gg.roxy.shared.styles.RoxyMonoFontFamily +import gg.roxy.shared.styles.RoxyTheme +import gg.roxy.shared.styles.roxyColors + +/** + * A segmented PIN entry: one digit per box, backed by a single text field. + * + * The boxes are decoration only -- there is exactly one focusable field holding + * the whole PIN. That is what makes backspace behave: a per-box implementation + * has to forward deletes between boxes by hand, and an already-empty box never + * receives the key event in the first place, so the deletion stops there. With + * one field the caret is always after the last digit, so backspace just erases + * the PIN right to left, one digit per press. + */ +@Composable +fun PinInput( + value: String, + onValueChange: (String) -> Unit, + modifier: Modifier = Modifier, + length: Int = PAIRING_PIN_LENGTH, + enabled: Boolean = true, + isError: Boolean = false, + imeAction: ImeAction = ImeAction.Done, + keyboardActions: KeyboardActions = KeyboardActions.Default, +) { + val interactionSource = remember { MutableInteractionSource() } + val isFocused by interactionSource.collectIsFocusedAsState() + + // Held locally because the field is fully controlled: its internal buffer + // only follows this value on recomposition. Rejected input (a 7th digit, or + // pasted letters) leaves `value` unchanged, so relying on the hoisted state + // alone would skip that recomposition and let the buffer keep characters + // the PIN never accepted -- which then need an extra backspace to clear. + var fieldValue by remember { + mutableStateOf(TextFieldValue(text = value, selection = TextRange(value.length))) + } + // Keeps the field in step when the PIN is changed from the outside, and + // pins the caret past the last digit so a tap on any box cannot drop it + // mid-PIN and desynchronise typing from the box the user is looking at. + if (fieldValue.text != value) { + fieldValue = TextFieldValue(text = value, selection = TextRange(value.length)) + } + + BasicTextField( + value = fieldValue, + onValueChange = { new -> + // Filtering here rather than on the keyboard type also covers paste + // and autofill, which happily deliver letters and spaces. + val digits = new.text.filter(Char::isDigit).take(length) + // Assigned unconditionally, so the field is corrected even when the + // filtered result matches the current PIN. + fieldValue = TextFieldValue(text = digits, selection = TextRange(digits.length)) + if (digits != value) onValueChange(digits) + }, + modifier = modifier.semantics { + password() + }, + enabled = enabled, + keyboardOptions = KeyboardOptions( + keyboardType = KeyboardType.NumberPassword, + imeAction = imeAction, + ), + keyboardActions = keyboardActions, + singleLine = true, + interactionSource = interactionSource, + // The real text is drawn by the boxes below; hide the field's own. + cursorBrush = SolidColor(Color.Transparent), + decorationBox = { innerTextField -> + Box { + Row( + modifier = Modifier.fillMaxWidth(), + horizontalArrangement = Arrangement.spacedBy(8.dp), + ) { + repeat(length) { index -> + PinCell( + digit = value.getOrNull(index), + // Once full there is no next box, so keep the + // highlight on the last one instead of dropping it. + isActive = isFocused && + (index == value.length || (index == length - 1 && value.length == length)), + isError = isError, + enabled = enabled, + // Splitting the available width keeps every cell the + // same size. Fixed widths cannot: once they overflow + // the dialog, Row shrinks whichever cell runs out of + // space last, leaving the final one visibly narrower. + modifier = Modifier.weight(1f), + ) + } + } + // Kept in the tree so the IME has a real field to attach to, + // but invisible and non-interfering. + Box(modifier = Modifier.matchParentSize().alpha(0f)) { + innerTextField() + } + } + }, + ) +} + +@Composable +private fun PinCell( + digit: Char?, + isActive: Boolean, + isError: Boolean, + enabled: Boolean, + modifier: Modifier = Modifier, +) { + val colors = MaterialTheme.roxyColors + + val borderColor by animateColorAsState( + targetValue = when { + // The active cell keeps its highlight even while the field is in + // error, so the entry point stays visible during a retry. + isActive -> colors.accent + isError -> colors.danger + digit != null -> colors.edgeStrong + else -> colors.edge + }, + animationSpec = tween(durationMillis = 150), + label = "pinCellBorder", + ) + + Box( + modifier = modifier + .height(52.dp) + .clip(RoundedCornerShape(12.dp)) + .background(if (enabled) colors.surface2 else colors.surface) + .border( + width = if (isActive || isError) 2.dp else 1.dp, + color = borderColor, + shape = RoundedCornerShape(12.dp), + ), + contentAlignment = Alignment.Center, + ) { + if (digit != null) { + Text( + text = digit.toString(), + style = MaterialTheme.typography.titleLarge.copy( + fontFamily = RoxyMonoFontFamily, + fontWeight = FontWeight.SemiBold, + fontSize = 22.sp, + textAlign = TextAlign.Center, + ), + color = if (enabled) colors.text else colors.textSubtle, + ) + } else if (isActive) { + BlinkingCaret(color = colors.accent) + } else { + // Placeholder dot, so an empty box does not read as broken. + Box( + modifier = Modifier + .width(8.dp) + .height(2.dp) + .clip(RoundedCornerShape(1.dp)) + .background(colors.textSubtle), + ) + } + } +} + +@Composable +private fun BlinkingCaret(color: Color) { + val transition = rememberInfiniteTransition(label = "caret") + val alpha by transition.animateFloat( + initialValue = 1f, + targetValue = 0f, + animationSpec = infiniteRepeatable( + animation = tween(durationMillis = 600), + repeatMode = RepeatMode.Reverse, + ), + label = "caretAlpha", + ) + Box( + modifier = Modifier + .alpha(alpha) + .width(2.dp) + .height(24.dp) + .clip(RoundedCornerShape(1.dp)) + .background(color), + ) +} + +@Preview(name = "PinInput - Dark", showBackground = true, backgroundColor = 0xFF0A0A0A) +@Composable +private fun PinInputDarkPreview() { + RoxyTheme(darkTheme = true) { + Box(modifier = Modifier.width(320.dp)) { + PinInput(value = "123", onValueChange = {}) + } + } +} + +@Preview(name = "PinInput - Error", showBackground = true, backgroundColor = 0xFF0A0A0A) +@Composable +private fun PinInputErrorPreview() { + RoxyTheme(darkTheme = true) { + Box(modifier = Modifier.width(320.dp)) { + PinInput(value = "1234", onValueChange = {}, isError = true) + } + } +} diff --git a/app/src/main/java/gg/roxy/shared/data/RemoteModels.kt b/app/src/main/java/gg/roxy/shared/data/RemoteModels.kt index b56e68b..989eac5 100644 --- a/app/src/main/java/gg/roxy/shared/data/RemoteModels.kt +++ b/app/src/main/java/gg/roxy/shared/data/RemoteModels.kt @@ -1,5 +1,7 @@ package gg.roxy.shared.data +import gg.roxy.shared.PAIRING_PIN_LENGTH + sealed interface RemoteConnectionState { data object Disconnected : RemoteConnectionState data object Connecting : RemoteConnectionState @@ -45,7 +47,7 @@ object RemoteWorkspaceUtils { if (token.isNotBlank()) { return ParsedQrPairing( token = token, - pin = pin?.filter { it.isDigit() }?.take(6), + pin = pin?.filter { it.isDigit() }?.take(PAIRING_PIN_LENGTH), rawUrl = url ?: trimmed, ) } @@ -115,7 +117,7 @@ object RemoteWorkspaceUtils { } val finalToken = extractedToken ?: trimmed - val cleanPin = extractedPin?.filter { it.isDigit() }?.take(6) + val cleanPin = extractedPin?.filter { it.isDigit() }?.take(PAIRING_PIN_LENGTH) return ParsedQrPairing( token = finalToken, diff --git a/app/src/main/java/gg/roxy/shared/data/RemoteWorkspaceClient.kt b/app/src/main/java/gg/roxy/shared/data/RemoteWorkspaceClient.kt index 6bfdc71..23e3aaf 100644 --- a/app/src/main/java/gg/roxy/shared/data/RemoteWorkspaceClient.kt +++ b/app/src/main/java/gg/roxy/shared/data/RemoteWorkspaceClient.kt @@ -6,6 +6,7 @@ import gg.roxy.chatFullscreen.businessLogic.ChatPartUiModel import gg.roxy.chatFullscreen.businessLogic.ToolCallStatus import gg.roxy.chatFullscreen.businessLogic.ToolCallType import gg.roxy.chatFullscreen.businessLogic.ToolCallUiModel +import gg.roxy.shared.PAIRING_PIN_LENGTH import java.util.UUID import java.util.concurrent.TimeUnit import javax.inject.Inject @@ -14,11 +15,13 @@ import kotlinx.coroutines.CoroutineScope import kotlinx.coroutines.Dispatchers import kotlinx.coroutines.Job import kotlinx.coroutines.delay +import kotlinx.coroutines.channels.Channel +import kotlinx.coroutines.flow.Flow +import kotlinx.coroutines.flow.filter +import kotlinx.coroutines.flow.map import kotlinx.coroutines.flow.MutableSharedFlow import kotlinx.coroutines.flow.MutableStateFlow -import kotlinx.coroutines.flow.SharedFlow import kotlinx.coroutines.flow.StateFlow -import kotlinx.coroutines.flow.asSharedFlow import kotlinx.coroutines.flow.asStateFlow import kotlinx.coroutines.launch import okhttp3.OkHttpClient @@ -37,6 +40,7 @@ sealed interface RemoteEvent { val tools: List, ) : RemoteEvent data class TextDelta(val sessionId: String, val chunk: String) : RemoteEvent + data class ReasoningDelta(val sessionId: String, val chunk: String) : RemoteEvent data class ToolStarted(val sessionId: String, val callId: String, val tool: String, val title: String) : RemoteEvent data class ToolDelta(val sessionId: String, val callId: String, val chunk: String) : RemoteEvent data class ToolEnded(val sessionId: String, val callId: String, val output: String, val ok: Boolean) : RemoteEvent @@ -45,19 +49,22 @@ sealed interface RemoteEvent { val isRunning: Boolean, val userText: String? = null, val inFlightText: String? = null, + val inFlightParts: List = emptyList(), val inFlightTools: List = emptyList(), ) : RemoteEvent data class ErrorReceived(val message: String) : RemoteEvent + data class QueueChanged(val sessionId: String, val count: Int) : RemoteEvent } interface RemoteWorkspaceClient { val connectionState: StateFlow - val events: SharedFlow + val events: Flow fun connect(rawTokenOrUrl: String, pin: String) - fun sendPrompt(text: String) + /** True means queued on the socket, not acknowledged by the host. */ + fun sendPrompt(text: String): Boolean fun switchSession(sessionId: String) fun refreshSessions() - fun abort() + fun abort(): Boolean fun disconnect() } @@ -89,8 +96,27 @@ class DefaultRemoteWorkspaceClient @Inject constructor( // replay = 1 so a snapshot emitted before the ViewModel subscribes is still // delivered; without it the chat stays empty until the next remote event. - private val _events = MutableSharedFlow(replay = 1, extraBufferCapacity = 64) - override val events: SharedFlow = _events.asSharedFlow() + private data class QueuedEvent(val generation: Int, val event: RemoteEvent) + private val _events = MutableSharedFlow(replay = 1, extraBufferCapacity = 64) + override val events: Flow = _events + .filter { it.generation == connectionGeneration } + .map { it.event } + private val eventQueue = Channel(capacity = 256) + + init { + scope.launch { + for (queued in eventQueue) { + if (queued.generation == connectionGeneration) _events.emit(queued) + } + } + } + + private fun publish(event: RemoteEvent) { + val generation = connectionGeneration + if (!eventQueue.trySend(QueuedEvent(generation, event)).isSuccess) { + failConnection("Could not keep up with PC updates. Reconnect to refresh the conversation.", generation) + } + } override fun connect(rawTokenOrUrl: String, pin: String) { val token = RemoteWorkspaceUtils.extractGuestToken(rawTokenOrUrl) @@ -100,7 +126,7 @@ class DefaultRemoteWorkspaceClient @Inject constructor( } val cleanedPin = pin.trim() - if (cleanedPin.length != 6) { + if (cleanedPin.length != PAIRING_PIN_LENGTH) { _connectionState.value = RemoteConnectionState.Error("PIN must be 6 digits") return } @@ -243,9 +269,7 @@ class DefaultRemoteWorkspaceClient @Inject constructor( ) ) } - scope.launch { - _events.emit(RemoteEvent.SessionsReceived(list, currentId)) - } + publish(RemoteEvent.SessionsReceived(list, currentId)) } "snapshot" -> { val sessionId = json.optString("sessionId", "") @@ -336,9 +360,7 @@ class DefaultRemoteWorkspaceClient @Inject constructor( } } - scope.launch { - _events.emit(RemoteEvent.SnapshotReceived(sessionId, messagesList, toolsList)) - } + publish(RemoteEvent.SnapshotReceived(sessionId, messagesList, toolsList)) } "delta" -> { val sessionId = json.optString("sessionId", "") @@ -347,33 +369,31 @@ class DefaultRemoteWorkspaceClient @Inject constructor( "text" -> { val delta = eventObj.optString("delta", "") if (delta.isNotEmpty()) { - scope.launch { - _events.emit(RemoteEvent.TextDelta(sessionId, delta)) - } + publish(RemoteEvent.TextDelta(sessionId, delta)) + } + } + "reasoning" -> { + val delta = eventObj.optString("delta", "") + if (delta.isNotEmpty()) { + publish(RemoteEvent.ReasoningDelta(sessionId, delta)) } } "tool-start" -> { val callId = eventObj.optString("callId", UUID.randomUUID().toString()) val tool = eventObj.optString("tool", "tool") val title = eventObj.optString("title", tool) - scope.launch { - _events.emit(RemoteEvent.ToolStarted(sessionId, callId, tool, title)) - } + publish(RemoteEvent.ToolStarted(sessionId, callId, tool, title)) } "tool-delta" -> { val callId = eventObj.optString("callId", "") val chunk = eventObj.optString("chunk", "") - scope.launch { - _events.emit(RemoteEvent.ToolDelta(sessionId, callId, chunk)) - } + publish(RemoteEvent.ToolDelta(sessionId, callId, chunk)) } "tool-end" -> { val callId = eventObj.optString("callId", "") val output = eventObj.optString("output", "") val ok = eventObj.optBoolean("ok", true) - scope.launch { - _events.emit(RemoteEvent.ToolEnded(sessionId, callId, output, ok)) - } + publish(RemoteEvent.ToolEnded(sessionId, callId, output, ok)) } } } @@ -383,21 +403,27 @@ class DefaultRemoteWorkspaceClient @Inject constructor( val userText = json.optString("userText").takeIf { it.isNotBlank() } val isRunning = state == "running" + val inFlightParts = mutableListOf() val inFlightTools = mutableListOf() - var inFlightText: String? = null + val textParts = mutableListOf() val partsArray = json.optJSONArray("parts") if (partsArray != null && partsArray.length() > 0) { - val textParts = mutableListOf() for (p in 0 until partsArray.length()) { val partObj = partsArray.optJSONObject(p) ?: continue when (partObj.optString("type")) { "text" -> { - val t = partObj.optString("text", "") - if (t.isNotBlank()) textParts.add(t) + val textPart = partObj.optString("text", "") + if (textPart.isNotBlank()) { + inFlightParts.add(ChatPartUiModel.Text(id = "turn-text-$p", text = textPart)) + textParts.add(textPart) + } } "reasoning" -> { - val r = partObj.optString("text", "") - if (r.isNotBlank()) textParts.add(r) + val reasoningText = partObj.optString("text", "") + if (reasoningText.isNotBlank()) { + inFlightParts.add(ChatPartUiModel.Reasoning(id = "turn-reasoning-$p", text = reasoningText)) + textParts.add(reasoningText) + } } "tool" -> { val toolName = partObj.optString("tool", "tool") @@ -406,43 +432,48 @@ class DefaultRemoteWorkspaceClient @Inject constructor( val toolOutput = partObj.optString("output", "") val callId = partObj.optString("callId", UUID.randomUUID().toString()) val toolType = resolveToolType(toolName) - inFlightTools.add( - ToolCallUiModel( - id = callId, - type = toolType, - name = toolName, - title = toolTitle, - detail = toolOutput, - status = if (toolState == "done") ToolCallStatus.Complete else ToolCallStatus.Running, - isExpanded = false, - ) + val toolModel = ToolCallUiModel( + id = callId, + type = toolType, + name = toolName, + title = toolTitle, + detail = toolOutput, + status = if (toolState == "done") ToolCallStatus.Complete else ToolCallStatus.Running, + isExpanded = false, ) + inFlightParts.add(ChatPartUiModel.Tool(toolModel)) + inFlightTools.add(toolModel) } } } - if (textParts.isNotEmpty()) { - inFlightText = textParts.joinToString("\n\n") - } } - scope.launch { - _events.emit(RemoteEvent.TurnChanged(sessionId, isRunning, userText, inFlightText, inFlightTools)) - } + publish( + RemoteEvent.TurnChanged( + sessionId = sessionId, + isRunning = isRunning, + userText = userText, + inFlightText = textParts.takeIf { it.isNotEmpty() }?.joinToString("\n\n"), + inFlightParts = inFlightParts, + inFlightTools = inFlightTools, + ) + ) } "error" -> { val msg = json.optString("message", "Unknown error from remote host") if (!isHandshakeComplete) { failConnection(msg, connectionGeneration) } else { - scope.launch { - _events.emit(RemoteEvent.ErrorReceived(msg)) - } + publish(RemoteEvent.ErrorReceived(msg)) } } + "queue" -> publish(RemoteEvent.QueueChanged( + sessionId = json.optString("sessionId", ""), + count = json.optJSONArray("items")?.length() ?: 0, + )) + "host-offline" -> failConnection("Your PC went offline. Reconnect when Roxy is available again.", connectionGeneration) "bye" -> { - handshakeTimeoutJob?.cancel() - isHandshakeComplete = false - _connectionState.value = RemoteConnectionState.Disconnected + disconnect() } } } @@ -456,13 +487,17 @@ class DefaultRemoteWorkspaceClient @Inject constructor( } } - override fun sendPrompt(text: String) { - val ws = activeWebSocket ?: return + override fun sendPrompt(text: String): Boolean { + val generation = connectionGeneration + val ws = activeWebSocket ?: return false + if (!isHandshakeComplete || text.isBlank()) return false val payload = JSONObject().apply { put("t", "prompt") put("text", text) } - ws.send(payload.toString()) + if (ws.send(payload.toString())) return true + failConnection("Could not send your message. Reconnect to your PC.", generation) + return false } override fun switchSession(sessionId: String) { @@ -501,12 +536,13 @@ class DefaultRemoteWorkspaceClient @Inject constructor( ws.send(payload.toString()) } - override fun abort() { - val ws = activeWebSocket ?: return + override fun abort(): Boolean { + val ws = activeWebSocket ?: return false + if (!isHandshakeComplete) return false val payload = JSONObject().apply { put("t", "abort") } - ws.send(payload.toString()) + return ws.send(payload.toString()) } override fun disconnect() { diff --git a/app/src/test/java/gg/roxy/chatFullscreen/components/ChatRowsTest.kt b/app/src/test/java/gg/roxy/chatFullscreen/components/ChatRowsTest.kt new file mode 100644 index 0000000..760895c --- /dev/null +++ b/app/src/test/java/gg/roxy/chatFullscreen/components/ChatRowsTest.kt @@ -0,0 +1,79 @@ +package gg.roxy.chatFullscreen.components + +import gg.roxy.chatFullscreen.businessLogic.ChatMessageUiModel +import gg.roxy.chatFullscreen.businessLogic.ChatPartUiModel +import gg.roxy.chatFullscreen.businessLogic.ToolCallStatus +import gg.roxy.chatFullscreen.businessLogic.ToolCallType +import gg.roxy.chatFullscreen.businessLogic.ToolCallUiModel +import org.junit.Assert.assertEquals +import org.junit.Assert.assertTrue +import org.junit.Test + +class ChatRowsTest { + @Test + fun buildChatRowsReturnsNewestRenderableRowFirst() { + val rows = buildChatRows( + messages = listOf( + ChatMessageUiModel(id = "user-1", text = "Hello", isUser = true), + ChatMessageUiModel( + id = "assistant-1", + parts = listOf( + ChatPartUiModel.Reasoning(id = "assistant-1-reasoning-0", text = "Thinking"), + ChatPartUiModel.Text(id = "assistant-1-text-1", text = "Answer"), + ), + ), + ), + toolCalls = emptyList(), + ) + + assertEquals( + listOf("assistant-1-text-1", "assistant-1-reasoning-0", "user-1"), + rows.map { it.key }, + ) + } + + @Test + fun orphanToolsRenderAtOldestEndOfTranscript() { + val orphan = ToolCallUiModel( + id = "orphan-tool", + type = ToolCallType.Terminal, + name = "bash", + title = "Detached tool", + detail = "Detached output", + status = ToolCallStatus.Complete, + ) + + val rows = buildChatRows( + messages = listOf(ChatMessageUiModel(id = "user-1", text = "Hello", isUser = true)), + toolCalls = listOf(orphan), + ) + + assertEquals("user-1", rows.first().key) + assertTrue(rows.last() is ChatRow.OrphanTools) + assertEquals("orphan-tool-calls", rows.last().key) + } + + @Test + fun rowKeysStayStableAcrossEquivalentSnapshots() { + val first = buildChatRows( + messages = listOf( + ChatMessageUiModel( + id = "assistant-1", + parts = listOf(ChatPartUiModel.Text(id = "assistant-1-text-0", text = "Hel")), + ), + ), + toolCalls = emptyList(), + ) + val second = buildChatRows( + messages = listOf( + ChatMessageUiModel( + id = "assistant-1", + parts = listOf(ChatPartUiModel.Text(id = "assistant-1-text-0", text = "Hello")), + ), + ), + toolCalls = emptyList(), + ) + + assertEquals(first.map { it.key }, second.map { it.key }) + } +} diff --git a/app/src/test/java/gg/roxy/shared/businessLogic/RoxyAppViewModelTest.kt b/app/src/test/java/gg/roxy/shared/businessLogic/RoxyAppViewModelTest.kt index 2083aed..96bbdc0 100644 --- a/app/src/test/java/gg/roxy/shared/businessLogic/RoxyAppViewModelTest.kt +++ b/app/src/test/java/gg/roxy/shared/businessLogic/RoxyAppViewModelTest.kt @@ -1,9 +1,11 @@ package gg.roxy.shared.businessLogic import gg.roxy.chatFullscreen.businessLogic.ChatMessageUiModel +import gg.roxy.chatFullscreen.businessLogic.ChatPartUiModel import gg.roxy.chatFullscreen.businessLogic.ToolCallStatus import gg.roxy.chatFullscreen.businessLogic.ToolCallType import gg.roxy.chatFullscreen.businessLogic.ToolCallUiModel +import gg.roxy.shared.PAIRING_PIN_LENGTH import gg.roxy.shared.data.RemoteConnectionState import gg.roxy.shared.data.RemoteEvent import gg.roxy.shared.data.RemoteSessionInfo @@ -17,6 +19,9 @@ import kotlinx.coroutines.flow.SharedFlow import kotlinx.coroutines.flow.StateFlow import kotlinx.coroutines.flow.asSharedFlow import kotlinx.coroutines.flow.asStateFlow +import kotlinx.coroutines.flow.first +import kotlinx.coroutines.runBlocking +import kotlinx.coroutines.withTimeout import org.junit.Assert.assertEquals import org.junit.Assert.assertFalse import org.junit.Assert.assertTrue @@ -31,13 +36,22 @@ class FakeRemoteWorkspaceClient : RemoteWorkspaceClient { var lastPromptSent: String? = null var lastSwitchedSession: String? = null + var acceptPrompts = true + var promptCount = 0 + var abortCount = 0 + var acceptAbort = true + + fun setConnection(state: RemoteConnectionState) { _connectionState.value = state } override fun connect(rawTokenOrUrl: String, pin: String) { _connectionState.value = RemoteConnectionState.Connected("Test PC") } - override fun sendPrompt(text: String) { + override fun sendPrompt(text: String): Boolean { + if (!acceptPrompts) return false lastPromptSent = text + promptCount++ + return true } override fun switchSession(sessionId: String) { @@ -45,7 +59,10 @@ class FakeRemoteWorkspaceClient : RemoteWorkspaceClient { } override fun refreshSessions() {} - override fun abort() {} + override fun abort(): Boolean { + abortCount++ + return acceptAbort + } override fun disconnect() { _connectionState.value = RemoteConnectionState.Disconnected } @@ -120,6 +137,9 @@ class RoxyAppViewModelTest { fun composerAndToolCallsAreControlledByTheViewModel() { val client = FakeRemoteWorkspaceClient() val viewModel = createViewModel(client = client) + client.connect("tok", "123456") + client.fakeEvents.tryEmit(RemoteEvent.SnapshotReceived("sess-1", emptyList(), emptyList())) + client.fakeEvents.tryEmit(RemoteEvent.TurnChanged("sess-1", false)) client.fakeEvents.tryEmit( RemoteEvent.ToolStarted( @@ -226,6 +246,80 @@ class RoxyAppViewModelTest { assertEquals("Checking logs now... All clear!", viewModel.uiState.value.chat.messages[1].text) } + @Test + fun desktopTurnStreamsCurrentInputReasoningAndOutputOverExistingHistory() { + val client = FakeRemoteWorkspaceClient() + val viewModel = createViewModel(client = client) + + client.fakeEvents.tryEmit( + RemoteEvent.SnapshotReceived( + sessionId = "sess-1", + messages = listOf(ChatMessageUiModel(id = "old", text = "Previous answer", isUser = false)), + tools = emptyList(), + ) + ) + client.fakeEvents.tryEmit( + RemoteEvent.TurnChanged( + sessionId = "sess-1", + isRunning = true, + userText = "Current question", + ) + ) + client.fakeEvents.tryEmit(RemoteEvent.ReasoningDelta("sess-1", "Working it out")) + client.fakeEvents.tryEmit(RemoteEvent.TextDelta("sess-1", "Current answer")) + + val messages = viewModel.uiState.value.chat.messages + assertEquals(listOf("Previous answer", "Current question", "Working it out\n\nCurrent answer"), messages.map { it.text }) + assertTrue(messages[1].isUser) + assertTrue(messages[2].parts[0] is ChatPartUiModel.Reasoning) + assertTrue(messages[2].parts[1] is ChatPartUiModel.Text) + } + + @Test + fun turnChangedKeepsStreamingPartIdsStableAndPreservesReasoning() { + val client = FakeRemoteWorkspaceClient() + val viewModel = createViewModel(client = client) + + client.fakeEvents.tryEmit( + RemoteEvent.SnapshotReceived( + sessionId = "sess-remote-1", + messages = listOf(ChatMessageUiModel(id = "user-1", text = "Explain", isUser = true)), + tools = emptyList(), + ) + ) + + client.fakeEvents.tryEmit( + RemoteEvent.TurnChanged( + sessionId = "sess-remote-1", + isRunning = true, + inFlightParts = listOf( + ChatPartUiModel.Reasoning(id = "turn-reasoning-0", text = "Thinking"), + ChatPartUiModel.Text(id = "turn-text-1", text = "Hel"), + ), + ) + ) + val firstAssistant = viewModel.uiState.value.chat.messages.last() + val firstParts = firstAssistant.parts + + client.fakeEvents.tryEmit( + RemoteEvent.TurnChanged( + sessionId = "sess-remote-1", + isRunning = true, + inFlightParts = listOf( + ChatPartUiModel.Reasoning(id = "turn-reasoning-0", text = "Thinking more"), + ChatPartUiModel.Text(id = "turn-text-1", text = "Hello"), + ), + ) + ) + val secondAssistant = viewModel.uiState.value.chat.messages.last() + val secondParts = secondAssistant.parts + + assertEquals(firstAssistant.id, secondAssistant.id) + assertEquals(firstParts.map { it.id }, secondParts.map { it.id }) + assertTrue(secondParts[0] is ChatPartUiModel.Reasoning) + assertEquals("Thinking more", (secondParts[0] as ChatPartUiModel.Reasoning).text) + assertEquals("Hello", (secondParts[1] as ChatPartUiModel.Text).text) + } @Test fun qrCodeScannedWithPinAutomaticallyConnects() { val client = FakeRemoteWorkspaceClient() @@ -252,7 +346,7 @@ class RoxyAppViewModelTest { assertEquals("token_without_pin", viewModel.uiState.value.main.prefilledToken) assertEquals("", viewModel.uiState.value.main.prefilledPin) assertTrue(viewModel.uiState.value.main.isConnectingDialogVisible) - assertEquals("QR code scanned! Enter the 6-digit PIN shown on your PC.", viewModel.uiState.value.main.qrFeedbackMessage) + assertEquals("QR code scanned! Enter the $PAIRING_PIN_LENGTH-digit PIN shown on your PC.", viewModel.uiState.value.main.qrFeedbackMessage) } @Test @@ -406,6 +500,305 @@ class RoxyAppViewModelTest { assertTrue(viewModel.uiState.value.chat.isSyncing) } + @Test + fun offlineSendPreservesDraftAndTranscript() { + val client = FakeRemoteWorkspaceClient() + val viewModel = connectedSession(client) + viewModel.updateComposer("Keep this draft") + client.setConnection(RemoteConnectionState.Error("Network lost")) + + viewModel.submitComposer() + + assertEquals("Keep this draft", viewModel.uiState.value.chat.composerText) + assertTrue(viewModel.uiState.value.chat.messages.isEmpty()) + assertEquals(0, client.promptCount) + assertFalse(viewModel.uiState.value.chat.canSubmit) + assertEquals("Network lost", viewModel.uiState.value.chat.errorMessage) + } + + @Test + fun socketRejectionPreservesDraftWithoutAnOptimisticMessage() { + val client = FakeRemoteWorkspaceClient() + val viewModel = connectedSession(client) + client.acceptPrompts = false + viewModel.updateComposer("Do not lose this") + + viewModel.submitComposer() + + assertEquals("Do not lose this", viewModel.uiState.value.chat.composerText) + assertTrue(viewModel.uiState.value.chat.messages.isEmpty()) + assertFalse(viewModel.uiState.value.chat.isRunning) + assertTrue(viewModel.uiState.value.chat.errorMessage!!.contains("not sent")) + } + + @Test + fun reconnectPreservesDraftAndRequestsTheSameSessionWithoutResending() { + val client = FakeRemoteWorkspaceClient() + val storage = FakeRemoteStorage().apply { savedToken = "tok"; savedPin = "123456" } + val viewModel = createViewModel(client, storage) + client.fakeEvents.tryEmit(RemoteEvent.SnapshotReceived("sess-1", emptyList(), emptyList())) + viewModel.updateComposer("A pending draft") + client.setConnection(RemoteConnectionState.Disconnected) + + viewModel.reconnectRemote() + + assertEquals("sess-1", client.lastSwitchedSession) + assertEquals("A pending draft", viewModel.uiState.value.chat.composerText) + assertTrue(viewModel.uiState.value.chat.isSyncing) + assertFalse(viewModel.uiState.value.chat.canSubmit) + assertEquals(0, client.promptCount) + } + + @Test + fun sessionSwitchRestoresEachSessionsDraft() { + val client = FakeRemoteWorkspaceClient() + val viewModel = connectedSession(client) + client.fakeEvents.tryEmit(RemoteEvent.SessionsReceived(listOf( + RemoteSessionInfo("sess-1", "One", "Project"), + RemoteSessionInfo("sess-2", "Two", "Project"), + ), "sess-1")) + viewModel.updateComposer("Draft one") + viewModel.openSession("sess-2") + viewModel.updateComposer("Draft two") + viewModel.openSession("sess-1") + assertEquals("Draft one", viewModel.uiState.value.chat.composerText) + viewModel.openSession("sess-2") + assertEquals("Draft two", viewModel.uiState.value.chat.composerText) + } + + @Test + fun onlyOnePromptCanBeOutstandingOrRunning() { + val client = FakeRemoteWorkspaceClient() + val viewModel = connectedSession(client) + viewModel.updateComposer("First") + viewModel.submitComposer() + viewModel.updateComposer("Second") + viewModel.submitComposer() + assertEquals(1, client.promptCount) + assertEquals("Second", viewModel.uiState.value.chat.composerText) + assertTrue(viewModel.uiState.value.chat.isAwaitingResponse) + + client.fakeEvents.tryEmit(RemoteEvent.TurnChanged("sess-1", true)) + assertEquals("First", viewModel.uiState.value.chat.messages.single().text) + viewModel.submitComposer() + assertEquals(1, client.promptCount) + client.fakeEvents.tryEmit(RemoteEvent.TurnChanged("sess-1", false)) + client.fakeEvents.tryEmit(RemoteEvent.SnapshotReceived("sess-1", viewModel.uiState.value.chat.messages, emptyList())) + client.fakeEvents.tryEmit(RemoteEvent.TurnChanged("sess-1", false)) + viewModel.submitComposer() + assertEquals(2, client.promptCount) + } + + @Test + fun remoteErrorIsVisibleAndRefreshRequiresBothSnapshotAndTurn() { + val client = FakeRemoteWorkspaceClient() + val viewModel = connectedSession(client) + viewModel.updateComposer("Draft") + client.fakeEvents.tryEmit(RemoteEvent.ErrorReceived("Provider unavailable")) + assertEquals("Provider unavailable", viewModel.uiState.value.chat.errorMessage) + assertFalse(viewModel.uiState.value.chat.canSubmit) + + viewModel.reconnectRemote() + client.fakeEvents.tryEmit(RemoteEvent.SnapshotReceived("sess-1", emptyList(), emptyList())) + assertFalse(viewModel.uiState.value.chat.canSubmit) + client.fakeEvents.tryEmit(RemoteEvent.TurnChanged("sess-1", false)) + assertTrue(viewModel.uiState.value.chat.canSubmit) + assertEquals("Draft", viewModel.uiState.value.chat.composerText) + } + + @Test + fun previousSessionsUpdatesCannotEnableSendForNewSession() { + val client = FakeRemoteWorkspaceClient() + val viewModel = connectedSession(client) + client.fakeEvents.tryEmit(RemoteEvent.SessionsReceived(listOf( + RemoteSessionInfo("sess-1", "One", "Project"), + RemoteSessionInfo("sess-2", "Two", "Project"), + ), "sess-1")) + viewModel.openSession("sess-2") + viewModel.updateComposer("For session two") + client.fakeEvents.tryEmit(RemoteEvent.SnapshotReceived("sess-1", emptyList(), emptyList())) + client.fakeEvents.tryEmit(RemoteEvent.TurnChanged("sess-1", false)) + viewModel.submitComposer() + assertEquals(0, client.promptCount) + client.fakeEvents.tryEmit(RemoteEvent.TurnChanged("sess-2", false)) + assertFalse(viewModel.uiState.value.chat.canSubmit) + client.fakeEvents.tryEmit(RemoteEvent.SnapshotReceived("sess-2", emptyList(), emptyList())) + assertTrue(viewModel.uiState.value.chat.canSubmit) + } + + @Test + fun queuedMobilePromptDoesNotSplitTheCurrentAssistantReply() { + val client = FakeRemoteWorkspaceClient() + val viewModel = connectedSession(client) + client.fakeEvents.tryEmit(RemoteEvent.SnapshotReceived("sess-1", listOf( + ChatMessageUiModel("assistant", "Current reply"), + ), emptyList())) + viewModel.updateComposer("My queued prompt") + viewModel.submitComposer() + client.fakeEvents.tryEmit(RemoteEvent.QueueChanged("sess-1", 1)) + client.fakeEvents.tryEmit(RemoteEvent.TextDelta("sess-1", " finished")) + assertEquals("Current reply finished", viewModel.uiState.value.chat.messages.single().text) + client.fakeEvents.tryEmit(RemoteEvent.QueueChanged("sess-1", 0)) + client.fakeEvents.tryEmit(RemoteEvent.TurnChanged("sess-1", true, userText = "My queued prompt")) + client.fakeEvents.tryEmit(RemoteEvent.TextDelta("sess-1", "New reply")) + assertEquals(listOf("Current reply finished", "My queued prompt", "New reply"), + viewModel.uiState.value.chat.messages.map { it.text }) + assertFalse(viewModel.uiState.value.chat.isAwaitingResponse) + } + + @Test + fun desktopQueueBlocksNewMobilePrompts() { + val client = FakeRemoteWorkspaceClient() + val viewModel = connectedSession(client) + client.fakeEvents.tryEmit(RemoteEvent.QueueChanged("sess-1", 2)) + viewModel.updateComposer("Wait for the queue") + viewModel.submitComposer() + assertEquals(0, client.promptCount) + client.fakeEvents.tryEmit(RemoteEvent.QueueChanged("sess-1", 0)) + assertTrue(viewModel.uiState.value.chat.canSubmit) + } + + @Test + fun stopWaitsForHostIdleAndDoesNotEraseTheNextDraft() { + val client = FakeRemoteWorkspaceClient() + val viewModel = runningMobileSession(client) + viewModel.updateComposer("Next question") + viewModel.stopTurn() + viewModel.stopTurn() + assertEquals(1, client.abortCount) + assertTrue(viewModel.uiState.value.chat.isRunning) + assertTrue(viewModel.uiState.value.chat.isStopping) + assertFalse(viewModel.uiState.value.chat.canSubmit) + client.fakeEvents.tryEmit(RemoteEvent.TurnChanged("other-session", false)) + assertTrue(viewModel.uiState.value.chat.isStopping) + client.fakeEvents.tryEmit(RemoteEvent.TurnChanged("sess-1", false)) + assertFalse(viewModel.uiState.value.chat.isStopping) + assertFalse(viewModel.uiState.value.chat.isRunning) + assertEquals("Next question", viewModel.uiState.value.chat.composerText) + assertFalse(viewModel.uiState.value.chat.canSubmit) + client.fakeEvents.tryEmit(RemoteEvent.SnapshotReceived("sess-1", viewModel.uiState.value.chat.messages, emptyList())) + client.fakeEvents.tryEmit(RemoteEvent.TurnChanged("sess-1", false)) + assertTrue(viewModel.uiState.value.chat.canSubmit) + } + + @Test + fun desktopStartedTurnDoesNotOfferAnUnsupportedStop() { + val client = FakeRemoteWorkspaceClient() + val viewModel = connectedSession(client) + client.fakeEvents.tryEmit(RemoteEvent.TurnChanged("sess-1", true, userText = "From desktop")) + assertFalse(viewModel.uiState.value.chat.canStop) + viewModel.stopTurn() + assertEquals(0, client.abortCount) + } + + @Test + fun rejectedStopKeepsRunningStateAndShowsRecovery() { + val client = FakeRemoteWorkspaceClient() + val viewModel = runningMobileSession(client) + client.acceptAbort = false + viewModel.stopTurn() + assertTrue(viewModel.uiState.value.chat.isRunning) + assertFalse(viewModel.uiState.value.chat.isStopping) + assertTrue(viewModel.uiState.value.chat.errorMessage!!.contains("Could not send Stop")) + client.setConnection(RemoteConnectionState.Error("Offline")) + viewModel.stopTurn() + assertEquals(1, client.abortCount) + } + + @Test + fun stopTimeoutDoesNotPretendTheHostStopped() = runBlocking { + val client = FakeRemoteWorkspaceClient() + val viewModel = runningMobileSession(client, timeoutMs = 50) + viewModel.stopTurn() + val state = withTimeout(2000) { viewModel.uiState.first { it.chat.errorMessage != null } } + assertTrue(state.chat.isRunning) + assertFalse(state.chat.isStopping) + assertFalse(state.chat.canSubmit) + assertTrue(state.chat.errorMessage!!.contains("has not confirmed Stop")) + } + + @Test + fun promptTimeoutRequiresRefreshWithoutAutomaticReplay() = runBlocking { + val client = FakeRemoteWorkspaceClient() + val viewModel = connectedSession(client, timeoutMs = 50) + viewModel.updateComposer("Run once") + viewModel.submitComposer() + val state = withTimeout(2000) { viewModel.uiState.first { it.chat.errorMessage != null } } + assertTrue(state.chat.errorMessage!!.contains("has not confirmed this message")) + assertEquals(1, client.promptCount) + viewModel.submitComposer() + assertEquals(1, client.promptCount) + } + + @Test + fun unrelatedDesktopTurnDoesNotAcknowledgePendingMobilePrompt() { + val client = FakeRemoteWorkspaceClient() + val viewModel = connectedSession(client) + viewModel.updateComposer("Mobile prompt") + viewModel.submitComposer() + client.fakeEvents.tryEmit(RemoteEvent.TurnChanged("sess-1", true, userText = "Desktop prompt")) + assertTrue(viewModel.uiState.value.chat.isAwaitingResponse) + assertFalse(viewModel.uiState.value.chat.isMobileTurn) + assertEquals("Desktop prompt", viewModel.uiState.value.chat.messages.single().text) + } + + @Test + fun completedMobileTurnRefreshesThePersistedReplyIncludingProviderErrors() { + val client = FakeRemoteWorkspaceClient() + val viewModel = runningMobileSession(client) + client.fakeEvents.tryEmit(RemoteEvent.TurnChanged("sess-1", false)) + assertEquals("sess-1", client.lastSwitchedSession) + assertTrue(viewModel.uiState.value.chat.isSyncing) + client.fakeEvents.tryEmit(RemoteEvent.SnapshotReceived("sess-1", listOf( + ChatMessageUiModel("user", "Start work", isUser = true), + ChatMessageUiModel("reply", "Model request failed."), + ), emptyList())) + client.fakeEvents.tryEmit(RemoteEvent.TurnChanged("sess-1", false)) + assertEquals("Model request failed.", viewModel.uiState.value.chat.messages.last().text) + assertFalse(viewModel.uiState.value.chat.isSyncing) + } + + @Test + fun pairingAnotherPcClearsThePreviousPcsSessionListAndDraft() { + val client = FakeRemoteWorkspaceClient() + val viewModel = connectedSession(client) + client.fakeEvents.tryEmit(RemoteEvent.SessionsReceived(listOf(RemoteSessionInfo("sess-1", "One", "Old PC")), "sess-1")) + viewModel.updateComposer("Private draft") + viewModel.connectRemote("different-token", "123456") + assertTrue(viewModel.uiState.value.main.projects.isEmpty()) + assertTrue(viewModel.uiState.value.chat.composerText.isEmpty()) + assertTrue(viewModel.uiState.value.chat.messages.isEmpty()) + } + + @Test + fun syncTimeoutOffersRecoveryAndKeepsDraft() = runBlocking { + val client = FakeRemoteWorkspaceClient() + val viewModel = connectedSession(client, timeoutMs = 50) + viewModel.updateComposer("Keep draft") + viewModel.reconnectRemote() + val state = withTimeout(2000) { viewModel.uiState.first { it.chat.errorMessage != null } } + assertFalse(state.chat.isSyncing) + assertFalse(state.chat.canSubmit) + assertEquals("Keep draft", state.chat.composerText) + } + + private fun runningMobileSession(client: FakeRemoteWorkspaceClient, timeoutMs: Long = 15_000): RoxyAppViewModel { + val viewModel = connectedSession(client, timeoutMs) + viewModel.updateComposer("Start work") + viewModel.submitComposer() + client.fakeEvents.tryEmit(RemoteEvent.TurnChanged("sess-1", true)) + assertTrue(viewModel.uiState.value.chat.canStop) + return viewModel + } + + private fun connectedSession(client: FakeRemoteWorkspaceClient, timeoutMs: Long = 15_000): RoxyAppViewModel { + val viewModel = RoxyAppViewModel(client, FakeRemoteStorage(), CoroutineScope(Dispatchers.Unconfined), timeoutMs) + client.connect("tok", "123456") + client.fakeEvents.tryEmit(RemoteEvent.SnapshotReceived("sess-1", emptyList(), emptyList())) + client.fakeEvents.tryEmit(RemoteEvent.TurnChanged("sess-1", false)) + return viewModel + } + private fun RoxyAppViewModel.toolCall(id: String): ToolCallUiModel = uiState.value.chat.toolCalls.first { it.id == id } } diff --git a/app/src/test/java/gg/roxy/shared/data/RemoteWorkspaceClientTest.kt b/app/src/test/java/gg/roxy/shared/data/RemoteWorkspaceClientTest.kt index 4a0702b..0a90275 100644 --- a/app/src/test/java/gg/roxy/shared/data/RemoteWorkspaceClientTest.kt +++ b/app/src/test/java/gg/roxy/shared/data/RemoteWorkspaceClientTest.kt @@ -1,11 +1,15 @@ package gg.roxy.shared.data +import gg.roxy.chatFullscreen.businessLogic.ChatPartUiModel import gg.roxy.chatFullscreen.businessLogic.ToolCallStatus import gg.roxy.chatFullscreen.businessLogic.ToolCallType import kotlinx.coroutines.CompletableDeferred +import kotlinx.coroutines.CoroutineStart import kotlinx.coroutines.Dispatchers import kotlinx.coroutines.delay import kotlinx.coroutines.flow.first +import kotlinx.coroutines.flow.take +import kotlinx.coroutines.flow.toList import kotlinx.coroutines.launch import kotlinx.coroutines.runBlocking import kotlinx.coroutines.withTimeout @@ -26,6 +30,64 @@ class MemoryRemoteStorage : RemoteStorage { class RemoteWorkspaceClientTest { + @Test + fun burstEventsKeepWireOrderEvenWhenTheConsumerIsSlow() = runBlocking { + val client = DefaultRemoteWorkspaceClient(MemoryRemoteStorage()) + val received = mutableListOf() + val job = launch(start = CoroutineStart.UNDISPATCHED) { + client.events.take(100).collect { + delay(1) + received.add((it as RemoteEvent.TextDelta).chunk) + } + } + repeat(100) { index -> + client.handleIncomingMessage("""{"t":"delta","sessionId":"s","event":{"type":"text","delta":"$index"}}""") + } + withTimeout(5000) { job.join() } + assertEquals((0 until 100).map { it.toString() }, received) + } + + @Test + fun reasoningDeltaIsPublishedInsteadOfBeingDropped() = runBlocking { + val client = DefaultRemoteWorkspaceClient(MemoryRemoteStorage()) + + client.handleIncomingMessage( + """{"t":"delta","sessionId":"s","event":{"type":"reasoning","delta":"Thinking"}}""" + ) + + val event = withTimeout(2000) { client.events.first() } + assertEquals(RemoteEvent.ReasoningDelta("s", "Thinking"), event) + } + + @Test + fun disconnectedGenerationDoesNotReplayItsTranscript() = runBlocking { + val client = DefaultRemoteWorkspaceClient(MemoryRemoteStorage()) + client.handleIncomingMessage("""{"t":"snapshot","sessionId":"old","messages":[]}""") + withTimeout(2000) { client.events.first() } + client.disconnect() + client.handleIncomingMessage("""{"t":"snapshot","sessionId":"new","messages":[]}""") + val event = withTimeout(2000) { client.events.first() } as RemoteEvent.SnapshotReceived + assertEquals("new", event.sessionId) + } + + @Test + fun hostOfflineRetiresTheConnection() { + val client = DefaultRemoteWorkspaceClient(MemoryRemoteStorage()) + client.handleIncomingMessage("""{"t":"hello-ok"}""") + client.handleIncomingMessage("""{"t":"host-offline"}""") + assertTrue(client.connectionState.value is RemoteConnectionState.Error) + assertFalse(client.sendPrompt("Do not send")) + } + + @Test + fun promptWithoutALiveSocketIsRejected() { + val client = DefaultRemoteWorkspaceClient(MemoryRemoteStorage()) + assertFalse(client.sendPrompt("Hello")) + assertFalse(client.abort()) + client.handleIncomingMessage("""{"t":"hello-ok"}""") + assertFalse(client.sendPrompt("Hello")) + } + @Test fun snapshotWithTextAndToolPartsParsesBothCorrectly() = runBlocking { val storage = MemoryRemoteStorage() @@ -233,6 +295,11 @@ class RemoteWorkspaceClientTest { assertTrue(event.isRunning) assertEquals("Run tests", event.userText) assertEquals("Starting tests now", event.inFlightText) + assertEquals(2, event.inFlightParts.size) + assertTrue(event.inFlightParts[0] is ChatPartUiModel.Tool) + val textPart = event.inFlightParts[1] as ChatPartUiModel.Text + assertEquals("turn-text-1", textPart.id) + assertEquals("Starting tests now", textPart.text) assertEquals(1, event.inFlightTools.size) assertEquals("call-live-1", event.inFlightTools[0].id) assertEquals(ToolCallStatus.Running, event.inFlightTools[0].status) diff --git a/docs/v1-chat-reliability.md b/docs/v1-chat-reliability.md new file mode 100644 index 0000000..04162d2 --- /dev/null +++ b/docs/v1-chat-reliability.md @@ -0,0 +1,66 @@ +# V1 chat reliability implementation + +Current branch: `jair/fixbugs1.0` (changed by the user during the pause). Started on `codex/v1-chat-reliability`, based on the chat/PIN fixes plus the model selector cleanup. All implementation, documentation, and commit messages are in English. + +## Scope and checkpoints + +Each module is committed separately. Do not publish a release until the verification checklist passes. + +- [x] Module 1: report transport send failures, block offline sends, preserve drafts, expose connection recovery in chat. +- [x] Module 2: display remote errors, wait for authoritative session synchronization, allow one turn at a time, preserve stream event ordering. +- [x] Module 3: connect Stop for mobile-started turns, wait for host confirmation, remove attachment placeholders. +- [x] Module 4: regression tests, debug/release compilation, and final review. + +## Design constraints + +- The desktop protocol remains unchanged. A successful WebSocket send only confirms local enqueueing, not host receipt. Never automatically replay prompts after a disconnect. +- Keep session drafts and transcripts across accidental disconnects; explicit disconnect or pairing a different PC clears them. +- Reconnect to the saved PC and resynchronize the selected session before enabling Send. +- Keep the host authoritative for running/idle state. Do not claim a turn stopped just because an abort frame was queued. +- No model picker, attachments, new-session creation, or queue UI in this release. +- Existing IDE and Google Services changes belong to the user and are excluded from commits. + +## Verification + +- [x] Unit tests cover offline/rejected sends, reconnect, session isolation, errors, duplicate submission, stream ordering, and Stop. +- [x] Debug APK builds. +- [x] Release build completes (distribution signing is separate). +- [ ] Device smoke test: pairing, incorrect PIN, text response, session switch, airplane mode/reconnect, background/resume, Stop. + +Initial environment issue: Gradle fails before running tasks with `Unable to establish loopback connection`. Investigate locally; do not mark tests passed based on older reports. No Android device was attached during the assessment. + +## Progress + +- Baseline saved in commit `0b01e02`; includes the already-reviewed model selector cleanup and English roadmap. +- Module 1 implemented and validated: 48 unit tests passed and the debug APK built. Drafts survive rejected sends, accidental disconnects, and session navigation; explicit disconnect clears them. Reconnect requests the previous session without replaying prompts. +- Local Windows workaround discovered: run Gradle with `JAVA_TOOL_OPTIONS=-Djdk.net.unixdomain.tmpdir=C:/nonexistent-roxy-unix-sockets`. The directory must not exist; Java falls back to TCP for its internal selector wakeup pipe. This is a process-local workaround, not a project setting or Android runtime change. +- Module 2: 56 unit tests passed and the debug APK built. Session readiness requires snapshot + turn; prompts remain pending until the host starts them; desktop queues block additional sends. Errors appear inside chat with a refresh action. Events are processed serially and filtered by connection generation, including replayed events. +- The user saved in-progress module 2 changes in `72ae0ab` during the pause. The module 2 checkpoint fixes the incomplete edits in that commit and adds regression coverage. +- Module 3: 62 unit tests passed and the debug APK built. Stop is exposed only for a confirmed mobile-started turn; repeated clicks, offline/rejected aborts, and timeouts are covered. The UI waits for host idle. Attachment controls and image placeholder text have been removed. +- Module 4: 65 unit tests passed with zero failures/errors. Debug and release APKs compiled successfully. `lintDebug` passed with 20 non-blocking dependency, style, resource-location, and manifest warnings. The final transcript is refreshed after a mobile turn ends so persisted provider errors are visible. Switching PCs clears the previous PC's session list and drafts. +- The instrumentation test APK also compiled, including two new composer tests covering the offline Send/IME guards and Stop's pending state. Instrumentation tests have NOT been executed: `adb devices -l` returned no devices. + +## Commands and artifacts + +In PowerShell on this machine: + +```powershell +$env:GRADLE_USER_HOME = 'C:/Users/Jair Escamilla/.gradle' +$env:JAVA_TOOL_OPTIONS = '-Djdk.net.unixdomain.tmpdir=C:/nonexistent-roxy-unix-sockets' +.\gradlew.bat :app:testDebugUnitTest :app:assembleDebug :app:assembleRelease :app:lintDebug +.\gradlew.bat :app:assembleDebugAndroidTest +# After connecting an Android device: +.\gradlew.bat :app:connectedDebugAndroidTest +``` + +- Installable debug APK: `app/build/outputs/apk/debug/app-debug.apk`. +- Unsigned release APK: `app/build/outputs/apk/release/app-release-unsigned.apk`. Distribution signing is still required. +- UI test APK: `app/build/outputs/apk/androidTest/debug/app-debug-androidTest.apk`. +- Unit test report: `app/build/reports/tests/testDebugUnitTest/index.html`. +- Lint report: `app/build/reports/lint-results-debug.html`. + +## Remaining release gate + +Connect a phone and the desktop host, then run the instrumentation tests and the smoke test above. Confirm a completed reply, a provider error, and a stopped mobile turn on the actual host. Also verify reconnection after backgrounding and changing networks. No prompts are automatically resent. Draft retention is in memory for the current app process; process-death persistence is outside this scope. + +The existing host protocol does not identify the session on generic error frames and only supports aborting mobile-started turns. The UI therefore exposes Stop only when it has observed confirmation of its own prompt. No desktop or relay changes were made, and nothing has been published or merged by this implementation.