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,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)
|
||||
}
|
||||
@@ -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>
|
||||
|
||||
@@ -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 -->
|
||||
|
||||
@@ -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>
|
||||
|
||||
@@ -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()
|
||||
|
||||
+44
@@ -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)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
+42
@@ -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),
|
||||
)
|
||||
}
|
||||
}
|
||||
Reference in new issue
Block a user