From 6845ae1671faf1857f75b49c805840a443b9d447 Mon Sep 17 00:00:00 2001 From: Devin AI <158243242+devin-ai-integration[bot]@users.noreply.github.com> Date: Wed, 18 Mar 2026 22:42:00 +0000 Subject: [PATCH 1/3] Migrate CapabilitiesModalView to use VModal (LUM-282) Co-Authored-By: ashlee@vellum.ai --- .../FirstMeeting/CapabilitiesModalView.swift | 98 +++++++------------ 1 file changed, 38 insertions(+), 60 deletions(-) diff --git a/clients/macos/vellum-assistant/Features/Onboarding/FirstMeeting/CapabilitiesModalView.swift b/clients/macos/vellum-assistant/Features/Onboarding/FirstMeeting/CapabilitiesModalView.swift index 166ea47bbdc..f4408f62c1a 100644 --- a/clients/macos/vellum-assistant/Features/Onboarding/FirstMeeting/CapabilitiesModalView.swift +++ b/clients/macos/vellum-assistant/Features/Onboarding/FirstMeeting/CapabilitiesModalView.swift @@ -6,73 +6,51 @@ struct CapabilitiesModalView: View { @Environment(\.dismiss) private var dismiss var body: some View { - VStack(spacing: 0) { - ScrollView { - VStack(alignment: .leading, spacing: VSpacing.xxl) { - sectionView( - icon: "sparkles", - iconColor: VColor.systemPositiveStrong, - title: "What I can do", - items: [ - "Browse the web and search for information", - "Read, write, and organize files", - "Manage tasks and reminders", - "Help with email drafts and replies", - "Take actions in apps on your behalf", - ] - ) + VModal(title: "") { + VStack(alignment: .leading, spacing: VSpacing.xxl) { + sectionView( + icon: "sparkles", + iconColor: VColor.systemPositiveStrong, + title: "What I can do", + items: [ + "Browse the web and search for information", + "Read, write, and organize files", + "Manage tasks and reminders", + "Help with email drafts and replies", + "Take actions in apps on your behalf", + ] + ) - sectionView( - icon: "shield.lefthalf.filled", - iconColor: VColor.systemNegativeStrong, - title: "What I won\u{2019}t do", - items: [ - "Act without asking when something\u{2019}s irreversible", - "Access your accounts without permission \u{2014} I\u{2019}ll always ask first", - "Store sensitive information like passwords", - "Make decisions that should be yours", - ] - ) + sectionView( + icon: "shield.lefthalf.filled", + iconColor: VColor.systemNegativeStrong, + title: "What I won\u{2019}t do", + items: [ + "Act without asking when something\u{2019}s irreversible", + "Access your accounts without permission \u{2014} I\u{2019}ll always ask first", + "Store sensitive information like passwords", + "Make decisions that should be yours", + ] + ) - sectionView( - icon: "car.fill", - iconColor: VColor.primaryActive, - title: "How control works", - items: [ - "You\u{2019}re always in the driver\u{2019}s seat", - "I\u{2019}ll ask before doing anything big", - "You can take over anytime", - "Say \u{201C}stop\u{201D} or press Escape to halt any action", - ] - ) - } - .padding(.horizontal, VSpacing.xxl) - .padding(.top, VSpacing.xxl) - .padding(.bottom, VSpacing.lg) + sectionView( + icon: "car.fill", + iconColor: VColor.primaryActive, + title: "How control works", + items: [ + "You\u{2019}re always in the driver\u{2019}s seat", + "I\u{2019}ll ask before doing anything big", + "You can take over anytime", + "Say \u{201C}stop\u{201D} or press Escape to halt any action", + ] + ) } - - Divider() - .background(VColor.borderBase.opacity(0.4)) - + } footer: { VButton(label: "Got it", style: .primary, isFullWidth: true) { dismiss() } - .padding(.horizontal, VSpacing.xxl) - .padding(.vertical, VSpacing.lg) } - .frame(width: 400, height: 480) - .background( - RoundedRectangle(cornerRadius: VRadius.xl) - .fill(.ultraThinMaterial) - .overlay( - RoundedRectangle(cornerRadius: VRadius.xl) - .fill(Meadow.panelBackground) - ) - .overlay( - RoundedRectangle(cornerRadius: VRadius.xl) - .stroke(Meadow.panelBorder, lineWidth: 1) - ) - ) + .frame(width: 400) } // MARK: - Section Builder From b6246d8e274d213131448c67facbf1b6eb5b3592 Mon Sep 17 00:00:00 2001 From: Devin AI <158243242+devin-ai-integration[bot]@users.noreply.github.com> Date: Wed, 18 Mar 2026 23:05:29 +0000 Subject: [PATCH 2/3] Remove dead FirstMeeting onboarding flow code MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Delete 10 unused files from FirstMeeting/ directory and remove first-meeting-specific state from OnboardingState. The FirstMeetingFlowView (the only consumer of these files) is never instantiated — the active onboarding uses OnboardingFlowView with a different step sequence. JITPermissionManager and JITPermissionView are retained as they are still used by MainWindowView. Co-Authored-By: ashlee@vellum.ai --- .../CapabilitiesBriefingView.swift | 87 ---- .../FirstMeeting/CapabilitiesModalView.swift | 89 ---- .../FirstMeeting/FirstMeetingEggView.swift | 39 -- .../FirstMeeting/FirstMeetingFlowView.swift | 213 ---------- .../FirstMeeting/FirstMeetingHatchView.swift | 106 ----- .../FirstMeetingIntroductionView.swift | 173 -------- .../FirstMeetingIntroductionViewModel.swift | 386 ------------------ .../FirstMeeting/ObservationModeView.swift | 131 ------ .../FirstMeeting/ObservationSessionView.swift | 249 ----------- .../FirstMeeting/ObservationSummaryView.swift | 153 ------- .../Features/Onboarding/OnboardingState.swift | 43 +- 11 files changed, 3 insertions(+), 1666 deletions(-) delete mode 100644 clients/macos/vellum-assistant/Features/Onboarding/FirstMeeting/CapabilitiesBriefingView.swift delete mode 100644 clients/macos/vellum-assistant/Features/Onboarding/FirstMeeting/CapabilitiesModalView.swift delete mode 100644 clients/macos/vellum-assistant/Features/Onboarding/FirstMeeting/FirstMeetingEggView.swift delete mode 100644 clients/macos/vellum-assistant/Features/Onboarding/FirstMeeting/FirstMeetingFlowView.swift delete mode 100644 clients/macos/vellum-assistant/Features/Onboarding/FirstMeeting/FirstMeetingHatchView.swift delete mode 100644 clients/macos/vellum-assistant/Features/Onboarding/FirstMeeting/FirstMeetingIntroductionView.swift delete mode 100644 clients/macos/vellum-assistant/Features/Onboarding/FirstMeeting/FirstMeetingIntroductionViewModel.swift delete mode 100644 clients/macos/vellum-assistant/Features/Onboarding/FirstMeeting/ObservationModeView.swift delete mode 100644 clients/macos/vellum-assistant/Features/Onboarding/FirstMeeting/ObservationSessionView.swift delete mode 100644 clients/macos/vellum-assistant/Features/Onboarding/FirstMeeting/ObservationSummaryView.swift diff --git a/clients/macos/vellum-assistant/Features/Onboarding/FirstMeeting/CapabilitiesBriefingView.swift b/clients/macos/vellum-assistant/Features/Onboarding/FirstMeeting/CapabilitiesBriefingView.swift deleted file mode 100644 index 635f33eacff..00000000000 --- a/clients/macos/vellum-assistant/Features/Onboarding/FirstMeeting/CapabilitiesBriefingView.swift +++ /dev/null @@ -1,87 +0,0 @@ -import VellumAssistantShared -import SwiftUI - -@MainActor -struct CapabilitiesBriefingView: View { - @Bindable var state: OnboardingState - var onComplete: () -> Void - - @State private var firstParagraphDone = false - @State private var showSecondParagraph = false - @State private var showButtons = false - @State private var showCapabilitiesModal = false - - private let firstText = "Okay, I think I\u{2019}ve got a good sense of where to start. Before we dive in \u{2014} quick road trip safety briefing." - private let secondText = "For now, think of it like you\u{2019}re driving and I\u{2019}m the passenger. I can navigate, handle stuff on my phone, keep us on track \u{2014} but you\u{2019}re steering." - - var body: some View { - HStack(alignment: .center, spacing: VSpacing.xxxl) { - // Creature on the left, small like in interview step - CreatureView(visible: true, animated: false) - .scaleEffect(0.5) - .frame(width: 200, height: 200) - - OnboardingPanel { - VStack(alignment: .leading, spacing: VSpacing.xl) { - // Framing text - VStack(alignment: .leading, spacing: VSpacing.lg) { - TypewriterText( - fullText: firstText, - speed: 0.03, - font: VFont.body, - onComplete: { - firstParagraphDone = true - withAnimation(.easeOut(duration: 0.4)) { - showSecondParagraph = true - } - } - ) - - if showSecondParagraph { - TypewriterText( - fullText: secondText, - speed: 0.03, - font: VFont.body, - onComplete: { - withAnimation(.easeOut(duration: 0.5)) { - showButtons = true - } - } - ) - .transition(.opacity.combined(with: .offset(y: 6))) - } - } - - // Action buttons - if showButtons { - VStack(spacing: VSpacing.md) { - OnboardingButton( - title: "Got it, let\u{2019}s go", - style: .primary, - fadeIn: true, - fadeDelay: 0.1 - ) { - state.capabilitiesBriefingShown = true - onComplete() - } - - OnboardingButton( - title: "See what I can do", - style: .tertiary, - fadeIn: true, - fadeDelay: 0.3 - ) { - showCapabilitiesModal = true - } - } - .transition(.opacity.combined(with: .offset(y: 8))) - } - } - } - .frame(maxWidth: 420) - } - .sheet(isPresented: $showCapabilitiesModal) { - CapabilitiesModalView() - } - } -} diff --git a/clients/macos/vellum-assistant/Features/Onboarding/FirstMeeting/CapabilitiesModalView.swift b/clients/macos/vellum-assistant/Features/Onboarding/FirstMeeting/CapabilitiesModalView.swift deleted file mode 100644 index f4408f62c1a..00000000000 --- a/clients/macos/vellum-assistant/Features/Onboarding/FirstMeeting/CapabilitiesModalView.swift +++ /dev/null @@ -1,89 +0,0 @@ -import VellumAssistantShared -import SwiftUI - -@MainActor -struct CapabilitiesModalView: View { - @Environment(\.dismiss) private var dismiss - - var body: some View { - VModal(title: "") { - VStack(alignment: .leading, spacing: VSpacing.xxl) { - sectionView( - icon: "sparkles", - iconColor: VColor.systemPositiveStrong, - title: "What I can do", - items: [ - "Browse the web and search for information", - "Read, write, and organize files", - "Manage tasks and reminders", - "Help with email drafts and replies", - "Take actions in apps on your behalf", - ] - ) - - sectionView( - icon: "shield.lefthalf.filled", - iconColor: VColor.systemNegativeStrong, - title: "What I won\u{2019}t do", - items: [ - "Act without asking when something\u{2019}s irreversible", - "Access your accounts without permission \u{2014} I\u{2019}ll always ask first", - "Store sensitive information like passwords", - "Make decisions that should be yours", - ] - ) - - sectionView( - icon: "car.fill", - iconColor: VColor.primaryActive, - title: "How control works", - items: [ - "You\u{2019}re always in the driver\u{2019}s seat", - "I\u{2019}ll ask before doing anything big", - "You can take over anytime", - "Say \u{201C}stop\u{201D} or press Escape to halt any action", - ] - ) - } - } footer: { - VButton(label: "Got it", style: .primary, isFullWidth: true) { - dismiss() - } - } - .frame(width: 400) - } - - // MARK: - Section Builder - - private func sectionView( - icon: String, - iconColor: Color, - title: String, - items: [String] - ) -> some View { - VStack(alignment: .leading, spacing: VSpacing.md) { - HStack(spacing: VSpacing.sm) { - VIconView(SFSymbolMapping.icon(forSFSymbol: icon, fallback: .puzzle), size: 14) - .foregroundColor(iconColor) - Text(title) - .font(VFont.headline) - .foregroundColor(VColor.contentDefault) - } - - VStack(alignment: .leading, spacing: VSpacing.sm) { - ForEach(items, id: \.self) { item in - HStack(alignment: .top, spacing: VSpacing.sm) { - Text("\u{2022}") - .font(VFont.body) - .foregroundColor(VColor.contentTertiary) - Text(item) - .font(VFont.body) - .foregroundColor(VColor.contentSecondary) - .fixedSize(horizontal: false, vertical: true) - } - } - } - .padding(.leading, VSpacing.xs) - } - } -} diff --git a/clients/macos/vellum-assistant/Features/Onboarding/FirstMeeting/FirstMeetingEggView.swift b/clients/macos/vellum-assistant/Features/Onboarding/FirstMeeting/FirstMeetingEggView.swift deleted file mode 100644 index 72a930a6e53..00000000000 --- a/clients/macos/vellum-assistant/Features/Onboarding/FirstMeeting/FirstMeetingEggView.swift +++ /dev/null @@ -1,39 +0,0 @@ -import VellumAssistantShared -import SwiftUI - -@MainActor -struct FirstMeetingEggView: View { - @Bindable var state: OnboardingState - - @State private var showButton = false - @State private var isHatching = false - - var body: some View { - VStack(spacing: VSpacing.xxl) { - TypewriterText( - fullText: "Something is waiting for you...", - speed: 0.06, - font: VFont.onboardingTitle - ) { - DispatchQueue.main.asyncAfter(deadline: .now() + 0.8) { - withAnimation(.easeOut(duration: 0.5)) { - showButton = true - } - } - } - - OnboardingButton(title: "Wake it up", style: .primary) { - guard !isHatching else { return } - isHatching = true - state.hasHatched = true - state.firstMeetingCrackProgress = 0.15 - DispatchQueue.main.asyncAfter(deadline: .now() + 1.0) { - state.advance() - } - } - .opacity(showButton ? 1 : 0) - .offset(y: showButton ? 0 : 8) - .disabled(isHatching) - } - } -} diff --git a/clients/macos/vellum-assistant/Features/Onboarding/FirstMeeting/FirstMeetingFlowView.swift b/clients/macos/vellum-assistant/Features/Onboarding/FirstMeeting/FirstMeetingFlowView.swift deleted file mode 100644 index a7731505e0b..00000000000 --- a/clients/macos/vellum-assistant/Features/Onboarding/FirstMeeting/FirstMeetingFlowView.swift +++ /dev/null @@ -1,213 +0,0 @@ -import SpriteKit -import VellumAssistantShared -import SwiftUI - -@MainActor -struct FirstMeetingFlowView: View { - @Bindable var state: OnboardingState - let daemonClient: DaemonClientProtocol - var onComplete: () -> Void - var onOpenSettings: () -> Void - - /// Tracks sub-phase within the observation step (step 4). - enum ObservationPhase { - case pitch - case session - case summary - } - @State private var observationPhase: ObservationPhase = .pitch - - /// Shared SpriteKit scene for the egg/hatch animation across steps 0-1. - @State private var eggScene: EggHatchScene = { - let s = EggHatchScene() - s.size = CGSize(width: 280, height: 480) - s.scaleMode = .resizeFill - s.backgroundColor = .clear - return s - }() - - var body: some View { - ZStack { - VColor.surfaceOverlay - .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 + egg scene - ZStack { - MeadowBackground() - - if state.currentStep <= 1 { - SpriteView(scene: eggScene, options: [.allowsTransparency]) - .frame(width: 280, height: 350) - .allowsHitTesting(false) - } - } - .frame(height: 350) - .clipped() - .onAppear { - // Set initial crack progress without animation for restored sessions - let progress = state.firstMeetingCrackProgress - if progress > 0 { - eggScene.setCrackProgress(progress, animated: false) - } - } - - // BOTTOM: Dark content panel - VStack(spacing: VSpacing.lg) { - Group { - switch state.currentStep { - case 0: - FirstMeetingEggView(state: state) - case 1: - FirstMeetingHatchView(state: state, scene: eggScene) - case 2: - FirstMeetingIntroductionView( - state: state, - daemonClient: daemonClient, - onComplete: { state.advance() } - ) - case 3: - CapabilitiesBriefingView(state: state, onComplete: { state.advance() }) - case 4: - observationStep - default: - EmptyView() - } - } - .transition( - .asymmetric( - insertion: .opacity.combined(with: .offset(y: 12)), - removal: .opacity.combined(with: .offset(y: -8)) - ) - ) - .id("\(state.currentStep)-\(observationPhase)") - - OnboardingFooter(currentStep: state.currentStep, totalSteps: 5) - .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: VColor.auxBlack.opacity(0.4), radius: 24, y: 12) - .padding(.vertical, VSpacing.xxxl) - } - .frame(maxWidth: .infinity, maxHeight: .infinity) - } - - // MARK: - Observation Step - - @ViewBuilder - private var observationStep: some View { - switch observationPhase { - case .pitch: - ObservationModeView( - state: state, - onStartObserving: { - withAnimation(.spring(duration: 0.6, bounce: 0.15)) { - observationPhase = .session - } - }, - onSkip: { - completeOnboarding() - } - ) - case .session: - ObservationSessionView( - state: state, - onComplete: { - withAnimation(.spring(duration: 0.6, bounce: 0.15)) { - observationPhase = .summary - } - }, - onStopEarly: { - withAnimation(.spring(duration: 0.6, bounce: 0.15)) { - observationPhase = .summary - } - } - ) - case .summary: - ObservationSummaryView( - state: state, - onAccept: { - completeOnboarding() - }, - onDecline: { - completeOnboarding() - } - ) - } - } - - private func completeOnboarding() { - state.observationCompleted = true - onComplete() - } - - // MARK: - Mock Chrome - - private var mockToolbar: some View { - HStack { - Spacer() - - HStack(spacing: VSpacing.sm) { - ForEach(["Automated", "Agent", "Control", "System"], id: \.self) { tab in - HStack(spacing: 4) { - VIconView(.circle, size: 7) - Text(tab) - } - .font(VFont.caption) - .foregroundColor(VColor.contentTertiary) - .padding(.horizontal, VSpacing.md) - .padding(.vertical, VSpacing.sm) - .background(VColor.surfaceBase.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) { - VButton(label: "Phone", iconOnly: VIcon.phoneCall.rawValue, style: .ghost) { } - - Text("What you need chef?") - .font(VFont.body) - .foregroundColor(VColor.contentTertiary) - .frame(maxWidth: .infinity, alignment: .leading) - .padding(.horizontal, VSpacing.lg) - .padding(.vertical, VSpacing.md) - .background(VColor.surfaceBase.opacity(0.3)) - .clipShape(RoundedRectangle(cornerRadius: VRadius.lg)) - } - .padding(.horizontal, VSpacing.lg) - .padding(.bottom, VSpacing.lg) - } -} diff --git a/clients/macos/vellum-assistant/Features/Onboarding/FirstMeeting/FirstMeetingHatchView.swift b/clients/macos/vellum-assistant/Features/Onboarding/FirstMeeting/FirstMeetingHatchView.swift deleted file mode 100644 index f0a524112b5..00000000000 --- a/clients/macos/vellum-assistant/Features/Onboarding/FirstMeeting/FirstMeetingHatchView.swift +++ /dev/null @@ -1,106 +0,0 @@ -import VellumAssistantShared -import SpriteKit -import SwiftUI - -@MainActor -struct FirstMeetingHatchView: View { - @Bindable var state: OnboardingState - let scene: EggHatchScene - - @State private var hatchTimer: Timer? - @State private var hasTriggeredDramaticCrack = false - @State private var hasTriggeredFullHatch = false - @State private var hatchCompleted = false - @State private var statusText = "Your velly is hatching..." - @State private var delegateAdapter: HatchDelegateAdapter? - - var body: some View { - VStack(spacing: VSpacing.xxl) { - TypewriterText( - fullText: statusText, - speed: 0.05, - font: VFont.onboardingTitle - ) - .id(statusText) - } - .onAppear { - setupDelegate() - startHatchTimer() - } - .onDisappear { - hatchTimer?.invalidate() - hatchTimer = nil - } - } - - // MARK: - Timer-Driven Hatch Sequence - - private func startHatchTimer() { - // Drive crackProgress from current value to 1.0 over ~8 seconds. - let startProgress = state.firstMeetingCrackProgress - let totalDuration: CGFloat = 8.0 - let tickInterval: CGFloat = 0.1 - let progressRange = 1.0 - startProgress - let increment = progressRange * (tickInterval / totalDuration) - - hatchTimer = Timer.scheduledTimer(withTimeInterval: tickInterval, repeats: true) { _ in - Task { @MainActor in - guard !hasTriggeredFullHatch else { - hatchTimer?.invalidate() - hatchTimer = nil - return - } - - let newProgress = min(state.firstMeetingCrackProgress + increment, 1.0) - state.firstMeetingCrackProgress = newProgress - scene.setCrackProgress(newProgress, animated: true) - - // At ~0.5 progress, trigger dramatic crack effects - if newProgress >= 0.5 && !hasTriggeredDramaticCrack { - hasTriggeredDramaticCrack = true - scene.triggerDramaticCrack(for: 3) - } - - // At 1.0 progress, trigger full hatch - if newProgress >= 1.0 && !hasTriggeredFullHatch { - hasTriggeredFullHatch = true - hatchTimer?.invalidate() - hatchTimer = nil - scene.triggerFullHatch() - } - } - } - } - - // MARK: - Delegate - - private func setupDelegate() { - let adapter = HatchDelegateAdapter { event in - Task { @MainActor in - if event == .fullHatchDone { - hatchCompleted = true - statusText = "Say hello!" - // Wait a moment then auto-advance to step 2 - DispatchQueue.main.asyncAfter(deadline: .now() + 1.5) { - state.advance() - } - } - } - } - delegateAdapter = adapter - scene.hatchDelegate = adapter - } -} - -/// Bridges the EggHatchSceneDelegate protocol to a closure for use in SwiftUI. -private final class HatchDelegateAdapter: EggHatchSceneDelegate { - let handler: @Sendable (HatchEvent) -> Void - - init(handler: @escaping @Sendable (HatchEvent) -> Void) { - self.handler = handler - } - - func sceneDidComplete(_ event: HatchEvent) { - handler(event) - } -} diff --git a/clients/macos/vellum-assistant/Features/Onboarding/FirstMeeting/FirstMeetingIntroductionView.swift b/clients/macos/vellum-assistant/Features/Onboarding/FirstMeeting/FirstMeetingIntroductionView.swift deleted file mode 100644 index 0db92d4f313..00000000000 --- a/clients/macos/vellum-assistant/Features/Onboarding/FirstMeeting/FirstMeetingIntroductionView.swift +++ /dev/null @@ -1,173 +0,0 @@ -import SwiftUI -import VellumAssistantShared - -@MainActor -struct FirstMeetingIntroductionView: View { - @Bindable var state: OnboardingState - let daemonClient: DaemonClientProtocol - let onComplete: () -> Void - - @State private var viewModel: FirstMeetingIntroductionViewModel - @State private var showControls = false - @State private var streamingMessageId = UUID() - @State private var hasCompleted = false - - init(state: OnboardingState, daemonClient: DaemonClientProtocol, onComplete: @escaping () -> Void) { - self.state = state - self.daemonClient = daemonClient - self.onComplete = onComplete - self._viewModel = State(initialValue: FirstMeetingIntroductionViewModel( - daemonClient: daemonClient - )) - } - - /// Combines finalized messages with any in-progress streaming text. - private var displayedMessages: [InterviewMessage] { - var msgs = viewModel.messages - if !viewModel.streamingText.isEmpty { - msgs.append(InterviewMessage(id: streamingMessageId, role: .assistant, text: viewModel.streamingText)) - } - return msgs - } - - private var sendButtonDisabled: Bool { - viewModel.inputText.trimmingCharacters(in: .whitespaces).isEmpty - } - - var body: some View { - VStack(spacing: 0) { - // Main content: evolving avatar left, chat panel right - HStack(alignment: .center, spacing: VSpacing.xxxl) { - // Avatar placeholder - VAvatarImage( - image: AvatarAppearanceManager.buildInitialLetterAvatar( - name: state.assistantName, - size: 128 - ), - size: 128, - showBorder: false - ) - - // Chat messages + input in panel - OnboardingPanel { - VStack(spacing: 0) { - InterviewChatView( - messages: displayedMessages, - inputText: viewModel.inputText, - isThinking: viewModel.isThinking, - isStreaming: !viewModel.streamingText.isEmpty, - onChipTap: { chip in - viewModel.inputText = chip - viewModel.sendMessage() - } - ) - .allowsHitTesting(!viewModel.isFinished) - - inputArea - } - } - .frame(maxWidth: 520, maxHeight: 560) - } - .frame(maxHeight: .infinity) - .padding(.horizontal, VSpacing.xxxl) - - // Skip link - if !viewModel.isFinished && showControls { - Button { - completeConversation() - } label: { - Text("Skip for now") - .font(VFont.caption) - .foregroundColor(VColor.contentTertiary) - } - .buttonStyle(.plain) - .pointerCursor() - .transition(.opacity) - .padding(.vertical, VSpacing.md) - } - } - .onAppear { - viewModel.startConversation() - DispatchQueue.main.asyncAfter(deadline: .now() + 1.0) { - withAnimation(.easeOut(duration: 0.5)) { - showControls = true - } - } - } - .onChange(of: viewModel.isFinished) { - if viewModel.isFinished { - completeConversation() - } - } - .onDisappear { - viewModel.cancel() - } - } - - // MARK: - Input Area - - private var inputArea: some View { - HStack(spacing: VSpacing.sm) { - VTextField( - placeholder: "Type a message\u{2026}", - text: $viewModel.inputText, - onSubmit: { - if !viewModel.inputText.trimmingCharacters(in: .whitespaces).isEmpty { - viewModel.sendMessage() - } - } - ) - - Button(action: { - if !viewModel.inputText.trimmingCharacters(in: .whitespaces).isEmpty { - viewModel.sendMessage() - } - }) { - VIconView(.arrowUp, size: 12) - .foregroundColor(VColor.auxWhite) - .frame(width: 24, height: 24) - .background( - Circle() - .fill(sendButtonDisabled ? VColor.contentTertiary : VColor.primaryBase) - ) - } - .buttonStyle(.plain) - .disabled(sendButtonDisabled) - .accessibilityLabel("Send message") - } - .padding(.horizontal, VSpacing.lg) - .padding(.vertical, VSpacing.md) - .background( - VColor.surfaceBase.opacity(0.5) - .overlay( - VStack { - Divider().background(VColor.borderBase.opacity(0.4)) - Spacer() - } - ) - ) - } - - // MARK: - Conversation Completion - - private func completeConversation() { - guard !hasCompleted else { return } - hasCompleted = true - - // Extract conversation data (name, first task candidate). - viewModel.extractConversationData() - - // Save extracted data to state. - if let name = viewModel.extractedName, !name.isEmpty { - state.assistantName = name - } - if let task = viewModel.extractedFirstTask { - state.firstTaskCandidate = task - } - - state.conversationCompleted = true - viewModel.endConversation() - - onComplete() - } -} diff --git a/clients/macos/vellum-assistant/Features/Onboarding/FirstMeeting/FirstMeetingIntroductionViewModel.swift b/clients/macos/vellum-assistant/Features/Onboarding/FirstMeeting/FirstMeetingIntroductionViewModel.swift deleted file mode 100644 index 05027bb98d5..00000000000 --- a/clients/macos/vellum-assistant/Features/Onboarding/FirstMeeting/FirstMeetingIntroductionViewModel.swift +++ /dev/null @@ -1,386 +0,0 @@ -import Foundation -import VellumAssistantShared -import Observation -import os - -private let log = Logger( - subsystem: Bundle.main.bundleIdentifier ?? "com.vellum.vellum-assistant", - category: "FirstMeetingIntroductionViewModel" -) - -@Observable -@MainActor -final class FirstMeetingIntroductionViewModel { - - // MARK: - Public State - - var messages: [InterviewMessage] = [] - var inputText: String = "" - var isThinking: Bool = false - var isComplete: Bool = false - var isFinished: Bool = false - var streamingText: String = "" - - /// Name extracted from the conversation, if the user provides one. - var extractedName: String? - - /// First task candidate extracted from the conversation. - var extractedFirstTask: String? - - // MARK: - Dependencies - - private let daemonClient: DaemonClientProtocol - - // MARK: - Internal State - - private let maxTurns = 10 - private var conversationId: String? - private var currentTask: Task? - private var startTime: Date? - - /// Number of completed assistant responses (greeting counts as turn 1). - var turnCount: Int { - messages.filter { $0.role == .assistant }.count - } - - // MARK: - Init - - init(daemonClient: DaemonClientProtocol) { - self.daemonClient = daemonClient - } - - // MARK: - Start Conversation - - /// Kicks off the first meeting conversation by creating a new daemon text conversation. - func startConversation() { - startTime = Date() - isThinking = true - streamingText = "" - - currentTask?.cancel() - currentTask = nil - - currentTask = Task { @MainActor [weak self] in - guard let self else { return } - - let stream = self.daemonClient.subscribe() - - do { - try self.daemonClient.send(ConversationCreateMessage( - title: "First meeting", - maxResponseTokens: 220, - transportChannelId: "vellum", - transportHints: [ - "onboarding-active", - "onboarding-phase:post_hatch", - "desktop-first-meeting" - ], - transportUxBrief: "Onboarding first-meeting conversation after hatch. Follow playbook sequence and update USER.md directly." - )) - } catch { - log.error("Failed to send conversation create: \(error.localizedDescription)") - self.isThinking = false - self.messages.append(InterviewMessage( - role: .assistant, - text: "I'm having trouble connecting. Please try again in a moment." - )) - return - } - - var accumulated = "" - - for await message in stream { - guard !Task.isCancelled else { break } - - switch message { - case .conversationInfo(let info): - if self.conversationId == nil { - self.conversationId = info.conversationId - log.info("First meeting conversation created: \(info.conversationId)") - - do { - try self.daemonClient.send(UserMessageMessage( - conversationId: info.conversationId, - content: "Hi! You just hatched and I want to set us up well.", - attachments: nil - )) - } catch { - log.error("Failed to send initial message: \(error.localizedDescription)") - self.isThinking = false - self.messages.append(InterviewMessage( - role: .assistant, - text: "I'm having trouble connecting. Please try again in a moment." - )) - return - } - } - - case .assistantTextDelta(let delta) where self.conversationId != nil: - // Filter by conversation to prevent contamination from concurrent conversations. - if let deltaConversationId = delta.conversationId, deltaConversationId != self.conversationId { - break - } - accumulated += delta.text - self.isThinking = false - self.streamingText = accumulated - - case .assistantThinkingDelta where self.conversationId != nil: - break - - case .messageComplete(let complete) where complete.conversationId == self.conversationId && self.conversationId != nil: - self.isThinking = false - self.streamingText = "" - let finalText = accumulated.isEmpty ? "(No response)" : accumulated - self.messages.append(InterviewMessage( - role: .assistant, - text: finalText - )) - log.info("First meeting greeting complete (\(accumulated.count) chars)") - return - - case .generationHandoff(let handoff) where handoff.conversationId == self.conversationId && self.conversationId != nil: - self.isThinking = false - self.streamingText = "" - let finalText = accumulated.isEmpty ? "(No response)" : accumulated - self.messages.append(InterviewMessage( - role: .assistant, - text: finalText - )) - log.info("First meeting greeting complete via handoff (\(accumulated.count) chars)") - return - - case .conversationError(let error) where error.conversationId == self.conversationId && self.conversationId != nil: - self.isThinking = false - self.streamingText = "" - log.error("First meeting start failed (conversation_error): \(error.userMessage)") - self.messages.append(InterviewMessage( - role: .assistant, - text: "I'm having trouble connecting. Please try again in a moment." - )) - return - - default: - break - } - } - - // Stream ended without a terminal message. - if !Task.isCancelled { - self.isThinking = false - self.streamingText = "" - if !accumulated.isEmpty { - self.messages.append(InterviewMessage( - role: .assistant, - text: accumulated - )) - } - } - } - } - - // MARK: - Send Follow-up Message - - /// Sends a follow-up user message within the existing conversation. - func sendMessage() { - let text = inputText.trimmingCharacters(in: .whitespacesAndNewlines) - guard !text.isEmpty, !isFinished else { return } - guard let conversationId else { - log.warning("Cannot send message — no active conversation") - return - } - - // Append user message immediately. - messages.append(InterviewMessage(role: .user, text: text)) - inputText = "" - isThinking = true - streamingText = "" - - let nextTurn = turnCount + 1 - let contentToSend = text - - let isLastTurn = nextTurn >= maxTurns - - currentTask?.cancel() - currentTask = nil - - currentTask = Task { @MainActor [weak self] in - guard let self else { return } - - let stream = self.daemonClient.subscribe() - - do { - try self.daemonClient.send(UserMessageMessage( - conversationId: conversationId, - content: contentToSend, - attachments: nil - )) - } catch { - log.error("Failed to send user message: \(error.localizedDescription)") - self.isThinking = false - self.messages.append(InterviewMessage( - role: .assistant, - text: "Sorry, I couldn't send that message. Please try again." - )) - return - } - - var accumulated = "" - - for await message in stream { - guard !Task.isCancelled else { break } - - switch message { - case .assistantTextDelta(let delta): - // Filter by conversation to prevent contamination from concurrent conversations. - if let deltaConversationId = delta.conversationId, deltaConversationId != conversationId { - break - } - accumulated += delta.text - self.isThinking = false - self.streamingText = accumulated - - case .assistantThinkingDelta: - break - - case .messageComplete(let complete) where complete.conversationId == conversationId: - self.isThinking = false - self.streamingText = "" - let finalText = accumulated.isEmpty ? "(No response)" : accumulated - self.messages.append(InterviewMessage( - role: .assistant, - text: finalText - )) - if isLastTurn { - self.isFinished = true - } - log.info("Follow-up response complete (\(accumulated.count) chars), turn \(nextTurn)/\(self.maxTurns)") - return - - case .generationHandoff(let handoff) where handoff.conversationId == conversationId: - self.isThinking = false - self.streamingText = "" - let finalText = accumulated.isEmpty ? "(No response)" : accumulated - self.messages.append(InterviewMessage( - role: .assistant, - text: finalText - )) - if isLastTurn { - self.isFinished = true - } - log.info("Follow-up response complete via handoff (\(accumulated.count) chars), turn \(nextTurn)/\(self.maxTurns)") - return - - case .conversationError(let error) where error.conversationId == conversationId: - self.isThinking = false - self.streamingText = "" - log.error("Conversation error during follow-up: \(error.userMessage)") - self.messages.append(InterviewMessage( - role: .assistant, - text: "Something went wrong. Please try again." - )) - return - - default: - break - } - } - - // Stream ended without a terminal message. - if !Task.isCancelled { - self.isThinking = false - self.streamingText = "" - if !accumulated.isEmpty { - self.messages.append(InterviewMessage( - role: .assistant, - text: accumulated - )) - } - } - } - } - - // MARK: - Extraction - - /// Extracts a first task candidate and optional assistant name from the conversation. - /// Called when the conversation completes or is skipped. - func extractConversationData() { - guard !messages.isEmpty else { return } - - // Simple heuristic: scan user messages for name-related patterns. - // The naming turn (~turn 9) typically contains the user's response to "give me a name". - for msg in messages where msg.role == .user { - let lower = msg.text.lowercased() - // Look for patterns like "call you X", "name you X", "your name is X", "how about X" - let namePatterns = [ - "call you ", "name you ", "your name is ", "name is ", - "how about ", "let's go with ", "i'll call you ", "calling you ", - ] - for pattern in namePatterns { - if let range = lower.range(of: pattern) { - let afterPattern = msg.text[range.upperBound...] - let candidate = afterPattern - .trimmingCharacters(in: .whitespacesAndNewlines) - .components(separatedBy: CharacterSet.alphanumerics.inverted) - .first ?? "" - if !candidate.isEmpty && candidate.count <= 20 { - extractedName = candidate.capitalized - } - } - } - } - - // Extract first task candidate: look for task-related signals in user messages - // from the task identification phase (roughly messages 12-16, i.e. turns 7-8). - let userMessages = messages.enumerated().filter { $0.element.role == .user } - // Focus on later messages where task identification happens - let laterMessages = userMessages.suffix(5) - for (_, msg) in laterMessages { - let lower = msg.text.lowercased() - let taskSignals = ["hand off", "help with", "take care of", "work on", - "start with", "first thing", "deal with", "handle"] - for signal in taskSignals { - if lower.contains(signal) { - extractedFirstTask = msg.text - break - } - } - if extractedFirstTask != nil { break } - } - - // Fallback: if no explicit task signal found, use the last substantive user message - // from the task phase as a candidate. - if extractedFirstTask == nil, let lastSubstantive = laterMessages.last(where: { - $0.element.text.count > 10 - }) { - extractedFirstTask = lastSubstantive.element.text - } - } - - // MARK: - End Conversation - - /// Marks the conversation as complete and cancels any in-progress streaming. - func endConversation() { - if let startTime { - let duration = Date().timeIntervalSince(startTime) - let turns = self.turnCount - let finished = self.isFinished - log.info("First meeting completed: turns=\(turns), finished=\(finished), duration=\(String(format: "%.1f", duration))s") - } - - isComplete = true - currentTask?.cancel() - currentTask = nil - conversationId = nil - isThinking = false - streamingText = "" - } - - // MARK: - Cancel - - /// Cancels any in-progress daemon communication task. - func cancel() { - currentTask?.cancel() - currentTask = nil - conversationId = nil - } -} diff --git a/clients/macos/vellum-assistant/Features/Onboarding/FirstMeeting/ObservationModeView.swift b/clients/macos/vellum-assistant/Features/Onboarding/FirstMeeting/ObservationModeView.swift deleted file mode 100644 index fee7d10fe3e..00000000000 --- a/clients/macos/vellum-assistant/Features/Onboarding/FirstMeeting/ObservationModeView.swift +++ /dev/null @@ -1,131 +0,0 @@ -import VellumAssistantShared -import SwiftUI - -/// Observation mode pitch view — step 4 of the first meeting flow. -/// Proposes that the assistant observe the user working for a few minutes -/// before offering autonomous help. -@MainActor -struct ObservationModeView: View { - @Bindable var state: OnboardingState - var onStartObserving: () -> Void - var onSkip: () -> Void - - @State private var pitchDone = false - @State private var showDurationPicker = false - @State private var showButtons = false - @State private var selectedMinutes: Int = 5 - - private var pitchText: String { - let task = state.firstTaskCandidate ?? "getting things done" - return "You mentioned \(task). I can definitely help with that. But since we just met, can I ride along while you do it for a few minutes first? Like \(selectedMinutes) minutes \u{2014} I\u{2019}ll watch and learn how you work, then I\u{2019}ll know how to actually help the way you\u{2019}d want." - } - - private let durationOptions = [3, 5, 10] - - var body: some View { - HStack(alignment: .center, spacing: VSpacing.xxxl) { - CreatureView(visible: true, animated: false) - .scaleEffect(0.5) - .frame(width: 200, height: 200) - - OnboardingPanel { - VStack(alignment: .leading, spacing: VSpacing.xl) { - TypewriterText( - fullText: pitchText, - speed: 0.03, - font: VFont.body, - onComplete: { - pitchDone = true - withAnimation(.easeOut(duration: 0.4)) { - showDurationPicker = true - } - DispatchQueue.main.asyncAfter(deadline: .now() + 0.3) { - withAnimation(.easeOut(duration: 0.5)) { - showButtons = true - } - } - } - ) - - // Duration selector - if showDurationPicker { - VStack(alignment: .leading, spacing: VSpacing.sm) { - Text("\(selectedMinutes) minutes okay?") - .font(VFont.bodyMedium) - .foregroundColor(VColor.contentSecondary) - - HStack(spacing: VSpacing.sm) { - ForEach(durationOptions, id: \.self) { minutes in - durationChip(minutes) - } - } - } - .transition(.opacity.combined(with: .offset(y: 6))) - } - - // Action buttons - if showButtons { - VStack(spacing: VSpacing.md) { - OnboardingButton( - title: "Start observing", - style: .primary, - fadeIn: true, - fadeDelay: 0.1 - ) { - state.observationDurationMinutes = selectedMinutes - onStartObserving() - } - - OnboardingButton( - title: "Skip for now", - style: .tertiary, - fadeIn: true, - fadeDelay: 0.3 - ) { - onSkip() - } - } - .transition(.opacity.combined(with: .offset(y: 8))) - } - } - } - .frame(maxWidth: 420) - } - } - - private func durationChip(_ minutes: Int) -> some View { - Button { - withAnimation(VAnimation.fast) { - selectedMinutes = minutes - } - } label: { - Text("\(minutes) min") - .font(VFont.captionMedium) - .foregroundColor( - minutes == selectedMinutes - ? VColor.contentDefault - : VColor.contentSecondary - ) - .padding(.horizontal, VSpacing.md) - .padding(.vertical, VSpacing.sm) - .background( - RoundedRectangle(cornerRadius: VRadius.md) - .fill( - minutes == selectedMinutes - ? VColor.primaryBase.opacity(0.3) - : VColor.surfaceBase - ) - ) - .overlay( - RoundedRectangle(cornerRadius: VRadius.md) - .stroke( - minutes == selectedMinutes - ? VColor.primaryBase.opacity(0.6) - : VColor.borderBase.opacity(0.5), - lineWidth: 1 - ) - ) - } - .buttonStyle(.plain) - } -} diff --git a/clients/macos/vellum-assistant/Features/Onboarding/FirstMeeting/ObservationSessionView.swift b/clients/macos/vellum-assistant/Features/Onboarding/FirstMeeting/ObservationSessionView.swift deleted file mode 100644 index 0a74489cae0..00000000000 --- a/clients/macos/vellum-assistant/Features/Onboarding/FirstMeeting/ObservationSessionView.swift +++ /dev/null @@ -1,249 +0,0 @@ -import VellumAssistantShared -import Combine -import SwiftUI - -/// Active observation session view — shows a timer countdown and running narration -/// of what the assistant "sees" while observing the user work. -/// Screen capture is stubbed; narration uses placeholder messages. -@MainActor -struct ObservationSessionView: View { - @Bindable var state: OnboardingState - var onComplete: () -> Void - var onStopEarly: () -> Void - - @State private var remainingSeconds: Int = 0 - @State private var totalSeconds: Int = 0 - @State private var timerActive: Bool = false - @State private var narrationMessages: [InterviewMessage] = [] - @State private var narrationIndex: Int = 0 - @State private var stopped = false - - /// Stub narration messages that simulate the assistant describing what it sees. - private static let stubNarrations: [String] = [ - "Okay, I\u{2019}m watching\u{2026} I see you have a few apps open. Interesting workflow.", - "You seem to switch between your browser and editor a lot \u{2014} I can help streamline that.", - "I notice you tend to organize things in a specific way. I\u{2019}ll remember that.", - "Got it \u{2014} you like to keep things tidy. I can work with that style.", - "Almost done! I\u{2019}m getting a good picture of how you work.", - ] - - private var progress: Double { - guard totalSeconds > 0 else { return 0 } - return 1.0 - (Double(remainingSeconds) / Double(totalSeconds)) - } - - private var timeDisplay: String { - let minutes = remainingSeconds / 60 - let seconds = remainingSeconds % 60 - return String(format: "%d:%02d", minutes, seconds) - } - - var body: some View { - HStack(alignment: .center, spacing: VSpacing.xxxl) { - // Creature on the left with "watching" indicator - VStack(spacing: VSpacing.md) { - CreatureView(visible: true, animated: false) - .scaleEffect(0.5) - .frame(width: 200, height: 200) - - HStack(spacing: VSpacing.xs) { - Circle() - .fill(VColor.systemPositiveStrong) - .frame(width: 8, height: 8) - .opacity(pulseOpacity) - - Text("Observing") - .font(VFont.captionMedium) - .foregroundColor(VColor.systemPositiveStrong) - .textSelection(.enabled) - } - } - - OnboardingPanel { - VStack(alignment: .leading, spacing: VSpacing.xl) { - // Timer header - HStack { - VStack(alignment: .leading, spacing: VSpacing.xxs) { - Text("Observation in progress") - .font(VFont.headline) - .foregroundColor(VColor.contentDefault) - .textSelection(.enabled) - - Text("\(timeDisplay) remaining") - .font(VFont.mono) - .foregroundColor(VColor.contentSecondary) - .textSelection(.enabled) - } - - Spacer() - - // Circular progress indicator - ZStack { - Circle() - .stroke(VColor.borderBase, lineWidth: 3) - .frame(width: 40, height: 40) - - Circle() - .trim(from: 0, to: progress) - .stroke(VColor.primaryBase, style: StrokeStyle(lineWidth: 3, lineCap: .round)) - .frame(width: 40, height: 40) - .rotationEffect(.degrees(-90)) - .animation(VAnimation.standard, value: progress) - - VIconView(.eye, size: 14) - .foregroundColor(VColor.primaryBase) - } - } - - // Progress bar - GeometryReader { geometry in - ZStack(alignment: .leading) { - RoundedRectangle(cornerRadius: VRadius.xs) - .fill(VColor.borderBase) - .frame(height: 4) - - RoundedRectangle(cornerRadius: VRadius.xs) - .fill(VColor.primaryBase) - .frame(width: geometry.size.width * progress, height: 4) - .animation(VAnimation.standard, value: progress) - } - } - .frame(height: 4) - - // Narration messages - ScrollViewReader { proxy in - ScrollView { - LazyVStack(spacing: VSpacing.md) { - ForEach(narrationMessages) { message in - NarrationBubble(text: message.text) - .id(message.id) - .transition(.opacity.combined(with: .move(edge: .bottom))) - } - } - .padding(.vertical, VSpacing.xs) - } - .frame(maxHeight: 200) - .onChange(of: narrationMessages.count) { - withAnimation(VAnimation.standard) { - if let last = narrationMessages.last { - proxy.scrollTo(last.id, anchor: .bottom) - } - } - } - } - - // Stop early button - OnboardingButton( - title: "Stop early", - style: .tertiary - ) { - stopObservation() - onStopEarly() - } - } - } - .frame(maxWidth: 420) - } - .onAppear { - startObservation() - } - .onDisappear { - timerActive = false - } - .onReceive(Timer.publish(every: 1.0, on: .main, in: .common).autoconnect()) { _ in - guard timerActive else { return } - if remainingSeconds > 0 { - remainingSeconds -= 1 - } else { - stopObservation() - onComplete() - } - } - } - - // MARK: - Pulse Animation - - @State private var pulseOpacity: Double = 1.0 - - private func startPulse() { - withAnimation(.easeInOut(duration: 1.0).repeatForever(autoreverses: true)) { - pulseOpacity = 0.3 - } - } - - // MARK: - Observation Logic - - private func startObservation() { - let minutes = state.observationDurationMinutes - totalSeconds = minutes * 60 - remainingSeconds = totalSeconds - - startPulse() - - // Add initial narration after a short delay - DispatchQueue.main.asyncAfter(deadline: .now() + 2.0) { [self] in - guard !stopped else { return } - addNextNarration() - } - - // Start countdown timer - timerActive = true - - // Schedule narration messages at intervals - let narrationInterval = max(Double(totalSeconds) / Double(Self.stubNarrations.count), 15.0) - for i in 1.. Void - var onDecline: () -> Void - - @State private var summaryDone = false - @State private var showInsights = false - @State private var showProposal = false - @State private var showButtons = false - - private let summaryText = "Okay, I think I\u{2019}ve got a good read on how you work. Here\u{2019}s what I noticed:" - - /// Stub observations — in production these would come from ambient analysis. - private var displayInsights: [String] { - if state.observationInsights.isEmpty { - return [ - "You switch between apps frequently \u{2014} I can help keep context across windows.", - "You tend to organize files methodically \u{2014} I\u{2019}ll match that style.", - "You like keyboard shortcuts \u{2014} I\u{2019}ll suggest efficient workflows.", - ] - } - // Use a curated subset of the collected insights - return Array(state.observationInsights.prefix(3)) - } - - private var proposalText: String { - let task = state.firstTaskCandidate ?? "your next task" - return "Based on what I saw, I think I could help with \(task) right away. Want me to give it a try?" - } - - var body: some View { - HStack(alignment: .center, spacing: VSpacing.xxxl) { - CreatureView(visible: true, animated: false) - .scaleEffect(0.5) - .frame(width: 200, height: 200) - - OnboardingPanel { - VStack(alignment: .leading, spacing: VSpacing.xl) { - // Summary introduction - TypewriterText( - fullText: summaryText, - speed: 0.03, - font: VFont.body, - onComplete: { - summaryDone = true - withAnimation(.easeOut(duration: 0.4)) { - showInsights = true - } - } - ) - - // Key observations - if showInsights { - VStack(alignment: .leading, spacing: VSpacing.md) { - ForEach(Array(displayInsights.enumerated()), id: \.offset) { index, insight in - InsightRow(text: insight, index: index) - .transition(.opacity.combined(with: .offset(y: 6))) - } - } - .transition(.opacity.combined(with: .offset(y: 6))) - .onAppear { - DispatchQueue.main.asyncAfter(deadline: .now() + 0.8) { - withAnimation(.easeOut(duration: 0.4)) { - showProposal = true - } - } - } - } - - // Proposal - if showProposal { - Text(proposalText) - .font(VFont.body) - .foregroundColor(VColor.contentSecondary) - .textSelection(.enabled) - .transition(.opacity.combined(with: .offset(y: 6))) - .onAppear { - DispatchQueue.main.asyncAfter(deadline: .now() + 0.5) { - withAnimation(.easeOut(duration: 0.5)) { - showButtons = true - } - } - } - } - - // Action buttons - if showButtons { - VStack(spacing: VSpacing.md) { - OnboardingButton( - title: "Let\u{2019}s try it", - style: .primary, - fadeIn: true, - fadeDelay: 0.1 - ) { - state.observationCompleted = true - onAccept() - } - - OnboardingButton( - title: "Maybe later", - style: .tertiary, - fadeIn: true, - fadeDelay: 0.3 - ) { - state.observationCompleted = true - onDecline() - } - } - .transition(.opacity.combined(with: .offset(y: 8))) - } - } - } - .frame(maxWidth: 420) - } - } -} - -// MARK: - Insight Row - -private struct InsightRow: View { - let text: String - let index: Int - - @State private var appeared = false - - var body: some View { - HStack(alignment: .top, spacing: VSpacing.sm) { - VIconView(.sparkles, size: 12) - .foregroundColor(VColor.primaryBase) - .padding(.top, 2) - - Text(text) - .font(VFont.body) - .foregroundColor(VColor.contentDefault) - .textSelection(.enabled) - } - .opacity(appeared ? 1 : 0) - .offset(y: appeared ? 0 : 4) - .onAppear { - DispatchQueue.main.asyncAfter(deadline: .now() + Double(index) * 0.2) { - withAnimation(.easeOut(duration: 0.4)) { - appeared = true - } - } - } - } -} diff --git a/clients/macos/vellum-assistant/Features/Onboarding/OnboardingState.swift b/clients/macos/vellum-assistant/Features/Onboarding/OnboardingState.swift index 0892a90637d..44843c1e027 100644 --- a/clients/macos/vellum-assistant/Features/Onboarding/OnboardingState.swift +++ b/clients/macos/vellum-assistant/Features/Onboarding/OnboardingState.swift @@ -1,11 +1,6 @@ import VellumAssistantShared import SwiftUI -enum OnboardingVariant: String { - case `default` - case firstMeeting = "first_meeting" -} - enum ActivationKey: String, CaseIterable { case fn case ctrl @@ -80,7 +75,6 @@ final class OnboardingState { var hasHatched: Bool = false var interviewCompleted: Bool = false var cloudProvider: String = "local" - var onboardingVariant: OnboardingVariant = .default /// When false, step changes are not written to UserDefaults (used by auth gate). var shouldPersist: Bool = true @@ -102,25 +96,12 @@ final class OnboardingState { var hatchCompleted: Bool = false var hatchFailed: Bool = false - // First-meeting-specific state - var firstMeetingCrackProgress: CGFloat = 0.0 - var conversationCompleted: Bool = false - var capabilitiesBriefingShown: Bool = false - var observationCompleted: Bool = false - var firstTaskCandidate: String? = nil - var observationDurationMinutes: Int = 5 - var observationInsights: [String] = [] - var anyPermissionDenied: Bool { !speechGranted || !accessibilityGranted || !screenGranted } /// Continuous crack progress (0.0–1.0) derived from step and permission state. - /// For the first meeting variant, uses a timer-driven stored property instead. var crackProgress: CGFloat { - if onboardingVariant == .firstMeeting { - return firstMeetingCrackProgress - } switch currentStep { case 0: return hasHatched ? 0.15 : 0.0 case 1: return 0.20 @@ -162,24 +143,8 @@ final class OnboardingState { cloudProvider = UserDefaults.standard.string(forKey: "onboarding.cloudProvider") ?? "local" skippedAPIKeyEntry = UserDefaults.standard.bool(forKey: "onboarding.skippedAPIKeyEntry") } - if let rawVariant = UserDefaults.standard.string(forKey: "onboarding.variant"), - let variant = OnboardingVariant(rawValue: rawVariant) { - onboardingVariant = variant - } - firstMeetingCrackProgress = CGFloat(UserDefaults.standard.double(forKey: "onboarding.firstMeetingCrackProgress")) - - // Clamp restored step to the variant's maximum to prevent out-of-range - // rendering (e.g. a step saved from the 8-step default flow would be - // invalid for the 5-step first-meeting flow). - let isManagedSignIn = MacOSClientFeatureFlagManager.shared.isEnabled("managed_sign_in_enabled") - let maxStep: Int - if isManagedSignIn { - maxStep = 3 - } else if onboardingVariant == .firstMeeting { - maxStep = 4 - } else { - maxStep = 3 - } + // Clamp restored step to the valid range. + let maxStep = 3 if currentStep > maxStep { currentStep = maxStep } @@ -200,8 +165,6 @@ final class OnboardingState { UserDefaults.standard.set(hasHatched, forKey: "onboarding.hatched") UserDefaults.standard.set(interviewCompleted, forKey: "onboarding.interviewCompleted") UserDefaults.standard.set(cloudProvider, forKey: "onboarding.cloudProvider") - UserDefaults.standard.set(onboardingVariant.rawValue, forKey: "onboarding.variant") - UserDefaults.standard.set(Double(firstMeetingCrackProgress), forKey: "onboarding.firstMeetingCrackProgress") UserDefaults.standard.set(Self.currentFlowVersion, forKey: "onboarding.flowVersion") UserDefaults.standard.set(skippedAPIKeyEntry, forKey: "onboarding.skippedAPIKeyEntry") } @@ -243,7 +206,7 @@ final class OnboardingState { } static func clearPersistedState() { - for key in ["onboarding.step", "onboarding.name", "onboarding.key", "onboarding.hatched", "onboarding.interviewCompleted", "onboarding.variant", "onboarding.firstMeetingCrackProgress", "onboarding.flowVersion", "onboarding.cloudProvider", "onboarding.skippedAPIKeyEntry"] { + for key in ["onboarding.step", "onboarding.name", "onboarding.key", "onboarding.hatched", "onboarding.interviewCompleted", "onboarding.flowVersion", "onboarding.cloudProvider", "onboarding.skippedAPIKeyEntry"] { UserDefaults.standard.removeObject(forKey: key) } } From f5dfa0ddfb3847131769950a5d9ff2345aa60862 Mon Sep 17 00:00:00 2001 From: Devin AI <158243242+devin-ai-integration[bot]@users.noreply.github.com> Date: Wed, 18 Mar 2026 23:09:51 +0000 Subject: [PATCH 3/3] Keep stale firstMeeting UserDefaults keys in clearPersistedState cleanup Co-Authored-By: ashlee@vellum.ai --- .../vellum-assistant/Features/Onboarding/OnboardingState.swift | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/clients/macos/vellum-assistant/Features/Onboarding/OnboardingState.swift b/clients/macos/vellum-assistant/Features/Onboarding/OnboardingState.swift index 44843c1e027..f43af6aaee9 100644 --- a/clients/macos/vellum-assistant/Features/Onboarding/OnboardingState.swift +++ b/clients/macos/vellum-assistant/Features/Onboarding/OnboardingState.swift @@ -206,7 +206,7 @@ final class OnboardingState { } static func clearPersistedState() { - for key in ["onboarding.step", "onboarding.name", "onboarding.key", "onboarding.hatched", "onboarding.interviewCompleted", "onboarding.flowVersion", "onboarding.cloudProvider", "onboarding.skippedAPIKeyEntry"] { + for key in ["onboarding.step", "onboarding.name", "onboarding.key", "onboarding.hatched", "onboarding.interviewCompleted", "onboarding.flowVersion", "onboarding.cloudProvider", "onboarding.skippedAPIKeyEntry", "onboarding.variant", "onboarding.firstMeetingCrackProgress"] { UserDefaults.standard.removeObject(forKey: key) } }