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
@@ -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)
}
}