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
16 changes: 8 additions & 8 deletions Loom.xcodeproj/project.pbxproj
Original file line number Diff line number Diff line change
Expand Up @@ -1176,7 +1176,7 @@
"CODE_SIGN_IDENTITY[sdk=macosx*]" = "Apple Development";
CODE_SIGN_STYLE = Automatic;
COMBINE_HIDPI_IMAGES = YES;
CURRENT_PROJECT_VERSION = 6;
CURRENT_PROJECT_VERSION = 7;
DEAD_CODE_STRIPPING = YES;
DEVELOPMENT_TEAM = K9UNF256S9;
ENABLE_HARDENED_RUNTIME = NO;
Expand All @@ -1194,7 +1194,7 @@
"$(inherited)",
"@executable_path/../Frameworks",
);
MARKETING_VERSION = 2.1.0;
MARKETING_VERSION = 2.2.0;
PRODUCT_BUNDLE_IDENTIFIER = com.chaiwithcode.loom;
PRODUCT_NAME = Loom;
PROVISIONING_PROFILE_SPECIFIER = "";
Expand All @@ -1210,7 +1210,7 @@
"CODE_SIGN_IDENTITY[sdk=macosx*]" = "Apple Development";
CODE_SIGN_STYLE = Automatic;
COMBINE_HIDPI_IMAGES = YES;
CURRENT_PROJECT_VERSION = 6;
CURRENT_PROJECT_VERSION = 7;
DEAD_CODE_STRIPPING = YES;
DEVELOPMENT_TEAM = K9UNF256S9;
ENABLE_HARDENED_RUNTIME = YES;
Expand All @@ -1228,7 +1228,7 @@
"$(inherited)",
"@executable_path/../Frameworks",
);
MARKETING_VERSION = 2.1.0;
MARKETING_VERSION = 2.2.0;
PRODUCT_BUNDLE_IDENTIFIER = com.chaiwithcode.loom;
PRODUCT_NAME = Loom;
PROVISIONING_PROFILE_SPECIFIER = "";
Expand Down Expand Up @@ -1328,7 +1328,7 @@
"CODE_SIGN_IDENTITY[sdk=macosx*]" = "Apple Development";
CODE_SIGN_STYLE = Automatic;
COMBINE_HIDPI_IMAGES = YES;
CURRENT_PROJECT_VERSION = 6;
CURRENT_PROJECT_VERSION = 7;
DEAD_CODE_STRIPPING = YES;
DEVELOPMENT_TEAM = K9UNF256S9;
ENABLE_HARDENED_RUNTIME = NO;
Expand All @@ -1344,7 +1344,7 @@
"@executable_path/../Frameworks",
"@executable_path/../../../../Frameworks",
);
MARKETING_VERSION = 2.1.0;
MARKETING_VERSION = 2.2.0;
PRODUCT_BUNDLE_IDENTIFIER = com.chaiwithcode.loom.widget;
PRODUCT_NAME = LoomWidgetExtension;
SKIP_INSTALL = YES;
Expand All @@ -1361,7 +1361,7 @@
"CODE_SIGN_IDENTITY[sdk=macosx*]" = "Apple Development";
CODE_SIGN_STYLE = Automatic;
COMBINE_HIDPI_IMAGES = YES;
CURRENT_PROJECT_VERSION = 6;
CURRENT_PROJECT_VERSION = 7;
DEAD_CODE_STRIPPING = YES;
DEVELOPMENT_TEAM = K9UNF256S9;
ENABLE_HARDENED_RUNTIME = YES;
Expand All @@ -1377,7 +1377,7 @@
"@executable_path/../Frameworks",
"@executable_path/../../../../Frameworks",
);
MARKETING_VERSION = 2.1.0;
MARKETING_VERSION = 2.2.0;
PRODUCT_BUNDLE_IDENTIFIER = com.chaiwithcode.loom.widget;
PRODUCT_NAME = LoomWidgetExtension;
SKIP_INSTALL = YES;
Expand Down
88 changes: 86 additions & 2 deletions Loom/App/NotificationCoordinator.swift
Original file line number Diff line number Diff line change
Expand Up @@ -52,6 +52,10 @@ public final class NotificationCoordinator: NSObject, UNUserNotificationCenterDe

public static let categoryIdentifier = "LOOM_NOTIFICATION_CATEGORY"
public static let openActionIdentifier = "LOOM_ACTION_VIEW"
public static let gitHubStarCategoryIdentifier = "LOOM_GITHUB_STAR_CATEGORY"
public static let starOnGitHubActionIdentifier = "STAR_ON_GITHUB_ACTION"
public static let dismissStarActionIdentifier = "DISMISS_STAR_ACTION"
public static let gitHubStarReminderIdentifier = "com.loom.github-star-reminder"
public static let deepLinkNotification = Notification.Name("com.loom.notificationDeepLink")

