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,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)
}