feat: add realtime mobile sync dashboard

This commit is contained in:
Codex
2026-03-23 14:39:16 +08:00
parent b338a1ff1a
commit 0148440ad5
5 changed files with 633 additions and 54 deletions

View File

@@ -210,6 +210,8 @@ android-app/app/build/outputs/apk/debug/app-debug.apk
安卓端当前包含:
- SSE 实时同步,自动订阅所有客户端的项目、对话、审批和任务进度
- 事件流断开时自动降级轮询,尽量保持跨端数据连续同步
- 会话创建、切换、持续对话
- 任务分组查看、暂停、恢复、取消、重排
- 审批查看与批准/拒绝
@@ -217,6 +219,12 @@ android-app/app/build/outputs/apk/debug/app-debug.apk
- 绑定新设备并生成启动命令
- 云端 Boss 地址切换与重排入口
同步模型说明:
- 电脑端 Web 控制台已经使用 SSE 实时事件流
- 安卓端现在也使用 SSE并在断线时自动重连和降级轮询
- 手机和电脑都直接读写同一套 Boss control plane 状态,所以项目、消息、审批和任务进度会尽量保持高同步
一键本地 demo
```bash

View File

@@ -0,0 +1,106 @@
package site.hyzq.bossandroid.network
import java.io.IOException
import kotlin.coroutines.coroutineContext
import kotlinx.coroutines.CancellationException
import kotlinx.coroutines.Dispatchers
import kotlinx.coroutines.delay
import kotlinx.coroutines.ensureActive
import kotlinx.coroutines.withContext
import kotlinx.serialization.decodeFromString
import kotlinx.serialization.json.Json
import okhttp3.OkHttpClient
import okhttp3.Request
import okio.BufferedSource
import site.hyzq.bossandroid.model.BossEvent
enum class SyncConnectionState {
CONNECTING,
LIVE,
RECONNECTING,
}
class BossRealtimeSync(
private val client: OkHttpClient = OkHttpClient(),
private val json: Json = Json {
ignoreUnknownKeys = true
explicitNulls = false
},
) {
suspend fun run(
baseUrl: String,
onStatusChanged: (SyncConnectionState) -> Unit,
onEvent: (BossEvent) -> Unit,
) = withContext(Dispatchers.IO) {
var firstAttempt = true
while (true) {
coroutineContext.ensureActive()
onStatusChanged(if (firstAttempt) SyncConnectionState.CONNECTING else SyncConnectionState.RECONNECTING)
try {
val request = Request.Builder()
.url("${normalizeBaseUrl(baseUrl)}/api/events/stream")
.build()
client.newCall(request).execute().use { response ->
if (!response.isSuccessful) {
throw IOException("SSE connection failed: ${response.code}")
}
val source = response.body?.source() ?: throw IOException("Empty event stream response")
onStatusChanged(SyncConnectionState.LIVE)
readStream(source, onEvent)
}
} catch (error: Throwable) {
if (error is CancellationException) {
throw error
}
onStatusChanged(SyncConnectionState.RECONNECTING)
delay(RETRY_DELAY_MS)
}
firstAttempt = false
}
}
private fun readStream(
source: BufferedSource,
onEvent: (BossEvent) -> Unit,
) {
val payloadLines = mutableListOf<String>()
while (!source.exhausted()) {
val rawLine = source.readUtf8Line() ?: break
when {
rawLine.startsWith(":") -> Unit
rawLine.isBlank() -> {
if (payloadLines.isNotEmpty()) {
val event = json.decodeFromString<BossEvent>(payloadLines.joinToString("\n"))
onEvent(event)
payloadLines.clear()
}
}
rawLine.startsWith("data:") -> {
payloadLines += rawLine.removePrefix("data:").trimStart()
}
}
}
}
private fun normalizeBaseUrl(input: String): String {
val trimmed = input.trim().trimEnd('/')
return if (trimmed.isBlank()) {
BossApi.DEFAULT_BASE_URL
} else if (trimmed.startsWith("http://") || trimmed.startsWith("https://")) {
trimmed
} else {
"http://$trimmed"
}
}
companion object {
private const val RETRY_DELAY_MS = 2_000L
}
}

View File

@@ -1,6 +1,7 @@
package site.hyzq.bossandroid.ui
import androidx.compose.foundation.background
import androidx.compose.foundation.clickable
import androidx.compose.foundation.horizontalScroll
import androidx.compose.foundation.layout.Arrangement
import androidx.compose.foundation.layout.Box
@@ -76,6 +77,7 @@ import site.hyzq.bossandroid.model.Message
import site.hyzq.bossandroid.model.Session
import site.hyzq.bossandroid.model.TaskItem
import site.hyzq.bossandroid.model.WorkerNode
import site.hyzq.bossandroid.network.SyncConnectionState
private val TaskGroups = listOf(
"进行中" to listOf("assigned", "running"),
@@ -111,7 +113,7 @@ fun BossApp(
Column(horizontalAlignment = Alignment.CenterHorizontally) {
Text("Boss 主控")
Text(
text = topBarSubtitle(uiState, selectedSession, selectedWorker),
text = "${syncStatusShortLabel(uiState.syncConnectionState)} · ${topBarSubtitle(uiState, selectedSession, selectedWorker)}",
style = MaterialTheme.typography.labelMedium,
color = MaterialTheme.colorScheme.onSurfaceVariant,
)
@@ -162,6 +164,7 @@ fun BossApp(
selectedSession = selectedSession,
selectedWorker = selectedWorker,
onSelectSession = viewModel::selectSession,
onSelectWorker = viewModel::selectWorker,
onSelectTab = viewModel::selectConversationTab,
onCreateSession = viewModel::createSession,
onSendMessage = viewModel::sendMessage,
@@ -202,6 +205,7 @@ private fun ConversationsScreen(
selectedSession: Session?,
selectedWorker: WorkerNode?,
onSelectSession: (String) -> Unit,
onSelectWorker: (String?) -> Unit,
onSelectTab: (ConversationTab) -> Unit,
onCreateSession: (String) -> Unit,
onSendMessage: (String) -> Unit,
@@ -233,6 +237,7 @@ private fun ConversationsScreen(
verticalArrangement = Arrangement.spacedBy(16.dp),
) {
CreateSessionCard(onCreateSession = onCreateSession)
GlobalSyncCard(uiState = uiState)
SessionSelector(
sessions = uiState.sessions,
selectedSessionId = uiState.selectedSessionId,
@@ -248,20 +253,16 @@ private fun ConversationsScreen(
)
}
if (selectedSession == null) {
EmptyStateCard(
title = "还没有可用会话",
body = "先创建一个项目会话,然后在这里持续对话、改需求、审批和看进度。",
)
return@Column
}
if (uiState.conversationTab != ConversationTab.OVERVIEW) {
selectedSession?.let { session ->
SessionSummaryCard(
session = selectedSession,
session = session,
workerCount = uiState.workers.size,
onArchive = onArchiveSession,
onRestore = onRestoreSession,
)
}
}
TabRow(selectedTabIndex = uiState.conversationTab.ordinal) {
ConversationTab.entries.forEach { tab ->
@@ -271,6 +272,7 @@ private fun ConversationsScreen(
text = {
Text(
text = when (tab) {
ConversationTab.OVERVIEW -> "总览"
ConversationTab.CHAT -> "对话"
ConversationTab.TASKS -> "任务"
ConversationTab.APPROVALS -> "审批"
@@ -282,13 +284,32 @@ private fun ConversationsScreen(
}
when (uiState.conversationTab) {
ConversationTab.CHAT -> ChatTab(
ConversationTab.OVERVIEW -> OverviewTab(
uiState = uiState,
onSelectSession = onSelectSession,
onSelectWorker = onSelectWorker,
)
ConversationTab.CHAT -> if (selectedSession == null) {
EmptyStateCard(
title = "先选择一个项目",
body = "总览里能看到所有客户端的对话;如果要继续某个项目,请先选中它。",
)
} else {
ChatTab(
sessionMessages = sessionMessages,
sessionEvents = sessionEvents,
onSendMessage = onSendMessage,
)
}
ConversationTab.TASKS -> TasksTab(
ConversationTab.TASKS -> if (selectedSession == null) {
EmptyStateCard(
title = "先选择一个项目",
body = "选中项目后,这里会展示它的任务树和实时进度。",
)
} else {
TasksTab(
tasks = filteredTasks,
workers = uiState.workers,
onPauseTask = onPauseTask,
@@ -296,8 +317,15 @@ private fun ConversationsScreen(
onCancelTask = onCancelTask,
onRequeueTask = onRequeueTask,
)
}
ConversationTab.APPROVALS -> ApprovalsTab(
ConversationTab.APPROVALS -> if (selectedSession == null) {
EmptyStateCard(
title = "先选择一个项目",
body = "选中项目后,这里会展示对应的审批项。",
)
} else {
ApprovalsTab(
approvals = sessionApprovals,
tasks = sessionTasks,
onApprove = onApprove,
@@ -305,6 +333,7 @@ private fun ConversationsScreen(
)
}
}
}
}
@Composable
@@ -320,6 +349,9 @@ private fun DevicesScreen(
val relatedTasks = remember(uiState.tasks) {
uiState.tasks.associateBy { it.id }
}
val relatedSessions = remember(uiState.sessions) {
uiState.sessions.associateBy { it.id }
}
Column(
modifier = Modifier
@@ -354,9 +386,11 @@ private fun DevicesScreen(
SectionHeading("已绑定设备")
uiState.workers.forEach { worker ->
val task = worker.currentTaskId?.let { relatedTasks[it] }
val session = task?.let { relatedSessions[it.sessionId] }
WorkerCard(
worker = worker,
currentTask = task,
currentSession = session,
selected = worker.id == uiState.selectedWorkerId,
onSelect = { onSelectWorker(worker.id) },
onMarkOffline = { onMarkOffline(worker.id) },
@@ -419,10 +453,11 @@ private fun SettingsScreen(
verticalArrangement = Arrangement.spacedBy(10.dp),
) {
Text("系统状态", style = MaterialTheme.typography.titleMedium, fontWeight = FontWeight.SemiBold)
MetricRow("连接状态", if (uiState.isRefreshing) "同步中" else "在线")
MetricRow("同步模式", syncStatusShortLabel(uiState.syncConnectionState))
MetricRow("会话数", uiState.health?.sessions?.toString() ?: uiState.sessions.size.toString())
MetricRow("设备数", uiState.health?.workers?.toString() ?: uiState.workers.size.toString())
MetricRow("最后同步", uiState.lastSyncedAt?.let(::formatLocalTimestamp) ?: "尚未同步")
MetricRow("最近事件", uiState.lastEventType ?: "暂无")
Row(horizontalArrangement = Arrangement.spacedBy(12.dp)) {
Button(onClick = onReconcile) {
Icon(Icons.Outlined.SyncAlt, contentDescription = null)
@@ -440,7 +475,7 @@ private fun SettingsScreen(
) {
Text("APP 范围", style = MaterialTheme.typography.titleMedium, fontWeight = FontWeight.SemiBold)
Text(
"这个安卓版本已经覆盖了主控对话、设备绑定、设备切换、任务查看、审批处理和主控地址切换。下一步可以继续补推送通知、SSE 实时流和扫码绑定",
"这个安卓版本现在会优先走 SSE 实时同步,自动刷新所有客户端的项目、对话和任务进度;当事件流断开时,会自动降级轮询保证数据继续同步",
color = MaterialTheme.colorScheme.onSurfaceVariant,
)
}
@@ -448,6 +483,164 @@ private fun SettingsScreen(
}
}
@Composable
private fun GlobalSyncCard(
uiState: BossUiState,
) {
val activeTaskCount = uiState.tasks.count { it.status in listOf("planning", "queued", "assigned", "running", "blocked", "paused", "waiting_approval") }
Card(
modifier = Modifier.fillMaxWidth(),
colors = CardDefaults.cardColors(containerColor = MaterialTheme.colorScheme.secondaryContainer),
) {
Column(
modifier = Modifier.padding(16.dp),
verticalArrangement = Arrangement.spacedBy(10.dp),
) {
Row(
modifier = Modifier.fillMaxWidth(),
horizontalArrangement = Arrangement.SpaceBetween,
verticalAlignment = Alignment.CenterVertically,
) {
Column(modifier = Modifier.weight(1f)) {
Text("全局同步", style = MaterialTheme.typography.titleMedium, fontWeight = FontWeight.SemiBold)
Text(
syncStatusLongLabel(uiState.syncConnectionState),
color = MaterialTheme.colorScheme.onSecondaryContainer,
)
}
StatusChip(
label = syncStatusShortLabel(uiState.syncConnectionState),
tone = syncTone(uiState.syncConnectionState),
)
}
Row(horizontalArrangement = Arrangement.spacedBy(10.dp)) {
AssistChip(onClick = {}, label = { Text("${uiState.sessions.size} 个项目") })
AssistChip(onClick = {}, label = { Text("${uiState.workers.size} 台客户端") })
AssistChip(onClick = {}, label = { Text("$activeTaskCount 个活跃任务") })
}
Text(
"最近事件:${uiState.lastEventType ?: "等待新事件"}",
color = MaterialTheme.colorScheme.onSecondaryContainer,
)
Text(
"最后同步:${uiState.lastSyncedAt?.let(::formatLocalTimestamp) ?: "尚未同步"}",
style = MaterialTheme.typography.bodySmall,
color = MaterialTheme.colorScheme.onSecondaryContainer.copy(alpha = 0.9f),
)
}
}
}
@Composable
private fun OverviewTab(
uiState: BossUiState,
onSelectSession: (String) -> Unit,
onSelectWorker: (String?) -> Unit,
) {
val messagesBySession = remember(uiState.messages) {
uiState.messages.groupBy { it.sessionId }
}
val tasksBySession = remember(uiState.tasks) {
uiState.tasks.groupBy { it.sessionId }
}
val workerById = remember(uiState.workers) {
uiState.workers.associateBy { it.id }
}
val activeTasks = remember(uiState.tasks) {
uiState.tasks.filter { it.status in listOf("planning", "queued", "assigned", "running", "blocked", "paused", "waiting_approval") }
}
val recentMessages = remember(uiState.messages) {
uiState.messages.sortedByDescending { it.createdAt }.take(12)
}
Column(verticalArrangement = Arrangement.spacedBy(16.dp)) {
SectionHeading("客户端实时视图")
if (uiState.workers.isEmpty()) {
EmptyStateCard(
title = "还没有在线客户端",
body = "绑定电脑端 worker 后,这里会自动显示每台设备当前正在处理的项目和任务。",
)
} else {
uiState.workers.forEach { worker ->
val task = worker.currentTaskId?.let { taskId -> uiState.tasks.firstOrNull { it.id == taskId } }
val session = task?.let { uiState.sessions.firstOrNull { sessionItem -> sessionItem.id == it.sessionId } }
ClientActivityCard(
worker = worker,
task = task,
session = session,
onSelectWorker = { onSelectWorker(worker.id) },
onSelectSession = { session?.id?.let(onSelectSession) },
)
}
}
SectionHeading("所有项目")
if (uiState.sessions.isEmpty()) {
EmptyStateCard(
title = "还没有项目",
body = "你可以在手机上创建也可以在电脑入口创建。Boss 会自动把它们同步到这里。",
)
} else {
uiState.sessions.forEach { session ->
val recentMessage = messagesBySession[session.id]?.maxByOrNull { it.createdAt }
val sessionTasks = tasksBySession[session.id].orEmpty()
val sessionWorkerNames = sessionTasks
.mapNotNull { task -> task.assignedWorkerId?.let(workerById::get)?.name }
.distinct()
ProjectOverviewCard(
session = session,
recentMessage = recentMessage,
activeTaskCount = sessionTasks.count {
it.status in listOf("planning", "queued", "assigned", "running", "blocked", "paused", "waiting_approval")
},
workerNames = sessionWorkerNames,
onSelectSession = { onSelectSession(session.id) },
)
}
}
SectionHeading("最近对话")
if (recentMessages.isEmpty()) {
EmptyStateCard(
title = "还没有对话",
body = "手机、Web 和命令行入口发出的消息都会汇总到这里。",
)
} else {
recentMessages.forEach { message ->
val session = uiState.sessions.firstOrNull { it.id == message.sessionId }
GlobalMessageCard(
message = message,
sessionTitle = session?.title ?: message.sessionId,
onSelectSession = { onSelectSession(message.sessionId) },
)
}
}
SectionHeading("活跃任务")
if (activeTasks.isEmpty()) {
EmptyStateCard(
title = "目前没有活跃任务",
body = "一旦任何电脑端开始跑任务,进度会实时刷新到这里。",
)
} else {
activeTasks.forEach { task ->
TaskCard(
task = task,
worker = task.assignedWorkerId?.let(workerById::get),
onPauseTask = {},
onResumeTask = {},
onCancelTask = {},
onRequeueTask = {},
actionsEnabled = false,
)
}
}
}
}
@Composable
private fun CreateSessionCard(
onCreateSession: (String) -> Unit,
@@ -816,6 +1009,7 @@ private fun BindDeviceCard(
private fun WorkerCard(
worker: WorkerNode,
currentTask: TaskItem?,
currentSession: Session?,
selected: Boolean,
onSelect: () -> Unit,
onMarkOffline: () -> Unit,
@@ -858,6 +1052,12 @@ private fun WorkerCard(
"当前任务:${it.title}"
} ?: "当前任务:空闲",
)
if (currentSession != null) {
Text(
"当前项目:${currentSession.title}",
color = MaterialTheme.colorScheme.onSurfaceVariant,
)
}
Text(
"最近心跳 ${formatRelative(worker.lastSeenAt)}",
color = MaterialTheme.colorScheme.onSurfaceVariant,
@@ -875,6 +1075,158 @@ private fun WorkerCard(
}
}
@Composable
private fun ClientActivityCard(
worker: WorkerNode,
task: TaskItem?,
session: Session?,
onSelectWorker: () -> Unit,
onSelectSession: () -> Unit,
) {
val health = workerHealth(worker)
OutlinedCard(
modifier = Modifier
.fillMaxWidth()
.clickable { onSelectWorker() },
) {
Column(
modifier = Modifier.padding(16.dp),
verticalArrangement = Arrangement.spacedBy(10.dp),
) {
Row(
modifier = Modifier.fillMaxWidth(),
horizontalArrangement = Arrangement.SpaceBetween,
verticalAlignment = Alignment.CenterVertically,
) {
Column(modifier = Modifier.weight(1f)) {
Text(worker.name, style = MaterialTheme.typography.titleMedium, fontWeight = FontWeight.SemiBold)
Text("${worker.os} · ${health.label}", color = MaterialTheme.colorScheme.onSurfaceVariant)
}
StatusChip(label = health.label, tone = health.tone)
}
Text("当前项目:${session?.title ?: "暂无"}")
Text("当前任务:${task?.title ?: "空闲"}", color = MaterialTheme.colorScheme.onSurfaceVariant)
Text(
"最近心跳 ${formatRelative(worker.lastSeenAt)}",
style = MaterialTheme.typography.bodySmall,
color = MaterialTheme.colorScheme.onSurfaceVariant,
)
Row(horizontalArrangement = Arrangement.spacedBy(12.dp)) {
OutlinedButton(onClick = onSelectWorker) {
Text("看这台设备")
}
if (session != null) {
TextButton(onClick = onSelectSession) {
Text("打开项目")
}
}
}
}
}
}
@Composable
private fun ProjectOverviewCard(
session: Session,
recentMessage: Message?,
activeTaskCount: Int,
workerNames: List<String>,
onSelectSession: () -> Unit,
) {
OutlinedCard(
modifier = Modifier
.fillMaxWidth()
.clickable { onSelectSession() },
colors = CardDefaults.outlinedCardColors(
containerColor = if (session.status == "archived") {
MaterialTheme.colorScheme.surfaceVariant
} else {
MaterialTheme.colorScheme.surface
},
),
) {
Column(
modifier = Modifier.padding(16.dp),
verticalArrangement = Arrangement.spacedBy(10.dp),
) {
Row(
modifier = Modifier.fillMaxWidth(),
horizontalArrangement = Arrangement.SpaceBetween,
verticalAlignment = Alignment.CenterVertically,
) {
Column(modifier = Modifier.weight(1f)) {
Text(session.title, style = MaterialTheme.typography.titleMedium, fontWeight = FontWeight.SemiBold)
Text("最近更新 ${formatRelative(session.updatedAt)}", color = MaterialTheme.colorScheme.onSurfaceVariant)
}
StatusChip(
label = if (session.status == "archived") "已归档" else "活跃",
tone = session.status,
)
}
if (session.activeObjective.isNotBlank()) {
Text("当前目标:${session.activeObjective}")
}
if (recentMessage != null) {
Text(
"最近对话:${recentMessage.content}",
color = MaterialTheme.colorScheme.onSurfaceVariant,
maxLines = 2,
overflow = TextOverflow.Ellipsis,
)
}
Row(horizontalArrangement = Arrangement.spacedBy(10.dp)) {
AssistChip(onClick = {}, label = { Text("$activeTaskCount 个活跃任务") })
if (workerNames.isNotEmpty()) {
AssistChip(
onClick = {},
label = { Text(workerNames.joinToString(" · ").take(28)) },
)
}
}
}
}
}
@Composable
private fun GlobalMessageCard(
message: Message,
sessionTitle: String,
onSelectSession: () -> Unit,
) {
OutlinedCard(
modifier = Modifier
.fillMaxWidth()
.clickable { onSelectSession() },
) {
Column(
modifier = Modifier.padding(14.dp),
verticalArrangement = Arrangement.spacedBy(8.dp),
) {
Row(
modifier = Modifier.fillMaxWidth(),
horizontalArrangement = Arrangement.SpaceBetween,
) {
Text(sessionTitle, fontWeight = FontWeight.SemiBold)
Text(formatClock(message.createdAt), color = MaterialTheme.colorScheme.onSurfaceVariant)
}
Text(
"${roleLabel(message.role)} · ${message.channel}",
style = MaterialTheme.typography.bodySmall,
color = MaterialTheme.colorScheme.onSurfaceVariant,
)
Text(
message.content,
maxLines = 3,
overflow = TextOverflow.Ellipsis,
)
}
}
}
@Composable
private fun MessageCard(
message: Message,
@@ -939,6 +1291,7 @@ private fun TaskCard(
onResumeTask: (String) -> Unit,
onCancelTask: (String) -> Unit,
onRequeueTask: (String) -> Unit,
actionsEnabled: Boolean = true,
) {
OutlinedCard(modifier = Modifier.fillMaxWidth()) {
Column(
@@ -976,6 +1329,7 @@ private fun TaskCard(
)
}
if (actionsEnabled) {
Row(horizontalArrangement = Arrangement.spacedBy(10.dp)) {
when (task.status) {
"paused" -> OutlinedButton(onClick = { onResumeTask(task.id) }) { Text("恢复") }
@@ -999,6 +1353,7 @@ private fun TaskCard(
}
}
}
}
}
@Composable
@@ -1181,12 +1536,36 @@ private fun topBarSubtitle(
selectedWorker: WorkerNode?,
): String {
return when (uiState.section) {
MainSection.CONVERSATIONS -> selectedSession?.title ?: "在手机上直接和 Boss 对话"
MainSection.CONVERSATIONS -> selectedSession?.title ?: "查看所有客户端的项目与对话"
MainSection.DEVICES -> selectedWorker?.let { "设备焦点:${it.name}" } ?: "绑定、切换和检查设备"
MainSection.SETTINGS -> "云端地址、健康状态与重排"
}
}
private fun syncStatusShortLabel(state: SyncConnectionState): String {
return when (state) {
SyncConnectionState.CONNECTING -> "连接中"
SyncConnectionState.LIVE -> "实时同步"
SyncConnectionState.RECONNECTING -> "重连中"
}
}
private fun syncStatusLongLabel(state: SyncConnectionState): String {
return when (state) {
SyncConnectionState.CONNECTING -> "正在连接实时事件流,准备订阅电脑端和手机端的所有变化。"
SyncConnectionState.LIVE -> "项目、对话、审批和任务进度正在通过事件流实时刷新。"
SyncConnectionState.RECONNECTING -> "实时事件流暂时中断,系统正在重连,并且用轮询保持同步。"
}
}
private fun syncTone(state: SyncConnectionState): String {
return when (state) {
SyncConnectionState.LIVE -> "live"
SyncConnectionState.CONNECTING -> "queued"
SyncConnectionState.RECONNECTING -> "lagging"
}
}
private fun roleLabel(role: String): String {
return when (role) {
"user" -> ""

View File

@@ -24,6 +24,8 @@ import site.hyzq.bossandroid.model.Session
import site.hyzq.bossandroid.model.TaskItem
import site.hyzq.bossandroid.model.WorkerNode
import site.hyzq.bossandroid.network.BossApi
import site.hyzq.bossandroid.network.BossRealtimeSync
import site.hyzq.bossandroid.network.SyncConnectionState
enum class MainSection {
CONVERSATIONS,
@@ -32,6 +34,7 @@ enum class MainSection {
}
enum class ConversationTab {
OVERVIEW,
CHAT,
TASKS,
APPROVALS,
@@ -51,7 +54,7 @@ data class GeneratedWorkerCommand(
data class BossUiState(
val baseUrl: String = BossApi.DEFAULT_BASE_URL,
val section: MainSection = MainSection.CONVERSATIONS,
val conversationTab: ConversationTab = ConversationTab.CHAT,
val conversationTab: ConversationTab = ConversationTab.OVERVIEW,
val sessions: List<Session> = emptyList(),
val messages: List<Message> = emptyList(),
val tasks: List<TaskItem> = emptyList(),
@@ -60,17 +63,21 @@ data class BossUiState(
val events: List<BossEvent> = emptyList(),
val selectedSessionId: String? = null,
val selectedWorkerId: String? = null,
val syncConnectionState: SyncConnectionState = SyncConnectionState.CONNECTING,
val health: HealthPayload? = null,
val isRefreshing: Boolean = false,
val generatedCommand: GeneratedWorkerCommand? = null,
val notice: UiNotice? = null,
val lastSyncedAt: Long? = null,
val lastEventAt: Long? = null,
val lastEventType: String? = null,
)
class BossViewModel(
application: Application,
) : AndroidViewModel(application) {
private val api = BossApi()
private val realtimeSync = BossRealtimeSync()
private val prefs = application.getSharedPreferences(PREFS_NAME, Context.MODE_PRIVATE)
private val refreshMutex = Mutex()
private val _uiState = MutableStateFlow(
@@ -81,10 +88,12 @@ class BossViewModel(
val uiState: StateFlow<BossUiState> = _uiState.asStateFlow()
private var pollingJob: Job? = null
private var streamJob: Job? = null
private var scheduledRefreshJob: Job? = null
init {
refresh(showSpinner = true)
startPolling()
restartRealtimeSync()
}
fun selectSection(section: MainSection) {
@@ -117,6 +126,7 @@ class BossViewModel(
val sanitized = sanitizeBaseUrl(input)
prefs.edit().putString(KEY_BASE_URL, sanitized).apply()
_uiState.update { it.copy(baseUrl = sanitized) }
restartRealtimeSync()
refresh(showSpinner = true)
}
@@ -259,6 +269,13 @@ class BossViewModel(
}
}
override fun onCleared() {
streamJob?.cancel()
pollingJob?.cancel()
scheduledRefreshJob?.cancel()
super.onCleared()
}
private fun runTaskMutation(
taskId: String,
successMessage: String,
@@ -365,6 +382,53 @@ class BossViewModel(
private fun currentBaseUrl(): String = uiState.value.baseUrl
private fun restartRealtimeSync() {
streamJob?.cancel()
stopPolling()
scheduledRefreshJob?.cancel()
streamJob = viewModelScope.launch {
realtimeSync.run(
baseUrl = currentBaseUrl(),
onStatusChanged = ::handleSyncStatusChange,
onEvent = ::handleRealtimeEvent,
)
}
}
private fun handleSyncStatusChange(state: SyncConnectionState) {
_uiState.update { current ->
current.copy(syncConnectionState = state)
}
if (state == SyncConnectionState.LIVE) {
stopPolling()
} else if (state == SyncConnectionState.RECONNECTING) {
startPolling()
}
}
private fun handleRealtimeEvent(event: BossEvent) {
_uiState.update { current ->
current.copy(
events = (listOf(event) + current.events).distinctBy { it.id }.take(MAX_EVENT_CACHE),
lastEventAt = System.currentTimeMillis(),
lastEventType = event.type,
)
}
scheduleRealtimeRefresh()
}
private fun scheduleRealtimeRefresh() {
if (scheduledRefreshJob?.isActive == true) {
return
}
scheduledRefreshJob = viewModelScope.launch {
delay(250)
refreshSnapshot(showSpinner = false)
}
}
private fun startPolling() {
pollingJob?.cancel()
pollingJob = viewModelScope.launch {
@@ -375,6 +439,11 @@ class BossViewModel(
}
}
private fun stopPolling() {
pollingJob?.cancel()
pollingJob = null
}
private fun sanitizeBaseUrl(input: String): String {
val trimmed = input.trim().trimEnd('/')
if (trimmed.isBlank()) {
@@ -465,7 +534,8 @@ class BossViewModel(
companion object {
private const val KEY_BASE_URL = "boss_base_url"
private const val POLL_INTERVAL_MS = 4_500L
private const val MAX_EVENT_CACHE = 300
private const val POLL_INTERVAL_MS = 2_000L
private const val PREFS_NAME = "boss_android"
}
}

View File

@@ -3,7 +3,9 @@ set -euo pipefail
ROOT_DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")/.." && pwd)"
DEFAULT_SERIAL="adb-A9TU024628000647-B0UNif._adb-tls-connect._tcp"
DEFAULT_SDK_DIR="/opt/homebrew/share/android-commandlinetools"
PACKAGE_NAME="site.hyzq.bossandroid"
ACTIVITY_NAME=".MainActivity"
APK_PATH="$ROOT_DIR/android-app/app/build/outputs/apk/debug/app-debug.apk"
TARGET_SERIAL="${BOSS_ANDROID_SERIAL:-$DEFAULT_SERIAL}"
@@ -18,6 +20,7 @@ Commands:
launch Launch the Boss app on the Honor device
build-install Build the debug APK and install it on the Honor device
screenshot Save a screenshot to /tmp/boss-honor-screen.png
ui-dump Dump the current UI hierarchy to /tmp/boss-honor-ui.xml
logcat Tail logcat for the Boss app only
Environment:
@@ -47,6 +50,12 @@ ensure_apk() {
build_debug_apk() {
(
cd "$ROOT_DIR/android-app"
if [[ -z "${ANDROID_HOME:-}" && -d "$DEFAULT_SDK_DIR" ]]; then
export ANDROID_HOME="$DEFAULT_SDK_DIR"
fi
if [[ -z "${ANDROID_SDK_ROOT:-}" && -d "${ANDROID_HOME:-}" ]]; then
export ANDROID_SDK_ROOT="$ANDROID_HOME"
fi
./gradlew assembleDebug
)
}
@@ -71,13 +80,13 @@ case "$command" in
;;
launch)
ensure_connected
adb -s "$TARGET_SERIAL" shell monkey -p "$PACKAGE_NAME" -c android.intent.category.LAUNCHER 1
adb -s "$TARGET_SERIAL" shell am start -n "$PACKAGE_NAME/$ACTIVITY_NAME"
;;
build-install)
ensure_connected
build_debug_apk
adb -s "$TARGET_SERIAL" install -r "$APK_PATH"
adb -s "$TARGET_SERIAL" shell monkey -p "$PACKAGE_NAME" -c android.intent.category.LAUNCHER 1
adb -s "$TARGET_SERIAL" shell am start -n "$PACKAGE_NAME/$ACTIVITY_NAME"
;;
screenshot)
ensure_connected
@@ -86,6 +95,13 @@ case "$command" in
adb -s "$TARGET_SERIAL" shell rm -f /sdcard/boss-honor-screen.png >/dev/null
echo "/tmp/boss-honor-screen.png"
;;
ui-dump)
ensure_connected
adb -s "$TARGET_SERIAL" shell uiautomator dump /sdcard/boss-honor-ui.xml >/dev/null
adb -s "$TARGET_SERIAL" pull /sdcard/boss-honor-ui.xml /tmp/boss-honor-ui.xml >/dev/null
adb -s "$TARGET_SERIAL" shell rm -f /sdcard/boss-honor-ui.xml >/dev/null
echo "/tmp/boss-honor-ui.xml"
;;
logcat)
ensure_connected
adb -s "$TARGET_SERIAL" logcat --pid="$(adb -s "$TARGET_SERIAL" shell pidof "$PACKAGE_NAME")"