public private(set) var lastDeepLinkPayload: DeepLinkPayload?
Expand Down Expand Up @@ -79,6 +83,7 @@ public final class NotificationCoordinator: NSObject, UNUserNotificationCenterDe
self.transitionDetector = transitionDetector
self.policy = NotificationPolicy()
super.init()
setupCategories()
}

public func configure(settings: LoomSettings, store: SessionStore? = nil) {
Expand Down Expand Up @@ -116,9 +121,12 @@ public final class NotificationCoordinator: NSObject, UNUserNotificationCenterDe
// LoomApp owns provider enablement so it can update monitoring.
// Notification policy reads enabled providers before each evaluation.

setupCategories()

if let store {
connect(to: store)
}
checkAndScheduleGitHubStarReminder()
}

public func connect(to store: SessionStore) {
Expand All @@ -132,13 +140,69 @@ public final class NotificationCoordinator: NSObject, UNUserNotificationCenterDe
title: "View in Loom",
options: [.foreground]
)
let category = UNNotificationCategory(
let mainCategory = UNNotificationCategory(
identifier: Self.categoryIdentifier,
actions: [viewAction],
intentIdentifiers: [],
options: [.customDismissAction]
)
center.setNotificationCategories([category])

let starAction = UNNotificationAction(
identifier: Self.starOnGitHubActionIdentifier,
title: "Star on GitHub",
options: [.foreground],
icon: UNNotificationActionIcon(systemImageName: "star.fill")
)
let laterAction = UNNotificationAction(
identifier: Self.dismissStarActionIdentifier,
title: "Maybe Later",
options: [],
icon: UNNotificationActionIcon(systemImageName: "clock")
)
let starCategory = UNNotificationCategory(
identifier: Self.gitHubStarCategoryIdentifier,
actions: [starAction, laterAction],
intentIdentifiers: [],
options: [.customDismissAction]
)

center.setNotificationCategories([mainCategory, starCategory])
}

public func checkAndScheduleGitHubStarReminder() {
guard let settings else { return }
guard !settings.hasSentGitHubStarNotification else { return }
let hasAvailableProviders = store?.enabledSnapshots.isEmpty == false
guard settings.isEligibleForGitHubStarPrompt(hasAvailableProviders: hasAvailableProviders) else { return }

settings.hasSentGitHubStarNotification = true

Task { @MainActor in
setupCategories()
let status = await checkAuthorizationStatus()
guard status == .authorized || status == .provisional else { return }

let content = UNMutableNotificationContent()
content.title = "Support Loom on GitHub ⭐"
content.subtitle = "github.com/chaiwithcode/Loom"
content.body = "Star the repository to help other macOS developers discover Loom."
content.categoryIdentifier = Self.gitHubStarCategoryIdentifier
content.sound = nil
content.interruptionLevel = .passive
content.threadIdentifier = "com.loom.community"
content.userInfo = [
NotificationPayloadKey.targetTab: "settings",
NotificationPayloadKey.eventType: "github-star-reminder"
]

let request = UNNotificationRequest(
identifier: Self.gitHubStarReminderIdentifier,
content: content,
trigger: nil
)

try? await center.add(request)
}
}

public func checkAuthorizationStatus() async -> NotificationAuthorizationStatus {
Expand Down Expand Up @@ -332,6 +396,7 @@ public final class NotificationCoordinator: NSObject, UNUserNotificationCenterDe
}

