diff --git a/src/hub/lib/composer/outbox.test.ts b/src/hub/lib/composer/outbox.test.ts index 77e6a4b500..6332458f66 100644 --- a/src/hub/lib/composer/outbox.test.ts +++ b/src/hub/lib/composer/outbox.test.ts @@ -1685,6 +1685,14 @@ test("legacy preferences gain independent module layouts without accepting off-s expect(widgetPreferencesSchema.safeParse({ topModules: ["../../invalid"] }).success).toBe(false); }); +test("send-and-close timing defaults to 2.5 seconds and can keep the panel open", () => { + expect(widgetPreferencesSchema.parse({}).sendCloseSeconds).toBe(2.5); + expect(widgetPreferencesSchema.parse({ sendCloseSeconds: 0 }).sendCloseSeconds).toBe(0); + expect(widgetPreferencesSchema.parse({ sendCloseSeconds: 10 }).sendCloseSeconds).toBe(10); + expect(widgetPreferencesSchema.safeParse({ sendCloseSeconds: -1 }).success).toBe(false); + expect(widgetPreferencesSchema.safeParse({ sendCloseSeconds: 61 }).success).toBe(false); +}); + test("an open TODO does not mark its session as waiting for an answer", async () => { const directory = await root(); const file = join(directory, "decisions.jsonl"); diff --git a/src/hub/lib/widget/staged.ts b/src/hub/lib/widget/staged.ts index 1243f95ef3..bef5b7e975 100644 --- a/src/hub/lib/widget/staged.ts +++ b/src/hub/lib/widget/staged.ts @@ -4,6 +4,7 @@ import { sendAnsweredDecisions } from "@app/question/lib/decisions/send"; import { type DecisionRecord, kindOf, readDecisions, updateDecisions } from "@app/question/lib/decisions/store"; import { answerInboxDecisions, type InboxAnswerResult } from "@app/question/lib/inbox/answer"; import { waitingBlock } from "@app/question/lib/inbox/load"; +import { logger } from "@genesiscz/utils/logger"; /** Where a session's answers go: the widget's target for it (the decision row alone has no source home). */ export interface StagedTarget { @@ -54,6 +55,8 @@ export async function sendStagedDecisions({ const row = staged.find((entry) => entry.id === id); if (!row) { + // Sent, discarded or answered meanwhile: nothing to send for it, and the reply must not read as a send. + logger.warn({ id }, "widget send-staged: decision is no longer staged"); continue; } @@ -85,13 +88,17 @@ export async function sendStagedDecisions({ { ...files, block: waitingBlock, deliver } ); result.sessions.push({ session, numbers, result: sent }); - + logger.info( + { session, numbers, delivered: sent.delivered, provider }, + sent.delivered ? "widget send-staged: delivered" : "widget send-staged: queued for the next prompt" + ); if (sent.delivered) { result.sent += rows.length; } else { result.queued += rows.length; } } catch (error) { + logger.warn({ session, numbers, error }, "widget send-staged: failed"); result.sessions.push({ session, numbers, error: error instanceof Error ? error.message : String(error) }); result.failed += rows.length; } diff --git a/src/hub/lib/widget/types.ts b/src/hub/lib/widget/types.ts index f03446f7de..42c4347425 100644 --- a/src/hub/lib/widget/types.ts +++ b/src/hub/lib/widget/types.ts @@ -52,6 +52,10 @@ export const widgetPreferencesSchema = z.object({ sidePosition: z.number().min(0).max(1).default(0.5), hoverPreviews: z.boolean().default(true), glassEffect: z.boolean().default(true), + /** The widget panel's own background: "dark" (solid near-black), "glass", or "theme" (the app-wide theme). */ + panelBackground: z.enum(["dark", "glass", "theme"]).default("dark"), + /** The hover list's rows show their waiting question under the session. */ + previewQuestion: z.boolean().default(true), display: z.string().default(""), providers: z.array(z.enum(["claude", "codex", "grok", "unknown"])).default([]), quietSeconds: z.number().int().min(3).max(300).default(15), @@ -85,6 +89,8 @@ export const widgetPreferencesSchema = z.object({ decisionClick: z.enum(["stage", "send"]).default("stage"), /** Leaving the inbox with staged decisions: ask what to do ("ask"), keep them staged, or send them. */ stagedOnLeave: z.enum(["ask", "keep", "send"]).default("ask"), + /** Seconds before a successful send-and-close folds the panel; 0 keeps it open. */ + sendCloseSeconds: z.number().min(0).max(60).default(2.5), /** The inbox of every session: entries grouped under their session ("session"), or one list. */ inboxGrouping: z.enum(["session", "list"]).default("session"), /** The filter a session's inbox opens with: every entry ("all"), the inbox entries, or the decisions. */ diff --git a/src/macos/GenesisKit/Sources/GenesisKit/Widgets/LiveWidgetView.swift b/src/macos/GenesisKit/Sources/GenesisKit/Widgets/LiveWidgetView.swift index 334eb7cf1a..41fa0a8e77 100644 --- a/src/macos/GenesisKit/Sources/GenesisKit/Widgets/LiveWidgetView.swift +++ b/src/macos/GenesisKit/Sources/GenesisKit/Widgets/LiveWidgetView.swift @@ -106,9 +106,9 @@ public struct LiveWidgetView: View { expanded: model.expanded == edge, animationsActive: model.placement == "both" || (model.placement == "top" ? edge == .top : edge != .top), cutoutWidth: cutout, compactHeight: compactHeight, - draft: Binding(get: { model.draft.text }, set: model.setText), + draft: model.composerText(), actions: AgentWidgetActions( - expand: { model.open(edge) }, collapse: model.collapse, + expand: { model.open(edge) }, collapse: { model.collapse() }, select: { model.select($0, edge: edge) }, choose: { model.choose(option: $0) }, submit: { model.submit() }, settings: { model.showSettings?() }, next: { model.next() }), liveContent: AnyView(content) @@ -209,23 +209,14 @@ public struct LiveWidgetView: View { if section == "Inbox", model.stagedView == nil { WidgetStagedChip(model: model) } ScrollViewReader { reader in ScrollView { - LazyVStack(alignment: .leading, spacing: 14) { - if model.stagedView != nil { - WidgetStagedList(model: model) - } else if section == "Inbox" { - inbox - } else if section == "Sessions" { - // 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 { - conversation - } + // The inbox is eager: a session holds tens of cards, and a lazy stack's estimated height + // under an open card made the scroll offset and the document chase each other without end + // (the widget hung at 99% CPU on a session's Decisions, Martin, 2026-10-25). + Group { if section == "Inbox" { - ForEach(model.outgoing) { message in - outgoing(message).id("outgoing-" + message.id) - } + VStack(alignment: .leading, spacing: 14) { sectionList } + } else { + LazyVStack(alignment: .leading, spacing: 14) { sectionList } } }.frame(maxWidth: .infinity, alignment: .leading).padding(.vertical, 2) .scrollOverflowContent() @@ -286,6 +277,27 @@ public struct LiveWidgetView: View { } } + /// The selected section's rows inside the pane's scroll view. + @ViewBuilder private var sectionList: some View { + if model.stagedView != nil { + WidgetStagedList(model: model) + } else if section == "Inbox" { + inbox + } else if section == "Sessions" { + // 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 { + conversation + } + if section == "Inbox" { + ForEach(model.outgoing) { message in + outgoing(message).id("outgoing-" + message.id) + } + } + } + /// The pane needs its chrome plus the whole scroll document; without a list (connecting, nothing selected) a /// short fixed height is enough. /// The three heights change in separate callbacks of one layout pass; read them together after it. @@ -366,7 +378,7 @@ public struct LiveWidgetView: View { model.showSettings?() } .accessibilityLabel("Widget settings") - IconButton(systemName: "xmark", tooltip: WidgetKeymap.hint("Collapse", WidgetKeymap.collapse), action: model.requestCollapse) + IconButton(systemName: "xmark", tooltip: WidgetKeymap.hint("Collapse", WidgetKeymap.collapse), action: { model.requestCollapse() }) .accessibilityLabel("Collapse widget") } } @@ -1212,7 +1224,7 @@ public struct LiveWidgetView: View { .frame(maxWidth: .infinity, minHeight: composerFieldHeight, maxHeight: composerFieldHeight, alignment: .topLeading) } else { - TextField(placeholder, text: Binding(get: { model.draft.text }, set: model.setText), axis: .vertical) + TextField(placeholder, text: model.composerText(ownAnswer: ownAnswer?.id), axis: .vertical) .textFieldStyle(.plain).font(.system(size: 12)).lineLimit(1...4).focused($editing) .onSubmit { model.send() } .accessibilityLabel(ownAnswer == nil ? "Reply to selected agent" : "Your own answer") diff --git a/src/macos/GenesisKit/Sources/GenesisKit/Widgets/WidgetAgentPreview.swift b/src/macos/GenesisKit/Sources/GenesisKit/Widgets/WidgetAgentPreview.swift index 879f8b5ced..0d64460514 100644 --- a/src/macos/GenesisKit/Sources/GenesisKit/Widgets/WidgetAgentPreview.swift +++ b/src/macos/GenesisKit/Sources/GenesisKit/Widgets/WidgetAgentPreview.swift @@ -108,11 +108,24 @@ struct AgentWidgetPreview: View { selection: Binding(get: { model.previewFilter }, set: { model.setPreview(filter: $0) }), minSegmentWidth: 70, identifier: "widget.preview.filter") Spacer(minLength: 4) - IconButton(systemName: "arrow.up.arrow.down", tooltip: sortTooltip, size: 10.5) { + // The order is named beside its icon, so reading it needs no hover (Martin, 2026-10-25). + Button { let sorts = WidgetPreviewChoices.sorts.map(\.value) let index = sorts.firstIndex(of: model.previewSort) ?? 0 model.setPreview(sort: sorts[(index + 1) % sorts.count]) - }.accessibilityLabel("Order: " + WidgetPreviewChoices.title(sort: model.previewSort)) + } label: { + HStack(spacing: 3) { + Image(systemName: "arrow.up.arrow.down").font(.system(size: 10.5)) + Text(WidgetPreviewChoices.shortTitle(sort: model.previewSort)).font(.system(size: 11)) + } + .foregroundStyle(.secondary) + .padding(.horizontal, 5).frame(minHeight: 22) + .contentShape(Rectangle()) + } + .buttonStyle(.genHoverPlain()) + .instantTooltip(sortTooltip) + .accessibilityLabel("Order: " + WidgetPreviewChoices.title(sort: model.previewSort)) + .accessibilityHint("Click for the next order") IconButton(systemName: "slider.horizontal.3", tooltip: "Choose what this list shows and its order", size: 10.5) { model.showSettingsPage?("widgets.sessions#inbox-preview") @@ -182,8 +195,20 @@ struct AgentWidgetPreview: View { return "Order: \(sorts[index].title). Click for \(sorts[(index + 1) % sorts.count].title)." } + static func pendingQuestion(_ item: WidgetInboxItem?, enabled: Bool) -> String? { + guard enabled, let item, let title = item.title, !title.isEmpty else { return nil } + return WidgetAlerts.kicker(item) + " · " + title + } + + static func rowAccessibilityLabel(_ session: WidgetSession, question: String?) -> String { + let label = "Open " + session.title + ", " + session.visualStatus.label + return question.map { label + ", " + $0 } ?? label + } + private func row(_ session: WidgetSession) -> some View { - WidgetHoverRow { hovering in HStack(spacing: 4) { + let question = Self.pendingQuestion(model.inboxFor(session.key)?.pendingItem, + enabled: model.snapshot?.state.preferences.previewQuestion ?? true) + return WidgetHoverRow { hovering in HStack(spacing: 4) { Button { model.openInboxNotification(on: surface, key: session.key) } label: { @@ -198,6 +223,12 @@ struct AgentWidgetPreview: View { Text(session.visualStatus.label).lineLimit(1) }.font(.system(size: 11)).foregroundStyle(.white.opacity(0.6)) WidgetSessionTimes(session: session, inbox: model.inboxFor(session.key)) + // The waiting question itself, so an alert that folded can be found again here (Martin, + // 2026-10-25: "I can't seem to find that same stuff anywhere"). + if let question { + Text(question) + .font(.system(size: 11)).foregroundStyle(Color.orange.opacity(0.9)).lineLimit(1) + } } Spacer(minLength: 2) if let inbox = model.inboxFor(session.key), inbox.unread + inbox.needsAnswer > 0 { @@ -214,7 +245,7 @@ struct AgentWidgetPreview: View { .contentShape(Rectangle()) } .buttonStyle(RowButtonStyle(cornerRadius: 10)) - .accessibilityLabel("Open " + session.title + ", " + session.visualStatus.label) + .accessibilityLabel(Self.rowAccessibilityLabel(session, question: question)) WidgetCmuxButton(model: model, session: session, live: hovering) } } .padding(.trailing, 4) diff --git a/src/macos/GenesisKit/Sources/GenesisKit/Widgets/WidgetDecisionOutcome.swift b/src/macos/GenesisKit/Sources/GenesisKit/Widgets/WidgetDecisionOutcome.swift index 54e8372252..41e41c642a 100644 --- a/src/macos/GenesisKit/Sources/GenesisKit/Widgets/WidgetDecisionOutcome.swift +++ b/src/macos/GenesisKit/Sources/GenesisKit/Widgets/WidgetDecisionOutcome.swift @@ -36,7 +36,8 @@ struct WidgetDecisionOutcome: View { } if !picked.isEmpty || !(card.answer ?? "").isEmpty { VStack(alignment: .leading, spacing: 4) { - Text(card.answeredVia == "agent" ? "Answer recorded by the agent" : "Your answer") + Text(card.answeredVia == "agent" ? "Answer recorded by the agent" + : card.answeredVia == "automatic_heuristic" ? "Answer read from your chat" : "Your answer") .font(.system(size: 10, weight: .semibold)).foregroundStyle(.secondary) ForEach(picked, id: \.id) { choice in HStack(alignment: .top, spacing: 6) { diff --git a/src/macos/GenesisKit/Sources/GenesisKit/Widgets/WidgetFeatureSettings.swift b/src/macos/GenesisKit/Sources/GenesisKit/Widgets/WidgetFeatureSettings.swift index 4d6a802698..85c110bbf9 100644 --- a/src/macos/GenesisKit/Sources/GenesisKit/Widgets/WidgetFeatureSettings.swift +++ b/src/macos/GenesisKit/Sources/GenesisKit/Widgets/WidgetFeatureSettings.swift @@ -208,9 +208,13 @@ private struct WidgetGeneralSettings: View { isOn: boolean("joinedEdges", prefs?.joinedEdges ?? true)) } NativeSettingsCard("Appearance") { - NativeSettingsToggle( - "Glass effect", detail: "Use your selected theme for widget surfaces.", - isOn: boolean("glassEffect", prefs?.glassEffect ?? true)) + NativeSettingsSegmented( + "Panel background", + detail: "Dark is a solid near-black panel. Glass shows the desktop through it. Same as the app follows the theme in General, which other windows can change.", + identifier: "widget.panelBackground", + options: [NativeSettingsOption("dark", "Dark"), NativeSettingsOption("glass", "Glass"), + NativeSettingsOption("theme", "Same as the app")], + selection: string("panelBackground", prefs?.panelBackground ?? "dark")) Divider() NativeSettingsToggle( "Reduce motion", detail: "Shared with General: keeps every native window and widget still.", @@ -221,6 +225,11 @@ private struct WidgetGeneralSettings: View { isOn: $appearance.reduceTransparency) } NativeSettingsCard("Inbox behavior") { + NativeSettingsToggle( + "Show the waiting question in the list", + detail: "Each session in the hover list shows its open decision under its name, so an alert that closed can be found again.", + isOn: boolean("previewQuestion", prefs?.previewQuestion ?? true)) + Divider() NativeSettingsRow( "Collapse when quiet", detail: "A draft, recording or active agent keeps the inbox open." ) { @@ -263,6 +272,17 @@ private struct WidgetGeneralSettings: View { }.labelsHidden().pickerStyle(.segmented).frame(width: 250, alignment: .trailing) } Divider() + NativeSettingsRow("Close after send", detail: "When closing sends staged decisions, keep the delivered note readable for this long. Any new typing, pick or session selection keeps the panel open.") { + Picker("Close after send", selection: Binding( + get: { prefs?.sendCloseSeconds ?? 2.5 }, + set: { patch(["sendCloseSeconds": .number($0)]) })) { + Text("Keep open").tag(0.0) + ForEach(Array(Set([2.5, 5, 10, prefs?.sendCloseSeconds ?? 2.5])).filter { $0 > 0 }.sorted(), id: \.self) { seconds in + Text("\(seconds.formatted()) seconds").tag(seconds) + } + }.labelsHidden().accessibilityIdentifier("widget.sendCloseSeconds") + } + Divider() NativeSettingsRow("Inbox of every session", detail: "The inbox button's list: grouped under each session, or one list.") { Picker("Inbox of every session", selection: string("inboxGrouping", prefs?.inboxGrouping ?? "session")) { Text("By session").tag("session") @@ -996,4 +1016,13 @@ enum WidgetPreviewChoices { ] static func title(sort: String) -> String { sorts.first { $0.value == sort }?.title ?? sort } + /// The one-word name the inbox header shows beside the sort button. + static func shortTitle(sort: String) -> String { + switch sort { + case "attention": return "Waiting" + case "recent": return "Newest" + case "project": return "Project" + default: return title(sort: sort) + } + } } diff --git a/src/macos/GenesisKit/Sources/GenesisKit/Widgets/WidgetHostView.swift b/src/macos/GenesisKit/Sources/GenesisKit/Widgets/WidgetHostView.swift index 9419d4549c..7150625362 100644 --- a/src/macos/GenesisKit/Sources/GenesisKit/Widgets/WidgetHostView.swift +++ b/src/macos/GenesisKit/Sources/GenesisKit/Widgets/WidgetHostView.swift @@ -86,11 +86,7 @@ struct WidgetHostView: View { .foregroundStyle(.white) .preferredColorScheme(.dark) .environment( - \.nativeSettingsTheme, - // Glass effect off: the theme's opaque form (Solid for the light themes, as before). - model.snapshot?.state.preferences.glassEffect == false - ? model.appearance.theme.effective(reduceTransparency: true, systemReduceTransparency: false) - : model.appearance.theme + \.nativeSettingsTheme, model.panelTheme ) .nativeSettingsAppearance(model.appearance) .widgetAccessibility(reduceMotion: model.reduceMotion, reduceTransparency: model.reduceTransparency) @@ -368,10 +364,12 @@ struct WidgetHostView: View { 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) } + // The panel is never shorter than the rail beside it, so the card fills the pane: one inset on every + // side, the panel's 18 pt corner minus that inset, and the alert flash on the card's own edge. Only the + // content is measured, so the fill cannot feed its own height back (Martin's screenshot, 2026-10-25: + // a short card over an empty pane, its flash border hugging the text). + WidgetPeekView(model: model, item: item) { model.reportPreviewFit($0 + 2 * WidgetPeekView.inset) } + .padding(WidgetPeekView.inset) .frame(maxWidth: .infinity, maxHeight: .infinity, alignment: .top) } else if selected.id == "agents" { AgentWidgetPreview(model: model, surface: surface) @@ -515,6 +513,13 @@ struct WarmPaneVisibility: ViewModifier { struct WidgetPeekView: View { @ObservedObject var model: WidgetModel let item: WidgetInboxItem + /// The card's natural height, before it stretches to fill the pane. + var measured: (CGFloat) -> Void = { _ in } + + /// The gap between the card and the panel edge on every side. + static let inset: CGFloat = 8 + /// The panel's corner (EdgePanelShape's 18) minus the inset, so the card's corner runs parallel to the panel's. + static let cornerRadius: CGFloat = 18 - inset /// The kicker names the decision ("Decision 4"), so the title under it does not repeat the number. private var title: String { @@ -527,52 +532,137 @@ struct WidgetPeekView: View { guard let surface = model.hoveredSurface else { return } model.openInboxNotification(on: surface, key: item.key, needsAnswer: item.needsAnswer) } label: { - VStack(alignment: .leading, spacing: 6) { - HStack(spacing: 6) { - Image(systemName: item.needsAnswer ? "questionmark.bubble.fill" : "text.bubble.fill") - .foregroundStyle(item.needsAnswer ? Color.orange : SessionPalette.blue) - Text(WidgetAlerts.kicker(item)).font(.system(size: 11, weight: .semibold)) - .foregroundStyle(item.needsAnswer ? Color.orange : .secondary) - 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(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].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: 11)) - .foregroundStyle(.secondary) + VStack(alignment: .leading, spacing: 0) { + header + .fixedSize(horizontal: false, vertical: true) + .onGeometryChange(for: CGFloat.self, of: \.size.height) { headerHeight = $0; report() } + Spacer(minLength: Self.footerGap) + footer + .onGeometryChange(for: CGFloat.self, of: \.size.height) { footerHeight = $0; report() } } - .padding(8) - .frame(maxWidth: .infinity, alignment: .leading) + .padding(Self.padding) + .frame(maxWidth: .infinity, maxHeight: .infinity, alignment: .topLeading) + .background(RoundedRectangle(cornerRadius: Self.cornerRadius, style: .continuous) + .fill(Color.primary.opacity(0.05))) + .overlay(RoundedRectangle(cornerRadius: Self.cornerRadius, style: .continuous) + .strokeBorder(Color.primary.opacity(0.08), lineWidth: 1)) .contentShape(Rectangle()) } - .buttonStyle(RowButtonStyle(cornerRadius: 10)) + .buttonStyle(RowButtonStyle(cornerRadius: Self.cornerRadius)) .attentionPulse(trigger: model.attentionTrigger(item.key), style: popupAttention, since: model.attentionStart(item.key)) .accessibilityLabel(WidgetAlerts.kicker(item) + ": " + title) + .accessibilityValue(WidgetPeekOptions.accessibilityValue(item)) .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) + .padding(.top, 5).padding(.trailing, 5) + } + } + + @State private var headerHeight: CGFloat = 0 + @State private var footerHeight: CGFloat = 0 + private static let padding: CGFloat = 10 + private static let footerGap: CGFloat = 10 + + /// The card's natural height: header, gap and footer, without the stretch that fills the pane. + private func report() { + guard headerHeight > 0, footerHeight > 0 else { return } + measured(headerHeight + Self.footerGap + footerHeight + 2 * Self.padding) + } + + private var footer: some View { + HStack(spacing: 3) { + Text(item.needsAnswer ? "Click to answer" : "Click to read") + Image(systemName: "chevron.right").font(.system(size: 9, weight: .semibold)) + } + .font(.system(size: 11)).foregroundStyle(.secondary) + } + + private var header: some View { + VStack(alignment: .leading, spacing: 6) { + HStack(spacing: 6) { + Image(systemName: item.needsAnswer ? "questionmark.bubble.fill" : "text.bubble.fill") + .foregroundStyle(item.needsAnswer ? Color.orange : SessionPalette.blue) + Text(WidgetAlerts.kicker(item)).font(.system(size: 11, weight: .semibold)) + .foregroundStyle(item.needsAnswer ? Color.orange : .secondary) + 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(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].filter { !$0.isEmpty }.joined(separator: " · ")) + .font(.system(size: 11)).foregroundStyle(.secondary) + .lineLimit(1).truncationMode(.middle) + } + // The pane is never shorter than the rail beside it; the decision's options fill it with what the click + // will ask, instead of an empty card (Martin, 2026-10-25: "this is now quite ugly"). + if item.needsAnswer, let options = item.options, !options.isEmpty { + WidgetPeekOptions(options: options, recommended: item.recommended).padding(.top, 4) + } } } private var popupAttention: AttentionPulseStyle { var style = model.attentionStyle - style.cornerRadius = 10 + style.cornerRadius = Self.cornerRadius return style } } +/// A decision's options in the alert card, up to four, the recommended one marked; read-only, the card's click answers. +struct WidgetPeekOptions: View { + let options: [String] + let recommended: String? + + var body: some View { + VStack(alignment: .leading, spacing: 4) { + ForEach(Array(options.prefix(4).enumerated()), id: \.offset) { index, option in + let picked = Self.isRecommended(option, at: index, recommended) + HStack(alignment: .firstTextBaseline, spacing: 6) { + Image(systemName: picked ? "star.fill" : "circle") + .font(.system(size: picked ? 8 : 6, weight: .semibold)) + .foregroundStyle(picked ? Color.green : Color.secondary) + .frame(width: 10) + Text(option).font(.system(size: 11.5)).lineLimit(2).fixedSize(horizontal: false, vertical: true) + .foregroundStyle(picked ? .primary : .secondary) + } + .accessibilityElement(children: .combine) + .accessibilityLabel(option + (picked ? ", recommended" : "")) + } + if options.count > 4 { + Text("\(options.count - 4) more").font(.system(size: 10.5)).foregroundStyle(.secondary).padding(.leading, 16) + } + } + } + + /// The single alert button speaks the same four options and recommendation its label draws. + static func accessibilityValue(_ item: WidgetInboxItem) -> String { + guard item.needsAnswer, let options = item.options else { return "" } + var labels = options.prefix(4).enumerated().map { index, option in + option + (isRecommended(option, at: index, item.recommended) ? ", recommended" : "") + } + if options.count > 4 { labels.append("\(options.count - 4) more") } + return labels.joined(separator: "; ") + } + + /// The hub names the recommended option by its letter ("b") or its whole text. + static func isRecommended(_ option: String, at index: Int, _ recommended: String?) -> Bool { + guard let recommended = recommended?.trimmingCharacters(in: .whitespaces).lowercased(), !recommended.isEmpty + else { return false } + let lower = option.lowercased() + let letter = String(UnicodeScalar(97 + index)!) + return recommended == letter || lower == recommended + || lower.hasPrefix(recommended + ")") || lower.hasPrefix(recommended + ".") + } +} + struct AgentWidgetModuleView: View { @ObservedObject var model: WidgetModel let presentation: WidgetModulePresentation diff --git a/src/macos/GenesisKit/Sources/GenesisKit/Widgets/WidgetMediaView.swift b/src/macos/GenesisKit/Sources/GenesisKit/Widgets/WidgetMediaView.swift index 9c4571b255..122f8cf480 100644 --- a/src/macos/GenesisKit/Sources/GenesisKit/Widgets/WidgetMediaView.swift +++ b/src/macos/GenesisKit/Sources/GenesisKit/Widgets/WidgetMediaView.swift @@ -109,10 +109,7 @@ struct WidgetMediaView: View { .padding(22) .titlebarBackground(NativeSettingsBackdrop()) .titlebarZone() - .environment(\.nativeSettingsTheme, - model.snapshot?.state.preferences.glassEffect == false - ? model.appearance.theme.effective(reduceTransparency: true, systemReduceTransparency: false) - : model.appearance.theme) + .environment(\.nativeSettingsTheme, model.panelTheme) .nativeSettingsAppearance(model.appearance) .preferredColorScheme(.dark) .onAppear { diff --git a/src/macos/GenesisKit/Sources/GenesisKit/Widgets/WidgetModel+Staging.swift b/src/macos/GenesisKit/Sources/GenesisKit/Widgets/WidgetModel+Staging.swift index 9a7749aef4..c08c43943c 100644 --- a/src/macos/GenesisKit/Sources/GenesisKit/Widgets/WidgetModel+Staging.swift +++ b/src/macos/GenesisKit/Sources/GenesisKit/Widgets/WidgetModel+Staging.swift @@ -85,6 +85,7 @@ extension WidgetModel { id: String, sessionId: String, revision: Int, option: String?, note: String? = nil, completed: (() -> Void)? = nil ) { + sendCloseRevision += 1 pendingStage[id] = option ?? "" var fields: [String: WidgetJSON] = [ "action": "ledger", "id": .string(id), "sessionId": .string(sessionId), @@ -119,7 +120,10 @@ extension WidgetModel { public func sendStaged(_ ids: [String], thenClose: Bool = false) { let chosen = stagedItems.filter { ids.contains($0.sourceId) } guard !chosen.isEmpty else { - if thenClose { collapse() } + // Nothing to send used to close the panel without a word, which read as a send that did nothing. + PerfLog.mark("widget.send-staged nothing to send: \(ids.count) ids picked, \(stagedItems.count) staged") + postNote(ids.isEmpty ? "No decision is checked, so nothing was sent." + : "These decisions are no longer staged, so nothing was sent.") return } deliverDecisions(chosen, action: "send-staged", thenClose: thenClose) @@ -138,6 +142,8 @@ extension WidgetModel { private func deliverDecisions(_ items: [WidgetInboxItem], action: String, thenClose: Bool = false) { let staged = action == "send-staged" let opened = openedAt + let closeRevision = sendCloseRevision + let draftAtSend = draft 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 @@ -152,6 +158,8 @@ extension WidgetModel { targets[target.sessionId] = .object(fields) titles[target.sessionId] = session.title } + PerfLog.mark("widget.decision-send \(action) start: \(items.count) to " + + titles.values.sorted().joined(separator: ", ")) afterMutations { [weak self] in guard let self else { return } do { @@ -165,10 +173,22 @@ extension WidgetModel { let waiting = items.filter { item in self.session(forKey: item.key).map { outcome.queuedSessions.contains($0.target.sessionId) } ?? false } + PerfLog.mark("widget.decision-send \(action) result: \(text)") 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() } + // Only the panel this send left from: one the user opened again meanwhile stays open. It folds + // after the note was readable: closing at once hid "delivered" and read as "Send did nothing" + // (Martin, 2026-10-25). + let closeSeconds = snapshot?.state.preferences.sendCloseSeconds ?? 2.5 + if thenClose, expanded != nil, openedAt == opened, sendCloseRevision == closeRevision, + closeSeconds > 0 { + Task { [weak self] in + do { try await Task.sleep(for: .seconds(closeSeconds)) } catch { return } + guard let self, self.expanded != nil, self.openedAt == opened, + self.sendCloseRevision == closeRevision, self.draft == draftAtSend else { return } + self.collapse() + } + } return } if outcome.failed > 0 { @@ -205,6 +225,7 @@ extension WidgetModel { /// 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 } + sendCloseRevision += 1 for id in ids { pendingStage[id] = "" } action(["action": "unstage", "ids": .array(ids.map { .string($0) })], failed: { [weak self] in for id in ids { self?.pendingStage[id] = nil } @@ -213,17 +234,17 @@ 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() { + public func requestCollapse(file: StaticString = #fileID, line: UInt = #line) { // 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() + collapse(file: file, line: line) return } switch snapshot?.state.preferences.stagedOnLeave ?? "ask" { case "keep": - collapse() + collapse(file: file, line: line) case "send": keptStaged.formUnion(fresh) sendStaged(Array(fresh), thenClose: true) diff --git a/src/macos/GenesisKit/Sources/GenesisKit/Widgets/WidgetModel.swift b/src/macos/GenesisKit/Sources/GenesisKit/Widgets/WidgetModel.swift index 8b5b2fb9c8..ed6154d3f3 100644 --- a/src/macos/GenesisKit/Sources/GenesisKit/Widgets/WidgetModel.swift +++ b/src/macos/GenesisKit/Sources/GenesisKit/Widgets/WidgetModel.swift @@ -266,6 +266,21 @@ public final class WidgetModel: ObservableObject { public private(set) var openedAt: TimeInterval = 0 public let bridge: ToolsBridge public let appearance: NativeSettingsAppearance + + /// The theme the widget panel and its media window draw with: the widget's own Panel background setting, so the + /// hub's View > Look or the General theme no longer turns the widget from dark to glass (Martin, 2026-10-25: "most + /// of the time it shows like this"). Reduce Transparency still turns glass solid. + public var panelTheme: NativeSettingsTheme { + switch snapshot?.state.preferences.panelBackground ?? "dark" { + case "glass": + return NativeSettingsTheme.glass.effective(reduceTransparency: appearance.reduceTransparency, + systemReduceTransparency: false) + case "theme": + return appearance.theme + default: + return .graphite + } + } private let defaults: UserDefaults private var appearanceSubscription: AnyCancellable? private var settingsOnly = false @@ -303,6 +318,8 @@ public final class WidgetModel: ObservableObject { private var draftTasks: [String: Task] = [:] private var dirtyDrafts: Set = [] private var draftRevisions: [String: Int] = [:] + /// Sending replaces the composer; bindings retained by its disposed field cannot write into the new draft. + @Published private var composerGeneration = 0 var actionRunner: ((WidgetJSON) async throws -> WidgetJSON)? private var submittedAssets: Set = [] private var submittedCards: Set = [] @@ -633,6 +650,7 @@ public final class WidgetModel: ObservableObject { } public func stop() { + sendCloseRevision += 1 peekTask?.cancel() preferenceTask?.cancel() flushPreferences() @@ -887,6 +905,7 @@ public final class WidgetModel: ObservableObject { public func hover(_ surface: WidgetSurfaceID, inside: Bool) { if inside { pointerInside.insert(surface.key) } else { pointerInside.remove(surface.key) } pointerChanged?(surface, inside) + if !inside { scheduleQuietReduction() } if !inside, peekItem != nil, hoveredSurface == surface { // The user moved onto the popup and away again: it closes like a hover preview. hoverTask?.cancel() @@ -1208,6 +1227,8 @@ public final class WidgetModel: ObservableObject { @Published public var stagedView: String? /// The staged decisions the user kept at the last leave; the prompt asks again only for a newly staged one. var keptStaged: Set = [] + /// User edits and selections invalidate a send's pending automatic close, even if changed back afterwards. + var sendCloseRevision = 0 /// When this widget last saved a decision's draft, by card id, for the card's "Draft saved" line. @Published public var draftSavedAt: [String: Date] = [:] @@ -1288,7 +1309,12 @@ public final class WidgetModel: ObservableObject { }) } - public func collapse() { + /// Every fold logs its caller, so "the widget folded" is found in app-perf.log (18 fold paths logged nothing, + /// 2026-10-25 hover hunt). + public func collapse(file: StaticString = #fileID, line: UInt = #line) { + if expanded != nil { + PerfLog.mark("widget.collapse \(expanded?.rawValue ?? "") from \(file):\(line)") + } cancelHoverPrewarm() agentFitFloor = nil openingInbox = nil @@ -1311,6 +1337,8 @@ public final class WidgetModel: ObservableObject { } public func select(_ key: String, edge: EdgePanelPlacement? = nil) { + if selectedKey != key { composerGeneration += 1 } + sendCloseRevision += 1 if openingInbox?.key != key { openingInbox = nil } inboxOverview = false selectedKey = key @@ -1487,9 +1515,22 @@ public final class WidgetModel: ObservableObject { } + /// A field keeps its session and generation, while a newly rendered composer accepts any text, even a repeat. + func composerText(ownAnswer: String? = nil) -> Binding { + let key = selectedKey + let generation = composerGeneration + return Binding(get: { [weak self] in self?.drafts[key]?.text ?? "" }, set: { [weak self] text in + guard let self, self.selectedKey == key, self.composerGeneration == generation, + ownAnswer == nil || self.ownAnswerCardID == ownAnswer else { return } + self.setText(text) + }) + } + public func setText(_ text: String) { + sendCloseRevision += 1 let key = selectedKey guard !key.isEmpty else { return } + var value = drafts[key] ?? WidgetDraft() value.text = String(text.prefix(Self.draftTextLimit)) drafts[key] = value @@ -1882,6 +1923,10 @@ public final class WidgetModel: ObservableObject { draftTasks[selectedKey]?.cancel() dirtyDrafts.insert(selectedKey) drafts[selectedKey] = WidgetDraft() + // A save of the typed text still on its way would land after the clear below and put the sent text back + // on the hub; a new revision makes it skip itself. + draftRevisions[selectedKey, default: 0] += 1 + composerGeneration += 1 submittedAssets.formUnion(submitted.assetIds) if let card, answering || choice != nil { submittedCards.insert(card.id) } action(["action": "draft-text", "key": .string(selectedKey), "text": ""]) @@ -2471,8 +2516,20 @@ public final class WidgetModel: ObservableObject { let delay = max(5, snapshot?.state.preferences.quietSeconds ?? 15) quietTask = Task { [weak self] in do { try await Task.sleep(for: .seconds(delay)) } catch { return } + guard let self else { return } + // A pointer over the panel, or a decision waiting in it, is someone reading: the quiet timer folded a + // session with open decisions under the reader's pointer (Martin, 2026-10-25). It tries again later. + // `pointerInside` and not `hoveredSurface`: opening a module clears the hover, not the pointer. + let waiting = self.inboxOverview ? self.inbox.needsAnswer > 0 : self.cards.contains(where: \.needsAnswer) + if !self.pointerInside.isEmpty || waiting { + PerfLog.mark("widget.quiet-collapse skipped: " + + (!self.pointerInside.isEmpty ? "pointer over the panel" : "a decision waits")) + self.quietSignature = "" + return + } + PerfLog.mark("widget.quiet-collapse after \(delay)s quiet in \(self.selectedKey)") // With staged decisions this asks first and the panel stays open (Martin, 2026-10-23). - self?.requestCollapse() + self.requestCollapse() } } diff --git a/src/macos/GenesisKit/Sources/GenesisKit/Widgets/WidgetStagedViews.swift b/src/macos/GenesisKit/Sources/GenesisKit/Widgets/WidgetStagedViews.swift index a8d7b3bfb6..05188be5f8 100644 --- a/src/macos/GenesisKit/Sources/GenesisKit/Widgets/WidgetStagedViews.swift +++ b/src/macos/GenesisKit/Sources/GenesisKit/Widgets/WidgetStagedViews.swift @@ -90,6 +90,9 @@ struct WidgetStagedList: View { .font(.system(size: 11)).foregroundStyle(.secondary) HStack(spacing: 8) { Button("Send \(chosen.count) now") { + // The pane keeps its height while the staged list closes, so the panel does not shrink + // away from the pointer (hover hunt H2, 2026-10-25). + model.holdAgentFit() model.sendStaged(chosen.map(\.sourceId)) model.stagedView = nil } diff --git a/src/macos/GenesisKit/Sources/GenesisKit/Widgets/WidgetWireModels.swift b/src/macos/GenesisKit/Sources/GenesisKit/Widgets/WidgetWireModels.swift index e41b8e783a..eb5590237b 100644 --- a/src/macos/GenesisKit/Sources/GenesisKit/Widgets/WidgetWireModels.swift +++ b/src/macos/GenesisKit/Sources/GenesisKit/Widgets/WidgetWireModels.swift @@ -283,6 +283,10 @@ public struct WidgetPreferences: Codable, Equatable, Sendable { public var sideStyle: String? public var joinedEdges: Bool? public var glassEffect: Bool? + /// "dark", "glass" or "theme"; nil reads as "dark". + public var panelBackground: String? + /// The hover list's rows show their waiting question; nil reads as on. + public var previewQuestion: Bool? public var display: String? public var providers: [String]? /// Widget alerts for new items: a chime (`alertSound`, a system sound name or "none") and a popup. @@ -308,6 +312,8 @@ public struct WidgetPreferences: Codable, Equatable, Sendable { public var decisionClick: String? /// Leaving the inbox with staged decisions: "ask" (default), "keep" or "send". public var stagedOnLeave: String? + /// Seconds to show a delivered note before a send-and-close folds the panel; 0 keeps it open. Default 2.5. + public var sendCloseSeconds: Double? /// The inbox of every session: "session" (default, grouped under each session) or "list". public var inboxGrouping: String? /// The filter a session's inbox opens with: "all" (default), "inbox" or "decisions". diff --git a/src/macos/GenesisKit/Sources/GenesisKit/Window/EdgePanelController.swift b/src/macos/GenesisKit/Sources/GenesisKit/Window/EdgePanelController.swift index dc6e6098e3..15532108d0 100644 --- a/src/macos/GenesisKit/Sources/GenesisKit/Window/EdgePanelController.swift +++ b/src/macos/GenesisKit/Sources/GenesisKit/Window/EdgePanelController.swift @@ -43,12 +43,16 @@ public extension EnvironmentValues { /// The panel's root: the hosting view fills it, and while a transition runs a shape layer masks it to the animated /// outline. The window itself changes size once per transition, so no frame of the motion re-lays out SwiftUI. -private final class EdgePanelRootView: NSView { +final class EdgePanelRootView: NSView { let maskLayer = CAShapeLayer() /// The pointer entered or left the window, by geometry. SwiftUI's hover is a hit test, and it said "left" with the /// pointer still on the panel when a scroll moved a part it does not count (the overlay scroller, an AppKit view) /// under it, so the hover list closed under a scrolling pointer (Martin, 2026-10-24). var pointerChanged: ((Bool) -> Void)? + var pointerLocation: () -> NSPoint = { NSEvent.mouseLocation } + private var localExitMonitor: Any? + private var globalExitMonitor: Any? + private(set) var ignoredExitPending = false override init(frame frameRect: NSRect) { super.init(frame: frameRect) @@ -66,8 +70,64 @@ private final class EdgePanelRootView: NSView { override var isFlipped: Bool { false } - override func mouseEntered(with event: NSEvent) { pointerChanged?(true) } - override func mouseExited(with event: NSEvent) { pointerChanged?(false) } + override func mouseEntered(with event: NSEvent) { + stopExitWatch() + pointerChanged?(true) + } + /// AppKit reports an exit when the window reshapes under a still pointer (5 of 17 such exits folded the preview, + /// app-perf.log 2026-10-25): an exit with the pointer still inside the window waits and checks again, and only a + /// pointer that really left reports false. Every ignored exit is logged. + override func mouseExited(with event: NSEvent) { + guard let window, window.frame.contains(pointerLocation()) else { + stopExitWatch() + pointerChanged?(false) + return + } + PerfLog.mark("edge.hover exit ignored: pointer still inside \(window.title)") + startExitWatch() + DispatchQueue.main.asyncAfter(deadline: .now() + 0.25) { [weak self] in + MainActor.assumeIsolated { self?.checkIgnoredExit() } + } + } + + /// Movement remains observable after AppKit consumed the exit transition during a reshape. No polling while idle. + private func startExitWatch() { + guard !ignoredExitPending else { return } + ignoredExitPending = true + let events: NSEvent.EventTypeMask = [.mouseMoved, .leftMouseDragged, .rightMouseDragged, .otherMouseDragged] + localExitMonitor = NSEvent.addLocalMonitorForEvents(matching: events) { [weak self] event in + self?.checkIgnoredExit() + return event + } + globalExitMonitor = NSEvent.addGlobalMonitorForEvents(matching: events) { [weak self] _ in + self?.checkIgnoredExit() + } + } + + func checkIgnoredExit() { + guard ignoredExitPending else { return } + guard let window else { stopExitWatch(); return } + guard !window.frame.contains(pointerLocation()) else { return } + PerfLog.mark("edge.hover left after a reshape: \(window.title)") + stopExitWatch() + pointerChanged?(false) + } + + private func stopExitWatch() { + for monitor in [localExitMonitor, globalExitMonitor].compactMap({ $0 }) { NSEvent.removeMonitor(monitor) } + localExitMonitor = nil + globalExitMonitor = nil + ignoredExitPending = false + } + + override func viewWillMove(toWindow newWindow: NSWindow?) { + if newWindow == nil { stopExitWatch() } + super.viewWillMove(toWindow: newWindow) + } + + deinit { + for monitor in [localExitMonitor, globalExitMonitor].compactMap({ $0 }) { NSEvent.removeMonitor(monitor) } + } var clipping = false { didSet { diff --git a/src/macos/GenesisKit/Tests/GenesisKitTests/EdgePanelGeometryTests.swift b/src/macos/GenesisKit/Tests/GenesisKitTests/EdgePanelGeometryTests.swift index 46fe0c85e9..3c7b2f5674 100644 --- a/src/macos/GenesisKit/Tests/GenesisKitTests/EdgePanelGeometryTests.swift +++ b/src/macos/GenesisKit/Tests/GenesisKitTests/EdgePanelGeometryTests.swift @@ -9,6 +9,37 @@ final class EdgePanelGeometryTests: XCTestCase { private let screen = CGRect(x: -1600, y: 100, width: 1600, height: 1000) private let visible = CGRect(x: -1600, y: 150, width: 1600, height: 920) + @MainActor + func testAnIgnoredExitKeepsWatchingUntilPointerMovementLeavesTheWindow() async throws { + let window = NSWindow(contentRect: CGRect(x: -10000, y: -10000, width: 200, height: 200), + styleMask: [.borderless], backing: .buffered, defer: false) + window.isReleasedWhenClosed = false + let root = EdgePanelRootView(frame: CGRect(x: 0, y: 0, width: 200, height: 200)) + window.contentView = root + defer { window.close() } + var point = NSPoint(x: window.frame.midX, y: window.frame.midY) + root.pointerLocation = { point } + var changes: [Bool] = [] + root.pointerChanged = { changes.append($0) } + let event = try XCTUnwrap(NSEvent.enterExitEvent(with: .mouseExited, location: .zero, modifierFlags: [], + timestamp: 0, windowNumber: window.windowNumber, context: nil, eventNumber: 0, trackingNumber: 0, + userData: nil)) + root.mouseExited(with: event) + try await Task.sleep(for: .milliseconds(300)) + XCTAssertTrue(root.ignoredExitPending, "the first recheck must not stop watching a still pointer") + XCTAssertTrue(changes.isEmpty) + point = NSPoint(x: window.frame.maxX + 1, y: window.frame.midY) + root.checkIgnoredExit() // The same handler the movement monitors call; no second mouseExited. + XCTAssertEqual(changes, [false]) + XCTAssertFalse(root.ignoredExitPending) + root.checkIgnoredExit() + XCTAssertEqual(changes, [false], "a leave is reported once") + point = NSPoint(x: window.frame.midX, y: window.frame.midY) + root.mouseExited(with: event) + window.contentView = nil + XCTAssertFalse(root.ignoredExitPending, "detaching the view removes its monitors") + } + func testTopWidthReservesRoomForTheCutoutAndEveryVisibleControl() { XCTAssertEqual(WidgetClusterGeometry.topWidth(cutout: 200, moduleCount: 1), 364) XCTAssertEqual(WidgetClusterGeometry.topWidth(cutout: 200, moduleCount: 6), 524) @@ -819,6 +850,13 @@ final class WidgetVisibilityTests: XCTestCase { seen = try read("peek", WidgetPeekView(model: model, item: peek)) XCTAssertTrue(seen.contains("Decision 8"), "read: \(seen)") XCTAssertTrue(seen.contains("Pick a parser"), "read: \(seen)") + // The card fills a pane taller than itself (the rail sets the panel's height) with one inset on every side. + let pane = WidgetPeekView(model: model, item: peek).padding(WidgetPeekView.inset) + .frame(width: 324, height: 270, alignment: .top) + .background(RoundedRectangle(cornerRadius: 18, style: .continuous).fill(Color(white: 0.17))) + seen = try read("peek-pane", pane) + XCTAssertTrue(seen.contains("Click to answer"), "read: \(seen)") + XCTAssertEqual(WidgetPeekView.cornerRadius + WidgetPeekView.inset, 18, "the card's corner runs parallel to the panel's") model.openDecision(id: "d_8_fixture-session", sessionKey: key) XCTAssertEqual(model.card?.id, "decision:d_8_fixture-session", "the link opens the newer decision") diff --git a/src/macos/GenesisKit/Tests/GenesisKitTests/WidgetInboxTasksPolishTests.swift b/src/macos/GenesisKit/Tests/GenesisKitTests/WidgetInboxTasksPolishTests.swift index 141766617b..2b41c43728 100644 --- a/src/macos/GenesisKit/Tests/GenesisKitTests/WidgetInboxTasksPolishTests.swift +++ b/src/macos/GenesisKit/Tests/GenesisKitTests/WidgetInboxTasksPolishTests.swift @@ -1163,11 +1163,68 @@ final class WidgetStagingTests: XCTestCase { model.resolveStagedLeave("send") XCTAssertEqual(model.expanded, .right, "the panel stays open until the hub answers") try await Task.sleep(for: .milliseconds(200)) + XCTAssertEqual(model.expanded, .right, "the delivered note stays readable before the panel folds") + try await Task.sleep(for: .milliseconds(2600)) 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") } + func testNewInteractionCancelsTheDeliveredSendClose() async throws { + let models = (0 ..< 4).map { _ in StagingFixture.model() } + defer { models.forEach { $0.stop() } } + for model in models { + model.actionRunner = { _ in ["sent": 1, "failed": 0] } + model.receive([try snapshot(draftOption: "a")]) + model.select(key) + model.expanded = .right + model.requestCollapse() + model.resolveStagedLeave("send") + } + for _ in 0 ..< 400 where models.contains(where: { $0.notice != "1 decision delivered." }) { + try await Task.sleep(for: .milliseconds(5)) + } + for model in models { XCTAssertEqual(model.notice, "1 decision delivered.") } + models[0].setText("A new reply") + var nextDecision = try XCTUnwrap(models[1].stagedItems.first) + nextDecision.id = "decision:d2" + nextDecision.sourceId = "d2" + nextDecision.draftOption = nil + models[1].choose(nextDecision, option: "b") + nextDecision.draftOption = "b" + models[1].reconcileStaged([nextDecision]) + models[2].select("another-session") + models[2].select(key) + models[3].discardStaged(["d1"]) + try await Task.sleep(for: .milliseconds(2600)) + for model in models { XCTAssertEqual(model.expanded, .right, "new work must stay visible") } + XCTAssertEqual(models[0].draft.text, "A new reply") + // A newly staged decision still goes through the normal leave confirmation. + models[1].requestCollapse() + XCTAssertTrue(models[1].stagedLeavePrompt) + } + + func testSendCloseCanKeepThePanelOpenOrUseTheConfiguredDelay() async throws { + let models = (0 ..< 2).map { _ in StagingFixture.model() } + defer { models.forEach { $0.stop() } } + for (index, model) in models.enumerated() { + model.actionRunner = { _ in ["sent": 1, "failed": 0] } + var wire = try JSONDecoder().decode(WidgetSnapshot.self, from: Data(snapshot(draftOption: "a").utf8)) + wire.state.preferences.sendCloseSeconds = index == 0 ? 0 : 0.05 + model.receive([String(decoding: try JSONEncoder().encode(wire), as: UTF8.self)]) + model.select(key) + model.expanded = .right + model.requestCollapse() + model.resolveStagedLeave("send") + } + for _ in 0 ..< 400 where models[1].expanded != nil || models[0].notice != "1 decision delivered." { + try await Task.sleep(for: .milliseconds(5)) + } + XCTAssertNil(models[1].expanded, "a configured delay replaces the 2.5 second default") + try await Task.sleep(for: .milliseconds(2600)) + XCTAssertEqual(models[0].expanded, .right, "Keep open disables the delayed fold") + } + func testASendNoSessionTookSaysWhyAndSendAgainDeliversIt() async throws { let defaults = UserDefaults(suiteName: "widget.staging." + UUID().uuidString)! let model = WidgetModel(binaryPath: "/fixture/tools", stateRoot: FileManager.default.temporaryDirectory.path, diff --git a/src/macos/GenesisKit/Tests/GenesisKitTests/WidgetPolishShotsTests.swift b/src/macos/GenesisKit/Tests/GenesisKitTests/WidgetPolishShotsTests.swift new file mode 100644 index 0000000000..17ceb8bc84 --- /dev/null +++ b/src/macos/GenesisKit/Tests/GenesisKitTests/WidgetPolishShotsTests.swift @@ -0,0 +1,283 @@ +import AppKit +import SwiftUI +import XCTest + +@testable import GenesisKit + +/// The widget's Panel background setting and the inbox header's sort label (Martin, 2026-10-25), checked as values and +/// rendered for the report with `WIDGET_POLISH_SHOTS=`. +@MainActor +final class WidgetPolishShotsTests: XCTestCase { + private let key = "local:claude:fixture-polish:" + + private func model(panelBackground: String?, cards: [[String: Any]] = []) throws -> WidgetModel { + let defaults = try XCTUnwrap(UserDefaults(suiteName: "widget.polish." + UUID().uuidString)) + // Its own state folder with the submission journal in place: a queued answer is written there first. + let state = FileManager.default.temporaryDirectory.appendingPathComponent("widget-polish-" + UUID().uuidString) + try FileManager.default.createDirectory(at: state.appendingPathComponent("native-submissions"), + withIntermediateDirectories: true) + let model = WidgetModel(binaryPath: "/fixture/tools", stateRoot: state.path, + defaults: defaults, appearance: NativeSettingsAppearance(defaults: defaults, + notificationNamespace: "polish." + UUID().uuidString, observeExternalChanges: false)) + var preferences: [String: Any] = ["excludedKeys": [], "projects": [], "sessions": [], "showChanges": false, + "placement": "both", "side": "right", "quietSeconds": 15, "voiceProvider": "openai", "voiceLanguage": "en", + "previewSort": "recent"] + if let panelBackground { preferences["panelBackground"] = panelBackground } + let sessions: [[String: Any]] = ["Kalendora hotfix research", "gt-native-ux", "gt-fixes"].enumerated().map { index, title in + ["key": key + "\(index)", "target": ["hostId": "local", "provider": "claude", "sessionId": "fixture-\(index)", + "sourceHome": "", "cwd": "/fixture"], "title": title, "project": "Fixture", + "activityAt": Date().timeIntervalSince1970 * 1000 - Double(index) * 60_000, + "status": index == 0 ? "working" : "idle", "pinned": false, "visible": true, "hiddenByFilter": false] + } + let data = try JSONSerialization.data(withJSONObject: ["version": 1, "state": ["version": 1, "revision": 0, + "selectedKey": key + "0", "preferences": preferences, "assets": [:], "drafts": [:], "outgoing": []], + "sessions": sessions, "cards": cards, "manifests": [:], "errors": [], "selectedKey": key + "0", + "notifications": ["unread": 0, "needsAnswer": 1, "complete": true, "truncated": false, + "sessions": [["key": key + "0", "unread": 0, "needsAnswer": 1, "pendingItem": Self.pending(key + "0"), + "items": [Self.pending(key + "0")]]]]]) + model.receive([String(decoding: data, as: UTF8.self)]) + return model + } + + static func pending(_ key: String) -> [String: Any] { + ["id": "decision:d10", "sourceId": "d10", "kind": "decision", "key": key, "at": Date().timeIntervalSince1970 * 1000, + "needsAnswer": true, "number": 10, "title": "P8 scope: where \"no automatic failover, show the error\" applies", + "options": ["a) only the fleet publisher", "b) every remote call", "c) the installer as well"], "recommended": "b", + "revision": 1] + } + + /// Martin, 2026-10-26: an own answer sent from its card left the card's field, came back in the top reply field + /// for a few seconds, and disappeared only when the hub's next snapshot cleared it. The perf log showed a draft + /// save half a second after the send: the closing field wrote the sent text back into the emptied draft. + func testASentOwnAnswerDoesNotComeBackInTheReplyField() throws { + let card: [String: Any] = ["id": "decision:d10", "kind": "decision", "sessionKey": key + "0", "sourceId": "d10", + "at": Date().timeIntervalSince1970 * 1000, "title": "Merge it in this run?", "body": "", "status": "open", + "revision": 1, "number": 10, "choices": [["id": "a", "title": "Yes", "recommended": true]], + "attachments": [], "refs": [], "read": false] + let model = try model(panelBackground: nil, cards: [card]) + defer { model.stop() } + let decision = try XCTUnwrap(model.cards.first) + model.openCard(decision) + model.openOwnAnswer(decision) + model.actionRunner = { _ in ["ok": true] } + let closingField = model.composerText(ownAnswer: decision.id) + let oldReplyField = model.composerText() + closingField.wrappedValue = "merge it after its round" + + model.send() + XCTAssertNil(model.ownAnswerCard, "the card's field closes once the answer is queued") + XCTAssertEqual(model.draft.text, "") + closingField.wrappedValue = "merge it after its round" + oldReplyField.wrappedValue = "merge it after its round" + XCTAssertEqual(model.draft.text, "", "disposed fields cannot write into the next composer") + model.composerText().wrappedValue = "merge it after its round" + XCTAssertEqual(model.draft.text, "merge it after its round", "the active composer accepts an immediate repeat") + model.setText("and one more thing") + XCTAssertEqual(model.draft.text, "and one more thing", "new typing after the send is kept") + } + + func testRegularComposerAcceptsAnImmediateRepeatAfterSend() throws { + let model = try model(panelBackground: nil) + defer { model.stop() } + model.actionRunner = { _ in ["ok": true] } + let oldField = model.composerText() + oldField.wrappedValue = "OK" + model.send() + oldField.wrappedValue = "OK" + XCTAssertEqual(model.draft.text, "", "an old binding cannot restore sent text") + model.composerText().wrappedValue = "OK" + XCTAssertEqual(model.draft.text, "OK", "the active reply field accepts the same message immediately") + } + + func testQueuedDraftSaveIsFencedBySendAndLaterSavesStillRun() async throws { + let model = try model(panelBackground: nil) + defer { model.stop() } + var writes: [String] = [] + let laterSave = expectation(description: "new draft reached the backend") + model.actionRunner = { value in + if case .object(let fields) = value, fields["action"] == .string("draft-text"), + case .string(let text) = fields["text"] { + writes.append(text) + if text == "next draft" { laterSave.fulfill() } + } + return ["ok": true] + } + var release: CheckedContinuation? + model.afterMutations { await withCheckedContinuation { release = $0 } } + for _ in 0 ..< 200 where release == nil { try await Task.sleep(for: .milliseconds(5)) } + guard let barrier = release else { return XCTFail("mutation barrier did not start") } + defer { release?.resume() } + model.setText("old queued draft") + // Let the debounce put its save behind the blocked mutation before Send invalidates that revision. + try await Task.sleep(for: .milliseconds(450)) + model.send() + let drained = expectation(description: "send mutations drained") + model.afterMutations { drained.fulfill() } + release = nil + barrier.resume() + await fulfillment(of: [drained], timeout: 3) + XCTAssertEqual(writes, [""], "the queued old save is skipped but the send's clear reaches the backend") + model.setText("next draft") + await fulfillment(of: [laterSave], timeout: 3) + XCTAssertEqual(writes, ["", "next draft"], "a legitimate later draft still saves") + } + + func testWaitingQuestionIsIncludedInThePreviewAccessibilityLabel() throws { + let model = try model(panelBackground: nil) + defer { model.stop() } + let session = try XCTUnwrap(model.selected) + let item = try XCTUnwrap(model.inboxFor(session.key)?.pendingItem) + let question = try XCTUnwrap(AgentWidgetPreview.pendingQuestion(item, enabled: true)) + XCTAssertTrue(AgentWidgetPreview.rowAccessibilityLabel(session, question: question).contains(question)) + XCTAssertNil(AgentWidgetPreview.pendingQuestion(item, enabled: false)) + XCTAssertNil(AgentWidgetPreview.pendingQuestion(nil, enabled: true)) + XCTAssertEqual(AgentWidgetPreview.rowAccessibilityLabel(session, question: nil), + "Open " + session.title + ", " + session.visualStatus.label) + var untitled = item + untitled.title = "" + XCTAssertNil(AgentWidgetPreview.pendingQuestion(untitled, enabled: true)) + } + + func testQuietCollapseRestartsWhenThePointerLeavesWithoutAnotherSnapshot() async throws { + let model = try model(panelBackground: nil) + defer { model.stop() } + model.actionRunner = { _ in ["ok": true] } + var wire = try XCTUnwrap(model.snapshot) + wire.sessions = wire.sessions.map { session in + var idle = session + idle.status = "idle" + return idle + } + wire.state.preferences.quietSeconds = 5 + model.receive([String(decoding: try JSONEncoder().encode(wire), as: UTF8.self)]) + let surface = WidgetSurfaceID(edge: .right) + model.hover(surface, inside: true) + model.open(.right) + try await Task.sleep(for: .milliseconds(5300)) + XCTAssertEqual(model.expanded, .right, "quiet expiry keeps a panel under the pointer open") + model.hover(surface, inside: false) + try await Task.sleep(for: .milliseconds(5300)) + XCTAssertNil(model.expanded, "pointer exit rearms quiet collapse without a new snapshot") + } + + func testQuietCollapseKeepsOverviewDecisionsFromOtherSessionsVisible() async throws { + let model = try model(panelBackground: nil) + defer { model.stop() } + model.actionRunner = { _ in ["ok": true] } + var wire = try XCTUnwrap(model.snapshot) + wire.sessions = wire.sessions.map { session in + var idle = session + idle.status = "idle" + return idle + } + wire.state.preferences.quietSeconds = 5 + var summary = try XCTUnwrap(wire.notifications) + var entry = try XCTUnwrap(summary.sessions.first) + let otherKey = wire.sessions[1].key + var decision = try XCTUnwrap(entry.pendingItem) + decision.key = otherKey + entry.key = otherKey + entry.pendingItem = decision + entry.items = [decision] + summary.sessions = [entry] + wire.notifications = summary + model.receive([String(decoding: try JSONEncoder().encode(wire), as: UTF8.self)]) + XCTAssertTrue(model.cards.isEmpty, "the selected session has no waiting card") + XCTAssertEqual(model.inboxOverviewItems.first?.key, otherKey) + model.inboxOverview = true + model.open(.right) + try await Task.sleep(for: .milliseconds(5300)) + XCTAssertEqual(model.expanded, .right, "a waiting overview decision protects keyboard readers too") + } + + func testTheAlertAccessibilityValueIncludesDisplayedOptionsAndRecommendation() throws { + let model = try model(panelBackground: nil) + defer { model.stop() } + var item = try XCTUnwrap(model.inboxFor(key + "0")?.pendingItem) + item.options = ["Keep", "Drop", "Review", "Pause", "Later"] + item.recommended = "b" + XCTAssertEqual(WidgetPeekOptions.accessibilityValue(item), + "Keep; Drop, recommended; Review; Pause; 1 more") + item.options = ["Keep", "Drop"] + item.recommended = "Keep" + XCTAssertEqual(WidgetPeekOptions.accessibilityValue(item), "Keep, recommended; Drop") + item.recommended = nil + XCTAssertEqual(WidgetPeekOptions.accessibilityValue(item), "Keep; Drop") + item.needsAnswer = false + XCTAssertEqual(WidgetPeekOptions.accessibilityValue(item), "", "read-only messages show no options") + item.needsAnswer = true + item.options = nil + XCTAssertEqual(WidgetPeekOptions.accessibilityValue(item), "") + item.options = [] + XCTAssertEqual(WidgetPeekOptions.accessibilityValue(item), "") + } + + func testTheRecommendedOptionIsFoundByLetterOrText() { + XCTAssertTrue(WidgetPeekOptions.isRecommended("b) every remote call", at: 1, "b")) + XCTAssertTrue(WidgetPeekOptions.isRecommended("keep", at: 0, "keep")) + XCTAssertFalse(WidgetPeekOptions.isRecommended("a) only the fleet publisher", at: 0, "b")) + XCTAssertFalse(WidgetPeekOptions.isRecommended("bigger", at: 0, "b")) + let options = ["Keep", "Drop"] + XCTAssertEqual(options.enumerated().filter { + WidgetPeekOptions.isRecommended($0.element, at: $0.offset, " B ") + }.map(\.element), ["Drop"], "wire options have no letter prefix") + XCTAssertFalse(WidgetPeekOptions.isRecommended("Drop", at: 1, nil)) + XCTAssertFalse(WidgetPeekOptions.isRecommended("Drop", at: 1, " ")) + } + + func testThePanelIsDarkUnlessGlassOrTheAppThemeIsPicked() throws { + XCTAssertEqual(try model(panelBackground: nil).panelTheme, .graphite, "an older snapshot reads as dark") + XCTAssertEqual(try model(panelBackground: "dark").panelTheme, .graphite) + XCTAssertEqual(try model(panelBackground: "glass").panelTheme, .glass) + let themed = try model(panelBackground: "theme") + XCTAssertEqual(themed.panelTheme, themed.appearance.theme) + defer { themed.stop() } + themed.appearance.theme = .glass + var wire = try XCTUnwrap(themed.snapshot) + wire.state.preferences.glassEffect = false + themed.receive([String(decoding: try JSONEncoder().encode(wire), as: UTF8.self)]) + XCTAssertEqual(themed.panelTheme, .glass, "the removed legacy glass toggle cannot override Same as the app") + themed.appearance.theme = .midnight + XCTAssertEqual(themed.panelTheme, .midnight, "Same as the app follows later theme changes too") + } + + func testTheSortButtonNamesTheOrderInOneWord() { + XCTAssertEqual(WidgetPreviewChoices.shortTitle(sort: "attention"), "Waiting") + XCTAssertEqual(WidgetPreviewChoices.shortTitle(sort: "recent"), "Newest") + XCTAssertEqual(WidgetPreviewChoices.shortTitle(sort: "project"), "Project") + } + + func testRendersTheSettingAndTheHeader() throws { + guard let folder = ProcessInfo.processInfo.environment["WIDGET_POLISH_SHOTS"] else { return } + let model = try model(panelBackground: "dark") + defer { model.stop() } + let general = try XCTUnwrap(WidgetFeatureSettings.sections(model: model, modules: WidgetModuleChoice.builtins, + openSession: { _ in }).flatMap(\.pages).first { $0.id == "widgets.general" }) + let page = ScrollView { general.content().padding(24) } + .frame(maxWidth: .infinity, maxHeight: .infinity, alignment: .top) + .background(NativeThemePalette.graphiteBase) + .environment(\.nativeSettingsTheme, .graphite) + .environment(\.colorScheme, .dark) + let pageShot = try KitShots.render(page, size: CGSize(width: 760, height: 1500), name: "widget-general", settle: 0.8) + try XCTUnwrap(pageShot.representation(using: .png, properties: [:])) + .write(to: URL(fileURLWithPath: folder).appendingPathComponent("widget-general-settings.png")) + let preview = AgentWidgetPreview(model: model, surface: WidgetSurfaceID(edge: .right)).padding(10) + .frame(width: 420, alignment: .top) + .background(NativeThemePalette.graphiteBase) + .environment(\.nativeSettingsTheme, model.panelTheme) + .environment(\.colorScheme, .dark) + let previewShot = try KitShots.render(preview, size: CGSize(width: 420, height: 320), name: "widget-preview", settle: 0.6) + try XCTUnwrap(previewShot.representation(using: .png, properties: [:])) + .write(to: URL(fileURLWithPath: folder).appendingPathComponent("widget-inbox-sort-label.png")) + // The alert card at the size of Martin's screenshot (2026-10-25 18:47): the pane is as tall as the rail. + let item = try XCTUnwrap(model.inboxFor(key + "0")?.pendingItem) + let peek = WidgetPeekView(model: model, item: item).padding(WidgetPeekView.inset) + .frame(width: 310, height: 290, alignment: .top) + .background(NativeThemePalette.graphiteBase) + .environment(\.nativeSettingsTheme, model.panelTheme) + .environment(\.colorScheme, .dark) + let peekShot = try KitShots.render(peek, size: CGSize(width: 310, height: 290), name: "widget-peek", settle: 0.6) + try XCTUnwrap(peekShot.representation(using: .png, properties: [:])) + .write(to: URL(fileURLWithPath: folder).appendingPathComponent("widget-alert-card.png")) + } +} diff --git a/src/macos/GenesisKit/Tests/GenesisKitTests/WidgetScrollReplayTests.swift b/src/macos/GenesisKit/Tests/GenesisKitTests/WidgetScrollReplayTests.swift index e72a470afb..9c8650bac1 100644 --- a/src/macos/GenesisKit/Tests/GenesisKitTests/WidgetScrollReplayTests.swift +++ b/src/macos/GenesisKit/Tests/GenesisKitTests/WidgetScrollReplayTests.swift @@ -66,6 +66,8 @@ final class WidgetScrollReplayTests: XCTestCase { model.section = "Inbox" // `GENESIS_WIDGET_REPLAY_OVERVIEW=1`: the inbox of every session (the inbox button), not the one session. if environment["GENESIS_WIDGET_REPLAY_OVERVIEW"] == "1" { model.inboxOverview = true } + // `GENESIS_WIDGET_REPLAY_FILTER=decisions`: the inbox filter ("all", "inbox", "decisions"). + if let filter = environment["GENESIS_WIDGET_REPLAY_FILTER"] { model.inboxFilter = filter } if let number = environment["GENESIS_WIDGET_REPLAY_OPEN"].flatMap(Int.init), let card = model.cards.first(where: { $0.number == number }) { model.openCard(card)