fix(android): device-feedback round 1

- decode fs.stat-derived epoch fields leniently (fractional mtimeMs from
  real hubs broke machines/files decode and pinned the offline banner)
- offline banner: only a failed sessions fetch counts; machines/baseline
  failures are advisory; live SSE emission clears it and seeds the unread
  baseline (all-rows-unread gray dot cascade)
- session row: single weighted title (short names no longer truncated at
  half width), unread dot moved beside the timestamp
- composer restyle: borderless input pill with inline mic, hand-drawn
  vector glyphs replacing emoji icons, 42dp round actions, park-draft
  relocated to the session overflow menu
This commit is contained in:
weishu
2026-08-18 16:27:32 +08:00
parent c4832f37e0
commit b9d5f803b2
12 changed files with 330 additions and 103 deletions
@@ -330,6 +330,13 @@ fun ChatScreen(
onRename = { renameDialogOpen = true },
onReopen = viewModel::reopenSession,
onDelete = { deleteDialogOpen = true },
// Draft-level action, relocated from the composer's
// own overflow (one less button in the input bar).
onParkDraft = if (viewModel.scratchlistEnabled && composerState.text.isNotBlank()) {
viewModel::parkComposerDraft
} else {
null
},
)
},
)
@@ -361,7 +368,6 @@ fun ChatScreen(
dictation = if (dictation != null) dictationState else null,
onDictationToggle = onDictationToggle,
onDictationCancel = { dictation?.cancel() },
onParkDraft = if (viewModel.scratchlistEnabled) viewModel::parkComposerDraft else null,
)
}
},
@@ -462,6 +468,8 @@ private fun SessionOverflowMenu(
onRename: () -> Unit,
onReopen: () -> Unit,
onDelete: () -> Unit,
/** null ⇒ hidden (scratchlist off or empty composer). */
onParkDraft: (() -> Unit)? = null,
) {
var open by remember { mutableStateOf(false) }
IconButton(onClick = { open = true }) {
@@ -475,6 +483,15 @@ private fun SessionOverflowMenu(
onRename()
},
)
if (onParkDraft != null) {
DropdownMenuItem(
text = { Text(stringResource(R.string.chat_park_draft)) },
onClick = {
open = false
onParkDraft()
},
)
}
if (!active) {
DropdownMenuItem(
text = { Text(stringResource(R.string.sessions_action_reopen)) },
@@ -14,6 +14,7 @@ import androidx.compose.foundation.layout.padding
import androidx.compose.foundation.layout.size
import androidx.compose.foundation.layout.widthIn
import androidx.compose.foundation.lazy.LazyColumn
import androidx.compose.foundation.text.BasicTextField
import androidx.compose.foundation.lazy.LazyRow
import androidx.compose.foundation.lazy.items
import androidx.compose.foundation.shape.CircleShape
@@ -22,8 +23,8 @@ import androidx.compose.material3.CircularProgressIndicator
import androidx.compose.material3.DropdownMenu
import androidx.compose.material3.DropdownMenuItem
import androidx.compose.material3.HorizontalDivider
import androidx.compose.material3.Icon
import androidx.compose.material3.MaterialTheme
import androidx.compose.material3.OutlinedTextField
import androidx.compose.material3.Surface
import androidx.compose.material3.Text
import androidx.compose.material3.TextButton
@@ -37,6 +38,7 @@ import androidx.compose.runtime.setValue
import androidx.compose.ui.Alignment
import androidx.compose.ui.Modifier
import androidx.compose.ui.draw.clip
import androidx.compose.ui.graphics.SolidColor
import androidx.compose.ui.graphics.graphicsLayer
import androidx.compose.ui.layout.ContentScale
import androidx.compose.ui.res.stringResource
@@ -84,8 +86,6 @@ fun ChatComposer(
dictation: DictationState? = null,
onDictationToggle: () -> Unit = {},
onDictationCancel: () -> Unit = {},
/** null ⇒ composer overflow hidden; set = "Park draft to scratchlist" (B-M4d). */
onParkDraft: (() -> Unit)? = null,
) {
Surface(color = MaterialTheme.colorScheme.surface, modifier = modifier.fillMaxWidth()) {
Column(modifier = Modifier.padding(horizontal = 10.dp, vertical = 6.dp)) {
@@ -120,30 +120,63 @@ fun ChatComposer(
modifier = Modifier.padding(bottom = 6.dp),
)
}
Row(verticalAlignment = Alignment.Bottom) {
Row(
verticalAlignment = Alignment.Bottom,
horizontalArrangement = Arrangement.spacedBy(6.dp),
) {
if (onAddAttachment != null) {
AddAttachmentButton(onClick = onAddAttachment)
}
if (onParkDraft != null) {
ComposerOverflowButton(
hasText = state.text.isNotBlank(),
onParkDraft = onParkDraft,
ComposerRoundButton(
glyph = PlusGlyph,
contentDescription = stringResource(R.string.chat_composer_add_attachment),
onClick = onAddAttachment,
)
}
OutlinedTextField(
value = state.text,
onValueChange = onTextChange,
placeholder = { Text(stringResource(R.string.chat_composer_placeholder)) },
minLines = 1,
maxLines = 6,
shape = RoundedCornerShape(20.dp),
// The input pill: borderless multiline field with the mic
// inline at its trailing edge (chat-bar idiom, not a form
// field — OutlinedTextField's label chrome read as broken).
Surface(
color = MaterialTheme.colorScheme.surfaceContainerHigh,
shape = RoundedCornerShape(22.dp),
modifier = Modifier.weight(1f),
)
if (dictation != null) {
MicButton(state = dictation, onToggle = onDictationToggle)
) {
Row(verticalAlignment = Alignment.Bottom) {
BasicTextField(
value = state.text,
onValueChange = onTextChange,
textStyle = MaterialTheme.typography.bodyLarge.copy(
color = MaterialTheme.colorScheme.onSurface,
),
cursorBrush = SolidColor(MaterialTheme.colorScheme.primary),
maxLines = 6,
modifier = Modifier
.weight(1f)
.padding(start = 14.dp, end = 4.dp, top = 11.dp, bottom = 11.dp),
decorationBox = { inner ->
Box {
if (state.text.isEmpty()) {
Text(
text = stringResource(R.string.chat_composer_placeholder),
style = MaterialTheme.typography.bodyLarge,
color = MaterialTheme.hapi.hint,
)
}
inner()
}
},
)
if (dictation != null) {
MicButton(state = dictation, onToggle = onDictationToggle)
}
}
}
if (state.canSteer) {
AbortButton(onAbort)
ComposerRoundButton(
glyph = StopGlyph,
contentDescription = stringResource(R.string.chat_composer_abort),
onClick = onAbort,
color = MaterialTheme.colorScheme.errorContainer,
contentColor = MaterialTheme.colorScheme.onErrorContainer,
)
}
SendButton(state = state, attachments = attachments, onSend = onSend, onSendSteer = onSendSteer)
}
@@ -254,20 +287,27 @@ internal fun formatChipSize(bytes: Long): String = when {
else -> "$bytes B"
}
/** The "+" button opening the attachment picker sheet. */
/** Shared 42 dp round action button (glyph icon, tinted via contentColor). */
@Composable
private fun AddAttachmentButton(onClick: () -> Unit) {
private fun ComposerRoundButton(
glyph: androidx.compose.ui.graphics.vector.ImageVector,
contentDescription: String?,
onClick: () -> Unit,
modifier: Modifier = Modifier,
color: androidx.compose.ui.graphics.Color = MaterialTheme.colorScheme.surfaceContainerHigh,
contentColor: androidx.compose.ui.graphics.Color = MaterialTheme.colorScheme.onSurfaceVariant,
enabled: Boolean = true,
) {
Surface(
color = MaterialTheme.colorScheme.surfaceContainerHigh,
contentColor = MaterialTheme.colorScheme.onSurfaceVariant,
color = color,
contentColor = contentColor,
shape = CircleShape,
enabled = enabled,
onClick = onClick,
modifier = Modifier
.padding(end = 6.dp, bottom = 4.dp)
.size(44.dp),
modifier = modifier.size(42.dp),
) {
Box(contentAlignment = Alignment.Center) {
Text(text = "+", fontSize = 22.sp)
Icon(glyph, contentDescription = contentDescription, modifier = Modifier.size(20.dp))
}
}
}
@@ -379,7 +419,7 @@ private fun MicButton(state: DictationState, onToggle: () -> Unit) {
color = if (recording) {
MaterialTheme.colorScheme.errorContainer
} else {
MaterialTheme.colorScheme.surfaceContainerHigh
androidx.compose.ui.graphics.Color.Transparent
},
contentColor = if (recording) {
MaterialTheme.colorScheme.onErrorContainer
@@ -390,18 +430,26 @@ private fun MicButton(state: DictationState, onToggle: () -> Unit) {
enabled = !busy,
onClick = onToggle,
modifier = Modifier
.padding(start = 6.dp, bottom = 4.dp)
.size(44.dp),
.padding(end = 4.dp, bottom = 4.dp)
.size(38.dp),
) {
Box(contentAlignment = Alignment.Center) {
when {
busy -> CircularProgressIndicator(
modifier = Modifier.size(18.dp),
modifier = Modifier.size(16.dp),
strokeWidth = 2.dp,
color = MaterialTheme.hapi.hint,
)
recording -> Text(text = "■", fontSize = 16.sp)
else -> Text(text = "🎙", fontSize = 17.sp)
recording -> Icon(
StopGlyph,
contentDescription = stringResource(R.string.chat_composer_stop_recording),
modifier = Modifier.size(18.dp),
)
else -> Icon(
MicGlyph,
contentDescription = stringResource(R.string.chat_composer_mic),
modifier = Modifier.size(19.dp),
)
}
}
}
@@ -409,60 +457,6 @@ private fun MicButton(state: DictationState, onToggle: () -> Unit) {
// ---------------------------------------------------------------- actions --
/**
* Composer overflow (B-M4d): draft-level actions that are not sends. Only
* "Park draft to scratchlist" for now — it moves the current text into the
* session's scratchlist and clears the composer.
*/
@Composable
private fun ComposerOverflowButton(
hasText: Boolean,
onParkDraft: () -> Unit,
) {
var open by remember { mutableStateOf(false) }
Surface(
color = MaterialTheme.colorScheme.surfaceContainerHigh,
contentColor = MaterialTheme.colorScheme.onSurfaceVariant,
shape = CircleShape,
onClick = { open = true },
modifier = Modifier
.padding(end = 6.dp, bottom = 4.dp)
.size(44.dp),
) {
Box(contentAlignment = Alignment.Center) {
Text(text = "⋯", fontSize = 18.sp)
}
}
DropdownMenu(expanded = open, onDismissRequest = { open = false }) {
DropdownMenuItem(
text = { Text(stringResource(R.string.chat_park_draft)) },
enabled = hasText,
onClick = {
open = false
onParkDraft()
},
)
}
}
@Composable
private fun AbortButton(onAbort: () -> Unit) {
Surface(
color = MaterialTheme.colorScheme.errorContainer,
contentColor = MaterialTheme.colorScheme.onErrorContainer,
shape = CircleShape,
onClick = onAbort,
modifier = Modifier
.padding(start = 6.dp, bottom = 4.dp)
.size(44.dp),
) {
Box(contentAlignment = Alignment.Center) {
// Stop square.
Text(text = "■", fontSize = 16.sp)
}
}
}
@OptIn(ExperimentalFoundationApi::class)
@Composable
private fun SendButton(
@@ -482,8 +476,7 @@ private fun SendButton(
Box(
modifier = Modifier
.padding(start = 6.dp, bottom = 4.dp)
.size(44.dp)
.size(42.dp)
.clip(CircleShape),
) {
Surface(
@@ -499,7 +492,7 @@ private fun SendButton(
},
shape = CircleShape,
modifier = Modifier
.size(44.dp)
.size(42.dp)
.combinedClickable(
enabled = enabled,
onClick = onSend,
@@ -516,7 +509,11 @@ private fun SendButton(
color = MaterialTheme.hapi.hint,
)
} else {
Text(text = "➤", fontSize = 18.sp)
Icon(
ArrowUpGlyph,
contentDescription = stringResource(R.string.chat_composer_send),
modifier = Modifier.size(20.dp),
)
}
}
}
@@ -553,7 +550,6 @@ private fun ChatComposerPreview() {
),
onTextChange = {}, onSend = {}, onSendSteer = {}, onAbort = {},
dictation = DictationState.Idle,
onParkDraft = {},
)
ChatComposer(
state = ComposerUiState(text = "Sending…", isSending = true, canSteer = false),
@@ -0,0 +1,57 @@
package app.hapi.companion.feature.chat.composer
import androidx.compose.ui.graphics.Color
import androidx.compose.ui.graphics.SolidColor
import androidx.compose.ui.graphics.StrokeCap
import androidx.compose.ui.graphics.StrokeJoin
import androidx.compose.ui.graphics.vector.ImageVector
import androidx.compose.ui.graphics.vector.addPathNodes
import androidx.compose.ui.unit.dp
// Hand-drawn 24 dp stroke glyphs (the FolderGlyph precedent) — no
// material-icons-extended dependency, and no emoji-as-icon (device fonts
// render those inconsistently, which is exactly what made the first composer
// build look broken).
private fun strokeIcon(name: String, pathData: String, strokeWidth: Float = 1.8f): ImageVector =
ImageVector.Builder(
name = name,
defaultWidth = 24.dp,
defaultHeight = 24.dp,
viewportWidth = 24f,
viewportHeight = 24f,
).apply {
addPath(
pathData = addPathNodes(pathData),
fill = null,
// Any opaque stroke works: Icon() recolors via ColorFilter tint.
stroke = SolidColor(Color.Black),
strokeLineWidth = strokeWidth,
strokeLineCap = StrokeCap.Round,
strokeLineJoin = StrokeJoin.Round,
)
}.build()
/** "+" — opens the attachment picker sheet. */
internal val PlusGlyph: ImageVector by lazy {
strokeIcon("HapiPlus", "M12 5 L12 19 M5 12 L19 12", 2f)
}
/** Microphone — dictation toggle. */
internal val MicGlyph: ImageVector by lazy {
strokeIcon(
"HapiMic",
"M12 3 a3 3 0 0 1 3 3 v5 a3 3 0 0 1 -6 0 v-5 a3 3 0 0 1 3 -3 " +
"M6.5 11 a5.5 5.5 0 0 0 11 0 M12 16.5 L12 20.5 M9 20.5 L15 20.5",
)
}
/** Stop square — abort while a turn runs, and the recording-stop state. */
internal val StopGlyph: ImageVector by lazy {
strokeIcon("HapiStop", "M8 8 h8 v8 h-8 z", 2f)
}
/** Upward arrow — send. */
internal val ArrowUpGlyph: ImageVector by lazy {
strokeIcon("HapiArrowUp", "M12 19 L12 5.5 M6.5 11 L12 5.5 L17.5 11", 2.2f)
}
@@ -121,6 +121,7 @@ fun SessionListScreen(
is SessionListError.RenameFailed -> R.string.sessions_error_rename
is SessionListError.DeleteFailed -> R.string.sessions_error_delete
is SessionListError.ReopenFailed -> R.string.sessions_error_reopen
is SessionListError.MachinesRefreshFailed -> R.string.sessions_error_machines
},
)
// The 409-on-delete conflict gets explicit wording; other messages
@@ -311,19 +312,23 @@ private fun SessionRow(
Spacer(modifier = Modifier.width(10.dp))
Column(modifier = Modifier.weight(1f)) {
Row(verticalAlignment = Alignment.CenterVertically) {
// One weighted element only: the title takes ALL leftover
// width (start-aligned, ellipsis on true overflow). Splitting
// the slack with a weighted trailing spacer truncated even
// short names at ~half the row (device-observed).
Text(
text = row.title,
style = MaterialTheme.typography.bodyLarge,
fontWeight = if (row.unread) FontWeight.SemiBold else FontWeight.Normal,
maxLines = 1,
overflow = TextOverflow.Ellipsis,
modifier = Modifier.weight(1f, fill = false),
modifier = Modifier.weight(1f),
)
if (row.unread) {
Spacer(modifier = Modifier.width(6.dp))
UnreadDot()
}
Spacer(modifier = Modifier.weight(1f))
Spacer(modifier = Modifier.width(8.dp))
Text(
text = localizedRelativeAge(summary.updatedAt),
style = MaterialTheme.typography.labelSmall,
@@ -16,6 +16,7 @@ import kotlinx.coroutines.flow.SharingStarted
import kotlinx.coroutines.flow.StateFlow
import kotlinx.coroutines.flow.asSharedFlow
import kotlinx.coroutines.flow.combine
import kotlinx.coroutines.flow.drop
import kotlinx.coroutines.flow.stateIn
import kotlinx.coroutines.launch
@@ -97,6 +98,24 @@ class SessionListViewModel(
private var refreshJob: Job? = null
init {
// Live SSE data is proof of connectivity: any list emission after a
// failed refresh clears the stale offline banner (a device-observed
// contradiction — active sessions updating under an "offline" banner).
scope.launch {
sessionStore.sessions.drop(1).collect { sessions ->
if (isOffline.value) isOffline.value = false
// Seed the unread baseline from SSE data too — when REST
// refresh fails but the stream works, unseeded watermarks
// would light every row's unread dot (once-per-scope inside
// the store, so repeated calls are no-ops).
if (sessions.isNotEmpty()) {
runCatching { lastSeenStore.initializeBaseline(hubKey, sessions) }
}
}
}
}
val uiState: StateFlow<SessionListUiState> = combine(
sessionStore.sessions,
machineStore.machines,
@@ -149,13 +168,24 @@ class SessionListViewModel(
refreshJob = scope.launch {
isRefreshing.value = true
try {
// Only a failed *sessions* fetch means "offline". Machines and
// the unread baseline are secondary: their failures must not
// pin the offline banner over a perfectly live list (this
// exact cascade shipped once — a machines decode error kept
// the banner up while SSE streamed active sessions).
sessionStore.refresh()
machineStore.refresh()
isOffline.value = false
hasRefreshedOnce.value = true
// First successful list for this hub seeds the unread baseline
// so historical sessions do not all light up as unread.
lastSeenStore.initializeBaseline(hubKey, sessionStore.sessions.value)
runCatching { lastSeenStore.initializeBaseline(hubKey, sessionStore.sessions.value) }
try {
machineStore.refresh()
} catch (cancellation: CancellationException) {
throw cancellation
} catch (error: Exception) {
_errors.tryEmit(SessionListError.MachinesRefreshFailed(error.message))
}
} catch (cancellation: CancellationException) {
throw cancellation
} catch (_: Exception) {
@@ -360,4 +390,9 @@ sealed interface SessionListError {
val stillActive: Boolean = false,
) : SessionListError
data class ReopenFailed(override val sessionId: String, override val message: String?) : SessionListError
/** Machines list refresh failed — advisory only, never the offline banner. */
data class MachinesRefreshFailed(override val message: String?) : SessionListError {
override val sessionId: String get() = ""
}
}
@@ -221,6 +221,7 @@
<string name="sessions_error_rename">重命名失败</string>
<string name="sessions_error_delete">删除失败</string>
<string name="sessions_error_reopen">重新打开失败</string>
<string name="sessions_error_machines">无法加载机器列表</string>
<string name="sessions_error_delete_active">会话仍在运行 — 请先归档</string>
<string name="sessions_reopen_failed_fallback">无法重新打开会话</string>
<string name="sessions_rename_title">重命名会话</string>
@@ -277,6 +278,11 @@
<string name="chat_recording">● 录音中… %1$s</string>
<string name="chat_cancel">取消</string>
<string name="chat_park_draft">将草稿存入草稿夹</string>
<string name="chat_composer_add_attachment">添加附件</string>
<string name="chat_composer_mic">开始语音输入</string>
<string name="chat_composer_stop_recording">停止录音</string>
<string name="chat_composer_send">发送</string>
<string name="chat_composer_abort">中止当前回合</string>
<string name="chat_send_steer">发送并插入当前回合</string>
<string name="chat_queued_one">1 条队列消息</string>
<string name="chat_queued_many">%1$d 条队列消息</string>
@@ -214,6 +214,7 @@
<string name="sessions_error_rename">Rename failed</string>
<string name="sessions_error_delete">Delete failed</string>
<string name="sessions_error_reopen">Reopen failed</string>
<string name="sessions_error_machines">Could not load machines</string>
<string name="sessions_error_delete_active">Session is still active — archive it first</string>
<string name="sessions_reopen_failed_fallback">Failed to reopen session</string>
<string name="sessions_rename_title">Rename session</string>
@@ -270,6 +271,11 @@
<string name="chat_recording">● Recording… %1$s</string>
<string name="chat_cancel">Cancel</string>
<string name="chat_park_draft">Park draft to scratchlist</string>
<string name="chat_composer_add_attachment">Add attachment</string>
<string name="chat_composer_mic">Start dictation</string>
<string name="chat_composer_stop_recording">Stop recording</string>
<string name="chat_composer_send">Send</string>
<string name="chat_composer_abort">Stop the current turn</string>
<string name="chat_send_steer">Send &amp; steer into current turn</string>
<string name="chat_queued_one">1 queued message</string>
<string name="chat_queued_many">%1$d queued messages</string>
@@ -207,7 +207,8 @@ data class MachineDirectoryEntry(
/** `'file' | 'directory' | 'other'`. */
val type: String,
val size: Long? = null,
/** Epoch ms. */
/** Epoch ms (fs mtime — may arrive fractional; see [LenientEpochMs]). */
@Serializable(with = LenientEpochMs::class)
val modified: Long? = null,
val isGitRepo: Boolean? = null,
)
@@ -29,7 +29,8 @@ data class FileReadResponse(
val content: String? = null,
/** Bytes on disk. */
val size: Long? = null,
/** Epoch ms. */
/** Epoch ms (fs mtime — may arrive fractional; see [LenientEpochMs]). */
@Serializable(with = LenientEpochMs::class)
val modified: Long? = null,
val error: String? = null,
)
@@ -51,7 +52,8 @@ data class FileSearchItem(
/** `'file' | 'folder'` (search results are files in practice). */
val fileType: String,
val size: Long? = null,
/** Epoch ms. */
/** Epoch ms (fs mtime — may arrive fractional; see [LenientEpochMs]). */
@Serializable(with = LenientEpochMs::class)
val modified: Long? = null,
)
@@ -73,6 +75,7 @@ data class DirectoryEntry(
/** `'file' | 'directory' | 'other'`. */
val type: String,
val size: Long? = null,
/** Epoch ms. */
/** Epoch ms (fs mtime — may arrive fractional; see [LenientEpochMs]). */
@Serializable(with = LenientEpochMs::class)
val modified: Long? = null,
)
@@ -0,0 +1,30 @@
package app.hapi.protocol.wire
import kotlinx.serialization.KSerializer
import kotlinx.serialization.descriptors.PrimitiveKind
import kotlinx.serialization.descriptors.PrimitiveSerialDescriptor
import kotlinx.serialization.descriptors.SerialDescriptor
import kotlinx.serialization.encoding.Decoder
import kotlinx.serialization.encoding.Encoder
/**
* Epoch-milliseconds field that the hub may deliver as a **fractional**
* number: anything derived from `fs.stat` `mtimeMs` (file `modified`,
* machine `startedCliMtimeMs`/`installedCliMtimeMs`) carries sub-millisecond
* precision, e.g. `1786932205158.1177` — observed in real hub data. zod's
* `z.number()` accepts that; a plain Kotlin `Long` decode throws and (before
* this) took the whole machines/files response down with it.
*
* Decodes any JSON number and truncates to ms. Encoding writes a plain long
* (we never need to reproduce the fraction).
*/
object LenientEpochMs : KSerializer<Long> {
override val descriptor: SerialDescriptor =
PrimitiveSerialDescriptor("app.hapi.protocol.wire.LenientEpochMs", PrimitiveKind.DOUBLE)
override fun deserialize(decoder: Decoder): Long = decoder.decodeDouble().toLong()
override fun serialize(encoder: Encoder, value: Long) {
encoder.encodeLong(value)
}
}
@@ -37,7 +37,9 @@ data class MachineMetadata(
val workspaceRoots: List<String>? = null,
/** Machine-scoped RPC capability ids this runner registers. */
val capabilities: List<String>? = null,
@Serializable(with = LenientEpochMs::class)
val startedCliMtimeMs: Long? = null,
@Serializable(with = LenientEpochMs::class)
val installedCliMtimeMs: Long? = null,
val supervisedRestart: Boolean? = null,
)
@@ -0,0 +1,69 @@
package app.hapi.protocol.wire
import kotlin.test.Test
import kotlin.test.assertEquals
/**
* Regression: `fs.stat`-derived epoch fields arrive **fractional** from real
* hubs (`"startedCliMtimeMs":1786932205158.1177` observed in a live DB); a
* plain `Long` decode threw and took the whole machines/files response down —
* on device that pinned an "offline" banner over a perfectly live list.
*/
class LenientEpochMsTest {
@Test
fun `machine metadata decodes fractional cli mtimes`() {
val machine = HapiJson.decodeFromString(
Machine.serializer(),
"""
{"id":"m1","namespace":"default","seq":1,"createdAt":1,"updatedAt":2,
"active":true,"activeAt":3,"metadataVersion":1,"runnerStateVersion":0,
"metadata":{"host":"h","platform":"linux","happyCliVersion":"0.28.0",
"startedCliMtimeMs":1786932205158.1177,
"installedCliMtimeMs":1786501585709.12}}
""".trimIndent(),
)
assertEquals(1786932205158L, machine.metadata?.startedCliMtimeMs)
assertEquals(1786501585709L, machine.metadata?.installedCliMtimeMs)
}
@Test
fun `integral mtimes still decode`() {
val machine = HapiJson.decodeFromString(
Machine.serializer(),
"""
{"id":"m1","namespace":"default","seq":1,"createdAt":1,"updatedAt":2,
"active":true,"activeAt":3,"metadataVersion":1,"runnerStateVersion":0,
"metadata":{"host":"h","platform":"linux","happyCliVersion":"0.28.0",
"startedCliMtimeMs":1786932205158}}
""".trimIndent(),
)
assertEquals(1786932205158L, machine.metadata?.startedCliMtimeMs)
}
@Test
fun `directory entries and file reads decode fractional modified`() {
val dir = HapiJson.decodeFromString(
DirectoryEntry.serializer(),
"""{"name":"src","type":"directory","modified":1786932205158.9}""",
)
assertEquals(1786932205158L, dir.modified)
val machineDir = HapiJson.decodeFromString(
MachineDirectoryEntry.serializer(),
"""{"name":"repo","type":"directory","modified":1786932205158.5,"isGitRepo":true}""",
)
assertEquals(1786932205158L, machineDir.modified)
val read = HapiJson.decodeFromString(
FileReadResponse.serializer(),
"""{"success":true,"content":"aGk=","size":2,"modified":1786932205158.0001}""",
)
assertEquals(1786932205158L, read.modified)
val search = HapiJson.decodeFromString(
FileSearchItem.serializer(),
"""{"fileName":"a.ts","filePath":"src","fullPath":"src/a.ts","fileType":"file","modified":1786932205159.75}""",
)
assertEquals(1786932205159L, search.modified)
}
}