diff --git a/android/app/src/main/kotlin/com/openmausbot/companion/ui/BotOverviewScreen.kt b/android/app/src/main/kotlin/com/openmausbot/companion/ui/BotOverviewScreen.kt index 4f5bb73c92..bbc95a4621 100644 --- a/android/app/src/main/kotlin/com/openmausbot/companion/ui/BotOverviewScreen.kt +++ b/android/app/src/main/kotlin/com/openmausbot/companion/ui/BotOverviewScreen.kt @@ -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 @@ -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 /** @@ -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) } @@ -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()) { diff --git a/android/app/src/main/kotlin/com/openmausbot/companion/ui/OverviewRules.kt b/android/app/src/main/kotlin/com/openmausbot/companion/ui/OverviewRules.kt index 98495fdf45..3725717ef3 100644 --- a/android/app/src/main/kotlin/com/openmausbot/companion/ui/OverviewRules.kt +++ b/android/app/src/main/kotlin/com/openmausbot/companion/ui/OverviewRules.kt @@ -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" diff --git a/android/app/src/test/kotlin/com/openmausbot/companion/ui/OverviewRulesTest.kt b/android/app/src/test/kotlin/com/openmausbot/companion/ui/OverviewRulesTest.kt index 5995dd29ac..c7ea9f5e15 100644 --- a/android/app/src/test/kotlin/com/openmausbot/companion/ui/OverviewRulesTest.kt +++ b/android/app/src/test/kotlin/com/openmausbot/companion/ui/OverviewRulesTest.kt @@ -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)) + } } diff --git a/android/core/src/main/kotlin/com/openmausbot/companion/core/Models.kt b/android/core/src/main/kotlin/com/openmausbot/companion/core/Models.kt index a454e404b0..c9e5bd3bbc 100644 --- a/android/core/src/main/kotlin/com/openmausbot/companion/core/Models.kt +++ b/android/core/src/main/kotlin/com/openmausbot/companion/core/Models.kt @@ -1019,6 +1019,19 @@ 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, @@ -1026,4 +1039,10 @@ data class BotOverview( val reaches: List = emptyList(), val wont: List = emptyList(), val recent: List = emptyList(), -) + /** Absent from desktops older than the checklist. */ + val setup: List? = null, +) { + /** The steps still open, in order; empty without a checklist or when all are done. */ + val remainingSetup: List + get() = setup.orEmpty().filter { !it.done } +} diff --git a/android/core/src/test/kotlin/com/openmausbot/companion/core/DecodingTest.kt b/android/core/src/test/kotlin/com/openmausbot/companion/core/DecodingTest.kt index ed473bc822..99d511b442 100644 --- a/android/core/src/test/kotlin/com/openmausbot/companion/core/DecodingTest.kt +++ b/android/core/src/test/kotlin/com/openmausbot/companion/core/DecodingTest.kt @@ -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( + """{"who":{"name":"A","title":"","blurb":"","soulLead":""},"does":[],"reaches":[],"wont":[],"recent":[]}""", + ) + assertEquals(null, overview.setup) + assertTrue(overview.remainingSetup.isEmpty()) } } diff --git a/ios/App/BotOverviewView.swift b/ios/App/BotOverviewView.swift index 26363f5ac9..ec1ea059a8 100644 --- a/ios/App/BotOverviewView.swift +++ b/ios/App/BotOverviewView.swift @@ -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 { diff --git a/ios/Sources/CompanionCore/Models.swift b/ios/Sources/CompanionCore/Models.swift index 1962f334a9..37105b4308 100644 --- a/ios/Sources/CompanionCore/Models.swift +++ b/ios/Sources/CompanionCore/Models.swift @@ -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. @@ -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 { diff --git a/ios/Tests/CompanionCoreTests/DecodingTests.swift b/ios/Tests/CompanionCoreTests/DecodingTests.swift index 0945fa20af..f14f8c6b72 100644 --- a/ios/Tests/CompanionCoreTests/DecodingTests.swift +++ b/ios/Tests/CompanionCoreTests/DecodingTests.swift @@ -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 { diff --git a/ios/Tests/CompanionCoreTests/Fixtures/bot-overview.json b/ios/Tests/CompanionCoreTests/Fixtures/bot-overview.json index b88cf6cb97..b538d631c5 100644 --- a/ios/Tests/CompanionCoreTests/Fixtures/bot-overview.json +++ b/ios/Tests/CompanionCoreTests/Fixtures/bot-overview.json @@ -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 + } ] }