diff --git a/android/README.md b/android/README.md index cfb69142..d716a163 100644 --- a/android/README.md +++ b/android/README.md @@ -16,7 +16,7 @@ Native companion app for the desktop's **Connect Phone** (Remote Access) feature - **Terminal space and zoom:** pinch with two fingers to magnify the whole terminal up to 400%, and pan with two fingers (or sideways with one) to read a part of it; one finger still scrolls the history. Swiping between tasks pauses while zoomed; double-tap or the zoom pill resets it. The expand icon in the title bar hides the title, tabs, and quick keys while keeping the message field and Send/Stop available; the restore icon or Android Back returns to the normal layout without losing your draft. Terminal and chat replies use full-width multiline fields, with action buttons below. - **History:** opening a terminal loads the desktop's history (up to 10,000 lines), and the phone keeps up to 20,000 lines while the terminal stays open. - **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 built-in chat's reply box (edit them in Settings; the terminal leaves them out to make room) and a mic button that dictates with Android's speech recognizer. +- **Quick replies and voice:** saved replies above the built-in chat's reply box and behind the star button in the terminal's 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. 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, agent and model, then describe the work; needs pairing. diff --git a/android/app/src/main/java/com/parallelcode/phone/ComposerExtras.kt b/android/app/src/main/java/com/parallelcode/phone/ComposerExtras.kt index ae89c0c2..9c6e56a0 100644 --- a/android/app/src/main/java/com/parallelcode/phone/ComposerExtras.kt +++ b/android/app/src/main/java/com/parallelcode/phone/ComposerExtras.kt @@ -9,8 +9,10 @@ import androidx.compose.foundation.horizontalScroll import androidx.compose.foundation.verticalScroll import androidx.compose.material.icons.Icons import androidx.compose.material.icons.filled.Mic +import androidx.compose.material.icons.filled.Star 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.fillMaxWidth @@ -18,13 +20,18 @@ import androidx.compose.foundation.layout.padding import androidx.compose.foundation.rememberScrollState import androidx.compose.material3.AlertDialog import androidx.compose.material3.AssistChip +import androidx.compose.material3.DropdownMenu +import androidx.compose.material3.DropdownMenuItem import androidx.compose.material3.Icon import androidx.compose.material3.OutlinedButton import androidx.compose.material3.MaterialTheme import androidx.compose.material3.Text import androidx.compose.material3.TextButton import androidx.compose.runtime.Composable +import androidx.compose.runtime.getValue +import androidx.compose.runtime.mutableStateOf import androidx.compose.runtime.remember +import androidx.compose.runtime.setValue import androidx.compose.ui.Modifier import androidx.compose.ui.hapticfeedback.HapticFeedbackType import androidx.compose.ui.platform.LocalContext @@ -54,6 +61,47 @@ fun QuickReplies(replies: List, enabled: Boolean, onPick: (String) -> Un } } +/** + * The same saved replies behind one button, for a reply box with no row to spare: tapping the + * button lists them, and picking one hands it to [onPick]. Hidden when there are none. [modifier] + * applies to the button itself. + */ +@Composable +fun QuickRepliesButton( + replies: List, + enabled: Boolean, + modifier: Modifier = Modifier, + onPick: (String) -> Unit, +) { + if (replies.isEmpty()) return + var open by remember { mutableStateOf(false) } + val haptic = LocalHapticFeedback.current + Box { + OutlinedButton( + onClick = { open = true }, + enabled = enabled, + modifier = modifier, + shape = MaterialTheme.shapes.large, + ) { + Icon(Icons.Filled.Star, contentDescription = "Quick replies") + } + DropdownMenu(expanded = open && enabled, onDismissRequest = { open = false }) { + replies.forEach { reply -> + DropdownMenuItem( + text = { Text(reply, maxLines = 2, overflow = TextOverflow.Ellipsis) }, + onClick = { + // The closing menu still takes taps while it animates out: pick only once. + if (!open) return@DropdownMenuItem + haptic.performHapticFeedback(HapticFeedbackType.TextHandleMove) + open = false + onPick(reply) + }, + ) + } + } + } +} + /** 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 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 feda628b..a72bb588 100644 --- a/android/app/src/main/java/com/parallelcode/phone/MainActivity.kt +++ b/android/app/src/main/java/com/parallelcode/phone/MainActivity.kt @@ -576,6 +576,8 @@ private fun PhoneApp(model: PhoneViewModel) { client = model.client, alwaysFollowOutput = alwaysFollowOutput, fitTerminalToPhone = fitTerminalToPhone && active, + quickReplies = quickReplies, + sendQuickReplies = sendQuickReplies, promptHistory = model.promptHistory, pageLabel = pageLabel, nextNeedingYou = nextTaskNeedingYou(agents, agentId), diff --git a/android/app/src/main/java/com/parallelcode/phone/Screens.kt b/android/app/src/main/java/com/parallelcode/phone/Screens.kt index 005636c4..db6f27f4 100644 --- a/android/app/src/main/java/com/parallelcode/phone/Screens.kt +++ b/android/app/src/main/java/com/parallelcode/phone/Screens.kt @@ -953,6 +953,8 @@ fun AgentScreen( client: RemoteClient, alwaysFollowOutput: Boolean, fitTerminalToPhone: Boolean, + quickReplies: List = emptyList(), + sendQuickReplies: Boolean = false, promptHistory: PromptHistoryStore? = null, pageLabel: String? = null, nextNeedingYou: RemoteAgent? = null, @@ -1222,6 +1224,8 @@ fun AgentScreen( ReplyBox( agentId = agentId, compact = terminalExpanded, + quickReplies = quickReplies, + sendQuickReplies = sendQuickReplies, working = agent?.running == true && (agent.attention == "active" || agent.attention == "shell_busy"), promptHistory = promptHistory, @@ -1700,6 +1704,8 @@ private fun arrowKeyFor(label: String): Pair? = when (label private fun ReplyBox( agentId: String, compact: Boolean = false, + quickReplies: List = emptyList(), + sendQuickReplies: Boolean = false, working: Boolean = false, promptHistory: PromptHistoryStore? = null, needsInput: Boolean = false, @@ -1821,6 +1827,21 @@ private fun ReplyBox( enabled = !busy, modifier = Modifier.fillMaxHeight(), ) { setDraft(appendToDraft(draft, it)) } + // Behind a button rather than a row of chips, so the terminal keeps its room. + QuickRepliesButton( + quickReplies, + enabled = !busy, + modifier = Modifier.fillMaxHeight(), + ) { + // A quick reply is for the agent, sent straight away or not: one that starts the + // draft leaves shell mode, so Send cannot run it as a shell command. + if (sendQuickReplies) { + run { send(it, false) } + } else { + if (draft.isBlank()) shellMode = false + setDraft(appendToDraft(draft, it)) + } + } if (promptHistory != null) { OutlinedButton( onClick = { showHistory = true }, 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 797e3718..6c90454b 100644 --- a/android/app/src/main/java/com/parallelcode/phone/SettingsScreen.kt +++ b/android/app/src/main/java/com/parallelcode/phone/SettingsScreen.kt @@ -446,7 +446,7 @@ fun SettingsScreen( HorizontalDivider(thickness = 1.dp, color = AppTheme.extra.borderSubtle, modifier = Modifier.padding(vertical = 14.dp)) SettingSwitchRow( title = "Send quick replies immediately", - description = "Tapping a quick reply in a built-in chat sends it straight away. Off, it is added to your draft first.", + description = "Tapping a quick reply sends it straight to the agent. Off, it is added to your draft first.", checked = sendQuickReplies, onCheckedChange = onSendQuickRepliesChange, ) @@ -1076,7 +1076,7 @@ private fun QuickRepliesEditor(replies: List, onChange: (List) - color = MaterialTheme.colorScheme.onSurface, ) Text( - "Shown above the reply box in built-in chats; tap one to add it to your message. One per line.", + "Shown above the reply box in built-in chats and behind the star button in terminals. One per line.", style = MaterialTheme.typography.bodyMedium, color = AppTheme.extra.textMuted, ) 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 c86197fc..a7c9c243 100644 --- a/android/app/src/main/java/com/parallelcode/phone/SettingsStore.kt +++ b/android/app/src/main/java/com/parallelcode/phone/SettingsStore.kt @@ -64,7 +64,7 @@ class SettingsStore(private val prefs: SharedPreferences) { 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. */ + /** Replies offered with the reply box (above it in chats, behind a button in terminals), 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() }