Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
5 changes: 3 additions & 2 deletions android/README.md
Original file line number Diff line number Diff line change
Expand Up @@ -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.

Expand Down Expand Up @@ -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/<taskId>` with `{ notes }`; reading works view-only, saving needs pairing |
| Close task | `POST /api/mobile/tasks/<taskId>/close` with `{ force }` (paired); `409` with `{ warnings }` when work would be lost |
Expand Down
36 changes: 36 additions & 0 deletions android/app/src/main/java/com/parallelcode/phone/AgentChoices.kt
Original file line number Diff line number Diff line change
@@ -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<AgentModel>,
)

/** Parsing for the desktop's agent list (GET /api/mobile/agents). */
internal fun parseAgentChoices(raw: String): List<MobileAgentChoice> {
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 })
},
)
}
}
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand All @@ -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
Expand Down Expand Up @@ -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 = {
Expand Down
91 changes: 82 additions & 9 deletions android/app/src/main/java/com/parallelcode/phone/RemoteClient.kt
Original file line number Diff line number Diff line change
Expand Up @@ -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

Expand All @@ -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)
Expand Down Expand Up @@ -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<ConnectionState> = _state.asStateFlow()

Expand Down Expand Up @@ -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<MobileAgentChoice> {
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. */
Expand All @@ -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)
}

Expand Down Expand Up @@ -363,6 +411,7 @@ class RemoteClient(
"/api/mobile/tasks/${encodePath(taskId)}/readiness",
null,
credentials.pairedToken ?: credentials.link?.token,
slow = true,
),
)

Expand All @@ -373,6 +422,7 @@ class RemoteClient(
"/api/mobile/tasks/${encodePath(taskId)}/merge",
JSONObject().put("squash", squash).put("cleanup", cleanup),
pairedTokenOrThrow(),
slow = true,
)
}

Expand All @@ -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<String> = 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")
Expand All @@ -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()) {
Expand All @@ -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.")
}
}
Expand Down
13 changes: 9 additions & 4 deletions android/app/src/main/java/com/parallelcode/phone/Screens.kt
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down Expand Up @@ -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)
}
},
)
Expand Down Expand Up @@ -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 = {
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand All @@ -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
Expand Down Expand Up @@ -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 = {
Expand Down
Loading
Loading