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

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
Original file line number Diff line number Diff line change
Expand Up @@ -100,7 +100,10 @@ class UiScreenshotInstrumentedTest {
capture("01-chat-empty", {
composeRule.onNodeWithText("チャット").assertIsDisplayed()
composeRule.onNodeWithText("GLM-5").assertIsDisplayed()
composeRule.onNodeWithText("project").assertIsDisplayed()
// The composer button row is attach / model / thinking … mic / send since the
// paseo redesign (5e290b1); the workspace chip it used to carry is gone, so this
// asserts the composer itself rather than a chip that no longer exists.
composeRule.onNodeWithText("OpenCodeにメッセージを送る…").assertIsDisplayed()
}) { PreviewChatHome() }

capture("02-runtime-not-ready", {
Expand Down Expand Up @@ -258,8 +261,11 @@ class UiScreenshotInstrumentedTest {

capture("09-provider-settings", {
composeRule.onNodeWithText("プロバイダ設定").assertIsDisplayed()
composeRule.onNodeWithText("保存済みの認証情報").assertIsDisplayed()
composeRule.onNodeWithText("APIキーを追加・更新").assertIsDisplayed()
// The screen is now a search field over provider rows; the old
// "saved credentials" / "add API key" sections are gone and their string
// resources are orphaned.
composeRule.onNodeWithText("プロバイダを検索…").assertIsDisplayed()
composeRule.onNodeWithText("Z.ai").assertIsDisplayed()
}) {
ProviderSettingsScreen(
state =
Expand All @@ -283,7 +289,10 @@ class UiScreenshotInstrumentedTest {
capture("10-voice-settings", {
composeRule.onNodeWithText("音声設定").assertIsDisplayed()
composeRule.onNodeWithText("ウェイクワード").assertIsDisplayed()
composeRule.onNodeWithText("ウェイクワード用の追加パックはまだ導入されていません。").assertIsDisplayed()
// The "extra pack not installed" notice was dropped along with its string
// resource; the row now carries the invocation description instead.
composeRule.onNodeWithText("「Hey Mycroft」でハンズフリーでアシスタントを起動します。")
.assertIsDisplayed()
}) {
VoiceSettingsScreen(
ttsEnabled = true,
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -186,6 +186,7 @@ class OpenCodeApplication : Application() {
registry = runtimeRegistry,
scope = applicationScope,
onPermissionAsked = notifications::notifyPermission,
onPermissionResolved = notifications::cancelPermission,
onSessionIdle = notifications::notifySessionComplete,
onSessionError = notifications::notifySessionError,
unreadStore = settings,
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -3,12 +3,16 @@ package com.opencode.android.core.notification
import android.content.BroadcastReceiver
import android.content.Context
import android.content.Intent
import android.util.Log
import com.opencode.android.OpenCodeApplication
import com.opencode.android.R
import com.opencode.android.runtime.PermissionResponse
import kotlinx.coroutines.CoroutineExceptionHandler
import kotlinx.coroutines.CoroutineScope
import kotlinx.coroutines.Dispatchers
import kotlinx.coroutines.SupervisorJob
import kotlinx.coroutines.launch
import kotlinx.coroutines.withTimeout

class PermissionActionReceiver : BroadcastReceiver() {
override fun onReceive(
Expand All @@ -23,22 +27,70 @@ class PermissionActionReceiver : BroadcastReceiver() {

val response = PermissionResponse.entries.firstOrNull { it.apiValue == responseValue } ?: return
val app = context.applicationContext as? OpenCodeApplication ?: return
val appContext = context.applicationContext
val pending = goAsync()
scope.launch {
try {
val backend = app.runtimeRegistry.selected.value ?: return@launch
val ok = backend.respondToPermission(sessionId, permissionId, response, remember)
if (ok) {
app.activityRepository.resolvePermission(permissionId)
app.notifications.cancelPermission(permissionId)
}
// Everything below can throw: the local runtime may no longer be installed,
// the permission may already have been answered in-app (4xx), or the network
// may be down. An escaping exception here kills the whole process, so the tap
// on the notification must never propagate one.
val result =
runCatching {
// goAsync() only keeps the process alive for a short window, so a hung
// request must not outlive it.
withTimeout(RESPONSE_TIMEOUT_MS) {
val backend =
app.runtimeRegistry.selected.value
?: error("No OpenCode runtime is selected")
backend.respondToPermission(sessionId, permissionId, response, remember)
}
}

result
.onSuccess { accepted ->
if (accepted) {
// Also cancels the notification and tells the chat screen to drop
// the card for this request.
runCatching { app.activityRepository.resolvePermission(permissionId) }
} else {
notifyFailure(app, appContext, sessionId)
}
}
.onFailure { error ->
Log.w(TAG, "Failed to answer permission $permissionId", error)
notifyFailure(app, appContext, sessionId)
}
} finally {
pending.finish()
runCatching { pending.finish() }
}
}
}

/**
* Leaves the approval notification in place (so the request is not silently lost) and tells
* the user the tap did not get through.
*/
private fun notifyFailure(
app: OpenCodeApplication,
context: Context,
sessionId: String,
) {
runCatching {
app.notifications.notifySessionError(
sessionId,
context.getString(R.string.notification_permission_failed),
)
}
}

companion object {
private val scope = CoroutineScope(SupervisorJob() + Dispatchers.IO)
private const val TAG = "PermissionAction"
private const val RESPONSE_TIMEOUT_MS = 8_000L
private val handler =
CoroutineExceptionHandler { _, error ->
Log.e(TAG, "Unhandled permission response failure", error)
}
private val scope = CoroutineScope(SupervisorJob() + Dispatchers.IO + handler)
}
}
Original file line number Diff line number Diff line change
Expand Up @@ -33,7 +33,7 @@ class RuntimeNotificationHelper(private val context: Context) {
extras =
mapOf(
EXTRA_OPEN_ACTIVITY to true,
EXTRA_SESSION_ID to request.sessionId,
EXTRA_TARGET_SESSION_ID to request.sessionId,
),
)
val allowOnce = permissionActionIntent(request, PermissionResponse.ONCE, remember = false)
Expand Down Expand Up @@ -75,7 +75,7 @@ class RuntimeNotificationHelper(private val context: Context) {
extras =
mapOf(
EXTRA_OPEN_CHAT to true,
EXTRA_SESSION_ID to sessionId,
EXTRA_TARGET_SESSION_ID to sessionId,
),
)
val notification =
Expand All @@ -100,7 +100,7 @@ class RuntimeNotificationHelper(private val context: Context) {
extras =
mapOf(
EXTRA_OPEN_ACTIVITY to true,
EXTRA_SESSION_ID to (sessionId.orEmpty()),
EXTRA_TARGET_SESSION_ID to (sessionId.orEmpty()),
),
)
val notification =
Expand Down Expand Up @@ -213,6 +213,9 @@ class RuntimeNotificationHelper(private val context: Context) {
const val EXTRA_OPEN_ACTIVITY = "open_activity"
const val EXTRA_OPEN_CHAT = "open_chat"
const val EXTRA_SESSION_ID = "session_id"

/** The key [com.opencode.android.MainActivity] reads to jump straight to a session. */
const val EXTRA_TARGET_SESSION_ID = "target_session_id"
const val EXTRA_PERMISSION_ID = "permission_id"
const val EXTRA_PERMISSION_RESPONSE = "permission_response"
const val EXTRA_PERMISSION_REMEMBER = "permission_remember"
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -47,6 +47,7 @@ class RuntimeActivityRepository(
private val retryDelayMillis: Long = 2_000L,
private val maxRetryDelayMillis: Long = 30_000L,
private val onPermissionAsked: ((PermissionRequest) -> Unit)? = null,
private val onPermissionResolved: ((String) -> Unit)? = null,
private val onSessionIdle: ((String) -> Unit)? = null,
private val onSessionError: ((String?, String?) -> Unit)? = null,
private val unreadStore: UnreadSessionStore? = null,
Expand All @@ -67,6 +68,14 @@ class RuntimeActivityRepository(
private val mutableEvents = MutableSharedFlow<OpenCodeEvent>(extraBufferCapacity = 128)
val events: SharedFlow<OpenCodeEvent> = mutableEvents.asSharedFlow()

/**
* Permission ids that have been answered somewhere in the app (notification action, activity
* screen, chat). OpenCode has no `permission.replied` event, so this is how other surfaces
* learn that a request they are still showing is already settled.
*/
private val mutableResolvedPermissions = MutableSharedFlow<String>(extraBufferCapacity = 64)
val resolvedPermissions: SharedFlow<String> = mutableResolvedPermissions.asSharedFlow()

init {
scope.launch {
registry.selected.collectLatest selected@{ target ->
Expand Down Expand Up @@ -125,6 +134,8 @@ class RuntimeActivityRepository(
mutableState.update { current ->
current.copy(permissions = current.permissions.filterNot { it.id == permissionId })
}
mutableResolvedPermissions.tryEmit(permissionId)
onPermissionResolved?.invoke(permissionId)
}

fun markSessionRead(sessionId: String) {
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -147,6 +147,8 @@ fun ChatHomeScreen(
onToggleFavorite: (String, String) -> Unit = { _, _ -> },
onSelectQuestionAnswer: (String, Int, String) -> Unit,
onSubmitQuestion: (String) -> Unit,
onCancelQuestion: (String) -> Unit = {},
onDismissQuestion: (String) -> Unit = {},
autoAcceptPermissions: Boolean = false,
onToggleAutoAccept: (Boolean) -> Unit = {},
sendBehavior: String = "interrupt",
Expand Down Expand Up @@ -334,6 +336,8 @@ fun ChatHomeScreen(
question = question,
onAnswerSelected = onSelectQuestionAnswer,
onSubmit = onSubmitQuestion,
onCancel = onCancelQuestion,
onDismiss = onDismissQuestion,
)
}
if (state.isThinking) {
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -222,6 +222,7 @@ class ChatViewModel(
* test clock advances through forever, so it stays off unless the real app asks for it.
*/
private val monitorConnectionQuality: Boolean = false,
private val resolvedPermissionFlow: Flow<String>? = null,
) : ViewModel() {
private val _uiState =
MutableStateFlow(
Expand All @@ -248,6 +249,17 @@ class ChatViewModel(
private val connectionMonitor = ConnectionQualityMonitor(viewModelScope)

init {
// A permission answered from the notification (or the activity screen) never comes back
// as an event, so without this the chat keeps showing a card for a settled request.
resolvedPermissionFlow?.let { flow ->
viewModelScope.launch {
flow.collect { permissionId ->
_uiState.update { state ->
state.copy(permissions = state.permissions.filterNot { it.id == permissionId })
}
}
}
}
if (backend != null) {
viewModelScope.launch {
// Only real transitions are reported, so merely opening an idle chat is not
Expand Down Expand Up @@ -812,6 +824,43 @@ class ChatViewModel(
}
}

/**
* Hides the question card and leaves the turn alone, so the user can ignore the question and
* just keep typing. The request stays open on the OpenCode side; this only affects what the
* chat shows.
*/
fun dismissQuestion(questionId: String) {
_uiState.update { state ->
state.copy(
pendingQuestions = state.pendingQuestions.filterNot { it.request.id == questionId },
)
}
}

/**
* Dismisses a question and stops the turn that is waiting on the answer. OpenCode has no
* "declined" reply for the question tool, so this is the way to end a turn outright rather
* than answering it — [dismissQuestion] is the lighter option that only clears the card.
*/
fun cancelQuestion(questionId: String) {
val pendingQuestion = _uiState.value.pendingQuestions.firstOrNull { it.request.id == questionId } ?: return
_uiState.update { state ->
state.copy(
pendingQuestions = state.pendingQuestions.filterNot { it.request.id == questionId },
)
}
val currentBackend = backend ?: return
viewModelScope.launch {
runCatching { currentBackend.abortSession(pendingQuestion.request.sessionId) }
.onSuccess {
_uiState.update { it.copy(isRunning = false, isThinking = false) }
}
.onFailure { error ->
_uiState.update { it.copy(error = error.safeMessage()) }
}
}
}

fun abort() {
val currentBackend = backend ?: return
val sessionId = _uiState.value.sessionId ?: return
Expand Down
Loading
Loading