feat(sessions): show project and last activity on history cards (#1133)

This commit is contained in:
Alfredo Ortegón Sepúlveda authored and GitHub committed 2026-09-16 00:14:09 +03:00
1 parent 529b424eb4
commit aed8ba4a6d
25 files changed
+1617 -80

No files matched your search

@@ -0,0 +1,173 @@
package com.m57.hermescontrol.ui.sessions
import androidx.compose.foundation.layout.Box
import androidx.compose.foundation.layout.width
import androidx.compose.runtime.CompositionLocalProvider
import androidx.compose.ui.Modifier
import androidx.compose.ui.graphics.Color
import androidx.compose.ui.platform.LocalDensity
import androidx.compose.ui.semantics.SemanticsProperties
import androidx.compose.ui.test.SemanticsMatcher
import androidx.compose.ui.test.assert
import androidx.compose.ui.test.assertIsDisplayed
import androidx.compose.ui.test.assertTextEquals
import androidx.compose.ui.test.getUnclippedBoundsInRoot
import androidx.compose.ui.test.junit4.createComposeRule
import androidx.compose.ui.test.onNodeWithTag
import androidx.compose.ui.test.onNodeWithText
import androidx.compose.ui.test.performClick
import androidx.compose.ui.unit.Density
import androidx.compose.ui.unit.dp
import androidx.test.ext.junit.runners.AndroidJUnit4
import androidx.test.filters.MediumTest
import com.m57.hermescontrol.data.model.SessionInfo
import com.m57.hermescontrol.data.model.SessionLiveStatus
import com.m57.hermescontrol.theme.HermesControlTheme
import com.m57.hermescontrol.ui.sessions.components.SessionCard
import org.junit.Assert.assertEquals
import org.junit.Assert.assertTrue
import org.junit.Rule
import org.junit.Test
import org.junit.runner.RunWith
/**
* History card content: project, activity age (with a spoken description), preview and footer.
* The card is clickable, so it merges its children; finders read the unmerged tree.
*/
@RunWith(AndroidJUnit4::class)
@MediumTest
class SessionCardTest {
@get:Rule
val composeTestRule = createComposeRule()
private val nowMillis = 1_800_000_000_000L
private var clicks = 0
private fun setCard(
session: SessionInfo,
project: SessionProject?,
liveStatus: SessionLiveStatus? = null,
fontScale: Float = 1f,
) {
composeTestRule.setContent {
val density = LocalDensity.current
CompositionLocalProvider(LocalDensity provides Density(density.density, fontScale)) {
HermesControlTheme {
Box(modifier = Modifier.width(360.dp)) {
SessionCard(
session = session,
displayTitle = session.title ?: "Untitled",
branchStem = null,
query = "",
isSelecting = false,
isSelected = false,
isDeleting = false,
isPinned = false,
liveStatus = liveStatus,
project = project,
nowMillis = nowMillis,
highlightBackground = Color.Unspecified,
highlightForeground = Color.Unspecified,
onCardClick = { clicks++ },
onToggleSelection = {},
onSelect = {},
onRename = {},
onTogglePin = {},
onToggleHide = {},
onDelete = {},
)
}
}
}
}
}
// Fixed 360dp width so the fit doesn't depend on the device screen. The one-line case keeps
// plenty of slack (one badge) because font metrics vary between devices and emulators.
private fun longModelSession(hidden: Boolean) =
SessionInfo(
id = "long",
title = "Draft the release notes",
model = "openrouter/anthropic/a-very-long-model-identifier-name",
message_count = 128,
hidden = hidden,
)
private fun bounds(tag: String) =
composeTestRule.onNodeWithTag(tag, useUnmergedTree = true).getUnclippedBoundsInRoot()
@Test
fun longModel_shrinksInsteadOfWrappingTheBadges() {
setCard(session = longModelSession(hidden = false), project = null, liveStatus = SessionLiveStatus.WORKING)
val count = bounds("session_footer_count_long")
val badge = bounds("session_live_status_long")
val countCenter = (count.top + count.bottom) / 2
val badgeCenter = (badge.top + badge.bottom) / 2
assertEquals("badges share the model's line", countCenter.value, badgeCenter.value, 1f)
}
@Test
fun largeFont_wrapsTheBadgesToASecondLine() {
setCard(
session = longModelSession(hidden = true),
project = null,
liveStatus = SessionLiveStatus.WORKING,
fontScale = 2f,
)
assertTrue(bounds("session_live_status_long").top >= bounds("session_footer_count_long").bottom)
}
@Test
fun rendersProjectAgePreviewAndFooter() {
setCard(
session =
SessionInfo(
id = "s1",
title = "Restyle the palette",
preview = "Can you check\nthe contrast?",
model = "gpt-5.5",
message_count = 24,
last_active = nowMillis / 1000.0 - 2 * 3600,
),
project = SessionProject(label = "Hermes", color = "hsl(120 68% 58%)"),
)
composeTestRule.onNodeWithTag("session_project_s1", useUnmergedTree = true).assertTextEquals("Hermes")
composeTestRule
.onNodeWithTag("session_age_s1", useUnmergedTree = true)
.assertTextEquals("2h")
.assert(SemanticsMatcher.expectValue(SemanticsProperties.ContentDescription, listOf("Active 2 hours ago")))
composeTestRule.onNodeWithText("Restyle the palette", useUnmergedTree = true).assertIsDisplayed()
composeTestRule
.onNodeWithTag(
"session_preview_s1",
useUnmergedTree = true,
).assertTextEquals("Can you check the contrast?")
composeTestRule.onNodeWithText("gpt-5.5", useUnmergedTree = true).assertIsDisplayed()
composeTestRule.onNodeWithText("24 messages", useUnmergedTree = true).assertIsDisplayed()
}
@Test
fun sessionWithoutWorkspace_showsHome_andNoPreviewLine() {
setCard(
session = SessionInfo(id = "s2", title = "Quick question", message_count = 1),
project = null,
)
composeTestRule.onNodeWithTag("session_project_s2", useUnmergedTree = true).assertTextEquals("Home")
composeTestRule.onNodeWithTag("session_preview_s2", useUnmergedTree = true).assertDoesNotExist()
composeTestRule.onNodeWithTag("session_age_s2", useUnmergedTree = true).assertDoesNotExist()
composeTestRule.onNodeWithText("1 message", useUnmergedTree = true).assertIsDisplayed()
}
@Test
fun tappingTheCard_opensIt() {
setCard(session = SessionInfo(id = "s3", title = "Open me"), project = null)
composeTestRule.onNodeWithTag("session_card_s3").performClick()
composeTestRule.runOnIdle { assertEquals(1, clicks) }
}
}
@@ -0,0 +1,39 @@
package com.m57.hermescontrol.data.model
import kotlinx.serialization.Serializable
/** Result of the `projects.list` JSON-RPC: the profile's named workspaces. */
@Serializable
data class ProjectsListResponse(
val projects: List<ProjectInfo> = emptyList(),
val activeId: String? = null,
)
/**
* A user-created project from the backend's per-profile `projects.db`. A
* session belongs to the project whose folder contains its cwd or repo root.
*/
@Serializable
data class ProjectInfo(
val id: String,
val name: String = "",
val slug: String? = null,
// CSS color string chosen in the desktop app, e.g. "hsl(210 68% 58%)".
val color: String? = null,
val icon: String? = null,
val primaryPath: String? = null,
// Older backends store SQLite 0/1 here.
@Serializable(with = LenientNullableBooleanSerializer::class)
val archived: Boolean? = null,
val folders: List<ProjectFolder> = emptyList(),
) {
val isArchived: Boolean get() = archived == true
}
@Serializable
data class ProjectFolder(
val path: String,
val label: String? = null,
@Serializable(with = LenientNullableBooleanSerializer::class)
val isPrimary: Boolean? = null,
)
@@ -79,6 +79,10 @@ data class SessionInfo(
val display_name: String? = null,
val model: String? = null,
val terminal_backend: String? = null,
// Workspace the session ran in. The project label resolves from these
// (explicit project folder, else the repo root, else the cwd leaf).
val cwd: String? = null,
val git_repo_root: String? = null,
// Epoch seconds of the latest message (backend-computed, present on all
// list rows). Recency source of truth; started_at is the fallback.
val last_active: Double? = null,
@@ -0,0 +1,39 @@
package com.m57.hermescontrol.data.ws
import com.m57.hermescontrol.data.model.ProjectInfo
import com.m57.hermescontrol.data.model.ProjectsListResponse
import com.m57.hermescontrol.data.remote.OkHttpProvider
import kotlinx.coroutines.CancellationException
import kotlinx.serialization.json.JsonObject
/** Supplies the profile's named projects so history rows can show which one a chat belongs to. */
interface ProjectsSource {
/** The project list, or null when the backend can't answer (offline, older server). */
suspend fun fetchProjects(): List<ProjectInfo>?
}
/** [ProjectsSource] backed by the gateway's `projects.list` JSON-RPC. */
class HermesProjectsSource(
// Only ask a live socket: an offline request would be queued and force a reconnect on
// every refresh. The view model refetches when the connection comes back.
private val isConnected: () -> Boolean = { HermesWsClient.connectionStatus.value == ConnectionStatus.CONNECTED },
private val rpcRequest: suspend (method: String, params: Map<String, Any>) -> Any? = { method, params ->
HermesWsClient.request(method, params).await()
},
) : ProjectsSource {
override suspend fun fetchProjects(): List<ProjectInfo>? {
if (!isConnected()) return null
return try {
val element = SessionLiveStatusDecoder.anyToJsonElement(rpcRequest(WsMethods.PROJECTS_LIST, emptyMap()))
if (element is JsonObject && element.containsKey("projects")) {
OkHttpProvider.json.decodeFromJsonElement(ProjectsListResponse.serializer(), element).projects
} else {
null
}
} catch (e: CancellationException) {
throw e
} catch (_: Exception) {
null
}
}
}
@@ -29,6 +29,11 @@ object WsMethods {
const val PROFILES_CONFIGURE = "profiles.configure"
const val PROFILES_LIST = "profiles.list"
// ── Projects ──────────────────────────────────────────────────────────
/** Named workspaces (id, name, color, folders) used to label history rows. */
const val PROJECTS_LIST = "projects.list"
// ── Interaction ───────────────────────────────────────────────────────
const val PROMPT_SUBMIT = "prompt.submit"
const val PROMPT_BTW = "prompt.btw"
@@ -159,6 +164,7 @@ object WsMethods {
// Binds HERMES_HOME so config/skills/pets resolve to the
// focused profile.
"verification.status", // line 282
PROJECTS_LIST, // methods_projects.py: @_registry.profile_scoped
"pet.info", // line 1253
"pet.info.meta", // line 1279
"pet.cells", // line 1302
@@ -71,3 +71,23 @@ fun parseHexColor(
fallback
}
}
private val HSL_COLOR =
Regex("""^hsla?\(\s*([\d.]+)(?:deg)?[\s,]+([\d.]+)%[\s,]+([\d.]+)%\s*(?:[,/]\s*[\d.]+%?\s*)?\)$""")
/**
* Parse a project color chosen in the desktop app: CSS `hsl(210 68% 58%)` (space or comma
* separated) or hex. Returns null for anything else so callers can simply omit the accent.
*/
fun parseProjectColor(raw: String?): Color? {
val value = raw?.trim().orEmpty()
if (value.isEmpty()) return null
if (value.startsWith("#")) return parseHexColor(value, Color.Unspecified).takeIf { it != Color.Unspecified }
val match = HSL_COLOR.matchEntire(value.lowercase()) ?: return null
val (hue, saturation, lightness) = match.destructured
val h = hue.toFloatOrNull() ?: return null
val s = saturation.toFloatOrNull()?.div(100f) ?: return null
val l = lightness.toFloatOrNull()?.div(100f) ?: return null
if (h !in 0f..360f || s !in 0f..1f || l !in 0f..1f) return null
return Color.hsl(h % 360f, s, l)
}
@@ -0,0 +1,71 @@
package com.m57.hermescontrol.ui.sessions
import com.m57.hermescontrol.data.model.SessionInfo
import java.time.Instant
import java.time.LocalDate
import java.time.ZoneId
/**
* How long ago a session was last active, bucketed the way chat lists show it:
* "now", minutes, hours, days for the last week, then the calendar date.
*/
sealed interface SessionAge {
data object Now : SessionAge
data class Minutes(
val value: Long,
) : SessionAge
data class Hours(
val value: Long,
) : SessionAge
data class Days(
val value: Long,
) : SessionAge
data class Date(
val date: LocalDate,
val sameYear: Boolean,
) : SessionAge
}
private const val MINUTE_SECONDS = 60L
private const val HOUR_SECONDS = 60 * MINUTE_SECONDS
private const val DAY_SECONDS = 24 * HOUR_SECONDS
private const val WEEK_SECONDS = 7 * DAY_SECONDS
/** Latest activity in epoch seconds: the backend's `last_active`, else `started_at`. */
fun SessionInfo.activityEpochSeconds(): Double? = last_active?.takeIf { it > 0.0 } ?: started_at?.takeIf { it > 0.0 }
fun sessionAge(
epochSeconds: Double?,
now: Instant,
zone: ZoneId,
): SessionAge? {
if (epochSeconds == null || epochSeconds <= 0.0) return null
val then = Instant.ofEpochSecond(epochSeconds.toLong())
val elapsed = (now.epochSecond - then.epochSecond).coerceAtLeast(0)
return when {
elapsed < MINUTE_SECONDS -> {
SessionAge.Now
}
elapsed < HOUR_SECONDS -> {
SessionAge.Minutes(elapsed / MINUTE_SECONDS)
}
elapsed < DAY_SECONDS -> {
SessionAge.Hours(elapsed / HOUR_SECONDS)
}
elapsed < WEEK_SECONDS -> {
SessionAge.Days(elapsed / DAY_SECONDS)
}
else -> {
val date = then.atZone(zone).toLocalDate()
SessionAge.Date(date, sameYear = date.year == now.atZone(zone).year)
}
}
}
@@ -0,0 +1,85 @@
package com.m57.hermescontrol.ui.sessions
import com.m57.hermescontrol.data.model.ProjectInfo
import com.m57.hermescontrol.data.model.SessionInfo
/** The workspace a history row belongs to: a display label plus the project's own color, if any. */
data class SessionProject(
val label: String,
val color: String? = null,
)
/**
* Names the project a session belongs to, mirroring the desktop sidebar card
* (`liveSessionProjectId` + `sessionProjectLabel`):
*
* 1. the non-archived project whose folder contains the cwd or the repo root
* (deepest folder wins, so nested projects resolve to the innermost one);
* 2. the repo root itself when the cwd sits under it (an auto project);
* 3. the recorded repo root's leaf (a worktree outside its repo still names it);
* 4. the cwd's leaf.
*
* Returns null when the session recorded no workspace; the card shows "Home".
*/
fun resolveSessionProject(
session: SessionInfo,
projects: List<ProjectInfo>,
): SessionProject? {
val cwd = session.cwd?.trim().orEmpty()
val recordedRoot = session.git_repo_root?.trim().orEmpty()
val repoRoot = recordedRoot.ifEmpty { cwd }
if (cwd.isEmpty() && repoRoot.isEmpty()) return null
val owner =
projects
.filterNot { it.isArchived }
.flatMap { project -> project.folders.map { project to it.path } }
.filter { (_, folder) -> isPathUnder(folder, cwd) || isPathUnder(folder, repoRoot) }
.maxByOrNull { (_, folder) -> pathSegments(folder).size }
if (owner != null) {
val (project, folder) = owner
val label = project.name.trim().ifEmpty { pathLeaf(folder) }
return SessionProject(label = label, color = project.color)
}
val autoRoot = repoRoot.takeIf { cwd.isEmpty() || isPathUnder(it, cwd) }
val label =
listOf(autoRoot, recordedRoot, cwd)
.firstNotNullOfOrNull { path -> path?.let(::pathLeaf)?.takeIf(String::isNotEmpty) }
?: return null
return SessionProject(label = label)
}
/** Path segments, ignoring mixed separators, repeated separators and trailing slashes. */
private fun pathSegments(path: String): List<String> = path.trim().split('/', '\\').filter(String::isNotEmpty)
private val WindowsDrive = Regex("^[A-Za-z]:[/\\\\]")
/** Drive-letter (`C:\…`), UNC (`\\srv`, `//srv`) or backslash-rooted paths, as the backend classifies them. */
private fun isWindowsPath(path: String): Boolean {
val trimmed = path.trim()
return WindowsDrive.containsMatchIn(trimmed) || trimmed.startsWith("\\") || trimmed.startsWith("//")
}
/**
* Segments for identity comparison. Windows paths fold case so `C:\Work` and `c:/work` are one
* folder; POSIX stays case-sensitive. Labels keep the recorded spelling.
*/
private fun comparisonSegments(path: String): List<String> {
val segments = pathSegments(path)
return if (isWindowsPath(path)) segments.map { it.lowercase() } else segments
}
private fun isPathUnder(
folder: String,
target: String,
): Boolean {
val folderSegments = comparisonSegments(folder)
val targetSegments = comparisonSegments(target)
if (folderSegments.isEmpty() || folderSegments.size > targetSegments.size) return false
return folderSegments.indices.all { folderSegments[it] == targetSegments[it] }
}
/** Last segment in its recorded spelling; the filesystem root stays as written ("/"). */
private fun pathLeaf(path: String): String = pathSegments(path).lastOrNull() ?: path.trim()
@@ -78,6 +78,7 @@ import androidx.compose.runtime.LaunchedEffect
import androidx.compose.runtime.derivedStateOf
import androidx.compose.runtime.getValue
import androidx.compose.runtime.mutableStateOf
import androidx.compose.runtime.produceState
import androidx.compose.runtime.remember
import androidx.compose.runtime.setValue
import androidx.compose.ui.Alignment
@@ -125,12 +126,14 @@ import com.m57.hermescontrol.ui.sessions.components.SessionsBulkActionBar
import com.m57.hermescontrol.ui.sessions.components.SessionsDialogs
import com.m57.hermescontrol.ui.sessions.components.SessionsStatsRow
import com.m57.hermescontrol.ui.sessions.components.automationGroups
import kotlinx.coroutines.delay
/**
* Auto-load the next history page when the user scrolls to within this many
* items of the end — a pre-load buffer so paging feels continuous.
*/
private const val AUTO_LOAD_THRESHOLD = 6
private const val AGE_TICK_MS = 60_000L
/**
* Maps a session source string to a Material icon for visual identification.
@@ -764,8 +767,19 @@ fun SessionsScreen(
viewModel.loadMore()
}
}
// One clock for every row's "15m" age, re-read on each minute boundary.
val nowMillis by produceState(System.currentTimeMillis()) {
while (true) {
delay(AGE_TICK_MS - value % AGE_TICK_MS)
value = System.currentTimeMillis()
}
}
val sessionCard: @Composable (SessionTreeItem) -> Unit = { item ->
val session = item.session
val project =
remember(session.cwd, session.git_repo_root, state.projects) {
resolveSessionProject(session, state.projects)
}
BranchRow(item = item) {
SessionCard(
session = session,
@@ -780,6 +794,8 @@ fun SessionsScreen(
isPinned = session.pinned == true,
isHidden = session.hidden == true,
liveStatus = state.liveStatuses[session.id],
project = project,
nowMillis = nowMillis,
highlightBackground = primaryContainer,
highlightForeground = onPrimaryContainer,
onCardClick = {
@@ -4,6 +4,7 @@ import androidx.lifecycle.ViewModel
import androidx.lifecycle.viewModelScope
import com.m57.hermescontrol.data.local.AuthManager
import com.m57.hermescontrol.data.model.BulkDeleteRequest
import com.m57.hermescontrol.data.model.ProjectInfo
import com.m57.hermescontrol.data.model.PruneRequest
import com.m57.hermescontrol.data.model.SessionInfo
import com.m57.hermescontrol.data.model.SessionLiveStatus
@@ -15,7 +16,9 @@ import com.m57.hermescontrol.data.remote.safeApiCall
import com.m57.hermescontrol.data.ws.ChangeEventHub
import com.m57.hermescontrol.data.ws.ChangeEvents
import com.m57.hermescontrol.data.ws.ConnectionStatus
import com.m57.hermescontrol.data.ws.HermesProjectsSource
import com.m57.hermescontrol.data.ws.HermesSessionLiveStatusSource
import com.m57.hermescontrol.data.ws.ProjectsSource
import com.m57.hermescontrol.data.ws.SessionLiveStatusSource
import com.m57.hermescontrol.data.ws.WsEvent
import com.m57.hermescontrol.ui.common.ToastHost
@@ -99,6 +102,8 @@ data class SessionsUiState(
val showHidden: Boolean = false,
val pinnedExpanded: Boolean = true,
val liveStatuses: Map<String, SessionLiveStatus> = emptyMap(),
// Named projects used to label each row with the workspace it belongs to.
val projects: List<ProjectInfo> = emptyList(),
) {
val isSearchMode: Boolean get() = searchQuery.isNotBlank()
@@ -114,6 +119,7 @@ data class SessionsUiState(
class SessionsViewModel(
private val liveStatusSource: SessionLiveStatusSource = HermesSessionLiveStatusSource(),
private val projectsSource: ProjectsSource = HermesProjectsSource(),
) : ViewModel(),
ToastHost {
private val _uiState = MutableStateFlow(SessionsUiState())
@@ -123,6 +129,7 @@ class SessionsViewModel(
private var pageJob: Job? = null
private var statsJob: Job? = null
private var trackingJob: Job? = null
private var projectsJob: Job? = null
private var trackingGeneration: Long = 0
private var liveTrackingState = SessionLiveTrackingState()
private var liveStatusRefreshInFlight = false
@@ -277,6 +284,7 @@ class SessionsViewModel(
loadEmptyCount()
if (trackingJob?.isActive == true) {
refreshLiveStatuses()
loadProjects()
}
loadJob =
safeLaunchLoad(
@@ -329,6 +337,19 @@ class SessionsViewModel(
)
}
/**
* Refresh the project list; a failed fetch keeps the last good one so labels don't flicker.
* Like live statuses, it only runs while the screen tracks the gateway (visible + connected).
*/
private fun loadProjects() {
projectsJob?.cancel()
projectsJob =
viewModelScope.launch {
val projects = projectsSource.fetchProjects() ?: return@launch
_uiState.update { it.copy(projects = projects) }
}
}
/** Load the next page and append to the existing session list. */
fun loadMore() {
val state = _uiState.value
@@ -905,6 +926,7 @@ class SessionsViewModel(
_uiState.update { it.copy(liveStatuses = emptyMap()) }
} else {
requestLiveStatusSnapshot(++trackingGeneration)
loadProjects()
}
}
}
@@ -940,6 +962,8 @@ class SessionsViewModel(
trackingGeneration++
trackingJob?.cancel()
trackingJob = null
projectsJob?.cancel()
projectsJob = null
liveStatusRefreshInFlight = false
liveStatusRefreshPending = false
liveTrackingState = SessionLiveStatusReducer.clear()
@@ -0,0 +1,70 @@
package com.m57.hermescontrol.ui.sessions.components
import android.text.format.DateFormat
import androidx.compose.runtime.Composable
import androidx.compose.ui.platform.LocalConfiguration
import androidx.compose.ui.res.pluralStringResource
import androidx.compose.ui.res.stringResource
import com.m57.hermescontrol.R
import com.m57.hermescontrol.ui.sessions.SessionAge
import java.time.format.DateTimeFormatter
import java.time.format.FormatStyle
/** Compact age shown on the card: "now", "15m", "3h", "2d", or a short date ("Sep 3"). */
@Composable
fun sessionAgeLabel(age: SessionAge): String =
when (age) {
SessionAge.Now -> {
stringResource(R.string.session_age_now)
}
is SessionAge.Minutes -> {
stringResource(R.string.session_age_minutes, age.value.toInt())
}
is SessionAge.Hours -> {
stringResource(R.string.session_age_hours, age.value.toInt())
}
is SessionAge.Days -> {
stringResource(R.string.session_age_days, age.value.toInt())
}
is SessionAge.Date -> {
val locale = LocalConfiguration.current.locales[0]
val skeleton = if (age.sameYear) "MMMd" else "yMMMd"
DateTimeFormatter
.ofPattern(DateFormat.getBestDateTimePattern(locale, skeleton), locale)
.format(age.date)
}
}
/**
* Spoken form of [sessionAgeLabel]. Screen readers misread micro units ("15m" as
* fifteen meters), so TalkBack gets the full phrase instead.
*/
@Composable
fun sessionAgeDescription(age: SessionAge): String =
when (age) {
SessionAge.Now -> {
stringResource(R.string.session_age_now_desc)
}
is SessionAge.Minutes -> {
pluralStringResource(R.plurals.session_age_minutes_desc, age.value.toInt(), age.value.toInt())
}
is SessionAge.Hours -> {
pluralStringResource(R.plurals.session_age_hours_desc, age.value.toInt(), age.value.toInt())
}
is SessionAge.Days -> {
pluralStringResource(R.plurals.session_age_days_desc, age.value.toInt(), age.value.toInt())
}
is SessionAge.Date -> {
val locale = LocalConfiguration.current.locales[0]
val date = DateTimeFormatter.ofLocalizedDate(FormatStyle.LONG).withLocale(locale).format(age.date)
stringResource(R.string.session_age_date_desc, date)
}
}
@@ -2,7 +2,9 @@ package com.m57.hermescontrol.ui.sessions.components
import androidx.compose.foundation.BorderStroke
import androidx.compose.foundation.ExperimentalFoundationApi
import androidx.compose.foundation.background
import androidx.compose.foundation.combinedClickable
import androidx.compose.foundation.layout.Arrangement
import androidx.compose.foundation.layout.Box
import androidx.compose.foundation.layout.Column
import androidx.compose.foundation.layout.Row
@@ -12,6 +14,7 @@ import androidx.compose.foundation.layout.height
import androidx.compose.foundation.layout.padding
import androidx.compose.foundation.layout.size
import androidx.compose.foundation.layout.width
import androidx.compose.foundation.shape.CircleShape
import androidx.compose.material.icons.Icons
import androidx.compose.material.icons.automirrored.filled.CallSplit
import androidx.compose.material.icons.automirrored.filled.Send
@@ -32,17 +35,24 @@ import androidx.compose.runtime.remember
import androidx.compose.runtime.setValue
import androidx.compose.ui.Alignment
import androidx.compose.ui.Modifier
import androidx.compose.ui.draw.clip
import androidx.compose.ui.graphics.Color
import androidx.compose.ui.graphics.vector.ImageVector
import androidx.compose.ui.layout.Layout
import androidx.compose.ui.platform.testTag
import androidx.compose.ui.res.pluralStringResource
import androidx.compose.ui.res.stringResource
import androidx.compose.ui.semantics.contentDescription
import androidx.compose.ui.semantics.semantics
import androidx.compose.ui.text.AnnotatedString
import androidx.compose.ui.text.SpanStyle
import androidx.compose.ui.text.buildAnnotatedString
import androidx.compose.ui.text.font.FontFamily
import androidx.compose.ui.text.font.FontWeight
import androidx.compose.ui.text.style.TextDirection
import androidx.compose.ui.text.style.TextOverflow
import androidx.compose.ui.text.withStyle
import androidx.compose.ui.unit.Constraints
import androidx.compose.ui.unit.dp
import com.m57.hermescontrol.R
import com.m57.hermescontrol.data.model.SessionInfo
@@ -50,8 +60,14 @@ import com.m57.hermescontrol.data.model.SessionLiveStatus
import com.m57.hermescontrol.data.model.SessionTreeItem
import com.m57.hermescontrol.theme.LocalHermesStatusColors
import com.m57.hermescontrol.theme.LocalSpacing
import com.m57.hermescontrol.theme.parseProjectColor
import com.m57.hermescontrol.ui.common.StatusBadge
import com.m57.hermescontrol.ui.common.StatusBadgeType
import com.m57.hermescontrol.ui.sessions.SessionProject
import com.m57.hermescontrol.ui.sessions.activityEpochSeconds
import com.m57.hermescontrol.ui.sessions.sessionAge
import java.time.Instant
import java.time.ZoneId
@Composable
fun BranchRow(
@@ -133,6 +149,13 @@ fun highlightText(
}
}
private val ProjectDotSize = 8.dp
private val MetaIconSize = 14.dp
// The model gives way to the badges down to this width before they wrap to a second line.
private val FooterLeadMinWidth = 72.dp
private val WhitespaceRun = Regex("\\s+")
@OptIn(ExperimentalFoundationApi::class)
@Composable
fun SessionCard(
@@ -148,6 +171,8 @@ fun SessionCard(
isPinned: Boolean,
isHidden: Boolean = false,
liveStatus: SessionLiveStatus? = null,
project: SessionProject? = null,
nowMillis: Long = System.currentTimeMillis(),
highlightBackground: Color,
highlightForeground: Color,
onCardClick: () -> Unit,
@@ -160,7 +185,7 @@ fun SessionCard(
) {
val spacing = LocalSpacing.current
val statusColors = LocalHermesStatusColors.current
val srcIcon = sourceIcon(session.source)
val palette = sessionCardPalette()
var menuExpanded by remember { mutableStateOf(false) }
Card(
@@ -172,10 +197,7 @@ fun SessionCard(
onClick = onCardClick,
onLongClick = { if (!isSelecting) menuExpanded = true },
),
colors =
CardDefaults.cardColors(
containerColor = MaterialTheme.colorScheme.surfaceContainer,
),
colors = CardDefaults.cardColors(containerColor = palette.card),
border =
if (isSelected) {
BorderStroke(2.dp, MaterialTheme.colorScheme.primary)
@@ -201,86 +223,57 @@ fun SessionCard(
Spacer(modifier = Modifier.width(spacing.sm))
}
if (isFork && !isSelecting) {
Icon(
imageVector = Icons.AutoMirrored.Filled.CallSplit,
contentDescription = stringResource(R.string.sessions_fork_indicator),
tint = MaterialTheme.colorScheme.primary,
modifier = Modifier.size(16.dp),
)
if (forkDepth > 0) {
Spacer(modifier = Modifier.width(spacing.xs))
Text(
text = forkDepth.toString(),
style = MaterialTheme.typography.labelLarge,
fontWeight = FontWeight.Bold,
color = MaterialTheme.colorScheme.primary,
)
}
Spacer(modifier = Modifier.width(spacing.sm))
}
if (srcIcon != null && !isSelecting) {
Icon(
imageVector = srcIcon,
contentDescription = sourceLabel(session.source),
tint = MaterialTheme.colorScheme.onSurfaceVariant,
modifier = Modifier.size(18.dp),
)
Spacer(modifier = Modifier.width(spacing.sm))
}
Column(modifier = Modifier.weight(1f)) {
Row(verticalAlignment = Alignment.CenterVertically) {
Text(
text =
if (query.isNotBlank()) {
highlightText(displayTitle, query, highlightBackground, highlightForeground)
} else {
AnnotatedString(displayTitle)
},
style = MaterialTheme.typography.titleMedium,
fontWeight = FontWeight.Bold,
maxLines = 1,
overflow = TextOverflow.Ellipsis,
modifier = Modifier.weight(1f),
)
if (isPinned) {
Spacer(modifier = Modifier.width(spacing.xs))
Icon(
imageVector = Icons.Filled.PushPin,
contentDescription = stringResource(R.string.sessions_pinned_indicator),
tint = MaterialTheme.colorScheme.primary,
modifier = Modifier.size(14.dp),
)
}
}
SessionCardHeader(
session = session,
project = project,
isFork = isFork && !isSelecting,
forkDepth = forkDepth,
isPinned = isPinned,
nowMillis = nowMillis,
palette = palette,
)
Spacer(modifier = Modifier.height(spacing.xs))
Row(
verticalAlignment = Alignment.CenterVertically,
) {
Text(
text =
if (query.isNotBlank()) {
highlightText(displayTitle, query, highlightBackground, highlightForeground)
} else {
AnnotatedString(displayTitle)
},
style = MaterialTheme.typography.titleMedium.copy(textDirection = TextDirection.Content),
fontWeight = FontWeight.SemiBold,
color = palette.title,
maxLines = 1,
overflow = TextOverflow.Ellipsis,
)
val preview =
session.preview
?.replace(WhitespaceRun, " ")
?.trim()
.orEmpty()
if (preview.isNotEmpty() && preview != displayTitle) {
Text(
text = stringResource(R.string.history_message_count, session.message_count ?: 0),
style = MaterialTheme.typography.bodySmall,
color = MaterialTheme.colorScheme.onSurfaceVariant,
text = preview,
style = MaterialTheme.typography.bodyMedium.copy(textDirection = TextDirection.Content),
color = palette.secondary,
maxLines = 1,
overflow = TextOverflow.Ellipsis,
modifier = Modifier.testTag("session_preview_${session.id}"),
)
if (session.hidden == true) {
Spacer(modifier = Modifier.width(spacing.sm))
StatusBadge(
text = stringResource(R.string.sessions_hidden_badge),
status = StatusBadgeType.NEUTRAL,
)
}
if (liveStatus != null) {
Spacer(modifier = Modifier.width(spacing.sm))
SessionLiveStatusIndicator(
liveStatus = liveStatus,
sessionId = session.id,
)
}
}
Spacer(modifier = Modifier.height(spacing.sm))
SessionCardFooter(
session = session,
showSource = !isSelecting,
liveStatus = liveStatus,
palette = palette,
)
}
}
@@ -314,3 +307,223 @@ fun SessionCard(
}
}
}
/** Project (with its color dot), fork and pin markers, and the activity age on the right. */
@Composable
private fun SessionCardHeader(
session: SessionInfo,
project: SessionProject?,
isFork: Boolean,
forkDepth: Int,
isPinned: Boolean,
nowMillis: Long,
palette: SessionCardPalette,
) {
val spacing = LocalSpacing.current
Row(verticalAlignment = Alignment.CenterVertically) {
Row(modifier = Modifier.weight(1f), verticalAlignment = Alignment.CenterVertically) {
val dotColor = remember(project?.color) { parseProjectColor(project?.color) }
if (dotColor != null) {
Box(
modifier =
Modifier
.size(ProjectDotSize)
.clip(CircleShape)
.background(dotColor),
)
Spacer(modifier = Modifier.width(spacing.sm))
}
Text(
text = project?.label ?: stringResource(R.string.sessions_project_home),
style = MaterialTheme.typography.labelMedium.copy(textDirection = TextDirection.Content),
color = palette.secondary,
maxLines = 1,
overflow = TextOverflow.Ellipsis,
modifier = Modifier.weight(1f, fill = false).testTag("session_project_${session.id}"),
)
if (isFork) {
Spacer(modifier = Modifier.width(spacing.sm))
Icon(
imageVector = Icons.AutoMirrored.Filled.CallSplit,
contentDescription = stringResource(R.string.sessions_fork_indicator),
tint = MaterialTheme.colorScheme.primary,
modifier = Modifier.size(MetaIconSize),
)
if (forkDepth > 0) {
Spacer(modifier = Modifier.width(spacing.xs))
Text(
text = forkDepth.toString(),
style = MaterialTheme.typography.labelMedium,
fontWeight = FontWeight.Bold,
color = MaterialTheme.colorScheme.primary,
)
}
}
if (isPinned) {
Spacer(modifier = Modifier.width(spacing.sm))
Icon(
imageVector = Icons.Filled.PushPin,
contentDescription = stringResource(R.string.sessions_pinned_indicator),
tint = MaterialTheme.colorScheme.primary,
modifier = Modifier.size(MetaIconSize),
)
}
}
Spacer(modifier = Modifier.width(spacing.md))
val age = sessionAge(session.activityEpochSeconds(), Instant.ofEpochMilli(nowMillis), ZoneId.systemDefault())
if (age != null) {
val description = sessionAgeDescription(age)
Text(
text = sessionAgeLabel(age),
style = MaterialTheme.typography.labelMedium,
color = palette.secondary,
maxLines = 1,
modifier =
Modifier
.testTag("session_age_${session.id}")
.semantics { contentDescription = description },
)
}
}
}
/**
* Whether the footer's badges share the first line. The model (the lead) is the part that gives
* way: it middle-ellipsizes down to [leadMinWidth] (or its own width when shorter) before the
* badges move to a second line. [badgesWidth] includes the gap before the badges; 0 means none.
*/
internal fun footerFitsOneLine(
maxWidth: Int,
leadWidth: Int,
countWidth: Int,
badgesWidth: Int,
leadMinWidth: Int,
): Boolean {
if (badgesWidth <= 0) return true
return maxWidth - countWidth - badgesWidth >= minOf(leadWidth, leadMinWidth)
}
/** Origin, model and message count, then the hidden badge and live status. */
@Composable
private fun SessionCardFooter(
session: SessionInfo,
showSource: Boolean,
liveStatus: SessionLiveStatus?,
palette: SessionCardPalette,
) {
val spacing = LocalSpacing.current
val messageCount = session.message_count ?: 0
val model = session.model?.trim().orEmpty()
val srcIcon = sourceIcon(session.source)
val hasBadges = session.hidden == true || liveStatus != null
Layout(
contents =
listOf(
{
Row(verticalAlignment = Alignment.CenterVertically) {
if (srcIcon != null && showSource) {
Icon(
imageVector = srcIcon,
contentDescription = sourceLabel(session.source),
tint = palette.secondary,
modifier = Modifier.size(MetaIconSize),
)
Spacer(modifier = Modifier.width(spacing.sm))
}
if (model.isNotEmpty()) {
Text(
text = model,
style =
MaterialTheme.typography.labelMedium.copy(
textDirection = TextDirection.Content,
),
color = palette.secondary,
maxLines = 1,
overflow = TextOverflow.MiddleEllipsis,
modifier = Modifier.weight(1f, fill = false),
)
Text(
text = " · ",
style = MaterialTheme.typography.labelMedium,
color = palette.secondary,
)
}
}
},
{
Text(
text = pluralStringResource(R.plurals.sessions_card_message_count, messageCount, messageCount),
style = MaterialTheme.typography.labelMedium,
color = palette.secondary,
maxLines = 1,
modifier = Modifier.testTag("session_footer_count_${session.id}"),
)
},
{
if (hasBadges) {
Row(
horizontalArrangement = Arrangement.spacedBy(spacing.sm),
verticalAlignment = Alignment.CenterVertically,
) {
if (session.hidden == true) {
StatusBadge(
text = stringResource(R.string.sessions_hidden_badge),
status = StatusBadgeType.NEUTRAL,
)
}
if (liveStatus != null) {
SessionLiveStatusIndicator(
liveStatus = liveStatus,
sessionId = session.id,
)
}
}
}
},
),
) { (leadMeasurables, countMeasurables, badgeMeasurables), constraints ->
val maxWidth = constraints.maxWidth
val loose = Constraints(maxWidth = maxWidth)
val gap = spacing.sm.roundToPx()
val lineGap = spacing.xs.roundToPx()
val count = countMeasurables.first().measure(loose)
val badges = badgeMeasurables.firstOrNull()?.measure(loose)
val lead = leadMeasurables.firstOrNull()
val leadNatural = lead?.maxIntrinsicWidth(Constraints.Infinity) ?: 0
val badgesSpace = badges?.let { it.width + gap } ?: 0
val oneLine =
footerFitsOneLine(
maxWidth = maxWidth,
leadWidth = leadNatural,
countWidth = count.width,
badgesWidth = badgesSpace,
leadMinWidth = FooterLeadMinWidth.roundToPx(),
)
val leadRoom = (maxWidth - count.width - if (oneLine) badgesSpace else 0).coerceAtLeast(0)
val leadPlaceable = lead?.measure(Constraints(maxWidth = minOf(leadNatural, leadRoom)))
val firstLineHeight =
maxOf(leadPlaceable?.height ?: 0, count.height, if (oneLine) badges?.height ?: 0 else 0)
val height = firstLineHeight + if (!oneLine && badges != null) lineGap + badges.height else 0
val width = if (constraints.hasBoundedWidth) maxWidth else constraints.minWidth
layout(width, height) {
var x = 0
leadPlaceable?.let {
it.placeRelative(x, (firstLineHeight - it.height) / 2)
x += it.width
}
count.placeRelative(x, (firstLineHeight - count.height) / 2)
x += count.width
badges?.let {
if (oneLine) {
it.placeRelative(x + gap, (firstLineHeight - it.height) / 2)
} else {
it.placeRelative(0, firstLineHeight + lineGap)
}
}
}
}
}
@@ -0,0 +1,49 @@
package com.m57.hermescontrol.ui.sessions.components
import androidx.compose.material3.ColorScheme
import androidx.compose.material3.MaterialTheme
import androidx.compose.runtime.Composable
import androidx.compose.runtime.ReadOnlyComposable
import androidx.compose.ui.graphics.Color
import androidx.compose.ui.graphics.lerp
import androidx.compose.ui.graphics.luminance
/**
* Colors for the history card. Every secondary line (project, age, preview, footer) is
* body-sized or smaller, so it needs 4.5:1 against the card. Some presets' onSurfaceVariant
* falls short on surfaceContainer (Gruvbox, Catppuccin light); there the secondary color is
* pulled toward onSurface just far enough to pass, keeping the hierarchy everywhere else.
* Gated in SessionCardContrastTest.
*/
internal data class SessionCardPalette(
val card: Color,
val title: Color,
val secondary: Color,
)
internal const val SESSION_CARD_MIN_TEXT_CONTRAST = 4.5f
private const val BLEND_STEPS = 20
internal fun sessionCardPalette(scheme: ColorScheme): SessionCardPalette {
val card = scheme.surfaceContainer
val secondary =
(0..BLEND_STEPS)
.asSequence()
.map { step -> lerp(scheme.onSurfaceVariant, scheme.onSurface, step / BLEND_STEPS.toFloat()) }
.firstOrNull { contrastRatio(it, card) >= SESSION_CARD_MIN_TEXT_CONTRAST }
?: scheme.onSurface
return SessionCardPalette(card = card, title = scheme.onSurface, secondary = secondary)
}
@Composable
@ReadOnlyComposable
internal fun sessionCardPalette(): SessionCardPalette = sessionCardPalette(MaterialTheme.colorScheme)
internal fun contrastRatio(
a: Color,
b: Color,
): Float {
val l1 = a.luminance()
val l2 = b.luminance()
return (maxOf(l1, l2) + 0.05f) / (minOf(l1, l2) + 0.05f)
}
+39
View File
@@ -545,6 +545,45 @@
<string name="history_empty_desc">ستظهر جلسات المحادثة السابقة من Hermes هنا.</string>
<string name="history_untitled">بلا عنوان</string>
<string name="history_message_count">%1$d رسالة</string>
<plurals name="sessions_card_message_count">
<item quantity="zero">%1$d رسالة</item>
<item quantity="one">%1$d رسالة</item>
<item quantity="two">%1$d رسالتان</item>
<item quantity="few">%1$d رسائل</item>
<item quantity="many">%1$d رسالة</item>
<item quantity="other">%1$d رسالة</item>
</plurals>
<string name="sessions_project_home">الرئيسية</string>
<string name="session_age_now">الآن</string>
<string name="session_age_minutes">%1$d د</string>
<string name="session_age_hours">%1$d س</string>
<string name="session_age_days">%1$d ي</string>
<string name="session_age_now_desc">نشط الآن</string>
<plurals name="session_age_minutes_desc">
<item quantity="zero">نشط منذ %1$d دقيقة</item>
<item quantity="one">نشط منذ %1$d دقيقة</item>
<item quantity="two">نشط منذ %1$d دقيقتين</item>
<item quantity="few">نشط منذ %1$d دقائق</item>
<item quantity="many">نشط منذ %1$d دقيقة</item>
<item quantity="other">نشط منذ %1$d دقيقة</item>
</plurals>
<plurals name="session_age_hours_desc">
<item quantity="zero">نشط منذ %1$d ساعة</item>
<item quantity="one">نشط منذ %1$d ساعة</item>
<item quantity="two">نشط منذ %1$d ساعتين</item>
<item quantity="few">نشط منذ %1$d ساعات</item>
<item quantity="many">نشط منذ %1$d ساعة</item>
<item quantity="other">نشط منذ %1$d ساعة</item>
</plurals>
<plurals name="session_age_days_desc">
<item quantity="zero">نشط منذ %1$d يوم</item>
<item quantity="one">نشط منذ %1$d يوم</item>
<item quantity="two">نشط منذ %1$d يومين</item>
<item quantity="few">نشط منذ %1$d أيام</item>
<item quantity="many">نشط منذ %1$d يومًا</item>
<item quantity="other">نشط منذ %1$d يوم</item>
</plurals>
<string name="session_age_date_desc">نشط %1$s</string>
<string name="history_load_more">تحميل المزيد من الجلسات…</string>
<string name="sessions_search_placeholder">البحث في الجلسات…</string>
<string name="sessions_search_empty_title">لا توجد نتائج</string>
+23
View File
@@ -434,6 +434,29 @@
<string name="history_empty_desc">이전 채팅 세션이 여기에 표시됩니다.</string>
<string name="history_untitled">제목 없음</string>
<string name="history_message_count">%1$d개 메시지</string>
<plurals name="sessions_card_message_count">
<item quantity="one">%1$d개 메시지</item>
<item quantity="other">%1$d개 메시지</item>
</plurals>
<string name="sessions_project_home">홈</string>
<string name="session_age_now">지금</string>
<string name="session_age_minutes">%1$d분</string>
<string name="session_age_hours">%1$d시간</string>
<string name="session_age_days">%1$d일</string>
<string name="session_age_now_desc">방금 활동</string>
<plurals name="session_age_minutes_desc">
<item quantity="one">%1$d분 전 활동</item>
<item quantity="other">%1$d분 전 활동</item>
</plurals>
<plurals name="session_age_hours_desc">
<item quantity="one">%1$d시간 전 활동</item>
<item quantity="other">%1$d시간 전 활동</item>
</plurals>
<plurals name="session_age_days_desc">
<item quantity="one">%1$d일 전 활동</item>
<item quantity="other">%1$d일 전 활동</item>
</plurals>
<string name="session_age_date_desc">%1$s 활동</string>
<string name="history_load_more">더 불러오기…</string>
<!-- Sessions Stats -->
+23
View File
@@ -532,6 +532,29 @@
<string name="history_empty_desc">之前的 Hermes 对话会话将显示在这里。</string>
<string name="history_untitled">未命名</string>
<string name="history_message_count">%1$d 条消息</string>
<plurals name="sessions_card_message_count">
<item quantity="one">%1$d 条消息</item>
<item quantity="other">%1$d 条消息</item>
</plurals>
<string name="sessions_project_home">主页</string>
<string name="session_age_now">刚刚</string>
<string name="session_age_minutes">%1$d分钟</string>
<string name="session_age_hours">%1$d小时</string>
<string name="session_age_days">%1$d天</string>
<string name="session_age_now_desc">刚刚活跃</string>
<plurals name="session_age_minutes_desc">
<item quantity="one">%1$d 分钟前活跃</item>
<item quantity="other">%1$d 分钟前活跃</item>
</plurals>
<plurals name="session_age_hours_desc">
<item quantity="one">%1$d 小时前活跃</item>
<item quantity="other">%1$d 小时前活跃</item>
</plurals>
<plurals name="session_age_days_desc">
<item quantity="one">%1$d 天前活跃</item>
<item quantity="other">%1$d 天前活跃</item>
</plurals>
<string name="session_age_date_desc">%1$s 活跃</string>
<string name="history_load_more">加载更多会话…</string>
<string name="sessions_search_placeholder">搜索会话…</string>
<string name="sessions_search_empty_title">无结果</string>
+24
View File
@@ -626,6 +626,30 @@
<string name="history_empty_desc">Previous chat sessions from Hermes will appear here.</string>
<string name="history_untitled">Untitled</string>
<string name="history_message_count">%1$d messages</string>
<plurals name="sessions_card_message_count">
<item quantity="one">%1$d message</item>
<item quantity="other">%1$d messages</item>
</plurals>
<string name="sessions_project_home">Home</string>
<!-- Short activity age on history cards (m = minutes, h = hours, d = days) -->
<string name="session_age_now">now</string>
<string name="session_age_minutes">%1$dm</string>
<string name="session_age_hours">%1$dh</string>
<string name="session_age_days">%1$dd</string>
<string name="session_age_now_desc">Active just now</string>
<plurals name="session_age_minutes_desc">
<item quantity="one">Active %1$d minute ago</item>
<item quantity="other">Active %1$d minutes ago</item>
</plurals>
<plurals name="session_age_hours_desc">
<item quantity="one">Active %1$d hour ago</item>
<item quantity="other">Active %1$d hours ago</item>
</plurals>
<plurals name="session_age_days_desc">
<item quantity="one">Active %1$d day ago</item>
<item quantity="other">Active %1$d days ago</item>
</plurals>
<string name="session_age_date_desc">Active %1$s</string>
<string name="history_load_more">Load more sessions…</string>
<!-- Sessions Stats -->
@@ -0,0 +1,67 @@
package com.m57.hermescontrol.data.model
import com.m57.hermescontrol.data.remote.OkHttpProvider
import org.junit.Assert.assertEquals
import org.junit.Assert.assertFalse
import org.junit.Assert.assertNull
import org.junit.Assert.assertTrue
import org.junit.Test
/** Wire-shape tests for the session workspace fields and the `projects.list` RPC result. */
class ProjectModelsTest {
private val json = OkHttpProvider.json
@Test
fun `session list row decodes its workspace`() {
val session =
json.decodeFromString<SessionInfo>(
"""{"id":"s1","cwd":"/srv/app/src","git_repo_root":"/srv/app","git_branch":"main"}""",
)
assertEquals("/srv/app/src", session.cwd)
assertEquals("/srv/app", session.git_repo_root)
}
@Test
fun `session list row without a workspace decodes nulls`() {
val session = json.decodeFromString<SessionInfo>("""{"id":"s1"}""")
assertNull(session.cwd)
assertNull(session.git_repo_root)
}
@Test
fun `projects list payload decodes folders, color and archived`() {
val response =
json.decodeFromString<ProjectsListResponse>(
"""
{"projects":[
{"id":"p_1","slug":"app","name":"App","description":null,"icon":"rocket",
"color":"hsl(120 68% 58%)","board_slug":null,"primary_path":"/srv/app",
"archived":false,"created_at":1787590315,
"folders":[{"path":"/srv/app","label":null,"is_primary":true,"added_at":1787590315}]},
{"id":"p_2","slug":"old","name":"Old","archived":1,"folders":[]}
],"active_id":"p_1"}
""".trimIndent(),
)
val app = response.projects[0]
assertEquals("App", app.name)
assertEquals("hsl(120 68% 58%)", app.color)
assertEquals("/srv/app", app.primaryPath)
assertEquals(listOf("/srv/app"), app.folders.map { it.path })
assertTrue(app.folders[0].isPrimary == true)
assertFalse(app.isArchived)
assertTrue(response.projects[1].isArchived)
assertEquals("p_1", response.activeId)
}
@Test
fun `project with only an id and name decodes defaults`() {
val project = json.decodeFromString<ProjectInfo>("""{"id":"p_3","name":"Bare"}""")
assertNull(project.color)
assertTrue(project.folders.isEmpty())
assertFalse(project.isArchived)
}
}
@@ -0,0 +1,85 @@
package com.m57.hermescontrol.data.ws
import kotlinx.coroutines.CancellationException
import kotlinx.coroutines.test.runTest
import org.junit.Assert.assertEquals
import org.junit.Assert.assertNull
import org.junit.Assert.assertTrue
import org.junit.Test
class ProjectsSourceTest {
@Test
fun `decodes the untyped rpc map`() =
runTest {
var calledMethod: String? = null
val source =
HermesProjectsSource(isConnected = { true }) { method, _ ->
calledMethod = method
mapOf(
"projects" to
listOf(
mapOf(
"id" to "p_1",
"name" to "App",
"color" to "hsl(1 2% 3%)",
"archived" to false,
"folders" to listOf(mapOf("path" to "/srv/app", "is_primary" to true)),
),
),
"active_id" to "p_1",
)
}
val projects = source.fetchProjects()
assertEquals(WsMethods.PROJECTS_LIST, calledMethod)
assertEquals(listOf("App"), projects?.map { it.name })
assertEquals(
"/srv/app",
projects
?.single()
?.folders
?.single()
?.path,
)
}
@Test
fun `skips the rpc while the socket is not connected`() =
runTest {
var calls = 0
val source =
HermesProjectsSource(
rpcRequest = { _, _ ->
calls++
mapOf("projects" to emptyList<Any>())
},
isConnected = { false },
)
assertNull(source.fetchProjects())
assertEquals("a disconnected fetch must not queue an RPC", 0, calls)
}
@Test
fun `rpc failure or unexpected payload yields null`() =
runTest {
assertNull(
HermesProjectsSource(isConnected = { true }) { _, _ -> error("method not found") }.fetchProjects(),
)
assertNull(HermesProjectsSource(isConnected = { true }) { _, _ -> "nope" }.fetchProjects())
assertNull(HermesProjectsSource(isConnected = { true }) { _, _ -> null }.fetchProjects())
}
@Test
fun `cancellation propagates`() =
runTest {
val thrown =
runCatching {
HermesProjectsSource(
isConnected = { true },
) { _, _ -> throw CancellationException("gone") }.fetchProjects()
}.exceptionOrNull()
assertTrue(thrown is CancellationException)
}
}
@@ -0,0 +1,43 @@
package com.m57.hermescontrol.theme
import androidx.compose.ui.graphics.Color
import org.junit.Assert.assertEquals
import org.junit.Assert.assertNull
import org.junit.Test
class ProjectColorTest {
private fun assertColor(
expected: Color,
actual: Color?,
) {
requireNotNull(actual)
assertEquals(expected.red, actual.red, 0.01f)
assertEquals(expected.green, actual.green, 0.01f)
assertEquals(expected.blue, actual.blue, 0.01f)
}
@Test
fun `space separated hsl parses`() {
assertColor(Color.hsl(210f, 0.68f, 0.58f), parseProjectColor("hsl(210 68% 58%)"))
}
@Test
fun `comma separated hsl parses`() {
assertColor(Color.hsl(0f, 0.68f, 0.58f), parseProjectColor(" hsl(0, 68%, 58%) "))
}
@Test
fun `hex parses`() {
assertColor(parseHexColor("#4DA8FF", Color.Unspecified), parseProjectColor("#4DA8FF"))
}
@Test
fun `invalid or missing colors are null`() {
assertNull(parseProjectColor(null))
assertNull(parseProjectColor(""))
assertNull(parseProjectColor("tomato"))
assertNull(parseProjectColor("hsl(400 68% 58%)"))
assertNull(parseProjectColor("hsl(210 168% 58%)"))
assertNull(parseProjectColor("#12"))
}
}
@@ -0,0 +1,73 @@
package com.m57.hermescontrol.ui.sessions
import com.m57.hermescontrol.data.model.SessionInfo
import org.junit.Assert.assertEquals
import org.junit.Assert.assertNull
import org.junit.Test
import java.time.LocalDate
import java.time.ZoneOffset
import java.time.ZonedDateTime
class SessionAgeTest {
private val zone = ZoneOffset.UTC
private val now = ZonedDateTime.of(2026, 9, 15, 12, 0, 0, 0, zone).toInstant()
private fun ageSecondsAgo(seconds: Long) = sessionAge((now.epochSecond - seconds).toDouble(), now, zone)
@Test
fun `under a minute reads as now`() {
assertEquals(SessionAge.Now, ageSecondsAgo(0))
assertEquals(SessionAge.Now, ageSecondsAgo(59))
}
@Test
fun `minutes bucket`() {
assertEquals(SessionAge.Minutes(1), ageSecondsAgo(60))
assertEquals(SessionAge.Minutes(15), ageSecondsAgo(15 * 60 + 30))
assertEquals(SessionAge.Minutes(59), ageSecondsAgo(3599))
}
@Test
fun `hours bucket`() {
assertEquals(SessionAge.Hours(1), ageSecondsAgo(3600))
assertEquals(SessionAge.Hours(21), ageSecondsAgo(21 * 3600))
assertEquals(SessionAge.Hours(23), ageSecondsAgo(24 * 3600 - 1))
}
@Test
fun `days bucket up to a week`() {
assertEquals(SessionAge.Days(1), ageSecondsAgo(24 * 3600))
assertEquals(SessionAge.Days(6), ageSecondsAgo(7 * 24 * 3600 - 1))
}
@Test
fun `a week or older shows the date`() {
assertEquals(
SessionAge.Date(LocalDate.of(2026, 9, 8), sameYear = true),
ageSecondsAgo(7 * 24 * 3600),
)
assertEquals(
SessionAge.Date(LocalDate.of(2025, 12, 31), sameYear = false),
sessionAge(ZonedDateTime.of(2025, 12, 31, 23, 0, 0, 0, zone).toEpochSecond().toDouble(), now, zone),
)
}
@Test
fun `future timestamps from clock skew read as now`() {
assertEquals(SessionAge.Now, ageSecondsAgo(-300))
}
@Test
fun `missing timestamps have no age`() {
assertNull(sessionAge(null, now, zone))
assertNull(sessionAge(0.0, now, zone))
}
@Test
fun `activity prefers last active over started at`() {
assertEquals(20.0, SessionInfo(id = "s", started_at = 10.0, last_active = 20.0).activityEpochSeconds())
assertEquals(10.0, SessionInfo(id = "s", started_at = 10.0).activityEpochSeconds())
assertEquals(10.0, SessionInfo(id = "s", started_at = 10.0, last_active = 0.0).activityEpochSeconds())
assertNull(SessionInfo(id = "s").activityEpochSeconds())
}
}
@@ -0,0 +1,159 @@
package com.m57.hermescontrol.ui.sessions
import com.m57.hermescontrol.data.model.ProjectFolder
import com.m57.hermescontrol.data.model.ProjectInfo
import com.m57.hermescontrol.data.model.SessionInfo
import org.junit.Assert.assertEquals
import org.junit.Assert.assertNull
import org.junit.Test
class SessionProjectTest {
private fun session(
cwd: String? = null,
repoRoot: String? = null,
) = SessionInfo(id = "s", cwd = cwd, git_repo_root = repoRoot)
private fun project(
name: String,
vararg folders: String,
color: String? = null,
archived: Boolean = false,
) = ProjectInfo(
id = "p_$name",
name = name,
color = color,
archived = archived,
folders = folders.map { ProjectFolder(path = it) },
)
@Test
fun `explicit project owns a cwd under its folder`() {
val projects = listOf(project("App", "/srv/app", color = "hsl(1 2% 3%)"))
assertEquals(
SessionProject(label = "App", color = "hsl(1 2% 3%)"),
resolveSessionProject(session(cwd = "/srv/app/src"), projects),
)
}
@Test
fun `explicit project matches on the repo root when the cwd sits outside`() {
val projects = listOf(project("App", "/srv/app"))
assertEquals(
"App",
resolveSessionProject(session(cwd = "/tmp/worktree", repoRoot = "/srv/app"), projects)?.label,
)
}
@Test
fun `deepest folder wins for nested projects`() {
val projects = listOf(project("Mono", "/srv/mono"), project("Web", "/srv/mono/web"))
assertEquals("Web", resolveSessionProject(session(cwd = "/srv/mono/web/src"), projects)?.label)
}
@Test
fun `archived projects are skipped`() {
val projects = listOf(project("Root", "/root", archived = true))
assertEquals("notes", resolveSessionProject(session(cwd = "/root/notes"), projects)?.label)
}
@Test
fun `sibling directory with a shared prefix is not under the folder`() {
val projects = listOf(project("Foo", "/root/foo"))
assertEquals("foobar", resolveSessionProject(session(cwd = "/root/foobar"), projects)?.label)
}
@Test
fun `trailing slashes do not break folder matching`() {
val projects = listOf(project("App", "/srv/app/"))
assertEquals("App", resolveSessionProject(session(cwd = "/srv/app/"), projects)?.label)
}
@Test
fun `unowned git session names its repo root`() {
assertEquals(
SessionProject(label = "hermes-agent", color = null),
resolveSessionProject(
session(cwd = "/code/hermes-agent/tools", repoRoot = "/code/hermes-agent"),
emptyList(),
),
)
}
@Test
fun `worktree outside its repo still names the repo`() {
assertEquals(
"hermes-agent",
resolveSessionProject(session(cwd = "/code/wt-123", repoRoot = "/code/hermes-agent"), emptyList())?.label,
)
}
@Test
fun `repo root only session names the root`() {
assertEquals("app", resolveSessionProject(session(repoRoot = "/srv/app"), emptyList())?.label)
}
@Test
fun `non git session names its cwd leaf`() {
assertEquals("scratch", resolveSessionProject(session(cwd = "/home/me/scratch"), emptyList())?.label)
}
@Test
fun `windows paths resolve`() {
val projects = listOf(project("Win", "C:\\code\\app"))
assertEquals("Win", resolveSessionProject(session(cwd = "C:\\code\\app\\src"), projects)?.label)
}
@Test
fun `windows paths match regardless of casing and separators`() {
val projects = listOf(project("Win", "C:\\Work\\App"))
assertEquals("Win", resolveSessionProject(session(cwd = "c:/work/app/src"), projects)?.label)
assertEquals("Win", resolveSessionProject(session(cwd = "C:\\WORK\\APP"), projects)?.label)
}
@Test
fun `unc paths match regardless of casing`() {
val projects = listOf(project("Share", "\\\\Server\\Repos\\App"))
assertEquals("Share", resolveSessionProject(session(cwd = "//server/repos/app/lib"), projects)?.label)
}
@Test
fun `posix paths stay case sensitive`() {
val projects = listOf(project("App", "/srv/App"))
assertEquals("app", resolveSessionProject(session(cwd = "/srv/app"), projects)?.label)
}
@Test
fun `labels keep the recorded spelling`() {
assertEquals("MyRepo", resolveSessionProject(session(cwd = "C:\\Code\\MyRepo"), emptyList())?.label)
}
@Test
fun `repeated separators do not break matching`() {
val projects = listOf(project("App", "/srv/app"))
assertEquals("App", resolveSessionProject(session(cwd = "/srv//app///src"), projects)?.label)
}
@Test
fun `session without a workspace has no project`() {
assertNull(resolveSessionProject(session(), listOf(project("App", "/srv/app"))))
assertNull(resolveSessionProject(session(cwd = " ", repoRoot = ""), emptyList()))
}
@Test
fun `blank explicit name falls back to the folder leaf`() {
val projects = listOf(project(" ", "/srv/app"))
assertEquals("app", resolveSessionProject(session(cwd = "/srv/app"), projects)?.label)
}
}
@@ -1,6 +1,7 @@
package com.m57.hermescontrol.ui.sessions
import com.m57.hermescontrol.data.model.LiveSessionSnapshot
import com.m57.hermescontrol.data.model.ProjectInfo
import com.m57.hermescontrol.data.model.SessionInfo
import com.m57.hermescontrol.data.model.SessionListResponse
import com.m57.hermescontrol.data.model.SessionLiveStatus
@@ -11,6 +12,7 @@ import com.m57.hermescontrol.data.remote.HermesApiService
import com.m57.hermescontrol.data.ws.ChangeEventHub
import com.m57.hermescontrol.data.ws.ChangeEvents
import com.m57.hermescontrol.data.ws.ConnectionStatus
import com.m57.hermescontrol.data.ws.ProjectsSource
import com.m57.hermescontrol.data.ws.SessionLiveStatusSource
import com.m57.hermescontrol.data.ws.WsEvent
import io.mockk.coEvery
@@ -64,8 +66,22 @@ class SessionsViewModelTest {
override val connectionStatus: StateFlow<ConnectionStatus> = connectionStatusFlow
}
private fun createViewModel(source: SessionLiveStatusSource = FakeSessionLiveStatusSource()): SessionsViewModel {
val vm = SessionsViewModel(liveStatusSource = source)
private class FakeProjectsSource : ProjectsSource {
var projectsToReturn: List<ProjectInfo>? = emptyList()
var fetchCallCount = 0
var projectsDeferred: CompletableDeferred<List<ProjectInfo>?>? = null
override suspend fun fetchProjects(): List<ProjectInfo>? {
fetchCallCount++
return projectsDeferred?.await() ?: projectsToReturn
}
}
private fun createViewModel(
source: SessionLiveStatusSource = FakeSessionLiveStatusSource(),
projectsSource: ProjectsSource = FakeProjectsSource(),
): SessionsViewModel {
val vm = SessionsViewModel(liveStatusSource = source, projectsSource = projectsSource)
testDispatcher.scheduler.advanceUntilIdle()
return vm
}
@@ -83,6 +99,96 @@ class SessionsViewModelTest {
unmockkAll()
}
@Test
fun `tracking loads the project list`() {
val projects = FakeProjectsSource().apply { projectsToReturn = listOf(ProjectInfo(id = "p_1", name = "App")) }
val vm = createViewModel(projectsSource = projects)
vm.startLiveStatusTracking()
testDispatcher.scheduler.runCurrent()
assertEquals(
listOf("App"),
vm.uiState.value.projects
.map { it.name },
)
vm.stopLiveStatusTracking()
}
@Test
fun `stopping tracking cancels an in-flight project fetch`() {
val pending = CompletableDeferred<List<ProjectInfo>?>()
val projects = FakeProjectsSource().apply { projectsDeferred = pending }
val vm = createViewModel(projectsSource = projects)
vm.startLiveStatusTracking()
testDispatcher.scheduler.runCurrent()
assertEquals(1, projects.fetchCallCount)
vm.stopLiveStatusTracking()
pending.complete(listOf(ProjectInfo(id = "p_late", name = "Late")))
testDispatcher.scheduler.advanceUntilIdle()
assertTrue(
vm.uiState.value.projects
.isEmpty(),
)
}
@Test
fun `loadSessions without tracking does not fetch projects`() {
val projects = FakeProjectsSource()
val vm = createViewModel(projectsSource = projects)
vm.loadSessions()
testDispatcher.scheduler.advanceUntilIdle()
assertEquals(0, projects.fetchCallCount)
}
@Test
fun `failed project refresh keeps the last good list`() {
val projects = FakeProjectsSource().apply { projectsToReturn = listOf(ProjectInfo(id = "p_1", name = "App")) }
val vm = createViewModel(projectsSource = projects)
vm.startLiveStatusTracking()
testDispatcher.scheduler.runCurrent()
projects.projectsToReturn = null
vm.loadSessions()
testDispatcher.scheduler.runCurrent()
assertEquals(2, projects.fetchCallCount)
assertEquals(
listOf("App"),
vm.uiState.value.projects
.map { it.name },
)
vm.stopLiveStatusTracking()
}
@Test
fun `reconnecting refreshes the project list`() {
val live = FakeSessionLiveStatusSource()
val projects = FakeProjectsSource()
val vm = createViewModel(source = live, projectsSource = projects)
vm.startLiveStatusTracking()
testDispatcher.scheduler.runCurrent()
live.connectionStatusFlow.value = ConnectionStatus.DISCONNECTED
testDispatcher.scheduler.runCurrent()
val before = projects.fetchCallCount
projects.projectsToReturn = listOf(ProjectInfo(id = "p_2", name = "Web"))
live.connectionStatusFlow.value = ConnectionStatus.CONNECTED
testDispatcher.scheduler.runCurrent()
assertTrue(projects.fetchCallCount > before)
assertEquals(
listOf("Web"),
vm.uiState.value.projects
.map { it.name },
)
vm.stopLiveStatusTracking()
}
@Test
fun `blank query resets search mode`() {
val vm = createViewModel()
@@ -0,0 +1,44 @@
package com.m57.hermescontrol.ui.sessions.components
import com.m57.hermescontrol.theme.ThemePreset
import com.m57.hermescontrol.theme.resolveColorScheme
import org.junit.Assert.assertEquals
import org.junit.Assert.assertTrue
import org.junit.Test
/**
* Contrast gate for the history card across every preset in both modes. Every line on the
* card is body-sized or smaller, so title and secondary text must both clear WCAG AA 4.5:1.
*/
class SessionCardContrastTest {
private fun forEveryPresetMode(block: (String, androidx.compose.material3.ColorScheme) -> Unit) {
ThemePreset.entries.forEach { preset ->
listOf(true, false).forEach { dark ->
block("$preset dark=$dark", resolveColorScheme(preset, darkTheme = dark))
}
}
}
@Test
fun cardTextMeetsContrastInEveryPresetMode() {
forEveryPresetMode { mode, scheme ->
val palette = sessionCardPalette(scheme)
listOf("title" to palette.title, "secondary" to palette.secondary).forEach { (role, color) ->
val ratio = contrastRatio(color, palette.card)
assertTrue(
"$mode $role contrast %.2f must be >= 4.5".format(ratio),
ratio >= SESSION_CARD_MIN_TEXT_CONTRAST,
)
}
}
}
@Test
fun secondaryKeepsTheThemeColorWhenItAlreadyPasses() {
forEveryPresetMode { mode, scheme ->
if (contrastRatio(scheme.onSurfaceVariant, scheme.surfaceContainer) >= SESSION_CARD_MIN_TEXT_CONTRAST) {
assertEquals(mode, scheme.onSurfaceVariant, sessionCardPalette(scheme).secondary)
}
}
}
}
@@ -0,0 +1,42 @@
package com.m57.hermescontrol.ui.sessions.components
import org.junit.Assert.assertFalse
import org.junit.Assert.assertTrue
import org.junit.Test
class SessionCardFooterTest {
@Test
fun `everything fits at its natural width`() {
assertTrue(
footerFitsOneLine(maxWidth = 400, leadWidth = 120, countWidth = 90, badgesWidth = 100, leadMinWidth = 80),
)
}
@Test
fun `a long model shrinks instead of pushing the badges down`() {
assertTrue(
footerFitsOneLine(maxWidth = 400, leadWidth = 380, countWidth = 90, badgesWidth = 150, leadMinWidth = 80),
)
}
@Test
fun `badges wrap only when the model would drop below its minimum`() {
assertFalse(
footerFitsOneLine(maxWidth = 300, leadWidth = 380, countWidth = 120, badgesWidth = 150, leadMinWidth = 80),
)
}
@Test
fun `a short model only needs its own width`() {
assertTrue(
footerFitsOneLine(maxWidth = 300, leadWidth = 40, countWidth = 120, badgesWidth = 140, leadMinWidth = 80),
)
}
@Test
fun `no badges always fits one line`() {
assertTrue(
footerFitsOneLine(maxWidth = 100, leadWidth = 380, countWidth = 120, badgesWidth = 0, leadMinWidth = 80),
)
}
}