Skip to content
Merged
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
Expand Up @@ -9,10 +9,15 @@ import androidx.compose.foundation.layout.padding
import androidx.compose.foundation.layout.size
import androidx.compose.foundation.rememberScrollState
import androidx.compose.foundation.verticalScroll
import androidx.compose.material.icons.Icons
import androidx.compose.material.icons.filled.CheckCircle
import androidx.compose.material.icons.outlined.CheckCircle
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.TextButton
import androidx.compose.material3.pulltorefresh.PullToRefreshBox
import androidx.compose.runtime.Composable
import androidx.compose.runtime.LaunchedEffect
Expand All @@ -29,6 +34,7 @@ import androidx.compose.ui.unit.dp
import androidx.compose.ui.unit.sp
import com.openmausbot.companion.R
import com.openmausbot.companion.core.BotOverview
import com.openmausbot.companion.core.Chat
import kotlinx.coroutines.launch

/**
Expand Down Expand Up @@ -112,7 +118,13 @@ fun BotOverviewScreen(botId: String, onBack: () -> Unit) {
) {
CircularProgressIndicator(modifier = Modifier.size(28.dp), strokeWidth = 3.dp)
}
current != null -> OverviewBody(current)
current != null -> OverviewBody(
overview = current,
onSetup = {
val bot = state.bot(botId) ?: return@OverviewBody
scope.launch { session.send("/setup", Chat.BotChat(bot)) }
},
)
failed -> FormSection(header = null) {
Text(OverviewRules.FAILED, color = secondaryTint)
}
Expand All @@ -123,7 +135,36 @@ fun BotOverviewScreen(botId: String, onBack: () -> Unit) {
}

@Composable
private fun OverviewBody(overview: BotOverview) {
private fun OverviewBody(overview: BotOverview, onSetup: () -> Unit) {
// The setup checklist the desktop's Overview opens with. The steps point
// at desktop settings sections a phone cannot open, so they read as a
// list here; the one thing a phone can do is hand the setup to the bot
// itself (`ios/App/BotOverviewView.swift`).
val steps = overview.setup
val remaining = overview.remainingSetup
var setupSent by remember(overview) { mutableStateOf(false) }
if (steps != null && remaining.isNotEmpty()) {
FormSection(header = OverviewRules.setupHeader(steps.size - remaining.size, steps.size)) {
steps.forEach { step ->
IconNote(
text = step.label,
icon = if (step.done) Icons.Filled.CheckCircle else Icons.Outlined.CheckCircle,
tint = if (step.done) MaterialTheme.colorScheme.primary else secondaryTint,
)
}
TextButton(
onClick = {
setupSent = true
onSetup()
},
enabled = !setupSent,
) {
Text(if (setupSent) OverviewRules.SETUP_SENT else OverviewRules.SETUP_ACTION)
}
Text(OverviewRules.SETUP_FOOTER, color = secondaryTint, fontSize = 12.sp)
}
}

FormSection(header = OverviewRules.WHO) {
Text(overview.who.name, fontWeight = FontWeight.SemiBold)
if (overview.who.title.isNotBlank()) {
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -17,6 +17,14 @@ object OverviewRules {
const val EMPTY_RECENT: String = "No changes recorded yet."
const val FAILED: String = "Couldn't load the overview."

/** "Finish setting up · 3 of 5 done" — the checklist header, as iOS words it. */
fun setupHeader(done: Int, total: Int): String = "Finish setting up · $done of $total done"

const val SETUP_ACTION: String = "Set up with the bot"
const val SETUP_SENT: String = "Sent — see the chat"
const val SETUP_FOOTER: String =
"The bot interviews you in the chat and fills these in itself. Folders, apps and instructions are changed on your computer."

const val WHO: String = "Who"
const val DOES: String = "Does"
const val REACHES: String = "Can reach"
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -13,4 +13,9 @@ class OverviewRulesTest {
fun `the title names the bot`() {
assertEquals("What Maus does", OverviewRules.title("Maus"))
}

@Test
fun `the checklist header counts progress the way iOS does`() {
assertEquals("Finish setting up · 3 of 5 done", OverviewRules.setupHeader(3, 5))
}
}
Original file line number Diff line number Diff line change
Expand Up @@ -1019,11 +1019,30 @@ data class BotOverviewWho(val name: String, val title: String, val blurb: String
@Serializable
data class BotOverviewRecent(val at: Double, val summary: String)

/**
* One step of a bot's setup checklist. [section] names the desktop settings
* section that finishes it; the phone cannot open that section, so it is
* informational here.
*/
@Serializable
data class BotOverviewSetupStep(
val id: String,
val label: String,
val done: Boolean,
val section: String? = null,
)

@Serializable
data class BotOverview(
val who: BotOverviewWho,
val does: List<String> = emptyList(),
val reaches: List<String> = emptyList(),
val wont: List<String> = emptyList(),
val recent: List<BotOverviewRecent> = emptyList(),
)
/** Absent from desktops older than the checklist. */
val setup: List<BotOverviewSetupStep>? = null,
) {
/** The steps still open, in order; empty without a checklist or when all are done. */
val remainingSetup: List<BotOverviewSetupStep>
get() = setup.orEmpty().filter { !it.done }
}
Original file line number Diff line number Diff line change
Expand Up @@ -442,5 +442,20 @@ class DecodingTest {
assertEquals("File bugs.", overview.who.soulLead)
assertTrue(overview.does.isNotEmpty())
assertTrue(overview.wont.isNotEmpty())
// the setup checklist: five steps for a bot whose engine has no
// connected-apps tools; "talk" happens in the chat, so no section
assertEquals(listOf("identity", "soul", "folder", "schedule", "talk"), overview.setup?.map { it.id })
assertEquals(listOf("folder", "talk"), overview.remainingSetup.map { it.id })
assertEquals(null, overview.setup?.last()?.section)
assertEquals("identity", overview.setup?.first()?.section)
}

