feat(chat): message persistence + background notifications (#2)
* feat(chat): message persistence + background notifications
Two critical gaps for daily-driver use:
1. Message persistence (Room database):
- New ChatMessageEntity / ChatMessageDao / HermesDatabase layer
- Every user message, streaming token, tool result, and assistant
reply is written to a local Room DB as it arrives
- switchSession loads cached messages instantly from Room before
the REST call returns — no more blank screen while loading
- Session resume also loads from cache first
- Messages survive app kills, process death, and configuration
changes
- ChatViewModel changed from ViewModel() to AndroidViewModel(app)
to access the database context
2. Background notifications (foreground service):
- ChatNotificationService keeps the WebSocket alive while the app
is backgrounded and posts a high-priority notification when a
MessageComplete or ClarifyRequest event arrives while the app
is not in the foreground
- Lifecycle wired via LifecycleEventObserver in ChatScreen:
ON_STOP starts the service, ON_START stops it
- Tapping the notification returns to the chat
- New permissions: POST_NOTIFICATIONS, FOREGROUND_SERVICE,
FOREGROUND_SERVICE_DATA_SYNC
Build changes:
- Added Room 2.7.1 + KSP to version catalog
- Added WorkManager 2.10.0 (reserved for future polling fallback)
- Updated ChatViewModelTest to mock HermesDatabase + use
AndroidViewModel constructor
* fix(build): use KSP 2.3.9 (new versioning scheme for Kotlin 2.3.x)
* fix(build): add Room schemaDirectory (required by Room Gradle plugin)
* fix(compile): add else branch to WsEvent when expression (exhaustiveness)
* ci(release): add release workflow + R8 minification + proguard rules
New .github/workflows/release.yml:
- Triggers on tag pushes (v*) and manual workflow_dispatch
- Runs full CI (ktlint + lint + test) before building
- Builds assembleRelease with version injection from tag
- Creates a GitHub Release with auto-generated changelog
- Attaches the release APK to the release
Build changes:
- Release builds now use R8 minification (isMinifyEnabled=true)
and resource shrinking (isShrinkResources=true) for smaller APKs
- versionCode/versionName injectable via -PversionCode / -PversionName
Gradle properties (passed by CI from the git tag)
- Local dev builds default to versionCode=1, versionName=1.0-dev
ProGuard rules:
- Keep all data model classes (Gson reflection)
- Keep Room entities and RoomDatabase subclasses
- Keep OkHttp/Retrofit interface methods
- Suppress warnings for Compose, Security Crypto, Coroutines
* feat(ci): release workflow + proguard rules + test fix
Release workflow (.github/workflows/release.yml):
- Triggers on tag push (v*) or manual workflow_dispatch
- Two jobs: ci (ktlint+lint+test) → build-release
- Builds a minified, shrunk release APK with version from tag
- Creates a GitHub Release with auto-generated release notes
- Attaches the APK as a release asset
Build config:
- versionCode/versionName now injected from CI via -P flags
- Release build enables isMinifyEnabled + isShrinkResources
- proguard-rules.pro: keep rules for Gson models, Room entities,
OkHttp/Retrofit interfaces, Coroutines, Compose, BuildConfig
Test fix:
- ChatViewModelTest: use coEvery for suspend DAO methods instead
of every (compile error — suspend functions need coEvery)
- Added stubs for all suspend DAO methods used by the ViewModel
---------
Co-authored-by: M57 <hy4ri@users.noreply.github.com>
This commit is contained in:
1 parent
7daf73ce0d
commit
5130c2e308
13 files changed
+722
-47
No files matched your search
@@ -0,0 +1,47 @@
|
||||
package com.m57.hermescontrol.data.local
|
||||
|
||||
import androidx.room.Dao
|
||||
import androidx.room.Insert
|
||||
import androidx.room.OnConflictStrategy
|
||||
import androidx.room.Query
|
||||
import kotlinx.coroutines.flow.Flow
|
||||
|
||||
@Dao
|
||||
interface ChatMessageDao {
|
||||
@Query("SELECT * FROM chat_messages WHERE session_id = :sessionId ORDER BY timestamp ASC")
|
||||
fun observeMessagesForSession(sessionId: String): Flow<List<ChatMessageEntity>>
|
||||
|
||||
@Query("SELECT * FROM chat_messages WHERE session_id = :sessionId ORDER BY timestamp ASC")
|
||||
suspend fun getMessagesForSession(sessionId: String): List<ChatMessageEntity>
|
||||
|
||||
@Insert(onConflict = OnConflictStrategy.REPLACE)
|
||||
suspend fun upsert(message: ChatMessageEntity)
|
||||
|
||||
@Insert(onConflict = OnConflictStrategy.REPLACE)
|
||||
suspend fun upsertAll(messages: List<ChatMessageEntity>)
|
||||
|
||||
@Query("UPDATE chat_messages SET content = :content, is_streaming = 0 WHERE id = :id")
|
||||
suspend fun finalizeMessage(
|
||||
id: String,
|
||||
content: String,
|
||||
)
|
||||
|
||||
@Query("UPDATE chat_messages SET content = :content WHERE id = :id")
|
||||
suspend fun updateContent(
|
||||
id: String,
|
||||
content: String,
|
||||
)
|
||||
|
||||
@Query("DELETE FROM chat_messages WHERE session_id = :sessionId")
|
||||
suspend fun deleteMessagesForSession(sessionId: String)
|
||||
|
||||
@Query(
|
||||
"DELETE FROM chat_messages WHERE session_id NOT IN " +
|
||||
"(SELECT DISTINCT session_id FROM chat_messages " +
|
||||
"ORDER BY timestamp DESC LIMIT :keepSessions)",
|
||||
)
|
||||
suspend fun pruneOldSessions(keepSessions: Int)
|
||||
|
||||
@Query("SELECT COUNT(*) FROM chat_messages")
|
||||
suspend fun count(): Int
|
||||
}
|
||||
@@ -0,0 +1,33 @@
|
||||
package com.m57.hermescontrol.data.local
|
||||
|
||||
import androidx.room.ColumnInfo
|
||||
import androidx.room.Entity
|
||||
import androidx.room.PrimaryKey
|
||||
|
||||
/**
|
||||
* Persisted chat message. Maps to the [com.m57.hermescontrol.ui.chat.ChatMessage]
|
||||
* UI model but is stored independently so it survives process death.
|
||||
*
|
||||
* Messages are scoped by [sessionId] so switching sessions loads the right
|
||||
* thread. The [createdAt] field preserves original ordering even if Room
|
||||
* reorders internally.
|
||||
*/
|
||||
@Entity(tableName = "chat_messages")
|
||||
data class ChatMessageEntity(
|
||||
@PrimaryKey
|
||||
val id: String,
|
||||
@ColumnInfo(name = "session_id")
|
||||
val sessionId: String,
|
||||
@ColumnInfo(name = "role")
|
||||
val role: String,
|
||||
@ColumnInfo(name = "content")
|
||||
val content: String,
|
||||
@ColumnInfo(name = "timestamp")
|
||||
val timestamp: Long,
|
||||
@ColumnInfo(name = "tool_name")
|
||||
val toolName: String? = null,
|
||||
@ColumnInfo(name = "tool_status")
|
||||
val toolStatus: String? = null,
|
||||
@ColumnInfo(name = "is_streaming")
|
||||
val isStreaming: Boolean = false,
|
||||
)
|
||||
@@ -0,0 +1,47 @@
|
||||
package com.m57.hermescontrol.data.local
|
||||
|
||||
import com.m57.hermescontrol.ui.chat.ChatMessage
|
||||
import com.m57.hermescontrol.ui.chat.MessageRole
|
||||
import com.m57.hermescontrol.ui.chat.ToolStatus
|
||||
|
||||
/**
|
||||
* Converts between the Room [ChatMessageEntity] and the UI [ChatMessage].
|
||||
*
|
||||
* Keep this as a pure mapping — no I/O, no side effects.
|
||||
*/
|
||||
|
||||
fun ChatMessageEntity.toUiModel(): ChatMessage =
|
||||
ChatMessage(
|
||||
id = id,
|
||||
role =
|
||||
when (role) {
|
||||
"USER" -> MessageRole.USER
|
||||
"ASSISTANT" -> MessageRole.ASSISTANT
|
||||
"SYSTEM" -> MessageRole.SYSTEM
|
||||
"TOOL" -> MessageRole.TOOL
|
||||
else -> MessageRole.ASSISTANT
|
||||
},
|
||||
content = content,
|
||||
timestamp = timestamp,
|
||||
isStreaming = isStreaming,
|
||||
toolName = toolName,
|
||||
toolStatus =
|
||||
when (toolStatus) {
|
||||
"RUNNING" -> ToolStatus.RUNNING
|
||||
"COMPLETED" -> ToolStatus.COMPLETED
|
||||
"FAILED" -> ToolStatus.FAILED
|
||||
else -> null
|
||||
},
|
||||
)
|
||||
|
||||
fun ChatMessage.toEntity(sessionId: String): ChatMessageEntity =
|
||||
ChatMessageEntity(
|
||||
id = id,
|
||||
sessionId = sessionId,
|
||||
role = role.name,
|
||||
content = content,
|
||||
timestamp = timestamp,
|
||||
toolName = toolName,
|
||||
toolStatus = toolStatus?.name,
|
||||
isStreaming = isStreaming,
|
||||
)
|
||||
@@ -0,0 +1,37 @@
|
||||
package com.m57.hermescontrol.data.local
|
||||
|
||||
import android.content.Context
|
||||
import androidx.room.Database
|
||||
import androidx.room.Room
|
||||
import androidx.room.RoomDatabase
|
||||
|
||||
@Database(
|
||||
entities = [ChatMessageEntity::class],
|
||||
version = 1,
|
||||
exportSchema = false,
|
||||
)
|
||||
abstract class HermesDatabase : RoomDatabase() {
|
||||
abstract fun chatMessageDao(): ChatMessageDao
|
||||
|
||||
companion object {
|
||||
@Volatile
|
||||
private var instance: HermesDatabase? = null
|
||||
|
||||
fun get(context: Context): HermesDatabase =
|
||||
instance ?: synchronized(this) {
|
||||
instance ?: Room
|
||||
.databaseBuilder(
|
||||
context.applicationContext,
|
||||
HermesDatabase::class.java,
|
||||
"hermes_control.db",
|
||||
).fallbackToDestructiveMigration()
|
||||
.build()
|
||||
.also { instance = it }
|
||||
}
|
||||
|
||||
/** For testing — inject a custom instance. */
|
||||
fun setForTest(db: HermesDatabase?) {
|
||||
instance = db
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,178 @@
|
||||
package com.m57.hermescontrol.notification
|
||||
|
||||
import android.app.Notification
|
||||
import android.app.NotificationChannel
|
||||
import android.app.NotificationManager
|
||||
import android.app.PendingIntent
|
||||
import android.app.Service
|
||||
import android.content.Context
|
||||
import android.content.Intent
|
||||
import android.os.Build
|
||||
import android.os.IBinder
|
||||
import androidx.core.app.NotificationCompat
|
||||
import com.m57.hermescontrol.MainActivity
|
||||
import com.m57.hermescontrol.R
|
||||
import com.m57.hermescontrol.data.local.AuthManager
|
||||
import com.m57.hermescontrol.data.ws.HermesWsClient
|
||||
import com.m57.hermescontrol.data.ws.WsEvent
|
||||
import kotlinx.coroutines.CoroutineScope
|
||||
import kotlinx.coroutines.Dispatchers
|
||||
import kotlinx.coroutines.Job
|
||||
import kotlinx.coroutines.SupervisorJob
|
||||
import kotlinx.coroutines.cancel
|
||||
import kotlinx.coroutines.flow.collect
|
||||
import kotlinx.coroutines.launch
|
||||
|
||||
/**
|
||||
* Foreground service that keeps the WebSocket connection alive while the app
|
||||
* is backgrounded and posts a notification when a new assistant reply
|
||||
* completes (MessageComplete event) while the app is not in the foreground.
|
||||
*
|
||||
* Lifecycle:
|
||||
* - Started by [NotificationHelper.start] when the user sends a message and
|
||||
* the app is about to go to the background (called from ChatScreen's
|
||||
* onStop / onPause).
|
||||
* - Stopped by [NotificationHelper.stop] when the app returns to the
|
||||
* foreground (called from ChatScreen's onStart / onResume).
|
||||
*
|
||||
* The service collects [WsEvent]s from [HermesWsClient] — the same stream
|
||||
* the ChatViewModel collects — and watches for [WsEvent.MessageComplete]
|
||||
* events that indicate the agent has finished replying.
|
||||
*/
|
||||
class ChatNotificationService : Service() {
|
||||
companion object {
|
||||
private const val CHANNEL_ID = "hermes_chat"
|
||||
private const val NOTIFICATION_ID = 1
|
||||
private const val PENDING_NOTIFICATION_ID = 2
|
||||
|
||||
private var isAppInForeground = false
|
||||
|
||||
fun setAppForeground(foreground: Boolean) {
|
||||
isAppInForeground = foreground
|
||||
}
|
||||
}
|
||||
|
||||
private val serviceScope = CoroutineScope(Dispatchers.IO + SupervisorJob())
|
||||
private var eventCollector: Job? = null
|
||||
|
||||
override fun onCreate() {
|
||||
super.onCreate()
|
||||
createNotificationChannel()
|
||||
startForeground(NOTIFICATION_ID, buildForegroundNotification("Hermes connected"))
|
||||
startEventCollection()
|
||||
}
|
||||
|
||||
private fun startEventCollection() {
|
||||
eventCollector =
|
||||
serviceScope.launch {
|
||||
HermesWsClient.events.collect { event ->
|
||||
if (!isAppInForeground) {
|
||||
when (event) {
|
||||
is WsEvent.MessageComplete -> {
|
||||
val preview =
|
||||
event.text
|
||||
.take(100)
|
||||
.replace("\n", " ")
|
||||
.ifBlank { "New message" }
|
||||
showReplyNotification(preview)
|
||||
}
|
||||
is WsEvent.ClarifyRequest -> {
|
||||
showReplyNotification("Hermes needs a clarification")
|
||||
}
|
||||
else -> {}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
private fun showReplyNotification(text: String) {
|
||||
val notification =
|
||||
NotificationCompat
|
||||
.Builder(this, CHANNEL_ID)
|
||||
.setSmallIcon(R.mipmap.ic_launcher)
|
||||
.setContentTitle("Hermes")
|
||||
.setContentText(text)
|
||||
.setStyle(NotificationCompat.BigTextStyle().bigText(text))
|
||||
.setPriority(NotificationCompat.PRIORITY_HIGH)
|
||||
.setAutoCancel(true)
|
||||
.setContentIntent(buildContentIntent())
|
||||
.build()
|
||||
|
||||
val manager = getSystemService(Context.NOTIFICATION_SERVICE) as NotificationManager
|
||||
manager.notify(PENDING_NOTIFICATION_ID, notification)
|
||||
}
|
||||
|
||||
private fun buildContentIntent(): PendingIntent =
|
||||
PendingIntent.getActivity(
|
||||
this,
|
||||
0,
|
||||
Intent(this, MainActivity::class.java),
|
||||
PendingIntent.FLAG_IMMUTABLE or PendingIntent.FLAG_UPDATE_CURRENT,
|
||||
)
|
||||
|
||||
private fun buildForegroundNotification(text: String): Notification =
|
||||
NotificationCompat
|
||||
.Builder(this, CHANNEL_ID)
|
||||
.setSmallIcon(R.mipmap.ic_launcher)
|
||||
.setContentTitle("Hermes")
|
||||
.setContentText(text)
|
||||
.setPriority(NotificationCompat.PRIORITY_LOW)
|
||||
.setOngoing(true)
|
||||
.build()
|
||||
|
||||
private fun createNotificationChannel() {
|
||||
if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.O) {
|
||||
val channel =
|
||||
NotificationChannel(
|
||||
CHANNEL_ID,
|
||||
"Hermes Chat",
|
||||
NotificationManager.IMPORTANCE_HIGH,
|
||||
).apply {
|
||||
description = "Notifications for new Hermes replies"
|
||||
}
|
||||
val manager = getSystemService(Context.NOTIFICATION_SERVICE) as NotificationManager
|
||||
manager.createNotificationChannel(channel)
|
||||
}
|
||||
}
|
||||
|
||||
override fun onStartCommand(
|
||||
intent: Intent?,
|
||||
flags: Int,
|
||||
startId: Int,
|
||||
): Int = START_STICKY
|
||||
|
||||
override fun onBind(intent: Intent?): IBinder? = null
|
||||
|
||||
override fun onDestroy() {
|
||||
eventCollector?.cancel()
|
||||
serviceScope.cancel()
|
||||
super.onDestroy()
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Helper to start/stop the notification service from the UI layer.
|
||||
*/
|
||||
object NotificationHelper {
|
||||
fun start(context: Context) {
|
||||
if (AuthManager.getToken().isNullOrBlank()) return
|
||||
val intent = Intent(context, ChatNotificationService::class.java)
|
||||
if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.O) {
|
||||
context.startForegroundService(intent)
|
||||
} else {
|
||||
context.startService(intent)
|
||||
}
|
||||
}
|
||||
|
||||
fun stop(context: Context) {
|
||||
context.stopService(Intent(context, ChatNotificationService::class.java))
|
||||
}
|
||||
|
||||
fun setAppForeground(
|
||||
context: Context,
|
||||
foreground: Boolean,
|
||||
) {
|
||||
ChatNotificationService.setAppForeground(foreground)
|
||||
}
|
||||
}
|
||||
@@ -66,6 +66,7 @@ import androidx.compose.material3.TextButton
|
||||
import androidx.compose.material3.TopAppBar
|
||||
import androidx.compose.material3.TopAppBarDefaults
|
||||
import androidx.compose.runtime.Composable
|
||||
import androidx.compose.runtime.DisposableEffect
|
||||
import androidx.compose.runtime.LaunchedEffect
|
||||
import androidx.compose.runtime.getValue
|
||||
import androidx.compose.runtime.mutableStateOf
|
||||
@@ -77,11 +78,13 @@ import androidx.compose.ui.Modifier
|
||||
import androidx.compose.ui.draw.clip
|
||||
import androidx.compose.ui.graphics.Brush
|
||||
import androidx.compose.ui.graphics.graphicsLayer
|
||||
import androidx.compose.ui.platform.LocalContext
|
||||
import androidx.compose.ui.text.font.FontWeight
|
||||
import androidx.compose.ui.unit.dp
|
||||
import androidx.compose.ui.unit.sp
|
||||
import androidx.lifecycle.compose.collectAsStateWithLifecycle
|
||||
import androidx.lifecycle.viewmodel.compose.viewModel
|
||||
import com.m57.hermescontrol.notification.NotificationHelper
|
||||
import com.m57.hermescontrol.theme.StatusGreen
|
||||
import com.m57.hermescontrol.theme.StatusRed
|
||||
|
||||
@@ -91,13 +94,41 @@ fun ChatScreen(
|
||||
onNavigateToSettings: () -> Unit,
|
||||
modifier: Modifier = Modifier,
|
||||
onOpenDrawer: (() -> Unit)? = null,
|
||||
viewModel: ChatViewModel = viewModel { ChatViewModel() },
|
||||
viewModel: ChatViewModel = viewModel(),
|
||||
) {
|
||||
val state by viewModel.uiState.collectAsStateWithLifecycle()
|
||||
val listState = rememberLazyListState()
|
||||
var inputText by rememberSaveable { mutableStateOf("") }
|
||||
val snackbarHostState = remember { SnackbarHostState() }
|
||||
val isDark = isSystemInDarkTheme()
|
||||
val context = LocalContext.current
|
||||
val lifecycleOwner = androidx.lifecycle.compose.LocalLifecycleOwner.current
|
||||
|
||||
// Manage notification foreground service lifecycle:
|
||||
// - When app goes to background (ON_STOP), mark as not-foreground and
|
||||
// start the notification service so we can post reply notifications.
|
||||
// - When app returns to foreground (ON_START), mark as foreground and
|
||||
// stop the notification service.
|
||||
DisposableEffect(lifecycleOwner) {
|
||||
val observer =
|
||||
androidx.lifecycle.LifecycleEventObserver { _, event ->
|
||||
when (event) {
|
||||
androidx.lifecycle.Lifecycle.Event.ON_START -> {
|
||||
NotificationHelper.setAppForeground(context, true)
|
||||
NotificationHelper.stop(context)
|
||||
}
|
||||
androidx.lifecycle.Lifecycle.Event.ON_STOP -> {
|
||||
NotificationHelper.setAppForeground(context, false)
|
||||
NotificationHelper.start(context)
|
||||
}
|
||||
else -> {}
|
||||
}
|
||||
}
|
||||
lifecycleOwner.lifecycle.addObserver(observer)
|
||||
onDispose {
|
||||
lifecycleOwner.lifecycle.removeObserver(observer)
|
||||
}
|
||||
}
|
||||
|
||||
// Auto-scroll to bottom on new messages
|
||||
LaunchedEffect(state.messages.size, state.currentStreamingText) {
|
||||
|
||||
@@ -1,8 +1,12 @@
|
||||
package com.m57.hermescontrol.ui.chat
|
||||
|
||||
import androidx.lifecycle.ViewModel
|
||||
import android.app.Application
|
||||
import androidx.lifecycle.AndroidViewModel
|
||||
import androidx.lifecycle.viewModelScope
|
||||
import com.m57.hermescontrol.data.local.AuthManager
|
||||
import com.m57.hermescontrol.data.local.HermesDatabase
|
||||
import com.m57.hermescontrol.data.local.toEntity
|
||||
import com.m57.hermescontrol.data.local.toUiModel
|
||||
import com.m57.hermescontrol.data.remote.ApiClient
|
||||
import com.m57.hermescontrol.data.ws.HermesWsClient
|
||||
import com.m57.hermescontrol.data.ws.WsEvent
|
||||
@@ -42,12 +46,13 @@ data class ClarifyUi(
|
||||
val options: List<String>,
|
||||
)
|
||||
|
||||
class ChatViewModel : ViewModel() {
|
||||
class ChatViewModel(application: Application) : AndroidViewModel(application) {
|
||||
private val _uiState = MutableStateFlow(ChatUiState())
|
||||
val uiState: StateFlow<ChatUiState> = _uiState.asStateFlow()
|
||||
|
||||
private val wsClient = HermesWsClient
|
||||
private val pendingRequests = mutableMapOf<String, String>() // requestId -> method
|
||||
private val dao = HermesDatabase.get(application).chatMessageDao()
|
||||
|
||||
init {
|
||||
connectWebSocket()
|
||||
@@ -75,15 +80,12 @@ class ChatViewModel : ViewModel() {
|
||||
_uiState.update { it.copy(isConnected = true, isLoading = false) }
|
||||
addSystemMessage("Connected to Hermes")
|
||||
loadSessions()
|
||||
// Create a new session if none exists
|
||||
if (_uiState.value.currentSessionId == null) {
|
||||
createNewSession()
|
||||
}
|
||||
}
|
||||
|
||||
is WsEvent.SessionInfo -> {
|
||||
// We may receive session info after creation
|
||||
}
|
||||
is WsEvent.SessionInfo -> {}
|
||||
|
||||
is WsEvent.MessageStart -> {
|
||||
_uiState.update {
|
||||
@@ -99,7 +101,6 @@ class ChatViewModel : ViewModel() {
|
||||
is WsEvent.MessageToken -> {
|
||||
_uiState.update { state ->
|
||||
val newText = state.currentStreamingText + event.token
|
||||
// Update or create the streaming message
|
||||
val messages = state.messages.toMutableList()
|
||||
val streamingIdx =
|
||||
messages.indexOfLast {
|
||||
@@ -116,6 +117,14 @@ class ChatViewModel : ViewModel() {
|
||||
),
|
||||
)
|
||||
}
|
||||
// Persist streaming message to DB
|
||||
val sessionId = state.currentSessionId
|
||||
if (sessionId != null) {
|
||||
val msg = messages.last()
|
||||
viewModelScope.launch(Dispatchers.IO) {
|
||||
dao.upsert(msg.toEntity(sessionId))
|
||||
}
|
||||
}
|
||||
state.copy(
|
||||
messages = messages,
|
||||
currentStreamingText = newText,
|
||||
@@ -140,19 +149,28 @@ class ChatViewModel : ViewModel() {
|
||||
messages.indexOfLast {
|
||||
it.role == MessageRole.ASSISTANT && it.isStreaming
|
||||
}
|
||||
val finalMessage: ChatMessage
|
||||
if (streamingIdx >= 0) {
|
||||
messages[streamingIdx] =
|
||||
finalMessage =
|
||||
messages[streamingIdx].copy(
|
||||
content = event.text,
|
||||
isStreaming = false,
|
||||
)
|
||||
messages[streamingIdx] = finalMessage
|
||||
} else {
|
||||
messages.add(
|
||||
finalMessage =
|
||||
ChatMessage(
|
||||
role = MessageRole.ASSISTANT,
|
||||
content = event.text,
|
||||
),
|
||||
)
|
||||
)
|
||||
messages.add(finalMessage)
|
||||
}
|
||||
// Persist finalized message
|
||||
val sessionId = state.currentSessionId
|
||||
if (sessionId != null) {
|
||||
viewModelScope.launch(Dispatchers.IO) {
|
||||
dao.finalizeMessage(finalMessage.id, event.text)
|
||||
}
|
||||
}
|
||||
state.copy(
|
||||
messages = messages,
|
||||
@@ -179,6 +197,12 @@ class ChatViewModel : ViewModel() {
|
||||
toolStatus = ToolStatus.RUNNING,
|
||||
)
|
||||
_uiState.update { state ->
|
||||
val sessionId = state.currentSessionId
|
||||
if (sessionId != null) {
|
||||
viewModelScope.launch(Dispatchers.IO) {
|
||||
dao.upsert(toolMessage.toEntity(sessionId))
|
||||
}
|
||||
}
|
||||
state.copy(messages = state.messages + toolMessage)
|
||||
}
|
||||
}
|
||||
@@ -198,6 +222,13 @@ class ChatViewModel : ViewModel() {
|
||||
toolStatus = ToolStatus.COMPLETED,
|
||||
content = event.data.toString(),
|
||||
)
|
||||
// Persist tool completion
|
||||
val sessionId = state.currentSessionId
|
||||
if (sessionId != null) {
|
||||
viewModelScope.launch(Dispatchers.IO) {
|
||||
dao.upsert(messages[toolIdx].toEntity(sessionId))
|
||||
}
|
||||
}
|
||||
}
|
||||
state.copy(messages = messages)
|
||||
}
|
||||
@@ -215,12 +246,9 @@ class ChatViewModel : ViewModel() {
|
||||
}
|
||||
}
|
||||
|
||||
is WsEvent.StatusUpdate -> {
|
||||
// Can be used for general status
|
||||
}
|
||||
is WsEvent.StatusUpdate -> {}
|
||||
|
||||
is WsEvent.SessionUpdated -> {
|
||||
// Refresh sessions list
|
||||
loadSessions()
|
||||
}
|
||||
|
||||
@@ -232,9 +260,7 @@ class ChatViewModel : ViewModel() {
|
||||
handleRpcError(event.id, event.error)
|
||||
}
|
||||
|
||||
is WsEvent.Unknown -> {
|
||||
// Handle or ignore unknown/unhandled events
|
||||
}
|
||||
is WsEvent.Unknown -> {}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -253,10 +279,10 @@ class ChatViewModel : ViewModel() {
|
||||
it.copy(
|
||||
currentSessionId = sessionId,
|
||||
isLoading = false,
|
||||
messages = it.messages,
|
||||
messages = emptyList(),
|
||||
)
|
||||
}
|
||||
addSystemMessage("Session created")
|
||||
addSystemMessage("Session created", persist = true)
|
||||
loadSessions()
|
||||
}
|
||||
|
||||
@@ -276,6 +302,11 @@ class ChatViewModel : ViewModel() {
|
||||
|
||||
WsMethods.SESSION_RESUME -> {
|
||||
_uiState.update { it.copy(isLoading = false) }
|
||||
// Load cached messages from Room before the REST call
|
||||
val sessionId = _uiState.value.currentSessionId
|
||||
if (sessionId != null) {
|
||||
loadCachedMessages(sessionId)
|
||||
}
|
||||
addSystemMessage("Session resumed")
|
||||
}
|
||||
|
||||
@@ -316,15 +347,18 @@ class ChatViewModel : ViewModel() {
|
||||
return
|
||||
}
|
||||
|
||||
// Add user message to UI
|
||||
val userMessage =
|
||||
ChatMessage(
|
||||
role = MessageRole.USER,
|
||||
content = text,
|
||||
)
|
||||
_uiState.update { state ->
|
||||
val messages = state.messages + userMessage
|
||||
viewModelScope.launch(Dispatchers.IO) {
|
||||
dao.upsert(userMessage.toEntity(sessionId))
|
||||
}
|
||||
state.copy(
|
||||
messages = state.messages + userMessage,
|
||||
messages = messages,
|
||||
isAgentTyping = true,
|
||||
)
|
||||
}
|
||||
@@ -337,7 +371,13 @@ class ChatViewModel : ViewModel() {
|
||||
|
||||
private fun handleSlashCommand(command: String) {
|
||||
val userMsg = ChatMessage(role = MessageRole.USER, content = command)
|
||||
val sessionId = _uiState.value.currentSessionId
|
||||
_uiState.update { it.copy(messages = it.messages + userMsg) }
|
||||
if (sessionId != null) {
|
||||
viewModelScope.launch(Dispatchers.IO) {
|
||||
dao.upsert(userMsg.toEntity(sessionId))
|
||||
}
|
||||
}
|
||||
|
||||
val parts = command.split(" ")
|
||||
val cmd = parts[0].lowercase()
|
||||
@@ -383,7 +423,7 @@ class ChatViewModel : ViewModel() {
|
||||
• **Gateway Running:** ${body.gateway_running ?: false}
|
||||
• **Active Sessions:** ${body.active_sessions ?: 0}
|
||||
• **Auth Required:** ${body.auth_required ?: false}
|
||||
|
||||
|
||||
**Platforms:**
|
||||
$platformsStr
|
||||
""".trimIndent()
|
||||
@@ -451,7 +491,13 @@ class ChatViewModel : ViewModel() {
|
||||
|
||||
private fun addAssistantMessage(text: String) {
|
||||
val msg = ChatMessage(role = MessageRole.ASSISTANT, content = text)
|
||||
val sessionId = _uiState.value.currentSessionId
|
||||
_uiState.update { it.copy(messages = it.messages + msg) }
|
||||
if (sessionId != null) {
|
||||
viewModelScope.launch(Dispatchers.IO) {
|
||||
dao.upsert(msg.toEntity(sessionId))
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
fun interruptSession() {
|
||||
@@ -491,6 +537,9 @@ class ChatViewModel : ViewModel() {
|
||||
showSessionPicker = false,
|
||||
)
|
||||
}
|
||||
// Load cached messages first — instant display
|
||||
loadCachedMessages(sessionId)
|
||||
// Then resume session on server
|
||||
viewModelScope.launch(Dispatchers.IO) {
|
||||
val requestId =
|
||||
wsClient.send(
|
||||
@@ -502,6 +551,21 @@ class ChatViewModel : ViewModel() {
|
||||
loadSessionMessages(sessionId)
|
||||
}
|
||||
|
||||
private fun loadCachedMessages(sessionId: String) {
|
||||
viewModelScope.launch(Dispatchers.IO) {
|
||||
val cached = dao.getMessagesForSession(sessionId)
|
||||
val cachedMessages = cached.map { it.toUiModel() }
|
||||
_uiState.update { state ->
|
||||
// Only replace if still showing this session and messages are empty/newer
|
||||
if (state.currentSessionId == sessionId) {
|
||||
state.copy(messages = cachedMessages, isLoading = false)
|
||||
} else {
|
||||
state
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
private fun loadSessionMessages(sessionId: String) {
|
||||
viewModelScope.launch {
|
||||
try {
|
||||
@@ -526,15 +590,11 @@ class ChatViewModel : ViewModel() {
|
||||
isStreaming = false,
|
||||
)
|
||||
}
|
||||
// B3 (Jun 18 2026, kanban t_33da8a97): switchSession fires
|
||||
// SESSION_RESUME (WS) and loadSessionMessages (REST) in
|
||||
// parallel. While this REST call is in flight, WsEvent
|
||||
// .MessageToken events append streaming messages to
|
||||
// `state.messages` (ChatViewModel.kt:99-125). Without
|
||||
// preservation, the unconditional `messages = chatMessages`
|
||||
// below stomps the just-streamed tokens. Preserve the
|
||||
// in-flight tail by appending any messages marked
|
||||
// isStreaming=true to the freshly-loaded history.
|
||||
// Persist loaded messages to Room for offline access
|
||||
val entities = chatMessages.map { it.toEntity(sessionId) }
|
||||
viewModelScope.launch(Dispatchers.IO) {
|
||||
dao.upsertAll(entities)
|
||||
}
|
||||
_uiState.update { state ->
|
||||
val streamingTail = state.messages.filter { it.isStreaming }
|
||||
state.copy(
|
||||
@@ -595,9 +655,20 @@ class ChatViewModel : ViewModel() {
|
||||
}
|
||||
}
|
||||
|
||||
private fun addSystemMessage(text: String) {
|
||||
private fun addSystemMessage(
|
||||
text: String,
|
||||
persist: Boolean = false,
|
||||
) {
|
||||
val msg = ChatMessage(role = MessageRole.SYSTEM, content = text)
|
||||
_uiState.update { it.copy(messages = it.messages + msg) }
|
||||
if (persist) {
|
||||
val sessionId = _uiState.value.currentSessionId
|
||||
if (sessionId != null) {
|
||||
viewModelScope.launch(Dispatchers.IO) {
|
||||
dao.upsert(msg.toEntity(sessionId))
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
override fun onCleared() {
|
||||
|
||||
Reference in new issue
Block a user