Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
Show all changes
22 commits
Select commit Hold shift + click to select a range
07bfc7c
fix: pin chat transcript to the newest message via reverseLayout
JairEsk Sep 7, 2026
76d8109
feat: segmented PIN entry for the pairing dialog
JairEsk Sep 7, 2026
9da7b8c
fix: resync PIN field when input is rejected
JairEsk Sep 7, 2026
8b3dc83
fix: keep orphan tool calls from owning the newest chat row
JairEsk Sep 7, 2026
9e74f8d
fix: derive streamed part ids from the turn that owns them
JairEsk Sep 7, 2026
1e8f918
fix: stop unrelated connection errors from marking the PIN cells
JairEsk Sep 7, 2026
a618efa
refactor: drop the unused chat sessionId
JairEsk Sep 7, 2026
a3cfc10
fix: gate the chat empty state on transcript emptiness
JairEsk Sep 7, 2026
0d66adc
perf: document chat row flattening cost and avoid a second pass
JairEsk Sep 7, 2026
38b0e64
chore: keep ConnectComputerDialog imports ordered
JairEsk Sep 7, 2026
f8e08fb
fix: make chat follow-tail tolerate small offsets
JairEsk Sep 7, 2026
d333cd7
fix: preserve live turn parts while streaming
JairEsk Sep 7, 2026
9cc2a4a
fix: tighten pairing PIN flow and constants
JairEsk Sep 7, 2026
0b01e02
chore: include model selector cleanup as v1 baseline
JairEsk Sep 7, 2026
0a014a4
docs: record modular v1 reliability implementation plan
JairEsk Sep 7, 2026
5bce104
fix: preserve chat drafts and recover disconnected sessions
JairEsk Sep 7, 2026
72ae0ab
Welcome mobile app 1.0
JairEsk Sep 7, 2026
aa2be37
fix: synchronize chat turns and surface remote failures
JairEsk Sep 8, 2026
110dbf8
feat: stop mobile turns and remove attachment placeholders
JairEsk Sep 8, 2026
765d68c
test: verify v1 recovery flows and release builds
JairEsk Sep 8, 2026
8980924
fix(remote): stream reasoning in active turns
JairEsk Sep 8, 2026
8206ca7
Merge remote-tracking branch 'origin/main' into jair/fixbugs1.0
JairEsk Sep 8, 2026
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
18 changes: 18 additions & 0 deletions .idea/appInsightsSettings.xml

Some generated files are not rendered by default. Learn more about how customized files appear on GitHub.

2 changes: 1 addition & 1 deletion .idea/misc.xml

Some generated files are not rendered by default. Learn more about how customized files appear on GitHub.

6 changes: 6 additions & 0 deletions .idea/vcs.xml

Some generated files are not rendered by default. Learn more about how customized files appear on GitHub.

