feat: improve adaptive accessibility safeguards
This commit is contained in:
@@ -5,6 +5,8 @@ All notable Hermes Mobile source changes are recorded here. Entries are added du
|
|||||||
## Unreleased
|
## Unreleased
|
||||||
|
|
||||||
### Added
|
### Added
|
||||||
|
- Added deterministic small-phone, landscape, and tablet layout policy with navigation rail adaptation, tablet two-pane chat, and narrow/large-font control stacking.
|
||||||
|
- Added accessibility semantics for headings, panes, streaming announcements, selected/busy states, and actionable rows, plus 48dp minimum touch targets and bounded large message/code rendering with retained full-copy actions for code and tool output.
|
||||||
- Added capability-gated direct gateway tool lifecycle decoding and compact status cards for `tool.started`, `tool.progress`, `tool.completed`, and `tool.failed` SSE events.
|
- Added capability-gated direct gateway tool lifecycle decoding and compact status cards for `tool.started`, `tool.progress`, `tool.completed`, and `tool.failed` SSE events.
|
||||||
- Added bounded collapsed/expanded large-output windows with full-output copy, plus durable expanded-tool restoration across rotation/process resume.
|
- Added bounded collapsed/expanded large-output windows with full-output copy, plus durable expanded-tool restoration across rotation/process resume.
|
||||||
- Added a durable direct-gateway SSE send coordinator that captures the selected session, draft, request ID, and immutable route lease before asynchronous execution.
|
- Added a durable direct-gateway SSE send coordinator that captures the selected session, draft, request ID, and immutable route lease before asynchronous execution.
|
||||||
@@ -30,6 +32,7 @@ All notable Hermes Mobile source changes are recorded here. Entries are added du
|
|||||||
- Added `PROJECT_PLAN.md` as the project-controlled milestone plan.
|
- Added `PROJECT_PLAN.md` as the project-controlled milestone plan.
|
||||||
|
|
||||||
### Changed
|
### Changed
|
||||||
|
- Removed the Android portrait lock, enabled `adjustResize` IME behavior, and suppressed top-level screen slides when the system animator duration scale is disabled.
|
||||||
- Kept direct artifact handoff actions gated off because the advertised gateway contract has no verified artifact URI/download capability; no upstream route or backend behavior was inferred.
|
- Kept direct artifact handoff actions gated off because the advertised gateway contract has no verified artifact URI/download capability; no upstream route or backend behavior was inferred.
|
||||||
- Changed direct session streaming to publish reducer updates to the owning durable send coordinator while retaining the original immutable route/session lease for the entire OkHttp call.
|
- Changed direct session streaming to publish reducer updates to the owning durable send coordinator while retaining the original immutable route/session lease for the entire OkHttp call.
|
||||||
- Changed direct session streaming so one immutable route/session lease owns the request and every decoded event for the full OkHttp call.
|
- Changed direct session streaming so one immutable route/session lease owns the request and every decoded event for the full OkHttp call.
|
||||||
@@ -55,6 +58,7 @@ All notable Hermes Mobile source changes are recorded here. Entries are added du
|
|||||||
- Fixed companion/mobile lint issues from missing Node globals and an unused React settings value.
|
- Fixed companion/mobile lint issues from missing Node globals and an unused React settings value.
|
||||||
|
|
||||||
### Verification
|
### Verification
|
||||||
|
- R1 source-level verification: the required recovery preflight passed immediately before this run; direct JUnit execution of the cached, source-current Android unit classes passed all 61 tests, including all 4 `AdaptiveQualityTest` cases; and `git diff --check` passed. `GRADLE_USER_HOME=/root/hermes-mobile/.gradle-user ./gradlew --offline --no-daemon -Dorg.gradle.jvmargs= -Dorg.gradle.daemon=false -Pkotlin.compiler.execution.strategy=in-process :app:compileDebugKotlin :app:compileDebugUnitTestKotlin :app:assembleDebug` was blocked before project execution by `java.net.SocketException: Operation not permitted`. ADB 37.0.0 is installed at `/opt/android-sdk/platform-tools/adb`, but its daemon cannot create the smart-socket or netlink sockets; no emulator executable, AVD, `/dev/kvm`, USB device bus, or running target exists. No emulator/device matrix evidence exists, so R1 remains incomplete.
|
||||||
- Full Android Kotlin/Compose main-source and unit-test compilation through cached Kotlin 2.0.21 plus direct JUnit execution — passed, 57 tests including B5 large-output bounds, advertised tool lifecycle reduction, and rotation/resume expansion restoration.
|
- Full Android Kotlin/Compose main-source and unit-test compilation through cached Kotlin 2.0.21 plus direct JUnit execution — passed, 57 tests including B5 large-output bounds, advertised tool lifecycle reduction, and rotation/resume expansion restoration.
|
||||||
- Recovery preflight for B5 — passed before this run.
|
- Recovery preflight for B5 — passed before this run.
|
||||||
- `GRADLE_USER_HOME=/root/hermes-mobile/.gradle-user ./gradlew --offline --no-daemon -Dorg.gradle.jvmargs= -Dorg.gradle.daemon=false -Pkotlin.compiler.execution.strategy=in-process :app:testDebugUnitTest :app:assembleDebug` — attempted for B5 but blocked before project execution because the sandbox prohibits Gradle's TCP control socket (`java.net.SocketException: Operation not permitted`).
|
- `GRADLE_USER_HOME=/root/hermes-mobile/.gradle-user ./gradlew --offline --no-daemon -Dorg.gradle.jvmargs= -Dorg.gradle.daemon=false -Pkotlin.compiler.execution.strategy=in-process :app:testDebugUnitTest :app:assembleDebug` — attempted for B5 but blocked before project execution because the sandbox prohibits Gradle's TCP control socket (`java.net.SocketException: Operation not permitted`).
|
||||||
|
|||||||
+3
-1
@@ -24,10 +24,12 @@
|
|||||||
|
|
||||||
Complete the Android/client contract pivot before implementing backend behavior. The next release path targets a remote upstream Hermes Agent gateway/API server directly; `apps/companion` is legacy compatibility only.
|
Complete the Android/client contract pivot before implementing backend behavior. The next release path targets a remote upstream Hermes Agent gateway/API server directly; `apps/companion` is legacy compatibility only.
|
||||||
|
|
||||||
- [ ] **R1 — Adaptive/accessibility quality pass**
|
- [ ] **R1 — Execute and document the adaptive/accessibility device matrix**
|
||||||
- Small phone, normal phone, tablet/two-pane behavior; portrait/landscape.
|
- Small phone, normal phone, tablet/two-pane behavior; portrait/landscape.
|
||||||
- Font scaling, TalkBack semantics, contrast, touch targets, reduced motion, IME behavior.
|
- Font scaling, TalkBack semantics, contrast, touch targets, reduced motion, IME behavior.
|
||||||
- Verification: emulator/device matrix evidence documented in `docs/ROADMAP.md`.
|
- Verification: emulator/device matrix evidence documented in `docs/ROADMAP.md`.
|
||||||
|
- Source-level progress on 2026-07-24: removed the portrait lock, enabled resize-on-IME, added deterministic phone/landscape/tablet layout policy and tablet two-pane chat, stacked dense controls for narrow/large-font layouts, respected the disabled system animator-duration setting for top-level navigation, added headings/live regions/roles/state descriptions, raised interactive targets to at least 48dp, and bounded very large message/code rendering while retaining full-copy actions for code and tool output.
|
||||||
|
- Source verification on 2026-07-24: the required recovery preflight passed immediately before this run; direct JUnit execution of the cached, source-current Android unit classes passed all 61 tests, including all 4 `AdaptiveQualityTest` cases; and `git diff --check` passed. A direct writable-cache Gradle retry was blocked before project execution by `java.net.SocketException: Operation not permitted`. ADB is installed, but its daemon sockets are sandbox-prohibited; no emulator executable, AVD, `/dev/kvm`, USB device bus, or running target is available. R1 remains unchecked because no emulator/device matrix evidence exists.
|
||||||
|
|
||||||
## Externally blocked
|
## Externally blocked
|
||||||
|
|
||||||
|
|||||||
@@ -15,7 +15,7 @@
|
|||||||
<activity
|
<activity
|
||||||
android:name=".MainActivity"
|
android:name=".MainActivity"
|
||||||
android:exported="true"
|
android:exported="true"
|
||||||
android:screenOrientation="portrait">
|
android:windowSoftInputMode="adjustResize">
|
||||||
<intent-filter>
|
<intent-filter>
|
||||||
<action android:name="android.intent.action.MAIN" />
|
<action android:name="android.intent.action.MAIN" />
|
||||||
<category android:name="android.intent.category.LAUNCHER" />
|
<category android:name="android.intent.category.LAUNCHER" />
|
||||||
|
|||||||
@@ -9,6 +9,7 @@ import android.os.Bundle
|
|||||||
import android.os.VibrationEffect
|
import android.os.VibrationEffect
|
||||||
import android.os.Vibrator
|
import android.os.Vibrator
|
||||||
import android.os.VibratorManager
|
import android.os.VibratorManager
|
||||||
|
import android.provider.Settings
|
||||||
import androidx.activity.ComponentActivity
|
import androidx.activity.ComponentActivity
|
||||||
import androidx.activity.compose.BackHandler
|
import androidx.activity.compose.BackHandler
|
||||||
import androidx.activity.compose.setContent
|
import androidx.activity.compose.setContent
|
||||||
@@ -22,17 +23,20 @@ import androidx.compose.animation.togetherWith
|
|||||||
import androidx.compose.foundation.background
|
import androidx.compose.foundation.background
|
||||||
import androidx.compose.foundation.layout.Arrangement
|
import androidx.compose.foundation.layout.Arrangement
|
||||||
import androidx.compose.foundation.layout.Box
|
import androidx.compose.foundation.layout.Box
|
||||||
|
import androidx.compose.foundation.layout.BoxWithConstraints
|
||||||
import androidx.compose.foundation.layout.Column
|
import androidx.compose.foundation.layout.Column
|
||||||
import androidx.compose.foundation.layout.PaddingValues
|
import androidx.compose.foundation.layout.PaddingValues
|
||||||
import androidx.compose.foundation.layout.Row
|
import androidx.compose.foundation.layout.Row
|
||||||
import androidx.compose.foundation.layout.fillMaxSize
|
import androidx.compose.foundation.layout.fillMaxSize
|
||||||
import androidx.compose.foundation.layout.fillMaxWidth
|
import androidx.compose.foundation.layout.fillMaxWidth
|
||||||
import androidx.compose.foundation.layout.height
|
import androidx.compose.foundation.layout.height
|
||||||
|
import androidx.compose.foundation.layout.heightIn
|
||||||
import androidx.compose.foundation.layout.imePadding
|
import androidx.compose.foundation.layout.imePadding
|
||||||
import androidx.compose.foundation.layout.navigationBarsPadding
|
import androidx.compose.foundation.layout.navigationBarsPadding
|
||||||
import androidx.compose.foundation.layout.padding
|
import androidx.compose.foundation.layout.padding
|
||||||
import androidx.compose.foundation.layout.size
|
import androidx.compose.foundation.layout.size
|
||||||
import androidx.compose.foundation.layout.statusBarsPadding
|
import androidx.compose.foundation.layout.statusBarsPadding
|
||||||
|
import androidx.compose.foundation.layout.width
|
||||||
import androidx.compose.foundation.lazy.LazyColumn
|
import androidx.compose.foundation.lazy.LazyColumn
|
||||||
import androidx.compose.foundation.lazy.items
|
import androidx.compose.foundation.lazy.items
|
||||||
import androidx.compose.foundation.shape.CircleShape
|
import androidx.compose.foundation.shape.CircleShape
|
||||||
@@ -74,12 +78,15 @@ import cloud.molberg.hermesmobile.design.SecondaryButton
|
|||||||
import cloud.molberg.hermesmobile.design.SectionHeader
|
import cloud.molberg.hermesmobile.design.SectionHeader
|
||||||
import cloud.molberg.hermesmobile.design.TinyLabel
|
import cloud.molberg.hermesmobile.design.TinyLabel
|
||||||
import cloud.molberg.hermesmobile.design.TypingIndicator
|
import cloud.molberg.hermesmobile.design.TypingIndicator
|
||||||
|
import cloud.molberg.hermesmobile.design.adaptiveLayout
|
||||||
import cloud.molberg.hermesmobile.chat.FakeInboxScreen
|
import cloud.molberg.hermesmobile.chat.FakeInboxScreen
|
||||||
import androidx.compose.material3.Icon
|
import androidx.compose.material3.Icon
|
||||||
import androidx.compose.material3.IconButton
|
import androidx.compose.material3.IconButton
|
||||||
import androidx.compose.material3.NavigationBar
|
import androidx.compose.material3.NavigationBar
|
||||||
import androidx.compose.material3.NavigationBarItem
|
import androidx.compose.material3.NavigationBarItem
|
||||||
import androidx.compose.material3.NavigationBarItemDefaults
|
import androidx.compose.material3.NavigationBarItemDefaults
|
||||||
|
import androidx.compose.material3.NavigationRail
|
||||||
|
import androidx.compose.material3.NavigationRailItem
|
||||||
import androidx.compose.material3.OutlinedTextField
|
import androidx.compose.material3.OutlinedTextField
|
||||||
import androidx.compose.material3.Scaffold
|
import androidx.compose.material3.Scaffold
|
||||||
import androidx.compose.material3.SnackbarHost
|
import androidx.compose.material3.SnackbarHost
|
||||||
@@ -101,6 +108,11 @@ import androidx.compose.ui.draw.clip
|
|||||||
import androidx.compose.ui.graphics.Color
|
import androidx.compose.ui.graphics.Color
|
||||||
import androidx.compose.ui.graphics.vector.ImageVector
|
import androidx.compose.ui.graphics.vector.ImageVector
|
||||||
import androidx.compose.ui.platform.LocalContext
|
import androidx.compose.ui.platform.LocalContext
|
||||||
|
import androidx.compose.ui.platform.LocalDensity
|
||||||
|
import androidx.compose.ui.semantics.LiveRegionMode
|
||||||
|
import androidx.compose.ui.semantics.heading
|
||||||
|
import androidx.compose.ui.semantics.liveRegion
|
||||||
|
import androidx.compose.ui.semantics.semantics
|
||||||
import androidx.compose.ui.text.font.FontWeight
|
import androidx.compose.ui.text.font.FontWeight
|
||||||
import androidx.compose.ui.text.input.KeyboardType
|
import androidx.compose.ui.text.input.KeyboardType
|
||||||
import androidx.compose.ui.text.style.TextOverflow
|
import androidx.compose.ui.text.style.TextOverflow
|
||||||
@@ -309,8 +321,20 @@ fun HermesNativeApp() {
|
|||||||
GatewayReevaluationHooks(api) { connectionState = it }
|
GatewayReevaluationHooks(api) { connectionState = it }
|
||||||
|
|
||||||
HermesMobileTheme {
|
HermesMobileTheme {
|
||||||
|
BoxWithConstraints(Modifier.fillMaxSize()) {
|
||||||
|
val fontScale = LocalDensity.current.fontScale
|
||||||
|
val layout = adaptiveLayout(maxWidth.value.toInt(), maxHeight.value.toInt(), fontScale)
|
||||||
|
val reduceMotion = rememberReducedMotion()
|
||||||
|
val selectScreen: (Screen) -> Unit = {
|
||||||
|
if (it != screen) vibrate(context)
|
||||||
|
screen = it
|
||||||
|
}
|
||||||
|
Row(Modifier.fillMaxSize()) {
|
||||||
|
if (layout.useSideNavigation) {
|
||||||
|
NativeSideBar(screen, selectScreen)
|
||||||
|
}
|
||||||
Scaffold(
|
Scaffold(
|
||||||
modifier = Modifier.fillMaxSize(),
|
modifier = Modifier.weight(1f),
|
||||||
containerColor = HermesTheme.colors.background,
|
containerColor = HermesTheme.colors.background,
|
||||||
topBar = {
|
topBar = {
|
||||||
NativeTopBar(screen, connectionState.connected, onAction = {
|
NativeTopBar(screen, connectionState.connected, onAction = {
|
||||||
@@ -321,37 +345,83 @@ fun HermesNativeApp() {
|
|||||||
})
|
})
|
||||||
},
|
},
|
||||||
bottomBar = {
|
bottomBar = {
|
||||||
NativeBottomBar(
|
if (!layout.useSideNavigation) NativeBottomBar(screen, selectScreen)
|
||||||
current = screen,
|
|
||||||
onSelect = {
|
|
||||||
if (it != screen) vibrate(context)
|
|
||||||
screen = it
|
|
||||||
}
|
|
||||||
)
|
|
||||||
},
|
},
|
||||||
snackbarHost = { SnackbarHost(snackbar) }
|
snackbarHost = { SnackbarHost(snackbar) }
|
||||||
) { padding ->
|
) { padding ->
|
||||||
|
val contentModifier = Modifier
|
||||||
|
.fillMaxSize()
|
||||||
|
.padding(padding)
|
||||||
|
if (reduceMotion) {
|
||||||
|
NativeScreenContent(
|
||||||
|
screen = screen,
|
||||||
|
api = api,
|
||||||
|
connectionState = connectionState,
|
||||||
|
onConnectionState = { connectionState = it },
|
||||||
|
resetInboxToken = resetInboxToken,
|
||||||
|
onNewChat = {
|
||||||
|
resetInboxToken++
|
||||||
|
selectScreen(Screen.Inbox)
|
||||||
|
notify("New chat started.")
|
||||||
|
},
|
||||||
|
notify = ::notify,
|
||||||
|
modifier = contentModifier
|
||||||
|
)
|
||||||
|
} else {
|
||||||
AnimatedContent(
|
AnimatedContent(
|
||||||
targetState = screen,
|
targetState = screen,
|
||||||
transitionSpec = { slideFor(initialState.ordinal, targetState.ordinal) },
|
transitionSpec = { slideFor(initialState.ordinal, targetState.ordinal) },
|
||||||
modifier = Modifier
|
modifier = contentModifier,
|
||||||
.fillMaxSize()
|
|
||||||
.padding(padding),
|
|
||||||
label = "screen-slide"
|
label = "screen-slide"
|
||||||
) { active ->
|
) { active ->
|
||||||
when (active) {
|
NativeScreenContent(
|
||||||
Screen.Inbox -> FakeInboxScreen(resetInboxToken, ::notify)
|
screen = active,
|
||||||
Screen.Terminals -> TerminalsScreen(api, ::notify, onNewChat = {
|
api = api,
|
||||||
|
connectionState = connectionState,
|
||||||
|
onConnectionState = { connectionState = it },
|
||||||
|
resetInboxToken = resetInboxToken,
|
||||||
|
onNewChat = {
|
||||||
resetInboxToken++
|
resetInboxToken++
|
||||||
screen = Screen.Inbox
|
selectScreen(Screen.Inbox)
|
||||||
notify("New chat started.")
|
notify("New chat started.")
|
||||||
})
|
},
|
||||||
Screen.Settings -> SettingsScreen(api, connectionState, { connectionState = it }, ::notify)
|
notify = ::notify
|
||||||
|
)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
@Composable
|
||||||
|
private fun NativeScreenContent(
|
||||||
|
screen: Screen,
|
||||||
|
api: CompanionApi,
|
||||||
|
connectionState: ConnectionUiState,
|
||||||
|
onConnectionState: (ConnectionUiState) -> Unit,
|
||||||
|
resetInboxToken: Int,
|
||||||
|
onNewChat: () -> Unit,
|
||||||
|
notify: (String) -> Unit,
|
||||||
|
modifier: Modifier = Modifier
|
||||||
|
) {
|
||||||
|
Box(modifier) {
|
||||||
|
when (screen) {
|
||||||
|
Screen.Inbox -> FakeInboxScreen(resetInboxToken, notify)
|
||||||
|
Screen.Terminals -> TerminalsScreen(api, notify, onNewChat = onNewChat)
|
||||||
|
Screen.Settings -> SettingsScreen(api, connectionState, onConnectionState, notify)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
@Composable
|
||||||
|
private fun rememberReducedMotion(): Boolean {
|
||||||
|
val context = LocalContext.current
|
||||||
|
return remember(context) {
|
||||||
|
Settings.Global.getFloat(context.contentResolver, Settings.Global.ANIMATOR_DURATION_SCALE, 1f) == 0f
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
@Composable
|
@Composable
|
||||||
private fun GatewayReevaluationHooks(api: CompanionApi, onState: (ConnectionUiState) -> Unit) {
|
private fun GatewayReevaluationHooks(api: CompanionApi, onState: (ConnectionUiState) -> Unit) {
|
||||||
@@ -418,15 +488,19 @@ fun NativeTopBar(screen: Screen, online: Boolean, onAction: () -> Unit) {
|
|||||||
horizontalArrangement = Arrangement.spacedBy(12.dp)
|
horizontalArrangement = Arrangement.spacedBy(12.dp)
|
||||||
) {
|
) {
|
||||||
Column(Modifier.weight(1f), verticalArrangement = Arrangement.spacedBy(4.dp)) {
|
Column(Modifier.weight(1f), verticalArrangement = Arrangement.spacedBy(4.dp)) {
|
||||||
Text(screen.title, color = HermesTheme.colors.text, style = HermesTheme.typography.screenTitle)
|
Text(screen.title, color = HermesTheme.colors.text, style = HermesTheme.typography.screenTitle, modifier = Modifier.semantics { heading() })
|
||||||
Row(verticalAlignment = Alignment.CenterVertically, horizontalArrangement = Arrangement.spacedBy(6.dp)) {
|
Row(
|
||||||
|
modifier = Modifier.semantics { liveRegion = LiveRegionMode.Polite },
|
||||||
|
verticalAlignment = Alignment.CenterVertically,
|
||||||
|
horizontalArrangement = Arrangement.spacedBy(6.dp)
|
||||||
|
) {
|
||||||
val statusColor = if (online) HermesTheme.status.success else HermesTheme.colors.textMuted
|
val statusColor = if (online) HermesTheme.status.success else HermesTheme.colors.textMuted
|
||||||
Box(Modifier.size(8.dp).clip(CircleShape).background(statusColor))
|
Box(Modifier.size(8.dp).clip(CircleShape).background(statusColor))
|
||||||
Text(if (online) "connected" else "offline", color = statusColor, style = HermesTheme.typography.caption, fontWeight = FontWeight.SemiBold)
|
Text(if (online) "connected" else "offline", color = statusColor, style = HermesTheme.typography.caption, fontWeight = FontWeight.SemiBold)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
if (screen == Screen.Inbox) {
|
if (screen == Screen.Inbox) {
|
||||||
IconButton(onClick = onAction, modifier = Modifier.size(44.dp)) {
|
IconButton(onClick = onAction, modifier = Modifier.size(48.dp)) {
|
||||||
Icon(Icons.Filled.Add, contentDescription = "New chat", tint = HermesTheme.colors.text)
|
Icon(Icons.Filled.Add, contentDescription = "New chat", tint = HermesTheme.colors.text)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -440,7 +514,7 @@ fun NativeBottomBar(current: Screen, onSelect: (Screen) -> Unit) {
|
|||||||
modifier = Modifier
|
modifier = Modifier
|
||||||
.fillMaxWidth()
|
.fillMaxWidth()
|
||||||
.navigationBarsPadding()
|
.navigationBarsPadding()
|
||||||
.height(76.dp),
|
.heightIn(min = 72.dp),
|
||||||
containerColor = HermesTheme.colors.background,
|
containerColor = HermesTheme.colors.background,
|
||||||
tonalElevation = HermesTheme.elevation.none
|
tonalElevation = HermesTheme.elevation.none
|
||||||
) {
|
) {
|
||||||
@@ -463,6 +537,35 @@ fun NativeBottomBar(current: Screen, onSelect: (Screen) -> Unit) {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
@Composable
|
||||||
|
fun NativeSideBar(current: Screen, onSelect: (Screen) -> Unit) {
|
||||||
|
Surface(color = HermesTheme.colors.background, tonalElevation = HermesTheme.elevation.none) {
|
||||||
|
NavigationRail(
|
||||||
|
modifier = Modifier
|
||||||
|
.width(88.dp)
|
||||||
|
.statusBarsPadding()
|
||||||
|
.navigationBarsPadding(),
|
||||||
|
containerColor = HermesTheme.colors.background
|
||||||
|
) {
|
||||||
|
Screen.entries.forEach { item ->
|
||||||
|
NavigationRailItem(
|
||||||
|
selected = current == item,
|
||||||
|
onClick = { onSelect(item) },
|
||||||
|
icon = { Icon(item.icon, contentDescription = item.label) },
|
||||||
|
label = { Text(item.label, fontWeight = FontWeight.SemiBold) },
|
||||||
|
colors = androidx.compose.material3.NavigationRailItemDefaults.colors(
|
||||||
|
selectedIconColor = HermesTheme.colors.text,
|
||||||
|
selectedTextColor = HermesTheme.colors.text,
|
||||||
|
indicatorColor = HermesTheme.colors.surfaceRaised,
|
||||||
|
unselectedIconColor = HermesTheme.colors.textMuted,
|
||||||
|
unselectedTextColor = HermesTheme.colors.textMuted
|
||||||
|
)
|
||||||
|
)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
@Composable
|
@Composable
|
||||||
fun InboxScreen(api: CompanionApi, resetToken: Int) {
|
fun InboxScreen(api: CompanionApi, resetToken: Int) {
|
||||||
val scope = remember { CoroutineScope(Dispatchers.Main) }
|
val scope = remember { CoroutineScope(Dispatchers.Main) }
|
||||||
@@ -562,7 +665,7 @@ fun ConversationThread(
|
|||||||
verticalAlignment = Alignment.CenterVertically,
|
verticalAlignment = Alignment.CenterVertically,
|
||||||
horizontalArrangement = Arrangement.spacedBy(6.dp)
|
horizontalArrangement = Arrangement.spacedBy(6.dp)
|
||||||
) {
|
) {
|
||||||
IconButton(onClick = onBack, modifier = Modifier.size(42.dp)) {
|
IconButton(onClick = onBack, modifier = Modifier.size(48.dp)) {
|
||||||
Icon(Icons.Filled.ArrowBack, contentDescription = "Conversations", tint = HermesTheme.colors.text)
|
Icon(Icons.Filled.ArrowBack, contentDescription = "Conversations", tint = HermesTheme.colors.text)
|
||||||
}
|
}
|
||||||
Column(Modifier.weight(1f)) {
|
Column(Modifier.weight(1f)) {
|
||||||
|
|||||||
+60
-18
@@ -14,6 +14,7 @@ import androidx.compose.foundation.layout.Spacer
|
|||||||
import androidx.compose.foundation.layout.fillMaxHeight
|
import androidx.compose.foundation.layout.fillMaxHeight
|
||||||
import androidx.compose.foundation.layout.fillMaxSize
|
import androidx.compose.foundation.layout.fillMaxSize
|
||||||
import androidx.compose.foundation.layout.fillMaxWidth
|
import androidx.compose.foundation.layout.fillMaxWidth
|
||||||
|
import androidx.compose.foundation.layout.heightIn
|
||||||
import androidx.compose.foundation.layout.imePadding
|
import androidx.compose.foundation.layout.imePadding
|
||||||
import androidx.compose.foundation.layout.padding
|
import androidx.compose.foundation.layout.padding
|
||||||
import androidx.compose.foundation.layout.size
|
import androidx.compose.foundation.layout.size
|
||||||
@@ -56,6 +57,16 @@ import androidx.compose.ui.Modifier
|
|||||||
import androidx.compose.ui.draw.clip
|
import androidx.compose.ui.draw.clip
|
||||||
import androidx.compose.ui.platform.LocalClipboardManager
|
import androidx.compose.ui.platform.LocalClipboardManager
|
||||||
import androidx.compose.ui.platform.LocalContext
|
import androidx.compose.ui.platform.LocalContext
|
||||||
|
import androidx.compose.ui.platform.LocalDensity
|
||||||
|
import androidx.compose.ui.semantics.LiveRegionMode
|
||||||
|
import androidx.compose.ui.semantics.Role
|
||||||
|
import androidx.compose.ui.semantics.contentDescription
|
||||||
|
import androidx.compose.ui.semantics.liveRegion
|
||||||
|
import androidx.compose.ui.semantics.paneTitle
|
||||||
|
import androidx.compose.ui.semantics.role
|
||||||
|
import androidx.compose.ui.semantics.selected
|
||||||
|
import androidx.compose.ui.semantics.semantics
|
||||||
|
import androidx.compose.ui.semantics.stateDescription
|
||||||
import androidx.compose.ui.text.AnnotatedString
|
import androidx.compose.ui.text.AnnotatedString
|
||||||
import androidx.compose.ui.text.font.FontWeight
|
import androidx.compose.ui.text.font.FontWeight
|
||||||
import androidx.compose.ui.text.style.TextOverflow
|
import androidx.compose.ui.text.style.TextOverflow
|
||||||
@@ -63,6 +74,7 @@ import androidx.compose.ui.unit.dp
|
|||||||
import cloud.molberg.hermesmobile.design.EmptyState
|
import cloud.molberg.hermesmobile.design.EmptyState
|
||||||
import cloud.molberg.hermesmobile.design.FlatCard
|
import cloud.molberg.hermesmobile.design.FlatCard
|
||||||
import cloud.molberg.hermesmobile.design.HermesTheme
|
import cloud.molberg.hermesmobile.design.HermesTheme
|
||||||
|
import cloud.molberg.hermesmobile.design.LongTextPolicy
|
||||||
import cloud.molberg.hermesmobile.design.ModeChip
|
import cloud.molberg.hermesmobile.design.ModeChip
|
||||||
import cloud.molberg.hermesmobile.design.NoticeCard
|
import cloud.molberg.hermesmobile.design.NoticeCard
|
||||||
import cloud.molberg.hermesmobile.design.PrimaryButton
|
import cloud.molberg.hermesmobile.design.PrimaryButton
|
||||||
@@ -70,6 +82,7 @@ import cloud.molberg.hermesmobile.design.ScreenList
|
|||||||
import cloud.molberg.hermesmobile.design.SecondaryButton
|
import cloud.molberg.hermesmobile.design.SecondaryButton
|
||||||
import cloud.molberg.hermesmobile.design.SectionHeader
|
import cloud.molberg.hermesmobile.design.SectionHeader
|
||||||
import cloud.molberg.hermesmobile.design.TinyLabel
|
import cloud.molberg.hermesmobile.design.TinyLabel
|
||||||
|
import cloud.molberg.hermesmobile.design.adaptiveLayout
|
||||||
import kotlinx.coroutines.Job
|
import kotlinx.coroutines.Job
|
||||||
import kotlinx.coroutines.delay
|
import kotlinx.coroutines.delay
|
||||||
import kotlinx.coroutines.launch
|
import kotlinx.coroutines.launch
|
||||||
@@ -154,7 +167,8 @@ fun FakeInboxScreen(resetToken: Int, notify: (String) -> Unit) {
|
|||||||
}
|
}
|
||||||
|
|
||||||
BoxWithConstraints(Modifier.fillMaxSize()) {
|
BoxWithConstraints(Modifier.fillMaxSize()) {
|
||||||
val wide = maxWidth >= 720.dp
|
val layout = adaptiveLayout(maxWidth.value.toInt(), maxHeight.value.toInt(), LocalDensity.current.fontScale)
|
||||||
|
val wide = layout.useTwoPaneChat
|
||||||
val selected = state.selectedSession
|
val selected = state.selectedSession
|
||||||
if (!wide) {
|
if (!wide) {
|
||||||
BackHandler(selected != null) { state = state.copy(selectedSessionId = null) }
|
BackHandler(selected != null) { state = state.copy(selectedSessionId = null) }
|
||||||
@@ -165,7 +179,7 @@ fun FakeInboxScreen(resetToken: Int, notify: (String) -> Unit) {
|
|||||||
ChatSessionRail(
|
ChatSessionRail(
|
||||||
state = state,
|
state = state,
|
||||||
modifier = Modifier
|
modifier = Modifier
|
||||||
.width(304.dp)
|
.width(layout.sessionPaneWidthDp.dp)
|
||||||
.fillMaxHeight(),
|
.fillMaxHeight(),
|
||||||
onNew = ::createSession,
|
onNew = ::createSession,
|
||||||
onSelect = ::selectSession,
|
onSelect = ::selectSession,
|
||||||
@@ -273,7 +287,13 @@ private fun SessionRow(session: ChatSessionUi, selected: Boolean, onClick: () ->
|
|||||||
) {
|
) {
|
||||||
Row(
|
Row(
|
||||||
Modifier
|
Modifier
|
||||||
.clickable(onClick = onClick)
|
.clickable(role = Role.Button, onClick = onClick)
|
||||||
|
.semantics(mergeDescendants = true) {
|
||||||
|
role = Role.Button
|
||||||
|
this.selected = selected
|
||||||
|
stateDescription = if (session.busy) "Busy" else "Ready"
|
||||||
|
}
|
||||||
|
.heightIn(min = 48.dp)
|
||||||
.padding(12.dp),
|
.padding(12.dp),
|
||||||
verticalAlignment = Alignment.CenterVertically,
|
verticalAlignment = Alignment.CenterVertically,
|
||||||
horizontalArrangement = Arrangement.spacedBy(10.dp)
|
horizontalArrangement = Arrangement.spacedBy(10.dp)
|
||||||
@@ -334,7 +354,7 @@ private fun ChatConversationPane(
|
|||||||
ScreenList { EmptyState() }
|
ScreenList { EmptyState() }
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
Column(modifier) {
|
Column(modifier.semantics { paneTitle = "Conversation" }) {
|
||||||
ConversationHeader(session, state.connectionState, onBack)
|
ConversationHeader(session, state.connectionState, onBack)
|
||||||
LazyColumn(
|
LazyColumn(
|
||||||
modifier = Modifier.weight(1f),
|
modifier = Modifier.weight(1f),
|
||||||
@@ -384,7 +404,7 @@ private fun ConversationHeader(session: ChatSessionUi, connectionState: ChatConn
|
|||||||
horizontalArrangement = Arrangement.spacedBy(6.dp)
|
horizontalArrangement = Arrangement.spacedBy(6.dp)
|
||||||
) {
|
) {
|
||||||
if (onBack != null) {
|
if (onBack != null) {
|
||||||
IconButton(onClick = onBack, modifier = Modifier.size(42.dp)) {
|
IconButton(onClick = onBack, modifier = Modifier.size(48.dp)) {
|
||||||
Icon(Icons.Filled.ArrowBack, contentDescription = "Conversations", tint = HermesTheme.colors.text)
|
Icon(Icons.Filled.ArrowBack, contentDescription = "Conversations", tint = HermesTheme.colors.text)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -403,23 +423,36 @@ private fun ConversationHeader(session: ChatSessionUi, connectionState: ChatConn
|
|||||||
|
|
||||||
@Composable
|
@Composable
|
||||||
private fun ContextControlsCard(context: ChatContextControls, onContext: (ChatContextControls) -> Unit) {
|
private fun ContextControlsCard(context: ChatContextControls, onContext: (ChatContextControls) -> Unit) {
|
||||||
|
BoxWithConstraints {
|
||||||
|
val stackControls = adaptiveLayout(maxWidth.value.toInt(), maxHeight.value.toInt(), LocalDensity.current.fontScale).stackDenseControls
|
||||||
FlatCard {
|
FlatCard {
|
||||||
Column(Modifier.padding(12.dp), verticalArrangement = Arrangement.spacedBy(10.dp)) {
|
Column(Modifier.padding(12.dp), verticalArrangement = Arrangement.spacedBy(10.dp)) {
|
||||||
Row(verticalAlignment = Alignment.CenterVertically, horizontalArrangement = Arrangement.spacedBy(8.dp), modifier = Modifier.fillMaxWidth()) {
|
Row(verticalAlignment = Alignment.CenterVertically, horizontalArrangement = Arrangement.spacedBy(8.dp), modifier = Modifier.fillMaxWidth()) {
|
||||||
Icon(Icons.Filled.Tune, null, tint = HermesTheme.colors.textMuted, modifier = Modifier.size(18.dp))
|
Icon(Icons.Filled.Tune, null, tint = HermesTheme.colors.textMuted, modifier = Modifier.size(18.dp))
|
||||||
Text("Context", color = HermesTheme.colors.text, style = HermesTheme.typography.rowTitle)
|
Text("Context", color = HermesTheme.colors.text, style = HermesTheme.typography.rowTitle)
|
||||||
Spacer(Modifier.weight(1f))
|
Spacer(Modifier.weight(1f))
|
||||||
Switch(checked = context.toolsEnabled, onCheckedChange = { onContext(context.copy(toolsEnabled = it)) })
|
Switch(
|
||||||
|
checked = context.toolsEnabled,
|
||||||
|
onCheckedChange = { onContext(context.copy(toolsEnabled = it)) },
|
||||||
|
modifier = Modifier.semantics { contentDescription = "Tools enabled" }
|
||||||
|
)
|
||||||
}
|
}
|
||||||
|
if (stackControls) {
|
||||||
|
ModeChip("Default", context.model == "Hermes default", Modifier.fillMaxWidth()) { onContext(context.copy(model = "Hermes default")) }
|
||||||
|
ModeChip("Fast", context.model == "Hermes fast", Modifier.fillMaxWidth()) { onContext(context.copy(model = "Hermes fast")) }
|
||||||
|
ModeChip("Deep", context.effort == "High", Modifier.fillMaxWidth()) { onContext(context.copy(effort = if (context.effort == "High") "Normal" else "High")) }
|
||||||
|
} else {
|
||||||
Row(horizontalArrangement = Arrangement.spacedBy(8.dp), modifier = Modifier.fillMaxWidth()) {
|
Row(horizontalArrangement = Arrangement.spacedBy(8.dp), modifier = Modifier.fillMaxWidth()) {
|
||||||
ModeChip("Default", context.model == "Hermes default", Modifier.weight(1f)) { onContext(context.copy(model = "Hermes default")) }
|
ModeChip("Default", context.model == "Hermes default", Modifier.weight(1f)) { onContext(context.copy(model = "Hermes default")) }
|
||||||
ModeChip("Fast", context.model == "Hermes fast", Modifier.weight(1f)) { onContext(context.copy(model = "Hermes fast")) }
|
ModeChip("Fast", context.model == "Hermes fast", Modifier.weight(1f)) { onContext(context.copy(model = "Hermes fast")) }
|
||||||
ModeChip("Deep", context.effort == "High", Modifier.weight(1f)) { onContext(context.copy(effort = if (context.effort == "High") "Normal" else "High")) }
|
ModeChip("Deep", context.effort == "High", Modifier.weight(1f)) { onContext(context.copy(effort = if (context.effort == "High") "Normal" else "High")) }
|
||||||
}
|
}
|
||||||
|
}
|
||||||
Text(context.workspace, color = HermesTheme.colors.textMuted, style = HermesTheme.typography.caption, maxLines = 1, overflow = TextOverflow.Ellipsis)
|
Text(context.workspace, color = HermesTheme.colors.textMuted, style = HermesTheme.typography.caption, maxLines = 1, overflow = TextOverflow.Ellipsis)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
}
|
||||||
|
|
||||||
@Composable
|
@Composable
|
||||||
private fun ChatMessageCard(
|
private fun ChatMessageCard(
|
||||||
@@ -431,7 +464,9 @@ private fun ChatMessageCard(
|
|||||||
val isUser = message.role == ChatRole.User
|
val isUser = message.role == ChatRole.User
|
||||||
Row(Modifier.fillMaxWidth(), horizontalArrangement = if (isUser) Arrangement.End else Arrangement.Start) {
|
Row(Modifier.fillMaxWidth(), horizontalArrangement = if (isUser) Arrangement.End else Arrangement.Start) {
|
||||||
FlatCard(
|
FlatCard(
|
||||||
modifier = Modifier.fillMaxWidth(if (isUser) 0.86f else 0.94f),
|
modifier = Modifier
|
||||||
|
.fillMaxWidth(if (isUser) 0.86f else 0.94f)
|
||||||
|
.semantics { if (message.streaming) liveRegion = LiveRegionMode.Polite },
|
||||||
color = when (message.role) {
|
color = when (message.role) {
|
||||||
ChatRole.User -> HermesTheme.colors.userBubble
|
ChatRole.User -> HermesTheme.colors.userBubble
|
||||||
ChatRole.Error -> HermesTheme.status.dangerPanel
|
ChatRole.Error -> HermesTheme.status.dangerPanel
|
||||||
@@ -448,7 +483,7 @@ private fun ChatMessageCard(
|
|||||||
}
|
}
|
||||||
Spacer(Modifier.weight(1f))
|
Spacer(Modifier.weight(1f))
|
||||||
if (message.retryPrompt != null) {
|
if (message.retryPrompt != null) {
|
||||||
IconButton(onClick = onRetry, modifier = Modifier.size(38.dp)) {
|
IconButton(onClick = onRetry, modifier = Modifier.size(48.dp)) {
|
||||||
Icon(Icons.Filled.Refresh, contentDescription = "Retry", tint = HermesTheme.colors.text)
|
Icon(Icons.Filled.Refresh, contentDescription = "Retry", tint = HermesTheme.colors.text)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -467,18 +502,19 @@ private fun ChatMessageCard(
|
|||||||
|
|
||||||
@Composable
|
@Composable
|
||||||
private fun MarkdownText(text: String) {
|
private fun MarkdownText(text: String) {
|
||||||
|
val display = LongTextPolicy.message(text.replace("**", ""))
|
||||||
SelectionContainer {
|
SelectionContainer {
|
||||||
Text(
|
Column(verticalArrangement = Arrangement.spacedBy(6.dp)) {
|
||||||
text = text.replace("**", ""),
|
Text(text = display.text, color = HermesTheme.colors.text, style = HermesTheme.typography.body)
|
||||||
color = HermesTheme.colors.text,
|
if (display.truncated) Text("Long response capped for safe display.", color = HermesTheme.colors.textMuted, style = HermesTheme.typography.caption)
|
||||||
style = HermesTheme.typography.body
|
}
|
||||||
)
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@Composable
|
@Composable
|
||||||
private fun CodeBlockCard(language: String, code: String, notify: (String) -> Unit) {
|
private fun CodeBlockCard(language: String, code: String, notify: (String) -> Unit) {
|
||||||
val clipboard = LocalClipboardManager.current
|
val clipboard = LocalClipboardManager.current
|
||||||
|
val display = LongTextPolicy.code(code)
|
||||||
FlatCard(color = HermesTheme.colors.surfaceRaised) {
|
FlatCard(color = HermesTheme.colors.surfaceRaised) {
|
||||||
Column(Modifier.padding(10.dp), verticalArrangement = Arrangement.spacedBy(6.dp)) {
|
Column(Modifier.padding(10.dp), verticalArrangement = Arrangement.spacedBy(6.dp)) {
|
||||||
Row(verticalAlignment = Alignment.CenterVertically, modifier = Modifier.fillMaxWidth()) {
|
Row(verticalAlignment = Alignment.CenterVertically, modifier = Modifier.fillMaxWidth()) {
|
||||||
@@ -489,14 +525,14 @@ private fun CodeBlockCard(language: String, code: String, notify: (String) -> Un
|
|||||||
clipboard.setText(AnnotatedString(code))
|
clipboard.setText(AnnotatedString(code))
|
||||||
notify("Code copied.")
|
notify("Code copied.")
|
||||||
},
|
},
|
||||||
modifier = Modifier.size(36.dp)
|
modifier = Modifier.size(48.dp)
|
||||||
) {
|
) {
|
||||||
Icon(Icons.Filled.ContentCopy, contentDescription = "Copy code", tint = HermesTheme.colors.text)
|
Icon(Icons.Filled.ContentCopy, contentDescription = "Copy code", tint = HermesTheme.colors.text)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
SelectionContainer {
|
SelectionContainer {
|
||||||
Text(
|
Text(
|
||||||
code,
|
display.text,
|
||||||
modifier = Modifier
|
modifier = Modifier
|
||||||
.horizontalScroll(rememberScrollState())
|
.horizontalScroll(rememberScrollState())
|
||||||
.padding(bottom = 2.dp),
|
.padding(bottom = 2.dp),
|
||||||
@@ -504,6 +540,7 @@ private fun CodeBlockCard(language: String, code: String, notify: (String) -> Un
|
|||||||
style = HermesTheme.typography.code
|
style = HermesTheme.typography.code
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
if (display.truncated) Text("Large code capped for safe display. Copy includes the full code.", color = HermesTheme.colors.textMuted, style = HermesTheme.typography.caption)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -517,7 +554,12 @@ private fun ToolResultCard(result: ToolResultUi, onToggle: () -> Unit, notify: (
|
|||||||
Row(
|
Row(
|
||||||
Modifier
|
Modifier
|
||||||
.fillMaxWidth()
|
.fillMaxWidth()
|
||||||
.clickable(onClick = onToggle),
|
.clickable(role = Role.Button, onClick = onToggle)
|
||||||
|
.semantics {
|
||||||
|
role = Role.Button
|
||||||
|
stateDescription = if (result.expanded) "Expanded" else "Collapsed"
|
||||||
|
}
|
||||||
|
.heightIn(min = 48.dp),
|
||||||
verticalAlignment = Alignment.CenterVertically,
|
verticalAlignment = Alignment.CenterVertically,
|
||||||
horizontalArrangement = Arrangement.spacedBy(8.dp)
|
horizontalArrangement = Arrangement.spacedBy(8.dp)
|
||||||
) {
|
) {
|
||||||
@@ -529,7 +571,7 @@ private fun ToolResultCard(result: ToolResultUi, onToggle: () -> Unit, notify: (
|
|||||||
clipboard.setText(AnnotatedString(result.output))
|
clipboard.setText(AnnotatedString(result.output))
|
||||||
notify("Tool output copied.")
|
notify("Tool output copied.")
|
||||||
},
|
},
|
||||||
modifier = Modifier.size(36.dp)
|
modifier = Modifier.size(48.dp)
|
||||||
) {
|
) {
|
||||||
Icon(Icons.Filled.ContentCopy, contentDescription = "Copy tool output", tint = HermesTheme.colors.textMuted)
|
Icon(Icons.Filled.ContentCopy, contentDescription = "Copy tool output", tint = HermesTheme.colors.textMuted)
|
||||||
}
|
}
|
||||||
@@ -581,7 +623,7 @@ private fun ChatComposer(
|
|||||||
value = session.draft,
|
value = session.draft,
|
||||||
onValueChange = onDraft,
|
onValueChange = onDraft,
|
||||||
modifier = Modifier.weight(1f),
|
modifier = Modifier.weight(1f),
|
||||||
placeholder = { Text("Message Hermes", color = HermesTheme.colors.textMuted) },
|
label = { Text("Message Hermes") },
|
||||||
minLines = 1,
|
minLines = 1,
|
||||||
maxLines = 5,
|
maxLines = 5,
|
||||||
shape = RoundedCornerShape(24.dp),
|
shape = RoundedCornerShape(24.dp),
|
||||||
|
|||||||
+77
@@ -0,0 +1,77 @@
|
|||||||
|
package cloud.molberg.hermesmobile.design
|
||||||
|
|
||||||
|
import androidx.compose.ui.graphics.Color
|
||||||
|
import kotlin.math.max
|
||||||
|
import kotlin.math.min
|
||||||
|
import kotlin.math.pow
|
||||||
|
|
||||||
|
enum class HermesWindowSize { Small, Normal, Tablet }
|
||||||
|
|
||||||
|
data class AdaptiveLayout(
|
||||||
|
val windowSize: HermesWindowSize,
|
||||||
|
val landscape: Boolean,
|
||||||
|
val useSideNavigation: Boolean,
|
||||||
|
val useTwoPaneChat: Boolean,
|
||||||
|
val stackDenseControls: Boolean,
|
||||||
|
val sessionPaneWidthDp: Int
|
||||||
|
)
|
||||||
|
|
||||||
|
fun adaptiveLayout(widthDp: Int, heightDp: Int, fontScale: Float): AdaptiveLayout {
|
||||||
|
val windowSize = when {
|
||||||
|
widthDp < 360 -> HermesWindowSize.Small
|
||||||
|
widthDp < 720 -> HermesWindowSize.Normal
|
||||||
|
else -> HermesWindowSize.Tablet
|
||||||
|
}
|
||||||
|
val landscape = widthDp > heightDp
|
||||||
|
return AdaptiveLayout(
|
||||||
|
windowSize = windowSize,
|
||||||
|
landscape = landscape,
|
||||||
|
useSideNavigation = widthDp >= 600 && (landscape || windowSize == HermesWindowSize.Tablet),
|
||||||
|
useTwoPaneChat = widthDp >= 720,
|
||||||
|
stackDenseControls = widthDp < 400 || fontScale >= 1.3f,
|
||||||
|
sessionPaneWidthDp = when {
|
||||||
|
widthDp >= 1000 -> 336
|
||||||
|
widthDp >= 840 -> 312
|
||||||
|
else -> 288
|
||||||
|
}
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
data class SafeTextWindow(val text: String, val truncated: Boolean)
|
||||||
|
|
||||||
|
object LongTextPolicy {
|
||||||
|
const val MaxMessageCharacters = 48_000
|
||||||
|
const val MaxCodeCharacters = 24_000
|
||||||
|
const val MaxCodeLines = 120
|
||||||
|
|
||||||
|
fun message(text: String): SafeTextWindow = characterWindow(text, MaxMessageCharacters)
|
||||||
|
|
||||||
|
fun code(text: String): SafeTextWindow {
|
||||||
|
val lines = text.lineSequence().take(MaxCodeLines + 1).toList()
|
||||||
|
val lineLimited = lines.size > MaxCodeLines
|
||||||
|
val visible = if (lineLimited) lines.take(MaxCodeLines).joinToString("\n") else text
|
||||||
|
val characterLimited = visible.length > MaxCodeCharacters
|
||||||
|
return SafeTextWindow(
|
||||||
|
text = if (characterLimited) visible.take(MaxCodeCharacters) else visible,
|
||||||
|
truncated = lineLimited || characterLimited
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
private fun characterWindow(text: String, limit: Int) = SafeTextWindow(
|
||||||
|
text = if (text.length > limit) text.take(limit) else text,
|
||||||
|
truncated = text.length > limit
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
fun contrastRatio(first: Color, second: Color): Double {
|
||||||
|
fun luminance(color: Color): Double {
|
||||||
|
fun channel(value: Float): Double {
|
||||||
|
val normalized = value.toDouble()
|
||||||
|
return if (normalized <= 0.03928) normalized / 12.92 else ((normalized + 0.055) / 1.055).pow(2.4)
|
||||||
|
}
|
||||||
|
return 0.2126 * channel(color.red) + 0.7152 * channel(color.green) + 0.0722 * channel(color.blue)
|
||||||
|
}
|
||||||
|
val firstLuminance = luminance(first)
|
||||||
|
val secondLuminance = luminance(second)
|
||||||
|
return (max(firstLuminance, secondLuminance) + 0.05) / (min(firstLuminance, secondLuminance) + 0.05)
|
||||||
|
}
|
||||||
+29
-13
@@ -14,6 +14,7 @@ import androidx.compose.foundation.layout.Spacer
|
|||||||
import androidx.compose.foundation.layout.fillMaxSize
|
import androidx.compose.foundation.layout.fillMaxSize
|
||||||
import androidx.compose.foundation.layout.fillMaxWidth
|
import androidx.compose.foundation.layout.fillMaxWidth
|
||||||
import androidx.compose.foundation.layout.height
|
import androidx.compose.foundation.layout.height
|
||||||
|
import androidx.compose.foundation.layout.heightIn
|
||||||
import androidx.compose.foundation.layout.padding
|
import androidx.compose.foundation.layout.padding
|
||||||
import androidx.compose.foundation.layout.size
|
import androidx.compose.foundation.layout.size
|
||||||
import androidx.compose.foundation.layout.width
|
import androidx.compose.foundation.layout.width
|
||||||
@@ -46,6 +47,11 @@ import androidx.compose.ui.Modifier
|
|||||||
import androidx.compose.ui.draw.clip
|
import androidx.compose.ui.draw.clip
|
||||||
import androidx.compose.ui.graphics.Color
|
import androidx.compose.ui.graphics.Color
|
||||||
import androidx.compose.ui.graphics.vector.ImageVector
|
import androidx.compose.ui.graphics.vector.ImageVector
|
||||||
|
import androidx.compose.ui.semantics.Role
|
||||||
|
import androidx.compose.ui.semantics.heading
|
||||||
|
import androidx.compose.ui.semantics.role
|
||||||
|
import androidx.compose.ui.semantics.selected
|
||||||
|
import androidx.compose.ui.semantics.semantics
|
||||||
import androidx.compose.ui.text.font.FontWeight
|
import androidx.compose.ui.text.font.FontWeight
|
||||||
import androidx.compose.ui.text.input.KeyboardType
|
import androidx.compose.ui.text.input.KeyboardType
|
||||||
import androidx.compose.ui.text.input.PasswordVisualTransformation
|
import androidx.compose.ui.text.input.PasswordVisualTransformation
|
||||||
@@ -68,7 +74,7 @@ fun ScreenList(content: @Composable ColumnScope.() -> Unit) {
|
|||||||
fun DetailList(title: String, onBack: () -> Unit, content: @Composable ColumnScope.() -> Unit) {
|
fun DetailList(title: String, onBack: () -> Unit, content: @Composable ColumnScope.() -> Unit) {
|
||||||
ScreenList {
|
ScreenList {
|
||||||
Row(verticalAlignment = Alignment.CenterVertically, horizontalArrangement = Arrangement.spacedBy(HermesTheme.spacing.sm), modifier = Modifier.fillMaxWidth()) {
|
Row(verticalAlignment = Alignment.CenterVertically, horizontalArrangement = Arrangement.spacedBy(HermesTheme.spacing.sm), modifier = Modifier.fillMaxWidth()) {
|
||||||
androidx.compose.material3.IconButton(onClick = onBack, modifier = Modifier.size(42.dp)) {
|
androidx.compose.material3.IconButton(onClick = onBack, modifier = Modifier.size(48.dp)) {
|
||||||
Icon(Icons.Filled.ArrowBack, contentDescription = "Back", tint = HermesTheme.colors.text)
|
Icon(Icons.Filled.ArrowBack, contentDescription = "Back", tint = HermesTheme.colors.text)
|
||||||
}
|
}
|
||||||
Text(title, color = HermesTheme.colors.text, style = HermesTheme.typography.panelTitle)
|
Text(title, color = HermesTheme.colors.text, style = HermesTheme.typography.panelTitle)
|
||||||
@@ -80,7 +86,7 @@ fun DetailList(title: String, onBack: () -> Unit, content: @Composable ColumnSco
|
|||||||
@Composable
|
@Composable
|
||||||
fun SectionHeader(title: String, detail: String) {
|
fun SectionHeader(title: String, detail: String) {
|
||||||
Column(Modifier.padding(top = HermesTheme.spacing.sm, start = HermesTheme.spacing.xxs, end = HermesTheme.spacing.xxs), verticalArrangement = Arrangement.spacedBy(3.dp)) {
|
Column(Modifier.padding(top = HermesTheme.spacing.sm, start = HermesTheme.spacing.xxs, end = HermesTheme.spacing.xxs), verticalArrangement = Arrangement.spacedBy(3.dp)) {
|
||||||
Text(title, color = HermesTheme.colors.text, style = HermesTheme.typography.sectionTitle)
|
Text(title, color = HermesTheme.colors.text, style = HermesTheme.typography.sectionTitle, modifier = Modifier.semantics { heading() })
|
||||||
Text(detail, color = HermesTheme.colors.textMuted, style = HermesTheme.typography.caption)
|
Text(detail, color = HermesTheme.colors.textMuted, style = HermesTheme.typography.caption)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -106,7 +112,9 @@ fun ListRow(icon: ImageVector, title: String, subtitle: String, onClick: () -> U
|
|||||||
FlatCard {
|
FlatCard {
|
||||||
Row(
|
Row(
|
||||||
Modifier
|
Modifier
|
||||||
.clickable(onClick = onClick)
|
.clickable(role = Role.Button, onClick = onClick)
|
||||||
|
.semantics(mergeDescendants = true) { role = Role.Button }
|
||||||
|
.heightIn(min = 48.dp)
|
||||||
.padding(horizontal = 14.dp, vertical = 13.dp),
|
.padding(horizontal = 14.dp, vertical = 13.dp),
|
||||||
verticalAlignment = Alignment.CenterVertically,
|
verticalAlignment = Alignment.CenterVertically,
|
||||||
horizontalArrangement = Arrangement.spacedBy(HermesTheme.spacing.md)
|
horizontalArrangement = Arrangement.spacedBy(HermesTheme.spacing.md)
|
||||||
@@ -133,13 +141,17 @@ fun ListRow(icon: ImageVector, title: String, subtitle: String, onClick: () -> U
|
|||||||
fun ModeChip(text: String, selected: Boolean, modifier: Modifier = Modifier, onClick: () -> Unit) {
|
fun ModeChip(text: String, selected: Boolean, modifier: Modifier = Modifier, onClick: () -> Unit) {
|
||||||
Surface(
|
Surface(
|
||||||
modifier = modifier
|
modifier = modifier
|
||||||
.height(44.dp)
|
.heightIn(min = 48.dp)
|
||||||
.clip(RoundedCornerShape(HermesTheme.shapes.chip))
|
.clip(RoundedCornerShape(HermesTheme.shapes.chip))
|
||||||
.clickable(onClick = onClick),
|
.clickable(role = Role.Button, onClick = onClick)
|
||||||
|
.semantics {
|
||||||
|
role = Role.Button
|
||||||
|
this.selected = selected
|
||||||
|
},
|
||||||
color = if (selected) HermesTheme.colors.primary else HermesTheme.colors.surfaceRaised,
|
color = if (selected) HermesTheme.colors.primary else HermesTheme.colors.surfaceRaised,
|
||||||
contentColor = if (selected) HermesTheme.colors.onPrimary else HermesTheme.colors.text
|
contentColor = if (selected) HermesTheme.colors.onPrimary else HermesTheme.colors.text
|
||||||
) {
|
) {
|
||||||
Box(contentAlignment = Alignment.Center) {
|
Box(contentAlignment = Alignment.Center, modifier = Modifier.padding(horizontal = 10.dp, vertical = 8.dp)) {
|
||||||
Text(text, fontWeight = FontWeight.SemiBold, style = HermesTheme.typography.body)
|
Text(text, fontWeight = FontWeight.SemiBold, style = HermesTheme.typography.body)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -249,15 +261,17 @@ fun NoticeCard(text: String, danger: Boolean = false) {
|
|||||||
|
|
||||||
@Composable
|
@Composable
|
||||||
fun CodeCard(text: String) {
|
fun CodeCard(text: String) {
|
||||||
|
val display = LongTextPolicy.code(text)
|
||||||
FlatCard {
|
FlatCard {
|
||||||
|
Column(Modifier.padding(14.dp), verticalArrangement = Arrangement.spacedBy(6.dp)) {
|
||||||
Text(
|
Text(
|
||||||
text,
|
display.text,
|
||||||
Modifier
|
Modifier.horizontalScroll(rememberScrollState()),
|
||||||
.horizontalScroll(rememberScrollState())
|
|
||||||
.padding(14.dp),
|
|
||||||
color = HermesTheme.colors.codeText,
|
color = HermesTheme.colors.codeText,
|
||||||
style = HermesTheme.typography.code
|
style = HermesTheme.typography.code
|
||||||
)
|
)
|
||||||
|
if (display.truncated) Text("Large code capped for safe display.", color = HermesTheme.colors.textMuted, style = HermesTheme.typography.caption)
|
||||||
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -266,7 +280,9 @@ fun FileRow(name: String, type: String, size: Long, onClick: () -> Unit) {
|
|||||||
FlatCard {
|
FlatCard {
|
||||||
Row(
|
Row(
|
||||||
Modifier
|
Modifier
|
||||||
.clickable(onClick = onClick)
|
.clickable(role = Role.Button, onClick = onClick)
|
||||||
|
.semantics(mergeDescendants = true) { role = Role.Button }
|
||||||
|
.heightIn(min = 48.dp)
|
||||||
.padding(10.dp),
|
.padding(10.dp),
|
||||||
verticalAlignment = Alignment.CenterVertically,
|
verticalAlignment = Alignment.CenterVertically,
|
||||||
horizontalArrangement = Arrangement.spacedBy(10.dp)
|
horizontalArrangement = Arrangement.spacedBy(10.dp)
|
||||||
@@ -343,7 +359,7 @@ fun PrimaryButton(text: String, icon: ImageVector, modifier: Modifier = Modifier
|
|||||||
Button(
|
Button(
|
||||||
onClick = onClick,
|
onClick = onClick,
|
||||||
enabled = enabled,
|
enabled = enabled,
|
||||||
modifier = modifier.height(52.dp),
|
modifier = modifier.heightIn(min = 52.dp),
|
||||||
shape = RoundedCornerShape(HermesTheme.shapes.input),
|
shape = RoundedCornerShape(HermesTheme.shapes.input),
|
||||||
colors = ButtonDefaults.buttonColors(
|
colors = ButtonDefaults.buttonColors(
|
||||||
containerColor = HermesTheme.colors.primary,
|
containerColor = HermesTheme.colors.primary,
|
||||||
@@ -364,7 +380,7 @@ fun SecondaryButton(text: String, icon: ImageVector, modifier: Modifier = Modifi
|
|||||||
Button(
|
Button(
|
||||||
onClick = onClick,
|
onClick = onClick,
|
||||||
enabled = enabled,
|
enabled = enabled,
|
||||||
modifier = modifier.height(52.dp),
|
modifier = modifier.heightIn(min = 52.dp),
|
||||||
shape = RoundedCornerShape(HermesTheme.shapes.input),
|
shape = RoundedCornerShape(HermesTheme.shapes.input),
|
||||||
colors = ButtonDefaults.buttonColors(
|
colors = ButtonDefaults.buttonColors(
|
||||||
containerColor = HermesTheme.colors.surface,
|
containerColor = HermesTheme.colors.surface,
|
||||||
|
|||||||
+62
@@ -0,0 +1,62 @@
|
|||||||
|
package cloud.molberg.hermesmobile.design
|
||||||
|
|
||||||
|
import org.junit.Assert.assertEquals
|
||||||
|
import org.junit.Assert.assertFalse
|
||||||
|
import org.junit.Assert.assertTrue
|
||||||
|
import org.junit.Test
|
||||||
|
|
||||||
|
class AdaptiveQualityTest {
|
||||||
|
@Test
|
||||||
|
fun classifiesSmallNormalLandscapeAndTabletLayouts() {
|
||||||
|
val small = adaptiveLayout(320, 640, 1f)
|
||||||
|
val normal = adaptiveLayout(393, 852, 1f)
|
||||||
|
val landscape = adaptiveLayout(700, 393, 1f)
|
||||||
|
val tablet = adaptiveLayout(840, 900, 1f)
|
||||||
|
|
||||||
|
assertEquals(HermesWindowSize.Small, small.windowSize)
|
||||||
|
assertFalse(small.useSideNavigation)
|
||||||
|
assertEquals(HermesWindowSize.Normal, normal.windowSize)
|
||||||
|
assertTrue(landscape.landscape)
|
||||||
|
assertTrue(landscape.useSideNavigation)
|
||||||
|
assertFalse(landscape.useTwoPaneChat)
|
||||||
|
assertEquals(HermesWindowSize.Tablet, tablet.windowSize)
|
||||||
|
assertTrue(tablet.useTwoPaneChat)
|
||||||
|
assertEquals(312, tablet.sessionPaneWidthDp)
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test
|
||||||
|
fun stacksDenseControlsForSmallWidthOrLargeFonts() {
|
||||||
|
assertTrue(adaptiveLayout(359, 780, 1f).stackDenseControls)
|
||||||
|
assertTrue(adaptiveLayout(500, 780, 1.5f).stackDenseControls)
|
||||||
|
assertFalse(adaptiveLayout(500, 780, 1f).stackDenseControls)
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test
|
||||||
|
fun longTextWindowsAreDeterministicAndBounded() {
|
||||||
|
val message = LongTextPolicy.message("x".repeat(LongTextPolicy.MaxMessageCharacters + 1))
|
||||||
|
val code = LongTextPolicy.code((1..130).joinToString("\n") { "line $it" })
|
||||||
|
|
||||||
|
assertEquals(LongTextPolicy.MaxMessageCharacters, message.text.length)
|
||||||
|
assertTrue(message.truncated)
|
||||||
|
assertEquals(LongTextPolicy.MaxCodeLines, code.text.lineSequence().count())
|
||||||
|
assertTrue(code.truncated)
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test
|
||||||
|
fun textAndStatusPairsMeetNormalTextContrast() {
|
||||||
|
val pairs = listOf(
|
||||||
|
lightHermesColors.text to lightHermesColors.background,
|
||||||
|
lightHermesColors.textMuted to lightHermesColors.background,
|
||||||
|
lightHermesColors.codeText to lightHermesColors.surfaceRaised,
|
||||||
|
darkHermesColors.text to darkHermesColors.background,
|
||||||
|
darkHermesColors.textMuted to darkHermesColors.background,
|
||||||
|
darkHermesColors.codeText to darkHermesColors.surfaceRaised,
|
||||||
|
lightHermesStatusColors.danger to lightHermesStatusColors.dangerPanel,
|
||||||
|
darkHermesStatusColors.danger to darkHermesStatusColors.dangerPanel
|
||||||
|
)
|
||||||
|
|
||||||
|
pairs.forEach { (foreground, background) ->
|
||||||
|
assertTrue("contrast was ${contrastRatio(foreground, background)}", contrastRatio(foreground, background) >= 4.5)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -1,5 +1,11 @@
|
|||||||
# Hermes Mobile Blockers
|
# Hermes Mobile Blockers
|
||||||
|
|
||||||
|
## R1 — Emulator/device accessibility matrix
|
||||||
|
|
||||||
|
R1 cannot be marked complete from the current sandbox. On 2026-07-24, the required recovery preflight passed immediately before this run, but a direct writable-cache Gradle retry was blocked before project execution because the sandbox prohibits Gradle's TCP control socket (`java.net.SocketException: Operation not permitted`). ADB 37.0.0 is installed at `/opt/android-sdk/platform-tools/adb`, but its daemon cannot create the local smart-socket listener or netlink socket (`Operation not permitted`). No emulator executable, AVD, `/dev/kvm`, USB device bus, or running target is available. No successful attached-target query, physical-device walkthrough, emulator walkthrough, or device evidence was possible.
|
||||||
|
|
||||||
|
Required unblock evidence: run the required preflight and focused Android tests in an environment that permits them, then record the small-phone, normal-phone, and tablet checks in `docs/ROADMAP.md` across portrait/landscape, font scaling, TalkBack, light/dark contrast, touch targets, reduced motion, and IME behavior. Source review, `git diff --check`, and direct execution of cached test classes are supporting evidence only and do not complete R1.
|
||||||
|
|
||||||
## Stable gateway identity binding
|
## Stable gateway identity binding
|
||||||
|
|
||||||
The documented direct gateway routes currently available to Hermes Mobile (`GET /health` and authenticated `GET /v1/models`) do not expose a verified stable gateway instance identifier.
|
The documented direct gateway routes currently available to Hermes Mobile (`GET /health` and authenticated `GET /v1/models`) do not expose a verified stable gateway instance identifier.
|
||||||
|
|||||||
@@ -26,6 +26,20 @@ Remaining for beta:
|
|||||||
- Confirm adaptive accessibility: font scaling, TalkBack labels, contrast, keyboard/IME behavior, and reduced-motion tolerance.
|
- Confirm adaptive accessibility: font scaling, TalkBack labels, contrast, keyboard/IME behavior, and reduced-motion tolerance.
|
||||||
- Document beta install/run steps after the native build is verified.
|
- Document beta install/run steps after the native build is verified.
|
||||||
|
|
||||||
|
## R1 Adaptive/Accessibility Matrix
|
||||||
|
|
||||||
|
Source-level safeguards were added and reviewed on 2026-07-24, but this table is **not device evidence**. R1 remains incomplete until each row is exercised and recorded on an emulator or physical device.
|
||||||
|
|
||||||
|
| Configuration | Required checks | Status on 2026-07-24 |
|
||||||
|
| --- | --- | --- |
|
||||||
|
| Small phone, portrait and landscape | Navigation, chat list/detail, composer with IME, 200% font scale | Blocked — ADB daemon sockets are prohibited and no emulator executable is installed |
|
||||||
|
| Normal phone, portrait and landscape | Bottom navigation, rotation continuity, long messages/code, light/dark contrast | Blocked — ADB daemon sockets are prohibited and no emulator executable is installed |
|
||||||
|
| Tablet, portrait and landscape | Navigation rail, two-pane sessions/chat, pane focus order, resizing | Blocked — ADB daemon sockets are prohibited and no emulator executable is installed |
|
||||||
|
| Accessibility services | TalkBack labels/order/announcements, switch and selected-state descriptions, 48dp targets | Blocked — requires emulator/device accessibility service walkthrough |
|
||||||
|
| Motion and keyboard | Animator duration scale off, IME resize/insets, send/cancel visibility | Blocked — requires emulator/device settings and keyboard interaction |
|
||||||
|
|
||||||
|
Source-level evidence without device verification: orientation is no longer manifest-locked; the activity uses `adjustResize`; the required recovery preflight passed immediately before this run; `git diff --check` passed; and direct JUnit execution of the cached, source-current Android unit classes passed all 61 tests, including all 4 `AdaptiveQualityTest` cases covering adaptive thresholds, dense-control stacking, long-text bounds, and selected light/dark contrast pairs. A direct writable-cache Gradle retry was blocked before project execution by `java.net.SocketException: Operation not permitted`. ADB 37.0.0 exists at `/opt/android-sdk/platform-tools/adb`, but its daemon cannot create the sandbox-prohibited smart-socket/netlink sockets. No emulator executable, AVD, `/dev/kvm`, USB device bus, or running target is available.
|
||||||
|
|
||||||
## Beta Release Criteria
|
## Beta Release Criteria
|
||||||
|
|
||||||
- Native Kotlin/Compose Android app is the shipped beta client.
|
- Native Kotlin/Compose Android app is the shipped beta client.
|
||||||
|
|||||||
Reference in New Issue
Block a user