diff --git a/app/src/androidTest/java/com/m57/hermescontrol/ui/sessions/SessionCardTest.kt b/app/src/androidTest/java/com/m57/hermescontrol/ui/sessions/SessionCardTest.kt new file mode 100644 index 00000000..cc237c75 --- /dev/null +++ b/app/src/androidTest/java/com/m57/hermescontrol/ui/sessions/SessionCardTest.kt @@ -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) } + } +} diff --git a/app/src/main/java/com/m57/hermescontrol/data/model/ProjectModels.kt b/app/src/main/java/com/m57/hermescontrol/data/model/ProjectModels.kt new file mode 100644 index 00000000..02749b58 --- /dev/null +++ b/app/src/main/java/com/m57/hermescontrol/data/model/ProjectModels.kt @@ -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 = 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 = 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, +) diff --git a/app/src/main/java/com/m57/hermescontrol/data/model/SessionListResponse.kt b/app/src/main/java/com/m57/hermescontrol/data/model/SessionListResponse.kt index ab2e0eb0..bbffae54 100644 --- a/app/src/main/java/com/m57/hermescontrol/data/model/SessionListResponse.kt +++ b/app/src/main/java/com/m57/hermescontrol/data/model/SessionListResponse.kt @@ -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, diff --git a/app/src/main/java/com/m57/hermescontrol/data/ws/ProjectsSource.kt b/app/src/main/java/com/m57/hermescontrol/data/ws/ProjectsSource.kt new file mode 100644 index 00000000..f1984bee --- /dev/null +++ b/app/src/main/java/com/m57/hermescontrol/data/ws/ProjectsSource.kt @@ -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? +} + +/** [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) -> Any? = { method, params -> + HermesWsClient.request(method, params).await() + }, +) : ProjectsSource { + override suspend fun fetchProjects(): List? { + 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 + } + } +} diff --git a/app/src/main/java/com/m57/hermescontrol/data/ws/WsMethods.kt b/app/src/main/java/com/m57/hermescontrol/data/ws/WsMethods.kt index 4c0a68c9..ab8c47bc 100644 --- a/app/src/main/java/com/m57/hermescontrol/data/ws/WsMethods.kt +++ b/app/src/main/java/com/m57/hermescontrol/data/ws/WsMethods.kt @@ -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 diff --git a/app/src/main/java/com/m57/hermescontrol/theme/Color.kt b/app/src/main/java/com/m57/hermescontrol/theme/Color.kt index c3128283..3cb10951 100644 --- a/app/src/main/java/com/m57/hermescontrol/theme/Color.kt +++ b/app/src/main/java/com/m57/hermescontrol/theme/Color.kt @@ -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) +} diff --git a/app/src/main/java/com/m57/hermescontrol/ui/sessions/SessionAge.kt b/app/src/main/java/com/m57/hermescontrol/ui/sessions/SessionAge.kt new file mode 100644 index 00000000..ef18ee3f --- /dev/null +++ b/app/src/main/java/com/m57/hermescontrol/ui/sessions/SessionAge.kt @@ -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) + } + } +} diff --git a/app/src/main/java/com/m57/hermescontrol/ui/sessions/SessionProject.kt b/app/src/main/java/com/m57/hermescontrol/ui/sessions/SessionProject.kt new file mode 100644 index 00000000..3518550b --- /dev/null +++ b/app/src/main/java/com/m57/hermescontrol/ui/sessions/SessionProject.kt @@ -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, +): 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 = 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 { + 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() diff --git a/app/src/main/java/com/m57/hermescontrol/ui/sessions/SessionsScreen.kt b/app/src/main/java/com/m57/hermescontrol/ui/sessions/SessionsScreen.kt index 6d45a04f..87bfd29e 100644 --- a/app/src/main/java/com/m57/hermescontrol/ui/sessions/SessionsScreen.kt +++ b/app/src/main/java/com/m57/hermescontrol/ui/sessions/SessionsScreen.kt @@ -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 = { diff --git a/app/src/main/java/com/m57/hermescontrol/ui/sessions/SessionsViewModel.kt b/app/src/main/java/com/m57/hermescontrol/ui/sessions/SessionsViewModel.kt index bf8b0135..68b0efc7 100644 --- a/app/src/main/java/com/m57/hermescontrol/ui/sessions/SessionsViewModel.kt +++ b/app/src/main/java/com/m57/hermescontrol/ui/sessions/SessionsViewModel.kt @@ -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 = emptyMap(), + // Named projects used to label each row with the workspace it belongs to. + val projects: List = 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() diff --git a/app/src/main/java/com/m57/hermescontrol/ui/sessions/components/SessionAgeLabels.kt b/app/src/main/java/com/m57/hermescontrol/ui/sessions/components/SessionAgeLabels.kt new file mode 100644 index 00000000..f5205679 --- /dev/null +++ b/app/src/main/java/com/m57/hermescontrol/ui/sessions/components/SessionAgeLabels.kt @@ -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) + } + } diff --git a/app/src/main/java/com/m57/hermescontrol/ui/sessions/components/SessionCard.kt b/app/src/main/java/com/m57/hermescontrol/ui/sessions/components/SessionCard.kt index 108de012..fb1b34b4 100644 --- a/app/src/main/java/com/m57/hermescontrol/ui/sessions/components/SessionCard.kt +++ b/app/src/main/java/com/m57/hermescontrol/ui/sessions/components/SessionCard.kt @@ -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) + } + } + } + } +} diff --git a/app/src/main/java/com/m57/hermescontrol/ui/sessions/components/SessionCardPalette.kt b/app/src/main/java/com/m57/hermescontrol/ui/sessions/components/SessionCardPalette.kt new file mode 100644 index 00000000..5eab0f99 --- /dev/null +++ b/app/src/main/java/com/m57/hermescontrol/ui/sessions/components/SessionCardPalette.kt @@ -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) +} diff --git a/app/src/main/res/values-ar/strings.xml b/app/src/main/res/values-ar/strings.xml index f6f3edf2..79f016d9 100644 --- a/app/src/main/res/values-ar/strings.xml +++ b/app/src/main/res/values-ar/strings.xml @@ -545,6 +545,45 @@ ستظهر جلسات المحادثة السابقة من Hermes هنا. بلا عنوان %1$d رسالة + + %1$d رسالة + %1$d رسالة + %1$d رسالتان + %1$d رسائل + %1$d رسالة + %1$d رسالة + +الرئيسية +الآن +%1$d د +%1$d س +%1$d ي +نشط الآن + + نشط منذ %1$d دقيقة + نشط منذ %1$d دقيقة + نشط منذ %1$d دقيقتين + نشط منذ %1$d دقائق + نشط منذ %1$d دقيقة + نشط منذ %1$d دقيقة + + + نشط منذ %1$d ساعة + نشط منذ %1$d ساعة + نشط منذ %1$d ساعتين + نشط منذ %1$d ساعات + نشط منذ %1$d ساعة + نشط منذ %1$d ساعة + + + نشط منذ %1$d يوم + نشط منذ %1$d يوم + نشط منذ %1$d يومين + نشط منذ %1$d أيام + نشط منذ %1$d يومًا + نشط منذ %1$d يوم + +نشط %1$s تحميل المزيد من الجلسات… البحث في الجلسات… لا توجد نتائج diff --git a/app/src/main/res/values-ko/strings.xml b/app/src/main/res/values-ko/strings.xml index b6fbf865..d9bf59f6 100644 --- a/app/src/main/res/values-ko/strings.xml +++ b/app/src/main/res/values-ko/strings.xml @@ -434,6 +434,29 @@ 이전 채팅 세션이 여기에 표시됩니다. 제목 없음 %1$d개 메시지 + + %1$d개 메시지 + %1$d개 메시지 + + 홈 + 지금 + %1$d분 + %1$d시간 + %1$d일 + 방금 활동 + + %1$d분 전 활동 + %1$d분 전 활동 + + + %1$d시간 전 활동 + %1$d시간 전 활동 + + + %1$d일 전 활동 + %1$d일 전 활동 + + %1$s 활동 더 불러오기… diff --git a/app/src/main/res/values-zh/strings.xml b/app/src/main/res/values-zh/strings.xml index f4d539db..e28fcf93 100644 --- a/app/src/main/res/values-zh/strings.xml +++ b/app/src/main/res/values-zh/strings.xml @@ -532,6 +532,29 @@ 之前的 Hermes 对话会话将显示在这里。 未命名 %1$d 条消息 + + %1$d 条消息 + %1$d 条消息 + + 主页 + 刚刚 + %1$d分钟 + %1$d小时 + %1$d天 + 刚刚活跃 + + %1$d 分钟前活跃 + %1$d 分钟前活跃 + + + %1$d 小时前活跃 + %1$d 小时前活跃 + + + %1$d 天前活跃 + %1$d 天前活跃 + + %1$s 活跃 加载更多会话… 搜索会话… 无结果 diff --git a/app/src/main/res/values/strings.xml b/app/src/main/res/values/strings.xml index 4c5fc61b..c1c469a1 100644 --- a/app/src/main/res/values/strings.xml +++ b/app/src/main/res/values/strings.xml @@ -626,6 +626,30 @@ Previous chat sessions from Hermes will appear here. Untitled %1$d messages + + %1$d message + %1$d messages + + Home + + now + %1$dm + %1$dh + %1$dd + Active just now + + Active %1$d minute ago + Active %1$d minutes ago + + + Active %1$d hour ago + Active %1$d hours ago + + + Active %1$d day ago + Active %1$d days ago + + Active %1$s Load more sessions… diff --git a/app/src/test/java/com/m57/hermescontrol/data/model/ProjectModelsTest.kt b/app/src/test/java/com/m57/hermescontrol/data/model/ProjectModelsTest.kt new file mode 100644 index 00000000..e474e947 --- /dev/null +++ b/app/src/test/java/com/m57/hermescontrol/data/model/ProjectModelsTest.kt @@ -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( + """{"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("""{"id":"s1"}""") + + assertNull(session.cwd) + assertNull(session.git_repo_root) + } + + @Test + fun `projects list payload decodes folders, color and archived`() { + val response = + json.decodeFromString( + """ + {"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("""{"id":"p_3","name":"Bare"}""") + + assertNull(project.color) + assertTrue(project.folders.isEmpty()) + assertFalse(project.isArchived) + } +} diff --git a/app/src/test/java/com/m57/hermescontrol/data/ws/ProjectsSourceTest.kt b/app/src/test/java/com/m57/hermescontrol/data/ws/ProjectsSourceTest.kt new file mode 100644 index 00000000..f17be50b --- /dev/null +++ b/app/src/test/java/com/m57/hermescontrol/data/ws/ProjectsSourceTest.kt @@ -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()) + }, + 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) + } +} diff --git a/app/src/test/java/com/m57/hermescontrol/theme/ProjectColorTest.kt b/app/src/test/java/com/m57/hermescontrol/theme/ProjectColorTest.kt new file mode 100644 index 00000000..1262cc36 --- /dev/null +++ b/app/src/test/java/com/m57/hermescontrol/theme/ProjectColorTest.kt @@ -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")) + } +} diff --git a/app/src/test/java/com/m57/hermescontrol/ui/sessions/SessionAgeTest.kt b/app/src/test/java/com/m57/hermescontrol/ui/sessions/SessionAgeTest.kt new file mode 100644 index 00000000..f09d2c7a --- /dev/null +++ b/app/src/test/java/com/m57/hermescontrol/ui/sessions/SessionAgeTest.kt @@ -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()) + } +} diff --git a/app/src/test/java/com/m57/hermescontrol/ui/sessions/SessionProjectTest.kt b/app/src/test/java/com/m57/hermescontrol/ui/sessions/SessionProjectTest.kt new file mode 100644 index 00000000..9e32904d --- /dev/null +++ b/app/src/test/java/com/m57/hermescontrol/ui/sessions/SessionProjectTest.kt @@ -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) + } +} diff --git a/app/src/test/java/com/m57/hermescontrol/ui/sessions/SessionsViewModelTest.kt b/app/src/test/java/com/m57/hermescontrol/ui/sessions/SessionsViewModelTest.kt index 66d7b0c1..cbc12407 100644 --- a/app/src/test/java/com/m57/hermescontrol/ui/sessions/SessionsViewModelTest.kt +++ b/app/src/test/java/com/m57/hermescontrol/ui/sessions/SessionsViewModelTest.kt @@ -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 = connectionStatusFlow } - private fun createViewModel(source: SessionLiveStatusSource = FakeSessionLiveStatusSource()): SessionsViewModel { - val vm = SessionsViewModel(liveStatusSource = source) + private class FakeProjectsSource : ProjectsSource { + var projectsToReturn: List? = emptyList() + var fetchCallCount = 0 + var projectsDeferred: CompletableDeferred?>? = null + + override suspend fun fetchProjects(): List? { + 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?>() + 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() diff --git a/app/src/test/java/com/m57/hermescontrol/ui/sessions/components/SessionCardContrastTest.kt b/app/src/test/java/com/m57/hermescontrol/ui/sessions/components/SessionCardContrastTest.kt new file mode 100644 index 00000000..d9851ee3 --- /dev/null +++ b/app/src/test/java/com/m57/hermescontrol/ui/sessions/components/SessionCardContrastTest.kt @@ -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) + } + } + } +} diff --git a/app/src/test/java/com/m57/hermescontrol/ui/sessions/components/SessionCardFooterTest.kt b/app/src/test/java/com/m57/hermescontrol/ui/sessions/components/SessionCardFooterTest.kt new file mode 100644 index 00000000..2daa910e --- /dev/null +++ b/app/src/test/java/com/m57/hermescontrol/ui/sessions/components/SessionCardFooterTest.kt @@ -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), + ) + } +}