Skip to content
Merged
Show file tree
Hide file tree
Changes from 5 commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
3 changes: 3 additions & 0 deletions apps/linows/src-tauri/Cargo.lock

Some generated files are not rendered by default. Learn more about how customized files appear on GitHub.

Original file line number Diff line number Diff line change
Expand Up @@ -22,6 +22,11 @@ final class SyntheticRowTests: XCTestCase {
("\(AppConstants.Launcher.PrefixSuggestion.resultIDPrefix)f\"", "prefixSuggestion"),
("\(AppConstants.Launcher.Calc.resultIDPrefix)42", "calc"),
("\(AppConstants.Launcher.CommandSuggestion.resultIDPrefix)calc", "commandSuggestion"),
(
AppConstants.Launcher.Meeting.resultID(url: "https://meet.jit.si/standup"),
"meeting"
),
(AppConstants.Launcher.Call.resultID(url: "facetime-audio://+15551234567"), "call"),
]
for (id, expected) in cases {
XCTAssertEqual(name(of: SyntheticRow.classify(resultID: id)), expected, id)
Expand All @@ -43,6 +48,8 @@ final class SyntheticRowTests: XCTestCase {
case .commandSuggestion: "commandSuggestion"
case .webURL: "webURL"
case .calc: "calc"
case .meeting: "meeting"
case .call: "call"
case nil: "nil"
}
}
Expand Down
2 changes: 2 additions & 0 deletions apps/macos/LauncherApp/look-app.xcodeproj/project.pbxproj
Original file line number Diff line number Diff line change
Expand Up @@ -293,6 +293,7 @@
INFOPLIST_KEY_NSAppleEventsUsageDescription = "Look uses Finder to empty the Trash and read how many items it contains.";
INFOPLIST_KEY_NSBluetoothAlwaysUsageDescription = "Look reads and toggles Bluetooth power from the launcher's quick actions.";
INFOPLIST_KEY_NSCalendarsFullAccessUsageDescription = "Look creates and edits calendar events when you ask it to.";
INFOPLIST_KEY_NSContactsUsageDescription = "Look finds who to message or FaceTime when you type a name.";
INFOPLIST_KEY_NSRemindersFullAccessUsageDescription = "Look creates and completes reminders when you ask it to.";
INFOPLIST_KEY_LSUIElement = YES;
INFOPLIST_KEY_NSHumanReadableCopyright = "";
Expand Down Expand Up @@ -338,6 +339,7 @@
INFOPLIST_KEY_NSAppleEventsUsageDescription = "Look uses Finder to empty the Trash and read how many items it contains.";
INFOPLIST_KEY_NSBluetoothAlwaysUsageDescription = "Look reads and toggles Bluetooth power from the launcher's quick actions.";
INFOPLIST_KEY_NSCalendarsFullAccessUsageDescription = "Look creates and edits calendar events when you ask it to.";
INFOPLIST_KEY_NSContactsUsageDescription = "Look finds who to message or FaceTime when you type a name.";
INFOPLIST_KEY_NSRemindersFullAccessUsageDescription = "Look creates and completes reminders when you ask it to.";
INFOPLIST_KEY_LSUIElement = YES;
INFOPLIST_KEY_NSHumanReadableCopyright = "";
Expand Down
10 changes: 9 additions & 1 deletion apps/macos/LauncherApp/look-app/Models/LauncherResult.swift
Original file line number Diff line number Diff line change
Expand Up @@ -22,7 +22,10 @@ struct LauncherResult: Identifiable {
let title: String
let subtitle: String?
let path: String
let score: Int
/// `var`, not `let`: most rows are built with their final rank, but the
/// call rows order themselves after the fact. Its POSITION is load-bearing
/// - the memberwise initializer is called positionally all over the app.
var score: Int
var clipboardContent: String? = nil
var clipboardCapturedAt: Date? = nil
var clipboardCharacterCount: Int? = nil
Expand All @@ -38,4 +41,9 @@ struct LauncherResult: Identifiable {
/// grouped display value.
var calcExpression: String? = nil
var calcRawValue: String? = nil
/// Set on the synthetic rows that open a URL (a meeting to join, a way to
/// reach a person): what the preview shows without re-parsing the subtitle
/// it was written into. The URL itself rides in the result id.
var linkKindLabel: String? = nil
var linkDetail: String? = nil
}
Original file line number Diff line number Diff line change
@@ -0,0 +1,221 @@
import AppKit
import Foundation

/// The two tiers that end in "open a URL from a list": `join` a meeting and
/// `call` a person.
///
/// Split out of `ActionController` because they share a shape and nothing else
/// does: resolve a name against a platform store (EventKit, Contacts), settle
/// access first so "cannot see" never reads as "nothing found", and hand back
/// a `LinkPicker` for the user to choose from.
extension ActionController {
/// Puts the joinable meetings on screen for the user to pick from.
///
/// It LISTS rather than joining outright, even when there is only one
/// candidate: "join" resolves to a specific meeting at a specific time, and
/// opening it before the user has seen which one gives them no way to
/// notice it picked the wrong thing. Returns feedback only when there is
/// nothing to show.
func presentJoinChoices(named name: String?, didAsk: Bool = false) -> String {
// Without access the calendar reads as empty, which downstream is
// indistinguishable from "you have no meetings" - and saying that when
// Look simply cannot see them sends the user hunting for the wrong
// problem. Settle access FIRST, and say so in its own words.
switch EventKitService.shared.calendarAccess {
case .authorized:
break
case .notDetermined:
// Once only. `requestCalendarAccess` discards its error, so a
// failed request leaves the status unchanged - retrying on that
// would spawn a task that requests again, forever.
guard !didAsk else { return Self.noCalendarAccess }
// A TCC prompt is one-shot, so it belongs at the moment the user
// asked for something that needs it, not at launch.
Task {
await EventKitService.shared.requestCalendarAccess()
setFeedback(presentJoinChoices(named: name, didAsk: true))
}
return ""
// Write-only can add events but cannot READ them, so there is nothing
// to join with it either.
case .writeOnly, .denied, .restricted:
setLinkPicker(nil)
return Self.noCalendarAccess
}

let wanted = name ?? ""
let outcome = MeetingService.shared.outcome(name: wanted)
guard !outcome.meetings.isEmpty else {
setLinkPicker(nil)
return Self.nothingToJoin(wanted: wanted, withoutLink: outcome.withoutLink)
}
setLinkPicker(
LinkPicker(header: "Join which?", options: outcome.meetings.map(Self.option), selected: 0))
return ""
}

/// One meeting as a picker row. The detail line is what the row promises:
/// which service, when, and the host the link will actually open.
private static func option(_ meeting: JoinableMeeting) -> LinkOption {
var detail = [meeting.providerLabel, MeetingTiming.phrase(meeting)]
if let host = URL(string: meeting.url)?.host { detail.append(host) }
return LinkOption(
id: meeting.url,
title: meeting.title,
detail: detail.joined(separator: " · "),
symbol: "video.fill",
url: meeting.url)
}
Comment thread
coderabbitai[bot] marked this conversation as resolved.

/// Ways to reach the person the user named.
///
/// ALWAYS lists, even for a single option. An earlier version dialled
/// straight through when there was only one way to reach someone, on the
/// theory that confirming what you just typed is noise - but from the
/// user's side nothing appeared in the launcher and then FaceTime started
/// ringing a person. Placing a call is socially expensive and has no undo,
/// so the row the user reads IS the confirmation.
func presentCallChoices(named name: String, modality: String?, didAsk: Bool = false)
-> String
{
switch ContactsService.shared.access {
case .authorized:
break
case .notDetermined:
// Once only, for the same reason as the calendar branch above.
guard !didAsk else { return Self.noContactsAccess }
Task {
await ContactsService.shared.requestAccess()
setFeedback(presentCallChoices(named: name, modality: modality, didAsk: true))
}
return ""
case .writeOnly, .denied, .restricted:
setLinkPicker(nil)
return Self.noContactsAccess
}

// The verb said "call" but not how, so this is the house default. Kept
// out of core: which one is right depends on what the platform can do.
let wantedModality = modality ?? EngineBridge.shared.defaultCallModality
let matches = ContactsService.shared.matches(name: name)
let options: [LinkOption] = matches.flatMap { match in
match.handles
.filter { $0.modalityID == wantedModality }
.compactMap { handle in Self.option(match: match, handle: handle) }
}

guard !options.isEmpty else {
setLinkPicker(nil)
guard !matches.isEmpty else {
return "No contact matching \u{201C}\(name)\u{201D}."
}
// Found the person, but not a handle that does THIS. Naming the
// gap beats "no contact", which would send them looking for the
// wrong problem.
return "\u{201C}\(matches[0].name)\u{201D} has no number or address for that."
}
// Several people is a "who"; one person with several numbers is a
// "how". Both are the same list, but the question is not.
setLinkPicker(
LinkPicker(
header: matches.count > 1 ? "Reach who?" : "Reach how?",
options: options,
selected: 0))
return ""
}

/// One way to reach one person, as a picker row.
private static func option(match: ContactMatch, handle: ContactHandle) -> LinkOption? {
guard let url = EngineBridge.shared.callURL(modality: handle.modalityID, handle: handle.handle)
else { return nil }
var detail = [handle.modalityLabel]
if let label = handle.handleLabel, !label.isEmpty { detail.append(label) }
detail.append(handle.handle)
return LinkOption(
id: "\(match.id)|\(handle.id)",
title: match.name,
detail: detail.joined(separator: " · "),
symbol: handle.modalityID == "message" ? "message.fill" : "video.fill",
url: url)
}

private static let noContactsAccess =
"Look has no contacts access, so it cannot find who you mean. "
+ "Grant it in Settings (\u{2318}\u{21E7},) under Permissions."

/// Names the actual blocker and where to fix it. Not "no meetings": Look
/// has not looked.
private static let noCalendarAccess =
"Look has no calendar access, so it cannot see your meetings. "
+ "Grant it in Settings (\u{2318}\u{21E7},) under Permissions."

/// Why there was nothing to open. A meeting that IS on the calendar but
/// carries no conferencing link gets named: "no meeting matching Testing"
/// reads as "you have no such meeting", which is a different, wrong
/// problem to go looking for.
private static func nothingToJoin(wanted: String, withoutLink: [String]) -> String {
let where_ = "Add a Zoom, Teams, or Meet link to its URL, location, or notes."
switch withoutLink.count {
case 0:
guard !wanted.isEmpty else { return "No meeting to join in the next two days." }
return "No meeting matching \u{201C}\(wanted)\u{201D} in the next two days."
case 1:
return "\u{201C}\(withoutLink[0])\u{201D} has no meeting link. \(where_)"
default:
let named = withoutLink.map { "\u{201C}\($0)\u{201D}" }.joined(separator: ", ")
return "No join link on \(named). \(where_)"
}
}

/// Opens the highlighted row. No confirm step beyond this and no undo,
/// unlike the calendar tools: the row the user just read IS the
/// confirmation, and opening a link reverses nothing.
///
/// Returns whether it opened, so the caller can put the launcher away.
/// Success leaves NO feedback behind: the meeting or the call is on screen
/// by then, and a sticky "Joining ..." bar would sit on the panel forever
/// (it also blocks the sessions list, which reads feedback as "busy").
/// A failure is the one case worth leaving up to read.
@discardableResult
func openSelectedLink() -> Bool {
guard let picker = linkPicker, let option = picker.selectedOption else { return false }
// Cleared only on success: if the open fails, the list stays up so the
// user can try another row instead of retyping the whole request.
guard let url = URL(string: option.url), NSWorkspace.shared.open(url) else {
setFeedback("Could not open \(option.title).")
return false
}
setLinkPicker(nil)
setFeedback("")
return true
}

/// Tab / arrows roll the picker. Returns false when none is up, so the key
/// falls through to whatever else the panel is showing.
@discardableResult
func movePickerSelection(forward: Bool) -> Bool {
guard var picker = linkPicker, !picker.options.isEmpty else { return false }
let count = picker.options.count
picker.selected =
forward
? (picker.selected >= count - 1 ? 0 : picker.selected + 1)
: (picker.selected <= 0 ? count - 1 : picker.selected - 1)
setLinkPicker(picker)
return true
}

/// Moves the highlight to a 1-based position, matching what the rows show.
/// Returns false when the number names no row, so a typed "7" with three
/// rows listed stays an ordinary message rather than doing nothing.
@discardableResult
func selectPickerRow(number: Int) -> Bool {
guard var picker = linkPicker, number >= 1, number <= picker.options.count else {
return false
}
picker.selected = number - 1
setLinkPicker(picker)
return true
}

func clearPicker() { setLinkPicker(nil) }
}
Original file line number Diff line number Diff line change
Expand Up @@ -61,6 +61,33 @@ final class ActionController: ObservableObject {
let candidates: [ActionCandidate]
}

/// One openable thing in a picker: a meeting to join, a way to reach a
/// person. Everything the row needs, plus the URL that IS the action.
struct LinkOption: Identifiable, Equatable {
let id: String
let title: String
/// The line that says what this row will actually do.
let detail: String
let symbol: String
let url: String
}

/// A list of things to open, and which one the keyboard is on.
///
/// Shared by `join` and `call` because both end the same way: the user
/// picks a row and a URL opens. Its own type rather than `PendingChoice` -
/// that one carries a tool call to re-propose, and neither of these is a
/// tool.
struct LinkPicker: Equatable {
let header: String
var options: [LinkOption]
var selected: Int

var selectedOption: LinkOption? {
options.indices.contains(selected) ? options[selected] : nil
}
}

/// The file paths picked in the launcher, pushed by `LauncherView` as the
/// picks change (this is a singleton with no view context). Picks survive
/// the query changing to `>summarize`, which the row selection does not, so
Expand All @@ -82,6 +109,8 @@ final class ActionController: ObservableObject {
/// leave half of itself behind with no way back.
@Published private(set) var pendingSteps: [PlannedAction] = []
@Published private(set) var pendingChoice: PendingChoice?
/// The rows a `join` or `call` turned up, or nil when nothing is pending.
@Published private(set) var linkPicker: LinkPicker?
/// Receipts from the last confirmed plan, in the order they ran. Undo
/// reverses them back to front.
@Published private(set) var lastReceipts: [ActionReceipt] = []
Expand Down Expand Up @@ -111,6 +140,14 @@ final class ActionController: ObservableObject {
/// A leftover disambiguation must not survive into an unrelated turn.
func clearPendingChoice() { pendingChoice = nil }

/// Writers for the extensions that live in other files (see
/// `ActionController+Links.swift`). The properties stay `private(set)` so
/// nothing outside this controller can write them, and in Swift a private
/// setter is scoped to the FILE - an extension elsewhere cannot assign it,
/// only call through.
func setLinkPicker(_ picker: LinkPicker?) { linkPicker = picker }
func setFeedback(_ text: String) { feedback = text }

/// A listing the user just saw becomes the referent set: "remove this
/// event" right after "what's on this week?" targets what was listed.
func rememberListed(_ listing: ScheduleContextProvider.Listing) {
Expand Down Expand Up @@ -275,6 +312,14 @@ final class ActionController: ObservableObject {
isPlanning = false
pendingSteps = []
feedback = memoryFeedback
case .join(let name):
isPlanning = false
pendingSteps = []
feedback = presentJoinChoices(named: name)
case .call(let name, let modality):
isPlanning = false
pendingSteps = []
feedback = presentCallChoices(named: name, modality: modality)
case .textOp(let label, let instruction):
isPlanning = false
pendingSteps = []
Expand Down Expand Up @@ -687,6 +732,7 @@ final class ActionController: ObservableObject {
planGeneration += 1
pendingSteps = []
pendingChoice = nil
linkPicker = nil
feedback = ""
isPlanning = false
}
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -109,7 +109,7 @@ final class ChatSessionController: ObservableObject {
guard !items.isEmpty else { return }
ConversationStore.upsert(AIConversation(
id: conversationID,
title: String((items.first?.text ?? "Conversation").prefix(48)),
title: AIConversation.singleLine(items.first?.text ?? "Conversation"),
updatedAt: Date(),
items: items.map {
AIConversation.StoredItem(
Expand Down
Loading
Loading