Compare commits
29
Commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
94565e9d6d | ||
|
|
56c2e6fa07 | ||
|
|
28629f3d93 | ||
|
|
0d1faf47a0 | ||
|
|
00288a2b3b | ||
|
|
8f52feffba | ||
|
|
ee29e49361 | ||
|
|
e2073b7692 | ||
|
|
70b6d8ee5a | ||
|
|
1f5e50ccd7 | ||
|
|
5580c9d9bb | ||
|
|
2ebdf55501 | ||
|
|
ad107ea205 | ||
|
|
f5aeb27e5a | ||
|
|
fdaeb121d5 | ||
|
|
06c0df6304 | ||
|
|
71a2b3a7fb | ||
|
|
65e48084cb | ||
|
|
1074ecc24f | ||
|
|
f2a23e32aa | ||
|
|
630cc6d316 | ||
|
|
e16205d82a | ||
|
|
676c37e5ca | ||
|
|
9b6fed9bdd | ||
|
|
4d90eef3d8 | ||
|
|
478323893a | ||
|
|
fcddeeb810 | ||
|
|
49002b7141 | ||
|
|
326eb47df3 |
@@ -38,6 +38,10 @@ jobs:
|
||||
working-directory: plugin/dashboard
|
||||
run: npm run build
|
||||
|
||||
- name: Test dashboard source
|
||||
working-directory: plugin/dashboard
|
||||
run: npm test
|
||||
|
||||
- name: Setup Python
|
||||
uses: actions/setup-python@v7
|
||||
with:
|
||||
@@ -54,7 +58,11 @@ jobs:
|
||||
run: pip install -r relay_server/requirements.txt fastapi httpx requests
|
||||
|
||||
- name: Run dashboard API tests
|
||||
run: python -m unittest plugin.dashboard.test_plugin_api
|
||||
run: >-
|
||||
python -m unittest
|
||||
plugin.dashboard.test_plugin_api
|
||||
plugin.dashboard.test_git_api
|
||||
plugin.dashboard.test_mobile_plugin_api
|
||||
|
||||
- name: Verify dashboard bundle outputs
|
||||
run: |
|
||||
|
||||
@@ -106,4 +106,8 @@ jobs:
|
||||
plugin/tests/test_session_grants.py \
|
||||
plugin/tests/test_native_layout_imports.py \
|
||||
plugin/tests/test_profile_discovery.py \
|
||||
plugin/tests/test_profiles_updated_broadcast.py
|
||||
plugin/tests/test_profiles_updated_broadcast.py \
|
||||
plugin/tests/test_git_state.py \
|
||||
plugin/tests/test_git_state_write.py \
|
||||
plugin/tests/test_git_state_extras.py \
|
||||
plugin/tests/test_mobile_plugin_store.py
|
||||
|
||||
@@ -6,6 +6,10 @@ The format is based on [Keep a Changelog](https://keepachangelog.com/), and this
|
||||
|
||||
## [Unreleased]
|
||||
|
||||
### Fixed
|
||||
|
||||
- **Returning from parent settings keeps Supervised Chat rendered.** Parent access now relocks without rebuilding the active navigation graph, and full Settings keeps a prominent shortcut back to Supervised Mode controls.
|
||||
|
||||
## [Android 1.13.1] - 2026-08-25
|
||||
|
||||
### Fixed
|
||||
@@ -19,6 +23,7 @@ The format is based on [Keep a Changelog](https://keepachangelog.com/), and this
|
||||
- **Provider usage and limits are available from top-level Settings.** Codex credential pools, Nous balances, and OpenCode Go account windows share one provider-neutral screen with Summary, Expanded, and Hidden presentation modes. Provider credentials remain on the Hermes host.
|
||||
- **Android Bot Mode provides one messenger-style workspace across saved Hermes gateways.** Bots and read-only group rooms aggregate without changing the foreground connection, Bot Chats retain exact gateway/profile ownership, and unavailable gateways keep clearly marked last-known roster entries.
|
||||
- **Android Assistant screen context.** Compatible unlocked assistant-button invocations can open Hermes, begin listening, and include bounded visible text plus an available screenshot in the first Standard voice turn. Ordinary wake and keyguard invocations remain screen-context free.
|
||||
- **Android Supervised Mode presents a parent-controlled, profile-pinned chat surface.** Parents can limit attachments, Standard voice, generated media, conversation history, actions, and technical metadata while device authentication protects full settings. Hermes-Relay can identify and revoke a paired supervised client without becoming the policy enforcement boundary.
|
||||
|
||||
### Changed
|
||||
|
||||
|
||||
@@ -11,6 +11,7 @@ import com.hermesandroid.relay.data.replaceHermesReachCredential
|
||||
import com.hermesandroid.relay.data.sameBrokerAuthority
|
||||
import com.hermesandroid.relay.data.PairingPreferences
|
||||
import com.hermesandroid.relay.data.Profile
|
||||
import com.hermesandroid.relay.data.SupervisedModePolicy
|
||||
import com.hermesandroid.relay.data.isSafeProfileUiMeta
|
||||
import com.hermesandroid.relay.network.relay.ChannelMultiplexer
|
||||
import com.hermesandroid.relay.network.relay.models.Envelope
|
||||
@@ -18,6 +19,8 @@ import com.hermesandroid.relay.network.shared.InvalidCredentialException
|
||||
import com.hermesandroid.relay.network.shared.normalizeCredentialForHeader
|
||||
import kotlinx.coroutines.CoroutineScope
|
||||
import kotlinx.coroutines.Dispatchers
|
||||
import kotlinx.coroutines.Job
|
||||
import kotlinx.coroutines.delay
|
||||
import kotlinx.coroutines.flow.MutableStateFlow
|
||||
import kotlinx.coroutines.flow.StateFlow
|
||||
import kotlinx.coroutines.flow.asSharedFlow
|
||||
@@ -53,6 +56,39 @@ sealed class AuthState {
|
||||
data class Failed(val reason: String) : AuthState()
|
||||
}
|
||||
|
||||
internal fun relaySupervisedModePayload(policy: SupervisedModePolicy): JsonObject {
|
||||
if (!policy.isActive) return buildJsonObject { put("active", false) }
|
||||
val capabilities = buildList {
|
||||
add("text_chat")
|
||||
if (policy.capabilities.newChat) add("new_chat")
|
||||
if (policy.capabilities.cancelResponse) add("cancel")
|
||||
if (policy.capabilities.steerResponse) add("steer")
|
||||
if (policy.capabilities.attachments) add("attachments")
|
||||
if (policy.capabilities.voice) add("voice")
|
||||
if (policy.capabilities.generatedImages) add("generated_images")
|
||||
if (policy.capabilities.shareGeneratedImages) add("share_images")
|
||||
if (policy.capabilities.copyResponses) add("copy")
|
||||
if (policy.capabilities.retryResponse) add("retry")
|
||||
if (policy.capabilities.quoteReplies) add("quote_reply")
|
||||
if (policy.visibility.resolved().showTimestamps) add("timestamps")
|
||||
}.take(12)
|
||||
return buildJsonObject {
|
||||
put("active", true)
|
||||
put("profile_label", policy.pinnedProfileName.orEmpty().take(80))
|
||||
put("capabilities", JsonArray(capabilities.map(::JsonPrimitive)))
|
||||
}
|
||||
}
|
||||
|
||||
internal fun relaySupervisedModeUpdateEnvelope(
|
||||
policy: SupervisedModePolicy,
|
||||
): Envelope = Envelope(
|
||||
channel = "system",
|
||||
type = "supervised.update",
|
||||
payload = buildJsonObject {
|
||||
put("supervised_mode", relaySupervisedModePayload(policy))
|
||||
},
|
||||
)
|
||||
|
||||
@Serializable
|
||||
data class ConnectionAuthSecrets(
|
||||
val sessionToken: String? = null,
|
||||
@@ -120,6 +156,60 @@ class AuthManager(
|
||||
private val eagerHydrate: Boolean = true,
|
||||
) : ChannelMultiplexer.ChannelHandler {
|
||||
|
||||
@Volatile
|
||||
private var supervisedMode: SupervisedModePolicy = SupervisedModePolicy()
|
||||
|
||||
@Volatile
|
||||
private var supervisedMetadataReconnectFallback: (() -> Unit)? = null
|
||||
private var pendingSupervisedUpdateId: String? = null
|
||||
private var supervisedUpdateFallbackJob: Job? = null
|
||||
|
||||
/**
|
||||
* Update the public client-mode tag sent on Relay auth. This does not grant
|
||||
* authority: Relay labels enforcement_owner=android_client and the Android
|
||||
* policy remains the enforcing surface.
|
||||
*/
|
||||
fun updateSupervisedMode(policy: SupervisedModePolicy) {
|
||||
if (supervisedMode == policy) return
|
||||
supervisedMode = policy
|
||||
if (_authState.value is AuthState.Paired) sendSupervisedModeUpdate()
|
||||
}
|
||||
|
||||
/**
|
||||
* Install the narrow compatibility path used when an older Relay ignores
|
||||
* `system/supervised.update`. Reopening the authenticated socket causes
|
||||
* the current policy to travel through the legacy `system/auth` payload.
|
||||
*/
|
||||
fun setSupervisedMetadataReconnectFallback(callback: () -> Unit) {
|
||||
supervisedMetadataReconnectFallback = callback
|
||||
}
|
||||
|
||||
private fun sendSupervisedModeUpdate() {
|
||||
val envelope = relaySupervisedModeUpdateEnvelope(supervisedMode)
|
||||
pendingSupervisedUpdateId = envelope.id
|
||||
supervisedUpdateFallbackJob?.cancel()
|
||||
multiplexer.send(envelope)
|
||||
supervisedUpdateFallbackJob = scope.launch {
|
||||
delay(SUPERVISED_UPDATE_ACK_TIMEOUT_MS)
|
||||
if (pendingSupervisedUpdateId == envelope.id) {
|
||||
pendingSupervisedUpdateId = null
|
||||
Log.i(TAG, "supervised.update unsupported or unacknowledged; refreshing Relay socket")
|
||||
supervisedMetadataReconnectFallback?.invoke()
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
private fun settleSupervisedModeUpdate(envelope: Envelope, unsupported: Boolean) {
|
||||
if (envelope.id != pendingSupervisedUpdateId) return
|
||||
pendingSupervisedUpdateId = null
|
||||
supervisedUpdateFallbackJob?.cancel()
|
||||
supervisedUpdateFallbackJob = null
|
||||
if (unsupported) {
|
||||
Log.i(TAG, "supervised.update rejected; refreshing Relay socket for compatibility")
|
||||
supervisedMetadataReconnectFallback?.invoke()
|
||||
}
|
||||
}
|
||||
|
||||
companion object {
|
||||
private const val TAG = "AuthManager"
|
||||
private const val KEY_SESSION_TOKEN = "session_token"
|
||||
@@ -134,6 +224,7 @@ class AuthManager(
|
||||
// migration has run, so we never rebuild the legacy keyset to re-check.
|
||||
private const val KEY_LEGACY_MIGRATED = "legacy_migrated"
|
||||
private const val PAIRING_CODE_LENGTH = 6
|
||||
private const val SUPERVISED_UPDATE_ACK_TIMEOUT_MS = 2_000L
|
||||
private val PAIRING_CODE_CHARS = ('A'..'Z') + ('0'..'9')
|
||||
|
||||
/**
|
||||
@@ -835,6 +926,10 @@ class AuthManager(
|
||||
put("device_form_factor", "phone")
|
||||
}
|
||||
|
||||
private fun JsonObjectBuilder.putSupervisedMode() {
|
||||
put("supervised_mode", relaySupervisedModePayload(supervisedMode))
|
||||
}
|
||||
|
||||
private fun relayDeviceName(): String {
|
||||
val configured = runCatching {
|
||||
Settings.Global.getString(context.contentResolver, "device_name")
|
||||
@@ -890,6 +985,7 @@ class AuthManager(
|
||||
put("device_id", deviceId)
|
||||
putRelayDeviceIdentity()
|
||||
putRelayClientSupports()
|
||||
putSupervisedMode()
|
||||
}
|
||||
}
|
||||
else -> {
|
||||
@@ -906,6 +1002,7 @@ class AuthManager(
|
||||
put("device_id", deviceId)
|
||||
putRelayDeviceIdentity()
|
||||
putRelayClientSupports()
|
||||
putSupervisedMode()
|
||||
pendingTtlSeconds?.let { put("ttl_seconds", it) }
|
||||
pendingGrants?.let { grants ->
|
||||
val obj = buildJsonObject {
|
||||
@@ -985,6 +1082,8 @@ class AuthManager(
|
||||
when (envelope.type) {
|
||||
"auth.ok" -> handleAuthOk(envelope)
|
||||
"auth.fail" -> handleAuthFail(envelope)
|
||||
"supervised.updated" -> settleSupervisedModeUpdate(envelope, unsupported = false)
|
||||
"error" -> settleSupervisedModeUpdate(envelope, unsupported = true)
|
||||
// `profiles.updated` push — sent by the v0.7.1+ relay on
|
||||
// the "pairing" channel whenever its in-memory profile
|
||||
// snapshot changes (file-watcher, SIGHUP, or a manual
|
||||
@@ -1129,6 +1228,11 @@ class AuthManager(
|
||||
get() = _authState.value is AuthState.Paired
|
||||
|
||||
private fun handleAuthOk(envelope: Envelope) {
|
||||
// A successful auth always carries the latest client report, including
|
||||
// after the compatibility reconnect used for older Relay versions.
|
||||
pendingSupervisedUpdateId = null
|
||||
supervisedUpdateFallbackJob?.cancel()
|
||||
supervisedUpdateFallbackJob = null
|
||||
scope.launch {
|
||||
try {
|
||||
val payload = envelope.payload
|
||||
|
||||
@@ -0,0 +1,163 @@
|
||||
package com.hermesandroid.relay.data
|
||||
|
||||
import com.hermesandroid.relay.network.upstream.DashboardApiClient
|
||||
import com.hermesandroid.relay.plugins.runtime.ScopedPluginApiClient
|
||||
import kotlinx.serialization.json.Json
|
||||
import kotlinx.serialization.json.JsonPrimitive
|
||||
import kotlinx.serialization.json.buildJsonArray
|
||||
import kotlinx.serialization.json.buildJsonObject
|
||||
import kotlinx.serialization.json.decodeFromJsonElement
|
||||
import kotlinx.serialization.json.put
|
||||
|
||||
// Read + write client for the Hermes-Relay Git State endpoints.
|
||||
// All requests are confined to the ``hermes-relay`` plugin namespace and the
|
||||
// ``git/*`` sub-path via ScopedPluginApiClient, which rejects traversal and
|
||||
// encodes query values.
|
||||
|
||||
private fun pathsArray(paths: List<String>) = buildJsonArray { paths.forEach { add(JsonPrimitive(it)) } }
|
||||
|
||||
class GitStateApiClient(
|
||||
dashboard: DashboardApiClient,
|
||||
) {
|
||||
private val scoped = ScopedPluginApiClient("hermes-relay", dashboard)
|
||||
private val json = Json { ignoreUnknownKeys = true }
|
||||
|
||||
suspend fun repos(): Result<List<GitRepo>> = scoped
|
||||
.get("git/repos")
|
||||
.mapCatching { element ->
|
||||
json.decodeFromJsonElement<ReposResponse>(element).repos
|
||||
}
|
||||
|
||||
suspend fun status(repo: String): Result<GitStatus> = scoped
|
||||
.get("git/status", mapOf("repo" to repo))
|
||||
.mapCatching { element -> json.decodeFromJsonElement<GitStatus>(element) }
|
||||
|
||||
suspend fun branches(repo: String): Result<List<GitBranch>> = scoped
|
||||
.get("git/branches", mapOf("repo" to repo))
|
||||
.mapCatching { element ->
|
||||
json.decodeFromJsonElement<BranchesResponse>(element).branches
|
||||
}
|
||||
|
||||
suspend fun diff(repo: String, path: String, kind: String): Result<GitDiff> = scoped
|
||||
.get("git/diff", mapOf("repo" to repo, "path" to path, "kind" to kind))
|
||||
.mapCatching { element -> json.decodeFromJsonElement<GitDiff>(element) }
|
||||
|
||||
suspend fun file(repo: String, path: String): Result<GitFile> = scoped
|
||||
.get("git/file", mapOf("repo" to repo, "path" to path))
|
||||
.mapCatching { element -> json.decodeFromJsonElement<GitFile>(element) }
|
||||
|
||||
// ── Write operations ───────────────────────────────────────────────────
|
||||
// Every write requires the plugin.api.write grant, which the app enforces
|
||||
// (see GitStateViewModel: a POST is never sent without the grant). The
|
||||
// server additionally enforces per-use confirmation strings for destructive
|
||||
// ops (discard/push/dirty-checkout) — the caller passes the echoed token.
|
||||
|
||||
suspend fun stage(repo: String, paths: List<String>): Result<GitMutationResult> =
|
||||
scoped.post("git/stage", buildJsonObject {
|
||||
put("repo", repo)
|
||||
put("paths", pathsArray(paths))
|
||||
}).mapCatching { json.decodeFromJsonElement<GitMutationResult>(it) }
|
||||
|
||||
suspend fun unstage(repo: String, paths: List<String>): Result<GitMutationResult> =
|
||||
scoped.post("git/unstage", buildJsonObject {
|
||||
put("repo", repo)
|
||||
put("paths", pathsArray(paths))
|
||||
}).mapCatching { json.decodeFromJsonElement<GitMutationResult>(it) }
|
||||
|
||||
suspend fun discard(
|
||||
repo: String,
|
||||
paths: List<String>,
|
||||
confirmation: String,
|
||||
deleteUntracked: Boolean = false,
|
||||
): Result<GitMutationResult> = scoped.post("git/discard", buildJsonObject {
|
||||
put("repo", repo)
|
||||
put("paths", pathsArray(paths))
|
||||
put("confirmation", confirmation)
|
||||
put("delete_untracked", deleteUntracked)
|
||||
}).mapCatching { json.decodeFromJsonElement<GitMutationResult>(it) }
|
||||
|
||||
suspend fun commit(repo: String, message: String): Result<GitMutationResult> =
|
||||
scoped.post("git/commit", buildJsonObject {
|
||||
put("repo", repo)
|
||||
put("message", message)
|
||||
}).mapCatching { json.decodeFromJsonElement<GitMutationResult>(it) }
|
||||
|
||||
suspend fun commitSelected(
|
||||
repo: String,
|
||||
message: String,
|
||||
paths: List<String>,
|
||||
): Result<GitMutationResult> = scoped.post("git/commit_selected", buildJsonObject {
|
||||
put("repo", repo)
|
||||
put("message", message)
|
||||
put("paths", pathsArray(paths))
|
||||
}).mapCatching { json.decodeFromJsonElement<GitMutationResult>(it) }
|
||||
|
||||
suspend fun fetch(repo: String, remote: String = "origin"): Result<GitMutationResult> =
|
||||
scoped.post("git/fetch", buildJsonObject {
|
||||
put("repo", repo)
|
||||
put("remote", remote)
|
||||
}).mapCatching { json.decodeFromJsonElement<GitMutationResult>(it) }
|
||||
|
||||
suspend fun pull(repo: String, remote: String = "origin", branch: String = ""): Result<GitMutationResult> =
|
||||
scoped.post("git/pull", buildJsonObject {
|
||||
put("repo", repo)
|
||||
put("remote", remote)
|
||||
put("branch", branch)
|
||||
}).mapCatching { json.decodeFromJsonElement<GitMutationResult>(it) }
|
||||
|
||||
suspend fun push(
|
||||
repo: String,
|
||||
confirmation: String,
|
||||
remote: String = "origin",
|
||||
branch: String = "",
|
||||
): Result<GitMutationResult> = scoped.post("git/push", buildJsonObject {
|
||||
put("repo", repo)
|
||||
put("remote", remote)
|
||||
put("branch", branch)
|
||||
put("confirmation", confirmation)
|
||||
}).mapCatching { json.decodeFromJsonElement<GitMutationResult>(it) }
|
||||
|
||||
suspend fun checkout(
|
||||
repo: String,
|
||||
ref: String,
|
||||
confirmation: String? = null,
|
||||
newBranch: String = "",
|
||||
track: Boolean = false,
|
||||
): Result<GitMutationResult> = scoped.post("git/checkout", buildJsonObject {
|
||||
put("repo", repo)
|
||||
put("ref", ref)
|
||||
if (confirmation != null) put("confirmation", confirmation)
|
||||
if (newBranch.isNotEmpty()) put("new_branch", newBranch)
|
||||
put("track", track)
|
||||
}).mapCatching { json.decodeFromJsonElement<GitMutationResult>(it) }
|
||||
|
||||
// ── Phase 3 extras ─────────────────────────────────────────────────────
|
||||
|
||||
/** Generate a commit-message suggestion from the staged diff. */
|
||||
suspend fun commitMessage(repo: String): Result<GitCommitMessage> =
|
||||
scoped.post("git/commit_message", buildJsonObject {
|
||||
put("repo", repo)
|
||||
}).mapCatching { json.decodeFromJsonElement<GitCommitMessage>(it) }
|
||||
|
||||
/** Generate a commit-message suggestion from the given paths' staged diff. */
|
||||
suspend fun commitMessageSelected(
|
||||
repo: String,
|
||||
paths: List<String>,
|
||||
): Result<GitCommitMessage> = scoped.post("git/commit_message_selected", buildJsonObject {
|
||||
put("repo", repo)
|
||||
put("paths", pathsArray(paths))
|
||||
}).mapCatching { json.decodeFromJsonElement<GitCommitMessage>(it) }
|
||||
|
||||
/** Checkout that auto-stashes a dirty tree first. */
|
||||
suspend fun stashCheckout(
|
||||
repo: String,
|
||||
ref: String,
|
||||
newBranch: String = "",
|
||||
track: Boolean = false,
|
||||
): Result<GitStashCheckoutResult> = scoped.post("git/stash_checkout", buildJsonObject {
|
||||
put("repo", repo)
|
||||
put("ref", ref)
|
||||
if (newBranch.isNotEmpty()) put("new_branch", newBranch)
|
||||
put("track", track)
|
||||
}).mapCatching { json.decodeFromJsonElement<GitStashCheckoutResult>(it) }
|
||||
}
|
||||
@@ -0,0 +1,101 @@
|
||||
package com.hermesandroid.relay.data
|
||||
|
||||
import kotlinx.serialization.SerialName
|
||||
import kotlinx.serialization.Serializable
|
||||
|
||||
/** A repository discovered by the plugin's /git/repos endpoint. */
|
||||
@Serializable
|
||||
data class GitRepo(
|
||||
val id: String,
|
||||
val name: String,
|
||||
val root: String,
|
||||
@SerialName("current_branch") val currentBranch: String? = null,
|
||||
val dirty: Boolean = false,
|
||||
)
|
||||
|
||||
/** Working-tree status from /git/status. */
|
||||
@Serializable
|
||||
data class GitStatus(
|
||||
val counts: GitStatusCounts = GitStatusCounts(),
|
||||
val staged: List<GitStatusEntry> = emptyList(),
|
||||
val modified: List<GitStatusEntry> = emptyList(),
|
||||
val untracked: List<GitStatusEntry> = emptyList(),
|
||||
val truncated: Boolean = false,
|
||||
)
|
||||
|
||||
@Serializable
|
||||
data class GitStatusCounts(
|
||||
val staged: Int = 0,
|
||||
val modified: Int = 0,
|
||||
val untracked: Int = 0,
|
||||
)
|
||||
|
||||
@Serializable
|
||||
data class GitStatusEntry(
|
||||
val path: String,
|
||||
)
|
||||
|
||||
/** A branch from /git/branches. */
|
||||
@Serializable
|
||||
data class GitBranch(
|
||||
val name: String,
|
||||
val upstream: String? = null,
|
||||
val ahead: Int = 0,
|
||||
val behind: Int = 0,
|
||||
@SerialName("is_current") val isCurrent: Boolean = false,
|
||||
)
|
||||
|
||||
/** A per-file diff from /git/diff. */
|
||||
@Serializable
|
||||
data class GitDiff(
|
||||
val path: String,
|
||||
val kind: String,
|
||||
val diff: String,
|
||||
val truncated: Boolean = false,
|
||||
)
|
||||
|
||||
/** A tracked-file read from /git/file. */
|
||||
@Serializable
|
||||
data class GitFile(
|
||||
val path: String,
|
||||
val content: String,
|
||||
val truncated: Boolean = false,
|
||||
)
|
||||
|
||||
/** Wrapper for /git/repos response. */
|
||||
@Serializable
|
||||
internal data class ReposResponse(
|
||||
val repos: List<GitRepo> = emptyList(),
|
||||
val notice: String? = null,
|
||||
)
|
||||
|
||||
/** Wrapper for /git/branches response. */
|
||||
@Serializable
|
||||
internal data class BranchesResponse(
|
||||
val branches: List<GitBranch> = emptyList(),
|
||||
)
|
||||
|
||||
/** A mutation response: fresh HEAD oid + working-tree status (+ branches). */
|
||||
@Serializable
|
||||
data class GitMutationResult(
|
||||
val head: String = "",
|
||||
val status: GitStatus = GitStatus(),
|
||||
val branches: List<GitBranch> = emptyList(),
|
||||
)
|
||||
|
||||
/** A /git/commit_message suggestion: generated message + optional notice. */
|
||||
@Serializable
|
||||
data class GitCommitMessage(
|
||||
val message: String = "",
|
||||
val notice: String = "",
|
||||
)
|
||||
|
||||
/** A /git/stash_checkout result: standard mutation shape + stash flag/message. */
|
||||
@Serializable
|
||||
data class GitStashCheckoutResult(
|
||||
val head: String = "",
|
||||
val status: GitStatus = GitStatus(),
|
||||
val branches: List<GitBranch> = emptyList(),
|
||||
val stashed: Boolean = false,
|
||||
@SerialName("stash_message") val stashMessage: String = "",
|
||||
)
|
||||
@@ -0,0 +1,234 @@
|
||||
package com.hermesandroid.relay.data
|
||||
|
||||
import kotlinx.serialization.SerialName
|
||||
import kotlinx.serialization.Serializable
|
||||
import com.hermesandroid.relay.ui.theme.AppThemes
|
||||
|
||||
/**
|
||||
* Parent-configured restrictions for the official Android client.
|
||||
*
|
||||
* This policy deliberately describes a client presentation mode, not a server
|
||||
* authorization boundary. The pinned profile is expected to have already been
|
||||
* configured with the appropriate server-side tool and content restrictions.
|
||||
*/
|
||||
@Serializable
|
||||
data class SupervisedModePolicy(
|
||||
val enabled: Boolean = false,
|
||||
val pinnedProfileName: String? = null,
|
||||
val capabilities: SupervisedCapabilities = SupervisedCapabilities(),
|
||||
val appearance: SupervisedAppearance = SupervisedAppearance(),
|
||||
val visibility: SupervisedVisibility = SupervisedVisibility(),
|
||||
val parentAccess: SupervisedParentAccess = SupervisedParentAccess(),
|
||||
) {
|
||||
/** A saved policy is usable only when it names a concrete Hermes profile. */
|
||||
val isConfigured: Boolean
|
||||
get() = !pinnedProfileName.isNullOrBlank()
|
||||
|
||||
/** Consumers should use this instead of treating [enabled] alone as sufficient. */
|
||||
val isActive: Boolean
|
||||
get() = enabled && isConfigured
|
||||
|
||||
internal fun normalized(): SupervisedModePolicy = copy(
|
||||
pinnedProfileName = pinnedProfileName?.trim()?.takeIf { it.isNotEmpty() },
|
||||
capabilities = capabilities.normalized(),
|
||||
appearance = appearance.normalized(),
|
||||
parentAccess = parentAccess.normalized(),
|
||||
)
|
||||
}
|
||||
|
||||
/** Actions and content types the supervised chat surface may expose. */
|
||||
@Serializable
|
||||
data class SupervisedCapabilities(
|
||||
val attachments: Boolean = false,
|
||||
val voice: Boolean = false,
|
||||
val generatedImages: Boolean = true,
|
||||
val conversationHistory: Boolean = false,
|
||||
val newChat: Boolean = true,
|
||||
val cancelResponse: Boolean = true,
|
||||
val steerResponse: Boolean = true,
|
||||
val retryResponse: Boolean = true,
|
||||
val copyResponses: Boolean = true,
|
||||
val quoteReplies: Boolean = true,
|
||||
val editAndResend: Boolean = false,
|
||||
val shareGeneratedImages: Boolean = false,
|
||||
val sessionActions: SupervisedSessionActions = SupervisedSessionActions(),
|
||||
val attachmentMaxCount: Int = DEFAULT_ATTACHMENT_MAX_COUNT,
|
||||
val attachmentMaxFileMb: Int = DEFAULT_ATTACHMENT_MAX_FILE_MB,
|
||||
val attachmentCategories: Set<SupervisedAttachmentCategory> = setOf(
|
||||
SupervisedAttachmentCategory.Images,
|
||||
),
|
||||
) {
|
||||
internal fun normalized(): SupervisedCapabilities = copy(
|
||||
attachmentMaxCount = attachmentMaxCount.coerceIn(1, MAX_ATTACHMENT_COUNT),
|
||||
attachmentMaxFileMb = attachmentMaxFileMb.coerceIn(1, MAX_ATTACHMENT_FILE_MB),
|
||||
attachmentCategories = attachmentCategories.ifEmpty {
|
||||
setOf(SupervisedAttachmentCategory.Images)
|
||||
},
|
||||
)
|
||||
|
||||
companion object {
|
||||
const val DEFAULT_ATTACHMENT_MAX_COUNT = 4
|
||||
const val DEFAULT_ATTACHMENT_MAX_FILE_MB = 10
|
||||
const val MAX_ATTACHMENT_COUNT = 10
|
||||
const val MAX_ATTACHMENT_FILE_MB = 100
|
||||
}
|
||||
}
|
||||
|
||||
/** Appearance applied only while the supervised root is locked. */
|
||||
@Serializable
|
||||
data class SupervisedAppearance(
|
||||
val appThemeId: String = AppThemes.DEFAULT_ID,
|
||||
val themePreference: String = "auto",
|
||||
val showPet: Boolean = false,
|
||||
val allowProfileIconChanges: Boolean = false,
|
||||
val allowBackgroundChanges: Boolean = false,
|
||||
) {
|
||||
internal fun normalized(): SupervisedAppearance = copy(
|
||||
appThemeId = AppThemes.byId(appThemeId).id,
|
||||
themePreference = themePreference.takeIf { it in VALID_THEME_PREFERENCES } ?: "auto",
|
||||
)
|
||||
|
||||
private companion object {
|
||||
val VALID_THEME_PREFERENCES = setOf("auto", "light", "dark")
|
||||
}
|
||||
}
|
||||
|
||||
/** Mutable operations available from a supervised conversation-history row. */
|
||||
@Serializable
|
||||
data class SupervisedSessionActions(
|
||||
val pin: Boolean = false,
|
||||
val rename: Boolean = false,
|
||||
val archive: Boolean = false,
|
||||
val delete: Boolean = false,
|
||||
val shareTranscript: Boolean = false,
|
||||
) {
|
||||
val enabledCount: Int
|
||||
get() = listOf(pin, rename, archive, delete, shareTranscript).count { it }
|
||||
|
||||
val allEnabled: Boolean
|
||||
get() = enabledCount == TOTAL
|
||||
|
||||
val noneEnabled: Boolean
|
||||
get() = enabledCount == 0
|
||||
|
||||
fun withAll(enabled: Boolean): SupervisedSessionActions = SupervisedSessionActions(
|
||||
pin = enabled,
|
||||
rename = enabled,
|
||||
archive = enabled,
|
||||
delete = enabled,
|
||||
shareTranscript = enabled,
|
||||
)
|
||||
|
||||
companion object {
|
||||
const val TOTAL = 5
|
||||
}
|
||||
}
|
||||
|
||||
enum class SupervisedSessionAction {
|
||||
Pin,
|
||||
Rename,
|
||||
Archive,
|
||||
Delete,
|
||||
ShareTranscript,
|
||||
}
|
||||
|
||||
fun SupervisedModePolicy.allowsSessionAction(action: SupervisedSessionAction): Boolean {
|
||||
if (!enabled) return true
|
||||
if (!capabilities.conversationHistory) return false
|
||||
return when (action) {
|
||||
SupervisedSessionAction.Pin -> capabilities.sessionActions.pin
|
||||
SupervisedSessionAction.Rename -> capabilities.sessionActions.rename
|
||||
SupervisedSessionAction.Archive -> capabilities.sessionActions.archive
|
||||
SupervisedSessionAction.Delete -> capabilities.sessionActions.delete
|
||||
SupervisedSessionAction.ShareTranscript -> capabilities.sessionActions.shareTranscript
|
||||
}
|
||||
}
|
||||
|
||||
@Serializable
|
||||
enum class SupervisedAttachmentCategory {
|
||||
@SerialName("images")
|
||||
Images,
|
||||
|
||||
@SerialName("documents")
|
||||
Documents,
|
||||
|
||||
@SerialName("audio")
|
||||
Audio,
|
||||
|
||||
@SerialName("video")
|
||||
Video,
|
||||
}
|
||||
|
||||
/**
|
||||
* Controls which metadata and conversation affordances are rendered.
|
||||
*
|
||||
* [Simple] is the quiet default. [Transparent] is a useful preset for older or
|
||||
* technical users, while [Custom] tells the UI to honor every stored toggle.
|
||||
*/
|
||||
@Serializable
|
||||
data class SupervisedVisibility(
|
||||
val preset: SupervisedVisibilityPreset = SupervisedVisibilityPreset.Simple,
|
||||
val showAgentIdentity: Boolean = true,
|
||||
val showModelName: Boolean = false,
|
||||
val showProfileName: Boolean = false,
|
||||
val showConnectionStatus: Boolean = true,
|
||||
val showTechnicalRoute: Boolean = false,
|
||||
val showTimestamps: Boolean = true,
|
||||
val showToolNames: Boolean = false,
|
||||
val showToolDetails: Boolean = false,
|
||||
val showWorkingStatus: Boolean = true,
|
||||
val showReasoning: Boolean = false,
|
||||
val showUsage: Boolean = false,
|
||||
) {
|
||||
/** Resolve presets to the concrete flags consumed by chat presentation. */
|
||||
fun resolved(): SupervisedVisibility = when (preset) {
|
||||
SupervisedVisibilityPreset.Simple -> SIMPLE
|
||||
SupervisedVisibilityPreset.Transparent -> TRANSPARENT
|
||||
SupervisedVisibilityPreset.Custom -> this
|
||||
}
|
||||
|
||||
companion object {
|
||||
val SIMPLE = SupervisedVisibility(preset = SupervisedVisibilityPreset.Simple)
|
||||
|
||||
val TRANSPARENT = SupervisedVisibility(
|
||||
preset = SupervisedVisibilityPreset.Transparent,
|
||||
showModelName = true,
|
||||
showProfileName = true,
|
||||
showTechnicalRoute = true,
|
||||
showToolNames = true,
|
||||
showUsage = true,
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
@Serializable
|
||||
enum class SupervisedVisibilityPreset {
|
||||
@SerialName("simple")
|
||||
Simple,
|
||||
|
||||
@SerialName("transparent")
|
||||
Transparent,
|
||||
|
||||
@SerialName("custom")
|
||||
Custom,
|
||||
}
|
||||
|
||||
/** Device-authentication and automatic relock behavior for parent access. */
|
||||
@Serializable
|
||||
data class SupervisedParentAccess(
|
||||
/** Reserved for forward-compatible persistence; normalization never permits an auth bypass. */
|
||||
val requireDeviceAuthentication: Boolean = true,
|
||||
val relockOnBackground: Boolean = true,
|
||||
val timeoutMinutes: Int = DEFAULT_TIMEOUT_MINUTES,
|
||||
) {
|
||||
internal fun normalized(): SupervisedParentAccess = copy(
|
||||
requireDeviceAuthentication = true,
|
||||
timeoutMinutes = timeoutMinutes.coerceIn(MIN_TIMEOUT_MINUTES, MAX_TIMEOUT_MINUTES),
|
||||
)
|
||||
|
||||
companion object {
|
||||
const val DEFAULT_TIMEOUT_MINUTES = 5
|
||||
const val MIN_TIMEOUT_MINUTES = 1
|
||||
const val MAX_TIMEOUT_MINUTES = 60
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,106 @@
|
||||
package com.hermesandroid.relay.data
|
||||
|
||||
import android.content.Context
|
||||
import android.util.Log
|
||||
import androidx.datastore.core.DataStore
|
||||
import androidx.datastore.preferences.core.Preferences
|
||||
import androidx.datastore.preferences.core.edit
|
||||
import androidx.datastore.preferences.core.stringPreferencesKey
|
||||
import kotlinx.coroutines.flow.Flow
|
||||
import kotlinx.coroutines.flow.map
|
||||
import kotlinx.serialization.builtins.MapSerializer
|
||||
import kotlinx.serialization.builtins.serializer
|
||||
import kotlinx.serialization.json.Json
|
||||
|
||||
/** Persists one independent [SupervisedModePolicy] per Hermes connection. */
|
||||
class SupervisedModeStore private constructor(
|
||||
private val dataStore: DataStore<Preferences>,
|
||||
) {
|
||||
constructor(context: Context) : this(context.relayDataStore)
|
||||
|
||||
private val json = Json {
|
||||
encodeDefaults = true
|
||||
ignoreUnknownKeys = true
|
||||
}
|
||||
private val serializer = MapSerializer(String.serializer(), SupervisedModePolicy.serializer())
|
||||
|
||||
fun policyFlow(connectionId: String): Flow<SupervisedModePolicy> =
|
||||
dataStore.data.map { preferences ->
|
||||
val decoded = decode(preferences[KEY_POLICIES])
|
||||
if (decoded.corrupt) {
|
||||
// A malformed persisted policy must never silently reopen the
|
||||
// unrestricted app. Enabled + unconfigured renders the
|
||||
// supervised recovery surface until an authenticated user
|
||||
// repairs or clears the policy.
|
||||
SupervisedModePolicy(enabled = true)
|
||||
} else {
|
||||
decoded.policies[connectionId]?.normalized() ?: SupervisedModePolicy()
|
||||
}
|
||||
}
|
||||
|
||||
suspend fun setPolicy(connectionId: String, policy: SupervisedModePolicy) {
|
||||
require(connectionId.isNotBlank()) { "connectionId must not be blank" }
|
||||
dataStore.edit { preferences ->
|
||||
val policies = decode(preferences[KEY_POLICIES]).policies.toMutableMap()
|
||||
policies[connectionId] = policy.normalized()
|
||||
preferences[KEY_POLICIES] = json.encodeToString(serializer, policies)
|
||||
}
|
||||
}
|
||||
|
||||
suspend fun updatePolicy(
|
||||
connectionId: String,
|
||||
transform: (SupervisedModePolicy) -> SupervisedModePolicy,
|
||||
) {
|
||||
require(connectionId.isNotBlank()) { "connectionId must not be blank" }
|
||||
dataStore.edit { preferences ->
|
||||
val policies = decode(preferences[KEY_POLICIES]).policies.toMutableMap()
|
||||
val current = policies[connectionId]?.normalized() ?: SupervisedModePolicy()
|
||||
policies[connectionId] = transform(current).normalized()
|
||||
preferences[KEY_POLICIES] = json.encodeToString(serializer, policies)
|
||||
}
|
||||
}
|
||||
|
||||
suspend fun setEnabled(connectionId: String, enabled: Boolean) {
|
||||
updatePolicy(connectionId) { it.copy(enabled = enabled) }
|
||||
}
|
||||
|
||||
suspend fun clear(connectionId: String) {
|
||||
dataStore.edit { preferences ->
|
||||
val policies = decode(preferences[KEY_POLICIES]).policies.toMutableMap()
|
||||
policies.remove(connectionId)
|
||||
if (policies.isEmpty()) {
|
||||
preferences.remove(KEY_POLICIES)
|
||||
} else {
|
||||
preferences[KEY_POLICIES] = json.encodeToString(serializer, policies)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/** Clear supervised policies without disturbing unrelated app settings. */
|
||||
suspend fun clearAll() {
|
||||
dataStore.edit { preferences -> preferences.remove(KEY_POLICIES) }
|
||||
}
|
||||
|
||||
private fun decode(raw: String?): DecodeResult {
|
||||
if (raw.isNullOrBlank()) return DecodeResult(emptyMap(), corrupt = false)
|
||||
return try {
|
||||
DecodeResult(json.decodeFromString(serializer, raw), corrupt = false)
|
||||
} catch (error: Exception) {
|
||||
Log.w(TAG, "Unable to decode supervised-mode policies; failing closed", error)
|
||||
DecodeResult(emptyMap(), corrupt = true)
|
||||
}
|
||||
}
|
||||
|
||||
private data class DecodeResult(
|
||||
val policies: Map<String, SupervisedModePolicy>,
|
||||
val corrupt: Boolean,
|
||||
)
|
||||
|
||||
internal companion object {
|
||||
private const val TAG = "SupervisedModeStore"
|
||||
private val KEY_POLICIES = stringPreferencesKey("supervised_mode_policies_v1")
|
||||
|
||||
fun forTesting(dataStore: DataStore<Preferences>): SupervisedModeStore =
|
||||
SupervisedModeStore(dataStore)
|
||||
}
|
||||
}
|
||||
@@ -153,7 +153,7 @@ class ChannelMultiplexer {
|
||||
)
|
||||
send(pong)
|
||||
}
|
||||
"auth.ok", "auth.fail" -> {
|
||||
"auth.ok", "auth.fail", "supervised.updated", "error" -> {
|
||||
// Delegate to system handler if registered
|
||||
handlers["system"]?.onMessage(envelope)
|
||||
}
|
||||
|
||||
@@ -442,6 +442,35 @@ class ConnectionManager(
|
||||
return true
|
||||
}
|
||||
|
||||
/**
|
||||
* Reopen the current authenticated Relay socket without discarding pair
|
||||
* state. Used only as a compatibility fallback when an older Relay does
|
||||
* not acknowledge a post-auth metadata update; the replacement socket's
|
||||
* normal `system/auth` frame carries the latest metadata.
|
||||
*/
|
||||
fun reconnectForAuthenticatedMetadataUpdate(): Boolean {
|
||||
val targetUrl = serverUrl?.takeIf { it.isNotBlank() } ?: return false
|
||||
if (isRelayRateLimitBackoffActive(
|
||||
rateLimitBackoffUntilMs,
|
||||
SystemClock.elapsedRealtime(),
|
||||
)
|
||||
) {
|
||||
Log.i(TAG, "metadata reconnect: preserving active rate-limit backoff")
|
||||
return false
|
||||
}
|
||||
val previousSocket = webSocket
|
||||
if (previousSocket == null) {
|
||||
connect(targetUrl)
|
||||
} else {
|
||||
doConnect(
|
||||
targetUrl,
|
||||
previousSocketToClose = previousSocket,
|
||||
replaceReason = "Relay metadata compatibility refresh",
|
||||
)
|
||||
}
|
||||
return true
|
||||
}
|
||||
|
||||
/**
|
||||
* Same as [connect] but bypasses the resolver — used by the network-
|
||||
* change callback when we've already picked a winner and just want to
|
||||
|
||||
@@ -48,6 +48,9 @@ interface VoiceAudioClient {
|
||||
val effectiveRoute: VoiceAudioRoute
|
||||
get() = route
|
||||
|
||||
/** Temporary client-policy override; the shared router honors it before user prefs. */
|
||||
fun setRouteOverride(route: VoiceAudioRoute?) = Unit
|
||||
|
||||
suspend fun transcribe(audioFile: File): Result<String>
|
||||
suspend fun synthesize(text: String): Result<File>
|
||||
|
||||
@@ -82,8 +85,15 @@ class AutoVoiceAudioClient(
|
||||
private val standardReadyProvider: () -> Boolean,
|
||||
private val relayReadyProvider: () -> Boolean,
|
||||
) : VoiceAudioClient {
|
||||
@Volatile
|
||||
private var routeOverride: VoiceAudioRoute? = null
|
||||
|
||||
override fun setRouteOverride(route: VoiceAudioRoute?) {
|
||||
routeOverride = route
|
||||
}
|
||||
|
||||
override val route: VoiceAudioRoute
|
||||
get() = routeProvider()
|
||||
get() = routeOverride ?: routeProvider()
|
||||
|
||||
/**
|
||||
* Resolve the configured preference to the backend a call would land on:
|
||||
@@ -92,7 +102,7 @@ class AutoVoiceAudioClient(
|
||||
* decide whether standard-only limitations (global TTS) currently apply.
|
||||
*/
|
||||
override val effectiveRoute: VoiceAudioRoute
|
||||
get() = when (routeProvider()) {
|
||||
get() = when (route) {
|
||||
VoiceAudioRoute.Standard -> VoiceAudioRoute.Standard
|
||||
VoiceAudioRoute.Relay -> VoiceAudioRoute.Relay
|
||||
VoiceAudioRoute.Auto ->
|
||||
@@ -114,7 +124,7 @@ class AutoVoiceAudioClient(
|
||||
private suspend fun <T> runWithSelectedRoute(
|
||||
block: suspend (VoiceAudioClient) -> Result<T>,
|
||||
): Result<T> {
|
||||
return when (routeProvider()) {
|
||||
return when (route) {
|
||||
VoiceAudioRoute.Standard -> {
|
||||
if (!standardReadyProvider()) {
|
||||
Result.failure(
|
||||
|
||||
@@ -54,6 +54,7 @@ import androidx.compose.runtime.rememberUpdatedState
|
||||
import androidx.compose.runtime.saveable.rememberSaveable
|
||||
import androidx.compose.runtime.setValue
|
||||
import androidx.compose.runtime.staticCompositionLocalOf
|
||||
import androidx.compose.runtime.withFrameNanos
|
||||
import androidx.lifecycle.Lifecycle
|
||||
import androidx.lifecycle.LifecycleEventObserver
|
||||
import androidx.lifecycle.compose.LocalLifecycleOwner
|
||||
@@ -133,6 +134,8 @@ import com.hermesandroid.relay.data.CandidateBuild
|
||||
import com.hermesandroid.relay.data.Connection
|
||||
import com.hermesandroid.relay.data.EndpointCandidate
|
||||
import com.hermesandroid.relay.data.FeatureFlags
|
||||
import com.hermesandroid.relay.data.SupervisedModePolicy
|
||||
import com.hermesandroid.relay.data.SupervisedModeStore
|
||||
import com.hermesandroid.relay.data.VoicePresentationMode
|
||||
import com.hermesandroid.relay.data.capabilities
|
||||
import com.hermesandroid.relay.data.displayLabel
|
||||
@@ -144,6 +147,7 @@ import com.hermesandroid.relay.util.HumanError
|
||||
import kotlinx.coroutines.delay
|
||||
import com.hermesandroid.relay.ui.onboarding.OnboardingScreen
|
||||
import com.hermesandroid.relay.ui.screens.AboutScreen
|
||||
import com.hermesandroid.relay.ui.screens.AdvancedSettingsScreen
|
||||
import com.hermesandroid.relay.ui.screens.AnalyticsScreen
|
||||
import com.hermesandroid.relay.ui.screens.AppearanceSettingsScreen
|
||||
import com.hermesandroid.relay.ui.screens.CustomThemeScreen
|
||||
@@ -170,9 +174,13 @@ import com.hermesandroid.relay.ui.screens.PermissionsStatusScreen
|
||||
import com.hermesandroid.relay.ui.screens.ProfileInspectorScreen
|
||||
import com.hermesandroid.relay.ui.screens.RealtimeVoiceTestScreen
|
||||
import com.hermesandroid.relay.ui.screens.SettingsScreen
|
||||
import com.hermesandroid.relay.ui.screens.SupervisedControlsScreen
|
||||
import com.hermesandroid.relay.ui.screens.SupervisedAppearanceSettingsScreen
|
||||
import com.hermesandroid.relay.ui.screens.UsageLimitsScreen
|
||||
import com.hermesandroid.relay.ui.screens.PluginsScreen
|
||||
import com.hermesandroid.relay.ui.screens.PluginPageScreen
|
||||
import com.hermesandroid.relay.ui.screens.GitStateScreen
|
||||
import com.hermesandroid.relay.viewmodel.GitStateViewModel
|
||||
import com.hermesandroid.relay.ui.screens.TerminalScreen
|
||||
import com.hermesandroid.relay.ui.screens.NotificationCompanionSettingsScreen
|
||||
import com.hermesandroid.relay.ui.screens.ProactiveSettingsScreen
|
||||
@@ -192,7 +200,9 @@ import com.hermesandroid.relay.viewmodel.ChatTransportPath
|
||||
import com.hermesandroid.relay.viewmodel.ChatTransportReadiness
|
||||
import com.hermesandroid.relay.viewmodel.ChatViewModel
|
||||
import com.hermesandroid.relay.viewmodel.ConnectionViewModel
|
||||
import com.hermesandroid.relay.plugins.runtime.PLUGIN_API_WRITE_CAPABILITY
|
||||
import com.hermesandroid.relay.viewmodel.PluginsViewModel
|
||||
import com.hermesandroid.relay.viewmodel.PluginsHubState
|
||||
import com.hermesandroid.relay.viewmodel.ProfileInspectorViewModel
|
||||
import com.hermesandroid.relay.viewmodel.TerminalViewModel
|
||||
import com.hermesandroid.relay.viewmodel.VoiceViewModel
|
||||
@@ -440,6 +450,7 @@ sealed class Screen(
|
||||
}
|
||||
data object Settings : Screen("settings", "Settings", Icons.Filled.Settings)
|
||||
data object Plugins : Screen("plugins", "Plugins", Icons.Filled.Extension)
|
||||
data object GitState : Screen("git_state", "Git", Icons.Filled.Code)
|
||||
data object PluginPage : Screen(
|
||||
"plugins/{pluginId}/pages/{pageId}",
|
||||
"Plugin",
|
||||
@@ -531,6 +542,17 @@ sealed class Screen(
|
||||
// the plural `ConnectionsSettings` subpage. See `ConnectionsSettings`
|
||||
// above for the surviving route.)
|
||||
data object ChatSettings : Screen("settings/chat", "Chat", Icons.Filled.Settings)
|
||||
data object AdvancedSettings : Screen("settings/advanced", "Advanced", Icons.Filled.Settings)
|
||||
data object SupervisedAppearanceSettings : Screen(
|
||||
"settings/supervised/appearance",
|
||||
"Appearance",
|
||||
Icons.Filled.Settings,
|
||||
)
|
||||
data object SupervisedControls : Screen(
|
||||
"settings/supervised",
|
||||
"Supervised mode",
|
||||
Icons.Filled.Settings,
|
||||
)
|
||||
data object ProviderUsage : Screen("settings/usage", "Usage & limits", Icons.Filled.Settings)
|
||||
data object MediaSettings : Screen("settings/media", "Media", Icons.Filled.Settings)
|
||||
data object AppearanceSettings : Screen("settings/appearance", "Appearance", Icons.Filled.Settings)
|
||||
@@ -589,6 +611,24 @@ sealed class Screen(
|
||||
}
|
||||
}
|
||||
|
||||
@Composable
|
||||
private fun SupervisedStartupLoadingScreen() {
|
||||
HermesRelayTheme(themePreference = "dark") {
|
||||
Box(
|
||||
modifier = Modifier
|
||||
.fillMaxSize()
|
||||
.background(MaterialTheme.colorScheme.background),
|
||||
contentAlignment = Alignment.Center,
|
||||
) {
|
||||
Text(
|
||||
text = "Loading protected settings…",
|
||||
style = MaterialTheme.typography.bodyMedium,
|
||||
color = MaterialTheme.colorScheme.onSurfaceVariant,
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@Composable
|
||||
fun RelayApp() {
|
||||
val applicationContext = LocalContext.current.applicationContext
|
||||
@@ -597,13 +637,17 @@ fun RelayApp() {
|
||||
val chatViewModel: ChatViewModel = processRuntime.chatViewModel
|
||||
val terminalViewModel: TerminalViewModel = viewModel()
|
||||
val pluginsViewModel: PluginsViewModel = viewModel()
|
||||
val gitStateViewModel: GitStateViewModel = viewModel()
|
||||
val voiceViewModel: VoiceViewModel = processRuntime.voiceViewModel
|
||||
val runtimeInitializationState by processRuntime.initializationState.collectAsState()
|
||||
|
||||
LaunchedEffect(processRuntime) {
|
||||
processRuntime.ensureInitialized()
|
||||
}
|
||||
if (runtimeInitializationState != HermesRuntimeInitializationState.Ready) return
|
||||
if (runtimeInitializationState != HermesRuntimeInitializationState.Ready) {
|
||||
SupervisedStartupLoadingScreen()
|
||||
return
|
||||
}
|
||||
|
||||
val voiceClient: RelayVoiceClient = processRuntime.relayVoiceClient
|
||||
val voicePreferences = processRuntime.voicePreferences
|
||||
@@ -700,6 +744,72 @@ fun RelayApp() {
|
||||
val profileSelectionSettled by connectionViewModel.profileSelectionSettled.collectAsState()
|
||||
val agentProfiles by connectionViewModel.agentProfiles.collectAsState()
|
||||
val activeConnectionId by connectionViewModel.activeConnectionId.collectAsState()
|
||||
val connectionStoreHydrated by
|
||||
connectionViewModel.connectionStore.isHydrated.collectAsState()
|
||||
val supervisedModeStore = remember(applicationContext) {
|
||||
SupervisedModeStore(applicationContext)
|
||||
}
|
||||
val supervisedPolicyState = produceState<Pair<String?, SupervisedModePolicy>?>(
|
||||
initialValue = null,
|
||||
key1 = activeConnectionId,
|
||||
key2 = supervisedModeStore,
|
||||
) {
|
||||
val connectionId = activeConnectionId
|
||||
if (connectionId == null) {
|
||||
value = null to SupervisedModePolicy()
|
||||
} else {
|
||||
supervisedModeStore.policyFlow(connectionId).collect { policy ->
|
||||
value = connectionId to policy
|
||||
}
|
||||
}
|
||||
}
|
||||
val ownedSupervisedPolicyState = supervisedPolicyState.value
|
||||
?.takeIf { (ownerConnectionId, _) -> ownerConnectionId == activeConnectionId }
|
||||
// Fail closed across process restoration. activeConnectionId starts as
|
||||
// null while ConnectionStore reads DataStore, so null alone cannot prove
|
||||
// this is a fresh install with no supervised policy to restore.
|
||||
if (!isRelayNavigationHydrated(
|
||||
connectionStoreHydrated = connectionStoreHydrated,
|
||||
activeConnectionId = activeConnectionId,
|
||||
supervisedPolicyHydrated = ownedSupervisedPolicyState != null,
|
||||
)
|
||||
) {
|
||||
SupervisedStartupLoadingScreen()
|
||||
return
|
||||
}
|
||||
val supervisedPolicy = ownedSupervisedPolicyState?.second ?: SupervisedModePolicy()
|
||||
val supervisedPinnedProfile = supervisedPolicy.pinnedProfileName?.let { name ->
|
||||
agentProfiles.firstOrNull { it.name.equals(name, ignoreCase = true) }
|
||||
}
|
||||
val supervisedProfileConfirmed = !supervisedPolicy.enabled || (
|
||||
profileSelectionSettled &&
|
||||
supervisedPinnedProfile != null &&
|
||||
selectedProfile?.name.equals(supervisedPinnedProfile.name, ignoreCase = true)
|
||||
)
|
||||
val chatSupervisedPolicy = if (supervisedPolicy.enabled && !supervisedProfileConfirmed) {
|
||||
supervisedPolicy.copy(pinnedProfileName = null)
|
||||
} else supervisedPolicy
|
||||
var parentAccessUnlocked by remember(activeConnectionId) { mutableStateOf(false) }
|
||||
|
||||
LaunchedEffect(
|
||||
activeConnectionId,
|
||||
supervisedPolicy,
|
||||
agentProfiles,
|
||||
selectedProfile,
|
||||
profileSelectionSettled,
|
||||
) {
|
||||
chatViewModel.updateSupervisedModePolicy(chatSupervisedPolicy)
|
||||
connectionViewModel.authManager.updateSupervisedMode(chatSupervisedPolicy)
|
||||
if (!supervisedPolicy.enabled) {
|
||||
parentAccessUnlocked = false
|
||||
return@LaunchedEffect
|
||||
}
|
||||
val pinned = supervisedPinnedProfile ?: return@LaunchedEffect
|
||||
if (!selectedProfile?.name.equals(pinned.name, ignoreCase = true)) {
|
||||
connectionViewModel.selectProfile(pinned)
|
||||
chatViewModel.activateGatewayProfile(pinned)
|
||||
}
|
||||
}
|
||||
val connections by connectionViewModel.connections.collectAsState()
|
||||
|
||||
val standardVoiceAvailability by connectionViewModel.standardVoiceAvailability.collectAsState()
|
||||
@@ -746,6 +856,11 @@ fun RelayApp() {
|
||||
val serverCapabilities by connectionViewModel.serverCapabilities.collectAsState()
|
||||
val gatewayAvailability by connectionViewModel.gatewayAvailability.collectAsState()
|
||||
val effectiveDashboardUrl by connectionViewModel.effectiveDashboardUrl.collectAsState()
|
||||
val gitOwnerKey = activeConnectionId?.takeIf { it.isNotBlank() }?.let { connectionId ->
|
||||
effectiveDashboardUrl.takeIf { it.isNotBlank() }?.let { dashboardUrl ->
|
||||
"$connectionId\u0000${effectiveSessionProfileName.orEmpty()}\u0000$dashboardUrl"
|
||||
}
|
||||
}
|
||||
LaunchedEffect(
|
||||
activeConnectionId,
|
||||
effectiveDashboardUrl,
|
||||
@@ -760,6 +875,28 @@ fun RelayApp() {
|
||||
sessionId = currentChatSessionId,
|
||||
)
|
||||
}
|
||||
LaunchedEffect(gitOwnerKey) {
|
||||
val dashboard = effectiveDashboardUrl
|
||||
.takeIf { it.isNotBlank() }
|
||||
?.let { connectionViewModel.dashboardClientForActive(it) }
|
||||
gitStateViewModel.configure(dashboard, gitOwnerKey)
|
||||
}
|
||||
|
||||
// Mirror the plugin.api.write grant into the Git view model so write
|
||||
// mutations are refused client-side until the user grants write access
|
||||
// (matches the plug-in's grant gating in PluginsViewModel).
|
||||
val pluginsHubState by pluginsViewModel.hubState.collectAsState()
|
||||
LaunchedEffect(pluginsHubState, gitOwnerKey) {
|
||||
val ready = pluginsHubState as? PluginsHubState.Ready
|
||||
val granted = ready
|
||||
?.takeIf { it.ownerKey == gitOwnerKey }
|
||||
?.plugins
|
||||
?.firstOrNull { it.catalog.id == "hermes-relay" }
|
||||
?.preferences
|
||||
?.grants
|
||||
?.contains(PLUGIN_API_WRITE_CAPABILITY) == true
|
||||
gitStateViewModel.setWriteGrant(gitOwnerKey, granted)
|
||||
}
|
||||
|
||||
// What's New auto-show
|
||||
val showWhatsNew by connectionViewModel.showWhatsNew.collectAsState()
|
||||
@@ -784,6 +921,22 @@ fun RelayApp() {
|
||||
val appearanceAccent by connectionViewModel.appearanceAccent.collectAsState()
|
||||
val appearanceShape by connectionViewModel.appearanceShape.collectAsState()
|
||||
val activeCustomTheme by connectionViewModel.activeCustomTheme.collectAsState()
|
||||
val navController = rememberNavController()
|
||||
val navBackStackEntry by navController.currentBackStackEntryAsState()
|
||||
val currentRoute = navBackStackEntry?.destination?.route
|
||||
val parentAccessForCurrentRoute = parentAccessUnlocked &&
|
||||
!shouldRelockParentAccess(
|
||||
supervisedEnabled = supervisedPolicy.enabled,
|
||||
parentAccessUnlocked = parentAccessUnlocked,
|
||||
route = currentRoute,
|
||||
)
|
||||
val resolvedTheme = resolveSupervisedTheme(
|
||||
policy = supervisedPolicy,
|
||||
parentAccessUnlocked = parentAccessForCurrentRoute,
|
||||
globalAppThemeId = appThemeId,
|
||||
globalThemePreference = themePreference,
|
||||
)
|
||||
val supervisedAppearanceLocked = supervisedPolicy.enabled && !parentAccessForCurrentRoute
|
||||
|
||||
// Resolve the active sphere skin (built-in / adaptive / user-loaded) and
|
||||
// publish it + the full available set so every MorphingSphere picks it up
|
||||
@@ -800,10 +953,10 @@ fun RelayApp() {
|
||||
value = SphereRegistry.builtIns +
|
||||
withContext(Dispatchers.IO) { SphereSkinLoader.loadUserSkins(sphereContext) }
|
||||
}
|
||||
val activeSphereSkin = remember(sphereSkinId, appThemeId, availableSphereSkins) {
|
||||
val activeSphereSkin = remember(sphereSkinId, resolvedTheme.appThemeId, availableSphereSkins) {
|
||||
SphereRegistry.resolve(
|
||||
selectedId = sphereSkinId,
|
||||
themeDefaultSkinId = AppThemes.byId(appThemeId).defaultSphereSkinId,
|
||||
themeDefaultSkinId = AppThemes.byId(resolvedTheme.appThemeId).defaultSphereSkinId,
|
||||
available = availableSphereSkins,
|
||||
)
|
||||
}
|
||||
@@ -938,37 +1091,19 @@ fun RelayApp() {
|
||||
),
|
||||
)
|
||||
HermesRelayTheme(
|
||||
appThemeId = appThemeId,
|
||||
themePreference = themePreference,
|
||||
appThemeId = resolvedTheme.appThemeId,
|
||||
themePreference = resolvedTheme.themePreference,
|
||||
fontScale = fontScale,
|
||||
appFontId = appFontId,
|
||||
accentHex = appearanceAccent,
|
||||
accentHex = appearanceAccent.takeIf { resolvedTheme.useGlobalCustomTheme },
|
||||
shapeId = appearanceShape,
|
||||
customTheme = activeCustomTheme,
|
||||
customTheme = activeCustomTheme.takeIf { resolvedTheme.useGlobalCustomTheme },
|
||||
) {
|
||||
// Surface a crash report from a previous session, if any. Renders a
|
||||
// platform Dialog (own window) so tree position is z-order-agnostic;
|
||||
// it just needs to be inside the theme for Material colors.
|
||||
CrashReportGate()
|
||||
|
||||
val navController = rememberNavController()
|
||||
|
||||
// === PHASE3-safety-rails-followup: cross-layer deep-link nav ===
|
||||
// Collect navigation requests posted by external launchers (e.g., the
|
||||
// BridgeForegroundService notification's "Settings" action). The
|
||||
// service sets EXTRA_NAV_ROUTE on its launch intent → MainActivity's
|
||||
// onCreate / onNewIntent reads it and pumps it onto NavRouteRequest →
|
||||
// we forward each emission to the NavController. Single observer at
|
||||
// the app root so every screen benefits.
|
||||
LaunchedEffect(navController) {
|
||||
com.hermesandroid.relay.util.NavRouteRequest.requests.collect { route ->
|
||||
navController.navigate(route) {
|
||||
launchSingleTop = true
|
||||
}
|
||||
}
|
||||
}
|
||||
// === END PHASE3-safety-rails-followup ===
|
||||
|
||||
// Wire the proactive "session" surfacing once: a message with
|
||||
// surfacing="session" is injected into the active chat conversation.
|
||||
// ChatViewModel isn't available where ConnectionViewModel builds the
|
||||
@@ -1039,8 +1174,74 @@ fun RelayApp() {
|
||||
// restart cleanly lands back in setup.
|
||||
val isDemoMode by connectionViewModel.isDemoMode.collectAsState()
|
||||
|
||||
val navBackStackEntry by navController.currentBackStackEntryAsState()
|
||||
val currentRoute = navBackStackEntry?.destination?.route
|
||||
// The unlock remains useful while moving between parent-only settings,
|
||||
// but never follows an enrolled device user back into supervised chat.
|
||||
// Cross-layer requests (notifications, services, deep links) use the
|
||||
// route-scoped unlock. As soon as Chat is current, the parent grant is
|
||||
// ineffective even before the state-clearing effect runs.
|
||||
LaunchedEffect(
|
||||
navController,
|
||||
supervisedPolicy.enabled,
|
||||
parentAccessForCurrentRoute,
|
||||
) {
|
||||
com.hermesandroid.relay.util.NavRouteRequest.requests.collect { route ->
|
||||
if (
|
||||
supervisedPolicy.enabled &&
|
||||
!isSupervisedRouteAllowed(route, parentAccessForCurrentRoute)
|
||||
) return@collect
|
||||
navController.navigate(route) {
|
||||
launchSingleTop = true
|
||||
}
|
||||
}
|
||||
}
|
||||
LaunchedEffect(
|
||||
supervisedPolicy.enabled,
|
||||
parentAccessForCurrentRoute,
|
||||
currentRoute,
|
||||
) {
|
||||
val redirect = shouldRedirectSupervisedRoute(
|
||||
supervisedEnabled = supervisedPolicy.enabled,
|
||||
parentAccessUnlocked = parentAccessForCurrentRoute,
|
||||
currentRoute = currentRoute,
|
||||
)
|
||||
if (redirect) {
|
||||
navController.navigate(Screen.Chat.route(openAgentSheet = false)) {
|
||||
popUpTo(navController.graph.findStartDestination().id) { inclusive = false }
|
||||
launchSingleTop = true
|
||||
}
|
||||
}
|
||||
}
|
||||
LaunchedEffect(supervisedPolicy.enabled, parentAccessUnlocked, currentRoute) {
|
||||
if (shouldRelockParentAccess(supervisedPolicy.enabled, parentAccessUnlocked, currentRoute)) {
|
||||
// Route-scoped authority is already false on Chat. Let Navigation
|
||||
// finish committing the new destination before clearing the raw
|
||||
// parent grant, otherwise the same-frame root recomposition can
|
||||
// leave a themed but contentless surface.
|
||||
withFrameNanos { }
|
||||
withFrameNanos { }
|
||||
parentAccessUnlocked = false
|
||||
}
|
||||
}
|
||||
LaunchedEffect(parentAccessUnlocked, supervisedPolicy.parentAccess.timeoutMinutes) {
|
||||
if (parentAccessUnlocked) {
|
||||
delay(supervisedPolicy.parentAccess.timeoutMinutes * 60_000L)
|
||||
parentAccessUnlocked = false
|
||||
}
|
||||
}
|
||||
DisposableEffect(lifecycleOwner, supervisedPolicy.enabled, parentAccessUnlocked) {
|
||||
val relockObserver = LifecycleEventObserver { _, event ->
|
||||
if (
|
||||
event == Lifecycle.Event.ON_PAUSE &&
|
||||
supervisedPolicy.enabled &&
|
||||
parentAccessUnlocked &&
|
||||
supervisedPolicy.parentAccess.relockOnBackground
|
||||
) {
|
||||
parentAccessUnlocked = false
|
||||
}
|
||||
}
|
||||
lifecycleOwner.lifecycle.addObserver(relockObserver)
|
||||
onDispose { lifecycleOwner.lifecycle.removeObserver(relockObserver) }
|
||||
}
|
||||
val suppressGlobalChrome = shouldSuppressGlobalChrome(
|
||||
onboardingCompleted = onboardingCompleted,
|
||||
isDemoMode = isDemoMode,
|
||||
@@ -1719,6 +1920,8 @@ fun RelayApp() {
|
||||
!suppressGlobalChrome &&
|
||||
!isKeyboardVisible &&
|
||||
!showStartupSphere &&
|
||||
(!supervisedPolicy.enabled ||
|
||||
supervisedPolicy.visibility.resolved().showTechnicalRoute) &&
|
||||
shouldShowConnectionFooter(voiceUiState.voiceMode, voicePresentationMode)
|
||||
) {
|
||||
val footerRoute = resolveFooterRouteCandidate(
|
||||
@@ -1793,12 +1996,16 @@ fun RelayApp() {
|
||||
.fillMaxSize()
|
||||
.padding(innerPadding),
|
||||
) {
|
||||
val routeContentAllowed = isSupervisedRouteContentAllowed(
|
||||
supervisedEnabled = supervisedPolicy.enabled,
|
||||
parentAccessUnlocked = parentAccessForCurrentRoute,
|
||||
currentRoute = currentRoute,
|
||||
)
|
||||
Box(modifier = Modifier.fillMaxSize()) {
|
||||
NavHost(
|
||||
navController = navController,
|
||||
startDestination = startDestination,
|
||||
modifier = Modifier
|
||||
.fillMaxWidth()
|
||||
.weight(1f),
|
||||
modifier = Modifier.fillMaxSize(),
|
||||
) {
|
||||
composable(Screen.Onboarding.route) {
|
||||
// The wizard inside OnboardingScreen now owns credential
|
||||
@@ -1885,15 +2092,43 @@ fun RelayApp() {
|
||||
// sheet.
|
||||
val openAgentSheetArg = backStackEntry.arguments
|
||||
?.getBoolean(Screen.Chat.ARG_OPEN_AGENT_SHEET, false) == true
|
||||
val requestedSessionId = backStackEntry.arguments
|
||||
val rawRequestedSessionId = backStackEntry.arguments
|
||||
?.getString(Screen.Chat.ARG_SESSION_ID)
|
||||
?.takeIf { it.isNotBlank() }
|
||||
val requestedProfileRoute = backStackEntry.arguments
|
||||
val rawRequestedProfileRoute = backStackEntry.arguments
|
||||
?.getString(Screen.Chat.ARG_PROFILE)
|
||||
?.takeIf { it.isNotBlank() }
|
||||
val requestedProactiveChatId = backStackEntry.arguments
|
||||
val rawRequestedProactiveChatId = backStackEntry.arguments
|
||||
?.getString(Screen.Chat.ARG_PROACTIVE_CHAT_ID)
|
||||
?.takeIf { it.isNotBlank() }
|
||||
// Nav/deep-link arguments are not ownership evidence. The
|
||||
// supervised drawer uses profile-scoped session rows
|
||||
// directly; external args stay discarded until an
|
||||
// owner-aware source can explicitly prove the binding.
|
||||
val sanitizedRouteArgs = sanitizeSupervisedChatRouteArgs(
|
||||
policy = supervisedPolicy,
|
||||
args = SupervisedChatRouteArgs(
|
||||
sessionId = rawRequestedSessionId,
|
||||
profile = rawRequestedProfileRoute,
|
||||
proactiveChatId = rawRequestedProactiveChatId,
|
||||
),
|
||||
pinnedProfileOwnershipProven = false,
|
||||
)
|
||||
val requestedSessionId = sanitizedRouteArgs.sessionId
|
||||
val requestedProfileRoute = sanitizedRouteArgs.profile
|
||||
val requestedProactiveChatId = sanitizedRouteArgs.proactiveChatId
|
||||
LaunchedEffect(
|
||||
supervisedPolicy.enabled,
|
||||
rawRequestedSessionId,
|
||||
rawRequestedProfileRoute,
|
||||
rawRequestedProactiveChatId,
|
||||
) {
|
||||
if (supervisedPolicy.enabled) {
|
||||
backStackEntry.arguments?.putString(Screen.Chat.ARG_SESSION_ID, null)
|
||||
backStackEntry.arguments?.putString(Screen.Chat.ARG_PROFILE, null)
|
||||
backStackEntry.arguments?.putString(Screen.Chat.ARG_PROACTIVE_CHAT_ID, null)
|
||||
}
|
||||
}
|
||||
val proactiveInboxEntries by connectionViewModel.inboxMessages.collectAsState()
|
||||
val phoneThreadChatIds by connectionViewModel.phoneThreadChatIds.collectAsState()
|
||||
LaunchedEffect(
|
||||
@@ -2056,6 +2291,7 @@ fun RelayApp() {
|
||||
launchSingleTop = true
|
||||
}
|
||||
},
|
||||
supervisedPolicy = chatSupervisedPolicy,
|
||||
onNavigateToBotMode = {
|
||||
navController.navigate(Screen.BotMode.route) { launchSingleTop = true }
|
||||
},
|
||||
@@ -2376,6 +2612,25 @@ fun RelayApp() {
|
||||
SettingsScreen(
|
||||
connectionViewModel = connectionViewModel,
|
||||
chatViewModel = chatViewModel,
|
||||
supervisedPolicy = supervisedPolicy,
|
||||
parentAccessUnlocked = parentAccessForCurrentRoute,
|
||||
onRequestParentAccess = { parentAccessUnlocked = true },
|
||||
onUpdateSupervisedPolicy = { policy ->
|
||||
activeConnectionId?.let { connectionId ->
|
||||
connectionSwitchScope.launch {
|
||||
supervisedModeStore.setPolicy(connectionId, policy)
|
||||
}
|
||||
}
|
||||
},
|
||||
onNavigateToAdvancedSettings = {
|
||||
navController.navigate(Screen.AdvancedSettings.route)
|
||||
},
|
||||
onNavigateToSupervisedAppearance = {
|
||||
navController.navigate(Screen.SupervisedAppearanceSettings.route)
|
||||
},
|
||||
onNavigateToSupervisedControls = {
|
||||
navController.navigate(Screen.SupervisedControls.route)
|
||||
},
|
||||
onBack = { navController.popBackStack() },
|
||||
// (The `onNavigateToChatWithAgentSheet` callback that
|
||||
// used to live here was removed 2026-04-21. Tapping
|
||||
@@ -2450,6 +2705,62 @@ fun RelayApp() {
|
||||
},
|
||||
)
|
||||
}
|
||||
composable(Screen.AdvancedSettings.route) {
|
||||
if (!parentAccessForCurrentRoute && supervisedPolicy.enabled) {
|
||||
LaunchedEffect(Unit) { navController.popBackStack() }
|
||||
} else {
|
||||
AdvancedSettingsScreen(
|
||||
supervisedPolicy = supervisedPolicy,
|
||||
onNavigateToSupervisedControls = {
|
||||
navController.navigate(Screen.SupervisedControls.route)
|
||||
},
|
||||
onBack = { navController.popBackStack() },
|
||||
)
|
||||
}
|
||||
}
|
||||
composable(Screen.SupervisedAppearanceSettings.route) {
|
||||
if (!supervisedPolicy.enabled && !parentAccessForCurrentRoute) {
|
||||
LaunchedEffect(Unit) { navController.popBackStack() }
|
||||
} else {
|
||||
SupervisedAppearanceSettingsScreen(
|
||||
connectionViewModel = connectionViewModel,
|
||||
policy = supervisedPolicy,
|
||||
onPolicyChange = { policy ->
|
||||
activeConnectionId?.let { connectionId ->
|
||||
connectionSwitchScope.launch {
|
||||
supervisedModeStore.setPolicy(connectionId, policy)
|
||||
}
|
||||
}
|
||||
},
|
||||
onBack = { navController.popBackStack() },
|
||||
)
|
||||
}
|
||||
}
|
||||
composable(Screen.SupervisedControls.route) {
|
||||
if (!parentAccessForCurrentRoute && supervisedPolicy.enabled) {
|
||||
LaunchedEffect(Unit) { navController.popBackStack() }
|
||||
} else {
|
||||
SupervisedControlsScreen(
|
||||
connectionViewModel = connectionViewModel,
|
||||
policy = supervisedPolicy,
|
||||
profiles = agentProfiles.filterNot { it.isDefault },
|
||||
onPolicyChange = { policy ->
|
||||
activeConnectionId?.let { connectionId ->
|
||||
connectionSwitchScope.launch {
|
||||
supervisedModeStore.setPolicy(connectionId, policy)
|
||||
}
|
||||
}
|
||||
},
|
||||
onBack = { navController.popBackStack() },
|
||||
onReturnToSupervisedView = {
|
||||
navController.navigate(Screen.Chat.route(openAgentSheet = false)) {
|
||||
popUpTo(Screen.Chat.route) { inclusive = false }
|
||||
launchSingleTop = true
|
||||
}
|
||||
},
|
||||
)
|
||||
}
|
||||
}
|
||||
composable(Screen.ProviderUsage.route) {
|
||||
UsageLimitsScreen(
|
||||
connectionViewModel = connectionViewModel,
|
||||
@@ -2462,10 +2773,20 @@ fun RelayApp() {
|
||||
viewModel = pluginsViewModel,
|
||||
onBack = { navController.popBackStack() },
|
||||
onOpenPage = { pluginId, pageId ->
|
||||
navController.navigate(Screen.PluginPage.route(pluginId, pageId))
|
||||
if (pluginId == "hermes-relay" && pageId == "git") {
|
||||
navController.navigate(Screen.GitState.route)
|
||||
} else {
|
||||
navController.navigate(Screen.PluginPage.route(pluginId, pageId))
|
||||
}
|
||||
},
|
||||
)
|
||||
}
|
||||
composable(Screen.GitState.route) {
|
||||
GitStateScreen(
|
||||
viewModel = gitStateViewModel,
|
||||
onBack = { navController.popBackStack() },
|
||||
)
|
||||
}
|
||||
composable(
|
||||
route = Screen.PluginPage.route,
|
||||
arguments = listOf(
|
||||
@@ -2927,7 +3248,8 @@ fun RelayApp() {
|
||||
composable(Screen.About.route) {
|
||||
AboutScreen(
|
||||
connectionViewModel = connectionViewModel,
|
||||
onBack = { navController.popBackStack() }
|
||||
onBack = { navController.popBackStack() },
|
||||
allowDeveloperUnlock = !supervisedPolicy.enabled || parentAccessForCurrentRoute,
|
||||
)
|
||||
}
|
||||
composable(
|
||||
@@ -3030,6 +3352,12 @@ fun RelayApp() {
|
||||
)
|
||||
}
|
||||
}
|
||||
if (!routeContentAllowed) {
|
||||
// Keep the graph mounted so the redirect can complete, but
|
||||
// cover restored parent-only content with an opaque fail-closed surface.
|
||||
SupervisedStartupLoadingScreen()
|
||||
}
|
||||
}
|
||||
} // end bridge-return wrapper column
|
||||
} // end CompositionLocalProvider
|
||||
}
|
||||
@@ -3042,6 +3370,7 @@ fun RelayApp() {
|
||||
val petSurfaceOwner = petSurfaceOwnerForRoute(currentRoute)
|
||||
val petActivity = petCompanionCoordinator.activityFor(petSurfaceOwner)
|
||||
val showFloatingPet = activeFloatingPet != null &&
|
||||
shouldShowPetInSupervisedMode(supervisedPolicy, parentAccessForCurrentRoute) &&
|
||||
floatingPetAllowedOnRoute(currentRoute) &&
|
||||
!petActivity.hidden &&
|
||||
!suppressGlobalChrome &&
|
||||
@@ -3072,6 +3401,7 @@ fun RelayApp() {
|
||||
),
|
||||
animationEnabled = animationEnabled,
|
||||
appForeground = appIsForeground,
|
||||
interactive = !supervisedAppearanceLocked,
|
||||
route = roamingRoute,
|
||||
visitRequest = petCompanionCoordinator.pendingVisitRequest,
|
||||
onVisitRequestConsumed = petCompanionCoordinator::clearVisitRequest,
|
||||
|
||||
@@ -0,0 +1,34 @@
|
||||
package com.hermesandroid.relay.ui
|
||||
|
||||
import com.hermesandroid.relay.data.SupervisedModePolicy
|
||||
|
||||
internal data class ResolvedSupervisedTheme(
|
||||
val appThemeId: String,
|
||||
val themePreference: String,
|
||||
val useGlobalCustomTheme: Boolean,
|
||||
)
|
||||
|
||||
/** Keep the supervised palette isolated from the parent's ordinary app theme. */
|
||||
internal fun resolveSupervisedTheme(
|
||||
policy: SupervisedModePolicy,
|
||||
parentAccessUnlocked: Boolean,
|
||||
globalAppThemeId: String,
|
||||
globalThemePreference: String,
|
||||
): ResolvedSupervisedTheme = if (policy.enabled && !parentAccessUnlocked) {
|
||||
ResolvedSupervisedTheme(
|
||||
appThemeId = policy.appearance.appThemeId,
|
||||
themePreference = policy.appearance.themePreference,
|
||||
useGlobalCustomTheme = false,
|
||||
)
|
||||
} else {
|
||||
ResolvedSupervisedTheme(
|
||||
appThemeId = globalAppThemeId,
|
||||
themePreference = globalThemePreference,
|
||||
useGlobalCustomTheme = true,
|
||||
)
|
||||
}
|
||||
|
||||
internal fun shouldShowPetInSupervisedMode(
|
||||
policy: SupervisedModePolicy,
|
||||
parentAccessUnlocked: Boolean,
|
||||
): Boolean = !policy.enabled || parentAccessUnlocked || policy.appearance.showPet
|
||||
@@ -0,0 +1,107 @@
|
||||
package com.hermesandroid.relay.ui
|
||||
|
||||
import com.hermesandroid.relay.data.ConnectionStore
|
||||
import com.hermesandroid.relay.data.SupervisedModePolicy
|
||||
|
||||
/** Allowlist applied to external, deep-link, and programmatic navigation. */
|
||||
internal fun isSupervisedRouteAllowed(route: String?, parentAccessUnlocked: Boolean): Boolean {
|
||||
if (parentAccessUnlocked) return true
|
||||
val normalized = route?.substringBefore('?') ?: return false
|
||||
return normalized == "chat" ||
|
||||
normalized == Screen.Settings.route ||
|
||||
normalized == Screen.SupervisedAppearanceSettings.route
|
||||
}
|
||||
|
||||
/** Do not inspect or mutate a NavController until its first destination exists. */
|
||||
internal fun shouldRedirectSupervisedRoute(
|
||||
supervisedEnabled: Boolean,
|
||||
parentAccessUnlocked: Boolean,
|
||||
currentRoute: String?,
|
||||
): Boolean = currentRoute != null &&
|
||||
supervisedEnabled &&
|
||||
!isSupervisedRouteAllowed(currentRoute, parentAccessUnlocked)
|
||||
|
||||
/** A null route is Navigation's pre-graph bootstrap state, not a forbidden destination. */
|
||||
internal fun isSupervisedRouteContentAllowed(
|
||||
supervisedEnabled: Boolean,
|
||||
parentAccessUnlocked: Boolean,
|
||||
currentRoute: String?,
|
||||
): Boolean = currentRoute == null ||
|
||||
!supervisedEnabled ||
|
||||
isSupervisedRouteAllowed(currentRoute, parentAccessUnlocked)
|
||||
|
||||
/**
|
||||
* Cold-start gate for the app navigation graph.
|
||||
*
|
||||
* A null active connection is also the seed value used while [ConnectionStore]
|
||||
* is reading DataStore. Callers must therefore wait for the store's explicit
|
||||
* hydration signal before treating null as "no connection" and composing the
|
||||
* unrestricted onboarding/settings graph.
|
||||
*/
|
||||
internal fun isRelayNavigationHydrated(
|
||||
connectionStoreHydrated: Boolean,
|
||||
activeConnectionId: String?,
|
||||
supervisedPolicyHydrated: Boolean,
|
||||
): Boolean = connectionStoreHydrated &&
|
||||
(activeConnectionId == null || supervisedPolicyHydrated)
|
||||
|
||||
/** A parent unlock never follows the user back into the supervised chat root. */
|
||||
internal fun shouldRelockParentAccess(
|
||||
supervisedEnabled: Boolean,
|
||||
parentAccessUnlocked: Boolean,
|
||||
route: String?,
|
||||
): Boolean = supervisedEnabled &&
|
||||
parentAccessUnlocked &&
|
||||
route?.substringBefore('?') == "chat"
|
||||
|
||||
/**
|
||||
* External chat route arguments are untrusted. A session may be restored only
|
||||
* after an owner-aware source has proved that it belongs to the pinned profile.
|
||||
*/
|
||||
internal fun mayRestoreSupervisedSessionRoute(
|
||||
policy: SupervisedModePolicy,
|
||||
requestedSessionId: String?,
|
||||
requestedProfile: String?,
|
||||
pinnedProfileOwnershipProven: Boolean,
|
||||
): Boolean = policy.isActive &&
|
||||
policy.capabilities.conversationHistory &&
|
||||
pinnedProfileOwnershipProven &&
|
||||
!requestedSessionId.isNullOrBlank() &&
|
||||
!requestedProfile.isNullOrBlank() &&
|
||||
requestedProfile.equals(policy.pinnedProfileName, ignoreCase = true)
|
||||
|
||||
internal data class SupervisedChatRouteArgs(
|
||||
val sessionId: String? = null,
|
||||
val profile: String? = null,
|
||||
val proactiveChatId: String? = null,
|
||||
)
|
||||
|
||||
/** Strip external chat targeting before any destination effect can dispatch it. */
|
||||
internal fun sanitizeSupervisedChatRouteArgs(
|
||||
policy: SupervisedModePolicy,
|
||||
args: SupervisedChatRouteArgs,
|
||||
pinnedProfileOwnershipProven: Boolean,
|
||||
): SupervisedChatRouteArgs {
|
||||
if (!policy.enabled) return args
|
||||
val allowSession = mayRestoreSupervisedSessionRoute(
|
||||
policy = policy,
|
||||
requestedSessionId = args.sessionId,
|
||||
requestedProfile = args.profile,
|
||||
pinnedProfileOwnershipProven = pinnedProfileOwnershipProven,
|
||||
)
|
||||
return if (allowSession) {
|
||||
args.copy(proactiveChatId = null)
|
||||
} else {
|
||||
SupervisedChatRouteArgs()
|
||||
}
|
||||
}
|
||||
|
||||
/** A disabled policy may become active only after an enrolled credential succeeds. */
|
||||
internal fun mayEnableSupervisedMode(
|
||||
policy: SupervisedModePolicy,
|
||||
deviceSecure: Boolean,
|
||||
deviceCredentialConfirmed: Boolean,
|
||||
): Boolean = !policy.enabled &&
|
||||
policy.isConfigured &&
|
||||
deviceSecure &&
|
||||
deviceCredentialConfirmed
|
||||
@@ -125,6 +125,7 @@ fun AttachmentGallery(
|
||||
if (attachments.size < 2) return
|
||||
|
||||
val context = LocalContext.current
|
||||
val exportAllowed = LocalImageExportAllowed.current
|
||||
val scope = rememberCoroutineScope()
|
||||
val blurMode = LocalMediaBlurMode.current
|
||||
val revealed = remember { mutableStateMapOf<String, Boolean>() }
|
||||
@@ -189,7 +190,7 @@ fun AttachmentGallery(
|
||||
)
|
||||
}
|
||||
|
||||
if (!blurred) {
|
||||
if (!blurred && exportAllowed) {
|
||||
SaveOverlayButton(
|
||||
onClick = {
|
||||
scope.launch { saveAttachment(context, attachment) }
|
||||
@@ -201,7 +202,7 @@ fun AttachmentGallery(
|
||||
}
|
||||
|
||||
AttachmentActionsMenu(
|
||||
expanded = menuExpanded,
|
||||
expanded = menuExpanded && exportAllowed,
|
||||
onDismiss = { menuExpanded = false },
|
||||
context = context,
|
||||
scope = scope,
|
||||
|
||||
@@ -312,6 +312,8 @@ fun AttachmentViewer(
|
||||
properties = DialogProperties(usePlatformDefaultWidth = false),
|
||||
) {
|
||||
val context = LocalContext.current
|
||||
val exportAllowed = LocalImageExportAllowed.current ||
|
||||
attachment.renderMode != AttachmentRenderMode.IMAGE
|
||||
AllowDeviceRotation()
|
||||
val scope = rememberCoroutineScope()
|
||||
var busy by remember { mutableStateOf(false) }
|
||||
@@ -405,6 +407,7 @@ fun AttachmentViewer(
|
||||
title = title,
|
||||
busy = busy,
|
||||
actionsEnabled = !blurred,
|
||||
exportAllowed = exportAllowed,
|
||||
onShare = onShare,
|
||||
onSave = onSave,
|
||||
onOpenExternal = onOpenExternal,
|
||||
@@ -448,6 +451,7 @@ internal fun AttachmentGalleryViewer(
|
||||
properties = DialogProperties(usePlatformDefaultWidth = false),
|
||||
) {
|
||||
val context = LocalContext.current
|
||||
val exportAllowed = LocalImageExportAllowed.current
|
||||
AllowDeviceRotation()
|
||||
val scope = rememberCoroutineScope()
|
||||
var busy by remember { mutableStateOf(false) }
|
||||
@@ -584,6 +588,7 @@ internal fun AttachmentGalleryViewer(
|
||||
title = toolbarTitle,
|
||||
busy = busy,
|
||||
actionsEnabled = !currentBlurred,
|
||||
exportAllowed = exportAllowed,
|
||||
onShare = onShare,
|
||||
onSave = onSave,
|
||||
onOpenExternal = onOpenExternal,
|
||||
@@ -613,6 +618,7 @@ private fun MediaViewerToolbar(
|
||||
title: String,
|
||||
busy: Boolean,
|
||||
actionsEnabled: Boolean = true,
|
||||
exportAllowed: Boolean = true,
|
||||
onShare: () -> Unit,
|
||||
onSave: () -> Unit,
|
||||
onOpenExternal: () -> Unit,
|
||||
@@ -653,11 +659,13 @@ private fun MediaViewerToolbar(
|
||||
) {
|
||||
Icon(Icons.Filled.OpenInNew, contentDescription = stringResource(R.string.attachment_open_externally_a11y))
|
||||
}
|
||||
IconButton(onClick = onShare, enabled = actionsEnabled && !busy, colors = tint) {
|
||||
Icon(Icons.Filled.Share, contentDescription = stringResource(R.string.attachment_share_a11y))
|
||||
}
|
||||
IconButton(onClick = onSave, enabled = actionsEnabled && !busy, colors = tint) {
|
||||
Icon(Icons.Filled.Download, contentDescription = stringResource(R.string.attachment_save_a11y))
|
||||
if (exportAllowed) {
|
||||
IconButton(onClick = onShare, enabled = actionsEnabled && !busy, colors = tint) {
|
||||
Icon(Icons.Filled.Share, contentDescription = stringResource(R.string.attachment_share_a11y))
|
||||
}
|
||||
IconButton(onClick = onSave, enabled = actionsEnabled && !busy, colors = tint) {
|
||||
Icon(Icons.Filled.Download, contentDescription = stringResource(R.string.attachment_save_a11y))
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -32,6 +32,7 @@ fun ChatFailurePanel(
|
||||
onDetails: () -> Unit,
|
||||
onRetry: () -> Unit,
|
||||
onDismiss: () -> Unit,
|
||||
showDetails: Boolean = true,
|
||||
modifier: Modifier = Modifier,
|
||||
) {
|
||||
Surface(
|
||||
@@ -72,8 +73,10 @@ fun ChatFailurePanel(
|
||||
horizontalArrangement = Arrangement.End,
|
||||
verticalAlignment = Alignment.CenterVertically,
|
||||
) {
|
||||
TextButton(onClick = onDetails) {
|
||||
Text(stringResource(R.string.chat_failure_details))
|
||||
if (showDetails) {
|
||||
TextButton(onClick = onDetails) {
|
||||
Text(stringResource(R.string.chat_failure_details))
|
||||
}
|
||||
}
|
||||
if (failure.recoverable) {
|
||||
TextButton(onClick = onRetry) {
|
||||
|
||||
@@ -27,6 +27,7 @@ import androidx.compose.runtime.mutableStateOf
|
||||
import androidx.compose.runtime.remember
|
||||
import androidx.compose.runtime.rememberCoroutineScope
|
||||
import androidx.compose.runtime.setValue
|
||||
import androidx.compose.runtime.staticCompositionLocalOf
|
||||
import androidx.compose.ui.Alignment
|
||||
import androidx.compose.ui.Modifier
|
||||
import androidx.compose.ui.graphics.Color
|
||||
@@ -42,6 +43,9 @@ import com.hermesandroid.relay.R
|
||||
import com.hermesandroid.relay.util.MediaSaver
|
||||
import kotlinx.coroutines.launch
|
||||
|
||||
/** Whether the current conversation policy permits copying image bytes out of the app. */
|
||||
val LocalImageExportAllowed = staticCompositionLocalOf { true }
|
||||
|
||||
/**
|
||||
* What the [ChatImageViewer] displays and how it obtains bytes for Save/Share.
|
||||
*
|
||||
@@ -104,6 +108,7 @@ fun ChatImageViewer(
|
||||
properties = DialogProperties(usePlatformDefaultWidth = false),
|
||||
) {
|
||||
val context = LocalContext.current
|
||||
val exportAllowed = LocalImageExportAllowed.current
|
||||
AllowDeviceRotation()
|
||||
val scope = rememberCoroutineScope()
|
||||
|
||||
@@ -156,60 +161,72 @@ fun ChatImageViewer(
|
||||
horizontalArrangement = Arrangement.spacedBy(4.dp),
|
||||
) {
|
||||
val tint = IconButtonDefaults.iconButtonColors(contentColor = Color.White)
|
||||
val cdShare = stringResource(R.string.cd_share)
|
||||
val cdSave = stringResource(R.string.cd_save)
|
||||
val cdClose = stringResource(R.string.cd_close_viewer)
|
||||
val errorMsg = context.getString(R.string.image_viewer_error)
|
||||
IconButton(
|
||||
onClick = {
|
||||
scope.launch {
|
||||
busy = true
|
||||
val bytes = runCatching { source.bytesProvider() }.getOrNull()
|
||||
busy = false
|
||||
if (bytes == null) {
|
||||
toast(context, errorMsg)
|
||||
return@launch
|
||||
}
|
||||
val uri = MediaSaver.stageForShare(context, bytes, source.displayName, source.mime)
|
||||
MediaSaver.share(context, uri, source.mime)
|
||||
}
|
||||
},
|
||||
colors = tint,
|
||||
) {
|
||||
Icon(Icons.Filled.Share, contentDescription = cdShare)
|
||||
}
|
||||
val savedFmt = context.getString(R.string.image_viewer_saved)
|
||||
val failedFmt = context.getString(R.string.image_viewer_failed)
|
||||
IconButton(
|
||||
onClick = {
|
||||
scope.launch {
|
||||
busy = true
|
||||
val bytes = runCatching { source.bytesProvider() }.getOrNull()
|
||||
if (bytes == null) {
|
||||
if (exportAllowed) {
|
||||
val cdShare = stringResource(R.string.cd_share)
|
||||
val cdSave = stringResource(R.string.cd_save)
|
||||
IconButton(
|
||||
onClick = {
|
||||
scope.launch {
|
||||
busy = true
|
||||
val bytes = runCatching { source.bytesProvider() }.getOrNull()
|
||||
busy = false
|
||||
toast(context, errorMsg)
|
||||
return@launch
|
||||
if (bytes == null) {
|
||||
toast(context, errorMsg)
|
||||
return@launch
|
||||
}
|
||||
val uri = MediaSaver.stageForShare(
|
||||
context,
|
||||
bytes,
|
||||
source.displayName,
|
||||
source.mime,
|
||||
)
|
||||
MediaSaver.share(context, uri, source.mime)
|
||||
}
|
||||
when (val result = MediaSaver.saveImage(context, bytes, source.displayName, source.mime)) {
|
||||
is MediaSaver.SaveResult.Saved -> {
|
||||
},
|
||||
colors = tint,
|
||||
) {
|
||||
Icon(Icons.Filled.Share, contentDescription = cdShare)
|
||||
}
|
||||
val savedFmt = context.getString(R.string.image_viewer_saved)
|
||||
val failedFmt = context.getString(R.string.image_viewer_failed)
|
||||
IconButton(
|
||||
onClick = {
|
||||
scope.launch {
|
||||
busy = true
|
||||
val bytes = runCatching { source.bytesProvider() }.getOrNull()
|
||||
if (bytes == null) {
|
||||
busy = false
|
||||
toast(context, savedFmt.format(result.location))
|
||||
toast(context, errorMsg)
|
||||
return@launch
|
||||
}
|
||||
MediaSaver.SaveResult.UseShareInstead -> {
|
||||
busy = false
|
||||
val uri = MediaSaver.stageForShare(context, bytes, source.displayName, source.mime)
|
||||
MediaSaver.share(context, uri, source.mime)
|
||||
}
|
||||
is MediaSaver.SaveResult.Failed -> {
|
||||
busy = false
|
||||
toast(context, failedFmt.format(result.message))
|
||||
when (val result = MediaSaver.saveImage(context, bytes, source.displayName, source.mime)) {
|
||||
is MediaSaver.SaveResult.Saved -> {
|
||||
busy = false
|
||||
toast(context, savedFmt.format(result.location))
|
||||
}
|
||||
MediaSaver.SaveResult.UseShareInstead -> {
|
||||
busy = false
|
||||
val uri = MediaSaver.stageForShare(
|
||||
context,
|
||||
bytes,
|
||||
source.displayName,
|
||||
source.mime,
|
||||
)
|
||||
MediaSaver.share(context, uri, source.mime)
|
||||
}
|
||||
is MediaSaver.SaveResult.Failed -> {
|
||||
busy = false
|
||||
toast(context, failedFmt.format(result.message))
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
},
|
||||
colors = tint,
|
||||
) {
|
||||
Icon(Icons.Filled.Download, contentDescription = cdSave)
|
||||
},
|
||||
colors = tint,
|
||||
) {
|
||||
Icon(Icons.Filled.Download, contentDescription = cdSave)
|
||||
}
|
||||
}
|
||||
IconButton(onClick = onDismiss, colors = tint) {
|
||||
Icon(Icons.Filled.Close, contentDescription = cdClose)
|
||||
|
||||
@@ -483,6 +483,7 @@ fun FloatingPetCompanion(
|
||||
compact: Boolean,
|
||||
animationEnabled: Boolean,
|
||||
appForeground: Boolean,
|
||||
interactive: Boolean = true,
|
||||
route: String?,
|
||||
visitRequest: PetVisitRequest?,
|
||||
onVisitRequestConsumed: (String) -> Unit,
|
||||
@@ -2318,13 +2319,14 @@ fun FloatingPetCompanion(
|
||||
}
|
||||
.pointerInput(
|
||||
pet.id,
|
||||
interactive,
|
||||
safeBounds,
|
||||
roamingRails,
|
||||
settledHabitat,
|
||||
positioned,
|
||||
surfaceScrolling,
|
||||
) {
|
||||
if (!floatingPetAcceptsPointerInput(positioned, surfaceScrolling)) {
|
||||
if (!interactive || !floatingPetAcceptsPointerInput(positioned, surfaceScrolling)) {
|
||||
return@pointerInput
|
||||
}
|
||||
detectDragGesturesAfterLongPress(
|
||||
@@ -2399,16 +2401,16 @@ fun FloatingPetCompanion(
|
||||
)
|
||||
}
|
||||
.clickable(
|
||||
enabled = floatingPetAcceptsPointerInput(positioned, surfaceScrolling),
|
||||
enabled = interactive && floatingPetAcceptsPointerInput(positioned, surfaceScrolling),
|
||||
) {
|
||||
tapReactionNonce += 1
|
||||
setMenuExpanded(true)
|
||||
}
|
||||
.semantics(mergeDescendants = true) {
|
||||
role = Role.Button
|
||||
if (interactive) role = Role.Button
|
||||
contentDescription = companionDescription
|
||||
stateDescription = stateLabel
|
||||
customActions = buildList {
|
||||
customActions = if (interactive) buildList {
|
||||
add(CustomAccessibilityAction(moveStartLabel) {
|
||||
onPlacementChanged(placement.copy(edge = PetLogicalEdge.Start)); true
|
||||
})
|
||||
@@ -2438,7 +2440,7 @@ fun FloatingPetCompanion(
|
||||
add(CustomAccessibilityAction(resetLabel) { onResetPlacement(); true })
|
||||
add(CustomAccessibilityAction(appearanceLabel) { onOpenAppearance(); true })
|
||||
add(CustomAccessibilityAction(hideLabel) { onHide(); true })
|
||||
}
|
||||
} else emptyList()
|
||||
},
|
||||
contentAlignment = Alignment.Center,
|
||||
) {
|
||||
@@ -2482,7 +2484,7 @@ fun FloatingPetCompanion(
|
||||
}
|
||||
|
||||
DropdownMenu(
|
||||
expanded = menuExpanded,
|
||||
expanded = interactive && menuExpanded,
|
||||
onDismissRequest = { setMenuExpanded(false) },
|
||||
) {
|
||||
DropdownMenuItem(
|
||||
|
||||
+18
-13
@@ -270,6 +270,7 @@ private fun ImageRender(
|
||||
maxWidth: Dp
|
||||
) {
|
||||
val context = LocalContext.current
|
||||
val exportAllowed = LocalImageExportAllowed.current
|
||||
val scope = rememberCoroutineScope()
|
||||
// Decode OFF the main thread — a large inbound image would otherwise block
|
||||
// composition. Null while decoding (placeholder); decodeFailed → file card.
|
||||
@@ -356,14 +357,14 @@ private fun ImageRender(
|
||||
}
|
||||
// One-tap save overlay — hidden while the blur cover is up so it
|
||||
// doesn't sit over the "tap to reveal" prompt.
|
||||
if (!blurred) {
|
||||
if (!blurred && exportAllowed) {
|
||||
SaveOverlayButton(
|
||||
onClick = { scope.launch { saveAttachment(context, attachment) } },
|
||||
modifier = Modifier.align(Alignment.TopEnd).padding(6.dp),
|
||||
)
|
||||
}
|
||||
AttachmentActionsMenu(
|
||||
expanded = menuExpanded,
|
||||
expanded = menuExpanded && exportAllowed,
|
||||
onDismiss = { menuExpanded = false },
|
||||
context = context,
|
||||
scope = scope,
|
||||
@@ -380,6 +381,8 @@ private fun FileCardRender(
|
||||
maxWidth: Dp
|
||||
) {
|
||||
val context = LocalContext.current
|
||||
val exportAllowed = LocalImageExportAllowed.current ||
|
||||
attachment.renderMode != AttachmentRenderMode.IMAGE
|
||||
val scope = rememberCoroutineScope()
|
||||
val (emoji, typeLabel) = emojiAndLabelFor(attachment.renderMode, attachment.contentType)
|
||||
var menuExpanded by remember { mutableStateOf(false) }
|
||||
@@ -463,21 +466,23 @@ private fun FileCardRender(
|
||||
}
|
||||
}
|
||||
// Visible one-tap save affordance (B2).
|
||||
IconButton(
|
||||
onClick = { scope.launch { saveAttachment(context, attachment) } },
|
||||
modifier = Modifier.size(32.dp),
|
||||
) {
|
||||
Icon(
|
||||
imageVector = Icons.Filled.Download,
|
||||
contentDescription = stringResource(R.string.inbound_attach_cd_save),
|
||||
tint = MaterialTheme.colorScheme.onSurfaceVariant,
|
||||
modifier = Modifier.size(18.dp),
|
||||
)
|
||||
if (exportAllowed) {
|
||||
IconButton(
|
||||
onClick = { scope.launch { saveAttachment(context, attachment) } },
|
||||
modifier = Modifier.size(32.dp),
|
||||
) {
|
||||
Icon(
|
||||
imageVector = Icons.Filled.Download,
|
||||
contentDescription = stringResource(R.string.inbound_attach_cd_save),
|
||||
tint = MaterialTheme.colorScheme.onSurfaceVariant,
|
||||
modifier = Modifier.size(18.dp),
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
AttachmentActionsMenu(
|
||||
expanded = menuExpanded,
|
||||
expanded = menuExpanded && exportAllowed,
|
||||
onDismiss = { menuExpanded = false },
|
||||
context = context,
|
||||
scope = scope,
|
||||
|
||||
@@ -94,6 +94,14 @@ import java.util.Date
|
||||
internal const val CHAT_PET_IDENTITY_OBSTACLE_PREFIX = "chat-message-identity:"
|
||||
private val MESSAGE_REACTIONS = listOf("❤️", "👍", "👎", "😂", "‼️", "❓")
|
||||
|
||||
internal fun assistantImageContent(
|
||||
content: String,
|
||||
showImages: Boolean,
|
||||
): Pair<String, List<ChatInlineImage>> {
|
||||
val (body, images) = extractChatInlineImages(content)
|
||||
return body to if (showImages) images else emptyList()
|
||||
}
|
||||
|
||||
@OptIn(ExperimentalFoundationApi::class)
|
||||
@Composable
|
||||
fun MessageBubble(
|
||||
@@ -101,6 +109,13 @@ fun MessageBubble(
|
||||
modifier: Modifier = Modifier,
|
||||
maxBubbleWidth: Dp = 300.dp,
|
||||
showThinking: Boolean = true,
|
||||
showAgentIdentity: Boolean = true,
|
||||
showTimestamps: Boolean = true,
|
||||
showWorkingStatus: Boolean = true,
|
||||
showUsage: Boolean = true,
|
||||
showTechnicalBadges: Boolean = true,
|
||||
showAssistantImages: Boolean = true,
|
||||
allowAssistantImageExport: Boolean = true,
|
||||
isFirstInGroup: Boolean = true,
|
||||
isLastInGroup: Boolean = true,
|
||||
onCopyMessage: (String) -> Unit = {},
|
||||
@@ -238,18 +253,24 @@ fun MessageBubble(
|
||||
// content so they render as real images (remote URLs via Coil) or a
|
||||
// graceful inline notice — not the blank element the markdown renderer
|
||||
// emits for an image link. User/system bubbles keep their raw content.
|
||||
val (markdownBody, inlineImages) = remember(visibleMessageContent, isUser, isSystem) {
|
||||
val (markdownBody, inlineImages) = remember(
|
||||
visibleMessageContent,
|
||||
isUser,
|
||||
isSystem,
|
||||
showAssistantImages,
|
||||
) {
|
||||
if (isUser || isSystem) {
|
||||
visibleMessageContent to emptyList()
|
||||
} else {
|
||||
extractChatInlineImages(visibleMessageContent)
|
||||
assistantImageContent(visibleMessageContent, showAssistantImages)
|
||||
}
|
||||
}
|
||||
val showImageGeneration = shouldShowImageGenerationPlaceholder(
|
||||
val showImageGeneration = showAssistantImages && showWorkingStatus && shouldShowImageGenerationPlaceholder(
|
||||
toolCalls = message.toolCalls,
|
||||
isStreaming = message.isStreaming,
|
||||
hasMediaResult = message.attachments.isNotEmpty() || inlineImages.isNotEmpty(),
|
||||
)
|
||||
val actionContent = if (!isUser && !isSystem) markdownBody else visibleMessageContent
|
||||
val streamingStatusLabel = if (
|
||||
!isUser &&
|
||||
!isSystem &&
|
||||
@@ -297,7 +318,10 @@ fun MessageBubble(
|
||||
val blurRepo = remember(context) { MediaSettingsRepository(context.applicationContext) }
|
||||
val blurMode by blurRepo.blurMode.collectAsState(initial = BlurMode.FLAGGED)
|
||||
|
||||
CompositionLocalProvider(LocalMediaBlurMode provides blurMode) {
|
||||
CompositionLocalProvider(
|
||||
LocalMediaBlurMode provides blurMode,
|
||||
LocalImageExportAllowed provides allowAssistantImageExport,
|
||||
) {
|
||||
Column(
|
||||
modifier = modifier.fillMaxWidth(),
|
||||
horizontalAlignment = alignment,
|
||||
@@ -305,7 +329,7 @@ fun MessageBubble(
|
||||
// Keep sender identity in the first-message label rather than a
|
||||
// persistent leading column. Long responses and every follow-up in the
|
||||
// group therefore retain the full bubble-width allowance.
|
||||
if (!isUser && !isSystem && isFirstInGroup && !message.agentName.isNullOrBlank()) {
|
||||
if (showAgentIdentity && !isUser && !isSystem && isFirstInGroup && !message.agentName.isNullOrBlank()) {
|
||||
Row(
|
||||
verticalAlignment = Alignment.CenterVertically,
|
||||
horizontalArrangement = Arrangement.spacedBy(6.dp),
|
||||
@@ -336,7 +360,7 @@ fun MessageBubble(
|
||||
}
|
||||
}
|
||||
|
||||
if (!isUser && !isSystem && message.badges.isNotEmpty()) {
|
||||
if (showTechnicalBadges && !isUser && !isSystem && message.badges.isNotEmpty()) {
|
||||
Row(
|
||||
modifier = Modifier
|
||||
.widthIn(max = maxBubbleWidth)
|
||||
@@ -415,7 +439,7 @@ fun MessageBubble(
|
||||
// is rendered directly in the conversation
|
||||
// lane below, without an opaque bubble. Cards and attachments still own
|
||||
// a normal bubble even when response prose has not arrived yet.
|
||||
streamingStatusLabel?.let { streamingStatus ->
|
||||
streamingStatusLabel?.takeIf { showWorkingStatus }?.let { streamingStatus ->
|
||||
StandaloneStreamingStatus(
|
||||
status = streamingStatus,
|
||||
accessibilityDescription = a11yDescription,
|
||||
@@ -508,7 +532,7 @@ fun MessageBubble(
|
||||
text = { Text(stringResource(R.string.msg_bubble_copy)) },
|
||||
onClick = {
|
||||
showMessageActions = false
|
||||
onCopyMessage(visibleMessageContent)
|
||||
onCopyMessage(actionContent)
|
||||
},
|
||||
)
|
||||
if (onQuoteMessage != null) {
|
||||
@@ -516,7 +540,7 @@ fun MessageBubble(
|
||||
text = { Text(stringResource(R.string.msg_bubble_quote)) },
|
||||
onClick = {
|
||||
showMessageActions = false
|
||||
onQuoteMessage(message.copy(content = visibleMessageContent))
|
||||
onQuoteMessage(message.copy(content = actionContent))
|
||||
},
|
||||
)
|
||||
}
|
||||
@@ -531,7 +555,7 @@ fun MessageBubble(
|
||||
},
|
||||
onClick = {
|
||||
showMessageActions = false
|
||||
onSpeakMessage?.invoke(visibleMessageContent)
|
||||
onSpeakMessage?.invoke(actionContent)
|
||||
},
|
||||
)
|
||||
}
|
||||
@@ -601,7 +625,7 @@ fun MessageBubble(
|
||||
) {
|
||||
showMessageActions = true
|
||||
} else {
|
||||
onCopyMessage(visibleMessageContent)
|
||||
onCopyMessage(actionContent)
|
||||
}
|
||||
}
|
||||
)
|
||||
@@ -798,7 +822,7 @@ fun MessageBubble(
|
||||
}
|
||||
}
|
||||
|
||||
val hasTokenUsage = !isUser &&
|
||||
val hasTokenUsage = showUsage && !isUser &&
|
||||
(message.inputTokens != null || message.outputTokens != null)
|
||||
|
||||
// Timestamp — only on the LAST bubble of a same-author run so a
|
||||
@@ -808,13 +832,13 @@ fun MessageBubble(
|
||||
// This row is reserved from the first streaming frame. Completion
|
||||
// can reveal both timestamp and token usage without adding a new
|
||||
// footer line or changing the bubble's measured height.
|
||||
if (isLastInGroup) {
|
||||
if (isLastInGroup && (showTimestamps || hasTokenUsage)) {
|
||||
Spacer(modifier = Modifier.height(2.dp))
|
||||
Row(
|
||||
horizontalArrangement = Arrangement.spacedBy(6.dp),
|
||||
verticalAlignment = Alignment.CenterVertically,
|
||||
) {
|
||||
Text(
|
||||
if (showTimestamps) Text(
|
||||
text = timeFormat.format(Date(message.timestamp)),
|
||||
style = MaterialTheme.typography.labelSmall,
|
||||
color = textColor.copy(alpha = if (message.isStreaming) 0f else 0.6f),
|
||||
@@ -883,15 +907,15 @@ fun MessageBubble(
|
||||
showEdit = showEditAction,
|
||||
onCopy = {
|
||||
showInlineActions = false
|
||||
onCopyMessage(visibleMessageContent)
|
||||
onCopyMessage(actionContent)
|
||||
},
|
||||
onQuote = {
|
||||
showInlineActions = false
|
||||
onQuoteMessage?.invoke(message.copy(content = visibleMessageContent))
|
||||
onQuoteMessage?.invoke(message.copy(content = actionContent))
|
||||
},
|
||||
onSpeak = {
|
||||
showInlineActions = false
|
||||
onSpeakMessage?.invoke(visibleMessageContent)
|
||||
onSpeakMessage?.invoke(actionContent)
|
||||
},
|
||||
onStopSpeaking = {
|
||||
showInlineActions = false
|
||||
|
||||
@@ -109,6 +109,7 @@ import androidx.compose.ui.unit.dp
|
||||
import com.hermesandroid.relay.R
|
||||
import com.hermesandroid.relay.data.ChatSession
|
||||
import com.hermesandroid.relay.data.SessionActivityState
|
||||
import com.hermesandroid.relay.data.SupervisedSessionActions
|
||||
import com.hermesandroid.relay.ui.theme.RelayRefresh
|
||||
import com.hermesandroid.relay.ui.theme.appearanceRoundedCornerShape
|
||||
import com.hermesandroid.relay.ui.theme.ProfileAccentSwatches
|
||||
@@ -208,6 +209,8 @@ fun SessionDrawerContent(
|
||||
animationEnabled: Boolean = true,
|
||||
autoTitlesSupported: Boolean = true,
|
||||
archiveSupported: Boolean = true,
|
||||
supervisedSessionActions: SupervisedSessionActions? = null,
|
||||
newChatEnabled: Boolean = true,
|
||||
onRefresh: (() -> Unit)? = null,
|
||||
/** Opens the separate Bot Mode messenger workspace; never changes drawer filters. */
|
||||
onOpenBotMode: (() -> Unit)? = null,
|
||||
@@ -288,8 +291,10 @@ fun SessionDrawerContent(
|
||||
allowBareSessionIds = !showAllProfiles,
|
||||
)
|
||||
val sourceSessions = sourceRows.map { it.session }
|
||||
val showThreads = threadsCapabilityActive || sourceSessions.any { isThreadSource(it.source) }
|
||||
val activeFilter = resolveSessionDrawerFilter(filter, showThreads, archiveSupported)
|
||||
val showThreads = supervisedSessionActions == null &&
|
||||
(threadsCapabilityActive || sourceSessions.any { isThreadSource(it.source) })
|
||||
val effectiveArchiveSupported = archiveSupported && supervisedSessionActions?.archive != false
|
||||
val activeFilter = resolveSessionDrawerFilter(filter, showThreads, effectiveArchiveSupported)
|
||||
// External gateway sources present (discord/telegram/cron/…) for the source
|
||||
// filter dropdown. Own chats (tui/api_server) + phone Threads aren't listed.
|
||||
val presentSources = sourceSessions
|
||||
@@ -405,7 +410,7 @@ fun SessionDrawerContent(
|
||||
)
|
||||
// Source filter — show/hide gateway sources (default hides the
|
||||
// noisy cron+webhook). Only when external sources are present.
|
||||
if (onToggleSourceHidden != null && presentSources.isNotEmpty()) {
|
||||
if (supervisedSessionActions == null && onToggleSourceHidden != null && presentSources.isNotEmpty()) {
|
||||
Box {
|
||||
IconButton(
|
||||
onClick = { sourceFilterOpen = true },
|
||||
@@ -466,7 +471,7 @@ fun SessionDrawerContent(
|
||||
// Threads affordance — a clean thread-spool that toggles the Threads
|
||||
// filter. Shown only when the Threads capability is active (or a Thread is
|
||||
// already present), so an ordinary no-relay drawer is visually unchanged.
|
||||
if (showThreads) {
|
||||
if (supervisedSessionActions == null && showThreads) {
|
||||
IconButton(
|
||||
onClick = {
|
||||
filter = if (filter == SessionDrawerFilter.Threads) {
|
||||
@@ -537,7 +542,8 @@ fun SessionDrawerContent(
|
||||
onNewChat()
|
||||
}
|
||||
},
|
||||
modifier = Modifier.fillMaxWidth()
|
||||
modifier = Modifier.fillMaxWidth(),
|
||||
enabled = newChatEnabled,
|
||||
) {
|
||||
Icon(Icons.Filled.Add, contentDescription = null)
|
||||
Spacer(modifier = Modifier.width(8.dp))
|
||||
@@ -602,8 +608,10 @@ fun SessionDrawerContent(
|
||||
}
|
||||
SessionDrawerFilter.entries
|
||||
.filter { item ->
|
||||
(item != SessionDrawerFilter.Threads || showThreads) &&
|
||||
(item != SessionDrawerFilter.Archive || archiveSupported)
|
||||
(item != SessionDrawerFilter.Threads ||
|
||||
(supervisedSessionActions == null && showThreads)) &&
|
||||
(item != SessionDrawerFilter.Archive ||
|
||||
effectiveArchiveSupported)
|
||||
}
|
||||
.forEach { item ->
|
||||
FilterChip(
|
||||
@@ -635,17 +643,19 @@ fun SessionDrawerContent(
|
||||
)
|
||||
}
|
||||
}
|
||||
TextButton(
|
||||
onClick = { customizeOpen = true },
|
||||
modifier = Modifier.align(Alignment.Start),
|
||||
) {
|
||||
Icon(
|
||||
Icons.Filled.FilterList,
|
||||
contentDescription = null,
|
||||
modifier = Modifier.size(16.dp),
|
||||
)
|
||||
Spacer(modifier = Modifier.width(6.dp))
|
||||
Text(stringResource(R.string.drawer_customize_sessions))
|
||||
if (supervisedSessionActions == null) {
|
||||
TextButton(
|
||||
onClick = { customizeOpen = true },
|
||||
modifier = Modifier.align(Alignment.Start),
|
||||
) {
|
||||
Icon(
|
||||
Icons.Filled.FilterList,
|
||||
contentDescription = null,
|
||||
modifier = Modifier.size(16.dp),
|
||||
)
|
||||
Spacer(modifier = Modifier.width(6.dp))
|
||||
Text(stringResource(R.string.drawer_customize_sessions))
|
||||
}
|
||||
}
|
||||
// "+ New Thread" — Discord-style user-created thread, shown when the
|
||||
// Threads filter is active. The first message opens the conversation.
|
||||
@@ -779,13 +789,20 @@ fun SessionDrawerContent(
|
||||
showTokens = viewOptions.showTokens,
|
||||
showCost = viewOptions.showCost,
|
||||
nowMillis = drawerNowMillis,
|
||||
actionsEnabled = !provisional,
|
||||
actionsEnabled = !provisional && (
|
||||
supervisedSessionActions == null ||
|
||||
supervisedSessionActions.pin ||
|
||||
supervisedSessionActions.rename ||
|
||||
supervisedSessionActions.delete ||
|
||||
(supervisedSessionActions.archive && archiveSupported)
|
||||
),
|
||||
isActive = !showAllProfiles && session.sessionId == currentSessionId,
|
||||
activityState = activityState,
|
||||
animationEnabled = animationEnabled && isOpen,
|
||||
pinned = session.pinned,
|
||||
archived = session.archived,
|
||||
archiveSupported = archiveSupported,
|
||||
supervisedSessionActions = supervisedSessionActions,
|
||||
onClick = {
|
||||
if (showAllProfiles) {
|
||||
onSelectProfileSession?.invoke(row.profile, session.sessionId)
|
||||
@@ -1347,6 +1364,7 @@ private fun SessionItem(
|
||||
pinned: Boolean,
|
||||
archived: Boolean,
|
||||
archiveSupported: Boolean,
|
||||
supervisedSessionActions: SupervisedSessionActions?,
|
||||
onClick: () -> Unit,
|
||||
onTogglePinned: () -> Unit,
|
||||
onToggleArchived: () -> Unit,
|
||||
@@ -1510,7 +1528,7 @@ private fun SessionItem(
|
||||
expanded = menuOpen,
|
||||
onDismissRequest = { menuOpen = false },
|
||||
) {
|
||||
DropdownMenuItem(
|
||||
if (supervisedSessionActions?.pin != false) DropdownMenuItem(
|
||||
text = {
|
||||
Text(
|
||||
if (pinned) {
|
||||
@@ -1536,7 +1554,7 @@ private fun SessionItem(
|
||||
onTogglePinned()
|
||||
},
|
||||
)
|
||||
DropdownMenuItem(
|
||||
if (supervisedSessionActions == null) DropdownMenuItem(
|
||||
text = { Text(stringResource(R.string.chat_copy_session_id)) },
|
||||
leadingIcon = {
|
||||
Icon(Icons.Filled.ContentCopy, contentDescription = null)
|
||||
@@ -1546,7 +1564,7 @@ private fun SessionItem(
|
||||
onCopySessionId()
|
||||
},
|
||||
)
|
||||
DropdownMenuItem(
|
||||
if (supervisedSessionActions?.rename != false) DropdownMenuItem(
|
||||
text = { Text(stringResource(R.string.drawer_rename)) },
|
||||
leadingIcon = {
|
||||
Icon(Icons.Filled.Edit, contentDescription = null)
|
||||
@@ -1556,7 +1574,7 @@ private fun SessionItem(
|
||||
onRename()
|
||||
},
|
||||
)
|
||||
if (archiveSupported) {
|
||||
if (archiveSupported && supervisedSessionActions?.archive != false) {
|
||||
DropdownMenuItem(
|
||||
text = { Text(if (archived) stringResource(R.string.drawer_restore) else stringResource(R.string.drawer_archive)) },
|
||||
leadingIcon = {
|
||||
@@ -1576,7 +1594,7 @@ private fun SessionItem(
|
||||
},
|
||||
)
|
||||
}
|
||||
DropdownMenuItem(
|
||||
if (supervisedSessionActions?.delete != false) DropdownMenuItem(
|
||||
text = {
|
||||
Text(
|
||||
text = stringResource(R.string.drawer_delete),
|
||||
|
||||
@@ -87,6 +87,8 @@ fun AboutScreen(
|
||||
connectionViewModel: ConnectionViewModel,
|
||||
onBack: () -> Unit,
|
||||
onUnlockDeveloperOptions: () -> Unit = {},
|
||||
/** Supervised clients may read About without gaining a settings mutation backdoor. */
|
||||
allowDeveloperUnlock: Boolean = true,
|
||||
) {
|
||||
val context = LocalContext.current
|
||||
val scope = rememberCoroutineScope()
|
||||
@@ -218,7 +220,7 @@ fun AboutScreen(
|
||||
Row(
|
||||
modifier = Modifier
|
||||
.fillMaxWidth()
|
||||
.clickable {
|
||||
.clickable(enabled = allowDeveloperUnlock) {
|
||||
if (devOptionsUnlocked) return@clickable
|
||||
val now = System.currentTimeMillis()
|
||||
if (now - lastTapTime > 2000) {
|
||||
|
||||
@@ -0,0 +1,96 @@
|
||||
package com.hermesandroid.relay.ui.screens
|
||||
|
||||
import androidx.compose.foundation.layout.Arrangement
|
||||
import androidx.compose.foundation.layout.Column
|
||||
import androidx.compose.foundation.layout.fillMaxSize
|
||||
import androidx.compose.foundation.layout.padding
|
||||
import androidx.compose.foundation.rememberScrollState
|
||||
import androidx.compose.foundation.verticalScroll
|
||||
import androidx.compose.material.icons.Icons
|
||||
import androidx.compose.material.icons.automirrored.filled.ArrowBack
|
||||
import androidx.compose.material.icons.filled.Security
|
||||
import androidx.compose.material3.ExperimentalMaterial3Api
|
||||
import androidx.compose.material3.Icon
|
||||
import androidx.compose.material3.IconButton
|
||||
import androidx.compose.material3.MaterialTheme
|
||||
import androidx.compose.material3.Scaffold
|
||||
import androidx.compose.material3.Text
|
||||
import androidx.compose.material3.TopAppBar
|
||||
import androidx.compose.material3.TopAppBarDefaults
|
||||
import androidx.compose.runtime.Composable
|
||||
import androidx.compose.ui.Modifier
|
||||
import androidx.compose.ui.res.stringResource
|
||||
import androidx.compose.ui.unit.dp
|
||||
import com.hermesandroid.relay.R
|
||||
import com.hermesandroid.relay.data.SupervisedModePolicy
|
||||
import com.hermesandroid.relay.ui.theme.LocalBrand
|
||||
|
||||
/** Optional and specialized features kept off the primary Settings surface. */
|
||||
@OptIn(ExperimentalMaterial3Api::class)
|
||||
@Composable
|
||||
fun AdvancedSettingsScreen(
|
||||
supervisedPolicy: SupervisedModePolicy,
|
||||
onNavigateToSupervisedControls: () -> Unit,
|
||||
onBack: () -> Unit,
|
||||
) {
|
||||
val isDarkTheme = LocalBrand.current.isDark
|
||||
|
||||
Scaffold(
|
||||
topBar = {
|
||||
TopAppBar(
|
||||
navigationIcon = {
|
||||
IconButton(onClick = onBack) {
|
||||
Icon(
|
||||
imageVector = Icons.AutoMirrored.Filled.ArrowBack,
|
||||
contentDescription = stringResource(R.string.settings_back),
|
||||
)
|
||||
}
|
||||
},
|
||||
title = { Text(stringResource(R.string.settings_advanced)) },
|
||||
colors = TopAppBarDefaults.topAppBarColors(
|
||||
containerColor = MaterialTheme.colorScheme.surface,
|
||||
),
|
||||
)
|
||||
},
|
||||
) { innerPadding ->
|
||||
Column(
|
||||
modifier = Modifier
|
||||
.fillMaxSize()
|
||||
.padding(innerPadding)
|
||||
.verticalScroll(rememberScrollState())
|
||||
.padding(horizontal = 16.dp, vertical = 16.dp),
|
||||
verticalArrangement = Arrangement.spacedBy(12.dp),
|
||||
) {
|
||||
Text(
|
||||
text = stringResource(R.string.settings_advanced_intro),
|
||||
style = MaterialTheme.typography.bodyMedium,
|
||||
color = MaterialTheme.colorScheme.onSurfaceVariant,
|
||||
)
|
||||
|
||||
SettingsCategoryRow(
|
||||
icon = Icons.Filled.Security,
|
||||
title = stringResource(R.string.settings_supervised_mode),
|
||||
subtitle = when {
|
||||
supervisedPolicy.isActive -> stringResource(
|
||||
R.string.settings_supervised_on_profile,
|
||||
supervisedPolicy.pinnedProfileName.orEmpty(),
|
||||
)
|
||||
supervisedPolicy.isConfigured -> stringResource(
|
||||
R.string.settings_supervised_ready_profile,
|
||||
supervisedPolicy.pinnedProfileName.orEmpty(),
|
||||
)
|
||||
else -> stringResource(R.string.settings_supervised_desc)
|
||||
},
|
||||
badge = supervisedPolicy.takeIf { it.isActive }?.let {
|
||||
SettingsStatusPillModel(
|
||||
label = stringResource(R.string.settings_supervised_on),
|
||||
tone = SettingsStatusTone.Good,
|
||||
)
|
||||
},
|
||||
onClick = onNavigateToSupervisedControls,
|
||||
isDarkTheme = isDarkTheme,
|
||||
petPerchKey = null,
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
+23
-18
@@ -1487,26 +1487,27 @@ private fun AppearanceSummaryRow(
|
||||
|
||||
/** Representative, theme-live chat sample so presets are judged in context. */
|
||||
@Composable
|
||||
private fun AppearanceLivePreview(
|
||||
internal fun AppearanceLivePreview(
|
||||
palette: BrandPalette,
|
||||
shapeScale: AppearanceShapeScale,
|
||||
restricted: Boolean = false,
|
||||
) {
|
||||
CompositionLocalProvider(
|
||||
LocalBrand provides palette,
|
||||
LocalAppearanceShapeScale provides shapeScale,
|
||||
) {
|
||||
MaterialTheme(colorScheme = palette.toColorScheme(), shapes = shapeScale.asMaterialShapes()) {
|
||||
AppearanceLivePreviewContent()
|
||||
AppearanceLivePreviewContent(restricted = restricted)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@Composable
|
||||
private fun AppearanceLivePreviewContent() {
|
||||
private fun AppearanceLivePreviewContent(restricted: Boolean) {
|
||||
val backgroundEnabled = LocalBackgroundVisualizationEnabled.current
|
||||
val backgroundAvatar = LocalAgentAvatar.current
|
||||
Card(
|
||||
modifier = Modifier.fillMaxWidth().height(294.dp),
|
||||
modifier = Modifier.fillMaxWidth().height(if (restricted) 258.dp else 294.dp),
|
||||
shape = MaterialTheme.shapes.large,
|
||||
colors = CardDefaults.cardColors(containerColor = MaterialTheme.colorScheme.surfaceContainerLow),
|
||||
border = androidx.compose.foundation.BorderStroke(1.dp, MaterialTheme.colorScheme.outlineVariant),
|
||||
@@ -1648,14 +1649,16 @@ private fun AppearanceLivePreviewContent() {
|
||||
style = MaterialTheme.typography.labelSmall.copy(fontSize = 8.sp),
|
||||
color = MaterialTheme.colorScheme.onSurfaceVariant,
|
||||
)
|
||||
Row(verticalAlignment = Alignment.CenterVertically) {
|
||||
Box(Modifier.size(6.dp).clip(CircleShape).background(LocalBrand.current.green))
|
||||
Text(
|
||||
text = stringResource(R.string.appearance_preview_tool_meta),
|
||||
style = MaterialTheme.typography.labelSmall.copy(fontSize = 9.sp),
|
||||
color = LocalBrand.current.green,
|
||||
modifier = Modifier.padding(start = 5.dp),
|
||||
)
|
||||
if (!restricted) {
|
||||
Row(verticalAlignment = Alignment.CenterVertically) {
|
||||
Box(Modifier.size(6.dp).clip(CircleShape).background(LocalBrand.current.green))
|
||||
Text(
|
||||
text = stringResource(R.string.appearance_preview_tool_meta),
|
||||
style = MaterialTheme.typography.labelSmall.copy(fontSize = 9.sp),
|
||||
color = LocalBrand.current.green,
|
||||
modifier = Modifier.padding(start = 5.dp),
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
Box(modifier = Modifier.padding(start = 6.dp).size(38.dp), contentAlignment = Alignment.Center) {
|
||||
@@ -1678,10 +1681,12 @@ private fun AppearanceLivePreviewContent() {
|
||||
horizontalArrangement = Arrangement.spacedBy(8.dp),
|
||||
) {
|
||||
Icon(Icons.Filled.Add, null, Modifier.size(18.dp), tint = MaterialTheme.colorScheme.onSurfaceVariant)
|
||||
Text("gpt-5.6-sol", style = MaterialTheme.typography.labelSmall.copy(fontSize = 9.sp))
|
||||
Icon(Icons.Filled.KeyboardArrowDown, null, Modifier.size(14.dp))
|
||||
Text("High", style = MaterialTheme.typography.labelSmall.copy(fontSize = 9.sp))
|
||||
Icon(Icons.Filled.KeyboardArrowDown, null, Modifier.size(14.dp))
|
||||
if (!restricted) {
|
||||
Text("gpt-5.6-sol", style = MaterialTheme.typography.labelSmall.copy(fontSize = 9.sp))
|
||||
Icon(Icons.Filled.KeyboardArrowDown, null, Modifier.size(14.dp))
|
||||
Text("High", style = MaterialTheme.typography.labelSmall.copy(fontSize = 9.sp))
|
||||
Icon(Icons.Filled.KeyboardArrowDown, null, Modifier.size(14.dp))
|
||||
}
|
||||
Text(
|
||||
stringResource(R.string.appearance_preview_message_placeholder),
|
||||
style = MaterialTheme.typography.labelSmall.copy(fontSize = 9.sp),
|
||||
@@ -1691,7 +1696,7 @@ private fun AppearanceLivePreviewContent() {
|
||||
Icon(Icons.Filled.GraphicEq, null, Modifier.size(18.dp), tint = MaterialTheme.colorScheme.primary)
|
||||
}
|
||||
}
|
||||
Surface(
|
||||
if (!restricted) Surface(
|
||||
modifier = Modifier.align(Alignment.CenterHorizontally),
|
||||
shape = appearanceRoundedCornerShape(16.dp),
|
||||
color = MaterialTheme.colorScheme.surfaceContainerHigh,
|
||||
@@ -1785,7 +1790,7 @@ private fun FontOptionRow(
|
||||
* are added.
|
||||
*/
|
||||
@Composable
|
||||
private fun ThemeSwatchChip(
|
||||
internal fun ThemeSwatchChip(
|
||||
appTheme: AppTheme,
|
||||
selected: Boolean,
|
||||
onClick: () -> Unit,
|
||||
|
||||
@@ -50,6 +50,7 @@ import androidx.compose.material.icons.filled.Code
|
||||
import androidx.compose.material.icons.filled.ContentCopy
|
||||
import androidx.compose.material.icons.filled.Edit
|
||||
import androidx.compose.material.icons.filled.Menu
|
||||
import androidx.compose.material.icons.filled.Settings
|
||||
import androidx.compose.material.icons.filled.MoreVert
|
||||
import androidx.compose.material.icons.filled.Search
|
||||
import androidx.compose.material.icons.filled.Share
|
||||
@@ -147,6 +148,7 @@ import androidx.compose.animation.fadeOut
|
||||
import androidx.compose.material.icons.filled.KeyboardArrowDown
|
||||
import androidx.compose.material3.SmallFloatingActionButton
|
||||
import androidx.compose.material3.SnackbarHost
|
||||
import androidx.compose.material3.Scaffold
|
||||
import androidx.compose.material3.SnackbarHostState
|
||||
import androidx.compose.material3.SnackbarDuration
|
||||
import androidx.compose.material3.SnackbarResult
|
||||
@@ -179,6 +181,11 @@ import com.hermesandroid.relay.data.MessageRole
|
||||
import com.hermesandroid.relay.data.PhysicalKeyboardEnterBehavior
|
||||
import com.hermesandroid.relay.data.ProfilePresentationPolicy
|
||||
import com.hermesandroid.relay.data.ProactiveInboxEntry
|
||||
import com.hermesandroid.relay.data.SessionActivityState
|
||||
import com.hermesandroid.relay.data.SupervisedAttachmentCategory
|
||||
import com.hermesandroid.relay.data.SupervisedModePolicy
|
||||
import com.hermesandroid.relay.data.SupervisedSessionAction
|
||||
import com.hermesandroid.relay.data.allowsSessionAction
|
||||
import com.hermesandroid.relay.data.VoicePresentationMode
|
||||
import com.hermesandroid.relay.data.hermesProcessNotificationOrNull
|
||||
import com.hermesandroid.relay.ui.components.AgentInfoSheet
|
||||
@@ -710,8 +717,40 @@ fun ChatScreen(
|
||||
// existing test/preview call sites keep compiling.
|
||||
onNavigateToVoiceSettings: () -> Unit = {},
|
||||
onNavigateToProfileInspector: (String) -> Unit = {},
|
||||
supervisedPolicy: SupervisedModePolicy = SupervisedModePolicy(),
|
||||
onNavigateToBotMode: () -> Unit = {},
|
||||
) {
|
||||
val supervised = supervisedPolicy.enabled
|
||||
val supervisedVisibility = supervisedPolicy.visibility.resolved()
|
||||
LaunchedEffect(supervisedPolicy) {
|
||||
voiceViewModel.updateSupervisedModePolicy(supervisedPolicy)
|
||||
}
|
||||
if (supervised && !supervisedPolicy.isActive) {
|
||||
Scaffold(
|
||||
topBar = {
|
||||
TopAppBar(
|
||||
title = { Text("Supervised chat unavailable") },
|
||||
actions = {
|
||||
IconButton(onClick = onNavigateToSettings) {
|
||||
Icon(Icons.Filled.Settings, contentDescription = "Settings")
|
||||
}
|
||||
},
|
||||
)
|
||||
},
|
||||
) { padding ->
|
||||
Box(
|
||||
modifier = Modifier.fillMaxSize().padding(padding).padding(24.dp),
|
||||
contentAlignment = Alignment.Center,
|
||||
) {
|
||||
Text(
|
||||
"The supervised profile is unavailable. Parent access is required to update this connection.",
|
||||
style = MaterialTheme.typography.bodyLarge,
|
||||
color = MaterialTheme.colorScheme.onSurfaceVariant,
|
||||
)
|
||||
}
|
||||
}
|
||||
return
|
||||
}
|
||||
val voiceUiState by voiceViewModel.uiState.collectAsState()
|
||||
val responseSpeechActive by voiceViewModel.responseSpeechActive.collectAsState()
|
||||
val isDemoMode by connectionViewModel.isDemoMode.collectAsState()
|
||||
@@ -734,7 +773,6 @@ fun ChatScreen(
|
||||
LaunchedEffect(voiceUiState.voiceMode) {
|
||||
if (!voiceUiState.voiceMode) voicePresentationOverride = null
|
||||
}
|
||||
|
||||
// Route classified chat errors (media cache, streaming failures, …) to
|
||||
// the app-wide snackbar. Same pattern every VM-bound screen uses.
|
||||
val snackbarHost = LocalSnackbarHost.current
|
||||
@@ -796,7 +834,22 @@ fun ChatScreen(
|
||||
}
|
||||
|
||||
|
||||
val messages by chatViewModel.messages.collectAsState()
|
||||
val rawMessages by chatViewModel.messages.collectAsState()
|
||||
val messages = remember(rawMessages, supervised, supervisedPolicy.capabilities.generatedImages) {
|
||||
if (!supervised) rawMessages
|
||||
else rawMessages.map { message ->
|
||||
if (message.role == MessageRole.ASSISTANT) {
|
||||
message.copy(
|
||||
attachments = if (supervisedPolicy.capabilities.generatedImages) {
|
||||
message.attachments.filter { it.isImage }
|
||||
} else {
|
||||
emptyList()
|
||||
},
|
||||
cards = emptyList(),
|
||||
)
|
||||
} else message
|
||||
}
|
||||
}
|
||||
val messageReactionsSupported by chatViewModel.messageReactionsSupported.collectAsState()
|
||||
val newestReactableMessageKeys = remember(messages) {
|
||||
setOfNotNull(
|
||||
@@ -823,10 +876,17 @@ fun ChatScreen(
|
||||
// Stable voice can use the standard Hermes dashboard audio routes or the
|
||||
// optional Relay voice routes. Gate the mic on either route being usable;
|
||||
// availability picks the actionable toast when neither is.
|
||||
val voiceReady by connectionViewModel.voiceReady.collectAsState()
|
||||
val connectionVoiceReady by connectionViewModel.voiceReady.collectAsState()
|
||||
val standardVoiceAvailability by connectionViewModel.standardVoiceAvailability.collectAsState()
|
||||
val voiceReady = if (supervised) {
|
||||
supervisedPolicy.capabilities.voice &&
|
||||
standardVoiceAvailability ==
|
||||
com.hermesandroid.relay.viewmodel.StandardVoiceAvailability.Ready
|
||||
} else {
|
||||
connectionVoiceReady
|
||||
}
|
||||
val chatSpeakResponseActionsEnabled =
|
||||
shouldOfferChatSpeakAction(voiceReady, voiceUiState.state)
|
||||
val standardVoiceAvailability by connectionViewModel.standardVoiceAvailability.collectAsState()
|
||||
val standardVoiceSignInRouteHint by
|
||||
connectionViewModel.standardVoiceSignInRouteHint.collectAsState()
|
||||
val dashboardRouteMovedHint by connectionViewModel.dashboardRouteMovedHint.collectAsState()
|
||||
@@ -990,8 +1050,15 @@ fun ChatScreen(
|
||||
?: sessionModelState.pickerModel?.let { model ->
|
||||
modelProviders.singleOrNull { model in it.models }?.slug
|
||||
}
|
||||
val showThinking by connectionViewModel.showThinking.collectAsState()
|
||||
val toolDisplay by connectionViewModel.toolDisplay.collectAsState()
|
||||
val configuredShowThinking by connectionViewModel.showThinking.collectAsState()
|
||||
val configuredToolDisplay by connectionViewModel.toolDisplay.collectAsState()
|
||||
val showThinking = configuredShowThinking &&
|
||||
(!supervised || supervisedVisibility.showReasoning)
|
||||
val toolDisplay = if (!supervised) configuredToolDisplay else when {
|
||||
supervisedVisibility.showToolDetails -> "detailed"
|
||||
supervisedVisibility.showToolNames -> "compact"
|
||||
else -> "off"
|
||||
}
|
||||
val smoothAutoScroll by connectionViewModel.smoothAutoScroll.collectAsState()
|
||||
val closeDrawerOnSend by connectionViewModel.closeDrawerOnSend.collectAsState()
|
||||
val keepComposerFocusedOnSend by
|
||||
@@ -1010,7 +1077,10 @@ fun ChatScreen(
|
||||
// marker so the user knows approvals are off without opening the agent drawer.
|
||||
val yoloEnabled by chatViewModel.yoloEnabled.collectAsState()
|
||||
val pendingAttachments by chatViewModel.pendingAttachments.collectAsState()
|
||||
val maxAttachmentMb by connectionViewModel.maxAttachmentMb.collectAsState()
|
||||
val configuredMaxAttachmentMb by connectionViewModel.maxAttachmentMb.collectAsState()
|
||||
val maxAttachmentMb = if (supervised) {
|
||||
minOf(configuredMaxAttachmentMb, supervisedPolicy.capabilities.attachmentMaxFileMb)
|
||||
} else configuredMaxAttachmentMb
|
||||
val charLimit by connectionViewModel.maxMessageLength.collectAsState()
|
||||
|
||||
// === Gateway desktop-parity state ===
|
||||
@@ -1019,6 +1089,9 @@ fun ChatScreen(
|
||||
val contextWindow by chatViewModel.contextWindow.collectAsState()
|
||||
// Injected-context audit sheet (opened by tapping the context meter).
|
||||
var showContextSheet by remember { mutableStateOf(false) }
|
||||
LaunchedEffect(supervised) {
|
||||
if (supervised) showContextSheet = false
|
||||
}
|
||||
val steerableTurn by chatViewModel.steerableTurn.collectAsState()
|
||||
val steerNotice by chatViewModel.steerNotice.collectAsState()
|
||||
val voiceHintSeen by connectionViewModel.voiceHintSeen.collectAsState()
|
||||
@@ -2020,9 +2093,9 @@ fun ChatScreen(
|
||||
}
|
||||
}
|
||||
}
|
||||
val showAutocomplete by remember(filteredCommands, inputText) {
|
||||
val showAutocomplete by remember(filteredCommands, inputText, supervised) {
|
||||
derivedStateOf {
|
||||
inputText.startsWith("/") && filteredCommands.isNotEmpty()
|
||||
!supervised && inputText.startsWith("/") && filteredCommands.isNotEmpty()
|
||||
}
|
||||
}
|
||||
|
||||
@@ -2295,7 +2368,7 @@ fun ChatScreen(
|
||||
}
|
||||
}
|
||||
val selectedProfileKey = AgentDisplay.profileSessionKey(selectedProfile?.name)
|
||||
val profileShelfAvailable = ProfilePresentationPolicy.shouldShowShelf(
|
||||
val profileShelfAvailable = !supervised && ProfilePresentationPolicy.shouldShowShelf(
|
||||
profiles = agentProfiles,
|
||||
presentation = profilePresentation,
|
||||
selectedKey = selectedProfileKey,
|
||||
@@ -2322,7 +2395,7 @@ fun ChatScreen(
|
||||
// Material routes scrim taps through the drawer's gesture handler.
|
||||
// Keep it enabled so tapping outside always dismisses the drawer; the
|
||||
// voice overlay already owns input while voice mode is visible.
|
||||
gesturesEnabled = true,
|
||||
gesturesEnabled = !supervised || supervisedPolicy.capabilities.conversationHistory,
|
||||
drawerContent = {
|
||||
val drawerProfileName = explicitBindingProfileName ?: effectiveProfile?.name
|
||||
val drawerTitle = if (drawerProfileName != null) {
|
||||
@@ -2366,7 +2439,9 @@ fun ChatScreen(
|
||||
}
|
||||
|
||||
SessionDrawerContent(
|
||||
sessions = sessions,
|
||||
sessions = if (
|
||||
supervised && !supervisedPolicy.capabilities.conversationHistory
|
||||
) emptyList() else sessions,
|
||||
currentSessionId = currentSessionId,
|
||||
scopeTitle = drawerTitle,
|
||||
scopeSubtitle = drawerSubtitle,
|
||||
@@ -2377,14 +2452,19 @@ fun ChatScreen(
|
||||
animationEnabled = animationEnabled,
|
||||
autoTitlesSupported = serverAutoTitles,
|
||||
archiveSupported = sessionArchivingSupported,
|
||||
supervisedSessionActions = supervisedPolicy.capabilities.sessionActions
|
||||
.takeIf { supervised },
|
||||
newChatEnabled = !supervised || supervisedPolicy.capabilities.newChat,
|
||||
onRefresh = { chatViewModel.refreshSessions() },
|
||||
onOpenBotMode = {
|
||||
scope.launch { drawerState.close() }
|
||||
onNavigateToBotMode()
|
||||
},
|
||||
onNewChat = {
|
||||
chatViewModel.createNewChat()
|
||||
scope.launch { drawerState.close() }
|
||||
if (!supervised || supervisedPolicy.capabilities.newChat) {
|
||||
chatViewModel.createNewChat()
|
||||
scope.launch { drawerState.close() }
|
||||
}
|
||||
},
|
||||
onNewDefaultChat = {
|
||||
if (isProfileLocked) return@SessionDrawerContent
|
||||
@@ -2410,6 +2490,9 @@ fun ChatScreen(
|
||||
scope.launch { drawerState.close() }
|
||||
},
|
||||
onDeleteSession = { sessionId ->
|
||||
if (supervised && !supervisedPolicy.allowsSessionAction(SupervisedSessionAction.Delete)) {
|
||||
return@SessionDrawerContent
|
||||
}
|
||||
val connectionId = activeConnection?.id
|
||||
val profileId = explicitBindingProfileName ?: selectedProfile?.name
|
||||
chatViewModel.deleteSession(sessionId) {
|
||||
@@ -2423,10 +2506,21 @@ fun ChatScreen(
|
||||
}
|
||||
},
|
||||
onRenameSession = { sessionId, title ->
|
||||
if (supervised && !supervisedPolicy.allowsSessionAction(SupervisedSessionAction.Rename)) {
|
||||
return@SessionDrawerContent
|
||||
}
|
||||
chatViewModel.renameSession(sessionId, title)
|
||||
},
|
||||
onSetSessionPinned = chatViewModel::setSessionPinned,
|
||||
onSetSessionArchived = chatViewModel::setSessionArchived,
|
||||
onSetSessionPinned = { sessionId, pinned ->
|
||||
if (!supervised || supervisedPolicy.allowsSessionAction(SupervisedSessionAction.Pin)) {
|
||||
chatViewModel.setSessionPinned(sessionId, pinned)
|
||||
}
|
||||
},
|
||||
onSetSessionArchived = { sessionId, archived ->
|
||||
if (!supervised || supervisedPolicy.allowsSessionAction(SupervisedSessionAction.Archive)) {
|
||||
chatViewModel.setSessionArchived(sessionId, archived)
|
||||
}
|
||||
},
|
||||
onCopySessionId = { sessionId ->
|
||||
scope.launch {
|
||||
clipboard.setClipEntry(
|
||||
@@ -2456,7 +2550,7 @@ fun ChatScreen(
|
||||
onToggleSourceHidden = { source, hidden ->
|
||||
connectionViewModel.setSourceHidden(source, hidden)
|
||||
},
|
||||
allProfilesSupported = !isProfileLocked &&
|
||||
allProfilesSupported = !supervised && !isProfileLocked &&
|
||||
!activeConnection?.resolvedDashboardUrl.isNullOrBlank(),
|
||||
allProfileSessions = allProfileSessions,
|
||||
allProfileSessionsLoading = allProfileSessionsLoading,
|
||||
@@ -2577,8 +2671,14 @@ fun ChatScreen(
|
||||
// Top bar — messaging app style with avatar, name, model subtitle
|
||||
TopAppBar(
|
||||
navigationIcon = {
|
||||
IconButton(onClick = { scope.launch { drawerState.open() } }) {
|
||||
Icon(Icons.Filled.Menu, contentDescription = stringResource(R.string.cd_sessions))
|
||||
if (!supervised || supervisedPolicy.capabilities.conversationHistory) {
|
||||
IconButton(onClick = { scope.launch { drawerState.open() } }) {
|
||||
Icon(Icons.Filled.Menu, contentDescription = stringResource(R.string.cd_sessions))
|
||||
}
|
||||
} else if (supervisedPolicy.capabilities.newChat) {
|
||||
IconButton(onClick = { chatViewModel.createNewChat() }) {
|
||||
Icon(Icons.Filled.Edit, contentDescription = "New chat")
|
||||
}
|
||||
}
|
||||
},
|
||||
title = {
|
||||
@@ -2610,8 +2710,10 @@ fun ChatScreen(
|
||||
// style subtitle status.
|
||||
var everConnected by remember { mutableStateOf(false) }
|
||||
if (headerChatReady) everConnected = true
|
||||
val showStreamingState = isStreaming &&
|
||||
(!supervised || supervisedVisibility.showWorkingStatus)
|
||||
val statusText = when {
|
||||
headerChatReady -> if (isStreaming) {
|
||||
headerChatReady -> if (showStreamingState) {
|
||||
stringResource(R.string.chat_streaming)
|
||||
} else {
|
||||
stringResource(R.string.chat_connected_label)
|
||||
@@ -2661,6 +2763,14 @@ fun ChatScreen(
|
||||
// personality label.
|
||||
val subtitleText = if (!headerChatReady) {
|
||||
statusText
|
||||
} else if (supervised) {
|
||||
buildList {
|
||||
if (supervisedVisibility.showProfileName) {
|
||||
conversationProfile?.name?.takeIf { it.isNotBlank() }?.let(::add)
|
||||
}
|
||||
if (supervisedVisibility.showModelName && !modelName.isNullOrBlank()) add(modelName)
|
||||
if (isEmpty() && supervisedVisibility.showConnectionStatus) add(statusText)
|
||||
}.joinToString(" · ")
|
||||
} else {
|
||||
resolveChatHeaderSubtitle(
|
||||
isStreaming = isStreaming,
|
||||
@@ -2679,7 +2789,7 @@ fun ChatScreen(
|
||||
verticalAlignment = Alignment.CenterVertically,
|
||||
horizontalArrangement = Arrangement.spacedBy(12.dp),
|
||||
modifier = Modifier
|
||||
.clickable {
|
||||
.clickable(enabled = !supervised) {
|
||||
if (profileShelfAvailable) {
|
||||
showProfileShelf = !showProfileShelf
|
||||
} else {
|
||||
@@ -2703,7 +2813,7 @@ fun ChatScreen(
|
||||
// Avatar — a plain 40dp circle whose letter swaps to the
|
||||
// active agent (profile or personality). No overlay ring:
|
||||
// the letter itself is the indicator.
|
||||
Box(modifier = Modifier.size(40.dp)) {
|
||||
if (!supervised || supervisedVisibility.showAgentIdentity) Box(modifier = Modifier.size(40.dp)) {
|
||||
Surface(
|
||||
modifier = Modifier.size(40.dp),
|
||||
shape = CircleShape,
|
||||
@@ -2753,14 +2863,16 @@ fun ChatScreen(
|
||||
}
|
||||
}
|
||||
}
|
||||
ConnectionStatusBadge(
|
||||
isConnected = headerChatReady,
|
||||
isConnecting = isConnecting,
|
||||
modifier = Modifier
|
||||
.size(10.dp)
|
||||
.align(Alignment.BottomEnd),
|
||||
size = 10.dp
|
||||
)
|
||||
if (!supervised || supervisedVisibility.showConnectionStatus) {
|
||||
ConnectionStatusBadge(
|
||||
isConnected = headerChatReady,
|
||||
isConnecting = isConnecting,
|
||||
modifier = Modifier
|
||||
.size(10.dp)
|
||||
.align(Alignment.BottomEnd),
|
||||
size = 10.dp,
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
// Name + single-line subtitle.
|
||||
@@ -2801,7 +2913,13 @@ fun ChatScreen(
|
||||
} else {
|
||||
Column {
|
||||
Text(
|
||||
text = if (agentDisplayName.isNotBlank()) agentDisplayName else stringResource(R.string.chat_agent_default),
|
||||
text = if (supervised && !supervisedVisibility.showAgentIdentity) {
|
||||
stringResource(R.string.screen_chat_label)
|
||||
} else if (agentDisplayName.isNotBlank()) {
|
||||
agentDisplayName
|
||||
} else {
|
||||
stringResource(R.string.chat_agent_default)
|
||||
},
|
||||
style = MaterialTheme.typography.titleMedium,
|
||||
maxLines = 1,
|
||||
overflow = androidx.compose.ui.text.style.TextOverflow.Ellipsis,
|
||||
@@ -2838,7 +2956,7 @@ fun ChatScreen(
|
||||
maxLines = 1,
|
||||
overflow = androidx.compose.ui.text.style.TextOverflow.Ellipsis,
|
||||
)
|
||||
if (isStreaming && animationEnabled) {
|
||||
if (showStreamingState && animationEnabled) {
|
||||
StreamingDots(
|
||||
color = subtitleColor,
|
||||
modifier = Modifier.clearAndSetSemantics { },
|
||||
@@ -2859,7 +2977,7 @@ fun ChatScreen(
|
||||
// full explanation (global mode / --yolo / per-session)
|
||||
// lives. Keeps the risk visible without eating subtitle
|
||||
// width on every turn.
|
||||
if (yoloEnabled == true) {
|
||||
if (!supervised && yoloEnabled == true) {
|
||||
RelayChromeIconButton(
|
||||
icon = Icons.Filled.Bolt,
|
||||
contentDescription = stringResource(R.string.cd_approvals_off),
|
||||
@@ -2877,12 +2995,14 @@ fun ChatScreen(
|
||||
// tappable → Connections, so the affordance moved with the
|
||||
// info. Dropping it here declutters the actions row and frees
|
||||
// width for the title subtitle.)
|
||||
RelayChromeIconButton(
|
||||
icon = Icons.Filled.Code,
|
||||
contentDescription = stringResource(R.string.cd_terminal),
|
||||
onClick = onNavigateToTerminal,
|
||||
modifier = Modifier.padding(end = 4.dp),
|
||||
)
|
||||
if (!supervised) {
|
||||
RelayChromeIconButton(
|
||||
icon = Icons.Filled.Code,
|
||||
contentDescription = stringResource(R.string.cd_terminal),
|
||||
onClick = onNavigateToTerminal,
|
||||
modifier = Modifier.padding(end = 4.dp),
|
||||
)
|
||||
}
|
||||
RelayChromeIconButton(
|
||||
icon = Icons.Filled.Tune,
|
||||
contentDescription = stringResource(R.string.cd_settings),
|
||||
@@ -2895,7 +3015,11 @@ fun ChatScreen(
|
||||
// Settings — which is what was squeezing the title subtitle.
|
||||
// Session identity is useful before the first message; sharing only appears
|
||||
// once the conversation has content.
|
||||
if (messages.isNotEmpty() || !currentSessionId.isNullOrBlank()) {
|
||||
if (
|
||||
(!supervised && (messages.isNotEmpty() || !currentSessionId.isNullOrBlank())) ||
|
||||
(supervised && messages.isNotEmpty() &&
|
||||
supervisedPolicy.allowsSessionAction(SupervisedSessionAction.ShareTranscript))
|
||||
) {
|
||||
var showOverflowMenu by remember { mutableStateOf(false) }
|
||||
Box {
|
||||
RelayChromeIconButton(
|
||||
@@ -2908,7 +3032,7 @@ fun ChatScreen(
|
||||
expanded = showOverflowMenu,
|
||||
onDismissRequest = { showOverflowMenu = false },
|
||||
) {
|
||||
currentSessionId?.takeIf { it.isNotBlank() }?.let { sessionId ->
|
||||
currentSessionId?.takeIf { !supervised && it.isNotBlank() }?.let { sessionId ->
|
||||
DropdownMenuItem(
|
||||
text = { Text(copySessionIdLabel) },
|
||||
leadingIcon = {
|
||||
@@ -2933,7 +3057,7 @@ fun ChatScreen(
|
||||
},
|
||||
)
|
||||
}
|
||||
if (messages.isNotEmpty()) {
|
||||
if (!supervised && messages.isNotEmpty()) {
|
||||
DropdownMenuItem(
|
||||
text = { Text(stringResource(R.string.chat_search_conversation)) },
|
||||
leadingIcon = {
|
||||
@@ -2957,6 +3081,22 @@ fun ChatScreen(
|
||||
shareConversation(context, messages)
|
||||
},
|
||||
)
|
||||
} else if (
|
||||
messages.isNotEmpty() &&
|
||||
supervisedPolicy.allowsSessionAction(
|
||||
SupervisedSessionAction.ShareTranscript,
|
||||
)
|
||||
) {
|
||||
DropdownMenuItem(
|
||||
text = { Text(stringResource(R.string.chat_share_conversation)) },
|
||||
leadingIcon = {
|
||||
Icon(Icons.Filled.Share, contentDescription = null)
|
||||
},
|
||||
onClick = {
|
||||
showOverflowMenu = false
|
||||
shareConversation(context, messages)
|
||||
},
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -2998,13 +3138,15 @@ fun ChatScreen(
|
||||
// and the mode strip — slim bar + `NN% · used/max` token readout,
|
||||
// color-graded by fullness. Composes to nothing until the server
|
||||
// reports a context_max for the session.
|
||||
ContextMeterBar(
|
||||
usedFraction = contextUsage,
|
||||
usedTokens = contextWindow?.usedTokens,
|
||||
maxTokens = contextWindow?.maxTokens,
|
||||
onClick = { showContextSheet = true },
|
||||
)
|
||||
if (showContextSheet) {
|
||||
if (!supervised || supervisedVisibility.showUsage) {
|
||||
ContextMeterBar(
|
||||
usedFraction = contextUsage,
|
||||
usedTokens = contextWindow?.usedTokens,
|
||||
maxTokens = contextWindow?.maxTokens,
|
||||
onClick = if (supervised) null else ({ showContextSheet = true }),
|
||||
)
|
||||
}
|
||||
if (!supervised && showContextSheet) {
|
||||
// Live audit of the exact extra context the agent will be
|
||||
// injected with on the next turn (transparency / auditability).
|
||||
InjectedContextSheet(
|
||||
@@ -3065,7 +3207,31 @@ fun ChatScreen(
|
||||
},
|
||||
label = "chatEmptyStatePhaseTransition",
|
||||
) { targetConnectState ->
|
||||
if (targetConnectState == ChatConnectState.Connecting) {
|
||||
if (supervised && targetConnectState != ChatConnectState.Ready) {
|
||||
Box(
|
||||
modifier = Modifier.fillMaxSize(),
|
||||
contentAlignment = Alignment.Center,
|
||||
) {
|
||||
if (supervisedVisibility.showConnectionStatus) {
|
||||
Column(
|
||||
horizontalAlignment = Alignment.CenterHorizontally,
|
||||
verticalArrangement = Arrangement.spacedBy(12.dp),
|
||||
) {
|
||||
if (targetConnectState == ChatConnectState.Connecting) {
|
||||
CircularProgressIndicator()
|
||||
}
|
||||
Text(
|
||||
text = if (targetConnectState == ChatConnectState.Connecting) {
|
||||
stringResource(R.string.chat_connecting_dots)
|
||||
} else {
|
||||
stringResource(R.string.chat_disconnected_label)
|
||||
},
|
||||
color = MaterialTheme.colorScheme.onSurfaceVariant,
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
} else if (targetConnectState == ChatConnectState.Connecting) {
|
||||
ChatColdStartLoadingState(
|
||||
animationEnabled = animationEnabled,
|
||||
streamingIntensity = streamingIntensity,
|
||||
@@ -3105,7 +3271,10 @@ fun ChatScreen(
|
||||
Spacer(modifier = Modifier.weight(0.15f))
|
||||
|
||||
// ASCII sphere (constrained to square aspect)
|
||||
if (LocalBackgroundVisualizationEnabled.current) {
|
||||
if (
|
||||
LocalBackgroundVisualizationEnabled.current &&
|
||||
(!supervised || supervisedVisibility.showAgentIdentity)
|
||||
) {
|
||||
Box(
|
||||
modifier = Modifier
|
||||
.fillMaxWidth()
|
||||
@@ -3133,7 +3302,10 @@ fun ChatScreen(
|
||||
// thread itself (not just the header) -
|
||||
// the desktop's intro.
|
||||
ChatConnectState.Ready ->
|
||||
if (effectiveProfile != null) {
|
||||
if (
|
||||
effectiveProfile != null &&
|
||||
(!supervised || supervisedVisibility.showAgentIdentity)
|
||||
) {
|
||||
stringResource(R.string.chat_prompt_chat_with, agentDisplayName)
|
||||
} else {
|
||||
stringResource(R.string.chat_start_conversation)
|
||||
@@ -3151,7 +3323,11 @@ fun ChatScreen(
|
||||
val profileBlurb = effectiveProfile?.description
|
||||
?.trim()
|
||||
?.takeIf { it.isNotBlank() && !it.equals(agentDisplayName, ignoreCase = true) }
|
||||
if (targetConnectState == ChatConnectState.Ready && profileBlurb != null) {
|
||||
if (
|
||||
targetConnectState == ChatConnectState.Ready &&
|
||||
profileBlurb != null &&
|
||||
(!supervised || supervisedVisibility.showAgentIdentity)
|
||||
) {
|
||||
Spacer(modifier = Modifier.height(6.dp))
|
||||
Text(
|
||||
text = profileBlurb,
|
||||
@@ -3268,6 +3444,7 @@ fun ChatScreen(
|
||||
// Ambient avatar behind messages
|
||||
if (
|
||||
LocalBackgroundVisualizationEnabled.current &&
|
||||
(!supervised || supervisedVisibility.showAgentIdentity) &&
|
||||
animationBehindChat &&
|
||||
!ambientMode
|
||||
) {
|
||||
@@ -3289,8 +3466,13 @@ fun ChatScreen(
|
||||
// /media/by-path route when a relay session is paired,
|
||||
// instead of degrading to the "image is on the server"
|
||||
// notice. Null when no relay (standard no-plugin) → notice.
|
||||
val relayServerImageResolver = remember(chatViewModel) {
|
||||
RelayServerImageResolver { path -> chatViewModel.resolveServerImage(path) }
|
||||
val relayServerImageResolver = remember(
|
||||
chatViewModel,
|
||||
supervised,
|
||||
supervisedPolicy.capabilities.generatedImages,
|
||||
) {
|
||||
if (supervised && !supervisedPolicy.capabilities.generatedImages) null
|
||||
else RelayServerImageResolver { path -> chatViewModel.resolveServerImage(path) }
|
||||
}
|
||||
val thinkingIndicatorConfig = remember(
|
||||
thinkingIndicatorStyle,
|
||||
@@ -3345,6 +3527,7 @@ fun ChatScreen(
|
||||
items(messages.size, key = { messages[it].uiKey }) { index ->
|
||||
val message = messages[index]
|
||||
val processNotification = message.hermesProcessNotificationOrNull()
|
||||
?.takeIf { !supervised || supervisedVisibility.showToolNames }
|
||||
|
||||
// Skip empty bubbles (content stripped by annotation parser, no tool calls,
|
||||
// no attachments). Attachments keep the bubble alive for inbound media;
|
||||
@@ -3369,7 +3552,10 @@ fun ChatScreen(
|
||||
messages[index + 1].timestamp - message.timestamp > GROUP_GAP_MS
|
||||
|
||||
// Date separator
|
||||
if (index == 0 || !isSameDay(messages[index - 1].timestamp, message.timestamp)) {
|
||||
if (
|
||||
(!supervised || supervisedVisibility.showTimestamps) &&
|
||||
(index == 0 || !isSameDay(messages[index - 1].timestamp, message.timestamp))
|
||||
) {
|
||||
DateSeparator(timestamp = message.timestamp)
|
||||
}
|
||||
|
||||
@@ -3381,7 +3567,9 @@ fun ChatScreen(
|
||||
message.attachments.isNotEmpty() ||
|
||||
message.cards.isNotEmpty()
|
||||
|
||||
message.backgroundTask?.let { task ->
|
||||
message.backgroundTask
|
||||
?.takeIf { !supervised || supervisedVisibility.showWorkingStatus }
|
||||
?.let { task ->
|
||||
val taskModifier = Modifier.padding(
|
||||
top = if (isFirstInGroup) 6.dp else 2.dp,
|
||||
bottom = if (shouldRenderBubble) 3.dp else 0.dp,
|
||||
@@ -3439,6 +3627,14 @@ fun ChatScreen(
|
||||
},
|
||||
maxBubbleWidth = maxBubbleWidth,
|
||||
showThinking = showThinking,
|
||||
showAgentIdentity = !supervised || supervisedVisibility.showAgentIdentity,
|
||||
showTimestamps = !supervised || supervisedVisibility.showTimestamps,
|
||||
showWorkingStatus = !supervised || supervisedVisibility.showWorkingStatus,
|
||||
showUsage = !supervised || supervisedVisibility.showUsage,
|
||||
showTechnicalBadges = !supervised || supervisedVisibility.showTechnicalRoute,
|
||||
showAssistantImages = !supervised || supervisedPolicy.capabilities.generatedImages,
|
||||
allowAssistantImageExport = !supervised ||
|
||||
supervisedPolicy.capabilities.shareGeneratedImages,
|
||||
isFirstInGroup = isFirstInGroup,
|
||||
isLastInGroup = isLastInGroup,
|
||||
recoveringAnswer = recoveringAnswer,
|
||||
@@ -3451,9 +3647,9 @@ fun ChatScreen(
|
||||
onAttachmentManualFetch = { msgId, idx ->
|
||||
chatViewModel.manualFetchAttachment(msgId, idx)
|
||||
},
|
||||
onCardAction = handleCardAction,
|
||||
onCardInput = handleCardInput,
|
||||
onSessionReference = { reference ->
|
||||
onCardAction = if (supervised) ({ _, _, _ -> }) else handleCardAction,
|
||||
onCardInput = if (supervised) ({ _, _, _ -> }) else handleCardInput,
|
||||
onSessionReference = if (supervised) null else { reference ->
|
||||
val target = agentProfiles.firstOrNull {
|
||||
it.name.equals(reference.profile, ignoreCase = true)
|
||||
}
|
||||
@@ -3471,6 +3667,7 @@ fun ChatScreen(
|
||||
}
|
||||
},
|
||||
onReact = if (
|
||||
!supervised &&
|
||||
isGatewayTransport &&
|
||||
messageReactionsSupported &&
|
||||
!message.isStreaming &&
|
||||
@@ -3484,6 +3681,7 @@ fun ChatScreen(
|
||||
null
|
||||
},
|
||||
onEditMessage = if (
|
||||
(!supervised || supervisedPolicy.capabilities.editAndResend) &&
|
||||
isGatewayTransport &&
|
||||
!isStreaming &&
|
||||
message.role == MessageRole.USER &&
|
||||
@@ -3502,10 +3700,14 @@ fun ChatScreen(
|
||||
null
|
||||
},
|
||||
animationEnabled = animationEnabled,
|
||||
onQuoteMessage = { quoted ->
|
||||
haptic.performHapticFeedback(HapticFeedbackType.LongPress)
|
||||
quotedMessage = quoted
|
||||
},
|
||||
onQuoteMessage = if (
|
||||
!supervised || supervisedPolicy.capabilities.quoteReplies
|
||||
) {
|
||||
{ quoted ->
|
||||
haptic.performHapticFeedback(HapticFeedbackType.LongPress)
|
||||
quotedMessage = quoted
|
||||
}
|
||||
} else null,
|
||||
onNavigateToMessage = { messageId ->
|
||||
val targetIndex = messages.indexOfFirst { it.id == messageId }
|
||||
if (targetIndex >= 0) {
|
||||
@@ -3516,7 +3718,10 @@ fun ChatScreen(
|
||||
scope.launch { listState.animateScrollToItem(targetIndex + 1) }
|
||||
}
|
||||
},
|
||||
onSpeakMessage = if (chatSpeakResponseActionsEnabled) {
|
||||
onSpeakMessage = if (
|
||||
chatSpeakResponseActionsEnabled &&
|
||||
(!supervised || supervisedPolicy.capabilities.voice)
|
||||
) {
|
||||
{ text -> voiceViewModel.speakResponse(text) }
|
||||
} else {
|
||||
null
|
||||
@@ -3527,6 +3732,9 @@ fun ChatScreen(
|
||||
null
|
||||
},
|
||||
onCopyMessage = { text ->
|
||||
if (supervised && !supervisedPolicy.capabilities.copyResponses) {
|
||||
return@MessageBubble
|
||||
}
|
||||
haptic.performHapticFeedback(HapticFeedbackType.LongPress)
|
||||
// The new Clipboard API is suspend-based, so the
|
||||
// setClipEntry call has to live inside a coroutine.
|
||||
@@ -3995,7 +4203,8 @@ fun ChatScreen(
|
||||
// Gateway redirect is text-only. Attachment-bearing follow-ups must
|
||||
// retain their files in the session-owned queue instead of showing
|
||||
// a correction action that cannot carry them.
|
||||
val canSteerCurrentMessage = steerableTurn && pendingAttachments.isEmpty()
|
||||
val canSteerCurrentMessage = steerableTurn && pendingAttachments.isEmpty() &&
|
||||
(!supervised || supervisedPolicy.capabilities.steerResponse)
|
||||
val trailing = when {
|
||||
!isStreaming && hasContent -> ChatInputTrailing.SEND
|
||||
!isStreaming -> ChatInputTrailing.VOICE
|
||||
@@ -4128,7 +4337,7 @@ fun ChatScreen(
|
||||
}
|
||||
}
|
||||
}
|
||||
val modelControl = modelPickerOptions.takeIf { it.isNotEmpty() }?.let {
|
||||
val modelControl = modelPickerOptions.takeIf { !supervised && it.isNotEmpty() }?.let {
|
||||
ChatInputPickerControl(
|
||||
value = compactModelChipLabel(currentModelForInput, modelDefaultLabel),
|
||||
contentDescription = stringResource(R.string.cd_select_model),
|
||||
@@ -4179,6 +4388,7 @@ fun ChatScreen(
|
||||
// is definitively unreachable (SSE-only) — the agent sheet carries the
|
||||
// disabled-with-reason version there.
|
||||
val effortControl = if (
|
||||
!supervised &&
|
||||
chatGatewayAvailability != GatewayAvailability.Unreachable &&
|
||||
effortAvailability.supported != false &&
|
||||
effortPickerOptions.isNotEmpty()
|
||||
@@ -4195,7 +4405,13 @@ fun ChatScreen(
|
||||
}
|
||||
|
||||
visibleChatFailure?.let { failure ->
|
||||
val failureRouteLabel = when (failure.route) {
|
||||
val displayFailure = if (!supervised) failure else failure.copy(
|
||||
model = failure.model.takeIf { supervisedVisibility.showModelName },
|
||||
provider = failure.provider.takeIf { supervisedVisibility.showTechnicalRoute },
|
||||
)
|
||||
val failureRouteLabel = if (
|
||||
supervised && !supervisedVisibility.showTechnicalRoute
|
||||
) "" else when (failure.route) {
|
||||
ChatFailureRoute.GATEWAY ->
|
||||
stringResource(R.string.chat_failure_route_gateway)
|
||||
ChatFailureRoute.API_FALLBACK ->
|
||||
@@ -4203,23 +4419,28 @@ fun ChatScreen(
|
||||
null -> ""
|
||||
}
|
||||
ChatFailurePanel(
|
||||
failure = failure,
|
||||
failure = displayFailure,
|
||||
routeLabel = failureRouteLabel,
|
||||
onDetails = { showChatFailureDetails = true },
|
||||
onRetry = { chatViewModel.retryLastMessage() },
|
||||
onRetry = {
|
||||
if (!supervised || supervisedPolicy.capabilities.retryResponse) {
|
||||
chatViewModel.retryLastMessage()
|
||||
}
|
||||
},
|
||||
onDismiss = chatViewModel::dismissChatFailure,
|
||||
showDetails = !supervised || supervisedVisibility.showTechnicalRoute,
|
||||
)
|
||||
if (showChatFailureDetails) {
|
||||
ChatFailureDetailsDialog(
|
||||
failure = failure,
|
||||
failure = displayFailure,
|
||||
routeLabel = failureRouteLabel,
|
||||
onCopy = {
|
||||
val details = buildString {
|
||||
append(failureRouteLabel)
|
||||
failure.provider?.takeIf { it.isNotBlank() }?.let { append(" · $it") }
|
||||
failure.model?.takeIf { it.isNotBlank() }?.let { append(" · $it") }
|
||||
displayFailure.provider?.takeIf { it.isNotBlank() }?.let { append(" · $it") }
|
||||
displayFailure.model?.takeIf { it.isNotBlank() }?.let { append(" · $it") }
|
||||
append("\n\n")
|
||||
append(failure.rawError)
|
||||
append(displayFailure.rawError)
|
||||
}
|
||||
scope.launch {
|
||||
clipboard.setClipEntry(
|
||||
@@ -4301,6 +4522,9 @@ fun ChatScreen(
|
||||
)
|
||||
},
|
||||
onStop = {
|
||||
if (supervised && !supervisedPolicy.capabilities.cancelResponse) {
|
||||
return@ChatInputBar
|
||||
}
|
||||
chatViewModel.cancelStream()
|
||||
// Firm haptic (LongPress — TextHandleMove was near-
|
||||
// imperceptible) plus a "Stopped" badge stamped on the turn
|
||||
@@ -4316,14 +4540,44 @@ fun ChatScreen(
|
||||
}
|
||||
},
|
||||
onAttachPhotos = {
|
||||
photoPickerLauncher.launch(
|
||||
PickVisualMediaRequest(ActivityResultContracts.PickVisualMedia.ImageOnly)
|
||||
)
|
||||
val allowed = !supervised || (
|
||||
supervisedPolicy.capabilities.attachments &&
|
||||
SupervisedAttachmentCategory.Images in
|
||||
supervisedPolicy.capabilities.attachmentCategories &&
|
||||
pendingAttachments.size < supervisedPolicy.capabilities.attachmentMaxCount
|
||||
)
|
||||
if (allowed) {
|
||||
photoPickerLauncher.launch(
|
||||
PickVisualMediaRequest(ActivityResultContracts.PickVisualMedia.ImageOnly)
|
||||
)
|
||||
}
|
||||
},
|
||||
onAttachFiles = { filePickerLauncher.launch(arrayOf("*/*")) },
|
||||
onAttachCamera = requestCameraCapture,
|
||||
onPasteImage = pasteImageFromClipboard,
|
||||
onLongPressAttach = { showCommandPalette = true },
|
||||
onAttachFiles = {
|
||||
if (!supervised || supervisedPolicy.capabilities.attachments) {
|
||||
val mimeTypes = if (!supervised) arrayOf("*/*") else buildList {
|
||||
val categories = supervisedPolicy.capabilities.attachmentCategories
|
||||
if (SupervisedAttachmentCategory.Images in categories) add("image/*")
|
||||
if (SupervisedAttachmentCategory.Audio in categories) add("audio/*")
|
||||
if (SupervisedAttachmentCategory.Video in categories) add("video/*")
|
||||
if (SupervisedAttachmentCategory.Documents in categories) {
|
||||
add("text/*")
|
||||
add("application/pdf")
|
||||
}
|
||||
}.toTypedArray()
|
||||
if (mimeTypes.isNotEmpty()) filePickerLauncher.launch(mimeTypes)
|
||||
}
|
||||
},
|
||||
onAttachCamera = if (!supervised || (
|
||||
supervisedPolicy.capabilities.attachments &&
|
||||
SupervisedAttachmentCategory.Images in
|
||||
supervisedPolicy.capabilities.attachmentCategories
|
||||
)) requestCameraCapture else ({ }),
|
||||
onPasteImage = if (!supervised || (
|
||||
supervisedPolicy.capabilities.attachments &&
|
||||
SupervisedAttachmentCategory.Images in
|
||||
supervisedPolicy.capabilities.attachmentCategories
|
||||
)) pasteImageFromClipboard else ({ }),
|
||||
onLongPressAttach = { if (!supervised) showCommandPalette = true },
|
||||
charLimit = charLimit,
|
||||
caption = turnStatus ?: inputCaption,
|
||||
voiceReady = voiceReady,
|
||||
@@ -4335,8 +4589,13 @@ fun ChatScreen(
|
||||
submitEnabled = pendingAttachments.none {
|
||||
it.state == com.hermesandroid.relay.data.AttachmentState.LOADING
|
||||
},
|
||||
largePasteThreshold = LARGE_PASTE_THRESHOLD_CHARS
|
||||
.takeIf { convertLargePastesToAttachments },
|
||||
largePasteThreshold = LARGE_PASTE_THRESHOLD_CHARS.takeIf {
|
||||
convertLargePastesToAttachments && (!supervised || (
|
||||
supervisedPolicy.capabilities.attachments &&
|
||||
SupervisedAttachmentCategory.Documents in
|
||||
supervisedPolicy.capabilities.attachmentCategories
|
||||
))
|
||||
},
|
||||
onLargePaste = { pastedText ->
|
||||
val owner = activeComposerDraftKey ?: composerDraftKey
|
||||
val sizeBytes = pastedText.toByteArray(Charsets.UTF_8).size.toLong()
|
||||
@@ -4654,7 +4913,7 @@ fun ChatScreen(
|
||||
}
|
||||
|
||||
// Command palette bottom sheet
|
||||
if (showCommandPalette) {
|
||||
if (showCommandPalette && !supervised) {
|
||||
CommandPalette(
|
||||
commands = allCommands,
|
||||
onSelect = { cmd ->
|
||||
@@ -4682,7 +4941,7 @@ fun ChatScreen(
|
||||
// personality, connection summary). Replaces the old AlertDialog and the
|
||||
// two top-bar chips (ProfilePicker + PersonalityPicker). Tap target is
|
||||
// the title Row in the TopAppBar above.
|
||||
if (showAgentInfo) {
|
||||
if (showAgentInfo && !supervised) {
|
||||
AgentInfoSheet(
|
||||
connectionViewModel = connectionViewModel,
|
||||
chatViewModel = chatViewModel,
|
||||
|
||||
@@ -0,0 +1,817 @@
|
||||
package com.hermesandroid.relay.ui.screens
|
||||
|
||||
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.rememberScrollState
|
||||
import androidx.compose.foundation.verticalScroll
|
||||
import androidx.compose.material.icons.Icons
|
||||
import androidx.compose.material.icons.automirrored.filled.ArrowBack
|
||||
import androidx.compose.material.icons.filled.AutoAwesome
|
||||
import androidx.compose.material3.AlertDialog
|
||||
import androidx.compose.material3.AssistChip
|
||||
import androidx.compose.material3.Button
|
||||
import androidx.compose.material3.Card
|
||||
import androidx.compose.material3.Checkbox
|
||||
import androidx.compose.material3.CircularProgressIndicator
|
||||
import androidx.compose.material3.ExperimentalMaterial3Api
|
||||
import androidx.compose.material3.Icon
|
||||
import androidx.compose.material3.IconButton
|
||||
import androidx.compose.material3.MaterialTheme
|
||||
import androidx.compose.material3.OutlinedButton
|
||||
import androidx.compose.material3.OutlinedTextField
|
||||
import androidx.compose.material3.Scaffold
|
||||
import androidx.compose.material3.Text
|
||||
import androidx.compose.material3.TextButton
|
||||
import androidx.compose.material3.TopAppBar
|
||||
import androidx.compose.runtime.Composable
|
||||
import androidx.compose.runtime.collectAsState
|
||||
import androidx.compose.runtime.getValue
|
||||
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.res.stringResource
|
||||
import androidx.compose.ui.text.font.FontFamily
|
||||
import androidx.compose.ui.text.font.FontWeight
|
||||
import androidx.compose.ui.unit.dp
|
||||
import com.hermesandroid.relay.R
|
||||
import com.hermesandroid.relay.data.GitBranch
|
||||
import com.hermesandroid.relay.data.GitDiff
|
||||
import com.hermesandroid.relay.data.GitFile
|
||||
import com.hermesandroid.relay.data.GitRepo
|
||||
import com.hermesandroid.relay.data.GitStatus
|
||||
import com.hermesandroid.relay.viewmodel.GitConfirmationStrings
|
||||
import com.hermesandroid.relay.viewmodel.GitContentViewState
|
||||
import com.hermesandroid.relay.viewmodel.GitMessageGenerationState
|
||||
import com.hermesandroid.relay.viewmodel.GitMutationState
|
||||
import com.hermesandroid.relay.viewmodel.GitRepoDetailState
|
||||
import com.hermesandroid.relay.viewmodel.GitStateUiState
|
||||
import com.hermesandroid.relay.viewmodel.GitStateViewModel
|
||||
import com.hermesandroid.relay.viewmodel.GitTarget
|
||||
|
||||
/**
|
||||
* Git State screen (read + write): repo picker → working-tree status/branches →
|
||||
* per-file diff or content. Writes require the ``plugin.api.write`` grant and
|
||||
* destructive ops (discard/push/dirty-checkout) require an explicit per-use
|
||||
* confirmation dialog; the fixed confirmation token is sent only on confirm.
|
||||
*/
|
||||
@OptIn(ExperimentalMaterial3Api::class)
|
||||
@Composable
|
||||
fun GitStateScreen(
|
||||
viewModel: GitStateViewModel,
|
||||
onBack: () -> Unit,
|
||||
) {
|
||||
val reposState by viewModel.repos.collectAsState()
|
||||
val detailState by viewModel.detail.collectAsState()
|
||||
val contentState by viewModel.content.collectAsState()
|
||||
val mutationState by viewModel.mutation.collectAsState()
|
||||
val hasGrant by viewModel.writeGrant.collectAsState()
|
||||
|
||||
// Hoisted at screen level so confirmation/commit dialogs are modal.
|
||||
var pendingConfirm by remember { mutableStateOf<ConfirmationRequest?>(null) }
|
||||
var showCommitDialog by remember { mutableStateOf(false) }
|
||||
var pushAfterCommit by rememberSaveable { mutableStateOf(false) }
|
||||
|
||||
// Staged paths for the AI magic-wand (commit_message_selected) + commit.
|
||||
val stagedPaths = (detailState as? GitRepoDetailState.Ready)
|
||||
?.status?.staged?.map { it.path } ?: emptyList()
|
||||
|
||||
val messageGenerationState by viewModel.messageGeneration.collectAsState()
|
||||
val stashNotice by viewModel.stashNotice.collectAsState()
|
||||
|
||||
Scaffold(
|
||||
topBar = {
|
||||
TopAppBar(
|
||||
title = { Text(stringResource(R.string.git_state_title)) },
|
||||
navigationIcon = {
|
||||
IconButton(onClick = onBack) {
|
||||
Icon(
|
||||
Icons.AutoMirrored.Filled.ArrowBack,
|
||||
stringResource(R.string.git_state_back),
|
||||
)
|
||||
}
|
||||
},
|
||||
)
|
||||
},
|
||||
) { padding ->
|
||||
Column(
|
||||
Modifier
|
||||
.fillMaxSize()
|
||||
.padding(padding)
|
||||
.padding(16.dp),
|
||||
verticalArrangement = Arrangement.spacedBy(16.dp),
|
||||
) {
|
||||
val repos = reposState
|
||||
when (repos) {
|
||||
GitStateUiState.Loading -> CenteredSpinner()
|
||||
is GitStateUiState.Error -> ErrorText(repos.message)
|
||||
is GitStateUiState.Ready -> {
|
||||
repos.notice?.let { ErrorText(it, warning = true) }
|
||||
RepoPicker(
|
||||
repos = repos.repos,
|
||||
selectedId = viewModel.selectedRepoIdForDisplay(),
|
||||
onSelect = viewModel::selectRepo,
|
||||
)
|
||||
when (val current = detailState) {
|
||||
GitRepoDetailState.Idle -> Unit
|
||||
GitRepoDetailState.Loading -> CenteredSpinner()
|
||||
is GitRepoDetailState.Error -> ErrorText(current.message)
|
||||
is GitRepoDetailState.Ready -> {
|
||||
MutationBanner(
|
||||
mutation = mutationState,
|
||||
onClear = viewModel::clearMutationError,
|
||||
)
|
||||
stashNotice?.let { notice ->
|
||||
Card(Modifier.fillMaxWidth()) {
|
||||
Text(
|
||||
notice,
|
||||
modifier = Modifier.padding(16.dp),
|
||||
color = MaterialTheme.colorScheme.primary,
|
||||
style = MaterialTheme.typography.bodyMedium,
|
||||
)
|
||||
}
|
||||
}
|
||||
if (!hasGrant) {
|
||||
WriteGrantNotice()
|
||||
}
|
||||
RepoDetail(
|
||||
status = current.status,
|
||||
branches = current.branches,
|
||||
hasGrant = hasGrant,
|
||||
onShowDiff = viewModel::loadDiff,
|
||||
onShowFile = viewModel::loadFile,
|
||||
onStage = { path -> viewModel.stage(listOf(path)) },
|
||||
onUnstage = { path -> viewModel.unstage(listOf(path)) },
|
||||
onDiscard = { paths, deleteUntracked ->
|
||||
viewModel.currentTarget()?.let { target ->
|
||||
pendingConfirm = ConfirmationRequest.Discard(paths, deleteUntracked, target)
|
||||
}
|
||||
},
|
||||
onCommitRequest = { showCommitDialog = true },
|
||||
onFetch = { viewModel.fetch() },
|
||||
onPull = { viewModel.pull() },
|
||||
onPush = {
|
||||
viewModel.currentTarget()?.let { target ->
|
||||
pendingConfirm = ConfirmationRequest.Push(target)
|
||||
}
|
||||
},
|
||||
onSwitchBranch = { ref ->
|
||||
val dirty = current.status.counts.staged > 0 ||
|
||||
current.status.counts.modified > 0 ||
|
||||
current.status.counts.untracked > 0
|
||||
if (dirty) {
|
||||
viewModel.currentTarget()?.let { target ->
|
||||
pendingConfirm = ConfirmationRequest.DirtyCheckout(ref, target)
|
||||
}
|
||||
} else {
|
||||
viewModel.checkout(ref)
|
||||
}
|
||||
},
|
||||
onStashSwitchBranch = { ref ->
|
||||
viewModel.stashCheckout(ref)
|
||||
},
|
||||
onCreateBranch = { name, track ->
|
||||
viewModel.checkout("", newBranch = name, track = track)
|
||||
},
|
||||
)
|
||||
}
|
||||
}
|
||||
ContentView(state = contentState)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
if (showCommitDialog) {
|
||||
CommitDialog(
|
||||
onDismiss = { showCommitDialog = false },
|
||||
hasStaged = stagedPaths.isNotEmpty(),
|
||||
generatingMessage = messageGenerationState is GitMessageGenerationState.Loading,
|
||||
onGenerate = {
|
||||
viewModel.generateCommitMessage(
|
||||
if (stagedPaths.isNotEmpty()) stagedPaths else null,
|
||||
)
|
||||
},
|
||||
generatedMessage = (messageGenerationState as? GitMessageGenerationState.Ready)?.message ?: "",
|
||||
generationNotice = (messageGenerationState as? GitMessageGenerationState.Ready)?.notice,
|
||||
pushAfterCommit = pushAfterCommit,
|
||||
onPushAfterCommitChange = { pushAfterCommit = it },
|
||||
onCommit = { message ->
|
||||
showCommitDialog = false
|
||||
viewModel.commit(message) { committedTarget ->
|
||||
if (pushAfterCommit) {
|
||||
pendingConfirm = ConfirmationRequest.Push(committedTarget)
|
||||
}
|
||||
}
|
||||
},
|
||||
)
|
||||
}
|
||||
|
||||
pendingConfirm?.let { request ->
|
||||
val onDismiss = { pendingConfirm = null }
|
||||
when (request) {
|
||||
is ConfirmationRequest.Discard -> AlertDialog(
|
||||
onDismissRequest = onDismiss,
|
||||
title = { Text(stringResource(R.string.git_state_confirm_discard_title)) },
|
||||
text = { Text(stringResource(R.string.git_state_confirm_discard_text)) },
|
||||
confirmButton = {
|
||||
TextButton(onClick = {
|
||||
pendingConfirm = null
|
||||
viewModel.discard(
|
||||
request.paths,
|
||||
GitConfirmationStrings.DISCARD,
|
||||
request.deleteUntracked,
|
||||
request.target,
|
||||
)
|
||||
}) {
|
||||
Text(stringResource(R.string.git_state_confirm_discard_confirm))
|
||||
}
|
||||
},
|
||||
dismissButton = {
|
||||
TextButton(onClick = onDismiss) {
|
||||
Text(stringResource(R.string.git_state_cancel))
|
||||
}
|
||||
},
|
||||
)
|
||||
is ConfirmationRequest.Push -> AlertDialog(
|
||||
onDismissRequest = onDismiss,
|
||||
title = { Text(stringResource(R.string.git_state_confirm_push_title)) },
|
||||
text = { Text(stringResource(R.string.git_state_confirm_push_text)) },
|
||||
confirmButton = {
|
||||
TextButton(onClick = {
|
||||
pendingConfirm = null
|
||||
viewModel.push(
|
||||
GitConfirmationStrings.PUSH,
|
||||
expectedTarget = request.target,
|
||||
)
|
||||
}) {
|
||||
Text(stringResource(R.string.git_state_confirm_push_confirm))
|
||||
}
|
||||
},
|
||||
dismissButton = {
|
||||
TextButton(onClick = onDismiss) {
|
||||
Text(stringResource(R.string.git_state_cancel))
|
||||
}
|
||||
},
|
||||
)
|
||||
is ConfirmationRequest.DirtyCheckout -> AlertDialog(
|
||||
onDismissRequest = onDismiss,
|
||||
title = { Text(stringResource(R.string.git_state_confirm_checkout_dirty_title)) },
|
||||
text = { Text(stringResource(R.string.git_state_confirm_checkout_dirty_text)) },
|
||||
confirmButton = {
|
||||
TextButton(onClick = {
|
||||
pendingConfirm = null
|
||||
viewModel.checkout(
|
||||
request.ref,
|
||||
GitConfirmationStrings.DIRTY_CHECKOUT,
|
||||
expectedTarget = request.target,
|
||||
)
|
||||
}) {
|
||||
Text(stringResource(R.string.git_state_confirm_checkout_confirm))
|
||||
}
|
||||
},
|
||||
dismissButton = {
|
||||
TextButton(onClick = onDismiss) {
|
||||
Text(stringResource(R.string.git_state_cancel))
|
||||
}
|
||||
},
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/** A destructive action awaiting explicit user confirmation. */
|
||||
private sealed interface ConfirmationRequest {
|
||||
data class Discard(
|
||||
val paths: List<String>,
|
||||
val deleteUntracked: Boolean,
|
||||
val target: GitTarget,
|
||||
) : ConfirmationRequest
|
||||
|
||||
data class Push(val target: GitTarget) : ConfirmationRequest
|
||||
data class DirtyCheckout(val ref: String, val target: GitTarget) : ConfirmationRequest
|
||||
}
|
||||
|
||||
@Composable
|
||||
private fun CommitDialog(
|
||||
onDismiss: () -> Unit,
|
||||
hasStaged: Boolean,
|
||||
generatingMessage: Boolean,
|
||||
onGenerate: () -> Unit,
|
||||
generatedMessage: String,
|
||||
generationNotice: String?,
|
||||
pushAfterCommit: Boolean,
|
||||
onPushAfterCommitChange: (Boolean) -> Unit,
|
||||
onCommit: (String) -> Unit,
|
||||
) {
|
||||
var message by rememberSaveable { mutableStateOf("") }
|
||||
// Pre-fill with the latest generated suggestion when it arrives.
|
||||
if (generatedMessage.isNotEmpty() && message.isBlank()) {
|
||||
message = generatedMessage
|
||||
}
|
||||
AlertDialog(
|
||||
onDismissRequest = onDismiss,
|
||||
title = { Text(stringResource(R.string.git_state_commit_title)) },
|
||||
text = {
|
||||
Column(verticalArrangement = Arrangement.spacedBy(8.dp)) {
|
||||
OutlinedTextField(
|
||||
value = message,
|
||||
onValueChange = { message = it },
|
||||
label = { Text(stringResource(R.string.git_state_commit_message_hint)) },
|
||||
modifier = Modifier.fillMaxWidth(),
|
||||
singleLine = false,
|
||||
trailingIcon = {
|
||||
IconButton(onClick = onGenerate, enabled = hasStaged && !generatingMessage) {
|
||||
Icon(
|
||||
Icons.Filled.AutoAwesome,
|
||||
stringResource(R.string.git_state_generate_message),
|
||||
)
|
||||
}
|
||||
},
|
||||
)
|
||||
if (generatingMessage) {
|
||||
Text(
|
||||
stringResource(R.string.git_state_generating_message),
|
||||
style = MaterialTheme.typography.bodySmall,
|
||||
color = MaterialTheme.colorScheme.primary,
|
||||
)
|
||||
}
|
||||
generationNotice?.let { notice ->
|
||||
Text(
|
||||
notice,
|
||||
style = MaterialTheme.typography.bodySmall,
|
||||
color = MaterialTheme.colorScheme.error,
|
||||
)
|
||||
}
|
||||
Row(verticalAlignment = Alignment.CenterVertically) {
|
||||
Checkbox(checked = pushAfterCommit, onCheckedChange = onPushAfterCommitChange)
|
||||
Text(stringResource(R.string.git_state_push_after_commit))
|
||||
}
|
||||
}
|
||||
},
|
||||
confirmButton = {
|
||||
TextButton(
|
||||
onClick = { onCommit(message) },
|
||||
enabled = message.isNotBlank(),
|
||||
) {
|
||||
Text(stringResource(R.string.git_state_commit_confirm))
|
||||
}
|
||||
},
|
||||
dismissButton = {
|
||||
TextButton(onClick = onDismiss) {
|
||||
Text(stringResource(R.string.git_state_cancel))
|
||||
}
|
||||
},
|
||||
)
|
||||
}
|
||||
|
||||
@Composable
|
||||
private fun RepoPicker(
|
||||
repos: List<GitRepo>,
|
||||
selectedId: String?,
|
||||
onSelect: (String) -> Unit,
|
||||
) {
|
||||
Row(
|
||||
Modifier
|
||||
.fillMaxWidth()
|
||||
.horizontalScroll(rememberScrollState()),
|
||||
horizontalArrangement = Arrangement.spacedBy(8.dp),
|
||||
) {
|
||||
repos.forEach { repo ->
|
||||
AssistChip(
|
||||
onClick = { onSelect(repo.id) },
|
||||
label = {
|
||||
Text(
|
||||
if (repo.dirty) "${repo.name} •" else repo.name,
|
||||
fontWeight = if (repo.id == selectedId) FontWeight.Bold else FontWeight.Normal,
|
||||
)
|
||||
},
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@Composable
|
||||
private fun MutationBanner(
|
||||
mutation: GitMutationState,
|
||||
onClear: () -> Unit,
|
||||
) {
|
||||
when (mutation) {
|
||||
GitMutationState.Idle -> Unit
|
||||
is GitMutationState.InProgress -> Card(Modifier.fillMaxWidth()) {
|
||||
Row(
|
||||
Modifier.padding(16.dp),
|
||||
horizontalArrangement = Arrangement.spacedBy(12.dp),
|
||||
verticalAlignment = Alignment.CenterVertically,
|
||||
) {
|
||||
CircularProgressIndicator(strokeWidth = 2.dp)
|
||||
Text(
|
||||
stringResource(R.string.git_state_mutation_in_progress, displayLabel(mutation.label)),
|
||||
style = MaterialTheme.typography.bodyMedium,
|
||||
)
|
||||
}
|
||||
}
|
||||
is GitMutationState.Success -> Card(Modifier.fillMaxWidth()) {
|
||||
Text(
|
||||
stringResource(
|
||||
R.string.git_state_mutation_success,
|
||||
displayLabel(mutation.label),
|
||||
mutation.head,
|
||||
),
|
||||
modifier = Modifier.padding(16.dp),
|
||||
color = MaterialTheme.colorScheme.primary,
|
||||
style = MaterialTheme.typography.bodyMedium,
|
||||
)
|
||||
}
|
||||
is GitMutationState.Error -> Card(Modifier.fillMaxWidth()) {
|
||||
Row(
|
||||
Modifier.padding(16.dp),
|
||||
horizontalArrangement = Arrangement.spacedBy(8.dp),
|
||||
verticalAlignment = Alignment.CenterVertically,
|
||||
) {
|
||||
Text(
|
||||
stringResource(
|
||||
R.string.git_state_mutation_failed,
|
||||
displayLabel(mutation.label),
|
||||
mutation.message,
|
||||
),
|
||||
modifier = Modifier.weight(1f),
|
||||
color = MaterialTheme.colorScheme.error,
|
||||
style = MaterialTheme.typography.bodyMedium,
|
||||
)
|
||||
TextButton(onClick = onClear) {
|
||||
Text(stringResource(R.string.git_state_cancel))
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
private fun displayLabel(label: String): String =
|
||||
label.replaceFirstChar { if (it.isLowerCase()) it.titlecase() else it.toString() }
|
||||
|
||||
@Composable
|
||||
private fun WriteGrantNotice() {
|
||||
Card(Modifier.fillMaxWidth()) {
|
||||
Text(
|
||||
stringResource(R.string.git_state_write_grant_required),
|
||||
modifier = Modifier.padding(16.dp),
|
||||
color = MaterialTheme.colorScheme.tertiary,
|
||||
style = MaterialTheme.typography.bodyMedium,
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
@Composable
|
||||
private fun RepoDetail(
|
||||
status: GitStatus,
|
||||
branches: List<GitBranch>,
|
||||
hasGrant: Boolean,
|
||||
onShowDiff: (String, String) -> Unit,
|
||||
onShowFile: (String) -> Unit,
|
||||
onStage: (String) -> Unit,
|
||||
onUnstage: (String) -> Unit,
|
||||
onDiscard: (List<String>, Boolean) -> Unit,
|
||||
onCommitRequest: () -> Unit,
|
||||
onFetch: () -> Unit,
|
||||
onPull: () -> Unit,
|
||||
onPush: () -> Unit,
|
||||
onSwitchBranch: (String) -> Unit,
|
||||
onStashSwitchBranch: (String) -> Unit,
|
||||
onCreateBranch: (String, Boolean) -> Unit,
|
||||
) {
|
||||
Column(verticalArrangement = Arrangement.spacedBy(16.dp)) {
|
||||
Card(Modifier.fillMaxWidth()) {
|
||||
Column(Modifier.padding(16.dp), verticalArrangement = Arrangement.spacedBy(8.dp)) {
|
||||
Text(
|
||||
"${stringResource(R.string.git_state_staged)} ${status.counts.staged} · " +
|
||||
"${stringResource(R.string.git_state_modified)} ${status.counts.modified} · " +
|
||||
"${stringResource(R.string.git_state_untracked)} ${status.counts.untracked}",
|
||||
style = MaterialTheme.typography.labelLarge,
|
||||
)
|
||||
if (status.truncated) {
|
||||
Text(
|
||||
stringResource(R.string.git_state_truncated),
|
||||
color = MaterialTheme.colorScheme.error,
|
||||
style = MaterialTheme.typography.bodySmall,
|
||||
)
|
||||
}
|
||||
status.staged.takeIf { it.isNotEmpty() }?.let { staged ->
|
||||
GroupHeader(stringResource(R.string.git_state_staged))
|
||||
staged.forEach { file ->
|
||||
StatusRow(
|
||||
path = file.path,
|
||||
primaryLabel = stringResource(R.string.git_state_unstage),
|
||||
onPrimary = { onUnstage(file.path) },
|
||||
secondaryLabel = stringResource(R.string.git_state_discard),
|
||||
onSecondary = { onDiscard(listOf(file.path), false) },
|
||||
onOpen = { onShowDiff(file.path, "staged") },
|
||||
enabled = hasGrant,
|
||||
)
|
||||
}
|
||||
}
|
||||
status.modified.takeIf { it.isNotEmpty() }?.let { modified ->
|
||||
GroupHeader(stringResource(R.string.git_state_modified))
|
||||
modified.forEach { file ->
|
||||
StatusRow(
|
||||
path = file.path,
|
||||
primaryLabel = stringResource(R.string.git_state_stage),
|
||||
onPrimary = { onStage(file.path) },
|
||||
secondaryLabel = stringResource(R.string.git_state_discard),
|
||||
onSecondary = { onDiscard(listOf(file.path), false) },
|
||||
onOpen = { onShowDiff(file.path, "unstaged") },
|
||||
enabled = hasGrant,
|
||||
)
|
||||
}
|
||||
}
|
||||
status.untracked.takeIf { it.isNotEmpty() }?.let { untracked ->
|
||||
GroupHeader(stringResource(R.string.git_state_untracked))
|
||||
untracked.forEach { file ->
|
||||
StatusRow(
|
||||
path = file.path,
|
||||
primaryLabel = stringResource(R.string.git_state_stage),
|
||||
onPrimary = { onStage(file.path) },
|
||||
secondaryLabel = stringResource(R.string.git_state_discard),
|
||||
onSecondary = { onDiscard(listOf(file.path), true) },
|
||||
onOpen = { onShowFile(file.path) },
|
||||
enabled = hasGrant,
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
// Commit + sync controls (writes; all gated by the grant).
|
||||
Row(
|
||||
Modifier
|
||||
.fillMaxWidth()
|
||||
.padding(top = 8.dp),
|
||||
horizontalArrangement = Arrangement.spacedBy(8.dp),
|
||||
) {
|
||||
OutlinedButton(
|
||||
onClick = onFetch,
|
||||
enabled = hasGrant && status.counts.untracked == 0,
|
||||
) {
|
||||
Text(stringResource(R.string.git_state_fetch))
|
||||
}
|
||||
OutlinedButton(onClick = onPull, enabled = hasGrant) {
|
||||
Text(stringResource(R.string.git_state_pull))
|
||||
}
|
||||
OutlinedButton(
|
||||
onClick = onPush,
|
||||
enabled = hasGrant && status.counts.staged == 0,
|
||||
) {
|
||||
Text(stringResource(R.string.git_state_push))
|
||||
}
|
||||
}
|
||||
Button(
|
||||
onClick = onCommitRequest,
|
||||
enabled = hasGrant && status.counts.staged > 0,
|
||||
modifier = Modifier.fillMaxWidth(),
|
||||
) {
|
||||
Text(stringResource(R.string.git_state_commit))
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
if (branches.isNotEmpty()) {
|
||||
BranchCard(
|
||||
branches = branches,
|
||||
hasGrant = hasGrant,
|
||||
onSwitch = onSwitchBranch,
|
||||
onStashSwitch = onStashSwitchBranch,
|
||||
onCreate = onCreateBranch,
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@Composable
|
||||
private fun StatusRow(
|
||||
path: String,
|
||||
primaryLabel: String,
|
||||
onPrimary: () -> Unit,
|
||||
secondaryLabel: String,
|
||||
onSecondary: () -> Unit,
|
||||
onOpen: () -> Unit,
|
||||
enabled: Boolean,
|
||||
) {
|
||||
Row(
|
||||
Modifier
|
||||
.fillMaxWidth()
|
||||
.padding(vertical = 4.dp),
|
||||
horizontalArrangement = Arrangement.spacedBy(4.dp),
|
||||
verticalAlignment = Alignment.CenterVertically,
|
||||
) {
|
||||
TextButton(onClick = onOpen, modifier = Modifier.weight(1f)) {
|
||||
Text(
|
||||
path,
|
||||
fontFamily = FontFamily.Monospace,
|
||||
maxLines = 1,
|
||||
)
|
||||
}
|
||||
TextButton(onClick = onPrimary, enabled = enabled) {
|
||||
Text(primaryLabel)
|
||||
}
|
||||
TextButton(onClick = onSecondary, enabled = enabled) {
|
||||
Text(secondaryLabel)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@Composable
|
||||
private fun BranchCard(
|
||||
branches: List<GitBranch>,
|
||||
hasGrant: Boolean,
|
||||
onSwitch: (String) -> Unit,
|
||||
onStashSwitch: (String) -> Unit,
|
||||
onCreate: (String, Boolean) -> Unit,
|
||||
) {
|
||||
var newBranchName by rememberSaveable { mutableStateOf("") }
|
||||
var track by rememberSaveable { mutableStateOf(false) }
|
||||
Card(Modifier.fillMaxWidth()) {
|
||||
Column(Modifier.padding(16.dp), verticalArrangement = Arrangement.spacedBy(8.dp)) {
|
||||
Text(
|
||||
stringResource(R.string.git_state_branches),
|
||||
style = MaterialTheme.typography.titleSmall,
|
||||
)
|
||||
branches.forEach { branch ->
|
||||
Row(
|
||||
Modifier.fillMaxWidth(),
|
||||
horizontalArrangement = Arrangement.spacedBy(8.dp),
|
||||
verticalAlignment = Alignment.CenterVertically,
|
||||
) {
|
||||
Text(
|
||||
branchLabel(branch),
|
||||
modifier = Modifier.weight(1f),
|
||||
style = MaterialTheme.typography.bodySmall,
|
||||
fontFamily = FontFamily.Monospace,
|
||||
)
|
||||
if (branch.isCurrent) {
|
||||
Text(
|
||||
stringResource(R.string.git_state_current),
|
||||
color = MaterialTheme.colorScheme.primary,
|
||||
style = MaterialTheme.typography.labelSmall,
|
||||
)
|
||||
} else {
|
||||
OutlinedButton(
|
||||
onClick = { onSwitch(branch.name) },
|
||||
enabled = hasGrant,
|
||||
) {
|
||||
Text(stringResource(R.string.git_state_switch))
|
||||
}
|
||||
OutlinedButton(
|
||||
onClick = { onStashSwitch(branch.name) },
|
||||
enabled = hasGrant,
|
||||
) {
|
||||
Text(stringResource(R.string.git_state_switch_stash))
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// Create a new branch (optionally tracking the remote).
|
||||
Row(
|
||||
Modifier.fillMaxWidth(),
|
||||
horizontalArrangement = Arrangement.spacedBy(8.dp),
|
||||
verticalAlignment = Alignment.CenterVertically,
|
||||
) {
|
||||
OutlinedTextField(
|
||||
value = newBranchName,
|
||||
onValueChange = { newBranchName = it },
|
||||
label = { Text(stringResource(R.string.git_state_new_branch_hint)) },
|
||||
singleLine = true,
|
||||
modifier = Modifier.weight(1f),
|
||||
)
|
||||
Button(
|
||||
onClick = {
|
||||
val name = newBranchName.trim()
|
||||
if (name.isNotEmpty()) {
|
||||
onCreate(name, track)
|
||||
newBranchName = ""
|
||||
track = false
|
||||
}
|
||||
},
|
||||
enabled = hasGrant && newBranchName.isNotBlank(),
|
||||
) {
|
||||
Text(stringResource(R.string.git_state_create_branch))
|
||||
}
|
||||
}
|
||||
Row(verticalAlignment = Alignment.CenterVertically) {
|
||||
Checkbox(checked = track, onCheckedChange = { track = it }, enabled = hasGrant)
|
||||
Text(stringResource(R.string.git_state_track_remote))
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
private fun branchLabel(branch: GitBranch): String {
|
||||
val base = branch.name
|
||||
if (branch.upstream == null) return base
|
||||
val track =
|
||||
if (branch.ahead > 0 || branch.behind > 0) {
|
||||
" (ahead ${branch.ahead}, behind ${branch.behind})"
|
||||
} else {
|
||||
""
|
||||
}
|
||||
return "$base → ${branch.upstream}$track"
|
||||
}
|
||||
|
||||
@Composable
|
||||
private fun GroupHeader(label: String) {
|
||||
Text(
|
||||
label,
|
||||
style = MaterialTheme.typography.labelSmall,
|
||||
color = MaterialTheme.colorScheme.onSurfaceVariant,
|
||||
modifier = Modifier.padding(top = 4.dp),
|
||||
)
|
||||
}
|
||||
|
||||
@Composable
|
||||
private fun ContentView(state: GitContentViewState) {
|
||||
when (state) {
|
||||
GitContentViewState.Idle -> Unit
|
||||
GitContentViewState.Loading -> CenteredSpinner()
|
||||
is GitContentViewState.Error -> ErrorText(state.message)
|
||||
is GitContentViewState.Diff -> MonospaceBlock(state.diff)
|
||||
is GitContentViewState.File -> MonospaceBlock(state.file)
|
||||
}
|
||||
}
|
||||
|
||||
@Composable
|
||||
private fun MonospaceBlock(diff: GitDiff) {
|
||||
Card(Modifier.fillMaxWidth()) {
|
||||
Column(Modifier.padding(16.dp), verticalArrangement = Arrangement.spacedBy(8.dp)) {
|
||||
Text(
|
||||
"${diff.path} (${diff.kind})",
|
||||
style = MaterialTheme.typography.labelMedium,
|
||||
fontFamily = FontFamily.Monospace,
|
||||
)
|
||||
if (diff.truncated) {
|
||||
Text(
|
||||
stringResource(R.string.git_state_truncated),
|
||||
color = MaterialTheme.colorScheme.error,
|
||||
style = MaterialTheme.typography.bodySmall,
|
||||
)
|
||||
}
|
||||
Text(
|
||||
diff.diff.ifEmpty { stringResource(R.string.git_state_no_changes) },
|
||||
style = MaterialTheme.typography.bodySmall,
|
||||
fontFamily = FontFamily.Monospace,
|
||||
modifier = Modifier
|
||||
.fillMaxWidth()
|
||||
.verticalScroll(rememberScrollState()),
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@Composable
|
||||
private fun MonospaceBlock(file: GitFile) {
|
||||
Card(Modifier.fillMaxWidth()) {
|
||||
Column(Modifier.padding(16.dp), verticalArrangement = Arrangement.spacedBy(8.dp)) {
|
||||
Text(
|
||||
file.path,
|
||||
style = MaterialTheme.typography.labelMedium,
|
||||
fontFamily = FontFamily.Monospace,
|
||||
)
|
||||
if (file.truncated) {
|
||||
Text(
|
||||
stringResource(R.string.git_state_truncated),
|
||||
color = MaterialTheme.colorScheme.error,
|
||||
style = MaterialTheme.typography.bodySmall,
|
||||
)
|
||||
}
|
||||
Text(
|
||||
file.content,
|
||||
style = MaterialTheme.typography.bodySmall,
|
||||
fontFamily = FontFamily.Monospace,
|
||||
modifier = Modifier
|
||||
.fillMaxWidth()
|
||||
.verticalScroll(rememberScrollState()),
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@Composable
|
||||
private fun CenteredSpinner() {
|
||||
Box(Modifier.fillMaxWidth(), contentAlignment = Alignment.Center) {
|
||||
CircularProgressIndicator()
|
||||
}
|
||||
}
|
||||
|
||||
@Composable
|
||||
private fun ErrorText(message: String, warning: Boolean = false) {
|
||||
Text(
|
||||
message,
|
||||
color = if (warning) MaterialTheme.colorScheme.tertiary else MaterialTheme.colorScheme.error,
|
||||
style = MaterialTheme.typography.bodyMedium,
|
||||
)
|
||||
}
|
||||
@@ -101,6 +101,7 @@ import com.hermesandroid.relay.data.Profile
|
||||
import com.hermesandroid.relay.data.ProviderUsageLandingMode
|
||||
import com.hermesandroid.relay.data.ProviderUsagePreferences
|
||||
import com.hermesandroid.relay.data.ProviderUsagePreferencesRepository
|
||||
import com.hermesandroid.relay.data.SupervisedModePolicy
|
||||
import com.hermesandroid.relay.network.usage.ProviderUsageRepository
|
||||
import com.hermesandroid.relay.network.usage.ProviderUsageResponse
|
||||
import com.hermesandroid.relay.network.upstream.GatewayAvailability
|
||||
@@ -157,6 +158,14 @@ fun SettingsScreen(
|
||||
connectionViewModel: ConnectionViewModel,
|
||||
/** Header back affordance — Settings is a pushed destination, not a tab. */
|
||||
onBack: (() -> Unit)? = null,
|
||||
supervisedPolicy: SupervisedModePolicy? = null,
|
||||
parentAccessUnlocked: Boolean = false,
|
||||
/** Called only after the restricted surface completes device authentication. */
|
||||
onRequestParentAccess: () -> Unit = {},
|
||||
onUpdateSupervisedPolicy: (SupervisedModePolicy) -> Unit = {},
|
||||
onNavigateToAdvancedSettings: () -> Unit = {},
|
||||
onNavigateToSupervisedAppearance: () -> Unit = {},
|
||||
onNavigateToSupervisedControls: () -> Unit = {},
|
||||
// Needed by the Active Agent summary card at the top of the screen — it
|
||||
// reads the current personality pick so the subtitle can render
|
||||
// `connection · model · personality` without re-reading ChatViewModel
|
||||
@@ -206,6 +215,21 @@ fun SettingsScreen(
|
||||
// discoverable before a pair-and-pick happens.
|
||||
onNavigateToProfileInspector: (profileName: String) -> Unit,
|
||||
) {
|
||||
// Keep the restricted root when an enabled policy becomes temporarily
|
||||
// unusable (for example, its profile was renamed). Parent authentication,
|
||||
// not a configuration error, is what unlocks the full settings surface.
|
||||
if (supervisedPolicy?.enabled == true && !parentAccessUnlocked) {
|
||||
SupervisedSettingsScreen(
|
||||
connectionViewModel = connectionViewModel,
|
||||
policy = supervisedPolicy,
|
||||
onPolicyChange = onUpdateSupervisedPolicy,
|
||||
onBack = onBack,
|
||||
onNavigateToAppearance = onNavigateToSupervisedAppearance,
|
||||
onParentAccessGranted = onRequestParentAccess,
|
||||
)
|
||||
return
|
||||
}
|
||||
|
||||
val context = LocalContext.current
|
||||
val isDarkTheme = LocalBrand.current.isDark
|
||||
|
||||
@@ -432,6 +456,20 @@ fun SettingsScreen(
|
||||
.padding(horizontal = 16.dp, vertical = 16.dp),
|
||||
verticalArrangement = Arrangement.spacedBy(12.dp),
|
||||
) {
|
||||
if (supervisedPolicy?.enabled == true && parentAccessUnlocked) {
|
||||
SettingsCategoryRow(
|
||||
icon = Icons.Filled.Security,
|
||||
title = "Supervised mode",
|
||||
subtitle = "On · ${supervisedPolicy.pinnedProfileName.orEmpty()}",
|
||||
badge = SettingsStatusPillModel(
|
||||
label = "On",
|
||||
tone = SettingsStatusTone.Good,
|
||||
),
|
||||
onClick = onNavigateToSupervisedControls,
|
||||
isDarkTheme = isDarkTheme,
|
||||
)
|
||||
}
|
||||
|
||||
// ── Active Agent summary ───────────────────────────────────
|
||||
// Mirrors the ChatScreen TopAppBar title block (avatar + name
|
||||
// + one-line `connection · model · personality` subtitle).
|
||||
@@ -497,6 +535,7 @@ fun SettingsScreen(
|
||||
modifier = Modifier.settingsPetSurface("settings-card:profile-lock"),
|
||||
)
|
||||
|
||||
|
||||
// ── Quick Controls ─────────────────────────────────────────
|
||||
// The switches flipped most often, pinned to the top-level Settings
|
||||
// landing instead of buried in a sub-screen. Persistent connection is
|
||||
@@ -666,6 +705,24 @@ fun SettingsScreen(
|
||||
isDarkTheme = isDarkTheme,
|
||||
)
|
||||
|
||||
SettingsCategoryRow(
|
||||
icon = Icons.Filled.Security,
|
||||
title = stringResource(R.string.settings_advanced),
|
||||
subtitle = when {
|
||||
supervisedPolicy?.isActive == true -> "On · ${supervisedPolicy.pinnedProfileName}"
|
||||
supervisedPolicy?.isConfigured == true -> "Ready · ${supervisedPolicy.pinnedProfileName}"
|
||||
else -> stringResource(R.string.settings_advanced_desc)
|
||||
},
|
||||
badge = supervisedPolicy?.takeIf { it.isActive }?.let {
|
||||
SettingsStatusPillModel(
|
||||
label = "On",
|
||||
tone = SettingsStatusTone.Good,
|
||||
)
|
||||
},
|
||||
onClick = onNavigateToAdvancedSettings,
|
||||
isDarkTheme = isDarkTheme,
|
||||
)
|
||||
|
||||
SettingsCategoryRow(
|
||||
icon = Icons.Filled.Analytics,
|
||||
title = stringResource(R.string.settings_analytics),
|
||||
@@ -1280,12 +1337,12 @@ private fun ProfileLockOptionRow(
|
||||
}
|
||||
}
|
||||
|
||||
private data class SettingsStatusPillModel(
|
||||
internal data class SettingsStatusPillModel(
|
||||
val label: String,
|
||||
val tone: SettingsStatusTone = SettingsStatusTone.Neutral,
|
||||
)
|
||||
|
||||
private enum class SettingsStatusTone {
|
||||
internal enum class SettingsStatusTone {
|
||||
Neutral,
|
||||
Good,
|
||||
Info,
|
||||
@@ -1483,18 +1540,22 @@ private fun SettingsSectionHeader(
|
||||
* mega-SettingsScreen.
|
||||
*/
|
||||
@Composable
|
||||
private fun SettingsCategoryRow(
|
||||
internal fun SettingsCategoryRow(
|
||||
icon: ImageVector,
|
||||
title: String,
|
||||
subtitle: String,
|
||||
onClick: () -> Unit,
|
||||
isDarkTheme: Boolean,
|
||||
badge: SettingsStatusPillModel? = null,
|
||||
petPerchKey: String = title,
|
||||
petPerchKey: String? = title,
|
||||
) {
|
||||
val surfaceModifier = if (petPerchKey != null) {
|
||||
Modifier.settingsPetSurface("settings-category:$petPerchKey")
|
||||
} else {
|
||||
Modifier
|
||||
}
|
||||
Card(
|
||||
modifier = Modifier
|
||||
.settingsPetSurface("settings-category:$petPerchKey")
|
||||
modifier = surfaceModifier
|
||||
.fillMaxWidth()
|
||||
.gradientBorder(
|
||||
shape = appearanceRoundedCornerShape(12.dp),
|
||||
|
||||
File diff suppressed because it is too large
Load Diff
@@ -49,6 +49,10 @@ import com.hermesandroid.relay.data.SessionActivityScope
|
||||
import com.hermesandroid.relay.data.SessionActivityUpdate
|
||||
import com.hermesandroid.relay.data.SessionLiveRuntime
|
||||
import com.hermesandroid.relay.data.SessionLiveStatus
|
||||
import com.hermesandroid.relay.data.SupervisedAttachmentCategory
|
||||
import com.hermesandroid.relay.data.SupervisedModePolicy
|
||||
import com.hermesandroid.relay.data.SupervisedSessionAction
|
||||
import com.hermesandroid.relay.data.allowsSessionAction
|
||||
import com.hermesandroid.relay.data.ToolCallEvent
|
||||
import com.hermesandroid.relay.data.VoiceIntentTrace
|
||||
import com.hermesandroid.relay.data.HermesCard
|
||||
@@ -336,6 +340,24 @@ internal fun voiceTurnTransportRejection(
|
||||
}
|
||||
|
||||
class ChatViewModel : ViewModel() {
|
||||
/**
|
||||
* Active Android-only supervision policy. RelayApp replaces this snapshot
|
||||
* whenever the active connection changes. Enforcement belongs here as well
|
||||
* as in Compose so alternate UI entry points cannot bypass the restrictions.
|
||||
*/
|
||||
@Volatile
|
||||
private var supervisedModePolicy: SupervisedModePolicy = SupervisedModePolicy()
|
||||
|
||||
fun updateSupervisedModePolicy(policy: SupervisedModePolicy) {
|
||||
supervisedModePolicy = policy
|
||||
if (policy.enabled) {
|
||||
_pendingAttachments.update { attachments ->
|
||||
attachments.filterIndexed { index, attachment ->
|
||||
isAttachmentAllowedBySupervision(attachment, index)
|
||||
}.take(policy.capabilities.attachmentMaxCount)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
private var apiClient: HermesApiClient? = null
|
||||
private var chatHandler: ChatHandler? = null
|
||||
@@ -828,7 +850,10 @@ class ChatViewModel : ViewModel() {
|
||||
}
|
||||
|
||||
fun addAttachment(attachment: Attachment) {
|
||||
_pendingAttachments.update { it + attachment }
|
||||
_pendingAttachments.update { current ->
|
||||
if (!isAttachmentAllowedBySupervision(attachment, current.size)) current
|
||||
else current + attachment
|
||||
}
|
||||
}
|
||||
|
||||
fun removeAttachment(index: Int) {
|
||||
@@ -838,11 +863,20 @@ class ChatViewModel : ViewModel() {
|
||||
}
|
||||
|
||||
fun replacePendingAttachments(attachments: List<Attachment>) {
|
||||
_pendingAttachments.value = attachments.toList()
|
||||
val policy = supervisedModePolicy
|
||||
_pendingAttachments.value = if (!policy.enabled) {
|
||||
attachments.toList()
|
||||
} else {
|
||||
attachments.filter { isAttachmentAllowedBySupervision(it, 0) }
|
||||
.take(policy.capabilities.attachmentMaxCount)
|
||||
}
|
||||
}
|
||||
|
||||
fun replaceAttachment(composerId: String, attachment: Attachment) {
|
||||
_pendingAttachments.update { attachments ->
|
||||
if (!isAttachmentAllowedBySupervision(attachment, (attachments.size - 1).coerceAtLeast(0))) {
|
||||
return@update attachments.filterNot { it.composerId == composerId }
|
||||
}
|
||||
var replaced = false
|
||||
val updated = attachments.map { current ->
|
||||
if (current.composerId == composerId) {
|
||||
@@ -872,6 +906,23 @@ class ChatViewModel : ViewModel() {
|
||||
_pendingAttachments.value = emptyList()
|
||||
}
|
||||
|
||||
private fun isAttachmentAllowedBySupervision(attachment: Attachment, existingCount: Int): Boolean {
|
||||
val policy = supervisedModePolicy
|
||||
if (!policy.enabled) return true
|
||||
val capabilities = policy.capabilities
|
||||
if (!policy.isActive || !capabilities.attachments) return false
|
||||
if (existingCount >= capabilities.attachmentMaxCount) return false
|
||||
val maxBytes = capabilities.attachmentMaxFileMb.toLong() * 1024L * 1024L
|
||||
if ((attachment.fileSize ?: 0L) > maxBytes) return false
|
||||
val category = when {
|
||||
attachment.contentType.startsWith("image/") -> SupervisedAttachmentCategory.Images
|
||||
attachment.contentType.startsWith("audio/") -> SupervisedAttachmentCategory.Audio
|
||||
attachment.contentType.startsWith("video/") -> SupervisedAttachmentCategory.Video
|
||||
else -> SupervisedAttachmentCategory.Documents
|
||||
}
|
||||
return category in capabilities.attachmentCategories
|
||||
}
|
||||
|
||||
// Server-side personality selection
|
||||
private val _selectedPersonality = MutableStateFlow("default")
|
||||
val selectedPersonality: StateFlow<String> = _selectedPersonality.asStateFlow()
|
||||
@@ -4624,6 +4675,7 @@ class ChatViewModel : ViewModel() {
|
||||
onReady: ((String?) -> Unit)? = null,
|
||||
onFailure: (() -> Unit)? = null,
|
||||
) {
|
||||
if (supervisedModePolicy.enabled && !supervisedModePolicy.capabilities.newChat) return
|
||||
val handler = chatHandler ?: return
|
||||
recordPreResetEvidence(handler, "new_chat")
|
||||
clearOpenedSessionOwner()
|
||||
@@ -4956,6 +5008,7 @@ class ChatViewModel : ViewModel() {
|
||||
}
|
||||
|
||||
fun deleteSession(sessionId: String, onDeleted: () -> Unit = {}) {
|
||||
if (!supervisedModePolicy.allowsSessionAction(SupervisedSessionAction.Delete)) return
|
||||
val handler = chatHandler ?: return
|
||||
val client = apiClient
|
||||
if (streamingEndpoint != "gateway" && client == null) return
|
||||
@@ -5020,6 +5073,7 @@ class ChatViewModel : ViewModel() {
|
||||
}
|
||||
|
||||
fun renameSession(sessionId: String, newTitle: String) {
|
||||
if (!supervisedModePolicy.allowsSessionAction(SupervisedSessionAction.Rename)) return
|
||||
val handler = chatHandler ?: return
|
||||
val client = apiClient
|
||||
if (streamingEndpoint != "gateway" && client == null) return
|
||||
@@ -5064,6 +5118,7 @@ class ChatViewModel : ViewModel() {
|
||||
}
|
||||
|
||||
fun setSessionPinned(sessionId: String, pinned: Boolean) {
|
||||
if (!supervisedModePolicy.allowsSessionAction(SupervisedSessionAction.Pin)) return
|
||||
val expectedContextKey = activeProfileContextKey
|
||||
val profileName = currentSessionProfileName()
|
||||
mutateSessionFlag(
|
||||
@@ -5082,6 +5137,7 @@ class ChatViewModel : ViewModel() {
|
||||
}
|
||||
|
||||
fun setSessionArchived(sessionId: String, archived: Boolean) {
|
||||
if (!supervisedModePolicy.allowsSessionAction(SupervisedSessionAction.Archive)) return
|
||||
if (!_sessionArchivingSupported.value) {
|
||||
emitError(
|
||||
UnsupportedOperationException("Archive and restore require Dashboard sessions"),
|
||||
@@ -5150,6 +5206,22 @@ class ChatViewModel : ViewModel() {
|
||||
|
||||
fun sendMessage(text: String) {
|
||||
if (text.isBlank()) return
|
||||
supervisedMessageBlockReason(supervisedModePolicy, text)?.let { reason ->
|
||||
chatHandler?.addSystemNotice(reason)
|
||||
return
|
||||
}
|
||||
if (supervisedModePolicy.enabled) {
|
||||
val attachments = _pendingAttachments.value
|
||||
if (attachments.any { attachment ->
|
||||
!isAttachmentAllowedBySupervision(attachment, attachments.indexOf(attachment))
|
||||
}
|
||||
) {
|
||||
chatHandler?.addSystemNotice(
|
||||
"One or more attachments are unavailable under the supervised policy.",
|
||||
)
|
||||
return
|
||||
}
|
||||
}
|
||||
recordRecentPrompt(text)
|
||||
|
||||
// Demo / Explore mode: there is no server, but a silently dead Send
|
||||
@@ -5418,6 +5490,10 @@ class ChatViewModel : ViewModel() {
|
||||
action: com.hermesandroid.relay.data.HermesCardAction,
|
||||
) {
|
||||
val handler = chatHandler ?: return
|
||||
if (supervisedModePolicy.enabled) {
|
||||
handler.addSystemNotice("This action is unavailable in supervised mode.")
|
||||
return
|
||||
}
|
||||
// Ask answers route straight to the gateway respond RPCs —
|
||||
// answerAsk records its own (sanitized) dispatch stamp, so don't
|
||||
// double-stamp here.
|
||||
@@ -5456,6 +5532,10 @@ class ChatViewModel : ViewModel() {
|
||||
ask: GatewayAsk,
|
||||
restored: ChatTurnAskCheckpoint? = null,
|
||||
) {
|
||||
if (supervisedModePolicy.enabled) {
|
||||
denySupervisedInteraction(handler, ask)
|
||||
return
|
||||
}
|
||||
val sessionId = handler.currentSessionId.value
|
||||
val contextKey = activeProfileContextKey
|
||||
val existing = _pendingAsk.value
|
||||
@@ -5584,6 +5664,33 @@ class ChatViewModel : ViewModel() {
|
||||
sessionId?.let { maybeNotifyInteraction(it, ask) }
|
||||
}
|
||||
|
||||
/**
|
||||
* Supervised Chat never exposes approval, clarification, sudo, or secret
|
||||
* inputs. Settle the upstream interaction immediately with its safest
|
||||
* negative/empty response; if that cannot be confirmed, interrupt the turn
|
||||
* so a hidden card cannot leave the session waiting indefinitely.
|
||||
*/
|
||||
private fun denySupervisedInteraction(handler: ChatHandler, ask: GatewayAsk) {
|
||||
val gateway = gatewayClient
|
||||
if (gateway == null) {
|
||||
handler.addSystemNotice("An interactive request was blocked by supervised mode.")
|
||||
cancelStream()
|
||||
return
|
||||
}
|
||||
viewModelScope.launch {
|
||||
val response: Result<GatewayAskResponse>? = when (ask.kind) {
|
||||
GatewayAsk.Kind.APPROVAL -> gateway.respondApproval(choice = "deny")
|
||||
GatewayAsk.Kind.CLARIFY -> ask.requestId?.let {
|
||||
gateway.respondClarify(it, "This supervised client cannot answer interactive requests.")
|
||||
}
|
||||
GatewayAsk.Kind.SUDO -> ask.requestId?.let { gateway.respondSudo(it, "") }
|
||||
GatewayAsk.Kind.SECRET -> ask.requestId?.let { gateway.respondSecret(it, "") }
|
||||
}
|
||||
handler.addSystemNotice("An interactive request was denied by supervised mode.")
|
||||
if (response == null || response.isFailure) cancelStream()
|
||||
}
|
||||
}
|
||||
|
||||
/** Render only upstream-supported approval values; old servers retain Approve/Deny. */
|
||||
private fun approvalActions(ask: GatewayAsk): List<HermesCardAction> {
|
||||
val advertised = ask.choices.orEmpty()
|
||||
@@ -9404,6 +9511,9 @@ class ChatViewModel : ViewModel() {
|
||||
* so we shouldn't see duplicate calls here.
|
||||
*/
|
||||
fun onMediaAttachmentRequested(messageId: String, token: String) {
|
||||
if (supervisedModePolicy.enabled &&
|
||||
!supervisedModePolicy.capabilities.generatedImages
|
||||
) return
|
||||
val handler = chatHandler ?: return
|
||||
val relay = relayHttpClient
|
||||
val repo = mediaSettingsRepo
|
||||
@@ -9461,6 +9571,9 @@ class ChatViewModel : ViewModel() {
|
||||
* token and uses [RelayHttpClient.fetchMedia].
|
||||
*/
|
||||
fun manualFetchAttachment(messageId: String, attachmentIndex: Int) {
|
||||
if (supervisedModePolicy.enabled &&
|
||||
!supervisedModePolicy.capabilities.generatedImages
|
||||
) return
|
||||
val handler = chatHandler ?: return
|
||||
val relay = relayHttpClient ?: return
|
||||
val repo = mediaSettingsRepo ?: return
|
||||
@@ -9534,6 +9647,9 @@ class ChatViewModel : ViewModel() {
|
||||
* it into the markdown-image renderer, which previously ignored the relay.
|
||||
*/
|
||||
suspend fun resolveServerImage(serverPath: String): ServerImageResult {
|
||||
if (supervisedModePolicy.enabled &&
|
||||
!supervisedModePolicy.capabilities.generatedImages
|
||||
) return ServerImageResult.Failure("Generated images are disabled in supervised mode")
|
||||
val relay = relayHttpClient
|
||||
?: return ServerImageResult.Failure("Relay not configured on this connection")
|
||||
// fetchMediaByPath returns Result<MediaBytes>; fold it ONCE, right here,
|
||||
@@ -9576,6 +9692,11 @@ class ChatViewModel : ViewModel() {
|
||||
expectedRole: MessageRole,
|
||||
unavailableMessage: String,
|
||||
) {
|
||||
if (
|
||||
expectedRole == MessageRole.ASSISTANT &&
|
||||
supervisedModePolicy.enabled &&
|
||||
!supervisedModePolicy.capabilities.generatedImages
|
||||
) return
|
||||
val handler = chatHandler ?: return
|
||||
val relay = relayHttpClient
|
||||
val repo = mediaSettingsRepo
|
||||
|
||||
@@ -2749,6 +2749,9 @@ class ConnectionViewModel(application: Application) : AndroidViewModel(applicati
|
||||
|
||||
private fun installAuthManager(am: AuthManager) {
|
||||
am.setActiveEndpointProvider { connectionManager.activeRelayEndpoint.value }
|
||||
am.setSupervisedMetadataReconnectFallback {
|
||||
connectionManager.reconnectForAuthenticatedMetadataUpdate()
|
||||
}
|
||||
authManager = am
|
||||
// Push into the flow so the flatMapLatest chains on authState /
|
||||
// pairingCode / currentPairedSession repoint to the new manager.
|
||||
@@ -3591,6 +3594,8 @@ class ConnectionViewModel(application: Application) : AndroidViewModel(applicati
|
||||
// ConnectionStore's EncryptedSharedPrefs.
|
||||
profileController.profileSelectionStore.clear(connectionId)
|
||||
profileController.profileLockStore.clear(connectionId)
|
||||
com.hermesandroid.relay.data.SupervisedModeStore(getApplication<Application>())
|
||||
.clear(connectionId)
|
||||
profileController.profilePresentationStore.clear(connectionId)
|
||||
profileController.profileSessionStore.clearConnection(connectionId)
|
||||
profileController.profileDisplayAliasStore.clearConnection(connectionId)
|
||||
@@ -3630,6 +3635,9 @@ class ConnectionViewModel(application: Application) : AndroidViewModel(applicati
|
||||
}
|
||||
|
||||
init {
|
||||
authManager.setSupervisedMetadataReconnectFallback {
|
||||
connectionManager.reconnectForAuthenticatedMetadataUpdate()
|
||||
}
|
||||
// Wire multiplexer to connection manager (for relay/bridge/terminal)
|
||||
multiplexer.setSendCallback { envelope ->
|
||||
connectionManager.send(envelope)
|
||||
@@ -4096,6 +4104,8 @@ class ConnectionViewModel(application: Application) : AndroidViewModel(applicati
|
||||
connectionStore.removeConnection(duplicate.id)
|
||||
profileController.profileSelectionStore.clear(duplicate.id)
|
||||
profileController.profileLockStore.clear(duplicate.id)
|
||||
com.hermesandroid.relay.data.SupervisedModeStore(getApplication<Application>())
|
||||
.clear(duplicate.id)
|
||||
profileController.profilePresentationStore.clear(duplicate.id)
|
||||
profileController.profileSessionStore.clearConnection(duplicate.id)
|
||||
}
|
||||
@@ -7190,6 +7200,8 @@ class ConnectionViewModel(application: Application) : AndroidViewModel(applicati
|
||||
check(dataManager.resetAppData()) { "App data store reset failed" }
|
||||
profileController.profileSelectionStore.clearAll()
|
||||
profileController.profileLockStore.clearAll()
|
||||
com.hermesandroid.relay.data.SupervisedModeStore(getApplication<Application>())
|
||||
.clearAll()
|
||||
profileController.profilePresentationStore.clearAll()
|
||||
profileController.profileSessionStore.clearAll()
|
||||
_apiServerUrl.value = ""
|
||||
|
||||
@@ -0,0 +1,510 @@
|
||||
package com.hermesandroid.relay.viewmodel
|
||||
|
||||
import android.app.Application
|
||||
import androidx.lifecycle.AndroidViewModel
|
||||
import androidx.lifecycle.viewModelScope
|
||||
import com.hermesandroid.relay.data.GitBranch
|
||||
import com.hermesandroid.relay.data.GitDiff
|
||||
import com.hermesandroid.relay.data.GitFile
|
||||
import com.hermesandroid.relay.data.GitRepo
|
||||
import com.hermesandroid.relay.data.GitStateApiClient
|
||||
import com.hermesandroid.relay.data.GitStatus
|
||||
import com.hermesandroid.relay.network.upstream.DashboardApiClient
|
||||
import kotlinx.coroutines.Job
|
||||
import kotlinx.coroutines.flow.MutableStateFlow
|
||||
import kotlinx.coroutines.flow.StateFlow
|
||||
import kotlinx.coroutines.flow.asStateFlow
|
||||
import kotlinx.coroutines.launch
|
||||
|
||||
sealed interface GitStateUiState {
|
||||
data object Loading : GitStateUiState
|
||||
data class Error(val message: String) : GitStateUiState
|
||||
data class Ready(val repos: List<GitRepo>, val notice: String?) : GitStateUiState
|
||||
}
|
||||
|
||||
sealed interface GitRepoDetailState {
|
||||
data object Idle : GitRepoDetailState
|
||||
data object Loading : GitRepoDetailState
|
||||
data class Error(val message: String) : GitRepoDetailState
|
||||
data class Ready(
|
||||
val status: GitStatus,
|
||||
val branches: List<GitBranch>,
|
||||
) : GitRepoDetailState
|
||||
}
|
||||
|
||||
sealed interface GitContentViewState {
|
||||
data object Idle : GitContentViewState
|
||||
data object Loading : GitContentViewState
|
||||
data class Error(val message: String) : GitContentViewState
|
||||
data class Diff(val diff: GitDiff) : GitContentViewState
|
||||
data class File(val file: GitFile) : GitContentViewState
|
||||
}
|
||||
|
||||
/** A single in-flight or completed write mutation on the selected repo. */
|
||||
sealed interface GitMutationState {
|
||||
data object Idle : GitMutationState
|
||||
data class InProgress(val label: String) : GitMutationState
|
||||
data class Error(val label: String, val message: String) : GitMutationState
|
||||
data class Success(val label: String, val head: String) : GitMutationState
|
||||
}
|
||||
|
||||
/** A commit-message generation attempt (AI magic-wand). */
|
||||
sealed interface GitMessageGenerationState {
|
||||
data object Idle : GitMessageGenerationState
|
||||
data object Loading : GitMessageGenerationState
|
||||
data class Ready(val message: String, val notice: String) : GitMessageGenerationState
|
||||
}
|
||||
|
||||
/** Fixed per-use confirmation tokens matching the plugin's server constants. */
|
||||
object GitConfirmationStrings {
|
||||
const val DISCARD = "discard"
|
||||
const val PUSH = "push"
|
||||
const val DIRTY_CHECKOUT = "checkout-dirty"
|
||||
}
|
||||
|
||||
data class GitTarget(
|
||||
val scopeKey: String,
|
||||
val repoId: String,
|
||||
val generation: Long,
|
||||
)
|
||||
|
||||
/**
|
||||
* View model for the Git State Android surface (read + write).
|
||||
*
|
||||
* Loads the scanned repo list from the Hermes-Relay plugin and, on selection,
|
||||
* fetches working-tree status + branches. Mutations (stage/unstage/discard/
|
||||
* commit/fetch/pull/push/checkout) all require the ``plugin.api.write`` grant:
|
||||
* ``configure`` binds one connection/profile/Dashboard owner and every mutation
|
||||
* refuses (surfacing a readable message, never a POST) when that owner's grant
|
||||
* is absent. Destructive ops
|
||||
* (discard/push/dirty-checkout) additionally require a per-use confirmation
|
||||
* string the caller echoes from GitConfirmationStrings.
|
||||
*/
|
||||
class GitStateViewModel(application: Application) : AndroidViewModel(application) {
|
||||
private val _repos = MutableStateFlow<GitStateUiState>(GitStateUiState.Loading)
|
||||
val repos: StateFlow<GitStateUiState> = _repos.asStateFlow()
|
||||
|
||||
private val _detail = MutableStateFlow<GitRepoDetailState>(GitRepoDetailState.Idle)
|
||||
val detail: StateFlow<GitRepoDetailState> = _detail.asStateFlow()
|
||||
|
||||
private val _content = MutableStateFlow<GitContentViewState>(GitContentViewState.Idle)
|
||||
val content: StateFlow<GitContentViewState> = _content.asStateFlow()
|
||||
|
||||
private val _mutation = MutableStateFlow<GitMutationState>(GitMutationState.Idle)
|
||||
val mutation: StateFlow<GitMutationState> = _mutation.asStateFlow()
|
||||
|
||||
private val _messageGeneration =
|
||||
MutableStateFlow<GitMessageGenerationState>(GitMessageGenerationState.Idle)
|
||||
val messageGeneration: StateFlow<GitMessageGenerationState> = _messageGeneration.asStateFlow()
|
||||
|
||||
private val _pushAfterCommit = MutableStateFlow(false)
|
||||
val pushAfterCommit: StateFlow<Boolean> = _pushAfterCommit.asStateFlow()
|
||||
|
||||
private val _stashNotice = MutableStateFlow<String?>(null)
|
||||
val stashNotice: StateFlow<String?> = _stashNotice.asStateFlow()
|
||||
|
||||
private val _writeGrant = MutableStateFlow(false)
|
||||
val writeGrant: StateFlow<Boolean> = _writeGrant.asStateFlow()
|
||||
|
||||
private var api: GitStateApiClient? = null
|
||||
private var reposJob: Job? = null
|
||||
private var detailJob: Job? = null
|
||||
private var contentJob: Job? = null
|
||||
private var mutationJob: Job? = null
|
||||
private var messageJob: Job? = null
|
||||
private var scopeKey: String? = null
|
||||
private var targetGeneration: Long = 0
|
||||
private var selectedRepoId: String? = null
|
||||
|
||||
fun selectedRepoIdForDisplay(): String? = selectedRepoId
|
||||
|
||||
fun currentTarget(): GitTarget? {
|
||||
val owner = scopeKey ?: return null
|
||||
val repo = selectedRepoId ?: return null
|
||||
return GitTarget(owner, repo, targetGeneration)
|
||||
}
|
||||
|
||||
fun configure(dashboard: DashboardApiClient?, ownerKey: String?) {
|
||||
reposJob?.cancel()
|
||||
detailJob?.cancel()
|
||||
contentJob?.cancel()
|
||||
mutationJob?.cancel()
|
||||
messageJob?.cancel()
|
||||
targetGeneration += 1
|
||||
scopeKey = ownerKey
|
||||
selectedRepoId = null
|
||||
_writeGrant.value = false
|
||||
_detail.value = GitRepoDetailState.Idle
|
||||
_content.value = GitContentViewState.Idle
|
||||
_mutation.value = GitMutationState.Idle
|
||||
_messageGeneration.value = GitMessageGenerationState.Idle
|
||||
_stashNotice.value = null
|
||||
api = dashboard?.let(::GitStateApiClient)
|
||||
loadRepos()
|
||||
}
|
||||
|
||||
/** Grants the plugin.api.write capability for this connection/profile. */
|
||||
fun setWriteGrant(ownerKey: String?, granted: Boolean) {
|
||||
if (ownerKey != scopeKey) return
|
||||
_writeGrant.value = granted
|
||||
}
|
||||
|
||||
fun hasWriteGrant(): Boolean = _writeGrant.value
|
||||
|
||||
fun loadRepos() {
|
||||
val client = api ?: run {
|
||||
_repos.value = GitStateUiState.Error("Dashboard connection unavailable")
|
||||
return
|
||||
}
|
||||
val expectedScope = scopeKey
|
||||
reposJob?.cancel()
|
||||
reposJob = viewModelScope.launch {
|
||||
_repos.value = GitStateUiState.Loading
|
||||
client.repos().fold(
|
||||
onSuccess = { list ->
|
||||
if (scopeKey == expectedScope) {
|
||||
_repos.value = GitStateUiState.Ready(list, null)
|
||||
}
|
||||
},
|
||||
onFailure = { error ->
|
||||
if (scopeKey == expectedScope) {
|
||||
_repos.value = GitStateUiState.Error(error.message ?: "Failed to load repositories")
|
||||
}
|
||||
},
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
fun selectRepo(repoId: String) {
|
||||
val client = api ?: return
|
||||
targetGeneration += 1
|
||||
selectedRepoId = repoId
|
||||
val target = currentTarget() ?: return
|
||||
_content.value = GitContentViewState.Idle
|
||||
_mutation.value = GitMutationState.Idle
|
||||
detailJob?.cancel()
|
||||
contentJob?.cancel()
|
||||
detailJob = viewModelScope.launch {
|
||||
_detail.value = GitRepoDetailState.Loading
|
||||
val statusResult = client.status(repoId)
|
||||
val branchesResult = client.branches(repoId)
|
||||
if (currentTarget() != target) return@launch
|
||||
if (statusResult.isFailure) {
|
||||
_detail.value = GitRepoDetailState.Error(
|
||||
statusResult.exceptionOrNull()?.message ?: "Failed to load status",
|
||||
)
|
||||
return@launch
|
||||
}
|
||||
val status: GitStatus = statusResult.getOrThrow()
|
||||
val branches: List<GitBranch> = branchesResult.getOrDefault(emptyList())
|
||||
_detail.value = GitRepoDetailState.Ready(status, branches)
|
||||
}
|
||||
}
|
||||
|
||||
/** Runs one owner/repository-bound mutation without cancelling another mutation. */
|
||||
private fun runMutation(
|
||||
label: String,
|
||||
expectedTarget: GitTarget? = null,
|
||||
onSuccess: (GitTarget) -> Unit = {},
|
||||
block: suspend (GitStateApiClient, String) -> Result<GitMutationState>,
|
||||
) {
|
||||
val client = api ?: run {
|
||||
_mutation.value = GitMutationState.Error(label, "Dashboard connection unavailable")
|
||||
return
|
||||
}
|
||||
val target = currentTarget() ?: run {
|
||||
_mutation.value = GitMutationState.Error(label, "No repository selected")
|
||||
return
|
||||
}
|
||||
if (expectedTarget != null && expectedTarget != target) {
|
||||
_mutation.value = GitMutationState.Error(label, "Repository context changed; review the action again.")
|
||||
return
|
||||
}
|
||||
if (!_writeGrant.value) {
|
||||
_mutation.value = GitMutationState.Error(
|
||||
label,
|
||||
"Allow plugin changes (plugin.api.write) before using this action.",
|
||||
)
|
||||
return
|
||||
}
|
||||
if (mutationJob?.isActive == true) {
|
||||
_mutation.value = GitMutationState.Error(label, "Another Git action is still in progress.")
|
||||
return
|
||||
}
|
||||
detailJob?.cancel()
|
||||
contentJob?.cancel()
|
||||
mutationJob = viewModelScope.launch {
|
||||
_mutation.value = GitMutationState.InProgress(label)
|
||||
block(client, target.repoId).fold(
|
||||
onSuccess = {
|
||||
if (currentTarget() != target) return@fold
|
||||
_mutation.value = it
|
||||
_content.value = GitContentViewState.Idle
|
||||
refreshDetail(client, target)
|
||||
onSuccess(target)
|
||||
},
|
||||
onFailure = { error ->
|
||||
if (currentTarget() == target) {
|
||||
_mutation.value = GitMutationState.Error(
|
||||
label,
|
||||
error.message ?: "Git action failed",
|
||||
)
|
||||
}
|
||||
},
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
private suspend fun refreshDetail(client: GitStateApiClient, target: GitTarget) {
|
||||
val statusResult = client.status(target.repoId)
|
||||
val branchesResult = client.branches(target.repoId)
|
||||
if (currentTarget() == target && statusResult.isSuccess) {
|
||||
_detail.value = GitRepoDetailState.Ready(
|
||||
statusResult.getOrDefault(GitStatus()),
|
||||
branchesResult.getOrDefault(emptyList()),
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
// ── Read operations ────────────────────────────────────────────────────
|
||||
|
||||
fun loadDiff(path: String, kind: String) {
|
||||
val target = currentTarget() ?: return
|
||||
val client = api ?: return
|
||||
contentJob?.cancel()
|
||||
contentJob = viewModelScope.launch {
|
||||
_content.value = GitContentViewState.Loading
|
||||
client.diff(target.repoId, path, kind).fold(
|
||||
onSuccess = { diff ->
|
||||
if (currentTarget() == target) {
|
||||
_content.value = GitContentViewState.Diff(diff)
|
||||
}
|
||||
},
|
||||
onFailure = { error ->
|
||||
if (currentTarget() == target) {
|
||||
_content.value = GitContentViewState.Error(
|
||||
error.message ?: "Failed to load diff",
|
||||
)
|
||||
}
|
||||
},
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
fun loadFile(path: String) {
|
||||
val target = currentTarget() ?: return
|
||||
val client = api ?: return
|
||||
contentJob?.cancel()
|
||||
contentJob = viewModelScope.launch {
|
||||
_content.value = GitContentViewState.Loading
|
||||
client.file(target.repoId, path).fold(
|
||||
onSuccess = { file ->
|
||||
if (currentTarget() == target) {
|
||||
_content.value = GitContentViewState.File(file)
|
||||
}
|
||||
},
|
||||
onFailure = { error ->
|
||||
if (currentTarget() == target) {
|
||||
_content.value = GitContentViewState.Error(
|
||||
error.message ?: "Failed to load file",
|
||||
)
|
||||
}
|
||||
},
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
// ── Write operations ───────────────────────────────────────────────────
|
||||
|
||||
fun stage(paths: List<String>) = runMutation("Stage") { c, r ->
|
||||
c.stage(r, paths).map { GitMutationState.Success("stage", it.head) }
|
||||
}
|
||||
|
||||
fun unstage(paths: List<String>) = runMutation("Unstage") { c, r ->
|
||||
c.unstage(r, paths).map { GitMutationState.Success("unstage", it.head) }
|
||||
}
|
||||
|
||||
fun discard(
|
||||
paths: List<String>,
|
||||
confirmation: String,
|
||||
deleteUntracked: Boolean = false,
|
||||
expectedTarget: GitTarget? = null,
|
||||
) =
|
||||
runMutation("Discard", expectedTarget = expectedTarget) { c, r ->
|
||||
c.discard(r, paths, confirmation, deleteUntracked)
|
||||
.map { GitMutationState.Success("discard", it.head) }
|
||||
}
|
||||
|
||||
fun commit(message: String, onSuccess: (GitTarget) -> Unit = {}) =
|
||||
runMutation("Commit", onSuccess = onSuccess) { c, r ->
|
||||
c.commit(r, message).map {
|
||||
GitMutationState.Success("commit", it.head)
|
||||
}
|
||||
}
|
||||
|
||||
fun commitSelected(message: String, paths: List<String>) = runMutation("Commit") { c, r ->
|
||||
c.commitSelected(r, message, paths).map {
|
||||
GitMutationState.Success("commit", it.head)
|
||||
}
|
||||
}
|
||||
|
||||
fun fetch(remote: String = "origin") = runMutation("Fetch") { c, r ->
|
||||
c.fetch(r, remote).map { GitMutationState.Success("fetch", it.head) }
|
||||
}
|
||||
|
||||
fun pull(remote: String = "origin", branch: String = "") = runMutation("Pull") { c, r ->
|
||||
c.pull(r, remote, branch).map { GitMutationState.Success("pull", it.head) }
|
||||
}
|
||||
|
||||
fun push(
|
||||
confirmation: String,
|
||||
remote: String = "origin",
|
||||
branch: String = "",
|
||||
expectedTarget: GitTarget? = null,
|
||||
) =
|
||||
runMutation("Push", expectedTarget = expectedTarget) { c, r ->
|
||||
c.push(r, confirmation, remote, branch).map { GitMutationState.Success("push", it.head) }
|
||||
}
|
||||
|
||||
fun checkout(
|
||||
ref: String,
|
||||
confirmation: String? = null,
|
||||
newBranch: String = "",
|
||||
track: Boolean = false,
|
||||
expectedTarget: GitTarget? = null,
|
||||
) = runMutation("Checkout", expectedTarget = expectedTarget) { c, r ->
|
||||
c.checkout(r, ref, confirmation, newBranch, track)
|
||||
.map { GitMutationState.Success("checkout", it.head) }
|
||||
}
|
||||
|
||||
// ── Phase 3 extras ─────────────────────────────────────────────────────
|
||||
|
||||
/** Toggle the push-after-commit flow (default OFF; never bypasses confirm). */
|
||||
fun setPushAfterCommit(enabled: Boolean) {
|
||||
_pushAfterCommit.value = enabled
|
||||
}
|
||||
|
||||
/**
|
||||
* Generate a commit-message suggestion from the staged diff (AI magic-wand).
|
||||
* Empty staged diff / model-unavailable degrade to a notice, never an error.
|
||||
* Uses the shared write grant gate (a POST is never sent without the grant).
|
||||
*/
|
||||
fun generateCommitMessage(paths: List<String>? = null) {
|
||||
val client = api ?: run {
|
||||
_messageGeneration.value =
|
||||
GitMessageGenerationState.Ready("", "Dashboard connection unavailable")
|
||||
return
|
||||
}
|
||||
val target = currentTarget() ?: run {
|
||||
_messageGeneration.value = GitMessageGenerationState.Ready("", "No repository selected")
|
||||
return
|
||||
}
|
||||
if (!_writeGrant.value) {
|
||||
_messageGeneration.value = GitMessageGenerationState.Ready(
|
||||
"",
|
||||
"Allow plugin changes (plugin.api.write) before using this action.",
|
||||
)
|
||||
return
|
||||
}
|
||||
messageJob?.cancel()
|
||||
messageJob = viewModelScope.launch {
|
||||
_messageGeneration.value = GitMessageGenerationState.Loading
|
||||
val result = if (paths != null) {
|
||||
client.commitMessageSelected(target.repoId, paths)
|
||||
} else {
|
||||
client.commitMessage(target.repoId)
|
||||
}
|
||||
if (currentTarget() != target) return@launch
|
||||
result.fold(
|
||||
onSuccess = { msg ->
|
||||
_messageGeneration.value = GitMessageGenerationState.Ready(msg.message, msg.notice)
|
||||
},
|
||||
onFailure = { error ->
|
||||
_messageGeneration.value = GitMessageGenerationState.Ready(
|
||||
"",
|
||||
error.message ?: "Could not generate a commit message.",
|
||||
)
|
||||
},
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Checkout that auto-stashes a dirty tree first. No confirmation is needed
|
||||
* because a stash is recoverable. Surfaces the stash message via [stashNotice].
|
||||
*/
|
||||
fun stashCheckout(ref: String, newBranch: String = "", track: Boolean = false) {
|
||||
val client = api ?: run {
|
||||
_mutation.value = GitMutationState.Error("Stash Checkout", "Dashboard connection unavailable")
|
||||
return
|
||||
}
|
||||
val target = currentTarget() ?: run {
|
||||
_mutation.value = GitMutationState.Error("Stash Checkout", "No repository selected")
|
||||
return
|
||||
}
|
||||
if (!_writeGrant.value) {
|
||||
_mutation.value = GitMutationState.Error(
|
||||
"Stash Checkout",
|
||||
"Allow plugin changes (plugin.api.write) before using this action.",
|
||||
)
|
||||
return
|
||||
}
|
||||
_stashNotice.value = null
|
||||
if (mutationJob?.isActive == true) {
|
||||
_mutation.value = GitMutationState.Error(
|
||||
"Stash Checkout",
|
||||
"Another Git action is still in progress.",
|
||||
)
|
||||
return
|
||||
}
|
||||
detailJob?.cancel()
|
||||
contentJob?.cancel()
|
||||
mutationJob = viewModelScope.launch {
|
||||
_mutation.value = GitMutationState.InProgress("Stash Checkout")
|
||||
client.stashCheckout(target.repoId, ref, newBranch, track).fold(
|
||||
onSuccess = { result ->
|
||||
if (currentTarget() != target) return@fold
|
||||
if (result.stashed) {
|
||||
_stashNotice.value =
|
||||
"Stashed changes on $ref as \"${result.stashMessage}\". Use \"git stash pop\" to restore them."
|
||||
}
|
||||
_mutation.value = GitMutationState.Success("stash-checkout", result.head)
|
||||
_content.value = GitContentViewState.Idle
|
||||
refreshDetail(client, target)
|
||||
},
|
||||
onFailure = { error ->
|
||||
if (currentTarget() == target) {
|
||||
_mutation.value = GitMutationState.Error(
|
||||
"Stash Checkout",
|
||||
error.message ?: "Git action failed",
|
||||
)
|
||||
}
|
||||
},
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
fun clearStashNotice() {
|
||||
_stashNotice.value = null
|
||||
}
|
||||
|
||||
/** True when the push-after-commit toggle is currently enabled. */
|
||||
fun isPushAfterCommitEnabled(): Boolean = _pushAfterCommit.value
|
||||
|
||||
/** True when the named destructive op needs a confirmation echo. */
|
||||
fun requiresConfirmation(op: String): Boolean = op in setOf("discard", "push", "dirty-checkout")
|
||||
|
||||
/** Fixed confirmation token for a destructive op (matches the server). */
|
||||
fun confirmationFor(op: String): String? = when (op) {
|
||||
"discard" -> GitConfirmationStrings.DISCARD
|
||||
"push" -> GitConfirmationStrings.PUSH
|
||||
"dirty-checkout" -> GitConfirmationStrings.DIRTY_CHECKOUT
|
||||
else -> null
|
||||
}
|
||||
|
||||
fun clearMutationError() {
|
||||
if (_mutation.value is GitMutationState.Error) {
|
||||
_mutation.value = GitMutationState.Idle
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -49,6 +49,7 @@ sealed interface PluginsHubState {
|
||||
data object Disconnected : PluginsHubState
|
||||
data object Loading : PluginsHubState
|
||||
data class Ready(
|
||||
val ownerKey: String,
|
||||
val plugins: List<PluginHubItem>,
|
||||
val preview: PluginCatalogPreview,
|
||||
val refreshing: Boolean = false,
|
||||
@@ -220,6 +221,7 @@ class PluginsViewModel(application: Application) : AndroidViewModel(application)
|
||||
_hubState.value = result.fold(
|
||||
onSuccess = { items ->
|
||||
PluginsHubState.Ready(
|
||||
ownerKey = expectedKey,
|
||||
plugins = items,
|
||||
preview = catalogPreview(items),
|
||||
)
|
||||
|
||||
@@ -0,0 +1,25 @@
|
||||
package com.hermesandroid.relay.viewmodel
|
||||
|
||||
import com.hermesandroid.relay.data.SupervisedModePolicy
|
||||
|
||||
/**
|
||||
* Fail-closed dispatch policy for Android Supervised Mode.
|
||||
*
|
||||
* This intentionally runs before demo handling, route selection, slash.exec,
|
||||
* command.dispatch, steering, and queueing. Kotlin's default trim recognizes
|
||||
* Unicode whitespace, preventing an indented slash command from bypassing the
|
||||
* client restriction.
|
||||
*/
|
||||
internal fun supervisedMessageBlockReason(
|
||||
policy: SupervisedModePolicy,
|
||||
text: String,
|
||||
): String? {
|
||||
if (!policy.enabled) return null
|
||||
if (!policy.isConfigured) {
|
||||
return "Supervised mode is unavailable until the parent selects a profile."
|
||||
}
|
||||
if (text.trimStart().startsWith('/')) {
|
||||
return "Slash commands are unavailable in supervised mode."
|
||||
}
|
||||
return null
|
||||
}
|
||||
@@ -21,6 +21,8 @@ import com.hermesandroid.relay.data.DEFAULT_VOICE_STOP_PHRASES
|
||||
import com.hermesandroid.relay.data.ChatMessage
|
||||
import com.hermesandroid.relay.data.MessageRole
|
||||
import com.hermesandroid.relay.data.RealtimeConversationContextMessage
|
||||
import com.hermesandroid.relay.data.SupervisedCapabilities
|
||||
import com.hermesandroid.relay.data.SupervisedModePolicy
|
||||
import com.hermesandroid.relay.data.ToolCall
|
||||
import com.hermesandroid.relay.data.VoiceEngineMode
|
||||
import com.hermesandroid.relay.data.VoiceIntentTrace
|
||||
@@ -279,6 +281,23 @@ internal fun realtimeTranscriptState(micCaptureActive: Boolean): VoiceState =
|
||||
*/
|
||||
enum class InteractionMode { TapToTalk, HoldToTalk, Continuous }
|
||||
|
||||
internal fun isVoiceCommandAllowed(
|
||||
action: VoiceCommandAction,
|
||||
policy: SupervisedModePolicy,
|
||||
): Boolean {
|
||||
if (!policy.enabled) return true
|
||||
val capabilities: SupervisedCapabilities = policy.capabilities
|
||||
return when (action) {
|
||||
VoiceCommandAction.StartNewChat -> capabilities.newChat
|
||||
VoiceCommandAction.StopResponse,
|
||||
VoiceCommandAction.CancelBackgroundTask -> capabilities.cancelResponse
|
||||
VoiceCommandAction.EndVoiceChat,
|
||||
VoiceCommandAction.PauseContinuousListening,
|
||||
VoiceCommandAction.ResumeContinuousListening,
|
||||
VoiceCommandAction.RepeatBackgroundAnswer -> capabilities.voice
|
||||
}
|
||||
}
|
||||
|
||||
internal fun InteractionMode.storageValue(): String = when (this) {
|
||||
InteractionMode.TapToTalk -> "tap"
|
||||
InteractionMode.HoldToTalk -> "hold"
|
||||
@@ -723,6 +742,7 @@ class VoiceViewModel(application: Application) : AndroidViewModel(application) {
|
||||
private var voicePreferences: VoicePreferencesRepository? = null
|
||||
private var voicePreferencesJob: Job? = null
|
||||
private var voiceEngineMode: VoiceEngineMode = VoiceEngineMode.HermesVoiceOutput
|
||||
private var supervisedModePolicy: SupervisedModePolicy = SupervisedModePolicy()
|
||||
private var voiceStopPhrases: List<String> = DEFAULT_VOICE_STOP_PHRASES
|
||||
private var finalAnswerOnly: Boolean = false
|
||||
private var realtimeTraceDetails: Boolean = false
|
||||
@@ -1380,6 +1400,28 @@ class VoiceViewModel(application: Application) : AndroidViewModel(application) {
|
||||
}
|
||||
}
|
||||
|
||||
/** Apply the active Android client policy at the voice coordinator boundary. */
|
||||
fun updateSupervisedModePolicy(policy: SupervisedModePolicy) {
|
||||
supervisedModePolicy = policy
|
||||
val supervised = policy.enabled
|
||||
voiceAudioClient?.setRouteOverride(if (supervised) VoiceAudioRoute.Standard else null)
|
||||
if (supervised) {
|
||||
if (voiceEngineMode == VoiceEngineMode.RealtimeAgent) closeRealtimeSession()
|
||||
voiceEngineMode = VoiceEngineMode.HermesVoiceOutput
|
||||
_voiceStats.update {
|
||||
it.copy(
|
||||
voiceEngineMode = VoiceEngineMode.HermesVoiceOutput.storageValue,
|
||||
)
|
||||
}
|
||||
if (!policy.capabilities.voice && _uiState.value.voiceMode) exitVoiceMode()
|
||||
} else {
|
||||
val prefs = voicePreferences ?: return
|
||||
viewModelScope.launch {
|
||||
prefs.settings.firstOrNull()?.let { applyVoiceSettingsSnapshot(it) }
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
private fun persistInteractionMode(mode: InteractionMode) {
|
||||
val prefs = voicePreferences ?: return
|
||||
viewModelScope.launch {
|
||||
@@ -1485,7 +1527,11 @@ class VoiceViewModel(application: Application) : AndroidViewModel(application) {
|
||||
) {
|
||||
closeRealtimeSession()
|
||||
}
|
||||
voiceEngineMode = nextEngineMode
|
||||
voiceEngineMode = if (supervisedModePolicy.enabled) {
|
||||
VoiceEngineMode.HermesVoiceOutput
|
||||
} else {
|
||||
nextEngineMode
|
||||
}
|
||||
voiceStopPhrases = settings.stopPhrases
|
||||
finalAnswerOnly = settings.finalAnswerOnly
|
||||
realtimeTraceDetails = settings.realtimeTraceDetails
|
||||
@@ -1506,7 +1552,7 @@ class VoiceViewModel(application: Application) : AndroidViewModel(application) {
|
||||
it.copy(
|
||||
vadThresholdMs = settings.silenceThresholdMs,
|
||||
interactionMode = settings.interactionMode,
|
||||
voiceEngineMode = settings.engineMode,
|
||||
voiceEngineMode = voiceEngineMode.storageValue,
|
||||
realtimeModel = settings.realtimeModel,
|
||||
realtimeVoice = settings.realtimeVoice,
|
||||
)
|
||||
@@ -1540,6 +1586,7 @@ class VoiceViewModel(application: Application) : AndroidViewModel(application) {
|
||||
activationId: String? = null,
|
||||
expectScreenContext: Boolean = false,
|
||||
) {
|
||||
if (supervisedModePolicy.enabled && !supervisedModePolicy.capabilities.voice) return
|
||||
val freshEntry = !_uiState.value.voiceMode
|
||||
val orphanedRun = _uiState.value
|
||||
.takeIf { freshEntry }
|
||||
@@ -2430,6 +2477,10 @@ class VoiceViewModel(application: Application) : AndroidViewModel(application) {
|
||||
if (!canSpeakSettledResponse(state, providerRealtimeAgentTurnActive.get())) {
|
||||
return false
|
||||
}
|
||||
if (
|
||||
supervisedModePolicy.enabled &&
|
||||
voiceAudioClient?.effectiveRoute != VoiceAudioRoute.Standard
|
||||
) return false
|
||||
|
||||
val spoken = sanitizeForTts(text)
|
||||
if (spoken.isBlank()) return false
|
||||
@@ -3007,6 +3058,17 @@ class VoiceViewModel(application: Application) : AndroidViewModel(application) {
|
||||
return null
|
||||
}
|
||||
|
||||
if (!isVoiceCommandAllowed(action, supervisedModePolicy)) {
|
||||
_uiState.update {
|
||||
it.copy(
|
||||
state = VoiceState.Idle,
|
||||
outputAudioActive = false,
|
||||
responseText = "That voice action is disabled by Parent controls.",
|
||||
)
|
||||
}
|
||||
return action
|
||||
}
|
||||
|
||||
Log.i(TAG, "Hands-free voice command action=$action source=${if (fromRealtime) "realtime" else "stt"}")
|
||||
DiagnosticsLog.record(
|
||||
category = DiagnosticCategory.Voice,
|
||||
@@ -3076,12 +3138,23 @@ class VoiceViewModel(application: Application) : AndroidViewModel(application) {
|
||||
setError("Voice pipeline not initialized")
|
||||
return
|
||||
}
|
||||
if (
|
||||
supervisedModePolicy.enabled &&
|
||||
audioClient.effectiveRoute != VoiceAudioRoute.Standard
|
||||
) {
|
||||
setError("Supervised voice requires the Standard Hermes voice route")
|
||||
return
|
||||
}
|
||||
currentTurnPcm = inputPcm
|
||||
currentTurnPcmSampleRate = inputSampleRate
|
||||
resetBrokeredToolSpeechState()
|
||||
resetRealtimeSpeechCoalescer()
|
||||
resetTtsTurnStats()
|
||||
val engineModeForTurn = voiceEngineMode
|
||||
val engineModeForTurn = if (supervisedModePolicy.enabled) {
|
||||
VoiceEngineMode.HermesVoiceOutput
|
||||
} else {
|
||||
voiceEngineMode
|
||||
}
|
||||
Log.i(
|
||||
TAG,
|
||||
"Processing voice input engine=${engineModeForTurn.storageValue} " +
|
||||
@@ -3230,7 +3303,7 @@ class VoiceViewModel(application: Application) : AndroidViewModel(application) {
|
||||
// ever mis-edited. If/when a `BuildFlavor.bridgeTier3` compile-
|
||||
// time constant exists we should still short-circuit here for
|
||||
// clarity, but today the factory already does the right thing.
|
||||
val bridgeHandler = voiceBridgeIntentHandler
|
||||
val bridgeHandler = voiceBridgeIntentHandler.takeUnless { supervisedModePolicy.enabled }
|
||||
|
||||
// === PHASE3-voice-cancel-midcountdown ===
|
||||
// Voice-in-voice cancel: if a destructive action is currently
|
||||
@@ -4906,7 +4979,8 @@ class VoiceViewModel(application: Application) : AndroidViewModel(application) {
|
||||
}
|
||||
|
||||
private fun shouldPreferRealtimeVoice(): Boolean =
|
||||
voiceOutputAvailable != false &&
|
||||
!supervisedModePolicy.enabled &&
|
||||
voiceOutputAvailable != false &&
|
||||
realtimePcmPlayer != null &&
|
||||
voiceClient != null &&
|
||||
// Use the RESOLVED route: AutoVoiceAudioClient.effectiveRoute maps
|
||||
|
||||
@@ -657,6 +657,14 @@
|
||||
<string name="settings_analytics_desc">Estatísticas de uso, TTFT, tokens e integridade</string>
|
||||
<string name="settings_diagnostics">Diagnóstico</string>
|
||||
<string name="settings_diagnostics_desc">Verificações de status e atividade recente da API, do relay, da sessão e da voz</string>
|
||||
<string name="settings_advanced">Avançado</string>
|
||||
<string name="settings_advanced_desc">Modo supervisionado e outros recursos opcionais</string>
|
||||
<string name="settings_advanced_intro">Recursos opcionais e especializados ficam aqui para manter a tela principal de Configurações organizada.</string>
|
||||
<string name="settings_supervised_mode">Modo supervisionado</string>
|
||||
<string name="settings_supervised_desc">Escolha um perfil e os recursos de chat permitidos</string>
|
||||
<string name="settings_supervised_on">Ativado</string>
|
||||
<string name="settings_supervised_on_profile">Ativado · %1$s</string>
|
||||
<string name="settings_supervised_ready_profile">Pronto · %1$s</string>
|
||||
<string name="settings_developer_options">Opções do desenvolvedor</string>
|
||||
<string name="settings_developer_options_desc">Flags de recursos, gerenciamento de dados e opções experimentais</string>
|
||||
<string name="settings_whats_new">Novidades</string>
|
||||
@@ -3850,6 +3858,48 @@
|
||||
<string name="plugins_keep">Manter</string>
|
||||
<string name="plugins_remove">Remover</string>
|
||||
<string name="plugins_remove_confirm">Remover “%1$s”? Esta página do plugin não aparecerá mais nos dispositivos Android conectados.</string>
|
||||
<string name="git_state_title">Git</string>
|
||||
<string name="git_state_back">Voltar</string>
|
||||
<string name="git_state_staged">Preparados</string>
|
||||
<string name="git_state_modified">Modificados</string>
|
||||
<string name="git_state_untracked">Não rastreados</string>
|
||||
<string name="git_state_branches">Ramos</string>
|
||||
<string name="git_state_truncated">Resultados truncados — apenas as primeiras entradas são exibidas.</string>
|
||||
<string name="git_state_no_changes">(sem alterações)</string>
|
||||
|
||||
<string name="git_state_write_grant_required">Alterações de gravação exigem a permissão de alterações de plugin. Ative "Permitir alterações de plugins (plugin.api.write)" nas configurações de permissão de Plugins.</string>
|
||||
<string name="git_state_stage">Preparar</string>
|
||||
<string name="git_state_unstage">Despreparar</string>
|
||||
<string name="git_state_discard">Descartar</string>
|
||||
<string name="git_state_commit">Confirmar</string>
|
||||
<string name="git_state_commit_title">Confirmar alterações preparadas</string>
|
||||
<string name="git_state_commit_message_hint">Mensagem do commit</string>
|
||||
<string name="git_state_commit_confirm">Confirmar</string>
|
||||
<string name="git_state_cancel">Cancelar</string>
|
||||
<string name="git_state_fetch">Buscar</string>
|
||||
<string name="git_state_pull">Puxar</string>
|
||||
<string name="git_state_push">Enviar</string>
|
||||
<string name="git_state_new_branch_hint">Nome da nova ramificação</string>
|
||||
<string name="git_state_create_branch">Criar ramificação</string>
|
||||
<string name="git_state_track_remote">Rastrear ramificação remota</string>
|
||||
<string name="git_state_switch">Alternar</string>
|
||||
<string name="git_state_current">Atual</string>
|
||||
<string name="git_state_mutation_in_progress">%1$s em andamento…</string>
|
||||
<string name="git_state_mutation_success">%1$s concluído. HEAD %2$s</string>
|
||||
<string name="git_state_mutation_failed">%1$s falhou: %2$s</string>
|
||||
<string name="git_state_confirm_discard_title">Descartar alterações?</string>
|
||||
<string name="git_state_confirm_discard_text">As alterações locais dos arquivos selecionados serão descartadas e não poderão ser recuperadas.</string>
|
||||
<string name="git_state_confirm_discard_confirm">Descartar</string>
|
||||
<string name="git_state_confirm_push_title">Enviar alterações?</string>
|
||||
<string name="git_state_confirm_push_text">Envia a ramificação atual para o repositório remoto. Isso publica os commits locais no repositório remoto.</string>
|
||||
<string name="git_state_confirm_push_confirm">Enviar</string>
|
||||
<string name="git_state_confirm_checkout_dirty_title">Alternar ramificação com alterações locais?</string>
|
||||
<string name="git_state_confirm_checkout_dirty_text">A árvore de trabalho tem alterações não confirmadas. A alternância pode carregá-las para a ramificação de destino.</string>
|
||||
<string name="git_state_confirm_checkout_confirm">Alternar</string>
|
||||
<string name="git_state_generate_message">Gerar mensagem de commit</string>
|
||||
<string name="git_state_generating_message">Gerando mensagem de commit…</string>
|
||||
<string name="git_state_push_after_commit">Enviar após o commit</string>
|
||||
<string name="git_state_switch_stash">Alternar (guardar se houver alterações)</string>
|
||||
<string name="support_bundle_review">Revisar informações de suporte</string>
|
||||
<string name="support_bundle_title">Informações de suporte</string>
|
||||
<string name="support_bundle_privacy">Nada é enviado automaticamente. Revise a exportação local com até %1$d relatórios.</string>
|
||||
|
||||
@@ -698,6 +698,14 @@
|
||||
<string name="settings_analytics_desc">使用统计、TTFT、token、健康状态</string>
|
||||
<string name="settings_diagnostics">诊断</string>
|
||||
<string name="settings_diagnostics_desc">状态检查,以及最近的 API、Relay、会话和语音活动</string>
|
||||
<string name="settings_advanced">高级</string>
|
||||
<string name="settings_advanced_desc">受监督模式和其他可选功能</string>
|
||||
<string name="settings_advanced_intro">可选和专用功能集中在此,以保持主设置界面简洁。</string>
|
||||
<string name="settings_supervised_mode">受监督模式</string>
|
||||
<string name="settings_supervised_desc">选择配置文件和允许的聊天功能</string>
|
||||
<string name="settings_supervised_on">已开启</string>
|
||||
<string name="settings_supervised_on_profile">已开启 · %1$s</string>
|
||||
<string name="settings_supervised_ready_profile">已就绪 · %1$s</string>
|
||||
<string name="settings_developer_options">开发者选项</string>
|
||||
<string name="settings_developer_options_desc">功能标志、数据管理、实验性</string>
|
||||
<string name="settings_whats_new">新功能</string>
|
||||
@@ -3938,6 +3946,48 @@
|
||||
<string name="plugins_keep">保留</string>
|
||||
<string name="plugins_remove">移除</string>
|
||||
<string name="plugins_remove_confirm">要移除“%1$s”吗?此插件页面将不再显示在已连接的 Android 设备上。</string>
|
||||
<string name="git_state_title">Git</string>
|
||||
<string name="git_state_back">返回</string>
|
||||
<string name="git_state_staged">已暂存</string>
|
||||
<string name="git_state_modified">已修改</string>
|
||||
<string name="git_state_untracked">未跟踪</string>
|
||||
<string name="git_state_branches">分支</string>
|
||||
<string name="git_state_truncated">结果已截断 — 仅显示前几条。</string>
|
||||
<string name="git_state_no_changes">(无更改)</string>
|
||||
|
||||
<string name="git_state_write_grant_required">写入更改需要插件更改权限。请在“插件”的权限设置中启用“允许插件更改 (plugin.api.write)”。</string>
|
||||
<string name="git_state_stage">暂存</string>
|
||||
<string name="git_state_unstage">取消暂存</string>
|
||||
<string name="git_state_discard">丢弃</string>
|
||||
<string name="git_state_commit">提交</string>
|
||||
<string name="git_state_commit_title">提交已暂存的更改</string>
|
||||
<string name="git_state_commit_message_hint">提交消息</string>
|
||||
<string name="git_state_commit_confirm">提交</string>
|
||||
<string name="git_state_cancel">取消</string>
|
||||
<string name="git_state_fetch">获取</string>
|
||||
<string name="git_state_pull">拉取</string>
|
||||
<string name="git_state_push">推送</string>
|
||||
<string name="git_state_new_branch_hint">新分支名称</string>
|
||||
<string name="git_state_create_branch">创建分支</string>
|
||||
<string name="git_state_track_remote">跟踪远程分支</string>
|
||||
<string name="git_state_switch">切换</string>
|
||||
<string name="git_state_current">当前</string>
|
||||
<string name="git_state_mutation_in_progress">%1$s 正在进行…</string>
|
||||
<string name="git_state_mutation_success">%1$s 已完成。HEAD %2$s</string>
|
||||
<string name="git_state_mutation_failed">%1$s 失败:%2$s</string>
|
||||
<string name="git_state_confirm_discard_title">丢弃更改?</string>
|
||||
<string name="git_state_confirm_discard_text">所选文件的本地更改将被丢弃,且无法恢复。</string>
|
||||
<string name="git_state_confirm_discard_confirm">丢弃</string>
|
||||
<string name="git_state_confirm_push_title">推送更改?</string>
|
||||
<string name="git_state_confirm_push_text">将当前分支推送到其远程仓库。这会把本地提交发送到远程仓库。</string>
|
||||
<string name="git_state_confirm_push_confirm">推送</string>
|
||||
<string name="git_state_confirm_checkout_dirty_title">切换包含本地更改的分支?</string>
|
||||
<string name="git_state_confirm_checkout_dirty_text">工作区有未提交的更改。切换后,这些更改可能会被带到目标分支。</string>
|
||||
<string name="git_state_confirm_checkout_confirm">切换</string>
|
||||
<string name="git_state_generate_message">生成提交信息</string>
|
||||
<string name="git_state_generating_message">正在生成提交信息…</string>
|
||||
<string name="git_state_push_after_commit">提交后推送</string>
|
||||
<string name="git_state_switch_stash">切换(有更改时暂存)</string>
|
||||
<string name="support_bundle_review">查看支持信息</string>
|
||||
<string name="support_bundle_title">支持信息</string>
|
||||
<string name="support_bundle_privacy">不会自动上传任何内容。请查看最多包含 %1$d 个报告的本地导出。</string>
|
||||
|
||||
@@ -698,6 +698,14 @@
|
||||
<string name="settings_analytics_desc">Nutzungsstatistiken, TTFT, Token, Status</string>
|
||||
<string name="settings_diagnostics">Diagnose</string>
|
||||
<string name="settings_diagnostics_desc">Statusprüfungen sowie letzte API-, Relay-, Sitzungs- und Sprachaktivitäten</string>
|
||||
<string name="settings_advanced">Erweitert</string>
|
||||
<string name="settings_advanced_desc">Beaufsichtigter Modus und weitere optionale Funktionen</string>
|
||||
<string name="settings_advanced_intro">Optionale und spezielle Funktionen befinden sich hier, damit die Haupteinstellungen übersichtlich bleiben.</string>
|
||||
<string name="settings_supervised_mode">Beaufsichtigter Modus</string>
|
||||
<string name="settings_supervised_desc">Profil und erlaubte Chatfunktionen auswählen</string>
|
||||
<string name="settings_supervised_on">Ein</string>
|
||||
<string name="settings_supervised_on_profile">Ein · %1$s</string>
|
||||
<string name="settings_supervised_ready_profile">Bereit · %1$s</string>
|
||||
<string name="settings_developer_options">Entwickleroptionen</string>
|
||||
<string name="settings_developer_options_desc">Funktionsschalter, Datenverwaltung, Experimente</string>
|
||||
<string name="settings_whats_new">Neuigkeiten</string>
|
||||
@@ -4010,6 +4018,48 @@
|
||||
<string name="plugins_keep">Behalten</string>
|
||||
<string name="plugins_remove">Entfernen</string>
|
||||
<string name="plugins_remove_confirm">„%1$s“ entfernen? Diese Plugin-Seite wird auf verbundenen Android-Geräten nicht mehr angezeigt.</string>
|
||||
<string name="git_state_title">Git</string>
|
||||
<string name="git_state_back">Zurück</string>
|
||||
<string name="git_state_staged">Gestaged</string>
|
||||
<string name="git_state_modified">Geändert</string>
|
||||
<string name="git_state_untracked">Unverfolgt</string>
|
||||
<string name="git_state_branches">Branches</string>
|
||||
<string name="git_state_truncated">Ergebnisse abgeschnitten – nur die ersten Einträge werden angezeigt.</string>
|
||||
<string name="git_state_no_changes">(keine Änderungen)</string>
|
||||
|
||||
<string name="git_state_write_grant_required">Für Schreibvorgänge ist die Plugin-Änderungsberechtigung erforderlich. Aktivieren Sie „Änderungen durch Plugins zulassen (plugin.api.write)“ in den Plugin-Berechtigungseinstellungen.</string>
|
||||
<string name="git_state_stage">Bereitstellen</string>
|
||||
<string name="git_state_unstage">Zurücksetzen</string>
|
||||
<string name="git_state_discard">Verwerfen</string>
|
||||
<string name="git_state_commit">Committen</string>
|
||||
<string name="git_state_commit_title">Bereitgestellte Änderungen committen</string>
|
||||
<string name="git_state_commit_message_hint">Commit-Nachricht</string>
|
||||
<string name="git_state_commit_confirm">Committen</string>
|
||||
<string name="git_state_cancel">Abbrechen</string>
|
||||
<string name="git_state_fetch">Abrufen</string>
|
||||
<string name="git_state_pull">Pullen</string>
|
||||
<string name="git_state_push">Pushen</string>
|
||||
<string name="git_state_new_branch_hint">Name des neuen Branches</string>
|
||||
<string name="git_state_create_branch">Branch erstellen</string>
|
||||
<string name="git_state_track_remote">Remote-Branch verfolgen</string>
|
||||
<string name="git_state_switch">Wechseln</string>
|
||||
<string name="git_state_current">Aktuell</string>
|
||||
<string name="git_state_mutation_in_progress">%1$s läuft…</string>
|
||||
<string name="git_state_mutation_success">%1$s abgeschlossen. HEAD %2$s</string>
|
||||
<string name="git_state_mutation_failed">%1$s fehlgeschlagen: %2$s</string>
|
||||
<string name="git_state_confirm_discard_title">Änderungen verwerfen?</string>
|
||||
<string name="git_state_confirm_discard_text">Lokale Änderungen an den ausgewählten Dateien werden verworfen und können nicht wiederhergestellt werden.</string>
|
||||
<string name="git_state_confirm_discard_confirm">Verwerfen</string>
|
||||
<string name="git_state_confirm_push_title">Änderungen pushen?</string>
|
||||
<string name="git_state_confirm_push_text">Pushen Sie den aktuellen Branch zu seinem Remote. Dabei werden lokale Commits an das Remote-Repository gesendet.</string>
|
||||
<string name="git_state_confirm_push_confirm">Pushen</string>
|
||||
<string name="git_state_confirm_checkout_dirty_title">Branch mit lokalen Änderungen wechseln?</string>
|
||||
<string name="git_state_confirm_checkout_dirty_text">Der Arbeitsbaum enthält nicht committete Änderungen. Beim Wechseln werden sie möglicherweise auf den Zielbranch übertragen.</string>
|
||||
<string name="git_state_confirm_checkout_confirm">Wechseln</string>
|
||||
<string name="git_state_generate_message">Commit-Nachricht generieren</string>
|
||||
<string name="git_state_generating_message">Commit-Nachricht wird generiert…</string>
|
||||
<string name="git_state_push_after_commit">Nach Commit pushen</string>
|
||||
<string name="git_state_switch_stash">Wechseln (bei Änderungen stashen)</string>
|
||||
<string name="support_bundle_review">Supportinformationen prüfen</string>
|
||||
<string name="support_bundle_title">Supportinformationen</string>
|
||||
<string name="support_bundle_privacy">Nichts wird automatisch hochgeladen. Prüfen Sie den lokalen Export mit bis zu %1$d Berichten.</string>
|
||||
|
||||
@@ -625,6 +625,14 @@
|
||||
<string name="settings_analytics_desc">Estadísticas de uso, TTFT, tokens, salud</string>
|
||||
<string name="settings_diagnostics">Diagnóstico</string>
|
||||
<string name="settings_diagnostics_desc">Verificaciones de estado, además de actividad reciente de API, relay, sesión y voz</string>
|
||||
<string name="settings_advanced">Avanzado</string>
|
||||
<string name="settings_advanced_desc">Modo supervisado y otras funciones opcionales</string>
|
||||
<string name="settings_advanced_intro">Las funciones opcionales y especializadas están aquí para mantener despejada la pantalla principal de Ajustes.</string>
|
||||
<string name="settings_supervised_mode">Modo supervisado</string>
|
||||
<string name="settings_supervised_desc">Elige un perfil y las funciones de chat permitidas</string>
|
||||
<string name="settings_supervised_on">Activado</string>
|
||||
<string name="settings_supervised_on_profile">Activado · %1$s</string>
|
||||
<string name="settings_supervised_ready_profile">Listo · %1$s</string>
|
||||
<string name="settings_developer_options">Opciones de desarrollador</string>
|
||||
<string name="settings_developer_options_desc">Indicadores de funciones, gestión de datos, experimental.</string>
|
||||
<string name="settings_whats_new">Novedades</string>
|
||||
@@ -3695,6 +3703,48 @@
|
||||
<string name="plugins_keep">Conservar</string>
|
||||
<string name="plugins_remove">Eliminar</string>
|
||||
<string name="plugins_remove_confirm">¿Eliminar «%1$s»? Esta página del plugin dejará de aparecer en los dispositivos Android conectados.</string>
|
||||
<string name="git_state_title">Git</string>
|
||||
<string name="git_state_back">Atrás</string>
|
||||
<string name="git_state_staged">Preparados</string>
|
||||
<string name="git_state_modified">Modificados</string>
|
||||
<string name="git_state_untracked">Sin seguimiento</string>
|
||||
<string name="git_state_branches">Ramas</string>
|
||||
<string name="git_state_truncated">Resultados truncados: solo se muestran las primeras entradas.</string>
|
||||
<string name="git_state_no_changes">(sin cambios)</string>
|
||||
|
||||
<string name="git_state_write_grant_required">Los cambios de escritura requieren el permiso de cambios de plugin. Activa «Permitir cambios de plugins (plugin.api.write)» en la configuración de permisos de Plugins.</string>
|
||||
<string name="git_state_stage">Preparar</string>
|
||||
<string name="git_state_unstage">Quitar de preparados</string>
|
||||
<string name="git_state_discard">Descartar</string>
|
||||
<string name="git_state_commit">Confirmar</string>
|
||||
<string name="git_state_commit_title">Confirmar cambios preparados</string>
|
||||
<string name="git_state_commit_message_hint">Mensaje de confirmación</string>
|
||||
<string name="git_state_commit_confirm">Confirmar</string>
|
||||
<string name="git_state_cancel">Cancelar</string>
|
||||
<string name="git_state_fetch">Obtener</string>
|
||||
<string name="git_state_pull">Traer</string>
|
||||
<string name="git_state_push">Enviar</string>
|
||||
<string name="git_state_new_branch_hint">Nombre de la nueva rama</string>
|
||||
<string name="git_state_create_branch">Crear rama</string>
|
||||
<string name="git_state_track_remote">Seguir rama remota</string>
|
||||
<string name="git_state_switch">Cambiar</string>
|
||||
<string name="git_state_current">Actual</string>
|
||||
<string name="git_state_mutation_in_progress">%1$s en curso…</string>
|
||||
<string name="git_state_mutation_success">%1$s completado. HEAD %2$s</string>
|
||||
<string name="git_state_mutation_failed">%1$s falló: %2$s</string>
|
||||
<string name="git_state_confirm_discard_title">¿Descartar cambios?</string>
|
||||
<string name="git_state_confirm_discard_text">Los cambios locales de los archivos seleccionados se descartarán y no podrán recuperarse.</string>
|
||||
<string name="git_state_confirm_discard_confirm">Descartar</string>
|
||||
<string name="git_state_confirm_push_title">¿Enviar cambios?</string>
|
||||
<string name="git_state_confirm_push_text">Envía la rama actual a su remoto. Esto sube los commits locales al repositorio remoto.</string>
|
||||
<string name="git_state_confirm_push_confirm">Enviar</string>
|
||||
<string name="git_state_confirm_checkout_dirty_title">¿Cambiar de rama con cambios locales?</string>
|
||||
<string name="git_state_confirm_checkout_dirty_text">El árbol de trabajo tiene cambios sin confirmar. Cambiar puede trasladarlos a la rama de destino.</string>
|
||||
<string name="git_state_confirm_checkout_confirm">Cambiar</string>
|
||||
<string name="git_state_generate_message">Generar mensaje de confirmación</string>
|
||||
<string name="git_state_generating_message">Generando mensaje de confirmación…</string>
|
||||
<string name="git_state_push_after_commit">Enviar después de confirmar</string>
|
||||
<string name="git_state_switch_stash">Cambiar (guardar en stash si hay cambios)</string>
|
||||
<string name="support_bundle_review">Revisar información de soporte</string>
|
||||
<string name="support_bundle_title">Información de soporte</string>
|
||||
<string name="support_bundle_privacy">Nada se sube automáticamente. Revisa la exportación local con hasta %1$d informes.</string>
|
||||
|
||||
@@ -698,6 +698,14 @@
|
||||
<string name="settings_analytics_desc">使用状況統計、TTFT、トークン、ヘルス</string>
|
||||
<string name="settings_diagnostics">診断</string>
|
||||
<string name="settings_diagnostics_desc">ステータス チェック、および最近の API、Relay、セッション、および音声アクティビティ</string>
|
||||
<string name="settings_advanced">詳細設定</string>
|
||||
<string name="settings_advanced_desc">監督モードとその他のオプション機能</string>
|
||||
<string name="settings_advanced_intro">メインの設定画面をシンプルに保つため、オプション機能と専門機能はここにまとめられています。</string>
|
||||
<string name="settings_supervised_mode">監督モード</string>
|
||||
<string name="settings_supervised_desc">プロファイルと許可するチャット機能を選択</string>
|
||||
<string name="settings_supervised_on">オン</string>
|
||||
<string name="settings_supervised_on_profile">オン · %1$s</string>
|
||||
<string name="settings_supervised_ready_profile">準備完了 · %1$s</string>
|
||||
<string name="settings_developer_options">開発者向けオプション</string>
|
||||
<string name="settings_developer_options_desc">機能フラグ、データ管理、実験的</string>
|
||||
<string name="settings_whats_new">新着情報</string>
|
||||
@@ -4009,6 +4017,48 @@
|
||||
<string name="plugins_keep">保持</string>
|
||||
<string name="plugins_remove">削除</string>
|
||||
<string name="plugins_remove_confirm">「%1$s」を削除しますか?接続された Android 端末にこのプラグインページは表示されなくなります。</string>
|
||||
<string name="git_state_title">Git</string>
|
||||
<string name="git_state_back">戻る</string>
|
||||
<string name="git_state_staged">ステージ済み</string>
|
||||
<string name="git_state_modified">変更</string>
|
||||
<string name="git_state_untracked">未追跡</string>
|
||||
<string name="git_state_branches">ブランチ</string>
|
||||
<string name="git_state_truncated">結果は切り詰められています。最初の項目のみ表示されます。</string>
|
||||
<string name="git_state_no_changes">(変更なし)</string>
|
||||
|
||||
<string name="git_state_write_grant_required">書き込み変更にはプラグイン変更権限が必要です。Plugins の権限設定で「プラグインの変更を許可する(plugin.api.write)」を有効にしてください。</string>
|
||||
<string name="git_state_stage">ステージ</string>
|
||||
<string name="git_state_unstage">ステージ解除</string>
|
||||
<string name="git_state_discard">破棄</string>
|
||||
<string name="git_state_commit">コミット</string>
|
||||
<string name="git_state_commit_title">ステージ済みの変更をコミット</string>
|
||||
<string name="git_state_commit_message_hint">コミットメッセージ</string>
|
||||
<string name="git_state_commit_confirm">コミット</string>
|
||||
<string name="git_state_cancel">キャンセル</string>
|
||||
<string name="git_state_fetch">フェッチ</string>
|
||||
<string name="git_state_pull">プル</string>
|
||||
<string name="git_state_push">プッシュ</string>
|
||||
<string name="git_state_new_branch_hint">新しいブランチ名</string>
|
||||
<string name="git_state_create_branch">ブランチを作成</string>
|
||||
<string name="git_state_track_remote">リモートブランチを追跡</string>
|
||||
<string name="git_state_switch">切り替え</string>
|
||||
<string name="git_state_current">現在</string>
|
||||
<string name="git_state_mutation_in_progress">%1$s を実行中…</string>
|
||||
<string name="git_state_mutation_success">%1$s が完了しました。HEAD %2$s</string>
|
||||
<string name="git_state_mutation_failed">%1$s に失敗しました: %2$s</string>
|
||||
<string name="git_state_confirm_discard_title">変更を破棄しますか?</string>
|
||||
<string name="git_state_confirm_discard_text">選択したファイルへのローカル変更は破棄され、元に戻せません。</string>
|
||||
<string name="git_state_confirm_discard_confirm">破棄</string>
|
||||
<string name="git_state_confirm_push_title">変更をプッシュしますか?</string>
|
||||
<string name="git_state_confirm_push_text">現在のブランチをリモートにプッシュします。ローカルのコミットがリモートリポジトリに送信されます。</string>
|
||||
<string name="git_state_confirm_push_confirm">プッシュ</string>
|
||||
<string name="git_state_confirm_checkout_dirty_title">ローカル変更のあるブランチに切り替えますか?</string>
|
||||
<string name="git_state_confirm_checkout_dirty_text">作業ツリーに未コミットの変更があります。切り替えると、それらが対象ブランチへ引き継がれる可能性があります。</string>
|
||||
<string name="git_state_confirm_checkout_confirm">切り替え</string>
|
||||
<string name="git_state_generate_message">コミットメッセージを生成</string>
|
||||
<string name="git_state_generating_message">コミットメッセージを生成中…</string>
|
||||
<string name="git_state_push_after_commit">コミット後にプッシュ</string>
|
||||
<string name="git_state_switch_stash">切り替え(変更があればスタッシュ)</string>
|
||||
<string name="support_bundle_review">サポート情報を確認</string>
|
||||
<string name="support_bundle_title">サポート情報</string>
|
||||
<string name="support_bundle_privacy">自動アップロードはありません。最大 %1$d 件のレポートを含む端末内エクスポートを確認してください。</string>
|
||||
|
||||
@@ -668,6 +668,14 @@
|
||||
<string name="settings_analytics_desc">Статистика использования, TTFT, токены, состояние</string>
|
||||
<string name="settings_diagnostics">Диагностика</string>
|
||||
<string name="settings_diagnostics_desc">Проверка состояния, а также недавняя активность API, Relay, сессий и голосовых данных</string>
|
||||
<string name="settings_advanced">Дополнительно</string>
|
||||
<string name="settings_advanced_desc">Режим с контролем и другие дополнительные функции</string>
|
||||
<string name="settings_advanced_intro">Дополнительные и специальные функции собраны здесь, чтобы не перегружать главный экран настроек.</string>
|
||||
<string name="settings_supervised_mode">Режим с контролем</string>
|
||||
<string name="settings_supervised_desc">Выберите профиль и разрешённые функции чата</string>
|
||||
<string name="settings_supervised_on">Вкл.</string>
|
||||
<string name="settings_supervised_on_profile">Вкл. · %1$s</string>
|
||||
<string name="settings_supervised_ready_profile">Готово · %1$s</string>
|
||||
<string name="settings_developer_options">Настройки разработчика</string>
|
||||
<string name="settings_developer_options_desc">Флаги функций, управление данными, экспериментальные</string>
|
||||
<string name="settings_whats_new">Что нового</string>
|
||||
@@ -3731,6 +3739,48 @@
|
||||
<string name="plugins_keep">Оставить</string>
|
||||
<string name="plugins_remove">Удалить</string>
|
||||
<string name="plugins_remove_confirm">Удалить «%1$s»? Эта страница плагина больше не будет отображаться на подключённых устройствах Android.</string>
|
||||
<string name="git_state_title">Git</string>
|
||||
<string name="git_state_back">Назад</string>
|
||||
<string name="git_state_staged">В индексе</string>
|
||||
<string name="git_state_modified">Изменённые</string>
|
||||
<string name="git_state_untracked">Неотслеживаемые</string>
|
||||
<string name="git_state_branches">Ветки</string>
|
||||
<string name="git_state_truncated">Результаты усечены — показаны только первые записи.</string>
|
||||
<string name="git_state_no_changes">(без изменений)</string>
|
||||
|
||||
<string name="git_state_write_grant_required">Для записи изменений требуется разрешение на изменение плагина. Включите «Разрешить изменения плагинов (plugin.api.write)» в настройках разрешений Plugins.</string>
|
||||
<string name="git_state_stage">Добавить в индекс</string>
|
||||
<string name="git_state_unstage">Убрать из индекса</string>
|
||||
<string name="git_state_discard">Отменить</string>
|
||||
<string name="git_state_commit">Коммит</string>
|
||||
<string name="git_state_commit_title">Зафиксировать индексированные изменения</string>
|
||||
<string name="git_state_commit_message_hint">Сообщение коммита</string>
|
||||
<string name="git_state_commit_confirm">Коммит</string>
|
||||
<string name="git_state_cancel">Отмена</string>
|
||||
<string name="git_state_fetch">Обновить</string>
|
||||
<string name="git_state_pull">Вытянуть</string>
|
||||
<string name="git_state_push">Отправить</string>
|
||||
<string name="git_state_new_branch_hint">Имя новой ветки</string>
|
||||
<string name="git_state_create_branch">Создать ветку</string>
|
||||
<string name="git_state_track_remote">Отслеживать удалённую ветку</string>
|
||||
<string name="git_state_switch">Переключить</string>
|
||||
<string name="git_state_current">Текущая</string>
|
||||
<string name="git_state_mutation_in_progress">%1$s выполняется…</string>
|
||||
<string name="git_state_mutation_success">%1$s завершено. HEAD %2$s</string>
|
||||
<string name="git_state_mutation_failed">%1$s не удалось: %2$s</string>
|
||||
<string name="git_state_confirm_discard_title">Отменить изменения?</string>
|
||||
<string name="git_state_confirm_discard_text">Локальные изменения выбранных файлов будут отменены и их нельзя будет восстановить.</string>
|
||||
<string name="git_state_confirm_discard_confirm">Отменить</string>
|
||||
<string name="git_state_confirm_push_title">Отправить изменения?</string>
|
||||
<string name="git_state_confirm_push_text">Отправить текущую ветку в её удалённый репозиторий. Это передаст локальные коммиты в удалённый репозиторий.</string>
|
||||
<string name="git_state_confirm_push_confirm">Отправить</string>
|
||||
<string name="git_state_confirm_checkout_dirty_title">Переключить ветку с локальными изменениями?</string>
|
||||
<string name="git_state_confirm_checkout_dirty_text">В рабочем дереве есть незакоммиченные изменения. Переключение может перенести их в целевую ветку.</string>
|
||||
<string name="git_state_confirm_checkout_confirm">Переключить</string>
|
||||
<string name="git_state_generate_message">Создать сообщение коммита</string>
|
||||
<string name="git_state_generating_message">Создание сообщения коммита…</string>
|
||||
<string name="git_state_push_after_commit">Отправить после коммита</string>
|
||||
<string name="git_state_switch_stash">Переключить (сохранить изменения, если есть)</string>
|
||||
<string name="support_bundle_review">Проверить сведения для поддержки</string>
|
||||
<string name="support_bundle_title">Сведения для поддержки</string>
|
||||
<string name="support_bundle_privacy">Ничего не загружается автоматически. Проверьте локальный экспорт с не более чем %1$d отчётами.</string>
|
||||
|
||||
@@ -736,6 +736,14 @@
|
||||
<string name="settings_analytics_desc">Usage stats, TTFT, tokens, health</string>
|
||||
<string name="settings_diagnostics">Diagnostics</string>
|
||||
<string name="settings_diagnostics_desc">Status checks, plus recent API, relay, session, and voice activity</string>
|
||||
<string name="settings_advanced">Advanced</string>
|
||||
<string name="settings_advanced_desc">Supervised mode and other optional features</string>
|
||||
<string name="settings_advanced_intro">Optional and specialized features live here to keep the main Settings screen focused.</string>
|
||||
<string name="settings_supervised_mode">Supervised mode</string>
|
||||
<string name="settings_supervised_desc">Choose a profile and approved chat features</string>
|
||||
<string name="settings_supervised_on">On</string>
|
||||
<string name="settings_supervised_on_profile">On · %1$s</string>
|
||||
<string name="settings_supervised_ready_profile">Ready · %1$s</string>
|
||||
<string name="settings_developer_options">Developer options</string>
|
||||
<string name="settings_developer_options_desc">Feature flags, data management, experimental</string>
|
||||
<string name="settings_whats_new">What\'s New</string>
|
||||
@@ -4206,6 +4214,51 @@
|
||||
<string name="plugins_keep">Keep</string>
|
||||
<string name="plugins_remove">Remove</string>
|
||||
<string name="plugins_remove_confirm">Remove “%1$s”? This plugin page will no longer appear on connected Android devices.</string>
|
||||
<string name="git_state_title">Git</string>
|
||||
<string name="git_state_back">Back</string>
|
||||
<string name="git_state_staged">Staged</string>
|
||||
<string name="git_state_modified">Modified</string>
|
||||
<string name="git_state_untracked">Untracked</string>
|
||||
<string name="git_state_branches">Branches</string>
|
||||
<string name="git_state_truncated">Results truncated — showing the first entries only.</string>
|
||||
<string name="git_state_no_changes">(no changes)</string>
|
||||
|
||||
<!-- Git write surface (Phase 2). All mutations require the plugin.api.write grant. -->
|
||||
<string name="git_state_write_grant_required">Write changes require the plugin change permission. Enable “Allow plugin changes (plugin.api.write)” in the Plugins grant settings.</string>
|
||||
<string name="git_state_stage">Stage</string>
|
||||
<string name="git_state_unstage">Unstage</string>
|
||||
<string name="git_state_discard">Discard</string>
|
||||
<string name="git_state_commit">Commit</string>
|
||||
<string name="git_state_commit_title">Commit staged changes</string>
|
||||
<string name="git_state_commit_message_hint">Commit message</string>
|
||||
<string name="git_state_commit_confirm">Commit</string>
|
||||
<string name="git_state_cancel">Cancel</string>
|
||||
<string name="git_state_fetch">Fetch</string>
|
||||
<string name="git_state_pull">Pull</string>
|
||||
<string name="git_state_push">Push</string>
|
||||
<string name="git_state_new_branch_hint">New branch name</string>
|
||||
<string name="git_state_create_branch">Create branch</string>
|
||||
<string name="git_state_track_remote">Track remote branch</string>
|
||||
<string name="git_state_switch">Switch</string>
|
||||
<string name="git_state_current">Current</string>
|
||||
<string name="git_state_mutation_in_progress">%1$s in progress…</string>
|
||||
<string name="git_state_mutation_success">%1$s completed. HEAD %2$s</string>
|
||||
<string name="git_state_mutation_failed">%1$s failed: %2$s</string>
|
||||
<!-- Destructive-op confirmations (token is sent only on explicit user confirmation). -->
|
||||
<string name="git_state_confirm_discard_title">Discard changes?</string>
|
||||
<string name="git_state_confirm_discard_text">Local changes to the selected file(s) will be discarded and cannot be recovered.</string>
|
||||
<string name="git_state_confirm_discard_confirm">Discard</string>
|
||||
<string name="git_state_confirm_push_title">Push changes?</string>
|
||||
<string name="git_state_confirm_push_text">Push the current branch to its remote. This sends local commits to the remote repository.</string>
|
||||
<string name="git_state_confirm_push_confirm">Push</string>
|
||||
<string name="git_state_confirm_checkout_dirty_title">Switch branch with local changes?</string>
|
||||
<string name="git_state_confirm_checkout_dirty_text">The working tree has uncommitted changes. Switching may carry them onto the target branch.</string>
|
||||
<string name="git_state_confirm_checkout_confirm">Switch</string>
|
||||
<string name="git_state_generate_message">Generate commit message</string>
|
||||
<string name="git_state_generating_message">Generating commit message…</string>
|
||||
<string name="git_state_push_after_commit">Push after commit</string>
|
||||
<string name="git_state_switch_stash">Switch (stash if dirty)</string>
|
||||
|
||||
<string name="support_bundle_review">Review support information</string>
|
||||
<string name="support_bundle_title">Support information</string>
|
||||
<string name="support_bundle_privacy">Nothing is uploaded automatically. Review the exact local export below. It contains up to %1$d recent reports.</string>
|
||||
|
||||
@@ -0,0 +1,55 @@
|
||||
package com.hermesandroid.relay.auth
|
||||
|
||||
import com.hermesandroid.relay.data.SupervisedCapabilities
|
||||
import com.hermesandroid.relay.data.SupervisedModePolicy
|
||||
import kotlinx.serialization.json.boolean
|
||||
import kotlinx.serialization.json.jsonArray
|
||||
import kotlinx.serialization.json.jsonObject
|
||||
import kotlinx.serialization.json.jsonPrimitive
|
||||
import org.junit.Assert.assertEquals
|
||||
import org.junit.Assert.assertFalse
|
||||
import org.junit.Assert.assertTrue
|
||||
import org.junit.Test
|
||||
|
||||
class SupervisedModeAuthPayloadTest {
|
||||
@Test fun `active policy reports only public capability ids`() {
|
||||
val payload = relaySupervisedModePayload(
|
||||
SupervisedModePolicy(
|
||||
enabled = true,
|
||||
pinnedProfileName = "willow",
|
||||
capabilities = SupervisedCapabilities(attachments = true, voice = true),
|
||||
),
|
||||
)
|
||||
assertTrue(payload.getValue("active").jsonPrimitive.boolean)
|
||||
assertEquals("willow", payload.getValue("profile_label").jsonPrimitive.content)
|
||||
val capabilities = payload.getValue("capabilities").jsonArray.map { it.jsonPrimitive.content }
|
||||
assertTrue("text_chat" in capabilities)
|
||||
assertTrue("attachments" in capabilities)
|
||||
assertTrue("voice" in capabilities)
|
||||
assertFalse(capabilities.any { it.contains("model") || it.contains("tool") })
|
||||
}
|
||||
|
||||
@Test fun `inactive update explicitly clears Relay tag`() {
|
||||
val payload = relaySupervisedModePayload(SupervisedModePolicy())
|
||||
assertFalse(payload.getValue("active").jsonPrimitive.boolean)
|
||||
assertEquals(setOf("active"), payload.keys)
|
||||
}
|
||||
|
||||
@Test fun `live update uses typed correlated system envelope`() {
|
||||
val envelope = relaySupervisedModeUpdateEnvelope(
|
||||
SupervisedModePolicy(
|
||||
enabled = true,
|
||||
pinnedProfileName = "willow",
|
||||
capabilities = SupervisedCapabilities(voice = true),
|
||||
),
|
||||
)
|
||||
|
||||
assertEquals("system", envelope.channel)
|
||||
assertEquals("supervised.update", envelope.type)
|
||||
assertTrue(envelope.id.isNotBlank())
|
||||
val mode = envelope.payload.getValue("supervised_mode")
|
||||
.jsonObject
|
||||
assertTrue(mode.getValue("active").jsonPrimitive.boolean)
|
||||
assertEquals("willow", mode.getValue("profile_label").jsonPrimitive.content)
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,221 @@
|
||||
package com.hermesandroid.relay.data
|
||||
|
||||
import androidx.datastore.core.DataStore
|
||||
import androidx.datastore.preferences.core.Preferences
|
||||
import androidx.datastore.preferences.core.emptyPreferences
|
||||
import androidx.datastore.preferences.core.mutablePreferencesOf
|
||||
import kotlinx.coroutines.flow.Flow
|
||||
import kotlinx.coroutines.flow.MutableStateFlow
|
||||
import kotlinx.coroutines.flow.first
|
||||
import kotlinx.coroutines.test.runTest
|
||||
import org.junit.Assert.assertEquals
|
||||
import org.junit.Assert.assertFalse
|
||||
import org.junit.Assert.assertTrue
|
||||
import org.junit.Test
|
||||
|
||||
class SupervisedModeStoreTest {
|
||||
|
||||
@Test
|
||||
fun freshConnectionUsesRestrictiveDefaults() = runTest {
|
||||
val store = SupervisedModeStore.forTesting(InMemorySupervisedPreferencesDataStore())
|
||||
|
||||
val policy = store.policyFlow("connection-a").first()
|
||||
|
||||
assertFalse(policy.enabled)
|
||||
assertFalse(policy.isConfigured)
|
||||
assertFalse(policy.isActive)
|
||||
assertFalse(policy.capabilities.attachments)
|
||||
assertFalse(policy.capabilities.voice)
|
||||
assertFalse(policy.visibility.resolved().showModelName)
|
||||
assertFalse(policy.visibility.resolved().showTechnicalRoute)
|
||||
assertTrue(policy.parentAccess.requireDeviceAuthentication)
|
||||
assertEquals(5, policy.parentAccess.timeoutMinutes)
|
||||
}
|
||||
|
||||
@Test
|
||||
fun policyRoundTripsWithCapabilitiesLimitsAndVisibility() = runTest {
|
||||
val dataStore = InMemorySupervisedPreferencesDataStore()
|
||||
val store = SupervisedModeStore.forTesting(dataStore)
|
||||
val saved = SupervisedModePolicy(
|
||||
enabled = true,
|
||||
pinnedProfileName = " willow ",
|
||||
capabilities = SupervisedCapabilities(
|
||||
attachments = true,
|
||||
voice = true,
|
||||
attachmentMaxCount = 6,
|
||||
attachmentMaxFileMb = 20,
|
||||
attachmentCategories = setOf(
|
||||
SupervisedAttachmentCategory.Images,
|
||||
SupervisedAttachmentCategory.Documents,
|
||||
),
|
||||
sessionActions = SupervisedSessionActions(
|
||||
pin = true,
|
||||
rename = true,
|
||||
shareTranscript = true,
|
||||
),
|
||||
),
|
||||
appearance = SupervisedAppearance(
|
||||
appThemeId = "rose",
|
||||
themePreference = "dark",
|
||||
showPet = true,
|
||||
allowProfileIconChanges = true,
|
||||
allowBackgroundChanges = true,
|
||||
),
|
||||
visibility = SupervisedVisibility(
|
||||
preset = SupervisedVisibilityPreset.Custom,
|
||||
showAgentIdentity = true,
|
||||
showModelName = true,
|
||||
showToolNames = true,
|
||||
),
|
||||
)
|
||||
|
||||
store.setPolicy("connection-a", saved)
|
||||
val restored = SupervisedModeStore.forTesting(dataStore).policyFlow("connection-a").first()
|
||||
|
||||
assertTrue(restored.isActive)
|
||||
assertEquals("willow", restored.pinnedProfileName)
|
||||
assertEquals(6, restored.capabilities.attachmentMaxCount)
|
||||
assertEquals(20, restored.capabilities.attachmentMaxFileMb)
|
||||
assertEquals(saved.capabilities.attachmentCategories, restored.capabilities.attachmentCategories)
|
||||
assertEquals(saved.capabilities.sessionActions, restored.capabilities.sessionActions)
|
||||
assertEquals("rose", restored.appearance.appThemeId)
|
||||
assertEquals("dark", restored.appearance.themePreference)
|
||||
assertTrue(restored.appearance.showPet)
|
||||
assertTrue(restored.appearance.allowProfileIconChanges)
|
||||
assertTrue(restored.appearance.allowBackgroundChanges)
|
||||
assertEquals(SupervisedVisibilityPreset.Custom, restored.visibility.preset)
|
||||
assertTrue(restored.visibility.showModelName)
|
||||
assertTrue(restored.visibility.showToolNames)
|
||||
}
|
||||
|
||||
@Test
|
||||
fun connectionsAreIsolatedAndClearRemovesOnlyTarget() = runTest {
|
||||
val store = SupervisedModeStore.forTesting(InMemorySupervisedPreferencesDataStore())
|
||||
store.setPolicy("connection-a", SupervisedModePolicy(true, "willow"))
|
||||
store.setPolicy("connection-b", SupervisedModePolicy(true, "juniper"))
|
||||
|
||||
store.clear("connection-a")
|
||||
|
||||
assertFalse(store.policyFlow("connection-a").first().enabled)
|
||||
assertEquals("juniper", store.policyFlow("connection-b").first().pinnedProfileName)
|
||||
}
|
||||
|
||||
@Test
|
||||
fun updateAndSetEnabledPreserveOtherPolicyFields() = runTest {
|
||||
val store = SupervisedModeStore.forTesting(InMemorySupervisedPreferencesDataStore())
|
||||
store.setPolicy(
|
||||
"connection-a",
|
||||
SupervisedModePolicy(
|
||||
pinnedProfileName = "willow",
|
||||
capabilities = SupervisedCapabilities(voice = true),
|
||||
),
|
||||
)
|
||||
|
||||
store.setEnabled("connection-a", true)
|
||||
store.updatePolicy("connection-a") {
|
||||
it.copy(visibility = it.visibility.copy(preset = SupervisedVisibilityPreset.Transparent))
|
||||
}
|
||||
|
||||
val policy = store.policyFlow("connection-a").first()
|
||||
assertTrue(policy.isActive)
|
||||
assertTrue(policy.capabilities.voice)
|
||||
assertEquals(SupervisedVisibilityPreset.Transparent, policy.visibility.preset)
|
||||
}
|
||||
|
||||
@Test
|
||||
fun invalidLimitsAreNormalizedAndEmptyCategoriesFallBackToImages() = runTest {
|
||||
val store = SupervisedModeStore.forTesting(InMemorySupervisedPreferencesDataStore())
|
||||
store.setPolicy(
|
||||
"connection-a",
|
||||
SupervisedModePolicy(
|
||||
pinnedProfileName = "willow",
|
||||
capabilities = SupervisedCapabilities(
|
||||
attachmentMaxCount = Int.MAX_VALUE,
|
||||
attachmentMaxFileMb = -1,
|
||||
attachmentCategories = emptySet(),
|
||||
),
|
||||
parentAccess = SupervisedParentAccess(
|
||||
requireDeviceAuthentication = false,
|
||||
timeoutMinutes = 0,
|
||||
),
|
||||
appearance = SupervisedAppearance(
|
||||
appThemeId = "missing-theme",
|
||||
themePreference = "sepia",
|
||||
),
|
||||
),
|
||||
)
|
||||
|
||||
val policy = store.policyFlow("connection-a").first()
|
||||
assertEquals(SupervisedCapabilities.MAX_ATTACHMENT_COUNT, policy.capabilities.attachmentMaxCount)
|
||||
assertEquals(1, policy.capabilities.attachmentMaxFileMb)
|
||||
assertEquals(setOf(SupervisedAttachmentCategory.Images), policy.capabilities.attachmentCategories)
|
||||
assertTrue(policy.parentAccess.requireDeviceAuthentication)
|
||||
assertEquals(SupervisedParentAccess.MIN_TIMEOUT_MINUTES, policy.parentAccess.timeoutMinutes)
|
||||
assertEquals("hermes-relay", policy.appearance.appThemeId)
|
||||
assertEquals("auto", policy.appearance.themePreference)
|
||||
}
|
||||
|
||||
@Test
|
||||
fun simplePresetResolvesToSafeValuesEvenIfStoredFlagsDiffer() {
|
||||
val visibility = SupervisedVisibility(
|
||||
preset = SupervisedVisibilityPreset.Simple,
|
||||
showModelName = true,
|
||||
showTechnicalRoute = true,
|
||||
showReasoning = true,
|
||||
).resolved()
|
||||
|
||||
assertFalse(visibility.showModelName)
|
||||
assertFalse(visibility.showTechnicalRoute)
|
||||
assertFalse(visibility.showReasoning)
|
||||
assertTrue(visibility.showAgentIdentity)
|
||||
assertTrue(visibility.showConnectionStatus)
|
||||
}
|
||||
|
||||
@Test
|
||||
fun malformedPersistedPolicyFailsClosed() = runTest {
|
||||
val policyKey = androidx.datastore.preferences.core.stringPreferencesKey(
|
||||
"supervised_mode_policies_v1",
|
||||
)
|
||||
val dataStore = InMemorySupervisedPreferencesDataStore(
|
||||
mutablePreferencesOf(policyKey to "{not-valid-json"),
|
||||
)
|
||||
|
||||
val policy = SupervisedModeStore.forTesting(dataStore)
|
||||
.policyFlow("connection-a")
|
||||
.first()
|
||||
|
||||
assertTrue(policy.enabled)
|
||||
assertFalse(policy.isConfigured)
|
||||
assertFalse(policy.isActive)
|
||||
}
|
||||
|
||||
@Test
|
||||
fun clearAllDoesNotClearUnrelatedPreferences() = runTest {
|
||||
val unrelatedKey = androidx.datastore.preferences.core.stringPreferencesKey("unrelated")
|
||||
val dataStore = InMemorySupervisedPreferencesDataStore(
|
||||
mutablePreferencesOf(unrelatedKey to "kept"),
|
||||
)
|
||||
val store = SupervisedModeStore.forTesting(dataStore)
|
||||
store.setPolicy("connection-a", SupervisedModePolicy(true, "willow"))
|
||||
|
||||
store.clearAll()
|
||||
|
||||
assertFalse(store.policyFlow("connection-a").first().enabled)
|
||||
assertEquals("kept", dataStore.data.first()[unrelatedKey])
|
||||
}
|
||||
}
|
||||
|
||||
private class InMemorySupervisedPreferencesDataStore(
|
||||
initial: Preferences = emptyPreferences(),
|
||||
) : DataStore<Preferences> {
|
||||
private val state = MutableStateFlow(initial)
|
||||
override val data: Flow<Preferences> = state
|
||||
|
||||
override suspend fun updateData(
|
||||
transform: suspend (t: Preferences) -> Preferences,
|
||||
): Preferences {
|
||||
val updated = transform(state.value)
|
||||
state.value = updated
|
||||
return updated
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,41 @@
|
||||
package com.hermesandroid.relay.data
|
||||
|
||||
import org.junit.Assert.assertEquals
|
||||
import org.junit.Assert.assertFalse
|
||||
import org.junit.Assert.assertTrue
|
||||
import org.junit.Test
|
||||
|
||||
class SupervisedSessionPolicyTest {
|
||||
@Test fun `session action summary derives none mixed and all`() {
|
||||
val none = SupervisedSessionActions()
|
||||
val mixed = none.copy(rename = true, delete = true)
|
||||
val all = none.withAll(true)
|
||||
|
||||
assertTrue(none.noneEnabled)
|
||||
assertEquals(2, mixed.enabledCount)
|
||||
assertFalse(mixed.noneEnabled)
|
||||
assertFalse(mixed.allEnabled)
|
||||
assertTrue(all.allEnabled)
|
||||
assertEquals(SupervisedSessionActions.TOTAL, all.enabledCount)
|
||||
}
|
||||
|
||||
@Test fun `supervised history and granular flag are both required`() {
|
||||
val base = SupervisedModePolicy(
|
||||
enabled = true,
|
||||
pinnedProfileName = "willow",
|
||||
capabilities = SupervisedCapabilities(
|
||||
conversationHistory = true,
|
||||
sessionActions = SupervisedSessionActions(rename = true),
|
||||
),
|
||||
)
|
||||
|
||||
assertTrue(base.allowsSessionAction(SupervisedSessionAction.Rename))
|
||||
assertFalse(base.allowsSessionAction(SupervisedSessionAction.Delete))
|
||||
assertFalse(
|
||||
base.copy(
|
||||
capabilities = base.capabilities.copy(conversationHistory = false),
|
||||
).allowsSessionAction(SupervisedSessionAction.Rename),
|
||||
)
|
||||
assertTrue(SupervisedModePolicy().allowsSessionAction(SupervisedSessionAction.Delete))
|
||||
}
|
||||
}
|
||||
+33
@@ -0,0 +1,33 @@
|
||||
package com.hermesandroid.relay.network.relay
|
||||
|
||||
import com.hermesandroid.relay.network.relay.models.Envelope
|
||||
import org.junit.Assert.assertEquals
|
||||
import org.junit.Test
|
||||
|
||||
class ChannelMultiplexerSupervisedUpdateTest {
|
||||
@Test fun `supervised update acknowledgement reaches system auth handler`() {
|
||||
val multiplexer = ChannelMultiplexer()
|
||||
val received = mutableListOf<Envelope>()
|
||||
multiplexer.registerHandler("system") { received += it }
|
||||
|
||||
val acknowledgement = Envelope(
|
||||
channel = "system",
|
||||
type = "supervised.updated",
|
||||
id = "update-1",
|
||||
)
|
||||
multiplexer.route(acknowledgement)
|
||||
|
||||
assertEquals(listOf(acknowledgement), received)
|
||||
}
|
||||
|
||||
@Test fun `correlated system error reaches system auth handler`() {
|
||||
val multiplexer = ChannelMultiplexer()
|
||||
val received = mutableListOf<Envelope>()
|
||||
multiplexer.registerHandler("system") { received += it }
|
||||
|
||||
val error = Envelope(channel = "system", type = "error", id = "update-2")
|
||||
multiplexer.route(error)
|
||||
|
||||
assertEquals(listOf(error), received)
|
||||
}
|
||||
}
|
||||
+37
@@ -0,0 +1,37 @@
|
||||
package com.hermesandroid.relay.network.shared
|
||||
|
||||
import com.hermesandroid.relay.data.VoiceAudioRoute
|
||||
import java.io.File
|
||||
import kotlinx.coroutines.test.runTest
|
||||
import org.junit.Assert.assertEquals
|
||||
import org.junit.Test
|
||||
|
||||
class AutoVoiceAudioClientSupervisionTest {
|
||||
@Test
|
||||
fun `route override forces standard even when auto prefers ready relay`() = runTest {
|
||||
val standard = FakeVoiceClient(VoiceAudioRoute.Standard, "standard")
|
||||
val relay = FakeVoiceClient(VoiceAudioRoute.Relay, "relay")
|
||||
val router = AutoVoiceAudioClient(
|
||||
standardClient = standard,
|
||||
relayClient = relay,
|
||||
routeProvider = { VoiceAudioRoute.Auto },
|
||||
standardReadyProvider = { true },
|
||||
relayReadyProvider = { true },
|
||||
)
|
||||
|
||||
assertEquals("relay", router.transcribe(File("voice.wav")).getOrThrow())
|
||||
router.setRouteOverride(VoiceAudioRoute.Standard)
|
||||
assertEquals(VoiceAudioRoute.Standard, router.effectiveRoute)
|
||||
assertEquals("standard", router.transcribe(File("voice.wav")).getOrThrow())
|
||||
router.setRouteOverride(null)
|
||||
assertEquals("relay", router.transcribe(File("voice.wav")).getOrThrow())
|
||||
}
|
||||
|
||||
private class FakeVoiceClient(
|
||||
override val route: VoiceAudioRoute,
|
||||
private val transcript: String,
|
||||
) : VoiceAudioClient {
|
||||
override suspend fun transcribe(audioFile: File): Result<String> = Result.success(transcript)
|
||||
override suspend fun synthesize(text: String): Result<File> = Result.success(File("voice.mp3"))
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,57 @@
|
||||
package com.hermesandroid.relay.ui
|
||||
|
||||
import com.hermesandroid.relay.data.SupervisedAppearance
|
||||
import com.hermesandroid.relay.data.SupervisedModePolicy
|
||||
import org.junit.Assert.assertEquals
|
||||
import org.junit.Assert.assertFalse
|
||||
import org.junit.Assert.assertTrue
|
||||
import org.junit.Test
|
||||
|
||||
class SupervisedAppearancePolicyTest {
|
||||
private val policy = SupervisedModePolicy(
|
||||
enabled = true,
|
||||
pinnedProfileName = "willow",
|
||||
appearance = SupervisedAppearance(
|
||||
appThemeId = "rose",
|
||||
themePreference = "dark",
|
||||
showPet = false,
|
||||
),
|
||||
)
|
||||
|
||||
@Test fun `locked supervised root uses only its own theme`() {
|
||||
val resolved = resolveSupervisedTheme(policy, false, "midnight", "light")
|
||||
|
||||
assertEquals("rose", resolved.appThemeId)
|
||||
assertEquals("dark", resolved.themePreference)
|
||||
assertFalse(resolved.useGlobalCustomTheme)
|
||||
}
|
||||
|
||||
@Test fun `parent access restores ordinary app theme`() {
|
||||
val resolved = resolveSupervisedTheme(policy, true, "midnight", "light")
|
||||
|
||||
assertEquals("midnight", resolved.appThemeId)
|
||||
assertEquals("light", resolved.themePreference)
|
||||
assertTrue(resolved.useGlobalCustomTheme)
|
||||
}
|
||||
|
||||
@Test fun `pet visibility follows supervised policy only while locked`() {
|
||||
assertFalse(shouldShowPetInSupervisedMode(policy, false))
|
||||
assertTrue(shouldShowPetInSupervisedMode(policy, true))
|
||||
assertTrue(
|
||||
shouldShowPetInSupervisedMode(
|
||||
policy.copy(appearance = policy.appearance.copy(showPet = true)),
|
||||
false,
|
||||
),
|
||||
)
|
||||
}
|
||||
|
||||
@Test fun `enabled recovery policy stays on restricted appearance defaults`() {
|
||||
val recovery = SupervisedModePolicy(enabled = true)
|
||||
val resolved = resolveSupervisedTheme(recovery, false, "rose", "dark")
|
||||
|
||||
assertEquals("hermes-relay", resolved.appThemeId)
|
||||
assertEquals("auto", resolved.themePreference)
|
||||
assertFalse(resolved.useGlobalCustomTheme)
|
||||
assertFalse(shouldShowPetInSupervisedMode(recovery, false))
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,121 @@
|
||||
package com.hermesandroid.relay.ui
|
||||
|
||||
import com.hermesandroid.relay.data.SupervisedCapabilities
|
||||
import com.hermesandroid.relay.data.SupervisedModePolicy
|
||||
import org.junit.Assert.assertFalse
|
||||
import org.junit.Assert.assertTrue
|
||||
import org.junit.Test
|
||||
|
||||
class SupervisedNavigationPolicyTest {
|
||||
@Test fun `locked surface permits only approved destinations`() {
|
||||
assertTrue(isSupervisedRouteAllowed("chat?sessionId=owned", false))
|
||||
assertTrue(isSupervisedRouteAllowed("settings", false))
|
||||
assertTrue(isSupervisedRouteAllowed(Screen.SupervisedAppearanceSettings.route, false))
|
||||
// The full Appearance destination includes profile/avatar/pet controls.
|
||||
// The supervised Settings root owns its own allowlisted theme controls.
|
||||
assertFalse(isSupervisedRouteAllowed("settings/appearance", false))
|
||||
assertFalse(isSupervisedRouteAllowed("settings/about", false))
|
||||
assertFalse(isSupervisedRouteAllowed(Screen.AdvancedSettings.route, false))
|
||||
assertFalse(isSupervisedRouteAllowed("manage", false))
|
||||
assertFalse(isSupervisedRouteAllowed("settings/developer", false))
|
||||
assertFalse(isSupervisedRouteAllowed("settings/supervised", false))
|
||||
assertFalse(isSupervisedRouteAllowed(null, false))
|
||||
}
|
||||
|
||||
@Test fun `parent unlock permits full navigation`() {
|
||||
assertTrue(isSupervisedRouteAllowed("manage", true))
|
||||
assertTrue(isSupervisedRouteAllowed(Screen.AdvancedSettings.route, true))
|
||||
}
|
||||
|
||||
@Test fun `supervised redirect waits until the navigation graph has a route`() {
|
||||
assertFalse(shouldRedirectSupervisedRoute(true, false, null))
|
||||
assertTrue(isSupervisedRouteContentAllowed(true, false, null))
|
||||
assertFalse(shouldRedirectSupervisedRoute(true, false, Screen.Chat.route))
|
||||
assertTrue(isSupervisedRouteContentAllowed(true, false, Screen.Chat.route))
|
||||
assertTrue(shouldRedirectSupervisedRoute(true, false, Screen.AdvancedSettings.route))
|
||||
assertFalse(isSupervisedRouteContentAllowed(true, false, Screen.AdvancedSettings.route))
|
||||
assertFalse(shouldRedirectSupervisedRoute(true, true, Screen.AdvancedSettings.route))
|
||||
assertTrue(isSupervisedRouteContentAllowed(true, true, Screen.AdvancedSettings.route))
|
||||
}
|
||||
|
||||
@Test fun `navigation waits for connection store before trusting null active id`() {
|
||||
assertFalse(isRelayNavigationHydrated(false, null, false))
|
||||
assertTrue(isRelayNavigationHydrated(true, null, false))
|
||||
assertFalse(isRelayNavigationHydrated(true, "home", false))
|
||||
assertTrue(isRelayNavigationHydrated(true, "home", true))
|
||||
}
|
||||
|
||||
@Test fun `parent access relocks as soon as chat becomes current`() {
|
||||
assertTrue(shouldRelockParentAccess(true, true, "chat?sessionId=ignored"))
|
||||
assertFalse(shouldRelockParentAccess(true, true, "settings/supervised"))
|
||||
assertFalse(shouldRelockParentAccess(false, true, "chat"))
|
||||
assertFalse(shouldRelockParentAccess(true, false, "chat"))
|
||||
}
|
||||
|
||||
@Test fun `supervised route session requires history pinned profile and trusted ownership proof`() {
|
||||
val policy = SupervisedModePolicy(
|
||||
enabled = true,
|
||||
pinnedProfileName = "willow",
|
||||
capabilities = SupervisedCapabilities(conversationHistory = true),
|
||||
)
|
||||
|
||||
assertFalse(mayRestoreSupervisedSessionRoute(policy, "session-1", "willow", false))
|
||||
assertFalse(mayRestoreSupervisedSessionRoute(policy, "session-1", "parent", true))
|
||||
assertTrue(mayRestoreSupervisedSessionRoute(policy, "session-1", "WILLOW", true))
|
||||
assertFalse(
|
||||
mayRestoreSupervisedSessionRoute(
|
||||
policy.copy(capabilities = policy.capabilities.copy(conversationHistory = false)),
|
||||
"session-1",
|
||||
"willow",
|
||||
true,
|
||||
),
|
||||
)
|
||||
}
|
||||
|
||||
@Test fun `supervised external route discards session profile and proactive targets`() {
|
||||
val policy = SupervisedModePolicy(
|
||||
enabled = true,
|
||||
pinnedProfileName = "willow",
|
||||
capabilities = SupervisedCapabilities(conversationHistory = true),
|
||||
)
|
||||
val external = SupervisedChatRouteArgs(
|
||||
sessionId = "parent-session",
|
||||
profile = "willow",
|
||||
proactiveChatId = "phone",
|
||||
)
|
||||
|
||||
assertTrue(
|
||||
sanitizeSupervisedChatRouteArgs(policy, external, false) ==
|
||||
SupervisedChatRouteArgs(),
|
||||
)
|
||||
assertTrue(
|
||||
sanitizeSupervisedChatRouteArgs(policy, external, true) ==
|
||||
external.copy(proactiveChatId = null),
|
||||
)
|
||||
assertTrue(
|
||||
sanitizeSupervisedChatRouteArgs(SupervisedModePolicy(), external, false) == external,
|
||||
)
|
||||
}
|
||||
|
||||
@Test fun `first enable requires configured policy secure screen and successful device credential`() {
|
||||
val configured = SupervisedModePolicy(pinnedProfileName = "willow")
|
||||
|
||||
assertFalse(
|
||||
mayEnableSupervisedMode(
|
||||
configured,
|
||||
deviceSecure = false,
|
||||
deviceCredentialConfirmed = true,
|
||||
),
|
||||
)
|
||||
assertFalse(
|
||||
mayEnableSupervisedMode(
|
||||
configured,
|
||||
deviceSecure = true,
|
||||
deviceCredentialConfirmed = false,
|
||||
),
|
||||
)
|
||||
assertFalse(mayEnableSupervisedMode(SupervisedModePolicy(), true, true))
|
||||
assertTrue(mayEnableSupervisedMode(configured, true, true))
|
||||
assertFalse(mayEnableSupervisedMode(configured.copy(enabled = true), true, true))
|
||||
}
|
||||
}
|
||||
+26
@@ -0,0 +1,26 @@
|
||||
package com.hermesandroid.relay.ui.components
|
||||
|
||||
import org.junit.Assert.assertEquals
|
||||
import org.junit.Assert.assertTrue
|
||||
import org.junit.Test
|
||||
|
||||
class SupervisedImagePresentationTest {
|
||||
@Test
|
||||
fun `disabled assistant images strip markdown without exposing a fetchable source`() {
|
||||
val content = "Here it is  and "
|
||||
|
||||
val (body, images) = assistantImageContent(content, showImages = false)
|
||||
|
||||
assertEquals("Here it is and", body)
|
||||
assertTrue(images.isEmpty())
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `enabled assistant images preserve all supported sources`() {
|
||||
val content = " "
|
||||
|
||||
val (_, images) = assistantImageContent(content, showImages = true)
|
||||
|
||||
assertEquals(listOf("https://example.com/a.png", "/tmp/b.png"), images.map { it.src })
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,174 @@
|
||||
package com.hermesandroid.relay.viewmodel
|
||||
|
||||
import android.app.Application
|
||||
import androidx.test.core.app.ApplicationProvider
|
||||
import com.hermesandroid.relay.network.upstream.DashboardApiClient
|
||||
import kotlinx.coroutines.Dispatchers
|
||||
import kotlinx.coroutines.ExperimentalCoroutinesApi
|
||||
import kotlinx.coroutines.flow.filterIsInstance
|
||||
import kotlinx.coroutines.flow.first
|
||||
import kotlinx.coroutines.runBlocking
|
||||
import kotlinx.coroutines.test.UnconfinedTestDispatcher
|
||||
import kotlinx.coroutines.test.resetMain
|
||||
import kotlinx.coroutines.test.setMain
|
||||
import kotlinx.coroutines.withTimeout
|
||||
import okhttp3.mockwebserver.MockResponse
|
||||
import okhttp3.mockwebserver.MockWebServer
|
||||
import org.junit.After
|
||||
import org.junit.Assert.assertEquals
|
||||
import org.junit.Assert.assertTrue
|
||||
import org.junit.Before
|
||||
import org.junit.Test
|
||||
import org.junit.runner.RunWith
|
||||
import org.robolectric.RobolectricTestRunner
|
||||
import org.robolectric.annotation.Config
|
||||
|
||||
@OptIn(ExperimentalCoroutinesApi::class)
|
||||
@RunWith(RobolectricTestRunner::class)
|
||||
@Config(sdk = [35])
|
||||
class GitStateExtrasViewModelTest {
|
||||
private val ownerKey = "connection-a\u0000default\u0000dashboard"
|
||||
private val mainDispatcher = UnconfinedTestDispatcher()
|
||||
private lateinit var application: Application
|
||||
private lateinit var server: MockWebServer
|
||||
|
||||
@Before
|
||||
fun setUp() {
|
||||
Dispatchers.setMain(mainDispatcher)
|
||||
application = ApplicationProvider.getApplicationContext()
|
||||
server = MockWebServer().apply { start() }
|
||||
}
|
||||
|
||||
@After
|
||||
fun tearDown() {
|
||||
server.shutdown()
|
||||
Dispatchers.resetMain()
|
||||
}
|
||||
|
||||
private fun viewModel(grant: Boolean = true): GitStateViewModel {
|
||||
val vm = GitStateViewModel(application)
|
||||
vm.configure(DashboardApiClient(server.url("/").toString()), ownerKey)
|
||||
vm.setWriteGrant(ownerKey, grant)
|
||||
return vm
|
||||
}
|
||||
|
||||
private fun enqueueJson(body: String) {
|
||||
server.enqueue(
|
||||
MockResponse().setHeader("Content-Type", "application/json").setBody(body),
|
||||
)
|
||||
}
|
||||
|
||||
private fun selectAlpha(vm: GitStateViewModel) {
|
||||
enqueueJson("""{"repos":[{"id":"alpha","name":"alpha","root":"/p/alpha","current_branch":"main","dirty":true}]}""")
|
||||
enqueueJson("""{"counts":{"staged":1,"modified":0,"untracked":0},"staged":[{"path":"a.txt"}],"modified":[],"untracked":[],"truncated":false}""")
|
||||
enqueueJson("""{"branches":[{"name":"main","upstream":"origin/main","ahead":0,"behind":0,"is_current":true}]}""")
|
||||
runBlocking { withTimeout(5_000) { vm.repos.filterIsInstance<GitStateUiState.Ready>().first() } }
|
||||
vm.selectRepo("alpha")
|
||||
runBlocking { withTimeout(5_000) { vm.detail.filterIsInstance<GitRepoDetailState.Ready>().first() } }
|
||||
repeat(3) { server.takeRequest() }
|
||||
}
|
||||
|
||||
// ── AI commit message (magic-wand) ─────────────────────────────────────
|
||||
|
||||
@Test
|
||||
fun `generate commit message pre-fills the suggestion via selected paths`() = runBlocking {
|
||||
val vm = viewModel()
|
||||
selectAlpha(vm)
|
||||
enqueueJson("""{"message":"feat: add feature","notice":""}""")
|
||||
vm.generateCommitMessage(listOf("a.txt"))
|
||||
val state = withTimeout(5_000) {
|
||||
vm.messageGeneration.filterIsInstance<GitMessageGenerationState.Ready>().first()
|
||||
}
|
||||
assertEquals("feat: add feature", state.message)
|
||||
assertEquals("", state.notice)
|
||||
val req = server.takeRequest()
|
||||
assertTrue(req.path!!.contains("/git/commit_message_selected"))
|
||||
assertTrue(req.body.readUtf8().contains("a.txt"))
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `generate message without grant is refused before any POST`() = runBlocking {
|
||||
val vm = viewModel(grant = false)
|
||||
selectAlpha(vm)
|
||||
vm.generateCommitMessage(null)
|
||||
val state = withTimeout(5_000) {
|
||||
vm.messageGeneration.filterIsInstance<GitMessageGenerationState.Ready>().first()
|
||||
}
|
||||
assertTrue(state.notice.contains("plugin.api.write"))
|
||||
assertEquals(3, server.requestCount)
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `empty staged diff surfaces notice without error`() = runBlocking {
|
||||
val vm = viewModel()
|
||||
selectAlpha(vm)
|
||||
enqueueJson("""{"message":"","notice":"nothing staged"}""")
|
||||
vm.generateCommitMessage(listOf("a.txt"))
|
||||
val state = withTimeout(5_000) {
|
||||
vm.messageGeneration.filterIsInstance<GitMessageGenerationState.Ready>().first()
|
||||
}
|
||||
assertEquals("", state.message)
|
||||
assertEquals("nothing staged", state.notice)
|
||||
}
|
||||
|
||||
// ── Push-after-commit toggle ───────────────────────────────────────────
|
||||
|
||||
@Test
|
||||
fun `push after commit defaults off and toggles`() {
|
||||
val vm = viewModel()
|
||||
assertTrue(!vm.isPushAfterCommitEnabled())
|
||||
vm.setPushAfterCommit(true)
|
||||
assertTrue(vm.isPushAfterCommitEnabled())
|
||||
vm.setPushAfterCommit(false)
|
||||
assertTrue(!vm.isPushAfterCommitEnabled())
|
||||
}
|
||||
|
||||
// ── Stash-checkout ─────────────────────────────────────────────────────
|
||||
|
||||
@Test
|
||||
fun `stash checkout surfaces the stash notice on success`() = runBlocking {
|
||||
val vm = viewModel()
|
||||
selectAlpha(vm)
|
||||
enqueueJson(
|
||||
"""{"head":"abc","stashed":true,"stash_message":"git-state: feature","status":{"counts":{"staged":0,"modified":0,"untracked":0},"staged":[],"modified":[],"untracked":[],"truncated":false},"branches":[]}""",
|
||||
)
|
||||
// refreshDetail fires two reads (status + branches).
|
||||
enqueueJson("""{"counts":{"staged":0,"modified":0,"untracked":0},"staged":[],"modified":[],"untracked":[],"truncated":false}""")
|
||||
enqueueJson("""{"branches":[]}""")
|
||||
vm.stashCheckout("feature")
|
||||
withTimeout(5_000) { vm.mutation.filterIsInstance<GitMutationState.Success>().first() }
|
||||
val notice = withTimeout(5_000) { vm.stashNotice.first { it != null } }!!
|
||||
assertTrue(notice.contains("git-state: feature"))
|
||||
assertTrue(notice.contains("git stash pop"))
|
||||
val req = server.takeRequest()
|
||||
assertTrue(req.path!!.contains("/git/stash_checkout"))
|
||||
assertTrue(req.body.readUtf8().contains("feature"))
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `stash checkout without grant is refused before any POST`() = runBlocking {
|
||||
val vm = viewModel(grant = false)
|
||||
selectAlpha(vm)
|
||||
vm.stashCheckout("feature")
|
||||
val state = withTimeout(5_000) {
|
||||
vm.mutation.filterIsInstance<GitMutationState.Error>().first()
|
||||
}
|
||||
assertTrue(state.message.contains("plugin.api.write"))
|
||||
assertEquals(3, server.requestCount)
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `clean stash checkout yields no stash notice`() = runBlocking {
|
||||
val vm = viewModel()
|
||||
selectAlpha(vm)
|
||||
enqueueJson(
|
||||
"""{"head":"abc","stashed":false,"stash_message":"","status":{"counts":{"staged":0,"modified":0,"untracked":0},"staged":[],"modified":[],"untracked":[],"truncated":false},"branches":[]}""",
|
||||
)
|
||||
enqueueJson("""{"counts":{"staged":0,"modified":0,"untracked":0},"staged":[],"modified":[],"untracked":[],"truncated":false}""")
|
||||
enqueueJson("""{"branches":[]}""")
|
||||
vm.stashCheckout("feature")
|
||||
withTimeout(5_000) { vm.mutation.filterIsInstance<GitMutationState.Success>().first() }
|
||||
// No stash notice for a clean checkout.
|
||||
assertEquals(null, vm.stashNotice.value)
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,167 @@
|
||||
package com.hermesandroid.relay.viewmodel
|
||||
|
||||
import android.app.Application
|
||||
import androidx.test.core.app.ApplicationProvider
|
||||
import com.hermesandroid.relay.network.upstream.DashboardApiClient
|
||||
import kotlinx.coroutines.Dispatchers
|
||||
import kotlinx.coroutines.ExperimentalCoroutinesApi
|
||||
import kotlinx.coroutines.flow.first
|
||||
import kotlinx.coroutines.flow.filterIsInstance
|
||||
import kotlinx.coroutines.runBlocking
|
||||
import kotlinx.coroutines.test.UnconfinedTestDispatcher
|
||||
import kotlinx.coroutines.test.resetMain
|
||||
import kotlinx.coroutines.test.setMain
|
||||
import kotlinx.coroutines.withTimeout
|
||||
import okhttp3.mockwebserver.MockResponse
|
||||
import okhttp3.mockwebserver.MockWebServer
|
||||
import org.junit.After
|
||||
import org.junit.Assert.assertEquals
|
||||
import org.junit.Assert.assertFalse
|
||||
import org.junit.Assert.assertNotNull
|
||||
import org.junit.Assert.assertTrue
|
||||
import org.junit.Before
|
||||
import org.junit.Test
|
||||
import org.junit.runner.RunWith
|
||||
import org.robolectric.RobolectricTestRunner
|
||||
import org.robolectric.annotation.Config
|
||||
|
||||
@OptIn(ExperimentalCoroutinesApi::class)
|
||||
@RunWith(RobolectricTestRunner::class)
|
||||
@Config(sdk = [35])
|
||||
class GitStateViewModelTest {
|
||||
private val ownerKey = "connection-a\u0000default\u0000dashboard"
|
||||
private val mainDispatcher = UnconfinedTestDispatcher()
|
||||
private lateinit var application: Application
|
||||
private lateinit var server: MockWebServer
|
||||
|
||||
@Before
|
||||
fun setUp() {
|
||||
Dispatchers.setMain(mainDispatcher)
|
||||
application = ApplicationProvider.getApplicationContext()
|
||||
server = MockWebServer().apply { start() }
|
||||
}
|
||||
|
||||
@After
|
||||
fun tearDown() {
|
||||
server.shutdown()
|
||||
Dispatchers.resetMain()
|
||||
}
|
||||
|
||||
private fun viewModel(): GitStateViewModel {
|
||||
val vm = GitStateViewModel(application)
|
||||
vm.configure(DashboardApiClient(server.url("/").toString()), ownerKey)
|
||||
return vm
|
||||
}
|
||||
|
||||
private fun enqueueJson(body: String) {
|
||||
server.enqueue(
|
||||
MockResponse()
|
||||
.setHeader("Content-Type", "application/json")
|
||||
.setBody(body),
|
||||
)
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `loadRepos maps repo list and notice`() = runBlocking {
|
||||
enqueueJson(
|
||||
"""{"repos":[{"id":"alpha","name":"alpha","root":"/p/alpha","current_branch":"main","dirty":false}],"notice":null}""",
|
||||
)
|
||||
val vm = viewModel()
|
||||
val state = withTimeout(5_000) {
|
||||
vm.repos.filterIsInstance<GitStateUiState.Ready>().first()
|
||||
}
|
||||
assertEquals(1, state.repos.size)
|
||||
assertEquals("alpha", state.repos.single().name)
|
||||
assertNotNull(vm.repos.value)
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `loadRepos surfaces server error`() = runBlocking {
|
||||
server.enqueue(MockResponse().setResponseCode(400).setBody("""{"detail":"unknown repository"}"""))
|
||||
val vm = viewModel()
|
||||
val state = withTimeout(5_000) {
|
||||
vm.repos.filterIsInstance<GitStateUiState.Error>().first()
|
||||
}
|
||||
assertTrue(state.message.isNotBlank())
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `selectRepo loads status and branches and preserves truncation flag`() = runBlocking {
|
||||
enqueueJson(
|
||||
"""{"repos":[{"id":"alpha","name":"alpha","root":"/p/alpha","current_branch":"main","dirty":true}]}""",
|
||||
)
|
||||
// status
|
||||
enqueueJson(
|
||||
"""{"counts":{"staged":1,"modified":2,"untracked":3},"staged":[{"path":"a.txt"}],"modified":[],"untracked":[],"truncated":true}""",
|
||||
)
|
||||
// branches
|
||||
enqueueJson(
|
||||
"""{"branches":[{"name":"main","upstream":"origin/main","ahead":1,"behind":0,"is_current":true}]}""",
|
||||
)
|
||||
val vm = viewModel()
|
||||
withTimeout(5_000) { vm.repos.filterIsInstance<GitStateUiState.Ready>().first() }
|
||||
vm.selectRepo("alpha")
|
||||
val ready = withTimeout(5_000) {
|
||||
vm.detail.filterIsInstance<GitRepoDetailState.Ready>().first()
|
||||
}
|
||||
assertEquals(1, ready.status.counts.staged)
|
||||
assertEquals(2, ready.status.counts.modified)
|
||||
assertEquals(3, ready.status.counts.untracked)
|
||||
assertTrue(ready.status.truncated)
|
||||
assertEquals("main", ready.branches.single().name)
|
||||
assertTrue(ready.branches.single().isCurrent)
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `loadDiff surfaces truncated diff`() = runBlocking {
|
||||
enqueueJson("""{"repos":[{"id":"alpha","name":"alpha","root":"/p/alpha"}]}""")
|
||||
enqueueJson("""{"counts":{"staged":0,"modified":1,"untracked":0},"staged":[],"modified":[{"path":"a.txt"}],"untracked":[],"truncated":false}""")
|
||||
enqueueJson("""{"branches":[]}""")
|
||||
enqueueJson("""{"path":"a.txt","kind":"unstaged","diff":"+change","truncated":true}""")
|
||||
|
||||
val vm = viewModel()
|
||||
withTimeout(5_000) { vm.repos.filterIsInstance<GitStateUiState.Ready>().first() }
|
||||
vm.selectRepo("alpha")
|
||||
withTimeout(5_000) { vm.detail.filterIsInstance<GitRepoDetailState.Ready>().first() }
|
||||
vm.loadDiff("a.txt", "unstaged")
|
||||
val content = withTimeout(5_000) {
|
||||
vm.content.filterIsInstance<GitContentViewState.Diff>().first()
|
||||
}
|
||||
assertEquals("a.txt", content.diff.path)
|
||||
assertTrue(content.diff.truncated)
|
||||
assertTrue(content.diff.diff.contains("change"))
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `loadFile surfaces content and truncation`() = runBlocking {
|
||||
enqueueJson("""{"repos":[{"id":"alpha","name":"alpha","root":"/p/alpha"}]}""")
|
||||
enqueueJson("""{"counts":{"staged":0,"modified":0,"untracked":1},"staged":[],"modified":[],"untracked":[{"path":"new.txt"}],"truncated":false}""")
|
||||
enqueueJson("""{"branches":[]}""")
|
||||
enqueueJson("""{"path":"new.txt","content":"hello world","truncated":false}""")
|
||||
|
||||
val vm = viewModel()
|
||||
withTimeout(5_000) { vm.repos.filterIsInstance<GitStateUiState.Ready>().first() }
|
||||
vm.selectRepo("alpha")
|
||||
withTimeout(5_000) { vm.detail.filterIsInstance<GitRepoDetailState.Ready>().first() }
|
||||
vm.loadFile("new.txt")
|
||||
val content = withTimeout(5_000) {
|
||||
vm.content.filterIsInstance<GitContentViewState.File>().first()
|
||||
}
|
||||
assertEquals("hello world", content.file.content)
|
||||
assertFalse(content.file.truncated)
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `selectRepo surfaces status error for unknown repo`() = runBlocking {
|
||||
enqueueJson("""{"repos":[{"id":"alpha","name":"alpha","root":"/p/alpha"}]}""")
|
||||
server.enqueue(MockResponse().setResponseCode(400).setBody("""{"detail":"unknown repository: bogus"}"""))
|
||||
enqueueJson("""{"branches":[]}""")
|
||||
val vm = viewModel()
|
||||
withTimeout(5_000) { vm.repos.filterIsInstance<GitStateUiState.Ready>().first() }
|
||||
vm.selectRepo("bogus")
|
||||
val error = withTimeout(5_000) {
|
||||
vm.detail.filterIsInstance<GitRepoDetailState.Error>().first()
|
||||
}
|
||||
assertTrue(error.message.contains("unknown repository"))
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,272 @@
|
||||
package com.hermesandroid.relay.viewmodel
|
||||
|
||||
import android.app.Application
|
||||
import androidx.test.core.app.ApplicationProvider
|
||||
import com.hermesandroid.relay.network.upstream.DashboardApiClient
|
||||
import kotlinx.coroutines.Dispatchers
|
||||
import kotlinx.coroutines.ExperimentalCoroutinesApi
|
||||
import kotlinx.coroutines.flow.filterIsInstance
|
||||
import kotlinx.coroutines.flow.first
|
||||
import kotlinx.coroutines.runBlocking
|
||||
import kotlinx.coroutines.test.UnconfinedTestDispatcher
|
||||
import kotlinx.coroutines.test.resetMain
|
||||
import kotlinx.coroutines.test.setMain
|
||||
import kotlinx.coroutines.withTimeout
|
||||
import okhttp3.mockwebserver.MockResponse
|
||||
import okhttp3.mockwebserver.MockWebServer
|
||||
import org.junit.After
|
||||
import org.junit.Assert.assertEquals
|
||||
import org.junit.Assert.assertFalse
|
||||
import org.junit.Assert.assertTrue
|
||||
import org.junit.Before
|
||||
import org.junit.Test
|
||||
import org.junit.runner.RunWith
|
||||
import org.robolectric.RobolectricTestRunner
|
||||
import org.robolectric.annotation.Config
|
||||
|
||||
@OptIn(ExperimentalCoroutinesApi::class)
|
||||
@RunWith(RobolectricTestRunner::class)
|
||||
@Config(sdk = [35])
|
||||
class GitStateWriteViewModelTest {
|
||||
private val ownerKey = "connection-a\u0000default\u0000dashboard"
|
||||
private val mainDispatcher = UnconfinedTestDispatcher()
|
||||
private lateinit var application: Application
|
||||
private lateinit var server: MockWebServer
|
||||
|
||||
@Before
|
||||
fun setUp() {
|
||||
Dispatchers.setMain(mainDispatcher)
|
||||
application = ApplicationProvider.getApplicationContext()
|
||||
server = MockWebServer().apply { start() }
|
||||
}
|
||||
|
||||
@After
|
||||
fun tearDown() {
|
||||
server.shutdown()
|
||||
Dispatchers.resetMain()
|
||||
}
|
||||
|
||||
private fun viewModel(grant: Boolean = true): GitStateViewModel {
|
||||
val vm = GitStateViewModel(application)
|
||||
vm.configure(DashboardApiClient(server.url("/").toString()), ownerKey)
|
||||
vm.setWriteGrant(ownerKey, grant)
|
||||
return vm
|
||||
}
|
||||
|
||||
private fun enqueueJson(body: String) {
|
||||
server.enqueue(
|
||||
MockResponse()
|
||||
.setHeader("Content-Type", "application/json")
|
||||
.setBody(body),
|
||||
)
|
||||
}
|
||||
|
||||
/** Loads the repo list + selects ``alpha`` so a mutation has a target. */
|
||||
private fun selectAlpha(vm: GitStateViewModel) {
|
||||
enqueueJson("""{"repos":[{"id":"alpha","name":"alpha","root":"/p/alpha","current_branch":"main","dirty":true}]}""")
|
||||
enqueueJson("""{"counts":{"staged":1,"modified":0,"untracked":0},"staged":[{"path":"a.txt"}],"modified":[],"untracked":[],"truncated":false}""")
|
||||
enqueueJson("""{"branches":[{"name":"main","upstream":"origin/main","ahead":0,"behind":0,"is_current":true}]}""")
|
||||
runBlocking { withTimeout(5_000) { vm.repos.filterIsInstance<GitStateUiState.Ready>().first() } }
|
||||
vm.selectRepo("alpha")
|
||||
runBlocking { withTimeout(5_000) { vm.detail.filterIsInstance<GitRepoDetailState.Ready>().first() } }
|
||||
// Drain the three read requests (repos/status/branches) so the next
|
||||
// takeRequest() returns the mutation POST we actually assert on.
|
||||
repeat(3) { server.takeRequest() }
|
||||
}
|
||||
|
||||
private fun enqueuePostSuccess(head: String) {
|
||||
enqueueJson("""{"head":"$head","status":{"counts":{"staged":0,"modified":0,"untracked":0},"staged":[],"modified":[],"untracked":[],"truncated":false}}""")
|
||||
// refreshDetail fires two more requests (status + branches).
|
||||
enqueueJson("""{"counts":{"staged":0,"modified":0,"untracked":0},"staged":[],"modified":[],"untracked":[],"truncated":false}""")
|
||||
enqueueJson("""{"branches":[]}""")
|
||||
}
|
||||
|
||||
// ── Grant gating (security first) ──────────────────────────────────────
|
||||
|
||||
@Test
|
||||
fun `stage without write grant is refused before any POST`() = runBlocking {
|
||||
val vm = viewModel(grant = false)
|
||||
selectAlpha(vm)
|
||||
vm.stage(listOf("a.txt"))
|
||||
val state = withTimeout(5_000) {
|
||||
vm.mutation.filterIsInstance<GitMutationState.Error>().first()
|
||||
}
|
||||
assertTrue(state.message.contains("plugin.api.write"))
|
||||
// No write POST was sent (only the 3 read requests for load/select).
|
||||
assertEquals(3, server.requestCount)
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `discard without write grant is refused`() = runBlocking {
|
||||
val vm = viewModel(grant = false)
|
||||
selectAlpha(vm)
|
||||
vm.discard(listOf("a.txt"), GitConfirmationStrings.DISCARD)
|
||||
val state = withTimeout(5_000) {
|
||||
vm.mutation.filterIsInstance<GitMutationState.Error>().first()
|
||||
}
|
||||
assertTrue(state.message.contains("plugin.api.write"))
|
||||
assertEquals(3, server.requestCount)
|
||||
}
|
||||
|
||||
// ── Happy paths ────────────────────────────────────────────────────────
|
||||
|
||||
@Test
|
||||
fun `stage sends POST and surfaces success + fresh status`() = runBlocking {
|
||||
val vm = viewModel()
|
||||
selectAlpha(vm)
|
||||
enqueuePostSuccess("abc123")
|
||||
vm.stage(listOf("a.txt"))
|
||||
val state = withTimeout(5_000) {
|
||||
vm.mutation.filterIsInstance<GitMutationState.Success>().first()
|
||||
}
|
||||
assertEquals("stage", state.label)
|
||||
assertEquals("abc123", state.head)
|
||||
val req = server.takeRequest()
|
||||
assertTrue(req.path!!.contains("/git/stage"))
|
||||
assertTrue(req.body.readUtf8().contains("a.txt"))
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `commit sends message and returns head`() = runBlocking {
|
||||
val vm = viewModel()
|
||||
selectAlpha(vm)
|
||||
enqueuePostSuccess("deadbeef")
|
||||
vm.commit("add feature")
|
||||
val state = withTimeout(5_000) {
|
||||
vm.mutation.filterIsInstance<GitMutationState.Success>().first()
|
||||
}
|
||||
assertEquals("commit", state.label)
|
||||
val req = server.takeRequest()
|
||||
assertTrue(req.path!!.contains("/git/commit"))
|
||||
assertTrue(req.body.readUtf8().contains("add feature"))
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `commit success callback fires only after successful response`() = runBlocking {
|
||||
val vm = viewModel()
|
||||
selectAlpha(vm)
|
||||
enqueuePostSuccess("abc")
|
||||
var committedTarget: GitTarget? = null
|
||||
|
||||
vm.commit("add feature") { committedTarget = it }
|
||||
withTimeout(5_000) { vm.mutation.filterIsInstance<GitMutationState.Success>().first() }
|
||||
|
||||
assertEquals("alpha", committedTarget?.repoId)
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `commit failure never invokes success callback`() = runBlocking {
|
||||
val vm = viewModel()
|
||||
selectAlpha(vm)
|
||||
server.enqueue(MockResponse().setResponseCode(400).setBody("""{"detail":"failed"}"""))
|
||||
var callbackCalled = false
|
||||
|
||||
vm.commit("add feature") { callbackCalled = true }
|
||||
withTimeout(5_000) { vm.mutation.filterIsInstance<GitMutationState.Error>().first() }
|
||||
|
||||
assertFalse(callbackCalled)
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `connection change revokes grant and rejects prior target`() = runBlocking {
|
||||
val vm = viewModel()
|
||||
selectAlpha(vm)
|
||||
val priorTarget = vm.currentTarget()!!
|
||||
enqueueJson("""{"repos":[]}""")
|
||||
|
||||
vm.configure(DashboardApiClient(server.url("/").toString()), "connection-b")
|
||||
vm.setWriteGrant(ownerKey, true)
|
||||
withTimeout(5_000) { vm.repos.filterIsInstance<GitStateUiState.Ready>().first() }
|
||||
vm.push(GitConfirmationStrings.PUSH, expectedTarget = priorTarget)
|
||||
|
||||
assertFalse(vm.hasWriteGrant())
|
||||
assertEquals(null, vm.currentTarget())
|
||||
assertTrue(vm.mutation.value is GitMutationState.Error)
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `discard echoes the fixed confirmation token`() = runBlocking {
|
||||
val vm = viewModel()
|
||||
selectAlpha(vm)
|
||||
enqueuePostSuccess("abc")
|
||||
vm.discard(listOf("a.txt"), GitConfirmationStrings.DISCARD)
|
||||
withTimeout(5_000) { vm.mutation.filterIsInstance<GitMutationState.Success>().first() }
|
||||
val req = server.takeRequest()
|
||||
assertTrue(req.path!!.contains("/git/discard"))
|
||||
assertTrue(req.body.readUtf8().contains(GitConfirmationStrings.DISCARD))
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `push echoes the confirmation token`() = runBlocking {
|
||||
val vm = viewModel()
|
||||
selectAlpha(vm)
|
||||
enqueuePostSuccess("abc")
|
||||
vm.push(GitConfirmationStrings.PUSH)
|
||||
withTimeout(5_000) { vm.mutation.filterIsInstance<GitMutationState.Success>().first() }
|
||||
val req = server.takeRequest()
|
||||
assertTrue(req.path!!.contains("/git/push"))
|
||||
assertTrue(req.body.readUtf8().contains(GitConfirmationStrings.PUSH))
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `fetch and pull send their endpoints`() = runBlocking {
|
||||
val vm = viewModel()
|
||||
selectAlpha(vm)
|
||||
enqueuePostSuccess("abc")
|
||||
vm.fetch()
|
||||
withTimeout(5_000) { vm.mutation.filterIsInstance<GitMutationState.Success>().first() }
|
||||
assertTrue(server.takeRequest().path!!.contains("/git/fetch"))
|
||||
// Drain the two refreshDetail reads (status + branches) so the next
|
||||
// takeRequest() sees only the pull POST.
|
||||
repeat(2) { server.takeRequest() }
|
||||
|
||||
enqueuePostSuccess("xyz")
|
||||
vm.pull("origin", "main")
|
||||
withTimeout(5_000) { vm.mutation.filterIsInstance<GitMutationState.Success>().first() }
|
||||
assertTrue(server.takeRequest().path!!.contains("/git/pull"))
|
||||
}
|
||||
|
||||
// ── Error branches ─────────────────────────────────────────────────────
|
||||
|
||||
@Test
|
||||
fun `commit surfaces server error as readable message`() = runBlocking {
|
||||
val vm = viewModel()
|
||||
selectAlpha(vm)
|
||||
server.enqueue(
|
||||
MockResponse().setResponseCode(400).setBody("""{"detail":"commit message must not be empty"}"""),
|
||||
)
|
||||
vm.commit(" ")
|
||||
val state = withTimeout(5_000) {
|
||||
vm.mutation.filterIsInstance<GitMutationState.Error>().first()
|
||||
}
|
||||
assertTrue(state.message.contains("must not be empty"))
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `discard wrong confirmation surfaces server 403`() = runBlocking {
|
||||
val vm = viewModel()
|
||||
selectAlpha(vm)
|
||||
server.enqueue(
|
||||
MockResponse().setResponseCode(403).setBody("""{"detail":"confirmation did not match"}"""),
|
||||
)
|
||||
vm.discard(listOf("a.txt"), "wrong")
|
||||
val state = withTimeout(5_000) {
|
||||
vm.mutation.filterIsInstance<GitMutationState.Error>().first()
|
||||
}
|
||||
assertTrue(state.message.contains("confirmation") || state.message.contains("403"))
|
||||
}
|
||||
|
||||
// ── Confirmation gating helpers ────────────────────────────────────────
|
||||
|
||||
@Test
|
||||
fun `requiresConfirmation and confirmationFor match destructive ops`() {
|
||||
val vm = viewModel()
|
||||
assertTrue(vm.requiresConfirmation("discard"))
|
||||
assertTrue(vm.requiresConfirmation("push"))
|
||||
assertTrue(vm.requiresConfirmation("dirty-checkout"))
|
||||
assertEquals(GitConfirmationStrings.DISCARD, vm.confirmationFor("discard"))
|
||||
assertEquals(GitConfirmationStrings.PUSH, vm.confirmationFor("push"))
|
||||
assertEquals(GitConfirmationStrings.DIRTY_CHECKOUT, vm.confirmationFor("dirty-checkout"))
|
||||
assertEquals(null, vm.confirmationFor("commit"))
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,31 @@
|
||||
package com.hermesandroid.relay.viewmodel
|
||||
|
||||
import com.hermesandroid.relay.data.SupervisedModePolicy
|
||||
import org.junit.Assert.assertEquals
|
||||
import org.junit.Assert.assertNull
|
||||
import org.junit.Test
|
||||
|
||||
class SupervisedChatPolicyTest {
|
||||
@Test
|
||||
fun `normal mode preserves slash commands`() {
|
||||
assertNull(supervisedMessageBlockReason(SupervisedModePolicy(), " /model"))
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `enabled policy fails closed without pinned profile`() {
|
||||
assertEquals(
|
||||
"Supervised mode is unavailable until the parent selects a profile.",
|
||||
supervisedMessageBlockReason(SupervisedModePolicy(enabled = true), "hello"),
|
||||
)
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `active policy blocks slash commands after unicode whitespace`() {
|
||||
val policy = SupervisedModePolicy(enabled = true, pinnedProfileName = "willow")
|
||||
assertEquals(
|
||||
"Slash commands are unavailable in supervised mode.",
|
||||
supervisedMessageBlockReason(policy, "\u2003\t /model hidden"),
|
||||
)
|
||||
assertNull(supervisedMessageBlockReason(policy, "please explain /model"))
|
||||
}
|
||||
}
|
||||
+35
@@ -0,0 +1,35 @@
|
||||
package com.hermesandroid.relay.viewmodel
|
||||
|
||||
import com.hermesandroid.relay.data.SupervisedCapabilities
|
||||
import com.hermesandroid.relay.data.SupervisedModePolicy
|
||||
import com.hermesandroid.relay.voice.VoiceCommandAction
|
||||
import org.junit.Assert.assertFalse
|
||||
import org.junit.Assert.assertTrue
|
||||
import org.junit.Test
|
||||
|
||||
class SupervisedVoiceCommandPolicyTest {
|
||||
@Test
|
||||
fun `normal mode preserves every voice command`() {
|
||||
VoiceCommandAction.entries.forEach { action ->
|
||||
assertTrue(isVoiceCommandAllowed(action, SupervisedModePolicy()))
|
||||
}
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `supervised mode gates new chat and cancellation independently`() {
|
||||
val policy = SupervisedModePolicy(
|
||||
enabled = true,
|
||||
pinnedProfileName = "willow",
|
||||
capabilities = SupervisedCapabilities(
|
||||
voice = true,
|
||||
newChat = false,
|
||||
cancelResponse = false,
|
||||
),
|
||||
)
|
||||
|
||||
assertFalse(isVoiceCommandAllowed(VoiceCommandAction.StartNewChat, policy))
|
||||
assertFalse(isVoiceCommandAllowed(VoiceCommandAction.StopResponse, policy))
|
||||
assertFalse(isVoiceCommandAllowed(VoiceCommandAction.CancelBackgroundTask, policy))
|
||||
assertTrue(isVoiceCommandAllowed(VoiceCommandAction.EndVoiceChat, policy))
|
||||
}
|
||||
}
|
||||
@@ -91,6 +91,8 @@ the `relay_plugin_draft` tool to create or replace a generated declarative page.
|
||||
tool accepts the same bounded schema as Android, stores JSON atomically below
|
||||
`HERMES_HOME/mobile-plugins`, and rejects every `action.request`. Generated previews
|
||||
therefore cannot reach Relay management APIs or acquire executable backend behavior.
|
||||
The contribution ID `git` is reserved for the Relay plugin's native Git workspace;
|
||||
generated drafts cannot shadow or duplicate that route.
|
||||
|
||||
The Relay mobile manifest exposes drafts as preview pages under the authenticated
|
||||
`hermes-relay` plugin namespace. Android polls the catalog every five seconds while
|
||||
|
||||
+119
-2
@@ -1,6 +1,6 @@
|
||||
# Hermes-Relay — Decisions & Implementation Guide
|
||||
|
||||
> Updated: 2026-04-06
|
||||
> Updated: 2026-08-24
|
||||
>
|
||||
> Read this before SPEC.md — it tells you what to build, what was deferred, and why.
|
||||
|
||||
@@ -3692,7 +3692,124 @@ be considered later without being silently introduced now.
|
||||
|
||||
---
|
||||
|
||||
## ADR 66 — Android Bot Mode is a separate upstream-owned messaging workspace
|
||||
## ADR 66 — Android Supervised Mode is a parent-controlled client policy
|
||||
|
||||
**Status:** Implemented in code; physical managed-device certification pending (2026-08-24).
|
||||
|
||||
**Context.** Some operators prepare a deliberately restricted Hermes profile
|
||||
for use through a parent-supervised Android client. The profile remains the
|
||||
authority for its model, prompt, tools, provider credentials, content behavior,
|
||||
and server-side data. Hermes-Relay should help a parent present a smaller,
|
||||
proctored phone interface without representing that interface as end-to-end
|
||||
child security or as a server-enforced account type.
|
||||
|
||||
**Decision.** Android will treat Supervised Mode as an opt-in, locally enforced
|
||||
policy pinned to one existing Connection and one existing Hermes profile. The
|
||||
parent is responsible for preparing and reviewing that profile before enabling
|
||||
the mode. Entering, changing, or leaving the parent policy requires Android
|
||||
device authentication. That prompt authenticates an enrolled device user, not
|
||||
a distinct server-side parent identity. While the policy is active, the app restores directly
|
||||
into a restricted root and never renders the ordinary app behind an
|
||||
authentication prompt. A missing Connection, missing profile, malformed policy,
|
||||
failed authentication, process restart, or restored route that cannot prove its
|
||||
owner fails closed to the restricted surface.
|
||||
|
||||
The ordinary Chat screen stays visually quiet. It does not carry a persistent
|
||||
"supervised" banner. Its existing Settings action opens only approved
|
||||
preferences; a separate **Parent access** row authenticates before showing the
|
||||
policy editor or full application settings. Backgrounding, inactivity, process
|
||||
recreation, and leaving parent settings relock parent access according to the
|
||||
policy. Deep links, notification actions, restored navigation, shortcuts, and
|
||||
programmatic routes pass the same gate.
|
||||
|
||||
The parent policy controls capabilities rather than imposing a special
|
||||
attachment count. Initial capabilities are text chat, new chat, cancel, steer,
|
||||
attachments, standard voice, generated-media viewing, save/share media, copy,
|
||||
retry, quote/reply, and edit/resend. Attachments and voice are independently
|
||||
enabled. When attachments are enabled, Android retains the normal supported
|
||||
attachment flow and its existing size/type limits unless the parent selects a
|
||||
stricter limit; disabling attachments removes every picker, paste-to-file,
|
||||
camera/share-to-chat, and restored-draft entry point. Disabling voice removes
|
||||
capture, voice intents, and voice settings from the restricted surface. Provider
|
||||
credentials remain on the configured Hermes host under the existing standard
|
||||
voice contract.
|
||||
|
||||
The restricted composer does not expose the command palette, slash
|
||||
autocomplete, server command catalog, or command-generated action cards. A
|
||||
leading slash is rejected locally rather than dispatched; approved outcomes
|
||||
such as New chat and Cancel remain explicit typed UI actions. Approval,
|
||||
clarification, secret, and elevated-access requests are denied or skipped
|
||||
immediately with a bounded notice. The supervised user cannot authorize them;
|
||||
a parent may retry from the authenticated full client.
|
||||
|
||||
Restricted Settings contains only parent-approved, non-authoritative choices,
|
||||
such as a supervised-only theme, text size, language, haptics, accessibility,
|
||||
message presentation, sensitive-media blur, and permitted voice playback
|
||||
preferences. Connections, Manage, profiles, models, personalities, reasoning,
|
||||
approvals, tools, plugins, Terminal, TUI, Bridge, Device Control, notification
|
||||
companion, diagnostics, logs, files, credentials, developer controls, Relay
|
||||
management, and other sessions are absent rather than shown disabled.
|
||||
|
||||
The parent may allow the configured floating pet and may independently let the
|
||||
supervised user change the phone-local profile icon or an already-installed chat
|
||||
background. The parent retains those appearance controls when supervised-user
|
||||
changes are disabled. Conversation history and its mutations are separate
|
||||
permissions: pin, rename, archive/restore, transcript sharing, and delete are
|
||||
individually allowlisted, while technical session identifiers and cross-profile
|
||||
administration remain hidden. Delete retains its confirmation step.
|
||||
|
||||
The parent also chooses what Chat discloses. **Simple** is the default: agent
|
||||
name/avatar plus generic Connected, Working, and Reconnecting states; it hides
|
||||
model, profile, provider/route, context, token/usage, reasoning, and tool detail.
|
||||
**Transparent** may add timestamps, bounded usage/context information, and
|
||||
approved activity labels without exposing arguments, results, paths, or
|
||||
credentials. **Custom** exposes the individual visibility switches. Model name
|
||||
and profile name default off in every new policy. Required errors, safety
|
||||
notices, parent-action states, and connection failures cannot be hidden by a
|
||||
cosmetic visibility choice.
|
||||
|
||||
Session selection is limited to the pinned profile. New chat creates a new
|
||||
conversation for that profile; history visibility, transcript retention, and
|
||||
conversation actions follow the parent policy. Ending Supervised Mode may clear
|
||||
local drafts, pending media, and restricted caches, but does not imply deletion
|
||||
of server-owned session history. Server history remains available through the
|
||||
parent's ordinary authenticated Hermes surfaces.
|
||||
|
||||
When the optional Relay plugin is paired, Android reports a bounded
|
||||
client-declared `supervised` tag and a non-sensitive policy summary with its
|
||||
ordinary device identity. Relay and its UI may display that tag and allow the
|
||||
operator to revoke the paired Relay session through the existing revocation
|
||||
model. The tag is informational: Relay does not interpret or enforce the Android
|
||||
policy, pin a profile, filter Gateway traffic, or certify the client. Revoking
|
||||
the Relay session removes Relay-backed capabilities but cannot revoke a direct
|
||||
Dashboard/Gateway session or remotely disable an Android-only policy. Without
|
||||
Relay pairing, Supervised Mode remains usable and locally enforced.
|
||||
|
||||
**Security and product boundary.** This mode restricts the official Android UI,
|
||||
not the Hermes agent or server. It cannot secure another client, a modified APK,
|
||||
direct server access, server-side tools, provider output, or a parent account
|
||||
whose credentials are available elsewhere. It is not a substitute for profile
|
||||
hardening, provider safety controls, parental review, operating-system controls,
|
||||
or applicable legal obligations. Public language uses **Supervised Mode** or
|
||||
**parent-controlled client**, not "child account," "safe for children," or
|
||||
"server enforced."
|
||||
|
||||
**Verification gate.** Implementation requires policy, authentication,
|
||||
navigation, process-death, deep-link, notification, capability, attachment,
|
||||
voice, session-ownership, Relay-tag, and revocation tests. Physical testing must
|
||||
cover the exact Android build on a managed/restricted device, including relock,
|
||||
restart, offline recovery, and attempts to escape the restricted root. Until
|
||||
that evidence exists, documentation and release notes must call the feature
|
||||
planned or experimental and must not call it child-ready.
|
||||
|
||||
**Consequences.** The project gains a generalized, low-noise supervised client
|
||||
without creating a new Hermes account type or making Relay a chat authorization
|
||||
proxy. Parents receive clear local controls and optional paired-device
|
||||
visibility, while server ownership and the limits of client-side enforcement
|
||||
remain explicit.
|
||||
---
|
||||
|
||||
## ADR 67 — Android Bot Mode is a separate upstream-owned messaging workspace
|
||||
|
||||
**Status:** Accepted (2026-08-24).
|
||||
|
||||
|
||||
@@ -13,7 +13,7 @@
|
||||
"verification": "ai-translated",
|
||||
"review_refs": [],
|
||||
"source_sha256": {
|
||||
"main": "15a2c5ffe5bed203117291fcff6a2f0d39f2181bd0b7f709746e5a659f48e7fd",
|
||||
"main": "c8a915aa7faf68a6c2b6f5e09bfe4fe69a6a60bdc9fbc2c252a3bda911c9cb36",
|
||||
"sideload": "4abff4f1069091ec2de735c3037a7ec7d77699cb4321e8511a622437bceaf7c2"
|
||||
},
|
||||
"surfaces": {
|
||||
@@ -44,11 +44,11 @@
|
||||
}
|
||||
},
|
||||
"es": {
|
||||
"native_name": "Español",
|
||||
"native_name": "Espa\u00f1ol",
|
||||
"verification": "ai-translated",
|
||||
"review_refs": [],
|
||||
"source_sha256": {
|
||||
"main": "15a2c5ffe5bed203117291fcff6a2f0d39f2181bd0b7f709746e5a659f48e7fd",
|
||||
"main": "c8a915aa7faf68a6c2b6f5e09bfe4fe69a6a60bdc9fbc2c252a3bda911c9cb36",
|
||||
"sideload": "4abff4f1069091ec2de735c3037a7ec7d77699cb4321e8511a622437bceaf7c2"
|
||||
},
|
||||
"surfaces": {
|
||||
@@ -68,11 +68,11 @@
|
||||
"website_source_sha256": "2de54aed7fd3c4e02ecbf57a4e9069ce64a3fd8d2874dc41b63732924fb354e1"
|
||||
},
|
||||
"ja": {
|
||||
"native_name": "日本語",
|
||||
"native_name": "\u65e5\u672c\u8a9e",
|
||||
"verification": "ai-translated",
|
||||
"review_refs": [],
|
||||
"source_sha256": {
|
||||
"main": "15a2c5ffe5bed203117291fcff6a2f0d39f2181bd0b7f709746e5a659f48e7fd",
|
||||
"main": "c8a915aa7faf68a6c2b6f5e09bfe4fe69a6a60bdc9fbc2c252a3bda911c9cb36",
|
||||
"sideload": "4abff4f1069091ec2de735c3037a7ec7d77699cb4321e8511a622437bceaf7c2"
|
||||
},
|
||||
"surfaces": {
|
||||
@@ -92,11 +92,11 @@
|
||||
"website_source_sha256": "2de54aed7fd3c4e02ecbf57a4e9069ce64a3fd8d2874dc41b63732924fb354e1"
|
||||
},
|
||||
"pt-BR": {
|
||||
"native_name": "Português (Brasil)",
|
||||
"native_name": "Portugu\u00eas (Brasil)",
|
||||
"verification": "ai-translated",
|
||||
"review_refs": [],
|
||||
"source_sha256": {
|
||||
"main": "15a2c5ffe5bed203117291fcff6a2f0d39f2181bd0b7f709746e5a659f48e7fd",
|
||||
"main": "c8a915aa7faf68a6c2b6f5e09bfe4fe69a6a60bdc9fbc2c252a3bda911c9cb36",
|
||||
"sideload": "4abff4f1069091ec2de735c3037a7ec7d77699cb4321e8511a622437bceaf7c2"
|
||||
},
|
||||
"surfaces": {
|
||||
@@ -116,11 +116,11 @@
|
||||
"website_source_sha256": "2de54aed7fd3c4e02ecbf57a4e9069ce64a3fd8d2874dc41b63732924fb354e1"
|
||||
},
|
||||
"ru": {
|
||||
"native_name": "Русский",
|
||||
"native_name": "\u0420\u0443\u0441\u0441\u043a\u0438\u0439",
|
||||
"verification": "ai-translated",
|
||||
"review_refs": [],
|
||||
"source_sha256": {
|
||||
"main": "15a2c5ffe5bed203117291fcff6a2f0d39f2181bd0b7f709746e5a659f48e7fd",
|
||||
"main": "c8a915aa7faf68a6c2b6f5e09bfe4fe69a6a60bdc9fbc2c252a3bda911c9cb36",
|
||||
"sideload": "4abff4f1069091ec2de735c3037a7ec7d77699cb4321e8511a622437bceaf7c2"
|
||||
},
|
||||
"surfaces": {
|
||||
@@ -131,11 +131,11 @@
|
||||
}
|
||||
},
|
||||
"zh-Hans": {
|
||||
"native_name": "简体中文",
|
||||
"native_name": "\u7b80\u4f53\u4e2d\u6587",
|
||||
"verification": "ai-translated",
|
||||
"review_refs": [],
|
||||
"source_sha256": {
|
||||
"main": "15a2c5ffe5bed203117291fcff6a2f0d39f2181bd0b7f709746e5a659f48e7fd",
|
||||
"main": "c8a915aa7faf68a6c2b6f5e09bfe4fe69a6a60bdc9fbc2c252a3bda911c9cb36",
|
||||
"sideload": "4abff4f1069091ec2de735c3037a7ec7d77699cb4321e8511a622437bceaf7c2"
|
||||
},
|
||||
"surfaces": {
|
||||
|
||||
+5
-1
@@ -7,7 +7,7 @@ Android's declarative plugin surface is specified in
|
||||
|
||||
**Status:** v1.0.0 stable. The default path supports chat, Manage, and voice on vanilla upstream Hermes without installing the Relay plugin. Relay is additive: terminal, bridge/device control, notification companion, remote access, extra/provider-native voice, desktop tooling, and dashboard Relay management. Historical phase notes remain in this file for context; the current route ownership source of truth is [`docs/upstream-surface-matrix.md`](upstream-surface-matrix.md).
|
||||
**Repo:** [Codename-11/hermes-relay](https://github.com/Codename-11/hermes-relay)
|
||||
**Updated:** 2026-08-22
|
||||
**Updated:** 2026-08-24
|
||||
|
||||
---
|
||||
|
||||
@@ -46,6 +46,10 @@ token, terminal/bridge grants, and optional network candidates.
|
||||
4. **Clean UX** — Material 3, minimal setup, and clear route identity for Vanilla Hermes vs Relay.
|
||||
5. **Offline-aware** — graceful degradation when connection drops. Auto-reconnect with exponential backoff.
|
||||
6. **Server-side state** — the app is a thin client. Sessions, history, memory, profiles, and dashboard state live on the Hermes server.
|
||||
7. **Supervision is a client policy** — Android may offer a parent-controlled,
|
||||
profile-pinned restricted interface, but it does not claim to make the
|
||||
selected Hermes profile, server, or agent child-safe. See ADR 66 and the
|
||||
[Supervised Mode guide](../user-docs/guide/supervised-mode.md).
|
||||
|
||||
---
|
||||
|
||||
|
||||
Vendored
+8
-8
File diff suppressed because one or more lines are too long
@@ -0,0 +1,337 @@
|
||||
"""Read and write Git state endpoints for the Hermes-Relay dashboard plugin.
|
||||
|
||||
Mounted by hermes-agent at ``/api/plugins/hermes-relay/git/*``. These routes
|
||||
power both the dashboard tab and the Android mobile page.
|
||||
|
||||
Route map
|
||||
---------
|
||||
Read (GET, no grant):
|
||||
- ``GET /git/repos`` → scanned repo list under the configured base path
|
||||
- ``GET /git/status`` → grouped working-tree status for one repo
|
||||
- ``GET /git/branches`` → branch list (name, upstream, ahead/behind, current)
|
||||
- ``GET /git/diff`` → per-file diff (kind=staged|unstaged)
|
||||
- ``GET /git/file`` → read a tracked file
|
||||
|
||||
Write (POST, require the plugin's ``plugin.api.write`` grant, which the app
|
||||
enforces client-side before ever sending the request — the same gate used for
|
||||
every mutating plugin action in PluginsViewModel.invokeAction):
|
||||
- ``POST /git/stage`` — stage path list → fresh status
|
||||
- ``POST /git/unstage`` — unstage path list → fresh status
|
||||
- ``POST /git/discard`` — discard paths (confirmation) → fresh status
|
||||
- ``POST /git/commit`` — commit staged index (message) → {head,status}
|
||||
- ``POST /git/commit_selected``— commit selected paths (message+paths)
|
||||
- ``POST /git/fetch`` — fetch a remote → {branches,status}
|
||||
- ``POST /git/pull`` — pull remote/branch → fresh status
|
||||
- ``POST /git/push`` — push (confirmation) → {branches,status}
|
||||
- ``POST /git/checkout`` — switch branch (new_branch/track; dirty→confirmation)
|
||||
|
||||
Destructive writes (discard, push, dirty checkout) enforce a per-use
|
||||
confirmation string server-side: missing/wrong → 403. Dirty/conflict trees →
|
||||
409. The HTTP mapping keeps raw stack traces and JSON dumps out of the UI.
|
||||
|
||||
Security
|
||||
--------
|
||||
- ``repo`` is an opaque id validated against the scanned allowlist; unknown
|
||||
ids → 400.
|
||||
- File paths are validated to reject traversal and absolute escapes.
|
||||
- Remote URLs are scrubbed of embedded userinfo.
|
||||
- No ``shell=True`` anywhere: every git invocation uses argument lists.
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
from typing import Any
|
||||
|
||||
from fastapi import APIRouter, Body, HTTPException, Query
|
||||
|
||||
from .. import git_state
|
||||
|
||||
router = APIRouter(prefix="/git")
|
||||
|
||||
|
||||
def _bad_request(exc: Exception) -> HTTPException:
|
||||
return HTTPException(status_code=400, detail=str(exc))
|
||||
|
||||
|
||||
# Structured error taxonomy → HTTP status. The UI renders ``detail`` (a
|
||||
# human-readable message) plus ``code`` for styling; no raw traces/JSON dumps.
|
||||
_GIT_ERROR_STATUS = {
|
||||
"non-repo": 400,
|
||||
"dirty": 409,
|
||||
"conflict": 409,
|
||||
"auth": 502,
|
||||
"network": 502,
|
||||
"invalid-input": 400,
|
||||
"missing-confirmation": 403,
|
||||
"wrong-confirmation": 403,
|
||||
}
|
||||
|
||||
|
||||
def _write_error(exc: git_state.GitError) -> HTTPException:
|
||||
status = _GIT_ERROR_STATUS.get(exc.code, 400)
|
||||
return HTTPException(status_code=status, detail=str(exc))
|
||||
|
||||
|
||||
def _resolve(repo: str) -> Any:
|
||||
return git_state.resolve_repo(git_state.base_path(), repo)
|
||||
|
||||
|
||||
@router.get("/repos")
|
||||
async def get_repos() -> dict[str, Any]:
|
||||
"""Return the scanned repo list plus a notice when the base path is missing."""
|
||||
base = git_state.base_path()
|
||||
repos = git_state.scan_repos(base)
|
||||
notice = None
|
||||
if not base.is_dir():
|
||||
notice = f"Git base path not found: {base}"
|
||||
return {"repos": repos, "base_path": str(base), "notice": notice}
|
||||
|
||||
|
||||
@router.get("/status")
|
||||
async def get_status(repo: str = Query(...)) -> dict[str, Any]:
|
||||
try:
|
||||
return git_state.repo_status(_resolve(repo))
|
||||
except git_state.GitStateError as exc:
|
||||
raise _bad_request(exc) from exc
|
||||
|
||||
|
||||
@router.get("/branches")
|
||||
async def get_branches(repo: str = Query(...)) -> dict[str, Any]:
|
||||
try:
|
||||
return {"branches": git_state.repo_branches(_resolve(repo))}
|
||||
except git_state.GitStateError as exc:
|
||||
raise _bad_request(exc) from exc
|
||||
|
||||
|
||||
@router.get("/diff")
|
||||
async def get_diff(
|
||||
repo: str = Query(...),
|
||||
path: str = Query(...),
|
||||
kind: str = Query("unstaged"),
|
||||
) -> dict[str, Any]:
|
||||
try:
|
||||
return git_state.repo_diff(_resolve(repo), path, kind)
|
||||
except git_state.GitStateError as exc:
|
||||
raise _bad_request(exc) from exc
|
||||
|
||||
|
||||
@router.get("/file")
|
||||
async def get_file(repo: str = Query(...), path: str = Query(...)) -> dict[str, Any]:
|
||||
try:
|
||||
return git_state.read_file(_resolve(repo), path)
|
||||
except git_state.GitStateError as exc:
|
||||
raise _bad_request(exc) from exc
|
||||
|
||||
|
||||
# ── Write endpoints ────────────────────────────────────────────────────────
|
||||
# Every write requires the plugin.api.write grant (enforced client-side). The
|
||||
# router re-validates repo + paths against the allowlist/traversal rules and
|
||||
# classifies git failures into a structured error the UI can render.
|
||||
|
||||
def _require_repo(body: dict[str, Any]) -> Any:
|
||||
repo = body.get("repo")
|
||||
if not isinstance(repo, str) or not repo:
|
||||
raise git_state.GitStateError("repo is required")
|
||||
return git_state.resolve_repo(git_state.base_path(), repo)
|
||||
|
||||
|
||||
@router.post("/stage")
|
||||
async def post_stage(body: dict[str, Any] = Body(...)) -> dict[str, Any]:
|
||||
try:
|
||||
return git_state.stage(_require_repo(body), _paths(body))
|
||||
except git_state.GitError as exc:
|
||||
raise _write_error(exc) from exc
|
||||
except git_state.GitStateError as exc:
|
||||
raise _bad_request(exc) from exc
|
||||
|
||||
|
||||
@router.post("/unstage")
|
||||
async def post_unstage(body: dict[str, Any] = Body(...)) -> dict[str, Any]:
|
||||
try:
|
||||
return git_state.unstage(_require_repo(body), _paths(body))
|
||||
except git_state.GitError as exc:
|
||||
raise _write_error(exc) from exc
|
||||
except git_state.GitStateError as exc:
|
||||
raise _bad_request(exc) from exc
|
||||
|
||||
|
||||
@router.post("/discard")
|
||||
async def post_discard(body: dict[str, Any] = Body(...)) -> dict[str, Any]:
|
||||
try:
|
||||
return git_state.discard(
|
||||
_require_repo(body),
|
||||
_paths(body),
|
||||
confirmation=body.get("confirmation"),
|
||||
delete_untracked=bool(body.get("delete_untracked", False)),
|
||||
)
|
||||
except git_state.GitError as exc:
|
||||
raise _write_error(exc) from exc
|
||||
except git_state.GitStateError as exc:
|
||||
raise _bad_request(exc) from exc
|
||||
|
||||
|
||||
@router.post("/commit")
|
||||
async def post_commit(body: dict[str, Any] = Body(...)) -> dict[str, Any]:
|
||||
try:
|
||||
return git_state.commit(_require_repo(body), _message(body))
|
||||
except git_state.GitError as exc:
|
||||
raise _write_error(exc) from exc
|
||||
except git_state.GitStateError as exc:
|
||||
raise _bad_request(exc) from exc
|
||||
|
||||
|
||||
@router.post("/commit_selected")
|
||||
async def post_commit_selected(body: dict[str, Any] = Body(...)) -> dict[str, Any]:
|
||||
try:
|
||||
return git_state.commit_selected(_require_repo(body), _message(body), _paths(body))
|
||||
except git_state.GitError as exc:
|
||||
raise _write_error(exc) from exc
|
||||
except git_state.GitStateError as exc:
|
||||
raise _bad_request(exc) from exc
|
||||
|
||||
|
||||
@router.post("/fetch")
|
||||
async def post_fetch(body: dict[str, Any] = Body(...)) -> dict[str, Any]:
|
||||
try:
|
||||
return git_state.fetch(_require_repo(body), _remote(body))
|
||||
except git_state.GitError as exc:
|
||||
raise _write_error(exc) from exc
|
||||
except git_state.GitStateError as exc:
|
||||
raise _bad_request(exc) from exc
|
||||
|
||||
|
||||
@router.post("/pull")
|
||||
async def post_pull(body: dict[str, Any] = Body(...)) -> dict[str, Any]:
|
||||
try:
|
||||
return git_state.pull(_require_repo(body), _remote(body), _branch(body))
|
||||
except git_state.GitError as exc:
|
||||
raise _write_error(exc) from exc
|
||||
except git_state.GitStateError as exc:
|
||||
raise _bad_request(exc) from exc
|
||||
|
||||
|
||||
@router.post("/push")
|
||||
async def post_push(body: dict[str, Any] = Body(...)) -> dict[str, Any]:
|
||||
try:
|
||||
return git_state.push(
|
||||
_require_repo(body),
|
||||
_remote(body),
|
||||
_branch(body),
|
||||
confirmation=body.get("confirmation"),
|
||||
)
|
||||
except git_state.GitError as exc:
|
||||
raise _write_error(exc) from exc
|
||||
except git_state.GitStateError as exc:
|
||||
raise _bad_request(exc) from exc
|
||||
|
||||
|
||||
@router.post("/checkout")
|
||||
async def post_checkout(body: dict[str, Any] = Body(...)) -> dict[str, Any]:
|
||||
try:
|
||||
new_branch = _str_opt(body, "new_branch")
|
||||
return git_state.checkout(
|
||||
_require_repo(body),
|
||||
_ref(body, allow_empty=bool(new_branch)),
|
||||
confirmation=body.get("confirmation"),
|
||||
new_branch=new_branch,
|
||||
track=bool(body.get("track", False)),
|
||||
)
|
||||
except git_state.GitError as exc:
|
||||
raise _write_error(exc) from exc
|
||||
except git_state.GitStateError as exc:
|
||||
raise _bad_request(exc) from exc
|
||||
|
||||
|
||||
@router.post("/stash_checkout")
|
||||
async def post_stash_checkout(body: dict[str, Any] = Body(...)) -> dict[str, Any]:
|
||||
"""Checkout that auto-stashes a dirty tree first.
|
||||
|
||||
Unlike a plain dirty checkout, no confirmation is required: a stash is
|
||||
recoverable (``git stash pop``), so this is not a data-loss path. The
|
||||
response carries ``stashed`` + ``stash_message`` so the UI can surface the
|
||||
stash after a successful switch.
|
||||
"""
|
||||
try:
|
||||
new_branch = _str_opt(body, "new_branch")
|
||||
return git_state.stash_checkout(
|
||||
_require_repo(body),
|
||||
_ref(body, allow_empty=bool(new_branch)),
|
||||
new_branch=new_branch,
|
||||
track=bool(body.get("track", False)),
|
||||
)
|
||||
except git_state.GitError as exc:
|
||||
raise _write_error(exc) from exc
|
||||
except git_state.GitStateError as exc:
|
||||
raise _bad_request(exc) from exc
|
||||
|
||||
|
||||
@router.post("/commit_message")
|
||||
async def post_commit_message(body: dict[str, Any] = Body(...)) -> dict[str, Any]:
|
||||
"""Generate a conventional-style commit message from the staged diff.
|
||||
|
||||
Empty staged diff → ``{message:"", notice:"nothing staged"}`` without calling
|
||||
the model. A missing/failed model degrades to an empty message + a
|
||||
``model unavailable`` notice — never a 500. Only staged content is sent.
|
||||
"""
|
||||
try:
|
||||
return await git_state.commit_message(_require_repo(body))
|
||||
except git_state.GitStateError as exc:
|
||||
raise _bad_request(exc) from exc
|
||||
|
||||
|
||||
@router.post("/commit_message_selected")
|
||||
async def post_commit_message_selected(
|
||||
body: dict[str, Any] = Body(...),
|
||||
) -> dict[str, Any]:
|
||||
"""Generate a message from the staged diff of the given ``paths`` only."""
|
||||
try:
|
||||
return await git_state.commit_message_selected(
|
||||
_require_repo(body), _paths(body)
|
||||
)
|
||||
except git_state.GitError as exc:
|
||||
raise _write_error(exc) from exc
|
||||
except git_state.GitStateError as exc:
|
||||
raise _bad_request(exc) from exc
|
||||
|
||||
|
||||
def _paths(body: dict[str, Any]) -> list[str]:
|
||||
paths = body.get("paths")
|
||||
if not isinstance(paths, list) or not paths or not all(isinstance(p, str) for p in paths):
|
||||
raise git_state.GitStateError("paths must be a non-empty list of strings")
|
||||
return paths
|
||||
|
||||
|
||||
def _message(body: dict[str, Any]) -> str:
|
||||
message = body.get("message")
|
||||
if not isinstance(message, str):
|
||||
raise git_state.GitStateError("message is required")
|
||||
return message
|
||||
|
||||
|
||||
def _remote(body: dict[str, Any]) -> str:
|
||||
remote = body.get("remote", "origin")
|
||||
if not isinstance(remote, str):
|
||||
raise git_state.GitStateError("remote must be a string")
|
||||
return remote or "origin"
|
||||
|
||||
|
||||
def _branch(body: dict[str, Any]) -> str:
|
||||
branch = body.get("branch", "")
|
||||
if not isinstance(branch, str):
|
||||
raise git_state.GitStateError("branch must be a string")
|
||||
return branch
|
||||
|
||||
|
||||
def _ref(body: dict[str, Any], *, allow_empty: bool = False) -> str:
|
||||
ref = body.get("ref", "" if allow_empty else None)
|
||||
if not isinstance(ref, str) or (not ref and not allow_empty):
|
||||
raise git_state.GitStateError("ref is required")
|
||||
return ref
|
||||
|
||||
|
||||
def _str_opt(body: dict[str, Any], key: str) -> str:
|
||||
value = body.get(key)
|
||||
return value if isinstance(value, str) else ""
|
||||
|
||||
|
||||
__all__ = ["router"]
|
||||
@@ -32,6 +32,15 @@ async def get_mobile_manifest() -> dict[str, Any]:
|
||||
|
||||
@router.get("/pages/{plugin_id}")
|
||||
async def get_mobile_page(plugin_id: str = Path(...)) -> dict[str, Any]:
|
||||
# The static read-only Git page is served directly from the git_state
|
||||
# module (not the generated-page store). It carries no filesystem paths
|
||||
# per the android-plugins.md document contract.
|
||||
if plugin_id == "git":
|
||||
from .. import git_state
|
||||
|
||||
document = git_state.build_git_document(git_state.base_path())
|
||||
document["host_revision"] = 1
|
||||
return document
|
||||
try:
|
||||
entry = _store().get(plugin_id)
|
||||
document = dict(entry["document"])
|
||||
|
||||
@@ -142,6 +142,7 @@ def _validate_public_url(url: str) -> str:
|
||||
|
||||
router = APIRouter()
|
||||
router.include_router(_plugin_module("dashboard.mobile_plugin_api").router)
|
||||
router.include_router(_plugin_module("dashboard.git_api").router)
|
||||
|
||||
|
||||
def _relay_unreachable(err: Exception) -> HTTPException:
|
||||
|
||||
@@ -6,6 +6,7 @@ import RelayManagement from "./tabs/RelayManagement.jsx";
|
||||
import BridgeActivity from "./tabs/BridgeActivity.jsx";
|
||||
import MediaInspector from "./tabs/MediaInspector.jsx";
|
||||
import RemoteAccess from "./tabs/RemoteAccess.jsx";
|
||||
import GitState from "./tabs/GitState.jsx";
|
||||
import RelayStatusSlot from "./components/RelayStatusSlot.jsx";
|
||||
import MobileConnectDialog from "./components/MobileConnectDialog.jsx";
|
||||
import { Button, Switch } from "./lib/ui-shims.jsx";
|
||||
@@ -19,6 +20,7 @@ const TABS = [
|
||||
{ key: "activity", label: "Activity" },
|
||||
{ key: "media", label: "Media" },
|
||||
{ key: "remote", label: "Remote Access" },
|
||||
{ key: "git", label: "Git" },
|
||||
];
|
||||
|
||||
function readAutoRefresh() {
|
||||
@@ -113,6 +115,7 @@ function RelayPluginRoot() {
|
||||
{tab === "activity" && <BridgeActivity autoRefresh={autoRefresh} />}
|
||||
{tab === "media" && <MediaInspector autoRefresh={autoRefresh} />}
|
||||
{tab === "remote" && <RemoteAccess autoRefresh={autoRefresh} />}
|
||||
{tab === "git" && <GitState autoRefresh={autoRefresh} />}
|
||||
</div>
|
||||
<MobileConnectDialog
|
||||
open={mobileConnectOpen}
|
||||
|
||||
@@ -139,3 +139,107 @@ export function mintPairingWithMode({ mode, publicUrl, prefer, ...rest } = {}) {
|
||||
body: JSON.stringify(body),
|
||||
});
|
||||
}
|
||||
|
||||
// ── Git State tab ───────────────────────────────────────────────────────────
|
||||
|
||||
export function getGitRepos() {
|
||||
return fetchJSON("/git/repos");
|
||||
}
|
||||
|
||||
export function getGitStatus(repo) {
|
||||
return fetchJSON(`/git/status?repo=${encodeURIComponent(repo)}`);
|
||||
}
|
||||
|
||||
export function getGitBranches(repo) {
|
||||
return fetchJSON(`/git/branches?repo=${encodeURIComponent(repo)}`);
|
||||
}
|
||||
|
||||
export function getGitDiff(repo, path, kind = "unstaged") {
|
||||
return fetchJSON(
|
||||
`/git/diff?repo=${encodeURIComponent(repo)}&path=${encodeURIComponent(path)}&kind=${encodeURIComponent(kind)}`,
|
||||
);
|
||||
}
|
||||
|
||||
export function getGitFile(repo, path) {
|
||||
return fetchJSON(
|
||||
`/git/file?repo=${encodeURIComponent(repo)}&path=${encodeURIComponent(path)}`,
|
||||
);
|
||||
}
|
||||
|
||||
// ── Git State write operations ────────────────────────────────────────────
|
||||
// Every write POST goes through the authenticated Dashboard plugin namespace.
|
||||
// Android separately enforces its local plugin.api.write preference before it
|
||||
// calls this namespace. Destructive ops pass a per-use confirmation token.
|
||||
|
||||
function postGit(path, body) {
|
||||
return fetchJSON(path, {
|
||||
method: "POST",
|
||||
headers: { "Content-Type": "application/json" },
|
||||
body: JSON.stringify(body),
|
||||
});
|
||||
}
|
||||
|
||||
export function gitStage(repo, paths) {
|
||||
return postGit("/git/stage", { repo, paths });
|
||||
}
|
||||
|
||||
export function gitUnstage(repo, paths) {
|
||||
return postGit("/git/unstage", { repo, paths });
|
||||
}
|
||||
|
||||
export function gitDiscard(repo, paths, confirmation, deleteUntracked = false) {
|
||||
return postGit("/git/discard", {
|
||||
repo,
|
||||
paths,
|
||||
confirmation,
|
||||
delete_untracked: deleteUntracked,
|
||||
});
|
||||
}
|
||||
|
||||
export function gitCommit(repo, message) {
|
||||
return postGit("/git/commit", { repo, message });
|
||||
}
|
||||
|
||||
export function gitFetch(repo, remote = "origin") {
|
||||
return postGit("/git/fetch", { repo, remote });
|
||||
}
|
||||
|
||||
export function gitPull(repo, remote = "origin", branch = "") {
|
||||
return postGit("/git/pull", { repo, remote, branch });
|
||||
}
|
||||
|
||||
export function gitPush(repo, confirmation, remote = "origin", branch = "") {
|
||||
return postGit("/git/push", { repo, remote, branch, confirmation });
|
||||
}
|
||||
|
||||
export function gitCheckout(repo, ref, opts = {}) {
|
||||
const body = {
|
||||
repo,
|
||||
ref,
|
||||
confirmation: opts.confirmation,
|
||||
new_branch: opts.newBranch || "",
|
||||
track: !!opts.track,
|
||||
};
|
||||
return postGit("/git/checkout", body);
|
||||
}
|
||||
|
||||
// ── Git State Phase 3 extras ───────────────────────────────────────────────
|
||||
// AI commit-message suggestions + auto-stashing checkout.
|
||||
|
||||
export function gitCommitMessage(repo) {
|
||||
return postGit("/git/commit_message", { repo });
|
||||
}
|
||||
|
||||
export function gitCommitMessageSelected(repo, paths) {
|
||||
return postGit("/git/commit_message_selected", { repo, paths });
|
||||
}
|
||||
|
||||
export function gitStashCheckout(repo, ref, opts = {}) {
|
||||
const body = {
|
||||
repo,
|
||||
ref,
|
||||
new_branch: opts.newBranch || "",
|
||||
track: !!opts.track,
|
||||
};
|
||||
return postGit("/git/stash_checkout", body);
|
||||
}
|
||||
|
||||
@@ -0,0 +1,148 @@
|
||||
// Pure data-mapping helpers for the Git State dashboard tab.
|
||||
// Kept free of React/DOM so they are unit-testable with node:test.
|
||||
|
||||
/**
|
||||
* Normalize a /git/repos response into a stable repo list.
|
||||
* Accepts {repos:[...]} or a bare array.
|
||||
*/
|
||||
export function normalizeRepos(data) {
|
||||
const list = Array.isArray(data) ? data : (data && data.repos) || [];
|
||||
return list.filter((r) => r && typeof r.id === "string");
|
||||
}
|
||||
|
||||
/**
|
||||
* Normalize a /git/status response into {counts, staged, modified, untracked,
|
||||
* truncated}. Missing groups become empty arrays.
|
||||
*/
|
||||
export function normalizeStatus(data) {
|
||||
const src = data && typeof data === "object" ? data : {};
|
||||
return {
|
||||
counts: {
|
||||
staged: Number(src.counts && src.counts.staged) || 0,
|
||||
modified: Number(src.counts && src.counts.modified) || 0,
|
||||
untracked: Number(src.counts && src.counts.untracked) || 0,
|
||||
},
|
||||
staged: Array.isArray(src.staged) ? src.staged : [],
|
||||
modified: Array.isArray(src.modified) ? src.modified : [],
|
||||
untracked: Array.isArray(src.untracked) ? src.untracked : [],
|
||||
truncated: !!src.truncated,
|
||||
};
|
||||
}
|
||||
|
||||
/**
|
||||
* Normalize a /git/branches response into a branch list.
|
||||
* Accepts {branches:[...]} or a bare array.
|
||||
*/
|
||||
export function normalizeBranches(data) {
|
||||
const list = Array.isArray(data) ? data : (data && data.branches) || [];
|
||||
return list.filter((b) => b && typeof b.name === "string");
|
||||
}
|
||||
|
||||
/**
|
||||
* Build a human-readable branch label, e.g. "main → origin/main (ahead 1)".
|
||||
*/
|
||||
export function branchLabel(branch) {
|
||||
if (!branch) return "";
|
||||
const base = branch.name || "";
|
||||
if (!branch.upstream) return base;
|
||||
const track =
|
||||
branch.ahead > 0 || branch.behind > 0
|
||||
? ` (ahead ${branch.ahead}, behind ${branch.behind})`
|
||||
: "";
|
||||
return `${base} → ${branch.upstream}${track}`;
|
||||
}
|
||||
|
||||
/**
|
||||
* True when a status response is truncated (over the server cap).
|
||||
*/
|
||||
export function isTruncated(status) {
|
||||
return !!(status && status.truncated);
|
||||
}
|
||||
|
||||
/**
|
||||
* Fixed per-use confirmation tokens for destructive git mutations. These
|
||||
* mirror the plugin's server-side constants (plugin/git_state.py) and are
|
||||
* sent in the POST body so the server can enforce the destructive gate.
|
||||
* The dashboard tab shows a human-readable description before echoing these.
|
||||
*/
|
||||
export const CONFIRMATIONS = {
|
||||
discard: "discard",
|
||||
push: "push",
|
||||
dirtyCheckout: "checkout-dirty",
|
||||
};
|
||||
|
||||
/** Ops that require a per-use confirmation string before the POST is sent. */
|
||||
const DESTRUCTIVE_OPS = new Set(["discard", "push", "dirty-checkout"]);
|
||||
|
||||
/**
|
||||
* True when the named mutation requires a per-use confirmation string.
|
||||
* The tab must not send the POST without it (matches the server gate).
|
||||
*/
|
||||
export function requiresConfirmation(op) {
|
||||
return DESTRUCTIVE_OPS.has(op);
|
||||
}
|
||||
|
||||
/**
|
||||
* Return the fixed confirmation token for a destructive op, or null when the
|
||||
* op is non-destructive (no confirmation needed).
|
||||
*/
|
||||
export function confirmationFor(op) {
|
||||
if (!requiresConfirmation(op)) return null;
|
||||
if (op === "discard") return CONFIRMATIONS.discard;
|
||||
if (op === "push") return CONFIRMATIONS.push;
|
||||
return CONFIRMATIONS.dirtyCheckout;
|
||||
}
|
||||
|
||||
/**
|
||||
* Normalize a mutation response ({head, status, branches}) into a stable
|
||||
* shape, filling missing groups so the tab can render without defensive
|
||||
* branching.
|
||||
*/
|
||||
export function normalizeMutationResult(data) {
|
||||
const src = data && typeof data === "object" ? data : {};
|
||||
return {
|
||||
head: typeof src.head === "string" ? src.head : "",
|
||||
status: normalizeStatus(src.status),
|
||||
branches: normalizeBranches(src.branches),
|
||||
};
|
||||
}
|
||||
|
||||
/**
|
||||
* Normalize a /git/commit_message response into {message, notice, stashed}.
|
||||
* Missing fields degrade safely so the tab can render without branching.
|
||||
*/
|
||||
export function normalizeCommitMessage(data) {
|
||||
const src = data && typeof data === "object" ? data : {};
|
||||
return {
|
||||
message: typeof src.message === "string" ? src.message : "",
|
||||
notice: typeof src.notice === "string" ? src.notice : "",
|
||||
};
|
||||
}
|
||||
|
||||
/**
|
||||
* True when a commit-message suggestion is usable (non-empty message).
|
||||
*/
|
||||
export function hasCommitSuggestion(result) {
|
||||
return !!(result && result.message && result.message.trim());
|
||||
}
|
||||
|
||||
export function isCurrentRepoRequest(currentRepo, currentGeneration, repo, generation) {
|
||||
return currentRepo === repo && currentGeneration === generation;
|
||||
}
|
||||
|
||||
export function shouldOfferPushAfterCommit(commitSucceeded, pushAfterCommit) {
|
||||
return commitSucceeded === true && pushAfterCommit === true;
|
||||
}
|
||||
|
||||
/**
|
||||
* Normalize a /git/stash_checkout response: the standard mutation shape plus
|
||||
* {stashed, stash_message}.
|
||||
*/
|
||||
export function normalizeStashCheckout(data) {
|
||||
const src = data && typeof data === "object" ? data : {};
|
||||
return {
|
||||
...normalizeMutationResult(data),
|
||||
stashed: !!src.stashed,
|
||||
stashMessage: typeof src.stash_message === "string" ? src.stash_message : "",
|
||||
};
|
||||
}
|
||||
@@ -0,0 +1,73 @@
|
||||
const MAX_PROFILE_LABEL_LENGTH = 80;
|
||||
const MAX_CAPABILITY_LENGTH = 32;
|
||||
const MAX_CAPABILITIES = 12;
|
||||
const MAX_VISIBLE_CAPABILITIES = 4;
|
||||
|
||||
const CAPABILITY_LABELS = {
|
||||
text_chat: "Text chat",
|
||||
attachments: "Attachments",
|
||||
voice: "Voice",
|
||||
generated_images: "Generated images",
|
||||
new_chat: "New chat",
|
||||
cancel: "Cancel",
|
||||
steer: "Steer",
|
||||
share_images: "Share images",
|
||||
copy: "Copy",
|
||||
retry: "Retry",
|
||||
quote_reply: "Quote & reply",
|
||||
timestamps: "Timestamps",
|
||||
};
|
||||
|
||||
function boundedText(value, maxLength) {
|
||||
if (typeof value !== "string") return null;
|
||||
const normalized = value.replace(/[\u0000-\u001f\u007f]/g, " ").replace(/\s+/g, " ").trim();
|
||||
if (!normalized) return null;
|
||||
return normalized.slice(0, maxLength);
|
||||
}
|
||||
|
||||
function capabilityLabel(value) {
|
||||
const normalized = boundedText(value, MAX_CAPABILITY_LENGTH);
|
||||
if (!normalized) return null;
|
||||
const key = normalized.toLowerCase();
|
||||
return CAPABILITY_LABELS[key] || null;
|
||||
}
|
||||
|
||||
/**
|
||||
* Normalize optional, client-reported supervised-mode metadata for display.
|
||||
* This deliberately requires active === true and never treats the report as a
|
||||
* Relay authorization policy.
|
||||
*/
|
||||
export function supervisedSessionDisplay(session) {
|
||||
const raw = session && session.supervised_mode;
|
||||
if (
|
||||
!raw ||
|
||||
typeof raw !== "object" ||
|
||||
Array.isArray(raw) ||
|
||||
raw.active !== true ||
|
||||
raw.enforcement_owner !== "android_client"
|
||||
) {
|
||||
return null;
|
||||
}
|
||||
|
||||
const profileLabel = boundedText(raw.profile_label, MAX_PROFILE_LABEL_LENGTH);
|
||||
const source = Array.isArray(raw.capabilities) ? raw.capabilities : [];
|
||||
const capabilities = [];
|
||||
const seen = new Set();
|
||||
for (const entry of source.slice(0, MAX_CAPABILITIES)) {
|
||||
const label = capabilityLabel(entry);
|
||||
if (!label) continue;
|
||||
const key = label.toLowerCase();
|
||||
if (seen.has(key)) continue;
|
||||
seen.add(key);
|
||||
capabilities.push(label);
|
||||
}
|
||||
|
||||
const visibleCapabilities = capabilities.slice(0, MAX_VISIBLE_CAPABILITIES);
|
||||
const remainingCapabilityCount = Math.max(0, capabilities.length - visibleCapabilities.length);
|
||||
|
||||
return {
|
||||
profileLabel,
|
||||
visibleCapabilities,
|
||||
remainingCapabilityCount,
|
||||
};
|
||||
}
|
||||
@@ -0,0 +1,760 @@
|
||||
const SDK = window.__HERMES_PLUGIN_SDK__;
|
||||
const { React } = SDK;
|
||||
const { useState, useEffect, useCallback, useRef } = SDK.hooks;
|
||||
|
||||
import {
|
||||
getGitRepos,
|
||||
getGitStatus,
|
||||
getGitBranches,
|
||||
getGitDiff,
|
||||
getGitFile,
|
||||
gitStage,
|
||||
gitUnstage,
|
||||
gitDiscard,
|
||||
gitCommit,
|
||||
gitCommitMessage,
|
||||
gitCommitMessageSelected,
|
||||
gitStashCheckout,
|
||||
gitFetch,
|
||||
gitPull,
|
||||
gitPush,
|
||||
gitCheckout,
|
||||
} from "../lib/api.js";
|
||||
import {
|
||||
normalizeMutationResult,
|
||||
normalizeCommitMessage,
|
||||
normalizeStashCheckout,
|
||||
hasCommitSuggestion,
|
||||
requiresConfirmation,
|
||||
confirmationFor,
|
||||
isCurrentRepoRequest,
|
||||
shouldOfferPushAfterCommit,
|
||||
} from "../lib/git-state.mjs";
|
||||
import {
|
||||
Alert,
|
||||
AlertTitle,
|
||||
AlertDescription,
|
||||
CardDescription,
|
||||
Button,
|
||||
Badge,
|
||||
Table,
|
||||
TableHeader,
|
||||
TableBody,
|
||||
TableRow,
|
||||
TableHead,
|
||||
TableCell,
|
||||
} from "../lib/ui-shims.jsx";
|
||||
|
||||
const {
|
||||
Card,
|
||||
CardHeader,
|
||||
CardTitle,
|
||||
CardContent,
|
||||
Label,
|
||||
} = SDK.components;
|
||||
|
||||
function TruncationNotice({ truncated }) {
|
||||
if (!truncated) return null;
|
||||
return (
|
||||
<div className="rounded-md border border-amber-500/40 bg-amber-500/10 p-2 text-xs text-amber-600">
|
||||
Results truncated — showing the first entries only.
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
function StatusRow({ status }) {
|
||||
if (!status) return null;
|
||||
const counts = status.counts || {};
|
||||
return (
|
||||
<div className="space-y-2">
|
||||
<div className="flex flex-wrap gap-2">
|
||||
<Badge variant="outline" className="text-xs">
|
||||
{counts.staged || 0} staged
|
||||
</Badge>
|
||||
<Badge variant="outline" className="text-xs">
|
||||
{counts.modified || 0} modified
|
||||
</Badge>
|
||||
<Badge variant="outline" className="text-xs">
|
||||
{counts.untracked || 0} untracked
|
||||
</Badge>
|
||||
</div>
|
||||
<TruncationNotice truncated={status.truncated} />
|
||||
{["staged", "modified", "untracked"].map((group) => {
|
||||
const items = status[group] || [];
|
||||
if (items.length === 0) return null;
|
||||
return (
|
||||
<div key={group}>
|
||||
<div className="text-xs font-medium uppercase tracking-wide text-muted-foreground">
|
||||
{group}
|
||||
</div>
|
||||
<ul className="mt-1 space-y-0.5">
|
||||
{items.map((item) => (
|
||||
<li key={`${group}-${item.path}`} className="font-mono text-xs">
|
||||
{item.path}
|
||||
</li>
|
||||
))}
|
||||
</ul>
|
||||
</div>
|
||||
);
|
||||
})}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
function BranchesRow({ branches }) {
|
||||
if (!branches || branches.length === 0) return null;
|
||||
return (
|
||||
<div className="space-y-1">
|
||||
{branches.map((b) => (
|
||||
<div key={b.name} className="flex items-center gap-2 text-xs">
|
||||
<span className="font-mono">{b.name}</span>
|
||||
{b.is_current ? <Badge className="text-xs">current</Badge> : null}
|
||||
{b.upstream ? (
|
||||
<span className="text-muted-foreground">
|
||||
→ {b.upstream}
|
||||
{b.ahead > 0 || b.behind > 0
|
||||
? ` (ahead ${b.ahead}, behind ${b.behind})`
|
||||
: ""}
|
||||
</span>
|
||||
) : null}
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
/**
|
||||
* Write controls for the authenticated Dashboard Git tab. Destructive ops
|
||||
* (discard/push/dirty-checkout) are confirmed via the per-use confirmation
|
||||
* mechanics before the POST is sent.
|
||||
*/
|
||||
function WriteControls({
|
||||
status,
|
||||
selected,
|
||||
commitMessage,
|
||||
onCommitMessageChange,
|
||||
generatingMessage,
|
||||
onGenerateMessage,
|
||||
commitNotice,
|
||||
newBranch,
|
||||
onNewBranchChange,
|
||||
branchRef,
|
||||
onBranchRefChange,
|
||||
mutating,
|
||||
pushAfterCommit,
|
||||
onPushAfterCommitChange,
|
||||
onStageAll,
|
||||
onUnstageAll,
|
||||
onDiscardAll,
|
||||
onStage,
|
||||
onUnstage,
|
||||
onDiscard,
|
||||
onCommit,
|
||||
onFetch,
|
||||
onPull,
|
||||
onPush,
|
||||
onCheckout,
|
||||
onStashCheckout,
|
||||
}) {
|
||||
const staged = (status && status.staged || []).map((e) => e.path);
|
||||
const modified = (status && status.modified || []).map((e) => e.path);
|
||||
return (
|
||||
<div className="space-y-3 rounded-md border p-3">
|
||||
<div className="text-xs font-medium uppercase tracking-wide text-muted-foreground">
|
||||
Write controls
|
||||
</div>
|
||||
|
||||
<div className="flex flex-wrap gap-2">
|
||||
<Button size="sm" variant="outline" disabled={mutating || modified.length === 0} onClick={onStageAll}>
|
||||
Stage modified
|
||||
</Button>
|
||||
<Button size="sm" variant="outline" disabled={mutating || staged.length === 0} onClick={onUnstageAll}>
|
||||
Unstage staged
|
||||
</Button>
|
||||
<Button size="sm" variant="outline" disabled={mutating || staged.length === 0} onClick={onDiscardAll}>
|
||||
Discard staged
|
||||
</Button>
|
||||
</div>
|
||||
|
||||
<div className="space-y-2">
|
||||
<div className="flex items-center gap-2">
|
||||
<input
|
||||
type="text"
|
||||
value={commitMessage}
|
||||
onChange={(e) => onCommitMessageChange(e.target.value)}
|
||||
placeholder="Commit message"
|
||||
className="w-full rounded-md border px-2 py-1 text-xs"
|
||||
/>
|
||||
<Button
|
||||
size="sm"
|
||||
variant="outline"
|
||||
disabled={mutating || generatingMessage || staged.length === 0}
|
||||
onClick={onGenerateMessage}
|
||||
title="Generate a commit message from the staged diff"
|
||||
>
|
||||
{generatingMessage ? "Generating…" : "Generate"}
|
||||
</Button>
|
||||
</div>
|
||||
{commitNotice ? (
|
||||
<div className="text-xs text-amber-600">{commitNotice}</div>
|
||||
) : null}
|
||||
<div className="flex items-center gap-2">
|
||||
<label className="flex items-center gap-2 text-xs">
|
||||
<input
|
||||
type="checkbox"
|
||||
checked={pushAfterCommit}
|
||||
onChange={(e) => onPushAfterCommitChange(e.target.checked)}
|
||||
/>
|
||||
Push after commit
|
||||
</label>
|
||||
<Button size="sm" disabled={mutating || !commitMessage.trim()} onClick={onCommit}>
|
||||
Commit
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="flex flex-wrap items-center gap-2">
|
||||
<input
|
||||
type="text"
|
||||
value={branchRef}
|
||||
onChange={(e) => onBranchRefChange(e.target.value)}
|
||||
placeholder="Branch to switch to"
|
||||
className="w-40 rounded-md border px-2 py-1 text-xs"
|
||||
/>
|
||||
<input
|
||||
type="text"
|
||||
value={newBranch}
|
||||
onChange={(e) => onNewBranchChange(e.target.value)}
|
||||
placeholder="New branch name"
|
||||
className="w-40 rounded-md border px-2 py-1 text-xs"
|
||||
/>
|
||||
<Button size="sm" variant="outline" disabled={mutating || !branchRef.trim()} onClick={onCheckout}>
|
||||
Checkout
|
||||
</Button>
|
||||
<Button
|
||||
size="sm"
|
||||
variant="outline"
|
||||
disabled={mutating || !branchRef.trim()}
|
||||
onClick={onStashCheckout}
|
||||
title="Switch branches, auto-stashing a dirty tree first"
|
||||
>
|
||||
Stash-checkout
|
||||
</Button>
|
||||
</div>
|
||||
|
||||
<div className="flex flex-wrap gap-2">
|
||||
<Button size="sm" variant="outline" disabled={mutating} onClick={onFetch}>
|
||||
Fetch
|
||||
</Button>
|
||||
<Button size="sm" variant="outline" disabled={mutating} onClick={onPull}>
|
||||
Pull
|
||||
</Button>
|
||||
<Button size="sm" variant="destructive" disabled={mutating} onClick={onPush}>
|
||||
Push
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
export default function GitState({ autoRefresh }) {
|
||||
const [repos, setRepos] = useState(null);
|
||||
const [selected, setSelected] = useState(null);
|
||||
const [status, setStatus] = useState(null);
|
||||
const [branches, setBranches] = useState(null);
|
||||
const [diff, setDiff] = useState(null);
|
||||
const [file, setFile] = useState(null);
|
||||
const [loading, setLoading] = useState(true);
|
||||
const [error, setError] = useState(null);
|
||||
const [notice, setNotice] = useState(null);
|
||||
const selectedRef = useRef(null);
|
||||
const requestGenerationRef = useRef(0);
|
||||
const mutationActiveRef = useRef(false);
|
||||
|
||||
const loadRepos = useCallback(async () => {
|
||||
setError(null);
|
||||
try {
|
||||
const data = await getGitRepos();
|
||||
const list = (data && data.repos) || [];
|
||||
setRepos(list);
|
||||
setNotice((data && data.notice) || null);
|
||||
const currentSelected = selectedRef.current;
|
||||
if (currentSelected && !list.some((r) => r.id === currentSelected)) {
|
||||
selectedRef.current = null;
|
||||
requestGenerationRef.current += 1;
|
||||
setSelected(null);
|
||||
setStatus(null);
|
||||
setBranches(null);
|
||||
setDiff(null);
|
||||
setFile(null);
|
||||
setGeneratingMessage(false);
|
||||
setCommitNotice(null);
|
||||
}
|
||||
} catch (err) {
|
||||
setError(err && err.message ? err.message : String(err));
|
||||
} finally {
|
||||
setLoading(false);
|
||||
}
|
||||
}, []);
|
||||
|
||||
useEffect(() => {
|
||||
loadRepos();
|
||||
}, [loadRepos]);
|
||||
|
||||
useEffect(() => {
|
||||
if (!autoRefresh) return undefined;
|
||||
const id = setInterval(loadRepos, 15000);
|
||||
return () => clearInterval(id);
|
||||
}, [autoRefresh, loadRepos]);
|
||||
|
||||
const selectRepo = useCallback(async (repoId) => {
|
||||
const generation = requestGenerationRef.current + 1;
|
||||
requestGenerationRef.current = generation;
|
||||
selectedRef.current = repoId;
|
||||
setSelected(repoId);
|
||||
setStatus(null);
|
||||
setBranches(null);
|
||||
setDiff(null);
|
||||
setFile(null);
|
||||
setGeneratingMessage(false);
|
||||
setCommitNotice(null);
|
||||
setError(null);
|
||||
try {
|
||||
const [st, br] = await Promise.all([
|
||||
getGitStatus(repoId),
|
||||
getGitBranches(repoId),
|
||||
]);
|
||||
if (!isCurrentRepoRequest(selectedRef.current, requestGenerationRef.current, repoId, generation)) return;
|
||||
setStatus(st);
|
||||
setBranches(br && br.branches);
|
||||
} catch (err) {
|
||||
if (!isCurrentRepoRequest(selectedRef.current, requestGenerationRef.current, repoId, generation)) return;
|
||||
setError(err && err.message ? err.message : String(err));
|
||||
}
|
||||
}, []);
|
||||
|
||||
const showDiff = useCallback(async (path, kind) => {
|
||||
const repoId = selectedRef.current;
|
||||
if (!repoId) return;
|
||||
const generation = requestGenerationRef.current;
|
||||
setFile(null);
|
||||
setError(null);
|
||||
try {
|
||||
const nextDiff = await getGitDiff(repoId, path, kind);
|
||||
if (!isCurrentRepoRequest(selectedRef.current, requestGenerationRef.current, repoId, generation)) return;
|
||||
setDiff(nextDiff);
|
||||
} catch (err) {
|
||||
if (!isCurrentRepoRequest(selectedRef.current, requestGenerationRef.current, repoId, generation)) return;
|
||||
setError(err && err.message ? err.message : String(err));
|
||||
}
|
||||
}, []);
|
||||
|
||||
const showFile = useCallback(async (path) => {
|
||||
const repoId = selectedRef.current;
|
||||
if (!repoId) return;
|
||||
const generation = requestGenerationRef.current;
|
||||
setDiff(null);
|
||||
setError(null);
|
||||
try {
|
||||
const nextFile = await getGitFile(repoId, path);
|
||||
if (!isCurrentRepoRequest(selectedRef.current, requestGenerationRef.current, repoId, generation)) return;
|
||||
setFile(nextFile);
|
||||
} catch (err) {
|
||||
if (!isCurrentRepoRequest(selectedRef.current, requestGenerationRef.current, repoId, generation)) return;
|
||||
setError(err && err.message ? err.message : String(err));
|
||||
}
|
||||
}, []);
|
||||
|
||||
// ── Authenticated Dashboard write controls + confirmations ──────────────
|
||||
const [commitMessage, setCommitMessage] = useState("");
|
||||
const [newBranch, setNewBranch] = useState("");
|
||||
const [branchRef, setBranchRef] = useState("");
|
||||
const [mutating, setMutating] = useState(false);
|
||||
const [mutationError, setMutationError] = useState(null);
|
||||
const [generatingMessage, setGeneratingMessage] = useState(false);
|
||||
const [commitNotice, setCommitNotice] = useState(null);
|
||||
const [pushAfterCommit, setPushAfterCommit] = useState(false);
|
||||
|
||||
const refreshDetail = useCallback(async (repoId, generation) => {
|
||||
const [st, br] = await Promise.all([
|
||||
getGitStatus(repoId),
|
||||
getGitBranches(repoId),
|
||||
]);
|
||||
if (!isCurrentRepoRequest(selectedRef.current, requestGenerationRef.current, repoId, generation)) return;
|
||||
setStatus(st);
|
||||
setBranches(br && br.branches);
|
||||
}, []);
|
||||
|
||||
const applyMutation = useCallback(
|
||||
async (op, paths, opts) => {
|
||||
const repoId = selectedRef.current;
|
||||
const generation = requestGenerationRef.current;
|
||||
if (!repoId || mutationActiveRef.current) return false;
|
||||
mutationActiveRef.current = true;
|
||||
setMutationError(null);
|
||||
setMutating(true);
|
||||
try {
|
||||
if (op === "stage") await gitStage(repoId, paths);
|
||||
else if (op === "unstage") await gitUnstage(repoId, paths);
|
||||
else if (op === "fetch") await gitFetch(repoId, opts?.remote || "origin");
|
||||
else if (op === "pull") await gitPull(repoId, opts?.remote || "origin", opts?.branch || "");
|
||||
else if (op === "commit") await gitCommit(repoId, opts?.message);
|
||||
else if (op === "discard") await gitDiscard(repoId, paths, opts?.confirmation, opts?.deleteUntracked);
|
||||
else if (op === "push") await gitPush(repoId, opts?.confirmation, opts?.remote || "origin", opts?.branch || "");
|
||||
else if (op === "checkout" || op === "dirty-checkout") {
|
||||
await gitCheckout(repoId, opts.ref, {
|
||||
confirmation: opts.confirmation,
|
||||
newBranch: opts.newBranch,
|
||||
track: opts.track,
|
||||
});
|
||||
} else throw new Error(`Unknown Git operation: ${op}`);
|
||||
if (!isCurrentRepoRequest(selectedRef.current, requestGenerationRef.current, repoId, generation)) {
|
||||
return false;
|
||||
}
|
||||
await refreshDetail(repoId, generation);
|
||||
return true;
|
||||
} catch (err) {
|
||||
if (isCurrentRepoRequest(selectedRef.current, requestGenerationRef.current, repoId, generation)) {
|
||||
setMutationError(err && err.message ? err.message : String(err));
|
||||
}
|
||||
return false;
|
||||
} finally {
|
||||
mutationActiveRef.current = false;
|
||||
setMutating(false);
|
||||
}
|
||||
},
|
||||
[refreshDetail],
|
||||
);
|
||||
|
||||
/**
|
||||
* Generate a commit-message suggestion from the staged diff (AI). Empty
|
||||
* staged diff / model-unavailable degrade to a notice, never an error.
|
||||
*/
|
||||
const generateMessage = useCallback(async () => {
|
||||
const repoId = selectedRef.current;
|
||||
if (!repoId) return;
|
||||
const generation = requestGenerationRef.current;
|
||||
setGeneratingMessage(true);
|
||||
setCommitNotice(null);
|
||||
try {
|
||||
const stagedPaths = (status && status.staged || []).map((e) => e.path);
|
||||
const data = stagedPaths.length > 0 && !status?.truncated
|
||||
? await gitCommitMessageSelected(repoId, stagedPaths)
|
||||
: await gitCommitMessage(repoId);
|
||||
if (!isCurrentRepoRequest(selectedRef.current, requestGenerationRef.current, repoId, generation)) return;
|
||||
const result = normalizeCommitMessage(data);
|
||||
if (hasCommitSuggestion(result)) {
|
||||
setCommitMessage(result.message);
|
||||
setCommitNotice(result.notice || null);
|
||||
} else {
|
||||
setCommitNotice(result.notice || "Nothing staged to generate a message from.");
|
||||
}
|
||||
} catch (err) {
|
||||
if (!isCurrentRepoRequest(selectedRef.current, requestGenerationRef.current, repoId, generation)) return;
|
||||
setCommitNotice(err && err.message ? err.message : String(err));
|
||||
} finally {
|
||||
if (isCurrentRepoRequest(selectedRef.current, requestGenerationRef.current, repoId, generation)) {
|
||||
setGeneratingMessage(false);
|
||||
}
|
||||
}
|
||||
}, [status]);
|
||||
|
||||
/**
|
||||
* Stash-checkout: switch branches, auto-stashing a dirty tree first. No
|
||||
* confirmation is needed because a stash is recoverable (git stash pop).
|
||||
* On success, surface the stash message so the user can pop it later.
|
||||
*/
|
||||
const doStashCheckout = useCallback(async () => {
|
||||
const ref = branchRef.trim();
|
||||
const repoId = selectedRef.current;
|
||||
const generation = requestGenerationRef.current;
|
||||
if (!ref || !repoId || mutationActiveRef.current) return;
|
||||
mutationActiveRef.current = true;
|
||||
setMutationError(null);
|
||||
setCommitNotice(null);
|
||||
setMutating(true);
|
||||
try {
|
||||
const data = await gitStashCheckout(repoId, ref, {
|
||||
newBranch: newBranch.trim(),
|
||||
track: false,
|
||||
});
|
||||
if (!isCurrentRepoRequest(selectedRef.current, requestGenerationRef.current, repoId, generation)) return;
|
||||
const result = normalizeStashCheckout(data);
|
||||
if (result.stashed) {
|
||||
setCommitNotice(
|
||||
`Stashed changes on ${ref} as “${result.stashMessage}”. Use “git stash pop” to restore them.`,
|
||||
);
|
||||
}
|
||||
await refreshDetail(repoId, generation);
|
||||
} catch (err) {
|
||||
if (isCurrentRepoRequest(selectedRef.current, requestGenerationRef.current, repoId, generation)) {
|
||||
setMutationError(err && err.message ? err.message : String(err));
|
||||
}
|
||||
} finally {
|
||||
mutationActiveRef.current = false;
|
||||
setMutating(false);
|
||||
setBranchRef("");
|
||||
setNewBranch("");
|
||||
}
|
||||
}, [branchRef, newBranch, refreshDetail]);
|
||||
|
||||
/**
|
||||
* Destructive ops (discard, push, dirty-checkout) gate on a per-use
|
||||
* confirmation echoed back to the server. Matches the dashboard's existing
|
||||
* confirm-before-destructive pattern (see RelayManagement onRevoke) and the
|
||||
* plugin's confirmation-string mechanics.
|
||||
*/
|
||||
const requestMutation = useCallback((op, opts) => {
|
||||
if (requiresConfirmation(op)) {
|
||||
const description =
|
||||
op === "discard"
|
||||
? "Discard local changes? This cannot be undone."
|
||||
: op === "push"
|
||||
? "Push local commits to the remote repository?"
|
||||
: "Working tree has uncommitted changes. Switch branches anyway?";
|
||||
if (!window.confirm(description)) return;
|
||||
const token = confirmationFor(op);
|
||||
if (op === "discard") applyMutation("discard", opts?.paths, { confirmation: token, deleteUntracked: opts?.deleteUntracked });
|
||||
else if (op === "push") applyMutation("push", [], { confirmation: token, remote: opts?.remote, branch: opts?.branch });
|
||||
else if (op === "dirty-checkout") applyMutation("dirty-checkout", [], { ...opts, confirmation: token });
|
||||
return;
|
||||
}
|
||||
applyMutation(op, opts?.paths, opts);
|
||||
}, [applyMutation]);
|
||||
|
||||
const doCommit = useCallback(async () => {
|
||||
const message = commitMessage.trim();
|
||||
if (!message) {
|
||||
setMutationError("Commit message must not be empty.");
|
||||
return;
|
||||
}
|
||||
const succeeded = await applyMutation("commit", [], { message });
|
||||
if (!succeeded) return;
|
||||
setCommitMessage("");
|
||||
// Push-after-commit: when the toggle is ON, immediately start the existing
|
||||
// push confirmation flow. Confirmation is still required (never bypassed);
|
||||
// the toggle only auto-starts it after a successful commit.
|
||||
if (shouldOfferPushAfterCommit(succeeded, pushAfterCommit)) {
|
||||
requestMutation("push", {});
|
||||
}
|
||||
}, [commitMessage, status, applyMutation, pushAfterCommit, requestMutation]);
|
||||
|
||||
const doCheckout = useCallback(async () => {
|
||||
const ref = branchRef.trim();
|
||||
if (!ref) return;
|
||||
const dirty = status && (status.counts.modified + status.counts.staged + status.counts.untracked) > 0;
|
||||
const opts = { ref, newBranch: newBranch.trim(), track: false };
|
||||
if (dirty && !opts.newBranch) {
|
||||
requestMutation("dirty-checkout", opts);
|
||||
setBranchRef("");
|
||||
setNewBranch("");
|
||||
return;
|
||||
}
|
||||
await applyMutation("checkout", [], opts);
|
||||
setBranchRef("");
|
||||
setNewBranch("");
|
||||
}, [branchRef, newBranch, status, applyMutation, requestMutation]);
|
||||
|
||||
if (loading && repos === null) {
|
||||
return <div className="text-sm text-muted-foreground">Loading repositories…</div>;
|
||||
}
|
||||
|
||||
if (error) {
|
||||
return (
|
||||
<Alert variant="destructive">
|
||||
<AlertTitle>Git state unavailable</AlertTitle>
|
||||
<AlertDescription>
|
||||
<pre className="whitespace-pre-wrap text-xs">{error}</pre>
|
||||
<Button className="mt-2" size="sm" variant="outline" onClick={loadRepos}>
|
||||
Retry
|
||||
</Button>
|
||||
</AlertDescription>
|
||||
</Alert>
|
||||
);
|
||||
}
|
||||
|
||||
const list = repos || [];
|
||||
|
||||
return (
|
||||
<div className="space-y-4">
|
||||
<Card>
|
||||
<CardHeader>
|
||||
<CardTitle>Repositories</CardTitle>
|
||||
<CardDescription>
|
||||
Repositories scanned from the configured Git base path.
|
||||
</CardDescription>
|
||||
</CardHeader>
|
||||
<CardContent>
|
||||
{notice ? (
|
||||
<div className="mb-3 rounded-md border border-amber-500/40 bg-amber-500/10 p-2 text-xs text-amber-600">
|
||||
{notice}
|
||||
</div>
|
||||
) : null}
|
||||
{list.length === 0 ? (
|
||||
<div className="text-sm text-muted-foreground">
|
||||
No repositories found.
|
||||
</div>
|
||||
) : (
|
||||
<div className="flex flex-wrap gap-2">
|
||||
{list.map((repo) => (
|
||||
<Button
|
||||
key={repo.id}
|
||||
size="sm"
|
||||
variant={selected === repo.id ? "default" : "outline"}
|
||||
onClick={() => selectRepo(repo.id)}
|
||||
>
|
||||
{repo.name}
|
||||
{repo.dirty ? " •" : ""}
|
||||
</Button>
|
||||
))}
|
||||
</div>
|
||||
)}
|
||||
</CardContent>
|
||||
</Card>
|
||||
|
||||
{selected ? (
|
||||
<Card>
|
||||
<CardHeader>
|
||||
<CardTitle>{selected}</CardTitle>
|
||||
<CardDescription>
|
||||
Working tree and branches. Tap a changed file to view its diff or
|
||||
content.
|
||||
</CardDescription>
|
||||
</CardHeader>
|
||||
<CardContent className="space-y-4">
|
||||
{mutationError ? (
|
||||
<Alert variant="destructive">
|
||||
<AlertTitle>Git mutation failed</AlertTitle>
|
||||
<AlertDescription>
|
||||
<pre className="whitespace-pre-wrap text-xs">{mutationError}</pre>
|
||||
</AlertDescription>
|
||||
</Alert>
|
||||
) : null}
|
||||
<StatusRow status={status} />
|
||||
<BranchesRow branches={branches} />
|
||||
<WriteControls
|
||||
status={status}
|
||||
branches={branches}
|
||||
selected={selected}
|
||||
commitMessage={commitMessage}
|
||||
onCommitMessageChange={setCommitMessage}
|
||||
generatingMessage={generatingMessage}
|
||||
onGenerateMessage={generateMessage}
|
||||
commitNotice={commitNotice}
|
||||
newBranch={newBranch}
|
||||
onNewBranchChange={setNewBranch}
|
||||
branchRef={branchRef}
|
||||
onBranchRefChange={setBranchRef}
|
||||
mutating={mutating}
|
||||
pushAfterCommit={pushAfterCommit}
|
||||
onPushAfterCommitChange={setPushAfterCommit}
|
||||
onStageAll={() => requestMutation("stage", { paths: (status && status.modified || []).map((e) => e.path) })}
|
||||
onUnstageAll={() => requestMutation("unstage", { paths: (status && status.staged || []).map((e) => e.path) })}
|
||||
onDiscardAll={() => requestMutation("discard", { paths: (status && status.staged || []).map((e) => e.path), deleteUntracked: false })}
|
||||
onStage={(path) => requestMutation("stage", { paths: [path] })}
|
||||
onUnstage={(path) => requestMutation("unstage", { paths: [path] })}
|
||||
onDiscard={(path) => requestMutation("discard", { paths: [path], deleteUntracked: false })}
|
||||
onCommit={doCommit}
|
||||
onFetch={() => requestMutation("fetch", {})}
|
||||
onPull={() => requestMutation("pull", {})}
|
||||
onPush={() => requestMutation("push", {})}
|
||||
onCheckout={doCheckout}
|
||||
onStashCheckout={doStashCheckout}
|
||||
/>
|
||||
|
||||
{status && (status.staged || []).length > 0 ? (
|
||||
<div>
|
||||
<div className="text-xs font-medium uppercase tracking-wide text-muted-foreground">
|
||||
Staged diffs
|
||||
</div>
|
||||
<ul className="mt-1 space-y-0.5">
|
||||
{(status.staged || []).map((item) => (
|
||||
<li key={`sd-${item.path}`}>
|
||||
<button
|
||||
type="button"
|
||||
className="font-mono text-xs text-primary underline"
|
||||
onClick={() => showDiff(item.path, "staged")}
|
||||
>
|
||||
{item.path}
|
||||
</button>
|
||||
</li>
|
||||
))}
|
||||
</ul>
|
||||
</div>
|
||||
) : null}
|
||||
|
||||
{status && (status.modified || []).length > 0 ? (
|
||||
<div>
|
||||
<div className="text-xs font-medium uppercase tracking-wide text-muted-foreground">
|
||||
Unstaged diffs
|
||||
</div>
|
||||
<ul className="mt-1 space-y-0.5">
|
||||
{(status.modified || []).map((item) => (
|
||||
<li key={`ud-${item.path}`}>
|
||||
<button
|
||||
type="button"
|
||||
className="font-mono text-xs text-primary underline"
|
||||
onClick={() => showDiff(item.path, "unstaged")}
|
||||
>
|
||||
{item.path}
|
||||
</button>
|
||||
</li>
|
||||
))}
|
||||
</ul>
|
||||
</div>
|
||||
) : null}
|
||||
|
||||
{status && (status.untracked || []).length > 0 ? (
|
||||
<div>
|
||||
<div className="text-xs font-medium uppercase tracking-wide text-muted-foreground">
|
||||
Untracked files
|
||||
</div>
|
||||
<ul className="mt-1 space-y-0.5">
|
||||
{(status.untracked || []).map((item) => (
|
||||
<li key={`uf-${item.path}`}>
|
||||
<button
|
||||
type="button"
|
||||
className="font-mono text-xs text-primary underline"
|
||||
onClick={() => showFile(item.path)}
|
||||
>
|
||||
{item.path}
|
||||
</button>
|
||||
</li>
|
||||
))}
|
||||
</ul>
|
||||
</div>
|
||||
) : null}
|
||||
|
||||
{diff ? (
|
||||
<div>
|
||||
<div className="flex items-center justify-between">
|
||||
<div className="text-xs font-medium uppercase tracking-wide text-muted-foreground">
|
||||
Diff — {diff.path} ({diff.kind})
|
||||
</div>
|
||||
<TruncationNotice truncated={diff.truncated} />
|
||||
</div>
|
||||
<pre className="mt-1 max-h-96 overflow-auto whitespace-pre-wrap rounded-md bg-muted/40 p-2 font-mono text-xs">
|
||||
{diff.diff || "(no changes)"}
|
||||
</pre>
|
||||
</div>
|
||||
) : null}
|
||||
|
||||
{file ? (
|
||||
<div>
|
||||
<div className="flex items-center justify-between">
|
||||
<div className="text-xs font-medium uppercase tracking-wide text-muted-foreground">
|
||||
File — {file.path}
|
||||
</div>
|
||||
<TruncationNotice truncated={file.truncated} />
|
||||
</div>
|
||||
<pre className="mt-1 max-h-96 overflow-auto whitespace-pre-wrap rounded-md bg-muted/40 p-2 font-mono text-xs">
|
||||
{file.content}
|
||||
</pre>
|
||||
</div>
|
||||
) : null}
|
||||
</CardContent>
|
||||
</Card>
|
||||
) : null}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -13,6 +13,7 @@ import {
|
||||
} from "../lib/api.js";
|
||||
import { relativeTime, ttlCountdown, uptime, shortToken } from "../lib/formatters.js";
|
||||
import { formatSessionExpiry } from "../lib/session-expiry.mjs";
|
||||
import { supervisedSessionDisplay } from "../lib/supervised-session.mjs";
|
||||
import PairDialog from "../components/PairDialog.jsx";
|
||||
import {
|
||||
Alert,
|
||||
@@ -596,6 +597,7 @@ export default function RelayManagement({ autoRefresh }) {
|
||||
|
||||
const ov = overview || {};
|
||||
const list = sessions || [];
|
||||
const hasSupervisedSession = list.some((session) => supervisedSessionDisplay(session));
|
||||
|
||||
return (
|
||||
<div className="space-y-4">
|
||||
@@ -643,6 +645,13 @@ export default function RelayManagement({ autoRefresh }) {
|
||||
</Button>
|
||||
</CardHeader>
|
||||
<CardContent>
|
||||
{hasSupervisedSession ? (
|
||||
<div className="mb-3 rounded-md border border-border bg-muted/20 px-3 py-2 text-xs text-muted-foreground">
|
||||
Supervised mode is reported and enforced by the Android client, not by Relay. Relay
|
||||
shows the client's reported settings here so a paired device can be identified and
|
||||
revoked.
|
||||
</div>
|
||||
) : null}
|
||||
{!autoRefresh ? (
|
||||
<div className="mb-3">
|
||||
<Button size="sm" variant="outline" onClick={load}>
|
||||
@@ -685,13 +694,21 @@ export default function RelayManagement({ autoRefresh }) {
|
||||
const grants = extractGrants(s);
|
||||
const type = classifySession(s, grants);
|
||||
const transport = sessionTransport(s);
|
||||
const supervised = supervisedSessionDisplay(s);
|
||||
const deviceDetail = [s.device_model, s.device_platform]
|
||||
.filter((value) => value && value !== "unknown")
|
||||
.join(" · ");
|
||||
return (
|
||||
<TableRow key={tokenPrefix || idx}>
|
||||
<TableCell className="font-medium">
|
||||
<div>{label}</div>
|
||||
<div className="flex flex-wrap items-center gap-2">
|
||||
<span>{label}</span>
|
||||
{supervised ? (
|
||||
<Badge variant="secondary" className="w-fit text-xs">
|
||||
Supervised
|
||||
</Badge>
|
||||
) : null}
|
||||
</div>
|
||||
<div className="font-mono text-xs font-normal text-muted-foreground">
|
||||
{tokenPrefix ? shortToken(tokenPrefix, 12) : "no token prefix"}
|
||||
</div>
|
||||
@@ -700,6 +717,22 @@ export default function RelayManagement({ autoRefresh }) {
|
||||
{deviceDetail}
|
||||
</div>
|
||||
) : null}
|
||||
{supervised && supervised.profileLabel ? (
|
||||
<div className="text-xs font-normal text-muted-foreground">
|
||||
Pinned profile: {supervised.profileLabel}
|
||||
</div>
|
||||
) : null}
|
||||
{supervised && supervised.visibleCapabilities.length > 0 ? (
|
||||
<div
|
||||
className="max-w-xs text-xs font-normal text-muted-foreground"
|
||||
title="Capabilities reported by the Android client"
|
||||
>
|
||||
Client allows: {supervised.visibleCapabilities.join(" · ")}
|
||||
{supervised.remainingCapabilityCount > 0
|
||||
? ` · +${supervised.remainingCapabilityCount} more`
|
||||
: ""}
|
||||
</div>
|
||||
) : null}
|
||||
</TableCell>
|
||||
<TableCell>
|
||||
<div className="flex flex-col gap-1">
|
||||
|
||||
@@ -0,0 +1,126 @@
|
||||
import test from "node:test";
|
||||
import assert from "node:assert/strict";
|
||||
import { readFileSync } from "node:fs";
|
||||
|
||||
import {
|
||||
normalizeMutationResult,
|
||||
confirmationFor,
|
||||
requiresConfirmation,
|
||||
CONFIRMATIONS,
|
||||
isCurrentRepoRequest,
|
||||
shouldOfferPushAfterCommit,
|
||||
} from "../src/lib/git-state.mjs";
|
||||
|
||||
test("normalizeMutationResult maps head/status/branches safely", () => {
|
||||
const r = normalizeMutationResult({
|
||||
head: "abc123",
|
||||
status: { counts: { staged: 1 } },
|
||||
branches: [{ name: "main" }],
|
||||
});
|
||||
assert.equal(r.head, "abc123");
|
||||
assert.equal(r.status.counts.staged, 1);
|
||||
assert.equal(r.branches.length, 1);
|
||||
});
|
||||
|
||||
test("normalizeMutationResult tolerates missing fields", () => {
|
||||
const r = normalizeMutationResult(null);
|
||||
assert.equal(r.head, "");
|
||||
assert.deepEqual(r.status.counts, { staged: 0, modified: 0, untracked: 0 });
|
||||
assert.deepEqual(r.branches, []);
|
||||
});
|
||||
|
||||
test("CONFIRMATIONS holds the fixed confirmation tokens", () => {
|
||||
assert.equal(CONFIRMATIONS.discard, "discard");
|
||||
assert.equal(CONFIRMATIONS.push, "push");
|
||||
assert.equal(CONFIRMATIONS.dirtyCheckout, "checkout-dirty");
|
||||
});
|
||||
|
||||
test("confirmationFor returns the token only for destructive ops", () => {
|
||||
assert.equal(confirmationFor("discard"), CONFIRMATIONS.discard);
|
||||
assert.equal(confirmationFor("push"), CONFIRMATIONS.push);
|
||||
assert.equal(confirmationFor("dirty-checkout"), CONFIRMATIONS.dirtyCheckout);
|
||||
assert.equal(confirmationFor("commit"), null);
|
||||
assert.equal(confirmationFor("stage"), null);
|
||||
});
|
||||
|
||||
test("requiresConfirmation gates only destructive ops", () => {
|
||||
assert.equal(requiresConfirmation("discard"), true);
|
||||
assert.equal(requiresConfirmation("push"), true);
|
||||
assert.equal(requiresConfirmation("dirty-checkout"), true);
|
||||
assert.equal(requiresConfirmation("stage"), false);
|
||||
assert.equal(requiresConfirmation("commit"), false);
|
||||
assert.equal(requiresConfirmation("fetch"), false);
|
||||
});
|
||||
|
||||
test("repository request ownership rejects stale repo or generation", () => {
|
||||
assert.equal(isCurrentRepoRequest("a", 2, "a", 2), true);
|
||||
assert.equal(isCurrentRepoRequest("b", 2, "a", 2), false);
|
||||
assert.equal(isCurrentRepoRequest("a", 3, "a", 2), false);
|
||||
});
|
||||
|
||||
test("push-after-commit requires the exact commit to succeed", () => {
|
||||
assert.equal(shouldOfferPushAfterCommit(true, true), true);
|
||||
assert.equal(shouldOfferPushAfterCommit(false, true), false);
|
||||
assert.equal(shouldOfferPushAfterCommit(true, false), false);
|
||||
});
|
||||
|
||||
test("GitState commits the complete index and dispatches clean checkout", () => {
|
||||
const source = readFileSync(new URL("../src/tabs/GitState.jsx", import.meta.url), "utf8");
|
||||
assert.match(source, /applyMutation\("commit", \[\], \{ message \}\)/);
|
||||
assert.doesNotMatch(source, /applyMutation\("commitSelected"/);
|
||||
assert.match(source, /op === "checkout" \|\| op === "dirty-checkout"/);
|
||||
});
|
||||
|
||||
test("GitState bulk actions dispatch one bounded path array", () => {
|
||||
const source = readFileSync(new URL("../src/tabs/GitState.jsx", import.meta.url), "utf8");
|
||||
assert.match(source, /onClick=\{onStageAll\}/);
|
||||
assert.match(source, /onClick=\{onUnstageAll\}/);
|
||||
assert.match(source, /onClick=\{onDiscardAll\}/);
|
||||
assert.doesNotMatch(source, /forEach\(onStage\)|forEach\(onUnstage\)|forEach\(onDiscard\)/);
|
||||
});
|
||||
|
||||
// ── Phase 3 extras ─────────────────────────────────────────────────────────
|
||||
|
||||
import {
|
||||
normalizeCommitMessage,
|
||||
hasCommitSuggestion,
|
||||
normalizeStashCheckout,
|
||||
} from "../src/lib/git-state.mjs";
|
||||
|
||||
test("normalizeCommitMessage maps message/notice safely", () => {
|
||||
const r = normalizeCommitMessage({ message: "feat: add x", notice: "" });
|
||||
assert.equal(r.message, "feat: add x");
|
||||
assert.equal(r.notice, "");
|
||||
});
|
||||
|
||||
test("normalizeCommitMessage tolerates missing/empty fields", () => {
|
||||
assert.deepEqual(normalizeCommitMessage(null), { message: "", notice: "" });
|
||||
assert.deepEqual(normalizeCommitMessage({}), { message: "", notice: "" });
|
||||
});
|
||||
|
||||
test("hasCommitSuggestion is false for empty/whitespace messages", () => {
|
||||
assert.equal(hasCommitSuggestion({ message: "feat: add x" }), true);
|
||||
assert.equal(hasCommitSuggestion({ message: "" }), false);
|
||||
assert.equal(hasCommitSuggestion({ message: " " }), false);
|
||||
assert.equal(hasCommitSuggestion(null), false);
|
||||
});
|
||||
|
||||
test("normalizeStashCheckout carries stashed + stash_message", () => {
|
||||
const r = normalizeStashCheckout({
|
||||
head: "abc",
|
||||
stashed: true,
|
||||
stash_message: "git-state: feature",
|
||||
status: { counts: { staged: 0 } },
|
||||
branches: [],
|
||||
});
|
||||
assert.equal(r.head, "abc");
|
||||
assert.equal(r.stashed, true);
|
||||
assert.equal(r.stashMessage, "git-state: feature");
|
||||
assert.equal(r.status.counts.staged, 0);
|
||||
});
|
||||
|
||||
test("normalizeStashCheckout defaults stashed false when absent", () => {
|
||||
const r = normalizeStashCheckout({});
|
||||
assert.equal(r.stashed, false);
|
||||
assert.equal(r.stashMessage, "");
|
||||
});
|
||||
@@ -0,0 +1,60 @@
|
||||
import test from "node:test";
|
||||
import assert from "node:assert/strict";
|
||||
|
||||
import {
|
||||
normalizeRepos,
|
||||
normalizeStatus,
|
||||
normalizeBranches,
|
||||
branchLabel,
|
||||
isTruncated,
|
||||
} from "../src/lib/git-state.mjs";
|
||||
|
||||
test("normalizeRepos accepts object and bare-array shapes", () => {
|
||||
assert.deepEqual(
|
||||
normalizeRepos({ repos: [{ id: "a", name: "A" }] }),
|
||||
[{ id: "a", name: "A" }],
|
||||
);
|
||||
assert.deepEqual(normalizeRepos([{ id: "b", name: "B" }]), [
|
||||
{ id: "b", name: "B" },
|
||||
]);
|
||||
assert.deepEqual(normalizeRepos(null), []);
|
||||
assert.deepEqual(normalizeRepos({ repos: [{ id: 1 }, null] }), []);
|
||||
});
|
||||
|
||||
test("normalizeStatus fills missing groups and preserves truncation", () => {
|
||||
const status = normalizeStatus({
|
||||
counts: { staged: 1, modified: 2, untracked: 3 },
|
||||
staged: [{ path: "a" }],
|
||||
truncated: true,
|
||||
});
|
||||
assert.equal(status.counts.staged, 1);
|
||||
assert.equal(status.counts.modified, 2);
|
||||
assert.equal(status.counts.untracked, 3);
|
||||
assert.deepEqual(status.staged, [{ path: "a" }]);
|
||||
assert.deepEqual(status.modified, []);
|
||||
assert.deepEqual(status.untracked, []);
|
||||
assert.equal(status.truncated, true);
|
||||
assert.equal(isTruncated(status), true);
|
||||
assert.equal(isTruncated(normalizeStatus({})), false);
|
||||
});
|
||||
|
||||
test("normalizeBranches accepts object and bare-array shapes", () => {
|
||||
assert.deepEqual(normalizeBranches({ branches: [{ name: "main" }] }), [
|
||||
{ name: "main" },
|
||||
]);
|
||||
assert.deepEqual(normalizeBranches([{ name: "dev" }]), [{ name: "dev" }]);
|
||||
assert.deepEqual(normalizeBranches(null), []);
|
||||
});
|
||||
|
||||
test("branchLabel renders upstream and ahead/behind", () => {
|
||||
assert.equal(branchLabel({ name: "main" }), "main");
|
||||
assert.equal(
|
||||
branchLabel({ name: "main", upstream: "origin/main" }),
|
||||
"main → origin/main",
|
||||
);
|
||||
assert.equal(
|
||||
branchLabel({ name: "feature", upstream: "origin/feature", ahead: 1, behind: 2 }),
|
||||
"feature → origin/feature (ahead 1, behind 2)",
|
||||
);
|
||||
assert.equal(branchLabel(null), "");
|
||||
});
|
||||
@@ -0,0 +1,81 @@
|
||||
import test from "node:test";
|
||||
import assert from "node:assert/strict";
|
||||
|
||||
import { supervisedSessionDisplay } from "../src/lib/supervised-session.mjs";
|
||||
|
||||
test("leaves legacy and inactive sessions unchanged", () => {
|
||||
assert.equal(supervisedSessionDisplay({ device_name: "Pixel" }), null);
|
||||
assert.equal(supervisedSessionDisplay({ supervised_mode: { active: false } }), null);
|
||||
assert.equal(supervisedSessionDisplay({ supervised_mode: { active: "true" } }), null);
|
||||
assert.equal(
|
||||
supervisedSessionDisplay({ supervised_mode: { active: true, enforcement_owner: "relay" } }),
|
||||
null,
|
||||
);
|
||||
assert.equal(supervisedSessionDisplay({ supervised_mode: { active: true } }), null);
|
||||
assert.equal(supervisedSessionDisplay({ supervised_mode: [] }), null);
|
||||
});
|
||||
|
||||
test("formats active client-reported metadata", () => {
|
||||
assert.deepEqual(
|
||||
supervisedSessionDisplay({
|
||||
supervised_mode: {
|
||||
active: true,
|
||||
profile_label: " Willow ",
|
||||
capabilities: ["attachments", "voice", "generated_images", "new_chat"],
|
||||
enforcement_owner: "android_client",
|
||||
},
|
||||
}),
|
||||
{
|
||||
profileLabel: "Willow",
|
||||
visibleCapabilities: ["Attachments", "Voice", "Generated images", "New chat"],
|
||||
remainingCapabilityCount: 0,
|
||||
},
|
||||
);
|
||||
});
|
||||
|
||||
test("bounds, sanitizes, and deduplicates untrusted display values", () => {
|
||||
const result = supervisedSessionDisplay({
|
||||
supervised_mode: {
|
||||
active: true,
|
||||
enforcement_owner: "android_client",
|
||||
profile_label: `Willow\u0000 ${"x".repeat(100)}`,
|
||||
capabilities: [
|
||||
"voice",
|
||||
"VOICE",
|
||||
"unknown_capability",
|
||||
"text_chat",
|
||||
"generated_images",
|
||||
"cancel",
|
||||
"steer",
|
||||
"attachments",
|
||||
"new_chat",
|
||||
"share_images",
|
||||
"copy",
|
||||
"retry",
|
||||
"quote_reply",
|
||||
"timestamps",
|
||||
],
|
||||
},
|
||||
});
|
||||
|
||||
assert.equal(result.profileLabel.length, 80);
|
||||
assert.deepEqual(
|
||||
result.visibleCapabilities,
|
||||
["Voice", "Text chat", "Generated images", "Cancel"],
|
||||
);
|
||||
assert.equal(result.remainingCapabilityCount, 6);
|
||||
});
|
||||
|
||||
test("tolerates malformed optional members", () => {
|
||||
assert.deepEqual(
|
||||
supervisedSessionDisplay({
|
||||
supervised_mode: {
|
||||
active: true,
|
||||
enforcement_owner: "android_client",
|
||||
profile_label: 42,
|
||||
capabilities: "voice",
|
||||
},
|
||||
}),
|
||||
{ profileLabel: null, visibleCapabilities: [], remainingCapabilityCount: 0 },
|
||||
);
|
||||
});
|
||||
@@ -0,0 +1,559 @@
|
||||
"""Tests for the write (POST) Git State endpoints in plugin/dashboard/git_api.py.
|
||||
|
||||
Write endpoints are POST and rely on the plugin's ``plugin.api.write`` grant
|
||||
which the app enforces client-side (see PluginsViewModel.invokeAction — the
|
||||
precedent gate). Server-side, destructive operations are additionally enforced
|
||||
by a required confirmation string; missing/wrong confirmation maps to 403 and
|
||||
dirty/conflict trees map to 409, so the UI can render a readable message.
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import os
|
||||
import subprocess
|
||||
import tempfile
|
||||
import unittest
|
||||
from pathlib import Path
|
||||
from unittest.mock import AsyncMock, patch
|
||||
|
||||
from fastapi import FastAPI
|
||||
from fastapi.testclient import TestClient
|
||||
|
||||
from plugin.dashboard import git_api
|
||||
from plugin import git_state
|
||||
|
||||
|
||||
def _run(cmd: list[str], cwd: Path) -> str:
|
||||
return subprocess.run(
|
||||
cmd, cwd=cwd, capture_output=True, text=True, check=True
|
||||
).stdout.strip()
|
||||
|
||||
|
||||
def _git(repo: Path, *args: str) -> str:
|
||||
return _run(["git", "-C", str(repo), *args], repo)
|
||||
|
||||
|
||||
def _init_repo(root: Path, name: str) -> Path:
|
||||
repo = root / name
|
||||
repo.mkdir(parents=True)
|
||||
_run(["git", "init", "-q", "-b", "main"], repo)
|
||||
_run(["git", "config", "user.email", "test@example.com"], repo)
|
||||
_run(["git", "config", "user.name", "Test User"], repo)
|
||||
(repo / "README.md").write_text("# Hello\n", encoding="utf-8")
|
||||
_git(repo, "add", "README.md")
|
||||
_git(repo, "commit", "-q", "-m", "initial commit")
|
||||
return repo
|
||||
|
||||
|
||||
def _init_bare_remote(root: Path, name: str) -> Path:
|
||||
remote = root / name
|
||||
remote.mkdir(parents=True, exist_ok=True)
|
||||
_run(["git", "init", "-q", "--bare", "-b", "main"], remote)
|
||||
return remote
|
||||
|
||||
|
||||
class GitWriteApiTests(unittest.TestCase):
|
||||
def setUp(self) -> None:
|
||||
self.temp = tempfile.TemporaryDirectory()
|
||||
self.addCleanup(self.temp.cleanup)
|
||||
self.base = Path(self.temp.name) / "projects"
|
||||
self.base.mkdir(parents=True)
|
||||
self.repo = _init_repo(self.base, "alpha")
|
||||
self.env = patch.dict(
|
||||
os.environ,
|
||||
{"HERMES_HOME": self.temp.name, "GIT_STATE_BASE_PATH": str(self.base)},
|
||||
)
|
||||
self.env.start()
|
||||
self.addCleanup(self.env.stop)
|
||||
app = FastAPI()
|
||||
app.include_router(git_api.router)
|
||||
self.client = TestClient(app)
|
||||
|
||||
def _stage(self, path: str) -> None:
|
||||
(self.repo / path).write_text("x", encoding="utf-8")
|
||||
self.client.post("/git/stage", json={"repo": "alpha", "paths": [path]})
|
||||
|
||||
def test_stage_returns_fresh_status(self) -> None:
|
||||
(self.repo / "new.txt").write_text("x", encoding="utf-8")
|
||||
response = self.client.post(
|
||||
"/git/stage", json={"repo": "alpha", "paths": ["new.txt"]}
|
||||
)
|
||||
self.assertEqual(200, response.status_code, response.text)
|
||||
body = response.json()
|
||||
self.assertEqual(1, body["status"]["counts"]["staged"])
|
||||
|
||||
def test_unknown_repo_rejected(self) -> None:
|
||||
response = self.client.post(
|
||||
"/git/stage", json={"repo": "bogus", "paths": ["x"]}
|
||||
)
|
||||
self.assertEqual(400, response.status_code, response.text)
|
||||
|
||||
def test_remote_operations_reject_urls_options_and_wrong_types(self) -> None:
|
||||
for path, payload in (
|
||||
("/git/fetch", {"remote": "https://example.invalid/repo.git"}),
|
||||
("/git/fetch", {"remote": "--all"}),
|
||||
("/git/pull", {"remote": ["origin"], "branch": "main"}),
|
||||
("/git/push", {"remote": "origin", "branch": "--mirror", "confirmation": "push"}),
|
||||
):
|
||||
with self.subTest(path=path, payload=payload):
|
||||
response = self.client.post(path, json={"repo": "alpha", **payload})
|
||||
self.assertEqual(400, response.status_code, response.text)
|
||||
|
||||
def test_commit_creates_commit(self) -> None:
|
||||
self._stage("feature.txt")
|
||||
before = _git(self.repo, "rev-parse", "HEAD")
|
||||
response = self.client.post(
|
||||
"/git/commit", json={"repo": "alpha", "message": "add feature"}
|
||||
)
|
||||
self.assertEqual(200, response.status_code, response.text)
|
||||
after = _git(self.repo, "rev-parse", "HEAD")
|
||||
self.assertNotEqual(before, after)
|
||||
|
||||
def test_commit_empty_message_rejected(self) -> None:
|
||||
self._stage("feature.txt")
|
||||
response = self.client.post(
|
||||
"/git/commit", json={"repo": "alpha", "message": " "}
|
||||
)
|
||||
self.assertEqual(400, response.status_code, response.text)
|
||||
|
||||
def test_discard_without_confirmation_is_403(self) -> None:
|
||||
(self.repo / "tracked.txt").write_text("v1", encoding="utf-8")
|
||||
_git(self.repo, "add", "tracked.txt")
|
||||
_git(self.repo, "commit", "-q", "-m", "add tracked")
|
||||
(self.repo / "tracked.txt").write_text("v2", encoding="utf-8")
|
||||
response = self.client.post(
|
||||
"/git/discard", json={"repo": "alpha", "paths": ["tracked.txt"]}
|
||||
)
|
||||
self.assertEqual(403, response.status_code, response.text)
|
||||
|
||||
def test_discard_wrong_confirmation_is_403(self) -> None:
|
||||
(self.repo / "tracked.txt").write_text("v2", encoding="utf-8")
|
||||
response = self.client.post(
|
||||
"/git/discard",
|
||||
json={
|
||||
"repo": "alpha",
|
||||
"paths": ["tracked.txt"],
|
||||
"confirmation": "wrong",
|
||||
},
|
||||
)
|
||||
self.assertEqual(403, response.status_code, response.text)
|
||||
|
||||
def test_discard_with_confirmation_succeeds(self) -> None:
|
||||
(self.repo / "tracked.txt").write_text("v1", encoding="utf-8")
|
||||
_git(self.repo, "add", "tracked.txt")
|
||||
_git(self.repo, "commit", "-q", "-m", "add tracked")
|
||||
(self.repo / "tracked.txt").write_text("v2", encoding="utf-8")
|
||||
response = self.client.post(
|
||||
"/git/discard",
|
||||
json={
|
||||
"repo": "alpha",
|
||||
"paths": ["tracked.txt"],
|
||||
"confirmation": git_state.CONFIRM_DISCARD,
|
||||
},
|
||||
)
|
||||
self.assertEqual(200, response.status_code, response.text)
|
||||
self.assertEqual("v1", (self.repo / "tracked.txt").read_text(encoding="utf-8"))
|
||||
|
||||
def test_push_requires_confirmation(self) -> None:
|
||||
remote = _init_bare_remote(self.base, "origin-bare")
|
||||
_git(self.repo, "remote", "add", "origin", str(remote))
|
||||
_git(self.repo, "push", "-q", "origin", "main")
|
||||
self._stage("feature.txt")
|
||||
self.client.post("/git/commit", json={"repo": "alpha", "message": "f"})
|
||||
response = self.client.post(
|
||||
"/git/push", json={"repo": "alpha", "remote": "origin", "branch": "main"}
|
||||
)
|
||||
self.assertEqual(403, response.status_code, response.text)
|
||||
|
||||
def test_push_with_confirmation_succeeds(self) -> None:
|
||||
remote = _init_bare_remote(self.base, "remote-bare")
|
||||
_git(self.repo, "remote", "add", "origin", str(remote))
|
||||
_git(self.repo, "push", "-q", "origin", "main")
|
||||
self._stage("feature.txt")
|
||||
self.client.post("/git/commit", json={"repo": "alpha", "message": "f"})
|
||||
response = self.client.post(
|
||||
"/git/push",
|
||||
json={
|
||||
"repo": "alpha",
|
||||
"remote": "origin",
|
||||
"branch": "main",
|
||||
"confirmation": git_state.CONFIRM_PUSH,
|
||||
},
|
||||
)
|
||||
self.assertEqual(200, response.status_code, response.text)
|
||||
|
||||
def test_checkout_dirty_requires_confirmation(self) -> None:
|
||||
_git(self.repo, "checkout", "-q", "-b", "feature")
|
||||
_git(self.repo, "checkout", "-q", "main")
|
||||
(self.repo / "tracked.txt").write_text("dirty", encoding="utf-8")
|
||||
response = self.client.post(
|
||||
"/git/checkout", json={"repo": "alpha", "ref": "feature"}
|
||||
)
|
||||
# Missing confirmation on a dirty-tree switch is the destructive gate.
|
||||
self.assertEqual(403, response.status_code, response.text)
|
||||
# Confirming proceeds (git still refuses to overwrite conflicting work).
|
||||
response = self.client.post(
|
||||
"/git/checkout",
|
||||
json={
|
||||
"repo": "alpha",
|
||||
"ref": "feature",
|
||||
"confirmation": git_state.CONFIRM_DIRTY_CHECKOUT,
|
||||
},
|
||||
)
|
||||
self.assertEqual(200, response.status_code, response.text)
|
||||
|
||||
def test_pull_dirty_returns_409_never_clobbers(self) -> None:
|
||||
remote = _init_bare_remote(self.base, "remote-bare2")
|
||||
_git(self.repo, "remote", "add", "origin", str(remote))
|
||||
_git(self.repo, "push", "-q", "origin", "main")
|
||||
# Advance the remote from a descendant clone.
|
||||
other = self.base / "other"
|
||||
_run(["git", "clone", "-q", str(remote), str(other)], self.base)
|
||||
_git(other, "config", "user.email", "t@e.c")
|
||||
_git(other, "config", "user.name", "T")
|
||||
(other / "tracked.txt").write_text("remote", encoding="utf-8")
|
||||
_git(other, "add", "tracked.txt")
|
||||
_git(other, "commit", "-q", "-m", "remote")
|
||||
_git(other, "push", "-q", "origin", "main")
|
||||
(self.repo / "tracked.txt").write_text("local-uncommitted", encoding="utf-8")
|
||||
response = self.client.post(
|
||||
"/git/pull", json={"repo": "alpha", "remote": "origin", "branch": "main"}
|
||||
)
|
||||
self.assertEqual(409, response.status_code, response.text)
|
||||
self.assertEqual("local-uncommitted", (self.repo / "tracked.txt").read_text(encoding="utf-8"))
|
||||
|
||||
def test_fetch_returns_branches(self) -> None:
|
||||
remote = _init_bare_remote(self.base, "remote-bare3")
|
||||
_git(self.repo, "remote", "add", "origin", str(remote))
|
||||
_git(self.repo, "push", "-q", "origin", "main")
|
||||
response = self.client.post(
|
||||
"/git/fetch", json={"repo": "alpha", "remote": "origin"}
|
||||
)
|
||||
self.assertEqual(200, response.status_code, response.text)
|
||||
body = response.json()
|
||||
self.assertIn("branches", body)
|
||||
|
||||
def test_structured_error_taxonomy_is_readable(self) -> None:
|
||||
# Unknown repo → 400 with a readable detail, never a stack trace.
|
||||
response = self.client.post(
|
||||
"/git/stage", json={"repo": "missing", "paths": ["x"]}
|
||||
)
|
||||
self.assertEqual(400, response.status_code, response.text)
|
||||
self.assertNotIn("Traceback", response.text)
|
||||
self.assertNotIn("subprocess", response.text.lower())
|
||||
|
||||
|
||||
def test_push_wrong_confirmation_is_403(self) -> None:
|
||||
remote = _init_bare_remote(self.base, "push-wrong-bare")
|
||||
_git(self.repo, "remote", "add", "origin", str(remote))
|
||||
_git(self.repo, "push", "-q", "origin", "main")
|
||||
self._stage("feature.txt")
|
||||
self.client.post("/git/commit", json={"repo": "alpha", "message": "f"})
|
||||
response = self.client.post(
|
||||
"/git/push",
|
||||
json={
|
||||
"repo": "alpha",
|
||||
"remote": "origin",
|
||||
"branch": "main",
|
||||
"confirmation": "nope",
|
||||
},
|
||||
)
|
||||
self.assertEqual(403, response.status_code, response.text)
|
||||
|
||||
def test_push_with_confirmation_updates_remote_and_returns_branches(self) -> None:
|
||||
remote = _init_bare_remote(self.base, "push-ok-bare")
|
||||
_git(self.repo, "remote", "add", "origin", str(remote))
|
||||
_git(self.repo, "push", "-q", "origin", "main")
|
||||
_git(self.repo, "branch", "-q", "--set-upstream-to=origin/main", "main")
|
||||
self._stage("feature.txt")
|
||||
r = self.client.post("/git/commit", json={"repo": "alpha", "message": "f"})
|
||||
self.assertEqual(200, r.status_code, r.text)
|
||||
head = r.json()["head"]
|
||||
response = self.client.post(
|
||||
"/git/push",
|
||||
json={
|
||||
"repo": "alpha",
|
||||
"remote": "origin",
|
||||
"branch": "main",
|
||||
"confirmation": git_state.CONFIRM_PUSH,
|
||||
},
|
||||
)
|
||||
self.assertEqual(200, response.status_code, response.text)
|
||||
body = response.json()
|
||||
self.assertIn("branches", body)
|
||||
self.assertIn("status", body)
|
||||
remote_head = _run(
|
||||
["git", "ls-remote", str(remote), "refs/heads/main"], self.base
|
||||
)
|
||||
self.assertIn(head, remote_head)
|
||||
|
||||
def test_checkout_dirty_tree_wrong_confirmation_is_403(self) -> None:
|
||||
_git(self.repo, "checkout", "-q", "-b", "feature")
|
||||
_git(self.repo, "checkout", "-q", "main")
|
||||
(self.repo / "tracked.txt").write_text("dirty", encoding="utf-8")
|
||||
response = self.client.post(
|
||||
"/git/checkout",
|
||||
json={"repo": "alpha", "ref": "feature", "confirmation": "wrong"},
|
||||
)
|
||||
self.assertEqual(403, response.status_code, response.text)
|
||||
|
||||
def test_checkout_clean_tree_works_without_confirmation(self) -> None:
|
||||
_git(self.repo, "checkout", "-q", "-b", "feature")
|
||||
_git(self.repo, "checkout", "-q", "main")
|
||||
response = self.client.post(
|
||||
"/git/checkout", json={"repo": "alpha", "ref": "feature"}
|
||||
)
|
||||
self.assertEqual(200, response.status_code, response.text)
|
||||
body = response.json()
|
||||
self.assertIn("branches", body)
|
||||
self.assertIn("status", body)
|
||||
self.assertEqual(
|
||||
"feature", _git(self.repo, "symbolic-ref", "--short", "HEAD")
|
||||
)
|
||||
|
||||
def test_checkout_new_branch_with_track_sets_upstream(self) -> None:
|
||||
remote = _init_bare_remote(self.base, "newbranch-bare")
|
||||
_git(self.repo, "remote", "add", "origin", str(remote))
|
||||
_git(self.repo, "push", "-q", "origin", "main")
|
||||
_git(self.repo, "branch", "-q", "--set-upstream-to=origin/main", "main")
|
||||
response = self.client.post(
|
||||
"/git/checkout",
|
||||
json={
|
||||
"repo": "alpha",
|
||||
"ref": "main",
|
||||
"new_branch": "exp",
|
||||
"track": True,
|
||||
},
|
||||
)
|
||||
self.assertEqual(200, response.status_code, response.text)
|
||||
body = response.json()
|
||||
self.assertIn("branches", body)
|
||||
self.assertIn("status", body)
|
||||
self.assertEqual("exp", _git(self.repo, "symbolic-ref", "--short", "HEAD"))
|
||||
# Upstream is set — rev-parse resolves to a commit, not an error.
|
||||
self.assertTrue(_git(self.repo, "rev-parse", "exp@{upstream}"))
|
||||
|
||||
def test_fetch_updates_remote_tracking_ref(self) -> None:
|
||||
remote = _init_bare_remote(self.base, "fetch-adv-bare")
|
||||
_git(self.repo, "remote", "add", "origin", str(remote))
|
||||
_git(self.repo, "push", "-q", "origin", "main")
|
||||
other = self.base / "other"
|
||||
_run(["git", "clone", "-q", str(remote), str(other)], self.base)
|
||||
_git(other, "config", "user.email", "t@e.c")
|
||||
_git(other, "config", "user.name", "T")
|
||||
(other / "remote.txt").write_text("rc fetch", encoding="utf-8")
|
||||
_git(other, "add", "remote.txt")
|
||||
_git(other, "commit", "-q", "-m", "rc fetch")
|
||||
_git(other, "push", "-q", "origin", "main")
|
||||
response = self.client.post(
|
||||
"/git/fetch", json={"repo": "alpha", "remote": "origin"}
|
||||
)
|
||||
self.assertEqual(200, response.status_code, response.text)
|
||||
body = response.json()
|
||||
self.assertIn("branches", body)
|
||||
self.assertIn("status", body)
|
||||
remote_main = _git(self.repo, "rev-parse", "origin/main")
|
||||
self.assertNotEqual(remote_main, _git(self.repo, "rev-parse", "HEAD"))
|
||||
|
||||
def test_pull_returns_200_with_remote_commit(self) -> None:
|
||||
remote = _init_bare_remote(self.base, "pull-ok-bare")
|
||||
_git(self.repo, "remote", "add", "origin", str(remote))
|
||||
_git(self.repo, "push", "-q", "origin", "main")
|
||||
_git(self.repo, "branch", "-q", "--set-upstream-to=origin/main", "main")
|
||||
other = self.base / "other"
|
||||
_run(["git", "clone", "-q", str(remote), str(other)], self.base)
|
||||
_git(other, "config", "user.email", "t@e.c")
|
||||
_git(other, "config", "user.name", "T")
|
||||
(other / "remote.txt").write_text("rc pull", encoding="utf-8")
|
||||
_git(other, "add", "remote.txt")
|
||||
_git(other, "commit", "-q", "-m", "rc pull")
|
||||
_git(other, "push", "-q", "origin", "main")
|
||||
response = self.client.post(
|
||||
"/git/pull",
|
||||
json={"repo": "alpha", "remote": "origin", "branch": "main"},
|
||||
)
|
||||
self.assertEqual(200, response.status_code, response.text)
|
||||
body = response.json()
|
||||
self.assertIn("status", body)
|
||||
head_files = _git(self.repo, "ls-tree", "-r", "--name-only", "HEAD")
|
||||
self.assertIn("remote.txt", head_files)
|
||||
|
||||
def test_commit_selected_commits_only_given_paths(self) -> None:
|
||||
(self.repo / "kept.txt").write_text("keep", encoding="utf-8")
|
||||
(self.repo / "skip.txt").write_text("skip", encoding="utf-8")
|
||||
_git(self.repo, "add", "kept.txt", "skip.txt")
|
||||
response = self.client.post(
|
||||
"/git/commit_selected",
|
||||
json={"repo": "alpha", "message": "commit kept", "paths": ["kept.txt"]},
|
||||
)
|
||||
self.assertEqual(200, response.status_code, response.text)
|
||||
body = response.json()
|
||||
self.assertIn("head", body)
|
||||
self.assertIn("status", body)
|
||||
head_files = _git(self.repo, "ls-tree", "-r", "--name-only", "HEAD")
|
||||
self.assertIn("kept.txt", head_files)
|
||||
self.assertNotIn("skip.txt", head_files)
|
||||
|
||||
def test_commit_response_includes_head_and_fresh_status(self) -> None:
|
||||
self._stage("feature.txt")
|
||||
response = self.client.post(
|
||||
"/git/commit", json={"repo": "alpha", "message": "add feature"}
|
||||
)
|
||||
self.assertEqual(200, response.status_code, response.text)
|
||||
body = response.json()
|
||||
self.assertIn("head", body)
|
||||
self.assertEqual(body["head"], _git(self.repo, "rev-parse", "HEAD"))
|
||||
self.assertIn("status", body)
|
||||
self.assertEqual(0, body["status"]["counts"]["staged"])
|
||||
self.assertEqual(0, body["status"]["counts"]["modified"])
|
||||
|
||||
def test_unstage_returns_fresh_status(self) -> None:
|
||||
(self.repo / "tracked.txt").write_text("v1", encoding="utf-8")
|
||||
_git(self.repo, "add", "tracked.txt")
|
||||
_git(self.repo, "commit", "-q", "-m", "add tracked")
|
||||
(self.repo / "tracked.txt").write_text("v2", encoding="utf-8")
|
||||
r = self.client.post(
|
||||
"/git/stage", json={"repo": "alpha", "paths": ["tracked.txt"]}
|
||||
)
|
||||
self.assertEqual(200, r.status_code, r.text)
|
||||
response = self.client.post(
|
||||
"/git/unstage", json={"repo": "alpha", "paths": ["tracked.txt"]}
|
||||
)
|
||||
self.assertEqual(200, response.status_code, response.text)
|
||||
body = response.json()
|
||||
self.assertIn("status", body)
|
||||
status = body["status"]
|
||||
self.assertNotIn(
|
||||
"tracked.txt", [e["path"] for e in status["staged"]]
|
||||
)
|
||||
self.assertIn(
|
||||
"tracked.txt", [e["path"] for e in status["modified"]]
|
||||
)
|
||||
|
||||
def test_stage_missing_repo_in_body_is_400(self) -> None:
|
||||
response = self.client.post("/git/stage", json={"paths": ["x"]})
|
||||
self.assertEqual(400, response.status_code, response.text)
|
||||
|
||||
def test_stage_traversal_path_is_400(self) -> None:
|
||||
response = self.client.post(
|
||||
"/git/stage", json={"repo": "alpha", "paths": ["../escape"]}
|
||||
)
|
||||
self.assertEqual(400, response.status_code, response.text)
|
||||
|
||||
def test_stage_too_many_paths_is_400(self) -> None:
|
||||
paths = [f"f{i}.txt" for i in range(201)]
|
||||
response = self.client.post(
|
||||
"/git/stage", json={"repo": "alpha", "paths": paths}
|
||||
)
|
||||
self.assertEqual(400, response.status_code, response.text)
|
||||
|
||||
|
||||
class GitExtrasApiTests(unittest.TestCase):
|
||||
"""Endpoints for the Phase 3 extras surface (AI messages + stash-checkout)."""
|
||||
|
||||
def setUp(self) -> None:
|
||||
self.temp = tempfile.TemporaryDirectory()
|
||||
self.addCleanup(self.temp.cleanup)
|
||||
self.base = Path(self.temp.name) / "projects"
|
||||
self.base.mkdir(parents=True)
|
||||
self.repo = _init_repo(self.base, "alpha")
|
||||
self.env = patch.dict(
|
||||
os.environ,
|
||||
{"HERMES_HOME": self.temp.name, "GIT_STATE_BASE_PATH": str(self.base)},
|
||||
)
|
||||
self.env.start()
|
||||
self.addCleanup(self.env.stop)
|
||||
app = FastAPI()
|
||||
app.include_router(git_api.router)
|
||||
self.client = TestClient(app)
|
||||
|
||||
def _staged(self, path: str, content: str) -> None:
|
||||
(self.repo / path).write_text(content, encoding="utf-8")
|
||||
self.client.post("/git/stage", json={"repo": "alpha", "paths": [path]})
|
||||
|
||||
def test_commit_message_empty_staged_returns_notice_without_model(self) -> None:
|
||||
# Clean tree → nothing staged → no model needed, no 500.
|
||||
response = self.client.post("/git/commit_message", json={"repo": "alpha"})
|
||||
self.assertEqual(200, response.status_code, response.text)
|
||||
body = response.json()
|
||||
self.assertEqual("", body["message"])
|
||||
self.assertEqual("nothing staged", body["notice"])
|
||||
|
||||
def test_commit_message_with_staged_diff_generates_message(self) -> None:
|
||||
self._staged("feature.txt", "new feature\n")
|
||||
with patch.object(git_state, "_llm_call", new=AsyncMock(return_value="resp")), patch.object(
|
||||
git_state, "_llm_extract", return_value="feat: add feature"
|
||||
):
|
||||
response = self.client.post(
|
||||
"/git/commit_message", json={"repo": "alpha"}
|
||||
)
|
||||
self.assertEqual(200, response.status_code, response.text)
|
||||
self.assertEqual("feat: add feature", response.json()["message"])
|
||||
|
||||
def test_commit_message_model_failure_degrades_gracefully(self) -> None:
|
||||
self._staged("a.txt", "x\n")
|
||||
with patch.object(
|
||||
git_state, "_llm_call", new=AsyncMock(side_effect=RuntimeError("no model"))
|
||||
):
|
||||
response = self.client.post(
|
||||
"/git/commit_message", json={"repo": "alpha"}
|
||||
)
|
||||
self.assertEqual(200, response.status_code, response.text)
|
||||
body = response.json()
|
||||
self.assertEqual("", body["message"])
|
||||
self.assertIn("model", body["notice"].lower())
|
||||
|
||||
def test_commit_message_selected_honors_only_given_paths(self) -> None:
|
||||
self._staged("kept.txt", "kept\n")
|
||||
self._staged("skip.txt", "skip\n")
|
||||
with patch.object(git_state, "_llm_call", new=AsyncMock(return_value="ok")), patch.object(
|
||||
git_state, "_llm_extract", return_value="add kept"
|
||||
):
|
||||
response = self.client.post(
|
||||
"/git/commit_message_selected",
|
||||
json={"repo": "alpha", "paths": ["kept.txt"]},
|
||||
)
|
||||
self.assertEqual(200, response.status_code, response.text)
|
||||
self.assertEqual("add kept", response.json()["message"])
|
||||
|
||||
def test_commit_message_unknown_repo_is_400(self) -> None:
|
||||
response = self.client.post(
|
||||
"/git/commit_message", json={"repo": "bogus"}
|
||||
)
|
||||
self.assertEqual(400, response.status_code, response.text)
|
||||
|
||||
def test_stash_checkout_dirty_tree_returns_stash_notice(self) -> None:
|
||||
_git(self.repo, "checkout", "-q", "-b", "feature")
|
||||
_git(self.repo, "checkout", "-q", "main")
|
||||
(self.repo / "README.md").write_text("dirty", encoding="utf-8")
|
||||
response = self.client.post(
|
||||
"/git/stash_checkout", json={"repo": "alpha", "ref": "feature"}
|
||||
)
|
||||
self.assertEqual(200, response.status_code, response.text)
|
||||
body = response.json()
|
||||
self.assertTrue(body["stashed"])
|
||||
self.assertEqual("git-state: feature", body["stash_message"])
|
||||
self.assertEqual("feature", _git(self.repo, "symbolic-ref", "--short", "HEAD"))
|
||||
self.assertIn("git-state: feature", _git(self.repo, "stash", "list"))
|
||||
|
||||
def test_stash_checkout_clean_tree_plain_checkout(self) -> None:
|
||||
_git(self.repo, "checkout", "-q", "-b", "feature")
|
||||
_git(self.repo, "checkout", "-q", "main")
|
||||
response = self.client.post(
|
||||
"/git/stash_checkout", json={"repo": "alpha", "ref": "feature"}
|
||||
)
|
||||
self.assertEqual(200, response.status_code, response.text)
|
||||
body = response.json()
|
||||
self.assertFalse(body["stashed"])
|
||||
self.assertEqual("", body["stash_message"])
|
||||
self.assertEqual("feature", _git(self.repo, "symbolic-ref", "--short", "HEAD"))
|
||||
|
||||
def test_stash_checkout_bad_ref_is_400(self) -> None:
|
||||
response = self.client.post(
|
||||
"/git/stash_checkout", json={"repo": "alpha", "ref": "nope"}
|
||||
)
|
||||
self.assertEqual(400, response.status_code, response.text)
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
unittest.main()
|
||||
@@ -50,7 +50,8 @@ class MobilePluginApiTests(unittest.TestCase):
|
||||
|
||||
manifest = self.client.get("/mobile/manifest").json()
|
||||
self.assertEqual("hermes-relay", manifest["id"])
|
||||
self.assertEqual("draft", manifest["contributions"][0]["status"])
|
||||
draft = next(c for c in manifest["contributions"] if c["id"] == "system-status")
|
||||
self.assertEqual("draft", draft["status"])
|
||||
loaded_document = self.client.get("/mobile/pages/system-status").json()
|
||||
self.assertEqual(1, loaded_document.pop("host_revision"))
|
||||
self.assertEqual(_document(), loaded_document)
|
||||
@@ -70,7 +71,35 @@ class MobilePluginApiTests(unittest.TestCase):
|
||||
json={"expected_digest": published_digest},
|
||||
)
|
||||
self.assertEqual({"ok": True, "id": "system-status"}, removed.json())
|
||||
self.assertEqual([], self.client.get("/mobile/manifest").json()["contributions"])
|
||||
remaining = self.client.get("/mobile/manifest").json()["contributions"]
|
||||
self.assertEqual(["git"], [c["id"] for c in remaining])
|
||||
|
||||
def test_manifest_exposes_static_git_page(self) -> None:
|
||||
manifest = self.client.get("/mobile/manifest").json()
|
||||
git = next(c for c in manifest["contributions"] if c["id"] == "git")
|
||||
self.assertEqual("page", git["surface"])
|
||||
self.assertEqual("mobile/pages/git", git["document"]["path"])
|
||||
# The Git page document is a GET-only read surface; it must not carry
|
||||
# any action.request (which would require the write grant).
|
||||
page = self.client.get("/mobile/pages/git").json()
|
||||
self.assertNotIn("action", str(page))
|
||||
|
||||
def test_git_page_document_is_served(self) -> None:
|
||||
response = self.client.get("/mobile/pages/git")
|
||||
self.assertEqual(200, response.status_code, response.text)
|
||||
body = response.json()
|
||||
self.assertEqual(1, body["schemaVersion"])
|
||||
self.assertEqual("git", body["pages"][0]["id"])
|
||||
self.assertEqual(1, body["host_revision"])
|
||||
|
||||
def test_reserved_git_plugin_id_is_rejected(self) -> None:
|
||||
response = self.client.put(
|
||||
"/mobile/plugins/git/draft",
|
||||
json={"title": "Shadow", "document": _document()},
|
||||
)
|
||||
self.assertEqual(400, response.status_code, response.text)
|
||||
manifest = self.client.get("/mobile/manifest").json()
|
||||
self.assertEqual(["git"], [item["id"] for item in manifest["contributions"]])
|
||||
|
||||
def test_traversal_and_bad_document_are_rejected(self) -> None:
|
||||
traversal = self.client.put(
|
||||
|
||||
+1063
File diff suppressed because it is too large
Load Diff
@@ -17,6 +17,7 @@ from typing import Any, Optional
|
||||
|
||||
|
||||
PLUGIN_ID_RE = re.compile(r"^[a-z0-9][a-z0-9._-]{0,63}$")
|
||||
RESERVED_PLUGIN_IDS = frozenset({"git"})
|
||||
MAX_DOCUMENT_BYTES = 512 * 1024
|
||||
ALLOWED_LIFECYCLES = frozenset({"session", "persistent"})
|
||||
ALLOWED_ELEMENT_TYPES = frozenset(
|
||||
@@ -132,6 +133,8 @@ class MobilePluginStore:
|
||||
for path in sorted(self.root.glob("*.json")):
|
||||
if not PLUGIN_ID_RE.fullmatch(path.stem):
|
||||
continue
|
||||
if path.stem in RESERVED_PLUGIN_IDS:
|
||||
continue
|
||||
entry = self._read(path.stem, required=False)
|
||||
if entry:
|
||||
entries.append({k: v for k, v in entry.items() if k != "document"})
|
||||
@@ -139,6 +142,26 @@ class MobilePluginStore:
|
||||
|
||||
def manifest(self) -> dict[str, Any]:
|
||||
contributions = []
|
||||
# Static read-only Git page contributed by the relay plugin itself.
|
||||
# It carries no filesystem paths (per the android-plugins.md document
|
||||
# contract); repo data flows through the /git/* API responses and is
|
||||
# rendered as plain text by the host. No plugin.api.write grant is
|
||||
# required for the read-only Git surface.
|
||||
contributions.append(
|
||||
{
|
||||
"id": "git",
|
||||
"surface": "page",
|
||||
"title": "Git",
|
||||
"description": "Browse repositories on this Hermes host",
|
||||
"status": "published",
|
||||
"lifecycle": "persistent",
|
||||
"revision": 1,
|
||||
"document": {
|
||||
"method": "GET",
|
||||
"path": "mobile/pages/git",
|
||||
},
|
||||
}
|
||||
)
|
||||
for summary in self.list():
|
||||
is_draft = summary["status"] == "draft"
|
||||
contributions.append(
|
||||
@@ -180,6 +203,8 @@ class MobilePluginStore:
|
||||
normalized = str(plugin_id).strip().lower()
|
||||
if not PLUGIN_ID_RE.fullmatch(normalized):
|
||||
raise MobilePluginStoreError("invalid plugin id")
|
||||
if normalized in RESERVED_PLUGIN_IDS:
|
||||
raise MobilePluginStoreError("plugin id is reserved")
|
||||
return normalized
|
||||
|
||||
@staticmethod
|
||||
@@ -311,6 +336,8 @@ class MobilePluginStore:
|
||||
if required:
|
||||
raise MobilePluginNotFoundError(plugin_id)
|
||||
return {}
|
||||
except MobilePluginStoreError:
|
||||
raise
|
||||
except (OSError, ValueError, json.JSONDecodeError):
|
||||
if required:
|
||||
raise MobilePluginNotFoundError(plugin_id)
|
||||
|
||||
+107
-1
@@ -97,6 +97,29 @@ _PAIRING_CODE_TTL = 600.0
|
||||
DEFAULT_REFRESH_TTL_SECONDS: float = 180 * 24 * 3600 # 180 days
|
||||
_REFRESH_TOKEN_BYTES = 32
|
||||
|
||||
# Client-reported supervised-mode metadata is intentionally small and
|
||||
# non-authoritative. Relay stores it only so paired-device surfaces can show
|
||||
# the operator which Android client is presenting a restricted UI. The
|
||||
# Android client remains the enforcement owner.
|
||||
SUPERVISED_PROFILE_LABEL_MAX_LENGTH = 80
|
||||
SUPERVISED_CAPABILITY_MAX_COUNT = 12
|
||||
SUPERVISED_CAPABILITIES: frozenset[str] = frozenset(
|
||||
{
|
||||
"attachments",
|
||||
"cancel",
|
||||
"copy",
|
||||
"generated_images",
|
||||
"new_chat",
|
||||
"quote_reply",
|
||||
"retry",
|
||||
"share_images",
|
||||
"steer",
|
||||
"text_chat",
|
||||
"timestamps",
|
||||
"voice",
|
||||
}
|
||||
)
|
||||
|
||||
|
||||
# ── Data models ──────────────────────────────────────────────────────────────
|
||||
|
||||
@@ -114,6 +137,58 @@ def _refresh_token_hash(token: str) -> str:
|
||||
return sha256(token.encode("utf-8")).hexdigest()
|
||||
|
||||
|
||||
@dataclass(frozen=True)
|
||||
class SupervisedMode:
|
||||
"""Bounded, client-reported metadata for paired-device display only."""
|
||||
|
||||
active: bool = False
|
||||
profile_label: str = ""
|
||||
capabilities: tuple[str, ...] = ()
|
||||
|
||||
def to_public_dict(self) -> dict[str, Any]:
|
||||
"""Return the stable public wire shape for an active report."""
|
||||
return {
|
||||
"active": True,
|
||||
"profile_label": self.profile_label,
|
||||
"capabilities": list(self.capabilities),
|
||||
"enforcement_owner": "android_client",
|
||||
}
|
||||
|
||||
|
||||
def parse_supervised_mode(value: Any) -> SupervisedMode:
|
||||
"""Validate untrusted supervised-mode metadata.
|
||||
|
||||
Missing, inactive, malformed, oversized, or unknown values all normalize
|
||||
to ordinary mode. Capability values are allowlisted so this public summary
|
||||
cannot become a side channel for model, tool, path, or arbitrary client
|
||||
data.
|
||||
"""
|
||||
ordinary = SupervisedMode()
|
||||
if not isinstance(value, dict) or value.get("active") is not True:
|
||||
return ordinary
|
||||
|
||||
raw_label = value.get("profile_label")
|
||||
raw_capabilities = value.get("capabilities", [])
|
||||
if not isinstance(raw_label, str) or not isinstance(raw_capabilities, list):
|
||||
return ordinary
|
||||
|
||||
if not raw_label.isprintable():
|
||||
return ordinary
|
||||
label = raw_label.strip()
|
||||
if not label or len(label) > SUPERVISED_PROFILE_LABEL_MAX_LENGTH:
|
||||
return ordinary
|
||||
if len(raw_capabilities) > SUPERVISED_CAPABILITY_MAX_COUNT:
|
||||
return ordinary
|
||||
|
||||
capabilities: list[str] = []
|
||||
for candidate in raw_capabilities:
|
||||
if not isinstance(candidate, str) or candidate not in SUPERVISED_CAPABILITIES:
|
||||
return ordinary
|
||||
if candidate not in capabilities:
|
||||
capabilities.append(candidate)
|
||||
return SupervisedMode(True, label, tuple(capabilities))
|
||||
|
||||
|
||||
def _default_grants(ttl_seconds: float, now: float) -> dict[str, float]:
|
||||
"""Compute default per-channel grants given an overall session TTL.
|
||||
|
||||
@@ -229,6 +304,7 @@ class Session:
|
||||
refresh_token: str | None = field(default=None, repr=False, compare=False)
|
||||
device_model: str = "unknown"
|
||||
device_platform: str = "unknown"
|
||||
supervised_mode: SupervisedMode = field(default_factory=SupervisedMode)
|
||||
|
||||
def __post_init__(self) -> None:
|
||||
if self.expires_at == 0.0:
|
||||
@@ -290,6 +366,7 @@ class TrustedDevice:
|
||||
device_form_factor: str = "unknown"
|
||||
device_model: str = "unknown"
|
||||
device_platform: str = "unknown"
|
||||
supervised_mode: SupervisedMode = field(default_factory=SupervisedMode)
|
||||
|
||||
def __post_init__(self) -> None:
|
||||
if self.expires_at == 0.0:
|
||||
@@ -458,7 +535,7 @@ def _session_to_json(session: Session) -> dict[str, Any]:
|
||||
return "never"
|
||||
return v
|
||||
|
||||
return {
|
||||
payload = {
|
||||
"token": session.token,
|
||||
"device_name": session.device_name,
|
||||
"device_id": session.device_id,
|
||||
@@ -473,6 +550,9 @@ def _session_to_json(session: Session) -> dict[str, Any]:
|
||||
"device_platform": session.device_platform,
|
||||
"first_seen": session.first_seen,
|
||||
}
|
||||
if session.supervised_mode.active:
|
||||
payload["supervised_mode"] = session.supervised_mode.to_public_dict()
|
||||
return payload
|
||||
|
||||
|
||||
def _session_from_json(payload: dict[str, Any]) -> Session | None:
|
||||
@@ -508,6 +588,7 @@ def _session_from_json(payload: dict[str, Any]) -> Session | None:
|
||||
device_model = str(payload.get("device_model", "unknown"))
|
||||
device_platform = str(payload.get("device_platform", "unknown"))
|
||||
first_seen = float(payload.get("first_seen", created_at))
|
||||
supervised_mode = parse_supervised_mode(payload.get("supervised_mode"))
|
||||
except (KeyError, TypeError, ValueError):
|
||||
return None
|
||||
|
||||
@@ -525,6 +606,7 @@ def _session_from_json(payload: dict[str, Any]) -> Session | None:
|
||||
device_model=device_model,
|
||||
device_platform=device_platform,
|
||||
first_seen=first_seen,
|
||||
supervised_mode=supervised_mode,
|
||||
)
|
||||
|
||||
|
||||
@@ -550,6 +632,8 @@ def _trusted_device_to_json(device: TrustedDevice) -> dict[str, Any]:
|
||||
}
|
||||
if device.grants is not None:
|
||||
payload["grants"] = dict(device.grants)
|
||||
if device.supervised_mode.active:
|
||||
payload["supervised_mode"] = device.supervised_mode.to_public_dict()
|
||||
return payload
|
||||
|
||||
|
||||
@@ -586,6 +670,7 @@ def _trusted_device_from_json(payload: dict[str, Any]) -> TrustedDevice | None:
|
||||
device_form_factor = str(payload.get("device_form_factor", "unknown"))
|
||||
device_model = str(payload.get("device_model", "unknown"))
|
||||
device_platform = str(payload.get("device_platform", "unknown"))
|
||||
supervised_mode = parse_supervised_mode(payload.get("supervised_mode"))
|
||||
except (KeyError, TypeError, ValueError):
|
||||
return None
|
||||
|
||||
@@ -606,6 +691,7 @@ def _trusted_device_from_json(payload: dict[str, Any]) -> TrustedDevice | None:
|
||||
device_form_factor=device_form_factor,
|
||||
device_model=device_model,
|
||||
device_platform=device_platform,
|
||||
supervised_mode=supervised_mode,
|
||||
)
|
||||
|
||||
|
||||
@@ -883,6 +969,7 @@ class SessionManager:
|
||||
device_form_factor: str = "unknown",
|
||||
device_model: str = "unknown",
|
||||
device_platform: str = "unknown",
|
||||
supervised_mode: SupervisedMode | None = None,
|
||||
issue_refresh_token: bool = False,
|
||||
) -> Session:
|
||||
"""Create a new session for an authenticated device.
|
||||
@@ -913,6 +1000,9 @@ class SessionManager:
|
||||
device_platform:
|
||||
Optional operating-system/platform metadata retained for device
|
||||
details. Neither field participates in authorization.
|
||||
supervised_mode:
|
||||
Optional bounded report of Android's supervised client state.
|
||||
Informational only; Relay does not enforce the reported policy.
|
||||
issue_refresh_token:
|
||||
When True, also create a persisted trusted-device credential and
|
||||
attach the raw one-time refresh token to the returned
|
||||
@@ -922,6 +1012,7 @@ class SessionManager:
|
||||
"""
|
||||
if ttl_seconds is None:
|
||||
ttl_seconds = DEFAULT_TTL_SECONDS
|
||||
supervised_mode = supervised_mode or SupervisedMode()
|
||||
|
||||
now = time.time()
|
||||
if ttl_seconds == 0:
|
||||
@@ -981,6 +1072,7 @@ class SessionManager:
|
||||
device_form_factor=device_form_factor,
|
||||
device_model=device_model,
|
||||
device_platform=device_platform,
|
||||
supervised_mode=supervised_mode,
|
||||
)
|
||||
|
||||
token = str(uuid.uuid4())
|
||||
@@ -997,6 +1089,7 @@ class SessionManager:
|
||||
device_form_factor=device_form_factor,
|
||||
device_model=device_model,
|
||||
device_platform=device_platform,
|
||||
supervised_mode=supervised_mode,
|
||||
first_seen=now,
|
||||
refresh_token=refresh_token,
|
||||
)
|
||||
@@ -1051,6 +1144,7 @@ class SessionManager:
|
||||
device_form_factor=session.device_form_factor,
|
||||
device_model=session.device_model,
|
||||
device_platform=session.device_platform,
|
||||
supervised_mode=session.supervised_mode,
|
||||
)
|
||||
session.refresh_token = refresh_token
|
||||
self._save_to_disk()
|
||||
@@ -1067,6 +1161,7 @@ class SessionManager:
|
||||
device_form_factor: str = "unknown",
|
||||
device_model: str = "unknown",
|
||||
device_platform: str = "unknown",
|
||||
supervised_mode: SupervisedMode | None = None,
|
||||
) -> Session | None:
|
||||
"""Mint a replacement session from a trusted-device refresh token.
|
||||
|
||||
@@ -1121,6 +1216,8 @@ class SessionManager:
|
||||
trusted.device_model = device_model
|
||||
if device_platform and device_platform != "unknown":
|
||||
trusted.device_platform = device_platform
|
||||
if supervised_mode is not None:
|
||||
trusted.supervised_mode = supervised_mode
|
||||
self._trusted_devices[new_refresh_hash] = trusted
|
||||
|
||||
session = self.create_session(
|
||||
@@ -1133,6 +1230,7 @@ class SessionManager:
|
||||
device_form_factor=trusted.device_form_factor,
|
||||
device_model=trusted.device_model,
|
||||
device_platform=trusted.device_platform,
|
||||
supervised_mode=trusted.supervised_mode,
|
||||
issue_refresh_token=False,
|
||||
)
|
||||
session.refresh_token = new_refresh_token
|
||||
@@ -1153,6 +1251,7 @@ class SessionManager:
|
||||
device_platform: str | None = None,
|
||||
client_surface: str | None = None,
|
||||
device_form_factor: str | None = None,
|
||||
supervised_mode: SupervisedMode | None = None,
|
||||
) -> None:
|
||||
"""Adopt identity metadata from a valid reconnecting client.
|
||||
|
||||
@@ -1180,6 +1279,10 @@ class SessionManager:
|
||||
setattr(session, field_name, value)
|
||||
changed = True
|
||||
|
||||
if supervised_mode is not None and session.supervised_mode != supervised_mode:
|
||||
session.supervised_mode = supervised_mode
|
||||
changed = True
|
||||
|
||||
for trusted in self._trusted_devices.values():
|
||||
if trusted.device_id != session.device_id:
|
||||
continue
|
||||
@@ -1192,6 +1295,9 @@ class SessionManager:
|
||||
if getattr(trusted, field_name) != value:
|
||||
setattr(trusted, field_name, value)
|
||||
changed = True
|
||||
if supervised_mode is not None and trusted.supervised_mode != supervised_mode:
|
||||
trusted.supervised_mode = supervised_mode
|
||||
changed = True
|
||||
|
||||
if changed:
|
||||
self._save_to_disk()
|
||||
|
||||
+61
-1
@@ -53,6 +53,7 @@ from .auth import (
|
||||
RateLimiter,
|
||||
Session,
|
||||
SessionManager,
|
||||
parse_supervised_mode,
|
||||
)
|
||||
from .channels.bridge import BridgeError, BridgeHandler
|
||||
from .channels.chat import ChatHandler
|
||||
@@ -887,7 +888,7 @@ def _session_to_dict(session: Session, current_token: str | None) -> dict[str, A
|
||||
return None if math.isinf(ts) else ts
|
||||
|
||||
grants_out = {k: _norm(v) for k, v in session.grants.items()}
|
||||
return {
|
||||
payload = {
|
||||
"token_prefix": session.token[:8],
|
||||
"device_name": session.device_name,
|
||||
"device_id": session.device_id,
|
||||
@@ -903,6 +904,9 @@ def _session_to_dict(session: Session, current_token: str | None) -> dict[str, A
|
||||
"device_platform": session.device_platform,
|
||||
"is_current": current_token is not None and session.token == current_token,
|
||||
}
|
||||
if session.supervised_mode.active:
|
||||
payload["supervised_mode"] = session.supervised_mode.to_public_dict()
|
||||
return payload
|
||||
|
||||
|
||||
def _require_bearer_session(
|
||||
@@ -1012,6 +1016,18 @@ async def handle_sessions_revoke(request: web.Request) -> web.Response:
|
||||
target = matches[0]
|
||||
revoked_self = current_token is not None and target.token == current_token
|
||||
server.sessions.revoke_session(target.token)
|
||||
# Revocation ends already-connected Relay sockets as well as preventing
|
||||
# future authentication. This does not enforce the Android supervised
|
||||
# policy; it revokes the ordinary paired Relay session that reported it.
|
||||
revoked_sockets = [
|
||||
ws for ws, token in server._clients.items() if token == target.token
|
||||
]
|
||||
for ws in revoked_sockets:
|
||||
if not ws.closed:
|
||||
await ws.close(
|
||||
code=aiohttp.WSCloseCode.POLICY_VIOLATION,
|
||||
message=b"Relay session revoked",
|
||||
)
|
||||
route_credential_id = credential_id_for(target.token)
|
||||
server.secure_link_route_credentials.pop(route_credential_id, None)
|
||||
if server.secure_link_connector is not None:
|
||||
@@ -4209,6 +4225,8 @@ def _build_auth_ok_payload(
|
||||
}
|
||||
if session.refresh_token:
|
||||
payload["refresh_token"] = session.refresh_token
|
||||
if session.supervised_mode.active:
|
||||
payload["supervised_mode"] = session.supervised_mode.to_public_dict()
|
||||
if route_credential is not None:
|
||||
payload["route_credential"] = route_credential
|
||||
return payload
|
||||
@@ -4310,6 +4328,11 @@ async def _authenticate(
|
||||
device_platform = str(
|
||||
payload.get("device_platform", "unknown") or "unknown"
|
||||
).strip()
|
||||
# Every authentication is a fresh client report. Missing or invalid
|
||||
# metadata explicitly returns the paired session to ordinary mode rather
|
||||
# than leaving a stale supervised badge behind after the client disables
|
||||
# the mode or downgrades.
|
||||
supervised_mode = parse_supervised_mode(payload.get("supervised_mode"))
|
||||
|
||||
# Pairing policy is attached by a loopback-only operator flow. Clients
|
||||
# may still send ttl_seconds / grants for wire compatibility, but those
|
||||
@@ -4338,6 +4361,7 @@ async def _authenticate(
|
||||
device_form_factor=(
|
||||
device_form_factor if "device_form_factor" in payload else None
|
||||
),
|
||||
supervised_mode=supervised_mode,
|
||||
)
|
||||
if (
|
||||
not refresh_token_attempt
|
||||
@@ -4370,6 +4394,7 @@ async def _authenticate(
|
||||
device_form_factor=device_form_factor,
|
||||
device_model=device_model,
|
||||
device_platform=device_platform,
|
||||
supervised_mode=supervised_mode,
|
||||
)
|
||||
if session is not None:
|
||||
server.rate_limiter.record_success(remote_ip)
|
||||
@@ -4403,6 +4428,7 @@ async def _authenticate(
|
||||
device_form_factor=device_form_factor,
|
||||
device_model=device_model,
|
||||
device_platform=device_platform,
|
||||
supervised_mode=supervised_mode,
|
||||
issue_refresh_token=True,
|
||||
)
|
||||
server.rate_limiter.record_success(remote_ip)
|
||||
@@ -4584,6 +4610,40 @@ async def _handle_system(
|
||||
elif msg_type == "pong":
|
||||
# Client responding to our ping — nothing to do
|
||||
pass
|
||||
elif msg_type == "supervised.update":
|
||||
# Informational update from an already-authenticated Android client.
|
||||
# The socket's paired session is the only ownership input: payload
|
||||
# fields cannot select or modify another session. Relay deliberately
|
||||
# does not enforce the reported client policy.
|
||||
token = server._clients.get(ws)
|
||||
session = server.sessions.get_session(token) if token else None
|
||||
if session is None:
|
||||
await _send_system(
|
||||
ws,
|
||||
"error",
|
||||
{"message": "Authenticated Relay session is no longer valid"},
|
||||
msg_id,
|
||||
)
|
||||
return
|
||||
if not isinstance(payload, dict):
|
||||
payload = {}
|
||||
supervised_mode = parse_supervised_mode(payload.get("supervised_mode"))
|
||||
server.sessions.update_session_device_metadata(
|
||||
session,
|
||||
supervised_mode=supervised_mode,
|
||||
)
|
||||
applied: dict[str, Any] = {
|
||||
"active": False,
|
||||
"enforcement_owner": "android_client",
|
||||
}
|
||||
if supervised_mode.active:
|
||||
applied = supervised_mode.to_public_dict()
|
||||
await _send_system(
|
||||
ws,
|
||||
"supervised.updated",
|
||||
{"supervised_mode": applied},
|
||||
msg_id,
|
||||
)
|
||||
else:
|
||||
logger.debug("Unhandled system message type: %s", msg_type)
|
||||
|
||||
|
||||
@@ -0,0 +1,427 @@
|
||||
"""Tests for the read-only Git state surface (plugin/git_state.py).
|
||||
|
||||
Fixtures create REAL throwaway git repositories in tmp_path — init, config
|
||||
user, commits, branches, and bare remotes. Git itself is never mocked.
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import os
|
||||
import subprocess
|
||||
import unittest
|
||||
from pathlib import Path
|
||||
from unittest.mock import patch
|
||||
|
||||
from plugin import git_state
|
||||
|
||||
|
||||
def _run(cmd: list[str], cwd: Path) -> str:
|
||||
result = subprocess.run(
|
||||
cmd,
|
||||
cwd=cwd,
|
||||
capture_output=True,
|
||||
text=True,
|
||||
check=True,
|
||||
)
|
||||
return result.stdout.strip()
|
||||
|
||||
|
||||
def _git(repo: Path, *args: str) -> str:
|
||||
return _run(["git", "-C", str(repo), *args], repo)
|
||||
|
||||
|
||||
def _init_repo(root: Path, name: str) -> Path:
|
||||
repo = root / name
|
||||
repo.mkdir(parents=True)
|
||||
_run(["git", "init", "-q", "-b", "main"], repo)
|
||||
_run(["git", "config", "user.email", "test@example.com"], repo)
|
||||
_run(["git", "config", "user.name", "Test User"], repo)
|
||||
(repo / "README.md").write_text("# Hello\n", encoding="utf-8")
|
||||
_git(repo, "add", "README.md")
|
||||
_git(repo, "commit", "-q", "-m", "initial commit")
|
||||
return repo
|
||||
|
||||
|
||||
def _add_remote(repo: Path, remote_url: str, name: str = "origin") -> None:
|
||||
_git(repo, "remote", "add", name, remote_url)
|
||||
|
||||
|
||||
def _link_directory(link: Path, target: Path) -> None:
|
||||
try:
|
||||
link.symlink_to(target, target_is_directory=True)
|
||||
except OSError:
|
||||
if os.name != "nt":
|
||||
raise
|
||||
_run(["cmd", "/c", "mklink", "/J", str(link), str(target)], link.parent)
|
||||
|
||||
|
||||
class GitStateScanTests(unittest.TestCase):
|
||||
def setUp(self) -> None:
|
||||
self.tmp = Path(self.tempdir())
|
||||
|
||||
def tempdir(self) -> str:
|
||||
import tempfile
|
||||
|
||||
self._td = tempfile.TemporaryDirectory()
|
||||
self.addCleanup(self._td.cleanup)
|
||||
return self._td.name
|
||||
|
||||
def test_scan_finds_nested_repos_and_ignores_non_repos(self) -> None:
|
||||
base = self.tmp / "projects"
|
||||
base.mkdir(parents=True)
|
||||
_init_repo(base, "alpha")
|
||||
_init_repo(base / "nested", "beta")
|
||||
# A plain directory with no .git must be ignored.
|
||||
(base / "plain").mkdir()
|
||||
(base / "plain" / "file.txt").write_text("x", encoding="utf-8")
|
||||
|
||||
repos = git_state.scan_repos(base)
|
||||
names = {r["name"] for r in repos}
|
||||
self.assertEqual({"alpha", "beta"}, names)
|
||||
for repo in repos:
|
||||
self.assertEqual("main", repo["current_branch"])
|
||||
self.assertFalse(repo["dirty"])
|
||||
|
||||
def test_scan_missing_base_path_returns_empty(self) -> None:
|
||||
missing = self.tmp / "does-not-exist"
|
||||
self.assertEqual([], git_state.scan_repos(missing))
|
||||
|
||||
def test_scan_excludes_git_internals(self) -> None:
|
||||
base = self.tmp / "projects"
|
||||
base.mkdir(parents=True)
|
||||
_init_repo(base, "alpha")
|
||||
# A .git directory itself must never be reported as a repo.
|
||||
repos = git_state.scan_repos(base)
|
||||
self.assertTrue(all(".git" not in r["name"] for r in repos))
|
||||
|
||||
def test_scan_marks_dirty_repo(self) -> None:
|
||||
base = self.tmp / "projects"
|
||||
base.mkdir(parents=True)
|
||||
repo = _init_repo(base, "dirty")
|
||||
(repo / "new.txt").write_text("untracked", encoding="utf-8")
|
||||
repos = git_state.scan_repos(base)
|
||||
dirty = next(r for r in repos if r["name"] == "dirty")
|
||||
self.assertTrue(dirty["dirty"])
|
||||
|
||||
def test_nested_same_name_repos_have_distinct_round_trip_ids(self) -> None:
|
||||
base = self.tmp / "projects"
|
||||
base.mkdir(parents=True)
|
||||
first = _init_repo(base / "team-a", "service")
|
||||
second = _init_repo(base / "team-b", "service")
|
||||
|
||||
repos = git_state.scan_repos(base)
|
||||
self.assertEqual({"team-a/service", "team-b/service"}, {repo["id"] for repo in repos})
|
||||
self.assertEqual(first.resolve(), git_state.resolve_repo(base, "team-a/service"))
|
||||
self.assertEqual(second.resolve(), git_state.resolve_repo(base, "team-b/service"))
|
||||
|
||||
def test_scan_rejects_linked_repo_outside_base(self) -> None:
|
||||
base = self.tmp / "projects"
|
||||
base.mkdir(parents=True)
|
||||
outside = _init_repo(self.tmp, "outside")
|
||||
link = base / "linked"
|
||||
_link_directory(link, outside)
|
||||
|
||||
self.assertEqual([], git_state.scan_repos(base))
|
||||
|
||||
|
||||
class GitStateStatusTests(unittest.TestCase):
|
||||
def setUp(self) -> None:
|
||||
import tempfile
|
||||
|
||||
self._td = tempfile.TemporaryDirectory()
|
||||
self.addCleanup(self._td.cleanup)
|
||||
self.base = Path(self._td.name) / "projects"
|
||||
self.base.mkdir(parents=True)
|
||||
self.repo = _init_repo(self.base, "status-repo")
|
||||
|
||||
def test_status_groups_modified_untracked_and_staged(self) -> None:
|
||||
(self.repo / "tracked.txt").write_text("v1", encoding="utf-8")
|
||||
_git(self.repo, "add", "tracked.txt")
|
||||
_git(self.repo, "commit", "-q", "-m", "add tracked")
|
||||
# staged change
|
||||
(self.repo / "tracked.txt").write_text("v2", encoding="utf-8")
|
||||
_git(self.repo, "add", "tracked.txt")
|
||||
# unstaged change
|
||||
(self.repo / "README.md").write_text("# Changed\n", encoding="utf-8")
|
||||
# untracked
|
||||
(self.repo / "untracked.txt").write_text("new", encoding="utf-8")
|
||||
|
||||
status = git_state.repo_status(self.repo)
|
||||
self.assertEqual(1, status["counts"]["staged"])
|
||||
self.assertEqual(1, status["counts"]["modified"])
|
||||
self.assertEqual(1, status["counts"]["untracked"])
|
||||
self.assertEqual("tracked.txt", status["staged"][0]["path"])
|
||||
self.assertEqual("README.md", status["modified"][0]["path"])
|
||||
self.assertEqual("untracked.txt", status["untracked"][0]["path"])
|
||||
self.assertFalse(status["truncated"])
|
||||
|
||||
def test_status_truncates_when_over_cap(self) -> None:
|
||||
for i in range(git_state.MAX_STATUS_ENTRIES + 5):
|
||||
(self.repo / f"file-{i}.txt").write_text("x", encoding="utf-8")
|
||||
status = git_state.repo_status(self.repo)
|
||||
self.assertTrue(status["truncated"])
|
||||
self.assertLessEqual(
|
||||
len(status["untracked"]),
|
||||
git_state.MAX_STATUS_ENTRIES,
|
||||
)
|
||||
|
||||
def test_status_lists_staged_and_modified_same_file(self) -> None:
|
||||
# A file staged AND then modified again ("MM" in porcelain) must appear
|
||||
# in BOTH the staged and modified groups (independent checks, not elif).
|
||||
(self.repo / "mm.txt").write_text("v1", encoding="utf-8")
|
||||
_git(self.repo, "add", "mm.txt")
|
||||
_git(self.repo, "commit", "-q", "-m", "add mm")
|
||||
(self.repo / "mm.txt").write_text("v2", encoding="utf-8")
|
||||
_git(self.repo, "add", "mm.txt")
|
||||
(self.repo / "mm.txt").write_text("v3", encoding="utf-8")
|
||||
|
||||
status = git_state.repo_status(self.repo)
|
||||
staged_paths = {e["path"] for e in status["staged"]}
|
||||
modified_paths = {e["path"] for e in status["modified"]}
|
||||
self.assertIn("mm.txt", staged_paths)
|
||||
self.assertIn("mm.txt", modified_paths)
|
||||
|
||||
def test_status_rename_emits_single_staged_entry(self) -> None:
|
||||
# `git mv` produces two NUL-separated porcelain records ("R new\0old\0");
|
||||
# the bare source-path record must be skipped, not misparsed as an XY
|
||||
# record. Use a source name starting with "M" so a naive parser would
|
||||
# misclassify the bare source record as staged with a truncated path.
|
||||
(self.repo / "Moved.txt").write_text("content", encoding="utf-8")
|
||||
_git(self.repo, "add", "Moved.txt")
|
||||
_git(self.repo, "commit", "-q", "-m", "add Moved")
|
||||
_git(self.repo, "mv", "Moved.txt", "new.txt")
|
||||
|
||||
status = git_state.repo_status(self.repo)
|
||||
self.assertEqual(["new.txt"], [e["path"] for e in status["staged"]])
|
||||
self.assertEqual([], status["modified"])
|
||||
self.assertEqual([], status["untracked"])
|
||||
|
||||
def test_status_unstaged_rename_shows_delete_and_untracked(self) -> None:
|
||||
# Rename on disk only (no `git mv`): delete + create → D + ??.
|
||||
(self.repo / "old.txt").write_text("content", encoding="utf-8")
|
||||
_git(self.repo, "add", "old.txt")
|
||||
_git(self.repo, "commit", "-q", "-m", "add old")
|
||||
(self.repo / "old.txt").unlink()
|
||||
(self.repo / "new.txt").write_text("content", encoding="utf-8")
|
||||
|
||||
status = git_state.repo_status(self.repo)
|
||||
self.assertEqual([], status["staged"])
|
||||
self.assertEqual(["old.txt"], [e["path"] for e in status["modified"]])
|
||||
self.assertEqual(["new.txt"], [e["path"] for e in status["untracked"]])
|
||||
|
||||
|
||||
class GitStateBranchesTests(unittest.TestCase):
|
||||
def setUp(self) -> None:
|
||||
import tempfile
|
||||
|
||||
self._td = tempfile.TemporaryDirectory()
|
||||
self.addCleanup(self._td.cleanup)
|
||||
self.base = Path(self._td.name) / "projects"
|
||||
self.base.mkdir(parents=True)
|
||||
self.repo = _init_repo(self.base, "branch-repo")
|
||||
|
||||
def test_branches_reports_current_upstream_ahead_behind(self) -> None:
|
||||
_git(self.repo, "checkout", "-q", "-b", "feature")
|
||||
(self.repo / "feature.txt").write_text("f", encoding="utf-8")
|
||||
_git(self.repo, "add", "feature.txt")
|
||||
_git(self.repo, "commit", "-q", "-m", "feature work")
|
||||
|
||||
branches = git_state.repo_branches(self.repo)
|
||||
by_name = {b["name"]: b for b in branches}
|
||||
self.assertIn("main", by_name)
|
||||
self.assertIn("feature", by_name)
|
||||
self.assertTrue(by_name["feature"]["is_current"])
|
||||
self.assertFalse(by_name["main"]["is_current"])
|
||||
|
||||
|
||||
class GitStateDiffTests(unittest.TestCase):
|
||||
def setUp(self) -> None:
|
||||
import tempfile
|
||||
|
||||
self._td = tempfile.TemporaryDirectory()
|
||||
self.addCleanup(self._td.cleanup)
|
||||
self.base = Path(self._td.name) / "projects"
|
||||
self.base.mkdir(parents=True)
|
||||
self.repo = _init_repo(self.base, "diff-repo")
|
||||
|
||||
def test_diff_unstaged_and_staged_kinds(self) -> None:
|
||||
(self.repo / "a.txt").write_text("one\n", encoding="utf-8")
|
||||
_git(self.repo, "add", "a.txt")
|
||||
_git(self.repo, "commit", "-q", "-m", "add a")
|
||||
# staged change
|
||||
(self.repo / "a.txt").write_text("two\n", encoding="utf-8")
|
||||
_git(self.repo, "add", "a.txt")
|
||||
# unstaged change
|
||||
(self.repo / "a.txt").write_text("three\n", encoding="utf-8")
|
||||
|
||||
staged = git_state.repo_diff(self.repo, "a.txt", kind="staged")
|
||||
self.assertIn("+two", staged["diff"])
|
||||
unstaged = git_state.repo_diff(self.repo, "a.txt", kind="unstaged")
|
||||
self.assertIn("+three", unstaged["diff"])
|
||||
|
||||
def test_diff_invalid_kind_raises(self) -> None:
|
||||
with self.assertRaises(ValueError):
|
||||
git_state.repo_diff(self.repo, "a.txt", kind="bogus")
|
||||
|
||||
def test_diff_truncates_large_output(self) -> None:
|
||||
big = "x" * 200_000
|
||||
(self.repo / "big.txt").write_text(big + "\n", encoding="utf-8")
|
||||
_git(self.repo, "add", "big.txt")
|
||||
_git(self.repo, "commit", "-q", "-m", "add big")
|
||||
(self.repo / "big.txt").write_text(big + "y\n", encoding="utf-8")
|
||||
result = git_state.repo_diff(self.repo, "big.txt", kind="unstaged")
|
||||
self.assertTrue(result["truncated"])
|
||||
self.assertLessEqual(len(result["diff"]), git_state.MAX_DIFF_BYTES)
|
||||
|
||||
|
||||
class GitStateFileTests(unittest.TestCase):
|
||||
def setUp(self) -> None:
|
||||
import tempfile
|
||||
|
||||
self._td = tempfile.TemporaryDirectory()
|
||||
self.addCleanup(self._td.cleanup)
|
||||
self.base = Path(self._td.name) / "projects"
|
||||
self.base.mkdir(parents=True)
|
||||
self.repo = _init_repo(self.base, "file-repo")
|
||||
|
||||
def test_read_tracked_file(self) -> None:
|
||||
content = git_state.read_file(self.repo, "README.md")
|
||||
self.assertIn("Hello", content["content"])
|
||||
|
||||
def test_read_tracked_file_returns_working_tree_not_committed(self) -> None:
|
||||
# A modified-but-uncommitted tracked file must return the on-disk
|
||||
# (working-tree) content, not the last committed version.
|
||||
(self.repo / "README.md").write_text("# Working Tree\n", encoding="utf-8")
|
||||
content = git_state.read_file(self.repo, "README.md")
|
||||
self.assertIn("Working Tree", content["content"])
|
||||
self.assertNotIn("Hello", content["content"])
|
||||
|
||||
def test_read_untracked_file_raises(self) -> None:
|
||||
(self.repo / "untracked.txt").write_text("new", encoding="utf-8")
|
||||
with self.assertRaises(ValueError):
|
||||
git_state.read_file(self.repo, "untracked.txt")
|
||||
|
||||
def test_read_missing_file_raises(self) -> None:
|
||||
with self.assertRaises(ValueError):
|
||||
git_state.read_file(self.repo, "nope.txt")
|
||||
|
||||
def test_read_tracked_binary_file_raises_gitstateerror(self) -> None:
|
||||
# A TRACKED binary file (e.g. a committed PNG) must raise GitStateError
|
||||
# ("binary file is not supported" / UTF-8), never an unhandled
|
||||
# UnicodeDecodeError that escapes as a 500.
|
||||
(self.repo / "image.png").write_bytes(b"\x89PNG\r\n\x1a\n\x00binary\xff\xfe")
|
||||
_git(self.repo, "add", "image.png")
|
||||
_git(self.repo, "commit", "-q", "-m", "add binary")
|
||||
with self.assertRaises(git_state.GitStateError) as ctx:
|
||||
git_state.read_file(self.repo, "image.png")
|
||||
message = str(ctx.exception)
|
||||
self.assertTrue("binary" in message or "UTF-8" in message)
|
||||
|
||||
def test_read_tracked_non_utf8_text_raises(self) -> None:
|
||||
# A tracked, NUL-free but non-UTF-8 text file (Latin-1) must raise a
|
||||
# clear GitStateError, not an unhandled UnicodeDecodeError.
|
||||
(self.repo / "latin1.txt").write_bytes(b"caf\xe9 latin1")
|
||||
_git(self.repo, "add", "latin1.txt")
|
||||
_git(self.repo, "commit", "-q", "-m", "add latin1")
|
||||
with self.assertRaises(git_state.GitStateError) as ctx:
|
||||
git_state.read_file(self.repo, "latin1.txt")
|
||||
self.assertIn("not valid UTF-8 text", str(ctx.exception))
|
||||
|
||||
def test_read_tracked_link_outside_repo_is_rejected(self) -> None:
|
||||
if os.name == "nt":
|
||||
outside = self.base / "outside"
|
||||
outside.mkdir()
|
||||
(outside / "secret.txt").write_text("secret", encoding="utf-8")
|
||||
link = self.repo / "leak"
|
||||
_link_directory(link, outside)
|
||||
tracked_path = "leak/secret.txt"
|
||||
else:
|
||||
outside = self.base / "outside.txt"
|
||||
outside.write_text("secret", encoding="utf-8")
|
||||
(self.repo / "leak.txt").symlink_to(outside)
|
||||
tracked_path = "leak.txt"
|
||||
_git(self.repo, "add", tracked_path)
|
||||
_git(self.repo, "commit", "-q", "-m", "track link")
|
||||
|
||||
with self.assertRaisesRegex(git_state.GitStateError, "escapes repository"):
|
||||
git_state.read_file(self.repo, tracked_path)
|
||||
|
||||
def test_read_tracked_file_is_bounded_during_read(self) -> None:
|
||||
(self.repo / "large.txt").write_text("x" * (git_state.MAX_FILE_BYTES + 100), encoding="utf-8")
|
||||
_git(self.repo, "add", "large.txt")
|
||||
_git(self.repo, "commit", "-q", "-m", "large")
|
||||
|
||||
result = git_state.read_file(self.repo, "large.txt")
|
||||
|
||||
self.assertTrue(result["truncated"])
|
||||
self.assertEqual(git_state.MAX_FILE_BYTES, len(result["content"]))
|
||||
|
||||
|
||||
class GitStateDocumentTests(unittest.TestCase):
|
||||
def test_document_missing_base_notice_leaks_no_path(self) -> None:
|
||||
import tempfile
|
||||
|
||||
self._td = tempfile.TemporaryDirectory()
|
||||
self.addCleanup(self._td.cleanup)
|
||||
missing = Path(self._td.name) / "does-not-exist"
|
||||
|
||||
doc = git_state.build_git_document(missing)
|
||||
notice = doc["pages"][0]["content"]["children"][0]
|
||||
self.assertEqual("notice", notice["id"])
|
||||
value = notice["text"]["value"]
|
||||
# The document contract forbids filesystem paths: no "/" and no
|
||||
# path-like substring (e.g. the tmp dir name).
|
||||
self.assertNotIn("/", value)
|
||||
self.assertNotIn(self._td.name, value)
|
||||
|
||||
|
||||
class GitStateSecurityTests(unittest.TestCase):
|
||||
def setUp(self) -> None:
|
||||
import tempfile
|
||||
|
||||
self._td = tempfile.TemporaryDirectory()
|
||||
self.addCleanup(self._td.cleanup)
|
||||
self.base = Path(self._td.name) / "projects"
|
||||
self.base.mkdir(parents=True)
|
||||
self.repo = _init_repo(self.base, "sec-repo")
|
||||
|
||||
def test_path_traversal_rejected(self) -> None:
|
||||
for bad in ("../outside", "/etc/passwd", "a/../../b", "..%2Fescape"):
|
||||
with self.subTest(path=bad):
|
||||
with self.assertRaises(ValueError):
|
||||
git_state.resolve_repo_path(self.repo, bad)
|
||||
|
||||
def test_remote_urls_scrubbed_of_userinfo(self) -> None:
|
||||
_add_remote(self.repo, "https://user:secret@example.com/org/repo.git", "https")
|
||||
_add_remote(self.repo, "ssh://git@example.com:2222/org/repo.git", "ssh")
|
||||
_add_remote(self.repo, "git@example.com:org/repo.git", "scp")
|
||||
remotes = git_state.repo_remotes(self.repo)
|
||||
self.assertEqual(3, len(remotes))
|
||||
for remote in remotes:
|
||||
self.assertNotIn("secret", remote["url"])
|
||||
self.assertNotIn("user:", remote["url"])
|
||||
self.assertNotIn("git@", remote["url"])
|
||||
|
||||
def test_git_error_text_scrubs_embedded_remote_credentials(self) -> None:
|
||||
message = git_state._safe_git_error(
|
||||
"fatal: unable to access 'https://user:secret@example.com/repo.git'"
|
||||
)
|
||||
self.assertNotIn("user", message)
|
||||
self.assertNotIn("secret", message)
|
||||
|
||||
def test_allowlist_accepts_only_scanned_repos(self) -> None:
|
||||
scanned = git_state.scan_repos(self.base)
|
||||
ids = {r["id"] for r in scanned}
|
||||
self.assertIn(git_state.repo_id(self.repo), ids)
|
||||
self.assertNotIn("bogus-id", ids)
|
||||
|
||||
def test_git_output_over_cap_fails_closed(self) -> None:
|
||||
for index in range(20):
|
||||
(self.repo / f"long-untracked-name-{index}.txt").write_text("x", encoding="utf-8")
|
||||
with patch.object(git_state, "MAX_GIT_OUTPUT_BYTES", 32):
|
||||
with self.assertRaisesRegex(git_state.GitStateError, "output exceeded"):
|
||||
git_state.repo_status(self.repo)
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
unittest.main()
|
||||
@@ -0,0 +1,269 @@
|
||||
"""Tests for the Phase 3 extras surface of git_state.
|
||||
|
||||
Covers:
|
||||
- ``commit_message`` / ``commit_message_selected``: staged-diff → LLM → a
|
||||
conventional-style message suggestion. The model client is MONKEYPATCHED
|
||||
(never a real API call); empty staged diff must skip the LLM entirely; a
|
||||
missing/failing model degrades to an empty message + notice, never a 500.
|
||||
- ``stash_checkout``: dirty tree → auto-stash + switch (stash list entry
|
||||
present); clean tree → plain checkout; unknown ref → error.
|
||||
|
||||
Fixtures build REAL throwaway git repos in tmp_path. git itself is never
|
||||
mocked.
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import asyncio
|
||||
import os
|
||||
import subprocess
|
||||
import unittest
|
||||
from pathlib import Path
|
||||
from unittest.mock import AsyncMock, MagicMock, patch
|
||||
|
||||
from plugin import git_state
|
||||
|
||||
|
||||
def _run(cmd: list[str], cwd: Path) -> str:
|
||||
return subprocess.run(
|
||||
cmd, cwd=cwd, capture_output=True, text=True, check=True
|
||||
).stdout.strip()
|
||||
|
||||
|
||||
def _git(repo: Path, *args: str) -> str:
|
||||
return _run(["git", "-C", str(repo), *args], repo)
|
||||
|
||||
|
||||
def _init_repo(root: Path, name: str) -> Path:
|
||||
repo = root / name
|
||||
repo.mkdir(parents=True)
|
||||
_run(["git", "init", "-q", "-b", "main"], repo)
|
||||
_run(["git", "config", "user.email", "test@example.com"], repo)
|
||||
_run(["git", "config", "user.name", "Test User"], repo)
|
||||
(repo / "README.md").write_text("# Hello\n", encoding="utf-8")
|
||||
_git(repo, "add", "README.md")
|
||||
_git(repo, "commit", "-q", "-m", "initial commit")
|
||||
return repo
|
||||
|
||||
|
||||
class _ExtrasBase(unittest.TestCase):
|
||||
def setUp(self) -> None:
|
||||
import tempfile
|
||||
|
||||
self._td = tempfile.TemporaryDirectory()
|
||||
self.addCleanup(self._td.cleanup)
|
||||
self.base = Path(self._td.name) / "projects"
|
||||
self.base.mkdir(parents=True)
|
||||
self.repo = _init_repo(self.base, "extras-repo")
|
||||
|
||||
def _staged_change(self, path: str, content: str) -> None:
|
||||
(self.repo / path).write_text(content, encoding="utf-8")
|
||||
_git(self.repo, "add", path)
|
||||
|
||||
def _branch(self, name: str) -> None:
|
||||
_git(self.repo, "checkout", "-q", "-b", name)
|
||||
_git(self.repo, "checkout", "-q", "main")
|
||||
|
||||
def _patch_llm(self, *, text: str = "", exc: Exception | None = None):
|
||||
"""Inject a fake agent.auxiliary_client chain into git_state.
|
||||
|
||||
``_llm_call`` is the async client; ``_llm_extract`` turns its return
|
||||
into text. The failure path raises ``exc`` from the client.
|
||||
"""
|
||||
client = AsyncMock(side_effect=exc) if exc else AsyncMock(return_value="resp")
|
||||
extractor = MagicMock(return_value=text)
|
||||
return patch.object(git_state, "_llm_call", client), client, extractor, patch.object(
|
||||
git_state, "_llm_extract", extractor
|
||||
)
|
||||
|
||||
|
||||
class CommitMessageTests(_ExtrasBase):
|
||||
def test_commit_message_returns_suggestion_from_staged_diff(self) -> None:
|
||||
self._staged_change("feature.txt", "new feature body\n")
|
||||
llm_patch, client, extractor, extract_patch = self._patch_llm(
|
||||
text="feat: add new feature\n\nBrings a new feature."
|
||||
)
|
||||
with llm_patch, extract_patch:
|
||||
result = asyncio.run(git_state.commit_message(self.repo))
|
||||
# The generated message is the LLM text (subject + optional body) so it
|
||||
# can flow into the commit dialog and be edited.
|
||||
self.assertEqual(
|
||||
{"message": "feat: add new feature\n\nBrings a new feature.", "notice": ""},
|
||||
result,
|
||||
)
|
||||
# The client was called with a messages list containing the staged diff.
|
||||
_, kwargs = client.call_args
|
||||
self.assertIn("messages", kwargs)
|
||||
payload = " ".join(str(m) for m in kwargs["messages"])
|
||||
self.assertIn("new feature", payload)
|
||||
extractor.assert_called_once_with("resp")
|
||||
|
||||
def test_commit_message_empty_staged_diff_skips_llm(self) -> None:
|
||||
# Clean tree: nothing staged → no LLM call.
|
||||
p = patch.object(git_state, "_llm_call", AsyncMock())
|
||||
with p as client:
|
||||
result = asyncio.run(git_state.commit_message(self.repo))
|
||||
self.assertEqual({"message": "", "notice": "nothing staged"}, result)
|
||||
client.assert_not_called()
|
||||
|
||||
def test_commit_message_skips_llm_when_staged_diff_is_empty(self) -> None:
|
||||
# A tracked file modified but NOT staged must never be sent.
|
||||
(self.repo / "README.md").write_text("# Hello v2\n", encoding="utf-8")
|
||||
p = patch.object(git_state, "_llm_call", AsyncMock())
|
||||
with p as client:
|
||||
result = asyncio.run(git_state.commit_message(self.repo))
|
||||
self.assertEqual({"message": "", "notice": "nothing staged"}, result)
|
||||
client.assert_not_called()
|
||||
|
||||
def test_commit_message_degrades_when_model_fails(self) -> None:
|
||||
self._staged_change("feature.txt", "boom\n")
|
||||
p, _, _, _ = self._patch_llm(exc=RuntimeError("no provider configured"))
|
||||
with p:
|
||||
result = asyncio.run(git_state.commit_message(self.repo))
|
||||
self.assertEqual("", result["message"])
|
||||
self.assertIn("model", result["notice"].lower())
|
||||
|
||||
def test_commit_message_degrades_when_model_unavailable(self) -> None:
|
||||
self._staged_change("feature.txt", "x\n")
|
||||
# Resolver itself fails (e.g. `agent` not installed).
|
||||
with patch.object(git_state, "_resolve_llm", side_effect=ImportError("no agent")):
|
||||
result = asyncio.run(git_state.commit_message(self.repo))
|
||||
self.assertEqual("", result["message"])
|
||||
self.assertIn("model", result["notice"].lower())
|
||||
|
||||
def test_commit_message_bounds_staged_diff(self) -> None:
|
||||
# A large staged diff is truncated to MAX_DIFF_BYTES and flagged.
|
||||
big = "x" * (git_state.MAX_DIFF_BYTES + 10)
|
||||
self._staged_change("big.txt", big)
|
||||
p, client, extractor, ep = self._patch_llm(text="add big")
|
||||
with p, ep:
|
||||
result = asyncio.run(git_state.commit_message(self.repo))
|
||||
self.assertEqual("add big", result["message"])
|
||||
_, kwargs = client.call_args
|
||||
payload = "".join(str(m) for m in kwargs["messages"])
|
||||
self.assertLessEqual(len(payload), git_state.MAX_DIFF_BYTES + 2048)
|
||||
self.assertNotIn("nothing staged", result.get("notice", ""))
|
||||
|
||||
|
||||
class CommitMessageSelectedTests(_ExtrasBase):
|
||||
def test_commit_message_selected_honors_only_given_paths(self) -> None:
|
||||
self._staged_change("kept.txt", "kept content\n")
|
||||
self._staged_change("skip.txt", "skip content\n")
|
||||
p, client, extractor, extract_p = self._patch_llm(text="add kept")
|
||||
with p, extract_p:
|
||||
result = asyncio.run(
|
||||
git_state.commit_message_selected(self.repo, ["kept.txt"])
|
||||
)
|
||||
self.assertEqual("add kept", result["message"])
|
||||
_, kwargs = client.call_args
|
||||
payload = "".join(str(m) for m in kwargs["messages"])
|
||||
self.assertIn("kept content", payload)
|
||||
self.assertNotIn("skip content", payload)
|
||||
|
||||
def test_commit_message_selected_skips_llm_when_none_selected_staged(self) -> None:
|
||||
self._staged_change("kept.txt", "kept\n")
|
||||
# A path with no staged content → nothing staged → no call.
|
||||
(self.repo / "other.txt").write_text("other\n", encoding="utf-8")
|
||||
_git(self.repo, "add", "other.txt")
|
||||
# Stage only kept.txt, ask for other.txt's diff (not staged) → nothing.
|
||||
p = patch.object(git_state, "_llm_call", AsyncMock())
|
||||
with p as client:
|
||||
# Ask for a path that has no STAGED diff: unstage other first.
|
||||
_git(self.repo, "restore", "--staged", "other.txt")
|
||||
result = asyncio.run(
|
||||
git_state.commit_message_selected(self.repo, ["other.txt"])
|
||||
)
|
||||
self.assertEqual({"message": "", "notice": "nothing staged"}, result)
|
||||
client.assert_not_called()
|
||||
|
||||
def test_commit_message_selected_rejects_traversal_path(self) -> None:
|
||||
self._staged_change("kept.txt", "kept\n")
|
||||
with self.assertRaises(git_state.GitStateError):
|
||||
asyncio.run(
|
||||
git_state.commit_message_selected(self.repo, ["../escape"])
|
||||
)
|
||||
|
||||
|
||||
class StashCheckoutTests(_ExtrasBase):
|
||||
def test_dirty_tree_stashes_then_switches(self) -> None:
|
||||
self._branch("feature")
|
||||
(self.repo / "README.md").write_text("dirty change\n", encoding="utf-8")
|
||||
result = git_state.stash_checkout(self.repo, "feature")
|
||||
self.assertTrue(result["stashed"])
|
||||
self.assertEqual("git-state: feature", result["stash_message"])
|
||||
self.assertEqual("feature", _git(self.repo, "symbolic-ref", "--short", "HEAD"))
|
||||
stash_list = _git(self.repo, "stash", "list")
|
||||
self.assertIn("git-state: feature", stash_list)
|
||||
|
||||
def test_clean_tree_plain_checkout_no_stash(self) -> None:
|
||||
self._branch("feature")
|
||||
result = git_state.stash_checkout(self.repo, "feature")
|
||||
self.assertFalse(result["stashed"])
|
||||
self.assertEqual("", result["stash_message"])
|
||||
self.assertEqual("feature", _git(self.repo, "symbolic-ref", "--short", "HEAD"))
|
||||
self.assertEqual("", _git(self.repo, "stash", "list"))
|
||||
|
||||
def test_clean_tree_new_branch_switch(self) -> None:
|
||||
result = git_state.stash_checkout(self.repo, "main", new_branch="exp")
|
||||
self.assertFalse(result["stashed"])
|
||||
self.assertEqual("exp", _git(self.repo, "symbolic-ref", "--short", "HEAD"))
|
||||
|
||||
def test_stash_returns_fresh_status_and_branches(self) -> None:
|
||||
self._branch("feature")
|
||||
(self.repo / "README.md").write_text("dirty\n", encoding="utf-8")
|
||||
result = git_state.stash_checkout(self.repo, "feature")
|
||||
self.assertIn("status", result)
|
||||
self.assertIn("branches", result)
|
||||
self.assertIn("head", result)
|
||||
|
||||
def test_bad_ref_raises(self) -> None:
|
||||
(self.repo / "README.md").write_text("still here\n", encoding="utf-8")
|
||||
with self.assertRaises(git_state.GitStateError):
|
||||
git_state.stash_checkout(self.repo, "no-such-branch")
|
||||
self.assertEqual("still here\n", (self.repo / "README.md").read_text(encoding="utf-8"))
|
||||
self.assertEqual("", _git(self.repo, "stash", "list"))
|
||||
|
||||
def test_existing_new_branch_is_rejected_before_stashing(self) -> None:
|
||||
(self.repo / "README.md").write_text("still here\n", encoding="utf-8")
|
||||
with self.assertRaisesRegex(git_state.GitError, "already exists"):
|
||||
git_state.stash_checkout(self.repo, "main", new_branch="main")
|
||||
self.assertEqual("still here\n", (self.repo / "README.md").read_text(encoding="utf-8"))
|
||||
self.assertEqual("", _git(self.repo, "stash", "list"))
|
||||
|
||||
def test_checkout_failure_restores_tracked_staged_and_untracked_changes(self) -> None:
|
||||
self._branch("feature")
|
||||
(self.repo / "README.md").write_text("dirty\n", encoding="utf-8")
|
||||
(self.repo / "staged.txt").write_text("staged\n", encoding="utf-8")
|
||||
_git(self.repo, "add", "staged.txt")
|
||||
(self.repo / "untracked.txt").write_text("untracked\n", encoding="utf-8")
|
||||
original_mutate = git_state._mutate
|
||||
|
||||
def fail_checkout(repo: Path, args: list[str]) -> str:
|
||||
if args[0] == "checkout":
|
||||
raise git_state.GitError("forced checkout failure", code="conflict")
|
||||
return original_mutate(repo, args)
|
||||
|
||||
with patch.object(git_state, "_mutate", side_effect=fail_checkout):
|
||||
with self.assertRaisesRegex(git_state.GitError, "working changes were restored"):
|
||||
git_state.stash_checkout(self.repo, "feature")
|
||||
|
||||
self.assertEqual("dirty\n", (self.repo / "README.md").read_text(encoding="utf-8"))
|
||||
self.assertTrue((self.repo / "staged.txt").exists())
|
||||
self.assertTrue((self.repo / "untracked.txt").exists())
|
||||
self.assertIn("staged.txt", _git(self.repo, "diff", "--cached", "--name-only"))
|
||||
self.assertIn("git-state: feature", _git(self.repo, "stash", "list"))
|
||||
|
||||
def test_new_branch_uses_requested_start_point(self) -> None:
|
||||
_git(self.repo, "checkout", "-q", "-b", "feature")
|
||||
(self.repo / "feature-only.txt").write_text("feature", encoding="utf-8")
|
||||
_git(self.repo, "add", "feature-only.txt")
|
||||
_git(self.repo, "commit", "-q", "-m", "feature")
|
||||
_git(self.repo, "checkout", "-q", "main")
|
||||
|
||||
git_state.stash_checkout(self.repo, "feature", new_branch="from-feature")
|
||||
|
||||
self.assertTrue((self.repo / "feature-only.txt").exists())
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
unittest.main()
|
||||
@@ -0,0 +1,406 @@
|
||||
"""Tests for the write (mutation) surface of git_state.
|
||||
|
||||
Security denials are tested FIRST: destructive mutations (discard, push, dirty
|
||||
checkout) reject when their confirmation string is missing or wrong. Then happy
|
||||
paths, then error branches. Fixtures build REAL throwaway git repos and bare
|
||||
remotes in tmp_path — git itself is never mocked.
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import os
|
||||
import subprocess
|
||||
import unittest
|
||||
from pathlib import Path
|
||||
|
||||
from plugin import git_state
|
||||
|
||||
|
||||
def _run(cmd: list[str], cwd: Path) -> str:
|
||||
result = subprocess.run(
|
||||
cmd,
|
||||
cwd=cwd,
|
||||
capture_output=True,
|
||||
text=True,
|
||||
check=True,
|
||||
)
|
||||
return result.stdout.strip()
|
||||
|
||||
|
||||
def _git(repo: Path, *args: str) -> str:
|
||||
return _run(["git", "-C", str(repo), *args], repo)
|
||||
|
||||
|
||||
def _init_repo(root: Path, name: str) -> Path:
|
||||
repo = root / name
|
||||
repo.mkdir(parents=True)
|
||||
_run(["git", "init", "-q", "-b", "main"], repo)
|
||||
_run(["git", "config", "user.email", "test@example.com"], repo)
|
||||
_run(["git", "config", "user.name", "Test User"], repo)
|
||||
(repo / "README.md").write_text("# Hello\n", encoding="utf-8")
|
||||
_git(repo, "add", "README.md")
|
||||
_git(repo, "commit", "-q", "-m", "initial commit")
|
||||
return repo
|
||||
|
||||
|
||||
def _init_bare_remote(root: Path, name: str) -> Path:
|
||||
remote = root / name
|
||||
remote.mkdir(parents=True, exist_ok=True)
|
||||
_run(["git", "init", "-q", "--bare", "-b", "main"], remote)
|
||||
return remote
|
||||
|
||||
|
||||
class _MutationBase(unittest.TestCase):
|
||||
def setUp(self) -> None:
|
||||
import tempfile
|
||||
|
||||
self._td = tempfile.TemporaryDirectory()
|
||||
self.addCleanup(self._td.cleanup)
|
||||
self.base = Path(self._td.name) / "projects"
|
||||
self.base.mkdir(parents=True)
|
||||
self.repo = _init_repo(self.base, "write-repo")
|
||||
|
||||
def _make_change(self, path: str = "feature.txt", content: str = "hello") -> None:
|
||||
(self.repo / path).write_text(content, encoding="utf-8")
|
||||
_git(self.repo, "add", path)
|
||||
|
||||
def _head(self) -> str:
|
||||
return _git(self.repo, "rev-parse", "HEAD")
|
||||
|
||||
|
||||
class StageUnstageTests(_MutationBase):
|
||||
def test_stage_moves_untracked_to_staged(self) -> None:
|
||||
(self.repo / "new.txt").write_text("x", encoding="utf-8")
|
||||
git_state.stage(self.repo, ["new.txt"])
|
||||
status = git_state.repo_status(self.repo)
|
||||
self.assertIn("new.txt", [e["path"] for e in status["staged"]])
|
||||
self.assertNotIn("new.txt", [e["path"] for e in status["untracked"]])
|
||||
|
||||
def test_stage_accepts_multiple_paths(self) -> None:
|
||||
(self.repo / "a.txt").write_text("a", encoding="utf-8")
|
||||
(self.repo / "b.txt").write_text("b", encoding="utf-8")
|
||||
git_state.stage(self.repo, ["a.txt", "b.txt"])
|
||||
status = git_state.repo_status(self.repo)
|
||||
staged = {e["path"] for e in status["staged"]}
|
||||
self.assertTrue({"a.txt", "b.txt"} <= staged)
|
||||
|
||||
def test_unstage_returns_to_modified(self) -> None:
|
||||
(self.repo / "tracked.txt").write_text("v1", encoding="utf-8")
|
||||
_git(self.repo, "add", "tracked.txt")
|
||||
_git(self.repo, "commit", "-q", "-m", "add tracked")
|
||||
(self.repo / "tracked.txt").write_text("v2", encoding="utf-8")
|
||||
_git(self.repo, "add", "tracked.txt")
|
||||
git_state.unstage(self.repo, ["tracked.txt"])
|
||||
status = git_state.repo_status(self.repo)
|
||||
self.assertNotIn("tracked.txt", [e["path"] for e in status["staged"]])
|
||||
self.assertIn("tracked.txt", [e["path"] for e in status["modified"]])
|
||||
|
||||
def test_stage_rejects_traversal_path(self) -> None:
|
||||
with self.assertRaises(git_state.GitStateError):
|
||||
git_state.stage(self.repo, ["../outside"])
|
||||
|
||||
|
||||
class CommitTests(_MutationBase):
|
||||
def test_commit_creates_a_real_commit(self) -> None:
|
||||
before = self._head()
|
||||
(self.repo / "feature.txt").write_text("feature", encoding="utf-8")
|
||||
git_state.stage(self.repo, ["feature.txt"])
|
||||
git_state.commit(self.repo, "add feature")
|
||||
after = self._head()
|
||||
self.assertNotEqual(before, after)
|
||||
message = _git(self.repo, "log", "-1", "--format=%s")
|
||||
self.assertEqual("add feature", message)
|
||||
|
||||
def test_commit_rejects_empty_message(self) -> None:
|
||||
(self.repo / "feature.txt").write_text("feature", encoding="utf-8")
|
||||
git_state.stage(self.repo, ["feature.txt"])
|
||||
with self.assertRaises(git_state.GitStateError):
|
||||
git_state.commit(self.repo, " ")
|
||||
|
||||
def test_commit_returns_fresh_status(self) -> None:
|
||||
(self.repo / "feature.txt").write_text("feature", encoding="utf-8")
|
||||
git_state.stage(self.repo, ["feature.txt"])
|
||||
result = git_state.commit(self.repo, "add feature")
|
||||
self.assertEqual(result["head"], self._head())
|
||||
self.assertEqual(result["status"]["counts"]["staged"], 0)
|
||||
self.assertEqual(result["status"]["counts"]["modified"], 0)
|
||||
|
||||
def test_commit_selected_commits_only_given_paths(self) -> None:
|
||||
(self.repo / "kept.txt").write_text("keep", encoding="utf-8")
|
||||
(self.repo / "skip.txt").write_text("skip", encoding="utf-8")
|
||||
_git(self.repo, "add", "kept.txt", "skip.txt")
|
||||
git_state.commit_selected(self.repo, "commit kept only", ["kept.txt"])
|
||||
head_files = _git(self.repo, "ls-tree", "-r", "--name-only", "HEAD")
|
||||
self.assertIn("kept.txt", head_files)
|
||||
self.assertNotIn("skip.txt", head_files)
|
||||
|
||||
def test_commit_selected_returns_fresh_status(self) -> None:
|
||||
(self.repo / "a.txt").write_text("a", encoding="utf-8")
|
||||
git_state.stage(self.repo, ["a.txt"])
|
||||
result = git_state.commit_selected(self.repo, "commit a", ["a.txt"])
|
||||
self.assertEqual(result["head"], self._head())
|
||||
self.assertEqual(result["status"]["counts"]["staged"], 0)
|
||||
|
||||
def test_commit_selected_rejects_empty_message(self) -> None:
|
||||
(self.repo / "a.txt").write_text("a", encoding="utf-8")
|
||||
git_state.stage(self.repo, ["a.txt"])
|
||||
with self.assertRaises(git_state.GitError):
|
||||
git_state.commit_selected(self.repo, "", ["a.txt"])
|
||||
|
||||
|
||||
class DiscardConfirmationTests(_MutationBase):
|
||||
def test_discard_requires_confirmation_string(self) -> None:
|
||||
(self.repo / "tracked.txt").write_text("dirty", encoding="utf-8")
|
||||
with self.assertRaises(git_state.GitError) as ctx:
|
||||
git_state.discard(self.repo, ["tracked.txt"], confirmation="")
|
||||
self.assertIn("confirmation", str(ctx.exception).lower())
|
||||
|
||||
def test_discard_rejects_wrong_confirmation(self) -> None:
|
||||
(self.repo / "tracked.txt").write_text("dirty", encoding="utf-8")
|
||||
with self.assertRaises(git_state.GitError):
|
||||
git_state.discard(self.repo, ["tracked.txt"], confirmation="wrong")
|
||||
|
||||
def test_discard_with_confirmation_reverts_tracked_changes(self) -> None:
|
||||
(self.repo / "tracked.txt").write_text("v1", encoding="utf-8")
|
||||
_git(self.repo, "add", "tracked.txt")
|
||||
_git(self.repo, "commit", "-q", "-m", "add tracked")
|
||||
(self.repo / "tracked.txt").write_text("v2", encoding="utf-8")
|
||||
git_state.discard(self.repo, ["tracked.txt"], confirmation=git_state.CONFIRM_DISCARD)
|
||||
content = (self.repo / "tracked.txt").read_text(encoding="utf-8")
|
||||
self.assertEqual("v1", content)
|
||||
|
||||
def test_discard_delete_untracked_removes_untracked(self) -> None:
|
||||
(self.repo / "untracked.txt").write_text("new", encoding="utf-8")
|
||||
git_state.discard(
|
||||
self.repo,
|
||||
["untracked.txt"],
|
||||
confirmation=git_state.CONFIRM_DISCARD,
|
||||
delete_untracked=True,
|
||||
)
|
||||
self.assertFalse((self.repo / "untracked.txt").exists())
|
||||
|
||||
def test_discard_returns_fresh_status(self) -> None:
|
||||
(self.repo / "tracked.txt").write_text("v1", encoding="utf-8")
|
||||
_git(self.repo, "add", "tracked.txt")
|
||||
_git(self.repo, "commit", "-q", "-m", "add tracked")
|
||||
(self.repo / "tracked.txt").write_text("v2", encoding="utf-8")
|
||||
result = git_state.discard(
|
||||
self.repo,
|
||||
["tracked.txt"],
|
||||
confirmation=git_state.CONFIRM_DISCARD,
|
||||
)
|
||||
self.assertEqual(result["status"]["counts"]["modified"], 0)
|
||||
|
||||
|
||||
class FetchPullPushTests(_MutationBase):
|
||||
def setUp(self) -> None:
|
||||
super().setUp()
|
||||
self.remote = _init_bare_remote(self.base, "origin-bare")
|
||||
_git(self.repo, "remote", "add", "origin", str(self.remote))
|
||||
_git(self.repo, "push", "-q", "origin", "main")
|
||||
_git(self.repo, "branch", "-q", "--set-upstream-to=origin/main", "main")
|
||||
|
||||
def test_fetch_rejects_unknown_and_option_like_remote(self) -> None:
|
||||
for remote in ("https://example.invalid/repo.git", "--all", "missing"):
|
||||
with self.subTest(remote=remote):
|
||||
with self.assertRaisesRegex(git_state.GitError, "remote"):
|
||||
git_state.fetch(self.repo, remote)
|
||||
|
||||
def test_pull_and_push_reject_option_like_branch(self) -> None:
|
||||
with self.assertRaisesRegex(git_state.GitError, "branch"):
|
||||
git_state.pull(self.repo, "origin", "--all")
|
||||
with self.assertRaisesRegex(git_state.GitError, "branch"):
|
||||
git_state.push(self.repo, "origin", "--mirror", git_state.CONFIRM_PUSH)
|
||||
|
||||
def test_fetch_updates_remote_refs(self) -> None:
|
||||
# Advance the remote from a descendant clone (not an independent repo:
|
||||
# an independent root has its own "initial commit" SHA, and when it
|
||||
# lands in a different second than the remote's initial commit the push
|
||||
# is rejected as non-fast-forward, flaking the test).
|
||||
other = self.base / "other"
|
||||
_run(["git", "clone", "-q", str(self.remote), str(other)], self.base)
|
||||
_git(other, "config", "user.email", "test@example.com")
|
||||
_git(other, "config", "user.name", "Test User")
|
||||
(other / "remote.txt").write_text("remote change fetch", encoding="utf-8")
|
||||
_git(other, "add", "remote.txt")
|
||||
_git(other, "commit", "-q", "-m", "remote change fetch")
|
||||
_git(other, "push", "-q", "origin", "main")
|
||||
|
||||
git_state.fetch(self.repo, "origin")
|
||||
remote_main = _git(self.repo, "rev-parse", "origin/main")
|
||||
self.assertNotEqual(remote_main, self._head())
|
||||
|
||||
def test_pull_brings_remote_commits(self) -> None:
|
||||
other = self.base / "other"
|
||||
_run(["git", "clone", "-q", str(self.remote), str(other)], self.base)
|
||||
_git(other, "config", "user.email", "test@example.com")
|
||||
_git(other, "config", "user.name", "Test User")
|
||||
(other / "remote.txt").write_text("remote change pull", encoding="utf-8")
|
||||
_git(other, "add", "remote.txt")
|
||||
_git(other, "commit", "-q", "-m", "remote change pull")
|
||||
_git(other, "push", "-q", "origin", "main")
|
||||
|
||||
git_state.pull(self.repo, "origin", "main")
|
||||
self.assertIn("remote.txt", _git(self.repo, "ls-tree", "-r", "--name-only", "HEAD"))
|
||||
|
||||
def test_push_requires_confirmation(self) -> None:
|
||||
(self.repo / "feature.txt").write_text("f", encoding="utf-8")
|
||||
git_state.stage(self.repo, ["feature.txt"])
|
||||
git_state.commit(self.repo, "feature")
|
||||
with self.assertRaises(git_state.GitError) as ctx:
|
||||
git_state.push(self.repo, "origin", "main", confirmation="")
|
||||
self.assertIn("confirmation", str(ctx.exception).lower())
|
||||
|
||||
def test_push_rejects_wrong_confirmation(self) -> None:
|
||||
(self.repo / "feature.txt").write_text("f", encoding="utf-8")
|
||||
git_state.stage(self.repo, ["feature.txt"])
|
||||
git_state.commit(self.repo, "feature")
|
||||
with self.assertRaises(git_state.GitError):
|
||||
git_state.push(self.repo, "origin", "main", confirmation="nope")
|
||||
|
||||
def test_push_with_confirmation_updates_remote(self) -> None:
|
||||
(self.repo / "feature.txt").write_text("f", encoding="utf-8")
|
||||
git_state.stage(self.repo, ["feature.txt"])
|
||||
git_state.commit(self.repo, "feature")
|
||||
git_state.push(self.repo, "origin", "main", confirmation=git_state.CONFIRM_PUSH)
|
||||
remote_head = _run(["git", "ls-remote", str(self.remote), "refs/heads/main"], self.remote)
|
||||
self.assertIn(self._head(), remote_head)
|
||||
|
||||
def test_pull_with_local_changes_yields_structured_dirty_error(self) -> None:
|
||||
# Repo tracks a file, then the remote advances it. A local uncommitted
|
||||
# edit to that file must never be clobbered by pull → structured dirty.
|
||||
(self.repo / "tracked.txt").write_text("base", encoding="utf-8")
|
||||
_git(self.repo, "add", "tracked.txt")
|
||||
_git(self.repo, "commit", "-q", "-m", "add tracked")
|
||||
_git(self.repo, "push", "-q", "origin", "main")
|
||||
|
||||
# Advance the remote from a clean clone whose history descends from it.
|
||||
other = self.base / "other"
|
||||
_run(["git", "clone", "-q", str(self.remote), str(other)], self.base)
|
||||
_git(other, "config", "user.email", "test@example.com")
|
||||
_git(other, "config", "user.name", "Test User")
|
||||
(other / "tracked.txt").write_text("remote", encoding="utf-8")
|
||||
_git(other, "add", "tracked.txt")
|
||||
_git(other, "commit", "-q", "-m", "remote tracked")
|
||||
_git(other, "push", "-q", "origin", "main")
|
||||
|
||||
# Local uncommitted change to tracked.txt → git refuses to clobber.
|
||||
(self.repo / "tracked.txt").write_text("uncommitted", encoding="utf-8")
|
||||
with self.assertRaises(git_state.GitError) as ctx:
|
||||
git_state.pull(self.repo, "origin", "main")
|
||||
self.assertEqual("dirty", ctx.exception.code)
|
||||
|
||||
def test_push_auth_failure_maps_to_auth_error(self) -> None:
|
||||
(self.repo / "feature.txt").write_text("f", encoding="utf-8")
|
||||
git_state.stage(self.repo, ["feature.txt"])
|
||||
git_state.commit(self.repo, "feature")
|
||||
# Point origin at a host that will reject credentials.
|
||||
_git(self.repo, "remote", "set-url", "origin", "https://invalid.invalid/x.git")
|
||||
with self.assertRaises(git_state.GitError) as ctx:
|
||||
git_state.push(self.repo, "origin", "main", confirmation=git_state.CONFIRM_PUSH)
|
||||
self.assertIn(ctx.exception.code, ("auth", "network"))
|
||||
|
||||
|
||||
class CheckoutTests(_MutationBase):
|
||||
def test_checkout_rejects_option_like_ref(self) -> None:
|
||||
with self.assertRaisesRegex(git_state.GitError, "git option"):
|
||||
git_state.checkout(self.repo, "--detach")
|
||||
|
||||
def test_checkout_switches_branch(self) -> None:
|
||||
_git(self.repo, "checkout", "-q", "-b", "feature")
|
||||
_git(self.repo, "checkout", "-q", "main")
|
||||
git_state.checkout(self.repo, "feature", confirmation="")
|
||||
self.assertEqual("feature", _git(self.repo, "symbolic-ref", "--short", "HEAD"))
|
||||
|
||||
def test_checkout_new_branch_creates_branch(self) -> None:
|
||||
git_state.checkout(
|
||||
self.repo,
|
||||
"main",
|
||||
new_branch="exp",
|
||||
confirmation="",
|
||||
)
|
||||
self.assertEqual("exp", _git(self.repo, "symbolic-ref", "--short", "HEAD"))
|
||||
|
||||
def test_checkout_new_branch_uses_requested_start_point(self) -> None:
|
||||
_git(self.repo, "checkout", "-q", "-b", "feature")
|
||||
(self.repo / "feature-only.txt").write_text("feature", encoding="utf-8")
|
||||
_git(self.repo, "add", "feature-only.txt")
|
||||
_git(self.repo, "commit", "-q", "-m", "feature")
|
||||
_git(self.repo, "checkout", "-q", "main")
|
||||
|
||||
git_state.checkout(self.repo, "feature", new_branch="from-feature")
|
||||
|
||||
self.assertTrue((self.repo / "feature-only.txt").exists())
|
||||
|
||||
def test_checkout_clean_tree_needs_no_confirmation(self) -> None:
|
||||
_git(self.repo, "checkout", "-q", "-b", "feature")
|
||||
_git(self.repo, "checkout", "-q", "main")
|
||||
git_state.checkout(self.repo, "feature", confirmation="")
|
||||
self.assertEqual("feature", _git(self.repo, "symbolic-ref", "--short", "HEAD"))
|
||||
|
||||
def test_checkout_dirty_tree_requires_confirmation(self) -> None:
|
||||
_git(self.repo, "checkout", "-q", "-b", "feature")
|
||||
_git(self.repo, "checkout", "-q", "main")
|
||||
(self.repo / "tracked.txt").write_text("dirty", encoding="utf-8")
|
||||
with self.assertRaises(git_state.GitError) as ctx:
|
||||
git_state.checkout(self.repo, "feature", confirmation="")
|
||||
self.assertIn("confirmation", str(ctx.exception).lower())
|
||||
|
||||
def test_checkout_dirty_tree_wrong_confirmation_rejected(self) -> None:
|
||||
_git(self.repo, "checkout", "-q", "-b", "feature")
|
||||
_git(self.repo, "checkout", "-q", "main")
|
||||
(self.repo / "tracked.txt").write_text("dirty", encoding="utf-8")
|
||||
with self.assertRaises(git_state.GitError):
|
||||
git_state.checkout(self.repo, "feature", confirmation="wrong")
|
||||
|
||||
def test_checkout_dirty_tree_with_confirmation_switches(self) -> None:
|
||||
_git(self.repo, "checkout", "-q", "-b", "feature")
|
||||
_git(self.repo, "checkout", "-q", "main")
|
||||
(self.repo / "tracked.txt").write_text("dirty", encoding="utf-8")
|
||||
git_state.checkout(
|
||||
self.repo,
|
||||
"feature",
|
||||
confirmation=git_state.CONFIRM_DIRTY_CHECKOUT,
|
||||
)
|
||||
self.assertEqual("feature", _git(self.repo, "symbolic-ref", "--short", "HEAD"))
|
||||
|
||||
def test_checkout_track_sets_upstream(self) -> None:
|
||||
self.remote = _init_bare_remote(self.base, "remote-bare.git")
|
||||
_git(self.repo, "remote", "add", "origin", str(self.remote))
|
||||
_git(self.repo, "push", "-q", "origin", "main")
|
||||
# Create origin/feature remotely via a second clone.
|
||||
other = _init_repo(self.base, "other")
|
||||
_git(other, "remote", "add", "origin", str(self.remote))
|
||||
_git(other, "checkout", "-q", "-b", "feature")
|
||||
(other / "feature.txt").write_text("f", encoding="utf-8")
|
||||
_git(other, "add", "feature.txt")
|
||||
_git(other, "commit", "-q", "-m", "feature")
|
||||
_git(other, "push", "-q", "origin", "feature")
|
||||
# Bring the remote-tracking ref into this repo, then track it.
|
||||
git_state.fetch(self.repo, "origin")
|
||||
git_state.checkout(
|
||||
self.repo,
|
||||
"origin/feature",
|
||||
confirmation="",
|
||||
track=True,
|
||||
)
|
||||
current = _git(self.repo, "symbolic-ref", "--short", "HEAD")
|
||||
self.assertEqual("feature", current)
|
||||
upstream = _git(self.repo, "rev-parse", "--abbrev-ref", "feature@{upstream}")
|
||||
self.assertEqual("origin/feature", upstream)
|
||||
|
||||
|
||||
class AllowlistTests(_MutationBase):
|
||||
def test_mutations_reject_unknown_repo(self) -> None:
|
||||
with self.assertRaises(git_state.GitStateError):
|
||||
git_state.stage(Path("/nonexistent"), ["x.txt"])
|
||||
with self.assertRaises(git_state.GitStateError):
|
||||
git_state.discard(
|
||||
Path("/nonexistent"),
|
||||
["x.txt"],
|
||||
confirmation=git_state.CONFIRM_DISCARD,
|
||||
)
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
unittest.main()
|
||||
@@ -46,14 +46,17 @@ class MobilePluginStoreTests(unittest.TestCase):
|
||||
|
||||
manifest = self.store.manifest()
|
||||
self.assertEqual("hermes-relay", manifest["id"])
|
||||
contribution = manifest["contributions"][0]
|
||||
contribution = next(c for c in manifest["contributions"] if c["id"] == "daily-brief")
|
||||
self.assertEqual("Draft: Daily Brief", contribution["title"])
|
||||
self.assertEqual("mobile/pages/daily-brief", contribution["document"]["path"])
|
||||
self.assertEqual(_document(), self.store.get("daily-brief")["document"])
|
||||
|
||||
published = self.store.publish("daily-brief")
|
||||
self.assertEqual("published", published["status"])
|
||||
self.assertEqual("Daily Brief", self.store.manifest()["contributions"][0]["title"])
|
||||
published_contribution = next(
|
||||
c for c in self.store.manifest()["contributions"] if c["id"] == "daily-brief"
|
||||
)
|
||||
self.assertEqual("Daily Brief", published_contribution["title"])
|
||||
|
||||
self.assertEqual({"ok": True, "id": "daily-brief"}, self.store.remove("daily-brief"))
|
||||
self.assertEqual([], self.store.list())
|
||||
@@ -81,6 +84,11 @@ class MobilePluginStoreTests(unittest.TestCase):
|
||||
document={"schemaVersion": 1, "pages": []},
|
||||
)
|
||||
|
||||
def test_rejects_reserved_git_id(self) -> None:
|
||||
with self.assertRaisesRegex(MobilePluginStoreError, "reserved"):
|
||||
self.store.draft("git", title="Shadow", description="", document=_document())
|
||||
self.assertEqual(["git"], [item["id"] for item in self.store.manifest()["contributions"]])
|
||||
|
||||
def test_listing_omits_document_payload(self) -> None:
|
||||
self.store.draft("compact", title="Compact", description="", document=_document())
|
||||
self.assertNotIn("document", self.store.list()[0])
|
||||
|
||||
@@ -0,0 +1,367 @@
|
||||
"""Client-reported supervised-mode metadata is bounded and informational."""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import json
|
||||
import tempfile
|
||||
import unittest
|
||||
from pathlib import Path
|
||||
from unittest.mock import AsyncMock
|
||||
|
||||
from aiohttp import web
|
||||
from aiohttp.test_utils import AioHTTPTestCase
|
||||
|
||||
from plugin.relay.auth import (
|
||||
SUPERVISED_CAPABILITY_MAX_COUNT,
|
||||
SUPERVISED_PROFILE_LABEL_MAX_LENGTH,
|
||||
SessionManager,
|
||||
SupervisedMode,
|
||||
parse_supervised_mode,
|
||||
)
|
||||
from plugin.relay.config import RelayConfig
|
||||
from plugin.relay.server import RelayServer, _build_auth_ok_payload, create_app
|
||||
|
||||
|
||||
class SupervisedModeParsingTests(unittest.TestCase):
|
||||
def test_valid_report_is_normalized_and_deduplicated(self) -> None:
|
||||
parsed = parse_supervised_mode(
|
||||
{
|
||||
"active": True,
|
||||
"profile_label": " Learning ",
|
||||
"capabilities": ["text_chat", "voice", "voice"],
|
||||
"enforcement_owner": "server", # client cannot override it
|
||||
}
|
||||
)
|
||||
|
||||
self.assertEqual(
|
||||
parsed,
|
||||
SupervisedMode(True, "Learning", ("text_chat", "voice")),
|
||||
)
|
||||
self.assertEqual(parsed.to_public_dict()["enforcement_owner"], "android_client")
|
||||
|
||||
def test_missing_inactive_and_malformed_reports_are_ordinary(self) -> None:
|
||||
invalid = (
|
||||
None,
|
||||
[],
|
||||
{"active": False, "profile_label": "Learning"},
|
||||
{"active": "true", "profile_label": "Learning"},
|
||||
{"active": True, "profile_label": 7},
|
||||
{"active": True, "profile_label": "Learning", "capabilities": {}},
|
||||
{"active": True, "profile_label": "Learning\n", "capabilities": []},
|
||||
{
|
||||
"active": True,
|
||||
"profile_label": "Learning",
|
||||
"capabilities": ["model:gpt-private"],
|
||||
},
|
||||
)
|
||||
for value in invalid:
|
||||
with self.subTest(value=value):
|
||||
self.assertEqual(parse_supervised_mode(value), SupervisedMode())
|
||||
|
||||
def test_oversized_report_is_ordinary(self) -> None:
|
||||
self.assertEqual(
|
||||
parse_supervised_mode(
|
||||
{
|
||||
"active": True,
|
||||
"profile_label": "x" * (SUPERVISED_PROFILE_LABEL_MAX_LENGTH + 1),
|
||||
"capabilities": [],
|
||||
}
|
||||
),
|
||||
SupervisedMode(),
|
||||
)
|
||||
self.assertEqual(
|
||||
parse_supervised_mode(
|
||||
{
|
||||
"active": True,
|
||||
"profile_label": "Learning",
|
||||
"capabilities": ["voice"] * (SUPERVISED_CAPABILITY_MAX_COUNT + 1),
|
||||
}
|
||||
),
|
||||
SupervisedMode(),
|
||||
)
|
||||
|
||||
|
||||
class SupervisedModePersistenceTests(unittest.TestCase):
|
||||
def test_active_report_and_trusted_device_survive_restart_and_refresh(self) -> None:
|
||||
with tempfile.TemporaryDirectory() as tmp:
|
||||
path = Path(tmp) / "sessions.json"
|
||||
mode = SupervisedMode(
|
||||
True,
|
||||
"Learning",
|
||||
("text_chat", "attachments", "voice"),
|
||||
)
|
||||
manager = SessionManager(persistence_path=path)
|
||||
session = manager.create_session(
|
||||
"Managed phone",
|
||||
"managed-phone-id",
|
||||
supervised_mode=mode,
|
||||
issue_refresh_token=True,
|
||||
)
|
||||
refresh_token = session.refresh_token
|
||||
assert refresh_token is not None
|
||||
|
||||
reloaded = SessionManager(persistence_path=path)
|
||||
restored = reloaded.get_session(session.token)
|
||||
self.assertIsNotNone(restored)
|
||||
assert restored is not None
|
||||
self.assertEqual(restored.supervised_mode, mode)
|
||||
|
||||
reloaded._sessions.clear()
|
||||
replacement = reloaded.refresh_session(
|
||||
refresh_token,
|
||||
device_name="Managed phone",
|
||||
device_id="managed-phone-id",
|
||||
)
|
||||
self.assertIsNotNone(replacement)
|
||||
assert replacement is not None
|
||||
self.assertEqual(replacement.supervised_mode, mode)
|
||||
|
||||
def test_legacy_and_invalid_disk_rows_load_as_ordinary(self) -> None:
|
||||
with tempfile.TemporaryDirectory() as tmp:
|
||||
path = Path(tmp) / "sessions.json"
|
||||
manager = SessionManager(persistence_path=path)
|
||||
session = manager.create_session("Legacy phone", "legacy-id")
|
||||
payload = json.loads(path.read_text(encoding="utf-8"))
|
||||
payload["sessions"][0]["supervised_mode"] = {
|
||||
"active": True,
|
||||
"profile_label": "Learning",
|
||||
"capabilities": ["unknown_future_value"],
|
||||
}
|
||||
path.write_text(json.dumps(payload), encoding="utf-8")
|
||||
|
||||
reloaded = SessionManager(persistence_path=path)
|
||||
restored = reloaded.get_session(session.token)
|
||||
self.assertIsNotNone(restored)
|
||||
assert restored is not None
|
||||
self.assertEqual(restored.supervised_mode, SupervisedMode())
|
||||
|
||||
payload["sessions"][0].pop("supervised_mode")
|
||||
path.write_text(json.dumps(payload), encoding="utf-8")
|
||||
legacy = SessionManager(persistence_path=path).get_session(session.token)
|
||||
self.assertIsNotNone(legacy)
|
||||
assert legacy is not None
|
||||
self.assertEqual(legacy.supervised_mode, SupervisedMode())
|
||||
|
||||
def test_auth_ok_only_emits_active_client_report(self) -> None:
|
||||
server = RelayServer(RelayConfig())
|
||||
ordinary = server.sessions.create_session("Phone", "ordinary-id")
|
||||
self.assertNotIn("supervised_mode", _build_auth_ok_payload(ordinary, server))
|
||||
|
||||
managed = server.sessions.create_session(
|
||||
"Managed phone",
|
||||
"managed-id",
|
||||
supervised_mode=SupervisedMode(True, "Learning", ("voice",)),
|
||||
)
|
||||
report = _build_auth_ok_payload(managed, server)["supervised_mode"]
|
||||
self.assertEqual(report["profile_label"], "Learning")
|
||||
self.assertEqual(report["capabilities"], ["voice"])
|
||||
self.assertEqual(report["enforcement_owner"], "android_client")
|
||||
|
||||
|
||||
class SupervisedModeSessionRoutesTests(AioHTTPTestCase):
|
||||
async def get_application(self) -> web.Application:
|
||||
return create_app(RelayConfig())
|
||||
|
||||
async def test_list_exposes_active_report_and_omits_ordinary_report(self) -> None:
|
||||
ordinary = self.app["server"].sessions.create_session("Phone", "ordinary-id")
|
||||
self.app["server"].sessions.create_session(
|
||||
"Managed phone",
|
||||
"managed-id",
|
||||
supervised_mode=SupervisedMode(
|
||||
True, "Learning", ("text_chat", "attachments")
|
||||
),
|
||||
)
|
||||
|
||||
response = await self.client.get(
|
||||
"/sessions", headers={"Authorization": f"Bearer {ordinary.token}"}
|
||||
)
|
||||
self.assertEqual(response.status, 200)
|
||||
rows = {row["device_name"]: row for row in (await response.json())["sessions"]}
|
||||
self.assertNotIn("supervised_mode", rows["Phone"])
|
||||
self.assertEqual(
|
||||
rows["Managed phone"]["supervised_mode"],
|
||||
{
|
||||
"active": True,
|
||||
"profile_label": "Learning",
|
||||
"capabilities": ["text_chat", "attachments"],
|
||||
"enforcement_owner": "android_client",
|
||||
},
|
||||
)
|
||||
|
||||
async def test_pairing_auth_records_and_returns_client_report(self) -> None:
|
||||
response = await self.client.post(
|
||||
"/pairing/register", json={"code": "MODE01"}
|
||||
)
|
||||
self.assertEqual(response.status, 200, await response.text())
|
||||
|
||||
socket = await self.client.ws_connect("/ws")
|
||||
await socket.send_json(
|
||||
{
|
||||
"channel": "system",
|
||||
"type": "auth",
|
||||
"payload": {
|
||||
"pairing_code": "MODE01",
|
||||
"device_name": "Managed phone",
|
||||
"device_id": "managed-auth-id",
|
||||
"client_surface": "android",
|
||||
"supervised_mode": {
|
||||
"active": True,
|
||||
"profile_label": "Learning",
|
||||
"capabilities": ["text_chat", "voice"],
|
||||
},
|
||||
},
|
||||
}
|
||||
)
|
||||
envelope = await socket.receive_json()
|
||||
await socket.close()
|
||||
|
||||
self.assertEqual(envelope["type"], "auth.ok")
|
||||
report = envelope["payload"]["supervised_mode"]
|
||||
self.assertEqual(report["profile_label"], "Learning")
|
||||
self.assertEqual(report["capabilities"], ["text_chat", "voice"])
|
||||
self.assertEqual(report["enforcement_owner"], "android_client")
|
||||
stored = self.app["server"].sessions.get_session(
|
||||
envelope["payload"]["session_token"]
|
||||
)
|
||||
self.assertIsNotNone(stored)
|
||||
assert stored is not None
|
||||
self.assertTrue(stored.supervised_mode.active)
|
||||
|
||||
reconnect = await self.client.ws_connect("/ws")
|
||||
await reconnect.send_json(
|
||||
{
|
||||
"channel": "system",
|
||||
"type": "auth",
|
||||
"payload": {
|
||||
"session_token": stored.token,
|
||||
"device_id": "managed-auth-id",
|
||||
},
|
||||
}
|
||||
)
|
||||
ordinary_envelope = await reconnect.receive_json()
|
||||
await reconnect.close()
|
||||
self.assertEqual(ordinary_envelope["type"], "auth.ok")
|
||||
self.assertNotIn("supervised_mode", ordinary_envelope["payload"])
|
||||
refreshed = self.app["server"].sessions.get_session(stored.token)
|
||||
self.assertIsNotNone(refreshed)
|
||||
assert refreshed is not None
|
||||
self.assertFalse(refreshed.supervised_mode.active)
|
||||
|
||||
async def test_authenticated_live_update_is_owned_acked_and_persisted(self) -> None:
|
||||
manager = self.app["server"].sessions
|
||||
original = SupervisedMode(True, "Learning", ("text_chat", "voice"))
|
||||
session = manager.create_session(
|
||||
"Managed phone",
|
||||
"managed-live-id",
|
||||
supervised_mode=original,
|
||||
issue_refresh_token=True,
|
||||
)
|
||||
other = manager.create_session(
|
||||
"Other phone",
|
||||
"other-id",
|
||||
supervised_mode=SupervisedMode(True, "Other", ("text_chat",)),
|
||||
)
|
||||
|
||||
socket = await self.client.ws_connect("/ws")
|
||||
await socket.send_json(
|
||||
{
|
||||
"channel": "system",
|
||||
"type": "auth",
|
||||
"payload": {
|
||||
"session_token": session.token,
|
||||
"device_id": session.device_id,
|
||||
"supervised_mode": original.to_public_dict(),
|
||||
},
|
||||
}
|
||||
)
|
||||
self.assertEqual((await socket.receive_json())["type"], "auth.ok")
|
||||
|
||||
await socket.send_json(
|
||||
{
|
||||
"channel": "system",
|
||||
"type": "supervised.update",
|
||||
"id": "update-active",
|
||||
"payload": {
|
||||
# Must be ignored: ownership comes from the authenticated
|
||||
# socket, not any selector supplied in the update body.
|
||||
"session_token": other.token,
|
||||
"supervised_mode": {
|
||||
"active": True,
|
||||
"profile_label": "School",
|
||||
"capabilities": ["text_chat", "attachments"],
|
||||
},
|
||||
},
|
||||
}
|
||||
)
|
||||
ack = await socket.receive_json()
|
||||
self.assertEqual(ack["type"], "supervised.updated")
|
||||
self.assertEqual(ack["id"], "update-active")
|
||||
self.assertEqual(
|
||||
ack["payload"]["supervised_mode"],
|
||||
{
|
||||
"active": True,
|
||||
"profile_label": "School",
|
||||
"capabilities": ["text_chat", "attachments"],
|
||||
"enforcement_owner": "android_client",
|
||||
},
|
||||
)
|
||||
self.assertEqual(
|
||||
manager.get_session(session.token).supervised_mode,
|
||||
SupervisedMode(True, "School", ("text_chat", "attachments")),
|
||||
)
|
||||
self.assertEqual(manager.get_session(other.token).supervised_mode.profile_label, "Other")
|
||||
self.assertTrue(
|
||||
any(
|
||||
device.device_id == session.device_id
|
||||
and device.supervised_mode.profile_label == "School"
|
||||
for device in manager._trusted_devices.values()
|
||||
)
|
||||
)
|
||||
|
||||
await socket.send_json(
|
||||
{
|
||||
"channel": "system",
|
||||
"type": "supervised.update",
|
||||
"id": "update-inactive",
|
||||
"payload": {"supervised_mode": {"active": False}},
|
||||
}
|
||||
)
|
||||
cleared = await socket.receive_json()
|
||||
await socket.close()
|
||||
self.assertEqual(cleared["type"], "supervised.updated")
|
||||
self.assertEqual(cleared["id"], "update-inactive")
|
||||
self.assertEqual(
|
||||
cleared["payload"]["supervised_mode"],
|
||||
{"active": False, "enforcement_owner": "android_client"},
|
||||
)
|
||||
self.assertFalse(manager.get_session(session.token).supervised_mode.active)
|
||||
self.assertTrue(
|
||||
all(
|
||||
not device.supervised_mode.active
|
||||
for device in manager._trusted_devices.values()
|
||||
if device.device_id == session.device_id
|
||||
)
|
||||
)
|
||||
|
||||
async def test_revoke_closes_connected_relay_socket(self) -> None:
|
||||
caller = self.app["server"].sessions.create_session("Caller", "caller-id")
|
||||
target = self.app["server"].sessions.create_session(
|
||||
"Managed phone",
|
||||
"managed-id",
|
||||
supervised_mode=SupervisedMode(True, "Learning", ("text_chat",)),
|
||||
)
|
||||
socket = AsyncMock()
|
||||
socket.closed = False
|
||||
self.app["server"]._clients[socket] = target.token
|
||||
|
||||
response = await self.client.delete(
|
||||
f"/sessions/{target.token[:8]}",
|
||||
headers={"Authorization": f"Bearer {caller.token}"},
|
||||
)
|
||||
|
||||
self.assertEqual(response.status, 200)
|
||||
self.assertIsNone(self.app["server"].sessions.get_session(target.token))
|
||||
socket.close.assert_awaited_once()
|
||||
self.assertEqual(
|
||||
socket.close.await_args.kwargs["message"], b"Relay session revoked"
|
||||
)
|
||||
@@ -220,6 +220,7 @@ export default defineConfig({
|
||||
{ text: 'Remote access', link: '/guide/remote-access' },
|
||||
{ text: 'Release tracks', link: '/guide/release-tracks' },
|
||||
{ text: 'Chat', link: '/guide/chat' },
|
||||
{ text: 'Supervised Mode', link: '/guide/supervised-mode' },
|
||||
{ text: 'Sessions', link: '/guide/sessions' },
|
||||
{ text: 'Troubleshooting', link: '/guide/troubleshooting' },
|
||||
],
|
||||
|
||||
@@ -98,6 +98,12 @@ Google Play builds do not include AccessibilityService-backed screen reading or
|
||||
| Message history | Loads from server on session switch |
|
||||
| Persistence | Last session resumes on app restart |
|
||||
|
||||
## Supervised access
|
||||
|
||||
| Feature | Status | Description |
|
||||
|---------|--------|-------------|
|
||||
| Android Supervised Mode | Planned | Parent-controlled, profile-pinned restricted client interface; not a server-enforced child account. See the [Supervised Mode guide](/guide/supervised-mode). |
|
||||
|
||||
## Analytics
|
||||
|
||||
| Feature | Description |
|
||||
|
||||
@@ -53,6 +53,7 @@ voice routes. Sideload builds additionally expose Android Device Control routes.
|
||||
- [Quick Start](/guide/quick-start) — Recommended Android + Relay setup
|
||||
- [Installation & Setup](/guide/getting-started) — Builds, manual setup, and fallbacks
|
||||
- [Chat Guide](/guide/chat) — Using the chat interface
|
||||
- [Supervised Mode](/guide/supervised-mode) — Planned parent-controlled, profile-pinned Android interface
|
||||
- [Sessions](/guide/sessions) — Managing conversations
|
||||
- [Features](/features/) — All features at a glance
|
||||
- [Architecture](/architecture/) — How it works under the hood
|
||||
|
||||
@@ -0,0 +1,226 @@
|
||||
---
|
||||
title: Supervised Mode
|
||||
description: Configure a parent-controlled, profile-pinned Hermes-Relay Android experience
|
||||
---
|
||||
|
||||
# Supervised Mode
|
||||
|
||||
::: warning Physical certification pending
|
||||
Supervised Mode is implemented in the Android client, but it has not completed
|
||||
physical managed-device certification. Treat it as experimental and do not call
|
||||
an installation child-ready until the managed-device checks below pass.
|
||||
:::
|
||||
|
||||
Supervised Mode is a parent-controlled, restricted view of Hermes-Relay for
|
||||
Android. It is intended for a parent or guardian who has already created and
|
||||
reviewed a suitably restricted Hermes profile and wants the phone app to expose
|
||||
only an approved set of chat features.
|
||||
|
||||
It is a client-interface control, not a child account or a server security
|
||||
boundary. The selected Hermes profile still controls the agent's prompt, model,
|
||||
tools, provider credentials, content behavior, and server-side data.
|
||||
|
||||
## Before enabling it
|
||||
|
||||
Prepare the Hermes profile first. At minimum, review its:
|
||||
|
||||
- identity and system instructions;
|
||||
- model and provider safety settings;
|
||||
- enabled skills, tools, and external services;
|
||||
- memory, files, schedules, and existing sessions;
|
||||
- voice and image-generation providers;
|
||||
- retention and parental-review expectations.
|
||||
|
||||
Supervised Mode cannot make an unrestricted profile safe by hiding controls on
|
||||
the phone. Ordinary prose can still cause the configured agent to use whatever
|
||||
server-side capabilities that profile has.
|
||||
|
||||
## Set it up
|
||||
|
||||
From full Android Settings, the parent:
|
||||
|
||||
1. Open **Settings → Advanced → Supervised Mode** for the active Hermes
|
||||
Connection.
|
||||
2. Choose one existing named profile. Android requires a secure device screen
|
||||
lock before the mode can be enabled.
|
||||
3. Select the allowed features and any stricter attachment or history limits.
|
||||
4. Choose a visibility preset or customize what appears in Chat.
|
||||
5. Review the summary, then enable the mode.
|
||||
|
||||
The app returns to the pinned profile's Chat screen. If the Connection or
|
||||
profile is unavailable, the restricted client shows a recovery state
|
||||
without falling back to another profile or exposing full Settings.
|
||||
|
||||
## The everyday experience
|
||||
|
||||
Chat should look like ordinary Hermes-Relay Chat. There is no persistent
|
||||
Supervised Mode banner consuming conversation space. The agent name and avatar
|
||||
remain the primary identity, with a small connection state when permitted.
|
||||
|
||||
The existing Settings button opens **Restricted Settings**, which contains only
|
||||
approved preferences. A clearly labelled **Parent access** row starts device
|
||||
authentication before any parent controls or full application settings appear.
|
||||
|
||||
Restricted Settings may include:
|
||||
|
||||
- a supervised-only theme, text size, language, and haptics;
|
||||
- parent-approved pet display and, when allowed, a phone-local profile icon and
|
||||
chat background;
|
||||
- accessibility preferences;
|
||||
- message presentation and sensitive-media blur;
|
||||
- harmless playback or interaction preferences when voice is allowed;
|
||||
- Help and About;
|
||||
- the locked Parent access row.
|
||||
|
||||
Connections, profiles, Manage, model controls, personalities, reasoning,
|
||||
approvals, tools, plugins, Terminal, TUI, Bridge, Device Control, notification
|
||||
companion, diagnostics, logs, files, credentials, developer options, Relay
|
||||
management, and other sessions are not shown.
|
||||
|
||||
The command palette, slash autocomplete, server command catalog, and command
|
||||
action cards are also absent. Messages whose first non-whitespace character is
|
||||
`/` are rejected by the restricted client. Approved outcomes such as New chat
|
||||
and Cancel remain normal, explicit buttons. If the agent requests approval, a
|
||||
secret, clarification, or elevated access, the restricted client denies or
|
||||
skips that request and shows a short notice. A parent can retry the task later
|
||||
from the full client after authentication.
|
||||
|
||||
## Allowed features
|
||||
|
||||
The parent chooses capabilities independently. The proposed controls are:
|
||||
|
||||
| Capability | Suggested default | Effect when disabled |
|
||||
|---|---:|---|
|
||||
| Text chat | On | Required for the restricted chat experience |
|
||||
| New chat | On | Removes the new-conversation action |
|
||||
| Cancel reply | On | Removes Stop while a reply is running |
|
||||
| Steer reply | On | Queues or disables mid-reply input instead |
|
||||
| Attachments | Parent choice | Removes pickers, camera/share intake, paste-to-file, and restored attachment drafts |
|
||||
| Standard voice | Parent choice | Removes recording, voice intents, and voice preferences |
|
||||
| Generated media | On | Hides generated-image viewing and related actions |
|
||||
| Save/share media | Off | Keeps permitted media view-only inside the app |
|
||||
| Copy replies | On | Removes copy actions |
|
||||
| Retry | On | Removes retry/regenerate actions |
|
||||
| Quote/reply | On | Removes quote/reply actions |
|
||||
| Edit and resend | Off | Prevents rewriting earlier prompts from the client |
|
||||
| Session history | Parent choice | Limits the pinned profile to the current or approved conversations |
|
||||
| Session actions | Off | Individually allows pin, rename, archive, share, and delete for visible history |
|
||||
|
||||
Attachments are a general capability, not a one-image rule. When enabled, the
|
||||
normal supported attachment flow and app limits apply unless the parent chooses
|
||||
a stricter maximum size or permitted-type policy. When disabled, every Android
|
||||
entry point must be removed or rejected consistently, including share intents
|
||||
and a draft restored after process death.
|
||||
|
||||
Standard voice uses the existing host-side voice configuration. Provider
|
||||
credentials stay on the Hermes host and are not exposed in Restricted Settings.
|
||||
|
||||
The supervised theme is stored separately from the parent app theme and applies
|
||||
only while the restricted root is locked. Pet display is parent-controlled.
|
||||
Profile-icon and background changes can be enabled independently for the
|
||||
supervised user; the authenticated parent retains those controls either way.
|
||||
|
||||
Session actions use a separate allowlist. The parent can allow all, allow none,
|
||||
or choose individual actions. Copying technical session identifiers, browsing
|
||||
other profiles, Relay Threads, and drawer customization remain unavailable.
|
||||
Delete continues to require confirmation.
|
||||
|
||||
Generated media is limited by display policy, not by a claim that Android can
|
||||
prove how the server created it. Parents may allow viewing while disabling save
|
||||
and share. Ordinary remote links, files, and unsupported media retain the app's
|
||||
normal safety behavior.
|
||||
|
||||
## What appears in Chat
|
||||
|
||||
Visibility controls affect presentation only. They never suppress an error,
|
||||
safety notice, parent-action state, or connection failure that requires
|
||||
attention.
|
||||
|
||||
### Simple (recommended)
|
||||
|
||||
- Shows the agent name and avatar.
|
||||
- Shows generic **Connected**, **Working**, and **Reconnecting** states.
|
||||
- Hides model, profile, provider, route, context, token usage, reasoning, and
|
||||
tool details.
|
||||
- Keeps the header and composer visually quiet.
|
||||
|
||||
### Transparent
|
||||
|
||||
Adds parent-approved timestamps, bounded usage or context information, and
|
||||
safe activity labels. It still does not reveal tool arguments, tool results,
|
||||
host paths, credentials, or administration surfaces.
|
||||
|
||||
### Custom
|
||||
|
||||
Lets the parent control individual surfaces, including:
|
||||
|
||||
- model name and profile name;
|
||||
- connection state and route identity;
|
||||
- timestamps, context, and token usage;
|
||||
- generic work status, tool names, and tool detail;
|
||||
- reasoning visibility;
|
||||
- message and media actions.
|
||||
|
||||
Model and profile names default off for a new policy. The agent's friendly name
|
||||
and avatar provide the normal identity in the Simple preset.
|
||||
|
||||
## Parent access and relocking
|
||||
|
||||
Enabling, changing, or ending Supervised Mode requires Android device
|
||||
authentication. Parent access should relock when its authenticated task closes,
|
||||
after the configured inactivity period, when the app backgrounds, or after
|
||||
process recreation.
|
||||
|
||||
Android's device-credential prompt authenticates any user enrolled for that
|
||||
device; it does not establish a separate parent identity. Use a device lock the
|
||||
supervised user does not know, or keep the device under direct supervision.
|
||||
|
||||
The restricted root is restored before the first interactive screen. Deep
|
||||
links, notification actions, shortcuts, saved back stacks, and share intents
|
||||
must not provide a route around it. A missing or unreadable policy fails closed
|
||||
to restricted recovery instead of opening full Settings.
|
||||
|
||||
Ending the mode may clear local drafts, pending attachments, and supervised
|
||||
media caches according to the parent's choice. It does not automatically delete
|
||||
Hermes sessions or history stored on the server. Parents review or delete that
|
||||
history through their normal authenticated Hermes interface.
|
||||
|
||||
## Optional Relay visibility
|
||||
|
||||
If the Android client is paired with the optional Relay plugin, it may identify
|
||||
itself with a client-reported **Supervised** tag and a short, non-sensitive
|
||||
capability summary. The Relay UI can then make the device easy to recognize and
|
||||
can revoke its paired Relay session through the normal paired-device controls.
|
||||
|
||||
The tag is informational. Relay does not enforce the Android policy, pin the
|
||||
Hermes profile, filter direct Dashboard/Gateway chat, or certify that the client
|
||||
is unmodified. Revoking the Relay session disables Relay-backed access for that
|
||||
pairing; it does not remotely end an Android-only mode or revoke an independent
|
||||
Dashboard sign-in. Supervised Mode does not require Relay.
|
||||
|
||||
## Limits of protection
|
||||
|
||||
Supervised Mode cannot control:
|
||||
|
||||
- another Hermes client or a modified Android build;
|
||||
- someone with direct access to the Hermes server or parent credentials;
|
||||
- tools, files, services, and provider behavior enabled in the selected profile;
|
||||
- server-side session retention or provider data handling;
|
||||
- the developmental suitability or factual accuracy of model output;
|
||||
- Android behavior outside the Hermes-Relay app.
|
||||
|
||||
Use it alongside a restrictive Hermes profile, parental supervision, Android
|
||||
parental or enterprise controls where appropriate, and regular review of the
|
||||
profile and its conversations.
|
||||
|
||||
## Certification requirement
|
||||
|
||||
The feature should not be described as child-ready until the exact Android
|
||||
build passes automated policy and navigation tests plus physical testing on a
|
||||
managed/restricted Android device. Certification must cover authentication,
|
||||
relocking, restart and offline recovery, process death, deep links,
|
||||
notifications, share intents, attachments, voice, session ownership, Relay
|
||||
tagging/revocation, and attempts to escape the restricted interface.
|
||||
|
||||
See [Profiles](/features/profiles) for the server-owned identity model and
|
||||
[Chat](/guide/chat) for the full, unrestricted interface.
|
||||
Reference in New Issue
Block a user