Skip to content
Merged
Show file tree
Hide file tree
Changes from all 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,187 @@
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 {
/// Joinable meetings for the user to pick from. Returns feedback only when
/// there is nothing to show.
func presentJoinChoices(named name: String?, didAsk: Bool = false) -> String {
// No access reads downstream as "no meetings", so settle it first.
switch EventKitService.shared.calendarAccess {
case .authorized:
break
case .notDetermined:
// Once only: a failed request leaves the status unchanged, and
// retrying on that loops.
guard !didAsk else { return Self.noCalendarAccess }
Task {
await EventKitService.shared.requestCalendarAccess()
setFeedback(presentJoinChoices(named: name, didAsk: true))
}
return ""
// Write-only cannot read events.
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.
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(
// Not the URL alone: a personal room and a recurring series
// repeat it, and duplicate ids break row identity.
id: "\(meeting.startUnixS)|\(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 one
/// option: a call has no undo, so the row the user reads is the confirm.
func presentCallChoices(named name: String, modality: String?, didAsk: Bool = false)
-> String
{
switch ContactsService.shared.access {
case .authorized:
break
case .notDetermined:
// Once only: see the calendar branch.
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 did not say how.
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 no handle for this modality.
return "\u{201C}\(matches[0].name)\u{201D} has no number or address for that."
}

setLinkPicker(
LinkPicker(
header: matches.count > 1 ? "Reach who?" : "Reach how?",
options: options,
selected: 0))
return ""
}

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."

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 with no link is named, since
/// that is a different problem from having no such meeting.
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. Returns whether it opened, so the caller can
/// hide the launcher. Success leaves no feedback: a sticky bar would block
/// the sessions list, which reads feedback as "busy".
@discardableResult
func openSelectedLink() -> Bool {
guard let picker = linkPicker, let option = picker.selectedOption else { return false }
// Cleared only on success, so a failed open leaves the list to retry.
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. False when none is up, so the key falls
/// through.
@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. False when the number names
/// no row, so it stays an ordinary message.
@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
Original file line number Diff line number Diff line change
Expand Up @@ -14,6 +14,23 @@ struct AIConversation: Codable, Identifiable {
var title: String
var updatedAt: Date
var items: [StoredItem]

/// How much of the first message becomes the title.
static let titleLimit = 48

/// One line, whitespace collapsed. A title is drawn in a list row and in the
/// delete banner, and the message it comes from can carry newlines (pasted
/// text, or Shift+Enter in the composer) - which render as a stack of short
/// rows rather than one line. Applied when the title is MADE and again when
/// it is DRAWN, so conversations stored before this stay tidy too.
static func singleLine(_ text: String, limit: Int = titleLimit) -> String {
String(text.split(whereSeparator: \.isWhitespace).joined(separator: " ").prefix(limit))
}

/// The title as one line, for any surface that draws it.
func displayTitle(limit: Int = AIConversation.titleLimit) -> String {
AIConversation.singleLine(title, limit: limit)
}
}

/// Thin shell over the Rust-core conversation store (core/ai), which owns the
Expand Down
Loading
Loading