feat(mac): add onboarding chat kickoff

This commit is contained in:
Peter Steinberger
2025-12-20 16:51:33 +00:00
parent e618a21f4e
commit 6b56f7d643
2 changed files with 60 additions and 50 deletions

View File

@@ -1,4 +1,5 @@
import AppKit
import ClawdisChatUI
import ClawdisIPC
import Combine
import Observation
@@ -72,9 +73,12 @@ struct OnboardingView: View {
@State private var identityEmoji: String = ""
@State private var identityStatus: String?
@State private var identityApplying = false
@State private var hasIdentity = false
@State private var didAutoKickoff = false
@State private var showAdvancedConnection = false
@State private var preferredGatewayID: String?
@State private var gatewayDiscovery: GatewayDiscoveryModel
@State private var onboardingChatModel: ClawdisChatViewModel
@State private var localGatewayProbe: LocalGatewayProbe?
@Bindable private var state: AppState
private var permissionMonitor: PermissionMonitor
@@ -83,24 +87,28 @@ struct OnboardingView: View {
private let contentHeight: CGFloat = 520
private let connectionPageIndex = 1
private let anthropicAuthPageIndex = 2
private let onboardingChatPageIndex = 8
private static let clipboardPoll = Timer.publish(every: 0.4, on: .main, in: .common).autoconnect()
private let permissionsPageIndex = 5
static func pageOrder(for mode: AppState.ConnectionMode) -> [Int] {
static func pageOrder(
for mode: AppState.ConnectionMode,
hasIdentity: Bool) -> [Int]
{
switch mode {
case .remote:
// Remote setup doesn't need local gateway/CLI/workspace setup pages,
// and WhatsApp/Telegram setup is optional.
[0, 1, 5, 9]
return hasIdentity ? [0, 1, 5, 9] : [0, 1, 5, 8, 9]
case .unconfigured:
[0, 1, 9]
return hasIdentity ? [0, 1, 9] : [0, 1, 8, 9]
case .local:
[0, 1, 2, 5, 6, 8, 9]
return hasIdentity ? [0, 1, 2, 5, 6, 9] : [0, 1, 2, 5, 6, 8, 9]
}
}
private var pageOrder: [Int] {
Self.pageOrder(for: self.state.connectionMode)
Self.pageOrder(for: self.state.connectionMode, hasIdentity: self.hasIdentity)
}
private var pageCount: Int { self.pageOrder.count }
@@ -129,6 +137,10 @@ struct OnboardingView: View {
self.state = state
self.permissionMonitor = permissionMonitor
self._gatewayDiscovery = State(initialValue: discoveryModel)
self._onboardingChatModel = State(
initialValue: ClawdisChatViewModel(
sessionKey: "onboarding",
transport: MacGatewayChatTransport()))
}
var body: some View {
@@ -169,6 +181,11 @@ struct OnboardingView: View {
self.reconcilePageForModeChange(previousActivePageIndex: oldActive)
self.updateDiscoveryMonitoring(for: self.activePageIndex)
}
.onChange(of: self.hasIdentity) { _, _ in
if self.currentPage >= self.pageOrder.count {
self.currentPage = max(0, self.pageOrder.count - 1)
}
}
.onDisappear {
self.stopPermissionMonitoring()
self.stopDiscovery()
@@ -219,7 +236,7 @@ struct OnboardingView: View {
case 6:
self.cliPage()
case 8:
self.whatsappPage()
self.onboardingChatPage()
case 9:
self.readyPage()
default:
@@ -988,58 +1005,22 @@ struct OnboardingView: View {
}
}
private func whatsappPage() -> some View {
private func onboardingChatPage() -> some View {
self.onboardingPage {
Text("Connect WhatsApp or Telegram")
Text("Meet your agent")
.font(.largeTitle.weight(.semibold))
Text(
"Optional: WhatsApp uses a QR login for your personal account. Telegram uses a bot token. " +
"Configure them on the machine where the gateway runs.")
"This is a dedicated onboarding chat. Your agent will introduce itself, " +
"learn who you are, and help you connect WhatsApp or Telegram if you want.")
.font(.body)
.foregroundStyle(.secondary)
.multilineTextAlignment(.center)
.frame(maxWidth: 520)
.fixedSize(horizontal: false, vertical: true)
self.onboardingCard {
self.featureRow(
title: "Open a terminal",
subtitle: "Use the machine where the gateway runs. If remote, SSH in first.",
systemImage: "terminal")
Text("WhatsApp")
.font(.headline)
self.featureRow(
title: "Run `clawdis login --verbose`",
subtitle: """
Scan the QR code with WhatsApp on your phone.
This links your personal session; no cloud gateway involved.
""",
systemImage: "qrcode.viewfinder")
self.featureRow(
title: "Re-link after timeouts",
subtitle: """
If Baileys auth expires, re-run login on that host.
Settings → General shows remote/local mode so you know where to run it.
""",
systemImage: "clock.arrow.circlepath")
Divider()
.padding(.vertical, 6)
Text("Telegram")
.font(.headline)
self.featureRow(
title: "Set `TELEGRAM_BOT_TOKEN`",
subtitle: """
Create a bot with @BotFather and set the token as an env var,
(or `telegram.botToken` in `~/.clawdis/clawdis.json`).
""",
systemImage: "key")
self.featureRow(
title: "Verify with `clawdis status --deep`",
subtitle: "This probes both WhatsApp and the Telegram API and prints whats configured.",
systemImage: "checkmark.shield")
self.onboardingCard(padding: 8) {
ClawdisChatView(viewModel: self.onboardingChatModel, style: .onboarding)
.frame(height: 420)
}
}
}
@@ -1288,6 +1269,7 @@ struct OnboardingView: View {
self.updatePermissionMonitoring(for: pageIndex)
self.updateDiscoveryMonitoring(for: pageIndex)
self.updateAuthMonitoring(for: pageIndex)
self.maybeKickoffOnboardingChat(for: pageIndex)
}
private func stopPermissionMonitoring() {
@@ -1399,9 +1381,11 @@ struct OnboardingView: View {
self.identityName = identity.name
self.identityTheme = identity.theme
self.identityEmoji = identity.emoji
self.hasIdentity = !identity.isEmpty
return
}
self.hasIdentity = false
if self.identityName.trimmingCharacters(in: .whitespacesAndNewlines).isEmpty {
self.identityName = "Clawd"
}
@@ -1468,6 +1452,27 @@ struct OnboardingView: View {
self.identityStatus = "Failed to save identity: \(error.localizedDescription)"
}
}
private func maybeKickoffOnboardingChat(for pageIndex: Int) {
guard pageIndex == self.onboardingChatPageIndex else { return }
guard !self.hasIdentity else { return }
guard !self.didAutoKickoff else { return }
self.didAutoKickoff = true
Task { @MainActor in
for _ in 0..<20 {
if !self.onboardingChatModel.isLoading { break }
try? await Task.sleep(nanoseconds: 200_000_000)
}
guard self.onboardingChatModel.messages.isEmpty else { return }
let kickoff =
"Hi! I just installed Clawdis and youre my brandnew agent. " +
"Please start the firstrun ritual from BOOTSTRAP.md, ask one question at a time, " +
"and guide me through choosing how we should talk (webonly, WhatsApp, or Telegram)."
self.onboardingChatModel.input = kickoff
self.onboardingChatModel.send()
}
}
}
private struct GlowingClawdisIcon: View {

View File

@@ -15,8 +15,13 @@ struct OnboardingViewSmokeTests {
}
@Test func pageOrderOmitsWorkspaceAndIdentitySteps() {
let order = OnboardingView.pageOrder(for: .local)
let order = OnboardingView.pageOrder(for: .local, hasIdentity: false)
#expect(!order.contains(7))
#expect(!order.contains(3))
}
@Test func pageOrderOmitsOnboardingChatWhenIdentityKnown() {
let order = OnboardingView.pageOrder(for: .local, hasIdentity: true)
#expect(!order.contains(8))
}
}