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
@@ -0,0 +1,160 @@
import SwiftUI

@MainActor
struct FirstMeetingFlowView: View {
@Bindable var state: OnboardingState
let daemonClient: DaemonClientProtocol
var onComplete: () -> Void
var onOpenSettings: () -> Void

var body: some View {
ZStack {
VColor.background
.ignoresSafeArea()

// Dimmed mock chrome — gives the "chat UI behind" effect
VStack(spacing: 0) {
mockToolbar
Spacer()
mockInputBar
}
.opacity(0.25)
.allowsHitTesting(false)

// Vertical card layout
VStack(spacing: 0) {
// TOP: Meadow background
ZStack {
MeadowBackground()
}
.frame(height: 350)
.clipped()

// BOTTOM: Dark content panel
VStack(spacing: VSpacing.lg) {
Group {
switch state.currentStep {
case 0:
Text("Egg step — coming soon")
.font(VFont.headline)
.foregroundColor(VColor.textPrimary)
case 1:
Text("Hatch step — coming soon")
.font(VFont.headline)
.foregroundColor(VColor.textPrimary)
case 2:
Text("Introduction — coming soon")
.font(VFont.headline)
.foregroundColor(VColor.textPrimary)
case 3:
Text("Capabilities briefing — coming soon")
.font(VFont.headline)
.foregroundColor(VColor.textPrimary)
case 4:
Text("Observation mode — coming soon")
.font(VFont.headline)
.foregroundColor(VColor.textPrimary)
default:
EmptyView()
Comment thread
alex-nork marked this conversation as resolved.
}
}
.transition(
.asymmetric(
insertion: .opacity.combined(with: .offset(y: 12)),
removal: .opacity.combined(with: .offset(y: -8))
)
)
.id(state.currentStep)

OnboardingProgressDots(currentStep: state.currentStep)
Comment thread
alex-nork marked this conversation as resolved.
.padding(.top, VSpacing.xs)
}
.padding(.horizontal, VSpacing.xxl)
.padding(.top, VSpacing.xl)
.padding(.bottom, VSpacing.xxl)
.frame(maxWidth: .infinity)
.background(
Rectangle()
.fill(.ultraThinMaterial)
.overlay(
Rectangle()
.fill(Meadow.panelBackground)
)
)
}
.frame(maxWidth: 640)
.clipShape(RoundedRectangle(cornerRadius: VRadius.xl))
.overlay(
RoundedRectangle(cornerRadius: VRadius.xl)
.stroke(Meadow.panelBorder, lineWidth: 1)
)
.shadow(color: .black.opacity(0.4), radius: 24, y: 12)
.padding(.vertical, VSpacing.xxxl)
}
.frame(maxWidth: .infinity, maxHeight: .infinity)
}

// MARK: - Mock Chrome

private var mockToolbar: some View {
HStack {
HStack(spacing: 6) {
Image(systemName: "bubble.left")
.font(.system(size: 11))
Text("Chat")
.font(VFont.bodyMedium)
}
.foregroundColor(VColor.textPrimary)
.padding(.horizontal, VSpacing.md)
.padding(.vertical, VSpacing.sm)
.background(VColor.surface.opacity(0.5))
.clipShape(RoundedRectangle(cornerRadius: VRadius.md))

Spacer()

HStack(spacing: VSpacing.sm) {
ForEach(["Automated", "Agent", "Control", "System"], id: \.self) { tab in
HStack(spacing: 4) {
Image(systemName: "circle")
.font(.system(size: 7))
Text(tab)
}
.font(VFont.caption)
.foregroundColor(VColor.textMuted)
.padding(.horizontal, VSpacing.md)
.padding(.vertical, VSpacing.sm)
.background(VColor.surface.opacity(0.3))
.clipShape(RoundedRectangle(cornerRadius: VRadius.md))
}
}
}
.padding(.horizontal, VSpacing.lg)
.padding(.top, 44) // below titlebar
}

private var mockInputBar: some View {
HStack(spacing: VSpacing.md) {
VCircleButton(icon: "phone.fill", label: "Phone", fillColor: Emerald._600.opacity(0.5)) { }

Text("What you need chef?")
.font(VFont.body)
.foregroundColor(VColor.textMuted)
.frame(maxWidth: .infinity, alignment: .leading)
.padding(.horizontal, VSpacing.lg)
.padding(.vertical, VSpacing.md)
.background(VColor.surface.opacity(0.3))
.clipShape(RoundedRectangle(cornerRadius: VRadius.lg))
}
.padding(.horizontal, VSpacing.lg)
.padding(.bottom, VSpacing.lg)
}
}

