Skip to content
Open
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
18 changes: 18 additions & 0 deletions ClaudeIsland.xcodeproj/project.pbxproj
Original file line number Diff line number Diff line change
Expand Up @@ -10,6 +10,7 @@
FD0F42E72EE7ABA400980302 /* Mixpanel in Frameworks */ = {isa = PBXBuildFile; productRef = FD0F42E62EE7ABA400980302 /* Mixpanel */; };
FDA49F7D2EE11E3C00F9612E /* Markdown in Frameworks */ = {isa = PBXBuildFile; productRef = FD33FD722EDF32D7002A6548 /* Markdown */; };
FDA49F7E2EE11E3C00F9612E /* Sparkle in Frameworks */ = {isa = PBXBuildFile; productRef = FDA49F7F2EE11E3C00F9612E /* Sparkle */; };
FDOCCLUS012EEF0A0000001 /* OcclusionKit in Frameworks */ = {isa = PBXBuildFile; productRef = FDOCCLUS022EEF0A0000002 /* OcclusionKit */; };
/* End PBXBuildFile section */

/* Begin PBXFileReference section */
Expand Down Expand Up @@ -45,6 +46,7 @@
FD0F42E72EE7ABA400980302 /* Mixpanel in Frameworks */,
FDA49F7D2EE11E3C00F9612E /* Markdown in Frameworks */,
FDA49F7E2EE11E3C00F9612E /* Sparkle in Frameworks */,
FDOCCLUS012EEF0A0000001 /* OcclusionKit in Frameworks */,
);
runOnlyForDeploymentPostprocessing = 0;
};
Expand Down Expand Up @@ -90,6 +92,7 @@
FD33FD722EDF32D7002A6548 /* Markdown */,
FDA49F7F2EE11E3C00F9612E /* Sparkle */,
FD0F42E62EE7ABA400980302 /* Mixpanel */,
FDOCCLUS022EEF0A0000002 /* OcclusionKit */,
);
productName = ClaudeIsland;
productReference = FD33FD5D2EDF32D7002A6548 /* Claude Island.app */;
Expand Down Expand Up @@ -123,6 +126,7 @@
FD33FD712EDF32D7002A6548 /* XCRemoteSwiftPackageReference "swift-markdown" */,
FDA49F802EE11E3C00F9612E /* XCRemoteSwiftPackageReference "Sparkle" */,
FD0F42E52EE7ABA400980302 /* XCRemoteSwiftPackageReference "mixpanel-swift" */,
FDOCCLUS032EEF0A0000003 /* XCLocalSwiftPackageReference "OcclusionKit" */,
);
preferredProjectObjectVersion = 77;
productRefGroup = FD33FD5E2EDF32D7002A6548 /* Products */;
Expand Down Expand Up @@ -393,8 +397,17 @@
minimumVersion = 2.0.0;
};
};
FDOCCLUS032EEF0A0000003 /* XCRemoteSwiftPackageReference "OcclusionKit" */ = {
isa = XCRemoteSwiftPackageReference;
repositoryURL = "https://github.com/akdoan1/OcclusionKit";
requirement = {
branch = main;
kind = branch;
};
};
/* End XCRemoteSwiftPackageReference section */


