mirror of
https://github.com/wu736139669/hapi.git
synced 2026-11-01 23:41:40 +00:00
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:
@@ -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)) },
|
||||
|
||||
+90
-94
@@ -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),
|
||||
|
||||
+57
@@ -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)
|
||||
}
|
||||
+7
-2
@@ -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,
|
||||
|
||||
+37
-2
@@ -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 & 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)
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user