diff --git a/android/README.md b/android/README.md index 30a1e0484..fb43db7a4 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 }]`; `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 | | 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/ChatScreen.kt b/android/app/src/main/java/com/parallelcode/phone/ChatScreen.kt index 7d588f484..26414a6eb 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 @@ -121,10 +123,11 @@ fun ChatScreen( colors = TopAppBarDefaults.topAppBarColors( containerColor = MaterialTheme.colorScheme.surface, titleContentColor = MaterialTheme.colorScheme.onSurface, + 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/RemoteClient.kt b/android/app/src/main/java/com/parallelcode/phone/RemoteClient.kt index 2823aee14..7f7bff368 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 @@ -52,6 +53,9 @@ interface TerminalListener { fun onOutput(data: ByteArray) } +/** 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) @@ -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, 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. + */ + 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() @@ -311,10 +324,39 @@ 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 refuses unknown paths for a paired token with 403. Tasks then use its + * default agent. + */ + 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.") + } + } + + /** + * 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) - return api("POST", "/api/mobile/tasks", body, pairedTokenOrThrow()).getString("taskId") + agentId?.let { body.put("agentId", it) } + model?.let { body.put("model", it) } + return api("POST", "/api/mobile/tasks", body, pairedTokenOrThrow(), slow = true).getString("taskId") } /** The task's notes panel; readable with the view-only token. */ @@ -328,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) } @@ -363,6 +411,7 @@ class RemoteClient( "/api/mobile/tasks/${encodePath(taskId)}/readiness", null, credentials.pairedToken ?: credentials.link?.token, + slow = true, ), ) @@ -373,6 +422,7 @@ class RemoteClient( "/api/mobile/tasks/${encodePath(taskId)}/merge", JSONObject().put("squash", squash).put("cleanup", cleanup), pairedTokenOrThrow(), + slow = true, ) } @@ -388,7 +438,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") @@ -403,14 +459,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()) { @@ -424,9 +492,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.") + } 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/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 = { 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..8e5209605 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,11 @@ 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 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("") } var name by rememberSaveable { mutableStateOf("") } var prompt by rememberSaveable { mutableStateOf("") } var busy by remember { mutableStateOf(false) } @@ -89,7 +91,22 @@ 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() + } + agentsLoaded = true } Scaffold( @@ -124,12 +141,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 +151,45 @@ 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. + if (agentsLoaded) { + 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 +259,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 +282,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..12784df6d 100644 --- a/electron/remote/protocol.ts +++ b/electron/remote/protocol.ts @@ -34,6 +34,16 @@ export interface RemoteAgent { collapsed?: boolean; } +/** 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 }[]; +} + /** * The desktop's answer to a phone's close request. `closed: false` means * nothing was closed because closing would lose work; `warnings` says what. 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();