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

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
37 changes: 37 additions & 0 deletions src/apps/mobile/README.md
Original file line number Diff line number Diff line change
Expand Up @@ -152,3 +152,40 @@ Account sign-in on Android, iOS and HarmonyOS opens the shared authorization pag
with separate GitHub and email-code options. Email users need no password and are
not automatically linked to GitHub users. Sign in with the same method and account
on the phone and the controlled desktop/CLI.

## Persistent goals

Android, iOS, and HarmonyOS expose **Set goal** in the remote composer **+** menu.
An existing goal appears as a compact strip above the text input. Tap it to read the host's goal, token usage and status, start or edit
an objective, pause automatic continuation, resume a paused/blocked/usage-limited
goal, or clear it. Pausing a goal does not cancel the current turn; use Stop for
that. Budget-limited and completed goals can be edited, but cannot be resumed.

The composer accepts `/goal <objective>`, `/goal`, `/goal edit`, `/goal pause`,
`/goal resume`, and `/goal clear`. Bare `/goal` and `/goal edit` open goal management (the creation editor when no goal exists).
Goal commands do not accept attachments and do not become ordinary chat messages.
A newer draft entered while a command is pending is retained.

The controlled Desktop or CLI must advertise `thread_goal_v1` in its live
workspace capabilities. Unsupported hosts show an upgrade message. Clients use
`thread_goal` with a session ID and an explicit action; the host resolves that
session's workspace and storage, including SSH workspace bindings. No
controller filesystem path participates in goal operations. Goal execution and
persistence stay on the host when the phone disconnects. Opening a connected conversation reads
the current snapshot; the goal strip refreshes every five seconds while foregrounded,
even with details closed. Returning to the foreground reloads it. Failed changes
retain the last confirmed state. Switching sessions/devices discards late replies.

Focused checks:

```bash
# From shared/
./gradlew :core-feature:jvmTest --tests '*RemoteSessionStoreTest.goal*' --tests '*ThreadGoalCommandTest*'
# From the repository root
node --test src/apps/mobile/harmonyos/tools/tests/thread-goal.test.cjs
```

The HarmonyOS `thread-goal` / `thread-goal-dark` native preview exercises the
production panel using in-memory state and no host requests. Android's
`ThreadGoalPanelTest` checks pause/resume and unsupported presentation in an
isolated Compose activity. Neither replaces a live remote-host acceptance test.
5 changes: 5 additions & 0 deletions src/apps/mobile/android/AGENTS.md
Original file line number Diff line number Diff line change
Expand Up @@ -68,3 +68,8 @@ Run instrumented Gradle tests on a dedicated test emulator, not an authenticated
manual-acceptance device. The test runner can uninstall the target application
after the suite and remove its local account state. Use `adb install -r` for
manual acceptance updates to preserve that state.

For the native Goal panel, use `:app:connectedDebugAndroidTest
-Pandroid.testInstrumentationRunnerArguments.class=com.openbitfun.mobile.app.ThreadGoalPanelTest`
on a dedicated test emulator. Shared command/state coverage is documented in the
mobile README.
Original file line number Diff line number Diff line change
@@ -0,0 +1,104 @@
package com.openbitfun.mobile.app

import android.graphics.Bitmap
import androidx.compose.runtime.*
import androidx.compose.ui.test.*
import androidx.compose.ui.test.junit4.v2.createComposeRule
import androidx.test.platform.app.InstrumentationRegistry
import com.openbitfun.mobile.app.ui.chat.ThreadGoalPanel
import com.openbitfun.mobile.app.ui.theme.OpenBitFunTheme
import com.openbitfun.mobile.core.feature.session.*
import org.junit.Assert.assertEquals
import org.junit.Rule
import org.junit.Test
import java.io.File

