From bae87e07244314c755078b0f350c703fd2af65a3 Mon Sep 17 00:00:00 2001 From: "dependabot[bot]" <49699333+dependabot[bot]@users.noreply.github.com> Date: Thu, 16 Jul 2026 21:08:31 +0000 Subject: [PATCH 01/16] chore(deps): bump the gradle group with 3 updates (#94) Bumps the gradle group with 3 updates: [com.google.protobuf:protobuf-java](https://github.com/protocolbuffers/protobuf), com.google.protobuf:protobuf-kotlin and [gradle-wrapper](https://github.com/gradle/gradle). Updates `com.google.protobuf:protobuf-java` from 3.25.5 to 3.25.9 - [Release notes](https://github.com/protocolbuffers/protobuf/releases) - [Commits](https://github.com/protocolbuffers/protobuf/commits) Updates `com.google.protobuf:protobuf-kotlin` from 3.25.5 to 3.25.9 Updates `gradle-wrapper` from 9.3.1 to 9.6.1 - [Release notes](https://github.com/gradle/gradle/releases) - [Commits](https://github.com/gradle/gradle/compare/v9.3.1...v9.6.1) --- updated-dependencies: - dependency-name: com.google.protobuf:protobuf-java dependency-version: 3.25.9 dependency-type: direct:production update-type: version-update:semver-patch dependency-group: gradle - dependency-name: com.google.protobuf:protobuf-kotlin dependency-version: 3.25.9 dependency-type: direct:production update-type: version-update:semver-patch dependency-group: gradle - dependency-name: gradle-wrapper dependency-version: 9.6.1 dependency-type: direct:production update-type: version-update:semver-minor dependency-group: gradle ... Signed-off-by: dependabot[bot] Co-authored-by: dependabot[bot] <49699333+dependabot[bot]@users.noreply.github.com> --- build.gradle.kts | 4 ++-- gradle/wrapper/gradle-wrapper.properties | 2 +- gradlew | 4 ++-- gradlew.bat | 4 ++-- 4 files changed, 7 insertions(+), 7 deletions(-) diff --git a/build.gradle.kts b/build.gradle.kts index c7f39f3..7c4ff08 100644 --- a/build.gradle.kts +++ b/build.gradle.kts @@ -11,8 +11,8 @@ buildscript { if (requested.group == "io.netty") useVersion("4.1.135.Final") } force( - "com.google.protobuf:protobuf-java:3.25.5", - "com.google.protobuf:protobuf-kotlin:3.25.5", + "com.google.protobuf:protobuf-java:3.25.9", + "com.google.protobuf:protobuf-kotlin:3.25.9", "org.bouncycastle:bcpg-jdk18on:1.84", "org.bitbucket.b_c:jose4j:0.9.6", "org.jdom:jdom2:2.0.6.1", diff --git a/gradle/wrapper/gradle-wrapper.properties b/gradle/wrapper/gradle-wrapper.properties index 9354476..a9db115 100644 --- a/gradle/wrapper/gradle-wrapper.properties +++ b/gradle/wrapper/gradle-wrapper.properties @@ -1,6 +1,6 @@ distributionBase=GRADLE_USER_HOME distributionPath=wrapper/dists -distributionUrl=https\://services.gradle.org/distributions/gradle-9.3.1-bin.zip +distributionUrl=https\://services.gradle.org/distributions/gradle-9.6.1-bin.zip networkTimeout=10000 retries=0 retryBackOffMs=500 diff --git a/gradlew b/gradlew index b9bb139..249efbb 100755 --- a/gradlew +++ b/gradlew @@ -20,7 +20,7 @@ ############################################################################## # -# Gradle start up script for POSIX generated by Gradle. +# gradlew start up script for POSIX generated by Gradle. # # Important for running: # @@ -29,7 +29,7 @@ # bash, then to run this script, type that shell name before the whole # command line, like: # -# ksh Gradle +# ksh gradlew # # Busybox and similar reduced shells will NOT work, because this script # requires all of these POSIX shell features: diff --git a/gradlew.bat b/gradlew.bat index aa5f10b..8508ef6 100644 --- a/gradlew.bat +++ b/gradlew.bat @@ -19,7 +19,7 @@ @if "%DEBUG%"=="" @echo off @rem ########################################################################## @rem -@rem Gradle startup script for Windows +@rem gradlew startup script for Windows @rem @rem ########################################################################## @@ -72,7 +72,7 @@ echo location of your Java installation. 1>&2 -@rem Execute Gradle +@rem Execute gradlew @rem endlocal doesn't take effect until after the line is parsed and variables are expanded @rem which allows us to clear the local environment before executing the java command endlocal & "%JAVA_EXE%" %DEFAULT_JVM_OPTS% %JAVA_OPTS% %GRADLE_OPTS% "-Dorg.gradle.appname=%APP_BASE_NAME%" -jar "%APP_HOME%\gradle\wrapper\gradle-wrapper.jar" %* & call :exitWithErrorLevel From 74f6a3aa7943e2e0b2c3b27b0d29037b29b8db18 Mon Sep 17 00:00:00 2001 From: Andrew Debnar <606931+adebnar@users.noreply.github.com> Date: Thu, 16 Jul 2026 21:43:56 +0000 Subject: [PATCH 02/16] chore: remove dead session-grouping code (#97) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The Sessions tab moved to a flat recency list + Sessions/Projects toggle (v0.1.50); the old Profile→Workspace grouping tree and its expansion store are no longer referenced by any production code. Remove groupSessions/WorkspaceGroup/ProfileGroup, GroupExpansionStore, the SessionsViewModel collapsedGroups/toggleGroup members, the Hilt provider, and the now-orphaned tests. --- .../data/repository/GroupExpansionStore.kt | 37 ---------- .../java/com/hermes/client/di/AppModule.kt | 7 -- .../client/ui/sessions/SessionGrouping.kt | 70 ------------------- .../client/ui/sessions/SessionsViewModel.kt | 9 --- .../com/hermes/client/ui/sessions/ViewMode.kt | 10 +++ .../client/ui/sessions/SessionGroupingTest.kt | 68 ------------------ .../ui/sessions/SessionsViewModelTest.kt | 15 +--- 7 files changed, 11 insertions(+), 205 deletions(-) delete mode 100644 app/src/main/java/com/hermes/client/data/repository/GroupExpansionStore.kt delete mode 100644 app/src/main/java/com/hermes/client/ui/sessions/SessionGrouping.kt create mode 100644 app/src/main/java/com/hermes/client/ui/sessions/ViewMode.kt delete mode 100644 app/src/test/java/com/hermes/client/ui/sessions/SessionGroupingTest.kt diff --git a/app/src/main/java/com/hermes/client/data/repository/GroupExpansionStore.kt b/app/src/main/java/com/hermes/client/data/repository/GroupExpansionStore.kt deleted file mode 100644 index 8a45df8..0000000 --- a/app/src/main/java/com/hermes/client/data/repository/GroupExpansionStore.kt +++ /dev/null @@ -1,37 +0,0 @@ -package com.hermes.client.data.repository - -import android.content.Context -import androidx.datastore.preferences.core.edit -import androidx.datastore.preferences.core.stringSetPreferencesKey -import androidx.datastore.preferences.preferencesDataStore -import kotlinx.coroutines.flow.Flow -import kotlinx.coroutines.flow.map - -private val Context.groupExpansionDataStore by preferencesDataStore(name = "session_group_expansion") - -/** - * Device-local store of which session-list groups are collapsed. Groups default to expanded, so - * only the *collapsed* keys are persisted — an empty set means everything is open. Keys are built - * by [profileKey]/[workspaceKey] so a profile group and a workspace group never collide. - */ -class GroupExpansionStore(private val context: Context) { - private val key = stringSetPreferencesKey("collapsed") - - /** The set of currently-collapsed group keys. */ - val collapsed: Flow> = context.groupExpansionDataStore.data.map { it[key] ?: emptySet() } - - suspend fun toggle(groupKey: String) { - context.groupExpansionDataStore.edit { prefs -> - val cur = prefs[key] ?: emptySet() - prefs[key] = if (groupKey in cur) cur - groupKey else cur + groupKey - } - } - - companion object { - /** Collapse key for a profile (top tier). */ - fun profileKey(profile: String?) = "p:${profile ?: "default"}" - - /** Collapse key for a workspace within a profile (sub tier). */ - fun workspaceKey(profile: String?, workspace: String) = "w:${profile ?: "default"}/$workspace" - } -} diff --git a/app/src/main/java/com/hermes/client/di/AppModule.kt b/app/src/main/java/com/hermes/client/di/AppModule.kt index 5cc6dbd..d077ce0 100644 --- a/app/src/main/java/com/hermes/client/di/AppModule.kt +++ b/app/src/main/java/com/hermes/client/di/AppModule.kt @@ -141,13 +141,6 @@ object AppModule { fun providePinStore(@ApplicationContext context: Context): com.hermes.client.data.repository.PinStore = com.hermes.client.data.repository.PinStore(context) - @Provides - @Singleton - fun provideGroupExpansionStore( - @ApplicationContext context: Context, - ): com.hermes.client.data.repository.GroupExpansionStore = - com.hermes.client.data.repository.GroupExpansionStore(context) - @Provides @Singleton fun provideViewModeStore( diff --git a/app/src/main/java/com/hermes/client/ui/sessions/SessionGrouping.kt b/app/src/main/java/com/hermes/client/ui/sessions/SessionGrouping.kt deleted file mode 100644 index 69d163d..0000000 --- a/app/src/main/java/com/hermes/client/ui/sessions/SessionGrouping.kt +++ /dev/null @@ -1,70 +0,0 @@ -package com.hermes.client.ui.sessions - -import com.hermes.client.data.repository.GroupExpansionStore -import com.hermes.client.domain.Session - -/** Which list the Chats screen shows: a flat recency list, or the gateway's project tree. */ -enum class ViewMode { SESSIONS, PROJECTS } - -/** - * A workspace sub-group within a profile. [sessions] is empty when the group is collapsed; - * [count] always reflects the true number of rows so the header reads correctly either way. - */ -data class WorkspaceGroup( - val workspace: String, - val count: Int, - val collapsed: Boolean, - val sessions: List, -) - -/** - * A profile group (top tier). [workspaces] is empty when the profile itself is collapsed, so a - * collapsed profile renders as just its header — hiding every workspace and row beneath it. - */ -data class ProfileGroup( - val profile: String, - val count: Int, - val collapsed: Boolean, - val workspaces: List, -) - -/** - * Build the render-ready two-tier tree: Profile → Workspace → rows. The result is exactly what - * the list should draw — collapsed groups already have their children removed — so the screen - * stays dumb and the grouping is unit-testable. Collapse state comes from [collapsed] (a set of - * keys built by [GroupExpansionStore]); [activeProfile] sorts first so the current tenant is on top. - */ -fun groupSessions( - sessions: List, - collapsed: Set, - activeProfile: String?, -): List = - sessions - .groupBy { it.profile ?: "default" } - .map { (profile, inProfile) -> - val profileCollapsed = GroupExpansionStore.profileKey(profile) in collapsed - val workspaces = - if (profileCollapsed) emptyList() - else inProfile - .groupBy { it.workspace } - .toSortedMap(compareBy({ it == "No workspace" }, { it })) - .map { (workspace, rows) -> - val wsCollapsed = GroupExpansionStore.workspaceKey(profile, workspace) in collapsed - WorkspaceGroup( - workspace = workspace, - count = rows.size, - collapsed = wsCollapsed, - sessions = if (wsCollapsed) emptyList() else rows, - ) - } - ProfileGroup(profile, inProfile.size, profileCollapsed, workspaces) - } - .sortedWith( - compareByDescending { it.profile == activeProfile } - .thenByDescending { it.count } - .thenBy { it.profile }, - ) - -/** Flat, most-recent-first order for Sessions mode. Sessions with no [Session.lastActive] sort last. */ -fun sessionsByRecency(sessions: List): List = - sessions.sortedByDescending { it.lastActive ?: Long.MIN_VALUE } diff --git a/app/src/main/java/com/hermes/client/ui/sessions/SessionsViewModel.kt b/app/src/main/java/com/hermes/client/ui/sessions/SessionsViewModel.kt index 3bf3528..df43d8b 100644 --- a/app/src/main/java/com/hermes/client/ui/sessions/SessionsViewModel.kt +++ b/app/src/main/java/com/hermes/client/ui/sessions/SessionsViewModel.kt @@ -5,7 +5,6 @@ import androidx.lifecycle.viewModelScope import com.hermes.client.data.network.HermesApiException import com.hermes.client.data.network.SearchResultDto import com.hermes.client.data.repository.ChatRepository -import com.hermes.client.data.repository.GroupExpansionStore import com.hermes.client.data.repository.PinStore import com.hermes.client.data.repository.ProfileManager import com.hermes.client.data.repository.SessionRepository @@ -44,7 +43,6 @@ class SessionsViewModel @Inject constructor( private val chat: ChatRepository, private val profileManager: ProfileManager, private val pinStore: PinStore, - private val groupExpansion: GroupExpansionStore, private val viewModeStore: ViewModeStore, ) : ViewModel() { private val _state = MutableStateFlow(SessionsUiState()) @@ -71,13 +69,6 @@ class SessionsViewModel @Inject constructor( fun isPinned(session: Session, tokens: Set = pinnedTokens.value): Boolean = PinStore.token(session.profile, session.id) in tokens - /** Keys of currently-collapsed Profile/Workspace groups (device-local; default expanded). */ - val collapsedGroups: StateFlow> = - groupExpansion.collapsed.stateIn(viewModelScope, SharingStarted.WhileSubscribed(5_000), emptySet()) - - /** Collapse or expand a group (profile or workspace) by its [GroupExpansionStore] key. */ - fun toggleGroup(groupKey: String) = viewModelScope.launch { groupExpansion.toggle(groupKey) } - /** Persisted view mode (Sessions flat list vs the gateway project tree). */ val viewMode: StateFlow = viewModeStore.mode.stateIn(viewModelScope, SharingStarted.WhileSubscribed(5_000), ViewMode.SESSIONS) diff --git a/app/src/main/java/com/hermes/client/ui/sessions/ViewMode.kt b/app/src/main/java/com/hermes/client/ui/sessions/ViewMode.kt new file mode 100644 index 0000000..740565c --- /dev/null +++ b/app/src/main/java/com/hermes/client/ui/sessions/ViewMode.kt @@ -0,0 +1,10 @@ +package com.hermes.client.ui.sessions + +import com.hermes.client.domain.Session + +/** Which list the Chats screen shows: a flat recency list, or the gateway's project tree. */ +enum class ViewMode { SESSIONS, PROJECTS } + +/** Flat, most-recent-first order for Sessions mode. Sessions with no [Session.lastActive] sort last. */ +fun sessionsByRecency(sessions: List): List = + sessions.sortedByDescending { it.lastActive ?: Long.MIN_VALUE } diff --git a/app/src/test/java/com/hermes/client/ui/sessions/SessionGroupingTest.kt b/app/src/test/java/com/hermes/client/ui/sessions/SessionGroupingTest.kt deleted file mode 100644 index b870546..0000000 --- a/app/src/test/java/com/hermes/client/ui/sessions/SessionGroupingTest.kt +++ /dev/null @@ -1,68 +0,0 @@ -package com.hermes.client.ui.sessions - -import com.hermes.client.data.repository.GroupExpansionStore -import com.hermes.client.domain.Session -import org.junit.Assert.assertEquals -import org.junit.Assert.assertFalse -import org.junit.Assert.assertTrue -import org.junit.Test - -class SessionGroupingTest { - private fun s(id: String, profile: String, workspace: String) = Session( - id = id, title = id, model = null, provider = null, messageCount = 1, - profile = profile, workspace = workspace, - ) - - private val sessions = listOf( - s("a", "personal", "app"), - s("b", "personal", "app"), - s("c", "personal", "docs"), - s("d", "odos", "infra"), - ) - - @Test fun groups_two_tiers_with_active_profile_first() { - val tree = groupSessions(sessions, collapsed = emptySet(), activeProfile = "odos") - - // Active profile sorts first even though it has fewer sessions. - assertEquals(listOf("odos", "personal"), tree.map { it.profile }) - val personal = tree.first { it.profile == "personal" } - assertEquals(3, personal.count) - assertEquals(listOf("app", "docs"), personal.workspaces.map { it.workspace }) - assertEquals(listOf("a", "b"), personal.workspaces.first { it.workspace == "app" }.sessions.map { it.id }) - } - - @Test fun collapsed_profile_hides_all_workspaces_and_rows() { - val collapsed = setOf(GroupExpansionStore.profileKey("personal")) - val tree = groupSessions(sessions, collapsed, activeProfile = null) - - val personal = tree.first { it.profile == "personal" } - assertTrue(personal.collapsed) - assertTrue("a collapsed profile renders no workspaces/rows", personal.workspaces.isEmpty()) - // Count still reflects reality so the header reads "3". - assertEquals(3, personal.count) - } - - @Test fun collapsed_workspace_hides_its_rows_but_keeps_count() { - val collapsed = setOf(GroupExpansionStore.workspaceKey("personal", "app")) - val tree = groupSessions(sessions, collapsed, activeProfile = null) - - val app = tree.first { it.profile == "personal" }.workspaces.first { it.workspace == "app" } - assertTrue(app.collapsed) - assertTrue("collapsed workspace hides rows", app.sessions.isEmpty()) - assertEquals("but the count is preserved", 2, app.count) - - val docs = tree.first { it.profile == "personal" }.workspaces.first { it.workspace == "docs" } - assertFalse("a sibling workspace stays expanded", docs.collapsed) - assertEquals(listOf("c"), docs.sessions.map { it.id }) - } - - private fun s(id: String, lastActive: Long?) = com.hermes.client.domain.Session( - id = id, title = id, model = null, provider = null, messageCount = 1, - profile = "personal", lastActive = lastActive, - ) - - @org.junit.Test fun sessionsByRecency_orders_newest_first_nulls_last() { - val out = sessionsByRecency(listOf(s("a", 100), s("b", null), s("c", 300))).map { it.id } - org.junit.Assert.assertEquals(listOf("c", "a", "b"), out) - } -} diff --git a/app/src/test/java/com/hermes/client/ui/sessions/SessionsViewModelTest.kt b/app/src/test/java/com/hermes/client/ui/sessions/SessionsViewModelTest.kt index c746d91..4886518 100644 --- a/app/src/test/java/com/hermes/client/ui/sessions/SessionsViewModelTest.kt +++ b/app/src/test/java/com/hermes/client/ui/sessions/SessionsViewModelTest.kt @@ -30,7 +30,6 @@ class SessionsViewModelTest { private val chatRepo = mockk(relaxed = true) private val profileManager = mockk(relaxed = true) private val pinStore = mockk(relaxed = true) - private val groupExpansion = mockk(relaxed = true) private val viewModeStore = mockk(relaxed = true) // Controllable so tests can flip the persisted view mode (drives the VM's launch/toggle load). private val modeFlow = MutableStateFlow(ViewMode.SESSIONS) @@ -40,7 +39,6 @@ class SessionsViewModelTest { every { chatRepo.events } returns kotlinx.coroutines.flow.MutableSharedFlow() every { profileManager.active } returns MutableStateFlow("personal") every { pinStore.pinned } returns MutableStateFlow>(emptySet()) - every { groupExpansion.collapsed } returns MutableStateFlow>(emptySet()) every { viewModeStore.mode } returns modeFlow } @@ -49,7 +47,7 @@ class SessionsViewModelTest { messageCount = 1, profile = profile, workspace = "No workspace", source = "hermes-dispatch", ) - private fun buildVm() = SessionsViewModel(sessionRepo, chatRepo, profileManager, pinStore, groupExpansion, viewModeStore) + private fun buildVm() = SessionsViewModel(sessionRepo, chatRepo, profileManager, pinStore, viewModeStore) private fun repoSession(id: String, repo: String?, profile: String = "personal") = Session( id = id, title = id, model = null, provider = null, messageCount = 1, @@ -195,17 +193,6 @@ class SessionsViewModelTest { io.mockk.coVerify(exactly = 0) { profileManager.switchTo(any()) } } - // T2: toggling a group delegates to the persisted store (collapse state survives navigation). - @Test fun toggleGroup_persists_via_store() = runTest { - coEvery { sessionRepo.listAllProfiles() } returns emptyList() - val vm = buildVm() - advanceUntilIdle() - - vm.toggleGroup("p:odos") - advanceUntilIdle() - io.mockk.coVerify { groupExpansion.toggle("p:odos") } - } - @Test fun setViewMode_persists_the_mode() = runTest { coEvery { sessionRepo.listAllProfiles() } returns emptyList() val vm = buildVm() From 695593e85f55b337d3cd2e3dcfaa35ebf59b66a1 Mon Sep 17 00:00:00 2001 From: Andrew Debnar Date: Thu, 16 Jul 2026 16:55:26 -0500 Subject: [PATCH 03/16] =?UTF-8?q?docs:=20competitive=20refresh=20=E2=80=94?= =?UTF-8?q?=20new=20mobile=20LLM=20app=20improvement=20backlog?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- docs/ideas/2026-07-16-competitive-refresh.md | 289 +++++++++++++++++++ 1 file changed, 289 insertions(+) create mode 100644 docs/ideas/2026-07-16-competitive-refresh.md diff --git a/docs/ideas/2026-07-16-competitive-refresh.md b/docs/ideas/2026-07-16-competitive-refresh.md new file mode 100644 index 0000000..a486451 --- /dev/null +++ b/docs/ideas/2026-07-16-competitive-refresh.md @@ -0,0 +1,289 @@ +# Competitive Refresh — what's NEXT for Hermes for Android (2026-07-16) + +A fresh survey of 2025–2026 mobile LLM apps (Android **and** iOS), read against everything +Hermes has already shipped or spec'd. The prior review (`competitive-review-2026-07-07.md`) and +its phased plan (`improvement-roadmap-2026-07-07.md`) closed the *consumer table-stakes* gaps — +voice dictation, regenerate/edit-resend, richer attachments, in-thread search, completion push, +tiered approvals (allow/ask/deny + per-profile "always allow" + once/session/always scopes), the +cron schedule builder, the activity feed, the session↔project toggle. **None of that is +re-proposed here.** This doc is only the layer *beyond* that plan. + +Standing constraints assumed throughout: no new gateway endpoints unless clearly worth it (flagged +per item), strict tenant isolation (generic acme/globex names), client-only preferred. + +Field surveyed: ChatGPT, Claude, Gemini, Copilot, Perplexity, Grok, Le Chat, Meta AI, Poe, DeepSeek +(consumer) · Cursor iOS, Codex mobile, Enchanted, Reins, Chatbox, LMSA, Msty, Open WebUI, LibreChat, +Pal Chat (power-user / self-hosted / agent clients). + +--- + +## 1. Executive summary — highest-leverage new directions + +1. **Live progress "ongoing run" surface (Android 16 Live Updates / `ProgressStyle`).** Cursor iOS + streams agent progress to the lock screen via Live Activities; Android 16 now has the equivalent. + Hermes pushes on *completion* but shows nothing *while a run is in flight* — a persistent, + glanceable "acme agent running · step 3/5" chip is the natural next beat after completion push. +2. **Self-hosted onboarding that isn't a manual URL+token paste.** Setup today is hand-typed + `http://100.x.x.x:9119` + token. LMSA auto-discovers LM Studio/Ollama on the LAN; Cursor/Codex + pair by **QR scan** from the desktop. A LAN/Tailscale discovery + QR-pair flow is a self-hosted-only + win no consumer app needs. +3. **Per-session generation controls + system-prompt override.** Reins and Chatbox let power users + set temperature / context size / max-tokens / a per-chat system prompt. Hermes has a per-session + *model* picker but no knobs and no per-session instruction override — a cheap power-user gap. +4. **Agent/persona presets on the phone.** Grok "Your Agents" (up to 4 custom), Gemini "Gems", + LMSA "personas" — one-tap reusable bundles of {model + system prompt + tool policy}. For Hermes + this is *per-tenant* and ties directly into its approval-policy and model machinery. +5. **A cross-tenant "Approvals & Needs-you inbox."** Hermes' multi-tenant isolation is its moat, but + today triage lives inside each profile. A single aggregated queue of pending approvals + blocked + runs *across all tenants* (color-coded) is something no consumer app can build — they're single-account. +6. **Proactive backend-health awareness.** Every consumer app assumes the backend is up; a self-hosted + client is the one product that must warn *"acme gateway unreachable for 6 min."* Hermes has a + diagnostics screen — promote it to a proactive, per-tenant reachability signal. +7. **Act-on-result quick actions from the notification/feed.** Cursor lets you review and **merge the + PR** straight from the phone. Hermes' analog: re-run / send-follow-up / approve / open — directly + from a completion notification or the activity card, without opening the thread. +8. **Record-to-task capture.** ChatGPT "Record Mode" turns a meeting/voice note into structured tasks. + Voice *dictation* is done; feeding a longer recording to an agent to produce a run is the agentic + extension. + +--- + +## 2. Idea inventory (grouped, ranked) + +Effort: **S** ≈ a day · **M** ≈ meatier wave · **L** ≈ multi-wave. +GW: **client-only** vs **gateway-assist?** (needs a bridge-API check / possible server change). + +### A. Agent-run awareness (the strongest cluster — extends the flagship) + +**A1. Live in-flight run progress (Android 16 Live Updates / `ProgressStyle`).** +- *Pattern / who:* Cursor iOS uses Apple **Live Activities** to stream agent status ("running… / + needs input / ready for review") to the lock screen + Dynamic Island. Android 16's + `Notification.ProgressStyle` + promoted "Live Updates" give a status-bar chip, notification-shade + card, and Always-On-Display presence for exactly this start→end journey shape. +- *Fit:* Very high. Hermes already runs a foreground WS service and already knows run state; it just + renders nothing *during* a run. A long agent task is precisely the "user-initiated start-to-end + journey" Live Updates were designed for. This is distinct from the shipped *completion* + notification — it's the minutes in between. +- *Effort:* **M** · *GW:* **client-only** if the WS already emits step/tool progress events (the + Phase-2 spike likely already answered this); **gateway-assist?** only if no progress event exists. +- *Priority:* **P0** — highest-leverage genuinely-new idea; small build on top of existing infra, and + it's the single most visible thing modern agent-remotes do that Hermes doesn't. + +**A2. Cross-tenant "Approvals & Needs-you inbox."** +- *Pattern / who:* No consumer app does this (they're single-account); it's a pure exploitation of + Hermes' multi-tenant design. Closest analog is Codex/Cursor's per-task approval queue, but + single-workspace. +- *Fit:* Very high and differentiating. One aggregated, color-coded queue: every pending approval and + every blocked/needs-input run across acme + globex + …, sorted by wait time, each row tappable to + the source thread. +- *Effort:* **M** · *GW:* **client-only** *if* pending approvals/blocked runs are derivable from the + per-profile state the app already polls; **gateway-assist?** if a pending-approvals list must be + fetched (the prior spike flagged there's no pending-approvals REST list — reuse that finding). +- *Priority:* **P1** — differentiating, but partly gated on the same data question Phase 2 raised. + +**A3. Act-on-result quick actions from notification / activity card.** +- *Pattern / who:* Cursor iOS — "leave follow-up instructions, or **merge the PR** directly from the + app" the moment an agent finishes. ChatGPT/Gemini scheduled-task cards offer re-run. +- *Fit:* High. On a completion notification or an activity-feed row, expose **Re-run**, **Send + follow-up** (quick-reply text), **Approve/Deny** (if blocked), **Open**. Turns awareness into action + without a context switch. Overlaps cron-followups (shipped) but generalizes it to *any* run. +- *Effort:* **S–M** · *GW:* **client-only** (reuses send + approval wire). +- *Priority:* **P1** — closes the loop the completion-push work opened. + +**A4. Proactive backend-health signal.** +- *Pattern / who:* Uniquely self-hosted — no consumer app worries the backend is down. LMSA's + connection-centric UX is the nearest cousin. +- *Fit:* High and defensible. A quiet per-tenant reachability indicator + an optional "gateway + unreachable for N min" notification. The diagnostics package already has the plumbing; this promotes + it from a screen you visit to a signal that finds you. +- *Effort:* **S** · *GW:* **client-only** (health ping against an existing endpoint). +- *Priority:* **P1** — cheap, and it targets the failure mode only a self-hosted user has. + +### B. Chat ergonomics & power-user controls + +**B1. Per-session generation controls + system-prompt override.** +- *Pattern / who:* **Reins** ("adjust temperature, seed, context size, max tokens for each + conversation, per-chat system prompt"), **Chatbox** (Top-P, two-decimal temperature, per-conversation + system prompt), Open WebUI (parameter tuning). +- *Fit:* High for Hermes' power-user base. Model-per-session exists; add an optional advanced sheet: + temperature / top-p / max-tokens / context window / a session system-prompt override. +- *Effort:* **S** (UI) · *GW:* **gateway-assist?** — verify the send/run payload accepts these fields; + gracefully hide any the gateway ignores. +- *Priority:* **P1** — small, and squarely serves the audience that self-hosts. + +**B2. Split / compare two models on one prompt.** +- *Pattern / who:* **Msty** "split chats"; Poe multi-bot; Open WebUI multi-model. +- *Fit:* Medium. Fan one prompt to two models/sessions side-by-side. Screen-real-estate-hard on a + phone; more a novelty than daily value. +- *Effort:* **M** · *GW:* **client-only** (two parallel sessions). +- *Priority:* **P2** — nice, low urgency. + +**B3. Natural-language cron ("every Friday, summarize acme's open runs").** +- *Pattern / who:* Gemini **Scheduled Actions** — "just tell it what and when." Hermes has a + structured builder; NL entry is a faster on-ramp that compiles to the same schedule. +- *Fit:* Medium. A text box that parses to the existing builder's fields (keep the builder as the + editable result). +- *Effort:* **S–M** · *GW:* **client-only** (parse locally or let an agent turn text→cron). +- *Priority:* **P2** — enhancement to a shipped feature. + +### C. Multi-tenant power-tools (differentiating) + +**C1. Per-tenant agent/persona presets.** +- *Pattern / who:* **Grok "Your Agents"** (up to 4 named custom agents w/ instructions), **Gemini + Gems**, **LMSA personas**. All single-account; Hermes' version is *per-tenant*. +- *Fit:* Very high. A saved preset = {default model + system-prompt override + approval-tier defaults} + scoped to a profile. Reuses B1 (params) + the tiered-approval policy store + the planned prompt + library — bundles them into a one-tap "start a run as ." +- *Effort:* **M** · *GW:* **client-only** (local per-profile store). +- *Priority:* **P1** — high differentiation, reuses several existing pieces. + +**C2. Per-tenant approval-policy dashboard.** +- *Pattern / who:* Extends the shipped per-profile "always allow" list; no competitor has a + multi-tenant view of it. Codex offers per-task vs cross-task MCP approval choices (single workspace). +- *Fit:* High. One screen to review/revoke each tenant's standing allowlist ("globex auto-allows + `web.fetch`") — the audit surface a multi-tenant operator wants. +- *Effort:* **S–M** · *GW:* **client-only** if the allowlist is client-stored; **gateway-assist?** + where `always` writes the gateway `config.yaml` (the tiered-approvals spec notes it does) — reads may + need a config fetch. +- *Priority:* **P2** — governance polish on top of tiered approvals. + +### D. Onboarding & connection (self-hosted-native) + +**D1. LAN/Tailscale auto-discovery + QR-pair setup.** +- *Pattern / who:* **LMSA** scans the network to auto-find LM Studio/Ollama; **Cursor/Codex** pair the + phone to a host by **scanning a QR** shown on desktop. +- *Fit:* Very high, self-hosted-only. Replace/augment the hand-typed URL+token with: (a) NSD/mDNS + discovery of a gateway on the LAN, (b) a QR shown by the dashboard that encodes URL+token, scanned in + setup. Kills the most error-prone step in the whole app. +- *Effort:* **M** · *GW:* **client-only** for mDNS + QR *scan*; **gateway-assist?** (small) if the + dashboard must render the pairing QR — worth it. +- *Priority:* **P1** — first impression of a self-hosted client, and directly on-niche. + +### E. Model / context management + +**E1. Reachability-aware model picker.** +- *Pattern / who:* Reins/LMSA surface which endpoints/models are actually reachable; Hermes lists + models but doesn't flag a provider that's down. +- *Fit:* Medium. Show per-provider health in the model sheet (gateway already knows provider status). +- *Effort:* **S** · *GW:* **gateway-assist?** (needs a provider-status read, may already exist). +- *Priority:* **P2**. + +### F. On-device / offline + +**F1. Offline compose-and-queue** — already in the hardening backlog; reaffirmed by LMSA's +local-first framing. Keep as-is (**M**, client-only, **P2**). *No new proposal.* + +*Deliberately NOT pursuing on-device inference* (LLMFarm/Private LLM/LM Studio local models) — see +Anti-recommendations. + +### G. Personalization / memory + +**G1. Memory management UI (per tenant).** +- *Pattern / who:* **ChatGPT** memory summary page — view, delete individual memories, "delete and + turn off." **Claude** persistent memory across chats. +- *Fit:* Medium-high, but the prior roadmap already has "memory / custom-instructions front-and-center" + (Phase 6). The *new* nuance worth folding in: a **per-tenant** memory viewer/editor with + individual-entry delete — multi-tenant makes memory-bleed between orgs a real concern, so a + tenant-scoped memory audit is the differentiated framing. +- *Effort:* **S–M** · *GW:* **gateway-assist?** (existing memory endpoints). +- *Priority:* **P1** — reframes a planned item around the isolation moat. + +### H. Sharing / export + +**H1. Chat export / share-out** — already planned (Phase 5). *No new proposal beyond it.* One small +add: **"share a run summary"** (result + which tools ran) rather than raw transcript, matching how +Cursor shares a run's artifacts. (**S**, client-only, **P2**.) + +### I. Home-screen / OS integration + +**I1. Home-screen widget + deep links** — already planned (Phase 4); Claude Android shipping widgets +confirms it's table stakes. *No new proposal.* + +**I2. Record-to-task capture.** +- *Pattern / who:* **ChatGPT "Record Mode"** — capture a meeting/voice note, transcribe, and convert + into structured tasks/plans. +- *Fit:* Medium, agentic. Beyond the shipped push-to-talk dictation: record a longer clip, hand the + transcript to an agent, get a run/task back. Fits "act on a few." +- *Effort:* **M** · *GW:* **client-only** if transcription uses native or the existing + `/api/audio/transcribe`; the agent run is already supported. +- *Priority:* **P2** — compelling but larger; validate demand first. + +### J. Accessibility + +**J1. TTS read-aloud of responses.** +- *Pattern / who:* **LMSA**, Open WebUI, Enchanted all offer TTS output. Hermes has voice *in* + (dictation) but not voice *out*. +- *Fit:* Medium. Native Android `TextToSpeech` to read an assistant turn — genuinely useful for long + agent reports while away from the screen, and an accessibility win. +- *Effort:* **S** · *GW:* **client-only** (native TTS). +- *Priority:* **P2** — cheap, rounds out the voice story without the cost of live voice mode. + +--- + +## 3. Explicitly Hermes-differentiating bets + +These exploit self-hosted / multi-tenant / agentic architecture — a generic consumer app **cannot** +copy them without becoming Hermes. + +1. **Cross-tenant Approvals & Needs-you inbox (A2)** — a single triage queue spanning every isolated + org. Impossible for single-account apps by construction. This is the multi-tenant moat made into a + daily-use surface. +2. **Live in-flight run progress across tenants (A1)** — a color-coded "who's running what right now" + Live Update. Consumer apps show one account's one task; Hermes can show acme + globex agents at once + on the AOD. +3. **Per-tenant agent/persona presets (C1) + approval-policy dashboard (C2)** — persona and governance + bundles that are *scoped to a tenant*. Grok/Gemini personas are global to one user; Hermes' are the + isolation boundary. +4. **Backend-health awareness (A4)** — the one thing only a self-hosted client owes its user. No + consumer app will ever build "your gateway is down," because they *are* the gateway. +5. **Self-hosted-native onboarding: LAN discovery + QR pair (D1)** — pairing to *your own* box on your + own network/Tailscale. A consumer SaaS has nothing to discover. +6. **Per-tenant memory audit (G1)** — memory-bleed between orgs is a multi-tenant-only risk; a + tenant-scoped, individually-deletable memory viewer is a trust feature consumer apps don't need. + +The through-line: consumer apps optimize *one* account's *one* conversation; Hermes' unclaimed +territory is **operating many isolated agent fleets from a phone** — inboxes, live status, personas, +policy, and health, all per-tenant. + +--- + +## 4. Anti-recommendations (do NOT chase) + +1. **Live two-way voice/camera mode (Gemini Live, ChatGPT Voice, Grok Voice).** Real-time bidirectional + audio/video needs streaming multimodal infra end-to-end (gateway + model). Enormous build, and a + self-hosted *agent-ops* tool isn't where "talk to it like a person" pays off. Dictation + TTS + (shipped + J1) cover the realistic need. +2. **On-device / local inference (LLMFarm, Private LLM, LM Studio mobile).** Hermes' entire premise is a + *remote* gateway that holds the models, credentials, and tools. Bundling a local model contradicts + the architecture and fragments where "the agent" lives. +3. **Image/video generation & "Imagine"/Sora-style creative surfaces (Grok, Gemini, ChatGPT).** Not the + niche; it's an operator's console, not a content studio. +4. **A full mobile code editor / heavy multi-file diff authoring (Cursor's own reviews call >80-line + diffs "problematic" on a phone).** Keep any agent-changes review **read-mostly** (the planned diff + viewer is right-sized); don't try to become an IDE. +5. **Connector / knowledge-file management UI (ChatGPT Projects' 40-file uploads, Claude Drive + connector).** Data sources, RAG, and connectors belong to the gateway/MCP layer Hermes already + talks to. Rebuilding a file/connector manager in the app duplicates server responsibility and blows + the "no new gateway endpoints" budget. +6. **CarPlay / Android Auto (ChatGPT CarPlay).** An approval-gated, human-in-the-loop agent tool is a + poor fit for a driving context; low ROI, safety-awkward. +7. **Social / discovery feeds, public prompt marketplaces (Poe-style).** Off-mission for a private, + self-hosted operator tool. + +--- + +## 5. Quick wins next (pick up immediately) + +1. **Per-session generation controls + system-prompt override (B1)** — S, client-only (pending a + one-line payload check). Direct hit on the self-hosting power-user. +2. **Backend-health signal (A4)** — S, client-only. Cheap, and only Hermes can offer it. +3. **Act-on-result quick actions on completion notifications (A3)** — S–M, client-only. Completes the + loop the shipped completion-push feature opened. +4. **TTS read-aloud (J1)** — S, client-only native `TextToSpeech`. Rounds out voice cheaply; accessibility win. +5. **LAN/Tailscale auto-discovery + QR-pair setup (D1)** — start with mDNS discovery (client-only) now; + add the dashboard QR (small gateway assist) as a fast-follow. Fixes the most error-prone step in the app. + +--- + +*Snapshot note: competitor model/version names are a mid-2026 moment and will drift; the durable +findings are the feature **patterns**, not the version numbers. Tenant names are generic throughout.* From de90709733118530dded384ed5084dbf63927abe Mon Sep 17 00:00:00 2001 From: Andrew Debnar <606931+adebnar@users.noreply.github.com> Date: Fri, 17 Jul 2026 01:18:16 +0000 Subject: [PATCH 04/16] feat: proactive backend-health signal (#98) * docs: spec for backend-health signal (quick-wins wave 1) * docs: implementation plan for backend-health signal * feat: add GatewayHealth model * feat: add GatewayHealthMonitor with /api/status probing * fix: propagate genuine cancellation in GatewayHealthMonitor probe attemptStatus() caught the generic CancellationException along with TimeoutCancellationException, silently swallowing real structured- cancellation (e.g. stopForeground() cancelling periodicJob mid-probe) and letting evaluate() write a false GatewayUnreachable state. Split the catch chain so only the timeout subclass is treated as retryable; any other CancellationException is rethrown. * feat: add HealthStrip + HealthSheet with pure mapping helpers * feat: surface backend health as a shell strip, You-tab badge, and detail sheet * fix: suppress false unreachable signal before a gateway is configured --- app/src/main/AndroidManifest.xml | 1 + .../client/data/network/GatewayHealth.kt | 23 + .../data/network/GatewayHealthMonitor.kt | 128 +++ .../java/com/hermes/client/di/AppModule.kt | 17 + .../client/ui/components/HealthStrip.kt | 118 +++ .../com/hermes/client/ui/nav/HermesNav.kt | 76 +- .../hermes/client/ui/nav/ShellViewModel.kt | 11 + .../data/network/GatewayHealthMonitorTest.kt | 112 +++ .../client/data/network/GatewayHealthTest.kt | 17 + .../client/ui/components/HealthStripTest.kt | 47 + .../plans/2026-07-16-backend-health-signal.md | 820 ++++++++++++++++++ ...2026-07-16-backend-health-signal-design.md | 151 ++++ 12 files changed, 1514 insertions(+), 7 deletions(-) create mode 100644 app/src/main/java/com/hermes/client/data/network/GatewayHealth.kt create mode 100644 app/src/main/java/com/hermes/client/data/network/GatewayHealthMonitor.kt create mode 100644 app/src/main/java/com/hermes/client/ui/components/HealthStrip.kt create mode 100644 app/src/test/java/com/hermes/client/data/network/GatewayHealthMonitorTest.kt create mode 100644 app/src/test/java/com/hermes/client/data/network/GatewayHealthTest.kt create mode 100644 app/src/test/java/com/hermes/client/ui/components/HealthStripTest.kt create mode 100644 docs/superpowers/plans/2026-07-16-backend-health-signal.md create mode 100644 docs/superpowers/specs/2026-07-16-backend-health-signal-design.md diff --git a/app/src/main/AndroidManifest.xml b/app/src/main/AndroidManifest.xml index 6d44e85..ba2476d 100644 --- a/app/src/main/AndroidManifest.xml +++ b/app/src/main/AndroidManifest.xml @@ -2,6 +2,7 @@ + diff --git a/app/src/main/java/com/hermes/client/data/network/GatewayHealth.kt b/app/src/main/java/com/hermes/client/data/network/GatewayHealth.kt new file mode 100644 index 0000000..e453e2c --- /dev/null +++ b/app/src/main/java/com/hermes/client/data/network/GatewayHealth.kt @@ -0,0 +1,23 @@ +package com.hermes.client.data.network + +/** + * Backend health, distinct from the WebSocket [ConnectionState]. Sourced from the device's + * connectivity plus the gateway's public `/api/status`. + */ +sealed interface GatewayHealth { + /** Before the first probe completes — renders nothing. */ + data object Unknown : GatewayHealth + + /** `/api/status` returned 2xx. [running] mirrors `gateway_running`. */ + data class Healthy(val version: String?, val running: Boolean, val latencyMs: Long?) : GatewayHealth + + /** The device has no network — the phone is offline, not the gateway. */ + data object DeviceOffline : GatewayHealth + + /** Network is up but `/api/status` failed (timeout, refused, non-2xx). */ + data class GatewayUnreachable(val detail: String?) : GatewayHealth +} + +/** True when the down-strip and the You-tab badge should show. */ +fun GatewayHealth.isUnhealthy(): Boolean = + this is GatewayHealth.DeviceOffline || this is GatewayHealth.GatewayUnreachable diff --git a/app/src/main/java/com/hermes/client/data/network/GatewayHealthMonitor.kt b/app/src/main/java/com/hermes/client/data/network/GatewayHealthMonitor.kt new file mode 100644 index 0000000..3185e02 --- /dev/null +++ b/app/src/main/java/com/hermes/client/data/network/GatewayHealthMonitor.kt @@ -0,0 +1,128 @@ +package com.hermes.client.data.network + +import android.content.Context +import android.net.ConnectivityManager +import android.net.NetworkCapabilities +import kotlinx.coroutines.CancellationException +import kotlinx.coroutines.CoroutineScope +import kotlinx.coroutines.Job +import kotlinx.coroutines.TimeoutCancellationException +import kotlinx.coroutines.delay +import kotlinx.coroutines.flow.MutableStateFlow +import kotlinx.coroutines.flow.StateFlow +import kotlinx.coroutines.flow.asStateFlow +import kotlinx.coroutines.launch +import kotlinx.coroutines.sync.Mutex +import kotlinx.coroutines.withTimeout + +/** Abstraction over Android connectivity so the monitor is unit-testable. */ +interface ConnectivityChecker { + /** True when the device has a validated, internet-capable network. */ + fun isOnline(): Boolean +} + +class AndroidConnectivityChecker(private val context: Context) : ConnectivityChecker { + override fun isOnline(): Boolean { + // If we can't read connectivity, assume online rather than false-flag DeviceOffline — + // the /api/status probe is then the source of truth. + val cm = context.getSystemService(Context.CONNECTIVITY_SERVICE) as? ConnectivityManager ?: return true + val net = cm.activeNetwork ?: return false + val caps = cm.getNetworkCapabilities(net) ?: return false + return caps.hasCapability(NetworkCapabilities.NET_CAPABILITY_INTERNET) && + caps.hasCapability(NetworkCapabilities.NET_CAPABILITY_VALIDATED) + } +} + +/** + * Proactively tracks whether the self-hosted gateway is reachable, exposed as [health]. + * Probes device connectivity first (→ [GatewayHealth.DeviceOffline]) then the public + * `/api/status` endpoint, with one immediate retry as debounce so a transient blip does not + * flash the down-strip. Probing runs only while the app is foregrounded (see [startForeground]). + */ +class GatewayHealthMonitor( + private val api: HermesRestApi, + private val connectivity: ConnectivityChecker, + private val connectionState: StateFlow, + private val scope: CoroutineScope, +) { + private val _health = MutableStateFlow(GatewayHealth.Unknown) + val health: StateFlow = _health.asStateFlow() + + private val probeGuard = Mutex() + private var periodicJob: Job? = null + + init { + // A dropped/errored socket is an early hint the backend may be gone — re-probe promptly. + // probe()'s tryLock coalesces this with any in-flight probe. + scope.launch { + connectionState.collect { st -> + if (st is ConnectionState.Error || st is ConnectionState.Disconnected) probe() + } + } + } + + /** Run one health probe, coalescing with any probe already in flight. */ + suspend fun probe() { + if (!probeGuard.tryLock()) return + try { + _health.value = evaluate() + } finally { + probeGuard.unlock() + } + } + + private suspend fun evaluate(): GatewayHealth { + if (!connectivity.isOnline()) return GatewayHealth.DeviceOffline + // First attempt; on a retryable failure (null) try once more before declaring the gateway down. + return attemptStatus() ?: attemptStatus() ?: GatewayHealth.GatewayUnreachable("unreachable") + } + + /** Terminal state on a definitive answer (healthy / unauthorized), or null for a retryable failure. */ + private suspend fun attemptStatus(): GatewayHealth? { + val start = System.nanoTime() + return try { + val dto = withTimeout(PROBE_TIMEOUT_MS) { api.gatewayStatus() } + val latencyMs = (System.nanoTime() - start) / 1_000_000 + GatewayHealth.Healthy(version = dto.version, running = dto.gatewayRunning, latencyMs = latencyMs) + } catch (e: HermesApiException) { + when (e.code) { + 401 -> GatewayHealth.GatewayUnreachable("unauthorized") // definitive + 0 -> GatewayHealth.Unknown // no gateway configured yet — not a down state + else -> null // retryable + } + } catch (e: TimeoutCancellationException) { + null // probe timed out — retryable + } catch (e: CancellationException) { + throw e // genuine cancellation (e.g. stopForeground) — never swallow + } catch (e: Exception) { + null // IO / other — retryable + } + } + + /** Fire an immediate probe (the sheet's Re-check button). */ + fun recheck() { + scope.launch { probe() } + } + + /** Begin foreground probing: probe now, then every [PROBE_INTERVAL_MS]. Idempotent. */ + fun startForeground() { + if (periodicJob?.isActive == true) return + periodicJob = scope.launch { + while (true) { + probe() + delay(PROBE_INTERVAL_MS) + } + } + } + + /** Stop foreground probing (app backgrounded). */ + fun stopForeground() { + periodicJob?.cancel() + periodicJob = null + } + + companion object { + const val PROBE_TIMEOUT_MS = 5_000L + const val PROBE_INTERVAL_MS = 30_000L + } +} diff --git a/app/src/main/java/com/hermes/client/di/AppModule.kt b/app/src/main/java/com/hermes/client/di/AppModule.kt index d077ce0..181cf00 100644 --- a/app/src/main/java/com/hermes/client/di/AppModule.kt +++ b/app/src/main/java/com/hermes/client/di/AppModule.kt @@ -103,6 +103,23 @@ object AppModule { configProvider = { store.load() }, ) + @Provides + @Singleton + fun provideConnectivityChecker( + @ApplicationContext context: Context, + ): com.hermes.client.data.network.ConnectivityChecker = + com.hermes.client.data.network.AndroidConnectivityChecker(context) + + @Provides + @Singleton + fun provideGatewayHealthMonitor( + api: HermesRestApi, + connectivity: com.hermes.client.data.network.ConnectivityChecker, + client: HermesGatewayClient, + scope: CoroutineScope, + ): com.hermes.client.data.network.GatewayHealthMonitor = + com.hermes.client.data.network.GatewayHealthMonitor(api, connectivity, client.connectionState, scope) + @Provides @Singleton fun provideChatRepository(client: HermesGatewayClient): ChatRepository = diff --git a/app/src/main/java/com/hermes/client/ui/components/HealthStrip.kt b/app/src/main/java/com/hermes/client/ui/components/HealthStrip.kt new file mode 100644 index 0000000..3fdfeab --- /dev/null +++ b/app/src/main/java/com/hermes/client/ui/components/HealthStrip.kt @@ -0,0 +1,118 @@ +package com.hermes.client.ui.components + +import androidx.compose.foundation.background +import androidx.compose.foundation.clickable +import androidx.compose.foundation.layout.Arrangement +import androidx.compose.foundation.layout.Column +import androidx.compose.foundation.layout.Row +import androidx.compose.foundation.layout.fillMaxWidth +import androidx.compose.foundation.layout.padding +import androidx.compose.foundation.layout.statusBarsPadding +import androidx.compose.material.icons.Icons +import androidx.compose.material.icons.automirrored.rounded.KeyboardArrowRight +import androidx.compose.material.icons.rounded.CloudOff +import androidx.compose.material3.ExperimentalMaterial3Api +import androidx.compose.material3.Icon +import androidx.compose.material3.MaterialTheme +import androidx.compose.material3.ModalBottomSheet +import androidx.compose.material3.Text +import androidx.compose.material3.TextButton +import androidx.compose.runtime.Composable +import androidx.compose.ui.Alignment +import androidx.compose.ui.Modifier +import androidx.compose.ui.graphics.Color +import androidx.compose.ui.text.style.TextAlign +import androidx.compose.ui.unit.dp +import com.hermes.client.data.network.GatewayHealth +import com.hermes.client.ui.theme.LocalProfileAccent + +/** Visual severity of the strip. Kept separate from color so it is unit-testable. */ +enum class HealthStripStyle { ERROR, NEUTRAL, NONE } + +fun healthStripStyle(health: GatewayHealth): HealthStripStyle = when (health) { + is GatewayHealth.GatewayUnreachable -> HealthStripStyle.ERROR + GatewayHealth.DeviceOffline -> HealthStripStyle.NEUTRAL + is GatewayHealth.Healthy, GatewayHealth.Unknown -> HealthStripStyle.NONE +} + +/** Short strip label; null when nothing should show. */ +fun healthStripLabel(health: GatewayHealth): String? = when (health) { + GatewayHealth.DeviceOffline -> "You're offline" + is GatewayHealth.GatewayUnreachable -> + if (health.detail == "unauthorized") "Gateway unauthorized" else "Gateway unreachable" + is GatewayHealth.Healthy, GatewayHealth.Unknown -> null +} + +/** Sheet detail copy for the current state. */ +fun healthSheetBody(health: GatewayHealth): String = when (health) { + is GatewayHealth.Healthy -> buildString { + append(if (health.running) "Gateway running" else "Gateway reachable, not running") + health.version?.let { append(" · v").append(it) } + health.latencyMs?.let { append(" · ").append(it).append(" ms") } + } + is GatewayHealth.GatewayUnreachable -> + if (health.detail == "unauthorized") "The gateway rejected the session token (unauthorized)." + else "The gateway isn't responding. It may be down or restarting." + GatewayHealth.DeviceOffline -> "Your device is offline — Hermes will reconnect automatically." + GatewayHealth.Unknown -> "Checking…" +} + +/** + * Slim status strip shown across all screens ONLY when unhealthy. Applies its own status-bar + * padding so it sits below the system bar; callers should consume the status-bars inset for the + * content beneath it so the screen's own top bar does not add a second gap. + */ +@Composable +fun HealthStrip(health: GatewayHealth, onClick: () -> Unit, modifier: Modifier = Modifier) { + val label = healthStripLabel(health) ?: return + val bg = when (healthStripStyle(health)) { + HealthStripStyle.ERROR -> MaterialTheme.colorScheme.errorContainer + HealthStripStyle.NEUTRAL -> MaterialTheme.colorScheme.surfaceVariant + HealthStripStyle.NONE -> Color.Transparent + } + val fg = when (healthStripStyle(health)) { + HealthStripStyle.ERROR -> MaterialTheme.colorScheme.onErrorContainer + else -> MaterialTheme.colorScheme.onSurfaceVariant + } + Row( + modifier = modifier + .fillMaxWidth() + .background(bg) + .clickable(onClick = onClick) + .statusBarsPadding() + .padding(horizontal = 16.dp, vertical = 8.dp), + verticalAlignment = Alignment.CenterVertically, + horizontalArrangement = Arrangement.Start, + ) { + Icon(Icons.Rounded.CloudOff, contentDescription = null, tint = fg, modifier = Modifier.padding(end = 8.dp)) + Text(label, style = MaterialTheme.typography.labelLarge, color = fg, modifier = Modifier.padding(end = 8.dp)) + Row(Modifier.fillMaxWidth(), horizontalArrangement = Arrangement.End) { + Icon(Icons.AutoMirrored.Rounded.KeyboardArrowRight, contentDescription = null, tint = fg) + } + } +} + +@OptIn(ExperimentalMaterial3Api::class) +@Composable +fun HealthSheet(health: GatewayHealth, onRecheck: () -> Unit, onDismiss: () -> Unit) { + val accent = LocalProfileAccent.current.accent + ModalBottomSheet(onDismissRequest = onDismiss) { + Column(Modifier.fillMaxWidth().padding(24.dp)) { + Text( + healthStripLabel(health) ?: "Gateway", + style = MaterialTheme.typography.titleMedium, + ) + Text( + healthSheetBody(health), + style = MaterialTheme.typography.bodyMedium, + color = MaterialTheme.colorScheme.onSurfaceVariant, + modifier = Modifier.padding(top = 8.dp), + ) + Row(Modifier.fillMaxWidth().padding(top = 16.dp), horizontalArrangement = Arrangement.End) { + TextButton(onClick = onRecheck) { + Text("Re-check", color = accent, textAlign = TextAlign.End) + } + } + } + } +} diff --git a/app/src/main/java/com/hermes/client/ui/nav/HermesNav.kt b/app/src/main/java/com/hermes/client/ui/nav/HermesNav.kt index cfa2707..060370b 100644 --- a/app/src/main/java/com/hermes/client/ui/nav/HermesNav.kt +++ b/app/src/main/java/com/hermes/client/ui/nav/HermesNav.kt @@ -1,28 +1,47 @@ package com.hermes.client.ui.nav +import androidx.compose.foundation.layout.Column import androidx.compose.foundation.layout.WindowInsets +import androidx.compose.foundation.layout.consumeWindowInsets +import androidx.compose.foundation.layout.fillMaxSize import androidx.compose.foundation.layout.padding +import androidx.compose.foundation.layout.statusBars import androidx.compose.material.icons.Icons import androidx.compose.material.icons.automirrored.rounded.Chat import androidx.compose.material.icons.rounded.Home import androidx.compose.material.icons.rounded.Person +import androidx.compose.material3.Badge +import androidx.compose.material3.BadgedBox import androidx.compose.material3.Icon import androidx.compose.material3.NavigationBar import androidx.compose.material3.NavigationBarItem import androidx.compose.material3.Scaffold import androidx.compose.material3.Text import androidx.compose.runtime.Composable +import androidx.compose.runtime.DisposableEffect import androidx.compose.runtime.LaunchedEffect 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.Modifier import androidx.compose.ui.graphics.vector.ImageVector +import androidx.hilt.lifecycle.viewmodel.compose.hiltViewModel +import androidx.lifecycle.Lifecycle +import androidx.lifecycle.LifecycleEventObserver +import androidx.lifecycle.ProcessLifecycleOwner +import androidx.lifecycle.compose.collectAsStateWithLifecycle import androidx.navigation.NavGraph.Companion.findStartDestination import androidx.navigation.compose.NavHost import androidx.navigation.compose.composable import androidx.navigation.compose.currentBackStackEntryAsState import androidx.navigation.compose.rememberNavController +import com.hermes.client.data.network.isUnhealthy import com.hermes.client.ui.admin.SessionAdminScreen import com.hermes.client.ui.chat.ChatScreen +import com.hermes.client.ui.components.HealthSheet +import com.hermes.client.ui.components.HealthStrip import com.hermes.client.ui.cron.CronDetailScreen import com.hermes.client.ui.cron.CronEditScreen import com.hermes.client.ui.cron.CronScreen @@ -75,6 +94,24 @@ fun HermesNav(hasConfig: Boolean, deepLinkRoute: String? = null, onDeepLinkConsu val backStackEntry by nav.currentBackStackEntryAsState() val route = backStackEntry?.destination?.route + val shellVm: ShellViewModel = hiltViewModel() + val health by shellVm.health.collectAsStateWithLifecycle() + var showHealthSheet by rememberSaveable { mutableStateOf(false) } + + // Probe only while the app is foregrounded (in-app-only v1). ProcessLifecycleOwner replays its + // current state on addObserver, so ON_START fires immediately if already foregrounded. + DisposableEffect(Unit) { + val obs = LifecycleEventObserver { _, e -> + when (e) { + Lifecycle.Event.ON_START -> shellVm.onAppForeground() + Lifecycle.Event.ON_STOP -> shellVm.onAppBackground() + else -> {} + } + } + ProcessLifecycleOwner.get().lifecycle.addObserver(obs) + onDispose { ProcessLifecycleOwner.get().lifecycle.removeObserver(obs) } + } + val onUnauthorized: () -> Unit = { nav.navigate("setup") { popUpTo(0) { inclusive = true } } } @@ -106,7 +143,15 @@ fun HermesNav(hasConfig: Boolean, deepLinkRoute: String? = null, onDeepLinkConsu NavigationBarItem( selected = route == tab.route, onClick = { switchTab(tab.route) }, - icon = { Icon(tab.icon, contentDescription = tab.label) }, + icon = { + if (tab.route == "you" && hasConfig && health.isUnhealthy()) { + BadgedBox(badge = { Badge() }) { + Icon(tab.icon, contentDescription = tab.label) + } + } else { + Icon(tab.icon, contentDescription = tab.label) + } + }, label = { Text(tab.label) }, ) } @@ -114,12 +159,20 @@ fun HermesNav(hasConfig: Boolean, deepLinkRoute: String? = null, onDeepLinkConsu } }, ) { padding -> - NavHost( - navController = nav, - startDestination = start, - // Only reserve space for the bottom bar; top/side insets are each screen's own job. - modifier = Modifier.padding(bottom = padding.calculateBottomPadding()), - ) { + Column(Modifier.fillMaxSize().padding(bottom = padding.calculateBottomPadding())) { + // Renders nothing when healthy. When shown it owns the status-bar inset, so the content + // below consumes that inset to avoid a second top gap under the strip. + if (hasConfig && health.isUnhealthy()) { + HealthStrip(health = health, onClick = { showHealthSheet = true }) + } + val contentModifier = + if (hasConfig && health.isUnhealthy()) Modifier.weight(1f).consumeWindowInsets(WindowInsets.statusBars) + else Modifier.weight(1f) + NavHost( + navController = nav, + startDestination = start, + modifier = contentModifier, + ) { composable("setup") { SetupScreen( onSaved = { @@ -222,6 +275,15 @@ fun HermesNav(hasConfig: Boolean, deepLinkRoute: String? = null, onDeepLinkConsu ) } composable("agents_tools") { AgentsToolsScreen(onMenu = back) } + } } } + + if (showHealthSheet) { + HealthSheet( + health = health, + onRecheck = { shellVm.recheckHealth() }, + onDismiss = { showHealthSheet = false }, + ) + } } diff --git a/app/src/main/java/com/hermes/client/ui/nav/ShellViewModel.kt b/app/src/main/java/com/hermes/client/ui/nav/ShellViewModel.kt index 5ab0448..b2cedf0 100644 --- a/app/src/main/java/com/hermes/client/ui/nav/ShellViewModel.kt +++ b/app/src/main/java/com/hermes/client/ui/nav/ShellViewModel.kt @@ -14,10 +14,14 @@ import javax.inject.Inject class ShellViewModel @Inject constructor( private val profileManager: ProfileManager, private val accentStore: ProfileAccentStore, + private val healthMonitor: com.hermes.client.data.network.GatewayHealthMonitor, ) : ViewModel() { val profiles: StateFlow> = profileManager.list val active: StateFlow = profileManager.active + /** Backend health for the shell's status strip + You-tab badge. */ + val health: StateFlow = healthMonitor.health + init { viewModelScope.launch { profileManager.refresh() } } fun switchProfile(name: String) = viewModelScope.launch { profileManager.switchTo(name) } @@ -27,4 +31,11 @@ class ShellViewModel @Inject constructor( /** Clear [profile]'s custom colour, reverting to the auto hue. */ fun clearAccent(profile: String) = viewModelScope.launch { accentStore.clear(profile) } + + /** Fire an immediate health probe (Re-check button). */ + fun recheckHealth() = healthMonitor.recheck() + + /** Foreground/background gating for periodic probing. */ + fun onAppForeground() = healthMonitor.startForeground() + fun onAppBackground() = healthMonitor.stopForeground() } diff --git a/app/src/test/java/com/hermes/client/data/network/GatewayHealthMonitorTest.kt b/app/src/test/java/com/hermes/client/data/network/GatewayHealthMonitorTest.kt new file mode 100644 index 0000000..179f507 --- /dev/null +++ b/app/src/test/java/com/hermes/client/data/network/GatewayHealthMonitorTest.kt @@ -0,0 +1,112 @@ +package com.hermes.client.data.network + +import io.mockk.Runs +import io.mockk.coEvery +import io.mockk.just +import io.mockk.mockk +import kotlinx.coroutines.ExperimentalCoroutinesApi +import kotlinx.coroutines.flow.MutableStateFlow +import kotlinx.coroutines.test.UnconfinedTestDispatcher +import kotlinx.coroutines.test.advanceUntilIdle +import kotlinx.coroutines.test.runTest +import org.junit.Assert.assertEquals +import org.junit.Assert.assertTrue +import org.junit.Test + +@OptIn(ExperimentalCoroutinesApi::class) +class GatewayHealthMonitorTest { + private val api = mockk() + private class FakeConnectivity(var online: Boolean = true) : ConnectivityChecker { + override fun isOnline() = online + } + + private fun ok() = GatewayStatusDto(version = "1.2.3", gatewayRunning = true, gatewayState = "running") + + @Test fun probe_reports_healthy_on_2xx() = runTest { + coEvery { api.gatewayStatus() } returns ok() + val m = GatewayHealthMonitor(api, FakeConnectivity(true), MutableStateFlow(ConnectionState.Connected), backgroundScope) + m.probe() + val h = m.health.value + assertTrue(h is GatewayHealth.Healthy) + assertEquals("1.2.3", (h as GatewayHealth.Healthy).version) + assertTrue(h.running) + } + + @Test fun probe_reports_device_offline_without_calling_api() = runTest { + val conn = FakeConnectivity(online = false) + val m = GatewayHealthMonitor(api, conn, MutableStateFlow(ConnectionState.Connected), backgroundScope) + m.probe() + assertEquals(GatewayHealth.DeviceOffline, m.health.value) + io.mockk.coVerify(exactly = 0) { api.gatewayStatus() } + } + + @Test fun probe_reports_gateway_unreachable_when_both_attempts_fail() = runTest { + coEvery { api.gatewayStatus() } throws RuntimeException("timeout") + val m = GatewayHealthMonitor(api, FakeConnectivity(true), MutableStateFlow(ConnectionState.Connected), backgroundScope) + m.probe() + assertTrue(m.health.value is GatewayHealth.GatewayUnreachable) + } + + @Test fun transient_first_failure_then_success_stays_healthy() = runTest { + (coEvery { api.gatewayStatus() } throws RuntimeException("blip")).andThen(ok()) + val m = GatewayHealthMonitor(api, FakeConnectivity(true), MutableStateFlow(ConnectionState.Connected), backgroundScope) + m.probe() + assertTrue(m.health.value is GatewayHealth.Healthy) + } + + @Test fun unauthorized_is_reported_without_retry() = runTest { + coEvery { api.gatewayStatus() } throws HermesApiException(401, "unauthorized") + val m = GatewayHealthMonitor(api, FakeConnectivity(true), MutableStateFlow(ConnectionState.Connected), backgroundScope) + m.probe() + val h = m.health.value + assertTrue(h is GatewayHealth.GatewayUnreachable) + assertEquals("unauthorized", (h as GatewayHealth.GatewayUnreachable).detail) + io.mockk.coVerify(exactly = 1) { api.gatewayStatus() } + } + + @Test fun no_gateway_configured_maps_to_unknown_not_unreachable() = runTest { + coEvery { api.gatewayStatus() } throws HermesApiException(0, "no gateway configured") + val m = GatewayHealthMonitor(api, FakeConnectivity(true), MutableStateFlow(ConnectionState.Connected), backgroundScope) + m.probe() + assertEquals(GatewayHealth.Unknown, m.health.value) + io.mockk.coVerify(exactly = 1) { api.gatewayStatus() } + } + + @Test fun genuine_cancellation_propagates_and_does_not_mark_unreachable() = runTest { + coEvery { api.gatewayStatus() } throws kotlinx.coroutines.CancellationException("cancelled") + val m = GatewayHealthMonitor(api, FakeConnectivity(true), MutableStateFlow(ConnectionState.Connected), backgroundScope) + var threw = false + try { + m.probe() + } catch (e: kotlinx.coroutines.CancellationException) { + threw = true + } + assertTrue(threw) + assertTrue(m.health.value is GatewayHealth.Unknown) // never set to a down state + } + + @Test fun recovery_from_unreachable_to_healthy() = runTest { + (coEvery { api.gatewayStatus() } throws RuntimeException("down")).andThenThrows(RuntimeException("down")).andThen(ok()) + val m = GatewayHealthMonitor(api, FakeConnectivity(true), MutableStateFlow(ConnectionState.Connected), backgroundScope) + m.probe() // both attempts fail -> unreachable + assertTrue(m.health.value is GatewayHealth.GatewayUnreachable) + m.probe() // next probe succeeds + assertTrue(m.health.value is GatewayHealth.Healthy) + } + + // Uses UnconfinedTestDispatcher: with this project's kotlinx-coroutines-test 1.11.0 / + // Kotlin 2.3.10 pairing, StandardTestDispatcher (the runTest default) never dispatches a + // backgroundScope.launch child via advanceUntilIdle() — reproduced with a minimal + // backgroundScope.launch { flow.collect {} } case outside this class. Unconfined avoids it; + // the assertions below are unchanged from the brief. + @Test fun ws_disconnect_triggers_a_probe() = runTest(UnconfinedTestDispatcher()) { + coEvery { api.gatewayStatus() } returns ok() + val conn = MutableStateFlow(ConnectionState.Connected) + val m = GatewayHealthMonitor(api, FakeConnectivity(true), conn, backgroundScope) + advanceUntilIdle() + conn.value = ConnectionState.Disconnected + advanceUntilIdle() + assertTrue(m.health.value is GatewayHealth.Healthy) + io.mockk.coVerify(atLeast = 1) { api.gatewayStatus() } + } +} diff --git a/app/src/test/java/com/hermes/client/data/network/GatewayHealthTest.kt b/app/src/test/java/com/hermes/client/data/network/GatewayHealthTest.kt new file mode 100644 index 0000000..39ce400 --- /dev/null +++ b/app/src/test/java/com/hermes/client/data/network/GatewayHealthTest.kt @@ -0,0 +1,17 @@ +package com.hermes.client.data.network + +import org.junit.Assert.assertFalse +import org.junit.Assert.assertTrue +import org.junit.Test + +class GatewayHealthTest { + @Test fun healthy_and_unknown_are_not_unhealthy() { + assertFalse(GatewayHealth.Unknown.isUnhealthy()) + assertFalse(GatewayHealth.Healthy(version = "1.2.3", running = true, latencyMs = 42).isUnhealthy()) + } + + @Test fun device_offline_and_gateway_unreachable_are_unhealthy() { + assertTrue(GatewayHealth.DeviceOffline.isUnhealthy()) + assertTrue(GatewayHealth.GatewayUnreachable("unreachable").isUnhealthy()) + } +} diff --git a/app/src/test/java/com/hermes/client/ui/components/HealthStripTest.kt b/app/src/test/java/com/hermes/client/ui/components/HealthStripTest.kt new file mode 100644 index 0000000..3f9dc3a --- /dev/null +++ b/app/src/test/java/com/hermes/client/ui/components/HealthStripTest.kt @@ -0,0 +1,47 @@ +package com.hermes.client.ui.components + +import com.hermes.client.data.network.GatewayHealth +import org.junit.Assert.assertEquals +import org.junit.Assert.assertNull +import org.junit.Assert.assertTrue +import org.junit.Test + +class HealthStripTest { + @Test fun style_is_none_when_healthy_or_unknown() { + assertEquals(HealthStripStyle.NONE, healthStripStyle(GatewayHealth.Unknown)) + assertEquals(HealthStripStyle.NONE, healthStripStyle(GatewayHealth.Healthy("1", true, 10))) + } + + @Test fun style_error_for_gateway_unreachable_neutral_for_device_offline() { + assertEquals(HealthStripStyle.ERROR, healthStripStyle(GatewayHealth.GatewayUnreachable("x"))) + assertEquals(HealthStripStyle.NEUTRAL, healthStripStyle(GatewayHealth.DeviceOffline)) + } + + @Test fun label_null_when_healthy() { + assertNull(healthStripLabel(GatewayHealth.Healthy("1", true, 10))) + assertNull(healthStripLabel(GatewayHealth.Unknown)) + } + + @Test fun label_distinguishes_offline_unreachable_unauthorized() { + assertEquals("You're offline", healthStripLabel(GatewayHealth.DeviceOffline)) + assertEquals("Gateway unreachable", healthStripLabel(GatewayHealth.GatewayUnreachable("unreachable"))) + assertEquals("Gateway unauthorized", healthStripLabel(GatewayHealth.GatewayUnreachable("unauthorized"))) + } + + @Test fun sheet_body_healthy_includes_version_and_latency() { + val body = healthSheetBody(GatewayHealth.Healthy(version = "1.2.3", running = true, latencyMs = 42)) + assertTrue(body.contains("running")) + assertTrue(body.contains("1.2.3")) + assertTrue(body.contains("42")) + } + + @Test fun sheet_body_reachable_not_running_when_running_false() { + val body = healthSheetBody(GatewayHealth.Healthy(version = null, running = false, latencyMs = null)) + assertTrue(body.contains("not running")) + } + + @Test fun sheet_body_offline_and_unauthorized_copy() { + assertTrue(healthSheetBody(GatewayHealth.DeviceOffline).contains("offline")) + assertTrue(healthSheetBody(GatewayHealth.GatewayUnreachable("unauthorized")).contains("unauthorized")) + } +} diff --git a/docs/superpowers/plans/2026-07-16-backend-health-signal.md b/docs/superpowers/plans/2026-07-16-backend-health-signal.md new file mode 100644 index 0000000..f2ab049 --- /dev/null +++ b/docs/superpowers/plans/2026-07-16-backend-health-signal.md @@ -0,0 +1,820 @@ +# Backend-Health Signal Implementation Plan + +> **For agentic workers:** REQUIRED SUB-SKILL: Use superpowers:subagent-driven-development (recommended) or superpowers:executing-plans to implement this plan task-by-task. Steps use checkbox (`- [ ]`) syntax for tracking. + +**Goal:** Give the app an app-wide, proactive backend-health signal that distinguishes *your phone is offline* from *the gateway is down* from *gateway up* — visible on every screen, but only when unhealthy. + +**Architecture:** A Hilt-singleton `GatewayHealthMonitor` probes the existing public `GET /api/status` (device-connectivity check first, then a timed HTTP call with one retry as debounce), exposing `StateFlow`. The shell (`HermesNav` via `ShellViewModel`) renders a down-strip + a `You`-tab badge + a detail sheet from that state. Fully client-only — no gateway changes. + +**Tech Stack:** Kotlin, Jetpack Compose, Material3, Hilt, Coroutines/StateFlow, OkHttp (via existing `HermesRestApi`). + +**Spec:** `docs/superpowers/specs/2026-07-16-backend-health-signal-design.md` + +## Global Constraints + +- Client-only: use `HermesRestApi.gatewayStatus()` → `GatewayStatusDto { version: String?, gatewayRunning: Boolean, gatewayState: String? }`. No new gateway endpoints, no gateway edits. +- Kotlin/Compose/Material3; per-tenant accent via `LocalProfileAccent` for neutral chrome only — **down states use semantic colors (error / surfaceVariant), never the tenant accent.** +- `GatewayHealth` is distinct from the WS `ConnectionState`. +- Tests are pure-logic + monitor style (mockk `HermesRestApi`, fake `ConnectivityChecker`, `runTest`). **No Compose UI tests.** +- No AI/assistant attribution in commits or files. +- Build env: `export JAVA_HOME="/Applications/Android Studio.app/Contents/jbr/Contents/Home"`. +- Constants live in `GatewayHealthMonitor.Companion`: `PROBE_TIMEOUT_MS = 5_000L`, `PROBE_INTERVAL_MS = 30_000L`. +- Branch: `feature/backend-health-signal` (off `dev`; spec committed at `ee69173`). All commits land here. + +--- + +### Task 1: Health model + +**Files:** +- Create: `app/src/main/java/com/hermes/client/data/network/GatewayHealth.kt` +- Test: `app/src/test/java/com/hermes/client/data/network/GatewayHealthTest.kt` + +**Interfaces:** +- Produces: `sealed interface GatewayHealth` with `Unknown`, `Healthy(version: String?, running: Boolean, latencyMs: Long?)`, `DeviceOffline`, `GatewayUnreachable(detail: String?)`; and `fun GatewayHealth.isUnhealthy(): Boolean`. + +- [ ] **Step 1: Write the failing test** + +`app/src/test/java/com/hermes/client/data/network/GatewayHealthTest.kt`: +```kotlin +package com.hermes.client.data.network + +import org.junit.Assert.assertFalse +import org.junit.Assert.assertTrue +import org.junit.Test + +class GatewayHealthTest { + @Test fun healthy_and_unknown_are_not_unhealthy() { + assertFalse(GatewayHealth.Unknown.isUnhealthy()) + assertFalse(GatewayHealth.Healthy(version = "1.2.3", running = true, latencyMs = 42).isUnhealthy()) + } + + @Test fun device_offline_and_gateway_unreachable_are_unhealthy() { + assertTrue(GatewayHealth.DeviceOffline.isUnhealthy()) + assertTrue(GatewayHealth.GatewayUnreachable("unreachable").isUnhealthy()) + } +} +``` + +- [ ] **Step 2: Run test to verify it fails** + +Run: `./gradlew :app:testDebugUnitTest --tests "com.hermes.client.data.network.GatewayHealthTest"` +Expected: FAIL — `GatewayHealth` unresolved (does not compile yet). + +- [ ] **Step 3: Write minimal implementation** + +`app/src/main/java/com/hermes/client/data/network/GatewayHealth.kt`: +```kotlin +package com.hermes.client.data.network + +/** + * Backend health, distinct from the WebSocket [ConnectionState]. Sourced from the device's + * connectivity plus the gateway's public `/api/status`. + */ +sealed interface GatewayHealth { + /** Before the first probe completes — renders nothing. */ + data object Unknown : GatewayHealth + + /** `/api/status` returned 2xx. [running] mirrors `gateway_running`. */ + data class Healthy(val version: String?, val running: Boolean, val latencyMs: Long?) : GatewayHealth + + /** The device has no network — the phone is offline, not the gateway. */ + data object DeviceOffline : GatewayHealth + + /** Network is up but `/api/status` failed (timeout, refused, non-2xx). */ + data class GatewayUnreachable(val detail: String?) : GatewayHealth +} + +/** True when the down-strip and the You-tab badge should show. */ +fun GatewayHealth.isUnhealthy(): Boolean = + this is GatewayHealth.DeviceOffline || this is GatewayHealth.GatewayUnreachable +``` + +- [ ] **Step 4: Run test to verify it passes** + +Run: `./gradlew :app:testDebugUnitTest --tests "com.hermes.client.data.network.GatewayHealthTest"` +Expected: PASS (2 tests). + +- [ ] **Step 5: Commit** + +```bash +git add app/src/main/java/com/hermes/client/data/network/GatewayHealth.kt \ + app/src/test/java/com/hermes/client/data/network/GatewayHealthTest.kt +git commit -m "feat: add GatewayHealth model" +``` + +--- + +### Task 2: Connectivity checker + health monitor + +**Files:** +- Create: `app/src/main/java/com/hermes/client/data/network/GatewayHealthMonitor.kt` (holds both `ConnectivityChecker`/`AndroidConnectivityChecker` and `GatewayHealthMonitor`) +- Modify: `app/src/main/AndroidManifest.xml` (ensure `ACCESS_NETWORK_STATE` permission) +- Test: `app/src/test/java/com/hermes/client/data/network/GatewayHealthMonitorTest.kt` + +**Interfaces:** +- Consumes: `GatewayHealth` (Task 1); `HermesRestApi.gatewayStatus(): GatewayStatusDto`; `HermesApiException(val code: Int, message)`; `ConnectionState` sealed interface (`Connected`/`Connecting`/`Reconnecting`/`Disconnected`/`Error`). +- Produces: + - `interface ConnectivityChecker { fun isOnline(): Boolean }` + - `class AndroidConnectivityChecker(context: Context) : ConnectivityChecker` + - `class GatewayHealthMonitor(api: HermesRestApi, connectivity: ConnectivityChecker, connectionState: StateFlow, scope: CoroutineScope)` with `val health: StateFlow`, `suspend fun probe()`, `fun recheck()`, `fun startForeground()`, `fun stopForeground()`, and `companion object { const val PROBE_TIMEOUT_MS = 5_000L; const val PROBE_INTERVAL_MS = 30_000L }`. + +- [ ] **Step 1: Write the failing test** + +`app/src/test/java/com/hermes/client/data/network/GatewayHealthMonitorTest.kt`: +```kotlin +package com.hermes.client.data.network + +import io.mockk.Runs +import io.mockk.coEvery +import io.mockk.just +import io.mockk.mockk +import kotlinx.coroutines.ExperimentalCoroutinesApi +import kotlinx.coroutines.flow.MutableStateFlow +import kotlinx.coroutines.test.advanceUntilIdle +import kotlinx.coroutines.test.runTest +import org.junit.Assert.assertEquals +import org.junit.Assert.assertTrue +import org.junit.Test + +@OptIn(ExperimentalCoroutinesApi::class) +class GatewayHealthMonitorTest { + private val api = mockk() + private class FakeConnectivity(var online: Boolean = true) : ConnectivityChecker { + override fun isOnline() = online + } + + private fun ok() = GatewayStatusDto(version = "1.2.3", gatewayRunning = true, gatewayState = "running") + + @Test fun probe_reports_healthy_on_2xx() = runTest { + coEvery { api.gatewayStatus() } returns ok() + val m = GatewayHealthMonitor(api, FakeConnectivity(true), MutableStateFlow(ConnectionState.Connected), backgroundScope) + m.probe() + val h = m.health.value + assertTrue(h is GatewayHealth.Healthy) + assertEquals("1.2.3", (h as GatewayHealth.Healthy).version) + assertTrue(h.running) + } + + @Test fun probe_reports_device_offline_without_calling_api() = runTest { + val conn = FakeConnectivity(online = false) + val m = GatewayHealthMonitor(api, conn, MutableStateFlow(ConnectionState.Connected), backgroundScope) + m.probe() + assertEquals(GatewayHealth.DeviceOffline, m.health.value) + io.mockk.coVerify(exactly = 0) { api.gatewayStatus() } + } + + @Test fun probe_reports_gateway_unreachable_when_both_attempts_fail() = runTest { + coEvery { api.gatewayStatus() } throws RuntimeException("timeout") + val m = GatewayHealthMonitor(api, FakeConnectivity(true), MutableStateFlow(ConnectionState.Connected), backgroundScope) + m.probe() + assertTrue(m.health.value is GatewayHealth.GatewayUnreachable) + } + + @Test fun transient_first_failure_then_success_stays_healthy() = runTest { + coEvery { api.gatewayStatus() } throws RuntimeException("blip") andThen ok() + val m = GatewayHealthMonitor(api, FakeConnectivity(true), MutableStateFlow(ConnectionState.Connected), backgroundScope) + m.probe() + assertTrue(m.health.value is GatewayHealth.Healthy) + } + + @Test fun unauthorized_is_reported_without_retry() = runTest { + coEvery { api.gatewayStatus() } throws HermesApiException(401, "unauthorized") + val m = GatewayHealthMonitor(api, FakeConnectivity(true), MutableStateFlow(ConnectionState.Connected), backgroundScope) + m.probe() + val h = m.health.value + assertTrue(h is GatewayHealth.GatewayUnreachable) + assertEquals("unauthorized", (h as GatewayHealth.GatewayUnreachable).detail) + io.mockk.coVerify(exactly = 1) { api.gatewayStatus() } + } + + @Test fun recovery_from_unreachable_to_healthy() = runTest { + coEvery { api.gatewayStatus() } throws RuntimeException("down") andThen RuntimeException("down") andThen ok() + val m = GatewayHealthMonitor(api, FakeConnectivity(true), MutableStateFlow(ConnectionState.Connected), backgroundScope) + m.probe() // both attempts fail -> unreachable + assertTrue(m.health.value is GatewayHealth.GatewayUnreachable) + m.probe() // next probe succeeds + assertTrue(m.health.value is GatewayHealth.Healthy) + } + + @Test fun ws_disconnect_triggers_a_probe() = runTest { + coEvery { api.gatewayStatus() } returns ok() + val conn = MutableStateFlow(ConnectionState.Connected) + val m = GatewayHealthMonitor(api, FakeConnectivity(true), conn, backgroundScope) + advanceUntilIdle() + conn.value = ConnectionState.Disconnected + advanceUntilIdle() + assertTrue(m.health.value is GatewayHealth.Healthy) + io.mockk.coVerify(atLeast = 1) { api.gatewayStatus() } + } +} +``` + +- [ ] **Step 2: Run test to verify it fails** + +Run: `./gradlew :app:testDebugUnitTest --tests "com.hermes.client.data.network.GatewayHealthMonitorTest"` +Expected: FAIL — `ConnectivityChecker` / `GatewayHealthMonitor` unresolved. + +- [ ] **Step 3: Write minimal implementation** + +`app/src/main/java/com/hermes/client/data/network/GatewayHealthMonitor.kt`: +```kotlin +package com.hermes.client.data.network + +import android.content.Context +import android.net.ConnectivityManager +import android.net.NetworkCapabilities +import kotlinx.coroutines.CoroutineScope +import kotlinx.coroutines.Job +import kotlinx.coroutines.delay +import kotlinx.coroutines.flow.MutableStateFlow +import kotlinx.coroutines.flow.StateFlow +import kotlinx.coroutines.flow.asStateFlow +import kotlinx.coroutines.launch +import kotlinx.coroutines.sync.Mutex +import kotlinx.coroutines.withTimeout + +/** Abstraction over Android connectivity so the monitor is unit-testable. */ +interface ConnectivityChecker { + /** True when the device has a validated, internet-capable network. */ + fun isOnline(): Boolean +} + +class AndroidConnectivityChecker(private val context: Context) : ConnectivityChecker { + override fun isOnline(): Boolean { + // If we can't read connectivity, assume online rather than false-flag DeviceOffline — + // the /api/status probe is then the source of truth. + val cm = context.getSystemService(Context.CONNECTIVITY_SERVICE) as? ConnectivityManager ?: return true + val net = cm.activeNetwork ?: return false + val caps = cm.getNetworkCapabilities(net) ?: return false + return caps.hasCapability(NetworkCapabilities.NET_CAPABILITY_INTERNET) && + caps.hasCapability(NetworkCapabilities.NET_CAPABILITY_VALIDATED) + } +} + +/** + * Proactively tracks whether the self-hosted gateway is reachable, exposed as [health]. + * Probes device connectivity first (→ [GatewayHealth.DeviceOffline]) then the public + * `/api/status` endpoint, with one immediate retry as debounce so a transient blip does not + * flash the down-strip. Probing runs only while the app is foregrounded (see [startForeground]). + */ +class GatewayHealthMonitor( + private val api: HermesRestApi, + private val connectivity: ConnectivityChecker, + private val connectionState: StateFlow, + private val scope: CoroutineScope, +) { + private val _health = MutableStateFlow(GatewayHealth.Unknown) + val health: StateFlow = _health.asStateFlow() + + private val probeGuard = Mutex() + private var periodicJob: Job? = null + + init { + // A dropped/errored socket is an early hint the backend may be gone — re-probe promptly. + // probe()'s tryLock coalesces this with any in-flight probe. + scope.launch { + connectionState.collect { st -> + if (st is ConnectionState.Error || st is ConnectionState.Disconnected) probe() + } + } + } + + /** Run one health probe, coalescing with any probe already in flight. */ + suspend fun probe() { + if (!probeGuard.tryLock()) return + try { + _health.value = evaluate() + } finally { + probeGuard.unlock() + } + } + + private suspend fun evaluate(): GatewayHealth { + if (!connectivity.isOnline()) return GatewayHealth.DeviceOffline + // First attempt; on a retryable failure (null) try once more before declaring the gateway down. + return attemptStatus() ?: attemptStatus() ?: GatewayHealth.GatewayUnreachable("unreachable") + } + + /** Terminal state on a definitive answer (healthy / unauthorized), or null for a retryable failure. */ + private suspend fun attemptStatus(): GatewayHealth? { + val start = System.nanoTime() + return try { + val dto = withTimeout(PROBE_TIMEOUT_MS) { api.gatewayStatus() } + val latencyMs = (System.nanoTime() - start) / 1_000_000 + GatewayHealth.Healthy(version = dto.version, running = dto.gatewayRunning, latencyMs = latencyMs) + } catch (e: HermesApiException) { + // 401 is a definitive answer (reachable but unauthorized) — do not retry. + if (e.code == 401) GatewayHealth.GatewayUnreachable("unauthorized") else null + } catch (e: Exception) { + null // timeout / IO — retryable + } + } + + /** Fire an immediate probe (the sheet's Re-check button). */ + fun recheck() { + scope.launch { probe() } + } + + /** Begin foreground probing: probe now, then every [PROBE_INTERVAL_MS]. Idempotent. */ + fun startForeground() { + if (periodicJob?.isActive == true) return + periodicJob = scope.launch { + while (true) { + probe() + delay(PROBE_INTERVAL_MS) + } + } + } + + /** Stop foreground probing (app backgrounded). */ + fun stopForeground() { + periodicJob?.cancel() + periodicJob = null + } + + companion object { + const val PROBE_TIMEOUT_MS = 5_000L + const val PROBE_INTERVAL_MS = 30_000L + } +} +``` + +- [ ] **Step 4: Ensure the connectivity permission exists** + +Confirm `app/src/main/AndroidManifest.xml` contains (add it next to the existing `INTERNET` permission if missing — `getNetworkCapabilities` needs it): +```xml + +``` +Run: `grep -n 'ACCESS_NETWORK_STATE\|android.permission.INTERNET' app/src/main/AndroidManifest.xml` +Expected: both permissions present after this step. + +- [ ] **Step 5: Run test to verify it passes** + +Run: `./gradlew :app:testDebugUnitTest --tests "com.hermes.client.data.network.GatewayHealthMonitorTest"` +Expected: PASS (7 tests). + +- [ ] **Step 6: Commit** + +```bash +git add app/src/main/java/com/hermes/client/data/network/GatewayHealthMonitor.kt \ + app/src/test/java/com/hermes/client/data/network/GatewayHealthMonitorTest.kt \ + app/src/main/AndroidManifest.xml +git commit -m "feat: add GatewayHealthMonitor with /api/status probing" +``` + +--- + +### Task 3: Health strip + sheet UI (with pure mapping helpers) + +**Files:** +- Create: `app/src/main/java/com/hermes/client/ui/components/HealthStrip.kt` +- Test: `app/src/test/java/com/hermes/client/ui/components/HealthStripTest.kt` + +**Interfaces:** +- Consumes: `GatewayHealth` (Task 1) + `isUnhealthy()`; `LocalProfileAccent` (existing, `com.hermes.client.ui.theme.LocalProfileAccent`, field `.accent`). +- Produces: + - `enum class HealthStripStyle { ERROR, NEUTRAL, NONE }` + - `fun healthStripStyle(health: GatewayHealth): HealthStripStyle` + - `fun healthStripLabel(health: GatewayHealth): String?` + - `fun healthSheetBody(health: GatewayHealth): String` + - `@Composable fun HealthStrip(health: GatewayHealth, onClick: () -> Unit, modifier: Modifier = Modifier)` + - `@Composable fun HealthSheet(health: GatewayHealth, onRecheck: () -> Unit, onDismiss: () -> Unit)` + +- [ ] **Step 1: Write the failing test** + +`app/src/test/java/com/hermes/client/ui/components/HealthStripTest.kt`: +```kotlin +package com.hermes.client.ui.components + +import com.hermes.client.data.network.GatewayHealth +import org.junit.Assert.assertEquals +import org.junit.Assert.assertNull +import org.junit.Assert.assertTrue +import org.junit.Test + +class HealthStripTest { + @Test fun style_is_none_when_healthy_or_unknown() { + assertEquals(HealthStripStyle.NONE, healthStripStyle(GatewayHealth.Unknown)) + assertEquals(HealthStripStyle.NONE, healthStripStyle(GatewayHealth.Healthy("1", true, 10))) + } + + @Test fun style_error_for_gateway_unreachable_neutral_for_device_offline() { + assertEquals(HealthStripStyle.ERROR, healthStripStyle(GatewayHealth.GatewayUnreachable("x"))) + assertEquals(HealthStripStyle.NEUTRAL, healthStripStyle(GatewayHealth.DeviceOffline)) + } + + @Test fun label_null_when_healthy() { + assertNull(healthStripLabel(GatewayHealth.Healthy("1", true, 10))) + assertNull(healthStripLabel(GatewayHealth.Unknown)) + } + + @Test fun label_distinguishes_offline_unreachable_unauthorized() { + assertEquals("You're offline", healthStripLabel(GatewayHealth.DeviceOffline)) + assertEquals("Gateway unreachable", healthStripLabel(GatewayHealth.GatewayUnreachable("unreachable"))) + assertEquals("Gateway unauthorized", healthStripLabel(GatewayHealth.GatewayUnreachable("unauthorized"))) + } + + @Test fun sheet_body_healthy_includes_version_and_latency() { + val body = healthSheetBody(GatewayHealth.Healthy(version = "1.2.3", running = true, latencyMs = 42)) + assertTrue(body.contains("running")) + assertTrue(body.contains("1.2.3")) + assertTrue(body.contains("42")) + } + + @Test fun sheet_body_reachable_not_running_when_running_false() { + val body = healthSheetBody(GatewayHealth.Healthy(version = null, running = false, latencyMs = null)) + assertTrue(body.contains("not running")) + } + + @Test fun sheet_body_offline_and_unauthorized_copy() { + assertTrue(healthSheetBody(GatewayHealth.DeviceOffline).contains("offline")) + assertTrue(healthSheetBody(GatewayHealth.GatewayUnreachable("unauthorized")).contains("unauthorized")) + } +} +``` + +- [ ] **Step 2: Run test to verify it fails** + +Run: `./gradlew :app:testDebugUnitTest --tests "com.hermes.client.ui.components.HealthStripTest"` +Expected: FAIL — helpers unresolved. + +- [ ] **Step 3: Write minimal implementation** + +`app/src/main/java/com/hermes/client/ui/components/HealthStrip.kt`: +```kotlin +package com.hermes.client.ui.components + +import androidx.compose.foundation.background +import androidx.compose.foundation.clickable +import androidx.compose.foundation.layout.Arrangement +import androidx.compose.foundation.layout.Column +import androidx.compose.foundation.layout.Row +import androidx.compose.foundation.layout.fillMaxWidth +import androidx.compose.foundation.layout.padding +import androidx.compose.foundation.layout.statusBarsPadding +import androidx.compose.material.icons.Icons +import androidx.compose.material.icons.automirrored.rounded.KeyboardArrowRight +import androidx.compose.material.icons.rounded.CloudOff +import androidx.compose.material3.ExperimentalMaterial3Api +import androidx.compose.material3.Icon +import androidx.compose.material3.MaterialTheme +import androidx.compose.material3.ModalBottomSheet +import androidx.compose.material3.Text +import androidx.compose.material3.TextButton +import androidx.compose.runtime.Composable +import androidx.compose.ui.Alignment +import androidx.compose.ui.Modifier +import androidx.compose.ui.graphics.Color +import androidx.compose.ui.text.style.TextAlign +import androidx.compose.ui.unit.dp +import com.hermes.client.data.network.GatewayHealth +import com.hermes.client.ui.theme.LocalProfileAccent + +/** Visual severity of the strip. Kept separate from color so it is unit-testable. */ +enum class HealthStripStyle { ERROR, NEUTRAL, NONE } + +fun healthStripStyle(health: GatewayHealth): HealthStripStyle = when (health) { + is GatewayHealth.GatewayUnreachable -> HealthStripStyle.ERROR + GatewayHealth.DeviceOffline -> HealthStripStyle.NEUTRAL + is GatewayHealth.Healthy, GatewayHealth.Unknown -> HealthStripStyle.NONE +} + +/** Short strip label; null when nothing should show. */ +fun healthStripLabel(health: GatewayHealth): String? = when (health) { + GatewayHealth.DeviceOffline -> "You're offline" + is GatewayHealth.GatewayUnreachable -> + if (health.detail == "unauthorized") "Gateway unauthorized" else "Gateway unreachable" + is GatewayHealth.Healthy, GatewayHealth.Unknown -> null +} + +/** Sheet detail copy for the current state. */ +fun healthSheetBody(health: GatewayHealth): String = when (health) { + is GatewayHealth.Healthy -> buildString { + append(if (health.running) "Gateway running" else "Gateway reachable, not running") + health.version?.let { append(" · v").append(it) } + health.latencyMs?.let { append(" · ").append(it).append(" ms") } + } + is GatewayHealth.GatewayUnreachable -> + if (health.detail == "unauthorized") "The gateway rejected the session token (unauthorized)." + else "The gateway isn't responding. It may be down or restarting." + GatewayHealth.DeviceOffline -> "Your device is offline — Hermes will reconnect automatically." + GatewayHealth.Unknown -> "Checking…" +} + +/** + * Slim status strip shown across all screens ONLY when unhealthy. Applies its own status-bar + * padding so it sits below the system bar; callers should consume the status-bars inset for the + * content beneath it so the screen's own top bar does not add a second gap. + */ +@Composable +fun HealthStrip(health: GatewayHealth, onClick: () -> Unit, modifier: Modifier = Modifier) { + val label = healthStripLabel(health) ?: return + val bg = when (healthStripStyle(health)) { + HealthStripStyle.ERROR -> MaterialTheme.colorScheme.errorContainer + HealthStripStyle.NEUTRAL -> MaterialTheme.colorScheme.surfaceVariant + HealthStripStyle.NONE -> Color.Transparent + } + val fg = when (healthStripStyle(health)) { + HealthStripStyle.ERROR -> MaterialTheme.colorScheme.onErrorContainer + else -> MaterialTheme.colorScheme.onSurfaceVariant + } + Row( + modifier = modifier + .fillMaxWidth() + .background(bg) + .clickable(onClick = onClick) + .statusBarsPadding() + .padding(horizontal = 16.dp, vertical = 8.dp), + verticalAlignment = Alignment.CenterVertically, + horizontalArrangement = Arrangement.Start, + ) { + Icon(Icons.Rounded.CloudOff, contentDescription = null, tint = fg, modifier = Modifier.padding(end = 8.dp)) + Text(label, style = MaterialTheme.typography.labelLarge, color = fg, modifier = Modifier.padding(end = 8.dp)) + Row(Modifier.fillMaxWidth(), horizontalArrangement = Arrangement.End) { + Icon(Icons.AutoMirrored.Rounded.KeyboardArrowRight, contentDescription = null, tint = fg) + } + } +} + +@OptIn(ExperimentalMaterial3Api::class) +@Composable +fun HealthSheet(health: GatewayHealth, onRecheck: () -> Unit, onDismiss: () -> Unit) { + val accent = LocalProfileAccent.current.accent + ModalBottomSheet(onDismissRequest = onDismiss) { + Column(Modifier.fillMaxWidth().padding(24.dp)) { + Text( + healthStripLabel(health) ?: "Gateway", + style = MaterialTheme.typography.titleMedium, + ) + Text( + healthSheetBody(health), + style = MaterialTheme.typography.bodyMedium, + color = MaterialTheme.colorScheme.onSurfaceVariant, + modifier = Modifier.padding(top = 8.dp), + ) + Row(Modifier.fillMaxWidth().padding(top = 16.dp), horizontalArrangement = Arrangement.End) { + TextButton(onClick = onRecheck) { + Text("Re-check", color = accent, textAlign = TextAlign.End) + } + } + } + } +} +``` + +- [ ] **Step 4: Run test to verify it passes** + +Run: `./gradlew :app:testDebugUnitTest --tests "com.hermes.client.ui.components.HealthStripTest"` +Expected: PASS (7 tests). + +- [ ] **Step 5: Compile the app (composables reference Material3/theme)** + +Run: `JAVA_HOME="/Applications/Android Studio.app/Contents/jbr/Contents/Home" ./gradlew :app:compileDebugKotlin` +Expected: BUILD SUCCESSFUL. + +- [ ] **Step 6: Commit** + +```bash +git add app/src/main/java/com/hermes/client/ui/components/HealthStrip.kt \ + app/src/test/java/com/hermes/client/ui/components/HealthStripTest.kt +git commit -m "feat: add HealthStrip + HealthSheet with pure mapping helpers" +``` + +--- + +### Task 4: Wire monitor into DI, ShellViewModel, and the shell + +**Files:** +- Modify: `app/src/main/java/com/hermes/client/di/AppModule.kt` (add two providers, after `provideHermesRestApi`) +- Modify: `app/src/main/java/com/hermes/client/ui/nav/ShellViewModel.kt` +- Modify: `app/src/main/java/com/hermes/client/ui/nav/HermesNav.kt` +- Test: none new (covered by Tasks 1–3 + the build). This task's deliverable is verified by compile + full test suite + `assembleBeta`. + +**Interfaces:** +- Consumes: `GatewayHealthMonitor`, `ConnectivityChecker`/`AndroidConnectivityChecker` (Task 2); `HealthStrip`, `HealthSheet` (Task 3); `GatewayHealth.isUnhealthy()` (Task 1); existing `HermesGatewayClient.connectionState`, `provideAppScope(): CoroutineScope`, `ShellViewModel`. + +- [ ] **Step 1: Add DI providers** + +In `app/src/main/java/com/hermes/client/di/AppModule.kt`, add these two providers immediately after `provideHermesRestApi(...)` (uses the existing `@ApplicationContext context: Context` and `scope: CoroutineScope` patterns already present in this file): +```kotlin + @Provides + @Singleton + fun provideConnectivityChecker( + @ApplicationContext context: Context, + ): com.hermes.client.data.network.ConnectivityChecker = + com.hermes.client.data.network.AndroidConnectivityChecker(context) + + @Provides + @Singleton + fun provideGatewayHealthMonitor( + api: HermesRestApi, + connectivity: com.hermes.client.data.network.ConnectivityChecker, + client: HermesGatewayClient, + scope: CoroutineScope, + ): com.hermes.client.data.network.GatewayHealthMonitor = + com.hermes.client.data.network.GatewayHealthMonitor(api, connectivity, client.connectionState, scope) +``` + +- [ ] **Step 2: Expose health from ShellViewModel** + +Replace the constructor and add the health members in `app/src/main/java/com/hermes/client/ui/nav/ShellViewModel.kt`: +```kotlin +@HiltViewModel +class ShellViewModel @Inject constructor( + private val profileManager: ProfileManager, + private val accentStore: ProfileAccentStore, + private val healthMonitor: com.hermes.client.data.network.GatewayHealthMonitor, +) : ViewModel() { + val profiles: StateFlow> = profileManager.list + val active: StateFlow = profileManager.active + + /** Backend health for the shell's status strip + You-tab badge. */ + val health: StateFlow = healthMonitor.health + + init { viewModelScope.launch { profileManager.refresh() } } + + fun switchProfile(name: String) = viewModelScope.launch { profileManager.switchTo(name) } + + /** Set a custom accent colour for [profile] (persisted; overrides the auto-hashed hue). */ + fun setAccent(profile: String, argb: Int) = viewModelScope.launch { accentStore.setColor(profile, argb) } + + /** Clear [profile]'s custom colour, reverting to the auto hue. */ + fun clearAccent(profile: String) = viewModelScope.launch { accentStore.clear(profile) } + + /** Fire an immediate health probe (Re-check button). */ + fun recheckHealth() = healthMonitor.recheck() + + /** Foreground/background gating for periodic probing. */ + fun onAppForeground() = healthMonitor.startForeground() + fun onAppBackground() = healthMonitor.stopForeground() +} +``` + +- [ ] **Step 3: Host the strip, badge, sheet, and lifecycle wiring in HermesNav** + +In `app/src/main/java/com/hermes/client/ui/nav/HermesNav.kt`, add imports: +```kotlin +import androidx.compose.foundation.layout.Column +import androidx.compose.foundation.layout.WindowInsets +import androidx.compose.foundation.layout.consumeWindowInsets +import androidx.compose.foundation.layout.fillMaxSize +import androidx.compose.foundation.layout.statusBars +import androidx.compose.material3.Badge +import androidx.compose.material3.BadgedBox +import androidx.compose.runtime.DisposableEffect +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.hilt.lifecycle.viewmodel.compose.hiltViewModel +import androidx.lifecycle.Lifecycle +import androidx.lifecycle.LifecycleEventObserver +import androidx.lifecycle.ProcessLifecycleOwner +import androidx.lifecycle.compose.collectAsStateWithLifecycle +import com.hermes.client.data.network.isUnhealthy +import com.hermes.client.ui.components.HealthSheet +import com.hermes.client.ui.components.HealthStrip +``` + +Near the top of `HermesNav`, after `val route = backStackEntry?.destination?.route`, add: +```kotlin + val shellVm: ShellViewModel = hiltViewModel() + val health by shellVm.health.collectAsStateWithLifecycle() + var showHealthSheet by rememberSaveable { mutableStateOf(false) } + + // Probe only while the app is foregrounded (in-app-only v1). ProcessLifecycleOwner replays its + // current state on addObserver, so ON_START fires immediately if already foregrounded. + DisposableEffect(Unit) { + val obs = LifecycleEventObserver { _, e -> + when (e) { + Lifecycle.Event.ON_START -> shellVm.onAppForeground() + Lifecycle.Event.ON_STOP -> shellVm.onAppBackground() + else -> {} + } + } + ProcessLifecycleOwner.get().lifecycle.addObserver(obs) + onDispose { ProcessLifecycleOwner.get().lifecycle.removeObserver(obs) } + } +``` + +Change the `You` tab's `NavigationBarItem` icon to badge when unhealthy. Replace the `icon = { Icon(tab.icon, contentDescription = tab.label) }` line inside `TABS.forEach` with: +```kotlin + icon = { + if (tab.route == "you" && health.isUnhealthy()) { + BadgedBox(badge = { Badge() }) { + Icon(tab.icon, contentDescription = tab.label) + } + } else { + Icon(tab.icon, contentDescription = tab.label) + } + }, +``` + +Replace the Scaffold content lambda (the `{ padding -> NavHost(...) { ... } }` block) so the strip sits above the `NavHost`. The `NavHost` body (all `composable(...) { }` entries) stays **exactly** as-is; only the wrapper changes: +```kotlin + ) { padding -> + Column(Modifier.fillMaxSize().padding(bottom = padding.calculateBottomPadding())) { + // Renders nothing when healthy. When shown it owns the status-bar inset, so the content + // below consumes that inset to avoid a second top gap under the strip. + if (health.isUnhealthy()) { + HealthStrip(health = health, onClick = { showHealthSheet = true }) + } + val contentModifier = + if (health.isUnhealthy()) Modifier.weight(1f).consumeWindowInsets(WindowInsets.statusBars) + else Modifier.weight(1f) + NavHost( + navController = nav, + startDestination = start, + modifier = contentModifier, + ) { + // ... existing composable(...) entries unchanged ... + } + } + } +``` +Note: the `NavHost`'s `modifier` changes from `Modifier.padding(bottom = padding.calculateBottomPadding())` to `contentModifier` (the bottom padding now lives on the enclosing `Column`). + +After the `Scaffold { ... }` block (still inside `HermesNav`), add the sheet host: +```kotlin + if (showHealthSheet) { + HealthSheet( + health = health, + onRecheck = { shellVm.recheckHealth() }, + onDismiss = { showHealthSheet = false }, + ) + } +``` + +- [ ] **Step 4: Compile** + +Run: `JAVA_HOME="/Applications/Android Studio.app/Contents/jbr/Contents/Home" ./gradlew :app:compileDebugKotlin` +Expected: BUILD SUCCESSFUL. (If `Column` needs `weight`, it is `androidx.compose.foundation.layout.ColumnScope.weight`, available inside `Column`.) + +- [ ] **Step 5: Run the full unit-test suite** + +Run: `JAVA_HOME="/Applications/Android Studio.app/Contents/jbr/Contents/Home" ./gradlew :app:testDebugUnitTest` +Expected: BUILD SUCCESSFUL, 0 failures (includes Tasks 1–3 suites). + +- [ ] **Step 6: Assemble the beta variant** + +Run: `JAVA_HOME="/Applications/Android Studio.app/Contents/jbr/Contents/Home" ./gradlew :app:assembleBeta` +Expected: BUILD SUCCESSFUL. + +- [ ] **Step 7: Commit** + +```bash +git add app/src/main/java/com/hermes/client/di/AppModule.kt \ + app/src/main/java/com/hermes/client/ui/nav/ShellViewModel.kt \ + app/src/main/java/com/hermes/client/ui/nav/HermesNav.kt +git commit -m "feat: surface backend health as a shell strip, You-tab badge, and detail sheet" +``` + +--- + +### Task 5: On-device verification + +**Files:** none (manual verification on the emulator or a connected device). + +This task has no code. Install the beta build and confirm the behavior against the spec. There is no automated Compose UI test (per Global Constraints), so this manual pass is the acceptance gate for the UI wiring. + +- [ ] **Step 1: Install the beta build** + +Run: +```bash +JAVA_HOME="/Applications/Android Studio.app/Contents/jbr/Contents/Home" ./gradlew :app:installBeta +``` +Expected: `Installed on 1 device`. + +- [ ] **Step 2: Verify the healthy state (no clutter)** + +With the gateway reachable and the app connected: confirm **no** strip is shown on the Chats / Home / You screens, and **no** badge on the `You` tab. Open the `You` tab, then any screen — still clean. + +- [ ] **Step 3: Verify gateway-unreachable** + +Stop the gateway (or point the app at a dead port), background/foreground the app to trigger a probe. Confirm: a red (errorContainer) strip reading **"Gateway unreachable"** appears across screens; the `You` tab shows a badge; tapping the strip opens the sheet with "The gateway isn't responding…" and a **Re-check** button. Restart the gateway, tap **Re-check** → strip and badge clear. + +- [ ] **Step 4: Verify device-offline distinction** + +Enable airplane mode. Confirm the strip reads **"You're offline"** in neutral (surfaceVariant) styling — visibly different from the red gateway-unreachable state — and the sheet body says the device is offline. Disable airplane mode → clears on the next probe. + +- [ ] **Step 5: Verify no double top gap** + +While the strip is visible, confirm the underlying screen's own top app bar sits directly beneath the strip with a single status-bar area (no doubled blank gap), and content is not pushed down twice. + +- [ ] **Step 6: Verify tenant accent + background pause** + +Confirm the strip/error styling is semantic (same red in every profile — not the tenant accent), while the sheet's **Re-check** button uses the tenant accent. Background the app for > 30s and confirm (via `adb logcat` filtering `rest` `GET /api/status`, or the DebugLog screen) that probing stops while backgrounded and resumes on return. + +- [ ] **Step 7: Record the verification result** + +No commit. Note the outcome (pass/fail per step) in the PR description when the branch is finished. + +--- + +## Notes for the executor + +- **Coalescing:** the spec's "ignore if a probe ran < 3s ago" is realized more simply as an in-flight `Mutex.tryLock` guard in `probe()` (no wall-clock dependency, unit-testable). Concurrent triggers (WS flap + periodic + Re-check) collapse to one probe; this is the intended behavior and a deliberate, equivalent simplification. +- **Latency in tests:** `System.nanoTime()` is real on the JVM test runtime, so `Healthy.latencyMs` is a small non-negative number in tests; assertions check presence/other fields, not an exact latency. +- **Do not** add a background/push path, a health-detail screen, provider-reachability, or latency history — all explicitly out of v1 per the spec. +``` diff --git a/docs/superpowers/specs/2026-07-16-backend-health-signal-design.md b/docs/superpowers/specs/2026-07-16-backend-health-signal-design.md new file mode 100644 index 0000000..dc1a6a5 --- /dev/null +++ b/docs/superpowers/specs/2026-07-16-backend-health-signal-design.md @@ -0,0 +1,151 @@ +# Backend-Health Signal — Design + +**Wave:** Quick-wins wave 1 (from `docs/ideas/2026-07-16-competitive-refresh.md`). **Branch:** `feature/backend-health-signal` (off `dev`). + +**Goal:** App-wide, proactive awareness of whether the self-hosted Hermes gateway is reachable and healthy — distinguishing *your phone is offline* from *the gateway is down* from *gateway up* — with zero visual clutter when everything is healthy. + +**Positioning:** This is the one signal only a self-hosted client owes its user. Today the app only surfaces the WebSocket `ConnectionState` **inside an open chat** (`ChatScreen.kt:260` via `StatusDot`); on every other screen (sessions, activity, cron, …) there is no backend signal at all. A user discovers a dead gateway only when a send fails. + +**Constraints:** Kotlin / Compose / Hilt / Material3, per-tenant accent. **Fully client-only** — no gateway changes. Uses the existing public `GET /api/status` endpoint (`HermesRestApi.gatewayStatus()` → `GatewayStatusDto { version, gateway_running, gateway_state }`, `Dtos.kt:8-12`). Standing repo constraints apply (no AI attribution; gitleaks before every push; tenant isolation; `main` only via approved PR). + +--- + +## Scope decisions (locked) + +1. **Ambition:** Proactive — actively probe `/api/status`, don't just passively reflect the WS socket. Distinguish device-offline vs gateway-unreachable vs gateway-healthy. +2. **Placement:** Global **down-strip** hosted by the shared outer `Scaffold` (`HermesNav.kt`), shown on **every** screen but **only when unhealthy**; a tiny **badge** on the `You` bottom-nav tab when unhealthy; tap either → a detail **bottom sheet** with a **Re-check** button. Nothing shown when healthy except the (absent) badge. +3. **Background reach:** **In-app only for v1.** Probe on app-resume + a light interval while foregrounded; stop when backgrounded. No push, no foreground-service changes. The state machine is built so a background-push variant is a small fast-follow (add a listener in the existing `GatewayConnectionService`). + +--- + +## Architecture + +Three well-bounded units: a **health model**, a **monitor** that produces it, and the **UI surface** that renders it. The monitor is the only stateful piece; the model and UI mapping are pure and independently testable. + +### 1. Health model — `data/network/GatewayHealth.kt` (new) + +Deliberately separate from `ConnectionState` (which is the WS socket lifecycle). This models the **backend**, sourced authoritatively from `/api/status` + device connectivity. + +```kotlin +sealed interface GatewayHealth { + /** Before the first probe completes. Renders nothing (no strip, no badge). */ + data object Unknown : GatewayHealth + + /** /api/status returned 2xx. */ + data class Healthy(val version: String?, val running: Boolean, val latencyMs: Long?) : GatewayHealth + + /** ConnectivityManager reports no network — the phone is offline, not the gateway. */ + data object DeviceOffline : GatewayHealth + + /** Network is up but /api/status failed (timeout, connection refused, non-2xx). */ + data class GatewayUnreachable(val detail: String?) : GatewayHealth +} + +/** True when the down-strip and You-tab badge should show. */ +fun GatewayHealth.isUnhealthy(): Boolean = + this is GatewayHealth.DeviceOffline || this is GatewayHealth.GatewayUnreachable +``` + +`Healthy.running` reflects `GatewayStatusDto.gateway_running`; a 2xx with `running == false` is still `Healthy` (gateway reachable) but the sheet surfaces "reachable, not running" copy. + +### 2. Monitor — `data/network/GatewayHealthMonitor.kt` (new, Hilt `@Singleton`) + +Exposes `val health: StateFlow` (initial value `Unknown`). Dependencies: `HermesRestApi`, a `ConnectivityManager` (from `@ApplicationContext`), and an app-scope `CoroutineScope`. + +**Probe algorithm (`suspend fun probe()`):** +1. If `ConnectivityManager` reports no validated network → emit `DeviceOffline`, return (skip HTTP). +2. Else call `api.gatewayStatus()` inside `withTimeout(PROBE_TIMEOUT_MS = 5_000)`, timing it: + - success → `Healthy(version, running = gateway_running, latencyMs = elapsed)`. + - `HermesApiException` with 401 → `GatewayUnreachable("unauthorized")` (distinct copy; does not trigger the existing auth/setup redirect — this is a status hint only). + - any other throwable / timeout → **one immediate retry**; if it also fails → `GatewayUnreachable(detail)` where `detail` is a short reason (`"timed out"`, `"unreachable"`, HTTP code). The single retry is the debounce that prevents a transient blip from flashing the strip. + +**Cadence:** +- Observe `ProcessLifecycleOwner.get().lifecycle`: on `ON_RESUME`, probe immediately and start a loop that re-probes every `PROBE_INTERVAL_MS = 30_000`; on `ON_STOP`, cancel the loop (no background probing). +- Subscribe to the WS `ConnectionState` (already exposed by `HermesGatewayClient`); when it transitions to `Error`/`Disconnected`, trigger an immediate `probe()` (cheap early signal, coalesced so a flapping socket can't spam probes — ignore if a probe ran < 3s ago). + +**Manual re-check:** `fun recheck()` launches an immediate `probe()` (used by the sheet's button). + +`PROBE_TIMEOUT_MS`, `PROBE_INTERVAL_MS`, and the WS-coalesce window are named constants in a companion object. + +### 3. UI surface + +**`ShellViewModel` (`ui/nav/ShellViewModel.kt`, modify):** inject `GatewayHealthMonitor`; expose `val health: StateFlow = monitor.health`. (This VM already backs the outer Scaffold chrome.) + +**`HermesNav.kt` (modify):** in the outer `Scaffold`: +- Collect `shellVm.health`. +- Render `HealthStrip(health, onClick = { showHealthSheet = true })` in a top slot above the `NavHost` content — the composable returns/draws nothing when `!health.isUnhealthy()`, so healthy state adds no layout. +- On the `You` `NavigationBarItem`, wrap the icon in `BadgedBox` showing a `Badge` when `health.isUnhealthy()`. +- Host a `HealthSheet(health, onRecheck = shellVm::recheck, onDismiss = …)` `ModalBottomSheet`, opened by the strip or the badge tap. + +**`ui/components/HealthStrip.kt` (new):** the strip composable, the sheet composable, and **pure mapping helpers**: +- `healthStripLabel(GatewayHealth): String?` — `DeviceOffline → "You're offline"`, `GatewayUnreachable → "Gateway unreachable"` (or "Gateway unauthorized" for the 401 detail), else `null`. +- `healthStripColor(GatewayHealth): Color` — semantic: red (`error`) for `GatewayUnreachable`, neutral/amber for `DeviceOffline`. **Down states are semantic, never per-tenant accent** (a down gateway looks the same in every tenant). Neutral chrome in the sheet (e.g. the Re-check button) may use `LocalProfileAccent`. +- Sheet body copy per state: healthy → "Gateway running · v{version} · {latencyMs} ms" (or "reachable, not running" when `running == false`); unreachable → detail + "Last checked {relative time}"; device-offline → "Your device is offline — Hermes will reconnect automatically." + +**DI (`di/AppModule.kt`, modify):** `@Provides @Singleton fun provideGatewayHealthMonitor(api, @ApplicationContext context, appScope): GatewayHealthMonitor`. (If no app-scope `CoroutineScope` is already provided, the monitor creates its own `CoroutineScope(SupervisorJob() + Dispatchers.Default)` internally — check existing DI for an app scope first and reuse it.) + +--- + +## Data flow + +``` +ProcessLifecycleOwner ─ON_RESUME/interval─┐ +WS ConnectionState ─Error/Disconnected────┤→ GatewayHealthMonitor.probe() +HealthSheet "Re-check" ─recheck()─────────┘ │ + │ ConnectivityManager? → DeviceOffline + │ else GET /api/status (5s, +1 retry) + ▼ + GatewayHealthMonitor.health: StateFlow + │ + ShellViewModel.health + │ + ┌──────────────────────────┼───────────────────────────┐ + HealthStrip (top slot, You-tab Badge HealthSheet (detail + only when unhealthy) (when unhealthy) + Re-check button) +``` + +## Error handling + +- **Probe timeout / connection refused:** retried once, then `GatewayUnreachable`. Never throws out of the monitor — all exceptions are caught and mapped to a state. +- **401 unauthorized:** mapped to `GatewayUnreachable("unauthorized")` with distinct copy; v1 does **not** re-drive the setup/auth flow from here (that stays owned by the existing send/auth path) — this is an advisory signal only. +- **No network:** short-circuits to `DeviceOffline` without an HTTP attempt. +- **Missing/blank `version`:** sheet omits the version segment; `Healthy` still valid. + +## Testing + +**`GatewayHealthMonitorTest`** (fake `HermesRestApi` + fake connectivity provider + `runTest`/`StandardTestDispatcher`): +- healthy on 2xx (captures version, running, latency). +- `DeviceOffline` when connectivity reports no network (no HTTP call made). +- `GatewayUnreachable` after a probe failure **and** its retry both fail. +- transient blip: first probe throws, retry succeeds → stays `Healthy` (debounce works). +- recovery: `GatewayUnreachable` → `Healthy` on a later successful probe. +- 401 → `GatewayUnreachable("unauthorized")`. +- WS-flip coalescing: two rapid `Disconnected` signals within the window trigger only one probe. + +**`HealthStripTest`** (pure): `isUnhealthy()`, `healthStripLabel`, `healthStripColor` for every state; badge-visibility predicate. + +UI logic lives in pure functions; no full Compose UI tests (matches the repo's VM-plus-pure-logic test style). + +## Files + +| Action | Path | Responsibility | +|--------|------|----------------| +| New | `data/network/GatewayHealth.kt` | Sealed health model + `isUnhealthy()` | +| New | `data/network/GatewayHealthMonitor.kt` | Connectivity + `/api/status` probing → `StateFlow` | +| New | `ui/components/HealthStrip.kt` | Strip + sheet composables + pure mapping helpers | +| Modify | `di/AppModule.kt` | Provide the monitor | +| Modify | `ui/nav/ShellViewModel.kt` | Expose `health` | +| Modify | `ui/nav/HermesNav.kt` | Host strip, badge the You tab, wire the sheet | +| New | `test/…/data/network/GatewayHealthMonitorTest.kt` | Monitor state transitions | +| New | `test/…/ui/components/HealthStripTest.kt` | Pure mapping helpers | + +## Explicitly out of v1 (deliberate anti-scope) + +- **Background push** when the gateway dies while the app is closed — the chosen fast-follow (adds a listener in `GatewayConnectionService`; the model already supports it). +- A **dedicated health/status screen** (version/uptime/latency history, provider-reachability). +- **Provider/LLM reachability** (whether the upstream model API is up) — `/api/status` only reports the gateway itself. +- **Latency history/graphs.** + +## Build & gates + +Build with `JAVA_HOME="/Applications/Android Studio.app/Contents/jbr/Contents/Home"`: `:app:compileDebugKotlin`, `:app:testDebugUnitTest`, `:app:assembleBeta`. gitleaks before every push; PR into `dev`. From e7f00d03bfd8e581458f0f61911a428bc53b47b4 Mon Sep 17 00:00:00 2001 From: Andrew Debnar <606931+adebnar@users.noreply.github.com> Date: Fri, 17 Jul 2026 03:01:29 +0000 Subject: [PATCH 05/16] feat: act on results from the notification shade (inline reply + approve-for-session) (#99) * docs: spec for act-on-result inline reply + broader approval (quick-wins wave 3) * docs: implementation plan for act-on-result inline reply + broader approval * feat: add reply + approve-for-session notification actions to the mapper * feat: handle reply + approve-for-session notification actions headlessly * feat: post inline-reply notification action with RemoteInput * fix: give the reply PendingIntent a distinct request code * docs: correct reply mechanism to clarify.respond + request_id (post-review) * fix: answer clarify via clarify.respond with request_id (reply + in-app) --- .../client/data/repository/ChatRepository.kt | 3 +- .../client/notifications/HermesNotifier.kt | 31 +- .../NotificationActionReceiver.kt | 58 ++- .../notifications/NotificationMapper.kt | 5 +- .../notifications/NotificationModels.kt | 18 +- .../com/hermes/client/ui/chat/ChatUiState.kt | 6 +- .../hermes/client/ui/chat/ChatViewModel.kt | 3 +- .../notifications/NotificationMapperTest.kt | 31 +- .../notifications/ReceiverActionTest.kt | 28 ++ .../hermes/client/ui/chat/ChatReducerTest.kt | 7 + .../2026-07-16-act-on-result-inline-reply.md | 431 ++++++++++++++++++ ...07-16-act-on-result-inline-reply-design.md | 170 +++++++ 12 files changed, 765 insertions(+), 26 deletions(-) create mode 100644 app/src/test/java/com/hermes/client/notifications/ReceiverActionTest.kt create mode 100644 docs/superpowers/plans/2026-07-16-act-on-result-inline-reply.md create mode 100644 docs/superpowers/specs/2026-07-16-act-on-result-inline-reply-design.md diff --git a/app/src/main/java/com/hermes/client/data/repository/ChatRepository.kt b/app/src/main/java/com/hermes/client/data/repository/ChatRepository.kt index 9f17e20..f7c8c86 100644 --- a/app/src/main/java/com/hermes/client/data/repository/ChatRepository.kt +++ b/app/src/main/java/com/hermes/client/data/repository/ChatRepository.kt @@ -132,9 +132,10 @@ class ChatRepository(private val client: HermesGatewayClient) { }) } - suspend fun respondClarify(sessionId: String, answer: String) { + suspend fun respondClarify(sessionId: String, requestId: String, answer: String) { client.call("clarify.respond", buildJsonObject { put("session_id", sessionId) + put("request_id", requestId) put("answer", answer) }) } diff --git a/app/src/main/java/com/hermes/client/notifications/HermesNotifier.kt b/app/src/main/java/com/hermes/client/notifications/HermesNotifier.kt index 93af807..bf57658 100644 --- a/app/src/main/java/com/hermes/client/notifications/HermesNotifier.kt +++ b/app/src/main/java/com/hermes/client/notifications/HermesNotifier.kt @@ -8,6 +8,7 @@ import android.content.Context import android.content.Intent import androidx.core.app.NotificationCompat import androidx.core.app.NotificationManagerCompat +import androidx.core.app.RemoteInput import com.hermes.client.MainActivity import com.hermes.client.R @@ -49,7 +50,16 @@ class HermesNotifier(private val context: Context) { .setGroup(spec.groupKey) .setContentIntent(openIntent(spec.route, spec.id)) spec.actions.forEach { a -> - b.addAction(0, a.label, actionIntent(a, spec.id)) + if (a.reply) { + val remoteInput = RemoteInput.Builder(Notif.KEY_REPLY_TEXT).setLabel("Reply…").build() + val action = NotificationCompat.Action.Builder(0, a.label, replyIntent(a, spec.id)) + .addRemoteInput(remoteInput) + .setAllowGeneratedReplies(false) + .build() + b.addAction(action) + } else { + b.addAction(0, a.label, actionIntent(a, spec.id)) + } } if (mgr.areNotificationsEnabled()) { mgr.notify(spec.id, b.build()) @@ -75,6 +85,25 @@ class HermesNotifier(private val context: Context) { return PendingIntent.getBroadcast(context, (a.action + a.sessionId).hashCode(), intent, pendingFlags()) } + private fun replyIntent(a: NotifAction, notifId: Int): PendingIntent { + val intent = Intent(context, NotificationActionReceiver::class.java).apply { + action = a.action + putExtra("session_id", a.sessionId) + putExtra("notif_id", notifId) + putExtra("request_id", a.requestId.orEmpty()) + } + // Direct-reply requires FLAG_MUTABLE so the system can attach the RemoteInput results. + // The intent is explicit (our own receiver), so it can't be redirected — mutability is safe. + return PendingIntent.getBroadcast( + context, + // Distinct namespace from actionIntent()'s button request code so a reply (MUTABLE) and a + // button (IMMUTABLE) can never share PendingIntent identity (would crash on Android 12+). + ("reply:" + a.action + a.sessionId).hashCode(), + intent, + PendingIntent.FLAG_UPDATE_CURRENT or PendingIntent.FLAG_MUTABLE, + ) + } + private fun pendingFlags() = PendingIntent.FLAG_UPDATE_CURRENT or PendingIntent.FLAG_IMMUTABLE companion object { diff --git a/app/src/main/java/com/hermes/client/notifications/NotificationActionReceiver.kt b/app/src/main/java/com/hermes/client/notifications/NotificationActionReceiver.kt index 4cda85e..829b56f 100644 --- a/app/src/main/java/com/hermes/client/notifications/NotificationActionReceiver.kt +++ b/app/src/main/java/com/hermes/client/notifications/NotificationActionReceiver.kt @@ -3,6 +3,7 @@ package com.hermes.client.notifications import android.content.BroadcastReceiver import android.content.Context import android.content.Intent +import androidx.core.app.RemoteInput import com.hermes.client.data.diagnostics.DebugLog import com.hermes.client.data.repository.ChatRepository import com.hermes.client.ui.chat.ApprovalChoice @@ -13,7 +14,27 @@ import kotlinx.coroutines.launch import kotlinx.coroutines.withTimeout import javax.inject.Inject -/** Handles the Allow once/Deny actions on an approval notification by sending the approval RPC. */ +/** What a received notification-action intent should do. Pure/testable, no Android deps. */ +sealed interface ReceiverAction { + data class Approval(val choice: ApprovalChoice) : ReceiverAction + data object Reply : ReceiverAction + data object Unknown : ReceiverAction +} + +fun receiverActionFor(action: String?): ReceiverAction = when (action) { + Notif.ACTION_ALLOW_ONCE -> ReceiverAction.Approval(ApprovalChoice.ONCE) + Notif.ACTION_ALLOW_SESSION -> ReceiverAction.Approval(ApprovalChoice.SESSION) + Notif.ACTION_DENY -> ReceiverAction.Approval(ApprovalChoice.DENY) + Notif.ACTION_REPLY -> ReceiverAction.Reply + else -> ReceiverAction.Unknown +} + +/** + * Handles a notification action headlessly: Allow-once/Session/Deny → `approval.respond`; an inline + * Reply → `clarify.respond` (answers the pending clarify request, doesn't start a new turn). Runs a + * single RPC on a background scope; only clears the notification once the RPC succeeds, so a failed + * action isn't silently lost. + */ @AndroidEntryPoint class NotificationActionReceiver : BroadcastReceiver() { @Inject lateinit var chat: ChatRepository @@ -22,22 +43,33 @@ class NotificationActionReceiver : BroadcastReceiver() { override fun onReceive(context: Context, intent: Intent) { val sid = intent.getStringExtra("session_id") ?: return val notifId = intent.getIntExtra("notif_id", -1) - val choice = when (intent.action) { - Notif.ACTION_ALLOW_ONCE -> ApprovalChoice.ONCE - else -> ApprovalChoice.DENY - } + val ra = receiverActionFor(intent.action) + val replyText = RemoteInput.getResultsFromIntent(intent) + ?.getCharSequence(Notif.KEY_REPLY_TEXT)?.toString()?.trim() + + // Nothing actionable, or a blank reply → leave the notification up (retryable) and stop. + if (ra is ReceiverAction.Unknown) return + if (ra is ReceiverAction.Reply && replyText.isNullOrBlank()) return + val pending = goAsync() CoroutineScope(Dispatchers.IO).launch { try { - runCatching { withTimeout(8_000) { chat.respondApproval(sid, choice) } } - .onSuccess { - // Only clear the notification once the RPC actually succeeded — on - // failure, leave it so the action isn't silently lost. - if (notifId != -1) notifier.cancel(notifId) - } - .onFailure { e -> - DebugLog.log("notif", "approval response failed session=$sid choice=$choice: ${e.message}") + runCatching { + withTimeout(8_000) { + when (ra) { + is ReceiverAction.Approval -> chat.respondApproval(sid, ra.choice) + ReceiverAction.Reply -> { + val requestId = intent.getStringExtra("request_id").orEmpty() + chat.respondClarify(sid, requestId, replyText!!) + } + ReceiverAction.Unknown -> Unit // unreachable (returned above) + } } + }.onSuccess { + if (notifId != -1) notifier.cancel(notifId) + }.onFailure { e -> + DebugLog.log("notif", "action failed session=$sid action=${intent.action}: ${e.message}") + } } finally { pending.finish() } diff --git a/app/src/main/java/com/hermes/client/notifications/NotificationMapper.kt b/app/src/main/java/com/hermes/client/notifications/NotificationMapper.kt index 9224376..db9208b 100644 --- a/app/src/main/java/com/hermes/client/notifications/NotificationMapper.kt +++ b/app/src/main/java/com/hermes/client/notifications/NotificationMapper.kt @@ -35,6 +35,7 @@ fun toNotificationSpec(event: ServerEvent, prefs: NotificationPrefs, appInForegr actions = if (elevated) listOf(NotifAction("Deny", Notif.ACTION_DENY, sid)) else listOf( NotifAction("Allow once", Notif.ACTION_ALLOW_ONCE, sid), + NotifAction("Session", Notif.ACTION_ALLOW_SESSION, sid), NotifAction("Deny", Notif.ACTION_DENY, sid), ), groupKey = "approval", @@ -44,7 +45,9 @@ fun toNotificationSpec(event: ServerEvent, prefs: NotificationPrefs, appInForegr Notif.EVENT_CLARIFY -> if (!prefs.approvals) null else NotificationSpec( id = id, channelId = Notif.CHANNEL_APPROVALS, title = "Needs your input", body = event.str("question") ?: "The agent has a question.", - route = "chat/$sid", actions = emptyList(), groupKey = "approval", + route = "chat/$sid", + actions = listOf(NotifAction("Reply", Notif.ACTION_REPLY, sid, reply = true, requestId = event.str("request_id"))), + groupKey = "approval", ) // Run finished: `message.complete` is the end-of-turn event on /api/ws (the app also uses it // to stop the "generating" spinner). Only notify when backgrounded; the per-session id above diff --git a/app/src/main/java/com/hermes/client/notifications/NotificationModels.kt b/app/src/main/java/com/hermes/client/notifications/NotificationModels.kt index 6d9e08c..5435197 100644 --- a/app/src/main/java/com/hermes/client/notifications/NotificationModels.kt +++ b/app/src/main/java/com/hermes/client/notifications/NotificationModels.kt @@ -7,8 +7,17 @@ data class NotificationPrefs( val runFinished: Boolean = true, ) -/** An inline notification action (Allow once/Deny) carrying the target session. */ -data class NotifAction(val label: String, val action: String, val sessionId: String) +/** + * An inline notification action carrying the target session. [reply] = true marks a direct-reply + * action (Android RemoteInput text field) rather than a plain button. + */ +data class NotifAction( + val label: String, + val action: String, + val sessionId: String, + val reply: Boolean = false, + val requestId: String? = null, +) /** A platform-independent description of a notification, so mapping stays unit-testable. */ data class NotificationSpec( @@ -41,5 +50,10 @@ object Notif { const val EVENT_ERROR = "error" const val ACTION_ALLOW_ONCE = "allow_once" + const val ACTION_ALLOW_SESSION = "allow_session" const val ACTION_DENY = "deny" + const val ACTION_REPLY = "reply" + + // RemoteInput result key for the inline reply on a clarify ("Needs your input") notification. + const val KEY_REPLY_TEXT = "reply_text" } diff --git a/app/src/main/java/com/hermes/client/ui/chat/ChatUiState.kt b/app/src/main/java/com/hermes/client/ui/chat/ChatUiState.kt index ff30ee0..5a42177 100644 --- a/app/src/main/java/com/hermes/client/ui/chat/ChatUiState.kt +++ b/app/src/main/java/com/hermes/client/ui/chat/ChatUiState.kt @@ -15,7 +15,7 @@ data class ApprovalRequest( val patternKeys: List, val allowPermanent: Boolean, ) -data class ClarifyRequest(val question: String, val options: List) +data class ClarifyRequest(val question: String, val options: List, val requestId: String = "") data class ChatUiState( val messages: List = emptyList(), @@ -101,7 +101,9 @@ fun ChatUiState.reduce(event: ServerEvent): ChatUiState { ), ) "clarify.request" -> state.copy( - pendingClarify = ClarifyRequest(event.str("question") ?: "", emptyList()), + pendingClarify = ClarifyRequest( + event.str("question") ?: "", emptyList(), event.str("request_id") ?: "", + ), ) "error" -> state.copy( messages = state.messages + ChatMessage( diff --git a/app/src/main/java/com/hermes/client/ui/chat/ChatViewModel.kt b/app/src/main/java/com/hermes/client/ui/chat/ChatViewModel.kt index 203f4cf..4d05add 100644 --- a/app/src/main/java/com/hermes/client/ui/chat/ChatViewModel.kt +++ b/app/src/main/java/com/hermes/client/ui/chat/ChatViewModel.kt @@ -254,8 +254,9 @@ class ChatViewModel @Inject constructor( } fun clarify(answer: String) { + val requestId = _state.value.pendingClarify?.requestId ?: "" _state.value = _state.value.copy(pendingClarify = null) - viewModelScope.launch { runCatching { chat.respondClarify(sessionId, answer) } } + viewModelScope.launch { runCatching { chat.respondClarify(sessionId, requestId, answer) } } } /** Appends a non-fatal error as a system message and stops the generating spinner. */ diff --git a/app/src/test/java/com/hermes/client/notifications/NotificationMapperTest.kt b/app/src/test/java/com/hermes/client/notifications/NotificationMapperTest.kt index 18dbd3b..b4ab23d 100644 --- a/app/src/test/java/com/hermes/client/notifications/NotificationMapperTest.kt +++ b/app/src/test/java/com/hermes/client/notifications/NotificationMapperTest.kt @@ -23,16 +23,23 @@ class NotificationMapperTest { assertEquals(Notif.CHANNEL_APPROVALS, spec.channelId) assertEquals("chat/s1", spec.route) assertTrue(spec.body.contains("Delete file?")) - assertEquals(listOf(Notif.ACTION_ALLOW_ONCE, Notif.ACTION_DENY), spec.actions.map { it.action }) + assertEquals( + listOf(Notif.ACTION_ALLOW_ONCE, Notif.ACTION_ALLOW_SESSION, Notif.ACTION_DENY), + spec.actions.map { it.action }, + ) + assertTrue(spec.actions.none { it.reply }) assertTrue(spec.actions.all { it.sessionId == "s1" }) } - @Test fun standard_approval_offers_allow_once_and_deny() { + @Test fun standard_approval_offers_allow_once_session_and_deny() { val e = ServerEvent("approval.request", "s1", buildJsonObject { put("session_id", "s1"); put("command", "git push -f"); put("allow_permanent", true) }) val spec = toNotificationSpec(e, on, appInForeground = false)!! - assertEquals(listOf(Notif.ACTION_ALLOW_ONCE, Notif.ACTION_DENY), spec.actions.map { it.action }) + assertEquals( + listOf(Notif.ACTION_ALLOW_ONCE, Notif.ACTION_ALLOW_SESSION, Notif.ACTION_DENY), + spec.actions.map { it.action }, + ) } @Test fun elevated_approval_offers_deny_only() { @@ -43,6 +50,14 @@ class NotificationMapperTest { assertEquals(listOf(Notif.ACTION_DENY), spec.actions.map { it.action }) } + @Test fun elevated_approval_has_no_session_action() { + val e = ServerEvent("approval.request", "s1", buildJsonObject { + put("session_id", "s1"); put("command", "rm -rf /"); put("allow_permanent", false) + }) + val spec = toNotificationSpec(e, on, appInForeground = false)!! + assertEquals(listOf(Notif.ACTION_DENY), spec.actions.map { it.action }) + } + @Test fun approval_notifies_regardless_of_foreground() { val e = event(Notif.EVENT_APPROVAL, "c1", "prompt" to "May I run rm?") assertNotNull(toNotificationSpec(e, on, appInForeground = false)) @@ -60,12 +75,18 @@ class NotificationMapperTest { } @Test fun clarify_notifies_with_question_regardless_of_foreground() { - val e = event(Notif.EVENT_CLARIFY, "c1", "question" to "Which repo?") + val e = event(Notif.EVENT_CLARIFY, "c1", "question" to "Which repo?", "request_id" to "req-9") val spec = toNotificationSpec(e, on, appInForeground = true)!! assertEquals("Needs your input", spec.title) assertEquals("Which repo?", spec.body) assertEquals("chat/c1", spec.route) - assertTrue(spec.actions.isEmpty()) + assertEquals(1, spec.actions.size) + val reply = spec.actions.single() + assertEquals(Notif.ACTION_REPLY, reply.action) + assertEquals("Reply", reply.label) + assertTrue(reply.reply) + assertEquals("c1", reply.sessionId) + assertEquals("req-9", reply.requestId) } @Test fun clarify_off_when_approvals_off() { diff --git a/app/src/test/java/com/hermes/client/notifications/ReceiverActionTest.kt b/app/src/test/java/com/hermes/client/notifications/ReceiverActionTest.kt new file mode 100644 index 0000000..1158a56 --- /dev/null +++ b/app/src/test/java/com/hermes/client/notifications/ReceiverActionTest.kt @@ -0,0 +1,28 @@ +package com.hermes.client.notifications + +import com.hermes.client.ui.chat.ApprovalChoice +import org.junit.Assert.assertEquals +import org.junit.Test + +class ReceiverActionTest { + @Test fun allow_once_maps_to_approval_once() { + assertEquals(ReceiverAction.Approval(ApprovalChoice.ONCE), receiverActionFor(Notif.ACTION_ALLOW_ONCE)) + } + + @Test fun allow_session_maps_to_approval_session() { + assertEquals(ReceiverAction.Approval(ApprovalChoice.SESSION), receiverActionFor(Notif.ACTION_ALLOW_SESSION)) + } + + @Test fun deny_maps_to_approval_deny() { + assertEquals(ReceiverAction.Approval(ApprovalChoice.DENY), receiverActionFor(Notif.ACTION_DENY)) + } + + @Test fun reply_maps_to_reply() { + assertEquals(ReceiverAction.Reply, receiverActionFor(Notif.ACTION_REPLY)) + } + + @Test fun null_and_unknown_map_to_unknown() { + assertEquals(ReceiverAction.Unknown, receiverActionFor(null)) + assertEquals(ReceiverAction.Unknown, receiverActionFor("something_else")) + } +} diff --git a/app/src/test/java/com/hermes/client/ui/chat/ChatReducerTest.kt b/app/src/test/java/com/hermes/client/ui/chat/ChatReducerTest.kt index 1e16fac..5b4b52b 100644 --- a/app/src/test/java/com/hermes/client/ui/chat/ChatReducerTest.kt +++ b/app/src/test/java/com/hermes/client/ui/chat/ChatReducerTest.kt @@ -103,6 +103,13 @@ class ChatReducerTest { assertEquals("rm -rf?", s.pendingApproval?.command) } + @Test fun clarify_request_captures_request_id() { + var s = ChatUiState.empty() + s = s.reduce(ev("clarify.request") { put("question", "Which repo?"); put("request_id", "req-9") }) + assertEquals("Which repo?", s.pendingClarify?.question) + assertEquals("req-9", s.pendingClarify?.requestId) + } + @Test fun thinking_delta_accumulates() { var s = ChatUiState.empty() s = s.reduce(ev("message.start") { put("message_id", "a1") }) diff --git a/docs/superpowers/plans/2026-07-16-act-on-result-inline-reply.md b/docs/superpowers/plans/2026-07-16-act-on-result-inline-reply.md new file mode 100644 index 0000000..a0b2363 --- /dev/null +++ b/docs/superpowers/plans/2026-07-16-act-on-result-inline-reply.md @@ -0,0 +1,431 @@ +# Act-on-Result: Inline Reply + Broader Approval — Implementation Plan + +> **For agentic workers:** REQUIRED SUB-SKILL: Use superpowers:subagent-driven-development (recommended) or superpowers:executing-plans to implement this plan task-by-task. Steps use checkbox (`- [ ]`) syntax for tracking. + +**Goal:** Add two client-only notification actions — an inline **Reply** to "Needs your input" (`clarify.request`) notifications via Android `RemoteInput` → headless `prompt.submit`, and an **Approve for session** action on standard-tier approval notifications via the existing `approval.respond`. + +**Architecture:** All changes live in the existing notification pipeline (`NotificationModels` → `NotificationMapper` → `HermesNotifier` → `NotificationActionReceiver`). The event→spec mapping and the action→intent decision are pure and unit-tested; the `RemoteInput`/`PendingIntent` glue is Android, verified on-device. + +**Tech Stack:** Kotlin, AndroidX Core (`NotificationCompat`, `RemoteInput`), Hilt, Coroutines. + +**Spec:** `docs/superpowers/specs/2026-07-16-act-on-result-inline-reply-design.md` + +## Global Constraints + +- Client-only: reuse `ChatRepository.submit(sessionId, text)` (→ `prompt.submit`) and `ChatRepository.respondApproval(sessionId, choice)` (→ `approval.respond`, wire `"session"` already accepted). No gateway edits. +- Android caps visible notification actions at 3: standard-tier approval is `[Allow once, Session, Deny]`. **No "Always" on the notification.** **Elevated-tier approval unchanged (Deny-only).** +- Reply is scoped to `clarify.request` only (not `message.complete`). +- The reply PendingIntent must be `FLAG_MUTABLE` (direct-reply requirement) and **explicit** (targets `NotificationActionReceiver`); button PendingIntents stay `FLAG_IMMUTABLE`. +- Tests: pure JUnit (`NotificationMapperTest`, new `ReceiverActionTest`). No Compose/instrumentation tests. +- No AI/assistant attribution in commits or files. +- Build env: `export JAVA_HOME="/Applications/Android Studio.app/Contents/jbr/Contents/Home"`. +- Branch: `feature/act-on-result-inline-reply` (off `dev`; spec committed at `a1947b8`). All commits land here. + +--- + +### Task 1: Model constants + mapper actions + +**Files:** +- Modify: `app/src/main/java/com/hermes/client/notifications/NotificationModels.kt` +- Modify: `app/src/main/java/com/hermes/client/notifications/NotificationMapper.kt` +- Test: `app/src/test/java/com/hermes/client/notifications/NotificationMapperTest.kt` + +**Interfaces:** +- Produces: `NotifAction(label, action, sessionId, reply: Boolean = false)`; constants `Notif.ACTION_REPLY = "reply"`, `Notif.ACTION_ALLOW_SESSION = "allow_session"`, `Notif.KEY_REPLY_TEXT = "reply_text"`. Mapper: `clarify.request` → one reply action; standard-tier `approval.request` → `[Allow once, Session, Deny]`. + +- [ ] **Step 1: Update the failing tests** + +In `NotificationMapperTest.kt`, make these edits (they will fail until the model/mapper change lands): + +Replace the assertion in `approval_makes_high_priority_spec_with_actions` (the `assertEquals(listOf(Notif.ACTION_ALLOW_ONCE, Notif.ACTION_DENY), ...)` line) with: +```kotlin + assertEquals( + listOf(Notif.ACTION_ALLOW_ONCE, Notif.ACTION_ALLOW_SESSION, Notif.ACTION_DENY), + spec.actions.map { it.action }, + ) + assertTrue(spec.actions.none { it.reply }) +``` + +Replace the whole `standard_approval_offers_allow_once_and_deny` test with: +```kotlin + @Test fun standard_approval_offers_allow_once_session_and_deny() { + val e = ServerEvent("approval.request", "s1", buildJsonObject { + put("session_id", "s1"); put("command", "git push -f"); put("allow_permanent", true) + }) + val spec = toNotificationSpec(e, on, appInForeground = false)!! + assertEquals( + listOf(Notif.ACTION_ALLOW_ONCE, Notif.ACTION_ALLOW_SESSION, Notif.ACTION_DENY), + spec.actions.map { it.action }, + ) + } +``` + +Replace the body of `clarify_notifies_with_question_regardless_of_foreground` (the `assertTrue(spec.actions.isEmpty())` line) with: +```kotlin + assertEquals(1, spec.actions.size) + val reply = spec.actions.single() + assertEquals(Notif.ACTION_REPLY, reply.action) + assertEquals("Reply", reply.label) + assertTrue(reply.reply) + assertEquals("c1", reply.sessionId) +``` + +Add a new test: +```kotlin + @Test fun elevated_approval_has_no_session_action() { + val e = ServerEvent("approval.request", "s1", buildJsonObject { + put("session_id", "s1"); put("command", "rm -rf /"); put("allow_permanent", false) + }) + val spec = toNotificationSpec(e, on, appInForeground = false)!! + assertEquals(listOf(Notif.ACTION_DENY), spec.actions.map { it.action }) + } +``` + +- [ ] **Step 2: Run the tests to verify they fail** + +Run: `./gradlew :app:testDebugUnitTest --tests "com.hermes.client.notifications.NotificationMapperTest"` +Expected: FAIL — `Notif.ACTION_ALLOW_SESSION`/`ACTION_REPLY` unresolved and/or the action-list assertions mismatch. + +- [ ] **Step 3: Update the model** + +In `NotificationModels.kt`, change the `NotifAction` data class and add three constants to the `Notif` object. + +Replace: +```kotlin +/** An inline notification action (Allow once/Deny) carrying the target session. */ +data class NotifAction(val label: String, val action: String, val sessionId: String) +``` +with: +```kotlin +/** + * An inline notification action carrying the target session. [reply] = true marks a direct-reply + * action (Android RemoteInput text field) rather than a plain button. + */ +data class NotifAction( + val label: String, + val action: String, + val sessionId: String, + val reply: Boolean = false, +) +``` + +In the `Notif` object, replace: +```kotlin + const val ACTION_ALLOW_ONCE = "allow_once" + const val ACTION_DENY = "deny" +``` +with: +```kotlin + const val ACTION_ALLOW_ONCE = "allow_once" + const val ACTION_ALLOW_SESSION = "allow_session" + const val ACTION_DENY = "deny" + const val ACTION_REPLY = "reply" + + // RemoteInput result key for the inline reply on a clarify ("Needs your input") notification. + const val KEY_REPLY_TEXT = "reply_text" +``` + +- [ ] **Step 4: Update the mapper** + +In `NotificationMapper.kt`, in the `Notif.EVENT_APPROVAL` branch, replace the `actions = ...` expression with (elevated unchanged; standard now includes Session): +```kotlin + actions = if (elevated) listOf(NotifAction("Deny", Notif.ACTION_DENY, sid)) + else listOf( + NotifAction("Allow once", Notif.ACTION_ALLOW_ONCE, sid), + NotifAction("Session", Notif.ACTION_ALLOW_SESSION, sid), + NotifAction("Deny", Notif.ACTION_DENY, sid), + ), +``` + +In the `Notif.EVENT_CLARIFY` branch, replace `actions = emptyList()` with a single reply action: +```kotlin + route = "chat/$sid", + actions = listOf(NotifAction("Reply", Notif.ACTION_REPLY, sid, reply = true)), + groupKey = "approval", +``` +(Keep the rest of the CLARIFY branch — title "Needs your input", body, channel — unchanged.) + +- [ ] **Step 5: Run the tests to verify they pass** + +Run: `./gradlew :app:testDebugUnitTest --tests "com.hermes.client.notifications.NotificationMapperTest"` +Expected: PASS (all existing + updated + new cases green). + +- [ ] **Step 6: Commit** + +```bash +git add app/src/main/java/com/hermes/client/notifications/NotificationModels.kt \ + app/src/main/java/com/hermes/client/notifications/NotificationMapper.kt \ + app/src/test/java/com/hermes/client/notifications/NotificationMapperTest.kt +git commit -m "feat: add reply + approve-for-session notification actions to the mapper" +``` + +--- + +### Task 2: Receiver action helper + headless handling + +**Files:** +- Modify: `app/src/main/java/com/hermes/client/notifications/NotificationActionReceiver.kt` +- Test: `app/src/test/java/com/hermes/client/notifications/ReceiverActionTest.kt` + +**Interfaces:** +- Consumes: `Notif.ACTION_*` constants (Task 1); `ChatRepository.submit(sessionId, text)` and `ChatRepository.respondApproval(sessionId, choice)`; `ApprovalChoice` (`ui.chat`, values `ONCE`/`SESSION`/`DENY`). +- Produces: `sealed interface ReceiverAction { Approval(choice); Reply; Unknown }` and `fun receiverActionFor(action: String?): ReceiverAction`. + +- [ ] **Step 1: Write the failing test** + +`app/src/test/java/com/hermes/client/notifications/ReceiverActionTest.kt`: +```kotlin +package com.hermes.client.notifications + +import com.hermes.client.ui.chat.ApprovalChoice +import org.junit.Assert.assertEquals +import org.junit.Test + +class ReceiverActionTest { + @Test fun allow_once_maps_to_approval_once() { + assertEquals(ReceiverAction.Approval(ApprovalChoice.ONCE), receiverActionFor(Notif.ACTION_ALLOW_ONCE)) + } + + @Test fun allow_session_maps_to_approval_session() { + assertEquals(ReceiverAction.Approval(ApprovalChoice.SESSION), receiverActionFor(Notif.ACTION_ALLOW_SESSION)) + } + + @Test fun deny_maps_to_approval_deny() { + assertEquals(ReceiverAction.Approval(ApprovalChoice.DENY), receiverActionFor(Notif.ACTION_DENY)) + } + + @Test fun reply_maps_to_reply() { + assertEquals(ReceiverAction.Reply, receiverActionFor(Notif.ACTION_REPLY)) + } + + @Test fun null_and_unknown_map_to_unknown() { + assertEquals(ReceiverAction.Unknown, receiverActionFor(null)) + assertEquals(ReceiverAction.Unknown, receiverActionFor("something_else")) + } +} +``` + +- [ ] **Step 2: Run the test to verify it fails** + +Run: `./gradlew :app:testDebugUnitTest --tests "com.hermes.client.notifications.ReceiverActionTest"` +Expected: FAIL — `ReceiverAction`/`receiverActionFor` unresolved. + +- [ ] **Step 3: Implement the helper + wire onReceive** + +Rewrite `NotificationActionReceiver.kt` to add the sealed type + pure helper and route Reply/Session headlessly: +```kotlin +package com.hermes.client.notifications + +import android.content.BroadcastReceiver +import android.content.Context +import android.content.Intent +import androidx.core.app.RemoteInput +import com.hermes.client.data.diagnostics.DebugLog +import com.hermes.client.data.repository.ChatRepository +import com.hermes.client.ui.chat.ApprovalChoice +import dagger.hilt.android.AndroidEntryPoint +import kotlinx.coroutines.CoroutineScope +import kotlinx.coroutines.Dispatchers +import kotlinx.coroutines.launch +import kotlinx.coroutines.withTimeout +import javax.inject.Inject + +/** What a received notification-action intent should do. Pure/testable, no Android deps. */ +sealed interface ReceiverAction { + data class Approval(val choice: ApprovalChoice) : ReceiverAction + data object Reply : ReceiverAction + data object Unknown : ReceiverAction +} + +fun receiverActionFor(action: String?): ReceiverAction = when (action) { + Notif.ACTION_ALLOW_ONCE -> ReceiverAction.Approval(ApprovalChoice.ONCE) + Notif.ACTION_ALLOW_SESSION -> ReceiverAction.Approval(ApprovalChoice.SESSION) + Notif.ACTION_DENY -> ReceiverAction.Approval(ApprovalChoice.DENY) + Notif.ACTION_REPLY -> ReceiverAction.Reply + else -> ReceiverAction.Unknown +} + +/** + * Handles a notification action headlessly: Allow-once/Session/Deny → `approval.respond`; an inline + * Reply → `prompt.submit`. Runs a single RPC on a background scope; only clears the notification + * once the RPC succeeds, so a failed action isn't silently lost. + */ +@AndroidEntryPoint +class NotificationActionReceiver : BroadcastReceiver() { + @Inject lateinit var chat: ChatRepository + @Inject lateinit var notifier: HermesNotifier + + override fun onReceive(context: Context, intent: Intent) { + val sid = intent.getStringExtra("session_id") ?: return + val notifId = intent.getIntExtra("notif_id", -1) + val ra = receiverActionFor(intent.action) + val replyText = RemoteInput.getResultsFromIntent(intent) + ?.getCharSequence(Notif.KEY_REPLY_TEXT)?.toString()?.trim() + + // Nothing actionable, or a blank reply → leave the notification up (retryable) and stop. + if (ra is ReceiverAction.Unknown) return + if (ra is ReceiverAction.Reply && replyText.isNullOrBlank()) return + + val pending = goAsync() + CoroutineScope(Dispatchers.IO).launch { + try { + runCatching { + withTimeout(8_000) { + when (ra) { + is ReceiverAction.Approval -> chat.respondApproval(sid, ra.choice) + ReceiverAction.Reply -> chat.submit(sid, replyText!!) + ReceiverAction.Unknown -> Unit // unreachable (returned above) + } + } + }.onSuccess { + if (notifId != -1) notifier.cancel(notifId) + }.onFailure { e -> + DebugLog.log("notif", "action failed session=$sid action=${intent.action}: ${e.message}") + } + } finally { + pending.finish() + } + } + } +} +``` + +- [ ] **Step 4: Run the test to verify it passes** + +Run: `./gradlew :app:testDebugUnitTest --tests "com.hermes.client.notifications.ReceiverActionTest"` +Expected: PASS (5 tests). + +- [ ] **Step 5: Compile the app** + +Run: `JAVA_HOME="/Applications/Android Studio.app/Contents/jbr/Contents/Home" ./gradlew :app:compileDebugKotlin` +Expected: BUILD SUCCESSFUL. (Confirms `chat.submit`/`chat.respondApproval` signatures and `ApprovalChoice.SESSION` resolve.) + +- [ ] **Step 6: Commit** + +```bash +git add app/src/main/java/com/hermes/client/notifications/NotificationActionReceiver.kt \ + app/src/test/java/com/hermes/client/notifications/ReceiverActionTest.kt +git commit -m "feat: handle reply + approve-for-session notification actions headlessly" +``` + +--- + +### Task 3: Notifier builds the RemoteInput reply action + +**Files:** +- Modify: `app/src/main/java/com/hermes/client/notifications/HermesNotifier.kt` +- Test: none new (Android glue; covered by compile + assembleBeta + on-device Task 4). + +**Interfaces:** +- Consumes: `NotifAction.reply` + `Notif.KEY_REPLY_TEXT` (Task 1). Reply actions are posted for specs whose `NotifAction.reply == true`. + +- [ ] **Step 1: Add the RemoteInput import** + +In `HermesNotifier.kt`, add to the imports: +```kotlin +import androidx.core.app.RemoteInput +``` + +- [ ] **Step 2: Branch the action build in `post()`** + +Replace the action loop in `post(spec)`: +```kotlin + spec.actions.forEach { a -> + b.addAction(0, a.label, actionIntent(a, spec.id)) + } +``` +with: +```kotlin + spec.actions.forEach { a -> + if (a.reply) { + val remoteInput = RemoteInput.Builder(Notif.KEY_REPLY_TEXT).setLabel("Reply…").build() + val action = NotificationCompat.Action.Builder(0, a.label, replyIntent(a, spec.id)) + .addRemoteInput(remoteInput) + .setAllowGeneratedReplies(false) + .build() + b.addAction(action) + } else { + b.addAction(0, a.label, actionIntent(a, spec.id)) + } + } +``` + +- [ ] **Step 3: Add the mutable reply PendingIntent builder** + +Add this private method next to `actionIntent(...)` in `HermesNotifier`: +```kotlin + private fun replyIntent(a: NotifAction, notifId: Int): PendingIntent { + val intent = Intent(context, NotificationActionReceiver::class.java).apply { + action = a.action + putExtra("session_id", a.sessionId) + putExtra("notif_id", notifId) + } + // Direct-reply requires FLAG_MUTABLE so the system can attach the RemoteInput results. + // The intent is explicit (our own receiver), so it can't be redirected — mutability is safe. + return PendingIntent.getBroadcast( + context, + (a.action + a.sessionId).hashCode(), + intent, + PendingIntent.FLAG_UPDATE_CURRENT or PendingIntent.FLAG_MUTABLE, + ) + } +``` +(Leave `actionIntent`/`pendingFlags` as-is: buttons keep `FLAG_IMMUTABLE`.) + +- [ ] **Step 4: Compile** + +Run: `JAVA_HOME="/Applications/Android Studio.app/Contents/jbr/Contents/Home" ./gradlew :app:compileDebugKotlin` +Expected: BUILD SUCCESSFUL. + +- [ ] **Step 5: Run the full unit-test suite** + +Run: `JAVA_HOME="/Applications/Android Studio.app/Contents/jbr/Contents/Home" ./gradlew :app:testDebugUnitTest` +Expected: BUILD SUCCESSFUL, 0 failures (includes the Task 1 + Task 2 suites). + +- [ ] **Step 6: Assemble the beta variant** + +Run: `JAVA_HOME="/Applications/Android Studio.app/Contents/jbr/Contents/Home" ./gradlew :app:assembleBeta` +Expected: BUILD SUCCESSFUL. + +- [ ] **Step 7: Commit** + +```bash +git add app/src/main/java/com/hermes/client/notifications/HermesNotifier.kt +git commit -m "feat: post inline-reply notification action with RemoteInput" +``` + +--- + +### Task 4: On-device verification (best-effort) + +**Files:** none (manual verification on the emulator or a connected device with notifications enabled + a reachable gateway). + +There is no automated Compose/instrumentation test (per Global Constraints); this manual pass is the acceptance gate for the `RemoteInput`/PendingIntent glue. Notifications must be enabled in the app and the foreground service running (so the WS is connected for the headless RPC). + +- [ ] **Step 1: Install the beta build** + +Run: +```bash +JAVA_HOME="/Applications/Android Studio.app/Contents/jbr/Contents/Home" ./gradlew :app:installBeta +``` +Expected: `Installed on 1 device`. + +- [ ] **Step 2: Verify the approve-for-session action** + +Trigger (or wait for) a **standard-tier** tool approval so an "Approval needed" notification posts. Confirm it shows three actions: **Allow once · Session · Deny**. Tap **Session** → the tool proceeds and the notification clears; a subsequent same-type tool call in that session is auto-allowed (session scope). Confirm an **elevated**-tier approval still shows **Deny** only. + +- [ ] **Step 3: Verify the inline reply action** + +When a **"Needs your input"** (`clarify.request`) notification appears, confirm it shows a **Reply** action that expands to an inline text field. Type an answer and send → confirm the text reaches the session as a new user turn (open the chat to verify) and the notification clears. Send a **blank** reply → confirm nothing is sent and the notification remains. + +- [ ] **Step 4: Note the caveat** + +A `clarify.request` requires an agent to actually ask a clarifying question, which cannot be forced on demand. If none occurs during the session, record that the reply path was verified by the pure tests + code review and the action's *appearance* (if any clarify notification is available), and note the live send was not exercised. Record the outcome in the PR description. + +--- + +## Notes for the executor + +- `ChatRepository.submit(sessionId, text)` is the existing headless `prompt.submit` call; `respondApproval(sessionId, choice)` is the existing `approval.respond` call. Both are already injected into the receiver as `chat`. Do not add new RPCs. +- The reply and button actions for the same session never collide on request code because their `a.action` strings differ (`reply` vs `allow_once`/`deny`/`allow_session`). +- Do NOT add "Always" to the notification (would be a 4th action, past Android's 3-action display budget) or a reply action to `message.complete` — both are explicit anti-scope. diff --git a/docs/superpowers/specs/2026-07-16-act-on-result-inline-reply-design.md b/docs/superpowers/specs/2026-07-16-act-on-result-inline-reply-design.md new file mode 100644 index 0000000..f320e23 --- /dev/null +++ b/docs/superpowers/specs/2026-07-16-act-on-result-inline-reply-design.md @@ -0,0 +1,170 @@ +# Act-on-Result: Inline Reply + Broader Approval — Design + +**Wave:** Quick-wins wave 3 (from `docs/ideas/2026-07-16-competitive-refresh.md`, "act-on-result"). **Branch:** `feature/act-on-result-inline-reply` (off `dev`). + +**Goal:** Let the user act on an agent's result from the notification shade without opening the app — answer a "Needs your input" prompt inline, and approve a tool call *for the session* (not just once). Fully client-only; reuses the existing headless `NotificationActionReceiver` → RPC pattern. + +**Positioning:** Most act-on-result actions already ship — approval notifications carry Allow-once/Deny (headless via `approval.respond`), and the activity feed has run-now / retry / open / view-full-chat. This wave adds the two missing high-value **notification** actions. + +**Constraints:** Kotlin / Compose / Hilt / Material3, per-tenant accent. **Client-only** — no gateway changes; reuses `prompt.submit` and `approval.respond`. Standing repo constraints (no AI attribution; gitleaks before every push; tenant isolation; `main` only via approved PR). + +--- + +## Correction (post-implementation review) + +The final review found the original mechanism below was **wrong**: a `clarify.request` is a *blocking* agent park on the gateway that is answered only by **`clarify.respond` with the event's `request_id`**. `prompt.submit` (originally specified) hits the gateway's busy handler while the turn is `running` — it **abandons the parked question and posts a disconnected new turn**. The review also found the *existing* in-app clarify path (`ChatRepository.respondClarify`) already omits `request_id` and fails with gateway error 4009 — a pre-existing bug. + +**Corrected mechanism (client-only; the `clarify.request` event carries `request_id`, added by the gateway's `_block`):** thread `request_id` end-to-end so the inline reply answers via `clarify.respond`, which also repairs the in-app bug: +- `ClarifyRequest` gains `requestId`; the `clarify.request` reducer captures `event.str("request_id")`. +- `ChatRepository.respondClarify(sessionId, requestId, answer)` sends `request_id`; `ChatViewModel.clarify` passes `pendingClarify.requestId`. +- `NotifAction` gains `requestId`; the mapper's clarify action carries it; `HermesNotifier` puts it as a `request_id` intent extra; `NotificationActionReceiver` reads it and calls `chat.respondClarify(sid, requestId, text)` (not `submit`). + +The **Approve-for-session** half of this spec is unaffected and correct. Where the sections below say `prompt.submit`/`chat.submit` for the reply, read `clarify.respond`/`respondClarify(sid, requestId, text)`. + +--- + +## Scope (locked) + +- **Reply** action on `clarify.request` ("Needs your input") notifications — today they carry no actions, only tap-to-open. Android `RemoteInput` → headless `prompt.submit`. +- **Approve for session** action on **standard-tier** `approval.request` notifications — today only Allow-once/Deny. Reuses `approval.respond` with `choice = "session"`. +- **Out:** "Always" on the notification (Android caps visible actions at 3; `[Allow once, Session, Deny]` is the budget — a permanent grant stays a deliberate in-app choice); reply on `message.complete` (run-finished isn't awaiting input); true "rerun this run" (needs a gateway RPC); cron-completion notifications (gateway doesn't emit them). + +--- + +## Architecture + +Four touch points, all in the existing notification pipeline. The event→spec mapping and the action→intent decision are pure and unit-tested; the `RemoteInput`/`PendingIntent`/receiver glue is Android, verified on-device. + +### 1. Model — `notifications/NotificationModels.kt` + +- `NotifAction` gains a reply flag: + ```kotlin + data class NotifAction( + val label: String, + val action: String, + val sessionId: String, + val reply: Boolean = false, // true → an inline RemoteInput reply action, not a button + ) + ``` +- New constants in the `Notif` object: + ```kotlin + const val ACTION_REPLY = "reply" + const val ACTION_ALLOW_SESSION = "allow_session" + const val KEY_REPLY_TEXT = "reply_text" // RemoteInput result key + ``` + +### 2. Event→spec mapping — `notifications/NotificationMapper.kt` + +- `clarify.request` → add a single reply action (keep the existing `chat/$sid` tap route + title "Needs your input"): + ```kotlin + actions = listOf(NotifAction("Reply", Notif.ACTION_REPLY, sid, reply = true)) + ``` +- `approval.request` **standard tier** → insert "Approve for session" between Allow-once and Deny: + ```kotlin + listOf( + NotifAction("Allow once", Notif.ACTION_ALLOW_ONCE, sid), + NotifAction("Session", Notif.ACTION_ALLOW_SESSION, sid), + NotifAction("Deny", Notif.ACTION_DENY, sid), + ) + ``` + **Elevated tier is unchanged** (Deny-only) — the tiered-approvals safety design stays intact. + +### 3. Notifier — `notifications/HermesNotifier.kt` + +In `post(spec)`, branch the per-action build: +- `action.reply == false` → the existing plain `addAction(0, label, actionIntent(a, id))` with a `FLAG_IMMUTABLE` broadcast PendingIntent (unchanged). +- `action.reply == true` → build a `NotificationCompat.Action` carrying a `RemoteInput`: + ```kotlin + val remoteInput = RemoteInput.Builder(Notif.KEY_REPLY_TEXT).setLabel("Reply…").build() + val piFlags = PendingIntent.FLAG_UPDATE_CURRENT or PendingIntent.FLAG_MUTABLE + val replyAction = NotificationCompat.Action.Builder(0, a.label, replyPendingIntent(a, spec.id, piFlags)) + .addRemoteInput(remoteInput) + .setAllowGeneratedReplies(false) + .build() + b.addAction(replyAction) + ``` + The reply PendingIntent targets the **explicit** `NotificationActionReceiver` component (same as `actionIntent`), so `FLAG_MUTABLE` is safe — an explicit intent can't be redirected; mutability only lets the system attach the `RemoteInput` results. (Existing button actions keep `FLAG_IMMUTABLE`.) + +### 4. Receiver — `notifications/NotificationActionReceiver.kt` + +A pure decision helper makes the dispatch unit-testable: +```kotlin +sealed interface ReceiverAction { + data class Approval(val choice: ApprovalChoice) : ReceiverAction + data object Reply : ReceiverAction + data object Unknown : ReceiverAction +} + +fun receiverActionFor(action: String?): ReceiverAction = when (action) { + Notif.ACTION_ALLOW_ONCE -> ReceiverAction.Approval(ApprovalChoice.ONCE) + Notif.ACTION_ALLOW_SESSION -> ReceiverAction.Approval(ApprovalChoice.SESSION) + Notif.ACTION_DENY -> ReceiverAction.Approval(ApprovalChoice.DENY) + Notif.ACTION_REPLY -> ReceiverAction.Reply + else -> ReceiverAction.Unknown +} +``` +`onReceive` (headless via `goAsync()` + `CoroutineScope(Dispatchers.IO)` + `withTimeout(8_000)`, mirroring the shipped approval handler): +- `Approval(choice)` → `chat.respondApproval(sid, choice)` (covers Allow-once, **Session**, Deny), cancel notif on success. +- `Reply` → `RemoteInput.getResultsFromIntent(intent)?.getCharSequence(Notif.KEY_REPLY_TEXT)`; trim; if non-blank → `chat.submit(sid, text.toString())` (the existing `prompt.submit`), cancel notif on success; blank → do nothing (leave the notification). +- `Unknown` → ignore. + +`chat.submit(sessionId, text)` and `chat.respondApproval(sessionId, choice)` are existing headless `suspend` funcs on `ChatRepository` over the gateway WebSocket. They require the socket to be connected — the same precondition the shipped approval action already relies on (the foreground `GatewayConnectionService` keeps it alive when notifications are enabled). + +--- + +## Data flow + +``` +clarify.request event ─ NotificationMapper ─→ spec.actions = [Reply(reply=true)] +approval.request event ─ NotificationMapper ─→ spec.actions = [Allow once, Session, Deny] (standard tier) + │ + HermesNotifier.post + reply? → NotificationCompat.Action + RemoteInput + FLAG_MUTABLE PendingIntent + button? → addAction + FLAG_IMMUTABLE PendingIntent + │ (user taps / types in the shade) + NotificationActionReceiver.onReceive (headless, goAsync + withTimeout) + receiverActionFor(action) + Reply → chat.submit(sid, RemoteInput text) ── prompt.submit + Approval(SESSION/ONCE/DENY) → chat.respondApproval(sid, choice) ── approval.respond + │ on success → notifier cancels the notification +``` + +## Error handling + +- Blank/absent reply text → no send, notification left in place (user can retry). +- RPC failure / timeout (`withTimeout(8_000)`) → notification is **not** cancelled (so the action can be retried), mirroring the shipped approval handler's success-gated cancel. +- Socket not connected → the RPC call fails within the timeout; notification stays. (No new failure mode vs. the existing approval action.) +- Unknown action string → ignored. + +## Testing + +**Pure `NotificationMapperTest` additions** (JUnit, existing pure test for `toNotificationSpec`): +- `clarify.request` → exactly one action, `reply == true`, `label == "Reply"`, `action == ACTION_REPLY`, correct `sessionId`; tap route still `chat/$sid`. +- standard-tier `approval.request` → actions `[Allow once, Session, Deny]` with `ACTION_ALLOW_SESSION` present and `reply == false` on all three. +- elevated-tier `approval.request` → unchanged (Deny-only), no Session action. + +**Pure `receiverActionFor` tests** (new small test): +- each action constant → the correct `ReceiverAction` (incl. `ACTION_ALLOW_SESSION → Approval(SESSION)`, `ACTION_REPLY → Reply`); `null`/unknown → `Unknown`. + +`RemoteInput` extraction, PendingIntent flags, and the receiver's coroutine glue are Android — no unit tests (repo style); covered by on-device verification. + +## On-device verification (best-effort) + +- **Approve-for-session:** trigger a standard-tier tool approval; confirm the notification shows `[Allow once, Session, Deny]`; tap **Session** → the tool proceeds and subsequent same-type calls in that session are auto-allowed (session scope), notification clears. +- **Inline reply:** when a `clarify.request` ("Needs your input") notification appears, confirm a **Reply** action with an inline text field; type and send → the text reaches the session (a new user turn) and the notification clears. +- **Caveat:** a `clarify.request` requires an agent to actually ask a clarifying question, which can't be forced on demand — so the reply path is verified opportunistically; the pure tests + review are the primary correctness gate. If no clarify arises during the session, note it and rely on the tests. + +## Files + +| Action | Path | Responsibility | +|--------|------|----------------| +| Modify | `notifications/NotificationModels.kt` | `NotifAction.reply` flag + `ACTION_REPLY`/`ACTION_ALLOW_SESSION`/`KEY_REPLY_TEXT` | +| Modify | `notifications/NotificationMapper.kt` | reply action on clarify; Session action on standard approval | +| Modify | `notifications/HermesNotifier.kt` | build RemoteInput reply action (MUTABLE explicit PI) vs. plain button | +| Modify | `notifications/NotificationActionReceiver.kt` | `receiverActionFor` helper + Reply→`submit` / Session→`respondApproval` headless handling | +| Modify/Create test | `test/…/notifications/NotificationMapperTest.kt` | mapper action assertions | +| Create test | `test/…/notifications/ReceiverActionTest.kt` | `receiverActionFor` mapping | + +## Build & gates + +`JAVA_HOME="/Applications/Android Studio.app/Contents/jbr/Contents/Home"`: `:app:compileDebugKotlin`, `:app:testDebugUnitTest`, `:app:assembleBeta`. gitleaks before every push; PR into `dev`. From fb5c1c5ba5d26627ef510a041b073692e706cb77 Mon Sep 17 00:00:00 2001 From: Andrew Debnar <606931+adebnar@users.noreply.github.com> Date: Fri, 17 Jul 2026 15:27:44 +0000 Subject: [PATCH 06/16] feat: QR-scan pairing on the setup screen (#100) * docs: spec for QR-scan pairing in setup (client-only) * docs: implementation plan for QR-scan pairing * feat: add pairing-QR payload parser * feat: SetupViewModel.applyPairing populates fields from a scanned QR * feat: add Scan-QR pairing button to setup (ZXing + CAMERA) * fix: reject non-URL pairing payloads and make probeLogin not throw on a bad URL --- app/build.gradle.kts | 1 + app/src/main/AndroidManifest.xml | 1 + .../hermes/client/data/network/GatedAuth.kt | 21 +- .../hermes/client/ui/setup/PairingPayload.kt | 25 ++ .../com/hermes/client/ui/setup/SetupScreen.kt | 21 + .../hermes/client/ui/setup/SetupViewModel.kt | 19 + .../client/ui/setup/PairingPayloadTest.kt | 47 +++ .../client/ui/setup/SetupViewModelTest.kt | 59 +++ .../plans/2026-07-17-qr-scan-pairing.md | 398 ++++++++++++++++++ .../2026-07-17-qr-scan-pairing-design.md | 122 ++++++ gradle/libs.versions.toml | 2 + 11 files changed, 706 insertions(+), 10 deletions(-) create mode 100644 app/src/main/java/com/hermes/client/ui/setup/PairingPayload.kt create mode 100644 app/src/test/java/com/hermes/client/ui/setup/PairingPayloadTest.kt create mode 100644 app/src/test/java/com/hermes/client/ui/setup/SetupViewModelTest.kt create mode 100644 docs/superpowers/plans/2026-07-17-qr-scan-pairing.md create mode 100644 docs/superpowers/specs/2026-07-17-qr-scan-pairing-design.md diff --git a/app/build.gradle.kts b/app/build.gradle.kts index 44f9d7b..2eae156 100644 --- a/app/build.gradle.kts +++ b/app/build.gradle.kts @@ -107,6 +107,7 @@ dependencies { implementation(libs.datastore.preferences) implementation(libs.security.crypto) implementation(libs.markdown.m3) + implementation(libs.zxing.embedded) debugImplementation(libs.compose.ui.tooling) debugImplementation(libs.compose.ui.test.manifest) diff --git a/app/src/main/AndroidManifest.xml b/app/src/main/AndroidManifest.xml index ba2476d..4c67ba7 100644 --- a/app/src/main/AndroidManifest.xml +++ b/app/src/main/AndroidManifest.xml @@ -3,6 +3,7 @@ + diff --git a/app/src/main/java/com/hermes/client/data/network/GatedAuth.kt b/app/src/main/java/com/hermes/client/data/network/GatedAuth.kt index ac58336..0c0c12c 100644 --- a/app/src/main/java/com/hermes/client/data/network/GatedAuth.kt +++ b/app/src/main/java/com/hermes/client/data/network/GatedAuth.kt @@ -69,12 +69,13 @@ class GatedAuth( put("username", cfg.username) put("password", cfg.password) } - val req = Request.Builder() - .url("${cfg.baseUrl.trimEnd('/')}/auth/password-login") - .post(json.encodeToString(JsonObject.serializer(), payload).toRequestBody(JSON_MEDIA)) - .build() - val ok = runCatching { loginClient.newCall(req).execute().use { it.isSuccessful } } - .getOrDefault(false) + val ok = runCatching { + val req = Request.Builder() + .url("${cfg.baseUrl.trimEnd('/')}/auth/password-login") + .post(json.encodeToString(JsonObject.serializer(), payload).toRequestBody(JSON_MEDIA)) + .build() + loginClient.newCall(req).execute().use { it.isSuccessful } + }.getOrDefault(false) DebugLog.log("ws", "gated login -> $ok") return ok } @@ -108,11 +109,11 @@ class GatedAuth( put("username", username) put("password", password) } - val req = Request.Builder() - .url("${baseUrl.trimEnd('/')}/auth/password-login") - .post(json.encodeToString(JsonObject.serializer(), payload).toRequestBody(JSON_MEDIA)) - .build() return runCatching { + val req = Request.Builder() + .url("${baseUrl.trimEnd('/')}/auth/password-login") + .post(json.encodeToString(JsonObject.serializer(), payload).toRequestBody(JSON_MEDIA)) + .build() OkHttpClient().newCall(req).execute().use { it.isSuccessful } }.getOrDefault(false) } diff --git a/app/src/main/java/com/hermes/client/ui/setup/PairingPayload.kt b/app/src/main/java/com/hermes/client/ui/setup/PairingPayload.kt new file mode 100644 index 0000000..a2a10e4 --- /dev/null +++ b/app/src/main/java/com/hermes/client/ui/setup/PairingPayload.kt @@ -0,0 +1,25 @@ +package com.hermes.client.ui.setup + +import kotlinx.serialization.Serializable +import kotlinx.serialization.json.Json + +/** Credentials carried by a Hermes pairing QR. Primary payload is url + username + password. */ +@Serializable +data class PairingPayload( + val v: Int = 0, + val url: String = "", + val token: String = "", + val username: String = "", + val password: String = "", +) + +private val pairingJson = Json { ignoreUnknownKeys = true } + +/** + * Parse a scanned QR string. Returns null (never throws) unless it is a valid v1 Hermes pairing + * object with a non-blank url — so a random/non-Hermes QR is rejected cleanly. + */ +fun parsePairingPayload(raw: String): PairingPayload? = + runCatching { pairingJson.decodeFromString(raw) } + .getOrNull() + ?.takeIf { it.v == 1 && (it.url.startsWith("http://") || it.url.startsWith("https://")) } diff --git a/app/src/main/java/com/hermes/client/ui/setup/SetupScreen.kt b/app/src/main/java/com/hermes/client/ui/setup/SetupScreen.kt index d0f19fa..dd967a3 100644 --- a/app/src/main/java/com/hermes/client/ui/setup/SetupScreen.kt +++ b/app/src/main/java/com/hermes/client/ui/setup/SetupScreen.kt @@ -1,5 +1,6 @@ package com.hermes.client.ui.setup +import androidx.activity.compose.rememberLauncherForActivityResult import androidx.compose.foundation.layout.Arrangement import androidx.compose.foundation.layout.Column import androidx.compose.foundation.layout.Row @@ -19,10 +20,16 @@ import androidx.compose.ui.Modifier import androidx.compose.ui.unit.dp import androidx.hilt.lifecycle.viewmodel.compose.hiltViewModel import androidx.lifecycle.compose.collectAsStateWithLifecycle +import com.journeyapps.barcodescanner.ScanContract +import com.journeyapps.barcodescanner.ScanOptions @Composable fun SetupScreen(vm: SetupViewModel = hiltViewModel(), onSaved: () -> Unit) { val state by vm.state.collectAsStateWithLifecycle() + val scanLauncher = rememberLauncherForActivityResult(ScanContract()) { result -> + // Null contents = the user cancelled or denied the camera; manual entry stays usable. + result.contents?.let { vm.applyPairing(it) } + } LaunchedEffect(state.saved) { if (state.saved) onSaved() } Column( // safeDrawingPadding keeps content clear of the status bar (clock/notifications) @@ -31,6 +38,20 @@ fun SetupScreen(vm: SetupViewModel = hiltViewModel(), onSaved: () -> Unit) { verticalArrangement = Arrangement.spacedBy(12.dp), ) { Text("Connect to Hermes", style = MaterialTheme.typography.headlineSmall) + OutlinedButton( + onClick = { + scanLauncher.launch( + ScanOptions().apply { + setDesiredBarcodeFormats(ScanOptions.QR_CODE) + setPrompt("Scan the Hermes pairing QR") + setBeepEnabled(false) + setOrientationLocked(false) + }, + ) + }, + modifier = Modifier.fillMaxWidth(), + ) { Text("Scan QR") } + state.scanError?.let { Text(it, color = MaterialTheme.colorScheme.error) } OutlinedTextField( value = state.url, onValueChange = vm::onUrlChange, diff --git a/app/src/main/java/com/hermes/client/ui/setup/SetupViewModel.kt b/app/src/main/java/com/hermes/client/ui/setup/SetupViewModel.kt index 4b21040..910bc10 100644 --- a/app/src/main/java/com/hermes/client/ui/setup/SetupViewModel.kt +++ b/app/src/main/java/com/hermes/client/ui/setup/SetupViewModel.kt @@ -22,6 +22,7 @@ data class SetupUiState( val password: String = "", val testResult: String? = null, val saved: Boolean = false, + val scanError: String? = null, ) @HiltViewModel @@ -59,4 +60,22 @@ class SetupViewModel @Inject constructor( gatedAuth.cookieJar.clear() _state.value = _state.value.copy(saved = true) } + + /** Apply a scanned pairing QR: prefill the fields and auto-run the existing probe. */ + fun applyPairing(raw: String) { + val p = parsePairingPayload(raw) + if (p == null) { + _state.value = _state.value.copy(scanError = "Not a Hermes pairing code") + return + } + _state.value = _state.value.copy( + url = p.url, token = p.token, username = p.username, password = p.password, + scanError = null, testResult = null, + ) + test() + } + + fun clearScanError() { + if (_state.value.scanError != null) _state.value = _state.value.copy(scanError = null) + } } diff --git a/app/src/test/java/com/hermes/client/ui/setup/PairingPayloadTest.kt b/app/src/test/java/com/hermes/client/ui/setup/PairingPayloadTest.kt new file mode 100644 index 0000000..eb0b32c --- /dev/null +++ b/app/src/test/java/com/hermes/client/ui/setup/PairingPayloadTest.kt @@ -0,0 +1,47 @@ +package com.hermes.client.ui.setup + +import org.junit.Assert.assertEquals +import org.junit.Assert.assertNull +import org.junit.Test + +class PairingPayloadTest { + @Test fun parses_gated_payload() { + val p = parsePairingPayload("""{"v":1,"url":"https://h.ts.net","username":"a","password":"p"}""")!! + assertEquals("https://h.ts.net", p.url) + assertEquals("a", p.username) + assertEquals("p", p.password) + assertEquals("", p.token) + } + + @Test fun parses_token_payload() { + val p = parsePairingPayload("""{"v":1,"url":"http://127.0.0.1:9119","token":"tok"}""")!! + assertEquals("tok", p.token) + assertEquals("", p.username) + } + + @Test fun ignores_unknown_keys() { + val p = parsePairingPayload("""{"v":1,"url":"http://h","extra":"x"}""")!! + assertEquals("http://h", p.url) + } + + @Test fun rejects_malformed_json() { + assertNull(parsePairingPayload("not json")) + assertNull(parsePairingPayload("{bad")) + assertNull(parsePairingPayload("\"https://h\"")) // a bare string, not an object + } + + @Test fun rejects_wrong_or_missing_version() { + assertNull(parsePairingPayload("""{"v":2,"url":"http://h"}""")) + assertNull(parsePairingPayload("""{"url":"http://h"}""")) // v defaults to 0 + } + + @Test fun rejects_blank_url() { + assertNull(parsePairingPayload("""{"v":1,"url":""}""")) + assertNull(parsePairingPayload("""{"v":1}""")) + } + + @Test fun rejects_non_url() { + assertNull(parsePairingPayload("""{"v":1,"url":"hello"}""")) + assertNull(parsePairingPayload("""{"v":1,"url":"ftp://h"}""")) + } +} diff --git a/app/src/test/java/com/hermes/client/ui/setup/SetupViewModelTest.kt b/app/src/test/java/com/hermes/client/ui/setup/SetupViewModelTest.kt new file mode 100644 index 0000000..42bae93 --- /dev/null +++ b/app/src/test/java/com/hermes/client/ui/setup/SetupViewModelTest.kt @@ -0,0 +1,59 @@ +package com.hermes.client.ui.setup + +import com.hermes.client.data.auth.CredentialStore +import com.hermes.client.data.network.GatedAuth +import com.hermes.client.data.network.HermesRestApi +import io.mockk.every +import io.mockk.mockk +import kotlinx.coroutines.Dispatchers +import kotlinx.coroutines.ExperimentalCoroutinesApi +import kotlinx.coroutines.test.StandardTestDispatcher +import kotlinx.coroutines.test.resetMain +import kotlinx.coroutines.test.setMain +import org.junit.After +import org.junit.Assert.assertEquals +import org.junit.Assert.assertNull +import org.junit.Before +import org.junit.Test + +@OptIn(ExperimentalCoroutinesApi::class) +class SetupViewModelTest { + private val store = mockk(relaxed = true) + private val rest = mockk(relaxed = true) + private val gatedAuth = mockk(relaxed = true) + + @Before fun setUp() { + Dispatchers.setMain(StandardTestDispatcher()) + every { store.load() } returns null + } + + @After fun tearDown() = Dispatchers.resetMain() + + private fun vm() = SetupViewModel(store, rest, gatedAuth) + + @Test fun applyPairing_populates_fields_from_valid_payload() { + val vm = vm() + vm.applyPairing("""{"v":1,"url":"https://h.ts.net","username":"a","password":"p"}""") + val s = vm.state.value + assertEquals("https://h.ts.net", s.url) + assertEquals("a", s.username) + assertEquals("p", s.password) + assertNull(s.scanError) + } + + @Test fun applyPairing_sets_scanError_and_leaves_fields_blank_on_garbage() { + val vm = vm() + vm.applyPairing("not a hermes code") + val s = vm.state.value + assertEquals("Not a Hermes pairing code", s.scanError) + assertEquals("", s.url) + assertEquals("", s.password) + } + + @Test fun clearScanError_clears_it() { + val vm = vm() + vm.applyPairing("garbage") + vm.clearScanError() + assertNull(vm.state.value.scanError) + } +} diff --git a/docs/superpowers/plans/2026-07-17-qr-scan-pairing.md b/docs/superpowers/plans/2026-07-17-qr-scan-pairing.md new file mode 100644 index 0000000..cbf498e --- /dev/null +++ b/docs/superpowers/plans/2026-07-17-qr-scan-pairing.md @@ -0,0 +1,398 @@ +# QR-Scan Pairing in Setup — Implementation Plan + +> **For agentic workers:** REQUIRED SUB-SKILL: Use superpowers:subagent-driven-development (recommended) or superpowers:executing-plans to implement this plan task-by-task. Steps use checkbox (`- [ ]`) syntax for tracking. + +**Goal:** Add a "Scan QR" path to the setup screen: scan a QR encoding the gateway URL + credentials, prefill the existing fields, auto-run the existing Test, then Save. Fully client-only. + +**Architecture:** A pure JSON payload parser (`parsePairingPayload`), a `SetupViewModel.applyPairing` entry point that reuses the existing `test()`/`save()`, and a ZXing scan launcher on `SetupScreen`. Parser + ViewModel logic are pure/unit-tested; the camera/ZXing launch is Android glue verified on-device. + +**Tech Stack:** Kotlin, Jetpack Compose, Material3, Hilt, kotlinx.serialization (existing), ZXing embedded (new). + +**Spec:** `docs/superpowers/specs/2026-07-17-qr-scan-pairing-design.md` + +## Global Constraints + +- Client-only: no gateway changes. Scanned payload maps onto `GatewayConfig(baseUrl, token, username, password)` via the existing `store.save`. +- Primary payload is **URL + username + password** (the loopback token is useless off-device); `token` optional. +- Scanner = **ZXing embedded** (`com.journeyapps:zxing-android-embedded`) — offline, GMS-free. Needs the `CAMERA` permission (ZXing's `CaptureActivity` handles the runtime request itself; a denied permission yields a null scan result → manual entry still works). +- Setup is pre-connection → **no tenant accent**; use default Material styling for the Scan button. +- Tests: pure JUnit (`PairingPayloadTest`, `SetupViewModelTest`). No Compose/instrumentation tests. +- No AI/assistant attribution in commits or files. +- Build env: `export JAVA_HOME="/Applications/Android Studio.app/Contents/jbr/Contents/Home"`. +- Branch: `feature/qr-scan-pairing` (off `dev`; spec committed). All commits land here. + +--- + +### Task 1: Pairing payload parser (pure) + +**Files:** +- Create: `app/src/main/java/com/hermes/client/ui/setup/PairingPayload.kt` +- Test: `app/src/test/java/com/hermes/client/ui/setup/PairingPayloadTest.kt` + +**Interfaces:** +- Produces: `@Serializable data class PairingPayload(v, url, token, username, password)` and `fun parsePairingPayload(raw: String): PairingPayload?` (null unless valid v1 with non-blank url; never throws). + +- [ ] **Step 1: Write the failing test** + +`app/src/test/java/com/hermes/client/ui/setup/PairingPayloadTest.kt`: +```kotlin +package com.hermes.client.ui.setup + +import org.junit.Assert.assertEquals +import org.junit.Assert.assertNull +import org.junit.Test + +class PairingPayloadTest { + @Test fun parses_gated_payload() { + val p = parsePairingPayload("""{"v":1,"url":"https://h.ts.net","username":"a","password":"p"}""")!! + assertEquals("https://h.ts.net", p.url) + assertEquals("a", p.username) + assertEquals("p", p.password) + assertEquals("", p.token) + } + + @Test fun parses_token_payload() { + val p = parsePairingPayload("""{"v":1,"url":"http://127.0.0.1:9119","token":"tok"}""")!! + assertEquals("tok", p.token) + assertEquals("", p.username) + } + + @Test fun ignores_unknown_keys() { + val p = parsePairingPayload("""{"v":1,"url":"http://h","extra":"x"}""")!! + assertEquals("http://h", p.url) + } + + @Test fun rejects_malformed_json() { + assertNull(parsePairingPayload("not json")) + assertNull(parsePairingPayload("{bad")) + assertNull(parsePairingPayload("\"https://h\"")) // a bare string, not an object + } + + @Test fun rejects_wrong_or_missing_version() { + assertNull(parsePairingPayload("""{"v":2,"url":"http://h"}""")) + assertNull(parsePairingPayload("""{"url":"http://h"}""")) // v defaults to 0 + } + + @Test fun rejects_blank_url() { + assertNull(parsePairingPayload("""{"v":1,"url":""}""")) + assertNull(parsePairingPayload("""{"v":1}""")) + } +} +``` + +- [ ] **Step 2: Run the test to verify it fails** + +Run: `./gradlew :app:testDebugUnitTest --tests "com.hermes.client.ui.setup.PairingPayloadTest"` +Expected: FAIL — `PairingPayload`/`parsePairingPayload` unresolved. + +- [ ] **Step 3: Write the implementation** + +`app/src/main/java/com/hermes/client/ui/setup/PairingPayload.kt`: +```kotlin +package com.hermes.client.ui.setup + +import kotlinx.serialization.Serializable +import kotlinx.serialization.json.Json + +/** Credentials carried by a Hermes pairing QR. Primary payload is url + username + password. */ +@Serializable +data class PairingPayload( + val v: Int = 0, + val url: String = "", + val token: String = "", + val username: String = "", + val password: String = "", +) + +private val pairingJson = Json { ignoreUnknownKeys = true } + +/** + * Parse a scanned QR string. Returns null (never throws) unless it is a valid v1 Hermes pairing + * object with a non-blank url — so a random/non-Hermes QR is rejected cleanly. + */ +fun parsePairingPayload(raw: String): PairingPayload? = + runCatching { pairingJson.decodeFromString(raw) } + .getOrNull() + ?.takeIf { it.v == 1 && it.url.isNotBlank() } +``` + +- [ ] **Step 4: Run the test to verify it passes** + +Run: `./gradlew :app:testDebugUnitTest --tests "com.hermes.client.ui.setup.PairingPayloadTest"` +Expected: PASS (6 tests). + +- [ ] **Step 5: Commit** + +```bash +git add app/src/main/java/com/hermes/client/ui/setup/PairingPayload.kt \ + app/src/test/java/com/hermes/client/ui/setup/PairingPayloadTest.kt +git commit -m "feat: add pairing-QR payload parser" +``` + +--- + +### Task 2: SetupViewModel.applyPairing + +**Files:** +- Modify: `app/src/main/java/com/hermes/client/ui/setup/SetupViewModel.kt` +- Test: `app/src/test/java/com/hermes/client/ui/setup/SetupViewModelTest.kt` + +**Interfaces:** +- Consumes: `parsePairingPayload` (Task 1); existing `test()`, `SetupUiState`, injected `store`/`rest`/`gatedAuth`. +- Produces: `SetupUiState.scanError: String?`; `fun applyPairing(raw: String)`; `fun clearScanError()`. + +- [ ] **Step 1: Write the failing test** + +`app/src/test/java/com/hermes/client/ui/setup/SetupViewModelTest.kt`: +```kotlin +package com.hermes.client.ui.setup + +import com.hermes.client.data.auth.CredentialStore +import com.hermes.client.data.network.GatedAuth +import com.hermes.client.data.network.HermesRestApi +import io.mockk.every +import io.mockk.mockk +import kotlinx.coroutines.Dispatchers +import kotlinx.coroutines.ExperimentalCoroutinesApi +import kotlinx.coroutines.test.StandardTestDispatcher +import kotlinx.coroutines.test.resetMain +import kotlinx.coroutines.test.setMain +import org.junit.After +import org.junit.Assert.assertEquals +import org.junit.Assert.assertNull +import org.junit.Before +import org.junit.Test + +@OptIn(ExperimentalCoroutinesApi::class) +class SetupViewModelTest { + private val store = mockk(relaxed = true) + private val rest = mockk(relaxed = true) + private val gatedAuth = mockk(relaxed = true) + + @Before fun setUp() { + Dispatchers.setMain(StandardTestDispatcher()) + every { store.load() } returns null + } + + @After fun tearDown() = Dispatchers.resetMain() + + private fun vm() = SetupViewModel(store, rest, gatedAuth) + + @Test fun applyPairing_populates_fields_from_valid_payload() { + val vm = vm() + vm.applyPairing("""{"v":1,"url":"https://h.ts.net","username":"a","password":"p"}""") + val s = vm.state.value + assertEquals("https://h.ts.net", s.url) + assertEquals("a", s.username) + assertEquals("p", s.password) + assertNull(s.scanError) + } + + @Test fun applyPairing_sets_scanError_and_leaves_fields_blank_on_garbage() { + val vm = vm() + vm.applyPairing("not a hermes code") + val s = vm.state.value + assertEquals("Not a Hermes pairing code", s.scanError) + assertEquals("", s.url) + assertEquals("", s.password) + } + + @Test fun clearScanError_clears_it() { + val vm = vm() + vm.applyPairing("garbage") + vm.clearScanError() + assertNull(vm.state.value.scanError) + } +} +``` +(Assertions are on the synchronous state changes; the `test()` probe `applyPairing` triggers runs in `viewModelScope` and is not asserted here — its behavior is unchanged and already exercised by the existing Test button.) + +- [ ] **Step 2: Run the test to verify it fails** + +Run: `./gradlew :app:testDebugUnitTest --tests "com.hermes.client.ui.setup.SetupViewModelTest"` +Expected: FAIL — `scanError`/`applyPairing`/`clearScanError` unresolved. + +- [ ] **Step 3: Modify the ViewModel** + +In `SetupViewModel.kt`, add `scanError` to the state: +```kotlin +data class SetupUiState( + val url: String = "", + val token: String = "", + val username: String = "", + val password: String = "", + val testResult: String? = null, + val saved: Boolean = false, + val scanError: String? = null, +) +``` +And add these two functions to the `SetupViewModel` class (e.g. after `save()`): +```kotlin + /** Apply a scanned pairing QR: prefill the fields and auto-run the existing probe. */ + fun applyPairing(raw: String) { + val p = parsePairingPayload(raw) + if (p == null) { + _state.value = _state.value.copy(scanError = "Not a Hermes pairing code") + return + } + _state.value = _state.value.copy( + url = p.url, token = p.token, username = p.username, password = p.password, + scanError = null, testResult = null, + ) + test() + } + + fun clearScanError() { + if (_state.value.scanError != null) _state.value = _state.value.copy(scanError = null) + } +``` + +- [ ] **Step 4: Run the test to verify it passes** + +Run: `./gradlew :app:testDebugUnitTest --tests "com.hermes.client.ui.setup.SetupViewModelTest"` +Expected: PASS (3 tests). + +- [ ] **Step 5: Commit** + +```bash +git add app/src/main/java/com/hermes/client/ui/setup/SetupViewModel.kt \ + app/src/test/java/com/hermes/client/ui/setup/SetupViewModelTest.kt +git commit -m "feat: SetupViewModel.applyPairing populates fields from a scanned QR" +``` + +--- + +### Task 3: ZXing dependency + CAMERA permission + Scan-QR button + +**Files:** +- Modify: `gradle/libs.versions.toml` +- Modify: `app/build.gradle.kts` +- Modify: `app/src/main/AndroidManifest.xml` +- Modify: `app/src/main/java/com/hermes/client/ui/setup/SetupScreen.kt` +- Test: none new (Android glue). Verified by compile + full suite + assembleBeta + Task 4. + +**Interfaces:** +- Consumes: `SetupViewModel.applyPairing` + `SetupUiState.scanError` (Task 2). + +- [ ] **Step 1: Add the ZXing version + library alias** + +In `gradle/libs.versions.toml`, add under `[versions]` (e.g. after the existing entries): +```toml +zxingEmbedded = "4.3.0" +``` +and under `[libraries]`: +```toml +zxing-embedded = { module = "com.journeyapps:zxing-android-embedded", version.ref = "zxingEmbedded" } +``` + +- [ ] **Step 2: Add the dependency** + +In `app/build.gradle.kts`, in the `dependencies { }` block (next to the other `implementation(libs.*)` lines, e.g. after `implementation(libs.markdown.m3)`): +```kotlin + implementation(libs.zxing.embedded) +``` + +- [ ] **Step 3: Add the CAMERA permission** + +In `app/src/main/AndroidManifest.xml`, add after the `ACCESS_NETWORK_STATE` line: +```xml + +``` + +- [ ] **Step 4: Add the Scan-QR button + launcher to SetupScreen** + +In `app/src/main/java/com/hermes/client/ui/setup/SetupScreen.kt`, add imports: +```kotlin +import androidx.activity.compose.rememberLauncherForActivityResult +import com.journeyapps.barcodescanner.ScanContract +import com.journeyapps.barcodescanner.ScanOptions +``` +Inside `SetupScreen`, after `val state by vm.state.collectAsStateWithLifecycle()`, register the scan launcher: +```kotlin + val scanLauncher = rememberLauncherForActivityResult(ScanContract()) { result -> + // Null contents = the user cancelled or denied the camera; manual entry stays usable. + result.contents?.let { vm.applyPairing(it) } + } +``` +Add the button + error text directly under the `Text("Connect to Hermes", …)` title (making scan the primary path), before the URL field: +```kotlin + OutlinedButton( + onClick = { + scanLauncher.launch( + ScanOptions().apply { + setDesiredBarcodeFormats(ScanOptions.QR_CODE) + setPrompt("Scan the Hermes pairing QR") + setBeepEnabled(false) + setOrientationLocked(false) + }, + ) + }, + modifier = Modifier.fillMaxWidth(), + ) { Text("Scan QR") } + state.scanError?.let { Text(it, color = MaterialTheme.colorScheme.error) } +``` +(The existing URL/username/password/token fields, Test/Save row, and `testResult` remain unchanged below.) + +- [ ] **Step 5: Compile** + +Run: `JAVA_HOME="/Applications/Android Studio.app/Contents/jbr/Contents/Home" ./gradlew :app:compileDebugKotlin` +Expected: BUILD SUCCESSFUL. (Resolves the ZXing dependency + `ScanContract`/`ScanOptions`.) + +- [ ] **Step 6: Run the full unit-test suite** + +Run: `JAVA_HOME="/Applications/Android Studio.app/Contents/jbr/Contents/Home" ./gradlew :app:testDebugUnitTest` +Expected: BUILD SUCCESSFUL, 0 failures (includes Tasks 1–2 suites). + +- [ ] **Step 7: Assemble the beta variant** + +Run: `JAVA_HOME="/Applications/Android Studio.app/Contents/jbr/Contents/Home" ./gradlew :app:assembleBeta` +Expected: BUILD SUCCESSFUL. + +- [ ] **Step 8: Commit** + +```bash +git add gradle/libs.versions.toml app/build.gradle.kts app/src/main/AndroidManifest.xml \ + app/src/main/java/com/hermes/client/ui/setup/SetupScreen.kt +git commit -m "feat: add Scan-QR pairing button to setup (ZXing + CAMERA)" +``` + +--- + +### Task 4: On-device verification + +**Files:** none (manual, on the emulator or a connected device). + +No automated Compose/camera test (per Global Constraints); this manual pass is the acceptance gate for the scanner glue. + +- [ ] **Step 1: Prepare a test QR** + +Encode this JSON as a QR with any offline QR tool (replace with a reachable gateway + real credentials): +```json +{"v":1,"url":"http://10.0.2.2:9119","username":"andrew","password":""} +``` +(For an emulator, `10.0.2.2` reaches the host; or use a `token` payload against a loopback-reachable gateway.) + +- [ ] **Step 2: Install the beta build** + +Run: `JAVA_HOME="/Applications/Android Studio.app/Contents/jbr/Contents/Home" ./gradlew :app:installBeta` +Expected: `Installed on 1 device`. + +- [ ] **Step 3: Verify the happy path** + +Launch the app to the setup screen → tap **Scan QR** → grant camera → scan the QR. Confirm: the URL/username/password fields **prefill**, the auto-Test shows "Connected", and **Save & continue** connects into the app. + +- [ ] **Step 4: Verify the error + denial paths** + +Scan a random non-Hermes QR → confirm **"Not a Hermes pairing code"** appears and the fields are untouched. Re-open Scan QR and **deny** the camera permission (or cancel) → confirm no crash and the manual URL/username/password entry still works. + +- [ ] **Step 5: Record the outcome** + +No commit. Note pass/fail per step in the PR description. + +--- + +## Notes for the executor + +- ZXing embedded's `CaptureActivity` is declared in the library manifest (merged automatically) and requests the CAMERA permission itself — no manual activity declaration and no separate Compose permission launcher are needed. A cancelled/denied scan returns `result.contents == null`, handled as a no-op. +- Do NOT add dashboard QR generation, mDNS discovery, Tailscale discovery, or a `hermes://` scheme — all explicit anti-scope. +- The scanned password persists only through the existing `CredentialStore`/`EncryptedCredentialStore`; do not log the payload. diff --git a/docs/superpowers/specs/2026-07-17-qr-scan-pairing-design.md b/docs/superpowers/specs/2026-07-17-qr-scan-pairing-design.md new file mode 100644 index 0000000..576101c --- /dev/null +++ b/docs/superpowers/specs/2026-07-17-qr-scan-pairing-design.md @@ -0,0 +1,122 @@ +# QR-Scan Pairing in Setup — Design + +**Wave:** Quick-wins (from `docs/ideas/2026-07-16-competitive-refresh.md`, self-hosted onboarding). **Branch:** `feature/qr-scan-pairing` (off `dev`). + +**Goal:** Add a "Scan QR" path to the setup screen so a user pairs by scanning a QR that encodes the gateway URL + credentials, instead of hand-typing a long `ts.net` URL + password. Fully client-only. + +**Positioning:** The self-hosted onboarding today is a manual paste of `http://100.x.x.x:9119` + username/password (or a loopback token). Scanning kills the error-prone paste. The QR is produced out-of-band for now (a dashboard QR *generator* is a separate fork/SPA follow-up, explicitly out of scope here). + +**Constraints:** Kotlin / Compose / Material3 / Hilt, per-tenant accent. **Client-only** — no gateway changes. Standing repo constraints (no AI attribution; gitleaks before every push; tenant isolation; `main` only via approved PR). + +**Key auth fact (from the gateway audit):** the loopback session token is **useless off-device** — any non-loopback bind forces the auth gate on, so a phone authenticates with **username + password** (`POST /auth/password-login`, already implemented via `GatedAuth.probeLogin`). A pairing QR's primary payload is therefore **URL + username + password**; `token` is optional (loopback/adb cases only). + +--- + +## Scope (locked) + +- **In:** an in-app QR *scanner* on the setup screen; a versioned JSON payload; prefill + auto-Test + explicit Save; CAMERA permission; a new scanner dependency. +- **Out:** dashboard QR *generation* (fork/SPA follow-up); mDNS/LAN auto-discovery (needs gateway `zeroconf` + non-loopback bind); Tailscale auto-discovery; a `hermes://` deep-link scheme (separate roadmap item). + +--- + +## Architecture + +Three units: a pure payload parser, a ViewModel entry point, and the setup-screen UI (scanner + permission glue). The parser and the ViewModel apply-logic are pure/unit-tested; the camera/ZXing launch is Android glue verified on-device. + +### 1. Scanner library — ZXing embedded + +`com.journeyapps:zxing-android-embedded` (pulls `com.google.zxing:core`). Chosen because it is **offline and Google-Play-Services-free** (the app is currently GMS-free; a self-hosted/privacy audience may run no-GMS ROMs), small, and integrates via a one-shot `ScanContract`/`ScanOptions` ActivityResult. Requires the `CAMERA` permission. + +*Alternatives considered:* CameraX + ML Kit barcode (in-Compose scanner, but more code + a larger bundled model); GMS `play-services-code-scanner` (no CAMERA permission, but requires Play Services — rejected to keep no-GMS support). + +### 2. Payload — versioned JSON — `ui/setup/PairingPayload.kt` (new) + +```kotlin +@Serializable +data class PairingPayload( + val v: Int = 0, + val url: String = "", + val token: String = "", + val username: String = "", + val password: String = "", +) + +/** Parse a scanned QR string; null if it isn't a valid v1 Hermes pairing code. */ +fun parsePairingPayload(raw: String): PairingPayload? +``` +Rules: parse `raw` as JSON with a lenient `Json { ignoreUnknownKeys = true }`; return null when it isn't an object, `v != 1`, or `url` is blank; otherwise the payload. Never throws — malformed input → null. Example valid payload: +```json +{ "v": 1, "url": "https://andrews-macbook.tailc63a9b.ts.net", "username": "andrew", "password": "…" } +``` + +### 3. ViewModel — `ui/setup/SetupViewModel.kt` (modify) + +Add a `scanError: String?` field to `SetupUiState` and an entry point: +```kotlin +fun applyPairing(raw: String) { + val p = parsePairingPayload(raw) + if (p == null) { + _state.value = _state.value.copy(scanError = "Not a Hermes pairing code") + return + } + _state.value = _state.value.copy( + url = p.url, token = p.token, username = p.username, password = p.password, + scanError = null, testResult = null, + ) + test() // reuse the existing probe; sets testResult to "Connected"/"Unreachable" +} +``` +`test()` and `save()` are unchanged. The scan populates the same fields the user could type, so the existing verify/persist path is reused end to end. (A `clearScanError()` may be added to dismiss the error on edit.) + +### 4. UI — `ui/setup/SetupScreen.kt` (modify) + +- A **"Scan QR"** button (per-tenant accent styling) above/beside the fields. +- A CAMERA permission launcher (`rememberLauncherForActivityResult(RequestPermission)`); on grant → launch the scanner; on denial → keep manual entry + a short inline rationale (no hard block). +- A ZXing scan launcher (`rememberLauncherForActivityResult(ScanContract())` with `ScanOptions().setDesiredBarcodeFormats(QR_CODE).setBeepEnabled(false)`); on a non-null result → `vm.applyPairing(result.contents)`. +- Surface `scanError` inline near the button; the prefilled fields + `testResult` show the outcome. The user reviews, then taps the existing **Save & continue**. + +### 5. Files + +| Action | Path | Responsibility | +|--------|------|----------------| +| Modify | `gradle/libs.versions.toml` | ZXing embedded version + library alias | +| Modify | `app/build.gradle.kts` | `implementation(libs.zxing.embedded)` | +| Modify | `app/src/main/AndroidManifest.xml` | `` | +| Create | `app/src/main/java/com/hermes/client/ui/setup/PairingPayload.kt` | payload model + pure `parsePairingPayload` | +| Modify | `app/src/main/java/com/hermes/client/ui/setup/SetupViewModel.kt` | `scanError` state + `applyPairing` | +| Modify | `app/src/main/java/com/hermes/client/ui/setup/SetupScreen.kt` | Scan-QR button, CAMERA permission, ZXing launcher | +| Create | `app/src/test/java/com/hermes/client/ui/setup/PairingPayloadTest.kt` | pure parser tests | +| Create | `app/src/test/java/com/hermes/client/ui/setup/SetupViewModelTest.kt` | `applyPairing` populate + scanError | + +## Data flow + +``` +[Scan QR] → CAMERA permission → ZXing ScanContract → result.contents (raw string) + → vm.applyPairing(raw) → parsePairingPayload(raw) + null → scanError = "Not a Hermes pairing code" + ok → populate url/token/username/password → test() → testResult + → user reviews prefilled fields → [Save & continue] → store.save(GatewayConfig(...)) +``` + +## Error handling + +- Malformed / non-Hermes / wrong-version / no-url QR → `scanError` message; fields untouched. +- Scan cancelled (`result.contents == null`) → no-op. +- CAMERA permission denied → manual entry remains fully usable; inline rationale; no crash. +- Unreachable after auto-Test → the existing `testResult = "Unreachable"` (user can fix the fields or re-scan). + +## Testing + +**Pure `PairingPayloadTest`:** valid gated payload (url+username+password); valid token payload (url+token); malformed JSON → null; missing/`v != 1` → null; blank `url` → null; unknown extra keys ignored. + +**`SetupViewModelTest`:** `applyPairing(validJson)` populates url/username/password and clears `scanError`, and triggers the probe (mock `rest.statusFor`/`gatedAuth.probeLogin` → asserts `testResult`); `applyPairing(garbage)` sets `scanError` and leaves fields blank. + +The CAMERA permission flow and the ZXing capture Activity are Android — no unit tests (repo style); verified on-device. + +## On-device verification + +Generate a QR encoding a valid `{v:1,url,username,password}` payload (any offline QR tool), open setup, tap **Scan QR**, grant camera, scan → confirm the fields prefill, the auto-Test runs, and **Save & continue** connects. Also: scan a random non-Hermes QR → "Not a Hermes pairing code"; deny the camera permission → manual entry still works. + +## Build & gates + +`JAVA_HOME="/Applications/Android Studio.app/Contents/jbr/Contents/Home"`: `:app:compileDebugKotlin`, `:app:testDebugUnitTest`, `:app:assembleBeta`. gitleaks before every push; PR into `dev`. diff --git a/gradle/libs.versions.toml b/gradle/libs.versions.toml index 0c1199d..1de429a 100644 --- a/gradle/libs.versions.toml +++ b/gradle/libs.versions.toml @@ -29,6 +29,7 @@ junit = "4.13.2" androidxTestCore = "1.7.0" androidxTestJunit = "1.3.0" androidxTestRunner = "1.7.0" +zxingEmbedded = "4.3.0" [libraries] core-ktx = { module = "androidx.core:core-ktx", version.ref = "coreKtx" } @@ -66,6 +67,7 @@ junit = { module = "junit:junit", version.ref = "junit" } androidx-test-core = { module = "androidx.test:core-ktx", version.ref = "androidxTestCore" } androidx-test-junit = { module = "androidx.test.ext:junit", version.ref = "androidxTestJunit" } androidx-test-runner = { module = "androidx.test:runner", version.ref = "androidxTestRunner" } +zxing-embedded = { module = "com.journeyapps:zxing-android-embedded", version.ref = "zxingEmbedded" } [plugins] android-application = { id = "com.android.application", version.ref = "agp" } From 4d38fcd18721645c257014cd58f90af487228d70 Mon Sep 17 00:00:00 2001 From: Andrew Debnar <606931+adebnar@users.noreply.github.com> Date: Fri, 17 Jul 2026 16:24:22 +0000 Subject: [PATCH 07/16] feat: read assistant responses aloud (TTS) (#101) * docs: spec + plan for TTS read-aloud * feat: add speechText markdown stripper for TTS * feat: TextToSpeechController + ChatViewModel read-aloud * feat: Read aloud/Stop item on assistant bubbles * fix: hide Read aloud when the message has no speakable text --- .../client/data/tts/TextToSpeechController.kt | 52 +++ .../java/com/hermes/client/di/AppModule.kt | 7 + .../hermes/client/ui/chat/ChatComponents.kt | 39 +- .../com/hermes/client/ui/chat/ChatScreen.kt | 5 + .../hermes/client/ui/chat/ChatViewModel.kt | 10 + .../com/hermes/client/ui/chat/SpeechText.kt | 27 ++ .../client/ui/chat/ChatViewModelTest.kt | 16 +- .../hermes/client/ui/chat/SpeechTextTest.kt | 32 ++ .../plans/2026-07-17-tts-read-aloud.md | 352 ++++++++++++++++++ .../specs/2026-07-17-tts-read-aloud-design.md | 76 ++++ 10 files changed, 612 insertions(+), 4 deletions(-) create mode 100644 app/src/main/java/com/hermes/client/data/tts/TextToSpeechController.kt create mode 100644 app/src/main/java/com/hermes/client/ui/chat/SpeechText.kt create mode 100644 app/src/test/java/com/hermes/client/ui/chat/SpeechTextTest.kt create mode 100644 docs/superpowers/plans/2026-07-17-tts-read-aloud.md create mode 100644 docs/superpowers/specs/2026-07-17-tts-read-aloud-design.md diff --git a/app/src/main/java/com/hermes/client/data/tts/TextToSpeechController.kt b/app/src/main/java/com/hermes/client/data/tts/TextToSpeechController.kt new file mode 100644 index 0000000..ac6d4c9 --- /dev/null +++ b/app/src/main/java/com/hermes/client/data/tts/TextToSpeechController.kt @@ -0,0 +1,52 @@ +package com.hermes.client.data.tts + +import android.content.Context +import android.speech.tts.TextToSpeech +import android.speech.tts.UtteranceProgressListener +import kotlinx.coroutines.flow.MutableStateFlow +import kotlinx.coroutines.flow.StateFlow +import kotlinx.coroutines.flow.asStateFlow + +/** Speaks text aloud; [speaking] is true while an utterance is playing. */ +interface TextToSpeechController { + val speaking: StateFlow + fun speak(text: String) + fun stop() +} + +private const val UTTERANCE_ID = "hermes-read-aloud" + +/** Android [TextToSpeech]-backed controller. Init is async; a speak before ready is queued. */ +class AndroidTtsManager(context: Context) : TextToSpeechController { + private val _speaking = MutableStateFlow(false) + override val speaking: StateFlow = _speaking.asStateFlow() + + private var ready = false + private var pending: String? = null + + private val engine: TextToSpeech = TextToSpeech(context.applicationContext) { status -> + ready = status == TextToSpeech.SUCCESS + if (ready) { + engine.setOnUtteranceProgressListener(object : UtteranceProgressListener() { + override fun onStart(utteranceId: String?) { _speaking.value = true } + override fun onDone(utteranceId: String?) { _speaking.value = false } + @Deprecated("legacy") override fun onError(utteranceId: String?) { _speaking.value = false } + override fun onError(utteranceId: String?, errorCode: Int) { _speaking.value = false } + override fun onStop(utteranceId: String?, interrupted: Boolean) { _speaking.value = false } + }) + pending?.let { speak(it); pending = null } + } + } + + override fun speak(text: String) { + if (text.isBlank()) return + if (!ready) { pending = text; return } + engine.speak(text, TextToSpeech.QUEUE_FLUSH, null, UTTERANCE_ID) + } + + override fun stop() { + pending = null + engine.stop() + _speaking.value = false + } +} diff --git a/app/src/main/java/com/hermes/client/di/AppModule.kt b/app/src/main/java/com/hermes/client/di/AppModule.kt index 181cf00..d1391d2 100644 --- a/app/src/main/java/com/hermes/client/di/AppModule.kt +++ b/app/src/main/java/com/hermes/client/di/AppModule.kt @@ -212,4 +212,11 @@ object AppModule { @Singleton fun provideEnvRepository(rest: HermesRestApi): com.hermes.client.data.repository.EnvRepository = com.hermes.client.data.repository.EnvRepository(rest) + + @Provides + @Singleton + fun provideTextToSpeechController( + @ApplicationContext context: Context, + ): com.hermes.client.data.tts.TextToSpeechController = + com.hermes.client.data.tts.AndroidTtsManager(context) } diff --git a/app/src/main/java/com/hermes/client/ui/chat/ChatComponents.kt b/app/src/main/java/com/hermes/client/ui/chat/ChatComponents.kt index 3f02e93..b64f9c9 100644 --- a/app/src/main/java/com/hermes/client/ui/chat/ChatComponents.kt +++ b/app/src/main/java/com/hermes/client/ui/chat/ChatComponents.kt @@ -81,6 +81,9 @@ fun ChatMessageList( isGenerating: Boolean = false, onEditResend: (String) -> Unit = {}, onRegenerate: () -> Unit = {}, + isSpeaking: Boolean = false, + onReadAloud: (String) -> Unit = {}, + onStopReading: () -> Unit = {}, highlightIndex: Int? = null, ) { val lastIndex = state.messages.lastIndex @@ -165,7 +168,16 @@ fun ChatMessageList( key = { index, msg -> "$index:${msg.id}" }, ) { index, msg -> val canRegenerate = msg.id == lastAssistantId && !isGenerating - MessageBubble(msg, canRegenerate, onEditResend, onRegenerate, highlighted = index == highlightIndex) + MessageBubble( + msg, + canRegenerate, + onEditResend, + onRegenerate, + isSpeaking, + onReadAloud, + onStopReading, + highlighted = index == highlightIndex, + ) } } } @@ -180,11 +192,14 @@ private fun MessageBubble( canRegenerate: Boolean, onEditResend: (String) -> Unit, onRegenerate: () -> Unit, + isSpeaking: Boolean, + onReadAloud: (String) -> Unit, + onStopReading: () -> Unit, highlighted: Boolean = false, ) { when (msg.role) { Role.USER -> UserBubble(msg, onEditResend, highlighted = highlighted) - else -> AssistantTurn(msg, canRegenerate, onRegenerate, highlighted = highlighted) + else -> AssistantTurn(msg, canRegenerate, onRegenerate, isSpeaking, onReadAloud, onStopReading, highlighted = highlighted) } } @@ -228,10 +243,19 @@ private fun UserBubble(msg: ChatMessage, onEditResend: (String) -> Unit, highlig @OptIn(ExperimentalFoundationApi::class) @Composable -private fun AssistantTurn(msg: ChatMessage, canRegenerate: Boolean, onRegenerate: () -> Unit, highlighted: Boolean = false) { +private fun AssistantTurn( + msg: ChatMessage, + canRegenerate: Boolean, + onRegenerate: () -> Unit, + isSpeaking: Boolean, + onReadAloud: (String) -> Unit, + onStopReading: () -> Unit, + highlighted: Boolean = false, +) { val clipboard = LocalClipboardManager.current val context = LocalContext.current var menuOpen by remember { mutableStateOf(false) } + val speakable = remember(msg.text) { speechText(msg.text).isNotBlank() } val accent = LocalProfileAccent.current.accent val hlShape = RoundedCornerShape(12.dp) Box { @@ -285,6 +309,15 @@ private fun AssistantTurn(msg: ChatMessage, canRegenerate: Boolean, onRegenerate onClick = { onRegenerate(); menuOpen = false }, ) } + if (speakable && !msg.isError) { + DropdownMenuItem( + text = { Text(if (isSpeaking) "Stop" else "Read aloud") }, + onClick = { + if (isSpeaking) onStopReading() else onReadAloud(msg.text) + menuOpen = false + }, + ) + } } } } diff --git a/app/src/main/java/com/hermes/client/ui/chat/ChatScreen.kt b/app/src/main/java/com/hermes/client/ui/chat/ChatScreen.kt index b2b0b9c..2de3f92 100644 --- a/app/src/main/java/com/hermes/client/ui/chat/ChatScreen.kt +++ b/app/src/main/java/com/hermes/client/ui/chat/ChatScreen.kt @@ -102,6 +102,8 @@ fun ChatScreen( val activeProfile by vm.activeProfile.collectAsStateWithLifecycle() val commands by vm.commands.collectAsStateWithLifecycle() val pathItems by vm.pathItems.collectAsStateWithLifecycle() + val speaking by vm.speaking.collectAsStateWithLifecycle() + androidx.compose.runtime.DisposableEffect(Unit) { onDispose { vm.stopReading() } } var draft by remember { mutableStateOf("") } var searchOpen by rememberSaveable { mutableStateOf(false) } var query by rememberSaveable { mutableStateOf("") } @@ -480,6 +482,9 @@ fun ChatScreen( isGenerating = state.isGenerating, onEditResend = { text -> draft = text; focusRequester.requestFocus() }, onRegenerate = { vm.regenerate() }, + isSpeaking = speaking, + onReadAloud = { vm.readAloud(it) }, + onStopReading = { vm.stopReading() }, modifier = Modifier.weight(1f), ) } diff --git a/app/src/main/java/com/hermes/client/ui/chat/ChatViewModel.kt b/app/src/main/java/com/hermes/client/ui/chat/ChatViewModel.kt index 4d05add..0e6b282 100644 --- a/app/src/main/java/com/hermes/client/ui/chat/ChatViewModel.kt +++ b/app/src/main/java/com/hermes/client/ui/chat/ChatViewModel.kt @@ -33,6 +33,7 @@ class ChatViewModel @Inject constructor( private val profileManager: ProfileManager, private val favoritesStore: com.hermes.client.data.repository.ModelFavoritesStore, private val pendingShareStore: com.hermes.client.share.PendingShareStore, + private val tts: com.hermes.client.data.tts.TextToSpeechController, ) : ViewModel() { private val _state = MutableStateFlow(ChatUiState.empty()) @@ -66,6 +67,15 @@ class ChatViewModel @Inject constructor( val favorites: kotlinx.coroutines.flow.StateFlow> = favoritesStore.favorites.stateIn(viewModelScope, kotlinx.coroutines.flow.SharingStarted.WhileSubscribed(5_000), emptySet()) + /** True while a response is being read aloud. */ + val speaking: kotlinx.coroutines.flow.StateFlow = tts.speaking + + /** Read [text] aloud (markdown stripped for cleaner speech). */ + fun readAloud(text: String) = tts.speak(speechText(text)) + + /** Stop any current read-aloud. */ + fun stopReading() = tts.stop() + data class ModelSheetUi( val query: String = "", val scope: com.hermes.client.ui.models.ModelScope = com.hermes.client.ui.models.ModelScope.SESSION, diff --git a/app/src/main/java/com/hermes/client/ui/chat/SpeechText.kt b/app/src/main/java/com/hermes/client/ui/chat/SpeechText.kt new file mode 100644 index 0000000..350c7c0 --- /dev/null +++ b/app/src/main/java/com/hermes/client/ui/chat/SpeechText.kt @@ -0,0 +1,27 @@ +package com.hermes.client.ui.chat + +/** + * Strip common markdown so TextToSpeech reads content, not syntax. Best-effort and intentionally + * simple — fenced code is dropped, inline code/emphasis/heading markers removed, links reduced to + * their text. Not a full markdown parser. + */ +fun speechText(raw: String): String { + if (raw.isBlank()) return "" + var s = raw + // Fenced code blocks: drop entirely (```lang ... ```). + s = Regex("```[\\s\\S]*?```").replace(s, " ") + // Links [text](url) -> text. + s = Regex("\\[([^\\]]+)]\\([^)]*\\)").replace(s) { it.groupValues[1] } + // Inline code `code` -> code. + s = Regex("`([^`]*)`").replace(s) { it.groupValues[1] } + // Heading markers at line start. + s = Regex("(?m)^\\s{0,3}#{1,6}\\s*").replace(s, "") + // Emphasis markers ** * __ _ (leave apostrophes/words intact). + s = s.replace("**", "").replace("__", "") + s = Regex("(?(relaxed = true) private val favoritesStore = mockk(relaxed = true) private val pendingShareStore = com.hermes.client.share.PendingShareStore() + private val tts = mockk(relaxed = true) @Before fun setUp() { Dispatchers.setMain(StandardTestDispatcher()) @@ -54,9 +55,10 @@ class ChatViewModelTest { coEvery { modelRepo.providers() } returns emptyList() coEvery { profileRepo.list() } returns emptyList() every { favoritesStore.favorites } returns MutableStateFlow(emptySet()) + every { tts.speaking } returns MutableStateFlow(false) } - private fun buildVm() = ChatViewModel(chatRepo, sessionRepo, modelRepo, profileRepo, profileManager, favoritesStore, pendingShareStore) + private fun buildVm() = ChatViewModel(chatRepo, sessionRepo, modelRepo, profileRepo, profileManager, favoritesStore, pendingShareStore, tts) @Test fun streamed_delta_appears_in_state() = runTest { val vm = buildVm() @@ -253,4 +255,16 @@ class ChatViewModelTest { coVerify { profileRepo.setActive("personal") } } + + @Test fun readAloud_speaks_markdown_stripped_text() { + val vm = buildVm() + vm.readAloud("**hi** `there`") + io.mockk.verify { tts.speak("hi there") } + } + + @Test fun stopReading_stops_tts() { + val vm = buildVm() + vm.stopReading() + io.mockk.verify { tts.stop() } + } } diff --git a/app/src/test/java/com/hermes/client/ui/chat/SpeechTextTest.kt b/app/src/test/java/com/hermes/client/ui/chat/SpeechTextTest.kt new file mode 100644 index 0000000..ae365a0 --- /dev/null +++ b/app/src/test/java/com/hermes/client/ui/chat/SpeechTextTest.kt @@ -0,0 +1,32 @@ +package com.hermes.client.ui.chat + +import org.junit.Assert.assertEquals +import org.junit.Assert.assertTrue +import org.junit.Test + +class SpeechTextTest { + @Test fun strips_emphasis_and_headings() { + assertEquals("Hello world", speechText("**Hello** _world_")) + assertEquals("Title", speechText("# Title")) + } + + @Test fun link_becomes_its_text() { + assertEquals("click here", speechText("[click here](https://example.com)")) + } + + @Test fun inline_code_backticks_stripped() { + assertEquals("run ls now", speechText("run `ls` now")) + } + + @Test fun fenced_code_block_removed() { + val out = speechText("before\n```kotlin\nval x = 1\n```\nafter") + assertTrue(out.contains("before")) + assertTrue(out.contains("after")) + assertTrue(!out.contains("val x = 1")) + } + + @Test fun plain_text_unchanged_and_empty_is_empty() { + assertEquals("just words", speechText("just words")) + assertEquals("", speechText("")) + } +} diff --git a/docs/superpowers/plans/2026-07-17-tts-read-aloud.md b/docs/superpowers/plans/2026-07-17-tts-read-aloud.md new file mode 100644 index 0000000..08d2676 --- /dev/null +++ b/docs/superpowers/plans/2026-07-17-tts-read-aloud.md @@ -0,0 +1,352 @@ +# TTS Read-Aloud Implementation Plan + +> **For agentic workers:** REQUIRED SUB-SKILL: Use superpowers:subagent-driven-development. Steps use checkbox (`- [ ]`) syntax. + +**Goal:** Read an assistant response aloud via native `TextToSpeech`, from the assistant-bubble action menu. Client-only. + +**Architecture:** A pure `speechText` markdown-stripper, a `TextToSpeechController` interface (Android impl wraps `TextToSpeech`, exposes `speaking: StateFlow`), `ChatViewModel` delegation, and a "Read aloud"/"Stop" dropdown item threaded through the message list. + +**Tech Stack:** Kotlin, Compose, Material3, Hilt, `android.speech.tts.TextToSpeech`. + +**Spec:** `docs/superpowers/specs/2026-07-17-tts-read-aloud-design.md` + +## Global Constraints +- Client-only; assistant turns only; no AI attribution. +- Build env: `export JAVA_HOME="/Applications/Android Studio.app/Contents/jbr/Contents/Home"`. +- Branch: `feature/tts-read-aloud` (off `dev`). All commits land here. + +--- + +### Task 1: `speechText` pure helper + +**Files:** Create `app/src/main/java/com/hermes/client/ui/chat/SpeechText.kt`; Test `app/src/test/java/com/hermes/client/ui/chat/SpeechTextTest.kt` + +- [ ] **Step 1: Write the failing test** + +`SpeechTextTest.kt`: +```kotlin +package com.hermes.client.ui.chat + +import org.junit.Assert.assertEquals +import org.junit.Assert.assertTrue +import org.junit.Test + +class SpeechTextTest { + @Test fun strips_emphasis_and_headings() { + assertEquals("Hello world", speechText("**Hello** _world_")) + assertEquals("Title", speechText("# Title")) + } + + @Test fun link_becomes_its_text() { + assertEquals("click here", speechText("[click here](https://example.com)")) + } + + @Test fun inline_code_backticks_stripped() { + assertEquals("run ls now", speechText("run `ls` now")) + } + + @Test fun fenced_code_block_removed() { + val out = speechText("before\n```kotlin\nval x = 1\n```\nafter") + assertTrue(out.contains("before")) + assertTrue(out.contains("after")) + assertTrue(!out.contains("val x = 1")) + } + + @Test fun plain_text_unchanged_and_empty_is_empty() { + assertEquals("just words", speechText("just words")) + assertEquals("", speechText("")) + } +} +``` + +- [ ] **Step 2: Run to verify it fails** + +Run: `./gradlew :app:testDebugUnitTest --tests "com.hermes.client.ui.chat.SpeechTextTest"` → FAIL (unresolved). + +- [ ] **Step 3: Implement** + +`SpeechText.kt`: +```kotlin +package com.hermes.client.ui.chat + +/** + * Strip common markdown so TextToSpeech reads content, not syntax. Best-effort and intentionally + * simple — fenced code is dropped, inline code/emphasis/heading markers removed, links reduced to + * their text. Not a full markdown parser. + */ +fun speechText(raw: String): String { + if (raw.isBlank()) return "" + var s = raw + // Fenced code blocks: drop entirely (```lang ... ```). + s = Regex("```[\\s\\S]*?```").replace(s, " ") + // Links [text](url) -> text. + s = Regex("\\[([^\\]]+)]\\([^)]*\\)").replace(s) { it.groupValues[1] } + // Inline code `code` -> code. + s = Regex("`([^`]*)`").replace(s) { it.groupValues[1] } + // Heading markers at line start. + s = Regex("(?m)^\\s{0,3}#{1,6}\\s*").replace(s, "") + // Emphasis markers ** * __ _ (leave apostrophes/words intact). + s = s.replace("**", "").replace("__", "") + s = Regex("(?; fun speak(text: String); fun stop() }`; `class AndroidTtsManager(context: Context) : TextToSpeechController`; `ChatViewModel.speaking`/`readAloud(text)`/`stopReading()`. + +- [ ] **Step 1: Write the failing test (extend ChatViewModelTest)** + +Add a mock field near the other mocks in `ChatViewModelTest.kt`: +```kotlin + private val tts = mockk(relaxed = true) +``` +Update `buildVm()` to pass it (new last arg): +```kotlin + private fun buildVm() = ChatViewModel(chatRepo, sessionRepo, modelRepo, profileRepo, profileManager, favoritesStore, pendingShareStore, tts) +``` +Add tests: +```kotlin + @Test fun readAloud_speaks_markdown_stripped_text() { + val vm = buildVm() + vm.readAloud("**hi** `there`") + io.mockk.verify { tts.speak("hi there") } + } + + @Test fun stopReading_stops_tts() { + val vm = buildVm() + vm.stopReading() + io.mockk.verify { tts.stop() } + } +``` +(If `every { tts.speaking } returns MutableStateFlow(false)` is needed for the VM to read `tts.speaking` at construction, add it to the test setup alongside the other `every { … }` stubs. `relaxed = true` returns a default for `speaking`, but if the VM assigns `val speaking = tts.speaking` a relaxed mock returns a relaxed StateFlow — acceptable; add an explicit stub only if a test asserts on `vm.speaking`.) + +- [ ] **Step 2: Run to verify it fails** + +Run: `./gradlew :app:testDebugUnitTest --tests "com.hermes.client.ui.chat.ChatViewModelTest"` → FAIL (unresolved `TextToSpeechController`/`readAloud`/`stopReading` + arity mismatch). + +- [ ] **Step 3: Create the controller + Android impl** + +`app/src/main/java/com/hermes/client/data/tts/TextToSpeechController.kt`: +```kotlin +package com.hermes.client.data.tts + +import android.content.Context +import android.speech.tts.TextToSpeech +import android.speech.tts.UtteranceProgressListener +import kotlinx.coroutines.flow.MutableStateFlow +import kotlinx.coroutines.flow.StateFlow +import kotlinx.coroutines.flow.asStateFlow + +/** Speaks text aloud; [speaking] is true while an utterance is playing. */ +interface TextToSpeechController { + val speaking: StateFlow + fun speak(text: String) + fun stop() +} + +private const val UTTERANCE_ID = "hermes-read-aloud" + +/** Android [TextToSpeech]-backed controller. Init is async; a speak before ready is queued. */ +class AndroidTtsManager(context: Context) : TextToSpeechController { + private val _speaking = MutableStateFlow(false) + override val speaking: StateFlow = _speaking.asStateFlow() + + private var ready = false + private var pending: String? = null + + private val engine = TextToSpeech(context.applicationContext) { status -> + ready = status == TextToSpeech.SUCCESS + if (ready) { + engine.setOnUtteranceProgressListener(object : UtteranceProgressListener() { + override fun onStart(utteranceId: String?) { _speaking.value = true } + override fun onDone(utteranceId: String?) { _speaking.value = false } + @Deprecated("legacy") override fun onError(utteranceId: String?) { _speaking.value = false } + override fun onError(utteranceId: String?, errorCode: Int) { _speaking.value = false } + override fun onStop(utteranceId: String?, interrupted: Boolean) { _speaking.value = false } + }) + pending?.let { speak(it); pending = null } + } + } + + override fun speak(text: String) { + if (text.isBlank()) return + if (!ready) { pending = text; return } + engine.speak(text, TextToSpeech.QUEUE_FLUSH, null, UTTERANCE_ID) + } + + override fun stop() { + pending = null + engine.stop() + _speaking.value = false + } +} +``` + +- [ ] **Step 4: Provide it via Hilt** + +In `app/src/main/java/com/hermes/client/di/AppModule.kt`, add a provider (mirror the existing `@Provides @Singleton` + `@ApplicationContext context: Context` style): +```kotlin + @Provides + @Singleton + fun provideTextToSpeechController( + @ApplicationContext context: Context, + ): com.hermes.client.data.tts.TextToSpeechController = + com.hermes.client.data.tts.AndroidTtsManager(context) +``` + +- [ ] **Step 5: Wire ChatViewModel** + +In `ChatViewModel.kt`, add the constructor param (new last param) and the three members. Add to the `@Inject constructor(...)`: +```kotlin + private val tts: com.hermes.client.data.tts.TextToSpeechController, +``` +Add inside the class body: +```kotlin + /** True while a response is being read aloud. */ + val speaking: kotlinx.coroutines.flow.StateFlow = tts.speaking + + /** Read [text] aloud (markdown stripped for cleaner speech). */ + fun readAloud(text: String) = tts.speak(speechText(text)) + + /** Stop any current read-aloud. */ + fun stopReading() = tts.stop() +``` + +- [ ] **Step 6: Run tests + compile** + +Run: `./gradlew :app:testDebugUnitTest --tests "com.hermes.client.ui.chat.ChatViewModelTest"` → PASS. +Run: `JAVA_HOME="/Applications/Android Studio.app/Contents/jbr/Contents/Home" ./gradlew :app:compileDebugKotlin` → BUILD SUCCESSFUL. + +- [ ] **Step 7: Commit** + +```bash +git add app/src/main/java/com/hermes/client/data/tts/TextToSpeechController.kt \ + app/src/main/java/com/hermes/client/di/AppModule.kt \ + app/src/main/java/com/hermes/client/ui/chat/ChatViewModel.kt \ + app/src/test/java/com/hermes/client/ui/chat/ChatViewModelTest.kt +git commit -m "feat: TextToSpeechController + ChatViewModel read-aloud" +``` + +--- + +### Task 3: Dropdown item + threading + stop-on-leave + +**Files:** +- Modify: `app/src/main/java/com/hermes/client/ui/chat/ChatComponents.kt` +- Modify: `app/src/main/java/com/hermes/client/ui/chat/ChatScreen.kt` +- Test: none new (Compose glue). Verified by compile + full suite + assembleBeta + Task 4. + +**Interfaces:** Consumes `ChatViewModel.speaking`/`readAloud`/`stopReading` (Task 2). + +- [ ] **Step 1: Thread params through the message list** + +In `ChatComponents.kt`, add three params to `ChatMessageList` (after `onRegenerate`): +```kotlin + isSpeaking: Boolean = false, + onReadAloud: (String) -> Unit = {}, + onStopReading: () -> Unit = {}, +``` +Pass them into `MessageBubble` where it is called inside `ChatMessageList` (alongside the existing args), and add matching params to `MessageBubble`: +```kotlin +private fun MessageBubble( + msg: ChatMessage, + canRegenerate: Boolean, + onEditResend: (String) -> Unit, + onRegenerate: () -> Unit, + isSpeaking: Boolean, + onReadAloud: (String) -> Unit, + onStopReading: () -> Unit, + highlighted: Boolean = false, +) +``` +and forward `isSpeaking`/`onReadAloud`/`onStopReading` to `AssistantTurn` (add the same three params to `AssistantTurn`'s signature). (`MessageBubble` dispatches `USER -> UserBubble`, `else -> AssistantTurn(...)` — add the args only to the `AssistantTurn` call.) + +- [ ] **Step 2: Add the dropdown item in `AssistantTurn`** + +In `AssistantTurn`'s `DropdownMenu`, after the `Copy` item (and the `canRegenerate` item), add: +```kotlin + if (msg.text.isNotBlank() && !msg.isError) { + DropdownMenuItem( + text = { Text(if (isSpeaking) "Stop" else "Read aloud") }, + onClick = { + if (isSpeaking) onStopReading() else onReadAloud(msg.text) + menuOpen = false + }, + ) + } +``` + +- [ ] **Step 3: Wire ChatScreen** + +In `ChatScreen.kt`, collect speaking near the other `collectAsStateWithLifecycle` calls: +```kotlin + val speaking by vm.speaking.collectAsStateWithLifecycle() +``` +At the `ChatMessageList(...)` call (~line 475), add: +```kotlin + isSpeaking = speaking, + onReadAloud = { vm.readAloud(it) }, + onStopReading = { vm.stopReading() }, +``` +Add a `DisposableEffect` in `ChatScreen` so audio stops when the screen leaves composition: +```kotlin + androidx.compose.runtime.DisposableEffect(Unit) { onDispose { vm.stopReading() } } +``` + +- [ ] **Step 4: Compile + full suite + assembleBeta** + +Run each (JAVA_HOME set): `:app:compileDebugKotlin`, `:app:testDebugUnitTest` (0 failures), `:app:assembleBeta`. All BUILD SUCCESSFUL. + +- [ ] **Step 5: Commit** + +```bash +git add app/src/main/java/com/hermes/client/ui/chat/ChatComponents.kt \ + app/src/main/java/com/hermes/client/ui/chat/ChatScreen.kt +git commit -m "feat: Read aloud/Stop item on assistant bubbles" +``` + +--- + +### Task 4: On-device verification + +**Files:** none (manual). + +- [ ] **Step 1:** `:app:installBeta`. +- [ ] **Step 2:** Open a chat with an assistant reply → long-press the assistant turn → tap **Read aloud** → confirm audio plays and the item flips to **Stop**. +- [ ] **Step 3:** Tap **Stop** → audio stops. Start again, then navigate back → audio stops (stop-on-leave). +- [ ] **Step 4:** Read a code-heavy reply → confirm backticks/markdown aren't read as literal syntax. +- [ ] **Step 5:** Record pass/fail in the PR description (no commit). If the emulator has no TTS engine/audio, note it and rely on the unit tests + review. + +--- + +## Notes for the executor +- `AndroidTtsManager` never `shutdown()`s the engine (a process-lifetime singleton); acceptable for v1. Do not add a language/voice picker or auto-read (anti-scope). +- If `UtteranceProgressListener`'s abstract members differ by API level, implement all required overrides so it compiles against the project's compileSdk. diff --git a/docs/superpowers/specs/2026-07-17-tts-read-aloud-design.md b/docs/superpowers/specs/2026-07-17-tts-read-aloud-design.md new file mode 100644 index 0000000..6c1dc67 --- /dev/null +++ b/docs/superpowers/specs/2026-07-17-tts-read-aloud-design.md @@ -0,0 +1,76 @@ +# TTS Read-Aloud — Design + +**Wave:** Quick-wins (client-only). **Branch:** `feature/tts-read-aloud` (off `dev`). + +**Goal:** Let the user have an assistant response read aloud via native Android `TextToSpeech`, from the existing per-bubble action menu. Fully client-only; no gateway involvement. + +**Constraints:** Kotlin / Compose / Material3 / Hilt. No AI attribution; gitleaks before push; PR into `dev`. + +## Scope + +- **In:** a "Read aloud" / "Stop" item on the assistant-bubble dropdown; a lifecycle-safe TTS wrapper; markdown-stripped speech text; stop when leaving the chat. +- **Out:** voice/language pickers, per-message playback UI/scrubber, auto-read-on-arrival, reading user/system messages (assistant turns only). + +## Architecture + +### 1. `ui/chat/SpeechText.kt` (new) — pure helper +`fun speechText(raw: String): String` — strip common markdown so TTS doesn't read syntax aloud: fenced code blocks removed, inline code backticks stripped, `**`/`*`/`_` emphasis markers removed, heading `#` markers removed, link `[text](url)` → `text`, collapse blank runs. Pure, unit-tested. + +### 2. `data/tts/TextToSpeechController.kt` (new) — interface + Android impl +```kotlin +interface TextToSpeechController { + val speaking: StateFlow + fun speak(text: String) // QUEUE_FLUSH (replaces any current utterance) + fun stop() +} +``` +`AndroidTtsManager(context)` (Hilt `@Singleton`): wraps `android.speech.tts.TextToSpeech`. Lazy async init via `OnInitListener` — a `speak` before init completes is queued and spoken on ready (or dropped if init failed). An `UtteranceProgressListener` drives `speaking` (`true` on start, `false` on done/error/stop). `speak` uses a fixed utterance id + `QUEUE_FLUSH`. Provided via a Hilt module using `@ApplicationContext`. + +### 3. `ui/chat/ChatViewModel.kt` (modify) +Inject `TextToSpeechController`; expose: +```kotlin +val speaking: StateFlow = tts.speaking +fun readAloud(text: String) = tts.speak(speechText(text)) +fun stopReading() = tts.stop() +``` + +### 4. UI — `ui/chat/ChatComponents.kt` + `ChatScreen.kt` (modify) +Thread `isSpeaking: Boolean`, `onReadAloud: (String) -> Unit`, `onStopReading: () -> Unit` through `ChatMessageList` → `MessageBubble` → `AssistantTurn` (mirroring the existing `onRegenerate` threading). In `AssistantTurn`'s `DropdownMenu`, add an item: +- `isSpeaking` → **"Stop"** → `onStopReading()`; else → **"Read aloud"** → `onReadAloud(msg.text)` (only when `msg.text.isNotBlank()` and `!msg.isError`). + +In `ChatScreen`: collect `vm.speaking`; pass `isSpeaking`, `onReadAloud = { vm.readAloud(it) }`, `onStopReading = { vm.stopReading() }` into `ChatMessageList` (call site ~line 475). Add a `DisposableEffect` (or reuse the existing leave path) calling `vm.stopReading()` when the chat screen leaves composition, so audio doesn't continue after navigating away. + +## Data flow +``` +long-press assistant bubble → "Read aloud" → onReadAloud(msg.text) + → vm.readAloud → tts.speak(speechText(text)) → TextToSpeech (QUEUE_FLUSH) + → UtteranceProgressListener → speaking=true → menu item shows "Stop" +"Stop" / leave chat → vm.stopReading() → tts.stop() → speaking=false +``` + +## Error handling +- TTS init failure → `speak` is a no-op (nothing read; no crash); `speaking` stays false. +- Blank/`isError` message → no "Read aloud" item shown. +- `speechText` on empty → "" → nothing spoken. + +## Testing +- **`SpeechTextTest`** (pure): code fence removed; inline backticks stripped; `**bold**`/`# heading`/`[t](u)` → clean text; plain text unchanged; empty → empty. +- **`ChatViewModelTest`** (mock `TextToSpeechController`): `readAloud("**hi**")` calls `tts.speak` with the markdown-stripped text; `stopReading()` calls `tts.stop()`; `speaking` reflects the controller's flow. +- The `AndroidTtsManager`/`TextToSpeech` glue is Android — verified on-device. + +## On-device verification +Open a chat with an assistant reply → long-press → **Read aloud** → confirm audio plays and the item flips to **Stop**; tap **Stop** → audio stops. Start reading, then navigate back → confirm audio stops. Read a code-heavy reply → confirm code syntax isn't read as literal backticks/markdown. + +## Files +| Action | Path | +|--------|------| +| New | `ui/chat/SpeechText.kt` + test | +| New | `data/tts/TextToSpeechController.kt` (interface + `AndroidTtsManager`) | +| Modify | `di/AppModule.kt` (provide the controller) | +| Modify | `ui/chat/ChatViewModel.kt` | +| Modify | `ui/chat/ChatComponents.kt` (menu + threading) | +| Modify | `ui/chat/ChatScreen.kt` (wire + stop-on-leave) | +| New test | `SpeechTextTest.kt`; extend `ChatViewModelTest` | + +## Build & gates +`JAVA_HOME="/Applications/Android Studio.app/Contents/jbr/Contents/Home"`: `:app:compileDebugKotlin`, `:app:testDebugUnitTest`, `:app:assembleBeta`. gitleaks before push; PR into `dev`. From 049342c2de64665db203c262e8c7dfe36e7f10fb Mon Sep 17 00:00:00 2001 From: Andrew Debnar <606931+adebnar@users.noreply.github.com> Date: Fri, 17 Jul 2026 16:47:49 +0000 Subject: [PATCH 08/16] feat: hermes:// deep links (tab + chat) (#102) * docs: spec + plan for hermes:// deep links * feat: add hermes:// deep-link route mapper * feat: accept hermes:// VIEW intents and guard deep-link navigation * fix: MainActivity singleTask so hermes:// links reuse the running instance --- app/src/main/AndroidManifest.xml | 7 + .../java/com/hermes/client/MainActivity.kt | 5 + .../hermes/client/ui/nav/DeepLinkMapper.kt | 21 ++ .../com/hermes/client/ui/nav/HermesNav.kt | 9 +- .../client/ui/nav/DeepLinkMapperTest.kt | 31 +++ .../plans/2026-07-17-hermes-deep-links.md | 202 ++++++++++++++++++ .../2026-07-17-hermes-deep-links-design.md | 101 +++++++++ 7 files changed, 375 insertions(+), 1 deletion(-) create mode 100644 app/src/main/java/com/hermes/client/ui/nav/DeepLinkMapper.kt create mode 100644 app/src/test/java/com/hermes/client/ui/nav/DeepLinkMapperTest.kt create mode 100644 docs/superpowers/plans/2026-07-17-hermes-deep-links.md create mode 100644 docs/superpowers/specs/2026-07-17-hermes-deep-links-design.md diff --git a/app/src/main/AndroidManifest.xml b/app/src/main/AndroidManifest.xml index 4c67ba7..11cc85c 100644 --- a/app/src/main/AndroidManifest.xml +++ b/app/src/main/AndroidManifest.xml @@ -19,6 +19,7 @@ @@ -30,6 +31,12 @@ + + + + + + segs.singleOrNull()?.takeIf { it in TAB_ROUTES } + "chat" -> segs.singleOrNull()?.takeIf { it.isNotBlank() && '/' !in it }?.let { "chat/$it" } + else -> null + } +} diff --git a/app/src/main/java/com/hermes/client/ui/nav/HermesNav.kt b/app/src/main/java/com/hermes/client/ui/nav/HermesNav.kt index 060370b..f1ab1dd 100644 --- a/app/src/main/java/com/hermes/client/ui/nav/HermesNav.kt +++ b/app/src/main/java/com/hermes/client/ui/nav/HermesNav.kt @@ -89,7 +89,14 @@ fun HermesNav(hasConfig: Boolean, deepLinkRoute: String? = null, onDeepLinkConsu val nav = rememberNavController() val start = if (hasConfig) "activity" else "setup" - LaunchedEffect(deepLinkRoute) { deepLinkRoute?.let { nav.navigate(it); onDeepLinkConsumed() } } + // Guard the navigate: a hermes:// deep link is untrusted, and even the notification path could + // carry a stale/unknown route — an unresolved route must be ignored, never crash. + LaunchedEffect(deepLinkRoute) { + deepLinkRoute?.let { + runCatching { nav.navigate(it) } + onDeepLinkConsumed() + } + } val backStackEntry by nav.currentBackStackEntryAsState() val route = backStackEntry?.destination?.route diff --git a/app/src/test/java/com/hermes/client/ui/nav/DeepLinkMapperTest.kt b/app/src/test/java/com/hermes/client/ui/nav/DeepLinkMapperTest.kt new file mode 100644 index 0000000..2fc9719 --- /dev/null +++ b/app/src/test/java/com/hermes/client/ui/nav/DeepLinkMapperTest.kt @@ -0,0 +1,31 @@ +package com.hermes.client.ui.nav + +import org.junit.Assert.assertEquals +import org.junit.Assert.assertNull +import org.junit.Test + +class DeepLinkMapperTest { + @Test fun tab_routes_map() { + assertEquals("sessions", deepLinkRouteFor("hermes://tab/sessions")) + assertEquals("activity", deepLinkRouteFor("hermes://tab/activity")) + assertEquals("you", deepLinkRouteFor("hermes://tab/you")) + } + + @Test fun chat_id_maps() { + assertEquals("chat/abc-123", deepLinkRouteFor("hermes://chat/abc-123")) + } + + @Test fun scheme_is_case_insensitive() { + assertEquals("sessions", deepLinkRouteFor("HERMES://tab/sessions")) + } + + @Test fun unknown_or_malformed_is_null() { + assertNull(deepLinkRouteFor("hermes://tab/nope")) + assertNull(deepLinkRouteFor("hermes://chat")) // no id + assertNull(deepLinkRouteFor("hermes://chat/a/b")) // two segments + assertNull(deepLinkRouteFor("hermes://bogus/x")) // unknown host + assertNull(deepLinkRouteFor("http://tab/sessions")) // wrong scheme + assertNull(deepLinkRouteFor("")) + assertNull(deepLinkRouteFor("not a uri at all ::: %%%")) + } +} diff --git a/docs/superpowers/plans/2026-07-17-hermes-deep-links.md b/docs/superpowers/plans/2026-07-17-hermes-deep-links.md new file mode 100644 index 0000000..af4f8cb --- /dev/null +++ b/docs/superpowers/plans/2026-07-17-hermes-deep-links.md @@ -0,0 +1,202 @@ +# `hermes://` Deep Links Implementation Plan + +> **For agentic workers:** REQUIRED SUB-SKILL: superpowers:subagent-driven-development. Checkbox (`- [ ]`) steps. + +**Goal:** Route `hermes://tab/{sessions|activity|you}` and `hermes://chat/` links into the app, reusing the existing deep-link rail; harden the navigate against bad routes. + +**Spec:** `docs/superpowers/specs/2026-07-17-hermes-deep-links-design.md` + +## Global Constraints +- Client-only; strict allowlist (unknown links ignored); no crash on a bad route. +- Build env: `export JAVA_HOME="/Applications/Android Studio.app/Contents/jbr/Contents/Home"`. +- Branch `feature/hermes-deep-links` (off `dev`). No AI attribution. + +--- + +### Task 1: Pure `deepLinkRouteFor` mapper + +**Files:** Create `app/src/main/java/com/hermes/client/ui/nav/DeepLinkMapper.kt`; Test `app/src/test/java/com/hermes/client/ui/nav/DeepLinkMapperTest.kt` + +- [ ] **Step 1: Write the failing test** + +`DeepLinkMapperTest.kt`: +```kotlin +package com.hermes.client.ui.nav + +import org.junit.Assert.assertEquals +import org.junit.Assert.assertNull +import org.junit.Test + +class DeepLinkMapperTest { + @Test fun tab_routes_map() { + assertEquals("sessions", deepLinkRouteFor("hermes://tab/sessions")) + assertEquals("activity", deepLinkRouteFor("hermes://tab/activity")) + assertEquals("you", deepLinkRouteFor("hermes://tab/you")) + } + + @Test fun chat_id_maps() { + assertEquals("chat/abc-123", deepLinkRouteFor("hermes://chat/abc-123")) + } + + @Test fun scheme_is_case_insensitive() { + assertEquals("sessions", deepLinkRouteFor("HERMES://tab/sessions")) + } + + @Test fun unknown_or_malformed_is_null() { + assertNull(deepLinkRouteFor("hermes://tab/nope")) + assertNull(deepLinkRouteFor("hermes://chat")) // no id + assertNull(deepLinkRouteFor("hermes://chat/a/b")) // two segments + assertNull(deepLinkRouteFor("hermes://bogus/x")) // unknown host + assertNull(deepLinkRouteFor("http://tab/sessions")) // wrong scheme + assertNull(deepLinkRouteFor("")) + assertNull(deepLinkRouteFor("not a uri at all ::: %%%")) + } +} +``` + +- [ ] **Step 2: Run to verify it fails** + +Run: `./gradlew :app:testDebugUnitTest --tests "com.hermes.client.ui.nav.DeepLinkMapperTest"` → FAIL (unresolved). + +- [ ] **Step 3: Implement** + +`DeepLinkMapper.kt`: +```kotlin +package com.hermes.client.ui.nav + +private val TAB_ROUTES = setOf("sessions", "activity", "you") + +/** + * Map a `hermes://` URI string to an internal nav route, or null if it isn't a recognised link. + * Parsed with [java.net.URI] (pure JVM, unit-testable). Strict allowlist — a `hermes://` link is + * untrusted external input (BROWSABLE), so anything unknown returns null and is ignored. + */ +fun deepLinkRouteFor(raw: String): String? { + if (raw.isBlank()) return null + val uri = runCatching { java.net.URI(raw) }.getOrNull() ?: return null + if (!"hermes".equals(uri.scheme, ignoreCase = true)) return null + val host = uri.host ?: return null + val segs = uri.path.orEmpty().split('/').filter { it.isNotBlank() } + return when (host) { + "tab" -> segs.singleOrNull()?.takeIf { it in TAB_ROUTES } + "chat" -> segs.singleOrNull()?.takeIf { it.isNotBlank() && '/' !in it }?.let { "chat/$it" } + else -> null + } +} +``` + +- [ ] **Step 4: Run to verify it passes** + +Run: `./gradlew :app:testDebugUnitTest --tests "com.hermes.client.ui.nav.DeepLinkMapperTest"` → PASS (4 tests). If `java.net.URI("not a uri at all ::: %%%")` throws instead of the `runCatching` catching it, confirm the `runCatching` wraps the constructor (it does) — the test expects null. + +- [ ] **Step 5: Commit** + +```bash +git add app/src/main/java/com/hermes/client/ui/nav/DeepLinkMapper.kt \ + app/src/test/java/com/hermes/client/ui/nav/DeepLinkMapperTest.kt +git commit -m "feat: add hermes:// deep-link route mapper" +``` + +--- + +### Task 2: Intent-filter + MainActivity + navigate hardening + +**Files:** +- Modify: `app/src/main/AndroidManifest.xml` +- Modify: `app/src/main/java/com/hermes/client/MainActivity.kt` +- Modify: `app/src/main/java/com/hermes/client/ui/nav/HermesNav.kt` +- Test: none new (Android glue). Compile + full suite + assembleBeta + Task 3. + +**Interfaces:** Consumes `deepLinkRouteFor` (Task 1). + +- [ ] **Step 1: Add the intent-filter** + +In `AndroidManifest.xml`, inside the existing `` block (after the existing intent-filters), add: +```xml + + + + + + +``` + +- [ ] **Step 2: Read `intent.data` in MainActivity** + +In `MainActivity.kt`, add the import: +```kotlin +import com.hermes.client.ui.nav.deepLinkRouteFor +``` +In `onCreate`, replace: +```kotlin + pendingRoute.value = intent?.getStringExtra("extra_route") + intent?.removeExtra("extra_route") +``` +with: +```kotlin + pendingRoute.value = intent?.getStringExtra("extra_route") + ?: intent?.data?.let { deepLinkRouteFor(it.toString()) } + intent?.removeExtra("extra_route") + intent?.data = null +``` +In `onNewIntent`, replace: +```kotlin + pendingRoute.value = intent.getStringExtra("extra_route") + intent.removeExtra("extra_route") +``` +with: +```kotlin + pendingRoute.value = intent.getStringExtra("extra_route") + ?: intent.data?.let { deepLinkRouteFor(it.toString()) } + intent.removeExtra("extra_route") + intent.data = null +``` + +- [ ] **Step 3: Harden the navigate in HermesNav** + +In `HermesNav.kt`, replace: +```kotlin + LaunchedEffect(deepLinkRoute) { deepLinkRoute?.let { nav.navigate(it); onDeepLinkConsumed() } } +``` +with: +```kotlin + // Guard the navigate: a hermes:// deep link is untrusted, and even the notification path could + // carry a stale/unknown route — an unresolved route must be ignored, never crash. + LaunchedEffect(deepLinkRoute) { + deepLinkRoute?.let { + runCatching { nav.navigate(it) } + onDeepLinkConsumed() + } + } +``` + +- [ ] **Step 4: Compile + full suite + assembleBeta** + +Run each (JAVA_HOME set): `:app:compileDebugKotlin`, `:app:testDebugUnitTest` (0 failures), `:app:assembleBeta` — all BUILD SUCCESSFUL. + +- [ ] **Step 5: Commit** + +```bash +git add app/src/main/AndroidManifest.xml \ + app/src/main/java/com/hermes/client/MainActivity.kt \ + app/src/main/java/com/hermes/client/ui/nav/HermesNav.kt +git commit -m "feat: accept hermes:// VIEW intents and guard deep-link navigation" +``` + +--- + +### Task 3: On-device verification (adb-driven, fully exercisable) + +**Files:** none. + +- [ ] **Step 1:** `:app:installBeta` (target the emulator explicitly if multiple devices: `adb -s emulator-5554 …`). The app must be configured (past setup) for tab navigation to land meaningfully; if on the setup screen, note it. +- [ ] **Step 2:** `adb shell am start -a android.intent.action.VIEW -d "hermes://tab/sessions" com.hermes.client.beta` → app foregrounds on the **Chats** tab. Repeat `hermes://tab/activity` (Home) and `hermes://tab/you` (You). +- [ ] **Step 3:** `adb shell am start -a android.intent.action.VIEW -d "hermes://chat/" com.hermes.client.beta` → opens that chat (if the active profile owns it). Confirm no crash if the id isn't found (lands/stays gracefully). +- [ ] **Step 4:** `adb shell am start -a android.intent.action.VIEW -d "hermes://bogus/x" com.hermes.client.beta` → app opens normally, link ignored, **no crash** (verify process alive: `adb shell pidof com.hermes.client.beta`). +- [ ] **Step 5:** Record pass/fail in the PR description (no commit). + +--- + +## Notes for the executor +- Keep the existing `extra_route` (notification) path working — the deep-link read is a **fallback** (`?:`) only when `extra_route` is absent. +- Do NOT add `?profile=` handling, cron links, `https://` App Links, or link *generation* — all explicit anti-scope for this wave. diff --git a/docs/superpowers/specs/2026-07-17-hermes-deep-links-design.md b/docs/superpowers/specs/2026-07-17-hermes-deep-links-design.md new file mode 100644 index 0000000..36a34e0 --- /dev/null +++ b/docs/superpowers/specs/2026-07-17-hermes-deep-links-design.md @@ -0,0 +1,101 @@ +# `hermes://` Deep Links — Design + +**Wave:** Quick-wins (client-only). **Branch:** `feature/hermes-deep-links` (off `dev`). + +**Goal:** Accept a public `hermes://` link that routes into a specific tab or chat — the foundation for a future home-screen widget and share-sheet. Client-only; reuses the existing `pendingRoute → HermesNav.deepLinkRoute → nav.navigate` rail. + +**Constraints:** Kotlin/Compose/Hilt. No AI attribution; gitleaks before push; PR into `dev`. + +## Scope + +- **In:** a `hermes://` VIEW/BROWSABLE intent-filter; a pure allowlist `Uri`→route mapper; wiring into the existing deep-link rail; hardening the currently-unguarded `nav.navigate` (a bad route currently crashes). +- **URLs:** `hermes://tab/sessions`, `hermes://tab/activity`, `hermes://tab/you`, `hermes://chat/`. +- **Out (deferred follow-ups):** `?profile=

` profile-switch for chat links (has an async switch-then-navigate ordering subtlety — its own design); `hermes://cron/` (cron is per-profile, same profile concern); App Links (`https://` + `autoVerify`); generating links (widget/share — later waves). + +## Security & tenant notes +- A `hermes://` link is **untrusted external input** (BROWSABLE = any app/web page can fire it). The mapper is a strict allowlist returning `null` for anything unknown; unknown/malformed links are silently ignored. Never forward a raw URL segment to `nav.navigate`. +- `hermes://chat/` opens in the **current active profile**. If the id belongs to another tenant, `ChatViewModel` loads against the active profile and shows "session not found" — it **fails safe** (session ids are UUIDs; no cross-tenant collision, so no other tenant's data is shown). Profile-aware chat links are a deferred enhancement. + +## Architecture + +### 1. `ui/nav/DeepLinkMapper.kt` (new) — pure mapper +Takes the raw URI **string** (parsed with `java.net.URI`, pure JVM — so it's unit-testable without Android `Uri`): +```kotlin +private val TAB_ROUTES = setOf("sessions", "activity", "you") + +/** Map a hermes:// URI string to an internal nav route, or null if it isn't a recognised link. */ +fun deepLinkRouteFor(raw: String): String? { + if (raw.isBlank()) return null + val uri = runCatching { java.net.URI(raw) }.getOrNull() ?: return null + if (!"hermes".equals(uri.scheme, ignoreCase = true)) return null + val host = uri.host ?: return null + val segs = uri.path.orEmpty().split('/').filter { it.isNotBlank() } + return when (host) { + "tab" -> segs.singleOrNull()?.takeIf { it in TAB_ROUTES } + "chat" -> segs.singleOrNull()?.takeIf { it.isNotBlank() && '/' !in it }?.let { "chat/$it" } + else -> null + } +} +``` + +### 2. `AndroidManifest.xml` (modify) — intent-filter +Add to the existing `MainActivity` `` block (already `exported="true"`): +```xml + + + + + + +``` + +### 3. `MainActivity.kt` (modify) — read `intent.data` +In `onCreate` and `onNewIntent`, fall back to a mapped `hermes://` link when there's no `extra_route`, and consume `intent.data` (mirroring the existing `removeExtra` discipline so a config-change recreation doesn't re-fire): +```kotlin +pendingRoute.value = intent?.getStringExtra("extra_route") + ?: intent?.data?.let { deepLinkRouteFor(it.toString()) } +intent?.removeExtra("extra_route") +intent?.data = null +``` +(`onNewIntent` uses the non-null `intent`; same two-line pattern after `setIntent(intent)`.) + +### 4. `HermesNav.kt` (modify) — harden the navigate +The `LaunchedEffect(deepLinkRoute)` currently does `nav.navigate(it)` unguarded — an unknown route throws. Wrap it so a bad/unknown route is ignored, not crashing (this also protects the notification `extra_route` path): +```kotlin +LaunchedEffect(deepLinkRoute) { + deepLinkRoute?.let { + runCatching { nav.navigate(it) } + onDeepLinkConsumed() + } +} +``` + +## Data flow +``` +external hermes://tab/sessions (or chat/) → VIEW intent → MainActivity onCreate/onNewIntent + → deepLinkRouteFor(intent.data.toString()) → "sessions" / "chat/" (or null → ignored) + → pendingRoute → HermesNav.deepLinkRoute → runCatching { nav.navigate(route) } → onDeepLinkConsumed +``` + +## Error handling +- Unknown host / bad path / wrong scheme / malformed URI → `deepLinkRouteFor` returns null → nothing navigated. +- A mapped route that somehow isn't registered → `runCatching` swallows the navigation exception (no crash). +- `intent.data` consumed (`= null`) after read so recreation doesn't re-navigate. + +## Testing +- **`DeepLinkMapperTest`** (pure JUnit, string input): each tab route maps; `hermes://chat/abc-123` → `chat/abc-123`; unknown host → null; `hermes://tab/nope` → null; `hermes://chat` (no id) → null; `hermes://chat/a/b` (2 segs) → null; wrong scheme `http://...` → null; blank/garbage → null; scheme case-insensitive. +- MainActivity/manifest/HermesNav are Android glue — verified on-device (deep links are directly testable via `adb am start -a VIEW -d "hermes://…"`). + +## On-device verification +`adb shell am start -a android.intent.action.VIEW -d "hermes://tab/sessions" com.hermes.client.beta` → app opens on the Chats tab. Repeat for `tab/activity`, `tab/you`, and `hermes://chat/` (opens that chat if in the right profile). `adb ... -d "hermes://bogus/x"` → app opens normally (link ignored, no crash). + +## Files +| Action | Path | +|--------|------| +| New | `ui/nav/DeepLinkMapper.kt` + `DeepLinkMapperTest.kt` | +| Modify | `AndroidManifest.xml` (intent-filter) | +| Modify | `MainActivity.kt` (read intent.data) | +| Modify | `ui/nav/HermesNav.kt` (harden navigate) | + +## Build & gates +`JAVA_HOME=…`: `:app:compileDebugKotlin`, `:app:testDebugUnitTest`, `:app:assembleBeta`. gitleaks before push; PR into `dev`. From 517e14b983a44b478281202a533aa5280b1e7626 Mon Sep 17 00:00:00 2001 From: Andrew Debnar <606931+adebnar@users.noreply.github.com> Date: Fri, 17 Jul 2026 17:16:42 +0000 Subject: [PATCH 09/16] feat: home-screen quick-launch widget (Glance) (#103) * docs: spec + plan for home-screen quick-launch widget * feat: recognise hermes://new deep link * feat: handle hermes://new to start a new chat * feat: add home-screen quick-launch widget (Glance) * fix: guard openNewChat against concurrent new-chat creation --- app/build.gradle.kts | 1 + app/src/main/AndroidManifest.xml | 10 + .../java/com/hermes/client/MainActivity.kt | 53 +++- .../hermes/client/ui/nav/DeepLinkMapper.kt | 8 + .../com/hermes/client/widget/HermesWidget.kt | 57 ++++ .../client/widget/HermesWidgetReceiver.kt | 8 + app/src/main/res/values/strings.xml | 4 + app/src/main/res/xml/hermes_widget_info.xml | 10 + .../client/ui/nav/DeepLinkMapperTest.kt | 15 + .../plans/2026-07-17-home-widget.md | 299 ++++++++++++++++++ .../specs/2026-07-17-home-widget-design.md | 134 ++++++++ gradle/libs.versions.toml | 2 + 12 files changed, 593 insertions(+), 8 deletions(-) create mode 100644 app/src/main/java/com/hermes/client/widget/HermesWidget.kt create mode 100644 app/src/main/java/com/hermes/client/widget/HermesWidgetReceiver.kt create mode 100644 app/src/main/res/values/strings.xml create mode 100644 app/src/main/res/xml/hermes_widget_info.xml create mode 100644 docs/superpowers/plans/2026-07-17-home-widget.md create mode 100644 docs/superpowers/specs/2026-07-17-home-widget-design.md diff --git a/app/build.gradle.kts b/app/build.gradle.kts index 2eae156..531fdc4 100644 --- a/app/build.gradle.kts +++ b/app/build.gradle.kts @@ -108,6 +108,7 @@ dependencies { implementation(libs.security.crypto) implementation(libs.markdown.m3) implementation(libs.zxing.embedded) + implementation(libs.glance.appwidget) debugImplementation(libs.compose.ui.tooling) debugImplementation(libs.compose.ui.test.manifest) diff --git a/app/src/main/AndroidManifest.xml b/app/src/main/AndroidManifest.xml index 11cc85c..8139aae 100644 --- a/app/src/main/AndroidManifest.xml +++ b/app/src/main/AndroidManifest.xml @@ -56,6 +56,16 @@ + + + + + + (null) + private val newChatInFlight = java.util.concurrent.atomic.AtomicBoolean(false) override fun onCreate(savedInstanceState: Bundle?) { super.onCreate(savedInstanceState) - pendingRoute.value = intent?.getStringExtra("extra_route") - ?: intent?.data?.let { deepLinkRouteFor(it.toString()) } - intent?.removeExtra("extra_route") - intent?.data = null + val dlData = intent?.data + if (dlData != null && isNewChatLink(dlData.toString())) { + openNewChat() + intent?.data = null + } else { + pendingRoute.value = intent?.getStringExtra("extra_route") + ?: dlData?.let { deepLinkRouteFor(it.toString()) } + intent?.removeExtra("extra_route") + intent?.data = null + } handleShare(intent) val hasConfig = credentialStore.load() != null val crashReport = CrashReporter.read(this) @@ -123,10 +131,16 @@ class MainActivity : ComponentActivity() { override fun onNewIntent(intent: Intent) { super.onNewIntent(intent) setIntent(intent) - pendingRoute.value = intent.getStringExtra("extra_route") - ?: intent.data?.let { deepLinkRouteFor(it.toString()) } - intent.removeExtra("extra_route") - intent.data = null + val dlData = intent.data + if (dlData != null && isNewChatLink(dlData.toString())) { + openNewChat() + intent.data = null + } else { + pendingRoute.value = intent.getStringExtra("extra_route") + ?: dlData?.let { deepLinkRouteFor(it.toString()) } + intent.removeExtra("extra_route") + intent.data = null + } handleShare(intent) } @@ -140,6 +154,29 @@ class MainActivity : ComponentActivity() { startActivity(Intent.createChooser(intent, "Share crash report")) } + /** Create a fresh chat and navigate to it (widget "New chat" / hermes://new). No-op if unconfigured. */ + private fun openNewChat() { + if (credentialStore.load() == null) return + if (!newChatInFlight.compareAndSet(false, true)) return // a create is already running — ignore repeat taps + lifecycleScope.launch { + try { + chat.connect() // idempotent; a cold start has no socket yet + runCatching { + profileManager.refresh() // load active profile so the session isn't orphaned to default + chat.createSession(profileManager.active.value) + }.onSuccess { id -> pendingRoute.value = "chat/$id" } + .onFailure { e -> + if (e is kotlinx.coroutines.CancellationException) throw e + android.widget.Toast.makeText( + this@MainActivity, "Couldn't start a chat", android.widget.Toast.LENGTH_SHORT, + ).show() + } + } finally { + newChatInFlight.set(false) + } + } + } + /** * Handle an incoming ACTION_SEND share (text or a single image): open a new chat with the text * pre-filled and/or the image attached. Reuses the notification deep-link rail. diff --git a/app/src/main/java/com/hermes/client/ui/nav/DeepLinkMapper.kt b/app/src/main/java/com/hermes/client/ui/nav/DeepLinkMapper.kt index e15dae5..6475a7d 100644 --- a/app/src/main/java/com/hermes/client/ui/nav/DeepLinkMapper.kt +++ b/app/src/main/java/com/hermes/client/ui/nav/DeepLinkMapper.kt @@ -19,3 +19,11 @@ fun deepLinkRouteFor(raw: String): String? { else -> null } } + +/** True for a `hermes://new` link (widget "New chat"). Not a nav route — the caller runs createSession. */ +fun isNewChatLink(raw: String): Boolean { + val uri = runCatching { java.net.URI(raw) }.getOrNull() ?: return false + if (!"hermes".equals(uri.scheme, ignoreCase = true) || uri.host != "new") return false + val segs = uri.path.orEmpty().split('/').filter { it.isNotBlank() } + return segs.isEmpty() +} diff --git a/app/src/main/java/com/hermes/client/widget/HermesWidget.kt b/app/src/main/java/com/hermes/client/widget/HermesWidget.kt new file mode 100644 index 0000000..4dca6cf --- /dev/null +++ b/app/src/main/java/com/hermes/client/widget/HermesWidget.kt @@ -0,0 +1,57 @@ +package com.hermes.client.widget + +import android.content.Context +import android.content.Intent +import android.net.Uri +import androidx.compose.runtime.Composable +import androidx.compose.ui.graphics.Color +import androidx.compose.ui.unit.dp +import androidx.compose.ui.unit.sp +import androidx.glance.GlanceId +import androidx.glance.GlanceModifier +import androidx.glance.action.clickable +import androidx.glance.appwidget.GlanceAppWidget +import androidx.glance.appwidget.action.actionStartActivity +import androidx.glance.appwidget.provideContent +import androidx.glance.background +import androidx.glance.layout.Alignment +import androidx.glance.layout.Column +import androidx.glance.layout.fillMaxSize +import androidx.glance.layout.fillMaxWidth +import androidx.glance.layout.padding +import androidx.glance.text.Text +import androidx.glance.text.TextAlign +import androidx.glance.text.TextStyle +import androidx.glance.unit.ColorProvider + +class HermesWidget : GlanceAppWidget() { + override suspend fun provideGlance(context: Context, id: GlanceId) { + provideContent { Content(context) } + } + + @Composable + private fun Content(context: Context) { + Column( + modifier = GlanceModifier.fillMaxSize() + .background(ColorProvider(Color(0xFF3B3BAF))) + .padding(10.dp), + verticalAlignment = Alignment.CenterVertically, + ) { + Item(context, "New chat", "hermes://new") + Item(context, "Chats", "hermes://tab/sessions") + Item(context, "Home", "hermes://tab/activity") + } + } + + @Composable + private fun Item(context: Context, label: String, uri: String) { + val intent = Intent(Intent.ACTION_VIEW, Uri.parse(uri)).setPackage(context.packageName) + Text( + text = label, + modifier = GlanceModifier.fillMaxWidth() + .padding(vertical = 6.dp) + .clickable(actionStartActivity(intent)), + style = TextStyle(color = ColorProvider(Color.White), fontSize = 16.sp, textAlign = TextAlign.Center), + ) + } +} diff --git a/app/src/main/java/com/hermes/client/widget/HermesWidgetReceiver.kt b/app/src/main/java/com/hermes/client/widget/HermesWidgetReceiver.kt new file mode 100644 index 0000000..7df7284 --- /dev/null +++ b/app/src/main/java/com/hermes/client/widget/HermesWidgetReceiver.kt @@ -0,0 +1,8 @@ +package com.hermes.client.widget + +import androidx.glance.appwidget.GlanceAppWidget +import androidx.glance.appwidget.GlanceAppWidgetReceiver + +class HermesWidgetReceiver : GlanceAppWidgetReceiver() { + override val glanceAppWidget: GlanceAppWidget = HermesWidget() +} diff --git a/app/src/main/res/values/strings.xml b/app/src/main/res/values/strings.xml new file mode 100644 index 0000000..d578630 --- /dev/null +++ b/app/src/main/res/values/strings.xml @@ -0,0 +1,4 @@ + + + Quick actions for Hermes + diff --git a/app/src/main/res/xml/hermes_widget_info.xml b/app/src/main/res/xml/hermes_widget_info.xml new file mode 100644 index 0000000..3e9df05 --- /dev/null +++ b/app/src/main/res/xml/hermes_widget_info.xml @@ -0,0 +1,10 @@ + + diff --git a/app/src/test/java/com/hermes/client/ui/nav/DeepLinkMapperTest.kt b/app/src/test/java/com/hermes/client/ui/nav/DeepLinkMapperTest.kt index 2fc9719..192ff89 100644 --- a/app/src/test/java/com/hermes/client/ui/nav/DeepLinkMapperTest.kt +++ b/app/src/test/java/com/hermes/client/ui/nav/DeepLinkMapperTest.kt @@ -1,7 +1,9 @@ package com.hermes.client.ui.nav import org.junit.Assert.assertEquals +import org.junit.Assert.assertFalse import org.junit.Assert.assertNull +import org.junit.Assert.assertTrue import org.junit.Test class DeepLinkMapperTest { @@ -28,4 +30,17 @@ class DeepLinkMapperTest { assertNull(deepLinkRouteFor("")) assertNull(deepLinkRouteFor("not a uri at all ::: %%%")) } + + @Test fun new_chat_link_recognised() { + assertTrue(isNewChatLink("hermes://new")) + assertTrue(isNewChatLink("HERMES://new")) + } + + @Test fun non_new_links_are_false() { + assertFalse(isNewChatLink("hermes://new/x")) + assertFalse(isNewChatLink("hermes://tab/sessions")) + assertFalse(isNewChatLink("http://new")) + assertFalse(isNewChatLink("")) + assertFalse(isNewChatLink("garbage ::: %%")) + } } diff --git a/docs/superpowers/plans/2026-07-17-home-widget.md b/docs/superpowers/plans/2026-07-17-home-widget.md new file mode 100644 index 0000000..4120baf --- /dev/null +++ b/docs/superpowers/plans/2026-07-17-home-widget.md @@ -0,0 +1,299 @@ +# Home-Screen Quick-Launch Widget Implementation Plan + +> **For agentic workers:** REQUIRED SUB-SKILL: superpowers:subagent-driven-development. Checkbox steps. + +**Goal:** A Glance widget with New chat / Chats / Home buttons; New chat via a new `hermes://new` verb reusing the share rail. + +**Spec:** `docs/superpowers/specs/2026-07-17-home-widget-design.md` + +## Global Constraints +- Client-only; static widget theme (no per-tenant accent in Glance); no AI attribution. +- Build env: `export JAVA_HOME="/Applications/Android Studio.app/Contents/jbr/Contents/Home"`. +- Branch `feature/home-widget` (off `dev`). + +--- + +### Task 1: `isNewChatLink` predicate + +**Files:** Modify `app/src/main/java/com/hermes/client/ui/nav/DeepLinkMapper.kt`; Test `app/src/test/java/com/hermes/client/ui/nav/DeepLinkMapperTest.kt` (extend) + +- [ ] **Step 1: Add failing tests** to `DeepLinkMapperTest.kt`: +```kotlin + @Test fun new_chat_link_recognised() { + assertTrue(isNewChatLink("hermes://new")) + assertTrue(isNewChatLink("HERMES://new")) + } + + @Test fun non_new_links_are_false() { + assertFalse(isNewChatLink("hermes://new/x")) + assertFalse(isNewChatLink("hermes://tab/sessions")) + assertFalse(isNewChatLink("http://new")) + assertFalse(isNewChatLink("")) + assertFalse(isNewChatLink("garbage ::: %%")) + } +``` +(Add `import org.junit.Assert.assertTrue` / `assertFalse` if not already present.) + +- [ ] **Step 2:** Run `./gradlew :app:testDebugUnitTest --tests "com.hermes.client.ui.nav.DeepLinkMapperTest"` → FAIL (unresolved `isNewChatLink`). + +- [ ] **Step 3: Implement** — append to `DeepLinkMapper.kt`: +```kotlin +/** True for a `hermes://new` link (widget "New chat"). Not a nav route — the caller runs createSession. */ +fun isNewChatLink(raw: String): Boolean { + val uri = runCatching { java.net.URI(raw) }.getOrNull() ?: return false + return "hermes".equals(uri.scheme, ignoreCase = true) && uri.host == "new" +} +``` + +- [ ] **Step 4:** Run the test → PASS. + +- [ ] **Step 5: Commit** +```bash +git add app/src/main/java/com/hermes/client/ui/nav/DeepLinkMapper.kt \ + app/src/test/java/com/hermes/client/ui/nav/DeepLinkMapperTest.kt +git commit -m "feat: recognise hermes://new deep link" +``` + +--- + +### Task 2: `openNewChat` + `hermes://new` handling in MainActivity + +**Files:** Modify `app/src/main/java/com/hermes/client/MainActivity.kt` + +**Interfaces:** Consumes `isNewChatLink` (Task 1); reuses existing `chat`, `profileManager`, `credentialStore`, `pendingRoute`, `lifecycleScope`. + +- [ ] **Step 1: Add the import** +```kotlin +import com.hermes.client.ui.nav.isNewChatLink +``` +(`deepLinkRouteFor` is already imported from the deep-links wave.) + +- [ ] **Step 2: Add `openNewChat()`** as a private method (near `handleShare`): +```kotlin + /** Create a fresh chat and navigate to it (widget "New chat" / hermes://new). No-op if unconfigured. */ + private fun openNewChat() { + if (credentialStore.load() == null) return + lifecycleScope.launch { + chat.connect() // idempotent; a cold start has no socket yet + runCatching { + profileManager.refresh() // load active profile so the session isn't orphaned to default + chat.createSession(profileManager.active.value) + }.onSuccess { id -> pendingRoute.value = "chat/$id" } + .onFailure { e -> + if (e is kotlinx.coroutines.CancellationException) throw e + android.widget.Toast.makeText( + this@MainActivity, "Couldn't start a chat", android.widget.Toast.LENGTH_SHORT, + ).show() + } + } + } +``` + +- [ ] **Step 3: Branch on `hermes://new` in onCreate.** Replace the existing deep-link read block in `onCreate`: +```kotlin + pendingRoute.value = intent?.getStringExtra("extra_route") + ?: intent?.data?.let { deepLinkRouteFor(it.toString()) } + intent?.removeExtra("extra_route") + intent?.data = null +``` +with: +```kotlin + val dlData = intent?.data + if (dlData != null && isNewChatLink(dlData.toString())) { + openNewChat() + intent?.data = null + } else { + pendingRoute.value = intent?.getStringExtra("extra_route") + ?: dlData?.let { deepLinkRouteFor(it.toString()) } + intent?.removeExtra("extra_route") + intent?.data = null + } +``` + +- [ ] **Step 4: Same branch in onNewIntent.** Replace the equivalent block: +```kotlin + pendingRoute.value = intent.getStringExtra("extra_route") + ?: intent.data?.let { deepLinkRouteFor(it.toString()) } + intent.removeExtra("extra_route") + intent.data = null +``` +with: +```kotlin + val dlData = intent.data + if (dlData != null && isNewChatLink(dlData.toString())) { + openNewChat() + intent.data = null + } else { + pendingRoute.value = intent.getStringExtra("extra_route") + ?: dlData?.let { deepLinkRouteFor(it.toString()) } + intent.removeExtra("extra_route") + intent.data = null + } +``` + +- [ ] **Step 5: Compile** — `JAVA_HOME=… ./gradlew :app:compileDebugKotlin` → BUILD SUCCESSFUL. + +- [ ] **Step 6: Commit** +```bash +git add app/src/main/java/com/hermes/client/MainActivity.kt +git commit -m "feat: handle hermes://new to start a new chat" +``` + +--- + +### Task 3: Glance widget + dependency + manifest + +**Files:** +- Modify: `gradle/libs.versions.toml`, `app/build.gradle.kts` +- New: `app/src/main/java/com/hermes/client/widget/HermesWidget.kt`, `HermesWidgetReceiver.kt` +- New: `app/src/main/res/xml/hermes_widget_info.xml` +- Modify: `app/src/main/res/values/strings.xml` (create if absent), `app/src/main/AndroidManifest.xml` +- Test: none new (Android glue). Compile + full suite + assembleBeta + Task 4. + +- [ ] **Step 1: Add the Glance dependency** + +`gradle/libs.versions.toml`: under `[versions]` add `glance = "1.1.1"`; under `[libraries]` add: +```toml +glance-appwidget = { module = "androidx.glance:glance-appwidget", version.ref = "glance" } +``` +`app/build.gradle.kts` (in `dependencies {}`): `implementation(libs.glance.appwidget)`. + +- [ ] **Step 2: Create the widget** + +`app/src/main/java/com/hermes/client/widget/HermesWidget.kt`: +```kotlin +package com.hermes.client.widget + +import android.content.Context +import android.content.Intent +import android.net.Uri +import androidx.compose.runtime.Composable +import androidx.compose.ui.graphics.Color +import androidx.compose.ui.unit.dp +import androidx.compose.ui.unit.sp +import androidx.glance.GlanceId +import androidx.glance.action.clickable +import androidx.glance.GlanceModifier +import androidx.glance.appwidget.GlanceAppWidget +import androidx.glance.appwidget.GlanceAppWidgetReceiver +import androidx.glance.appwidget.action.actionStartActivity +import androidx.glance.appwidget.provideContent +import androidx.glance.background +import androidx.glance.layout.Alignment +import androidx.glance.layout.Column +import androidx.glance.layout.fillMaxSize +import androidx.glance.layout.fillMaxWidth +import androidx.glance.layout.padding +import androidx.glance.text.Text +import androidx.glance.text.TextAlign +import androidx.glance.text.TextStyle +import androidx.glance.unit.ColorProvider + +class HermesWidget : GlanceAppWidget() { + override suspend fun provideGlance(context: Context, id: GlanceId) { + provideContent { Content(context) } + } + + @Composable + private fun Content(context: Context) { + Column( + modifier = GlanceModifier.fillMaxSize() + .background(ColorProvider(Color(0xFF3B3BAF))) + .padding(10.dp), + verticalAlignment = Alignment.CenterVertically, + ) { + Item(context, "New chat", "hermes://new") + Item(context, "Chats", "hermes://tab/sessions") + Item(context, "Home", "hermes://tab/activity") + } + } + + @Composable + private fun Item(context: Context, label: String, uri: String) { + val intent = Intent(Intent.ACTION_VIEW, Uri.parse(uri)).setPackage(context.packageName) + Text( + text = label, + modifier = GlanceModifier.fillMaxWidth() + .padding(vertical = 6.dp) + .clickable(actionStartActivity(intent)), + style = TextStyle(color = ColorProvider(Color.White), fontSize = 16.sp, textAlign = TextAlign.Center), + ) + } +} + +class HermesWidgetReceiver : GlanceAppWidgetReceiver() { + override val glanceAppWidget: GlanceAppWidget = HermesWidget() +} +``` +NOTE: the exact Glance imports/API for the resolved `glance-appwidget` version take precedence — if `padding`, `clickable`, `background`, `ColorProvider`, or `actionStartActivity` signatures differ, adjust imports/calls so it compiles. `GlanceModifier.clickable` is `androidx.glance.action.clickable`; `padding(vertical = 6.dp)` uses `androidx.compose.ui.unit.dp`. Keep the three buttons + their `hermes://` URIs and the explicit `setPackage` — that is the contract. (Put `HermesWidgetReceiver` in its own file `HermesWidgetReceiver.kt` if the reviewer prefers; either is fine.) + +- [ ] **Step 3: Provider XML** + +`app/src/main/res/xml/hermes_widget_info.xml`: +```xml + + +``` + +- [ ] **Step 4: String resource** + +Ensure `app/src/main/res/values/strings.xml` exists; add: +```xml + Quick actions for Hermes +``` +(If `strings.xml` doesn't exist, create it with a `` root containing this string.) + +- [ ] **Step 5: Manifest receiver** + +In `AndroidManifest.xml`, inside `` (near the existing `NotificationActionReceiver`), add: +```xml + + + + + + +``` + +- [ ] **Step 6: Compile + full suite + assembleBeta** + +Run each (JAVA_HOME set): `:app:compileDebugKotlin`, `:app:testDebugUnitTest` (0 failures), `:app:assembleBeta` — all BUILD SUCCESSFUL. **If the Glance dependency version fails to resolve or an API signature differs, adjust the version and imports minimally until it builds; the widget's three buttons + hermes:// targets are the contract.** + +- [ ] **Step 7: Commit** +```bash +git add gradle/libs.versions.toml app/build.gradle.kts \ + app/src/main/java/com/hermes/client/widget/ \ + app/src/main/res/xml/hermes_widget_info.xml \ + app/src/main/res/values/strings.xml \ + app/src/main/AndroidManifest.xml +git commit -m "feat: add home-screen quick-launch widget (Glance)" +``` + +--- + +### Task 4: On-device verification (best-effort) + +- [ ] **Step 1:** `:app:installBeta` (target `emulator-5554` if multiple devices). +- [ ] **Step 2:** `adb -s emulator-5554 shell am start -a android.intent.action.VIEW -d "hermes://new" com.hermes.client.beta` → if configured, a new chat opens; if not configured, no crash (verify `pidof`). +- [ ] **Step 3:** `adb -s emulator-5554 shell dumpsys appwidget | grep -i hermes` → the `HermesWidgetReceiver` provider is registered. +- [ ] **Step 4:** (Best-effort) confirm the widget appears in the launcher's widget picker; placing + tapping on the emulator is optional — the `hermes://` launch targets are already verified from the deep-links wave. +- [ ] **Step 5:** Record pass/fail in the PR description. + +--- + +## Notes for the executor +- Do NOT add a "needs you" dynamic glance, per-tenant accent, or a "scan" button — explicit anti-scope. +- The widget's launch intents are explicit (`setPackage(self)`), so they can only open this app. +- If Glance `1.1.1` doesn't resolve, use the newest `androidx.glance:glance-appwidget` 1.x that builds against AGP 9.1 / Compose BOM 2025.12. diff --git a/docs/superpowers/specs/2026-07-17-home-widget-design.md b/docs/superpowers/specs/2026-07-17-home-widget-design.md new file mode 100644 index 0000000..59cb8b7 --- /dev/null +++ b/docs/superpowers/specs/2026-07-17-home-widget-design.md @@ -0,0 +1,134 @@ +# Home-Screen Quick-Launch Widget — Design + +**Wave:** Quick-wins (client-only). **Branch:** `feature/home-widget` (off `dev`). + +**Goal:** A home-screen widget with quick-launch buttons — **New chat**, **Chats**, **Home** — using Jetpack Glance. Reuses the `hermes://` deep links (just shipped) for launch, plus one new `hermes://new` verb for new-chat. Client-only. + +**Constraints:** Kotlin/Compose/Glance/Hilt. No AI attribution; gitleaks before push; PR into `dev`. + +## Scope +- **In:** a Glance `GlanceAppWidget` + receiver + provider XML + manifest receiver; the `androidx.glance:glance-appwidget` dependency; a `hermes://new` verb + `MainActivity.openNewChat()` reusing the existing share rail. +- **Out (deferred):** a dynamic "needs you" glance (no persisted attention count exists — all live socket/network; would need a persisted counter first); per-tenant accent in the widget (not available in Glance — use a static theme); a "scan" button (setup-time action, not a daily quick-launch); resizable rich content. + +## Architecture + +### 1. `hermes://new` verb — `ui/nav/DeepLinkMapper.kt` (modify) +`hermes://new` is **not** a static nav route (new-chat is an async `createSession` RPC), so it isn't handled by `deepLinkRouteFor`. Add a pure predicate: +```kotlin +fun isNewChatLink(raw: String): Boolean { + val uri = runCatching { java.net.URI(raw) }.getOrNull() ?: return false + return "hermes".equals(uri.scheme, ignoreCase = true) && uri.host == "new" +} +``` + +### 2. `MainActivity.kt` (modify) — handle `hermes://new` +Extract the new-chat rail from `handleShare` into a reusable helper (identical `connect → refresh → createSession → pendingRoute` sequence, minus the share payload): +```kotlin +/** Create a fresh chat and navigate to it (widget "New chat" / hermes://new). No-op if unconfigured. */ +private fun openNewChat() { + if (credentialStore.load() == null) return + lifecycleScope.launch { + chat.connect() // idempotent; a cold start has no socket yet + runCatching { + profileManager.refresh() // load active profile so the session isn't orphaned to default + chat.createSession(profileManager.active.value) + }.onSuccess { id -> pendingRoute.value = "chat/$id" } + .onFailure { e -> + if (e is kotlinx.coroutines.CancellationException) throw e + android.widget.Toast.makeText(this@MainActivity, "Couldn't start a chat", android.widget.Toast.LENGTH_SHORT).show() + } + } +} +``` +In `onCreate` and `onNewIntent`, branch on the incoming `hermes://new` link before the existing route handling: +```kotlin +val data = intent?.data +if (data != null && isNewChatLink(data.toString())) { + openNewChat() + intent?.data = null +} else { + pendingRoute.value = intent?.getStringExtra("extra_route") + ?: data?.let { deepLinkRouteFor(it.toString()) } + intent?.removeExtra("extra_route") + intent?.data = null +} +``` +(`onNewIntent` uses the non-null `intent`; same branch.) + +### 3. Widget — `widget/HermesWidget.kt` + `widget/HermesWidgetReceiver.kt` (new) +A `GlanceAppWidget` rendering three buttons; each starts an **explicit** VIEW intent (`setPackage(context.packageName)`) into the already-`singleTask` MainActivity: +```kotlin +class HermesWidget : GlanceAppWidget() { + override suspend fun provideGlance(context: Context, id: GlanceId) { + provideContent { Content(context) } + } + + @Composable + private fun Content(context: Context) { + Column( + modifier = GlanceModifier.fillMaxSize().background(ColorProvider(Color(0xFF3B3BAF))).padding(10.dp), + verticalAlignment = Alignment.CenterVertically, + ) { + Item(context, "New chat", "hermes://new") + Item(context, "Chats", "hermes://tab/sessions") + Item(context, "Home", "hermes://tab/activity") + } + } + + @Composable + private fun Item(context: Context, label: String, uri: String) { + val intent = Intent(Intent.ACTION_VIEW, Uri.parse(uri)).setPackage(context.packageName) + Text( + label, + modifier = GlanceModifier.fillMaxWidth().padding(vertical = 6.dp).clickable(actionStartActivity(intent)), + style = TextStyle(color = ColorProvider(Color.White), fontSize = 16.sp, textAlign = TextAlign.Center), + ) + } +} + +class HermesWidgetReceiver : GlanceAppWidgetReceiver() { + override val glanceAppWidget: GlanceAppWidget = HermesWidget() +} +``` +(Static color — per-tenant accent is a Compose-runtime local unavailable in Glance. Exact Glance API/imports may need minor adjustment to the resolved `glance-appwidget` version; behavior is the contract.) + +### 4. Resources + manifest +- `res/xml/hermes_widget_info.xml` — `` (minWidth/Height, `resizeMode`, `widgetCategory="home_screen"`, `initialLayout="@layout/glance_default_loading_layout"` which `glance-appwidget` provides, a `description` string). +- `res/values/strings.xml` — add `Quick actions for Hermes`. +- `AndroidManifest.xml` — a `` with an `APPWIDGET_UPDATE` intent-filter + `android.appwidget.provider` meta-data (mirror the existing `NotificationActionReceiver` receiver block). + +### 5. Dependency +`gradle/libs.versions.toml`: `glance = "1.1.1"` + `glance-appwidget = { module = "androidx.glance:glance-appwidget", version.ref = "glance" }`. `app/build.gradle.kts`: `implementation(libs.glance.appwidget)`. (If 1.1.1 doesn't resolve against the current AGP/Compose, bump to the latest 1.x that does.) + +## Data flow +``` +widget button tap → actionStartActivity(VIEW hermes://…, pkg=self) → MainActivity (singleTask, onNewIntent/onCreate) + hermes://new → isNewChatLink → openNewChat() → connect→refresh→createSession → pendingRoute="chat/$id" → nav + hermes://tab/sessions | tab/activity → deepLinkRouteFor → pendingRoute → nav +``` + +## Error handling +- `openNewChat` when unconfigured → no-op (returns early). On a createSession failure → toast, no crash (mirrors share). +- Widget buttons target an explicit intent (setPackage self) — can't be hijacked. +- Deep-link routes reuse the already-hardened `runCatching { nav.navigate }`. + +## Testing +- **`DeepLinkMapperTest`** (extend, pure): `isNewChatLink("hermes://new")` → true; `hermes://new/x`, `hermes://tab/sessions`, `http://new`, blank/garbage → false. +- Glance widget, receiver, `openNewChat`, and MainActivity intent glue are Android — verified on-device (best-effort). + +## On-device verification +- `adb shell am start -a android.intent.action.VIEW -d "hermes://new" com.hermes.client.beta` → opens a new chat (if configured) / no crash if not. +- Confirm the widget is registered: `adb shell dumpsys appwidget | grep -i hermes` (provider present), and it appears in the launcher's widget picker. Placing + tapping on the emulator is best-effort; the deep-link targets are already verified from wave 2. + +## Files +| Action | Path | +|--------|------| +| Modify | `ui/nav/DeepLinkMapper.kt` (`isNewChatLink`) + `DeepLinkMapperTest.kt` | +| Modify | `MainActivity.kt` (`openNewChat` + hermes://new branch) | +| New | `widget/HermesWidget.kt`, `widget/HermesWidgetReceiver.kt` | +| New | `res/xml/hermes_widget_info.xml`; add `widget_description` to `res/values/strings.xml` | +| Modify | `AndroidManifest.xml` (receiver) | +| Modify | `gradle/libs.versions.toml`, `app/build.gradle.kts` (glance dep) | + +## Build & gates +`JAVA_HOME=…`: `:app:compileDebugKotlin`, `:app:testDebugUnitTest`, `:app:assembleBeta`. gitleaks before push; PR into `dev`. diff --git a/gradle/libs.versions.toml b/gradle/libs.versions.toml index 1de429a..fa6ae0d 100644 --- a/gradle/libs.versions.toml +++ b/gradle/libs.versions.toml @@ -30,6 +30,7 @@ androidxTestCore = "1.7.0" androidxTestJunit = "1.3.0" androidxTestRunner = "1.7.0" zxingEmbedded = "4.3.0" +glance = "1.1.1" [libraries] core-ktx = { module = "androidx.core:core-ktx", version.ref = "coreKtx" } @@ -68,6 +69,7 @@ androidx-test-core = { module = "androidx.test:core-ktx", version.ref = "android androidx-test-junit = { module = "androidx.test.ext:junit", version.ref = "androidxTestJunit" } androidx-test-runner = { module = "androidx.test:runner", version.ref = "androidxTestRunner" } zxing-embedded = { module = "com.journeyapps:zxing-android-embedded", version.ref = "zxingEmbedded" } +glance-appwidget = { module = "androidx.glance:glance-appwidget", version.ref = "glance" } [plugins] android-application = { id = "com.android.application", version.ref = "agp" } From 7517bf9565fd2f52ef6fd2ef3befff3f62cda880 Mon Sep 17 00:00:00 2001 From: Andrew Debnar <606931+adebnar@users.noreply.github.com> Date: Fri, 17 Jul 2026 17:45:38 +0000 Subject: [PATCH 10/16] feat: prompt/snippet library (#104) * docs: spec + plan for prompt/snippet library * feat: SavedPrompt model + pure prompt-list helpers * feat: PromptStore + prompt-library/chat view-model wiring * feat: saved-prompts manage screen + composer picker --- .../client/data/repository/PromptStore.kt | 51 +++ .../java/com/hermes/client/di/AppModule.kt | 5 + .../com/hermes/client/ui/chat/ChatScreen.kt | 35 ++ .../hermes/client/ui/chat/ChatViewModel.kt | 5 + .../com/hermes/client/ui/nav/HermesNav.kt | 3 + .../client/ui/settings/PromptLibraryScreen.kt | 139 ++++++++ .../client/ui/settings/SettingsScreen.kt | 2 + .../client/data/repository/PromptStoreTest.kt | 31 ++ .../client/ui/chat/ChatViewModelTest.kt | 4 +- .../ui/settings/PromptLibraryViewModelTest.kt | 46 +++ .../plans/2026-07-17-prompt-library.md | 312 ++++++++++++++++++ .../specs/2026-07-17-prompt-library-design.md | 85 +++++ 12 files changed, 717 insertions(+), 1 deletion(-) create mode 100644 app/src/main/java/com/hermes/client/data/repository/PromptStore.kt create mode 100644 app/src/main/java/com/hermes/client/ui/settings/PromptLibraryScreen.kt create mode 100644 app/src/test/java/com/hermes/client/data/repository/PromptStoreTest.kt create mode 100644 app/src/test/java/com/hermes/client/ui/settings/PromptLibraryViewModelTest.kt create mode 100644 docs/superpowers/plans/2026-07-17-prompt-library.md create mode 100644 docs/superpowers/specs/2026-07-17-prompt-library-design.md diff --git a/app/src/main/java/com/hermes/client/data/repository/PromptStore.kt b/app/src/main/java/com/hermes/client/data/repository/PromptStore.kt new file mode 100644 index 0000000..e40e747 --- /dev/null +++ b/app/src/main/java/com/hermes/client/data/repository/PromptStore.kt @@ -0,0 +1,51 @@ +package com.hermes.client.data.repository + +import android.content.Context +import androidx.datastore.preferences.core.edit +import androidx.datastore.preferences.core.emptyPreferences +import androidx.datastore.preferences.core.stringPreferencesKey +import androidx.datastore.preferences.preferencesDataStore +import kotlinx.coroutines.flow.Flow +import kotlinx.coroutines.flow.catch +import kotlinx.coroutines.flow.map +import kotlinx.serialization.Serializable +import kotlinx.serialization.decodeFromString +import kotlinx.serialization.encodeToString +import kotlinx.serialization.json.Json +import java.io.IOException + +/** A user-saved reusable prompt (device-local). */ +@Serializable +data class SavedPrompt(val id: String, val title: String, val body: String) + +private val promptJson = Json { ignoreUnknownKeys = true } + +/** Decode the stored JSON list; never throws — corrupt/absent → empty. */ +fun decodePrompts(raw: String?): List = + runCatching { promptJson.decodeFromString>(raw ?: "[]") }.getOrDefault(emptyList()) + +fun encodePrompts(list: List): String = promptJson.encodeToString(list) + +/** Replace the element whose id matches [p], preserving position; otherwise append. */ +fun upsertPrompt(list: List, p: SavedPrompt): List = + if (list.any { it.id == p.id }) list.map { if (it.id == p.id) p else it } else list + p + +fun deletePrompt(list: List, id: String): List = list.filterNot { it.id == id } + +private val Context.promptDataStore by preferencesDataStore(name = "saved_prompts") + +/** Device-local, global store of the user's saved prompts (JSON list under one key). */ +class PromptStore(private val context: Context) { + private val key = stringPreferencesKey("prompts") + + val prompts: Flow> = + context.promptDataStore.data + .catch { e -> if (e is IOException) emit(emptyPreferences()) else throw e } + .map { decodePrompts(it[key]) } + + suspend fun upsert(p: SavedPrompt) = + context.promptDataStore.edit { it[key] = encodePrompts(upsertPrompt(decodePrompts(it[key]), p)) } + + suspend fun delete(id: String) = + context.promptDataStore.edit { it[key] = encodePrompts(deletePrompt(decodePrompts(it[key]), id)) } +} diff --git a/app/src/main/java/com/hermes/client/di/AppModule.kt b/app/src/main/java/com/hermes/client/di/AppModule.kt index d1391d2..0dccf81 100644 --- a/app/src/main/java/com/hermes/client/di/AppModule.kt +++ b/app/src/main/java/com/hermes/client/di/AppModule.kt @@ -219,4 +219,9 @@ object AppModule { @ApplicationContext context: Context, ): com.hermes.client.data.tts.TextToSpeechController = com.hermes.client.data.tts.AndroidTtsManager(context) + + @Provides + @Singleton + fun providePromptStore(@ApplicationContext context: Context): com.hermes.client.data.repository.PromptStore = + com.hermes.client.data.repository.PromptStore(context) } diff --git a/app/src/main/java/com/hermes/client/ui/chat/ChatScreen.kt b/app/src/main/java/com/hermes/client/ui/chat/ChatScreen.kt index 2de3f92..da6a123 100644 --- a/app/src/main/java/com/hermes/client/ui/chat/ChatScreen.kt +++ b/app/src/main/java/com/hermes/client/ui/chat/ChatScreen.kt @@ -32,6 +32,7 @@ import androidx.compose.foundation.layout.Arrangement import androidx.compose.foundation.text.KeyboardActions import androidx.compose.foundation.text.KeyboardOptions import androidx.compose.material.icons.Icons +import androidx.compose.material.icons.automirrored.rounded.NoteAdd import androidx.compose.material.icons.automirrored.rounded.Send import androidx.compose.material.icons.rounded.ArrowDropDown import androidx.compose.material.icons.rounded.AttachFile @@ -48,8 +49,11 @@ import androidx.compose.material3.DropdownMenuItem import androidx.compose.material3.ExperimentalMaterial3Api import androidx.compose.material3.Icon import androidx.compose.material3.IconButton +import androidx.compose.material3.ListItem import androidx.compose.material3.MaterialTheme +import androidx.compose.material3.ModalBottomSheet import androidx.compose.material3.minimumInteractiveComponentSize +import androidx.compose.material3.rememberModalBottomSheetState import com.hermes.client.ui.theme.LocalProfileAccent import androidx.compose.ui.hapticfeedback.HapticFeedbackType import androidx.compose.ui.platform.LocalHapticFeedback @@ -103,6 +107,8 @@ fun ChatScreen( val commands by vm.commands.collectAsStateWithLifecycle() val pathItems by vm.pathItems.collectAsStateWithLifecycle() val speaking by vm.speaking.collectAsStateWithLifecycle() + val savedPrompts by vm.savedPrompts.collectAsStateWithLifecycle() + var showPromptSheet by remember { mutableStateOf(false) } androidx.compose.runtime.DisposableEffect(Unit) { onDispose { vm.stopReading() } } var draft by remember { mutableStateOf("") } var searchOpen by rememberSaveable { mutableStateOf(false) } @@ -351,6 +357,9 @@ fun ChatScreen( ) } } + IconButton(onClick = { showPromptSheet = true }) { + Icon(Icons.AutoMirrored.Rounded.NoteAdd, contentDescription = "Saved prompts") + } if (speechAvailable) { IconButton(onClick = { startDictation() }) { Icon( @@ -532,6 +541,32 @@ fun ChatScreen( onDismiss = { modelSheetOpen = false }, ) } + + if (showPromptSheet) { + val promptSheetState = rememberModalBottomSheetState() + ModalBottomSheet(onDismissRequest = { showPromptSheet = false }, sheetState = promptSheetState) { + if (savedPrompts.isEmpty()) { + Text( + "No saved prompts yet — add them in Settings › Saved prompts.", + modifier = Modifier.padding(24.dp), + ) + } else { + LazyColumn(Modifier.fillMaxWidth()) { + items(savedPrompts, key = { it.id }) { p -> + ListItem( + headlineContent = { Text(p.title) }, + supportingContent = { Text(p.body.lineSequence().firstOrNull().orEmpty()) }, + modifier = Modifier.clickable { + draft = if (draft.isBlank()) p.body else draft.trimEnd() + "\n" + p.body + showPromptSheet = false + focusRequester.requestFocus() + }, + ) + } + } + } + } + } } @Composable diff --git a/app/src/main/java/com/hermes/client/ui/chat/ChatViewModel.kt b/app/src/main/java/com/hermes/client/ui/chat/ChatViewModel.kt index 0e6b282..2634a71 100644 --- a/app/src/main/java/com/hermes/client/ui/chat/ChatViewModel.kt +++ b/app/src/main/java/com/hermes/client/ui/chat/ChatViewModel.kt @@ -34,6 +34,7 @@ class ChatViewModel @Inject constructor( private val favoritesStore: com.hermes.client.data.repository.ModelFavoritesStore, private val pendingShareStore: com.hermes.client.share.PendingShareStore, private val tts: com.hermes.client.data.tts.TextToSpeechController, + private val promptStore: com.hermes.client.data.repository.PromptStore, ) : ViewModel() { private val _state = MutableStateFlow(ChatUiState.empty()) @@ -76,6 +77,10 @@ class ChatViewModel @Inject constructor( /** Stop any current read-aloud. */ fun stopReading() = tts.stop() + /** Device-local saved prompts, for the composer's prompt picker. */ + val savedPrompts: kotlinx.coroutines.flow.StateFlow> = + promptStore.prompts.stateIn(viewModelScope, kotlinx.coroutines.flow.SharingStarted.WhileSubscribed(5_000), emptyList()) + data class ModelSheetUi( val query: String = "", val scope: com.hermes.client.ui.models.ModelScope = com.hermes.client.ui.models.ModelScope.SESSION, diff --git a/app/src/main/java/com/hermes/client/ui/nav/HermesNav.kt b/app/src/main/java/com/hermes/client/ui/nav/HermesNav.kt index f1ab1dd..30a33da 100644 --- a/app/src/main/java/com/hermes/client/ui/nav/HermesNav.kt +++ b/app/src/main/java/com/hermes/client/ui/nav/HermesNav.kt @@ -260,6 +260,9 @@ fun HermesNav(hasConfig: Boolean, deepLinkRoute: String? = null, onDeepLinkConsu com.hermes.client.ui.settings.NotificationsScreen(onBack = { nav.popBackStack() }) } composable("settings_memory") { MemorySettingsScreen(onBack = { nav.popBackStack() }) } + composable("settings_prompts") { + com.hermes.client.ui.settings.PromptLibraryScreen(onBack = { nav.popBackStack() }) + } composable("settings_mcp") { McpSettingsScreen(onBack = { nav.popBackStack() }) } composable("settings_env") { EnvScreen(onBack = { nav.popBackStack() }) } composable("settings_connection") { diff --git a/app/src/main/java/com/hermes/client/ui/settings/PromptLibraryScreen.kt b/app/src/main/java/com/hermes/client/ui/settings/PromptLibraryScreen.kt new file mode 100644 index 0000000..3db000a --- /dev/null +++ b/app/src/main/java/com/hermes/client/ui/settings/PromptLibraryScreen.kt @@ -0,0 +1,139 @@ +package com.hermes.client.ui.settings + +import androidx.compose.foundation.clickable +import androidx.compose.foundation.layout.fillMaxSize +import androidx.compose.foundation.layout.fillMaxWidth +import androidx.compose.foundation.layout.padding +import androidx.compose.foundation.lazy.LazyColumn +import androidx.compose.foundation.lazy.items +import androidx.compose.material.icons.Icons +import androidx.compose.material.icons.automirrored.rounded.ArrowBack +import androidx.compose.material.icons.rounded.Add +import androidx.compose.material.icons.rounded.Delete +import androidx.compose.material3.AlertDialog +import androidx.compose.material3.ExperimentalMaterial3Api +import androidx.compose.material3.Icon +import androidx.compose.material3.IconButton +import androidx.compose.material3.ListItem +import androidx.compose.material3.OutlinedTextField +import androidx.compose.material3.Scaffold +import androidx.compose.material3.Text +import androidx.compose.material3.TextButton +import androidx.compose.runtime.Composable +import androidx.compose.runtime.getValue +import androidx.compose.runtime.mutableStateOf +import androidx.compose.runtime.remember +import androidx.compose.runtime.setValue +import androidx.compose.ui.Modifier +import androidx.compose.ui.unit.dp +import androidx.hilt.lifecycle.viewmodel.compose.hiltViewModel +import androidx.lifecycle.ViewModel +import androidx.lifecycle.compose.collectAsStateWithLifecycle +import androidx.lifecycle.viewModelScope +import com.hermes.client.data.repository.PromptStore +import com.hermes.client.data.repository.SavedPrompt +import dagger.hilt.android.lifecycle.HiltViewModel +import kotlinx.coroutines.flow.SharingStarted +import kotlinx.coroutines.flow.StateFlow +import kotlinx.coroutines.flow.stateIn +import kotlinx.coroutines.launch +import java.util.UUID +import javax.inject.Inject + +@HiltViewModel +class PromptLibraryViewModel @Inject constructor(private val store: PromptStore) : ViewModel() { + val prompts: StateFlow> = + store.prompts.stateIn(viewModelScope, SharingStarted.WhileSubscribed(5_000), emptyList()) + + /** Create (id == null) or edit an existing prompt. Blank title falls back to the body's first line. */ + fun save(id: String?, title: String, body: String) { + val cleanBody = body.trim() + if (cleanBody.isEmpty() && title.isBlank()) return + val cleanTitle = title.trim().ifBlank { cleanBody.lineSequence().firstOrNull()?.take(60).orEmpty() } + viewModelScope.launch { store.upsert(SavedPrompt(id ?: UUID.randomUUID().toString(), cleanTitle, cleanBody)) } + } + + fun delete(id: String) = viewModelScope.launch { store.delete(id) } +} + +@OptIn(ExperimentalMaterial3Api::class) +@Composable +fun PromptLibraryScreen( + onBack: () -> Unit, + vm: PromptLibraryViewModel = hiltViewModel(), +) { + val prompts by vm.prompts.collectAsStateWithLifecycle() + var editing by remember { mutableStateOf(null) } + var adding by remember { mutableStateOf(false) } + + Scaffold( + topBar = { + com.hermes.client.ui.components.HermesTopBar( + title = "Saved prompts", + navigationIcon = { IconButton(onClick = onBack) { Icon(Icons.AutoMirrored.Rounded.ArrowBack, contentDescription = "Back") } }, + actions = { + IconButton(onClick = { adding = true }) { + Icon(Icons.Rounded.Add, contentDescription = "New") + } + }, + ) + }, + ) { padding -> + if (prompts.isEmpty()) { + Text( + "No saved prompts yet. Tap New to add one.", + modifier = Modifier.padding(padding).padding(24.dp), + ) + } else { + LazyColumn(Modifier.padding(padding).fillMaxSize()) { + items(prompts, key = { it.id }) { p -> + ListItem( + headlineContent = { Text(p.title) }, + supportingContent = { Text(p.body.lineSequence().firstOrNull().orEmpty()) }, + trailingContent = { + IconButton(onClick = { vm.delete(p.id) }) { + Icon(Icons.Rounded.Delete, contentDescription = "Delete") + } + }, + modifier = Modifier.clickable { editing = p }, + ) + } + } + } + } + + if (adding || editing != null) { + val current = editing + var title by remember(current) { mutableStateOf(current?.title.orEmpty()) } + var body by remember(current) { mutableStateOf(current?.body.orEmpty()) } + val dismiss = { adding = false; editing = null } + AlertDialog( + onDismissRequest = dismiss, + title = { Text(if (current == null) "New prompt" else "Edit prompt") }, + text = { + androidx.compose.foundation.layout.Column { + OutlinedTextField( + value = title, + onValueChange = { title = it }, + label = { Text("Title") }, + singleLine = true, + modifier = Modifier.fillMaxWidth(), + ) + OutlinedTextField( + value = body, + onValueChange = { body = it }, + label = { Text("Prompt") }, + modifier = Modifier.fillMaxWidth().padding(top = 8.dp), + ) + } + }, + confirmButton = { + TextButton( + onClick = { vm.save(current?.id, title, body); dismiss() }, + enabled = title.isNotBlank() || body.isNotBlank(), + ) { Text("Save") } + }, + dismissButton = { TextButton(onClick = dismiss) { Text("Cancel") } }, + ) + } +} diff --git a/app/src/main/java/com/hermes/client/ui/settings/SettingsScreen.kt b/app/src/main/java/com/hermes/client/ui/settings/SettingsScreen.kt index 85e2867..cd18453 100644 --- a/app/src/main/java/com/hermes/client/ui/settings/SettingsScreen.kt +++ b/app/src/main/java/com/hermes/client/ui/settings/SettingsScreen.kt @@ -40,6 +40,8 @@ fun SettingsScreen( HorizontalDivider() Entry("Memory & budgets", "Memory, user profile & default model") { onNavigate("settings_memory") } HorizontalDivider() + Entry("Saved prompts", "Reusable prompts for the composer") { onNavigate("settings_prompts") } + HorizontalDivider() Entry("MCP servers", "View and edit connected MCP servers") { onNavigate("settings_mcp") } HorizontalDivider() Entry("API keys & env", "Provider keys and tool env vars") { onNavigate("settings_env") } diff --git a/app/src/test/java/com/hermes/client/data/repository/PromptStoreTest.kt b/app/src/test/java/com/hermes/client/data/repository/PromptStoreTest.kt new file mode 100644 index 0000000..a650edd --- /dev/null +++ b/app/src/test/java/com/hermes/client/data/repository/PromptStoreTest.kt @@ -0,0 +1,31 @@ +package com.hermes.client.data.repository + +import org.junit.Assert.assertEquals +import org.junit.Test + +class PromptStoreTest { + private val a = SavedPrompt("1", "Greet", "Say hello") + private val b = SavedPrompt("2", "Bye", "Say bye") + + @Test fun decode_bad_or_empty_is_empty_list() { + assertEquals(emptyList(), decodePrompts(null)) + assertEquals(emptyList(), decodePrompts("")) + assertEquals(emptyList(), decodePrompts("not json")) + assertEquals(emptyList(), decodePrompts("{}")) + } + + @Test fun encode_decode_round_trip() { + assertEquals(listOf(a, b), decodePrompts(encodePrompts(listOf(a, b)))) + } + + @Test fun upsert_appends_new_and_replaces_existing() { + assertEquals(listOf(a, b), upsertPrompt(listOf(a), b)) + val edited = a.copy(title = "Hi") + assertEquals(listOf(edited, b), upsertPrompt(listOf(a, b), edited)) // replaced in place, order kept + } + + @Test fun delete_removes_by_id_and_noops_when_absent() { + assertEquals(listOf(b), deletePrompt(listOf(a, b), "1")) + assertEquals(listOf(a, b), deletePrompt(listOf(a, b), "nope")) + } +} diff --git a/app/src/test/java/com/hermes/client/ui/chat/ChatViewModelTest.kt b/app/src/test/java/com/hermes/client/ui/chat/ChatViewModelTest.kt index 72b9d2e..bb02277 100644 --- a/app/src/test/java/com/hermes/client/ui/chat/ChatViewModelTest.kt +++ b/app/src/test/java/com/hermes/client/ui/chat/ChatViewModelTest.kt @@ -41,6 +41,7 @@ class ChatViewModelTest { private val favoritesStore = mockk(relaxed = true) private val pendingShareStore = com.hermes.client.share.PendingShareStore() private val tts = mockk(relaxed = true) + private val promptStore = mockk(relaxed = true) @Before fun setUp() { Dispatchers.setMain(StandardTestDispatcher()) @@ -56,9 +57,10 @@ class ChatViewModelTest { coEvery { profileRepo.list() } returns emptyList() every { favoritesStore.favorites } returns MutableStateFlow(emptySet()) every { tts.speaking } returns MutableStateFlow(false) + every { promptStore.prompts } returns MutableStateFlow(emptyList()) } - private fun buildVm() = ChatViewModel(chatRepo, sessionRepo, modelRepo, profileRepo, profileManager, favoritesStore, pendingShareStore, tts) + private fun buildVm() = ChatViewModel(chatRepo, sessionRepo, modelRepo, profileRepo, profileManager, favoritesStore, pendingShareStore, tts, promptStore) @Test fun streamed_delta_appears_in_state() = runTest { val vm = buildVm() diff --git a/app/src/test/java/com/hermes/client/ui/settings/PromptLibraryViewModelTest.kt b/app/src/test/java/com/hermes/client/ui/settings/PromptLibraryViewModelTest.kt new file mode 100644 index 0000000..0b6bd67 --- /dev/null +++ b/app/src/test/java/com/hermes/client/ui/settings/PromptLibraryViewModelTest.kt @@ -0,0 +1,46 @@ +package com.hermes.client.ui.settings + +import com.hermes.client.data.repository.PromptStore +import com.hermes.client.data.repository.SavedPrompt +import io.mockk.coVerify +import io.mockk.every +import io.mockk.mockk +import kotlinx.coroutines.Dispatchers +import kotlinx.coroutines.ExperimentalCoroutinesApi +import kotlinx.coroutines.flow.MutableStateFlow +import kotlinx.coroutines.test.StandardTestDispatcher +import kotlinx.coroutines.test.advanceUntilIdle +import kotlinx.coroutines.test.resetMain +import kotlinx.coroutines.test.runTest +import kotlinx.coroutines.test.setMain +import org.junit.After +import org.junit.Before +import org.junit.Test + +@OptIn(ExperimentalCoroutinesApi::class) +class PromptLibraryViewModelTest { + private val store = mockk(relaxed = true) + @Before fun setUp() { Dispatchers.setMain(StandardTestDispatcher()); every { store.prompts } returns MutableStateFlow(emptyList()) } + @After fun tearDown() = Dispatchers.resetMain() + private fun vm() = PromptLibraryViewModel(store) + + @Test fun save_new_generates_id_and_upserts() = runTest { + vm().save(null, "T", "B"); advanceUntilIdle() + coVerify { store.upsert(match { it.title == "T" && it.body == "B" && it.id.isNotBlank() }) } + } + + @Test fun save_existing_keeps_id() = runTest { + vm().save("keep-me", "T", "B"); advanceUntilIdle() + coVerify { store.upsert(match { it.id == "keep-me" }) } + } + + @Test fun blank_title_falls_back_to_first_body_line() = runTest { + vm().save(null, " ", "first line\nsecond"); advanceUntilIdle() + coVerify { store.upsert(match { it.title == "first line" }) } + } + + @Test fun delete_delegates() = runTest { + vm().delete("x"); advanceUntilIdle() + coVerify { store.delete("x") } + } +} diff --git a/docs/superpowers/plans/2026-07-17-prompt-library.md b/docs/superpowers/plans/2026-07-17-prompt-library.md new file mode 100644 index 0000000..b8b5518 --- /dev/null +++ b/docs/superpowers/plans/2026-07-17-prompt-library.md @@ -0,0 +1,312 @@ +# Prompt / Snippet Library Implementation Plan + +> **For agentic workers:** REQUIRED SUB-SKILL: superpowers:subagent-driven-development. Checkbox steps. + +**Goal:** Device-local saved prompts: a store, a composer picker (appends into the draft), and a Settings manage screen. + +**Spec:** `docs/superpowers/specs/2026-07-17-prompt-library-design.md` + +## Global Constraints +- Client-only, device-local (no gateway/sync). No AI attribution. +- Build env: `export JAVA_HOME="/Applications/Android Studio.app/Contents/jbr/Contents/Home"`. +- Branch `feature/prompt-library` (off `dev`). + +--- + +### Task 1: Model + pure helpers + +**Files:** Create `app/src/main/java/com/hermes/client/data/repository/PromptStore.kt` (model + pure helpers only in this task); Test `app/src/test/java/com/hermes/client/data/repository/PromptStoreTest.kt` + +- [ ] **Step 1: Write the failing test** `PromptStoreTest.kt`: +```kotlin +package com.hermes.client.data.repository + +import org.junit.Assert.assertEquals +import org.junit.Test + +class PromptStoreTest { + private val a = SavedPrompt("1", "Greet", "Say hello") + private val b = SavedPrompt("2", "Bye", "Say bye") + + @Test fun decode_bad_or_empty_is_empty_list() { + assertEquals(emptyList(), decodePrompts(null)) + assertEquals(emptyList(), decodePrompts("")) + assertEquals(emptyList(), decodePrompts("not json")) + assertEquals(emptyList(), decodePrompts("{}")) + } + + @Test fun encode_decode_round_trip() { + assertEquals(listOf(a, b), decodePrompts(encodePrompts(listOf(a, b)))) + } + + @Test fun upsert_appends_new_and_replaces_existing() { + assertEquals(listOf(a, b), upsertPrompt(listOf(a), b)) + val edited = a.copy(title = "Hi") + assertEquals(listOf(edited, b), upsertPrompt(listOf(a, b), edited)) // replaced in place, order kept + } + + @Test fun delete_removes_by_id_and_noops_when_absent() { + assertEquals(listOf(b), deletePrompt(listOf(a, b), "1")) + assertEquals(listOf(a, b), deletePrompt(listOf(a, b), "nope")) + } +} +``` + +- [ ] **Step 2:** Run `./gradlew :app:testDebugUnitTest --tests "com.hermes.client.data.repository.PromptStoreTest"` → FAIL (unresolved). + +- [ ] **Step 3: Implement** the model + pure helpers in `PromptStore.kt`: +```kotlin +package com.hermes.client.data.repository + +import android.content.Context +import androidx.datastore.preferences.core.edit +import androidx.datastore.preferences.core.emptyPreferences +import androidx.datastore.preferences.core.stringPreferencesKey +import androidx.datastore.preferences.preferencesDataStore +import kotlinx.coroutines.flow.Flow +import kotlinx.coroutines.flow.catch +import kotlinx.coroutines.flow.map +import kotlinx.serialization.Serializable +import kotlinx.serialization.json.Json +import java.io.IOException + +/** A user-saved reusable prompt (device-local). */ +@Serializable +data class SavedPrompt(val id: String, val title: String, val body: String) + +private val promptJson = Json { ignoreUnknownKeys = true } + +/** Decode the stored JSON list; never throws — corrupt/absent → empty. */ +fun decodePrompts(raw: String?): List = + runCatching { promptJson.decodeFromString>(raw ?: "[]") }.getOrDefault(emptyList()) + +fun encodePrompts(list: List): String = promptJson.encodeToString(list) + +/** Replace the element whose id matches [p], preserving position; otherwise append. */ +fun upsertPrompt(list: List, p: SavedPrompt): List = + if (list.any { it.id == p.id }) list.map { if (it.id == p.id) p else it } else list + p + +fun deletePrompt(list: List, id: String): List = list.filterNot { it.id == id } +``` +(The `PromptStore` class is added in Task 2 — this task ships only the `@Serializable` model + the four pure functions.) + +- [ ] **Step 4:** Run the test → PASS (4 tests). + +- [ ] **Step 5: Commit** +```bash +git add app/src/main/java/com/hermes/client/data/repository/PromptStore.kt \ + app/src/test/java/com/hermes/client/data/repository/PromptStoreTest.kt +git commit -m "feat: SavedPrompt model + pure prompt-list helpers" +``` + +--- + +### Task 2: PromptStore + DI + ViewModels + +**Files:** +- Modify: `app/src/main/java/com/hermes/client/data/repository/PromptStore.kt` (add the class) +- Modify: `app/src/main/java/com/hermes/client/di/AppModule.kt` +- Modify: `app/src/main/java/com/hermes/client/ui/chat/ChatViewModel.kt` +- Create: `app/src/main/java/com/hermes/client/ui/settings/PromptLibraryScreen.kt` (the `PromptLibraryViewModel` part; the Composable UI is Task 3) +- Test: Create `app/src/test/java/com/hermes/client/ui/settings/PromptLibraryViewModelTest.kt`; extend `ChatViewModelTest.kt` + +**Interfaces:** Consumes `SavedPrompt`/helpers (Task 1). Produces `PromptStore`, `ChatViewModel.savedPrompts`, `PromptLibraryViewModel(prompts/save/delete)`. + +- [ ] **Step 1: Add the `PromptStore` class** to `PromptStore.kt`: +```kotlin +private val Context.promptDataStore by preferencesDataStore(name = "saved_prompts") + +/** Device-local, global store of the user's saved prompts (JSON list under one key). */ +class PromptStore(private val context: Context) { + private val key = stringPreferencesKey("prompts") + + val prompts: Flow> = + context.promptDataStore.data + .catch { e -> if (e is IOException) emit(emptyPreferences()) else throw e } + .map { decodePrompts(it[key]) } + + suspend fun upsert(p: SavedPrompt) = + context.promptDataStore.edit { it[key] = encodePrompts(upsertPrompt(decodePrompts(it[key]), p)) } + + suspend fun delete(id: String) = + context.promptDataStore.edit { it[key] = encodePrompts(deletePrompt(decodePrompts(it[key]), id)) } +} +``` + +- [ ] **Step 2: Provide it via Hilt** — in `AppModule.kt` (mirror `providePinStore`): +```kotlin + @Provides + @Singleton + fun providePromptStore(@ApplicationContext context: Context): com.hermes.client.data.repository.PromptStore = + com.hermes.client.data.repository.PromptStore(context) +``` + +- [ ] **Step 3: ChatViewModel.savedPrompts** — add the constructor param (new last param) and the flow: +```kotlin + private val promptStore: com.hermes.client.data.repository.PromptStore, +``` +```kotlin + /** Device-local saved prompts, for the composer's prompt picker. */ + val savedPrompts: kotlinx.coroutines.flow.StateFlow> = + promptStore.prompts.stateIn(viewModelScope, kotlinx.coroutines.flow.SharingStarted.WhileSubscribed(5_000), emptyList()) +``` + +- [ ] **Step 4: PromptLibraryViewModel** — create `PromptLibraryScreen.kt` with just the ViewModel for now: +```kotlin +package com.hermes.client.ui.settings + +import androidx.lifecycle.ViewModel +import androidx.lifecycle.viewModelScope +import com.hermes.client.data.repository.PromptStore +import com.hermes.client.data.repository.SavedPrompt +import dagger.hilt.android.lifecycle.HiltViewModel +import kotlinx.coroutines.flow.SharingStarted +import kotlinx.coroutines.flow.StateFlow +import kotlinx.coroutines.flow.stateIn +import kotlinx.coroutines.launch +import java.util.UUID +import javax.inject.Inject + +@HiltViewModel +class PromptLibraryViewModel @Inject constructor(private val store: PromptStore) : ViewModel() { + val prompts: StateFlow> = + store.prompts.stateIn(viewModelScope, SharingStarted.WhileSubscribed(5_000), emptyList()) + + /** Create (id == null) or edit an existing prompt. Blank title falls back to the body's first line. */ + fun save(id: String?, title: String, body: String) { + val cleanBody = body.trim() + if (cleanBody.isEmpty() && title.isBlank()) return + val cleanTitle = title.trim().ifBlank { cleanBody.lineSequence().firstOrNull()?.take(60).orEmpty() } + viewModelScope.launch { store.upsert(SavedPrompt(id ?: UUID.randomUUID().toString(), cleanTitle, cleanBody)) } + } + + fun delete(id: String) = viewModelScope.launch { store.delete(id) } +} +``` + +- [ ] **Step 5: Tests.** Create `PromptLibraryViewModelTest.kt` (mock `PromptStore`, `runTest` + `Dispatchers.setMain`): +```kotlin +package com.hermes.client.ui.settings + +import com.hermes.client.data.repository.PromptStore +import com.hermes.client.data.repository.SavedPrompt +import io.mockk.coVerify +import io.mockk.every +import io.mockk.mockk +import kotlinx.coroutines.Dispatchers +import kotlinx.coroutines.ExperimentalCoroutinesApi +import kotlinx.coroutines.flow.MutableStateFlow +import kotlinx.coroutines.test.StandardTestDispatcher +import kotlinx.coroutines.test.advanceUntilIdle +import kotlinx.coroutines.test.resetMain +import kotlinx.coroutines.test.runTest +import kotlinx.coroutines.test.setMain +import org.junit.After +import org.junit.Before +import org.junit.Test + +@OptIn(ExperimentalCoroutinesApi::class) +class PromptLibraryViewModelTest { + private val store = mockk(relaxed = true) + @Before fun setUp() { Dispatchers.setMain(StandardTestDispatcher()); every { store.prompts } returns MutableStateFlow(emptyList()) } + @After fun tearDown() = Dispatchers.resetMain() + private fun vm() = PromptLibraryViewModel(store) + + @Test fun save_new_generates_id_and_upserts() = runTest { + vm().save(null, "T", "B"); advanceUntilIdle() + coVerify { store.upsert(match { it.title == "T" && it.body == "B" && it.id.isNotBlank() }) } + } + + @Test fun save_existing_keeps_id() = runTest { + vm().save("keep-me", "T", "B"); advanceUntilIdle() + coVerify { store.upsert(match { it.id == "keep-me" }) } + } + + @Test fun blank_title_falls_back_to_first_body_line() = runTest { + vm().save(null, " ", "first line\nsecond"); advanceUntilIdle() + coVerify { store.upsert(match { it.title == "first line" }) } + } + + @Test fun delete_delegates() = runTest { + vm().delete("x"); advanceUntilIdle() + coVerify { store.delete("x") } + } +} +``` +Extend `ChatViewModelTest.kt`: add `private val promptStore = mockk(relaxed = true)` with `every { promptStore.prompts } returns MutableStateFlow(emptyList())` in setUp, and pass it as the new last arg in `buildVm()`. + +- [ ] **Step 6:** Run `./gradlew :app:testDebugUnitTest --tests "com.hermes.client.ui.settings.PromptLibraryViewModelTest" --tests "com.hermes.client.ui.chat.ChatViewModelTest"` → PASS. Then `:app:compileDebugKotlin` → BUILD SUCCESSFUL. + +- [ ] **Step 7: Commit** +```bash +git add app/src/main/java/com/hermes/client/data/repository/PromptStore.kt \ + app/src/main/java/com/hermes/client/di/AppModule.kt \ + app/src/main/java/com/hermes/client/ui/chat/ChatViewModel.kt \ + app/src/main/java/com/hermes/client/ui/settings/PromptLibraryScreen.kt \ + app/src/test/java/com/hermes/client/ui/settings/PromptLibraryViewModelTest.kt \ + app/src/test/java/com/hermes/client/ui/chat/ChatViewModelTest.kt +git commit -m "feat: PromptStore + prompt-library/chat view-model wiring" +``` + +--- + +### Task 3: UI — manage screen + composer picker + +**Files:** +- Modify: `app/src/main/java/com/hermes/client/ui/settings/PromptLibraryScreen.kt` (add the Composable) +- Modify: `app/src/main/java/com/hermes/client/ui/nav/HermesNav.kt` (route), `app/src/main/java/com/hermes/client/ui/settings/SettingsScreen.kt` (Entry) +- Modify: `app/src/main/java/com/hermes/client/ui/chat/ChatScreen.kt` (composer icon + picker sheet) +- Test: none new (Compose glue). Compile + full suite + assembleBeta + Task 4. + +- [ ] **Step 1: `PromptLibraryScreen` Composable.** Add to `PromptLibraryScreen.kt`: an `@OptIn(ExperimentalMaterial3Api::class) @Composable fun PromptLibraryScreen(onBack: () -> Unit, vm: PromptLibraryViewModel = hiltViewModel())` — a `Scaffold` with a top bar (title "Saved prompts", back arrow = `onBack`, mirror `MemorySettingsScreen`'s top bar) and a "New" action; body = a `LazyColumn` of `ListItem`s over `vm.prompts.collectAsStateWithLifecycle()` (headline = `title`, supporting = `body` first line, trailing = a delete `IconButton` → `vm.delete(id)`, `Modifier.clickable` to open the edit dialog). An add/edit `AlertDialog` (state `var editing by remember { mutableStateOf(null) }` + `var adding by remember { mutableStateOf(false) }`) with two `OutlinedTextField`s (title, body); confirm → `vm.save(editing?.id, title, body)`; confirm disabled when both blank; empty-list state text: "No saved prompts yet. Tap New to add one." Follow the existing settings-screen composition style. + +- [ ] **Step 2: Register the route** — in `HermesNav.kt`, next to the other `settings_*` routes: +```kotlin + composable("settings_prompts") { + com.hermes.client.ui.settings.PromptLibraryScreen(onBack = { nav.popBackStack() }) + } +``` + +- [ ] **Step 3: Settings entry** — in `SettingsScreen.kt`, add (e.g. after the "Memory & budgets" entry, with a `HorizontalDivider`): +```kotlin + HorizontalDivider() + Entry("Saved prompts", "Reusable prompts for the composer") { onNavigate("settings_prompts") } +``` + +- [ ] **Step 4: Composer picker** — in `ChatScreen.kt`: + - Collect: `val savedPrompts by vm.savedPrompts.collectAsStateWithLifecycle()` and `var showPromptSheet by remember { mutableStateOf(false) }`. + - Add an `IconButton` in the composer `Row` (near the Attach/Mic leading icons) using an available Material icon (e.g. `Icons.AutoMirrored.Rounded.NoteAdd` or `Icons.Rounded.Bookmark`; pick one that exists in `material-icons-extended`) with `onClick = { showPromptSheet = true }`, `contentDescription = "Saved prompts"`. + - Host a `ModalBottomSheet` when `showPromptSheet`: a `LazyColumn` of the `savedPrompts` (`ListItem` headline=title, supporting=body preview, `Modifier.clickable {}` → on pick: + ```kotlin + draft = if (draft.isBlank()) p.body else draft.trimEnd() + "\n" + p.body + showPromptSheet = false + focusRequester.requestFocus() + ``` + ); when empty, a `Text("No saved prompts yet — add them in Settings › Saved prompts.")`. + +- [ ] **Step 5: Compile + full suite + assembleBeta** — all three (JAVA_HOME set) BUILD SUCCESSFUL. + +- [ ] **Step 6: Commit** +```bash +git add app/src/main/java/com/hermes/client/ui/settings/PromptLibraryScreen.kt \ + app/src/main/java/com/hermes/client/ui/nav/HermesNav.kt \ + app/src/main/java/com/hermes/client/ui/settings/SettingsScreen.kt \ + app/src/main/java/com/hermes/client/ui/chat/ChatScreen.kt +git commit -m "feat: saved-prompts manage screen + composer picker" +``` + +--- + +### Task 4: On-device verification + +- [ ] **Step 1:** `:app:installBeta` (target `emulator-5554` if multiple devices). +- [ ] **Step 2:** Settings › Saved prompts → New → enter title + body → Save → appears in the list. Edit it (change body) → persists. Delete → removed. +- [ ] **Step 3:** Open/create a chat → tap the composer's saved-prompts icon → the sheet lists the prompt → pick → it's appended into the draft and the field is focused. Type first, then pick → the prompt appends after a newline. +- [ ] **Step 4:** With no prompts saved, the picker sheet shows the empty-state copy. +- [ ] **Step 5:** Record pass/fail in the PR description. + +--- + +## Notes for the executor +- Do NOT add share-sheet export, App Actions, a `/`-command trigger, or sync — explicit anti-scope. +- Pick composer/picker icons that actually exist in `androidx.compose.material.icons` (`material-icons-extended` is a dependency); if an icon name is unavailable, substitute a present one — the behavior (a picker that appends into `draft`) is the contract. diff --git a/docs/superpowers/specs/2026-07-17-prompt-library-design.md b/docs/superpowers/specs/2026-07-17-prompt-library-design.md new file mode 100644 index 0000000..d1ec9b7 --- /dev/null +++ b/docs/superpowers/specs/2026-07-17-prompt-library-design.md @@ -0,0 +1,85 @@ +# Prompt / Snippet Library — Design + +**Wave:** Quick-wins (client-only). **Branch:** `feature/prompt-library` (off `dev`). + +**Goal:** Let the user save reusable prompts locally, insert one into the chat composer with a tap, and manage them (add/edit/delete) from a Settings screen. Client-only; device-local (no gateway, no sync). + +**Constraints:** Kotlin/Compose/Material3/Hilt. No AI attribution; gitleaks before push; PR into `dev`. + +## Scope +- **In:** a device-local `PromptStore` (DataStore + JSON `List`); a composer icon-button → picker that **appends** the chosen prompt into `draft`; a "Saved prompts" manage screen (list + add/edit dialog + delete) reachable from Settings. +- **Out (deferred):** sharing a prompt *out* via the share-sheet / registering as a share target; App Actions / shortcuts; `/`-command trigger (the icon-button picker is v1); cloud/desktop sync; caret-precise insertion (`draft` is a `String`, so append, not insert-at-cursor). + +## Architecture + +### 1. Model + pure helpers — `data/repository/PromptStore.kt` (new) +```kotlin +@Serializable +data class SavedPrompt(val id: String, val title: String, val body: String) +``` +Pure, unit-testable free functions (never throw): +- `fun decodePrompts(raw: String?): List` — `runCatching { Json.decodeFromString(raw ?: "[]") }.getOrDefault(emptyList())`. +- `fun encodePrompts(list: List): String` — `Json.encodeToString(list)`. +- `fun upsertPrompt(list, p): List` — replace the element with `p.id` if present, else append. +- `fun deletePrompt(list, id): List` — filter out `id`. + +### 2. `PromptStore` (DataStore) — same file +Mirror `ModelFavoritesStore`: `preferencesDataStore(name = "saved_prompts")`, a single `stringPreferencesKey("prompts")`. Device-local, global (not per-profile). +```kotlin +val prompts: Flow> = + context.promptDataStore.data + .catch { e -> if (e is java.io.IOException) emit(emptyPreferences()) else throw e } + .map { decodePrompts(it[key]) } + +suspend fun upsert(p: SavedPrompt) = context.promptDataStore.edit { it[key] = encodePrompts(upsertPrompt(decodePrompts(it[key]), p)) } +suspend fun delete(id: String) = context.promptDataStore.edit { it[key] = encodePrompts(deletePrompt(decodePrompts(it[key]), id)) } +``` +Provided `@Singleton` in `di/AppModule.kt` (mirror `providePinStore`). + +### 3. Composer picker — `ui/chat/ChatViewModel.kt` + `ChatScreen.kt` (modify) +- `ChatViewModel`: inject `PromptStore`; expose `val savedPrompts: StateFlow> = promptStore.prompts.stateIn(...)`. +- `ChatScreen`: add an `IconButton` (a notes/library icon) in the composer `Row` (leading, near the Attach/Mic buttons). On tap → open a `ModalBottomSheet` listing `savedPrompts` (title + body preview). On pick → append into the existing `draft` and refocus, reusing the edit-resend gesture: + ```kotlin + draft = if (draft.isBlank()) p.body else draft.trimEnd() + "\n" + p.body + showPromptSheet = false + focusRequester.requestFocus() + ``` + Empty state in the sheet: "No saved prompts yet — add them in Settings › Saved prompts." + +### 4. Manage screen — `ui/settings/PromptLibraryScreen.kt` (new) + nav/settings wiring +- `PromptLibraryViewModel` (`@HiltViewModel`, injects `PromptStore`): `val prompts: StateFlow>`; `fun save(id: String?, title: String, body: String)` (new `SavedPrompt(id ?: randomUUID, title, body)` → `store.upsert`); `fun delete(id: String)`. +- `PromptLibraryScreen(onBack)`: `Scaffold` + a top bar with a back arrow (mirror `MemorySettingsScreen`) + a `LazyColumn` of `ListItem`s (headline=title, supporting=body preview; tap → edit dialog; a trailing delete `IconButton`) + a "New prompt" button/FAB. Add/edit uses an `AlertDialog` with two `OutlinedTextField`s (title, body); Save disabled when both blank; blank title falls back to the first line of the body. +- `HermesNav.kt`: `composable("settings_prompts") { PromptLibraryScreen(onBack = { nav.popBackStack() }) }`. +- `SettingsScreen.kt`: a new `Entry("Saved prompts", "Reusable prompts for the composer") { onNavigate("settings_prompts") }` (+ a `HorizontalDivider`). + +## Data flow +``` +Settings › Saved prompts → PromptLibraryScreen → add/edit/delete → PromptLibraryViewModel → PromptStore.upsert/delete → DataStore +Composer library icon → ModalBottomSheet(savedPrompts from ChatViewModel) → pick → append to draft + focus +``` + +## Error handling +- Corrupt/missing store JSON → `decodePrompts` returns `[]` (never throws); `IOException` on read → empty via `.catch`. +- Blank title → derive from body's first line; both blank → Save disabled (can't create an empty prompt). +- Appending to a non-empty draft inserts a newline separator; empty draft → just the body. + +## Testing +- **`PromptStoreTest`** (pure): `decodePrompts` of valid JSON, of null/`""`/garbage → `[]`; `encodePrompts`/`decodePrompts` round-trip; `upsertPrompt` appends a new id and replaces an existing id (preserving order); `deletePrompt` removes by id and is a no-op for a missing id. +- **`PromptLibraryViewModelTest`** (mock `PromptStore`): `save(null, …)` calls `store.upsert` with a generated id + given title/body; `save(existingId, …)` upserts with that id; `delete(id)` calls `store.delete(id)`; blank-title fallback to body's first line. +- DataStore wiring, the bottom sheet, and the manage screen are Android/Compose — verified on-device. + +## On-device verification +Settings › Saved prompts → add a prompt (title + body) → it appears in the list; edit it; delete it. Open a chat → tap the composer library icon → the saved prompt shows → pick → it's appended to the draft and the field is focused. Empty-state copy shows when no prompts exist. + +## Files +| Action | Path | +|--------|------| +| New | `data/repository/PromptStore.kt` (model + pure helpers + store) + `PromptStoreTest.kt` | +| Modify | `di/AppModule.kt` (provide store) | +| Modify | `ui/chat/ChatViewModel.kt` (savedPrompts) | +| Modify | `ui/chat/ChatScreen.kt` (composer library icon + picker sheet) | +| New | `ui/settings/PromptLibraryScreen.kt` (screen + `PromptLibraryViewModel`) + `PromptLibraryViewModelTest.kt` | +| Modify | `ui/nav/HermesNav.kt` (route), `ui/settings/SettingsScreen.kt` (Entry) | + +## Build & gates +`JAVA_HOME=…`: `:app:compileDebugKotlin`, `:app:testDebugUnitTest`, `:app:assembleBeta`. gitleaks before push; PR into `dev`. From fb9b9c8f5e42d8b5a2c0cc49c910784f5a2846df Mon Sep 17 00:00:00 2001 From: Andrew Debnar <606931+adebnar@users.noreply.github.com> Date: Fri, 17 Jul 2026 18:08:41 +0000 Subject: [PATCH 11/16] feat: export/share a chat transcript (#105) * docs: spec + plan for thread export/share * feat: add transcriptText conversation formatter * feat: Copy/Share transcript overflow menu in the chat top bar * fix: guard transcript export against oversized payloads; label SYSTEM notices correctly --- .../com/hermes/client/ui/chat/ChatScreen.kt | 54 ++++++ .../hermes/client/ui/chat/MessageActions.kt | 17 ++ .../ui/chat/MessageActionsTranscriptTest.kt | 38 ++++ .../plans/2026-07-17-thread-export.md | 182 ++++++++++++++++++ .../specs/2026-07-17-thread-export-design.md | 66 +++++++ 5 files changed, 357 insertions(+) create mode 100644 app/src/test/java/com/hermes/client/ui/chat/MessageActionsTranscriptTest.kt create mode 100644 docs/superpowers/plans/2026-07-17-thread-export.md create mode 100644 docs/superpowers/specs/2026-07-17-thread-export-design.md diff --git a/app/src/main/java/com/hermes/client/ui/chat/ChatScreen.kt b/app/src/main/java/com/hermes/client/ui/chat/ChatScreen.kt index da6a123..8dd0902 100644 --- a/app/src/main/java/com/hermes/client/ui/chat/ChatScreen.kt +++ b/app/src/main/java/com/hermes/client/ui/chat/ChatScreen.kt @@ -40,6 +40,7 @@ import androidx.compose.material.icons.rounded.Close import androidx.compose.material.icons.rounded.KeyboardArrowDown import androidx.compose.material.icons.rounded.KeyboardArrowUp import androidx.compose.material.icons.rounded.Mic +import androidx.compose.material.icons.rounded.MoreVert import androidx.compose.material.icons.rounded.Search import androidx.compose.material.icons.rounded.Stop import androidx.compose.material3.AlertDialog @@ -56,7 +57,9 @@ import androidx.compose.material3.minimumInteractiveComponentSize import androidx.compose.material3.rememberModalBottomSheetState import com.hermes.client.ui.theme.LocalProfileAccent import androidx.compose.ui.hapticfeedback.HapticFeedbackType +import androidx.compose.ui.platform.LocalClipboardManager import androidx.compose.ui.platform.LocalHapticFeedback +import androidx.compose.ui.text.AnnotatedString import kotlinx.coroutines.launch import androidx.compose.material3.OutlinedTextField import androidx.compose.material3.Scaffold @@ -159,6 +162,8 @@ fun ChatScreen( // Image attach: read picked/captured bytes and stage them onto the session. val context = androidx.compose.ui.platform.LocalContext.current + val clipboard = LocalClipboardManager.current + var transcriptMenu by remember { mutableStateOf(false) } val attachScope = androidx.compose.runtime.rememberCoroutineScope() fun readBytes(uri: Uri): ByteArray? = @@ -266,6 +271,55 @@ fun ChatScreen( ), ) StatusDot(connState) + Box { + IconButton(onClick = { transcriptMenu = true }) { + Icon( + Icons.Rounded.MoreVert, + contentDescription = "More", + tint = com.hermes.client.ui.components.AccentChrome.onBar, + ) + } + DropdownMenu(expanded = transcriptMenu, onDismissRequest = { transcriptMenu = false }) { + DropdownMenuItem( + text = { Text("Copy transcript") }, + onClick = { + val t = transcriptText(state.messages) + if (t.isBlank()) { + android.widget.Toast.makeText(context, "Nothing to export yet", android.widget.Toast.LENGTH_SHORT).show() + } else { + runCatching { + clipboard.setText(AnnotatedString(t)) + android.widget.Toast.makeText(context, "Transcript copied", android.widget.Toast.LENGTH_SHORT).show() + }.onFailure { + android.widget.Toast.makeText(context, "Couldn't copy transcript", android.widget.Toast.LENGTH_SHORT).show() + } + } + transcriptMenu = false + }, + ) + DropdownMenuItem( + text = { Text("Share transcript") }, + onClick = { + val t = transcriptText(state.messages) + if (t.isBlank()) { + android.widget.Toast.makeText(context, "Nothing to export yet", android.widget.Toast.LENGTH_SHORT).show() + } else { + val send = android.content.Intent(android.content.Intent.ACTION_SEND).apply { + type = "text/plain" + putExtra(android.content.Intent.EXTRA_SUBJECT, "Hermes chat transcript") + putExtra(android.content.Intent.EXTRA_TEXT, t) + } + runCatching { + context.startActivity(android.content.Intent.createChooser(send, "Share transcript")) + }.onFailure { + android.widget.Toast.makeText(context, "Couldn't share transcript", android.widget.Toast.LENGTH_SHORT).show() + } + } + transcriptMenu = false + }, + ) + } + } }, ) }, diff --git a/app/src/main/java/com/hermes/client/ui/chat/MessageActions.kt b/app/src/main/java/com/hermes/client/ui/chat/MessageActions.kt index 47f039b..fd6efc8 100644 --- a/app/src/main/java/com/hermes/client/ui/chat/MessageActions.kt +++ b/app/src/main/java/com/hermes/client/ui/chat/MessageActions.kt @@ -6,3 +6,20 @@ import com.hermes.client.domain.Role /** The text of the most recent USER message, or null if there is none (used to re-ask). */ fun lastUserMessageText(messages: List): String? = messages.lastOrNull { it.role == Role.USER }?.text?.takeIf { it.isNotBlank() } + +/** + * Render the conversation to a plain-text, role-labeled transcript. Body text is verbatim + * (markdown preserved). Blank-text turns (tool-only / still-streaming stubs) are skipped. + */ +fun transcriptText(messages: List): String = + messages + .filter { it.text.isNotBlank() } + .joinToString("\n\n") { m -> + val label = when { + m.isError -> "Error" + m.role == Role.SYSTEM -> "System" + m.role == Role.USER -> "You" + else -> "Assistant" + } + "$label:\n${m.text}" + } diff --git a/app/src/test/java/com/hermes/client/ui/chat/MessageActionsTranscriptTest.kt b/app/src/test/java/com/hermes/client/ui/chat/MessageActionsTranscriptTest.kt new file mode 100644 index 0000000..2cf3110 --- /dev/null +++ b/app/src/test/java/com/hermes/client/ui/chat/MessageActionsTranscriptTest.kt @@ -0,0 +1,38 @@ +package com.hermes.client.ui.chat + +import com.hermes.client.domain.ChatMessage +import com.hermes.client.domain.Role +import org.junit.Assert.assertEquals +import org.junit.Assert.assertTrue +import org.junit.Test + +class MessageActionsTranscriptTest { + private fun msg(role: Role, text: String, isError: Boolean = false) = + ChatMessage(id = "x", role = role, text = text, isError = isError) + + @Test fun empty_is_empty() { + assertEquals("", transcriptText(emptyList())) + } + + @Test fun labels_user_and_assistant() { + val t = transcriptText(listOf(msg(Role.USER, "hi"), msg(Role.ASSISTANT, "hello"))) + assertEquals("You:\nhi\n\nAssistant:\nhello", t) + } + + @Test fun skips_blank_turns() { + val t = transcriptText(listOf(msg(Role.USER, "q"), msg(Role.ASSISTANT, " "), msg(Role.ASSISTANT, "a"))) + assertEquals("You:\nq\n\nAssistant:\na", t) + } + + @Test fun error_labelled_error_and_system_labelled_system() { + assertTrue(transcriptText(listOf(msg(Role.ASSISTANT, "boom", isError = true))).startsWith("Error:")) + assertTrue(transcriptText(listOf(msg(Role.SYSTEM, "note"))).startsWith("System:")) + assertTrue(transcriptText(listOf(msg(Role.USER, "boom", isError = true))).startsWith("Error:")) + } + + @Test fun markdown_preserved_verbatim() { + val t = transcriptText(listOf(msg(Role.ASSISTANT, "```kotlin\nval x = 1\n```"))) + assertTrue(t.contains("```kotlin")) + assertTrue(t.contains("val x = 1")) + } +} diff --git a/docs/superpowers/plans/2026-07-17-thread-export.md b/docs/superpowers/plans/2026-07-17-thread-export.md new file mode 100644 index 0000000..89ca875 --- /dev/null +++ b/docs/superpowers/plans/2026-07-17-thread-export.md @@ -0,0 +1,182 @@ +# Thread Export / Share Implementation Plan + +> **For agentic workers:** REQUIRED SUB-SKILL: superpowers:subagent-driven-development. Checkbox steps. + +**Goal:** Copy or share a chat as a plain-text transcript via a chat top-bar overflow menu. + +**Spec:** `docs/superpowers/specs/2026-07-17-thread-export-design.md` + +## Global Constraints +- Client-only; reuse loaded history + existing clipboard/`ACTION_SEND` patterns. No AI attribution. +- Build env: `export JAVA_HOME="/Applications/Android Studio.app/Contents/jbr/Contents/Home"`. +- Branch `feature/thread-export` (off `dev`). + +--- + +### Task 1: `transcriptText` pure helper + +**Files:** Modify `app/src/main/java/com/hermes/client/ui/chat/MessageActions.kt`; Test `app/src/test/java/com/hermes/client/ui/chat/MessageActionsTest.kt` (extend if it exists, else create) + +- [ ] **Step 1: Write the failing test.** Add to (or create) `MessageActionsTest.kt`: +```kotlin +package com.hermes.client.ui.chat + +import com.hermes.client.domain.ChatMessage +import com.hermes.client.domain.Role +import org.junit.Assert.assertEquals +import org.junit.Assert.assertTrue +import org.junit.Test + +class MessageActionsTranscriptTest { + private fun msg(role: Role, text: String, isError: Boolean = false) = + ChatMessage(id = "x", role = role, text = text, isError = isError) + + @Test fun empty_is_empty() { + assertEquals("", transcriptText(emptyList())) + } + + @Test fun labels_user_and_assistant() { + val t = transcriptText(listOf(msg(Role.USER, "hi"), msg(Role.ASSISTANT, "hello"))) + assertEquals("You:\nhi\n\nAssistant:\nhello", t) + } + + @Test fun skips_blank_turns() { + val t = transcriptText(listOf(msg(Role.USER, "q"), msg(Role.ASSISTANT, " "), msg(Role.ASSISTANT, "a"))) + assertEquals("You:\nq\n\nAssistant:\na", t) + } + + @Test fun error_and_system_labelled_error() { + assertTrue(transcriptText(listOf(msg(Role.ASSISTANT, "boom", isError = true))).startsWith("Error:")) + assertTrue(transcriptText(listOf(msg(Role.SYSTEM, "sys"))).startsWith("Error:")) + } + + @Test fun markdown_preserved_verbatim() { + val t = transcriptText(listOf(msg(Role.ASSISTANT, "```kotlin\nval x = 1\n```"))) + assertTrue(t.contains("```kotlin")) + assertTrue(t.contains("val x = 1")) + } +} +``` +(If `MessageActionsTest.kt` already exists, add these as a new class in the same file or a new file `MessageActionsTranscriptTest.kt` — either is fine; keep the existing tests intact.) + +- [ ] **Step 2:** Run `./gradlew :app:testDebugUnitTest --tests "com.hermes.client.ui.chat.MessageActionsTranscriptTest"` → FAIL (unresolved `transcriptText`). + +- [ ] **Step 3: Implement** — append to `MessageActions.kt` (it already imports `ChatMessage` and `Role`): +```kotlin +/** + * Render the conversation to a plain-text, role-labeled transcript. Body text is verbatim + * (markdown preserved). Blank-text turns (tool-only / still-streaming stubs) are skipped. + */ +fun transcriptText(messages: List): String = + messages + .filter { it.text.isNotBlank() } + .joinToString("\n\n") { m -> + val label = when { + m.isError || m.role == Role.SYSTEM -> "Error" + m.role == Role.USER -> "You" + else -> "Assistant" + } + "$label:\n${m.text}" + } +``` + +- [ ] **Step 4:** Run the test → PASS (5 tests). + +- [ ] **Step 5: Commit** +```bash +git add app/src/main/java/com/hermes/client/ui/chat/MessageActions.kt \ + app/src/test/java/com/hermes/client/ui/chat/MessageActionsTranscriptTest.kt +git commit -m "feat: add transcriptText conversation formatter" +``` +(Adjust the `git add` test path if you extended the existing `MessageActionsTest.kt` instead.) + +--- + +### Task 2: Chat top-bar overflow menu + +**Files:** Modify `app/src/main/java/com/hermes/client/ui/chat/ChatScreen.kt` + +**Interfaces:** Consumes `transcriptText` (Task 1). + +- [ ] **Step 1: Add imports + state.** Ensure these imports exist in `ChatScreen.kt` (add any missing): +```kotlin +import androidx.compose.material.icons.Icons +import androidx.compose.material.icons.rounded.MoreVert +import androidx.compose.ui.platform.LocalClipboardManager +import androidx.compose.ui.text.AnnotatedString +``` +Near the other composer/UI state (e.g. by `val context = LocalContext.current` at line ~161), add: +```kotlin + val clipboard = LocalClipboardManager.current + var transcriptMenu by remember { mutableStateOf(false) } +``` + +- [ ] **Step 2: Add the overflow to the top-bar `actions`.** In the `HermesTopBar(...) { actions = { … } }` block, after `StatusDot(connState)`, add: +```kotlin + Box { + IconButton(onClick = { transcriptMenu = true }) { + Icon( + Icons.Rounded.MoreVert, + contentDescription = "More", + tint = com.hermes.client.ui.components.AccentChrome.onBar, + ) + } + DropdownMenu(expanded = transcriptMenu, onDismissRequest = { transcriptMenu = false }) { + DropdownMenuItem( + text = { Text("Copy transcript") }, + onClick = { + val t = transcriptText(state.messages) + if (t.isBlank()) { + android.widget.Toast.makeText(context, "Nothing to export yet", android.widget.Toast.LENGTH_SHORT).show() + } else { + clipboard.setText(AnnotatedString(t)) + android.widget.Toast.makeText(context, "Transcript copied", android.widget.Toast.LENGTH_SHORT).show() + } + transcriptMenu = false + }, + ) + DropdownMenuItem( + text = { Text("Share transcript") }, + onClick = { + val t = transcriptText(state.messages) + if (t.isBlank()) { + android.widget.Toast.makeText(context, "Nothing to export yet", android.widget.Toast.LENGTH_SHORT).show() + } else { + val send = android.content.Intent(android.content.Intent.ACTION_SEND).apply { + type = "text/plain" + putExtra(android.content.Intent.EXTRA_SUBJECT, "Hermes chat transcript") + putExtra(android.content.Intent.EXTRA_TEXT, t) + } + context.startActivity(android.content.Intent.createChooser(send, "Share transcript")) + } + transcriptMenu = false + }, + ) + } + } +``` +NOTE: use the actual name of the chat UI-state variable in this file for `state.messages` — read the file to confirm whether it's `state`, `uiState`, or similar (the same variable already rendered by `ChatMessageList(...)`). `DropdownMenu`/`DropdownMenuItem` are already imported (lines 47-48). If `IconButton`/`Icon`/`Box`/`Text`/`remember`/`mutableStateOf` aren't imported, add them (they're standard and almost certainly already present). + +- [ ] **Step 3: Compile + full suite + assembleBeta.** Run each (JAVA_HOME set): `:app:compileDebugKotlin`, `:app:testDebugUnitTest` (0 failures), `:app:assembleBeta` — all BUILD SUCCESSFUL. + +- [ ] **Step 4: Commit** +```bash +git add app/src/main/java/com/hermes/client/ui/chat/ChatScreen.kt +git commit -m "feat: Copy/Share transcript overflow menu in the chat top bar" +``` + +--- + +### Task 3: On-device verification + +- [ ] **Step 1:** `:app:installBeta` (target `emulator-5554` if multiple devices). The app should be configured/connected; open a chat that has a few turns (or create one and send a message). +- [ ] **Step 2:** Chat top bar → ⋮ → **Copy transcript** → confirm a "Transcript copied" toast; paste into another field/app and confirm the role-labeled transcript ("You:" / "Assistant:"). +- [ ] **Step 3:** ⋮ → **Share transcript** → confirm the system share chooser opens with the transcript as text. +- [ ] **Step 4:** On a brand-new empty chat, ⋮ → either item → confirm "Nothing to export yet" and no chooser/clipboard change. +- [ ] **Step 5:** Record pass/fail in the PR description. + +--- + +## Notes for the executor +- Do NOT add file export (FileProvider/.txt), include `thinking`/tool output, or add ViewModel/Activity changes — explicit anti-scope. Everything is wired inline in `ChatScreen`. +- Confirm the in-file chat-state variable name before using `state.messages`. diff --git a/docs/superpowers/specs/2026-07-17-thread-export-design.md b/docs/superpowers/specs/2026-07-17-thread-export-design.md new file mode 100644 index 0000000..6aa1914 --- /dev/null +++ b/docs/superpowers/specs/2026-07-17-thread-export-design.md @@ -0,0 +1,66 @@ +# Thread Export / Share — Design + +**Wave:** Quick-wins (client-only). **Branch:** `feature/thread-export` (off `dev`). + +**Goal:** Let the user copy or share a whole chat conversation as a plain-text transcript, from an overflow menu in the chat top bar. Client-only; reuses the already-loaded history + existing clipboard/`ACTION_SEND` patterns. + +**Constraints:** Kotlin/Compose/Material3. No AI attribution; gitleaks before push; PR into `dev`. + +## Scope +- **In:** a pure `transcriptText(messages)` formatter; a `MoreVert` overflow menu in the chat top bar with **Copy transcript** (clipboard) and **Share transcript** (`ACTION_SEND` text/plain chooser). +- **Out (deferred):** file export (`.txt`/`.md` via FileProvider/`EXTRA_STREAM`); including reasoning (`thinking`)/tool output in the transcript; per-selection/range export; import. + +## Architecture + +### 1. `ui/chat/MessageActions.kt` (modify) — pure helper +```kotlin +/** + * Render the conversation to a plain-text, role-labeled transcript. Body text is verbatim + * (markdown preserved). Blank-text turns (tool-only / still-streaming stubs) are skipped. + */ +fun transcriptText(messages: List): String = + messages + .filter { it.text.isNotBlank() } + .joinToString("\n\n") { m -> + val label = when { + m.isError || m.role == Role.SYSTEM -> "Error" + m.role == Role.USER -> "You" + else -> "Assistant" + } + "$label:\n${m.text}" + } +``` + +### 2. `ui/chat/ChatScreen.kt` (modify) — overflow menu +In the `HermesTopBar` `actions` slot (after `StatusDot(connState)`), add a `MoreVert` `IconButton` + a `DropdownMenu` (`var transcriptMenu by remember { mutableStateOf(false) }`). Two items: +- **"Copy transcript"** → `val t = transcriptText(uiState.messages); if (t.isBlank()) toast("Nothing to export yet") else { clipboard.setText(AnnotatedString(t)); toast("Transcript copied") }; transcriptMenu = false`. +- **"Share transcript"** → `val t = transcriptText(uiState.messages); if (t.isBlank()) toast("Nothing to export yet") else context.startActivity(Intent.createChooser(Intent(ACTION_SEND).apply { type = "text/plain"; putExtra(EXTRA_SUBJECT, "Hermes chat transcript"); putExtra(EXTRA_TEXT, t) }, "Share transcript")); transcriptMenu = false`. + +`uiState.messages`, `LocalContext.current` (already `val context` at line 161) are in scope; add `val clipboard = LocalClipboardManager.current`. Icon tint uses `AccentChrome.onBar` like the existing Search icon. No ViewModel/Activity changes. + +## Data flow +``` +chat top bar ⋮ → Copy transcript → transcriptText(messages) → clipboard.setText + toast + → Share transcript → transcriptText(messages) → ACTION_SEND text/plain chooser +(empty transcript → "Nothing to export yet" toast, no clipboard/chooser) +``` + +## Error handling +- Empty conversation / all-blank → both items toast "Nothing to export yet" and do nothing. +- `transcriptText` is pure and total (never throws). + +## Testing +- **`MessageActionsTest`** (extend, pure): empty list → `""`; a USER+ASSISTANT pair → `"You:\n…\n\nAssistant:\n…"`; a blank-text turn is skipped; an `isError`/SYSTEM turn → `"Error:\n…"`; markdown in a body is preserved verbatim. +- The top-bar menu, clipboard, and share chooser are Android/Compose glue — verified on-device. + +## On-device verification +Open a chat with a few turns → top-bar ⋮ → **Copy transcript** → paste elsewhere shows the role-labeled transcript; **Share transcript** → the system chooser opens with the transcript text. On an empty chat, both show "Nothing to export yet". + +## Files +| Action | Path | +|--------|------| +| Modify | `ui/chat/MessageActions.kt` (`transcriptText`) + `MessageActionsTest.kt` | +| Modify | `ui/chat/ChatScreen.kt` (overflow menu) | + +## Build & gates +`JAVA_HOME=…`: `:app:compileDebugKotlin`, `:app:testDebugUnitTest`, `:app:assembleBeta`. gitleaks before push; PR into `dev`. From f85841ad3d31577d20e7db742ee8597755513a60 Mon Sep 17 00:00:00 2001 From: Andrew Debnar <606931+adebnar@users.noreply.github.com> Date: Fri, 17 Jul 2026 22:36:32 +0000 Subject: [PATCH 12/16] feat: richer approval-sheet context (grants + owner-override) (#106) * docs: spec + plan for approval context enrichment * feat: extract smart_denied from approval.request events * feat: show full allowlist grants + smart-denied warning on the approval sheet --- .../hermes/client/ui/chat/ApprovalSheet.kt | 17 +++ .../com/hermes/client/ui/chat/ChatUiState.kt | 2 + .../hermes/client/ui/chat/ChatReducerTest.kt | 17 +++ .../plans/2026-07-17-approval-context.md | 117 ++++++++++++++++++ .../2026-07-17-approval-context-design.md | 86 +++++++++++++ 5 files changed, 239 insertions(+) create mode 100644 docs/superpowers/plans/2026-07-17-approval-context.md create mode 100644 docs/superpowers/specs/2026-07-17-approval-context-design.md diff --git a/app/src/main/java/com/hermes/client/ui/chat/ApprovalSheet.kt b/app/src/main/java/com/hermes/client/ui/chat/ApprovalSheet.kt index d3e0d29..63a9c2d 100644 --- a/app/src/main/java/com/hermes/client/ui/chat/ApprovalSheet.kt +++ b/app/src/main/java/com/hermes/client/ui/chat/ApprovalSheet.kt @@ -59,6 +59,23 @@ fun ApprovalSheet(req: ApprovalRequest, onRespond: (ApprovalChoice) -> Unit, onD Text(req.description, style = MaterialTheme.typography.bodySmall, modifier = Modifier.padding(bottom = 16.dp)) } + if (req.patternKeys.isNotEmpty()) { + Text( + "Grants: ${req.patternKeys.joinToString(", ")}", + style = MaterialTheme.typography.bodySmall, + color = MaterialTheme.colorScheme.onSurfaceVariant, + modifier = Modifier.padding(bottom = 8.dp), + ) + } + if (req.smartDenied) { + Text( + "Owner override — approve only this one operation.", + style = MaterialTheme.typography.bodySmall, + color = MaterialTheme.colorScheme.error, + modifier = Modifier.padding(bottom = 8.dp), + ) + } + if (tier == ApprovalTier.STANDARD) { Button( onClick = { onRespond(ApprovalChoice.ONCE) }, diff --git a/app/src/main/java/com/hermes/client/ui/chat/ChatUiState.kt b/app/src/main/java/com/hermes/client/ui/chat/ChatUiState.kt index 5a42177..1aba9e3 100644 --- a/app/src/main/java/com/hermes/client/ui/chat/ChatUiState.kt +++ b/app/src/main/java/com/hermes/client/ui/chat/ChatUiState.kt @@ -14,6 +14,7 @@ data class ApprovalRequest( val description: String, val patternKeys: List, val allowPermanent: Boolean, + val smartDenied: Boolean = false, ) data class ClarifyRequest(val question: String, val options: List, val requestId: String = "") @@ -98,6 +99,7 @@ fun ChatUiState.reduce(event: ServerEvent): ChatUiState { patternKeys = event.strList("pattern_keys") .ifEmpty { event.str("pattern_key")?.let { listOf(it) } ?: emptyList() }, allowPermanent = event.bool("allow_permanent") ?: false, + smartDenied = event.bool("smart_denied") ?: false, ), ) "clarify.request" -> state.copy( diff --git a/app/src/test/java/com/hermes/client/ui/chat/ChatReducerTest.kt b/app/src/test/java/com/hermes/client/ui/chat/ChatReducerTest.kt index 5b4b52b..d2b2ce0 100644 --- a/app/src/test/java/com/hermes/client/ui/chat/ChatReducerTest.kt +++ b/app/src/test/java/com/hermes/client/ui/chat/ChatReducerTest.kt @@ -103,6 +103,23 @@ class ChatReducerTest { assertEquals("rm -rf?", s.pendingApproval?.command) } + @Test fun approval_request_extracts_smart_denied_and_all_keys() { + var s = ChatUiState.empty() + s = s.reduce(ev("approval.request") { + put("command", "rm -rf?") + put("smart_denied", true) + putJsonArray("pattern_keys") { add("shell.rm"); add("shell.dangerous") } + }) + assertEquals(true, s.pendingApproval?.smartDenied) + assertEquals(listOf("shell.rm", "shell.dangerous"), s.pendingApproval?.patternKeys) + } + + @Test fun approval_request_smart_denied_defaults_false() { + var s = ChatUiState.empty() + s = s.reduce(ev("approval.request") { put("command", "ls") }) + assertEquals(false, s.pendingApproval?.smartDenied) + } + @Test fun clarify_request_captures_request_id() { var s = ChatUiState.empty() s = s.reduce(ev("clarify.request") { put("question", "Which repo?"); put("request_id", "req-9") }) diff --git a/docs/superpowers/plans/2026-07-17-approval-context.md b/docs/superpowers/plans/2026-07-17-approval-context.md new file mode 100644 index 0000000..5a7cb84 --- /dev/null +++ b/docs/superpowers/plans/2026-07-17-approval-context.md @@ -0,0 +1,117 @@ +# Approval Context Enrichment Implementation Plan + +> **For agentic workers:** REQUIRED SUB-SKILL: superpowers:subagent-driven-development. Checkbox steps. + +**Goal:** Surface the full allowlist keys + the `smart_denied` signal (already in the event) on the approval sheet. + +**Spec:** `docs/superpowers/specs/2026-07-17-approval-context-design.md` + +## Global Constraints +- Client-only (reads existing payload keys; no gateway change). No AI attribution. +- Build env: `export JAVA_HOME="/Applications/Android Studio.app/Contents/jbr/Contents/Home"`. +- Branch `feature/approval-context` (off `dev`). + +--- + +### Task 1: `smartDenied` field + reducer extraction + +**Files:** Modify `app/src/main/java/com/hermes/client/ui/chat/ChatUiState.kt`; Test `app/src/test/java/com/hermes/client/ui/chat/ChatReducerTest.kt` (extend) + +- [ ] **Step 1: Write the failing test.** Add to `ChatReducerTest.kt` (mirror the existing `approval_request_sets_pending` test's `ev("approval.request") { put(...) }` pattern): +```kotlin + @Test fun approval_request_extracts_smart_denied_and_all_keys() { + var s = ChatUiState.empty() + s = s.reduce(ev("approval.request") { + put("command", "rm -rf?") + put("smart_denied", true) + putJsonArray("pattern_keys") { add("shell.rm"); add("shell.dangerous") } + }) + assertEquals(true, s.pendingApproval?.smartDenied) + assertEquals(listOf("shell.rm", "shell.dangerous"), s.pendingApproval?.patternKeys) + } + + @Test fun approval_request_smart_denied_defaults_false() { + var s = ChatUiState.empty() + s = s.reduce(ev("approval.request") { put("command", "ls") }) + assertEquals(false, s.pendingApproval?.smartDenied) + } +``` +(If `putJsonArray`/`add` require imports — `kotlinx.serialization.json.putJsonArray`, `kotlinx.serialization.json.add` — add them, or match however the existing tests build a JSON array. If the existing `ev` helper's builder differs, follow its exact shape; the assertions are the contract.) + +- [ ] **Step 2:** Run `./gradlew :app:testDebugUnitTest --tests "com.hermes.client.ui.chat.ChatReducerTest"` → FAIL (unresolved `smartDenied`). + +- [ ] **Step 3: Implement.** In `ChatUiState.kt`, add the field to `ApprovalRequest`: +```kotlin +data class ApprovalRequest( + val command: String, + val description: String, + val patternKeys: List, + val allowPermanent: Boolean, + val smartDenied: Boolean = false, +) +``` +And extract it in the `"approval.request"` reducer branch (add after the `allowPermanent = …` line): +```kotlin + allowPermanent = event.bool("allow_permanent") ?: false, + smartDenied = event.bool("smart_denied") ?: false, +``` + +- [ ] **Step 4:** Run the test → PASS (existing approval tests + the 2 new ones). + +- [ ] **Step 5: Commit** +```bash +git add app/src/main/java/com/hermes/client/ui/chat/ChatUiState.kt \ + app/src/test/java/com/hermes/client/ui/chat/ChatReducerTest.kt +git commit -m "feat: extract smart_denied from approval.request events" +``` + +--- + +### Task 2: Approval sheet enrichment + +**Files:** Modify `app/src/main/java/com/hermes/client/ui/chat/ApprovalSheet.kt` + +**Interfaces:** Consumes `ApprovalRequest.smartDenied` + `patternKeys` (Task 1). + +- [ ] **Step 1: Add the Grants row + warning.** In `ApprovalSheet.kt`, after the `description` block (the `if (req.description.isNotBlank()) { … }`) and BEFORE `if (tier == ApprovalTier.STANDARD) { … }`, insert: +```kotlin + if (req.patternKeys.isNotEmpty()) { + Text( + "Grants: ${req.patternKeys.joinToString(", ")}", + style = MaterialTheme.typography.bodySmall, + color = MaterialTheme.colorScheme.onSurfaceVariant, + modifier = Modifier.padding(bottom = 8.dp), + ) + } + if (req.smartDenied) { + Text( + "Owner override — approve only this one operation.", + style = MaterialTheme.typography.bodySmall, + color = MaterialTheme.colorScheme.error, + modifier = Modifier.padding(bottom = 8.dp), + ) + } +``` +No other change (tier logic + buttons unchanged). + +- [ ] **Step 2: Compile + full suite + assembleBeta.** Run each (JAVA_HOME set): `:app:compileDebugKotlin`, `:app:testDebugUnitTest` (0 failures), `:app:assembleBeta` — all BUILD SUCCESSFUL. + +- [ ] **Step 3: Commit** +```bash +git add app/src/main/java/com/hermes/client/ui/chat/ApprovalSheet.kt +git commit -m "feat: show full allowlist grants + smart-denied warning on the approval sheet" +``` + +--- + +### Task 3: On-device verification (best-effort) + +- [ ] **Step 1:** `:app:installBeta`. +- [ ] **Step 2:** If a standard tool approval with multiple allowlist keys can be triggered, confirm the sheet shows "Grants: k1, k2" under the command/description; a `smart_denied` approval shows the red "Owner override…" caption. +- [ ] **Step 3:** Note: approvals require an agent tool call under manual-approval mode and can't be forced on demand — if none arises, record that the render is covered by the reducer tests + code review. Record pass/fail in the PR. + +--- + +## Notes for the executor +- Do NOT try to add tool name / args / cwd / diff — those aren't in the `approval.request` payload (a gateway change, explicit anti-scope). +- Keep the tier logic and buttons untouched; this is purely additive display. diff --git a/docs/superpowers/specs/2026-07-17-approval-context-design.md b/docs/superpowers/specs/2026-07-17-approval-context-design.md new file mode 100644 index 0000000..c6a8dcd --- /dev/null +++ b/docs/superpowers/specs/2026-07-17-approval-context-design.md @@ -0,0 +1,86 @@ +# Approval Context Enrichment — Design + +**Wave:** Quick-wins (client-only). **Branch:** `feature/approval-context` (off `dev`). + +**Goal:** Surface more of the context the `approval.request` event already carries on the approval sheet, so the user decides without opening the thread. Client-only — reads existing payload keys; no gateway change. + +**Constraints:** Kotlin/Compose/Material3. No AI attribution; gitleaks before push; PR into `dev`. + +## Reality check (from the gateway audit) +The `approval.request` event carries only: `command`, `description`, `pattern_keys`/`pattern_key`, `allow_permanent`, `smart_denied`, `choices`. There is **no** tool name, args, cwd, or diff in the payload (those would need a gateway change — out of scope). The sheet already shows the (redacted) `command` (monospace) + `description`. So the client-only enrichment is small: +1. Show **all** allowlist keys a permanent approval would grant (today only the first is shown, in the title). +2. Surface the `smart_denied` owner-override signal (currently discarded) as a distinct warning. + +## Scope +- **In:** add `smartDenied` to `ApprovalRequest` + extract it in the reducer; render a "Grants:" all-keys row and a `smart_denied` warning caption on `ApprovalSheet`. +- **Out (needs gateway):** tool name, arguments, working directory, diff/preview. + +## Architecture + +### 1. `ui/chat/ChatUiState.kt` (modify) +Add a field to `ApprovalRequest`: +```kotlin +data class ApprovalRequest( + val command: String, + val description: String, + val patternKeys: List, + val allowPermanent: Boolean, + val smartDenied: Boolean = false, +) +``` +Extract it in the `"approval.request"` reducer branch: +```kotlin + allowPermanent = event.bool("allow_permanent") ?: false, + smartDenied = event.bool("smart_denied") ?: false, +``` + +### 2. `ui/chat/ApprovalSheet.kt` (modify) +After the `description` block and before the tier buttons, add: +- **Grants row** — when `req.patternKeys.size > 0`, a `bodySmall` labeled line listing every key (so the user sees the full allowlist scope a Session/Always approval covers), styled `onSurfaceVariant`: + ```kotlin + if (req.patternKeys.isNotEmpty()) { + Text( + "Grants: ${req.patternKeys.joinToString(", ")}", + style = MaterialTheme.typography.bodySmall, + color = MaterialTheme.colorScheme.onSurfaceVariant, + modifier = Modifier.padding(bottom = 8.dp), + ) + } + ``` +- **smart_denied warning** — when `req.smartDenied`, an `error`-colored caption above the buttons: + ```kotlin + if (req.smartDenied) { + Text( + "Owner override — approve only this one operation.", + style = MaterialTheme.typography.bodySmall, + color = MaterialTheme.colorScheme.error, + modifier = Modifier.padding(bottom = 8.dp), + ) + } + ``` +No change to the tier logic or buttons. + +## Data flow +``` +approval.request event → reducer extracts command/description/patternKeys/allowPermanent/smartDenied → ApprovalRequest +ApprovalSheet → title (first key) + command (mono) + description + "Grants: " + [smart_denied warning] + tier buttons +``` + +## Error handling +- Missing `smart_denied` → false (no warning). Empty `patternKeys` → no Grants row. All fields default-safe; the reducer already tolerates absent keys. + +## Testing +- **`ChatReducerTest`** (extend): `approval.request` with `smart_denied=true` → `pendingApproval.smartDenied == true`; without it → false; with `pattern_keys` → `patternKeys` holds all keys. +- `ApprovalSheet` rendering is Compose glue — verified on-device (best-effort; approvals can't be forced on demand — the render logic is a straightforward conditional over the new fields). + +## On-device verification +When a standard approval with multiple allowlist keys arrives, the sheet shows "Grants: k1, k2"; a `smart_denied` (owner-override) approval shows the red warning caption. (Approvals require an agent tool call gated by manual-approval mode; best-effort — covered by the reducer tests + reviewed render.) + +## Files +| Action | Path | +|--------|------| +| Modify | `ui/chat/ChatUiState.kt` (`smartDenied` field + reducer) + `ChatReducerTest.kt` | +| Modify | `ui/chat/ApprovalSheet.kt` (Grants row + warning) | + +## Build & gates +`JAVA_HOME=…`: `:app:compileDebugKotlin`, `:app:testDebugUnitTest`, `:app:assembleBeta`. gitleaks before push; PR into `dev`. From fcd6fbfa0661f53bb526ab2c13f420ff5230387d Mon Sep 17 00:00:00 2001 From: Andrew Debnar <606931+adebnar@users.noreply.github.com> Date: Sat, 18 Jul 2026 00:42:50 +0000 Subject: [PATCH 13/16] feat: per-tenant persona picker (#107) * docs: spec + plan for per-tenant persona picker * feat: parse configured personas from gateway config * feat: ChatViewModel persona load + apply * feat: persona picker sheet in the chat overflow menu * fix: handle default sentinel + persona-apply rejection/failure; keep sheet open until resolved --- .../com/hermes/client/ui/chat/ChatScreen.kt | 19 ++ .../hermes/client/ui/chat/ChatViewModel.kt | 37 +++ .../java/com/hermes/client/ui/chat/Persona.kt | 27 +++ .../com/hermes/client/ui/chat/PersonaSheet.kt | 95 ++++++++ .../client/ui/chat/ChatViewModelTest.kt | 27 ++- .../com/hermes/client/ui/chat/PersonaTest.kt | 35 +++ .../plans/2026-07-17-persona-picker.md | 217 ++++++++++++++++++ .../specs/2026-07-17-persona-picker-design.md | 94 ++++++++ 8 files changed, 550 insertions(+), 1 deletion(-) create mode 100644 app/src/main/java/com/hermes/client/ui/chat/Persona.kt create mode 100644 app/src/main/java/com/hermes/client/ui/chat/PersonaSheet.kt create mode 100644 app/src/test/java/com/hermes/client/ui/chat/PersonaTest.kt create mode 100644 docs/superpowers/plans/2026-07-17-persona-picker.md create mode 100644 docs/superpowers/specs/2026-07-17-persona-picker-design.md diff --git a/app/src/main/java/com/hermes/client/ui/chat/ChatScreen.kt b/app/src/main/java/com/hermes/client/ui/chat/ChatScreen.kt index 8dd0902..f5109f0 100644 --- a/app/src/main/java/com/hermes/client/ui/chat/ChatScreen.kt +++ b/app/src/main/java/com/hermes/client/ui/chat/ChatScreen.kt @@ -112,6 +112,8 @@ fun ChatScreen( val speaking by vm.speaking.collectAsStateWithLifecycle() val savedPrompts by vm.savedPrompts.collectAsStateWithLifecycle() var showPromptSheet by remember { mutableStateOf(false) } + val personaUi by vm.personaUi.collectAsStateWithLifecycle() + var showPersonaSheet by remember { mutableStateOf(false) } androidx.compose.runtime.DisposableEffect(Unit) { onDispose { vm.stopReading() } } var draft by remember { mutableStateOf("") } var searchOpen by rememberSaveable { mutableStateOf(false) } @@ -318,6 +320,14 @@ fun ChatScreen( transcriptMenu = false }, ) + DropdownMenuItem( + text = { Text("Persona") }, + onClick = { + transcriptMenu = false + vm.loadPersonas() + showPersonaSheet = true + }, + ) } } }, @@ -621,6 +631,15 @@ fun ChatScreen( } } } + + if (showPersonaSheet) { + PersonaSheet( + ui = personaUi, + onPick = { vm.setPersona(it) }, + onRetry = { vm.loadPersonas() }, + onDismiss = { showPersonaSheet = false }, + ) + } } @Composable diff --git a/app/src/main/java/com/hermes/client/ui/chat/ChatViewModel.kt b/app/src/main/java/com/hermes/client/ui/chat/ChatViewModel.kt index 2634a71..a93e620 100644 --- a/app/src/main/java/com/hermes/client/ui/chat/ChatViewModel.kt +++ b/app/src/main/java/com/hermes/client/ui/chat/ChatViewModel.kt @@ -35,6 +35,7 @@ class ChatViewModel @Inject constructor( private val pendingShareStore: com.hermes.client.share.PendingShareStore, private val tts: com.hermes.client.data.tts.TextToSpeechController, private val promptStore: com.hermes.client.data.repository.PromptStore, + private val configRepo: com.hermes.client.data.repository.ConfigRepository, ) : ViewModel() { private val _state = MutableStateFlow(ChatUiState.empty()) @@ -339,4 +340,40 @@ class ChatViewModel @Inject constructor( fun selectProfile(name: String) { viewModelScope.launch { runCatching { profileRepo.setActive(name) } } } + + data class PersonaUi( + val personas: List = emptyList(), + val active: String? = null, + val loading: Boolean = false, + val error: String? = null, + ) + private val _personaUi = MutableStateFlow(PersonaUi()) + val personaUi: StateFlow = _personaUi.asStateFlow() + + /** Fetch the profile's configured personalities (called when the persona sheet opens). */ + fun loadPersonas() { + _personaUi.value = _personaUi.value.copy(loading = true, error = null) + viewModelScope.launch { + runCatching { configRepo.get(profileManager.active.value) } + .onSuccess { cfg -> _personaUi.value = PersonaUi(parsePersonas(cfg), activePersonaOf(cfg)) } + .onFailure { _personaUi.value = _personaUi.value.copy(loading = false, error = "Couldn't load personas") } + } + } + + /** Apply a persona to this session (null / "none" / "default" clears it). */ + fun setPersona(name: String?) { + val wire = name?.takeIf { it.isNotBlank() && !it.equals("none", true) && !it.equals("default", true) } ?: "none" + _personaUi.value = _personaUi.value.copy(loading = true, error = null) + viewModelScope.launch { + runCatching { chat.slashExec(sessionId, "/personality $wire") } + .onSuccess { out -> + if (out != null && out.contains("unknown", ignoreCase = true)) { + _personaUi.value = _personaUi.value.copy(loading = false, error = "Couldn't apply that persona") + } else { + _personaUi.value = _personaUi.value.copy(loading = false, active = if (wire == "none") null else wire) + } + } + .onFailure { _personaUi.value = _personaUi.value.copy(loading = false, error = "Couldn't apply persona") } + } + } } diff --git a/app/src/main/java/com/hermes/client/ui/chat/Persona.kt b/app/src/main/java/com/hermes/client/ui/chat/Persona.kt new file mode 100644 index 0000000..b0f87bf --- /dev/null +++ b/app/src/main/java/com/hermes/client/ui/chat/Persona.kt @@ -0,0 +1,27 @@ +package com.hermes.client.ui.chat + +import kotlinx.serialization.json.JsonObject +import kotlinx.serialization.json.JsonPrimitive + +/** A gateway-configured personality the user can apply to a session. */ +data class Persona(val name: String, val description: String) + +private fun JsonObject.objOrNull(key: String): JsonObject? = (this[key] as? JsonObject) +private fun JsonObject.strOrNull(key: String): String? = + (this[key] as? JsonPrimitive)?.takeIf { it.isString }?.content + +/** Personalities from `agent.personalities` (name → String | object). Sorted by name; never throws. */ +fun parsePersonas(config: JsonObject): List { + val personalities = config.objOrNull("agent")?.objOrNull("personalities") ?: return emptyList() + return personalities.entries.map { (name, value) -> + val desc = (value as? JsonObject)?.let { + it.strOrNull("description") ?: it.strOrNull("tone") ?: it.strOrNull("style") ?: "" + }.orEmpty().trim() + Persona(name, desc) + }.sortedBy { it.name.lowercase() } +} + +/** The active personality (`display.personality`); blank/"none"/"default" → null. */ +fun activePersonaOf(config: JsonObject): String? = + config.objOrNull("display")?.strOrNull("personality") + ?.takeIf { it.isNotBlank() && !it.equals("none", ignoreCase = true) && !it.equals("default", ignoreCase = true) } diff --git a/app/src/main/java/com/hermes/client/ui/chat/PersonaSheet.kt b/app/src/main/java/com/hermes/client/ui/chat/PersonaSheet.kt new file mode 100644 index 0000000..f9fa671 --- /dev/null +++ b/app/src/main/java/com/hermes/client/ui/chat/PersonaSheet.kt @@ -0,0 +1,95 @@ +package com.hermes.client.ui.chat + +import androidx.compose.foundation.clickable +import androidx.compose.foundation.layout.Box +import androidx.compose.foundation.layout.Column +import androidx.compose.foundation.layout.fillMaxWidth +import androidx.compose.foundation.layout.height +import androidx.compose.foundation.layout.padding +import androidx.compose.foundation.lazy.LazyColumn +import androidx.compose.foundation.lazy.items +import androidx.compose.material.icons.Icons +import androidx.compose.material.icons.rounded.Check +import androidx.compose.material3.CircularProgressIndicator +import androidx.compose.material3.ExperimentalMaterial3Api +import androidx.compose.material3.Icon +import androidx.compose.material3.ListItem +import androidx.compose.material3.MaterialTheme +import androidx.compose.material3.ModalBottomSheet +import androidx.compose.material3.Text +import androidx.compose.material3.TextButton +import androidx.compose.material3.rememberModalBottomSheetState +import androidx.compose.runtime.Composable +import androidx.compose.ui.Alignment +import androidx.compose.ui.Modifier +import androidx.compose.ui.unit.dp +import com.hermes.client.ui.theme.LocalProfileAccent + +@OptIn(ExperimentalMaterial3Api::class) +@Composable +fun PersonaSheet( + ui: ChatViewModel.PersonaUi, + onPick: (String?) -> Unit, + onRetry: () -> Unit, + onDismiss: () -> Unit, +) { + val accent = LocalProfileAccent.current.accent + val sheetState = rememberModalBottomSheetState() + + ModalBottomSheet(onDismissRequest = onDismiss, sheetState = sheetState) { + Column(Modifier.fillMaxWidth().padding(horizontal = 20.dp, vertical = 8.dp)) { + Text("Persona", style = MaterialTheme.typography.titleMedium, modifier = Modifier.padding(bottom = 8.dp)) + + when { + ui.loading -> { + Box(Modifier.fillMaxWidth().height(120.dp), contentAlignment = Alignment.Center) { + CircularProgressIndicator() + } + } + ui.error != null -> { + Text(ui.error, color = MaterialTheme.colorScheme.error, modifier = Modifier.padding(bottom = 8.dp)) + TextButton(onClick = onRetry) { Text("Retry") } + } + else -> { + LazyColumn(Modifier.fillMaxWidth()) { + item { + ListItem( + headlineContent = { Text("None (default)") }, + trailingContent = { + if (ui.active == null) { + Icon(Icons.Rounded.Check, contentDescription = "Active", tint = accent) + } + }, + modifier = Modifier.clickable { onPick(null) }, + ) + } + if (ui.personas.isEmpty()) { + item { + Text( + "No personas configured — add them in your gateway config.", + style = MaterialTheme.typography.bodySmall, + color = MaterialTheme.colorScheme.onSurfaceVariant, + modifier = Modifier.padding(horizontal = 16.dp, vertical = 8.dp), + ) + } + } + items(ui.personas, key = { it.name }) { p -> + ListItem( + headlineContent = { Text(p.name) }, + supportingContent = if (p.description.isNotBlank()) { + { Text(p.description) } + } else null, + trailingContent = { + if (p.name == ui.active) { + Icon(Icons.Rounded.Check, contentDescription = "Active", tint = accent) + } + }, + modifier = Modifier.clickable { onPick(p.name) }, + ) + } + } + } + } + } + } +} diff --git a/app/src/test/java/com/hermes/client/ui/chat/ChatViewModelTest.kt b/app/src/test/java/com/hermes/client/ui/chat/ChatViewModelTest.kt index bb02277..493df12 100644 --- a/app/src/test/java/com/hermes/client/ui/chat/ChatViewModelTest.kt +++ b/app/src/test/java/com/hermes/client/ui/chat/ChatViewModelTest.kt @@ -42,6 +42,7 @@ class ChatViewModelTest { private val pendingShareStore = com.hermes.client.share.PendingShareStore() private val tts = mockk(relaxed = true) private val promptStore = mockk(relaxed = true) + private val configRepo = mockk(relaxed = true) @Before fun setUp() { Dispatchers.setMain(StandardTestDispatcher()) @@ -60,7 +61,7 @@ class ChatViewModelTest { every { promptStore.prompts } returns MutableStateFlow(emptyList()) } - private fun buildVm() = ChatViewModel(chatRepo, sessionRepo, modelRepo, profileRepo, profileManager, favoritesStore, pendingShareStore, tts, promptStore) + private fun buildVm() = ChatViewModel(chatRepo, sessionRepo, modelRepo, profileRepo, profileManager, favoritesStore, pendingShareStore, tts, promptStore, configRepo) @Test fun streamed_delta_appears_in_state() = runTest { val vm = buildVm() @@ -269,4 +270,28 @@ class ChatViewModelTest { vm.stopReading() io.mockk.verify { tts.stop() } } + + @Test fun setPersona_sends_personality_slash() = runTest { + val vm = buildVm() + vm.setPersona("witty"); advanceUntilIdle() + io.mockk.coVerify { chatRepo.slashExec(any(), "/personality witty") } + } + + @Test fun setPersona_null_clears_with_none() = runTest { + val vm = buildVm() + vm.setPersona(null); advanceUntilIdle() + io.mockk.coVerify { chatRepo.slashExec(any(), "/personality none") } + } + + // chat.slashExec returns command-level errors in its output string (only transport failures + // throw), so a gateway rejection of an unknown persona must surface as an error, not silently + // set active — otherwise the UI would show a persona as applied when the gateway refused it. + @Test fun setPersona_rejection_surfaces_error_and_does_not_set_active() = runTest { + coEvery { chatRepo.slashExec(any(), any()) } returns "unknown personality: x" + val vm = buildVm() + vm.setPersona("bad"); advanceUntilIdle() + + assertTrue("a gateway rejection must surface a persona error", vm.personaUi.value.error != null) + assertEquals(null, vm.personaUi.value.active) + } } diff --git a/app/src/test/java/com/hermes/client/ui/chat/PersonaTest.kt b/app/src/test/java/com/hermes/client/ui/chat/PersonaTest.kt new file mode 100644 index 0000000..a77c26c --- /dev/null +++ b/app/src/test/java/com/hermes/client/ui/chat/PersonaTest.kt @@ -0,0 +1,35 @@ +package com.hermes.client.ui.chat + +import kotlinx.serialization.json.Json +import kotlinx.serialization.json.JsonObject +import org.junit.Assert.assertEquals +import org.junit.Assert.assertNull +import org.junit.Test + +class PersonaTest { + private fun obj(s: String): JsonObject = Json.decodeFromString(JsonObject.serializer(), s) + + @Test fun parses_string_and_object_personas_sorted() { + val c = obj("""{"agent":{"personalities":{"witty":"Be witty","coach":{"description":"A coach","tone":"warm"}}}}""") + val ps = parsePersonas(c) + assertEquals(listOf("coach", "witty"), ps.map { it.name }) // sorted + assertEquals("A coach", ps.first { it.name == "coach" }.description) // object → description + assertEquals("", ps.first { it.name == "witty" }.description) // string → no description + } + + @Test fun missing_agent_or_personalities_is_empty() { + assertEquals(emptyList(), parsePersonas(obj("""{}"""))) + assertEquals(emptyList(), parsePersonas(obj("""{"agent":{}}"""))) + } + + @Test fun active_persona_reads_display_and_maps_none_to_null() { + assertEquals("coach", activePersonaOf(obj("""{"display":{"personality":"coach"}}"""))) + assertNull(activePersonaOf(obj("""{"display":{"personality":"none"}}"""))) + assertNull(activePersonaOf(obj("""{"display":{"personality":""}}"""))) + assertNull(activePersonaOf(obj("""{}"""))) + } + + @Test fun active_persona_maps_default_to_null() { + assertNull(activePersonaOf(obj("""{"display":{"personality":"default"}}"""))) + } +} diff --git a/docs/superpowers/plans/2026-07-17-persona-picker.md b/docs/superpowers/plans/2026-07-17-persona-picker.md new file mode 100644 index 0000000..813d873 --- /dev/null +++ b/docs/superpowers/plans/2026-07-17-persona-picker.md @@ -0,0 +1,217 @@ +# Per-Tenant Persona Picker Implementation Plan + +> **For agentic workers:** REQUIRED SUB-SKILL: superpowers:subagent-driven-development. Checkbox steps. + +**Goal:** Pick a gateway-configured personality and apply it to the session, from the chat overflow menu. + +**Spec:** `docs/superpowers/specs/2026-07-17-persona-picker-design.md` + +## Global Constraints +- Client-only; read personas via `ConfigRepository.get(activeProfile)`, set via `slashExec("/personality ")`. No AI attribution. +- Build env: `export JAVA_HOME="/Applications/Android Studio.app/Contents/jbr/Contents/Home"`. +- Branch `feature/persona-picker` (off `dev`). + +--- + +### Task 1: Persona model + pure parsers + +**Files:** Create `app/src/main/java/com/hermes/client/ui/chat/Persona.kt`; Test `app/src/test/java/com/hermes/client/ui/chat/PersonaTest.kt` + +- [ ] **Step 1: Write the failing test** `PersonaTest.kt`: +```kotlin +package com.hermes.client.ui.chat + +import kotlinx.serialization.json.Json +import kotlinx.serialization.json.JsonObject +import org.junit.Assert.assertEquals +import org.junit.Assert.assertNull +import org.junit.Test + +class PersonaTest { + private fun obj(s: String): JsonObject = Json.decodeFromString(JsonObject.serializer(), s) + + @Test fun parses_string_and_object_personas_sorted() { + val c = obj("""{"agent":{"personalities":{"witty":"Be witty","coach":{"description":"A coach","tone":"warm"}}}}""") + val ps = parsePersonas(c) + assertEquals(listOf("coach", "witty"), ps.map { it.name }) // sorted + assertEquals("A coach", ps.first { it.name == "coach" }.description) // object → description + assertEquals("", ps.first { it.name == "witty" }.description) // string → no description + } + + @Test fun missing_agent_or_personalities_is_empty() { + assertEquals(emptyList(), parsePersonas(obj("""{}"""))) + assertEquals(emptyList(), parsePersonas(obj("""{"agent":{}}"""))) + } + + @Test fun active_persona_reads_display_and_maps_none_to_null() { + assertEquals("coach", activePersonaOf(obj("""{"display":{"personality":"coach"}}"""))) + assertNull(activePersonaOf(obj("""{"display":{"personality":"none"}}"""))) + assertNull(activePersonaOf(obj("""{"display":{"personality":""}}"""))) + assertNull(activePersonaOf(obj("""{}"""))) + } +} +``` +(Delete the stray `cfg` helper if it doesn't compile — only `obj(...)` is needed. Match the project's kotlinx.serialization imports.) + +- [ ] **Step 2:** Run `./gradlew :app:testDebugUnitTest --tests "com.hermes.client.ui.chat.PersonaTest"` → FAIL. + +- [ ] **Step 3: Implement** `Persona.kt`: +```kotlin +package com.hermes.client.ui.chat + +import kotlinx.serialization.json.JsonObject +import kotlinx.serialization.json.JsonPrimitive +import kotlinx.serialization.json.jsonObject +import kotlinx.serialization.json.jsonPrimitive + +/** A gateway-configured personality the user can apply to a session. */ +data class Persona(val name: String, val description: String) + +private fun JsonObject.objOrNull(key: String): JsonObject? = (this[key] as? JsonObject) +private fun JsonObject.strOrNull(key: String): String? = + (this[key] as? JsonPrimitive)?.takeIf { it.isString }?.content + +/** Personalities from `agent.personalities` (name → String | object). Sorted by name; never throws. */ +fun parsePersonas(config: JsonObject): List { + val personalities = config.objOrNull("agent")?.objOrNull("personalities") ?: return emptyList() + return personalities.entries.map { (name, value) -> + val desc = (value as? JsonObject)?.let { + it.strOrNull("description") ?: it.strOrNull("tone") ?: it.strOrNull("style") ?: "" + }.orEmpty().trim() + Persona(name, desc) + }.sortedBy { it.name.lowercase() } +} + +/** The active personality (`display.personality`); blank/"none" → null. */ +fun activePersonaOf(config: JsonObject): String? = + config.objOrNull("display")?.strOrNull("personality") + ?.takeIf { it.isNotBlank() && !it.equals("none", ignoreCase = true) } +``` + +- [ ] **Step 4:** Run the test → PASS. + +- [ ] **Step 5: Commit** +```bash +git add app/src/main/java/com/hermes/client/ui/chat/Persona.kt \ + app/src/test/java/com/hermes/client/ui/chat/PersonaTest.kt +git commit -m "feat: parse configured personas from gateway config" +``` + +--- + +### Task 2: ChatViewModel persona wiring + +**Files:** Modify `app/src/main/java/com/hermes/client/ui/chat/ChatViewModel.kt`, `app/src/main/java/com/hermes/client/di/AppModule.kt` (only if `ConfigRepository` isn't already provided — check first); Test `app/src/test/java/com/hermes/client/ui/chat/ChatViewModelTest.kt` (extend) + +**Interfaces:** Consumes `parsePersonas`/`activePersonaOf` (Task 1), `ConfigRepository.get(profile)`, `ChatRepository.slashExec`. + +- [ ] **Step 1: Confirm `ConfigRepository` is Hilt-provided.** `grep -n "ConfigRepository" app/src/main/java/com/hermes/client/di/AppModule.kt` — it's already used by the settings screens, so a provider likely exists. If NOT, add `@Provides @Singleton fun provideConfigRepository(rest: HermesRestApi): ConfigRepository = ConfigRepository(rest)`. + +- [ ] **Step 2: Write the failing test (extend `ChatViewModelTest`).** Add a mock + `buildVm` arg + tests: +```kotlin + private val configRepo = mockk(relaxed = true) +``` +Update `buildVm()` to pass `configRepo` as the new last arg. Add: +```kotlin + @Test fun setPersona_sends_personality_slash() = runTest { + val vm = buildVm() + vm.setPersona("witty"); advanceUntilIdle() + io.mockk.coVerify { chatRepo.slashExec(any(), "/personality witty") } + } + + @Test fun setPersona_null_clears_with_none() = runTest { + val vm = buildVm() + vm.setPersona(null); advanceUntilIdle() + io.mockk.coVerify { chatRepo.slashExec(any(), "/personality none") } + } +``` +(If the test file's mockk/runTest conventions differ, match them; the `slashExec` verifications are the contract. `chatRepo` is the existing ChatRepository mock name in this test.) + +- [ ] **Step 3:** Run `./gradlew :app:testDebugUnitTest --tests "com.hermes.client.ui.chat.ChatViewModelTest"` → FAIL. + +- [ ] **Step 4: Implement** in `ChatViewModel.kt`: +Add the constructor param (new last): +```kotlin + private val configRepo: com.hermes.client.data.repository.ConfigRepository, +``` +Add the state + methods: +```kotlin + data class PersonaUi( + val personas: List = emptyList(), + val active: String? = null, + val loading: Boolean = false, + val error: String? = null, + ) + private val _personaUi = MutableStateFlow(PersonaUi()) + val personaUi: StateFlow = _personaUi.asStateFlow() + + /** Fetch the profile's configured personalities (called when the persona sheet opens). */ + fun loadPersonas() { + _personaUi.value = _personaUi.value.copy(loading = true, error = null) + viewModelScope.launch { + runCatching { configRepo.get(profileManager.active.value) } + .onSuccess { cfg -> _personaUi.value = PersonaUi(parsePersonas(cfg), activePersonaOf(cfg)) } + .onFailure { _personaUi.value = _personaUi.value.copy(loading = false, error = "Couldn't load personas") } + } + } + + /** Apply a persona to this session (null / "none" clears it). */ + fun setPersona(name: String?) { + val wire = name?.takeIf { it.isNotBlank() && !it.equals("none", true) } ?: "none" + viewModelScope.launch { + runCatching { chat.slashExec(sessionId, "/personality $wire") } + .onSuccess { _personaUi.value = _personaUi.value.copy(active = if (wire == "none") null else wire) } + } + } +``` +(Ensure `MutableStateFlow`/`StateFlow`/`asStateFlow`/`launch` imports exist — they do, used throughout this VM.) + +- [ ] **Step 5:** Run the test → PASS. Then `:app:compileDebugKotlin` → BUILD SUCCESSFUL. + +- [ ] **Step 6: Commit** +```bash +git add app/src/main/java/com/hermes/client/ui/chat/ChatViewModel.kt \ + app/src/main/java/com/hermes/client/di/AppModule.kt \ + app/src/test/java/com/hermes/client/ui/chat/ChatViewModelTest.kt +git commit -m "feat: ChatViewModel persona load + apply" +``` +(Drop `AppModule.kt` from the add if you didn't need to change it.) + +--- + +### Task 3: Persona sheet + overflow item + +**Files:** Create `app/src/main/java/com/hermes/client/ui/chat/PersonaSheet.kt`; Modify `app/src/main/java/com/hermes/client/ui/chat/ChatScreen.kt` + +**Interfaces:** Consumes `ChatViewModel.personaUi`/`loadPersonas`/`setPersona` (Task 2). + +- [ ] **Step 1: PersonaSheet composable.** Create `PersonaSheet.kt` with `@OptIn(ExperimentalMaterial3Api::class) @Composable fun PersonaSheet(ui: PersonaUi, onPick: (String?) -> Unit, onRetry: () -> Unit, onDismiss: () -> Unit)`: a `ModalBottomSheet` containing a title "Persona"; when `ui.loading` a centered spinner; when `ui.error != null` the error text + a "Retry" `TextButton(onRetry)`; else a `LazyColumn` with a "None (default)" `ListItem` (`onClick = { onPick(null) }`) followed by one `ListItem` per `ui.personas` (headline = name, supporting = description when non-blank, `onClick = { onPick(p.name) }`); mark the active row (`p.name == ui.active`, or None when `ui.active == null`) with a trailing check `Icon` tinted `LocalProfileAccent.current.accent`. When `ui.personas` is empty and not loading/error, show a hint `Text("No personas configured — add them in your gateway config.")` under the None row. (`PersonaUi` is nested in `ChatViewModel`; reference it as `ChatViewModel.PersonaUi`.) + +- [ ] **Step 2: Overflow item + host in ChatScreen.** In `ChatScreen.kt`: + - Add `var showPersonaSheet by remember { mutableStateOf(false) }` and `val personaUi by vm.personaUi.collectAsStateWithLifecycle()`. + - In the top-bar overflow `DropdownMenu` (the Copy/Share transcript one), add a `DropdownMenuItem(text = { Text("Persona") }, onClick = { transcriptMenu = false; vm.loadPersonas(); showPersonaSheet = true })`. + - After the existing sheets/hosts, add: `if (showPersonaSheet) { PersonaSheet(ui = personaUi, onPick = { vm.setPersona(it); showPersonaSheet = false }, onRetry = { vm.loadPersonas() }, onDismiss = { showPersonaSheet = false }) }`. + +- [ ] **Step 3: Compile + full suite + assembleBeta** — all three (JAVA_HOME set) BUILD SUCCESSFUL. + +- [ ] **Step 4: Commit** +```bash +git add app/src/main/java/com/hermes/client/ui/chat/PersonaSheet.kt \ + app/src/main/java/com/hermes/client/ui/chat/ChatScreen.kt +git commit -m "feat: persona picker sheet in the chat overflow menu" +``` + +--- + +### Task 4: On-device verification (best-effort) + +- [ ] **Step 1:** `:app:installBeta`. Connect to a gateway that has ≥1 configured personality (`agent.personalities` in its config). +- [ ] **Step 2:** Open a chat → overflow ⋮ → Persona → the sheet lists the configured personas + "None (default)", marking the active one. Tap one → applies (sheet closes; the active mark moves). Tap None → clears. +- [ ] **Step 3:** If no personas are configured, confirm only "None (default)" + the hint appears; if the config fetch fails, the error + Retry appears. +- [ ] **Step 4:** Note: requires a connected session + configured personas; if unavailable, record that the parse + apply are covered by `PersonaTest` + `ChatViewModelTest` + review. Record in the PR. + +--- + +## Notes for the executor +- Do NOT add persona creation/editing (gateway-config-owned), profile-default persistence (`config.set` — deferred), or a non-chat persona surface — anti-scope. +- Read personas for the SESSION's active profile (the `profileManager.active.value` passed to `configRepo.get`) so a name valid for this tenant isn't rejected. diff --git a/docs/superpowers/specs/2026-07-17-persona-picker-design.md b/docs/superpowers/specs/2026-07-17-persona-picker-design.md new file mode 100644 index 0000000..adf4561 --- /dev/null +++ b/docs/superpowers/specs/2026-07-17-persona-picker-design.md @@ -0,0 +1,94 @@ +# Per-Tenant Persona Picker — Design + +**Wave:** Quick-wins (client-only). **Branch:** `feature/persona-picker` (off `dev`). + +**Goal:** Let the user pick one of the personalities already configured in the gateway (per-profile) and apply it to the current session — exposing an existing gateway capability with no backend change. + +**Constraints:** Kotlin/Compose/Material3/Hilt, per-tenant. No AI attribution; gitleaks before push; PR into `dev`. + +## Feasibility (from the gateway audit) +- **Read:** `GET /api/config` (already used via `ConfigRepository.get(profile)`) returns `agent.personalities` (a map `name → String | {system_prompt, tone, style, …}`) and `display.personality` (the active one). No gateway change to populate a picker. +- **Set:** the existing `slashExec(sessionId, "/personality ")` applies a pre-registered persona to the session (session-scoped/ephemeral; gated to configured names by the gateway; `none`/`default` clears it). Mirrors the model picker's `/model … --session`. +- **Per-tenant for free:** config read + the slash apply are both profile-scoped. Read personalities for the **session's** profile (the active profile). + +## Scope +- **In:** parse personalities + active from the config; a "Persona" item in the chat overflow menu → a bottom sheet listing them (+ "None (default)"), highlighting the active; apply via `slashExec`. +- **Out (deferred):** persisting the choice as the profile default (`config.set key=personality` — a separate small RPC); creating/editing personas (they're gateway-config-managed); a persona picker on non-chat surfaces. + +## Architecture + +### 1. `ui/chat/Persona.kt` (new) — model + pure parsers +```kotlin +data class Persona(val name: String, val description: String) + +/** Personalities configured for this profile, from the gateway config JSON (agent.personalities). */ +fun parsePersonas(config: JsonObject): List +/** The active personality name (display.personality), or null/"none" when unset. */ +fun activePersonaOf(config: JsonObject): String? +``` +`parsePersonas`: read `config["agent"]?.jsonObject?["personalities"]?.jsonObject`; for each entry, `name = key`; `description` = if the value is a JSON object, its `description`/`tone`/`style` (first available, trimmed) else "" (string-valued personas have no separate description). Sorted by name. Never throws (missing keys → empty list). `activePersonaOf`: `config["display"]?.jsonObject?["personality"]` as string; map blank/`"none"` → null. + +### 2. `ui/chat/ChatViewModel.kt` (modify) +Inject `ConfigRepository` (new last constructor param). Add: +```kotlin +data class PersonaUi(val personas: List = emptyList(), val active: String? = null, val loading: Boolean = false, val error: String? = null) +val personaUi: StateFlow // MutableStateFlow-backed + +fun loadPersonas() { // called when the sheet opens + _personaUi.value = _personaUi.value.copy(loading = true, error = null) + viewModelScope.launch { + runCatching { configRepo.get(profileManager.active.value) } + .onSuccess { cfg -> _personaUi.value = PersonaUi(parsePersonas(cfg), activePersonaOf(cfg)) } + .onFailure { _personaUi.value = _personaUi.value.copy(loading = false, error = "Couldn't load personas") } + } +} + +fun setPersona(name: String?) { // null / "none" clears + val wire = name?.takeIf { it.isNotBlank() } ?: "none" + viewModelScope.launch { + runCatching { chat.slashExec(sessionId, "/personality $wire") } + .onSuccess { _personaUi.value = _personaUi.value.copy(active = name?.takeIf { it != "none" }) } + } +} +``` + +### 3. `ui/chat/ChatScreen.kt` (modify) — overflow item + sheet +- In the existing top-bar overflow `DropdownMenu` (the one with Copy/Share transcript), add a **"Persona"** `DropdownMenuItem` → `showPersonaSheet = true; transcriptMenu = false; vm.loadPersonas()`. +- Host a `PersonaSheet` (a `ModalBottomSheet`, gated by `var showPersonaSheet`) that renders `vm.personaUi`: + - a "None (default)" row + one row per `Persona` (headline = name, supporting = description if non-blank), the active one visually marked (accent check / bold); + - loading → a spinner; error → the error text + a Retry (`vm.loadPersonas()`); + - on a row tap → `vm.setPersona(name)` (or `null` for None) + close the sheet. + Use `LocalProfileAccent` for the active highlight (per-tenant accent). + +### 4. `ui/chat/PersonaSheet.kt` (new) — the sheet composable (or inline in ChatScreen) +A small `@Composable fun PersonaSheet(ui: PersonaUi, onPick: (String?) -> Unit, onRetry: () -> Unit, onDismiss: () -> Unit)`. + +## Data flow +``` +overflow ⋮ → Persona → vm.loadPersonas() → ConfigRepository.get(activeProfile) → parsePersonas + activePersonaOf → PersonaUi +PersonaSheet → pick name → vm.setPersona(name) → slashExec("/personality ") → active updated → sheet closes +``` + +## Error handling +- Config fetch fails → error state + Retry; no crash. +- No `agent.personalities` configured → empty list; the sheet shows only "None (default)" with an "Add personas in your gateway config" hint. +- `slashExec` failure → active unchanged (the gateway rejects unknown names; only configured names are offered, so this is rare). + +## Testing +- **`PersonaTest`** (pure): `parsePersonas` of a config with string-valued + object-valued personas → correct names/descriptions, sorted; missing `agent`/`personalities` → empty; `activePersonaOf` reads `display.personality`, maps blank/"none" → null. +- **`ChatViewModelTest`** (extend, mock `ConfigRepository` + `ChatRepository`): `setPersona("witty")` calls `slashExec(sid, "/personality witty")`; `setPersona(null)` → `"/personality none"`; `loadPersonas()` populates `personaUi` from a stubbed config. +- The sheet + overflow are Compose glue — verified on-device (best-effort). + +## On-device verification +With a gateway that has ≥1 configured personality: chat overflow ⋮ → Persona → the sheet lists them + None, marking the active; tap one → applies (subsequent replies reflect it), sheet closes. With none configured → only "None (default)" + the hint. + +## Files +| Action | Path | +|--------|------| +| New | `ui/chat/Persona.kt` (model + parsers) + `PersonaTest.kt` | +| Modify | `ui/chat/ChatViewModel.kt` (ConfigRepository + personaUi/loadPersonas/setPersona) + `ChatViewModelTest.kt` | +| New | `ui/chat/PersonaSheet.kt` | +| Modify | `ui/chat/ChatScreen.kt` (overflow item + sheet host) | + +## Build & gates +`JAVA_HOME=…`: `:app:compileDebugKotlin`, `:app:testDebugUnitTest`, `:app:assembleBeta`. gitleaks before push; PR into `dev`. From f17267097e2cc4cf93479527001c1dce29b72c22 Mon Sep 17 00:00:00 2001 From: Andrew Debnar <606931+adebnar@users.noreply.github.com> Date: Sat, 18 Jul 2026 01:44:49 +0000 Subject: [PATCH 14/16] =?UTF-8?q?feat:=20record-to-task=20(voice=20note=20?= =?UTF-8?q?=E2=86=92=20transcribe=20=E2=86=92=20new=20prefilled=20chat)=20?= =?UTF-8?q?(#108)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit * docs: record-to-task (v2) spec + implementation plan * feat: add transcribe() REST call for /api/audio/transcribe * feat: audio data-url encoder + RECORD_AUDIO permission * feat: MediaRecorder-backed AudioRecorder + Hilt provider * fix: release recorder + delete temp file on record start/read failure * feat: RecordTaskViewModel orchestrating record→transcribe→new prefilled chat * fix: guard stopAndTranscribe reentrancy + encode audio off main thread * feat: record-a-task mic action + sheet on the home session list * fix: treat explicit null transcript as empty in transcribe() --- app/src/main/AndroidManifest.xml | 1 + .../hermes/client/data/audio/AudioDataUrl.kt | 5 + .../hermes/client/data/audio/AudioRecorder.kt | 75 +++ .../client/data/network/HermesRestApi.kt | 20 + .../java/com/hermes/client/di/AppModule.kt | 5 + .../client/ui/record/RecordTaskSheet.kt | 91 +++ .../client/ui/record/RecordTaskViewModel.kt | 150 +++++ .../client/ui/sessions/SessionsScreen.kt | 63 ++ .../client/data/audio/AudioDataUrlTest.kt | 19 + .../network/HermesRestApiTranscribeTest.kt | 60 ++ .../ui/record/RecordTaskViewModelTest.kt | 126 ++++ .../plans/2026-07-17-record-to-task.md | 620 ++++++++++++++++++ .../specs/2026-07-17-record-to-task-design.md | 120 ++++ 13 files changed, 1355 insertions(+) create mode 100644 app/src/main/java/com/hermes/client/data/audio/AudioDataUrl.kt create mode 100644 app/src/main/java/com/hermes/client/data/audio/AudioRecorder.kt create mode 100644 app/src/main/java/com/hermes/client/ui/record/RecordTaskSheet.kt create mode 100644 app/src/main/java/com/hermes/client/ui/record/RecordTaskViewModel.kt create mode 100644 app/src/test/java/com/hermes/client/data/audio/AudioDataUrlTest.kt create mode 100644 app/src/test/java/com/hermes/client/data/network/HermesRestApiTranscribeTest.kt create mode 100644 app/src/test/java/com/hermes/client/ui/record/RecordTaskViewModelTest.kt create mode 100644 docs/superpowers/plans/2026-07-17-record-to-task.md create mode 100644 docs/superpowers/specs/2026-07-17-record-to-task-design.md diff --git a/app/src/main/AndroidManifest.xml b/app/src/main/AndroidManifest.xml index 8139aae..ec80db8 100644 --- a/app/src/main/AndroidManifest.xml +++ b/app/src/main/AndroidManifest.xml @@ -4,6 +4,7 @@ + diff --git a/app/src/main/java/com/hermes/client/data/audio/AudioDataUrl.kt b/app/src/main/java/com/hermes/client/data/audio/AudioDataUrl.kt new file mode 100644 index 0000000..947d1fb --- /dev/null +++ b/app/src/main/java/com/hermes/client/data/audio/AudioDataUrl.kt @@ -0,0 +1,5 @@ +package com.hermes.client.data.audio + +/** Build a base64 data URL the gateway's transcribe endpoint accepts: data:;base64,. */ +fun audioDataUrl(bytes: ByteArray, mime: String): String = + "data:$mime;base64," + java.util.Base64.getEncoder().encodeToString(bytes) diff --git a/app/src/main/java/com/hermes/client/data/audio/AudioRecorder.kt b/app/src/main/java/com/hermes/client/data/audio/AudioRecorder.kt new file mode 100644 index 0000000..e10851f --- /dev/null +++ b/app/src/main/java/com/hermes/client/data/audio/AudioRecorder.kt @@ -0,0 +1,75 @@ +package com.hermes.client.data.audio + +import android.content.Context +import android.media.MediaRecorder +import android.os.Build +import java.io.File + +/** A captured voice note. */ +data class Recording(val bytes: ByteArray, val mime: String) { + override fun equals(other: Any?) = + other is Recording && mime == other.mime && bytes.contentEquals(other.bytes) + override fun hashCode() = 31 * bytes.contentHashCode() + mime.hashCode() +} + +/** Records a single voice note. Interface so RecordTaskViewModel is testable with a fake. */ +interface AudioRecorder { + fun start() + fun stop(): Recording? + fun cancel() +} + +/** MediaRecorder-backed recorder writing audio/mp4 (AAC) to an app-cache temp file. */ +class MediaAudioRecorder(private val context: Context) : AudioRecorder { + private var recorder: MediaRecorder? = null + private var outputFile: File? = null + + override fun start() { + if (recorder != null) return + val file = File.createTempFile("rec_", ".m4a", context.cacheDir) + val rec = if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.S) MediaRecorder(context) + else @Suppress("DEPRECATION") MediaRecorder() + rec.setAudioSource(MediaRecorder.AudioSource.MIC) + rec.setOutputFormat(MediaRecorder.OutputFormat.MPEG_4) + rec.setAudioEncoder(MediaRecorder.AudioEncoder.AAC) + rec.setAudioEncodingBitRate(96_000) + rec.setAudioSamplingRate(44_100) + rec.setOutputFile(file.absolutePath) + try { + rec.prepare() + rec.start() + } catch (e: Exception) { + runCatching { rec.release() } + file.delete() + throw e + } + recorder = rec + outputFile = file + } + + override fun stop(): Recording? { + val rec = recorder ?: return null + val file = outputFile + recorder = null + outputFile = null + val stopped = runCatching { rec.stop() }.isSuccess + runCatching { rec.release() } + if (!stopped || file == null || !file.exists() || file.length() == 0L) { + file?.delete() + return null + } + val bytes = runCatching { file.readBytes() }.getOrNull() + file.delete() + return bytes?.let { Recording(it, "audio/mp4") } + } + + override fun cancel() { + val rec = recorder ?: return + val file = outputFile + recorder = null + outputFile = null + runCatching { rec.stop() } + runCatching { rec.release() } + file?.delete() + } +} diff --git a/app/src/main/java/com/hermes/client/data/network/HermesRestApi.kt b/app/src/main/java/com/hermes/client/data/network/HermesRestApi.kt index 7a4aee5..eecc8cd 100644 --- a/app/src/main/java/com/hermes/client/data/network/HermesRestApi.kt +++ b/app/src/main/java/com/hermes/client/data/network/HermesRestApi.kt @@ -4,6 +4,7 @@ import com.hermes.client.data.auth.GatewayConfig import kotlinx.coroutines.Dispatchers import kotlinx.coroutines.withContext import kotlinx.serialization.json.Json +import kotlinx.serialization.json.JsonNull import kotlinx.serialization.json.JsonObject import kotlinx.serialization.json.buildJsonObject import kotlinx.serialization.json.jsonPrimitive @@ -292,6 +293,25 @@ class HermesRestApi( } } + /** + * Transcribe a recorded voice note. [dataUrl] is a base64 data URL (data:;base64,) + * the gateway's POST /api/audio/transcribe accepts; returns the trimmed transcript ("" if the + * STT backend returned nothing). Throws HermesApiException on a non-2xx (e.g. no STT configured). + */ + suspend fun transcribe(dataUrl: String, mimeType: String): String = withContext(Dispatchers.IO) { + val obj = buildJsonObject { put("data_url", dataUrl); put("mime_type", mimeType) } + val payload = json.encodeToString(JsonObject.serializer(), obj) + .toRequestBody("application/json".toMediaType()) + okHttp.newCall(builder("/api/audio/transcribe").post(payload).build()).execute().use { resp -> + val body = resp.body?.string().orEmpty() + if (!resp.isSuccessful) throw HermesApiException(resp.code, "transcription failed") + // Treat an explicit JSON null (out-of-contract, but guards against a phantom "null" + // transcript) the same as a missing field → "". + val el = json.decodeFromString(body)["transcript"] + if (el == null || el is JsonNull) "" else el.jsonPrimitive.content.trim() + } + } + suspend fun skills(): List = get("/api/skills") suspend fun toggleSkill(name: String, enabled: Boolean) = withContext(Dispatchers.IO) { diff --git a/app/src/main/java/com/hermes/client/di/AppModule.kt b/app/src/main/java/com/hermes/client/di/AppModule.kt index 0dccf81..8c3e9df 100644 --- a/app/src/main/java/com/hermes/client/di/AppModule.kt +++ b/app/src/main/java/com/hermes/client/di/AppModule.kt @@ -224,4 +224,9 @@ object AppModule { @Singleton fun providePromptStore(@ApplicationContext context: Context): com.hermes.client.data.repository.PromptStore = com.hermes.client.data.repository.PromptStore(context) + + @Provides + @Singleton + fun provideAudioRecorder(@ApplicationContext context: Context): com.hermes.client.data.audio.AudioRecorder = + com.hermes.client.data.audio.MediaAudioRecorder(context) } diff --git a/app/src/main/java/com/hermes/client/ui/record/RecordTaskSheet.kt b/app/src/main/java/com/hermes/client/ui/record/RecordTaskSheet.kt new file mode 100644 index 0000000..7274854 --- /dev/null +++ b/app/src/main/java/com/hermes/client/ui/record/RecordTaskSheet.kt @@ -0,0 +1,91 @@ +package com.hermes.client.ui.record + +import androidx.compose.foundation.background +import androidx.compose.foundation.layout.Box +import androidx.compose.foundation.layout.Column +import androidx.compose.foundation.layout.fillMaxWidth +import androidx.compose.foundation.layout.padding +import androidx.compose.foundation.layout.size +import androidx.compose.foundation.shape.CircleShape +import androidx.compose.material3.Button +import androidx.compose.material3.CircularProgressIndicator +import androidx.compose.material3.ExperimentalMaterial3Api +import androidx.compose.material3.MaterialTheme +import androidx.compose.material3.ModalBottomSheet +import androidx.compose.material3.Text +import androidx.compose.material3.TextButton +import androidx.compose.material3.rememberModalBottomSheetState +import androidx.compose.runtime.Composable +import androidx.compose.ui.Alignment +import androidx.compose.ui.Modifier +import androidx.compose.ui.semantics.contentDescription +import androidx.compose.ui.semantics.semantics +import androidx.compose.ui.unit.dp +import com.hermes.client.ui.theme.LocalProfileAccent + +/** + * Bottom sheet driving the record -> transcribe -> new-chat flow. The host (SessionsScreen) owns + * showing/hiding this sheet and starts recording when it opens; this composable only renders + * [RecordUi] and forwards taps. + */ +@OptIn(ExperimentalMaterial3Api::class) +@Composable +fun RecordTaskSheet( + ui: RecordUi, + onStop: () -> Unit, + onCancel: () -> Unit, + onRetry: () -> Unit, + onDismiss: () -> Unit, +) { + val accent = LocalProfileAccent.current.accent + val sheetState = rememberModalBottomSheetState() + + ModalBottomSheet(onDismissRequest = onDismiss, sheetState = sheetState) { + Column( + modifier = Modifier + .fillMaxWidth() + .padding(horizontal = 24.dp, vertical = 16.dp), + horizontalAlignment = Alignment.CenterHorizontally, + ) { + val error = ui.error + when { + error != null -> { + Text( + error, + color = MaterialTheme.colorScheme.error, + modifier = Modifier.padding(bottom = 16.dp), + ) + Button(onClick = onRetry, modifier = Modifier.padding(bottom = 8.dp)) { + Text("Try again") + } + TextButton(onClick = onDismiss) { Text("Close") } + } + ui.phase == RecordPhase.RECORDING -> { + Box( + modifier = Modifier + .size(72.dp) + .padding(bottom = 16.dp) + .background(accent, CircleShape) + .semantics { contentDescription = "Recording in progress" }, + ) + Text("Recording…", modifier = Modifier.padding(bottom = 16.dp)) + Button(onClick = onStop, modifier = Modifier.padding(bottom = 8.dp)) { + Text("Stop") + } + TextButton(onClick = onCancel) { Text("Cancel") } + } + ui.phase == RecordPhase.TRANSCRIBING -> { + CircularProgressIndicator( + modifier = Modifier + .padding(bottom = 16.dp) + .semantics { contentDescription = "Transcribing recording" }, + ) + Text("Transcribing…") + } + else -> { + Text("Getting ready…") + } + } + } + } +} diff --git a/app/src/main/java/com/hermes/client/ui/record/RecordTaskViewModel.kt b/app/src/main/java/com/hermes/client/ui/record/RecordTaskViewModel.kt new file mode 100644 index 0000000..4cb6e30 --- /dev/null +++ b/app/src/main/java/com/hermes/client/ui/record/RecordTaskViewModel.kt @@ -0,0 +1,150 @@ +package com.hermes.client.ui.record + +import androidx.lifecycle.ViewModel +import androidx.lifecycle.viewModelScope +import com.hermes.client.data.audio.AudioRecorder +import com.hermes.client.data.audio.audioDataUrl +import com.hermes.client.data.network.HermesRestApi +import com.hermes.client.data.repository.ChatRepository +import com.hermes.client.data.repository.ProfileManager +import com.hermes.client.share.PendingShare +import com.hermes.client.share.PendingShareStore +import dagger.hilt.android.lifecycle.HiltViewModel +import kotlinx.coroutines.CancellationException +import kotlinx.coroutines.CoroutineDispatcher +import kotlinx.coroutines.Dispatchers +import kotlinx.coroutines.Job +import kotlinx.coroutines.flow.MutableSharedFlow +import kotlinx.coroutines.flow.MutableStateFlow +import kotlinx.coroutines.flow.SharedFlow +import kotlinx.coroutines.flow.StateFlow +import kotlinx.coroutines.flow.asSharedFlow +import kotlinx.coroutines.flow.asStateFlow +import kotlinx.coroutines.launch +import kotlinx.coroutines.withContext +import javax.inject.Inject + +enum class RecordPhase { IDLE, RECORDING, TRANSCRIBING } + +data class RecordUi(val phase: RecordPhase = RecordPhase.IDLE, val error: String? = null) + +/** + * Orchestrates the record -> transcribe -> new prefilled chat flow: capture a voice note, + * send it to the gateway's transcribe endpoint, create a fresh session for the active profile, + * stash the transcript as a [PendingShare] keyed by the new session id, and signal navigation + * to it via [navigateTo]. + * + * The primary constructor takes function-typed collaborators (rather than the concrete + * [HermesRestApi]/[ChatRepository] types) so this class is unit-testable with fakes; the + * secondary `@Inject` constructor adapts the real dependencies to those function shapes. + */ +@HiltViewModel +class RecordTaskViewModel( + private val recorder: AudioRecorder, + private val transcribe: suspend (dataUrl: String, mime: String) -> String, + private val createSession: suspend (profile: String?) -> String, + private val activeProfile: StateFlow, + private val refreshProfiles: suspend () -> Unit, + private val pendingShareStore: PendingShareStore, + // Where the blocking MediaRecorder start()/stop() calls run. Defaults to the real IO + // dispatcher in production; tests override it with their TestDispatcher so + // advanceUntilIdle() can deterministically drive the recorder calls (Dispatchers.IO is a + // real thread pool the test scheduler has no visibility into). + private val ioDispatcher: CoroutineDispatcher = Dispatchers.IO, +) : ViewModel() { + + @Inject constructor( + recorder: AudioRecorder, + api: HermesRestApi, + chat: ChatRepository, + profileManager: ProfileManager, + pendingShareStore: PendingShareStore, + ) : this( + recorder = recorder, + transcribe = { url, mime -> api.transcribe(url, mime) }, + createSession = { profile -> chat.connect(); chat.createSession(profile) }, + activeProfile = profileManager.active, + refreshProfiles = { profileManager.refresh() }, + pendingShareStore = pendingShareStore, + ) + + private val _ui = MutableStateFlow(RecordUi()) + val ui: StateFlow = _ui.asStateFlow() + + private val _navigateTo = MutableSharedFlow(extraBufferCapacity = 1) + val navigateTo: SharedFlow = _navigateTo.asSharedFlow() + + // Tracks the most recently launched pipeline coroutine so cancel() can abort it before its + // (async, IO-dispatched) work has a chance to write a stale phase over IDLE. + private var activeJob: Job? = null + + // Tracks the in-flight recorder.start() coroutine so stopAndTranscribe() can wait for it to + // finish before calling recorder.stop() - otherwise a stop() that lands during the start + // window could run before start() ever executed, stranding the recorder state. + private var startJob: Job? = null + + fun startRecording() { + if (_ui.value.phase != RecordPhase.IDLE) return + // Set RECORDING synchronously (not after the IO hop) so a stop tap that races the start + // call sees the correct phase immediately instead of IDLE. + _ui.value = RecordUi(RecordPhase.RECORDING) + startJob = viewModelScope.launch { + try { + withContext(ioDispatcher) { recorder.start() } + } catch (e: CancellationException) { + throw e + } catch (e: Exception) { + _ui.value = RecordUi(RecordPhase.IDLE, error = "Couldn't start recording") + } + } + activeJob = startJob + } + + fun stopAndTranscribe() { + // Guards both a double stop-tap (second call sees TRANSCRIBING/IDLE, not RECORDING) and + // a stop with nothing recording. TRANSCRIBING is set synchronously below so a + // back-to-back second call is rejected here even before the first call's coroutine runs. + if (_ui.value.phase != RecordPhase.RECORDING) return + _ui.value = RecordUi(RecordPhase.TRANSCRIBING) + activeJob = viewModelScope.launch { + try { + // Never let stop() precede a still-running start() - wait for it first. + startJob?.join() + // Recorder stop + base64 encoding are both blocking/CPU work; keep them off Main. + val encoded = withContext(ioDispatcher) { + val clip = recorder.stop() + clip?.let { audioDataUrl(it.bytes, it.mime) to it.mime } + } + if (encoded == null) { + _ui.value = RecordUi(RecordPhase.IDLE, error = "Nothing recorded") + return@launch + } + val (dataUrl, mime) = encoded + val text = transcribe(dataUrl, mime).trim() + if (text.isBlank()) { + _ui.value = RecordUi(RecordPhase.IDLE, error = "Couldn't transcribe that") + return@launch + } + refreshProfiles() + val id = createSession(activeProfile.value) + pendingShareStore.put(id, PendingShare(text = text)) + _navigateTo.emit(id) + _ui.value = RecordUi(RecordPhase.IDLE) + } catch (e: CancellationException) { + throw e + } catch (e: Exception) { + _ui.value = RecordUi(RecordPhase.IDLE, error = "Transcription failed") + } + } + } + + fun cancel() { + activeJob?.cancel() + recorder.cancel() + _ui.value = RecordUi(RecordPhase.IDLE) + } + + fun dismissError() { + if (_ui.value.error != null) _ui.value = _ui.value.copy(error = null) + } +} diff --git a/app/src/main/java/com/hermes/client/ui/sessions/SessionsScreen.kt b/app/src/main/java/com/hermes/client/ui/sessions/SessionsScreen.kt index fd4c74e..35bdef8 100644 --- a/app/src/main/java/com/hermes/client/ui/sessions/SessionsScreen.kt +++ b/app/src/main/java/com/hermes/client/ui/sessions/SessionsScreen.kt @@ -1,5 +1,10 @@ package com.hermes.client.ui.sessions +import android.Manifest +import android.content.pm.PackageManager +import android.widget.Toast +import androidx.activity.compose.rememberLauncherForActivityResult +import androidx.activity.result.contract.ActivityResultContracts import androidx.compose.foundation.ExperimentalFoundationApi import androidx.compose.foundation.background import androidx.compose.foundation.clickable @@ -21,6 +26,7 @@ import androidx.compose.material.icons.Icons import androidx.compose.material.icons.rounded.Add import androidx.compose.material.icons.rounded.Archive import androidx.compose.material.icons.rounded.Close +import androidx.compose.material.icons.rounded.Mic import androidx.compose.material.icons.rounded.PushPin import androidx.compose.material3.AlertDialog import androidx.compose.material3.DropdownMenu @@ -49,13 +55,19 @@ import androidx.compose.runtime.getValue import androidx.compose.runtime.mutableStateOf import androidx.compose.runtime.remember import androidx.compose.runtime.rememberCoroutineScope +import androidx.compose.runtime.saveable.rememberSaveable import androidx.compose.runtime.setValue import androidx.compose.ui.Alignment import androidx.compose.ui.Modifier +import androidx.compose.ui.platform.LocalContext import androidx.compose.ui.text.input.ImeAction +import androidx.core.content.ContextCompat import androidx.hilt.lifecycle.viewmodel.compose.hiltViewModel import androidx.lifecycle.compose.collectAsStateWithLifecycle import com.hermes.client.domain.Session +import com.hermes.client.ui.record.RecordPhase +import com.hermes.client.ui.record.RecordTaskSheet +import com.hermes.client.ui.record.RecordTaskViewModel import kotlinx.coroutines.launch @OptIn(ExperimentalMaterial3Api::class) @@ -76,12 +88,46 @@ fun SessionsScreen( val viewMode by vm.viewMode.collectAsStateWithLifecycle() val projectsState by vm.projectsState.collectAsStateWithLifecycle() val scope = rememberCoroutineScope() + val context = LocalContext.current // I1: route to Setup when a 401 is received LaunchedEffect(state.unauthorized) { if (state.unauthorized) onUnauthorized() } + // Record-a-task: mic entry point on the home session list. The sheet's own show/hide state + // lives here (not in the VM) so it survives recomposition without coupling the VM to + // navigation visibility; recordVm drives the actual record/transcribe pipeline. + val recordVm: RecordTaskViewModel = hiltViewModel() + var showRecord by rememberSaveable { mutableStateOf(false) } + val recordUi by recordVm.ui.collectAsStateWithLifecycle() + val micPermission = rememberLauncherForActivityResult( + ActivityResultContracts.RequestPermission() + ) { granted -> + if (granted) { + showRecord = true + recordVm.startRecording() + } else { + Toast.makeText(context, "Microphone needed to record a task", Toast.LENGTH_SHORT).show() + } + } + fun onMicTap() { + if (ContextCompat.checkSelfPermission(context, Manifest.permission.RECORD_AUDIO) == + PackageManager.PERMISSION_GRANTED + ) { + showRecord = true + recordVm.startRecording() + } else { + micPermission.launch(Manifest.permission.RECORD_AUDIO) + } + } + LaunchedEffect(Unit) { + recordVm.navigateTo.collect { id -> + showRecord = false + onOpen(id) + } + } + // Re-fetch on every resume — notably when returning from a chat. The "sessions" nav entry // (and its ViewModel) stays alive across navigation, so init() runs only once; without this // a session created or updated while in a chat never appears until a profile switch or app @@ -96,6 +142,9 @@ fun SessionsScreen( com.hermes.client.ui.components.HermesTopBar( title = "Chats", actions = { + IconButton(onClick = { onMicTap() }) { + Icon(Icons.Rounded.Mic, contentDescription = "Record a task") + } TextButton( onClick = onOpenArchived, colors = androidx.compose.material3.ButtonDefaults.textButtonColors( @@ -288,6 +337,20 @@ fun SessionsScreen( } } } + + if (showRecord) { + RecordTaskSheet( + ui = recordUi, + onStop = { recordVm.stopAndTranscribe() }, + onCancel = { recordVm.cancel(); showRecord = false }, + onRetry = { recordVm.dismissError(); recordVm.startRecording() }, + onDismiss = { + if (recordUi.phase == RecordPhase.RECORDING) recordVm.cancel() + recordVm.dismissError() + showRecord = false + }, + ) + } } @Composable diff --git a/app/src/test/java/com/hermes/client/data/audio/AudioDataUrlTest.kt b/app/src/test/java/com/hermes/client/data/audio/AudioDataUrlTest.kt new file mode 100644 index 0000000..89c90da --- /dev/null +++ b/app/src/test/java/com/hermes/client/data/audio/AudioDataUrlTest.kt @@ -0,0 +1,19 @@ +package com.hermes.client.data.audio + +import org.junit.Assert.assertEquals +import org.junit.Assert.assertTrue +import org.junit.Test + +class AudioDataUrlTest { + @Test fun builds_base64_data_url_with_mime_prefix() { + val bytes = byteArrayOf(1, 2, 3, 4) + val url = audioDataUrl(bytes, "audio/mp4") + assertTrue(url.startsWith("data:audio/mp4;base64,")) + val b64 = url.removePrefix("data:audio/mp4;base64,") + assertEquals(bytes.toList(), java.util.Base64.getDecoder().decode(b64).toList()) + } + + @Test fun empty_bytes_still_valid() { + assertEquals("data:audio/mp4;base64,", audioDataUrl(ByteArray(0), "audio/mp4")) + } +} diff --git a/app/src/test/java/com/hermes/client/data/network/HermesRestApiTranscribeTest.kt b/app/src/test/java/com/hermes/client/data/network/HermesRestApiTranscribeTest.kt new file mode 100644 index 0000000..a088a5b --- /dev/null +++ b/app/src/test/java/com/hermes/client/data/network/HermesRestApiTranscribeTest.kt @@ -0,0 +1,60 @@ +package com.hermes.client.data.network + +import com.hermes.client.data.auth.GatewayConfig +import kotlinx.coroutines.test.runTest +import kotlinx.serialization.json.Json +import mockwebserver3.MockResponse +import mockwebserver3.MockWebServer +import mockwebserver3.junit4.MockWebServerRule +import okhttp3.OkHttpClient +import org.junit.Assert.assertEquals +import org.junit.Assert.assertTrue +import org.junit.Rule +import org.junit.Test + +class HermesRestApiTranscribeTest { + @get:Rule val serverRule = MockWebServerRule() + private val json = Json { ignoreUnknownKeys = true } + + private fun api(server: MockWebServer) = HermesRestApi(OkHttpClient(), json) { + GatewayConfig(baseUrl = server.url("/").toString().trimEnd('/'), token = "secret") + } + + @Test fun transcribe_returns_trimmed_transcript_and_posts_data_url() = runTest { + serverRule.server.enqueue(MockResponse.Builder().code(200).body( + """{"ok":true,"transcript":" book the flight ","provider":"local"}""" + ).build()) + + val text = api(serverRule.server).transcribe("data:audio/mp4;base64,AAA", "audio/mp4") + assertEquals("book the flight", text) + + val recorded = serverRule.server.takeRequest() + assertEquals("/api/audio/transcribe", recorded.target) + assertEquals("secret", recorded.headers["X-Hermes-Session-Token"]) + val sent = recorded.body?.utf8().orEmpty() + assertTrue(sent.contains("\"data_url\":\"data:audio/mp4;base64,AAA\"")) + assertTrue(sent.contains("\"mime_type\":\"audio/mp4\"")) + } + + @Test fun transcribe_blank_transcript_returns_empty() = runTest { + serverRule.server.enqueue(MockResponse.Builder().code(200).body("""{"ok":true}""").build()) + assertEquals("", api(serverRule.server).transcribe("data:audio/mp4;base64,AAA", "audio/mp4")) + } + + @Test fun transcribe_explicit_null_transcript_returns_empty() = runTest { + serverRule.server.enqueue(MockResponse.Builder().code(200).body( + """{"ok":true,"transcript":null}""" + ).build()) + assertEquals("", api(serverRule.server).transcribe("data:audio/mp4;base64,AAA", "audio/mp4")) + } + + @Test fun transcribe_error_throws() = runTest { + serverRule.server.enqueue(MockResponse.Builder().code(400).body("""{"detail":"no stt"}""").build()) + try { + api(serverRule.server).transcribe("data:audio/mp4;base64,AAA", "audio/mp4") + org.junit.Assert.fail("expected HermesApiException") + } catch (e: HermesApiException) { + assertEquals(400, e.code) + } + } +} diff --git a/app/src/test/java/com/hermes/client/ui/record/RecordTaskViewModelTest.kt b/app/src/test/java/com/hermes/client/ui/record/RecordTaskViewModelTest.kt new file mode 100644 index 0000000..7d7d5b1 --- /dev/null +++ b/app/src/test/java/com/hermes/client/ui/record/RecordTaskViewModelTest.kt @@ -0,0 +1,126 @@ +package com.hermes.client.ui.record + +import com.hermes.client.data.audio.AudioRecorder +import com.hermes.client.data.audio.Recording +import com.hermes.client.share.PendingShare +import com.hermes.client.share.PendingShareStore +import kotlinx.coroutines.CoroutineScope +import kotlinx.coroutines.Dispatchers +import kotlinx.coroutines.ExperimentalCoroutinesApi +import kotlinx.coroutines.flow.MutableStateFlow +import kotlinx.coroutines.launch +import kotlinx.coroutines.test.StandardTestDispatcher +import kotlinx.coroutines.test.advanceUntilIdle +import kotlinx.coroutines.test.resetMain +import kotlinx.coroutines.test.runTest +import kotlinx.coroutines.test.setMain +import org.junit.After +import org.junit.Assert.assertEquals +import org.junit.Assert.assertNull +import org.junit.Before +import org.junit.Test + +@OptIn(ExperimentalCoroutinesApi::class) +class RecordTaskViewModelTest { + private val dispatcher = StandardTestDispatcher() + @Before fun setUp() = Dispatchers.setMain(dispatcher) + @After fun tearDown() = Dispatchers.resetMain() + + private class FakeRecorder(var result: Recording?) : AudioRecorder { + var started = false; var cancelled = false + override fun start() { started = true } + override fun stop() = result + override fun cancel() { cancelled = true } + } + + private fun vm( + recorder: AudioRecorder, + transcribe: suspend (String, String) -> String = { _, _ -> "hi" }, + createSession: suspend (String?) -> String = { "sess-1" }, + store: PendingShareStore = PendingShareStore(), + refresh: suspend () -> Unit = {}, + ) = RecordTaskViewModel( + recorder = recorder, + transcribe = transcribe, + createSession = createSession, + activeProfile = MutableStateFlow("personal"), + refreshProfiles = refresh, + pendingShareStore = store, + // Route the recorder's blocking-call dispatch through the same TestDispatcher as this + // test's scheduler, so advanceUntilIdle() deterministically drives it (Dispatchers.IO, + // the production default, is a real thread pool the test scheduler can't see). + ioDispatcher = dispatcher, + ) + + @Test fun happy_path_transcribes_creates_session_and_stashes_prefill() = runTest { + val store = PendingShareStore() + val nav = mutableListOf() + val model = vm(FakeRecorder(Recording(byteArrayOf(1,2,3), "audio/mp4")), + transcribe = { _, _ -> " book the flight " }, createSession = { "sess-1" }, store = store) + val job = CoroutineScope(dispatcher).launch { model.navigateTo.collect { nav.add(it) } } + model.startRecording(); advanceUntilIdle() + assertEquals(RecordPhase.RECORDING, model.ui.value.phase) + model.stopAndTranscribe(); advanceUntilIdle() + assertEquals(listOf("sess-1"), nav) + assertEquals("book the flight", store.take("sess-1")?.text) + assertEquals(RecordPhase.IDLE, model.ui.value.phase) + assertNull(model.ui.value.error) + job.cancel() + } + + @Test fun nothing_recorded_sets_error_and_skips_transcribe() = runTest { + var called = false + val model = vm(FakeRecorder(null), transcribe = { _, _ -> called = true; "x" }) + model.startRecording(); model.stopAndTranscribe(); advanceUntilIdle() + assertEquals(false, called) + assertEquals(RecordPhase.IDLE, model.ui.value.phase) + org.junit.Assert.assertNotNull(model.ui.value.error) + } + + @Test fun blank_transcript_sets_error_and_creates_no_session() = runTest { + var created = false + val model = vm(FakeRecorder(Recording(byteArrayOf(1), "audio/mp4")), + transcribe = { _, _ -> " " }, createSession = { created = true; "s" }) + model.startRecording(); model.stopAndTranscribe(); advanceUntilIdle() + assertEquals(false, created) + org.junit.Assert.assertNotNull(model.ui.value.error) + } + + @Test fun transcribe_failure_sets_error() = runTest { + val model = vm(FakeRecorder(Recording(byteArrayOf(1), "audio/mp4")), + transcribe = { _, _ -> throw RuntimeException("boom") }) + model.startRecording(); model.stopAndTranscribe(); advanceUntilIdle() + org.junit.Assert.assertNotNull(model.ui.value.error) + assertEquals(RecordPhase.IDLE, model.ui.value.phase) + } + + @Test fun cancel_stops_recorder_and_returns_idle() = runTest { + val rec = FakeRecorder(Recording(byteArrayOf(1), "audio/mp4")) + val model = vm(rec) + model.startRecording(); model.cancel(); advanceUntilIdle() + assertEquals(true, rec.cancelled) + assertEquals(RecordPhase.IDLE, model.ui.value.phase) + } + + @Test fun double_stop_does_not_clobber_transcribing() = runTest { + val nav = mutableListOf() + var sessionCount = 0 + val model = vm( + FakeRecorder(Recording(byteArrayOf(1, 2, 3), "audio/mp4")), + transcribe = { _, _ -> "book the flight" }, + createSession = { sessionCount++; "sess-1" }, + ) + val job = CoroutineScope(dispatcher).launch { model.navigateTo.collect { nav.add(it) } } + model.startRecording() + // Two stop taps back-to-back, before the scheduler runs either coroutine body: the + // second call must be rejected by the synchronous TRANSCRIBING guard, not race the first. + model.stopAndTranscribe() + model.stopAndTranscribe() + advanceUntilIdle() + assertEquals(1, sessionCount) + assertEquals(listOf("sess-1"), nav) + assertEquals(RecordPhase.IDLE, model.ui.value.phase) + assertNull(model.ui.value.error) + job.cancel() + } +} diff --git a/docs/superpowers/plans/2026-07-17-record-to-task.md b/docs/superpowers/plans/2026-07-17-record-to-task.md new file mode 100644 index 0000000..148527d --- /dev/null +++ b/docs/superpowers/plans/2026-07-17-record-to-task.md @@ -0,0 +1,620 @@ +# Record-to-Task (v2) Implementation Plan + +> **For agentic workers:** REQUIRED SUB-SKILL: superpowers:subagent-driven-development. Steps use checkbox syntax. + +**Goal:** Record a spoken task → transcribe it via the gateway → open a new chat prefilled with the transcript. + +**Architecture:** A framework `MediaRecorder` (behind an `AudioRecorder` interface) captures `audio/mp4`; a pure `audioDataUrl` encodes it; `HermesRestApi.transcribe` POSTs to the existing `/api/audio/transcribe`; `RecordTaskViewModel` orchestrates record→transcribe→create-session→prefill; a `RecordTaskSheet` on the home session list drives it. Prefill reuses the existing `PendingShareStore` share rail. + +**Tech Stack:** Kotlin, Compose/Material3, Hilt, OkHttp (hand-rolled), kotlinx.serialization, `MediaRecorder`, `java.util.Base64`. + +## Global Constraints +- Client-only; no gateway change. Gateway must have an STT backend — the app surfaces its absence as an error, never crashes. +- Kotlin/Compose/Material3/Hilt. Per-tenant accent (`LocalProfileAccent`) for chrome/active affordances; semantic `colorScheme.error` for error states — never the accent for errors. +- No AI/assistant attribution in commits, files, or PRs. +- Build env: `export JAVA_HOME="/Applications/Android Studio.app/Contents/jbr/Contents/Home"`. Gates per task: `:app:compileDebugKotlin`, `:app:testDebugUnitTest`, `:app:assembleBeta`. +- Branch `feature/record-to-task` (off `dev`). gitleaks before push; PR into `dev`. +- `runCatching` blocks must rethrow `kotlinx.coroutines.CancellationException` (repo convention — see `SessionsViewModel.createSession`). + +--- + +### Task 1: `HermesRestApi.transcribe` + MockWebServer test + +**Files:** +- Modify: `app/src/main/java/com/hermes/client/data/network/HermesRestApi.kt` +- Test: `app/src/test/java/com/hermes/client/data/network/HermesRestApiTranscribeTest.kt` + +**Interfaces:** +- Produces: `suspend fun HermesRestApi.transcribe(dataUrl: String, mimeType: String): String` — returns the trimmed transcript (may be `""`); throws `HermesApiException(code, …)` on a non-2xx response. + +- [ ] **Step 1: Write the failing test.** Create `HermesRestApiTranscribeTest.kt` (mirror `HermesRestApiTest.kt`'s `MockWebServerRule` + `api(server)` helper): +```kotlin +package com.hermes.client.data.network + +import com.hermes.client.data.auth.GatewayConfig +import kotlinx.coroutines.test.runTest +import kotlinx.serialization.json.Json +import mockwebserver3.MockResponse +import mockwebserver3.MockWebServer +import mockwebserver3.junit4.MockWebServerRule +import okhttp3.OkHttpClient +import org.junit.Assert.assertEquals +import org.junit.Assert.assertTrue +import org.junit.Rule +import org.junit.Test + +class HermesRestApiTranscribeTest { + @get:Rule val serverRule = MockWebServerRule() + private val json = Json { ignoreUnknownKeys = true } + + private fun api(server: MockWebServer) = HermesRestApi(OkHttpClient(), json) { + GatewayConfig(baseUrl = server.url("/").toString().trimEnd('/'), token = "secret") + } + + @Test fun transcribe_returns_trimmed_transcript_and_posts_data_url() = runTest { + serverRule.server.enqueue(MockResponse.Builder().code(200).body( + """{"ok":true,"transcript":" book the flight ","provider":"local"}""" + ).build()) + + val text = api(serverRule.server).transcribe("data:audio/mp4;base64,AAA", "audio/mp4") + assertEquals("book the flight", text) + + val recorded = serverRule.server.takeRequest() + assertEquals("/api/audio/transcribe", recorded.target) + assertEquals("secret", recorded.headers["X-Hermes-Session-Token"]) + val sent = recorded.body?.utf8().orEmpty() + assertTrue(sent.contains("\"data_url\":\"data:audio/mp4;base64,AAA\"")) + assertTrue(sent.contains("\"mime_type\":\"audio/mp4\"")) + } + + @Test fun transcribe_blank_transcript_returns_empty() = runTest { + serverRule.server.enqueue(MockResponse.Builder().code(200).body("""{"ok":true}""").build()) + assertEquals("", api(serverRule.server).transcribe("data:audio/mp4;base64,AAA", "audio/mp4")) + } + + @Test fun transcribe_error_throws() = runTest { + serverRule.server.enqueue(MockResponse.Builder().code(400).body("""{"detail":"no stt"}""").build()) + try { + api(serverRule.server).transcribe("data:audio/mp4;base64,AAA", "audio/mp4") + org.junit.Assert.fail("expected HermesApiException") + } catch (e: HermesApiException) { + assertEquals(400, e.code) + } + } +} +``` +(If `recorded.body?.utf8()` differs from the repo's recorded-body accessor, match however `HermesRestApiTest` reads a POST body; the assertions are the contract. If no POST-body test exists there, `recorded.body?.utf8()` is correct for `mockwebserver3`.) + +- [ ] **Step 2: Run → FAIL** (unresolved `transcribe`): +`./gradlew :app:testDebugUnitTest --tests "com.hermes.client.data.network.HermesRestApiTranscribeTest"` + +- [ ] **Step 3: Implement.** In `HermesRestApi.kt`, add (next to `revealEnv`): +```kotlin + /** + * Transcribe a recorded voice note. [dataUrl] is a base64 data URL (data:;base64,) + * the gateway's POST /api/audio/transcribe accepts; returns the trimmed transcript ("" if the + * STT backend returned nothing). Throws HermesApiException on a non-2xx (e.g. no STT configured). + */ + suspend fun transcribe(dataUrl: String, mimeType: String): String = withContext(Dispatchers.IO) { + val obj = buildJsonObject { put("data_url", dataUrl); put("mime_type", mimeType) } + val payload = json.encodeToString(JsonObject.serializer(), obj) + .toRequestBody("application/json".toMediaType()) + okHttp.newCall(builder("/api/audio/transcribe").post(payload).build()).execute().use { resp -> + val body = resp.body?.string().orEmpty() + if (!resp.isSuccessful) throw HermesApiException(resp.code, "transcription failed") + json.decodeFromString(body)["transcript"]?.jsonPrimitive?.content?.trim() ?: "" + } + } +``` +(If `encodeToString(JsonObject.serializer(), obj)` isn't the form used nearby, match `revealEnv`'s exact encode call. `JsonObject.serializer()` + `jsonPrimitive` are already imported.) + +- [ ] **Step 4: Run → PASS** (all 3 tests). + +- [ ] **Step 5: Commit** +```bash +git add app/src/main/java/com/hermes/client/data/network/HermesRestApi.kt \ + app/src/test/java/com/hermes/client/data/network/HermesRestApiTranscribeTest.kt +git commit -m "feat: add transcribe() REST call for /api/audio/transcribe" +``` + +--- + +### Task 2: `audioDataUrl` pure encoder + RECORD_AUDIO manifest permission + +**Files:** +- Create: `app/src/main/java/com/hermes/client/data/audio/AudioDataUrl.kt` +- Test: `app/src/test/java/com/hermes/client/data/audio/AudioDataUrlTest.kt` +- Modify: `app/src/main/AndroidManifest.xml` + +**Interfaces:** +- Produces: `fun audioDataUrl(bytes: ByteArray, mime: String): String`. + +- [ ] **Step 1: Write the failing test.** Create `AudioDataUrlTest.kt`: +```kotlin +package com.hermes.client.data.audio + +import org.junit.Assert.assertEquals +import org.junit.Assert.assertTrue +import org.junit.Test + +class AudioDataUrlTest { + @Test fun builds_base64_data_url_with_mime_prefix() { + val bytes = byteArrayOf(1, 2, 3, 4) + val url = audioDataUrl(bytes, "audio/mp4") + assertTrue(url.startsWith("data:audio/mp4;base64,")) + val b64 = url.removePrefix("data:audio/mp4;base64,") + assertEquals(bytes.toList(), java.util.Base64.getDecoder().decode(b64).toList()) + } + + @Test fun empty_bytes_still_valid() { + assertEquals("data:audio/mp4;base64,", audioDataUrl(ByteArray(0), "audio/mp4")) + } +} +``` + +- [ ] **Step 2: Run → FAIL:** `./gradlew :app:testDebugUnitTest --tests "com.hermes.client.data.audio.AudioDataUrlTest"` + +- [ ] **Step 3: Implement.** Create `AudioDataUrl.kt`: +```kotlin +package com.hermes.client.data.audio + +/** Build a base64 data URL the gateway's transcribe endpoint accepts: data:;base64,. */ +fun audioDataUrl(bytes: ByteArray, mime: String): String = + "data:$mime;base64," + java.util.Base64.getEncoder().encodeToString(bytes) +``` + +- [ ] **Step 4: Run → PASS.** + +- [ ] **Step 5: Add the permission.** In `AndroidManifest.xml`, add alongside the existing `` lines: +```xml + +``` + +- [ ] **Step 6: Compile check** (manifest merges): `./gradlew :app:compileDebugKotlin` + +- [ ] **Step 7: Commit** +```bash +git add app/src/main/java/com/hermes/client/data/audio/AudioDataUrl.kt \ + app/src/test/java/com/hermes/client/data/audio/AudioDataUrlTest.kt \ + app/src/main/AndroidManifest.xml +git commit -m "feat: audio data-url encoder + RECORD_AUDIO permission" +``` + +--- + +### Task 3: `AudioRecorder` interface + `MediaAudioRecorder` + Hilt provider + +**Files:** +- Create: `app/src/main/java/com/hermes/client/data/audio/AudioRecorder.kt` +- Modify: `app/src/main/java/com/hermes/client/di/AppModule.kt` + +**Interfaces:** +- Produces: `interface AudioRecorder { fun start(); fun stop(): Recording?; fun cancel() }` and `data class Recording(val bytes: ByteArray, val mime: String)`. +- Consumes: nothing from prior tasks. + +No unit test — `MediaRecorder` is device-bound (covered on-device). This task's gate is compile + the ViewModel test in Task 4 exercising a fake `AudioRecorder`. + +- [ ] **Step 1: Implement.** Create `AudioRecorder.kt`: +```kotlin +package com.hermes.client.data.audio + +import android.content.Context +import android.media.MediaRecorder +import android.os.Build +import java.io.File + +/** A captured voice note. */ +data class Recording(val bytes: ByteArray, val mime: String) { + override fun equals(other: Any?) = + other is Recording && mime == other.mime && bytes.contentEquals(other.bytes) + override fun hashCode() = 31 * bytes.contentHashCode() + mime.hashCode() +} + +/** Records a single voice note. Interface so RecordTaskViewModel is testable with a fake. */ +interface AudioRecorder { + fun start() + fun stop(): Recording? + fun cancel() +} + +/** MediaRecorder-backed recorder writing audio/mp4 (AAC) to an app-cache temp file. */ +class MediaAudioRecorder(private val context: Context) : AudioRecorder { + private var recorder: MediaRecorder? = null + private var outputFile: File? = null + + override fun start() { + if (recorder != null) return + val file = File.createTempFile("rec_", ".m4a", context.cacheDir) + val rec = if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.S) MediaRecorder(context) + else @Suppress("DEPRECATION") MediaRecorder() + rec.setAudioSource(MediaRecorder.AudioSource.MIC) + rec.setOutputFormat(MediaRecorder.OutputFormat.MPEG_4) + rec.setAudioEncoder(MediaRecorder.AudioEncoder.AAC) + rec.setAudioEncodingBitRate(96_000) + rec.setAudioSamplingRate(44_100) + rec.setOutputFile(file.absolutePath) + rec.prepare() + rec.start() + recorder = rec + outputFile = file + } + + override fun stop(): Recording? { + val rec = recorder ?: return null + val file = outputFile + recorder = null + outputFile = null + val stopped = runCatching { rec.stop() }.isSuccess + runCatching { rec.release() } + if (!stopped || file == null || !file.exists() || file.length() == 0L) { + file?.delete() + return null + } + val bytes = file.readBytes() + file.delete() + return Recording(bytes, "audio/mp4") + } + + override fun cancel() { + val rec = recorder ?: return + val file = outputFile + recorder = null + outputFile = null + runCatching { rec.stop() } + runCatching { rec.release() } + file?.delete() + } +} +``` + +- [ ] **Step 2: Provide via Hilt.** In `AppModule.kt`, add a provider (match the module's existing `@Provides`/`@Singleton` + `@ApplicationContext` style): +```kotlin + @Provides + @Singleton + fun provideAudioRecorder( + @dagger.hilt.android.qualifiers.ApplicationContext context: android.content.Context, + ): com.hermes.client.data.audio.AudioRecorder = + com.hermes.client.data.audio.MediaAudioRecorder(context) +``` +(If `AppModule` already imports `@ApplicationContext`/`Context`/`Singleton`, use the short names to match the file.) + +- [ ] **Step 3: Compile:** `./gradlew :app:compileDebugKotlin` → SUCCESSFUL. + +- [ ] **Step 4: Commit** +```bash +git add app/src/main/java/com/hermes/client/data/audio/AudioRecorder.kt \ + app/src/main/java/com/hermes/client/di/AppModule.kt +git commit -m "feat: MediaRecorder-backed AudioRecorder + Hilt provider" +``` + +--- + +### Task 4: `RecordTaskViewModel` + orchestration test + +**Files:** +- Create: `app/src/main/java/com/hermes/client/ui/record/RecordTaskViewModel.kt` +- Test: `app/src/test/java/com/hermes/client/ui/record/RecordTaskViewModelTest.kt` + +**Interfaces:** +- Consumes: `AudioRecorder`/`Recording` (Task 3), `HermesRestApi.transcribe` (Task 1), `audioDataUrl` (Task 2), `ChatRepository.createSession(profile: String?): String`, `ProfileManager.active: StateFlow` + `refresh()`, `PendingShareStore.put(id, PendingShare(text=…))`. +- Produces: `RecordTaskViewModel` with `ui: StateFlow`, `navigateTo: SharedFlow`, and `startRecording()`, `stopAndTranscribe()`, `cancel()`, `dismissError()`. + +- [ ] **Step 1: Write the failing test.** Create `RecordTaskViewModelTest.kt`. Fakes implement the real interfaces; `HermesRestApi` and `ChatRepository` are open enough to fake via their public methods — if a class is `final`, wrap the two calls the VM needs behind small interfaces is OUT OF SCOPE; instead construct real instances with fakes is OUT OF SCOPE. Use these fakes (the VM must take its collaborators as constructor params typed to allow these fakes — see Step 3; if `HermesRestApi`/`ChatRepository` are final classes, the VM takes function-typed params `transcribe: suspend (String,String)->String` and `createSession: suspend (String?)->String` instead, and the real wiring passes `api::transcribe` / `chat::createSession`): +```kotlin +package com.hermes.client.ui.record + +import com.hermes.client.data.audio.AudioRecorder +import com.hermes.client.data.audio.Recording +import com.hermes.client.share.PendingShare +import com.hermes.client.share.PendingShareStore +import kotlinx.coroutines.Dispatchers +import kotlinx.coroutines.ExperimentalCoroutinesApi +import kotlinx.coroutines.flow.MutableStateFlow +import kotlinx.coroutines.test.StandardTestDispatcher +import kotlinx.coroutines.test.advanceUntilIdle +import kotlinx.coroutines.test.resetMain +import kotlinx.coroutines.test.runTest +import kotlinx.coroutines.test.setMain +import org.junit.After +import org.junit.Assert.assertEquals +import org.junit.Assert.assertNull +import org.junit.Before +import org.junit.Test + +@OptIn(ExperimentalCoroutinesApi::class) +class RecordTaskViewModelTest { + private val dispatcher = StandardTestDispatcher() + @Before fun setUp() = Dispatchers.setMain(dispatcher) + @After fun tearDown() = Dispatchers.resetMain() + + private class FakeRecorder(var result: Recording?) : AudioRecorder { + var started = false; var cancelled = false + override fun start() { started = true } + override fun stop() = result + override fun cancel() { cancelled = true } + } + + private fun vm( + recorder: AudioRecorder, + transcribe: suspend (String, String) -> String = { _, _ -> "hi" }, + createSession: suspend (String?) -> String = { "sess-1" }, + store: PendingShareStore = PendingShareStore(), + refresh: suspend () -> Unit = {}, + ) = RecordTaskViewModel( + recorder = recorder, + transcribe = transcribe, + createSession = createSession, + activeProfile = MutableStateFlow("personal"), + refreshProfiles = refresh, + pendingShareStore = store, + ) + + @Test fun happy_path_transcribes_creates_session_and_stashes_prefill() = runTest { + val store = PendingShareStore() + val nav = mutableListOf() + val model = vm(FakeRecorder(Recording(byteArrayOf(1,2,3), "audio/mp4")), + transcribe = { _, _ -> " book the flight " }, createSession = { "sess-1" }, store = store) + val job = kotlinx.coroutines.CoroutineScope(dispatcher).launch { model.navigateTo.collect { nav.add(it) } } + model.startRecording(); advanceUntilIdle() + assertEquals(RecordPhase.RECORDING, model.ui.value.phase) + model.stopAndTranscribe(); advanceUntilIdle() + assertEquals(listOf("sess-1"), nav) + assertEquals("book the flight", store.take("sess-1")?.text) + assertEquals(RecordPhase.IDLE, model.ui.value.phase) + assertNull(model.ui.value.error) + job.cancel() + } + + @Test fun nothing_recorded_sets_error_and_skips_transcribe() = runTest { + var called = false + val model = vm(FakeRecorder(null), transcribe = { _, _ -> called = true; "x" }) + model.startRecording(); model.stopAndTranscribe(); advanceUntilIdle() + assertEquals(false, called) + assertEquals(RecordPhase.IDLE, model.ui.value.phase) + org.junit.Assert.assertNotNull(model.ui.value.error) + } + + @Test fun blank_transcript_sets_error_and_creates_no_session() = runTest { + var created = false + val model = vm(FakeRecorder(Recording(byteArrayOf(1), "audio/mp4")), + transcribe = { _, _ -> " " }, createSession = { created = true; "s" }) + model.startRecording(); model.stopAndTranscribe(); advanceUntilIdle() + assertEquals(false, created) + org.junit.Assert.assertNotNull(model.ui.value.error) + } + + @Test fun transcribe_failure_sets_error() = runTest { + val model = vm(FakeRecorder(Recording(byteArrayOf(1), "audio/mp4")), + transcribe = { _, _ -> throw RuntimeException("boom") }) + model.startRecording(); model.stopAndTranscribe(); advanceUntilIdle() + org.junit.Assert.assertNotNull(model.ui.value.error) + assertEquals(RecordPhase.IDLE, model.ui.value.phase) + } + + @Test fun cancel_stops_recorder_and_returns_idle() = runTest { + val rec = FakeRecorder(Recording(byteArrayOf(1), "audio/mp4")) + val model = vm(rec) + model.startRecording(); model.cancel(); advanceUntilIdle() + assertEquals(true, rec.cancelled) + assertEquals(RecordPhase.IDLE, model.ui.value.phase) + } +} +``` +(Import `kotlinx.coroutines.launch`/`CoroutineScope` as needed. If the repo has an existing `MainDispatcherRule`, use it instead of the manual `setMain`/`resetMain` — match the sibling ViewModel tests.) + +- [ ] **Step 2: Run → FAIL:** `./gradlew :app:testDebugUnitTest --tests "com.hermes.client.ui.record.RecordTaskViewModelTest"` + +- [ ] **Step 3: Implement.** Create `RecordTaskViewModel.kt`. Use function-typed collaborators for `transcribe`/`createSession` so the VM is unit-testable and Hilt wiring binds the real methods: +```kotlin +package com.hermes.client.ui.record + +import androidx.lifecycle.ViewModel +import androidx.lifecycle.viewModelScope +import com.hermes.client.data.audio.AudioRecorder +import com.hermes.client.data.audio.audioDataUrl +import com.hermes.client.data.network.HermesRestApi +import com.hermes.client.data.repository.ChatRepository +import com.hermes.client.data.repository.ProfileManager +import com.hermes.client.share.PendingShare +import com.hermes.client.share.PendingShareStore +import dagger.hilt.android.lifecycle.HiltViewModel +import kotlinx.coroutines.CancellationException +import kotlinx.coroutines.flow.MutableSharedFlow +import kotlinx.coroutines.flow.MutableStateFlow +import kotlinx.coroutines.flow.SharedFlow +import kotlinx.coroutines.flow.StateFlow +import kotlinx.coroutines.flow.asSharedFlow +import kotlinx.coroutines.flow.asStateFlow +import kotlinx.coroutines.launch +import javax.inject.Inject + +enum class RecordPhase { IDLE, RECORDING, TRANSCRIBING } + +data class RecordUi(val phase: RecordPhase = RecordPhase.IDLE, val error: String? = null) + +class RecordTaskViewModel( + private val recorder: AudioRecorder, + private val transcribe: suspend (dataUrl: String, mime: String) -> String, + private val createSession: suspend (profile: String?) -> String, + private val activeProfile: StateFlow, + private val refreshProfiles: suspend () -> Unit, + private val pendingShareStore: PendingShareStore, +) : ViewModel() { + + private val _ui = MutableStateFlow(RecordUi()) + val ui: StateFlow = _ui.asStateFlow() + + private val _navigateTo = MutableSharedFlow(extraBufferCapacity = 1) + val navigateTo: SharedFlow = _navigateTo.asSharedFlow() + + fun startRecording() { + if (_ui.value.phase != RecordPhase.IDLE) return + runCatching { recorder.start() } + .onSuccess { _ui.value = RecordUi(RecordPhase.RECORDING) } + .onFailure { _ui.value = RecordUi(RecordPhase.IDLE, error = "Couldn't start recording") } + } + + fun stopAndTranscribe() { + if (_ui.value.phase != RecordPhase.RECORDING) return + val clip = recorder.stop() + if (clip == null) { + _ui.value = RecordUi(RecordPhase.IDLE, error = "Nothing recorded") + return + } + _ui.value = RecordUi(RecordPhase.TRANSCRIBING) + viewModelScope.launch { + try { + val text = transcribe(audioDataUrl(clip.bytes, clip.mime), clip.mime) + if (text.isBlank()) { + _ui.value = RecordUi(RecordPhase.IDLE, error = "Couldn't transcribe that") + return@launch + } + refreshProfiles() + val id = createSession(activeProfile.value) + pendingShareStore.put(id, PendingShare(text = text)) + _navigateTo.emit(id) + _ui.value = RecordUi(RecordPhase.IDLE) + } catch (e: CancellationException) { + throw e + } catch (e: Exception) { + _ui.value = RecordUi(RecordPhase.IDLE, error = "Transcription failed") + } + } + } + + fun cancel() { + recorder.cancel() + _ui.value = RecordUi(RecordPhase.IDLE) + } + + fun dismissError() { if (_ui.value.phase == RecordPhase.IDLE) _ui.value = RecordUi(RecordPhase.IDLE) } +} + +/** Hilt factory binding the function-typed collaborators to the real repositories. */ +@HiltViewModel +class RecordTaskViewModelHilt @Inject constructor( + recorder: AudioRecorder, + api: HermesRestApi, + chat: ChatRepository, + profileManager: ProfileManager, + pendingShareStore: PendingShareStore, +) : ViewModel() { + val delegate = RecordTaskViewModel( + recorder = recorder, + transcribe = { url, mime -> api.transcribe(url, mime) }, + createSession = { profile -> chat.connect(); chat.createSession(profile) }, + activeProfile = profileManager.active, + refreshProfiles = { profileManager.refresh() }, + pendingShareStore = pendingShareStore, + ) +} +``` +NOTE for the implementer: the `RecordTaskViewModelHilt` wrapper above is a starting point — if the codebase's `hiltViewModel()` call sites expect a single VM, prefer making `RecordTaskViewModel` itself the `@HiltViewModel` with an `@Inject constructor` that takes the real `HermesRestApi`/`ChatRepository`/`ProfileManager` and adapts them internally to the function types (keep a **second, non-Hilt** constructor — or a test-only secondary constructor — that takes the function-typed params for the unit test). Choose whichever matches the repo's other ViewModels; the unit test in Step 1 only requires that a `RecordTaskViewModel` can be built from the fakes/function-types shown. Confirm `ChatRepository.connect()` exists (it's used by `SessionsViewModel`/`MainActivity`); if `createSession` already connects, drop the `chat.connect()`. + +- [ ] **Step 4: Run → PASS** (all VM tests). +- [ ] **Step 5: Full gates:** `:app:compileDebugKotlin`, `:app:testDebugUnitTest` (0 failures), `:app:assembleBeta` — all SUCCESSFUL. +- [ ] **Step 6: Commit** +```bash +git add app/src/main/java/com/hermes/client/ui/record/RecordTaskViewModel.kt \ + app/src/test/java/com/hermes/client/ui/record/RecordTaskViewModelTest.kt +git commit -m "feat: RecordTaskViewModel orchestrating record→transcribe→new prefilled chat" +``` + +--- + +### Task 5: `RecordTaskSheet` + SessionsScreen mic action + RECORD_AUDIO request + +**Files:** +- Create: `app/src/main/java/com/hermes/client/ui/record/RecordTaskSheet.kt` +- Modify: `app/src/main/java/com/hermes/client/ui/sessions/SessionsScreen.kt` + +**Interfaces:** +- Consumes: `RecordTaskViewModel` (`ui`, `navigateTo`, `startRecording`, `stopAndTranscribe`, `cancel`, `dismissError`), `RecordPhase`, `RecordUi`; `SessionsScreen`'s existing `onOpen: (String) -> Unit`. + +Compose glue — no unit test; gate is compile + assembleBeta + on-device (best-effort). + +- [ ] **Step 1: Create `RecordTaskSheet.kt`.** A `ModalBottomSheet` rendering `RecordUi`. Use `LocalProfileAccent` for the record/active button (match how other sheets read the accent — grep `LocalProfileAccent` in the repo); `MaterialTheme.colorScheme.error` for the error text. Contract: +```kotlin +@androidx.compose.material3.ExperimentalMaterial3Api +@androidx.compose.runtime.Composable +fun RecordTaskSheet( + ui: RecordUi, + onStop: () -> Unit, + onCancel: () -> Unit, + onRetry: () -> Unit, + onDismiss: () -> Unit, +) +``` +Body: a `ModalBottomSheet(onDismissRequest = onDismiss)` with a centered column: +- `ui.error != null` → the error text (`color = MaterialTheme.colorScheme.error`) + a "Try again" button (`onRetry`) + a "Close" text button (`onDismiss`). +- else by `ui.phase`: + - `RECORDING` → a large filled circular record indicator (accent), a caption "Recording…", a filled **Stop** button (`onStop`) and a text **Cancel** (`onCancel`). + - `TRANSCRIBING` → a `CircularProgressIndicator` + "Transcribing…". + - `IDLE` → a caption "Getting ready…" (transient; the host starts recording on open). +Keep it small and dependency-free (Material3 only). Provide `contentDescription`s. + +- [ ] **Step 2: Wire SessionsScreen.** In `SessionsScreen.kt`: + 1. Get the VM: `val recordVm: RecordTaskViewModel = hiltViewModel()` (match the file's other `hiltViewModel()` usage; if using the Hilt-wrapper pattern from Task 4, expose `.delegate`). + 2. State + permission launcher near the top of the composable: +```kotlin + var showRecord by rememberSaveable { mutableStateOf(false) } + val recordUi by recordVm.ui.collectAsStateWithLifecycle() + val micPermission = rememberLauncherForActivityResult( + ActivityResultContracts.RequestPermission() + ) { granted -> + if (granted) { showRecord = true; recordVm.startRecording() } + else android.widget.Toast.makeText(context, + "Microphone needed to record a task", android.widget.Toast.LENGTH_SHORT).show() + } + fun onMicTap() { + val ctx = context + if (androidx.core.content.ContextCompat.checkSelfPermission(ctx, Manifest.permission.RECORD_AUDIO) + == android.content.pm.PackageManager.PERMISSION_GRANTED) { + showRecord = true; recordVm.startRecording() + } else micPermission.launch(Manifest.permission.RECORD_AUDIO) + } + LaunchedEffect(Unit) { recordVm.navigateTo.collect { showRecord = false; onOpen(it) } } +``` + (Use the file's existing `context`/`LocalContext` handle; add imports: `androidx.activity.compose.rememberLauncherForActivityResult`, `androidx.activity.result.contract.ActivityResultContracts`, `android.Manifest`, `androidx.lifecycle.compose.collectAsStateWithLifecycle`, `androidx.hilt.navigation.compose.hiltViewModel` — match repo conventions.) + 3. Add a **mic** `IconButton` to the `TopAppBar` `actions` (the block at ~line 98, beside the archived action): +```kotlin + IconButton(onClick = { onMicTap() }) { + Icon(Icons.Rounded.Mic, contentDescription = "Record a task") + } +``` + (Import `androidx.compose.material.icons.rounded.Mic` + `Icon` if not already present.) + 4. Host the sheet (near the FAB / end of the Scaffold content): +```kotlin + if (showRecord) { + RecordTaskSheet( + ui = recordUi, + onStop = { recordVm.stopAndTranscribe() }, + onCancel = { recordVm.cancel(); showRecord = false }, + onRetry = { recordVm.dismissError(); recordVm.startRecording() }, + onDismiss = { + if (recordUi.phase == RecordPhase.RECORDING) recordVm.cancel() + recordVm.dismissError(); showRecord = false + }, + ) + } +``` + +- [ ] **Step 3: Gates:** `:app:compileDebugKotlin`, `:app:testDebugUnitTest` (0 failures), `:app:assembleBeta` — all SUCCESSFUL. + +- [ ] **Step 4: On-device (best-effort).** `:app:installBeta`. Home → tap the mic action → grant RECORD_AUDIO → sheet shows "Recording…" → speak → **Stop** → "Transcribing…" → a new chat opens with the transcript in the composer. If the gateway has no STT, confirm the sheet shows an error and no chat is created (no crash). Record pass/fail + the STT caveat in the PR. + +- [ ] **Step 5: Commit** +```bash +git add app/src/main/java/com/hermes/client/ui/record/RecordTaskSheet.kt \ + app/src/main/java/com/hermes/client/ui/sessions/SessionsScreen.kt +git commit -m "feat: record-a-task mic action + sheet on the home session list" +``` + +--- + +## Notes for the executor +- Prefill (not auto-send): the transcript lands in the new chat's composer via `PendingShareStore` — do NOT auto-submit. +- Per-tenant accent for the record/active affordances; `colorScheme.error` for errors — never the accent for errors. +- Do NOT touch the composer's existing `RecognizerIntent` dictation mic (`ChatScreen.kt`) — this is a separate home-screen entry point. +- The gateway STT backend is a runtime prerequisite, not a client concern — surface its absence as the transcribe error path; never crash. +- If `HermesRestApi`/`ChatRepository`/`ProfileManager` differ from the assumed signatures, the ground truth is the code — adapt the wiring, keep the VM's function-typed seam so the unit test holds. diff --git a/docs/superpowers/specs/2026-07-17-record-to-task-design.md b/docs/superpowers/specs/2026-07-17-record-to-task-design.md new file mode 100644 index 0000000..678b18f --- /dev/null +++ b/docs/superpowers/specs/2026-07-17-record-to-task-design.md @@ -0,0 +1,120 @@ +# Record-to-Task (v2) — Design + +**Wave:** Quick-wins (client-only). **Branch:** `feature/record-to-task` (off `dev`). + +**Goal:** Capture a spoken task as a longer voice note, transcribe it server-side, and open a **new** chat prefilled with the transcript — turning a voice note into a task without typing. + +**Constraints:** Kotlin/Compose/Material3/Hilt, per-tenant accent for chrome (semantic colors for error). No AI attribution; gitleaks before push; PR into `dev`. + +## Feasibility (from the gateway + app audit) +- **Transcribe endpoint exists:** `POST /api/audio/transcribe` (`hermes_cli/web_server.py:3743`) takes JSON `{ data_url, mime_type? }` where `data_url` is a base64 data URL (`data:;base64,`). Accepts `audio/*` (incl. `audio/mp4`/m4a, aac, webm, wav…); 25 MB cap. Returns `{ ok, transcript, provider }`; failures are non-2xx with a `{detail}` body. +- **Gateway prerequisite (not client-controlled):** transcription needs an STT backend on the gateway host — default `local` (faster-whisper), or a cloud STT key + `stt.provider`. If none is configured the endpoint returns an error; the app surfaces it gracefully. +- **Authenticated REST client ready:** `HermesRestApi` (hand-rolled OkHttp) already attaches the gateway base URL + `X-Hermes-Session-Token` on every call. A new `transcribe(...)` method just POSTs. +- **New-chat + prefill rail ready:** `ChatRepository.createSession(profile)` + the existing `PendingShareStore.put(id, PendingShare(text=…))` → `ChatViewModel` picks it up as `initialDraft` → the composer draft. This is exactly how text-share opens a prefilled chat today. +- **No new Gradle deps:** `MediaRecorder` + Base64 are framework; OkHttp already present; `okhttp-mockwebserver` is already a test dep. + +## Scope +- **In:** a "Record a task" mic action on the home session list → RECORD_AUDIO runtime permission → record (MediaRecorder, `audio/mp4`/AAC) → stop → base64 data URL → `POST /api/audio/transcribe` → on success, create a new chat and **prefill** the transcript into its composer (review-before-send). Recording/transcribing/error UX in a bottom sheet. +- **Out (deferred):** auto-submit without review; in-chat "record note into this thread"; audio playback/waveform; on-device offline transcription; a settings toggle for STT provider (gateway-config-owned). + +## Why prefill, not auto-send +STT is imperfect and the note may be long. Prefilling the new chat's composer lets the user glance/fix before sending — and reuses the existing share rail verbatim (zero new navigation plumbing). Auto-send is the deferred variant. + +## Architecture + +### 1. `data/network/HermesRestApi.kt` (modify) — transcribe +```kotlin +suspend fun transcribe(dataUrl: String, mimeType: String): String = withContext(Dispatchers.IO) { + val obj = buildJsonObject { put("data_url", dataUrl); put("mime_type", mimeType) } + val payload = json.encodeToString(JsonObject.serializer(), obj) + .toRequestBody("application/json".toMediaType()) + okHttp.newCall(builder("/api/audio/transcribe").post(payload).build()).execute().use { resp -> + val body = resp.body?.string().orEmpty() + if (!resp.isSuccessful) throw HermesApiException(resp.code, "transcription failed") + json.decodeFromString(body)["transcript"]?.jsonPrimitive?.content?.trim() ?: "" + } +} +``` +Mirrors `revealEnv`. Tested with MockWebServer (already a test dep). + +### 2. `data/audio/AudioDataUrl.kt` (new, pure) — encoding +```kotlin +/** Build a base64 data URL the gateway accepts: data:;base64,. */ +fun audioDataUrl(bytes: ByteArray, mime: String): String = + "data:$mime;base64," + java.util.Base64.getEncoder().encodeToString(bytes) +``` +Uses `java.util.Base64` (JVM — unit-testable without Robolectric), not `android.util.Base64`. + +### 3. `data/audio/AudioRecorder.kt` (new) — recorder abstraction + MediaRecorder impl +```kotlin +data class Recording(val bytes: ByteArray, val mime: String) + +/** Records a single voice note. Injected so the ViewModel is testable with a fake. */ +interface AudioRecorder { + fun start() // begin capture (no-op if already recording) + fun stop(): Recording? // stop + return the clip (null on failure/empty) + fun cancel() // stop + discard +} +``` +`MediaAudioRecorder(@ApplicationContext ctx)` — MediaRecorder `MPEG_4`/`AAC` to a cache temp file, `stop()` reads bytes + returns mime `audio/mp4`, `cancel()` deletes. Device-bound → covered on-device, not unit-tested. Provided via Hilt. + +### 4. `ui/record/RecordTaskViewModel.kt` (new) — orchestration +Injects `AudioRecorder`, `HermesRestApi`, `ChatRepository`, `ProfileManager`, `PendingShareStore`. +```kotlin +enum class RecordPhase { IDLE, RECORDING, TRANSCRIBING } +data class RecordUi(val phase: RecordPhase = RecordPhase.IDLE, val error: String? = null) +val ui: StateFlow +val navigateTo: SharedFlow // created session id → host navigates + +fun startRecording() // recorder.start(); phase=RECORDING +fun stopAndTranscribe() // recorder.stop() → encode → transcribe → new chat +fun cancel() // recorder.cancel(); phase=IDLE +fun dismissError() // phase=IDLE, error=null +``` +`stopAndTranscribe`: `recorder.stop()` → null ⇒ error "Nothing recorded"; else `audioDataUrl(bytes,mime)` → phase=TRANSCRIBING → `runCatching { api.transcribe(url, mime) }`; blank ⇒ error "Couldn't transcribe that"; failure ⇒ error "Transcription failed"; success ⇒ `chat.connect()`, `profileManager.refresh()`, `id = chat.createSession(profileManager.active.value)`, `pendingShareStore.put(id, PendingShare(text=transcript))`, emit `id`, phase=IDLE. All `runCatching` rethrows `CancellationException`. + +### 5. `ui/record/RecordTaskSheet.kt` (new) + `ui/sessions/SessionsScreen.kt` (modify) +- **Sheet** (`ModalBottomSheet`): IDLE → a large record button + hint; RECORDING → a pulsing record indicator + a **Stop** button + Cancel; TRANSCRIBING → spinner "Transcribing…"; `error != null` → the message (in `colorScheme.error`) + Retry (→ IDLE) / Dismiss. Record/active accents use `LocalProfileAccent`; error uses the semantic error color. +- **SessionsScreen:** add a **mic** `IconButton` ("Record a task") in the existing `TopAppBar` `actions`. Tap → check `RECORD_AUDIO` (a `RequestPermission()` launcher); granted ⇒ open the sheet + `recordVm.startRecording()`; else request, and open+start on grant. Collect `recordVm.navigateTo` → `onOpen(id)` (the existing nav callback) → the new chat opens prefilled. + +### 6. `AndroidManifest.xml` (modify) + Hilt +- Add ``. +- `AppModule`: `@Provides fun provideAudioRecorder(@ApplicationContext ctx): AudioRecorder = MediaAudioRecorder(ctx)`. + +## Data flow +``` +home ⋮ mic → RECORD_AUDIO grant → sheet + recorder.start +Stop → recorder.stop() bytes/mime → audioDataUrl → POST /api/audio/transcribe → transcript + → createSession(active) → PendingShareStore.put(id, PendingShare(text=transcript)) → onOpen(id) + → ChatViewModel.open() takes the share → composer prefilled with the transcript +``` + +## Error handling +- Permission denied → sheet not opened (or a toast "Microphone needed to record a task"). +- Nothing recorded / empty clip → error state, no chat created. +- Blank transcript (STT returned nothing) / endpoint error (no STT configured, 4xx/5xx/413) → error state + Retry; no chat created; no crash. +- `createSession` failure after a good transcript → error "Couldn't start a chat" (transcript preserved for Retry is out of scope; Retry re-records). + +## Testing +- **`AudioDataUrlTest`** (pure): known bytes + mime → exact `data:;base64,` (verify prefix + round-trip decode). +- **`HermesRestApiTranscribeTest`** (MockWebServer): 200 `{transcript:"hi"}` → "hi" (trimmed); 400 → `HermesApiException(400)`; 200 with missing/blank transcript → "". +- **`RecordTaskViewModelTest`** (fakes): stop→transcribe→createSession→stash `PendingShare(text)`→emit id (phase returns IDLE); null recording → error, no api call; blank transcript → error, no session; transcribe throws → error; createSession throws → error. +- `MediaAudioRecorder`, the sheet, and SessionsScreen wiring are device/Compose glue — verified on-device (best-effort; needs mic + a gateway with STT). + +## On-device verification +Home → mic → grant RECORD_AUDIO → record a short spoken task → Stop → "Transcribing…" → a new chat opens with the transcript in the composer. With no STT on the gateway → an error state, no chat, no crash. (Best-effort: the emulator mic + a gateway STT backend are required; covered by the unit tests + reviews otherwise.) + +## Files +| Action | Path | +|--------|------| +| Modify | `data/network/HermesRestApi.kt` (`transcribe`) + `HermesRestApiTranscribeTest.kt` | +| New | `data/audio/AudioDataUrl.kt` + `AudioDataUrlTest.kt` | +| New | `data/audio/AudioRecorder.kt` (interface + `MediaAudioRecorder`) | +| Modify | `di/AppModule.kt` (provide `AudioRecorder`) | +| New | `ui/record/RecordTaskViewModel.kt` + `RecordTaskViewModelTest.kt` | +| New | `ui/record/RecordTaskSheet.kt` | +| Modify | `ui/sessions/SessionsScreen.kt` (mic action + sheet host + permission) | +| Modify | `AndroidManifest.xml` (RECORD_AUDIO) | + +## Build & gates +`JAVA_HOME=…`: `:app:compileDebugKotlin`, `:app:testDebugUnitTest`, `:app:assembleBeta`. gitleaks before push; PR into `dev`. From b337f3818a5d95621a0fd865ac84e17253247f34 Mon Sep 17 00:00:00 2001 From: Andrew Debnar Date: Fri, 17 Jul 2026 21:30:27 -0500 Subject: [PATCH 15/16] chore: bump version to 0.1.51 (quick-wins batch: #98-#108) --- app/build.gradle.kts | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/app/build.gradle.kts b/app/build.gradle.kts index 531fdc4..5ca4bb6 100644 --- a/app/build.gradle.kts +++ b/app/build.gradle.kts @@ -24,8 +24,8 @@ android { applicationId = "com.hermes.client" minSdk = 26 targetSdk = 37 - versionCode = 54 - versionName = "0.1.50" + versionCode = 55 + versionName = "0.1.51" testInstrumentationRunner = "com.hermes.client.HiltTestRunner" // App name; the beta build type overrides this so both can be installed at once. manifestPlaceholders["appLabel"] = "Hermes" From ea7aedb2ad85e6b90839ced143b027aba94ed0f9 Mon Sep 17 00:00:00 2001 From: Andrew Debnar Date: Fri, 17 Jul 2026 22:02:45 -0500 Subject: [PATCH 16/16] fix: rethrow CancellationException in persona ops, guard cold-start connect, hoist speechText regexes --- .../java/com/hermes/client/MainActivity.kt | 2 +- .../hermes/client/ui/chat/ChatViewModel.kt | 10 +++++-- .../com/hermes/client/ui/chat/SpeechText.kt | 26 +++++++++++++------ 3 files changed, 27 insertions(+), 11 deletions(-) diff --git a/app/src/main/java/com/hermes/client/MainActivity.kt b/app/src/main/java/com/hermes/client/MainActivity.kt index 13cafbb..78964ec 100644 --- a/app/src/main/java/com/hermes/client/MainActivity.kt +++ b/app/src/main/java/com/hermes/client/MainActivity.kt @@ -160,8 +160,8 @@ class MainActivity : ComponentActivity() { if (!newChatInFlight.compareAndSet(false, true)) return // a create is already running — ignore repeat taps lifecycleScope.launch { try { - chat.connect() // idempotent; a cold start has no socket yet runCatching { + chat.connect() // idempotent; a cold start has no socket yet profileManager.refresh() // load active profile so the session isn't orphaned to default chat.createSession(profileManager.active.value) }.onSuccess { id -> pendingRoute.value = "chat/$id" } diff --git a/app/src/main/java/com/hermes/client/ui/chat/ChatViewModel.kt b/app/src/main/java/com/hermes/client/ui/chat/ChatViewModel.kt index a93e620..f249c0a 100644 --- a/app/src/main/java/com/hermes/client/ui/chat/ChatViewModel.kt +++ b/app/src/main/java/com/hermes/client/ui/chat/ChatViewModel.kt @@ -356,7 +356,10 @@ class ChatViewModel @Inject constructor( viewModelScope.launch { runCatching { configRepo.get(profileManager.active.value) } .onSuccess { cfg -> _personaUi.value = PersonaUi(parsePersonas(cfg), activePersonaOf(cfg)) } - .onFailure { _personaUi.value = _personaUi.value.copy(loading = false, error = "Couldn't load personas") } + .onFailure { e -> + if (e is kotlinx.coroutines.CancellationException) throw e + _personaUi.value = _personaUi.value.copy(loading = false, error = "Couldn't load personas") + } } } @@ -373,7 +376,10 @@ class ChatViewModel @Inject constructor( _personaUi.value = _personaUi.value.copy(loading = false, active = if (wire == "none") null else wire) } } - .onFailure { _personaUi.value = _personaUi.value.copy(loading = false, error = "Couldn't apply persona") } + .onFailure { e -> + if (e is kotlinx.coroutines.CancellationException) throw e + _personaUi.value = _personaUi.value.copy(loading = false, error = "Couldn't apply persona") + } } } } diff --git a/app/src/main/java/com/hermes/client/ui/chat/SpeechText.kt b/app/src/main/java/com/hermes/client/ui/chat/SpeechText.kt index 350c7c0..6486b98 100644 --- a/app/src/main/java/com/hermes/client/ui/chat/SpeechText.kt +++ b/app/src/main/java/com/hermes/client/ui/chat/SpeechText.kt @@ -1,5 +1,15 @@ package com.hermes.client.ui.chat +// Compiled once at file load rather than per call, so repeated TTS reads don't re-allocate patterns. +private val FENCED_CODE_REGEX = Regex("```[\\s\\S]*?```") +private val LINK_REGEX = Regex("\\[([^\\]]+)]\\([^)]*\\)") +private val INLINE_CODE_REGEX = Regex("`([^`]*)`") +private val HEADING_REGEX = Regex("(?m)^\\s{0,3}#{1,6}\\s*") +private val EMPHASIS_LEADING_REGEX = Regex("(? text. - s = Regex("\\[([^\\]]+)]\\([^)]*\\)").replace(s) { it.groupValues[1] } + s = LINK_REGEX.replace(s) { it.groupValues[1] } // Inline code `code` -> code. - s = Regex("`([^`]*)`").replace(s) { it.groupValues[1] } + s = INLINE_CODE_REGEX.replace(s) { it.groupValues[1] } // Heading markers at line start. - s = Regex("(?m)^\\s{0,3}#{1,6}\\s*").replace(s, "") + s = HEADING_REGEX.replace(s, "") // Emphasis markers ** * __ _ (leave apostrophes/words intact). s = s.replace("**", "").replace("__", "") - s = Regex("(?