From 07bfc7cbe173d3f87a9ff0003041e7a74717b271 Mon Sep 17 00:00:00 2001 From: Jair Escamilla Date: Sun, 6 Sep 2026 18:05:55 -0600 Subject: [PATCH 01/21] fix: pin chat transcript to the newest message via reverseLayout The chat list drove autoscroll from a followTail flag recomputed as `!listState.canScrollForward` whenever scrolling settled. Streamed markdown grows after each measure pass, so the pass right after a scroll to the bottom made the list scrollable again and cleared the flag by itself, stranding the viewport above the newest message. Two further problems came from the same design: scrollToItem takes the ScrollableState mutex, so it raced user gestures and flings and could be cancelled mid-animation, and the layout -> scroll -> layout loop only terminated because snapshotFlow deduplicates. Invert the ownership instead of patching the flag. With reverseLayout the newest row is index 0 and is drawn against the bottom edge, and the list anchors scrolling on the first visible item, so staying at the bottom becomes a property of measurement rather than something code chases: a growing row is the anchor, so it expands upwards on its own. Only insertion still needs handling, since stable keys keep the anchor on the previously newest row. requestScrollToItem does that without taking the scroll mutex, so it cannot race gestures. Keys are kept, unlike Jetchat, because ReasoningCard and code blocks hold rememberSaveable state that would otherwise shuffle between rows. Flatten the transcript into ChatRow up front so indices are meaningful, and key the list state on a new sessionId so each session keeps its own position -- the old reset compared title and project, which collides between sessions sharing a name. --- .../businessLogic/ChatFullScreenUiState.kt | 1 + .../components/ChatFullScreen.kt | 361 +++++++++--------- .../shared/businessLogic/RoxyAppViewModel.kt | 4 + 3 files changed, 187 insertions(+), 179 deletions(-) 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..ea5a60f 100644 --- a/app/src/main/java/gg/roxy/chatFullscreen/businessLogic/ChatFullScreenUiState.kt +++ b/app/src/main/java/gg/roxy/chatFullscreen/businessLogic/ChatFullScreenUiState.kt @@ -58,6 +58,7 @@ data class ChatMessageUiModel( @Immutable data class ChatFullScreenUiState( + val sessionId: String = "", val sessionTitle: String, val projectName: String, val messages: List, 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..ba52bc5 100644 --- a/app/src/main/java/gg/roxy/chatFullscreen/components/ChatFullScreen.kt +++ b/app/src/main/java/gg/roxy/chatFullscreen/components/ChatFullScreen.kt @@ -18,6 +18,7 @@ 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.items import androidx.compose.foundation.lazy.rememberLazyListState import androidx.compose.foundation.shape.CircleShape import androidx.compose.material.icons.Icons @@ -31,9 +32,12 @@ 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.key +import androidx.compose.runtime.remember import androidx.compose.ui.Alignment import androidx.compose.ui.Modifier import androidx.compose.ui.text.font.FontWeight @@ -43,13 +47,71 @@ 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 +private 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`. */ +private fun buildChatRows( + messages: List, + toolCalls: List, +): List { + val rows = mutableListOf() + + 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 -> ChatRow.Tool(part.tool) + } + } + message.text.isNotBlank() -> rows += ChatRow.Markdown(message.id, message.text) + } + } + + // Fallback for tools not associated with an existing message part. + val renderedToolIds = rows.filterIsInstance().mapTo(mutableSetOf()) { it.tool.id } + val orphanTools = toolCalls.filterNot { it.id in renderedToolIds } + if (orphanTools.isNotEmpty()) rows += ChatRow.OrphanTools(orphanTools) + + rows.reverse() + return rows +} @Composable fun ChatFullScreen( @@ -63,6 +125,35 @@ fun ChatFullScreen( val colors = MaterialTheme.roxyColors BackHandler(onBack = onBackClick) + val rows = remember(uiState.messages, uiState.toolCalls) { + buildChatRows(uiState.messages, uiState.toolCalls) + } + + // Each session gets its own scroll position, so opening one starts on its + // newest row instead of inheriting wherever the previous one was left. + val listState = key(uiState.sessionId) { rememberLazyListState() } + + // The list is reversed, so the anchor item is the newest row: this reads as + // "the viewport is resting against the bottom edge". + val isAtNewestRow by remember(listState) { + derivedStateOf { + listState.firstVisibleItemIndex == 0 && listState.firstVisibleItemScrollOffset == 0 + } + } + + // 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()?.key + LaunchedEffect(newestRowKey) { + // Effects run before this frame's measure pass, so isAtNewestRow still + // describes the layout as it was before the row arrived: a user who had + // scrolled up into history is left alone. + if (isAtNewestRow) listState.requestScrollToItem(0) + } + Column( modifier = modifier .fillMaxSize() @@ -79,184 +170,93 @@ fun ChatFullScreen( ) HorizontalDivider(color = colors.border) - 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 (rows.isEmpty()) { + 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 { + LazyColumn( + state = listState, + modifier = Modifier + .weight(1f) + .fillMaxWidth(), + // 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, ) } } @@ -273,7 +273,13 @@ fun ChatFullScreen( ChatComposer( text = uiState.composerText, onTextChange = onComposerChange, - onSubmit = onComposerSubmit, + onSubmit = { + onComposerSubmit() + // Sending always returns to the newest row, even from deep + // in the history. The request applies to the next measure, + // by which point the sent message is row 0. + listState.requestScrollToItem(0) + }, modifier = Modifier.widthIn(max = 720.dp), ) } @@ -367,9 +373,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/shared/businessLogic/RoxyAppViewModel.kt b/app/src/main/java/gg/roxy/shared/businessLogic/RoxyAppViewModel.kt index 3378144..d044d7c 100644 --- a/app/src/main/java/gg/roxy/shared/businessLogic/RoxyAppViewModel.kt +++ b/app/src/main/java/gg/roxy/shared/businessLogic/RoxyAppViewModel.kt @@ -144,6 +144,7 @@ class RoxyAppViewModel( projects = emptyList(), ), chat = state.chat.copy( + sessionId = "", sessionTitle = "", projectName = "", messages = emptyList(), @@ -572,6 +573,7 @@ class RoxyAppViewModel( connectionError = null, ), chat = state.chat.copy( + sessionId = "", sessionTitle = "", projectName = "", messages = emptyList(), @@ -627,6 +629,7 @@ class RoxyAppViewModel( isComputerMenuExpanded = false, ), chat = state.chat.copy( + sessionId = sessionId, sessionTitle = session.title, projectName = project.name, composerText = "", @@ -755,6 +758,7 @@ private fun initialUiState(): RoxyAppUiState { projects = emptyList(), ), chat = ChatFullScreenUiState( + sessionId = "", sessionTitle = "", projectName = "", messages = emptyList(), From 76d8109d48a7fd7f16534671669c07abb71a2deb Mon Sep 17 00:00:00 2001 From: Jair Escamilla Date: Sun, 6 Sep 2026 18:06:00 -0600 Subject: [PATCH 02/21] feat: segmented PIN entry for the pairing dialog Replace the single-line PIN field with six per-digit cells, backed by one BasicTextField holding the whole PIN rather than one field per cell. That backing choice is what makes backspace erase the PIN digit by digit. With six real fields an empty cell never receives the key event at all -- there is no text to delete, so the IME emits nothing -- and deletion stalls on the first empty cell unless keys are forwarded between fields by hand with onKeyEvent and FocusRequester. One field keeps the caret after the last digit, so deleting right to left is the native behaviour. The caret is also pinned past the last digit on every recomposition, so tapping a cell cannot drop it mid-PIN and desynchronise typing from the cell being looked at, and input is filtered in onValueChange instead of relying on the keyboard type, which covers paste and autofill. Cells divide the available width so they stay equal on any screen; fixed widths overflowed the dialog, and an overflowing Row shrinks whichever child runs out of space last, which left the sixth cell narrower. Height stays fixed because aspectRatio grew the cells vertically once their width was shared. Also give the connect button room for its spinner and label: the default content padding wrapped "Connecting..." onto a second line, which broke the button's vertical centring. The spinner was drawn in colors.bg while the button sits disabled on surface2, making it nearly invisible, so it now uses the disabled content colour. --- .../components/ConnectComputerDialog.kt | 48 +--- .../gg/roxy/shared/components/PinInput.kt | 231 ++++++++++++++++++ 2 files changed, 242 insertions(+), 37 deletions(-) create mode 100644 app/src/main/java/gg/roxy/shared/components/PinInput.kt 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..cad315b 100644 --- a/app/src/main/java/gg/roxy/mainFullscreen/components/ConnectComputerDialog.kt +++ b/app/src/main/java/gg/roxy/mainFullscreen/components/ConnectComputerDialog.kt @@ -8,6 +8,7 @@ import androidx.compose.foundation.layout.Row import androidx.compose.foundation.layout.Spacer import androidx.compose.foundation.layout.fillMaxWidth import androidx.compose.foundation.layout.height +import androidx.compose.foundation.layout.PaddingValues import androidx.compose.foundation.layout.padding import androidx.compose.foundation.layout.size import androidx.compose.foundation.layout.width @@ -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 @@ -41,10 +41,10 @@ import androidx.compose.ui.Modifier 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.components.PinInput import gg.roxy.shared.styles.RoxyMonoFontFamily import gg.roxy.shared.styles.roxyColors @@ -243,39 +243,12 @@ fun ConnectComputerDialog( ), color = colors.textSubtle, ) - OutlinedTextField( + PinInput( value = pinInput, - onValueChange = { if (it.length <= 6) pinInput = it }, + onValueChange = { pinInput = it }, modifier = Modifier.fillMaxWidth(), - placeholder = { - Text( - "e.g. 123456", - style = MaterialTheme.typography.bodySmall, - color = colors.textSubtle, - ) - }, - 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, - ), + enabled = !isConnecting, + isError = errorMessage != null, keyboardActions = KeyboardActions( onDone = { keyboardController?.hide() @@ -329,6 +302,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 +312,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/components/PinInput.kt b/app/src/main/java/gg/roxy/shared/components/PinInput.kt new file mode 100644 index 0000000..aa60d5b --- /dev/null +++ b/app/src/main/java/gg/roxy/shared/components/PinInput.kt @@ -0,0 +1,231 @@ +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.remember +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.contentDescription +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.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 = 6, + enabled: Boolean = true, + isError: Boolean = false, + imeAction: ImeAction = ImeAction.Done, + keyboardActions: KeyboardActions = KeyboardActions.Default, +) { + val interactionSource = remember { MutableInteractionSource() } + val isFocused by interactionSource.collectIsFocusedAsState() + + // Rebuilt on every recomposition with the caret pinned past the last digit, + // so a tap on any box cannot drop the caret into the middle of the PIN and + // desynchronise typing from the box the user is looking at. + val 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) + if (digits != value) onValueChange(digits) + }, + modifier = modifier.semantics { + contentDescription = "$length digit PIN, ${value.length} entered" + }, + 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 { + isError -> colors.danger + isActive -> colors.accent + 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) + } + } +} From 9da7b8c1f86c4c595d647540a26e5aee5da20efe Mon Sep 17 00:00:00 2001 From: Jair Escamilla Date: Sun, 6 Sep 2026 19:01:32 -0600 Subject: [PATCH 03/21] fix: resync PIN field when input is rejected BasicTextField's TextFieldValue overload is fully controlled: its internal buffer only follows the passed value on recomposition. The filter skipped onValueChange when the digits were unchanged, which is exactly the case that needs the resync -- so no recomposition happened and the buffer kept characters the PIN had rejected. Typing a 7th digit into a full PIN left 7 characters in the buffer while 6 were displayed, so the next backspace deleted the invisible one and the visible digit needed a second press. Pasting letters into an empty field left them buffered the same way. Hold the TextFieldValue locally and assign it on every change, so the field is corrected even when the PIN itself does not change. --- .../gg/roxy/shared/components/PinInput.kt | 23 +++++++++++++++---- 1 file changed, 19 insertions(+), 4 deletions(-) diff --git a/app/src/main/java/gg/roxy/shared/components/PinInput.kt b/app/src/main/java/gg/roxy/shared/components/PinInput.kt index aa60d5b..564dc3d 100644 --- a/app/src/main/java/gg/roxy/shared/components/PinInput.kt +++ b/app/src/main/java/gg/roxy/shared/components/PinInput.kt @@ -22,7 +22,9 @@ 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 @@ -70,10 +72,20 @@ fun PinInput( val interactionSource = remember { MutableInteractionSource() } val isFocused by interactionSource.collectIsFocusedAsState() - // Rebuilt on every recomposition with the caret pinned past the last digit, - // so a tap on any box cannot drop the caret into the middle of the PIN and - // desynchronise typing from the box the user is looking at. - val fieldValue = TextFieldValue(text = value, selection = TextRange(value.length)) + // 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, @@ -81,6 +93,9 @@ fun PinInput( // 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 { From 8b3dc83bf66b041062984ea4648e5948f6e45c84 Mon Sep 17 00:00:00 2001 From: Jair Escamilla Date: Sun, 6 Sep 2026 19:03:00 -0600 Subject: [PATCH 04/21] fix: keep orphan tool calls from owning the newest chat row buildChatRows appended the orphan tool pile before reversing, so whenever it existed it became index 0 for good, and its key is a constant. That defeated both halves of the pin: an incoming message landed at index 1 leaving the newest key unchanged, so the LaunchedEffect never fired and the message stayed off screen, and requestScrollToItem(0) on submit scrolled to the tool pile instead of the sent message. Orphans are reachable in production -- SnapshotReceived takes event.tools as-is without requiring the tools to appear in parts, so any desktop snapshot carrying a non-empty tools list produces them. They have no chronological anchor, so prepend them instead: they render at the oldest end of the transcript and the newest row is always a real message. Fixing the ordering rather than excluding them from the newest-key computation also corrects the submit scroll, which the narrower fix would have left pointing at the pile. --- .../gg/roxy/chatFullscreen/components/ChatFullScreen.kt | 9 +++++++-- 1 file changed, 7 insertions(+), 2 deletions(-) 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 ba52bc5..1c4ff75 100644 --- a/app/src/main/java/gg/roxy/chatFullscreen/components/ChatFullScreen.kt +++ b/app/src/main/java/gg/roxy/chatFullscreen/components/ChatFullScreen.kt @@ -104,10 +104,15 @@ private fun buildChatRows( } } - // Fallback for tools not associated with an existing message part. + // 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. val renderedToolIds = rows.filterIsInstance().mapTo(mutableSetOf()) { it.tool.id } val orphanTools = toolCalls.filterNot { it.id in renderedToolIds } - if (orphanTools.isNotEmpty()) rows += ChatRow.OrphanTools(orphanTools) + if (orphanTools.isNotEmpty()) rows.add(0, ChatRow.OrphanTools(orphanTools)) rows.reverse() return rows From 9e74f8da3fe8150ca282ffddb0811ab8b63afa0d Mon Sep 17 00:00:00 2001 From: Jair Escamilla Date: Sun, 6 Sep 2026 19:04:41 -0600 Subject: [PATCH 05/21] fix: derive streamed part ids from the turn that owns them TurnChanged minted a fresh UUID for the in-flight text part on every event, not just when the turn closed. Each streaming chunk therefore changed the row key, which the list reads as a removal plus an insertion: the row was rebuilt, its layout state discarded, and LaunchedEffect on the newest key re-fired spuriously. The old autoscroll survived this by re-pinning every frame by brute force. Anchoring the viewport on keys makes their stability a correctness requirement, so fix it here. Reuse the containing message's id, which is already stable across the turn, and shape the part id like the snapshot parser's "\$id-text-\$p" so the key also survives the streamed turn being replaced by the server-sent version when the turn closes. --- .../shared/businessLogic/RoxyAppViewModel.kt | 21 ++++++++++++++++--- 1 file changed, 18 insertions(+), 3 deletions(-) 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 d044d7c..2075f01 100644 --- a/app/src/main/java/gg/roxy/shared/businessLogic/RoxyAppViewModel.kt +++ b/app/src/main/java/gg/roxy/shared/businessLogic/RoxyAppViewModel.kt @@ -376,23 +376,38 @@ class RoxyAppViewModel( } if (event.inFlightTools.isNotEmpty() || event.inFlightText != null) { + // The turn streams as a whole message per event, so the row + // keys have to be derived from the message that owns them. + // A fresh id per event would rebuild the streaming row on + // every chunk, discarding its layout state and making the + // list treat it as a removal plus an insertion. + val isNewTurn = currentMessages.isEmpty() || currentMessages.last().isUser + val turnId = if (isNewTurn) { + UUID.randomUUID().toString() + } else { + currentMessages.last().id + } + val inFlightParts = mutableListOf() event.inFlightTools.forEach { tool -> inFlightParts.add(ChatPartUiModel.Tool(tool)) } if (event.inFlightText != null) { + // Matches the shape the snapshot parser emits, so the key + // survives the streamed turn being replaced by its + // server-sent version once the turn closes. inFlightParts.add( ChatPartUiModel.Text( - id = UUID.randomUUID().toString(), + id = "$turnId-text-0", text = event.inFlightText, ) ) } - if (currentMessages.isEmpty() || currentMessages.last().isUser) { + if (isNewTurn) { currentMessages.add( ChatMessageUiModel( - id = UUID.randomUUID().toString(), + id = turnId, isUser = false, parts = inFlightParts, ) From 1e8f918a9bc683489ffdd539b2c212dec97c3a38 Mon Sep 17 00:00:00 2001 From: Jair Escamilla Date: Sun, 6 Sep 2026 19:06:33 -0600 Subject: [PATCH 06/21] fix: stop unrelated connection errors from marking the PIN cells isError was wired to errorMessage != null, but connectionError is a single field shared with QR scanning, so "Invalid QR code: no Roxy connection token found." and "QR Scanner error: ..." painted the PIN cells red despite saying nothing about the PIN. Nothing cleared it as the user typed either, since the error lives in the view model and is only reset by another connection attempt. Drop the cell marking once the PIN is edited. The banner still shows the message, so no information is lost, but the cells stop attributing the failure to the digits. Also let the active cell outrank the error state in PinCell. The error arm came first, so an errored field lost its active-cell highlight entirely and the user had no visible entry point while retrying. --- .../components/ConnectComputerDialog.kt | 14 ++++++++++++-- .../java/gg/roxy/shared/components/PinInput.kt | 4 +++- 2 files changed, 15 insertions(+), 3 deletions(-) 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 cad315b..d2519cb 100644 --- a/app/src/main/java/gg/roxy/mainFullscreen/components/ConnectComputerDialog.kt +++ b/app/src/main/java/gg/roxy/mainFullscreen/components/ConnectComputerDialog.kt @@ -65,6 +65,13 @@ fun ConnectComputerDialog( var pinInput by remember(initialPin) { mutableStateOf(initialPin) } val keyboardController = LocalSoftwareKeyboardController.current + // 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 == 6 && !isConnecting Dialog(onDismissRequest = onDismiss) { @@ -245,10 +252,13 @@ fun ConnectComputerDialog( ) PinInput( value = pinInput, - onValueChange = { pinInput = it }, + onValueChange = { + pinInput = it + pinEditedSinceError = true + }, modifier = Modifier.fillMaxWidth(), enabled = !isConnecting, - isError = errorMessage != null, + isError = isPinError, keyboardActions = KeyboardActions( onDone = { keyboardController?.hide() diff --git a/app/src/main/java/gg/roxy/shared/components/PinInput.kt b/app/src/main/java/gg/roxy/shared/components/PinInput.kt index 564dc3d..311c072 100644 --- a/app/src/main/java/gg/roxy/shared/components/PinInput.kt +++ b/app/src/main/java/gg/roxy/shared/components/PinInput.kt @@ -156,8 +156,10 @@ private fun PinCell( val borderColor by animateColorAsState( targetValue = when { - isError -> colors.danger + // 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 }, From a618efabf927ab356d41134bcad2b43d114a3e2a Mon Sep 17 00:00:00 2001 From: Jair Escamilla Date: Sun, 6 Sep 2026 19:07:52 -0600 Subject: [PATCH 07/21] refactor: drop the unused chat sessionId RoxyApp switches destinations with a when, so ChatFullScreen leaves the composition on the way back to Main and its list state is recreated anyway. openSession is only reachable from the session list, so there is no chat-to-chat transition: the sessionId could only change while mounted during disconnect, which clears the messages regardless. The field and its four assignments existed purely to key the list state on something navigation already guarantees, and the comment claimed a behaviour that was never the reason it worked. Remove it rather than document it as defensive -- if in-place session switching is added, the list state can be keyed then, at which point it will actually do something. --- .../chatFullscreen/businessLogic/ChatFullScreenUiState.kt | 1 - .../java/gg/roxy/chatFullscreen/components/ChatFullScreen.kt | 5 +---- .../java/gg/roxy/shared/businessLogic/RoxyAppViewModel.kt | 4 ---- 3 files changed, 1 insertion(+), 9 deletions(-) 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 ea5a60f..5748297 100644 --- a/app/src/main/java/gg/roxy/chatFullscreen/businessLogic/ChatFullScreenUiState.kt +++ b/app/src/main/java/gg/roxy/chatFullscreen/businessLogic/ChatFullScreenUiState.kt @@ -58,7 +58,6 @@ data class ChatMessageUiModel( @Immutable data class ChatFullScreenUiState( - val sessionId: String = "", val sessionTitle: String, val projectName: String, val messages: List, 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 1c4ff75..2052be3 100644 --- a/app/src/main/java/gg/roxy/chatFullscreen/components/ChatFullScreen.kt +++ b/app/src/main/java/gg/roxy/chatFullscreen/components/ChatFullScreen.kt @@ -36,7 +36,6 @@ import androidx.compose.runtime.Immutable import androidx.compose.runtime.LaunchedEffect import androidx.compose.runtime.derivedStateOf import androidx.compose.runtime.getValue -import androidx.compose.runtime.key import androidx.compose.runtime.remember import androidx.compose.ui.Alignment import androidx.compose.ui.Modifier @@ -134,9 +133,7 @@ fun ChatFullScreen( buildChatRows(uiState.messages, uiState.toolCalls) } - // Each session gets its own scroll position, so opening one starts on its - // newest row instead of inheriting wherever the previous one was left. - val listState = key(uiState.sessionId) { rememberLazyListState() } + val listState = rememberLazyListState() // The list is reversed, so the anchor item is the newest row: this reads as // "the viewport is resting against the bottom edge". 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 2075f01..a40e73d 100644 --- a/app/src/main/java/gg/roxy/shared/businessLogic/RoxyAppViewModel.kt +++ b/app/src/main/java/gg/roxy/shared/businessLogic/RoxyAppViewModel.kt @@ -144,7 +144,6 @@ class RoxyAppViewModel( projects = emptyList(), ), chat = state.chat.copy( - sessionId = "", sessionTitle = "", projectName = "", messages = emptyList(), @@ -588,7 +587,6 @@ class RoxyAppViewModel( connectionError = null, ), chat = state.chat.copy( - sessionId = "", sessionTitle = "", projectName = "", messages = emptyList(), @@ -644,7 +642,6 @@ class RoxyAppViewModel( isComputerMenuExpanded = false, ), chat = state.chat.copy( - sessionId = sessionId, sessionTitle = session.title, projectName = project.name, composerText = "", @@ -773,7 +770,6 @@ private fun initialUiState(): RoxyAppUiState { projects = emptyList(), ), chat = ChatFullScreenUiState( - sessionId = "", sessionTitle = "", projectName = "", messages = emptyList(), From a3cfc1034524f1130d9d2ccec9714d1997d34411 Mon Sep 17 00:00:00 2001 From: Jair Escamilla Date: Sun, 6 Sep 2026 21:31:32 -0600 Subject: [PATCH 08/21] fix: gate the chat empty state on transcript emptiness rows describes renderable rows, not whether the session has a transcript. An assistant message with no parts and blank text legitimately produces zero rows, but the session is not empty and should not show "No messages yet. Send a prompt to get started." The previous condition regressed the old messages.isEmpty() && toolCalls.isEmpty() check by conflating those two meanings. Restore the semantic check against the source UI state and leave the list branch responsible only for rendering whatever rows exist. --- .../java/gg/roxy/chatFullscreen/components/ChatFullScreen.kt | 3 ++- 1 file changed, 2 insertions(+), 1 deletion(-) 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 2052be3..1daa1ae 100644 --- a/app/src/main/java/gg/roxy/chatFullscreen/components/ChatFullScreen.kt +++ b/app/src/main/java/gg/roxy/chatFullscreen/components/ChatFullScreen.kt @@ -132,6 +132,7 @@ fun ChatFullScreen( val rows = remember(uiState.messages, uiState.toolCalls) { buildChatRows(uiState.messages, uiState.toolCalls) } + val isSessionEmpty = uiState.messages.isEmpty() && uiState.toolCalls.isEmpty() val listState = rememberLazyListState() @@ -172,7 +173,7 @@ fun ChatFullScreen( ) HorizontalDivider(color = colors.border) - if (rows.isEmpty()) { + if (isSessionEmpty) { Box( modifier = Modifier .weight(1f) From 0d66adc1abb0db8649ee117cc33cdd3b2d9a673a Mon Sep 17 00:00:00 2001 From: Jair Escamilla Date: Sun, 6 Sep 2026 21:33:19 -0600 Subject: [PATCH 09/21] perf: document chat row flattening cost and avoid a second pass buildChatRows is rebuilt on every streaming message update, so it does an O(n) transcript walk per chunk. Keep that transformation in the UI layer: rows are presentation state for LazyColumn, and moving the same walk into the ViewModel would couple business state to composable row shapes without reducing the work. Make the trade-off explicit, and remove the avoidable extra pass over rows used to recover rendered tool ids. Record those ids while walking message parts instead, and only allocate the set when the snapshot actually has tools to compare against. --- .../components/ChatFullScreen.kt | 23 +++++++++++++++---- 1 file changed, 18 insertions(+), 5 deletions(-) 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 1daa1ae..bf30266 100644 --- a/app/src/main/java/gg/roxy/chatFullscreen/components/ChatFullScreen.kt +++ b/app/src/main/java/gg/roxy/chatFullscreen/components/ChatFullScreen.kt @@ -81,12 +81,21 @@ private sealed interface ChatRow { } } -/** Flattens the transcript into newest-first order, ready for [LazyColumn]'s `reverseLayout`. */ +/** + * 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. + */ private fun buildChatRows( messages: List, toolCalls: List, ): List { val rows = mutableListOf() + val renderedToolIds = if (toolCalls.isEmpty()) null else mutableSetOf() messages.forEach { message -> when { @@ -96,7 +105,10 @@ private fun buildChatRows( rows += when (part) { is ChatPartUiModel.Text -> ChatRow.Markdown(part.id, part.text) is ChatPartUiModel.Reasoning -> ChatRow.Reasoning(part) - is ChatPartUiModel.Tool -> ChatRow.Tool(part.tool) + is ChatPartUiModel.Tool -> { + renderedToolIds?.add(part.tool.id) + ChatRow.Tool(part.tool) + } } } message.text.isNotBlank() -> rows += ChatRow.Markdown(message.id, message.text) @@ -109,9 +121,10 @@ private fun buildChatRows( // 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. - val renderedToolIds = rows.filterIsInstance().mapTo(mutableSetOf()) { it.tool.id } - val orphanTools = toolCalls.filterNot { it.id in renderedToolIds } - if (orphanTools.isNotEmpty()) rows.add(0, ChatRow.OrphanTools(orphanTools)) + renderedToolIds?.let { ids -> + val orphanTools = toolCalls.filterNot { it.id in ids } + if (orphanTools.isNotEmpty()) rows.add(0, ChatRow.OrphanTools(orphanTools)) + } rows.reverse() return rows From 38b0e6423f78355b86cf6dc40978cf24b7cc5df7 Mon Sep 17 00:00:00 2001 From: Jair Escamilla Date: Sun, 6 Sep 2026 21:34:15 -0600 Subject: [PATCH 10/21] chore: keep ConnectComputerDialog imports ordered Move PaddingValues back into the capitalized layout import group so the file keeps its existing import ordering convention. --- .../gg/roxy/mainFullscreen/components/ConnectComputerDialog.kt | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) 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 d2519cb..39915c5 100644 --- a/app/src/main/java/gg/roxy/mainFullscreen/components/ConnectComputerDialog.kt +++ b/app/src/main/java/gg/roxy/mainFullscreen/components/ConnectComputerDialog.kt @@ -4,11 +4,11 @@ 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 import androidx.compose.foundation.layout.height -import androidx.compose.foundation.layout.PaddingValues import androidx.compose.foundation.layout.padding import androidx.compose.foundation.layout.size import androidx.compose.foundation.layout.width From f8e08fb68a3c8fb08bf1237a3dc7f5e64e253424 Mon Sep 17 00:00:00 2001 From: Jair Escamilla Date: Sun, 6 Sep 2026 22:17:16 -0600 Subject: [PATCH 11/21] fix: make chat follow-tail tolerate small offsets A reversed chat list should keep following the newest row when the viewport is effectively resting on it, not only when its scroll offset is exactly zero. A one-pixel offset after a fling or nested scroll should not opt the user out of autoscroll. Use a viewport-relative threshold for the follow-tail check, persist the LazyListState with its saver, and show an explicit Latest affordance when new rows arrive while the user is reading history. Sending remains the only forced jump to row 0 because a user-submitted prompt is explicit navigation back to the live edge. Expose buildChatRows internally and cover row ordering/key behaviour with unit tests, including orphan tools at the oldest end. --- .../components/ChatFullScreen.kt | 101 ++++++++++++------ .../chatFullscreen/components/ChatRowsTest.kt | 79 ++++++++++++++ 2 files changed, 150 insertions(+), 30 deletions(-) create mode 100644 app/src/test/java/gg/roxy/chatFullscreen/components/ChatRowsTest.kt 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 bf30266..bb2c75c 100644 --- a/app/src/main/java/gg/roxy/chatFullscreen/components/ChatFullScreen.kt +++ b/app/src/main/java/gg/roxy/chatFullscreen/components/ChatFullScreen.kt @@ -18,12 +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.LazyListState import androidx.compose.foundation.lazy.items -import androidx.compose.foundation.lazy.rememberLazyListState 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 @@ -36,7 +38,10 @@ import androidx.compose.runtime.Immutable import androidx.compose.runtime.LaunchedEffect 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 @@ -56,7 +61,7 @@ import gg.roxy.shared.styles.roxyColors * is what pins the viewport to the bottom of the conversation. */ @Immutable -private sealed interface ChatRow { +internal sealed interface ChatRow { val key: String @Immutable @@ -90,7 +95,7 @@ private sealed interface ChatRow { * 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. */ -private fun buildChatRows( +internal fun buildChatRows( messages: List, toolCalls: List, ): List { @@ -147,27 +152,40 @@ fun ChatFullScreen( } val isSessionEmpty = uiState.messages.isEmpty() && uiState.toolCalls.isEmpty() - val listState = rememberLazyListState() + val listState = rememberSaveable(saver = LazyListState.Saver) { LazyListState() } - // The list is reversed, so the anchor item is the newest row: this reads as - // "the viewport is resting against the bottom edge". - val isAtNewestRow by remember(listState) { + // 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 { - listState.firstVisibleItemIndex == 0 && listState.firstVisibleItemScrollOffset == 0 + 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()?.key + val newestRowKey = rows.firstOrNull { it !is ChatRow.OrphanTools }?.key LaunchedEffect(newestRowKey) { - // Effects run before this frame's measure pass, so isAtNewestRow still + // 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. - if (isAtNewestRow) listState.requestScrollToItem(0) + // 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( @@ -216,25 +234,28 @@ fun ChatFullScreen( } } } else { - LazyColumn( - state = listState, + Box( modifier = Modifier .weight(1f) .fillMaxWidth(), - // 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) { + 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, @@ -277,6 +298,24 @@ fun ChatFullScreen( } } } + 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( @@ -291,10 +330,12 @@ fun ChatFullScreen( onTextChange = onComposerChange, onSubmit = { onComposerSubmit() - // Sending always returns to the newest row, even from deep - // in the history. The request applies to the next measure, - // by which point the sent message is row 0. + // 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), ) 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 }) + } +} From d333cd7b6f1acacb708d8b7e9af4e49e437ea77f Mon Sep 17 00:00:00 2001 From: Jair Escamilla Date: Sun, 6 Sep 2026 22:17:27 -0600 Subject: [PATCH 12/21] fix: preserve live turn parts while streaming The turn frame parser collapsed text and reasoning parts into one inFlightText blob. That made ReasoningCard disappear during streaming and then reappear only after the final snapshot, and it made the stable row key work depend on a synthetic single text part that did not match the snapshot shape. Emit the live parts from RemoteWorkspaceClient alongside the legacy inFlightText summary, and let the ViewModel rebuild their ids from the stable containing message id plus the source part index. That keeps text and reasoning rows stable across chunks and aligned with the snapshot ids ("-text-" / "-reasoning-"). Add regression coverage for parser output and ViewModel key stability. --- .../shared/businessLogic/RoxyAppViewModel.kt | 47 ++++++++-------- .../roxy/shared/data/RemoteWorkspaceClient.kt | 56 ++++++++++++------- .../businessLogic/RoxyAppViewModelTest.kt | 50 ++++++++++++++++- .../shared/data/RemoteWorkspaceClientTest.kt | 6 ++ 4 files changed, 115 insertions(+), 44 deletions(-) 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 a40e73d..ba27f69 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 @@ -374,12 +375,11 @@ class RoxyAppViewModel( ) } - if (event.inFlightTools.isNotEmpty() || event.inFlightText != null) { - // The turn streams as a whole message per event, so the row - // keys have to be derived from the message that owns them. - // A fresh id per event would rebuild the streaming row on - // every chunk, discarding its layout state and making the - // list treat it as a removal plus an insertion. + 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() @@ -387,20 +387,21 @@ class RoxyAppViewModel( currentMessages.last().id } - val inFlightParts = mutableListOf() - event.inFlightTools.forEach { tool -> - inFlightParts.add(ChatPartUiModel.Tool(tool)) - } - if (event.inFlightText != null) { - // Matches the shape the snapshot parser emits, so the key - // survives the streamed turn being replaced by its - // server-sent version once the turn closes. - inFlightParts.add( - ChatPartUiModel.Text( - id = "$turnId-text-0", - 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 (isNewTurn) { @@ -523,7 +524,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( @@ -543,7 +544,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, ) ) @@ -754,6 +755,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/data/RemoteWorkspaceClient.kt b/app/src/main/java/gg/roxy/shared/data/RemoteWorkspaceClient.kt index 6bfdc71..602ca6d 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 @@ -45,6 +46,7 @@ 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 @@ -100,7 +102,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 } @@ -383,21 +385,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,27 +414,33 @@ 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)) + _events.emit( + RemoteEvent.TurnChanged( + sessionId = sessionId, + isRunning = isRunning, + userText = userText, + inFlightText = textParts.takeIf { it.isNotEmpty() }?.joinToString("\n\n"), + inFlightParts = inFlightParts, + inFlightTools = inFlightTools, + ) + ) } } "error" -> { 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..bbb2e09 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 @@ -226,6 +228,52 @@ class RoxyAppViewModelTest { assertEquals("Checking logs now... All clear!", viewModel.uiState.value.chat.messages[1].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 +300,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 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..8991708 100644 --- a/app/src/test/java/gg/roxy/shared/data/RemoteWorkspaceClientTest.kt +++ b/app/src/test/java/gg/roxy/shared/data/RemoteWorkspaceClientTest.kt @@ -1,5 +1,6 @@ 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 @@ -233,6 +234,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) From 9cc2a4aabcd1309b114158a8efcadac8b9ccd0d4 Mon Sep 17 00:00:00 2001 From: Jair Escamilla Date: Sun, 6 Sep 2026 22:17:39 -0600 Subject: [PATCH 13/21] fix: tighten pairing PIN flow and constants Keep the pairing PIN length in one shared constant instead of scattering literal 6 checks across the dialog, parser, client validation, and PinInput default. Make the token field's Next action focus the PIN, and focus the PIN immediately when a QR scan prefills the token but still needs manual PIN entry. PinInput now keeps the editable text field's own semantics and marks it as a password instead of overriding the same node with a custom contentDescription that conflicted with EditableText in TalkBack. Add an instrumented Compose regression test for rejected input: pasting letters plus a seventh digit should not leave an invisible character in the IME buffer that requires an extra backspace. --- .../gg/roxy/shared/components/PinInputTest.kt | 49 +++++++++++++++++++ .../components/ConnectComputerDialog.kt | 20 ++++++-- .../java/gg/roxy/shared/PairingConstants.kt | 3 ++ .../gg/roxy/shared/components/PinInput.kt | 7 +-- .../java/gg/roxy/shared/data/RemoteModels.kt | 6 ++- 5 files changed, 77 insertions(+), 8 deletions(-) create mode 100644 app/src/androidTest/java/gg/roxy/shared/components/PinInputTest.kt create mode 100644 app/src/main/java/gg/roxy/shared/PairingConstants.kt 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/mainFullscreen/components/ConnectComputerDialog.kt b/app/src/main/java/gg/roxy/mainFullscreen/components/ConnectComputerDialog.kt index 39915c5..b2e149c 100644 --- a/app/src/main/java/gg/roxy/mainFullscreen/components/ConnectComputerDialog.kt +++ b/app/src/main/java/gg/roxy/mainFullscreen/components/ConnectComputerDialog.kt @@ -32,18 +32,22 @@ 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.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,6 +68,7 @@ fun ConnectComputerDialog( var tokenInput by remember(initialTokenOrUrl) { mutableStateOf(initialTokenOrUrl) } var pinInput by remember(initialPin) { mutableStateOf(initialPin) } val keyboardController = LocalSoftwareKeyboardController.current + val pinFocusRequester = remember { FocusRequester() } // connectionError is a single field shared with QR scanning, so it also // carries failures that say nothing about the PIN. Stop marking the cells @@ -72,7 +77,13 @@ fun ConnectComputerDialog( var pinEditedSinceError by remember(errorMessage) { mutableStateOf(false) } val isPinError = errorMessage != null && !pinEditedSinceError - val canConnect = tokenInput.isNotBlank() && pinInput.trim().length == 6 && !isConnecting + 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( @@ -236,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, @@ -256,7 +268,9 @@ fun ConnectComputerDialog( pinInput = it pinEditedSinceError = true }, - modifier = Modifier.fillMaxWidth(), + modifier = Modifier + .fillMaxWidth() + .focusRequester(pinFocusRequester), enabled = !isConnecting, isError = isPinError, keyboardActions = KeyboardActions( 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/components/PinInput.kt b/app/src/main/java/gg/roxy/shared/components/PinInput.kt index 311c072..cbe6e24 100644 --- a/app/src/main/java/gg/roxy/shared/components/PinInput.kt +++ b/app/src/main/java/gg/roxy/shared/components/PinInput.kt @@ -31,7 +31,7 @@ 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.contentDescription +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 @@ -44,6 +44,7 @@ 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 @@ -63,7 +64,7 @@ fun PinInput( value: String, onValueChange: (String) -> Unit, modifier: Modifier = Modifier, - length: Int = 6, + length: Int = PAIRING_PIN_LENGTH, enabled: Boolean = true, isError: Boolean = false, imeAction: ImeAction = ImeAction.Done, @@ -99,7 +100,7 @@ fun PinInput( if (digits != value) onValueChange(digits) }, modifier = modifier.semantics { - contentDescription = "$length digit PIN, ${value.length} entered" + password() }, enabled = enabled, keyboardOptions = KeyboardOptions( 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, From 0b01e027bedbb3811050b70054d6ec05b0c53ebe Mon Sep 17 00:00:00 2001 From: Jair Escamilla Date: Mon, 7 Sep 2026 13:32:10 -0600 Subject: [PATCH 14/21] chore: include model selector cleanup as v1 baseline --- .../chatFullscreen/components/ChatComposer.kt | 395 +----------------- docs/model-selector-roadmap.md | 72 ++++ 2 files changed, 78 insertions(+), 389 deletions(-) create mode 100644 docs/model-selector-roadmap.md 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 0f7615e..c89d583 100644 --- a/app/src/main/java/gg/roxy/chatFullscreen/components/ChatComposer.kt +++ b/app/src/main/java/gg/roxy/chatFullscreen/components/ChatComposer.kt @@ -1,8 +1,6 @@ package gg.roxy.chatFullscreen.components import androidx.compose.foundation.BorderStroke -import androidx.compose.foundation.background -import androidx.compose.foundation.layout.Arrangement import androidx.compose.foundation.layout.Box import androidx.compose.foundation.layout.Column import androidx.compose.foundation.layout.Row @@ -10,7 +8,6 @@ import androidx.compose.foundation.layout.Spacer import androidx.compose.foundation.layout.fillMaxWidth import androidx.compose.foundation.layout.height import androidx.compose.foundation.layout.heightIn -import androidx.compose.foundation.layout.navigationBarsPadding import androidx.compose.foundation.layout.padding import androidx.compose.foundation.layout.size import androidx.compose.foundation.layout.width @@ -19,94 +16,32 @@ 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.foundation.Canvas -import androidx.compose.foundation.clickable -import androidx.compose.foundation.lazy.LazyColumn -import androidx.compose.foundation.lazy.items 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.AutoAwesome -import androidx.compose.material.icons.rounded.Build -import androidx.compose.material.icons.rounded.Check -import androidx.compose.material.icons.rounded.Close -import androidx.compose.material.icons.rounded.Psychology -import androidx.compose.material.icons.rounded.PushPin -import androidx.compose.material.icons.rounded.Schedule -import androidx.compose.material.icons.rounded.Search -import androidx.compose.material.icons.rounded.UnfoldMore -import androidx.compose.material3.ExperimentalMaterial3Api import androidx.compose.material3.Icon -import androidx.compose.material3.IconButton import androidx.compose.material3.MaterialTheme -import androidx.compose.material3.ModalBottomSheet import androidx.compose.material3.Surface import androidx.compose.material3.Text -import androidx.compose.material3.rememberModalBottomSheetState import androidx.compose.runtime.Composable -import androidx.compose.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.geometry.Offset import androidx.compose.ui.graphics.Color import androidx.compose.ui.graphics.SolidColor -import androidx.compose.ui.graphics.StrokeCap import androidx.compose.ui.semantics.contentDescription import androidx.compose.ui.semantics.semantics -import androidx.compose.ui.text.font.FontWeight import androidx.compose.ui.text.input.ImeAction import androidx.compose.ui.unit.dp -import androidx.compose.ui.unit.sp -import gg.roxy.shared.styles.RoxyMonoFontFamily import gg.roxy.shared.styles.roxyColors -enum class ModelProvider { - Anthropic, - Google, -} - -data class RoxyModelItem( - val id: String, - val provider: ModelProvider, - val section: String, - val hasReasoning: Boolean = true, - val hasTools: Boolean = true, - val isPinned: Boolean = true, -) - -val DesktopRoxyModels = listOf( - // PINNED - RoxyModelItem("claude-sonnet-4-6", ModelProvider.Anthropic, "PINNED"), - RoxyModelItem("gemini-pro-agent", ModelProvider.Google, "PINNED"), - RoxyModelItem("claude-opus-4-6-thinking", ModelProvider.Anthropic, "PINNED"), - RoxyModelItem("claude-opus-5", ModelProvider.Anthropic, "PINNED"), - RoxyModelItem("claude-sonnet-5", ModelProvider.Anthropic, "PINNED"), - RoxyModelItem("gemini-3.1-pro-low", ModelProvider.Google, "PINNED"), - RoxyModelItem("gemini-3.8-flash-high", ModelProvider.Google, "PINNED"), - - // LATEST - CLAUDE (SUBSCRIPTION) - RoxyModelItem("claude-sonnet-4-6", ModelProvider.Anthropic, "LATEST - CLAUDE (SUBSCRIPTION)", isPinned = false), - - // CLAUDE (SUBSCRIPTION) - RoxyModelItem("claude-opus-5", ModelProvider.Anthropic, "CLAUDE (SUBSCRIPTION)", isPinned = true), -) - -@OptIn(ExperimentalMaterial3Api::class) @Composable fun ChatComposer( text: String, onTextChange: (String) -> Unit, onSubmit: () -> Unit, modifier: Modifier = Modifier, - initialModel: String = "gemini-3.8-flash-high", ) { val colors = MaterialTheme.roxyColors - var currentModel by rememberSaveable { mutableStateOf(initialModel) } - var showModelSheet by rememberSaveable { mutableStateOf(false) } Surface( modifier = modifier.fillMaxWidth(), @@ -172,40 +107,12 @@ fun ChatComposer( Spacer(Modifier.width(8.dp)) - // Model Selector Pill (Interactive) - Surface( - onClick = { showModelSheet = true }, - shape = CircleShape, - color = colors.elevated, - border = BorderStroke(1.dp, colors.edgeStrong), - ) { - Row( - modifier = Modifier.padding(horizontal = 9.dp, vertical = 6.dp), - verticalAlignment = Alignment.CenterVertically, - horizontalArrangement = Arrangement.spacedBy(5.dp), - ) { - Icon( - imageVector = Icons.Rounded.AutoAwesome, - contentDescription = null, - modifier = Modifier.size(13.dp), - tint = colors.accent, - ) - Text( - text = currentModel, - style = MaterialTheme.typography.labelSmall, - fontWeight = FontWeight.Medium, - color = colors.text, - ) - Icon( - imageVector = Icons.Rounded.UnfoldMore, - contentDescription = "Change model", - modifier = Modifier.size(14.dp), - tint = colors.textSubtle, - ) - } - } - - Spacer(Modifier.weight(1f)) + Text( + text = "Uses this session's desktop model", + style = MaterialTheme.typography.labelSmall, + color = colors.textMuted, + modifier = Modifier.weight(1f).padding(end = 8.dp), + ) // Send Button with clean default theme (White when active) val isSendActive = text.isNotBlank() @@ -229,294 +136,4 @@ fun ChatComposer( } } } - - if (showModelSheet) { - ModelSelectorBottomSheet( - selectedModel = currentModel, - onModelSelected = { modelId -> - currentModel = modelId - showModelSheet = false - }, - onDismiss = { showModelSheet = false }, - ) - } -} - -@OptIn(ExperimentalMaterial3Api::class) -@Composable -fun ModelSelectorBottomSheet( - selectedModel: String, - onModelSelected: (String) -> Unit, - onDismiss: () -> Unit, -) { - val colors = MaterialTheme.roxyColors - val sheetState = rememberModalBottomSheetState(skipPartiallyExpanded = true) - var searchQuery by rememberSaveable { mutableStateOf("") } - - val filteredModels = remember(searchQuery) { - if (searchQuery.isBlank()) { - DesktopRoxyModels - } else { - DesktopRoxyModels.filter { it.id.contains(searchQuery.trim(), ignoreCase = true) } - } - } - - val groupedModels = remember(filteredModels) { - filteredModels.groupBy { it.section } - } - - ModalBottomSheet( - onDismissRequest = onDismiss, - sheetState = sheetState, - containerColor = Color(0xFF131418), - contentColor = Color(0xFFEEEEEE), - scrimColor = Color.Black.copy(alpha = 0.65f), - shape = RoundedCornerShape(topStart = 20.dp, topEnd = 20.dp), - dragHandle = { - Box( - modifier = Modifier - .padding(vertical = 10.dp) - .size(width = 36.dp, height = 4.dp) - .background(Color(0xFF2E2F38), CircleShape), - ) - }, - ) { - Column( - modifier = Modifier - .fillMaxWidth() - .navigationBarsPadding() - .padding(horizontal = 16.dp, vertical = 6.dp), - verticalArrangement = Arrangement.spacedBy(10.dp), - ) { - // Search Bar matching desktop popup - Surface( - shape = RoundedCornerShape(9.dp), - color = Color(0xFF1C1D23), - border = BorderStroke(1.dp, Color(0xFF2B2C36)), - modifier = Modifier.fillMaxWidth(), - ) { - Row( - modifier = Modifier - .fillMaxWidth() - .padding(horizontal = 12.dp, vertical = 8.dp), - verticalAlignment = Alignment.CenterVertically, - horizontalArrangement = Arrangement.spacedBy(9.dp), - ) { - Icon( - imageVector = Icons.Rounded.Search, - contentDescription = "Search", - tint = Color(0xFF7A7C88), - modifier = Modifier.size(16.dp), - ) - BasicTextField( - value = searchQuery, - onValueChange = { searchQuery = it }, - textStyle = MaterialTheme.typography.bodyMedium.copy(color = Color(0xFFE4E4E7), fontSize = 13.5.sp), - singleLine = true, - cursorBrush = SolidColor(Color(0xFF38BDF8)), - modifier = Modifier.weight(1f), - decorationBox = { innerTextField -> - if (searchQuery.isEmpty()) { - Text( - text = "Search models...", - style = MaterialTheme.typography.bodyMedium.copy(fontSize = 13.5.sp), - color = Color(0xFF6B6D7A), - ) - } - innerTextField() - }, - ) - if (searchQuery.isNotEmpty()) { - IconButton( - onClick = { searchQuery = "" }, - modifier = Modifier.size(18.dp), - ) { - Icon( - imageVector = Icons.Rounded.Close, - contentDescription = "Clear", - tint = Color(0xFF8E90A0), - modifier = Modifier.size(15.dp), - ) - } - } - } - } - - // Models List grouped by section matching desktop - LazyColumn( - modifier = Modifier - .fillMaxWidth() - .heightIn(max = 480.dp), - verticalArrangement = Arrangement.spacedBy(1.dp), - ) { - groupedModels.forEach { (section, models) -> - item(key = "header_$section") { - Row( - modifier = Modifier - .fillMaxWidth() - .padding(top = 8.dp, bottom = 2.dp, start = 6.dp), - verticalAlignment = Alignment.CenterVertically, - horizontalArrangement = Arrangement.spacedBy(6.dp), - ) { - when (section) { - "PINNED" -> { - Icon( - imageVector = Icons.Rounded.PushPin, - contentDescription = null, - modifier = Modifier.size(12.dp), - tint = Color(0xFF888B98), - ) - } - "LATEST - CLAUDE (SUBSCRIPTION)" -> { - Icon( - imageVector = Icons.Rounded.Schedule, - contentDescription = null, - modifier = Modifier.size(12.dp), - tint = Color(0xFF888B98), - ) - } - else -> { - ClaudeAsteriskIcon(modifier = Modifier.size(12.dp), color = Color(0xFFD97706)) - } - } - Text( - text = section, - style = MaterialTheme.typography.labelSmall.copy( - fontFamily = RoxyMonoFontFamily, - letterSpacing = 1.sp, - fontWeight = FontWeight.SemiBold, - fontSize = 10.5.sp, - ), - color = Color(0xFF888B98), - ) - } - } - - items(models, key = { "${it.section}_${it.id}" }) { model -> - val isSelected = model.id == selectedModel - Surface( - onClick = { onModelSelected(model.id) }, - shape = RoundedCornerShape(6.dp), - color = if (isSelected) Color(0xFF1B2433) else Color.Transparent, - modifier = Modifier.fillMaxWidth(), - ) { - Row( - modifier = Modifier - .fillMaxWidth() - .padding(horizontal = 8.dp, vertical = 5.5.dp), - verticalAlignment = Alignment.CenterVertically, - ) { - // Left checkmark - Box( - modifier = Modifier.size(16.dp), - contentAlignment = Alignment.Center, - ) { - if (isSelected) { - Icon( - imageVector = Icons.Rounded.Check, - contentDescription = "Selected", - modifier = Modifier.size(14.dp), - tint = Color(0xFF38BDF8), - ) - } - } - - Spacer(Modifier.width(6.dp)) - - // Vendor logo - if (model.provider == ModelProvider.Google) { - Icon( - imageVector = Icons.Rounded.AutoAwesome, - contentDescription = "Gemini", - modifier = Modifier.size(14.dp), - tint = Color(0xFF38BDF8), - ) - } else { - ClaudeAsteriskIcon( - modifier = Modifier.size(14.dp), - color = Color(0xFFE06C43), - ) - } - - Spacer(Modifier.width(8.dp)) - - // Model name - Text( - text = model.id, - style = MaterialTheme.typography.bodyMedium.copy( - fontFamily = RoxyMonoFontFamily, - fontSize = 12.5.sp, - ), - fontWeight = if (isSelected) FontWeight.SemiBold else FontWeight.Normal, - color = if (isSelected) Color.White else Color(0xFFD4D4D8), - modifier = Modifier.weight(1f), - ) - - // Right capability badges (Reasoning, Tools, Pin) - Row( - verticalAlignment = Alignment.CenterVertically, - horizontalArrangement = Arrangement.spacedBy(6.dp), - ) { - if (model.hasReasoning) { - Icon( - imageVector = Icons.Rounded.Psychology, - contentDescription = "Reasoning", - modifier = Modifier.size(14.dp), - tint = Color(0xFF38BDF8), - ) - } - if (model.hasTools) { - Icon( - imageVector = Icons.Rounded.Build, - contentDescription = "Tools", - modifier = Modifier.size(13.dp), - tint = Color(0xFF4ADE80), - ) - } - if (model.isPinned) { - Icon( - imageVector = Icons.Rounded.PushPin, - contentDescription = "Pinned", - modifier = Modifier.size(13.dp), - tint = Color(0xFF38BDF8), - ) - } - } - } - } - } - } - } - } - } -} - -@Composable -fun ClaudeAsteriskIcon( - modifier: Modifier = Modifier, - color: Color = Color(0xFFE06C43), -) { - Canvas(modifier = modifier.size(15.dp)) { - val strokeWidth = 1.9.dp.toPx() - val radius = size.minDimension / 2f - val center = Offset(size.width / 2f, size.height / 2f) - for (i in 0 until 8) { - val angle = (i * 45f) * (Math.PI / 180f).toFloat() - val start = Offset( - center.x + (radius * 0.28f) * kotlin.math.cos(angle), - center.y + (radius * 0.28f) * kotlin.math.sin(angle), - ) - val end = Offset( - center.x + radius * kotlin.math.cos(angle), - center.y + radius * kotlin.math.sin(angle), - ) - drawLine( - color = color, - start = start, - end = end, - strokeWidth = strokeWidth, - cap = StrokeCap.Round, - ) - } - } } diff --git a/docs/model-selector-roadmap.md b/docs/model-selector-roadmap.md new file mode 100644 index 0000000..0318cc1 --- /dev/null +++ b/docs/model-selector-roadmap.md @@ -0,0 +1,72 @@ +# Model selector: assessment and roadmap + +Assessment dated September 7, 2026, based on the local Android code and `../roxy` (desktop). The production version and relay implementation have not been verified. + +## Decision + +**A complete implementation has medium-to-high complexity.** The desktop already has a model catalog and per-session configuration, but neither is exposed to mobile clients. Implementation requires extending the remote protocol, adding desktop and Android support, checking the relay, and validating synchronization between clients. + +The Android demo has been removed: its hardcoded catalog, local selection state, search, bottom sheet, and placeholder models. The composer now explains that messages use the model configured for the session on the desktop. Prompt submission behavior is unchanged. + +## Code findings + +| Location | Observed behavior | +| --- | --- | +| `app/src/main/java/gg/roxy/chatFullscreen/components/ChatComposer.kt` (before this cleanup) | `DesktopRoxyModels` was hardcoded and `currentModel` existed only in `rememberSaveable`. There was no callback to the ViewModel. | +| `app/src/main/java/gg/roxy/shared/data/RemoteWorkspaceClient.kt` | `sendPrompt` sends only `{ "t": "prompt", "text": "..." }`. It does not receive catalogs or model-change acknowledgments. | +| `../roxy/src/main/services/remote-protocol.ts` | `GuestFrame` supports `prompt`, `abort`, `list`, `switch`, and `dequeue`; there are no model operations. | +| `../roxy/src/main/services/remote.ts` | `onFrame` does not handle model selection. `runTurn` resolves session configuration and selects the provider/model on the PC. This host would ignore a model field added to an Android prompt. | +| `../roxy/src/main/services/models.ts` | `listModels(providerId)` already retrieves provider catalogs, including authenticated catalogs and proxies. Reuse this implementation. | +| `../roxy/src/shared/session-config.ts` | `resolveSessionConfig` treats provider and model as a pair and maintains per-session configuration. Context and reasoning-effort rules also exist. | +| `../roxy/src/main/db/repo.ts` | `listConnectedProviders` and `setChatConfig` already query connected providers and persist configuration. | + +A connected provider's catalog does not always prove access to every listed model: some paths use public catalogs. The UI should reflect restrictions known to the host and surface actual provider rejections without claiming permissions it cannot verify. + +## Implementation sequence + +### 1. Define the protocol and compatibility behavior + +- Advertise an optional capability, such as `model-selection-v1`. New clients hide the selector when the host does not advertise it; older clients must continue working. +- Define catalog queries, selection requests, and acknowledgments. Example request: `{ "t": "select-model", "requestId": "r1", "sessionId": "s1", "providerId": "p1", "modelId": "m1", "expectedRevision": 3 }`. +- Respond with `requestId`, `sessionId`, the effective provider/model pair, and a revision, or an explicit error. Message names in this document are proposals, not existing APIs. +- Include stable identifiers, display names, providers, capabilities, and known availability in the catalog. Never send credentials. +- Check the relay's allowed message types, role validation, and size limits. Host comments describe it as a JSON intermediary, but support for new types still needs verification. Update the web client's protocol definition as well. + +Acceptance criterion: a documented protocol and a test proving that messages traverse the relay in both directions. + +### 2. Implement desktop host support + +- Build the catalog from `listConnectedProviders()` and `listModels(providerId)`, reusing their caches and filters. Distinguish a provider with no models from a failed query; support partial results and retries. +- Resolve and publish the effective selection using the same logic that executes turns, including defaults. Avoid a separate resolver that could display a different model from the one being executed. +- Validate the session, connected provider, model, and revision; persist the pair with `setChatConfig`. Align defaults for new sessions with the desktop selector's policy. +- Acknowledge changes only after saving. Notify desktop and mobile clients when configuration changes, including changes initiated on the PC. +- Publish state on connection, session switches, and reconnection. Discard query responses belonging to an earlier connection or session. +- For the initial scope, reject changes while the session has an active turn or pending queue. This prevents unexpected model changes for messages already submitted. Apply the same rule in both clients and enforce it on the host. + +Acceptance criterion: an acknowledged selection determines the next turn's model without modifying other sessions. + +### 3. Connect Android + +- Add catalog/configuration DTOs and events in `RemoteModels.kt` and `RemoteWorkspaceClient.kt`. +- Keep the catalog, confirmed selection, and pending request in `RoxyAppViewModel`, keyed by connection and session. Clear data when switching PCs or disconnecting. +- Extend `ChatFullScreenUiState` and wire callbacks through `MainActivity`, `RoxyApp`, and `ChatFullScreen` to `ChatComposer`. +- Restore the selector using host data: search, provider groups, loading, empty catalog, errors, and retries. Use `(providerId, modelId)` as the identity because multiple providers may use the same model name. +- Keep the confirmed selection marked while another selection is being saved. Block prompt submission while a change is pending so a race cannot send the message with the previous model. On error or timeout, retain the confirmed selection and query again. +- Reflect changes made on the PC and keep state isolated between sessions. Defer favorites and visual extras until synchronization works. + +Acceptance criterion: the mobile client displays the received catalog and never presents a local selection as already applied on the PC. + +### 4. Validate and release + +- Protocol tests: empty/partial catalogs, errors, acknowledgment, timeout, unknown types, and compatibility with older hosts. +- Host tests: disconnected provider, invalid model, missing session, stale revision, persistence, and isolation between sessions. +- Android tests: open A, switch to B before receiving A's response, switch PCs, reconnect, and receive desktop configuration changes. +- Integration test: select a model on Android and verify the `providerId` and `model` arguments received by `runSessionTurn`; checking the label alone is insufficient. +- Test with a real PC and phone: multiple providers, selection changes from both ends, reconnection, and rejection during execution or while prompts are queued. +- Release compatible relay support first if needed, then the host, and finally Android. Keep the selector hidden for unsupported versions. + +## Rough estimate + +**2-4 working sessions** for protocol/relay work, the host, Android, and joint validation, assuming repository access and a test environment. This is not a delivery guarantee: timing depends particularly on relay restrictions and how desktop configuration changes are propagated. + +Recommended first milestone: a real catalog and effective model in read-only mode, gated by capability support. Second milestone: persisted, acknowledged selection with an execution test. Enable the interactive selector once both are complete. From 0a014a4770ffe4a66082ef9b5f1549485c136e83 Mon Sep 17 00:00:00 2001 From: Jair Escamilla Date: Mon, 7 Sep 2026 13:32:42 -0600 Subject: [PATCH 15/21] docs: record modular v1 reliability implementation plan --- docs/v1-chat-reliability.md | 35 +++++++++++++++++++++++++++++++++++ 1 file changed, 35 insertions(+) create mode 100644 docs/v1-chat-reliability.md diff --git a/docs/v1-chat-reliability.md b/docs/v1-chat-reliability.md new file mode 100644 index 0000000..8cbd12e --- /dev/null +++ b/docs/v1-chat-reliability.md @@ -0,0 +1,35 @@ +# V1 chat reliability implementation + +Branch: `codex/v1-chat-reliability`. Based on the current 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. + +- [ ] Module 1: report transport send failures, block offline sends, preserve drafts, expose connection recovery in chat. +- [ ] Module 2: display remote errors, wait for authoritative session synchronization, allow one turn at a time, preserve stream event ordering. +- [ ] Module 3: connect Stop for mobile-started turns, wait for host confirmation, remove attachment placeholders. +- [ ] 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 + +- [ ] Unit tests cover offline/rejected sends, reconnect, session isolation, errors, duplicate submission, stream ordering, and Stop. +- [ ] Debug APK builds. +- [ ] 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. +- Implementation pending. From 5bce1043bb28f662137c98497bafbc5e068a7bea Mon Sep 17 00:00:00 2001 From: Jair Escamilla Date: Mon, 7 Sep 2026 13:36:21 -0600 Subject: [PATCH 16/21] fix: preserve chat drafts and recover disconnected sessions --- app/src/main/java/gg/roxy/MainActivity.kt | 1 + app/src/main/java/gg/roxy/RoxyApp.kt | 2 + .../businessLogic/ChatFullScreenUiState.kt | 7 +- .../chatFullscreen/components/ChatComposer.kt | 5 +- .../components/ChatFullScreen.kt | 3 + .../components/ChatStatusBanner.kt | 41 +++++++++ .../shared/businessLogic/RoxyAppViewModel.kt | 92 +++++++++++++------ .../roxy/shared/data/RemoteWorkspaceClient.kt | 10 +- .../businessLogic/RoxyAppViewModelTest.kt | 84 ++++++++++++++++- .../shared/data/RemoteWorkspaceClientTest.kt | 8 ++ docs/v1-chat-reliability.md | 6 +- 11 files changed, 220 insertions(+), 39 deletions(-) create mode 100644 app/src/main/java/gg/roxy/chatFullscreen/components/ChatStatusBanner.kt diff --git a/app/src/main/java/gg/roxy/MainActivity.kt b/app/src/main/java/gg/roxy/MainActivity.kt index 27e719c..5ce47a6 100644 --- a/app/src/main/java/gg/roxy/MainActivity.kt +++ b/app/src/main/java/gg/roxy/MainActivity.kt @@ -37,6 +37,7 @@ class MainActivity : ComponentActivity() { onBackFromChat = viewModel::showMainScreen, onComposerChange = viewModel::updateComposer, onComposerSubmit = viewModel::submitComposer, + onReconnect = viewModel::reconnectRemote, 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..9843dc9 100644 --- a/app/src/main/java/gg/roxy/RoxyApp.kt +++ b/app/src/main/java/gg/roxy/RoxyApp.kt @@ -25,6 +25,7 @@ fun RoxyApp( onDisconnectComputer: () -> Unit = {}, initialToken: String = "", initialPin: String = "", + onReconnect: () -> Unit = {}, ) { when (uiState.destination) { RoxyDestination.Main -> MainFullScreen( @@ -43,6 +44,7 @@ fun RoxyApp( ) RoxyDestination.Chat -> ChatFullScreen( + onReconnect = onReconnect, 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..7048b19 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,9 @@ 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 canSubmit: Boolean get() = isConnected && !isSyncing && composerText.isNotBlank() +} 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..50ee28c 100644 --- a/app/src/main/java/gg/roxy/chatFullscreen/components/ChatComposer.kt +++ b/app/src/main/java/gg/roxy/chatFullscreen/components/ChatComposer.kt @@ -40,6 +40,7 @@ fun ChatComposer( onTextChange: (String) -> Unit, onSubmit: () -> Unit, modifier: Modifier = Modifier, + canSubmit: Boolean = true, ) { val colors = MaterialTheme.roxyColors @@ -65,7 +66,7 @@ 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()) { @@ -115,7 +116,7 @@ fun ChatComposer( ) // Send Button with clean default theme (White when active) - val isSendActive = text.isNotBlank() + val isSendActive = canSubmit && text.isNotBlank() Surface( onClick = onSubmit, enabled = isSendActive, 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 bb2c75c..9b6925f 100644 --- a/app/src/main/java/gg/roxy/chatFullscreen/components/ChatFullScreen.kt +++ b/app/src/main/java/gg/roxy/chatFullscreen/components/ChatFullScreen.kt @@ -143,6 +143,7 @@ fun ChatFullScreen( onComposerSubmit: () -> Unit, onToolCallClick: (String) -> Unit, modifier: Modifier = Modifier, + onReconnect: () -> Unit = {}, ) { val colors = MaterialTheme.roxyColors BackHandler(onBack = onBackClick) @@ -203,6 +204,7 @@ fun ChatFullScreen( onBackClick = onBackClick, ) HorizontalDivider(color = colors.border) + ChatStatusBanner(uiState, onReconnect) if (isSessionEmpty) { Box( @@ -327,6 +329,7 @@ fun ChatFullScreen( ) { ChatComposer( text = uiState.composerText, + canSubmit = uiState.canSubmit, onTextChange = onComposerChange, onSubmit = { onComposerSubmit() 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..fef80e1 --- /dev/null +++ b/app/src/main/java/gg/roxy/chatFullscreen/components/ChatStatusBanner.kt @@ -0,0 +1,41 @@ +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." + 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) { + TextButton(onClick = onReconnect) { Text("Reconnect") } + } + } + } +} 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 ba27f69..5ad035b 100644 --- a/app/src/main/java/gg/roxy/shared/businessLogic/RoxyAppViewModel.kt +++ b/app/src/main/java/gg/roxy/shared/businessLogic/RoxyAppViewModel.kt @@ -62,11 +62,13 @@ class RoxyAppViewModel( val uiState: StateFlow = _uiState.asStateFlow() private var activeSessionId: String? = null + private var pairingToken: String? = storage.savedToken private data class SessionChatCache( val messages: List = emptyList(), val toolCalls: List = emptyList(), val isRunning: Boolean = false, + val draft: String = "", ) private val sessionCache = mutableMapOf() @@ -87,6 +89,7 @@ class RoxyAppViewModel( when (connectionState) { is RemoteConnectionState.Connecting -> { state.copy( + chat = state.chat.copy(isConnected = false, isConnecting = true, errorMessage = null), main = state.main.copy( isConnecting = true, connectionError = null, @@ -105,6 +108,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), @@ -117,6 +121,13 @@ class RoxyAppViewModel( } is RemoteConnectionState.Error -> { state.copy( + chat = state.chat.copy( + isConnected = false, + isConnecting = false, + isRunning = false, + isSyncing = false, + errorMessage = connectionState.message, + ), main = state.main.copy( isConnecting = false, connectionError = connectionState.message, @@ -128,8 +139,6 @@ class RoxyAppViewModel( ) } is RemoteConnectionState.Disconnected -> { - sessionCache.clear() - activeSessionId = null val emptyPc = ComputerUiModel( id = "none", name = "No computer connected", @@ -137,24 +146,28 @@ 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, + isConnecting = false, + isRunning = false, isSyncing = false, + errorMessage = if (activeSessionId != null) "Connection lost. Reconnect before sending another message." else null, ), ) } } } + if (connectionState is RemoteConnectionState.Connected) { + activeSessionId?.let { sessionId -> + _uiState.update { it.copy(chat = it.chat.copy(isSyncing = true)) } + remoteClient.switchSession(sessionId) + } + } } } @@ -563,12 +576,32 @@ class RoxyAppViewModel( } fun connectRemote(tokenOrUrl: String, pin: String) { + val token = RemoteWorkspaceUtils.extractGuestToken(tokenOrUrl) + if (token != pairingToken) { + sessionCache.clear() + activeSessionId = null + _uiState.update { it.copy(destination = RoxyDestination.Main, chat = initialUiState().chat) } + } + pairingToken = token remoteClient.connect(tokenOrUrl, pin) } + fun reconnectRemote() { + if (_uiState.value.chat.isConnecting) 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 -> @@ -587,13 +620,7 @@ class RoxyAppViewModel( isConnecting = false, connectionError = null, ), - chat = state.chat.copy( - sessionTitle = "", - projectName = "", - messages = emptyList(), - toolCalls = emptyList(), - isSyncing = false, - ), + chat = initialUiState().chat, ) } } @@ -645,7 +672,8 @@ class RoxyAppViewModel( chat = state.chat.copy( sessionTitle = session.title, projectName = project.name, - composerText = "", + composerText = cached?.draft ?: "", + errorMessage = null, messages = cached?.messages ?: emptyList(), toolCalls = cached?.toolCalls ?: emptyList(), isRunning = cached?.isRunning ?: false, @@ -660,14 +688,24 @@ 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(errorMessage = "Message was not sent. Your draft is saved. Reconnect and try again.")) } + return + } val userMessage = ChatMessageUiModel( id = UUID.randomUUID().toString(), @@ -675,27 +713,23 @@ class RoxyAppViewModel( isUser = true, ) - val activeId = activeSessionId - _uiState.update { state -> state.copy( chat = state.chat.copy( composerText = "", messages = state.chat.messages + userMessage, isRunning = true, + errorMessage = null, ) ) } - if (activeId != null) { - val cached = sessionCache[activeId] ?: SessionChatCache() - sessionCache[activeId] = cached.copy( - messages = cached.messages + userMessage, - isRunning = true, - ) - } - - remoteClient.sendPrompt(currentText) + val cached = sessionCache[activeId] ?: SessionChatCache() + sessionCache[activeId] = cached.copy( + messages = cached.messages + userMessage, + isRunning = true, + draft = "", + ) } fun toggleToolCall(toolCallId: String) { 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 602ca6d..ec504cf 100644 --- a/app/src/main/java/gg/roxy/shared/data/RemoteWorkspaceClient.kt +++ b/app/src/main/java/gg/roxy/shared/data/RemoteWorkspaceClient.kt @@ -56,7 +56,8 @@ interface RemoteWorkspaceClient { val connectionState: StateFlow val events: SharedFlow 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() @@ -470,13 +471,14 @@ class DefaultRemoteWorkspaceClient @Inject constructor( } } - override fun sendPrompt(text: String) { - val ws = activeWebSocket ?: return + override fun sendPrompt(text: String): Boolean { + 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()) + return ws.send(payload.toString()) } override fun switchSession(sessionId: String) { 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 bbb2e09..3a9d5f6 100644 --- a/app/src/test/java/gg/roxy/shared/businessLogic/RoxyAppViewModelTest.kt +++ b/app/src/test/java/gg/roxy/shared/businessLogic/RoxyAppViewModelTest.kt @@ -33,13 +33,20 @@ class FakeRemoteWorkspaceClient : RemoteWorkspaceClient { var lastPromptSent: String? = null var lastSwitchedSession: String? = null + var acceptPrompts = true + var promptCount = 0 + + 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) { @@ -122,6 +129,8 @@ 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.ToolStarted( @@ -454,6 +463,79 @@ 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) + } + + private fun connectedSession(client: FakeRemoteWorkspaceClient): RoxyAppViewModel { + val viewModel = createViewModel(client) + client.connect("tok", "123456") + client.fakeEvents.tryEmit(RemoteEvent.SnapshotReceived("sess-1", emptyList(), emptyList())) + 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 8991708..f6a835a 100644 --- a/app/src/test/java/gg/roxy/shared/data/RemoteWorkspaceClientTest.kt +++ b/app/src/test/java/gg/roxy/shared/data/RemoteWorkspaceClientTest.kt @@ -27,6 +27,14 @@ class MemoryRemoteStorage : RemoteStorage { class RemoteWorkspaceClientTest { + @Test + fun promptWithoutALiveSocketIsRejected() { + val client = DefaultRemoteWorkspaceClient(MemoryRemoteStorage()) + assertFalse(client.sendPrompt("Hello")) + client.handleIncomingMessage("""{"t":"hello-ok"}""") + assertFalse(client.sendPrompt("Hello")) + } + @Test fun snapshotWithTextAndToolPartsParsesBothCorrectly() = runBlocking { val storage = MemoryRemoteStorage() diff --git a/docs/v1-chat-reliability.md b/docs/v1-chat-reliability.md index 8cbd12e..ca91d5d 100644 --- a/docs/v1-chat-reliability.md +++ b/docs/v1-chat-reliability.md @@ -6,7 +6,7 @@ Branch: `codex/v1-chat-reliability`. Based on the current chat/PIN fixes plus th Each module is committed separately. Do not publish a release until the verification checklist passes. -- [ ] Module 1: report transport send failures, block offline sends, preserve drafts, expose connection recovery in chat. +- [x] Module 1: report transport send failures, block offline sends, preserve drafts, expose connection recovery in chat. - [ ] Module 2: display remote errors, wait for authoritative session synchronization, allow one turn at a time, preserve stream event ordering. - [ ] Module 3: connect Stop for mobile-started turns, wait for host confirmation, remove attachment placeholders. - [ ] Module 4: regression tests, debug/release compilation, and final review. @@ -32,4 +32,6 @@ Initial environment issue: Gradle fails before running tasks with `Unable to est ## Progress - Baseline saved in commit `0b01e02`; includes the already-reviewed model selector cleanup and English roadmap. -- Implementation pending. +- 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. +- Next: module 2. Remote errors and overlapping sends are not yet addressed by module 1. From 72ae0abbe5f3445a5073f6c3b27dc807346cb2c2 Mon Sep 17 00:00:00 2001 From: Jair Escamilla Date: Mon, 7 Sep 2026 13:53:05 -0600 Subject: [PATCH 17/21] Welcome mobile app 1.0 --- .idea/appInsightsSettings.xml | 18 +++ .idea/misc.xml | 2 +- .idea/vcs.xml | 6 + app/google-services.json | Bin 0 -> 1388 bytes .../businessLogic/ChatFullScreenUiState.kt | 6 +- .../components/ChatStatusBanner.kt | 7 +- .../shared/businessLogic/RoxyAppViewModel.kt | 104 ++++++++++++++++-- .../roxy/shared/data/RemoteWorkspaceClient.kt | 74 +++++++------ .../businessLogic/RoxyAppViewModelTest.kt | 2 + 9 files changed, 174 insertions(+), 45 deletions(-) create mode 100644 .idea/appInsightsSettings.xml create mode 100644 .idea/vcs.xml create mode 100644 app/google-services.json 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 0000000000000000000000000000000000000000..2c7c9cea0f2672463a1084c3ed386f5c6184ddcc GIT binary patch literal 1388 zcma)+SxW;k6ot>T;D3-jYtgD$`&Mwl2N4lO5DB%L)oyfg#lNn8H|ca{>bNiu$t1bw zo^#JlK0n^HsuivAIgK>dQ1@!7p{cyO8mP^RQ=2JU!xDFidh?FHV}qdinFUA*5B?!ndCts1lQ{WfiB|!JRS=nMb=QGRJII#=$n&4WjD&E&G?U%cJ^SRsN?i zsvMEWoJ*V>Fzcf@cB8}Jj!K~utTR$i5+*jj=jtC<@*NX%4Z)t_+8ve5IudhT>n7uK zg71hKQEwl*%NW=#$vd8${qB6SV_h>tg>{|vO&-murW3uA=>m-hxN@Z@U2?0AbsoGq zh4VQqYGFLonU27Dg3}3O2lRd7%A9zmoBvy-V!d`F#(D&+5jwBvT}XAsyJByMMNEnC z;ZCIpzr`DlqtDF~%j&6hk2*}li!`PaJiC9nmb`5VD;tY?B6}{~&sM>F8luHsh)!_K WxOBssN1<_?%>Sb Unit) { 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 @@ -33,8 +36,8 @@ fun ChatStatusBanner(state: ChatFullScreenUiState, onReconnect: () -> Unit) { color = colors.text, modifier = Modifier.semantics { liveRegion = LiveRegionMode.Polite }, ) - if (!state.isConnecting) { - TextButton(onClick = onReconnect) { Text("Reconnect") } + 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/shared/businessLogic/RoxyAppViewModel.kt b/app/src/main/java/gg/roxy/shared/businessLogic/RoxyAppViewModel.kt index 5ad035b..72ce790 100644 --- a/app/src/main/java/gg/roxy/shared/businessLogic/RoxyAppViewModel.kt +++ b/app/src/main/java/gg/roxy/shared/businessLogic/RoxyAppViewModel.kt @@ -24,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 @@ -63,12 +65,19 @@ class RoxyAppViewModel( 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 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, ) private val sessionCache = mutableMapOf() @@ -85,11 +94,18 @@ 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() + } _uiState.update { state -> when (connectionState) { is RemoteConnectionState.Connecting -> { state.copy( - chat = state.chat.copy(isConnected = false, isConnecting = true, errorMessage = null), + chat = state.chat.copy(isConnected = false, isConnecting = true, isSessionReady = false, errorMessage = null), main = state.main.copy( isConnecting = true, connectionError = null, @@ -123,6 +139,7 @@ class RoxyAppViewModel( state.copy( chat = state.chat.copy( isConnected = false, + isSessionReady = false, isConnecting = false, isRunning = false, isSyncing = false, @@ -153,6 +170,7 @@ class RoxyAppViewModel( ), chat = state.chat.copy( isConnected = false, + isSessionReady = false, isConnecting = false, isRunning = false, isSyncing = false, @@ -164,7 +182,7 @@ class RoxyAppViewModel( } if (connectionState is RemoteConnectionState.Connected) { activeSessionId?.let { sessionId -> - _uiState.update { it.copy(chat = it.chat.copy(isSyncing = true)) } + beginSessionSync(sessionId) remoteClient.switchSession(sessionId) } } @@ -187,6 +205,7 @@ class RoxyAppViewModel( } } is RemoteEvent.SnapshotReceived -> { + snapshotsReceived.add(event.sessionId) val current = sessionCache[event.sessionId] ?: SessionChatCache() val allTools = if (event.tools.isNotEmpty()) { event.tools @@ -196,6 +215,7 @@ class RoxyAppViewModel( sessionCache[event.sessionId] = current.copy( messages = event.messages, toolCalls = allTools, + pendingPrompt = null, ) if (activeSessionId == null || activeSessionId == event.sessionId) { @@ -208,6 +228,8 @@ class RoxyAppViewModel( messages = event.messages, toolCalls = allTools, isSyncing = false, + isSessionReady = isSessionReady(event.sessionId), + isAwaitingResponse = false, ) ) } @@ -376,8 +398,15 @@ class RoxyAppViewModel( } } is RemoteEvent.TurnChanged -> { + turnsReceived.add(event.sessionId) val current = sessionCache[event.sessionId] ?: SessionChatCache() val currentMessages = current.messages.toMutableList() + if (event.isRunning && current.pendingPrompt != null && current.queuedPromptCount == 0 && event.userText == null) { + currentMessages.add(current.pendingPrompt) + } + if (event.isRunning && (event.userText == current.pendingPrompt?.text || current.queuedPromptCount == 0)) { + responseTimeoutJobs.remove(event.sessionId)?.cancel() + } if (event.userText != null && (currentMessages.isEmpty() || currentMessages.last().text != event.userText)) { currentMessages.add( ChatMessageUiModel( @@ -442,6 +471,7 @@ class RoxyAppViewModel( isRunning = event.isRunning, messages = currentMessages, toolCalls = currentTools, + pendingPrompt = if (event.isRunning && (event.userText == current.pendingPrompt?.text || current.queuedPromptCount == 0)) null else current.pendingPrompt, ) if (activeSessionId == null || activeSessionId == event.sessionId) { @@ -451,18 +481,53 @@ class RoxyAppViewModel( isRunning = event.isRunning, messages = currentMessages, toolCalls = currentTools, + isSessionReady = isSessionReady(event.sessionId) && state.chat.errorMessage == null, + isAwaitingResponse = sessionCache[event.sessionId]?.pendingPrompt != null, ) ) } } } 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 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(15_000) + if (activeSessionId == sessionId && !isSessionReady(sessionId)) { + _uiState.update { it.copy(chat = it.chat.copy(isSyncing = false, errorMessage = "Could not refresh this session. Try refreshing again.")) } + } } } @@ -580,6 +645,8 @@ class RoxyAppViewModel( if (token != pairingToken) { sessionCache.clear() activeSessionId = null + snapshotsReceived.clear() + turnsReceived.clear() _uiState.update { it.copy(destination = RoxyDestination.Main, chat = initialUiState().chat) } } pairingToken = token @@ -588,6 +655,12 @@ class RoxyAppViewModel( 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()) { @@ -645,8 +718,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()) @@ -673,11 +747,14 @@ class RoxyAppViewModel( sessionTitle = session.title, projectName = project.name, composerText = cached?.draft ?: "", - errorMessage = null, + 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, ), ) } @@ -692,6 +769,7 @@ class RoxyAppViewModel( val cached = sessionCache[sessionId] ?: SessionChatCache() sessionCache[sessionId] = cached.copy(draft = text) } + remoteClient.switchSession(sessionId) _uiState.update { state -> state.copy(chat = state.chat.copy(composerText = text)) } @@ -717,8 +795,7 @@ class RoxyAppViewModel( state.copy( chat = state.chat.copy( composerText = "", - messages = state.chat.messages + userMessage, - isRunning = true, + isAwaitingResponse = true, errorMessage = null, ) ) @@ -726,10 +803,19 @@ class RoxyAppViewModel( val cached = sessionCache[activeId] ?: SessionChatCache() sessionCache[activeId] = cached.copy( - messages = cached.messages + userMessage, - isRunning = true, + pendingPrompt = userMessage, draft = "", ) + responseTimeoutJobs[activeId] = scope.launch { + delay(15_000) + 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)) } + } + } + } } fun toggleToolCall(toolCallId: String) { 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 ec504cf..ae78bf6 100644 --- a/app/src/main/java/gg/roxy/shared/data/RemoteWorkspaceClient.kt +++ b/app/src/main/java/gg/roxy/shared/data/RemoteWorkspaceClient.kt @@ -15,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 @@ -50,11 +52,12 @@ sealed interface RemoteEvent { 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) /** True means queued on the socket, not acknowledged by the host. */ fun sendPrompt(text: String): Boolean @@ -92,8 +95,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) @@ -246,9 +268,7 @@ class DefaultRemoteWorkspaceClient @Inject constructor( ) ) } - scope.launch { - _events.emit(RemoteEvent.SessionsReceived(list, currentId)) - } + publish(RemoteEvent.SessionsReceived(list, currentId)) } "snapshot" -> { val sessionId = json.optString("sessionId", "") @@ -339,9 +359,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", "") @@ -350,33 +368,25 @@ 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)) } } "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)) } } } @@ -431,13 +441,12 @@ class DefaultRemoteWorkspaceClient @Inject constructor( } } - scope.launch { - _events.emit( + publish( RemoteEvent.TurnChanged( sessionId = sessionId, isRunning = isRunning, userText = userText, - inFlightText = textParts.takeIf { it.isNotEmpty() }?.joinToString("\n\n"), + inFlightText = textParts.takeIf { it.isNotEmpty()?.joinToString("\n\n"), inFlightParts = inFlightParts, inFlightTools = inFlightTools, ) @@ -449,15 +458,16 @@ class DefaultRemoteWorkspaceClient @Inject constructor( 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() } } } 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 3a9d5f6..879b79f 100644 --- a/app/src/test/java/gg/roxy/shared/businessLogic/RoxyAppViewModelTest.kt +++ b/app/src/test/java/gg/roxy/shared/businessLogic/RoxyAppViewModelTest.kt @@ -131,6 +131,7 @@ class RoxyAppViewModelTest { 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( @@ -533,6 +534,7 @@ class RoxyAppViewModelTest { val viewModel = createViewModel(client) client.connect("tok", "123456") client.fakeEvents.tryEmit(RemoteEvent.SnapshotReceived("sess-1", emptyList(), emptyList())) + client.fakeEvents.tryEmit(RemoteEvent.TurnChanged("sess-1", false)) return viewModel } From aa2be37337008883b8e0f9c835a071fab5c65366 Mon Sep 17 00:00:00 2001 From: Jair Escamilla Date: Mon, 7 Sep 2026 19:21:50 -0600 Subject: [PATCH 18/21] fix: synchronize chat turns and surface remote failures --- .../shared/businessLogic/RoxyAppViewModel.kt | 9 +- .../roxy/shared/data/RemoteWorkspaceClient.kt | 17 ++-- .../businessLogic/RoxyAppViewModelTest.kt | 90 +++++++++++++++++++ .../shared/data/RemoteWorkspaceClientTest.kt | 40 +++++++++ docs/v1-chat-reliability.md | 8 +- 5 files changed, 149 insertions(+), 15 deletions(-) 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 72ce790..fc277d3 100644 --- a/app/src/main/java/gg/roxy/shared/businessLogic/RoxyAppViewModel.kt +++ b/app/src/main/java/gg/roxy/shared/businessLogic/RoxyAppViewModel.kt @@ -401,10 +401,13 @@ class RoxyAppViewModel( 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 (event.isRunning && (event.userText == current.pendingPrompt?.text || current.queuedPromptCount == 0)) { + if (confirmsPendingPrompt) { responseTimeoutJobs.remove(event.sessionId)?.cancel() } if (event.userText != null && (currentMessages.isEmpty() || currentMessages.last().text != event.userText)) { @@ -471,7 +474,7 @@ class RoxyAppViewModel( isRunning = event.isRunning, messages = currentMessages, toolCalls = currentTools, - pendingPrompt = if (event.isRunning && (event.userText == current.pendingPrompt?.text || current.queuedPromptCount == 0)) null else current.pendingPrompt, + pendingPrompt = if (confirmsPendingPrompt) null else current.pendingPrompt, ) if (activeSessionId == null || activeSessionId == event.sessionId) { @@ -758,6 +761,7 @@ class RoxyAppViewModel( ), ) } + remoteClient.switchSession(sessionId) } fun showMainScreen() { @@ -769,7 +773,6 @@ class RoxyAppViewModel( val cached = sessionCache[sessionId] ?: SessionChatCache() sessionCache[sessionId] = cached.copy(draft = text) } - remoteClient.switchSession(sessionId) _uiState.update { state -> state.copy(chat = state.chat.copy(composerText = text)) } 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 ae78bf6..4330aa0 100644 --- a/app/src/main/java/gg/roxy/shared/data/RemoteWorkspaceClient.kt +++ b/app/src/main/java/gg/roxy/shared/data/RemoteWorkspaceClient.kt @@ -442,16 +442,15 @@ class DefaultRemoteWorkspaceClient @Inject constructor( } publish( - RemoteEvent.TurnChanged( - sessionId = sessionId, - isRunning = isRunning, - userText = userText, - inFlightText = textParts.takeIf { it.isNotEmpty()?.joinToString("\n\n"), - inFlightParts = inFlightParts, - inFlightTools = inFlightTools, - ) + 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") 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 879b79f..89453b8 100644 --- a/app/src/test/java/gg/roxy/shared/businessLogic/RoxyAppViewModelTest.kt +++ b/app/src/test/java/gg/roxy/shared/businessLogic/RoxyAppViewModelTest.kt @@ -530,6 +530,96 @@ class RoxyAppViewModelTest { 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)) + 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) + } + private fun connectedSession(client: FakeRemoteWorkspaceClient): RoxyAppViewModel { val viewModel = createViewModel(client) client.connect("tok", "123456") 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 f6a835a..b07ad7b 100644 --- a/app/src/test/java/gg/roxy/shared/data/RemoteWorkspaceClientTest.kt +++ b/app/src/test/java/gg/roxy/shared/data/RemoteWorkspaceClientTest.kt @@ -4,9 +4,12 @@ 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 @@ -27,6 +30,43 @@ 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 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()) diff --git a/docs/v1-chat-reliability.md b/docs/v1-chat-reliability.md index ca91d5d..2d39c61 100644 --- a/docs/v1-chat-reliability.md +++ b/docs/v1-chat-reliability.md @@ -1,13 +1,13 @@ # V1 chat reliability implementation -Branch: `codex/v1-chat-reliability`. Based on the current chat/PIN fixes plus the model selector cleanup. All implementation, documentation, and commit messages are in English. +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. -- [ ] Module 2: display remote errors, wait for authoritative session synchronization, allow one turn at a time, preserve stream event ordering. +- [x] Module 2: display remote errors, wait for authoritative session synchronization, allow one turn at a time, preserve stream event ordering. - [ ] Module 3: connect Stop for mobile-started turns, wait for host confirmation, remove attachment placeholders. - [ ] Module 4: regression tests, debug/release compilation, and final review. @@ -34,4 +34,6 @@ Initial environment issue: Gradle fails before running tasks with `Unable to est - 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. -- Next: module 2. Remote errors and overlapping sends are not yet addressed by module 1. +- 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. +- Next: module 3 (Stop and attachment cleanup). From 110dbf811e9d77d12b9befc0f428ccf1755aec44 Mon Sep 17 00:00:00 2001 From: Jair Escamilla Date: Mon, 7 Sep 2026 19:26:07 -0600 Subject: [PATCH 19/21] feat: stop mobile turns and remove attachment placeholders --- app/src/main/java/gg/roxy/MainActivity.kt | 1 + app/src/main/java/gg/roxy/RoxyApp.kt | 2 + .../businessLogic/ChatFullScreenUiState.kt | 5 +- .../chatFullscreen/components/ChatComposer.kt | 45 +++----- .../components/ChatFullScreen.kt | 9 +- .../shared/businessLogic/RoxyAppViewModel.kt | 46 +++++++- .../roxy/shared/data/RemoteWorkspaceClient.kt | 9 +- .../businessLogic/RoxyAppViewModelTest.kt | 104 +++++++++++++++++- .../shared/data/RemoteWorkspaceClientTest.kt | 1 + docs/v1-chat-reliability.md | 5 +- 10 files changed, 182 insertions(+), 45 deletions(-) diff --git a/app/src/main/java/gg/roxy/MainActivity.kt b/app/src/main/java/gg/roxy/MainActivity.kt index 5ce47a6..cf6c941 100644 --- a/app/src/main/java/gg/roxy/MainActivity.kt +++ b/app/src/main/java/gg/roxy/MainActivity.kt @@ -38,6 +38,7 @@ class MainActivity : ComponentActivity() { 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 9843dc9..b5ff4a9 100644 --- a/app/src/main/java/gg/roxy/RoxyApp.kt +++ b/app/src/main/java/gg/roxy/RoxyApp.kt @@ -26,6 +26,7 @@ fun RoxyApp( initialToken: String = "", initialPin: String = "", onReconnect: () -> Unit = {}, + onStop: () -> Unit = {}, ) { when (uiState.destination) { RoxyDestination.Main -> MainFullScreen( @@ -45,6 +46,7 @@ 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 b3593fb..dd0f20a 100644 --- a/app/src/main/java/gg/roxy/chatFullscreen/businessLogic/ChatFullScreenUiState.kt +++ b/app/src/main/java/gg/roxy/chatFullscreen/businessLogic/ChatFullScreenUiState.kt @@ -71,7 +71,10 @@ data class ChatFullScreenUiState( 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 && queuedPromptCount == 0 && composerText.isNotBlank() + !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 50ee28c..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 @@ -41,6 +39,10 @@ fun ChatComposer( onSubmit: () -> Unit, modifier: Modifier = Modifier, canSubmit: Boolean = true, + showStop: Boolean = false, + canStop: Boolean = false, + isStopping: Boolean = false, + onStop: () -> Unit = {}, ) { val colors = MaterialTheme.roxyColors @@ -71,7 +73,7 @@ fun ChatComposer( Box { if (text.isEmpty()) { Text( - text = "Ask Roxy anything... (paste or drop images)", + text = "Ask Roxy anything...", style = MaterialTheme.typography.bodyMedium, color = colors.textMuted, ) @@ -88,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, @@ -115,22 +97,21 @@ fun ChatComposer( modifier = Modifier.weight(1f).padding(end = 8.dp), ) - // Send Button with clean default theme (White when active) - val isSendActive = canSubmit && 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 9b6925f..e825af2 100644 --- a/app/src/main/java/gg/roxy/chatFullscreen/components/ChatFullScreen.kt +++ b/app/src/main/java/gg/roxy/chatFullscreen/components/ChatFullScreen.kt @@ -144,6 +144,7 @@ fun ChatFullScreen( onToolCallClick: (String) -> Unit, modifier: Modifier = Modifier, onReconnect: () -> Unit = {}, + onStop: () -> Unit = {}, ) { val colors = MaterialTheme.roxyColors BackHandler(onBack = onBackClick) @@ -200,6 +201,7 @@ fun ChatFullScreen( sessionTitle = uiState.sessionTitle, projectName = uiState.projectName, isRunning = uiState.isRunning, + isStopping = uiState.isStopping, isSyncing = uiState.isSyncing, onBackClick = onBackClick, ) @@ -330,6 +332,10 @@ 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() @@ -351,6 +357,7 @@ fun ChatHeader( sessionTitle: String, projectName: String, isRunning: Boolean = false, + isStopping: Boolean = false, isSyncing: Boolean = false, onBackClick: () -> Unit, modifier: Modifier = Modifier, @@ -406,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, ) 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 fc277d3..b225b9a 100644 --- a/app/src/main/java/gg/roxy/shared/businessLogic/RoxyAppViewModel.kt +++ b/app/src/main/java/gg/roxy/shared/businessLogic/RoxyAppViewModel.kt @@ -49,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 @@ -69,6 +70,7 @@ class RoxyAppViewModel( 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(), @@ -78,6 +80,8 @@ class RoxyAppViewModel( val pendingPrompt: ChatMessageUiModel? = null, val queuedPromptCount: Int = 0, val errorMessage: String? = null, + val isMobileTurn: Boolean = false, + val isStopping: Boolean = false, ) private val sessionCache = mutableMapOf() @@ -100,6 +104,9 @@ class RoxyAppViewModel( 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) { @@ -142,6 +149,7 @@ class RoxyAppViewModel( isSessionReady = false, isConnecting = false, isRunning = false, + isStopping = false, isSyncing = false, errorMessage = connectionState.message, ), @@ -173,6 +181,7 @@ class RoxyAppViewModel( isSessionReady = false, isConnecting = false, isRunning = false, + isStopping = false, isSyncing = false, errorMessage = if (activeSessionId != null) "Connection lost. Reconnect before sending another message." else null, ), @@ -230,6 +239,7 @@ class RoxyAppViewModel( isSyncing = false, isSessionReady = isSessionReady(event.sessionId), isAwaitingResponse = false, + isMobileTurn = current.isMobileTurn, ) ) } @@ -410,6 +420,7 @@ class RoxyAppViewModel( 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( @@ -475,6 +486,8 @@ class RoxyAppViewModel( 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) { @@ -486,6 +499,8 @@ class RoxyAppViewModel( 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, ) ) } @@ -527,7 +542,7 @@ class RoxyAppViewModel( _uiState.update { it.copy(chat = it.chat.copy(isSyncing = true, isSessionReady = false, errorMessage = null)) } syncTimeoutJob?.cancel() syncTimeoutJob = scope.launch { - delay(15_000) + 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.")) } } @@ -758,6 +773,8 @@ class RoxyAppViewModel( isSessionReady = false, queuedPromptCount = cached?.queuedPromptCount ?: 0, isAwaitingResponse = cached?.pendingPrompt != null, + isMobileTurn = cached?.isMobileTurn ?: false, + isStopping = cached?.isStopping ?: false, ), ) } @@ -810,7 +827,7 @@ class RoxyAppViewModel( draft = "", ) responseTimeoutJobs[activeId] = scope.launch { - delay(15_000) + 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) @@ -821,6 +838,31 @@ class RoxyAppViewModel( } } + 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) { val activeId = activeSessionId if (activeId != null) { 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 4330aa0..a6c5ccf 100644 --- a/app/src/main/java/gg/roxy/shared/data/RemoteWorkspaceClient.kt +++ b/app/src/main/java/gg/roxy/shared/data/RemoteWorkspaceClient.kt @@ -63,7 +63,7 @@ interface RemoteWorkspaceClient { fun sendPrompt(text: String): Boolean fun switchSession(sessionId: String) fun refreshSessions() - fun abort() + fun abort(): Boolean fun disconnect() } @@ -526,12 +526,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/shared/businessLogic/RoxyAppViewModelTest.kt b/app/src/test/java/gg/roxy/shared/businessLogic/RoxyAppViewModelTest.kt index 89453b8..eea54e4 100644 --- a/app/src/test/java/gg/roxy/shared/businessLogic/RoxyAppViewModelTest.kt +++ b/app/src/test/java/gg/roxy/shared/businessLogic/RoxyAppViewModelTest.kt @@ -19,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 @@ -35,6 +38,8 @@ class FakeRemoteWorkspaceClient : RemoteWorkspaceClient { var lastSwitchedSession: String? = null var acceptPrompts = true var promptCount = 0 + var abortCount = 0 + var acceptAbort = true fun setConnection(state: RemoteConnectionState) { _connectionState.value = state } @@ -54,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 } @@ -620,8 +628,98 @@ class RoxyAppViewModelTest { assertTrue(viewModel.uiState.value.chat.canSubmit) } - private fun connectedSession(client: FakeRemoteWorkspaceClient): RoxyAppViewModel { - val viewModel = createViewModel(client) + @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) + 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) + } + + 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)) 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 b07ad7b..224aa57 100644 --- a/app/src/test/java/gg/roxy/shared/data/RemoteWorkspaceClientTest.kt +++ b/app/src/test/java/gg/roxy/shared/data/RemoteWorkspaceClientTest.kt @@ -71,6 +71,7 @@ class RemoteWorkspaceClientTest { fun promptWithoutALiveSocketIsRejected() { val client = DefaultRemoteWorkspaceClient(MemoryRemoteStorage()) assertFalse(client.sendPrompt("Hello")) + assertFalse(client.abort()) client.handleIncomingMessage("""{"t":"hello-ok"}""") assertFalse(client.sendPrompt("Hello")) } diff --git a/docs/v1-chat-reliability.md b/docs/v1-chat-reliability.md index 2d39c61..8b241fc 100644 --- a/docs/v1-chat-reliability.md +++ b/docs/v1-chat-reliability.md @@ -8,7 +8,7 @@ Each module is committed separately. Do not publish a release until the verifica - [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. -- [ ] Module 3: connect Stop for mobile-started turns, wait for host confirmation, remove attachment placeholders. +- [x] Module 3: connect Stop for mobile-started turns, wait for host confirmation, remove attachment placeholders. - [ ] Module 4: regression tests, debug/release compilation, and final review. ## Design constraints @@ -36,4 +36,5 @@ Initial environment issue: Gradle fails before running tasks with `Unable to est - 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. -- Next: module 3 (Stop and attachment cleanup). +- 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. +- Next: final review, release compilation, and device-test availability. From 765d68c1016387f1e653bf6b37a19f56cee617af Mon Sep 17 00:00:00 2001 From: Jair Escamilla Date: Mon, 7 Sep 2026 19:30:51 -0600 Subject: [PATCH 20/21] test: verify v1 recovery flows and release builds --- .../components/ChatComposerTest.kt | 58 +++++++++++++++++++ .../shared/businessLogic/RoxyAppViewModel.kt | 24 +++++++- .../roxy/shared/data/RemoteWorkspaceClient.kt | 5 +- .../businessLogic/RoxyAppViewModelTest.kt | 45 ++++++++++++++ docs/v1-chat-reliability.md | 36 ++++++++++-- 5 files changed, 159 insertions(+), 9 deletions(-) create mode 100644 app/src/androidTest/java/gg/roxy/chatFullscreen/components/ChatComposerTest.kt 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/main/java/gg/roxy/shared/businessLogic/RoxyAppViewModel.kt b/app/src/main/java/gg/roxy/shared/businessLogic/RoxyAppViewModel.kt index b225b9a..97e68c6 100644 --- a/app/src/main/java/gg/roxy/shared/businessLogic/RoxyAppViewModel.kt +++ b/app/src/main/java/gg/roxy/shared/businessLogic/RoxyAppViewModel.kt @@ -237,7 +237,7 @@ class RoxyAppViewModel( messages = event.messages, toolCalls = allTools, isSyncing = false, - isSessionReady = isSessionReady(event.sessionId), + isSessionReady = isSessionReady(event.sessionId) && state.chat.errorMessage == null, isAwaitingResponse = false, isMobileTurn = current.isMobileTurn, ) @@ -504,6 +504,13 @@ class RoxyAppViewModel( ) ) } + // 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 -> { @@ -660,12 +667,20 @@ 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, chat = initialUiState().chat) } + _uiState.update { it.copy( + destination = RoxyDestination.Main, + main = it.main.copy(projects = emptyList()), + chat = initialUiState().chat, + ) } } pairingToken = token remoteClient.connect(tokenOrUrl, pin) @@ -801,7 +816,10 @@ class RoxyAppViewModel( 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(errorMessage = "Message was not sent. Your draft is saved. Reconnect and try again.")) } + _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 } 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 a6c5ccf..555104a 100644 --- a/app/src/main/java/gg/roxy/shared/data/RemoteWorkspaceClient.kt +++ b/app/src/main/java/gg/roxy/shared/data/RemoteWorkspaceClient.kt @@ -481,13 +481,16 @@ class DefaultRemoteWorkspaceClient @Inject constructor( } 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) } - return 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) { 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 eea54e4..e73893f 100644 --- a/app/src/test/java/gg/roxy/shared/businessLogic/RoxyAppViewModelTest.kt +++ b/app/src/test/java/gg/roxy/shared/businessLogic/RoxyAppViewModelTest.kt @@ -555,6 +555,8 @@ class RoxyAppViewModelTest { 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) } @@ -645,6 +647,9 @@ class RoxyAppViewModelTest { 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) } @@ -709,6 +714,46 @@ class RoxyAppViewModelTest { 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") diff --git a/docs/v1-chat-reliability.md b/docs/v1-chat-reliability.md index 8b241fc..04162d2 100644 --- a/docs/v1-chat-reliability.md +++ b/docs/v1-chat-reliability.md @@ -9,7 +9,7 @@ Each module is committed separately. Do not publish a release until the verifica - [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. -- [ ] Module 4: regression tests, debug/release compilation, and final review. +- [x] Module 4: regression tests, debug/release compilation, and final review. ## Design constraints @@ -22,9 +22,9 @@ Each module is committed separately. Do not publish a release until the verifica ## Verification -- [ ] Unit tests cover offline/rejected sends, reconnect, session isolation, errors, duplicate submission, stream ordering, and Stop. -- [ ] Debug APK builds. -- [ ] Release build completes (distribution signing is separate). +- [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. @@ -37,4 +37,30 @@ Initial environment issue: Gradle fails before running tasks with `Unable to est - 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. -- Next: final review, release compilation, and device-test availability. +- 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. From 89809246ae5573a29e15bdfd38411ed65e7585ae Mon Sep 17 00:00:00 2001 From: Jair Escamilla Date: Tue, 8 Sep 2026 12:10:11 -0600 Subject: [PATCH 21/21] fix(remote): stream reasoning in active turns --- .../shared/businessLogic/RoxyAppViewModel.kt | 111 ++++++++++++------ .../roxy/shared/data/RemoteWorkspaceClient.kt | 7 ++ .../businessLogic/RoxyAppViewModelTest.kt | 28 +++++ .../shared/data/RemoteWorkspaceClientTest.kt | 12 ++ 4 files changed, 124 insertions(+), 34 deletions(-) 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 97e68c6..193aeb9 100644 --- a/app/src/main/java/gg/roxy/shared/businessLogic/RoxyAppViewModel.kt +++ b/app/src/main/java/gg/roxy/shared/businessLogic/RoxyAppViewModel.kt @@ -245,40 +245,16 @@ class RoxyAppViewModel( } } } - 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 @@ -538,6 +514,73 @@ class RoxyAppViewModel( } } + 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 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 555104a..23e3aaf 100644 --- a/app/src/main/java/gg/roxy/shared/data/RemoteWorkspaceClient.kt +++ b/app/src/main/java/gg/roxy/shared/data/RemoteWorkspaceClient.kt @@ -40,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 @@ -371,6 +372,12 @@ class DefaultRemoteWorkspaceClient @Inject constructor( 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") 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 e73893f..96bbdc0 100644 --- a/app/src/test/java/gg/roxy/shared/businessLogic/RoxyAppViewModelTest.kt +++ b/app/src/test/java/gg/roxy/shared/businessLogic/RoxyAppViewModelTest.kt @@ -246,6 +246,34 @@ 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() { 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 224aa57..0a90275 100644 --- a/app/src/test/java/gg/roxy/shared/data/RemoteWorkspaceClientTest.kt +++ b/app/src/test/java/gg/roxy/shared/data/RemoteWorkspaceClientTest.kt @@ -47,6 +47,18 @@ class RemoteWorkspaceClientTest { 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())