Binary file added app/google-services.json
Binary file not shown.
Original file line number Diff line number Diff line change
@@ -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) }
}
}
49 changes: 49 additions & 0 deletions app/src/androidTest/java/gg/roxy/shared/components/PinInputTest.kt
Original file line number Diff line number Diff line change
@@ -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)
}
}
}
2 changes: 2 additions & 0 deletions app/src/main/java/gg/roxy/MainActivity.kt
Original file line number Diff line number Diff line change
Expand Up @@ -37,6 +37,8 @@ class MainActivity : ComponentActivity() {
onBackFromChat = viewModel::showMainScreen,
onComposerChange = viewModel::updateComposer,
onComposerSubmit = viewModel::submitComposer,
onReconnect = viewModel::reconnectRemote,
onStop = viewModel::stopTurn,
onToolCallClick = viewModel::toggleToolCall,
onAddNewComputer = viewModel::showConnectDialog,
onScanQrCode = ::startQrScanner,
Expand Down
4 changes: 4 additions & 0 deletions app/src/main/java/gg/roxy/RoxyApp.kt
Original file line number Diff line number Diff line change
Expand Up @@ -25,6 +25,8 @@ fun RoxyApp(
onDisconnectComputer: () -> Unit = {},
initialToken: String = "",
initialPin: String = "",
onReconnect: () -> Unit = {},
onStop: () -> Unit = {},
) {
when (uiState.destination) {
RoxyDestination.Main -> MainFullScreen(
Expand All @@ -43,6 +45,8 @@ fun RoxyApp(
)

RoxyDestination.Chat -> ChatFullScreen(
onReconnect = onReconnect,
onStop = onStop,
uiState = uiState.chat,
onBackClick = onBackFromChat,
onComposerChange = onComposerChange,
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -65,4 +65,16 @@ data class ChatFullScreenUiState(
val composerText: String = "",
val isRunning: Boolean = false,
val isSyncing: Boolean = false,
)
val isConnected: Boolean = false,
val isConnecting: Boolean = false,
val errorMessage: String? = null,
val isSessionReady: Boolean = false,
val queuedPromptCount: Int = 0,
val isAwaitingResponse: Boolean = false,
val isMobileTurn: Boolean = false,
val isStopping: Boolean = false,
) {
val canSubmit: Boolean get() = isConnected && isSessionReady && !isSyncing &&
!isRunning && !isAwaitingResponse && !isStopping && queuedPromptCount == 0 && composerText.isNotBlank()
val canStop: Boolean get() = isConnected && isSessionReady && isRunning && isMobileTurn && !isStopping
}
48 changes: 15 additions & 33 deletions app/src/main/java/gg/roxy/chatFullscreen/components/ChatComposer.kt
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand All @@ -40,6 +38,11 @@ fun ChatComposer(
onTextChange: (String) -> Unit,
onSubmit: () -> Unit,
modifier: Modifier = Modifier,
canSubmit: Boolean = true,
showStop: Boolean = false,
canStop: Boolean = false,
isStopping: Boolean = false,
onStop: () -> Unit = {},
) {
val colors = MaterialTheme.roxyColors

Expand All @@ -65,12 +68,12 @@ fun ChatComposer(
textStyle = MaterialTheme.typography.bodyMedium.copy(color = colors.text),
cursorBrush = SolidColor(colors.accent),
keyboardOptions = KeyboardOptions(imeAction = ImeAction.Send),
keyboardActions = KeyboardActions(onSend = { if (text.isNotBlank()) onSubmit() }),
keyboardActions = KeyboardActions(onSend = { if (canSubmit && text.isNotBlank()) onSubmit() }),
decorationBox = { innerTextField ->
Box {
if (text.isEmpty()) {
Text(
text = "Ask Roxy anything... (paste or drop images)",
text = "Ask Roxy anything...",
style = MaterialTheme.typography.bodyMedium,
color = colors.textMuted,
)
Expand All @@ -87,49 +90,28 @@ 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,
color = colors.textMuted,
modifier = Modifier.weight(1f).padding(end = 8.dp),
)

// Send Button with clean default theme (White when active)
val isSendActive = text.isNotBlank()
val isActionEnabled = if (showStop) canStop else canSubmit && text.isNotBlank()
Surface(
onClick = onSubmit,
enabled = isSendActive,
onClick = if (showStop) onStop else onSubmit,
enabled = isActionEnabled,
modifier = Modifier.size(34.dp),
shape = RoundedCornerShape(10.dp),
color = if (isSendActive) Color.White else colors.white.copy(alpha = 0.25f),
color = if (isActionEnabled) Color.White else colors.white.copy(alpha = 0.25f),
contentColor = Color.Black,
) {
Box(contentAlignment = Alignment.Center) {
Icon(
imageVector = Icons.Rounded.ArrowUpward,
contentDescription = "Send",
imageVector = if (showStop) Icons.Rounded.Stop else Icons.Rounded.ArrowUpward,
contentDescription = if (showStop) { if (isStopping) "Stopping" else "Stop" } else "Send",
modifier = Modifier.size(18.dp),
tint = if (isSendActive) Color.Black else colors.textSubtle,
tint = if (isActionEnabled) Color.Black else colors.textSubtle,
)
}
}
Expand Down
Loading