#Preview {
FirstMeetingFlowView(
state: OnboardingState(),
daemonClient: DaemonClient(),
onComplete: {},
onOpenSettings: {}
)
}
Original file line number Diff line number Diff line change
@@ -1,5 +1,10 @@
import SwiftUI

enum OnboardingVariant: String {
case `default`
case firstMeeting = "first_meeting"
}

enum ActivationKey: String, CaseIterable {
case fn
case ctrl
Expand All @@ -26,6 +31,13 @@ final class OnboardingState {
var skipPermissionChecks: Bool = false
var hasHatched: Bool = false
var interviewCompleted: Bool = false
var onboardingVariant: OnboardingVariant = .default

// First-meeting-specific state
var conversationCompleted: Bool = false
var capabilitiesBriefingShown: Bool = false
var observationCompleted: Bool = false
var firstTaskCandidate: String? = nil

var anyPermissionDenied: Bool {
!speechGranted || !accessibilityGranted || !screenGranted
Expand Down Expand Up @@ -59,6 +71,10 @@ final class OnboardingState {
hasHatched = UserDefaults.standard.bool(forKey: "onboarding.hatched")
interviewCompleted = UserDefaults.standard.bool(forKey: "onboarding.interviewCompleted")
}
if let rawVariant = UserDefaults.standard.string(forKey: "onboarding.variant"),
let variant = OnboardingVariant(rawValue: rawVariant) {
onboardingVariant = variant
}
}

func advance() {
Expand All @@ -75,10 +91,11 @@ final class OnboardingState {
UserDefaults.standard.set(chosenKey.rawValue, forKey: "onboarding.key")
UserDefaults.standard.set(hasHatched, forKey: "onboarding.hatched")
UserDefaults.standard.set(interviewCompleted, forKey: "onboarding.interviewCompleted")
UserDefaults.standard.set(onboardingVariant.rawValue, forKey: "onboarding.variant")
}

static func clearPersistedState() {
for key in ["onboarding.step", "onboarding.name", "onboarding.key", "onboarding.hatched", "onboarding.interviewCompleted"] {
for key in ["onboarding.step", "onboarding.name", "onboarding.key", "onboarding.hatched", "onboarding.interviewCompleted", "onboarding.variant"] {
UserDefaults.standard.removeObject(forKey: key)
}
}
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -17,26 +17,44 @@ final class OnboardingWindow {
if CommandLine.arguments.contains("--skip-permission-checks") {
state.skipPermissionChecks = true
}
if let idx = CommandLine.arguments.firstIndex(of: "--onboarding-variant"),
idx + 1 < CommandLine.arguments.count,
CommandLine.arguments[idx + 1] == "first_meeting" {
state.onboardingVariant = .firstMeeting
Comment thread
alex-nork marked this conversation as resolved.
}
#endif

let flowView = OnboardingFlowView(
state: state,
daemonClient: daemonClient,
onComplete: { [weak self] in
guard let self else { return }
self.onComplete?(self.state)
},
onOpenSettings: { [weak self] in
guard let self else { return }
self.onComplete?(self.state)
// Settings will be opened by AppDelegate after onComplete
DispatchQueue.main.asyncAfter(deadline: .now() + 0.3) {
NSApp.sendAction(Selector(("showSettingsWindow:")), to: nil, from: nil)
}
let onComplete: () -> Void = { [weak self] in
guard let self else { return }
self.onComplete?(self.state)
}
let onOpenSettings: () -> Void = { [weak self] in
guard let self else { return }
self.onComplete?(self.state)
// Settings will be opened by AppDelegate after onComplete
DispatchQueue.main.asyncAfter(deadline: .now() + 0.3) {
NSApp.sendAction(Selector(("showSettingsWindow:")), to: nil, from: nil)
}
)
}

let contentView: AnyView
if state.onboardingVariant == .firstMeeting {
contentView = AnyView(FirstMeetingFlowView(
state: state,
daemonClient: daemonClient,
onComplete: onComplete,
onOpenSettings: onOpenSettings
))
} else {
contentView = AnyView(OnboardingFlowView(
state: state,
daemonClient: daemonClient,
onComplete: onComplete,
onOpenSettings: onOpenSettings
))
}

let hostingController = NSHostingController(rootView: flowView)
let hostingController = NSHostingController(rootView: contentView)

let window = NSWindow(
contentRect: NSRect(x: 0, y: 0, width: 1366, height: 849),
Expand Down