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/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/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..a15ec22f3f 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 : SessionPalette.secondary } 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(SessionPalette.secondary) .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(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/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..2a0c72aed3 100644 --- a/src/macos/GenesisKit/Sources/GenesisKit/Recording/RecordingTargetPicker.swift +++ b/src/macos/GenesisKit/Sources/GenesisKit/Recording/RecordingTargetPicker.swift @@ -22,6 +22,26 @@ public enum RecordingPickerTab: String, CaseIterable, Hashable, Sendable { } } +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 +128,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 +159,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 +175,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 +217,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(SessionPalette.secondary) 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 +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(.secondary) + 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(.tertiary) + .font(.system(size: 11)).foregroundStyle(SessionPalette.secondary) .multilineTextAlignment(.center).fixedSize(horizontal: false, vertical: true) .frame(maxWidth: 300) } @@ -215,16 +252,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(SessionPalette.secondary) .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(SessionPalette.secondary) + .frame(maxWidth: .infinity, minHeight: 80) + } } else { ScrollViewReader { proxy in FittedScroll(maxHeight: 330) { @@ -254,12 +301,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(SessionPalette.secondary) } .padding(.leading, row.symbol == "macwindow" ? 22 : 6) .padding(.trailing, 8) @@ -269,17 +316,25 @@ public struct RecordingTargetPicker: View { .contentShape(Rectangle()) } .buttonStyle(.genHoverPlain()) - .onHover { inside in - if inside { highlighted = row.id } + // 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 + } + if highlighted != row.id { 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(SessionPalette.secondary) + Text(verbatim: row.detail).font(.system(size: 11)).foregroundStyle(SessionPalette.secondary) } .padding(.horizontal, 6) .padding(.top, 6) @@ -292,17 +347,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(SessionPalette.secondary) } } 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(SessionPalette.secondary) + } + + /// 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 +383,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..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: 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 @@ -41,16 +46,46 @@ public struct NativeSettingsDisclosure: View { .frame(maxWidth: .infinity, minHeight: 36, alignment: .leading) .contentShape(Rectangle()) } - .buttonStyle(.plain) + .buttonStyle(.genHoverRow(cornerRadius: KitTheme.radiusSmall)) .nativeSettingsPointer() .nativeSettingsDisabledLook() .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/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..87fdb86fa1 --- /dev/null +++ b/src/macos/GenesisKit/Sources/GenesisKit/Settings/NativeSettingsSearch.swift @@ -0,0 +1,283 @@ +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. 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. +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 } + } + +} + +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 + /// 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 +} + +/// 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) } + } + } + } + .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 { + 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) + .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..a020f62c23 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: View { + @Environment(\.nativeSettingsSearchCard) private var searchCard private let title: String? private let subtitle: String? private let content: Content @@ -12,21 +13,30 @@ 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)) 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). 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, card: searchCard), cornerRadius: 22) + } else { + card + } } } public struct NativeSettingsRow: View { @Environment(\.isEnabled) private var isEnabled + @Environment(\.nativeSettingsSearchCard) private var searchCard private let title: String private let detail: String? private let accessory: Accessory @@ -55,6 +65,8 @@ public struct NativeSettingsRow: View { Spacer(minLength: 0) accessory } + .nativeSettingsSearchable(NativeSettingsSearchEntry(kind: .row, title: title, detail: detail, card: searchCard), + cornerRadius: 10, outset: 8) } } @@ -105,7 +117,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 +130,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 +144,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 +164,114 @@ public struct FeatureSettingsView: View { .nativeSettingsAppearance(appearance) .preferredColorScheme(.dark) } +} + +/// 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 { + 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) + } + .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) } + } + } +} - private var sidebar: some View { +/// 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 +279,23 @@ 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) } + } + // 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. + 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..da5844ea6d 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,20 @@ 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 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? { + 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. public static let rowTransition: AnyTransition = .asymmetric( @@ -43,7 +57,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(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 9d6bbb3302..334eb7cf1a 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, @@ -120,26 +123,31 @@ 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) } + .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 { @@ -207,7 +215,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 { @@ -224,13 +233,24 @@ 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 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) + } + } .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 +277,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 +346,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 +446,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 +465,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 @@ -458,7 +482,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) @@ -467,7 +491,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 +577,74 @@ 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 } + 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 +658,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,37 +690,9 @@ 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 - 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( - .white.opacity(0.07), in: RoundedRectangle(cornerRadius: 5)) - Text(Self.choiceText(choice)).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 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 key = Self.choiceKey(index) + 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) } @@ -637,17 +702,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 +805,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 +849,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 +891,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 +916,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 +940,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(.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) @@ -861,14 +958,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(.system(size: 11)) + if !line.detail.isEmpty { + Text(line.detail).font(.system(size: 11)).foregroundStyle(SessionPalette.secondary) + } } } + .accessibilityElement(children: .combine) } } } @@ -879,40 +980,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 } } @@ -957,12 +1131,14 @@ 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) 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 +1227,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 +1256,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 +1286,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 +1321,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 +1377,187 @@ 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. 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(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 + /// 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 + /// 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. + let model: WidgetModel + 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] = [:] + /// 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 { + 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, + sourceId: card.sourceId, + request: .object([ + "action": "ledger", "id": .string(card.sourceId), "sessionId": .string(session), + "expectedRevision": .number(Double(revision)), "state": .string(state), + ]), + 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.heldCardIDs.insert(id) + model.heldDecisionIDs.insert(card.sourceId) + 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] text in + guard text != note else { return } + Task { @MainActor [weak self] in self?.commit(id) } + } + hold.deadline = Task { [weak self] in + do { try await Task.sleep(for: Self.longestHold) } catch { return } + self?.commit(id) + } + 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) { + 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 + model.heldCardIDs.remove(id) + model.heldDecisionIDs.remove(hold.sourceId) + } + }, 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 + model.heldCardIDs.remove(id) + model.heldDecisionIDs.remove(hold.sourceId) + } + } + }) + } + + 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 + model.heldCardIDs.remove(id) + model.heldDecisionIDs.remove(hold.sourceId) + 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. final class WidgetContentFit { static let placeholderHeight: CGFloat = 360 @@ -1182,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/Shelf/WidgetShelfModules.swift b/src/macos/GenesisKit/Sources/GenesisKit/Widgets/Shelf/WidgetShelfModules.swift index 4665196df8..ede13c3cf2 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,20 @@ 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") + 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) .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..6236970a28 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 `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. @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,18 @@ 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 + /// 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? + /// The CLI removal once the Undo ran out; teardown waits for it. + private var removal: Task? + /// Items whose CLI removal started and has not finished; a list read must not show them again. + private var removingIDs: Set = [] + /// 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, @@ -96,10 +119,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) } + self.undoSeconds = undoSeconds let bridge = ToolsBridge(binaryPath: binaryPath) self.request = { arguments, timeout in var prefix = ["widget"] @@ -131,9 +157,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 }, + undoSeconds: @escaping @MainActor () -> Double = { 10 } ) { self.request = request + self.announce = announce + self.undoSeconds = undoSeconds self.screenRecorder = screenRecorder self.screenCaptureAccess = screenCaptureAccess self.permissionGate = permissionGate @@ -148,6 +178,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() @@ -192,13 +224,22 @@ 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) - 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 && !self.removingIDs.contains($0.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 +297,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 +308,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 +378,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 +391,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 +420,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 +452,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 +471,69 @@ public final class WidgetShelfStore: ObservableObject { } } + /// 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) { - 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 = Duration.seconds(undoSeconds()) + 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() + } + + /// 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() + 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 + removingIDs.insert(item.id) + 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) } + } + self?.listGeneration += 1 + self?.removingIDs.remove(item.id) + 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 { @@ -486,10 +584,17 @@ 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)") } + /// 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/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 8b4115e896..b048141fa7 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,17 @@ 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") + 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) } @@ -185,9 +196,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..6e0d7bba19 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 `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() public var recipientPickerVisibilityChanged: (Bool) -> Void = { _ in } private let request: ([String]) async throws -> Data @@ -41,6 +44,15 @@ public final class WidgetVoiceNotesStore: ObservableObject { private let presentMicrophonePermission: @MainActor (PermissionNeed) -> Void private var operation: Task? private var refreshTask: Task? + /// 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? + /// The CLI delete of a discarded note once its Undo ran out; teardown waits for it. + private var discardDelete: Task? + /// Notes whose CLI delete started and has not finished; a list read must not show them again. + private var deletingIDs: Set = [] private var watcher: DirectoryWatcher? private var watchedPath: String? private var cached = false @@ -52,7 +64,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"] @@ -65,7 +78,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, @@ -77,8 +91,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) }, + undoSeconds: @escaping @MainActor () -> Double = { 10 }) { self.micLauncher = micLauncher + self.undoSeconds = undoSeconds self.readMicrophonePermission = readMicrophonePermission self.requestMicrophonePermission = requestMicrophonePermission self.activateForMicrophone = activateForMicrophone @@ -122,8 +138,11 @@ 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 && !deletingIDs.contains($0.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) @@ -297,15 +316,68 @@ public final class WidgetVoiceNotesStore: ObservableObject { } } + /// 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 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 = Duration.seconds(undoSeconds()) + 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." + } + + /// 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() + discardTimer = nil + discarded = nil + 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)]) + // 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 + } 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 + self.refresh(force: true) + } } } @@ -362,14 +434,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..879f8b5ced 100644 --- a/src/macos/GenesisKit/Sources/GenesisKit/Widgets/WidgetAgentPreview.swift +++ b/src/macos/GenesisKit/Sources/GenesisKit/Widgets/WidgetAgentPreview.swift @@ -56,13 +56,15 @@ struct WidgetSessionActivity: View { } } .accessibilityElement(children: .ignore) - .accessibilityLabel(status.label + ", \(max(0, count)) inbox notifications") + .accessibilityLabel(status.label + ", " + Plural.count(max(0, count), "inbox item")) } } 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") @@ -76,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: { @@ -88,10 +90,10 @@ 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.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) { @@ -142,12 +150,12 @@ 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: 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,13 @@ 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 } + .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 { @@ -181,7 +196,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 +218,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 +253,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. @@ -248,7 +268,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/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..5a9c3fafa3 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 } @@ -99,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, @@ -121,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 @@ -212,13 +222,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. @@ -251,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() @@ -326,12 +342,57 @@ 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 + } + 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) + } + public func registerModule(_ module: WidgetModuleDescriptor) throws { try modules.register(module) if display != nil { rebuildPanels() } @@ -340,7 +401,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 +559,7 @@ public final class WidgetCoordinator: NSObject, NSWindowDelegate { private func sync() { resolvePendingLaunch() + applyOpenShortcut() if lastShown != panelsEnabled { rebuildPanels() return @@ -751,6 +813,14 @@ 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, 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 + } guard let expanded = model.expanded, let panel = panels[WidgetSurfaceID(edge: expanded, group: expanded == .top ? 0 : model.activeSideGroup)]? .panel, @@ -775,9 +845,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 +867,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 +875,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 +905,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..9419d4549c 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, - pulse: model.inboxPulse, reduceMotion: model.effectiveReduceMotion, complete: model.inbox.complete) + if selected?.id == "agents", model.waitingCount > 0 { + WidgetInboxCount(count: model.waitingCount, needsAnswer: true, + pulse: model.waitingPulse, 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, - pulse: model.inboxPulse, reduceMotion: model.effectiveReduceMotion, + if id == "agents", model.waitingCount > 0 { + WidgetInboxCount(count: model.waitingCount, needsAnswer: true, + pulse: model.waitingPulse, 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..6e3ba79fca 100644 --- a/src/macos/GenesisKit/Sources/GenesisKit/Widgets/WidgetInboxOverview.swift +++ b/src/macos/GenesisKit/Sources/GenesisKit/Widgets/WidgetInboxOverview.swift @@ -1,12 +1,16 @@ 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 /// 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? + /// 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" } @@ -20,16 +24,96 @@ 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. + 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) + } + .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 } + } + } + + /// 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; 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]) } ?? [] + let option = focusedOption.flatMap { options.firstIndex(of: $0) } + switch kind { + case .open: + 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 + } + model.releaseAgentFit() + focusedOption = nil + openID = openID == item.id ? nil : item.id + case .up, .down: + 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) + } + } + + private func list(_ items: [WidgetInboxItem]) -> some 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() @@ -37,7 +121,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") @@ -72,7 +156,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) } } @@ -98,7 +182,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) @@ -116,7 +200,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: " · ") } @@ -128,6 +212,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: { @@ -158,7 +243,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) @@ -170,6 +255,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 +266,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? { @@ -188,39 +278,57 @@ 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. 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: 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((stages ? "Stage " : "Answer ") + 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 d11f434378..1b80cefc6a 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 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(previous))/\(label(next)) switch · \(label(collapse)) close" - } - - /// "⌘V media · ⎋ close". - public static var mediaFooter: String { - "\(label(paste)) media · \(label(collapse)) close" + "\(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/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..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. @@ -113,24 +113,34 @@ 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") + "…") } + // 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 { @@ -150,20 +160,48 @@ 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 { + if let sendingNote, notice == sendingNote { notice = nil } + 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) }, + detail: outcome.details) } + flashUnsent(waiting.isEmpty ? items : waiting) } catch { - if action == "resend-answers" { self.notice = nil } - self.report(error) + 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.", + 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 +214,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,31 +225,43 @@ 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() } } +/// 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 @@ -227,10 +279,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 +301,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..8b5b2fb9c8 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 @@ -84,7 +87,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 +140,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 +194,75 @@ 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 + 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) } + 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() } + } + /// 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 + + /// 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, 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() + } + + /// 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 @@ -385,9 +470,15 @@ 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 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) 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. @@ -507,6 +598,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 +619,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 +848,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 @@ -878,6 +987,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 { @@ -909,6 +1019,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 +1117,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. @@ -1077,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`). @@ -1192,6 +1314,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 @@ -1229,6 +1352,141 @@ 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 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] = [] + /// 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. + 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 } + 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 } + 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) + } + + /// 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) 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 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: + 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 + } + + /// 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 +2231,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 +2570,23 @@ 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? +} + +/// 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 77bdd01878..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,14 +13,19 @@ 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 } 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 +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 if reportsOrder { model.sessionListOrder = keys } } + .onDisappear { if reportsOrder { model.sessionListOrder = [] } } } private var projectFilter: some View { @@ -128,14 +138,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 +170,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 +201,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 +212,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..a8d7b3bfb6 100644 --- a/src/macos/GenesisKit/Sources/GenesisKit/Widgets/WidgetStagedViews.swift +++ b/src/macos/GenesisKit/Sources/GenesisKit/Widgets/WidgetStagedViews.swift @@ -94,7 +94,9 @@ 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) .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..e41b8e783a 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,34 @@ 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. A stored chord without ⌘, ⌥ or ⌃ would take a + /// plain key from every app, so the default stands in for it. + public var openShortcutChord: WidgetShortcutChord? { + 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 + } +} + 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..d8efb20dd5 --- /dev/null +++ b/src/macos/GenesisKit/Sources/GenesisKitBase/Permissions/ApprovalAttention.swift @@ -0,0 +1,233 @@ +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 stopped = false + + 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) + } + } + + /// 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, 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. + ApprovalNotificationRelay.register(token: token, center: center, onClick: onClick) + + let content = UNMutableNotificationContent() + content.title = model.title + content.subtitle = "Waiting for your answer" + // 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] + 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 + } + DispatchQueue.main.async { + MainActor.assumeIsolated { + // 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)") + } + } + } + } + + /// 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 + } + ApprovalNotificationRelay.unregister(token: token, center: center) + } +} + +/// 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" + + 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? + + private init(previous: UNUserNotificationCenterDelegate?) { + self.previous = previous + } + + 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, + 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 let token = response.notification.request.content.userInfo[Self.tokenKey] as? String else { + if previous?.userNotificationCenter?(center, didReceive: response, withCompletionHandler: completionHandler) == nil { + completionHandler() + } + return + } + + KitHost.log("approval reminder clicked id=\(response.notification.request.identifier)") + DispatchQueue.main.async { + // 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/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..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() } } @@ -425,9 +426,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/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/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/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, 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..b321e85212 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,80 @@ 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) }, undoSeconds: { 0.1 }) + 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.")) + } + + /// 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 + // 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 + } + + 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 @@ -1172,7 +1246,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") } @@ -1220,6 +1294,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/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/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/GenesisKit/Tests/GenesisKitTests/RecordingTests.swift b/src/macos/GenesisKit/Tests/GenesisKitTests/RecordingTests.swift index dad65777e2..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."]) } } @@ -536,7 +537,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..f71d6465db 100644 --- a/src/macos/GenesisKit/Tests/GenesisKitTests/SettingsControlsTests.swift +++ b/src/macos/GenesisKit/Tests/GenesisKitTests/SettingsControlsTests.swift @@ -483,4 +483,57 @@ 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: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"]) + 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..19bef00f69 100644 --- a/src/macos/GenesisKit/Tests/GenesisKitTests/ToolsLineStreamTests.swift +++ b/src/macos/GenesisKit/Tests/GenesisKitTests/ToolsLineStreamTests.swift @@ -428,12 +428,77 @@ 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", + 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) + requestMicrophonePermission: prompt, activateForMicrophone: activate, presentMicrophonePermission: presentPermission, + undoSeconds: { undoSeconds }) + } + + /// 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"]) + }, undoSeconds: 0.1) + 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) + } + + /// 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 + // 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" { + 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 @@ -461,6 +526,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 cb232be80f..141766617b 100644 --- a/src/macos/GenesisKit/Tests/GenesisKitTests/WidgetInboxTasksPolishTests.swift +++ b/src/macos/GenesisKit/Tests/GenesisKitTests/WidgetInboxTasksPolishTests.swift @@ -637,6 +637,68 @@ 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(#"{"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 + /// 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 +742,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 +946,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 +1161,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,9 +1190,11 @@ 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") + XCTAssertEqual(model.noticeDetail, "Staged fixture: \(reason)", "the hub's own reason stays behind Details") again.run() try await Task.sleep(for: .milliseconds(200)) @@ -1054,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. @@ -1061,6 +1211,132 @@ 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 { + _ = 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") + 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") + } + + /// 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( + 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 +1593,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..9e4e420e17 100644 --- a/src/macos/GenesisKit/Tests/GenesisKitTests/WidgetKeymapTests.swift +++ b/src/macos/GenesisKit/Tests/GenesisKitTests/WidgetKeymapTests.swift @@ -55,6 +55,54 @@ 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") + } + + /// 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) + let commandH = KeyStroke(keyCode: 4, character: "h", modifiers: .command) + 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: [.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") + } + 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..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 @@ -230,6 +231,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.refreshOnActivation() + } } 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..286baec18e 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,17 @@ 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 + /// 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"), @@ -72,10 +83,51 @@ 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() + } + + /// 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)) } + if let kind = messageKind, rows.first(where: { $0.kind == kind })?.state != .denied { + lastMessage = nil + messageKind = nil + } + } + + private func recheckProbes(now: Date = Date()) { + // 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 + Task { @MainActor in + for kind in kinds { + probeResults[kind] = await access.recheck(kind) + } + rechecking = false + rebuildRows() + } } // MARK: - Actions @@ -83,16 +135,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..ae68dcc9fd 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(.system(size: 11)) + .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(.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() } - .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(.system(size: 11)) + .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..13d5a98702 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. 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. @@ -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. Return does not allow it; click Allow.", + 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. 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]) 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..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) } } @@ -401,7 +403,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 +539,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 +628,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 +641,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 + syncFields(from: old) + sentEvery = nil + sentRetryTimes = nil + } + // 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,15 +663,48 @@ 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 } + // 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?) { + 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 } - store.set(task, ["--every", value]) + guard !value.isEmpty, value != task.every, value != sentEvery else { return } + sentEvery = 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], 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 a912dc18ad..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." } } @@ -1165,10 +1168,16 @@ 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) + 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) + } + if let miss = agents.requestMiss { + NoticePill(text: miss, tone: .warning) { agents.requestMiss = nil } .padding(.horizontal, 14) .padding(.vertical, 4) } 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..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) @@ -115,11 +115,11 @@ 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) - .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 7c5a90533c..b83a65f35b 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. + 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 + } + + @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> = [] @@ -794,7 +805,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 +815,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) } } } } @@ -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 } @@ -846,7 +855,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 +865,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) @@ -870,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") @@ -880,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 } @@ -890,19 +906,39 @@ 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 } } } } } +/// 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 +965,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 +1007,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) @@ -1063,8 +1111,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 } @@ -1165,7 +1213,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, $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") { @@ -1179,15 +1227,23 @@ 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 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 claimsShortcut || 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 { @@ -1195,8 +1251,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 @@ -1210,8 +1269,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 +1280,11 @@ 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(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) } @@ -1626,7 +1691,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 +2286,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..1c3602c68e 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,74 @@ 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 or the + /// task list could not be read (the task may be there). + var daemonFixTitle: String? { + guard let daemonTask else { return nil } + guard !daemonTask || daemonTaskEvery != daemonTaskWanted else { return nil } + return daemonTask ? "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 +152,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 +183,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 +192,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 +260,52 @@ 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 + /// 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 { + 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 !hidden, 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 +318,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 +329,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 +350,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 +364,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 +377,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 +387,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 +399,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 +456,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 Command") { Clipboard.copy(HubNotifyWords.installCommand, what: "command") } + } + .accessibilityIdentifier("notify.install") } } @@ -341,43 +504,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..19831f11ab 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,41 @@ 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 { + // 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." + : 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() + } + ) + .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 +1685,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 +1797,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 +1928,8 @@ struct PRStateIcon: View { .foregroundColor(tone.color) .frame(width: 16) .instantTooltip(tone.title) + .accessibilityElement(children: .ignore) + .accessibilityLabel(Text(verbatim: tone.title)) } } @@ -1926,17 +1969,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 report “\(ci)”" + } + }() 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)) } } } @@ -2058,8 +2112,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") { @@ -2083,7 +2137,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.postLaunchNotice(outcome) } } // The palette's "review": the same popover, with the command shown before Start. @@ -2103,7 +2157,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.postLaunchNotice(outcome) } } } @@ -2286,7 +2340,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) } diff --git a/src/macos/GenesisTools/Sources/Parts/Hub/Hub/HubTimeline.swift b/src/macos/GenesisTools/Sources/Parts/Hub/Hub/HubTimeline.swift index a246f1af48..8edd38546e 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,21 @@ enum TimelinePrompts { } } +// 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 { + /// 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 { + case .cancelled: return + case .launched(let text): post(text, .success) + case .failed(let text): post(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 +734,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." + post("This repository is not on this Mac.", .warning) return } showCommit(repo: repo, sha: sha, title: title) @@ -725,7 +764,7 @@ extension HubModel { @MainActor func openTimelineSession(_ event: TimelineEvent, transcriptQuery query: String? = nil) { guard let session = timelineSession(event) else { - notice = "This row names no session." + post("This row names no session.", .warning) return } if !sessions.contains(where: { $0.id == session.id }) { @@ -764,7 +803,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." + post("This row names no PR.", .warning) return } setMode(.prs) @@ -779,7 +818,11 @@ 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" + 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) + } } } } @@ -832,7 +875,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.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 ?? "") } @@ -840,14 +883,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.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.notice = "Reading the commit's PRs; open the row to pick one." + self.post("Reading the commit's PRs; open the row to pick one.", .success) } } if let repo = event.repo, let sha = event.sha { @@ -859,7 +902,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.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) } @@ -882,7 +925,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?.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) } @@ -1144,18 +1187,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 +1231,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 +1251,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 +1350,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: ", ")).") @@ -1292,8 +1389,8 @@ struct TimelineMain: View { } .titlebarLabel() Spacer() - if let notice = model.notice { - NoticePill(text: notice, isError: notice.contains("failed") || notice.hasPrefix("No ")) { 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") @@ -1545,7 +1642,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.post("The repository's origin is not known yet; try again in a moment.", .warning) } } label: { text @@ -1596,17 +1693,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.postLaunchNotice(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.postLaunchNotice(outcome) } case .reply(let event): TimelineReplyForm(event: event, timeline: timeline) { notice in popover = nil - if let notice { model.notice = notice } + if let notice { model.post(notice.text, notice.tone) } } } } @@ -1647,7 +1744,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 +1768,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..e47a9a20fe 100644 --- a/src/macos/GenesisTools/Sources/Parts/Hub/Hub/HubWindow.swift +++ b/src/macos/GenesisTools/Sources/Parts/Hub/Hub/HubWindow.swift @@ -413,6 +413,25 @@ private final class HubAppDelegate: NSObject, NSApplicationDelegate { } } +/// 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) + } +} + /// 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 { @@ -604,7 +623,19 @@ 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:)) } + } + + /// 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). @@ -775,9 +806,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 +1013,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 +1182,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 +1421,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 +1451,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 +1506,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 +2137,30 @@ 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 (`HubInboxModel.keyOrder`), from the fold state the list draws. + private static func inboxKeyOrder(_ inbox: HubInboxModel, sort: String, filter: String) -> [String] { + HubInboxModel.keyOrder(inbox.sorted(InboxSort(rawValue: sort) ?? .recent, filter: filter), showsQuiet: inbox.showsQuiet) + } + private var filterPlaceholder: String { switch model.mode { case .sessions: return "Filter sessions, projects, accounts…" @@ -2202,9 +2258,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 +2304,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 +2385,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 +2506,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 +2546,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 +2598,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 +2611,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 +2711,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 +2756,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 +2793,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 +2836,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 +2886,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 +2907,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..b6b12e03ed 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 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.cadenceOptions(including: own ?? config.intervalMinutes).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/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() { diff --git a/src/macos/GenesisTools/Tests/HubNotifyTests.swift b/src/macos/GenesisTools/Tests/HubNotifyTests.swift index b99881c5b8..821cc5fc92 100644 --- a/src/macos/GenesisTools/Tests/HubNotifyTests.swift +++ b/src/macos/GenesisTools/Tests/HubNotifyTests.swift @@ -68,11 +68,52 @@ 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") + 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. + 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..36c63f84b1 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,61 @@ 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]) + } + + /// 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) } + } +}