feat(mac): add onboarding chat kickoff
parent
e618a21f4e
commit
6b56f7d643
|
|
@ -1,4 +1,5 @@
|
||||||
import AppKit
|
import AppKit
|
||||||
|
import ClawdisChatUI
|
||||||
import ClawdisIPC
|
import ClawdisIPC
|
||||||
import Combine
|
import Combine
|
||||||
import Observation
|
import Observation
|
||||||
|
|
@ -72,9 +73,12 @@ struct OnboardingView: View {
|
||||||
@State private var identityEmoji: String = ""
|
@State private var identityEmoji: String = ""
|
||||||
@State private var identityStatus: String?
|
@State private var identityStatus: String?
|
||||||
@State private var identityApplying = false
|
@State private var identityApplying = false
|
||||||
|
@State private var hasIdentity = false
|
||||||
|
@State private var didAutoKickoff = false
|
||||||
@State private var showAdvancedConnection = false
|
@State private var showAdvancedConnection = false
|
||||||
@State private var preferredGatewayID: String?
|
@State private var preferredGatewayID: String?
|
||||||
@State private var gatewayDiscovery: GatewayDiscoveryModel
|
@State private var gatewayDiscovery: GatewayDiscoveryModel
|
||||||
|
@State private var onboardingChatModel: ClawdisChatViewModel
|
||||||
@State private var localGatewayProbe: LocalGatewayProbe?
|
@State private var localGatewayProbe: LocalGatewayProbe?
|
||||||
@Bindable private var state: AppState
|
@Bindable private var state: AppState
|
||||||
private var permissionMonitor: PermissionMonitor
|
private var permissionMonitor: PermissionMonitor
|
||||||
|
|
@ -83,24 +87,28 @@ struct OnboardingView: View {
|
||||||
private let contentHeight: CGFloat = 520
|
private let contentHeight: CGFloat = 520
|
||||||
private let connectionPageIndex = 1
|
private let connectionPageIndex = 1
|
||||||
private let anthropicAuthPageIndex = 2
|
private let anthropicAuthPageIndex = 2
|
||||||
|
private let onboardingChatPageIndex = 8
|
||||||
|
|
||||||
private static let clipboardPoll = Timer.publish(every: 0.4, on: .main, in: .common).autoconnect()
|
private static let clipboardPoll = Timer.publish(every: 0.4, on: .main, in: .common).autoconnect()
|
||||||
private let permissionsPageIndex = 5
|
private let permissionsPageIndex = 5
|
||||||
static func pageOrder(for mode: AppState.ConnectionMode) -> [Int] {
|
static func pageOrder(
|
||||||
|
for mode: AppState.ConnectionMode,
|
||||||
|
hasIdentity: Bool) -> [Int]
|
||||||
|
{
|
||||||
switch mode {
|
switch mode {
|
||||||
case .remote:
|
case .remote:
|
||||||
// Remote setup doesn't need local gateway/CLI/workspace setup pages,
|
// Remote setup doesn't need local gateway/CLI/workspace setup pages,
|
||||||
// and WhatsApp/Telegram setup is optional.
|
// and WhatsApp/Telegram setup is optional.
|
||||||
[0, 1, 5, 9]
|
return hasIdentity ? [0, 1, 5, 9] : [0, 1, 5, 8, 9]
|
||||||
case .unconfigured:
|
case .unconfigured:
|
||||||
[0, 1, 9]
|
return hasIdentity ? [0, 1, 9] : [0, 1, 8, 9]
|
||||||
case .local:
|
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] {
|
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 }
|
private var pageCount: Int { self.pageOrder.count }
|
||||||
|
|
@ -129,6 +137,10 @@ struct OnboardingView: View {
|
||||||
self.state = state
|
self.state = state
|
||||||
self.permissionMonitor = permissionMonitor
|
self.permissionMonitor = permissionMonitor
|
||||||
self._gatewayDiscovery = State(initialValue: discoveryModel)
|
self._gatewayDiscovery = State(initialValue: discoveryModel)
|
||||||
|
self._onboardingChatModel = State(
|
||||||
|
initialValue: ClawdisChatViewModel(
|
||||||
|
sessionKey: "onboarding",
|
||||||
|
transport: MacGatewayChatTransport()))
|
||||||
}
|
}
|
||||||
|
|
||||||
var body: some View {
|
var body: some View {
|
||||||
|
|
@ -169,6 +181,11 @@ struct OnboardingView: View {
|
||||||
self.reconcilePageForModeChange(previousActivePageIndex: oldActive)
|
self.reconcilePageForModeChange(previousActivePageIndex: oldActive)
|
||||||
self.updateDiscoveryMonitoring(for: self.activePageIndex)
|
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 {
|
.onDisappear {
|
||||||
self.stopPermissionMonitoring()
|
self.stopPermissionMonitoring()
|
||||||
self.stopDiscovery()
|
self.stopDiscovery()
|
||||||
|
|
@ -219,7 +236,7 @@ struct OnboardingView: View {
|
||||||
case 6:
|
case 6:
|
||||||
self.cliPage()
|
self.cliPage()
|
||||||
case 8:
|
case 8:
|
||||||
self.whatsappPage()
|
self.onboardingChatPage()
|
||||||
case 9:
|
case 9:
|
||||||
self.readyPage()
|
self.readyPage()
|
||||||
default:
|
default:
|
||||||
|
|
@ -988,58 +1005,22 @@ struct OnboardingView: View {
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
private func whatsappPage() -> some View {
|
private func onboardingChatPage() -> some View {
|
||||||
self.onboardingPage {
|
self.onboardingPage {
|
||||||
Text("Connect WhatsApp or Telegram")
|
Text("Meet your agent")
|
||||||
.font(.largeTitle.weight(.semibold))
|
.font(.largeTitle.weight(.semibold))
|
||||||
Text(
|
Text(
|
||||||
"Optional: WhatsApp uses a QR login for your personal account. Telegram uses a bot token. " +
|
"This is a dedicated onboarding chat. Your agent will introduce itself, " +
|
||||||
"Configure them on the machine where the gateway runs.")
|
"learn who you are, and help you connect WhatsApp or Telegram if you want.")
|
||||||
.font(.body)
|
.font(.body)
|
||||||
.foregroundStyle(.secondary)
|
.foregroundStyle(.secondary)
|
||||||
.multilineTextAlignment(.center)
|
.multilineTextAlignment(.center)
|
||||||
.frame(maxWidth: 520)
|
.frame(maxWidth: 520)
|
||||||
.fixedSize(horizontal: false, vertical: true)
|
.fixedSize(horizontal: false, vertical: true)
|
||||||
|
|
||||||
self.onboardingCard {
|
self.onboardingCard(padding: 8) {
|
||||||
self.featureRow(
|
ClawdisChatView(viewModel: self.onboardingChatModel, style: .onboarding)
|
||||||
title: "Open a terminal",
|
.frame(height: 420)
|
||||||
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 what’s configured.",
|
|
||||||
systemImage: "checkmark.shield")
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
@ -1288,6 +1269,7 @@ struct OnboardingView: View {
|
||||||
self.updatePermissionMonitoring(for: pageIndex)
|
self.updatePermissionMonitoring(for: pageIndex)
|
||||||
self.updateDiscoveryMonitoring(for: pageIndex)
|
self.updateDiscoveryMonitoring(for: pageIndex)
|
||||||
self.updateAuthMonitoring(for: pageIndex)
|
self.updateAuthMonitoring(for: pageIndex)
|
||||||
|
self.maybeKickoffOnboardingChat(for: pageIndex)
|
||||||
}
|
}
|
||||||
|
|
||||||
private func stopPermissionMonitoring() {
|
private func stopPermissionMonitoring() {
|
||||||
|
|
@ -1399,9 +1381,11 @@ struct OnboardingView: View {
|
||||||
self.identityName = identity.name
|
self.identityName = identity.name
|
||||||
self.identityTheme = identity.theme
|
self.identityTheme = identity.theme
|
||||||
self.identityEmoji = identity.emoji
|
self.identityEmoji = identity.emoji
|
||||||
|
self.hasIdentity = !identity.isEmpty
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
|
|
||||||
|
self.hasIdentity = false
|
||||||
if self.identityName.trimmingCharacters(in: .whitespacesAndNewlines).isEmpty {
|
if self.identityName.trimmingCharacters(in: .whitespacesAndNewlines).isEmpty {
|
||||||
self.identityName = "Clawd"
|
self.identityName = "Clawd"
|
||||||
}
|
}
|
||||||
|
|
@ -1468,6 +1452,27 @@ struct OnboardingView: View {
|
||||||
self.identityStatus = "Failed to save identity: \(error.localizedDescription)"
|
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 you’re my brand‑new agent. " +
|
||||||
|
"Please start the first‑run ritual from BOOTSTRAP.md, ask one question at a time, " +
|
||||||
|
"and guide me through choosing how we should talk (web‑only, WhatsApp, or Telegram)."
|
||||||
|
self.onboardingChatModel.input = kickoff
|
||||||
|
self.onboardingChatModel.send()
|
||||||
|
}
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
private struct GlowingClawdisIcon: View {
|
private struct GlowingClawdisIcon: View {
|
||||||
|
|
|
||||||
|
|
@ -15,8 +15,13 @@ struct OnboardingViewSmokeTests {
|
||||||
}
|
}
|
||||||
|
|
||||||
@Test func pageOrderOmitsWorkspaceAndIdentitySteps() {
|
@Test func pageOrderOmitsWorkspaceAndIdentitySteps() {
|
||||||
let order = OnboardingView.pageOrder(for: .local)
|
let order = OnboardingView.pageOrder(for: .local, hasIdentity: false)
|
||||||
#expect(!order.contains(7))
|
#expect(!order.contains(7))
|
||||||
#expect(!order.contains(3))
|
#expect(!order.contains(3))
|
||||||
}
|
}
|
||||||
|
|
||||||
|
@Test func pageOrderOmitsOnboardingChatWhenIdentityKnown() {
|
||||||
|
let order = OnboardingView.pageOrder(for: .local, hasIdentity: true)
|
||||||
|
#expect(!order.contains(8))
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
|
||||||
Loading…
Reference in New Issue