From f8ed67bed33e4e758ce35c0b0d890fd5ced3ffa2 Mon Sep 17 00:00:00 2001 From: BW Date: Tue, 29 Sep 2026 17:36:06 -0600 Subject: [PATCH 1/7] feat(android): native phone app for Remote Access A Kotlin/Compose client for the Connect Phone API in android/. - Connect by QR code or link, pair with the desktop PIN, and link several computers, each with its own pairing - Agent list with minimized tasks, usage meters (when the desktop serves /api/mobile/usage), pull to refresh, and a home-screen widget - Swipe sideways between open tasks, with the position in the header - Terminal: desktop colors, lazy rendering, reliable follow (optionally always jump to the latest), quick keys, quick replies, voice input, and an opt-in mode that sizes the PTY to the phone - Changes tab with per-file diffs, notes, new tasks, and closing tasks with the desktop's warnings - Built-in chat tasks: transcript, approvals and questions, send / stop - Optional notifications when an agent needs input, errors or finishes - Obsidian dark and light themes, splash, themed icon, predictive back, animations and haptics; release builds shrink with R8 Desktop support: - POST /api/mobile/tasks/:id/close (paired) and GET /api/mobile/tasks/:id/diff (view-only and paired), renderer-backed - `view-size` socket message: a paired phone may size a terminal while viewing it; the desktop's size returns on release or disconnect - Subscribing sends a rendered snapshot with up to 1000 lines of history (@xterm/addon-serialize over the existing headless mirror) - Minimized tasks appear in the phone's agent list Co-Authored-By: Claude Opus 5.5 --- .prettierignore | 4 + android/.gitignore | 6 + android/README.md | 59 + android/app/build.gradle.kts | 51 + android/app/proguard-rules.pro | 2 + android/app/src/main/AndroidManifest.xml | 58 + .../com/parallelcode/phone/AgentNotifier.kt | 37 + .../parallelcode/phone/AgentWatchService.kt | 156 ++ .../com/parallelcode/phone/AgentWidget.kt | 84 + .../java/com/parallelcode/phone/ChatScreen.kt | 544 ++++++ .../java/com/parallelcode/phone/ChatState.kt | 84 + .../com/parallelcode/phone/ComposerExtras.kt | 74 + .../com/parallelcode/phone/ConnectionLink.kt | 37 + .../com/parallelcode/phone/CredentialStore.kt | 118 ++ .../java/com/parallelcode/phone/DiffPane.kt | 193 +++ .../com/parallelcode/phone/MainActivity.kt | 439 +++++ .../parallelcode/phone/PhoneApplication.kt | 36 + .../java/com/parallelcode/phone/Protocol.kt | 99 ++ .../com/parallelcode/phone/RemoteClient.kt | 611 +++++++ .../java/com/parallelcode/phone/Screens.kt | 1485 +++++++++++++++++ .../com/parallelcode/phone/SettingsScreen.kt | 711 ++++++++ .../com/parallelcode/phone/SettingsStore.kt | 95 ++ .../java/com/parallelcode/phone/TaskDiff.kt | 58 + .../com/parallelcode/phone/TaskScreens.kt | 397 +++++ .../com/parallelcode/phone/TerminalBuffer.kt | 33 + .../com/parallelcode/phone/TerminalScreen.kt | 407 +++++ .../com/parallelcode/phone/TerminalStyle.kt | 182 ++ .../main/java/com/parallelcode/phone/Theme.kt | 186 +++ .../main/java/com/parallelcode/phone/Usage.kt | 258 +++ .../res/drawable/ic_launcher_foreground.xml | 11 + .../res/drawable/ic_launcher_monochrome.xml | 11 + .../src/main/res/drawable/ic_notification.xml | 11 + .../main/res/drawable/widget_background.xml | 7 + .../app/src/main/res/layout/widget_agents.xml | 50 + .../res/mipmap-anydpi-v26/ic_launcher.xml | 6 + .../main/res/mipmap-xxhdpi/ic_launcher.png | Bin 0 -> 3383 bytes .../app/src/main/res/values-v31/themes.xml | 12 + android/app/src/main/res/values/colors.xml | 4 + android/app/src/main/res/values/strings.xml | 4 + android/app/src/main/res/values/themes.xml | 8 + .../main/res/xml/data_extraction_rules.xml | 10 + .../src/main/res/xml/widget_agents_info.xml | 11 + .../parallelcode/phone/AgentNotifierTest.kt | 50 + .../com/parallelcode/phone/AgentWidgetTest.kt | 28 + .../parallelcode/phone/ConnectionLinkTest.kt | 31 + .../parallelcode/phone/CredentialStoreTest.kt | 57 + .../phone/FakeSharedPreferences.kt | 64 + .../com/parallelcode/phone/ProtocolTest.kt | 94 ++ .../parallelcode/phone/SettingsStoreTest.kt | 107 ++ .../com/parallelcode/phone/TaskDiffTest.kt | 43 + .../parallelcode/phone/TerminalBufferTest.kt | 44 + .../parallelcode/phone/TerminalScreenTest.kt | 144 ++ .../parallelcode/phone/TerminalStyleTest.kt | 56 + .../java/com/parallelcode/phone/UsageTest.kt | 72 + android/build.gradle.kts | 4 + android/gradle.properties | 3 + android/gradle/wrapper/gradle-wrapper.jar | Bin 0 -> 48462 bytes .../gradle/wrapper/gradle-wrapper.properties | 9 + android/gradlew | 248 +++ android/gradlew.bat | 82 + android/settings.gradle.kts | 18 + electron/ipc/channel-manifest.json | 2 + electron/ipc/pty.test.ts | 53 + electron/ipc/pty.ts | 85 +- electron/ipc/register-wiring.test.ts | 2 + electron/ipc/register.ts | 37 +- electron/ipc/terminal-query-responder.ts | 23 +- electron/preload.cjs | 2 + electron/remote/close-route.test.ts | 186 +++ electron/remote/coordinator-scoping.test.ts | 1 + electron/remote/diff-route.test.ts | 149 ++ electron/remote/mindmap-route.test.ts | 1 + electron/remote/notes-route.test.ts | 1 + electron/remote/pairing.test.ts | 1 + electron/remote/protocol.test.ts | 25 + electron/remote/protocol.ts | 50 +- electron/remote/ring-buffer.test.ts | 54 + electron/remote/ring-buffer.ts | 2 +- electron/remote/server-chat.test.ts | 1 + electron/remote/server-helpers.test.ts | 96 +- electron/remote/server-ws.test.ts | 69 + electron/remote/server.ts | 182 +- electron/remote/session-route.test.ts | 1 + eslint.config.js | 2 + package-lock.json | 10 + package.json | 1 + src/remote/attention.ts | 3 +- src/store/remoteStatusSync.ts | 19 +- src/store/remoteTaskHandler.client.test.tsx | 84 + src/store/remoteTaskHandler.ts | 81 +- 90 files changed, 8982 insertions(+), 44 deletions(-) create mode 100644 android/.gitignore create mode 100644 android/README.md create mode 100644 android/app/build.gradle.kts create mode 100644 android/app/proguard-rules.pro create mode 100644 android/app/src/main/AndroidManifest.xml create mode 100644 android/app/src/main/java/com/parallelcode/phone/AgentNotifier.kt create mode 100644 android/app/src/main/java/com/parallelcode/phone/AgentWatchService.kt create mode 100644 android/app/src/main/java/com/parallelcode/phone/AgentWidget.kt create mode 100644 android/app/src/main/java/com/parallelcode/phone/ChatScreen.kt create mode 100644 android/app/src/main/java/com/parallelcode/phone/ChatState.kt create mode 100644 android/app/src/main/java/com/parallelcode/phone/ComposerExtras.kt create mode 100644 android/app/src/main/java/com/parallelcode/phone/ConnectionLink.kt create mode 100644 android/app/src/main/java/com/parallelcode/phone/CredentialStore.kt create mode 100644 android/app/src/main/java/com/parallelcode/phone/DiffPane.kt create mode 100644 android/app/src/main/java/com/parallelcode/phone/MainActivity.kt create mode 100644 android/app/src/main/java/com/parallelcode/phone/PhoneApplication.kt create mode 100644 android/app/src/main/java/com/parallelcode/phone/Protocol.kt create mode 100644 android/app/src/main/java/com/parallelcode/phone/RemoteClient.kt create mode 100644 android/app/src/main/java/com/parallelcode/phone/Screens.kt create mode 100644 android/app/src/main/java/com/parallelcode/phone/SettingsScreen.kt create mode 100644 android/app/src/main/java/com/parallelcode/phone/SettingsStore.kt create mode 100644 android/app/src/main/java/com/parallelcode/phone/TaskDiff.kt create mode 100644 android/app/src/main/java/com/parallelcode/phone/TaskScreens.kt create mode 100644 android/app/src/main/java/com/parallelcode/phone/TerminalBuffer.kt create mode 100644 android/app/src/main/java/com/parallelcode/phone/TerminalScreen.kt create mode 100644 android/app/src/main/java/com/parallelcode/phone/TerminalStyle.kt create mode 100644 android/app/src/main/java/com/parallelcode/phone/Theme.kt create mode 100644 android/app/src/main/java/com/parallelcode/phone/Usage.kt create mode 100644 android/app/src/main/res/drawable/ic_launcher_foreground.xml create mode 100644 android/app/src/main/res/drawable/ic_launcher_monochrome.xml create mode 100644 android/app/src/main/res/drawable/ic_notification.xml create mode 100644 android/app/src/main/res/drawable/widget_background.xml create mode 100644 android/app/src/main/res/layout/widget_agents.xml create mode 100644 android/app/src/main/res/mipmap-anydpi-v26/ic_launcher.xml create mode 100644 android/app/src/main/res/mipmap-xxhdpi/ic_launcher.png create mode 100644 android/app/src/main/res/values-v31/themes.xml create mode 100644 android/app/src/main/res/values/colors.xml create mode 100644 android/app/src/main/res/values/strings.xml create mode 100644 android/app/src/main/res/values/themes.xml create mode 100644 android/app/src/main/res/xml/data_extraction_rules.xml create mode 100644 android/app/src/main/res/xml/widget_agents_info.xml create mode 100644 android/app/src/test/java/com/parallelcode/phone/AgentNotifierTest.kt create mode 100644 android/app/src/test/java/com/parallelcode/phone/AgentWidgetTest.kt create mode 100644 android/app/src/test/java/com/parallelcode/phone/ConnectionLinkTest.kt create mode 100644 android/app/src/test/java/com/parallelcode/phone/CredentialStoreTest.kt create mode 100644 android/app/src/test/java/com/parallelcode/phone/FakeSharedPreferences.kt create mode 100644 android/app/src/test/java/com/parallelcode/phone/ProtocolTest.kt create mode 100644 android/app/src/test/java/com/parallelcode/phone/SettingsStoreTest.kt create mode 100644 android/app/src/test/java/com/parallelcode/phone/TaskDiffTest.kt create mode 100644 android/app/src/test/java/com/parallelcode/phone/TerminalBufferTest.kt create mode 100644 android/app/src/test/java/com/parallelcode/phone/TerminalScreenTest.kt create mode 100644 android/app/src/test/java/com/parallelcode/phone/TerminalStyleTest.kt create mode 100644 android/app/src/test/java/com/parallelcode/phone/UsageTest.kt create mode 100644 android/build.gradle.kts create mode 100644 android/gradle.properties create mode 100644 android/gradle/wrapper/gradle-wrapper.jar create mode 100644 android/gradle/wrapper/gradle-wrapper.properties create mode 100755 android/gradlew create mode 100644 android/gradlew.bat create mode 100644 android/settings.gradle.kts create mode 100644 electron/remote/close-route.test.ts create mode 100644 electron/remote/diff-route.test.ts create mode 100644 electron/remote/ring-buffer.test.ts diff --git a/.prettierignore b/.prettierignore index 101dc6ab7..fe5a688d2 100644 --- a/.prettierignore +++ b/.prettierignore @@ -17,3 +17,7 @@ package-lock.json *.AppImage *.deb *.dmg + +# Android build outputs +android/.gradle/ +android/**/build/ diff --git a/android/.gitignore b/android/.gitignore new file mode 100644 index 000000000..7501d2e4b --- /dev/null +++ b/android/.gitignore @@ -0,0 +1,6 @@ +.gradle/ +.kotlin/ +.idea/ +build/ +local.properties +*.iml diff --git a/android/README.md b/android/README.md new file mode 100644 index 000000000..ee9a3c56c --- /dev/null +++ b/android/README.md @@ -0,0 +1,59 @@ +# Parallel Code for Android + +Native companion app for the desktop's **Connect Phone** (Remote Access) feature. It talks to the same HTTP/WebSocket API as the phone web UI in `src/remote/`. + +## What it does + +- **Connect:** scan the QR code in Connect Phone, or paste the link under it. This gives a view-only token. +- **Pair:** enter the six-digit code from Connect Phone to get a paired token, which may type into terminals. "Keep this phone authorized" asks the desktop to remember the phone across restarts. +- **Several computers:** link more than one desktop (for example the installed app and a dev build, or two machines) and switch between them in Settings → Computers; each keeps its own pairing. +- **Agents:** live list with each agent's status and last line, under the desktop's Claude, Codex, and Antigravity 5-hour and weekly usage meters (hidden on desktops without `/api/mobile/usage`). +- **Minimized tasks:** tasks minimized on the desktop are pinned below the live list; a setting hides them. +- **Settings:** theme (follow system, Obsidian Dark, Light), keep the screen on, connection status, and forget this computer. +- **Swipe between tasks:** with a task open, swipe sideways to the previous or next one in the list; the header shows its position ("2 of 5"). +- **Terminal:** an agent's terminal in the desktop's default Obsidian colors (light or dark with the phone). Once paired: a reply box and keys a phone keyboard lacks (Enter, Esc, Tab, arrows, Ctrl+C). With "Fit the terminal to this phone" on (Settings, off by default), the terminal takes the phone's size while open so full-screen agents such as Claude Code fill it; the computer's own terminal shifts meanwhile and gets its size back when you leave. +- **Changes:** the task's diff against its base branch, file by file with added and removed lines. +- **Quick replies and voice:** saved replies above the reply box (edit them in Settings) 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. +- **Notes:** read a task's notes panel; edit and save it once paired. +- **New task:** pick a project and 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. + +- **Built-in chat:** read the conversation, send messages, stop the agent, and answer its approvals and questions once paired. Choosing the model and attaching images stay on the computer. + +## Build + +Needs JDK 17+ and the Android SDK (compile SDK 37). Set `ANDROID_HOME` or add `sdk.dir` to `android/local.properties`. + +```sh +cd android +./gradlew testDebugUnitTest # unit tests +./gradlew assembleDebug # app/build/outputs/apk/debug/app-debug.apk +./gradlew installDebug # install on a connected device +``` + +QR scanning uses the Google Play services code scanner, so the app needs no camera permission. On phones without Play services, paste the link instead. + +## How it maps to the server + +See `electron/remote/server.ts` and `electron/remote/protocol.ts`. + +| Step | Request | +| ------------ | ----------------------------------------------------------------------------------------------------------------------------- | +| Pair | `POST /api/pair/verify` with `Authorization: Bearer ` and `{ pin, remember }`; returns `{ token }` | +| Connect | WebSocket `/ws`; first message `{ type: "auth", token }`. The paired token is used when present | +| 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 }` | +| 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 | +| Changes | `GET /api/mobile/tasks//diff` → `{ diff, truncated }`; readable view-only | +| Reply | `input` with `submit: true` and a `requestId`; confirmed by `input-result` | +| Close `4001` | Paired token rejected: drop it and reconnect view-only. QR token rejected: scan again | +| Close `4003` | Typing rights lost: drop the paired token | +| HTTP 401 | On a paired-token request: drop the paired token and reconnect view-only | + +Remote Access serves plain HTTP on the LAN or Tailscale address, so the app allows cleartext traffic. Credentials live in app-private storage and are excluded from backups and device transfer. diff --git a/android/app/build.gradle.kts b/android/app/build.gradle.kts new file mode 100644 index 000000000..0db2e1597 --- /dev/null +++ b/android/app/build.gradle.kts @@ -0,0 +1,51 @@ +plugins { + id("com.android.application") + id("org.jetbrains.kotlin.plugin.compose") +} + +android { + namespace = "com.parallelcode.phone" + compileSdk = 37 + + defaultConfig { + applicationId = "com.parallelcode.phone" + minSdk = 26 + targetSdk = 37 + versionCode = 1 + versionName = "0.1.0" + } + + buildTypes { + release { + // R8 drops unused code and resources; the libraries ship their own keep rules. + isMinifyEnabled = true + isShrinkResources = true + proguardFiles(getDefaultProguardFile("proguard-android-optimize.txt"), "proguard-rules.pro") + } + } + + compileOptions { + sourceCompatibility = JavaVersion.VERSION_17 + targetCompatibility = JavaVersion.VERSION_17 + } + + buildFeatures { + compose = true + } +} + +dependencies { + implementation(platform("androidx.compose:compose-bom:2026.09.00")) + implementation("androidx.compose.material3:material3") + implementation("androidx.activity:activity-compose:1.13.0") + implementation("com.squareup.okhttp3:okhttp:5.3.2") + // Installs the baseline profiles Compose ships, so a sideloaded APK starts and scrolls + // compiled rather than interpreted. + implementation("androidx.profileinstaller:profileinstaller:1.4.1") + // Scanner UI comes from Google Play services, so the app needs no camera permission. + implementation("com.google.android.gms:play-services-code-scanner:16.1.0") + + testImplementation("junit:junit:4.13.2") + // android.jar only has stubs for org.json; unit tests need the real implementation. + testImplementation("org.json:json:20260814") +} diff --git a/android/app/proguard-rules.pro b/android/app/proguard-rules.pro new file mode 100644 index 000000000..84e314b96 --- /dev/null +++ b/android/app/proguard-rules.pro @@ -0,0 +1,2 @@ +# App-specific R8 keep rules. OkHttp, Compose and ML Kit bring their own consumer rules, and the +# app reads JSON through org.json (part of Android), so nothing needs keeping here yet. diff --git a/android/app/src/main/AndroidManifest.xml b/android/app/src/main/AndroidManifest.xml new file mode 100644 index 000000000..de3172cc7 --- /dev/null +++ b/android/app/src/main/AndroidManifest.xml @@ -0,0 +1,58 @@ + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + diff --git a/android/app/src/main/java/com/parallelcode/phone/AgentNotifier.kt b/android/app/src/main/java/com/parallelcode/phone/AgentNotifier.kt new file mode 100644 index 000000000..cfaafd740 --- /dev/null +++ b/android/app/src/main/java/com/parallelcode/phone/AgentNotifier.kt @@ -0,0 +1,37 @@ +package com.parallelcode.phone + +/** An agent change worth a notification. */ +enum class AgentEvent { NEEDS_INPUT, ERROR, FINISHED } + +data class AgentNotice(val agent: RemoteAgent, val event: AgentEvent) + +private val BUSY = setOf("active", "shell_busy") +private val SETTLED = setOf("ready", "review", "idle") + +/** + * Turns successive agent lists into notices. The first list is the baseline: agents already + * waiting when watching starts are not announced. + */ +class AgentNotifier { + private var previous: Map? = null + + fun update(agents: List): List { + val before = previous + previous = agents.associateBy { it.agentId } + if (before == null) return emptyList() + return agents.mapNotNull { agent -> + val old = before[agent.agentId] ?: return@mapNotNull null + eventFor(old, agent)?.let { AgentNotice(agent, it) } + } + } +} + +internal fun eventFor(old: RemoteAgent, new: RemoteAgent): AgentEvent? = when { + new.collapsed -> null + new.attention == old.attention && new.running == old.running -> null + new.attention == "needs_input" -> AgentEvent.NEEDS_INPUT + new.attention == "error" -> AgentEvent.ERROR + old.running && !new.running -> AgentEvent.FINISHED + old.attention in BUSY && new.attention in SETTLED -> AgentEvent.FINISHED + else -> null +} diff --git a/android/app/src/main/java/com/parallelcode/phone/AgentWatchService.kt b/android/app/src/main/java/com/parallelcode/phone/AgentWatchService.kt new file mode 100644 index 000000000..89f0758ab --- /dev/null +++ b/android/app/src/main/java/com/parallelcode/phone/AgentWatchService.kt @@ -0,0 +1,156 @@ +package com.parallelcode.phone + +import android.Manifest +import android.app.NotificationChannel +import android.app.NotificationManager +import android.app.PendingIntent +import android.app.Service +import android.content.Context +import android.content.Intent +import android.content.pm.PackageManager +import android.content.pm.ServiceInfo +import android.os.Build +import android.os.IBinder +import androidx.core.app.NotificationCompat +import androidx.core.app.NotificationManagerCompat +import androidx.core.app.ServiceCompat +import androidx.core.content.ContextCompat +import kotlinx.coroutines.CoroutineScope +import kotlinx.coroutines.Dispatchers +import kotlinx.coroutines.SupervisorJob +import kotlinx.coroutines.cancel +import kotlinx.coroutines.delay +import kotlinx.coroutines.launch + +/** + * Keeps the desktop connection open while the app is in the background and notifies when an agent + * needs input, hits an error or finishes. Runs only while agent notifications are on in Settings. + */ +class AgentWatchService : Service() { + private val scope = CoroutineScope(SupervisorJob() + Dispatchers.Main.immediate) + private val app get() = application as PhoneApplication + + override fun onBind(intent: Intent?): IBinder? = null + + override fun onCreate() { + super.onCreate() + createChannels(this) + ServiceCompat.startForeground( + this, + WATCHING_ID, + watchingNotification(0), + if (Build.VERSION.SDK_INT >= 34) ServiceInfo.FOREGROUND_SERVICE_TYPE_SPECIAL_USE else 0, + ) + app.client.start(HOLDER) + // The widget's usage lines; the app refreshes them itself while on screen. + scope.launch { + while (true) { + if (!app.inForeground) runCatching { app.client.fetchUsage() } + delay(USAGE_REFRESH_MS) + } + } + val notifier = AgentNotifier() + scope.launch { + app.client.agents.collect { agents -> + val notices = notifier.update(agents) + if (!canNotify()) return@collect + val manager = NotificationManagerCompat.from(this@AgentWatchService) + if (!app.inForeground) { + notices.filter { app.settings.notifiesFor(it.event) }.forEach { notice -> + manager.notify(notice.agent.agentId.hashCode(), agentNotification(notice)) + } + } + manager.notify(WATCHING_ID, watchingNotification(agents.count { it.attention == "needs_input" })) + } + } + } + + override fun onStartCommand(intent: Intent?, flags: Int, startId: Int): Int = START_STICKY + + override fun onDestroy() { + scope.cancel() + app.client.stop(HOLDER) + super.onDestroy() + } + + private fun canNotify() = Build.VERSION.SDK_INT < 33 || + ContextCompat.checkSelfPermission(this, Manifest.permission.POST_NOTIFICATIONS) == + PackageManager.PERMISSION_GRANTED + + private fun openApp(agentId: String?): PendingIntent { + val intent = Intent(this, MainActivity::class.java) + .addFlags(Intent.FLAG_ACTIVITY_SINGLE_TOP or Intent.FLAG_ACTIVITY_CLEAR_TOP) + agentId?.let { intent.putExtra(MainActivity.EXTRA_AGENT_ID, it) } + return PendingIntent.getActivity( + this, + agentId?.hashCode() ?: 0, + intent, + PendingIntent.FLAG_IMMUTABLE or PendingIntent.FLAG_UPDATE_CURRENT, + ) + } + + private fun watchingNotification(needInput: Int) = + NotificationCompat.Builder(this, CHANNEL_WATCHING) + .setSmallIcon(R.drawable.ic_notification) + .setContentTitle("Watching your agents") + .setContentText( + when (needInput) { + 0 -> "You'll be notified when one needs you." + 1 -> "1 agent needs input." + else -> "$needInput agents need input." + }, + ) + .setOngoing(true) + .setSilent(true) + .setContentIntent(openApp(null)) + .build() + + private fun agentNotification(notice: AgentNotice) = + NotificationCompat.Builder(this, CHANNEL_AGENTS) + .setSmallIcon(R.drawable.ic_notification) + .setContentTitle(notice.agent.taskName) + .setContentText( + when (notice.event) { + AgentEvent.NEEDS_INPUT -> "Needs your input" + AgentEvent.ERROR -> "Hit an error" + AgentEvent.FINISHED -> "Finished" + }, + ) + .setSubText(notice.agent.agentName) + .setCategory(NotificationCompat.CATEGORY_MESSAGE) + .setPriority(NotificationCompat.PRIORITY_HIGH) + .setAutoCancel(true) + .setContentIntent(openApp(notice.agent.agentId)) + .build() + + companion object { + private const val HOLDER = "notifications" + private const val USAGE_REFRESH_MS = 5 * 60_000L + private const val WATCHING_ID = 1 + private const val CHANNEL_AGENTS = "agents" + private const val CHANNEL_WATCHING = "watching" + + /** Start or stop the service to match the setting and whether a computer is linked. */ + fun sync(context: Context) { + val app = context.applicationContext as PhoneApplication + val intent = Intent(context, AgentWatchService::class.java) + if (app.settings.notificationsEnabled && app.client.state.value.link != null) { + ContextCompat.startForegroundService(context, intent) + } else { + context.stopService(intent) + } + } + + private fun createChannels(context: Context) { + val manager = context.getSystemService(NotificationManager::class.java) + manager.createNotificationChannel( + NotificationChannel(CHANNEL_AGENTS, "Agent updates", NotificationManager.IMPORTANCE_HIGH) + .apply { description = "An agent needs input, hit an error or finished." }, + ) + manager.createNotificationChannel( + NotificationChannel(CHANNEL_WATCHING, "Watching agents", NotificationManager.IMPORTANCE_MIN) + .apply { description = "Shown while the app keeps the connection open in the background." }, + ) + } + } +} diff --git a/android/app/src/main/java/com/parallelcode/phone/AgentWidget.kt b/android/app/src/main/java/com/parallelcode/phone/AgentWidget.kt new file mode 100644 index 000000000..8bf87c1e2 --- /dev/null +++ b/android/app/src/main/java/com/parallelcode/phone/AgentWidget.kt @@ -0,0 +1,84 @@ +package com.parallelcode.phone + +import android.app.PendingIntent +import android.appwidget.AppWidgetManager +import android.appwidget.AppWidgetProvider +import android.content.ComponentName +import android.content.Context +import android.content.Intent +import android.widget.RemoteViews +import androidx.core.content.edit +import java.time.LocalTime +import java.time.format.DateTimeFormatter + +/** What the widget shows, worked out from the live agent list and usage snapshot. */ +data class WidgetSummary(val headline: String, val usage: String) + +fun widgetSummary(agents: List, usage: List, connected: Boolean): WidgetSummary { + val live = agents.filter { !it.collapsed } + val needInput = live.count { it.attention == "needs_input" || it.attention == "error" } + val working = live.count { it.running && (it.attention == "active" || it.attention == "shell_busy") } + val headline = when { + !connected -> "Not connected" + needInput > 0 && working > 0 -> "$needInput need you · $working working" + needInput > 0 -> "$needInput need${if (needInput == 1) "s" else ""} you" + working > 0 -> "$working working" + live.isEmpty() -> "No agents running" + else -> "All quiet" + } + val lines = usage.filter { it.hasSnapshot }.map { provider -> + val windows = listOfNotNull( + provider.fiveHour?.let { "5h ${it.remainingPercent}%" }, + provider.sevenDay?.let { "7d ${it.remainingPercent}%" }, + ).joinToString(" ") + "${provider.label.padEnd(11)} $windows" + } + return WidgetSummary(headline, if (lines.isEmpty()) "" else "Left:\n" + lines.joinToString("\n")) +} + +/** + * Home-screen widget: agents that need you and the usage meters. The app pushes updates while its + * connection is open (on screen, or in the background with notifications on); in between, the + * widget shows the last update and its time. + */ +class AgentWidget : AppWidgetProvider() { + override fun onUpdate(context: Context, manager: AppWidgetManager, ids: IntArray) = render(context, manager, ids) + + companion object { + private const val PREFS = "widget" + private const val KEY_HEADLINE = "headline" + private const val KEY_USAGE = "usage" + private const val KEY_UPDATED = "updated" + + /** Store [summary] and redraw every placed widget. */ + fun publish(context: Context, summary: WidgetSummary) { + val prefs = context.getSharedPreferences(PREFS, Context.MODE_PRIVATE) + if (prefs.getString(KEY_HEADLINE, null) == summary.headline && prefs.getString(KEY_USAGE, null) == summary.usage) return + prefs.edit { + putString(KEY_HEADLINE, summary.headline) + putString(KEY_USAGE, summary.usage) + putString(KEY_UPDATED, LocalTime.now().format(DateTimeFormatter.ofPattern("HH:mm"))) + } + val manager = AppWidgetManager.getInstance(context) + render(context, manager, manager.getAppWidgetIds(ComponentName(context, AgentWidget::class.java))) + } + + private fun render(context: Context, manager: AppWidgetManager, ids: IntArray) { + if (ids.isEmpty()) return + val prefs = context.getSharedPreferences(PREFS, Context.MODE_PRIVATE) + val open = PendingIntent.getActivity( + context, + 0, + Intent(context, MainActivity::class.java), + PendingIntent.FLAG_IMMUTABLE or PendingIntent.FLAG_UPDATE_CURRENT, + ) + val views = RemoteViews(context.packageName, R.layout.widget_agents).apply { + setTextViewText(R.id.widget_headline, prefs.getString(KEY_HEADLINE, null) ?: "Open the app to connect") + setTextViewText(R.id.widget_usage, prefs.getString(KEY_USAGE, null).orEmpty()) + setTextViewText(R.id.widget_updated, prefs.getString(KEY_UPDATED, null).orEmpty()) + setOnClickPendingIntent(R.id.widget_root, open) + } + manager.updateAppWidget(ids, views) + } + } +} diff --git a/android/app/src/main/java/com/parallelcode/phone/ChatScreen.kt b/android/app/src/main/java/com/parallelcode/phone/ChatScreen.kt new file mode 100644 index 000000000..db77d26c5 --- /dev/null +++ b/android/app/src/main/java/com/parallelcode/phone/ChatScreen.kt @@ -0,0 +1,544 @@ +package com.parallelcode.phone + +import androidx.compose.foundation.BorderStroke +import androidx.compose.foundation.background +import androidx.compose.foundation.border +import androidx.compose.foundation.clickable +import androidx.compose.foundation.horizontalScroll +import androidx.compose.foundation.layout.Arrangement +import androidx.compose.foundation.layout.Box +import androidx.compose.foundation.layout.Column +import androidx.compose.foundation.layout.PaddingValues +import androidx.compose.foundation.layout.Row +import androidx.compose.foundation.layout.Spacer +import androidx.compose.foundation.layout.fillMaxSize +import androidx.compose.foundation.layout.fillMaxWidth +import androidx.compose.foundation.layout.height +import androidx.compose.foundation.layout.imePadding +import androidx.compose.foundation.layout.padding +import androidx.compose.foundation.layout.size +import androidx.compose.foundation.layout.widthIn +import androidx.compose.foundation.lazy.LazyColumn +import androidx.compose.foundation.lazy.items +import androidx.compose.foundation.lazy.rememberLazyListState +import androidx.compose.foundation.rememberScrollState +import androidx.compose.foundation.text.selection.SelectionContainer +import androidx.compose.material3.Button +import androidx.compose.material3.ButtonDefaults +import androidx.compose.material3.CircularProgressIndicator +import androidx.compose.material3.ExperimentalMaterial3Api +import androidx.compose.material3.FilterChip +import androidx.compose.material3.HorizontalDivider +import androidx.compose.material3.MaterialTheme +import androidx.compose.material3.OutlinedButton +import androidx.compose.material3.OutlinedTextField +import androidx.compose.material3.Scaffold +import androidx.compose.material3.Text +import androidx.compose.material3.TextButton +import androidx.compose.material3.TopAppBar +import androidx.compose.material3.TopAppBarDefaults +import androidx.compose.runtime.Composable +import androidx.compose.runtime.DisposableEffect +import androidx.compose.runtime.LaunchedEffect +import androidx.compose.runtime.collectAsState +import androidx.compose.runtime.getValue +import androidx.compose.runtime.mutableStateOf +import androidx.compose.runtime.remember +import androidx.compose.runtime.rememberCoroutineScope +import androidx.compose.runtime.saveable.rememberSaveable +import androidx.compose.runtime.setValue +import androidx.compose.ui.Alignment +import androidx.compose.ui.Modifier +import androidx.compose.ui.text.font.FontFamily +import androidx.compose.ui.text.font.FontWeight +import androidx.compose.ui.text.input.PasswordVisualTransformation +import androidx.compose.ui.text.input.VisualTransformation +import androidx.compose.ui.text.style.TextOverflow +import androidx.compose.ui.unit.dp +import androidx.compose.ui.unit.sp +import androidx.compose.ui.platform.LocalHapticFeedback +import androidx.compose.ui.hapticfeedback.HapticFeedbackType +import androidx.compose.ui.graphics.graphicsLayer +import androidx.compose.foundation.shape.CircleShape +import androidx.compose.animation.core.tween +import androidx.compose.animation.core.spring +import androidx.compose.animation.core.rememberInfiniteTransition +import androidx.compose.animation.core.keyframes +import androidx.compose.animation.core.infiniteRepeatable +import androidx.compose.animation.core.animateFloat +import androidx.compose.animation.core.Spring +import androidx.compose.animation.core.RepeatMode +import androidx.compose.animation.core.FastOutSlowInEasing +import kotlinx.coroutines.launch +import org.json.JSONObject + +/** Tool output past this is cut on the phone; the desktop shows all of it. */ +private const val MAX_TOOL_OUTPUT = 4000 + +/** A task whose agent runs in the desktop's built-in chat, as in the phone web UI's ChatDetail. */ +@OptIn(ExperimentalMaterial3Api::class) +@Composable +fun ChatScreen( + agent: RemoteAgent?, + agentId: String, + state: ConnectionState, + client: RemoteClient, + quickReplies: List, + pageLabel: String? = null, + onBack: () -> Unit, + onPair: () -> Unit, +) { + val chatFlow = remember(agentId) { client.watchChat(agentId) } + DisposableEffect(agentId) { onDispose { client.unwatchChat(agentId) } } + val chat by chatFlow.collectAsState() + var closing by remember { mutableStateOf(false) } + val agentName = agent?.agentName ?: "The agent" + val canSend = state.canControl && state.status == ConnectionStatus.CONNECTED + + if (closing && agent != null) { + CloseTaskDialog( + taskName = agent.taskName, + close = { force -> client.closeTask(agent.taskId, force) }, + onDismiss = { closing = false }, + onClosed = { + closing = false + onBack() + }, + ) + } + + Scaffold( + containerColor = MaterialTheme.colorScheme.background, + topBar = { + Column { + TopAppBar( + colors = TopAppBarDefaults.topAppBarColors( + containerColor = MaterialTheme.colorScheme.surface, + titleContentColor = MaterialTheme.colorScheme.onSurface, + ), + navigationIcon = { + TextButton(onClick = onBack) { + Text("Back", color = MaterialTheme.colorScheme.primary, fontWeight = FontWeight.SemiBold) + } + }, + title = { + Column { + Text( + agent?.taskName ?: "Chat", + maxLines = 1, + overflow = TextOverflow.Ellipsis, + style = MaterialTheme.typography.titleMedium, + fontWeight = FontWeight.Bold, + ) + Text( + (chat?.let { chatStatusLabel(it) } ?: statusLabel(state)) + (pageLabel?.let { " · $it" } ?: ""), + style = MaterialTheme.typography.bodySmall, + color = AppTheme.extra.textMuted, + ) + } + }, + actions = { + if (agent != null && state.canControl) { + TextButton(onClick = { closing = true }) { + Text("Close", color = MaterialTheme.colorScheme.error, fontWeight = FontWeight.SemiBold) + } + } + }, + ) + HorizontalDivider(thickness = 1.dp, color = AppTheme.extra.border) + } + }, + ) { padding -> + Column( + Modifier + .fillMaxSize() + .padding(padding) + .imePadding(), + ) { + val current = chat + if (current?.status == "closed") { + Banner("This chat has stopped. Reconnect it on your computer to continue.") + } + current?.error?.let { Banner(it, error = true) } + if (current == null) { + Box(Modifier.weight(1f).fillMaxWidth(), contentAlignment = Alignment.Center) { + Text("Loading the conversation…", color = AppTheme.extra.textMuted) + } + } else { + ChatTranscript( + chat = current, + agentName = agentName, + canRespond = canSend, + respond = { request, decision, answers -> + val params = JSONObject() + .put("requestId", request.id) + .put("decision", decision) + .put("answers", JSONObject(answers)) + client.sendChatAction(agentId, "respond", params) + }, + modifier = Modifier.weight(1f), + ) + } + if (!state.canControl) { + Spacer(Modifier.height(8.dp)) + PairBanner(onPair) + Spacer(Modifier.height(8.dp)) + } else { + ChatComposer( + draftKey = agentId, + quickReplies = quickReplies, + enabled = canSend && current != null && current.status != "closed", + working = current?.status == "working", + send = { text -> client.sendChatAction(agentId, "send", JSONObject().put("text", text)) }, + stop = { client.sendChatAction(agentId, "interrupt") }, + ) + } + } + } +} + +private fun chatStatusLabel(chat: ChatState) = when (chat.status) { + "starting" -> "Starting…" + "working" -> "Working" + "closed" -> "Stopped" + else -> if (chat.requests.isNotEmpty()) "Needs input" else "Ready" +} + (chat.model?.let { " · $it" } ?: "") + +@Composable +private fun Banner(text: String, error: Boolean = false) { + Text( + text, + Modifier + .fillMaxWidth() + .background(if (error) MaterialTheme.colorScheme.errorContainer else AppTheme.extra.warningBannerBg) + .padding(horizontal = 16.dp, vertical = 10.dp), + color = if (error) MaterialTheme.colorScheme.onErrorContainer else AppTheme.extra.warningText, + style = MaterialTheme.typography.bodyMedium, + ) +} + +@Composable +private fun ChatTranscript( + chat: ChatState, + agentName: String, + canRespond: Boolean, + respond: suspend (ChatRequest, String, Map) -> Unit, + modifier: Modifier, +) { + val list = rememberLazyListState() + val count = chat.items.size + chat.requests.size + // Follow the conversation while the reader is at its end; leave them be if they scrolled up. + LaunchedEffect(count, chat.items.lastOrNull()?.text?.length) { + val last = list.layoutInfo.visibleItemsInfo.lastOrNull()?.index ?: -1 + if (count > 0 && last >= list.layoutInfo.totalItemsCount - 3) list.animateScrollToItem(count - 1) + } + LazyColumn( + modifier = modifier.fillMaxWidth(), + state = list, + contentPadding = PaddingValues(12.dp), + verticalArrangement = Arrangement.spacedBy(10.dp), + ) { + items(chat.items, key = { "item:${it.id}" }) { item -> + // New messages fade and rise in; existing ones slide when others arrive. + Box(Modifier.animateItem(fadeInSpec = tween(220), placementSpec = spring(stiffness = Spring.StiffnessMediumLow))) { + when (item.kind) { + "user" -> UserMessage(item.text) + "tool" -> ToolItem(item) + else -> SelectionContainer { + Text(item.text, style = MaterialTheme.typography.bodyMedium, color = MaterialTheme.colorScheme.onSurface) + } + } + } + } + items(chat.requests, key = { "request:${it.id}" }) { request -> + Box(Modifier.animateItem()) { RequestCard(request, agentName, canRespond, respond) } + } + if (chat.status == "working" && chat.requests.isEmpty()) { + item(key = "typing") { TypingIndicator(Modifier.animateItem()) } + } + } +} + +/** Three dots rising in turn while the agent works. */ +@Composable +private fun TypingIndicator(modifier: Modifier) { + val transition = rememberInfiniteTransition(label = "typing") + Row(modifier.padding(vertical = 4.dp), horizontalArrangement = Arrangement.spacedBy(4.dp)) { + repeat(3) { i -> + val lift by transition.animateFloat( + initialValue = 0f, + targetValue = 1f, + animationSpec = infiniteRepeatable( + animation = keyframes { + durationMillis = 1200 + 0f at 0 + i * 150 + 1f at 300 + i * 150 + 0f at 600 + i * 150 + }, + ), + label = "dot$i", + ) + Box( + Modifier + .size(7.dp) + .graphicsLayer { + translationY = -lift * 5.dp.toPx() + alpha = 0.4f + 0.6f * lift + } + .background(AppTheme.extra.textMuted, CircleShape), + ) + } + } +} + +@Composable +private fun UserMessage(text: String) { + Box(Modifier.fillMaxWidth(), contentAlignment = Alignment.CenterEnd) { + SelectionContainer { + Text( + text, + Modifier + .widthIn(max = 320.dp) + .background(MaterialTheme.colorScheme.primaryContainer, MaterialTheme.shapes.small) + .padding(horizontal = 12.dp, vertical = 8.dp), + color = MaterialTheme.colorScheme.onPrimaryContainer, + style = MaterialTheme.typography.bodyMedium, + ) + } + } +} + +@Composable +private fun ToolItem(item: ChatItem) { + var open by rememberSaveable(item.id) { mutableStateOf(false) } + val activity = item.activity + val (mark, color) = when (activity?.status) { + "running" -> "…" to AppTheme.extra.textMuted + "failed" -> "✗" to MaterialTheme.colorScheme.error + "declined", "interrupted" -> "–" to AppTheme.extra.warningText + else -> "✓" to AppTheme.extra.success + } + val output = item.text.take(MAX_TOOL_OUTPUT) + Column( + Modifier + .fillMaxWidth() + .border(BorderStroke(1.dp, AppTheme.extra.borderSubtle), MaterialTheme.shapes.small) + .clickable(enabled = output.isNotBlank()) { open = !open } + .padding(horizontal = 10.dp, vertical = 8.dp), + ) { + Row(horizontalArrangement = Arrangement.spacedBy(8.dp)) { + Text(mark, color = color, fontWeight = FontWeight.Bold) + Text( + activity?.command ?: activity?.label ?: "Tool", + fontFamily = if (activity?.command != null) FontFamily.Monospace else null, + style = MaterialTheme.typography.bodySmall, + color = AppTheme.extra.textMuted, + maxLines = if (open) Int.MAX_VALUE else 1, + overflow = TextOverflow.Ellipsis, + ) + } + if (open) { + Text( + output + if (item.text.length > MAX_TOOL_OUTPUT) "\n… (more on your computer)" else "", + Modifier + .padding(top = 6.dp) + .horizontalScroll(rememberScrollState()), + fontFamily = FontFamily.Monospace, + fontSize = 11.sp, + lineHeight = 14.sp, + color = MaterialTheme.colorScheme.onSurface, + softWrap = false, + ) + } + } +} + +/** The desktop's RequestCard: an approval to allow or decline, or questions to answer. */ +@Composable +private fun RequestCard( + request: ChatRequest, + agentName: String, + canRespond: Boolean, + respond: suspend (ChatRequest, String, Map) -> Unit, +) { + val scope = rememberCoroutineScope() + var answers by remember(request.id) { mutableStateOf(mapOf()) } + var picked by remember(request.id) { mutableStateOf(mapOf>()) } + var busy by remember(request.id) { mutableStateOf(false) } + var error by remember(request.id) { mutableStateOf(null) } + var showDetails by remember(request.id) { mutableStateOf(false) } + val isQuestion = request.kind == "question" + + val haptic = LocalHapticFeedback.current + // The card breathes like a waiting agent card, so it reads as the thing to act on. + val glow by rememberInfiniteTransition(label = "requestGlow").animateFloat( + initialValue = 0.45f, + targetValue = 1f, + animationSpec = infiniteRepeatable(tween(900, easing = FastOutSlowInEasing), RepeatMode.Reverse), + label = "requestGlowAlpha", + ) + + fun submit(decision: String) { + haptic.performHapticFeedback(if (decision == "decline") HapticFeedbackType.Reject else HapticFeedbackType.Confirm) + busy = true + error = null + scope.launch { + try { + respond(request, decision, answers) + } catch (e: Exception) { + error = e.message + } finally { + busy = false + } + } + } + + Column( + Modifier + .fillMaxWidth() + .background(AppTheme.extra.cardBgAttention) + .border(BorderStroke(1.dp, AppTheme.extra.attentionBorder.copy(alpha = glow))) + .padding(12.dp), + verticalArrangement = Arrangement.spacedBy(10.dp), + ) { + Text( + if (isQuestion) "$agentName needs your input" else "Approval needed" + (request.action?.let { ": $it" } ?: ""), + fontWeight = FontWeight.Bold, + color = AppTheme.extra.warningText, + ) + if (!isQuestion) { + Text(request.text, style = MaterialTheme.typography.bodyMedium) + request.details?.let { details -> + Text( + if (showDetails) "Hide details" else "Details", + Modifier.clickable { showDetails = !showDetails }, + color = MaterialTheme.colorScheme.primary, + style = MaterialTheme.typography.bodySmall, + ) + if (showDetails) { + Text(details, fontFamily = FontFamily.Monospace, fontSize = 11.sp, lineHeight = 14.sp) + } + } + } + request.questions.forEach { q -> + Text(q.question, style = MaterialTheme.typography.bodyMedium, fontWeight = FontWeight.Medium) + if (q.options.isNotEmpty()) { + Row( + Modifier.horizontalScroll(rememberScrollState()), + horizontalArrangement = Arrangement.spacedBy(6.dp), + ) { + q.options.forEach { option -> + val selected = if (q.multiSelect) picked[q.id].orEmpty().contains(option.label) else answers[q.id] == option.label + FilterChip( + selected = selected, + enabled = !busy, + onClick = { + if (q.multiSelect) { + val previous = picked[q.id].orEmpty() + val next = if (option.label in previous) previous - option.label else previous + option.label + picked = picked + (q.id to next) + answers = answers + (q.id to next.joinToString(", ")) + } else { + answers = answers + (q.id to option.label) + } + }, + label = { Text(option.label) }, + ) + } + } + } + OutlinedTextField( + value = answers[q.id].orEmpty(), + onValueChange = { + answers = answers + (q.id to it) + picked = picked + (q.id to emptyList()) + }, + modifier = Modifier.fillMaxWidth(), + placeholder = { Text(if (q.options.isEmpty()) "Your answer" else "Or type your own answer") }, + visualTransformation = if (q.isSecret) PasswordVisualTransformation() else VisualTransformation.None, + singleLine = true, + enabled = !busy, + ) + } + Row(horizontalArrangement = Arrangement.spacedBy(8.dp), verticalAlignment = Alignment.CenterVertically) { + val answered = request.questions.all { answers[it.id]?.isNotBlank() == true } + Button( + onClick = { submit("accept") }, + enabled = canRespond && !busy && answered, + colors = if (request.defaultToNo) ButtonDefaults.outlinedButtonColors() else ButtonDefaults.buttonColors(), + ) { Text(if (isQuestion) "Submit answers" else "Allow once") } + if (!isQuestion && request.canAlwaysAllow) { + OutlinedButton(onClick = { submit("accept-always") }, enabled = canRespond && !busy) { Text("Always allow") } + } + if (!isQuestion) { + OutlinedButton(onClick = { submit("decline") }, enabled = canRespond && !busy) { Text("Decline") } + } + if (busy) CircularProgressIndicator(Modifier.size(18.dp), strokeWidth = 2.dp) + } + if (!isQuestion && request.canAlwaysAllow) { + request.alwaysAllowNote?.let { + Text("Always allow will $it.", style = MaterialTheme.typography.bodySmall, color = AppTheme.extra.textMuted) + } + } + error?.let { Text(it, color = MaterialTheme.colorScheme.error, style = MaterialTheme.typography.bodySmall) } + } +} + +@Composable +private fun ChatComposer( + draftKey: String, + quickReplies: List, + enabled: Boolean, + working: Boolean, + send: suspend (String) -> Unit, + stop: suspend () -> Unit, +) { + val scope = rememberCoroutineScope() + var draft by rememberSaveable(draftKey) { mutableStateOf("") } + var busy by remember { mutableStateOf(false) } + var error by remember { mutableStateOf(null) } + + fun run(action: suspend () -> Unit, clearDraft: Boolean) { + busy = true + error = null + scope.launch { + try { + action() + if (clearDraft) draft = "" + } catch (e: Exception) { + error = e.message + } finally { + busy = false + } + } + } + + Column( + Modifier + .fillMaxWidth() + .background(MaterialTheme.colorScheme.surface) + .padding(horizontal = 12.dp, vertical = 8.dp), + ) { + error?.let { Text(it, color = MaterialTheme.colorScheme.error, style = MaterialTheme.typography.bodySmall) } + QuickReplies(quickReplies, enabled = enabled && !busy) { draft = appendToDraft(draft, it) } + Row(verticalAlignment = Alignment.CenterVertically, horizontalArrangement = Arrangement.spacedBy(8.dp)) { + OutlinedTextField( + value = draft, + onValueChange = { draft = it }, + modifier = Modifier.weight(1f), + placeholder = { Text("Message the agent") }, + maxLines = 5, + enabled = enabled, + ) + VoiceInputButton(enabled = enabled && !busy) { draft = appendToDraft(draft, it) } + if (working && draft.isBlank()) { + OutlinedButton(onClick = { run(stop, clearDraft = false) }, enabled = enabled && !busy) { Text("Stop") } + } else { + Button( + onClick = { run({ send(draft.trim()) }, clearDraft = true) }, + enabled = enabled && !busy && draft.isNotBlank(), + ) { Text("Send") } + } + } + } +} diff --git a/android/app/src/main/java/com/parallelcode/phone/ChatState.kt b/android/app/src/main/java/com/parallelcode/phone/ChatState.kt new file mode 100644 index 000000000..c28eb0a49 --- /dev/null +++ b/android/app/src/main/java/com/parallelcode/phone/ChatState.kt @@ -0,0 +1,84 @@ +package com.parallelcode.phone + +import org.json.JSONArray +import org.json.JSONObject + +// Mirrors AgentChatState in electron/shared/agent-chat-types.ts: the parts the phone shows. + +data class ChatActivity(val type: String, val label: String, val status: String, val command: String?) + +data class ChatItem(val id: String, val kind: String, val text: String, val activity: ChatActivity?) + +data class ChatOption(val label: String, val description: String) + +data class ChatQuestion( + val id: String, + val question: String, + val isSecret: Boolean, + val multiSelect: Boolean, + val options: List, +) + +/** [id] keeps the desktop's type (string or number): the respond action must send it back as is. */ +data class ChatRequest( + val id: Any, + val kind: String, + val text: String, + val action: String?, + val details: String?, + val questions: List, + val defaultToNo: Boolean, + val canAlwaysAllow: Boolean, + val alwaysAllowNote: String?, +) + +data class ChatState( + val status: String, + val model: String?, + val items: List, + val requests: List, + val error: String?, +) + +private fun JSONObject.stringOrNull(key: String): String? = + if (has(key) && !isNull(key)) optString(key) else null + +private inline fun JSONArray?.mapObjects(transform: (JSONObject) -> T): List = + if (this == null) emptyList() else List(length()) { transform(getJSONObject(it)) } + +fun parseChatState(json: JSONObject) = ChatState( + status = json.optString("status", "starting"), + model = json.stringOrNull("model"), + items = json.optJSONArray("items").mapObjects { item -> + ChatItem( + id = item.optString("id"), + kind = item.optString("kind"), + text = item.optString("text"), + activity = item.optJSONObject("activity")?.let { + ChatActivity(it.optString("type"), it.optString("label"), it.optString("status"), it.stringOrNull("command")) + }, + ) + }, + requests = json.optJSONArray("requests").mapObjects { request -> + ChatRequest( + id = request.get("id"), + kind = request.optString("kind"), + text = request.optString("text"), + action = request.stringOrNull("action"), + details = request.stringOrNull("details"), + questions = request.optJSONArray("questions").mapObjects { q -> + ChatQuestion( + id = q.optString("id"), + question = q.optString("question"), + isSecret = q.optBoolean("isSecret"), + multiSelect = q.optBoolean("multiSelect"), + options = q.optJSONArray("options").mapObjects { ChatOption(it.optString("label"), it.optString("description")) }, + ) + }, + defaultToNo = request.optBoolean("defaultToNo"), + canAlwaysAllow = request.optBoolean("canAlwaysAllow"), + alwaysAllowNote = request.stringOrNull("alwaysAllowNote"), + ) + }, + error = json.stringOrNull("error"), +) diff --git a/android/app/src/main/java/com/parallelcode/phone/ComposerExtras.kt b/android/app/src/main/java/com/parallelcode/phone/ComposerExtras.kt new file mode 100644 index 000000000..643cbc171 --- /dev/null +++ b/android/app/src/main/java/com/parallelcode/phone/ComposerExtras.kt @@ -0,0 +1,74 @@ +package com.parallelcode.phone + +import android.app.Activity +import android.content.Intent +import android.speech.RecognizerIntent +import androidx.activity.compose.rememberLauncherForActivityResult +import androidx.activity.result.contract.ActivityResultContracts +import androidx.compose.foundation.horizontalScroll +import androidx.compose.foundation.layout.Arrangement +import androidx.compose.foundation.layout.Row +import androidx.compose.foundation.rememberScrollState +import androidx.compose.material3.AssistChip +import androidx.compose.material3.MaterialTheme +import androidx.compose.material3.Text +import androidx.compose.material3.TextButton +import androidx.compose.runtime.Composable +import androidx.compose.runtime.remember +import androidx.compose.ui.Modifier +import androidx.compose.ui.hapticfeedback.HapticFeedbackType +import androidx.compose.ui.platform.LocalContext +import androidx.compose.ui.platform.LocalHapticFeedback +import androidx.compose.ui.unit.dp + +/** Saved replies from Settings: tapping one puts it in the draft, ready to send or edit. */ +@Composable +fun QuickReplies(replies: List, enabled: Boolean, onPick: (String) -> Unit) { + if (replies.isEmpty()) return + val haptic = LocalHapticFeedback.current + Row( + Modifier.horizontalScroll(rememberScrollState()), + horizontalArrangement = Arrangement.spacedBy(6.dp), + ) { + replies.forEach { reply -> + AssistChip( + onClick = { + haptic.performHapticFeedback(HapticFeedbackType.TextHandleMove) + onPick(reply) + }, + enabled = enabled, + label = { Text(reply, maxLines = 1) }, + ) + } + } +} + +/** Adds [text] to a draft, as typing it after what is already there would. */ +fun appendToDraft(draft: String, text: String): String = when { + draft.isBlank() -> text + draft.endsWith(" ") || draft.endsWith("\n") -> draft + text + else -> "$draft $text" +} + +/** + * Dictate into the draft with Android's speech recognizer. Hidden when the phone has none; the + * recognizer shows its own listening UI, so the app needs no microphone permission. + */ +@Composable +fun VoiceInputButton(enabled: Boolean, onText: (String) -> Unit) { + val context = LocalContext.current + val intent = remember { + Intent(RecognizerIntent.ACTION_RECOGNIZE_SPEECH) + .putExtra(RecognizerIntent.EXTRA_LANGUAGE_MODEL, RecognizerIntent.LANGUAGE_MODEL_FREE_FORM) + .putExtra(RecognizerIntent.EXTRA_PROMPT, "Speak your reply") + } + val available = remember { intent.resolveActivity(context.packageManager) != null } + val launcher = rememberLauncherForActivityResult(ActivityResultContracts.StartActivityForResult()) { result -> + if (result.resultCode != Activity.RESULT_OK) return@rememberLauncherForActivityResult + result.data?.getStringArrayListExtra(RecognizerIntent.EXTRA_RESULTS)?.firstOrNull()?.let(onText) + } + if (!available) return + TextButton(onClick = { launcher.launch(intent) }, enabled = enabled) { + Text("🎤", style = MaterialTheme.typography.titleMedium) + } +} diff --git a/android/app/src/main/java/com/parallelcode/phone/ConnectionLink.kt b/android/app/src/main/java/com/parallelcode/phone/ConnectionLink.kt new file mode 100644 index 000000000..f9852ead4 --- /dev/null +++ b/android/app/src/main/java/com/parallelcode/phone/ConnectionLink.kt @@ -0,0 +1,37 @@ +package com.parallelcode.phone + +import java.net.URI +import java.net.URISyntaxException +import java.net.URLDecoder + +/** The "Connect Phone" QR code on the desktop: `http://host:port/?token=`. */ +data class ConnectionLink(val baseUrl: String, val token: String) { + val webSocketUrl: String + get() = "ws" + baseUrl.removePrefix("http") + "/ws" + + companion object { + // The server rejects longer tokens in its WebSocket auth message. + private const val MAX_TOKEN_LENGTH = 200 + + fun parse(raw: String): ConnectionLink? { + val uri = try { + URI(raw.trim()) + } catch (_: URISyntaxException) { + return null + } + val scheme = uri.scheme?.lowercase() + if (scheme != "http" && scheme != "https") return null + val authority = uri.rawAuthority?.takeIf { uri.host != null } ?: return null + val token = uri.rawQuery + ?.split('&') + ?.firstNotNullOfOrNull { part -> + val (key, value) = part.split('=', limit = 2).let { it[0] to it.getOrNull(1) } + value?.takeIf { key == "token" } + } + ?.let { URLDecoder.decode(it, "UTF-8") } + ?.takeIf { it.isNotEmpty() && it.length <= MAX_TOKEN_LENGTH } + ?: return null + return ConnectionLink("$scheme://$authority", token) + } + } +} diff --git a/android/app/src/main/java/com/parallelcode/phone/CredentialStore.kt b/android/app/src/main/java/com/parallelcode/phone/CredentialStore.kt new file mode 100644 index 000000000..a72a4869c --- /dev/null +++ b/android/app/src/main/java/com/parallelcode/phone/CredentialStore.kt @@ -0,0 +1,118 @@ +package com.parallelcode.phone + +import android.content.SharedPreferences +import androidx.core.content.edit +import org.json.JSONArray +import org.json.JSONObject + +/** A desktop this phone has linked to, by its Remote Access address. */ +data class SavedComputer(val baseUrl: String, val token: String, val pairedToken: String?) { + /** "192.168.1.20:7777": what the phone shows for this computer. */ + val label: String get() = baseUrl.substringAfter("://") +} + +/** + * The desktops this phone is linked to, and which one it uses. The view-only token comes from the + * QR code; the paired token is minted by entering the desktop's PIN and is the one allowed to type. + * Computers are keyed by address. App-private storage with backups disabled in the manifest. + */ +class CredentialStore(private val prefs: SharedPreferences) { + + init { + migrateSingleComputer() + } + + val computers: List + get() { + val array = runCatching { JSONArray(prefs.getString(KEY_COMPUTERS, "[]")) }.getOrElse { JSONArray() } + return List(array.length()) { i -> + val c = array.getJSONObject(i) + SavedComputer( + c.getString("baseUrl"), + c.getString("token"), + if (c.isNull("pairedToken")) null else c.optString("pairedToken").ifEmpty { null }, + ) + } + } + + private val active: SavedComputer? + get() = prefs.getString(KEY_ACTIVE, null)?.let { url -> computers.firstOrNull { it.baseUrl == url } } + + val link: ConnectionLink? + get() = active?.let { ConnectionLink(it.baseUrl, it.token) } + + val pairedToken: String? + get() = active?.pairedToken + + /** + * Use [link]'s computer, adding it if new. A new QR token for a known address may belong to a + * different computer now, so it drops that address's paired token. + */ + fun saveLink(link: ConnectionLink) { + val others = computers.filter { it.baseUrl != link.baseUrl } + write(others + SavedComputer(link.baseUrl, link.token, null), active = link.baseUrl) + } + + /** Switch to a saved computer. */ + fun select(baseUrl: String) { + if (computers.any { it.baseUrl == baseUrl }) prefs.edit { putString(KEY_ACTIVE, baseUrl) } + } + + fun savePairedToken(token: String) = updateActive { it.copy(pairedToken = token) } + + fun clearPairedToken() = updateActive { it.copy(pairedToken = null) } + + /** Forget a saved computer; forgetting the one in use leaves none selected. */ + fun remove(baseUrl: String) { + val current = prefs.getString(KEY_ACTIVE, null) + write(computers.filter { it.baseUrl != baseUrl }, active = current.takeIf { it != baseUrl }) + } + + /** Forget the computer in use. */ + fun clear() { + prefs.getString(KEY_ACTIVE, null)?.let(::remove) + } + + private fun updateActive(change: (SavedComputer) -> SavedComputer) { + val url = prefs.getString(KEY_ACTIVE, null) ?: return + write(computers.map { if (it.baseUrl == url) change(it) else it }, active = url) + } + + private fun write(list: List, active: String?) { + val array = JSONArray() + list.forEach { + array.put( + JSONObject() + .put("baseUrl", it.baseUrl) + .put("token", it.token) + .put("pairedToken", it.pairedToken ?: JSONObject.NULL), + ) + } + prefs.edit { + putString(KEY_COMPUTERS, array.toString()) + if (active == null) remove(KEY_ACTIVE) else putString(KEY_ACTIVE, active) + } + } + + /** Earlier versions kept one computer in flat keys; carry it over, pairing included. */ + private fun migrateSingleComputer() { + val baseUrl = prefs.getString(LEGACY_BASE_URL, null) ?: return + val token = prefs.getString(LEGACY_TOKEN, null) + if (token != null && !prefs.contains(KEY_COMPUTERS)) { + write(listOf(SavedComputer(baseUrl, token, prefs.getString(LEGACY_PAIRED_TOKEN, null))), active = baseUrl) + } + prefs.edit { + remove(LEGACY_BASE_URL) + remove(LEGACY_TOKEN) + remove(LEGACY_PAIRED_TOKEN) + } + } + + private companion object { + const val KEY_COMPUTERS = "computers" + const val KEY_ACTIVE = "active" + const val LEGACY_BASE_URL = "baseUrl" + const val LEGACY_TOKEN = "token" + const val LEGACY_PAIRED_TOKEN = "pairedToken" + } +} diff --git a/android/app/src/main/java/com/parallelcode/phone/DiffPane.kt b/android/app/src/main/java/com/parallelcode/phone/DiffPane.kt new file mode 100644 index 000000000..88f315046 --- /dev/null +++ b/android/app/src/main/java/com/parallelcode/phone/DiffPane.kt @@ -0,0 +1,193 @@ +package com.parallelcode.phone + +import androidx.compose.animation.AnimatedVisibility +import androidx.compose.animation.expandVertically +import androidx.compose.animation.fadeIn +import androidx.compose.animation.fadeOut +import androidx.compose.animation.shrinkVertically +import androidx.compose.foundation.background +import androidx.compose.foundation.clickable +import androidx.compose.foundation.horizontalScroll +import androidx.compose.foundation.layout.Arrangement +import androidx.compose.foundation.layout.Box +import androidx.compose.foundation.layout.Column +import androidx.compose.foundation.layout.Row +import androidx.compose.foundation.layout.fillMaxSize +import androidx.compose.foundation.layout.fillMaxWidth +import androidx.compose.foundation.layout.padding +import androidx.compose.foundation.lazy.LazyColumn +import androidx.compose.foundation.lazy.items +import androidx.compose.foundation.rememberScrollState +import androidx.compose.material3.CircularProgressIndicator +import androidx.compose.material3.ExperimentalMaterial3Api +import androidx.compose.material3.HorizontalDivider +import androidx.compose.material3.MaterialTheme +import androidx.compose.material3.Text +import androidx.compose.material3.TextButton +import androidx.compose.material3.pulltorefresh.PullToRefreshBox +import androidx.compose.runtime.Composable +import androidx.compose.runtime.LaunchedEffect +import androidx.compose.runtime.getValue +import androidx.compose.runtime.mutableIntStateOf +import androidx.compose.runtime.mutableStateOf +import androidx.compose.runtime.remember +import androidx.compose.runtime.saveable.rememberSaveable +import androidx.compose.runtime.setValue +import androidx.compose.ui.Alignment +import androidx.compose.ui.Modifier +import androidx.compose.ui.graphics.Color +import androidx.compose.ui.text.font.FontFamily +import androidx.compose.ui.text.font.FontWeight +import androidx.compose.ui.text.style.TextOverflow +import androidx.compose.ui.unit.dp +import androidx.compose.ui.unit.sp + +/** Diff lines shown per file on the phone; the desktop shows the rest. */ +private const val MAX_FILE_LINES = 1500 + +/** The task's changes against its base branch, file by file, as the desktop's diff view. */ +@OptIn(ExperimentalMaterial3Api::class) +@Composable +fun DiffPane(taskId: String, client: RemoteClient, modifier: Modifier) { + var reload by remember { mutableIntStateOf(0) } + var files by remember(taskId) { mutableStateOf?>(null) } + var truncated by remember(taskId) { mutableStateOf(false) } + var error by remember(taskId) { mutableStateOf(null) } + var loading by remember { mutableStateOf(false) } + + LaunchedEffect(taskId, reload) { + loading = true + error = null + try { + val (diff, cut) = client.fetchDiff(taskId) + files = parseUnifiedDiff(diff) + truncated = cut + } catch (e: ApiException) { + error = if (e.status == 403 || e.status == 404) "Update Parallel Code on your computer to see changes here." else e.message + } finally { + loading = false + } + } + + Column(modifier) { + Row( + Modifier + .fillMaxWidth() + .padding(start = 16.dp, end = 4.dp), + verticalAlignment = Alignment.CenterVertically, + ) { + val current = files + Text( + when { + current == null -> "" + current.isEmpty() -> "No changes" + else -> "${current.size} file${if (current.size == 1) "" else "s"} · +${current.sumOf { it.added }} −${current.sumOf { it.removed }}" + }, + Modifier.weight(1f), + style = MaterialTheme.typography.bodySmall, + color = AppTheme.extra.textMuted, + ) + TextButton(onClick = { reload++ }, enabled = !loading) { Text("Refresh") } + } + HorizontalDivider(thickness = 1.dp, color = AppTheme.extra.border) + error?.let { + Text(it, Modifier.padding(16.dp), color = MaterialTheme.colorScheme.error) + return@Column + } + val current = files ?: run { + if (loading) { + Box(Modifier.fillMaxSize(), contentAlignment = Alignment.Center) { CircularProgressIndicator(strokeWidth = 2.dp) } + } + return@Column + } + if (truncated) { + Text( + "This diff is too large for the phone; showing the start.", + Modifier + .fillMaxWidth() + .background(AppTheme.extra.warningBannerBg) + .padding(horizontal = 16.dp, vertical = 8.dp), + color = AppTheme.extra.warningText, + style = MaterialTheme.typography.bodySmall, + ) + } + // Pull down to refresh, as on the agent list. + PullToRefreshBox(isRefreshing = loading, onRefresh = { reload++ }, modifier = Modifier.fillMaxSize()) { + LazyColumn(Modifier.fillMaxSize()) { + items(current, key = { it.path }) { file -> DiffFileRow(file) } + } + } + } +} + +@Composable +private fun DiffFileRow(file: DiffFile) { + var open by rememberSaveable(file.path) { mutableStateOf(false) } + Column { + Row( + Modifier + .fillMaxWidth() + .clickable(enabled = !file.binary) { open = !open } + .padding(horizontal = 16.dp, vertical = 10.dp), + horizontalArrangement = Arrangement.spacedBy(8.dp), + verticalAlignment = Alignment.CenterVertically, + ) { + Text(if (open) "▾" else "▸", color = AppTheme.extra.textMuted) + Text( + file.path, + Modifier.weight(1f), + fontFamily = FontFamily.Monospace, + fontSize = 12.sp, + maxLines = 2, + overflow = TextOverflow.StartEllipsis, + ) + if (file.binary) { + Text("binary", style = MaterialTheme.typography.bodySmall, color = AppTheme.extra.textMuted) + } else { + Text("+${file.added}", color = AppTheme.extra.success, fontSize = 12.sp, fontWeight = FontWeight.SemiBold) + Text("−${file.removed}", color = MaterialTheme.colorScheme.error, fontSize = 12.sp, fontWeight = FontWeight.SemiBold) + } + } + AnimatedVisibility(visible = open, enter = expandVertically() + fadeIn(), exit = shrinkVertically() + fadeOut()) { + DiffLines(file) + } + HorizontalDivider(thickness = 1.dp, color = AppTheme.extra.borderSubtle) + } +} + +@Composable +private fun DiffLines(file: DiffFile) { + val added = AppTheme.extra.success + val removed = MaterialTheme.colorScheme.error + val hunk = AppTheme.extra.textMuted + Box( + Modifier + .fillMaxWidth() + .background(AppTheme.extra.inputBg) + .horizontalScroll(rememberScrollState()) + .padding(horizontal = 12.dp, vertical = 6.dp), + ) { + Column { + file.lines.take(MAX_FILE_LINES).forEach { line -> + val (fg, bg) = when { + line.startsWith("@@") -> hunk to Color.Transparent + line.startsWith("+") -> added to added.copy(alpha = 0.12f) + line.startsWith("-") -> removed to removed.copy(alpha = 0.12f) + else -> MaterialTheme.colorScheme.onSurface to Color.Transparent + } + Text( + line.ifEmpty { " " }, + Modifier.background(bg), + color = fg, + fontFamily = FontFamily.Monospace, + fontSize = 11.sp, + lineHeight = 14.sp, + softWrap = false, + ) + } + if (file.lines.size > MAX_FILE_LINES) { + Text("… ${file.lines.size - MAX_FILE_LINES} more lines on your computer", color = hunk, fontSize = 11.sp) + } + } + } +} diff --git a/android/app/src/main/java/com/parallelcode/phone/MainActivity.kt b/android/app/src/main/java/com/parallelcode/phone/MainActivity.kt new file mode 100644 index 000000000..1fb4e62dc --- /dev/null +++ b/android/app/src/main/java/com/parallelcode/phone/MainActivity.kt @@ -0,0 +1,439 @@ +package com.parallelcode.phone +import android.Manifest +import android.app.Application +import android.content.Context +import android.content.Intent +import android.content.pm.PackageManager +import android.os.Build +import android.os.Bundle +import android.view.WindowManager +import androidx.activity.ComponentActivity +import androidx.activity.compose.BackHandler +import androidx.compose.runtime.snapshotFlow +import androidx.compose.foundation.pager.rememberPagerState +import androidx.compose.foundation.pager.HorizontalPager +import androidx.compose.ui.hapticfeedback.HapticFeedbackType +import androidx.compose.ui.platform.LocalHapticFeedback +import androidx.compose.ui.unit.dp +import androidx.compose.ui.graphics.graphicsLayer +import androidx.compose.ui.Modifier +import androidx.compose.runtime.remember +import androidx.compose.runtime.mutableFloatStateOf +import androidx.activity.compose.PredictiveBackHandler +import androidx.activity.compose.rememberLauncherForActivityResult +import androidx.activity.compose.setContent +import androidx.activity.enableEdgeToEdge +import androidx.activity.result.contract.ActivityResultContracts +import androidx.activity.viewModels +import androidx.compose.animation.AnimatedContent +import androidx.compose.animation.core.tween +import androidx.compose.animation.fadeIn +import androidx.compose.animation.fadeOut +import androidx.compose.animation.slideInHorizontally +import androidx.compose.animation.slideOutHorizontally +import androidx.compose.animation.togetherWith +import androidx.compose.foundation.isSystemInDarkTheme +import androidx.compose.runtime.Composable +import androidx.compose.runtime.DisposableEffect +import androidx.compose.runtime.LaunchedEffect +import androidx.compose.runtime.collectAsState +import androidx.compose.runtime.getValue +import androidx.compose.runtime.mutableStateOf +import androidx.compose.runtime.saveable.rememberSaveable +import androidx.compose.runtime.setValue +import androidx.compose.ui.platform.LocalContext +import androidx.core.content.ContextCompat +import androidx.lifecycle.AndroidViewModel +import kotlinx.coroutines.flow.MutableStateFlow +import kotlinx.coroutines.flow.StateFlow +import kotlinx.coroutines.flow.asStateFlow + +/** Which agent changes notify, from Settings (see [AgentWatchService]). */ +data class NotificationPrefs( + val enabled: Boolean, + val needsInput: Boolean, + val errors: Boolean, + val finished: Boolean, +) + +class PhoneViewModel(application: Application) : AndroidViewModel(application) { + private val phoneApp = application as PhoneApplication + val client = phoneApp.client + val settingsStore = phoneApp.settings + + /** An agent to open, from a tapped notification. */ + val openAgentRequest = MutableStateFlow(null) + + private val _notifications = MutableStateFlow(readNotificationPrefs()) + val notifications: StateFlow = _notifications.asStateFlow() + + private fun readNotificationPrefs() = NotificationPrefs( + enabled = settingsStore.notificationsEnabled, + needsInput = settingsStore.notifyNeedsInput, + errors = settingsStore.notifyErrors, + finished = settingsStore.notifyFinished, + ) + + fun setNotifications(prefs: NotificationPrefs) { + settingsStore.notificationsEnabled = prefs.enabled + settingsStore.notifyNeedsInput = prefs.needsInput + settingsStore.notifyErrors = prefs.errors + settingsStore.notifyFinished = prefs.finished + _notifications.value = prefs + AgentWatchService.sync(getApplication()) + } + private val _keepScreenOn = MutableStateFlow(settingsStore.keepScreenOn) + val keepScreenOn: StateFlow = _keepScreenOn.asStateFlow() + + private val _keepScreenOnOnlyWhenActive = MutableStateFlow(settingsStore.keepScreenOnOnlyWhenActive) + val keepScreenOnOnlyWhenActive: StateFlow = _keepScreenOnOnlyWhenActive.asStateFlow() + + private val _themeMode = MutableStateFlow(settingsStore.themeMode) + val themeMode: StateFlow = _themeMode.asStateFlow() + + private val _showMinimizedTasks = MutableStateFlow(settingsStore.showMinimizedTasks) + val showMinimizedTasks: StateFlow = _showMinimizedTasks.asStateFlow() + + private val _quickReplies = MutableStateFlow(settingsStore.quickReplies) + val quickReplies: StateFlow> = _quickReplies.asStateFlow() + + fun setQuickReplies(value: List) { + settingsStore.quickReplies = value + _quickReplies.value = settingsStore.quickReplies + } + + private val _fitTerminalToPhone = MutableStateFlow(settingsStore.fitTerminalToPhone) + val fitTerminalToPhone: StateFlow = _fitTerminalToPhone.asStateFlow() + + fun setFitTerminalToPhone(value: Boolean) { + settingsStore.fitTerminalToPhone = value + _fitTerminalToPhone.value = value + } + + private val _alwaysFollowOutput = MutableStateFlow(settingsStore.alwaysFollowOutput) + val alwaysFollowOutput: StateFlow = _alwaysFollowOutput.asStateFlow() + + fun setAlwaysFollowOutput(value: Boolean) { + settingsStore.alwaysFollowOutput = value + _alwaysFollowOutput.value = value + } + + fun setKeepScreenOn(value: Boolean) { + settingsStore.keepScreenOn = value + _keepScreenOn.value = value + } + + fun setKeepScreenOnOnlyWhenActive(value: Boolean) { + settingsStore.keepScreenOnOnlyWhenActive = value + _keepScreenOnOnlyWhenActive.value = value + } + + fun setThemeMode(value: String) { + settingsStore.themeMode = value + _themeMode.value = value + } + + fun setShowMinimizedTasks(value: Boolean) { + settingsStore.showMinimizedTasks = value + _showMinimizedTasks.value = value + } +} + +class MainActivity : ComponentActivity() { + private val model: PhoneViewModel by viewModels() + + override fun onCreate(savedInstanceState: Bundle?) { + super.onCreate(savedInstanceState) + openAgentFrom(intent) + enableEdgeToEdge() + setContent { + val keepScreenOn by model.keepScreenOn.collectAsState() + val keepScreenOnOnlyActive by model.keepScreenOnOnlyWhenActive.collectAsState() + val agents by model.client.agents.collectAsState() + val themeMode by model.themeMode.collectAsState() + + val hasActiveAgent = agents.any { it.running && (it.attention == "active" || it.attention == "shell_busy") } + val shouldKeepAwake = keepScreenOn && (!keepScreenOnOnlyActive || hasActiveAgent) + + DisposableEffect(shouldKeepAwake) { + if (shouldKeepAwake) { + window.addFlags(WindowManager.LayoutParams.FLAG_KEEP_SCREEN_ON) + } else { + window.clearFlags(WindowManager.LayoutParams.FLAG_KEEP_SCREEN_ON) + } + onDispose { + window.clearFlags(WindowManager.LayoutParams.FLAG_KEEP_SCREEN_ON) + } + } + + val darkTheme = when (themeMode) { + SettingsStore.THEME_DARK -> true + SettingsStore.THEME_LIGHT -> false + else -> isSystemInDarkTheme() + } + + ParallelCodeTheme(darkTheme = darkTheme) { + PhoneApp(model) + } + } + } + + override fun onNewIntent(intent: Intent) { + super.onNewIntent(intent) + openAgentFrom(intent) + } + + private fun openAgentFrom(intent: Intent?) { + intent?.getStringExtra(EXTRA_AGENT_ID)?.let { model.openAgentRequest.value = it } + } + + // The socket stays open while the app is visible, like the phone web UI's tab, and in the + // background only while agent notifications are on. + override fun onStart() { + super.onStart() + (application as PhoneApplication).inForeground = true + model.client.start(HOLDER) + model.client.resumeViewSize() + AgentWatchService.sync(this) + } + + override fun onStop() { + model.client.pauseViewSize() + model.client.stop(HOLDER) + (application as PhoneApplication).inForeground = false + super.onStop() + } + + companion object { + const val EXTRA_AGENT_ID = "agentId" + private const val HOLDER = "app" + } +} + +private sealed interface Screen { + data object Agents : Screen + data object Pair : Screen + data object NewTask : Screen + data object Settings : Screen + data class Agent(val agentId: String) : Screen +} + +@Composable +private fun PhoneApp(model: PhoneViewModel) { + val state by model.client.state.collectAsState() + val agents by model.client.agents.collectAsState() + var screenKey by rememberSaveable { mutableStateOf("agents") } + val screen = when { + screenKey == "pair" -> Screen.Pair + screenKey == "new-task" -> Screen.NewTask + screenKey == "settings" -> Screen.Settings + screenKey.startsWith("agent:") -> Screen.Agent(screenKey.removePrefix("agent:")) + else -> Screen.Agents + } + val link = state.link + val openAgentRequest by model.openAgentRequest.collectAsState() + val haptic = LocalHapticFeedback.current + LaunchedEffect(openAgentRequest) { + val agentId = openAgentRequest ?: return@LaunchedEffect + haptic.performHapticFeedback(HapticFeedbackType.Confirm) + screenKey = "agent:$agentId" + model.openAgentRequest.value = null + } + + val computers by model.client.computers.collectAsState() + if (link == null) { + ConnectScreen( + expired = state.linkExpired, + onLink = { model.client.link(it) }, + saved = computers, + onSelectSaved = { model.client.switchTo(it) }, + ) + return + } + if (screenKey == "add-computer") { + BackHandler { screenKey = "settings" } + ConnectScreen( + expired = false, + onLink = { + model.client.link(it) + screenKey = "agents" + }, + onCancel = { screenKey = "settings" }, + ) + return + } + // Predictive back: the screen shrinks and slides with the swipe, and only leaves on release. + var backProgress by remember { mutableFloatStateOf(0f) } + if (screen != Screen.Agents) { + PredictiveBackHandler { events -> + try { + events.collect { backProgress = it.progress } + screenKey = "agents" + } finally { + backProgress = 0f + } + } + } + AnimatedContent( + targetState = screen, + modifier = Modifier.graphicsLayer { + val p = backProgress + scaleX = 1f - 0.08f * p + scaleY = 1f - 0.08f * p + translationX = p * 48.dp.toPx() + alpha = 1f - 0.25f * p + }, + transitionSpec = { + if (initialState == Screen.Agents) { + (slideInHorizontally(animationSpec = tween(280)) { width -> (width * 0.15f).toInt() } + fadeIn(tween(250))) + .togetherWith(slideOutHorizontally(animationSpec = tween(240)) { width -> -(width * 0.15f).toInt() } + fadeOut(tween(200))) + } else if (targetState == Screen.Agents) { + (slideInHorizontally(animationSpec = tween(280)) { width -> -(width * 0.15f).toInt() } + fadeIn(tween(250))) + .togetherWith(slideOutHorizontally(animationSpec = tween(240)) { width -> (width * 0.15f).toInt() } + fadeOut(tween(200))) + } else { + fadeIn(tween(200)).togetherWith(fadeOut(tween(200))) + } + }, + // Swiping between tasks changes which agent is open without leaving the task pager. + contentKey = { if (it is Screen.Agent) "agent" else it }, + label = "screenTransition", + ) { currentScreen -> + when (currentScreen) { + Screen.Agents -> { + val showMinimizedTasks by model.showMinimizedTasks.collectAsState() + AgentsScreen( + client = model.client, + host = link.baseUrl, + state = state, + agents = agents, + showMinimizedTasks = showMinimizedTasks, + onOpen = { screenKey = "agent:${it.agentId}" }, + onPair = { screenKey = "pair" }, + onNewTask = { screenKey = "new-task" }, + onSettings = { screenKey = "settings" }, + ) + } + Screen.Settings -> { + val keepScreenOn by model.keepScreenOn.collectAsState() + val keepScreenOnOnlyActive by model.keepScreenOnOnlyWhenActive.collectAsState() + val themeMode by model.themeMode.collectAsState() + val showMinimizedTasks by model.showMinimizedTasks.collectAsState() + val latencyMs by model.client.latencyMs.collectAsState() + val alwaysFollowOutput by model.alwaysFollowOutput.collectAsState() + val notifications by model.notifications.collectAsState() + val quickReplies by model.quickReplies.collectAsState() + val permission = rememberLauncherForActivityResult(ActivityResultContracts.RequestPermission()) { granted -> + model.setNotifications(notifications.copy(enabled = granted)) + } + val context = LocalContext.current + SettingsScreen( + keepScreenOn = keepScreenOn, + onKeepScreenOnChange = model::setKeepScreenOn, + keepScreenOnOnlyActive = keepScreenOnOnlyActive, + onKeepScreenOnOnlyActiveChange = model::setKeepScreenOnOnlyWhenActive, + themeMode = themeMode, + onThemeModeChange = model::setThemeMode, + showMinimizedTasks = showMinimizedTasks, + onShowMinimizedTasksChange = model::setShowMinimizedTasks, + alwaysFollowOutput = alwaysFollowOutput, + onAlwaysFollowOutputChange = model::setAlwaysFollowOutput, + fitTerminalToPhone = model.fitTerminalToPhone.collectAsState().value, + onFitTerminalToPhoneChange = model::setFitTerminalToPhone, + quickReplies = quickReplies, + onQuickRepliesChange = model::setQuickReplies, + notifications = notifications, + onNotificationsChange = { prefs -> + val needsPermission = prefs.enabled && !notifications.enabled && + Build.VERSION.SDK_INT >= 33 && + ContextCompat.checkSelfPermission(context, Manifest.permission.POST_NOTIFICATIONS) != + PackageManager.PERMISSION_GRANTED + if (needsPermission) permission.launch(Manifest.permission.POST_NOTIFICATIONS) + else model.setNotifications(prefs) + }, + latencyMs = latencyMs, + state = state, + computers = computers, + onSwitchComputer = { + model.client.switchTo(it) + screenKey = "agents" + }, + onForgetComputer = { model.client.forget(it) }, + onAddComputer = { screenKey = "add-computer" }, + onPair = { screenKey = "pair" }, + onForget = { + model.client.forget() + AgentWatchService.sync(context) + screenKey = "agents" + }, + onBack = { screenKey = "agents" }, + ) + } + Screen.Pair -> PairScreen( + pair = model.client::pair, + onDone = { screenKey = "agents" }, + ) + Screen.NewTask -> NewTaskScreen( + client = model.client, + onDone = { screenKey = "agents" }, + onNeedsPairing = { screenKey = "pair" }, + ) + is Screen.Agent -> { + val alwaysFollowOutput by model.alwaysFollowOutput.collectAsState() + val quickReplies by model.quickReplies.collectAsState() + val fitTerminalToPhone by model.fitTerminalToPhone.collectAsState() + // The tasks in the order the list shows them: swipe sideways to move between them. + val pages = remember(agents) { agents.filter { !it.collapsed } + agents.filter { it.collapsed } } + val openIndex = pages.indexOfFirst { it.agentId == currentScreen.agentId } + + @Composable + fun Task(agentId: String, active: Boolean, pageLabel: String?) { + val agent = agents.firstOrNull { it.agentId == agentId } + if (agent?.isChat == true) { + ChatScreen( + agent = agent, + agentId = agentId, + state = state, + client = model.client, + quickReplies = quickReplies, + pageLabel = pageLabel, + onBack = { screenKey = "agents" }, + onPair = { screenKey = "pair" }, + ) + } else AgentScreen( + agent = agent, + agentId = agentId, + state = state, + client = model.client, + alwaysFollowOutput = alwaysFollowOutput, + fitTerminalToPhone = fitTerminalToPhone && active, + quickReplies = quickReplies, + pageLabel = pageLabel, + onBack = { screenKey = "agents" }, + onPair = { screenKey = "pair" }, + ) + } + + if (openIndex < 0 || pages.size < 2) { + // A task that left the list (or the only one) has nothing to swipe to. + Task(currentScreen.agentId, active = true, pageLabel = null) + } else { + val pager = rememberPagerState(initialPage = openIndex) { pages.size } + LaunchedEffect(pager, pages) { + snapshotFlow { pager.settledPage }.collect { page -> + pages.getOrNull(page)?.let { screenKey = "agent:${it.agentId}" } + } + } + HorizontalPager( + state = pager, + key = { pages[it].agentId }, + pageSpacing = 8.dp, + ) { page -> + // Only the settled page may resize the desktop terminal; a neighbour + // composed mid-swipe just shows its output. + Task(pages[page].agentId, active = page == pager.settledPage, pageLabel = "${page + 1} of ${pages.size}") + } + } + } + } + } +} diff --git a/android/app/src/main/java/com/parallelcode/phone/PhoneApplication.kt b/android/app/src/main/java/com/parallelcode/phone/PhoneApplication.kt new file mode 100644 index 000000000..196739b78 --- /dev/null +++ b/android/app/src/main/java/com/parallelcode/phone/PhoneApplication.kt @@ -0,0 +1,36 @@ +package com.parallelcode.phone + +import android.app.Application +import android.content.Context +import kotlinx.coroutines.CoroutineScope +import kotlinx.coroutines.Dispatchers +import kotlinx.coroutines.SupervisorJob +import kotlinx.coroutines.flow.combine +import kotlinx.coroutines.launch + +/** + * Owns the connection so the visible app and the background notification service share one socket + * (see [RemoteClient.start]). + */ +class PhoneApplication : Application() { + lateinit var client: RemoteClient + private set + lateinit var settings: SettingsStore + private set + + /** True while the app is on screen; agent notifications stay quiet then. */ + @Volatile + var inForeground = false + + override fun onCreate() { + super.onCreate() + client = RemoteClient(CredentialStore(getSharedPreferences("desktop", Context.MODE_PRIVATE))) + settings = SettingsStore(getSharedPreferences(SettingsStore.PREFS_NAME, Context.MODE_PRIVATE)) + // Keep the home-screen widget current whenever the connection is open. + CoroutineScope(SupervisorJob() + Dispatchers.Main.immediate).launch { + combine(client.agents, client.usage, client.state) { agents, usage, state -> + widgetSummary(agents, usage, state.status == ConnectionStatus.CONNECTED) + }.collect { AgentWidget.publish(this@PhoneApplication, it) } + } + } +} diff --git a/android/app/src/main/java/com/parallelcode/phone/Protocol.kt b/android/app/src/main/java/com/parallelcode/phone/Protocol.kt new file mode 100644 index 000000000..d78ea1a74 --- /dev/null +++ b/android/app/src/main/java/com/parallelcode/phone/Protocol.kt @@ -0,0 +1,99 @@ +package com.parallelcode.phone + +import org.json.JSONObject + +// Mirrors the server messages in electron/remote/protocol.ts that this app uses. + +data class RemoteAgent( + val agentId: String, + val taskId: String, + val taskName: String, + val running: Boolean, + val exitCode: Int?, + val lastLine: String, + val projectName: String?, + val agentName: String?, + /** Renderer-derived status such as `needs_input`, `active` or `idle`. */ + val attention: String, + /** The desktop's built-in chat has no terminal to stream. */ + val isChat: Boolean, + val collapsed: Boolean = false, +) + +sealed interface ServerMessage { + data class Agents(val list: List) : ServerMessage + data class Output(val agentId: String, val data: ByteArray) : ServerMessage + data class Scrollback(val agentId: String, val data: ByteArray, val cols: Int, val rows: Int) : + ServerMessage + data class Status(val agentId: String, val running: Boolean, val exitCode: Int?) : ServerMessage + data class InputResult(val requestId: String, val ok: Boolean, val error: String?) : + ServerMessage + data class Chat(val agentId: String, val state: ChatState) : ServerMessage +} + +private fun JSONObject.optStringOrNull(key: String): String? = + if (has(key) && !isNull(key)) optString(key) else null + +private fun JSONObject.optIntOrNull(key: String): Int? = + if (has(key) && !isNull(key)) optInt(key) else null + +private fun decodeBase64(data: String): ByteArray = java.util.Base64.getDecoder().decode(data) + +/** Returns null for messages this app ignores and for malformed ones. */ +fun parseServerMessage(raw: String): ServerMessage? = try { + val msg = JSONObject(raw) + when (msg.getString("type")) { + "agents" -> { + val list = msg.getJSONArray("list") + ServerMessage.Agents(List(list.length()) { i -> parseAgent(list.getJSONObject(i)) }) + } + "output" -> ServerMessage.Output(msg.getString("agentId"), decodeBase64(msg.getString("data"))) + "scrollback" -> ServerMessage.Scrollback( + msg.getString("agentId"), + decodeBase64(msg.getString("data")), + msg.getInt("cols"), + msg.optInt("rows", 24), + ) + "status" -> ServerMessage.Status( + msg.getString("agentId"), + msg.getString("status") == "running", + msg.optIntOrNull("exitCode"), + ) + "chat-state" -> ServerMessage.Chat(msg.getString("agentId"), parseChatState(msg.getJSONObject("state"))) + "input-result" -> ServerMessage.InputResult( + msg.getString("requestId"), + msg.getBoolean("ok"), + msg.optStringOrNull("error"), + ) + else -> null + } +} catch (_: Exception) { + null +} + +private fun parseAgent(a: JSONObject) = RemoteAgent( + agentId = a.getString("agentId"), + taskId = a.getString("taskId"), + taskName = a.getString("taskName"), + running = a.getString("status") == "running", + exitCode = a.optIntOrNull("exitCode"), + lastLine = a.optString("lastLine"), + projectName = a.optStringOrNull("projectName"), + agentName = a.optStringOrNull("agentName"), + attention = a.optString("attention", "idle"), + isChat = a.optString("kind") == "chat", + collapsed = a.optBoolean("collapsed", false), +) + +/** + * Prepares a reply the way the phone web UI does (src/remote/terminalText.ts): control characters + * must not escape the pasted region or submit midway. + */ +fun messageForTerminal(text: String, bracketedPaste: Boolean): String { + val clean = text + .replace(Regex("\r\n?"), "\n") + .replace(Regex("[\\x00-\\x08\\x0b-\\x1f\\x7f]"), "") + .trim() + if (clean.isEmpty()) return "" + return if (bracketedPaste) "\u001b[200~$clean\u001b[201~" else clean.replace('\n', ' ') +} diff --git a/android/app/src/main/java/com/parallelcode/phone/RemoteClient.kt b/android/app/src/main/java/com/parallelcode/phone/RemoteClient.kt new file mode 100644 index 000000000..d01a3abbe --- /dev/null +++ b/android/app/src/main/java/com/parallelcode/phone/RemoteClient.kt @@ -0,0 +1,611 @@ +package com.parallelcode.phone + +import kotlinx.coroutines.CompletableDeferred +import kotlinx.coroutines.CoroutineScope +import kotlinx.coroutines.Dispatchers +import kotlinx.coroutines.Job +import kotlinx.coroutines.SupervisorJob +import kotlinx.coroutines.TimeoutCancellationException +import kotlinx.coroutines.cancel +import kotlinx.coroutines.delay +import kotlinx.coroutines.flow.MutableStateFlow +import kotlinx.coroutines.flow.StateFlow +import kotlinx.coroutines.flow.asStateFlow +import kotlinx.coroutines.flow.update +import kotlinx.coroutines.launch +import kotlinx.coroutines.withContext +import kotlinx.coroutines.withTimeout +import okhttp3.MediaType.Companion.toMediaType +import okhttp3.OkHttpClient +import okhttp3.Request +import okhttp3.RequestBody.Companion.toRequestBody +import okhttp3.Response +import okhttp3.WebSocket +import okhttp3.WebSocketListener +import org.json.JSONArray +import org.json.JSONException +import org.json.JSONObject +import java.io.IOException +import java.net.URLEncoder +import java.util.concurrent.TimeUnit + +enum class ConnectionStatus { CONNECTING, CONNECTED, DISCONNECTED } + +/** What the UI needs to know about the link to the desktop. */ +data class ConnectionState( + /** The linked desktop; null until a QR code is scanned. */ + val link: ConnectionLink? = null, + val status: ConnectionStatus = ConnectionStatus.DISCONNECTED, + /** True once the socket authenticated with the paired token, which may type. */ + val canControl: Boolean = false, + /** Set when the desktop rejected the QR-code token; only a fresh scan recovers. */ + val linkExpired: Boolean = false, +) + +interface TerminalListener { + fun onScrollback(data: ByteArray, cols: Int, rows: Int) + 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). */ +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?) + +/** + * Client for the desktop's Remote Access server (electron/remote/server.ts). Mirrors the phone web + * UI in src/remote/ws.ts: authenticate with the first WebSocket message, prefer the paired token, + * and fall back to view-only when the desktop revokes it. All state changes on the main thread. + */ +class RemoteClient(private val credentials: CredentialStore) { + private val scope = CoroutineScope(SupervisorJob() + Dispatchers.Main.immediate) + private val http = OkHttpClient.Builder() + .connectTimeout(10, TimeUnit.SECONDS) + .pingInterval(30, TimeUnit.SECONDS) + .build() + + private val _state = MutableStateFlow(ConnectionState(link = credentials.link)) + val state: StateFlow = _state.asStateFlow() + private val _agents = MutableStateFlow>(emptyList()) + val agents: StateFlow> = _agents.asStateFlow() + private val _computers = MutableStateFlow(credentials.computers) + + /** Every desktop this phone has linked to; [ConnectionState.link] is the one in use. */ + val computers: StateFlow> = _computers.asStateFlow() + private val _latencyMs = MutableStateFlow(null) + val latencyMs: StateFlow = _latencyMs.asStateFlow() + + private var socket: WebSocket? = null + // OkHttp queues sends before the socket opens, which would put them ahead of the auth message. + private var socketOpen = false + private var authKind = TokenKind.MOBILE + private var started = false + private var reconnectJob: Job? = null + private var handshakeJob: Job? = null + private val terminalBuffers = mutableMapOf() + private val subscribedAgents = mutableSetOf() + private val chats = mutableMapOf>() + private val chatWatchers = mutableMapOf() + /** The agent whose PTY takes this phone's terminal size, and that size (see [setViewSize]). */ + private var viewSize: Triple? = null + private var viewSizeSent = false + private val terminalListeners = mutableMapOf>() + private val pending = mutableMapOf>() + private var nextRequestId = 0 + + private enum class TokenKind { MOBILE, PAIRED } + + /** Keep a socket open while the app is in the foreground. */ + private val holders = mutableSetOf() + + /** + * Keep the connection open for [holder] (the visible app, the background notification service); + * it closes once no holder remains. + */ + fun start(holder: String) { + holders.add(holder) + started = true + connect() + } + + fun stop(holder: String) { + holders.remove(holder) + if (holders.isNotEmpty()) return + started = false + closeSocket() + } + + fun link(link: ConnectionLink) { + credentials.saveLink(link) + useActiveComputer() + } + + /** Switch to another saved computer. */ + fun switchTo(baseUrl: String) { + if (baseUrl == credentials.link?.baseUrl) return + credentials.select(baseUrl) + useActiveComputer() + } + + /** Forget a saved computer; forgetting the one in use leaves the phone unlinked. */ + fun forget(baseUrl: String? = credentials.link?.baseUrl) { + if (baseUrl == null) return + val inUse = baseUrl == credentials.link?.baseUrl + credentials.remove(baseUrl) + _computers.value = credentials.computers + if (!inUse) return + closeSocket() + resetSession() + _state.value = ConnectionState() + } + + /** Drop everything from the previous computer and connect to the one now selected. */ + private fun useActiveComputer() { + closeSocket() + resetSession() + _computers.value = credentials.computers + _state.value = ConnectionState(link = credentials.link) + reconnect() + } + + private fun resetSession() { + _agents.value = emptyList() + _latencyMs.value = null + _usage.value = emptyList() + terminalBuffers.clear() + subscribedAgents.clear() + terminalListeners.clear() + chats.values.forEach { it.value = null } + viewSize = null + viewSizeSent = false + } + + fun reconnect() { + closeSocket() + if (started) connect() + } + + /** Trade the desktop's six-digit PIN for a paired token, then reconnect with it. */ + suspend fun pair(pin: String, remember: Boolean) { + val reply = api( + "POST", + "/api/pair/verify", + JSONObject().put("pin", pin).put("remember", remember), + token = credentials.pairedToken ?: credentials.link?.token, + ) + val token = reply.optString("token").takeIf { it.isNotEmpty() } + ?: throw ApiException("Your computer sent an unexpected reply.") + credentials.savePairedToken(token) + _computers.value = credentials.computers + reconnect() + } + + /** Projects a paired phone may start tasks in. */ + suspend fun fetchProjects(): List { + val list = JSONArray(apiRaw("GET", "/api/mobile/projects", null, pairedTokenOrThrow())) + return List(list.length()) { i -> + val p = list.getJSONObject(i) + MobileProject(p.getString("id"), p.getString("name"), p.optString("agentName").ifEmpty { null }) + } + } + + /** Start a top-level task on the desktop; returns its task id. */ + suspend fun createTask(projectId: String, name: String, prompt: String): String { + val body = JSONObject().put("projectId", projectId).put("name", name).put("prompt", prompt) + return api("POST", "/api/mobile/tasks", body, pairedTokenOrThrow()).getString("taskId") + } + + /** The task's notes panel; readable with the view-only token. */ + suspend fun fetchNotes(taskId: String): String = + api("GET", notesPath(taskId), null, credentials.pairedToken ?: credentials.link?.token) + .optString("notes") + + suspend fun saveNotes(taskId: String, notes: String) { + api("PUT", notesPath(taskId), JSONObject().put("notes", notes), pairedTokenOrThrow()) + } + + /** The task's changes against its base branch; readable with the view-only token. */ + suspend fun fetchDiff(taskId: String): Pair { + val json = api("GET", "/api/mobile/tasks/${encodePath(taskId)}/diff", null, credentials.pairedToken ?: credentials.link?.token) + return json.optString("diff") to json.optBoolean("truncated") + } + + /** The desktop status bar's subscription usage; readable with the view-only token. */ + suspend fun fetchUsage(): List = + parseUsage(api("GET", "/api/mobile/usage", null, credentials.pairedToken ?: credentials.link?.token)) + .also { _usage.value = it } + + private val _usage = MutableStateFlow>(emptyList()) + + /** The last usage snapshot fetched, for the widget. */ + val usage: StateFlow> = _usage.asStateFlow() + + /** + * Close a task on the desktop: stops its agents and removes its worktree. Unless [force], the + * 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()) + emptyList() + } catch (e: ApiException) { + val warnings = e.json?.optJSONArray("warnings") + if (e.status != 409 || warnings == null || warnings.length() == 0) throw e + List(warnings.length()) { warnings.optString(it) } + } + + private fun notesPath(taskId: String) = "/api/mobile/notes/" + encodePath(taskId) + + private fun encodePath(segment: String) = URLEncoder.encode(segment, "UTF-8").replace("+", "%20") + + 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 = + try { + JSONObject(apiRaw(method, path, body, token)) + } catch (e: JSONException) { + throw ApiException("Your computer sent an unexpected reply.") + } + + private suspend fun apiRaw(method: String, path: String, body: JSONObject?, token: String?): String { + val link = credentials.link ?: throw ApiException("Not connected to a computer.") + if (token == null) throw ApiException("Not connected to a computer.") + val request = Request.Builder() + .url(link.baseUrl + path) + .header("Authorization", "Bearer $token") + .method(method, body?.toString()?.toRequestBody("application/json".toMediaType())) + .build() + val start = System.currentTimeMillis() + val (code, text) = withContext(Dispatchers.IO) { + try { + http.newCall(request).execute().use { it.code to it.body.string() } + } catch (e: IOException) { + _latencyMs.value = null + throw ApiException("Could not reach your computer. Check you're on the same network.") + } + } + val elapsed = System.currentTimeMillis() - start + _latencyMs.value = elapsed + if (code in 200..299) return text + val json = runCatching { JSONObject(text) }.getOrNull() + val error = json?.optString("error")?.takeIf { it.isNotEmpty() } + // 401 means the desktop no longer knows this token, so drop to view-only like a 4001 close. + // 403 only means this route is not open to the token (or to an older desktop), so the + // pairing stays. + if (code == 401 && token == credentials.pairedToken && path != "/api/pair/verify") { + credentials.clearPairedToken() + _computers.value = credentials.computers + reconnect() + throw ApiException("This phone is no longer paired. Pair again to continue.", code) + } + throw ApiException(error ?: "Request failed ($code).", code, json) + } + + /** + * Stream an agent's terminal while it is on screen. Only viewed terminals stream: opening one + * starts from the desktop's rendered snapshot, so nothing needs buffering in the background. + * Pair with [releaseTerminal]. + */ + fun getTerminalBuffer(agentId: String): TerminalBuffer { + val buffer = terminalBuffers.getOrPut(agentId) { TerminalBuffer(agentId) } + subscribeAgent(agentId) + return buffer + } + + /** Stop streaming a terminal that left the screen. */ + fun releaseTerminal(agentId: String) { + terminalBuffers.remove(agentId) + if (subscribedAgents.remove(agentId) && socketOpen) { + send(JSONObject().put("type", "unsubscribe").put("agentId", agentId)) + } + } + + private fun subscribeAgent(agentId: String) { + if (subscribedAgents.add(agentId) && socketOpen) { + send(JSONObject().put("type", "subscribe").put("agentId", agentId)) + } + } + + /** + * Size an agent's PTY to this phone's terminal view, so full-screen TUIs such as Claude Code fill + * the phone. Needs pairing. The desktop gets its size back on [releaseViewSize] or disconnect. + */ + fun setViewSize(agentId: String, cols: Int, rows: Int) { + viewSize?.let { if (it.first != agentId) releaseViewSize(it.first) } + if (viewSize == Triple(agentId, cols, rows)) return + viewSize = Triple(agentId, cols, rows) + viewSizeSent = false + sendViewSize() + } + + fun releaseViewSize(agentId: String) { + if (viewSize?.first != agentId) return + viewSize = null + if (!socketOpen) return + send(JSONObject().put("type", "view-size").put("agentId", agentId)) + // Resubscribe for a fresh snapshot, which carries the desktop's size again. + if (agentId in subscribedAgents) { + send(JSONObject().put("type", "unsubscribe").put("agentId", agentId)) + send(JSONObject().put("type", "subscribe").put("agentId", agentId)) + } + } + + /** + * The app left the screen but the socket stays open for notifications: give the desktop its + * size back until [resumeViewSize]. + */ + fun pauseViewSize() { + val agentId = viewSize?.first ?: return + if (socketOpen && viewSizeSent) send(JSONObject().put("type", "view-size").put("agentId", agentId)) + viewSizeSent = false + } + + fun resumeViewSize() = sendViewSize() + + /** Only paired sockets may size a PTY; the server closes a view-only one that tries. */ + private fun sendViewSize() { + val (agentId, cols, rows) = viewSize ?: return + if (viewSizeSent || !socketOpen || !_state.value.canControl) return + send(JSONObject().put("type", "view-size").put("agentId", agentId).put("cols", cols).put("rows", rows)) + viewSizeSent = true + terminalBuffers[agentId]?.resize(cols, rows) + } + + /** Stream an agent's terminal. Retained for backwards compatibility. */ + fun watchTerminal(agentId: String, listener: TerminalListener): () -> Unit { + getTerminalBuffer(agentId) + val listeners = terminalListeners.getOrPut(agentId) { mutableSetOf() } + listeners.add(listener) + return { + listeners.remove(listener) + if (listeners.isEmpty()) { + terminalListeners.remove(agentId) + } + } + } + + /** Type into an agent's terminal; `submit` presses Enter once the text has landed. */ + suspend fun sendInput(agentId: String, data: String, submit: Boolean) { + if (data.length > MAX_INPUT_LENGTH) { + throw IOException("This message is too long. Shorten it and try again.") + } + request( + JSONObject() + .put("type", "input") + .put("agentId", agentId) + .put("data", data) + .put("submit", submit), + ) + } + + /** + * Follow a built-in chat's conversation, as the phone web UI does. Call [unwatchChat] when done; + * the state stays null until the desktop sends the first frame. + */ + fun watchChat(agentId: String): StateFlow { + val flow = chats.getOrPut(agentId) { MutableStateFlow(null) } + val watchers = chatWatchers[agentId] ?: 0 + chatWatchers[agentId] = watchers + 1 + if (watchers == 0 && socketOpen) send(JSONObject().put("type", "chat-subscribe").put("agentId", agentId)) + return flow.asStateFlow() + } + + fun unwatchChat(agentId: String) { + val watchers = (chatWatchers[agentId] ?: return) - 1 + if (watchers > 0) { + chatWatchers[agentId] = watchers + return + } + chatWatchers.remove(agentId) + chats.remove(agentId) + if (socketOpen) send(JSONObject().put("type", "chat-unsubscribe").put("agentId", agentId)) + } + + /** One of the desktop's chat actions (send, interrupt, respond, …); needs pairing. */ + suspend fun sendChatAction(agentId: String, action: String, params: JSONObject = JSONObject()) { + if (params.toString().toByteArray().size > MAX_CHAT_MESSAGE_LENGTH) { + throw IOException("This message is too long to send from a phone.") + } + request( + JSONObject() + .put("type", "chat-action") + .put("agentId", agentId) + .put("action", action) + .put("params", params), + ) + } + + /** Send a message the server confirms with an input-result for its requestId. */ + private suspend fun request(msg: JSONObject) { + val ws = socket + if (!_state.value.canControl || ws == null) { + throw IOException("Reconnect before sending. Your draft has been kept.") + } + val requestId = (++nextRequestId).toString() + val result = CompletableDeferred() + pending[requestId] = result + msg.put("requestId", requestId) + if (!ws.send(msg.toString())) { + pending.remove(requestId) + throw IOException("Could not send. Your draft has been kept.") + } + try { + withTimeout(REQUEST_TIMEOUT_MS) { result.await() } + } catch (e: TimeoutCancellationException) { + throw IOException( + "Delivery could not be confirmed. Check the output before retrying; your draft has been kept.", + ) + } finally { + pending.remove(requestId) + } + } + + private fun connect() { + if (socket != null) return + val link = credentials.link ?: return + val paired = credentials.pairedToken + authKind = if (paired != null) TokenKind.PAIRED else TokenKind.MOBILE + val token = paired ?: link.token + + reconnectJob?.cancel() + _state.update { it.copy(status = ConnectionStatus.CONNECTING, canControl = false, linkExpired = false) } + val request = Request.Builder().url(link.webSocketUrl).build() + lateinit var ws: WebSocket + ws = http.newWebSocket(request, object : WebSocketListener() { + override fun onOpen(webSocket: WebSocket, response: Response) { + // Authenticate in the first message, not the URL, so the token stays out of logs. + webSocket.send(JSONObject().put("type", "auth").put("token", token).toString()) + scope.launch { + if (socket !== ws) return@launch + socketOpen = true + viewSizeSent = false + subscribedAgents.forEach { + send(JSONObject().put("type", "subscribe").put("agentId", it)) + } + chatWatchers.keys.forEach { + send(JSONObject().put("type", "chat-subscribe").put("agentId", it)) + } + } + } + + override fun onMessage(webSocket: WebSocket, text: String) { + val msg = parseServerMessage(text) ?: return + scope.launch { if (socket === ws) handle(msg) } + } + + override fun onClosing(webSocket: WebSocket, code: Int, reason: String) { + webSocket.close(code, null) + scope.launch { if (socket === ws) onDisconnect(code) } + } + + override fun onFailure(webSocket: WebSocket, t: Throwable, response: Response?) { + scope.launch { if (socket === ws) onDisconnect(CLOSE_ABNORMAL) } + } + }) + socket = ws + // A sleeping phone or unreachable computer may never finish the handshake. + handshakeJob = scope.launch { + delay(HANDSHAKE_TIMEOUT_MS) + if (socket === ws && _state.value.status != ConnectionStatus.CONNECTED) { + onDisconnect(CLOSE_ABNORMAL) + } + } + } + + private fun handle(msg: ServerMessage) { + when (msg) { + is ServerMessage.Agents -> { + handshakeJob?.cancel() + _state.update { + it.copy(status = ConnectionStatus.CONNECTED, canControl = authKind == TokenKind.PAIRED) + } + _agents.value = msg.list + sendViewSize() + + // Clean up deleted agents that no longer exist on the desktop + val activeIds = msg.list.map { it.agentId }.toSet() + val deleted = subscribedAgents.filter { it !in activeIds } + deleted.forEach { id -> + subscribedAgents.remove(id) + terminalBuffers.remove(id) + terminalListeners.remove(id) + if (socketOpen) { + send(JSONObject().put("type", "unsubscribe").put("agentId", id)) + } + } + } + is ServerMessage.Status -> { + _agents.update { list -> + list.map { if (it.agentId == msg.agentId) it.copy(running = msg.running, exitCode = msg.exitCode) else it } + } + if (msg.running) { + getTerminalBuffer(msg.agentId) + } + } + is ServerMessage.Scrollback -> { + val buffer = terminalBuffers.getOrPut(msg.agentId) { TerminalBuffer(msg.agentId) } + buffer.onScrollback(msg.data, msg.cols, msg.rows) + // A snapshot sent before the server applied this phone's size carries the desktop's. + viewSize?.let { (agentId, cols, rows) -> if (agentId == msg.agentId && viewSizeSent) buffer.resize(cols, rows) } + terminalListeners[msg.agentId]?.toList()?.forEach { it.onScrollback(msg.data, msg.cols, msg.rows) } + } + is ServerMessage.Output -> { + terminalBuffers[msg.agentId]?.onOutput(msg.data) + terminalListeners[msg.agentId]?.toList()?.forEach { it.onOutput(msg.data) } + } + is ServerMessage.Chat -> chats[msg.agentId]?.value = msg.state + is ServerMessage.InputResult -> { + val result = pending.remove(msg.requestId) ?: return + if (msg.ok) { + result.complete(Unit) + } else { + result.completeExceptionally(IOException(msg.error ?: "Could not send. Your draft has been kept.")) + } + } + } + } + + private fun onDisconnect(code: Int) { + closeSocket() + // 4001: the desktop rejected the token. A stale paired token falls back to view-only; a + // stale QR-code token needs a fresh scan. 4003: this phone lost its typing rights. + when { + code == CLOSE_UNAUTHORIZED && authKind == TokenKind.MOBILE -> { + credentials.clear() + _computers.value = credentials.computers + _agents.value = emptyList() + _state.value = ConnectionState(linkExpired = true) + return + } + code == CLOSE_UNAUTHORIZED || code == CLOSE_FORBIDDEN -> { + credentials.clearPairedToken() + _computers.value = credentials.computers + if (code == CLOSE_UNAUTHORIZED) { + if (started) connect() + return + } + } + } + if (!started) return + reconnectJob = scope.launch { + delay(RECONNECT_DELAY_MS) + connect() + } + } + + private fun closeSocket() { + handshakeJob?.cancel() + reconnectJob?.cancel() + socketOpen = false + socket?.let { + socket = null + it.close(CLOSE_NORMAL, null) + } + _state.update { it.copy(status = ConnectionStatus.DISCONNECTED, canControl = false) } + _latencyMs.value = null + val interrupted = IOException( + "Connection interrupted. Your message may have reached the terminal. Check the output before retrying.", + ) + pending.values.forEach { it.completeExceptionally(interrupted) } + pending.clear() + } + + private fun send(msg: JSONObject) { + if (socketOpen) socket?.send(msg.toString()) + } + + private companion object { + const val CLOSE_NORMAL = 1000 + const val CLOSE_ABNORMAL = 1006 + const val CLOSE_UNAUTHORIZED = 4001 + const val CLOSE_FORBIDDEN = 4003 + const val HANDSHAKE_TIMEOUT_MS = 10_000L + const val RECONNECT_DELAY_MS = 3_000L + const val REQUEST_TIMEOUT_MS = 10_000L + + // The server drops a socket message past 64 KiB; leave room for the envelope. + const val MAX_CHAT_MESSAGE_LENGTH = 60_000 + const val MAX_INPUT_LENGTH = 4096 + } +} diff --git a/android/app/src/main/java/com/parallelcode/phone/Screens.kt b/android/app/src/main/java/com/parallelcode/phone/Screens.kt new file mode 100644 index 000000000..92d82bf70 --- /dev/null +++ b/android/app/src/main/java/com/parallelcode/phone/Screens.kt @@ -0,0 +1,1485 @@ +package com.parallelcode.phone + +import androidx.compose.animation.AnimatedContent +import androidx.compose.animation.AnimatedVisibility +import androidx.compose.animation.core.FastOutSlowInEasing +import androidx.compose.animation.core.RepeatMode +import androidx.compose.animation.core.Spring +import androidx.compose.animation.core.animateFloat +import androidx.compose.animation.core.animateFloatAsState +import androidx.compose.animation.core.infiniteRepeatable +import androidx.compose.animation.core.rememberInfiniteTransition +import androidx.compose.animation.core.spring +import androidx.compose.animation.core.tween +import androidx.compose.animation.expandVertically +import androidx.compose.animation.fadeIn +import androidx.compose.animation.fadeOut +import androidx.compose.animation.shrinkVertically +import androidx.compose.animation.slideInHorizontally +import androidx.compose.animation.slideInVertically +import androidx.compose.animation.slideOutHorizontally +import androidx.compose.animation.slideOutVertically +import androidx.compose.animation.togetherWith +import androidx.compose.foundation.ScrollState +import androidx.compose.foundation.interaction.MutableInteractionSource +import androidx.compose.foundation.interaction.collectIsPressedAsState +import androidx.compose.ui.graphics.graphicsLayer +import androidx.compose.foundation.BorderStroke +import androidx.compose.foundation.background +import androidx.compose.foundation.border +import androidx.compose.foundation.clickable +import androidx.compose.foundation.horizontalScroll +import androidx.compose.foundation.layout.Arrangement +import androidx.compose.foundation.layout.Box +import androidx.compose.foundation.layout.BoxWithConstraints +import androidx.compose.foundation.layout.Column +import androidx.compose.foundation.layout.PaddingValues +import androidx.compose.foundation.layout.Row +import androidx.compose.foundation.layout.Spacer +import androidx.compose.foundation.layout.fillMaxHeight +import androidx.compose.foundation.layout.fillMaxSize +import androidx.compose.foundation.layout.fillMaxWidth +import androidx.compose.foundation.layout.height +import androidx.compose.foundation.layout.imePadding +import androidx.compose.foundation.layout.offset +import androidx.compose.foundation.layout.padding +import androidx.compose.foundation.layout.size +import androidx.compose.foundation.layout.width +import androidx.compose.foundation.lazy.LazyColumn +import androidx.compose.foundation.lazy.items +import androidx.compose.foundation.rememberScrollState +import androidx.compose.foundation.shape.CircleShape +import androidx.compose.foundation.shape.RoundedCornerShape +import androidx.compose.foundation.text.KeyboardOptions +import androidx.compose.foundation.verticalScroll +import androidx.compose.material3.AlertDialog +import androidx.compose.material3.Button +import androidx.compose.material3.ButtonDefaults +import androidx.compose.material3.Card +import androidx.compose.material3.CardDefaults +import androidx.compose.material3.CircularProgressIndicator +import androidx.compose.material3.DropdownMenu +import androidx.compose.material3.DropdownMenuItem +import androidx.compose.material3.ExperimentalMaterial3Api +import androidx.compose.material3.HorizontalDivider +import androidx.compose.material3.MaterialTheme +import androidx.compose.material3.OutlinedButton +import androidx.compose.material3.OutlinedTextField +import androidx.compose.material3.OutlinedTextFieldDefaults +import androidx.compose.material3.PrimaryTabRow +import androidx.compose.material3.Scaffold +import androidx.compose.material3.Surface +import androidx.compose.material3.Switch +import androidx.compose.material3.SwitchDefaults +import androidx.compose.material3.Tab +import androidx.compose.material3.Text +import androidx.compose.material3.TextButton +import androidx.compose.material3.pulltorefresh.PullToRefreshBox +import androidx.compose.material3.TopAppBar +import androidx.compose.material3.TopAppBarDefaults +import androidx.compose.ui.draw.clip +import androidx.compose.runtime.Composable +import androidx.compose.runtime.collectAsState +import androidx.compose.runtime.DisposableEffect +import androidx.compose.runtime.LaunchedEffect +import androidx.compose.runtime.derivedStateOf +import androidx.compose.runtime.getValue +import androidx.compose.runtime.mutableFloatStateOf +import androidx.compose.runtime.mutableIntStateOf +import androidx.compose.runtime.mutableStateOf +import androidx.compose.runtime.remember +import androidx.compose.runtime.rememberCoroutineScope +import androidx.compose.runtime.saveable.rememberSaveable +import androidx.compose.runtime.setValue +import androidx.compose.runtime.withFrameNanos +import androidx.compose.ui.Alignment +import androidx.compose.ui.Modifier +import androidx.compose.ui.graphics.Color +import androidx.compose.ui.hapticfeedback.HapticFeedbackType +import androidx.compose.ui.platform.LocalContext +import androidx.compose.ui.platform.LocalHapticFeedback +import kotlinx.coroutines.delay +import androidx.compose.ui.text.SpanStyle +import androidx.compose.ui.text.buildAnnotatedString +import androidx.compose.ui.text.font.FontStyle +import androidx.compose.ui.text.style.TextDecoration +import androidx.compose.ui.text.withStyle +import androidx.compose.ui.text.font.FontFamily +import androidx.compose.ui.text.font.FontWeight +import androidx.compose.ui.text.input.KeyboardType +import androidx.compose.ui.text.style.TextOverflow +import androidx.compose.ui.unit.dp +import androidx.compose.ui.unit.sp +import androidx.compose.animation.core.Animatable +import androidx.compose.animation.animateColorAsState +import androidx.compose.foundation.layout.requiredWidth +import androidx.compose.foundation.lazy.rememberLazyListState +import androidx.compose.foundation.lazy.LazyListState +import androidx.compose.foundation.layout.heightIn +import androidx.compose.ui.text.rememberTextMeasurer +import androidx.compose.ui.text.TextStyle +import androidx.compose.ui.platform.LocalDensity +import com.google.mlkit.vision.barcode.common.Barcode +import com.google.mlkit.vision.codescanner.GmsBarcodeScannerOptions +import com.google.mlkit.vision.codescanner.GmsBarcodeScanning +import kotlinx.coroutines.launch +import java.io.IOException + +private const val NOT_A_LINK = "That isn't a Parallel Code link. Open Connect Phone on your computer and try again." + +@Composable +fun ConnectScreen( + expired: Boolean, + onLink: (ConnectionLink) -> Unit, + saved: List = emptyList(), + onSelectSaved: (String) -> Unit = {}, + onCancel: (() -> Unit)? = null, +) { + val context = LocalContext.current + var pasted by rememberSaveable { mutableStateOf("") } + var error by remember { mutableStateOf(null) } + + fun accept(raw: String) { + val link = ConnectionLink.parse(raw) + if (link == null) error = NOT_A_LINK else onLink(link) + } + + SetupPage(title = "Connect to your computer") { + if (expired) { + Surface( + shape = MaterialTheme.shapes.large, + color = MaterialTheme.colorScheme.errorContainer, + border = BorderStroke(1.dp, MaterialTheme.colorScheme.error), + modifier = Modifier.fillMaxWidth(), + ) { + Text( + "This link no longer works. Your computer restarted Remote Access or disconnected this phone.", + color = MaterialTheme.colorScheme.onErrorContainer, + modifier = Modifier.padding(12.dp), + style = MaterialTheme.typography.bodyMedium, + ) + } + } + Text( + "On your computer, open Connect Phone and scan the QR code.", + color = AppTheme.extra.textMuted, + style = MaterialTheme.typography.bodyMedium, + ) + Button( + modifier = Modifier.fillMaxWidth(), + shape = MaterialTheme.shapes.large, + colors = ButtonDefaults.buttonColors( + containerColor = MaterialTheme.colorScheme.primary, + contentColor = MaterialTheme.colorScheme.onPrimary, + ), + onClick = { + error = null + val options = GmsBarcodeScannerOptions.Builder() + .setBarcodeFormats(Barcode.FORMAT_QR_CODE) + .build() + GmsBarcodeScanning.getClient(context, options).startScan() + .addOnSuccessListener { accept(it.rawValue.orEmpty()) } + .addOnFailureListener { error = "The scanner isn't available. Paste the link instead." } + }, + ) { Text("Scan QR code", fontWeight = FontWeight.SemiBold) } + Text( + "Or paste the link shown under the QR code:", + style = MaterialTheme.typography.bodySmall, + color = AppTheme.extra.textMuted, + ) + OutlinedTextField( + value = pasted, + onValueChange = { pasted = it }, + modifier = Modifier.fillMaxWidth(), + singleLine = true, + placeholder = { Text("http://192.168.1.20:7777/?token=…") }, + keyboardOptions = KeyboardOptions(keyboardType = KeyboardType.Uri), + shape = MaterialTheme.shapes.large, + colors = OutlinedTextFieldDefaults.colors( + focusedContainerColor = AppTheme.extra.inputBg, + unfocusedContainerColor = AppTheme.extra.inputBg, + focusedBorderColor = MaterialTheme.colorScheme.primary, + unfocusedBorderColor = AppTheme.extra.border, + ), + ) + TextButton(onClick = { accept(pasted) }, enabled = pasted.isNotBlank()) { + Text("Connect", fontWeight = FontWeight.SemiBold) + } + error?.let { Text(it, color = MaterialTheme.colorScheme.error) } + if (saved.isNotEmpty()) { + Text( + "Or use a computer you've linked before:", + style = MaterialTheme.typography.bodySmall, + color = AppTheme.extra.textMuted, + ) + saved.forEach { computer -> + OutlinedButton(onClick = { onSelectSaved(computer.baseUrl) }, modifier = Modifier.fillMaxWidth()) { + Text(computer.label, fontFamily = FontFamily.Monospace) + } + } + } + onCancel?.let { TextButton(onClick = it) { Text("Cancel") } } + } +} + +@Composable +fun PairScreen(pair: suspend (pin: String, remember: Boolean) -> Unit, onDone: () -> Unit) { + val scope = rememberCoroutineScope() + var pin by rememberSaveable { mutableStateOf("") } + var remember by rememberSaveable { mutableStateOf(true) } + var busy by remember { mutableStateOf(false) } + var error by remember { mutableStateOf(null) } + + SetupPage(title = "Enable replies") { + Text( + "Enter the six-digit code from Connect Phone on your computer to send messages to agents.", + color = AppTheme.extra.textMuted, + style = MaterialTheme.typography.bodyMedium, + ) + OutlinedTextField( + value = pin, + onValueChange = { pin = it.filter(Char::isDigit).take(6) }, + modifier = Modifier.fillMaxWidth(), + label = { Text("Code from your computer") }, + singleLine = true, + enabled = !busy, + textStyle = MaterialTheme.typography.headlineSmall.copy(fontFamily = FontFamily.Monospace), + keyboardOptions = KeyboardOptions(keyboardType = KeyboardType.NumberPassword), + shape = MaterialTheme.shapes.large, + colors = OutlinedTextFieldDefaults.colors( + focusedContainerColor = AppTheme.extra.inputBg, + unfocusedContainerColor = AppTheme.extra.inputBg, + focusedBorderColor = MaterialTheme.colorScheme.primary, + unfocusedBorderColor = AppTheme.extra.border, + ), + ) + Row(verticalAlignment = Alignment.CenterVertically) { + Column(Modifier.weight(1f)) { + Text("Keep this phone authorized", fontWeight = FontWeight.Medium) + Text( + "Stay paired after your computer restarts. Use only on a phone you trust.", + style = MaterialTheme.typography.bodySmall, + color = AppTheme.extra.textMuted, + ) + } + Switch( + checked = remember, + onCheckedChange = { remember = it }, + enabled = !busy, + colors = SwitchDefaults.colors( + checkedThumbColor = MaterialTheme.colorScheme.onPrimary, + checkedTrackColor = MaterialTheme.colorScheme.primary, + ), + ) + } + error?.let { Text(it, color = MaterialTheme.colorScheme.error) } + Button( + modifier = Modifier.fillMaxWidth(), + enabled = pin.length == 6 && !busy, + shape = MaterialTheme.shapes.large, + colors = ButtonDefaults.buttonColors( + containerColor = MaterialTheme.colorScheme.primary, + contentColor = MaterialTheme.colorScheme.onPrimary, + ), + onClick = { + busy = true + error = null + scope.launch { + try { + pair(pin, remember) + onDone() + } catch (e: ApiException) { + error = e.message + } finally { + busy = false + } + } + }, + ) { Text(if (busy) "Authorizing…" else "Enable replies", fontWeight = FontWeight.SemiBold) } + TextButton(modifier = Modifier.fillMaxWidth(), onClick = onDone, enabled = !busy) { + Text("Continue viewing only") + } + } +} + +@Composable +private fun SetupPage(title: String, content: @Composable () -> Unit) { + Surface( + modifier = Modifier.fillMaxSize(), + color = MaterialTheme.colorScheme.background, + ) { + Column( + Modifier + .fillMaxSize() + .imePadding() + .verticalScroll(rememberScrollState()) + .padding(24.dp), + verticalArrangement = Arrangement.spacedBy(16.dp, Alignment.CenterVertically), + ) { + Text( + title, + style = MaterialTheme.typography.headlineMedium, + fontWeight = FontWeight.Bold, + color = MaterialTheme.colorScheme.onBackground, + ) + content() + } + } +} + +@OptIn(ExperimentalMaterial3Api::class) +@Composable +fun AgentsScreen( + client: RemoteClient, + host: String, + state: ConnectionState, + agents: List, + showMinimizedTasks: Boolean, + onOpen: (RemoteAgent) -> Unit, + onPair: () -> Unit, + onNewTask: () -> Unit, + onSettings: () -> Unit, +) { + var refreshing by remember { mutableStateOf(false) } + val scope = rememberCoroutineScope() + val activeAgents = remember(agents) { agents.filter { !it.collapsed } } + val minimizedAgents = remember(agents) { agents.filter { it.collapsed } } + + Scaffold( + containerColor = MaterialTheme.colorScheme.background, + topBar = { + Column { + TopAppBar( + colors = TopAppBarDefaults.topAppBarColors( + containerColor = MaterialTheme.colorScheme.surface, + titleContentColor = MaterialTheme.colorScheme.onSurface, + actionIconContentColor = MaterialTheme.colorScheme.primary, + ), + title = { + Column { + Text( + "Agents", + style = MaterialTheme.typography.titleLarge, + fontWeight = FontWeight.Bold, + ) + Row( + verticalAlignment = Alignment.CenterVertically, + horizontalArrangement = Arrangement.spacedBy(6.dp), + ) { + val statusDotColor = when (state.status) { + ConnectionStatus.CONNECTED -> AppTheme.extra.success + ConnectionStatus.CONNECTING -> AppTheme.extra.warningText + ConnectionStatus.DISCONNECTED -> MaterialTheme.colorScheme.error + } + val infiniteTransition = rememberInfiniteTransition(label = "connPulse") + val pulseAlpha by if (state.status == ConnectionStatus.CONNECTED) { + infiniteTransition.animateFloat( + initialValue = 0.45f, + targetValue = 1f, + animationSpec = infiniteRepeatable( + animation = tween(1200, easing = FastOutSlowInEasing), + repeatMode = RepeatMode.Reverse, + ), + label = "connDotAlpha", + ) + } else { + remember { mutableFloatStateOf(1f) } + } + Box( + Modifier + .size(7.dp) + .clip(CircleShape) + .background(statusDotColor.copy(alpha = pulseAlpha)), + ) + Text( + "${statusLabel(state)} · ${host.substringAfter("://")}", + style = MaterialTheme.typography.bodySmall, + color = AppTheme.extra.textMuted, + ) + } + } + }, + actions = { + if (state.canControl) { + Button( + onClick = onNewTask, + shape = MaterialTheme.shapes.small, + contentPadding = PaddingValues(horizontal = 12.dp, vertical = 6.dp), + colors = ButtonDefaults.buttonColors( + containerColor = MaterialTheme.colorScheme.primary, + contentColor = MaterialTheme.colorScheme.onPrimary, + ), + modifier = Modifier.padding(end = 4.dp), + ) { + Text("New task", fontWeight = FontWeight.SemiBold, style = MaterialTheme.typography.labelLarge) + } + } + TextButton(onClick = onSettings) { + Text("Settings", color = MaterialTheme.colorScheme.primary, fontWeight = FontWeight.Medium) + } + }, + ) + HorizontalDivider(thickness = 1.dp, color = AppTheme.extra.border) + } + }, + bottomBar = { + if (showMinimizedTasks && minimizedAgents.isNotEmpty()) { + MinimizedTasksBottomBar( + minimizedAgents = minimizedAgents, + onOpen = onOpen, + ) + } + }, + ) { padding -> + PullToRefreshBox( + isRefreshing = refreshing, + onRefresh = { + refreshing = true + scope.launch { + client.reconnect() + try { client.fetchUsage() } catch (_: Exception) {} + delay(600) + refreshing = false + } + }, + modifier = Modifier + .fillMaxSize() + .padding(padding), + ) { + LazyColumn( + Modifier.fillMaxSize(), + verticalArrangement = Arrangement.spacedBy(10.dp), + contentPadding = PaddingValues(vertical = 12.dp), + ) { + item { UsageStrip(client, connected = state.status == ConnectionStatus.CONNECTED) } + if (state.status == ConnectionStatus.CONNECTED && !state.canControl) { + item { PairBanner(onPair) } + } + if (state.status == ConnectionStatus.CONNECTED && activeAgents.isEmpty()) { + item { + Text( + if (showMinimizedTasks && minimizedAgents.isNotEmpty()) "No active agents running." else "No agents are running.", + Modifier.padding(24.dp), + color = AppTheme.extra.textMuted, + style = MaterialTheme.typography.bodyLarge, + ) + } + } + if (state.status != ConnectionStatus.CONNECTED && activeAgents.isEmpty() && (!showMinimizedTasks || minimizedAgents.isEmpty())) { + item { + Row( + Modifier.padding(24.dp), + verticalAlignment = Alignment.CenterVertically, + ) { + CircularProgressIndicator( + Modifier.size(20.dp), + color = MaterialTheme.colorScheme.primary, + strokeWidth = 2.dp, + ) + Spacer(Modifier.width(12.dp)) + Text( + "Reaching your computer…", + color = AppTheme.extra.textMuted, + ) + } + } + } + items(activeAgents, key = { it.agentId }) { agent -> + AgentCard(agent, onOpen, modifier = Modifier.animateItem()) + } + } + } + } +} + +@Composable +private fun MinimizedTasksBottomBar( + minimizedAgents: List, + onOpen: (RemoteAgent) -> Unit, + modifier: Modifier = Modifier, +) { + var isExpanded by rememberSaveable { mutableStateOf(true) } + Surface( + modifier = modifier.fillMaxWidth(), + color = MaterialTheme.colorScheme.surface, + border = BorderStroke(1.dp, AppTheme.extra.border), + ) { + Column( + modifier = Modifier + .fillMaxWidth() + .padding(bottom = 8.dp), + ) { + Row( + modifier = Modifier + .fillMaxWidth() + .clickable { isExpanded = !isExpanded } + .padding(horizontal = 16.dp, vertical = 10.dp), + verticalAlignment = Alignment.CenterVertically, + horizontalArrangement = Arrangement.SpaceBetween, + ) { + Row( + verticalAlignment = Alignment.CenterVertically, + horizontalArrangement = Arrangement.spacedBy(8.dp), + ) { + Box( + modifier = Modifier + .size(7.dp) + .clip(CircleShape) + .background(AppTheme.extra.textMuted), + ) + Text( + "Minimized (${minimizedAgents.size})", + style = MaterialTheme.typography.labelLarge, + fontWeight = FontWeight.SemiBold, + color = MaterialTheme.colorScheme.onSurface, + ) + } + Text( + if (isExpanded) "Hide" else "Show", + style = MaterialTheme.typography.labelMedium, + color = MaterialTheme.colorScheme.primary, + fontWeight = FontWeight.SemiBold, + ) + } + AnimatedVisibility( + visible = isExpanded, + enter = expandVertically(animationSpec = tween(220, easing = FastOutSlowInEasing)) + fadeIn(tween(180)), + exit = shrinkVertically(animationSpec = tween(200, easing = FastOutSlowInEasing)) + fadeOut(tween(150)), + ) { + Row( + modifier = Modifier + .fillMaxWidth() + .horizontalScroll(rememberScrollState()) + .padding(horizontal = 16.dp, vertical = 4.dp), + horizontalArrangement = Arrangement.spacedBy(10.dp), + ) { + minimizedAgents.forEach { agent -> + MinimizedTaskCard( + agent = agent, + onClick = { onOpen(agent) }, + single = minimizedAgents.size == 1, + ) + } + } + } + } + } +} + +@Composable +private fun MinimizedTaskCard( + agent: RemoteAgent, + onClick: () -> Unit, + single: Boolean, +) { + Card( + modifier = Modifier + .width(if (single) 260.dp else 220.dp) + .clickable(onClick = onClick), + shape = MaterialTheme.shapes.large, + colors = CardDefaults.cardColors(containerColor = AppTheme.extra.cardBg), + border = BorderStroke(1.dp, AppTheme.extra.borderSubtle), + ) { + Column( + modifier = Modifier.padding(horizontal = 12.dp, vertical = 9.dp), + verticalArrangement = Arrangement.spacedBy(4.dp), + ) { + Row( + verticalAlignment = Alignment.CenterVertically, + horizontalArrangement = Arrangement.spacedBy(6.dp), + ) { + Text( + text = agent.taskName, + style = MaterialTheme.typography.bodyMedium, + fontWeight = FontWeight.SemiBold, + color = AppTheme.extra.textPrimary, + maxLines = 1, + overflow = TextOverflow.Ellipsis, + modifier = Modifier.weight(1f), + ) + Text( + text = "Minimized", + style = MaterialTheme.typography.labelSmall.copy(fontSize = 10.sp), + color = AppTheme.extra.textMuted, + ) + } + listOfNotNull(agent.projectName, agent.agentName).takeIf { it.isNotEmpty() }?.let { + Text( + text = it.joinToString(" · "), + style = MaterialTheme.typography.bodySmall.copy(fontSize = 11.sp), + color = AppTheme.extra.textMuted, + maxLines = 1, + overflow = TextOverflow.Ellipsis, + ) + } + if (agent.lastLine.isNotBlank()) { + Text( + text = agent.lastLine, + style = MaterialTheme.typography.bodySmall.copy(fontSize = 11.sp, lineHeight = 14.sp), + fontFamily = FontFamily.Monospace, + color = AppTheme.extra.textMuted, + maxLines = 1, + overflow = TextOverflow.Ellipsis, + ) + } + } + } +} + +@Composable +internal fun PairBanner(onPair: () -> Unit) { + Card( + modifier = Modifier.padding(horizontal = 16.dp), + shape = MaterialTheme.shapes.large, + colors = CardDefaults.cardColors(containerColor = AppTheme.extra.warningBannerBg), + border = BorderStroke(1.dp, AppTheme.extra.attentionBorder), + ) { + Row( + Modifier.padding(horizontal = 16.dp, vertical = 12.dp), + verticalAlignment = Alignment.CenterVertically, + ) { + Text( + "View only. Pair to send replies.", + Modifier.weight(1f), + color = AppTheme.extra.warningText, + style = MaterialTheme.typography.bodyMedium, + fontWeight = FontWeight.Medium, + ) + Button( + onClick = onPair, + shape = MaterialTheme.shapes.small, + colors = ButtonDefaults.buttonColors( + containerColor = AppTheme.extra.attentionBorder, + contentColor = AppTheme.extra.warningText, + ), + ) { Text("Pair", fontWeight = FontWeight.Bold) } + } + } +} + +@Composable +private fun AgentCard( + agent: RemoteAgent, + onOpen: (RemoteAgent) -> Unit, + modifier: Modifier = Modifier, +) { + val isAttention = !agent.collapsed && (agent.attention == "needs_input" || agent.attention == "error") + val attentionTransition = rememberInfiniteTransition(label = "attentionPulse") + val attentionGlow by if (isAttention) { + attentionTransition.animateFloat( + initialValue = 0.45f, + targetValue = 1f, + animationSpec = infiniteRepeatable( + animation = tween(900, easing = FastOutSlowInEasing), + repeatMode = RepeatMode.Reverse, + ), + label = "attentionGlow", + ) + } else { + remember { mutableFloatStateOf(1f) } + } + + val cardBorderColor = if (isAttention) { + if (agent.attention == "error") MaterialTheme.colorScheme.error.copy(alpha = attentionGlow) + else AppTheme.extra.attentionBorder.copy(alpha = attentionGlow) + } else { + AppTheme.extra.border + } + val cardBg by animateColorAsState( + if (isAttention) AppTheme.extra.cardBgAttention else AppTheme.extra.cardBg, + animationSpec = tween(300), + label = "cardBg", + ) + // A card that just started needing you bumps once, so the change catches the eye. + val bump = remember { Animatable(1f) } + LaunchedEffect(isAttention) { + if (!isAttention) return@LaunchedEffect + bump.animateTo(1.03f, tween(140)) + bump.animateTo(1f, spring(dampingRatio = Spring.DampingRatioMediumBouncy)) + } + + val (statusColor, statusText) = when { + agent.collapsed -> Pair(AppTheme.extra.textMuted, "Minimized") + !agent.running -> Pair(AppTheme.extra.textMuted, agent.exitCode?.let { "Exited ($it)" } ?: "Exited") + agent.attention == "needs_input" -> Pair(AppTheme.extra.warningText, "Needs input") + agent.attention == "error" -> Pair(MaterialTheme.colorScheme.error, "Error") + agent.attention == "active" -> Pair(MaterialTheme.colorScheme.primary, "Working") + agent.attention == "shell_busy" -> Pair(MaterialTheme.colorScheme.primary, "Running command") + agent.attention == "ready" -> Pair(AppTheme.extra.success, "Ready") + agent.attention == "review" -> Pair(AppTheme.extra.review, "Review") + else -> Pair(AppTheme.extra.textMuted, "Idle") + } + + val isWorking = !agent.collapsed && agent.running && (agent.attention == "active" || agent.attention == "shell_busy") + val infiniteTransition = rememberInfiniteTransition(label = "agentWorkingPulse") + val dotAlpha by if (isWorking) { + infiniteTransition.animateFloat( + initialValue = 0.35f, + targetValue = 1f, + animationSpec = infiniteRepeatable( + animation = tween(800, easing = FastOutSlowInEasing), + repeatMode = RepeatMode.Reverse, + ), + label = "workingDotAlpha", + ) + } else { + remember { mutableFloatStateOf(1f) } + } + + Card( + modifier = modifier + .fillMaxWidth() + .padding(horizontal = 16.dp) + .graphicsLayer { + scaleX = bump.value + scaleY = bump.value + } + .clickable { onOpen(agent) }, + shape = MaterialTheme.shapes.large, + colors = CardDefaults.cardColors(containerColor = cardBg), + border = BorderStroke(1.dp, cardBorderColor), + ) { + Column( + Modifier.padding(14.dp), + verticalArrangement = Arrangement.spacedBy(8.dp), + ) { + Row( + verticalAlignment = Alignment.CenterVertically, + horizontalArrangement = Arrangement.spacedBy(8.dp), + ) { + Text( + agent.taskName, + Modifier.weight(1f), + fontWeight = FontWeight.SemiBold, + style = MaterialTheme.typography.titleMedium, + color = AppTheme.extra.textPrimary, + maxLines = 1, + overflow = TextOverflow.Ellipsis, + ) + Surface( + shape = MaterialTheme.shapes.large, + color = statusColor.copy(alpha = 0.12f), + border = BorderStroke(1.dp, statusColor.copy(alpha = 0.35f)), + ) { + Row( + Modifier.padding(horizontal = 8.dp, vertical = 3.dp), + verticalAlignment = Alignment.CenterVertically, + horizontalArrangement = Arrangement.spacedBy(5.dp), + ) { + Box( + Modifier + .size(6.dp) + .clip(CircleShape) + .background(statusColor.copy(alpha = dotAlpha)), + ) + Text( + statusText, + style = MaterialTheme.typography.labelSmall, + fontWeight = FontWeight.SemiBold, + color = statusColor, + ) + } + } + } + listOfNotNull(agent.projectName, agent.agentName).takeIf { it.isNotEmpty() }?.let { + Text( + it.joinToString(" · "), + style = MaterialTheme.typography.bodySmall, + color = AppTheme.extra.textMuted, + ) + } + val detail = agent.lastLine.ifBlank { if (agent.isChat) "Built-in chat" else "" } + if (detail.isNotBlank()) { + Box( + Modifier + .fillMaxWidth() + .clip(MaterialTheme.shapes.small) + .background(MaterialTheme.colorScheme.background) + .border(BorderStroke(1.dp, AppTheme.extra.borderSubtle), MaterialTheme.shapes.small) + .padding(horizontal = 10.dp, vertical = 7.dp), + ) { + Text( + detail, + style = MaterialTheme.typography.bodySmall.copy(fontSize = 12.sp, lineHeight = 16.sp), + fontFamily = FontFamily.Monospace, + color = AppTheme.extra.textMuted, + maxLines = 2, + overflow = TextOverflow.Ellipsis, + ) + } + } + } + } +} + +@OptIn(ExperimentalMaterial3Api::class) +@Composable +fun AgentScreen( + agent: RemoteAgent?, + agentId: String, + state: ConnectionState, + client: RemoteClient, + alwaysFollowOutput: Boolean, + fitTerminalToPhone: Boolean, + quickReplies: List, + pageLabel: String? = null, + onBack: () -> Unit, + onPair: () -> Unit, +) { + val buffer = remember(agentId) { client.getTerminalBuffer(agentId) } + val version by buffer.version.collectAsState() + // Recomposition is batched per frame, so a burst of output renders the text once. + val lines = remember(version) { buffer.screen.styledLines() } + var tab by rememberSaveable { mutableStateOf(AgentTab.TERMINAL) } + var closing by remember { mutableStateOf(false) } + var viewSize by remember { mutableStateOf?>(null) } + + // With "Fit the terminal to this phone" on and paired, the PTY takes this screen's size so + // full-screen TUIs fill the phone; leaving + // the screen hands it back to the desktop. Settle first: the keyboard animates the height. + val sizeTerminal = fitTerminalToPhone && state.canControl && agent != null && agent.running && agent.collapsed != true + LaunchedEffect(sizeTerminal, viewSize) { + val (cols, rows) = viewSize ?: return@LaunchedEffect + if (!sizeTerminal) { + client.releaseViewSize(agentId) + return@LaunchedEffect + } + delay(300) + client.setViewSize(agentId, cols, rows) + } + DisposableEffect(agentId) { + onDispose { + client.releaseTerminal(agentId) + client.releaseViewSize(agentId) + } + } + + if (closing && agent != null) { + CloseTaskDialog( + taskName = agent.taskName, + close = { force -> client.closeTask(agent.taskId, force) }, + onDismiss = { closing = false }, + onClosed = { + closing = false + onBack() + }, + ) + } + + Scaffold( + containerColor = MaterialTheme.colorScheme.background, + topBar = { + Column { + TopAppBar( + 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) + } + }, + title = { + Column { + Text( + agent?.taskName ?: "Agent", + maxLines = 1, + overflow = TextOverflow.Ellipsis, + style = MaterialTheme.typography.titleMedium, + fontWeight = FontWeight.Bold, + ) + Text( + (agent?.let(::agentStatusLabel) ?: statusLabel(state)) + (pageLabel?.let { " · $it" } ?: ""), + style = MaterialTheme.typography.bodySmall, + color = AppTheme.extra.textMuted, + ) + } + }, + actions = { + if (agent != null && state.canControl) { + TextButton(onClick = { closing = true }) { + Text("Close", color = MaterialTheme.colorScheme.error, fontWeight = FontWeight.SemiBold) + } + } + }, + ) + HorizontalDivider(thickness = 1.dp, color = AppTheme.extra.border) + } + }, + ) { padding -> + Column( + Modifier + .fillMaxSize() + .padding(padding) + .imePadding(), + ) { + PrimaryTabRow( + selectedTabIndex = tab.ordinal, + containerColor = MaterialTheme.colorScheme.surface, + contentColor = MaterialTheme.colorScheme.primary, + divider = { HorizontalDivider(thickness = 1.dp, color = AppTheme.extra.border) }, + ) { + AgentTab.entries.forEach { + Tab( + selected = tab == it, + onClick = { tab = it }, + text = { + Text( + it.label, + fontWeight = if (tab == it) FontWeight.Bold else FontWeight.Normal, + ) + }, + ) + } + } + AnimatedContent( + targetState = tab, + transitionSpec = { + if (targetState.ordinal > initialState.ordinal) { + (slideInHorizontally(animationSpec = tween(220)) { width -> width / 4 } + fadeIn(tween(180))) + .togetherWith(slideOutHorizontally(animationSpec = tween(200)) { width -> -width / 4 } + fadeOut(tween(160))) + } else { + (slideInHorizontally(animationSpec = tween(220)) { width -> -width / 4 } + fadeIn(tween(180))) + .togetherWith(slideOutHorizontally(animationSpec = tween(200)) { width -> width / 4 } + fadeOut(tween(160))) + } + }, + modifier = Modifier.weight(1f), + label = "agentTabTransition", + ) { currentTab -> + when (currentTab) { + AgentTab.TERMINAL -> if (agent?.collapsed == true) { + Box( + modifier = Modifier + .fillMaxSize() + .padding(24.dp), + contentAlignment = Alignment.Center, + ) { + Column( + horizontalAlignment = Alignment.CenterHorizontally, + verticalArrangement = Arrangement.spacedBy(8.dp), + ) { + Text( + "This task is minimized on your computer.", + style = MaterialTheme.typography.bodyLarge, + fontWeight = FontWeight.Medium, + color = MaterialTheme.colorScheme.onSurface, + ) + Text( + "Expand it on desktop to resume terminal interaction.", + style = MaterialTheme.typography.bodyMedium, + color = AppTheme.extra.textMuted, + ) + } + } + } else { + TerminalText( + lines, + buffer.screen.cols, + Modifier.fillMaxSize(), + alwaysFollow = alwaysFollowOutput, + onViewSize = { cols, rows -> viewSize = cols to rows }, + ) + } + AgentTab.NOTES -> if (agent != null) { + NotesPane(agent.taskId, state.canControl, client, Modifier.fillMaxSize()) + } else { + Text( + "This agent is no longer running.", + Modifier + .fillMaxSize() + .padding(16.dp), + color = AppTheme.extra.textMuted, + ) + } + AgentTab.CHANGES -> if (agent != null) { + DiffPane(agent.taskId, client, Modifier.fillMaxSize()) + } else { + Text( + "This agent is no longer running.", + Modifier + .fillMaxSize() + .padding(16.dp), + color = AppTheme.extra.textMuted, + ) + } + } + } + if (!state.canControl) { + Spacer(Modifier.height(8.dp)) + PairBanner(onPair) + Spacer(Modifier.height(8.dp)) + } else if (tab == AgentTab.TERMINAL && agent?.collapsed != true) { + ReplyBox( + quickReplies = quickReplies, + send = { draft -> + val data = messageForTerminal(draft, buffer.screen.bracketedPaste) + if (data.isNotEmpty()) client.sendInput(agentId, data, submit = true) + }, + sendKey = { client.sendInput(agentId, it, submit = false) }, + ) + } + } + } +} + +/** + * Confirms closing a task, as the desktop's Close Task dialog does. The first confirm asks the + * desktop to close only if no work would be lost; if it answers with warnings, they are shown and + * a second confirm forces the close. + */ +@Composable +internal fun CloseTaskDialog( + taskName: String, + close: suspend (force: Boolean) -> List, + onDismiss: () -> Unit, + onClosed: () -> Unit, +) { + val scope = rememberCoroutineScope() + var warnings by remember { mutableStateOf>(emptyList()) } + var busy by remember { mutableStateOf(false) } + var error by remember { mutableStateOf(null) } + + fun submit() { + val force = warnings.isNotEmpty() + busy = true + error = null + scope.launch { + try { + val refused = close(force) + if (refused.isEmpty()) onClosed() else warnings = refused + } catch (e: ApiException) { + error = e.message + } finally { + busy = false + } + } + } + + AlertDialog( + onDismissRequest = { if (!busy) onDismiss() }, + containerColor = MaterialTheme.colorScheme.surfaceVariant, + title = { Text("Close task?") }, + text = { + Column(verticalArrangement = Arrangement.spacedBy(10.dp)) { + Text( + "“$taskName” closes on your computer: its agents and shells stop, and its worktree, if any, is removed.", + color = AppTheme.extra.textMuted, + ) + warnings.forEach { + Text( + it, + Modifier + .fillMaxWidth() + .background(AppTheme.extra.warningBannerBg) + .padding(horizontal = 10.dp, vertical = 8.dp), + color = AppTheme.extra.warningText, + fontWeight = FontWeight.SemiBold, + ) + } + error?.let { Text(it, color = MaterialTheme.colorScheme.error) } + } + }, + confirmButton = { + TextButton(onClick = ::submit, enabled = !busy) { + if (busy) { + CircularProgressIndicator(Modifier.size(18.dp), strokeWidth = 2.dp) + } else { + Text( + if (warnings.isEmpty()) "Close" else "Close anyway", + color = MaterialTheme.colorScheme.error, + fontWeight = FontWeight.SemiBold, + ) + } + } + }, + dismissButton = { + TextButton(onClick = onDismiss, enabled = !busy) { Text("Cancel") } + }, + ) +} + +private enum class AgentTab(val label: String) { TERMINAL("Terminal"), CHANGES("Changes"), NOTES("Notes") } + +/** Keys agent TUIs ask for that a phone keyboard can't type, as in the phone web UI. */ +private val QUICK_KEYS = listOf( + "Enter" to "\r", + "Esc" to "\u001b", + "Tab" to "\t", + "↑" to "\u001b[A", + "↓" to "\u001b[B", + "/" to "/", + "Ctrl+C" to "\u0003", + "Ctrl+D" to "\u0004", + "Clear" to "\u000c", +) + +@Composable +private fun TerminalText( + lines: List>, + cols: Int, + modifier: Modifier, + alwaysFollow: Boolean, + onViewSize: (cols: Int, rows: Int) -> Unit, +) { + val palette = if (AppTheme.extra.dark) TerminalPalette.OBSIDIAN else TerminalPalette.OBSIDIAN_LIGHT + // A lazy list lays out only the lines on screen; history lines keep their style runs between + // frames, so a spinner repainting one row no longer rebuilds thousands of lines. + val list = rememberLazyListState() + val horizontal = rememberScrollState() + val scope = rememberCoroutineScope() + var follow by remember { mutableStateOf(true) } + val isNearBottom by remember { derivedStateOf { !list.canScrollForward } } + var hasNewOutputWhileScrolled by remember { mutableStateOf(false) } + + // Follow output only while parked at the bottom, decided where each scroll ends. Deciding at + // the start instead kept follow on for a scroll that began at the bottom (the usual case), so + // an agent that redraws constantly (Claude's spinner) snapped the view back on its next frame. + // With "Always scroll to latest output" on, a scroll only holds the view while the finger is down. + LaunchedEffect(list.isScrollInProgress, alwaysFollow) { + if (!list.isScrollInProgress) { + follow = alwaysFollow || isNearBottom + if (follow) hasNewOutputWhileScrolled = false + } + } + + LaunchedEffect(lines) { + if (follow && !list.isScrollInProgress) { + withFrameNanos {} + // A scroll may have started or ended during that frame. + if (follow && !list.isScrollInProgress && lines.isNotEmpty()) list.scrollToItem(lines.lastIndex) + } else if (!isNearBottom) { + hasNewOutputWhileScrolled = true + } + } + + BoxWithConstraints( + modifier = modifier + .fillMaxSize() + .background(Color(palette.background)), + ) { + // The PTY keeps the desktop's size, which rarely matches the phone. Size the font so its + // columns span the screen width (within readable bounds), and anchor a screen shorter + // than the view to the bottom, next to the reply box, as a terminal window would. + val density = LocalDensity.current + val measurer = rememberTextMeasurer() + val paddingPx = with(density) { (TERMINAL_PADDING_H * 2).roundToPx() } + val textWidthPx = constraints.maxWidth - paddingPx + val charPxAt10 = remember(measurer) { + measurer.measure("0".repeat(10), TextStyle(fontFamily = FontFamily.Monospace, fontSize = 10.sp)).size.width / 10f + } + val fontSize = (10f * textWidthPx / (cols.coerceAtLeast(1) * charPxAt10)).coerceIn(8f, 14f).sp + // Wide terminals at the smallest font scroll sideways; the lazy list needs a fixed width. + val contentWidth = with(density) { + maxOf(constraints.maxWidth, (cols * charPxAt10 * fontSize.value / 10f).toInt() + paddingPx).toDp() + } + + // The size this view fits at the default font, offered for the PTY. + val viewPaddingPx = with(density) { 16.dp.roundToPx() } + val viewHeightPx = constraints.maxHeight + LaunchedEffect(textWidthPx, viewHeightPx) { + val probe = measurer.measure( + "0".repeat(10), + TextStyle(fontFamily = FontFamily.Monospace, fontSize = TERMINAL_FONT, lineHeight = TERMINAL_FONT * 1.27f), + ) + val viewCols = (textWidthPx / (probe.size.width / 10f)).toInt() + val viewRows = ((viewHeightPx - viewPaddingPx) / probe.size.height.toFloat()).toInt() + if (viewCols >= 20 && viewRows >= 5) onViewSize(viewCols, viewRows) + } + + Box( + Modifier + .fillMaxSize() + .horizontalScroll(horizontal), + ) { + LazyColumn( + state = list, + modifier = Modifier + .requiredWidth(contentWidth) + .fillMaxHeight(), + contentPadding = PaddingValues(horizontal = TERMINAL_PADDING_H, vertical = 8.dp), + verticalArrangement = Arrangement.Bottom, + ) { + items(lines.size) { i -> + val line = lines[i] + val text = remember(line, palette) { terminalLine(line, palette) } + Text( + text = text, + color = Color(palette.foreground), + fontFamily = FontFamily.Monospace, + fontSize = fontSize, + lineHeight = fontSize * 1.27f, + softWrap = false, + maxLines = 1, + ) + } + } + } + + // Vertical scrollbar indicator along the right edge + TerminalVerticalScrollbar( + list = list, + modifier = Modifier + .align(Alignment.CenterEnd) + .fillMaxHeight() + .padding(end = 2.dp, top = 4.dp, bottom = 4.dp), + ) + + // Floating jump-to-bottom / new-output pill button + AnimatedVisibility( + visible = !isNearBottom, + enter = fadeIn(tween(180)) + slideInVertically(tween(200)) { it / 2 }, + exit = fadeOut(tween(150)) + slideOutVertically(tween(180)) { it / 2 }, + modifier = Modifier + .align(Alignment.BottomEnd) + .padding(end = 16.dp, bottom = 12.dp), + ) { + val jumpInteractionSource = remember { MutableInteractionSource() } + val isPressed by jumpInteractionSource.collectIsPressedAsState() + val scale by animateFloatAsState( + targetValue = if (isPressed) 0.94f else 1f, + animationSpec = spring(dampingRatio = Spring.DampingRatioMediumBouncy, stiffness = Spring.StiffnessLow), + label = "jumpToBottomScale", + ) + Surface( + onClick = { + follow = true + hasNewOutputWhileScrolled = false + scope.launch { if (lines.isNotEmpty()) list.animateScrollToItem(lines.lastIndex) } + }, + interactionSource = jumpInteractionSource, + shape = RoundedCornerShape(20.dp), + color = if (hasNewOutputWhileScrolled) MaterialTheme.colorScheme.primaryContainer else MaterialTheme.colorScheme.surfaceVariant, + border = BorderStroke(1.dp, if (hasNewOutputWhileScrolled) MaterialTheme.colorScheme.primary else AppTheme.extra.border), + shadowElevation = 6.dp, + modifier = Modifier.graphicsLayer { + scaleX = scale + scaleY = scale + }, + ) { + Row( + modifier = Modifier.padding(horizontal = 14.dp, vertical = 7.dp), + verticalAlignment = Alignment.CenterVertically, + horizontalArrangement = Arrangement.spacedBy(6.dp), + ) { + Text( + text = if (hasNewOutputWhileScrolled) "↓ New output" else "↓ Latest", + style = MaterialTheme.typography.labelMedium, + fontWeight = FontWeight.Bold, + color = if (hasNewOutputWhileScrolled) MaterialTheme.colorScheme.onPrimaryContainer else AppTheme.extra.textPrimary, + ) + } + } + } + } +} + +private val TERMINAL_PADDING_H = 12.dp +private val TERMINAL_FONT = 11.sp + +@Composable +private fun TerminalVerticalScrollbar( + list: LazyListState, + modifier: Modifier = Modifier, +) { + val info = list.layoutInfo + val total = info.totalItemsCount + val visible = info.visibleItemsInfo.size + if (total == 0 || visible >= total) return + BoxWithConstraints(modifier.width(4.dp)) { + val totalHeight = maxHeight + val thumbHeightDp = totalHeight * (visible.toFloat() / total).coerceIn(0.08f, 0.9f) + val scrollRatio = (list.firstVisibleItemIndex.toFloat() / (total - visible)).coerceIn(0f, 1f) + Box( + Modifier + .offset(y = (totalHeight - thumbHeightDp) * scrollRatio) + .fillMaxWidth() + .height(thumbHeightDp) + .clip(RoundedCornerShape(2.dp)) + .background( + if (list.isScrollInProgress) AppTheme.extra.textPrimary.copy(alpha = 0.55f) + else AppTheme.extra.textMuted.copy(alpha = 0.28f) + ), + ) + } +} + +private fun terminalLine(line: List, palette: TerminalPalette) = + buildAnnotatedString { + line.forEach { span -> + if (span.style == CellStyle.DEFAULT) { + append(span.text) + return@forEach + } + val s = palette.resolve(span.style) + withStyle( + SpanStyle( + color = Color(s.foreground), + background = s.background?.let(::Color) ?: Color.Unspecified, + fontWeight = if (s.bold) FontWeight.Bold else null, + fontStyle = if (s.italic) FontStyle.Italic else null, + textDecoration = when { + s.underline && s.strike -> TextDecoration.combine( + listOf(TextDecoration.Underline, TextDecoration.LineThrough), + ) + s.underline -> TextDecoration.Underline + s.strike -> TextDecoration.LineThrough + else -> null + }, + ), + ) { append(span.text) } + } + } + +@Composable +private fun QuickKeyButton( + label: String, + data: String, + busy: Boolean, + onSendKey: (String) -> Unit, +) { + val haptic = LocalHapticFeedback.current + val interactionSource = remember { MutableInteractionSource() } + val isPressed by interactionSource.collectIsPressedAsState() + val scale by animateFloatAsState( + targetValue = if (isPressed) 0.92f else 1f, + animationSpec = spring(stiffness = Spring.StiffnessMediumLow), + label = "quickKeyScale", + ) + OutlinedButton( + onClick = { + haptic.performHapticFeedback(HapticFeedbackType.TextHandleMove) + onSendKey(data) + }, + enabled = !busy, + shape = MaterialTheme.shapes.small, + border = BorderStroke(1.dp, AppTheme.extra.border), + colors = ButtonDefaults.outlinedButtonColors( + containerColor = MaterialTheme.colorScheme.surfaceVariant, + contentColor = AppTheme.extra.textPrimary, + ), + interactionSource = interactionSource, + contentPadding = PaddingValues(horizontal = 10.dp, vertical = 4.dp), + modifier = Modifier.graphicsLayer { + scaleX = scale + scaleY = scale + }, + ) { + Text(label, fontFamily = FontFamily.Monospace, fontSize = 12.sp, fontWeight = FontWeight.Medium) + } +} + +@Composable +private fun ReplyBox( + quickReplies: List, + send: suspend (String) -> Unit, + sendKey: suspend (String) -> Unit, +) { + val scope = rememberCoroutineScope() + val haptic = LocalHapticFeedback.current + var draft by rememberSaveable { mutableStateOf("") } + var busy by remember { mutableStateOf(false) } + var error by remember { mutableStateOf(null) } + fun run(action: suspend () -> Unit) { + busy = true + error = null + scope.launch { + try { + action() + } catch (e: IOException) { + error = e.message + } finally { + busy = false + } + } + } + Surface( + color = MaterialTheme.colorScheme.surface, + border = BorderStroke(1.dp, AppTheme.extra.border), + ) { + Column(Modifier.padding(horizontal = 12.dp, vertical = 10.dp), verticalArrangement = Arrangement.spacedBy(8.dp)) { + Row( + Modifier.horizontalScroll(rememberScrollState()), + horizontalArrangement = Arrangement.spacedBy(6.dp), + ) { + QUICK_KEYS.forEach { (label, data) -> + QuickKeyButton(label, data, busy = busy) { run { sendKey(it) } } + } + } + QuickReplies(quickReplies, enabled = !busy) { draft = appendToDraft(draft, it) } + AnimatedVisibility( + visible = error != null, + enter = expandVertically() + fadeIn(), + exit = shrinkVertically() + fadeOut(), + ) { + error?.let { Text(it, color = MaterialTheme.colorScheme.error, style = MaterialTheme.typography.bodySmall) } + } + Row(verticalAlignment = Alignment.Bottom) { + OutlinedTextField( + value = draft, + onValueChange = { draft = it }, + modifier = Modifier.weight(1f), + placeholder = { Text("Reply to agent", color = AppTheme.extra.textSubtle) }, + maxLines = 5, + enabled = !busy, + shape = MaterialTheme.shapes.large, + colors = OutlinedTextFieldDefaults.colors( + focusedContainerColor = AppTheme.extra.inputBg, + unfocusedContainerColor = AppTheme.extra.inputBg, + focusedBorderColor = MaterialTheme.colorScheme.primary, + unfocusedBorderColor = AppTheme.extra.border, + ), + ) + VoiceInputButton(enabled = !busy) { draft = appendToDraft(draft, it) } + val sendInteraction = remember { MutableInteractionSource() } + val sendPressed by sendInteraction.collectIsPressedAsState() + val sendScale by animateFloatAsState( + targetValue = if (sendPressed) 0.94f else 1f, + animationSpec = spring(stiffness = Spring.StiffnessMediumLow), + label = "sendButtonScale", + ) + Button( + enabled = draft.isNotBlank() && !busy, + shape = MaterialTheme.shapes.large, + interactionSource = sendInteraction, + colors = ButtonDefaults.buttonColors( + containerColor = MaterialTheme.colorScheme.primary, + contentColor = MaterialTheme.colorScheme.onPrimary, + ), + modifier = Modifier.graphicsLayer { + scaleX = sendScale + scaleY = sendScale + }, + onClick = { + haptic.performHapticFeedback(HapticFeedbackType.LongPress) + run { + send(draft) + draft = "" + } + }, + ) { Text("Send", fontWeight = FontWeight.Bold) } + } + } + } +} + +fun statusLabel(state: ConnectionState) = when (state.status) { + ConnectionStatus.CONNECTED -> if (state.canControl) "Connected" else "Connected, view only" + ConnectionStatus.CONNECTING -> "Connecting…" + ConnectionStatus.DISCONNECTED -> "Offline" +} + +internal fun agentStatusLabel(agent: RemoteAgent): String { + if (agent.collapsed) return "Minimized" + if (!agent.running) return agent.exitCode?.let { "Exited ($it)" } ?: "Exited" + return when (agent.attention) { + "needs_input" -> "Needs input" + "active" -> "Working" + "shell_busy" -> "Running command" + "error" -> "Error" + "ready" -> "Ready" + "review" -> "Review" + else -> "Idle" + } +} diff --git a/android/app/src/main/java/com/parallelcode/phone/SettingsScreen.kt b/android/app/src/main/java/com/parallelcode/phone/SettingsScreen.kt new file mode 100644 index 000000000..c2ef12743 --- /dev/null +++ b/android/app/src/main/java/com/parallelcode/phone/SettingsScreen.kt @@ -0,0 +1,711 @@ +package com.parallelcode.phone + +import androidx.compose.animation.AnimatedVisibility +import androidx.compose.animation.core.FastOutSlowInEasing +import androidx.compose.animation.core.tween +import androidx.compose.animation.expandVertically +import androidx.compose.animation.fadeIn +import androidx.compose.animation.fadeOut +import androidx.compose.animation.shrinkVertically +import androidx.compose.foundation.BorderStroke +import androidx.compose.foundation.background +import androidx.compose.foundation.clickable +import androidx.compose.foundation.layout.Arrangement +import androidx.compose.foundation.layout.Box +import androidx.compose.foundation.layout.Column +import androidx.compose.foundation.layout.PaddingValues +import androidx.compose.foundation.layout.Row +import androidx.compose.foundation.layout.Spacer +import androidx.compose.foundation.layout.fillMaxSize +import androidx.compose.foundation.layout.fillMaxWidth +import androidx.compose.foundation.layout.height +import androidx.compose.foundation.layout.padding +import androidx.compose.foundation.layout.size +import androidx.compose.foundation.lazy.LazyColumn +import androidx.compose.foundation.shape.CircleShape +import androidx.compose.foundation.shape.RoundedCornerShape +import androidx.compose.material3.Button +import androidx.compose.material3.ButtonDefaults +import androidx.compose.material3.Card +import androidx.compose.material3.CardDefaults +import androidx.compose.material3.ExperimentalMaterial3Api +import androidx.compose.material3.HorizontalDivider +import androidx.compose.material3.MaterialTheme +import androidx.compose.material3.OutlinedButton +import androidx.compose.material3.RadioButton +import androidx.compose.material3.RadioButtonDefaults +import androidx.compose.material3.Scaffold +import androidx.compose.material3.Switch +import androidx.compose.material3.SwitchDefaults +import androidx.compose.material3.Text +import androidx.compose.material3.TextButton +import androidx.compose.material3.TopAppBar +import androidx.compose.material3.TopAppBarDefaults +import androidx.compose.runtime.Composable +import androidx.compose.ui.Alignment +import androidx.compose.ui.Modifier +import androidx.compose.ui.draw.clip +import androidx.compose.ui.text.font.FontFamily +import androidx.compose.ui.text.font.FontWeight +import androidx.compose.ui.unit.dp +import androidx.compose.runtime.rememberUpdatedState +import androidx.compose.runtime.DisposableEffect +import androidx.compose.runtime.setValue +import androidx.compose.runtime.getValue +import androidx.compose.runtime.mutableStateOf +import androidx.compose.runtime.remember +import androidx.compose.ui.focus.onFocusChanged +import androidx.compose.material3.OutlinedTextField +import androidx.compose.ui.unit.sp + +@OptIn(ExperimentalMaterial3Api::class) +@Composable +fun SettingsScreen( + keepScreenOn: Boolean, + onKeepScreenOnChange: (Boolean) -> Unit, + keepScreenOnOnlyActive: Boolean, + onKeepScreenOnOnlyActiveChange: (Boolean) -> Unit, + themeMode: String, + onThemeModeChange: (String) -> Unit, + showMinimizedTasks: Boolean, + onShowMinimizedTasksChange: (Boolean) -> Unit, + alwaysFollowOutput: Boolean, + onAlwaysFollowOutputChange: (Boolean) -> Unit, + fitTerminalToPhone: Boolean, + onFitTerminalToPhoneChange: (Boolean) -> Unit, + quickReplies: List, + onQuickRepliesChange: (List) -> Unit, + notifications: NotificationPrefs, + onNotificationsChange: (NotificationPrefs) -> Unit, + latencyMs: Long?, + state: ConnectionState, + computers: List, + onSwitchComputer: (String) -> Unit, + onForgetComputer: (String) -> Unit, + onAddComputer: () -> Unit, + onPair: () -> Unit, + onForget: () -> Unit, + onBack: () -> Unit, +) { + Scaffold( + containerColor = MaterialTheme.colorScheme.background, + topBar = { + Column { + TopAppBar( + 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) + } + }, + title = { + Text( + "Settings", + style = MaterialTheme.typography.titleLarge, + fontWeight = FontWeight.Bold, + ) + }, + ) + HorizontalDivider(thickness = 1.dp, color = AppTheme.extra.border) + } + }, + ) { padding -> + LazyColumn( + modifier = Modifier + .fillMaxSize() + .padding(padding), + contentPadding = PaddingValues(16.dp), + verticalArrangement = Arrangement.spacedBy(20.dp), + ) { + // DISPLAY SECTION + item { + SectionHeader("DISPLAY") + Spacer(Modifier.height(8.dp)) + Card( + modifier = Modifier.fillMaxWidth(), + shape = MaterialTheme.shapes.large, + colors = CardDefaults.cardColors(containerColor = MaterialTheme.colorScheme.surface), + border = BorderStroke(1.dp, AppTheme.extra.border), + ) { + Column( + modifier = Modifier + .fillMaxWidth() + .padding(16.dp), + verticalArrangement = Arrangement.spacedBy(14.dp), + ) { + Row( + modifier = Modifier.fillMaxWidth(), + verticalAlignment = Alignment.CenterVertically, + horizontalArrangement = Arrangement.SpaceBetween, + ) { + Column( + modifier = Modifier + .weight(1f) + .padding(end = 16.dp), + ) { + Text( + "Keep screen awake", + style = MaterialTheme.typography.bodyLarge, + fontWeight = FontWeight.SemiBold, + color = MaterialTheme.colorScheme.onSurface, + ) + Spacer(Modifier.height(4.dp)) + Text( + "Prevent the display from sleeping while Parallel Code is open.", + style = MaterialTheme.typography.bodyMedium, + color = AppTheme.extra.textMuted, + ) + } + Switch( + checked = keepScreenOn, + onCheckedChange = onKeepScreenOnChange, + colors = SwitchDefaults.colors( + checkedThumbColor = MaterialTheme.colorScheme.onPrimary, + checkedTrackColor = MaterialTheme.colorScheme.primary, + uncheckedThumbColor = AppTheme.extra.textMuted, + uncheckedTrackColor = AppTheme.extra.inputBg, + uncheckedBorderColor = AppTheme.extra.border, + ), + ) + } + + AnimatedVisibility( + visible = keepScreenOn, + enter = expandVertically(animationSpec = tween(280, easing = FastOutSlowInEasing)) + fadeIn(tween(220)), + exit = shrinkVertically(animationSpec = tween(240, easing = FastOutSlowInEasing)) + fadeOut(tween(180)), + ) { + Column( + modifier = Modifier.fillMaxWidth(), + verticalArrangement = Arrangement.spacedBy(14.dp), + ) { + HorizontalDivider(thickness = 1.dp, color = AppTheme.extra.borderSubtle) + Row( + modifier = Modifier.fillMaxWidth(), + verticalAlignment = Alignment.CenterVertically, + horizontalArrangement = Arrangement.SpaceBetween, + ) { + Column( + modifier = Modifier + .weight(1f) + .padding(end = 16.dp), + ) { + Text( + "Only while tasks are active", + style = MaterialTheme.typography.bodyMedium, + fontWeight = FontWeight.Medium, + color = MaterialTheme.colorScheme.onSurface, + ) + Spacer(Modifier.height(2.dp)) + Text( + "Allow screen to sleep when all agents finish or are idle.", + style = MaterialTheme.typography.bodySmall, + color = AppTheme.extra.textMuted, + ) + } + Switch( + checked = keepScreenOnOnlyActive, + onCheckedChange = onKeepScreenOnOnlyActiveChange, + colors = SwitchDefaults.colors( + checkedThumbColor = MaterialTheme.colorScheme.onPrimary, + checkedTrackColor = MaterialTheme.colorScheme.primary, + uncheckedThumbColor = AppTheme.extra.textMuted, + uncheckedTrackColor = AppTheme.extra.inputBg, + uncheckedBorderColor = AppTheme.extra.border, + ), + ) + } + } + } + } + } + } + + // APPEARANCE SECTION + item { + SectionHeader("APPEARANCE") + Spacer(Modifier.height(8.dp)) + Card( + modifier = Modifier.fillMaxWidth(), + shape = MaterialTheme.shapes.large, + colors = CardDefaults.cardColors(containerColor = MaterialTheme.colorScheme.surface), + border = BorderStroke(1.dp, AppTheme.extra.border), + ) { + Column( + modifier = Modifier + .fillMaxWidth() + .padding(horizontal = 8.dp, vertical = 6.dp), + ) { + ThemeOptionRow( + title = "Follow system", + subtitle = "Match Android device theme settings", + selected = themeMode == SettingsStore.THEME_SYSTEM, + onClick = { onThemeModeChange(SettingsStore.THEME_SYSTEM) }, + ) + HorizontalDivider(thickness = 1.dp, color = AppTheme.extra.borderSubtle, modifier = Modifier.padding(horizontal = 8.dp)) + ThemeOptionRow( + title = "Obsidian Dark", + subtitle = "Flat charcoal with an amber accent, like the desktop", + selected = themeMode == SettingsStore.THEME_DARK, + onClick = { onThemeModeChange(SettingsStore.THEME_DARK) }, + ) + HorizontalDivider(thickness = 1.dp, color = AppTheme.extra.borderSubtle, modifier = Modifier.padding(horizontal = 8.dp)) + ThemeOptionRow( + title = "Light", + subtitle = "Clean high-contrast daytime theme", + selected = themeMode == SettingsStore.THEME_LIGHT, + onClick = { onThemeModeChange(SettingsStore.THEME_LIGHT) }, + ) + } + } + } + + // TASKS SECTION + item { + SectionHeader("TASKS") + Spacer(Modifier.height(8.dp)) + Card( + modifier = Modifier.fillMaxWidth(), + shape = MaterialTheme.shapes.large, + colors = CardDefaults.cardColors(containerColor = MaterialTheme.colorScheme.surface), + border = BorderStroke(1.dp, AppTheme.extra.border), + ) { + Column( + modifier = Modifier + .fillMaxWidth() + .padding(16.dp), + verticalArrangement = Arrangement.spacedBy(14.dp), + ) { + SettingSwitchRow( + title = "Show minimized tasks", + description = "Pin collapsed and minimized tasks to the bottom of the overview.", + checked = showMinimizedTasks, + onCheckedChange = onShowMinimizedTasksChange, + ) + } + } + } + + // NOTIFICATIONS SECTION + item { + SectionHeader("NOTIFICATIONS") + Spacer(Modifier.height(8.dp)) + Card( + modifier = Modifier.fillMaxWidth(), + shape = MaterialTheme.shapes.large, + colors = CardDefaults.cardColors(containerColor = MaterialTheme.colorScheme.surface), + border = BorderStroke(1.dp, AppTheme.extra.border), + ) { + Column( + modifier = Modifier + .fillMaxWidth() + .padding(16.dp), + verticalArrangement = Arrangement.spacedBy(14.dp), + ) { + SettingSwitchRow( + title = "Notify me about agents", + description = "Keeps the connection open in the background, with a quiet ongoing notification. Uses some battery.", + checked = notifications.enabled, + onCheckedChange = { onNotificationsChange(notifications.copy(enabled = it)) }, + ) + AnimatedVisibility( + visible = notifications.enabled, + enter = expandVertically(animationSpec = tween(280, easing = FastOutSlowInEasing)) + fadeIn(tween(220)), + exit = shrinkVertically(animationSpec = tween(240, easing = FastOutSlowInEasing)) + fadeOut(tween(180)), + ) { + Column(verticalArrangement = Arrangement.spacedBy(14.dp)) { + HorizontalDivider(thickness = 1.dp, color = AppTheme.extra.borderSubtle) + SettingSwitchRow( + title = "Needs input", + description = "An agent is waiting for an answer or approval.", + checked = notifications.needsInput, + onCheckedChange = { onNotificationsChange(notifications.copy(needsInput = it)) }, + ) + SettingSwitchRow( + title = "Errors", + description = "An agent stopped on an error.", + checked = notifications.errors, + onCheckedChange = { onNotificationsChange(notifications.copy(errors = it)) }, + ) + SettingSwitchRow( + title = "Finished", + description = "An agent finished working or exited.", + checked = notifications.finished, + onCheckedChange = { onNotificationsChange(notifications.copy(finished = it)) }, + ) + } + } + } + } + } + + // TERMINAL SECTION + item { + SectionHeader("TERMINAL") + Spacer(Modifier.height(8.dp)) + Card( + modifier = Modifier.fillMaxWidth(), + shape = MaterialTheme.shapes.large, + colors = CardDefaults.cardColors(containerColor = MaterialTheme.colorScheme.surface), + border = BorderStroke(1.dp, AppTheme.extra.border), + ) { + Column( + modifier = Modifier + .fillMaxWidth() + .padding(16.dp), + ) { + QuickRepliesEditor(quickReplies, onQuickRepliesChange) + HorizontalDivider(thickness = 1.dp, color = AppTheme.extra.borderSubtle, modifier = Modifier.padding(vertical = 14.dp)) + SettingSwitchRow( + title = "Always scroll to latest output", + description = "Jump to new output even after scrolling up. Off, the terminal follows output only while you are at the bottom.", + checked = alwaysFollowOutput, + onCheckedChange = onAlwaysFollowOutputChange, + ) + HorizontalDivider(thickness = 1.dp, color = AppTheme.extra.borderSubtle, modifier = Modifier.padding(vertical = 14.dp)) + SettingSwitchRow( + title = "Fit the terminal to this phone", + description = "Full-screen agents such as Claude Code fill the phone while you view them. Your computer's terminal is redrawn for the phone meanwhile and shifts until you leave.", + checked = fitTerminalToPhone, + onCheckedChange = onFitTerminalToPhoneChange, + ) + } + } + } + + // DESKTOP CONNECTION SECTION + item { + SectionHeader("DESKTOP CONNECTION") + Spacer(Modifier.height(8.dp)) + Card( + modifier = Modifier.fillMaxWidth(), + shape = MaterialTheme.shapes.large, + colors = CardDefaults.cardColors(containerColor = MaterialTheme.colorScheme.surface), + border = BorderStroke(1.dp, AppTheme.extra.border), + ) { + Column( + modifier = Modifier + .fillMaxWidth() + .padding(16.dp), + verticalArrangement = Arrangement.spacedBy(12.dp), + ) { + val host = state.link?.baseUrl ?: "Not connected" + Row( + verticalAlignment = Alignment.CenterVertically, + horizontalArrangement = Arrangement.SpaceBetween, + modifier = Modifier.fillMaxWidth(), + ) { + Text("Computer", style = MaterialTheme.typography.bodyMedium, color = AppTheme.extra.textMuted) + Text( + host.substringAfter("://"), + style = MaterialTheme.typography.bodyMedium, + fontFamily = FontFamily.Monospace, + fontWeight = FontWeight.Medium, + color = MaterialTheme.colorScheme.onSurface, + ) + } + + Row( + verticalAlignment = Alignment.CenterVertically, + horizontalArrangement = Arrangement.SpaceBetween, + modifier = Modifier.fillMaxWidth(), + ) { + Text("Status", style = MaterialTheme.typography.bodyMedium, color = AppTheme.extra.textMuted) + Row(verticalAlignment = Alignment.CenterVertically, horizontalArrangement = Arrangement.spacedBy(6.dp)) { + Box( + modifier = Modifier + .size(8.dp) + .clip(CircleShape) + .background(if (state.status == ConnectionStatus.CONNECTED) AppTheme.extra.success else AppTheme.extra.textMuted), + ) + Text( + statusLabel(state), + style = MaterialTheme.typography.bodyMedium, + fontWeight = FontWeight.Medium, + color = if (state.status == ConnectionStatus.CONNECTED) AppTheme.extra.success else AppTheme.extra.textMuted, + ) + } + } + + if (state.status == ConnectionStatus.CONNECTED) { + Row( + verticalAlignment = Alignment.CenterVertically, + horizontalArrangement = Arrangement.SpaceBetween, + modifier = Modifier.fillMaxWidth(), + ) { + Text("Latency", style = MaterialTheme.typography.bodyMedium, color = AppTheme.extra.textMuted) + Text( + latencyMs?.let { "${it} ms" } ?: "< 10 ms", + style = MaterialTheme.typography.bodyMedium, + fontFamily = FontFamily.Monospace, + fontWeight = FontWeight.Medium, + color = AppTheme.extra.textPrimary, + ) + } + } + + Row( + verticalAlignment = Alignment.CenterVertically, + horizontalArrangement = Arrangement.SpaceBetween, + modifier = Modifier.fillMaxWidth(), + ) { + Text("Permissions", style = MaterialTheme.typography.bodyMedium, color = AppTheme.extra.textMuted) + Text( + if (state.canControl) "Full control (paired)" else "View only", + style = MaterialTheme.typography.bodyMedium, + fontWeight = FontWeight.Medium, + color = if (state.canControl) MaterialTheme.colorScheme.primary else AppTheme.extra.textMuted, + ) + } + + if (!state.canControl && state.status == ConnectionStatus.CONNECTED) { + Button( + onClick = onPair, + modifier = Modifier.fillMaxWidth(), + shape = MaterialTheme.shapes.small, + colors = ButtonDefaults.buttonColors( + containerColor = MaterialTheme.colorScheme.primary, + contentColor = MaterialTheme.colorScheme.onPrimary, + ), + ) { + Text("Pair with PIN to enable replies", fontWeight = FontWeight.SemiBold) + } + } + + HorizontalDivider(thickness = 1.dp, color = AppTheme.extra.border) + + OutlinedButton( + onClick = onForget, + modifier = Modifier.fillMaxWidth(), + shape = MaterialTheme.shapes.small, + border = BorderStroke(1.dp, MaterialTheme.colorScheme.error.copy(alpha = 0.5f)), + colors = ButtonDefaults.outlinedButtonColors(contentColor = MaterialTheme.colorScheme.error), + ) { + Text("Forget this computer", fontWeight = FontWeight.SemiBold) + } + } + } + } + + // COMPUTERS SECTION + item { + SectionHeader("COMPUTERS") + Spacer(Modifier.height(8.dp)) + Card( + modifier = Modifier.fillMaxWidth(), + shape = MaterialTheme.shapes.large, + colors = CardDefaults.cardColors(containerColor = MaterialTheme.colorScheme.surface), + border = BorderStroke(1.dp, AppTheme.extra.border), + ) { + Column(Modifier.fillMaxWidth().padding(horizontal = 8.dp, vertical = 6.dp)) { + computers.forEach { computer -> + ComputerRow( + computer = computer, + inUse = computer.baseUrl == state.link?.baseUrl, + onSelect = { onSwitchComputer(computer.baseUrl) }, + onForget = { onForgetComputer(computer.baseUrl) }, + ) + HorizontalDivider(thickness = 1.dp, color = AppTheme.extra.borderSubtle, modifier = Modifier.padding(horizontal = 8.dp)) + } + TextButton(onClick = onAddComputer, modifier = Modifier.fillMaxWidth()) { + Text("Add another computer", fontWeight = FontWeight.SemiBold) + } + } + } + } + + // ABOUT SECTION + item { + SectionHeader("ABOUT") + Spacer(Modifier.height(8.dp)) + Card( + modifier = Modifier.fillMaxWidth(), + shape = MaterialTheme.shapes.large, + colors = CardDefaults.cardColors(containerColor = MaterialTheme.colorScheme.surface), + border = BorderStroke(1.dp, AppTheme.extra.border), + ) { + Column( + modifier = Modifier + .fillMaxWidth() + .padding(16.dp), + verticalArrangement = Arrangement.spacedBy(8.dp), + ) { + Row( + verticalAlignment = Alignment.CenterVertically, + horizontalArrangement = Arrangement.SpaceBetween, + modifier = Modifier.fillMaxWidth(), + ) { + Text("Parallel Code", fontWeight = FontWeight.SemiBold, color = MaterialTheme.colorScheme.onSurface) + Text("v3.1.0", color = AppTheme.extra.textMuted, fontFamily = FontFamily.Monospace) + } + Text( + "Mobile companion for monitoring and interacting with parallel AI agents.", + style = MaterialTheme.typography.bodySmall, + color = AppTheme.extra.textMuted, + ) + } + } + } + } + } +} + +@Composable +private fun ThemeOptionRow( + title: String, + subtitle: String, + selected: Boolean, + onClick: () -> Unit, +) { + Row( + modifier = Modifier + .fillMaxWidth() + .clip(MaterialTheme.shapes.small) + .clickable(onClick = onClick) + .padding(horizontal = 8.dp, vertical = 10.dp), + verticalAlignment = Alignment.CenterVertically, + ) { + RadioButton( + selected = selected, + onClick = null, + colors = RadioButtonDefaults.colors( + selectedColor = MaterialTheme.colorScheme.primary, + unselectedColor = AppTheme.extra.textMuted, + ), + ) + Column(modifier = Modifier.padding(start = 10.dp)) { + Text( + title, + style = MaterialTheme.typography.bodyMedium, + fontWeight = if (selected) FontWeight.SemiBold else FontWeight.Normal, + color = MaterialTheme.colorScheme.onSurface, + ) + Text( + subtitle, + style = MaterialTheme.typography.bodySmall, + color = AppTheme.extra.textMuted, + ) + } + } +} + +@Composable +private fun SectionHeader(title: String) { + Text( + text = title, + style = MaterialTheme.typography.labelSmall, + fontWeight = FontWeight.Bold, + letterSpacing = 1.sp, + color = AppTheme.extra.textMuted, + modifier = Modifier.padding(start = 4.dp), + ) +} + +@Composable +private fun SettingSwitchRow( + title: String, + description: String, + checked: Boolean, + onCheckedChange: (Boolean) -> Unit, +) { + Row( + modifier = Modifier.fillMaxWidth(), + verticalAlignment = Alignment.CenterVertically, + horizontalArrangement = Arrangement.SpaceBetween, + ) { + Column( + modifier = Modifier + .weight(1f) + .padding(end = 16.dp), + ) { + Text( + title, + style = MaterialTheme.typography.bodyLarge, + fontWeight = FontWeight.SemiBold, + color = MaterialTheme.colorScheme.onSurface, + ) + Spacer(Modifier.height(4.dp)) + Text( + description, + style = MaterialTheme.typography.bodyMedium, + color = AppTheme.extra.textMuted, + ) + } + Switch( + checked = checked, + onCheckedChange = onCheckedChange, + colors = SwitchDefaults.colors( + checkedThumbColor = MaterialTheme.colorScheme.onPrimary, + checkedTrackColor = MaterialTheme.colorScheme.primary, + uncheckedThumbColor = AppTheme.extra.textMuted, + uncheckedTrackColor = AppTheme.extra.inputBg, + uncheckedBorderColor = AppTheme.extra.border, + ), + ) + } +} + +/** One reply per line; saved when the field loses focus or the screen closes. */ +@Composable +private fun QuickRepliesEditor(replies: List, onChange: (List) -> Unit) { + var text by remember(replies) { mutableStateOf(replies.joinToString("\n")) } + val latest by rememberUpdatedState(text) + DisposableEffect(Unit) { onDispose { if (latest.lines() != replies) onChange(latest.lines()) } } + Column(verticalArrangement = Arrangement.spacedBy(6.dp)) { + Text( + "Quick replies", + style = MaterialTheme.typography.bodyLarge, + fontWeight = FontWeight.SemiBold, + color = MaterialTheme.colorScheme.onSurface, + ) + Text( + "Shown above the reply box; tap one to add it to your message. One per line.", + style = MaterialTheme.typography.bodyMedium, + color = AppTheme.extra.textMuted, + ) + OutlinedTextField( + value = text, + onValueChange = { text = it }, + modifier = Modifier + .fillMaxWidth() + .onFocusChanged { if (!it.isFocused) onChange(text.lines()) }, + minLines = 3, + maxLines = 8, + ) + } +} + +/** A saved computer: tap to switch to it; the one in use is marked and cannot be forgotten here. */ +@Composable +private fun ComputerRow(computer: SavedComputer, inUse: Boolean, onSelect: () -> Unit, onForget: () -> Unit) { + Row( + modifier = Modifier + .fillMaxWidth() + .clickable(enabled = !inUse, onClick = onSelect) + .padding(horizontal = 8.dp, vertical = 10.dp), + verticalAlignment = Alignment.CenterVertically, + ) { + RadioButton(selected = inUse, onClick = if (inUse) null else onSelect) + Column(Modifier.weight(1f).padding(start = 4.dp)) { + Text( + computer.label, + fontFamily = FontFamily.Monospace, + style = MaterialTheme.typography.bodyMedium, + fontWeight = FontWeight.Medium, + color = MaterialTheme.colorScheme.onSurface, + ) + Text( + (if (inUse) "In use · " else "") + if (computer.pairedToken != null) "Paired" else "View only", + style = MaterialTheme.typography.bodySmall, + color = AppTheme.extra.textMuted, + ) + } + if (!inUse) { + TextButton(onClick = onForget) { Text("Forget", color = MaterialTheme.colorScheme.error) } + } + } +} + diff --git a/android/app/src/main/java/com/parallelcode/phone/SettingsStore.kt b/android/app/src/main/java/com/parallelcode/phone/SettingsStore.kt new file mode 100644 index 000000000..f405d4269 --- /dev/null +++ b/android/app/src/main/java/com/parallelcode/phone/SettingsStore.kt @@ -0,0 +1,95 @@ +package com.parallelcode.phone + +import android.content.SharedPreferences +import androidx.core.content.edit + +class SettingsStore(private val prefs: SharedPreferences) { + + var keepScreenOn: Boolean + get() = prefs.getBoolean(KEY_KEEP_SCREEN_ON, false) + set(value) { + prefs.edit { putBoolean(KEY_KEEP_SCREEN_ON, value) } + } + + var keepScreenOnOnlyWhenActive: Boolean + get() = prefs.getBoolean(KEY_KEEP_SCREEN_ON_ONLY_ACTIVE, false) + set(value) { + prefs.edit { putBoolean(KEY_KEEP_SCREEN_ON_ONLY_ACTIVE, value) } + } + + var themeMode: String + get() = prefs.getString(KEY_THEME_MODE, THEME_SYSTEM) ?: THEME_SYSTEM + set(value) { + prefs.edit { putString(KEY_THEME_MODE, value) } + } + + var showMinimizedTasks: Boolean + get() = prefs.getBoolean(KEY_SHOW_MINIMIZED_TASKS, false) + set(value) { + prefs.edit { putBoolean(KEY_SHOW_MINIMIZED_TASKS, value) } + } + + /** Jump to new terminal output even when scrolled up; off, output is followed only at the bottom. */ + var alwaysFollowOutput: Boolean + get() = prefs.getBoolean(KEY_ALWAYS_FOLLOW_OUTPUT, false) + set(value) { + prefs.edit { putBoolean(KEY_ALWAYS_FOLLOW_OUTPUT, value) } + } + + /** + * Give a viewed terminal the phone's size, so full-screen agents fill the phone. Off by default: + * while on, the desktop pane shows output drawn for the phone and shifts until you leave. + */ + var fitTerminalToPhone: Boolean + get() = prefs.getBoolean(KEY_FIT_TERMINAL, false) + set(value) = prefs.edit { putBoolean(KEY_FIT_TERMINAL, value) } + + /** Replies offered above the reply box, one per line. */ + var quickReplies: List + get() = (prefs.getString(KEY_QUICK_REPLIES, null) ?: DEFAULT_QUICK_REPLIES.joinToString("\n")) + .lines().map { it.trim() }.filter { it.isNotEmpty() } + set(value) = prefs.edit { putString(KEY_QUICK_REPLIES, value.joinToString("\n")) } + + /** Keep watching in the background and notify about agents (see [AgentWatchService]). */ + var notificationsEnabled: Boolean + get() = prefs.getBoolean(KEY_NOTIFICATIONS, false) + set(value) = prefs.edit { putBoolean(KEY_NOTIFICATIONS, value) } + + var notifyNeedsInput: Boolean + get() = prefs.getBoolean(KEY_NOTIFY_NEEDS_INPUT, true) + set(value) = prefs.edit { putBoolean(KEY_NOTIFY_NEEDS_INPUT, value) } + + var notifyErrors: Boolean + get() = prefs.getBoolean(KEY_NOTIFY_ERRORS, true) + set(value) = prefs.edit { putBoolean(KEY_NOTIFY_ERRORS, value) } + + var notifyFinished: Boolean + get() = prefs.getBoolean(KEY_NOTIFY_FINISHED, false) + set(value) = prefs.edit { putBoolean(KEY_NOTIFY_FINISHED, value) } + + fun notifiesFor(event: AgentEvent): Boolean = when (event) { + AgentEvent.NEEDS_INPUT -> notifyNeedsInput + AgentEvent.ERROR -> notifyErrors + AgentEvent.FINISHED -> notifyFinished + } + + companion object { + const val PREFS_NAME = "settings" + const val KEY_KEEP_SCREEN_ON = "keepScreenOn" + const val KEY_KEEP_SCREEN_ON_ONLY_ACTIVE = "keepScreenOnOnlyActive" + const val KEY_THEME_MODE = "themeMode" + const val KEY_SHOW_MINIMIZED_TASKS = "showMinimizedTasks" + const val KEY_ALWAYS_FOLLOW_OUTPUT = "alwaysFollowOutput" + const val KEY_FIT_TERMINAL = "fitTerminalToPhone" + const val KEY_QUICK_REPLIES = "quickReplies" + val DEFAULT_QUICK_REPLIES = listOf("continue", "yes", "run the tests", "commit this") + const val KEY_NOTIFICATIONS = "notifications" + const val KEY_NOTIFY_NEEDS_INPUT = "notifyNeedsInput" + const val KEY_NOTIFY_ERRORS = "notifyErrors" + const val KEY_NOTIFY_FINISHED = "notifyFinished" + + const val THEME_SYSTEM = "system" + const val THEME_DARK = "dark" + const val THEME_LIGHT = "light" + } +} diff --git a/android/app/src/main/java/com/parallelcode/phone/TaskDiff.kt b/android/app/src/main/java/com/parallelcode/phone/TaskDiff.kt new file mode 100644 index 000000000..46f252390 --- /dev/null +++ b/android/app/src/main/java/com/parallelcode/phone/TaskDiff.kt @@ -0,0 +1,58 @@ +package com.parallelcode.phone + +/** One changed file in a unified diff, as the desktop's diff view lists it. */ +data class DiffFile(val path: String, val added: Int, val removed: Int, val lines: List, val binary: Boolean) + +/** Split `git diff` output into files; hunk lines keep their ' ', '+', '-' or '@@' prefix. */ +fun parseUnifiedDiff(diff: String): List { + val files = mutableListOf() + var path: String? = null + var lines = mutableListOf() + var added = 0 + var removed = 0 + var binary = false + var inHunk = false + + fun finish() { + path?.let { files.add(DiffFile(it, added, removed, lines, binary)) } + lines = mutableListOf() + added = 0 + removed = 0 + binary = false + inHunk = false + } + + for (line in diff.lineSequence()) { + when { + line.startsWith("diff --git ") -> { + finish() + // "diff --git a/old b/new": the new path, which +++ may refine below. + path = line.substringAfter(" b/", line.removePrefix("diff --git ")) + } + !inHunk && line.startsWith("+++ ") -> { + val target = line.removePrefix("+++ ") + if (target != "/dev/null") path = target.removePrefix("b/") + } + !inHunk && line.startsWith("--- ") -> { + val source = line.removePrefix("--- ") + if (path == null && source != "/dev/null") path = source.removePrefix("a/") + } + line.startsWith("Binary files ") -> binary = true + line.startsWith("@@") -> { + inHunk = true + lines.add(line) + } + inHunk && line.startsWith("+") -> { + added++ + lines.add(line) + } + inHunk && line.startsWith("-") -> { + removed++ + lines.add(line) + } + inHunk && (line.startsWith(" ") || line.startsWith("\\")) -> lines.add(line) + } + } + finish() + return files +} diff --git a/android/app/src/main/java/com/parallelcode/phone/TaskScreens.kt b/android/app/src/main/java/com/parallelcode/phone/TaskScreens.kt new file mode 100644 index 000000000..91b4a7ba7 --- /dev/null +++ b/android/app/src/main/java/com/parallelcode/phone/TaskScreens.kt @@ -0,0 +1,397 @@ +package com.parallelcode.phone + +import androidx.compose.animation.AnimatedContent +import androidx.compose.animation.AnimatedVisibility +import androidx.compose.animation.core.Spring +import androidx.compose.animation.core.animateFloatAsState +import androidx.compose.animation.core.spring +import androidx.compose.animation.expandVertically +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 +import androidx.compose.foundation.layout.Column +import androidx.compose.foundation.layout.Row +import androidx.compose.foundation.layout.fillMaxSize +import androidx.compose.foundation.layout.fillMaxWidth +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.ExperimentalMaterial3Api +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 +import androidx.compose.material3.TopAppBarDefaults +import androidx.compose.runtime.Composable +import androidx.compose.runtime.LaunchedEffect +import androidx.compose.runtime.getValue +import androidx.compose.runtime.mutableIntStateOf +import androidx.compose.runtime.mutableStateOf +import androidx.compose.runtime.remember +import androidx.compose.runtime.rememberCoroutineScope +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 + +// Matches MAX_NOTES_BYTES in electron/remote/server.ts. +private const val MAX_NOTES_BYTES = 100 * 1024 + +@OptIn(ExperimentalMaterial3Api::class) +@Composable +fun NewTaskScreen(client: RemoteClient, onDone: () -> Unit, onNeedsPairing: () -> Unit) { + val scope = rememberCoroutineScope() + var projects by remember { mutableStateOf?>(null) } + var loadAttempt by remember { mutableIntStateOf(0) } + var projectId by rememberSaveable { mutableStateOf("") } + var name by rememberSaveable { mutableStateOf("") } + var prompt by rememberSaveable { mutableStateOf("") } + var busy by remember { mutableStateOf(false) } + var error by remember { mutableStateOf(null) } + // Same default title as the phone web UI: the start of the prompt. + val title = name.trim().ifEmpty { prompt.trim().replace(Regex("\\s+"), " ").take(80) } + + fun failed(e: ApiException, suffix: String = "") { + // The client has already dropped a rejected paired token. + if (e.status == 401) onNeedsPairing() else error = e.message + suffix + } + + LaunchedEffect(loadAttempt) { + error = null + try { + val list = client.fetchProjects() + projects = list + if (list.none { it.id == projectId }) projectId = list.firstOrNull()?.id.orEmpty() + } catch (e: ApiException) { + projects = emptyList() + failed(e) + } + } + + Scaffold( + containerColor = MaterialTheme.colorScheme.background, + topBar = { + Column { + TopAppBar( + colors = TopAppBarDefaults.topAppBarColors( + containerColor = MaterialTheme.colorScheme.surface, + titleContentColor = MaterialTheme.colorScheme.onSurface, + navigationIconContentColor = MaterialTheme.colorScheme.primary, + ), + navigationIcon = { + TextButton(onClick = onDone, enabled = !busy) { + Text("Cancel", color = MaterialTheme.colorScheme.primary, fontWeight = FontWeight.SemiBold) + } + }, + title = { + Text("New task", fontWeight = FontWeight.Bold, style = MaterialTheme.typography.titleLarge) + }, + ) + HorizontalDivider(thickness = 1.dp, color = AppTheme.extra.border) + } + }, + ) { padding -> + Column( + Modifier + .fillMaxSize() + .padding(padding) + .imePadding() + .verticalScroll(rememberScrollState()) + .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) + loaded.isEmpty() -> { + Text( + "No projects available. Add one in Parallel Code on your computer.", + color = AppTheme.extra.textMuted, + ) + 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, + 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, + ) + } + } + } + } + } + OutlinedTextField( + value = prompt, + onValueChange = { prompt = it }, + modifier = Modifier + .fillMaxWidth() + .heightIn(min = 140.dp), + label = { Text("What should the agent work on?") }, + enabled = !busy, + shape = MaterialTheme.shapes.large, + colors = OutlinedTextFieldDefaults.colors( + focusedContainerColor = AppTheme.extra.inputBg, + unfocusedContainerColor = AppTheme.extra.inputBg, + focusedBorderColor = MaterialTheme.colorScheme.primary, + unfocusedBorderColor = AppTheme.extra.border, + ), + ) + OutlinedTextField( + value = name, + onValueChange = { name = it }, + modifier = Modifier.fillMaxWidth(), + label = { Text("Task name (optional)") }, + placeholder = { Text(title, color = AppTheme.extra.textSubtle) }, + singleLine = true, + enabled = !busy, + shape = MaterialTheme.shapes.large, + colors = OutlinedTextFieldDefaults.colors( + focusedContainerColor = AppTheme.extra.inputBg, + unfocusedContainerColor = AppTheme.extra.inputBg, + focusedBorderColor = MaterialTheme.colorScheme.primary, + unfocusedBorderColor = AppTheme.extra.border, + ), + ) + AnimatedVisibility( + visible = error != null, + enter = expandVertically() + fadeIn(), + exit = shrinkVertically() + fadeOut(), + ) { + error?.let { Text(it, color = MaterialTheme.colorScheme.error) } + } + val createInteractionSource = remember { MutableInteractionSource() } + val isCreatePressed by createInteractionSource.collectIsPressedAsState() + val createScale by animateFloatAsState( + targetValue = if (isCreatePressed) 0.96f else 1f, + animationSpec = spring(dampingRatio = Spring.DampingRatioMediumBouncy, stiffness = Spring.StiffnessLow), + label = "createButtonScale", + ) + Button( + interactionSource = createInteractionSource, + modifier = Modifier + .fillMaxWidth() + .graphicsLayer { + scaleX = createScale + scaleY = createScale + }, + enabled = !busy && projectId.isNotEmpty() && prompt.isNotBlank(), + shape = MaterialTheme.shapes.large, + colors = ButtonDefaults.buttonColors( + containerColor = MaterialTheme.colorScheme.primary, + contentColor = MaterialTheme.colorScheme.onPrimary, + ), + onClick = { + busy = true + error = null + scope.launch { + try { + client.createTask(projectId, title, prompt.trim()) + onDone() + } catch (e: ApiException) { + failed( + e, + " Your draft is kept. If the connection dropped, check the task list before retrying.", + ) + } finally { + busy = false + } + } + }, + ) { Text(if (busy) "Creating…" else "Create task", fontWeight = FontWeight.Bold) } + } + } +} + +/** 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) { + val scope = rememberCoroutineScope() + var notes by rememberSaveable(taskId) { mutableStateOf("") } + var dirty by rememberSaveable(taskId) { mutableStateOf(false) } + var loaded by remember(taskId) { mutableStateOf(false) } + var loadAttempt by remember { mutableIntStateOf(0) } + var saving by remember { mutableStateOf(false) } + var saved by remember { mutableStateOf(false) } + var error by remember { mutableStateOf(null) } + + LaunchedEffect(taskId, loadAttempt) { + error = null + try { + val remote = client.fetchNotes(taskId) + // Keep unsaved edits rather than overwrite them with the desktop's copy. + if (!dirty) notes = remote + loaded = true + } catch (e: ApiException) { + error = e.message + } + } + + Column(modifier.padding(12.dp), verticalArrangement = Arrangement.spacedBy(8.dp)) { + OutlinedTextField( + value = notes, + onValueChange = { + notes = it + dirty = true + saved = false + }, + modifier = Modifier + .fillMaxWidth() + .weight(1f), + placeholder = { Text(if (loaded) "No notes yet" else "Loading notes…", color = AppTheme.extra.textSubtle) }, + readOnly = !canEdit || !loaded, + shape = MaterialTheme.shapes.large, + colors = OutlinedTextFieldDefaults.colors( + focusedContainerColor = AppTheme.extra.inputBg, + unfocusedContainerColor = AppTheme.extra.inputBg, + focusedBorderColor = MaterialTheme.colorScheme.primary, + unfocusedBorderColor = AppTheme.extra.border, + ), + ) + AnimatedVisibility( + visible = error != null, + enter = expandVertically() + fadeIn(), + exit = shrinkVertically() + fadeOut(), + ) { + error?.let { Text(it, color = MaterialTheme.colorScheme.error) } + } + Row(verticalAlignment = Alignment.CenterVertically) { + AnimatedContent( + targetState = when { + saving -> "saving" + dirty -> "dirty" + saved -> "saved" + else -> "idle" + }, + transitionSpec = { fadeIn() togetherWith fadeOut() }, + label = "notesStatusAnimation", + modifier = Modifier.weight(1f), + ) { status -> + Text( + text = when (status) { + "saving" -> "Saving…" + "dirty" -> "Unsaved changes" + "saved" -> "✓ Saved to your computer" + else -> "" + }, + style = MaterialTheme.typography.bodySmall, + color = when (status) { + "dirty" -> AppTheme.extra.warningText + "saved" -> AppTheme.extra.success + else -> AppTheme.extra.textMuted + }, + ) + } + if (!loaded) TextButton(onClick = { loadAttempt++ }) { Text("Reload") } + if (canEdit) { + val saveInteractionSource = remember { MutableInteractionSource() } + val isSavePressed by saveInteractionSource.collectIsPressedAsState() + val saveScale by animateFloatAsState( + targetValue = if (isSavePressed) 0.94f else 1f, + animationSpec = spring(dampingRatio = Spring.DampingRatioMediumBouncy, stiffness = Spring.StiffnessLow), + label = "saveButtonScale", + ) + Button( + interactionSource = saveInteractionSource, + modifier = Modifier.graphicsLayer { + scaleX = saveScale + scaleY = saveScale + }, + enabled = loaded && dirty && !saving, + shape = MaterialTheme.shapes.large, + colors = ButtonDefaults.buttonColors( + containerColor = MaterialTheme.colorScheme.primary, + contentColor = MaterialTheme.colorScheme.onPrimary, + ), + onClick = { + if (notes.toByteArray().size > MAX_NOTES_BYTES) { + error = "Notes must be 100 KB or less." + return@Button + } + saving = true + error = null + val text = notes + scope.launch { + try { + client.saveNotes(taskId, text) + // Typing during the save keeps the note marked unsaved. + if (notes == text) { + dirty = false + saved = true + } + } catch (e: ApiException) { + error = e.message + } finally { + saving = false + } + } + }, + ) { Text("Save", fontWeight = FontWeight.Bold) } + } + } + } +} diff --git a/android/app/src/main/java/com/parallelcode/phone/TerminalBuffer.kt b/android/app/src/main/java/com/parallelcode/phone/TerminalBuffer.kt new file mode 100644 index 000000000..0f638eba1 --- /dev/null +++ b/android/app/src/main/java/com/parallelcode/phone/TerminalBuffer.kt @@ -0,0 +1,33 @@ +package com.parallelcode.phone + +import kotlinx.coroutines.flow.MutableStateFlow +import kotlinx.coroutines.flow.StateFlow +import kotlinx.coroutines.flow.asStateFlow + +/** + * Retains virtual terminal state and scrollback history for an agent. + * Kept in memory while the app is connected so background tasks can buffer their + * output and allow users to read up when selecting the task. + */ +class TerminalBuffer( + val agentId: String, + val screen: TerminalScreen = TerminalScreen(), +) : TerminalListener { + private val _version = MutableStateFlow(0) + val version: StateFlow = _version.asStateFlow() + + override fun onScrollback(data: ByteArray, cols: Int, rows: Int) { + screen.reset(cols, rows, data) + _version.value++ + } + + fun resize(cols: Int, rows: Int) { + screen.resize(cols, rows) + _version.value++ + } + + override fun onOutput(data: ByteArray) { + screen.feed(data) + _version.value++ + } +} diff --git a/android/app/src/main/java/com/parallelcode/phone/TerminalScreen.kt b/android/app/src/main/java/com/parallelcode/phone/TerminalScreen.kt new file mode 100644 index 000000000..a762251a7 --- /dev/null +++ b/android/app/src/main/java/com/parallelcode/phone/TerminalScreen.kt @@ -0,0 +1,407 @@ +package com.parallelcode.phone + +import java.nio.ByteBuffer +import java.nio.CharBuffer +import java.nio.charset.CodingErrorAction + +/** A run of characters sharing one [CellStyle]-encoded style. */ +data class StyledSpan(val text: String, val style: Long) + +/** + * VT100 screen for an agent's PTY stream. Agent TUIs redraw with cursor movement, so stripping + * escape codes would garble them; this keeps a character grid and applies the movement, erase, + * scroll and SGR (color and style) sequences they use. + */ +class TerminalScreen(cols: Int = 80, rows: Int = 24) { + var cols = cols + private set + var rows = rows + private set + + /** Set by the program (`CSI ? 2004 h`); multi-line replies must then be wrapped as a paste. */ + var bracketedPaste = false + private set + + private class Line(cols: Int, style: Long) { + val chars = CharArray(cols) { ' ' } + val styles = LongArray(cols) { style } + + fun fill(from: Int, to: Int, style: Long) { + chars.fill(' ', from, to) + styles.fill(style, from, to) + } + } + + private var grid = blankGrid() + private var savedMainGrid: Array? = null + // Lines that scrolled off never change again, so they are kept already split into style runs: + // rendering then only has to split the screen's rows. + private val history = ArrayDeque>() + private var row = 0 + private var col = 0 + private var wrapPending = false + private var scrollTop = 0 + private var scrollBottom = rows - 1 + private var savedRow = 0 + private var savedCol = 0 + private var style = CellStyle.DEFAULT + private var savedStyle = CellStyle.DEFAULT + + private var state = State.GROUND + private val sequence = StringBuilder() + private val decoder = Charsets.UTF_8.newDecoder() + .onMalformedInput(CodingErrorAction.REPLACE) + .onUnmappableCharacter(CodingErrorAction.REPLACE) + private var undecoded = ByteArray(0) + + private enum class State { GROUND, ESCAPE, ESCAPE_CHARSET, CSI, STRING, STRING_ESCAPE } + + /** Replace everything with a server scrollback snapshot. */ + fun reset(cols: Int, rows: Int, data: ByteArray) { + this.cols = cols.coerceIn(1, 500) + this.rows = rows.coerceIn(1, 300) + style = CellStyle.DEFAULT + savedStyle = CellStyle.DEFAULT + grid = blankGrid() + savedMainGrid = null + history.clear() + row = 0 + col = 0 + wrapPending = false + scrollTop = 0 + scrollBottom = this.rows - 1 + savedRow = 0 + savedCol = 0 + bracketedPaste = false + state = State.GROUND + sequence.clear() + decoder.reset() + undecoded = ByteArray(0) + feed(data) + } + + /** + * Change the screen size in place, as a terminal window does when the PTY is resized for this + * phone. When rows shrink, lines above the cursor scroll into history so the cursor stays put. + */ + fun resize(cols: Int, rows: Int) { + val newCols = cols.coerceIn(1, 500) + val newRows = rows.coerceIn(1, 300) + if (newCols == this.cols && newRows == this.rows) return + val shift = (row + 1 - newRows).coerceAtLeast(0) + if (savedMainGrid == null) { + for (r in 0 until shift) { + history.addLast(spans(grid[r])) + if (history.size > MAX_HISTORY) history.removeFirst() + } + } + fun refit(old: Array, from: Int) = Array(newRows) { r -> + val line = Line(newCols, CellStyle.DEFAULT) + old.getOrNull(from + r)?.let { src -> + val n = minOf(newCols, src.chars.size) + src.chars.copyInto(line.chars, 0, 0, n) + src.styles.copyInto(line.styles, 0, 0, n) + } + line + } + grid = refit(grid, shift) + savedMainGrid = savedMainGrid?.let { refit(it, 0) } + this.cols = newCols + this.rows = newRows + scrollTop = 0 + scrollBottom = newRows - 1 + moveTo(row - shift, col) + savedRow = savedRow.coerceIn(0, newRows - 1) + savedCol = savedCol.coerceIn(0, newCols - 1) + } + + fun feed(data: ByteArray) { + // A multi-byte character can be split across chunks; carry the tail to the next call. + val input = ByteBuffer.wrap(undecoded + data) + val out = CharBuffer.allocate(input.remaining() + 1) + decoder.decode(input, out, false) + undecoded = ByteArray(input.remaining()).also { input.get(it) } + out.flip() + while (out.hasRemaining()) process(out.get()) + } + + /** Scrolled-off history followed by the screen, as plain text. */ + fun text(): String = styledLines().joinToString("\n") { line -> line.joinToString("") { it.text } } + + /** History then screen, each line split into style runs, without trailing blank lines. */ + fun styledLines(): List> { + val lines = ArrayList>(history.size + rows) + lines.addAll(history) + grid.mapTo(lines, ::spans) + while (lines.isNotEmpty() && lines.last().isEmpty()) lines.removeAt(lines.lastIndex) + return lines + } + + private fun spans(line: Line): List { + // Trailing blanks only matter when they are painted with a background color. + var end = line.chars.size + while (end > 0 && line.chars[end - 1] == ' ' && CellStyle.bg(line.styles[end - 1]) == CellStyle.DEFAULT_COLOR) end-- + val spans = ArrayList() + var start = 0 + while (start < end) { + var stop = start + 1 + while (stop < end && line.styles[stop] == line.styles[start]) stop++ + spans.add(StyledSpan(String(line.chars, start, stop - start), line.styles[start])) + start = stop + } + return spans + } + + private fun blankGrid() = Array(rows) { Line(cols, CellStyle.DEFAULT) } + + /** Erased and scrolled-in cells take the current background, as in xterm. */ + private fun blankLine() = Line(cols, CellStyle.eraseStyle(style)) + + private fun process(c: Char) { + when (state) { + State.GROUND -> ground(c) + State.ESCAPE -> escape(c) + State.ESCAPE_CHARSET -> state = State.GROUND + State.CSI -> { + if (c in '@'..'~') { + state = State.GROUND + csi(sequence.toString(), c) + } else if (sequence.length < 128) { + sequence.append(c) + } + } + // OSC, DCS and similar strings end with BEL or ESC \; nothing in them is shown. + State.STRING -> when (c) { + '\u0007' -> state = State.GROUND + '\u001b' -> state = State.STRING_ESCAPE + } + State.STRING_ESCAPE -> state = if (c == '\\') State.GROUND else State.STRING + } + } + + private fun ground(c: Char) { + when (c) { + '\u001b' -> state = State.ESCAPE + '\r' -> carriageReturn() + '\n', '\u000b', '\u000c' -> lineFeed() + '\b' -> { + wrapPending = false + if (col > 0) col-- + } + '\t' -> { + wrapPending = false + col = minOf(cols - 1, (col / 8 + 1) * 8) + } + else -> if (c >= ' ' && c != '\u007f') put(c) + } + } + + private fun escape(c: Char) { + state = State.GROUND + when (c) { + '[' -> { + sequence.clear() + state = State.CSI + } + ']', 'P', '_', '^', 'X' -> state = State.STRING + '(', ')', '*', '+', '#', '%' -> state = State.ESCAPE_CHARSET + '7' -> saveCursor() + '8' -> restoreCursor() + 'D' -> lineFeed() + 'E' -> { + carriageReturn() + lineFeed() + } + 'M' -> reverseIndex() + 'c' -> reset(cols, rows, ByteArray(0)) + } + } + + private fun put(c: Char) { + if (wrapPending) { + carriageReturn() + lineFeed() + } + grid[row].chars[col] = c + grid[row].styles[col] = style + if (col == cols - 1) wrapPending = true else col++ + } + + private fun carriageReturn() { + col = 0 + wrapPending = false + } + + private fun lineFeed() { + wrapPending = false + if (row == scrollBottom) scrollUp(1) else if (row < rows - 1) row++ + } + + private fun reverseIndex() { + wrapPending = false + if (row == scrollTop) scrollDown(1) else if (row > 0) row-- + } + + private fun scrollUp(n: Int) { + repeat(n.coerceAtMost(scrollBottom - scrollTop + 1)) { + // Lines scrolling off the top row move into history so agent TUIs with pinned status bars can be scrolled. + if (scrollTop == 0 && savedMainGrid == null) { + history.addLast(spans(grid[0])) + if (history.size > MAX_HISTORY) history.removeFirst() + } + for (r in scrollTop until scrollBottom) grid[r] = grid[r + 1] + grid[scrollBottom] = blankLine() + } + } + + private fun scrollDown(n: Int) { + repeat(n.coerceAtMost(scrollBottom - scrollTop + 1)) { + for (r in scrollBottom downTo scrollTop + 1) grid[r] = grid[r - 1] + grid[scrollTop] = blankLine() + } + } + + private fun saveCursor() { + savedRow = row + savedCol = col + savedStyle = style + } + + private fun restoreCursor() { + moveTo(savedRow, savedCol) + style = savedStyle + } + + private fun moveTo(r: Int, c: Int) { + row = r.coerceIn(0, rows - 1) + col = c.coerceIn(0, cols - 1) + wrapPending = false + } + + private fun csi(body: String, final: Char) { + if (final == 'm' && !body.startsWith('?') && !body.startsWith('>')) { + style = CellStyle.applySgr(style, body) + return + } + val private = body.startsWith('?') + val params = body.trimStart('?', '>', '<', '=') + .takeWhile { it.isDigit() || it == ';' } + .split(';') + .map { it.toIntOrNull() ?: 0 } + fun arg(i: Int, default: Int = 1) = params.getOrNull(i)?.takeIf { it != 0 } ?: default + fun rawArg(i: Int) = params.getOrNull(i) ?: 0 + + if (private) { + if (final == 'h' || final == 'l') params.forEach { setMode(it, final == 'h') } + return + } + val line = grid[row] + val erase = CellStyle.eraseStyle(style) + when (final) { + 'A' -> moveTo(maxOf(row - arg(0), if (row >= scrollTop) scrollTop else 0), col) + 'B' -> moveTo(minOf(row + arg(0), if (row <= scrollBottom) scrollBottom else rows - 1), col) + 'C' -> moveTo(row, col + arg(0)) + 'D' -> moveTo(row, col - arg(0)) + 'E' -> moveTo(row + arg(0), 0) + 'F' -> moveTo(row - arg(0), 0) + 'G', '`' -> moveTo(row, arg(0) - 1) + 'd' -> moveTo(arg(0) - 1, col) + 'H', 'f' -> moveTo(arg(0) - 1, arg(1) - 1) + 'J' -> eraseInDisplay(rawArg(0)) + 'K' -> eraseInLine(rawArg(0)) + 'L' -> if (row in scrollTop..scrollBottom) insertLines(arg(0)) + 'M' -> if (row in scrollTop..scrollBottom) deleteLines(arg(0)) + '@' -> { + val n = arg(0).coerceAtMost(cols - col) + for (c in cols - 1 downTo col + n) { + line.chars[c] = line.chars[c - n] + line.styles[c] = line.styles[c - n] + } + line.fill(col, col + n, erase) + } + 'P' -> { + val n = arg(0).coerceAtMost(cols - col) + for (c in col until cols - n) { + line.chars[c] = line.chars[c + n] + line.styles[c] = line.styles[c + n] + } + line.fill(cols - n, cols, erase) + } + 'X' -> line.fill(col, minOf(cols, col + arg(0)), erase) + 'S' -> scrollUp(arg(0)) + 'T' -> scrollDown(arg(0)) + 'r' -> { + val top = arg(0) - 1 + val bottom = arg(1, rows) - 1 + if (top < bottom && bottom < rows) { + scrollTop = top + scrollBottom = bottom + moveTo(0, 0) + } + } + 's' -> saveCursor() + 'u' -> restoreCursor() + } + } + + private fun setMode(mode: Int, on: Boolean) { + when (mode) { + 2004 -> bracketedPaste = on + 47, 1047, 1049 -> { + if (on && savedMainGrid == null) { + if (mode == 1049) saveCursor() + savedMainGrid = grid + grid = blankGrid() + } else if (!on) { + savedMainGrid?.let { grid = it } + savedMainGrid = null + if (mode == 1049) restoreCursor() + } + } + } + } + + private fun eraseInDisplay(mode: Int) { + when (mode) { + 0 -> { + eraseInLine(0) + for (r in row + 1 until rows) grid[r] = blankLine() + } + 1 -> { + eraseInLine(1) + for (r in 0 until row) grid[r] = blankLine() + } + 2 -> for (r in 0 until rows) grid[r] = blankLine() + 3 -> history.clear() + } + } + + private fun eraseInLine(mode: Int) { + val erase = CellStyle.eraseStyle(style) + when (mode) { + 0 -> grid[row].fill(col, cols, erase) + 1 -> grid[row].fill(0, col + 1, erase) + 2 -> grid[row].fill(0, cols, erase) + } + } + + private fun insertLines(n: Int) { + repeat(n.coerceAtMost(scrollBottom - row + 1)) { + for (r in scrollBottom downTo row + 1) grid[r] = grid[r - 1] + grid[row] = blankLine() + } + col = 0 + } + + private fun deleteLines(n: Int) { + repeat(n.coerceAtMost(scrollBottom - row + 1)) { + for (r in row until scrollBottom) grid[r] = grid[r + 1] + grid[scrollBottom] = blankLine() + } + col = 0 + } + + private companion object { + const val MAX_HISTORY = 5000 + } +} diff --git a/android/app/src/main/java/com/parallelcode/phone/TerminalStyle.kt b/android/app/src/main/java/com/parallelcode/phone/TerminalStyle.kt new file mode 100644 index 000000000..1578296c8 --- /dev/null +++ b/android/app/src/main/java/com/parallelcode/phone/TerminalStyle.kt @@ -0,0 +1,182 @@ +package com.parallelcode.phone + +/** + * A cell's SGR state packed into a Long so the screen stores one primitive per cell. + * Bits 0–25: foreground, 26–51: background, 52+: flags. A color is [DEFAULT_COLOR], a palette + * index (0–255), or [RGB_FLAG] plus a 24-bit RGB value. + */ +object CellStyle { + const val DEFAULT_COLOR = 0x3FFFFFF + const val RGB_FLAG = 0x1000000 + private const val COLOR_BITS = 26 + private const val FLAG_SHIFT = 52 + + const val BOLD = 1 + const val DIM = 2 + const val ITALIC = 4 + const val UNDERLINE = 8 + const val INVERSE = 16 + const val STRIKE = 32 + const val HIDDEN = 64 + + const val DEFAULT: Long = DEFAULT_COLOR.toLong() or (DEFAULT_COLOR.toLong() shl COLOR_BITS) + + fun fg(style: Long): Int = (style and DEFAULT_COLOR.toLong()).toInt() + fun bg(style: Long): Int = ((style shr COLOR_BITS) and DEFAULT_COLOR.toLong()).toInt() + fun flags(style: Long): Int = (style ushr FLAG_SHIFT).toInt() + + fun of(fg: Int, bg: Int, flags: Int): Long = + fg.toLong() or (bg.toLong() shl COLOR_BITS) or (flags.toLong() shl FLAG_SHIFT) + + /** Erased cells keep only the background, as xterm does. */ + fun eraseStyle(style: Long): Long = of(DEFAULT_COLOR, bg(style), 0) + + /** Apply one `CSI … m` body, in both `;` and `:` sub-parameter forms. */ + fun applySgr(style: Long, body: String): Long { + var fg = fg(style) + var bg = bg(style) + var flags = flags(style) + val groups = body.split(';') + var i = 0 + while (i < groups.size) { + val group = groups[i] + if (':' in group) { + val sub = group.split(':').map { it.toIntOrNull() } + when (sub[0]) { + 38 -> extendedColor(sub.drop(1))?.let { fg = it } + 48 -> extendedColor(sub.drop(1))?.let { bg = it } + 4 -> flags = if (sub.getOrNull(1) == 0) flags and UNDERLINE.inv() else flags or UNDERLINE + } + i++ + continue + } + val code = group.toIntOrNull() ?: 0 + if (code == 38 || code == 48) { + // `38;5;n` or `38;2;r;g;b` + val mode = groups.getOrNull(i + 1)?.toIntOrNull() + val argCount = when (mode) { + 5 -> 1 + 2 -> 3 + else -> 0 + } + val args = groups.subList(minOf(i + 1, groups.size), minOf(i + 2 + argCount, groups.size)) + .map { it.toIntOrNull() } + extendedColor(args)?.let { if (code == 38) fg = it else bg = it } + i += 2 + argCount + continue + } + when (code) { + 0 -> { + fg = DEFAULT_COLOR + bg = DEFAULT_COLOR + flags = 0 + } + 1 -> flags = flags or BOLD + 2 -> flags = flags or DIM + 3 -> flags = flags or ITALIC + 4 -> flags = flags or UNDERLINE + 7 -> flags = flags or INVERSE + 8 -> flags = flags or HIDDEN + 9 -> flags = flags or STRIKE + 21, 22 -> flags = flags and (BOLD or DIM).inv() + 23 -> flags = flags and ITALIC.inv() + 24 -> flags = flags and UNDERLINE.inv() + 27 -> flags = flags and INVERSE.inv() + 28 -> flags = flags and HIDDEN.inv() + 29 -> flags = flags and STRIKE.inv() + in 30..37 -> fg = code - 30 + 39 -> fg = DEFAULT_COLOR + in 40..47 -> bg = code - 40 + 49 -> bg = DEFAULT_COLOR + in 90..97 -> fg = code - 90 + 8 + in 100..107 -> bg = code - 100 + 8 + } + i++ + } + return of(fg, bg, flags) + } + + /** `[5, n]` or `[2, r, g, b]`; the colon form may put a color-space id before r, g, b. */ + private fun extendedColor(args: List): Int? = when (args.getOrNull(0)) { + 5 -> args.getOrNull(1)?.takeIf { it in 0..255 } + 2 -> { + val rgb = (if (args.size >= 5) args.takeLast(3) else args.drop(1)) + .filterNotNull() + .filter { it in 0..255 } + if (rgb.size == 3) RGB_FLAG or (rgb[0] shl 16) or (rgb[1] shl 8) or rgb[2] else null + } + else -> null + } +} + +/** Colors for one span, as ARGB. `background` is null where the terminal background shows. */ +data class ResolvedStyle( + val foreground: Int, + val background: Int?, + val bold: Boolean, + val italic: Boolean, + val underline: Boolean, + val strike: Boolean, +) + +/** The desktop's terminal colors (`src/lib/theme.ts`) for its default Obsidian presets. */ +class TerminalPalette(val foreground: Int, val background: Int, private val ansi: IntArray) { + fun resolve(style: Long): ResolvedStyle { + val flags = CellStyle.flags(style) + val bold = flags and CellStyle.BOLD != 0 + var fg = CellStyle.fg(style).let { c -> + when { + c == CellStyle.DEFAULT_COLOR -> foreground + // Like xterm.js, bold text in the first eight colors uses their bright variant. + bold && c < 8 -> ansi[c + 8] + else -> color(c) + } + } + var bg = CellStyle.bg(style).takeIf { it != CellStyle.DEFAULT_COLOR }?.let(::color) + if (flags and CellStyle.INVERSE != 0) { + val swapped = bg ?: background + bg = fg + fg = swapped + } + if (flags and CellStyle.DIM != 0) fg = (fg and 0xFFFFFF) or (0x80 shl 24) + if (flags and CellStyle.HIDDEN != 0) fg = bg ?: background + return ResolvedStyle( + foreground = fg, + background = bg, + bold = bold, + italic = flags and CellStyle.ITALIC != 0, + underline = flags and CellStyle.UNDERLINE != 0, + strike = flags and CellStyle.STRIKE != 0, + ) + } + + private fun color(c: Int): Int = OPAQUE or when { + c and CellStyle.RGB_FLAG != 0 -> c and 0xFFFFFF + c < 16 -> ansi[c] + c < 232 -> { + val i = c - 16 + (CUBE[i / 36] shl 16) or (CUBE[i / 6 % 6] shl 8) or CUBE[i % 6] + } + else -> (8 + (c - 232) * 10).let { (it shl 16) or (it shl 8) or it } + } + + companion object { + private const val OPAQUE = 0xFF shl 24 + private val CUBE = intArrayOf(0, 95, 135, 175, 215, 255) + + private fun palette(fg: Int, bg: Int, vararg ansi: Int) = + TerminalPalette(OPAQUE or fg, OPAQUE or bg, IntArray(16) { OPAQUE or ansi[it] }) + + val OBSIDIAN = palette( + 0xe4e4e4, 0x1e1e1e, + 0x2e2e2e, 0xe08c96, 0x98c9ae, 0xdfc18e, 0x8fb3dc, 0xc1b0e8, 0x8ec9c9, 0xc9c9c9, + 0x858585, 0xeaa0aa, 0xaddcc1, 0xead3a8, 0xa8c5e8, 0xd2c4f0, 0xa6dada, 0xededed, + ) + + val OBSIDIAN_LIGHT = palette( + 0x1f2329, 0xffffff, + 0x24292e, 0xcf222e, 0x116329, 0x8a6d00, 0x0550ae, 0x8250df, 0x1b7c83, 0x6e7781, + 0x57606a, 0xa40e26, 0x1a7f37, 0x633c01, 0x0969da, 0x6639ba, 0x3192aa, 0x1f2329, + ) + } +} diff --git a/android/app/src/main/java/com/parallelcode/phone/Theme.kt b/android/app/src/main/java/com/parallelcode/phone/Theme.kt new file mode 100644 index 000000000..800db0e7c --- /dev/null +++ b/android/app/src/main/java/com/parallelcode/phone/Theme.kt @@ -0,0 +1,186 @@ +package com.parallelcode.phone + +import androidx.compose.foundation.isSystemInDarkTheme +import androidx.compose.foundation.shape.RoundedCornerShape +import androidx.compose.material3.MaterialTheme +import androidx.compose.material3.Shapes +import androidx.compose.material3.darkColorScheme +import androidx.compose.material3.lightColorScheme +import androidx.compose.runtime.Composable +import androidx.compose.runtime.CompositionLocalProvider +import androidx.compose.runtime.Immutable +import androidx.compose.runtime.staticCompositionLocalOf +import androidx.compose.ui.graphics.Color +import androidx.compose.ui.unit.dp + +// Obsidian dark palette: the desktop's default look (html[data-look='obsidian'] in src/styles.css). +// Flat charcoal neutrals with an amber accent; "needs you" uses the distinct orange warning. +val ColorBackgroundDark = Color(0xFF171717) // --bg +val ColorSurfaceDark = Color(0xFF1E1E1E) // --task-panel-bg +val ColorSurfaceVariantDark = Color(0xFF242424) // --bg-elevated +val ColorInputBgDark = Color(0xFF262626) // --bg-input +val ColorCardBgDark = Color(0xFF1E1E1E) // --island-bg +val ColorCardBgAttentionDark = Color(0xFF2F2825) // --warning 8% over --island-bg +val ColorBorderDark = Color(0xFF333333) // --border +val ColorBorderSubtleDark = Color(0xFF292929) // --border-subtle +val ColorAttentionBorderDark = Color(0xFF885C47) // --warning 50% over --island-bg +val ColorAttentionBgDark = Color(0x33F29B70) +val ColorWarningBannerBgDark = Color(0xFF372D28) // --warning 12% over --island-bg +val ColorWarningTextDark = Color(0xFFF29B70) // --warning +val ColorAccentAmber = Color(0xFFC4A77D) // --accent +val ColorOnAccentAmber = Color(0xFF1E1B16) // --accent-text +val ColorPrimaryContainerDark = Color(0xFF39342D) // --accent 16% over --island-bg +val ColorOnPrimaryContainerDark = Color(0xFFD6BD96) // --accent-hover +val ColorInfoGrey = Color(0xFFC9C9C9) // --info +val ColorInfoContainerDark = Color(0xFF303030) // --bg-hover +val ColorOnInfoContainerDark = Color(0xFFEDEDED) +val ColorSuccessGreen = Color(0xFF98C9AE) // --success +val ColorReviewPurple = Color(0xFFC1B0E8) // --review +val ColorErrorRed = Color(0xFFEAA0AA) // --error +val ColorErrorContainerDark = Color(0xFF362E2F) // --error 12% over --island-bg +val ColorOnErrorContainerDark = Color(0xFFF5C6CC) +val ColorTextPrimaryDark = Color(0xFFEDEDED) // --fg +val ColorTextMutedDark = Color(0xFFB5B5B5) // --fg-muted +val ColorTextSubtleDark = Color(0xFF919191) // --fg-subtle + +@Immutable +data class ExtendedColors( + /** The app's own dark/light choice, which can differ from the phone's. */ + val dark: Boolean, + val border: Color, + val borderSubtle: Color, + val inputBg: Color, + val cardBg: Color, + val cardBgAttention: Color, + val attentionBorder: Color, + val attentionBg: Color, + val warningBannerBg: Color, + val warningText: Color, + val success: Color, + val review: Color, + val textPrimary: Color, + val textMuted: Color, + val textSubtle: Color, +) + +val DarkExtendedColors = ExtendedColors( + dark = true, + border = ColorBorderDark, + borderSubtle = ColorBorderSubtleDark, + inputBg = ColorInputBgDark, + cardBg = ColorCardBgDark, + cardBgAttention = ColorCardBgAttentionDark, + attentionBorder = ColorAttentionBorderDark, + attentionBg = ColorAttentionBgDark, + warningBannerBg = ColorWarningBannerBgDark, + warningText = ColorWarningTextDark, + success = ColorSuccessGreen, + review = ColorReviewPurple, + textPrimary = ColorTextPrimaryDark, + textMuted = ColorTextMutedDark, + textSubtle = ColorTextSubtleDark, +) + +// Obsidian Light palette: html[data-look='obsidian-light'] in src/styles.css. Warm paper neutrals, +// bronze accent; status hues darkened for AA contrast on white. +val LightExtendedColors = ExtendedColors( + dark = false, + border = Color(0xFFDDDCD8), // --border + borderSubtle = Color(0xFFE9E8E4), // --border-subtle + inputBg = Color(0xFFF2F2F0), // --bg-input + cardBg = Color(0xFFFFFFFF), // --island-bg + cardBgAttention = Color(0xFFFBF3ED), // --warning 6% over white + attentionBorder = Color(0xFFD9A57F), // --warning 45% over white + attentionBg = Color(0x22AD4E00), + warningBannerBg = Color(0xFFFAEFE6), // --warning 10% over white + warningText = Color(0xFFAD4E00), // --warning + success = Color(0xFF2F7D4F), // --success + review = Color(0xFF6D4FC2), // --review + textPrimary = Color(0xFF1F1F1F), // --fg + textMuted = Color(0xFF555555), // --fg-muted + textSubtle = Color(0xFF6E6E6E), // --fg-subtle +) + +val LocalExtendedColors = staticCompositionLocalOf { DarkExtendedColors } + +val ParallelCodeDarkColorScheme = darkColorScheme( + primary = ColorAccentAmber, + onPrimary = ColorOnAccentAmber, + primaryContainer = ColorPrimaryContainerDark, + onPrimaryContainer = ColorOnPrimaryContainerDark, + secondary = ColorInfoGrey, + onSecondary = ColorBackgroundDark, + secondaryContainer = ColorInfoContainerDark, + onSecondaryContainer = ColorOnInfoContainerDark, + tertiary = ColorReviewPurple, + onTertiary = Color(0xFF231A38), + background = ColorBackgroundDark, + onBackground = ColorTextPrimaryDark, + surface = ColorSurfaceDark, + onSurface = ColorTextPrimaryDark, + surfaceVariant = ColorSurfaceVariantDark, + onSurfaceVariant = ColorTextMutedDark, + outline = ColorBorderDark, + outlineVariant = ColorBorderSubtleDark, + error = ColorErrorRed, + onError = Color(0xFF3A1519), + errorContainer = ColorErrorContainerDark, + onErrorContainer = ColorOnErrorContainerDark, +) + +val ParallelCodeLightColorScheme = lightColorScheme( + primary = Color(0xFF8A6433), // --accent + onPrimary = Color.White, // --accent-text + primaryContainer = Color(0xFFF1ECE6), // --accent 12% over white + onPrimaryContainer = Color(0xFF75532A), // --accent-hover + secondary = Color(0xFF5E5E5E), // --info + onSecondary = Color.White, + secondaryContainer = Color(0xFFEBEBE8), // --bg-hover + onSecondaryContainer = Color(0xFF1F1F1F), + tertiary = Color(0xFF6D4FC2), // --review + onTertiary = Color.White, + background = Color(0xFFF4F4F2), // --bg + onBackground = Color(0xFF1F1F1F), + surface = Color(0xFFFFFFFF), // --bg-elevated + onSurface = Color(0xFF1F1F1F), + surfaceVariant = Color(0xFFEBEBE8), + onSurfaceVariant = Color(0xFF555555), + outline = Color(0xFFDDDCD8), + outlineVariant = Color(0xFFE9E8E4), + error = Color(0xFFB3383F), // --error + onError = Color.White, + errorContainer = Color(0xFFF7E9EA), + onErrorContainer = Color(0xFF7D2328), +) + +// Obsidian is square-edged in both variants: only small controls keep a slight rounding. +val ObsidianShapes = Shapes( + extraSmall = RoundedCornerShape(4.dp), + small = RoundedCornerShape(4.dp), + medium = RoundedCornerShape(0.dp), + large = RoundedCornerShape(0.dp), + extraLarge = RoundedCornerShape(0.dp), +) + +@Composable +fun ParallelCodeTheme( + darkTheme: Boolean = isSystemInDarkTheme(), + content: @Composable () -> Unit, +) { + val colorScheme = if (darkTheme) ParallelCodeDarkColorScheme else ParallelCodeLightColorScheme + val extendedColors = if (darkTheme) DarkExtendedColors else LightExtendedColors + + CompositionLocalProvider(LocalExtendedColors provides extendedColors) { + MaterialTheme( + colorScheme = colorScheme, + shapes = ObsidianShapes, + content = content, + ) + } +} + +object AppTheme { + val extra: ExtendedColors + @Composable + get() = LocalExtendedColors.current +} diff --git a/android/app/src/main/java/com/parallelcode/phone/Usage.kt b/android/app/src/main/java/com/parallelcode/phone/Usage.kt new file mode 100644 index 000000000..d22dd9268 --- /dev/null +++ b/android/app/src/main/java/com/parallelcode/phone/Usage.kt @@ -0,0 +1,258 @@ +package com.parallelcode.phone + +import androidx.compose.foundation.BorderStroke +import androidx.compose.foundation.background +import androidx.compose.foundation.border +import androidx.compose.foundation.clickable +import androidx.compose.foundation.layout.Arrangement +import androidx.compose.foundation.layout.Box +import androidx.compose.foundation.layout.Column +import androidx.compose.foundation.layout.Row +import androidx.compose.foundation.layout.fillMaxHeight +import androidx.compose.foundation.layout.fillMaxWidth +import androidx.compose.foundation.layout.height +import androidx.compose.foundation.layout.padding +import androidx.compose.foundation.layout.width +import androidx.compose.animation.core.FastOutSlowInEasing +import androidx.compose.animation.core.tween +import androidx.compose.animation.core.Animatable +import androidx.compose.foundation.shape.RoundedCornerShape +import androidx.compose.material3.Card +import androidx.compose.material3.CardDefaults +import androidx.compose.material3.MaterialTheme +import androidx.compose.material3.Text +import androidx.compose.runtime.Composable +import androidx.compose.runtime.LaunchedEffect +import androidx.compose.runtime.getValue +import androidx.compose.runtime.mutableIntStateOf +import androidx.compose.runtime.mutableStateOf +import androidx.compose.runtime.remember +import androidx.compose.runtime.setValue +import androidx.compose.ui.Alignment +import androidx.compose.ui.Modifier +import androidx.compose.ui.draw.clip +import androidx.compose.ui.graphics.Color +import androidx.compose.ui.semantics.contentDescription +import androidx.compose.ui.semantics.semantics +import androidx.compose.ui.text.font.FontWeight +import androidx.compose.ui.unit.dp +import kotlinx.coroutines.delay +import org.json.JSONObject +import java.time.Instant +import java.time.ZoneId +import java.time.format.DateTimeFormatter +import java.util.Locale +import kotlin.math.roundToInt + +// Mirrors UsageState in electron/ipc/shared-types.ts and the helpers in src/components/usage-format.ts. + +data class UsageWindow(val usedPercent: Double, val resetsAt: Long?) { + val remainingPercent: Int + get() = maxOf(0, (100 - usedPercent).roundToInt()) + val warn: Boolean + get() = usedPercent >= USAGE_WARN_PERCENT +} + +data class ProviderUsage( + val label: String, + val fiveHour: UsageWindow?, + val sevenDay: UsageWindow?, + /** `error` keeps the last snapshot but marks it stale. */ + val status: String, + val error: String?, +) { + val hasSnapshot: Boolean + get() = fiveHour != null || sevenDay != null +} + +/** Past this share of a window, the meter turns amber. */ +private const val USAGE_WARN_PERCENT = 80 + +private val PROVIDERS = listOf( + "claude" to "Claude", + "codex" to "Codex", + "antigravity" to "Antigravity", +) + +/** + * Providers the desktop status bar would show: those with a snapshot, plus those whose refresh + * failed so the reader sees why the meter stopped moving. + */ +fun parseUsage(json: JSONObject): List { + val result = mutableListOf() + val seen = mutableSetOf() + + for ((key, label) in PROVIDERS) { + val p = json.optJSONObject(key) ?: continue + seen.add(key) + val usage = ProviderUsage( + label = label, + fiveHour = p.optJSONObject("fiveHour")?.let(::parseWindow), + sevenDay = p.optJSONObject("sevenDay")?.let(::parseWindow), + status = p.optString("status"), + error = if (p.isNull("error")) null else p.optString("error"), + ) + if (usage.hasSnapshot || usage.status == "error") { + result.add(usage) + } + } + + for (key in json.keys()) { + if (key in seen) continue + val p = json.optJSONObject(key) ?: continue + val label = when (key.lowercase(Locale.ROOT)) { + "antigravity", "agy" -> "Antigravity" + else -> key.replaceFirstChar { if (it.isLowerCase()) it.titlecase(Locale.getDefault()) else it.toString() } + } + val usage = ProviderUsage( + label = label, + fiveHour = p.optJSONObject("fiveHour")?.let(::parseWindow), + sevenDay = p.optJSONObject("sevenDay")?.let(::parseWindow), + status = p.optString("status"), + error = if (p.isNull("error")) null else p.optString("error"), + ) + if (usage.hasSnapshot || usage.status == "error") { + result.add(usage) + } + } + + return result +} + +private fun parseWindow(w: JSONObject) = UsageWindow( + usedPercent = w.optDouble("usedPercent", 0.0), + resetsAt = if (w.isNull("resetsAt")) null else w.optLong("resetsAt").takeIf { it > 0 }, +) + +/** "resets 14:30" today, "resets Thu 09:00" otherwise, "reset due" once passed, "" when unknown. */ +fun formatReset( + resetsAt: Long?, + now: Long = System.currentTimeMillis(), + zone: ZoneId = ZoneId.systemDefault(), + locale: Locale = Locale.getDefault(), +): String { + if (resetsAt == null) return "" + if (resetsAt <= now) return "reset due" + val at = Instant.ofEpochMilli(resetsAt).atZone(zone) + val time = at.format(DateTimeFormatter.ofPattern("HH:mm", locale)) + if (at.toLocalDate() == Instant.ofEpochMilli(now).atZone(zone).toLocalDate()) return "resets $time" + return "resets ${at.format(DateTimeFormatter.ofPattern("EEE", locale))} $time" +} + +// The desktop polls the usage endpoints itself; this only re-reads its snapshot. +private const val USAGE_POLL_MS = 60_000L + +/** The desktop status bar's subscription meters. Hidden until the desktop has a snapshot. */ +@Composable +fun UsageStrip(client: RemoteClient, connected: Boolean) { + var usage by remember { mutableStateOf>(emptyList()) } + var refresh by remember { mutableIntStateOf(0) } + // Reads on every (re)connect and tap, then once a minute. + LaunchedEffect(connected, refresh) { + while (connected) { + try { + usage = client.fetchUsage() + } catch (_: ApiException) { + // Keep the last snapshot; the connection status already reports outages. + } + delay(USAGE_POLL_MS) + } + } + if (usage.isEmpty()) return + Card( + modifier = Modifier + .fillMaxWidth() + .padding(horizontal = 16.dp) + .semantics { contentDescription = "Agent usage, tap to refresh" } + .clickable { refresh++ }, + shape = MaterialTheme.shapes.large, + colors = CardDefaults.cardColors(containerColor = MaterialTheme.colorScheme.surface), + border = BorderStroke(1.dp, AppTheme.extra.border), + ) { + Column(Modifier.padding(14.dp), verticalArrangement = Arrangement.spacedBy(8.dp)) { + usage.forEach { provider -> + val stale = provider.status == "error" + Column(verticalArrangement = Arrangement.spacedBy(6.dp)) { + Text( + provider.label.uppercase(), + fontWeight = FontWeight.Bold, + style = MaterialTheme.typography.labelSmall, + color = AppTheme.extra.textMuted, + ) + provider.fiveHour?.let { UsageMeter("5h", it, stale) } + provider.sevenDay?.let { UsageMeter("7d", it, stale) } + if (!provider.hasSnapshot) { + Text( + "usage unavailable · ${provider.error.orEmpty()}", + style = MaterialTheme.typography.bodySmall, + color = AppTheme.extra.textSubtle, + ) + } + } + } + } + } +} + +@Composable +private fun UsageMeter(label: String, window: UsageWindow, stale: Boolean) { + val left = window.remainingPercent + val isCritical = left <= 5 || window.usedPercent >= 95.0 + val fill = when { + stale -> AppTheme.extra.textSubtle + isCritical -> MaterialTheme.colorScheme.error + window.warn -> AppTheme.extra.warningText + else -> MaterialTheme.colorScheme.primary + } + // Fills from empty on first show, then eases between snapshots. + val target = (left / 100f).coerceIn(0f, 1f) + val progress = remember { Animatable(0f) } + LaunchedEffect(target) { progress.animateTo(target, tween(durationMillis = 700, easing = FastOutSlowInEasing)) } + val animatedProgress = progress.value + val textColor = when { + stale -> AppTheme.extra.textSubtle + isCritical -> MaterialTheme.colorScheme.error + window.warn -> AppTheme.extra.warningText + else -> MaterialTheme.colorScheme.onSurface + } + Row(verticalAlignment = Alignment.CenterVertically) { + Text( + label, + Modifier.width(28.dp), + style = MaterialTheme.typography.bodySmall, + color = AppTheme.extra.textMuted, + ) + Box( + Modifier + .weight(1f) + .height(6.dp) + .clip(RoundedCornerShape(3.dp)) + .background(MaterialTheme.colorScheme.background) + .border(BorderStroke(1.dp, AppTheme.extra.border), RoundedCornerShape(3.dp)) + .semantics { contentDescription = "$label window $left% remaining" }, + ) { + Box( + Modifier + .fillMaxHeight() + .fillMaxWidth(animatedProgress) + .background(fill), + ) + } + Row(Modifier.padding(start = 8.dp), verticalAlignment = Alignment.CenterVertically) { + Text( + "$left% left", + style = MaterialTheme.typography.bodySmall, + fontWeight = FontWeight.Medium, + color = textColor, + ) + val reset = if (window.remainingPercent == 100) "" else formatReset(window.resetsAt) + if (reset.isNotEmpty()) { + Text( + " · $reset", + style = MaterialTheme.typography.bodySmall, + color = AppTheme.extra.textMuted, + ) + } + } + } +} diff --git a/android/app/src/main/res/drawable/ic_launcher_foreground.xml b/android/app/src/main/res/drawable/ic_launcher_foreground.xml new file mode 100644 index 000000000..66a93f28d --- /dev/null +++ b/android/app/src/main/res/drawable/ic_launcher_foreground.xml @@ -0,0 +1,11 @@ + + + + + diff --git a/android/app/src/main/res/drawable/ic_launcher_monochrome.xml b/android/app/src/main/res/drawable/ic_launcher_monochrome.xml new file mode 100644 index 000000000..fada78150 --- /dev/null +++ b/android/app/src/main/res/drawable/ic_launcher_monochrome.xml @@ -0,0 +1,11 @@ + + + + + diff --git a/android/app/src/main/res/drawable/ic_notification.xml b/android/app/src/main/res/drawable/ic_notification.xml new file mode 100644 index 000000000..70adfa449 --- /dev/null +++ b/android/app/src/main/res/drawable/ic_notification.xml @@ -0,0 +1,11 @@ + + + + + diff --git a/android/app/src/main/res/drawable/widget_background.xml b/android/app/src/main/res/drawable/widget_background.xml new file mode 100644 index 000000000..9684d0759 --- /dev/null +++ b/android/app/src/main/res/drawable/widget_background.xml @@ -0,0 +1,7 @@ + + + + + + + diff --git a/android/app/src/main/res/layout/widget_agents.xml b/android/app/src/main/res/layout/widget_agents.xml new file mode 100644 index 000000000..252eaa395 --- /dev/null +++ b/android/app/src/main/res/layout/widget_agents.xml @@ -0,0 +1,50 @@ + + + + + + + + + + + + + + diff --git a/android/app/src/main/res/mipmap-anydpi-v26/ic_launcher.xml b/android/app/src/main/res/mipmap-anydpi-v26/ic_launcher.xml new file mode 100644 index 000000000..c78bee3b5 --- /dev/null +++ b/android/app/src/main/res/mipmap-anydpi-v26/ic_launcher.xml @@ -0,0 +1,6 @@ + + + + + + diff --git a/android/app/src/main/res/mipmap-xxhdpi/ic_launcher.png b/android/app/src/main/res/mipmap-xxhdpi/ic_launcher.png new file mode 100644 index 0000000000000000000000000000000000000000..0b02e9d40f46536866b8907bbdfd5342f0351a26 GIT binary patch literal 3383 zcmd^Bdo+~m8h>X7gTc_nh-#*7q>~d}oY;ifa+`{AI_)kvs8xverm$Ux@yYH;L`Kxk zb%&KYF4YiaVk9BCG@Vjnj7yAW%nZhiF_`m>z1Qlm{pUHSwa&NJx1R5LzUO(~-+G_t zeSUA6x0k!Fwy`zp>v}So5S!zT^9nxZZ*)D|>xWC(o|XA! zCC-f`Vc=6|DXG4!;q+G(=~xy!7(Z7C(PXklq_aV+7{@}uG6IXROvnNWm} z^;0+w&&l_lpN#2|S+oVTAFw;f-b(g08`?7ENXC-m53?C`DcKmIJ8dH6HVxiTP8@Z9VNmCqzDn0Wuxxtm)M)!)sS674;mW2_guS?<~8(pLC zugRo({@%)B~(zS6EV@br5{IpYRo-_VDR3KFp?uIl0KcM zLKrm(x5p!3Pft=K(~0GZh<|AMtQbSdmUuwv2nny3?iIOdWe?F|J@*^nKn=A`_w0|? zb1K=k@~5exDm|q!8HL}QY zjy(^ejs)D2j0_W|a)Ff&WH^7dWhr+lZHP5C1FW)dZpgMY12GujLt6}&8m{VYnA8b%vot*xAPQ2ep_ zJ+H46%KzygMJ3%|`UhpS?x`$QW?iUwHQi{w+r}+5WWjObtr zzcbJzQU3K4mNp%dn)Fuu{BKAS&}8-ef?@4(r|Nu|98CQg=tD&n%Z^N*2V&u>;+j4{ z_N}cOPL0_SwLr-fU-yS2;%`e85lZK*t=O41{Hjh0&>5I=B&b0^;#Tx@fjc6clRZ&D zs$i2i>F7#I()OhjvJnDMD{-HlZpXYpiBVrv(N#3zRWD5oAb4pBikV++u*wImS5X4A z3Jj;C9*XXA-S+`aBRXYCy5JuoL z&a3daa2(~KT0usMx7!Bhq}2AKib_JJIl7KsT+mn5AS*btedJvi1> zGNuU-mo2-!K+!dY9tjPvLwI6skLZZeWOoTMc9}2^9$TQsHFKp(a0mi;UH(Cf~LEj48QSxG>zQNtw<+Acb2#L6igA#U_ z?}F!0goq4-%)A0JG)Wd}s)MYuV|qq1yjz3{MRLFUP?>L#i5Q)Z9Sn?9KxEpdvT%ThOavbb2#fG;fRE zaUjsz*|84^gFRZ*frzzb5$53Y4f(`vLvx!z$}3aAFoCe=6#PL!LvT?Xg_DnpNL{5C zh6%{fJITqZp9Fp)Mtl)Pl2X; zu9Zu&796Y+K)?BLMqtco0TfP6`q9D?pUq4uTif{Qb)Jcr?jpV#YV?L_G`E#O# z(Z$U)OaF$rQRAj8-@2dY^~lsAv<9?w#EI_Jx2FFsLIYj0Uhy#R0mpbcvjx-^*efR( z%*y#04;u`kq`V_owy`Q=$e4Qx^wEcIYgLXB9X4~@K;P}@yeQ_@+PyB+%iF+?@%7~3 zuxI=$sw-LM=k$BELLKoa2>GtryDj=qZLk)yZ-KEgi(R}|z0`sn#)5S(OX*%mO6&aD z!zgA5xMiPyr((t~MH5`;3Kl2su8NuIMUX7B$addPw3~MusVXDA<>AF$hflWrV zxAi=Q+|>u4D@g@7gPU$x%BXBfm@9I-P+o>Z`FRKW2uEVt4hYBBo-ZHy1*O!BNKXDM zNq%myZ`R^zX7r$#Jn;r$X}eRK`*?(#DsQBv*M-J + + + + diff --git a/android/app/src/main/res/values/colors.xml b/android/app/src/main/res/values/colors.xml new file mode 100644 index 000000000..939652381 --- /dev/null +++ b/android/app/src/main/res/values/colors.xml @@ -0,0 +1,4 @@ + + + #FF000000 + diff --git a/android/app/src/main/res/values/strings.xml b/android/app/src/main/res/values/strings.xml new file mode 100644 index 000000000..1a94d66df --- /dev/null +++ b/android/app/src/main/res/values/strings.xml @@ -0,0 +1,4 @@ + + + Agents that need you, and your subscription usage + diff --git a/android/app/src/main/res/values/themes.xml b/android/app/src/main/res/values/themes.xml new file mode 100644 index 000000000..6b6b76acb --- /dev/null +++ b/android/app/src/main/res/values/themes.xml @@ -0,0 +1,8 @@ + + + + diff --git a/android/app/src/main/res/xml/data_extraction_rules.xml b/android/app/src/main/res/xml/data_extraction_rules.xml new file mode 100644 index 000000000..3b83f0445 --- /dev/null +++ b/android/app/src/main/res/xml/data_extraction_rules.xml @@ -0,0 +1,10 @@ + + + + + + + + + + diff --git a/android/app/src/main/res/xml/widget_agents_info.xml b/android/app/src/main/res/xml/widget_agents_info.xml new file mode 100644 index 000000000..5d2e3b390 --- /dev/null +++ b/android/app/src/main/res/xml/widget_agents_info.xml @@ -0,0 +1,11 @@ + + diff --git a/android/app/src/test/java/com/parallelcode/phone/AgentNotifierTest.kt b/android/app/src/test/java/com/parallelcode/phone/AgentNotifierTest.kt new file mode 100644 index 000000000..c380ad595 --- /dev/null +++ b/android/app/src/test/java/com/parallelcode/phone/AgentNotifierTest.kt @@ -0,0 +1,50 @@ +package com.parallelcode.phone + +import org.junit.Assert.assertEquals +import org.junit.Assert.assertTrue +import org.junit.Test + +class AgentNotifierTest { + private fun agent(attention: String, running: Boolean = true, collapsed: Boolean = false) = RemoteAgent( + agentId = "a", + taskId = "t", + taskName = "Task", + running = running, + exitCode = null, + lastLine = "", + projectName = null, + agentName = "Claude Code", + attention = attention, + isChat = false, + collapsed = collapsed, + ) + + @Test + fun firstListIsTheBaseline() { + assertTrue(AgentNotifier().update(listOf(agent("needs_input"))).isEmpty()) + } + + @Test + fun announcesNeedsInputErrorsAndFinishing() { + val notifier = AgentNotifier() + notifier.update(listOf(agent("active"))) + assertEquals(listOf(AgentEvent.NEEDS_INPUT), notifier.update(listOf(agent("needs_input"))).map { it.event }) + assertEquals(listOf(AgentEvent.ERROR), notifier.update(listOf(agent("error"))).map { it.event }) + notifier.update(listOf(agent("active"))) + assertEquals(listOf(AgentEvent.FINISHED), notifier.update(listOf(agent("ready"))).map { it.event }) + notifier.update(listOf(agent("active"))) + assertEquals( + listOf(AgentEvent.FINISHED), + notifier.update(listOf(agent("active", running = false))).map { it.event }, + ) + } + + @Test + fun staysQuietForUnchangedMinimizedOrNewAgents() { + val notifier = AgentNotifier() + notifier.update(listOf(agent("needs_input"))) + assertTrue(notifier.update(listOf(agent("needs_input"))).isEmpty()) + assertTrue(notifier.update(listOf(agent("error", collapsed = true))).isEmpty()) + assertTrue(notifier.update(listOf(agent("active"), agent("needs_input").copy(agentId = "b"))).isEmpty()) + } +} diff --git a/android/app/src/test/java/com/parallelcode/phone/AgentWidgetTest.kt b/android/app/src/test/java/com/parallelcode/phone/AgentWidgetTest.kt new file mode 100644 index 000000000..0363ab46b --- /dev/null +++ b/android/app/src/test/java/com/parallelcode/phone/AgentWidgetTest.kt @@ -0,0 +1,28 @@ +package com.parallelcode.phone + +import org.junit.Assert.assertEquals +import org.junit.Test + +class AgentWidgetTest { + private fun agent(id: String, attention: String, collapsed: Boolean = false) = RemoteAgent( + agentId = id, taskId = id, taskName = id, running = true, exitCode = null, lastLine = "", + projectName = null, agentName = null, attention = attention, isChat = false, collapsed = collapsed, + ) + + @Test + fun countsAgentsThatNeedYouAndWorking() { + val agents = listOf(agent("a", "needs_input"), agent("b", "active"), agent("c", "error", collapsed = true)) + assertEquals("1 need you · 1 working", widgetSummary(agents, emptyList(), connected = true).headline) + assertEquals("1 needs you", widgetSummary(agents.take(1), emptyList(), connected = true).headline) + assertEquals("Not connected", widgetSummary(agents, emptyList(), connected = false).headline) + } + + @Test + fun listsRemainingUsagePerProvider() { + val usage = listOf( + ProviderUsage("Claude", UsageWindow(22.0, null), UsageWindow(87.0, null), "ok", null), + ProviderUsage("Codex", null, null, "ok", null), + ) + assertEquals("Left:\nClaude 5h 78% 7d 13%", widgetSummary(emptyList(), usage, connected = true).usage) + } +} diff --git a/android/app/src/test/java/com/parallelcode/phone/ConnectionLinkTest.kt b/android/app/src/test/java/com/parallelcode/phone/ConnectionLinkTest.kt new file mode 100644 index 000000000..2645654bc --- /dev/null +++ b/android/app/src/test/java/com/parallelcode/phone/ConnectionLinkTest.kt @@ -0,0 +1,31 @@ +package com.parallelcode.phone + +import org.junit.Assert.assertEquals +import org.junit.Assert.assertNull +import org.junit.Test + +class ConnectionLinkTest { + @Test + fun parsesTheDesktopQrUrl() { + val link = ConnectionLink.parse("http://192.168.1.20:7777?token=abc") + assertEquals(ConnectionLink("http://192.168.1.20:7777", "abc"), link) + assertEquals("ws://192.168.1.20:7777/ws", link?.webSocketUrl) + } + + @Test + fun keepsOnlyOriginAndDecodesTheToken() { + val link = ConnectionLink.parse(" https://desk.tail1.ts.net/?x=1&token=a%2Bb_c ") + assertEquals(ConnectionLink("https://desk.tail1.ts.net", "a+b_c"), link) + assertEquals("wss://desk.tail1.ts.net/ws", link?.webSocketUrl) + } + + @Test + fun rejectsLinksThatCannotReachADesktop() { + assertNull(ConnectionLink.parse("http://192.168.1.20:7777")) + assertNull(ConnectionLink.parse("http://192.168.1.20:7777?token=")) + assertNull(ConnectionLink.parse("ftp://host?token=abc")) + assertNull(ConnectionLink.parse("javascript:alert(1)?token=abc")) + assertNull(ConnectionLink.parse("not a url")) + assertNull(ConnectionLink.parse("http://host?token=" + "a".repeat(201))) + } +} diff --git a/android/app/src/test/java/com/parallelcode/phone/CredentialStoreTest.kt b/android/app/src/test/java/com/parallelcode/phone/CredentialStoreTest.kt new file mode 100644 index 000000000..2d6f6af80 --- /dev/null +++ b/android/app/src/test/java/com/parallelcode/phone/CredentialStoreTest.kt @@ -0,0 +1,57 @@ +package com.parallelcode.phone + +import org.junit.Assert.assertEquals +import org.junit.Assert.assertNull +import org.junit.Test + +class CredentialStoreTest { + private val home = ConnectionLink("http://192.168.1.20:7777", "home-token") + private val dev = ConnectionLink("http://192.168.1.20:8777", "dev-token") + + @Test + fun keepsEachComputerWithItsOwnPairing() { + val store = CredentialStore(FakeSharedPreferences()) + store.saveLink(home) + store.savePairedToken("home-paired") + store.saveLink(dev) + assertEquals(dev, store.link) + assertNull(store.pairedToken) + + store.select(home.baseUrl) + assertEquals(home, store.link) + assertEquals("home-paired", store.pairedToken) + assertEquals(listOf("192.168.1.20:7777", "192.168.1.20:8777"), store.computers.map { it.label }.sorted()) + } + + @Test + fun forgettingTheComputerInUseLeavesNoneSelected() { + val store = CredentialStore(FakeSharedPreferences()) + store.saveLink(home) + store.saveLink(dev) + store.clear() + assertNull(store.link) + assertEquals(listOf(home.baseUrl), store.computers.map { it.baseUrl }) + } + + @Test + fun rescanningAnAddressReplacesItsTokenAndDropsPairing() { + val store = CredentialStore(FakeSharedPreferences()) + store.saveLink(home) + store.savePairedToken("old") + store.saveLink(home.copy(token = "new")) + assertEquals(1, store.computers.size) + assertEquals("new", store.link?.token) + assertNull(store.pairedToken) + } + + @Test + fun migratesTheSingleComputerFromEarlierVersions() { + val prefs = FakeSharedPreferences() + prefs.edit().putString("baseUrl", home.baseUrl).putString("token", home.token) + .putString("pairedToken", "kept").commit() + val store = CredentialStore(prefs) + assertEquals(home, store.link) + assertEquals("kept", store.pairedToken) + assertNull(prefs.getString("baseUrl", null)) + } +} diff --git a/android/app/src/test/java/com/parallelcode/phone/FakeSharedPreferences.kt b/android/app/src/test/java/com/parallelcode/phone/FakeSharedPreferences.kt new file mode 100644 index 000000000..f7735e2b4 --- /dev/null +++ b/android/app/src/test/java/com/parallelcode/phone/FakeSharedPreferences.kt @@ -0,0 +1,64 @@ +package com.parallelcode.phone + +import android.content.SharedPreferences + +/** Simple in-memory fake of Android SharedPreferences for unit tests. */ +internal class FakeSharedPreferences : SharedPreferences { + private val map = mutableMapOf() + + override fun getAll(): Map = map + override fun getString(key: String?, defValue: String?): String? = (map[key] as? String) ?: defValue + @Suppress("UNCHECKED_CAST") + override fun getStringSet(key: String?, defValues: Set?): Set? = + (map[key] as? Set) ?: defValues + override fun getInt(key: String?, defValue: Int): Int = (map[key] as? Int) ?: defValue + override fun getLong(key: String?, defValue: Long): Long = (map[key] as? Long) ?: defValue + override fun getFloat(key: String?, defValue: Float): Float = (map[key] as? Float) ?: defValue + override fun getBoolean(key: String?, defValue: Boolean): Boolean = (map[key] as? Boolean) ?: defValue + override fun contains(key: String?): Boolean = map.containsKey(key) + override fun edit(): SharedPreferences.Editor = FakeEditor(map) + override fun registerOnSharedPreferenceChangeListener(listener: SharedPreferences.OnSharedPreferenceChangeListener?) {} + override fun unregisterOnSharedPreferenceChangeListener(listener: SharedPreferences.OnSharedPreferenceChangeListener?) {} + + private class FakeEditor(private val backingMap: MutableMap) : SharedPreferences.Editor { + private val pending = mutableMapOf() + private var clearPending = false + + override fun putString(key: String?, value: String?): SharedPreferences.Editor = apply { + if (key != null) pending[key] = value + } + override fun putStringSet(key: String?, values: Set?): SharedPreferences.Editor = apply { + if (key != null) pending[key] = values + } + override fun putInt(key: String?, value: Int): SharedPreferences.Editor = apply { + if (key != null) pending[key] = value + } + override fun putLong(key: String?, value: Long): SharedPreferences.Editor = apply { + if (key != null) pending[key] = value + } + override fun putFloat(key: String?, value: Float): SharedPreferences.Editor = apply { + if (key != null) pending[key] = value + } + override fun putBoolean(key: String?, value: Boolean): SharedPreferences.Editor = apply { + if (key != null) pending[key] = value + } + override fun remove(key: String?): SharedPreferences.Editor = apply { + if (key != null) pending[key] = null + } + override fun clear(): SharedPreferences.Editor = apply { + clearPending = true + } + override fun commit(): Boolean { + apply() + return true + } + override fun apply() { + if (clearPending) backingMap.clear() + for ((k, v) in pending) { + if (v == null) backingMap.remove(k) else backingMap[k] = v + } + pending.clear() + clearPending = false + } + } +} diff --git a/android/app/src/test/java/com/parallelcode/phone/ProtocolTest.kt b/android/app/src/test/java/com/parallelcode/phone/ProtocolTest.kt new file mode 100644 index 000000000..958040f38 --- /dev/null +++ b/android/app/src/test/java/com/parallelcode/phone/ProtocolTest.kt @@ -0,0 +1,94 @@ +package com.parallelcode.phone + +import org.junit.Assert.assertArrayEquals +import org.junit.Assert.assertEquals +import org.junit.Assert.assertNull +import org.junit.Test + +class ProtocolTest { + @Test + fun parsesTheAgentList() { + val msg = parseServerMessage( + """{"type":"agents","list":[{"agentId":"a1","taskId":"t1","taskName":"Fix login", + "status":"running","exitCode":null,"lastLine":"thinking","projectName":"web", + "attention":"needs_input"},{"agentId":"c1","taskId":"t2","taskName":"Chat", + "status":"exited","exitCode":0,"lastLine":"","attention":"idle","kind":"chat"}]}""", + ) as ServerMessage.Agents + val (terminal, chat) = msg.list + assertEquals("Fix login", terminal.taskName) + assertEquals(true, terminal.running) + assertNull(terminal.exitCode) + assertEquals("web", terminal.projectName) + assertNull(terminal.agentName) + assertEquals("needs_input", terminal.attention) + assertEquals(false, terminal.isChat) + assertEquals(false, terminal.collapsed) + assertEquals(0, chat.exitCode) + assertEquals(true, chat.isChat) + assertEquals(false, chat.collapsed) + } + + @Test + fun parsesCollapsedAgents() { + val msg = parseServerMessage( + """{"type":"agents","list":[{"agentId":"collapsed:t3","taskId":"t3","taskName":"Old Task", + "status":"exited","exitCode":null,"lastLine":"Done","attention":"idle","collapsed":true}]}""", + ) as ServerMessage.Agents + val (agent) = msg.list + assertEquals("Old Task", agent.taskName) + assertEquals(true, agent.collapsed) + assertEquals(false, agent.running) + } + + @Test + fun decodesTerminalData() { + val scrollback = parseServerMessage( + """{"type":"scrollback","agentId":"a1","data":"aGk=","cols":100}""", + ) as ServerMessage.Scrollback + assertArrayEquals("hi".toByteArray(), scrollback.data) + assertEquals(100, scrollback.cols) + assertEquals(24, scrollback.rows) + } + + @Test + fun parsesInputResults() { + assertEquals( + ServerMessage.InputResult("7", false, "busy"), + parseServerMessage("""{"type":"input-result","requestId":"7","ok":false,"error":"busy"}"""), + ) + } + + @Test + fun ignoresUnknownAndMalformedMessages() { + assertNull(parseServerMessage("""{"type":"mystery","agentId":"a"}""")) + assertNull(parseServerMessage("""{"type":"chat-state","agentId":"a"}""")) + assertNull(parseServerMessage("""{"type":"output","agentId":"a"}""")) + assertNull(parseServerMessage("nope")) + } + + @Test + fun parsesChatStateWithRequestsKeepingTheirIdType() { + val msg = parseServerMessage( + """{"type":"chat-state","agentId":"a","state":{"status":"ready","model":"opus", + "items":[{"id":"1","kind":"user","text":"hi"}, + {"id":"2","kind":"tool","text":"out","activity":{"type":"command","label":"Run","status":"failed","command":"ls"}}], + "requests":[{"id":7,"since":0,"kind":"question","text":"","questions":[ + {"id":"q","question":"Which?","isSecret":false,"multiSelect":true, + "options":[{"label":"A","description":""}]}]}]}}""", + ) as ServerMessage.Chat + assertEquals("a", msg.agentId) + assertEquals(listOf("user", "tool"), msg.state.items.map { it.kind }) + assertEquals("ls", msg.state.items[1].activity?.command) + val request = msg.state.requests.single() + assertEquals(7, request.id) + assertEquals(true, request.questions.single().multiSelect) + assertEquals("A", request.questions.single().options.single().label) + } + + @Test + fun preparesRepliesLikeThePhoneWebUi() { + assertEquals("one two", messageForTerminal(" one\r\ntwo\u0003 ", bracketedPaste = false)) + assertEquals("\u001b[200~one\ntwo\u001b[201~", messageForTerminal("one\rtwo", bracketedPaste = true)) + assertEquals("", messageForTerminal(" \u001b ", bracketedPaste = true)) + } +} diff --git a/android/app/src/test/java/com/parallelcode/phone/SettingsStoreTest.kt b/android/app/src/test/java/com/parallelcode/phone/SettingsStoreTest.kt new file mode 100644 index 000000000..39cf741fa --- /dev/null +++ b/android/app/src/test/java/com/parallelcode/phone/SettingsStoreTest.kt @@ -0,0 +1,107 @@ +package com.parallelcode.phone + +import android.content.SharedPreferences +import org.junit.Assert.assertEquals +import org.junit.Assert.assertFalse +import org.junit.Assert.assertTrue +import org.junit.Before +import org.junit.Test + +class SettingsStoreTest { + + private lateinit var prefs: FakeSharedPreferences + private lateinit var store: SettingsStore + + @Before + fun setUp() { + prefs = FakeSharedPreferences() + store = SettingsStore(prefs) + } + + @Test + fun defaultsToKeepScreenOnDisabled() { + assertFalse(store.keepScreenOn) + } + + @Test + fun enablesKeepScreenOnAndPersists() { + store.keepScreenOn = true + assertTrue(store.keepScreenOn) + assertTrue(prefs.getBoolean(SettingsStore.KEY_KEEP_SCREEN_ON, false)) + } + + @Test + fun togglesKeepScreenOnBackToDisabled() { + store.keepScreenOn = true + assertTrue(store.keepScreenOn) + store.keepScreenOn = false + assertFalse(store.keepScreenOn) + assertFalse(prefs.getBoolean(SettingsStore.KEY_KEEP_SCREEN_ON, true)) + } + + @Test + fun defaultsToKeepScreenOnOnlyWhenActiveDisabled() { + assertFalse(store.keepScreenOnOnlyWhenActive) + } + + @Test + fun enablesKeepScreenOnOnlyWhenActiveAndPersists() { + store.keepScreenOnOnlyWhenActive = true + assertTrue(store.keepScreenOnOnlyWhenActive) + assertTrue(prefs.getBoolean(SettingsStore.KEY_KEEP_SCREEN_ON_ONLY_ACTIVE, false)) + } + + @Test + fun defaultsToSystemThemeMode() { + assertEquals(SettingsStore.THEME_SYSTEM, store.themeMode) + } + + @Test + fun setsThemeModeAndPersists() { + store.themeMode = SettingsStore.THEME_DARK + assertEquals(SettingsStore.THEME_DARK, store.themeMode) + assertEquals(SettingsStore.THEME_DARK, prefs.getString(SettingsStore.KEY_THEME_MODE, null)) + + store.themeMode = SettingsStore.THEME_LIGHT + assertEquals(SettingsStore.THEME_LIGHT, store.themeMode) + assertEquals(SettingsStore.THEME_LIGHT, prefs.getString(SettingsStore.KEY_THEME_MODE, null)) + } + + @Test + fun defaultsToShowMinimizedTasksDisabled() { + assertFalse(store.showMinimizedTasks) + } + + @Test + fun enablesShowMinimizedTasksAndPersists() { + store.showMinimizedTasks = true + assertTrue(store.showMinimizedTasks) + assertTrue(prefs.getBoolean(SettingsStore.KEY_SHOW_MINIMIZED_TASKS, false)) + + store.showMinimizedTasks = false + assertFalse(store.showMinimizedTasks) + assertFalse(prefs.getBoolean(SettingsStore.KEY_SHOW_MINIMIZED_TASKS, true)) + } + + @Test + fun quickRepliesDefaultAndDropBlankLines() { + assertEquals(SettingsStore.DEFAULT_QUICK_REPLIES, store.quickReplies) + store.quickReplies = listOf(" continue ", "", "ship it") + assertEquals(listOf("continue", "ship it"), store.quickReplies) + } + + @Test + fun appendsToADraftLikeTyping() { + assertEquals("yes", appendToDraft(" ", "yes")) + assertEquals("ok yes", appendToDraft("ok", "yes")) + assertEquals("ok yes", appendToDraft("ok ", "yes")) + } + + @Test + fun alwaysFollowOutputDefaultsOffAndPersists() { + assertFalse(store.alwaysFollowOutput) + store.alwaysFollowOutput = true + assertTrue(store.alwaysFollowOutput) + assertTrue(prefs.getBoolean(SettingsStore.KEY_ALWAYS_FOLLOW_OUTPUT, false)) + } +} diff --git a/android/app/src/test/java/com/parallelcode/phone/TaskDiffTest.kt b/android/app/src/test/java/com/parallelcode/phone/TaskDiffTest.kt new file mode 100644 index 000000000..a617e15cd --- /dev/null +++ b/android/app/src/test/java/com/parallelcode/phone/TaskDiffTest.kt @@ -0,0 +1,43 @@ +package com.parallelcode.phone + +import org.junit.Assert.assertEquals +import org.junit.Assert.assertTrue +import org.junit.Test + +class TaskDiffTest { + @Test + fun splitsFilesAndCountsLines() { + val files = parseUnifiedDiff( + """ + diff --git a/src/a.kt b/src/a.kt + index 1..2 100644 + --- a/src/a.kt + +++ b/src/a.kt + @@ -1,2 +1,2 @@ + keep + -old + +new + +more + diff --git a/gone.txt b/gone.txt + deleted file mode 100644 + --- a/gone.txt + +++ /dev/null + @@ -1 +0,0 @@ + -bye + diff --git a/logo.png b/logo.png + Binary files a/logo.png and b/logo.png differ + """.trimIndent(), + ) + assertEquals(listOf("src/a.kt", "gone.txt", "logo.png"), files.map { it.path }) + assertEquals(2, files[0].added) + assertEquals(1, files[0].removed) + assertEquals(listOf("@@ -1,2 +1,2 @@", " keep", "-old", "+new", "+more"), files[0].lines) + assertEquals(1, files[1].removed) + assertTrue(files[2].binary) + } + + @Test + fun emptyDiffHasNoFiles() { + assertEquals(emptyList(), parseUnifiedDiff("")) + } +} diff --git a/android/app/src/test/java/com/parallelcode/phone/TerminalBufferTest.kt b/android/app/src/test/java/com/parallelcode/phone/TerminalBufferTest.kt new file mode 100644 index 000000000..95ed77af9 --- /dev/null +++ b/android/app/src/test/java/com/parallelcode/phone/TerminalBufferTest.kt @@ -0,0 +1,44 @@ +package com.parallelcode.phone + +import org.junit.Assert.assertEquals +import org.junit.Assert.assertTrue +import org.junit.Test + +class TerminalBufferTest { + @Test + fun updatesVersionAndResetsScreenOnScrollback() { + val buffer = TerminalBuffer("agent-1") + assertEquals(0, buffer.version.value) + + buffer.onScrollback("initial output\r\n".toByteArray(), cols = 40, rows = 10) + assertEquals(1, buffer.version.value) + assertTrue(buffer.screen.text().contains("initial output")) + } + + @Test + fun updatesVersionAndAppendsOutput() { + val buffer = TerminalBuffer("agent-1") + buffer.onScrollback("step 1\r\n".toByteArray(), cols = 40, rows = 10) + assertEquals(1, buffer.version.value) + + buffer.onOutput("step 2\r\n".toByteArray()) + assertEquals(2, buffer.version.value) + val text = buffer.screen.text() + assertTrue(text.contains("step 1")) + assertTrue(text.contains("step 2")) + } + + @Test + fun retainsDeepScrollbackHistory() { + val buffer = TerminalBuffer("agent-1") + // Feed 100 lines through a 5-row screen + buffer.onScrollback("".toByteArray(), cols = 40, rows = 5) + for (i in 1..100) { + buffer.onOutput("log line $i\r\n".toByteArray()) + } + val text = buffer.screen.text() + assertTrue(text.contains("log line 1")) + assertTrue(text.contains("log line 100")) + assertTrue(buffer.screen.styledLines().size >= 100) + } +} diff --git a/android/app/src/test/java/com/parallelcode/phone/TerminalScreenTest.kt b/android/app/src/test/java/com/parallelcode/phone/TerminalScreenTest.kt new file mode 100644 index 000000000..c76fe7234 --- /dev/null +++ b/android/app/src/test/java/com/parallelcode/phone/TerminalScreenTest.kt @@ -0,0 +1,144 @@ +package com.parallelcode.phone + +import org.junit.Assert.assertEquals +import org.junit.Assert.assertTrue +import org.junit.Test + +class TerminalScreenTest { + private fun screen(cols: Int = 10, rows: Int = 3, data: String = "") = + TerminalScreen().apply { reset(cols, rows, data.toByteArray()) } + + @Test + fun appliesCursorMovementAndErase() { + val s = screen(data = "hello\r\nworld\u001b[1;1Hj\u001b[2;3H\u001b[K") + assertEquals("jello\nwo", s.text()) + } + + @Test + fun dropsColorsAndTitles() { + val s = screen(data = "\u001b]0;title\u0007\u001b[1;31mred\u001b[0m ok") + assertEquals("red ok", s.text()) + } + + @Test + fun scrollsFullLinesIntoHistory() { + val s = screen(rows = 2, data = "1\r\n2\r\n3\r\n4") + assertEquals("1\n2\n3\n4", s.text()) + } + + @Test + fun wrapsOnlyWhenTheNextCharacterArrives() { + val s = screen(cols = 3, data = "abc\r\nd") + assertEquals("abc\nd", s.text()) + assertEquals("abc\nd", screen(cols = 3, data = "abcd").text()) + } + + @Test + fun preservesHistoryWhenScrollRegionStartsAtTop() { + // Status line pinned at the bottom; lines scrolling off the top go to history. + val s = screen(rows = 3, data = "\u001b[3;1Hstatus\u001b[1;2r\u001b[1;1Ha\r\nb\r\nc") + assertEquals("a\nb\nc\nstatus", s.text()) + } + + @Test + fun keepsSubRegionRedrawsOutOfHistoryWhenHeaderPinned() { + // Header pinned at row 1; only rows 2-3 scroll. + val s = screen(rows = 3, data = "\u001b[1;1Hheader\u001b[2;3r\u001b[2;1Ha\r\nb\r\nc") + assertEquals("header\nb\nc", s.text()) + } + + @Test + fun preservesHistoryAcrossMultipleScrollsWithPinnedFooter() { + // TUI with 4 rows: row 4 is a pinned status footer, rows 1-3 scroll. + // Print 10 lines through the scrolling region. + val input = StringBuilder("\u001b[4;1Hfooter\u001b[1;3r\u001b[1;1H") + for (i in 1..10) { + input.append("line $i\r\n") + } + val s = screen(rows = 4, data = input.toString()) + val text = s.text() + assertTrue(text.contains("line 1")) + assertTrue(text.contains("line 10")) + assertTrue(text.contains("footer")) + // 7 lines scrolled into history + 4 lines on grid (including trailing lines) + val lines = s.styledLines() + assertTrue(lines.size >= 10) + } + + @Test + fun restoresTheMainScreenAfterAFullScreenProgram() { + val s = screen(data = "shell\u001b[?1049hvim stuff\u001b[?1049l") + assertEquals("shell", s.text()) + } + + @Test + fun decodesUtf8SplitAcrossChunks() { + val s = screen() + val bytes = "é✓".toByteArray() + s.feed(bytes.copyOfRange(0, 1)) + s.feed(bytes.copyOfRange(1, bytes.size)) + assertEquals("é✓", s.text()) + } + + @Test + fun tracksBracketedPasteMode() { + val s = screen(data = "\u001b[?2004h") + assertTrue(s.bracketedPaste) + s.feed("\u001b[?2004l".toByteArray()) + assertEquals(false, s.bracketedPaste) + } + + @Test + fun keepsColorsPerCell() { + val s = screen(cols = 20, data = "\u001b[31mred\u001b[0m \u001b[1;38;2;1;2;3mrgb\u001b[m") + val (red, plain, rgb) = s.styledLines().single() + assertEquals("red", red.text) + assertEquals(1, CellStyle.fg(red.style)) + assertEquals(CellStyle.DEFAULT, plain.style) + assertEquals(CellStyle.RGB_FLAG or 0x010203, CellStyle.fg(rgb.style)) + assertTrue(CellStyle.flags(rgb.style) and CellStyle.BOLD != 0) + } + + @Test + fun erasePaintsTheCurrentBackground() { + val s = screen(cols = 4, rows = 1, data = "\u001b[44m\u001b[2K\u001b[0mx") + val spans = s.styledLines().single() + assertEquals(listOf("x", " "), spans.map { it.text }) + assertEquals(4, CellStyle.bg(spans[1].style)) + } + + @Test + fun retainsUpTo5000LinesOfHistory() { + val s = screen(rows = 2) + // Feed 5,050 lines + for (i in 1..5050) { + s.feed("L$i\r\n".toByteArray()) + } + val lines = s.styledLines() + // Should keep 5000 history lines + 1 non-empty grid row (trailing blank row is trimmed) + assertEquals(5001, lines.size) + // Earliest line should be L50, since 1-49 were pruned past 5000 + val text = s.text() + assertTrue(text.contains("L5050")) + assertTrue(text.contains("L52")) + assertEquals(false, text.contains("L40\n")) + } + + @Test + fun resizeKeepsTheCursorLineAndScrollsTheTopIntoHistory() { + val s = screen(cols = 5, rows = 3, data = "a\r\nb\r\nc") + s.resize(4, 2) + assertEquals("a\nb\nc", s.text()) + s.feed("\u001b[1;1Hx".toByteArray()) + assertEquals("a\nx\nc", s.text()) + } + + @Test + fun resizeGrowsAndTruncatesColumns() { + val s = screen(cols = 5, rows = 2, data = "abcde") + s.resize(3, 4) + assertEquals("abc", s.text()) + s.feed("\u001b[4;1Hz".toByteArray()) + assertEquals("abc\n\n\nz", s.text()) + } +} diff --git a/android/app/src/test/java/com/parallelcode/phone/TerminalStyleTest.kt b/android/app/src/test/java/com/parallelcode/phone/TerminalStyleTest.kt new file mode 100644 index 000000000..82f10bd42 --- /dev/null +++ b/android/app/src/test/java/com/parallelcode/phone/TerminalStyleTest.kt @@ -0,0 +1,56 @@ +package com.parallelcode.phone + +import org.junit.Assert.assertEquals +import org.junit.Assert.assertNull +import org.junit.Assert.assertTrue +import org.junit.Test + +class TerminalStyleTest { + private fun sgr(body: String, from: Long = CellStyle.DEFAULT) = CellStyle.applySgr(from, body) + + @Test + fun parsesBasicBrightAnd256Colors() { + assertEquals(2, CellStyle.fg(sgr("32"))) + assertEquals(12, CellStyle.bg(sgr("104"))) + assertEquals(208, CellStyle.fg(sgr("38;5;208"))) + assertEquals(17, CellStyle.bg(sgr("48:5:17"))) + } + + @Test + fun parsesTrueColorInBothForms() { + val rgb = CellStyle.RGB_FLAG or 0x0a141e + assertEquals(rgb, CellStyle.fg(sgr("38;2;10;20;30"))) + assertEquals(rgb, CellStyle.fg(sgr("38:2::10:20:30"))) + assertEquals(rgb, CellStyle.bg(sgr("48:2:10:20:30"))) + // Parameters after an extended color still apply. + assertTrue(CellStyle.flags(sgr("38;2;10;20;30;1")) and CellStyle.BOLD != 0) + } + + @Test + fun resetsAndClearsAttributes() { + val styled = sgr("1;3;4;7;31;42") + assertEquals(CellStyle.DEFAULT, sgr("0", styled)) + assertEquals(CellStyle.DEFAULT, sgr("", styled)) + val cleared = sgr("22;23;24;27;39;49", styled) + assertEquals(CellStyle.DEFAULT, cleared) + } + + @Test + fun resolvesThroughTheDesktopPalette() { + val p = TerminalPalette.OBSIDIAN + assertEquals(0xFFe08c96.toInt(), p.resolve(sgr("31")).foreground) + // Bold basic colors brighten, as in xterm.js. + assertEquals(0xFFeaa0aa.toInt(), p.resolve(sgr("1;31")).foreground) + assertEquals(0xFFff8700.toInt(), p.resolve(sgr("38;5;208")).foreground) + assertEquals(0xFF808080.toInt(), p.resolve(sgr("38;5;244")).foreground) + assertNull(p.resolve(CellStyle.DEFAULT).background) + } + + @Test + fun inverseSwapsWithTheThemeDefaults() { + val p = TerminalPalette.OBSIDIAN + val s = p.resolve(sgr("7")) + assertEquals(p.background, s.foreground) + assertEquals(p.foreground, s.background) + } +} diff --git a/android/app/src/test/java/com/parallelcode/phone/UsageTest.kt b/android/app/src/test/java/com/parallelcode/phone/UsageTest.kt new file mode 100644 index 000000000..d1a42eaae --- /dev/null +++ b/android/app/src/test/java/com/parallelcode/phone/UsageTest.kt @@ -0,0 +1,72 @@ +package com.parallelcode.phone + +import org.json.JSONObject +import org.junit.Assert.assertEquals +import org.junit.Assert.assertTrue +import org.junit.Test +import java.time.ZoneOffset +import java.time.ZonedDateTime +import java.util.Locale + +class UsageTest { + @Test + fun showsProvidersWithASnapshotOrARefreshError() { + val usage = parseUsage( + JSONObject( + """{ + "claude": {"fiveHour": {"usedPercent": 82.4, "resetsAt": 1700000000000}, + "sevenDay": {"usedPercent": 10, "resetsAt": null}, + "fetchedAt": 1, "status": "ok", "error": null}, + "codex": {"fiveHour": null, "sevenDay": null, "fetchedAt": null, + "status": "error", "error": "rate limited"} + }""", + ), + ) + val (claude, codex) = usage + assertEquals("Claude", claude.label) + assertEquals(18, claude.fiveHour?.remainingPercent) + assertTrue(claude.fiveHour?.warn == true) + assertEquals(null, claude.sevenDay?.resetsAt) + assertEquals("rate limited", codex.error) + } + + @Test + fun showsAntigravityUsageWhenAvailable() { + val usage = parseUsage( + JSONObject( + """{ + "antigravity": { + "fiveHour": {"usedPercent": 40.0, "resetsAt": 1700000000000}, + "sevenDay": {"usedPercent": 15.0, "resetsAt": null}, + "fetchedAt": 1, + "status": "ok", + "error": null + } + }""", + ), + ) + assertEquals(1, usage.size) + val agy = usage.first() + assertEquals("Antigravity", agy.label) + assertEquals(60, agy.fiveHour?.remainingPercent) + assertEquals(85, agy.sevenDay?.remainingPercent) + } + + @Test + fun hidesProvidersWithoutASubscription() { + val none = """{"fiveHour": null, "sevenDay": null, "fetchedAt": null, "status": "unavailable", "error": null}""" + assertEquals(emptyList(), parseUsage(JSONObject("""{"claude": $none, "codex": $none, "antigravity": $none}"""))) + } + + @Test + fun formatsResetTimes() { + val zone = ZoneOffset.UTC + val now = ZonedDateTime.of(2026, 9, 28, 10, 0, 0, 0, zone).toInstant().toEpochMilli() + fun at(day: Int, hour: Int) = ZonedDateTime.of(2026, 9, day, hour, 30, 0, 0, zone).toInstant().toEpochMilli() + assertEquals("resets 14:30", formatReset(at(28, 14), now, zone, Locale.US)) + val thursday = ZonedDateTime.of(2026, 10, 1, 9, 30, 0, 0, zone).toInstant().toEpochMilli() + assertEquals("resets Thu 09:30", formatReset(thursday, now, zone, Locale.US)) + assertEquals("reset due", formatReset(at(28, 9), now, zone, Locale.US)) + assertEquals("", formatReset(null, now, zone, Locale.US)) + } +} diff --git a/android/build.gradle.kts b/android/build.gradle.kts new file mode 100644 index 000000000..12a7ab91e --- /dev/null +++ b/android/build.gradle.kts @@ -0,0 +1,4 @@ +plugins { + id("com.android.application") version "9.4.1" apply false + id("org.jetbrains.kotlin.plugin.compose") version "2.4.20" apply false +} diff --git a/android/gradle.properties b/android/gradle.properties new file mode 100644 index 000000000..459e0db2c --- /dev/null +++ b/android/gradle.properties @@ -0,0 +1,3 @@ +org.gradle.jvmargs=-Xmx2g -Dfile.encoding=UTF-8 +android.useAndroidX=true +kotlin.code.style=official diff --git a/android/gradle/wrapper/gradle-wrapper.jar b/android/gradle/wrapper/gradle-wrapper.jar new file mode 100644 index 0000000000000000000000000000000000000000..b1b8ef56b44f16b14dc800fa8103a6d89abb526f GIT binary patch literal 48462 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 R2np~f1o-$>Q}y+){|A@R9n$~+ literal 0 HcmV?d00001 diff --git a/android/gradle/wrapper/gradle-wrapper.properties b/android/gradle/wrapper/gradle-wrapper.properties new file mode 100644 index 000000000..a9db11550 --- /dev/null +++ b/android/gradle/wrapper/gradle-wrapper.properties @@ -0,0 +1,9 @@ +distributionBase=GRADLE_USER_HOME +distributionPath=wrapper/dists +distributionUrl=https\://services.gradle.org/distributions/gradle-9.6.1-bin.zip +networkTimeout=10000 +retries=0 +retryBackOffMs=500 +validateDistributionUrl=true +zipStoreBase=GRADLE_USER_HOME +zipStorePath=wrapper/dists diff --git a/android/gradlew b/android/gradlew new file mode 100755 index 000000000..249efbb03 --- /dev/null +++ b/android/gradlew @@ -0,0 +1,248 @@ +#!/bin/sh + +# +# Copyright © 2015 the original authors. +# +# Licensed under the Apache License, Version 2.0 (the "License"); +# you may not use this file except in compliance with the License. +# You may obtain a copy of the License at +# +# https://www.apache.org/licenses/LICENSE-2.0 +# +# Unless required by applicable law or agreed to in writing, software +# distributed under the License is distributed on an "AS IS" BASIS, +# WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +# See the License for the specific language governing permissions and +# limitations under the License. +# +# SPDX-License-Identifier: Apache-2.0 +# + +############################################################################## +# +# gradlew start up script for POSIX generated by Gradle. +# +# Important for running: +# +# (1) You need a POSIX-compliant shell to run this script. If your /bin/sh is +# noncompliant, but you have some other compliant shell such as ksh or +# bash, then to run this script, type that shell name before the whole +# command line, like: +# +# ksh gradlew +# +# Busybox and similar reduced shells will NOT work, because this script +# requires all of these POSIX shell features: +# * functions; +# * expansions «$var», «${var}», «${var:-default}», «${var+SET}», +# «${var#prefix}», «${var%suffix}», and «$( cmd )»; +# * compound commands having a testable exit status, especially «case»; +# * various built-in commands including «command», «set», and «ulimit». +# +# Important for patching: +# +# (2) This script targets any POSIX shell, so it avoids extensions provided +# by Bash, Ksh, etc; in particular arrays are avoided. +# +# The "traditional" practice of packing multiple parameters into a +# space-separated string is a well documented source of bugs and security +# problems, so this is (mostly) avoided, by progressively accumulating +# options in "$@", and eventually passing that to Java. +# +# Where the inherited environment variables (DEFAULT_JVM_OPTS, JAVA_OPTS, +# and GRADLE_OPTS) rely on word-splitting, this is performed explicitly; +# see the in-line comments for details. +# +# There are tweaks for specific operating systems such as AIX, CygWin, +# Darwin, MinGW, and NonStop. +# +# (3) This script is generated from the Groovy template +# https://github.com/gradle/gradle/blob/3d91ce3b8caaf77ad09f381f43615b715b53f72c/platforms/jvm/plugins-application/src/main/resources/org/gradle/api/internal/plugins/unixStartScript.txt +# within the Gradle project. +# +# You can find Gradle at https://github.com/gradle/gradle/. +# +############################################################################## + +# Attempt to set APP_HOME + +# Resolve links: $0 may be a link +app_path=$0 + +# Need this for daisy-chained symlinks. +while + APP_HOME=${app_path%"${app_path##*/}"} # leaves a trailing /; empty if no leading path + [ -h "$app_path" ] +do + ls=$( ls -ld "$app_path" ) + link=${ls#*' -> '} + case $link in #( + /*) app_path=$link ;; #( + *) app_path=$APP_HOME$link ;; + esac +done + +# This is normally unused +# shellcheck disable=SC2034 +APP_BASE_NAME=${0##*/} +# Discard cd standard output in case $CDPATH is set (https://github.com/gradle/gradle/issues/25036) +APP_HOME=$( cd -P "${APP_HOME:-./}" > /dev/null && printf '%s\n' "$PWD" ) || exit + +# Use the maximum available, or set MAX_FD != -1 to use that value. +MAX_FD=maximum + +warn () { + echo "$*" +} >&2 + +die () { + echo + echo "$*" + echo + exit 1 +} >&2 + +# OS specific support (must be 'true' or 'false'). +cygwin=false +msys=false +darwin=false +nonstop=false +case "$( uname )" in #( + CYGWIN* ) cygwin=true ;; #( + Darwin* ) darwin=true ;; #( + MSYS* | MINGW* ) msys=true ;; #( + NONSTOP* ) nonstop=true ;; +esac + + + +# Determine the Java command to use to start the JVM. +if [ -n "$JAVA_HOME" ] ; then + if [ -x "$JAVA_HOME/jre/sh/java" ] ; then + # IBM's JDK on AIX uses strange locations for the executables + JAVACMD=$JAVA_HOME/jre/sh/java + else + JAVACMD=$JAVA_HOME/bin/java + fi + if [ ! -x "$JAVACMD" ] ; then + die "ERROR: JAVA_HOME is set to an invalid directory: $JAVA_HOME + +Please set the JAVA_HOME variable in your environment to match the +location of your Java installation." + fi +else + JAVACMD=java + if ! command -v java >/dev/null 2>&1 + then + die "ERROR: JAVA_HOME is not set and no 'java' command could be found in your PATH. + +Please set the JAVA_HOME variable in your environment to match the +location of your Java installation." + fi +fi + +# Increase the maximum file descriptors if we can. +if ! "$cygwin" && ! "$darwin" && ! "$nonstop" ; then + case $MAX_FD in #( + max*) + # In POSIX sh, ulimit -H is undefined. That's why the result is checked to see if it worked. + # shellcheck disable=SC2039,SC3045 + MAX_FD=$( ulimit -H -n ) || + warn "Could not query maximum file descriptor limit" + esac + case $MAX_FD in #( + '' | soft) :;; #( + *) + # In POSIX sh, ulimit -n is undefined. That's why the result is checked to see if it worked. + # shellcheck disable=SC2039,SC3045 + ulimit -n "$MAX_FD" || + warn "Could not set maximum file descriptor limit to $MAX_FD" + esac +fi + +# Collect all arguments for the java command, stacking in reverse order: +# * args from the command line +# * the main class name +# * -classpath +# * -D...appname settings +# * --module-path (only if needed) +# * DEFAULT_JVM_OPTS, JAVA_OPTS, and GRADLE_OPTS environment variables. + +# For Cygwin or MSYS, switch paths to Windows format before running java +if "$cygwin" || "$msys" ; then + APP_HOME=$( cygpath --path --mixed "$APP_HOME" ) + + JAVACMD=$( cygpath --unix "$JAVACMD" ) + + # Now convert the arguments - kludge to limit ourselves to /bin/sh + for arg do + if + case $arg in #( + -*) false ;; # don't mess with options #( + /?*) t=${arg#/} t=/${t%%/*} # looks like a POSIX filepath + [ -e "$t" ] ;; #( + *) false ;; + esac + then + arg=$( cygpath --path --ignore --mixed "$arg" ) + fi + # Roll the args list around exactly as many times as the number of + # args, so each arg winds up back in the position where it started, but + # possibly modified. + # + # NB: a `for` loop captures its iteration list before it begins, so + # changing the positional parameters here affects neither the number of + # iterations, nor the values presented in `arg`. + shift # remove old arg + set -- "$@" "$arg" # push replacement arg + done +fi + + +# Add default JVM options here. You can also use JAVA_OPTS and GRADLE_OPTS to pass JVM options to this script. +DEFAULT_JVM_OPTS='"-Xmx64m" "-Xms64m"' + +# Collect all arguments for the java command: +# * DEFAULT_JVM_OPTS, JAVA_OPTS, and optsEnvironmentVar are not allowed to contain shell fragments, +# and any embedded shellness will be escaped. +# * For example: A user cannot expect ${Hostname} to be expanded, as it is an environment variable and will be +# treated as '${Hostname}' itself on the command line. + +set -- \ + "-Dorg.gradle.appname=$APP_BASE_NAME" \ + -jar "$APP_HOME/gradle/wrapper/gradle-wrapper.jar" \ + "$@" + +# Stop when "xargs" is not available. +if ! command -v xargs >/dev/null 2>&1 +then + die "xargs is not available" +fi + +# Use "xargs" to parse quoted args. +# +# With -n1 it outputs one arg per line, with the quotes and backslashes removed. +# +# In Bash we could simply go: +# +# readarray ARGS < <( xargs -n1 <<<"$var" ) && +# set -- "${ARGS[@]}" "$@" +# +# but POSIX shell has neither arrays nor command substitution, so instead we +# post-process each arg (as a line of input to sed) to backslash-escape any +# character that might be a shell metacharacter, then use eval to reverse +# that process (while maintaining the separation between arguments), and wrap +# the whole thing up as a single "set" statement. +# +# This will of course break if any of these variables contains a newline or +# an unmatched quote. +# + +eval "set -- $( + printf '%s\n' "$DEFAULT_JVM_OPTS $JAVA_OPTS $GRADLE_OPTS" | + xargs -n1 | + sed ' s~[^-[:alnum:]+,./:=@_]~\\&~g; ' | + tr '\n' ' ' + )" '"$@"' + +exec "$JAVACMD" "$@" diff --git a/android/gradlew.bat b/android/gradlew.bat new file mode 100644 index 000000000..8508ef684 --- /dev/null +++ b/android/gradlew.bat @@ -0,0 +1,82 @@ +@rem +@rem Copyright 2015 the original author or authors. +@rem +@rem Licensed under the Apache License, Version 2.0 (the "License"); +@rem you may not use this file except in compliance with the License. +@rem You may obtain a copy of the License at +@rem +@rem https://www.apache.org/licenses/LICENSE-2.0 +@rem +@rem Unless required by applicable law or agreed to in writing, software +@rem distributed under the License is distributed on an "AS IS" BASIS, +@rem WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +@rem See the License for the specific language governing permissions and +@rem limitations under the License. +@rem +@rem SPDX-License-Identifier: Apache-2.0 +@rem + +@if "%DEBUG%"=="" @echo off +@rem ########################################################################## +@rem +@rem gradlew startup script for Windows +@rem +@rem ########################################################################## + +@rem Set local scope for the variables, and ensure extensions are enabled +setlocal EnableExtensions + +set DIRNAME=%~dp0 +if "%DIRNAME%"=="" set DIRNAME=. +@rem This is normally unused +set APP_BASE_NAME=%~n0 +set APP_HOME=%DIRNAME% + +@rem Resolve any "." and ".." in APP_HOME to make it shorter. +for %%i in ("%APP_HOME%") do set APP_HOME=%%~fi + +@rem Add default JVM options here. You can also use JAVA_OPTS and GRADLE_OPTS to pass JVM options to this script. +set DEFAULT_JVM_OPTS="-Xmx64m" "-Xms64m" + +@rem Find java.exe +if defined JAVA_HOME goto findJavaFromJavaHome + +set JAVA_EXE=java.exe +%JAVA_EXE% -version >NUL 2>&1 +if %ERRORLEVEL% equ 0 goto execute + +echo. 1>&2 +echo ERROR: JAVA_HOME is not set and no 'java' command could be found in your PATH. 1>&2 +echo. 1>&2 +echo Please set the JAVA_HOME variable in your environment to match the 1>&2 +echo location of your Java installation. 1>&2 + +"%COMSPEC%" /c exit 1 + +:findJavaFromJavaHome +set JAVA_HOME=%JAVA_HOME:"=% +set JAVA_EXE=%JAVA_HOME%/bin/java.exe + +if exist "%JAVA_EXE%" goto execute + +echo. 1>&2 +echo ERROR: JAVA_HOME is set to an invalid directory: %JAVA_HOME% 1>&2 +echo. 1>&2 +echo Please set the JAVA_HOME variable in your environment to match the 1>&2 +echo location of your Java installation. 1>&2 + +"%COMSPEC%" /c exit 1 + +:execute +@rem Setup the command line + + + +@rem Execute gradlew +@rem endlocal doesn't take effect until after the line is parsed and variables are expanded +@rem which allows us to clear the local environment before executing the java command +endlocal & "%JAVA_EXE%" %DEFAULT_JVM_OPTS% %JAVA_OPTS% %GRADLE_OPTS% "-Dorg.gradle.appname=%APP_BASE_NAME%" -jar "%APP_HOME%\gradle\wrapper\gradle-wrapper.jar" %* & call :exitWithErrorLevel + +:exitWithErrorLevel +@rem Use "%COMSPEC%" /c exit to allow operators to work properly in scripts +"%COMSPEC%" /c exit %ERRORLEVEL% diff --git a/android/settings.gradle.kts b/android/settings.gradle.kts new file mode 100644 index 000000000..9db5f17bb --- /dev/null +++ b/android/settings.gradle.kts @@ -0,0 +1,18 @@ +pluginManagement { + repositories { + google() + mavenCentral() + gradlePluginPortal() + } +} + +dependencyResolutionManagement { + repositoriesMode.set(RepositoriesMode.FAIL_ON_PROJECT_REPOS) + repositories { + google() + mavenCentral() + } +} + +rootProject.name = "ParallelCodePhone" +include(":app") diff --git a/electron/ipc/channel-manifest.json b/electron/ipc/channel-manifest.json index 2ebfca44c..b54ef4b67 100644 --- a/electron/ipc/channel-manifest.json +++ b/electron/ipc/channel-manifest.json @@ -96,6 +96,8 @@ "MCP_PublishTourRequest": "mcp_publish_tour_request", "Remote_GetNotesRequest": "remote_get_notes_request", "Remote_SetNotesRequest": "remote_set_notes_request", + "Remote_CloseTaskRequest": "remote_close_task_request", + "Remote_GetDiffRequest": "remote_get_diff_request", "Remote_UpdateTaskStatus": "remote_update_task_status", "Remote_RendererReply": "remote_renderer_reply", "PlanContent": "plan_content", diff --git a/electron/ipc/pty.test.ts b/electron/ipc/pty.test.ts index 23cedd10c..29b558fc0 100644 --- a/electron/ipc/pty.test.ts +++ b/electron/ipc/pty.test.ts @@ -108,10 +108,12 @@ import { onPtyEvent, projectImageTag, resizeAgent, + setAgentRemoteSize, resolveProjectDockerfile, spawnAgent, setAgentHookRuntime, subscribeToAgent, + subscribeToAgentRendered, validateCommand, writeToAgent, writeAgentPrompt, @@ -1105,6 +1107,18 @@ describe('spawnAgent terminal queries', () => { await vi.waitFor(() => expect(proc.write).toHaveBeenCalledWith('\x1b[1;3R')); }); + it('takes a phone size and restores the desktop size, remembering desktop resizes', async () => { + const proc = await launch('agent-remote-size'); + resizeAgent('agent-remote-size', 100, 30); + setAgentRemoteSize('agent-remote-size', { cols: 60, rows: 50 }); + expect(proc.resize).toHaveBeenLastCalledWith(60, 50); + // The desktop pane refits while the phone views: remembered, not applied. + resizeAgent('agent-remote-size', 110, 32); + expect(proc.resize).toHaveBeenLastCalledWith(60, 50); + setAgentRemoteSize('agent-remote-size', null); + expect(proc.resize).toHaveBeenLastCalledWith(110, 32); + }); + it('answers at the size the PTY was resized to', async () => { const proc = await launch('agent-query-resize'); proc.emitData('\r\n'.repeat(30)); @@ -2173,3 +2187,42 @@ describe('Claude terminal handoff', () => { } }); }); + +describe('subscribeToAgentRendered', () => { + async function launch(agentId: string) { + await spawnAgent( + createMockNotify(), + buildSpawnArgs({ agentId, command: 'claude', args: [], dockerMode: false }), + ); + return mockPtySpawn.mock.results[mockPtySpawn.mock.results.length - 1].value as ReturnType< + typeof mockPtySpawn + >; + } + + const decode = (encoded: string) => Buffer.from(encoded, 'base64').toString(); + + it('starts from the rendered screen and sends later output once, after it', async () => { + const proc = await launch('agent-rendered'); + // Repaints of one line: the raw replay keeps every frame, the render only the last. + proc.emitData('first\r\nframe 1\rframe 2\rframe 3'); + const events: string[] = []; + const subscriber = subscribeToAgentRendered( + 'agent-rendered', + (snapshot) => events.push(`snapshot:${decode(snapshot?.data ?? '')}`), + (encoded) => events.push(`output:${decode(encoded)}`), + ); + expect(subscriber).not.toBeNull(); + proc.emitData('\r\nlater'); + + await vi.waitFor(() => expect(events.some((e) => e === 'output:\r\nlater')).toBe(true)); + expect(events).toHaveLength(2); + expect(events[0]).toContain('first'); + expect(events[0]).toContain('frame 3'); + expect(events[0]).not.toContain('frame 1'); + expect(events[1]).toBe('output:\r\nlater'); + }); + + it('returns null for an unknown agent', () => { + expect(subscribeToAgentRendered('missing', vi.fn(), vi.fn())).toBeNull(); + }); +}); diff --git a/electron/ipc/pty.ts b/electron/ipc/pty.ts index 99060168a..c46f4a1fb 100644 --- a/electron/ipc/pty.ts +++ b/electron/ipc/pty.ts @@ -53,6 +53,12 @@ interface PtySession { peerInputQueue?: { data: string; at: number }[]; /** Assigned container name when running in Docker mode, null otherwise. */ containerName: string | null; + /** The size the desktop pane asked for; restored when a phone stops overriding it. */ + desktopSize?: { cols: number; rows: number }; + /** A viewing phone's size, which the PTY takes while it is set. */ + remoteSize?: { cols: number; rows: number }; + /** Send batched output now, so subscribers have everything the mirror has parsed. */ + flushOutput?: () => void; } const sessions = new Map(); @@ -631,6 +637,8 @@ function attachPtyOutputHandlers( } }; + session.flushOutput = flush; + session.proc.onData((data: string) => { session.queries.feed(data); const chunk = Buffer.from(data, 'utf8'); @@ -754,10 +762,7 @@ export async function spawnAgent( existing.taskId = args.taskId; existing.isShell = args.isShell ?? existing.isShell; existing.proc.resume(); - if (args.cols > 0 && args.rows > 0) { - existing.proc.resize(args.cols, args.rows); - existing.queries.resize(args.cols, args.rows); - } + if (args.cols > 0 && args.rows > 0) resizeDesktopView(existing, args.cols, args.rows); if (existing.scrollback.length > 0) { sendToChannel(notify, channelId, { type: 'Data', @@ -1012,11 +1017,44 @@ export async function writeAgentPrompt( } } +function applySize(session: PtySession, cols: number, rows: number): void { + if (session.proc.cols === cols && session.proc.rows === rows) return; + session.proc.resize(cols, rows); + session.queries.resize(cols, rows); +} + +function resizeDesktopView(session: PtySession, cols: number, rows: number): void { + session.desktopSize = { cols, rows }; + if (!session.remoteSize) applySize(session, cols, rows); +} + +/** Resize for the desktop pane. While a phone overrides the size, it is remembered instead. */ export function resizeAgent(agentId: string, cols: number, rows: number): void { const session = sessions.get(agentId); if (!session) throw new Error(`Agent not found: ${agentId}`); - session.proc.resize(cols, rows); - session.queries.resize(cols, rows); + resizeDesktopView(session, cols, rows); +} + +/** + * Give the PTY a viewing phone's size, so a full-screen TUI fills the phone, or + * pass null to hand it back to the desktop pane's last size. + */ +export function setAgentRemoteSize( + agentId: string, + size: { cols: number; rows: number } | null, +): void { + const session = sessions.get(agentId); + if (!session) throw new Error(`Agent not found: ${agentId}`); + if (size) { + session.desktopSize ??= { cols: session.proc.cols, rows: session.proc.rows }; + session.remoteSize = size; + applySize(session, size.cols, size.rows); + return; + } + if (!session.remoteSize) return; + session.remoteSize = undefined; + const desktop = session.desktopSize; + if (desktop) applySize(session, desktop.cols, desktop.rows); } export function pauseAgent(agentId: string): void { @@ -1094,6 +1132,41 @@ export function subscribeToAgent(agentId: string, cb: (encoded: string) => void) return true; } +/** + * Subscribe starting from a rendered snapshot: the screen and up to 1000 lines + * of history from the main-process mirror, as ANSI text, instead of the raw + * byte replay, which redraw-heavy TUIs fill with repaints of one screen. + * Output already in the snapshot is not sent again; later output reaches `cb` + * after `onSnapshot`. The snapshot is null when the mirror is gone (the process + * exited), so the caller can fall back to the raw replay. Returns the + * subscriber to pass to unsubscribeFromAgent, or null for an unknown agent. + */ +export function subscribeToAgentRendered( + agentId: string, + onSnapshot: (snapshot: { data: string; cols: number; rows: number } | null) => void, + cb: (encoded: string) => void, +): ((encoded: string) => void) | null { + const session = sessions.get(agentId); + if (!session) return null; + // The mirror parses output as it arrives but subscribers get it in batches: + // flush, so what the snapshot will contain has all been sent already. + session.flushOutput?.(); + const { cols, rows } = session.proc; + let queued: string[] | null = []; + const subscriber = (encoded: string) => { + if (queued) queued.push(encoded); + else cb(encoded); + }; + session.subscribers.add(subscriber); + void session.queries.serialize().then((text) => { + const later = queued ?? []; + queued = null; + onSnapshot(text === null ? null : { data: Buffer.from(text).toString('base64'), cols, rows }); + for (const encoded of later) cb(encoded); + }); + return subscriber; +} + /** Remove a previously registered output subscriber. */ export function unsubscribeFromAgent(agentId: string, cb: (encoded: string) => void): void { sessions.get(agentId)?.subscribers.delete(cb); diff --git a/electron/ipc/register-wiring.test.ts b/electron/ipc/register-wiring.test.ts index 533e49f39..6f0284ada 100644 --- a/electron/ipc/register-wiring.test.ts +++ b/electron/ipc/register-wiring.test.ts @@ -81,7 +81,9 @@ const NOT_HANDLED_HERE: readonly string[] = [ IPC.NotificationFailed, IPC.PlanContent, IPC.PrChecksUpdate, + IPC.Remote_CloseTaskRequest, IPC.Remote_CreateTaskRequest, + IPC.Remote_GetDiffRequest, IPC.Remote_GetNotesRequest, IPC.Remote_GetProjectsRequest, IPC.Remote_SetNotesRequest, diff --git a/electron/ipc/register.ts b/electron/ipc/register.ts index 374da300d..552a8d335 100644 --- a/electron/ipc/register.ts +++ b/electron/ipc/register.ts @@ -70,7 +70,12 @@ import { readCoverageSummary } from './coverage.js'; import { loadEslintQualityFindings } from './eslint-quality-findings.js'; import { buildVerifyEnv, validateVerifyCommand, verificationRunner } from './verify.js'; import { startRemoteServer, getMCPLogs, type RemoteProject } from '../remote/server.js'; -import type { RemoteAttentionState, RemoteAgent } from '../remote/protocol.js'; +import type { + RemoteAttentionState, + RemoteCloseResult, + RemoteTaskContext, + RemoteTaskDiff, +} from '../remote/protocol.js'; import { atomicWriteFileSync } from '../mcp/atomic.js'; import { getUserDataDir } from '../user-data-dir.js'; import { @@ -578,10 +583,7 @@ export function registerAllHandlers(win: BrowserWindow): void { // the same richer status as the desktop. The renderer owns this computation // (it depends on reactive terminal/git/steps state), so main just caches it. const taskAttention = new Map(); - const taskContext = new Map< - string, - Pick - >(); + const taskContext = new Map(); // --- MCP coordinator (lazy — only loaded when coordinator mode is enabled) --- let coordinatorHandlersRegistered = false; @@ -1666,8 +1668,19 @@ export function registerAllHandlers(win: BrowserWindow): void { callRenderer<{ notes: string }>(IPC.Remote_GetNotesRequest, { taskId }).then((r) => r.notes), setTaskNotes: (taskId: string, notes: string) => callRenderer<{ ok: boolean }>(IPC.Remote_SetNotesRequest, { taskId, notes }).then(() => {}), + closeTaskFromMobile: (taskId: string, force: boolean) => + callRenderer(IPC.Remote_CloseTaskRequest, { taskId, force }), + getTaskDiff: (taskId: string) => + callRenderer(IPC.Remote_GetDiffRequest, { taskId }), getTaskAttention: (taskId: string): RemoteAttentionState => taskAttention.get(taskId) ?? 'idle', getTaskContext: (taskId: string) => taskContext.get(taskId), + getCollapsedTaskIds: (): string[] => { + const result: string[] = []; + for (const [taskId, ctx] of taskContext.entries()) { + if (ctx.collapsed) result.push(taskId); + } + return result; + }, }; const remoteServerOptions = (): Omit< @@ -1711,7 +1724,14 @@ export function registerAllHandlers(win: BrowserWindow): void { statuses?: Record; contexts?: Record< string, - { projectName?: unknown; projectColor?: unknown; agentName?: unknown; lastLine?: unknown } + { + projectName?: unknown; + projectColor?: unknown; + agentName?: unknown; + lastLine?: unknown; + taskName?: unknown; + collapsed?: unknown; + } >; }, ) => { @@ -1722,7 +1742,12 @@ export function registerAllHandlers(win: BrowserWindow): void { if (args.contexts && typeof args.contexts === 'object') { for (const [taskId, context] of Object.entries(args.contexts)) { if (!context || typeof context !== 'object') continue; + const taskName = + typeof context.taskName === 'string' ? context.taskName.slice(0, 200) : undefined; + if (taskName) taskNames.set(taskId, taskName); taskContext.set(taskId, { + taskName, + collapsed: Boolean(context.collapsed), projectName: typeof context.projectName === 'string' ? context.projectName.slice(0, 200) : '', projectColor: diff --git a/electron/ipc/terminal-query-responder.ts b/electron/ipc/terminal-query-responder.ts index a3cce0ed0..55b468d02 100644 --- a/electron/ipc/terminal-query-responder.ts +++ b/electron/ipc/terminal-query-responder.ts @@ -1,14 +1,17 @@ import headless from '@xterm/headless'; +import serializeAddon from '@xterm/addon-serialize'; const { Terminal } = headless; +const { SerializeAddon } = serializeAddon; /** * Lines kept above the viewport. Widening a terminal unwraps lines and pulls * scrollback back into view, which moves the cursor row, so the mirror needs - * about a screen's worth to agree with the renderer's xterm. Far below the - * renderer's 10k because one mirror runs per PTY in the main process. + * about a screen's worth to agree with the renderer's xterm. Phones are sent + * this history when they open a terminal. Far below the renderer's 10k because + * one mirror runs per PTY in the main process. */ -const SCROLLBACK_LINES = 200; +const SCROLLBACK_LINES = 1000; // A cursor position report: CSI row;col R, or the DEC form CSI ? row;col R. // eslint-disable-next-line no-control-regex @@ -26,6 +29,11 @@ export interface TerminalQueryResponder { feedDisplayOnly(data: string): void; /** Current visible screen and input mode; null until queued output has been parsed. */ snapshot(): { text: string; bracketedPaste: boolean } | null; + /** + * The screen and history as ANSI text that redraws them, once everything fed + * so far is parsed. Null once disposed. + */ + serialize(): Promise; resize(cols: number, rows: number): void; dispose(): void; } @@ -48,6 +56,8 @@ export function createTerminalQueryResponder(opts: { rows: Math.max(1, opts.rows), scrollback: SCROLLBACK_LINES, }); + const serializer = new SerializeAddon(); + term.loadAddon(serializer); let muted = 0; let disposed = false; let pendingWrites = 0; @@ -84,6 +94,13 @@ export function createTerminalQueryResponder(opts: { } return { text: lines.join('\n'), bracketedPaste: term.modes.bracketedPasteMode }; }, + serialize() { + if (disposed) return Promise.resolve(null); + // Writes are parsed in order, so this callback runs after all earlier feeds. + return new Promise((resolve) => { + term.write('', () => resolve(disposed ? null : serializer.serialize())); + }); + }, resize(cols, rows) { if (!disposed && cols > 0 && rows > 0) term.resize(cols, rows); }, diff --git a/electron/preload.cjs b/electron/preload.cjs index 1278a0f71..5b31d7783 100644 --- a/electron/preload.cjs +++ b/electron/preload.cjs @@ -103,6 +103,8 @@ const ALLOWED_CHANNELS = new Set([ 'mcp_publish_tour_request', 'remote_get_notes_request', 'remote_set_notes_request', + 'remote_close_task_request', + 'remote_get_diff_request', 'remote_update_task_status', 'remote_renderer_reply', 'plan_content', diff --git a/electron/remote/close-route.test.ts b/electron/remote/close-route.test.ts new file mode 100644 index 000000000..8bed0046f --- /dev/null +++ b/electron/remote/close-route.test.ts @@ -0,0 +1,186 @@ +// HTTP access control + validation for the mobile task-close route +// (POST /api/mobile/tasks/:taskId/close). Closing removes a worktree, so only +// the paired token may call it, and without force the desktop's warnings +// come back as a 409 instead of a close. + +import { describe, it, expect, vi, beforeEach, afterEach, type Mock } from 'vitest'; +import http from 'node:http'; +import type { RemoteCloseResult } from './protocol.js'; + +vi.mock('../ipc/pty.js', () => ({ + writeToAgent: vi.fn(), + resizeAgent: vi.fn(), + killAgent: vi.fn(), + subscribeToAgent: vi.fn(), + subscribeToAgentRendered: vi.fn(() => null), + unsubscribeFromAgent: vi.fn(), + getAgentScrollback: vi.fn(() => null), + getActiveAgentIds: vi.fn(() => []), + getAgentMeta: vi.fn(() => null), + getAgentCols: vi.fn(() => 80), + getAgentRows: vi.fn(() => 24), + onPtyEvent: vi.fn(() => vi.fn()), +})); + +const { startRemoteServer } = await import('./server.js'); + +type StartOpts = Parameters[0]; + +let port = 0; +let mobileToken = ''; +let coordinatorToken = ''; +let subtaskToken = ''; +let generatePin: () => { pin: string; expiresAt: number }; +let stop: () => Promise; +let closeTaskFromMobile: Mock<(taskId: string, force: boolean) => Promise>; + +async function start(extra: Partial = {}): Promise { + const srv = await startRemoteServer({ + port: 0, + host: '127.0.0.1', + staticDir: '/nonexistent', + getTaskName: (id) => id, + getAgentStatus: () => ({ status: 'exited', exitCode: null, lastLine: '' }), + getCoordinator: () => null, + closeTaskFromMobile, + ...extra, + }); + port = srv.port; + mobileToken = srv.mobileToken; + coordinatorToken = srv.token; + subtaskToken = srv.subtaskToken; + generatePin = srv.generatePairingPin; + stop = srv.stop; +} + +/** Elevate the mobile token to a paired one via the desktop PIN. */ +async function pair(): Promise { + const { pin } = generatePin(); + const res = await request('POST', '/api/pair/verify', { token: mobileToken, body: { pin } }); + expect(res.status).toBe(201); + return (res.json as { token: string }).token; +} + +beforeEach(() => { + closeTaskFromMobile = vi.fn( + async (_taskId: string, force: boolean): Promise => + force ? { closed: true } : { closed: false, warnings: ['uncommitted changes'] }, + ); +}); + +afterEach(async () => { + await stop(); +}); + +interface Res { + status: number; + json: unknown; +} + +/** Raw HTTP request so the exact (possibly malformed) path reaches the server. */ +function request( + method: string, + path: string, + opts: { token?: string; body?: unknown } = {}, +): Promise { + return new Promise((resolve, reject) => { + const headers: Record = {}; + if (opts.token) headers.Authorization = `Bearer ${opts.token}`; + let payload: string | undefined; + if (opts.body !== undefined) { + payload = typeof opts.body === 'string' ? opts.body : JSON.stringify(opts.body); + headers['Content-Type'] = 'application/json'; + } + const req = http.request({ host: '127.0.0.1', port, method, path, headers }, (res) => { + let data = ''; + res.on('data', (c) => (data += c)); + res.on('end', () => { + let json: unknown = undefined; + try { + json = data ? JSON.parse(data) : undefined; + } catch { + json = data; + } + resolve({ status: res.statusCode ?? 0, json }); + }); + }); + req.on('error', reject); + if (payload !== undefined) req.write(payload); + req.end(); + }); +} + +const PATH = '/api/mobile/tasks/task-1/close'; + +describe('POST /api/mobile/tasks/:taskId/close', () => { + beforeEach(() => start()); + + it('returns 401 without a token', async () => { + const res = await request('POST', PATH, { body: {} }); + expect(res.status).toBe(401); + expect(closeTaskFromMobile).not.toHaveBeenCalled(); + }); + + it('returns 403 for the view-only mobile token', async () => { + const res = await request('POST', PATH, { token: mobileToken, body: { force: true } }); + expect(res.status).toBe(403); + expect(closeTaskFromMobile).not.toHaveBeenCalled(); + }); + + it('returns 403 for coordinator and subtask tokens', async () => { + for (const token of [coordinatorToken, subtaskToken]) { + const res = await request('POST', PATH, { token, body: { force: true } }); + expect(res.status).toBe(403); + } + expect(closeTaskFromMobile).not.toHaveBeenCalled(); + }); + + it('returns 409 with the warnings when closing would lose work', async () => { + const token = await pair(); + const res = await request('POST', PATH, { token, body: {} }); + expect(res.status).toBe(409); + expect(res.json).toEqual({ + error: 'closing would lose work', + warnings: ['uncommitted changes'], + }); + expect(closeTaskFromMobile).toHaveBeenCalledWith('task-1', false); + }); + + it('closes with force for a paired token', async () => { + const token = await pair(); + const res = await request('POST', PATH, { token, body: { force: true } }); + expect(res.status).toBe(200); + expect(res.json).toEqual({ ok: true }); + expect(closeTaskFromMobile).toHaveBeenCalledWith('task-1', true); + }); + + it('rejects a non-boolean force', async () => { + const token = await pair(); + const res = await request('POST', PATH, { token, body: { force: 'yes' } }); + expect(res.status).toBe(400); + expect(closeTaskFromMobile).not.toHaveBeenCalled(); + }); + + it('rejects a malformed escape and prototype keys', async () => { + const token = await pair(); + for (const id of ['%', '__proto__', 'constructor']) { + const res = await request('POST', `/api/mobile/tasks/${id}/close`, { token, body: {} }); + expect(res.status).toBe(400); + } + expect(closeTaskFromMobile).not.toHaveBeenCalled(); + }); + + it('returns 405 for other methods', async () => { + const token = await pair(); + const res = await request('GET', PATH, { token }); + expect(res.status).toBe(405); + }); + + it('returns 503 when the desktop cannot close tasks', async () => { + await stop(); + await start({ closeTaskFromMobile: undefined }); + const token = await pair(); + const res = await request('POST', PATH, { token, body: { force: true } }); + expect(res.status).toBe(503); + }); +}); diff --git a/electron/remote/coordinator-scoping.test.ts b/electron/remote/coordinator-scoping.test.ts index 1add19049..85227c6a0 100644 --- a/electron/remote/coordinator-scoping.test.ts +++ b/electron/remote/coordinator-scoping.test.ts @@ -12,6 +12,7 @@ vi.mock('../ipc/pty.js', () => ({ resizeAgent: vi.fn(), killAgent: vi.fn(), subscribeToAgent: vi.fn(), + subscribeToAgentRendered: vi.fn(() => null), unsubscribeFromAgent: vi.fn(), getAgentScrollback: vi.fn(() => null), getActiveAgentIds: vi.fn(() => []), diff --git a/electron/remote/diff-route.test.ts b/electron/remote/diff-route.test.ts new file mode 100644 index 000000000..5e36e3514 --- /dev/null +++ b/electron/remote/diff-route.test.ts @@ -0,0 +1,149 @@ +// HTTP access control + validation for the mobile task-diff route +// (GET /api/mobile/tasks/:taskId/diff): readable by the view-only and paired +// phone tokens, like notes, and by nothing else. + +import { describe, it, expect, vi, beforeEach, afterEach, type Mock } from 'vitest'; +import http from 'node:http'; +import type { RemoteTaskDiff } from './protocol.js'; + +vi.mock('../ipc/pty.js', () => ({ + writeToAgent: vi.fn(), + resizeAgent: vi.fn(), + killAgent: vi.fn(), + subscribeToAgent: vi.fn(), + subscribeToAgentRendered: vi.fn(() => null), + unsubscribeFromAgent: vi.fn(), + getAgentScrollback: vi.fn(() => null), + getActiveAgentIds: vi.fn(() => []), + getAgentMeta: vi.fn(() => null), + getAgentCols: vi.fn(() => 80), + getAgentRows: vi.fn(() => 24), + onPtyEvent: vi.fn(() => vi.fn()), +})); + +const { startRemoteServer } = await import('./server.js'); + +type StartOpts = Parameters[0]; + +let port = 0; +let mobileToken = ''; +let coordinatorToken = ''; +let subtaskToken = ''; +let generatePin: () => { pin: string; expiresAt: number }; +let stop: () => Promise; +let getTaskDiff: Mock<(taskId: string) => Promise>; + +async function start(extra: Partial = {}): Promise { + const srv = await startRemoteServer({ + port: 0, + host: '127.0.0.1', + staticDir: '/nonexistent', + getTaskName: (id) => id, + getAgentStatus: () => ({ status: 'exited', exitCode: null, lastLine: '' }), + getCoordinator: () => null, + getTaskDiff, + ...extra, + }); + port = srv.port; + mobileToken = srv.mobileToken; + coordinatorToken = srv.token; + subtaskToken = srv.subtaskToken; + generatePin = srv.generatePairingPin; + stop = srv.stop; +} + +/** Elevate the mobile token to a paired one via the desktop PIN. */ +async function pair(): Promise { + const { pin } = generatePin(); + const res = await request('POST', '/api/pair/verify', { token: mobileToken, body: { pin } }); + expect(res.status).toBe(201); + return (res.json as { token: string }).token; +} + +beforeEach(() => { + getTaskDiff = vi.fn(async (_taskId: string) => ({ + diff: 'diff --git a/x b/x', + truncated: false, + })); +}); + +afterEach(async () => { + await stop(); +}); + +interface Res { + status: number; + json: unknown; +} + +/** Raw HTTP request so the exact (possibly malformed) path reaches the server. */ +function request( + method: string, + path: string, + opts: { token?: string; body?: unknown } = {}, +): Promise { + return new Promise((resolve, reject) => { + const headers: Record = {}; + if (opts.token) headers.Authorization = `Bearer ${opts.token}`; + let payload: string | undefined; + if (opts.body !== undefined) { + payload = typeof opts.body === 'string' ? opts.body : JSON.stringify(opts.body); + headers['Content-Type'] = 'application/json'; + } + const req = http.request({ host: '127.0.0.1', port, method, path, headers }, (res) => { + let data = ''; + res.on('data', (c) => (data += c)); + res.on('end', () => { + let json: unknown = undefined; + try { + json = data ? JSON.parse(data) : undefined; + } catch { + json = data; + } + resolve({ status: res.statusCode ?? 0, json }); + }); + }); + req.on('error', reject); + if (payload !== undefined) req.write(payload); + req.end(); + }); +} + +const PATH = '/api/mobile/tasks/task-1/diff'; + +describe('GET /api/mobile/tasks/:taskId/diff', () => { + beforeEach(() => start()); + + it('returns 401 without a token', async () => { + expect((await request('GET', PATH)).status).toBe(401); + expect(getTaskDiff).not.toHaveBeenCalled(); + }); + + it('returns 403 for coordinator and subtask tokens', async () => { + for (const token of [coordinatorToken, subtaskToken]) { + expect((await request('GET', PATH, { token })).status).toBe(403); + } + expect(getTaskDiff).not.toHaveBeenCalled(); + }); + + it('returns the diff for the view-only and paired tokens', async () => { + for (const token of [mobileToken, await pair()]) { + const res = await request('GET', PATH, { token }); + expect(res.status).toBe(200); + expect(res.json).toEqual({ diff: 'diff --git a/x b/x', truncated: false }); + } + expect(getTaskDiff).toHaveBeenCalledWith('task-1'); + }); + + it('rejects a malformed escape and prototype keys', async () => { + for (const id of ['%', '__proto__']) { + const res = await request('GET', `/api/mobile/tasks/${id}/diff`, { token: mobileToken }); + expect(res.status).toBe(400); + } + expect(getTaskDiff).not.toHaveBeenCalled(); + }); + + it('returns 405 for writes', async () => { + expect((await request('POST', PATH, { token: await pair(), body: {} })).status).toBe(405); + }); +}); diff --git a/electron/remote/mindmap-route.test.ts b/electron/remote/mindmap-route.test.ts index 187db579d..3cb6efa71 100644 --- a/electron/remote/mindmap-route.test.ts +++ b/electron/remote/mindmap-route.test.ts @@ -23,6 +23,7 @@ vi.mock('../ipc/pty.js', () => ({ resizeAgent: vi.fn(), killAgent: vi.fn(), subscribeToAgent: vi.fn(), + subscribeToAgentRendered: vi.fn(() => null), unsubscribeFromAgent: vi.fn(), getAgentScrollback: vi.fn(() => null), getActiveAgentIds: vi.fn(() => []), diff --git a/electron/remote/notes-route.test.ts b/electron/remote/notes-route.test.ts index 4d086fb7d..22a950f23 100644 --- a/electron/remote/notes-route.test.ts +++ b/electron/remote/notes-route.test.ts @@ -11,6 +11,7 @@ vi.mock('../ipc/pty.js', () => ({ resizeAgent: vi.fn(), killAgent: vi.fn(), subscribeToAgent: vi.fn(), + subscribeToAgentRendered: vi.fn(() => null), unsubscribeFromAgent: vi.fn(), getAgentScrollback: vi.fn(() => null), getActiveAgentIds: vi.fn(() => []), diff --git a/electron/remote/pairing.test.ts b/electron/remote/pairing.test.ts index 527746524..55a583935 100644 --- a/electron/remote/pairing.test.ts +++ b/electron/remote/pairing.test.ts @@ -13,6 +13,7 @@ vi.mock('../ipc/pty.js', () => ({ resizeAgent: vi.fn(), killAgent: vi.fn(), subscribeToAgent: vi.fn(), + subscribeToAgentRendered: vi.fn(() => null), unsubscribeFromAgent: vi.fn(), getAgentScrollback: vi.fn(() => null), getActiveAgentIds: vi.fn(() => []), diff --git a/electron/remote/protocol.test.ts b/electron/remote/protocol.test.ts index d34985153..9c5150160 100644 --- a/electron/remote/protocol.test.ts +++ b/electron/remote/protocol.test.ts @@ -60,3 +60,28 @@ describe('chat message validation', () => { expect(parseClientMessage(JSON.stringify({ ...action, ...invalid }))).toBeNull(); }); }); + +describe('parseClientMessage view-size', () => { + it('accepts a size and a bare release', () => { + expect( + parseClientMessage(JSON.stringify({ type: 'view-size', agentId: 'a', cols: 60, rows: 50 })), + ).toEqual({ type: 'view-size', agentId: 'a', cols: 60, rows: 50 }); + expect(parseClientMessage(JSON.stringify({ type: 'view-size', agentId: 'a' }))).toEqual({ + type: 'view-size', + agentId: 'a', + }); + }); + + it('rejects partial or out-of-range sizes', () => { + for (const size of [ + { cols: 60 }, + { cols: 0, rows: 50 }, + { cols: 60, rows: 501 }, + { cols: 1.5, rows: 2 }, + ]) { + expect( + parseClientMessage(JSON.stringify({ type: 'view-size', agentId: 'a', ...size })), + ).toBeNull(); + } + }); +}); diff --git a/electron/remote/protocol.ts b/electron/remote/protocol.ts index 3a856eecf..5918c679a 100644 --- a/electron/remote/protocol.ts +++ b/electron/remote/protocol.ts @@ -30,8 +30,31 @@ export interface RemoteAgent { attention: RemoteAttentionState; /** Set for the app's built-in chat, which has no terminal to stream. */ kind?: 'chat'; + /** True when the task is collapsed / minimized on the desktop. */ + 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. + */ +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. */ +export interface RemoteTaskDiff { + diff: string; + truncated: boolean; +} + +/** Metadata attached to a task's remote agent entry. */ +export type RemoteTaskContext = Pick< + RemoteAgent, + 'projectName' | 'projectColor' | 'agentName' | 'lastLine' +> & { + taskName?: string; + collapsed?: boolean; +}; + /** Conversation actions a paired phone may take on a running chat. */ export const REMOTE_CHAT_ACTIONS = [ 'models', @@ -117,6 +140,18 @@ export interface ResizeCommand { rows: number; } +/** + * A paired phone's terminal size for an agent it is viewing: the PTY takes it + * so full-screen TUIs fill the phone. Without cols/rows it hands the size back + * to the desktop; so does disconnecting. + */ +export interface ViewSizeCommand { + type: 'view-size'; + agentId: string; + cols?: number; + rows?: number; +} + export interface KillCommand { type: 'kill'; agentId: string; @@ -155,6 +190,7 @@ export type ClientMessage = | AuthCommand | InputCommand | ResizeCommand + | ViewSizeCommand | KillCommand | SubscribeCommand | UnsubscribeCommand @@ -199,15 +235,19 @@ export function parseClientMessage(raw: string): ClientMessage | null { ...(typeof msg.prefixKey === 'string' ? { prefixKey: msg.prefixKey } : {}), }; case 'resize': - if (typeof msg.cols !== 'number' || typeof msg.rows !== 'number') return null; - if (!Number.isInteger(msg.cols) || !Number.isInteger(msg.rows)) return null; - if (msg.cols < 1 || msg.cols > 500 || msg.rows < 1 || msg.rows > 500) return null; + if (!isTerminalDimension(msg.cols) || !isTerminalDimension(msg.rows)) return null; return { type: 'resize', agentId: msg.agentId, cols: msg.cols, rows: msg.rows, }; + case 'view-size': { + if (msg.cols === undefined && msg.rows === undefined) + return { type: 'view-size', agentId: msg.agentId }; + if (!isTerminalDimension(msg.cols) || !isTerminalDimension(msg.rows)) return null; + return { type: 'view-size', agentId: msg.agentId, cols: msg.cols, rows: msg.rows }; + } case 'kill': return { type: 'kill', agentId: msg.agentId }; case 'subscribe': @@ -227,6 +267,10 @@ export function parseClientMessage(raw: string): ClientMessage | null { } } +function isTerminalDimension(value: unknown): value is number { + return typeof value === 'number' && Number.isInteger(value) && value >= 1 && value <= 500; +} + function parseChatAction(msg: Record, agentId: string): ChatActionCommand | null { if (typeof msg.requestId !== 'string' || !msg.requestId.length || msg.requestId.length > 80) return null; diff --git a/electron/remote/ring-buffer.test.ts b/electron/remote/ring-buffer.test.ts new file mode 100644 index 000000000..6b7d933cb --- /dev/null +++ b/electron/remote/ring-buffer.test.ts @@ -0,0 +1,54 @@ +import { describe, expect, it } from 'vitest'; +import { RingBuffer } from './ring-buffer.js'; + +describe('RingBuffer', () => { + it('defaults to 2 MB capacity', () => { + const rb = new RingBuffer(); + // Default capacity should be 2 MB + expect(rb.length).toBe(0); + const chunk = Buffer.alloc(1024 * 1024, 'a'); + rb.write(chunk); + expect(rb.length).toBe(1024 * 1024); + }); + + it('reads back appended data in order', () => { + const rb = new RingBuffer(64); + rb.write(Buffer.from('hello ')); + rb.write(Buffer.from('world')); + expect(rb.read().toString('utf8')).toBe('hello world'); + expect(rb.length).toBe(11); + }); + + it('wraps around and retains the latest bytes when capacity is exceeded', () => { + const rb = new RingBuffer(10); + rb.write(Buffer.from('0123456789')); + expect(rb.read().toString('utf8')).toBe('0123456789'); + expect(rb.length).toBe(10); + + rb.write(Buffer.from('abc')); + expect(rb.read().toString('utf8')).toBe('3456789abc'); + expect(rb.length).toBe(10); + }); + + it('handles write larger than capacity by keeping tail', () => { + const rb = new RingBuffer(5); + rb.write(Buffer.from('0123456789')); + expect(rb.read().toString('utf8')).toBe('56789'); + expect(rb.length).toBe(5); + }); + + it('converts to base64 correctly', () => { + const rb = new RingBuffer(32); + rb.write(Buffer.from('test data')); + expect(rb.toBase64()).toBe(Buffer.from('test data').toString('base64')); + }); + + it('clears correctly', () => { + const rb = new RingBuffer(32); + rb.write(Buffer.from('temporary')); + expect(rb.length).toBe(9); + rb.clear(); + expect(rb.length).toBe(0); + expect(rb.read().length).toBe(0); + }); +}); diff --git a/electron/remote/ring-buffer.ts b/electron/remote/ring-buffer.ts index 963811748..11bb1fff8 100644 --- a/electron/remote/ring-buffer.ts +++ b/electron/remote/ring-buffer.ts @@ -4,7 +4,7 @@ export class RingBuffer { private pos = 0; private full = false; - constructor(private readonly capacity: number = 64 * 1024) { + constructor(private readonly capacity: number = 2 * 1024 * 1024) { this.buf = Buffer.alloc(capacity); } diff --git a/electron/remote/server-chat.test.ts b/electron/remote/server-chat.test.ts index 950082659..792a2b0ed 100644 --- a/electron/remote/server-chat.test.ts +++ b/electron/remote/server-chat.test.ts @@ -12,6 +12,7 @@ vi.mock('../ipc/pty.js', () => ({ resizeAgent: vi.fn(), killAgent: vi.fn(), subscribeToAgent: vi.fn(), + subscribeToAgentRendered: vi.fn(() => null), unsubscribeFromAgent: vi.fn(), getAgentScrollback: vi.fn(() => null), getActiveAgentIds: vi.fn(() => ['a1', 'a2']), diff --git a/electron/remote/server-helpers.test.ts b/electron/remote/server-helpers.test.ts index cddb38570..03ec74717 100644 --- a/electron/remote/server-helpers.test.ts +++ b/electron/remote/server-helpers.test.ts @@ -9,6 +9,7 @@ vi.mock('../ipc/pty.js', () => ({ resizeAgent: vi.fn(), killAgent: vi.fn(), subscribeToAgent: vi.fn(), + subscribeToAgentRendered: vi.fn(() => null), unsubscribeFromAgent: vi.fn(), getAgentScrollback: vi.fn(() => null), getActiveAgentIds: vi.fn(() => []), @@ -22,7 +23,8 @@ vi.mock('./protocol.js', () => ({ parseClientMessage: vi.fn(() => null), })); -const { requireOwnedTask, readCoordinatorBody } = await import('./server.js'); +const pty = await import('../ipc/pty.js'); +const { requireOwnedTask, readCoordinatorBody, buildAgentList } = await import('./server.js'); type FakeRequest = EventEmitter & { destroy: ReturnType }; @@ -102,3 +104,95 @@ describe('readCoordinatorBody', () => { expect(req.destroy).not.toHaveBeenCalled(); }); }); + +describe('buildAgentList', () => { + it('includes active agents and incorporates collapsed tasks', () => { + vi.mocked(pty.getActiveAgentIds).mockReturnValue(['agent-1']); + vi.mocked(pty.getAgentMeta).mockImplementation((id) => + id === 'agent-1' + ? ({ + agentId: 'agent-1', + taskId: 'task-1', + isShell: false, + command: 'node', + args: [], + cols: 80, + rows: 24, + createdAt: Date.now(), + } as ReturnType) + : null, + ); + + const getTaskName = (id: string) => (id === 'task-1' ? 'Task 1' : 'Task 2'); + const getAgentStatus = () => ({ + status: 'running' as const, + exitCode: null, + lastLine: 'Working...', + }); + const getTaskAttention = () => 'active' as const; + const getTaskContext = (id: string) => + id === 'task-2' + ? { + projectName: 'Proj', + projectColor: '#fff', + agentName: 'AgentDef', + lastLine: 'Last known line', + taskName: 'Task 2 Name', + collapsed: true, + } + : undefined; + const getCollapsedTaskIds = () => ['task-2']; + + const agents = buildAgentList( + getTaskName, + getAgentStatus, + getTaskAttention, + getTaskContext, + getCollapsedTaskIds, + ); + + expect(agents).toHaveLength(2); + expect(agents[0]).toMatchObject({ + agentId: 'agent-1', + taskId: 'task-1', + taskName: 'Task 1', + status: 'running', + }); + expect(agents[1]).toMatchObject({ + agentId: 'collapsed:task-2', + taskId: 'task-2', + taskName: 'Task 2 Name', + projectName: 'Proj', + status: 'exited', + collapsed: true, + lastLine: 'Last known line', + }); + }); + + it('marks existing agents as collapsed if present in getCollapsedTaskIds', () => { + vi.mocked(pty.getActiveAgentIds).mockReturnValue(['agent-1']); + vi.mocked(pty.getAgentMeta).mockImplementation( + () => + ({ + agentId: 'agent-1', + taskId: 'task-1', + isShell: false, + }) as ReturnType, + ); + + const agents = buildAgentList( + () => 'Task 1', + () => ({ status: 'running', exitCode: null, lastLine: '' }), + () => 'idle', + undefined, + () => ['task-1'], + ); + + expect(agents).toHaveLength(1); + expect(agents[0]).toMatchObject({ + agentId: 'agent-1', + taskId: 'task-1', + collapsed: true, + }); + }); +}); diff --git a/electron/remote/server-ws.test.ts b/electron/remote/server-ws.test.ts index dd4237020..41c17158e 100644 --- a/electron/remote/server-ws.test.ts +++ b/electron/remote/server-ws.test.ts @@ -14,8 +14,10 @@ import { join } from 'node:path'; vi.mock('../ipc/pty.js', () => ({ writeToAgent: vi.fn(), resizeAgent: vi.fn(), + setAgentRemoteSize: vi.fn(), killAgent: vi.fn(), subscribeToAgent: vi.fn(), + subscribeToAgentRendered: vi.fn(() => null), unsubscribeFromAgent: vi.fn(), getAgentScrollback: vi.fn(() => null), getActiveAgentIds: vi.fn(() => []), @@ -135,6 +137,14 @@ describe('mobile token over WebSocket', () => { expect(pty.resizeAgent).not.toHaveBeenCalled(); }); + it('rejects view-size with 4003 and leaves the PTY size alone', async () => { + const ws = await connectAndAuth(mobileToken); + const closed = waitForClose(ws); + ws.send(JSON.stringify({ type: 'view-size', agentId: 'agent-1', cols: 60, rows: 50 })); + expect(await closed).toBe(4003); + expect(pty.setAgentRemoteSize).not.toHaveBeenCalled(); + }); + it('rejects kill with 4003 and does not kill the agent', async () => { const ws = await connectAndAuth(mobileToken); const closed = waitForClose(ws); @@ -144,6 +154,65 @@ describe('mobile token over WebSocket', () => { }); }); +describe('subscribe snapshot', () => { + it('sends the rendered snapshot when the PTY has one', async () => { + vi.mocked(pty.subscribeToAgentRendered).mockImplementation((_id, onSnapshot) => { + onSnapshot({ data: 'cmVuZGVyZWQ=', cols: 60, rows: 50 }); + return vi.fn(); + }); + const ws = await connectAndAuth(mobileToken); + const scrollback = new Promise((resolve) => + ws.on('message', (raw) => { + const msg = JSON.parse(String(raw)) as { type: string }; + if (msg.type === 'scrollback') resolve(msg); + }), + ); + ws.send(JSON.stringify({ type: 'subscribe', agentId: 'agent-1' })); + expect(await scrollback).toEqual({ + type: 'scrollback', + agentId: 'agent-1', + data: 'cmVuZGVyZWQ=', + cols: 60, + rows: 50, + }); + expect(pty.getAgentScrollback).not.toHaveBeenCalled(); + ws.close(); + }); +}); + +describe('paired phone view size', () => { + it('sizes the PTY and hands it back when the phone disconnects', async () => { + const ws = await connectAndAuth(await pair()); + ws.send(JSON.stringify({ type: 'view-size', agentId: 'agent-1', cols: 60, rows: 50 })); + await vi.waitFor(() => + expect(pty.setAgentRemoteSize).toHaveBeenCalledWith('agent-1', { cols: 60, rows: 50 }), + ); + ws.close(); + await vi.waitFor(() => expect(pty.setAgentRemoteSize).toHaveBeenCalledWith('agent-1', null)); + }); + + it('hands the size back on an explicit release', async () => { + const ws = await connectAndAuth(await pair()); + ws.send(JSON.stringify({ type: 'view-size', agentId: 'agent-1', cols: 60, rows: 50 })); + ws.send(JSON.stringify({ type: 'view-size', agentId: 'agent-1' })); + await vi.waitFor(() => expect(pty.setAgentRemoteSize).toHaveBeenCalledWith('agent-1', null)); + ws.close(); + }); + + it('ignores a release from a phone that does not own the size', async () => { + const owner = await connectAndAuth(await pair()); + const other = await connectAndAuth(await pair()); + owner.send(JSON.stringify({ type: 'view-size', agentId: 'agent-1', cols: 60, rows: 50 })); + await vi.waitFor(() => expect(pty.setAgentRemoteSize).toHaveBeenCalledTimes(1)); + other.send(JSON.stringify({ type: 'view-size', agentId: 'agent-1' })); + other.close(); + // Give the ignored release a chance to arrive before checking. + await new Promise((resolve) => setTimeout(resolve, 50)); + expect(pty.setAgentRemoteSize).toHaveBeenCalledTimes(1); + owner.close(); + }); +}); + describe('paired token over WebSocket', () => { it('forwards input to the agent PTY', async () => { const ws = await connectAndAuth(await pair()); diff --git a/electron/remote/server.ts b/electron/remote/server.ts index 6d95159fe..8d6883017 100644 --- a/electron/remote/server.ts +++ b/electron/remote/server.ts @@ -11,8 +11,10 @@ import { warn } from '../log.js'; import { writeToAgent, resizeAgent, + setAgentRemoteSize, killAgent, subscribeToAgent, + subscribeToAgentRendered, unsubscribeFromAgent, getAgentScrollback, getActiveAgentIds, @@ -26,6 +28,9 @@ import { type ServerMessage, type RemoteAgent, type RemoteAttentionState, + type RemoteTaskContext, + type RemoteCloseResult, + type RemoteTaskDiff, } from './protocol.js'; import { createChatSubscriptions, @@ -311,7 +316,7 @@ function getNetworkIps(): { wifi: string | null; tailscale: string | null } { } /** Build the agent list, deduplicated by taskId (keeps main agent per task). */ -function buildAgentList( +export function buildAgentList( getTaskName: (taskId: string) => string, getAgentStatus: (agentId: string) => { status: 'running' | 'exited'; @@ -319,9 +324,8 @@ function buildAgentList( lastLine: string; }, getTaskAttention: (taskId: string) => RemoteAttentionState, - getTaskContext?: ( - taskId: string, - ) => Pick | undefined, + getTaskContext?: (taskId: string) => RemoteTaskContext | undefined, + getCollapsedTaskIds?: () => string[], ): RemoteAgent[] { const byTask = new Map(); for (const agentId of getActiveAgentIds()) { @@ -346,6 +350,29 @@ function buildAgentList( byTask.set(meta.taskId, agent); } } + + if (getCollapsedTaskIds) { + for (const taskId of getCollapsedTaskIds()) { + const existing = byTask.get(taskId); + if (existing) { + existing.collapsed = true; + } else { + const ctx = getTaskContext?.(taskId); + byTask.set(taskId, { + agentId: `collapsed:${taskId}`, + taskId, + taskName: ctx?.taskName || getTaskName(taskId), + status: 'exited', + exitCode: null, + lastLine: ctx?.lastLine ?? '', + attention: getTaskAttention(taskId), + collapsed: true, + ...ctx, + }); + } + } + } + return Array.from(byTask.values()); } @@ -934,11 +961,14 @@ export function startRemoteServer(opts: { getTaskNotes?: (taskId: string) => Promise; /** Persist a task's notes (renderer-backed). */ setTaskNotes?: (taskId: string, notes: string) => Promise; + /** Close a task (renderer-backed); unless `force`, refuses when work would be lost. */ + closeTaskFromMobile?: (taskId: string, force: boolean) => Promise; + /** A task's diff against its base branch (renderer-backed). */ + getTaskDiff?: (taskId: string) => Promise; /** Renderer-derived task attention state (needs input, working, ready, …). */ getTaskAttention?: (taskId: string) => RemoteAttentionState; - getTaskContext?: ( - taskId: string, - ) => Pick | undefined; + getTaskContext?: (taskId: string) => RemoteTaskContext | undefined; + getCollapsedTaskIds?: () => string[]; /** The desktop's built-in chats; without it phones only see terminals. */ chats?: RemoteChatSource; }): Promise { @@ -949,7 +979,13 @@ export function startRemoteServer(opts: { opts.getTaskAttention ?? (() => 'idle'); const agentList = (): RemoteAgent[] => withChatAgents( - buildAgentList(opts.getTaskName, opts.getAgentStatus, getTaskAttention, opts.getTaskContext), + buildAgentList( + opts.getTaskName, + opts.getAgentStatus, + getTaskAttention, + opts.getTaskContext, + opts.getCollapsedTaskIds, + ), opts.chats?.list() ?? [], (taskId) => ({ taskName: opts.getTaskName(taskId), @@ -1353,6 +1389,66 @@ export function startRemoteServer(opts: { return jsonEnd(405, { error: 'method not allowed' }); } + // --- Task diff (read: mobile + paired) --- + // The same changes the desktop's diff view shows. Read-only, like notes: + // the view-only token already streams the terminals that made them. + const diffMatch = url.pathname.match(/^\/api\/mobile\/tasks\/([^/]+)\/diff$/); + if (diffMatch) { + if (tokenClass !== 'mobile' && tokenClass !== 'paired') + return jsonEnd(403, { error: 'forbidden' }); + if (req.method !== 'GET') return jsonEnd(405, { error: 'method not allowed' }); + const getTaskDiff = opts.getTaskDiff; + if (!getTaskDiff) return jsonEnd(503, { error: 'diff unavailable' }); + let taskId: string; + try { + taskId = decodeURIComponent(diffMatch[1]); + } catch { + return jsonEnd(400, { error: 'invalid task id' }); + } + if (taskId === '__proto__' || taskId === 'constructor' || taskId === 'prototype') { + return jsonEnd(400, { error: 'invalid task id' }); + } + getTaskDiff(taskId) + .then((diff) => jsonEnd(200, diff)) + .catch((err) => jsonEnd(500, { error: String(err) })); + return; + } + + // --- Paired-mobile task close --- + // Closing stops the task's agents and removes its worktree, so it needs + // the paired token. Without `force` the desktop refuses when work would + // be lost and answers 409 with its Close Task dialog's warnings. + const closeMatch = url.pathname.match(/^\/api\/mobile\/tasks\/([^/]+)\/close$/); + if (closeMatch) { + if (tokenClass !== 'paired') return jsonEnd(403, { error: 'forbidden' }); + if (req.method !== 'POST') return jsonEnd(405, { error: 'method not allowed' }); + const closeTask = opts.closeTaskFromMobile; + if (!closeTask) return jsonEnd(503, { error: 'task close unavailable' }); + let taskId: string; + try { + taskId = decodeURIComponent(closeMatch[1]); + } catch { + return jsonEnd(400, { error: 'invalid task id' }); + } + if (taskId === '__proto__' || taskId === 'constructor' || taskId === 'prototype') { + return jsonEnd(400, { error: 'invalid task id' }); + } + readJsonBody(req) + .then((body) => { + if (body.force !== undefined && typeof body.force !== 'boolean') + return jsonEnd(400, { error: 'force must be a boolean' }); + closeTask(taskId, body.force === true) + .then((result) => + result.closed + ? jsonEnd(200, { ok: true }) + : jsonEnd(409, { error: 'closing would lose work', warnings: result.warnings }), + ) + .catch((err) => jsonEnd(500, { error: String(err) })); + }) + .catch(() => jsonEnd(400, { error: 'bad request' })); + return; + } + // --- Task notes (read: mobile + paired; write: paired) --- // The notes textarea shown on the desktop task panel. The QR-code mobile // token may read notes; writing them (text that lands in the desktop UI @@ -1664,6 +1760,8 @@ export function startRemoteServer(opts: { }); const clientSubs = new WeakMap void>>(); + // Which phone currently sizes each agent's PTY (see ViewSizeCommand). + const viewSizeOwners = new Map(); const authenticatedClients = new Set(); const clientTokenTypes = new Map(); const pendingSubmissions = new Map>(); @@ -1788,6 +1886,12 @@ export function startRemoteServer(opts: { ws.close(4003, 'Pairing required'); return; } + // A viewing phone may size the terminal once paired: it changes what the + // desktop pane shows, and typing already needs the same trust. + if (msg.type === 'view-size' && tokenType !== 'coordinator' && tokenType !== 'paired') { + ws.close(4003, 'Pairing required'); + return; + } if ((msg.type === 'resize' || msg.type === 'kill') && tokenType !== 'coordinator') { ws.close(4003, 'Forbidden'); return; @@ -1903,6 +2007,23 @@ export function startRemoteServer(opts: { } break; + case 'view-size': { + const size = + msg.cols !== undefined && msg.rows !== undefined + ? { cols: msg.cols, rows: msg.rows } + : null; + // The last phone to size an agent owns it; only the owner hands it back. + if (!size && viewSizeOwners.get(msg.agentId) !== ws) break; + try { + setAgentRemoteSize(msg.agentId, size); + if (size) viewSizeOwners.set(msg.agentId, ws); + else viewSizeOwners.delete(msg.agentId); + } catch { + viewSizeOwners.delete(msg.agentId); + } + break; + } + case 'kill': try { killAgent(msg.agentId); @@ -1915,19 +2036,23 @@ export function startRemoteServer(opts: { const subs = clientSubs.get(ws); if (subs?.has(msg.agentId)) break; - const scrollback = getAgentScrollback(msg.agentId); - if (scrollback) { + const sendScrollback = (data: string, cols: number, rows: number) => { + if (ws.readyState !== WebSocket.OPEN) return; ws.send( JSON.stringify({ type: 'scrollback', agentId: msg.agentId, - data: scrollback, - cols: getAgentCols(msg.agentId), - rows: getAgentRows(msg.agentId), + data, + cols, + rows, } satisfies ServerMessage), ); - } - + }; + const sendRawScrollback = () => { + const scrollback = getAgentScrollback(msg.agentId); + if (scrollback) + sendScrollback(scrollback, getAgentCols(msg.agentId), getAgentRows(msg.agentId)); + }; const cb = (encoded: string) => { if (ws.readyState === WebSocket.OPEN) { ws.send( @@ -1939,8 +2064,22 @@ export function startRemoteServer(opts: { ); } }; - if (subscribeToAgent(msg.agentId, cb)) { - subs?.set(msg.agentId, cb); + // Phones get the rendered screen and history; a live agent's raw + // replay can be nothing but repaints of its last screen. + const subscriber = subscribeToAgentRendered( + msg.agentId, + (snapshot) => { + if (snapshot) sendScrollback(snapshot.data, snapshot.cols, snapshot.rows); + else sendRawScrollback(); + }, + cb, + ); + if (subscriber) { + subs?.set(msg.agentId, subscriber); + } else { + // Not a live PTY session: replay what is left, as before. + sendRawScrollback(); + if (subscribeToAgent(msg.agentId, cb)) subs?.set(msg.agentId, cb); } break; } @@ -1958,6 +2097,15 @@ export function startRemoteServer(opts: { }); ws.on('close', () => { + for (const [agentId, owner] of viewSizeOwners) { + if (owner !== ws) continue; + viewSizeOwners.delete(agentId); + try { + setAgentRemoteSize(agentId, null); + } catch { + /* agent gone */ + } + } authenticatedClients.delete(ws); clientTokenTypes.delete(ws); const timer = authTimers.get(ws); diff --git a/electron/remote/session-route.test.ts b/electron/remote/session-route.test.ts index bb43d9a2f..0498caf3b 100644 --- a/electron/remote/session-route.test.ts +++ b/electron/remote/session-route.test.ts @@ -9,6 +9,7 @@ vi.mock('../ipc/pty.js', () => ({ resizeAgent: vi.fn(), killAgent: vi.fn(), subscribeToAgent: vi.fn(), + subscribeToAgentRendered: vi.fn(() => null), unsubscribeFromAgent: vi.fn(), getAgentScrollback: vi.fn(() => null), getActiveAgentIds: vi.fn(() => []), diff --git a/eslint.config.js b/eslint.config.js index f6f3e4da4..1043a7fe5 100644 --- a/eslint.config.js +++ b/eslint.config.js @@ -12,6 +12,8 @@ export default [ 'dist-electron/**', 'dist-remote/**', 'release/**', + 'android/.gradle/**', + 'android/**/build/**', 'node_modules/**', '.worktrees/**', '.claude/**', diff --git a/package-lock.json b/package-lock.json index b34afa1ec..59aeb7dfb 100644 --- a/package-lock.json +++ b/package-lock.json @@ -23,6 +23,7 @@ "@modelcontextprotocol/sdk": "^1.27.1", "@xterm/addon-fit": "^0.12.0-beta.195", "@xterm/addon-search": "^0.17.0-beta.195", + "@xterm/addon-serialize": "^0.15.0-beta.195", "@xterm/addon-web-links": "^0.13.0-beta.195", "@xterm/addon-webgl": "^0.20.0-beta.194", "@xterm/headless": "^6.1.0-beta.195", @@ -4497,6 +4498,15 @@ "@xterm/xterm": "^6.1.0-beta.195" } }, + "node_modules/@xterm/addon-serialize": { + "version": "0.15.0-beta.195", + "resolved": "https://registry.npmjs.org/@xterm/addon-serialize/-/addon-serialize-0.15.0-beta.195.tgz", + "integrity": "sha512-BKlEQ5cao0J8sLvtFU/kmc4LAkAS+2JkblVIEu/FFHWUsuEaA4yZz4BtZrWBhql2JRfOq62hZFrqahZ0ViuZgA==", + "license": "MIT", + "peerDependencies": { + "@xterm/xterm": "^6.1.0-beta.195" + } + }, "node_modules/@xterm/addon-web-links": { "version": "0.13.0-beta.195", "resolved": "https://registry.npmjs.org/@xterm/addon-web-links/-/addon-web-links-0.13.0-beta.195.tgz", diff --git a/package.json b/package.json index 11656d88b..5705c9d70 100644 --- a/package.json +++ b/package.json @@ -59,6 +59,7 @@ "@modelcontextprotocol/sdk": "^1.27.1", "@xterm/addon-fit": "^0.12.0-beta.195", "@xterm/addon-search": "^0.17.0-beta.195", + "@xterm/addon-serialize": "^0.15.0-beta.195", "@xterm/addon-web-links": "^0.13.0-beta.195", "@xterm/addon-webgl": "^0.20.0-beta.194", "@xterm/headless": "^6.1.0-beta.195", diff --git a/src/remote/attention.ts b/src/remote/attention.ts index 62836c101..1d0829e72 100644 --- a/src/remote/attention.ts +++ b/src/remote/attention.ts @@ -35,8 +35,9 @@ const BY_ATTENTION: Partial> = { }; export function agentStatusDisplay( - agent: Pick, + agent: Pick, ): StatusDisplay { + if (agent.collapsed) return { label: 'Minimized', color: GREY, glow: false }; const known = BY_ATTENTION[agent.attention]; if (known) return known; // attention is 'idle' (or unknown): distinguish a live idle agent from an diff --git a/src/store/remoteStatusSync.ts b/src/store/remoteStatusSync.ts index dd9b6d23c..95e67f703 100644 --- a/src/store/remoteStatusSync.ts +++ b/src/store/remoteStatusSync.ts @@ -72,7 +72,10 @@ export function startRemoteStatusSync(): () => void { const statuses: Record = {}; const contexts: Record< string, - Pick + Pick< + RemoteAgent, + 'projectName' | 'projectColor' | 'agentName' | 'lastLine' | 'taskName' | 'collapsed' + > > = {}; for (const taskId of [...store.taskOrder, ...store.collapsedTaskOrder]) { statuses[taskId] = getTaskAttentionState(taskId); @@ -83,13 +86,21 @@ export function startRemoteStatusSync(): () => void { const agent = store.agents[agentId]; const project = store.projects.find((project) => project.id === task.projectId); contexts[taskId] = { + taskName: task.name, + collapsed: Boolean(task.collapsed), projectName: project?.name ?? '', projectColor: project?.color ?? '', - agentName: agent?.def.name ?? '', + agentName: agent?.def.name ?? task.savedAgentDef?.name ?? '', // Untracked like the terminal tail: a streaming reply changes every frame. lastLine: taskUsesAgentChat(task) - ? untrack(() => remoteChatPreview(store.agents[task.agentIds[0]]?.chatState)) - : remoteOutputPreview(getAgentOutputTail(agentId)), + ? untrack(() => + remoteChatPreview( + task.agentIds[0] ? store.agents[task.agentIds[0]]?.chatState : undefined, + ), + ) + : agentId + ? remoteOutputPreview(getAgentOutputTail(agentId)) + : '', }; } diff --git a/src/store/remoteTaskHandler.client.test.tsx b/src/store/remoteTaskHandler.client.test.tsx index 89fa2b60c..8f875a64a 100644 --- a/src/store/remoteTaskHandler.client.test.tsx +++ b/src/store/remoteTaskHandler.client.test.tsx @@ -188,3 +188,87 @@ it('adds a task created from a phone without taking focus from the active task', expect(store.activeTaskId).toBe('task'); expect(store.activeAgentId).toBe('agent'); }); + +/** 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 }); + 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('refuses to close a task with unsaved work unless forced', async () => { + vi.mocked(invoke).mockImplementation(async (channel: string) => + channel === IPC.GetWorktreeStatus + ? { has_uncommitted_changes: true, has_committed_changes: false } + : undefined, + ); + + const reply = await closeRequest(false); + + expect(reply).toMatchObject({ + ok: true, + data: { + closed: false, + warnings: ['There are uncommitted changes that will be permanently lost.'], + }, + }); + expect(vi.mocked(invoke)).not.toHaveBeenCalledWith(IPC.DeleteTask, expect.anything()); + expect(store.tasks.task.closingStatus).toBeUndefined(); +}); + +it('closes a clean task without forcing', async () => { + vi.mocked(invoke).mockImplementation(async (channel: string) => + channel === IPC.GetWorktreeStatus + ? { has_uncommitted_changes: false, has_committed_changes: false } + : undefined, + ); + + const reply = await closeRequest(false); + + expect(reply).toMatchObject({ ok: true, data: { closed: true } }); + expect(vi.mocked(invoke)).toHaveBeenCalledWith(IPC.DeleteTask, expect.anything()); +}); + +it('force-closes without checking the worktree', async () => { + const reply = await closeRequest(true); + + expect(reply).toMatchObject({ ok: true, data: { closed: true } }); + expect(vi.mocked(invoke)).not.toHaveBeenCalledWith(IPC.GetWorktreeStatus, expect.anything()); + expect(vi.mocked(invoke)).toHaveBeenCalledWith(IPC.DeleteTask, expect.anything()); +}); + +it('reports an unknown task instead of closing', async () => { + listeners.get(IPC.Remote_CloseTaskRequest)?.({ reqId: 'req', taskId: 'missing', force: true }); + await vi.waitFor(() => + expect(vi.mocked(invoke)).toHaveBeenCalledWith( + IPC.Remote_RendererReply, + expect.objectContaining({ ok: false, error: 'Task not found' }), + ), + ); +}); + +it('answers a phone diff request with the task diff against its base', async () => { + setStore('tasks', 'task', 'baseBranch', 'main'); + vi.mocked(invoke).mockImplementation(async (channel: string) => + channel === IPC.GetAllFileDiffs ? 'diff --git a/x b/x' : undefined, + ); + + listeners.get(IPC.Remote_GetDiffRequest)?.({ reqId: 'req', taskId: 'task' }); + + await vi.waitFor(() => + expect(vi.mocked(invoke)).toHaveBeenCalledWith( + IPC.Remote_RendererReply, + expect.objectContaining({ + ok: true, + data: { diff: 'diff --git a/x b/x', truncated: false }, + }), + ), + ); + expect(vi.mocked(invoke)).toHaveBeenCalledWith(IPC.GetAllFileDiffs, { + worktreePath: '/tmp/task', + baseBranch: 'main', + }); +}); diff --git a/src/store/remoteTaskHandler.ts b/src/store/remoteTaskHandler.ts index 8fa411f8f..ec367fba1 100644 --- a/src/store/remoteTaskHandler.ts +++ b/src/store/remoteTaskHandler.ts @@ -9,12 +9,16 @@ import { getTaskMindMap, openCanvasViewFromAgent, updateTaskMindMapFromAgent } f import { getTaskReasoning, updateTaskReasoningFromAgent } from './reasoning'; import { store } from './core'; import { codeProjects } from './projects'; -import { createTask, updateTaskNotes } from './tasks'; +import { closeTask, createTask, getCoordinatorCloseWarning, updateTaskNotes } from './tasks'; import { invoke } from '../lib/ipc'; import { errMessage } from '../lib/log'; import { IPC } from '../../electron/ipc/channels'; import { resolveSkipPermissionsArgs } from '../../electron/shared/skip-permissions'; -import type { AgentDef, GitIgnoredEntry } from '../ipc/types'; +import type { AgentDef, GitIgnoredEntry, WorktreeStatus } from '../ipc/types'; +import type { Task } from './types'; +import type { RemoteCloseResult, RemoteTaskDiff } from '../../electron/remote/protocol'; +import { getTaskDiffBaseBranch, loadTaskDiff } from '../lib/load-task-diff'; +import { getProjectPath } from './projects'; interface RendererRequest { reqId: string; @@ -35,6 +39,11 @@ interface SetNotesRequest extends RendererRequest { notes: string; } +interface CloseTaskRequest extends RendererRequest { + taskId: string; + force: boolean; +} + function reply(reqId: string, ok: boolean, data?: unknown, error?: string): void { // Fire-and-forget: main resolves/rejects the pending HTTP response by reqId. invoke(IPC.Remote_RendererReply, { reqId, ok, data, error }).catch(() => {}); @@ -140,6 +149,66 @@ function handleGetNotes(req: GetNotesRequest): void { reply(req.reqId, true, { notes: store.tasks[req.taskId].notes ?? '' }); } +/** What closing would lose, worded like the desktop Close Task dialog. */ +async function closeTaskWarnings(task: Task): Promise { + const warnings: string[] = []; + const coordinatorWarning = getCoordinatorCloseWarning(task.id); + if (coordinatorWarning) warnings.push(coordinatorWarning); + if (task.gitIsolation === 'worktree' && !task.externalWorktree) { + const status = await invoke(IPC.GetWorktreeStatus, { + worktreePath: task.worktreePath, + }); + if (status.has_uncommitted_changes) + warnings.push('There are uncommitted changes that will be permanently lost.'); + if (status.has_committed_changes) + warnings.push('This branch has commits that have not been merged into main.'); + } + return warnings; +} + +async function handleCloseTask(req: CloseTaskRequest): Promise { + try { + if (!isKnownTask(store.tasks, req.taskId)) throw new Error('Task not found'); + if (!req.force) { + const warnings = await closeTaskWarnings(store.tasks[req.taskId]); + if (warnings.length > 0) { + reply(req.reqId, true, { closed: false, warnings } satisfies RemoteCloseResult); + return; + } + } + await closeTask(req.taskId); + // closeTask records backend failures on the task instead of throwing. + const after = store.tasks[req.taskId]; + if (after?.closingStatus === 'error') throw new Error(after.closingError ?? 'Close failed'); + reply(req.reqId, true, { closed: true } satisfies RemoteCloseResult); + } catch (err) { + reply(req.reqId, false, undefined, errMessage(err)); + } +} + +/** Past this, a phone gets the start of the diff; the desktop shows all of it. */ +const MAX_REMOTE_DIFF_CHARS = 1_000_000; + +async function handleGetDiff(req: GetNotesRequest): Promise { + try { + if (!isKnownTask(store.tasks, req.taskId)) throw new Error('Task not found'); + const task = store.tasks[req.taskId]; + const { rawDiff } = await loadTaskDiff({ + worktreePath: task.worktreePath, + projectRoot: getProjectPath(task.projectId), + branchName: task.branchName, + baseBranch: getTaskDiffBaseBranch(task.gitIsolation, task.baseBranch), + }); + const truncated = rawDiff.length > MAX_REMOTE_DIFF_CHARS; + reply(req.reqId, true, { + diff: truncated ? rawDiff.slice(0, MAX_REMOTE_DIFF_CHARS) : rawDiff, + truncated, + } satisfies RemoteTaskDiff); + } catch (err) { + reply(req.reqId, false, undefined, errMessage(err)); + } +} + function handleSetNotes(req: SetNotesRequest): void { if (!isKnownTask(store.tasks, req.taskId)) { reply(req.reqId, false, undefined, 'Task not found'); @@ -197,6 +266,12 @@ export function startRemoteTaskHandlers(): () => void { if (data && typeof data === 'object') handleSetNotes(data as SetNotesRequest); }, ); + const offDiff = window.electron.ipcRenderer.on(IPC.Remote_GetDiffRequest, (data: unknown) => { + if (data && typeof data === 'object') void handleGetDiff(data as GetNotesRequest); + }); + const offClose = window.electron.ipcRenderer.on(IPC.Remote_CloseTaskRequest, (data: unknown) => { + if (data && typeof data === 'object') void handleCloseTask(data as CloseTaskRequest); + }); const offReadMap = window.electron.ipcRenderer.on(IPC.MCP_ReadMindMapRequest, (data: unknown) => { if (!data || typeof data !== 'object') return; const req = data as GetNotesRequest; @@ -254,5 +329,7 @@ export function startRemoteTaskHandlers(): () => void { offCreate(); offGetNotes(); offSetNotes(); + offClose(); + offDiff(); }; } From 9e3f57c707d5e3ff4fc0127bd3c7a8cfa919e39c Mon Sep 17 00:00:00 2001 From: BW Date: Tue, 29 Sep 2026 20:57:08 -0600 Subject: [PATCH 2/7] feat(android): widget card transparency and color Settings -> Widget now sets how opaque the home-screen widget's card is (100/75/50/25%) and what color it is (Obsidian, Slate, Light). The fill and border are one color faded together, and each card carries text colors that stay readable, so a light card gets dark text. Both settings push to placed widgets immediately instead of waiting for the next publish, and Settings previews the real card at the chosen transparency. RemoteViews can only set a background from a resource, so the stops are shape drawables: three colors by four stops. A contrast test keeps each palette's text on its own card at or above the 4.5:1 WCAG AA floor; it caught Slate's timestamp color at 4.1:1, now lightened. --- android/README.md | 4 +- .../com/parallelcode/phone/AgentWidget.kt | 98 +++++++++++ .../com/parallelcode/phone/MainActivity.kt | 23 +++ .../com/parallelcode/phone/SettingsScreen.kt | 152 ++++++++++++++++++ .../com/parallelcode/phone/SettingsStore.kt | 15 ++ .../main/res/drawable/widget_background.xml | 7 - .../res/drawable/widget_card_light_100.xml | 7 + .../res/drawable/widget_card_light_25.xml | 7 + .../res/drawable/widget_card_light_50.xml | 7 + .../res/drawable/widget_card_light_75.xml | 7 + .../res/drawable/widget_card_obsidian_100.xml | 7 + .../res/drawable/widget_card_obsidian_25.xml | 7 + .../res/drawable/widget_card_obsidian_50.xml | 7 + .../res/drawable/widget_card_obsidian_75.xml | 7 + .../res/drawable/widget_card_slate_100.xml | 7 + .../res/drawable/widget_card_slate_25.xml | 7 + .../res/drawable/widget_card_slate_50.xml | 7 + .../res/drawable/widget_card_slate_75.xml | 7 + .../app/src/main/res/layout/widget_agents.xml | 3 +- android/app/src/main/res/values/colors.xml | 30 ++++ .../com/parallelcode/phone/AgentWidgetTest.kt | 54 +++++++ .../parallelcode/phone/SettingsStoreTest.kt | 34 ++++ 22 files changed, 494 insertions(+), 10 deletions(-) delete mode 100644 android/app/src/main/res/drawable/widget_background.xml create mode 100644 android/app/src/main/res/drawable/widget_card_light_100.xml create mode 100644 android/app/src/main/res/drawable/widget_card_light_25.xml create mode 100644 android/app/src/main/res/drawable/widget_card_light_50.xml create mode 100644 android/app/src/main/res/drawable/widget_card_light_75.xml create mode 100644 android/app/src/main/res/drawable/widget_card_obsidian_100.xml create mode 100644 android/app/src/main/res/drawable/widget_card_obsidian_25.xml create mode 100644 android/app/src/main/res/drawable/widget_card_obsidian_50.xml create mode 100644 android/app/src/main/res/drawable/widget_card_obsidian_75.xml create mode 100644 android/app/src/main/res/drawable/widget_card_slate_100.xml create mode 100644 android/app/src/main/res/drawable/widget_card_slate_25.xml create mode 100644 android/app/src/main/res/drawable/widget_card_slate_50.xml create mode 100644 android/app/src/main/res/drawable/widget_card_slate_75.xml diff --git a/android/README.md b/android/README.md index ee9a3c56c..b0f17b8b9 100644 --- a/android/README.md +++ b/android/README.md @@ -9,12 +9,12 @@ Native companion app for the desktop's **Connect Phone** (Remote Access) feature - **Several computers:** link more than one desktop (for example the installed app and a dev build, or two machines) and switch between them in Settings → Computers; each keeps its own pairing. - **Agents:** live list with each agent's status and last line, under the desktop's Claude, Codex, and Antigravity 5-hour and weekly usage meters (hidden on desktops without `/api/mobile/usage`). - **Minimized tasks:** tasks minimized on the desktop are pinned below the live list; a setting hides them. -- **Settings:** theme (follow system, Obsidian Dark, Light), keep the screen on, connection status, and forget this computer. +- **Settings:** theme (follow system, Obsidian Dark, Light), keep the screen on, widget background transparency and card color, connection status, and forget this computer. - **Swipe between tasks:** with a task open, swipe sideways to the previous or next one in the list; the header shows its position ("2 of 5"). - **Terminal:** an agent's terminal in the desktop's default Obsidian colors (light or dark with the phone). Once paired: a reply box and keys a phone keyboard lacks (Enter, Esc, Tab, arrows, Ctrl+C). With "Fit the terminal to this phone" on (Settings, off by default), the terminal takes the phone's size while open so full-screen agents such as Claude Code fill it; the computer's own terminal shifts meanwhile and gets its size back when you leave. - **Changes:** the task's diff against its base branch, file by file with added and removed lines. - **Quick replies and voice:** saved replies above the reply box (edit them in Settings) 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. +- **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. - **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. diff --git a/android/app/src/main/java/com/parallelcode/phone/AgentWidget.kt b/android/app/src/main/java/com/parallelcode/phone/AgentWidget.kt index 8bf87c1e2..473c5d065 100644 --- a/android/app/src/main/java/com/parallelcode/phone/AgentWidget.kt +++ b/android/app/src/main/java/com/parallelcode/phone/AgentWidget.kt @@ -10,6 +10,89 @@ import android.widget.RemoteViews import androidx.core.content.edit import java.time.LocalTime import java.time.format.DateTimeFormatter +import kotlin.math.abs + +/** How opaque the widget's card is, in the stops the settings slider offers, opaque first. */ +val WIDGET_TRANSPARENCY_STEPS = listOf(100, 75, 50, 25) + +/** Snap [percent] to the nearest stop, so the slider and a hand-edited value always land on a card. */ +fun widgetTransparencyStep(percent: Int): Int = + WIDGET_TRANSPARENCY_STEPS.minByOrNull { abs(it - percent) } ?: WIDGET_TRANSPARENCY_STEPS.first() + +/** + * A card color, with text colors that stay readable on it. The card's fill and border are the + * same color; the Light card carries a light grey border so its edge reads against white. + */ +data class WidgetPalette( + val key: String, + val label: String, + /** The opaque card fill, so Settings can preview the card without loading a drawable. */ + val fill: Int, + val title: Int, + val headline: Int, + val usage: Int, + val updated: Int, + /** The card shape at each transparency stop; RemoteViews sets a background by resource only. */ + val backgrounds: Map, +) { + /** The card shape at [percent] opacity, snapped to a stop. */ + fun background(percent: Int): Int = backgrounds.getValue(widgetTransparencyStep(percent)) +} + +val WIDGET_PALETTES = listOf( + WidgetPalette( + key = "obsidian", + label = "Obsidian", + fill = 0xFF1E1E1E.toInt(), + title = 0xFFC4A77D.toInt(), // --accent + headline = 0xFFEDEDED.toInt(), // --fg + usage = 0xFFB5B5B5.toInt(), // --fg-muted + updated = 0xFF919191.toInt(), // --fg-subtle + backgrounds = mapOf( + 100 to R.drawable.widget_card_obsidian_100, + 75 to R.drawable.widget_card_obsidian_75, + 50 to R.drawable.widget_card_obsidian_50, + 25 to R.drawable.widget_card_obsidian_25, + ), + ), + WidgetPalette( + key = "slate", + label = "Slate", + fill = 0xFF3A3F44.toInt(), + title = 0xFFD8C39B.toInt(), + headline = 0xFFF2F4F5.toInt(), + usage = 0xFFC4CACE.toInt(), + updated = 0xFFA8AFB4.toInt(), + backgrounds = mapOf( + 100 to R.drawable.widget_card_slate_100, + 75 to R.drawable.widget_card_slate_75, + 50 to R.drawable.widget_card_slate_50, + 25 to R.drawable.widget_card_slate_25, + ), + ), + WidgetPalette( + key = "light", + label = "Light", + fill = 0xFFFFFFFF.toInt(), + title = 0xFF8A6433.toInt(), // the light theme's accent + headline = 0xFF1F1F1F.toInt(), + usage = 0xFF555555.toInt(), + updated = 0xFF6E6E6E.toInt(), + backgrounds = mapOf( + 100 to R.drawable.widget_card_light_100, + 75 to R.drawable.widget_card_light_75, + 50 to R.drawable.widget_card_light_50, + 25 to R.drawable.widget_card_light_25, + ), + ), +) + +/** The palette for a stored [key], falling back to Obsidian for an unknown one. */ +fun widgetPalette(key: String?): WidgetPalette = + WIDGET_PALETTES.firstOrNull { it.key == key } ?: WIDGET_PALETTES.first() + +/** The card shape for [key] at [percent] opacity. */ +fun widgetBackground(key: String?, percent: Int): Int = widgetPalette(key).background(percent) /** What the widget shows, worked out from the live agent list and usage snapshot. */ data class WidgetSummary(val headline: String, val usage: String) @@ -59,6 +142,11 @@ class AgentWidget : AppWidgetProvider() { putString(KEY_USAGE, summary.usage) putString(KEY_UPDATED, LocalTime.now().format(DateTimeFormatter.ofPattern("HH:mm"))) } + refresh(context) + } + + /** Redraw every placed widget, so a settings change shows without waiting for new data. */ + fun refresh(context: Context) { val manager = AppWidgetManager.getInstance(context) render(context, manager, manager.getAppWidgetIds(ComponentName(context, AgentWidget::class.java))) } @@ -66,17 +154,27 @@ class AgentWidget : AppWidgetProvider() { private fun render(context: Context, manager: AppWidgetManager, ids: IntArray) { if (ids.isEmpty()) return val prefs = context.getSharedPreferences(PREFS, Context.MODE_PRIVATE) + val settings = context.getSharedPreferences(SettingsStore.PREFS_NAME, Context.MODE_PRIVATE) val open = PendingIntent.getActivity( context, 0, Intent(context, MainActivity::class.java), PendingIntent.FLAG_IMMUTABLE or PendingIntent.FLAG_UPDATE_CURRENT, ) + val palette = widgetPalette(settings.getString(SettingsStore.KEY_WIDGET_PALETTE, null)) + val transparency = settings.getInt(SettingsStore.KEY_WIDGET_TRANSPARENCY, 100) val views = RemoteViews(context.packageName, R.layout.widget_agents).apply { setTextViewText(R.id.widget_headline, prefs.getString(KEY_HEADLINE, null) ?: "Open the app to connect") setTextViewText(R.id.widget_usage, prefs.getString(KEY_USAGE, null).orEmpty()) setTextViewText(R.id.widget_updated, prefs.getString(KEY_UPDATED, null).orEmpty()) + // The card's color picks the text colors too, so a light card stays readable. + setTextColor(R.id.widget_title, palette.title) + setTextColor(R.id.widget_headline, palette.headline) + setTextColor(R.id.widget_usage, palette.usage) + setTextColor(R.id.widget_updated, palette.updated) setOnClickPendingIntent(R.id.widget_root, open) + // RemoteViews can only set a background through the View setter it reflects on. + setInt(R.id.widget_root, "setBackgroundResource", palette.background(transparency)) } manager.updateAppWidget(ids, views) } diff --git a/android/app/src/main/java/com/parallelcode/phone/MainActivity.kt b/android/app/src/main/java/com/parallelcode/phone/MainActivity.kt index 1fb4e62dc..773a32c78 100644 --- a/android/app/src/main/java/com/parallelcode/phone/MainActivity.kt +++ b/android/app/src/main/java/com/parallelcode/phone/MainActivity.kt @@ -137,6 +137,25 @@ class PhoneViewModel(application: Application) : AndroidViewModel(application) { settingsStore.showMinimizedTasks = value _showMinimizedTasks.value = value } + + private val _widgetTransparency = MutableStateFlow(settingsStore.widgetTransparency) + val widgetTransparency: StateFlow = _widgetTransparency.asStateFlow() + + fun setWidgetTransparency(value: Int) { + settingsStore.widgetTransparency = value + _widgetTransparency.value = settingsStore.widgetTransparency + // The widget draws on its own schedule, so push the new card to it right away. + AgentWidget.refresh(getApplication()) + } + + private val _widgetPalette = MutableStateFlow(settingsStore.widgetPalette) + val widgetPalette: StateFlow = _widgetPalette.asStateFlow() + + fun setWidgetPalette(value: String) { + settingsStore.widgetPalette = value + _widgetPalette.value = settingsStore.widgetPalette + AgentWidget.refresh(getApplication()) + } } class MainActivity : ComponentActivity() { @@ -339,6 +358,10 @@ private fun PhoneApp(model: PhoneViewModel) { onAlwaysFollowOutputChange = model::setAlwaysFollowOutput, fitTerminalToPhone = model.fitTerminalToPhone.collectAsState().value, onFitTerminalToPhoneChange = model::setFitTerminalToPhone, + widgetTransparency = model.widgetTransparency.collectAsState().value, + onWidgetTransparencyChange = model::setWidgetTransparency, + widgetPalette = model.widgetPalette.collectAsState().value, + onWidgetPaletteChange = model::setWidgetPalette, quickReplies = quickReplies, onQuickRepliesChange = model::setQuickReplies, notifications = notifications, 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 c2ef12743..15c6d750e 100644 --- a/android/app/src/main/java/com/parallelcode/phone/SettingsScreen.kt +++ b/android/app/src/main/java/com/parallelcode/phone/SettingsScreen.kt @@ -9,12 +9,14 @@ import androidx.compose.animation.fadeOut import androidx.compose.animation.shrinkVertically import androidx.compose.foundation.BorderStroke import androidx.compose.foundation.background +import androidx.compose.foundation.border import androidx.compose.foundation.clickable import androidx.compose.foundation.layout.Arrangement import androidx.compose.foundation.layout.Box import androidx.compose.foundation.layout.Column import androidx.compose.foundation.layout.PaddingValues import androidx.compose.foundation.layout.Row +import androidx.compose.foundation.layout.RowScope import androidx.compose.foundation.layout.Spacer import androidx.compose.foundation.layout.fillMaxSize import androidx.compose.foundation.layout.fillMaxWidth @@ -35,6 +37,8 @@ import androidx.compose.material3.OutlinedButton import androidx.compose.material3.RadioButton import androidx.compose.material3.RadioButtonDefaults import androidx.compose.material3.Scaffold +import androidx.compose.material3.Slider +import androidx.compose.material3.SliderDefaults import androidx.compose.material3.Switch import androidx.compose.material3.SwitchDefaults import androidx.compose.material3.Text @@ -45,6 +49,7 @@ import androidx.compose.runtime.Composable import androidx.compose.ui.Alignment import androidx.compose.ui.Modifier import androidx.compose.ui.draw.clip +import androidx.compose.ui.graphics.Color import androidx.compose.ui.text.font.FontFamily import androidx.compose.ui.text.font.FontWeight import androidx.compose.ui.unit.dp @@ -57,6 +62,7 @@ import androidx.compose.runtime.remember import androidx.compose.ui.focus.onFocusChanged import androidx.compose.material3.OutlinedTextField import androidx.compose.ui.unit.sp +import kotlin.math.roundToInt @OptIn(ExperimentalMaterial3Api::class) @Composable @@ -73,6 +79,10 @@ fun SettingsScreen( onAlwaysFollowOutputChange: (Boolean) -> Unit, fitTerminalToPhone: Boolean, onFitTerminalToPhoneChange: (Boolean) -> Unit, + widgetTransparency: Int, + onWidgetTransparencyChange: (Int) -> Unit, + widgetPalette: String, + onWidgetPaletteChange: (String) -> Unit, quickReplies: List, onQuickRepliesChange: (List) -> Unit, notifications: NotificationPrefs, @@ -376,6 +386,106 @@ fun SettingsScreen( } } + // WIDGET SECTION + item { + SectionHeader("WIDGET") + Spacer(Modifier.height(8.dp)) + Card( + modifier = Modifier.fillMaxWidth(), + shape = MaterialTheme.shapes.large, + colors = CardDefaults.cardColors(containerColor = MaterialTheme.colorScheme.surface), + border = BorderStroke(1.dp, AppTheme.extra.border), + ) { + Column( + modifier = Modifier + .fillMaxWidth() + .padding(16.dp), + verticalArrangement = Arrangement.spacedBy(10.dp), + ) { + Row( + modifier = Modifier.fillMaxWidth(), + verticalAlignment = Alignment.CenterVertically, + horizontalArrangement = Arrangement.SpaceBetween, + ) { + Text( + "Background transparency", + style = MaterialTheme.typography.bodyLarge, + fontWeight = FontWeight.SemiBold, + color = MaterialTheme.colorScheme.onSurface, + ) + Text( + "$widgetTransparency%", + style = MaterialTheme.typography.bodyLarge, + fontFamily = FontFamily.Monospace, + fontWeight = FontWeight.Medium, + color = MaterialTheme.colorScheme.primary, + ) + } + Text( + "How much of your wallpaper shows through the home-screen widget. Its border fades with the card.", + style = MaterialTheme.typography.bodyMedium, + color = AppTheme.extra.textMuted, + ) + Slider( + value = widgetTransparency.toFloat(), + onValueChange = { onWidgetTransparencyChange(widgetTransparencyStep(it.roundToInt())) }, + valueRange = WIDGET_TRANSPARENCY_STEPS.last().toFloat()..WIDGET_TRANSPARENCY_STEPS.first().toFloat(), + // The stops above are the only values, so the slider snaps between them. + steps = WIDGET_TRANSPARENCY_STEPS.size - 2, + colors = SliderDefaults.colors( + thumbColor = MaterialTheme.colorScheme.primary, + activeTrackColor = MaterialTheme.colorScheme.primary, + inactiveTrackColor = AppTheme.extra.inputBg, + inactiveTickColor = AppTheme.extra.border, + activeTickColor = MaterialTheme.colorScheme.onPrimary, + ), + ) + Row( + modifier = Modifier.fillMaxWidth(), + horizontalArrangement = Arrangement.SpaceBetween, + ) { + Text( + "${WIDGET_TRANSPARENCY_STEPS.last()}%", + style = MaterialTheme.typography.labelSmall, + color = AppTheme.extra.textMuted, + ) + Text( + "${WIDGET_TRANSPARENCY_STEPS.first()}%", + style = MaterialTheme.typography.labelSmall, + color = AppTheme.extra.textMuted, + ) + } + + HorizontalDivider(thickness = 1.dp, color = AppTheme.extra.borderSubtle) + + Text( + "Card color", + style = MaterialTheme.typography.bodyLarge, + fontWeight = FontWeight.SemiBold, + color = MaterialTheme.colorScheme.onSurface, + ) + Text( + "The card's color. Its text colors follow so they stay readable.", + style = MaterialTheme.typography.bodyMedium, + color = AppTheme.extra.textMuted, + ) + Row( + modifier = Modifier.fillMaxWidth(), + horizontalArrangement = Arrangement.spacedBy(10.dp), + ) { + WIDGET_PALETTES.forEach { palette -> + WidgetSwatch( + palette = palette, + transparency = widgetTransparency, + selected = palette.key == widgetPalette, + onClick = { onWidgetPaletteChange(palette.key) }, + ) + } + } + } + } + } + // DESKTOP CONNECTION SECTION item { SectionHeader("DESKTOP CONNECTION") @@ -648,6 +758,48 @@ private fun SettingSwitchRow( } } +/** A card color to tap: a preview of the widget in that color at the chosen transparency. */ +@Composable +private fun RowScope.WidgetSwatch( + palette: WidgetPalette, + transparency: Int, + selected: Boolean, + onClick: () -> Unit, +) { + // The card drawables hold the same alpha steps, so mirror it here over the settings surface. + val fill = Color(palette.fill).copy(alpha = widgetTransparencyStep(transparency) / 100f) + Column( + modifier = Modifier + .weight(1f) + .clip(MaterialTheme.shapes.small) + .clickable(onClick = onClick) + .padding(vertical = 4.dp), + verticalArrangement = Arrangement.spacedBy(6.dp), + ) { + Box( + modifier = Modifier + .fillMaxWidth() + .height(48.dp) + .clip(RoundedCornerShape(8.dp)) + .background(MaterialTheme.colorScheme.background) + .background(fill) + .border( + width = if (selected) 2.dp else 1.dp, + color = if (selected) MaterialTheme.colorScheme.primary else AppTheme.extra.border, + shape = RoundedCornerShape(8.dp), + ), + contentAlignment = Alignment.Center, + ) { + Text( + palette.label, + style = MaterialTheme.typography.labelMedium, + fontWeight = if (selected) FontWeight.Bold else FontWeight.Medium, + color = Color(palette.headline), + ) + } + } +} + /** One reply per line; saved when the field loses focus or the screen closes. */ @Composable private fun QuickRepliesEditor(replies: List, onChange: (List) -> Unit) { diff --git a/android/app/src/main/java/com/parallelcode/phone/SettingsStore.kt b/android/app/src/main/java/com/parallelcode/phone/SettingsStore.kt index f405d4269..2faf22788 100644 --- a/android/app/src/main/java/com/parallelcode/phone/SettingsStore.kt +++ b/android/app/src/main/java/com/parallelcode/phone/SettingsStore.kt @@ -50,6 +50,19 @@ class SettingsStore(private val prefs: SharedPreferences) { .lines().map { it.trim() }.filter { it.isNotEmpty() } set(value) = prefs.edit { putString(KEY_QUICK_REPLIES, value.joinToString("\n")) } + /** + * How opaque the home-screen widget's card is, as one of [WIDGET_TRANSPARENCY_STEPS]. Lower + * values show more wallpaper through the card; its border fades with the fill. + */ + var widgetTransparency: Int + get() = widgetTransparencyStep(prefs.getInt(KEY_WIDGET_TRANSPARENCY, 100)) + set(value) = prefs.edit { putInt(KEY_WIDGET_TRANSPARENCY, widgetTransparencyStep(value)) } + + /** The widget's card color, as a [WIDGET_PALETTES] key; unknown keys read back as Obsidian. */ + var widgetPalette: String + get() = widgetPalette(prefs.getString(KEY_WIDGET_PALETTE, null)).key + set(value) = prefs.edit { putString(KEY_WIDGET_PALETTE, widgetPalette(value).key) } + /** Keep watching in the background and notify about agents (see [AgentWatchService]). */ var notificationsEnabled: Boolean get() = prefs.getBoolean(KEY_NOTIFICATIONS, false) @@ -87,6 +100,8 @@ class SettingsStore(private val prefs: SharedPreferences) { const val KEY_NOTIFY_NEEDS_INPUT = "notifyNeedsInput" const val KEY_NOTIFY_ERRORS = "notifyErrors" const val KEY_NOTIFY_FINISHED = "notifyFinished" + const val KEY_WIDGET_TRANSPARENCY = "widgetTransparency" + const val KEY_WIDGET_PALETTE = "widgetPalette" const val THEME_SYSTEM = "system" const val THEME_DARK = "dark" diff --git a/android/app/src/main/res/drawable/widget_background.xml b/android/app/src/main/res/drawable/widget_background.xml deleted file mode 100644 index 9684d0759..000000000 --- a/android/app/src/main/res/drawable/widget_background.xml +++ /dev/null @@ -1,7 +0,0 @@ - - - - - - - diff --git a/android/app/src/main/res/drawable/widget_card_light_100.xml b/android/app/src/main/res/drawable/widget_card_light_100.xml new file mode 100644 index 000000000..5b5c823f4 --- /dev/null +++ b/android/app/src/main/res/drawable/widget_card_light_100.xml @@ -0,0 +1,7 @@ + + + + + + + diff --git a/android/app/src/main/res/drawable/widget_card_light_25.xml b/android/app/src/main/res/drawable/widget_card_light_25.xml new file mode 100644 index 000000000..0aed36e8d --- /dev/null +++ b/android/app/src/main/res/drawable/widget_card_light_25.xml @@ -0,0 +1,7 @@ + + + + + + + diff --git a/android/app/src/main/res/drawable/widget_card_light_50.xml b/android/app/src/main/res/drawable/widget_card_light_50.xml new file mode 100644 index 000000000..2cecba24c --- /dev/null +++ b/android/app/src/main/res/drawable/widget_card_light_50.xml @@ -0,0 +1,7 @@ + + + + + + + diff --git a/android/app/src/main/res/drawable/widget_card_light_75.xml b/android/app/src/main/res/drawable/widget_card_light_75.xml new file mode 100644 index 000000000..9700c07a9 --- /dev/null +++ b/android/app/src/main/res/drawable/widget_card_light_75.xml @@ -0,0 +1,7 @@ + + + + + + + diff --git a/android/app/src/main/res/drawable/widget_card_obsidian_100.xml b/android/app/src/main/res/drawable/widget_card_obsidian_100.xml new file mode 100644 index 000000000..69b972aa6 --- /dev/null +++ b/android/app/src/main/res/drawable/widget_card_obsidian_100.xml @@ -0,0 +1,7 @@ + + + + + + + diff --git a/android/app/src/main/res/drawable/widget_card_obsidian_25.xml b/android/app/src/main/res/drawable/widget_card_obsidian_25.xml new file mode 100644 index 000000000..59e2182da --- /dev/null +++ b/android/app/src/main/res/drawable/widget_card_obsidian_25.xml @@ -0,0 +1,7 @@ + + + + + + + diff --git a/android/app/src/main/res/drawable/widget_card_obsidian_50.xml b/android/app/src/main/res/drawable/widget_card_obsidian_50.xml new file mode 100644 index 000000000..f32eda1e4 --- /dev/null +++ b/android/app/src/main/res/drawable/widget_card_obsidian_50.xml @@ -0,0 +1,7 @@ + + + + + + + diff --git a/android/app/src/main/res/drawable/widget_card_obsidian_75.xml b/android/app/src/main/res/drawable/widget_card_obsidian_75.xml new file mode 100644 index 000000000..d5e4b3b84 --- /dev/null +++ b/android/app/src/main/res/drawable/widget_card_obsidian_75.xml @@ -0,0 +1,7 @@ + + + + + + + diff --git a/android/app/src/main/res/drawable/widget_card_slate_100.xml b/android/app/src/main/res/drawable/widget_card_slate_100.xml new file mode 100644 index 000000000..76d462d7f --- /dev/null +++ b/android/app/src/main/res/drawable/widget_card_slate_100.xml @@ -0,0 +1,7 @@ + + + + + + + diff --git a/android/app/src/main/res/drawable/widget_card_slate_25.xml b/android/app/src/main/res/drawable/widget_card_slate_25.xml new file mode 100644 index 000000000..74ea36d15 --- /dev/null +++ b/android/app/src/main/res/drawable/widget_card_slate_25.xml @@ -0,0 +1,7 @@ + + + + + + + diff --git a/android/app/src/main/res/drawable/widget_card_slate_50.xml b/android/app/src/main/res/drawable/widget_card_slate_50.xml new file mode 100644 index 000000000..c23533e9a --- /dev/null +++ b/android/app/src/main/res/drawable/widget_card_slate_50.xml @@ -0,0 +1,7 @@ + + + + + + + diff --git a/android/app/src/main/res/drawable/widget_card_slate_75.xml b/android/app/src/main/res/drawable/widget_card_slate_75.xml new file mode 100644 index 000000000..0c98d71f1 --- /dev/null +++ b/android/app/src/main/res/drawable/widget_card_slate_75.xml @@ -0,0 +1,7 @@ + + + + + + + diff --git a/android/app/src/main/res/layout/widget_agents.xml b/android/app/src/main/res/layout/widget_agents.xml index 252eaa395..e06b3889b 100644 --- a/android/app/src/main/res/layout/widget_agents.xml +++ b/android/app/src/main/res/layout/widget_agents.xml @@ -3,7 +3,7 @@ android:id="@+id/widget_root" android:layout_width="match_parent" android:layout_height="match_parent" - android:background="@drawable/widget_background" + android:background="@drawable/widget_card_obsidian_100" android:orientation="vertical" android:padding="12dp"> @@ -13,6 +13,7 @@ android:orientation="horizontal"> #FF000000 + + + #FF1E1E1E + #BF1E1E1E + #801E1E1E + #401E1E1E + #FF1E1E1E + #BF1E1E1E + #801E1E1E + #401E1E1E + + #FF3A3F44 + #BF3A3F44 + #803A3F44 + #403A3F44 + #FF3A3F44 + #BF3A3F44 + #803A3F44 + #403A3F44 + + #FFFFFFFF + #BFFFFFFF + #80FFFFFF + #40FFFFFF + #FFE8EAEC + #BFE8EAEC + #80E8EAEC + #40E8EAEC diff --git a/android/app/src/test/java/com/parallelcode/phone/AgentWidgetTest.kt b/android/app/src/test/java/com/parallelcode/phone/AgentWidgetTest.kt index 0363ab46b..dfaad8c16 100644 --- a/android/app/src/test/java/com/parallelcode/phone/AgentWidgetTest.kt +++ b/android/app/src/test/java/com/parallelcode/phone/AgentWidgetTest.kt @@ -1,6 +1,7 @@ package com.parallelcode.phone import org.junit.Assert.assertEquals +import org.junit.Assert.assertTrue import org.junit.Test class AgentWidgetTest { @@ -25,4 +26,57 @@ class AgentWidgetTest { ) assertEquals("Left:\nClaude 5h 78% 7d 13%", widgetSummary(emptyList(), usage, connected = true).usage) } + + @Test + fun snapsTransparencyToTheOfferedStops() { + assertEquals(100, widgetTransparencyStep(100)) + assertEquals(75, widgetTransparencyStep(80)) + assertEquals(50, widgetTransparencyStep(40)) + assertEquals(25, widgetTransparencyStep(0)) + // Out of range and nonsense values still land on a real stop. + assertEquals(100, widgetTransparencyStep(140)) + assertEquals(25, widgetTransparencyStep(-10)) + } + + @Test + fun everyPaletteAndStopHasItsOwnCard() { + val cards = WIDGET_PALETTES.flatMap { palette -> + WIDGET_TRANSPARENCY_STEPS.map { palette.background(it) } + } + val expected = WIDGET_PALETTES.size * WIDGET_TRANSPARENCY_STEPS.size + assertEquals(expected, cards.size) + assertEquals("each color and stop needs its own shape resource", expected, cards.distinct().size) + } + + @Test + fun looksUpTheCardForAColorAndStop() { + assertEquals(WIDGET_PALETTES[1].background(50), widgetBackground("slate", 50)) + // An unknown color falls back to the first palette rather than failing to draw. + assertEquals(WIDGET_PALETTES.first().background(100), widgetBackground("chartreuse", 100)) + assertEquals(WIDGET_PALETTES.first().background(100), widgetBackground(null, 100)) + // A stop between two values snaps down to a real card. + assertEquals(widgetBackground("light", 75), widgetBackground("light", 80)) + } + + @Test + fun everyPaletteKeepsItsTextReadableOnItsCard() { + // Relative luminance of the card fill, used to pick light or dark text. + fun channel(value: Int): Double { + val c = value / 255.0 + return if (c <= 0.03928) c / 12.92 else Math.pow((c + 0.055) / 1.055, 2.4) + } + fun luminance(color: Int): Double = + 0.2126 * channel((color shr 16) and 0xFF) + + 0.7152 * channel((color shr 8) and 0xFF) + + 0.0722 * channel(color and 0xFF) + + WIDGET_PALETTES.forEach { palette -> + val card = luminance(palette.fill) + listOf("title" to palette.title, "headline" to palette.headline, "usage" to palette.usage, "updated" to palette.updated) + .forEach { (name, text) -> + val contrast = (Math.max(card, luminance(text)) + 0.05) / (Math.min(card, luminance(text)) + 0.05) + assertTrue("$name on ${palette.label} is only ${"%.1f".format(contrast)}:1", contrast >= 4.5) + } + } + } } diff --git a/android/app/src/test/java/com/parallelcode/phone/SettingsStoreTest.kt b/android/app/src/test/java/com/parallelcode/phone/SettingsStoreTest.kt index 39cf741fa..445b13ba4 100644 --- a/android/app/src/test/java/com/parallelcode/phone/SettingsStoreTest.kt +++ b/android/app/src/test/java/com/parallelcode/phone/SettingsStoreTest.kt @@ -104,4 +104,38 @@ class SettingsStoreTest { assertTrue(store.alwaysFollowOutput) assertTrue(prefs.getBoolean(SettingsStore.KEY_ALWAYS_FOLLOW_OUTPUT, false)) } + + @Test + fun widgetTransparencyDefaultsToOpaqueAndPersists() { + assertEquals(100, store.widgetTransparency) + store.widgetTransparency = 50 + assertEquals(50, store.widgetTransparency) + assertEquals(50, prefs.getInt(SettingsStore.KEY_WIDGET_TRANSPARENCY, 100)) + } + + @Test + fun widgetTransparencySnapsToAStopOnTheWayInAndOut() { + store.widgetTransparency = 90 + assertEquals(100, store.widgetTransparency) + assertEquals(100, prefs.getInt(SettingsStore.KEY_WIDGET_TRANSPARENCY, 0)) + + // A value written by an older build still reads back as a real stop. + prefs.edit().putInt(SettingsStore.KEY_WIDGET_TRANSPARENCY, 42).apply() + assertEquals(50, store.widgetTransparency) + } + + @Test + fun widgetPaletteDefaultsToObsidianAndPersists() { + assertEquals("obsidian", store.widgetPalette) + store.widgetPalette = "light" + assertEquals("light", store.widgetPalette) + assertEquals("light", prefs.getString(SettingsStore.KEY_WIDGET_PALETTE, null)) + } + + @Test + fun unknownWidgetPaletteFallsBackToObsidian() { + store.widgetPalette = "chartreuse" + assertEquals("obsidian", store.widgetPalette) + assertEquals("obsidian", prefs.getString(SettingsStore.KEY_WIDGET_PALETTE, null)) + } } From 91bb1ba58fd1beefaaffa9da18a44637070bdaa8 Mon Sep 17 00:00:00 2001 From: BW Date: Wed, 30 Sep 2026 13:59:07 -0600 Subject: [PATCH 3/7] feat(remote): add phone diff review and task lifecycle Add a Diff tab to the phone UI and merge/close actions behind a read-only readiness dialog. Diffs come from the renderer's own loadTaskDiff rather than the MCP coordinator: Coordinator.tasks only tracks delegated sub-tasks, so the coordinator route would 404 for phone-created and ordinary desktop tasks. Readiness reuses the desktop's pure buildMergeReadiness so both surfaces agree on what blocks a merge. Read routes (diff, readiness) follow notes and accept the view-only token; merge and close run real git and require the PIN-elevated paired token. All task ids are rejected at the boundary when they name a prototype-chain key. --- electron/ipc/channel-manifest.json | 2 + electron/ipc/register-wiring.test.ts | 2 + electron/ipc/register.ts | 5 + electron/preload.cjs | 2 + electron/remote/merge-route.test.ts | 275 ++++++++++++++++++++++++ electron/remote/protocol.ts | 22 ++ electron/remote/server.ts | 72 +++++++ src/remote/AgentDetail.client.test.tsx | 253 +++++++++++++++++++++- src/remote/AgentDetail.tsx | 213 +++++++++++-------- src/remote/TaskActionsDialog.tsx | 279 +++++++++++++++++++++++++ src/remote/TaskDiffView.tsx | 141 +++++++++++++ src/remote/api.ts | 75 +++++++ src/remote/mobile.css | 186 +++++++++++++++++ src/store/remoteTaskHandler.ts | 126 ++++++++++- 14 files changed, 1560 insertions(+), 93 deletions(-) create mode 100644 electron/remote/merge-route.test.ts create mode 100644 src/remote/TaskActionsDialog.tsx create mode 100644 src/remote/TaskDiffView.tsx diff --git a/electron/ipc/channel-manifest.json b/electron/ipc/channel-manifest.json index b54ef4b67..6528e4fd0 100644 --- a/electron/ipc/channel-manifest.json +++ b/electron/ipc/channel-manifest.json @@ -98,6 +98,8 @@ "Remote_SetNotesRequest": "remote_set_notes_request", "Remote_CloseTaskRequest": "remote_close_task_request", "Remote_GetDiffRequest": "remote_get_diff_request", + "Remote_GetMergeReadinessRequest": "remote_get_merge_readiness_request", + "Remote_MergeTaskRequest": "remote_merge_task_request", "Remote_UpdateTaskStatus": "remote_update_task_status", "Remote_RendererReply": "remote_renderer_reply", "PlanContent": "plan_content", diff --git a/electron/ipc/register-wiring.test.ts b/electron/ipc/register-wiring.test.ts index 6f0284ada..4ba0bcf11 100644 --- a/electron/ipc/register-wiring.test.ts +++ b/electron/ipc/register-wiring.test.ts @@ -86,6 +86,8 @@ const NOT_HANDLED_HERE: readonly string[] = [ IPC.Remote_GetDiffRequest, IPC.Remote_GetNotesRequest, IPC.Remote_GetProjectsRequest, + IPC.Remote_GetMergeReadinessRequest, + IPC.Remote_MergeTaskRequest, IPC.Remote_SetNotesRequest, IPC.StepsContent, IPC.SuperProductivityOpenTaskRequested, diff --git a/electron/ipc/register.ts b/electron/ipc/register.ts index 552a8d335..1133b25c8 100644 --- a/electron/ipc/register.ts +++ b/electron/ipc/register.ts @@ -73,6 +73,7 @@ import { startRemoteServer, getMCPLogs, type RemoteProject } from '../remote/ser import type { RemoteAttentionState, RemoteCloseResult, + RemoteMergeReadiness, RemoteTaskContext, RemoteTaskDiff, } from '../remote/protocol.js'; @@ -1672,6 +1673,10 @@ export function registerAllHandlers(win: BrowserWindow): void { callRenderer(IPC.Remote_CloseTaskRequest, { taskId, force }), getTaskDiff: (taskId: string) => callRenderer(IPC.Remote_GetDiffRequest, { taskId }), + getMergeReadiness: (taskId: string) => + callRenderer(IPC.Remote_GetMergeReadinessRequest, { taskId }), + mergeTaskFromMobile: (req: { taskId: string; squash: boolean; cleanup: boolean }) => + callRenderer<{ ok: boolean }>(IPC.Remote_MergeTaskRequest, req).then(() => {}), getTaskAttention: (taskId: string): RemoteAttentionState => taskAttention.get(taskId) ?? 'idle', getTaskContext: (taskId: string) => taskContext.get(taskId), getCollapsedTaskIds: (): string[] => { diff --git a/electron/preload.cjs b/electron/preload.cjs index 5b31d7783..8e04b11db 100644 --- a/electron/preload.cjs +++ b/electron/preload.cjs @@ -105,6 +105,8 @@ const ALLOWED_CHANNELS = new Set([ 'remote_set_notes_request', 'remote_close_task_request', 'remote_get_diff_request', + 'remote_get_merge_readiness_request', + 'remote_merge_task_request', 'remote_update_task_status', 'remote_renderer_reply', 'plan_content', diff --git a/electron/remote/merge-route.test.ts b/electron/remote/merge-route.test.ts new file mode 100644 index 000000000..372a7ce49 --- /dev/null +++ b/electron/remote/merge-route.test.ts @@ -0,0 +1,275 @@ +// HTTP access control + validation for the phone's merge routes: +// GET /api/mobile/tasks/:taskId/readiness — read-only, like diff/notes +// POST /api/mobile/tasks/:taskId/merge — runs real git, so paired only +// +// The view-only QR token may read readiness but must never be able to merge. + +import { describe, it, expect, vi, beforeEach, afterEach, type Mock } from 'vitest'; +import http from 'node:http'; +import type { RemoteMergeReadiness } from './protocol.js'; + +vi.mock('../ipc/pty.js', () => ({ + writeToAgent: vi.fn(), + resizeAgent: vi.fn(), + killAgent: vi.fn(), + subscribeToAgent: vi.fn(), + subscribeToAgentRendered: vi.fn(() => null), + unsubscribeFromAgent: vi.fn(), + getAgentScrollback: vi.fn(() => null), + getActiveAgentIds: vi.fn(() => []), + getAgentMeta: vi.fn(() => null), + getAgentCols: vi.fn(() => 80), + getAgentRows: vi.fn(() => 24), + onPtyEvent: vi.fn(() => vi.fn()), +})); + +const { startRemoteServer } = await import('./server.js'); + +type StartOpts = Parameters[0]; + +let port = 0; +let mobileToken = ''; +let coordinatorToken = ''; +let subtaskToken = ''; +let generatePin: () => { pin: string; expiresAt: number }; +let stop: () => Promise; +let getMergeReadiness: Mock<(taskId: string) => Promise>; +let mergeTaskFromMobile: Mock>; + +const READY: RemoteMergeReadiness = { + readiness: { + overall: 'ready', + checks: [{ label: 'Merge safety', status: 'pass', detail: 'Branch is mergeable.' }], + }, + canMerge: true, + baseBranch: 'main', + branchName: 'task/thing', +}; + +async function start(extra: Partial = {}): Promise { + const srv = await startRemoteServer({ + port: 0, + host: '127.0.0.1', + staticDir: '/nonexistent', + getTaskName: (id) => id, + getAgentStatus: () => ({ status: 'exited', exitCode: null, lastLine: '' }), + getCoordinator: () => null, + getMergeReadiness, + mergeTaskFromMobile, + ...extra, + }); + port = srv.port; + mobileToken = srv.mobileToken; + coordinatorToken = srv.token; + subtaskToken = srv.subtaskToken; + generatePin = srv.generatePairingPin; + stop = srv.stop; +} + +/** Elevate the mobile token to a paired one via the desktop PIN. */ +async function pair(): Promise { + const { pin } = generatePin(); + const res = await request('POST', '/api/pair/verify', { token: mobileToken, body: { pin } }); + expect(res.status).toBe(201); + return (res.json as { token: string }).token; +} + +beforeEach(() => { + getMergeReadiness = vi.fn(async (_taskId: string) => READY); + mergeTaskFromMobile = vi.fn(async (_req) => {}); +}); + +afterEach(async () => { + await stop(); +}); + +interface Res { + status: number; + json: unknown; +} + +/** Raw HTTP request so the exact (possibly malformed) path reaches the server. */ +function request( + method: string, + path: string, + opts: { token?: string; body?: unknown } = {}, +): Promise { + return new Promise((resolve, reject) => { + const headers: Record = {}; + if (opts.token) headers.Authorization = `Bearer ${opts.token}`; + let payload: string | undefined; + if (opts.body !== undefined) { + payload = typeof opts.body === 'string' ? opts.body : JSON.stringify(opts.body); + headers['Content-Type'] = 'application/json'; + } + const req = http.request({ host: '127.0.0.1', port, method, path, headers }, (res) => { + let data = ''; + res.on('data', (c) => (data += c)); + res.on('end', () => { + let json: unknown = undefined; + try { + json = data ? JSON.parse(data) : undefined; + } catch { + json = data; + } + resolve({ status: res.statusCode ?? 0, json }); + }); + }); + req.on('error', reject); + if (payload !== undefined) req.write(payload); + req.end(); + }); +} + +const READY_PATH = '/api/mobile/tasks/task-1/readiness'; +const MERGE_PATH = '/api/mobile/tasks/task-1/merge'; + +describe('GET /api/mobile/tasks/:taskId/readiness', () => { + beforeEach(() => start()); + + it('returns 401 without a token', async () => { + expect((await request('GET', READY_PATH)).status).toBe(401); + expect(getMergeReadiness).not.toHaveBeenCalled(); + }); + + it('returns 403 for coordinator and subtask tokens', async () => { + for (const token of [coordinatorToken, subtaskToken]) { + expect((await request('GET', READY_PATH, { token })).status).toBe(403); + } + expect(getMergeReadiness).not.toHaveBeenCalled(); + }); + + // Read-only, so the view-only token may read it — same as diff and notes. + it('returns readiness for the view-only and paired tokens', async () => { + for (const token of [mobileToken, await pair()]) { + const res = await request('GET', READY_PATH, { token }); + expect(res.status).toBe(200); + expect(res.json).toEqual(READY); + } + expect(getMergeReadiness).toHaveBeenCalledWith('task-1'); + }); + + it('rejects a malformed escape and prototype keys', async () => { + for (const id of ['%', '__proto__', 'constructor', 'prototype']) { + const res = await request('GET', `/api/mobile/tasks/${id}/readiness`, { token: mobileToken }); + expect(res.status).toBe(400); + } + expect(getMergeReadiness).not.toHaveBeenCalled(); + }); + + it('returns 405 for writes', async () => { + expect((await request('POST', READY_PATH, { token: await pair(), body: {} })).status).toBe(405); + }); + + it('returns 503 when the renderer bridge is unavailable', async () => { + await stop(); + await start({ getMergeReadiness: undefined }); + expect((await request('GET', READY_PATH, { token: mobileToken })).status).toBe(503); + }); + + it('passes a blocked verdict through with canMerge false', async () => { + getMergeReadiness.mockResolvedValueOnce({ + readiness: { + overall: 'blocked', + checks: [ + { label: 'Merge safety', status: 'blocked', detail: 'Worktree has a detached HEAD.' }, + ], + }, + canMerge: false, + baseBranch: 'main', + branchName: 'task/thing', + }); + const res = await request('GET', READY_PATH, { token: mobileToken }); + expect(res.status).toBe(200); + expect((res.json as { canMerge: boolean }).canMerge).toBe(false); + }); + + it('maps a renderer failure to 500', async () => { + getMergeReadiness.mockRejectedValueOnce(new Error('git exploded')); + const res = await request('GET', READY_PATH, { token: mobileToken }); + expect(res.status).toBe(500); + expect((res.json as { error: string }).error).toContain('git exploded'); + }); +}); + +describe('POST /api/mobile/tasks/:taskId/merge', () => { + beforeEach(() => start()); + + it('returns 401 without a token', async () => { + expect((await request('POST', MERGE_PATH, { body: {} })).status).toBe(401); + expect(mergeTaskFromMobile).not.toHaveBeenCalled(); + }); + + // Merging runs real git, so the view-only token must be refused. + it('returns 403 for the view-only token', async () => { + expect((await request('POST', MERGE_PATH, { token: mobileToken, body: {} })).status).toBe(403); + expect(mergeTaskFromMobile).not.toHaveBeenCalled(); + }); + + it('returns 403 for coordinator and subtask tokens', async () => { + for (const token of [coordinatorToken, subtaskToken]) { + expect((await request('POST', MERGE_PATH, { token, body: {} })).status).toBe(403); + } + expect(mergeTaskFromMobile).not.toHaveBeenCalled(); + }); + + it('merges for a paired token and defaults both flags to false', async () => { + const res = await request('POST', MERGE_PATH, { token: await pair(), body: {} }); + expect(res.status).toBe(200); + expect(mergeTaskFromMobile).toHaveBeenCalledWith({ + taskId: 'task-1', + squash: false, + cleanup: false, + }); + }); + + it('forwards explicit squash and cleanup', async () => { + const res = await request('POST', MERGE_PATH, { + token: await pair(), + body: { squash: true, cleanup: true }, + }); + expect(res.status).toBe(200); + expect(mergeTaskFromMobile).toHaveBeenCalledWith({ + taskId: 'task-1', + squash: true, + cleanup: true, + }); + }); + + // Never coerce a truthy value into permission to merge. + it('rejects non-boolean flags rather than coercing them', async () => { + const token = await pair(); + expect((await request('POST', MERGE_PATH, { token, body: { squash: 'yes' } })).status).toBe( + 400, + ); + expect((await request('POST', MERGE_PATH, { token, body: { cleanup: 1 } })).status).toBe(400); + expect(mergeTaskFromMobile).not.toHaveBeenCalled(); + }); + + it('rejects a malformed escape and prototype keys', async () => { + const token = await pair(); + for (const id of ['%', '__proto__', 'constructor', 'prototype']) { + const res = await request('POST', `/api/mobile/tasks/${id}/merge`, { token, body: {} }); + expect(res.status).toBe(400); + } + expect(mergeTaskFromMobile).not.toHaveBeenCalled(); + }); + + it('returns 405 for reads', async () => { + expect((await request('GET', MERGE_PATH, { token: await pair() })).status).toBe(405); + expect(mergeTaskFromMobile).not.toHaveBeenCalled(); + }); + + it('returns 503 when the renderer bridge is unavailable', async () => { + await stop(); + await start({ mergeTaskFromMobile: undefined }); + expect((await request('POST', MERGE_PATH, { token: await pair(), body: {} })).status).toBe(503); + }); + + it('maps a merge failure to 500 with the reason', async () => { + mergeTaskFromMobile.mockRejectedValueOnce(new Error('Only worktree tasks can be merged')); + const res = await request('POST', MERGE_PATH, { token: await pair(), body: {} }); + expect(res.status).toBe(500); + expect((res.json as { error: string }).error).toContain('Only worktree tasks'); + }); +}); diff --git a/electron/remote/protocol.ts b/electron/remote/protocol.ts index 5918c679a..f1334fe86 100644 --- a/electron/remote/protocol.ts +++ b/electron/remote/protocol.ts @@ -46,6 +46,28 @@ export interface RemoteTaskDiff { truncated: boolean; } +/** One readiness row in a phone's merge dialog; mirrors the desktop's panel. */ +export interface RemoteReadinessCheck { + label: string; + status: 'pass' | 'warning' | 'blocked' | 'checking' | 'neutral'; + detail: string; +} + +/** + * Read-only merge readiness, plus the flags a phone's confirm dialog needs. + * Built by the desktop's own `buildMergeReadiness`, so both surfaces agree on + * what blocks a merge. `canMerge` is false only for a blocker, never a warning. + */ +export interface RemoteMergeReadiness { + readiness: { + overall: 'ready' | 'attention' | 'blocked' | 'checking'; + checks: RemoteReadinessCheck[]; + }; + canMerge: boolean; + baseBranch: string; + branchName: string; +} + /** Metadata attached to a task's remote agent entry. */ export type RemoteTaskContext = Pick< RemoteAgent, diff --git a/electron/remote/server.ts b/electron/remote/server.ts index 8d6883017..d526a6b58 100644 --- a/electron/remote/server.ts +++ b/electron/remote/server.ts @@ -30,6 +30,7 @@ import { type RemoteAttentionState, type RemoteTaskContext, type RemoteCloseResult, + type RemoteMergeReadiness, type RemoteTaskDiff, } from './protocol.js'; import { @@ -959,6 +960,14 @@ export function startRemoteServer(opts: { publishTour?: (taskId: string, payload: AgentTourPayload) => Promise; /** Read a task's notes (renderer-backed). */ getTaskNotes?: (taskId: string) => Promise; + /** Read merge readiness for the phone's merge dialog (renderer-backed). */ + getMergeReadiness?: (taskId: string) => Promise; + /** Merge a task on behalf of a paired phone (renderer-backed). */ + mergeTaskFromMobile?: (req: { + taskId: string; + squash: boolean; + cleanup: boolean; + }) => Promise; /** Persist a task's notes (renderer-backed). */ setTaskNotes?: (taskId: string, notes: string) => Promise; /** Close a task (renderer-backed); unless `force`, refuses when work would be lost. */ @@ -1509,6 +1518,69 @@ export function startRemoteServer(opts: { return jsonEnd(405, { error: 'method not allowed' }); } + // --- Merge readiness (read: mobile + paired) --- + // Read-only, so it follows diff/notes. Phones render these checks verbatim + // before merging; the merge itself is the paired-only write below. + const readinessMatch = url.pathname.match(/^\/api\/mobile\/tasks\/([^/]+)\/readiness$/); + if (readinessMatch) { + if (tokenClass !== 'mobile' && tokenClass !== 'paired') + return jsonEnd(403, { error: 'forbidden' }); + if (req.method !== 'GET') return jsonEnd(405, { error: 'method not allowed' }); + const getMergeReadiness = opts.getMergeReadiness; + if (!getMergeReadiness) return jsonEnd(503, { error: 'readiness unavailable' }); + let taskId: string; + try { + taskId = decodeURIComponent(readinessMatch[1]); + } catch { + return jsonEnd(400, { error: 'invalid task id' }); + } + if (taskId === '__proto__' || taskId === 'constructor' || taskId === 'prototype') { + return jsonEnd(400, { error: 'invalid task id' }); + } + getMergeReadiness(taskId) + .then((result) => jsonEnd(200, result)) + .catch((err) => jsonEnd(500, { error: String(err) })); + return; + } + + // --- Paired-mobile task merge --- + // Merging runs real git against the base branch, so it needs the paired + // token. `cleanup` is opt-in and defaults off, so a tap never deletes a + // worktree or branch the way the desktop checkbox explicitly allows. + const mergeMatch = url.pathname.match(/^\/api\/mobile\/tasks\/([^/]+)\/merge$/); + if (mergeMatch) { + if (tokenClass !== 'paired') return jsonEnd(403, { error: 'forbidden' }); + if (req.method !== 'POST') return jsonEnd(405, { error: 'method not allowed' }); + const mergeTaskFromMobile = opts.mergeTaskFromMobile; + if (!mergeTaskFromMobile) return jsonEnd(503, { error: 'task merge unavailable' }); + let taskId: string; + try { + taskId = decodeURIComponent(mergeMatch[1]); + } catch { + return jsonEnd(400, { error: 'invalid task id' }); + } + if (taskId === '__proto__' || taskId === 'constructor' || taskId === 'prototype') { + return jsonEnd(400, { error: 'invalid task id' }); + } + readJsonBody(req) + .then((body) => { + if (body.squash !== undefined && typeof body.squash !== 'boolean') + return jsonEnd(400, { error: 'squash must be a boolean' }); + if (body.cleanup !== undefined && typeof body.cleanup !== 'boolean') + return jsonEnd(400, { error: 'cleanup must be a boolean' }); + return mergeTaskFromMobile({ + taskId, + squash: body.squash === true, + cleanup: body.cleanup === true, + }).then( + () => jsonEnd(200, { ok: true }), + (err: unknown) => jsonEnd(500, { error: String(err) }), + ); + }) + .catch(() => jsonEnd(400, { error: 'bad request' })); + return; + } + // Coordinator agents reach their own tasks and canvases, never other agents' terminals. if ( coordinatorAgent && diff --git a/src/remote/AgentDetail.client.test.tsx b/src/remote/AgentDetail.client.test.tsx index 8d62e036a..85e50e2c0 100644 --- a/src/remote/AgentDetail.client.test.tsx +++ b/src/remote/AgentDetail.client.test.tsx @@ -1,6 +1,7 @@ import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest'; import { render } from 'solid-js/web'; import { AgentDetail } from './AgentDetail'; +import { fetchMergeReadiness, fetchNotes, fetchTaskDiff, mergeTask, closeTask } from './api'; import { sendInput } from './ws'; const terminalMocks = vi.hoisted(() => ({ @@ -42,6 +43,15 @@ vi.mock('@xterm/xterm', () => ({ scrollToBottom() {} }, })); +const wsMocks = vi.hoisted(() => ({ canControl: true })); + +const READY_MERGE = { + readiness: { overall: 'ready' as const, checks: [] }, + canMerge: true, + baseBranch: 'main', + branchName: 'task/thing', +}; + vi.mock('./ws', () => ({ agents: () => [ { @@ -53,7 +63,7 @@ vi.mock('./ws', () => ({ }, ], status: () => 'connected', - canControl: () => true, + canControl: () => wsMocks.canControl, reconnect: vi.fn(), subscribeAgent: vi.fn(), unsubscribeAgent: vi.fn(), @@ -67,6 +77,15 @@ vi.mock('./ws', () => ({ vi.mock('./api', () => ({ fetchNotes: vi.fn().mockResolvedValue('Notes from desktop'), saveNotes: vi.fn(), + fetchTaskDiff: vi.fn().mockResolvedValue({ diff: '', truncated: false }), + fetchMergeReadiness: vi.fn().mockResolvedValue({ + readiness: { overall: 'ready', checks: [] }, + canMerge: true, + baseBranch: 'main', + branchName: 'task/thing', + }), + mergeTask: vi.fn().mockResolvedValue(undefined), + closeTask: vi.fn().mockResolvedValue(undefined), ApiError: class extends Error {}, })); @@ -74,6 +93,14 @@ let host: HTMLDivElement; let dispose: () => void; beforeEach(() => { vi.clearAllMocks(); + // clearAllMocks drops queued one-shot values too, but re-establish the + // defaults so each test starts from a known API state. + vi.mocked(fetchNotes).mockResolvedValue('Notes from desktop'); + vi.mocked(fetchTaskDiff).mockResolvedValue({ diff: '', truncated: false }); + vi.mocked(fetchMergeReadiness).mockResolvedValue(READY_MERGE); + vi.mocked(mergeTask).mockResolvedValue(undefined); + vi.mocked(closeTask).mockResolvedValue({ warnings: [] }); + wsMocks.canControl = true; terminalMocks.options.fontSize = 14; localStorage.clear(); vi.spyOn(HTMLCanvasElement.prototype, 'getContext').mockReturnValue(null); @@ -85,13 +112,13 @@ afterEach(() => { host.remove(); vi.restoreAllMocks(); }); -function mount() { +function mount(onBack: () => void = () => {}) { dispose = render( () => ( {}} + onBack={onBack} onNeedsPairing={() => {}} onNextTask={() => {}} /> @@ -104,6 +131,29 @@ function composer() { if (!field) throw new Error('Missing composer'); return field; } +function tab(label: string) { + const button = [...host.querySelectorAll('nav button')].find( + (b) => b.textContent === label, + ); + if (!button) throw new Error(`Missing ${label} tab`); + return button; +} +async function openTab(label: string) { + tab(label).click(); + await Promise.resolve(); + await Promise.resolve(); +} +function dialogButton(label: string) { + const button = [ + ...host.querySelectorAll('.mobile-dialog-actions button'), + ].find((b) => b.textContent === label); + if (!button) throw new Error(`Missing dialog button: ${label}`); + return button; +} +async function openMergeDialog() { + host.querySelector('.mobile-diff-actions')?.click(); + await vi.waitFor(() => expect(host.querySelector('.mobile-dialog')).not.toBeNull()); +} function type(text: string) { composer().value = text; composer().dispatchEvent(new Event('input', { bubbles: true })); @@ -301,6 +351,7 @@ describe('phone terminal viewport', () => { expect([...host.querySelectorAll('nav button')].map((button) => button.textContent)).toEqual([ 'Terminal', 'Notes', + 'Diff', ]); expect(host.querySelector('.mobile-tabs [aria-label="Smaller terminal text"]')).not.toBeNull(); expect(host.querySelector('.mobile-tabs [aria-label="Larger terminal text"]')).not.toBeNull(); @@ -317,6 +368,202 @@ describe('phone terminal viewport', () => { expect(host.querySelector('[aria-label="Smaller terminal text"]')).toBeNull(); }); + it('renders the task diff in the Diff tab and hides the terminal composer', async () => { + vi.mocked(fetchTaskDiff).mockResolvedValue({ + diff: 'diff --git a/src/a.ts b/src/a.ts\n@@ -1,2 +1,2 @@\n keep\n-old\n+new\n', + truncated: false, + }); + mount(); + await openTab('Diff'); + await vi.waitFor(() => + expect(host.querySelector('.mobile-diff-path')?.textContent).toBe('src/a.ts'), + ); + // Files start collapsed so a long change list stays scannable on a phone. + expect(host.querySelector('.mobile-diff-line-add')).toBeNull(); + host.querySelector('.mobile-diff-file-head')?.click(); + await vi.waitFor(() => expect(host.querySelector('.mobile-diff-line-add')).not.toBeNull()); + expect(host.querySelector('.mobile-diff-line-add')?.textContent).toBe('new'); + expect(host.querySelector('.mobile-diff-line-remove')?.textContent).toBe('old'); + // The prompt box types into the terminal, so it must not appear on Diff. + expect(host.querySelector('[aria-label="Message agent"]')).toBeNull(); + // Nor may the Notes save footer leak onto a read-only tab. + expect(host.querySelector('.mobile-notes-footer')).toBeNull(); + }); + + it('still offers the notes footer on Notes, so the views stay independent', async () => { + mount(); + await openTab('Notes'); + expect(host.querySelector('.mobile-notes-footer')).not.toBeNull(); + expect(host.querySelector('[aria-label="Message agent"]')).toBeNull(); + }); + + it('says so when the desktop truncated a diff too large for the phone', async () => { + vi.mocked(fetchTaskDiff).mockResolvedValue({ + diff: 'diff --git a/a.ts b/a.ts\n@@ -1 +1 @@\n-a\n+b\n', + truncated: true, + }); + mount(); + await openTab('Diff'); + await vi.waitFor(() => expect(host.textContent).toContain('too large for the phone')); + }); + + it('reports no changes when the task has nothing to compare', async () => { + vi.mocked(fetchTaskDiff).mockResolvedValue({ diff: '', truncated: false }); + mount(); + await openTab('Diff'); + await vi.waitFor(() => expect(host.textContent).toContain('No changes yet.')); + }); + + it('surfaces a diff load failure', async () => { + vi.mocked(fetchTaskDiff).mockRejectedValue(new Error('git unavailable')); + mount(); + await openTab('Diff'); + await vi.waitFor(() => expect(host.textContent).toContain('git unavailable')); + }); + + it('refetches the diff when re-entering the tab so new work shows up', async () => { + vi.mocked(fetchTaskDiff).mockResolvedValue({ diff: '', truncated: false }); + mount(); + await openTab('Diff'); + await openTab('Terminal'); + await openTab('Diff'); + await vi.waitFor(() => expect(fetchTaskDiff).toHaveBeenCalledTimes(2)); + }); + + it('shows the desktop readiness checks read-only in the merge dialog', async () => { + vi.mocked(fetchMergeReadiness).mockResolvedValue({ + readiness: { + overall: 'attention', + checks: [ + { label: 'Merge safety', status: 'warning', detail: 'main is 2 commits ahead.' }, + { label: 'Verification', status: 'warning', detail: 'No verification was reported.' }, + ], + }, + canMerge: true, + baseBranch: 'main', + branchName: 'task/thing', + }); + mount(); + await openTab('Diff'); + await openMergeDialog(); + await vi.waitFor(() => expect(host.textContent).toContain('Needs attention')); + expect(host.textContent).toContain('main is 2 commits ahead.'); + expect(host.textContent).toContain('No verification was reported.'); + // A warning is advisory: the merge action stays available. + expect(dialogButton('Merge').disabled).toBe(false); + }); + + it('disables merging when readiness reports a blocker', async () => { + vi.mocked(fetchMergeReadiness).mockResolvedValue({ + readiness: { + overall: 'blocked', + checks: [ + { label: 'Merge safety', status: 'blocked', detail: 'Worktree has a detached HEAD.' }, + ], + }, + canMerge: false, + baseBranch: 'main', + branchName: 'task/thing', + }); + mount(); + await openTab('Diff'); + await openMergeDialog(); + await vi.waitFor(() => expect(host.textContent).toContain('Not ready to merge')); + expect(dialogButton('Merge').disabled).toBe(true); + }); + + it('merges with the chosen options and returns to the list', async () => { + const back = vi.fn(); + mount(back); + await openTab('Diff'); + await openMergeDialog(); + // First tap opens the confirm step; the option checkboxes live there. + dialogButton('Merge').click(); + await vi.waitFor(() => + expect(host.querySelectorAll('.mobile-dialog-option input').length).toBe(2), + ); + for (const box of host.querySelectorAll('.mobile-dialog-option input')) { + box.checked = true; + box.dispatchEvent(new Event('change', { bubbles: true })); + } + dialogButton('Merge').click(); + await vi.waitFor(() => + expect(mergeTask).toHaveBeenCalledWith('t1', { squash: true, cleanup: true }), + ); + // A merged task may be gone, so the list is where the user should land. + await vi.waitFor(() => expect(back).toHaveBeenCalled()); + }); + + it('surfaces a merge failure and stays in the dialog', async () => { + vi.mocked(mergeTask).mockRejectedValueOnce(new Error('Only worktree tasks can be merged')); + mount(); + await openTab('Diff'); + await openMergeDialog(); + dialogButton('Merge').click(); + dialogButton('Merge').click(); + await vi.waitFor(() => expect(host.textContent).toContain('Only worktree tasks can be merged')); + expect(host.querySelector('.mobile-dialog')).not.toBeNull(); + }); + + it('closes a task after an explicit confirmation', async () => { + mount(); + await openTab('Diff'); + await openMergeDialog(); + dialogButton('Close').click(); + await vi.waitFor(() => expect(host.textContent).toContain('Close task')); + // The first Close only opens the confirmation; nothing is destroyed yet. + expect(closeTask).not.toHaveBeenCalled(); + dialogButton('Close task').click(); + await vi.waitFor(() => expect(closeTask).toHaveBeenCalledWith('t1', false)); + }); + + it('keeps the dialog open and explains when a close would lose work', async () => { + vi.mocked(closeTask).mockResolvedValue({ warnings: ['2 child tasks still running'] }); + mount(); + await openTab('Diff'); + await openMergeDialog(); + dialogButton('Close').click(); + await vi.waitFor(() => expect(host.textContent).toContain('Close task')); + dialogButton('Close task').click(); + // The desktop refused; the reason is shown instead of navigating away. + await vi.waitFor(() => expect(host.textContent).toContain('2 child tasks still running')); + expect(host.querySelector('.mobile-dialog')).not.toBeNull(); + }); + + it('retries a refused close with force only once the user asks', async () => { + vi.mocked(closeTask) + .mockResolvedValueOnce({ warnings: ['uncommitted changes'] }) + .mockResolvedValueOnce({ warnings: [] }); + mount(); + await openTab('Diff'); + await openMergeDialog(); + dialogButton('Close').click(); + await vi.waitFor(() => expect(host.textContent).toContain('Close task')); + dialogButton('Close task').click(); + await vi.waitFor(() => expect(host.textContent).toContain('uncommitted changes')); + // The force checkbox only appears after a refusal, and stays opt-in. + const forceBox = host.querySelector('.mobile-dialog-option input'); + if (!forceBox) throw new Error('Missing force checkbox'); + expect(closeTask).toHaveBeenLastCalledWith('t1', false); + forceBox.checked = true; + forceBox.dispatchEvent(new Event('change', { bubbles: true })); + dialogButton('Close task').click(); + await vi.waitFor(() => expect(closeTask).toHaveBeenLastCalledWith('t1', true)); + }); + + it('hides merge and close for a view-only device', async () => { + wsMocks.canControl = false; + mount(); + await openTab('Diff'); + await openMergeDialog(); + await vi.waitFor(() => expect(host.textContent).toContain('Pair this phone')); + const labels = [...host.querySelectorAll('.mobile-dialog-actions button')].map( + (b) => b.textContent, + ); + expect(labels).not.toContain('Merge'); + expect(labels).not.toContain('Close'); + }); + it('pans the desktop grid before scrolling history and shields gestures from xterm', () => { localStorage.setItem('parallel-mobile:output-view', 'terminal'); mount(); diff --git a/src/remote/AgentDetail.tsx b/src/remote/AgentDetail.tsx index 506e49243..3c18412e4 100644 --- a/src/remote/AgentDetail.tsx +++ b/src/remote/AgentDetail.tsx @@ -3,6 +3,8 @@ import { Terminal } from '@xterm/xterm'; import { TERMINAL_SCROLL_OPTIONS, base64ToUint8Array } from '../lib/terminalConstants'; import { createTerminalHttpLinkHandler } from '../lib/terminalLinks'; import { fetchNotes, saveNotes, ApiError } from './api'; +import { TaskDiffView } from './TaskDiffView'; +import { TaskActionsDialog } from './TaskActionsDialog'; import { clearPairedToken } from './auth'; import { readLocal, writeLocal } from './storage'; import { messageForTerminal } from './terminalText'; @@ -53,7 +55,10 @@ export function AgentDetail(props: AgentDetailProps) { const [sending, setSending] = createSignal(false); const [sendError, setSendError] = createSignal(''); const [sent, setSent] = createSignal(false); - const [view, setView] = createSignal<'terminal' | 'notes'>('terminal'); + const [view, setView] = createSignal<'terminal' | 'notes' | 'diff'>('terminal'); + // Bumped to force the diff tab to refetch; the resource keys on it. + const [diffToken, setDiffToken] = createSignal(0); + const [showActions, setShowActions] = createSignal(false); const [multilinePaste, setMultilinePaste] = createSignal(false); const [terminalBottom, setTerminalBottom] = createSignal(true); const [zoom, setZoom] = createSignal(1); @@ -407,8 +412,12 @@ export function AgentDetail(props: AgentDetailProps) { setSent(false); } - function selectView(next: 'terminal' | 'notes') { + function selectView(next: 'terminal' | 'notes' | 'diff') { + const wasDiff = view() === 'diff'; setView(next); + // Re-entering the diff tab refetches, so returning to it always shows the + // agent's newest work rather than a snapshot from the first visit. + if (next === 'diff' && wasDiff) setDiffToken((n) => n + 1); requestAnimationFrame(() => { if (!disposed) { fitTerminal(); @@ -431,6 +440,7 @@ export function AgentDetail(props: AgentDetailProps) { each={[ { id: 'terminal' as const, label: 'Terminal' }, { id: 'notes' as const, label: 'Notes' }, + { id: 'diff' as const, label: 'Diff' }, ]} > {(tab) => ( @@ -503,6 +513,19 @@ export function AgentDetail(props: AgentDetailProps) { /> + +
+ + + + +
+
@@ -524,98 +547,103 @@ export function AgentDetail(props: AgentDetailProps) {
+ {/* The composer types into the agent's terminal, so it belongs to the + terminal view only. The notes footer belongs to Notes alone, and the + read-only Diff tab gets neither. */} - -

This terminal sends line breaks as spaces.

-
- - - -
- - + +