Skip to content

Commit f3c9c14

Browse files
authored
feat(agent): add actionable task notifications (#1322)
1 parent a3669ee commit f3c9c14

14 files changed

Lines changed: 1571 additions & 46 deletions

Pine/AccessibilityIdentifiers.swift

Lines changed: 3 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -12,6 +12,9 @@ nonisolated enum AccessibilityID {
1212
static let agentHandoffReadOnlyContextToggle =
1313
"agentHandoffReadOnlyContextToggle"
1414
static let agentHandoffStatus = "agentHandoffStatus"
15+
static let agentNotificationSettings = "agentNotificationSettings"
16+
static let agentNotificationEnableButton = "agentNotificationEnableButton"
17+
static let agentNotificationMainToggle = "agentNotificationMainToggle"
1518
static let generalFontSizeSlider = "generalFontSizeSlider"
1619
static let generalSettingsPane = "generalSettingsPane"
1720
static let keyBindingsSettingsPane = "keyBindingsSettingsPane"

Pine/Agent/AgentHandoffSettingsView.swift

Lines changed: 209 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -6,11 +6,14 @@
66
//
77

88
import SwiftUI
9+
import AppKit
910

1011
@MainActor
1112
struct PineSettingsView: View {
1213
let lspSettings: LSPSettings
1314
let handoffSettings: AgentHandoffSettings
15+
let notificationController: AgentNotificationController
16+
let agentTasks: AgentTaskRegistry
1417
let shellSettings: ShellSettings
1518
let editorSettings: EditorSettings
1619
let terminalThemeSettings: TerminalThemeSettings
@@ -22,13 +25,17 @@ struct PineSettingsView: View {
2225
init(
2326
lspSettings: LSPSettings,
2427
handoffSettings: AgentHandoffSettings,
28+
notificationController: AgentNotificationController,
29+
agentTasks: AgentTaskRegistry,
2530
shellSettings: ShellSettings,
2631
editorSettings: EditorSettings,
2732
terminalThemeSettings: TerminalThemeSettings = .shared,
2833
quickTerminalSettings: QuickTerminalSettings = .shared
2934
) {
3035
self.lspSettings = lspSettings
3136
self.handoffSettings = handoffSettings
37+
self.notificationController = notificationController
38+
self.agentTasks = agentTasks
3239
self.shellSettings = shellSettings
3340
self.editorSettings = editorSettings
3441
self.terminalThemeSettings = terminalThemeSettings
@@ -65,7 +72,11 @@ struct PineSettingsView: View {
6572
}
6673
.tag(SettingsPane.SettingsPaneID.languages)
6774

68-
AgentHandoffSettingsView(settings: handoffSettings)
75+
AgentSettingsView(
76+
handoffSettings: handoffSettings,
77+
notificationController: notificationController,
78+
agentTasks: agentTasks
79+
)
6980
.tabItem {
7081
Label(
7182
Strings.settingsAgentHandoffTab,
@@ -89,6 +100,203 @@ struct PineSettingsView: View {
89100
nonisolated static let selectedPaneKey = "settings.selectedPane"
90101
}
91102

103+
@MainActor
104+
private struct AgentSettingsView: View {
105+
let handoffSettings: AgentHandoffSettings
106+
let notificationController: AgentNotificationController
107+
let agentTasks: AgentTaskRegistry
108+
109+
var body: some View {
110+
ScrollView {
111+
VStack(alignment: .leading, spacing: 22) {
112+
AgentNotificationSettingsView(
113+
controller: notificationController,
114+
agentTasks: agentTasks
115+
)
116+
Divider()
117+
AgentHandoffSettingsView(settings: handoffSettings)
118+
.frame(height: 380)
119+
}
120+
.padding(20)
121+
}
122+
.frame(width: 720, height: 500)
123+
}
124+
}
125+
126+
@MainActor
127+
private struct AgentNotificationSettingsView: View {
128+
let controller: AgentNotificationController
129+
let agentTasks: AgentTaskRegistry
130+
131+
private var agentOptions: [(String, String)] {
132+
let values = agentTasks.tasks.map {
133+
($0.descriptor.typeIdentifier, $0.descriptor.agentType.displayName)
134+
}
135+
return Dictionary(values, uniquingKeysWith: { current, _ in current })
136+
.sorted { $0.value.localizedStandardCompare($1.value) == .orderedAscending }
137+
}
138+
139+
private var projectOptions: [(String, String)] {
140+
let values = agentTasks.tasks.map {
141+
($0.project.canonicalProjectPath, URL(fileURLWithPath: $0.project.canonicalProjectPath).lastPathComponent)
142+
}
143+
return Dictionary(values, uniquingKeysWith: { current, _ in current })
144+
.sorted { $0.value.localizedStandardCompare($1.value) == .orderedAscending }
145+
}
146+
147+
private var taskOptions: [(UUID, String)] {
148+
agentTasks.tasks
149+
.filter { $0.lifecycle != .dismissed }
150+
.map { task in
151+
let fallback = task.descriptor.agentType.displayName
152+
return (task.id, task.title ?? fallback)
153+
}
154+
.sorted { $0.1.localizedStandardCompare($1.1) == .orderedAscending }
155+
}
156+
157+
var body: some View {
158+
VStack(alignment: .leading, spacing: 14) {
159+
Text(Strings.agentNotificationsSettingsTitle)
160+
.font(.title2.weight(.semibold))
161+
Text(Strings.agentNotificationsPermissionExplanation)
162+
.font(.callout)
163+
.foregroundStyle(.secondary)
164+
165+
permissionControl
166+
167+
GroupBox(Strings.agentNotificationsEvents) {
168+
VStack(alignment: .leading, spacing: 10) {
169+
eventToggle(.waitingInput, Strings.agentNotificationsWaiting)
170+
eventToggle(.failed, Strings.agentNotificationsFailed)
171+
eventToggle(.completed, Strings.agentNotificationsCompleted)
172+
eventToggle(.processEnded, Strings.agentNotificationsProcessEnded)
173+
}
174+
.frame(maxWidth: .infinity, alignment: .leading)
175+
.padding(.vertical, 4)
176+
}
177+
.disabled(!controller.settings.isEnabled)
178+
179+
if !agentOptions.isEmpty {
180+
preferenceGroup(
181+
Strings.agentNotificationsAgents,
182+
options: agentOptions,
183+
isEnabled: { !controller.settings.disabledAgentIDs.contains($0) },
184+
setEnabled: controller.settings.setAgent
185+
)
186+
}
187+
if !projectOptions.isEmpty {
188+
preferenceGroup(
189+
Strings.agentNotificationsProjects,
190+
options: projectOptions,
191+
isEnabled: { !controller.settings.disabledProjectPaths.contains($0) },
192+
setEnabled: controller.settings.setProject
193+
)
194+
}
195+
if !taskOptions.isEmpty {
196+
GroupBox(Strings.agentNotificationsTasks) {
197+
VStack(alignment: .leading, spacing: 8) {
198+
ForEach(taskOptions, id: \.0) { taskID, displayName in
199+
Toggle(
200+
displayName,
201+
isOn: Binding(
202+
get: {
203+
!controller.settings.mutedTaskIDs.contains(taskID)
204+
},
205+
set: {
206+
controller.settings.setTask(taskID, enabled: $0)
207+
}
208+
)
209+
)
210+
}
211+
}
212+
.frame(maxWidth: .infinity, alignment: .leading)
213+
.padding(.vertical, 4)
214+
}
215+
.disabled(!controller.settings.isEnabled)
216+
}
217+
218+
Text(Strings.agentNotificationsFocusHelp)
219+
.font(.caption)
220+
.foregroundStyle(.secondary)
221+
}
222+
.accessibilityIdentifier(AccessibilityID.agentNotificationSettings)
223+
.task { await controller.refreshAuthorizationStatus() }
224+
}
225+
226+
@ViewBuilder
227+
private var permissionControl: some View {
228+
switch controller.authorizationStatus {
229+
case .notDetermined:
230+
Button(Strings.agentNotificationsEnable) {
231+
Task { await controller.requestAuthorization() }
232+
}
233+
.accessibilityIdentifier(AccessibilityID.agentNotificationEnableButton)
234+
case .denied:
235+
HStack {
236+
Label(Strings.agentNotificationsDenied, systemImage: "bell.slash")
237+
.foregroundStyle(.secondary)
238+
Spacer()
239+
Button(Strings.agentNotificationsOpenSystemSettings) {
240+
guard let url = URL(string: "x-apple.systempreferences:com.apple.Notifications-Settings.extension") else { return }
241+
NSWorkspace.shared.open(url)
242+
}
243+
}
244+
case .authorized:
245+
Toggle(
246+
Strings.agentNotificationsMainToggle,
247+
isOn: Binding(
248+
get: { controller.settings.isEnabled },
249+
set: {
250+
if $0 {
251+
controller.settings.setEnabled(true)
252+
} else {
253+
controller.disable()
254+
}
255+
}
256+
)
257+
)
258+
.accessibilityIdentifier(AccessibilityID.agentNotificationMainToggle)
259+
}
260+
}
261+
262+
private func eventToggle(
263+
_ kind: AgentNotificationEventKind,
264+
_ title: LocalizedStringKey
265+
) -> some View {
266+
Toggle(
267+
title,
268+
isOn: Binding(
269+
get: { controller.settings.enabledEvents.contains(kind) },
270+
set: { controller.settings.setEvent(kind, enabled: $0) }
271+
)
272+
)
273+
}
274+
275+
private func preferenceGroup(
276+
_ title: LocalizedStringKey,
277+
options: [(String, String)],
278+
isEnabled: @escaping (String) -> Bool,
279+
setEnabled: @escaping (String, Bool) -> Void
280+
) -> some View {
281+
GroupBox(title) {
282+
VStack(alignment: .leading, spacing: 8) {
283+
ForEach(options, id: \.0) { identifier, displayName in
284+
Toggle(
285+
displayName,
286+
isOn: Binding(
287+
get: { isEnabled(identifier) },
288+
set: { setEnabled(identifier, $0) }
289+
)
290+
)
291+
}
292+
}
293+
.frame(maxWidth: .infinity, alignment: .leading)
294+
.padding(.vertical, 4)
295+
}
296+
.disabled(!controller.settings.isEnabled)
297+
}
298+
}
299+
92300
/// Identifiers for the Settings scene panes (issue #337). The raw value is
93301
/// persisted so the last-selected pane is restored on reopen.
94302
enum SettingsPane {
@@ -169,7 +377,6 @@ struct AgentHandoffSettingsView: View {
169377
Spacer(minLength: 0)
170378
}
171379
.padding(20)
172-
.frame(width: 720, height: 500)
173380
}
174381

175382
private func handoffGuarantee(

0 commit comments

Comments
 (0)