From f2088dee6f51049018ecd8cc494d2288bccbdd75 Mon Sep 17 00:00:00 2001 From: Claude Date: Sat, 10 Oct 2026 05:30:03 +0000 Subject: [PATCH 1/5] feat(android): use icon buttons for back and settings Replace the Back text buttons on the agent, chat and settings screens and the Settings text button on the task list with icon buttons, and add a plus icon to the New task button. Co-Authored-By: Claude Opus 5.5 Claude-Session: https://claude.ai/code/session_01EezzT287BK4Co1sTK9W1Aq --- .../main/java/com/parallelcode/phone/ChatScreen.kt | 6 ++++-- .../src/main/java/com/parallelcode/phone/Screens.kt | 13 +++++++++---- .../java/com/parallelcode/phone/SettingsScreen.kt | 6 ++++-- 3 files changed, 17 insertions(+), 8 deletions(-) diff --git a/android/app/src/main/java/com/parallelcode/phone/ChatScreen.kt b/android/app/src/main/java/com/parallelcode/phone/ChatScreen.kt index 7d588f484..6f32fe937 100644 --- a/android/app/src/main/java/com/parallelcode/phone/ChatScreen.kt +++ b/android/app/src/main/java/com/parallelcode/phone/ChatScreen.kt @@ -24,6 +24,7 @@ import androidx.compose.foundation.lazy.rememberLazyListState import androidx.compose.foundation.rememberScrollState import androidx.compose.foundation.text.selection.SelectionContainer import androidx.compose.material.icons.Icons +import androidx.compose.material.icons.automirrored.filled.ArrowBack import androidx.compose.material.icons.filled.History import androidx.compose.material.icons.filled.Stop import androidx.compose.material3.Button @@ -33,6 +34,7 @@ import androidx.compose.material3.ExperimentalMaterial3Api import androidx.compose.material3.FilterChip import androidx.compose.material3.HorizontalDivider import androidx.compose.material3.Icon +import androidx.compose.material3.IconButton import androidx.compose.material3.MaterialTheme import androidx.compose.material3.OutlinedButton import androidx.compose.material3.OutlinedTextField @@ -123,8 +125,8 @@ fun ChatScreen( titleContentColor = MaterialTheme.colorScheme.onSurface, ), navigationIcon = { - TextButton(onClick = onBack) { - Text("Back", color = MaterialTheme.colorScheme.primary, fontWeight = FontWeight.SemiBold) + IconButton(onClick = onBack) { + Icon(Icons.AutoMirrored.Filled.ArrowBack, contentDescription = "Back") } }, title = { diff --git a/android/app/src/main/java/com/parallelcode/phone/Screens.kt b/android/app/src/main/java/com/parallelcode/phone/Screens.kt index 656e692cc..4b1a1f59e 100644 --- a/android/app/src/main/java/com/parallelcode/phone/Screens.kt +++ b/android/app/src/main/java/com/parallelcode/phone/Screens.kt @@ -57,12 +57,15 @@ import androidx.compose.foundation.shape.RoundedCornerShape import androidx.compose.foundation.text.KeyboardOptions import androidx.compose.foundation.verticalScroll import androidx.compose.material.icons.Icons +import androidx.compose.material.icons.automirrored.filled.ArrowBack +import androidx.compose.material.icons.filled.Add import androidx.compose.material.icons.filled.Fullscreen import androidx.compose.material.icons.filled.FullscreenExit import androidx.compose.material.icons.filled.History import androidx.compose.material.icons.filled.KeyboardArrowDown import androidx.compose.material.icons.filled.KeyboardArrowRight import androidx.compose.material.icons.filled.KeyboardArrowUp +import androidx.compose.material.icons.filled.Settings import androidx.compose.material.icons.filled.Stop import androidx.compose.material3.AlertDialog import androidx.compose.material3.Button @@ -510,11 +513,13 @@ fun AgentsScreen( ), modifier = Modifier.padding(end = 4.dp), ) { + Icon(Icons.Filled.Add, contentDescription = null, modifier = Modifier.size(18.dp)) + Spacer(Modifier.width(4.dp)) Text("New task", fontWeight = FontWeight.SemiBold, style = MaterialTheme.typography.labelLarge) } } - TextButton(onClick = onSettings) { - Text("Settings", color = MaterialTheme.colorScheme.primary, fontWeight = FontWeight.Medium) + IconButton(onClick = onSettings) { + Icon(Icons.Filled.Settings, contentDescription = "Settings", tint = MaterialTheme.colorScheme.primary) } }, ) @@ -1022,8 +1027,8 @@ fun AgentScreen( navigationIconContentColor = MaterialTheme.colorScheme.primary, ), navigationIcon = { - TextButton(onClick = onBack) { - Text("Back", color = MaterialTheme.colorScheme.primary, fontWeight = FontWeight.SemiBold) + IconButton(onClick = onBack) { + Icon(Icons.AutoMirrored.Filled.ArrowBack, contentDescription = "Back") } }, title = { diff --git a/android/app/src/main/java/com/parallelcode/phone/SettingsScreen.kt b/android/app/src/main/java/com/parallelcode/phone/SettingsScreen.kt index 199f9504a..797e37180 100644 --- a/android/app/src/main/java/com/parallelcode/phone/SettingsScreen.kt +++ b/android/app/src/main/java/com/parallelcode/phone/SettingsScreen.kt @@ -29,6 +29,7 @@ import androidx.compose.foundation.lazy.LazyColumn import androidx.compose.foundation.shape.CircleShape import androidx.compose.foundation.shape.RoundedCornerShape import androidx.compose.material.icons.Icons +import androidx.compose.material.icons.automirrored.filled.ArrowBack import androidx.compose.material.icons.filled.Check import androidx.compose.material3.AlertDialog import androidx.compose.material3.Button @@ -38,6 +39,7 @@ import androidx.compose.material3.CardDefaults import androidx.compose.material3.ExperimentalMaterial3Api import androidx.compose.material3.HorizontalDivider import androidx.compose.material3.Icon +import androidx.compose.material3.IconButton import androidx.compose.material3.MaterialTheme import androidx.compose.material3.OutlinedButton import androidx.compose.material3.RadioButton @@ -127,8 +129,8 @@ fun SettingsScreen( navigationIconContentColor = MaterialTheme.colorScheme.primary, ), navigationIcon = { - TextButton(onClick = onBack) { - Text("← Back", color = MaterialTheme.colorScheme.primary, fontWeight = FontWeight.SemiBold) + IconButton(onClick = onBack) { + Icon(Icons.AutoMirrored.Filled.ArrowBack, contentDescription = "Back") } }, title = { From 54da00350be422c9306ac9bdafe113e2727fae55 Mon Sep 17 00:00:00 2001 From: Claude Date: Sat, 10 Oct 2026 05:30:11 +0000 Subject: [PATCH 2/5] fix(android): tint chat back icon like other screens Co-Authored-By: Claude Opus 5.5 Claude-Session: https://claude.ai/code/session_01EezzT287BK4Co1sTK9W1Aq --- android/app/src/main/java/com/parallelcode/phone/ChatScreen.kt | 1 + 1 file changed, 1 insertion(+) diff --git a/android/app/src/main/java/com/parallelcode/phone/ChatScreen.kt b/android/app/src/main/java/com/parallelcode/phone/ChatScreen.kt index 6f32fe937..26414a6eb 100644 --- a/android/app/src/main/java/com/parallelcode/phone/ChatScreen.kt +++ b/android/app/src/main/java/com/parallelcode/phone/ChatScreen.kt @@ -123,6 +123,7 @@ fun ChatScreen( colors = TopAppBarDefaults.topAppBarColors( containerColor = MaterialTheme.colorScheme.surface, titleContentColor = MaterialTheme.colorScheme.onSurface, + navigationIconContentColor = MaterialTheme.colorScheme.primary, ), navigationIcon = { IconButton(onClick = onBack) { From d21fc7c826324db68665a952605dd837464dcbde Mon Sep 17 00:00:00 2001 From: Claude Date: Sat, 10 Oct 2026 05:41:32 +0000 Subject: [PATCH 3/5] feat(android): pick agent and model for new tasks The phone's New task screen now chooses the project, agent and model from dropdowns instead of a list of radio cards. The desktop serves GET /api/mobile/agents with the available agents and the models each offers (Claude aliases, Codex models from its CLI cache), and POST /api/mobile/tasks accepts optional agentId and model. The renderer checks the choice against the same list and applies the model with the existing reviewer model override. Older phones and desktops keep working: without a choice the desktop default agent runs, and the app hides the agent pickers when a desktop lacks the route. Co-Authored-By: Claude Opus 5.5 Claude-Session: https://claude.ai/code/session_01EezzT287BK4Co1sTK9W1Aq --- android/README.md | 5 +- .../com/parallelcode/phone/AgentChoices.kt | 36 ++++ .../com/parallelcode/phone/RemoteClient.kt | 25 ++- .../com/parallelcode/phone/TaskScreens.kt | 172 ++++++++++++------ .../parallelcode/phone/AgentChoicesTest.kt | 38 ++++ electron/ipc/channel-manifest.json | 1 + electron/ipc/register-wiring.test.ts | 1 + electron/ipc/register.ts | 11 +- electron/preload.cjs | 1 + electron/remote/pairing.test.ts | 50 +++++ electron/remote/protocol.ts | 10 + electron/remote/server.ts | 54 +++++- src/store/remoteTaskHandler.client.test.tsx | 113 ++++++++++++ src/store/remoteTaskHandler.ts | 84 ++++++++- 14 files changed, 524 insertions(+), 77 deletions(-) create mode 100644 android/app/src/main/java/com/parallelcode/phone/AgentChoices.kt create mode 100644 android/app/src/test/java/com/parallelcode/phone/AgentChoicesTest.kt diff --git a/android/README.md b/android/README.md index 30a1e0484..a7256850e 100644 --- a/android/README.md +++ b/android/README.md @@ -19,7 +19,7 @@ Native companion app for the desktop's **Connect Phone** (Remote Access) feature - **Quick replies and voice:** saved replies above the built-in chat's reply box (edit them in Settings; the terminal leaves them out to make room) and a mic button that dictates with Android's speech recognizer. - **Widget:** a home-screen widget with the agents that need you and the usage meters, updated while the app is connected. Settings → Widget sets its background transparency (opaque, 75%, 50% or 25%; the border fades with the card, so your wallpaper shows through) and its card color (Obsidian, Slate or Light, each with text colors that stay readable). - **Notes:** read a task's notes panel; edit and save it once paired. -- **New task:** pick a project and describe the work; needs pairing. +- **New task:** pick a project, agent and model, then describe the work; needs pairing. - **Notifications:** optional, in Settings. A foreground service keeps the connection open in the background and notifies when an agent needs input, hits an error, or finishes (each can be turned off); tapping one opens that agent. - **Close task:** from an agent's screen; needs pairing. Like the desktop, it warns before losing uncommitted or unmerged work. @@ -93,7 +93,8 @@ See `electron/remote/server.ts` and `electron/remote/protocol.ts`. | Watch | `subscribe` / `unsubscribe`; the server sends `scrollback`, then `output` (base64 PTY bytes) | | View size | `view-size` with `{ cols, rows }` (paired) while a terminal is open; without them, or on disconnect, the desktop size returns | | Projects | `GET /api/mobile/projects` (paired) | -| New task | `POST /api/mobile/tasks` with `{ projectId, name, prompt }` (paired); returns `{ taskId }` | +| Agents | `GET /api/mobile/agents` (paired) → `[{ id, name, isDefault, models }]`; `404` on older desktops | +| New task | `POST /api/mobile/tasks` with `{ projectId, name, prompt, agentId?, model? }` (paired); returns `{ taskId }` | | Usage | `GET /api/mobile/usage`; the desktop status bar's snapshot, readable view-only | | Notes | `GET` / `PUT /api/mobile/notes/` with `{ notes }`; reading works view-only, saving needs pairing | | Close task | `POST /api/mobile/tasks//close` with `{ force }` (paired); `409` with `{ warnings }` when work would be lost | diff --git a/android/app/src/main/java/com/parallelcode/phone/AgentChoices.kt b/android/app/src/main/java/com/parallelcode/phone/AgentChoices.kt new file mode 100644 index 000000000..cc457f523 --- /dev/null +++ b/android/app/src/main/java/com/parallelcode/phone/AgentChoices.kt @@ -0,0 +1,36 @@ +package com.parallelcode.phone + +import org.json.JSONArray + +/** A model the desktop lets a phone pick for an agent; [label] is what to show. */ +data class AgentModel(val id: String, val label: String) + +/** + * An agent the phone may start a task with. [models] is empty for an agent that + * only runs with the model its desktop settings configure. + */ +data class MobileAgentChoice( + val id: String, + val name: String, + val isDefault: Boolean, + val models: List, +) + +/** Parsing for the desktop's agent list (GET /api/mobile/agents). */ +internal fun parseAgentChoices(raw: String): List { + val list = JSONArray(raw) + return List(list.length()) { i -> + val agent = list.getJSONObject(i) + val models = agent.optJSONArray("models") ?: JSONArray() + MobileAgentChoice( + id = agent.getString("id"), + name = agent.getString("name"), + isDefault = agent.optBoolean("isDefault", false), + models = List(models.length()) { j -> + val model = models.getJSONObject(j) + val id = model.getString("id") + AgentModel(id, model.optString("label").ifEmpty { id }) + }, + ) + } +} diff --git a/android/app/src/main/java/com/parallelcode/phone/RemoteClient.kt b/android/app/src/main/java/com/parallelcode/phone/RemoteClient.kt index 2823aee14..00809669e 100644 --- a/android/app/src/main/java/com/parallelcode/phone/RemoteClient.kt +++ b/android/app/src/main/java/com/parallelcode/phone/RemoteClient.kt @@ -311,9 +311,30 @@ class RemoteClient( } } - /** Start a top-level task on the desktop; returns its task id. */ - suspend fun createTask(projectId: String, name: String, prompt: String): String { + /** + * Agents and models a paired phone may start tasks with. Empty from a desktop + * that predates the route (it answers 404); tasks then use its default agent. + */ + suspend fun fetchAgentChoices(): List = try { + parseAgentChoices(apiRaw("GET", "/api/mobile/agents", null, pairedTokenOrThrow())) + } catch (e: ApiException) { + if (e.status == 404) emptyList() else throw e + } + + /** + * Start a top-level task on the desktop; returns its task id. A null [agentId] + * or [model] leaves that choice to the desktop's defaults. + */ + suspend fun createTask( + projectId: String, + name: String, + prompt: String, + agentId: String? = null, + model: String? = null, + ): String { val body = JSONObject().put("projectId", projectId).put("name", name).put("prompt", prompt) + agentId?.let { body.put("agentId", it) } + model?.let { body.put("model", it) } return api("POST", "/api/mobile/tasks", body, pairedTokenOrThrow()).getString("taskId") } diff --git a/android/app/src/main/java/com/parallelcode/phone/TaskScreens.kt b/android/app/src/main/java/com/parallelcode/phone/TaskScreens.kt index 91b4a7ba7..e202f5b03 100644 --- a/android/app/src/main/java/com/parallelcode/phone/TaskScreens.kt +++ b/android/app/src/main/java/com/parallelcode/phone/TaskScreens.kt @@ -10,7 +10,6 @@ import androidx.compose.animation.fadeIn import androidx.compose.animation.fadeOut import androidx.compose.animation.shrinkVertically import androidx.compose.animation.togetherWith -import androidx.compose.foundation.BorderStroke import androidx.compose.foundation.interaction.MutableInteractionSource import androidx.compose.foundation.interaction.collectIsPressedAsState import androidx.compose.foundation.layout.Arrangement @@ -22,20 +21,20 @@ import androidx.compose.foundation.layout.heightIn import androidx.compose.foundation.layout.imePadding import androidx.compose.foundation.layout.padding import androidx.compose.foundation.rememberScrollState -import androidx.compose.foundation.selection.selectable import androidx.compose.foundation.shape.RoundedCornerShape import androidx.compose.foundation.verticalScroll import androidx.compose.material3.Button import androidx.compose.material3.ButtonDefaults +import androidx.compose.material3.DropdownMenuItem import androidx.compose.material3.ExperimentalMaterial3Api +import androidx.compose.material3.ExposedDropdownMenuAnchorType +import androidx.compose.material3.ExposedDropdownMenuBox +import androidx.compose.material3.ExposedDropdownMenuDefaults import androidx.compose.material3.HorizontalDivider import androidx.compose.material3.MaterialTheme import androidx.compose.material3.OutlinedTextField import androidx.compose.material3.OutlinedTextFieldDefaults -import androidx.compose.material3.RadioButton -import androidx.compose.material3.RadioButtonDefaults import androidx.compose.material3.Scaffold -import androidx.compose.material3.Surface import androidx.compose.material3.Text import androidx.compose.material3.TextButton import androidx.compose.material3.TopAppBar @@ -51,9 +50,7 @@ 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.draw.clip import androidx.compose.ui.graphics.graphicsLayer -import androidx.compose.ui.semantics.Role import androidx.compose.ui.text.font.FontWeight import androidx.compose.ui.unit.dp import kotlinx.coroutines.launch @@ -68,6 +65,10 @@ fun NewTaskScreen(client: RemoteClient, onDone: () -> Unit, onNeedsPairing: () - var projects by remember { mutableStateOf?>(null) } var loadAttempt by remember { mutableIntStateOf(0) } var projectId by rememberSaveable { mutableStateOf("") } + var agents by remember { mutableStateOf>(emptyList()) } + var agentId by rememberSaveable { mutableStateOf("") } + // Empty runs the agent with the model its desktop settings configure. + var modelId by rememberSaveable { mutableStateOf("") } var name by rememberSaveable { mutableStateOf("") } var prompt by rememberSaveable { mutableStateOf("") } var busy by remember { mutableStateOf(false) } @@ -89,6 +90,20 @@ fun NewTaskScreen(client: RemoteClient, onDone: () -> Unit, onNeedsPairing: () - } catch (e: ApiException) { projects = emptyList() failed(e) + return@LaunchedEffect + } + try { + val choices = client.fetchAgentChoices() + agents = choices + val agent = choices.find { it.id == agentId } ?: choices.find { it.isDefault } ?: choices.firstOrNull() + agentId = agent?.id.orEmpty() + if (agent?.models?.none { it.id == modelId } != false) modelId = "" + } catch (e: ApiException) { + // Without the list the desktop still starts its default agent. + agents = emptyList() + agentId = "" + modelId = "" + if (e.status == 401) onNeedsPairing() } } @@ -124,12 +139,6 @@ fun NewTaskScreen(client: RemoteClient, onDone: () -> Unit, onNeedsPairing: () - .padding(16.dp), verticalArrangement = Arrangement.spacedBy(14.dp), ) { - Text( - "PROJECT", - style = MaterialTheme.typography.labelSmall, - fontWeight = FontWeight.Bold, - color = AppTheme.extra.textMuted, - ) val loaded = projects when { loaded == null -> Text("Loading projects…", color = AppTheme.extra.textMuted) @@ -140,47 +149,43 @@ fun NewTaskScreen(client: RemoteClient, onDone: () -> Unit, onNeedsPairing: () - ) TextButton(onClick = { loadAttempt++ }) { Text("Retry") } } - else -> loaded.forEach { project -> - val isSelected = project.id == projectId - Surface( - shape = MaterialTheme.shapes.large, - color = if (isSelected) MaterialTheme.colorScheme.primaryContainer.copy(alpha = 0.35f) else MaterialTheme.colorScheme.surface, - border = BorderStroke(1.dp, if (isSelected) MaterialTheme.colorScheme.primary else AppTheme.extra.border), - modifier = Modifier - .fillMaxWidth() - .clip(MaterialTheme.shapes.large) - .selectable( - selected = isSelected, - enabled = !busy, - role = Role.RadioButton, - onClick = { projectId = project.id }, - ), - ) { - Row( - Modifier.padding(12.dp), - verticalAlignment = Alignment.CenterVertically, - ) { - RadioButton( - selected = isSelected, - onClick = null, + else -> { + SelectField( + label = "Project", + options = loaded, + selected = loaded.find { it.id == projectId }, + optionLabel = { it.name }, + onSelect = { projectId = it.id }, + enabled = !busy, + ) + val agent = agents.find { it.id == agentId } + if (agent == null) { + // An older desktop lists no agents; say which one it will start. + loaded.find { it.id == projectId }?.agentName?.let { + Text("Runs with $it", style = MaterialTheme.typography.bodySmall, color = AppTheme.extra.textMuted) + } + } else { + SelectField( + label = "Agent", + options = agents, + selected = agent, + optionLabel = { it.name }, + onSelect = { + if (it.id != agentId) modelId = "" + agentId = it.id + }, + enabled = !busy, + ) + if (agent.models.isNotEmpty()) { + val defaultModel = AgentModel("", "Default") + SelectField( + label = "Model", + options = listOf(defaultModel) + agent.models, + selected = agent.models.find { it.id == modelId } ?: defaultModel, + optionLabel = { it.label }, + onSelect = { modelId = it.id }, enabled = !busy, - colors = RadioButtonDefaults.colors( - selectedColor = MaterialTheme.colorScheme.primary, - unselectedColor = AppTheme.extra.textMuted, - ), ) - Column(Modifier.padding(start = 10.dp)) { - Text( - project.name, - fontWeight = if (isSelected) FontWeight.SemiBold else FontWeight.Normal, - color = AppTheme.extra.textPrimary, - ) - Text( - project.agentName?.let { "Runs with $it" } ?: "Runs with your default agent", - style = MaterialTheme.typography.bodySmall, - color = AppTheme.extra.textMuted, - ) - } } } } @@ -250,7 +255,13 @@ fun NewTaskScreen(client: RemoteClient, onDone: () -> Unit, onNeedsPairing: () - error = null scope.launch { try { - client.createTask(projectId, title, prompt.trim()) + client.createTask( + projectId, + title, + prompt.trim(), + agentId.ifEmpty { null }, + modelId.ifEmpty { null }, + ) onDone() } catch (e: ApiException) { failed( @@ -267,6 +278,61 @@ fun NewTaskScreen(client: RemoteClient, onDone: () -> Unit, onNeedsPairing: () - } } +/** A read-only dropdown, the phone's equivalent of an HTML select. */ +@OptIn(ExperimentalMaterial3Api::class) +@Composable +private fun SelectField( + label: String, + options: List, + selected: T?, + optionLabel: (T) -> String, + onSelect: (T) -> Unit, + enabled: Boolean, +) { + var expanded by remember { mutableStateOf(false) } + ExposedDropdownMenuBox( + expanded = expanded && enabled, + onExpandedChange = { if (enabled) expanded = it }, + ) { + OutlinedTextField( + value = selected?.let(optionLabel).orEmpty(), + onValueChange = {}, + readOnly = true, + singleLine = true, + enabled = enabled, + label = { Text(label) }, + trailingIcon = { ExposedDropdownMenuDefaults.TrailingIcon(expanded = expanded && enabled) }, + shape = MaterialTheme.shapes.large, + colors = OutlinedTextFieldDefaults.colors( + focusedContainerColor = AppTheme.extra.inputBg, + unfocusedContainerColor = AppTheme.extra.inputBg, + focusedBorderColor = MaterialTheme.colorScheme.primary, + unfocusedBorderColor = AppTheme.extra.border, + ), + modifier = Modifier + .fillMaxWidth() + .menuAnchor(ExposedDropdownMenuAnchorType.PrimaryNotEditable, enabled), + ) + ExposedDropdownMenu(expanded = expanded && enabled, onDismissRequest = { expanded = false }) { + options.forEach { option -> + DropdownMenuItem( + text = { + Text( + optionLabel(option), + fontWeight = if (option == selected) FontWeight.SemiBold else FontWeight.Normal, + ) + }, + onClick = { + onSelect(option) + expanded = false + }, + contentPadding = ExposedDropdownMenuDefaults.ItemContentPadding, + ) + } + } + } +} + /** The task's notes panel from the desktop; editable once the phone is paired. */ @Composable fun NotesPane(taskId: String, canEdit: Boolean, client: RemoteClient, modifier: Modifier) { diff --git a/android/app/src/test/java/com/parallelcode/phone/AgentChoicesTest.kt b/android/app/src/test/java/com/parallelcode/phone/AgentChoicesTest.kt new file mode 100644 index 000000000..ec694acea --- /dev/null +++ b/android/app/src/test/java/com/parallelcode/phone/AgentChoicesTest.kt @@ -0,0 +1,38 @@ +package com.parallelcode.phone + +import org.junit.Assert.assertEquals +import org.junit.Test + +class AgentChoicesTest { + @Test + fun readsAgentsWithTheirModels() { + val agents = parseAgentChoices( + """ + [{"id":"claude","name":"Claude Code","isDefault":true, + "models":[{"id":"opus","label":"opus"},{"id":"gpt-5","label":""}]}, + {"id":"gemini","name":"Gemini","isDefault":false,"models":[]}] + """.trimIndent(), + ) + assertEquals( + listOf( + MobileAgentChoice( + "claude", + "Claude Code", + true, + // A blank label falls back to the model id. + listOf(AgentModel("opus", "opus"), AgentModel("gpt-5", "gpt-5")), + ), + MobileAgentChoice("gemini", "Gemini", false, emptyList()), + ), + agents, + ) + } + + @Test + fun treatsMissingFieldsAsNoModelsAndNotDefault() { + assertEquals( + listOf(MobileAgentChoice("codex", "Codex", false, emptyList())), + parseAgentChoices("""[{"id":"codex","name":"Codex"}]"""), + ) + } +} diff --git a/electron/ipc/channel-manifest.json b/electron/ipc/channel-manifest.json index ed4271f52..1cf98d7c3 100644 --- a/electron/ipc/channel-manifest.json +++ b/electron/ipc/channel-manifest.json @@ -87,6 +87,7 @@ "GeneratePairingPin": "generate_pairing_pin", "Remote_GetProjectsRequest": "remote_get_projects_request", "Remote_CreateTaskRequest": "remote_create_task_request", + "Remote_GetAgentsRequest": "remote_get_agents_request", "MCP_ReadMindMapRequest": "mcp_read_mindmap_request", "MCP_ReadReasoningRequest": "mcp_read_reasoning_request", "MCP_UpdateReasoningRequest": "mcp_update_reasoning_request", diff --git a/electron/ipc/register-wiring.test.ts b/electron/ipc/register-wiring.test.ts index 056501ed3..edfc284d7 100644 --- a/electron/ipc/register-wiring.test.ts +++ b/electron/ipc/register-wiring.test.ts @@ -86,6 +86,7 @@ const NOT_HANDLED_HERE: readonly string[] = [ IPC.PrChecksUpdate, IPC.Remote_CloseTaskRequest, IPC.Remote_CreateTaskRequest, + IPC.Remote_GetAgentsRequest, IPC.Remote_GetDiffRequest, IPC.Remote_GetNotesRequest, IPC.Remote_GetProjectsRequest, diff --git a/electron/ipc/register.ts b/electron/ipc/register.ts index adbc6d791..3b124a736 100644 --- a/electron/ipc/register.ts +++ b/electron/ipc/register.ts @@ -71,8 +71,14 @@ import { readCoverageSummary } from './coverage.js'; import { loadEslintQualityFindings } from './eslint-quality-findings.js'; import { buildVerifyEnv, validateVerifyCommand, verificationRunner } from './verify.js'; import { scanEvidence } from './evidence-scan.js'; -import { startRemoteServer, getMCPLogs, type RemoteProject } from '../remote/server.js'; +import { + startRemoteServer, + getMCPLogs, + type MobileTaskRequest, + type RemoteProject, +} from '../remote/server.js'; import type { + RemoteAgentChoice, RemoteAttentionState, RemoteCloseResult, RemoteMergeReadiness, @@ -1776,7 +1782,8 @@ export function registerAllHandlers(win: BrowserWindow): void { callRenderer(IPC.MCP_SubmitEvidenceRequest, { taskId, payload }), getEvidence: (taskId: string) => callRenderer(IPC.MCP_GetEvidenceRequest, { taskId }), getProjects: () => callRenderer(IPC.Remote_GetProjectsRequest, {}), - createTaskFromMobile: (req: { projectId: string; name: string; prompt: string }) => + getAgentChoices: () => callRenderer(IPC.Remote_GetAgentsRequest, {}), + createTaskFromMobile: (req: MobileTaskRequest) => callRenderer<{ taskId: string }>(IPC.Remote_CreateTaskRequest, req), getTaskNotes: (taskId: string) => callRenderer<{ notes: string }>(IPC.Remote_GetNotesRequest, { taskId }).then((r) => r.notes), diff --git a/electron/preload.cjs b/electron/preload.cjs index 304a9640c..4babad416 100644 --- a/electron/preload.cjs +++ b/electron/preload.cjs @@ -94,6 +94,7 @@ const ALLOWED_CHANNELS = new Set([ 'generate_pairing_pin', 'remote_get_projects_request', 'remote_create_task_request', + 'remote_get_agents_request', 'mcp_read_mindmap_request', 'mcp_read_reasoning_request', 'mcp_update_reasoning_request', diff --git a/electron/remote/pairing.test.ts b/electron/remote/pairing.test.ts index 55a583935..665c7b391 100644 --- a/electron/remote/pairing.test.ts +++ b/electron/remote/pairing.test.ts @@ -51,6 +51,10 @@ let credentialsDir: string; let generatePin: () => { pin: string; expiresAt: number }; const createTaskFromMobile = vi.fn(async () => ({ taskId: 'task-123' })); const getProjects = vi.fn(async () => [{ id: 'proj-1', name: 'Repo One' }]); +const agentChoices = [ + { id: 'claude', name: 'Claude', isDefault: true, models: [{ id: 'opus', label: 'opus' }] }, +]; +const getAgentChoices = vi.fn(async () => agentChoices); function req(method: string, path: string, token: string, body?: unknown): Promise { return new Promise((resolve, reject) => { @@ -93,6 +97,7 @@ async function startServer(enableRemembered = true) { getAgentStatus: () => ({ status: 'exited', exitCode: null, lastLine: '' }), getCoordinator: () => null, getProjects, + getAgentChoices, createTaskFromMobile, }); if (enableRemembered) srv.enableRememberedDevices(join(credentialsDir, 'phones.json')); @@ -286,6 +291,7 @@ describe('pairing', () => { describe('paired-mobile routes', () => { it('mobile token cannot list projects or create tasks (403)', async () => { expect((await req('GET', '/api/mobile/projects', mobileToken)).status).toBe(403); + expect((await req('GET', '/api/mobile/agents', mobileToken)).status).toBe(403); expect( ( await req('POST', '/api/mobile/tasks', mobileToken, { @@ -320,6 +326,50 @@ describe('paired-mobile routes', () => { }); }); + it('paired token can list agents and their models', async () => { + const paired = await pair(); + const res = await req('GET', '/api/mobile/agents', paired); + expect(res.status).toBe(200); + expect(await res.json()).toEqual(agentChoices); + }); + + it('passes a chosen agent and model through to task creation', async () => { + const paired = await pair(); + const res = await req('POST', '/api/mobile/tasks', paired, { + projectId: 'proj-1', + name: 'Fix bug', + prompt: 'Investigate the crash', + agentId: 'claude', + model: 'opus', + }); + expect(res.status).toBe(201); + expect(createTaskFromMobile).toHaveBeenCalledWith({ + projectId: 'proj-1', + name: 'Fix bug', + prompt: 'Investigate the crash', + agentId: 'claude', + model: 'opus', + }); + }); + + it.each([ + { agentId: '' }, + { agentId: 42 }, + { model: '--dangerously-skip-permissions' }, + { model: 'opus extra' }, + { model: 7 }, + ])('rejects an invalid agent or model choice: %o', async (choice) => { + const paired = await pair(); + const res = await req('POST', '/api/mobile/tasks', paired, { + projectId: 'proj-1', + name: 'n', + prompt: 'x', + ...choice, + }); + expect(res.status).toBe(400); + expect(createTaskFromMobile).not.toHaveBeenCalled(); + }); + it('rejects task creation with a missing name or prompt', async () => { const paired = await pair(); expect( diff --git a/electron/remote/protocol.ts b/electron/remote/protocol.ts index 9e9da2d37..32abcd195 100644 --- a/electron/remote/protocol.ts +++ b/electron/remote/protocol.ts @@ -38,6 +38,16 @@ export interface RemoteAgent { * The desktop's answer to a phone's close request. `closed: false` means * nothing was closed because closing would lose work; `warnings` says what. */ +/** An agent the mobile "New Task" screen can launch, with the models it offers. */ +export interface RemoteAgentChoice { + id: string; + name: string; + /** The agent the desktop New Task dialog would preselect. */ + isDefault: boolean; + /** Model overrides; empty when the agent only runs with its configured model. */ + models: { id: string; label: string }[]; +} + export type RemoteCloseResult = { closed: true } | { closed: false; warnings: string[] }; /** A task's changes against its base branch, as a unified diff; `truncated` when cut short. */ diff --git a/electron/remote/server.ts b/electron/remote/server.ts index cebe68fef..ae7742287 100644 --- a/electron/remote/server.ts +++ b/electron/remote/server.ts @@ -28,6 +28,7 @@ import { parseClientMessage, type ServerMessage, type RemoteAgent, + type RemoteAgentChoice, type RemoteAttentionState, type RemoteTaskContext, type RemoteCloseResult, @@ -56,6 +57,7 @@ import type { Coordinator } from '../mcp/coordinator.js'; import { validateBranchName } from '../mcp/validation.js'; import type { ApiTaskDetail, LandSelfInput, SubtaskVerification } from '../mcp/types.js'; import { parseSignalDoneInput } from '../shared/completion-report.js'; +import { CODEX_MODEL_PATTERN } from '../shared/ask-code-models.js'; // --- MCP log ring buffer --- export interface MCPLogEntry { @@ -303,6 +305,15 @@ export interface RemoteProject { name: string; } +/** The task a paired phone asked for; agent and model fall back to the desktop default. */ +export type MobileTaskRequest = { + projectId: string; + name: string; + prompt: string; + agentId?: string; + model?: string; +}; + /** Detect available network IPs (WiFi and Tailscale). */ function getNetworkIps(): { wifi: string | null; tailscale: string | null } { const nets = networkInterfaces(); @@ -987,12 +998,10 @@ export function startRemoteServer(opts: { ) => Promise; /** List projects the mobile "New Task" screen can target (renderer-backed). */ getProjects?: () => Promise; + /** List agents and models the mobile "New Task" screen offers (renderer-backed). */ + getAgentChoices?: () => Promise; /** Create a top-level task on behalf of a paired phone (renderer-backed). */ - createTaskFromMobile?: (req: { - projectId: string; - name: string; - prompt: string; - }) => Promise<{ taskId: string }>; + createTaskFromMobile?: (req: MobileTaskRequest) => Promise<{ taskId: string }>; readMindMap?: (taskId: string) => Promise; readReasoning?: (taskId: string) => Promise; updateReasoning?: (taskId: string, update: ReasoningUpdate) => Promise; @@ -1455,11 +1464,24 @@ export function startRemoteServer(opts: { } // --- Paired-mobile task creation --- - // GET projects for the picker + POST a new top-level task. Both require the - // elevated "paired" token; the read-only mobile token is rejected here. - if (url.pathname === '/api/mobile/projects' || url.pathname === '/api/mobile/tasks') { + // GET projects and agents for the pickers + POST a new top-level task. All + // require the elevated "paired" token; the read-only mobile token is rejected here. + if ( + url.pathname === '/api/mobile/projects' || + url.pathname === '/api/mobile/agents' || + url.pathname === '/api/mobile/tasks' + ) { if (tokenClass !== 'paired') return jsonEnd(403, { error: 'forbidden' }); + if (url.pathname === '/api/mobile/agents' && req.method === 'GET') { + if (!opts.getAgentChoices) return jsonEnd(503, { error: 'task creation unavailable' }); + opts + .getAgentChoices() + .then((agents) => jsonEnd(200, agents)) + .catch((err) => jsonEnd(500, { error: String(err) })); + return; + } + if (url.pathname === '/api/mobile/projects' && req.method === 'GET') { if (!opts.getProjects) return jsonEnd(503, { error: 'task creation unavailable' }); opts @@ -1486,7 +1508,21 @@ export function startRemoteServer(opts: { const projectId = typeof body.projectId === 'string' ? body.projectId : ''; if (!projectId) return jsonEnd(400, { error: 'projectId must be a non-empty string' }); - createTask({ projectId, name, prompt }) + const request: MobileTaskRequest = { projectId, name, prompt }; + // Optional: older phones omit both and get the desktop default agent. + if (body.agentId !== undefined) { + if (typeof body.agentId !== 'string' || !body.agentId || body.agentId.length > 200) + return jsonEnd(400, { error: 'agentId must be a non-empty string' }); + request.agentId = body.agentId; + } + if (body.model !== undefined) { + // The renderer checks the model against the agent's list; this + // only keeps the value a plain CLI argument. + if (typeof body.model !== 'string' || !CODEX_MODEL_PATTERN.test(body.model)) + return jsonEnd(400, { error: 'model is not a valid model name' }); + request.model = body.model; + } + createTask(request) .then((r) => jsonEnd(201, { taskId: r.taskId })) .catch((err) => jsonEnd(500, { error: String(err) })); }) diff --git a/src/store/remoteTaskHandler.client.test.tsx b/src/store/remoteTaskHandler.client.test.tsx index c978bd2ef..ed052b6d8 100644 --- a/src/store/remoteTaskHandler.client.test.tsx +++ b/src/store/remoteTaskHandler.client.test.tsx @@ -189,6 +189,119 @@ it('adds a task created from a phone without taking focus from the active task', expect(store.activeAgentId).toBe('agent'); }); +const phoneAgents = [ + { + id: 'claude', + name: 'Claude', + command: 'claude', + args: ['--model', 'sonnet'], + resume_args: ['--continue'], + skip_permissions_args: [], + description: '', + }, + { + id: 'codex', + name: 'Codex', + command: '/usr/bin/codex', + args: [], + resume_args: [], + skip_permissions_args: [], + description: '', + }, + { + id: 'gemini', + name: 'Gemini', + command: 'gemini', + args: [], + resume_args: [], + skip_permissions_args: [], + description: '', + }, + { + id: 'missing', + name: 'Missing', + command: 'missing', + args: [], + resume_args: [], + skip_permissions_args: [], + description: '', + available: false, + }, +]; + +function mockPhoneTaskIpc() { + setStore('projects', [ + { id: 'project', name: 'Project', path: '/tmp/project', color: '', defaultBaseBranch: 'main' }, + ]); + setStore('availableAgents', phoneAgents); + setStore('lastAgentId', 'codex'); + vi.mocked(invoke).mockImplementation(async (channel: string) => { + if (channel === IPC.ListCodexModels) return [{ slug: 'gpt-5', displayName: 'GPT-5' }]; + if (channel === IPC.GetGitignoredDirs) return []; + if (channel === IPC.CreateTask) + return { id: 'phone-task', branch_name: 'task/phone', worktree_path: '/tmp/phone' }; + return undefined; + }); +} + +/** Sends a phone request and waits for the reply the main process would forward. */ +async function phoneRequest(channel: string, payload: Record) { + listeners.get(channel)?.({ reqId: 'req', ...payload }); + await vi.waitFor(() => + expect(vi.mocked(invoke)).toHaveBeenCalledWith(IPC.Remote_RendererReply, expect.anything()), + ); + const reply = vi.mocked(invoke).mock.calls.find(([c]) => c === IPC.Remote_RendererReply); + return reply?.[1] as { ok: boolean; data?: unknown; error?: string }; +} + +it('lists available agents with the models a phone may pick', async () => { + mockPhoneTaskIpc(); + const reply = await phoneRequest(IPC.Remote_GetAgentsRequest, {}); + expect(reply.data).toEqual([ + { + id: 'claude', + name: 'Claude', + isDefault: false, + models: ['fable', 'opus', 'sonnet', 'haiku'].map((id) => ({ id, label: id })), + }, + { id: 'codex', name: 'Codex', isDefault: true, models: [{ id: 'gpt-5', label: 'GPT-5' }] }, + { id: 'gemini', name: 'Gemini', isDefault: false, models: [] }, + ]); +}); + +it('launches a phone task with the chosen agent and model', async () => { + mockPhoneTaskIpc(); + const reply = await phoneRequest(IPC.Remote_CreateTaskRequest, { + projectId: 'project', + name: 'From phone', + prompt: 'Do it', + agentId: 'claude', + model: 'opus', + }); + expect(reply).toMatchObject({ ok: true, data: { taskId: 'phone-task' } }); + const agentId = store.tasks['phone-task']?.agentIds[0] ?? ''; + // The chosen model replaces the configured one rather than adding a second flag. + expect(store.agents[agentId]?.def).toMatchObject({ id: 'claude', args: ['--model', 'opus'] }); +}); + +it.each([ + { agentId: 'missing', error: 'Agent not available' }, + { agentId: 'unknown', error: 'Agent not available' }, + { agentId: 'gemini', model: 'opus', error: 'Model not available for this agent' }, + { agentId: 'claude', model: 'gpt-5', error: 'Model not available for this agent' }, +])('rejects a stale agent or model choice: $agentId $model', async (choice) => { + mockPhoneTaskIpc(); + const reply = await phoneRequest(IPC.Remote_CreateTaskRequest, { + projectId: 'project', + name: 'From phone', + prompt: 'Do it', + agentId: choice.agentId, + model: choice.model, + }); + expect(reply).toMatchObject({ ok: false, error: choice.error }); + expect(vi.mocked(invoke)).not.toHaveBeenCalledWith(IPC.CreateTask, expect.anything()); +}); + /** Replies to the close request once the handler has finished. */ async function closeRequest(force: boolean) { listeners.get(IPC.Remote_CloseTaskRequest)?.({ reqId: 'req', taskId: 'task', force }); diff --git a/src/store/remoteTaskHandler.ts b/src/store/remoteTaskHandler.ts index 42a3c71c9..ab886969a 100644 --- a/src/store/remoteTaskHandler.ts +++ b/src/store/remoteTaskHandler.ts @@ -28,9 +28,18 @@ import { getTaskDiffBaseBranch, loadTaskDiff } from '../lib/load-task-diff'; import { IPC } from '../../electron/ipc/channels'; import { parseEvidenceSubmission } from '../../electron/shared/evidence'; import { resolveSkipPermissionsArgs } from '../../electron/shared/skip-permissions'; +import { + ASK_CODE_CLAUDE_MODELS, + type CodexModelChoice, +} from '../../electron/shared/ask-code-models'; +import { reviewerWithModel } from '../lib/agent-handoff'; import type { AgentDef, GitIgnoredEntry, MergeStatus, WorktreeStatus } from '../ipc/types'; import type { Task } from './types'; -import type { RemoteCloseResult, RemoteTaskDiff } from '../../electron/remote/protocol'; +import type { + RemoteAgentChoice, + RemoteCloseResult, + RemoteTaskDiff, +} from '../../electron/remote/protocol'; interface RendererRequest { reqId: string; @@ -40,6 +49,8 @@ interface CreateTaskRequest extends RendererRequest { projectId: string; name: string; prompt: string; + agentId?: string; + model?: string; } interface GetNotesRequest extends RendererRequest { @@ -71,13 +82,67 @@ function handleGetProjects(req: RendererRequest): void { codeProjects().map((p) => ({ id: p.id, name: p.name, - agentName: - (store.availableAgents.find((a) => a.id === store.lastAgentId) ?? store.availableAgents[0]) - ?.name ?? '', + agentName: defaultAgent()?.name ?? '', })), ); } +/** The agent the desktop New Task dialog preselects: the last one used, else the first. */ +function defaultAgent(): AgentDef | undefined { + return store.availableAgents.find((a) => a.id === store.lastAgentId) ?? store.availableAgents[0]; +} + +/** + * Models a phone may pick for an agent: the Claude aliases and the Codex CLI's + * cached list, the same choices the desktop model menus offer. Other agents + * only run with the model their own arguments configure. + */ +async function agentModelChoices(agent: AgentDef): Promise { + const command = agent.command.split('/').pop(); + if (command === 'claude') + return ASK_CODE_CLAUDE_MODELS.map((model) => ({ id: model, label: model })); + if (command !== 'codex') return []; + try { + const list = await invoke(IPC.ListCodexModels); + return Array.isArray(list) ? list.map((m) => ({ id: m.slug, label: m.displayName })) : []; + } catch { + // The CLI may not have written its cache yet; the agent still runs with its default. + return []; + } +} + +async function handleGetAgents(req: RendererRequest): Promise { + try { + const fallback = defaultAgent(); + const agents: RemoteAgentChoice[] = await Promise.all( + store.availableAgents + .filter((agent) => agent.available !== false) + .map(async (agent) => ({ + id: agent.id, + name: agent.name, + isDefault: agent.id === fallback?.id, + models: await agentModelChoices(agent), + })), + ); + reply(req.reqId, true, agents); + } catch (err) { + reply(req.reqId, false, undefined, errMessage(err)); + } +} + +/** The agent a phone asked for, with its model override applied. Throws on a stale choice. */ +async function resolveMobileAgent(req: CreateTaskRequest): Promise { + const agentDef = req.agentId + ? store.availableAgents.find((a) => a.id === req.agentId && a.available !== false) + : defaultAgent(); + if (!agentDef) throw new Error(req.agentId ? 'Agent not available' : 'No agent configured'); + if (!req.model) return agentDef; + const models = await agentModelChoices(agentDef); + if (!models.some((m) => m.id === req.model)) + throw new Error('Model not available for this agent'); + return reviewerWithModel(agentDef, req.model); +} + /** * Whether a task created from a paired phone should launch with the agent's * skip-permissions flag. @@ -99,11 +164,8 @@ async function handleCreateTask(req: CreateTaskRequest): Promise { const project = store.projects.find((p) => p.id === req.projectId); if (!project) throw new Error('Project not found'); - // Default agent: the last one used, else the first available (mirrors the - // New Task dialog's initial selection). - const agentDef = - store.availableAgents.find((a) => a.id === store.lastAgentId) ?? store.availableAgents[0]; - if (!agentDef) throw new Error('No agent configured'); + // Without a choice from the phone, the agent the New Task dialog preselects. + const agentDef = await resolveMobileAgent(req); // Non-git projects can't use worktree isolation; fall back to working // directly in the project folder. @@ -385,6 +447,9 @@ export function startRemoteTaskHandlers(): () => void { if (data && typeof data === 'object') handleGetProjects(data as RendererRequest); }, ); + const offAgents = window.electron.ipcRenderer.on(IPC.Remote_GetAgentsRequest, (data: unknown) => { + if (data && typeof data === 'object') void handleGetAgents(data as RendererRequest); + }); const offCreate = window.electron.ipcRenderer.on( IPC.Remote_CreateTaskRequest, (data: unknown) => { @@ -514,6 +579,7 @@ export function startRemoteTaskHandlers(): () => void { offPublishGitHubList(); offProjects(); offCreate(); + offAgents(); offGetNotes(); offSetNotes(); offClose(); From 6a13c367d028e0d71a13c403982c32c7b17f195d Mon Sep 17 00:00:00 2001 From: Claude Date: Sat, 10 Oct 2026 05:43:03 +0000 Subject: [PATCH 4/5] fix(android): wait for the desktop when creating, merging or closing tasks Creating a task makes the desktop build a git worktree before it answers, and it allows itself 120 s for that. The app's HTTP client kept OkHttp's default 10 s read timeout, so a slower creation failed on the phone with "Could not reach your computer" even though the desktop went on to create the task. Merge and close wait on git the same way. These requests now get a read timeout just above the desktop's, and a timeout on them says the computer did not answer in time instead of calling it unreachable. Co-Authored-By: Claude Opus 5.5 Claude-Session: https://claude.ai/code/session_01EezzT287BK4Co1sTK9W1Aq --- .../com/parallelcode/phone/RemoteClient.kt | 49 ++++++++++++++++--- 1 file changed, 43 insertions(+), 6 deletions(-) diff --git a/android/app/src/main/java/com/parallelcode/phone/RemoteClient.kt b/android/app/src/main/java/com/parallelcode/phone/RemoteClient.kt index 00809669e..c6412fe9f 100644 --- a/android/app/src/main/java/com/parallelcode/phone/RemoteClient.kt +++ b/android/app/src/main/java/com/parallelcode/phone/RemoteClient.kt @@ -27,6 +27,7 @@ import org.json.JSONArray import org.json.JSONException import org.json.JSONObject import java.io.IOException +import java.net.SocketTimeoutException import java.net.URLEncoder import java.util.concurrent.TimeUnit @@ -54,6 +55,9 @@ interface TerminalListener { /** A REST call the desktop refused or could not answer; `status` is 0 when it was unreachable. */ /** [json] is the error reply's body, for routes that explain a refusal (e.g. close warnings). */ +/** Read timeout for requests that wait on desktop git work; see [RemoteClient.slowHttp]. */ +private const val SLOW_REQUEST_SECONDS = 130L + class ApiException(message: String, val status: Int = 0, val json: JSONObject? = null) : IOException(message) data class MobileProject(val id: String, val name: String, val agentName: String?) @@ -94,6 +98,15 @@ class RemoteClient( .pingInterval(30, TimeUnit.SECONDS) .build() + /** + * For requests the desktop answers only once real git work is done: creating a task builds a + * worktree, merging and closing run git too. The desktop waits up to 120 s for that work + * (callRenderer in electron/ipc/register.ts), so OkHttp's default 10 s read timeout reported + * "could not reach your computer" for a request that was still running, and usually succeeded. + * A little longer than the desktop lets the desktop's own error come through instead. + */ + private val slowHttp = http.newBuilder().readTimeout(SLOW_REQUEST_SECONDS, TimeUnit.SECONDS).build() + private val _state = MutableStateFlow(ConnectionState(link = credentials.link)) val state: StateFlow = _state.asStateFlow() @@ -335,7 +348,7 @@ class RemoteClient( val body = JSONObject().put("projectId", projectId).put("name", name).put("prompt", prompt) agentId?.let { body.put("agentId", it) } model?.let { body.put("model", it) } - return api("POST", "/api/mobile/tasks", body, pairedTokenOrThrow()).getString("taskId") + return api("POST", "/api/mobile/tasks", body, pairedTokenOrThrow(), slow = true).getString("taskId") } /** The task's notes panel; readable with the view-only token. */ @@ -394,6 +407,7 @@ class RemoteClient( "/api/mobile/tasks/${encodePath(taskId)}/merge", JSONObject().put("squash", squash).put("cleanup", cleanup), pairedTokenOrThrow(), + slow = true, ) } @@ -409,7 +423,13 @@ class RemoteClient( * desktop refuses when work would be lost and this returns its warnings; empty means closed. */ suspend fun closeTask(taskId: String, force: Boolean): List = try { - api("POST", "/api/mobile/tasks/${encodePath(taskId)}/close", JSONObject().put("force", force), pairedTokenOrThrow()) + api( + "POST", + "/api/mobile/tasks/${encodePath(taskId)}/close", + JSONObject().put("force", force), + pairedTokenOrThrow(), + slow = true, + ) emptyList() } catch (e: ApiException) { val warnings = e.json?.optJSONArray("warnings") @@ -424,14 +444,26 @@ class RemoteClient( private fun pairedTokenOrThrow(): String = credentials.pairedToken ?: throw ApiException("Pair this phone first.", 401) - private suspend fun api(method: String, path: String, body: JSONObject?, token: String?): JSONObject = + private suspend fun api( + method: String, + path: String, + body: JSONObject?, + token: String?, + slow: Boolean = false, + ): JSONObject = try { - JSONObject(apiRaw(method, path, body, token)) + JSONObject(apiRaw(method, path, body, token, slow)) } catch (e: JSONException) { throw ApiException("Your computer sent an unexpected reply.") } - private suspend fun apiRaw(method: String, path: String, body: JSONObject?, token: String?): String { + private suspend fun apiRaw( + method: String, + path: String, + body: JSONObject?, + token: String?, + slow: Boolean = false, + ): String { val link = credentials.link ?: throw ApiException("Not connected to a computer.") if (token == null) throw ApiException("Not connected to a computer.") if (mustWaitForVpn()) { @@ -445,9 +477,14 @@ class RemoteClient( val start = System.currentTimeMillis() val (code, text) = withContext(Dispatchers.IO) { try { - http.newCall(request).execute().use { it.code to it.body.string() } + (if (slow) slowHttp else http).newCall(request).execute().use { it.code to it.body.string() } } catch (e: IOException) { _latencyMs.value = null + // A slow request may still finish on the desktop, so don't call it unreachable. + // (A connect timeout is also a SocketTimeoutException, hence the hedged wording.) + if (slow && e is SocketTimeoutException) { + throw ApiException("Your computer did not answer in time. The change may still go through.") + } throw ApiException("Could not reach your computer. Check you're on the same network.") } } From be053c0684ce3f48e79288e19825374bb001f025 Mon Sep 17 00:00:00 2001 From: Claude Date: Sat, 10 Oct 2026 05:47:43 +0000 Subject: [PATCH 5/5] fix(android): address review of agent picker and slow requests - Treat 403 as an older desktop without the agents route; that is what it answers a paired token, not 404. - Report a malformed agents reply as an error instead of crashing. - Give the diff and merge readiness the longer timeout too: the desktop runs git for both. - Don't flash the older-desktop agent note while agents load. - Keep new declarations below the doc comments they had displaced. Co-Authored-By: Claude Opus 5.5 Claude-Session: https://claude.ai/code/session_01EezzT287BK4Co1sTK9W1Aq --- android/README.md | 2 +- .../com/parallelcode/phone/RemoteClient.kt | 37 +++++++++++++------ .../com/parallelcode/phone/TaskScreens.kt | 8 +++- electron/remote/protocol.ts | 8 ++-- 4 files changed, 37 insertions(+), 18 deletions(-) diff --git a/android/README.md b/android/README.md index a7256850e..fb43db7a4 100644 --- a/android/README.md +++ b/android/README.md @@ -93,7 +93,7 @@ See `electron/remote/server.ts` and `electron/remote/protocol.ts`. | Watch | `subscribe` / `unsubscribe`; the server sends `scrollback`, then `output` (base64 PTY bytes) | | View size | `view-size` with `{ cols, rows }` (paired) while a terminal is open; without them, or on disconnect, the desktop size returns | | Projects | `GET /api/mobile/projects` (paired) | -| Agents | `GET /api/mobile/agents` (paired) → `[{ id, name, isDefault, models }]`; `404` on older desktops | +| Agents | `GET /api/mobile/agents` (paired) → `[{ id, name, isDefault, models }]`; `403` on older desktops | | New task | `POST /api/mobile/tasks` with `{ projectId, name, prompt, agentId?, model? }` (paired); returns `{ taskId }` | | Usage | `GET /api/mobile/usage`; the desktop status bar's snapshot, readable view-only | | Notes | `GET` / `PUT /api/mobile/notes/` with `{ notes }`; reading works view-only, saving needs pairing | diff --git a/android/app/src/main/java/com/parallelcode/phone/RemoteClient.kt b/android/app/src/main/java/com/parallelcode/phone/RemoteClient.kt index c6412fe9f..7f7bff368 100644 --- a/android/app/src/main/java/com/parallelcode/phone/RemoteClient.kt +++ b/android/app/src/main/java/com/parallelcode/phone/RemoteClient.kt @@ -53,11 +53,11 @@ interface TerminalListener { fun onOutput(data: ByteArray) } -/** A REST call the desktop refused or could not answer; `status` is 0 when it was unreachable. */ -/** [json] is the error reply's body, for routes that explain a refusal (e.g. close warnings). */ /** Read timeout for requests that wait on desktop git work; see [RemoteClient.slowHttp]. */ private const val SLOW_REQUEST_SECONDS = 130L +/** A REST call the desktop refused or could not answer; `status` is 0 when it was unreachable. */ +/** [json] is the error reply's body, for routes that explain a refusal (e.g. close warnings). */ class ApiException(message: String, val status: Int = 0, val json: JSONObject? = null) : IOException(message) data class MobileProject(val id: String, val name: String, val agentName: String?) @@ -100,7 +100,7 @@ class RemoteClient( /** * For requests the desktop answers only once real git work is done: creating a task builds a - * worktree, merging and closing run git too. The desktop waits up to 120 s for that work + * worktree; merging, closing, the diff and merge readiness run git too. The desktop waits up to 120 s for that work * (callRenderer in electron/ipc/register.ts), so OkHttp's default 10 s read timeout reported * "could not reach your computer" for a request that was still running, and usually succeeded. * A little longer than the desktop lets the desktop's own error come through instead. @@ -325,13 +325,21 @@ class RemoteClient( } /** - * Agents and models a paired phone may start tasks with. Empty from a desktop - * that predates the route (it answers 404); tasks then use its default agent. + * Agents and models a paired phone may start tasks with. Empty from a desktop that predates + * the route: it refuses unknown paths for a paired token with 403. Tasks then use its + * default agent. */ - suspend fun fetchAgentChoices(): List = try { - parseAgentChoices(apiRaw("GET", "/api/mobile/agents", null, pairedTokenOrThrow())) - } catch (e: ApiException) { - if (e.status == 404) emptyList() else throw e + suspend fun fetchAgentChoices(): List { + val raw = try { + apiRaw("GET", "/api/mobile/agents", null, pairedTokenOrThrow()) + } catch (e: ApiException) { + if (e.status == 403 || e.status == 404) return emptyList() else throw e + } + return try { + parseAgentChoices(raw) + } catch (e: JSONException) { + throw ApiException("Your computer sent an unexpected reply.") + } } /** @@ -362,7 +370,13 @@ class RemoteClient( /** The task's changes against its base branch; readable with the view-only token. */ suspend fun fetchDiff(taskId: String): TaskDiff { - val json = api("GET", "/api/mobile/tasks/${encodePath(taskId)}/diff", null, credentials.pairedToken ?: credentials.link?.token) + val json = api( + "GET", + "/api/mobile/tasks/${encodePath(taskId)}/diff", + null, + credentials.pairedToken ?: credentials.link?.token, + slow = true, + ) return TaskDiff.from(json) } @@ -397,6 +411,7 @@ class RemoteClient( "/api/mobile/tasks/${encodePath(taskId)}/readiness", null, credentials.pairedToken ?: credentials.link?.token, + slow = true, ), ) @@ -483,7 +498,7 @@ class RemoteClient( // A slow request may still finish on the desktop, so don't call it unreachable. // (A connect timeout is also a SocketTimeoutException, hence the hedged wording.) if (slow && e is SocketTimeoutException) { - throw ApiException("Your computer did not answer in time. The change may still go through.") + throw ApiException("Your computer did not answer in time.") } throw ApiException("Could not reach your computer. Check you're on the same network.") } diff --git a/android/app/src/main/java/com/parallelcode/phone/TaskScreens.kt b/android/app/src/main/java/com/parallelcode/phone/TaskScreens.kt index e202f5b03..8e5209605 100644 --- a/android/app/src/main/java/com/parallelcode/phone/TaskScreens.kt +++ b/android/app/src/main/java/com/parallelcode/phone/TaskScreens.kt @@ -66,6 +66,7 @@ fun NewTaskScreen(client: RemoteClient, onDone: () -> Unit, onNeedsPairing: () - var loadAttempt by remember { mutableIntStateOf(0) } var projectId by rememberSaveable { mutableStateOf("") } var agents by remember { mutableStateOf>(emptyList()) } + var agentsLoaded by remember { mutableStateOf(false) } var agentId by rememberSaveable { mutableStateOf("") } // Empty runs the agent with the model its desktop settings configure. var modelId by rememberSaveable { mutableStateOf("") } @@ -105,6 +106,7 @@ fun NewTaskScreen(client: RemoteClient, onDone: () -> Unit, onNeedsPairing: () - modelId = "" if (e.status == 401) onNeedsPairing() } + agentsLoaded = true } Scaffold( @@ -161,8 +163,10 @@ fun NewTaskScreen(client: RemoteClient, onDone: () -> Unit, onNeedsPairing: () - val agent = agents.find { it.id == agentId } if (agent == null) { // An older desktop lists no agents; say which one it will start. - loaded.find { it.id == projectId }?.agentName?.let { - Text("Runs with $it", style = MaterialTheme.typography.bodySmall, color = AppTheme.extra.textMuted) + if (agentsLoaded) { + loaded.find { it.id == projectId }?.agentName?.let { + Text("Runs with $it", style = MaterialTheme.typography.bodySmall, color = AppTheme.extra.textMuted) + } } } else { SelectField( diff --git a/electron/remote/protocol.ts b/electron/remote/protocol.ts index 32abcd195..12784df6d 100644 --- a/electron/remote/protocol.ts +++ b/electron/remote/protocol.ts @@ -34,10 +34,6 @@ export interface RemoteAgent { collapsed?: boolean; } -/** - * The desktop's answer to a phone's close request. `closed: false` means - * nothing was closed because closing would lose work; `warnings` says what. - */ /** An agent the mobile "New Task" screen can launch, with the models it offers. */ export interface RemoteAgentChoice { id: string; @@ -48,6 +44,10 @@ export interface RemoteAgentChoice { models: { id: string; label: string }[]; } +/** + * The desktop's answer to a phone's close request. `closed: false` means + * nothing was closed because closing would lose work; `warnings` says what. + */ export type RemoteCloseResult = { closed: true } | { closed: false; warnings: string[] }; /** A task's changes against its base branch, as a unified diff; `truncated` when cut short. */