/* Begin XCSwiftPackageProductDependency section */
FD0F42E62EE7ABA400980302 /* Mixpanel */ = {
isa = XCSwiftPackageProductDependency;
Expand All @@ -411,6 +424,11 @@
package = FDA49F802EE11E3C00F9612E /* XCRemoteSwiftPackageReference "Sparkle" */;
productName = Sparkle;
};
FDOCCLUS022EEF0A0000002 /* OcclusionKit */ = {
isa = XCSwiftPackageProductDependency;
package = FDOCCLUS032EEF0A0000003 /* XCRemoteSwiftPackageReference "OcclusionKit" */;
productName = OcclusionKit;
};
/* End XCSwiftPackageProductDependency section */
};
rootObject = FD33FD552EDF32D7002A6548 /* Project object */;
Expand Down
7 changes: 6 additions & 1 deletion ClaudeIsland/Core/NotchViewModel.swift
Original file line number Diff line number Diff line change
Expand Up @@ -50,6 +50,7 @@ class NotchViewModel: ObservableObject {

private let screenSelector = ScreenSelector.shared
private let soundSelector = SoundSelector.shared
private let suppressionSelector = SuppressionSelector.shared

// MARK: - Geometry

Expand All @@ -74,7 +75,7 @@ class NotchViewModel: ObservableObject {
// Compact size for settings menu
return CGSize(
width: min(screenRect.width * 0.4, 480),
height: 420 + screenSelector.expandedPickerHeight + soundSelector.expandedPickerHeight
height: 420 + screenSelector.expandedPickerHeight + soundSelector.expandedPickerHeight + suppressionSelector.expandedPickerHeight
)
case .instances:
return CGSize(
Expand Down Expand Up @@ -117,6 +118,10 @@ class NotchViewModel: ObservableObject {
soundSelector.$isPickerExpanded
.sink { [weak self] _ in self?.objectWillChange.send() }
.store(in: &cancellables)

suppressionSelector.$isPickerExpanded
.sink { [weak self] _ in self?.objectWillChange.send() }
.store(in: &cancellables)
}

// MARK: - Event Handling
Expand Down
32 changes: 32 additions & 0 deletions ClaudeIsland/Core/Settings.swift
Original file line number Diff line number Diff line change
Expand Up @@ -31,13 +31,29 @@ enum NotificationSound: String, CaseIterable {
}
}

/// Sound suppression modes
enum SoundSuppression: String, CaseIterable {
case never = "Never"
case whenFocused = "When Focused"
case whenVisible = "When Visible"

var description: String {
switch self {
case .never: return "Always play sounds"
case .whenFocused: return "Suppress when app or session is active"
case .whenVisible: return "Suppress when app or session is visible"
}
}
}

enum AppSettings {
private static let defaults = UserDefaults.standard

// MARK: - Keys

private enum Keys {
static let notificationSound = "notificationSound"
static let soundSuppression = "soundSuppression"
}

// MARK: - Notification Sound
Expand All @@ -55,4 +71,20 @@ enum AppSettings {
defaults.set(newValue.rawValue, forKey: Keys.notificationSound)
}
}

// MARK: - Sound Suppression

/// When to suppress notification sounds based on app visibility/focus
static var soundSuppression: SoundSuppression {
get {
guard let rawValue = defaults.string(forKey: Keys.soundSuppression),
let value = SoundSuppression(rawValue: rawValue) else {
return .whenFocused // Default
}
return value
}
set {
defaults.set(newValue.rawValue, forKey: Keys.soundSuppression)
}
}
}
36 changes: 36 additions & 0 deletions ClaudeIsland/Core/SuppressionSelector.swift
Original file line number Diff line number Diff line change
@@ -0,0 +1,36 @@
//
// SuppressionSelector.swift
// ClaudeIsland
//
// Manages sound suppression selection state for the settings menu
//

import Combine
import Foundation

@MainActor
class SuppressionSelector: ObservableObject {
static let shared = SuppressionSelector()

// MARK: - Published State

@Published var isPickerExpanded: Bool = false

// MARK: - Constants

/// Number of suppression options (Never, When Focused, When Visible)
private let optionCount = 3

/// Height per option row (taller for descriptions)
private let rowHeight: CGFloat = 44

private init() {}

// MARK: - Public API

/// Extra height needed when picker is expanded
var expandedPickerHeight: CGFloat {
guard isPickerExpanded else { return 0 }
return CGFloat(optionCount) * rowHeight + 8 // +8 for padding
}
}
133 changes: 133 additions & 0 deletions ClaudeIsland/UI/Components/SuppressionPickerRow.swift
Original file line number Diff line number Diff line change
@@ -0,0 +1,133 @@
//
// SuppressionPickerRow.swift
// ClaudeIsland
//
// Sound suppression mode selection picker for settings menu
//

import SwiftUI

struct SuppressionPickerRow: View {
@ObservedObject var suppressionSelector: SuppressionSelector
@State private var isHovered = false
@State private var selectedSuppression: SoundSuppression = AppSettings.soundSuppression

private var isExpanded: Bool {
suppressionSelector.isPickerExpanded
}

private func setExpanded(_ value: Bool) {
suppressionSelector.isPickerExpanded = value
}

var body: some View {
VStack(spacing: 0) {
// Main row - shows current selection
Button {
withAnimation(.easeInOut(duration: 0.2)) {
setExpanded(!isExpanded)
}
} label: {
HStack(spacing: 10) {
Image(systemName: "bell.slash")
.font(.system(size: 12))
.foregroundColor(textColor)
.frame(width: 16)

Text("Sound Suppression")
.font(.system(size: 13, weight: .medium))
.foregroundColor(textColor)

Spacer()

Text(selectedSuppression.rawValue)
.font(.system(size: 11))
.foregroundColor(.white.opacity(0.4))
.lineLimit(1)

Image(systemName: isExpanded ? "chevron.up" : "chevron.down")
.font(.system(size: 10))
.foregroundColor(.white.opacity(0.4))
}
.padding(.horizontal, 12)
.padding(.vertical, 10)
.background(
RoundedRectangle(cornerRadius: 8)
.fill(isHovered ? Color.white.opacity(0.08) : Color.clear)
)
}
.buttonStyle(.plain)
.onHover { isHovered = $0 }

// Expanded suppression list
if isExpanded {
VStack(spacing: 2) {
ForEach(SoundSuppression.allCases, id: \.self) { suppression in
SuppressionOptionRow(
suppression: suppression,
isSelected: selectedSuppression == suppression
) {
selectedSuppression = suppression
AppSettings.soundSuppression = suppression
}
}
}
.padding(.leading, 28)
.padding(.top, 4)
}
}
.onAppear {
selectedSuppression = AppSettings.soundSuppression
}
}

private var textColor: Color {
.white.opacity(isHovered ? 1.0 : 0.7)
}
}

// MARK: - Suppression Option Row

private struct SuppressionOptionRow: View {
let suppression: SoundSuppression
let isSelected: Bool
let action: () -> Void

@State private var isHovered = false

var body: some View {
Button(action: action) {
HStack(spacing: 8) {
Circle()
.fill(isSelected ? TerminalColors.green : Color.white.opacity(0.2))
.frame(width: 6, height: 6)

VStack(alignment: .leading, spacing: 2) {
Text(suppression.rawValue)
.font(.system(size: 12, weight: .medium))
.foregroundColor(.white.opacity(isHovered ? 1.0 : 0.7))

Text(suppression.description)
.font(.system(size: 10))
.foregroundColor(.white.opacity(0.4))
}

Spacer()

if isSelected {
Image(systemName: "checkmark")
.font(.system(size: 10, weight: .bold))
.foregroundColor(TerminalColors.green)
}
}
.padding(.horizontal, 10)
.padding(.vertical, 8)
.background(
RoundedRectangle(cornerRadius: 6)
.fill(isHovered ? Color.white.opacity(0.06) : Color.clear)
)
}
.buttonStyle(.plain)
.onHover { isHovered = $0 }
}
}
2 changes: 2 additions & 0 deletions ClaudeIsland/UI/Views/NotchMenuView.swift
Original file line number Diff line number Diff line change
Expand Up @@ -18,6 +18,7 @@ struct NotchMenuView: View {
@ObservedObject private var updateManager = UpdateManager.shared
@ObservedObject private var screenSelector = ScreenSelector.shared
@ObservedObject private var soundSelector = SoundSelector.shared
@ObservedObject private var suppressionSelector = SuppressionSelector.shared
@State private var hooksInstalled: Bool = false
@State private var launchAtLogin: Bool = false

Expand All @@ -38,6 +39,7 @@ struct NotchMenuView: View {
// Appearance settings
ScreenPickerRow(screenSelector: screenSelector)
SoundPickerRow(soundSelector: soundSelector)
SuppressionPickerRow(suppressionSelector: suppressionSelector)

Divider()
.background(Color.white.opacity(0.08))
Expand Down
54 changes: 41 additions & 13 deletions ClaudeIsland/UI/Views/NotchView.swift
Original file line number Diff line number Diff line change
Expand Up @@ -419,10 +419,31 @@ struct NotchView: View {
let currentIds = Set(sessions.map { $0.stableId })
let newPendingIds = currentIds.subtracting(previousPendingIds)

if !newPendingIds.isEmpty &&
viewModel.status == .closed &&
!TerminalVisibilityDetector.isTerminalVisibleOnCurrentSpace() {
viewModel.notchOpen(reason: .notification)
if !newPendingIds.isEmpty && viewModel.status == .closed {
// Get the new pending sessions and check if any of their terminals are NOT visible
let newPendingSessions = sessions.filter { newPendingIds.contains($0.stableId) }

// Check visibility asynchronously
Task {
var anySessionTerminalNotVisible = false
for session in newPendingSessions {
guard let pid = session.pid else {
anySessionTerminalNotVisible = true
break
}
let isVisible = await TerminalVisibilityDetector.isSessionTerminalVisible(sessionPid: pid)
if !isVisible {
anySessionTerminalNotVisible = true
break
}
}

if anySessionTerminalNotVisible {
await MainActor.run {
viewModel.notchOpen(reason: .notification)
}
}
}
}

previousPendingIds = currentIds
Expand Down Expand Up @@ -484,18 +505,25 @@ struct NotchView: View {
}

/// Determine if notification sound should play for the given sessions
/// Returns true if ANY session is not actively focused
/// Returns true if sound should play, false if suppressed
private func shouldPlayNotificationSound(for sessions: [SessionState]) async -> Bool {
let mode = AppSettings.soundSuppression

// Never suppress - always play
if mode == .never { return true }

// Suppress if Claude Island is active (menu/chat open)
if NSApplication.shared.isActive { return false }

// Check each triggering session - play if ANY is not visible/focused
for session in sessions {
guard let pid = session.pid else {
// No PID means we can't check focus, assume not focused
return true
}
guard let pid = session.pid else { return true }

let isFocused = await TerminalVisibilityDetector.isSessionFocused(sessionPid: pid)
if !isFocused {
return true
}
let shouldSuppress = mode == .whenVisible
? await TerminalVisibilityDetector.isSessionTerminalVisible(sessionPid: pid)
: await TerminalVisibilityDetector.isSessionFocused(sessionPid: pid)

if !shouldSuppress { return true }
}

return false
Expand Down
Loading