Skip to content
Merged
Show file tree
Hide file tree
Changes from 1 commit
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
Original file line number Diff line number Diff line change
Expand Up @@ -22,6 +22,10 @@ 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"
),
]
for (id, expected) in cases {
XCTAssertEqual(name(of: SyntheticRow.classify(resultID: id)), expected, id)
Expand All @@ -43,6 +47,7 @@ final class SyntheticRowTests: XCTestCase {
case .commandSuggestion: "commandSuggestion"
case .webURL: "webURL"
case .calc: "calc"
case .meeting: "meeting"
case nil: "nil"
}
}
Expand Down
5 changes: 5 additions & 0 deletions apps/macos/LauncherApp/look-app/Models/LauncherResult.swift
Original file line number Diff line number Diff line change
Expand Up @@ -38,4 +38,9 @@ struct LauncherResult: Identifiable {
/// grouped display value.
var calcExpression: String? = nil
var calcRawValue: String? = nil
/// Set only for the synthetic meeting row: what the preview shows without
/// re-parsing the subtitle it was written into. The join URL itself rides
/// in the result id.
var meetingProviderLabel: String? = nil
var meetingWhen: String? = nil
}
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
41 changes: 41 additions & 0 deletions apps/macos/LauncherApp/look-app/Support/AppConstants.swift
Original file line number Diff line number Diff line change
Expand Up @@ -94,6 +94,30 @@ enum AppConstants {
}
}

/// The ⌘-digit chips on the AI sessions list. A ⌘ chord is ONE
/// keypress, so there is no ⌘10 and ten rows is the hard ceiling:
/// ⌘1…⌘9 then ⌘0 for the tenth. Older sessions are reached by typing
/// (the list filters on title and content), Tab/↑↓, then Enter.
enum AISessions {
/// Rows carrying a chip, and therefore how many the list shows.
static let jumpKeyLimit = 10
/// The tenth row wraps onto `0`, the key sitting next to `9`.
private static let lastRowDigit = 0

/// The digit shown on row `index`, or nil past the mapped rows.
static func jumpDigit(forRow index: Int) -> Int? {
guard index >= 0, index < jumpKeyLimit else { return nil }
return index == jumpKeyLimit - 1 ? lastRowDigit : index + 1
}

/// The row ⌘`digit` addresses, or nil when the digit maps to none.
static func row(forJumpDigit digit: Int) -> Int? {
if digit == lastRowDigit { return jumpKeyLimit - 1 }
guard digit > 0, digit < jumpKeyLimit else { return nil }
return digit - 1
}
}

