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 display_name: String? = null,
|
||||||
val model: String? = null,
|
val model: String? = null,
|
||||||
val terminal_backend: 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
|
// Epoch seconds of the latest message (backend-computed, present on all
|
||||||
// list rows). Recency source of truth; started_at is the fallback.
|
// list rows). Recency source of truth; started_at is the fallback.
|
||||||
val last_active: Double? = null,
|
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_CONFIGURE = "profiles.configure"
|
||||||
const val PROFILES_LIST = "profiles.list"
|
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 ───────────────────────────────────────────────────────
|
// ── Interaction ───────────────────────────────────────────────────────
|
||||||
const val PROMPT_SUBMIT = "prompt.submit"
|
const val PROMPT_SUBMIT = "prompt.submit"
|
||||||
const val PROMPT_BTW = "prompt.btw"
|
const val PROMPT_BTW = "prompt.btw"
|
||||||
@@ -159,6 +164,7 @@ object WsMethods {
|
|||||||
// Binds HERMES_HOME so config/skills/pets resolve to the
|
// Binds HERMES_HOME so config/skills/pets resolve to the
|
||||||
// focused profile.
|
// focused profile.
|
||||||
"verification.status", // line 282
|
"verification.status", // line 282
|
||||||
|
PROJECTS_LIST, // methods_projects.py: @_registry.profile_scoped
|
||||||
"pet.info", // line 1253
|
"pet.info", // line 1253
|
||||||
"pet.info.meta", // line 1279
|
"pet.info.meta", // line 1279
|
||||||
"pet.cells", // line 1302
|
"pet.cells", // line 1302
|
||||||
|
|||||||
@@ -71,3 +71,23 @@ fun parseHexColor(
|
|||||||
fallback
|
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.derivedStateOf
|
||||||
import androidx.compose.runtime.getValue
|
import androidx.compose.runtime.getValue
|
||||||
import androidx.compose.runtime.mutableStateOf
|
import androidx.compose.runtime.mutableStateOf
|
||||||
|
import androidx.compose.runtime.produceState
|
||||||
import androidx.compose.runtime.remember
|
import androidx.compose.runtime.remember
|
||||||
import androidx.compose.runtime.setValue
|
import androidx.compose.runtime.setValue
|
||||||
import androidx.compose.ui.Alignment
|
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.SessionsDialogs
|
||||||
import com.m57.hermescontrol.ui.sessions.components.SessionsStatsRow
|
import com.m57.hermescontrol.ui.sessions.components.SessionsStatsRow
|
||||||
import com.m57.hermescontrol.ui.sessions.components.automationGroups
|
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
|
* 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.
|
* items of the end — a pre-load buffer so paging feels continuous.
|
||||||
*/
|
*/
|
||||||
private const val AUTO_LOAD_THRESHOLD = 6
|
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.
|
* Maps a session source string to a Material icon for visual identification.
|
||||||
@@ -764,8 +767,19 @@ fun SessionsScreen(
|
|||||||
viewModel.loadMore()
|
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 sessionCard: @Composable (SessionTreeItem) -> Unit = { item ->
|
||||||
val session = item.session
|
val session = item.session
|
||||||
|
val project =
|
||||||
|
remember(session.cwd, session.git_repo_root, state.projects) {
|
||||||
|
resolveSessionProject(session, state.projects)
|
||||||
|
}
|
||||||
BranchRow(item = item) {
|
BranchRow(item = item) {
|
||||||
SessionCard(
|
SessionCard(
|
||||||
session = session,
|
session = session,
|
||||||
@@ -780,6 +794,8 @@ fun SessionsScreen(
|
|||||||
isPinned = session.pinned == true,
|
isPinned = session.pinned == true,
|
||||||
isHidden = session.hidden == true,
|
isHidden = session.hidden == true,
|
||||||
liveStatus = state.liveStatuses[session.id],
|
liveStatus = state.liveStatuses[session.id],
|
||||||
|
project = project,
|
||||||
|
nowMillis = nowMillis,
|
||||||
highlightBackground = primaryContainer,
|
highlightBackground = primaryContainer,
|
||||||
highlightForeground = onPrimaryContainer,
|
highlightForeground = onPrimaryContainer,
|
||||||
onCardClick = {
|
onCardClick = {
|
||||||
|
|||||||
@@ -4,6 +4,7 @@ import androidx.lifecycle.ViewModel
|
|||||||
import androidx.lifecycle.viewModelScope
|
import androidx.lifecycle.viewModelScope
|
||||||
import com.m57.hermescontrol.data.local.AuthManager
|
import com.m57.hermescontrol.data.local.AuthManager
|
||||||
import com.m57.hermescontrol.data.model.BulkDeleteRequest
|
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.PruneRequest
|
||||||
import com.m57.hermescontrol.data.model.SessionInfo
|
import com.m57.hermescontrol.data.model.SessionInfo
|
||||||
import com.m57.hermescontrol.data.model.SessionLiveStatus
|
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.ChangeEventHub
|
||||||
import com.m57.hermescontrol.data.ws.ChangeEvents
|
import com.m57.hermescontrol.data.ws.ChangeEvents
|
||||||
import com.m57.hermescontrol.data.ws.ConnectionStatus
|
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.HermesSessionLiveStatusSource
|
||||||
|
import com.m57.hermescontrol.data.ws.ProjectsSource
|
||||||
import com.m57.hermescontrol.data.ws.SessionLiveStatusSource
|
import com.m57.hermescontrol.data.ws.SessionLiveStatusSource
|
||||||
import com.m57.hermescontrol.data.ws.WsEvent
|
import com.m57.hermescontrol.data.ws.WsEvent
|
||||||
import com.m57.hermescontrol.ui.common.ToastHost
|
import com.m57.hermescontrol.ui.common.ToastHost
|
||||||
@@ -99,6 +102,8 @@ data class SessionsUiState(
|
|||||||
val showHidden: Boolean = false,
|
val showHidden: Boolean = false,
|
||||||
val pinnedExpanded: Boolean = true,
|
val pinnedExpanded: Boolean = true,
|
||||||
val liveStatuses: Map<String, SessionLiveStatus> = emptyMap(),
|
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()
|
val isSearchMode: Boolean get() = searchQuery.isNotBlank()
|
||||||
|
|
||||||
@@ -114,6 +119,7 @@ data class SessionsUiState(
|
|||||||
|
|
||||||
class SessionsViewModel(
|
class SessionsViewModel(
|
||||||
private val liveStatusSource: SessionLiveStatusSource = HermesSessionLiveStatusSource(),
|
private val liveStatusSource: SessionLiveStatusSource = HermesSessionLiveStatusSource(),
|
||||||
|
private val projectsSource: ProjectsSource = HermesProjectsSource(),
|
||||||
) : ViewModel(),
|
) : ViewModel(),
|
||||||
ToastHost {
|
ToastHost {
|
||||||
private val _uiState = MutableStateFlow(SessionsUiState())
|
private val _uiState = MutableStateFlow(SessionsUiState())
|
||||||
@@ -123,6 +129,7 @@ class SessionsViewModel(
|
|||||||
private var pageJob: Job? = null
|
private var pageJob: Job? = null
|
||||||
private var statsJob: Job? = null
|
private var statsJob: Job? = null
|
||||||
private var trackingJob: Job? = null
|
private var trackingJob: Job? = null
|
||||||
|
private var projectsJob: Job? = null
|
||||||
private var trackingGeneration: Long = 0
|
private var trackingGeneration: Long = 0
|
||||||
private var liveTrackingState = SessionLiveTrackingState()
|
private var liveTrackingState = SessionLiveTrackingState()
|
||||||
private var liveStatusRefreshInFlight = false
|
private var liveStatusRefreshInFlight = false
|
||||||
@@ -277,6 +284,7 @@ class SessionsViewModel(
|
|||||||
loadEmptyCount()
|
loadEmptyCount()
|
||||||
if (trackingJob?.isActive == true) {
|
if (trackingJob?.isActive == true) {
|
||||||
refreshLiveStatuses()
|
refreshLiveStatuses()
|
||||||
|
loadProjects()
|
||||||
}
|
}
|
||||||
loadJob =
|
loadJob =
|
||||||
safeLaunchLoad(
|
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. */
|
/** Load the next page and append to the existing session list. */
|
||||||
fun loadMore() {
|
fun loadMore() {
|
||||||
val state = _uiState.value
|
val state = _uiState.value
|
||||||
@@ -905,6 +926,7 @@ class SessionsViewModel(
|
|||||||
_uiState.update { it.copy(liveStatuses = emptyMap()) }
|
_uiState.update { it.copy(liveStatuses = emptyMap()) }
|
||||||
} else {
|
} else {
|
||||||
requestLiveStatusSnapshot(++trackingGeneration)
|
requestLiveStatusSnapshot(++trackingGeneration)
|
||||||
|
loadProjects()
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -940,6 +962,8 @@ class SessionsViewModel(
|
|||||||
trackingGeneration++
|
trackingGeneration++
|
||||||
trackingJob?.cancel()
|
trackingJob?.cancel()
|
||||||
trackingJob = null
|
trackingJob = null
|
||||||
|
projectsJob?.cancel()
|
||||||
|
projectsJob = null
|
||||||
liveStatusRefreshInFlight = false
|
liveStatusRefreshInFlight = false
|
||||||
liveStatusRefreshPending = false
|
liveStatusRefreshPending = false
|
||||||
liveTrackingState = SessionLiveStatusReducer.clear()
|
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.BorderStroke
|
||||||
import androidx.compose.foundation.ExperimentalFoundationApi
|
import androidx.compose.foundation.ExperimentalFoundationApi
|
||||||
|
import androidx.compose.foundation.background
|
||||||
import androidx.compose.foundation.combinedClickable
|
import androidx.compose.foundation.combinedClickable
|
||||||
|
import androidx.compose.foundation.layout.Arrangement
|
||||||
import androidx.compose.foundation.layout.Box
|
import androidx.compose.foundation.layout.Box
|
||||||
import androidx.compose.foundation.layout.Column
|
import androidx.compose.foundation.layout.Column
|
||||||
import androidx.compose.foundation.layout.Row
|
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.padding
|
||||||
import androidx.compose.foundation.layout.size
|
import androidx.compose.foundation.layout.size
|
||||||
import androidx.compose.foundation.layout.width
|
import androidx.compose.foundation.layout.width
|
||||||
|
import androidx.compose.foundation.shape.CircleShape
|
||||||
import androidx.compose.material.icons.Icons
|
import androidx.compose.material.icons.Icons
|
||||||
import androidx.compose.material.icons.automirrored.filled.CallSplit
|
import androidx.compose.material.icons.automirrored.filled.CallSplit
|
||||||
import androidx.compose.material.icons.automirrored.filled.Send
|
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.runtime.setValue
|
||||||
import androidx.compose.ui.Alignment
|
import androidx.compose.ui.Alignment
|
||||||
import androidx.compose.ui.Modifier
|
import androidx.compose.ui.Modifier
|
||||||
|
import androidx.compose.ui.draw.clip
|
||||||
import androidx.compose.ui.graphics.Color
|
import androidx.compose.ui.graphics.Color
|
||||||
import androidx.compose.ui.graphics.vector.ImageVector
|
import androidx.compose.ui.graphics.vector.ImageVector
|
||||||
|
import androidx.compose.ui.layout.Layout
|
||||||
import androidx.compose.ui.platform.testTag
|
import androidx.compose.ui.platform.testTag
|
||||||
|
import androidx.compose.ui.res.pluralStringResource
|
||||||
import androidx.compose.ui.res.stringResource
|
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.AnnotatedString
|
||||||
import androidx.compose.ui.text.SpanStyle
|
import androidx.compose.ui.text.SpanStyle
|
||||||
import androidx.compose.ui.text.buildAnnotatedString
|
import androidx.compose.ui.text.buildAnnotatedString
|
||||||
import androidx.compose.ui.text.font.FontFamily
|
import androidx.compose.ui.text.font.FontFamily
|
||||||
import androidx.compose.ui.text.font.FontWeight
|
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.style.TextOverflow
|
||||||
import androidx.compose.ui.text.withStyle
|
import androidx.compose.ui.text.withStyle
|
||||||
|
import androidx.compose.ui.unit.Constraints
|
||||||
import androidx.compose.ui.unit.dp
|
import androidx.compose.ui.unit.dp
|
||||||
import com.m57.hermescontrol.R
|
import com.m57.hermescontrol.R
|
||||||
import com.m57.hermescontrol.data.model.SessionInfo
|
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.data.model.SessionTreeItem
|
||||||
import com.m57.hermescontrol.theme.LocalHermesStatusColors
|
import com.m57.hermescontrol.theme.LocalHermesStatusColors
|
||||||
import com.m57.hermescontrol.theme.LocalSpacing
|
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.StatusBadge
|
||||||
import com.m57.hermescontrol.ui.common.StatusBadgeType
|
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
|
@Composable
|
||||||
fun BranchRow(
|
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)
|
@OptIn(ExperimentalFoundationApi::class)
|
||||||
@Composable
|
@Composable
|
||||||
fun SessionCard(
|
fun SessionCard(
|
||||||
@@ -148,6 +171,8 @@ fun SessionCard(
|
|||||||
isPinned: Boolean,
|
isPinned: Boolean,
|
||||||
isHidden: Boolean = false,
|
isHidden: Boolean = false,
|
||||||
liveStatus: SessionLiveStatus? = null,
|
liveStatus: SessionLiveStatus? = null,
|
||||||
|
project: SessionProject? = null,
|
||||||
|
nowMillis: Long = System.currentTimeMillis(),
|
||||||
highlightBackground: Color,
|
highlightBackground: Color,
|
||||||
highlightForeground: Color,
|
highlightForeground: Color,
|
||||||
onCardClick: () -> Unit,
|
onCardClick: () -> Unit,
|
||||||
@@ -160,7 +185,7 @@ fun SessionCard(
|
|||||||
) {
|
) {
|
||||||
val spacing = LocalSpacing.current
|
val spacing = LocalSpacing.current
|
||||||
val statusColors = LocalHermesStatusColors.current
|
val statusColors = LocalHermesStatusColors.current
|
||||||
val srcIcon = sourceIcon(session.source)
|
val palette = sessionCardPalette()
|
||||||
var menuExpanded by remember { mutableStateOf(false) }
|
var menuExpanded by remember { mutableStateOf(false) }
|
||||||
|
|
||||||
Card(
|
Card(
|
||||||
@@ -172,10 +197,7 @@ fun SessionCard(
|
|||||||
onClick = onCardClick,
|
onClick = onCardClick,
|
||||||
onLongClick = { if (!isSelecting) menuExpanded = true },
|
onLongClick = { if (!isSelecting) menuExpanded = true },
|
||||||
),
|
),
|
||||||
colors =
|
colors = CardDefaults.cardColors(containerColor = palette.card),
|
||||||
CardDefaults.cardColors(
|
|
||||||
containerColor = MaterialTheme.colorScheme.surfaceContainer,
|
|
||||||
),
|
|
||||||
border =
|
border =
|
||||||
if (isSelected) {
|
if (isSelected) {
|
||||||
BorderStroke(2.dp, MaterialTheme.colorScheme.primary)
|
BorderStroke(2.dp, MaterialTheme.colorScheme.primary)
|
||||||
@@ -201,86 +223,57 @@ fun SessionCard(
|
|||||||
Spacer(modifier = Modifier.width(spacing.sm))
|
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)) {
|
Column(modifier = Modifier.weight(1f)) {
|
||||||
Row(verticalAlignment = Alignment.CenterVertically) {
|
SessionCardHeader(
|
||||||
Text(
|
session = session,
|
||||||
text =
|
project = project,
|
||||||
if (query.isNotBlank()) {
|
isFork = isFork && !isSelecting,
|
||||||
highlightText(displayTitle, query, highlightBackground, highlightForeground)
|
forkDepth = forkDepth,
|
||||||
} else {
|
isPinned = isPinned,
|
||||||
AnnotatedString(displayTitle)
|
nowMillis = nowMillis,
|
||||||
},
|
palette = palette,
|
||||||
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),
|
|
||||||
)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
Spacer(modifier = Modifier.height(spacing.xs))
|
Spacer(modifier = Modifier.height(spacing.xs))
|
||||||
|
|
||||||
Row(
|
Text(
|
||||||
verticalAlignment = Alignment.CenterVertically,
|
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(
|
||||||
text = stringResource(R.string.history_message_count, session.message_count ?: 0),
|
text = preview,
|
||||||
style = MaterialTheme.typography.bodySmall,
|
style = MaterialTheme.typography.bodyMedium.copy(textDirection = TextDirection.Content),
|
||||||
color = MaterialTheme.colorScheme.onSurfaceVariant,
|
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_empty_desc">ستظهر جلسات المحادثة السابقة من Hermes هنا.</string>
|
||||||
<string name="history_untitled">بلا عنوان</string>
|
<string name="history_untitled">بلا عنوان</string>
|
||||||
<string name="history_message_count">%1$d رسالة</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="history_load_more">تحميل المزيد من الجلسات…</string>
|
||||||
<string name="sessions_search_placeholder">البحث في الجلسات…</string>
|
<string name="sessions_search_placeholder">البحث في الجلسات…</string>
|
||||||
<string name="sessions_search_empty_title">لا توجد نتائج</string>
|
<string name="sessions_search_empty_title">لا توجد نتائج</string>
|
||||||
|
|||||||
@@ -434,6 +434,29 @@
|
|||||||
<string name="history_empty_desc">이전 채팅 세션이 여기에 표시됩니다.</string>
|
<string name="history_empty_desc">이전 채팅 세션이 여기에 표시됩니다.</string>
|
||||||
<string name="history_untitled">제목 없음</string>
|
<string name="history_untitled">제목 없음</string>
|
||||||
<string name="history_message_count">%1$d개 메시지</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="history_load_more">더 불러오기…</string>
|
||||||
|
|
||||||
<!-- Sessions Stats -->
|
<!-- Sessions Stats -->
|
||||||
|
|||||||
@@ -532,6 +532,29 @@
|
|||||||
<string name="history_empty_desc">之前的 Hermes 对话会话将显示在这里。</string>
|
<string name="history_empty_desc">之前的 Hermes 对话会话将显示在这里。</string>
|
||||||
<string name="history_untitled">未命名</string>
|
<string name="history_untitled">未命名</string>
|
||||||
<string name="history_message_count">%1$d 条消息</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="history_load_more">加载更多会话…</string>
|
||||||
<string name="sessions_search_placeholder">搜索会话…</string>
|
<string name="sessions_search_placeholder">搜索会话…</string>
|
||||||
<string name="sessions_search_empty_title">无结果</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_empty_desc">Previous chat sessions from Hermes will appear here.</string>
|
||||||
<string name="history_untitled">Untitled</string>
|
<string name="history_untitled">Untitled</string>
|
||||||
<string name="history_message_count">%1$d messages</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>
|
<string name="history_load_more">Load more sessions…</string>
|
||||||
|
|
||||||
<!-- Sessions Stats -->
|
<!-- 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
|
package com.m57.hermescontrol.ui.sessions
|
||||||
|
|
||||||
import com.m57.hermescontrol.data.model.LiveSessionSnapshot
|
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.SessionInfo
|
||||||
import com.m57.hermescontrol.data.model.SessionListResponse
|
import com.m57.hermescontrol.data.model.SessionListResponse
|
||||||
import com.m57.hermescontrol.data.model.SessionLiveStatus
|
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.ChangeEventHub
|
||||||
import com.m57.hermescontrol.data.ws.ChangeEvents
|
import com.m57.hermescontrol.data.ws.ChangeEvents
|
||||||
import com.m57.hermescontrol.data.ws.ConnectionStatus
|
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.SessionLiveStatusSource
|
||||||
import com.m57.hermescontrol.data.ws.WsEvent
|
import com.m57.hermescontrol.data.ws.WsEvent
|
||||||
import io.mockk.coEvery
|
import io.mockk.coEvery
|
||||||
@@ -64,8 +66,22 @@ class SessionsViewModelTest {
|
|||||||
override val connectionStatus: StateFlow<ConnectionStatus> = connectionStatusFlow
|
override val connectionStatus: StateFlow<ConnectionStatus> = connectionStatusFlow
|
||||||
}
|
}
|
||||||
|
|
||||||
private fun createViewModel(source: SessionLiveStatusSource = FakeSessionLiveStatusSource()): SessionsViewModel {
|
private class FakeProjectsSource : ProjectsSource {
|
||||||
val vm = SessionsViewModel(liveStatusSource = source)
|
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()
|
testDispatcher.scheduler.advanceUntilIdle()
|
||||||
return vm
|
return vm
|
||||||
}
|
}
|
||||||
@@ -83,6 +99,96 @@ class SessionsViewModelTest {
|
|||||||
unmockkAll()
|
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
|
@Test
|
||||||
fun `blank query resets search mode`() {
|
fun `blank query resets search mode`() {
|
||||||
val vm = createViewModel()
|
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