From 0fa03755f4daa5ca5c3b34448a1dd2eb019557bf Mon Sep 17 00:00:00 2001 From: Martin Date: Sat, 24 Oct 2026 23:10:08 +0200 Subject: [PATCH 01/18] fix(GenesisKit, GenesisHub, widgets): the 88 UX gaps from the 2026-10-24 audit: the rail counts what needs you, notes fade, undo and failures say what happened in one sentence, the recording picker, permission dialogs, hub panes and settings search follow the ux rules --- src/hub/lib/widget/types.ts | 13 + .../Audio/VoiceMicrophonePermission.swift | 4 +- .../Recording/RecordingControlsView.swift | 31 +- .../RecordingSessionController.swift | 4 +- .../Recording/RecordingSettings.swift | 4 +- .../Recording/RecordingTargetPicker.swift | 129 +++-- .../Settings/NativeSettingsControls.swift | 2 +- .../Settings/NativeSettingsModel.swift | 4 + .../Settings/NativeSettingsSearch.swift | 291 +++++++++++ .../Settings/NativeSettingsViews.swift | 144 +++++- .../Sources/GenesisKit/Style/SWR.swift | 9 +- .../GenesisKit/Widgets/LiveWidgetView.swift | 471 ++++++++++++++---- .../Widgets/Shelf/WidgetShelfModules.swift | 23 +- .../Widgets/Shelf/WidgetShelfStore.swift | 119 ++++- .../Widgets/Tasks/WidgetTasksModule.swift | 4 +- .../VoiceNotes/WidgetVoiceNotesModule.swift | 21 +- .../VoiceNotes/WidgetVoiceNotesStore.swift | 81 ++- .../Widgets/WidgetAgentPreview.swift | 29 +- .../GenesisKit/Widgets/WidgetAlerts.swift | 17 +- .../GenesisKit/Widgets/WidgetBubble.swift | 12 +- .../GenesisKit/Widgets/WidgetCmuxButton.swift | 10 + .../Widgets/WidgetCoordinator.swift | 81 ++- .../Widgets/WidgetFeatureSettings.swift | 71 ++- .../GenesisKit/Widgets/WidgetHostView.swift | 94 +++- .../Widgets/WidgetInboxOverview.swift | 41 ++ .../GenesisKit/Widgets/WidgetKeymap.swift | 36 +- .../GenesisKit/Widgets/WidgetLayout.swift | 5 + .../Widgets/WidgetModel+Staging.swift | 156 ++++-- .../GenesisKit/Widgets/WidgetModel.swift | 223 ++++++++- .../Widgets/WidgetSessionBrowser.swift | 18 +- .../Widgets/WidgetStagedViews.swift | 12 +- .../GenesisKit/Widgets/WidgetWireModels.swift | 33 ++ .../Permissions/ApprovalAttention.swift | 191 +++++++ .../Permissions/ApprovalDialog.swift | 18 +- .../Permissions/PermissionCenter.swift | 4 +- .../Permissions/PermissionKind.swift | 8 + .../Permissions/PermissionSettingsGuide.swift | 12 +- .../GenesisKitBase/Style/GenAnnounce.swift | 32 ++ .../GenesisKitTests/GateAuditTests.swift | 20 + .../GenesisKitTests/GenesisKitTests.swift | 37 +- .../GenesisKitTests/MediaThumbnailTests.swift | 2 +- .../GenesisKitTests/RecordingTests.swift | 2 +- .../SettingsControlsTests.swift | 42 ++ .../ToolsLineStreamTests.swift | 38 +- .../WidgetInboxTasksPolishTests.swift | 287 ++++++++++- .../GenesisKitTests/WidgetKeymapTests.swift | 26 + .../Sources/Host/App/GenesisToolsApp.swift | 4 + .../Sources/Host/App/PermissionsModel.swift | 46 +- .../GenesisTools/Sources/Host/App/Views.swift | 51 +- .../Sources/Host/RouteApproval.swift | 65 ++- .../Features/Settings/DaemonSettings.swift | 69 ++- .../Sources/Parts/Hub/Hub/HubAgents.swift | 13 +- .../Sources/Parts/Hub/Hub/HubComponents.swift | 3 + .../Parts/Hub/Hub/HubDecisionsPane.swift | 2 +- .../Sources/Parts/Hub/Hub/HubInbox.swift | 78 ++- .../Sources/Parts/Hub/Hub/HubNotify.swift | 300 ++++++++--- .../Sources/Parts/Hub/Hub/HubPRs.swift | 71 ++- .../Sources/Parts/Hub/Hub/HubTimeline.swift | 188 +++++-- .../Sources/Parts/Hub/Hub/HubWindow.swift | 335 ++++++++++--- .../Parts/Hub/Review/StoredProposals.swift | 5 +- .../Hub/Settings/PRActivitySettings.swift | 108 ++-- .../Sources/Parts/Widget/WidgetWindow.swift | 2 + .../Tests/DaemonSettingsTests.swift | 3 +- .../GenesisTools/Tests/HubNotifyTests.swift | 48 +- .../GenesisTools/Tests/HubTimelineTests.swift | 18 + .../Tests/PermissionsModelTests.swift | 29 +- 66 files changed, 3706 insertions(+), 643 deletions(-) create mode 100644 src/macos/GenesisKit/Sources/GenesisKit/Settings/NativeSettingsSearch.swift create mode 100644 src/macos/GenesisKit/Sources/GenesisKitBase/Permissions/ApprovalAttention.swift create mode 100644 src/macos/GenesisKit/Sources/GenesisKitBase/Style/GenAnnounce.swift diff --git a/src/hub/lib/widget/types.ts b/src/hub/lib/widget/types.ts index db7ab9868b..f03446f7de 100644 --- a/src/hub/lib/widget/types.ts +++ b/src/hub/lib/widget/types.ts @@ -93,6 +93,19 @@ export const widgetPreferencesSchema = z.object({ overviewFilter: z.enum(["all", "needs", "unread"]).default("all"), /** The composer's microphone: records a voice note and puts its transcript in the field ("note"), or live dictation. */ composerVoice: z.enum(["ask", "note", "live"]).default("ask"), + /** Seconds the alert popup stays while the pointer is not on it. */ + alertPopupSeconds: z.number().int().min(3).max(60).default(7), + /** Seconds a widget note stays before it fades; 0 keeps it until it is closed. A note with a button stays 10 s or more. */ + noteSeconds: z.number().int().min(0).max(60).default(6), + /** A global shortcut opens and closes the widget from any app. */ + openShortcutEnabled: z.boolean().default(true), + /** That shortcut: a Carbon virtual key code and modifier mask (GenesisKit `HotkeyChord`); ⌃⌥⌘W by default. */ + openShortcut: z + .object({ + keyCode: z.number().int().min(0).max(255), + modifiers: z.number().int().min(0).max(0xffff), + }) + .default({ keyCode: 0x0d, modifiers: 0x1900 }), }); // Zod defaults also run inside partial objects; a patch must retain only supplied keys. diff --git a/src/macos/GenesisKit/Sources/GenesisKit/Audio/VoiceMicrophonePermission.swift b/src/macos/GenesisKit/Sources/GenesisKit/Audio/VoiceMicrophonePermission.swift index 7c03bd4bf2..2b2eec3dc5 100644 --- a/src/macos/GenesisKit/Sources/GenesisKit/Audio/VoiceMicrophonePermission.swift +++ b/src/macos/GenesisKit/Sources/GenesisKit/Audio/VoiceMicrophonePermission.swift @@ -19,7 +19,9 @@ public enum VoiceMicrophonePermission: Equatable, Sendable { switch self { case .authorized: return nil case .notDetermined: return "Microphone access is needed. Press Record to review the macOS permission prompt." - case .denied: return "Microphone access is off. Enable this app in System Settings → Privacy & Security → Microphone." + case .denied: + let app = PermissionKind.appName + return "Microphone access is off. In System Settings, open Privacy & Security, then Microphone and turn on \(app)." case .restricted: return "Microphone access is restricted by this Mac's settings or administrator." case .unknown: return "Microphone permission could not be checked. Review Microphone settings before recording." } diff --git a/src/macos/GenesisKit/Sources/GenesisKit/Recording/RecordingControlsView.swift b/src/macos/GenesisKit/Sources/GenesisKit/Recording/RecordingControlsView.swift index 84f5534759..7fc07ce6ad 100644 --- a/src/macos/GenesisKit/Sources/GenesisKit/Recording/RecordingControlsView.swift +++ b/src/macos/GenesisKit/Sources/GenesisKit/Recording/RecordingControlsView.swift @@ -159,13 +159,28 @@ public struct RecordingControlsView: View { .modifier(NativeGlassShapeSurface(shape: RoundedRectangle(cornerRadius: 14, style: .continuous))) .environment(\.colorScheme, .dark) .animation(reduceMotion ? nil : .easeOut(duration: 0.16), value: confirming) - .onChange(of: state.phase) { _, phase in + .onChange(of: state.phase) { old, phase in if !phase.isActive || phase == .finishing { confirming = nil } + announce(from: old, to: phase) } .accessibilityElement(children: .contain) .accessibilityLabel("Recording controls") } + /// Phases change by themselves (the recorder starts late, the limit pauses), so VoiceOver is told. + private func announce(from old: RecordingPhase, to phase: RecordingPhase) { + switch phase { + case .recording: + GenAnnounce.post(old == .paused ? "Recording resumed" : "Recording started") + case .paused: + GenAnnounce.post(state.pauseReason == "limit" ? "Recording paused, the length limit is reached" : "Recording paused") + case .finishing: + GenAnnounce.post("Recording stopped, saving") + default: + break + } + } + // MARK: Status (the drag area) private var status: some View { @@ -177,7 +192,7 @@ public struct RecordingControlsView: View { .font(.system(size: 13, weight: .semibold).monospacedDigit()) .foregroundStyle(paused ? .secondary : .primary) if let caption = statusCaption { - Text(caption).font(.system(size: 9.5, weight: .medium)) + Text(caption).font(.system(size: 11, weight: .medium)) .foregroundStyle(captionColor) .lineLimit(1) } @@ -223,7 +238,7 @@ public struct RecordingControlsView: View { } private var captionColor: Color { - state.phase == .paused || remainingMs != nil ? .orange : .secondary + state.phase == .paused || remainingMs != nil ? .orange : RecordingInk.readable } private var accessibilityStatus: String { @@ -285,7 +300,7 @@ public struct RecordingControlsView: View { .frameProbe("recording.pause") } } - IconButton(systemName: "arrow.counterclockwise", tooltip: "Restart: drop what was recorded and start again", + IconButton(systemName: "arrow.counterclockwise", tooltip: "Restart: discard what was recorded and start again", size: 13) { ask(.restart, then: actions.restart) } @@ -310,7 +325,7 @@ public struct RecordingControlsView: View { .accessibilityLabel(state.useTitle) .accessibilityIdentifier("recording.use") .frameProbe("recording.use") - IconButton(systemName: "trash", tooltip: "Stop and discard", size: 13) { + IconButton(systemName: "trash", tooltip: "Discard the recording", size: 13) { ask(.discard, then: actions.discard) } .disabled(busy) @@ -334,7 +349,7 @@ public struct RecordingControlsView: View { HStack(spacing: 8) { Text(kind == .discard ? "Discard \(RecordingClock.format(state.elapsedMs)) of recording?" - : "Restart and drop \(RecordingClock.format(state.elapsedMs))?") + : "Restart and discard \(RecordingClock.format(state.elapsedMs))?") .font(.system(size: 12, weight: .medium)) .lineLimit(1) .fixedSize() @@ -370,7 +385,7 @@ public struct RecordingControlsView: View { private func preparingRow(_ text: String) -> some View { HStack(alignment: .top, spacing: 6) { Image(systemName: "hourglass").font(.system(size: 10)).foregroundStyle(.secondary) - Text(text).font(.system(size: 11)).foregroundStyle(.secondary) + Text(text).font(.system(size: 11)).foregroundStyle(RecordingInk.readable) .lineLimit(3).fixedSize(horizontal: false, vertical: true) .frame(maxWidth: 420, alignment: .leading) Spacer(minLength: 0) @@ -383,7 +398,7 @@ public struct RecordingControlsView: View { private func noticeRow(_ notice: String) -> some View { HStack(alignment: .top, spacing: 6) { Image(systemName: "exclamationmark.triangle.fill").font(.system(size: 10)).foregroundStyle(.orange) - Text(notice).font(.system(size: 11)).foregroundStyle(.secondary) + Text(notice).font(.system(size: 11)).foregroundStyle(RecordingInk.readable) .lineLimit(3).fixedSize(horizontal: false, vertical: true) .textSelection(.enabled) .frame(maxWidth: 420, alignment: .leading) diff --git a/src/macos/GenesisKit/Sources/GenesisKit/Recording/RecordingSessionController.swift b/src/macos/GenesisKit/Sources/GenesisKit/Recording/RecordingSessionController.swift index 76be55b432..97fbba38c3 100644 --- a/src/macos/GenesisKit/Sources/GenesisKit/Recording/RecordingSessionController.swift +++ b/src/macos/GenesisKit/Sources/GenesisKit/Recording/RecordingSessionController.swift @@ -184,7 +184,9 @@ public final class RecordingSessionController: ObservableObject { guard !ended else { return } ended = true phase = .failed + // One sentence on screen; the recorder's own words go to the log. let tail = stderr.trimmingCharacters(in: .whitespacesAndNewlines).suffix(600) - onFailed?(tail.isEmpty ? "The recorder stopped unexpectedly (exit \(status))." : String(tail), nil) + KitHost.log("recording.session stderr (exit \(status)) \(tail.isEmpty ? "(empty)" : String(tail))") + onFailed?("The recorder stopped unexpectedly. The details are in the log.", nil) } } diff --git a/src/macos/GenesisKit/Sources/GenesisKit/Recording/RecordingSettings.swift b/src/macos/GenesisKit/Sources/GenesisKit/Recording/RecordingSettings.swift index 5b8b443691..9707942762 100644 --- a/src/macos/GenesisKit/Sources/GenesisKit/Recording/RecordingSettings.swift +++ b/src/macos/GenesisKit/Sources/GenesisKit/Recording/RecordingSettings.swift @@ -55,7 +55,7 @@ public final class RecordingSettings: ObservableObject { didSet { defaults.set(showsBorder, forKey: Key.showsBorder) } } - /// "Stop and discard" and "Restart" ask once inside the controls before they drop what was recorded. Default on. + /// "Discard" and "Restart" ask once inside the controls before they lose what was recorded. Default on. @Published public var confirmsDiscard: Bool { didSet { defaults.set(confirmsDiscard, forKey: Key.confirmsDiscard) } } @@ -93,7 +93,7 @@ public struct RecordingSettingsView: View { } Divider() NativeSettingsToggle( - "Ask before discarding", detail: "Stop and discard, and Restart, ask once inside the controls.", + "Ask before discarding", detail: "Discard and Restart ask once inside the controls.", identifier: "recording.confirmsDiscard", isOn: $settings.confirmsDiscard) } NativeSettingsCard("Recording") { diff --git a/src/macos/GenesisKit/Sources/GenesisKit/Recording/RecordingTargetPicker.swift b/src/macos/GenesisKit/Sources/GenesisKit/Recording/RecordingTargetPicker.swift index a3b192417e..3f24db2039 100644 --- a/src/macos/GenesisKit/Sources/GenesisKit/Recording/RecordingTargetPicker.swift +++ b/src/macos/GenesisKit/Sources/GenesisKit/Recording/RecordingTargetPicker.swift @@ -22,6 +22,31 @@ public enum RecordingPickerTab: String, CaseIterable, Hashable, Sendable { } } +/// Text the person must read on the dark panels: 11 pt or more at about 62% white (above 4.5:1), never tertiary grey. +enum RecordingInk { + static let readable = Color.white.opacity(0.62) +} + +extension RecordingPickerTab { + /// The chord that switches to this tab, shown in the tab's tooltip. + var chord: String { + switch self { + case .windows: return "⌘1" + case .apps: return "⌘2" + case .displays: return "⌘3" + } + } + + static func forKey(_ character: Character) -> RecordingPickerTab? { + switch character { + case "1": return .windows + case "2": return .apps + case "3": return .displays + default: return nil + } + } +} + /// One line of the picker: an app header over its windows, or a target that a click records. struct RecordingPickerRow: Identifiable, Equatable { enum Kind: Equatable { @@ -108,6 +133,9 @@ public struct RecordingTargetPicker: View { /// The list took over 4 s: the hint names the known slow cause. @State private var slowLoad = false @FocusState private var filterFocused: Bool + /// Where the pointer rested when the arrows last moved the highlight; a hover there is the list scrolling under + /// the pointer, not the person moving it, so the keyboard highlight stays. + @State private var keyboardMouse: CGPoint? public init(load: Load, title: String = "Choose what to record", initial: RecordingTarget?, onChoose: @escaping (RecordingTarget) -> Void, onCancel: @escaping () -> Void, @@ -136,6 +164,7 @@ public struct RecordingTargetPicker: View { header GenSegmentedTabs("What to record", items: RecordingPickerTab.allCases.map { .init($0, $0.title) }, selection: $tab, minSegmentWidth: 114, identifier: "recording.picker.tabs") + .instantTooltip(RecordingPickerTab.allCases.map { "\($0.title) \($0.chord)" }.joined(separator: ", ")) filterField content footer @@ -151,6 +180,32 @@ public struct RecordingTargetPicker: View { .onChange(of: load) { _, _ in highlight(initial: true) } .onChange(of: tab) { _, _ in onFilterChange(tab, query); highlight(initial: false) } .onChange(of: query) { _, _ in onFilterChange(tab, query); highlight(initial: false) } + // The keys live on the root, so they work wherever focus is (the filter, a row, nothing). + .focusable() + .focusEffectDisabled() + .onExitCommand(perform: onCancel) + .onKeyPress(.escape) { + onCancel() + return .handled + } + .onKeyPress(.downArrow) { + move(1) + return .handled + } + .onKeyPress(.upArrow) { + move(-1) + return .handled + } + .onKeyPress(.return) { + chooseHighlighted() + return .handled + } + .onKeyPress(phases: .down) { press in + guard press.modifiers == .command, let character = press.characters.first, + let next = RecordingPickerTab.forKey(character) else { return .ignored } + tab = next + return .handled + } .accessibilityElement(children: .contain) .accessibilityLabel(title) } @@ -167,25 +222,12 @@ public struct RecordingTargetPicker: View { private var filterField: some View { HStack(spacing: 6) { - Image(systemName: "magnifyingglass").font(.system(size: 11)).foregroundStyle(.secondary) + Image(systemName: "magnifyingglass").font(.system(size: 11)).foregroundStyle(RecordingInk.readable) TextField("Filter \(tab.title.lowercased())", text: $query) .textFieldStyle(.plain) .font(.system(size: 12)) .focused($filterFocused) .onSubmit(chooseHighlighted) - .onExitCommand(perform: onCancel) - .onKeyPress(.escape) { - onCancel() - return .handled - } - .onKeyPress(.downArrow) { - move(1) - return .handled - } - .onKeyPress(.upArrow) { - move(-1) - return .handled - } .accessibilityIdentifier("recording.picker.filter") } .padding(.horizontal, 8) @@ -199,11 +241,11 @@ public struct RecordingTargetPicker: View { VStack(spacing: 6) { HStack(spacing: 8) { ProgressView().controlSize(.small) - Text("Looking for windows and displays…").font(.system(size: 12)).foregroundStyle(.secondary) + Text("Looking for windows and displays…").font(.system(size: 12)).foregroundStyle(RecordingInk.readable) } if slowLoad { Text("The first time after an update, the recorder is built first. This can take up to a minute.") - .font(.system(size: 11)).foregroundStyle(.tertiary) + .font(.system(size: 11)).foregroundStyle(RecordingInk.readable) .multilineTextAlignment(.center).fixedSize(horizontal: false, vertical: true) .frame(maxWidth: 300) } @@ -215,16 +257,26 @@ public struct RecordingTargetPicker: View { } case let .failed(message): VStack(spacing: 8) { - Text(message).font(.system(size: 12)).foregroundStyle(.secondary) + Text(message).font(.system(size: 12)).foregroundStyle(RecordingInk.readable) .multilineTextAlignment(.center).fixedSize(horizontal: false, vertical: true) Button("Try again", action: onRetry).buttonStyle(.genHover()) } .frame(maxWidth: .infinity, minHeight: 120) case .loaded: if rows.isEmpty { - Text(query.isEmpty ? "Nothing to record here." : "Nothing matches “\(query)”.") - .font(.system(size: 12)).foregroundStyle(.secondary) - .frame(maxWidth: .infinity, minHeight: 80) + if query.isEmpty { + EmptyState( + symbol: "rectangle.dashed.badge.record", + text: "Nothing to record here.", + detail: "If you expected windows, Screen Recording may be off for this app.", + actionTitle: "Open Screen Recording settings", + action: { NSWorkspace.shared.open(PermissionKind.screenRecording.settingsURL) }) + .frame(maxWidth: .infinity, minHeight: 120) + } else { + Text("Nothing matches “\(query)”.") + .font(.system(size: 12)).foregroundStyle(RecordingInk.readable) + .frame(maxWidth: .infinity, minHeight: 80) + } } else { ScrollViewReader { proxy in FittedScroll(maxHeight: 330) { @@ -254,12 +306,12 @@ public struct RecordingTargetPicker: View { Text(row.title).font(.system(size: 12, weight: isHighlighted ? .semibold : .regular)) .lineLimit(1).truncationMode(.middle) if let badge = row.badge { - Text(badge).font(.system(size: 9, weight: .semibold)) + Text(badge).font(.system(size: 11, weight: .semibold)) .padding(.horizontal, 5).padding(.vertical, 1) .background(.white.opacity(0.12), in: Capsule()) } Spacer(minLength: 8) - Text(verbatim: row.detail).font(.system(size: 10.5).monospacedDigit()).foregroundStyle(.secondary) + Text(verbatim: row.detail).font(.system(size: 11).monospacedDigit()).foregroundStyle(RecordingInk.readable) } .padding(.leading, row.symbol == "macwindow" ? 22 : 6) .padding(.trailing, 8) @@ -270,16 +322,23 @@ public struct RecordingTargetPicker: View { } .buttonStyle(.genHoverPlain()) .onHover { inside in - if inside { highlighted = row.id } + guard inside else { return } + if let rested = keyboardMouse { + let now = NSEvent.mouseLocation + // The list scrolled under a resting pointer: keep the keyboard's row until the pointer moves. + if hypot(now.x - rested.x, now.y - rested.y) < 3 { return } + keyboardMouse = nil + } + highlighted = row.id } - .accessibilityLabel("Record \(row.title)") + .accessibilityLabel(accessibilityLabel(row)) .accessibilityIdentifier("recording.picker.\(row.id)") .frameProbe("recording.picker.\(row.id)") } else { HStack(spacing: 6) { icon(row).frame(width: 14, height: 14) - Text(row.title).font(.system(size: 11, weight: .semibold)).foregroundStyle(.secondary) - Text(verbatim: row.detail).font(.system(size: 10)).foregroundStyle(.tertiary) + Text(row.title).font(.system(size: 11, weight: .semibold)).foregroundStyle(RecordingInk.readable) + Text(verbatim: row.detail).font(.system(size: 11)).foregroundStyle(RecordingInk.readable) } .padding(.horizontal, 6) .padding(.top, 6) @@ -292,17 +351,26 @@ public struct RecordingTargetPicker: View { if let image = RecordingAppIcon.image(pid: row.pid) { Image(nsImage: image).resizable().interpolation(.high) } else { - Image(systemName: row.symbol).font(.system(size: 12)).foregroundStyle(.secondary) + Image(systemName: row.symbol).font(.system(size: 12)).foregroundStyle(RecordingInk.readable) } } private var footer: some View { HStack(spacing: 4) { - Text("↑ ↓ to choose · Return records · Esc cancels") + Text("↑ ↓ to choose · Return records · Esc cancels · ⌘1 ⌘2 ⌘3 switch tabs") + .fixedSize(horizontal: false, vertical: true) Spacer(minLength: 0) } - .font(.system(size: 10)) - .foregroundStyle(.tertiary) + .font(.system(size: 11)) + .foregroundStyle(RecordingInk.readable) + } + + /// What VoiceOver reads for a row: the action, then the badge and the details the row shows. + private func accessibilityLabel(_ row: RecordingPickerRow) -> String { + var parts = ["Record \(row.title)"] + if let badge = row.badge { parts.append(badge) } + parts.append(row.detail.replacingOccurrences(of: " × ", with: " by ")) + return parts.joined(separator: ", ") } private func highlight(initial useInitial: Bool) { @@ -319,6 +387,7 @@ public struct RecordingTargetPicker: View { private func move(_ step: Int) { let candidates = targetRows guard !candidates.isEmpty else { return } + keyboardMouse = NSEvent.mouseLocation let index = candidates.firstIndex { $0.id == highlighted } ?? -1 highlighted = candidates[min(max(index + step, 0), candidates.count - 1)].id } diff --git a/src/macos/GenesisKit/Sources/GenesisKit/Settings/NativeSettingsControls.swift b/src/macos/GenesisKit/Sources/GenesisKit/Settings/NativeSettingsControls.swift index 4f6b94db69..418ddf4de9 100644 --- a/src/macos/GenesisKit/Sources/GenesisKit/Settings/NativeSettingsControls.swift +++ b/src/macos/GenesisKit/Sources/GenesisKit/Settings/NativeSettingsControls.swift @@ -41,7 +41,7 @@ public struct NativeSettingsDisclosure: View { .frame(maxWidth: .infinity, minHeight: 36, alignment: .leading) .contentShape(Rectangle()) } - .buttonStyle(.plain) + .buttonStyle(.genHoverRow(cornerRadius: KitTheme.radiusSmall)) .nativeSettingsPointer() .nativeSettingsDisabledLook() .accessibilityIdentifier(identifier) diff --git a/src/macos/GenesisKit/Sources/GenesisKit/Settings/NativeSettingsModel.swift b/src/macos/GenesisKit/Sources/GenesisKit/Settings/NativeSettingsModel.swift index 3d9651aec7..506c2ec650 100644 --- a/src/macos/GenesisKit/Sources/GenesisKit/Settings/NativeSettingsModel.swift +++ b/src/macos/GenesisKit/Sources/GenesisKit/Settings/NativeSettingsModel.swift @@ -66,9 +66,13 @@ public final class NativeSettingsHighlightCenter: ObservableObject { @Published public private(set) var token = 0 /// The last request a card pulsed for, so going back to the page does not replay an old one. private var deliveredToken = 0 + /// When the last request came: a card that appears later (a page that opens on it) flashes only what is left of + /// the flash, and a page visited again later does not flash or scroll again. + @Published public private(set) var requestedAt: Date? public func request(_ anchor: String) { self.anchor = anchor + requestedAt = Date() token += 1 } diff --git a/src/macos/GenesisKit/Sources/GenesisKit/Settings/NativeSettingsSearch.swift b/src/macos/GenesisKit/Sources/GenesisKit/Settings/NativeSettingsSearch.swift new file mode 100644 index 0000000000..bc24f77a19 --- /dev/null +++ b/src/macos/GenesisKit/Sources/GenesisKit/Settings/NativeSettingsSearch.swift @@ -0,0 +1,291 @@ +import SwiftUI + +// The settings window's search (H4): one field above the page list finds a setting on any page by its card title, its +// row title or its detail, and a click opens the page there and flashes the card or row. +// +// Pages are views, not data, so the search learns their cards and rows from what they draw: each titled +// `NativeSettingsCard` and each `NativeSettingsRow` reports itself through a preference, the page on screen keeps its +// entries current, and the first search of a window draws every other page once, hidden, to learn theirs. + +/// One thing the search finds on a page: a titled card or a row, with the words it shows. +public struct NativeSettingsSearchEntry: Equatable, Sendable { + public enum Kind: String, Sendable { case card, row } + + public let kind: Kind + public let title: String + public let detail: String? + /// The titled card a row sits in. + public var card: String? + + public init(kind: Kind, title: String, detail: String? = nil, card: String? = nil) { + self.kind = kind + self.title = title + self.detail = detail + self.card = card + } + + /// The entry's name on its page; titles are unique on a page in practice, and a repeat finds the first. + var key: String { "\(kind.rawValue):\(title)" } +} + +/// A settings page as the search sees it: its words, without its views. +public struct NativeSettingsSearchPage: Equatable, Sendable { + public let id: String + public let title: String + public let subtitle: String + public let section: String + + public init(id: String, title: String, subtitle: String = "", section: String = "") { + self.id = id + self.title = title + self.subtitle = subtitle + self.section = section + } +} + +/// One result: what the list shows, and where a click goes (`` or `#`). +public struct NativeSettingsSearchResult: Identifiable, Equatable, Sendable { + public let target: String + public let title: String + public let context: String + public var id: String { target } +} + +public enum NativeSettingsSearch { + /// The anchor of a card or row on a page, as a jump requests it and the card or row answers to it. + static func anchor(page: String, key: String) -> String { "search/\(page)/\(key)" } + + /// The results for `query`, best first. Every word of the query must appear in the result's title, its detail, its + /// card or its page. A title that starts with the query ranks first, then one that contains it, then one that holds + /// every word, then a match in the other words; within a rank the sidebar's order holds. Case and accents are + /// ignored. + public static func results( + _ query: String, pages: [NativeSettingsSearchPage], entries: [String: [NativeSettingsSearchEntry]], limit: Int = 60 + ) -> [NativeSettingsSearchResult] { + let needle = query.trimmingCharacters(in: .whitespacesAndNewlines) + guard !needle.isEmpty else { return [] } + let words = needle.split(whereSeparator: \.isWhitespace).map(String.init) + func found(_ fields: [String?]) -> Bool { + words.allSatisfy { word in fields.contains { $0?.localizedStandardContains(word) == true } } + } + func rank(_ title: String) -> Int { + if title.range(of: needle, options: [.caseInsensitive, .diacriticInsensitive, .anchored]) != nil { return 0 } + if title.localizedStandardContains(needle) { return 1 } + return found([title]) ? 2 : 3 + } + var ranked: [(rank: Int, order: Int, result: NativeSettingsSearchResult)] = [] + var seen: Set = [] + func add(_ result: NativeSettingsSearchResult) { + guard seen.insert(result.target).inserted else { return } + ranked.append((rank(result.title), ranked.count, result)) + } + for page in pages { + if found([page.title, page.subtitle, page.section]) { + add(NativeSettingsSearchResult( + target: page.id, title: page.title, context: page.section.isEmpty ? page.subtitle : page.section)) + } + for entry in entries[page.id] ?? [] where found([entry.title, entry.detail, entry.card, page.title]) { + let context = entry.card.map { $0 == entry.title ? page.title : "\(page.title) · \($0)" } ?? page.title + add(NativeSettingsSearchResult( + target: "\(page.id)#\(anchor(page: page.id, key: entry.key))", title: entry.title, context: context)) + } + } + return ranked.sorted { ($0.rank, $0.order) < ($1.rank, $1.order) }.prefix(limit).map(\.result) + } +} + +/// What the search knows of each page, by page id. Not observed by the window itself, so a page that reports its +/// entries redraws only the result list. +@MainActor +final class NativeSettingsSearchIndex: ObservableObject { + @Published private(set) var entries: [String: [NativeSettingsSearchEntry]] = [:] + + func set(_ page: String, _ list: [NativeSettingsSearchEntry]) { + if entries[page] != list { entries[page] = list } + } + + /// A page that was drawn at least once, entries or not. + func markSeen(_ page: String) { + if entries[page] == nil { entries[page] = [] } + } + + func knows(_ page: String) -> Bool { entries[page] != nil } +} + +struct NativeSettingsSearchKey: PreferenceKey { + static let defaultValue: [NativeSettingsSearchEntry] = [] + + static func reduce(value: inout [NativeSettingsSearchEntry], nextValue: () -> [NativeSettingsSearchEntry]) { + value.append(contentsOf: nextValue()) + } +} + +extension EnvironmentValues { + /// The settings page a card or row is drawn on; nil outside the settings window. + @Entry var nativeSettingsPageID: String? = nil + /// True in the hidden copy of the pages the search reads: nothing there flashes. + @Entry var nativeSettingsIndexing = false +} + +/// A card or row the search can jump to: when a jump names it, it flashes, and its marker is what the page scrolls to. +private struct NativeSettingsSearchTarget: ViewModifier { + let key: String + let cornerRadius: CGFloat + let outset: CGFloat + @Environment(\.nativeSettingsPageID) private var page + @Environment(\.nativeSettingsIndexing) private var indexing + @ObservedObject private var center = NativeSettingsHighlightCenter.shared + + func body(content: Content) -> some View { + content.overlay { + if !indexing, let page, let anchor = center.anchor, anchor == NativeSettingsSearch.anchor(page: page, key: key) { + Color.clear + .padding(-outset) + .attentionPulse( + trigger: center.token, + style: .borderFlash(color: .accentColor, cornerRadius: cornerRadius), + since: center.requestedAt + ) + .id(anchor) + .allowsHitTesting(false) + .accessibilityHidden(true) + } + } + } +} + +extension View { + /// Reports a card or row to the settings search and makes it a jump target. + func nativeSettingsSearchable(_ entry: NativeSettingsSearchEntry, cornerRadius: CGFloat, outset: CGFloat = 0) -> some View { + modifier(NativeSettingsSearchTarget(key: entry.key, cornerRadius: cornerRadius, outset: outset)) + .transformPreference(NativeSettingsSearchKey.self) { entries in + if entry.kind == .card { + for index in entries.indices where entries[index].card == nil { entries[index].card = entry.title } + entries.insert(entry, at: 0) + } else { + entries.append(entry) + } + } + } +} + +/// Every page but the one on screen, drawn once and hidden while a search runs, so the search learns their cards and +/// rows. Their own loads run as if each page had been opened; nothing is drawn, hit or read by VoiceOver. +struct NativeSettingsSearchIndexer: View { + @ObservedObject var store: NativeSettingsStore + let search: NativeSettingsSearchIndex + + var body: some View { + ZStack(alignment: .topLeading) { + ForEach(store.sections.flatMap(\.pages).filter { $0.id != store.selectedPageID }) { page in + let id = page.id + page.content() + .environment(\.nativeSettingsPageID, id) + .onPreferenceChange(NativeSettingsSearchKey.self) { entries in + MainActor.assumeIsolated { search.set(id, entries) } + } + .onAppear { search.markSeen(id) } + } + } + .environment(\.nativeSettingsIndexing, true) + .frame(width: 620) + .fixedSize(horizontal: false, vertical: true) + .hidden() + .disabled(true) + .allowsHitTesting(false) + .accessibilityHidden(true) + } +} + +/// The search field and its results, in the sidebar above the page list. Return opens the first result, Esc empties +/// the field, ⌘F puts the cursor in it. +struct NativeSettingsSearchField: View { + @Binding var query: String + let focused: FocusState.Binding + let submit: () -> Void + + var body: some View { + HStack(spacing: 6) { + Image(systemName: "magnifyingglass").font(.system(size: 12)).foregroundStyle(.secondary) + .accessibilityHidden(true) + TextField("Search settings", text: $query) + .textFieldStyle(.plain) + .font(.system(size: 13)) + .focused(focused) + .onSubmit(submit) + .onExitCommand { query = "" } + .accessibilityIdentifier("settings.search") + if !query.isEmpty { + IconButton(systemName: "xmark.circle.fill", tooltip: "Clear the search", size: 11) { + query = "" + focused.wrappedValue = true + } + } + } + .padding(.horizontal, 10) + .frame(height: 30) + .background(RoundedRectangle(cornerRadius: 8).fill(.white.opacity(0.05))) + .overlay(RoundedRectangle(cornerRadius: 8).stroke(.white.opacity(0.1), lineWidth: 0.5)) + .background { + Button("Search settings") { focused.wrappedValue = true } + .keyboardShortcut("f", modifiers: .command) + .opacity(0) + .frame(width: 0, height: 0) + .accessibilityHidden(true) + } + } +} + +/// The results of a search, in the place of the page list. +struct NativeSettingsSearchResults: View { + let query: String + @ObservedObject var store: NativeSettingsStore + @ObservedObject var search: NativeSettingsSearchIndex + let open: (NativeSettingsSearchResult) -> Void + let clear: () -> Void + + static func pages(_ store: NativeSettingsStore) -> [NativeSettingsSearchPage] { + store.sections.flatMap { section in + section.pages.map { + NativeSettingsSearchPage(id: $0.id, title: $0.title, subtitle: $0.subtitle, section: section.title) + } + } + } + + var body: some View { + let results = NativeSettingsSearch.results(query, pages: Self.pages(store), entries: search.entries) + VStack(alignment: .leading, spacing: 1) { + if results.isEmpty { + VStack(alignment: .leading, spacing: 8) { + Text("No setting matches “\(query.trimmingCharacters(in: .whitespacesAndNewlines))”.") + .font(.system(size: 12)).foregroundStyle(.secondary) + .fixedSize(horizontal: false, vertical: true) + Button("Clear the search", action: clear) + .buttonStyle(.genHoverPlain()) + .font(.system(size: 12)) + } + .padding(.horizontal, 10).padding(.top, 4) + } else { + Text(Plural.count(results.count, "result")) + .font(.system(size: 11, weight: .semibold)).foregroundStyle(.secondary) + .padding(.horizontal, 10).padding(.bottom, 4) + .accessibilityAddTraits(.isHeader) + ForEach(results) { result in + Button { open(result) } label: { + VStack(alignment: .leading, spacing: 2) { + Text(result.title).font(.system(size: 13)).lineLimit(2) + if !result.context.isEmpty { + Text(result.context).font(.system(size: 11)).foregroundStyle(.secondary).lineLimit(1) + } + } + .frame(maxWidth: .infinity, alignment: .leading) + .padding(.horizontal, 10).padding(.vertical, 5).contentShape(Rectangle()) + } + .buttonStyle(.genHoverRow()) + .instantTooltip(result.context.isEmpty ? "Open \(result.title)" : "Open \(result.title) in \(result.context)") + .accessibilityIdentifier("settings.search.result.\(result.target)") + } + } + } + } +} diff --git a/src/macos/GenesisKit/Sources/GenesisKit/Settings/NativeSettingsViews.swift b/src/macos/GenesisKit/Sources/GenesisKit/Settings/NativeSettingsViews.swift index 454ba9ff62..23effc4587 100644 --- a/src/macos/GenesisKit/Sources/GenesisKit/Settings/NativeSettingsViews.swift +++ b/src/macos/GenesisKit/Sources/GenesisKit/Settings/NativeSettingsViews.swift @@ -12,7 +12,7 @@ public struct NativeSettingsCard: View { } public var body: some View { - VStack(alignment: .leading, spacing: 16) { + let card = VStack(alignment: .leading, spacing: 16) { if let title { VStack(alignment: .leading, spacing: 5) { Text(title).font(.system(size: 13, weight: .semibold)) @@ -22,6 +22,12 @@ public struct NativeSettingsCard: View { content }.padding(20).frame(maxWidth: .infinity, alignment: .leading) .nativeGlassSurface() + // A titled card is a place the settings search finds and flashes (NativeSettingsSearch.swift). + if let title { + card.nativeSettingsSearchable(NativeSettingsSearchEntry(kind: .card, title: title, detail: subtitle), cornerRadius: 22) + } else { + card + } } } @@ -55,6 +61,7 @@ public struct NativeSettingsRow: View { Spacer(minLength: 0) accessory } + .nativeSettingsSearchable(NativeSettingsSearchEntry(kind: .row, title: title, detail: detail), cornerRadius: 10, outset: 8) } } @@ -105,7 +112,9 @@ public struct FeatureSettingsView: View { @ObservedObject private var store: NativeSettingsStore @ObservedObject private var appearance: NativeSettingsAppearance @Environment(\.accessibilityReduceMotion) private var systemStill - @Namespace private var navigation + /// What the settings search knows of each page. Held, not observed: a page that reports its cards and rows redraws + /// the result list, never this window and the page on screen. + @State private var search = NativeSettingsSearchIndex() private let title: String public init(store: NativeSettingsStore, title: String = "Settings") { @@ -116,7 +125,7 @@ public struct FeatureSettingsView: View { public var body: some View { HStack(spacing: 0) { - sidebar + NativeSettingsSidebar(store: store, appearance: appearance, search: search, title: title) Rectangle().fill(.white.opacity(0.075)).frame(width: 1) VStack(alignment: .leading, spacing: 0) { if let page = store.selectedPage { @@ -130,16 +139,9 @@ public struct FeatureSettingsView: View { } Spacer(minLength: 0) }.padding(.horizontal, 28).padding(.top, 24).padding(.bottom, 23) - ScrollView { - VStack(alignment: .leading, spacing: 18) { - if let error = store.catalogError { - Label(error.description, systemImage: "exclamationmark.triangle") - .font(.system(size: 12)).foregroundStyle(.orange) - } - page.content() - }.padding(.horizontal, 28).padding(.bottom, 28) - .frame(maxWidth: .infinity, alignment: .leading) - }.id(page.id) + NativeSettingsPageScroll( + page: page, catalogError: store.catalogError, search: search, + still: systemStill || appearance.reduceMotion) } else { VStack(spacing: 12) { Image(systemName: "slider.horizontal.3").font(.system(size: 34)).foregroundStyle(.secondary) @@ -157,27 +159,101 @@ public struct FeatureSettingsView: View { .nativeSettingsAppearance(appearance) .preferredColorScheme(.dark) } +} - private var sidebar: some View { +/// The page on screen. It keeps the search's entries for the page current, and brings the card or row a search result +/// or a link asked for into view while it flashes. +@MainActor +private struct NativeSettingsPageScroll: View { + let page: NativeSettingsPage + let catalogError: NativeSettingsCatalogError? + let search: NativeSettingsSearchIndex + let still: Bool + @ObservedObject private var center = NativeSettingsHighlightCenter.shared + + var body: some View { + let id = page.id + ScrollViewReader { proxy in + ScrollView { + VStack(alignment: .leading, spacing: 18) { + if let error = catalogError { + Label(error.description, systemImage: "exclamationmark.triangle") + .font(.system(size: 12)).foregroundStyle(.orange) + } + page.content() + .environment(\.nativeSettingsPageID, id) + .onPreferenceChange(NativeSettingsSearchKey.self) { entries in + MainActor.assumeIsolated { search.set(id, entries) } + } + }.padding(.horizontal, 28).padding(.bottom, 28) + .frame(maxWidth: .infinity, alignment: .leading) + } + // Only a fresh request scrolls: a page visited again later stays where its reader left it. + .task(id: center.token) { + guard let anchor = center.anchor, let at = center.requestedAt, Date().timeIntervalSince(at) < 2 else { return } + try? await Task.sleep(for: .milliseconds(80)) + let place = UnitPoint(x: 0.5, y: 0.15) + if still { + proxy.scrollTo(anchor, anchor: place) + } else { + withAnimation(.easeInOut(duration: 0.25)) { proxy.scrollTo(anchor, anchor: place) } + } + } + } + .id(id) + } +} + +/// The page list, and the search field above it. While the field holds text, its results take the list's place. +@MainActor +private struct NativeSettingsSidebar: View { + @ObservedObject var store: NativeSettingsStore + @ObservedObject var appearance: NativeSettingsAppearance + let search: NativeSettingsSearchIndex + let title: String + @Environment(\.accessibilityReduceMotion) private var systemStill + @Namespace private var navigation + @State private var query = "" + @FocusState private var searchFocused: Bool + /// The hidden copy of the other pages is drawn: from a search that starts while a page is still unknown, until the + /// field is empty again. Later searches use what it learned. + @State private var indexing = false + + private var searching: Bool { !query.trimmingCharacters(in: .whitespacesAndNewlines).isEmpty } + + var body: some View { VStack(alignment: .leading, spacing: 0) { HStack(spacing: 10) { Image(systemName: "slider.horizontal.3").font(.system(size: 19, weight: .medium)) .foregroundStyle(.white.opacity(0.7)) Text(title).font(.system(size: 24, weight: .semibold, design: .rounded)) - }.padding(.horizontal, 24).padding(.top, 28).padding(.bottom, 16) + }.padding(.horizontal, 24).padding(.top, 28).padding(.bottom, 14) + NativeSettingsSearchField(query: $query, focused: $searchFocused, submit: openFirst) + .padding(.horizontal, 12).padding(.bottom, 12) // The list scrolls under the header with a visible scroller and room after the last row, so the last // page is never hidden behind a footer (it was: "Providers" sat half cut above one in a 760 pt window). ScrollView { - VStack(alignment: .leading, spacing: 12) { - ForEach(store.sections) { section in - VStack(alignment: .leading, spacing: 1) { - if !section.title.isEmpty { - Text(section.title).font(.system(size: 11, weight: .semibold)).foregroundStyle( - .secondary - ) - .padding(.horizontal, 10).padding(.bottom, 4) + Group { + if searching { + NativeSettingsSearchResults( + query: query, store: store, search: search, open: open, + clear: { + query = "" + searchFocused = true + }) + } else { + VStack(alignment: .leading, spacing: 12) { + ForEach(store.sections) { section in + VStack(alignment: .leading, spacing: 1) { + if !section.title.isEmpty { + Text(section.title).font(.system(size: 11, weight: .semibold)).foregroundStyle( + .secondary + ) + .padding(.horizontal, 10).padding(.bottom, 4) + } + ForEach(section.pages) { page in navigationRow(page) } + } } - ForEach(section.pages) { page in navigationRow(page) } } } }.padding(.horizontal, 12).padding(.top, 2).padding(.bottom, 16) @@ -185,6 +261,26 @@ public struct FeatureSettingsView: View { .scrollIndicators(.automatic) }.frame(width: 238) .titlebarBackground(Color.white.opacity(0.025)) + .background(alignment: .topLeading) { + if indexing { NativeSettingsSearchIndexer(store: store, search: search) } + } + .onChange(of: searching) { _, now in + if !now { + indexing = false + } else if store.sections.flatMap(\.pages).contains(where: { !search.knows($0.id) && $0.id != store.selectedPageID }) { + indexing = true + } + } + } + + /// A page, or a card or row on it: the page opens, scrolls there, and flashes it. + private func open(_ result: NativeSettingsSearchResult) { + store.select(pageID: result.target) + } + + private func openFirst() { + let pages = NativeSettingsSearchResults.pages(store) + if let first = NativeSettingsSearch.results(query, pages: pages, entries: search.entries).first { open(first) } } private func navigationRow(_ page: NativeSettingsPage) -> some View { diff --git a/src/macos/GenesisKit/Sources/GenesisKit/Style/SWR.swift b/src/macos/GenesisKit/Sources/GenesisKit/Style/SWR.swift index 6a51be7b35..60de807465 100644 --- a/src/macos/GenesisKit/Sources/GenesisKit/Style/SWR.swift +++ b/src/macos/GenesisKit/Sources/GenesisKit/Style/SWR.swift @@ -1,3 +1,4 @@ +import AppKit import SwiftUI /// Stale-while-revalidate on screen: a view paints the last answer (`DiskCache`), marks it as @@ -9,7 +10,11 @@ import SwiftUI /// SWR.fade(moved, current: { changed }, clear: { changed = [] }) /// row.swrFlash(changed.contains(row.id)).transition(SWR.rowTransition) public enum SWR { - public static let animation: Animation = .spring(response: 0.35, dampingFraction: 0.85) + /// The refresh spring, or nil (an instant change) while the system's Reduce Motion is on. Read at each refresh, + /// so the setting takes effect without a restart. + public static var animation: Animation? { + NSWorkspace.shared.accessibilityDisplayShouldReduceMotion ? nil : .spring(response: 0.35, dampingFraction: 0.85) + } /// New rows slide in from the top; gone rows fade. public static let rowTransition: AnyTransition = .asymmetric( @@ -43,7 +48,7 @@ public enum SWR { Task { @MainActor in try? await Task.sleep(nanoseconds: 1_600_000_000) guard current() == flashed else { return } - withAnimation(.easeOut(duration: 0.6)) { clear() } + withAnimation(NSWorkspace.shared.accessibilityDisplayShouldReduceMotion ? nil : .easeOut(duration: 0.6)) { clear() } } } } diff --git a/src/macos/GenesisKit/Sources/GenesisKit/Widgets/LiveWidgetView.swift b/src/macos/GenesisKit/Sources/GenesisKit/Widgets/LiveWidgetView.swift index 9d6bbb3302..118060fea7 100644 --- a/src/macos/GenesisKit/Sources/GenesisKit/Widgets/LiveWidgetView.swift +++ b/src/macos/GenesisKit/Sources/GenesisKit/Widgets/LiveWidgetView.swift @@ -1,4 +1,5 @@ import AppKit +import Combine import SwiftUI import UniformTypeIdentifiers @@ -25,8 +26,6 @@ public struct LiveWidgetView: View { } @State private var retry: WidgetOutgoing? - /// The session inbox's filter picked here; until then the one the `inboxFilter` setting opens with. - @State private var inboxFilterPick: String? @State private var choosingSession = false @State private var sourcesExpanded = false @Environment(\.accessibilityReduceMotion) private var systemReduceMotion @@ -38,6 +37,10 @@ public struct LiveWidgetView: View { /// The composer field's height, so its stand-in takes exactly its place while the panel moves. @State private var composerFieldHeight: CGFloat = 0 @Environment(\.edgePanelMoving) private var panelMoving + /// Dismiss and Mark complete wait out their Undo note before they reach the decision log. + @ObservedObject private var ledgerUndo = WidgetLedgerUndo.shared + /// The Conversation tab's merged list, rebuilt only when its inputs change. + @State private var timelineCache = TimelineCache() public init( model: WidgetModel, edge: EdgePanelPlacement, cutout: CGFloat = 0, compactHeight: CGFloat = 36, @@ -132,14 +135,14 @@ public struct LiveWidgetView: View { } IconButton(systemName: "xmark", tooltip: "Dismiss this note") { model.notice = nil } } + // A note under the pointer waits to fade. A note closed under the pointer gets no exit event. + .onHover { model.noticeHovered($0) } + .onDisappear { model.noticeHovered(false) } } if let error = model.error { - HStack(alignment: .top) { - Text(error).font(.system(size: 11)).foregroundStyle(.orange).textSelection(.enabled) - .lineLimit(4) - Spacer(minLength: 4) - IconButton(systemName: "xmark", tooltip: "Dismiss this error") { model.error = nil } - } + // One sentence; the raw text (a process's stderr) only behind Details. + NoticePill(text: error, more: model.errorDetail, tone: .error) { model.error = nil } + .accessibilityIdentifier("widget.error") } if model.connectionLost && model.snapshot != nil { HStack { @@ -224,13 +227,22 @@ public struct LiveWidgetView: View { } .modifier(LatestScrollAnchor(enabled: section == "Conversation", identity: model.selectedKey)) // A new view (section or session) starts at its own top: the Sessions list's deep offset, - // kept for the much shorter inbox, drew its rows over the header (Martin, 2026-10-23). - .id(section + "|" + model.selectedKey) + // kept for the much shorter inbox, drew its rows over the header (Martin, 2026-10-23). The + // session list keeps its place when Up and Down pick another session in it. + .id(section == "Sessions" ? section : section + "|" + model.selectedKey) .onChange(of: WidgetOutgoingPosition(session: model.selectedKey, messageID: model.outgoing.last?.id)) { previous, next in guard section == "Inbox", previous.session == next.session, let id = next.messageID, Self.followsOutgoing(model.outgoing.last) else { return } ScrollViewPositioning.scroll(reader, to: "outgoing-" + id, anchor: .bottom) } + // Up and Down move the selection; the list brings the chosen row into view. + .onChange(of: model.listKey) { _, _ in + if section == "Inbox", let id = model.card?.id { + reader.scrollTo("card-" + id) + } else if section == "Sessions" { + reader.scrollTo(model.selectedKey) + } + } .modifier(DocumentHeight(onScrollView: true) { fit.document = $0; reportFit() }) .onGeometryChange(for: CGFloat.self, of: \.size.height) { fit.viewport = $0; reportFit() } .onDisappear { fit.viewport = nil; fit.document = nil; reportFit() } @@ -257,7 +269,7 @@ public struct LiveWidgetView: View { if let url { model.importFile(url) } else if let error { - model.error = error.localizedDescription + model.fail("This file could not be attached.", detail: error.localizedDescription) } } } @@ -326,7 +338,7 @@ public struct LiveWidgetView: View { }.accessibilityLabel("Back to the session") } } else { - IconButton(systemName: "tray.full", tooltip: "Inbox entries from every session") { + IconButton(systemName: "tray.full", tooltip: "Inbox items from every session") { model.inboxOverview = true }.accessibilityLabel("Inbox from every session") if let selected = model.selected { @@ -426,18 +438,17 @@ public struct LiveWidgetView: View { /// Every item of the session as a row; the open row carries the whole card in place. Several answers from one /// session read as a list, not as one card behind a drop-down. - /// The session inbox's filter: every entry, the inbox entries (answers, results, questions) or the decisions. - private var inboxFilter: String { inboxFilterPick ?? model.snapshot?.state.preferences.inboxFilter ?? "all" } - private var inboxFilterBar: some View { VStack(alignment: .leading, spacing: 6) { HStack(spacing: 8) { GenSegmentedTabs( "Inbox filter", - items: [.init("all", "All"), .init("inbox", "Inbox entries"), .init("decisions", "Decisions")], - selection: Binding(get: { inboxFilter }, set: { value in + items: [.init("all", "All"), .init("inbox", "Messages"), .init("decisions", "Decisions")], + // The model holds the filter, so Up and Down walk the rows on screen. + selection: Binding(get: { model.inboxFilter }, set: { value in model.holdAgentFit() - inboxFilterPick = value + model.inboxFilterPicked = true + model.inboxFilter = value }), minSegmentWidth: 64, identifier: "widget.inbox.filter") Spacer() markAllReadButton @@ -446,11 +457,16 @@ public struct LiveWidgetView: View { } } + /// The session's cards, each Dismiss or Mark complete that waits for its Undo note already applied. + private var shownCards: [WidgetCard] { + ledgerUndo.moves.isEmpty ? model.cards : model.cards.map { ledgerUndo.applied($0) } + } + @ViewBuilder private var inbox: some View { if !model.cards.isEmpty { - let shown = Self.filtered(model.cards, by: inboxFilter) + let shown = Self.filtered(shownCards, by: model.inboxFilter) if shown.isEmpty { - Text(inboxFilter == "decisions" ? "No decisions in this session." : "No inbox entries in this session.") + Text(model.inboxFilter == "decisions" ? "No decisions in this session." : "No messages in this session.") .font(.callout).foregroundStyle(.secondary).padding(.vertical, 6) } ForEach(shown.reversed()) { card in @@ -467,7 +483,7 @@ public struct LiveWidgetView: View { } private var inboxHeadline: String { - let cards = model.cards + let cards = shownCards let waiting = cards.filter(\.needsAnswer).count let unread = cards.filter { !$0.read && !$0.needsAnswer }.count return [cards.count == 1 ? "1 item" : "\(cards.count) items", @@ -553,6 +569,19 @@ public struct LiveWidgetView: View { && (model.stagedOption(card.sourceId, stored: card.draftOption) != nil || card.draft != nil) } + /// The key that picks the option at `index` (the open decision's number keys, as the keymap has them), or nil. + static func choiceKey(_ index: Int) -> String? { + guard index < 9 else { return nil } + let label = WidgetKeymap.label(WidgetKeymap.choiceID(index + 1)) + return label.isEmpty ? nil : label + } + + /// Dismiss or Mark complete: the card shows its new state at once, and the decision log hears of it only when + /// the note with Undo goes away. + private func holdMove(_ card: WidgetCard, to state: String) { + ledgerUndo.hold(card, to: state, model: model, reduceMotion: reduceMotion) + } + static func choiceTooltip(_ choice: WidgetChoice, index: Int, stages: Bool, picked: Bool) -> String { let key = index < 9 ? " · key \(index + 1)" : "" if !stages { return "Answer \(choice.id)" + (choice.recommended ? " (recommended)" : "") + key } @@ -566,7 +595,8 @@ public struct LiveWidgetView: View { card.kind == "decision", card.needsAnswer { HStack(spacing: 4) { Image(systemName: "checkmark.circle.fill").foregroundStyle(SessionPalette.blue) - Text(card.draft.map { "Draft saved: " + $0 } ?? "Draft saved").lineLimit(2) + // "Staged", as the option badge and the hub say: a note that waits to be sent with the others. + Text(card.draft.map { "Note staged: " + $0 } ?? "Note staged").lineLimit(2) if model.draftSavedAt[card.id] != nil { Text("·") LiveAgo(date: saved, style: .brief) @@ -597,16 +627,25 @@ public struct LiveWidgetView: View { let staged = model.stagedOption(card.sourceId, stored: card.draftOption) ForEach(Array(card.choices.enumerated()), id: \.element.id) { index, choice in let picked = staged == choice.id + let key = Self.choiceKey(index) Button { model.choose(card, option: choice.id) } label: { HStack(alignment: .top, spacing: 8) { - Text(verbatim: choice.id).font(.system(size: 10.5, weight: .semibold, design: .rounded)) - .frame(width: 20, height: 20).background( + // The badge is the key that picks the option; the letter, which the agent and the + // hub name it by, leads its text. + Text(verbatim: key ?? choice.id).font(.system(size: 10.5, weight: .semibold, design: .rounded)) + .padding(.horizontal, 4) + .frame(minWidth: 20, minHeight: 20).background( .white.opacity(0.07), in: RoundedRectangle(cornerRadius: 5)) - Text(Self.choiceText(choice)).font(.system(size: 12)).fixedSize( - horizontal: false, vertical: true) - .padding(.top, 2) + HStack(alignment: .firstTextBaseline, spacing: 5) { + if key != nil { + Text(verbatim: choice.id + ")").foregroundStyle(SessionPalette.secondary) + } + Text(Self.choiceText(choice)).fixedSize(horizontal: false, vertical: true) + } + .font(.system(size: 12)) + .padding(.top, 2) Spacer(minLength: 0) if picked { Text("Staged").font(.system(size: 10, weight: .semibold)) @@ -637,17 +676,17 @@ public struct LiveWidgetView: View { if card.status == "open" { Button("Acknowledge") { model.ledger(card, state: "acknowledged") } } - Button("Mark complete") { model.ledger(card, state: "implemented") } - Button("Dismiss") { model.ledger(card, state: "dismissed") } + Button("Mark complete") { holdMove(card, to: "implemented") } + Button("Dismiss") { holdMove(card, to: "dismissed") } } else if card.kind == "decision" && card.needsAnswer { - Button("Save draft") { model.saveDraft(card) } - .instantTooltip("Keeps your text as this decision's note, staged with your other decisions.") + Button("Stage note") { model.saveDraft(card) } + .instantTooltip("Stages your text as this decision's note, sent later with your other staged decisions.") if staged(card) { Button("Send now") { model.sendStaged([card.sourceId]) } .instantTooltip("Sends this staged decision alone, without the others.") Button("Unstage") { model.discardStaged([card.sourceId]) } } - Button("Dismiss") { model.ledger(card, state: "dismissed") } + Button("Dismiss") { holdMove(card, to: "dismissed") } } if card.kind == "result" || card.kind == "answer" { Button("Create handoff") { model.destination("handoff") } @@ -740,13 +779,39 @@ public struct LiveWidgetView: View { case "decision": if let replacement = card.supersededBy { return "Decision · Superseded by DECISION \(replacement.number)" } if let option = card.option, !option.isEmpty, ["acknowledged", "implemented", "sent", "answered"].contains(card.status) { - return "Decision · Answered \(option) · " + card.status.capitalized + return "Decision · Answered \(option) · " + statusPhrase(card.status, kind: card.kind) } - return "Decision · " + card.status.capitalized - default: return card.kind.capitalized + " · " + card.status.capitalized + return "Decision · " + statusPhrase(card.status, kind: card.kind) + case "form": return "Question · " + statusPhrase(card.status, kind: card.kind) + default: return sentenceCase(card.kind) + " · " + statusPhrase(card.status, kind: card.kind) } } + /// One phrase for each state of the decision log and the question forms, the same in every tab and row. + static func statusPhrase(_ status: String, kind: String) -> String { + if kind == "todo" { return WidgetTask.label(for: status) } + switch status { + case "open", "pending": return "Waiting for your answer" + case "drafted": return "Staged" + // A decision's answer is chosen and waits for its delivery; a form's answer is the end. + case "answered": return kind == "decision" ? "Waiting to send" : "Answered" + case "sent": return "Sent" + case "acknowledged": return "Acknowledged" + case "implemented": return "Implemented" + case "dismissed": return "Dismissed" + case "superseded": return "Superseded" + case "timeout": return "No answer in time" + case "cancelled": return "Withdrawn" + default: return sentenceCase(status) + } + } + + /// "waiting-route" reads "Waiting route", never "Waiting-Route". + static func sentenceCase(_ value: String) -> String { + let words = value.replacingOccurrences(of: "-", with: " ").replacingOccurrences(of: "_", with: " ") + return words.prefix(1).uppercased() + words.dropFirst() + } + /// A collapsed row's one line: the body's first line of prose, without Markdown marks. static func previewLine(_ body: String) -> String? { for line in body.split(separator: "\n", omittingEmptySubsequences: true) { @@ -758,7 +823,7 @@ public struct LiveWidgetView: View { return nil } - /// The option text without a letter the agent already wrote ("a) Record…"); the badge shows the letter. + /// The option text without a letter the agent already wrote ("a) Record…"); the row draws the letter itself. static func choiceText(_ choice: WidgetChoice) -> String { let title = choice.title.trimmingCharacters(in: .whitespaces) for prefix in [choice.id + ")", "(" + choice.id + ")", choice.id + ".", choice.id + ":"] { @@ -800,6 +865,10 @@ public struct LiveWidgetView: View { .white.opacity(selected ? 0.12 : 0.045), in: RoundedRectangle(cornerRadius: 8)) .contentShape(RoundedRectangle(cornerRadius: 8)) }.buttonStyle(RowButtonStyle(cornerRadius: 8)) + // The circle shows the pick; VoiceOver hears it as the selected trait and a value. + .accessibilityLabel(choice.label) + .accessibilityValue(selected ? "Selected" : "Not selected") + .accessibilityAddTraits(selected ? .isSelected : []) } if item.allowFreeText != false { TextField( @@ -821,6 +890,9 @@ public struct LiveWidgetView: View { } @ViewBuilder private var conversation: some View { + let timeline = timelineCache.items(for: TimelineCache.Inputs( + transcript: model.transcript, cards: shownCards, outgoing: model.outgoing, + activity: model.sessionActivity, provider: model.selected?.target.provider ?? "")) if let error = model.transcriptError { Text(error).font(.caption).foregroundStyle(.orange).textSelection(.enabled) } @@ -842,8 +914,7 @@ public struct LiveWidgetView: View { } case .card(let card): VStack(alignment: .leading, spacing: 6) { - Text(card.kind.capitalized + " · " + card.status).font(.caption2).foregroundStyle( - .secondary) + Text(Self.kindLabel(card)).font(.caption2).foregroundStyle(.secondary) Text(card.numberedTitle).font(.system(size: 12, weight: .semibold)).textSelection(.enabled) if !card.body.isEmpty { WidgetMarkdown(text: card.body) @@ -861,14 +932,18 @@ public struct LiveWidgetView: View { }.padding(10).background(.white.opacity(0.04), in: RoundedRectangle(cornerRadius: 11)) case .outgoing(let message): outgoing(message) - case .activity(let event): + case .activity(let line): HStack(alignment: .top, spacing: 8) { Image(systemName: "clock.arrow.circlepath").foregroundStyle(.secondary) + .accessibilityHidden(true) VStack(alignment: .leading, spacing: 3) { - Text(event.title).font(.caption) - Text(event.sourceId + " · " + event.body).font(.caption2).foregroundStyle(.secondary) + Text(line.title).font(.caption) + if !line.detail.isEmpty { + Text(line.detail).font(.caption2).foregroundStyle(.secondary) + } } } + .accessibilityElement(children: .combine) } } } @@ -879,40 +954,113 @@ public struct LiveWidgetView: View { } } - private struct TimelineItem: Identifiable { + fileprivate struct TimelineItem: Identifiable { enum Event { case turn(TranscriptTurn) case card(WidgetCard) case outgoing(WidgetOutgoing) - case activity(WidgetActivityEvent) + case activity(ActivityLine) } let id: String let date: Date let event: Event } - private var timeline: [TimelineItem] { - let iso = ISO8601DateFormatter() - iso.formatOptions = [.withInternetDateTime, .withFractionalSeconds] - let plain = ISO8601DateFormatter() - let turns = model.transcript.filter { !$0.text.isEmpty }.map { turn in - TimelineItem( - id: "turn:" + turn.id, - date: turn.at.flatMap { iso.date(from: $0) ?? plain.date(from: $0) } ?? .distantPast, - event: .turn(turn)) + + /// A decision event in words, for the Conversation tab. + struct ActivityLine: Equatable { + let title: String + let detail: String + } + + /// The Conversation tab's list: turns, cards, outgoing messages and decision events in time order. The body runs + /// on every publish of the model (a voice level, a hover), so the merge, the date parsing and the sort run only + /// when an input changed. A plain reference in `@State`, so a rebuild never re-renders. + @MainActor + private final class TimelineCache { + struct Inputs: Equatable { + var transcript: [TranscriptTurn] + var cards: [WidgetCard] + var outgoing: [WidgetOutgoing] + var activity: [WidgetActivityEvent] + var provider: String } - let cards = model.cards.map { - TimelineItem(id: $0.id, date: Date(timeIntervalSince1970: $0.at / 1000), event: .card($0)) + private var inputs: Inputs? + private var items: [TimelineItem] = [] + + func items(for next: Inputs) -> [TimelineItem] { + if next != inputs { + items = Self.merged(next) + inputs = next + } + return items } - let outgoing = model.outgoing.map { - TimelineItem( - id: "out:" + $0.id, date: Date(timeIntervalSince1970: $0.createdAt / 1000), - event: .outgoing($0)) + + private static func merged(_ inputs: Inputs) -> [TimelineItem] { + let turns = inputs.transcript.filter { !$0.text.isEmpty }.map { turn in + TimelineItem(id: "turn:" + turn.id, date: SessionFormat.parseISO(turn.at) ?? .distantPast, + event: .turn(turn)) + } + let cards = inputs.cards.map { + TimelineItem(id: $0.id, date: Date(timeIntervalSince1970: $0.at / 1000), event: .card($0)) + } + let outgoing = inputs.outgoing.map { + TimelineItem( + id: "out:" + $0.id, date: Date(timeIntervalSince1970: $0.createdAt / 1000), + event: .outgoing($0)) + } + let numbers = Dictionary(inputs.cards.compactMap { card in card.number.map { (card.sourceId, $0) } }, + uniquingKeysWith: { first, _ in first }) + let activity = inputs.activity.map { event in + TimelineItem( + id: "event:" + event.id, date: Date(timeIntervalSince1970: event.at / 1000), + event: .activity(LiveWidgetView.activityLine( + event, number: numbers[event.sourceId], provider: inputs.provider))) + } + return (turns + cards + outgoing + activity).sorted { + $0.date == $1.date ? $0.id < $1.id : $0.date < $1.date + } } - let activity = model.sessionActivity.map { - TimelineItem(id: "event:" + $0.id, date: Date(timeIntervalSince1970: $0.at / 1000), event: .activity($0)) + } + + /// A decision event from the log: what happened, then which decision (its number when the session's card is + /// known, else the session's provider) and the state and route it reached. The wire carries the log's raw names + /// ("Decision send_failed", "answered · cmux") and the row id, which mean nothing to the reader. + static func activityLine(_ event: WidgetActivityEvent, number: Int?, provider: String) -> ActivityLine { + let prefix = "Decision " + let name = event.title.hasPrefix(prefix) ? String(event.title.dropFirst(prefix.count)) : "" + let title: String + switch name { + case "created": title = "Decision posted" + case "updated": title = "Decision updated" + case "superseded": title = "Decision superseded" + case "harvested": title = "Decision found in the conversation" + case "delivery": title = "Answer delivered" + case "stale": title = "Reminder sent: the decision still waits" + case "send_failed": title = "Answer not delivered" + default: title = sentenceCase(event.title) } - return (turns + cards + outgoing + activity).sorted { - $0.date == $1.date ? $0.id < $1.id : $0.date < $1.date + let decision = number.map { "DECISION \($0)" } ?? AIProviders.meta(for: provider).displayName + let facts = event.body.components(separatedBy: " · ").compactMap { part -> String? in + routePhrase(part) ?? (decisionStates.contains(part) ? statusPhrase(part, kind: "decision") : nil) + } + return ActivityLine(title: title, detail: ([decision] + facts).filter { !$0.isEmpty }.joined(separator: " · ")) + } + + /// The states of the decision log (`src/question/lib/decisions/store.ts`). + private static let decisionStates: Set = [ + "open", "drafted", "answered", "sent", "acknowledged", "implemented", "dismissed", "superseded", + ] + + /// How an answer reached its agent (the log's `delivery.route`). + static func routePhrase(_ route: String) -> String? { + switch route { + case "cmux": return "through cmux" + case "codex": return "through Codex" + case "claude-peer": return "as a Claude message" + case "prompt": return "as a new prompt" + case "queued": return "queued for the session" + case "resume": return "by resuming the session" + default: return nil } } @@ -962,7 +1110,9 @@ public struct LiveWidgetView: View { Spacer(minLength: 0) }.foregroundStyle(message.state == "sent" ? .green : .secondary) if let error = message.error, !(message.state == "waiting-route" && message.receipt?.channel == "session-queue") { - Text(error).font(.caption2).foregroundStyle(.orange).textSelection(.enabled) + // What happened in one sentence; the transport's own text only behind Details. + NoticePill(text: Self.outgoingProblem(message), more: error, + tone: message.state == "failed" ? .error : .warning) } if message.isWithdrawable || message.needsRecovery { HStack { @@ -1051,27 +1201,25 @@ public struct LiveWidgetView: View { .instantTooltip(ownAnswer == nil ? "Send (Return)" : "Send as your answer (Return)") .accessibilityLabel(ownAnswer == nil ? "Send reply" : "Send your answer") } - HStack(spacing: 15) { - Button(action: model.chooseFiles) { Image(systemName: "paperclip") }.instantTooltip( - "Attach images or videos" - ).accessibilityLabel("Attach media") - Button { + // Each icon is the kit's 22 pt target (IconButton), with 4 pt between neighbours instead of a 15 pt strip + // where a click did nothing. + HStack(spacing: 4) { + IconButton(systemName: "paperclip", tooltip: "Attach images or videos", action: model.chooseFiles) + .accessibilityLabel("Attach media") + IconButton(systemName: "clipboard", tooltip: WidgetKeymap.hint("Paste image or video", WidgetKeymap.paste)) { _ = model.pasteMedia() - } label: { - Image(systemName: "clipboard") - }.instantTooltip(WidgetKeymap.hint("Paste image or video", WidgetKeymap.paste)).accessibilityLabel("Paste media") - Button(action: model.capture) { Image(systemName: "camera") }.instantTooltip("Capture a screenshot") - .accessibilityLabel("Capture screenshot") - Button(action: model.toggleVideoCapture) { - Image(systemName: model.recordingVideo ? "stop.circle.fill" : "video") } - .foregroundStyle(model.recordingVideo ? .red : .secondary) - .instantTooltip(model.recordingVideo ? "Stop the recording and paste it into the draft" - : "Record a window, an app or a display (up to 10 min)") - .accessibilityLabel(model.recordingVideo ? "Stop and paste the recording" : "Record the screen") + .accessibilityLabel("Paste media") + IconButton(systemName: "camera", tooltip: "Capture a screenshot", action: model.capture) + .accessibilityLabel("Capture screenshot") + IconButton(systemName: model.recordingVideo ? "stop.circle.fill" : "video", + tooltip: model.recordingVideo ? "Stop the recording and paste it into the draft" + : "Record a window, an app or a display (up to 10 min)", + tint: model.recordingVideo ? Color.red : nil, action: model.toggleVideoCapture) + .accessibilityLabel(model.recordingVideo ? "Stop and paste the recording" : "Record the screen") voiceButton - Spacer() - Text(WidgetKeymap.mediaFooter).font(.system(size: 9)).foregroundStyle(.tertiary) + Spacer(minLength: 8) + composerKeys }.font(.system(size: 12)).foregroundStyle(.secondary).buttonStyle(.genHoverIcon()) } .padding(11).background(.white.opacity(0.065), in: RoundedRectangle(cornerRadius: 13)) @@ -1082,8 +1230,27 @@ public struct LiveWidgetView: View { .frameProbe("widget.composer") } + /// The composer's keys, in a hint beside the field: Return sends, Option-Return adds a line (SwiftUI's own key + /// for a vertical field with `onSubmit`), and the paste and close keys in force. A narrow card (an own answer) + /// drops the last ones first. Text the user reads: 11 pt at 70% white. + private var composerKeys: some View { + let paste = WidgetKeymap.label(WidgetKeymap.paste) + let close = WidgetKeymap.label(WidgetKeymap.collapse) + let base = "↩ send · ⌥↩ new line" + let media = paste.isEmpty ? base : base + " · \(paste) media" + return ViewThatFits(in: .horizontal) { + Text(close.isEmpty ? media : media + " · \(close) close") + Text(media) + Text(base) + } + .font(.system(size: 11)).foregroundStyle(SessionPalette.secondary).lineLimit(1) + .layoutPriority(1) + .accessibilityElement(children: .ignore) + .accessibilityLabel("Return sends. Option Return adds a new line.") + } + /// The microphone: Stop while a dictation runs; otherwise a menu of the two modes when the setting asks, else the - /// set mode at once. + /// set mode at once. Both are drawn buttons with the kit's hover and hand cursor (a SwiftUI `Menu` has neither). @ViewBuilder private var voiceButton: some View { let icon = Image(systemName: model.voiceActive ? "stop.circle.fill" : "mic") .padding(.horizontal, 8).padding(.vertical, 4) @@ -1093,14 +1260,12 @@ public struct LiveWidgetView: View { .foregroundStyle(model.voiceActive ? .red : .secondary) .instantTooltip(model.voiceActionLabel).accessibilityLabel(model.voiceActionLabel) } else { - Menu { - Button("Dictate into the reply box", systemImage: "text.cursor") { model.startVoice(.dictate) } - Button("Voice note into the reply box (keeps the recording)", systemImage: "waveform") { - model.startVoice(.note) - } - .disabled(model.voiceNoteClip == nil) - } label: { icon } - .menuStyle(.borderlessButton).menuIndicator(.hidden).fixedSize() + MenuButton(style: .genHoverIcon(), items: { + [.action("Dictate into the reply box") { model.startVoice(.dictate) }, + .action("Voice note into the reply box (keeps the recording)", enabled: model.voiceNoteClip != nil) { + model.startVoice(.note) + }] + }) { icon } .modifier(WidgetGlassControl()) .foregroundStyle(.secondary) .instantTooltip(model.voiceActionLabel).accessibilityLabel(model.voiceActionLabel) @@ -1130,7 +1295,19 @@ public struct LiveWidgetView: View { case "waiting-route": return message.receipt?.channel == "session-queue" ? "Queued for this session · waiting for the agent" : "Waiting for an available session route" case "unknown": return "Delivery unknown · check the conversation" - default: return message.state.capitalized + case "failed": return "Not delivered" + default: return Self.sentenceCase(message.state) + } + } + + /// The sentence over a message's raw delivery error: what it means for the message and what the buttons under + /// it can do. + static func outgoingProblem(_ message: WidgetOutgoing) -> String { + switch message.state { + case "failed": return "The agent did not get this message. Retry it, or choose another destination." + case "waiting-route": return "No session can take this message yet. It goes out when one can." + case "unknown": return "No receipt came back. Check the conversation before you retry." + default: return "The last attempt to send this message did not work." } } private func showMedia(_ selection: WidgetMediaSelection) { @@ -1174,6 +1351,118 @@ private struct DocumentHeight: ViewModifier { } } +/// Dismiss and Mark complete in a session's inbox wait for their Undo note before they reach the decision log. The +/// log has no move back from a dismissed decision, and the widget's `ledger` action cannot reopen a task, so the +/// change is held here instead of undone there: the card shows its new state at once, Undo drops the change, and the +/// change goes to the log when the note goes away (it faded after the note duration setting, was closed, or a newer +/// note replaced it). Shared by every panel of the widget, and kept while a panel is closed. +@MainActor +final class WidgetLedgerUndo: ObservableObject { + static let shared = WidgetLedgerUndo() + /// The longest a change waits, even under a note that stays until it is closed: a change still held when the app + /// quits is lost, so it is not held for long. + static let longestHold: Duration = .seconds(60) + + struct Move: Equatable { + let state: String + /// The card's revision when the user acted. A snapshot with a newer one shows the card as it is. + let revision: Int + } + + private struct Hold { + let move: Move + let request: WidgetJSON + let note: String + var watch: AnyCancellable? + var deadline: Task? + } + + /// Card id to the move the card shows. + @Published private(set) var moves: [String: Move] = [:] + private var holds: [String: Hold] = [:] + + /// `card` with its held or just committed move applied. + func applied(_ card: WidgetCard) -> WidgetCard { + guard let move = moves[card.id], move.revision == card.revision else { return card } + var shown = card + shown.status = move.state + return shown + } + + /// Moves `card` to `state` ("dismissed", "implemented") on screen now, and in the log when its note goes. + func hold(_ card: WidgetCard, to state: String, model: WidgetModel, reduceMotion: Bool) { + // The session is read now: the user may pick another one before the note goes. + guard let session = model.selected?.target.sessionId, let revision = card.revision else { return } + let id = card.id + let move = Move(state: state, revision: revision) + let note = Self.note(card, state: state) + var hold = Hold( + move: move, + request: .object([ + "action": "ledger", "id": .string(card.sourceId), "sessionId": .string(session), + "expectedRevision": .number(Double(revision)), "state": .string(state), + ]), + note: note) + moves[id] = move + // Folded, the card is no longer the decision the number keys and the composer answer. + GenMotion.perform(expanding: false, reduceMotion: reduceMotion) { _ = model.foldedCardIDs.insert(id) } + model.postNote(note, action: WidgetNoticeAction(title: "Undo") { [weak self, weak model] in + guard let self, let model else { return } + self.undo(id, model: model, reduceMotion: reduceMotion) + }) + // The current value is this note; any later one (nil, or another note) means it went away. + hold.watch = model.$notice.dropFirst().sink { [weak self, weak model] text in + guard text != note else { return } + Task { @MainActor [weak self, weak model] in + guard let self, let model else { return } + self.commit(id, model: model) + } + } + hold.deadline = Task { [weak self, weak model] in + do { try await Task.sleep(for: Self.longestHold) } catch { return } + guard let self, let model else { return } + self.commit(id, model: model) + } + holds[id] = hold + } + + /// "DECISION 4 dismissed.", "Task 2 completed.": what changed, in the note that offers Undo. + static func note(_ card: WidgetCard, state: String) -> String { + let name = card.kind == "todo" + ? card.number.map { "Task \($0)" } ?? "Task" + : card.number.map { "DECISION \($0)" } ?? "Decision" + return name + (state == "implemented" ? " completed." : " dismissed.") + } + + private func commit(_ id: String, model: WidgetModel) { + guard let hold = holds.removeValue(forKey: id) else { return } + hold.watch?.cancel() + hold.deadline?.cancel() + // Its Undo could not take the change back any more. + if model.notice == hold.note { model.notice = nil } + model.action(hold.request, failed: { [weak self] in + // The log refused it (the item changed elsewhere): the card shows its real state again, and the error + // line says why. + if self?.moves[id] == hold.move { self?.moves[id] = nil } + }, completed: { [weak self] in + // The card keeps its new look until a snapshot carries the newer revision; then the entry is inert. + Task { @MainActor [weak self] in + try? await Task.sleep(for: .seconds(30)) + if self?.moves[id] == hold.move { self?.moves[id] = nil } + } + }) + } + + private func undo(_ id: String, model: WidgetModel, reduceMotion: Bool) { + guard let hold = holds.removeValue(forKey: id) else { return } + hold.watch?.cancel() + hold.deadline?.cancel() + moves[id] = nil + GenMotion.perform(expanding: true, reduceMotion: reduceMotion) { _ = model.foldedCardIDs.remove(id) } + model.postNote("Undone.") + } +} + /// Measured heights of LiveWidgetView's pane, its scroll viewport and the scroll document. final class WidgetContentFit { static let placeholderHeight: CGFloat = 360 diff --git a/src/macos/GenesisKit/Sources/GenesisKit/Widgets/Shelf/WidgetShelfModules.swift b/src/macos/GenesisKit/Sources/GenesisKit/Widgets/Shelf/WidgetShelfModules.swift index 4665196df8..8ea71bf1dc 100644 --- a/src/macos/GenesisKit/Sources/GenesisKit/Widgets/Shelf/WidgetShelfModules.swift +++ b/src/macos/GenesisKit/Sources/GenesisKit/Widgets/Shelf/WidgetShelfModules.swift @@ -4,7 +4,7 @@ public extension WidgetShelfStore { func captureModule() -> WidgetModuleDescriptor { WidgetModuleDescriptor( id: "capture", title: "Capture", symbol: "viewfinder", tint: .orange, - summary: { self.isCapturing ? "Select an area…" : "\(self.captures.count) captures" }, + summary: { self.isCapturing ? "Select an area…" : Plural.count(self.captures.count, "capture") }, visibilityChanged: { self.visibilityChanged(module: "capture", presentation: $0) } ) { WidgetShelfModuleView(store: self, captureOnly: true, presentation: $0) } } @@ -12,7 +12,7 @@ public extension WidgetShelfStore { func shelfModule() -> WidgetModuleDescriptor { WidgetModuleDescriptor( id: "shelf", title: "File Shelf", symbol: "tray.full", tint: .cyan, - summary: { "\(self.items.count) items" }, + summary: { Plural.count(self.items.count, "item") }, visibilityChanged: { self.visibilityChanged(module: "shelf", presentation: $0) } ) { WidgetShelfModuleView(store: self, captureOnly: false, presentation: $0) } } @@ -41,7 +41,7 @@ struct WidgetShelfModuleView: View { if presentation == .compact { HStack(spacing: 7) { Image(systemName: symbol).foregroundStyle(tint) - Text(store.isCapturing ? "Select an area…" : "\(displayedItems.count) \(captureOnly ? "captures" : "items")") + Text(store.isCapturing ? "Select an area…" : Plural.count(displayedItems.count, captureOnly ? "capture" : "item")) .font(.system(size: 11, weight: .medium)) } } else { @@ -76,7 +76,9 @@ struct WidgetShelfModuleView: View { Image(systemName: symbol).foregroundStyle(tint) Text(title).font(.system(size: 15, weight: .semibold)) Spacer() - Text("\(displayedItems.count) staged").font(.system(size: 10)).foregroundStyle(.secondary) + // "Staged" is the inbox's word for a decision answer kept to send later; the shelf keeps items. + Text(Plural.count(displayedItems.count, captureOnly ? "capture" : "item")) + .font(.system(size: 11)).foregroundStyle(.secondary) } } @@ -119,6 +121,19 @@ struct WidgetShelfModuleView: View { if let error = store.error { Text(error).font(.system(size: 11)).foregroundStyle(KitPalette.removed) .fixedSize(horizontal: false, vertical: true) + } else if let removed = store.removed, !captureOnly || removed.kind == .capture { + HStack(spacing: 8) { + Text(removed.kind == .capture ? "\(removed.name) was removed." + : "\(removed.name) was removed from the shelf. The original file is kept.") + .font(.system(size: 11)).foregroundStyle(.secondary) + .lineLimit(2).truncationMode(.middle) + Spacer(minLength: 4) + Button("Undo", action: store.undoRemove) + .buttonStyle(.genHover()).font(.system(size: 11, weight: .medium)) + .keyboardShortcut("z", modifiers: .command) + .instantTooltip("Puts it back (⌘Z)") + .accessibilityIdentifier("shelf-undo-remove") + } } else if let notice = store.notice { Text(notice).font(.system(size: 11)).foregroundStyle(.secondary) .fixedSize(horizontal: false, vertical: true) diff --git a/src/macos/GenesisKit/Sources/GenesisKit/Widgets/Shelf/WidgetShelfStore.swift b/src/macos/GenesisKit/Sources/GenesisKit/Widgets/Shelf/WidgetShelfStore.swift index a160945c7d..5baf0243a6 100644 --- a/src/macos/GenesisKit/Sources/GenesisKit/Widgets/Shelf/WidgetShelfStore.swift +++ b/src/macos/GenesisKit/Sources/GenesisKit/Widgets/Shelf/WidgetShelfStore.swift @@ -55,10 +55,21 @@ struct WidgetShelfSnapshot: Decodable, Sendable { public final class WidgetShelfStore: ObservableObject { @Published public private(set) var items: [WidgetShelfItem] = [] @Published public private(set) var isBusy = false - @Published public private(set) var isCapturing = false + /// The area selection is up; VoiceOver hears it, since the screen changes without a click in the shelf. + @Published public private(set) var isCapturing = false { + didSet { if isCapturing, !oldValue { announce("Select an area to capture. Press Escape to cancel.", .medium) } } + } @Published public private(set) var captureAccessGranted: Bool? - @Published public private(set) var error: String? - @Published public private(set) var notice: String? + /// The status line's failure; it arrives after the action that caused it, so VoiceOver hears it. + @Published public private(set) var error: String? { + didSet { if let error, error != oldValue { announce(Self.spoken(error), .high) } } + } + @Published public private(set) var notice: String? { + didSet { if let notice, notice != oldValue { announce(notice, .medium) } } + } + /// The item the user just removed: gone from the list, still on the shelf until `removalUndoDelay` runs out, so + /// the status line's Undo can bring it back (ux checklist "Undo and destructive actions"). + @Published public private(set) var removed: WidgetShelfItem? /// True once the first inventory read finished, so a view can tell a first load from an item arriving. @Published public private(set) var loaded = false /// A screen recording started from the Capture shelf runs (from its picker until it ends). @@ -88,6 +99,13 @@ public final class WidgetShelfStore: ObservableObject { private var isChoosingFiles = false private var filePicker: NSOpenPanel? private var stopped = false + private let announce: @MainActor (String, GenAnnounce.Priority) -> Void + private let removalUndoDelay: Duration + /// Where the removed item stood, so Undo puts it back in place. + private var removedIndex = 0 + private var removalTimer: Task? + /// The CLI removal once the Undo ran out; teardown waits for it. + private var removal: Task? public init( binaryPath: String, stateRoot: String? = nil, @@ -100,6 +118,8 @@ public final class WidgetShelfStore: ObservableObject { ) { screenCaptureAccess = { PermissionAccess.live.isGranted(.screenRecording) } permissionGate = { await PermissionCenter.shared.ensure($0) } + announce = { GenAnnounce.post($0, priority: $1) } + removalUndoDelay = .seconds(10) let bridge = ToolsBridge(binaryPath: binaryPath) self.request = { arguments, timeout in var prefix = ["widget"] @@ -131,9 +151,13 @@ public final class WidgetShelfStore: ObservableObject { onCaptureWillBegin: @escaping () -> Void = {}, onCaptureStaged: @escaping () -> Void = {}, onDialogVisibilityChanged: @escaping (Bool) -> Void = { _ in }, - attachToDraft: ((WidgetShelfItem, WidgetSession) async throws -> Void)? = nil + attachToDraft: ((WidgetShelfItem, WidgetSession) async throws -> Void)? = nil, + announce: @escaping @MainActor (String, GenAnnounce.Priority) -> Void = { _, _ in }, + removalUndoDelay: Duration = .seconds(10) ) { self.request = request + self.announce = announce + self.removalUndoDelay = removalUndoDelay self.screenRecorder = screenRecorder self.screenCaptureAccess = screenCaptureAccess self.permissionGate = permissionGate @@ -148,6 +172,8 @@ public final class WidgetShelfStore: ObservableObject { /// Coordinator teardown is terminal for this store; hiding a module leaves active staging alone. public func stop() { guard !stopped else { return } + // The user removed it; teardown must not put it back on the shelf. + commitRemoval() stopped = true visibleModules.removeAll() watcher?.stop() @@ -196,9 +222,11 @@ public final class WidgetShelfStore: ObservableObject { let data = try await self.request(["list", "--json"], 15) guard !self.stopped, !Task.isCancelled else { return } let snapshot = try JSONDecoder().decode(WidgetShelfSnapshot.self, from: data) - if self.items != snapshot.items { - self.captureItems = snapshot.items.filter { $0.kind == .capture } - self.items = snapshot.items + // A removed item stays on disk during its Undo; it stays off the list. + let shown = snapshot.items.filter { $0.id != self.removed?.id } + if self.items != shown { + self.captureItems = shown.filter { $0.kind == .capture } + self.items = shown } self.statePath = snapshot.statePath if !self.loaded { self.loaded = true } @@ -256,7 +284,7 @@ public final class WidgetShelfStore: ObservableObject { private var screenRecordingNeed: PermissionNeed { PermissionNeed( .screenRecording, - reason: "Capture takes a screenshot of the area you select and stages it in the widget.", + reason: "Capture takes a screenshot of the area you select and keeps it in the widget until you choose an inbox.", grantWorksInNewProcess: true, onGranted: { [weak self] in self?.capture() }) } @@ -267,7 +295,7 @@ public final class WidgetShelfStore: ObservableObject { guard !stopped, !isBusy else { return } isCapturing = true onCaptureWillBegin() - start(success: "Screenshot staged. Choose an inbox when you are ready.") { + start(success: "Screenshot added. Choose an inbox when you are ready.") { do { let data = try await self.request(["capture"], 125) if try JSONDecoder().decode(WidgetShelfCaptureResponse.self, from: data).cancelled == true { @@ -337,12 +365,12 @@ public final class WidgetShelfStore: ObservableObject { let stamp = Date().formatted(.dateTime.hour().minute().second()) let data = try await request(["video", movie.path, "--name", "Screen recording \(stamp).mp4"], 300) let item = try JSONDecoder().decode(WidgetShelfItem.self, from: data) - guard item.kind == .capture else { throw ToolsBridgeError.refused("The recording was not staged as a capture.") } + guard item.kind == .capture else { throw ToolsBridgeError.refused("The recording could not be added as a capture.") } do { try FileManager.default.removeItem(at: movie) } catch { GenesisKit.log("widget shelf: recording cleanup \(error.localizedDescription)") } guard !stopped else { return } - notice = "Recording staged. Choose an inbox when you are ready." + notice = "Recording added. Choose an inbox when you are ready." refresh() onCaptureStaged() } @@ -350,10 +378,10 @@ public final class WidgetShelfStore: ObservableObject { public func importFiles(_ urls: [URL], asImages: Bool = false) { guard !stopped, !isBusy, !urls.isEmpty else { return } guard urls.allSatisfy(\.isFileURL) else { - error = "Only local files can be staged." + error = "Only local files can be added." return } - start(success: urls.count == 1 ? "Item staged." : "\(urls.count) items staged.") { + start(success: urls.count == 1 ? "Item added." : "\(urls.count) items added.") { var failures: [String] = [] for url in urls { try Task.checkCancellation() @@ -379,7 +407,7 @@ public final class WidgetShelfStore: ObservableObject { filePicker = panel panel.canChooseDirectories = false panel.allowsMultipleSelection = true - panel.prompt = asImages ? "Stage images" : "Add to shelf" + panel.prompt = asImages ? "Add images" : "Add to shelf" if asImages { panel.allowedContentTypes = [.image] } panel.begin { [weak self] response in Task { @MainActor in @@ -411,7 +439,7 @@ public final class WidgetShelfStore: ObservableObject { error = "Copy a file or image first." return } - start(success: "Clipboard image staged.") { + start(success: "Clipboard image added.") { let directory = FileManager.default.temporaryDirectory.appendingPathComponent("widget-paste-\(UUID().uuidString)", isDirectory: true) let temporary = directory.appendingPathComponent("Clipboard image.png") try await Task.detached(priority: .userInitiated) { @@ -430,12 +458,63 @@ public final class WidgetShelfStore: ObservableObject { } } + /// Takes `item` off the list at once and removes it from the shelf after `removalUndoDelay`, unless Undo brings + /// it back first. Removing another item removes the first one now. public func remove(_ item: WidgetShelfItem) { - start(success: "Removed from the shelf. Files and existing drafts are preserved.") { - _ = try await self.request(["remove", item.id], 15) + guard !stopped, !isBusy, let index = items.firstIndex(where: { $0.id == item.id }) else { return } + commitRemoval() + removed = item + removedIndex = index + items.remove(at: index) + captureItems.removeAll { $0.id == item.id } + error = nil + // A capture has no original elsewhere; a file does. + notice = item.kind == .capture ? "Removed from Capture. Drafts that already use it keep it." + : "Removed from the shelf. The original file and existing drafts are kept." + let delay = removalUndoDelay + removalTimer = Task { [weak self] in + do { try await Task.sleep(for: delay) } catch { return } + self?.commitRemoval() + } + } + + /// Undo of Remove from shelf: the item comes back in its place. + public func undoRemove() { + guard let item = removed else { return } + removalTimer?.cancel() + removalTimer = nil + removed = nil + if !items.contains(where: { $0.id == item.id }) { + items.insert(item, at: min(removedIndex, items.count)) + captureItems = items.filter { $0.kind == .capture } + } + notice = "\(item.name) is back" + (item.kind == .capture ? "." : " on the shelf.") + refresh() + } + + /// Removes the item for good: its Undo ran out, another item was removed, or the store stops. + private func commitRemoval() { + guard let item = removed else { return } + removalTimer?.cancel() + removalTimer = nil + removed = nil + let previous = removal + // The request is held, not the store: a removal that runs at teardown still reaches the CLI. + let request = request + removal = Task { [weak self] in + await previous?.value + do { + _ = try await request(["remove", item.id], 15) + } catch { + // Still on the shelf: say why; the list read below shows it again. + if let self, !self.stopped { self.report(error) } + } + if let self, !self.stopped { self.refresh() } } } + func waitForRemoval() async { await removal?.value } + public func attach(_ item: WidgetShelfItem, to recipient: WidgetSession) { start(success: "Added to inbox draft. Nothing has been sent.") { if let attachToDraft = self.attachToDraft { @@ -490,6 +569,12 @@ public final class WidgetShelfStore: ObservableObject { GenesisKit.log("widget shelf: \(error.localizedDescription)") } + /// The first line of an error, short enough for VoiceOver to read out. + nonisolated static func spoken(_ error: String) -> String { + let line = error.split(whereSeparator: \.isNewline).first.map(String.init) ?? error + return line.count > 160 ? String(line.prefix(157)) + "…" : line + } + deinit { watcher?.stop() refreshTask?.cancel() diff --git a/src/macos/GenesisKit/Sources/GenesisKit/Widgets/Tasks/WidgetTasksModule.swift b/src/macos/GenesisKit/Sources/GenesisKit/Widgets/Tasks/WidgetTasksModule.swift index fcc901c919..877df01f5b 100644 --- a/src/macos/GenesisKit/Sources/GenesisKit/Widgets/Tasks/WidgetTasksModule.swift +++ b/src/macos/GenesisKit/Sources/GenesisKit/Widgets/Tasks/WidgetTasksModule.swift @@ -381,7 +381,7 @@ private struct WidgetTaskRow: View { if task.isLocal { Text("Written in the widget").font(.caption2).foregroundStyle(.secondary) } else { - Text([task.provider.capitalized, task.sessionTitle ?? task.sessionId, task.sourceContext.aiAgent] + Text([task.provider.capitalized, task.sessionTitle ?? "Untitled session", task.sourceContext.aiAgent] .compactMap { $0 }.joined(separator: " · ")) .font(.caption2).foregroundStyle(.secondary).textSelection(.enabled) if let cwd = task.sourceContext.cwd { @@ -400,7 +400,7 @@ private struct WidgetTaskRow: View { private func menuItems() -> [MenuButtonItem] { var items: [MenuButtonItem] = task.actions.map { action in - .action(action == .acknowledge ? "Start (acknowledge)" : action.title) { store.perform(action, on: task) } + .action(action == .acknowledge ? "Start" : action.title) { store.perform(action, on: task) } } if task.editable { items += [.divider, .action("Edit…") { edit.begin(task) }] diff --git a/src/macos/GenesisKit/Sources/GenesisKit/Widgets/VoiceNotes/WidgetVoiceNotesModule.swift b/src/macos/GenesisKit/Sources/GenesisKit/Widgets/VoiceNotes/WidgetVoiceNotesModule.swift index 8b4115e896..bd71cb3d5a 100644 --- a/src/macos/GenesisKit/Sources/GenesisKit/Widgets/VoiceNotes/WidgetVoiceNotesModule.swift +++ b/src/macos/GenesisKit/Sources/GenesisKit/Widgets/VoiceNotes/WidgetVoiceNotesModule.swift @@ -79,8 +79,8 @@ private struct WidgetVoiceNotesView: View { Label("Voice Notes", systemImage: "recordingtape").font(.system(size: 15, weight: .semibold)) Spacer() Text("\(store.notes.count) local").font(.caption2).foregroundStyle(.secondary) - Button { store.refresh(force: true) } label: { Image(systemName: "arrow.clockwise") } - .buttonStyle(.plain).accessibilityLabel("Refresh voice notes").disabled(store.isBusy) + IconButton(systemName: "arrow.clockwise", tooltip: "Refresh voice notes") { store.refresh(force: true) } + .disabled(store.isBusy) } if presentation == .expanded { expanded @@ -119,7 +119,7 @@ private struct WidgetVoiceNotesView: View { Text("Up to 30 seconds").font(.caption2).foregroundStyle(.secondary) Spacer() if store.isBusy { - Button("Cancel", action: store.cancel).buttonStyle(.borderless) + Button("Cancel", action: store.cancel).buttonStyle(.genHover()) } } if let phase = store.phase { @@ -150,6 +150,16 @@ private struct WidgetVoiceNotesView: View { dismissedError = error } .accessibilityIdentifier("voice-notes-error") + } else if store.discarded != nil { + // No ⌘Z here: the transcript editor below owns ⌘Z for its own text. + HStack(spacing: 8) { + Label("Recording discarded.", systemImage: "trash").font(.system(size: 11)).foregroundStyle(.secondary) + Spacer(minLength: 4) + Button("Undo", action: store.undoDiscard) + .buttonStyle(.genHover()).font(.system(size: 11, weight: .medium)) + .instantTooltip("Brings the recording back. It is deleted for good after a few seconds.") + .accessibilityIdentifier("voice-notes-undo-discard") + } } else if let receipt = store.receipt { Label(receipt, systemImage: "checkmark.circle").font(.system(size: 11)).foregroundStyle(.secondary) } @@ -185,9 +195,10 @@ private struct WidgetVoiceNotesView: View { .frame(minHeight: 100).disabled(store.isBusy) .accessibilityLabel("Voice note transcript") HStack { - Button("Save text", action: store.saveText).buttonStyle(.borderless) + Button("Save text", action: store.saveText).buttonStyle(.genHover()) Spacer() - Button("Discard", role: .destructive, action: store.discard).buttonStyle(.borderless) + Button("Discard", role: .destructive, action: store.discard).buttonStyle(.genHover(accent: .red)) + .instantTooltip("Removes this recording. Undo brings it back for a few seconds.") }.font(.caption).disabled(store.isBusy) WidgetRecipientPicker(sessions: store.sessions, selection: $store.recipientKey, presentationChanged: store.recipientPickerVisibilityChanged) diff --git a/src/macos/GenesisKit/Sources/GenesisKit/Widgets/VoiceNotes/WidgetVoiceNotesStore.swift b/src/macos/GenesisKit/Sources/GenesisKit/Widgets/VoiceNotes/WidgetVoiceNotesStore.swift index 7e6bfa4066..d40a1a120f 100644 --- a/src/macos/GenesisKit/Sources/GenesisKit/Widgets/VoiceNotes/WidgetVoiceNotesStore.swift +++ b/src/macos/GenesisKit/Sources/GenesisKit/Widgets/VoiceNotes/WidgetVoiceNotesStore.swift @@ -23,6 +23,9 @@ public final class WidgetVoiceNotesStore: ObservableObject { @Published public var selectedID: String? @Published public var recipientKey = "" @Published private var drafts: [String: String] = [:] + /// The note the user just discarded: gone from the list, still on disk until `discardUndoDelay` runs out, so + /// Undo can bring it back (ux checklist "Undo and destructive actions"). + @Published public private(set) var discarded: WidgetVoiceNote? public let meter = WidgetVoiceRecordingMeter() public var recipientPickerVisibilityChanged: (Bool) -> Void = { _ in } private let request: ([String]) async throws -> Data @@ -41,6 +44,12 @@ public final class WidgetVoiceNotesStore: ObservableObject { private let presentMicrophonePermission: @MainActor (PermissionNeed) -> Void private var operation: Task? private var refreshTask: Task? + private let discardUndoDelay: Duration + /// Where the discarded note stood in the list, so Undo puts it back in place. + private var discardedIndex = 0 + private var discardTimer: Task? + /// The CLI delete of a discarded note once its Undo ran out; teardown waits for it. + private var discardDelete: Task? private var watcher: DirectoryWatcher? private var watchedPath: String? private var cached = false @@ -77,8 +86,10 @@ public final class WidgetVoiceNotesStore: ObservableObject { readMicrophonePermission: @escaping () -> VoiceMicrophonePermission = { .current }, requestMicrophonePermission: @escaping () async throws -> VoiceMicrophonePermission = { try await VoiceMicrophonePermission.request() }, activateForMicrophone: @escaping () -> Void = { NSApp?.activate(ignoringOtherApps: true) }, - presentMicrophonePermission: @escaping @MainActor (PermissionNeed) -> Void = { PermissionCenter.shared.require($0) }) { + presentMicrophonePermission: @escaping @MainActor (PermissionNeed) -> Void = { PermissionCenter.shared.require($0) }, + discardUndoDelay: Duration = .seconds(10)) { self.micLauncher = micLauncher + self.discardUndoDelay = discardUndoDelay self.readMicrophonePermission = readMicrophonePermission self.requestMicrophonePermission = requestMicrophonePermission self.activateForMicrophone = activateForMicrophone @@ -122,8 +133,9 @@ public final class WidgetVoiceNotesStore: ObservableObject { try Task.checkCancellation() guard !stopped, generation == requestedGeneration else { return } let snapshot = try JSONDecoder().decode(WidgetVoiceNoteSnapshot.self, from: data) - notes = snapshot.notes - drafts = drafts.filter { id, _ in self.notes.contains { $0.id == id } } + // A discarded note is still on disk during its Undo; it stays out of the list. + notes = snapshot.notes.filter { $0.id != discarded?.id } + drafts = drafts.filter { id, _ in self.notes.contains { $0.id == id } || id == self.discarded?.id } if selected == nil { selectedID = notes.first?.id } cached = true watch(snapshot.statePath) @@ -297,15 +309,58 @@ public final class WidgetVoiceNotesStore: ObservableObject { } } + /// Takes the selected note off the list at once and deletes it after `discardUndoDelay`, unless Undo brings it + /// back first. A second discard deletes the first one now. public func discard() { - guard let note = selected else { return } - begin("Discarding") { [self] in - _ = try await request(["discard", note.id, "--revision", String(note.revision)]) - generation += 1 - notes.removeAll { $0.id == note.id } - drafts[note.id] = nil - selectedID = notes.first?.id - receipt = "Local recording discarded." + guard !stopped, !isBusy, let note = selected, let index = notes.firstIndex(where: { $0.id == note.id }) else { return } + deleteDiscarded() + generation += 1 + discarded = note + discardedIndex = index + notes.remove(at: index) + selectedID = notes.first?.id + error = nil + receipt = "Recording discarded." + let delay = discardUndoDelay + discardTimer = Task { [weak self] in + do { try await Task.sleep(for: delay) } catch { return } + self?.deleteDiscarded() + } + } + + /// Undo of Discard: the note returns to its place and is selected again. + public func undoDiscard() { + guard let note = discarded else { return } + discardTimer?.cancel() + discardTimer = nil + discarded = nil + generation += 1 + if !notes.contains(where: { $0.id == note.id }) { notes.insert(note, at: min(discardedIndex, notes.count)) } + selectedID = note.id + receipt = "Recording restored." + } + + /// Deletes the discarded note for good: its Undo ran out, another note was discarded, or the store stops. + private func deleteDiscarded() { + guard let note = discarded else { return } + discardTimer?.cancel() + discardTimer = nil + discarded = nil + drafts[note.id] = nil + let previous = discardDelete + // The request is held, not the store: a delete that runs at teardown still reaches the CLI. + let request = request + discardDelete = Task { [weak self] in + await previous?.value + do { + _ = try await request(["discard", note.id, "--revision", String(note.revision)]) + } catch { + // The recording is still on disk: show why and read the list again, so it comes back. + guard let self, !self.stopped else { return } + self.report(error) + self.cached = false + self.refresh(force: true) + } } } @@ -362,14 +417,18 @@ public final class WidgetVoiceNotesStore: ObservableObject { func waitForOperation() async { await operation?.value } func waitForRefresh() async { await refreshTask?.value } + func waitForDiscard() async { await discardDelete?.value } public func cancel() { operation?.cancel(); cancelCommand() } public func shutdown() async { stop() await operation?.value await refreshTask?.value + await discardDelete?.value } public func stop() { + // The user discarded it; teardown must not bring it back on the next launch. + deleteDiscarded() stopped = true cancel() refreshTask?.cancel() diff --git a/src/macos/GenesisKit/Sources/GenesisKit/Widgets/WidgetAgentPreview.swift b/src/macos/GenesisKit/Sources/GenesisKit/Widgets/WidgetAgentPreview.swift index fc7b5aed5d..b5e9c37bee 100644 --- a/src/macos/GenesisKit/Sources/GenesisKit/Widgets/WidgetAgentPreview.swift +++ b/src/macos/GenesisKit/Sources/GenesisKit/Widgets/WidgetAgentPreview.swift @@ -63,6 +63,8 @@ struct WidgetSessionActivity: View { struct AgentWidgetPreview: View { @ObservedObject var model: WidgetModel let surface: WidgetSurfaceID + /// The session whose cmux panel was opened from the row's accessibility action (the button itself exists only on the hovered row). + @State private var cmuxPanelKey: String? var body: some View { let _ = RenderProbe.hit("widget.preview") @@ -91,7 +93,7 @@ struct AgentWidgetPreview: View { }.buttonStyle(.genHoverPlain()).instantTooltip("Every unread inbox entry, from all sessions") } if model.inboxCount == 0 { - Text(model.inbox.complete ? "All caught up" : "Inbox status unavailable") + Text(model.snapshot == nil ? "Loading inbox" : model.inbox.complete ? "All caught up" : "Inbox status unavailable") .font(.system(size: 10)).foregroundStyle(.secondary) } else { IconButton(systemName: "checkmark.circle", tooltip: "Mark all read. Nothing is deleted.", @@ -117,7 +119,13 @@ struct AgentWidgetPreview: View { }.accessibilityLabel("Inbox preview settings") }.padding(.horizontal, 5).padding(.bottom, 3) - if model.previewList.isEmpty { + if model.previewList.isEmpty && model.snapshot == nil { + HStack(spacing: 8) { + ProgressView().controlSize(.small) + Text("Loading sessions").font(.system(size: 11)).foregroundStyle(.secondary) + }.frame(maxWidth: .infinity, alignment: .leading).padding(.horizontal, 5).padding(.vertical, 6) + .accessibilityElement(children: .combine) + } else if model.previewList.isEmpty { HStack(spacing: 12) { GenesisWidgetMark() VStack(alignment: .leading, spacing: 4) { @@ -144,10 +152,10 @@ struct AgentWidgetPreview: View { Image(systemName: "rectangle.stack").font(.system(size: 10)) Text("Projects & sessions").font(.system(size: 10, weight: .medium)) Spacer() - Text(Plural.count(model.previewList.count, "session")).font(.system(size: 9.5)).monospacedDigit() + Text(Plural.count(model.previewList.count, "session")).font(.system(size: 11)).monospacedDigit() Image(systemName: "chevron.right").font(.system(size: 8, weight: .semibold)) } - .foregroundStyle(.white.opacity(0.5)).padding(.horizontal, 5).padding(.vertical, 5) + .foregroundStyle(.white.opacity(0.6)).padding(.horizontal, 5).padding(.vertical, 5) .contentShape(Rectangle()) }.buttonStyle(.genHoverPlain()) .instantTooltip("Browse every project and session") @@ -159,6 +167,8 @@ struct AgentWidgetPreview: View { .fixedSize(horizontal: false, vertical: true) .onGeometryChange(for: CGFloat.self, of: \.size.height) { model.reportPreviewFit($0) } .frame(maxWidth: .infinity, maxHeight: .infinity, alignment: .top) + // Like the row button's own popover: while it is open the edge panel must not collapse. + .onChange(of: cmuxPanelKey) { _, key in model.cmuxPopoverOpen = key != nil } } private var sortTooltip: String { @@ -181,7 +191,7 @@ struct AgentWidgetPreview: View { .lineLimit(1) Text("·") Text(session.visualStatus.label).lineLimit(1) - }.font(.system(size: 9.5)).foregroundStyle(.white.opacity(0.45)) + }.font(.system(size: 11)).foregroundStyle(.white.opacity(0.6)) WidgetSessionTimes(session: session, inbox: model.inboxFor(session.key)) } Spacer(minLength: 2) @@ -203,6 +213,11 @@ struct AgentWidgetPreview: View { WidgetCmuxButton(model: model, session: session, live: hovering) } } .padding(.trailing, 4) + .accessibilityAction(named: "Open cmux for " + session.title) { cmuxPanelKey = session.key } + .popover(isPresented: Binding(get: { cmuxPanelKey == session.key }, set: { if !$0 { cmuxPanelKey = nil } }), + arrowEdge: .trailing) { + WidgetCmuxPanel(model: model, session: session).padding(14).frame(width: 380) + } .background( model.inboxFor(session.key).map { $0.needsAnswer > 0 } == true || session.status == "waiting" ? Color.orange.opacity(0.065) : .white.opacity(0.025), @@ -233,10 +248,10 @@ struct WidgetSessionTimes: View { if let entry = Self.latest(inbox) { Text("·") LiveAgo(date: Date(timeIntervalSince1970: entry.at / 1000), style: .brief) { entry.verb + " " + $0 } - .foregroundStyle(entry.waiting ? Color.orange.opacity(0.8) : .white.opacity(0.45)) + .foregroundStyle(entry.waiting ? Color.orange.opacity(0.8) : .white.opacity(0.6)) } } - .font(.system(size: 9.5)).foregroundStyle(.white.opacity(0.45)).lineLimit(1) + .font(.system(size: 11)).foregroundStyle(.white.opacity(0.6)).lineLimit(1) } /// The newest entry: a question still waiting wins, since that is what the row is about. diff --git a/src/macos/GenesisKit/Sources/GenesisKit/Widgets/WidgetAlerts.swift b/src/macos/GenesisKit/Sources/GenesisKit/Widgets/WidgetAlerts.swift index 1949eacaff..c85d5426ad 100644 --- a/src/macos/GenesisKit/Sources/GenesisKit/Widgets/WidgetAlerts.swift +++ b/src/macos/GenesisKit/Sources/GenesisKit/Widgets/WidgetAlerts.swift @@ -25,16 +25,29 @@ public enum WidgetAlerts { items.filter(\.needsAnswer).max { $0.at < $1.at } ?? items.max { $0.at < $1.at } } - /// The popup's first line. + /// The popup's first line: "Decision 4", "New question", "New answer". Sentence case, one word per thing. public static func kicker(_ item: WidgetInboxItem) -> String { switch item.kind { - case "decision": return item.number.map { "New decision · DECISION \($0)" } ?? "New decision" + case "decision": return item.number.map { "Decision \($0)" } ?? "New decision" case "form": return "New question" case "answer": return "New answer" default: return "New in the inbox" } } + /// What VoiceOver says when an alert arrives: what it is, its title and the session that sent it. + public static func announcement(_ item: WidgetInboxItem, session: String?) -> String { + var parts = [kicker(item)] + if let title = item.title?.trimmingCharacters(in: .whitespacesAndNewlines), !title.isEmpty { parts.append(title) } + if let session, !session.isEmpty { parts.append("from " + session) } + return parts.joined(separator: ", ") + } + + /// Seconds the alert popup stays while the pointer is not on it (`alertPopupSeconds`, default 7). + public static func popupSeconds(_ preferences: WidgetPreferences?) -> Double { + max(3, min(60, preferences?.alertPopupSeconds ?? 7)) + } + /// The sound for a burst: `alertSound` when anything in it waits for an answer, else the answers' own sound /// (`alertAnswerSound`, where "same" means `alertSound`). nil plays the default sound. public static func sound(for items: [WidgetInboxItem], preferences: WidgetPreferences?) -> String? { diff --git a/src/macos/GenesisKit/Sources/GenesisKit/Widgets/WidgetBubble.swift b/src/macos/GenesisKit/Sources/GenesisKit/Widgets/WidgetBubble.swift index 02762308b7..40afffd5b9 100644 --- a/src/macos/GenesisKit/Sources/GenesisKit/Widgets/WidgetBubble.swift +++ b/src/macos/GenesisKit/Sources/GenesisKit/Widgets/WidgetBubble.swift @@ -12,17 +12,23 @@ struct WidgetBubbleChrome: ViewModifier { let reduceMotion: Bool /// When the session's flash began; a bubble rebuilt mid-flash (the rail reordered) plays the rest of it. var since: Date? + /// Room around the bubble that still takes its click and hover: half the gap to each neighbour, so the strip + /// between two bubbles is never dead (ux checklist, Hit targets). The caller takes the same amount out of its + /// spacing, so nothing moves. + var hitPadding = EdgeInsets() @State private var ring = 0.0 @State private var settle: Task? func body(content: Content) -> some View { content .background(Circle().fill(Color.white.opacity(ring > 0 ? 0.07 : 0)).padding(2)) - // The whole circle takes clicks and hover at every ring value: the count is not hit-testable, and SwiftUI - // does not hit-test the fill at opacity 0, so a bubble whose attention ended took no click at all. - .contentShape(Circle()) .overlay(Circle().strokeBorder(Color.white.opacity(ring), lineWidth: 1.5).padding(2).allowsHitTesting(false)) .attentionPulse(trigger: trigger, style: attention, since: since) + .padding(hitPadding) + // The whole target takes clicks and hover at every ring value: the count is not hit-testable, and SwiftUI + // does not hit-test the fill at opacity 0, so a bubble whose attention ended took no click at all. With + // hit padding the target is the padded rectangle; without it, the circle. + .contentShape(hitPadding == EdgeInsets() ? AnyShape(Circle()) : AnyShape(Rectangle())) .onHover { inside in settle?.cancel() guard inside else { diff --git a/src/macos/GenesisKit/Sources/GenesisKit/Widgets/WidgetCmuxButton.swift b/src/macos/GenesisKit/Sources/GenesisKit/Widgets/WidgetCmuxButton.swift index 2828eb0995..e66c99a4a0 100644 --- a/src/macos/GenesisKit/Sources/GenesisKit/Widgets/WidgetCmuxButton.swift +++ b/src/macos/GenesisKit/Sources/GenesisKit/Widgets/WidgetCmuxButton.swift @@ -11,6 +11,9 @@ struct WidgetCmuxButton: View { /// hover sensor, because an accessibility client walks every control of every row per update (swift skill, /// "Dense lists and accessibility clients"). The popover stays attached, so an open one survives the pointer leaving. var live = true + /// The list this button sits in ("sessions"), so a row's accessibility action (`WidgetModel.requestCmux`) opens the + /// popover of this row's button and not of another button for the same session elsewhere on screen. + var requestScope: String? @State private var open = false var body: some View { @@ -32,6 +35,13 @@ struct WidgetCmuxButton: View { // flag, as the session chooser has one: closing it must never release a window the host opened. .onChange(of: open) { _, value in model.cmuxPopoverOpen = value } .onDisappear { if open { model.cmuxPopoverOpen = false } } + // A keyboard or VoiceOver user cannot reach the button of a row that is not hovered; the row's accessibility + // action asks for it through the model, and the popover opens on this row (ux catalogue R5). + .onChange(of: model.cmuxRequest) { _, request in + guard let request, request.key == session.key, request.scope == requestScope else { return } + model.cmuxRequest = nil + open = true + } } } diff --git a/src/macos/GenesisKit/Sources/GenesisKit/Widgets/WidgetCoordinator.swift b/src/macos/GenesisKit/Sources/GenesisKit/Widgets/WidgetCoordinator.swift index c829e5c707..32ac9efc1e 100644 --- a/src/macos/GenesisKit/Sources/GenesisKit/Widgets/WidgetCoordinator.swift +++ b/src/macos/GenesisKit/Sources/GenesisKit/Widgets/WidgetCoordinator.swift @@ -46,6 +46,14 @@ public final class WidgetCoordinator: NSObject, NSWindowDelegate { public var passivePanels = false { didSet { panels.values.forEach { $0.becomesKeyOnExpand = !passivePanels } } } + /// Whether this coordinator owns the global "open the widget" shortcut (`openShortcut` preference). Only the + /// widget face sets it: the preview studio and tests must not take a key from every app on the Mac. + public var registersOpenShortcut = false + private var openHotKey: CompanionHotKey? + /// The chord `openHotKey` holds, or nil while none is registered. + private var openHotKeyChord: WidgetShortcutChord? + /// "GNWD": Genesis widget. Its own Carbon identity, so it never shadows dictation's (`CompanionHotKey.flowSignature`). + static let openHotKeySignature: OSType = 0x474E_5744 /// Orders one edge panel front. Tests replace it to prove a hidden widget never shows a panel. var orderFront: (EdgePanelController) -> Void = { $0.show() } var panelCount: Int { panels.count } @@ -212,13 +220,17 @@ public final class WidgetCoordinator: NSObject, NSWindowDelegate { guard let item = WidgetAlerts.popupItem(wanted) else { return } PerfLog.mark("widget.alert items=\(wanted.count) id=\(item.id) kind=\(item.kind)") playChime(WidgetAlerts.sound(for: wanted, preferences: preferences)) + // The chime and the flash say nothing to VoiceOver; one announcement per burst, like the chime. + GenAnnounce.post(WidgetAlerts.announcement(item, session: model.session(forKey: item.key)?.title), + priority: item.needsAnswer ? .high : .medium) // The session that asked flashes on its rail bubble and in the lists, popup or not: it says why the widget // chimed or opened (Martin, 2026-10-23). model.flashAttention(item.key, seconds: WidgetAlerts.flashSeconds(preferences)) // Checked before the module switch: a popup the user's interaction refuses must not swap the pane they use. guard preferences?.alertPopup ?? true, let surface = peekSurface(), model.canPeek(on: surface) else { return } // The peek shows the agents module and gives the user's own module back when it ends. - model.showPeek(item, on: surface, module: moduleIDs(for: surface).contains("agents") ? "agents" : nil) + model.showPeek(item, on: surface, module: moduleIDs(for: surface).contains("agents") ? "agents" : nil, + seconds: WidgetAlerts.popupSeconds(preferences)) } /// The visible edge whose selected module is the agents inbox, else the first visible edge that has it. @@ -326,12 +338,54 @@ public final class WidgetCoordinator: NSObject, NSWindowDelegate { model.stop() modules.removeAllSurfaces() removePanelMonitors() + openHotKey?.stop() + openHotKey = nil + openHotKeyChord = nil if let screenObserver { NotificationCenter.default.removeObserver(screenObserver) } panels.values.forEach { $0.hide() } settings?.close() mediaWindow?.close() } + /// Registers, moves or drops the global shortcut that opens the widget, as the preferences say (ux catalogue O1). + /// Only while the widget is on: with it off there is nothing to open. + private func applyOpenShortcut() { + let wanted = registersOpenShortcut && !stopped && panelsEnabled + ? model.snapshot?.state.preferences.openShortcutChord : nil + guard wanted != openHotKeyChord else { return } + openHotKey?.stop() + openHotKey = nil + openHotKeyChord = wanted + guard let wanted else { return } + let key = CompanionHotKey(keyCode: wanted.keyCode, modifiers: wanted.modifiers, + signature: Self.openHotKeySignature, hotKeyId: 1) + key.onKeyDown = { [weak self] _ in + Task { @MainActor in self?.toggleFromShortcut() } + } + let label = wanted.hotkey.label + if key.start() { + openHotKey = key + PerfLog.mark("widget.shortcut registered \(label)") + } else { + PerfLog.mark("widget.shortcut refused \(label)") + model.postNote("The shortcut \(label) to open the widget is taken. Choose another in Widget settings.") + } + } + + /// The shortcut: opens the agents pane with the keyboard in it, or closes the open panel (staged decisions still + /// ask first). + func toggleFromShortcut() { + guard panelsEnabled else { return } + if model.expanded != nil { + model.requestCollapse() + return + } + let surface = peekSurface() ?? panels.keys.sorted { $0.key < $1.key }.first + guard let surface else { return } + PerfLog.mark("widget.shortcut open \(surface.key)") + model.openModule("agents", on: surface) + } + public func registerModule(_ module: WidgetModuleDescriptor) throws { try modules.register(module) if display != nil { rebuildPanels() } @@ -340,7 +394,7 @@ public final class WidgetCoordinator: NSObject, NSWindowDelegate { private func compactHeight(_ ids: [String]) -> CGFloat { WidgetSideStripMetrics( classic: model.snapshot?.state.preferences.sideStyle == "classic", - moduleIDs: ids, visibleSessionCount: model.railActivitySessions.count, hasInboxBadge: model.inboxCount > 0 + moduleIDs: ids, visibleSessionCount: model.railActivitySessions.count, hasInboxBadge: model.waitingCount > 0 ).minimumHeight } @@ -498,6 +552,7 @@ public final class WidgetCoordinator: NSObject, NSWindowDelegate { private func sync() { resolvePendingLaunch() + applyOpenShortcut() if lastShown != panelsEnabled { rebuildPanels() return @@ -751,6 +806,13 @@ public final class WidgetCoordinator: NSObject, NSWindowDelegate { } private func handleKey(_ event: NSEvent) -> NSEvent? { + // The alert popup never takes the keyboard, but when the widget has it (a panel of another edge is key), Esc + // closes the popup before anything else. + if model.expanded == nil, model.peekItem != nil, let stroke = KeyStroke(event), + WidgetKeymap.commandIDs(for: stroke, in: [.window], keymap: WidgetKeymap.shared).contains(WidgetKeymap.collapse) { + model.dismissPeek() + return nil + } guard let expanded = model.expanded, let panel = panels[WidgetSurfaceID(edge: expanded, group: expanded == .top ? 0 : model.activeSideGroup)]? .panel, @@ -775,9 +837,12 @@ public final class WidgetCoordinator: NSObject, NSWindowDelegate { switch Self.panelKey( stroke: stroke, context: .init(previewOpen: MediaPreviewKeyboard.isOpen, agents: isAgent, editingText: editingText, - answering: model.answerCard)) + answering: model.answerCard, list: model.keyboardList)) { case .pass: return event + case .list(let key): + // Only a list that took the key swallows it; otherwise the scroll view under it still scrolls. + return model.moveInList(key) ? nil : event case .collapse: // The leave question first (Esc keeps the staged decisions), then the staged view, then the list. if model.stagedLeavePrompt { @@ -794,7 +859,7 @@ public final class WidgetCoordinator: NSObject, NSWindowDelegate { return nil } - enum PanelKey: Equatable { case pass, collapse, previous, next, choose(String) } + enum PanelKey: Equatable { case pass, collapse, previous, next, choose(String), list(WidgetListKey.Kind) } struct PanelKeyContext { let previewOpen: Bool @@ -802,6 +867,9 @@ public final class WidgetCoordinator: NSObject, NSWindowDelegate { let editingText: Bool /// `WidgetModel.answerCard`: an open, unanswered decision, never a folded one. let answering: WidgetCard? + /// `WidgetModel.keyboardList`: a list (the inbox of every session, a session's inbox, the sessions) is on + /// screen, so Up, Down and Return move in it. + var list: Bool = false } /// What a key the media preview did not take does on the panel. While the preview is open, nothing: @@ -829,6 +897,11 @@ public final class WidgetCoordinator: NSObject, NSWindowDelegate { guard context.agents, !context.editingText else { return .pass } if ids.contains(WidgetKeymap.previous) { return .previous } if ids.contains(WidgetKeymap.next) { return .next } + if context.list { + if ids.contains(WidgetKeymap.listUp) { return .list(.up) } + if ids.contains(WidgetKeymap.listDown) { return .list(.down) } + if ids.contains(WidgetKeymap.listOpen) { return .list(.open) } + } if let number = WidgetKeymap.choiceNumber(in: ids), let card = context.answering, number <= card.choices.count { diff --git a/src/macos/GenesisKit/Sources/GenesisKit/Widgets/WidgetFeatureSettings.swift b/src/macos/GenesisKit/Sources/GenesisKit/Widgets/WidgetFeatureSettings.swift index 9356792351..4d6a802698 100644 --- a/src/macos/GenesisKit/Sources/GenesisKit/Widgets/WidgetFeatureSettings.swift +++ b/src/macos/GenesisKit/Sources/GenesisKit/Widgets/WidgetFeatureSettings.swift @@ -281,7 +281,7 @@ private struct WidgetGeneralSettings: View { } NativeSettingsCard("Alerts", subtitle: "When an agent asks or answers while the widget is closed.") { NativeSettingsToggle( - "Decisions and questions", detail: "A new ❓ DECISION or question that waits for your answer.", + "Decisions and questions", detail: "A new question that waits for your answer.", identifier: "widget.alertDecisions", isOn: boolean("alertDecisions", prefs?.alertDecisions ?? true)) Divider() NativeSettingsToggle( @@ -300,6 +300,21 @@ private struct WidgetGeneralSettings: View { "Open the widget", detail: "The widget opens a short preview of the new item; a click on it answers.", identifier: "widget.alertPopup", isOn: boolean("alertPopup", prefs?.alertPopup ?? true)) Divider() + NativeSettingsRow("Preview stays", detail: "How long the preview stays while the pointer is not on it. × or Esc closes it sooner.") { + Picker( + "Preview stays", + selection: Binding( + get: { Int(WidgetAlerts.popupSeconds(prefs)) }, + set: { patch(["alertPopupSeconds": .number(Double($0))]) }) + ) { + ForEach(Self.popupSecondsChoices(current: Int(WidgetAlerts.popupSeconds(prefs))), id: \.self) { + Text("\($0) seconds").tag($0) + } + }.labelsHidden().frame(width: 150, alignment: .trailing) + .accessibilityIdentifier("widget.alertPopupSeconds") + } + .disabled(!(prefs?.alertPopup ?? true)) + Divider() NativeSettingsRow("Flash the session", detail: "Its rail bubble and its row flash, so you see which session asked.") { Picker( "Flash the session", @@ -314,13 +329,61 @@ private struct WidgetGeneralSettings: View { Text("20 seconds").tag(20) }.labelsHidden().frame(width: 150, alignment: .trailing) } + Divider() + NativeSettingsRow("Notes stay", detail: "How long a note under the widget's header stays, such as \"Inbox marked read\". A note with a button stays at least 10 seconds; errors stay until you close them.") { + Picker( + "Notes stay", + selection: Binding( + get: { Int(prefs?.noteSeconds ?? 6) }, + set: { patch(["noteSeconds": .number(Double($0))]) }) + ) { + ForEach(Self.noteSecondsChoices(current: Int(prefs?.noteSeconds ?? 6)), id: \.self) { seconds in + Text(seconds == 0 ? "Until closed" : "\(seconds) seconds").tag(seconds) + } + }.labelsHidden().frame(width: 150, alignment: .trailing) + .accessibilityIdentifier("widget.noteSeconds") + } } .nativeSettingsAnchor("alerts") + NativeSettingsCard("Shortcut", subtitle: "A global shortcut that opens the widget from any app.") { + NativeSettingsToggle( + "Open with a shortcut", + detail: "Opens the agent inbox with the keyboard in it; press it again to close the widget.", + identifier: "widget.openShortcutEnabled", + isOn: boolean("openShortcutEnabled", prefs?.openShortcutEnabled ?? true)) + Divider() + NativeSettingsRow("Shortcut", detail: "Works in every app while the widget is on.") { + HotkeyRecorder( + chord: Binding( + get: { (prefs?.openShortcut ?? .defaultOpen).hotkey }, + set: { chord in + patch(["openShortcut": .object([ + "keyCode": .number(Double(chord.keyCode)), + "modifiers": .number(Double(chord.modifiers)), + ])]) + }), + defaultChord: WidgetShortcutChord.defaultOpen.hotkey, + identifier: "widget.openShortcut") + } + .disabled(!(prefs?.openShortcutEnabled ?? true)) + } + .nativeSettingsAnchor("shortcut") WidgetAgentHooksCard(bridge: model.bridge) } }.task { model.refreshSettings() } } + /// The popup durations offered, plus a stored value that is none of them, so the picker never shows a blank. + static func popupSecondsChoices(current: Int) -> [Int] { + Array(Set([4, 7, 10, 15, 30, current])).sorted() + } + + /// The note durations offered (0 is "Until closed"), plus a stored value that is none of them. + static func noteSecondsChoices(current: Int) -> [Int] { + let timed = Array(Set([3, 6, 10, 20, current]).subtracting([0])).sorted() + return timed + [0] + } + /// A system sound picker with a play button. `same` adds "Same as decisions", stored as "same". private func soundPicker(_ key: String, _ value: String, same: Bool) -> some View { let played = value == "same" ? prefs?.alertSound ?? WidgetAlerts.defaultSound : value @@ -663,6 +726,7 @@ private struct WidgetDictationSettings: View { @State private var languageSaved = false @State private var savedReset: Task? @FocusState private var languageFocused: Bool + @GenReduceMotion private var reduceMotion private var prefs: WidgetPreferences? { model.snapshot?.state.preferences } private var provider: VoiceConfiguration.Provider? { configuration?.providers.first { $0.id == prefs?.voiceProvider } @@ -775,11 +839,12 @@ private struct WidgetDictationSettings: View { /// One timer: a second save restarts the two seconds instead of letting the first save's timer hide it early. private func confirmLanguageSaved() { - withAnimation(.easeOut(duration: 0.15)) { languageSaved = true } + // A fade is allowed under Reduce Motion, but the page keeps every setting change still (ux checklist, Motion). + withAnimation(reduceMotion ? nil : .easeOut(duration: 0.15)) { languageSaved = true } savedReset?.cancel() savedReset = Task { @MainActor in do { try await Task.sleep(for: .seconds(2)) } catch { return } - withAnimation(.easeOut(duration: 0.3)) { languageSaved = false } + withAnimation(reduceMotion ? nil : .easeOut(duration: 0.3)) { languageSaved = false } } } diff --git a/src/macos/GenesisKit/Sources/GenesisKit/Widgets/WidgetHostView.swift b/src/macos/GenesisKit/Sources/GenesisKit/Widgets/WidgetHostView.swift index 15d1879264..2c1418aebc 100644 --- a/src/macos/GenesisKit/Sources/GenesisKit/Widgets/WidgetHostView.swift +++ b/src/macos/GenesisKit/Sources/GenesisKit/Widgets/WidgetHostView.swift @@ -39,7 +39,7 @@ struct WidgetHostView: View { private var sideMetrics: WidgetSideStripMetrics { WidgetSideStripMetrics(classic: classicSide, moduleIDs: moduleIDs, - visibleSessionCount: model.railActivitySessions.count, hasInboxBadge: model.inboxCount > 0) + visibleSessionCount: model.railActivitySessions.count, hasInboxBadge: model.waitingCount > 0) } private var shape: EdgePanelShape { @@ -152,26 +152,29 @@ struct WidgetHostView: View { Text(selected?.id == "agents" ? "Agents" : selected?.title ?? "Widgets") .font(.system(size: 12, weight: .semibold)) } - if selected?.id == "agents", model.inboxCount > 0 { - WidgetInboxCount(count: model.inboxCount, needsAnswer: model.inbox.needsAnswer > 0, + if selected?.id == "agents", model.waitingCount > 0 { + WidgetInboxCount(count: model.waitingCount, needsAnswer: true, pulse: model.inboxPulse, reduceMotion: model.effectiveReduceMotion, complete: model.inbox.complete) } }.fixedSize() } .buttonStyle(.genHoverPlain()) .accessibilityLabel("Open " + (selected?.title ?? "widgets")) + .accessibilityValue(selected?.id == "agents" ? Self.waitingValue(model.waitingCount) : "") .accessibilityIdentifier("widget.primary." + surface.key) .instantTooltip(Self.moduleTooltip(id: selected?.id, title: selected?.title ?? "Widgets", inbox: model.inbox)) HStack(spacing: 8) { if selected?.id == "agents" { - HStack(spacing: 5) { + // No spacing between the buttons: each bubble takes half the 5 pt gap as its own target (R2). + HStack(spacing: 0) { ForEach(Array(model.railActivitySessions.prefix(3))) { session in Button { model.toggleSession(session.key, on: surface) } label: { sessionActivity(session) .frame(minWidth: 30, minHeight: 30).fixedSize() - .modifier(bubbleChrome(session)) + .modifier(bubbleChrome(session, hitPadding: EdgeInsets( + top: 0, leading: Self.topBubbleGap / 2, bottom: 0, trailing: Self.topBubbleGap / 2))) }.buttonStyle(.plain) .instantTooltip(model.bubbleTooltip(sessionTooltip(session))) .accessibilityLabel(sessionSummary(session)) @@ -179,6 +182,8 @@ struct WidgetHostView: View { .onHover { model.hoverSession(session.key, on: surface, inside: $0) } } } + // The outer bubbles' halves of the gap reach past the row; the row keeps its old width. + .padding(.horizontal, -Self.topBubbleGap / 2) } ForEach(Array(moduleIDs.filter { $0 != selected?.id }.prefix(4)), id: \.self) { id in moduleButton(id, size: 25, location: "strip") @@ -273,18 +278,23 @@ struct WidgetHostView: View { ForEach(moduleIDs, id: \.self) { id in moduleButton(id, size: sideMetrics.moduleSize, location: "rail") if id == "agents", sideMetrics.sessionCount > 0 { - VStack(spacing: WidgetSideStripMetrics.sessionSpacing) { + // No spacing between the buttons: each bubble takes half the gap above and below it, and the rail's + // spare width beside it, as its own target, so no click between two bubbles is lost (R2). + VStack(spacing: 0) { ForEach(Array(model.railActivitySessions.prefix(sideMetrics.sessionCount))) { session in Button { model.toggleSession(session.key, on: surface) } label: { sessionActivity(session) .frame(width: WidgetSideStripMetrics.sessionWidth, height: WidgetSideStripMetrics.sessionHeight) - .modifier(bubbleChrome(session)) + .modifier(bubbleChrome(session, hitPadding: WidgetSideStripMetrics.sessionHitPadding)) .overlay(alignment: .bottomTrailing) { // Which agent the count belongs to, at a glance; the tooltip names the session. + // Inset by the hit padding, so it sits on the bubble's corner as before. AIProviderGlyph(meta: AIProviders.meta(for: session.target.provider), size: 11) .opacity(0.85).allowsHitTesting(false) + .padding(.bottom, WidgetSideStripMetrics.sessionHitPadding.bottom) + .padding(.trailing, WidgetSideStripMetrics.sessionHitPadding.trailing) } } .buttonStyle(.plain) @@ -294,6 +304,9 @@ struct WidgetHostView: View { .onHover { model.hoverSession(session.key, on: surface, inside: $0) } } } + // The first and last bubbles' halves of the gap reach into the stack's neighbours' spacing, so the + // stack keeps the height `WidgetSideStripMetrics.minimumHeight` counts. + .padding(.vertical, -WidgetSideStripMetrics.sessionSpacing / 2) } } } @@ -352,7 +365,15 @@ struct WidgetHostView: View { private func previewPane(_ selected: WidgetModuleDescriptor) -> some View { VStack(spacing: 0) { - if selected.id == "agents" { + if selected.id == "agents", let item = model.peekItem { + // The alert popup (`showPeek`): one item, a click answers it, × or Esc closes it. Measured like the + // hover list, so the panel takes the popup's own height. + WidgetPeekView(model: model, item: item) + .padding(.horizontal, 6).padding(.vertical, 8) + .fixedSize(horizontal: false, vertical: true) + .onGeometryChange(for: CGFloat.self, of: \.size.height) { model.reportPreviewFit($0) } + .frame(maxWidth: .infinity, maxHeight: .infinity, alignment: .top) + } else if selected.id == "agents" { AgentWidgetPreview(model: model, surface: surface) } else { Button(action: expand) { @@ -380,9 +401,17 @@ struct WidgetHostView: View { .frame(maxHeight: .infinity, alignment: .top) } - /// Inbox counts describe only the Agents module; every other module button names its module. + /// Inbox counts describe only the Agents module; every other module button names its module. The badge counts + /// what waits for your answer (the hub's Inbox count); unread answers are only named here. static func moduleTooltip(id: String?, title: String, inbox: WidgetInboxSummary) -> String { - id == "agents" ? "Inbox: \(inbox.unread) unread, \(inbox.needsAnswer) need an answer" : title + guard id == "agents" else { return title } + let waiting = max(0, inbox.needsAnswer), unread = max(0, inbox.unread) + return "Inbox: \(waiting) waiting for your answer, \(unread) unread" + } + + /// The badge's count for VoiceOver, which the button's own label would otherwise hide. + static func waitingValue(_ count: Int) -> String { + count > 0 ? "\(count) waiting for your answer" : "Nothing waits for you" } private func moduleButton(_ id: String, size: CGFloat, location: String = "content") -> some View { @@ -396,13 +425,13 @@ struct WidgetHostView: View { .font(.system(size: 13, weight: .medium)) .frame(height: 16) .foregroundStyle(classicSide ? Color.white : registry.module(id)?.tint ?? .secondary) - if id == "agents", model.inboxCount > 0 { - WidgetInboxCount(count: model.inboxCount, needsAnswer: model.inbox.needsAnswer > 0, + if id == "agents", model.waitingCount > 0 { + WidgetInboxCount(count: model.waitingCount, needsAnswer: true, pulse: model.inboxPulse, reduceMotion: model.effectiveReduceMotion, complete: model.inbox.complete, compact: true) } } - .frame(minWidth: size, minHeight: surface.edge != .top && id == "agents" && model.inboxCount > 0 + .frame(minWidth: size, minHeight: surface.edge != .top && id == "agents" && model.waitingCount > 0 ? WidgetSideStripMetrics.badgedModuleHeight : size) .background( selected?.id == id && presentation != .compact ? Color.white.opacity(0.09) : .clear, @@ -411,15 +440,19 @@ struct WidgetHostView: View { .buttonStyle(.genHoverPlain()) .instantTooltip(Self.moduleTooltip(id: id, title: registry.module(id)?.title ?? id, inbox: model.inbox)) .accessibilityLabel("Open " + (registry.module(id)?.title ?? id)) + .accessibilityValue(id == "agents" ? Self.waitingValue(model.waitingCount) : "") .accessibilityIdentifier("widget." + location + "." + surface.key + "." + id) } + /// The top strip's gap between two session bubbles, which the bubbles split as hit padding. + static let topBubbleGap: CGFloat = 5 + /// The bubble's hover ring and the alert flash, in place of the rectangular hover plate of `.genHoverPlain`. - private func bubbleChrome(_ session: WidgetSession) -> WidgetBubbleChrome { + private func bubbleChrome(_ session: WidgetSession, hitPadding: EdgeInsets = EdgeInsets()) -> WidgetBubbleChrome { var style = model.attentionStyle style.cornerRadius = 17 return WidgetBubbleChrome(trigger: model.attentionTrigger(session.key), attention: style, - reduceMotion: model.effectiveReduceMotion, since: model.attentionStart(session.key)) + reduceMotion: model.effectiveReduceMotion, since: model.attentionStart(session.key), hitPadding: hitPadding) } private func sessionActivity(_ session: WidgetSession) -> some View { @@ -477,11 +510,18 @@ struct WarmPaneVisibility: ViewModifier { } } -/// The alert popup: what arrived and from which session. A click opens it in the inbox. +/// The alert popup: what arrived and from which session. A click opens it in the inbox; the close button, Esc while the +/// widget has the keyboard, and VoiceOver's escape gesture close it without opening anything. struct WidgetPeekView: View { @ObservedObject var model: WidgetModel let item: WidgetInboxItem + /// The kicker names the decision ("Decision 4"), so the title under it does not repeat the number. + private var title: String { + let plain = item.title?.trimmingCharacters(in: .whitespacesAndNewlines) ?? "" + return plain.isEmpty ? "Open the inbox to see it." : plain + } + var body: some View { Button { guard let surface = model.hoveredSurface else { return } @@ -496,22 +536,34 @@ struct WidgetPeekView: View { Spacer(minLength: 0) LiveAgo(date: Date(timeIntervalSince1970: item.at / 1000), style: .brief) .font(.system(size: 10)).foregroundStyle(.secondary) + // Room for the close button, which sits over this corner outside the row button. + Color.clear.frame(width: 18, height: 1) } - Text(item.numberedTitle ?? "Open the inbox to see it.").font(.system(size: 12.5, weight: .medium)) + Text(title).font(.system(size: 12.5, weight: .medium)) .lineLimit(3).multilineTextAlignment(.leading).fixedSize(horizontal: false, vertical: true) if let session = model.session(forKey: item.key) { - Text(session.title + " · " + session.project).font(.system(size: 10.5)).foregroundStyle(.secondary) + Text([session.title, session.project].filter { !$0.isEmpty }.joined(separator: " · ")) + .font(.system(size: 11)).foregroundStyle(.secondary) .lineLimit(1).truncationMode(.middle) } - Text(item.needsAnswer ? "Click to answer" : "Click to read").font(.system(size: 10)) - .foregroundStyle(.tertiary) + Text(item.needsAnswer ? "Click to answer" : "Click to read").font(.system(size: 11)) + .foregroundStyle(.secondary) } + .padding(8) .frame(maxWidth: .infinity, alignment: .leading) .contentShape(Rectangle()) } .buttonStyle(RowButtonStyle(cornerRadius: 10)) .attentionPulse(trigger: model.attentionTrigger(item.key), style: popupAttention, since: model.attentionStart(item.key)) - .accessibilityLabel(WidgetAlerts.kicker(item) + ": " + (item.numberedTitle ?? "")) + .accessibilityLabel(WidgetAlerts.kicker(item) + ": " + title) + .accessibilityAction(.escape) { model.dismissPeek() } + .accessibilityAction(named: "Close") { model.dismissPeek() } + .overlay(alignment: .topTrailing) { + IconButton(systemName: "xmark", tooltip: "Close without opening it", size: 10) { model.dismissPeek() } + .accessibilityLabel("Close the alert") + .accessibilityIdentifier("widget.peek.close") + .padding(.top, 3).padding(.trailing, 3) + } } private var popupAttention: AttentionPulseStyle { diff --git a/src/macos/GenesisKit/Sources/GenesisKit/Widgets/WidgetInboxOverview.swift b/src/macos/GenesisKit/Sources/GenesisKit/Widgets/WidgetInboxOverview.swift index 7d9a40cb03..645178e65a 100644 --- a/src/macos/GenesisKit/Sources/GenesisKit/Widgets/WidgetInboxOverview.swift +++ b/src/macos/GenesisKit/Sources/GenesisKit/Widgets/WidgetInboxOverview.swift @@ -7,6 +7,8 @@ struct WidgetInboxOverview: View { /// The filter picked here; until then the one the `overviewFilter` setting opens with. @State private var filterPick: String? @State private var openID: String? + /// The row Up and Down chose (`WidgetModel.listKey`); Return opens or folds it. The pointer never moves it. + @State private var focusID: String? @GenReduceMotion private var reduceMotion private var filter: String { filterPick ?? model.snapshot?.state.preferences.overviewFilter ?? "all" } @@ -23,9 +25,42 @@ struct WidgetInboxOverview: View { /// Entries under their session ("session", the default) or one list (Widgets → Inbox, and the header button). private var grouped: Bool { (model.snapshot?.state.preferences.inboxGrouping ?? "session") == "session" } + /// The rows in the order they are drawn: grouped under their sessions, or the one list. + private func drawnOrder(_ items: [WidgetInboxItem]) -> [WidgetInboxItem] { + grouped ? Self.groups(items).flatMap(\.items) : items + } + var body: some View { let items = items let _ = RenderProbe.hit("widget.overview") + // Inside the pane's scroll view: the proxy scrolls the row the keys chose into view. + ScrollViewReader { proxy in + list(items) + .onChange(of: model.listKey) { _, key in + guard let key else { return } + handle(key.kind, rows: drawnOrder(items), proxy: proxy) + } + } + } + + /// Up and Down move the highlight, Return opens or folds the highlighted row. No motion: keys repeat. + private func handle(_ kind: WidgetListKey.Kind, rows: [WidgetInboxItem], proxy: ScrollViewProxy) { + let current = focusID.flatMap { id in rows.firstIndex { $0.id == id } } + switch kind { + case .open: + guard let current else { return } + let id = rows[current].id + model.releaseAgentFit() + openID = openID == id ? nil : id + case .up, .down: + guard let target = WidgetModel.listTarget(current: current, step: kind == .up ? -1 : 1, count: rows.count) + else { return } + focusID = rows[target].id + proxy.scrollTo(rows[target].id) + } + } + + private func list(_ items: [WidgetInboxItem]) -> some View { VStack(alignment: .leading, spacing: 10) { HStack(spacing: 8) { GenSegmentedTabs( @@ -128,6 +163,7 @@ struct WidgetInboxOverview: View { private func row(_ item: WidgetInboxItem) -> some View { let open = openID == item.id + let focused = focusID == item.id let session = model.session(forKey: item.key) return VStack(alignment: .leading, spacing: 0) { Button { toggle(item) } label: { @@ -170,6 +206,7 @@ struct WidgetInboxOverview: View { .buttonStyle(RowButtonStyle(cornerRadius: 11)) .accessibilityLabel(Self.kindLabel(item) + ": " + Self.title(item)) .accessibilityValue(open ? "Expanded" : "Collapsed") + .accessibilityAddTraits(focused ? .isSelected : []) .accessibilityIdentifier("widget.overview.row." + item.id) if open { detail(item, session: session).padding(.horizontal, 12).padding(.top, 2).padding(.bottom, 12) @@ -180,6 +217,10 @@ struct WidgetInboxOverview: View { .background(Color.white.opacity(open ? 0.055 : 0.03), in: RoundedRectangle(cornerRadius: 11)) .overlay(RoundedRectangle(cornerRadius: 11) .strokeBorder(item.needsAnswer ? Color.orange.opacity(0.28) : .clear, lineWidth: 1)) + // The keyboard's row: a focus ring, the look of the system's own. + .overlay(RoundedRectangle(cornerRadius: 11) + .strokeBorder(focused ? Color(nsColor: .keyboardFocusIndicatorColor) : .clear, lineWidth: 2)) + .id(item.id) } private func stagedLine(_ item: WidgetInboxItem) -> String? { diff --git a/src/macos/GenesisKit/Sources/GenesisKit/Widgets/WidgetKeymap.swift b/src/macos/GenesisKit/Sources/GenesisKit/Widgets/WidgetKeymap.swift index d11f434378..21c21f34ba 100644 --- a/src/macos/GenesisKit/Sources/GenesisKit/Widgets/WidgetKeymap.swift +++ b/src/macos/GenesisKit/Sources/GenesisKit/Widgets/WidgetKeymap.swift @@ -22,8 +22,13 @@ public enum WidgetKeymap { public static let quit = "widget.app.quit" public static let paste = "widget.media.paste" public static let collapse = "widget.panel.collapse" + /// J and K: the previous and next session. The ids keep their old names, so saved keys still apply. public static let previous = "widget.message.previous" public static let next = "widget.message.next" + /// Up, Down and Return in the list on screen: the inbox of every session, a session's inbox, the sessions. + public static let listUp = "widget.list.up" + public static let listDown = "widget.list.down" + public static let listOpen = "widget.list.open" public static let previewClose = "widget.preview.close" public static let previewPrevious = "widget.preview.previous" public static let previewNext = "widget.preview.next" @@ -37,7 +42,9 @@ public enum WidgetKeymap { public static let library = KeymapLibrary(id: "widget", title: "Agent Widget", commands: [ KeymapCommand(settings, "Widget Sessions", group: "App", keys: [.character(",", .command)], scope: .app), - KeymapCommand(hub, "Open Hub", group: "App", keys: [.character("h", .command)], scope: .app), + // ⌘O, not ⌘H: ⌘H hides the app on every Mac, and the hub binds it to Hide. + KeymapCommand(hub, "Open Hub", group: "App", detail: "Opens the hub at its Agents view. ⌘H stays Hide.", + keys: [.character("o", .command)], scope: .app), KeymapCommand(quit, "Quit", group: "App", keys: [.character("q", .command)], scope: .app, configurable: false), KeymapCommand(paste, "Paste Media", group: "Composer", keys: [ .character("v", .command), @@ -48,8 +55,21 @@ public enum WidgetKeymap { KeymapCommand(collapse, "Collapse", group: "Panel", detail: "Also while a text field has the focus. An open preview takes Esc first.", keys: [.code(53)]), - KeymapCommand(previous, "Previous Message", group: "Messages", keys: [.character("j")]), - KeymapCommand(next, "Next Message", group: "Messages", keys: [.character("k")]), + KeymapCommand(previous, "Previous Session", group: "Sessions", + detail: "Opens the session above the selected one in the widget's session list.", + keys: [.character("j")]), + KeymapCommand(next, "Next Session", group: "Sessions", + detail: "Opens the session below the selected one in the widget's session list.", + keys: [.character("k")]), + KeymapCommand(listUp, "Previous Row", group: "Lists", + detail: "Moves up in the inbox or session list on screen. Not while a text field has the focus.", + keys: [.code(126)]), + KeymapCommand(listDown, "Next Row", group: "Lists", + detail: "Moves down in the inbox or session list on screen. Not while a text field has the focus.", + keys: [.code(125)]), + KeymapCommand(listOpen, "Open Row", group: "Lists", + detail: "Opens or folds the row the arrows chose; in the session list, opens that session.", + keys: [.code(36)]), ] + numberRow.map { number, code in KeymapCommand(choiceID(number), "Answer \(number)", group: "Answers", detail: "The digit, or that place on the number row.", @@ -98,13 +118,9 @@ public enum WidgetKeymap { public static func label(_ id: String) -> String { shared.label(for: id) ?? "" } - /// "1–3 answer · J/K switch · ⎋ close", with the keys in force. + /// "1–3 answer · ↑/↓ move · J/K session · ⎋ close", with the keys in force. public static var agentFooter: String { - "\(label(choiceID(1)))–\(label(choiceID(3))) answer · \(label(previous))/\(label(next)) switch · \(label(collapse)) close" - } - - /// "⌘V media · ⎋ close". - public static var mediaFooter: String { - "\(label(paste)) media · \(label(collapse)) close" + "\(label(choiceID(1)))–\(label(choiceID(3))) answer · \(label(listUp))/\(label(listDown)) move · " + + "\(label(previous))/\(label(next)) session · \(label(collapse)) close" } } diff --git a/src/macos/GenesisKit/Sources/GenesisKit/Widgets/WidgetLayout.swift b/src/macos/GenesisKit/Sources/GenesisKit/Widgets/WidgetLayout.swift index 77f301b73f..c6371222bb 100644 --- a/src/macos/GenesisKit/Sources/GenesisKit/Widgets/WidgetLayout.swift +++ b/src/macos/GenesisKit/Sources/GenesisKit/Widgets/WidgetLayout.swift @@ -121,6 +121,11 @@ struct WidgetSideStripMetrics { static let sessionWidth: CGFloat = 38 static let sessionHeight: CGFloat = 34 static let sessionSpacing: CGFloat = 6 + /// A rail session bubble's target beyond its drawn 38 by 34 pt: half the gap to each neighbour and the rail's + /// spare width beside it, so a click between two bubbles reaches one of them (ux checklist, Hit targets). + static let sessionHitPadding = EdgeInsets( + top: sessionSpacing / 2, leading: (width - sessionWidth) / 2, + bottom: sessionSpacing / 2, trailing: (width - sessionWidth) / 2) static let minimumSpacer: CGFloat = 2 static let sessionLimit = 4 diff --git a/src/macos/GenesisKit/Sources/GenesisKit/Widgets/WidgetModel+Staging.swift b/src/macos/GenesisKit/Sources/GenesisKit/Widgets/WidgetModel+Staging.swift index d1499c890f..fab7904909 100644 --- a/src/macos/GenesisKit/Sources/GenesisKit/Widgets/WidgetModel+Staging.swift +++ b/src/macos/GenesisKit/Sources/GenesisKit/Widgets/WidgetModel+Staging.swift @@ -113,24 +113,32 @@ extension WidgetModel { return Plural.count(items.count, "decision") + " \(verb) to " + Plural.count(sessions, "session") } - /// Sends the staged decisions `ids`: one message per session. - public func sendStaged(_ ids: [String]) { + /// Sends the staged decisions `ids`: one message per session. With `thenClose` (a send on the way out of the + /// panel) the panel stays open until the hub answers and closes only when every answer was delivered, so a send + /// that failed is read where it happened instead of in a closed panel (ux catalogue P1). + public func sendStaged(_ ids: [String], thenClose: Bool = false) { let chosen = stagedItems.filter { ids.contains($0.sourceId) } - guard !chosen.isEmpty else { return } - deliverDecisions(chosen, action: "send-staged") + guard !chosen.isEmpty else { + if thenClose { collapse() } + return + } + deliverDecisions(chosen, action: "send-staged", thenClose: thenClose) } /// Sends again answered decisions that no session took (the notice's Send again), for every kind of agent. public func resendAnswers(_ items: [WidgetInboxItem]) { guard !items.isEmpty else { return } - notice = "Sending again…" + postNote("Sending again…") deliverDecisions(items, action: "resend-answers") } - /// One message per session through the hub, then a notice that says what really happened: delivered, waiting - /// for the session's next prompt (with why, and Send again), or not sent at all. It waits for the hub writes - /// queued before it, so a pick changed a moment ago is the one that goes. - private func deliverDecisions(_ items: [WidgetInboxItem], action: String) { + /// One message per session through the hub, then a note that says what really happened: delivered, waiting + /// for the session's next prompt (with why, and Send again), or not sent at all (an error, with the hub's own + /// words behind Details). When the panel was closed meanwhile, the sessions that took nothing flash on the rail. + private func deliverDecisions(_ items: [WidgetInboxItem], action: String, thenClose: Bool = false) { + let staged = action == "send-staged" + let opened = openedAt + if thenClose { postNote("Sending " + Plural.count(items.count, "decision") + "…") } var targets: [String: WidgetJSON] = [:] var titles: [String: String] = [:] for item in items { @@ -150,20 +158,45 @@ extension WidgetModel { "targets": .object(targets), ]) let outcome = WidgetDecisionSendOutcome(result, titles: titles) - self.notice = outcome.notice(items: items, staged: action == "send-staged") + let text = outcome.notice(items: items, staged: staged) + if let details = outcome.details { PerfLog.mark("widget.decision-send \(action) not delivered: \(details)") } let waiting = items.filter { item in self.session(forKey: item.key).map { outcome.queuedSessions.contains($0.target.sessionId) } ?? false } - if !waiting.isEmpty { - self.noticeAction = WidgetNoticeAction(title: "Send again") { [weak self] in self?.resendAnswers(waiting) } + if outcome.delivered { + postNote(text) + // Only the panel this send left from: one the user opened again meanwhile stays open. + if thenClose, expanded != nil, openedAt == opened { collapse() } + return + } + if outcome.failed > 0 { + fail(text, detail: outcome.details) + if staged { keptStaged.subtract(items.map(\.sourceId)) } + } else { + postNote(text, action: waiting.isEmpty ? nil + : WidgetNoticeAction(title: "Send again") { [weak self] in self?.resendAnswers(waiting) }) } + flashUnsent(waiting.isEmpty ? items : waiting) } catch { if action == "resend-answers" { self.notice = nil } - self.report(error) + PerfLog.mark("widget.decision-send \(action) failed: \(error.localizedDescription)") + fail(staged ? "The decisions could not be sent. They stay staged, so you can send them again." + : "The answers could not be sent again. They still wait for the session's next prompt.", + detail: error.localizedDescription) + if staged { keptStaged.subtract(items.map(\.sourceId)) } + flashUnsent(items) } } } + /// A send result that arrives after the panel closed: the sessions it concerns flash on the rail (the alert flash + /// setting), so the note waiting in the panel is noticed. + private func flashUnsent(_ items: [WidgetInboxItem]) { + guard expanded == nil else { return } + let seconds = WidgetAlerts.flashSeconds(snapshot?.state.preferences) + for key in Set(items.map(\.key)) { flashAttention(key, seconds: seconds) } + } + /// Discards the picks and notes of the staged decisions `ids`; they still wait for an answer. public func discardStaged(_ ids: [String]) { guard !ids.isEmpty else { return } @@ -176,8 +209,10 @@ extension WidgetModel { /// A close the user asked for (Esc, the close button, a click outside, the idle timer): with staged decisions it /// asks first, as `stagedOnLeave` says ("ask" by default; "keep" closes, "send" sends them and closes). public func requestCollapse() { - let ids = Set(stagedItems.map(\.sourceId)) - guard expanded != nil, !ids.isEmpty else { + // Staged decisions answered at an earlier leave (kept, or already on their way) do not ask again, so a + // second Esc while a send runs closes the panel. + let fresh = Set(stagedItems.map(\.sourceId)).subtracting(keptStaged) + guard expanded != nil, !fresh.isEmpty else { collapse() return } @@ -185,27 +220,27 @@ extension WidgetModel { case "keep": collapse() case "send": - sendStaged(Array(ids)) - collapse() + keptStaged.formUnion(fresh) + sendStaged(Array(fresh), thenClose: true) default: - guard !ids.isSubset(of: keptStaged) else { - collapse() - return - } stagedLeavePrompt = true } } - /// The answer to the leave question: "send", "keep" or "discard". The panel closes after any of them. + /// The answer to the leave question: "send", "keep" or "discard". Keep and Discard close the panel at once; + /// Send closes it once the hub says every answer was delivered, and leaves it open with the reason otherwise. public func resolveStagedLeave(_ choice: String) { let ids = stagedItems.map(\.sourceId) stagedLeavePrompt = false + stagedView = nil switch choice { - case "send": sendStaged(ids) + case "send": + keptStaged.formUnion(ids) + sendStaged(ids, thenClose: true) + return case "discard": discardStaged(ids) default: keptStaged = Set(ids) } - stagedView = nil collapse() } } @@ -227,10 +262,20 @@ struct WidgetDecisionSendOutcome { var sent = 0 var queued = 0 var failed = 0 - /// Session ids whose answers no receiver took, with the hub's one-line reason and the session's title. - var waiting: [(session: String, title: String, reason: String)] = [] + /// Sessions whose answers no receiver took: the session id, its title, the reason as one sentence, and the hub's + /// own words (for Details and the log). + var waiting: [(session: String, title: String, reason: String, detail: String?)] = [] + /// The hub's own words for each session whose send threw, as ": <error>". + var failures: [String] = [] var queuedSessions: Set<String> { Set(waiting.map(\.session)) } + /// Every answer reached a live receiver. + var delivered: Bool { waiting.isEmpty && failed == 0 } + /// The hub's raw reasons, one line per session, for Details and the log; nil when it gave none. + var details: String? { + let lines = waiting.compactMap { entry in entry.detail.map { "\(entry.title): \($0)" } } + failures + return lines.isEmpty ? nil : lines.joined(separator: "\n") + } init(_ result: WidgetJSON, titles: [String: String]) { guard case .object(let fields) = result else { return } @@ -239,22 +284,63 @@ struct WidgetDecisionSendOutcome { if case .number(let value) = fields["failed"] { failed = Int(value) } guard case .array(let sessions) = fields["sessions"] else { return } for case .object(let entry) in sessions { - guard case .string(let session) = entry["session"], case .object(let answer) = entry["result"], - answer["delivered"] != .bool(true) else { continue } - var reason = "No live receiver took them." - if case .string(let detail) = answer["detail"], !detail.isEmpty { reason = detail } - waiting.append((session, titles[session] ?? "the session", reason)) + guard case .string(let session) = entry["session"] else { continue } + let title = titles[session] ?? "the session" + if case .string(let error) = entry["error"], !error.isEmpty { + failures.append("\(title): \(error)") + continue + } + guard case .object(let answer) = entry["result"], answer["delivered"] != .bool(true) else { continue } + var detail: String? + if case .string(let text) = answer["detail"], !text.isEmpty { detail = text } + waiting.append((session, title, Self.sentence(for: detail), detail)) } } - /// The notice line: a plain summary when everything went out, else what did not and why. + /// Why no receiver took the answers, in one sentence. The hub words its reasons for logs ("No native Claude + /// receiver claims this session.", "no cmux pane runs this session"); the known ones are matched here, and + /// anything else reads as the generic sentence with the hub's text behind Details (ux catalogue P24). The hub's + /// reasons: `src/question/lib/decisions/deliver.ts` (`resolveDeliveryTarget`, `nativeWorkers`, `undelivered`) + /// and `delivery-text.ts` (`deliverySentence`). + static func sentence(for detail: String?) -> String { + let generic = "No running agent took them." + guard let text = detail?.lowercased(), !text.isEmpty else { return generic } + let known: [(needle: String, sentence: String)] = [ + ("worker is busy", "Its worker is busy with another turn."), + ("worker session has not started", "Its worker has not started yet."), + ("not ready for another turn", "Its worker is not ready for another turn."), + ("claude terminals run this session", + "Several Claude terminals run this session, so none was picked. Close the extra one, then send again."), + ("no native claude receiver", "No open Claude terminal takes messages for this session."), + ("receiver belongs to another source home", "Its Claude terminal runs under another account."), + ("worker belongs to another source home", "Its worker runs under another account."), + ("several owned workers match", "Several workers match this session, so none was picked."), + ("worker runs this thread", "No Codex worker runs this conversation."), + ("cmux is not running", "cmux is not running."), + ("workspace of this session was closed", "Its cmux workspace was closed."), + ("no cmux pane runs this session", "No cmux pane runs this session."), + ("cmux panes match this session", "Several cmux panes match this session, so none was picked."), + ("does not identify the exact recipient", "No cmux pane could be matched to this session for certain."), + ("cmux send failed", "cmux could not type the answers."), + ("too large to save for later", "The answers are too long to keep for a later delivery."), + ("already saved this message", "An earlier send already saved them for this session."), + ] + return known.first { text.contains($0.needle) }?.sentence ?? generic + } + + /// The note: a plain summary when everything went out, else what did not and why. func notice(items: [WidgetInboxItem], staged: Bool) -> String { var parts: [String] = [] if sent > 0 { parts.append(Plural.count(sent, "decision") + " delivered.") } if let first = waiting.first { - let reason = first.reason.hasSuffix(".") ? first.reason : first.reason + "." - let place = waiting.count == 1 ? first.title : Plural.count(waiting.count, "session") - parts.append("Not delivered to \(place): \(reason) The answers wait for its next prompt.") + if waiting.count == 1 { + parts.append("Not delivered to \(first.title): \(first.reason) The answers wait for its next prompt.") + } else { + // One reason for all of them is worth saying; several differ per session and live behind Details. + let why = Set(waiting.map(\.reason)).count == 1 ? first.reason + " " : "" + parts.append("Not delivered to \(Plural.count(waiting.count, "session")): \(why)" + + "The answers wait for each session's next prompt.") + } } if failed > 0 { parts.append("\(Plural.count(failed, "decision")) could not be sent" + (staged ? " and stay staged." : ".")) diff --git a/src/macos/GenesisKit/Sources/GenesisKit/Widgets/WidgetModel.swift b/src/macos/GenesisKit/Sources/GenesisKit/Widgets/WidgetModel.swift index bad90cd196..7174ceb166 100644 --- a/src/macos/GenesisKit/Sources/GenesisKit/Widgets/WidgetModel.swift +++ b/src/macos/GenesisKit/Sources/GenesisKit/Widgets/WidgetModel.swift @@ -84,7 +84,18 @@ public final class WidgetModel: ObservableObject { private var exitedDuringDrag: WidgetSurfaceID? private var hoveredSession: (key: String, surface: WidgetSurfaceID)? private var transcriptHoverTask: Task<Void, Never>? - @Published public var error: String? + /// One sentence about what failed. An error never fades: it stays until the user closes it or a new one replaces + /// it. A new error is announced to VoiceOver. A new or cleared error drops `errorDetail`, so set the detail after + /// the sentence (`fail(_:detail:)` does both). + @Published public var error: String? { + didSet { + guard error != oldValue else { return } + errorDetail = nil + if let error { GenAnnounce.post(error, priority: .high) } + } + } + /// The raw text behind `error` (a process's stderr), shown only behind a "Details" disclosure. + @Published public var errorDetail: String? @Published public var drafts: [String: WidgetDraft] = [:] /// The watch worker exited on its own. The snapshot is stale and nothing dispatches queued messages until /// `start()` runs again, so the view keeps a Reconnect action visible even after the error is dismissed. @@ -126,6 +137,14 @@ public final class WidgetModel: ObservableObject { @Published public var sessionChooserOpen = false /// Set by a session's cmux popover alone, for the same reason. @Published public var cmuxPopoverOpen = false + /// A list row's "Open cmux" accessibility action, for that row's `WidgetCmuxButton` (which exists only under the + /// pointer) to open its popover. The button clears it. + @Published public var cmuxRequest: WidgetCmuxRequest? + + /// Opens the cmux popover of `key`'s row in the list `scope` (its `WidgetCmuxButton(requestScope:)`). + public func requestCmux(_ key: String, scope: String?) { + cmuxRequest = WidgetCmuxRequest(key: key, scope: scope) + } /// Hover previews and the outside-click collapse stay off while a window, picker or the chooser is open. public var dialogOpen: Bool { get { hostDialogOpen || sessionChooserOpen || cmuxPopoverOpen } @@ -172,12 +191,70 @@ public final class WidgetModel: ObservableObject { public var openDestination: ((WidgetSession, String, String?) -> Void)? /// Called with each preference patch once the hub has stored it. public var preferencesSaved: (([String: WidgetJSON]) -> Void)? - /// One line under the header; a new line drops the button the old one had. + /// One line under the header; a new line drops the button the old one had. A note fades by itself + /// (`scheduleNoticeFade`) and is announced to VoiceOver; prefer `postNote(_:action:)`, which also restarts the fade + /// and the announcement when the same text is posted again. @Published public var notice: String? { - didSet { if notice != oldValue { noticeAction = nil } } + didSet { + guard notice != oldValue else { return } + noticeAction = nil + // A hover that ended with the panel (it closed under the pointer) must not keep the next note forever. + noticeHeld = false + if let notice { GenAnnounce.post(notice) } + scheduleNoticeFade() + } + } + /// A button beside `notice`: "Send again" after a send no session took. A note with a button stays longer. + @Published public var noticeAction: WidgetNoticeAction? { + didSet { scheduleNoticeFade() } + } + private var noticeFade: Task<Void, Never>? + /// The pointer is on the note: its fade waits until the pointer leaves (ux checklist, Attention). + private var noticeHeld = false + + /// Shows `text` as the widget's note, with an optional button (Undo, Send again). It is announced, and it fades + /// after the note duration setting (`noteSeconds`, 6 s by default); a note with a button stays at least 10 s. A + /// newer note replaces it and cancels its fade. Errors go to `error`, which never fades. + public func postNote(_ text: String, action: WidgetNoticeAction? = nil) { + // The same text again does not change `notice`, so its didSet would neither announce nor restart the fade. + if notice == text { GenAnnounce.post(text) } + notice = text + noticeAction = action + scheduleNoticeFade() + } + + /// Seconds a note stays: the `noteSeconds` preference (0 keeps it until it is closed), at least 10 with a button. + nonisolated static func noteSeconds(_ preferences: WidgetPreferences?, hasAction: Bool) -> Double? { + let base = max(0, min(60, preferences?.noteSeconds ?? 6)) + guard base > 0 else { return nil } + return hasAction ? max(base, 10) : base + } + + /// The pointer entered or left the note: a note under the pointer does not fade, and leaving it starts the full + /// duration again. For the view that draws the note. + public func noticeHovered(_ inside: Bool) { + noticeHeld = inside + scheduleNoticeFade() + } + + private func scheduleNoticeFade() { + noticeFade?.cancel() + noticeFade = nil + guard let text = notice, !noticeHeld, + let seconds = Self.noteSeconds(snapshot?.state.preferences, hasAction: noticeAction != nil) else { return } + noticeFade = Task { [weak self] in + do { try await Task.sleep(for: .seconds(seconds)) } catch { return } + guard let self, self.notice == text, !self.noticeHeld else { return } + self.notice = nil + } + } + + /// An error in one sentence, with the raw text behind it for "Details". + public func fail(_ message: String, detail: String? = nil) { + error = message + let trimmed = detail?.trimmingCharacters(in: .whitespacesAndNewlines) + errorDetail = trimmed?.isEmpty == false ? trimmed : nil } - /// A button beside `notice`: "Send again" after a send no session took. - @Published public var noticeAction: WidgetNoticeAction? public private(set) var openedAt: TimeInterval = 0 public let bridge: ToolsBridge public let appearance: NativeSettingsAppearance @@ -507,6 +584,9 @@ public final class WidgetModel: ObservableObject { } } + /// What the widget says when its watch process ends by itself; the process's stderr is `errorDetail`. + public static let connectionLostMessage = "Lost the connection to the hub. Reconnect to try again." + public func start() { guard watcher == nil else { return } stopping = false @@ -525,7 +605,9 @@ public final class WidgetModel: ObservableObject { guard let self, !self.stopping, !exit.stopped else { return } self.watcher = nil self.connectionLost = true - self.error = "Widget connection stopped. " + exit.stderr.suffix(600) + // One sentence on screen; the process's own words go behind Details (ux checklist, Errors). + self.fail(Self.connectionLostMessage, detail: String(exit.stderr.suffix(4000))) + PerfLog.mark("widget.connection lost status=\(exit.status) \(exit.stderr.suffix(600))") }) // Cleared only once a watcher runs: a reconnect that cannot launch keeps Reconnect on screen. connectionLost = false @@ -752,6 +834,19 @@ public final class WidgetModel: ObservableObject { } } + /// The popup's close button, Esc and VoiceOver's escape: the popup ends without opening anything. + public func dismissPeek() { + guard let item = peekItem else { return } + PerfLog.mark("widget.peek dismissed id=\(item.id)") + hoverTask?.cancel() + endPeek() + // The pointer is on the panel (it pressed ×): without this the hover list would open in the popup's place. + if expanded == nil, let surface = hoveredSurface, presentation(for: surface) == .preview { + hoveredSurface = nil + presentationChanged?() + } + } + private func endPeek() { peekTask?.cancel() peekTask = nil @@ -909,6 +1004,9 @@ public final class WidgetModel: ObservableObject { ? railActive : railActive.sorted(by: Self.previewOrder(inbox: inboxSessions, sort: previewSort)) inboxOverviewItems = Self.overviewItems(value.sessions) reconcileStaged(value.staged ?? value.sessions.flatMap { $0.items ?? [] }) + if !inboxFilterPicked, let opening = snapshot?.state.preferences.inboxFilter, opening != inboxFilter { + inboxFilter = opening + } previewList = Self.previewRows( sessions: snapshot?.sessions ?? [], inbox: inboxSessions, filter: previewFilter, sort: previewSort) @@ -1004,7 +1102,12 @@ public final class WidgetModel: ObservableObject { style.fadeOut = 0.3 return style } + /// Every unread or waiting inbox item: what "Mark all read" acts on. public var inboxCount: Int { max(0, inbox.unread) + max(0, inbox.needsAnswer) } + /// The rail badge: only the items that wait for the user's answer (open or drafted decisions and pending + /// questions), the kind of item the hub's Inbox counts. Unread answers and results do not count: a badge of weeks + /// of read-later items read as 99+ while the hub said 26 (ux catalogue R1). + public var waitingCount: Int { max(0, inbox.needsAnswer) } /// Expands one inbox card in place. An unread answer or result counts as read once the user opens it, the same /// way opening it from a notification does. @@ -1229,6 +1332,95 @@ public final class WidgetModel: ObservableObject { select(sessions[(index + direction + sessions.count) % sessions.count].key) } + // MARK: Up, Down and Return in the lists (ux catalogue R7) + + /// The last list key, for the view that draws the list on screen: the inbox of every session moves its own + /// highlight with it, and a session's inbox and the session list scroll to the row it chose. + @Published public private(set) var listKey: WidgetListKey? + /// The session inbox's filter ("all", "inbox", "decisions"), so the keys walk the rows the user sees. The + /// inbox's filter control writes it. + @Published public var inboxFilter = "all" + /// Set once the reader picks a filter; until then the filter follows the `inboxFilter` setting. + public var inboxFilterPicked = false + /// The session list's rows in the order it draws them (its search, pins and open groups applied), written by + /// the list as it changes. Empty: the list has not said, and the keys leave it alone. + public var sessionListOrder: [String] = [] + + /// Whether the agents pane shows a list that takes Up, Down and Return: the inbox of every session, a session's + /// inbox with entries, or the session list. Not the staged view, the leave question or the conversation. + public var keyboardList: Bool { + guard expanded != nil, activeModuleID == "agents", stagedView == nil, !stagedLeavePrompt else { return false } + if inboxOverview { return !inboxOverviewItems.isEmpty } + guard selected != nil else { return false } + return (section == "Sessions" && !sessionListOrder.isEmpty) || (section == "Inbox" && !cards.isEmpty) + } + + /// Up, Down or Return in the list on screen. True when the list took the key; false lets it through (the scroll + /// view then scrolls). The ends of a list stop the move; they do not wrap. + @discardableResult + public func moveInList(_ kind: WidgetListKey.Kind) -> Bool { + guard keyboardList else { return false } + listKey = WidgetListKey(kind: kind, serial: (listKey?.serial ?? 0) + 1) + // The inbox of every session owns its filter, grouping and highlight (WidgetInboxOverview). + if inboxOverview { return true } + switch section { + case "Inbox": return moveCard(kind) + case "Sessions": return moveSession(kind) + default: return false + } + } + + /// The row `step` rows from `current` in `count` rows, stopping at the ends; with no current row, the first row + /// going down and the last going up. + nonisolated static func listTarget(current: Int?, step: Int, count: Int) -> Int? { + guard count > 0 else { return nil } + guard let current else { return step < 0 ? count - 1 : 0 } + return min(max(current + step, 0), count - 1) + } + + /// A session's inbox, as drawn: the filter's rows, newest first. Up and Down open the row above or below (the + /// inbox shows one open row); Return folds or opens the current one. No motion: the keys repeat. + private func moveCard(_ kind: WidgetListKey.Kind) -> Bool { + let shown = Array(LiveWidgetView.filtered(cards, by: inboxFilter).reversed()) + guard !shown.isEmpty else { return false } + let current = card.flatMap { open in shown.firstIndex { $0.id == open.id } } + releaseAgentFit() + switch kind { + case .open: + guard let current else { return false } + if foldedCardIDs.contains(shown[current].id) { + openCard(shown[current]) + } else { + foldedCardIDs.insert(shown[current].id) + } + case .up, .down: + guard let target = Self.listTarget(current: current, step: kind == .up ? -1 : 1, count: shown.count), + target != current || foldedCardIDs.contains(shown[target].id) else { return true } + openCard(shown[target]) + } + return true + } + + /// The session list: Up and Down select the session above or below (its row shows the selection), Return opens + /// its inbox, and Esc comes back to the list. + private func moveSession(_ kind: WidgetListKey.Kind) -> Bool { + let order = sessionListOrder + guard !order.isEmpty else { return false } + let current = order.firstIndex(of: selectedKey) + switch kind { + case .open: + guard current != nil else { return false } + listOrigin = .sessions + section = "Inbox" + case .up, .down: + guard let target = Self.listTarget(current: current, step: kind == .up ? -1 : 1, count: order.count), + order[target] != selectedKey else { return true } + select(order[target]) + } + return true + } + + public func setText(_ text: String) { let key = selectedKey guard !key.isEmpty else { return } @@ -1973,7 +2165,10 @@ public final class WidgetModel: ObservableObject { guard let self else { return } // Only a clean exit proves the audio writer closed the clip. self.finishVoice(fileClip: exit.status == 0) - if exit.status != 0 && !exit.stopped { self.error = String(exit.stderr.suffix(600)) } + if exit.status != 0 && !exit.stopped { + self.fail("Dictation stopped unexpectedly. Press the microphone to try again.", + detail: String(exit.stderr.suffix(4000))) + } }) } catch { self.voiceActive = false @@ -2309,3 +2504,17 @@ public enum WidgetVoiceMode: String, Sendable { /// Words into the reply box, and the audio kept as a voice note with its session and decision. case note } + +/// A key the user pressed in the widget's list on screen (Up, Down, Return). `serial` makes two presses of the same +/// key two changes, so a view can react to each. +public struct WidgetListKey: Equatable, Sendable { + public enum Kind: Equatable, Sendable { case up, down, open } + public let kind: Kind + public let serial: Int +} + +/// Which session's cmux popover a row's accessibility action asked for, and in which list. +public struct WidgetCmuxRequest: Equatable, Sendable { + public let key: String + public let scope: String? +} diff --git a/src/macos/GenesisKit/Sources/GenesisKit/Widgets/WidgetSessionBrowser.swift b/src/macos/GenesisKit/Sources/GenesisKit/Widgets/WidgetSessionBrowser.swift index 77bdd01878..3d25d7e029 100644 --- a/src/macos/GenesisKit/Sources/GenesisKit/Widgets/WidgetSessionBrowser.swift +++ b/src/macos/GenesisKit/Sources/GenesisKit/Widgets/WidgetSessionBrowser.swift @@ -18,6 +18,10 @@ public struct WidgetSessionBrowser: View { public var body: some View { let _ = RenderProbe.hit("widget.sessions") let groups = WidgetAgentTree.groups(model.snapshot?.sessions ?? [], query: query, onlyPinned: onlyPinned) + // The rows in the order drawn, for the arrow keys. + let order = groups.prefix(limit).flatMap { group in + [group.parent.key] + (isOpen(group) ? group.children.map(\.session.key) : []) + } LazyVStack(alignment: .leading, spacing: 10) { HStack(spacing: 8) { Image(systemName: "magnifyingglass").foregroundStyle(.secondary) @@ -74,6 +78,8 @@ public struct WidgetSessionBrowser: View { } .onChange(of: query) { _, _ in limit = 40 } .onChange(of: onlyPinned) { _, _ in limit = 40 } + .onChange(of: order, initial: true) { _, keys in model.sessionListOrder = keys } + .onDisappear { model.sessionListOrder = [] } } private var projectFilter: some View { @@ -128,14 +134,14 @@ public struct WidgetSessionBrowser: View { let selected = session.key == model.selectedKey return WidgetHoverRow { hovering in HStack(alignment: .center, spacing: 2) { if group.children.isEmpty { - Color.clear.frame(width: 18, height: 18) + Color.clear.frame(width: 22, height: 22) } else { Button { toggle(group) } label: { Image(systemName: "chevron.right").font(.system(size: 9, weight: .semibold)) .foregroundStyle(.secondary).rotationEffect(.degrees(isOpen(group) ? 90 : 0)) - .frame(width: 18, height: 26).contentShape(Rectangle()) + .frame(width: 16, height: 16).contentShape(Rectangle()) } - .buttonStyle(.genHoverPlain()) + .buttonStyle(.genHoverIcon(diameter: 22)) .instantTooltip(isOpen(group) ? "Hide its sub-agents" : "Show its \(Plural.count(group.children.count, "sub-agent"))") .accessibilityLabel((isOpen(group) ? "Hide sub-agents of " : "Show sub-agents of ") + session.title) .accessibilityIdentifier("widget.sessions.group." + group.id) @@ -160,6 +166,8 @@ public struct WidgetSessionBrowser: View { trailing(session, hovering: hovering) } } .padding(.horizontal, 4) + .accessibilityAction(named: "Open cmux for " + session.title) { model.requestCmux(session.key, scope: "sessions") } + .id(session.key) .background(selected ? Color.blue.opacity(0.13) : .clear, in: RoundedRectangle(cornerRadius: 10)) .overlay(RoundedRectangle(cornerRadius: 10).strokeBorder(selected ? Color.blue.opacity(0.35) : .clear)) } @@ -189,6 +197,8 @@ public struct WidgetSessionBrowser: View { trailing(session, hovering: hovering) } } .padding(.leading, CGFloat(min(depth, 4)) * 14).padding(.trailing, 4) + .accessibilityAction(named: "Open cmux for " + session.title) { model.requestCmux(session.key, scope: "sessions") } + .id(session.key) .background(selected ? Color.blue.opacity(0.13) : .clear, in: RoundedRectangle(cornerRadius: 10)) .overlay(RoundedRectangle(cornerRadius: 10).strokeBorder(selected ? Color.blue.opacity(0.35) : .clear)) } @@ -198,7 +208,7 @@ public struct WidgetSessionBrowser: View { private func trailing(_ session: WidgetSession, hovering: Bool) -> some View { HStack(spacing: 0) { WidgetCmuxButton(model: model, session: session, size: 10.5, - live: hovering || session.key == model.selectedKey) + live: hovering || session.key == model.selectedKey, requestScope: "sessions") Button { model.action(["action": "visibility", "key": .string(session.key), "pinned": .bool(!session.pinned)]) } label: { diff --git a/src/macos/GenesisKit/Sources/GenesisKit/Widgets/WidgetStagedViews.swift b/src/macos/GenesisKit/Sources/GenesisKit/Widgets/WidgetStagedViews.swift index 034bf23988..0a06a09d6b 100644 --- a/src/macos/GenesisKit/Sources/GenesisKit/Widgets/WidgetStagedViews.swift +++ b/src/macos/GenesisKit/Sources/GenesisKit/Widgets/WidgetStagedViews.swift @@ -50,6 +50,7 @@ struct WidgetStagedList: View { @GenReduceMotion private var reduceMotion /// Unchecked decision ids; everything else is checked, so a newly staged one goes by default. @State private var skipped: Set<String> = [] + @GenReduceMotion private var reduceMotion private var scopeAll: Bool { model.stagedView == "all" || model.sessionStaged.isEmpty } private var items: [WidgetInboxItem] { scopeAll ? model.stagedItems : model.sessionStaged } @@ -95,6 +96,7 @@ struct WidgetStagedList: View { } .buttonStyle(.borderedProminent).disabled(chosen.isEmpty) .keyboardShortcut(.return, modifiers: [.command]) + .instantTooltip("Sends the checked decisions now, one message per session (⌘↩)") Button("Discard checked") { model.discardStaged(chosen.map(\.sourceId)) } .buttonStyle(.genHover()).disabled(chosen.isEmpty) .instantTooltip("Clears the picks and notes; the decisions still wait for an answer.") @@ -155,11 +157,15 @@ struct WidgetStagedLeavePrompt: View { .font(.system(size: 13, weight: .semibold)).fixedSize(horizontal: false, vertical: true) Text(WidgetModel.stagedSummary(items) + (items.count == 1 ? " if you send it now." : " if you send them now.")) .font(.system(size: 11)).foregroundStyle(.secondary) + // Return and Esc keep the answers (the safe choice); sending cannot be undone, so it needs ⌘↩ or a click. HStack(spacing: 8) { - Button("Send now") { model.resolveStagedLeave("send") } - .buttonStyle(.borderedProminent).keyboardShortcut(.return, modifiers: []) Button("Keep staged (send later)") { model.resolveStagedLeave("keep") } - .buttonStyle(.genHover()) + .buttonStyle(.borderedProminent).keyboardShortcut(.defaultAction) + .instantTooltip("Closes the panel; the decisions stay staged for later (↩)") + Button("Send now") { model.resolveStagedLeave("send") } + .buttonStyle(.genHover(accent: SessionPalette.blue)) + .keyboardShortcut(.return, modifiers: [.command]) + .instantTooltip("Sends them now, one message per session (⌘↩)") Button("Discard") { model.resolveStagedLeave("discard") } .buttonStyle(.genHover(accent: .red)) .instantTooltip("Clears the picks and notes; the decisions still wait for an answer.") diff --git a/src/macos/GenesisKit/Sources/GenesisKit/Widgets/WidgetWireModels.swift b/src/macos/GenesisKit/Sources/GenesisKit/Widgets/WidgetWireModels.swift index 4b8241e733..e5e7a65612 100644 --- a/src/macos/GenesisKit/Sources/GenesisKit/Widgets/WidgetWireModels.swift +++ b/src/macos/GenesisKit/Sources/GenesisKit/Widgets/WidgetWireModels.swift @@ -316,6 +316,15 @@ public struct WidgetPreferences: Codable, Equatable, Sendable { public var overviewFilter: String? /// The composer's microphone: "note" (default, a voice note whose transcript fills the field) or "live". public var composerVoice: String? + /// Seconds the alert popup stays when the pointer is not on it. Default 7. + public var alertPopupSeconds: Double? + /// Seconds a widget note stays before it fades; 0 keeps it until it is closed. Default 6. A note with a button + /// stays at least 10 s. + public var noteSeconds: Double? + /// A global shortcut opens and closes the widget (`openShortcut`). Default on, with ⌃⌥⌘W. + public var openShortcutEnabled: Bool? + /// The shortcut, as a Carbon key code and modifier mask (`HotkeyChord`). + public var openShortcut: WidgetShortcutChord? public var layout: WidgetLayoutConfiguration { WidgetLayoutConfiguration( @@ -330,6 +339,30 @@ public struct WidgetPreferences: Codable, Equatable, Sendable { public var voiceModel: String? public var voiceLanguage: String } +/// A global shortcut as the widget preferences store it: a Carbon virtual key code and a Carbon modifier mask. +public struct WidgetShortcutChord: Codable, Equatable, Sendable { + public var keyCode: UInt32 + public var modifiers: UInt32 + + public init(keyCode: UInt32, modifiers: UInt32) { + self.keyCode = keyCode + self.modifiers = modifiers + } + + /// ⌃⌥⌘W: the dictation shortcut's family (⌃⌥⌘D), and no macOS or common app shortcut. + public static let defaultOpen = WidgetShortcutChord( + keyCode: 0x0D, modifiers: HotkeyChord.control | HotkeyChord.option | HotkeyChord.command) + + public var hotkey: HotkeyChord { HotkeyChord(keyCode: keyCode, modifiers: modifiers) } +} + +extension WidgetPreferences { + /// The shortcut that opens the widget, or nil while it is off. + public var openShortcutChord: WidgetShortcutChord? { + (openShortcutEnabled ?? true) ? openShortcut ?? .defaultOpen : nil + } +} + public struct WidgetOutgoing: Codable, Identifiable, Equatable, Sendable { public struct Receipt: Codable, Equatable, Sendable { public var channel: String diff --git a/src/macos/GenesisKit/Sources/GenesisKitBase/Permissions/ApprovalAttention.swift b/src/macos/GenesisKit/Sources/GenesisKitBase/Permissions/ApprovalAttention.swift new file mode 100644 index 0000000000..64fbb8048d --- /dev/null +++ b/src/macos/GenesisKit/Sources/GenesisKitBase/Permissions/ApprovalAttention.swift @@ -0,0 +1,191 @@ +import AppKit +import UserNotifications + +/// How a waiting approval request (the vault key, an AI account) gets the user's attention. The panel never takes the +/// keyboard by itself, so a stray Return cannot answer it; without these cues a request on another screen or Space +/// waited out its deadline unseen. Both cues are settings of the host app (GenesisTools Settings, "Approval +/// requests"); the values here are the defaults. +public enum ApprovalAttention { + /// The sound a request plays when it appears: a name from /System/Library/Sounds, or "none". + public static let soundKey = "approvals.sound" + /// Its own sound, so it is not taken for the widget's inbox chime (Glass); Purr is soft and under 1 s. + public static let defaultSound = "Purr" + public static let sounds = [ + "Purr", "Bottle", "Tink", "Frog", "Morse", "Basso", "Glass", "Ping", "Pop", "Submarine", "Hero", "Funk", "Blow", + "Sosumi", + ] + + /// Seconds a request waits before a notification follows it; 0 posts it at once, -1 never. + public static let notifyAfterKey = "approvals.notifyAfterSeconds" + public static let defaultNotifyAfter = 15 + public static let notifyAfterChoices = [ + ApprovalNotifyChoice(seconds: 0, title: "At once"), + ApprovalNotifyChoice(seconds: 15, title: "After 15 seconds"), + ApprovalNotifyChoice(seconds: 30, title: "After 30 seconds"), + ApprovalNotifyChoice(seconds: 60, title: "After 1 minute"), + ApprovalNotifyChoice(seconds: -1, title: "Never"), + ] + + public static var sound: String { + UserDefaults.standard.string(forKey: soundKey) ?? defaultSound + } + + public static var notifyAfter: Int { + UserDefaults.standard.object(forKey: notifyAfterKey) as? Int ?? defaultNotifyAfter + } + + /// When the reminder for a request that appears now would be posted; nil when it would not be posted at all: the + /// setting says never, or the request denies itself within 5 s of it, when the card could only mislead. + public static func reminderDelay(notifyAfter: Int, deadline: Date?, now: Date = Date()) -> TimeInterval? { + guard notifyAfter >= 0 else { return nil } + let delay = TimeInterval(notifyAfter) + if let deadline, delay > deadline.timeIntervalSince(now) - 5 { return nil } + return delay + } + + /// Plays `name` once; "none" or an unknown name is silent. `NSSound.play` waits for the output device (about + /// 0.8 s on the main thread, measured for the widget's chime), so it plays from a background queue. + public static func play(_ name: String) { + guard name != "none" else { return } + queue.async { + guard let player = NSSound(named: NSSound.Name(name)) else { + KitHost.log("approval unknown sound \(name)") + return + } + player.play() + playing.append(player) + if playing.count > 2 { playing.removeFirst() } + } + } + + private static let queue = DispatchQueue(label: "genesiskit.approval.sound", qos: .userInitiated) + /// Keeps the last players alive until they finish; a released NSSound stops at once. + private nonisolated(unsafe) static var playing: [NSSound] = [] +} + +public struct ApprovalNotifyChoice: Identifiable, Hashable, Sendable { + public let seconds: Int + public let title: String + + public var id: Int { seconds } +} + +/// One request's cues: the sound and the VoiceOver line when it appears, the notification while it still waits, and +/// the cleanup when it is answered or its deadline passes. +@MainActor +final class ApprovalReminder { + private let model: ApprovalDialogModel + private let onClick: () -> Void + private let token = UUID().uuidString + private var pending: DispatchWorkItem? + private var expiry: DispatchWorkItem? + private var postedID: String? + private var relay: ApprovalNotificationRelay? + + init(model: ApprovalDialogModel, onClick: @escaping () -> Void) { + self.model = model + self.onClick = onClick + } + + func start() { + let sound = ApprovalAttention.sound + ApprovalAttention.play(sound) + GenAnnounce.post("Approval needed. \(model.title)", priority: .high) + let delay = ApprovalAttention.reminderDelay(notifyAfter: ApprovalAttention.notifyAfter, deadline: model.deadline) + KitHost.log("approval \(model.identifier) shown sound=\(sound) reminder=\(delay.map { "\(Int($0))s" } ?? "off")") + guard let delay else { return } + + let work = DispatchWorkItem { [weak self] in + MainActor.assumeIsolated { self?.post() } + } + pending = work + DispatchQueue.main.asyncAfter(deadline: .now() + delay, execute: work) + + // The card never outlives the request: the caller's own deadline ends its process right after this. + if let deadline = model.deadline { + let end = DispatchWorkItem { [weak self] in + MainActor.assumeIsolated { self?.stop() } + } + expiry = end + DispatchQueue.main.asyncAfter(deadline: .now() + max(0, deadline.timeIntervalSinceNow - 0.5), execute: end) + } + } + + private func post() { + pending = nil + guard !model.answered, NotificationEngine.shared.centerProvider() != nil else { return } + // A click on the card must reach this process's panel, and must not reach the host's own click handler, + // which ends a process it finds without a route. + let center = UNUserNotificationCenter.current() + let relay = ApprovalNotificationRelay(previous: center.delegate, onClick: onClick) + center.delegate = relay + self.relay = relay + + let body = [model.purpose, model.command].compactMap { $0 }.joined(separator: " ") + let result = NotificationEngine.shared.post(EngineNotification( + source: "approval", title: model.title, subtitle: "Waiting for your answer", body: body, + idPrefix: "approval.\(model.identifier)", replaceKey: "approval.\(model.identifier)", + userInfo: [ApprovalNotificationRelay.tokenKey: token], + sound: ApprovalAttention.sound == "none" ? nil : UNNotificationSound.default)) + if case let .posted(id) = result { + postedID = id + KitHost.log("approval \(model.identifier) reminder posted id=\(id)") + } + } + + /// Cancels a reminder not posted yet, takes a posted one out of Notification Center, and gives the click handling + /// back to the host. Safe to call twice. + func stop() { + pending?.cancel() + pending = nil + expiry?.cancel() + expiry = nil + if let id = postedID { + NotificationEngine.shared.centerProvider()?.removeDelivered(identifiers: [id]) + postedID = nil + } + if let relay { + let center = UNUserNotificationCenter.current() + if center.delegate === relay { center.delegate = relay.previous } + self.relay = nil + } + } +} + +/// Takes a click on any approval reminder and brings the waiting panel forward; every other notification goes on to +/// the delegate that was in place before (the host's), so its routes and replies still run. +final class ApprovalNotificationRelay: NSObject, UNUserNotificationCenterDelegate { + static let tokenKey = "genesis.approval" + + let previous: UNUserNotificationCenterDelegate? + private let onClick: () -> Void + + init(previous: UNUserNotificationCenterDelegate?, onClick: @escaping () -> Void) { + self.previous = previous + self.onClick = onClick + } + + func userNotificationCenter(_ center: UNUserNotificationCenter, willPresent notification: UNNotification, + withCompletionHandler completionHandler: @escaping (UNNotificationPresentationOptions) -> Void) { + if previous?.userNotificationCenter?(center, willPresent: notification, withCompletionHandler: completionHandler) == nil { + completionHandler([.banner, .sound]) + } + } + + func userNotificationCenter(_ center: UNUserNotificationCenter, didReceive response: UNNotificationResponse, + withCompletionHandler completionHandler: @escaping () -> Void) { + guard response.notification.request.content.userInfo[Self.tokenKey] != nil else { + if previous?.userNotificationCenter?(center, didReceive: response, withCompletionHandler: completionHandler) == nil { + completionHandler() + } + return + } + + KitHost.log("approval reminder clicked id=\(response.notification.request.identifier)") + let onClick = onClick + DispatchQueue.main.async { + onClick() + completionHandler() + } + } +} diff --git a/src/macos/GenesisKit/Sources/GenesisKitBase/Permissions/ApprovalDialog.swift b/src/macos/GenesisKit/Sources/GenesisKitBase/Permissions/ApprovalDialog.swift index a60572e540..11c04863c7 100644 --- a/src/macos/GenesisKit/Sources/GenesisKitBase/Permissions/ApprovalDialog.swift +++ b/src/macos/GenesisKit/Sources/GenesisKitBase/Permissions/ApprovalDialog.swift @@ -254,11 +254,14 @@ public struct ApprovalDialogView: View { /// One floating panel for one request. It never runs a modal session (a modal loop on the main queue stops every /// timer queued there, so a deadline never fired), never activates its app, and takes the keyboard only after the -/// user clicks into it: a request that pops up while someone types must not catch a stray Return. +/// user clicks into it: a request that pops up while someone types must not catch a stray Return. So that it is not +/// missed, it plays a sound and speaks to VoiceOver when it appears, and a notification follows while it waits +/// (`ApprovalAttention`, both settings). @MainActor public final class ApprovalPanelPresenter: NSObject, NSWindowDelegate { private var panel: NSPanel? private var model: ApprovalDialogModel? + private var reminder: ApprovalReminder? public override init() {} @@ -292,14 +295,27 @@ public final class ApprovalPanelPresenter: NSObject, NSWindowDelegate { self.panel = panel self.model = model panel.orderFrontRegardless() + let reminder = ApprovalReminder(model: model) { [weak self] in self?.bringForward() } + self.reminder = reminder + reminder.start() } public func close() { + reminder?.stop() + reminder = nil panel?.delegate = nil panel?.orderOut(nil) panel = nil } + /// A click on the reminder: the panel moves to the screen under the pointer and comes to the front, still without + /// taking the keyboard. + private func bringForward() { + guard let panel, model?.answered == false else { return } + panel.setFrameOrigin(Self.origin(for: panel.frame.size)) + panel.orderFrontRegardless() + } + /// ⌘W answers the way Deny does. public func windowShouldClose(_ sender: NSWindow) -> Bool { model?.deny() diff --git a/src/macos/GenesisKit/Sources/GenesisKitBase/Permissions/PermissionCenter.swift b/src/macos/GenesisKit/Sources/GenesisKitBase/Permissions/PermissionCenter.swift index bafe418c34..bd0c3d9c4e 100644 --- a/src/macos/GenesisKit/Sources/GenesisKitBase/Permissions/PermissionCenter.swift +++ b/src/macos/GenesisKit/Sources/GenesisKitBase/Permissions/PermissionCenter.swift @@ -425,9 +425,7 @@ public final class PermissionDialogModel: ObservableObject, Identifiable { needs = [need] self.status = status self.center = center - appName = Bundle.main.object(forInfoDictionaryKey: "CFBundleDisplayName") as? String - ?? Bundle.main.object(forInfoDictionaryKey: "CFBundleName") as? String - ?? ProcessInfo.processInfo.processName + appName = PermissionKind.appName if status == .needsRelaunch, !worksInNewProcess { phase = .relaunch } } diff --git a/src/macos/GenesisKit/Sources/GenesisKitBase/Permissions/PermissionKind.swift b/src/macos/GenesisKit/Sources/GenesisKitBase/Permissions/PermissionKind.swift index 79b37d61ba..31d4888889 100644 --- a/src/macos/GenesisKit/Sources/GenesisKitBase/Permissions/PermissionKind.swift +++ b/src/macos/GenesisKit/Sources/GenesisKitBase/Permissions/PermissionKind.swift @@ -41,6 +41,14 @@ public enum PermissionKind: String, CaseIterable, Identifiable, Sendable, Codabl } } + /// The name this app has in System Settings' lists: the bundle's display name, else its name, else the process. + /// Every sentence that tells the user which switch to turn on names the app with it, never "this app". + public static var appName: String { + Bundle.main.object(forInfoDictionaryKey: "CFBundleDisplayName") as? String + ?? Bundle.main.object(forInfoDictionaryKey: "CFBundleName") as? String + ?? ProcessInfo.processInfo.processName + } + /// The name System Settings shows for the grant. public var title: String { switch self { diff --git a/src/macos/GenesisKit/Sources/GenesisKitBase/Permissions/PermissionSettingsGuide.swift b/src/macos/GenesisKit/Sources/GenesisKitBase/Permissions/PermissionSettingsGuide.swift index 19b9bf3737..b4aa8f6811 100644 --- a/src/macos/GenesisKit/Sources/GenesisKitBase/Permissions/PermissionSettingsGuide.swift +++ b/src/macos/GenesisKit/Sources/GenesisKitBase/Permissions/PermissionSettingsGuide.swift @@ -124,6 +124,9 @@ public final class PermissionSettingsGuide { panel.hidesOnDeactivate = false panel.isReleasedWhenClosed = false panel.collectionBehavior = [.canJoinAllSpaces, .fullScreenAuxiliary, .ignoresCycle] + // The helper shows only while System Settings is the active app, so without this its close button's + // tooltip never appeared at all. + panel.allowsToolTipsWhenApplicationIsInactive = true panel.contentView = host panel.setAccessibilityIdentifier("permission-settings-guide") return panel @@ -184,18 +187,23 @@ struct PermissionGuideView: View { .padding(.bottom, 12) .background(.black.opacity(0.35), in: RoundedRectangle(cornerRadius: 16, style: .continuous)) .overlay(alignment: .topTrailing) { + // The 18 pt circle is drawn inside a 24 pt target: the padding sits inside the button, so a click + // just beside the circle still closes the helper. Button(action: onClose) { Image(systemName: "xmark") .font(.system(size: 9, weight: .bold)) .foregroundStyle(.white.opacity(hovering ? 0.95 : 0.6)) .frame(width: 18, height: 18) .background(.white.opacity(hovering ? 0.22 : 0.1), in: Circle()) + .padding(3) + .contentShape(Rectangle()) } .buttonStyle(.plain) + .pointerCursor() .onHover { hovering = $0 } .help("Close this helper") - .accessibilityLabel("Close") - .padding(6) + .accessibilityLabel("Close the helper") + .padding(3) } .padding(4) .environment(\.colorScheme, .dark) diff --git a/src/macos/GenesisKit/Sources/GenesisKitBase/Style/GenAnnounce.swift b/src/macos/GenesisKit/Sources/GenesisKitBase/Style/GenAnnounce.swift new file mode 100644 index 0000000000..553f9735de --- /dev/null +++ b/src/macos/GenesisKit/Sources/GenesisKitBase/Style/GenAnnounce.swift @@ -0,0 +1,32 @@ +import AppKit + +/// Speaks a short message through VoiceOver for something that changed without the user's action: a new decision, a +/// send that failed, a recording that started. Each post also writes `a11y.announce <text>` to the kit log, so a test +/// or a live check can prove it ran without VoiceOver on. +public enum GenAnnounce { + public enum Priority { + case low, medium, high + + var level: NSAccessibilityPriorityLevel { + switch self { + case .low: return .low + case .medium: return .medium + case .high: return .high + } + } + } + + @MainActor + public static func post(_ text: String, priority: Priority = .medium) { + let message = text.trimmingCharacters(in: .whitespacesAndNewlines) + guard !message.isEmpty else { return } + KitHost.log("a11y.announce \(message)") + // NSApp is nil in a process that never created the application (a test run): log only. + guard let app = NSApp else { return } + let element: Any = app.keyWindow ?? app.mainWindow ?? app + NSAccessibility.post(element: element, notification: .announcementRequested, userInfo: [ + .announcement: message, + .priority: priority.level.rawValue, + ]) + } +} diff --git a/src/macos/GenesisKit/Tests/GenesisKitTests/GateAuditTests.swift b/src/macos/GenesisKit/Tests/GenesisKitTests/GateAuditTests.swift index 85fc9ebec7..0302655152 100644 --- a/src/macos/GenesisKit/Tests/GenesisKitTests/GateAuditTests.swift +++ b/src/macos/GenesisKit/Tests/GenesisKitTests/GateAuditTests.swift @@ -288,3 +288,23 @@ final class ApprovalCommandTextTests: XCTestCase { [.program, .program, .flag, .argument], "a flag with = carries its own value") } } + +/// A waiting approval reminds the user once, at the delay the setting picks, and never in its last seconds. +final class ApprovalAttentionTests: XCTestCase { + func testTheReminderFollowsItsSettingAndNeverOutlivesTheRequest() { + let now = Date() + let deadline = now.addingTimeInterval(170) + XCTAssertEqual(ApprovalAttention.reminderDelay(notifyAfter: 15, deadline: deadline, now: now), 15) + XCTAssertEqual(ApprovalAttention.reminderDelay(notifyAfter: 0, deadline: deadline, now: now), 0, "at once") + XCTAssertNil(ApprovalAttention.reminderDelay(notifyAfter: -1, deadline: deadline, now: now), "never") + XCTAssertNil(ApprovalAttention.reminderDelay(notifyAfter: 168, deadline: deadline, now: now), + "a card in the last seconds before the request denies itself could only mislead") + XCTAssertEqual(ApprovalAttention.reminderDelay(notifyAfter: 60, deadline: nil, now: now), 60) + } + + func testTheDefaultsAreASoftSoundOfItsOwnAndAReminderAfter15Seconds() { + XCTAssertEqual(ApprovalAttention.defaultSound, "Purr") + XCTAssertTrue(ApprovalAttention.sounds.contains(ApprovalAttention.defaultSound)) + XCTAssertTrue(ApprovalAttention.notifyAfterChoices.contains { $0.seconds == ApprovalAttention.defaultNotifyAfter }) + } +} diff --git a/src/macos/GenesisKit/Tests/GenesisKitTests/GenesisKitTests.swift b/src/macos/GenesisKit/Tests/GenesisKitTests/GenesisKitTests.swift index 767900ffc3..2cc1ccdfc2 100644 --- a/src/macos/GenesisKit/Tests/GenesisKitTests/GenesisKitTests.swift +++ b/src/macos/GenesisKit/Tests/GenesisKitTests/GenesisKitTests.swift @@ -315,7 +315,7 @@ final class WidgetShelfStoreTests: XCTestCase { store.importFiles([URL(string: "https://example.com/etc/hosts")!]) XCTAssertEqual(calls, 0) XCTAssertFalse(store.isBusy) - XCTAssertEqual(store.error, "Only local files can be staged.") + XCTAssertEqual(store.error, "Only local files can be added.") } func testFailedImportContinuesOtherFilesAndReleasesOwnerForRetry() async { @@ -439,6 +439,41 @@ final class WidgetShelfStoreTests: XCTestCase { XCTAssertEqual(attached, ["chosen-recipient"]) XCTAssertTrue(store.notice?.contains("Nothing has been sent") == true) } + + /// Remove from shelf takes the row off at once and removes the item only after the Undo time; Undo brings it + /// back, and the status line is spoken (ux catalogue P5, P9). + func testRemoveWaitsBehindUndoAndUndoBringsTheItemBack() async throws { + let itemJSON = String(decoding: try JSONEncoder().encode(item), as: UTF8.self) + let listed = Data(#"{"revision":1,"items":[\#(itemJSON)],"statePath":"/fixture/widget-shelf/state.json"}"#.utf8) + var calls: [[String]] = [] + var spoken: [String] = [] + let store = WidgetShelfStore(request: { args, _ in + calls.append(args) + return listed + }, announce: { text, _ in spoken.append(text) }, removalUndoDelay: .milliseconds(100)) + defer { store.stop() } + store.refresh() + guard try await pollUntil("the list is read", { store.items.count == 1 }) else { return } + + store.remove(item) + XCTAssertTrue(store.items.isEmpty, "the row leaves at once") + XCTAssertEqual(store.removed, item) + store.undoRemove() + XCTAssertEqual(store.items, [item]) + XCTAssertNil(store.removed) + try await Task.sleep(for: .milliseconds(200)) + XCTAssertFalse(calls.contains { $0.first == "remove" }, "Undo in time removes nothing") + + store.remove(item) + store.refresh() + try await Task.sleep(for: .milliseconds(30)) + XCTAssertTrue(store.items.isEmpty, "a list read during the Undo time does not bring the row back") + guard try await pollUntil("the removal runs", { calls.contains { $0.first == "remove" } }) else { return } + await store.waitForRemoval() + XCTAssertEqual(calls.last { $0.first == "remove" }, ["remove", item.id]) + XCTAssertTrue(spoken.contains("Removed from the shelf. The original file and existing drafts are kept.")) + XCTAssertTrue(spoken.contains("notes.pdf is back on the shelf.")) + } } @MainActor diff --git a/src/macos/GenesisKit/Tests/GenesisKitTests/MediaThumbnailTests.swift b/src/macos/GenesisKit/Tests/GenesisKitTests/MediaThumbnailTests.swift index 2f8ea19034..27d7758c1c 100644 --- a/src/macos/GenesisKit/Tests/GenesisKitTests/MediaThumbnailTests.swift +++ b/src/macos/GenesisKit/Tests/GenesisKitTests/MediaThumbnailTests.swift @@ -715,7 +715,7 @@ final class WidgetCaptureFeedbackTests: XCTestCase { }, onCaptureStaged: { reopened += 1 }) await complete(store) { store.capture() } XCTAssertEqual(reopened, 1) - XCTAssertEqual(store.notice, "Screenshot staged. Choose an inbox when you are ready.") + XCTAssertEqual(store.notice, "Screenshot added. Choose an inbox when you are ready.") response = Data(#"{"cancelled":true,"reason":"user"}"#.utf8) await complete(store) { store.capture() } diff --git a/src/macos/GenesisKit/Tests/GenesisKitTests/RecordingTests.swift b/src/macos/GenesisKit/Tests/GenesisKitTests/RecordingTests.swift index dad65777e2..6c59e18e5a 100644 --- a/src/macos/GenesisKit/Tests/GenesisKitTests/RecordingTests.swift +++ b/src/macos/GenesisKit/Tests/GenesisKitTests/RecordingTests.swift @@ -536,7 +536,7 @@ final class RecordingHostTests: XCTestCase { XCTAssertEqual(Array(video.prefix(3)), ["video", file.path, "--name"]) XCTAssertTrue(video[3].hasPrefix("Screen recording ")) XCTAssertEqual(staged, 1, "the shelf reopens to show the new capture") - XCTAssertEqual(store.notice, "Recording staged. Choose an inbox when you are ready.") + XCTAssertEqual(store.notice, "Recording added. Choose an inbox when you are ready.") XCTAssertFalse(FileManager.default.fileExists(atPath: file.path)) XCTAssertFalse(store.canRecord, "a store made without a recorder offers no Record screen button") } diff --git a/src/macos/GenesisKit/Tests/GenesisKitTests/SettingsControlsTests.swift b/src/macos/GenesisKit/Tests/GenesisKitTests/SettingsControlsTests.swift index 30f3c16fda..f0b0170c0f 100644 --- a/src/macos/GenesisKit/Tests/GenesisKitTests/SettingsControlsTests.swift +++ b/src/macos/GenesisKit/Tests/GenesisKitTests/SettingsControlsTests.swift @@ -483,4 +483,46 @@ final class SettingsControlsTests: XCTestCase { XCTAssertEqual(FocusForgetRange.lastHour.start(now: now), now.addingTimeInterval(-3_600)) XCTAssertEqual(FocusForgetRange.everything.start(now: now), Date(timeIntervalSince1970: 0)) } + + // MARK: - Settings search (H4) + + /// The search finds a page by its words, a card or row by its title, detail or card, ranks a title that starts + /// with the query first, and points each result at its page and card or row. + func testTheSettingsSearchFindsCardsAndRowsAcrossPagesAndRanksTitlesFirst() { + let pages = [ + NativeSettingsSearchPage(id: "general", title: "General", subtitle: "Theme and motion", section: "App"), + NativeSettingsSearchPage(id: "notifications.prActivity", title: "PR activity", subtitle: "Banners", section: "Notifications"), + ] + let entries: [String: [NativeSettingsSearchEntry]] = [ + "general": [ + NativeSettingsSearchEntry(kind: .card, title: "Accessibility"), + NativeSettingsSearchEntry(kind: .row, title: "Reduce motion", detail: "Stops the slides.", card: "Accessibility"), + ], + "notifications.prActivity": [ + NativeSettingsSearchEntry(kind: .card, title: "Defaults", detail: "A project follows these."), + NativeSettingsSearchEntry(kind: .row, title: "Cadence", detail: "How often a project is polled.", card: "Defaults"), + NativeSettingsSearchEntry(kind: .row, title: "Only my PRs", detail: "Changes on PRs someone else opened stay silent.", card: "Defaults"), + ], + ] + let cadence = NativeSettingsSearch.results("cadence", pages: pages, entries: entries) + XCTAssertEqual(cadence.map(\.title), ["Cadence"]) + XCTAssertEqual(cadence.first?.context, "PR activity · Defaults") + let target = cadence.first?.target ?? "" + XCTAssertEqual(NativeSettingsHighlightCenter.split(target).page, "notifications.prActivity") + XCTAssertEqual( + NativeSettingsHighlightCenter.split(target).anchor, + NativeSettingsSearch.anchor(page: "notifications.prActivity", key: "row:Cadence")) + + // A detail match and a case- and accent-blind match; every word must be found somewhere. + XCTAssertEqual(NativeSettingsSearch.results("SILENT", pages: pages, entries: entries).map(\.title), ["Only my PRs"]) + XCTAssertEqual(NativeSettingsSearch.results("reduce slides", pages: pages, entries: entries).map(\.title), ["Reduce motion"]) + XCTAssertTrue(NativeSettingsSearch.results("reduce cadence", pages: pages, entries: entries).isEmpty) + + // A page result opens the page itself; a title that starts with the query comes before one that only holds it. + let motion = NativeSettingsSearch.results("motion", pages: pages, entries: entries) + XCTAssertEqual(motion.map(\.title), ["Reduce motion", "General"]) + XCTAssertEqual(motion.last?.target, "general") + XCTAssertEqual(NativeSettingsSearch.results("Pr", pages: pages, entries: entries).first?.title, "PR activity") + XCTAssertTrue(NativeSettingsSearch.results(" ", pages: pages, entries: entries).isEmpty) + } } diff --git a/src/macos/GenesisKit/Tests/GenesisKitTests/ToolsLineStreamTests.swift b/src/macos/GenesisKit/Tests/GenesisKitTests/ToolsLineStreamTests.swift index 87a7bc3b44..14284bd614 100644 --- a/src/macos/GenesisKit/Tests/GenesisKitTests/ToolsLineStreamTests.swift +++ b/src/macos/GenesisKit/Tests/GenesisKitTests/ToolsLineStreamTests.swift @@ -428,12 +428,46 @@ final class WidgetVoiceNotesStoreTests: XCTestCase { acquire: @escaping () async throws -> any VoiceRecordingLease = { FixtureVoiceLease() }, presentPermission: @escaping @MainActor (PermissionNeed) -> Void = { _ in }, recipients: [WidgetSession]? = nil, finish: @escaping () -> Void = {}, - launcher: String = "/fixture/Preview.app/Contents/MacOS/launcher") -> WidgetVoiceNotesStore { + launcher: String = "/fixture/Preview.app/Contents/MacOS/launcher", + discardDelay: Duration = .seconds(10)) -> WidgetVoiceNotesStore { WidgetVoiceNotesStore(micLauncher: launcher, request: request, execute: execute, finishCapture: finish, cancelCommand: {}, acquireAudio: acquire, settings: { WidgetVoiceNoteSettings(provider: "fixture", model: "test-model", language: "en") }, sessions: { recipients ?? [self.recipient] }, attachDraft: attach, readMicrophonePermission: permission, - requestMicrophonePermission: prompt, activateForMicrophone: activate, presentMicrophonePermission: presentPermission) + requestMicrophonePermission: prompt, activateForMicrophone: activate, presentMicrophonePermission: presentPermission, + discardUndoDelay: discardDelay) + } + + /// Discard takes the note off the list at once and deletes it only after the Undo time; Undo brings it back + /// (ux catalogue P4). + func testDiscardWaitsBehindUndoAndUndoBringsTheNoteBack() async throws { + var calls: [[String]] = [] + let store = make(request: { args in + calls.append(args) + return try self.data(["revision": 0, "notes": [self.note()], "statePath": "/fixture/widget/voice-notes/notes.json"]) + }, discardDelay: .milliseconds(100)) + defer { store.stop() } + store.refresh() + await store.waitForRefresh() + XCTAssertEqual(store.selectedID, noteID) + + store.discard() + XCTAssertTrue(store.notes.isEmpty, "the note leaves the list at once") + XCTAssertEqual(store.discarded?.id, noteID) + store.undoDiscard() + XCTAssertEqual(store.notes.map(\.id), [noteID]) + XCTAssertEqual(store.selectedID, noteID) + try await Task.sleep(for: .milliseconds(200)) + XCTAssertFalse(calls.contains { $0.first == "discard" }, "Undo in time deletes nothing") + + store.discard() + store.refresh(force: true) + await store.waitForRefresh() + XCTAssertTrue(store.notes.isEmpty, "a list read during the Undo time does not bring it back") + try await Task.sleep(for: .milliseconds(200)) + await store.waitForDiscard() + XCTAssertEqual(calls.last { $0.first == "discard" }, ["discard", noteID, "--revision", "1"]) + XCTAssertNil(store.discarded) } /// The reply box's live dictation names its clip and files the note through one-shot commands. They answer diff --git a/src/macos/GenesisKit/Tests/GenesisKitTests/WidgetInboxTasksPolishTests.swift b/src/macos/GenesisKit/Tests/GenesisKitTests/WidgetInboxTasksPolishTests.swift index cb232be80f..35d8f1e010 100644 --- a/src/macos/GenesisKit/Tests/GenesisKitTests/WidgetInboxTasksPolishTests.swift +++ b/src/macos/GenesisKit/Tests/GenesisKitTests/WidgetInboxTasksPolishTests.swift @@ -637,6 +637,65 @@ final class WidgetInboxPolishTests: XCTestCase { XCTAssertEqual(WidgetAlerts.flashSeconds(try prefs(#"{"alertFlashSeconds":0}"#)), 0) } + /// The popup says "Decision 4", not "New decision · DECISION 4"; VoiceOver hears what arrived and from where; the + /// popup's time and a note's time are settings (ux catalogue R4, R6, R10, R3). + func testAlertWordsAndTheTimesTheySettle() throws { + func prefs(_ json: String) throws -> WidgetPreferences { + let base = #"{"excludedKeys":[],"projects":[],"sessions":[],"showChanges":true,"placement":"side","side":"right","quietSeconds":15,"voiceProvider":"xai","voiceLanguage":"","# + return try JSONDecoder().decode(WidgetPreferences.self, from: Data((base + json.dropFirst()).utf8)) + } + let decision = WidgetInboxItem(id: "decision:d_4_s", sourceId: "d_4_s", kind: "decision", key: "k", at: 1, + needsAnswer: true, title: "Keep the cache?", number: 4) + XCTAssertEqual(WidgetAlerts.kicker(decision), "Decision 4") + XCTAssertEqual(WidgetAlerts.announcement(decision, session: "Fixture session"), + "Decision 4, Keep the cache?, from Fixture session") + let answer = WidgetInboxItem(id: "answer:a", sourceId: "a", kind: "answer", key: "k", at: 2, needsAnswer: false) + XCTAssertEqual(WidgetAlerts.announcement(answer, session: nil), "New answer") + XCTAssertEqual(WidgetAlerts.popupSeconds(nil), 7, "the popup stays 7 s by default") + XCTAssertEqual(WidgetAlerts.popupSeconds(try prefs(#"{"alertPopupSeconds":15}"#)), 15) + XCTAssertEqual(WidgetModel.noteSeconds(nil, hasAction: false), 6, "a note fades after 6 s by default") + XCTAssertEqual(WidgetModel.noteSeconds(nil, hasAction: true), 10, "a note with Undo or Send again stays 10 s") + XCTAssertEqual(WidgetModel.noteSeconds(try prefs(#"{"noteSeconds":20}"#), hasAction: true), 20) + XCTAssertNil(WidgetModel.noteSeconds(try prefs(#"{"noteSeconds":0}"#), hasAction: false), "0 keeps it") + XCTAssertEqual(try prefs(#"{}"#).openShortcutChord, .defaultOpen, "the shortcut is on, ⌃⌥⌘W, by default") + XCTAssertNil(try prefs(#"{"openShortcutEnabled":false}"#).openShortcutChord) + XCTAssertEqual(try prefs(#"{"openShortcut":{"keyCode":2,"modifiers":6400}}"#).openShortcutChord, + WidgetShortcutChord(keyCode: 2, modifiers: 6400)) + } + + /// The rail badge counts what waits for an answer, the hub's Inbox count; unread answers do not (R1). A lost + /// connection says one sentence and keeps the stderr for Details; a cleared or new error drops it (R11). + @MainActor + func testTheBadgeCountsWaitingItemsAndAnErrorKeepsItsDetailApart() { + let defaults = UserDefaults(suiteName: "widget.badge.\(UUID().uuidString)")! + let model = WidgetModel(binaryPath: "/fixture/no-process", defaults: defaults) + model.updateInbox(WidgetInboxSummary(unread: 120, needsAnswer: 3, complete: true, truncated: false, sessions: [])) + XCTAssertEqual(model.waitingCount, 3) + XCTAssertEqual(model.inboxCount, 123, "Mark all read still sees every unread item") + model.fail(WidgetModel.connectionLostMessage, detail: " error: watch exited 1\n") + XCTAssertEqual(model.error, WidgetModel.connectionLostMessage) + XCTAssertEqual(model.errorDetail, "error: watch exited 1") + model.error = "Another failure." + XCTAssertNil(model.errorDetail, "a new error does not show the old one's details") + model.fail("Third.", detail: "raw") + model.error = nil + XCTAssertNil(model.errorDetail) + model.postNote("Saved.", action: WidgetNoticeAction(title: "Undo") {}) + XCTAssertEqual(model.notice, "Saved.") + XCTAssertEqual(model.noticeAction?.title, "Undo") + model.postNote("Next note.") + XCTAssertNil(model.noticeAction, "a new note drops the old note's button") + } + + func testListKeysStopAtTheEnds() { + XCTAssertEqual(WidgetModel.listTarget(current: nil, step: 1, count: 3), 0) + XCTAssertEqual(WidgetModel.listTarget(current: nil, step: -1, count: 3), 2) + XCTAssertEqual(WidgetModel.listTarget(current: 2, step: 1, count: 3), 2) + XCTAssertEqual(WidgetModel.listTarget(current: 0, step: -1, count: 3), 0) + XCTAssertEqual(WidgetModel.listTarget(current: 1, step: 1, count: 3), 2) + XCTAssertNil(WidgetModel.listTarget(current: nil, step: 1, count: 0)) + } + @MainActor func testFlashingOneSessionLeavesEveryOtherSessionsTriggerAlone() { let defaults = UserDefaults(suiteName: "widget.attention.\(UUID().uuidString)")! @@ -680,6 +739,41 @@ final class WidgetInboxPolishTests: XCTestCase { XCTAssertEqual(LiveWidgetView.filtered(cards, by: "all").count, 5) } + /// Each ledger state reads as one phrase in every tab (P20), a decision event as words without its row id (P21), + /// and the Undo note names what changed (P3). + @MainActor + func testStatesEventsAndUndoNotesAreWordsNotRawValues() { + XCTAssertEqual(LiveWidgetView.statusPhrase("answered", kind: "decision"), "Waiting to send") + XCTAssertEqual(LiveWidgetView.statusPhrase("answered", kind: "form"), "Answered") + XCTAssertEqual(LiveWidgetView.statusPhrase("implemented", kind: "todo"), "Completed") + XCTAssertEqual(LiveWidgetView.statusPhrase("waiting-route", kind: "other"), "Waiting route", + "an unknown state is sentence case, never Waiting-Route") + let form = WidgetCard(id: "form:f1", kind: "form", sessionKey: "k", sourceId: "f1", at: 1, title: "Q", body: "", + status: "answered", choices: [], attachments: [], refs: [], read: true) + XCTAssertEqual(LiveWidgetView.kindLabel(form), "Question · Answered") + + let delivered = WidgetActivityEvent(id: "e1", sourceId: "d_4_s", at: 1, title: "Decision delivery", + body: "sent · cmux") + XCTAssertEqual(LiveWidgetView.activityLine(delivered, number: 4, provider: "claude"), + .init(title: "Answer delivered", detail: "DECISION 4 · Sent · through cmux")) + XCTAssertEqual(LiveWidgetView.activityLine(delivered, number: nil, provider: "claude").detail, + "Claude · Sent · through cmux", "without the card, the provider's name, never d_4_s") + let failed = WidgetActivityEvent(id: "e2", sourceId: "d_4_s", at: 1, title: "Decision send_failed", + body: "answered · something-new") + XCTAssertEqual(LiveWidgetView.activityLine(failed, number: 4, provider: "codex"), + .init(title: "Answer not delivered", detail: "DECISION 4 · Waiting to send"), + "a value the widget has no words for is left out") + + let decision = WidgetCard(id: "decision:d_4_s", kind: "decision", sessionKey: "k", sourceId: "d_4_s", at: 1, + title: "T", body: "", status: "open", revision: 1, number: 4, choices: [], + attachments: [], refs: [], read: true) + XCTAssertEqual(WidgetLedgerUndo.note(decision, state: "dismissed"), "DECISION 4 dismissed.") + let task = WidgetCard(id: "decision:t_2_s", kind: "todo", sessionKey: "k", sourceId: "t_2_s", at: 1, + title: "T", body: "", status: "open", revision: 1, number: 2, choices: [], + attachments: [], refs: [], read: true) + XCTAssertEqual(WidgetLedgerUndo.note(task, state: "implemented"), "Task 2 completed.") + } + @MainActor func testASnapshotDecodedForAStoppedWatchIsDroppedAfterARestart() async throws { let decoder = WidgetSnapshotDecoder() @@ -849,6 +943,55 @@ final class WidgetAnswerTargetTests: XCTestCase { XCTAssertEqual(key("j", keyCode: 0x26, answering: card), .previous) XCTAssertEqual(key("", keyCode: 53, answering: card), .collapse) } + + /// Dismiss shows at once and offers Undo; Undo sends nothing and keeps the decision open; the change reaches the + /// decision log only when its note goes away, with the session and revision of the click (P3). + func testDismissWaitsForItsUndoNoteAndUndoKeepsTheDecisionOpen() async throws { + let root = FileManager.default.temporaryDirectory.appendingPathComponent("widget-undo-\(UUID().uuidString)") + try FileManager.default.createDirectory(at: root.appendingPathComponent("native-submissions"), + withIntermediateDirectories: true) + defer { try? FileManager.default.removeItem(at: root) } + let defaults = try XCTUnwrap(UserDefaults(suiteName: "widget.undo." + UUID().uuidString)) + let model = WidgetModel(binaryPath: "/fixture/no-process", stateRoot: root.path, defaults: defaults, + appearance: NativeSettingsAppearance(defaults: defaults, + notificationNamespace: "undo." + UUID().uuidString, observeExternalChanges: false)) + defer { model.stop() } + final class Sent { var requests: [WidgetJSON] = [] } + let sent = Sent() + // Only the decision log's moves: opening the card also marks it read. + model.actionRunner = { value in + if case .object(let fields) = value, fields["action"] == .string("ledger") { sent.requests.append(value) } + return .null + } + let card = decision() + model.receive([try snapshot(card)]) + if model.selectedKey != key { model.select(key) } + model.openCard(card) + let undo = WidgetLedgerUndo() + + undo.hold(card, to: "dismissed", model: model, reduceMotion: true) + XCTAssertEqual(undo.applied(card).status, "dismissed", "the card shows the change at once") + XCTAssertFalse(undo.applied(card).needsAnswer) + XCTAssertNil(model.answerCard, "folded, the number keys no longer answer it") + XCTAssertEqual(model.notice, "DECISION 1 dismissed.") + XCTAssertEqual(model.noticeAction?.title, "Undo") + model.noticeAction?.run() + XCTAssertEqual(undo.applied(card).status, "open") + XCTAssertEqual(model.answerCard?.id, card.id, "Undo opens the card again") + XCTAssertEqual(model.notice, "Undone.") + try await Task.sleep(for: .milliseconds(50)) + XCTAssertTrue(sent.requests.isEmpty, "an undone change never reaches the log") + + undo.hold(card, to: "dismissed", model: model, reduceMotion: true) + XCTAssertTrue(sent.requests.isEmpty, "nothing is sent while the note can still undo it") + model.notice = nil + let deadline = Date().addingTimeInterval(2) + while sent.requests.isEmpty, Date() < deadline { try await Task.sleep(for: .milliseconds(10)) } + XCTAssertEqual(sent.requests, [.object([ + "action": "ledger", "id": "d_1_fixture", "sessionId": "fixture", "expectedRevision": .number(1), + "state": "dismissed", + ])]) + } } /// The camera button when macOS refuses the screenshot. @@ -1015,8 +1158,9 @@ final class WidgetStagingTests: XCTestCase { XCTAssertEqual(model.expanded, .right, "and keeps the panel open while it asks") model.resolveStagedLeave("send") - XCTAssertNil(model.expanded) + XCTAssertEqual(model.expanded, .right, "the panel stays open until the hub answers") try await Task.sleep(for: .milliseconds(200)) + XCTAssertNil(model.expanded, "and closes once every answer was delivered") XCTAssertEqual(names(actions).last, "send-staged") XCTAssertEqual(WidgetModel.stagedSummary(model.stagedItems), "1 decision will be sent to 1 session") } @@ -1043,7 +1187,8 @@ final class WidgetStagingTests: XCTestCase { model.sendStaged(["d1"]) try await Task.sleep(for: .milliseconds(200)) - XCTAssertEqual(model.notice, "Not delivered to Staged fixture: \(reason) The answers wait for its next prompt.") + XCTAssertEqual(model.notice, "Not delivered to Staged fixture: Several Claude terminals run this session, so none " + + "was picked. Close the extra one, then send again. The answers wait for its next prompt.") let again = try XCTUnwrap(model.noticeAction, "a send nobody took offers Send again") XCTAssertEqual(again.title, "Send again") @@ -1061,6 +1206,83 @@ final class WidgetStagingTests: XCTestCase { names(actions).filter { ["ledger", "send-staged"].contains($0) } } + /// Send now in the leave question: a send no session took keeps the panel open with the reason and Send again + /// (ux catalogue P1), and the next close does not ask about the same decisions again. + func testSendNowOnTheWayOutStaysOpenWhenNoSessionTookIt() async throws { + _ = NSApplication.shared + let model = StagingFixture.model() + defer { model.stop() } + model.actionRunner = { value in + guard case .object(let fields) = value, fields["action"] == .string("send-staged") else { return ["ok": true] } + return ["sent": 0, "queued": 1, "failed": 0, "sessions": [["session": "fixture-staged", "numbers": [7], + "result": ["delivered": false, "detail": "no cmux pane runs this session"]]]] + } + model.receive([try snapshot(draftOption: "a")]) + model.select(key) + model.expanded = .right + model.requestCollapse() + XCTAssertTrue(model.stagedLeavePrompt) + + model.resolveStagedLeave("send") + XCTAssertFalse(model.stagedLeavePrompt) + XCTAssertEqual(model.expanded, .right, "the panel waits for the hub's answer") + try await Task.sleep(for: .milliseconds(200)) + XCTAssertEqual(model.expanded, .right, "a send no session took stays where the user can read it") + XCTAssertEqual(model.notice, "Not delivered to Staged fixture: No cmux pane runs this session. " + + "The answers wait for its next prompt.") + XCTAssertEqual(model.noticeAction?.title, "Send again") + + model.requestCollapse() + XCTAssertFalse(model.stagedLeavePrompt, "these decisions were already sent; closing does not ask again") + XCTAssertNil(model.expanded) + } + + /// A send that fails outright keeps the panel open with one sentence and the raw text behind Details, and the + /// decisions, still staged, are asked about again at the next close. + func testAFailedSendOnTheWayOutStaysOpenAndAsksAgainNextTime() async throws { + _ = NSApplication.shared + let model = StagingFixture.model() + defer { model.stop() } + model.actionRunner = { value in + guard case .object(let fields) = value, fields["action"] == .string("send-staged") else { return ["ok": true] } + throw ToolsBridgeError.refused("hub call exited 1: fixture stderr") + } + model.receive([try snapshot(draftOption: "a")]) + model.select(key) + model.expanded = .right + model.requestCollapse() + model.resolveStagedLeave("send") + try await Task.sleep(for: .milliseconds(200)) + XCTAssertEqual(model.expanded, .right) + XCTAssertEqual(model.error, "The decisions could not be sent. They stay staged, so you can send them again.") + XCTAssertEqual(model.errorDetail, "hub call exited 1: fixture stderr") + + model.requestCollapse() + XCTAssertTrue(model.stagedLeavePrompt, "still staged, so the next close asks again") + } + + /// The hub's reasons for a send no session took read as sentences; the raw text stays for Details (P24). + func testTheHubsReasonsReadAsSentences() { + XCTAssertEqual(WidgetDecisionSendOutcome.sentence( + for: "The Claude worker is busy; the answer remains queued for a later turn."), + "Its worker is busy with another turn.") + XCTAssertEqual(WidgetDecisionSendOutcome.sentence( + for: "no cmux pane runs this session Saved for this exact session; awaiting a consumer acknowledgement."), + "No cmux pane runs this session.") + XCTAssertEqual(WidgetDecisionSendOutcome.sentence(for: "a reason the widget has not seen"), + "No running agent took them.") + XCTAssertEqual(WidgetDecisionSendOutcome.sentence(for: nil), "No running agent took them.") + + let outcome = WidgetDecisionSendOutcome(["sent": 0, "queued": 2, "failed": 0, "sessions": [ + ["session": "s1", "numbers": [3], "result": ["delivered": false, "detail": "cmux is not running"]], + ["session": "s2", "numbers": [4], "result": ["delivered": false, "detail": "cmux is not running"]], + ]], titles: ["s1": "First", "s2": "Second"]) + XCTAssertFalse(outcome.delivered) + XCTAssertEqual(outcome.details, "First: cmux is not running\nSecond: cmux is not running") + XCTAssertEqual(outcome.notice(items: [], staged: true), "Not delivered to 2 sessions: cmux is not running. " + + "The answers wait for each session's next prompt.") + } + func testKeepingStagedDecisionsDoesNotAskAgainForTheSameOnes() throws { let defaults = UserDefaults(suiteName: "widget.staging." + UUID().uuidString)! let model = WidgetModel(binaryPath: "/fixture/tools", stateRoot: FileManager.default.temporaryDirectory.path, @@ -1317,6 +1539,67 @@ final class WidgetBubbleHitTests: XCTestCase { XCTAssertEqual(taps.count, 1, "a click on a bubble with no ring opens its session") } + /// Two rail bubbles as the side rail stacks them: no spacing, half the gap as each one's hit padding. + struct RailPair: View { + @ObservedObject var upper: Taps + @ObservedObject var lower: Taps + var body: some View { + VStack(spacing: 0) { + bubble(upper) + bubble(lower) + } + .padding(.vertical, -WidgetSideStripMetrics.sessionSpacing / 2) + .frameProbe("rail") + .frame(width: 120, height: 120) + } + + private func bubble(_ taps: Taps) -> some View { + Button { taps.count += 1 } label: { + Text("4").allowsHitTesting(false) + .frame(width: WidgetSideStripMetrics.sessionWidth, height: WidgetSideStripMetrics.sessionHeight) + .modifier(WidgetBubbleChrome(trigger: 0, attention: .borderFlash(color: .orange, cornerRadius: 17), + reduceMotion: true, hitPadding: WidgetSideStripMetrics.sessionHitPadding)) + } + .buttonStyle(.plain) + } + } + + /// The 6 pt strip between two rail bubbles was dead (ux catalogue R2): its upper half now opens the upper session, + /// its lower half the lower one. + func testTheGapBetweenTwoRailBubblesBelongsToThem() throws { + _ = NSApplication.shared + FrameProbe.enabled = true + FrameProbe.reset() + defer { FrameProbe.enabled = false } + let upper = Taps(), lower = Taps() + let window = NSWindow(contentRect: NSRect(x: 0, y: 0, width: 120, height: 120), styleMask: [.borderless], + backing: .buffered, defer: false) + window.isReleasedWhenClosed = false + window.contentView = FirstClickHost(rootView: AnyView(RailPair(upper: upper, lower: lower))) + window.alphaValue = 0 + window.level = .init(rawValue: Int(CGWindowLevelForKey(.desktopWindow)) - 1) + window.order(.below, relativeTo: 0) + defer { window.orderOut(nil); window.close() } + pump(0.6) + let frame = try XCTUnwrap(FrameProbe.frames["rail"]) + XCTAssertEqual(frame.height, WidgetSideStripMetrics.sessionHeight * 2 + WidgetSideStripMetrics.sessionSpacing, + accuracy: 0.5, "the stack keeps the height the rail metrics count") + // The drawn gap is the middle 6 pt of the stack: 1 pt above its centre is the upper half, 1 pt below the lower. + func click(_ topY: CGFloat) { + let point = NSPoint(x: frame.midX, y: 120 - topY) + for type in [NSEvent.EventType.leftMouseDown, .leftMouseUp] { + NSApp.postEvent(NSEvent.mouseEvent(with: type, location: point, modifierFlags: [], + timestamp: ProcessInfo.processInfo.systemUptime, windowNumber: window.windowNumber, context: nil, + eventNumber: 0, clickCount: 1, pressure: 1)!, atStart: false) + } + pump(0.4) + } + click(frame.midY - 1.5) + XCTAssertEqual(upper.count, 1, "the gap's upper half opens the upper bubble") + click(frame.midY + 1.5) + XCTAssertEqual(lower.count, 1, "the gap's lower half opens the lower bubble") + } + /// Two rows on a glass panel with a gap between them, as the widget's hover list draws them. struct GlassRows: View { @ObservedObject var taps: Taps diff --git a/src/macos/GenesisKit/Tests/GenesisKitTests/WidgetKeymapTests.swift b/src/macos/GenesisKit/Tests/GenesisKitTests/WidgetKeymapTests.swift index 01dfccb376..76ebbe1bb8 100644 --- a/src/macos/GenesisKit/Tests/GenesisKitTests/WidgetKeymapTests.swift +++ b/src/macos/GenesisKit/Tests/GenesisKitTests/WidgetKeymapTests.swift @@ -55,6 +55,32 @@ final class WidgetKeymapTests: XCTestCase { XCTAssertEqual(press(0x14, "š", answering: answering), .choose("ask")) } + /// Up, Down and Return move in the list on screen (ux catalogue R7), never while typing and never without a list. + func testArrowsAndReturnMoveInTheListOnScreen() { + func press(_ keyCode: UInt16, list: Bool, editingText: Bool = false) -> WidgetCoordinator.PanelKey { + WidgetCoordinator.panelKey(keyCode: keyCode, characters: "", context: .init( + previewOpen: false, agents: true, editingText: editingText, answering: nil, list: list), keymap: keys()) + } + XCTAssertEqual(press(126, list: true), .list(.up)) + XCTAssertEqual(press(125, list: true), .list(.down)) + XCTAssertEqual(press(36, list: true), .list(.open)) + XCTAssertEqual(press(125, list: false), .pass, "no list on screen: the scroll view keeps the arrow") + XCTAssertEqual(press(126, list: true, editingText: true), .pass, "the caret keeps the arrows in a field") + XCTAssertEqual(press(36, list: true, editingText: true), .pass, "Return in the composer is the composer's") + } + + /// ⌘H hides an app on every Mac; Open Hub moved to ⌘O, and J and K say what they move (R8, R9). + func testOpenHubLeavesCommandHToHideAndJAndKNameTheSession() throws { + let keys = Keymap(library: WidgetKeymap.library, store: nil, layout: TableKeyLayout.us) + let commandH = KeyStroke(keyCode: 4, character: "h", modifiers: .command) + XCTAssertEqual(keys.commands(for: commandH, in: [.window]).map(\.id), []) + let commandO = KeyStroke(keyCode: 31, character: "o", modifiers: .command) + XCTAssertEqual(keys.commands(for: commandO, in: [.window]).map(\.id), [WidgetKeymap.hub]) + let titles = Dictionary(uniqueKeysWithValues: WidgetKeymap.library.commands.map { ($0.id, $0.title) }) + XCTAssertEqual(titles[WidgetKeymap.previous], "Previous Session") + XCTAssertEqual(titles[WidgetKeymap.next], "Next Session") + } + func testJAndKDoNothingWhileTyping() { XCTAssertEqual(press(38, "j", editingText: true), .pass) XCTAssertEqual(press(40, "k", editingText: true), .pass) diff --git a/src/macos/GenesisTools/Sources/Host/App/GenesisToolsApp.swift b/src/macos/GenesisTools/Sources/Host/App/GenesisToolsApp.swift index d6a895d86b..3a518e32df 100644 --- a/src/macos/GenesisTools/Sources/Host/App/GenesisToolsApp.swift +++ b/src/macos/GenesisTools/Sources/Host/App/GenesisToolsApp.swift @@ -230,6 +230,10 @@ struct RootView: View { services.refresh() settings.refresh() } + // A grant changed in System Settings shows as soon as the user comes back, without Refresh. + .onReceive(NotificationCenter.default.publisher(for: NSApplication.didBecomeActiveNotification)) { _ in + permissions.refresh() + } } private var tabs: some View { diff --git a/src/macos/GenesisTools/Sources/Host/App/PermissionsModel.swift b/src/macos/GenesisTools/Sources/Host/App/PermissionsModel.swift index 301344b172..9f860c7b27 100644 --- a/src/macos/GenesisTools/Sources/Host/App/PermissionsModel.swift +++ b/src/macos/GenesisTools/Sources/Host/App/PermissionsModel.swift @@ -19,6 +19,11 @@ enum GrantAction: Equatable { case openPane(String) /// touch the resource once so macOS asks (folders, Automation) case probe + + var opensSettings: Bool { + if case .openPane = self { return true } + return false + } } struct PermissionRow: Identifiable, Equatable { @@ -45,11 +50,15 @@ struct PermissionRow: Identifiable, Equatable { /// command needs each grant and remembers probe answers, which macOS has no status for. final class PermissionsModel: ObservableObject { @Published private(set) var rows: [PermissionRow] = [] + /// The id of the row whose request waits for macOS. @Published private(set) var busy: String? @Published private(set) var lastMessage: String? private let access: PermissionAccess private var probeResults: [PermissionKind: PermissionStatus] = [:] + /// The grant `lastMessage` is about: once a refresh finds it no longer refused, the message goes. + private var messageKind: PermissionKind? + private var rechecking = false private static let usedBy: [(PermissionKind, String)] = [ (.calendars, "tools macos calendar, tools todo sync"), @@ -72,10 +81,39 @@ final class PermissionsModel: ObservableObject { self.access = access } + /// The title of the row whose request waits for macOS. + var busyTitle: String? { + busy.map { id in rows.first { $0.id == id }?.title ?? id } + } + + /// Reads every grant again, and runs each answered probe again: a probe grant has no silent read, so without it a + /// grant changed in System Settings kept its old answer. macOS answered those probes before, so none prompts. func refresh() { + rebuildRows() + recheckProbes() + } + + private func rebuildRows() { rows = Self.usedBy.map { kind, usedBy in PermissionRow(kind: kind, usedBy: usedBy, state: probeResults[kind] ?? access.status(kind)) } + if let kind = messageKind, rows.first(where: { $0.kind == kind })?.state != .denied { + lastMessage = nil + messageKind = nil + } + } + + private func recheckProbes() { + let kinds = PermissionKind.allCases.filter { probeResults[$0] != nil } + guard !kinds.isEmpty, !rechecking, busy == nil else { return } + rechecking = true + Task { @MainActor in + for kind in kinds { + probeResults[kind] = await access.recheck(kind) + } + rechecking = false + rebuildRows() + } } // MARK: - Actions @@ -83,16 +121,20 @@ final class PermissionsModel: ObservableObject { func request(_ row: PermissionRow) { busy = row.id lastMessage = nil + messageKind = nil let kind = row.kind Task { @MainActor in let result = await access.request(kind) if kind.requestStyle == .probe { probeResults[kind] = result - if result == .denied { lastMessage = "\(row.title): macOS refused. Allow it in System Settings." } + if result == .denied { + lastMessage = "\(row.title): macOS refused. Allow it in System Settings." + messageKind = kind + } } busy = nil - refresh() + rebuildRows() } } diff --git a/src/macos/GenesisTools/Sources/Host/App/Views.swift b/src/macos/GenesisTools/Sources/Host/App/Views.swift index cd3f481ccc..2229181a8c 100644 --- a/src/macos/GenesisTools/Sources/Host/App/Views.swift +++ b/src/macos/GenesisTools/Sources/Host/App/Views.swift @@ -31,42 +31,56 @@ struct PermissionsView: View { TableColumn("") { row in HStack { actionButton(row) - Button("Settings…") { model.openPane(row.pane) } + // A row whose action already opens System Settings needs no second button for it. + if !(row.action.opensSettings && row.state != .granted) { + Button("Settings…") { model.openPane(row.pane) } + .help("Opens this permission's list in System Settings.") + } } } .width(min: 220) } + Text("Full Disk Access and Accessibility are turned on in System Settings, not in a prompt: open the list, then drag \(appName) into it.") + .font(.caption) + .foregroundStyle(.secondary) + .fixedSize(horizontal: false, vertical: true) + HStack { - if let busy = model.busy { + if let busy = model.busyTitle { ProgressView().controlSize(.small) - Text("Waiting for macOS (\(busy))…").font(.caption) + Text("Asking macOS for \(busy)…").font(.caption) } if let message = model.lastMessage { Text(message).font(.caption).foregroundStyle(.orange) } Spacer() Button("Reveal app in Finder") { model.revealApp() } - .help("Full Disk Access and Accessibility have no prompt: drag the app into the list.") + .help("Shows \(appName) in Finder, ready to drag into a System Settings list.") } } } + private var appName: String { PermissionKind.appName } + @ViewBuilder private func actionButton(_ row: PermissionRow) -> some View { switch row.action { case .prompt: - Button(row.state == .granted ? "Granted" : "Request") { model.request(row) } + Button(row.state == .granted ? "Granted" : "Ask macOS") { model.request(row) } .disabled(row.state == .granted || model.busy != nil) + .help("Shows the macOS prompt for this permission.") case .openPane(let pane): - Button(row.state == .granted ? "Granted" : "Open list & reveal") { + Button(row.state == .granted ? "Granted" : "Open System Settings") { model.openPane(pane) model.revealApp() } .disabled(row.state == .granted) + .help("Opens this list in System Settings and shows \(appName) in Finder, ready to drag into it.") case .probe: - Button(row.state == .granted ? "Granted" : "Test (asks)") { model.request(row) } + Button(row.state == .granted ? "Granted" : "Ask macOS") { model.request(row) } .disabled(row.state == .granted || model.busy != nil) + .help("Uses it once, so macOS shows its prompt.") } } } @@ -116,6 +130,8 @@ struct SettingsView: View { @ObservedObject var model: SettingsModel @AppStorage(AppMainWindow.key) private var mainWindow = AppMainWindow.permissions @AppStorage(PermissionSettingsGuide.enabledKey) private var settingsGuide = true + @AppStorage(ApprovalAttention.soundKey) private var approvalSound = ApprovalAttention.defaultSound + @AppStorage(ApprovalAttention.notifyAfterKey) private var approvalNotifyAfter = ApprovalAttention.defaultNotifyAfter var body: some View { Form { @@ -137,6 +153,27 @@ struct SettingsView: View { .foregroundStyle(.secondary) } + Section("Approval requests") { + HStack { + Picker("Sound when a request appears", selection: $approvalSound) { + Text("None").tag("none") + ForEach(ApprovalAttention.sounds, id: \.self) { name in + Text(name).tag(name) + } + } + Button("Play") { ApprovalAttention.play(approvalSound) } + .disabled(approvalSound == "none") + } + Picker("Notification while it waits", selection: $approvalNotifyAfter) { + ForEach(ApprovalAttention.notifyAfterChoices) { choice in + Text(choice.title).tag(choice.seconds) + } + } + Text("A program that asks for the vault key or an AI account opens a request window. The window never takes the keyboard by itself, so this sound and notification tell you it is waiting.") + .font(.caption) + .foregroundStyle(.secondary) + } + Section("Launcher") { Toggle("Route `tools` through this app (owns the privacy grants)", isOn: $model.launcherEnabled) Text(model.launcherEnabled diff --git a/src/macos/GenesisTools/Sources/Host/RouteApproval.swift b/src/macos/GenesisTools/Sources/Host/RouteApproval.swift index 8dbe01c827..458d007702 100644 --- a/src/macos/GenesisTools/Sources/Host/RouteApproval.swift +++ b/src/macos/GenesisTools/Sources/Host/RouteApproval.swift @@ -2,7 +2,8 @@ import AppKit import Foundation /// The Allow / Deny card for a route that runs a command. Same look as RouteToast: dark card, -/// gold hairline, the Genesis Tools mark. Return allows, Esc denies. +/// gold hairline, the GenesisTools mark. Only a click allows: the card takes the keyboard while +/// the user may still be typing elsewhere, so Return does nothing here. Esc denies. enum RouteApproval { /// The "Running" toast is already up (statusBar level, same spot) and would hide this card, /// so it steps aside while the question is open and only comes back when the run is allowed. @@ -30,7 +31,8 @@ private enum Palette { static let gold = NSColor(srgbRed: 1, green: 0.62, blue: 0.12, alpha: 1) static let brand = NSColor(srgbRed: 0.96, green: 0.78, blue: 0.45, alpha: 0.9) static let text = NSColor(white: 0.96, alpha: 1) - static let dim = NSColor(white: 0.55, alpha: 1) + /// 62% white: text the user must read keeps 4.5:1 on the card. + static let dim = NSColor(white: 0.62, alpha: 1) static let flag = NSColor(srgbRed: 0.96, green: 0.78, blue: 0.45, alpha: 0.85) static let well = NSColor(white: 1, alpha: 0.05) static let wellBorder = NSColor(white: 1, alpha: 0.08) @@ -45,6 +47,7 @@ final class RouteApprovalCard: NSPanel { init(argv: [String], open: String?) { let body = ApprovalBody(argv: argv, open: open) let size = body.fittingSize + let headline = ApprovalBody.headline(argv) super.init( contentRect: NSRect(x: 0, y: 0, width: size.width, height: size.height), styleMask: [.borderless], @@ -59,6 +62,10 @@ final class RouteApprovalCard: NSPanel { hasShadow = true hidesOnDeactivate = false contentView = body + // VoiceOver reads a dialog named by what it asks, not an unnamed borderless window. + title = "Approval needed: \(headline)" + setAccessibilitySubrole(.dialog) + setAccessibilityLabel("Approval needed: \(headline)") body.onAnswer = { [weak self] answer in self?.finish(answer) } @@ -67,12 +74,14 @@ final class RouteApprovalCard: NSPanel { func runModal() -> Bool { let resting = frame.origin + // Under Reduce Motion the card fades in where it rests; otherwise it rises the last 14 pt. + let rise: CGFloat = GenMotion.systemReduceMotion ? 0 : 14 alphaValue = 0 - setFrameOrigin(NSPoint(x: resting.x, y: resting.y - 14)) + setFrameOrigin(NSPoint(x: resting.x, y: resting.y - rise)) NSApp.activate(ignoringOtherApps: true) makeKeyAndOrderFront(nil) orderFrontRegardless() - fadeIn(to: resting) + fadeIn(to: resting, rise: rise) NSApp.runModal(for: self) orderOut(nil) return allowed @@ -80,10 +89,11 @@ final class RouteApprovalCard: NSPanel { /// Stepped by a timer in `.common` modes, not `animator()`: the card is modal, and an animation /// that does not tick in the modal run loop would leave an invisible card waiting for a click. - /// The timer stops itself after `duration`. - private func fadeIn(to resting: NSPoint) { + /// The timer stops itself after `duration`. Nothing waits on it: the buttons answer from the + /// first frame. + private func fadeIn(to resting: NSPoint, rise: CGFloat) { let start = Date() - let duration = 0.28 + let duration = rise == 0 ? 0.15 : GenMotion.expandDuration let timer = Timer(timeInterval: 1.0 / 60, repeats: true) { [weak self] timer in guard let self else { timer.invalidate() @@ -93,7 +103,7 @@ final class RouteApprovalCard: NSPanel { let progress = min(1, Date().timeIntervalSince(start) / duration) let eased = CGFloat(1 - pow(1 - progress, 3)) alphaValue = eased - setFrameOrigin(NSPoint(x: resting.x, y: resting.y - 14 * (1 - eased))) + setFrameOrigin(NSPoint(x: resting.x, y: resting.y - rise * (1 - eased))) if progress >= 1 { timer.invalidate() } @@ -145,19 +155,20 @@ private final class ApprovalBody: NSView { header.orientation = .horizontal header.alignment = .top - let kicker = label("APPROVAL NEEDED", size: 12, weight: .semibold, color: Palette.gold) + let kicker = label("Approval needed", size: 12, weight: .semibold, color: Palette.gold) let headline = label(Self.headline(argv), size: 24, weight: .semibold, color: Palette.text) var rows: [NSView] = [header, kicker, headline, commandWell(argv)] if let program = argv.first, program.contains("/") { - rows.append(caption("PROGRAM", Self.shorten(program))) + rows.append(caption("Program", Self.shorten(program))) } if let open, !open.isEmpty { - rows.append(caption("THEN OPENS", open)) + rows.append(caption("Then opens", open)) } - rows.append(label("Runs once, without a shell. Nothing happens if you deny.", size: 11, weight: .regular, color: Palette.dim)) + rows.append(label("Runs once, without a shell. Nothing happens if you deny. Only a click allows it.", + size: 11, weight: .regular, color: Palette.dim)) rows.append(buttonRow()) let stack = NSStackView(views: rows) @@ -201,7 +212,7 @@ private final class ApprovalBody: NSView { dot.heightAnchor.constraint(equalToConstant: 8), ]) - let brand = label("GENESIS TOOLS", size: 11, weight: .semibold, color: Palette.brand) + let brand = label("GenesisTools", size: 11, weight: .semibold, color: Palette.brand) let row = NSStackView(views: [dot, brand]) row.orientation = .horizontal row.alignment = .centerY @@ -238,7 +249,7 @@ private final class ApprovalBody: NSView { } private func caption(_ title: String, _ value: String) -> NSView { - let name = label(title, size: 10, weight: .semibold, color: Palette.dim) + let name = label(title, size: 11, weight: .semibold, color: Palette.dim) name.translatesAutoresizingMaskIntoConstraints = false name.widthAnchor.constraint(equalToConstant: 78).isActive = true @@ -262,11 +273,16 @@ private final class ApprovalBody: NSView { deny.keyEquivalent = "\u{1b}" deny.target = self deny.action = #selector(denyClicked) + deny.toolTip = "Nothing runs (Esc)" + deny.setAccessibilityHelp("Esc denies too.") - let allow = ApprovalButton(title: "Allow", hint: "↩", primary: true) - allow.keyEquivalent = "\r" + // No key equivalent on purpose: the card takes the keyboard when it appears, and a Return meant + // for another app must not run a command. Full Keyboard Access still reaches it with Tab and Space. + let allow = ApprovalButton(title: "Allow", hint: nil, primary: true) allow.target = self allow.action = #selector(allowClicked) + allow.toolTip = "Runs the command once. Only a click allows it, so a key pressed while you type cannot." + allow.setAccessibilityHelp("Runs the command once.") let row = NSStackView(views: [NSView(), deny, allow]) row.orientation = .horizontal @@ -299,7 +315,7 @@ private final class ApprovalBody: NSView { /// `Run tools say` for `[.../tools, say, ...]`; the program alone when the next word is a flag. /// A script behind a runner (`bun .../rohlik.ts add`) is named by the script, not the runner. - private static func headline(_ argv: [String]) -> String { + static func headline(_ argv: [String]) -> String { guard let program = argv.first else { return "Run a command" } var name = (program as NSString).lastPathComponent var rest = Array(argv.dropFirst()) @@ -367,7 +383,9 @@ private final class ApprovalBody: NSView { } private final class ApprovalButton: NSButton { - init(title: String, hint: String, primary: Bool) { + /// `hint` is the key drawn after the title; it is left out of the accessibility label, so VoiceOver + /// reads "Deny", not "Deny esc". + init(title: String, hint: String?, primary: Bool) { super.init(frame: .zero) isBordered = false wantsLayer = true @@ -382,11 +400,14 @@ private final class ApprovalButton: NSButton { .font: NSFont.systemFont(ofSize: 13, weight: .semibold), .foregroundColor: ink, ]) - text.append(NSAttributedString(string: " \(hint)", attributes: [ - .font: NSFont.systemFont(ofSize: 11, weight: .medium), - .foregroundColor: hintInk, - ])) + if let hint { + text.append(NSAttributedString(string: " \(hint)", attributes: [ + .font: NSFont.systemFont(ofSize: 11, weight: .medium), + .foregroundColor: hintInk, + ])) + } attributedTitle = text + setAccessibilityLabel(title) translatesAutoresizingMaskIntoConstraints = false NSLayoutConstraint.activate([ diff --git a/src/macos/GenesisTools/Sources/Parts/Features/Settings/DaemonSettings.swift b/src/macos/GenesisTools/Sources/Parts/Features/Settings/DaemonSettings.swift index 8c1a3bfd6a..21dd35e96e 100644 --- a/src/macos/GenesisTools/Sources/Parts/Features/Settings/DaemonSettings.swift +++ b/src/macos/GenesisTools/Sources/Parts/Features/Settings/DaemonSettings.swift @@ -401,7 +401,11 @@ private struct DaemonTaskCard: View { .buttonStyle(.bordered) .nativeSettingsPointer() .disabled(store.runningNow.contains(task.name)) - .instantTooltip("Run it once now with its timeout and priority (tools daemon run \(task.name))") + .instantTooltip("Run this task once now, with its timeout and priority") + // The command for a terminal stays one right-click away. + .contextMenu { + Button("Copy Command") { Clipboard.copy("tools daemon run \(task.name)", what: "command") } + } .accessibilityIdentifier("daemon.\(task.name).run") Toggle("Enabled", isOn: Binding( get: { task.enabled }, @@ -533,13 +537,22 @@ private struct DaemonTaskEditor: View { @ObservedObject var store: DaemonSettingsStore @State private var every = "" @State private var retryTimes = "" + /// The text field being edited: leaving one saves it, as Return does. + @FocusState private var editing: Field? + /// The last value sent to the daemon from each field, until the task comes back with it. + @State private var sentEvery: String? + @State private var sentRetryTimes: String? + + private enum Field { case every, retryTimes } var body: some View { VStack(alignment: .leading, spacing: 14) { - NativeSettingsRow("Schedule", detail: "\"every 5 minutes\", \"every 2 hours\" or \"every day at 03:00\". Return saves.") { + NativeSettingsRow("Schedule", detail: "\"every 5 minutes\", \"every 2 hours\" or \"every day at 03:00\". Return or leaving the field saves; Esc puts it back.") { TextField("every 5 minutes", text: $every) .textFieldStyle(.roundedBorder).frame(width: 200) + .focused($editing, equals: .every) .onSubmit { commitEvery() } + .onExitCommand { every = task.every } .accessibilityIdentifier("daemon.\(task.name).every") } NativeSettingsRow("Timeout", detail: "A run still going after this long is stopped and counts as timed out.") { @@ -613,10 +626,12 @@ private struct DaemonTaskEditor: View { .accessibilityIdentifier("daemon.\(task.name).missedRun") } if task.missedRun.mode == "retry-at" { - NativeSettingsRow("Times", detail: "Comma-separated, 24-hour HH:MM. Return saves.") { + NativeSettingsRow("Times", detail: "Comma-separated, 24-hour HH:MM. Return or leaving the field saves; Esc puts it back.") { TextField("10:00, 12:00", text: $retryTimes) .textFieldStyle(.roundedBorder).frame(width: 200) - .onSubmit { store.set(task, ["--retry-at", retryTimes]) } + .focused($editing, equals: .retryTimes) + .onSubmit { commitRetryTimes() } + .onExitCommand { retryTimes = Self.times(task) } .accessibilityIdentifier("daemon.\(task.name).retryAt") } } @@ -624,7 +639,18 @@ private struct DaemonTaskEditor: View { NativeSettingsRow("Command", detail: task.command) { EmptyView() } } .onAppear(perform: syncFields) - .onChange(of: task) { _, _ in syncFields() } + // Another setting's save brings a new task; a field the user is still typing in keeps their text. + .onChange(of: task) { old, _ in + sentEvery = nil + sentRetryTimes = nil + syncFields(from: old) + } + // A click on another field or Tab saves what was typed, as Return does. + .onChange(of: editing) { old, _ in + if let old { commit(old) } + } + // Closing the disclosure, another page or the window saves an edit that is still open. + .onDisappear { commit(nil) } } private var priorityDetail: String { @@ -635,16 +661,45 @@ private struct DaemonTaskEditor: View { } } + private static func times(_ task: DaemonTaskConfig) -> String { + (task.missedRun.times ?? []).joined(separator: ", ") + } + private func syncFields() { every = task.every - retryTimes = (task.missedRun.times ?? []).joined(separator: ", ") + retryTimes = Self.times(task) } + /// Takes the new task's values, except in a field that has the focus and holds an edit not saved yet. + private func syncFields(from old: DaemonTaskConfig) { + if editing != .every || every == old.every { every = task.every } + if editing != .retryTimes || retryTimes == Self.times(old) { retryTimes = Self.times(task) } + } + + private func commit(_ field: Field?) { + switch field { + case .every: commitEvery() + case .retryTimes: commitRetryTimes() + case nil: + commitEvery() + commitRetryTimes() + } + } + + /// Saves a changed schedule once: Return and the focus leaving the field right after it do not save it twice. private func commitEvery() { let value = every.trimmingCharacters(in: .whitespaces) - guard !value.isEmpty, value != task.every else { return } + guard !value.isEmpty, value != task.every, value != sentEvery else { return } + sentEvery = value store.set(task, ["--every", value]) } + + private func commitRetryTimes() { + let value = retryTimes.trimmingCharacters(in: .whitespaces) + guard task.missedRun.mode == "retry-at", !value.isEmpty, value != Self.times(task), value != sentRetryTimes else { return } + sentRetryTimes = value + store.set(task, ["--retry-at", value]) + } } /// The recent runs; a click opens the run's output under it, from its run log. diff --git a/src/macos/GenesisTools/Sources/Parts/Hub/Hub/HubAgents.swift b/src/macos/GenesisTools/Sources/Parts/Hub/Hub/HubAgents.swift index a912dc18ad..6a8d026e2f 100644 --- a/src/macos/GenesisTools/Sources/Parts/Hub/Hub/HubAgents.swift +++ b/src/macos/GenesisTools/Sources/Parts/Hub/Hub/HubAgents.swift @@ -1165,12 +1165,13 @@ struct AgentsListView: View { ScrollView { LazyVStack(alignment: .leading, spacing: 2, pinnedViews: [.sectionHeaders]) { if let error = agents.error { - Text(error) - .font(.system(size: 11)) - .foregroundColor(ReviewPalette.removed) - .lineLimit(3) - .padding(.horizontal, 14) - .padding(.vertical, 4) + VStack(alignment: .leading, spacing: 8) { + NoticePill(text: "Could not load the agents.", more: error, tone: .error) + GhostButton("Try again", symbol: "arrow.clockwise", tooltip: "Load the agents again", + action: agents.reload) + } + .padding(.horizontal, 14) + .padding(.vertical, 4) } ForEach(sections, id: \.title) { section in Section { diff --git a/src/macos/GenesisTools/Sources/Parts/Hub/Hub/HubComponents.swift b/src/macos/GenesisTools/Sources/Parts/Hub/Hub/HubComponents.swift index 203df7f8d4..f376b1ef48 100644 --- a/src/macos/GenesisTools/Sources/Parts/Hub/Hub/HubComponents.swift +++ b/src/macos/GenesisTools/Sources/Parts/Hub/Hub/HubComponents.swift @@ -753,6 +753,9 @@ struct GroupHeader: View { if let path { Button("Copy absolute path") { PathOpener.copy(path, what: "path") } } + if let remove { + Button("Hide project") { remove() } + } } } } diff --git a/src/macos/GenesisTools/Sources/Parts/Hub/Hub/HubDecisionsPane.swift b/src/macos/GenesisTools/Sources/Parts/Hub/Hub/HubDecisionsPane.swift index 532aea626e..c26d13334e 100644 --- a/src/macos/GenesisTools/Sources/Parts/Hub/Hub/HubDecisionsPane.swift +++ b/src/macos/GenesisTools/Sources/Parts/Hub/Hub/HubDecisionsPane.swift @@ -115,7 +115,7 @@ struct DecisionsView: View { .frame(width: 210) .instantTooltip("Which decisions to show") // Short enough for the pane's header; the whole explanation is the tooltip (it was cut mid-sentence, H19). - Text("A click marks an option · Send delivers") + Text("A click stages an option · Send delivers") .font(.system(size: 11)) .foregroundColor(ReviewPalette.dim) .lineLimit(1) diff --git a/src/macos/GenesisTools/Sources/Parts/Hub/Hub/HubInbox.swift b/src/macos/GenesisTools/Sources/Parts/Hub/Hub/HubInbox.swift index 7c5a90533c..eee72cbd36 100644 --- a/src/macos/GenesisTools/Sources/Parts/Hub/Hub/HubInbox.swift +++ b/src/macos/GenesisTools/Sources/Parts/Hub/Hub/HubInbox.swift @@ -846,7 +846,6 @@ struct InboxListView: View { } } .frame(width: 16, height: 16) - IconButton(systemName: "arrow.clockwise", tooltip: "Look for waiting sessions again") { inbox.load() } } .padding(.horizontal, 14) .padding(.bottom, 6) @@ -857,8 +856,16 @@ struct InboxListView: View { .skeletonShimmer() .accessibilityElement(children: .ignore) .accessibilityLabel("Looking for waiting sessions") + } else if rows.isEmpty, let failure = inbox.error { + InboxLoadFailure(raw: failure) { inbox.load() } + .padding(14) + } else if rows.isEmpty, !inbox.sessions.isEmpty, !model.filter.trimmed.isEmpty { + EmptyState( + text: "No session matches “\(model.filter)”", + actionTitle: "Clear filter" + ) { model.filter = "" } } else if rows.isEmpty { - Text(inbox.error ?? "Nothing is waiting for you.") + Text("Nothing is waiting for you.") .font(.system(size: 12)) .foregroundColor(ReviewPalette.dim) .padding(14) @@ -899,10 +906,29 @@ struct InboxListView: View { } } +/// A failed load of the Inbox: one sentence and a retry; the raw failure sits behind Details. +private struct InboxLoadFailure: View { + let raw: String + let retry: () -> Void + + var body: some View { + VStack(alignment: .leading, spacing: 8) { + NoticePill(text: "Could not load the waiting sessions.", more: raw, tone: .error) + GhostButton("Try again", symbol: "arrow.clockwise", tooltip: "Look for waiting sessions again", action: retry) + } + } +} + private struct InboxSessionRow: View { let session: InboxSession let selected: Bool + private var waitingText: String { + session.isStale + ? "\(Plural.count(session.waiting, "open item")), quiet for three days: not counted as waiting" + : "\(Plural.count(session.waiting, "answer")) waiting" + } + var body: some View { HStack(alignment: .top, spacing: 9) { ProviderBadge(provider: session.provider ?? "?") @@ -929,9 +955,9 @@ private struct InboxSessionRow: View { .foregroundColor(session.isStale ? ReviewPalette.dim : .black) .frame(minWidth: 18, minHeight: 18) .background(Capsule().fill(session.isStale ? Color.white.opacity(0.08) : InboxStyle.accent)) - .instantTooltip(session.isStale - ? "\(Plural.count(session.waiting, "open item")), quiet for three days: not counted as waiting" - : "\(Plural.count(session.waiting, "answer")) waiting") + .instantTooltip(waitingText) + .accessibilityElement(children: .ignore) + .accessibilityLabel(Text(waitingText)) } else { Badge("queued", color: ReviewPalette.modified, look: .filled) .instantTooltip("Answered; the session's next prompt receives it. Nothing waits for you here.") @@ -971,12 +997,24 @@ struct InboxMain: View { LazyVStack(alignment: .leading, spacing: 22) { if rows.isEmpty, inbox.loading { PaneSkeleton("Looking for waiting sessions") - } else if rows.isEmpty { - Text("No session is waiting for an answer.") - .font(.system(size: 13)) - .foregroundColor(ReviewPalette.dim) + } else if rows.isEmpty, let failure = inbox.error { + InboxLoadFailure(raw: failure) { inbox.load() } .frame(maxWidth: .infinity) .padding(.top, 40) + } else if rows.isEmpty, !inbox.sessions.isEmpty, !model.filter.trimmed.isEmpty { + EmptyState( + text: "No session matches “\(model.filter)”", + actionTitle: "Clear filter" + ) { model.filter = "" } + .padding(.top, 40) + } else if rows.isEmpty { + EmptyState( + symbol: "tray", + text: "No session is waiting for an answer.", + actionTitle: "Look again", + action: { inbox.load() } + ) + .padding(.top, 40) } ForEach(rows.filter { !$0.isQueuedOnly }) { session in InboxSessionSection(model: model, inbox: inbox, session: session) @@ -1179,7 +1217,7 @@ private struct InboxSessionSection: View { /// One session's Send: "Send N answers to <session>", the exact message under it, then where it went /// (or why it did not, with Resume elsewhere). Shared by the Inbox section and the Decisions pane, so -/// both send the same way: every marked answer as one message through `tools question inbox send`. +/// both send the same way: every staged answer as one message through `tools question inbox send`. struct InboxSendBar: View { @ObservedObject var model: HubModel @ObservedObject var inbox: HubInboxModel @@ -1188,6 +1226,12 @@ struct InboxSendBar: View { private var drafted: [InboxItem] { session.draftedItems } private var sending: Bool { session.sessionId.map { inbox.sendingSessions.contains($0) } ?? false } private var result: InboxDelivery? { session.sessionId.flatMap { inbox.sessionResults[$0] } } + private var ownsShortcut: Bool { + guard !drafted.isEmpty else { return false } + if inbox.selectedID == session.id { return true } + let staging = inbox.sessions.filter { !$0.draftedItems.isEmpty } + return staging.count == 1 && staging[0].id == session.id + } /// The message the Send button will type, previewed under it. static func preview(_ drafted: [InboxItem]) -> String { @@ -1210,8 +1254,10 @@ struct InboxSendBar: View { Button { inbox.sendSession(session) } label: { - Label(sending ? "Sending…" : "Send \(drafted.count) answer\(drafted.count == 1 ? "" : "s") to \(session.displayTitle)", systemImage: "paperplane.fill") + Label(sending ? "Sending…" : "Send \(Plural.count(drafted.count, "answer")) to \(session.displayTitle)", systemImage: "paperplane.fill") .font(.system(size: 12, weight: .semibold)) + .lineLimit(1) + .truncationMode(.tail) .foregroundColor(drafted.isEmpty ? ReviewPalette.dim : .black) .padding(.horizontal, 12) .frame(height: 28) @@ -1219,7 +1265,10 @@ struct InboxSendBar: View { } .buttonStyle(.genHoverPlain()) .disabled(drafted.isEmpty || sending || session.sessionId == nil) - .instantTooltip("Send every marked answer to the agent as one message") + // One session answers the chord: the selected one, or the only one with staged answers. + .keyboardShortcut(ownsShortcut ? KeyboardShortcut(.return, modifiers: .command) : nil) + .instantTooltip("Send every staged answer to the agent as one message (⌘↩)") + .accessibilityHint(Text("Command-Return")) if sending { ProgressView().controlSize(.small) } @@ -1626,7 +1675,7 @@ struct InboxDecisionCard: View { .buttonStyle(.genHoverRow(accent: .white, cornerRadius: 7)) .disabled(!pickable) .instantTooltip(pickable - ? (chosen ? "Click to unmark \(choice.id)) · ⌘-click to add another" : "Mark \(choice.id)) · ⌘-click to add a second") + ? (chosen ? "Click to unstage \(choice.id)) · ⌘-click to add another" : "Stage \(choice.id)) · ⌘-click to stage a second") : "Already \(item.status)") } @@ -2221,9 +2270,10 @@ private struct InboxFormCard: View { .foregroundColor(.white) .background(Circle().fill(Color.black.opacity(0.6))) } - .buttonStyle(.plain) + .buttonStyle(.genHoverPlain()) .offset(x: 4, y: -4) .instantTooltip("Remove this image") + .accessibilityLabel(Text("Remove image")) } } } diff --git a/src/macos/GenesisTools/Sources/Parts/Hub/Hub/HubNotify.swift b/src/macos/GenesisTools/Sources/Parts/Hub/Hub/HubNotify.swift index c01f0bfd50..0009a17ab4 100644 --- a/src/macos/GenesisTools/Sources/Parts/Hub/Hub/HubNotify.swift +++ b/src/macos/GenesisTools/Sources/Parts/Hub/Hub/HubNotify.swift @@ -30,6 +30,13 @@ struct HubNotifyStatus: Decodable, Equatable { let lastOkAt: String? let lastError: String? let lastAttemptAt: String? + + /// A failing project in one sentence ("2 polls failed. The next try is at 21:49."); the host's raw error goes + /// behind Details, never into the sentence or a tooltip. + var problemText: String { + let next = HubFormat.date(nextAt).map { "at \($0.formatted(date: .omitted, time: .shortened))" } ?? "soon" + return "\(Plural.count(failures, "poll")) failed. The next try is \(next)." + } } struct Event: Decodable, Equatable, Identifiable { @@ -62,6 +69,72 @@ struct HubNotifyStatus: Decodable, Equatable { static func decode(_ data: Data) throws -> HubNotifyStatus { try JSONDecoder().decode(HubNotifyStatus.self, from: data) } + + /// The daemon task's state in one line; the bell and the settings page say the same words. + var daemonTitle: String { + switch daemonTask { + case true?: + return daemonTaskEvery == daemonTaskWanted + ? "The daemon checks every minute" + : "The daemon task still runs \(daemonTaskEvery ?? "at an old cadence")" + case false?: return "Nothing polls yet: the daemon task is missing" + case nil: return "The daemon's task list is unreadable" + } + } + + /// The button that fixes the daemon task (`tools hub notify install`), or nil while it ticks every minute. + var daemonFixTitle: String? { + guard daemonTask != true || daemonTaskEvery != daemonTaskWanted else { return nil } + return daemonTask == true ? "Tick every minute" : "Add the daemon task" + } +} + +/// The words of the PR notification settings, shared by the bell popover and Settings, so one setting reads the same +/// in both places and offers the same choices (H17). +enum HubNotifyWords { + static let enabledDetail = "When this is off, no project is polled and nothing posts." + static let onlyMineDetail = "Changes on PRs someone else opened stay silent." + static let cadenceDetail = "How often a project without its own cadence is polled." + static let pollNowDetail = "Poll every watched project now, ignoring cadences and backoff" + static let installDetail = "Adds one background task that checks every watched project each minute." + static let installCommand = "tools hub notify install" + static let eventsDetail = "Which events this project posts; the defaults apply until you change one here" + + /// The cadences a project can pick, in minutes (the CLI takes any whole minute from 1 to 60). + static let cadenceChoices = [1, 2, 3, 5, 10, 15, 20, 30, 45, 60] + + static func cadenceTitle(_ minutes: Int) -> String { + minutes == 1 ? "Every minute" : "Every \(minutes) minutes" + } + + /// `cadenceChoices`, plus the stored value when the config holds another one. + static func cadenceOptions(including value: Int) -> [Int] { + cadenceChoices.contains(value) ? cadenceChoices : (cadenceChoices + [value]).sorted() + } + + /// The label of a project's events menu: "All events (default)", "3 events". + static func eventsSummary(repo: HubNotifyStatus.Repo?, config: HubNotifyStatus.Config) -> String { + let on = HubNotifyEvent.allCases.filter { repo?.events?[$0.rawValue] ?? config.events[$0.rawValue] ?? false } + let label = on.count == HubNotifyEvent.allCases.count ? "All events" : Plural.count(on.count, "event") + return repo?.events == nil ? "\(label) (default)" : label + } + + /// A project's events menu: each event checked as it applies to the project, then "Follow the defaults". + @MainActor + static func eventItems(path: String, repo: HubNotifyStatus.Repo?, config: HubNotifyStatus.Config, store: HubNotifyStore) -> [MenuButtonItem] { + HubNotifyEvent.allCases.map { event -> MenuButtonItem in + let own = repo?.events?[event.rawValue] + let effective = own ?? config.events[event.rawValue] ?? false + return .action("\(event.title)\(own == nil ? "" : " (this project)")", checked: effective) { + store.set(["--repo", path, "--event", "\(event.rawValue)=\(effective ? "off" : "on")"]) + } + } + [ + .divider, + .action("Follow the defaults", enabled: repo?.events != nil) { + store.set(["--repo", path, "--reset-repo-events"]) + }, + ] + } } enum HubNotifyEvent: String, CaseIterable, Identifiable { @@ -77,6 +150,29 @@ enum HubNotifyEvent: String, CaseIterable, Identifiable { case .merged: return "PR merged" } } + + /// When the event posts; the settings row's detail and the bell's tooltip. + var detail: String { + switch self { + case .thread: return "Someone else opens a review thread or comment thread on a PR." + case .reviewRequested: return "You are added as a reviewer." + case .ciFailed: return "The checks of a PR's newest commit fail." + case .ciPassed: return "The checks of a PR's newest commit pass." + case .botReview: return "A review bot posts a review." + case .merged: return "A watched open PR is merged." + } + } +} + +/// A note of the PR notification settings: one sentence, its tone, the raw output behind Details, and what Retry runs +/// again. The code that knows what happened sets the tone; the words never pick a colour (H5, H6). +struct HubNotifyNotice { + let id = UUID() + let text: String + let tone: NoticePill.Tone + var detail: String? + var more: String? + var retry: (@MainActor () -> Void)? } @MainActor @@ -85,7 +181,7 @@ final class HubNotifyStore: ObservableObject { @Published private(set) var status: HubNotifyStatus? @Published private(set) var busy = false - @Published var message: String? + @Published var message: HubNotifyNotice? /// Runs in flight: `busy` holds until the last one ends, not the first. private var inFlight = 0 { didSet { busy = inFlight > 0 } @@ -94,46 +190,57 @@ final class HubNotifyStore: ObservableObject { private var statusGeneration = 0 func load() { - run(["hub", "notify", "status", "--json"], label: "notify.status", decodesStatus: true) + run(["hub", "notify", "status", "--json"], label: "notify.status", failure: "The notification settings could not be read.", decodesStatus: true) } /// The status read has ended, with the settings or with the error it shows. var settled: Bool { !busy && (status != nil || message != nil) } - /// One `tools hub notify set` change, then the status again. + /// One `tools hub notify set` change, then the status again. Retry sends the same change. func set(_ flags: [String]) { - run(["hub", "notify", "set"] + flags + ["--json"], label: "notify.set", reload: true) + run(["hub", "notify", "set"] + flags + ["--json"], label: "notify.set", failure: "The change was not saved.", reload: true) } func pollNow() { - run(["hub", "notify", "poll", "--force", "--json"], label: "notify.poll", reload: true, summary: Self.pollSummary) + run(["hub", "notify", "poll", "--force", "--json"], label: "notify.poll", failure: "The check did not run.", reload: true, summary: Self.pollSummary) } - /// One line for `tools hub notify poll --json` (src/hub/lib/notify-poll.ts `PollReport`). - nonisolated static func pollSummary(_ data: Data) -> String? { + /// One note for `tools hub notify poll --json` (src/hub/lib/notify-poll.ts `PollReport`): a count on success, the + /// CLI's own reason behind Details when it skipped. + nonisolated static func pollSummary(_ data: Data) -> HubNotifyNotice { guard let report = try? JSONSerialization.jsonObject(with: data) as? [String: Any] else { - return "Reading the poll report failed" + return HubNotifyNotice(text: "The check ran, but its report could not be read.", tone: .warning, more: String(decoding: data.prefix(2000), as: UTF8.self)) } let items = (report["items"] as? [Any])?.count ?? 0 let posted = report["posted"] as? Int ?? 0 let requests = report["requests"] as? Int ?? 0 - if let skipped = report["skipped"] as? String { return "Poll skipped: \(skipped)" } - return "Polled: \(items) events, \(posted) posted, \(requests) requests" + if let skipped = report["skipped"] as? String { + return HubNotifyNotice(text: "The check did not run.", tone: .warning, more: skipped) + } + return HubNotifyNotice( + text: "Polled: \(Plural.count(items, "event")), \(posted) posted, \(Plural.count(requests, "request"))", + tone: .success + ) } /// `tools hub notify install`: the one `hub-pr-notify` daemon task, ticking every minute. func installDaemonTask() { - run(["hub", "notify", "install"], label: "notify.install", reload: true) { _ in "The daemon task is installed; it ticks every minute" } + run(["hub", "notify", "install"], label: "notify.install", failure: "The daemon task was not added.", reload: true) { _ in + HubNotifyNotice(text: "The daemon task is installed; it ticks every minute", tone: .success) + } } func sendTest(pr: String?) { - run(["hub", "notify", "test"] + (pr.map { ["--pr", $0] } ?? []), label: "notify.test") { _ in "Test notification posted" } + run(["hub", "notify", "test"] + (pr.map { ["--pr", $0] } ?? []), label: "notify.test", failure: "The test notification was not posted.") { _ in + HubNotifyNotice(text: "Test notification posted", tone: .success) + } } /// `decodesStatus`: the output is `status --json`. Only `load` sets it; `set --json` prints the config alone. + /// `failure`: the sentence a failed run shows; the CLI's own error goes behind Details, and Retry runs it again. private func run( - _ args: [String], label: String, decodesStatus: Bool = false, reload: Bool = false, - summary: ((Data) -> String?)? = nil + _ args: [String], label: String, failure: String, decodesStatus: Bool = false, reload: Bool = false, + summary: ((Data) -> HubNotifyNotice)? = nil ) { inFlight += 1 if decodesStatus { @@ -151,22 +258,50 @@ final class HubNotifyStore: ObservableObject { message = summary(data) } else if decodesStatus, generation == statusGeneration { // A silent `try?` left the popover on "Reading the notification settings…" forever - // whenever the CLI's JSON and this Decodable drifted apart. + // whenever the CLI's JSON and this Decodable drifted apart. A retry cannot fix that drift. do { status = try HubNotifyStatus.decode(data) } catch { - message = "Reading tools hub notify status --json failed: \(error)" + message = HubNotifyNotice( + text: failure, tone: .error, + detail: "The app and the tools command expect different formats. Updating both usually fixes it.", + more: "tools \(args.joined(separator: " ")): \(error)" + ) } } if reload { load() } case .failure(let error): span.end("failed") - message = "\(error)" + message = HubNotifyNotice(text: failure, tone: .error, more: "\(error)") { [weak self] in + self?.message = nil + self?.run(args, label: label, failure: failure, decodesStatus: decodesStatus, reload: reload, summary: summary) + } } } } } +/// The note under the bell's settings and the PR activity page, with Retry when a retry can help. VoiceOver hears a +/// failure, since it lands after the click that caused it. +struct HubNotifyNoticeView: View { + @ObservedObject var store: HubNotifyStore + + var body: some View { + if let message = store.message { + NoticePill( + text: message.text, detail: message.detail, more: message.more, tone: message.tone, + fixes: message.retry.map { retry in [NoticeFix.button("Retry", symbol: "arrow.clockwise", perform: retry)] } ?? [] + ) { store.message = nil } + .onAppear { announce(message) } + .onChange(of: message.id) { _, _ in announce(message) } + } + } + + private func announce(_ message: HubNotifyNotice) { + if message.tone != .success { GenAnnounce.post(message.text) } + } +} + /// The bell: filled while notifications are on and at least one repo is watched. struct HubNotifyButton: View { @ObservedObject var prs: PRsModel @@ -179,7 +314,7 @@ struct HubNotifyButton: View { } var body: some View { - IconButton(systemName: active ? "bell.fill" : "bell", tooltip: "PR notifications: which events, which repos") { open.toggle() } + IconButton(systemName: active ? "bell.fill" : "bell", tooltip: "PR notifications: which events, which projects") { open.toggle() } .popover(isPresented: $open, arrowEdge: .bottom) { HubNotifySettings(prs: prs, store: store) } @@ -190,7 +325,9 @@ struct HubNotifyButton: View { struct HubNotifySettings: View { @ObservedObject var prs: PRsModel @ObservedObject var store: HubNotifyStore - @State private var find = PanelFindModel(scope: "notify", title: "repos and recent events") + @State private var find = PanelFindModel(scope: "notify", title: "projects and recent events") + /// Failing projects whose error is open under their row. + @State private var openProblems: Set<String> = [] /// The hub's projects (one checkout each) plus every configured repo, by path. private var repoPaths: [(path: String, name: String)] { @@ -209,15 +346,13 @@ struct HubNotifySettings: View { PanelFindBar(find: find) if let status = store.status { content(status) - } else { + } else if store.busy || store.message == nil { SkeletonLines(count: 4) .skeletonShimmer() .accessibilityElement(children: .ignore) .accessibilityLabel("Reading the notification settings") } - if let message = store.message { - NoticePill(text: message, isError: message.contains("exited") || message.contains("failed")) { store.message = nil } - } + HubNotifyNoticeView(store: store) } .padding(14) .frame(width: 400) @@ -225,7 +360,7 @@ struct HubNotifySettings: View { .panelFind(find, revision: store.status) { findRows } } - /// What ⌘F searches here: the repo names and the recent events, as shown. + /// What ⌘F searches here: the project names and the recent events, as shown. private var findRows: [PanelFindRow] { repoPaths.map { PanelFindRow(id: "repo:\($0.path)", fields: [PanelFindField("name", $0.name)]) } + (store.status?.recent.prefix(5) ?? []).map { PanelFindRow(id: "event:\($0.id)", fields: [PanelFindField("message", $0.message)]) } @@ -238,6 +373,7 @@ struct HubNotifySettings: View { Toggle("PR notifications", isOn: Binding(get: { config.enabled }, set: { store.set(["--enabled", $0 ? "on" : "off"]) })) .toggleStyle(.switch) .font(.system(size: 13, weight: .semibold)) + .instantTooltip(HubNotifyWords.enabledDetail) Spacer() if store.busy { ProgressView().controlSize(.small) } } @@ -247,8 +383,8 @@ struct HubNotifySettings: View { .font(.system(size: 11)) .foregroundColor(ReviewPalette.dim) .fixedSize(horizontal: false, vertical: true) - if status.daemonTask == false { - daemonHint + if let fix = status.daemonFixTitle { + daemonHint(status.daemonTitle, fix: fix) } section("Events") @@ -259,19 +395,30 @@ struct HubNotifySettings: View { )) .toggleStyle(.checkbox) .font(.system(size: 12)) + .instantTooltip(event.detail) } + // The same switch, choices and words as Settings' PR activity page (H17): a stepper here once took any minute + // while the page offered fixed choices. HStack(spacing: 14) { Toggle("Only my PRs", isOn: Binding(get: { config.onlyMine }, set: { store.set(["--only-mine", $0 ? "on" : "off"]) })) .toggleStyle(.checkbox) - .instantTooltip("Only PRs/MRs you opened") - Stepper(value: Binding(get: { config.intervalMinutes }, set: { store.set(["--interval", String($0)]) }), in: 1...60) { - Text(verbatim: "Every \(config.intervalMinutes) min").font(.system(size: 12, design: .monospaced)) + .instantTooltip(HubNotifyWords.onlyMineDetail) + Spacer(minLength: 0) + Picker("Cadence", selection: Binding( + get: { config.intervalMinutes }, + set: { store.set(["--interval", String($0)]) } + )) { + ForEach(HubNotifyWords.cadenceOptions(including: config.intervalMinutes), id: \.self) { + Text(HubNotifyWords.cadenceTitle($0)).tag($0) + } } - .instantTooltip("Minutes between polls of a repo without its own cadence (Settings → Notifications → PR activity); a failing repo backs off up to an hour") + .pickerStyle(.menu) + .fixedSize() + .instantTooltip(HubNotifyWords.cadenceDetail) } .font(.system(size: 12)) - section("Repos") + section("Projects") ScrollView { VStack(alignment: .leading, spacing: 2) { ForEach(repoPaths, id: \.path) { repo in @@ -305,25 +452,37 @@ struct HubNotifySettings: View { HStack(spacing: 12) { Button("Check now") { store.pollNow() } - .instantTooltip("Poll every watched repo now, ignoring the interval and any backoff") + .instantTooltip(HubNotifyWords.pollNowDetail) Button("Send a test") { store.sendTest(pr: prs.selected.map { "\($0.project)\($0.isGitLab ? "!" : "#")\($0.number)" }) } .instantTooltip("Post one notification marked as a test; its click opens the hub at the selected PR") Spacer() Button("Config file") { PathOpener.cursor(status.configPath) } - .instantTooltip(status.configPath) + .instantTooltip("Open \((status.configPath as NSString).abbreviatingWithTildeInPath) in Cursor") + Button("Settings…") { FeatureSettingsLaunch.open(pageID: PRActivitySettingsPages.pageID) } + .instantTooltip("Open the PR activity page in Settings: a cadence and events per project") } .buttonStyle(.genHoverPlain()) .font(.system(size: 11.5)) .disabled(store.busy) } - private var daemonHint: some View { - HStack(spacing: 6) { + /// Nothing polls, or the daemon task ticks at an old cadence: the button Settings has, in the same words (H8). + private func daemonHint(_ title: String, fix: String) -> some View { + HStack(alignment: .firstTextBaseline, spacing: 6) { Image(systemName: "exclamationmark.triangle").foregroundColor(ReviewPalette.modified) - Text("Nothing polls yet. Run `tools hub notify install` once to add the daemon task.") + Text(title) .font(.system(size: 11)) .fixedSize(horizontal: false, vertical: true) - IconButton(systemName: "doc.on.doc", tooltip: "Copy the command") { PathOpener.copy("tools hub notify install") } + Spacer(minLength: 4) + Button(fix) { store.installDaemonTask() } + .buttonStyle(.genHover()) + .font(.system(size: 11.5)) + .disabled(store.busy) + .instantTooltip(HubNotifyWords.installDetail) + .contextMenu { + Button("Copy the terminal command") { Clipboard.copy(HubNotifyWords.installCommand, what: "command") } + } + .accessibilityIdentifier("notify.install") } } @@ -341,43 +500,44 @@ struct HubNotifySettings: View { let repo = config.repos[path] let watched = repo?.enabled == true let problem = watched ? state.repos.values.first { $0.path == path && $0.lastError != nil } : nil - return HStack(spacing: 6) { - Toggle(isOn: Binding(get: { watched }, set: { store.set(["--repo", path, "--repo-enabled", $0 ? "on" : "off"]) })) { - FindText(name, field: "name").font(.system(size: 12)) - } - .toggleStyle(.checkbox) - .instantTooltip(path) - if let problem, let error = problem.lastError { - Image(systemName: "exclamationmark.circle") - .font(.system(size: 11)) - .foregroundColor(ReviewPalette.removed) - .instantTooltip("\(problem.failures) failed polls; next try \(problem.nextAt ?? "soon")\n\(error)") - } - Spacer(minLength: 4) - if watched { - MenuButton(items: { - HubNotifyEvent.allCases.map { event -> MenuButtonItem in - let own = repo?.events?[event.rawValue] - let effective = own ?? config.events[event.rawValue] ?? false - return .action("\(event.title)\(own == nil ? "" : " (this repo)")", checked: effective) { - store.set(["--repo", path, "--event", "\(event.rawValue)=\(effective ? "off" : "on")"]) + let showsProblem = problem != nil && openProblems.contains(path) + return VStack(alignment: .leading, spacing: 4) { + HStack(spacing: 6) { + Toggle(isOn: Binding(get: { watched }, set: { store.set(["--repo", path, "--repo-enabled", $0 ? "on" : "off"]) })) { + FindText(name, field: "name").font(.system(size: 12)) + } + .toggleStyle(.checkbox) + .instantTooltip(path) + if let problem { + // The tooltip says what happened; a click shows the host's own error below the row (H21). + IconButton( + systemName: "exclamationmark.circle", + tooltip: showsProblem ? "Hide the error" : "\(problem.problemText) Click to see the error.", + size: 11, tint: ReviewPalette.modified + ) { + if showsProblem { openProblems.remove(path) } else { openProblems.insert(path) } + } + } + Spacer(minLength: 4) + if watched { + MenuButton(items: { + HubNotifyWords.eventItems(path: path, repo: repo, config: config, store: store) + }) { + HStack(spacing: 3) { + Text(HubNotifyWords.eventsSummary(repo: repo, config: config)).font(.system(size: 11)) + Image(systemName: "chevron.down").font(.system(size: 8, weight: .semibold)) } - } + [ - .divider, - .action("Follow the global switches", enabled: repo?.events != nil) { - store.set(["--repo", path, "--reset-repo-events"]) - }, - ] - }) { - HStack(spacing: 3) { - Text(repo?.events == nil ? "All events" : "Own events").font(.system(size: 11)) - Image(systemName: "chevron.down").font(.system(size: 8, weight: .semibold)) } + .fixedSize() + .instantTooltip(HubNotifyWords.eventsDetail) } - .fixedSize() - .instantTooltip("Which events this repo posts; the global switches apply unless you change one here") + } + .frame(height: 24) + if showsProblem, let problem { + NoticePill(text: problem.problemText, more: problem.lastError, tone: .warning) + .padding(.leading, 20) + .padding(.bottom, 4) } } - .frame(height: 24) } } diff --git a/src/macos/GenesisTools/Sources/Parts/Hub/Hub/HubPRs.swift b/src/macos/GenesisTools/Sources/Parts/Hub/Hub/HubPRs.swift index c843a44fa5..1b3e046a41 100644 --- a/src/macos/GenesisTools/Sources/Parts/Hub/Hub/HubPRs.swift +++ b/src/macos/GenesisTools/Sources/Parts/Hub/Hub/HubPRs.swift @@ -396,7 +396,7 @@ final class PRsModel: ObservableObject { private var freshGeneration = -1 /// The key of the rows on screen (the cache paints before the fresh answer). private var appliedKey: String? - private var lastLoad: Date? + private(set) var lastLoad: Date? private var searchTask: Task<Void, Never>? /// PR details being fetched, and when each last arrived fresh. private var detailLoading: Set<String> = [] @@ -1521,15 +1521,16 @@ struct PRListView: View { @StateObject private var prefs = GroupPrefs(key: "prs.repos") @ObservedObject private var readiness = PRReadinessStore.shared @GenReduceMotion private var reduceMotion + /// The list's keyboard focus: a click on a row gives it, then Up and Down move the selection (`SidebarArrowKeys`). + @FocusState private var listFocused: Bool private var statePicker: some View { - Picker("", selection: Binding(get: { prs.state }, set: { prs.state = $0; prs.reload() })) { - Text("Open").tag("open") - Text("Merged").tag("merged") - Text("All").tag("all") - } - .pickerStyle(.segmented) - .labelsHidden() + GenSegmentedTabs( + "PR state", + items: [.init("open", "Open"), .init("merged", "Merged"), .init("all", "All")], + selection: Binding(get: { prs.state }, set: { prs.state = $0; prs.reload() }), + minSegmentWidth: 52 + ) .instantTooltip("Which PRs/MRs to list") } @@ -1599,6 +1600,34 @@ struct PRListView: View { .skeletonShimmer() .accessibilityElement(children: .ignore) .accessibilityLabel("Loading PRs and MRs") + } else if prs.prs.isEmpty, !prs.loading, prs.lastLoad != nil, prs.errors.isEmpty { + EmptyState( + symbol: "arrow.triangle.pull", + text: prs.mineOnly ? "None of your PRs/MRs match." : "No PRs/MRs match.", + detail: prs.mineOnly ? "Turn off Mine to see everyone's." : "Try another state, or reload.", + actionTitle: prs.mineOnly ? "Show everyone's" : "Reload", + action: { + if prs.mineOnly { + prs.mineOnly = false + } + prs.reload() + } + ) + .padding(.top, 24) + } else if groups.isEmpty, !prs.prs.isEmpty, !prs.loading { + if model.filter.trimmed.isEmpty { + EmptyState( + text: "None of your PRs/MRs match.", + actionTitle: "Show everyone's" + ) { prs.mineOnly = false; prs.reload() } + .padding(.top, 24) + } else { + EmptyState( + text: "No PR or MR matches “\(model.filter)”", + actionTitle: "Clear filter" + ) { model.filter = "" } + .padding(.top, 24) + } } ForEach(groups, id: \.project) { group in Section { @@ -1649,6 +1678,11 @@ struct PRListView: View { loaded: !prs.prs.isEmpty, prefs: prefs )) + .modifier(SidebarArrowKeys( + ids: { [collapsed = prefs.collapsed] in groups.filter { !collapsed.contains($0.project) }.flatMap { $0.rows.map(\.id) } }, + selection: { prs.selectedID }, + select: { id in if let pr = prs.prs.first(where: { $0.id == id }) { prs.select(pr, opened: true) } }, + proxy: proxy, focused: $listFocused)) } } // The list's own layout width: a sidebar drag that re-lays it out per step flips this per step. @@ -1756,7 +1790,7 @@ struct PRListView: View { // New and updated rows after a refresh flash once. .swrFlash(prs.changed.contains(pr.id)) .contentShape(Rectangle()) - .rowButton(cornerRadius: 8) { prs.select(pr, opened: true) } + .rowButton(cornerRadius: 8) { listFocused = true; prs.select(pr, opened: true) } .padding(.horizontal, 6) .instantTooltip("\(pr.label) \(pr.headBranch) → \(pr.baseBranch)\nRight-click for its web pages") // The row stays one button (select); its labels' web pages live here, since a link inside @@ -1887,6 +1921,8 @@ struct PRStateIcon: View { .foregroundColor(tone.color) .frame(width: 16) .instantTooltip(tone.title) + .accessibilityElement(children: .ignore) + .accessibilityLabel(Text(verbatim: tone.title)) } } @@ -1926,17 +1962,28 @@ struct CIBadge: View { default: return ("clock", ReviewPalette.dim) } }() + let sentence: String = { + switch ci { + case "success": return "All checks passed" + case "failed": return "Some checks failed" + case "running": return "Checks are running" + case "pending": return "Checks are waiting to start" + default: return "The checks have no clear state" + } + }() let icon = Image(systemName: symbol).font(.system(size: 11)).foregroundColor(color) if let url { Button { ExternalOpener.open(url) } label: { icon } .buttonStyle(.genHoverIcon()) - .instantTooltip("CI: \(ci). Open the checks\n\(url.absoluteString)") - .accessibilityLabel(Text("CI: \(ci)")) + .instantTooltip("\(sentence). Open the checks\n\(url.absoluteString)") + .accessibilityLabel(Text(verbatim: sentence)) .accessibilityRemoveTraits(.isButton) .accessibilityAddTraits(.isLink) .accessibilityValue(Text(url.absoluteString)) } else { - icon.instantTooltip("CI: \(ci)") + icon.instantTooltip(sentence) + .accessibilityElement(children: .ignore) + .accessibilityLabel(Text(verbatim: sentence)) } } } diff --git a/src/macos/GenesisTools/Sources/Parts/Hub/Hub/HubTimeline.swift b/src/macos/GenesisTools/Sources/Parts/Hub/Hub/HubTimeline.swift index a246f1af48..3239ae1493 100644 --- a/src/macos/GenesisTools/Sources/Parts/Hub/Hub/HubTimeline.swift +++ b/src/macos/GenesisTools/Sources/Parts/Hub/Hub/HubTimeline.swift @@ -150,6 +150,17 @@ enum TimelineRange: String, CaseIterable { } } + /// The next range that covers more, for an empty feed's "Show …" button (H16); nil when none does: Last 30 + /// days is the longest, and a custom range was picked on purpose. + var wider: TimelineRange? { + switch self { + case .lastHour: return .last24h + case .last24h, .today, .yesterday, .sinceLastVisit: return .last7 + case .last7: return .last30 + case .last30, .custom: return nil + } + } + /// The range in local time; a missing last visit falls back to the last 24 hours. func interval(now: Date = Date(), lastVisit: Date?, customFrom: Date, customTo: Date, calendar: Calendar = .current) -> DateInterval { let today = calendar.startOfDay(for: now) @@ -210,8 +221,8 @@ final class HubTimelineModel: ObservableObject { @Published private(set) var loadedAt: Date? @Published private(set) var error: String? @Published private(set) var hasMore = false - /// Why the last "Load older" failed; the footer shows it beside the button (the page is not empty, - /// so the empty-list error line never renders then). + /// Why the last "Load older" failed, as the CLI said it; the footer shows one sentence beside the button and this + /// behind Details (the page is not empty, so the list's own failure note never renders then). @Published private(set) var olderError: String? private var nextBefore: String? /// The page on screen is the last run's for this range and filters, from disk; the fresh one is loading. @@ -312,6 +323,20 @@ final class HubTimelineModel: ObservableObject { range.interval(lastVisit: lastVisit, customFrom: customFrom, customTo: customTo) } + /// A filter of the feed is on: a hidden kind, one project, an author, or "only what needs me". The sidebar's text + /// filter is the hub's own (`HubModel.filter`). + var hasFilters: Bool { + !hidden.isEmpty || project != nil || author != .all || needsMe + } + + /// Shows every kind, project and author again (an empty feed's "Clear filters"). + func clearFilters() { + hidden = [] + project = nil + author = .all + needsMe = false + } + /// The rows the list shows: hidden kinds, the project and the sidebar filter text. func shown(filter: String) -> [TimelineEvent] { let needle = filter.trimmed.lowercased() @@ -493,7 +518,7 @@ final class HubTimelineModel: ObservableObject { self.hasMore = envelope.hasMore ?? false self.nextBefore = envelope.nextBefore case .failure(let failure): - self.olderError = "Could not read older activity: \(failure)" + self.olderError = "\(failure)" } } } @@ -673,7 +698,45 @@ enum TimelinePrompts { } } +/// The tone of each note the Activity pane posts, kept with its text by the code that knows what happened (H5): the +/// colour never comes from words like "failed" in the text. `HubModel.notice` is one string every pane shares, so the +/// tone cannot travel inside it yet; Activity records it here, keyed by the text. +@MainActor +enum TimelineNotice { + private static var tones: [String: NoticePill.Tone] = [:] + + static func tone(of text: String) -> NoticePill.Tone? { tones[text] } + + static func record(_ text: String, _ tone: NoticePill.Tone) { + if tones.count > 100 { tones.removeAll() } + tones[text] = tone + } + + /// A note another pane posted (the command palette's "Starting a new Claude session…", say): the old reading of its + /// words, until `HubModel.notice` carries its own tone. + static func otherPaneTone(_ text: String) -> NoticePill.Tone { + text.contains("failed") || text.hasPrefix("No ") ? .error : .success + } +} + extension HubModel { + /// Shows an Activity note with its tone. + @MainActor + func postTimelineNotice(_ text: String, _ tone: NoticePill.Tone) { + TimelineNotice.record(text, tone) + notice = text + } + + /// A launch from an Activity row (resume, a new agent) as a note: started or refused. + @MainActor + func postTimelineNotice(_ outcome: LaunchOutcome) { + switch outcome { + case .cancelled: return + case .launched(let text): postTimelineNotice(text, .success) + case .failed(let text): postTimelineNotice(text, .error) + } + } + /// A timeline row's click: the session, the commit in its repository's diff, the PR, or the decision's session. @MainActor func openTimelineEvent(_ event: TimelineEvent) { @@ -695,7 +758,7 @@ extension HubModel { @MainActor func showTimelineCommit(_ event: TimelineEvent, sha: String?, title: String) { guard let repo = event.repo, let sha, FileManager.default.fileExists(atPath: repo) else { - notice = "This repository is not on this Mac." + postTimelineNotice("This repository is not on this Mac.", .warning) return } showCommit(repo: repo, sha: sha, title: title) @@ -725,7 +788,7 @@ extension HubModel { @MainActor func openTimelineSession(_ event: TimelineEvent, transcriptQuery query: String? = nil) { guard let session = timelineSession(event) else { - notice = "This row names no session." + postTimelineNotice("This row names no session.", .warning) return } if !sessions.contains(where: { $0.id == session.id }) { @@ -764,7 +827,7 @@ extension HubModel { @MainActor func openTimelinePR(_ event: TimelineEvent, reveal path: String? = nil) { guard let ref = event.pr.flatMap({ pr in HubPRRef(pr.ref)?.withPage(pr.url) }) else { - notice = "This row names no PR." + postTimelineNotice("This row names no PR.", .warning) return } setMode(.prs) @@ -779,7 +842,13 @@ extension HubModel { DispatchQueue.global(qos: .userInitiated).async { let failure = TerminalHosts.current.focus(sessionId: id, provider: provider) DispatchQueue.main.async { [weak self] in - self?.notice = failure.map { "No cmux pane found for this session: \($0.suffix(120))" } ?? "Focused the session's cmux pane" + MainActor.assumeIsolated { + if let failure { + self?.postTimelineNotice("No cmux pane found for this session: \(failure.suffix(120))", .error) + } else { + self?.postTimelineNotice("Focused the session's cmux pane", .success) + } + } } } } @@ -832,7 +901,7 @@ extension HubModel { if let url = self.timelineHostURL(event) { ExternalOpener.open(url) } else { - self.notice = "The repository's origin is not known yet; try again in a moment." + self.postTimelineNotice("The repository's origin is not known yet; try again in a moment.", .warning) } } add("copy", "number", "Copy the sha") { PathOpener.copy(event.sha ?? "") } @@ -840,14 +909,14 @@ extension HubModel { guard let self, let timeline else { return } if case .commit(let detail)? = timeline.details[event.id] { guard let first = detail.prs.first, let ref = HubPRRef(first.ref)?.withPage(first.url) else { - self.notice = "No PR of this repository contains \(event.shortSha ?? "this commit")." + self.postTimelineNotice("No PR of this repository contains \(event.shortSha ?? "this commit").", .warning) return } self.setMode(.prs) self.prs.request(ref) } else { if timeline.isExpanded(event) { timeline.loadDetail(event) } else { timeline.toggle(event) } - self.notice = "Reading the commit's PRs; open the row to pick one." + self.postTimelineNotice("Reading the commit's PRs; open the row to pick one.", .success) } } if let repo = event.repo, let sha = event.sha { @@ -859,7 +928,7 @@ extension HubModel { if let url = self.timelineHostURL(event, compare: true) { ExternalOpener.open(url) } else { - self.notice = "No compare page: a new branch, or the origin is not known yet." + self.postTimelineNotice("No compare page: a new branch, or the origin is not known yet.", .warning) } } add("pr", "arrow.triangle.pull", "Open the PR in the hub", disabled: event.pr == nil) { [weak self] in self?.openTimelinePR(event) } @@ -882,7 +951,7 @@ extension HubModel { add("resolve", resolved ? "checkmark.circle.fill" : "checkmark.circle", resolved ? "Reopen this thread" : "Resolve this thread") { [weak self, weak timeline] in guard let timeline, let url = event.pr?.url, let thread = event.threadId else { return } timeline.threadStore(for: url).resolve(thread: thread, resolved: !resolved) - self?.notice = resolved ? "Reopening the thread…" : "Resolving the thread…" + self?.postTimelineNotice(resolved ? "Reopening the thread…" : "Resolving the thread…", .success) } add("host", "arrow.up.right.square", "Open the comment on the host") { [weak self] in if let url = self?.timelineHostURL(event) { ExternalOpener.open(url) } @@ -1144,18 +1213,18 @@ struct TimelineMain: View { PanelFindBar(find: find) ScrollView { LazyVStack(alignment: .leading, spacing: 0, pinnedViews: [.sectionHeaders]) { + // A failed read says so in one sentence with Retry, above whatever page is still on screen (H7). + if let failure = timeline.error { + loadFailure(failure) + } if events.isEmpty, timeline.loading { SkeletonRows(count: 12, leading: .dot) .skeletonShimmer() .accessibilityElement(children: .ignore) .accessibilityLabel("Reading the activity") .padding(.top, 8) - } else if events.isEmpty { - Text(timeline.error ?? (timeline.events.isEmpty ? "Nothing happened in this range." : "Nothing matches these filters.")) - .font(.system(size: 13)) - .foregroundColor(ReviewPalette.dim) - .frame(maxWidth: .infinity) - .padding(.top, 40) + } else if events.isEmpty, timeline.error == nil { + emptyFeed } ForEach(days) { day in Section { @@ -1188,6 +1257,13 @@ struct TimelineMain: View { } } .frame(maxWidth: .infinity, maxHeight: .infinity) + // A failed read lands after the click or refresh that asked for it, so VoiceOver hears it. + .onChange(of: timeline.error) { _, failure in + if failure != nil { GenAnnounce.post(timeline.events.isEmpty ? Self.loadFailedText : Self.refreshFailedText) } + } + .onChange(of: timeline.olderError) { _, failure in + if failure != nil { GenAnnounce.post(Self.olderFailedText) } + } // Only the flag is state: a resize step on the same side of the threshold changes nothing. .onGeometryChange(for: Bool.self, of: { $0.size.width < Self.compactWidth }) { compact = $0 } // The find re-runs when the shown rows change, a row folds open or closed, or a detail lands. @@ -1201,6 +1277,57 @@ struct TimelineMain: View { return events.map(\.rowID).joined(separator: "\n") + "\u{1}" + open.joined(separator: "\n") + (compact ? "\u{2}" : "") } + static let loadFailedText = "The activity could not be read." + static let refreshFailedText = "The activity could not be refreshed. The list shows the last page read." + static let olderFailedText = "Older activity could not be read." + + private func loadFailure(_ failure: String) -> some View { + NoticePill( + text: timeline.events.isEmpty ? Self.loadFailedText : Self.refreshFailedText, + more: failure, tone: .error, + fixes: [.button("Retry", symbol: "arrow.clockwise") { timeline.load(fresh: true) }] + ) + .frame(maxWidth: .infinity, alignment: .leading) + .padding(.horizontal, 20) + .padding(.vertical, 10) + } + + /// Nothing to show: why, and the way out (H16). Filters that hid every row offer Clear filters; a range that + /// could be longer offers the next longer one. + private var emptyFeed: some View { + let filtered = !timeline.events.isEmpty || timeline.hasFilters || !model.filter.trimmed.isEmpty + let wider = timeline.range.wider + return VStack(spacing: 0) { + EmptyState( + symbol: "clock.arrow.circlepath", + text: filtered ? "Nothing matches these filters." : "Nothing happened in this range." + ) + HStack(spacing: 10) { + if filtered { + GhostButton( + "Clear filters", symbol: "line.3.horizontal.decrease.circle", + tooltip: "Show every kind, project and author, and empty the filter field", + identifier: "timeline.empty.clearFilters" + ) { + timeline.clearFilters() + model.filter = "" + } + } + if let wider { + GhostButton( + "Show \(wider.title.lowercased())", symbol: "calendar", + tooltip: "Read a longer range: \(wider.title.lowercased())", + identifier: "timeline.empty.widenRange" + ) { + timeline.range = wider + } + } + } + } + .frame(maxWidth: .infinity) + .padding(.top, 24) + } + private func dayHeader(_ day: TimelineDay) -> some View { let title = day.title() return HStack { @@ -1249,12 +1376,8 @@ struct TimelineMain: View { if timeline.loadingOlder { ProgressView().controlSize(.small) } else if let failure = timeline.olderError { - Label(failure, systemImage: "exclamationmark.triangle.fill") - .font(.system(size: 11)) - .foregroundColor(ReviewPalette.removed) - .lineLimit(2) - .truncationMode(.middle) - .textSelection(.enabled) + // "Load older" beside it is the retry; the CLI's own words wait behind Details, never cut. + NoticePill(text: Self.olderFailedText, more: failure, tone: .error) } if !timeline.truncated.isEmpty { Text("A page ends where its busiest source stopped reading: \(timeline.truncated.joined(separator: ", ")).") @@ -1293,7 +1416,7 @@ struct TimelineMain: View { .titlebarLabel() Spacer() if let notice = model.notice { - NoticePill(text: notice, isError: notice.contains("failed") || notice.hasPrefix("No ")) { model.notice = nil } + NoticePill(text: notice, tone: TimelineNotice.tone(of: notice) ?? TimelineNotice.otherPaneTone(notice)) { model.notice = nil } } if !timeline.warnings.isEmpty { Image(systemName: "exclamationmark.triangle.fill") @@ -1545,7 +1668,7 @@ struct TimelineRowView: View { if let url = model.timelineHostURL(event) { ExternalOpener.open(url) } else { - model.notice = "The repository's origin is not known yet; try again in a moment." + model.postTimelineNotice("The repository's origin is not known yet; try again in a moment.", .warning) } } label: { text @@ -1596,17 +1719,17 @@ struct TimelineRowView: View { case .resume(let session): LaunchPicker(mode: .resume(session)) { outcome in popover = nil - if let notice = outcome.notice { model.notice = notice } + model.postTimelineNotice(outcome) } case .newAgent(let cwd, let name, let prompt): LaunchPicker(mode: .new(cwd: cwd, name: name, prompt: prompt)) { outcome in popover = nil - if let notice = outcome.notice { model.notice = notice } + model.postTimelineNotice(outcome) } case .reply(let event): TimelineReplyForm(event: event, timeline: timeline) { notice in popover = nil - if let notice { model.notice = notice } + if let notice { model.postTimelineNotice(notice.text, notice.tone) } } } } @@ -1647,7 +1770,8 @@ private extension View { struct TimelineReplyForm: View { let event: TimelineEvent @ObservedObject var timeline: HubTimelineModel - let done: (String?) -> Void + /// The note the reply leaves, with its tone; nil for Cancel. + let done: ((text: String, tone: NoticePill.Tone)?) -> Void @State private var text = "" var body: some View { @@ -1670,11 +1794,11 @@ struct TimelineReplyForm: View { Button("Cancel") { done(nil) }.keyboardShortcut(.cancelAction) Button("Reply") { guard let url = event.pr?.url, let thread = event.threadId else { - done("This comment names no thread to reply in.") + done((text: "This comment names no thread to reply in.", tone: .warning)) return } timeline.threadStore(for: url).reply(thread: thread, body: text, draft: false) - done("Replying on \(event.pr?.ref ?? "the PR")…") + done((text: "Replying on \(event.pr?.ref ?? "the PR")…", tone: .success)) } .keyboardShortcut(.defaultAction) .disabled(text.trimmed.isEmpty) diff --git a/src/macos/GenesisTools/Sources/Parts/Hub/Hub/HubWindow.swift b/src/macos/GenesisTools/Sources/Parts/Hub/Hub/HubWindow.swift index faec75e931..a37f9d11cb 100644 --- a/src/macos/GenesisTools/Sources/Parts/Hub/Hub/HubWindow.swift +++ b/src/macos/GenesisTools/Sources/Parts/Hub/Hub/HubWindow.swift @@ -415,6 +415,25 @@ private final class HubAppDelegate: NSObject, NSApplicationDelegate { /// What the left column lists: agent sessions, the worktrees (branches) they worked in, PRs, the /// sessions waiting for an answer (Hub/HubInbox.swift), or today's activity (Hub/HubTimeline.swift). +/// A note in a hub pane's header and its tone, stored together by the code that knows whether it failed: the headers +/// used to guess a failure from words ("failed", "cmux:"), and a note without such a word showed a failure in green. +struct HubNotice: Equatable { + let text: String + let tone: NoticePill.Tone + + init(_ text: String, _ tone: NoticePill.Tone) { + self.text = text + self.tone = tone + } + + /// A note set as plain text (`model.notice = "…"`) by a pane not moved to `post(_:_:)` yet: the words the + /// headers read before, in one place. Goes away with the last `String` setter. + init(guessing text: String) { + let failed = text.hasPrefix("cmux:") || text.hasPrefix("Could not") || text.contains("failed") || text.contains("not in") + self.init(text, failed ? .error : .success) + } +} + enum HubMode: String, CaseIterable { case sessions, worktrees, prs, inbox, timeline, agents @@ -604,7 +623,17 @@ final class HubModel: ObservableObject { private var worktreeSessions: [String: [HubSession]]? @Published var loadingWorktrees = false @Published var selectedWorktree: String? - @Published var notice: String? + /// The note in the panes' headers, with the tone the code that posted it knows (`post(_:_:)`). + @Published var headerNotice: HubNotice? + /// The note as plain text, for panes that still set a `String`: it gets the tone the headers used to guess. + var notice: String? { + get { headerNotice?.text } + set { headerNotice = newValue.map(HubNotice.init(guessing:)) } + } + + func post(_ text: String, _ tone: NoticePill.Tone) { + headerNotice = HubNotice(text, tone) + } /// Only the latest commit lookup may navigate, and any later selection invalidates it. var commitLinkRequest = UUID() /// ⌘⇧F / palette "grep": the find-in-files panel is open with this query ("" = empty field). @@ -775,9 +804,9 @@ final class HubModel: ObservableObject { prs.select(pr) } else { prs.showUnavailable(id) - notice = prs.state == "all" + post(prs.state == "all" ? "That PR is not in the PR list any more." - : "That PR is not in the \(prs.state) list any more; pick All to find it." + : "That PR is not in the \(prs.state) list any more; pick All to find it.", .warning) } case .inbox: inbox.selectedID = id == Self.wholeList ? nil : id @@ -982,7 +1011,7 @@ final class HubModel: ObservableObject { func showChange(path: String, line: Int?) { tab = .changes guard let review else { - notice = "This session's folder is not on this Mac." + post("This session's folder is not on this Mac.", .warning) return } // An absolute path finds its file in whichever ticked root holds it. @@ -1151,7 +1180,7 @@ final class HubModel: ObservableObject { return } - notice = "No session \(wanted.prefix(8)) in the last \(Self.olderSessionHours / 24) days" + post("No session \(wanted.prefix(8)) in the last \(Self.olderSessionHours / 24) days", .warning) if settle { if let first = sessions.first { select(first.id) @@ -1390,13 +1419,17 @@ final class HubModel: ObservableObject { // openInTerminal blocks on the terminal host's CLI (up to its timeout): never on the main actor. let name = (path as NSString).lastPathComponent let starting = "Starting a new Claude session in \(name)…" - notice = starting + post(starting, .success) Task.detached(priority: .userInitiated) { let failure = AgentLauncher.openInTerminal(name: name, cwd: path, command: ["tools", "claude", "run"]) // Only over its own "Starting…" line: a newer notice (a copied id, say) stays on screen. await MainActor.run { - if self.notice == starting { - self.notice = failure ?? "Started a new Claude session in \(name)." + if self.headerNotice?.text == starting { + if let failure { + self.post(failure, .error) + } else { + self.post("Started a new Claude session in \(name).", .success) + } } } } @@ -1416,13 +1449,13 @@ final class HubModel: ObservableObject { if let pr = prs.selected { if (pr.localWorktree ?? pr.repoRoot) == nil { // No checkout to review in, so the detail shows no "Review with agent" to open. - notice = "\(pr.label) has no local checkout to review in." + post("\(pr.label) has no local checkout to review in.", .warning) } else { setMode(.prs) prs.reviewRequested = true } } else { - notice = "Select a PR first, then run review again." + post("Select a PR first, then run review again.", .warning) } case .toggleGlass: toggleGlass() @@ -1471,7 +1504,7 @@ final class HubModel: ObservableObject { // A fresh launch applies its flags before the first list loads. HubHandoffRequests.shared.pending = HubHandoffRequests.firstSelection } else { - notice = "No session to hand off: select one, or pass --session." + post("No session to hand off: select one, or pass --session.", .warning) } } } @@ -2102,9 +2135,34 @@ private struct SessionListView: View { @ObservedObject private var stuck = HubStuckStore.shared /// Whether the agent of an open, followed transcript works or waits (Hub/HubSessionDetail.swift); the same. @ObservedObject private var live = HubLiveActivityStore.shared + /// The keyboard focus of the list on screen: a click on a row or Tab gives it, then Up and Down move the + /// selection (`SidebarArrowKeys`). One state for every mode, since one list shows at a time. + @FocusState private var listFocused: Bool + /// The Inbox's order, the one its list shows (Hub/HubInbox.swift `InboxListView`), for the arrow keys. + @AppStorage("hub.inbox.sort") private var inboxSort = InboxSort.recent.rawValue + + /// The Activity filters' "All projects" row in the arrow keys' order; no project has this name. + private static let allProjects = "\u{0}all" private var mode: SessionGrouping { SessionGrouping(rawValue: grouping) ?? .time } + /// The sessions the arrow keys step through, as the list shows them: the agent processes entry, then every + /// row of an open group. + private static func sessionKeyOrder(_ sections: [(title: String, rows: [HubSession], managed: Bool)], collapsed: Set<String>) -> [String] { + [AgentProcs.selectionID] + sections + .filter { !($0.managed && collapsed.contains($0.title)) } + .flatMap { $0.rows.map(\.id) } + } + + /// The Inbox rows the arrow keys step through, in InboxListView's order: the waiting sessions, then the quiet + /// ones while one of them is selected (their fold is open then). + private static func inboxKeyOrder(_ inbox: HubInboxModel, sort: String, filter: String) -> [String] { + let rows = inbox.sorted(InboxSort(rawValue: sort) ?? .recent, filter: filter) + let fresh = rows.filter { !$0.isStale }.map(\.id) + let quiet = rows.filter(\.isStale).map(\.id) + return quiet.contains(inbox.selectedID ?? "") ? fresh + quiet : fresh + } + private var filterPlaceholder: String { switch model.mode { case .sessions: return "Filter sessions, projects, accounts…" @@ -2202,9 +2260,27 @@ private struct SessionListView: View { } else if model.mode == .prs { PRListView(model: model, prs: model.prs) } else if model.mode == .inbox { - InboxListView(model: model, inbox: model.inbox) + // The rows are Hub/HubInbox.swift's; the keys step through the same order. Read at the press: this + // view does not observe the Inbox. + let sort = inboxSort + ScrollViewReader { proxy in + InboxListView(model: model, inbox: model.inbox) + .modifier(SidebarArrowKeys( + ids: { Self.inboxKeyOrder(model.inbox, sort: sort, filter: model.filter) }, + selection: { model.inbox.selectedID }, + select: { model.inbox.selectedID = $0 }, + proxy: proxy, focused: $listFocused, focusOnClick: true)) + } } else if model.mode == .timeline { - TimelineListView(model: model, timeline: model.timeline) + // Up and Down pick the project above or below in the Projects filter (All, then each project). + ScrollViewReader { proxy in + TimelineListView(model: model, timeline: model.timeline) + .modifier(SidebarArrowKeys( + ids: { [Self.allProjects] + model.timeline.projects.map(\.name) }, + selection: { model.timeline.project ?? Self.allProjects }, + select: { model.timeline.project = $0 == Self.allProjects ? nil : $0 }, + proxy: proxy, focused: $listFocused, focusOnClick: true)) + } } else if model.mode == .agents { // Staging: the widget and Clicky entries exist only in the Preview app (main.swift). if NativeStaging.facesEnabled { @@ -2230,43 +2306,69 @@ private struct SessionListView: View { } AgentsListView(model: model, agents: model.agents) } else { - ScrollView { - LazyVStack(alignment: .leading, spacing: 2, pinnedViews: [.sectionHeaders]) { - // Hub/HubAgentProcs.swift: every agent session's process tree, orphans first. - AgentProcsEntry(model: model) - if model.sessions.isEmpty, model.loadingSessions { - SkeletonRows(count: 10, leading: .avatar) - .skeletonShimmer() - .accessibilityElement(children: .ignore) - .accessibilityLabel("Loading sessions") - } - ForEach(sections, id: \.title) { section in - Section { - if !(section.managed && prefs.collapsed.contains(section.title)) { - ForEach(section.rows) { session in - SessionRowView(session: session, selected: session.id == model.selectedID, stuck: stuck.verdicts[session.sessionId], work: live.work[session.sessionId]) - .rowButton { model.select(session.id) } - .swrFlash(model.changedSessions.contains(session.id), cornerRadius: 6) - .transition(SWR.rowTransition) + ScrollViewReader { proxy in + ScrollView { + LazyVStack(alignment: .leading, spacing: 2, pinnedViews: [.sectionHeaders]) { + // Hub/HubAgentProcs.swift: every agent session's process tree, orphans first. + AgentProcsEntry(model: model) + .id(AgentProcs.selectionID) + if model.sessions.isEmpty, model.loadingSessions { + SkeletonRows(count: 10, leading: .avatar) + .skeletonShimmer() + .accessibilityElement(children: .ignore) + .accessibilityLabel("Loading sessions") + } + ForEach(sections, id: \.title) { section in + Section { + if !(section.managed && prefs.collapsed.contains(section.title)) { + ForEach(section.rows) { session in + let activity = SessionRowActivity(work: live.work[session.sessionId], isLive: session.isLive) + let selected = session.id == model.selectedID + SessionRowView(session: session, selected: selected, stuck: stuck.verdicts[session.sessionId], activity: activity) + .rowButton { + listFocused = true + model.select(session.id) + } + // The dot's state in words for VoiceOver; the label is the row's text. + .accessibilityValue(Text(activity?.word ?? "")) + .accessibilityAddTraits(selected ? .isSelected : []) + .id(session.id) + .swrFlash(model.changedSessions.contains(session.id), cornerRadius: 6) + .transition(SWR.rowTransition) + } + } + } header: { + if section.managed { + GroupHeader( + title: section.title, + count: section.rows.count, + prefs: prefs, + allNames: sections.map(\.title), + path: mode == .harness ? nil : section.rows.first.map { projectRoot(of: $0.cwd) }.flatMap { $0.isEmpty ? nil : $0 } + ) + } else { + PlainGroupHeader(title: section.title, count: section.rows.count) } } - } header: { - if section.managed { - GroupHeader( - title: section.title, - count: section.rows.count, - prefs: prefs, - allNames: sections.map(\.title), - path: mode == .harness ? nil : section.rows.first.map { projectRoot(of: $0.cwd) }.flatMap { $0.isEmpty ? nil : $0 } - ) - } else { - PlainGroupHeader(title: section.title, count: section.rows.count) - } } + // The filter hid every session: say so, with the way back. Older matches may still show below. + if sections.isEmpty, !model.sessions.isEmpty, !model.filter.trimmed.isEmpty { + EmptyState( + symbol: "line.3.horizontal.decrease.circle", + text: "No session matches “\(model.filter.trimmed)”", + actionTitle: "Clear filter" + ) { model.filter = "" } + .padding(.top, 8) + } + HubHistorySection(model: model, history: history, shown: Set(sections.flatMap(\.rows).map(\.sessionId))) } - HubHistorySection(model: model, history: history, shown: Set(sections.flatMap(\.rows).map(\.sessionId))) + .padding(.bottom, 12) } - .padding(.bottom, 12) + .modifier(SidebarArrowKeys( + ids: { [collapsed = prefs.collapsed] in Self.sessionKeyOrder(sections, collapsed: collapsed) }, + selection: { model.selectedID }, + select: { model.select($0) }, + proxy: proxy, focused: $listFocused)) } // Polls `tools hub stuck` (every 2 min) for the live sessions while this list shows; a new live set restarts it. .task(id: HubStuck.watched(model.sessions)) { await stuck.watch(HubStuck.watched(model.sessions)) } @@ -2285,29 +2387,96 @@ private struct SessionListView: View { } } +/// Up and Down move a sidebar list's selection while the list has the keyboard focus; a click on a row or Tab +/// gives it. A text field with the focus keeps its own arrows, since the keys reach this view only through its +/// focus. One step per press, no key repeat: each selection loads its pane, and a held key would start a load per +/// repeat. The rows stay where they are unless the new one is out of sight (no anchor, no animation). +struct SidebarArrowKeys: ViewModifier { + /// The selectable rows in the order the list shows them, folded groups left out. Read at the press. + let ids: () -> [String] + /// Read at the press too: a list built in another file changes its selection without this view knowing. + let selection: () -> String? + let select: (String) -> Void + var proxy: ScrollViewProxy? + let focused: FocusState<Bool>.Binding + /// A click anywhere in the list gives it the focus as well, for a list whose rows are built in another file and + /// cannot set it themselves. The rows' own clicks still run. + var focusOnClick = false + + func body(content: Content) -> some View { + content + .focusable() + // The selected row is the focused look, as in the Agents list. + .focusEffectDisabled() + .focused(focused) + .simultaneousGesture(TapGesture().onEnded { focused.wrappedValue = true }, including: focusOnClick ? .all : .subviews) + .onKeyPress(.upArrow, phases: .down) { _ in move(-1) } + .onKeyPress(.downArrow, phases: .down) { _ in move(1) } + } + + private func move(_ step: Int) -> KeyPress.Result { + let ids = ids() + guard let first = ids.first, let last = ids.last else { return .ignored } + let next: String + if let current = selection(), let index = ids.firstIndex(of: current) { + guard ids.indices.contains(index + step) else { return .handled } + next = ids[index + step] + } else { + next = step > 0 ? first : last + } + select(next) + proxy?.scrollTo(next) + return .handled + } +} + +/// What a session row's dot means, in a word too: the dot alone said it in colour only (working and live are both +/// green, waiting is orange). +enum SessionRowActivity: Equatable { + case working, waiting, live + + /// The open transcript's live follow outranks the list's own live rule (a warm cache, a recent write). + init?(work: HubLiveActivityStore.Work?, isLive: Bool) { + switch work { + case .working: self = .working + case .waiting: self = .waiting + case nil: + guard isLive else { return nil } + self = .live + } + } + + var word: String { + switch self { + case .working: return "working" + case .waiting: return "waiting" + case .live: return "live" + } + } + + /// The same colours the open transcript's last row and header use. + var color: Color { + self == .waiting ? ReviewPalette.modified : ReviewPalette.added + } + + /// Working and waiting print their word on the row: only the open, followed session has them, so few rows carry + /// it. Live is every warm session; its dot stays the sign and VoiceOver reads the word. + var showsWord: Bool { self != .live } +} + /// The provider's letter on its colour (session rows, the Inbox). private struct SessionRowView: View { let session: HubSession let selected: Bool var stuck: StuckVerdict? - /// What the open transcript's live follow says, for the session it follows; nil for every other row. - var work: HubLiveActivityStore.Work? - - /// Green while the agent works or the session is live (a warm cache, a recent write), orange while it waits - /// for the user: the same activity the open transcript's last row and header show. - private var dotColor: Color? { - switch work { - case .working: return ReviewPalette.added - case .waiting: return ReviewPalette.modified - case nil: return session.isLive ? ReviewPalette.added : nil - } - } + /// The dot: working or waiting from the open transcript's follow, else live from the list; nil for a quiet row. + var activity: SessionRowActivity? var body: some View { HStack(alignment: .top, spacing: 9) { ZStack(alignment: .bottomTrailing) { ProviderBadge(provider: session.provider) - if let dotColor { + if let dotColor = activity?.color { Circle() .fill(dotColor) .frame(width: 7, height: 7) @@ -2339,6 +2508,13 @@ private struct SessionRowView: View { .layoutPriority(-1) } Spacer(minLength: 0) + if let activity, activity.showsWord { + // Whole, like the age; VoiceOver reads it from the row's value instead. + Text(activity.word) + .foregroundColor(activity.color) + .fixedSize() + .accessibilityHidden(true) + } // Whole: the project and account truncate first, the age read "21 sec. a…" before. LiveAgo(date: session.lastActivity, style: .brief).fixedSize() } @@ -2372,6 +2548,12 @@ struct SessionDetailView: View { @State private var fitting: Int? /// A pane that did not fit, shown as a drawer over the others from its rail. @State private var drawer: HubTab? + @GenReduceMotion private var reduceMotion + + /// Opens or closes the drawer on the edge panels' curve; at once under Reduce Motion. + private func setDrawer(_ tab: HubTab?) { + GenMotion.perform(expanding: tab != nil, reduceMotion: reduceMotion) { drawer = tab } + } /// The open panes, in order, that fit into `width` at their minimums; the rest become rails. /// The first pane always shows, however narrow the window. @@ -2418,7 +2600,7 @@ struct SessionDetailView: View { ForEach(railed, id: \.self) { tab in Rectangle().fill(ReviewPalette.hairline).frame(width: 1) PaneRail(tab: tab, open: drawer == tab) { - withAnimation(.snappy(duration: 0.25)) { drawer = drawer == tab ? nil : tab } + setDrawer(drawer == tab ? nil : tab) } } } @@ -2431,8 +2613,9 @@ struct SessionDetailView: View { .overlay(alignment: .leading) { Rectangle().fill(ReviewPalette.hairline).frame(width: 1) } .shadow(color: .black.opacity(0.45), radius: 18, x: -6) .padding(.trailing, CGFloat(railed.count) * (PaneRail.width + 1)) - .transition(.move(edge: .trailing).combined(with: .opacity)) - .onExitCommand { withAnimation(.snappy(duration: 0.2)) { drawer = nil } } + // Slides in from its rail; under Reduce Motion it appears in place. + .transition(reduceMotion ? .opacity : .move(edge: .trailing).combined(with: .opacity)) + .onExitCommand { setDrawer(nil) } } } } @@ -2530,8 +2713,8 @@ struct SessionDetailView: View { HStack(spacing: 8) { AgentChildTitle(agents: agent.agents, parent: agent.parent, node: node) Spacer() - if let notice = model.notice { - NoticePill(text: notice, isError: notice.hasPrefix("cmux:") || notice.contains("failed") || notice.contains("not in")) { model.notice = nil } + if let notice = model.headerNotice { + NoticePill(text: notice.text, tone: notice.tone) { model.headerNotice = nil } } paneToggles AgentCopyIdButton(model: model, node: node) @@ -2575,12 +2758,13 @@ struct SessionDetailView: View { HubForecastChip(account: session.account) .layoutPriority(-1) } - if let notice = model.notice { - NoticePill(text: notice, isError: notice.hasPrefix("cmux:") || notice.contains("failed")) { model.notice = nil } + if let notice = model.headerNotice { + NoticePill(text: notice.text, tone: notice.tone) { model.headerNotice = nil } } paneToggles IconButton(systemName: "doc.richtext", tooltip: "Copy as Markdown (json2md): session, changes, decisions, recent turns") { - Task { @MainActor in model.notice = await model.exportSession(session) } + // The export answers in words only (HubMarkdownExport, Hub/HubWorktrees.swift): its tone is guessed. + Task { @MainActor in model.headerNotice = HubNotice(guessing: await model.exportSession(session)) } } } } @@ -2611,7 +2795,7 @@ struct SessionDetailView: View { } Button { PathOpener.copy(session.sessionId) - model.notice = "Session id copied" + model.post("Session id copied", .success) } label: { chip("number", String(session.sessionId.prefix(8))) } @@ -2654,6 +2838,7 @@ private struct PaneToggle: View { @ObservedObject var model: HubModel let badge: PaneBadge @State private var targeted = false + @GenReduceMotion private var reduceMotion var body: some View { dragAndDrop(button) @@ -2703,8 +2888,9 @@ private struct PaneToggle: View { .strokeBorder(ReviewPalette.renamed, lineWidth: 1.5) .opacity(targeted ? 1 : 0) ) - .scaleEffect(targeted ? 1.06 : 1) - .animation(.snappy(duration: 0.18), value: targeted) + // Under Reduce Motion the drop target keeps its size and its outline appears at once. + .scaleEffect(targeted && !reduceMotion ? 1.06 : 1) + .animation(reduceMotion ? nil : .snappy(duration: 0.18), value: targeted) } .buttonStyle(.genHoverPlain()) .instantTooltip(open @@ -2723,8 +2909,15 @@ private struct PaneToggle: View { } .dropDestination(for: String.self) { items, _ in guard let dragged = items.first, let from = HubTab(rawValue: dragged) else { return false } - withAnimation(.spring(response: 0.32, dampingFraction: 0.78)) { - model.swapTabs(from, tab) + // A drop release may settle with a spring; under Reduce Motion the two swap at once. + if reduceMotion { + var instant = Transaction() + instant.disablesAnimations = true + withTransaction(instant) { model.swapTabs(from, tab) } + } else { + withAnimation(.spring(response: 0.32, dampingFraction: 0.78)) { + model.swapTabs(from, tab) + } } return true } isTargeted: { inside in diff --git a/src/macos/GenesisTools/Sources/Parts/Hub/Review/StoredProposals.swift b/src/macos/GenesisTools/Sources/Parts/Hub/Review/StoredProposals.swift index 61f99e45d9..3815ed9dd4 100644 --- a/src/macos/GenesisTools/Sources/Parts/Hub/Review/StoredProposals.swift +++ b/src/macos/GenesisTools/Sources/Parts/Hub/Review/StoredProposals.swift @@ -122,10 +122,13 @@ struct StoredProposalsList: View { .foregroundColor(ReviewPalette.dim) case .found(let identity, let choices): if choices.isEmpty { - Text("No agent review stored for \(identity.label). An agent adds one with tools hub proposal push; Reload looks again.") + Text("No agent review is stored for \(identity.label) yet.") .font(.system(size: 11)) .foregroundColor(ReviewPalette.dim) .fixedSize(horizontal: false, vertical: true) + GhostButton("Look again", symbol: "arrow.clockwise", tooltip: "Look for a stored review of this PR again") { + model.lookupStoredProposals() + } } else { ForEach(choices) { choice in StoredProposalRow(choice: choice) diff --git a/src/macos/GenesisTools/Sources/Parts/Hub/Settings/PRActivitySettings.swift b/src/macos/GenesisTools/Sources/Parts/Hub/Settings/PRActivitySettings.swift index a3db85b9c9..b3bcc7b33a 100644 --- a/src/macos/GenesisTools/Sources/Parts/Hub/Settings/PRActivitySettings.swift +++ b/src/macos/GenesisTools/Sources/Parts/Hub/Settings/PRActivitySettings.swift @@ -25,13 +25,6 @@ enum PRActivitySettingsPages { } } -/// The cadences a project can pick, in minutes (the CLI takes any whole minute from 1 to 60). -private let cadenceChoices = [1, 2, 3, 5, 10, 15, 20, 30, 45, 60] - -private func cadenceTitle(_ minutes: Int) -> String { - minutes == 1 ? "Every minute" : "Every \(minutes) minutes" -} - @MainActor struct PRActivitySettingsView: View { @ObservedObject var store: HubNotifyStore @@ -40,7 +33,7 @@ struct PRActivitySettingsView: View { VStack(alignment: .leading, spacing: 18) { if let status = store.status { content(status) - } else { + } else if store.busy || store.message == nil { NativeSettingsCard { HStack(spacing: 8) { ProgressView().controlSize(.small) @@ -48,11 +41,7 @@ struct PRActivitySettingsView: View { } } } - if let message = store.message { - NoticePill(text: message, isError: message.contains("exited") || message.contains("failed")) { - store.message = nil - } - } + HubNotifyNoticeView(store: store) } .onAppear { store.load() } } @@ -63,24 +52,28 @@ struct PRActivitySettingsView: View { NativeSettingsCard { NativeSettingsToggle( "PR notifications", - detail: "When this is off, no project is polled and nothing posts.", + detail: HubNotifyWords.enabledDetail, identifier: "prActivity.enabled", isOn: Binding(get: { config.enabled }, set: { store.set(["--enabled", $0 ? "on" : "off"]) })) Divider() - NativeSettingsRow(tickTitle(status), detail: pollLine(status)) { + NativeSettingsRow(status.daemonTitle, detail: pollLine(status)) { HStack(spacing: 8) { if store.busy { ProgressView().controlSize(.small) } - if status.daemonTask != true || status.daemonTaskEvery != status.daemonTaskWanted { - Button(status.daemonTask == true ? "Tick every minute" : "Add the daemon task") { + if let fix = status.daemonFixTitle { + // The tooltip says what happens; the command is one right-click away, for a terminal (O7). + Button(fix) { store.installDaemonTask() } .buttonStyle(.bordered).nativeSettingsPointer() - .instantTooltip("tools hub notify install: one daemon task that ticks every minute") + .instantTooltip(HubNotifyWords.installDetail) + .contextMenu { + Button("Copy the terminal command") { Clipboard.copy(HubNotifyWords.installCommand, what: "command") } + } .accessibilityIdentifier("prActivity.install") } Button("Check now") { store.pollNow() } .buttonStyle(.bordered).nativeSettingsPointer() - .instantTooltip("Poll every watched project now, ignoring cadences and backoff") + .instantTooltip(HubNotifyWords.pollNowDetail) .accessibilityIdentifier("prActivity.pollNow") } } @@ -108,12 +101,14 @@ struct PRActivitySettingsView: View { } NativeSettingsCard("Defaults", subtitle: "A project follows these until you change its own cadence or events.") { - NativeSettingsRow("Cadence", detail: "How often a project without its own cadence is polled.") { + NativeSettingsRow("Cadence", detail: HubNotifyWords.cadenceDetail) { Picker("Cadence", selection: Binding( get: { config.intervalMinutes }, set: { store.set(["--interval", String($0)]) } )) { - ForEach(cadenceOptions(including: config.intervalMinutes), id: \.self) { Text(cadenceTitle($0)).tag($0) } + ForEach(HubNotifyWords.cadenceOptions(including: config.intervalMinutes), id: \.self) { + Text(HubNotifyWords.cadenceTitle($0)).tag($0) + } } .pickerStyle(.menu).labelsHidden().frame(width: 170) .accessibilityIdentifier("prActivity.defaultCadence") @@ -121,14 +116,14 @@ struct PRActivitySettingsView: View { Divider() ForEach(HubNotifyEvent.allCases) { event in NativeSettingsToggle( - event.title, detail: eventDetail(event), identifier: "prActivity.event.\(event.rawValue)", + event.title, detail: event.detail, identifier: "prActivity.event.\(event.rawValue)", isOn: Binding( get: { config.events[event.rawValue] ?? false }, set: { store.set(["--event", "\(event.rawValue)=\($0 ? "on" : "off")"]) })) } Divider() NativeSettingsToggle( - "Only my PRs", detail: "Changes on PRs someone else opened stay silent.", + "Only my PRs", detail: HubNotifyWords.onlyMineDetail, identifier: "prActivity.onlyMine", isOn: Binding(get: { config.onlyMine }, set: { store.set(["--only-mine", $0 ? "on" : "off"]) })) } @@ -139,7 +134,7 @@ struct PRActivitySettingsView: View { .instantTooltip("Post one notification marked as a test; its click opens the hub's PRs") Button("Config file") { PathOpener.cursor(status.configPath) } .buttonStyle(.genHoverPlain()).font(.system(size: 12)) - .instantTooltip(status.configPath) + .instantTooltip("Open \((status.configPath as NSString).abbreviatingWithTildeInPath) in Cursor") Spacer(minLength: 0) } } @@ -148,34 +143,12 @@ struct PRActivitySettingsView: View { URL(fileURLWithPath: path).lastPathComponent } - private func tickTitle(_ status: HubNotifyStatus) -> String { - switch status.daemonTask { - case true?: - return status.daemonTaskEvery == status.daemonTaskWanted - ? "The daemon checks every minute" - : "The daemon task still runs \(status.daemonTaskEvery ?? "at an old cadence")" - case false?: return "Nothing polls yet: the daemon task is missing" - case nil: return "The daemon's task list is unreadable" - } - } - private func pollLine(_ status: HubNotifyStatus) -> String { let last = HubFormat.date(status.lastPollAt).map { "Last poll \($0.formatted(date: .omitted, time: .shortened))" } ?? "Never polled" return "\(last) · \(status.pollsLastHour) polls and \(status.requestsLastHour) host requests in the last hour" } - private func eventDetail(_ event: HubNotifyEvent) -> String { - switch event { - case .thread: return "Someone else opens a review thread or comment thread on a PR." - case .reviewRequested: return "You are added as a reviewer." - case .ciFailed: return "The checks of a PR's newest commit fail." - case .ciPassed: return "The checks of a PR's newest commit pass." - case .botReview: return "A review bot posts a review." - case .merged: return "A watched open PR is merged." - } - } - private func addProject() { let panel = NSOpenPanel() panel.canChooseDirectories = true @@ -188,11 +161,6 @@ struct PRActivitySettingsView: View { } } -/// `cadenceChoices`, plus the stored value when the config holds another one. -private func cadenceOptions(including value: Int) -> [Int] { - cadenceChoices.contains(value) ? cadenceChoices : (cadenceChoices + [value]).sorted() -} - @MainActor private struct PRActivityProjectRow: View { let path: String @@ -234,16 +202,16 @@ private struct PRActivityProjectRow: View { private var cadenceMenu: some View { let own = repo?.intervalMinutes return MenuButton(items: { - [MenuButtonItem.action("Default (\(cadenceTitle(config.intervalMinutes).lowercased()))", checked: own == nil) { + [MenuButtonItem.action("Default (\(HubNotifyWords.cadenceTitle(config.intervalMinutes).lowercased()))", checked: own == nil) { store.set(["--repo", path, "--reset-repo-interval"]) }, .divider] - + cadenceChoices.map { minutes in - .action(cadenceTitle(minutes), checked: own == minutes) { + + HubNotifyWords.cadenceChoices.map { minutes in + .action(HubNotifyWords.cadenceTitle(minutes), checked: own == minutes) { store.set(["--repo", path, "--interval", String(minutes)]) } } }) { - menuLabel(own.map(cadenceTitle) ?? "\(cadenceTitle(config.intervalMinutes)) (default)") + menuLabel(own.map(HubNotifyWords.cadenceTitle) ?? "\(HubNotifyWords.cadenceTitle(config.intervalMinutes)) (default)") } .fixedSize() .instantTooltip("How often this project is polled") @@ -252,32 +220,15 @@ private struct PRActivityProjectRow: View { private var eventsMenu: some View { MenuButton(items: { - HubNotifyEvent.allCases.map { event -> MenuButtonItem in - let own = repo?.events?[event.rawValue] - let effective = own ?? config.events[event.rawValue] ?? false - return .action("\(event.title)\(own == nil ? "" : " (this project)")", checked: effective) { - store.set(["--repo", path, "--event", "\(event.rawValue)=\(effective ? "off" : "on")"]) - } - } + [ - .divider, - .action("Follow the defaults", enabled: repo?.events != nil) { - store.set(["--repo", path, "--reset-repo-events"]) - }, - ] + HubNotifyWords.eventItems(path: path, repo: repo, config: config, store: store) }) { - menuLabel(eventsSummary) + menuLabel(HubNotifyWords.eventsSummary(repo: repo, config: config)) } .fixedSize() - .instantTooltip("Which events this project posts; the defaults apply until you change one here") + .instantTooltip(HubNotifyWords.eventsDetail) .accessibilityIdentifier("prActivity.repo.\(name).events") } - private var eventsSummary: String { - let on = HubNotifyEvent.allCases.filter { repo?.events?[$0.rawValue] ?? config.events[$0.rawValue] ?? false } - let label = on.count == HubNotifyEvent.allCases.count ? "All events" : Plural.count(on.count, "event") - return repo?.events == nil ? "\(label) (default)" : label - } - private func menuLabel(_ text: String) -> some View { HStack(spacing: 4) { Text(text).font(.system(size: 12)) @@ -290,11 +241,8 @@ private struct PRActivityProjectRow: View { @ViewBuilder private var stateLine: some View { if let state, let error = state.lastError { - Label( - "\(Plural.count(state.failures, "failed poll")) · next try \(HubFormat.date(state.nextAt)?.formatted(date: .omitted, time: .shortened) ?? "soon") · \(error)", - systemImage: "exclamationmark.circle" - ) - .font(.system(size: 11)).foregroundStyle(.orange).lineLimit(2) + // One sentence; the host's own error behind Details, whole (it was cut at two lines, H21). + NoticePill(text: state.problemText, more: error, tone: .warning) } else if let state, let ok = HubFormat.date(state.lastOkAt) { LiveAgo(date: ok, style: .ago) { "Polled \($0)" } .font(.system(size: 11)).foregroundStyle(.secondary) diff --git a/src/macos/GenesisTools/Sources/Parts/Widget/WidgetWindow.swift b/src/macos/GenesisTools/Sources/Parts/Widget/WidgetWindow.swift index d63e9c71b1..474519b774 100644 --- a/src/macos/GenesisTools/Sources/Parts/Widget/WidgetWindow.swift +++ b/src/macos/GenesisTools/Sources/Parts/Widget/WidgetWindow.swift @@ -51,6 +51,8 @@ private final class AgentWidgetDelegate: NSObject, NSApplicationDelegate { WidgetLaunch.start(args) }) coordinator?.settingsPresenter = { FeatureSettingsLaunch.open(pageID: $0) } + // The widget face is the one process that owns the global "open the widget" shortcut. + coordinator?.registersOpenShortcut = true coordinator?.model.openDestination = { session, mode, file in var args = [ "--hub", "--mode", "sessions", "--session", session.target.sessionId, diff --git a/src/macos/GenesisTools/Tests/DaemonSettingsTests.swift b/src/macos/GenesisTools/Tests/DaemonSettingsTests.swift index 1793b7277d..dcf2161766 100644 --- a/src/macos/GenesisTools/Tests/DaemonSettingsTests.swift +++ b/src/macos/GenesisTools/Tests/DaemonSettingsTests.swift @@ -65,7 +65,8 @@ final class DaemonSettingsTests: XCTestCase { let plugin = HubSettingsPlugin(notify: store) XCTAssertTrue(plugin.settingsReady(page: "general")) XCTAssertFalse(plugin.settingsReady(page: PRActivitySettingsPages.pageID), "no notify status yet") - store.message = "tools hub notify status exited 1" + store.message = HubNotifyNotice(text: "The notification settings could not be read.", tone: .error, + more: "tools hub notify status exited 1") XCTAssertTrue(plugin.settingsReady(page: PRActivitySettingsPages.pageID), "an error the page shows ends the wait") } diff --git a/src/macos/GenesisTools/Tests/HubNotifyTests.swift b/src/macos/GenesisTools/Tests/HubNotifyTests.swift index b99881c5b8..238d958e1a 100644 --- a/src/macos/GenesisTools/Tests/HubNotifyTests.swift +++ b/src/macos/GenesisTools/Tests/HubNotifyTests.swift @@ -68,11 +68,49 @@ final class HubNotifyTests: XCTestCase { let notDue = """ {"at":"2026-09-24T21:43:00.326Z","skipped":"not due: the last poll ran at 2026-09-24T21:42:59.184Z, every 3 min","dryRun":false,"repos":[],"items":[],"posted":0,"requests":0,"elapsedMs":2} """ - XCTAssertEqual(HubNotifyStore.pollSummary(Data(polled.utf8)), "Polled: 0 events, 0 posted, 1 requests") - XCTAssertEqual( - HubNotifyStore.pollSummary(Data(notDue.utf8)), - "Poll skipped: not due: the last poll ran at 2026-09-24T21:42:59.184Z, every 3 min" - ) + let done = HubNotifyStore.pollSummary(Data(polled.utf8)) + XCTAssertEqual(done.text, "Polled: 0 events, 0 posted, 1 request") + XCTAssertEqual(done.tone, .success) + XCTAssertNil(done.more) + // A skip is one sentence with its tone; the CLI's own reason waits behind Details (H5, H6). + let skipped = HubNotifyStore.pollSummary(Data(notDue.utf8)) + XCTAssertEqual(skipped.text, "The check did not run.") + XCTAssertEqual(skipped.tone, .warning) + XCTAssertEqual(skipped.more, "not due: the last poll ran at 2026-09-24T21:42:59.184Z, every 3 min") + let unreadable = HubNotifyStore.pollSummary(Data("not json".utf8)) + XCTAssertEqual(unreadable.tone, .warning) + XCTAssertEqual(unreadable.more, "not json") + } + + /// The bell and the PR activity page name the daemon task's state and its fix in the same words (H8, H17). + func testTheDaemonTaskStateAndItsFixReadTheSameEverywhere() throws { + let missing = try HubNotifyStatus.decode(Data(firstRunJSON.utf8)) + XCTAssertEqual(missing.daemonTitle, "Nothing polls yet: the daemon task is missing") + XCTAssertEqual(missing.daemonFixTitle, "Add the daemon task") + let ticking = try HubNotifyStatus.decode(Data(polledJSON.utf8)) + XCTAssertEqual(ticking.daemonTitle, "The daemon checks every minute") + XCTAssertNil(ticking.daemonFixTitle) + let stale = try HubNotifyStatus.decode(Data(firstRunJSON + .replacingOccurrences(of: "\"daemonTask\":false", with: "\"daemonTask\":true") + .replacingOccurrences(of: "\"daemonTaskEvery\":null", with: "\"daemonTaskEvery\":\"every 5 minutes\"").utf8)) + XCTAssertEqual(stale.daemonTitle, "The daemon task still runs every 5 minutes") + XCTAssertEqual(stale.daemonFixTitle, "Tick every minute") + } + + /// One cadence list for the bell and the page; a stored value outside it stays pickable. + func testTheCadenceChoicesKeepAStoredValueOutsideTheList() { + XCTAssertEqual(HubNotifyWords.cadenceOptions(including: 3), HubNotifyWords.cadenceChoices) + XCTAssertEqual(HubNotifyWords.cadenceOptions(including: 7), [1, 2, 3, 5, 7, 10, 15, 20, 30, 45, 60]) + XCTAssertEqual(HubNotifyWords.cadenceTitle(1), "Every minute") + XCTAssertEqual(HubNotifyWords.cadenceTitle(15), "Every 15 minutes") + } + + /// A failing project reads as one sentence; the host's error is not in it. + func testAFailingProjectIsOneSentenceWithoutTheRawError() throws { + let status = try HubNotifyStatus.decode(Data(polledJSON.utf8)) + let failing = try XCTUnwrap(status.repos.values.first { $0.path == "/tmp/gt/shop" }) + XCTAssertTrue(failing.problemText.hasPrefix("2 polls failed. The next try is at "), failing.problemText) + XCTAssertFalse(failing.problemText.contains("no such host")) } /// The argv a banner click hands the hub (`openHubCommand` in notify-poll.ts, pinned by notify.test.ts). diff --git a/src/macos/GenesisTools/Tests/HubTimelineTests.swift b/src/macos/GenesisTools/Tests/HubTimelineTests.swift index 6c9542ee92..95772ee852 100644 --- a/src/macos/GenesisTools/Tests/HubTimelineTests.swift +++ b/src/macos/GenesisTools/Tests/HubTimelineTests.swift @@ -55,6 +55,24 @@ final class HubTimelineTests: XCTestCase { XCTAssertEqual(TimelineRange.allCases.prefix(3), [.lastHour, .last24h, .today]) } + /// An empty feed's "Show …" button reads the next range that covers more; the longest and a custom one have none. + func testAWiderRangeCoversMoreAndTheLongestHasNone() { + let now = date("2026-03-02T14:00:00Z") + var calendar = Calendar(identifier: .gregorian) + calendar.timeZone = TimeZone(identifier: "UTC") ?? .gmt + for range in TimelineRange.allCases { + guard let wider = range.wider else { continue } + let narrow = range.interval(now: now, lastVisit: nil, customFrom: now, customTo: now, calendar: calendar) + let wide = wider.interval(now: now, lastVisit: nil, customFrom: now, customTo: now, calendar: calendar) + XCTAssertLessThanOrEqual(wide.start, narrow.start, "\(range) widens to \(wider)") + XCTAssertGreaterThan(wide.duration, narrow.duration, "\(range) widens to \(wider)") + } + XCTAssertEqual(TimelineRange.lastHour.wider, .last24h) + XCTAssertEqual(TimelineRange.last7.wider, .last30) + XCTAssertNil(TimelineRange.last30.wider) + XCTAssertNil(TimelineRange.custom.wider) + } + func testSinceLastVisitFallsBackToTheLast24HoursWithoutAnEarlierVisit() { let now = date("2026-03-02T14:00:00Z") let visited = date("2026-02-27T09:30:00Z") diff --git a/src/macos/GenesisTools/Tests/PermissionsModelTests.swift b/src/macos/GenesisTools/Tests/PermissionsModelTests.swift index 57bc948ff6..614def91c4 100644 --- a/src/macos/GenesisTools/Tests/PermissionsModelTests.swift +++ b/src/macos/GenesisTools/Tests/PermissionsModelTests.swift @@ -8,13 +8,15 @@ import XCTest final class PermissionsModelTests: XCTestCase { private final class FakeSystem: PermissionSystem, @unchecked Sendable { var statuses: [PermissionKind: PermissionStatus] = [:] + /// What a request answers; unset is a refusal for Downloads and a grant for the rest. + var answers: [PermissionKind: PermissionStatus] = [:] private(set) var requests: [PermissionKind] = [] func status(_ kind: PermissionKind) -> PermissionStatus { statuses[kind] ?? .notDetermined } func request(_ kind: PermissionKind) async -> PermissionStatus { requests.append(kind) - return kind == .downloadsFolder ? .denied : .granted + return answers[kind] ?? (kind == .downloadsFolder ? .denied : .granted) } func openSettings(_ kind: PermissionKind) -> Bool { true } @@ -53,4 +55,29 @@ final class PermissionsModelTests: XCTestCase { XCTAssertEqual(model.rows.first { $0.kind == .downloadsFolder }?.state, .denied) XCTAssertTrue(model.lastMessage?.contains("Downloads folder") == true) } + + /// The window refreshes when the user comes back from System Settings: the answered probe runs again (macOS + /// answered it before, so it does not prompt) and the "macOS refused" line goes once the grant is fixed. + func testARefreshAfterTheGrantIsFixedClearsTheRefusal() async throws { + let system = FakeSystem() + system.statuses[.downloadsFolder] = .unknown("asks on first use") + let model = PermissionsModel(access: PermissionAccess(system: system)) + model.refresh() + let downloads = try XCTUnwrap(model.rows.first { $0.kind == .downloadsFolder }) + model.request(downloads) + for _ in 0 ..< 200 where model.busy != nil { + try await Task.sleep(for: .milliseconds(5)) + } + XCTAssertNotNil(model.lastMessage) + XCTAssertNil(model.busyTitle) + + system.answers[.downloadsFolder] = .granted + model.refresh() + for _ in 0 ..< 200 where model.lastMessage != nil { + try await Task.sleep(for: .milliseconds(5)) + } + XCTAssertNil(model.lastMessage) + XCTAssertEqual(model.rows.first { $0.kind == .downloadsFolder }?.state, .granted) + XCTAssertEqual(system.requests, [.downloadsFolder, .downloadsFolder]) + } } From 58e9a074818f71bd56f8dc5c96959d3036c4b47d Mon Sep 17 00:00:00 2001 From: Martin <admin@phpcode.eu> Date: Sat, 24 Oct 2026 23:13:22 +0200 Subject: [PATCH 02/18] test(GenesisKit): the widget, recording and daemon tests read the new UX wording and types --- .../GenesisKit/Tests/GenesisKitTests/GenesisKitTests.swift | 2 +- .../GenesisKit/Tests/GenesisKitTests/RecordingTests.swift | 3 ++- .../GenesisKit/Tests/GenesisKitTests/WidgetKeymapTests.swift | 4 ++-- 3 files changed, 5 insertions(+), 4 deletions(-) diff --git a/src/macos/GenesisKit/Tests/GenesisKitTests/GenesisKitTests.swift b/src/macos/GenesisKit/Tests/GenesisKitTests/GenesisKitTests.swift index 2cc1ccdfc2..e086a44c43 100644 --- a/src/macos/GenesisKit/Tests/GenesisKitTests/GenesisKitTests.swift +++ b/src/macos/GenesisKit/Tests/GenesisKitTests/GenesisKitTests.swift @@ -1207,7 +1207,7 @@ final class WidgetInboxNotificationTests: XCTestCase { func testInboxTooltipDescribesOnlyTheAgentsModule() { let inbox = WidgetInboxSummary(unread: 3, needsAnswer: 1, complete: true, truncated: false, sessions: []) XCTAssertEqual(WidgetHostView.moduleTooltip(id: "agents", title: "Agents", inbox: inbox), - "Inbox: 3 unread, 1 need an answer") + "Inbox: 1 waiting for your answer, 3 unread") XCTAssertEqual(WidgetHostView.moduleTooltip(id: "shelf", title: "Shelf", inbox: inbox), "Shelf") XCTAssertEqual(WidgetHostView.moduleTooltip(id: nil, title: "Widgets", inbox: inbox), "Widgets") } diff --git a/src/macos/GenesisKit/Tests/GenesisKitTests/RecordingTests.swift b/src/macos/GenesisKit/Tests/GenesisKitTests/RecordingTests.swift index 6c59e18e5a..9458da352b 100644 --- a/src/macos/GenesisKit/Tests/GenesisKitTests/RecordingTests.swift +++ b/src/macos/GenesisKit/Tests/GenesisKitTests/RecordingTests.swift @@ -143,7 +143,8 @@ final class RecordingSessionControllerTests: XCTestCase { crashed.onFailed = { message, _ in messages.append(message) } try crashed.start(target: .display(index: 0, name: nil), excludeWindowIDs: [], frameRate: 15, showsBorder: true) exit(9, "bun crashed\n") - XCTAssertEqual(messages, ["bun crashed"]) + // One sentence on screen; the stderr tail goes to the log (C6). + XCTAssertEqual(messages, ["The recorder stopped unexpectedly. The details are in the log."]) } } diff --git a/src/macos/GenesisKit/Tests/GenesisKitTests/WidgetKeymapTests.swift b/src/macos/GenesisKit/Tests/GenesisKitTests/WidgetKeymapTests.swift index 76ebbe1bb8..08891b4565 100644 --- a/src/macos/GenesisKit/Tests/GenesisKitTests/WidgetKeymapTests.swift +++ b/src/macos/GenesisKit/Tests/GenesisKitTests/WidgetKeymapTests.swift @@ -73,9 +73,9 @@ final class WidgetKeymapTests: XCTestCase { func testOpenHubLeavesCommandHToHideAndJAndKNameTheSession() throws { let keys = Keymap(library: WidgetKeymap.library, store: nil, layout: TableKeyLayout.us) let commandH = KeyStroke(keyCode: 4, character: "h", modifiers: .command) - XCTAssertEqual(keys.commands(for: commandH, in: [.window]).map(\.id), []) + XCTAssertEqual(keys.commands(for: commandH, in: [.app, .window]).map(\.id), []) let commandO = KeyStroke(keyCode: 31, character: "o", modifiers: .command) - XCTAssertEqual(keys.commands(for: commandO, in: [.window]).map(\.id), [WidgetKeymap.hub]) + XCTAssertEqual(keys.commands(for: commandO, in: [.app]).map(\.id), [WidgetKeymap.hub]) let titles = Dictionary(uniqueKeysWithValues: WidgetKeymap.library.commands.map { ($0.id, $0.title) }) XCTAssertEqual(titles[WidgetKeymap.previous], "Previous Session") XCTAssertEqual(titles[WidgetKeymap.next], "Next Session") From 5f9c739b990eb40bb32c930d2ea71b359ed6eaef Mon Sep 17 00:00:00 2001 From: Martin <admin@phpcode.eu> Date: Sat, 24 Oct 2026 23:17:28 +0200 Subject: [PATCH 03/18] test(GenesisKit): the rail badge, alert title, recorder failure and Open Hub tests follow the new counts, words and keys --- .../Tests/GenesisKitTests/EdgePanelGeometryTests.swift | 7 ++++--- 1 file changed, 4 insertions(+), 3 deletions(-) diff --git a/src/macos/GenesisKit/Tests/GenesisKitTests/EdgePanelGeometryTests.swift b/src/macos/GenesisKit/Tests/GenesisKitTests/EdgePanelGeometryTests.swift index 93fce2e9a4..46fe0c85e9 100644 --- a/src/macos/GenesisKit/Tests/GenesisKitTests/EdgePanelGeometryTests.swift +++ b/src/macos/GenesisKit/Tests/GenesisKitTests/EdgePanelGeometryTests.swift @@ -817,7 +817,7 @@ final class WidgetVisibilityTests: XCTestCase { kind: "decision", key: key, at: Date().timeIntervalSince1970 * 1000, needsAnswer: true, title: "Pick a parser for DECISION 8", number: 8) seen = try read("peek", WidgetPeekView(model: model, item: peek)) - XCTAssertTrue(seen.contains("New decision"), "read: \(seen)") + XCTAssertTrue(seen.contains("Decision 8"), "read: \(seen)") XCTAssertTrue(seen.contains("Pick a parser"), "read: \(seen)") model.openDecision(id: "d_8_fixture-session", sessionKey: key) @@ -826,7 +826,8 @@ final class WidgetVisibilityTests: XCTestCase { func testAlertKickerAndOwnAnswerCardStates() { let item = WidgetInboxItem(id: "a", sourceId: "a", kind: "decision", key: "k", at: 1, needsAnswer: true, number: 9) - XCTAssertEqual(WidgetAlerts.kicker(item), "New decision · DECISION 9") + // The number once, in sentence case (R10). + XCTAssertEqual(WidgetAlerts.kicker(item), "Decision 9") var card = WidgetCard(id: "decision:d_9_s", kind: "decision", sessionKey: "k", sourceId: "d_9_s", at: 1, title: "T", body: "", status: "superseded", choices: [], attachments: [], refs: [], read: true) card.supersededBy = .init(id: "d_12_s", number: 12, sessionKey: "k", title: "Newer") @@ -1752,7 +1753,7 @@ final class WidgetRosterTests: XCTestCase { var heights: [CGFloat] = [] for count in [0, 128, 0] { model.updateInbox(WidgetInboxSummary( - unread: count, needsAnswer: 0, complete: true, truncated: false, sessions: [])) + unread: count, needsAnswer: count, complete: true, truncated: false, sessions: [])) let metrics = WidgetSideStripMetrics(classic: style == "classic", moduleIDs: ids, visibleSessionCount: model.railActivitySessions.count, hasInboxBadge: count > 0) let root = WidgetHostView(model: model, registry: registry, From f6bb987c345a0881c47bb4a87cbab32d7106a0e1 Mon Sep 17 00:00:00 2001 From: Martin <admin@phpcode.eu> Date: Sun, 25 Oct 2026 11:28:46 +0100 Subject: [PATCH 04/18] fix(GenesisKit, GenesisHub, widgets): review round on the UX fixes: undo windows follow the note setting, arrows move through decision options, the footer and hints are readable at 11 pt, a shelf or task list that cannot be read says so in one sentence, and pending ledger moves are saved when the app quits --- src/hub/lib/widget/shelf.ts | 4 +- .../Sources/GenesisKit/Style/SWR.swift | 17 +- .../GenesisKit/Widgets/AgentWidgetViews.swift | 2 +- .../GenesisKit/Widgets/LiveWidgetView.swift | 209 ++++++++++++------ .../Widgets/Shelf/WidgetShelfModules.swift | 1 + .../Widgets/Shelf/WidgetShelfStore.swift | 26 ++- .../Widgets/Tasks/WidgetTasksStore.swift | 3 +- .../VoiceNotes/WidgetVoiceNotesModule.swift | 1 + .../VoiceNotes/WidgetVoiceNotesStore.swift | 25 ++- .../Widgets/WidgetAgentPreview.swift | 13 +- .../Widgets/WidgetCoordinator.swift | 6 +- .../GenesisKit/Widgets/WidgetHostView.swift | 4 +- .../Widgets/WidgetInboxOverview.swift | 131 +++++++---- .../GenesisKit/Widgets/WidgetKeymap.swift | 4 +- .../Widgets/WidgetModel+Staging.swift | 12 + .../GenesisKit/Widgets/WidgetModel.swift | 59 ++++- .../Widgets/WidgetSessionBrowser.swift | 10 +- .../GenesisKitTests/GenesisKitTests.swift | 15 +- .../ToolsLineStreamTests.swift | 6 +- .../WidgetInboxTasksPolishTests.swift | 26 ++- .../GenesisKitTests/WidgetKeymapTests.swift | 22 ++ 21 files changed, 436 insertions(+), 160 deletions(-) diff --git a/src/hub/lib/widget/shelf.ts b/src/hub/lib/widget/shelf.ts index c978a0f9b8..ccde8ed282 100644 --- a/src/hub/lib/widget/shelf.ts +++ b/src/hub/lib/widget/shelf.ts @@ -359,7 +359,7 @@ export async function readShelfAttachment({ root, id, key }: { root?: string; id const item = (await listWidgetShelf(root)).items.find((entry) => entry.id === id); if (!item || !(await Bun.file(item.path).exists())) { - throw new Error("The staged item is unavailable; import it again"); + throw new Error("This item is no longer on the shelf. Add it again."); } const state = await readWidgetState(root); @@ -390,7 +390,7 @@ export async function attachShelfItem({ const item = (await listWidgetShelf(root)).items.find((entry) => entry.id === id); if (!item || !(await Bun.file(item.path).exists())) { - throw new Error("The staged item is unavailable; import it again"); + throw new Error("This item is no longer on the shelf. Add it again."); } return mutateWidgetState(root, (state) => { diff --git a/src/macos/GenesisKit/Sources/GenesisKit/Style/SWR.swift b/src/macos/GenesisKit/Sources/GenesisKit/Style/SWR.swift index 60de807465..da5844ea6d 100644 --- a/src/macos/GenesisKit/Sources/GenesisKit/Style/SWR.swift +++ b/src/macos/GenesisKit/Sources/GenesisKit/Style/SWR.swift @@ -10,10 +10,19 @@ import SwiftUI /// SWR.fade(moved, current: { changed }, clear: { changed = [] }) /// row.swrFlash(changed.contains(row.id)).transition(SWR.rowTransition) public enum SWR { - /// The refresh spring, or nil (an instant change) while the system's Reduce Motion is on. Read at each refresh, - /// so the setting takes effect without a restart. + /// The refresh spring, or nil (an instant change) while Reduce Motion is on: the system's or the app's own + /// (`NativeSettingsAppearance`, the hub's General → Reduce motion), the two `@GenReduceMotion` reads. Read at + /// each refresh, so a change takes effect without a restart. public static var animation: Animation? { - NSWorkspace.shared.accessibilityDisplayShouldReduceMotion ? nil : .spring(response: 0.35, dampingFraction: 0.85) + reduceMotion ? nil : .spring(response: 0.35, dampingFraction: 0.85) + } + + /// `withAnimation` runs on the main thread, and so does every caller; off it the app's setting cannot be read and + /// only the system's counts. + private static var reduceMotion: Bool { + if NSWorkspace.shared.accessibilityDisplayShouldReduceMotion { return true } + guard Thread.isMainThread else { return false } + return MainActor.assumeIsolated { NativeSettingsAppearance.shared.reduceMotion } } /// New rows slide in from the top; gone rows fade. @@ -48,7 +57,7 @@ public enum SWR { Task { @MainActor in try? await Task.sleep(nanoseconds: 1_600_000_000) guard current() == flashed else { return } - withAnimation(NSWorkspace.shared.accessibilityDisplayShouldReduceMotion ? nil : .easeOut(duration: 0.6)) { clear() } + withAnimation(reduceMotion ? nil : .easeOut(duration: 0.6)) { clear() } } } } diff --git a/src/macos/GenesisKit/Sources/GenesisKit/Widgets/AgentWidgetViews.swift b/src/macos/GenesisKit/Sources/GenesisKit/Widgets/AgentWidgetViews.swift index 1de5fc56d8..425aec8425 100644 --- a/src/macos/GenesisKit/Sources/GenesisKit/Widgets/AgentWidgetViews.swift +++ b/src/macos/GenesisKit/Sources/GenesisKit/Widgets/AgentWidgetViews.swift @@ -48,7 +48,7 @@ struct WidgetInboxCount: View { LinearKeyframe(0, duration: 0.33) } } - .accessibilityLabel((complete ? "" : "At least ") + "\(count) inbox notifications") + .accessibilityLabel((complete ? "" : "At least ") + (needsAnswer ? Plural.count(count, "item waits", "items wait") + " for your answer" : Plural.count(count, "unread item"))) } } diff --git a/src/macos/GenesisKit/Sources/GenesisKit/Widgets/LiveWidgetView.swift b/src/macos/GenesisKit/Sources/GenesisKit/Widgets/LiveWidgetView.swift index 118060fea7..14002af101 100644 --- a/src/macos/GenesisKit/Sources/GenesisKit/Widgets/LiveWidgetView.swift +++ b/src/macos/GenesisKit/Sources/GenesisKit/Widgets/LiveWidgetView.swift @@ -210,7 +210,8 @@ public struct LiveWidgetView: View { } else if section == "Inbox" { inbox } else if section == "Sessions" { - WidgetSessionBrowser(model: model) + // Only this list reports its drawn order for Up and Down; the chooser popover does not. + WidgetSessionBrowser(model: model, reportsOrder: true) } else if section == "Changes" { changes } else { @@ -237,7 +238,9 @@ public struct LiveWidgetView: View { } // Up and Down move the selection; the list brings the chosen row into view. .onChange(of: model.listKey) { _, _ in - if section == "Inbox", let id = model.card?.id { + if section == "Inbox", let focus = model.choiceFocus { + reader.scrollTo("choice-" + focus.cardID + "-" + focus.choiceID) + } else if section == "Inbox", let id = model.card?.id { reader.scrollTo("card-" + id) } else if section == "Sessions" { reader.scrollTo(model.selectedKey) @@ -474,7 +477,7 @@ public struct LiveWidgetView: View { } } else if model.inboxLoading { ProgressView("Loading this session’s inbox…") - .controlSize(.small).padding(.vertical, 12) + .controlSize(.small).font(.system(size: 11)).padding(.vertical, 12) .accessibilityIdentifier("widget.inbox.loading") } else { Text("No inbox items for this session.").foregroundStyle(.secondary).font(.callout) @@ -569,6 +572,61 @@ public struct LiveWidgetView: View { && (model.stagedOption(card.sourceId, stored: card.draftOption) != nil || card.draft != nil) } + /// One option of a decision: the key badge, "a)" and the text, Staged, the recommended mark and the focus ring. + @ViewBuilder + private func optionRow(_ card: WidgetCard, choice: WidgetChoice, index: Int, key: String?, picked: Bool, + focused: Bool) -> some View { + let shape = RoundedRectangle(cornerRadius: 9) + let fill: Color = picked ? SessionPalette.blue.opacity(0.16) : .white.opacity(0.065) + let border: Color = picked ? SessionPalette.blue.opacity(0.7) : .clear + let ring: Color = focused ? Color(nsColor: .keyboardFocusIndicatorColor) : .clear + let label = (model.decisionClickStages ? "Stage " : "Answer ") + "\(choice.id): " + Self.choiceText(choice) + Button { + model.choose(card, option: choice.id) + } label: { + optionLabel(choice, key: key, picked: picked) + .padding(9).frame(maxWidth: .infinity, alignment: .leading) + .background(fill, in: shape) + .overlay(shape.strokeBorder(border, lineWidth: 1)) + // Up and Down move through the options; the chosen one wears the focus ring. + .overlay(shape.strokeBorder(ring, lineWidth: 2)) + .contentShape(shape) + } + .buttonStyle(RowButtonStyle(cornerRadius: 9)).disabled(model.importing > 0) + .accessibilityAddTraits(focused ? .isSelected : []) + .id("choice-" + card.id + "-" + choice.id) + .instantTooltip(Self.choiceTooltip(choice, index: index, stages: model.decisionClickStages, picked: picked)) + .accessibilityLabel(label) + .accessibilityValue(picked ? "Staged" : "") + } + + private func optionLabel(_ choice: WidgetChoice, key: String?, picked: Bool) -> some View { + HStack(alignment: .top, spacing: 8) { + // The badge is the key that picks the option; the letter, which the agent and the hub name it by, leads + // its text. + Text(verbatim: key ?? choice.id).font(.system(size: 11, weight: .semibold, design: .rounded)) + .padding(.horizontal, 4) + .frame(minWidth: 20, minHeight: 20) + .background(.white.opacity(0.07), in: RoundedRectangle(cornerRadius: 5)) + HStack(alignment: .firstTextBaseline, spacing: 5) { + if key != nil { + Text(verbatim: choice.id + ")").foregroundStyle(SessionPalette.secondary) + } + Text(Self.choiceText(choice)).fixedSize(horizontal: false, vertical: true) + } + .font(.system(size: 12)) + .padding(.top, 2) + Spacer(minLength: 0) + if picked { + Text("Staged").font(.system(size: 10, weight: .semibold)) + .foregroundStyle(SessionPalette.blue).padding(.top, 3) + } + if choice.recommended { + Image(systemName: "sparkle").foregroundStyle(.orange) + } + } + } + /// The key that picks the option at `index` (the open decision's number keys, as the keymap has them), or nil. static func choiceKey(_ index: Int) -> String? { guard index < 9 else { return nil } @@ -628,45 +686,8 @@ public struct LiveWidgetView: View { ForEach(Array(card.choices.enumerated()), id: \.element.id) { index, choice in let picked = staged == choice.id let key = Self.choiceKey(index) - Button { - model.choose(card, option: choice.id) - } label: { - HStack(alignment: .top, spacing: 8) { - // The badge is the key that picks the option; the letter, which the agent and the - // hub name it by, leads its text. - Text(verbatim: key ?? choice.id).font(.system(size: 10.5, weight: .semibold, design: .rounded)) - .padding(.horizontal, 4) - .frame(minWidth: 20, minHeight: 20).background( - .white.opacity(0.07), in: RoundedRectangle(cornerRadius: 5)) - HStack(alignment: .firstTextBaseline, spacing: 5) { - if key != nil { - Text(verbatim: choice.id + ")").foregroundStyle(SessionPalette.secondary) - } - Text(Self.choiceText(choice)).fixedSize(horizontal: false, vertical: true) - } - .font(.system(size: 12)) - .padding(.top, 2) - Spacer(minLength: 0) - if picked { - Text("Staged").font(.system(size: 10, weight: .semibold)) - .foregroundStyle(SessionPalette.blue).padding(.top, 3) - } - if choice.recommended { - Image(systemName: "sparkle").foregroundStyle(.orange) - } - }.padding(9).frame(maxWidth: .infinity, alignment: .leading) - .background(picked ? SessionPalette.blue.opacity(0.16) : .white.opacity(0.065), - in: RoundedRectangle(cornerRadius: 9)) - .overlay(RoundedRectangle(cornerRadius: 9) - .strokeBorder(picked ? SessionPalette.blue.opacity(0.7) : .clear, lineWidth: 1)) - .contentShape(RoundedRectangle(cornerRadius: 9)) - } - .buttonStyle(RowButtonStyle(cornerRadius: 9)).disabled(model.importing > 0) - .instantTooltip(Self.choiceTooltip(choice, index: index, stages: model.decisionClickStages, - picked: picked)) - .accessibilityLabel((model.decisionClickStages ? "Stage " : "Answer ") + "\(choice.id): " - + Self.choiceText(choice)) - .accessibilityValue(picked ? "Staged" : "") + let focused = model.choiceFocus == WidgetChoiceFocus(cardID: card.id, choiceID: choice.id) + optionRow(card, choice: choice, index: index, key: key, picked: picked, focused: focused) } ownAnswer(card) } @@ -914,7 +935,7 @@ public struct LiveWidgetView: View { } case .card(let card): VStack(alignment: .leading, spacing: 6) { - Text(Self.kindLabel(card)).font(.caption2).foregroundStyle(.secondary) + Text(Self.kindLabel(card)).font(.system(size: 11)).foregroundStyle(SessionPalette.secondary) Text(card.numberedTitle).font(.system(size: 12, weight: .semibold)).textSelection(.enabled) if !card.body.isEmpty { WidgetMarkdown(text: card.body) @@ -937,9 +958,9 @@ public struct LiveWidgetView: View { Image(systemName: "clock.arrow.circlepath").foregroundStyle(.secondary) .accessibilityHidden(true) VStack(alignment: .leading, spacing: 3) { - Text(line.title).font(.caption) + Text(line.title).font(.system(size: 11)) if !line.detail.isEmpty { - Text(line.detail).font(.caption2).foregroundStyle(.secondary) + Text(line.detail).font(.system(size: 11)).foregroundStyle(SessionPalette.secondary) } } } @@ -1039,7 +1060,7 @@ public struct LiveWidgetView: View { case "send_failed": title = "Answer not delivered" default: title = sentenceCase(event.title) } - let decision = number.map { "DECISION \($0)" } ?? AIProviders.meta(for: provider).displayName + let decision = number.map { "Decision \($0)" } ?? AIProviders.meta(for: provider).displayName let facts = event.body.components(separatedBy: " · ").compactMap { part -> String? in routePhrase(part) ?? (decisionStates.contains(part) ? statusPhrase(part, kind: "decision") : nil) } @@ -1105,7 +1126,7 @@ public struct LiveWidgetView: View { systemName: message.state == "sent" ? "checkmark.circle.fill" : "exclamationmark.circle" ) } - Text(receiptLabel(message)).font(.system(size: 10)) + Text(receiptLabel(message)).font(.system(size: 11)) .accessibilityIdentifier("widget.receipt." + message.id) Spacer(minLength: 0) }.foregroundStyle(message.state == "sent" ? .green : .secondary) @@ -1355,13 +1376,15 @@ private struct DocumentHeight: ViewModifier { /// log has no move back from a dismissed decision, and the widget's `ledger` action cannot reopen a task, so the /// change is held here instead of undone there: the card shows its new state at once, Undo drops the change, and the /// change goes to the log when the note goes away (it faded after the note duration setting, was closed, or a newer -/// note replaced it). Shared by every panel of the widget, and kept while a panel is closed. +/// note replaced it). Shared by every panel of the widget, and kept while a panel is closed. What the user did still +/// lands when the panel's model goes away (a hold keeps its model until it is sent) and when the app quits. @MainActor final class WidgetLedgerUndo: ObservableObject { - static let shared = WidgetLedgerUndo() - /// The longest a change waits, even under a note that stays until it is closed: a change still held when the app - /// quits is lost, so it is not held for long. + static let shared = WidgetLedgerUndo(sendsBeforeQuit: true) + /// The longest a change waits, even under a note that stays until it is closed. static let longestHold: Duration = .seconds(60) + /// How long a quit waits for the held changes to reach the hub. + static let quitWait: DispatchTimeInterval = .seconds(4) struct Move: Equatable { let state: String @@ -1373,6 +1396,8 @@ final class WidgetLedgerUndo: ObservableObject { let move: Move let request: WidgetJSON let note: String + /// Kept until the change is sent, so a panel's model that goes away still sends it. + let model: WidgetModel var watch: AnyCancellable? var deadline: Task<Void, Never>? } @@ -1380,6 +1405,20 @@ final class WidgetLedgerUndo: ObservableObject { /// Card id to the move the card shows. @Published private(set) var moves: [String: Move] = [:] private var holds: [String: Hold] = [:] + /// Changes handed to the model's queue and not answered yet: a quit sends them again, because that queue never + /// runs again. A second copy finds the item already moved and changes nothing. + private var sending: [String: (request: WidgetJSON, bridge: ToolsBridge)] = [:] + /// The quit observer of the shared instance, which lives as long as the app. + private var quitObserver: NSObjectProtocol? + + init(sendsBeforeQuit: Bool = false) { + guard sendsBeforeQuit else { return } + quitObserver = NotificationCenter.default.addObserver( + forName: NSApplication.willTerminateNotification, object: nil, queue: .main + ) { [weak self] _ in + MainActor.assumeIsolated { self?.sendBeforeQuit() } + } + } /// `card` with its held or just committed move applied. func applied(_ card: WidgetCard) -> WidgetCard { @@ -1402,49 +1441,48 @@ final class WidgetLedgerUndo: ObservableObject { "action": "ledger", "id": .string(card.sourceId), "sessionId": .string(session), "expectedRevision": .number(Double(revision)), "state": .string(state), ]), - note: note) + note: note, model: model) moves[id] = move // Folded, the card is no longer the decision the number keys and the composer answer. GenMotion.perform(expanding: false, reduceMotion: reduceMotion) { _ = model.foldedCardIDs.insert(id) } - model.postNote(note, action: WidgetNoticeAction(title: "Undo") { [weak self, weak model] in - guard let self, let model else { return } - self.undo(id, model: model, reduceMotion: reduceMotion) + model.postNote(note, action: WidgetNoticeAction(title: "Undo") { [weak self] in + self?.undo(id, reduceMotion: reduceMotion) }) // The current value is this note; any later one (nil, or another note) means it went away. - hold.watch = model.$notice.dropFirst().sink { [weak self, weak model] text in + hold.watch = model.$notice.dropFirst().sink { [weak self] text in guard text != note else { return } - Task { @MainActor [weak self, weak model] in - guard let self, let model else { return } - self.commit(id, model: model) - } + Task { @MainActor [weak self] in self?.commit(id) } } - hold.deadline = Task { [weak self, weak model] in + hold.deadline = Task { [weak self] in do { try await Task.sleep(for: Self.longestHold) } catch { return } - guard let self, let model else { return } - self.commit(id, model: model) + self?.commit(id) } holds[id] = hold } - /// "DECISION 4 dismissed.", "Task 2 completed.": what changed, in the note that offers Undo. + /// "Decision 4 dismissed.", "Task 2 completed.": what changed, in the note that offers Undo. static func note(_ card: WidgetCard, state: String) -> String { let name = card.kind == "todo" ? card.number.map { "Task \($0)" } ?? "Task" - : card.number.map { "DECISION \($0)" } ?? "Decision" + : card.number.map { "Decision \($0)" } ?? "Decision" return name + (state == "implemented" ? " completed." : " dismissed.") } - private func commit(_ id: String, model: WidgetModel) { + private func commit(_ id: String) { guard let hold = holds.removeValue(forKey: id) else { return } hold.watch?.cancel() hold.deadline?.cancel() + let model = hold.model // Its Undo could not take the change back any more. if model.notice == hold.note { model.notice = nil } + sending[id] = (hold.request, model.bridge) model.action(hold.request, failed: { [weak self] in + self?.sending[id] = nil // The log refused it (the item changed elsewhere): the card shows its real state again, and the error // line says why. if self?.moves[id] == hold.move { self?.moves[id] = nil } }, completed: { [weak self] in + self?.sending[id] = nil // The card keeps its new look until a snapshot carries the newer revision; then the entry is inert. Task { @MainActor [weak self] in try? await Task.sleep(for: .seconds(30)) @@ -1453,14 +1491,51 @@ final class WidgetLedgerUndo: ObservableObject { }) } - private func undo(_ id: String, model: WidgetModel, reduceMotion: Bool) { + private func undo(_ id: String, reduceMotion: Bool) { guard let hold = holds.removeValue(forKey: id) else { return } hold.watch?.cancel() hold.deadline?.cancel() moves[id] = nil + let model = hold.model GenMotion.perform(expanding: true, reduceMotion: reduceMotion) { _ = model.foldedCardIDs.remove(id) } model.postNote("Undone.") } + + /// The app quits while changes still wait for their note, or wait in the model's queue. The main actor is busy + /// quitting, so that queue never runs again: each change goes to the hub straight through the bridge, off the + /// main thread, and the quit waits for them up to `quitWait`. The `ledger` action reads the decision log itself, + /// so it needs no widget state root. + private func sendBeforeQuit() { + let held = holds.values.map { (request: $0.request, bridge: $0.model.bridge) } + for hold in holds.values { + hold.watch?.cancel() + hold.deadline?.cancel() + } + holds.removeAll() + let work = held + Array(sending.values) + sending.removeAll() + guard !work.isEmpty else { return } + let done = DispatchSemaphore(value: 0) + Task.detached { + for item in work { await WidgetLedgerUndo.send(item.request, through: item.bridge) } + done.signal() + } + if done.wait(timeout: .now() + Self.quitWait) == .timedOut { + PerfLog.mark("widget.ledger quit: \(work.count) change(s) not confirmed before the app quit") + } + } + + nonisolated private static func send(_ request: WidgetJSON, through bridge: ToolsBridge) async { + let file = FileManager.default.temporaryDirectory + .appendingPathComponent("widget-ledger-" + UUID().uuidString + ".json") + do { + try JSONEncoder().encode(request).write(to: file, options: .atomic) + defer { try? FileManager.default.removeItem(at: file) } + _ = try await bridge.run(subcommand: "hub", args: ["widget", "call", "--input", file.path], timeoutSeconds: 4) + } catch { + PerfLog.mark("widget.ledger quit send \(error.localizedDescription)") + } + } } /// Measured heights of LiveWidgetView's pane, its scroll viewport and the scroll document. diff --git a/src/macos/GenesisKit/Sources/GenesisKit/Widgets/Shelf/WidgetShelfModules.swift b/src/macos/GenesisKit/Sources/GenesisKit/Widgets/Shelf/WidgetShelfModules.swift index 8ea71bf1dc..ede13c3cf2 100644 --- a/src/macos/GenesisKit/Sources/GenesisKit/Widgets/Shelf/WidgetShelfModules.swift +++ b/src/macos/GenesisKit/Sources/GenesisKit/Widgets/Shelf/WidgetShelfModules.swift @@ -133,6 +133,7 @@ struct WidgetShelfModuleView: View { .keyboardShortcut("z", modifiers: .command) .instantTooltip("Puts it back (⌘Z)") .accessibilityIdentifier("shelf-undo-remove") + IconButton(systemName: "xmark", tooltip: "Close; the item is removed now", action: store.confirmRemoval) } } else if let notice = store.notice { Text(notice).font(.system(size: 11)).foregroundStyle(.secondary) diff --git a/src/macos/GenesisKit/Sources/GenesisKit/Widgets/Shelf/WidgetShelfStore.swift b/src/macos/GenesisKit/Sources/GenesisKit/Widgets/Shelf/WidgetShelfStore.swift index 5baf0243a6..09af7ef6f9 100644 --- a/src/macos/GenesisKit/Sources/GenesisKit/Widgets/Shelf/WidgetShelfStore.swift +++ b/src/macos/GenesisKit/Sources/GenesisKit/Widgets/Shelf/WidgetShelfStore.swift @@ -67,7 +67,7 @@ public final class WidgetShelfStore: ObservableObject { @Published public private(set) var notice: String? { didSet { if let notice, notice != oldValue { announce(notice, .medium) } } } - /// The item the user just removed: gone from the list, still on the shelf until `removalUndoDelay` runs out, so + /// The item the user just removed: gone from the list, still on the shelf until `undoSeconds` run out, so /// the status line's Undo can bring it back (ux checklist "Undo and destructive actions"). @Published public private(set) var removed: WidgetShelfItem? /// True once the first inventory read finished, so a view can tell a first load from an item arriving. @@ -100,7 +100,8 @@ public final class WidgetShelfStore: ObservableObject { private var filePicker: NSOpenPanel? private var stopped = false private let announce: @MainActor (String, GenAnnounce.Priority) -> Void - private let removalUndoDelay: Duration + /// How long a removal waits behind Undo (`WidgetModel.undoSeconds`), read at each removal. + private let undoSeconds: @MainActor () -> Double /// Where the removed item stood, so Undo puts it back in place. private var removedIndex = 0 private var removalTimer: Task<Void, Never>? @@ -114,12 +115,13 @@ public final class WidgetShelfStore: ObservableObject { onCaptureWillBegin: @escaping () -> Void = {}, onCaptureStaged: @escaping () -> Void = {}, onDialogVisibilityChanged: @escaping (Bool) -> Void = { _ in }, - attachToDraft: ((WidgetShelfItem, WidgetSession) async throws -> Void)? = nil + attachToDraft: ((WidgetShelfItem, WidgetSession) async throws -> Void)? = nil, + undoSeconds: @escaping @MainActor () -> Double = { WidgetModel.undoSeconds(nil) } ) { screenCaptureAccess = { PermissionAccess.live.isGranted(.screenRecording) } permissionGate = { await PermissionCenter.shared.ensure($0) } announce = { GenAnnounce.post($0, priority: $1) } - removalUndoDelay = .seconds(10) + self.undoSeconds = undoSeconds let bridge = ToolsBridge(binaryPath: binaryPath) self.request = { arguments, timeout in var prefix = ["widget"] @@ -153,11 +155,11 @@ public final class WidgetShelfStore: ObservableObject { onDialogVisibilityChanged: @escaping (Bool) -> Void = { _ in }, attachToDraft: ((WidgetShelfItem, WidgetSession) async throws -> Void)? = nil, announce: @escaping @MainActor (String, GenAnnounce.Priority) -> Void = { _, _ in }, - removalUndoDelay: Duration = .seconds(10) + undoSeconds: @escaping @MainActor () -> Double = { 10 } ) { self.request = request self.announce = announce - self.removalUndoDelay = removalUndoDelay + self.undoSeconds = undoSeconds self.screenRecorder = screenRecorder self.screenCaptureAccess = screenCaptureAccess self.permissionGate = permissionGate @@ -458,7 +460,7 @@ public final class WidgetShelfStore: ObservableObject { } } - /// Takes `item` off the list at once and removes it from the shelf after `removalUndoDelay`, unless Undo brings + /// Takes `item` off the list at once and removes it from the shelf after `undoSeconds`, unless Undo brings /// it back first. Removing another item removes the first one now. public func remove(_ item: WidgetShelfItem) { guard !stopped, !isBusy, let index = items.firstIndex(where: { $0.id == item.id }) else { return } @@ -471,7 +473,7 @@ public final class WidgetShelfStore: ObservableObject { // A capture has no original elsewhere; a file does. notice = item.kind == .capture ? "Removed from Capture. Drafts that already use it keep it." : "Removed from the shelf. The original file and existing drafts are kept." - let delay = removalUndoDelay + let delay = Duration.seconds(undoSeconds()) removalTimer = Task { [weak self] in do { try await Task.sleep(for: delay) } catch { return } self?.commitRemoval() @@ -492,7 +494,10 @@ public final class WidgetShelfStore: ObservableObject { refresh() } - /// Removes the item for good: its Undo ran out, another item was removed, or the store stops. + /// The Undo row's close button: the removal happens now instead of when the Undo time runs out. + public func confirmRemoval() { commitRemoval() } + + /// Removes the item for good: its Undo ran out or was closed, another item was removed, or the store stops. private func commitRemoval() { guard let item = removed else { return } removalTimer?.cancel() @@ -565,7 +570,8 @@ public final class WidgetShelfStore: ObservableObject { } private func report(_ error: Error) { - self.error = error.localizedDescription + // A reply the shelf cannot decode is one sentence on screen; the decoder's own words go to the log only. + self.error = error is DecodingError ? "The shelf could not be read. Try again in a moment." : error.localizedDescription GenesisKit.log("widget shelf: \(error.localizedDescription)") } diff --git a/src/macos/GenesisKit/Sources/GenesisKit/Widgets/Tasks/WidgetTasksStore.swift b/src/macos/GenesisKit/Sources/GenesisKit/Widgets/Tasks/WidgetTasksStore.swift index a0406e8056..007ae146ac 100644 --- a/src/macos/GenesisKit/Sources/GenesisKit/Widgets/Tasks/WidgetTasksStore.swift +++ b/src/macos/GenesisKit/Sources/GenesisKit/Widgets/Tasks/WidgetTasksStore.swift @@ -477,7 +477,8 @@ public final class WidgetTasksStore: ObservableObject { } private func report(_ failure: Error) { - error = failure.localizedDescription + // A reply the list cannot decode is one sentence on screen; the decoder's own words go to the log only. + error = failure is DecodingError ? "The task list could not be read. Try again in a moment." : failure.localizedDescription PerfLog.mark("widget.tasks \(failure.localizedDescription)") } } diff --git a/src/macos/GenesisKit/Sources/GenesisKit/Widgets/VoiceNotes/WidgetVoiceNotesModule.swift b/src/macos/GenesisKit/Sources/GenesisKit/Widgets/VoiceNotes/WidgetVoiceNotesModule.swift index bd71cb3d5a..b048141fa7 100644 --- a/src/macos/GenesisKit/Sources/GenesisKit/Widgets/VoiceNotes/WidgetVoiceNotesModule.swift +++ b/src/macos/GenesisKit/Sources/GenesisKit/Widgets/VoiceNotes/WidgetVoiceNotesModule.swift @@ -159,6 +159,7 @@ private struct WidgetVoiceNotesView: View { .buttonStyle(.genHover()).font(.system(size: 11, weight: .medium)) .instantTooltip("Brings the recording back. It is deleted for good after a few seconds.") .accessibilityIdentifier("voice-notes-undo-discard") + IconButton(systemName: "xmark", tooltip: "Close; the recording is deleted now", action: store.confirmDiscard) } } else if let receipt = store.receipt { Label(receipt, systemImage: "checkmark.circle").font(.system(size: 11)).foregroundStyle(.secondary) diff --git a/src/macos/GenesisKit/Sources/GenesisKit/Widgets/VoiceNotes/WidgetVoiceNotesStore.swift b/src/macos/GenesisKit/Sources/GenesisKit/Widgets/VoiceNotes/WidgetVoiceNotesStore.swift index d40a1a120f..c0c79cfbaf 100644 --- a/src/macos/GenesisKit/Sources/GenesisKit/Widgets/VoiceNotes/WidgetVoiceNotesStore.swift +++ b/src/macos/GenesisKit/Sources/GenesisKit/Widgets/VoiceNotes/WidgetVoiceNotesStore.swift @@ -23,7 +23,7 @@ public final class WidgetVoiceNotesStore: ObservableObject { @Published public var selectedID: String? @Published public var recipientKey = "" @Published private var drafts: [String: String] = [:] - /// The note the user just discarded: gone from the list, still on disk until `discardUndoDelay` runs out, so + /// The note the user just discarded: gone from the list, still on disk until `undoSeconds` run out, so /// Undo can bring it back (ux checklist "Undo and destructive actions"). @Published public private(set) var discarded: WidgetVoiceNote? public let meter = WidgetVoiceRecordingMeter() @@ -44,7 +44,8 @@ public final class WidgetVoiceNotesStore: ObservableObject { private let presentMicrophonePermission: @MainActor (PermissionNeed) -> Void private var operation: Task<Void, Never>? private var refreshTask: Task<Void, Never>? - private let discardUndoDelay: Duration + /// How long a discard waits behind Undo (`WidgetModel.undoSeconds`), read at each discard. + private let undoSeconds: @MainActor () -> Double /// Where the discarded note stood in the list, so Undo puts it back in place. private var discardedIndex = 0 private var discardTimer: Task<Void, Never>? @@ -61,7 +62,8 @@ public final class WidgetVoiceNotesStore: ObservableObject { acquireAudio: @escaping () async throws -> any VoiceRecordingLease, settings: @escaping () -> WidgetVoiceNoteSettings, sessions: @escaping () -> [WidgetSession], - attachDraft: @escaping (WidgetSession, WidgetVoiceNote) async throws -> String) { + attachDraft: @escaping (WidgetSession, WidgetVoiceNote) async throws -> String, + undoSeconds: @escaping @MainActor () -> Double = { WidgetModel.undoSeconds(nil) }) { let bridge = ToolsBridge(binaryPath: binaryPath) let transport = VoiceCommandTransport(binaryPath: binaryPath, stateRoot: stateRoot) let prefix = ["widget"] + (stateRoot.map { ["--state-root", $0] } ?? []) + ["voice-notes"] @@ -74,7 +76,8 @@ public final class WidgetVoiceNotesStore: ObservableObject { return Data(result.stdout.utf8) }, execute: { args, lease, event in try await transport.run(args: args, lease: lease, onEvent: event) }, finishCapture: { transport.finishRecording() }, cancelCommand: { transport.cancel() }, - acquireAudio: acquireAudio, settings: settings, sessions: sessions, attachDraft: attachDraft) + acquireAudio: acquireAudio, settings: settings, sessions: sessions, attachDraft: attachDraft, + undoSeconds: undoSeconds) } init(micLauncher: String, request: @escaping ([String]) async throws -> Data, @@ -87,9 +90,9 @@ public final class WidgetVoiceNotesStore: ObservableObject { requestMicrophonePermission: @escaping () async throws -> VoiceMicrophonePermission = { try await VoiceMicrophonePermission.request() }, activateForMicrophone: @escaping () -> Void = { NSApp?.activate(ignoringOtherApps: true) }, presentMicrophonePermission: @escaping @MainActor (PermissionNeed) -> Void = { PermissionCenter.shared.require($0) }, - discardUndoDelay: Duration = .seconds(10)) { + undoSeconds: @escaping @MainActor () -> Double = { 10 }) { self.micLauncher = micLauncher - self.discardUndoDelay = discardUndoDelay + self.undoSeconds = undoSeconds self.readMicrophonePermission = readMicrophonePermission self.requestMicrophonePermission = requestMicrophonePermission self.activateForMicrophone = activateForMicrophone @@ -309,7 +312,7 @@ public final class WidgetVoiceNotesStore: ObservableObject { } } - /// Takes the selected note off the list at once and deletes it after `discardUndoDelay`, unless Undo brings it + /// Takes the selected note off the list at once and deletes it after `undoSeconds`, unless Undo brings it /// back first. A second discard deletes the first one now. public func discard() { guard !stopped, !isBusy, let note = selected, let index = notes.firstIndex(where: { $0.id == note.id }) else { return } @@ -321,7 +324,7 @@ public final class WidgetVoiceNotesStore: ObservableObject { selectedID = notes.first?.id error = nil receipt = "Recording discarded." - let delay = discardUndoDelay + let delay = Duration.seconds(undoSeconds()) discardTimer = Task { [weak self] in do { try await Task.sleep(for: delay) } catch { return } self?.deleteDiscarded() @@ -340,7 +343,11 @@ public final class WidgetVoiceNotesStore: ObservableObject { receipt = "Recording restored." } - /// Deletes the discarded note for good: its Undo ran out, another note was discarded, or the store stops. + /// The Undo row's close button: the delete happens now instead of when the Undo time runs out. + public func confirmDiscard() { deleteDiscarded() } + + /// Deletes the discarded note for good: its Undo ran out or was closed, another note was discarded, or the store + /// stops. private func deleteDiscarded() { guard let note = discarded else { return } discardTimer?.cancel() diff --git a/src/macos/GenesisKit/Sources/GenesisKit/Widgets/WidgetAgentPreview.swift b/src/macos/GenesisKit/Sources/GenesisKit/Widgets/WidgetAgentPreview.swift index b5e9c37bee..653240b2f9 100644 --- a/src/macos/GenesisKit/Sources/GenesisKit/Widgets/WidgetAgentPreview.swift +++ b/src/macos/GenesisKit/Sources/GenesisKit/Widgets/WidgetAgentPreview.swift @@ -56,7 +56,7 @@ struct WidgetSessionActivity: View { } } .accessibilityElement(children: .ignore) - .accessibilityLabel(status.label + ", \(max(0, count)) inbox notifications") + .accessibilityLabel(status.label + ", " + Plural.count(max(0, count), "inbox item")) } } @@ -78,10 +78,10 @@ struct AgentWidgetPreview: View { Button { model.toggleInboxOverview(on: surface) } label: { HStack(spacing: 4) { WidgetInboxCount(count: model.inbox.needsAnswer, needsAnswer: true, - pulse: model.inboxPulse, reduceMotion: model.effectiveReduceMotion, complete: model.inbox.complete) + pulse: model.waitingPulse, reduceMotion: model.effectiveReduceMotion, complete: model.inbox.complete) Text("need you").font(.system(size: 10)) } - }.buttonStyle(.genHoverPlain()).instantTooltip("Every inbox entry that waits for your answer") + }.buttonStyle(.genHoverPlain()).instantTooltip("Every inbox item that waits for your answer") } if model.inbox.unread > 0 { Button { model.toggleInboxOverview(on: surface) } label: { @@ -90,7 +90,7 @@ struct AgentWidgetPreview: View { pulse: model.inboxPulse, reduceMotion: model.effectiveReduceMotion, complete: model.inbox.complete) Text("unread").font(.system(size: 10)) } - }.buttonStyle(.genHoverPlain()).instantTooltip("Every unread inbox entry, from all sessions") + }.buttonStyle(.genHoverPlain()).instantTooltip("Every unread inbox item, from all sessions") } if model.inboxCount == 0 { Text(model.snapshot == nil ? "Loading inbox" : model.inbox.complete ? "All caught up" : "Inbox status unavailable") @@ -150,7 +150,7 @@ struct AgentWidgetPreview: View { } label: { HStack { Image(systemName: "rectangle.stack").font(.system(size: 10)) - Text("Projects & sessions").font(.system(size: 10, weight: .medium)) + Text("Projects & sessions").font(.system(size: 11, weight: .medium)) Spacer() Text(Plural.count(model.previewList.count, "session")).font(.system(size: 11)).monospacedDigit() Image(systemName: "chevron.right").font(.system(size: 8, weight: .semibold)) @@ -169,6 +169,7 @@ struct AgentWidgetPreview: View { .frame(maxWidth: .infinity, maxHeight: .infinity, alignment: .top) // Like the row button's own popover: while it is open the edge panel must not collapse. .onChange(of: cmuxPanelKey) { _, key in model.cmuxPopoverOpen = key != nil } + .onDisappear { if cmuxPanelKey != nil { model.cmuxPopoverOpen = false } } } private var sortTooltip: String { @@ -263,7 +264,7 @@ struct WidgetSessionTimes: View { switch item.kind { case "answer": return ("answered", item.at, false) case "result": return ("finished", item.at, false) - default: return ("new entry", item.at, false) + default: return ("new item", item.at, false) } } } diff --git a/src/macos/GenesisKit/Sources/GenesisKit/Widgets/WidgetCoordinator.swift b/src/macos/GenesisKit/Sources/GenesisKit/Widgets/WidgetCoordinator.swift index 32ac9efc1e..1a43bf93ac 100644 --- a/src/macos/GenesisKit/Sources/GenesisKit/Widgets/WidgetCoordinator.swift +++ b/src/macos/GenesisKit/Sources/GenesisKit/Widgets/WidgetCoordinator.swift @@ -107,7 +107,8 @@ public final class WidgetCoordinator: NSObject, NSWindowDelegate { attachToDraft: { [weak model] item, recipient in guard let model else { throw CancellationError() } try await model.attachShelfItem(item.id, to: recipient.key) - }) + }, + undoSeconds: { [weak model] in model?.undoSeconds ?? WidgetModel.undoSeconds(nil) }) self.shelf = shelf let tasks = WidgetTasksStore( binaryPath: binaryPath, @@ -129,7 +130,8 @@ public final class WidgetCoordinator: NSObject, NSWindowDelegate { try await model.attachVoiceNote(note, to: session.key) self?.showShelfDraft(for: session) return "Added to \(session.title)'s draft. Nothing has been sent." - }) + }, + undoSeconds: { [weak model] in model?.undoSeconds ?? WidgetModel.undoSeconds(nil) }) voiceNotes.recipientPickerVisibilityChanged = { [weak self] visible in guard let self else { return } self.model.dialogOpen = visible || self.settings?.isVisible == true || self.mediaWindow?.isVisible == true diff --git a/src/macos/GenesisKit/Sources/GenesisKit/Widgets/WidgetHostView.swift b/src/macos/GenesisKit/Sources/GenesisKit/Widgets/WidgetHostView.swift index 2c1418aebc..9419d4549c 100644 --- a/src/macos/GenesisKit/Sources/GenesisKit/Widgets/WidgetHostView.swift +++ b/src/macos/GenesisKit/Sources/GenesisKit/Widgets/WidgetHostView.swift @@ -154,7 +154,7 @@ struct WidgetHostView: View { } if selected?.id == "agents", model.waitingCount > 0 { WidgetInboxCount(count: model.waitingCount, needsAnswer: true, - pulse: model.inboxPulse, reduceMotion: model.effectiveReduceMotion, complete: model.inbox.complete) + pulse: model.waitingPulse, reduceMotion: model.effectiveReduceMotion, complete: model.inbox.complete) } }.fixedSize() } @@ -427,7 +427,7 @@ struct WidgetHostView: View { .foregroundStyle(classicSide ? Color.white : registry.module(id)?.tint ?? .secondary) if id == "agents", model.waitingCount > 0 { WidgetInboxCount(count: model.waitingCount, needsAnswer: true, - pulse: model.inboxPulse, reduceMotion: model.effectiveReduceMotion, + pulse: model.waitingPulse, reduceMotion: model.effectiveReduceMotion, complete: model.inbox.complete, compact: true) } } diff --git a/src/macos/GenesisKit/Sources/GenesisKit/Widgets/WidgetInboxOverview.swift b/src/macos/GenesisKit/Sources/GenesisKit/Widgets/WidgetInboxOverview.swift index 645178e65a..2034def1ad 100644 --- a/src/macos/GenesisKit/Sources/GenesisKit/Widgets/WidgetInboxOverview.swift +++ b/src/macos/GenesisKit/Sources/GenesisKit/Widgets/WidgetInboxOverview.swift @@ -1,6 +1,6 @@ import SwiftUI -/// The inbox button's view: inbox entries from every session in one list, the waiting ones first. A row opens in place +/// The inbox button's view: inbox items from every session in one list, the waiting ones first. A row opens in place /// to show its session and the way to that session's whole inbox (with the entry open) and its cmux pane. struct WidgetInboxOverview: View { @ObservedObject var model: WidgetModel @@ -9,6 +9,8 @@ struct WidgetInboxOverview: View { @State private var openID: String? /// The row Up and Down chose (`WidgetModel.listKey`); Return opens or folds it. The pointer never moves it. @State private var focusID: String? + /// The option letter of the focused row's open decision that the keys chose; nil on the row itself. + @State private var focusedOption: String? @GenReduceMotion private var reduceMotion private var filter: String { filterPick ?? model.snapshot?.state.preferences.overviewFilter ?? "all" } @@ -22,7 +24,7 @@ struct WidgetInboxOverview: View { } } - /// Entries under their session ("session", the default) or one list (Widgets → Inbox, and the header button). + /// Items under their session ("session", the default) or one list (Widgets → Inbox, and the header button). private var grouped: Bool { (model.snapshot?.state.preferences.inboxGrouping ?? "session") == "session" } /// The rows in the order they are drawn: grouped under their sessions, or the one list. @@ -43,18 +45,50 @@ struct WidgetInboxOverview: View { } } - /// Up and Down move the highlight, Return opens or folds the highlighted row. No motion: keys repeat. + /// The option letters of `item` when it is an open decision that waits for an answer: "a", "b", …. + private func openOptions(_ item: WidgetInboxItem) -> [String] { + guard openID == item.id, item.kind == "decision", item.needsAnswer, let options = item.options else { return [] } + return options.indices.map(Self.letter) + } + + static func letter(_ index: Int) -> String { String(UnicodeScalar(97 + index).map(Character.init) ?? "?") } + + /// Up and Down move the highlight, and inside an open decision walk its options first; Return opens or folds the + /// highlighted row, or stages the highlighted option as a click does. No motion: keys repeat. private func handle(_ kind: WidgetListKey.Kind, rows: [WidgetInboxItem], proxy: ScrollViewProxy) { let current = focusID.flatMap { id in rows.firstIndex { $0.id == id } } + let options = current.map { openOptions(rows[$0]) } ?? [] + let option = focusedOption.flatMap { options.firstIndex(of: $0) } switch kind { case .open: guard let current else { return } - let id = rows[current].id + let item = rows[current] + if let option { + model.choose(item, option: options[option]) + return + } model.releaseAgentFit() - openID = openID == id ? nil : id + focusedOption = nil + openID = openID == item.id ? nil : item.id case .up, .down: - guard let target = WidgetModel.listTarget(current: current, step: kind == .up ? -1 : 1, count: rows.count) - else { return } + let step = kind == .up ? -1 : 1 + if !options.isEmpty { + switch WidgetModel.optionMove(focused: option, step: step, count: options.count) { + case .option(let index): + focusedOption = options[index] + proxy.scrollTo(rows[current ?? 0].id + "#" + options[index]) + return + case .cardRow: + focusedOption = nil + proxy.scrollTo(rows[current ?? 0].id) + return + case .leave: + break + } + } + guard let target = WidgetModel.listTarget(current: current, step: step, count: rows.count), + target != current else { return } + focusedOption = nil focusID = rows[target].id proxy.scrollTo(rows[target].id) } @@ -64,7 +98,7 @@ struct WidgetInboxOverview: View { VStack(alignment: .leading, spacing: 10) { HStack(spacing: 8) { GenSegmentedTabs( - "Inbox entries", + "Inbox items", items: [.init("all", "All"), .init("needs", "Needs you"), .init("unread", "Unread")], selection: Binding(get: { filter }, set: { value in model.holdAgentFit() @@ -72,7 +106,7 @@ struct WidgetInboxOverview: View { }), minSegmentWidth: 70, identifier: "widget.overview.filter") Spacer(minLength: 4) IconButton(systemName: grouped ? "list.bullet" : "rectangle.3.group", - tooltip: grouped ? "Show one list" : "Group the entries by session") { + tooltip: grouped ? "Show one list" : "Group the items by session") { model.holdAgentFit() model.updatePreferences(["inboxGrouping": .string(grouped ? "list" : "session")]) }.accessibilityLabel(grouped ? "Show one list" : "Group by session") @@ -107,7 +141,7 @@ struct WidgetInboxOverview: View { } } if model.inbox.truncated { - Text("The newest entries are shown. Open a session for its whole inbox.") + Text("The newest items are shown. Open a session for its whole inbox.") .font(.caption2).foregroundStyle(.tertiary) } } @@ -133,7 +167,7 @@ struct WidgetInboxOverview: View { AIProviderGlyph(meta: AIProviders.meta(for: session.target.provider), size: 14) } Text(session?.title ?? "Unknown session").font(.system(size: 11.5, weight: .semibold)).lineLimit(1) - Text([Plural.count(items.count, "entry", "entries"), waiting > 0 ? "\(waiting) need you" : nil] + Text([Plural.count(items.count, "item"), waiting > 0 ? "\(waiting) need you" : nil] .compactMap { $0 }.joined(separator: " · ")) .font(.system(size: 10)).foregroundStyle(waiting > 0 ? Color.orange : .secondary) Spacer(minLength: 0) @@ -151,7 +185,7 @@ struct WidgetInboxOverview: View { private static func summary(_ items: [WidgetInboxItem]) -> String { let waiting = items.filter(\.needsAnswer).count let sessions = Set(items.map(\.key)).count - return [Plural.count(items.count, "entry", "entries"), waiting > 0 ? "\(waiting) need your answer" : nil, + return [Plural.count(items.count, "item"), waiting > 0 ? "\(waiting) need your answer" : nil, "from " + Plural.count(sessions, "session")].compactMap { $0 }.joined(separator: " · ") } @@ -194,7 +228,7 @@ struct WidgetInboxOverview: View { if let count = item.count, count > 1 { Text(verbatim: "×\(count)").font(.system(size: 10, weight: .semibold)).monospacedDigit() .foregroundStyle(.secondary).padding(.top, 3) - .instantTooltip("\(count) entries like this one in this session") + .instantTooltip("\(count) items like this one in this session") } Image(systemName: "chevron.right").font(.system(size: 9, weight: .semibold)) .foregroundStyle(.secondary).rotationEffect(.degrees(open ? 90 : 0)).padding(.top, 4) @@ -229,39 +263,56 @@ struct WidgetInboxOverview: View { return WidgetStagedText.line(item, option: option) } + /// One option row of an open decision: the letter badge, the text, Staged, the recommended mark, the focus ring. + private func optionLabel(_ letter: String, title: String, picked: Bool, focused: Bool, recommended: Bool) -> some View { + let shape = RoundedRectangle(cornerRadius: 8) + let fill: Color = picked ? SessionPalette.blue.opacity(0.16) : .white.opacity(0.06) + let border: Color = picked ? SessionPalette.blue.opacity(0.7) : .clear + let ring: Color = focused ? Color(nsColor: .keyboardFocusIndicatorColor) : .clear + return HStack(alignment: .top, spacing: 8) { + Text(verbatim: letter).font(.system(size: 11, weight: .semibold, design: .rounded)) + .frame(width: 20, height: 20).background(.white.opacity(0.07), in: RoundedRectangle(cornerRadius: 5)) + Text(title).font(.system(size: 12)).fixedSize(horizontal: false, vertical: true).padding(.top, 2) + Spacer(minLength: 0) + if picked { + Text("Staged").font(.system(size: 10, weight: .semibold)).foregroundStyle(SessionPalette.blue) + .padding(.top, 3) + } + if recommended { Image(systemName: "sparkle").foregroundStyle(.orange) } + } + .padding(8).frame(maxWidth: .infinity, alignment: .leading) + .background(fill, in: shape) + .overlay(shape.strokeBorder(border, lineWidth: 1)) + // The keyboard's option: the same focus ring as the row's. + .overlay(shape.strokeBorder(ring, lineWidth: 2)) + .contentShape(shape) + } + + /// One option of an open decision as a button. A pick here always stages (`WidgetModel.choose(_:option:)` for an + /// inbox item), whatever the decision click setting says, so its words say Stage too. + private func optionButton(_ item: WidgetInboxItem, letter: String, title: String, picked: Bool) -> some View { + let focused = focusID == item.id && focusedOption == letter + let recommended = item.recommended == letter + let tooltip = LiveWidgetView.choiceTooltip(WidgetChoice(id: letter, title: title, recommended: recommended), + index: 10, stages: true, picked: picked) + return Button { model.choose(item, option: letter) } label: { + optionLabel(letter, title: title, picked: picked, focused: focused, recommended: recommended) + } + .buttonStyle(RowButtonStyle(cornerRadius: 8)) + .instantTooltip(tooltip) + .accessibilityLabel("Stage " + letter + ": " + title) + .accessibilityValue(picked ? "Staged" : "") + .accessibilityAddTraits(focused ? .isSelected : []) + .id(item.id + "#" + letter) + } + /// An open decision's options, each staging (or sending, by the setting) that pick from here. @ViewBuilder private func choices(_ item: WidgetInboxItem) -> some View { if item.kind == "decision", item.needsAnswer, let options = item.options, !options.isEmpty { let staged = model.stagedOption(item.sourceId, stored: item.draftOption) VStack(alignment: .leading, spacing: 6) { ForEach(Array(options.enumerated()), id: \.offset) { index, title in - let letter = String(UnicodeScalar(97 + index).map(Character.init) ?? "?") - let picked = staged == letter - Button { model.choose(item, option: letter) } label: { - HStack(alignment: .top, spacing: 8) { - Text(verbatim: letter).font(.system(size: 10.5, weight: .semibold, design: .rounded)) - .frame(width: 20, height: 20).background(.white.opacity(0.07), in: RoundedRectangle(cornerRadius: 5)) - Text(title).font(.system(size: 12)).fixedSize(horizontal: false, vertical: true).padding(.top, 2) - Spacer(minLength: 0) - if picked { - Text("Staged").font(.system(size: 10, weight: .semibold)).foregroundStyle(SessionPalette.blue) - .padding(.top, 3) - } - if item.recommended == letter { Image(systemName: "sparkle").foregroundStyle(.orange) } - } - .padding(8).frame(maxWidth: .infinity, alignment: .leading) - .background(picked ? SessionPalette.blue.opacity(0.16) : .white.opacity(0.06), - in: RoundedRectangle(cornerRadius: 8)) - .overlay(RoundedRectangle(cornerRadius: 8) - .strokeBorder(picked ? SessionPalette.blue.opacity(0.7) : .clear, lineWidth: 1)) - .contentShape(RoundedRectangle(cornerRadius: 8)) - } - .buttonStyle(RowButtonStyle(cornerRadius: 8)) - .instantTooltip(LiveWidgetView.choiceTooltip( - WidgetChoice(id: letter, title: title, recommended: item.recommended == letter), - index: 10, stages: model.decisionClickStages, picked: picked)) - .accessibilityLabel((model.decisionClickStages ? "Stage " : "Answer ") + letter + ": " + title) - .accessibilityValue(picked ? "Staged" : "") + optionButton(item, letter: Self.letter(index), title: title, picked: staged == Self.letter(index)) } } } diff --git a/src/macos/GenesisKit/Sources/GenesisKit/Widgets/WidgetKeymap.swift b/src/macos/GenesisKit/Sources/GenesisKit/Widgets/WidgetKeymap.swift index 21c21f34ba..1b80cefc6a 100644 --- a/src/macos/GenesisKit/Sources/GenesisKit/Widgets/WidgetKeymap.swift +++ b/src/macos/GenesisKit/Sources/GenesisKit/Widgets/WidgetKeymap.swift @@ -118,9 +118,9 @@ public enum WidgetKeymap { public static func label(_ id: String) -> String { shared.label(for: id) ?? "" } - /// "1–3 answer · ↑/↓ move · J/K session · ⎋ close", with the keys in force. + /// "1 to 3 answer · ↑/↓ move · J/K session · ⎋ close", with the keys in force. No dash in a visible string. public static var agentFooter: String { - "\(label(choiceID(1)))–\(label(choiceID(3))) answer · \(label(listUp))/\(label(listDown)) move · " + "\(label(choiceID(1))) to \(label(choiceID(3))) answer · \(label(listUp))/\(label(listDown)) move · " + "\(label(previous))/\(label(next)) session · \(label(collapse)) close" } } diff --git a/src/macos/GenesisKit/Sources/GenesisKit/Widgets/WidgetModel+Staging.swift b/src/macos/GenesisKit/Sources/GenesisKit/Widgets/WidgetModel+Staging.swift index fab7904909..ead99d2289 100644 --- a/src/macos/GenesisKit/Sources/GenesisKit/Widgets/WidgetModel+Staging.swift +++ b/src/macos/GenesisKit/Sources/GenesisKit/Widgets/WidgetModel+Staging.swift @@ -245,6 +245,18 @@ extension WidgetModel { } } +/// How long an Undo row holds a removed item back (Remove from shelf, Discard of a voice note): as long as a note with +/// a button stays (`noteSeconds`, at least 10 s), so every Undo in the widget lasts the same. With notes set to stay +/// until closed (`noteSeconds` 0), the row stays until it is closed, but 60 s at most, since the delete must happen. +public extension WidgetModel { + nonisolated static func undoSeconds(_ preferences: WidgetPreferences?) -> Double { + noteSeconds(preferences, hasAction: true) ?? 60 + } + + /// `undoSeconds` for the current preferences; the coordinator hands this to the shelf and voice-notes stores. + var undoSeconds: Double { Self.undoSeconds(snapshot?.state.preferences) } +} + /// A button beside the widget's notice line. public struct WidgetNoticeAction { public let title: String diff --git a/src/macos/GenesisKit/Sources/GenesisKit/Widgets/WidgetModel.swift b/src/macos/GenesisKit/Sources/GenesisKit/Widgets/WidgetModel.swift index 7174ceb166..5b7f2a1dc5 100644 --- a/src/macos/GenesisKit/Sources/GenesisKit/Widgets/WidgetModel.swift +++ b/src/macos/GenesisKit/Sources/GenesisKit/Widgets/WidgetModel.swift @@ -40,6 +40,9 @@ public final class WidgetModel: ObservableObject { public private(set) var previewList: [WidgetSession] = [] @Published public private(set) var inbox = WidgetInboxSummary.empty @Published public private(set) var inboxPulse = 0 + /// Bumped only when an item that waits for an answer arrives: the rail badge counts those (`waitingCount`), so it + /// pulses only when its own number rises, never for an unread answer it does not count. + @Published public private(set) var waitingPulse = 0 private var inboxSessions: [String: WidgetInboxSession] = [:] private var inboxObserved = false private var inboxStartedAt = 0.0 @@ -973,6 +976,7 @@ public final class WidgetModel: ObservableObject { } inboxObserved = inboxObserved || value.complete if arrived { inboxPulse += 1 } + if arrivals.contains(where: \.needsAnswer) { waitingPulse += 1 } if !arrivals.isEmpty { inboxArrived?(arrivals) } for key in arrivedKeys { inboxSessionPulses[key, default: 0] += 1 } if inboxWatermarks.count > 4096 { @@ -1295,6 +1299,7 @@ public final class WidgetModel: ObservableObject { if openingInbox?.key != key { openingInbox = nil } inboxOverview = false selectedKey = key + choiceFocus = nil lastSelected = snapshot?.sessions.first { $0.key == key } if expanded != nil { refreshContext() } selectedCardID = nil @@ -1342,6 +1347,9 @@ public final class WidgetModel: ObservableObject { @Published public var inboxFilter = "all" /// Set once the reader picks a filter; until then the filter follows the `inboxFilter` setting. public var inboxFilterPicked = false + /// The option of the open decision card that Up and Down chose; nil while the card row itself is chosen. Kept + /// with its card, because option ids ("a", "b") repeat across decisions. + @Published public var choiceFocus: WidgetChoiceFocus? /// The session list's rows in the order it draws them (its search, pins and open groups applied), written by /// the list as it changes. Empty: the list has not said, and the keys leave it alone. public var sessionListOrder: [String] = [] @@ -1378,24 +1386,63 @@ public final class WidgetModel: ObservableObject { return min(max(current + step, 0), count - 1) } + /// Where Up or Down goes among an open decision's options: to an option, back to the card row (Up from the first + /// option), or out of the card (Up from the card row, Down from the last option). + enum OptionMove: Equatable { case option(Int), cardRow, leave } + + nonisolated static func optionMove(focused: Int?, step: Int, count: Int) -> OptionMove { + guard count > 0 else { return .leave } + guard let focused else { return step < 0 ? .leave : .option(0) } + let target = focused + step + if target < 0 { return .cardRow } + return target < count ? .option(target) : .leave + } + /// A session's inbox, as drawn: the filter's rows, newest first. Up and Down open the row above or below (the - /// inbox shows one open row); Return folds or opens the current one. No motion: the keys repeat. + /// inbox shows one open row) and, in an open decision, walk its options first; Return folds or opens the current + /// row, or picks the chosen option the way a click does. No motion: the keys repeat. private func moveCard(_ kind: WidgetListKey.Kind) -> Bool { let shown = Array(LiveWidgetView.filtered(cards, by: inboxFilter).reversed()) guard !shown.isEmpty else { return false } let current = card.flatMap { open in shown.firstIndex { $0.id == open.id } } + // The open decision's options, when the row the keys stand on is that decision. + let decision = current.flatMap { index in answerCard.flatMap { $0.id == shown[index].id ? $0 : nil } } + let focused = decision.flatMap { open in + choiceFocus?.cardID == open.id ? open.choices.firstIndex(where: { $0.id == choiceFocus?.choiceID }) : nil + } releaseAgentFit() switch kind { case .open: guard let current else { return false } - if foldedCardIDs.contains(shown[current].id) { + if let decision, let focused { + guard decisionClickStages else { + // Return never sends (ux checklist, Keyboard); with "Send at once" the number key does. + postNote("Return does not send an answer. Press \(focused + 1) to send this one.") + return true + } + choose(decision, option: decision.choices[focused].id) + } else if foldedCardIDs.contains(shown[current].id) { openCard(shown[current]) } else { foldedCardIDs.insert(shown[current].id) } case .up, .down: - guard let target = Self.listTarget(current: current, step: kind == .up ? -1 : 1, count: shown.count), + let step = kind == .up ? -1 : 1 + if let decision { + switch Self.optionMove(focused: focused, step: step, count: decision.choices.count) { + case .option(let index): + choiceFocus = WidgetChoiceFocus(cardID: decision.id, choiceID: decision.choices[index].id) + return true + case .cardRow: + choiceFocus = nil + return true + case .leave: + break + } + } + guard let target = Self.listTarget(current: current, step: step, count: shown.count), target != current || foldedCardIDs.contains(shown[target].id) else { return true } + choiceFocus = nil openCard(shown[target]) } return true @@ -2518,3 +2565,9 @@ public struct WidgetCmuxRequest: Equatable, Sendable { public let key: String public let scope: String? } + +/// The option of a decision card that the arrow keys chose (`WidgetModel.choiceFocus`). +public struct WidgetChoiceFocus: Equatable, Sendable { + public let cardID: String + public let choiceID: String +} diff --git a/src/macos/GenesisKit/Sources/GenesisKit/Widgets/WidgetSessionBrowser.swift b/src/macos/GenesisKit/Sources/GenesisKit/Widgets/WidgetSessionBrowser.swift index 3d25d7e029..24a73333a2 100644 --- a/src/macos/GenesisKit/Sources/GenesisKit/Widgets/WidgetSessionBrowser.swift +++ b/src/macos/GenesisKit/Sources/GenesisKit/Widgets/WidgetSessionBrowser.swift @@ -3,6 +3,9 @@ import SwiftUI public struct WidgetSessionBrowser: View { @ObservedObject private var model: WidgetModel private let onOpen: ((WidgetSession) -> Void)? + /// True only for the panel's Sessions tab: the session chooser popover and the settings page share the model's + /// `sessionListOrder` and must not overwrite or clear the order of the list still on screen. + private let reportsOrder: Bool @State private var query = "" @State private var onlyPinned = false @State private var limit = 40 @@ -10,8 +13,9 @@ public struct WidgetSessionBrowser: View { @State private var collapsed = Set<String>() @GenReduceMotion private var reduceMotion - public init(model: WidgetModel, onOpen: ((WidgetSession) -> Void)? = nil) { + public init(model: WidgetModel, reportsOrder: Bool = false, onOpen: ((WidgetSession) -> Void)? = nil) { self.model = model + self.reportsOrder = reportsOrder self.onOpen = onOpen } @@ -78,8 +82,8 @@ public struct WidgetSessionBrowser: View { } .onChange(of: query) { _, _ in limit = 40 } .onChange(of: onlyPinned) { _, _ in limit = 40 } - .onChange(of: order, initial: true) { _, keys in model.sessionListOrder = keys } - .onDisappear { model.sessionListOrder = [] } + .onChange(of: order, initial: true) { _, keys in if reportsOrder { model.sessionListOrder = keys } } + .onDisappear { if reportsOrder { model.sessionListOrder = [] } } } private var projectFilter: some View { diff --git a/src/macos/GenesisKit/Tests/GenesisKitTests/GenesisKitTests.swift b/src/macos/GenesisKit/Tests/GenesisKitTests/GenesisKitTests.swift index e086a44c43..affde342f4 100644 --- a/src/macos/GenesisKit/Tests/GenesisKitTests/GenesisKitTests.swift +++ b/src/macos/GenesisKit/Tests/GenesisKitTests/GenesisKitTests.swift @@ -450,7 +450,7 @@ final class WidgetShelfStoreTests: XCTestCase { let store = WidgetShelfStore(request: { args, _ in calls.append(args) return listed - }, announce: { text, _ in spoken.append(text) }, removalUndoDelay: .milliseconds(100)) + }, announce: { text, _ in spoken.append(text) }, undoSeconds: { 0.1 }) defer { store.stop() } store.refresh() guard try await pollUntil("the list is read", { store.items.count == 1 }) else { return } @@ -1255,6 +1255,19 @@ final class WidgetInboxNotificationTests: XCTestCase { XCTAssertEqual(model.inboxCount, 1) } + /// The rail badge counts only what waits for an answer, so a new unread answer must not pulse it (judge G7). + func testTheWaitingBadgePulsesOnlyForANewWaitingItem() { + let model = model() + defer { model.stop() } + let now = Date().timeIntervalSince1970 * 1000 + model.updateInbox(summary(item("old", at: now - 1000))) + model.updateInbox(summary(item("answer", at: now + 100_000))) + XCTAssertEqual(model.inboxPulse, 1, "the hover list's unread count still pulses for the answer") + XCTAssertEqual(model.waitingPulse, 0, "the waiting badge does not") + model.updateInbox(summary(item("question", at: now + 200_000, pending: true))) + XCTAssertEqual(model.waitingPulse, 1, "a new question raises the waiting count and pulses the badge") + } + func testIncompleteSnapshotDoesNotSwallowTheArrivalItCannotRaise() { let model = model() defer { model.stop() } diff --git a/src/macos/GenesisKit/Tests/GenesisKitTests/ToolsLineStreamTests.swift b/src/macos/GenesisKit/Tests/GenesisKitTests/ToolsLineStreamTests.swift index 14284bd614..3ac938700d 100644 --- a/src/macos/GenesisKit/Tests/GenesisKitTests/ToolsLineStreamTests.swift +++ b/src/macos/GenesisKit/Tests/GenesisKitTests/ToolsLineStreamTests.swift @@ -429,13 +429,13 @@ final class WidgetVoiceNotesStoreTests: XCTestCase { presentPermission: @escaping @MainActor (PermissionNeed) -> Void = { _ in }, recipients: [WidgetSession]? = nil, finish: @escaping () -> Void = {}, launcher: String = "/fixture/Preview.app/Contents/MacOS/launcher", - discardDelay: Duration = .seconds(10)) -> WidgetVoiceNotesStore { + undoSeconds: Double = 10) -> WidgetVoiceNotesStore { WidgetVoiceNotesStore(micLauncher: launcher, request: request, execute: execute, finishCapture: finish, cancelCommand: {}, acquireAudio: acquire, settings: { WidgetVoiceNoteSettings(provider: "fixture", model: "test-model", language: "en") }, sessions: { recipients ?? [self.recipient] }, attachDraft: attach, readMicrophonePermission: permission, requestMicrophonePermission: prompt, activateForMicrophone: activate, presentMicrophonePermission: presentPermission, - discardUndoDelay: discardDelay) + undoSeconds: { undoSeconds }) } /// Discard takes the note off the list at once and deletes it only after the Undo time; Undo brings it back @@ -445,7 +445,7 @@ final class WidgetVoiceNotesStoreTests: XCTestCase { let store = make(request: { args in calls.append(args) return try self.data(["revision": 0, "notes": [self.note()], "statePath": "/fixture/widget/voice-notes/notes.json"]) - }, discardDelay: .milliseconds(100)) + }, undoSeconds: 0.1) defer { store.stop() } store.refresh() await store.waitForRefresh() diff --git a/src/macos/GenesisKit/Tests/GenesisKitTests/WidgetInboxTasksPolishTests.swift b/src/macos/GenesisKit/Tests/GenesisKitTests/WidgetInboxTasksPolishTests.swift index 35d8f1e010..285a231f36 100644 --- a/src/macos/GenesisKit/Tests/GenesisKitTests/WidgetInboxTasksPolishTests.swift +++ b/src/macos/GenesisKit/Tests/GenesisKitTests/WidgetInboxTasksPolishTests.swift @@ -755,19 +755,19 @@ final class WidgetInboxPolishTests: XCTestCase { let delivered = WidgetActivityEvent(id: "e1", sourceId: "d_4_s", at: 1, title: "Decision delivery", body: "sent · cmux") XCTAssertEqual(LiveWidgetView.activityLine(delivered, number: 4, provider: "claude"), - .init(title: "Answer delivered", detail: "DECISION 4 · Sent · through cmux")) + .init(title: "Answer delivered", detail: "Decision 4 · Sent · through cmux")) XCTAssertEqual(LiveWidgetView.activityLine(delivered, number: nil, provider: "claude").detail, "Claude · Sent · through cmux", "without the card, the provider's name, never d_4_s") let failed = WidgetActivityEvent(id: "e2", sourceId: "d_4_s", at: 1, title: "Decision send_failed", body: "answered · something-new") XCTAssertEqual(LiveWidgetView.activityLine(failed, number: 4, provider: "codex"), - .init(title: "Answer not delivered", detail: "DECISION 4 · Waiting to send"), + .init(title: "Answer not delivered", detail: "Decision 4 · Waiting to send"), "a value the widget has no words for is left out") let decision = WidgetCard(id: "decision:d_4_s", kind: "decision", sessionKey: "k", sourceId: "d_4_s", at: 1, title: "T", body: "", status: "open", revision: 1, number: 4, choices: [], attachments: [], refs: [], read: true) - XCTAssertEqual(WidgetLedgerUndo.note(decision, state: "dismissed"), "DECISION 4 dismissed.") + XCTAssertEqual(WidgetLedgerUndo.note(decision, state: "dismissed"), "Decision 4 dismissed.") let task = WidgetCard(id: "decision:t_2_s", kind: "todo", sessionKey: "k", sourceId: "t_2_s", at: 1, title: "T", body: "", status: "open", revision: 1, number: 2, choices: [], attachments: [], refs: [], read: true) @@ -973,7 +973,7 @@ final class WidgetAnswerTargetTests: XCTestCase { XCTAssertEqual(undo.applied(card).status, "dismissed", "the card shows the change at once") XCTAssertFalse(undo.applied(card).needsAnswer) XCTAssertNil(model.answerCard, "folded, the number keys no longer answer it") - XCTAssertEqual(model.notice, "DECISION 1 dismissed.") + XCTAssertEqual(model.notice, "Decision 1 dismissed.") XCTAssertEqual(model.noticeAction?.title, "Undo") model.noticeAction?.run() XCTAssertEqual(undo.applied(card).status, "open") @@ -1261,6 +1261,24 @@ final class WidgetStagingTests: XCTestCase { XCTAssertTrue(model.stagedLeavePrompt, "still staged, so the next close asks again") } + /// Every Undo row (Remove from shelf, Discard of a voice note) lasts as long as a note with a button; notes that + /// stay until closed hold the item 60 s at most (judge round F4). + func testUndoRowsLastAsLongAsANoteWithAButton() throws { + func prefs(_ json: String) throws -> WidgetPreferences { + let base = #"{"excludedKeys":[],"projects":[],"sessions":[],"showChanges":true,"placement":"side","side":"right","quietSeconds":15,"voiceProvider":"xai","voiceLanguage":"","# + return try JSONDecoder().decode(WidgetPreferences.self, from: Data((base + json.dropFirst()).utf8)) + } + XCTAssertEqual(WidgetModel.undoSeconds(nil), 10, "the default note time is 6 s, so a note with Undo stays 10 s") + XCTAssertEqual(WidgetModel.undoSeconds(try prefs(#"{"noteSeconds":3}"#)), 10) + XCTAssertEqual(WidgetModel.undoSeconds(try prefs(#"{"noteSeconds":20}"#)), 20) + XCTAssertEqual(WidgetModel.undoSeconds(try prefs(#"{"noteSeconds":0}"#)), 60, "until closed, at most 60 s") + for json in [#"{"noteSeconds":6}"#, #"{"noteSeconds":3}"#, #"{"noteSeconds":20}"#] { + let preferences = try prefs(json) + XCTAssertEqual(WidgetModel.undoSeconds(preferences), WidgetModel.noteSeconds(preferences, hasAction: true), + "the same time as the note's own Undo or Send again") + } + } + /// The hub's reasons for a send no session took read as sentences; the raw text stays for Details (P24). func testTheHubsReasonsReadAsSentences() { XCTAssertEqual(WidgetDecisionSendOutcome.sentence( diff --git a/src/macos/GenesisKit/Tests/GenesisKitTests/WidgetKeymapTests.swift b/src/macos/GenesisKit/Tests/GenesisKitTests/WidgetKeymapTests.swift index 08891b4565..9e4e420e17 100644 --- a/src/macos/GenesisKit/Tests/GenesisKitTests/WidgetKeymapTests.swift +++ b/src/macos/GenesisKit/Tests/GenesisKitTests/WidgetKeymapTests.swift @@ -69,6 +69,28 @@ final class WidgetKeymapTests: XCTestCase { XCTAssertEqual(press(36, list: true, editingText: true), .pass, "Return in the composer is the composer's") } + /// With a decision card open, Up and Down still reach the list (which walks the card's options first), and the + /// digits still answer (judge round, R7). + func testArrowsWalkAnOpenDecisionsOptionsAndDigitsStillAnswer() { + let open = card() + let context = WidgetCoordinator.PanelKeyContext(previewOpen: false, agents: true, editingText: false, + answering: open, list: true) + func press(_ keyCode: UInt16, _ characters: String = "") -> WidgetCoordinator.PanelKey { + WidgetCoordinator.panelKey(keyCode: keyCode, characters: characters, context: context, keymap: keys()) + } + XCTAssertEqual(press(125), .list(.down), "Down goes to the model, which moves among the options") + XCTAssertEqual(press(126), .list(.up)) + XCTAssertEqual(press(0x13, "ě"), .choose("drop"), "the number row still answers option 2") + + // The model's step through three options: in from the card row, down to the last, then out of the card. + XCTAssertEqual(WidgetModel.optionMove(focused: nil, step: 1, count: 3), .option(0)) + XCTAssertEqual(WidgetModel.optionMove(focused: 0, step: 1, count: 3), .option(1)) + XCTAssertEqual(WidgetModel.optionMove(focused: 2, step: 1, count: 3), .leave, "past the last option: next row") + XCTAssertEqual(WidgetModel.optionMove(focused: 0, step: -1, count: 3), .cardRow, "up from the first: the card") + XCTAssertEqual(WidgetModel.optionMove(focused: nil, step: -1, count: 3), .leave, "up from the card: row above") + XCTAssertEqual(WidgetModel.optionMove(focused: nil, step: 1, count: 0), .leave, "no options: the rows move") + } + /// ⌘H hides an app on every Mac; Open Hub moved to ⌘O, and J and K say what they move (R8, R9). func testOpenHubLeavesCommandHToHideAndJAndKNameTheSession() throws { let keys = Keymap(library: WidgetKeymap.library, store: nil, layout: TableKeyLayout.us) From e6247b1cb1edb68bc873f050fb0ef4226e986fee Mon Sep 17 00:00:00 2001 From: Martin <admin@phpcode.eu> Date: Sun, 25 Oct 2026 11:28:48 +0100 Subject: [PATCH 05/18] fix(GenesisKit, permissions): review round on the UX fixes: a granted permission is rechecked for 10 s after the app returns, the recording picker drops its own ink style, and a test proves Return never allows a route approval --- .../Recording/RecordingControlsView.swift | 6 ++-- .../Recording/RecordingTargetPicker.swift | 25 ++++++--------- .../Permissions/PermissionCenter.swift | 5 +-- .../Permissions/SystemPermissions.swift | 10 ++++++ .../GenesisKitTests/PermissionTests.swift | 11 +++++++ .../Sources/Host/App/GenesisToolsApp.swift | 5 +-- .../Sources/Host/App/PermissionsModel.swift | 14 +++++++- .../GenesisTools/Sources/Host/App/Views.swift | 8 ++--- .../Tests/PermissionsModelTests.swift | 32 +++++++++++++++++++ .../GenesisTools/Tests/VaultUnlockTests.swift | 23 +++++++++++++ 10 files changed, 112 insertions(+), 27 deletions(-) diff --git a/src/macos/GenesisKit/Sources/GenesisKit/Recording/RecordingControlsView.swift b/src/macos/GenesisKit/Sources/GenesisKit/Recording/RecordingControlsView.swift index 7fc07ce6ad..a15ec22f3f 100644 --- a/src/macos/GenesisKit/Sources/GenesisKit/Recording/RecordingControlsView.swift +++ b/src/macos/GenesisKit/Sources/GenesisKit/Recording/RecordingControlsView.swift @@ -238,7 +238,7 @@ public struct RecordingControlsView: View { } private var captionColor: Color { - state.phase == .paused || remainingMs != nil ? .orange : RecordingInk.readable + state.phase == .paused || remainingMs != nil ? .orange : SessionPalette.secondary } private var accessibilityStatus: String { @@ -385,7 +385,7 @@ public struct RecordingControlsView: View { private func preparingRow(_ text: String) -> some View { HStack(alignment: .top, spacing: 6) { Image(systemName: "hourglass").font(.system(size: 10)).foregroundStyle(.secondary) - Text(text).font(.system(size: 11)).foregroundStyle(RecordingInk.readable) + Text(text).font(.system(size: 11)).foregroundStyle(SessionPalette.secondary) .lineLimit(3).fixedSize(horizontal: false, vertical: true) .frame(maxWidth: 420, alignment: .leading) Spacer(minLength: 0) @@ -398,7 +398,7 @@ public struct RecordingControlsView: View { private func noticeRow(_ notice: String) -> some View { HStack(alignment: .top, spacing: 6) { Image(systemName: "exclamationmark.triangle.fill").font(.system(size: 10)).foregroundStyle(.orange) - Text(notice).font(.system(size: 11)).foregroundStyle(RecordingInk.readable) + Text(notice).font(.system(size: 11)).foregroundStyle(SessionPalette.secondary) .lineLimit(3).fixedSize(horizontal: false, vertical: true) .textSelection(.enabled) .frame(maxWidth: 420, alignment: .leading) diff --git a/src/macos/GenesisKit/Sources/GenesisKit/Recording/RecordingTargetPicker.swift b/src/macos/GenesisKit/Sources/GenesisKit/Recording/RecordingTargetPicker.swift index 3f24db2039..315dd0b2d8 100644 --- a/src/macos/GenesisKit/Sources/GenesisKit/Recording/RecordingTargetPicker.swift +++ b/src/macos/GenesisKit/Sources/GenesisKit/Recording/RecordingTargetPicker.swift @@ -22,11 +22,6 @@ public enum RecordingPickerTab: String, CaseIterable, Hashable, Sendable { } } -/// Text the person must read on the dark panels: 11 pt or more at about 62% white (above 4.5:1), never tertiary grey. -enum RecordingInk { - static let readable = Color.white.opacity(0.62) -} - extension RecordingPickerTab { /// The chord that switches to this tab, shown in the tab's tooltip. var chord: String { @@ -222,7 +217,7 @@ public struct RecordingTargetPicker: View { private var filterField: some View { HStack(spacing: 6) { - Image(systemName: "magnifyingglass").font(.system(size: 11)).foregroundStyle(RecordingInk.readable) + Image(systemName: "magnifyingglass").font(.system(size: 11)).foregroundStyle(SessionPalette.secondary) TextField("Filter \(tab.title.lowercased())", text: $query) .textFieldStyle(.plain) .font(.system(size: 12)) @@ -241,11 +236,11 @@ public struct RecordingTargetPicker: View { VStack(spacing: 6) { HStack(spacing: 8) { ProgressView().controlSize(.small) - Text("Looking for windows and displays…").font(.system(size: 12)).foregroundStyle(RecordingInk.readable) + Text("Looking for windows and displays…").font(.system(size: 12)).foregroundStyle(SessionPalette.secondary) } if slowLoad { Text("The first time after an update, the recorder is built first. This can take up to a minute.") - .font(.system(size: 11)).foregroundStyle(RecordingInk.readable) + .font(.system(size: 11)).foregroundStyle(SessionPalette.secondary) .multilineTextAlignment(.center).fixedSize(horizontal: false, vertical: true) .frame(maxWidth: 300) } @@ -257,7 +252,7 @@ public struct RecordingTargetPicker: View { } case let .failed(message): VStack(spacing: 8) { - Text(message).font(.system(size: 12)).foregroundStyle(RecordingInk.readable) + Text(message).font(.system(size: 12)).foregroundStyle(SessionPalette.secondary) .multilineTextAlignment(.center).fixedSize(horizontal: false, vertical: true) Button("Try again", action: onRetry).buttonStyle(.genHover()) } @@ -274,7 +269,7 @@ public struct RecordingTargetPicker: View { .frame(maxWidth: .infinity, minHeight: 120) } else { Text("Nothing matches “\(query)”.") - .font(.system(size: 12)).foregroundStyle(RecordingInk.readable) + .font(.system(size: 12)).foregroundStyle(SessionPalette.secondary) .frame(maxWidth: .infinity, minHeight: 80) } } else { @@ -311,7 +306,7 @@ public struct RecordingTargetPicker: View { .background(.white.opacity(0.12), in: Capsule()) } Spacer(minLength: 8) - Text(verbatim: row.detail).font(.system(size: 11).monospacedDigit()).foregroundStyle(RecordingInk.readable) + Text(verbatim: row.detail).font(.system(size: 11).monospacedDigit()).foregroundStyle(SessionPalette.secondary) } .padding(.leading, row.symbol == "macwindow" ? 22 : 6) .padding(.trailing, 8) @@ -337,8 +332,8 @@ public struct RecordingTargetPicker: View { } else { HStack(spacing: 6) { icon(row).frame(width: 14, height: 14) - Text(row.title).font(.system(size: 11, weight: .semibold)).foregroundStyle(RecordingInk.readable) - Text(verbatim: row.detail).font(.system(size: 11)).foregroundStyle(RecordingInk.readable) + Text(row.title).font(.system(size: 11, weight: .semibold)).foregroundStyle(SessionPalette.secondary) + Text(verbatim: row.detail).font(.system(size: 11)).foregroundStyle(SessionPalette.secondary) } .padding(.horizontal, 6) .padding(.top, 6) @@ -351,7 +346,7 @@ public struct RecordingTargetPicker: View { if let image = RecordingAppIcon.image(pid: row.pid) { Image(nsImage: image).resizable().interpolation(.high) } else { - Image(systemName: row.symbol).font(.system(size: 12)).foregroundStyle(RecordingInk.readable) + Image(systemName: row.symbol).font(.system(size: 12)).foregroundStyle(SessionPalette.secondary) } } @@ -362,7 +357,7 @@ public struct RecordingTargetPicker: View { Spacer(minLength: 0) } .font(.system(size: 11)) - .foregroundStyle(RecordingInk.readable) + .foregroundStyle(SessionPalette.secondary) } /// What VoiceOver reads for a row: the action, then the badge and the details the row shows. diff --git a/src/macos/GenesisKit/Sources/GenesisKitBase/Permissions/PermissionCenter.swift b/src/macos/GenesisKit/Sources/GenesisKitBase/Permissions/PermissionCenter.swift index bd0c3d9c4e..795bc73a52 100644 --- a/src/macos/GenesisKit/Sources/GenesisKitBase/Permissions/PermissionCenter.swift +++ b/src/macos/GenesisKit/Sources/GenesisKitBase/Permissions/PermissionCenter.swift @@ -143,9 +143,10 @@ public final class PermissionCenter { private var activationObserver: ObservationToken? private var lastGrantRecheck: Date? - /// At most every 10 s: activating the app is frequent, and an Automation probe runs AppleScript. + /// At most every `PermissionAccess.activationRecheckInterval` (10 s): activating the app is frequent, and an + /// Automation probe runs AppleScript. private func recheckGrantedProbesOnActivation() { - if let lastGrantRecheck, Date().timeIntervalSince(lastGrantRecheck) < 10 { return } + guard PermissionAccess.activationRecheckDue(last: lastGrantRecheck) else { return } lastGrantRecheck = Date() Task { await recheckGrantedProbes() } } diff --git a/src/macos/GenesisKit/Sources/GenesisKitBase/Permissions/SystemPermissions.swift b/src/macos/GenesisKit/Sources/GenesisKitBase/Permissions/SystemPermissions.swift index cc328da69d..eea39876d7 100644 --- a/src/macos/GenesisKit/Sources/GenesisKitBase/Permissions/SystemPermissions.swift +++ b/src/macos/GenesisKit/Sources/GenesisKitBase/Permissions/SystemPermissions.swift @@ -315,6 +315,16 @@ public struct PermissionAccess: Sendable { /// The real system and the simulation in this app's defaults. public static let live = PermissionAccess(system: SystemPermissions(), simulation: { PermissionSimulation.current() }) + /// The shortest time between two rechecks of the answered probes that an app activation starts. Activating the app + /// is frequent, and an Automation probe runs AppleScript. `PermissionCenter` and the settings window both use it. + public static let activationRecheckInterval: TimeInterval = 10 + + /// True when an activation may recheck the answered probes: never yet, or `activationRecheckInterval` since the last. + public static func activationRecheckDue(last: Date?, now: Date = Date()) -> Bool { + guard let last else { return true } + return now.timeIntervalSince(last) >= activationRecheckInterval + } + public let system: any PermissionSystem private let simulation: @Sendable () -> PermissionSimulation private let lifted = LiftedKinds() diff --git a/src/macos/GenesisKit/Tests/GenesisKitTests/PermissionTests.swift b/src/macos/GenesisKit/Tests/GenesisKitTests/PermissionTests.swift index bcabceb7ca..ebb75c7f6f 100644 --- a/src/macos/GenesisKit/Tests/GenesisKitTests/PermissionTests.swift +++ b/src/macos/GenesisKit/Tests/GenesisKitTests/PermissionTests.swift @@ -1143,3 +1143,14 @@ final class PermissionSettingsGuideTests: XCTestCase { } } } + +/// An app activation rechecks the answered probes at most every 10 s, in the permission center and the settings window. +final class ActivationRecheckLimitTests: XCTestCase { + func testTheRecheckIsDueFirstAndThenAfter10Seconds() { + let last = Date(timeIntervalSince1970: 1_000) + XCTAssertEqual(PermissionAccess.activationRecheckInterval, 10) + XCTAssertTrue(PermissionAccess.activationRecheckDue(last: nil, now: last)) + XCTAssertFalse(PermissionAccess.activationRecheckDue(last: last, now: last.addingTimeInterval(9.9))) + XCTAssertTrue(PermissionAccess.activationRecheckDue(last: last, now: last.addingTimeInterval(10))) + } +} diff --git a/src/macos/GenesisTools/Sources/Host/App/GenesisToolsApp.swift b/src/macos/GenesisTools/Sources/Host/App/GenesisToolsApp.swift index 3a518e32df..9fe6dc3943 100644 --- a/src/macos/GenesisTools/Sources/Host/App/GenesisToolsApp.swift +++ b/src/macos/GenesisTools/Sources/Host/App/GenesisToolsApp.swift @@ -154,7 +154,8 @@ final class GenesisAppDelegate: NSObject, NSApplicationDelegate { let window = NSWindow(contentViewController: controller) window.title = "GenesisTools" window.styleMask = [.titled, .closable, .miniaturizable, .resizable] - window.setContentSize(NSSize(width: 860, height: 620)) + // 660, not 620: the line under the table about Full Disk Access and Accessibility (Q9) took the last row. + window.setContentSize(NSSize(width: 860, height: 660)) window.center() window.makeKeyAndOrderFront(nil) self.window = window @@ -232,7 +233,7 @@ struct RootView: View { } // A grant changed in System Settings shows as soon as the user comes back, without Refresh. .onReceive(NotificationCenter.default.publisher(for: NSApplication.didBecomeActiveNotification)) { _ in - permissions.refresh() + permissions.refreshOnActivation() } } diff --git a/src/macos/GenesisTools/Sources/Host/App/PermissionsModel.swift b/src/macos/GenesisTools/Sources/Host/App/PermissionsModel.swift index 9f860c7b27..d8a8e556e7 100644 --- a/src/macos/GenesisTools/Sources/Host/App/PermissionsModel.swift +++ b/src/macos/GenesisTools/Sources/Host/App/PermissionsModel.swift @@ -59,6 +59,8 @@ final class PermissionsModel: ObservableObject { /// The grant `lastMessage` is about: once a refresh finds it no longer refused, the message goes. private var messageKind: PermissionKind? private var rechecking = false + /// When the answered probes last ran again; an activation reruns them at most every 10 s. + private var lastProbeRecheck: Date? private static let usedBy: [(PermissionKind, String)] = [ (.calendars, "tools macos calendar, tools todo sync"), @@ -93,6 +95,15 @@ final class PermissionsModel: ObservableObject { recheckProbes() } + /// The app became active (the user may be back from System Settings). The rows are read again every time; the + /// answered probes run again at most every `PermissionAccess.activationRecheckInterval`, as the permission center + /// does, since activating is frequent and an Automation probe runs AppleScript. The Refresh button is not limited. + func refreshOnActivation(now: Date = Date()) { + rebuildRows() + guard PermissionAccess.activationRecheckDue(last: lastProbeRecheck, now: now) else { return } + recheckProbes(now: now) + } + private func rebuildRows() { rows = Self.usedBy.map { kind, usedBy in PermissionRow(kind: kind, usedBy: usedBy, state: probeResults[kind] ?? access.status(kind)) @@ -103,10 +114,11 @@ final class PermissionsModel: ObservableObject { } } - private func recheckProbes() { + private func recheckProbes(now: Date = Date()) { let kinds = PermissionKind.allCases.filter { probeResults[$0] != nil } guard !kinds.isEmpty, !rechecking, busy == nil else { return } rechecking = true + lastProbeRecheck = now Task { @MainActor in for kind in kinds { probeResults[kind] = await access.recheck(kind) diff --git a/src/macos/GenesisTools/Sources/Host/App/Views.swift b/src/macos/GenesisTools/Sources/Host/App/Views.swift index 2229181a8c..ae68dcc9fd 100644 --- a/src/macos/GenesisTools/Sources/Host/App/Views.swift +++ b/src/macos/GenesisTools/Sources/Host/App/Views.swift @@ -42,17 +42,17 @@ struct PermissionsView: View { } Text("Full Disk Access and Accessibility are turned on in System Settings, not in a prompt: open the list, then drag \(appName) into it.") - .font(.caption) + .font(.system(size: 11)) .foregroundStyle(.secondary) .fixedSize(horizontal: false, vertical: true) HStack { if let busy = model.busyTitle { ProgressView().controlSize(.small) - Text("Asking macOS for \(busy)…").font(.caption) + Text("Asking macOS for \(busy)…").font(.system(size: 11)) } if let message = model.lastMessage { - Text(message).font(.caption).foregroundStyle(.orange) + Text(message).font(.system(size: 11)).foregroundStyle(.orange) } Spacer() Button("Reveal app in Finder") { model.revealApp() } @@ -170,7 +170,7 @@ struct SettingsView: View { } } Text("A program that asks for the vault key or an AI account opens a request window. The window never takes the keyboard by itself, so this sound and notification tell you it is waiting.") - .font(.caption) + .font(.system(size: 11)) .foregroundStyle(.secondary) } diff --git a/src/macos/GenesisTools/Tests/PermissionsModelTests.swift b/src/macos/GenesisTools/Tests/PermissionsModelTests.swift index 614def91c4..36c63f84b1 100644 --- a/src/macos/GenesisTools/Tests/PermissionsModelTests.swift +++ b/src/macos/GenesisTools/Tests/PermissionsModelTests.swift @@ -80,4 +80,36 @@ final class PermissionsModelTests: XCTestCase { XCTAssertEqual(model.rows.first { $0.kind == .downloadsFolder }?.state, .granted) XCTAssertEqual(system.requests, [.downloadsFolder, .downloadsFolder]) } + + /// Coming back to the app reads the rows every time, but runs the answered probes again at most every 10 s, as the + /// permission center does (`PermissionAccess.activationRecheckInterval`). + func testActivationRerunsTheAnsweredProbesAtMostEvery10Seconds() async throws { + let system = FakeSystem() + system.statuses[.downloadsFolder] = .unknown("asks on first use") + let model = PermissionsModel(access: PermissionAccess(system: system)) + model.refresh() + model.request(try XCTUnwrap(model.rows.first { $0.kind == .downloadsFolder })) + for _ in 0 ..< 200 where model.busy != nil { + try await Task.sleep(for: .milliseconds(5)) + } + XCTAssertEqual(system.requests.count, 1) + + let start = Date() + model.refreshOnActivation(now: start) + for _ in 0 ..< 200 where system.requests.count < 2 { + try await Task.sleep(for: .milliseconds(5)) + } + XCTAssertEqual(system.requests.count, 2, "the first activation reruns the answered probe") + try await Task.sleep(for: .milliseconds(50)) + + model.refreshOnActivation(now: start.addingTimeInterval(3)) + try await Task.sleep(for: .milliseconds(50)) + XCTAssertEqual(system.requests.count, 2, "a second activation within 10 s runs no probe") + + model.refreshOnActivation(now: start.addingTimeInterval(11)) + for _ in 0 ..< 200 where system.requests.count < 3 { + try await Task.sleep(for: .milliseconds(5)) + } + XCTAssertEqual(system.requests.count, 3, "after 10 s the next activation runs it again") + } } diff --git a/src/macos/GenesisTools/Tests/VaultUnlockTests.swift b/src/macos/GenesisTools/Tests/VaultUnlockTests.swift index b352b625d6..c9a8e40c4b 100644 --- a/src/macos/GenesisTools/Tests/VaultUnlockTests.swift +++ b/src/macos/GenesisTools/Tests/VaultUnlockTests.swift @@ -607,3 +607,26 @@ final class VaultUnlockTests: XCTestCase { XCTAssertNotEqual(VaultWindowLock.path(for: "vault|/a|x.ts"), VaultWindowLock.path(for: "vault|/a|y.ts")) } } + +/// The route approval card (Sources/Host/RouteApproval.swift) takes the keyboard when it appears, so a Return meant for +/// another app must not run a command: Allow has no key, only Esc answers (Deny). UX catalogue Q1 and Q5. +@MainActor +final class RouteApprovalCardTests: XCTestCase { + func testAllowIsClickOnlyAndEscDenies() throws { + _ = NSApplication.shared + let card = RouteApprovalCard(argv: ["/usr/bin/true", "--flag"], open: nil) + let buttons = Self.buttons(in: try XCTUnwrap(card.contentView)) + let allow = try XCTUnwrap(buttons.first { $0.accessibilityLabel() == "Allow" }, "VoiceOver reads Allow alone") + let deny = try XCTUnwrap(buttons.first { $0.accessibilityLabel() == "Deny" }, "VoiceOver reads Deny, not Deny esc") + XCTAssertEqual(allow.keyEquivalent, "", "Return must not allow") + XCTAssertEqual(deny.keyEquivalent, "\u{1b}") + XCTAssertFalse(buttons.contains { $0.keyEquivalent == "\r" }, "no button answers Return") + XCTAssertEqual(card.accessibilitySubrole(), .dialog) + XCTAssertEqual(card.title, "Approval needed: Run true") + } + + private static func buttons(in view: NSView) -> [NSButton] { + let own: [NSButton] = (view as? NSButton).map { [$0] } ?? [] + return own + view.subviews.flatMap { buttons(in: $0) } + } +} From 35bf21eda173c1678b6945bb4547aeaa47fb68e9 Mon Sep 17 00:00:00 2001 From: Martin <admin@phpcode.eu> Date: Sun, 25 Oct 2026 11:28:50 +0100 Subject: [PATCH 06/18] fix(GenesisHub, settings): review round on the UX fixes: one hub notice with a tone replaces the timeline notice, hidden settings pages stop announcing, the bell popover resets when it closes, and the reports keep their order --- .../Settings/NativeSettingsSearch.swift | 18 ++--- .../Sources/Parts/Hub/Hub/HubInbox.swift | 6 +- .../Sources/Parts/Hub/Hub/HubNotify.swift | 6 +- .../Sources/Parts/Hub/Hub/HubPRs.swift | 21 ++++-- .../Sources/Parts/Hub/Hub/HubTimeline.swift | 73 ++++++------------- .../Sources/Parts/Hub/Hub/HubWindow.swift | 6 +- .../Hub/Settings/PRActivitySettings.swift | 2 +- 7 files changed, 58 insertions(+), 74 deletions(-) diff --git a/src/macos/GenesisKit/Sources/GenesisKit/Settings/NativeSettingsSearch.swift b/src/macos/GenesisKit/Sources/GenesisKit/Settings/NativeSettingsSearch.swift index bc24f77a19..1db0a9b556 100644 --- a/src/macos/GenesisKit/Sources/GenesisKit/Settings/NativeSettingsSearch.swift +++ b/src/macos/GenesisKit/Sources/GenesisKit/Settings/NativeSettingsSearch.swift @@ -123,8 +123,9 @@ struct NativeSettingsSearchKey: PreferenceKey { extension EnvironmentValues { /// The settings page a card or row is drawn on; nil outside the settings window. @Entry var nativeSettingsPageID: String? = nil - /// True in the hidden copy of the pages the search reads: nothing there flashes. - @Entry var nativeSettingsIndexing = false + /// True in the hidden copy of the pages the search reads: nothing there flashes, and a page's own code must not + /// speak (`GenAnnounce`) or ask for anything, since nobody sees it. + @Entry public var nativeSettingsIndexing = false } /// A card or row the search can jump to: when a jump names it, it flashes, and its marker is what the page scrolls to. @@ -256,15 +257,10 @@ struct NativeSettingsSearchResults: View { let results = NativeSettingsSearch.results(query, pages: Self.pages(store), entries: search.entries) VStack(alignment: .leading, spacing: 1) { if results.isEmpty { - VStack(alignment: .leading, spacing: 8) { - Text("No setting matches “\(query.trimmingCharacters(in: .whitespacesAndNewlines))”.") - .font(.system(size: 12)).foregroundStyle(.secondary) - .fixedSize(horizontal: false, vertical: true) - Button("Clear the search", action: clear) - .buttonStyle(.genHoverPlain()) - .font(.system(size: 12)) - } - .padding(.horizontal, 10).padding(.top, 4) + EmptyState( + symbol: "magnifyingglass", + text: "No setting matches “\(query.trimmingCharacters(in: .whitespacesAndNewlines))”.", + actionTitle: "Clear the search", action: clear) } else { Text(Plural.count(results.count, "result")) .font(.system(size: 11, weight: .semibold)).foregroundStyle(.secondary) diff --git a/src/macos/GenesisTools/Sources/Parts/Hub/Hub/HubInbox.swift b/src/macos/GenesisTools/Sources/Parts/Hub/Hub/HubInbox.swift index eee72cbd36..f28dd13322 100644 --- a/src/macos/GenesisTools/Sources/Parts/Hub/Hub/HubInbox.swift +++ b/src/macos/GenesisTools/Sources/Parts/Hub/Hub/HubInbox.swift @@ -1101,8 +1101,8 @@ struct InboxMain: View { } .titlebarLabel() Spacer() - if let notice = model.notice { - NoticePill(text: notice) { model.notice = nil } + if let notice = model.headerNotice { + NoticePill(text: notice.text, tone: notice.tone) { model.headerNotice = nil } } if let missing = inbox.revealMissing { NoticePill(text: missing, isError: true) { inbox.revealMissing = nil } @@ -1203,7 +1203,7 @@ private struct InboxSessionSection: View { } if let provider = session.provider, ["claude", "codex", "grok"].contains(provider) { IconButton(systemName: "terminal", tooltip: "Show this session's cmux pane") { - inbox.focusPane(session) { model.notice = $0 } + inbox.focusPane(session) { model.post($0, $0.hasPrefix("No cmux pane") ? .error : .success) } } } IconButton(systemName: "text.bubble", tooltip: hubSession == nil ? "This session is older than the hub's list" : "Open this session's transcript in the hub") { diff --git a/src/macos/GenesisTools/Sources/Parts/Hub/Hub/HubNotify.swift b/src/macos/GenesisTools/Sources/Parts/Hub/Hub/HubNotify.swift index 0009a17ab4..36f20ab9db 100644 --- a/src/macos/GenesisTools/Sources/Parts/Hub/Hub/HubNotify.swift +++ b/src/macos/GenesisTools/Sources/Parts/Hub/Hub/HubNotify.swift @@ -285,6 +285,8 @@ final class HubNotifyStore: ObservableObject { /// failure, since it lands after the click that caused it. struct HubNotifyNoticeView: View { @ObservedObject var store: HubNotifyStore + /// The settings search draws the PR activity page hidden to learn its rows; a note there is nobody's to hear. + @Environment(\.nativeSettingsIndexing) private var hidden var body: some View { if let message = store.message { @@ -298,7 +300,7 @@ struct HubNotifyNoticeView: View { } private func announce(_ message: HubNotifyNotice) { - if message.tone != .success { GenAnnounce.post(message.text) } + if !hidden, message.tone != .success { GenAnnounce.post(message.text) } } } @@ -480,7 +482,7 @@ struct HubNotifySettings: View { .disabled(store.busy) .instantTooltip(HubNotifyWords.installDetail) .contextMenu { - Button("Copy the terminal command") { Clipboard.copy(HubNotifyWords.installCommand, what: "command") } + Button("Copy Command") { Clipboard.copy(HubNotifyWords.installCommand, what: "command") } } .accessibilityIdentifier("notify.install") } diff --git a/src/macos/GenesisTools/Sources/Parts/Hub/Hub/HubPRs.swift b/src/macos/GenesisTools/Sources/Parts/Hub/Hub/HubPRs.swift index 1b3e046a41..471f75bd43 100644 --- a/src/macos/GenesisTools/Sources/Parts/Hub/Hub/HubPRs.swift +++ b/src/macos/GenesisTools/Sources/Parts/Hub/Hub/HubPRs.swift @@ -2105,8 +2105,8 @@ struct PRDetailView: View { CIBadge(ci: pr.ci, url: detail?.webUrls?.checks.flatMap(URL.init(string:))) PRReadinessHeaderChip(pr: pr) Spacer() - if let notice = model.notice { - NoticePill(text: notice, isError: notice.contains("failed") || notice.hasPrefix("cmux:")) { model.notice = nil } + if let notice = model.headerNotice { + NoticePill(text: notice.text, tone: notice.tone) { model.headerNotice = nil } } if prs.review != nil { IconButton(systemName: "arrow.up.right.square", tooltip: "Open this review in its own window: the same diff, threads and proposal") { @@ -2130,7 +2130,7 @@ struct PRDetailView: View { .popover(isPresented: $reviewing, arrowEdge: .bottom) { LaunchPicker(mode: .new(cwd: path, name: "review \(pr.repo) \(pr.label)", prompt: Self.reviewPrompt(pr))) { outcome in reviewing = false - if let notice = outcome.notice { model.notice = notice } + model.postLaunch(outcome) } } // The palette's "review": the same popover, with the command shown before Start. @@ -2150,7 +2150,7 @@ struct PRDetailView: View { .popover(isPresented: $launching, arrowEdge: .bottom) { LaunchPicker(mode: .new(cwd: path, name: "\(pr.repo) \(pr.label)")) { outcome in launching = false - if let notice = outcome.notice { model.notice = notice } + model.postLaunch(outcome) } } } @@ -2333,7 +2333,7 @@ struct PRDetailView: View { .tint(Color(red: 0.62, green: 0.78, blue: 1)) .environment(\.openURL, OpenURLAction { url in if let id = PRDescriptionLinker.prID(from: url) { - if !prs.open(id: id) { model.notice = "That PR is no longer in the list" } + if !prs.open(id: id) { model.post("That PR is no longer in the list", .warning) } } else { ExternalOpener.open(url) } @@ -2758,3 +2758,14 @@ struct PRSessionsSection: View { return "No session worked on this branch, its commits or its files." } } + +extension HubModel { + /// A launch outcome in the header with its tone: a start is success, a failure is an error, a cancel says nothing. + func postLaunch(_ outcome: LaunchOutcome) { + switch outcome { + case .cancelled: break + case .launched(let text): post(text, .success) + case .failed(let text): post(text, .error) + } + } +} diff --git a/src/macos/GenesisTools/Sources/Parts/Hub/Hub/HubTimeline.swift b/src/macos/GenesisTools/Sources/Parts/Hub/Hub/HubTimeline.swift index 3239ae1493..70e3f96225 100644 --- a/src/macos/GenesisTools/Sources/Parts/Hub/Hub/HubTimeline.swift +++ b/src/macos/GenesisTools/Sources/Parts/Hub/Hub/HubTimeline.swift @@ -698,42 +698,17 @@ enum TimelinePrompts { } } -/// The tone of each note the Activity pane posts, kept with its text by the code that knows what happened (H5): the -/// colour never comes from words like "failed" in the text. `HubModel.notice` is one string every pane shares, so the -/// tone cannot travel inside it yet; Activity records it here, keyed by the text. -@MainActor -enum TimelineNotice { - private static var tones: [String: NoticePill.Tone] = [:] - - static func tone(of text: String) -> NoticePill.Tone? { tones[text] } - - static func record(_ text: String, _ tone: NoticePill.Tone) { - if tones.count > 100 { tones.removeAll() } - tones[text] = tone - } - - /// A note another pane posted (the command palette's "Starting a new Claude session…", say): the old reading of its - /// words, until `HubModel.notice` carries its own tone. - static func otherPaneTone(_ text: String) -> NoticePill.Tone { - text.contains("failed") || text.hasPrefix("No ") ? .error : .success - } -} +// Activity's notes go through `HubModel.post(_:_:)` with the tone the code that posts them knows (H5), so the same note +// shows the same colour in every pane's header (`HubNotice`, Hub/HubWindow.swift). extension HubModel { - /// Shows an Activity note with its tone. - @MainActor - func postTimelineNotice(_ text: String, _ tone: NoticePill.Tone) { - TimelineNotice.record(text, tone) - notice = text - } - /// A launch from an Activity row (resume, a new agent) as a note: started or refused. @MainActor - func postTimelineNotice(_ outcome: LaunchOutcome) { + func postLaunchNotice(_ outcome: LaunchOutcome) { switch outcome { case .cancelled: return - case .launched(let text): postTimelineNotice(text, .success) - case .failed(let text): postTimelineNotice(text, .error) + case .launched(let text): post(text, .success) + case .failed(let text): post(text, .error) } } @@ -758,7 +733,7 @@ extension HubModel { @MainActor func showTimelineCommit(_ event: TimelineEvent, sha: String?, title: String) { guard let repo = event.repo, let sha, FileManager.default.fileExists(atPath: repo) else { - postTimelineNotice("This repository is not on this Mac.", .warning) + post("This repository is not on this Mac.", .warning) return } showCommit(repo: repo, sha: sha, title: title) @@ -788,7 +763,7 @@ extension HubModel { @MainActor func openTimelineSession(_ event: TimelineEvent, transcriptQuery query: String? = nil) { guard let session = timelineSession(event) else { - postTimelineNotice("This row names no session.", .warning) + post("This row names no session.", .warning) return } if !sessions.contains(where: { $0.id == session.id }) { @@ -827,7 +802,7 @@ extension HubModel { @MainActor func openTimelinePR(_ event: TimelineEvent, reveal path: String? = nil) { guard let ref = event.pr.flatMap({ pr in HubPRRef(pr.ref)?.withPage(pr.url) }) else { - postTimelineNotice("This row names no PR.", .warning) + post("This row names no PR.", .warning) return } setMode(.prs) @@ -842,12 +817,10 @@ extension HubModel { DispatchQueue.global(qos: .userInitiated).async { let failure = TerminalHosts.current.focus(sessionId: id, provider: provider) DispatchQueue.main.async { [weak self] in - MainActor.assumeIsolated { - if let failure { - self?.postTimelineNotice("No cmux pane found for this session: \(failure.suffix(120))", .error) - } else { - self?.postTimelineNotice("Focused the session's cmux pane", .success) - } + if let failure { + self?.post("No cmux pane found for this session: \(failure.suffix(120))", .error) + } else { + self?.post("Focused the session's cmux pane", .success) } } } @@ -901,7 +874,7 @@ extension HubModel { if let url = self.timelineHostURL(event) { ExternalOpener.open(url) } else { - self.postTimelineNotice("The repository's origin is not known yet; try again in a moment.", .warning) + self.post("The repository's origin is not known yet; try again in a moment.", .warning) } } add("copy", "number", "Copy the sha") { PathOpener.copy(event.sha ?? "") } @@ -909,14 +882,14 @@ extension HubModel { guard let self, let timeline else { return } if case .commit(let detail)? = timeline.details[event.id] { guard let first = detail.prs.first, let ref = HubPRRef(first.ref)?.withPage(first.url) else { - self.postTimelineNotice("No PR of this repository contains \(event.shortSha ?? "this commit").", .warning) + self.post("No PR of this repository contains \(event.shortSha ?? "this commit").", .warning) return } self.setMode(.prs) self.prs.request(ref) } else { if timeline.isExpanded(event) { timeline.loadDetail(event) } else { timeline.toggle(event) } - self.postTimelineNotice("Reading the commit's PRs; open the row to pick one.", .success) + self.post("Reading the commit's PRs; open the row to pick one.", .success) } } if let repo = event.repo, let sha = event.sha { @@ -928,7 +901,7 @@ extension HubModel { if let url = self.timelineHostURL(event, compare: true) { ExternalOpener.open(url) } else { - self.postTimelineNotice("No compare page: a new branch, or the origin is not known yet.", .warning) + self.post("No compare page: a new branch, or the origin is not known yet.", .warning) } } add("pr", "arrow.triangle.pull", "Open the PR in the hub", disabled: event.pr == nil) { [weak self] in self?.openTimelinePR(event) } @@ -951,7 +924,7 @@ extension HubModel { add("resolve", resolved ? "checkmark.circle.fill" : "checkmark.circle", resolved ? "Reopen this thread" : "Resolve this thread") { [weak self, weak timeline] in guard let timeline, let url = event.pr?.url, let thread = event.threadId else { return } timeline.threadStore(for: url).resolve(thread: thread, resolved: !resolved) - self?.postTimelineNotice(resolved ? "Reopening the thread…" : "Resolving the thread…", .success) + self?.post(resolved ? "Reopening the thread…" : "Resolving the thread…", .success) } add("host", "arrow.up.right.square", "Open the comment on the host") { [weak self] in if let url = self?.timelineHostURL(event) { ExternalOpener.open(url) } @@ -1415,8 +1388,8 @@ struct TimelineMain: View { } .titlebarLabel() Spacer() - if let notice = model.notice { - NoticePill(text: notice, tone: TimelineNotice.tone(of: notice) ?? TimelineNotice.otherPaneTone(notice)) { model.notice = nil } + if let notice = model.headerNotice { + NoticePill(text: notice.text, tone: notice.tone) { model.headerNotice = nil } } if !timeline.warnings.isEmpty { Image(systemName: "exclamationmark.triangle.fill") @@ -1668,7 +1641,7 @@ struct TimelineRowView: View { if let url = model.timelineHostURL(event) { ExternalOpener.open(url) } else { - model.postTimelineNotice("The repository's origin is not known yet; try again in a moment.", .warning) + model.post("The repository's origin is not known yet; try again in a moment.", .warning) } } label: { text @@ -1719,17 +1692,17 @@ struct TimelineRowView: View { case .resume(let session): LaunchPicker(mode: .resume(session)) { outcome in popover = nil - model.postTimelineNotice(outcome) + model.postLaunchNotice(outcome) } case .newAgent(let cwd, let name, let prompt): LaunchPicker(mode: .new(cwd: cwd, name: name, prompt: prompt)) { outcome in popover = nil - model.postTimelineNotice(outcome) + model.postLaunchNotice(outcome) } case .reply(let event): TimelineReplyForm(event: event, timeline: timeline) { notice in popover = nil - if let notice { model.postTimelineNotice(notice.text, notice.tone) } + if let notice { model.post(notice.text, notice.tone) } } } } diff --git a/src/macos/GenesisTools/Sources/Parts/Hub/Hub/HubWindow.swift b/src/macos/GenesisTools/Sources/Parts/Hub/Hub/HubWindow.swift index a37f9d11cb..3c98d4255c 100644 --- a/src/macos/GenesisTools/Sources/Parts/Hub/Hub/HubWindow.swift +++ b/src/macos/GenesisTools/Sources/Parts/Hub/Hub/HubWindow.swift @@ -413,8 +413,6 @@ private final class HubAppDelegate: NSObject, NSApplicationDelegate { } } -/// What the left column lists: agent sessions, the worktrees (branches) they worked in, PRs, the -/// sessions waiting for an answer (Hub/HubInbox.swift), or today's activity (Hub/HubTimeline.swift). /// A note in a hub pane's header and its tone, stored together by the code that knows whether it failed: the headers /// used to guess a failure from words ("failed", "cmux:"), and a note without such a word showed a failure in green. struct HubNotice: Equatable { @@ -434,6 +432,8 @@ struct HubNotice: Equatable { } } +/// What the left column lists: agent sessions, the worktrees (branches) they worked in, PRs, the +/// sessions waiting for an answer (Hub/HubInbox.swift), or today's activity (Hub/HubTimeline.swift). enum HubMode: String, CaseIterable { case sessions, worktrees, prs, inbox, timeline, agents @@ -631,9 +631,11 @@ final class HubModel: ObservableObject { set { headerNotice = newValue.map(HubNotice.init(guessing:)) } } + /// Shows `text` in the panes' headers with `tone`; any hub file in this module posts through it (Activity included). func post(_ text: String, _ tone: NoticePill.Tone) { headerNotice = HubNotice(text, tone) } + /// Only the latest commit lookup may navigate, and any later selection invalidates it. var commitLinkRequest = UUID() /// ⌘⇧F / palette "grep": the find-in-files panel is open with this query ("" = empty field). diff --git a/src/macos/GenesisTools/Sources/Parts/Hub/Settings/PRActivitySettings.swift b/src/macos/GenesisTools/Sources/Parts/Hub/Settings/PRActivitySettings.swift index b3bcc7b33a..d46f085cbd 100644 --- a/src/macos/GenesisTools/Sources/Parts/Hub/Settings/PRActivitySettings.swift +++ b/src/macos/GenesisTools/Sources/Parts/Hub/Settings/PRActivitySettings.swift @@ -67,7 +67,7 @@ struct PRActivitySettingsView: View { .buttonStyle(.bordered).nativeSettingsPointer() .instantTooltip(HubNotifyWords.installDetail) .contextMenu { - Button("Copy the terminal command") { Clipboard.copy(HubNotifyWords.installCommand, what: "command") } + Button("Copy Command") { Clipboard.copy(HubNotifyWords.installCommand, what: "command") } } .accessibilityIdentifier("prActivity.install") } From 4bfd05b86e1694716e868896fd712a376ad010d8 Mon Sep 17 00:00:00 2001 From: Martin <admin@phpcode.eu> Date: Sun, 25 Oct 2026 11:40:00 +0100 Subject: [PATCH 07/18] fix(GenesisKit, permissions): bot review on the UX fixes: the approval reminder posts through UNUserNotificationCenter (the notification engine is not on this base), one relay serves every reminder, and a probe answered outside the window is rechecked --- .../Permissions/ApprovalAttention.swift | 84 ++++++++++++------- .../Sources/Host/App/PermissionsModel.swift | 4 +- .../Sources/Host/RouteApproval.swift | 8 +- 3 files changed, 60 insertions(+), 36 deletions(-) diff --git a/src/macos/GenesisKit/Sources/GenesisKitBase/Permissions/ApprovalAttention.swift b/src/macos/GenesisKit/Sources/GenesisKitBase/Permissions/ApprovalAttention.swift index 64fbb8048d..2a960ed561 100644 --- a/src/macos/GenesisKit/Sources/GenesisKitBase/Permissions/ApprovalAttention.swift +++ b/src/macos/GenesisKit/Sources/GenesisKitBase/Permissions/ApprovalAttention.swift @@ -80,7 +80,6 @@ final class ApprovalReminder { private var pending: DispatchWorkItem? private var expiry: DispatchWorkItem? private var postedID: String? - private var relay: ApprovalNotificationRelay? init(model: ApprovalDialogModel, onClick: @escaping () -> Void) { self.model = model @@ -111,26 +110,33 @@ final class ApprovalReminder { } } + /// nil where Notification Center must not be touched: under `swift test` the bundle is not an app, and + /// `UNUserNotificationCenter.current()` raises there and ends the process. + private static var center: UNUserNotificationCenter? { + guard NSClassFromString("XCTestCase") == nil, Bundle.main.bundlePath.hasSuffix(".app") else { return nil } + return UNUserNotificationCenter.current() + } + private func post() { pending = nil - guard !model.answered, NotificationEngine.shared.centerProvider() != nil else { return } + guard !model.answered, let center = Self.center else { return } // A click on the card must reach this process's panel, and must not reach the host's own click handler, // which ends a process it finds without a route. - let center = UNUserNotificationCenter.current() - let relay = ApprovalNotificationRelay(previous: center.delegate, onClick: onClick) - center.delegate = relay - self.relay = relay - - let body = [model.purpose, model.command].compactMap { $0 }.joined(separator: " ") - let result = NotificationEngine.shared.post(EngineNotification( - source: "approval", title: model.title, subtitle: "Waiting for your answer", body: body, - idPrefix: "approval.\(model.identifier)", replaceKey: "approval.\(model.identifier)", - userInfo: [ApprovalNotificationRelay.tokenKey: token], - sound: ApprovalAttention.sound == "none" ? nil : UNNotificationSound.default)) - if case let .posted(id) = result { - postedID = id - KitHost.log("approval \(model.identifier) reminder posted id=\(id)") + ApprovalNotificationRelay.register(token: token, center: center, onClick: onClick) + + let content = UNMutableNotificationContent() + content.title = model.title + content.subtitle = "Waiting for your answer" + content.body = [model.purpose, model.command].compactMap { $0 }.joined(separator: " ") + content.sound = ApprovalAttention.sound == "none" ? nil : UNNotificationSound.default + content.threadIdentifier = "approval.\(model.identifier)" + content.userInfo = [ApprovalNotificationRelay.tokenKey: token] + let id = "approval.\(model.identifier).\(token)" + center.add(UNNotificationRequest(identifier: id, content: content, trigger: nil)) { error in + if let error { KitHost.log("approval reminder failed id=\(id): \(error.localizedDescription)") } } + postedID = id + KitHost.log("approval \(model.identifier) reminder posted id=\(id)") } /// Cancels a reminder not posted yet, takes a posted one out of Notification Center, and gives the click handling @@ -140,29 +146,45 @@ final class ApprovalReminder { pending = nil expiry?.cancel() expiry = nil + guard let center = Self.center else { return } if let id = postedID { - NotificationEngine.shared.centerProvider()?.removeDelivered(identifiers: [id]) + center.removeDeliveredNotifications(withIdentifiers: [id]) postedID = nil } - if let relay { - let center = UNUserNotificationCenter.current() - if center.delegate === relay { center.delegate = relay.previous } - self.relay = nil - } + ApprovalNotificationRelay.unregister(token: token, center: center) } } -/// Takes a click on any approval reminder and brings the waiting panel forward; every other notification goes on to -/// the delegate that was in place before (the host's), so its routes and replies still run. +/// Takes a click on an approval reminder and brings that request's panel forward; every other notification goes on +/// to the delegate that was in place before (the host's), so its routes and replies still run. One relay serves every +/// reminder of the process, and the host's delegate comes back when the last reminder stops. final class ApprovalNotificationRelay: NSObject, UNUserNotificationCenterDelegate { static let tokenKey = "genesis.approval" - let previous: UNUserNotificationCenterDelegate? - private let onClick: () -> Void + private static var shared: ApprovalNotificationRelay? + /// The click handler of each reminder still waiting, by its token. Read and written on the main thread only. + private static var handlers: [String: () -> Void] = [:] + + /// Strong: the notification center holds its delegate weakly, and the host's must still be there on restore. + private let previous: UNUserNotificationCenterDelegate? - init(previous: UNUserNotificationCenterDelegate?, onClick: @escaping () -> Void) { + private init(previous: UNUserNotificationCenterDelegate?) { self.previous = previous - self.onClick = onClick + } + + static func register(token: String, center: UNUserNotificationCenter, onClick: @escaping () -> Void) { + handlers[token] = onClick + guard shared == nil else { return } + let relay = ApprovalNotificationRelay(previous: center.delegate) + shared = relay + center.delegate = relay + } + + static func unregister(token: String, center: UNUserNotificationCenter) { + handlers[token] = nil + guard handlers.isEmpty, let relay = shared else { return } + if center.delegate === relay { center.delegate = relay.previous } + shared = nil } func userNotificationCenter(_ center: UNUserNotificationCenter, willPresent notification: UNNotification, @@ -174,7 +196,7 @@ final class ApprovalNotificationRelay: NSObject, UNUserNotificationCenterDelegat func userNotificationCenter(_ center: UNUserNotificationCenter, didReceive response: UNNotificationResponse, withCompletionHandler completionHandler: @escaping () -> Void) { - guard response.notification.request.content.userInfo[Self.tokenKey] != nil else { + guard let token = response.notification.request.content.userInfo[Self.tokenKey] as? String else { if previous?.userNotificationCenter?(center, didReceive: response, withCompletionHandler: completionHandler) == nil { completionHandler() } @@ -182,9 +204,9 @@ final class ApprovalNotificationRelay: NSObject, UNUserNotificationCenterDelegat } KitHost.log("approval reminder clicked id=\(response.notification.request.identifier)") - let onClick = onClick DispatchQueue.main.async { - onClick() + // A reminder that already stopped has no handler: its panel is gone, so the click does nothing. + Self.handlers[token]?() completionHandler() } } diff --git a/src/macos/GenesisTools/Sources/Host/App/PermissionsModel.swift b/src/macos/GenesisTools/Sources/Host/App/PermissionsModel.swift index d8a8e556e7..1f72a77421 100644 --- a/src/macos/GenesisTools/Sources/Host/App/PermissionsModel.swift +++ b/src/macos/GenesisTools/Sources/Host/App/PermissionsModel.swift @@ -115,7 +115,9 @@ final class PermissionsModel: ObservableObject { } private func recheckProbes(now: Date = Date()) { - let kinds = PermissionKind.allCases.filter { probeResults[$0] != nil } + // Every probe kind, not only those answered here: another feature may have asked, and `recheck` skips a kind + // macOS never answered. + let kinds = PermissionKind.allCases.filter { $0.requestStyle == .probe } guard !kinds.isEmpty, !rechecking, busy == nil else { return } rechecking = true lastProbeRecheck = now diff --git a/src/macos/GenesisTools/Sources/Host/RouteApproval.swift b/src/macos/GenesisTools/Sources/Host/RouteApproval.swift index 458d007702..13d5a98702 100644 --- a/src/macos/GenesisTools/Sources/Host/RouteApproval.swift +++ b/src/macos/GenesisTools/Sources/Host/RouteApproval.swift @@ -2,8 +2,8 @@ import AppKit import Foundation /// The Allow / Deny card for a route that runs a command. Same look as RouteToast: dark card, -/// gold hairline, the GenesisTools mark. Only a click allows: the card takes the keyboard while -/// the user may still be typing elsewhere, so Return does nothing here. Esc denies. +/// gold hairline, the GenesisTools mark. Return does nothing here: the card takes the keyboard while +/// the user may still be typing elsewhere. A click allows, or Space after Tab with Full Keyboard Access. Esc denies. enum RouteApproval { /// The "Running" toast is already up (statusBar level, same spot) and would hide this card, /// so it steps aside while the question is open and only comes back when the run is allowed. @@ -167,7 +167,7 @@ private final class ApprovalBody: NSView { rows.append(caption("Then opens", open)) } - rows.append(label("Runs once, without a shell. Nothing happens if you deny. Only a click allows it.", + rows.append(label("Runs once, without a shell. Nothing happens if you deny. Return does not allow it; click Allow.", size: 11, weight: .regular, color: Palette.dim)) rows.append(buttonRow()) @@ -281,7 +281,7 @@ private final class ApprovalBody: NSView { let allow = ApprovalButton(title: "Allow", hint: nil, primary: true) allow.target = self allow.action = #selector(allowClicked) - allow.toolTip = "Runs the command once. Only a click allows it, so a key pressed while you type cannot." + allow.toolTip = "Runs the command once. Return does not press it, so a key pressed while you type cannot." allow.setAccessibilityHelp("Runs the command once.") let row = NSStackView(views: [NSView(), deny, allow]) From f6507863c2429a086e58ac09095417febdeccf28 Mon Sep 17 00:00:00 2001 From: Martin <admin@phpcode.eu> Date: Sun, 25 Oct 2026 11:40:02 +0100 Subject: [PATCH 08/18] fix(GenesisKit, GenesisHub, widgets): bot review on the UX fixes: a discarded note or shelf item stays gone, the cmux popover key clears when its row leaves the list, Esc stays with the widget, and the open shortcut falls back when it has no modifier --- .../GenesisKit/Widgets/Shelf/WidgetShelfStore.swift | 6 +++++- .../Widgets/VoiceNotes/WidgetVoiceNotesStore.swift | 7 ++++++- .../GenesisKit/Widgets/WidgetAgentPreview.swift | 4 ++++ .../Sources/GenesisKit/Widgets/WidgetCoordinator.swift | 10 +++++++--- .../GenesisKit/Widgets/WidgetInboxOverview.swift | 2 ++ .../Sources/GenesisKit/Widgets/WidgetModel.swift | 4 ++++ .../Sources/GenesisKit/Widgets/WidgetStagedViews.swift | 3 ++- .../Sources/GenesisKit/Widgets/WidgetWireModels.swift | 8 ++++++-- .../GenesisKitTests/WidgetInboxTasksPolishTests.swift | 5 ++++- 9 files changed, 40 insertions(+), 9 deletions(-) diff --git a/src/macos/GenesisKit/Sources/GenesisKit/Widgets/Shelf/WidgetShelfStore.swift b/src/macos/GenesisKit/Sources/GenesisKit/Widgets/Shelf/WidgetShelfStore.swift index 09af7ef6f9..c7acbc483d 100644 --- a/src/macos/GenesisKit/Sources/GenesisKit/Widgets/Shelf/WidgetShelfStore.swift +++ b/src/macos/GenesisKit/Sources/GenesisKit/Widgets/Shelf/WidgetShelfStore.swift @@ -107,6 +107,8 @@ public final class WidgetShelfStore: ObservableObject { private var removalTimer: Task<Void, Never>? /// The CLI removal once the Undo ran out; teardown waits for it. private var removal: Task<Void, Never>? + /// Items whose CLI removal started and has not finished; a list read must not show them again. + private var removingIDs: Set<String> = [] public init( binaryPath: String, stateRoot: String? = nil, @@ -225,7 +227,7 @@ public final class WidgetShelfStore: ObservableObject { guard !self.stopped, !Task.isCancelled else { return } let snapshot = try JSONDecoder().decode(WidgetShelfSnapshot.self, from: data) // A removed item stays on disk during its Undo; it stays off the list. - let shown = snapshot.items.filter { $0.id != self.removed?.id } + let shown = snapshot.items.filter { $0.id != self.removed?.id && !self.removingIDs.contains($0.id) } if self.items != shown { self.captureItems = shown.filter { $0.kind == .capture } self.items = shown @@ -506,6 +508,7 @@ public final class WidgetShelfStore: ObservableObject { let previous = removal // The request is held, not the store: a removal that runs at teardown still reaches the CLI. let request = request + removingIDs.insert(item.id) removal = Task { [weak self] in await previous?.value do { @@ -514,6 +517,7 @@ public final class WidgetShelfStore: ObservableObject { // Still on the shelf: say why; the list read below shows it again. if let self, !self.stopped { self.report(error) } } + self?.removingIDs.remove(item.id) if let self, !self.stopped { self.refresh() } } } diff --git a/src/macos/GenesisKit/Sources/GenesisKit/Widgets/VoiceNotes/WidgetVoiceNotesStore.swift b/src/macos/GenesisKit/Sources/GenesisKit/Widgets/VoiceNotes/WidgetVoiceNotesStore.swift index c0c79cfbaf..3201099282 100644 --- a/src/macos/GenesisKit/Sources/GenesisKit/Widgets/VoiceNotes/WidgetVoiceNotesStore.swift +++ b/src/macos/GenesisKit/Sources/GenesisKit/Widgets/VoiceNotes/WidgetVoiceNotesStore.swift @@ -51,6 +51,8 @@ public final class WidgetVoiceNotesStore: ObservableObject { private var discardTimer: Task<Void, Never>? /// The CLI delete of a discarded note once its Undo ran out; teardown waits for it. private var discardDelete: Task<Void, Never>? + /// Notes whose CLI delete started and has not finished; a list read must not show them again. + private var deletingIDs: Set<String> = [] private var watcher: DirectoryWatcher? private var watchedPath: String? private var cached = false @@ -137,7 +139,7 @@ public final class WidgetVoiceNotesStore: ObservableObject { guard !stopped, generation == requestedGeneration else { return } let snapshot = try JSONDecoder().decode(WidgetVoiceNoteSnapshot.self, from: data) // A discarded note is still on disk during its Undo; it stays out of the list. - notes = snapshot.notes.filter { $0.id != discarded?.id } + notes = snapshot.notes.filter { $0.id != discarded?.id && !deletingIDs.contains($0.id) } drafts = drafts.filter { id, _ in self.notes.contains { $0.id == id } || id == self.discarded?.id } if selected == nil { selectedID = notes.first?.id } cached = true @@ -357,12 +359,15 @@ public final class WidgetVoiceNotesStore: ObservableObject { let previous = discardDelete // The request is held, not the store: a delete that runs at teardown still reaches the CLI. let request = request + deletingIDs.insert(note.id) discardDelete = Task { [weak self] in await previous?.value do { _ = try await request(["discard", note.id, "--revision", String(note.revision)]) + self?.deletingIDs.remove(note.id) } catch { // The recording is still on disk: show why and read the list again, so it comes back. + self?.deletingIDs.remove(note.id) guard let self, !self.stopped else { return } self.report(error) self.cached = false diff --git a/src/macos/GenesisKit/Sources/GenesisKit/Widgets/WidgetAgentPreview.swift b/src/macos/GenesisKit/Sources/GenesisKit/Widgets/WidgetAgentPreview.swift index 653240b2f9..879f8b5ced 100644 --- a/src/macos/GenesisKit/Sources/GenesisKit/Widgets/WidgetAgentPreview.swift +++ b/src/macos/GenesisKit/Sources/GenesisKit/Widgets/WidgetAgentPreview.swift @@ -170,6 +170,10 @@ struct AgentWidgetPreview: View { // Like the row button's own popover: while it is open the edge panel must not collapse. .onChange(of: cmuxPanelKey) { _, key in model.cmuxPopoverOpen = key != nil } .onDisappear { if cmuxPanelKey != nil { model.cmuxPopoverOpen = false } } + // A row that leaves the list takes its popover along; its key must not hold the panel open. + .onChange(of: model.previewList.map(\.key)) { _, keys in + if let key = cmuxPanelKey, !keys.contains(key) { cmuxPanelKey = nil } + } } private var sortTooltip: String { diff --git a/src/macos/GenesisKit/Sources/GenesisKit/Widgets/WidgetCoordinator.swift b/src/macos/GenesisKit/Sources/GenesisKit/Widgets/WidgetCoordinator.swift index 1a43bf93ac..06a2172dba 100644 --- a/src/macos/GenesisKit/Sources/GenesisKit/Widgets/WidgetCoordinator.swift +++ b/src/macos/GenesisKit/Sources/GenesisKit/Widgets/WidgetCoordinator.swift @@ -382,8 +382,11 @@ public final class WidgetCoordinator: NSObject, NSWindowDelegate { model.requestCollapse() return } - let surface = peekSurface() ?? panels.keys.sorted { $0.key < $1.key }.first - guard let surface else { return } + guard let surface = peekSurface() else { + model.postNote("Add Agent Inbox to a visible edge to open it with the shortcut.") + showSettings(pageID: "widgets.modules") + return + } PerfLog.mark("widget.shortcut open \(surface.key)") model.openModule("agents", on: surface) } @@ -810,7 +813,8 @@ public final class WidgetCoordinator: NSObject, NSWindowDelegate { private func handleKey(_ event: NSEvent) -> NSEvent? { // The alert popup never takes the keyboard, but when the widget has it (a panel of another edge is key), Esc // closes the popup before anything else. - if model.expanded == nil, model.peekItem != nil, let stroke = KeyStroke(event), + if model.expanded == nil, model.peekItem != nil, panels.values.contains(where: { $0.panel === event.window }), + let stroke = KeyStroke(event), WidgetKeymap.commandIDs(for: stroke, in: [.window], keymap: WidgetKeymap.shared).contains(WidgetKeymap.collapse) { model.dismissPeek() return nil diff --git a/src/macos/GenesisKit/Sources/GenesisKit/Widgets/WidgetInboxOverview.swift b/src/macos/GenesisKit/Sources/GenesisKit/Widgets/WidgetInboxOverview.swift index 2034def1ad..76efbe78bf 100644 --- a/src/macos/GenesisKit/Sources/GenesisKit/Widgets/WidgetInboxOverview.swift +++ b/src/macos/GenesisKit/Sources/GenesisKit/Widgets/WidgetInboxOverview.swift @@ -42,6 +42,8 @@ struct WidgetInboxOverview: View { guard let key else { return } handle(key.kind, rows: drawnOrder(items), proxy: proxy) } + .onChange(of: focusID) { _, id in model.inboxOverviewHasFocus = id != nil } + .onDisappear { model.inboxOverviewHasFocus = false } } } diff --git a/src/macos/GenesisKit/Sources/GenesisKit/Widgets/WidgetModel.swift b/src/macos/GenesisKit/Sources/GenesisKit/Widgets/WidgetModel.swift index 5b7f2a1dc5..d5d346581b 100644 --- a/src/macos/GenesisKit/Sources/GenesisKit/Widgets/WidgetModel.swift +++ b/src/macos/GenesisKit/Sources/GenesisKit/Widgets/WidgetModel.swift @@ -1353,6 +1353,9 @@ public final class WidgetModel: ObservableObject { /// The session list's rows in the order it draws them (its search, pins and open groups applied), written by /// the list as it changes. Empty: the list has not said, and the keys leave it alone. public var sessionListOrder: [String] = [] + /// Whether the inbox of every session has a row the keys chose, written by that view. Without one, Return is not + /// the list's and reaches the focused control (Mark all read). + public var inboxOverviewHasFocus = false /// Whether the agents pane shows a list that takes Up, Down and Return: the inbox of every session, a session's /// inbox with entries, or the session list. Not the staged view, the leave question or the conversation. @@ -1368,6 +1371,7 @@ public final class WidgetModel: ObservableObject { @discardableResult public func moveInList(_ kind: WidgetListKey.Kind) -> Bool { guard keyboardList else { return false } + if inboxOverview, kind == .open, !inboxOverviewHasFocus { return false } listKey = WidgetListKey(kind: kind, serial: (listKey?.serial ?? 0) + 1) // The inbox of every session owns its filter, grouping and highlight (WidgetInboxOverview). if inboxOverview { return true } diff --git a/src/macos/GenesisKit/Sources/GenesisKit/Widgets/WidgetStagedViews.swift b/src/macos/GenesisKit/Sources/GenesisKit/Widgets/WidgetStagedViews.swift index 0a06a09d6b..55d87b8e18 100644 --- a/src/macos/GenesisKit/Sources/GenesisKit/Widgets/WidgetStagedViews.swift +++ b/src/macos/GenesisKit/Sources/GenesisKit/Widgets/WidgetStagedViews.swift @@ -95,7 +95,8 @@ struct WidgetStagedList: View { model.stagedView = nil } .buttonStyle(.borderedProminent).disabled(chosen.isEmpty) - .keyboardShortcut(.return, modifiers: [.command]) + // The leave question over this list owns ⌘↩ while it is shown. + .keyboardShortcut(model.stagedLeavePrompt ? nil : KeyboardShortcut(.return, modifiers: [.command])) .instantTooltip("Sends the checked decisions now, one message per session (⌘↩)") Button("Discard checked") { model.discardStaged(chosen.map(\.sourceId)) } .buttonStyle(.genHover()).disabled(chosen.isEmpty) diff --git a/src/macos/GenesisKit/Sources/GenesisKit/Widgets/WidgetWireModels.swift b/src/macos/GenesisKit/Sources/GenesisKit/Widgets/WidgetWireModels.swift index e5e7a65612..e41b8e783a 100644 --- a/src/macos/GenesisKit/Sources/GenesisKit/Widgets/WidgetWireModels.swift +++ b/src/macos/GenesisKit/Sources/GenesisKit/Widgets/WidgetWireModels.swift @@ -357,9 +357,13 @@ public struct WidgetShortcutChord: Codable, Equatable, Sendable { } extension WidgetPreferences { - /// The shortcut that opens the widget, or nil while it is off. + /// The shortcut that opens the widget, or nil while it is off. A stored chord without ⌘, ⌥ or ⌃ would take a + /// plain key from every app, so the default stands in for it. public var openShortcutChord: WidgetShortcutChord? { - (openShortcutEnabled ?? true) ? openShortcut ?? .defaultOpen : nil + guard openShortcutEnabled ?? true else { return nil } + let chord = openShortcut ?? .defaultOpen + let required = HotkeyChord.command | HotkeyChord.option | HotkeyChord.control + return chord.modifiers & required != 0 ? chord : .defaultOpen } } diff --git a/src/macos/GenesisKit/Tests/GenesisKitTests/WidgetInboxTasksPolishTests.swift b/src/macos/GenesisKit/Tests/GenesisKitTests/WidgetInboxTasksPolishTests.swift index 285a231f36..86b57bebce 100644 --- a/src/macos/GenesisKit/Tests/GenesisKitTests/WidgetInboxTasksPolishTests.swift +++ b/src/macos/GenesisKit/Tests/GenesisKitTests/WidgetInboxTasksPolishTests.swift @@ -657,10 +657,13 @@ final class WidgetInboxPolishTests: XCTestCase { XCTAssertEqual(WidgetModel.noteSeconds(nil, hasAction: true), 10, "a note with Undo or Send again stays 10 s") XCTAssertEqual(WidgetModel.noteSeconds(try prefs(#"{"noteSeconds":20}"#), hasAction: true), 20) XCTAssertNil(WidgetModel.noteSeconds(try prefs(#"{"noteSeconds":0}"#), hasAction: false), "0 keeps it") - XCTAssertEqual(try prefs(#"{}"#).openShortcutChord, .defaultOpen, "the shortcut is on, ⌃⌥⌘W, by default") + XCTAssertEqual(try prefs(#"{"alertSound":"Glass"}"#).openShortcutChord, .defaultOpen, + "the shortcut is on, ⌃⌥⌘W, by default") XCTAssertNil(try prefs(#"{"openShortcutEnabled":false}"#).openShortcutChord) XCTAssertEqual(try prefs(#"{"openShortcut":{"keyCode":2,"modifiers":6400}}"#).openShortcutChord, WidgetShortcutChord(keyCode: 2, modifiers: 6400)) + XCTAssertEqual(try prefs(#"{"openShortcut":{"keyCode":13,"modifiers":512}}"#).openShortcutChord, .defaultOpen, + "a chord with only Shift would take a plain key from every app") } /// The rail badge counts what waits for an answer, the hub's Inbox count; unread answers do not (R1). A lost From 0634fe02a3d9194a771e32ea2252db971fd344af Mon Sep 17 00:00:00 2001 From: Martin <admin@phpcode.eu> Date: Sun, 25 Oct 2026 11:40:04 +0100 Subject: [PATCH 09/18] fix(GenesisHub, settings): bot review on the UX fixes: settings search finds rows in folded sections and opens them, a failed daemon save can be retried, the send preview names each letter once, and the cadence menu lists a stored custom cadence --- .../Settings/NativeSettingsControls.swift | 37 ++++++++++++++++++- .../Settings/NativeSettingsViews.swift | 37 +++++++++++++------ .../Features/Settings/DaemonSettings.swift | 18 ++++++--- .../Sources/Parts/Hub/Hub/HubAgents.swift | 12 +++++- .../Parts/Hub/Hub/HubDecisionsPane.swift | 2 +- .../Sources/Parts/Hub/Hub/HubInbox.swift | 12 ++++-- .../Sources/Parts/Hub/Hub/HubNotify.swift | 8 ++-- .../Hub/Settings/PRActivitySettings.swift | 2 +- .../GenesisTools/Tests/HubNotifyTests.swift | 3 ++ 9 files changed, 101 insertions(+), 30 deletions(-) diff --git a/src/macos/GenesisKit/Sources/GenesisKit/Settings/NativeSettingsControls.swift b/src/macos/GenesisKit/Sources/GenesisKit/Settings/NativeSettingsControls.swift index 418ddf4de9..0540624987 100644 --- a/src/macos/GenesisKit/Sources/GenesisKit/Settings/NativeSettingsControls.swift +++ b/src/macos/GenesisKit/Sources/GenesisKit/Settings/NativeSettingsControls.swift @@ -16,7 +16,12 @@ public struct NativeSettingsDisclosure<Content: View>: View { private let identifier: String private let content: Content @State private var expanded: Bool + /// The search keys of the rows inside, read from the folded copy, so a search jump to one of them opens this. + @State private var keys: [String] = [] @GenReduceMotion private var reduceMotion + @Environment(\.nativeSettingsIndexing) private var indexing + @Environment(\.nativeSettingsPageID) private var page + @ObservedObject private var center = NativeSettingsHighlightCenter.shared public init(_ title: String, identifier: String, @ViewBuilder content: () -> Content) { self.title = title @@ -47,10 +52,40 @@ public struct NativeSettingsDisclosure<Content: View>: View { .accessibilityIdentifier(identifier) .accessibilityLabel(title) .accessibilityValue(expanded ? "Expanded" : "Collapsed") - if expanded { content.padding(.top, 8).transition(.genReveal) } + if expanded || indexing { + content.padding(.top, 8).transition(.genReveal) + } else { + folded + } } // The section closes over its content; a few points of slack keep a control's focus ring. .clipShape(Rectangle().inset(by: -3)) + .onChange(of: center.token) { _, _ in openForSearch() } + .onChange(of: keys) { _, _ in openForSearch() } + } + + /// The content while folded: drawn with no height and hidden, as the search indexer draws other pages, so the + /// settings search still lists the rows inside. + private var folded: some View { + content + .environment(\.nativeSettingsIndexing, true) + .frame(height: 0) + .clipped() + .hidden() + .disabled(true) + .allowsHitTesting(false) + .accessibilityHidden(true) + .onPreferenceChange(NativeSettingsSearchKey.self) { entries in + MainActor.assumeIsolated { keys = entries.map(\.key) } + } + } + + /// Opens the section when a fresh search jump names a row inside it. + private func openForSearch() { + guard !expanded, !indexing, let page, let anchor = center.anchor, let at = center.requestedAt, + Date().timeIntervalSince(at) < 2, + keys.contains(where: { NativeSettingsSearch.anchor(page: page, key: $0) == anchor }) else { return } + expanded = true } } diff --git a/src/macos/GenesisKit/Sources/GenesisKit/Settings/NativeSettingsViews.swift b/src/macos/GenesisKit/Sources/GenesisKit/Settings/NativeSettingsViews.swift index 23effc4587..e1bd2eeaa0 100644 --- a/src/macos/GenesisKit/Sources/GenesisKit/Settings/NativeSettingsViews.swift +++ b/src/macos/GenesisKit/Sources/GenesisKit/Settings/NativeSettingsViews.swift @@ -183,25 +183,38 @@ private struct NativeSettingsPageScroll: View { page.content() .environment(\.nativeSettingsPageID, id) .onPreferenceChange(NativeSettingsSearchKey.self) { entries in - MainActor.assumeIsolated { search.set(id, entries) } + MainActor.assumeIsolated { + let had = search.entries[id].map(holdsTarget) ?? false + search.set(id, entries) + // A card drawn after its page loaded its settings: scroll once it is there. + if !had, holdsTarget(entries) { Task { await reveal(proxy) } } + } } }.padding(.horizontal, 28).padding(.bottom, 28) .frame(maxWidth: .infinity, alignment: .leading) } - // Only a fresh request scrolls: a page visited again later stays where its reader left it. - .task(id: center.token) { - guard let anchor = center.anchor, let at = center.requestedAt, Date().timeIntervalSince(at) < 2 else { return } - try? await Task.sleep(for: .milliseconds(80)) - let place = UnitPoint(x: 0.5, y: 0.15) - if still { - proxy.scrollTo(anchor, anchor: place) - } else { - withAnimation(.easeInOut(duration: 0.25)) { proxy.scrollTo(anchor, anchor: place) } - } - } + .task(id: center.token) { await reveal(proxy) } } .id(id) } + + private func holdsTarget(_ entries: [NativeSettingsSearchEntry]) -> Bool { + guard let anchor = center.anchor else { return false } + return entries.contains { NativeSettingsSearch.anchor(page: page.id, key: $0.key) == anchor } + } + + /// Only a fresh request scrolls: a page visited again later stays where its reader left it. + private func reveal(_ proxy: ScrollViewProxy) async { + guard center.anchor != nil, let at = center.requestedAt, Date().timeIntervalSince(at) < 2 else { return } + try? await Task.sleep(for: .milliseconds(80)) + guard let anchor = center.anchor else { return } + let place = UnitPoint(x: 0.5, y: 0.15) + if still { + proxy.scrollTo(anchor, anchor: place) + } else { + withAnimation(.easeInOut(duration: 0.25)) { proxy.scrollTo(anchor, anchor: place) } + } + } } /// The page list, and the search field above it. While the field holds text, its results take the list's place. diff --git a/src/macos/GenesisTools/Sources/Parts/Features/Settings/DaemonSettings.swift b/src/macos/GenesisTools/Sources/Parts/Features/Settings/DaemonSettings.swift index 21dd35e96e..d2d2a5037b 100644 --- a/src/macos/GenesisTools/Sources/Parts/Features/Settings/DaemonSettings.swift +++ b/src/macos/GenesisTools/Sources/Parts/Features/Settings/DaemonSettings.swift @@ -239,8 +239,9 @@ final class DaemonSettingsStore: ObservableObject { } /// One `tools daemon config set <task> <flags> --json`. The switch moves at once; the CLI's answer (the stored - /// task) replaces it, and a refused change puts the old value back and says why. - func set(_ task: DaemonTaskConfig, _ flags: [String], optimistic: ((inout DaemonTaskConfig) -> Void)? = nil) { + /// task) replaces it, and a refused change puts the old value back, says why and runs `onFailure`. + func set(_ task: DaemonTaskConfig, _ flags: [String], optimistic: ((inout DaemonTaskConfig) -> Void)? = nil, + onFailure: (() -> Void)? = nil) { writeGeneration += 1 let before = status if let optimistic, let index = status?.tasks.firstIndex(where: { $0.name == task.name }) { @@ -257,6 +258,7 @@ final class DaemonSettingsStore: ObservableObject { show("Saved \(task.name). The daemon uses it from its next tick.", error: false) case .failure(let error): status = before + onFailure?() show("\(task.name): \(error)", error: true) } } @@ -641,9 +643,9 @@ private struct DaemonTaskEditor: View { .onAppear(perform: syncFields) // Another setting's save brings a new task; a field the user is still typing in keeps their text. .onChange(of: task) { old, _ in + syncFields(from: old) sentEvery = nil sentRetryTimes = nil - syncFields(from: old) } // A click on another field or Tab saves what was typed, as Return does. .onChange(of: editing) { old, _ in @@ -673,7 +675,10 @@ private struct DaemonTaskEditor: View { /// Takes the new task's values, except in a field that has the focus and holds an edit not saved yet. private func syncFields(from old: DaemonTaskConfig) { if editing != .every || every == old.every { every = task.every } - if editing != .retryTimes || retryTimes == Self.times(old) { retryTimes = Self.times(task) } + // The text just saved comes back in the stored form ("10:00,12:00" as "10:00, 12:00"), so it is not saved again. + if editing != .retryTimes || retryTimes == Self.times(old) || retryTimes == sentRetryTimes { + retryTimes = Self.times(task) + } } private func commit(_ field: Field?) { @@ -691,14 +696,15 @@ private struct DaemonTaskEditor: View { let value = every.trimmingCharacters(in: .whitespaces) guard !value.isEmpty, value != task.every, value != sentEvery else { return } sentEvery = value - store.set(task, ["--every", value]) + // A failed save lets the same text be sent again. + store.set(task, ["--every", value], onFailure: { if sentEvery == value { sentEvery = nil } }) } private func commitRetryTimes() { let value = retryTimes.trimmingCharacters(in: .whitespaces) guard task.missedRun.mode == "retry-at", !value.isEmpty, value != Self.times(task), value != sentRetryTimes else { return } sentRetryTimes = value - store.set(task, ["--retry-at", value]) + store.set(task, ["--retry-at", value], onFailure: { if sentRetryTimes == value { sentRetryTimes = nil } }) } } diff --git a/src/macos/GenesisTools/Sources/Parts/Hub/Hub/HubAgents.swift b/src/macos/GenesisTools/Sources/Parts/Hub/Hub/HubAgents.swift index 6a8d026e2f..913d08f9e9 100644 --- a/src/macos/GenesisTools/Sources/Parts/Hub/Hub/HubAgents.swift +++ b/src/macos/GenesisTools/Sources/Parts/Hub/Hub/HubAgents.swift @@ -278,6 +278,8 @@ final class HubAgentsModel: ObservableObject { private(set) var showingCached = false @Published private(set) var loaded = false @Published private(set) var error: String? + /// A `--session` or `--agent` request the loaded list does not hold; the list itself loaded fine. + @Published var requestMiss: String? /// `AgentTree.key(parent:child:)` of the open child. @Published var selectedID: String? { didSet { @@ -938,6 +940,7 @@ final class HubAgentsModel: ObservableObject { /// named by its id, its id's start, or its teammate name; the parent by its id or id's start. func request(parent: String?, child: String) { pendingRequest = (parent, child) + requestMiss = nil resolvePendingRequest(final: false) } @@ -952,7 +955,7 @@ final class HubAgentsModel: ObservableObject { revealRequest += 1 } else if final { pendingRequest = nil - error = "No agent session \(wanted.parent?.prefix(8) ?? "") in the list" + requestMiss = "No agent session \(wanted.parent?.prefix(8) ?? "") in the list." } return } @@ -965,7 +968,7 @@ final class HubAgentsModel: ObservableObject { revealRequest += 1 } else if final { pendingRequest = nil - error = "No agent \(wanted.child)\(wanted.parent.map { " under session \($0.prefix(8))" } ?? "") in the list" + requestMiss = "No agent \(wanted.child)\(wanted.parent.map { " under session \($0.prefix(8))" } ?? "") in the list." } } @@ -1173,6 +1176,11 @@ struct AgentsListView: View { .padding(.horizontal, 14) .padding(.vertical, 4) } + if let miss = agents.requestMiss { + NoticePill(text: miss, tone: .warning) { agents.requestMiss = nil } + .padding(.horizontal, 14) + .padding(.vertical, 4) + } ForEach(sections, id: \.title) { section in Section { ForEach(section.rows) { item in diff --git a/src/macos/GenesisTools/Sources/Parts/Hub/Hub/HubDecisionsPane.swift b/src/macos/GenesisTools/Sources/Parts/Hub/Hub/HubDecisionsPane.swift index c26d13334e..a64293ecac 100644 --- a/src/macos/GenesisTools/Sources/Parts/Hub/Hub/HubDecisionsPane.swift +++ b/src/macos/GenesisTools/Sources/Parts/Hub/Hub/HubDecisionsPane.swift @@ -119,7 +119,7 @@ struct DecisionsView: View { .font(.system(size: 11)) .foregroundColor(ReviewPalette.dim) .lineLimit(1) - .instantTooltip("Posted and harvested decisions, and the ones the last reply asks. A click on an option marks it; Send delivers every marked answer to the session.") + .instantTooltip("Posted and harvested decisions, and the ones the last reply asks. A click on an option stages it; Send delivers every staged answer to the session.") if model.loadingDecisions { ProgressView().controlSize(.small) } diff --git a/src/macos/GenesisTools/Sources/Parts/Hub/Hub/HubInbox.swift b/src/macos/GenesisTools/Sources/Parts/Hub/Hub/HubInbox.swift index f28dd13322..9961315201 100644 --- a/src/macos/GenesisTools/Sources/Parts/Hub/Hub/HubInbox.swift +++ b/src/macos/GenesisTools/Sources/Parts/Hub/Hub/HubInbox.swift @@ -1239,8 +1239,11 @@ struct InboxSendBar: View { guard let number = item.number else { return nil } let letters = (item.draftOption ?? "").map { "\($0)) " }.joined() let note = item.draft?.trimmed ?? "" - let labels = (item.draftOption ?? "").compactMap { letter in - (item.choices ?? []).first { $0.id == String(letter) }?.label + // Most labels already start with their own "b) "; drop it so the letter is not written twice. + let labels = (item.draftOption ?? "").compactMap { letter -> String? in + guard let label = (item.choices ?? []).first(where: { $0.id == String(letter) })?.label else { return nil } + let prefix = "\(letter)) " + return label.hasPrefix(prefix) ? String(label.dropFirst(prefix.count)) : label } let text = note.isEmpty ? labels.joined(separator: " / ") : note return "DECISION \(number): \(letters)\(text)".trimmed @@ -1267,8 +1270,9 @@ struct InboxSendBar: View { .disabled(drafted.isEmpty || sending || session.sessionId == nil) // One session answers the chord: the selected one, or the only one with staged answers. .keyboardShortcut(ownsShortcut ? KeyboardShortcut(.return, modifiers: .command) : nil) - .instantTooltip("Send every staged answer to the agent as one message (⌘↩)") - .accessibilityHint(Text("Command-Return")) + .instantTooltip(ownsShortcut ? "Send every staged answer to the agent as one message (⌘↩)" + : "Send every staged answer to the agent as one message") + .accessibilityHint(Text(ownsShortcut ? "Command-Return" : "")) if sending { ProgressView().controlSize(.small) } diff --git a/src/macos/GenesisTools/Sources/Parts/Hub/Hub/HubNotify.swift b/src/macos/GenesisTools/Sources/Parts/Hub/Hub/HubNotify.swift index 36f20ab9db..1c3602c68e 100644 --- a/src/macos/GenesisTools/Sources/Parts/Hub/Hub/HubNotify.swift +++ b/src/macos/GenesisTools/Sources/Parts/Hub/Hub/HubNotify.swift @@ -82,10 +82,12 @@ struct HubNotifyStatus: Decodable, Equatable { } } - /// The button that fixes the daemon task (`tools hub notify install`), or nil while it ticks every minute. + /// The button that fixes the daemon task (`tools hub notify install`), or nil while it ticks every minute or the + /// task list could not be read (the task may be there). var daemonFixTitle: String? { - guard daemonTask != true || daemonTaskEvery != daemonTaskWanted else { return nil } - return daemonTask == true ? "Tick every minute" : "Add the daemon task" + guard let daemonTask else { return nil } + guard !daemonTask || daemonTaskEvery != daemonTaskWanted else { return nil } + return daemonTask ? "Tick every minute" : "Add the daemon task" } } diff --git a/src/macos/GenesisTools/Sources/Parts/Hub/Settings/PRActivitySettings.swift b/src/macos/GenesisTools/Sources/Parts/Hub/Settings/PRActivitySettings.swift index d46f085cbd..b6b12e03ed 100644 --- a/src/macos/GenesisTools/Sources/Parts/Hub/Settings/PRActivitySettings.swift +++ b/src/macos/GenesisTools/Sources/Parts/Hub/Settings/PRActivitySettings.swift @@ -205,7 +205,7 @@ private struct PRActivityProjectRow: View { [MenuButtonItem.action("Default (\(HubNotifyWords.cadenceTitle(config.intervalMinutes).lowercased()))", checked: own == nil) { store.set(["--repo", path, "--reset-repo-interval"]) }, .divider] - + HubNotifyWords.cadenceChoices.map { minutes in + + HubNotifyWords.cadenceOptions(including: own ?? config.intervalMinutes).map { minutes in .action(HubNotifyWords.cadenceTitle(minutes), checked: own == minutes) { store.set(["--repo", path, "--interval", String(minutes)]) } diff --git a/src/macos/GenesisTools/Tests/HubNotifyTests.swift b/src/macos/GenesisTools/Tests/HubNotifyTests.swift index 238d958e1a..821cc5fc92 100644 --- a/src/macos/GenesisTools/Tests/HubNotifyTests.swift +++ b/src/macos/GenesisTools/Tests/HubNotifyTests.swift @@ -95,6 +95,9 @@ final class HubNotifyTests: XCTestCase { .replacingOccurrences(of: "\"daemonTaskEvery\":null", with: "\"daemonTaskEvery\":\"every 5 minutes\"").utf8)) XCTAssertEqual(stale.daemonTitle, "The daemon task still runs every 5 minutes") XCTAssertEqual(stale.daemonFixTitle, "Tick every minute") + let unread = try HubNotifyStatus.decode(Data(firstRunJSON + .replacingOccurrences(of: "\"daemonTask\":false", with: "\"daemonTask\":null").utf8)) + XCTAssertNil(unread.daemonFixTitle, "an unreadable task list offers no fix: the task may be there") } /// One cadence list for the bell and the page; a stored value outside it stays pickable. From 581ad6b88df3f56c43b5bdd424f0ff94c74f6cb1 Mon Sep 17 00:00:00 2001 From: Martin <admin@phpcode.eu> Date: Sun, 25 Oct 2026 11:46:14 +0100 Subject: [PATCH 10/18] fix(GenesisKit, GenesisHub, widgets): the staged list declares Reduce Motion once, the staging tests pass stagedOnly through, and the shelf tests read the new missing-item sentence --- src/hub/lib/composer/outbox.test.ts | 6 ++++-- .../Sources/GenesisKit/Widgets/WidgetStagedViews.swift | 1 - 2 files changed, 4 insertions(+), 3 deletions(-) diff --git a/src/hub/lib/composer/outbox.test.ts b/src/hub/lib/composer/outbox.test.ts index f919f4f964..77e6a4b500 100644 --- a/src/hub/lib/composer/outbox.test.ts +++ b/src/hub/lib/composer/outbox.test.ts @@ -2270,7 +2270,7 @@ describe("durable capture and file shelf", () => { const { item } = await importShelfFile({ root: directory, input }); await unlink(item.path); await expect(attachShelfItem({ root: directory, id: item.id, key: widgetSessionKey(target) })).rejects.toThrow( - "unavailable" + "no longer on the shelf" ); expect((await readWidgetState(directory)).drafts).toEqual({}); }); @@ -2665,7 +2665,9 @@ test("shelf attachment descriptors inspect metadata and draft without creating o ); expect(await stateFile.bytes()).toEqual(before); const absent = join(directory, "not-created"); - await expect(readShelfAttachment({ root: absent, id: "missing", key: "chosen" })).rejects.toThrow("unavailable"); + await expect(readShelfAttachment({ root: absent, id: "missing", key: "chosen" })).rejects.toThrow( + "no longer on the shelf" + ); expect(await Bun.file(join(absent, "state.json")).exists()).toBe(false); }); diff --git a/src/macos/GenesisKit/Sources/GenesisKit/Widgets/WidgetStagedViews.swift b/src/macos/GenesisKit/Sources/GenesisKit/Widgets/WidgetStagedViews.swift index 55d87b8e18..a8d7b3bfb6 100644 --- a/src/macos/GenesisKit/Sources/GenesisKit/Widgets/WidgetStagedViews.swift +++ b/src/macos/GenesisKit/Sources/GenesisKit/Widgets/WidgetStagedViews.swift @@ -50,7 +50,6 @@ struct WidgetStagedList: View { @GenReduceMotion private var reduceMotion /// Unchecked decision ids; everything else is checked, so a newly staged one goes by default. @State private var skipped: Set<String> = [] - @GenReduceMotion private var reduceMotion private var scopeAll: Bool { model.stagedView == "all" || model.sessionStaged.isEmpty } private var items: [WidgetInboxItem] { scopeAll ? model.stagedItems : model.sessionStaged } From af4659024eadfe084f602004085e51e3f0b9cccb Mon Sep 17 00:00:00 2001 From: Martin <admin@phpcode.eu> Date: Sun, 25 Oct 2026 12:28:42 +0100 Subject: [PATCH 11/18] fix(GenesisKit, permissions): the permissions window rechecks only the probes it answered; a grant from elsewhere is rechecked app-wide on activation --- .../GenesisTools/Sources/Host/App/PermissionsModel.swift | 6 +++--- 1 file changed, 3 insertions(+), 3 deletions(-) diff --git a/src/macos/GenesisTools/Sources/Host/App/PermissionsModel.swift b/src/macos/GenesisTools/Sources/Host/App/PermissionsModel.swift index 1f72a77421..286baec18e 100644 --- a/src/macos/GenesisTools/Sources/Host/App/PermissionsModel.swift +++ b/src/macos/GenesisTools/Sources/Host/App/PermissionsModel.swift @@ -115,9 +115,9 @@ final class PermissionsModel: ObservableObject { } private func recheckProbes(now: Date = Date()) { - // Every probe kind, not only those answered here: another feature may have asked, and `recheck` skips a kind - // macOS never answered. - let kinds = PermissionKind.allCases.filter { $0.requestStyle == .probe } + // Only the probes answered in this window. A grant another feature got is rechecked app-wide on activation + // (`PermissionCenter.recheckGrantedProbesOnActivation`), and the rows read that remembered answer. + let kinds = PermissionKind.allCases.filter { probeResults[$0] != nil } guard !kinds.isEmpty, !rechecking, busy == nil else { return } rechecking = true lastProbeRecheck = now From 187828ea50f3ba113e015c305c925ef619f56d8e Mon Sep 17 00:00:00 2001 From: Martin <admin@phpcode.eu> Date: Sun, 25 Oct 2026 13:32:45 +0100 Subject: [PATCH 12/18] fix(GenesisKit, GenesisHub, widgets): bot review round 2: with Send at once the overview's options say Answer and Return does not send, a held dismissal cannot be answered, quitting waits for a shelf removal, the picker follows the pointer, and a stopped reminder cancels its notification --- .../Recording/RecordingTargetPicker.swift | 7 ++++--- .../GenesisKit/Widgets/LiveWidgetView.swift | 12 ++++++++++-- .../GenesisKit/Widgets/WidgetCoordinator.swift | 2 ++ .../GenesisKit/Widgets/WidgetInboxOverview.swift | 16 +++++++++++----- .../Sources/GenesisKit/Widgets/WidgetModel.swift | 8 ++++++-- .../Permissions/ApprovalAttention.swift | 16 ++++++++++++++-- 6 files changed, 47 insertions(+), 14 deletions(-) diff --git a/src/macos/GenesisKit/Sources/GenesisKit/Recording/RecordingTargetPicker.swift b/src/macos/GenesisKit/Sources/GenesisKit/Recording/RecordingTargetPicker.swift index 315dd0b2d8..2a0c72aed3 100644 --- a/src/macos/GenesisKit/Sources/GenesisKit/Recording/RecordingTargetPicker.swift +++ b/src/macos/GenesisKit/Sources/GenesisKit/Recording/RecordingTargetPicker.swift @@ -316,15 +316,16 @@ public struct RecordingTargetPicker: View { .contentShape(Rectangle()) } .buttonStyle(.genHoverPlain()) - .onHover { inside in - guard inside else { return } + // Every move, not only entry: a pointer that moves inside the row it rested on takes the highlight back. + .onContinuousHover { phase in + guard case .active = phase else { return } if let rested = keyboardMouse { let now = NSEvent.mouseLocation // The list scrolled under a resting pointer: keep the keyboard's row until the pointer moves. if hypot(now.x - rested.x, now.y - rested.y) < 3 { return } keyboardMouse = nil } - highlighted = row.id + if highlighted != row.id { highlighted = row.id } } .accessibilityLabel(accessibilityLabel(row)) .accessibilityIdentifier("recording.picker.\(row.id)") diff --git a/src/macos/GenesisKit/Sources/GenesisKit/Widgets/LiveWidgetView.swift b/src/macos/GenesisKit/Sources/GenesisKit/Widgets/LiveWidgetView.swift index 14002af101..476f0890c3 100644 --- a/src/macos/GenesisKit/Sources/GenesisKit/Widgets/LiveWidgetView.swift +++ b/src/macos/GenesisKit/Sources/GenesisKit/Widgets/LiveWidgetView.swift @@ -1445,6 +1445,7 @@ final class WidgetLedgerUndo: ObservableObject { moves[id] = move // Folded, the card is no longer the decision the number keys and the composer answer. GenMotion.perform(expanding: false, reduceMotion: reduceMotion) { _ = model.foldedCardIDs.insert(id) } + model.heldCardIDs.insert(id) model.postNote(note, action: WidgetNoticeAction(title: "Undo") { [weak self] in self?.undo(id, reduceMotion: reduceMotion) }) @@ -1480,13 +1481,19 @@ final class WidgetLedgerUndo: ObservableObject { self?.sending[id] = nil // The log refused it (the item changed elsewhere): the card shows its real state again, and the error // line says why. - if self?.moves[id] == hold.move { self?.moves[id] = nil } + if self?.moves[id] == hold.move { + self?.moves[id] = nil + model.heldCardIDs.remove(id) + } }, completed: { [weak self] in self?.sending[id] = nil // The card keeps its new look until a snapshot carries the newer revision; then the entry is inert. Task { @MainActor [weak self] in try? await Task.sleep(for: .seconds(30)) - if self?.moves[id] == hold.move { self?.moves[id] = nil } + if self?.moves[id] == hold.move { + self?.moves[id] = nil + model.heldCardIDs.remove(id) + } } }) } @@ -1497,6 +1504,7 @@ final class WidgetLedgerUndo: ObservableObject { hold.deadline?.cancel() moves[id] = nil let model = hold.model + model.heldCardIDs.remove(id) GenMotion.perform(expanding: true, reduceMotion: reduceMotion) { _ = model.foldedCardIDs.remove(id) } model.postNote("Undone.") } diff --git a/src/macos/GenesisKit/Sources/GenesisKit/Widgets/WidgetCoordinator.swift b/src/macos/GenesisKit/Sources/GenesisKit/Widgets/WidgetCoordinator.swift index 06a2172dba..5a9c3fafa3 100644 --- a/src/macos/GenesisKit/Sources/GenesisKit/Widgets/WidgetCoordinator.swift +++ b/src/macos/GenesisKit/Sources/GenesisKit/Widgets/WidgetCoordinator.swift @@ -265,6 +265,8 @@ public final class WidgetCoordinator: NSObject, NSWindowDelegate { if let shutdownTask { await shutdownTask.value; return } let task = Task { [self] in stop() + // A shelf item removed during its Undo is removed before the app quits, or it is back on the next launch. + await shelf?.waitForRemoval() await voiceNotes?.shutdown() await runtimeStart?.value await flowRuntime.stop() diff --git a/src/macos/GenesisKit/Sources/GenesisKit/Widgets/WidgetInboxOverview.swift b/src/macos/GenesisKit/Sources/GenesisKit/Widgets/WidgetInboxOverview.swift index 76efbe78bf..0ecf596efa 100644 --- a/src/macos/GenesisKit/Sources/GenesisKit/Widgets/WidgetInboxOverview.swift +++ b/src/macos/GenesisKit/Sources/GenesisKit/Widgets/WidgetInboxOverview.swift @@ -56,7 +56,8 @@ struct WidgetInboxOverview: View { static func letter(_ index: Int) -> String { String(UnicodeScalar(97 + index).map(Character.init) ?? "?") } /// Up and Down move the highlight, and inside an open decision walk its options first; Return opens or folds the - /// highlighted row, or stages the highlighted option as a click does. No motion: keys repeat. + /// highlighted row, or stages the highlighted option as a click does; with "Send at once" Return does not send, + /// as in a session's inbox. No motion: keys repeat. private func handle(_ kind: WidgetListKey.Kind, rows: [WidgetInboxItem], proxy: ScrollViewProxy) { let current = focusID.flatMap { id in rows.firstIndex { $0.id == id } } let options = current.map { openOptions(rows[$0]) } ?? [] @@ -66,6 +67,10 @@ struct WidgetInboxOverview: View { guard let current else { return } let item = rows[current] if let option { + guard model.decisionClickStages else { + model.postNote("Return does not send an answer. Click the option to send it.") + return + } model.choose(item, option: options[option]) return } @@ -290,19 +295,20 @@ struct WidgetInboxOverview: View { .contentShape(shape) } - /// One option of an open decision as a button. A pick here always stages (`WidgetModel.choose(_:option:)` for an - /// inbox item), whatever the decision click setting says, so its words say Stage too. + /// One option of an open decision as a button. It stages, or with "Send at once" sends, as the decision click + /// setting says (`WidgetModel.choose(_:option:)` for an inbox item), and its words follow that setting. private func optionButton(_ item: WidgetInboxItem, letter: String, title: String, picked: Bool) -> some View { let focused = focusID == item.id && focusedOption == letter let recommended = item.recommended == letter + let stages = model.decisionClickStages let tooltip = LiveWidgetView.choiceTooltip(WidgetChoice(id: letter, title: title, recommended: recommended), - index: 10, stages: true, picked: picked) + index: 10, stages: stages, picked: picked) return Button { model.choose(item, option: letter) } label: { optionLabel(letter, title: title, picked: picked, focused: focused, recommended: recommended) } .buttonStyle(RowButtonStyle(cornerRadius: 8)) .instantTooltip(tooltip) - .accessibilityLabel("Stage " + letter + ": " + title) + .accessibilityLabel((stages ? "Stage " : "Answer ") + letter + ": " + title) .accessibilityValue(picked ? "Staged" : "") .accessibilityAddTraits(focused ? .isSelected : []) .id(item.id + "#" + letter) diff --git a/src/macos/GenesisKit/Sources/GenesisKit/Widgets/WidgetModel.swift b/src/macos/GenesisKit/Sources/GenesisKit/Widgets/WidgetModel.swift index d5d346581b..dd4bcf648d 100644 --- a/src/macos/GenesisKit/Sources/GenesisKit/Widgets/WidgetModel.swift +++ b/src/macos/GenesisKit/Sources/GenesisKit/Widgets/WidgetModel.swift @@ -465,9 +465,13 @@ public final class WidgetModel: ObservableObject { } /// Inbox rows the user folded. A folded decision stays selected, but nothing answers it until it opens again. @Published public var foldedCardIDs: Set<String> = [] - /// The decision the composer and the number keys answer: the selected one, unanswered and not folded. + /// Inbox rows shown as dismissed or done while their Undo runs or their change is on its way + /// (`WidgetLedgerUndo`). Opened again, such a row is still not answered. + @Published public var heldCardIDs: Set<String> = [] + /// The decision the composer and the number keys answer: the selected one, unanswered, not folded and not held. public var answerCard: WidgetCard? { - guard let card, card.kind == "decision", card.needsAnswer, !foldedCardIDs.contains(card.id) else { return nil } + guard let card, card.kind == "decision", card.needsAnswer, !foldedCardIDs.contains(card.id), + !heldCardIDs.contains(card.id) else { return nil } return card } /// The decision whose card shows the composer for the user's own answer instead of the options. diff --git a/src/macos/GenesisKit/Sources/GenesisKitBase/Permissions/ApprovalAttention.swift b/src/macos/GenesisKit/Sources/GenesisKitBase/Permissions/ApprovalAttention.swift index 2a960ed561..9a36c0ff32 100644 --- a/src/macos/GenesisKit/Sources/GenesisKitBase/Permissions/ApprovalAttention.swift +++ b/src/macos/GenesisKit/Sources/GenesisKitBase/Permissions/ApprovalAttention.swift @@ -80,6 +80,7 @@ final class ApprovalReminder { private var pending: DispatchWorkItem? private var expiry: DispatchWorkItem? private var postedID: String? + private var stopped = false init(model: ApprovalDialogModel, onClick: @escaping () -> Void) { self.model = model @@ -132,8 +133,17 @@ final class ApprovalReminder { content.threadIdentifier = "approval.\(model.identifier)" content.userInfo = [ApprovalNotificationRelay.tokenKey: token] let id = "approval.\(model.identifier).\(token)" - center.add(UNNotificationRequest(identifier: id, content: content, trigger: nil)) { error in - if let error { KitHost.log("approval reminder failed id=\(id): \(error.localizedDescription)") } + center.add(UNNotificationRequest(identifier: id, content: content, trigger: nil)) { [weak self] error in + if let error { + KitHost.log("approval reminder failed id=\(id): \(error.localizedDescription)") + return + } + // The add is asynchronous: a request answered while it ran must not leave its card behind. + DispatchQueue.main.async { + MainActor.assumeIsolated { + if self?.stopped ?? true { center.removeDeliveredNotifications(withIdentifiers: [id]) } + } + } } postedID = id KitHost.log("approval \(model.identifier) reminder posted id=\(id)") @@ -142,12 +152,14 @@ final class ApprovalReminder { /// Cancels a reminder not posted yet, takes a posted one out of Notification Center, and gives the click handling /// back to the host. Safe to call twice. func stop() { + stopped = true pending?.cancel() pending = nil expiry?.cancel() expiry = nil guard let center = Self.center else { return } if let id = postedID { + center.removePendingNotificationRequests(withIdentifiers: [id]) center.removeDeliveredNotifications(withIdentifiers: [id]) postedID = nil } From bfb77cba0a25f63b68248cad23dfc204f5b4310a Mon Sep 17 00:00:00 2001 From: Martin <admin@phpcode.eu> Date: Sun, 25 Oct 2026 13:32:46 +0100 Subject: [PATCH 13/18] fix(GenesisHub): bot review round 2: a pane notice carries its tone, the Decisions pane sends with Command-Return, and the duplicate launch notice helper is gone --- .../Sources/Parts/Hub/Hub/HubDecisionsPane.swift | 2 +- .../Sources/Parts/Hub/Hub/HubInbox.swift | 10 ++++++---- .../Sources/Parts/Hub/Hub/HubPRs.swift | 15 ++------------- .../Sources/Parts/Hub/Hub/HubTimeline.swift | 3 ++- 4 files changed, 11 insertions(+), 19 deletions(-) diff --git a/src/macos/GenesisTools/Sources/Parts/Hub/Hub/HubDecisionsPane.swift b/src/macos/GenesisTools/Sources/Parts/Hub/Hub/HubDecisionsPane.swift index a64293ecac..22581435dd 100644 --- a/src/macos/GenesisTools/Sources/Parts/Hub/Hub/HubDecisionsPane.swift +++ b/src/macos/GenesisTools/Sources/Parts/Hub/Hub/HubDecisionsPane.swift @@ -81,7 +81,7 @@ struct DecisionsView: View { ForEach(shown) { item in InboxDecisionCard(model: model, inbox: inbox, session: session, item: item) } - InboxSendBar(model: model, inbox: inbox, session: session) + InboxSendBar(model: model, inbox: inbox, session: session, claimsShortcut: true) } } .padding(18) diff --git a/src/macos/GenesisTools/Sources/Parts/Hub/Hub/HubInbox.swift b/src/macos/GenesisTools/Sources/Parts/Hub/Hub/HubInbox.swift index 9961315201..c8f78a4190 100644 --- a/src/macos/GenesisTools/Sources/Parts/Hub/Hub/HubInbox.swift +++ b/src/macos/GenesisTools/Sources/Parts/Hub/Hub/HubInbox.swift @@ -794,7 +794,7 @@ final class HubInboxModel: ObservableObject { } /// Brings the session's cmux pane forward through its own agent's resolver (`tools <agent> cmux focus`). - func focusPane(_ session: InboxSession, notice: @escaping (String) -> Void) { + func focusPane(_ session: InboxSession, notice: @escaping (String, NoticePill.Tone) -> Void) { guard let sessionId = session.sessionId else { return } let tool = cmuxTool(provider: session.provider) DispatchQueue.global(qos: .userInitiated).async { @@ -804,7 +804,7 @@ final class HubInboxModel: ObservableObject { let message = failed ? "No cmux pane found for this session: " + (capture.map { String(decoding: $0.stderr, as: UTF8.self).trimmed.suffix(120) } ?? "tools did not run") : "Focused the session's cmux pane" - DispatchQueue.main.async { notice(String(message)) } + DispatchQueue.main.async { notice(String(message), failed ? .error : .success) } } } } @@ -1203,7 +1203,7 @@ private struct InboxSessionSection: View { } if let provider = session.provider, ["claude", "codex", "grok"].contains(provider) { IconButton(systemName: "terminal", tooltip: "Show this session's cmux pane") { - inbox.focusPane(session) { model.post($0, $0.hasPrefix("No cmux pane") ? .error : .success) } + inbox.focusPane(session) { model.post($0, $1) } } } IconButton(systemName: "text.bubble", tooltip: hubSession == nil ? "This session is older than the hub's list" : "Open this session's transcript in the hub") { @@ -1222,13 +1222,15 @@ struct InboxSendBar: View { @ObservedObject var model: HubModel @ObservedObject var inbox: HubInboxModel let session: InboxSession + /// The Decisions pane shows one session's bar, whatever the Inbox has selected, so that bar takes ⌘↩. + var claimsShortcut = false private var drafted: [InboxItem] { session.draftedItems } private var sending: Bool { session.sessionId.map { inbox.sendingSessions.contains($0) } ?? false } private var result: InboxDelivery? { session.sessionId.flatMap { inbox.sessionResults[$0] } } private var ownsShortcut: Bool { guard !drafted.isEmpty else { return false } - if inbox.selectedID == session.id { return true } + if claimsShortcut || inbox.selectedID == session.id { return true } let staging = inbox.sessions.filter { !$0.draftedItems.isEmpty } return staging.count == 1 && staging[0].id == session.id } diff --git a/src/macos/GenesisTools/Sources/Parts/Hub/Hub/HubPRs.swift b/src/macos/GenesisTools/Sources/Parts/Hub/Hub/HubPRs.swift index 471f75bd43..f5852b46d5 100644 --- a/src/macos/GenesisTools/Sources/Parts/Hub/Hub/HubPRs.swift +++ b/src/macos/GenesisTools/Sources/Parts/Hub/Hub/HubPRs.swift @@ -2130,7 +2130,7 @@ struct PRDetailView: View { .popover(isPresented: $reviewing, arrowEdge: .bottom) { LaunchPicker(mode: .new(cwd: path, name: "review \(pr.repo) \(pr.label)", prompt: Self.reviewPrompt(pr))) { outcome in reviewing = false - model.postLaunch(outcome) + model.postLaunchNotice(outcome) } } // The palette's "review": the same popover, with the command shown before Start. @@ -2150,7 +2150,7 @@ struct PRDetailView: View { .popover(isPresented: $launching, arrowEdge: .bottom) { LaunchPicker(mode: .new(cwd: path, name: "\(pr.repo) \(pr.label)")) { outcome in launching = false - model.postLaunch(outcome) + model.postLaunchNotice(outcome) } } } @@ -2758,14 +2758,3 @@ struct PRSessionsSection: View { return "No session worked on this branch, its commits or its files." } } - -extension HubModel { - /// A launch outcome in the header with its tone: a start is success, a failure is an error, a cancel says nothing. - func postLaunch(_ outcome: LaunchOutcome) { - switch outcome { - case .cancelled: break - case .launched(let text): post(text, .success) - case .failed(let text): post(text, .error) - } - } -} diff --git a/src/macos/GenesisTools/Sources/Parts/Hub/Hub/HubTimeline.swift b/src/macos/GenesisTools/Sources/Parts/Hub/Hub/HubTimeline.swift index 70e3f96225..8edd38546e 100644 --- a/src/macos/GenesisTools/Sources/Parts/Hub/Hub/HubTimeline.swift +++ b/src/macos/GenesisTools/Sources/Parts/Hub/Hub/HubTimeline.swift @@ -702,7 +702,8 @@ enum TimelinePrompts { // shows the same colour in every pane's header (`HubNotice`, Hub/HubWindow.swift). extension HubModel { - /// A launch from an Activity row (resume, a new agent) as a note: started or refused. + /// A launch (resume, a new agent) from an Activity or PR row as a note with its tone: a start is success, a + /// failure is an error, a cancel says nothing. @MainActor func postLaunchNotice(_ outcome: LaunchOutcome) { switch outcome { From 5a7750a9fa71bb8bb8e24831def111c75c14bf49 Mon Sep 17 00:00:00 2001 From: Martin <admin@phpcode.eu> Date: Sun, 25 Oct 2026 14:14:25 +0100 Subject: [PATCH 14/18] fix(GenesisKit, GenesisHub, widgets): bot review round 3: settings search finds a row title repeated in several cards, a refused voice note discard keeps its edits, the inbox highlight leaves with its row, a failed send drops its Sending note, an approval reminder counts as posted only once added, and the PR list offers Clear filter and names an unknown check state --- .../Settings/NativeSettingsSearch.swift | 7 +++++-- .../Settings/NativeSettingsViews.swift | 7 +++++-- .../VoiceNotes/WidgetVoiceNotesStore.swift | 7 +++++-- .../Widgets/WidgetInboxOverview.swift | 8 ++++++++ .../Widgets/WidgetModel+Staging.swift | 5 ++++- .../Permissions/ApprovalAttention.swift | 14 +++++++++---- .../SettingsControlsTests.swift | 13 +++++++++++- .../ToolsLineStreamTests.swift | 20 +++++++++++++++++++ .../WidgetInboxTasksPolishTests.swift | 1 + .../Sources/Parts/Hub/Hub/HubPRs.swift | 13 +++++++++--- 10 files changed, 80 insertions(+), 15 deletions(-) diff --git a/src/macos/GenesisKit/Sources/GenesisKit/Settings/NativeSettingsSearch.swift b/src/macos/GenesisKit/Sources/GenesisKit/Settings/NativeSettingsSearch.swift index 1db0a9b556..04e27cc456 100644 --- a/src/macos/GenesisKit/Sources/GenesisKit/Settings/NativeSettingsSearch.swift +++ b/src/macos/GenesisKit/Sources/GenesisKit/Settings/NativeSettingsSearch.swift @@ -24,8 +24,9 @@ public struct NativeSettingsSearchEntry: Equatable, Sendable { self.card = card } - /// The entry's name on its page; titles are unique on a page in practice, and a repeat finds the first. - var key: String { "\(kind.rawValue):\(title)" } + /// The entry's name on its page. A row's names its card too: one row title can repeat in several cards (every + /// background task has a Schedule row), and each must be its own result and jump target. + var key: String { "\(kind.rawValue):\(card.map { "\($0)/" } ?? "")\(title)" } } /// A settings page as the search sees it: its words, without its views. @@ -123,6 +124,8 @@ struct NativeSettingsSearchKey: PreferenceKey { extension EnvironmentValues { /// The settings page a card or row is drawn on; nil outside the settings window. @Entry var nativeSettingsPageID: String? = nil + /// The title of the titled card a row is drawn in, so the row's search key and its jump target name the card. + @Entry var nativeSettingsSearchCard: String? = nil /// True in the hidden copy of the pages the search reads: nothing there flashes, and a page's own code must not /// speak (`GenAnnounce`) or ask for anything, since nobody sees it. @Entry public var nativeSettingsIndexing = false diff --git a/src/macos/GenesisKit/Sources/GenesisKit/Settings/NativeSettingsViews.swift b/src/macos/GenesisKit/Sources/GenesisKit/Settings/NativeSettingsViews.swift index e1bd2eeaa0..3e8228d18c 100644 --- a/src/macos/GenesisKit/Sources/GenesisKit/Settings/NativeSettingsViews.swift +++ b/src/macos/GenesisKit/Sources/GenesisKit/Settings/NativeSettingsViews.swift @@ -1,6 +1,7 @@ import SwiftUI public struct NativeSettingsCard<Content: View>: View { + @Environment(\.nativeSettingsSearchCard) private var searchCard private let title: String? private let subtitle: String? private let content: Content @@ -19,7 +20,7 @@ public struct NativeSettingsCard<Content: View>: View { if let subtitle { Text(subtitle).font(.system(size: 11)).foregroundStyle(.secondary) } } } - content + content.environment(\.nativeSettingsSearchCard, title ?? searchCard) }.padding(20).frame(maxWidth: .infinity, alignment: .leading) .nativeGlassSurface() // A titled card is a place the settings search finds and flashes (NativeSettingsSearch.swift). @@ -33,6 +34,7 @@ public struct NativeSettingsCard<Content: View>: View { public struct NativeSettingsRow<Accessory: View>: View { @Environment(\.isEnabled) private var isEnabled + @Environment(\.nativeSettingsSearchCard) private var searchCard private let title: String private let detail: String? private let accessory: Accessory @@ -61,7 +63,8 @@ public struct NativeSettingsRow<Accessory: View>: View { Spacer(minLength: 0) accessory } - .nativeSettingsSearchable(NativeSettingsSearchEntry(kind: .row, title: title, detail: detail), cornerRadius: 10, outset: 8) + .nativeSettingsSearchable(NativeSettingsSearchEntry(kind: .row, title: title, detail: detail, card: searchCard), + cornerRadius: 10, outset: 8) } } diff --git a/src/macos/GenesisKit/Sources/GenesisKit/Widgets/VoiceNotes/WidgetVoiceNotesStore.swift b/src/macos/GenesisKit/Sources/GenesisKit/Widgets/VoiceNotes/WidgetVoiceNotesStore.swift index 3201099282..743b12b46e 100644 --- a/src/macos/GenesisKit/Sources/GenesisKit/Widgets/VoiceNotes/WidgetVoiceNotesStore.swift +++ b/src/macos/GenesisKit/Sources/GenesisKit/Widgets/VoiceNotes/WidgetVoiceNotesStore.swift @@ -140,7 +140,9 @@ public final class WidgetVoiceNotesStore: ObservableObject { let snapshot = try JSONDecoder().decode(WidgetVoiceNoteSnapshot.self, from: data) // A discarded note is still on disk during its Undo; it stays out of the list. notes = snapshot.notes.filter { $0.id != discarded?.id && !deletingIDs.contains($0.id) } - drafts = drafts.filter { id, _ in self.notes.contains { $0.id == id } || id == self.discarded?.id } + drafts = drafts.filter { id, _ in + self.notes.contains { $0.id == id } || id == self.discarded?.id || self.deletingIDs.contains(id) + } if selected == nil { selectedID = notes.first?.id } cached = true watch(snapshot.statePath) @@ -355,7 +357,6 @@ public final class WidgetVoiceNotesStore: ObservableObject { discardTimer?.cancel() discardTimer = nil discarded = nil - drafts[note.id] = nil let previous = discardDelete // The request is held, not the store: a delete that runs at teardown still reaches the CLI. let request = request @@ -365,6 +366,8 @@ public final class WidgetVoiceNotesStore: ObservableObject { do { _ = try await request(["discard", note.id, "--revision", String(note.revision)]) self?.deletingIDs.remove(note.id) + // The edits go only once the note is gone: a refused delete brings the note back with them. + self?.drafts[note.id] = nil } catch { // The recording is still on disk: show why and read the list again, so it comes back. self?.deletingIDs.remove(note.id) diff --git a/src/macos/GenesisKit/Sources/GenesisKit/Widgets/WidgetInboxOverview.swift b/src/macos/GenesisKit/Sources/GenesisKit/Widgets/WidgetInboxOverview.swift index 0ecf596efa..6e3ba79fca 100644 --- a/src/macos/GenesisKit/Sources/GenesisKit/Widgets/WidgetInboxOverview.swift +++ b/src/macos/GenesisKit/Sources/GenesisKit/Widgets/WidgetInboxOverview.swift @@ -43,6 +43,14 @@ struct WidgetInboxOverview: View { handle(key.kind, rows: drawnOrder(items), proxy: proxy) } .onChange(of: focusID) { _, id in model.inboxOverviewHasFocus = id != nil } + // A row that left the list (a filter, the grouping or a snapshot) cannot keep the keys' highlight, or + // Return would still answer it. + .onChange(of: drawnOrder(items).map(\.id)) { _, ids in + if let id = focusID, !ids.contains(id) { + focusID = nil + focusedOption = nil + } + } .onDisappear { model.inboxOverviewHasFocus = false } } } diff --git a/src/macos/GenesisKit/Sources/GenesisKit/Widgets/WidgetModel+Staging.swift b/src/macos/GenesisKit/Sources/GenesisKit/Widgets/WidgetModel+Staging.swift index ead99d2289..9a528bf4c3 100644 --- a/src/macos/GenesisKit/Sources/GenesisKit/Widgets/WidgetModel+Staging.swift +++ b/src/macos/GenesisKit/Sources/GenesisKit/Widgets/WidgetModel+Staging.swift @@ -139,6 +139,8 @@ extension WidgetModel { let staged = action == "send-staged" let opened = openedAt if thenClose { postNote("Sending " + Plural.count(items.count, "decision") + "…") } + // The "Sending…" note this send put up; a failure takes it down, unless another note replaced it meanwhile. + let sendingNote = thenClose || action == "resend-answers" ? notice : nil var targets: [String: WidgetJSON] = [:] var titles: [String: String] = [:] for item in items { @@ -170,6 +172,7 @@ extension WidgetModel { return } if outcome.failed > 0 { + if let sendingNote, notice == sendingNote { notice = nil } fail(text, detail: outcome.details) if staged { keptStaged.subtract(items.map(\.sourceId)) } } else { @@ -178,7 +181,7 @@ extension WidgetModel { } flashUnsent(waiting.isEmpty ? items : waiting) } catch { - if action == "resend-answers" { self.notice = nil } + if let sendingNote, self.notice == sendingNote { self.notice = nil } PerfLog.mark("widget.decision-send \(action) failed: \(error.localizedDescription)") fail(staged ? "The decisions could not be sent. They stay staged, so you can send them again." : "The answers could not be sent again. They still wait for the session's next prompt.", diff --git a/src/macos/GenesisKit/Sources/GenesisKitBase/Permissions/ApprovalAttention.swift b/src/macos/GenesisKit/Sources/GenesisKitBase/Permissions/ApprovalAttention.swift index 9a36c0ff32..9ca3f5261b 100644 --- a/src/macos/GenesisKit/Sources/GenesisKitBase/Permissions/ApprovalAttention.swift +++ b/src/macos/GenesisKit/Sources/GenesisKitBase/Permissions/ApprovalAttention.swift @@ -133,20 +133,26 @@ final class ApprovalReminder { content.threadIdentifier = "approval.\(model.identifier)" content.userInfo = [ApprovalNotificationRelay.tokenKey: token] let id = "approval.\(model.identifier).\(token)" + // No authorization prompt here: a prompt in the middle of an approval is the wrong moment, and the first grant + // is `tools notify authorize` (macos.md). Without it the add fails, the log says so, and the panel still waits. + let identifier = model.identifier center.add(UNNotificationRequest(identifier: id, content: content, trigger: nil)) { [weak self] error in if let error { KitHost.log("approval reminder failed id=\(id): \(error.localizedDescription)") return } - // The add is asynchronous: a request answered while it ran must not leave its card behind. DispatchQueue.main.async { MainActor.assumeIsolated { - if self?.stopped ?? true { center.removeDeliveredNotifications(withIdentifiers: [id]) } + // The add is asynchronous: a request answered while it ran must not leave its card behind. + guard let self, !self.stopped else { + center.removeDeliveredNotifications(withIdentifiers: [id]) + return + } + self.postedID = id + KitHost.log("approval \(identifier) reminder posted id=\(id)") } } } - postedID = id - KitHost.log("approval \(model.identifier) reminder posted id=\(id)") } /// Cancels a reminder not posted yet, takes a posted one out of Notification Center, and gives the click handling diff --git a/src/macos/GenesisKit/Tests/GenesisKitTests/SettingsControlsTests.swift b/src/macos/GenesisKit/Tests/GenesisKitTests/SettingsControlsTests.swift index f0b0170c0f..f71d6465db 100644 --- a/src/macos/GenesisKit/Tests/GenesisKitTests/SettingsControlsTests.swift +++ b/src/macos/GenesisKit/Tests/GenesisKitTests/SettingsControlsTests.swift @@ -511,7 +511,18 @@ final class SettingsControlsTests: XCTestCase { XCTAssertEqual(NativeSettingsHighlightCenter.split(target).page, "notifications.prActivity") XCTAssertEqual( NativeSettingsHighlightCenter.split(target).anchor, - NativeSettingsSearch.anchor(page: "notifications.prActivity", key: "row:Cadence")) + NativeSettingsSearch.anchor(page: "notifications.prActivity", key: "row:Defaults/Cadence")) + + // One row title in two cards (a Schedule row per background task): two results, each with its own target. + let tasks = ["general": [ + NativeSettingsSearchEntry(kind: .card, title: "Backup"), + NativeSettingsSearchEntry(kind: .row, title: "Schedule", card: "Backup"), + NativeSettingsSearchEntry(kind: .card, title: "Cleanup"), + NativeSettingsSearchEntry(kind: .row, title: "Schedule", card: "Cleanup"), + ]] + let schedules = NativeSettingsSearch.results("schedule", pages: pages, entries: tasks) + XCTAssertEqual(schedules.map(\.context), ["General · Backup", "General · Cleanup"]) + XCTAssertEqual(Set(schedules.map(\.target)).count, 2) // A detail match and a case- and accent-blind match; every word must be found somewhere. XCTAssertEqual(NativeSettingsSearch.results("SILENT", pages: pages, entries: entries).map(\.title), ["Only my PRs"]) diff --git a/src/macos/GenesisKit/Tests/GenesisKitTests/ToolsLineStreamTests.swift b/src/macos/GenesisKit/Tests/GenesisKitTests/ToolsLineStreamTests.swift index 3ac938700d..78ec3e4f66 100644 --- a/src/macos/GenesisKit/Tests/GenesisKitTests/ToolsLineStreamTests.swift +++ b/src/macos/GenesisKit/Tests/GenesisKitTests/ToolsLineStreamTests.swift @@ -495,6 +495,26 @@ final class WidgetVoiceNotesStoreTests: XCTestCase { XCTAssertNil(store.error, "a filed note reports no failure") } + /// A delete the CLI refuses brings the note back with the transcript edits it had. + func testARefusedDiscardKeepsTheEditedTranscript() async throws { + let store = make(request: { args in + if args.first == "discard" { throw ToolsBridgeError.refused("the note changed") } + return try self.data(["revision": 0, "notes": [self.note()], "statePath": "/fixture/widget/voice-notes/notes.json"]) + }, undoSeconds: 0) + defer { store.stop() } + store.refresh() + await store.waitForRefresh() + store.text = "Edited words" + + store.discard() + store.confirmDiscard() + await store.waitForDiscard() + await store.waitForRefresh() + XCTAssertEqual(store.notes.map(\.id), [noteID], "the refused delete brings the note back") + store.selectedID = noteID + XCTAssertEqual(store.text, "Edited words") + } + func testOpeningVoiceNotesDoesNotBuildHundredsOfRecipientMenuItems() async throws { _ = NSApplication.shared let recipients = (0..<800).map { index in diff --git a/src/macos/GenesisKit/Tests/GenesisKitTests/WidgetInboxTasksPolishTests.swift b/src/macos/GenesisKit/Tests/GenesisKitTests/WidgetInboxTasksPolishTests.swift index 86b57bebce..6407e30b4b 100644 --- a/src/macos/GenesisKit/Tests/GenesisKitTests/WidgetInboxTasksPolishTests.swift +++ b/src/macos/GenesisKit/Tests/GenesisKitTests/WidgetInboxTasksPolishTests.swift @@ -1259,6 +1259,7 @@ final class WidgetStagingTests: XCTestCase { XCTAssertEqual(model.expanded, .right) XCTAssertEqual(model.error, "The decisions could not be sent. They stay staged, so you can send them again.") XCTAssertEqual(model.errorDetail, "hub call exited 1: fixture stderr") + XCTAssertNil(model.notice, "the Sending note does not stay beside the error") model.requestCollapse() XCTAssertTrue(model.stagedLeavePrompt, "still staged, so the next close asks again") diff --git a/src/macos/GenesisTools/Sources/Parts/Hub/Hub/HubPRs.swift b/src/macos/GenesisTools/Sources/Parts/Hub/Hub/HubPRs.swift index f5852b46d5..19831f11ab 100644 --- a/src/macos/GenesisTools/Sources/Parts/Hub/Hub/HubPRs.swift +++ b/src/macos/GenesisTools/Sources/Parts/Hub/Hub/HubPRs.swift @@ -1601,14 +1601,21 @@ struct PRListView: View { .accessibilityElement(children: .ignore) .accessibilityLabel("Loading PRs and MRs") } else if prs.prs.isEmpty, !prs.loading, prs.lastLoad != nil, prs.errors.isEmpty { + // A filter the server found nothing for: reloading repeats the same query, so the way + // out is clearing it. + let filtered = !prs.mineOnly && !model.filter.trimmed.isEmpty EmptyState( symbol: "arrow.triangle.pull", text: prs.mineOnly ? "None of your PRs/MRs match." : "No PRs/MRs match.", - detail: prs.mineOnly ? "Turn off Mine to see everyone's." : "Try another state, or reload.", - actionTitle: prs.mineOnly ? "Show everyone's" : "Reload", + detail: prs.mineOnly ? "Turn off Mine to see everyone's." + : filtered ? "Nothing matches “\(model.filter)”." : "Try another state, or reload.", + actionTitle: prs.mineOnly ? "Show everyone's" : filtered ? "Clear filter" : "Reload", action: { if prs.mineOnly { prs.mineOnly = false + } else if filtered { + model.filter = "" + return } prs.reload() } @@ -1968,7 +1975,7 @@ struct CIBadge: View { case "failed": return "Some checks failed" case "running": return "Checks are running" case "pending": return "Checks are waiting to start" - default: return "The checks have no clear state" + default: return "The checks report “\(ci)”" } }() let icon = Image(systemName: symbol).font(.system(size: 11)).foregroundColor(color) From 04ea28b38d5bc0165da11e0fdc1b17cf8e66381a Mon Sep 17 00:00:00 2001 From: Martin <admin@phpcode.eu> Date: Mon, 26 Oct 2026 01:04:14 +0100 Subject: [PATCH 15/18] fix(GenesisKit, GenesisHub, widgets): #563 followup - a decision dismissed during its Undo is never sent, a loading settings page stays unindexed, queued sends keep their reasons behind Details, the Inbox keys follow the quiet fold, nested cards jump to themselves, and an approval notification shows no command --- .../Settings/NativeSettingsSearch.swift | 11 ++-- .../Settings/NativeSettingsViews.swift | 6 +- .../GenesisKit/Widgets/LiveWidgetView.swift | 61 ++++++++++++++++--- .../Widgets/WidgetModel+Staging.swift | 6 +- .../GenesisKit/Widgets/WidgetModel.swift | 15 ++++- .../Permissions/ApprovalAttention.swift | 4 +- .../SettingsControlsTests.swift | 11 ++++ .../WidgetInboxTasksPolishTests.swift | 32 ++++++++++ .../Sources/Parts/Hub/Hub/HubInbox.swift | 24 +++++--- .../Sources/Parts/Hub/Hub/HubWindow.swift | 8 +-- .../Tests/HubInboxTimelineTests.swift | 14 +++++ 11 files changed, 155 insertions(+), 37 deletions(-) diff --git a/src/macos/GenesisKit/Sources/GenesisKit/Settings/NativeSettingsSearch.swift b/src/macos/GenesisKit/Sources/GenesisKit/Settings/NativeSettingsSearch.swift index 04e27cc456..341a06819f 100644 --- a/src/macos/GenesisKit/Sources/GenesisKit/Settings/NativeSettingsSearch.swift +++ b/src/macos/GenesisKit/Sources/GenesisKit/Settings/NativeSettingsSearch.swift @@ -105,12 +105,10 @@ final class NativeSettingsSearchIndex: ObservableObject { if entries[page] != list { entries[page] = list } } - /// A page that was drawn at least once, entries or not. - func markSeen(_ page: String) { - if entries[page] == nil { entries[page] = [] } - } - - func knows(_ page: String) -> Bool { entries[page] != nil } + /// A page whose cards and rows reached the index. A page drawn while it still loads reports nothing yet (the + /// Daemon page shows its task cards only after its status arrives): it stays unknown, so the next search draws it + /// again instead of caching it as a page with nothing to find. + func knows(_ page: String) -> Bool { !(entries[page]?.isEmpty ?? true) } } struct NativeSettingsSearchKey: PreferenceKey { @@ -188,7 +186,6 @@ struct NativeSettingsSearchIndexer: View { .onPreferenceChange(NativeSettingsSearchKey.self) { entries in MainActor.assumeIsolated { search.set(id, entries) } } - .onAppear { search.markSeen(id) } } } .environment(\.nativeSettingsIndexing, true) diff --git a/src/macos/GenesisKit/Sources/GenesisKit/Settings/NativeSettingsViews.swift b/src/macos/GenesisKit/Sources/GenesisKit/Settings/NativeSettingsViews.swift index 3e8228d18c..19350862a0 100644 --- a/src/macos/GenesisKit/Sources/GenesisKit/Settings/NativeSettingsViews.swift +++ b/src/macos/GenesisKit/Sources/GenesisKit/Settings/NativeSettingsViews.swift @@ -23,9 +23,11 @@ public struct NativeSettingsCard<Content: View>: View { content.environment(\.nativeSettingsSearchCard, title ?? searchCard) }.padding(20).frame(maxWidth: .infinity, alignment: .leading) .nativeGlassSurface() - // A titled card is a place the settings search finds and flashes (NativeSettingsSearch.swift). + // A titled card is a place the settings search finds and flashes (NativeSettingsSearch.swift). A card inside + // another titled card names that parent, so its result and its jump marker share one key. if let title { - card.nativeSettingsSearchable(NativeSettingsSearchEntry(kind: .card, title: title, detail: subtitle), cornerRadius: 22) + card.nativeSettingsSearchable( + NativeSettingsSearchEntry(kind: .card, title: title, detail: subtitle, card: searchCard), cornerRadius: 22) } else { card } diff --git a/src/macos/GenesisKit/Sources/GenesisKit/Widgets/LiveWidgetView.swift b/src/macos/GenesisKit/Sources/GenesisKit/Widgets/LiveWidgetView.swift index 476f0890c3..334eb7cf1a 100644 --- a/src/macos/GenesisKit/Sources/GenesisKit/Widgets/LiveWidgetView.swift +++ b/src/macos/GenesisKit/Sources/GenesisKit/Widgets/LiveWidgetView.swift @@ -123,17 +123,22 @@ public struct LiveWidgetView: View { VStack(alignment: .leading, spacing: 12) { header if let notice = model.notice { - HStack(alignment: .top) { - Text(notice).font(.caption).foregroundStyle(.secondary) - .fixedSize(horizontal: false, vertical: true) - Spacer() - if let action = model.noticeAction { - Button(action.title) { action.run() } - .buttonStyle(.genHover()) - .controlSize(.small) - .accessibilityIdentifier("widget.notice.action") + VStack(alignment: .leading, spacing: 4) { + HStack(alignment: .top) { + Text(notice).font(.caption).foregroundStyle(.secondary) + .fixedSize(horizontal: false, vertical: true) + Spacer() + if let action = model.noticeAction { + Button(action.title) { action.run() } + .buttonStyle(.genHover()) + .controlSize(.small) + .accessibilityIdentifier("widget.notice.action") + } + IconButton(systemName: "xmark", tooltip: "Dismiss this note") { model.notice = nil } + } + if let detail = model.noticeDetail { + WidgetNoticeDetails(text: detail) } - IconButton(systemName: "xmark", tooltip: "Dismiss this note") { model.notice = nil } } // A note under the pointer waits to fade. A note closed under the pointer gets no exit event. .onHover { model.noticeHovered($0) } @@ -1394,6 +1399,8 @@ final class WidgetLedgerUndo: ObservableObject { private struct Hold { let move: Move + /// The decision's id in the log: held, it leaves every staged send (`WidgetModel.heldDecisionIDs`). + let sourceId: String let request: WidgetJSON let note: String /// Kept until the change is sent, so a panel's model that goes away still sends it. @@ -1437,6 +1444,7 @@ final class WidgetLedgerUndo: ObservableObject { let note = Self.note(card, state: state) var hold = Hold( move: move, + sourceId: card.sourceId, request: .object([ "action": "ledger", "id": .string(card.sourceId), "sessionId": .string(session), "expectedRevision": .number(Double(revision)), "state": .string(state), @@ -1446,6 +1454,7 @@ final class WidgetLedgerUndo: ObservableObject { // Folded, the card is no longer the decision the number keys and the composer answer. GenMotion.perform(expanding: false, reduceMotion: reduceMotion) { _ = model.foldedCardIDs.insert(id) } model.heldCardIDs.insert(id) + model.heldDecisionIDs.insert(card.sourceId) model.postNote(note, action: WidgetNoticeAction(title: "Undo") { [weak self] in self?.undo(id, reduceMotion: reduceMotion) }) @@ -1484,6 +1493,7 @@ final class WidgetLedgerUndo: ObservableObject { if self?.moves[id] == hold.move { self?.moves[id] = nil model.heldCardIDs.remove(id) + model.heldDecisionIDs.remove(hold.sourceId) } }, completed: { [weak self] in self?.sending[id] = nil @@ -1493,6 +1503,7 @@ final class WidgetLedgerUndo: ObservableObject { if self?.moves[id] == hold.move { self?.moves[id] = nil model.heldCardIDs.remove(id) + model.heldDecisionIDs.remove(hold.sourceId) } } }) @@ -1505,6 +1516,7 @@ final class WidgetLedgerUndo: ObservableObject { moves[id] = nil let model = hold.model model.heldCardIDs.remove(id) + model.heldDecisionIDs.remove(hold.sourceId) GenMotion.perform(expanding: true, reduceMotion: reduceMotion) { _ = model.foldedCardIDs.remove(id) } model.postNote("Undone.") } @@ -1554,3 +1566,32 @@ final class WidgetContentFit { var document: CGFloat? var scheduled = false } + +/// The full reasons behind a widget note, as `NoticePill` shows its own behind "Details". +private struct WidgetNoticeDetails: View { + let text: String + @Environment(\.accessibilityReduceMotion) private var reduceMotion + @State private var showsMore = false + + var body: some View { + VStack(alignment: .leading, spacing: 4) { + Button { + GenMotion.perform(expanding: !showsMore, reduceMotion: reduceMotion) { showsMore.toggle() } + } label: { + Label(showsMore ? "Hide details" : "Details", systemImage: showsMore ? "chevron.down" : "chevron.right") + .font(.system(size: 11)) + .foregroundColor(KitPalette.dim) + } + .buttonStyle(.genHoverPlain()) + .instantTooltip(showsMore ? "Hide why each session was not reached" : "Show why each session was not reached") + .accessibilityValue(showsMore ? "Expanded" : "Collapsed") + .accessibilityIdentifier("widget.notice.details") + if showsMore { + Text(text).font(.system(size: 11, design: .monospaced)).foregroundColor(KitPalette.dim) + .textSelection(.enabled) + .fixedSize(horizontal: false, vertical: true) + .transition(.genReveal) + } + } + } +} diff --git a/src/macos/GenesisKit/Sources/GenesisKit/Widgets/WidgetModel+Staging.swift b/src/macos/GenesisKit/Sources/GenesisKit/Widgets/WidgetModel+Staging.swift index 9a528bf4c3..9a7749aef4 100644 --- a/src/macos/GenesisKit/Sources/GenesisKit/Widgets/WidgetModel+Staging.swift +++ b/src/macos/GenesisKit/Sources/GenesisKit/Widgets/WidgetModel+Staging.swift @@ -97,7 +97,7 @@ extension WidgetModel { /// The staged items as the snapshot shows them, and the picks staged here that it now shows. func reconcileStaged(_ items: [WidgetInboxItem]) { let staged = items.filter(\.isStaged) - if staged != stagedItems { stagedItems = staged } + if staged != stagedRows { stagedRows = staged } guard !pendingStage.isEmpty else { return } let shown = Dictionary(items.map { ($0.sourceId, $0.draftOption ?? "") }, uniquingKeysWith: { first, _ in first }) // A decision the list no longer holds shows no pick, so a pending unstage ("") is done. @@ -176,8 +176,10 @@ extension WidgetModel { fail(text, detail: outcome.details) if staged { keptStaged.subtract(items.map(\.sourceId)) } } else { + // The summary names one reason at most; every session's own reason stays behind Details. postNote(text, action: waiting.isEmpty ? nil - : WidgetNoticeAction(title: "Send again") { [weak self] in self?.resendAnswers(waiting) }) + : WidgetNoticeAction(title: "Send again") { [weak self] in self?.resendAnswers(waiting) }, + detail: outcome.details) } flashUnsent(waiting.isEmpty ? items : waiting) } catch { diff --git a/src/macos/GenesisKit/Sources/GenesisKit/Widgets/WidgetModel.swift b/src/macos/GenesisKit/Sources/GenesisKit/Widgets/WidgetModel.swift index dd4bcf648d..8b5b2fb9c8 100644 --- a/src/macos/GenesisKit/Sources/GenesisKit/Widgets/WidgetModel.swift +++ b/src/macos/GenesisKit/Sources/GenesisKit/Widgets/WidgetModel.swift @@ -201,6 +201,7 @@ public final class WidgetModel: ObservableObject { didSet { guard notice != oldValue else { return } noticeAction = nil + noticeDetail = nil // A hover that ended with the panel (it closed under the pointer) must not keep the next note forever. noticeHeld = false if let notice { GenAnnounce.post(notice) } @@ -211,6 +212,9 @@ public final class WidgetModel: ObservableObject { @Published public var noticeAction: WidgetNoticeAction? { didSet { scheduleNoticeFade() } } + /// The reasons behind `notice` (each session a queued send did not reach, and why), shown behind Details. A new + /// note drops them. + @Published public var noticeDetail: String? private var noticeFade: Task<Void, Never>? /// The pointer is on the note: its fade waits until the pointer leaves (ux checklist, Attention). private var noticeHeld = false @@ -218,11 +222,12 @@ public final class WidgetModel: ObservableObject { /// Shows `text` as the widget's note, with an optional button (Undo, Send again). It is announced, and it fades /// after the note duration setting (`noteSeconds`, 6 s by default); a note with a button stays at least 10 s. A /// newer note replaces it and cancels its fade. Errors go to `error`, which never fades. - public func postNote(_ text: String, action: WidgetNoticeAction? = nil) { + public func postNote(_ text: String, action: WidgetNoticeAction? = nil, detail: String? = nil) { // The same text again does not change `notice`, so its didSet would neither announce nor restart the fade. if notice == text { GenAnnounce.post(text) } notice = text noticeAction = action + noticeDetail = detail scheduleNoticeFade() } @@ -468,6 +473,8 @@ public final class WidgetModel: ObservableObject { /// Inbox rows shown as dismissed or done while their Undo runs or their change is on its way /// (`WidgetLedgerUndo`). Opened again, such a row is still not answered. @Published public var heldCardIDs: Set<String> = [] + /// The decision ids of `heldCardIDs`: such a decision is out of every staged send until its Undo brings it back. + @Published public var heldDecisionIDs: Set<String> = [] /// The decision the composer and the number keys answer: the selected one, unanswered, not folded and not held. public var answerCard: WidgetCard? { guard let card, card.kind == "decision", card.needsAnswer, !foldedCardIDs.contains(card.id), @@ -1188,7 +1195,11 @@ public final class WidgetModel: ObservableObject { /// sessions × 8 entries, sorted, is too much to derive in a body that reads it several times per pass. public private(set) var inboxOverviewItems: [WidgetInboxItem] = [] /// Every staged decision of every session (the decision log's unsent picks and notes), stored with the inbox. - @Published public internal(set) var stagedItems: [WidgetInboxItem] = [] + @Published var stagedRows: [WidgetInboxItem] = [] + /// The staged decisions that may be sent: `stagedRows` without a decision dismissed or completed while its Undo + /// runs (`heldDecisionIDs`). Every send, the leave question and the Review list read this, so a close that sends + /// can never deliver an answer the user just discarded. + public var stagedItems: [WidgetInboxItem] { stagedRows.filter { !heldDecisionIDs.contains($0.sourceId) } } /// Picks staged or cleared here that the next snapshot has not shown yet, by decision id ("" is a cleared pick). @Published var pendingStage: [String: String] = [:] /// The question about staged decisions before the panel closes (`requestCollapse`). diff --git a/src/macos/GenesisKit/Sources/GenesisKitBase/Permissions/ApprovalAttention.swift b/src/macos/GenesisKit/Sources/GenesisKitBase/Permissions/ApprovalAttention.swift index 9ca3f5261b..d8efb20dd5 100644 --- a/src/macos/GenesisKit/Sources/GenesisKitBase/Permissions/ApprovalAttention.swift +++ b/src/macos/GenesisKit/Sources/GenesisKitBase/Permissions/ApprovalAttention.swift @@ -128,7 +128,9 @@ final class ApprovalReminder { let content = UNMutableNotificationContent() content.title = model.title content.subtitle = "Waiting for your answer" - content.body = [model.purpose, model.command].compactMap { $0 }.joined(separator: " ") + // The command stays in the approval panel: a vault request can carry private arguments, and macOS shows a + // notification's body on the lock screen and in Notification Center. + content.body = model.purpose content.sound = ApprovalAttention.sound == "none" ? nil : UNNotificationSound.default content.threadIdentifier = "approval.\(model.identifier)" content.userInfo = [ApprovalNotificationRelay.tokenKey: token] diff --git a/src/macos/GenesisKit/Tests/GenesisKitTests/SettingsControlsTests.swift b/src/macos/GenesisKit/Tests/GenesisKitTests/SettingsControlsTests.swift index f71d6465db..00a858ff48 100644 --- a/src/macos/GenesisKit/Tests/GenesisKitTests/SettingsControlsTests.swift +++ b/src/macos/GenesisKit/Tests/GenesisKitTests/SettingsControlsTests.swift @@ -486,6 +486,17 @@ final class SettingsControlsTests: XCTestCase { // MARK: - Settings search (H4) + /// A page drawn while it still loads reported nothing: the index does not count it as known, so the next search + /// draws it again; its rows, once reported, make it known. + func testASettingsPageThatReportedNothingStaysUnindexed() { + let index = NativeSettingsSearchIndex() + XCTAssertFalse(index.knows("daemon")) + index.set("daemon", []) + XCTAssertFalse(index.knows("daemon"), "a loading page is drawn again by the next search") + index.set("daemon", [NativeSettingsSearchEntry(kind: .card, title: "Backup")]) + XCTAssertTrue(index.knows("daemon")) + } + /// The search finds a page by its words, a card or row by its title, detail or card, ranks a title that starts /// with the query first, and points each result at its page and card or row. func testTheSettingsSearchFindsCardsAndRowsAcrossPagesAndRanksTitlesFirst() { diff --git a/src/macos/GenesisKit/Tests/GenesisKitTests/WidgetInboxTasksPolishTests.swift b/src/macos/GenesisKit/Tests/GenesisKitTests/WidgetInboxTasksPolishTests.swift index 6407e30b4b..141766617b 100644 --- a/src/macos/GenesisKit/Tests/GenesisKitTests/WidgetInboxTasksPolishTests.swift +++ b/src/macos/GenesisKit/Tests/GenesisKitTests/WidgetInboxTasksPolishTests.swift @@ -1194,6 +1194,7 @@ final class WidgetStagingTests: XCTestCase { + "was picked. Close the extra one, then send again. The answers wait for its next prompt.") let again = try XCTUnwrap(model.noticeAction, "a send nobody took offers Send again") XCTAssertEqual(again.title, "Send again") + XCTAssertEqual(model.noticeDetail, "Staged fixture: \(reason)", "the hub's own reason stays behind Details") again.run() try await Task.sleep(for: .milliseconds(200)) @@ -1202,6 +1203,7 @@ final class WidgetStagingTests: XCTestCase { XCTAssertEqual(resend["ids"], .array(["d1"])) XCTAssertEqual(model.notice, "1 decision delivered.") XCTAssertNil(model.noticeAction, "a delivered send leaves no button") + XCTAssertNil(model.noticeDetail, "a new note drops the old reasons") } /// The decision writes and sends among `actions`, without the selection and read marks around them. @@ -1209,6 +1211,36 @@ final class WidgetStagingTests: XCTestCase { names(actions).filter { ["ledger", "send-staged"].contains($0) } } + /// A decision dismissed while its Undo runs is out of every staged send: Review and send does not list it, a send + /// skips it, and closing neither asks about it nor sends it. Undo brings it back. + func testAHeldDecisionLeavesEveryStagedSend() async throws { + _ = NSApplication.shared + let model = StagingFixture.model() + defer { model.stop() } + var actions: [WidgetJSON] = [] + model.actionRunner = { value in + actions.append(value) + return ["sent": 1, "queued": 0, "failed": 0, "sessions": [["session": "fixture-staged", "numbers": [7], + "result": ["delivered": true, "detail": "codex worker w1"]]]] + } + model.receive([try snapshot(draftOption: "a")]) + model.select(key) + XCTAssertEqual(model.stagedItems.map(\.sourceId), ["d1"]) + + model.heldDecisionIDs.insert("d1") + XCTAssertTrue(model.stagedItems.isEmpty, "a held decision is not listed for sending") + model.sendStaged(["d1"]) + model.expanded = .right + model.requestCollapse() + XCTAssertFalse(model.stagedLeavePrompt, "closing does not ask about a held decision") + XCTAssertNil(model.expanded) + try await Task.sleep(for: .milliseconds(200)) + XCTAssertFalse(names(actions).contains("send-staged"), "nothing sent the discarded answer") + + model.heldDecisionIDs.remove("d1") + XCTAssertEqual(model.stagedItems.map(\.sourceId), ["d1"], "Undo makes it sendable again") + } + /// Send now in the leave question: a send no session took keeps the panel open with the reason and Send again /// (ux catalogue P1), and the next close does not ask about the same decisions again. func testSendNowOnTheWayOutStaysOpenWhenNoSessionTookIt() async throws { diff --git a/src/macos/GenesisTools/Sources/Parts/Hub/Hub/HubInbox.swift b/src/macos/GenesisTools/Sources/Parts/Hub/Hub/HubInbox.swift index c8f78a4190..657ac088a3 100644 --- a/src/macos/GenesisTools/Sources/Parts/Hub/Hub/HubInbox.swift +++ b/src/macos/GenesisTools/Sources/Parts/Hub/Hub/HubInbox.swift @@ -360,6 +360,14 @@ enum InboxSort: String, CaseIterable { @MainActor final class HubInboxModel: ObservableObject { + /// The Inbox rows the arrow keys step through, in InboxListView's order: the waiting sessions, then the quiet ones + /// while their fold is open. + static func keyOrder(_ rows: [InboxSession], showsQuiet: Bool) -> [String] { + let fresh = rows.filter { !$0.isStale }.map(\.id) + return showsQuiet ? fresh + rows.filter(\.isStale).map(\.id) : fresh + } + + @Published private(set) var sessions: [InboxSession] = [] /// A notification's target that is not waiting any more: said once, instead of the click doing nothing. @Published var revealMissing: String? @@ -368,6 +376,9 @@ final class HubInboxModel: ObservableObject { @Published private(set) var error: String? /// The session the sidebar picked; the main list scrolls to it. @Published var selectedID: String? + /// The sidebar's fold of sessions quiet for three days is open. One state for the list and its arrow keys, so Down + /// reaches exactly the rows on screen (`keyOrder`). + @Published var showsQuiet = false /// Each answered item's delivery, by item id, until the next answer to it. @Published private(set) var deliveries: [String: InboxDelivery] = [:] @Published private(set) var sending: Set<String> = [] @@ -815,8 +826,6 @@ struct InboxListView: View { @ObservedObject var model: HubModel @ObservedObject var inbox: HubInboxModel @AppStorage("hub.inbox.sort") private var sortKey = InboxSort.recent.rawValue - /// Sessions quiet for three days fold under one row; a pick of one of them (a link) opens the fold. - @State private var showOlder = false private var sort: InboxSort { InboxSort(rawValue: sortKey) ?? .recent } @@ -877,7 +886,7 @@ struct InboxListView: View { } if !older.isEmpty { // The header alone is the disclosure, inset like the rows; the rows keep their own insets. - GenDisclosure(isExpanded: $showOlder, accessibilityTitle: "Sessions quiet for three days or more") { + GenDisclosure(isExpanded: $inbox.showsQuiet, accessibilityTitle: "Sessions quiet for three days or more") { EmptyView() } label: { Text(verbatim: "\(Plural.count(older.count, "session")) quiet for 3+ days") @@ -887,7 +896,7 @@ struct InboxListView: View { .padding(.horizontal, 16) .padding(.top, 8) .instantTooltip("Nothing moved in these sessions for three days. Their questions stay answerable here; they no longer count as waiting.") - if showOlder { + if inbox.showsQuiet { ForEach(older) { session in InboxSessionRow(session: session, selected: session.id == inbox.selectedID) .rowButton { inbox.selectedID = session.id } @@ -897,10 +906,11 @@ struct InboxListView: View { } .padding(.bottom, 12) } - // Keyed on the selected row being a quiet one, not on the selection alone: a reveal can select a session - // before the load that lists it lands, and the fold must open when the row arrives too. + // Sessions quiet for three days fold under one row; a pick of one of them (a link) opens the fold. Keyed on + // the selected row being a quiet one, not on the selection alone: a reveal can select a session before the + // load that lists it lands, and the fold must open when the row arrives too. .onChange(of: rows.contains { $0.id == inbox.selectedID && $0.isStale } ? inbox.selectedID : nil, initial: true) { _, id in - if id != nil { showOlder = true } + if id != nil { inbox.showsQuiet = true } } } } diff --git a/src/macos/GenesisTools/Sources/Parts/Hub/Hub/HubWindow.swift b/src/macos/GenesisTools/Sources/Parts/Hub/Hub/HubWindow.swift index 3c98d4255c..e47a9a20fe 100644 --- a/src/macos/GenesisTools/Sources/Parts/Hub/Hub/HubWindow.swift +++ b/src/macos/GenesisTools/Sources/Parts/Hub/Hub/HubWindow.swift @@ -2156,13 +2156,9 @@ private struct SessionListView: View { .flatMap { $0.rows.map(\.id) } } - /// The Inbox rows the arrow keys step through, in InboxListView's order: the waiting sessions, then the quiet - /// ones while one of them is selected (their fold is open then). + /// The Inbox rows the arrow keys step through (`HubInboxModel.keyOrder`), from the fold state the list draws. private static func inboxKeyOrder(_ inbox: HubInboxModel, sort: String, filter: String) -> [String] { - let rows = inbox.sorted(InboxSort(rawValue: sort) ?? .recent, filter: filter) - let fresh = rows.filter { !$0.isStale }.map(\.id) - let quiet = rows.filter(\.isStale).map(\.id) - return quiet.contains(inbox.selectedID ?? "") ? fresh + quiet : fresh + HubInboxModel.keyOrder(inbox.sorted(InboxSort(rawValue: sort) ?? .recent, filter: filter), showsQuiet: inbox.showsQuiet) } private var filterPlaceholder: String { diff --git a/src/macos/GenesisTools/Tests/HubInboxTimelineTests.swift b/src/macos/GenesisTools/Tests/HubInboxTimelineTests.swift index 00f38a5e13..bf13a19260 100644 --- a/src/macos/GenesisTools/Tests/HubInboxTimelineTests.swift +++ b/src/macos/GenesisTools/Tests/HubInboxTimelineTests.swift @@ -172,6 +172,20 @@ final class HubDecisionsSourceTests: XCTestCase { XCTAssertEqual(session.draftedItems.map(\.id), ["d_1_s-alpha"]) } + /// The arrow keys step through the rows the Inbox shows: the quiet sessions only while their fold is open, whichever + /// row is selected (a fold opened with the mouse, and one left open after the selection moved up). + func testTheInboxArrowKeysFollowTheQuietFold() { + func session(_ id: String, stale: Bool) -> InboxSession { + InboxSession( + sessionId: id, provider: "claude", title: id, project: nil, cwd: "/tmp/gt/app", branch: nil, + account: nil, lastAt: "", waiting: 1, drafted: nil, queued: nil, stale: stale, reply: nil, items: [] + ) + } + let rows = [session("fresh-a", stale: false), session("quiet-b", stale: true), session("fresh-c", stale: false)] + XCTAssertEqual(HubInboxModel.keyOrder(rows, showsQuiet: false), ["fresh-a", "fresh-c"]) + XCTAssertEqual(HubInboxModel.keyOrder(rows, showsQuiet: true), ["fresh-a", "fresh-c", "quiet-b"]) + } + /// A click MARKS an option: plain click replaces the pick (and clears it on the chosen one), /// Cmd-click adds or removes a letter. Nothing here sends. func testAClickMarksAndCmdClickAdds() { From 545cbe847a137aa7ae5ca1906d97ed328e8fbd46 Mon Sep 17 00:00:00 2001 From: Martin <admin@phpcode.eu> Date: Mon, 26 Oct 2026 01:12:30 +0100 Subject: [PATCH 16/18] fix(GenesisKit, GenesisHub, settings): #563 followup - every settings search draws the hidden pages again, so rows a page lists after its load are found, and the Inbox key order builds off the main actor --- .../GenesisKit/Settings/NativeSettingsSearch.swift | 4 ---- .../GenesisKit/Settings/NativeSettingsViews.swift | 11 ++++------- .../Tests/GenesisKitTests/SettingsControlsTests.swift | 11 ----------- .../GenesisTools/Sources/Parts/Hub/Hub/HubInbox.swift | 2 +- 4 files changed, 5 insertions(+), 23 deletions(-) diff --git a/src/macos/GenesisKit/Sources/GenesisKit/Settings/NativeSettingsSearch.swift b/src/macos/GenesisKit/Sources/GenesisKit/Settings/NativeSettingsSearch.swift index 341a06819f..87fdb86fa1 100644 --- a/src/macos/GenesisKit/Sources/GenesisKit/Settings/NativeSettingsSearch.swift +++ b/src/macos/GenesisKit/Sources/GenesisKit/Settings/NativeSettingsSearch.swift @@ -105,10 +105,6 @@ final class NativeSettingsSearchIndex: ObservableObject { if entries[page] != list { entries[page] = list } } - /// A page whose cards and rows reached the index. A page drawn while it still loads reports nothing yet (the - /// Daemon page shows its task cards only after its status arrives): it stays unknown, so the next search draws it - /// again instead of caching it as a page with nothing to find. - func knows(_ page: String) -> Bool { !(entries[page]?.isEmpty ?? true) } } struct NativeSettingsSearchKey: PreferenceKey { diff --git a/src/macos/GenesisKit/Sources/GenesisKit/Settings/NativeSettingsViews.swift b/src/macos/GenesisKit/Sources/GenesisKit/Settings/NativeSettingsViews.swift index 19350862a0..a020f62c23 100644 --- a/src/macos/GenesisKit/Sources/GenesisKit/Settings/NativeSettingsViews.swift +++ b/src/macos/GenesisKit/Sources/GenesisKit/Settings/NativeSettingsViews.swift @@ -282,13 +282,10 @@ private struct NativeSettingsSidebar: View { .background(alignment: .topLeading) { if indexing { NativeSettingsSearchIndexer(store: store, search: search) } } - .onChange(of: searching) { _, now in - if !now { - indexing = false - } else if store.sections.flatMap(\.pages).contains(where: { !search.knows($0.id) && $0.id != store.selectedPageID }) { - indexing = true - } - } + // Every search draws the hidden pages again. A page's rows can arrive after its own load (the Daemon page + // lists its tasks once its status is read), and a search cleared before that would otherwise leave them + // out of every later search. The index keeps what each page reported, so results show at once. + .onChange(of: searching) { _, now in indexing = now } } /// A page, or a card or row on it: the page opens, scrolls there, and flashes it. diff --git a/src/macos/GenesisKit/Tests/GenesisKitTests/SettingsControlsTests.swift b/src/macos/GenesisKit/Tests/GenesisKitTests/SettingsControlsTests.swift index 00a858ff48..f71d6465db 100644 --- a/src/macos/GenesisKit/Tests/GenesisKitTests/SettingsControlsTests.swift +++ b/src/macos/GenesisKit/Tests/GenesisKitTests/SettingsControlsTests.swift @@ -486,17 +486,6 @@ final class SettingsControlsTests: XCTestCase { // MARK: - Settings search (H4) - /// A page drawn while it still loads reported nothing: the index does not count it as known, so the next search - /// draws it again; its rows, once reported, make it known. - func testASettingsPageThatReportedNothingStaysUnindexed() { - let index = NativeSettingsSearchIndex() - XCTAssertFalse(index.knows("daemon")) - index.set("daemon", []) - XCTAssertFalse(index.knows("daemon"), "a loading page is drawn again by the next search") - index.set("daemon", [NativeSettingsSearchEntry(kind: .card, title: "Backup")]) - XCTAssertTrue(index.knows("daemon")) - } - /// The search finds a page by its words, a card or row by its title, detail or card, ranks a title that starts /// with the query first, and points each result at its page and card or row. func testTheSettingsSearchFindsCardsAndRowsAcrossPagesAndRanksTitlesFirst() { diff --git a/src/macos/GenesisTools/Sources/Parts/Hub/Hub/HubInbox.swift b/src/macos/GenesisTools/Sources/Parts/Hub/Hub/HubInbox.swift index 657ac088a3..b83a65f35b 100644 --- a/src/macos/GenesisTools/Sources/Parts/Hub/Hub/HubInbox.swift +++ b/src/macos/GenesisTools/Sources/Parts/Hub/Hub/HubInbox.swift @@ -362,7 +362,7 @@ enum InboxSort: String, CaseIterable { final class HubInboxModel: ObservableObject { /// The Inbox rows the arrow keys step through, in InboxListView's order: the waiting sessions, then the quiet ones /// while their fold is open. - static func keyOrder(_ rows: [InboxSession], showsQuiet: Bool) -> [String] { + nonisolated static func keyOrder(_ rows: [InboxSession], showsQuiet: Bool) -> [String] { let fresh = rows.filter { !$0.isStale }.map(\.id) return showsQuiet ? fresh + rows.filter(\.isStale).map(\.id) : fresh } From 043e0deccfe370587100b3d47f794aae2645010c Mon Sep 17 00:00:00 2001 From: Martin <admin@phpcode.eu> Date: Mon, 26 Oct 2026 02:59:57 +0100 Subject: [PATCH 17/18] fix(GenesisKit, GenesisHub, widgets): #563 followup - a list read that started before a shelf removal or a voice-note delete finished is read again or dropped, so the item never comes back --- .../Widgets/Shelf/WidgetShelfStore.swift | 10 +++++ .../VoiceNotes/WidgetVoiceNotesStore.swift | 2 + .../GenesisKitTests/GenesisKitTests.swift | 38 +++++++++++++++++++ .../ToolsLineStreamTests.swift | 30 +++++++++++++++ 4 files changed, 80 insertions(+) diff --git a/src/macos/GenesisKit/Sources/GenesisKit/Widgets/Shelf/WidgetShelfStore.swift b/src/macos/GenesisKit/Sources/GenesisKit/Widgets/Shelf/WidgetShelfStore.swift index c7acbc483d..6236970a28 100644 --- a/src/macos/GenesisKit/Sources/GenesisKit/Widgets/Shelf/WidgetShelfStore.swift +++ b/src/macos/GenesisKit/Sources/GenesisKit/Widgets/Shelf/WidgetShelfStore.swift @@ -109,6 +109,8 @@ public final class WidgetShelfStore: ObservableObject { private var removal: Task<Void, Never>? /// Items whose CLI removal started and has not finished; a list read must not show them again. private var removingIDs: Set<String> = [] + /// Advanced when a CLI removal finishes, so a list read that started before it is read again instead of shown. + private var listGeneration = 0 public init( binaryPath: String, stateRoot: String? = nil, @@ -222,9 +224,16 @@ public final class WidgetShelfStore: ObservableObject { guard let self, !self.stopped, !Task.isCancelled else { return } repeat { self.refreshAgain = false + let generation = self.listGeneration do { let data = try await self.request(["list", "--json"], 15) guard !self.stopped, !Task.isCancelled else { return } + // A removal finished while this read ran: it may still list the item, so read again. + guard generation == self.listGeneration else { + self.refreshAgain = true + continue + } + let snapshot = try JSONDecoder().decode(WidgetShelfSnapshot.self, from: data) // A removed item stays on disk during its Undo; it stays off the list. let shown = snapshot.items.filter { $0.id != self.removed?.id && !self.removingIDs.contains($0.id) } @@ -517,6 +526,7 @@ public final class WidgetShelfStore: ObservableObject { // Still on the shelf: say why; the list read below shows it again. if let self, !self.stopped { self.report(error) } } + self?.listGeneration += 1 self?.removingIDs.remove(item.id) if let self, !self.stopped { self.refresh() } } diff --git a/src/macos/GenesisKit/Sources/GenesisKit/Widgets/VoiceNotes/WidgetVoiceNotesStore.swift b/src/macos/GenesisKit/Sources/GenesisKit/Widgets/VoiceNotes/WidgetVoiceNotesStore.swift index 743b12b46e..6e0d7bba19 100644 --- a/src/macos/GenesisKit/Sources/GenesisKit/Widgets/VoiceNotes/WidgetVoiceNotesStore.swift +++ b/src/macos/GenesisKit/Sources/GenesisKit/Widgets/VoiceNotes/WidgetVoiceNotesStore.swift @@ -365,6 +365,8 @@ public final class WidgetVoiceNotesStore: ObservableObject { await previous?.value do { _ = try await request(["discard", note.id, "--revision", String(note.revision)]) + // A list read that started before the delete may still list the note: it is dropped. + self?.generation += 1 self?.deletingIDs.remove(note.id) // The edits go only once the note is gone: a refused delete brings the note back with them. self?.drafts[note.id] = nil diff --git a/src/macos/GenesisKit/Tests/GenesisKitTests/GenesisKitTests.swift b/src/macos/GenesisKit/Tests/GenesisKitTests/GenesisKitTests.swift index affde342f4..22f75e6ade 100644 --- a/src/macos/GenesisKit/Tests/GenesisKitTests/GenesisKitTests.swift +++ b/src/macos/GenesisKit/Tests/GenesisKitTests/GenesisKitTests.swift @@ -474,6 +474,44 @@ final class WidgetShelfStoreTests: XCTestCase { XCTAssertTrue(spoken.contains("Removed from the shelf. The original file and existing drafts are kept.")) XCTAssertTrue(spoken.contains("notes.pdf is back on the shelf.")) } + + /// A list read that saw the shelf before a removal finished does not put the removed item back, not even for a + /// moment. + func testAListReadFromBeforeARemovalFinishedNeverShowsTheItemAgain() async throws { + let itemJSON = String(decoding: try JSONEncoder().encode(item), as: UTF8.self) + let listed = Data(#"{"revision":1,"items":[\#(itemJSON)],"statePath":"/fixture/widget-shelf/state.json"}"#.utf8) + var removedOnDisk = false + var holdNextList = false + var shown: [[WidgetShelfItem]] = [] + let store = WidgetShelfStore(request: { args, _ in + if args.first == "list" && holdNextList { + holdNextList = false + while !removedOnDisk { try await Task.sleep(for: .milliseconds(5)) } + return listed + } + + if args.first == "remove" { + removedOnDisk = true + return self.empty + } + + return removedOnDisk ? self.empty : listed + }, undoSeconds: { 60 }) + defer { store.stop() } + store.refresh() + guard try await pollUntil("the list is read", { store.items.count == 1 }) else { return } + + store.remove(item) + let watch = store.$items.sink { shown.append($0) } + defer { watch.cancel() } + holdNextList = true + store.refresh() + store.confirmRemoval() + await store.waitForRemoval() + try await Task.sleep(for: .milliseconds(150)) + XCTAssertTrue(store.items.isEmpty) + XCTAssertFalse(shown.contains { $0.contains(self.item) }, "the read from before the removal never showed the item") + } } @MainActor diff --git a/src/macos/GenesisKit/Tests/GenesisKitTests/ToolsLineStreamTests.swift b/src/macos/GenesisKit/Tests/GenesisKitTests/ToolsLineStreamTests.swift index 78ec3e4f66..4489a6e73f 100644 --- a/src/macos/GenesisKit/Tests/GenesisKitTests/ToolsLineStreamTests.swift +++ b/src/macos/GenesisKit/Tests/GenesisKitTests/ToolsLineStreamTests.swift @@ -470,6 +470,36 @@ final class WidgetVoiceNotesStoreTests: XCTestCase { XCTAssertNil(store.discarded) } + /// A list read that started during the Undo time and answers after the delete does not bring the note back. + func testAListReadFromBeforeADeleteFinishedDoesNotBringTheNoteBack() async throws { + var deletedOnDisk = false + var holdNextList = false + let store = make(request: { args in + if args.first == "list" && holdNextList { + holdNextList = false + while !deletedOnDisk { try await Task.sleep(for: .milliseconds(5)) } + } + + if args.first == "discard" { + deletedOnDisk = true + } + + return try self.data(["revision": 0, "notes": [self.note()], "statePath": "/fixture/widget/voice-notes/notes.json"]) + }, undoSeconds: 60) + defer { store.stop() } + store.refresh() + await store.waitForRefresh() + + store.discard() + holdNextList = true + store.refresh(force: true) + store.confirmDiscard() + await store.waitForDiscard() + await store.waitForRefresh() + XCTAssertTrue(deletedOnDisk) + XCTAssertTrue(store.notes.isEmpty, "the read from before the delete is dropped") + } + /// The reply box's live dictation names its clip and files the note through one-shot commands. They answer /// `{"path"}` and `{"kind":"added","note"}`, which the recorder's transport (`execute`) would refuse. func testALiveNoteGetsItsClipPathAndIsFiledThroughOneShotCommands() async throws { From 87970e6fa2ac9ebdefdfeb3f3cc4f82bf8df9809 Mon Sep 17 00:00:00 2001 From: Martin <admin@phpcode.eu> Date: Mon, 26 Oct 2026 03:55:52 +0100 Subject: [PATCH 18/18] fix(GenesisKit, GenesisHub, widgets): #563 followup - the removal and delete race tests wait at most 5 s for their rendezvous, so a broken store fails them instead of hanging the suite --- .../GenesisKit/Tests/GenesisKitTests/GenesisKitTests.swift | 3 ++- .../Tests/GenesisKitTests/ToolsLineStreamTests.swift | 3 ++- 2 files changed, 4 insertions(+), 2 deletions(-) diff --git a/src/macos/GenesisKit/Tests/GenesisKitTests/GenesisKitTests.swift b/src/macos/GenesisKit/Tests/GenesisKitTests/GenesisKitTests.swift index 22f75e6ade..b321e85212 100644 --- a/src/macos/GenesisKit/Tests/GenesisKitTests/GenesisKitTests.swift +++ b/src/macos/GenesisKit/Tests/GenesisKitTests/GenesisKitTests.swift @@ -486,7 +486,8 @@ final class WidgetShelfStoreTests: XCTestCase { let store = WidgetShelfStore(request: { args, _ in if args.first == "list" && holdNextList { holdNextList = false - while !removedOnDisk { try await Task.sleep(for: .milliseconds(5)) } + // At most 5 s, so a broken store fails the test instead of hanging it. + for _ in 0..<1000 where !removedOnDisk { try await Task.sleep(for: .milliseconds(5)) } return listed } diff --git a/src/macos/GenesisKit/Tests/GenesisKitTests/ToolsLineStreamTests.swift b/src/macos/GenesisKit/Tests/GenesisKitTests/ToolsLineStreamTests.swift index 4489a6e73f..19bef00f69 100644 --- a/src/macos/GenesisKit/Tests/GenesisKitTests/ToolsLineStreamTests.swift +++ b/src/macos/GenesisKit/Tests/GenesisKitTests/ToolsLineStreamTests.swift @@ -477,7 +477,8 @@ final class WidgetVoiceNotesStoreTests: XCTestCase { let store = make(request: { args in if args.first == "list" && holdNextList { holdNextList = false - while !deletedOnDisk { try await Task.sleep(for: .milliseconds(5)) } + // At most 5 s, so a broken store fails the test instead of hanging it. + for _ in 0..<1000 where !deletedOnDisk { try await Task.sleep(for: .milliseconds(5)) } } if args.first == "discard" {