enum QueryPrefix {
static let apps = "a\""
static let files = "f\""
Expand Down Expand Up @@ -203,6 +227,23 @@ enum AppConstants {
// Synthesized calculator row, pinned above everything else while the
// query is arithmetic (shared `core/calc` intent gate via EngineBridge).
// Like WebSuggestion/WebURL, told apart from real candidates by id.
/// The synthesized "Join <meeting>" row. Told apart from real
/// candidates by id; the join URL rides in it, so pressing Enter never
/// has to re-read the calendar.
enum Meeting {
static let resultIDPrefix = "meeting:"

static func resultID(url: String) -> String {
resultIDPrefix + url
}

/// Recovers the join URL encoded in a result id, or nil.
static func url(fromResultID resultID: String) -> String? {
guard resultID.hasPrefix(resultIDPrefix) else { return nil }
return String(resultID.dropFirst(resultIDPrefix.count))
}
}

enum Calc {
static let resultIDPrefix = "calc:"
static let enterToCopyHint = "Enter to copy"
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -186,6 +186,29 @@ nonisolated final class EventKitService: @unchecked Sendable {
}
}

/// Events in a window, flattened for the meeting core: the three fields a
/// join link hides in, plus what it takes to choose between them.
///
/// `refreshSourcesIfNecessary` first, because an invite that arrived
/// moments ago may not have synced down yet and "join my next meeting" is
/// asked precisely when a meeting is about to start. It is a hint, not a
/// blocking fetch, so it costs nothing when the store is already current.
func meetingEventPayloads(from: Date, to: Date) -> [MeetingEventPayload] {
guard calendarAccess == .authorized else { return [] }
store.refreshSourcesIfNecessary()
let predicate = store.predicateForEvents(withStart: from, end: to, calendars: nil)
return store.events(matching: predicate).map { event in
MeetingEventPayload(
title: event.title ?? "Untitled",
startUnixS: Int64(event.startDate.timeIntervalSince1970),
endUnixS: Int64(event.endDate.timeIntervalSince1970),
url: event.url?.absoluteString,
location: event.location,
notes: event.notes,
allDay: event.isAllDay)
}
}

/// Event cache mirroring the reminder cache, so the per-keystroke `@` mutate
/// preview resolves without a live EventKit fetch each stroke. Warmed while
/// composing (throttled) and forced on `.EKEventStoreChanged`. Main-thread
Expand Down
111 changes: 111 additions & 0 deletions apps/macos/LauncherApp/look-app/Support/Calendar/MeetingService.swift
Original file line number Diff line number Diff line change
@@ -0,0 +1,111 @@
import AppKit
import Foundation

/// One event, flattened to exactly the fields the Rust core needs to find a
/// join link and pick between meetings. Keys match `look_ai::meeting::EventInput`.
nonisolated struct MeetingEventPayload: Encodable {
let title: String
let startUnixS: Int64
let endUnixS: Int64
let url: String?
let location: String?
let notes: String?
let allDay: Bool
}

/// The meeting to join, as decided in core. Mirrors
/// `look_ai::meeting::JoinableMeeting`.
nonisolated struct JoinableMeeting: Decodable, Equatable {
let title: String
let startUnixS: Int64
let endUnixS: Int64
let url: String
/// Provider id (`teams`, `zoom`, `meet`, ...). `providerLabel` is what to show.
let provider: String
let providerLabel: String
/// Negative once the meeting has started.
let startsInS: Int64
let inProgress: Bool

var startDate: Date { Date(timeIntervalSince1970: TimeInterval(startUnixS)) }
var joinURL: URL? { URL(string: url) }

/// Rounded up, so a meeting 61 seconds out reads "in 2 min" rather than
/// "in 1 min" for most of the minute it is counting down.
var minutesUntilStart: Int {
Int((Double(startsInS) / 60.0).rounded(.up))
}
}

/// "Join my next meeting": read the calendar, let core find the link, open it.
///
/// Look makes no network call here. A Teams, Zoom, or Meet invite already
/// carries its join URL, and the account sync that put it there is the OS's
/// job (see docs/ai-eventkit-connector.md).
nonisolated final class MeetingService: @unchecked Sendable {
static let shared = MeetingService()

private enum Metrics {
/// How far ahead to look for something to join. Long enough to answer
/// "my next meeting" during a quiet morning, short enough that the
/// fetch stays cheap on the launcher's open path.
static let lookahead: TimeInterval = 12 * 60 * 60
/// A meeting that started a while ago is still joinable, so the window
/// opens slightly behind now. Core drops anything already ended.
static let lookbehind: TimeInterval = -60 * 60
/// Mirrors the event cache in `EventKitService`. The join row is a
/// COMPUTED property of the launcher view, so SwiftUI re-reads it on
/// every update; without this, one keystroke would mean several
/// EventKit fetches.
static let cacheTTL: TimeInterval = 5
}

private let lock = NSLock()
private var cached: JoinableMeeting?
private var cachedAt = Date.distantPast

private init() {}

/// The meeting to join right now, or nil when there is nothing to join.
/// Cached for `Metrics.cacheTTL`; the countdown in the subtitle is derived
/// from the meeting's own start time, so a cached answer is not a stale one.
func nextMeeting(now: Date = Date()) -> JoinableMeeting? {
lock.lock()
defer { lock.unlock() }
if now.timeIntervalSince(cachedAt) < Metrics.cacheTTL {
return cached
}
cachedAt = now
cached = fetchNextMeeting(now: now)
return cached
}

/// Drop the cache, for when the calendar changed under us.
func invalidate() {
lock.lock()
defer { lock.unlock() }
cachedAt = .distantPast
cached = nil
}

private func fetchNextMeeting(now: Date) -> JoinableMeeting? {
let events = EventKitService.shared.meetingEventPayloads(
from: now.addingTimeInterval(Metrics.lookbehind),
to: now.addingTimeInterval(Metrics.lookahead))
guard !events.isEmpty else { return nil }
guard let json = try? JSONEncoder().encode(events),
let jsonString = String(data: json, encoding: .utf8)
else { return nil }
return EngineBridge.shared.nextJoinableMeeting(
eventsJSON: jsonString, now: Int64(now.timeIntervalSince1970))
}

/// Opens the join link. The https form is deliberate: it reaches the
/// desktop app through universal links when installed, and the browser when
/// not, where `msteams:` / `zoommtg:` would fail silently.
@discardableResult
func join(_ meeting: JoinableMeeting) -> Bool {
guard let url = meeting.joinURL else { return false }
return NSWorkspace.shared.open(url)
}
}
Original file line number Diff line number Diff line change
Expand Up @@ -108,6 +108,14 @@ private func look_ai_parse_explicit(_ input: UnsafePointer<CChar>?, _ modelAvail
nonisolated
private func look_ai_route(_ memoryPath: UnsafePointer<CChar>?, _ input: UnsafePointer<CChar>?, _ modelAvailable: Bool, _ now: Int64) -> UnsafeMutablePointer<CChar>?

@_silgen_name("look_meeting_is_join_query")
nonisolated
private func look_meeting_is_join_query(_ query: UnsafePointer<CChar>?) -> Bool

@_silgen_name("look_meeting_next_json")
nonisolated
private func look_meeting_next_json(_ eventsJSON: UnsafePointer<CChar>?, _ now: Int64) -> UnsafeMutablePointer<CChar>?

@_silgen_name("look_ai_chat_start")
nonisolated
private func look_ai_chat_start(_ host: UnsafePointer<CChar>?, _ model: UnsafePointer<CChar>?, _ messagesJSON: UnsafePointer<CChar>?, _ optionsJSON: UnsafePointer<CChar>?) -> UInt64
Expand Down Expand Up @@ -374,6 +382,27 @@ final class EngineBridge: @unchecked Sendable {
let relaxed: String?
}

/// Whether the typed text is asking to join a meeting. Pure string work in
/// core, so it is safe on the per-keystroke path.
nonisolated func isJoinQuery(_ query: String) -> Bool {
query.withCString { look_meeting_is_join_query($0) }
}

/// The meeting to join out of `eventsJSON`, or nil when none of them is an
/// online meeting. Which event wins, and where the join link hides inside
/// it, are decided in core (`look_ai::meeting`) so every shell agrees.
nonisolated func nextJoinableMeeting(eventsJSON: String, now: Int64) -> JoinableMeeting? {
guard let ptr = eventsJSON.withCString({ look_meeting_next_json($0, now) }) else {
return nil
}
defer { look_free_cstring(ptr) }
guard let data = String(cString: ptr).data(using: .utf8) else { return nil }
// Core answers the JSON literal `null` when nothing is joinable, and
// decoding that into a non-optional throws, which `try?` turns into the
// nil this returns anyway.
return try? JSONDecoder().decode(JoinableMeeting.self, from: data)
}

/// Natural-language file recall over Look's own index. Returns nil when the
/// query is not a file-recall query (so the caller does normal search).
nonisolated func searchFiles(query: String, limit: Int = 40) -> FileRecallOutcome? {
Expand Down
Loading
Loading