public func processStoreUpdate() {
checkAndScheduleGitHubStarReminder()
guard let store, !store.fixture.isDemo, let settings, settings.notificationsEnabled else { return }
updatePolicyFromSettings()

Expand Down Expand Up @@ -433,6 +498,25 @@ public final class NotificationCoordinator: NSObject, UNUserNotificationCenterDe
didReceive response: UNNotificationResponse,
withCompletionHandler completionHandler: @escaping () -> Void
) {
let request = response.notification.request
let categoryID = request.content.categoryIdentifier
let actionID = response.actionIdentifier

if categoryID == Self.gitHubStarCategoryIdentifier || request.identifier == Self.gitHubStarReminderIdentifier {
Task { @MainActor in
if actionID == Self.starOnGitHubActionIdentifier || actionID == UNNotificationDefaultActionIdentifier {
if let url = LoomSettings.gitHubRepoURL as URL? {
NSWorkspace.shared.open(url)
}
self.settings?.markGitHubStarCompleted()
} else if actionID == Self.dismissStarActionIdentifier || actionID == UNNotificationDismissActionIdentifier {
self.settings?.dismissGitHubStarPrompt()
}
completionHandler()
}
return
}

let userInfo = response.notification.request.content.userInfo
let targetTab = userInfo[NotificationPayloadKey.targetTab] as? String ?? "sessions"
let providerRaw = userInfo[NotificationPayloadKey.providerID] as? String
Expand Down
12 changes: 11 additions & 1 deletion Loom/Core/Discovery/SharedCLIDiscoveryCache.swift
Original file line number Diff line number Diff line change
Expand Up @@ -98,7 +98,17 @@ public actor SharedCLIDiscoveryCache {
let mappedState = mapState(status.state)

let rawProjectName = status.projectName.trimmingCharacters(in: .whitespacesAndNewlines)
let projectName = (rawProjectName.isEmpty || rawProjectName == "/") ? "\(providerID.displayName) Session" : rawProjectName
let homePath = FileManager.default.homeDirectoryForCurrentUser.standardizedFileURL.path
let isHomeDir = status.workspacePath.map { URL(fileURLWithPath: $0).standardizedFileURL.path == homePath } ?? false
let projectName: String = {
if isHomeDir || rawProjectName == "Home (~)" {
return "Home (~)"
}
if rawProjectName.isEmpty || rawProjectName == "/" {
return "\(providerID.displayName) Session"
}
return rawProjectName
}()

let safeWorkspacePath: String? = {
guard let path = status.workspacePath, !path.isEmpty, path != "/" else { return nil }
Expand Down
2 changes: 1 addition & 1 deletion Loom/Core/Models/AppVersion.swift
Original file line number Diff line number Diff line change
Expand Up @@ -13,7 +13,7 @@ public struct AppVersion: Equatable, Hashable, Sendable {
public let buildVersion: String?
public let isDevelopmentBuild: Bool

public static let fallbackMarketingVersion = "2.1.0"
public static let fallbackMarketingVersion = "2.2.0"

public init(
marketingVersion: String,
Expand Down
24 changes: 24 additions & 0 deletions Loom/Core/Models/LoomSettings.swift
Original file line number Diff line number Diff line change
Expand Up @@ -178,6 +178,8 @@ public final class LoomSettings {
public nonisolated static let defaultMenuBarStatusMode: MenuBarStatusMode = .iconOnly
public nonisolated static let hasDismissedGitHubStarPromptKey = "com.loom.hasDismissedGitHubStarPrompt"
public nonisolated static let defaultHasDismissedGitHubStarPrompt: Bool = false
public nonisolated static let hasSentGitHubStarNotificationKey = "com.loom.hasSentGitHubStarNotification"
public nonisolated static let defaultHasSentGitHubStarNotification: Bool = false
public nonisolated static let firstLaunchDateKey = "com.loom.firstLaunchDate"
public nonisolated static let distinctObservedSessionIDsKey = "com.loom.distinctObservedSessionIDs"
public nonisolated static let gitHubRepoURL = URL(string: "https://github.com/chaiwithcode/Loom")!
Expand Down Expand Up @@ -332,6 +334,12 @@ public final class LoomSettings {
}
}

public var hasSentGitHubStarNotification: Bool {
didSet {
defaults.set(hasSentGitHubStarNotification, forKey: Self.hasSentGitHubStarNotificationKey)
}
}

public var firstLaunchDate: Date {
didSet {
defaults.set(firstLaunchDate, forKey: Self.firstLaunchDateKey)
Expand Down Expand Up @@ -573,6 +581,13 @@ public final class LoomSettings {
self.hasDismissedGitHubStarPrompt = Self.defaultHasDismissedGitHubStarPrompt
}

// hasSentGitHubStarNotification: validate boolean type, default false
if let num = defaults.object(forKey: Self.hasSentGitHubStarNotificationKey) as? NSNumber {
self.hasSentGitHubStarNotification = num.boolValue
} else {
self.hasSentGitHubStarNotification = Self.defaultHasSentGitHubStarNotification
}

// firstLaunchDate: retrieve stored date or record current date
if let storedDate = defaults.object(forKey: Self.firstLaunchDateKey) as? Date {
self.firstLaunchDate = storedDate
Expand Down Expand Up @@ -694,6 +709,7 @@ public final class LoomSettings {
minimumWorkingDuration = Self.defaultMinimumWorkingDuration
menuBarStatusMode = Self.defaultMenuBarStatusMode
hasDismissedGitHubStarPrompt = Self.defaultHasDismissedGitHubStarPrompt
hasSentGitHubStarNotification = Self.defaultHasSentGitHubStarNotification
}

public func moveProvider(fromOffsets source: IndexSet, toOffset destination: Int) {
Expand Down Expand Up @@ -721,6 +737,10 @@ public final class LoomSettings {
}
}

public var shouldShowGitHubStarInvitation: Bool {
isEligibleForGitHubStarPrompt()
}

public func isEligibleForGitHubStarPrompt(hasAvailableProviders: Bool = true, now: Date = Date()) -> Bool {
#if DEBUG
if ProcessInfo.processInfo.arguments.contains("--force-github-star-card") ||
Expand All @@ -740,6 +760,10 @@ public final class LoomSettings {
hasDismissedGitHubStarPrompt = true
}

public func markGitHubStarCompleted() {
hasDismissedGitHubStarPrompt = true
}

public func persistToSharedAppGroup(appGroup: SharedAppGroup = .shared) {
guard let containerURL = appGroup.containerURL else { return }
let settingsURL = containerURL.appendingPathComponent("settings.json", isDirectory: false)
Expand Down
40 changes: 38 additions & 2 deletions Loom/Providers/Antigravity/AntigravityCLIDetector.swift
Original file line number Diff line number Diff line change
Expand Up @@ -155,11 +155,31 @@ public final class AntigravityCLIDetector: Sendable {
var matchedUUIDs = Set<String>()

// 2. Pair running `agy` processes with the most recently modified transcript
let homePath = FileManager.default.homeDirectoryForCurrentUser.standardizedFileURL.path
let recentThreshold = Date().addingTimeInterval(-300) // 5 minutes

for (index, proc) in liveAgyProcs.enumerated() {
let parentT3 = ProcessTreeScanner.findAncestor(of: proc.pid, in: processTable, matching: t3RootPIDs)
let surface: AgentSurface = parentT3 != nil ? .t3Orchestrated : .cli

let transcriptEntry = index < recentTranscripts.count ? recentTranscripts[index] : nil
let normalizedCwd = proc.cwd.map { URL(fileURLWithPath: $0).standardizedFileURL.path }

// Filter out background agy helper processes whose cwd == "/" or cwd == homePath if they have no matching transcript
if transcriptEntry == nil && (normalizedCwd == nil || normalizedCwd == "/" || normalizedCwd == homePath) {
continue
}

// Even if transcriptEntry exists, if cwd == homePath or "/", require the transcript to have been modified recently
if let entry = transcriptEntry {
if normalizedCwd == "/" {
continue
}
if normalizedCwd == homePath && entry.mtime < recentThreshold {
continue
}
}

let uuid = transcriptEntry?.uuid ?? "agy-pid-\(proc.pid)"

if let entry = transcriptEntry {
Expand All @@ -170,7 +190,15 @@ public final class AntigravityCLIDetector: Sendable {
}

let resolvedCWD = proc.cwd ?? cwdCache.withLock { $0[uuid] }
let projectName = resolvedCWD.map { URL(fileURLWithPath: $0).lastPathComponent } ?? "agy"
let projectName: String = {
guard let path = resolvedCWD else { return "agy" }
let homePath = FileManager.default.homeDirectoryForCurrentUser.standardizedFileURL.path
if URL(fileURLWithPath: path).standardizedFileURL.path == homePath {
return "Home (~)"
}
let rawName = URL(fileURLWithPath: path).lastPathComponent
return (rawName.isEmpty || rawName == "/") ? "agy" : rawName
}()

var state: AgentRunState = .idle
var lastUpdated = Date()
Expand Down Expand Up @@ -212,7 +240,15 @@ public final class AntigravityCLIDetector: Sendable {
for entry in recentTranscripts where !matchedUUIDs.contains(entry.uuid) {
guard Calendar.current.isDateInToday(entry.mtime) else { continue }
let cachedCWD = cwdCache.withLock { $0[entry.uuid] }
let projectName = cachedCWD.map { URL(fileURLWithPath: $0).lastPathComponent } ?? "agy-session"
let projectName: String = {
guard let path = cachedCWD else { return "agy-session" }
let homePath = FileManager.default.homeDirectoryForCurrentUser.standardizedFileURL.path
if URL(fileURLWithPath: path).standardizedFileURL.path == homePath {
return "Home (~)"
}
let rawName = URL(fileURLWithPath: path).lastPathComponent
return (rawName.isEmpty || rawName == "/") ? "agy-session" : rawName
}()

results.append(
AgentSessionStatus(
Expand Down
9 changes: 9 additions & 0 deletions Loom/Providers/Antigravity/AntigravityProvider.swift
Original file line number Diff line number Diff line change
Expand Up @@ -381,6 +381,15 @@ public final class AntigravityProvider: AgentProvider, Sendable {
merged.append(cli)
}
}
merged = merged.filter { session in
let isBareUnknown = session.state == .unknown
&& session.projectPath == nil
&& (session.providerSessionID?.trimmingCharacters(in: .whitespacesAndNewlines).isEmpty ?? true)
&& session.discoveryEvidence.contains(where: {
$0.contains("bare process without worker subprocesses or session metadata")
})
return !isBareUnknown
}
return merged
}
}
Loading
Loading