class ThreadGoalPanelTest {
@get:Rule val compose = createComposeRule()
@Test fun activeGoalCanPauseAndResume() = verify(false)
@Test fun darkGoalCanPauseAndResume() = verify(true)
private fun verify(dark: Boolean) {
val context = InstrumentationRegistry.getInstrumentation().targetContext
val actions = mutableListOf<ThreadGoalAction>()
compose.setContent {
var state by remember { mutableStateOf(ThreadGoalUiState("s").copy(visible = true, loaded = true, objective = "Verify mobile goals", status = "active", tokensUsed = 1200)) }
OpenBitFunTheme(dark = dark) {
ThreadGoalPanel(state, "s", true) { intent ->
val goal = intent as RemoteSessionIntent.Goal
actions += goal.action
if (goal.action == ThreadGoalAction.PAUSE) state = state.copy(status = "paused")
if (goal.action == ThreadGoalAction.RESUME) state = state.copy(status = "active")
}
}
}
compose.onNodeWithText(context.getString(R.string.goal_pause)).performClick()
compose.onAllNodesWithText(context.getString(R.string.goal_paused)).onLast().assertIsDisplayed()
compose.onNodeWithText(context.getString(R.string.goal_resume)).performClick()
compose.onAllNodesWithText(context.getString(R.string.goal_active)).onLast().assertIsDisplayed()
assertEquals(listOf(ThreadGoalAction.PAUSE, ThreadGoalAction.RESUME), actions.filter { it != ThreadGoalAction.READ })
compose.waitForIdle()
InstrumentationRegistry.getInstrumentation().uiAutomation.takeScreenshot().let { bitmap ->
File(context.cacheDir, "goal-${if (dark) "dark" else "light"}.png").outputStream().use { bitmap.compress(Bitmap.CompressFormat.PNG, 100, it) }
}
}
@Test fun editCancelPreservesHostGoalAndCloseKeepsStrip() {
val context = InstrumentationRegistry.getInstrumentation().targetContext
val actions = mutableListOf<ThreadGoalAction>()
var state by mutableStateOf(ThreadGoalUiState("s").copy(visible = true, loaded = true,
objective = "Keep typing", status = "active"))
compose.setContent { OpenBitFunTheme(dark = false) {
ThreadGoalPanel(state, "s", true) {
val action = (it as RemoteSessionIntent.Goal).action
actions += action
if (action == ThreadGoalAction.CLOSE) state = state.copy(visible = false)
}
} }
compose.onNodeWithText(context.getString(R.string.goal_modify)).performClick()
compose.runOnIdle { state = state.copy(busy = true) }
compose.onNode(hasSetTextAction()).assertIsEnabled().performTextReplacement("Unsaved draft")
compose.runOnIdle { state = state.copy(busy = false) }
compose.onNodeWithText(context.getString(R.string.goal_cancel)).performClick()
compose.onNodeWithText("Unsaved draft").assertDoesNotExist()
compose.onNodeWithText(context.getString(R.string.goal_close)).performClick()
compose.onNodeWithText("Keep typing").assertIsDisplayed()
assertEquals(false, actions.contains(ThreadGoalAction.EDIT))
}
@Test fun clearingRequiresConfirmation() {
val context = InstrumentationRegistry.getInstrumentation().targetContext
val actions = mutableListOf<ThreadGoalAction>()
compose.setContent { OpenBitFunTheme(dark = false) {
ThreadGoalPanel(ThreadGoalUiState("s").copy(visible = true, loaded = true,
objective = "Keep this goal", status = "active"), "s", true) {
actions += (it as RemoteSessionIntent.Goal).action
}
} }
compose.onNodeWithText(context.getString(R.string.goal_clear)).performClick()
assertEquals(false, actions.contains(ThreadGoalAction.CLEAR))
compose.onNodeWithText(context.getString(R.string.goal_clearhint)).assertIsDisplayed()
compose.onNodeWithText(context.getString(R.string.goal_clear)).performClick()
assertEquals(true, actions.contains(ThreadGoalAction.CLEAR))
}
@Test fun creatingGoalReturnsToComposerStrip() {
val context = InstrumentationRegistry.getInstrumentation().targetContext
var state by mutableStateOf(ThreadGoalUiState("s").copy(visible = true, loaded = true))
compose.setContent { OpenBitFunTheme(dark = false) {
ThreadGoalPanel(state, "s", true) {
val goal = it as RemoteSessionIntent.Goal
if (goal.action == ThreadGoalAction.START) state = state.copy(objective = goal.objective, status = "active")
if (goal.action == ThreadGoalAction.CLOSE) state = state.copy(visible = false)
}
} }
compose.onNode(hasSetTextAction()).performTextReplacement("Ship the aligned UI")
compose.onAllNodesWithText(context.getString(R.string.goal_set)).onLast().performClick()
compose.onNodeWithText("Ship the aligned UI").assertIsDisplayed()
compose.onNodeWithText(context.getString(R.string.goal_close)).assertDoesNotExist()
}
@Test fun unsupportedHostExplainsWhyWithoutOfferingMutations() {
val context = InstrumentationRegistry.getInstrumentation().targetContext
compose.setContent { OpenBitFunTheme(dark = false) {
ThreadGoalPanel(ThreadGoalUiState("s").copy(visible = true, failure = ThreadGoalFailure.UNSUPPORTED), "s", true) {}
} }
compose.onNodeWithText(context.getString(R.string.goal_unsupported)).assertIsDisplayed()
compose.onNodeWithText(context.getString(R.string.goal_start)).assertDoesNotExist()
}
}
Original file line number Diff line number Diff line change
Expand Up @@ -56,6 +56,7 @@ import androidx.compose.ui.layout.ContentScale
import androidx.compose.ui.platform.testTag
import androidx.compose.ui.platform.LocalConfiguration
import androidx.compose.ui.platform.LocalDensity
import androidx.compose.ui.platform.LocalSoftwareKeyboardController
import androidx.compose.ui.res.painterResource
import androidx.compose.ui.res.stringResource
import androidx.compose.ui.semantics.Role
Expand Down Expand Up @@ -122,6 +123,9 @@ private val ExpandedActionRowHeight = MobileDesignGeometry.ComposerExpandedActio
*/
@Composable
internal fun ComposerBar(
goalContent: @Composable () -> Unit = {},
onGoal: (() -> Unit)? = null,
hasGoal: Boolean = false,
draft: String,
images: List<ComposerImage>,
busy: Boolean,
Expand Down Expand Up @@ -180,7 +184,7 @@ internal fun ComposerBar(
easing = OpenBitFunEaseOut,
)
val radius by animateDpAsState(
if (expanded || images.isNotEmpty()) {
if (expanded || images.isNotEmpty() || hasGoal) {
MobileDesignGeometry.ComposerExpandedRadius
} else {
MobileDesignGeometry.ComposerCollapsedRadius
Expand All @@ -189,7 +193,7 @@ internal fun ComposerBar(
label = "composer-radius",
)
val contentTopPadding by animateDpAsState(
if (expanded) 4.dp else 0.dp,
if (expanded || hasGoal) 4.dp else 0.dp,
structureSpec,
label = "composer-top-padding",
)
Expand Down Expand Up @@ -246,6 +250,7 @@ internal fun ComposerBar(
),
),
) {
goalContent()
if (capabilities.supportsAttachments && images.isNotEmpty()) {
AttachmentStrip(
images = images,
Expand All @@ -264,11 +269,11 @@ internal fun ComposerBar(
// While expanded both side controls move to the row below,
// so the field gets the full width for what is being typed.
AnimatedVisibility(
visible = !expanded && capabilities.supportsAttachments && capabilities.showAddButton,
visible = !expanded && ((capabilities.supportsAttachments && capabilities.showAddButton) || onGoal != null),
enter = compactControlEnter,
exit = compactControlExit,
) {
AddButton(
AddButton(onGoal = onGoal, hasGoal = hasGoal,
enabled = !busy && images.size < MAX_COMPOSER_IMAGES,
onClick = onAttach,
)
Expand Down Expand Up @@ -313,8 +318,8 @@ internal fun ComposerBar(
.height(ExpandedActionRowHeight)
.padding(start = 2.dp),
) {
if (capabilities.supportsAttachments && capabilities.showAddButton) {
AddButton(
if ((capabilities.supportsAttachments && capabilities.showAddButton) || onGoal != null) {
AddButton(onGoal = onGoal, hasGoal = hasGoal,
enabled = !busy && images.size < MAX_COMPOSER_IMAGES,
onClick = onAttach,
)
Expand Down Expand Up @@ -410,19 +415,25 @@ private fun ComposerField(

/** The attachment control: a plain glyph, sized to match the primary action. */
@Composable
private fun AddButton(enabled: Boolean, onClick: () -> Unit) {
private fun AddButton(enabled: Boolean, onClick: () -> Unit, onGoal: (() -> Unit)? = null, hasGoal: Boolean = false) {
val keyboard = LocalSoftwareKeyboardController.current
var expanded by remember { mutableStateOf(false) }
Box(
contentAlignment = Alignment.Center,
modifier = Modifier
.size(ActionSize)
.clip(CircleShape)
.clickable(role = Role.Button, enabled = enabled, onClick = onClick),
.clickable(role = Role.Button, enabled = enabled || onGoal != null, onClick = { if (onGoal != null) expanded = true else onClick() }),
) {
DropdownMenu(expanded = expanded, onDismissRequest = { expanded = false }) {
androidx.compose.material3.DropdownMenuItem(text = { Text(stringResource(R.string.message_attach_image)) }, enabled = enabled, onClick = { expanded = false; onClick() })
androidx.compose.material3.DropdownMenuItem(text = { Text(stringResource(if (hasGoal) R.string.goal_manage else R.string.goal_set)) }, onClick = { expanded = false; keyboard?.hide(); onGoal?.invoke() })
}
Icon(
painterResource(R.drawable.ic_symbol_plus),
contentDescription = stringResource(R.string.message_attach_image),
tint = MaterialTheme.colorScheme.onSurface,
modifier = Modifier.size(22.dp).alpha(if (enabled) 1f else DimmedAlpha),
modifier = Modifier.size(22.dp).alpha(if (enabled || onGoal != null) 1f else DimmedAlpha),
)
}
}
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -344,6 +344,9 @@ internal fun ConversationView(
}
}
ComposerBar(
goalContent = { ThreadGoalPanel(state.threadGoal, sessionId, phase == ConnectionPhase.CONNECTED, onIntent) },
onGoal = { onIntent(RemoteSessionIntent.Goal(sessionId, com.openbitfun.mobile.core.feature.session.ThreadGoalAction.OPEN)) },
hasGoal = state.threadGoal.sessionId == sessionId && state.threadGoal.objective != null,
draft = draft,
images = images,
// An empty session id would send nowhere, so it reads as busy.
Expand Down
Loading
Loading