feat(sessions): show project and last activity on history cards (#1133)
This commit is contained in:
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)
|
||||
}
|
||||
Reference in new issue
Block a user