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/5] 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 166ea47bbd..f4408f62c1 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/5] 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 635f33eacf..0000000000 --- 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 f4408f62c1..0000000000 --- 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 72a930a6e5..0000000000 --- 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 a7731505e0..0000000000 --- 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 f0a524112b..0000000000 --- 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 0db92d4f31..0000000000 --- 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 05027bb98d..0000000000 --- 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 fee7d10fe3..0000000000 --- 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 0a74489cae..0000000000 --- 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 0892a90637..44843c1e02 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/5] 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 44843c1e02..f43af6aaee 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) } } From f32daba785c1625e3d2bd9db93613e4296579824 Mon Sep 17 00:00:00 2001 From: Devin AI <158243242+devin-ai-integration[bot]@users.noreply.github.com> Date: Thu, 19 Mar 2026 00:46:36 +0000 Subject: [PATCH 4/5] Remove dead onboarding code: Hatch/, Interview/, and unused step views Delete 23 unreachable files (~3,400 lines) from the onboarding system: - Hatch/: EggSceneView, HatchViewModel, OnboardingStageImage, EggHatchScene, EggFragmentMap, PixelArtData, CreatureView (egg/hatch animation system) - Interview/: InterviewStepView, InterviewChatView, InterviewViewModel, InterviewMessage, ProfileExtractor (onboarding chat interview) - Step views: AliveStepView, NamingStepView, FnKeyStepView, SpeechPermissionStepView, AccessibilityPermissionStepView, ScreenPermissionStepView (old onboarding steps 4-8) - Utilities: ReactionBubble, MeadowBackground, TypewriterText, OnboardingPanel, OnboardingFooter Clean up OnboardingState: remove crackProgress, interviewCompleted, permission-related properties (speechGranted, accessibilityGranted, screenGranted, skipPermissionChecks, anyPermissionDenied). Remove skipPermissionChecks debug flag from OnboardingWindow (no longer read by any view). Co-Authored-By: ashlee@vellum.ai --- .../AccessibilityPermissionStepView.swift | 64 --- .../Features/Onboarding/AliveStepView.swift | 113 ----- .../Features/Onboarding/FnKeyStepView.swift | 272 ---------- .../Onboarding/Hatch/CreatureView.swift | 63 --- .../Onboarding/Hatch/EggFragmentMap.swift | 179 ------- .../Onboarding/Hatch/EggHatchScene.swift | 473 ------------------ .../Onboarding/Hatch/EggSceneView.swift | 39 -- .../Onboarding/Hatch/HatchViewModel.swift | 8 - .../Hatch/OnboardingStageImage.swift | 167 ------- .../Onboarding/Hatch/PixelArtData.swift | 64 --- .../Interview/InterviewChatView.swift | 204 -------- .../Interview/InterviewMessage.swift | 20 - .../Interview/InterviewStepView.swift | 157 ------ .../Interview/InterviewViewModel.swift | 330 ------------ .../Interview/ProfileExtractor.swift | 311 ------------ .../Onboarding/MeadowBackground.swift | 20 - .../Features/Onboarding/NamingStepView.swift | 71 --- .../Onboarding/OnboardingFooter.swift | 28 -- .../Features/Onboarding/OnboardingPanel.swift | 26 - .../Features/Onboarding/OnboardingState.swift | 27 - .../Onboarding/OnboardingWindow.swift | 6 - .../Features/Onboarding/ReactionBubble.swift | 34 -- .../Onboarding/ScreenPermissionStepView.swift | 64 --- .../Onboarding/SpeechPermissionStepView.swift | 64 --- .../Features/Onboarding/TypewriterText.swift | 50 -- 25 files changed, 2854 deletions(-) delete mode 100644 clients/macos/vellum-assistant/Features/Onboarding/AccessibilityPermissionStepView.swift delete mode 100644 clients/macos/vellum-assistant/Features/Onboarding/AliveStepView.swift delete mode 100644 clients/macos/vellum-assistant/Features/Onboarding/FnKeyStepView.swift delete mode 100644 clients/macos/vellum-assistant/Features/Onboarding/Hatch/CreatureView.swift delete mode 100644 clients/macos/vellum-assistant/Features/Onboarding/Hatch/EggFragmentMap.swift delete mode 100644 clients/macos/vellum-assistant/Features/Onboarding/Hatch/EggHatchScene.swift delete mode 100644 clients/macos/vellum-assistant/Features/Onboarding/Hatch/EggSceneView.swift delete mode 100644 clients/macos/vellum-assistant/Features/Onboarding/Hatch/HatchViewModel.swift delete mode 100644 clients/macos/vellum-assistant/Features/Onboarding/Hatch/OnboardingStageImage.swift delete mode 100644 clients/macos/vellum-assistant/Features/Onboarding/Hatch/PixelArtData.swift delete mode 100644 clients/macos/vellum-assistant/Features/Onboarding/Interview/InterviewChatView.swift delete mode 100644 clients/macos/vellum-assistant/Features/Onboarding/Interview/InterviewMessage.swift delete mode 100644 clients/macos/vellum-assistant/Features/Onboarding/Interview/InterviewStepView.swift delete mode 100644 clients/macos/vellum-assistant/Features/Onboarding/Interview/InterviewViewModel.swift delete mode 100644 clients/macos/vellum-assistant/Features/Onboarding/Interview/ProfileExtractor.swift delete mode 100644 clients/macos/vellum-assistant/Features/Onboarding/MeadowBackground.swift delete mode 100644 clients/macos/vellum-assistant/Features/Onboarding/NamingStepView.swift delete mode 100644 clients/macos/vellum-assistant/Features/Onboarding/OnboardingFooter.swift delete mode 100644 clients/macos/vellum-assistant/Features/Onboarding/OnboardingPanel.swift delete mode 100644 clients/macos/vellum-assistant/Features/Onboarding/ReactionBubble.swift delete mode 100644 clients/macos/vellum-assistant/Features/Onboarding/ScreenPermissionStepView.swift delete mode 100644 clients/macos/vellum-assistant/Features/Onboarding/SpeechPermissionStepView.swift delete mode 100644 clients/macos/vellum-assistant/Features/Onboarding/TypewriterText.swift diff --git a/clients/macos/vellum-assistant/Features/Onboarding/AccessibilityPermissionStepView.swift b/clients/macos/vellum-assistant/Features/Onboarding/AccessibilityPermissionStepView.swift deleted file mode 100644 index 36141446d9..0000000000 --- a/clients/macos/vellum-assistant/Features/Onboarding/AccessibilityPermissionStepView.swift +++ /dev/null @@ -1,64 +0,0 @@ -import VellumAssistantShared -import SwiftUI - -@MainActor -struct AccessibilityPermissionStepView: View { - @Bindable var state: OnboardingState - - @State private var showContent = false - - var body: some View { - VStack(spacing: VSpacing.xl) { - VStack(spacing: VSpacing.md) { - Text("Computer control stays optional") - .font(VFont.onboardingTitle) - .foregroundColor(VColor.contentDefault) - .textSelection(.enabled) - - Text("Do not request Accessibility permission during initial onboarding. Enable computer control later from Settings when you explicitly choose it.") - .font(VFont.onboardingSubtitle) - .foregroundColor(VColor.contentSecondary) - .multilineTextAlignment(.center) - .frame(maxWidth: 420) - .textSelection(.enabled) - } - .opacity(showContent ? 1 : 0) - .offset(y: showContent ? 0 : 8) - - VStack(alignment: .leading, spacing: VSpacing.sm) { - Text("Deferred setup") - .font(VFont.bodyMedium) - .foregroundColor(VColor.contentDefault) - .textSelection(.enabled) - - Text("Accessibility and screen permissions are requested only after you explicitly enable computer control in Settings.") - .font(VFont.caption) - .foregroundColor(VColor.contentTertiary) - .textSelection(.enabled) - } - .frame(maxWidth: .infinity, alignment: .leading) - .padding(VSpacing.lg) - .background( - RoundedRectangle(cornerRadius: VRadius.md) - .fill(VColor.surfaceBase.opacity(0.3)) - .overlay( - RoundedRectangle(cornerRadius: VRadius.md) - .stroke(VColor.borderBase.opacity(0.4), lineWidth: 1) - ) - ) - .opacity(showContent ? 1 : 0) - - OnboardingButton(title: "Continue", style: .primary) { - state.advance() - } - .opacity(showContent ? 1 : 0) - } - .onAppear { - DispatchQueue.main.asyncAfter(deadline: .now() + 0.3) { - withAnimation(.easeOut(duration: 0.4)) { - showContent = true - } - } - } - } -} diff --git a/clients/macos/vellum-assistant/Features/Onboarding/AliveStepView.swift b/clients/macos/vellum-assistant/Features/Onboarding/AliveStepView.swift deleted file mode 100644 index 6f416f4cec..0000000000 --- a/clients/macos/vellum-assistant/Features/Onboarding/AliveStepView.swift +++ /dev/null @@ -1,113 +0,0 @@ -import VellumAssistantShared -import SwiftUI - -@MainActor -struct AliveStepView: View { - @Bindable var state: OnboardingState - var onComplete: () -> Void - var onOpenSettings: () -> Void - - @State private var showAbilities = false - @State private var showButtons = false - - private var abilities: [(String, String)] { - [ - ("Voice conversations", "mic.fill"), - ("Takes action for you", "hand.tap.fill"), - ("Context-aware help", "brain.head.profile"), - ("Hold \(state.chosenKey.displayName) to activate", "keyboard"), - ] - } - - var body: some View { - VStack(spacing: VSpacing.xl) { - VStack(spacing: VSpacing.md) { - Text("\(state.assistantName.isEmpty ? "It" : state.assistantName) has hatched.") - .font(VFont.onboardingTitle) - .foregroundColor(VColor.contentDefault) - .textSelection(.enabled) - - Text("All set up and ready to help.") - .font(VFont.onboardingSubtitle) - .foregroundColor(VColor.contentSecondary) - .textSelection(.enabled) - } - - // Ability tags — 2x2 grid - VStack(spacing: VSpacing.md + VSpacing.xxs) { - ForEach([0, 2], id: \.self) { row in - HStack(spacing: VSpacing.md + VSpacing.xxs) { - ForEach(row.. some View { - HStack(spacing: VSpacing.sm) { - VIconView(SFSymbolMapping.icon(forSFSymbol: icon, fallback: .puzzle), size: 11) - Text(title) - .font(VFont.captionMedium) - .textSelection(.enabled) - } - .foregroundColor(VColor.contentDefault.opacity(0.8)) - .padding(.horizontal, VSpacing.lg) - .padding(.vertical, VSpacing.sm) - .background( - Capsule() - .fill(VColor.surfaceBase.opacity(0.5)) - .overlay( - Capsule() - .stroke(VColor.borderBase.opacity(0.4), lineWidth: 1) - ) - ) - } -} diff --git a/clients/macos/vellum-assistant/Features/Onboarding/FnKeyStepView.swift b/clients/macos/vellum-assistant/Features/Onboarding/FnKeyStepView.swift deleted file mode 100644 index 240a7b9bc2..0000000000 --- a/clients/macos/vellum-assistant/Features/Onboarding/FnKeyStepView.swift +++ /dev/null @@ -1,272 +0,0 @@ -import VellumAssistantShared -import SwiftUI -import AVFoundation -import Speech - -@MainActor -struct FnKeyStepView: View { - @Bindable var state: OnboardingState - - @State private var showTitle = false - @State private var showContent = false - @State private var pulseScale: CGFloat = 1.0 - @State private var micGranted = false - @State private var speechGranted = false - @State private var permissionsRequested = false - @State private var permissionPollTimer: Timer? - - private var allPermissionsGranted: Bool { - micGranted && speechGranted - } - - var body: some View { - // Title - Text("Need voice mode?") - .font(.system(size: 32, weight: .regular, design: .serif)) - .foregroundColor(VColor.contentDefault) - .textSelection(.enabled) - .opacity(showTitle ? 1 : 0) - .offset(y: showTitle ? 0 : 8) - .padding(.bottom, VSpacing.md) - - // Subtitle - Text(permissionsRequested - ? "Grant the permissions to continue." - : "Hold fn + shift anywhere to talk to \(state.assistantName).") - .font(.system(size: 16)) - .foregroundColor(VColor.contentSecondary) - .textSelection(.enabled) - .opacity(showTitle ? 1 : 0) - .offset(y: showTitle ? 0 : 8) - .animation(.easeInOut(duration: 0.3), value: permissionsRequested) - - Spacer() - - // Content area - VStack(spacing: VSpacing.md) { - if permissionsRequested { - // Permission status rows - VStack(spacing: 0) { - permissionRow( - icon: VIcon.mic.rawValue, - label: "Microphone", - granted: micGranted - ) - Divider() - .background(VColor.borderBase) - permissionRow( - icon: "waveform", - label: "Speech Recognition", - granted: speechGranted - ) - } - .background( - RoundedRectangle(cornerRadius: VRadius.lg) - .stroke(VColor.borderBase, lineWidth: 1) - ) - .clipShape(RoundedRectangle(cornerRadius: VRadius.lg)) - .transition(.opacity.combined(with: .move(edge: .trailing))) - } else { - // Key badge row - HStack(spacing: VSpacing.sm) { - keyBadge("fn") - Text("+") - .font(.system(size: 18, weight: .medium, design: .monospaced)) - .foregroundColor(VColor.contentTertiary) - keyBadge("shift") - } - .frame(maxWidth: .infinity) - .padding(.vertical, VSpacing.lg) - .background( - RoundedRectangle(cornerRadius: VRadius.lg) - .stroke(VColor.borderBase, lineWidth: 1) - ) - .scaleEffect(pulseScale) - .transition(.opacity.combined(with: .move(edge: .leading))) - } - - // Primary button - if allPermissionsGranted { - Button(action: { - state.chosenKey = .fnShift - state.advance() - }) { - Text("Continue") - .font(.system(size: 15, weight: .medium)) - .foregroundColor(VColor.auxWhite) - .frame(maxWidth: .infinity) - .padding(.vertical, VSpacing.lg) - .background( - RoundedRectangle(cornerRadius: VRadius.lg) - .fill(VColor.primaryBase) - ) - } - .buttonStyle(.plain) - .pointerCursor() - .transition(.opacity) - } else { - Button(action: { requestPermissions() }) { - Text(permissionsRequested ? "Open System Settings" : "Enable Voice Mode") - .font(.system(size: 15, weight: .medium)) - .foregroundColor(VColor.auxWhite) - .frame(maxWidth: .infinity) - .padding(.vertical, VSpacing.lg) - .background( - RoundedRectangle(cornerRadius: VRadius.lg) - .fill(VColor.primaryBase) - ) - } - .buttonStyle(.plain) - .pointerCursor() - } - - // Skip + Back - HStack(spacing: VSpacing.lg) { - Button(action: { - stopPolling() - state.chosenKey = .none - state.advance() - }) { - Text("Skip") - .font(.system(size: 13)) - .foregroundColor(VColor.contentTertiary) - } - .buttonStyle(.plain) - .pointerCursor() - - OnboardingButton(title: "Back", style: .ghost) { - stopPolling() - withAnimation(.spring(duration: 0.6, bounce: 0.15)) { - state.currentStep = 3 - } - } - } - .padding(.top, VSpacing.xs) - } - .padding(.horizontal, VSpacing.xxl) - .padding(.bottom, VSpacing.lg) - .opacity(showContent ? 1 : 0) - .offset(y: showContent ? 0 : 12) - .animation(.spring(duration: 0.4, bounce: 0.1), value: permissionsRequested) - .animation(.spring(duration: 0.4, bounce: 0.1), value: allPermissionsGranted) - .onAppear { - checkCurrentPermissions() - withAnimation(.easeOut(duration: 0.5).delay(0.1)) { - showTitle = true - } - withAnimation(.easeOut(duration: 0.5).delay(0.3)) { - showContent = true - } - withAnimation(.easeInOut(duration: 1.5).repeatForever(autoreverses: true).delay(0.5)) { - pulseScale = 1.03 - } - } - .onDisappear { - stopPolling() - } - - OnboardingFooter(currentStep: state.currentStep) - .padding(.bottom, VSpacing.lg) - } - - // MARK: - Subviews - - private func permissionRow(icon: String, label: String, granted: Bool) -> some View { - HStack { - VIconView(SFSymbolMapping.icon(forSFSymbol: icon, fallback: .puzzle), size: 14) - .foregroundColor(granted ? VColor.systemPositiveStrong : VColor.contentTertiary) - .frame(width: 24) - Text(label) - .font(.system(size: 15)) - .foregroundColor(VColor.contentDefault) - .textSelection(.enabled) - Spacer() - VIconView(granted ? .circleCheck : .circle, size: 16) - .foregroundColor(granted ? VColor.systemPositiveStrong : VColor.contentTertiary) - } - .padding(.horizontal, VSpacing.lg) - .padding(.vertical, VSpacing.md) - } - - private func keyBadge(_ label: String) -> some View { - Text(label) - .font(.system(size: 16, weight: .medium, design: .monospaced)) - .foregroundColor(VColor.contentDefault) - .textSelection(.enabled) - .padding(.horizontal, VSpacing.xl) - .padding(.vertical, VSpacing.sm) - } - - // MARK: - Permissions - - private func checkCurrentPermissions() { - micGranted = AVCaptureDevice.authorizationStatus(for: .audio) == .authorized - speechGranted = SFSpeechRecognizer.authorizationStatus() == .authorized - } - - private func requestPermissions() { - // If already requested and denied, open System Settings - if permissionsRequested { - if !micGranted { - openPrivacySettings(for: "Privacy_Microphone") - } else if !speechGranted { - openPrivacySettings(for: "Privacy_SpeechRecognition") - } - return - } - - withAnimation { - permissionsRequested = true - } - - // Request microphone first - let micStatus = AVCaptureDevice.authorizationStatus(for: .audio) - if micStatus == .notDetermined { - AVCaptureDevice.requestAccess(for: .audio) { granted in - Task { @MainActor in - self.micGranted = granted - // After mic, request speech - self.requestSpeechPermission() - } - } - } else { - micGranted = micStatus == .authorized - requestSpeechPermission() - } - - // Start polling for permission changes (user may grant via System Settings) - startPolling() - } - - private func requestSpeechPermission() { - let speechStatus = SFSpeechRecognizer.authorizationStatus() - if speechStatus == .notDetermined { - SFSpeechRecognizer.requestAuthorization { status in - Task { @MainActor in - self.speechGranted = status == .authorized - } - } - } else { - speechGranted = speechStatus == .authorized - } - } - - private func startPolling() { - permissionPollTimer = Timer.scheduledTimer(withTimeInterval: 1.0, repeats: true) { _ in - Task { @MainActor in - checkCurrentPermissions() - } - } - } - - private func stopPolling() { - permissionPollTimer?.invalidate() - permissionPollTimer = nil - } - - private func openPrivacySettings(for pane: String) { - if let url = URL(string: "x-apple.systempreferences:com.apple.preference.security?\(pane)") { - NSWorkspace.shared.open(url) - } - } -} diff --git a/clients/macos/vellum-assistant/Features/Onboarding/Hatch/CreatureView.swift b/clients/macos/vellum-assistant/Features/Onboarding/Hatch/CreatureView.swift deleted file mode 100644 index 632885d6ab..0000000000 --- a/clients/macos/vellum-assistant/Features/Onboarding/Hatch/CreatureView.swift +++ /dev/null @@ -1,63 +0,0 @@ -import VellumAssistantShared -import SwiftUI - -/// The revealed creature with spring entrance and breathing animation. -/// Now shows the avatar image (custom or initial-letter fallback) instead of a pixel blob. -struct CreatureView: View { - let visible: Bool - var animated: Bool = true - @State private var appearance = AvatarAppearanceManager.shared - - // Precomputed transparency flag — avoids expensive bitmap analysis during animation frames. - @State private var avatarIsTransparent = false - - @State private var appeared = false - @State private var bounceOffset: CGFloat = 0 - @State private var breatheScaleY: CGFloat = 1.0 - @State private var breatheScaleX: CGFloat = 1.0 - - var body: some View { - if visible { - avatarImage - .scaleEffect(x: breatheScaleX, y: breatheScaleY, anchor: .bottom) - .offset(y: bounceOffset) - .scaleEffect(appeared ? 1.0 : 0.0) - .opacity(appeared ? 1.0 : 0.0) - .onChange(of: appearance.fullAvatarImage) { - avatarIsTransparent = VAvatarImage.imageHasTransparency(appearance.fullAvatarImage) - } - .onAppear { - avatarIsTransparent = VAvatarImage.imageHasTransparency(appearance.fullAvatarImage) - if animated { - withAnimation(.spring(response: 0.6, dampingFraction: 0.5, blendDuration: 0)) { - appeared = true - } - DispatchQueue.main.asyncAfter(deadline: .now() + 0.3) { - withAnimation(.easeOut(duration: 0.6)) { - bounceOffset = -15 - } - DispatchQueue.main.asyncAfter(deadline: .now() + 0.6) { - withAnimation(.easeIn(duration: 0.3)) { - bounceOffset = 0 - } - } - } - } else { - appeared = true - } - let breatheDelay: Double = animated ? 1.0 : 0.0 - DispatchQueue.main.asyncAfter(deadline: .now() + breatheDelay) { - withAnimation(.easeInOut(duration: 3).repeatForever(autoreverses: true)) { - breatheScaleY = 1.03 - breatheScaleX = 0.98 - } - } - } - } - } - - private var avatarImage: some View { - VAvatarImage(image: appearance.fullAvatarImage, size: 200, isTransparent: avatarIsTransparent, showBorder: false) - .shadow(radius: 8) - } -} diff --git a/clients/macos/vellum-assistant/Features/Onboarding/Hatch/EggFragmentMap.swift b/clients/macos/vellum-assistant/Features/Onboarding/Hatch/EggFragmentMap.swift deleted file mode 100644 index 13f6759233..0000000000 --- a/clients/macos/vellum-assistant/Features/Onboarding/Hatch/EggFragmentMap.swift +++ /dev/null @@ -1,179 +0,0 @@ -import CoreGraphics - -/// Assigns each egg pixel to one of 7 shell fragments and defines their drift/burst behavior. -/// Replaces CrackGeometry.swift. -enum EggFragmentMap { - - /// Fragment indices: - /// 0 = crown (top cap) - /// 1 = upper-left - /// 2 = upper-right - /// 3 = center-left - /// 4 = center-right - /// 5 = lower-left - /// 6 = lower-right - - struct FragmentDrift { - var dx: CGFloat - var dy: CGFloat - var rotation: CGFloat // radians - } - - // MARK: - Fragment Assignment Map - - /// Maps each egg pixel to a fragment index (0–6). Same dimensions as PixelArtData.egg (28×36). - /// nil where the egg pixel is nil (transparent). - static let fragmentMap: [[Int?]] = { - let egg = PixelArtData.egg - let rows = egg.count // 36 - let cols = egg[0].count // 28 - var map = [[Int?]](repeating: [Int?](repeating: nil, count: cols), count: rows) - - for row in 0.. [FragmentDrift] { - let clamped = min(max(progress, 0), 0.95) - - // Find surrounding stages - var lower = driftStages[0] - var upper = driftStages[0] - for i in 0.. SKSpriteNode { - let rows = grid.count - let cols = grid[0].count - let width = Int(CGFloat(cols) * pixelSize) - let height = Int(CGFloat(rows) * pixelSize) - - let colorSpace = CGColorSpaceCreateDeviceRGB() - guard let context = CGContext( - data: nil, - width: width, - height: height, - bitsPerComponent: 8, - bytesPerRow: width * 4, - space: colorSpace, - bitmapInfo: CGImageAlphaInfo.premultipliedLast.rawValue - ) else { - return SKSpriteNode() - } - - let ps = Int(pixelSize) - for row in 0..> 16) & 0xFF) / 255.0 - let g = CGFloat((hex >> 8) & 0xFF) / 255.0 - let b = CGFloat(hex & 0xFF) / 255.0 - context.setFillColor(red: r, green: g, blue: b, alpha: 1.0) - let x = col * ps - let y = (rows - 1 - row) * ps - context.fill(CGRect(x: x, y: y, width: ps, height: ps)) - } - } - - guard let cgImage = context.makeImage() else { - return SKSpriteNode() - } - - let texture = SKTexture(cgImage: cgImage) - texture.filteringMode = .nearest - return SKSpriteNode(texture: texture, size: CGSize(width: width, height: height)) - } - - private func setupGlow() { - glowNode = SKEffectNode() - glowNode.shouldRasterize = true - glowNode.filter = CIFilter(name: "CIGaussianBlur", parameters: ["inputRadius": 20.0]) - glowNode.zPosition = 5 - - glowSpriteNode = SKSpriteNode(color: NSColor(Meadow.eggGlow), size: CGSize(width: 160, height: 200)) - glowSpriteNode.alpha = 0.3 - glowNode.addChild(glowSpriteNode) - glowNode.position = CGPoint(x: 0, y: 10) - addChild(glowNode) - - let pulseUp = SKAction.run { [weak self] in - self?.glowSpriteNode.run(SKAction.fadeAlpha(to: 0.5, duration: 1.5)) - } - let pulseDown = SKAction.run { [weak self] in - self?.glowSpriteNode.run(SKAction.fadeAlpha(to: 0.3, duration: 1.5)) - } - let wait = SKAction.wait(forDuration: 1.5) - glowNode.run(SKAction.repeatForever(SKAction.sequence([pulseUp, wait, pulseDown, wait]))) - } - - private func setupFireflies() { - let colors: [NSColor] = [ - NSColor(Meadow.eggGlow).withAlphaComponent(0.6), - NSColor(Meadow.eggGlowIntense).withAlphaComponent(0.5), - NSColor(Meadow.crackLight).withAlphaComponent(0.4), - ] - - for i in 0..<5 { - let dot = SKShapeNode(circleOfRadius: 2) - dot.fillColor = colors[i % colors.count] - dot.strokeColor = .clear - dot.alpha = 0 - dot.zPosition = 3 - dot.position = CGPoint( - x: CGFloat.random(in: -100...100), - y: CGFloat.random(in: -80...80) - ) - addChild(dot) - - let dur = CGFloat.random(in: 6...9) - let fadeIn = SKAction.fadeAlpha(to: CGFloat.random(in: 0.3...0.7), duration: Double(dur / 2)) - let fadeOut = SKAction.fadeAlpha(to: 0.05, duration: Double(dur / 2)) - let moveBy = SKAction.moveBy( - x: CGFloat.random(in: -40...40), - y: CGFloat.random(in: -30...30), - duration: Double(dur) - ) - let moveBack = moveBy.reversed() - let group1 = SKAction.group([fadeIn, moveBy]) - let group2 = SKAction.group([fadeOut, moveBack]) - dot.run(SKAction.repeatForever(SKAction.sequence([ - SKAction.wait(forDuration: Double(i) * 0.8), - group1, - group2, - ]))) - } - } - - // MARK: - Idle Animations - - private func startIdleAnimations() { - guard eggContainer != nil else { return } - let floatUp = SKAction.moveBy(x: 0, y: 5, duration: 1.5) - floatUp.timingMode = .easeInEaseOut - let floatDown = floatUp.reversed() - let floatAction = SKAction.repeatForever(SKAction.sequence([floatUp, floatDown])) - idleFloatAction = floatAction - eggContainer.run(floatAction, withKey: "idleFloat") - creatureNode?.run(floatAction, withKey: "idleFloat") - } - - // MARK: - Public API - - func setCrackProgress(_ progress: CGFloat, animated: Bool) { - guard !hasFullyHatched, eggContainer != nil, glowSpriteNode != nil else { return } - currentProgress = progress - - let drifts = EggFragmentMap.interpolatedDrifts(for: progress) - let duration: TimeInterval = animated ? 0.5 : 0 - - for frag in fragmentNodes { - guard frag.index < drifts.count else { continue } - let drift = drifts[frag.index] - let targetPos = CGPoint( - x: frag.centerOffset.x + drift.dx, - y: frag.centerOffset.y + drift.dy - ) - - if animated { - frag.sprite.run(SKAction.group([ - SKAction.move(to: targetPos, duration: duration), - SKAction.rotate(toAngle: drift.rotation, duration: duration), - ])) - } else { - frag.sprite.position = targetPos - frag.sprite.zRotation = drift.rotation - } - } - - // Fade creature in - let creatureAlpha: CGFloat - if progress <= 0.10 { - creatureAlpha = 0 - } else if progress >= 0.40 { - creatureAlpha = 1 - } else { - creatureAlpha = (progress - 0.10) / 0.30 - } - - if animated { - creatureNode?.run(SKAction.fadeAlpha(to: creatureAlpha, duration: duration)) - } else { - creatureNode?.alpha = creatureAlpha - } - - let glowAlpha = 0.3 + progress * 0.5 - glowSpriteNode.run(SKAction.fadeAlpha(to: glowAlpha, duration: animated ? 0.5 : 0)) - } - - func triggerDramaticCrack(for step: Int) { - guard eggContainer != nil else { return } - - eggContainer.removeAction(forKey: "idleFloat") - creatureNode?.removeAction(forKey: "idleFloat") - - let shakeRight = SKAction.moveBy(x: 6, y: 0, duration: 0.04) - let shakeLeft = SKAction.moveBy(x: -12, y: 0, duration: 0.04) - let shakeCenter = SKAction.moveBy(x: 6, y: 0, duration: 0.04) - let shakeSeq = SKAction.sequence([shakeRight, shakeLeft, shakeCenter]) - let shake = SKAction.repeat(shakeSeq, count: 6) - - let flash = SKSpriteNode(color: NSColor(VColor.auxWhite), size: CGSize(width: 300, height: 300)) - flash.position = eggContainer.position - flash.alpha = 0 - flash.zPosition = 50 - addChild(flash) - - let flashIn = SKAction.fadeAlpha(to: 0.7, duration: 0.1) - let flashOut = SKAction.fadeAlpha(to: 0, duration: 0.4) - let removeFlash = SKAction.removeFromParent() - flash.run(SKAction.sequence([flashIn, flashOut, removeFlash])) - - spawnCrackSparkles() - - for frag in fragmentNodes { - let jitterX = CGFloat.random(in: -3...3) - let jitterY = CGFloat.random(in: -3...3) - let jitter = SKAction.sequence([ - SKAction.moveBy(x: jitterX, y: jitterY, duration: 0.05), - SKAction.moveBy(x: -jitterX, y: -jitterY, duration: 0.05), - ]) - frag.sprite.run(SKAction.repeat(jitter, count: 4)) - } - - eggContainer.run(shake) { [weak self] in - guard let self else { return } - self.eggContainer.position = CGPoint(x: 0, y: 10) - self.creatureNode?.position = CGPoint(x: 0, y: 10) - self.startIdleAnimations() - self.hatchDelegate?.sceneDidComplete(.dramaticCrackDone) - } - } - - func triggerFullHatch() { - guard eggContainer != nil, !hasFullyHatched else { return } - hasFullyHatched = true - - eggContainer.removeAllActions() - creatureNode?.removeAllActions() - - let flash = SKSpriteNode(color: NSColor(VColor.auxWhite), size: CGSize(width: 400, height: 400)) - flash.position = eggContainer.position - flash.alpha = 0 - flash.zPosition = 50 - addChild(flash) - flash.run(SKAction.sequence([ - SKAction.fadeAlpha(to: 0.85, duration: 0.15), - SKAction.fadeAlpha(to: 0, duration: 0.6), - SKAction.removeFromParent(), - ])) - - for frag in fragmentNodes { - let worldPos = eggContainer.convert(frag.sprite.position, to: self) - let worldRotation = frag.sprite.zRotation - frag.sprite.removeFromParent() - frag.sprite.position = worldPos - frag.sprite.zRotation = worldRotation - frag.sprite.zPosition = 20 - addChild(frag.sprite) - - frag.sprite.physicsBody = SKPhysicsBody(rectangleOf: frag.sprite.size) - frag.sprite.physicsBody?.affectedByGravity = true - frag.sprite.physicsBody?.collisionBitMask = 0 - frag.sprite.physicsBody?.contactTestBitMask = 0 - frag.sprite.physicsBody?.linearDamping = 0.5 - frag.sprite.physicsBody?.angularDamping = 0.3 - - if frag.index < EggFragmentMap.burstVelocities.count { - let v = EggFragmentMap.burstVelocities[frag.index] - frag.sprite.physicsBody?.applyImpulse(CGVector(dx: v.dx * 0.12, dy: v.dy * 0.12)) - frag.sprite.physicsBody?.applyAngularImpulse(v.angularImpulse) - } - - frag.sprite.run(SKAction.sequence([ - SKAction.wait(forDuration: 0.8), - SKAction.fadeOut(withDuration: 0.4), - SKAction.removeFromParent(), - ])) - } - - eggContainer.removeFromParent() - - DispatchQueue.main.asyncAfter(deadline: .now() + 0.5) { [weak self] in - self?.showCreature() - } - } - - // MARK: - Crack Sparkles - - private func spawnCrackSparkles() { - let sparklePositions: [CGPoint] = [ - CGPoint(x: -10, y: 20), - CGPoint(x: 15, y: -5), - CGPoint(x: -5, y: -15), - CGPoint(x: 20, y: 10), - ] - - for pos in sparklePositions { - let sparkle = SKShapeNode(circleOfRadius: 3) - sparkle.fillColor = NSColor(Meadow.crackLight) - sparkle.strokeColor = .clear - sparkle.position = CGPoint( - x: eggContainer.position.x + pos.x, - y: eggContainer.position.y + pos.y - ) - sparkle.zPosition = 15 - sparkle.alpha = 0 - addChild(sparkle) - - let appear = SKAction.fadeIn(withDuration: 0.1) - let grow = SKAction.scale(to: 1.5, duration: 0.2) - let fadeAndShrink = SKAction.group([ - SKAction.fadeOut(withDuration: 0.4), - SKAction.scale(to: 0, duration: 0.4), - ]) - sparkle.run(SKAction.sequence([appear, grow, fadeAndShrink, SKAction.removeFromParent()])) - } - } - - // MARK: - Creature - - private func showCreature() { - guard let creatureNode else { return } - - creatureNode.alpha = 1 - creatureNode.position = CGPoint(x: 0, y: 10) - creatureNode.setScale(0) - - let appear = SKAction.group([ - SKAction.fadeIn(withDuration: 0.2), - SKAction.scale(to: 1.1, duration: 0.3), - ]) - appear.timingMode = .easeOut - let settle = SKAction.scale(to: 1.0, duration: 0.2) - settle.timingMode = .easeInEaseOut - - let bounceUp = SKAction.moveBy(x: 0, y: 15, duration: 0.3) - bounceUp.timingMode = .easeOut - let bounceDown = SKAction.moveBy(x: 0, y: -15, duration: 0.2) - bounceDown.timingMode = .easeIn - - creatureNode.run(SKAction.sequence([appear, settle, bounceUp, bounceDown])) { [weak self] in - let breatheUp = SKAction.scaleY(to: 1.03, duration: 1.5) - breatheUp.timingMode = .easeInEaseOut - let breatheDown = SKAction.scaleY(to: 1.0, duration: 1.5) - breatheDown.timingMode = .easeInEaseOut - creatureNode.run(SKAction.repeatForever(SKAction.sequence([breatheUp, breatheDown]))) - - self?.spawnCelebration() - self?.hatchDelegate?.sceneDidComplete(.fullHatchDone) - } - } - - private func spawnCelebration() { - for _ in 0..<12 { - let sparkle = SKShapeNode(circleOfRadius: CGFloat.random(in: 2...4)) - sparkle.fillColor = NSColor(Meadow.eggGlow) - sparkle.strokeColor = .clear - sparkle.position = CGPoint(x: 0, y: 10) - sparkle.zPosition = 25 - sparkle.alpha = 0.8 - addChild(sparkle) - - let angle = CGFloat.random(in: 0...(2 * .pi)) - let distance = CGFloat.random(in: 60...120) - let dx = cos(angle) * distance - let dy = sin(angle) * distance - - let move = SKAction.moveBy(x: dx, y: dy, duration: Double.random(in: 0.8...1.4)) - move.timingMode = .easeOut - let fade = SKAction.fadeOut(withDuration: 1.0) - let shrink = SKAction.scale(to: 0.2, duration: 1.2) - let group = SKAction.group([move, fade, shrink]) - sparkle.run(SKAction.sequence([group, SKAction.removeFromParent()])) - } - } -} diff --git a/clients/macos/vellum-assistant/Features/Onboarding/Hatch/EggSceneView.swift b/clients/macos/vellum-assistant/Features/Onboarding/Hatch/EggSceneView.swift deleted file mode 100644 index c2f01e79eb..0000000000 --- a/clients/macos/vellum-assistant/Features/Onboarding/Hatch/EggSceneView.swift +++ /dev/null @@ -1,39 +0,0 @@ -import SpriteKit -import SwiftUI - -/// SwiftUI wrapper for the SpriteKit egg hatch scene. -struct EggSceneView: View { - let state: OnboardingState - @State private var scene: EggHatchScene = { - let s = EggHatchScene() - s.size = CGSize(width: 280, height: 480) - s.scaleMode = .resizeFill - s.backgroundColor = .clear - return s - }() - - var body: some View { - SpriteView(scene: scene, options: [.allowsTransparency]) - .onAppear { - // Set initial crack progress without animation for restored sessions - let progress = state.crackProgress - if progress > 0 { - scene.setCrackProgress(progress, animated: false) - } - // Resume full hatch if restored at step 7 (Alive) - if state.currentStep == 7 { - scene.triggerFullHatch() - } - } - .onChange(of: state.crackProgress) { _, newValue in - scene.setCrackProgress(newValue, animated: true) - } - .onChange(of: state.currentStep) { old, new in - if (4...6).contains(new) && new > old { - scene.triggerDramaticCrack(for: new) - } else if new == 7 { - scene.triggerFullHatch() - } - } - } -} diff --git a/clients/macos/vellum-assistant/Features/Onboarding/Hatch/HatchViewModel.swift b/clients/macos/vellum-assistant/Features/Onboarding/Hatch/HatchViewModel.swift deleted file mode 100644 index 2810b1e4bd..0000000000 --- a/clients/macos/vellum-assistant/Features/Onboarding/Hatch/HatchViewModel.swift +++ /dev/null @@ -1,8 +0,0 @@ -import SwiftUI -import Observation - -/// Thin adapter for legacy references. The main hatch logic is now in EggHatchScene. -@Observable -final class HatchViewModel { - var onComplete: (() -> Void)? -} diff --git a/clients/macos/vellum-assistant/Features/Onboarding/Hatch/OnboardingStageImage.swift b/clients/macos/vellum-assistant/Features/Onboarding/Hatch/OnboardingStageImage.swift deleted file mode 100644 index b105a02a50..0000000000 --- a/clients/macos/vellum-assistant/Features/Onboarding/Hatch/OnboardingStageImage.swift +++ /dev/null @@ -1,167 +0,0 @@ -import SwiftUI -import VellumAssistantShared - -/// Displays the correct stage PNG for the current onboarding step -/// with Pokemon-style hatch animations: shaking, white flash, glow. -struct OnboardingStageImage: View { - let currentStep: Int - - @State private var bobOffset: CGFloat = 0 - @State private var shakeOffset: CGFloat = 0 - @State private var shakeRotation: Double = 0 - @State private var flashOpacity: Double = 0 - @State private var glowOpacity: Double = 0 - @State private var glowScale: CGFloat = 1.0 - @State private var displayedStage: Int = 1 - @State private var isTransitioning = false - - /// Maps onboarding step (0-7) to stage image number (1-5). - private var stageNumber: Int { - switch currentStep { - case 0, 1, 2: return 1 - case 3: return 2 - case 4, 5: return 3 - case 6: return 4 - default: return 5 - } - } - - var body: some View { - ZStack(alignment: .bottom) { - Color.clear - - ZStack { - // Glow behind the sprite - Ellipse() - .fill( - RadialGradient( - colors: [VColor.auxWhite.opacity(0.6), VColor.auxWhite.opacity(0.0)], - center: .center, - startRadius: 10, - endRadius: 100 - ) - ) - .frame(width: 200, height: 120) - .scaleEffect(glowScale) - .opacity(glowOpacity) - .offset(y: 20) // center glow on sprite body - .blur(radius: 8) - - // Stage sprite - if let url = ResourceBundle.bundle.url(forResource: "stage-\(displayedStage)", withExtension: "png"), - let nsImage = NSImage(contentsOf: url) { - Image(nsImage: nsImage) - .interpolation(.none) - .resizable() - .aspectRatio(contentMode: .fit) - .frame(maxHeight: 180) - .id(displayedStage) - .transition( - .asymmetric( - insertion: .scale(scale: 1.08).combined(with: .opacity), - removal: .scale(scale: 0.92).combined(with: .opacity) - ) - ) - } - } - .offset(x: shakeOffset, y: bobOffset - 40) - .animation( - .easeInOut(duration: 2.5).repeatForever(autoreverses: true), - value: bobOffset - ) - .rotationEffect(.degrees(shakeRotation)) - - // White flash overlay - Rectangle() - .fill(VColor.auxWhite) - .opacity(flashOpacity) - } - .onAppear { - displayedStage = stageNumber - // Defer the bob animation start to the next run-loop iteration - // so the repeatForever .animation() modifier doesn't infect - // the displayedStage change above. When onboarding resumes - // from persisted progress, both assignments would otherwise - // happen in the same SwiftUI update cycle, letting the - // repeat-forever animation leak to the stage transition. - DispatchQueue.main.async { - bobOffset = -4 - } - } - .onChange(of: stageNumber) { oldStage, newStage in - guard oldStage != newStage, !isTransitioning else { return } - playHatchTransition(to: newStage) - } - } - - // MARK: - Animations - - private func playHatchTransition(to newStage: Int) { - isTransitioning = true - - // Phase 1: Subtle shake — gentle wobble - let shakeDuration = 0.08 - let shakeSequence = [ - (offset: CGFloat(2), rotation: 1.0), - (offset: CGFloat(-2), rotation: -1.0), - (offset: CGFloat(3), rotation: 1.5), - (offset: CGFloat(-3), rotation: -1.5), - (offset: CGFloat(2), rotation: 1.0), - (offset: CGFloat(-2), rotation: -1.0), - (offset: CGFloat(0), rotation: 0.0), - ] - - for (index, shake) in shakeSequence.enumerated() { - let delay = Double(index) * shakeDuration - DispatchQueue.main.asyncAfter(deadline: .now() + delay) { - withAnimation(.linear(duration: shakeDuration)) { - shakeOffset = shake.offset - shakeRotation = shake.rotation - } - } - } - - // Phase 2: Glow builds during shake - let shakeEnd = Double(shakeSequence.count) * shakeDuration - withAnimation(.easeIn(duration: shakeEnd)) { - glowOpacity = 0.4 - glowScale = 1.15 - } - - // Phase 3: White flash + swap sprite - DispatchQueue.main.asyncAfter(deadline: .now() + shakeEnd) { - // Brief flash in - withAnimation(.easeIn(duration: 0.1)) { - flashOpacity = 0.3 - } - - // Swap sprite at peak flash - DispatchQueue.main.asyncAfter(deadline: .now() + 0.1) { - withAnimation(.spring(duration: 0.5, bounce: 0.2)) { - displayedStage = newStage - } - - // Flash out - withAnimation(.easeOut(duration: 0.2)) { - flashOpacity = 0 - } - - // Glow pulse then fade - withAnimation(.easeOut(duration: 0.3)) { - glowScale = 1.3 - glowOpacity = 0.6 - } - DispatchQueue.main.asyncAfter(deadline: .now() + 0.3) { - withAnimation(.easeOut(duration: 0.5)) { - glowOpacity = 0 - glowScale = 1.0 - } - } - - DispatchQueue.main.asyncAfter(deadline: .now() + 0.6) { - isTransitioning = false - } - } - } - } -} diff --git a/clients/macos/vellum-assistant/Features/Onboarding/Hatch/PixelArtData.swift b/clients/macos/vellum-assistant/Features/Onboarding/Hatch/PixelArtData.swift deleted file mode 100644 index 0643715501..0000000000 --- a/clients/macos/vellum-assistant/Features/Onboarding/Hatch/PixelArtData.swift +++ /dev/null @@ -1,64 +0,0 @@ -import VellumAssistantShared -import Foundation - -/// Static pixel-art grids for egg and dino, stored as 2D arrays of UInt32? hex colors. -/// nil = transparent pixel. Each art pixel maps to `Meadow.artPixelSize` points. -enum PixelArtData { - - // MARK: - Palette Constants - - // Egg (amber) - static let eH: UInt32 = 0xFEEC94 // highlight - static let eL: UInt32 = 0xFDD94E // light body - static let eM: UInt32 = 0xFAC426 // mid body - static let eB: UInt32 = 0xE8A020 // base body - static let eS: UInt32 = 0xC97C10 // shadow - static let eD: UInt32 = 0xA35E0C // deep shadow - - // MARK: - Egg Grid (28 wide × 36 tall) - - static let egg: [[UInt32?]] = { - let n: UInt32? = nil - let H = eH, L = eL, M = eM, B = eB, S = eS, D = eD - return [ - // 0 1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21 22 23 24 25 26 27 - [ n, n, n, n, n, n, n, n, n, n, n, H, H, H, H, H, H, n, n, n, n, n, n, n, n, n, n, n], // 0 - [ n, n, n, n, n, n, n, n, n, H, H, H, L, L, L, L, H, H, H, n, n, n, n, n, n, n, n, n], // 1 - [ n, n, n, n, n, n, n, H, H, L, L, L, L, L, L, L, L, L, L, H, H, n, n, n, n, n, n, n], // 2 - [ n, n, n, n, n, n, H, L, L, L, L, L, M, M, M, M, L, L, L, L, L, H, n, n, n, n, n, n], // 3 - [ n, n, n, n, n, H, L, L, L, M, M, M, M, M, M, M, M, M, L, L, L, L, H, n, n, n, n, n], // 4 - [ n, n, n, n, H, L, L, M, M, M, M, M, M, M, M, M, M, M, M, M, L, L, L, H, n, n, n, n], // 5 - [ n, n, n, H, L, L, M, M, M, M, M, B, B, B, B, B, B, M, M, M, M, M, L, L, H, n, n, n], // 6 - [ n, n, n, H, L, M, M, M, B, B, B, B, B, B, B, B, B, B, B, M, M, M, M, L, H, n, n, n], // 7 - [ n, n, H, L, M, M, M, B, B, B, B, B, B, B, B, B, B, B, B, B, M, M, M, L, L, H, n, n], // 8 - [ n, n, H, L, M, M, B, B, B, B, B, B, B, B, B, B, B, B, B, B, B, M, M, M, L, H, n, n], // 9 - [ n, H, L, M, M, B, B, B, B, B, B, B, B, B, B, B, B, B, B, B, B, B, M, M, L, L, H, n], // 10 - [ n, H, L, M, M, B, B, B, B, B, B, B, B, B, B, B, B, B, B, B, B, B, M, M, L, L, H, n], // 11 - [ n, H, L, M, B, B, B, B, B, B, S, S, B, B, B, B, B, S, S, B, B, B, B, M, M, L, H, n], // 12 - [ H, L, M, M, B, B, B, B, B, S, S, S, B, B, B, B, S, S, S, B, B, B, B, B, M, M, L, H], // 13 - [ H, L, M, M, B, B, B, B, B, B, S, B, B, B, B, B, B, S, B, B, B, B, B, B, M, M, L, H], // 14 - [ H, L, M, B, B, B, B, B, B, B, B, B, B, B, B, B, B, B, B, B, B, B, B, B, B, M, L, H], // 15 - [ H, L, M, B, B, B, B, B, B, B, B, B, B, B, B, B, B, B, B, B, B, B, B, B, B, M, L, H], // 16 - [ H, L, M, B, B, B, B, B, B, B, B, B, B, B, B, B, B, B, B, B, B, B, B, B, B, M, L, H], // 17 - [ H, L, M, B, B, B, B, B, B, B, B, S, S, B, B, S, S, B, B, B, B, B, B, B, B, M, L, H], // 18 - [ H, L, M, B, B, B, B, B, B, B, S, S, S, S, S, S, S, S, B, B, B, B, B, B, B, M, L, H], // 19 - [ H, L, M, M, B, B, B, B, B, S, S, D, S, S, S, S, D, S, S, B, B, B, B, B, M, M, L, H], // 20 - [ n, H, L, M, B, B, B, B, S, S, D, D, D, S, S, D, D, D, S, S, B, B, B, B, M, L, H, n], // 21 - [ n, H, L, M, M, B, B, S, S, D, D, D, D, D, D, D, D, D, D, S, S, B, B, M, M, L, H, n], // 22 - [ n, H, L, M, M, B, B, S, S, D, D, D, D, D, D, D, D, D, D, S, S, B, B, M, M, L, H, n], // 23 - [ n, n, H, L, M, M, B, B, S, S, D, D, D, D, D, D, D, D, S, S, B, B, M, M, L, H, n, n], // 24 - [ n, n, H, L, M, M, B, B, S, S, S, D, D, D, D, D, D, S, S, S, B, B, M, M, L, H, n, n], // 25 - [ n, n, n, H, L, M, M, B, B, S, S, S, D, D, D, D, S, S, S, B, B, M, M, L, H, n, n, n], // 26 - [ n, n, n, H, L, M, M, B, B, S, S, S, S, D, D, S, S, S, S, B, B, M, M, L, H, n, n, n], // 27 - [ n, n, n, n, H, L, M, M, B, B, S, S, S, S, S, S, S, S, B, B, M, M, L, H, n, n, n, n], // 28 - [ n, n, n, n, H, L, M, M, B, B, B, S, S, S, S, S, S, B, B, B, M, M, L, H, n, n, n, n], // 29 - [ n, n, n, n, n, H, L, M, M, B, B, B, S, S, S, S, B, B, B, M, M, L, H, n, n, n, n, n], // 30 - [ n, n, n, n, n, n, H, L, M, M, B, B, B, S, S, B, B, B, M, M, L, H, n, n, n, n, n, n], // 31 - [ n, n, n, n, n, n, n, H, L, M, M, B, B, B, B, B, B, M, M, L, H, n, n, n, n, n, n, n], // 32 - [ n, n, n, n, n, n, n, n, H, L, M, M, B, B, B, B, M, M, L, H, n, n, n, n, n, n, n, n], // 33 - [ n, n, n, n, n, n, n, n, n, H, L, M, M, M, M, M, M, L, H, n, n, n, n, n, n, n, n, n], // 34 - [ n, n, n, n, n, n, n, n, n, n, H, H, L, L, L, L, H, H, n, n, n, n, n, n, n, n, n, n], // 35 - ] - }() - -} diff --git a/clients/macos/vellum-assistant/Features/Onboarding/Interview/InterviewChatView.swift b/clients/macos/vellum-assistant/Features/Onboarding/Interview/InterviewChatView.swift deleted file mode 100644 index 5870b0a075..0000000000 --- a/clients/macos/vellum-assistant/Features/Onboarding/Interview/InterviewChatView.swift +++ /dev/null @@ -1,204 +0,0 @@ -import VellumAssistantShared -import SwiftUI - -struct InterviewChatView: View { - let messages: [InterviewMessage] - let inputText: String - let isThinking: Bool - let isStreaming: Bool - var onChipTap: ((String) -> Void)? = nil - - /// Whether suggestion chips should be visible. - /// Only show after the first assistant greeting has fully completed (not while streaming). - private var showSuggestionChips: Bool { - let hasGreeting = messages.contains { $0.role == .assistant } - let hasUserMessage = messages.contains { $0.role == .user } - return hasGreeting && !hasUserMessage && inputText.isEmpty && !isStreaming - } - - var body: some View { - VStack(spacing: 0) { - messageList - if showSuggestionChips { - suggestionChips - .transition(.opacity.combined(with: .move(edge: .bottom))) - } - } - .animation(VAnimation.standard, value: showSuggestionChips) - } - - // MARK: - Message List - - private var messageList: some View { - ScrollViewReader { proxy in - ScrollView { - LazyVStack(spacing: VSpacing.md) { - ForEach(messages) { message in - MessageBubble(message: message) - .id(message.id) - .transition(.opacity.combined(with: .move(edge: .bottom))) - } - - if isThinking { - TypingIndicator() - .id("typing-indicator") - .transition(.opacity.combined(with: .move(edge: .bottom))) - } - } - .padding(.horizontal, VSpacing.lg) - .padding(.vertical, VSpacing.md) - } - .onChange(of: messages.count) { - withAnimation(VAnimation.standard) { - if let lastMessage = messages.last { - proxy.scrollTo(lastMessage.id, anchor: .bottom) - } - } - } - .onChange(of: isThinking) { - if isThinking { - withAnimation(VAnimation.standard) { - proxy.scrollTo("typing-indicator", anchor: .bottom) - } - } - } - } - } - - // MARK: - Suggestion Chips - - private static let chipTexts = [ - "Automate repetitive tasks", - "Research & summarize faster", - "Help me write & edit", - "Just exploring what\u{2019}s possible", - ] - - private var suggestionChips: some View { - VStack(spacing: VSpacing.sm) { - HStack(spacing: VSpacing.sm) { - ForEach(Self.chipTexts.prefix(2), id: \.self) { chip in - chipButton(chip) - } - } - HStack(spacing: VSpacing.sm) { - ForEach(Self.chipTexts.suffix(2), id: \.self) { chip in - chipButton(chip) - } - } - } - .padding(.horizontal, VSpacing.lg) - .padding(.vertical, VSpacing.sm) - } - - private func chipButton(_ chip: String) -> some View { - Button { - onChipTap?(chip) - } label: { - Text(chip) - .font(VFont.caption) - .foregroundColor(VColor.contentSecondary) - .padding(.horizontal, VSpacing.md) - .padding(.vertical, VSpacing.sm) - .background(VColor.surfaceBase) - .clipShape(Capsule()) - .overlay( - Capsule() - .stroke(VColor.borderBase.opacity(0.5), lineWidth: 1) - ) - } - .buttonStyle(.plain) - .pointerCursor() - } -} - -// MARK: - Message Bubble - -private struct MessageBubble: View { - let message: InterviewMessage - - private var isAssistant: Bool { message.role == .assistant } - - var body: some View { - HStack { - if !isAssistant { Spacer(minLength: 0) } - - Text(message.text) - .font(VFont.body) - .foregroundColor(isAssistant ? VColor.contentDefault : VColor.auxWhite) - .textSelection(.enabled) - .padding(.horizontal, VSpacing.lg) - .padding(.vertical, VSpacing.md) - .background( - RoundedRectangle(cornerRadius: VRadius.md) - .fill(bubbleFill) - ) - .if(!isAssistant) { view in - view.vShadow(VShadow.accentGlow) - } - .frame(maxWidth: maxBubbleWidth, alignment: isAssistant ? .leading : .trailing) - - if isAssistant { Spacer(minLength: 0) } - } - } - - private var bubbleFill: some ShapeStyle { - if isAssistant { - return AnyShapeStyle(VColor.surfaceBase.opacity(0.5)) - } else { - return AnyShapeStyle( - LinearGradient( - colors: [Meadow.userBubbleGradientStart, Meadow.userBubbleGradientEnd], - startPoint: .topLeading, - endPoint: .bottomTrailing - ) - ) - } - } - - private var maxBubbleWidth: CGFloat { - 340 - } -} - -// MARK: - Typing Indicator - -private struct TypingIndicator: View { - @State private var phase: Int = 0 - @State private var timer: Timer? - - var body: some View { - HStack { - HStack(spacing: VSpacing.xs) { - ForEach(0..<3, id: \.self) { index in - Circle() - .fill(VColor.contentSecondary) - .frame(width: 6, height: 6) - .opacity(dotOpacity(for: index)) - } - } - .padding(.horizontal, VSpacing.lg) - .padding(.vertical, VSpacing.md) - .background( - RoundedRectangle(cornerRadius: VRadius.md) - .fill(VColor.surfaceBase.opacity(0.5)) - ) - - Spacer() - } - .onAppear { startAnimation() } - .onDisappear { timer?.invalidate() } - } - - private func dotOpacity(for index: Int) -> Double { - phase == index ? 1.0 : 0.4 - } - - private func startAnimation() { - timer = Timer.scheduledTimer(withTimeInterval: 0.4, repeats: true) { _ in - withAnimation(.easeInOut(duration: 0.3)) { - phase = (phase + 1) % 3 - } - } - } -} diff --git a/clients/macos/vellum-assistant/Features/Onboarding/Interview/InterviewMessage.swift b/clients/macos/vellum-assistant/Features/Onboarding/Interview/InterviewMessage.swift deleted file mode 100644 index 5380392f97..0000000000 --- a/clients/macos/vellum-assistant/Features/Onboarding/Interview/InterviewMessage.swift +++ /dev/null @@ -1,20 +0,0 @@ -import Foundation - -struct InterviewMessage: Identifiable { - enum Role { - case assistant - case user - } - - let id: UUID - let role: Role - let text: String - let timestamp: Date - - init(id: UUID = UUID(), role: Role, text: String, timestamp: Date = Date()) { - self.id = id - self.role = role - self.text = text - self.timestamp = timestamp - } -} diff --git a/clients/macos/vellum-assistant/Features/Onboarding/Interview/InterviewStepView.swift b/clients/macos/vellum-assistant/Features/Onboarding/Interview/InterviewStepView.swift deleted file mode 100644 index 9b3a51ad9c..0000000000 --- a/clients/macos/vellum-assistant/Features/Onboarding/Interview/InterviewStepView.swift +++ /dev/null @@ -1,157 +0,0 @@ -import SwiftUI -import VellumAssistantShared - -@MainActor -struct InterviewStepView: View { - @Bindable var state: OnboardingState - let daemonClient: DaemonClientProtocol - let onComplete: () -> Void - - @State private var viewModel: InterviewViewModel - @State private var showControls = false - @State private var streamingMessageId = UUID() - - init(state: OnboardingState, daemonClient: DaemonClientProtocol, onComplete: @escaping () -> Void) { - self.state = state - self.daemonClient = daemonClient - self.onComplete = onComplete - self._viewModel = State(initialValue: InterviewViewModel( - daemonClient: daemonClient, - assistantName: state.assistantName - )) - } - - /// 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: dino left, chat panel right - HStack(alignment: .center, spacing: VSpacing.xxxl) { - // Hatched dinosaur — same visual size as in hatch scene - CreatureView(visible: true, animated: false) - .scaleEffect(0.5) - .frame(width: 200, height: 200) - - // 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 { - completeInterview() - } label: { - Text("Skip setup for now") - .font(VFont.caption) - .foregroundColor(VColor.contentTertiary) - } - .buttonStyle(.plain) - .pointerCursor() - .transition(.opacity) - .padding(.vertical, VSpacing.md) - } - - OnboardingFooter(currentStep: state.currentStep) - .padding(.bottom, VSpacing.lg) - } - .onAppear { - viewModel.startInterview() - DispatchQueue.main.asyncAfter(deadline: .now() + 1.0) { - withAnimation(.easeOut(duration: 0.5)) { - showControls = true - } - } - } - .onChange(of: viewModel.isFinished) { - if viewModel.isFinished { - completeInterview() - } - } - .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: - Interview Completion - - private func completeInterview() { - state.interviewCompleted = true - viewModel.endInterview() - - onComplete() - } -} diff --git a/clients/macos/vellum-assistant/Features/Onboarding/Interview/InterviewViewModel.swift b/clients/macos/vellum-assistant/Features/Onboarding/Interview/InterviewViewModel.swift deleted file mode 100644 index fcef0c88bd..0000000000 --- a/clients/macos/vellum-assistant/Features/Onboarding/Interview/InterviewViewModel.swift +++ /dev/null @@ -1,330 +0,0 @@ -import Foundation -import VellumAssistantShared -import Observation -import os - -private let log = Logger( - subsystem: Bundle.main.bundleIdentifier ?? "com.vellum.vellum-assistant", - category: "InterviewViewModel" -) - -@Observable -@MainActor -final class InterviewViewModel { - - // MARK: - Public State - - var messages: [InterviewMessage] = [] - var inputText: String = "" - var isThinking: Bool = false - var isComplete: Bool = false - var isFinished: Bool = false - var streamingText: String = "" - - // MARK: - Dependencies - - private let daemonClient: DaemonClientProtocol - private let assistantName: String - - // MARK: - Internal State - - private let maxTurns = 5 - 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, assistantName: String) { - self.daemonClient = daemonClient - self.assistantName = assistantName - } - - // MARK: - Start Interview - - /// Kicks off the interview by creating a new daemon text conversation in onboarding mode. - /// The assistant-side playbook/prompt system owns the conversation intelligence. - /// Captures the conversation ID and streams the assistant's opening reply into state. - func startInterview() { - 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 { - let trimmedName = self.assistantName.trimmingCharacters(in: .whitespacesAndNewlines) - var hints = [ - "onboarding-active", - "onboarding-phase:post_hatch", - "desktop-first-conversation" - ] - if !trimmedName.isEmpty { - hints.append("assistant-name:\(trimmedName)") - } - try self.daemonClient.send(ConversationCreateMessage( - title: "Getting to know you", - maxResponseTokens: 220, - transportChannelId: "vellum", - transportHints: hints, - transportUxBrief: "Onboarding conversation after hatch. Follow the channel playbook 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): - // Capture the daemon-assigned conversation ID, then send a natural - // first user message to kick off the conversation. - if self.conversationId == nil { - self.conversationId = info.conversationId - log.info("Interview conversation created: \(info.conversationId)") - - do { - try self.daemonClient.send(UserMessageMessage( - conversationId: info.conversationId, - content: "Hi! I just hatched you and I want to get set up together.", - 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: - accumulated += delta.text - self.isThinking = false - self.streamingText = accumulated - - case .assistantThinkingDelta where self.conversationId != nil: - // Stay in thinking state while the model reasons. - 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("Interview 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("Interview 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("Interview 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 interview conversation. - /// Subscribes to the daemon stream and listens for the assistant's streamed reply - /// rather than creating a new conversation, keeping the conversation context intact. - 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 // the response about to be generated - 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): - accumulated += delta.text - self.isThinking = false - self.streamingText = accumulated - - case .assistantThinkingDelta: - // Stay in thinking state while the model reasons. - 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)") - 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)") - return - - case .conversationError(let error) where error.conversationId == conversationId: - self.isThinking = false - self.streamingText = "" - log.error("Conversation error during follow-up (conversation_error): \(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: - End Interview - - /// Marks the interview as complete and cancels any in-progress streaming. - func endInterview() { - if let startTime { - let duration = Date().timeIntervalSince(startTime) - let turns = self.turnCount - let finished = self.isFinished - log.info("Interview 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/Interview/ProfileExtractor.swift b/clients/macos/vellum-assistant/Features/Onboarding/Interview/ProfileExtractor.swift deleted file mode 100644 index 4ad48c7703..0000000000 --- a/clients/macos/vellum-assistant/Features/Onboarding/Interview/ProfileExtractor.swift +++ /dev/null @@ -1,311 +0,0 @@ -import Foundation -import VellumAssistantShared -import Observation -import os - -private let log = Logger( - subsystem: Bundle.main.bundleIdentifier ?? "com.vellum.vellum-assistant", - category: "ProfileExtractor" -) - -// MARK: - UserProfile - -struct UserProfile: Codable, Sendable { - let name: String? - let role: String? - let goals: [String]? - let painPoints: [String]? - let communicationStyle: String? - let interests: [String]? - let personality: String? -} - -// MARK: - Extraction Response - -private struct ExtractionResponse: Codable { - let profile: UserProfile - let personality: String - let userBehavior: String -} - -// MARK: - ProfileExtractor - -/// Extracts a structured user profile from an interview transcript by sending -/// the conversation to a new daemon conversation with a profile-extraction system prompt. -/// Merges `personality` and `userBehavior` into `~/.vellum/workspace/SOUL.md` and stores the -/// profile in UserDefaults for client-side use. -/// -/// Designed to run in the background after onboarding completes. Fails silently -/// on any error — logs but does not crash or surface errors to the user. -@Observable -@MainActor -final class ProfileExtractor { - - // MARK: - Dependencies - - private let daemonClient: DaemonClientProtocol - - // MARK: - Init - - init(daemonClient: DaemonClientProtocol) { - self.daemonClient = daemonClient - } - - // MARK: - Extraction - - /// Runs profile extraction against the daemon in the background. - /// Creates a new conversation with a profile-extraction system prompt, sends the - /// formatted interview transcript, parses the JSON response, writes SOUL.md, - /// and stores profile data in UserDefaults. - func extractProfile(from messages: [InterviewMessage], assistantName: String) async { - do { - try await performExtraction(from: messages, assistantName: assistantName) - } catch { - log.error("Profile extraction failed: \(error.localizedDescription)") - } - } - - // MARK: - Private - - private static let extractionPrompt = """ - You are analyzing a conversation between an AI assistant and a new user. - Extract a structured profile as JSON with these fields: - - name: string (if mentioned) - - role: string (profession/occupation) - - goals: string[] (what they want to accomplish) - - painPoints: string[] (what frustrates them) - - communicationStyle: "casual" | "formal" | "mixed" - - interests: string[] (topics they care about) - - personality: string (1-2 sentence description) - - Then generate two additional fields based on what you learned: - - personality: A 2-3 sentence personality description for the assistant that reflects \ - what was learned about this user. Write it as a description of the assistant's personality. - - userBehavior: 3-5 bullet points (each starting with "- ") describing how the assistant \ - should interact with THIS specific human based on their preferences, communication style, \ - and needs. - - Output ONLY valid JSON in this format: - {"profile": {...}, "personality": "...", "userBehavior": "- point 1\\n- point 2\\n..."} - """ - - private func performExtraction(from messages: [InterviewMessage], assistantName: String) async throws { - guard !messages.isEmpty else { - log.info("No interview messages to extract profile from") - return - } - - // Format the transcript. - let transcript = formatTranscript(messages, assistantName: assistantName) - - // Subscribe to the daemon stream before creating the conversation - // so we don't miss the conversation_info message. - let stream = daemonClient.subscribe() - - // Create a new extraction conversation with the system prompt override. - try daemonClient.send(ConversationCreateMessage( - title: "Profile extraction", - systemPromptOverride: Self.extractionPrompt, - maxResponseTokens: 1024 - )) - - // Wait for conversation creation, send the transcript, and accumulate the response. - // Filter all streaming events by conversationId so we only process deltas and - // completion from our own extraction conversation, not from unrelated concurrent - // conversations (e.g., a chat the user starts while extraction runs). - var conversationId: String? - var accumulated = "" - - for await message in stream { - switch message { - case .conversationInfo(let info): - if conversationId == nil { - conversationId = info.conversationId - log.info("Extraction conversation created: \(info.conversationId)") - - try daemonClient.send(UserMessageMessage( - conversationId: info.conversationId, - content: "Here is the interview transcript to analyze:\n\n\(transcript)", - attachments: nil - )) - } - - case .assistantTextDelta(let delta) where delta.conversationId == conversationId && conversationId != nil: - accumulated += delta.text - - case .assistantThinkingDelta where conversationId != nil: - break - - case .messageComplete(let complete) where complete.conversationId == conversationId && conversationId != nil: - log.info("Extraction response complete (\(accumulated.count) chars)") - processExtractionResponse(accumulated) - return - - case .generationHandoff(let handoff) where handoff.conversationId == conversationId && conversationId != nil: - log.info("Extraction response complete via handoff (\(accumulated.count) chars)") - processExtractionResponse(accumulated) - return - - case .conversationError(let error) where error.conversationId == conversationId: - log.error("Extraction conversation error (conversation_error): \(error.userMessage)") - return - - default: - break - } - } - - // Stream ended without completion -- try to use whatever we accumulated. - if !accumulated.isEmpty { - log.warning("Extraction stream ended early, attempting to parse partial response") - processExtractionResponse(accumulated) - } - } - - /// Formats interview messages into a readable transcript. - private func formatTranscript(_ messages: [InterviewMessage], assistantName: String) -> String { - let name = assistantName.isEmpty ? "Assistant" : assistantName - return messages.map { msg in - let speaker = msg.role == .assistant ? name : "User" - return "\(speaker): \(msg.text)" - }.joined(separator: "\n\n") - } - - /// Parses the JSON response, writes SOUL.md, and stores profile data in UserDefaults. - private func processExtractionResponse(_ responseText: String) { - guard let jsonData = extractJSON(from: responseText) else { - log.error("Could not find JSON object in extraction response") - return - } - - do { - let response = try JSONDecoder().decode(ExtractionResponse.self, from: jsonData) - updateSoulFile(personality: response.personality, userBehavior: response.userBehavior) - storeProfile(response.profile) - log.info("Profile extraction complete — name: \(response.profile.name ?? "unknown")") - } catch { - log.error("Failed to decode extraction response: \(error.localizedDescription)") - } - } - - /// Extracts JSON from the response text, handling potential markdown code blocks. - private func extractJSON(from text: String) -> Data? { - var cleaned = text.trimmingCharacters(in: .whitespacesAndNewlines) - - // Strip markdown code block wrappers if present. - if cleaned.hasPrefix("```json") { - cleaned = String(cleaned.dropFirst(7)) - } else if cleaned.hasPrefix("```") { - cleaned = String(cleaned.dropFirst(3)) - } - if cleaned.hasSuffix("```") { - cleaned = String(cleaned.dropLast(3)) - } - cleaned = cleaned.trimmingCharacters(in: .whitespacesAndNewlines) - - // Find the first { and last } to extract the JSON object. - guard let startIdx = cleaned.firstIndex(of: "{"), - let endIdx = cleaned.lastIndex(of: "}") else { - return nil - } - - let jsonString = String(cleaned[startIdx...endIdx]) - return jsonString.data(using: .utf8) - } - - /// Updates `~/.vellum/workspace/SOUL.md` by merging personality and user-behavior - /// content into the existing file's `## Personality` and `## User-Specific Behavior` - /// sections rather than overwriting the whole file (which would nuke Core Principles, - /// Boundaries, Evolution guardrails, etc.). - /// - /// If SOUL.md doesn't exist yet, writes a minimal file with just these two sections; - /// the daemon's `ensurePromptFiles()` will seed the full template on next startup. - private func updateSoulFile(personality: String, userBehavior: String) { - let vellumDir = NSHomeDirectory() + "/.vellum/workspace" - let soulPath = vellumDir + "/SOUL.md" - - do { - try FileManager.default.createDirectory( - atPath: vellumDir, - withIntermediateDirectories: true, - attributes: nil - ) - - let content: String - if FileManager.default.fileExists(atPath: soulPath), - let existing = try? String(contentsOfFile: soulPath, encoding: .utf8) { - // Merge into existing SOUL.md by replacing section content - var updated = existing - updated = replaceSectionContent(in: updated, section: "## Personality", newContent: personality) - updated = replaceSectionContent(in: updated, section: "## User-Specific Behavior", newContent: userBehavior) - content = updated - } else { - // No existing SOUL.md — write minimal sections - content = """ - # SOUL - - ## Personality - - \(personality) - - ## User-Specific Behavior - - \(userBehavior) - """ - } - - try content.write(toFile: soulPath, atomically: true, encoding: .utf8) - log.info("Updated SOUL.md at \(soulPath)") - } catch { - log.error("Failed to update SOUL.md: \(error.localizedDescription)") - } - } - - /// Replaces the content of a markdown section (everything between the section heading - /// and the next `##` heading) with new content, preserving the heading itself. - private func replaceSectionContent(in text: String, section: String, newContent: String) -> String { - // Find the section heading - guard let sectionRange = text.range(of: section) else { - // Section doesn't exist — append it at the end - return text.trimmingCharacters(in: .whitespacesAndNewlines) + "\n\n\(section)\n\n\(newContent)\n" - } - - // Find what comes after this section heading: the next ## heading (or end of file) - let afterHeadingStr = String(text[sectionRange.upperBound...]) - let nextSectionPattern = #"\n## "# - let nextSectionOffset: String.Index? - if let regex = try? NSRegularExpression(pattern: nextSectionPattern), - let match = regex.firstMatch( - in: afterHeadingStr, - range: NSRange(afterHeadingStr.startIndex..., in: afterHeadingStr) - ), - let matchRange = Range(match.range, in: afterHeadingStr) { - // Convert the offset back to the original text index - let offset = afterHeadingStr.distance(from: afterHeadingStr.startIndex, to: matchRange.lowerBound) - nextSectionOffset = text.index(sectionRange.upperBound, offsetBy: offset) - } else { - nextSectionOffset = nil - } - - let sectionContentEnd = nextSectionOffset ?? text.endIndex - - // Build replacement: heading + newline + new content + trailing newline - let replacement = "\(section)\n\n\(newContent)\n" - - var result = text - let replaceRange = sectionRange.lowerBound.. 1 else { return 0 } - let fraction = Double(currentStep) / Double(totalSteps - 1) - return min(Int(fraction * Double(totalDots - 1) + 0.5), totalDots - 1) - } - - var body: some View { - Text("\u{00A9} 2026 Vellum Inc.") - .font(VFont.monoSmall) - .foregroundStyle(VColor.contentTertiary.opacity(0.5)) - } -} diff --git a/clients/macos/vellum-assistant/Features/Onboarding/OnboardingPanel.swift b/clients/macos/vellum-assistant/Features/Onboarding/OnboardingPanel.swift deleted file mode 100644 index 8fb8306035..0000000000 --- a/clients/macos/vellum-assistant/Features/Onboarding/OnboardingPanel.swift +++ /dev/null @@ -1,26 +0,0 @@ -import VellumAssistantShared -import SwiftUI - -/// Compact dark frosted glass card for onboarding step content. -struct OnboardingPanel: View { - @ViewBuilder var content: Content - - var body: some View { - content - .padding(.horizontal, VSpacing.xxl) - .padding(.vertical, VSpacing.xxxl) - .frame(maxWidth: 420) - .background( - RoundedRectangle(cornerRadius: VRadius.lg) - .fill(.ultraThinMaterial) - .overlay( - RoundedRectangle(cornerRadius: VRadius.lg) - .fill(Meadow.panelBackground) - ) - .overlay( - RoundedRectangle(cornerRadius: VRadius.lg) - .stroke(Meadow.panelBorder, lineWidth: 1) - ) - ) - } -} diff --git a/clients/macos/vellum-assistant/Features/Onboarding/OnboardingState.swift b/clients/macos/vellum-assistant/Features/Onboarding/OnboardingState.swift index f43af6aaee..a9aa8c34ec 100644 --- a/clients/macos/vellum-assistant/Features/Onboarding/OnboardingState.swift +++ b/clients/macos/vellum-assistant/Features/Onboarding/OnboardingState.swift @@ -27,10 +27,6 @@ final class OnboardingState { var currentStep: Int = 0 var assistantName: String = "Velly" var chosenKey: ActivationKey = .fn - var speechGranted: Bool = false - var accessibilityGranted: Bool = false - var screenGranted: Bool = false - var skipPermissionChecks: Bool = false /// Whether the user explicitly skipped login during onboarding. var skippedAuth: Bool = false @@ -73,7 +69,6 @@ final class OnboardingState { } } var hasHatched: Bool = false - var interviewCompleted: Bool = false var cloudProvider: String = "local" /// When false, step changes are not written to UserDefaults (used by auth gate). @@ -96,26 +91,6 @@ final class OnboardingState { var hatchCompleted: Bool = false var hatchFailed: Bool = false - var anyPermissionDenied: Bool { - !speechGranted || !accessibilityGranted || !screenGranted - } - - /// Continuous crack progress (0.0–1.0) derived from step and permission state. - var crackProgress: CGFloat { - switch currentStep { - case 0: return hasHatched ? 0.15 : 0.0 - case 1: return 0.20 - case 2: return 0.25 - case 3: return 0.35 - case 4: return 0.60 - case 5: return speechGranted ? 0.70 : 0.65 - case 6: return accessibilityGranted ? 0.80 : 0.70 - case 7: return screenGranted ? 0.95 : 0.85 - case 8: return 1.0 - default: return 1.0 - } - } - /// Restore onboarding progress from a previous session (e.g. after macOS /// kills the app when toggling screen-recording permission). init() { @@ -139,7 +114,6 @@ final class OnboardingState { chosenKey = key } hasHatched = UserDefaults.standard.bool(forKey: "onboarding.hatched") - interviewCompleted = UserDefaults.standard.bool(forKey: "onboarding.interviewCompleted") cloudProvider = UserDefaults.standard.string(forKey: "onboarding.cloudProvider") ?? "local" skippedAPIKeyEntry = UserDefaults.standard.bool(forKey: "onboarding.skippedAPIKeyEntry") } @@ -163,7 +137,6 @@ final class OnboardingState { UserDefaults.standard.set(assistantName, forKey: "onboarding.name") 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(cloudProvider, forKey: "onboarding.cloudProvider") UserDefaults.standard.set(Self.currentFlowVersion, forKey: "onboarding.flowVersion") UserDefaults.standard.set(skippedAPIKeyEntry, forKey: "onboarding.skippedAPIKeyEntry") diff --git a/clients/macos/vellum-assistant/Features/Onboarding/OnboardingWindow.swift b/clients/macos/vellum-assistant/Features/Onboarding/OnboardingWindow.swift index 05661d251b..19742718b2 100644 --- a/clients/macos/vellum-assistant/Features/Onboarding/OnboardingWindow.swift +++ b/clients/macos/vellum-assistant/Features/Onboarding/OnboardingWindow.swift @@ -19,12 +19,6 @@ final class OnboardingWindow { } func show() { - #if DEBUG - if CommandLine.arguments.contains("--skip-permission-checks") { - state.skipPermissionChecks = true - } - #endif - let flowView = OnboardingFlowView( state: state, daemonClient: daemonClient, diff --git a/clients/macos/vellum-assistant/Features/Onboarding/ReactionBubble.swift b/clients/macos/vellum-assistant/Features/Onboarding/ReactionBubble.swift deleted file mode 100644 index 31491784df..0000000000 --- a/clients/macos/vellum-assistant/Features/Onboarding/ReactionBubble.swift +++ /dev/null @@ -1,34 +0,0 @@ -import VellumAssistantShared -import SwiftUI - -struct ReactionBubble: View { - let text: String - var delay: TimeInterval = 0.4 - - @State private var visible = false - - var body: some View { - Text(text) - .font(VFont.body) - .foregroundColor(VColor.contentDefault.opacity(0.9)) - .padding(.horizontal, VSpacing.xl) - .padding(.vertical, VSpacing.md + VSpacing.xxs) - .background( - RoundedRectangle(cornerRadius: VRadius.lg) - .fill(VColor.surfaceBase.opacity(0.5)) - .overlay( - RoundedRectangle(cornerRadius: VRadius.lg) - .stroke(VColor.borderBase.opacity(0.4), lineWidth: 1) - ) - ) - .opacity(visible ? 1 : 0) - .offset(y: visible ? 0 : 8) - .onAppear { - DispatchQueue.main.asyncAfter(deadline: .now() + delay) { - withAnimation(.easeOut(duration: 0.5)) { - visible = true - } - } - } - } -} diff --git a/clients/macos/vellum-assistant/Features/Onboarding/ScreenPermissionStepView.swift b/clients/macos/vellum-assistant/Features/Onboarding/ScreenPermissionStepView.swift deleted file mode 100644 index f9de5412d9..0000000000 --- a/clients/macos/vellum-assistant/Features/Onboarding/ScreenPermissionStepView.swift +++ /dev/null @@ -1,64 +0,0 @@ -import VellumAssistantShared -import SwiftUI - -@MainActor -struct ScreenPermissionStepView: View { - @Bindable var state: OnboardingState - - @State private var showContent = false - - var body: some View { - VStack(spacing: VSpacing.xl) { - VStack(spacing: VSpacing.md) { - Text("Screen access comes later") - .font(VFont.onboardingTitle) - .foregroundColor(VColor.contentDefault) - .textSelection(.enabled) - - Text("Skip screen-recording permission during the first conversation. Start it later from Settings when you explicitly choose computer-control setup.") - .font(VFont.onboardingSubtitle) - .foregroundColor(VColor.contentSecondary) - .multilineTextAlignment(.center) - .frame(maxWidth: 420) - .textSelection(.enabled) - } - .opacity(showContent ? 1 : 0) - .offset(y: showContent ? 0 : 8) - - VStack(alignment: .leading, spacing: VSpacing.sm) { - Text("Deferred setup") - .font(VFont.bodyMedium) - .foregroundColor(VColor.contentDefault) - .textSelection(.enabled) - - Text("Screen Recording requests follow an explicit opt-in flow, not proactive onboarding prompts.") - .font(VFont.caption) - .foregroundColor(VColor.contentTertiary) - .textSelection(.enabled) - } - .frame(maxWidth: .infinity, alignment: .leading) - .padding(VSpacing.lg) - .background( - RoundedRectangle(cornerRadius: VRadius.md) - .fill(VColor.surfaceBase.opacity(0.3)) - .overlay( - RoundedRectangle(cornerRadius: VRadius.md) - .stroke(VColor.borderBase.opacity(0.4), lineWidth: 1) - ) - ) - .opacity(showContent ? 1 : 0) - - OnboardingButton(title: "Continue", style: .primary) { - state.advance() - } - .opacity(showContent ? 1 : 0) - } - .onAppear { - DispatchQueue.main.asyncAfter(deadline: .now() + 0.3) { - withAnimation(.easeOut(duration: 0.4)) { - showContent = true - } - } - } - } -} diff --git a/clients/macos/vellum-assistant/Features/Onboarding/SpeechPermissionStepView.swift b/clients/macos/vellum-assistant/Features/Onboarding/SpeechPermissionStepView.swift deleted file mode 100644 index f1a5ca40c5..0000000000 --- a/clients/macos/vellum-assistant/Features/Onboarding/SpeechPermissionStepView.swift +++ /dev/null @@ -1,64 +0,0 @@ -import VellumAssistantShared -import SwiftUI - -@MainActor -struct SpeechPermissionStepView: View { - @Bindable var state: OnboardingState - - @State private var showContent = false - - var body: some View { - VStack(spacing: VSpacing.xl) { - VStack(spacing: VSpacing.md) { - Text("Voice mode is optional") - .font(VFont.onboardingTitle) - .foregroundColor(VColor.contentDefault) - .textSelection(.enabled) - - Text("Skip microphone setup during first-run. You can enable voice mode later from the Settings panel when you want it.") - .font(VFont.onboardingSubtitle) - .foregroundColor(VColor.contentSecondary) - .multilineTextAlignment(.center) - .frame(maxWidth: 420) - .textSelection(.enabled) - } - .opacity(showContent ? 1 : 0) - .offset(y: showContent ? 0 : 8) - - VStack(alignment: .leading, spacing: VSpacing.sm) { - Text("Deferred setup") - .font(VFont.bodyMedium) - .foregroundColor(VColor.contentDefault) - .textSelection(.enabled) - - Text("Permission requests happen only after you explicitly enable voice mode in Settings.") - .font(VFont.caption) - .foregroundColor(VColor.contentTertiary) - .textSelection(.enabled) - } - .frame(maxWidth: .infinity, alignment: .leading) - .padding(VSpacing.lg) - .background( - RoundedRectangle(cornerRadius: VRadius.md) - .fill(VColor.surfaceBase.opacity(0.3)) - .overlay( - RoundedRectangle(cornerRadius: VRadius.md) - .stroke(VColor.borderBase.opacity(0.4), lineWidth: 1) - ) - ) - .opacity(showContent ? 1 : 0) - - OnboardingButton(title: "Continue", style: .primary) { - state.advance() - } - .opacity(showContent ? 1 : 0) - } - .onAppear { - DispatchQueue.main.asyncAfter(deadline: .now() + 0.3) { - withAnimation(.easeOut(duration: 0.4)) { - showContent = true - } - } - } - } -} diff --git a/clients/macos/vellum-assistant/Features/Onboarding/TypewriterText.swift b/clients/macos/vellum-assistant/Features/Onboarding/TypewriterText.swift deleted file mode 100644 index 527b0b432b..0000000000 --- a/clients/macos/vellum-assistant/Features/Onboarding/TypewriterText.swift +++ /dev/null @@ -1,50 +0,0 @@ -import VellumAssistantShared -import SwiftUI - -struct TypewriterText: View { - let fullText: String - var speed: TimeInterval = 0.05 - var font: Font = VFont.onboardingTitle - var onComplete: (() -> Void)? = nil - - @State private var displayedText = "" - @State private var timer: Timer? - @State private var charIndex = 0 - - var body: some View { - ZStack { - // Invisible full text reserves the final height - Text(fullText) - .font(font) - .foregroundColor(.clear) - .fixedSize(horizontal: false, vertical: true) - .accessibilityHidden(true) - - Text(displayedText) - .font(font) - .foregroundColor(VColor.contentDefault) - .fixedSize(horizontal: false, vertical: true) - } - .onAppear { - startTyping() - } - .onDisappear { - timer?.invalidate() - } - } - - private func startTyping() { - displayedText = "" - charIndex = 0 - let characters = Array(fullText) - timer = Timer.scheduledTimer(withTimeInterval: speed, repeats: true) { t in - if charIndex < characters.count { - displayedText.append(characters[charIndex]) - charIndex += 1 - } else { - t.invalidate() - onComplete?() - } - } - } -} From dcda2d2ff97d5ff0e993a52e4e714c5ac51e85d7 Mon Sep 17 00:00:00 2001 From: Devin AI <158243242+devin-ai-integration[bot]@users.noreply.github.com> Date: Thu, 19 Mar 2026 01:13:13 +0000 Subject: [PATCH 5/5] Extract UserProfile struct to standalone file ProfileExtractor.swift (deleted as dead code) contained the UserProfile struct which MainWindowView still uses to decode the stored user profile from UserDefaults. Move the struct to its own file in MainWindow/. Co-Authored-By: ashlee@vellum.ai --- .../Features/MainWindow/UserProfile.swift | 14 ++++++++++++++ 1 file changed, 14 insertions(+) create mode 100644 clients/macos/vellum-assistant/Features/MainWindow/UserProfile.swift diff --git a/clients/macos/vellum-assistant/Features/MainWindow/UserProfile.swift b/clients/macos/vellum-assistant/Features/MainWindow/UserProfile.swift new file mode 100644 index 0000000000..c760e96d69 --- /dev/null +++ b/clients/macos/vellum-assistant/Features/MainWindow/UserProfile.swift @@ -0,0 +1,14 @@ +import Foundation + +/// Lightweight representation of the user profile stored in UserDefaults +/// under the `"user.profile"` key. Originally populated by the onboarding +/// interview's profile-extraction step. +struct UserProfile: Codable, Sendable { + let name: String? + let role: String? + let goals: [String]? + let painPoints: [String]? + let communicationStyle: String? + let interests: [String]? + let personality: String? +}