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 00000000000..c760e96d699 --- /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? +} 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 36141446d9a..00000000000 --- 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 6f416f4cecb..00000000000 --- 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 240a7b9bc25..00000000000 --- 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 632885d6ab0..00000000000 --- 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 13f6759233b..00000000000 --- 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 c2f01e79eb8..00000000000 --- 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 2810b1e4bd9..00000000000 --- 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 b105a02a50a..00000000000 --- 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 0643715501a..00000000000 --- 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 5870b0a0756..00000000000 --- 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 5380392f974..00000000000 --- 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 9b3a51ad9cf..00000000000 --- 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 fcef0c88bd7..00000000000 --- 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 4ad48c77030..00000000000 --- 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 8fb83060352..00000000000 --- 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 f43af6aaee9..a9aa8c34ecf 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 05661d251b8..19742718b24 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 31491784df4..00000000000 --- 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 f9de5412d98..00000000000 --- 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 f1a5ca40c5f..00000000000 --- 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 527b0b432b2..00000000000 --- 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?() - } - } - } -}