@Test
fun anOverviewWithoutAChecklistStillDecodes() {
val overview = CompanionJson.decodeFromString<BotOverview>(
"""{"who":{"name":"A","title":"","blurb":"","soulLead":""},"does":[],"reaches":[],"wont":[],"recent":[]}""",
)
assertEquals(null, overview.setup)
assertTrue(overview.remainingSetup.isEmpty())
}
}
34 changes: 34 additions & 0 deletions ios/App/BotOverviewView.swift
Original file line number Diff line number Diff line change
Expand Up @@ -12,10 +12,44 @@ struct BotOverviewView: View {
@State private var overview: BotOverview?
@State private var loading = false
@State private var failed = false
@State private var setupSent = false

var body: some View {
List {
if let overview {
// The setup checklist the desktop's Overview opens with. The
// steps point at desktop settings sections a phone cannot
// open, so they read as a list here; the one thing a phone
// can do is hand the setup to the bot itself.
let remaining = overview.remainingSetup
if let steps = overview.setup, !remaining.isEmpty {
Section {
ForEach(steps, id: \.id) { step in
Label {
Text(step.label)
.strikethrough(step.done)
.foregroundStyle(step.done ? .secondary : .primary)
} icon: {
Image(systemName: step.done ? "checkmark.circle.fill" : "circle")
.foregroundStyle(step.done ? Color.accentColor : Color.secondary)
}
}
Button {
Task {
await session.send("/setup", to: .bot(bot))
setupSent = true
}
} label: {
Label(setupSent ? "Sent — see the chat" : "Set up with the bot", systemImage: "sparkles")
}
.disabled(setupSent)
} header: {
Text("Finish setting up · \(steps.count - remaining.count) of \(steps.count) done")
} footer: {
Text("The bot interviews you in the chat and fills these in itself. Folders, apps and instructions are changed on your computer.")
}
}

Section("Who") {
Text(overview.who.name).font(.headline)
if !overview.who.title.isEmpty {
Expand Down
18 changes: 18 additions & 0 deletions ios/Sources/CompanionCore/Models.swift
Original file line number Diff line number Diff line change
Expand Up @@ -301,6 +301,16 @@ public struct BotOverviewRecent: Codable, Hashable, Sendable {
public var summary: String
}

/// One step of a bot's setup checklist. `section` names the desktop
/// settings section that finishes it; the phone shows the step but cannot
/// open that section, so it is informational here.
public struct BotOverviewSetupStep: Codable, Hashable, Sendable {
public var id: String
public var label: String
public var done: Bool
public var section: String?
}

/// A read-only summary of one bot: who it is, what it does, what it can
/// reach, what it won't do, and its recent activity. No settings and no
/// transcript — this is the shape a phone is allowed to poll for.
Expand All @@ -310,6 +320,14 @@ public struct BotOverview: Codable, Hashable, Sendable {
public var reaches: [String]
public var wont: [String]
public var recent: [BotOverviewRecent]
/// Absent from desktops older than the checklist.
public var setup: [BotOverviewSetupStep]?

/// The steps still open, in the order a person would do them. Empty when
/// the desktop sent no checklist or everything is done.
public var remainingSetup: [BotOverviewSetupStep] {
(setup ?? []).filter { !$0.done }
}
}

public struct GroupResponder: Codable, Hashable, Sendable {
Expand Down
13 changes: 13 additions & 0 deletions ios/Tests/CompanionCoreTests/DecodingTests.swift
Original file line number Diff line number Diff line change
Expand Up @@ -63,6 +63,19 @@ final class DecodingTests: XCTestCase {
XCTAssertFalse(overview.does.isEmpty)
XCTAssertFalse(overview.wont.isEmpty)
XCTAssertFalse(overview.recent.isEmpty)
// the setup checklist: five steps for a bot whose engine has no
// connected-apps tools; "talk" happens in the chat, so no section
XCTAssertEqual(overview.setup?.map(\.id), ["identity", "soul", "folder", "schedule", "talk"])
XCTAssertEqual(overview.remainingSetup.map(\.id), ["folder", "talk"])
XCTAssertNil(overview.setup?.last?.section)
XCTAssertEqual(overview.setup?.first?.section, "identity")
}

func testAnOverviewWithoutAChecklistStillDecodes() throws {
let data = Data(#"{"who":{"name":"A","title":"","blurb":"","soulLead":""},"does":[],"reaches":[],"wont":[],"recent":[]}"#.utf8)
let overview = try JSONDecoder().decode(BotOverview.self, from: data)
XCTAssertNil(overview.setup)
XCTAssertTrue(overview.remainingSetup.isEmpty)
}

func testStorePreviewRemainsADecodableFleet() throws {
Expand Down
31 changes: 31 additions & 0 deletions ios/Tests/CompanionCoreTests/Fixtures/bot-overview.json
Original file line number Diff line number Diff line change
Expand Up @@ -22,5 +22,36 @@
"at": 1788642361925,
"summary": "soul: 0 → 29 bytes"
}
],
"setup": [
{
"id": "identity",
"label": "Give it a name and a job title",
"done": true,
"section": "identity"
},
{
"id": "soul",
"label": "Write its standing instructions",
"done": true,
"section": "soul"
},
{
"id": "folder",
"label": "Pick a working folder",
"done": false,
"section": "access"
},
{
"id": "schedule",
"label": "Give it a schedule or a trigger",
"done": true,
"section": "routines"
},
{
"id": "talk",
"label": "Send it a first message",
"done": false
}
]
}
Loading