Skip to content

Commit 5befff2

Browse files
committed
Merge onboarding trust flow improvements
2 parents 2f0a41c + 7151dfc commit 5befff2

File tree

6 files changed

+342
-38
lines changed

6 files changed

+342
-38
lines changed

desktop/Desktop/Sources/AuthService.swift

Lines changed: 43 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -49,6 +49,10 @@ class AuthService {
4949
return "\(urlScheme)://auth/callback"
5050
}
5151

52+
private var currentBundleIdentifier: String {
53+
Bundle.main.bundleIdentifier ?? "unknown.bundle"
54+
}
55+
5256
private var urlScheme: String {
5357
if let urlTypes = Bundle.main.infoDictionary?["CFBundleURLTypes"] as? [[String: Any]],
5458
let firstType = urlTypes.first,
@@ -503,6 +507,16 @@ class AuthService {
503507
let state = queryItems.first(where: { $0.name == "state" })?.value
504508
let error = queryItems.first(where: { $0.name == "error" })?.value
505509

510+
if let state, let targetBundleId = targetBundleIdentifier(from: state), targetBundleId != currentBundleIdentifier {
511+
NSLog(
512+
"OMI AUTH: Callback is for bundle %@, current bundle is %@. Forwarding...",
513+
targetBundleId,
514+
currentBundleIdentifier
515+
)
516+
forwardOAuthCallback(url: url, toBundleId: targetBundleId)
517+
return
518+
}
519+
506520
if let error = error {
507521
NSLog("OMI AUTH: OAuth error: %@", error)
508522
oauthContinuation?.resume(throwing: AuthError.oauthError(error))
@@ -1052,10 +1066,38 @@ class AuthService {
10521066
private func generateState() -> String {
10531067
var bytes = [UInt8](repeating: 0, count: 32)
10541068
_ = SecRandomCopyBytes(kSecRandomDefault, bytes.count, &bytes)
1055-
return Data(bytes).base64EncodedString()
1069+
let nonce = Data(bytes).base64EncodedString()
10561070
.replacingOccurrences(of: "+", with: "-")
10571071
.replacingOccurrences(of: "/", with: "_")
10581072
.replacingOccurrences(of: "=", with: "")
1073+
// Encode source bundle in state so callbacks can be routed back to the
1074+
// originating app, even when multiple dev builds share URL schemes.
1075+
return "\(nonce)|\(currentBundleIdentifier)"
1076+
}
1077+
1078+
private func targetBundleIdentifier(from state: String) -> String? {
1079+
let parts = state.split(separator: "|", maxSplits: 1).map(String.init)
1080+
guard parts.count == 2 else { return nil }
1081+
let bundleId = parts[1].trimmingCharacters(in: .whitespacesAndNewlines)
1082+
return bundleId.isEmpty ? nil : bundleId
1083+
}
1084+
1085+
private func forwardOAuthCallback(url: URL, toBundleId bundleId: String) {
1086+
guard let appURL = NSWorkspace.shared.urlForApplication(withBundleIdentifier: bundleId) else {
1087+
NSLog("OMI AUTH: Unable to forward callback. Bundle %@ not found.", bundleId)
1088+
return
1089+
}
1090+
1091+
let config = NSWorkspace.OpenConfiguration()
1092+
config.activates = true
1093+
1094+
NSWorkspace.shared.open([url], withApplicationAt: appURL, configuration: config) { _, error in
1095+
if let error {
1096+
NSLog("OMI AUTH: Failed to forward callback to %@: %@", bundleId, error.localizedDescription)
1097+
} else {
1098+
NSLog("OMI AUTH: Forwarded callback to %@", bundleId)
1099+
}
1100+
}
10591101
}
10601102

10611103
// MARK: - Native Apple Sign In Helpers

desktop/Desktop/Sources/Chat/ChatPrompts.swift

Lines changed: 19 additions & 13 deletions
Original file line numberDiff line numberDiff line change
@@ -668,7 +668,7 @@ struct ChatPrompts {
668668
Say hi to {user_given_name} and confirm the name. Example: "Hey {user_given_name}! That's what I should call you, right?"
669669
Use `ask_followup` with options like ["Yes!", "Call me something else"].
670670
If they want a different name, ask what they prefer and call `set_user_preferences(name: "...")`.
671-
If confirmed, say: "Nice to meet you {name}! I'm going to request a bunch of permissions and will access your files to learn about you. I won't function well if you don't grant it.\n\nYou can trust me — I'm fully open-source, transparent and secure. All your data belongs to you and encrypted!"
671+
If confirmed, say: "Nice to meet you {name}! omi protects your data: open-source, encrypted, and you own everything."
672672
Then call `save_knowledge_graph` with just the user's name as a person node. This seeds the live graph with their name at the center.
673673
674674
STEP 1.5 — LANGUAGE PREFERENCE
@@ -696,14 +696,14 @@ struct ChatPrompts {
696696
Share 1-2 specific observations connecting web research + file findings (1 sentence each), then END your message with an explicit question.
697697
CRITICAL: Your message text MUST end with a question mark. Don't just state observations — ASK the user something.
698698
Bad: "I see screenpipe repos, RAG workshops, and VS Code extensions."
699-
Good: "I see screenpipe repos, RAG workshops, and VS Code extensions. What are you mainly working on right now?"
699+
Good: "I see screenpipe repos, RAG workshops, and VS Code extensions. What's your top goal right now?"
700700
Then call `ask_followup` with 2-4 quick-reply options that are meaningful answers to YOUR question.
701-
- If they appear to have a job/company: ask about their current focus, with specific options based on discoveries.
702-
- If no job info: ask what they mainly use their computer for, with general options.
703-
Example: ask_followup(question: "What are you mainly working on right now?", options: ["Building [product]", "Design + frontend", "Something else"])
704-
The user can also type their own answer in the input field — you don't need to add a "Something else" option.
701+
- Ask for ONE top monthly goal, not project names.
702+
- Offer 3 options based on discovered context plus one typed option.
703+
Example: ask_followup(question: "What's your top one goal this month?", options: ["Ship [specific project]", "Improve [specific skill/workflow]", "I'll type my own"])
704+
The options should be inferred from their files/web context, not generic.
705705
WAIT for the user to reply (click a button or type).
706-
After the user replies, call `save_knowledge_graph` with any new context from their response.
706+
After the user replies, call `save_knowledge_graph` with the chosen goal as a concept node connected to the user.
707707
708708
STEP 5 — PRIVACY NOTE + PERMISSIONS
709709
Before asking for any permissions, send a trust-building message about data ownership. Example:
@@ -720,12 +720,17 @@ struct ChatPrompts {
720720
- Give a 1-sentence concrete explanation of what Omi does with that permission (max 20 words).
721721
- Then RE-ASK the same permission with `ask_followup` again: ["Grant [Permission Name]", "Skip"].
722722
- Do NOT move to the next permission — stay on this one until the user grants or skips.
723-
Here's what each permission does:
724-
- **Microphone**: Transcribes your meetings and calls so Omi can give real-time advice and summaries.
725-
- **Notifications**: Sends proactive tips and reminders based on what you're working on.
726-
- **Accessibility**: Reads UI elements on screen so Omi understands which app and context you're in.
727-
- **Automation**: Controls apps (like AppleScript) to take actions on your behalf when you ask.
728-
- **Screen Recording**: Captures screen content so Omi can see what you're looking at and help contextually.
723+
Keep permission explanations ultra-short and plain, with no technical jargon:
724+
- **Microphone**: "I need this to summarize your meetings."
725+
- **Notifications**: "I need this to proactively help you during the day."
726+
- **Accessibility**: "I need this to understand which app you're using."
727+
- **Automation**: "I need this to take actions for you when asked."
728+
- **Screen Recording**: "I need this to understand what you're working on."
729+
- **Files scan**: "I need this to learn your work context and be more helpful."
730+
731+
IMPORTANT for notifications:
732+
- Before requesting notification permission, confirm the app is in Applications.
733+
- If not in Applications, ask the user to move omi to Applications first, then retry.
729734
730735
Order: microphone → notifications → accessibility → automation → screen_recording (last, needs restart).
731736
Skip already-granted permissions. If user clicks "Skip": say "No worries" and move to the next one. NEVER nag.
@@ -820,6 +825,7 @@ struct ChatPrompts {
820825
- Use first name sparingly (not every message)
821826
- React authentically to discoveries
822827
- Don't explain what Omi does — let them discover it naturally
828+
- NEVER show technical details to users (no SQL, file paths, command lines, JSON, or tool names).
823829
"""
824830

825831
// MARK: - Onboarding Exploration (Parallel Background Session)

desktop/Desktop/Sources/OnboardingChatView.swift

Lines changed: 124 additions & 14 deletions
Original file line numberDiff line numberDiff line change
@@ -92,6 +92,8 @@ struct OnboardingChatView: View {
9292
@State private var hasStarted: Bool = false
9393
@State private var onboardingCompleted: Bool = false
9494
@State private var quickReplyOptions: [String] = []
95+
@State private var awaitingGoalInput: Bool = false
96+
@State private var createdGoalTitles: Set<String> = []
9597
@State private var isGrantingPermission: Bool = false
9698
@State private var pendingPermissionType: String? = nil // e.g. "microphone" — waiting for user to grant
9799
@FocusState private var isInputFocused: Bool
@@ -111,9 +113,19 @@ struct OnboardingChatView: View {
111113
VStack(spacing: 0) {
112114
// Header
113115
HStack {
114-
Text("Setting up omi")
115-
.font(.system(size: 18, weight: .semibold))
116-
.foregroundColor(OmiColors.textPrimary)
116+
if let logoImage = whiteTemplateLogoImage() {
117+
Image(nsImage: logoImage)
118+
.resizable()
119+
.renderingMode(.template)
120+
.foregroundColor(.white)
121+
.scaledToFit()
122+
.frame(width: 52, height: 18)
123+
.accessibilityLabel("omi")
124+
} else {
125+
Text("omi")
126+
.font(.system(size: 18, weight: .semibold))
127+
.foregroundColor(.white)
128+
}
117129

118130
Spacer()
119131

@@ -141,12 +153,15 @@ struct OnboardingChatView: View {
141153
ScrollView {
142154
VStack(spacing: 16) {
143155
ForEach(chatProvider.messages) { message in
144-
OnboardingChatBubble(message: message)
156+
OnboardingChatBubble(
157+
message: message,
158+
hidePermissionImages: !quickReplyOptions.isEmpty || pendingPermissionType != nil || awaitingGoalInput
159+
)
145160
.id(message.id)
146161
}
147162

148163
// Parallel exploration card (appears after scan_files)
149-
if explorationRunning || (explorationCompleted && !explorationText.isEmpty) {
164+
if !shouldHideExplorationCard && (explorationRunning || (explorationCompleted && !explorationText.isEmpty)) {
150165
ExplorationProfileCard(
151166
text: explorationText,
152167
isRunning: explorationRunning,
@@ -448,6 +463,12 @@ struct OnboardingChatView: View {
448463
}
449464
ChatToolExecutor.onQuickReplyOptions = { options in
450465
quickReplyOptions = options
466+
if options.contains(where: { isTypeYourOwnOption($0) }) {
467+
awaitingGoalInput = true
468+
}
469+
}
470+
ChatToolExecutor.onQuickReplyQuestion = { question in
471+
awaitingGoalInput = isGoalPriorityQuestion(question)
451472
}
452473
ChatToolExecutor.onKnowledgeGraphUpdated = { [weak graphViewModel] in
453474
guard let vm = graphViewModel else { return }
@@ -545,6 +566,9 @@ struct OnboardingChatView: View {
545566
ChatToolExecutor.resumeFollowup(with: text)
546567

547568
Task {
569+
if awaitingGoalInput {
570+
await maybeCreateGoal(from: text, source: "typed")
571+
}
548572
await chatProvider.sendMessage(text)
549573
}
550574
}
@@ -583,6 +607,8 @@ struct OnboardingChatView: View {
583607
if result.contains("granted") {
584608
// Granted immediately — tell the AI
585609
await chatProvider.sendMessage("\(option) — done!")
610+
} else if result.contains("move omi to /Applications first") {
611+
await chatProvider.sendMessage("Move omi to Applications, reopen it, then tap Grant Notifications again.")
586612
} else {
587613
// Pending — wait silently for the permission check timer to detect it
588614
// The onChange handlers for appState.has*Permission will send the message
@@ -591,13 +617,93 @@ struct OnboardingChatView: View {
591617
}
592618
} else {
593619
// Regular quick reply — resume the blocked ask_followup tool, then send as message
620+
let shouldCreateFromSelection = awaitingGoalInput && !isTypeYourOwnOption(option)
621+
if shouldCreateFromSelection {
622+
awaitingGoalInput = false
623+
} else if isTypeYourOwnOption(option) {
624+
awaitingGoalInput = true
625+
}
594626
ChatToolExecutor.resumeFollowup(with: option)
595627
Task {
628+
if shouldCreateFromSelection {
629+
await maybeCreateGoal(from: option, source: "selected")
630+
}
596631
await chatProvider.sendMessage(option)
597632
}
598633
}
599634
}
600635

636+
private var shouldHideExplorationCard: Bool {
637+
!quickReplyOptions.isEmpty || pendingPermissionType != nil || awaitingGoalInput
638+
}
639+
640+
private func whiteTemplateLogoImage() -> NSImage? {
641+
guard
642+
let logoURL = Bundle.resourceBundle.url(forResource: "omi_text_logo", withExtension: "png"),
643+
let loadedLogoImage = NSImage(contentsOf: logoURL)
644+
else {
645+
return nil
646+
}
647+
let logoImage = loadedLogoImage.copy() as? NSImage ?? loadedLogoImage
648+
logoImage.isTemplate = true
649+
return logoImage
650+
}
651+
652+
private func isGoalPriorityQuestion(_ text: String) -> Bool {
653+
let lower = text.lowercased()
654+
return lower.contains("top one goal")
655+
|| lower.contains("top goal")
656+
|| lower.contains("top priority")
657+
|| lower.contains("priority right now")
658+
}
659+
660+
private func isTypeYourOwnOption(_ text: String) -> Bool {
661+
let lower = text.lowercased()
662+
return lower.contains("type my own") || lower.contains("i'll type my own") || lower.contains("i’ll type my own")
663+
}
664+
665+
private func normalizedGoalTitle(_ text: String) -> String {
666+
text
667+
.trimmingCharacters(in: .whitespacesAndNewlines)
668+
.replacingOccurrences(of: "\n", with: " ")
669+
}
670+
671+
private func shouldSkipGoalCreation(_ title: String) -> Bool {
672+
if title.isEmpty {
673+
return true
674+
}
675+
let lower = title.lowercased()
676+
if lower == "skip" || lower == "done" || lower.contains("type my own") {
677+
return true
678+
}
679+
return false
680+
}
681+
682+
private func maybeCreateGoal(from rawText: String, source: String) async {
683+
let title = normalizedGoalTitle(rawText)
684+
guard !shouldSkipGoalCreation(title) else { return }
685+
686+
let dedupeKey = title.lowercased()
687+
guard !createdGoalTitles.contains(dedupeKey) else { return }
688+
689+
do {
690+
let goal = try await APIClient.shared.createGoal(
691+
title: title,
692+
description: "Added from onboarding",
693+
goalType: .boolean,
694+
targetValue: 1,
695+
currentValue: 0,
696+
source: "onboarding_\(source)"
697+
)
698+
_ = try? await GoalStorage.shared.syncServerGoal(goal)
699+
createdGoalTitles.insert(dedupeKey)
700+
awaitingGoalInput = false
701+
log("OnboardingChat: Created goal from onboarding input: \(title)")
702+
} catch {
703+
logError("OnboardingChat: Failed to create goal from onboarding input", error: error)
704+
}
705+
}
706+
601707
private func handleOnboardingComplete() {
602708
log("OnboardingChatView: Chat step complete, advancing to next onboarding step")
603709

@@ -860,6 +966,7 @@ struct OnboardingChatView: View {
860966

861967
struct OnboardingChatBubble: View {
862968
let message: ChatMessage
969+
var hidePermissionImages: Bool = false
863970

864971
/// Whether this AI message has any visible content (non-empty text or visible tool calls)
865972
private var hasVisibleContent: Bool {
@@ -930,7 +1037,12 @@ struct OnboardingChatBubble: View {
9301037
ForEach(message.contentBlocks) { block in
9311038
switch block {
9321039
case .toolCall(_, let name, let status, _, let input, _):
933-
let indicator = OnboardingToolIndicator(toolName: name, status: status, input: input)
1040+
let indicator = OnboardingToolIndicator(
1041+
toolName: name,
1042+
status: status,
1043+
input: input,
1044+
hidePermissionImage: hidePermissionImages
1045+
)
9341046
if !indicator.isHidden {
9351047
indicator
9361048
}
@@ -976,6 +1088,7 @@ struct OnboardingToolIndicator: View {
9761088
let toolName: String
9771089
let status: ToolCallStatus
9781090
var input: ToolCallInput? = nil
1091+
var hidePermissionImage: Bool = false
9791092

9801093
var body: some View {
9811094
VStack(alignment: .leading, spacing: 8) {
@@ -995,7 +1108,7 @@ struct OnboardingToolIndicator: View {
9951108
}
9961109

9971110
// Show permission guide image automatically for scan_files and request_permission
998-
if let permImage = permissionImageType {
1111+
if !hidePermissionImage, let permImage = permissionImageType {
9991112
OnboardingPermissionImage(permissionType: permImage)
10001113
}
10011114
}
@@ -1005,6 +1118,7 @@ struct OnboardingToolIndicator: View {
10051118
/// Whether this tool should be hidden from the UI (e.g. ask_followup renders its own UI)
10061119
var isHidden: Bool {
10071120
cleanToolName == "ask_followup"
1121+
|| cleanToolName == "save_knowledge_graph"
10081122
}
10091123

10101124
/// Strip MCP prefix from tool name (e.g. "mcp__omi-tools__scan_files" → "scan_files")
@@ -1040,17 +1154,13 @@ struct OnboardingToolIndicator: View {
10401154
case "complete_onboarding":
10411155
return status == .running ? "Finishing setup..." : "Setup complete"
10421156
case "save_knowledge_graph":
1043-
return status == .running ? "Building knowledge graph..." : "Knowledge graph saved"
1157+
return status == .running ? "Updating your profile..." : "Profile updated"
10441158
default:
1045-
if toolName.hasPrefix("WebSearch:") {
1046-
let query = String(toolName.dropFirst("WebSearch: ".count)).trimmingCharacters(in: CharacterSet(charactersIn: "\""))
1047-
return status == .running ? "Searching: \(query)" : "Searched: \(query)"
1048-
}
10491159
if toolName == "WebSearch" || toolName.contains("search") || toolName.contains("web") {
1050-
return status == .running ? "Searching the web..." : "Web search complete"
1160+
return status == .running ? "Learning more about you..." : "Learned more about you"
10511161
}
10521162
if toolName.hasPrefix("WebFetch:") || toolName == "WebFetch" {
1053-
return status == .running ? "Reading webpage..." : "Webpage read"
1163+
return status == .running ? "Reading context..." : "Context updated"
10541164
}
10551165
return status == .running ? "Working..." : "Done"
10561166
}

0 commit comments

Comments
 (0)