Skip to content
Closed
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
Original file line number Diff line number Diff line change
@@ -0,0 +1,73 @@
package com.openmausbot.companion.ui

import com.openmausbot.companion.core.ActivityRow
import java.time.Instant
import java.time.LocalDate
import java.time.ZoneId
import java.time.format.DateTimeFormatter
import java.time.format.DateTimeParseException

/**
* A bot's activity log, as rules — the copy and the grouping behind
* [BotActivityScreen]. Rows arrive newest first from the computer; the
* screen shows them under day headings with an outcome chip, matching
* `ios/App/BotActivityView.swift` and the desktop's Activity panel.
*/
object ActivityRules {
const val PROFILE_ROW: String = "Activity"
const val FAILED: String = "Couldn't load the activity log."

fun title(name: String): String = "$name activity"

fun empty(name: String): String = "Nothing yet. Once $name runs a tool or asks for an approval, it shows up here."

/** One short word per outcome, and whether it reads as good, bad, live, or waiting. */
enum class Tone { GOOD, BAD, LIVE, WAITING, PLAIN }

data class Chip(val text: String, val tone: Tone)

fun chip(outcome: String): Chip = when (outcome) {
"ran" -> Chip("Ran", Tone.GOOD)
"allowed" -> Chip("Allowed", Tone.GOOD)
"failed" -> Chip("Failed", Tone.BAD)
"denied" -> Chip("Denied", Tone.BAD)
"running" -> Chip("Running", Tone.LIVE)
"waiting" -> Chip("Needs you", Tone.WAITING)
else -> Chip(outcome, Tone.PLAIN)
}

data class Day(val key: LocalDate, val label: String, val rows: List<ActivityRow>)

private val dayFormat: DateTimeFormatter = DateTimeFormatter.ofPattern("EEE d MMM")
private val timeFormat: DateTimeFormatter = DateTimeFormatter.ofPattern("HH:mm")

fun instant(iso: String): Instant? = try {
Instant.parse(iso)
} catch (_: DateTimeParseException) {
null
}

/** "09:00" in the viewer's zone, or nothing when the stamp is unreadable. */
fun time(iso: String, zone: ZoneId = ZoneId.systemDefault()): String =
instant(iso)?.atZone(zone)?.toLocalTime()?.format(timeFormat).orEmpty()

/** Group newest-first rows under their local day, keeping that order. */
fun days(rows: List<ActivityRow>, today: LocalDate = LocalDate.now(), zone: ZoneId = ZoneId.systemDefault()): List<Day> {
val result = mutableListOf<Day>()
for (row in rows) {
val date = instant(row.at)?.atZone(zone)?.toLocalDate() ?: today
val last = result.lastOrNull()
if (last != null && last.key == date) {
result[result.lastIndex] = last.copy(rows = last.rows + row)
} else {
val label = when (date) {
today -> "Today"
today.minusDays(1) -> "Yesterday"
else -> date.format(dayFormat)
}
result += Day(date, label, listOf(row))
}
}
return result
}
}
Original file line number Diff line number Diff line change
Expand Up @@ -19,6 +19,8 @@ import androidx.compose.material.icons.Icons
import androidx.compose.material.icons.filled.AddCircle
import androidx.compose.material.icons.filled.Check
import androidx.compose.material.icons.filled.Delete
import androidx.compose.material.icons.filled.Face
import androidx.compose.material.icons.filled.List
import androidx.compose.material.icons.filled.Info
import androidx.compose.material.icons.filled.Warning
import androidx.compose.material3.CircularProgressIndicator
Expand Down Expand Up @@ -90,7 +92,13 @@ import kotlinx.coroutines.withContext
*/
@OptIn(ExperimentalMaterial3Api::class)
@Composable
internal fun AgentProfileSheet(bot: Bot, onDismiss: () -> Unit, onOpenOverview: (String) -> Unit) {
internal fun AgentProfileSheet(
bot: Bot,
onDismiss: () -> Unit,
onOpenOverview: (String) -> Unit,
onOpenActivity: (String) -> Unit = {},
onOpenTeamMemory: (String) -> Unit = {},
) {
val environment = LocalCompanion.current
val session = environment.session
val state by session.state.collectAsState()
Expand Down Expand Up @@ -219,6 +227,16 @@ internal fun AgentProfileSheet(bot: Bot, onDismiss: () -> Unit, onOpenOverview:
icon = Icons.Filled.Info,
onClick = { onOpenOverview(bot.id) },
)
ActionRow(
text = ActivityRules.PROFILE_ROW,
icon = Icons.Filled.List,
onClick = { onOpenActivity(bot.id) },
)
ActionRow(
text = TeamMemoryRules.PROFILE_ROW,
icon = Icons.Filled.Face,
onClick = { onOpenTeamMemory(bot.section.orEmpty()) },
)
}

FormSection(header = "Model", footer = ModelRules.FOOTER) {
Expand Down
Original file line number Diff line number Diff line change
@@ -0,0 +1,170 @@
package com.openmausbot.companion.ui

import androidx.compose.foundation.background
import androidx.compose.foundation.layout.Arrangement
import androidx.compose.foundation.layout.Column
import androidx.compose.foundation.layout.Row
import androidx.compose.foundation.layout.fillMaxSize
import androidx.compose.foundation.layout.fillMaxWidth
import androidx.compose.foundation.layout.padding
import androidx.compose.foundation.layout.size
import androidx.compose.foundation.layout.width
import androidx.compose.foundation.rememberScrollState
import androidx.compose.foundation.shape.RoundedCornerShape
import androidx.compose.foundation.verticalScroll
import androidx.compose.material3.CircularProgressIndicator
import androidx.compose.material3.ExperimentalMaterial3Api
import androidx.compose.material3.HorizontalDivider
import androidx.compose.material3.MaterialTheme
import androidx.compose.material3.Text
import androidx.compose.material3.pulltorefresh.PullToRefreshBox
import androidx.compose.runtime.Composable
import androidx.compose.runtime.LaunchedEffect
import androidx.compose.runtime.collectAsState
import androidx.compose.runtime.getValue
import androidx.compose.runtime.mutableStateOf
import androidx.compose.runtime.remember
import androidx.compose.runtime.rememberCoroutineScope
import androidx.compose.runtime.setValue
import androidx.compose.ui.Alignment
import androidx.compose.ui.Modifier
import androidx.compose.ui.text.font.FontFamily
import androidx.compose.ui.text.font.FontWeight
import androidx.compose.ui.text.style.TextOverflow
import androidx.compose.ui.unit.dp
import androidx.compose.ui.unit.sp
import com.openmausbot.companion.core.ActivityRow
import kotlinx.coroutines.launch

/**
* What one bot did, newest first, grouped by day: every tool it used and
* every approval it asked for, each with the outcome. The phone twin of the
* desktop's Activity panel and of `ios/App/BotActivityView.swift`;
* read-only, like [BotOverviewScreen] beside it.
*/
@OptIn(ExperimentalMaterial3Api::class)
@Composable
fun BotActivityScreen(botId: String, onBack: () -> Unit) {
val environment = LocalCompanion.current
val session = environment.session
val state by session.state.collectAsState()
val connection by session.connection.collectAsState()
val connectionId = connection?.id
val scope = rememberCoroutineScope()

var rows by remember(botId, connectionId) { mutableStateOf<List<ActivityRow>?>(null) }
var loading by remember(botId, connectionId) { mutableStateOf(true) }
var refreshing by remember(botId, connectionId) { mutableStateOf(false) }
var failed by remember(botId, connectionId) { mutableStateOf(false) }

suspend fun refresh(showProgress: Boolean = false) {
if (showProgress) refreshing = true
try {
val loaded = session.loadActivity(botId)
failed = loaded == null
if (loaded != null) rows = loaded
} finally {
if (showProgress) refreshing = false
}
}

LaunchedEffect(botId, connectionId) {
loading = true
try {
refresh()
} finally {
loading = false
}
}

val name = state.bot(botId)?.name.orEmpty()

Column(modifier = Modifier.fillMaxSize()) {
Row(
modifier = Modifier
.fillMaxWidth()
.padding(horizontal = 10.dp, vertical = 8.dp),
horizontalArrangement = Arrangement.spacedBy(10.dp),
verticalAlignment = Alignment.CenterVertically,
) {
HeaderBackButton(onBack)
Text(ActivityRules.title(name), fontSize = 17.sp, fontWeight = FontWeight.SemiBold, modifier = Modifier.weight(1f))
}
HorizontalDivider()

PullToRefreshBox(
isRefreshing = refreshing,
onRefresh = { scope.launch { refresh(showProgress = true) } },
modifier = Modifier.fillMaxSize(),
) {
Column(
modifier = Modifier
.fillMaxSize()
.verticalScroll(rememberScrollState())
.padding(vertical = 16.dp),
verticalArrangement = Arrangement.spacedBy(16.dp),
) {
val current = rows
when {
loading -> Row(
modifier = Modifier.fillMaxWidth().padding(horizontal = 20.dp),
horizontalArrangement = Arrangement.Center,
) {
CircularProgressIndicator(modifier = Modifier.size(28.dp), strokeWidth = 3.dp)
}
current != null && current.isEmpty() -> FormSection(header = null) {
Text(ActivityRules.empty(name), color = secondaryTint)
}
current != null -> ActivityRules.days(current).forEach { day ->
FormSection(header = day.label) {
day.rows.forEach { ActivityLine(it) }
}
}
failed -> FormSection(header = null) {
Text(ActivityRules.FAILED, color = secondaryTint)
}
}
}
}
}
}

@Composable
private fun ActivityLine(row: ActivityRow) {
val chip = ActivityRules.chip(row.outcome)
val chipColor = when (chip.tone) {
ActivityRules.Tone.GOOD -> MaterialTheme.colorScheme.primary
ActivityRules.Tone.BAD -> MaterialTheme.colorScheme.error
ActivityRules.Tone.LIVE -> MaterialTheme.colorScheme.primary
ActivityRules.Tone.WAITING -> MaterialTheme.colorScheme.tertiary
ActivityRules.Tone.PLAIN -> secondaryTint
}
Row(
modifier = Modifier.fillMaxWidth(),
horizontalArrangement = Arrangement.spacedBy(10.dp),
verticalAlignment = Alignment.Top,
) {
Text(ActivityRules.time(row.at), fontSize = 12.sp, color = secondaryTint, modifier = Modifier.width(44.dp))
Column(modifier = Modifier.weight(1f)) {
Row(horizontalArrangement = Arrangement.spacedBy(4.dp)) {
row.app?.let {
Text(it, fontSize = 15.sp, fontWeight = FontWeight.Medium)
Text("·", fontSize = 15.sp, color = secondaryTint)
}
Text(row.label, fontSize = 15.sp, maxLines = 1, overflow = TextOverflow.Ellipsis)
}
row.summary?.takeIf { it.isNotBlank() }?.let {
Text(it, fontSize = 12.sp, fontFamily = FontFamily.Monospace, color = secondaryTint, maxLines = 2, overflow = TextOverflow.Ellipsis)
}
}
Text(
chip.text,
fontSize = 11.sp,
fontWeight = FontWeight.Medium,
color = chipColor,
modifier = Modifier
.background(chipColor.copy(alpha = 0.15f), RoundedCornerShape(999.dp))
.padding(horizontal = 6.dp, vertical = 2.dp),
)
}
}
Original file line number Diff line number Diff line change
Expand Up @@ -139,6 +139,8 @@ fun ChatScreen(
onBack: () -> Unit,
onOpenComputer: (String) -> Unit,
onOpenOverview: (String) -> Unit,
onOpenActivity: (String) -> Unit,
onOpenTeamMemory: (String) -> Unit,
/**
* True while this conversation is still on the navigator stack (including
* under Computer). Used on dispose to keep the in-memory draft across a
Expand Down Expand Up @@ -166,7 +168,7 @@ fun ChatScreen(
// that is open is the one deleted.
val resolved = (destination as? Destination.Thread)?.let { resolution.chat.target }
LaunchedEffect(resolved) { if (resolved != null) onResolved(resolved) }
LoadedChat(resolution.chat, state, onBack, onOpenComputer, onOpenOverview, retainsDraft)
LoadedChat(resolution.chat, state, onBack, onOpenComputer, onOpenOverview, onOpenActivity, onOpenTeamMemory, retainsDraft)
}
}
}
Expand Down Expand Up @@ -196,6 +198,8 @@ private fun LoadedChat(
onBack: () -> Unit,
onOpenComputer: (String) -> Unit,
onOpenOverview: (String) -> Unit,
onOpenActivity: (String) -> Unit,
onOpenTeamMemory: (String) -> Unit,
retainsDraft: (chatId: String) -> Boolean,
) {
// The bot's *current* thread, not the one the destination named. Switching or
Expand Down Expand Up @@ -972,6 +976,14 @@ private fun LoadedChat(
onOpenOverview(it)
showingProfile = false
},
onOpenActivity = {
onOpenActivity(it)
showingProfile = false
},
onOpenTeamMemory = {
onOpenTeamMemory(it)
showingProfile = false
},
)
}

Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -46,6 +46,15 @@ sealed interface Destination {
*/
data class Overview(val botId: String) : Destination

/** A bot's activity log: what it did, with the outcome. Read-only. */
data class Activity(val botId: String) : Destination

/**
* A section's team memory — the people, places, decisions and terms every
* bot there shares. Addressed by the section key; "" is General.
*/
data class TeamMemory(val section: String) : Destination

/**
* A conversation, in one of the two ways something can name one.
*
Expand Down Expand Up @@ -138,6 +147,8 @@ class CompanionNavigator(initial: List<Destination> = listOf(Destination.Roster)
private const val THREAD = "thread:"
private const val COMPUTER = "computer:"
private const val OVERVIEW = "overview:"
private const val ACTIVITY = "activity:"
private const val TEAM_MEMORY = "teammemory:"
private const val BOT_CHAT = "botchat:"
private const val ROOM_CHAT = "roomchat:"

Expand All @@ -150,6 +161,8 @@ class CompanionNavigator(initial: List<Destination> = listOf(Destination.Roster)
is Destination.Thread -> THREAD + it.threadId
is Destination.Computer -> COMPUTER + it.botId
is Destination.Overview -> OVERVIEW + it.botId
is Destination.Activity -> ACTIVITY + it.botId
is Destination.TeamMemory -> TEAM_MEMORY + it.section
is Destination.Chat -> when (val target = it.target) {
is ChatTarget.Bot -> BOT_CHAT + join(target.botId, target.threadId)
is ChatTarget.Room -> ROOM_CHAT + join(target.roomId, target.threadId)
Expand All @@ -166,6 +179,8 @@ class CompanionNavigator(initial: List<Destination> = listOf(Destination.Roster)
it.startsWith(THREAD) -> Destination.Thread(it.removePrefix(THREAD))
it.startsWith(COMPUTER) -> Destination.Computer(it.removePrefix(COMPUTER))
it.startsWith(OVERVIEW) -> Destination.Overview(it.removePrefix(OVERVIEW))
it.startsWith(ACTIVITY) -> Destination.Activity(it.removePrefix(ACTIVITY))
it.startsWith(TEAM_MEMORY) -> Destination.TeamMemory(it.removePrefix(TEAM_MEMORY))
it.startsWith(BOT_CHAT) -> split(it.removePrefix(BOT_CHAT))
?.let { (owner, thread) -> Destination.Chat(ChatTarget.Bot(owner, thread)) }
it.startsWith(ROOM_CHAT) -> split(it.removePrefix(ROOM_CHAT))
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -387,6 +387,8 @@ private fun PairedScreen(
onBack = navigator::pop,
onOpenComputer = { navigator.push(Destination.Computer(it)) },
onOpenOverview = { navigator.push(Destination.Overview(it)) },
onOpenActivity = { navigator.push(Destination.Activity(it)) },
onOpenTeamMemory = { navigator.push(Destination.TeamMemory(it)) },
// Push Computer keeps the chat under the top; pop to roster does not.
retainsDraft = navigator::retainsChatDraft,
)
Expand All @@ -398,6 +400,14 @@ private fun PairedScreen(
botId = destination.botId,
onBack = navigator::pop,
)
is Destination.Activity -> BotActivityScreen(
botId = destination.botId,
onBack = navigator::pop,
)
is Destination.TeamMemory -> TeamMemoryScreen(
section = destination.section,
onBack = navigator::pop,
)
}
}

Expand Down
Loading
Loading