From 30af2020824bd2e0e3c43afb6bf93cdaebf9d7c0 Mon Sep 17 00:00:00 2001 From: Martin Date: Fri, 23 Oct 2026 15:26:44 +0200 Subject: [PATCH 01/51] feat(GenesisKit, GenesisHub, widgets): the inbox button lists every session's entries, the hover list shows every session that needs you with a filter, an order and a settings shortcut, every session gets a cmux button, and the review commit picker picks a range with Shift --- .claude/skills/swift/references/gotchas.md | 16 ++ .claude/skills/ux/references/checklist.md | 18 ++ .claude/skills/ux/references/findings.md | 18 ++ scripts/native/widget-drive.ts | 1 + src/hub/lib/composer/outbox.test.ts | 20 +++ src/hub/lib/widget/snapshot.ts | 19 +- src/hub/lib/widget/types.ts | 4 + .../GenesisKit/Cmux/CmuxCommandRunner.swift | 120 +++++++++++++ .../GenesisKit/Controls/AgentRosterRow.swift | 13 +- .../Diff/Views/GitCommitPicker.swift | 106 ++++++++++-- .../Settings/NativeSettingsModel.swift | 65 ++++++- .../GenesisKit/Widgets/AgentWidgetViews.swift | 2 + .../GenesisKit/Widgets/LiveWidgetView.swift | 91 ++++++++-- .../Widgets/WidgetAgentPreview.swift | 129 ++++++++------ .../GenesisKit/Widgets/WidgetCmuxButton.swift | 138 +++++++++++++++ .../Widgets/WidgetCoordinator.swift | 1 + .../Widgets/WidgetFeatureSettings.swift | 32 ++++ .../GenesisKit/Widgets/WidgetHostView.swift | 14 +- .../Widgets/WidgetInboxOverview.swift | 162 ++++++++++++++++++ .../GenesisKit/Widgets/WidgetModel.swift | 108 +++++++++++- .../Widgets/WidgetSessionBrowser.swift | 130 ++++++++++---- .../GenesisKit/Widgets/WidgetWireModels.swift | 8 + .../GenesisKitTests/Diff/GitPickerTests.swift | 20 +++ .../WidgetInboxTasksPolishTests.swift | 33 ++++ .../Sources/Parts/Hub/Hub/HubLaunch.swift | 92 +--------- .../Sources/Parts/Hub/Hub/HubWorktrees.swift | 7 +- .../Parts/Hub/Review/ReviewWindow.swift | 31 +++- .../Sources/Parts/Widget/WidgetWindow.swift | 3 + .../Tests/HubMenuButtonTests.swift | 6 +- 29 files changed, 1176 insertions(+), 231 deletions(-) create mode 100644 src/macos/GenesisKit/Sources/GenesisKit/Cmux/CmuxCommandRunner.swift create mode 100644 src/macos/GenesisKit/Sources/GenesisKit/Widgets/WidgetCmuxButton.swift create mode 100644 src/macos/GenesisKit/Sources/GenesisKit/Widgets/WidgetInboxOverview.swift diff --git a/.claude/skills/swift/references/gotchas.md b/.claude/skills/swift/references/gotchas.md index 0e8d1e0fa0..97878181d9 100644 --- a/.claude/skills/swift/references/gotchas.md +++ b/.claude/skills/swift/references/gotchas.md @@ -390,3 +390,19 @@ Keep measured findings separate from hypotheses; faster CPU numbers alone do not - **Driving a background app's context menu with `tools control` is not safe yet.** `AXShowMenu` on a SwiftUI tab blocked 2 s and opened the parent's menu first; the menu rows were not in `see --scope menu`; a coordinate click into the open menu closed it without choosing. Trigger the code path from a test instead. + +## Row-only controls with popovers, key modifiers, shared builds — 2026-10-23 15:17 + +- **A control that exists only on the hovered row must keep its popover open after the pointer leaves.** The + popover's `isPresented` lives in the control; remove the control on hover-out and SwiftUI closes the popover. + Keep the `.popover` modifier on a same-size `Color.clear` placeholder while the row is not live, and treat "popover + open" as live (`WidgetCmuxButton(live:)`, GenesisKit Widgets). Hover is the row's, not the control's: a hover + sensor on the button covers only the button (`WidgetHoverRow { hovering in … }`). +- **A popover from an edge panel needs its own "dialog open" flag.** Setting the host's shared `dialogOpen` from the + popover's `onChange` can release a window the host opened when the popover closes. One flag per presenter, ORed + into the getter (`sessionChooserOpen`, `cmuxPopoverOpen`). +- **`.onKeyPress(.downArrow) { … }` has no key press to read.** Its closure takes no argument; to read Shift use + `.onKeyPress(.downArrow, phases: [.down, .repeat]) { press in press.modifiers.contains(.shift) }`. A click's + modifiers come from `NSEvent.modifierFlags` inside the button action. +- **"input file … was modified during the build" is another session editing GenesisKit**, not your code. Rerun the + build through `.claude/skills/babysit/scripts/swift-slot.ts`; do not stash or touch their file. diff --git a/.claude/skills/ux/references/checklist.md b/.claude/skills/ux/references/checklist.md index 28a2f092a6..5c4cbdcc55 100644 --- a/.claude/skills/ux/references/checklist.md +++ b/.claude/skills/ux/references/checklist.md @@ -45,6 +45,24 @@ Walk it for every view you change. Each line exists because it was missed at lea - One navigation per surface; no two controls that do the same thing side by side. - Distinct icons for distinct features (two modules once shared `waveform`). +## Widget and panel controls (Martin, 2026-10-23 14:40) +- A text field inside a padded box: the whole box focuses the field (`.contentShape` + `.onTapGesture { focus = true }` + on the box); a click beside or under the one-line field did nothing. +- A button that opened something closes it on a second click when what it opened is still on screen (a rail + session's inbox, the inbox overview). A second click that does nothing reads as a dead control. +- A count leads to the list of exactly what it counts. "35 need you" over 4 rows, and "99+ unread" that opened one + arbitrary session, are both defects: the count opens a list of every counted item (scrollable, filterable), and + each item says which session it belongs to (name, project, short id) and how to get there. +- Every session row offers a way to its terminal: the cmux button with GenesisKit `CmuxSessionPanel` (focus, open + in its last pane, choose a pane), one shared component in the hub and the widget. +- A list's filter and order live next to the list, with a settings shortcut that opens the page at the card and + pulses it (`#` + `.nativeSettingsAnchor(id)`, GenesisKit `AttentionPulse`). +- One row style for every top-level item of a list. Sessions with sub-agents drew as disclosure headers and sessions + without as plain rows, in one list: same row, a chevron only where there is something to fold, and selection, pin + and cmux inside the row's own box. +- A scroll view reused for several sections (or sessions) gets an identity per content (`.id(section + key)`): it + otherwise keeps the old offset, and a short section drew its rows over the header. + ## Feedback - Every action answers within 100 ms: visible state change, sound for capture, progress for slow work. - Errors say what happened and what to do, in a dialog when the user must act. A missing grant goes through diff --git a/.claude/skills/ux/references/findings.md b/.claude/skills/ux/references/findings.md index 54640d8af8..8a5407d1d4 100644 --- a/.claude/skills/ux/references/findings.md +++ b/.claude/skills/ux/references/findings.md @@ -3,6 +3,24 @@ Newest first. Each entry: date, symptom as the user saw it, cause, fix, commit. Generic lessons go to checklist.md or motion.md as well. +- 2026-10-23 15:17 — **The widget's inbox and session lists (Martin's review with seven screenshots).** + - *Only the text line of the composer took a click.* The `TextField` was one line inside a padded box. Fix: the + box is the target (`LiveWidgetView.composer`, legacy `AgentWidgetView` too). + - *A second click on a rail session ("4") did nothing.* Every session button called `openInboxNotification` + again. Fix: `WidgetModel.toggleSession` / `toggleInboxOverview` collapse when that view is already open. + - *"35 need you" with four rows.* The preview took `prefix(4)` of the visible sessions. Fix: `previewRows`, every + session that needs you in a `FittedScroll`, an "Active too" filter, three orders, and a settings shortcut to + Projects / Sessions `#inbox-preview`, which pulses. + - *"99+ unread" opened one random session with no name or way to its terminal, and nothing else was reachable.* + The button picked the newest item of the whole inbox. Fix: an inbox overview over every session (the snapshot + now lists up to eight entries per session, `WidgetInboxSession.items`), each entry with its session and "Open in + its session", a session inbox filter (All / Inbox entries / Decisions), and a cmux button on every session. + - *A session opened from Sessions drew its inbox rows over the header.* The pane's one `ScrollView` kept the long + Sessions list's offset. Fix: `.id(section + "|" + selectedKey)`. + - *Sessions with and without sub-agents looked different.* Two row styles in one list. Fix: one top-level row. + - The first cut put the cmux button on every row of a list of hundreds; the swift skill's dense-list rule caught + it before commit (row-only controls, `WidgetHoverRow`). + - 2026-10-15 00:50 — **The vault approval window (GenesisTools.app `vault.unlock`).** - *"This can be probably nicer": one block of text with full paths, the raw command, two UUIDs and five buttons in a row.* It was an `NSAlert`. Now a SwiftUI panel (GenesisKit Permissions/ApprovalDialog.swift): the reason diff --git a/scripts/native/widget-drive.ts b/scripts/native/widget-drive.ts index faca5aafc2..6cb125e13b 100644 --- a/scripts/native/widget-drive.ts +++ b/scripts/native/widget-drive.ts @@ -13,6 +13,7 @@ import { SafeJSON } from "@genesiscz/utils/json"; * * Commands (WidgetWindow.swift runTestCommand): `expand [group]`, `module [group]`, * `hover [group]`, `unhover [group]`, `collapse`, `select `, `settings [page]`, + * `inbox [group]` (the inbox button), `session [group]` (a rail session button), * `passive on|off`. Send `passive on` before `expand` on the user's own screen: an expanded panel otherwise takes the * keyboard from the app in front. * Edges: top, right, left. The face honours them only with staging on (`bun scripts/native/staging.ts on`) or in the diff --git a/src/hub/lib/composer/outbox.test.ts b/src/hub/lib/composer/outbox.test.ts index d6fba0282e..aac9840eb1 100644 --- a/src/hub/lib/composer/outbox.test.ts +++ b/src/hub/lib/composer/outbox.test.ts @@ -74,9 +74,11 @@ import { import { claudeAssistantText, discoverWidgetCatalog, + INBOX_ITEMS_PER_SESSION, inboxWindowCounts, lastAssistantTextOf, lastClaudeAssistantText, + listedInboxItems, readWidgetChanges, readWidgetDecisionEvents, realWidgetSources, @@ -1677,6 +1679,8 @@ test("decision cards carry recorded answers and supersede links; only open decis number: 3, title: widgetTitle(replacement.prompt), }); + // The overview lists the session's entries, the waiting one first. + expect(session?.items?.[0]).toMatchObject({ id: `decision:${replacement.id}`, needsAnswer: true }); expect( snapshot.notifications?.sessions.find((entry) => entry.pendingItem?.sourceId === elsewhere.id)?.pendingItem ).toMatchObject({ number: 1, title: "Other" }); @@ -1694,6 +1698,22 @@ test("decision cards carry recorded answers and supersede links; only open decis expect(moved.notifications?.sessions.find((entry) => entry.key === sessionKey)?.needsAnswer ?? 0).toBe(0); }); +test("a session lists its waiting entries first, then the newest, at most eight", () => { + const item = (id: string, at: number, needsAnswer = false) => ({ + id, + sourceId: id, + kind: needsAnswer ? ("decision" as const) : ("answer" as const), + key: "k", + at, + needsAnswer, + }); + const items = [item("old", 1), item("ask", 2, true), ...Array.from({ length: 9 }, (_, i) => item(`n${i}`, 10 + i))]; + const listed = listedInboxItems(items); + expect(listed).toHaveLength(INBOX_ITEMS_PER_SESSION); + expect(listed.map((entry) => entry.id).slice(0, 3)).toEqual(["ask", "n8", "n7"]); + expect(listed.map((entry) => entry.id)).not.toContain("old"); +}); + test("alert preferences default on with the Glass sound, load from an older state, and refuse a bad sound", async () => { const directory = await root(); await writeFile( diff --git a/src/hub/lib/widget/snapshot.ts b/src/hub/lib/widget/snapshot.ts index e7da1eca7d..2b4badba76 100644 --- a/src/hub/lib/widget/snapshot.ts +++ b/src/hub/lib/widget/snapshot.ts @@ -195,6 +195,8 @@ export interface WidgetInboxItem { title?: string; /** A decision's number. */ number?: number; + /** How many inbox items this entry stands for: an answers group lists only its newest item. */ + count?: number; } export interface WidgetInboxSession { key: string; @@ -203,6 +205,18 @@ export interface WidgetInboxSession { latest?: WidgetInboxItem; unreadItem?: WidgetInboxItem; pendingItem?: WidgetInboxItem; + /** The session's listed entries, the ones waiting for an answer first, then newest first; at most `INBOX_ITEMS_PER_SESSION`. */ + items?: WidgetInboxItem[]; +} + +/** The entries one session lists in the widget's inbox overview; its counts still cover every item. */ +export const INBOX_ITEMS_PER_SESSION = 8; + +/** A session's entries for the overview: the ones waiting for an answer first, then newest first, capped. */ +export function listedInboxItems(items: WidgetInboxItem[]): WidgetInboxItem[] { + return [...items] + .sort((a, b) => Number(b.needsAnswer) - Number(a.needsAnswer) || b.at - a.at) + .slice(0, INBOX_ITEMS_PER_SESSION); } export interface WidgetInboxSummary { unread: number; @@ -1000,6 +1014,8 @@ export async function widgetSnapshot({ if (!entry[target] || item.at > entry[target].at) { entry[target] = item; } + + entry.items = [...(entry.items ?? []), count > 1 ? { ...item, count } : item]; inboxSessions.set(session.key, entry); }; // Counted per item: each SQL group carries its session's newest item and how many of its items fall after the @@ -1125,7 +1141,8 @@ export async function widgetSnapshot({ truncated: inboxData.truncated || inboxSessions.size > 512, sessions: [...inboxSessions.values()] .sort((a, b) => b.needsAnswer - a.needsAnswer || b.unread - a.unread) - .slice(0, 512), + .slice(0, 512) + .map((entry) => ({ ...entry, items: listedInboxItems(entry.items ?? []) })), ...(prof.enabled ? { profile: { hostId: "local" as const } } : {}), }; const inboxMetadata: { notifications?: WidgetInboxSummary } = { notifications }; diff --git a/src/hub/lib/widget/types.ts b/src/hub/lib/widget/types.ts index 7f19052541..5b8716d922 100644 --- a/src/hub/lib/widget/types.ts +++ b/src/hub/lib/widget/types.ts @@ -67,6 +67,10 @@ export const widgetPreferencesSchema = z.object({ alertSound: z.string().trim().min(1).max(40).default("Glass"), /** Show the widget's own popup with an alert. */ alertPopup: z.boolean().default(true), + /** The agents hover list: only sessions that need you, or every active session too. */ + previewFilter: z.enum(["needs-you", "active"]).default("needs-you"), + /** The agents hover list's order: waiting for you first, newest activity first, or by project. */ + previewSort: z.enum(["attention", "recent", "project"]).default("attention"), }); // Zod defaults also run inside partial objects; a patch must retain only supplied keys. diff --git a/src/macos/GenesisKit/Sources/GenesisKit/Cmux/CmuxCommandRunner.swift b/src/macos/GenesisKit/Sources/GenesisKit/Cmux/CmuxCommandRunner.swift new file mode 100644 index 0000000000..3f453ab541 --- /dev/null +++ b/src/macos/GenesisKit/Sources/GenesisKit/Cmux/CmuxCommandRunner.swift @@ -0,0 +1,120 @@ +import Foundation + +/// The cmux CLI for every part that opens a session or a command in a pane: the hub's `CmuxHost` and the widget's cmux +/// button. One binary lookup, the new workspace / pane / tab / type-into-a-tab moves, and the reading of +/// `tools cmux focus --json`. Blocking (each call spawns a process): call it off the main thread. +public enum CmuxCommandRunner { + public static var binary: String? { + ["/Applications/cmux.app/Contents/Resources/bin/cmux", "~/.local/bin/cmux", "/usr/local/bin/cmux", "/opt/homebrew/bin/cmux"] + .map { ($0 as NSString).expandingTildeInPath } + .first { FileManager.default.isExecutableFile(atPath: $0) } + } + + @discardableResult + public static func cmux(_ args: [String]) -> (ok: Bool, out: String) { + guard let binary else { return (false, "cmux is not installed") } + let process = Process() + process.executableURL = URL(fileURLWithPath: binary) + process.arguments = args + var environment = ProcessInfo.processInfo.environment + environment["CMUX_QUIET"] = "1" + process.environment = environment + guard let result = try? process.runCapturing() else { return (false, "cmux failed to start") } + let text = String(decoding: result.stdout, as: UTF8.self) + return (result.status == 0, result.status == 0 ? text : String(decoding: result.stderr, as: UTF8.self) + text) + } + + /// Runs `command` in `cwd` at `target`. An error message, or nil when it opened. Words are shell-quoted here because + /// cmux takes one command string. + public static func run(_ command: [String], cwd: String, name: String, at target: CmuxTarget) -> String? { + let words = command.map(ShellWords.word).joined(separator: " ") + let line = cwd.isEmpty ? words : "cd \(ShellWords.word(cwd)) && \(words)" + switch target { + case .newWorkspace(let window): + let result = cmux(["new-workspace", "--name", name, "--cwd", cwd, "--command", words, "--focus", "true"] + + (window.map { ["--window", $0] } ?? [])) + return result.ok ? nil : result.out.trimmed + case .newPane(let workspace): + return typeInto(cmux(["new-pane", "--type", "terminal", "--direction", "right", "--workspace", workspace, + "--focus", "true"]), workspace: workspace, line: line) + case .newTab(let workspace, let pane): + return typeInto(cmux(["new-surface", "--type", "terminal", "--workspace", workspace, "--pane", pane, + "--focus", "true"]), workspace: workspace, line: line) + case .surface(let workspace, let surface): + return send(line, workspace: workspace, surface: surface) + } + } + + /// new-surface / new-pane print the new surface ref; the command is typed into it, then Enter. + private static func typeInto(_ created: (ok: Bool, out: String), workspace: String, line: String) -> String? { + guard created.ok else { return created.out.trimmed } + guard let range = created.out.range(of: #"surface:\d+"#, options: .regularExpression) else { + return "cmux did not report the new surface: \(created.out.trimmed)" + } + return send(line, workspace: workspace, surface: String(created.out[range])) + } + + private static func send(_ line: String, workspace: String, surface: String) -> String? { + let sent = cmux(["send", "--workspace", workspace, "--surface", surface, line]) + guard sent.ok else { return sent.out.trimmed } + let enter = cmux(["send-key", "--workspace", workspace, "--surface", surface, "Enter"]) + return enter.ok ? nil : enter.out.trimmed + } + + /// The command that resumes an agent session in a terminal, or nil for an agent with none. + public static func resumeCommand(provider: String, sessionId: String) -> [String]? { + switch provider { + case "claude": return ["tools", "claude", "resume", sessionId] + case "codex": return ["codex", "resume", sessionId] + case "grok": return ["grok", "--resume", sessionId] + default: return nil + } + } + + /// The `tools ` that owns a session's cmux resolver: `tools codex cmux` and `tools grok cmux` know their own + /// history (Grok has no journal and its own fallback); anything else goes through Claude's. + public static func tool(provider: String?) -> String { + switch provider { + case "codex", "grok": return provider ?? "claude" + default: return "claude" + } + } + + public static let paneGone = "That cmux pane is gone. Choose a pane to resume the session in." + + /// What `tools cmux focus --json` said, in words: nil when it focused. A recorded tab that was closed + /// answers `gone`; before, the hub and Genesis showed cmux's raw "[socket] RPC error". + public static func focusFailure(status: Int32, stdout: Data, stderr: Data) -> String? { + guard status != 0 else { return nil } + struct Opaque: Decodable { + init(from decoder: Decoder) throws {} + } + struct Result: Decodable { + let gone: Bool? + let matches: [Opaque]? + } + if let result = try? JSONDecoder().decode(Result.self, from: stdout) { + if result.gone == true { + return paneGone + } + if result.matches?.isEmpty == true { + return "No cmux pane shows this session. Choose a pane to resume it in." + } + } + let lines = String(decoding: stderr, as: UTF8.self).split(whereSeparator: \.isNewline) + let said = lines.map { $0.trimmingCharacters(in: CharacterSet(charactersIn: "│■▲● ").union(.whitespaces)) } + .last { !$0.isEmpty } + return said.map { String($0) } ?? "tools claude cmux focus exited \(status)" + } + + /// `open-session --json` reports a failure as {ok: false, error} on stdout, with stderr empty. + public static func openSessionFailure(status: Int32, stdout: Data, stderr: Data) -> String? { + guard status != 0 else { return nil } + struct Failure: Decodable { let error: String } + if let failure = try? JSONDecoder().decode(Failure.self, from: stdout) { + return failure.error + } + let text = String(decoding: stderr, as: UTF8.self).trimmed + return text.isEmpty ? "tools claude cmux open-session exited \(status)" : String(text.suffix(300)) + } +} diff --git a/src/macos/GenesisKit/Sources/GenesisKit/Controls/AgentRosterRow.swift b/src/macos/GenesisKit/Sources/GenesisKit/Controls/AgentRosterRow.swift index 99831d3cba..c7d5783178 100644 --- a/src/macos/GenesisKit/Sources/GenesisKit/Controls/AgentRosterRow.swift +++ b/src/macos/GenesisKit/Sources/GenesisKit/Controls/AgentRosterRow.swift @@ -106,9 +106,12 @@ public struct AgentRosterGroupLabel: View { public let running: Int public var live: Bool public var lastAt: Date? + /// Inbox items waiting in the session, shown beside its name (the widget's session list). + public var unread: Int public init(title: String, provider: String, project: String? = nil, account: String? = nil, - total: Int, running: Int, live: Bool, lastAt: Date? = nil) { + total: Int, running: Int, live: Bool, lastAt: Date? = nil, unread: Int = 0) { + self.unread = unread self.title = title self.provider = provider self.project = project @@ -129,7 +132,13 @@ public struct AgentRosterGroupLabel: View { } }.padding(.top, 1) VStack(alignment: .leading, spacing: 3) { - Text(title).font(.system(size: 12.5, weight: .semibold)).lineLimit(2) + HStack(alignment: .firstTextBaseline, spacing: 6) { + Text(title).font(.system(size: 12.5, weight: .semibold)).lineLimit(2) + if unread > 0 { + Spacer(minLength: 0) + CountBadge(unread, tooltip: "\(Plural.count(unread, "inbox item"))", color: KitPalette.modified) + } + } HStack(spacing: 6) { if let project { Text(project).layoutPriority(-2) } if let account { diff --git a/src/macos/GenesisKit/Sources/GenesisKit/Diff/Views/GitCommitPicker.swift b/src/macos/GenesisKit/Sources/GenesisKit/Diff/Views/GitCommitPicker.swift index b641b0c0cb..1a1fde58af 100644 --- a/src/macos/GenesisKit/Sources/GenesisKit/Diff/Views/GitCommitPicker.swift +++ b/src/macos/GenesisKit/Sources/GenesisKit/Diff/Views/GitCommitPicker.swift @@ -10,6 +10,7 @@ // `git log --no-walk` for all of its commits. // +import AppKit import SwiftUI /// How much a commit changed: every file, and the chooser's own file when it names one. @@ -218,10 +219,19 @@ public extension GitCommitPicker where Label == GitPickerChip { public extension View { /// The commit list as a popover from this view. Its rows are built when it opens, never while it is closed. + /// With `onPickRange`, Shift with the arrows or a Shift-click picks several commits in a row: the callback gets + /// them in list order, every commit between the two ends included, even ones the filter hides. func gitCommitPopover(isPresented: Binding, choices: [GitCommitChoice], selection: String?, title: String? = nil, - details: GitCommitDetailsLoader? = nil, onPick: @escaping (GitCommitChoice) -> Void) -> some View { + details: GitCommitDetailsLoader? = nil, onPick: @escaping (GitCommitChoice) -> Void, + onPickRange: (([GitCommitChoice]) -> Void)? = nil) -> some View { popover(isPresented: isPresented, arrowEdge: .bottom) { - GitCommitPickerList(choices: choices, selection: selection, title: title, details: details) { choice in + GitCommitPickerList(choices: choices, selection: selection, title: title, details: details, + pickRange: onPickRange.map { pickRange in + { range in + isPresented.wrappedValue = false + pickRange(range) + } + }) { choice in isPresented.wrappedValue = false onPick(choice) } @@ -246,6 +256,8 @@ struct GitCommitPickerState { private(set) var rows: [GitCommitChoice] = [] /// The row the keyboard or the pointer is on: Return picks it. var highlighted: String? + /// Where a Shift-extended range started; nil while one commit is chosen. + var anchor: String? init(choices: [GitCommitChoice], selection: String?, query: String = "") { self.choices = choices @@ -254,8 +266,35 @@ struct GitCommitPickerState { highlighted = selection.flatMap { sha in rows.first { $0.sha == sha }?.id } ?? rows.first?.id } - /// Moves the highlight `step` rows, stopping at either end; from no highlight, down starts at the top. + /// Moves the highlight `step` rows, stopping at either end; from no highlight, down starts at the top. A plain move + /// ends a range. mutating func move(_ step: Int) { + anchor = nil + advance(step) + } + + /// Shift with an arrow: the range grows from where it started. + mutating func extend(_ step: Int) { + if anchor == nil { anchor = highlighted } + advance(step) + } + + /// Shift-click: the range runs from where it started (or the highlighted row) to `id`. + mutating func extend(to id: String) { + if anchor == nil { anchor = highlighted } + highlighted = id + } + + /// The commits of a range, in the list's order and with every commit between its ends (also the ones the filter + /// hides, so the diff never skips one); nil while one commit is chosen. + var range: [GitCommitChoice]? { + guard let anchor, let highlighted, anchor != highlighted, + let start = choices.firstIndex(where: { $0.id == anchor }), + let end = choices.firstIndex(where: { $0.id == highlighted }) else { return nil } + return Array(choices[min(start, end)...max(start, end)]) + } + + private mutating func advance(_ step: Int) { guard !rows.isEmpty else { return } let index = highlighted.flatMap { id in rows.firstIndex { $0.id == id } } ?? (step > 0 ? -1 : rows.count) highlighted = rows[min(max(index + step, 0), rows.count - 1)].id @@ -280,6 +319,8 @@ struct GitCommitPickerList: View { let selection: String? let title: String? let details: GitCommitDetailsLoader? + /// Several commits in a row; nil offers one commit only. + let pickRange: (([GitCommitChoice]) -> Void)? let pick: (GitCommitChoice) -> Void @State private var state: GitCommitPickerState @@ -296,10 +337,11 @@ struct GitCommitPickerList: View { static let page = 8 init(choices: [GitCommitChoice], selection: String?, title: String?, details: GitCommitDetailsLoader?, query: String = "", - pick: @escaping (GitCommitChoice) -> Void) { + pickRange: (([GitCommitChoice]) -> Void)? = nil, pick: @escaping (GitCommitChoice) -> Void) { self.selection = selection self.title = title self.details = details + self.pickRange = pickRange self.pick = pick _state = State(initialValue: GitCommitPickerState(choices: choices, selection: selection, query: query)) } @@ -344,11 +386,9 @@ struct GitCommitPickerList: View { .textFieldStyle(.plain) .font(.system(size: 12.5)) .focused($searchFocused) - .onSubmit { - if let choice = state.highlightedChoice { pick(choice) } - } - .onKeyPress(.upArrow) { move(-1) } - .onKeyPress(.downArrow) { move(1) } + .onSubmit { submit() } + .onKeyPress(.upArrow, phases: [.down, .repeat]) { press in move(-1, extending: press.modifiers.contains(.shift)) } + .onKeyPress(.downArrow, phases: [.down, .repeat]) { press in move(1, extending: press.modifiers.contains(.shift)) } .onKeyPress(.pageUp) { move(-Self.page) } .onKeyPress(.pageDown) { move(Self.page) } .accessibilityLabel("Filter commits") @@ -374,13 +414,17 @@ struct GitCommitPickerList: View { ScrollViewReader { proxy in FittedScroll(maxHeight: Self.maxListHeight) { LazyVStack(spacing: 1) { + let inRange = Set(state.range?.map(\.id) ?? []) ForEach(state.rows) { choice in - GitCommitPickerRow(choice: choice, current: choice.sha == selection, highlighted: choice.id == state.highlighted) { - pick(choice) + GitCommitPickerRow(choice: choice, current: choice.sha == selection, + highlighted: choice.id == state.highlighted || inRange.contains(choice.id)) { + click(choice) } .id(choice.id) .onHover { inside in - guard inside, state.highlighted != choice.id, Date().timeIntervalSince(keyMovedAt) > 0.4 else { return } + // A range keeps its ends while the pointer crosses other rows. + guard inside, state.anchor == nil, state.highlighted != choice.id, + Date().timeIntervalSince(keyMovedAt) > 0.4 else { return } state.highlighted = choice.id } } @@ -413,7 +457,7 @@ struct GitCommitPickerList: View { .accessibilityLabel("Reading the line counts") } Spacer(minLength: 8) - Text("↑↓ move · ↩ show · esc close") + Text(verbatim: keysHint) .accessibilityHidden(true) } .font(.system(size: 10.5)) @@ -422,8 +466,40 @@ struct GitCommitPickerList: View { .padding(.vertical, 6) } - private func move(_ step: Int) -> KeyPress.Result { - state.move(step) + /// The keys, and with a range how many commits Return shows. + private var keysHint: String { + if let range = state.range { + return "\(Plural.count(range.count, "commit")) chosen · ↩ show them · ↑↓ back to one" + } + return pickRange == nil ? "↑↓ move · ↩ show · esc close" + : "↑↓ move · ⇧↑↓ or ⇧-click more · ↩ show · esc close" + } + + private func submit() { + if pickRange != nil, let range = state.range { + pickRange?(range) + } else if let choice = state.highlightedChoice { + pick(choice) + } + } + + /// A click shows that one commit; with Shift (when ranges are offered) it extends the range instead. + private func click(_ choice: GitCommitChoice) { + if pickRange != nil, NSEvent.modifierFlags.contains(.shift) { + state.extend(to: choice.id) + keyMovedAt = Date() + return + } + + pick(choice) + } + + private func move(_ step: Int, extending: Bool = false) -> KeyPress.Result { + if extending, pickRange != nil { + state.extend(step) + } else { + state.move(step) + } scrollTarget = state.highlighted keyMovedAt = Date() return .handled diff --git a/src/macos/GenesisKit/Sources/GenesisKit/Settings/NativeSettingsModel.swift b/src/macos/GenesisKit/Sources/GenesisKit/Settings/NativeSettingsModel.swift index 065b17b104..b9d525958d 100644 --- a/src/macos/GenesisKit/Sources/GenesisKit/Settings/NativeSettingsModel.swift +++ b/src/macos/GenesisKit/Sources/GenesisKit/Settings/NativeSettingsModel.swift @@ -56,6 +56,56 @@ public enum NativeSettingsCatalogError: Error, Equatable, CustomStringConvertibl } } +/// A settings link can name a card on its page: `widgets.sessions#inbox-preview` selects the page and pulses the card +/// that carries `.nativeSettingsAnchor("inbox-preview")`. One settings window per process, so one shared center. +@MainActor +public final class NativeSettingsHighlightCenter: ObservableObject { + public static let shared = NativeSettingsHighlightCenter() + @Published public private(set) var anchor: String? + /// Bumped on every request, so the same card asked for twice pulses twice. + @Published public private(set) var token = 0 + + public func request(_ anchor: String) { + self.anchor = anchor + token += 1 + } + + /// "page#anchor" into its page id and the anchor; a plain page id has no anchor. + public nonisolated static func split(_ requested: String) -> (page: String, anchor: String?) { + guard let mark = requested.firstIndex(of: "#") else { return (requested, nil) } + let anchor = String(requested[requested.index(after: mark)...]) + return (String(requested[.. some View { + content + .attentionPulse(trigger: pulses, style: .flash(color: .accentColor, cornerRadius: 12)) + // A window that opens on the page asks before the card exists: the card pulses once it is on screen. + .onAppear { if center.anchor == id { pulseSoon() } } + .onChange(of: center.token) { _, _ in if center.anchor == id { pulseSoon() } } + } + + private func pulseSoon() { + Task { @MainActor in + try? await Task.sleep(for: .milliseconds(350)) + pulses += 1 + } + } +} + +extension View { + /// Names this card for a settings link (`#`): following the link pulses it. + public func nativeSettingsAnchor(_ id: String) -> some View { + modifier(NativeSettingsAnchor(id: id)) + } +} + @MainActor public final class NativeSettingsStore: ObservableObject { @Published public private(set) var sections: [NativeSettingsSection] @@ -78,25 +128,30 @@ public final class NativeSettingsStore: ObservableObject { catalogError = error let acceptedSections = error == nil ? sections.sorted { $0.order < $1.order } : [] self.sections = acceptedSections - let requested = initialPageID ?? defaults.string(forKey: selectionKey) + let link = initialPageID.map(NativeSettingsHighlightCenter.split) + let requested = link?.page ?? defaults.string(forKey: selectionKey) let pages = acceptedSections.flatMap(\.pages) selectedPageID = pages.first(where: { $0.id == requested })?.id ?? pages.first?.id pendingPageID = requested != selectedPageID ? requested : nil + if let anchor = link?.anchor { NativeSettingsHighlightCenter.shared.request(anchor) } } public var selectedPage: NativeSettingsPage? { sections.lazy.flatMap(\.pages).first { $0.id == selectedPageID } } + /// `pageID` may name a card too (`#`): the page opens and the card pulses. @discardableResult - public func select(pageID: String) -> Bool { + public func select(pageID requested: String) -> Bool { + let (pageID, anchor) = NativeSettingsHighlightCenter.split(requested) guard sections.contains(where: { $0.pages.contains(where: { $0.id == pageID }) }) else { - pendingPageID = pageID + pendingPageID = requested return false } pendingPageID = nil selectedPageID = pageID defaults.set(pageID, forKey: selectionKey) + if let anchor { NativeSettingsHighlightCenter.shared.request(anchor) } return true } @@ -115,7 +170,9 @@ public final class NativeSettingsStore: ObservableObject { catalogError = nil candidate.sort { $0.order < $1.order } sections = candidate - if let pendingPageID, candidate.contains(where: { $0.pages.contains(where: { $0.id == pendingPageID }) }) { + if let pendingPageID, candidate.contains(where: { + $0.pages.contains(where: { $0.id == NativeSettingsHighlightCenter.split(pendingPageID).page }) + }) { return select(pageID: pendingPageID) } if selectedPage == nil { selectedPageID = candidate.lazy.flatMap(\.pages).first?.id } diff --git a/src/macos/GenesisKit/Sources/GenesisKit/Widgets/AgentWidgetViews.swift b/src/macos/GenesisKit/Sources/GenesisKit/Widgets/AgentWidgetViews.swift index 84dfe48e62..1de5fc56d8 100644 --- a/src/macos/GenesisKit/Sources/GenesisKit/Widgets/AgentWidgetViews.swift +++ b/src/macos/GenesisKit/Sources/GenesisKit/Widgets/AgentWidgetViews.swift @@ -405,6 +405,8 @@ public struct AgentWidgetView: View { action: actions.submit) } .padding(11).background(.white.opacity(0.065), in: RoundedRectangle(cornerRadius: 12)) + .contentShape(RoundedRectangle(cornerRadius: 12)) + .onTapGesture { composerFocused = true } HStack(spacing: 6) { if sending { ProgressView().controlSize(.mini) diff --git a/src/macos/GenesisKit/Sources/GenesisKit/Widgets/LiveWidgetView.swift b/src/macos/GenesisKit/Sources/GenesisKit/Widgets/LiveWidgetView.swift index c42b03d0ba..a1ffbfbf85 100644 --- a/src/macos/GenesisKit/Sources/GenesisKit/Widgets/LiveWidgetView.swift +++ b/src/macos/GenesisKit/Sources/GenesisKit/Widgets/LiveWidgetView.swift @@ -25,6 +25,8 @@ public struct LiveWidgetView: View { } @State private var retry: WidgetOutgoing? + /// The session inbox's filter: every entry, the inbox entries (answers, results, questions) or the decisions. + @State private var inboxFilter = "all" @State private var choosingSession = false @State private var sourcesExpanded = false @Environment(\.accessibilityReduceMotion) private var systemReduceMotion @@ -147,14 +149,26 @@ public struct LiveWidgetView: View { .textSelection(.enabled) }.font(.caption).foregroundStyle(.orange) } - GenSegmentedTabs("View", items: sectionItems, selection: Binding(get: { section }, set: { section = $0 })) - .accessibilityIdentifier("widget.sections") + if !model.inboxOverview { + GenSegmentedTabs("View", items: sectionItems, selection: Binding(get: { section }, set: { section = $0 })) + .accessibilityIdentifier("widget.sections") + } if model.snapshot == nil || (model.selected == nil && model.snapshot?.rosterLoading == true) { VStack(spacing: 12) { ProgressView() Text("Connecting to your local agents…").font(.callout).foregroundStyle(.secondary) if model.error != nil || model.connectionLost { Button("Reconnect", action: model.start) } }.frame(maxWidth: .infinity, maxHeight: .infinity) + } else if model.inboxOverview { + ScrollView { + WidgetInboxOverview(model: model) + .frame(maxWidth: .infinity, alignment: .leading).padding(.vertical, 2) + .scrollOverflowContent() + .onGeometryChange(for: CGFloat.self, of: \.size.height) { fit.document = $0; reportFit() } + } + .onGeometryChange(for: CGFloat.self, of: \.size.height) { fit.viewport = $0; reportFit() } + .onDisappear { fit.viewport = nil; fit.document = nil; reportFit() } + .scrollOverflowHints() } else if model.selected == nil { if model.inbox.sessions.isEmpty { VStack(spacing: 10) { @@ -190,6 +204,9 @@ public struct LiveWidgetView: View { .onGeometryChange(for: CGFloat.self, of: \.size.height) { fit.document = $0; reportFit() } } .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) .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 else { return } ScrollViewPositioning.scroll(reader, to: "outgoing-" + id, anchor: .bottom) @@ -200,7 +217,9 @@ public struct LiveWidgetView: View { .scrollOverflowHints() } // While a decision card holds the composer for an own answer, the bottom one would edit the same draft. - if section != "Sessions" && !(section == "Inbox" && model.ownAnswerCard != nil) { composer } + if !model.inboxOverview && section != "Sessions" && !(section == "Inbox" && model.ownAnswerCard != nil) { + composer + } } } .mediaPreviewHost() @@ -232,7 +251,7 @@ public struct LiveWidgetView: View { fit.scheduled = false guard let outer = fit.outer else { return } // The same condition that chooses the list over the connecting / nothing-selected placeholders. - if model.snapshot != nil, model.selected != nil { + if model.snapshot != nil, model.selected != nil || model.inboxOverview { // The list is shown but not measured yet: wait instead of reporting the placeholder for a moment. guard let viewport = fit.viewport, let document = fit.document else { return } model.reportAgentFit(outer - viewport + document) @@ -248,12 +267,11 @@ public struct LiveWidgetView: View { choosingSession = true } label: { VStack(alignment: .leading, spacing: 3) { - Text(model.selected?.title ?? "Agent inbox").font(.system(size: 13, weight: .semibold)) - .lineLimit(1) - Text( - [model.selected?.target.provider.capitalized, model.selected?.project].compactMap { $0 } - .joined(separator: " · ") - ) + Text(model.inboxOverview ? "Inbox" : model.selected?.title ?? "Agent inbox") + .font(.system(size: 13, weight: .semibold)).lineLimit(1) + Text(model.inboxOverview ? "Every session" + : [model.selected?.target.provider.capitalized, model.selected?.project].compactMap { $0 } + .joined(separator: " · ")) .font(.system(size: 10)).foregroundStyle(.secondary).lineLimit(1) } .padding(.horizontal, 6).padding(.vertical, 3) @@ -273,12 +291,29 @@ public struct LiveWidgetView: View { .onChange(of: choosingSession) { _, value in model.sessionChooserOpen = value } .onDisappear { model.sessionChooserOpen = false } Spacer(minLength: 0) - IconButton(systemName: "pin.slash", tooltip: "Unpin this session from the widget", action: model.unpinSelected) - .accessibilityLabel("Unpin selected session") - IconButton(systemName: "rectangle.on.rectangle", tooltip: "Open this session in Hub") { - model.openHub?(model.selected) + if model.inboxOverview { + if model.selected != nil { + IconButton(systemName: "chevron.left", tooltip: "Back to " + (model.selected?.title ?? "the session")) { + model.inboxOverview = false + }.accessibilityLabel("Back to the session") + } + } else { + IconButton(systemName: "tray.full", tooltip: "Inbox entries from every session") { + model.inboxOverview = true + }.accessibilityLabel("Inbox from every session") + if let selected = model.selected { + WidgetCmuxButton(model: model, session: selected, size: 12) + } + } + // Both act on one session, which the overview of every session does not show. + if !model.inboxOverview { + IconButton(systemName: "pin.slash", tooltip: "Unpin this session from the widget", action: model.unpinSelected) + .accessibilityLabel("Unpin selected session") + IconButton(systemName: "rectangle.on.rectangle", tooltip: "Open this session in Hub") { + model.openHub?(model.selected) + } + .accessibilityLabel("Open session in Hub") } - .accessibilityLabel("Open session in Hub") IconButton(systemName: "slider.horizontal.3", tooltip: "Widget settings and filters") { model.showSettings?() } @@ -356,11 +391,20 @@ public struct LiveWidgetView: View { @ViewBuilder private var inbox: some View { if !model.cards.isEmpty { HStack(spacing: 8) { - Text(inboxHeadline).font(.system(size: 11)).foregroundStyle(.secondary) + GenSegmentedTabs( + "Inbox filter", + items: [.init("all", "All"), .init("inbox", "Inbox entries"), .init("decisions", "Decisions")], + selection: $inboxFilter, minSegmentWidth: 64, identifier: "widget.inbox.filter") Spacer() markAllReadButton } - ForEach(model.cards.reversed()) { card in + Text(inboxHeadline).font(.system(size: 11)).foregroundStyle(.secondary) + let shown = Self.filtered(model.cards, by: inboxFilter) + if shown.isEmpty { + Text(inboxFilter == "decisions" ? "No decisions in this session." : "No inbox entries in this session.") + .font(.callout).foregroundStyle(.secondary).padding(.vertical, 6) + } + ForEach(shown.reversed()) { card in inboxRow(card).id("card-" + card.id) } } else if model.inboxLoading { @@ -560,6 +604,15 @@ public struct LiveWidgetView: View { } } + /// "inbox": answers, results and questions; "decisions": decisions and tasks; anything else: every card. + static func filtered(_ cards: [WidgetCard], by filter: String) -> [WidgetCard] { + switch filter { + case "inbox": return cards.filter { !["decision", "todo"].contains($0.kind) } + case "decisions": return cards.filter { ["decision", "todo"].contains($0.kind) } + default: return cards + } + } + static func kindSymbol(_ kind: String) -> String { switch kind { case "answer": return "text.bubble" @@ -914,6 +967,10 @@ public struct LiveWidgetView: View { }.font(.system(size: 12)).foregroundStyle(.secondary).buttonStyle(.genHoverIcon()) } .padding(11).background(.white.opacity(0.065), in: RoundedRectangle(cornerRadius: 13)) + // The whole box is the field's target: a click beside the one-line field (or under it, in the padding) starts + // typing. The buttons inside keep their own clicks, because a child's gesture wins over this one. + .contentShape(RoundedRectangle(cornerRadius: 13)) + .onTapGesture { editing = true } } private func attachments(_ ids: [String], tile: CGSize, removable: Bool) -> some View { diff --git a/src/macos/GenesisKit/Sources/GenesisKit/Widgets/WidgetAgentPreview.swift b/src/macos/GenesisKit/Sources/GenesisKit/Widgets/WidgetAgentPreview.swift index fc245684e9..8a219b9752 100644 --- a/src/macos/GenesisKit/Sources/GenesisKit/Widgets/WidgetAgentPreview.swift +++ b/src/macos/GenesisKit/Sources/GenesisKit/Widgets/WidgetAgentPreview.swift @@ -63,7 +63,6 @@ struct WidgetSessionActivity: View { struct AgentWidgetPreview: View { @ObservedObject var model: WidgetModel let surface: WidgetSurfaceID - private var waiting: Int { model.waitingSessionCount } var body: some View { VStack(alignment: .leading, spacing: 8) { @@ -73,22 +72,22 @@ struct AgentWidgetPreview: View { Text("Inbox").font(.system(size: 12, weight: .semibold)) Spacer() if model.inbox.needsAnswer > 0 { - Button { model.openInboxNotification(on: surface, needsAnswer: true) } label: { + 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) Text("need you").font(.system(size: 10)) } - }.buttonStyle(.genHoverPlain()).instantTooltip("Open need you inbox items") + }.buttonStyle(.genHoverPlain()).instantTooltip("Every inbox entry that waits for your answer") } if model.inbox.unread > 0 { - Button { model.openInboxNotification(on: surface, needsAnswer: false) } label: { + Button { model.toggleInboxOverview(on: surface) } label: { HStack(spacing: 4) { WidgetInboxCount(count: model.inbox.unread, needsAnswer: false, pulse: model.inboxPulse, reduceMotion: model.effectiveReduceMotion, complete: model.inbox.complete) Text("unread").font(.system(size: 10)) } - }.buttonStyle(.genHoverPlain()).instantTooltip("Open unread inbox items") + }.buttonStyle(.genHoverPlain()).instantTooltip("Every unread inbox entry, from all sessions") } if model.inboxCount == 0 { Text(model.inbox.complete ? "All caught up" : "Inbox status unavailable") @@ -98,75 +97,58 @@ struct AgentWidgetPreview: View { size: 11, action: model.markAllRead) .accessibilityLabel("Mark all inbox items read") } - }.padding(.horizontal, 5).padding(.bottom, 5) + }.padding(.horizontal, 5) - if model.previewSessions.isEmpty { + HStack(spacing: 6) { + GenSegmentedTabs( + "Show", items: WidgetPreviewChoices.filters.map { .init($0.value, $0.title) }, + 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) { + 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)) + IconButton(systemName: "slider.horizontal.3", tooltip: "Choose what this list shows and its order", + size: 10.5) { + model.showSettingsPage?("widgets.sessions#inbox-preview") + }.accessibilityLabel("Inbox preview settings") + }.padding(.horizontal, 5).padding(.bottom, 3) + + if model.previewList.isEmpty { HStack(spacing: 12) { GenesisWidgetMark() VStack(alignment: .leading, spacing: 4) { - Text("Nothing needs you.").font(.system(size: 13, weight: .medium)) + Text(model.previewFilter == "active" ? "Nothing is running." : "Nothing needs you.") + .font(.system(size: 13, weight: .medium)) Text("New questions and finished work appear here.") .font(.system(size: 11)).foregroundStyle(.secondary) } - }.frame(maxWidth: .infinity, maxHeight: .infinity, alignment: .leading).padding(.horizontal, 5) + }.frame(maxWidth: .infinity, alignment: .leading).padding(.horizontal, 5).padding(.vertical, 6) } else { - ForEach(model.previewSessions) { session in - Button { - model.openInboxNotification(on: surface, key: session.key) - } label: { - HStack(spacing: 9) { - AIProviderGlyph(meta: AIProviders.meta(for: session.target.provider), size: 21) - VStack(alignment: .leading, spacing: 4) { - Text(session.title).font(.system(size: 11.5, weight: .medium)).lineLimit(1) - HStack(spacing: 5) { - Text( - session.project.isEmpty ? session.target.provider.capitalized : session.project - ) - .lineLimit(1) - Text("·") - Text(session.visualStatus.label).lineLimit(1) - }.font(.system(size: 9.5)).foregroundStyle(.white.opacity(0.45)) - } - Spacer(minLength: 2) - if let inbox = model.inboxFor(session.key), inbox.unread + inbox.needsAnswer > 0 { - WidgetInboxCount(count: inbox.unread + inbox.needsAnswer, needsAnswer: inbox.needsAnswer > 0, - pulse: model.inboxPulseFor(session.key), reduceMotion: model.effectiveReduceMotion, - complete: model.inbox.complete) - } else { - WidgetActivityIndicator(status: session.visualStatus, animate: !model.effectiveReduceMotion) - .frame(width: 12, height: 12) - } - } - .padding(.horizontal, 9).padding(.vertical, 9) - .frame(maxWidth: .infinity, alignment: .leading) - .background( - session.status == "waiting" ? Color.orange.opacity(0.065) : .white.opacity(0.025), - in: RoundedRectangle(cornerRadius: 10)) + FittedScroll(maxHeight: 360) { + LazyVStack(spacing: 6) { + ForEach(model.previewList) { session in row(session) } } - .buttonStyle(.genHoverRow()) - .accessibilityLabel("Open " + session.title + ", " + session.visualStatus.label) } } - Spacer(minLength: 0) Button { model.section = "Sessions" + model.inboxOverview = false model.openModule("agents", on: surface) } label: { HStack { Image(systemName: "rectangle.stack").font(.system(size: 10)) Text("Projects & sessions").font(.system(size: 10, weight: .medium)) Spacer() - if model.activeSessionCount > 0 { - Text("\(model.activeSessionCount) active").font(.system(size: 9.5)).monospacedDigit() - } + Text(Plural.count(model.previewList.count, "session")).font(.system(size: 9.5)).monospacedDigit() Image(systemName: "chevron.right").font(.system(size: 8, weight: .semibold)) } .foregroundStyle(.white.opacity(0.5)).padding(.horizontal, 5).padding(.vertical, 5) .contentShape(Rectangle()) }.buttonStyle(.genHoverPlain()) - .instantTooltip(model.activeSessionCount > 0 - ? "\(model.activeSessionCount) sessions are working or wait for you. Browse every project and session." - : "Browse every project and session") + .instantTooltip("Browse every project and session") .accessibilityLabel("Browse all projects and sessions") } .padding(.horizontal, 13).padding(.vertical, 14) @@ -176,4 +158,51 @@ struct AgentWidgetPreview: View { .onGeometryChange(for: CGFloat.self, of: \.size.height) { model.reportPreviewFit($0) } .frame(maxWidth: .infinity, maxHeight: .infinity, alignment: .top) } + + private var sortTooltip: String { + let sorts = WidgetPreviewChoices.sorts + let index = sorts.firstIndex { $0.value == model.previewSort } ?? 0 + return "Order: \(sorts[index].title). Click for \(sorts[(index + 1) % sorts.count].title)." + } + + private func row(_ session: WidgetSession) -> some View { + WidgetHoverRow { hovering in HStack(spacing: 4) { + Button { + model.openInboxNotification(on: surface, key: session.key) + } label: { + HStack(spacing: 9) { + AIProviderGlyph(meta: AIProviders.meta(for: session.target.provider), size: 21) + VStack(alignment: .leading, spacing: 4) { + Text(session.title).font(.system(size: 11.5, weight: .medium)).lineLimit(1) + HStack(spacing: 5) { + Text(session.project.isEmpty ? session.target.provider.capitalized : session.project) + .lineLimit(1) + Text("·") + Text(session.visualStatus.label).lineLimit(1) + }.font(.system(size: 9.5)).foregroundStyle(.white.opacity(0.45)) + } + Spacer(minLength: 2) + if let inbox = model.inboxFor(session.key), inbox.unread + inbox.needsAnswer > 0 { + WidgetInboxCount(count: inbox.unread + inbox.needsAnswer, needsAnswer: inbox.needsAnswer > 0, + pulse: model.inboxPulseFor(session.key), reduceMotion: model.effectiveReduceMotion, + complete: model.inbox.complete) + } else { + WidgetActivityIndicator(status: session.visualStatus, animate: !model.effectiveReduceMotion) + .frame(width: 12, height: 12) + } + } + .padding(.horizontal, 9).padding(.vertical, 9) + .frame(maxWidth: .infinity, alignment: .leading) + .contentShape(Rectangle()) + } + .buttonStyle(RowButtonStyle(cornerRadius: 10)) + .accessibilityLabel("Open " + session.title + ", " + session.visualStatus.label) + WidgetCmuxButton(model: model, session: session, live: hovering) + } } + .padding(.trailing, 4) + .background( + model.inboxFor(session.key).map { $0.needsAnswer > 0 } == true || session.status == "waiting" + ? Color.orange.opacity(0.065) : .white.opacity(0.025), + in: RoundedRectangle(cornerRadius: 10)) + } } diff --git a/src/macos/GenesisKit/Sources/GenesisKit/Widgets/WidgetCmuxButton.swift b/src/macos/GenesisKit/Sources/GenesisKit/Widgets/WidgetCmuxButton.swift new file mode 100644 index 0000000000..2828eb0995 --- /dev/null +++ b/src/macos/GenesisKit/Sources/GenesisKit/Widgets/WidgetCmuxButton.swift @@ -0,0 +1,138 @@ +import SwiftUI + +/// The cmux button every widget session carries: a popover with the shared `CmuxSessionPanel` (Focus, open in a pane, +/// choose a pane), the same block the hub's session sidebar shows. The widget drives it through its own `tools` +/// bridge; the pane moves go through GenesisKit's `CmuxCommandRunner`, which the hub's `CmuxHost` uses too. +struct WidgetCmuxButton: View { + @ObservedObject var model: WidgetModel + let session: WidgetSession + var size: CGFloat = 11 + /// In a list, false on every row but the hovered or selected one: the row then carries no button, tooltip or + /// 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 + @State private var open = false + + var body: some View { + Group { + if live || open { + IconButton(systemName: "terminal", tooltip: "cmux: focus this session, or resume it in a pane", size: size) { + open = true + } + .accessibilityLabel("cmux for " + session.title) + .accessibilityIdentifier("widget.cmux." + session.key) + } else { + Color.clear.frame(width: size + 13, height: size + 13).accessibilityHidden(true) + } + } + .popover(isPresented: $open, arrowEdge: .trailing) { + WidgetCmuxPanel(model: model, session: session).padding(14).frame(width: 380) + } + // The popover takes the pointer out of the edge panel; while it is open the panel must not collapse. Its own + // 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 list row that knows whether the pointer is on it, for `WidgetCmuxButton(live:)` and its other row-only controls. +struct WidgetHoverRow: View { + @ViewBuilder let content: (Bool) -> Content + @State private var hovering = false + + var body: some View { + content(hovering).onHover { hovering = $0 } + } +} + +struct WidgetCmuxPanel: View { + @ObservedObject var model: WidgetModel + let session: WidgetSession + @State private var tree: CmuxTree? + @State private var loading = false + @State private var busy = false + @State private var choosing = false + @State private var notice: CmuxSessionPanel.Notice? + + var body: some View { + VStack(alignment: .leading, spacing: 10) { + VStack(alignment: .leading, spacing: 2) { + Text(session.title).font(.system(size: 12.5, weight: .semibold)).lineLimit(2) + Text([AIProviders.meta(for: session.target.provider).displayName, session.project, + String(session.target.sessionId.prefix(8))].filter { !$0.isEmpty }.joined(separator: " · ")) + .font(.system(size: 10.5)).foregroundStyle(.secondary).lineLimit(1).textSelection(.enabled) + } + CmuxSessionPanel( + sessionId: session.target.sessionId, + window: nil, workspace: nil, pane: nil, surface: nil, + tree: tree, loading: loading, busy: busy, lastPane: nil, + choosing: $choosing, notice: $notice, + modeKey: "widget.cmux.pickerMode", + focus: { Task { await perform(focus) } }, + open: { target in Task { await perform { await open(at: target) } } }, + reload: load) + } + .task { load() } + } + + private func load() { + guard !loading else { return } + loading = true + Task { + // Not the exit status: with cmux unreachable the command prints {available: false, error} and exits 1. + let result = try? await model.bridge.run(subcommand: "ai", args: ["cmux", "tree", "--json"], timeoutSeconds: 20) + tree = result.flatMap { try? CmuxTree.decode(Data($0.stdout.utf8)) } + loading = false + } + } + + private func focus() async -> String? { + let tool = CmuxCommandRunner.tool(provider: session.target.provider) + do { + let result = try await model.bridge.run( + subcommand: tool, args: ["cmux", "focus", session.target.sessionId, "--first", "--json"], timeoutSeconds: 30) + return CmuxCommandRunner.focusFailure( + status: result.exitCode, stdout: Data(result.stdout.utf8), stderr: Data(result.stderr.utf8)) + } catch { + return error.localizedDescription + } + } + + /// Claude resumes through `tools claude cmux open-session`; another agent's resume command is typed into the pane. + private func open(at target: CmuxTarget) async -> String? { + let sessionId = session.target.sessionId + if session.target.provider == "claude", + let args = target.openSessionArgs ?? tree?.windows.first.map({ ["--window", $0.target] }) { + do { + let result = try await model.bridge.run( + subcommand: "claude", args: ["cmux", "open-session", sessionId] + args + ["--json"], timeoutSeconds: 60) + return CmuxCommandRunner.openSessionFailure( + status: result.exitCode, stdout: Data(result.stdout.utf8), stderr: Data(result.stderr.utf8)) + } catch { + return error.localizedDescription + } + } + guard let command = CmuxCommandRunner.resumeCommand(provider: session.target.provider, sessionId: sessionId) else { + return "No resume command for \(session.target.provider)" + } + let cwd = session.target.cwd + let name = session.title + return await Task.detached(priority: .userInitiated) { + CmuxCommandRunner.run(command, cwd: cwd, name: name, at: target) + }.value + } + + private func perform(_ action: @escaping () async -> String?) async { + busy = true + let error = await action() + busy = false + if error == CmuxCommandRunner.paneGone { + // The tab it ran in is closed: say so in words and show where it can go instead. + notice = CmuxSessionPanel.Notice(text: error ?? "", isError: true) + choosing = true + } else { + notice = CmuxSessionPanel.Notice(text: error.map { "cmux: \($0)" } ?? "Done", isError: error != nil) + } + load() + } +} diff --git a/src/macos/GenesisKit/Sources/GenesisKit/Widgets/WidgetCoordinator.swift b/src/macos/GenesisKit/Sources/GenesisKit/Widgets/WidgetCoordinator.swift index da49b5fb3f..31bd235a76 100644 --- a/src/macos/GenesisKit/Sources/GenesisKit/Widgets/WidgetCoordinator.swift +++ b/src/macos/GenesisKit/Sources/GenesisKit/Widgets/WidgetCoordinator.swift @@ -166,6 +166,7 @@ public final class WidgetCoordinator: NSObject, NSWindowDelegate { motion.pointerInside = inside } model.showSettings = { [weak self] in self?.showSettings() } + model.showSettingsPage = { [weak self] in self?.showSettings(pageID: $0) } model.showMedia = { [weak self] selection in self?.showMedia(selection) } model.inboxArrived = { [weak self] items in self?.alert(items) } } diff --git a/src/macos/GenesisKit/Sources/GenesisKit/Widgets/WidgetFeatureSettings.swift b/src/macos/GenesisKit/Sources/GenesisKit/Widgets/WidgetFeatureSettings.swift index ff311cf3dc..7e7eb00f7f 100644 --- a/src/macos/GenesisKit/Sources/GenesisKit/Widgets/WidgetFeatureSettings.swift +++ b/src/macos/GenesisKit/Sources/GenesisKit/Widgets/WidgetFeatureSettings.swift @@ -303,6 +303,7 @@ private struct WidgetSessionSettings: View { var body: some View { VStack(alignment: .leading, spacing: 18) { + previewCard NativeSettingsCard("Your sessions", subtitle: "Pin a session to keep it in the widget's rail. Click one to open it in the widget.") { WidgetSessionBrowser(model: model, onOpen: openSession) } @@ -311,6 +312,22 @@ private struct WidgetSessionSettings: View { }.task { model.refreshSettings() } } + /// The agents hover list. Its settings button opens this page at `#inbox-preview`, and the card pulses. + private var previewCard: some View { + NativeSettingsCard("Inbox preview", subtitle: "The list that opens when the pointer rests on the agents button.") { + NativeSettingsSegmented( + "Show", identifier: "widget.preview.filter", + options: WidgetPreviewChoices.filters, + selection: Binding(get: { model.previewFilter }, set: { model.setPreview(filter: $0) }), + minSegmentWidth: 96) + NativeSettingsPicker( + "Order", identifier: "widget.preview.sort", + options: WidgetPreviewChoices.sorts, + selection: Binding(get: { model.previewSort }, set: { model.setPreview(sort: $0) })) + } + .nativeSettingsAnchor("inbox-preview") + } + private var filterCard: some View { let selected = prefs?.sessions ?? [] let projects = prefs?.projects ?? [] @@ -721,3 +738,18 @@ private struct WidgetSettingsError: View { } } } + +/// The hover list's filter and order choices, shared by the settings card and the list's own controls. +enum WidgetPreviewChoices { + static let filters: [NativeSettingsOption] = [ + .init("needs-you", "Needs you", detail: "Sessions with a question or decision waiting for your answer."), + .init("active", "Active too", detail: "Also the sessions that work now or hold unread answers."), + ] + static let sorts: [NativeSettingsOption] = [ + .init("attention", "Waiting first", detail: "Sessions that wait for you first, then working ones, newest first."), + .init("recent", "Newest activity", detail: "The session that did something last comes first."), + .init("project", "By project", detail: "Grouped by project name, newest first inside a project."), + ] + + static func title(sort: String) -> String { sorts.first { $0.value == sort }?.title ?? sort } +} diff --git a/src/macos/GenesisKit/Sources/GenesisKit/Widgets/WidgetHostView.swift b/src/macos/GenesisKit/Sources/GenesisKit/Widgets/WidgetHostView.swift index 04a118c664..d25584c6f9 100644 --- a/src/macos/GenesisKit/Sources/GenesisKit/Widgets/WidgetHostView.swift +++ b/src/macos/GenesisKit/Sources/GenesisKit/Widgets/WidgetHostView.swift @@ -132,7 +132,7 @@ struct WidgetHostView: View { } }) { Button { - if selected?.id == "agents", model.inboxCount > 0 { model.openInboxNotification(on: surface) } + if selected?.id == "agents" { model.toggleInboxOverview(on: surface) } else { expand() } } label: { HStack(spacing: 7) { @@ -162,7 +162,7 @@ struct WidgetHostView: View { HStack(spacing: 5) { ForEach(Array(model.railActivitySessions.prefix(3))) { session in Button { - model.openInboxNotification(on: surface, key: session.key) + model.toggleSession(session.key, on: surface) } label: { sessionActivity(session) .frame(minWidth: 24, minHeight: 30).fixedSize() @@ -270,7 +270,7 @@ struct WidgetHostView: View { VStack(spacing: WidgetSideStripMetrics.sessionSpacing) { ForEach(Array(model.railActivitySessions.prefix(sideMetrics.sessionCount))) { session in Button { - model.openInboxNotification(on: surface, key: session.key) + model.toggleSession(session.key, on: surface) } label: { sessionActivity(session) .frame(width: WidgetSideStripMetrics.sessionWidth, height: WidgetSideStripMetrics.sessionHeight) @@ -368,7 +368,9 @@ struct WidgetHostView: View { } } .frame(width: selected.previewSize.width) - .frame(maxHeight: .infinity) + // Pinned to the top: while the window still has the last measured height, an overflow hides the footer, never + // the header (the hover list opened on its middle rows, 2026-10-23). + .frame(maxHeight: .infinity, alignment: .top) } /// Inbox counts describe only the Agents module; every other module button names its module. @@ -378,7 +380,7 @@ struct WidgetHostView: View { private func moduleButton(_ id: String, size: CGFloat, location: String = "content") -> some View { Button { - if id == "agents", model.inboxCount > 0 { model.openInboxNotification(on: surface) } + if id == "agents" { model.toggleInboxOverview(on: surface) } else { model.openModule(id, on: surface) } } label: { let layout = surface.edge == .top ? AnyLayout(HStackLayout(spacing: 3)) : AnyLayout(VStackLayout(spacing: 4)) @@ -422,7 +424,7 @@ struct WidgetHostView: View { if inbox.needsAnswer > 0 { lines.append("\(inbox.needsAnswer) waiting for your answer") } if inbox.unread > 0 { lines.append("\(inbox.unread) unread") } } - lines.append("Click to open its inbox") + lines.append("Click to open its inbox, again to hide it") return TooltipContent(title: session.title, bullets: lines) } diff --git a/src/macos/GenesisKit/Sources/GenesisKit/Widgets/WidgetInboxOverview.swift b/src/macos/GenesisKit/Sources/GenesisKit/Widgets/WidgetInboxOverview.swift new file mode 100644 index 0000000000..8e30b237d7 --- /dev/null +++ b/src/macos/GenesisKit/Sources/GenesisKit/Widgets/WidgetInboxOverview.swift @@ -0,0 +1,162 @@ +import SwiftUI + +/// The inbox button's view: inbox entries 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 + @State private var filter = "all" + @State private var openID: String? + @GenReduceMotion private var reduceMotion + + private var items: [WidgetInboxItem] { + let all = model.inboxOverviewItems + switch filter { + case "needs": return all.filter(\.needsAnswer) + case "unread": return all.filter { !$0.needsAnswer } + default: return all + } + } + + var body: some View { + let items = items + VStack(alignment: .leading, spacing: 10) { + HStack(spacing: 8) { + GenSegmentedTabs( + "Inbox entries", + items: [.init("all", "All"), .init("needs", "Needs you"), .init("unread", "Unread")], + selection: $filter, minSegmentWidth: 70, identifier: "widget.overview.filter") + Spacer(minLength: 4) + if model.inboxCount > 0 { + Button("Mark all read", action: model.markAllRead) + .buttonStyle(.genHover()).font(.system(size: 11, weight: .medium)) + .instantTooltip("Answers become read; older questions and decisions stop counting. Nothing is deleted.") + } + } + if items.isEmpty { + VStack(spacing: 10) { + GenesisWidgetMark() + Text(filter == "needs" ? "Nothing waits for your answer." : "Nothing new in the inbox.") + .font(.headline) + Text("Questions, screenshots and finished work from every session land here.") + .font(.caption).foregroundStyle(.secondary) + }.frame(maxWidth: .infinity).padding(.vertical, 28) + } else { + Text(Self.summary(items)).font(.system(size: 11)).foregroundStyle(.secondary) + // Hundreds of entries: lazy, so only the rows on screen exist (swift skill, dense lists). + LazyVStack(alignment: .leading, spacing: 10) { + ForEach(items, id: \.id) { item in row(item) } + } + if model.inbox.truncated { + Text("The newest entries are shown. Open a session for its whole inbox.") + .font(.caption2).foregroundStyle(.tertiary) + } + } + } + } + + 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, + "from " + Plural.count(sessions, "session")].compactMap { $0 }.joined(separator: " · ") + } + + private func toggle(_ item: WidgetInboxItem) { + let opening = openID != item.id + GenMotion.perform(expanding: opening, reduceMotion: reduceMotion) { openID = opening ? item.id : nil } + } + + private func row(_ item: WidgetInboxItem) -> some View { + let open = openID == item.id + let session = model.session(forKey: item.key) + return VStack(alignment: .leading, spacing: 0) { + Button { toggle(item) } label: { + HStack(alignment: .top, spacing: 9) { + Circle().fill(item.needsAnswer ? Color.orange : SessionPalette.blue) + .frame(width: 7, height: 7).padding(.top, 5) + Image(systemName: LiveWidgetView.kindSymbol(item.kind)).font(.system(size: 11)) + .foregroundStyle(.secondary).frame(width: 14).padding(.top, 1) + VStack(alignment: .leading, spacing: 3) { + Text(Self.title(item)).font(.system(size: 12.5, weight: open ? .semibold : .medium)) + .lineLimit(open ? 6 : 2).fixedSize(horizontal: false, vertical: true) + HStack(spacing: 4) { + Text(Self.kindLabel(item)) + Text("·") + Text(session?.title ?? "Unknown session").lineLimit(1) + Text("·") + LiveAgo(date: Date(timeIntervalSince1970: item.at / 1000), style: .brief) + }.font(.system(size: 10)).foregroundStyle(item.needsAnswer ? Color.orange : .secondary) + } + Spacer(minLength: 4) + 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") + } + Image(systemName: "chevron.right").font(.system(size: 9, weight: .semibold)) + .foregroundStyle(.secondary).rotationEffect(.degrees(open ? 90 : 0)).padding(.top, 4) + } + .padding(.horizontal, 10).padding(.vertical, 9) + .frame(maxWidth: .infinity, alignment: .leading) + .contentShape(Rectangle()) + } + .buttonStyle(RowButtonStyle(cornerRadius: 11)) + .accessibilityLabel(Self.kindLabel(item) + ": " + Self.title(item)) + .accessibilityValue(open ? "Expanded" : "Collapsed") + .accessibilityIdentifier("widget.overview.row." + item.id) + if open { + detail(item, session: session).padding(.horizontal, 12).padding(.top, 2).padding(.bottom, 12) + .transition(.genReveal) + } + } + .clipShape(RoundedRectangle(cornerRadius: 11)) + .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)) + } + + private func detail(_ item: WidgetInboxItem, session: WidgetSession?) -> some View { + VStack(alignment: .leading, spacing: 10) { + if let session { + HStack(spacing: 8) { + AIProviderGlyph(meta: AIProviders.meta(for: session.target.provider), size: 18) + VStack(alignment: .leading, spacing: 2) { + Text(session.title).font(.system(size: 11.5, weight: .medium)).lineLimit(1) + Text([session.project, session.visualStatus.label, String(session.target.sessionId.prefix(8))] + .filter { !$0.isEmpty }.joined(separator: " · ")) + .font(.system(size: 10)).foregroundStyle(.secondary).lineLimit(1) + } + } + } + HStack(spacing: 8) { + Button(item.needsAnswer ? "Answer in its session" : "Open in its session") { model.openInboxItem(item) } + .buttonStyle(.genHover(accent: item.needsAnswer ? .orange : KitTheme.accent)) + .accessibilityIdentifier("widget.overview.open." + item.id) + if let session { + Button("Whole inbox") { + model.select(session.key) + model.section = "Inbox" + }.buttonStyle(.genHover()) + Spacer(minLength: 0) + WidgetCmuxButton(model: model, session: session, size: 12) + } + }.font(.caption) + } + } + + static func title(_ item: WidgetInboxItem) -> String { + if let title = item.title, !title.isEmpty { + return item.number.map { "DECISION \($0): " + title } ?? title + } + return kindLabel(item) + } + + static func kindLabel(_ item: WidgetInboxItem) -> String { + if item.needsAnswer { return item.kind == "decision" ? "Decision · needs your answer" : "Needs your answer" } + switch item.kind { + case "answer": return "New answer" + case "result": return "Agent result" + default: return item.kind.capitalized + } + } +} diff --git a/src/macos/GenesisKit/Sources/GenesisKit/Widgets/WidgetModel.swift b/src/macos/GenesisKit/Sources/GenesisKit/Widgets/WidgetModel.swift index 5613ee104b..fa448a2499 100644 --- a/src/macos/GenesisKit/Sources/GenesisKit/Widgets/WidgetModel.swift +++ b/src/macos/GenesisKit/Sources/GenesisKit/Widgets/WidgetModel.swift @@ -30,6 +30,9 @@ public final class WidgetModel: ObservableObject { /// Rail sessions that are working or hold unread items. Stored, because every layout pass of every edge panel reads /// it: filtering ~1000 sessions there cost a quarter of the main thread while panels animated. public private(set) var railActivitySessions: [WidgetSession] = [] + /// The agents hover list (`previewRows`): every session that needs you, with the "active" filter also the + /// sessions that work now. Stored with the inbox, because every hover layout reads it. + public private(set) var previewList: [WidgetSession] = [] @Published public private(set) var inbox = WidgetInboxSummary.empty @Published public private(set) var inboxPulse = 0 private var inboxSessions: [String: WidgetInboxSession] = [:] @@ -40,6 +43,9 @@ public final class WidgetModel: ObservableObject { private var openingInbox: WidgetInboxItem? private var inboxReadRequests: Set = [] @Published public var selectedKey = "" + /// The expanded agents pane shows the inbox entries of every session instead of one session (the inbox button). + /// Opening a session ends it. + @Published public var inboxOverview = false { didSet { if inboxOverview != oldValue { presentationChanged?() } } } @Published public var selectedCardID: String? { didSet { presentationChanged?() } } @Published public var section = "Inbox" { didSet { @@ -109,9 +115,11 @@ public final class WidgetModel: ObservableObject { @Published private var hostDialogOpen = false /// Set by the session chooser alone, so closing it (or its view going away) cannot release a window still open. @Published public var sessionChooserOpen = false + /// Set by a session's cmux popover alone, for the same reason. + @Published public var cmuxPopoverOpen = false /// 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 } + get { hostDialogOpen || sessionChooserOpen || cmuxPopoverOpen } set { hostDialogOpen = newValue } } private var mediaPicker: NSOpenPanel? @@ -119,6 +127,8 @@ public final class WidgetModel: ObservableObject { /// The pointer entered or left an edge panel, before any hover delay. public var pointerChanged: ((WidgetSurfaceID, Bool) -> Void)? public var showSettings: (() -> Void)? + /// Opens Settings at a page, or at a card on it (`widgets.sessions#inbox-preview`), which then pulses. + public var showSettingsPage: ((String) -> Void)? public var showMedia: ((WidgetMediaSelection) -> Void)? public var openHub: ((WidgetSession?) -> Void)? public var recordVoiceNote: ((String) -> Void)? @@ -776,6 +786,53 @@ public final class WidgetModel: ObservableObject { let inbox = inboxSessions[session.key] return session.visualStatus == .working || (inbox?.unread ?? 0) + (inbox?.needsAnswer ?? 0) > 0 } + inboxOverviewItems = Self.overviewItems(value.sessions) + previewList = Self.previewRows( + sessions: snapshot?.sessions ?? [], inbox: inboxSessions, + filter: previewFilter, sort: previewSort) + } + + public var previewFilter: String { snapshot?.state.preferences.previewFilter ?? "needs-you" } + public var previewSort: String { snapshot?.state.preferences.previewSort ?? "attention" } + + /// The hover list. "Needs you": a session with an unanswered item, or a shown session that waits. With the + /// "active" filter, shown top-level sessions that work or hold unread items join it. Orders: "attention" (waiting + /// for you, then working, then newest), "recent" (newest activity) and "project" (by project, then newest). + nonisolated static func previewRows( + sessions: [WidgetSession], inbox: [String: WidgetInboxSession], filter: String, sort: String + ) -> [WidgetSession] { + func needs(_ session: WidgetSession) -> Bool { + (inbox[session.key]?.needsAnswer ?? 0) > 0 || (session.visible && session.status == "waiting") + } + let rows = sessions.filter { session in + if needs(session) { return true } + guard filter == "active", session.visible, session.parentKey == nil, session.agentId == nil else { return false } + return session.status == "working" || (inbox[session.key]?.unread ?? 0) > 0 + } + func rank(_ session: WidgetSession) -> Int { + needs(session) ? 0 : session.status == "working" ? 1 : 2 + } + return rows.sorted { lhs, rhs in + switch sort { + case "recent": + return lhs.activityAt > rhs.activityAt + case "project": + let order = lhs.project.localizedCaseInsensitiveCompare(rhs.project) + return order == .orderedSame ? lhs.activityAt > rhs.activityAt : order == .orderedAscending + default: + let (left, right) = (rank(lhs), rank(rhs)) + return left == right ? lhs.activityAt > rhs.activityAt : left < right + } + } + } + + /// Stores the hover list's filter or order. + public func setPreview(filter: String? = nil, sort: String? = nil) { + var patch: [String: WidgetJSON] = [:] + if let filter { patch["previewFilter"] = .string(filter) } + if let sort { patch["previewSort"] = .string(sort) } + guard !patch.isEmpty else { return } + updatePreferences(patch) } public func inboxFor(_ key: String) -> WidgetInboxSession? { inboxSessions[key] } @@ -847,6 +904,54 @@ public final class WidgetModel: ObservableObject { acknowledgeOpenedInbox() } + /// The overview's entries from every session: the waiting ones first, then newest first. A hub from before the + /// per-session list gives each session's pending and unread item. Stored when the inbox changes: up to 512 + /// 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] = [] + + nonisolated static func overviewItems(_ sessions: [WidgetInboxSession]) -> [WidgetInboxItem] { + sessions.flatMap { entry in entry.items ?? [entry.pendingItem, entry.unreadItem].compactMap { $0 } } + .sorted { lhs, rhs in + lhs.needsAnswer == rhs.needsAnswer ? (lhs.at == rhs.at ? lhs.id < rhs.id : lhs.at > rhs.at) : lhs.needsAnswer + } + } + + /// An overview entry's "Open in session": that session's inbox with the entry open. + public func openInboxItem(_ item: WidgetInboxItem) { + guard !stopping else { return } + openingInbox = item + select(item.key) + selectedCardID = item.id + foldedCardIDs.remove(item.id) + section = "Inbox" + acknowledgeOpenedInbox() + } + + /// The inbox button: every session's entries in one list. A second click while that list is open hides the panel. + public func toggleInboxOverview(on surface: WidgetSurfaceID) { + guard !stopping else { return } + if presentation(for: surface) == .expanded, activeModuleID == "agents", inboxOverview { + collapse() + return + } + + inboxOverview = true + section = "Inbox" + openModule("agents", on: surface) + } + + /// A session button on the rail or the top strip: opens that session's inbox, or hides the panel when the same + /// session is already open there (Martin, 2026-10-23: a second click did nothing). + public func toggleSession(_ key: String, on surface: WidgetSurfaceID) { + guard !stopping else { return } + if presentation(for: surface) == .expanded, activeModuleID == "agents", !inboxOverview, selectedKey == key { + collapse() + return + } + + openInboxNotification(on: surface, key: key) + } + private func acknowledgeOpenedInbox() { guard !stopping, let item = openingInbox, expanded != nil, activeModuleID == "agents", section == "Inbox", selectedKey == item.key, let displayed = cards.first(where: { $0.id == item.id }), displayed.at == item.at @@ -885,6 +990,7 @@ public final class WidgetModel: ObservableObject { public func select(_ key: String, edge: EdgePanelPlacement? = nil) { if openingInbox?.key != key { openingInbox = nil } + inboxOverview = false selectedKey = key lastSelected = snapshot?.sessions.first { $0.key == key } selectedCardID = nil diff --git a/src/macos/GenesisKit/Sources/GenesisKit/Widgets/WidgetSessionBrowser.swift b/src/macos/GenesisKit/Sources/GenesisKit/Widgets/WidgetSessionBrowser.swift index 17004a8dbe..878b8a2384 100644 --- a/src/macos/GenesisKit/Sources/GenesisKit/Widgets/WidgetSessionBrowser.swift +++ b/src/macos/GenesisKit/Sources/GenesisKit/Widgets/WidgetSessionBrowser.swift @@ -8,6 +8,7 @@ public struct WidgetSessionBrowser: View { @State private var limit = 40 @State private var expanded = Set() @State private var collapsed = Set() + @GenReduceMotion private var reduceMotion public init(model: WidgetModel, onOpen: ((WidgetSession) -> Void)? = nil) { self.model = model @@ -52,29 +53,15 @@ public struct WidgetSessionBrowser: View { .frame(maxWidth: .infinity) } else { ForEach(Array(groups.prefix(limit))) { group in - if group.children.isEmpty { - row(group.parent, title: group.parent.title, depth: 0) - } else { - GenDisclosure(isExpanded: Binding(get: { - if !query.isEmpty { return true } - if collapsed.contains(group.id) { return false } - return expanded.contains(group.id) || group.id == model.selectedKey - || group.children.contains { $0.id == model.selectedKey } - }, set: { value in - if value { expanded.insert(group.id); collapsed.remove(group.id) } - else { expanded.remove(group.id); collapsed.insert(group.id) } - }), minHeight: 34, identifier: "widget.sessions.group." + group.id) { + // Every top-level session is the same row, with or without sub-agents; a chevron folds them. + VStack(alignment: .leading, spacing: 2) { + topRow(group) + if !group.children.isEmpty && isOpen(group) { LazyVStack(spacing: 2) { - row(group.parent, title: "Main", depth: 0) - ForEach(group.children) { child in row(child.session, title: child.session.title, depth: child.depth) } - }.padding(.top, 6) - } label: { - AgentRosterGroupLabel(title: group.parent.title, provider: group.parent.target.provider, - project: group.parent.project, account: group.parent.account, - total: group.children.count, running: group.running, - live: group.parent.status == "working" || group.running > 0, - lastAt: Date(timeIntervalSince1970: group.parent.activityAt / 1000)) - .padding(.vertical, 4) + ForEach(group.children) { child in + row(child.session, title: child.session.title, depth: child.depth + 1) + } + }.transition(.genReveal) } } } @@ -111,12 +98,71 @@ public struct WidgetSessionBrowser: View { .fixedSize() } + private func isOpen(_ group: WidgetAgentTree.Group) -> Bool { + if !query.isEmpty { return true } + if collapsed.contains(group.id) { return false } + return expanded.contains(group.id) || group.children.contains { $0.id == model.selectedKey } + } + + private func toggle(_ group: WidgetAgentTree.Group) { + let opening = !isOpen(group) + GenMotion.perform(expanding: opening, reduceMotion: reduceMotion) { + if opening { expanded.insert(group.id); collapsed.remove(group.id) } + else { expanded.remove(group.id); collapsed.insert(group.id) } + } + } + + private func open(_ session: WidgetSession) { + if let onOpen { onOpen(session) } + else { model.select(session.key); model.section = "Inbox" } + } + + /// A top-level session: its name, project, account and sub-agent count, the same for every session. + private func topRow(_ group: WidgetAgentTree.Group) -> some View { + let session = group.parent + 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) + } 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()) + } + .buttonStyle(.genHoverPlain()) + .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) + } + Button { open(session) } label: { + VStack(alignment: .leading, spacing: 3) { + AgentRosterGroupLabel(title: session.title, provider: session.target.provider, + project: session.project, account: session.account, + total: group.children.count, running: group.running, + live: session.status == "working" || group.running > 0, + lastAt: Date(timeIntervalSince1970: session.activityAt / 1000), + unread: model.inboxFor(session.key).map { $0.unread + $0.needsAnswer } ?? 0) + if session.hiddenByFilter { + Text("Hidden by widget filters").font(.caption2).foregroundStyle(.orange).padding(.leading, 31) + } + } + .padding(.vertical, 7).padding(.horizontal, 4) + .frame(maxWidth: .infinity, alignment: .leading).contentShape(Rectangle()) + } + .buttonStyle(RowButtonStyle(cornerRadius: 8)) + .accessibilityLabel("Open session " + session.title + ", " + session.visualStatus.label) + trailing(session, hovering: hovering) + } } + .padding(.horizontal, 4) + .background(selected ? Color.blue.opacity(0.13) : .clear, in: RoundedRectangle(cornerRadius: 10)) + .overlay(RoundedRectangle(cornerRadius: 10).strokeBorder(selected ? Color.blue.opacity(0.35) : .clear)) + } + private func row(_ session: WidgetSession, title: String, depth: Int) -> some View { - HStack(alignment: .top, spacing: 5) { - Button { - if let onOpen { onOpen(session) } - else { model.select(session.key); model.section = "Inbox" } - } label: { + let selected = session.key == model.selectedKey + return WidgetHoverRow { hovering in HStack(alignment: .center, spacing: 2) { + Button { open(session) } label: { VStack(alignment: .leading, spacing: 3) { AgentRosterRow(title: title, provider: session.target.provider, role: session.role ?? "lead", model: session.model, account: session.account, @@ -125,28 +171,42 @@ public struct WidgetSessionBrowser: View { lastAt: Date(timeIntervalSince1970: session.activityAt / 1000), toolCalls: session.toolCalls ?? 0, unread: model.inboxFor(session.key).map { $0.unread + $0.needsAnswer } ?? 0, - selected: session.key == model.selectedKey, showsRunningLabel: session.agentId == nil) + selected: selected, showsRunningLabel: session.agentId == nil) if session.hiddenByFilter { Text("Hidden by widget filters").font(.caption2).foregroundStyle(.orange).padding(.leading, 15) } - }.frame(maxWidth: .infinity, alignment: .leading) - }.buttonStyle(.genHoverRow()) - .accessibilityLabel("Open session " + session.title + ", " + session.visualStatus.label) + } + .padding(.vertical, 6).padding(.horizontal, 6) + .frame(maxWidth: .infinity, alignment: .leading).contentShape(Rectangle()) + } + .buttonStyle(RowButtonStyle(cornerRadius: 8)) + .accessibilityLabel("Open session " + session.title + ", " + session.visualStatus.label) + trailing(session, hovering: hovering) + } } + .padding(.leading, CGFloat(min(depth, 4)) * 14).padding(.trailing, 4) + .background(selected ? Color.blue.opacity(0.13) : .clear, in: RoundedRectangle(cornerRadius: 10)) + .overlay(RoundedRectangle(cornerRadius: 10).strokeBorder(selected ? Color.blue.opacity(0.35) : .clear)) + } + + /// cmux and pin, inside the row's own box so its selection and hover cover them. cmux is live only on the + /// hovered or selected row. + 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) Button { model.action(["action": "visibility", "key": .string(session.key), "pinned": .bool(!session.pinned)]) } label: { Image(systemName: session.pinned ? "pin.fill" : "pin") .font(.system(size: 10)).foregroundStyle(session.pinned ? Color.blue : .secondary) - .frame(width: 22, height: 25) + .frame(width: 24, height: 24).contentShape(Rectangle()) }.buttonStyle(.genHoverPlain()).instantTooltip(session.pinned ? "Unpin from widget" : "Pin to widget") .accessibilityLabel((session.pinned ? "Unpin " : "Pin ") + session.title) } - .padding(.leading, CGFloat(min(depth, 4)) * 12).padding(.vertical, 5).padding(.horizontal, 6) - .background(session.key == model.selectedKey ? Color.blue.opacity(0.12) : .clear, - in: RoundedRectangle(cornerRadius: 9)) } private func setProjects(_ projects: [String]) { model.action(["action": "preferences", "patch": ["projects": .array(projects.map(WidgetJSON.string))]]) } } + diff --git a/src/macos/GenesisKit/Sources/GenesisKit/Widgets/WidgetWireModels.swift b/src/macos/GenesisKit/Sources/GenesisKit/Widgets/WidgetWireModels.swift index 7eed9bbd47..4711180179 100644 --- a/src/macos/GenesisKit/Sources/GenesisKit/Widgets/WidgetWireModels.swift +++ b/src/macos/GenesisKit/Sources/GenesisKit/Widgets/WidgetWireModels.swift @@ -286,6 +286,10 @@ public struct WidgetPreferences: Codable, Equatable, Sendable { public var alertAnswers: Bool? public var alertSound: String? public var alertPopup: Bool? + /// The agents hover list: "needs-you" (default) or "active" (sessions that work now too). + public var previewFilter: String? + /// The agents hover list's order: "attention" (default), "recent" or "project". + public var previewSort: String? public var layout: WidgetLayoutConfiguration { WidgetLayoutConfiguration( @@ -375,6 +379,8 @@ public struct WidgetInboxItem: Codable, Equatable, Sendable { /// A short title (a decision's title or prompt, an answer's question) and a decision's number, for alerts. public var title: String? = nil public var number: Int? = nil + /// How many items this entry stands for: an answers group lists only its newest item. + public var count: Int? = nil } public struct WidgetInboxSession: Codable, Equatable, Sendable { public var key: String @@ -383,6 +389,8 @@ public struct WidgetInboxSession: Codable, Equatable, Sendable { public var latest: WidgetInboxItem? public var unreadItem: WidgetInboxItem? public var pendingItem: WidgetInboxItem? + /// The session's listed entries for the inbox overview, waiting ones first, then newest; nil from an older hub. + public var items: [WidgetInboxItem]? = nil } public struct WidgetInboxSummary: Codable, Equatable, Sendable { public struct Profile: Codable, Equatable, Sendable { public var hostId: String } diff --git a/src/macos/GenesisKit/Tests/GenesisKitTests/Diff/GitPickerTests.swift b/src/macos/GenesisKit/Tests/GenesisKitTests/Diff/GitPickerTests.swift index 972e33d9f6..09346c758a 100644 --- a/src/macos/GenesisKit/Tests/GenesisKitTests/Diff/GitPickerTests.swift +++ b/src/macos/GenesisKit/Tests/GenesisKitTests/Diff/GitPickerTests.swift @@ -95,6 +95,26 @@ final class GitPickerTests: XCTestCase { XCTAssertEqual(unpicked.highlighted, choices[0].id, "Down from nothing starts at the top") } + func testShiftExtendsARangeThatKeepsEveryCommitBetweenItsEnds() { + let choices = Self.choices(10) + var state = GitCommitPickerState(choices: choices, selection: choices[2].sha) + XCTAssertNil(state.range, "one commit is no range") + state.extend(1) + state.extend(1) + XCTAssertEqual(state.range?.map(\.subject), ["Change number 2", "Change number 3", "Change number 4"]) + state.extend(-3) + XCTAssertEqual(state.range?.map(\.subject), ["Change number 1", "Change number 2"], "the range grows from its start") + state.move(1) + XCTAssertNil(state.range, "a plain move is back to one commit") + + // A Shift-click across a filter: the hidden commits between the ends still belong to the range. + state.highlighted = choices[1].id + state.extend(to: choices[1].id) + state.query = "number 6" + state.extend(to: choices[6].id) + XCTAssertEqual(state.range?.count, 6, "commits 1 to 6, though the filter shows only one row") + } + func testTheDetailsComeFromOneGitLogWithTheFileAndTheCommitCounts() throws { let repo = FileManager.default.temporaryDirectory.appendingPathComponent("commit-details-\(UUID().uuidString)") try FileManager.default.createDirectory(at: repo.appendingPathComponent("notes"), withIntermediateDirectories: true) diff --git a/src/macos/GenesisKit/Tests/GenesisKitTests/WidgetInboxTasksPolishTests.swift b/src/macos/GenesisKit/Tests/GenesisKitTests/WidgetInboxTasksPolishTests.swift index 410015e7ee..538ca53dc3 100644 --- a/src/macos/GenesisKit/Tests/GenesisKitTests/WidgetInboxTasksPolishTests.swift +++ b/src/macos/GenesisKit/Tests/GenesisKitTests/WidgetInboxTasksPolishTests.swift @@ -594,6 +594,39 @@ final class WidgetInboxPolishTests: XCTestCase { XCTAssertEqual(roster.active, 2, "952 sessions, 2 of them active: the preview shows 2, not 952") } + func testTheHoverListShowsEverySessionThatNeedsYouAndActiveOnesOnlyWhenAsked() { + let target = WidgetTarget(hostId: "local", provider: "claude", sessionId: "s", sourceHome: "", cwd: "/fixture") + func session(_ key: String, _ status: String, at: Double, project: String = "Fixture", visible: Bool = true) + -> WidgetSession { + WidgetSession(key: key, target: target, title: key, project: project, activityAt: at, + status: status, pinned: true, visible: visible, hiddenByFilter: false) + } + let sessions = [session("asks", "recent", at: 1, project: "Beta"), session("waits", "waiting", at: 2), + session("hidden-asks", "recent", at: 3, visible: false), session("works", "working", at: 9), + session("quiet", "finished", at: 8)] + let inbox = ["asks": WidgetInboxSession(key: "asks", unread: 0, needsAnswer: 2), + "hidden-asks": WidgetInboxSession(key: "hidden-asks", unread: 0, needsAnswer: 1)] + let needs = WidgetModel.previewRows(sessions: sessions, inbox: inbox, filter: "needs-you", sort: "attention") + XCTAssertEqual(needs.map(\.key), ["hidden-asks", "waits", "asks"], + "every session that needs you, also one the rail does not show, newest first") + let active = WidgetModel.previewRows(sessions: sessions, inbox: inbox, filter: "active", sort: "attention") + XCTAssertEqual(active.map(\.key), ["hidden-asks", "waits", "asks", "works"], "working ones join after them") + XCTAssertEqual(WidgetModel.previewRows(sessions: sessions, inbox: inbox, filter: "active", sort: "recent") + .map(\.key), ["works", "hidden-asks", "waits", "asks"]) + XCTAssertEqual(WidgetModel.previewRows(sessions: sessions, inbox: inbox, filter: "needs-you", sort: "project") + .first?.key, "asks", "Beta before Fixture") + } + + func testTheSessionInboxFilterSplitsInboxEntriesFromDecisions() { + let cards = ["answer", "result", "form", "decision", "todo"].map { kind in + WidgetCard(id: kind, kind: kind, sessionKey: "k", sourceId: kind, at: 1, title: kind, body: "", + status: "open", choices: [], attachments: [], refs: [], read: true) + } + XCTAssertEqual(LiveWidgetView.filtered(cards, by: "inbox").map(\.kind), ["answer", "result", "form"]) + XCTAssertEqual(LiveWidgetView.filtered(cards, by: "decisions").map(\.kind), ["decision", "todo"]) + XCTAssertEqual(LiveWidgetView.filtered(cards, by: "all").count, 5) + } + @MainActor func testASnapshotDecodedForAStoppedWatchIsDroppedAfterARestart() async throws { let decoder = WidgetSnapshotDecoder() diff --git a/src/macos/GenesisTools/Sources/Parts/Hub/Hub/HubLaunch.swift b/src/macos/GenesisTools/Sources/Parts/Hub/Hub/HubLaunch.swift index 99e9ce35c0..68c543b185 100644 --- a/src/macos/GenesisTools/Sources/Parts/Hub/Hub/HubLaunch.swift +++ b/src/macos/GenesisTools/Sources/Parts/Hub/Hub/HubLaunch.swift @@ -30,14 +30,8 @@ extension TerminalHost { func send(sessionId: String, text: String) -> String? { send(sessionId: sessionId, provider: nil, text: text) } } -/// The `tools ` that owns a session's cmux resolver: `tools codex cmux` and `tools grok cmux` know -/// their own history (Grok has no journal and its own fallback); anything else goes through Claude's. -func cmuxTool(provider: String?) -> String { - switch provider { - case "codex", "grok": return provider ?? "claude" - default: return "claude" - } -} +/// The `tools ` that owns a session's cmux resolver (GenesisKit `CmuxCommandRunner.tool`). +func cmuxTool(provider: String?) -> String { CmuxCommandRunner.tool(provider: provider) } enum TerminalHosts { static let current: TerminalHost = CmuxHost() @@ -54,11 +48,7 @@ struct CmuxHost: TerminalHost { var isAvailable: Bool { Self.binary != nil } - static var binary: String? { - ["/Applications/cmux.app/Contents/Resources/bin/cmux", "~/.local/bin/cmux", "/usr/local/bin/cmux", "/opt/homebrew/bin/cmux"] - .map { ($0 as NSString).expandingTildeInPath } - .first { FileManager.default.isExecutableFile(atPath: $0) } - } + static var binary: String? { CmuxCommandRunner.binary } func tree() -> CmuxTree? { let span = HubPerf.begin("cmux.tree") @@ -83,31 +73,11 @@ struct CmuxHost: TerminalHost { } } - /// What `tools claude cmux focus --json` said, in words: nil when it focused. A recorded tab that - /// was closed answers `gone`; before, the hub and Genesis showed cmux's raw "[socket] RPC error". static func focusFailure(status: Int32, stdout: Data, stderr: Data) -> String? { - guard status != 0 else { return nil } - struct Opaque: Decodable { - init(from decoder: Decoder) throws {} - } - struct Result: Decodable { - let gone: Bool? - let matches: [Opaque]? - } - if let result = try? JSONDecoder().decode(Result.self, from: stdout) { - if result.gone == true { - return CmuxHost.paneGone - } - if result.matches?.isEmpty == true { - return "No cmux pane shows this session. Choose a pane to resume it in." - } - } - let lines = String(decoding: stderr, as: UTF8.self).split(whereSeparator: \.isNewline) - let said = lines.map { $0.trimmingCharacters(in: CharacterSet(charactersIn: "│■▲● ").union(.whitespaces)) }.last { !$0.isEmpty } - return said.map { String($0) } ?? "tools claude cmux focus exited \(status)" + CmuxCommandRunner.focusFailure(status: status, stdout: stdout, stderr: stderr) } - static let paneGone = "That cmux pane is gone. Choose a pane to resume the session in." + static let paneGone = CmuxCommandRunner.paneGone func send(sessionId: String, provider: String?, text: String) -> String? { do { @@ -142,69 +112,25 @@ struct CmuxHost: TerminalHost { } } - /// `open-session --json` reports a failure as {ok: false, error} on stdout, with stderr empty. private static func failure(of capture: ProcessCapture) -> String { - struct Failure: Decodable { let error: String } - if let failure = try? JSONDecoder().decode(Failure.self, from: capture.stdout) { - return failure.error - } - let stderr = String(decoding: capture.stderr, as: UTF8.self).trimmed - return stderr.isEmpty ? "tools claude cmux open-session exited \(capture.status)" : String(stderr.suffix(300)) + CmuxCommandRunner.openSessionFailure(status: capture.status, stdout: capture.stdout, stderr: capture.stderr) + ?? "tools claude cmux open-session failed" } private static func openSessionArgs(_ target: CmuxTarget) -> [String]? { target.openSessionArgs } - /// Words are shell-quoted here because cmux takes one command string; nothing in them comes from a URL. private func run(_ command: [String], cwd: String, name: String, at target: CmuxTarget) -> String? { - let words = command.map(ShellWords.word).joined(separator: " ") - let line = cwd.isEmpty ? words : "cd \(ShellWords.word(cwd)) && \(words)" - switch target { - case .newWorkspace(let window): - let result = Self.cmux(["new-workspace", "--name", name, "--cwd", cwd, "--command", words, "--focus", "true"] + (window.map { ["--window", $0] } ?? [])) - return result.ok ? nil : result.out.trimmed - case .newPane(let workspace): - return typeInto(Self.cmux(["new-pane", "--type", "terminal", "--direction", "right", "--workspace", workspace, "--focus", "true"]), workspace: workspace, line: line) - case .newTab(let workspace, let pane): - return typeInto(Self.cmux(["new-surface", "--type", "terminal", "--workspace", workspace, "--pane", pane, "--focus", "true"]), workspace: workspace, line: line) - case .surface(let workspace, let surface): - return send(line, workspace: workspace, surface: surface) - } - } - - /// new-surface / new-pane print the new surface ref; the command is typed into it, then Enter. - private func typeInto(_ created: (ok: Bool, out: String), workspace: String, line: String) -> String? { - guard created.ok else { return created.out.trimmed } - guard let range = created.out.range(of: "surface:[0-9]+", options: .regularExpression) else { - return "cmux did not report the new surface: \(created.out.trimmed)" - } - return send(line, workspace: workspace, surface: String(created.out[range])) - } - - private func send(_ line: String, workspace: String, surface: String) -> String? { - let sent = Self.cmux(["send", "--workspace", workspace, "--surface", surface, line]) - guard sent.ok else { return sent.out.trimmed } - let enter = Self.cmux(["send-key", "--workspace", workspace, "--surface", surface, "Enter"]) - return enter.ok ? nil : enter.out.trimmed + CmuxCommandRunner.run(command, cwd: cwd, name: name, at: target) } @discardableResult static func cmux(_ args: [String]) -> (ok: Bool, out: String) { - guard let binary else { return (false, "cmux is not installed") } let span = HubPerf.begin("cmux.\(args.first ?? "")") defer { span.end() } - let process = Process() - process.executableURL = URL(fileURLWithPath: binary) - process.arguments = args - var environment = ProcessInfo.processInfo.environment - environment["CMUX_QUIET"] = "1" - process.environment = environment - guard let result = try? process.runCapturing() else { return (false, "cmux failed to start") } - let text = String(decoding: result.stdout, as: UTF8.self) - return (result.status == 0, result.status == 0 ? text : String(decoding: result.stderr, as: UTF8.self) + text) + return CmuxCommandRunner.cmux(args) } - } // MARK: - Loading the tree off the main thread diff --git a/src/macos/GenesisTools/Sources/Parts/Hub/Hub/HubWorktrees.swift b/src/macos/GenesisTools/Sources/Parts/Hub/Hub/HubWorktrees.swift index d71f97d2ff..a4b9f34cba 100644 --- a/src/macos/GenesisTools/Sources/Parts/Hub/Hub/HubWorktrees.swift +++ b/src/macos/GenesisTools/Sources/Parts/Hub/Hub/HubWorktrees.swift @@ -120,12 +120,7 @@ enum WorktreeDiscovery { enum AgentLauncher { static func resumeCommand(for session: HubSession) -> [String]? { - switch session.provider { - case "claude": return ["tools", "claude", "resume", session.sessionId] - case "codex": return ["codex", "resume", session.sessionId] - case "grok": return ["grok", "--resume", session.sessionId] - default: return nil - } + CmuxCommandRunner.resumeCommand(provider: session.provider, sessionId: session.sessionId) } /// Runs `command` in `cwd` in a new workspace of the current terminal host (cmux today, see diff --git a/src/macos/GenesisTools/Sources/Parts/Hub/Review/ReviewWindow.swift b/src/macos/GenesisTools/Sources/Parts/Hub/Review/ReviewWindow.swift index 9103d4a451..689475145a 100644 --- a/src/macos/GenesisTools/Sources/Parts/Hub/Review/ReviewWindow.swift +++ b/src/macos/GenesisTools/Sources/Parts/Hub/Review/ReviewWindow.swift @@ -2826,8 +2826,9 @@ private struct ScopeLink: View { } /// Codex's review source menu: what the diff compares. Its label is the chip the markdown viewer's scope picker shows -/// (GenesisKit `GitPickerChip`); "Pick a Commit…" opens the commit chooser (`gitCommitPopover`), with each commit's -/// author, date and line counts, a filter and the keyboard. +/// (GenesisKit `GitPickerChip`); "Pick Commit(s)…" opens the commit chooser (`gitCommitPopover`), with each commit's +/// author, date and line counts, a filter and the keyboard. Shift with the arrows or a Shift-click picks several +/// commits in a row, shown as one range from the parent of the oldest to the newest. struct ScopeMenu: View { @ObservedObject var model: ReviewModel @State private var pickingCommit = false @@ -2846,9 +2847,20 @@ struct ScopeMenu: View { .instantTooltip("What this diff compares: \(label)") // The list exists only while it is open: a closed chooser builds nothing (GenesisKit GitCommitPicker.swift). .gitCommitPopover(isPresented: $pickingCommit, choices: model.commits.map { GitCommitChoice($0) }, selection: shownCommit, - title: "Commits ahead of \(model.base ?? "the base")", details: GitCommitDetailsLoader(folder: model.repo)) { choice in - model.setScope(.commit(sha: choice.sha, title: choice.subject)) - } + title: "Commits ahead of \(model.base ?? "the base")", details: GitCommitDetailsLoader(folder: model.repo), + onPick: { choice in model.setScope(.commit(sha: choice.sha, title: choice.subject)) }, + onPickRange: { range in + if let scope = Self.rangeScope(range) { model.setScope(scope) } + }) + } + + /// Several commits of the list (newest first, as `model.commits` holds them) as one range: from the parent of the + /// oldest to the newest. One commit stays a commit scope. + static func rangeScope(_ range: [GitCommitChoice]) -> DiffScope? { + guard let newest = range.first, let oldest = range.last else { return nil } + guard range.count > 1 else { return .commit(sha: newest.sha, title: newest.subject) } + return .range(base: oldest.sha + "^", head: newest.sha, + label: "\(oldest.sha.prefix(7))..\(newest.sha.prefix(7)) (\(Plural.count(range.count, "commit")))") } /// The commit the diff shows now, for the chooser's check. @@ -2864,10 +2876,13 @@ struct ScopeMenu: View { // fills, not a greyed-out item. let turns = model.session != nil && model.remoteHead == nil let onCommit: Bool = { - if case .commit = model.scope { return true } - return false + switch model.scope { + case .commit: return true + case .range(_, let head, _, _): return model.commits.contains { $0.sha == head } + default: return false + } }() - let committed: MenuButtonItem = .action(model.commits.isEmpty ? "No Commits Ahead of the Base" : "Pick a Commit…", + let committed: MenuButtonItem = .action(model.commits.isEmpty ? "No Commits Ahead of the Base" : "Pick Commit(s)…", checked: onCommit, enabled: !model.commits.isEmpty, run: pickCommit) let prItem: [MenuButtonItem] = model.prScope.map { scope in [.action("PR \(model.prLabel)", checked: model.scope == scope) { model.setScope(scope) }] diff --git a/src/macos/GenesisTools/Sources/Parts/Widget/WidgetWindow.swift b/src/macos/GenesisTools/Sources/Parts/Widget/WidgetWindow.swift index 2e532dbd1a..e58197674d 100644 --- a/src/macos/GenesisTools/Sources/Parts/Widget/WidgetWindow.swift +++ b/src/macos/GenesisTools/Sources/Parts/Widget/WidgetWindow.swift @@ -125,6 +125,9 @@ private final class AgentWidgetDelegate: NSObject, NSApplicationDelegate { if parts.count > 1, let target = surface(2) { model.openModule(parts[1], on: target) } case "hover", "unhover": if let target = surface(1) { model.hover(target, inside: parts.first == "hover") } + // What the inbox button and a rail session button do: open, or hide when already open. + case "inbox": if let target = surface(1) { model.toggleInboxOverview(on: target) } + case "session": if parts.count > 1, let target = surface(2) { model.toggleSession(parts[1], on: target) } case "collapse": model.collapse() case "select": if parts.count > 1 { model.select(parts[1]) } case "settings": coordinator.showSettings(pageID: parts.count > 1 ? parts[1] : "widgets.general") diff --git a/src/macos/GenesisTools/Tests/HubMenuButtonTests.swift b/src/macos/GenesisTools/Tests/HubMenuButtonTests.swift index 64d5fa5a12..9d777e8d27 100644 --- a/src/macos/GenesisTools/Tests/HubMenuButtonTests.swift +++ b/src/macos/GenesisTools/Tests/HubMenuButtonTests.swift @@ -79,17 +79,17 @@ final class HubMenuButtonTests: XCTestCase { ] var opened = 0 var items = MenuButtonPresenter.menu(ScopeMenu.items(model: model) { opened += 1 }).items - let pick = items.first { $0.title == "Pick a Commit…" } + let pick = items.first { $0.title == "Pick Commit(s)…" } XCTAssertEqual(pick?.isEnabled, true) XCTAssertEqual(pick?.state, .off) XCTAssertNil(pick?.submenu, "the chooser is a popover built when it opens, not a submenu") let menu = MenuButtonPresenter.menu(ScopeMenu.items(model: model) { opened += 1 }) - menu.performActionForItem(at: menu.items.firstIndex { $0.title == "Pick a Commit…" } ?? -1) + menu.performActionForItem(at: menu.items.firstIndex { $0.title == "Pick Commit(s)…" } ?? -1) XCTAssertEqual(opened, 1) model.scope = .commit(sha: model.commits[1].sha, title: model.commits[1].subject) items = MenuButtonPresenter.menu(ScopeMenu.items(model: model)).items - XCTAssertEqual(items.first { $0.title == "Pick a Commit…" }?.state, .on) + XCTAssertEqual(items.first { $0.title == "Pick Commit(s)…" }?.state, .on) XCTAssertEqual(GitCommitChoice(model.commits[0]).relative, "2 hours ago", "the git words show until the details arrive") } From a490d464c0f790d004e75bcfd4897df6a8d1336b Mon Sep 17 00:00:00 2001 From: Martin Date: Fri, 23 Oct 2026 16:29:00 +0200 Subject: [PATCH 02/51] feat(GenesisKit, GenesisHub, widgets): bubbles answer the pointer, the hover list stops blinking, rows and the header carry their times and context, alerts get a sound per kind and flash the session that asked, Esc goes back to the list first, and opinionated behavior becomes a setting --- .claude/rules/opinionated-settings.md | 45 +++++++ .claude/skills/ux/SKILL.md | 24 ++++ .claude/skills/ux/references/checklist.md | 14 ++ .claude/skills/ux/references/findings.md | 16 ++- CLAUDE.md | 1 + src/hub/lib/widget/types.ts | 8 ++ .../GenesisKit/Controls/FittedScroll.swift | 14 +- .../Flow/Support/AttentionPulse.swift | 18 ++- .../Settings/NativeSettingsModel.swift | 2 +- .../GenesisKit/Widgets/LiveWidgetView.swift | 42 ++++-- .../Widgets/WidgetAgentPreview.swift | 86 +++++++++++- .../GenesisKit/Widgets/WidgetAlerts.swift | 13 ++ .../GenesisKit/Widgets/WidgetBubble.swift | 52 ++++++++ .../Widgets/WidgetCoordinator.swift | 7 +- .../Widgets/WidgetFeatureSettings.swift | 64 +++++++-- .../GenesisKit/Widgets/WidgetHostView.swift | 32 ++++- .../Widgets/WidgetInboxOverview.swift | 6 +- .../GenesisKit/Widgets/WidgetModel.swift | 124 +++++++++++++++++- .../Widgets/WidgetSessionBrowser.swift | 6 +- .../GenesisKit/Widgets/WidgetWireModels.swift | 8 ++ .../Window/EdgePanelController.swift | 3 + .../WidgetInboxTasksPolishTests.swift | 53 ++++++++ 22 files changed, 597 insertions(+), 41 deletions(-) create mode 100644 .claude/rules/opinionated-settings.md create mode 100644 src/macos/GenesisKit/Sources/GenesisKit/Widgets/WidgetBubble.swift diff --git a/.claude/rules/opinionated-settings.md b/.claude/rules/opinionated-settings.md new file mode 100644 index 0000000000..79863b6f40 --- /dev/null +++ b/.claude/rules/opinionated-settings.md @@ -0,0 +1,45 @@ +--- +paths: + - "src/macos/**/*.swift" + - "**/*.tsx" + - "src/*/ui/**" + - "src/dashboard/**" + - "src/dev-dashboard/**" + - "src/hub/lib/widget/**" + - "src/notify/**" +--- + +# Opinionated behavior gets a setting + +Loaded by Claude Code when you read, write or edit native or web UI code, the widget's wire schema, or notifications. + +Martin's rule (2026-10-23): when a UI change picks one way where a reasonable user could want another, the choice +is a setting with that way as its default. You decide the default and ship the setting in the same change; do not +ask first, and do not ship the behavior hard-coded. + +## Opinionated means + +- **Interruptions**: a sound, which sound per kind of event, a popup, a panel that opens or expands by itself, a + notification, a focus steal. +- **Attention cues**: a flash, a pulse, a badge, their length and their colour; "flash the thing that caused it" is + the expected default, and its duration is the setting. +- **Placement and order**: where a composer or toolbar sits (top or bottom), list order, which filter a list opens + with, what a list shows (only what needs you, or everything active). +- **Hover behavior**: a tooltip or a preview on hover, hover delays, whether hover opens anything at all. +- **Timings**: auto-collapse delays, how long a popup stays. + +Not opinionated: a bug fix, a layout that was wrong, a missing hover effect, accessibility labels, performance. +Those are fixed, not made optional. + +## How + +- One preference per choice, stored where its surface already stores preferences (the widget: the zod schema in + `src/hub/lib/widget/types.ts` plus `WidgetPreferences` in GenesisKit `WidgetWireModels.swift`; a native window: + its settings store). An enum over a boolean when a third way is plausible ("auto" | "always" | "never"). +- The control goes on the settings page of that surface, in the card the behavior belongs to, with a one-line detail + that says what it does. A sound picker has a play button. A list that has a filter in place gets a settings + shortcut that opens the page at its card (`#`, `.nativeSettingsAnchor`), which flashes the card's border. +- The default is your best judgment of what Martin asked for; the option you did not pick stays reachable. +- A shared component's look change keeps the old look as a named option (the `AttentionPulse` presets: the border + flash is the default, the filled flash stays as `.filledFlash`). +- Say in the reply which settings you added and their defaults. diff --git a/.claude/skills/ux/SKILL.md b/.claude/skills/ux/SKILL.md index 0c45b6aed0..dcc60cd6e1 100644 --- a/.claude/skills/ux/SKILL.md +++ b/.claude/skills/ux/SKILL.md @@ -26,6 +26,30 @@ thing for real, frame by frame, and comparing it with the checklist**, then fixi after sit next to each other. 6. **Write down what was wrong on the first try** in the ledger, generically, with the commit. +## What Martin asks for, before he has to (his reviews of 2026-10-23) + +Read this before a widget, hub or settings change, and do these without being asked. Each line is a request he had +to make by screenshot. + +- **Every clickable thing answers the pointer.** Round bubbles get a round hover ring, not a rectangular plate or + nothing. If he cannot tell a thing is clickable by hovering it, it is a defect. +- **Opinionated means configurable.** A sound, a popup, an auto-expand, a flash, a tooltip, a placement, an order: + ship a setting with your pick as its default (`.claude/rules/opinionated-settings.md`), and say which you added. +- **Say why something moved.** When the widget chimes or opens by itself, the session that caused it flashes + (its bubble, its row, its popup) for a configurable few seconds. A cue without its cause is noise. +- **Rows carry their times.** A session row says when it was last active and when its newest question or answer + arrived ("active 12m ago · asked 3m ago"), live (`LiveAgo`). A header says what the session is: agent, project, + model, working or idle for how long, context size. +- **No jumps.** A filter that empties a list does not shrink an open panel; a tab, filter or headline never scrolls + under a fade; content never blinks empty while a panel grows or a hover changes it. +- **The action is near the eye.** The reply box sits where the reading happens (under the tabs), not at the far + bottom of a tall panel. +- **Attention cues point, they do not cover.** A flash is the border; a tinted overlay over a whole card reads as + covered. Border flash is the `AttentionPulse` default. +- **A tooltip never stands in for the real hover behavior.** If hover should open a preview, a tooltip appearing + instead is a bug to fix, not a fallback. +- **Every count opens what it counts**, and every session offers its terminal (cmux) and its inbox. + ## Design authorities (load before a judgment call) | Question | Load | diff --git a/.claude/skills/ux/references/checklist.md b/.claude/skills/ux/references/checklist.md index 5c4cbdcc55..134cd00968 100644 --- a/.claude/skills/ux/references/checklist.md +++ b/.claude/skills/ux/references/checklist.md @@ -63,6 +63,20 @@ Walk it for every view you change. Each line exists because it was missed at lea - A scroll view reused for several sections (or sessions) gets an identity per content (`.id(section + key)`): it otherwise keeps the old offset, and a short section drew its rows over the header. +## Widget and panel controls, round 2 (Martin, 2026-10-23 15:35) +- Hover on every control, shaped like the control: a circle bubble gets a ring (`WidgetBubbleChrome`), which pulses + once on enter and settles. Opacity only, never a repeating animation. +- A filter bar and its headline sit outside the scroll view they filter, pinned under the tabs. +- A filter change in an open panel holds the panel's height (`WidgetModel.holdAgentFit`); folding a row or switching + view releases it. An empty filter result must not collapse the panel. +- A header line under a title is the place for facts: agent · project · model · working / idle `LiveAgo` · ctx. +- Rows: last active and newest question or answer, both `LiveAgo`. +- An alert (sound, popup, auto-expand) has a setting each, a sound per kind with a play button, and flashes the + session that caused it for `alertFlashSeconds`. +- A hover tooltip that duplicates a hover preview is off by default ("auto"), and the preview must open reliably: + a control the pointer reaches inside a panel starts the panel's hover itself, since the panel's own hover fires + only on crossing its edge. + ## Feedback - Every action answers within 100 ms: visible state change, sound for capture, progress for slow work. - Errors say what happened and what to do, in a dialog when the user must act. A missing grant goes through diff --git a/.claude/skills/ux/references/findings.md b/.claude/skills/ux/references/findings.md index 8a5407d1d4..f2293a5f68 100644 --- a/.claude/skills/ux/references/findings.md +++ b/.claude/skills/ux/references/findings.md @@ -3,7 +3,21 @@ Newest first. Each entry: date, symptom as the user saw it, cause, fix, commit. Generic lessons go to checklist.md or motion.md as well. -- 2026-10-23 15:17 — **The widget's inbox and session lists (Martin's review with seven screenshots).** +- 2026-10-23 16:30 — **Widget round 2 and the markdown tab bar (Martin's screenshots 23-28).** + - *Rail bubbles had no hover answer.* `.genHoverPlain` drew a rectangular plate a round bubble hid. Fix: + `WidgetBubbleChrome`, a round ring that pulses once on enter and settles (opacity only). + - *The hover list blinked and opened cut at the top.* One hover asked the panel for 336, 295 and 489 pt within 42 ms + (`edge.request` lines in `~/.genesis-tools/logs/app-perf.log`): the list is rebuilt per hover and its first layout + measured short. Fix: `FittedScroll(initialHeight:)` from the last height, shrinks wait 300 ms, side content pinned + to the top. Lesson: log every resize request with its branch before guessing at a blink. + - *A bubble showed its tooltip instead of the preview.* The panel's hover fires only on crossing its edge; after a + collapse under a resting pointer it never fired again. Fix: a bubble the pointer reaches starts the panel hover; + the bubble tooltip is off while previews are on (`bubbleTooltip`). + - *A filter that emptied the list dropped the open panel's height.* Fix: `holdAgentFit` before a filter change. + - *72 tabs wrapped into nine rows.* Fix: tab groups in GenesisKit (`MarkdownTabGrouping`), inline by default, the + wrapped bar capped at 4 rows with a fade. A first engine split big groups into singletons, so everything landed in + "Other": render the real data (`MARKDOWN_TAB_GROUPS_RENDER`) before calling a grouping rule done. +- 2026-10-23 15:17 — **The widget's inbox and session lists (Martin's review with seven screenshots).** Commit 5e0a6e328d. - *Only the text line of the composer took a click.* The `TextField` was one line inside a padded box. Fix: the box is the target (`LiveWidgetView.composer`, legacy `AgentWidgetView` too). - *A second click on a rail session ("4") did nothing.* Every session button called `openInboxNotification` diff --git a/CLAUDE.md b/CLAUDE.md index 86a98a8be1..7e68071d23 100644 --- a/CLAUDE.md +++ b/CLAUDE.md @@ -14,6 +14,7 @@ The detailed rules live in area files. Claude Code loads an area file by itself - AI, accounts, secrets, cost, agent history (`src/utils/ai/`, `src/utils/security/`, `src/utils/agent-sessions/`, `src/ai-proxy/`, `src/ask/`, `src/claude/`, `src/codex/`): `.claude/rules/ai.md`. - Models and pricing (the catalog, the proxy ledger, Claude request rules, Codex and Grok model lists, the handoff-to rankings): `.claude/rules/models.md`. Where each model fact lives, and the checklist for a new model. - Web UI (`*.tsx`, `src/*/ui/`, `src/dashboard/`, `src/dev-dashboard/`): `.claude/rules/web-ui.md`. +- Any UI, native or web, the widget schema and notifications: `.claude/rules/opinionated-settings.md`. A behavior a reasonable user could want another way (a sound, a popup, a flash, a placement, a hover tooltip, a timing) ships as a setting with your pick as its default. - macOS app, notifications, privacy grants (`src/macos/`, `src/utils/macos/`, `src/notify/`): `.claude/rules/macos.md`. Window rules for the Swift app (`src/macos/GenesisTools/`): `.claude/rules/macos-app.md`. - The personal-data probe and the git hooks: `.claude/rules/placeholder-check.md`. - Browser automation (`src/chrome-devtools/`, `src/utils/chrome-devtools/`, which another repo vendors): `.claude/rules/chrome-devtools.md`. The plugin (`plugins/`, `.claude-plugin/`): `plugins.md`. CI, guards and git hooks (`.github/`, `scripts/ci/`, `.githooks/`): `ci.md`. Dependencies (`package.json`, `bun.lock`): `dependencies.md`. diff --git a/src/hub/lib/widget/types.ts b/src/hub/lib/widget/types.ts index 5b8716d922..f66048998f 100644 --- a/src/hub/lib/widget/types.ts +++ b/src/hub/lib/widget/types.ts @@ -71,6 +71,14 @@ export const widgetPreferencesSchema = z.object({ previewFilter: z.enum(["needs-you", "active"]).default("needs-you"), /** The agents hover list's order: waiting for you first, newest activity first, or by project. */ previewSort: z.enum(["attention", "recent", "project"]).default("attention"), + /** The sound an answer or message plays; "same" uses `alertSound`, "none" plays no sound. */ + alertAnswerSound: z.string().trim().min(1).max(40).default("same"), + /** Seconds the session that raised an alert flashes in the widget; 0 does not flash it. */ + alertFlashSeconds: z.number().int().min(0).max(30).default(5), + /** Where a session inbox puts its reply composer: under the tabs, or below the list. */ + inboxComposer: z.enum(["top", "bottom"]).default("top"), + /** A rail session bubble's tooltip: only while hover previews are off ("auto"), always, or never. */ + bubbleTooltip: z.enum(["auto", "always", "never"]).default("auto"), }); // Zod defaults also run inside partial objects; a patch must retain only supplied keys. diff --git a/src/macos/GenesisKit/Sources/GenesisKit/Controls/FittedScroll.swift b/src/macos/GenesisKit/Sources/GenesisKit/Controls/FittedScroll.swift index 13c6a889d9..97bd0a00bb 100644 --- a/src/macos/GenesisKit/Sources/GenesisKit/Controls/FittedScroll.swift +++ b/src/macos/GenesisKit/Sources/GenesisKit/Controls/FittedScroll.swift @@ -11,14 +11,23 @@ import SwiftUI public struct FittedScroll: View { public var maxHeight: CGFloat public var minHeight: CGFloat + /// The height until the content is first measured, and every measurement goes to `measured`. A view that is + /// built again each time it shows (the widget's hover list) passes its last height here: from 0, the first layout + /// pass reported a list-less height, its window shrank and grew back within 40 ms, and the panel blinked + /// (edge.request log, 2026-10-23: 336, then 295, then 489 pt for one hover). + public var initialHeight: CGFloat + private let measured: ((CGFloat) -> Void)? private let content: () -> Content @State private var height: CGFloat = 0 @State private var expansion = FittedExpansionNote() @GenReduceMotion private var reduceMotion - public init(maxHeight: CGFloat, minHeight: CGFloat = 0, @ViewBuilder content: @escaping () -> Content) { + public init(maxHeight: CGFloat, minHeight: CGFloat = 0, initialHeight: CGFloat = 0, + measured: ((CGFloat) -> Void)? = nil, @ViewBuilder content: @escaping () -> Content) { self.maxHeight = maxHeight self.minHeight = minHeight + self.initialHeight = initialHeight + self.measured = measured self.content = content } @@ -29,11 +38,12 @@ public struct FittedScroll: View { .scrollOverflowContent() } .scrollOverflowHints() - .frame(height: min(maxHeight, max(minHeight, height))) + .frame(height: min(maxHeight, max(minHeight, height > 0 ? height : initialHeight))) .onGenExpansion { expansion.note($0) } } private func apply(_ value: CGFloat) { + measured?(value) guard height > 0, let expanding = expansion.recent() else { height = value return diff --git a/src/macos/GenesisKit/Sources/GenesisKit/Flow/Support/AttentionPulse.swift b/src/macos/GenesisKit/Sources/GenesisKit/Flow/Support/AttentionPulse.swift index daf5f60405..98f3fcce68 100644 --- a/src/macos/GenesisKit/Sources/GenesisKit/Flow/Support/AttentionPulse.swift +++ b/src/macos/GenesisKit/Sources/GenesisKit/Flow/Support/AttentionPulse.swift @@ -28,8 +28,15 @@ struct AttentionPulseStyle: Equatable { /// Number of blinks. Never zero, and a zero period cannot divide by zero. var cycles: Int { max(1, Int((duration / max(period, 0.05)).rounded())) } - /// Three quick flashes of border and fill over about 1.4 s. Reads as "look here, now". - static func flash(color: Color, cornerRadius: CGFloat) -> Self { + /// Three quick flashes of the border over about 1.4 s. Reads as "look here, now" without covering the content, + /// so it is the default (Martin, 2026-10-23: a settings card under a tinted overlay read as covered, not pointed at). + static func borderFlash(color: Color, cornerRadius: CGFloat) -> Self { + Self(color: color, cornerRadius: cornerRadius, lineWidth: 2, + duration: 1.44, period: 0.48, fadeIn: 0.12, fadeOut: 0.2) + } + + /// The same three flashes with a tinted fill over the whole view, for a target too small for a border to read. + static func filledFlash(color: Color, cornerRadius: CGFloat) -> Self { Self(color: color, cornerRadius: cornerRadius, fillOpacity: 0.30, duration: 1.44, period: 0.48, fadeIn: 0.12, fadeOut: 0.2) } @@ -109,8 +116,11 @@ struct AttentionPulse: ViewModifier { } extension View { - /// Blinks this view for attention each time `trigger` changes. Pass a counter you bump. - func attentionPulse(trigger: Trigger, style: AttentionPulseStyle) -> some View { + /// Blinks this view for attention each time `trigger` changes. Pass a counter you bump. The default is a + /// border-only flash in the accent colour; `.filledFlash` and `.slowBorder` are the other presets. + func attentionPulse( + trigger: Trigger, style: AttentionPulseStyle = .borderFlash(color: .accentColor, cornerRadius: 12) + ) -> some View { modifier(AttentionPulse(trigger: trigger, style: style)) } } diff --git a/src/macos/GenesisKit/Sources/GenesisKit/Settings/NativeSettingsModel.swift b/src/macos/GenesisKit/Sources/GenesisKit/Settings/NativeSettingsModel.swift index b9d525958d..418e13683d 100644 --- a/src/macos/GenesisKit/Sources/GenesisKit/Settings/NativeSettingsModel.swift +++ b/src/macos/GenesisKit/Sources/GenesisKit/Settings/NativeSettingsModel.swift @@ -85,7 +85,7 @@ private struct NativeSettingsAnchor: ViewModifier { func body(content: Content) -> some View { content - .attentionPulse(trigger: pulses, style: .flash(color: .accentColor, cornerRadius: 12)) + .attentionPulse(trigger: pulses) // A window that opens on the page asks before the card exists: the card pulses once it is on screen. .onAppear { if center.anchor == id { pulseSoon() } } .onChange(of: center.token) { _, _ in if center.anchor == id { pulseSoon() } } diff --git a/src/macos/GenesisKit/Sources/GenesisKit/Widgets/LiveWidgetView.swift b/src/macos/GenesisKit/Sources/GenesisKit/Widgets/LiveWidgetView.swift index a1ffbfbf85..0c2e1a825a 100644 --- a/src/macos/GenesisKit/Sources/GenesisKit/Widgets/LiveWidgetView.swift +++ b/src/macos/GenesisKit/Sources/GenesisKit/Widgets/LiveWidgetView.swift @@ -182,6 +182,10 @@ public struct LiveWidgetView: View { waitingSessions } } else { + if composerOnTop { composer } + // Pinned above the list: inside it, a session that opened at its selected card scrolled the filter + // under the top fade (Martin's image 24, 2026-10-23). + if section == "Inbox", !model.cards.isEmpty { inboxFilterBar } ScrollViewReader { reader in ScrollView { LazyVStack(alignment: .leading, spacing: 14) { @@ -217,7 +221,7 @@ public struct LiveWidgetView: View { .scrollOverflowHints() } // While a decision card holds the composer for an own answer, the bottom one would edit the same draft. - if !model.inboxOverview && section != "Sessions" && !(section == "Inbox" && model.ownAnswerCard != nil) { + if showsComposer && !composerOnTop { composer } } @@ -269,10 +273,13 @@ public struct LiveWidgetView: View { VStack(alignment: .leading, spacing: 3) { Text(model.inboxOverview ? "Inbox" : model.selected?.title ?? "Agent inbox") .font(.system(size: 13, weight: .semibold)).lineLimit(1) - Text(model.inboxOverview ? "Every session" - : [model.selected?.target.provider.capitalized, model.selected?.project].compactMap { $0 } - .joined(separator: " · ")) - .font(.system(size: 10)).foregroundStyle(.secondary).lineLimit(1) + if model.inboxOverview { + Text("Every session").font(.system(size: 10)).foregroundStyle(.secondary).lineLimit(1) + } else if let selected = model.selected { + WidgetSessionFacts(session: selected, + contextTokens: WidgetModel.contextTokens(model.transcript) ?? model.contextTokens[selected.key], + animate: !reduceMotion) + } } .padding(.horizontal, 6).padding(.vertical, 3) } @@ -332,6 +339,16 @@ public struct LiveWidgetView: View { private var reduceMotion: Bool { systemReduceMotion || model.effectiveReduceMotion } + /// While a decision card holds the composer for an own answer, a second one would edit the same draft. + private var showsComposer: Bool { + !model.inboxOverview && section != "Sessions" && !(section == "Inbox" && model.ownAnswerCard != nil) + } + + /// The inbox's composer sits under the tabs unless the user moved it to the bottom (Widgets → Sessions). + private var composerOnTop: Bool { + showsComposer && section == "Inbox" && (model.snapshot?.state.preferences.inboxComposer ?? "top") == "top" + } + /// No session picked: the sessions that hold inbox items, so nothing waits out of sight. private var waitingSessions: some View { ScrollView { @@ -388,17 +405,25 @@ 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. - @ViewBuilder private var inbox: some View { - if !model.cards.isEmpty { + 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: $inboxFilter, minSegmentWidth: 64, identifier: "widget.inbox.filter") + selection: Binding(get: { inboxFilter }, set: { value in + model.holdAgentFit() + inboxFilter = value + }), minSegmentWidth: 64, identifier: "widget.inbox.filter") Spacer() markAllReadButton } Text(inboxHeadline).font(.system(size: 11)).foregroundStyle(.secondary) + } + } + + @ViewBuilder private var inbox: some View { + if !model.cards.isEmpty { let shown = Self.filtered(model.cards, by: inboxFilter) if shown.isEmpty { Text(inboxFilter == "decisions" ? "No decisions in this session." : "No inbox entries in this session.") @@ -435,6 +460,7 @@ public struct LiveWidgetView: View { /// Opens or folds a row in one `GenMotion` animation; the panel around it grows or shrinks to fit (W3). private func toggle(_ card: WidgetCard) { let opening = !isOpen(card) + model.releaseAgentFit() GenMotion.perform(expanding: opening, reduceMotion: reduceMotion) { if opening { model.openCard(card) diff --git a/src/macos/GenesisKit/Sources/GenesisKit/Widgets/WidgetAgentPreview.swift b/src/macos/GenesisKit/Sources/GenesisKit/Widgets/WidgetAgentPreview.swift index 8a219b9752..d799a2d305 100644 --- a/src/macos/GenesisKit/Sources/GenesisKit/Widgets/WidgetAgentPreview.swift +++ b/src/macos/GenesisKit/Sources/GenesisKit/Widgets/WidgetAgentPreview.swift @@ -127,7 +127,8 @@ struct AgentWidgetPreview: View { } }.frame(maxWidth: .infinity, alignment: .leading).padding(.horizontal, 5).padding(.vertical, 6) } else { - FittedScroll(maxHeight: 360) { + FittedScroll(maxHeight: 360, initialHeight: model.previewListHeight, + measured: { if $0 > 0 { model.previewListHeight = $0 } }) { LazyVStack(spacing: 6) { ForEach(model.previewList) { session in row(session) } } @@ -172,7 +173,7 @@ struct AgentWidgetPreview: View { } label: { HStack(spacing: 9) { AIProviderGlyph(meta: AIProviders.meta(for: session.target.provider), size: 21) - VStack(alignment: .leading, spacing: 4) { + VStack(alignment: .leading, spacing: 3) { Text(session.title).font(.system(size: 11.5, weight: .medium)).lineLimit(1) HStack(spacing: 5) { Text(session.project.isEmpty ? session.target.provider.capitalized : session.project) @@ -180,6 +181,7 @@ struct AgentWidgetPreview: View { Text("·") Text(session.visualStatus.label).lineLimit(1) }.font(.system(size: 9.5)).foregroundStyle(.white.opacity(0.45)) + WidgetSessionTimes(session: session, inbox: model.inboxFor(session.key)) } Spacer(minLength: 2) if let inbox = model.inboxFor(session.key), inbox.unread + inbox.needsAnswer > 0 { @@ -204,5 +206,85 @@ struct AgentWidgetPreview: View { model.inboxFor(session.key).map { $0.needsAnswer > 0 } == true || session.status == "waiting" ? Color.orange.opacity(0.065) : .white.opacity(0.025), in: RoundedRectangle(cornerRadius: 10)) + .attentionPulse(trigger: model.attentionTrigger(session.key), style: rowAttention) + } + + private var rowAttention: AttentionPulseStyle { + var style = model.attentionStyle + style.cornerRadius = 10 + return style + } +} + +/// When a session was last active and when its newest question or answer arrived: "working now · asked 3m ago", +/// "active 12m ago · answered 1h ago". Both live, so a row left open keeps counting. +struct WidgetSessionTimes: View { + let session: WidgetSession + let inbox: WidgetInboxSession? + + var body: some View { + HStack(spacing: 5) { + if session.visualStatus == .working { + Text("working now") + } else if session.activityAt > 0 { + LiveAgo(date: Date(timeIntervalSince1970: session.activityAt / 1000), style: .brief) { "active " + $0 } + } + 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)) + } + } + .font(.system(size: 9.5)).foregroundStyle(.white.opacity(0.45)).lineLimit(1) + } + + /// The newest entry: a question still waiting wins, since that is what the row is about. + static func latest(_ inbox: WidgetInboxSession?) -> (verb: String, at: Double, waiting: Bool)? { + if let item = inbox?.pendingItem, item.at.isFinite, item.at > 0 { + return (item.kind == "decision" ? "decision asked" : "asked", item.at, true) + } + guard let item = inbox?.unreadItem ?? inbox?.latest, item.at.isFinite, item.at > 0 else { return nil } + switch item.kind { + case "answer": return ("answered", item.at, false) + case "result": return ("finished", item.at, false) + default: return ("new entry", item.at, false) + } + } +} + +/// The header line under a session's title: agent, project and model, then whether it works now or how long it +/// has been idle, and its context size (Martin, 2026-10-23: "the header probably can have way more info"). +struct WidgetSessionFacts: View { + let session: WidgetSession + let contextTokens: Int? + let animate: Bool + + var body: some View { + HStack(spacing: 5) { + Text(Self.origin(session)).lineLimit(1).truncationMode(.middle) + Text("·") + if session.visualStatus == .working { + WidgetActivityIndicator(status: .working, animate: animate) + Text("working").foregroundStyle(session.visualStatus.color) + } else if session.activityAt > 0 { + LiveAgo(date: Date(timeIntervalSince1970: session.activityAt / 1000), style: .brief) { "idle " + $0 } + } else { + Text(session.visualStatus.label) + } + if let contextTokens { + Text("·") + // The header is the session chooser's button, whose tooltip covers this line; VoiceOver says it. + Text("ctx " + SessionFormat.tokens(contextTokens)).monospacedDigit() + .accessibilityLabel("Context size of the last model call, \(contextTokens) tokens") + } + } + .font(.system(size: 10)).foregroundStyle(.secondary).lineLimit(1) + } + + /// "Claude · col-tools · opus-5-5": the agent, the project, and the model without its vendor prefix. + static func origin(_ session: WidgetSession) -> String { + let model = session.model.map { $0.replacingOccurrences(of: "claude-", with: "") } + return [AIProviders.meta(for: session.target.provider).displayName, session.project, model] + .compactMap { $0 }.filter { !$0.isEmpty }.joined(separator: " · ") } } diff --git a/src/macos/GenesisKit/Sources/GenesisKit/Widgets/WidgetAlerts.swift b/src/macos/GenesisKit/Sources/GenesisKit/Widgets/WidgetAlerts.swift index ec37a3c67e..33d4991068 100644 --- a/src/macos/GenesisKit/Sources/GenesisKit/Widgets/WidgetAlerts.swift +++ b/src/macos/GenesisKit/Sources/GenesisKit/Widgets/WidgetAlerts.swift @@ -35,6 +35,19 @@ public enum WidgetAlerts { } } + /// 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? { + let asks = items.contains { $0.needsAnswer || $0.kind == "decision" || $0.kind == "form" } + guard !asks, let answer = preferences?.alertAnswerSound, answer != "same" else { return preferences?.alertSound } + return answer + } + + /// Seconds the session behind an alert flashes in the widget; 0 is off. + public static func flashSeconds(_ preferences: WidgetPreferences?) -> Double { + max(0, min(30, preferences?.alertFlashSeconds ?? 5)) + } + private static var lastChime = Date.distantPast /// Plays `name` once; a burst of arrivals within 1.5 s chimes once. "none" or an unknown name is silent. diff --git a/src/macos/GenesisKit/Sources/GenesisKit/Widgets/WidgetBubble.swift b/src/macos/GenesisKit/Sources/GenesisKit/Widgets/WidgetBubble.swift new file mode 100644 index 0000000000..f702453720 --- /dev/null +++ b/src/macos/GenesisKit/Sources/GenesisKit/Widgets/WidgetBubble.swift @@ -0,0 +1,52 @@ +import SwiftUI + +/// The chrome of a session bubble on the rail and the top strip: a ring that answers the pointer, and the flash of +/// a session that just asked for you. +/// +/// Hover: the ring fades in strong, then settles to a quieter ring while the pointer stays, so a bubble reads as a +/// control the moment it is reached (Martin, 2026-10-23: "missing hover effects (pulsing around)"). Opacity only, +/// two timed steps, never a repeating animation: a `repeatForever` lays the whole window out every frame (swift skill). +struct WidgetBubbleChrome: ViewModifier { + let trigger: Int + let attention: AttentionPulseStyle + let reduceMotion: Bool + @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)) + .overlay(Circle().strokeBorder(Color.white.opacity(ring), lineWidth: 1.5).padding(2).allowsHitTesting(false)) + .attentionPulse(trigger: trigger, style: attention) + .onHover { inside in + settle?.cancel() + guard inside else { + withAnimation(reduceMotion ? nil : .easeOut(duration: 0.18)) { ring = 0 } + return + } + guard !reduceMotion else { + ring = 0.5 + return + } + withAnimation(.easeOut(duration: 0.14)) { ring = 0.9 } + settle = Task { @MainActor in + try? await Task.sleep(for: .milliseconds(220)) + guard !Task.isCancelled else { return } + withAnimation(.easeInOut(duration: 0.35)) { ring = 0.45 } + } + } + .onDisappear { settle?.cancel() } + } +} + +extension WidgetModel { + /// A rail bubble's tooltip under `bubbleTooltip`: "auto" shows it only while hover previews are off, because + /// the preview already names the session and an extra label over it read as the preview failing to open. + func bubbleTooltip(_ content: TooltipContent) -> TooltipContent { + switch snapshot?.state.preferences.bubbleTooltip ?? "auto" { + case "always": return content + case "never": return TooltipContent() + default: return layout.hoverPreviews ? TooltipContent() : content + } + } +} diff --git a/src/macos/GenesisKit/Sources/GenesisKit/Widgets/WidgetCoordinator.swift b/src/macos/GenesisKit/Sources/GenesisKit/Widgets/WidgetCoordinator.swift index 31bd235a76..0fad4a7a64 100644 --- a/src/macos/GenesisKit/Sources/GenesisKit/Widgets/WidgetCoordinator.swift +++ b/src/macos/GenesisKit/Sources/GenesisKit/Widgets/WidgetCoordinator.swift @@ -181,7 +181,10 @@ public final class WidgetCoordinator: NSObject, NSWindowDelegate { let wanted = WidgetAlerts.wanted(items, preferences: preferences) guard let item = WidgetAlerts.popupItem(wanted) else { return } PerfLog.mark("widget.alert items=\(wanted.count) id=\(item.id) kind=\(item.kind)") - playChime(preferences?.alertSound) + playChime(WidgetAlerts.sound(for: wanted, preferences: preferences)) + // 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. @@ -693,7 +696,7 @@ public final class WidgetCoordinator: NSObject, NSWindowDelegate { answering: model.answerCard)) { case .pass: return event - case .collapse: model.collapse() + case .collapse: if !model.escapeToList() { model.collapse() } case .previous: model.next(-1) case .next: model.next() case .choose(let choice): model.submit(choice: choice) diff --git a/src/macos/GenesisKit/Sources/GenesisKit/Widgets/WidgetFeatureSettings.swift b/src/macos/GenesisKit/Sources/GenesisKit/Widgets/WidgetFeatureSettings.swift index 7e7eb00f7f..73b96f42db 100644 --- a/src/macos/GenesisKit/Sources/GenesisKit/Widgets/WidgetFeatureSettings.swift +++ b/src/macos/GenesisKit/Sources/GenesisKit/Widgets/WidgetFeatureSettings.swift @@ -173,6 +173,14 @@ private struct WidgetGeneralSettings: View { NativeSettingsToggle( "Hover previews", detail: "See a preview without changing keyboard focus.", isOn: boolean("hoverPreviews", prefs?.hoverPreviews ?? true)) + Divider() + NativeSettingsRow("Session bubble tooltips", detail: "Automatic shows them only while hover previews are off.") { + Picker("Session bubble tooltips", selection: string("bubbleTooltip", prefs?.bubbleTooltip ?? "auto")) { + Text("Automatic").tag("auto") + Text("Always").tag("always") + Text("Never").tag("never") + }.labelsHidden().pickerStyle(.segmented).frame(width: 250, alignment: .trailing) + } } NativeSettingsCard("Shape") { NativeSettingsRow("Side style", detail: "Classic keeps a compact monochrome rail with its handle below.") { @@ -219,6 +227,13 @@ private struct WidgetGeneralSettings: View { NativeSettingsToggle( "Changed files", detail: "Show a session's working changes alongside its conversation.", isOn: boolean("showChanges", prefs?.showChanges ?? true)) + Divider() + NativeSettingsRow("Reply box", detail: "Where a session's inbox puts the reply box.") { + Picker("Reply box", selection: string("inboxComposer", prefs?.inboxComposer ?? "top")) { + Text("Under the tabs").tag("top") + Text("Below the list").tag("bottom") + }.labelsHidden().pickerStyle(.segmented).frame(width: 250, alignment: .trailing) + } } NativeSettingsCard("Alerts", subtitle: "When an agent asks or answers while the widget is closed.") { NativeSettingsToggle( @@ -229,26 +244,53 @@ private struct WidgetGeneralSettings: View { "Answers and messages", detail: "A new logged answer or a message an agent sent you.", identifier: "widget.alertAnswers", isOn: boolean("alertAnswers", prefs?.alertAnswers ?? true)) Divider() - NativeSettingsRow("Sound", detail: "One chime per burst of new items.") { - HStack(spacing: 6) { - Picker("Alert sound", selection: string("alertSound", prefs?.alertSound ?? WidgetAlerts.defaultSound)) { - Text("None").tag("none") - ForEach(WidgetAlerts.sounds, id: \.self) { Text($0).tag($0) } - }.labelsHidden().frame(width: 140, alignment: .trailing) - IconButton(systemName: "speaker.wave.2", tooltip: "Play the alert sound") { - WidgetAlerts.preview(prefs?.alertSound ?? WidgetAlerts.defaultSound) - }.disabled((prefs?.alertSound ?? WidgetAlerts.defaultSound) == "none") - } + NativeSettingsRow("Sound for decisions", detail: "Decisions and questions that wait for you. One chime per burst.") { + soundPicker("alertSound", prefs?.alertSound ?? WidgetAlerts.defaultSound, same: false) + } + Divider() + NativeSettingsRow("Sound for answers", detail: "Answers and messages, when nothing in the burst waits for you.") { + soundPicker("alertAnswerSound", prefs?.alertAnswerSound ?? "same", same: true) } Divider() NativeSettingsToggle( - "Popup", detail: "The widget opens a short preview of the new item; a click on it answers.", + "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("Flash the session", detail: "Its rail bubble and its row flash, so you see which session asked.") { + Picker( + "Flash the session", + selection: Binding( + get: { Int(WidgetAlerts.flashSeconds(prefs)) }, + set: { patch(["alertFlashSeconds": .number(Double($0))]) }) + ) { + Text("Off").tag(0) + Text("3 seconds").tag(3) + Text("5 seconds").tag(5) + Text("10 seconds").tag(10) + Text("20 seconds").tag(20) + }.labelsHidden().frame(width: 150, alignment: .trailing) + } } + .nativeSettingsAnchor("alerts") } }.task { model.refreshSettings() } } + /// 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 + return HStack(spacing: 6) { + Picker(key == "alertSound" ? "Decision sound" : "Answer sound", selection: string(key, value)) { + if same { Text("Same as decisions").tag("same") } + Text("None").tag("none") + ForEach(WidgetAlerts.sounds, id: \.self) { Text($0).tag($0) } + }.labelsHidden().frame(width: 160, alignment: .trailing) + IconButton(systemName: "speaker.wave.2", tooltip: "Play this sound") { + WidgetAlerts.preview(played) + }.disabled(played == "none") + } + } + private func string(_ key: String, _ value: String) -> Binding { Binding(get: { value }, set: { patch([key: .string($0)]) }) } diff --git a/src/macos/GenesisKit/Sources/GenesisKit/Widgets/WidgetHostView.swift b/src/macos/GenesisKit/Sources/GenesisKit/Widgets/WidgetHostView.swift index d25584c6f9..4be2eefbb6 100644 --- a/src/macos/GenesisKit/Sources/GenesisKit/Widgets/WidgetHostView.swift +++ b/src/macos/GenesisKit/Sources/GenesisKit/Widgets/WidgetHostView.swift @@ -58,8 +58,11 @@ struct WidgetHostView: View { joined: joined) } + /// Side panels pin their content to the top: content taller than its window (a measurement still on its way, a + /// screen too short) then loses its footer, never its header. Centred, the hover list opened with its header and + /// its last row both cut off (Martin's screenshot, 2026-10-23). private var edgeAlignment: Alignment { - surface.edge == .top ? .top : (surface.edge == .right ? .trailing : .leading) + surface.edge == .top ? .top : (surface.edge == .right ? .topTrailing : .topLeading) } private var topPadding: CGFloat { @@ -165,9 +168,10 @@ struct WidgetHostView: View { model.toggleSession(session.key, on: surface) } label: { sessionActivity(session) - .frame(minWidth: 24, minHeight: 30).fixedSize() - }.buttonStyle(.genHoverPlain()) - .instantTooltip(sessionTooltip(session)) + .frame(minWidth: 30, minHeight: 30).fixedSize() + .modifier(bubbleChrome(session)) + }.buttonStyle(.plain) + .instantTooltip(model.bubbleTooltip(sessionTooltip(session))) .accessibilityLabel(sessionSummary(session)) .accessibilityIdentifier("widget.agent." + session.key) .onHover { model.hoverSession(session.key, on: surface, inside: $0) } @@ -274,14 +278,15 @@ struct WidgetHostView: View { } label: { sessionActivity(session) .frame(width: WidgetSideStripMetrics.sessionWidth, height: WidgetSideStripMetrics.sessionHeight) + .modifier(bubbleChrome(session)) .overlay(alignment: .bottomTrailing) { // Which agent the count belongs to, at a glance; the tooltip names the session. AIProviderGlyph(meta: AIProviders.meta(for: session.target.provider), size: 11) .opacity(0.85).allowsHitTesting(false) } } - .buttonStyle(.genHoverPlain()) - .instantTooltip(sessionTooltip(session)) + .buttonStyle(.plain) + .instantTooltip(model.bubbleTooltip(sessionTooltip(session))) .accessibilityLabel(sessionSummary(session)) .accessibilityIdentifier("widget.agent." + session.key) .onHover { model.hoverSession(session.key, on: surface, inside: $0) } @@ -407,6 +412,14 @@ struct WidgetHostView: View { .accessibilityIdentifier("widget." + location + "." + surface.key + "." + id) } + /// The bubble's hover ring and the alert flash, in place of the rectangular hover plate of `.genHoverPlain`. + private func bubbleChrome(_ session: WidgetSession) -> WidgetBubbleChrome { + var style = model.attentionStyle + style.cornerRadius = 17 + return WidgetBubbleChrome(trigger: model.attentionTrigger(session.key), attention: style, + reduceMotion: model.effectiveReduceMotion) + } + private func sessionActivity(_ session: WidgetSession) -> some View { let inbox = model.inboxFor(session.key) return WidgetSessionActivity(status: session.visualStatus, @@ -495,8 +508,15 @@ struct WidgetPeekView: View { .contentShape(Rectangle()) } .buttonStyle(RowButtonStyle(cornerRadius: 10)) + .attentionPulse(trigger: model.attentionTrigger(item.key), style: popupAttention) .accessibilityLabel(WidgetAlerts.kicker(item) + ": " + (item.title ?? "")) } + + private var popupAttention: AttentionPulseStyle { + var style = model.attentionStyle + style.cornerRadius = 10 + return style + } } struct AgentWidgetModuleView: View { diff --git a/src/macos/GenesisKit/Sources/GenesisKit/Widgets/WidgetInboxOverview.swift b/src/macos/GenesisKit/Sources/GenesisKit/Widgets/WidgetInboxOverview.swift index 8e30b237d7..dff6fa31de 100644 --- a/src/macos/GenesisKit/Sources/GenesisKit/Widgets/WidgetInboxOverview.swift +++ b/src/macos/GenesisKit/Sources/GenesisKit/Widgets/WidgetInboxOverview.swift @@ -24,7 +24,10 @@ struct WidgetInboxOverview: View { GenSegmentedTabs( "Inbox entries", items: [.init("all", "All"), .init("needs", "Needs you"), .init("unread", "Unread")], - selection: $filter, minSegmentWidth: 70, identifier: "widget.overview.filter") + selection: Binding(get: { filter }, set: { value in + model.holdAgentFit() + filter = value + }), minSegmentWidth: 70, identifier: "widget.overview.filter") Spacer(minLength: 4) if model.inboxCount > 0 { Button("Mark all read", action: model.markAllRead) @@ -63,6 +66,7 @@ struct WidgetInboxOverview: View { private func toggle(_ item: WidgetInboxItem) { let opening = openID != item.id + model.releaseAgentFit() GenMotion.perform(expanding: opening, reduceMotion: reduceMotion) { openID = opening ? item.id : nil } } diff --git a/src/macos/GenesisKit/Sources/GenesisKit/Widgets/WidgetModel.swift b/src/macos/GenesisKit/Sources/GenesisKit/Widgets/WidgetModel.swift index fa448a2499..592e976ea9 100644 --- a/src/macos/GenesisKit/Sources/GenesisKit/Widgets/WidgetModel.swift +++ b/src/macos/GenesisKit/Sources/GenesisKit/Widgets/WidgetModel.swift @@ -241,26 +241,70 @@ public final class WidgetModel: ObservableObject { /// The agents preview's measured height. The old estimate (100 + 54 per row) was 11 pt plus 2 pt per row short, /// so the centred content lost its header and the top of its first row (2026-10-10, Martin's screenshot). public private(set) var previewFitHeight: CGFloat? + /// The hover list's last measured height. The list is built again on every hover; it starts at this height so its + /// first layout is already the real one (`FittedScroll.initialHeight`). Not published: it must not re-render. + var previewListHeight: CGFloat = 0 public var previewHeight: CGFloat { if let previewFitHeight { return previewFitHeight } return sessionRoster.preview.isEmpty ? 180 : 112 + CGFloat(sessionRoster.preview.count) * 56 } /// Whole points and a 2 pt hysteresis, as `reportAgentFit`, so the measurement cannot chase its own resize. + /// + /// A taller measurement applies at once; a shorter one only after it held for `previewShrinkDelay`. The list is + /// built again on every hover, and its first layout passes measured it short (header and footer, then the rows): + /// one hover asked for 336, 295 and 489 pt within 42 ms, the panel shrank and grew back, and its content blinked + /// or opened cut at the top (edge.request log and Martin's screenshots, 2026-10-23). func reportPreviewFit(_ height: CGFloat) { let rounded = ceil(height) - if let current = previewFitHeight, abs(rounded - current) < 2 { return } + if let current = previewFitHeight, abs(rounded - current) < 2 { + previewShrink?.cancel() + return + } + previewShrink?.cancel() + if let current = previewFitHeight, rounded < current { + previewShrink = Task { [weak self] in + do { try await Task.sleep(for: Self.previewShrinkDelay) } catch { return } + guard let self else { return } + self.previewFitHeight = rounded + self.presentationChanged?() + } + return + } previewFitHeight = rounded presentationChanged?() } + static let previewShrinkDelay = Duration.milliseconds(300) + private var previewShrink: Task? + public var selected: WidgetSession? { sessionIndex[selectedKey] ?? (lastSelected?.key == selectedKey ? lastSelected : nil) } + /// The height a filter change may not shrink the pane below, for the view (overview, section, session) it was + /// taken in. A filter that shows fewer rows, or none, used to drop the open panel by hundreds of points and grow + /// it back on the next click (Martin, 2026-10-23: "it probably doesn't need to lower the height"). + private var agentFitFloor: (view: String, height: CGFloat)? + private var fitView: String { "\(inboxOverview)|\(section)|\(selectedKey)" } + + /// Called just before a filter changes what an open pane lists: the pane keeps at least its current height. + public func holdAgentFit() { + guard expanded != nil, let height = agentFitHeight else { return } + agentFitFloor = (fitView, max(height, agentFitFloor?.view == fitView ? agentFitFloor?.height ?? 0 : 0)) + } + + /// A row folding or another view: the pane fits its content again. + public func releaseAgentFit() { + agentFitFloor = nil + } + /// Whole points, and only a change of 2 pt or more, so a measurement cannot chase its own resize. func reportAgentFit(_ height: CGFloat?) { - let rounded = height.map { ceil($0) } + var rounded = height.map { ceil($0) } + if let floor = agentFitFloor { + if floor.view == fitView, let value = rounded { rounded = max(value, floor.height) } else { agentFitFloor = nil } + } if let rounded, let current = agentFitHeight, abs(rounded - current) < 2 { return } guard rounded != agentFitHeight else { return } agentFitHeight = rounded @@ -592,6 +636,10 @@ public final class WidgetModel: ObservableObject { return } guard layout.hoverPreviews, !dialogOpen, !draggingSide, expanded == nil else { return } + // The panel's own hover fires only when the pointer crosses its edge. After a click collapsed the panel + // under a resting pointer it never fired again, so the next bubble showed its tooltip and no preview + // (Martin, 2026-10-23). A bubble the pointer reaches starts the panel's hover itself. + if hoveredSurface != surface { hover(surface, inside: true) } cancelHoverPrewarm() hoveredSession = (key, surface) transcriptHoverTask = Task { [weak self] in @@ -728,6 +776,7 @@ public final class WidgetModel: ObservableObject { expanded = edge prewarmTranscript() resumeTranscript() + refreshContext() presentationChanged?() scheduleQuietReduction() if openingInbox == nil, let card, card.kind == "answer", !card.read { @@ -837,6 +886,31 @@ public final class WidgetModel: ObservableObject { public func inboxFor(_ key: String) -> WidgetInboxSession? { inboxSessions[key] } public func inboxPulseFor(_ key: String) -> Int { inboxSessionPulses[key] ?? 0 } + + /// The session an alert came from, flashed for `attentionSeconds` on its rail bubble, its hover row and its + /// popup. The counter restarts a running flash (`AttentionPulse`). + /// One counter per session, so flashing one session never changes another session's trigger. + @Published public private(set) var attentionCounts: [String: Int] = [:] + public private(set) var attentionSeconds: Double = 5 + + public func flashAttention(_ key: String, seconds: Double) { + guard seconds > 0 else { return } + attentionSeconds = seconds + attentionCounts[key, default: 0] += 1 + } + + /// The pulse trigger for one session: changes only when that session is flashed. + public func attentionTrigger(_ key: String) -> Int { attentionCounts[key] ?? 0 } + + /// The style every flash of a session that needs you uses. + var attentionStyle: AttentionPulseStyle { + var style = AttentionPulseStyle.borderFlash(color: .orange, cornerRadius: 12) + style.duration = attentionSeconds + style.period = 0.8 + style.fadeIn = 0.18 + style.fadeOut = 0.3 + return style + } public var inboxCount: Int { max(0, inbox.unread) + 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 @@ -878,6 +952,7 @@ public final class WidgetModel: ObservableObject { public func openInboxNotification(on surface: WidgetSurfaceID, key: String? = nil, needsAnswer: Bool? = nil) { guard !stopping else { return } + listOrigin = .overview let candidates: [WidgetInboxSession] if let key { candidates = inboxSessions[key].map { [$0] } ?? [] @@ -917,8 +992,26 @@ public final class WidgetModel: ObservableObject { } /// An overview entry's "Open in session": that session's inbox with the entry open. + /// The list a session was opened from, which the first Esc goes back to (Martin, 2026-10-23: "it should first go + /// to the list and the second escape should close the expansion entirely"). + public enum ListOrigin: Sendable { case overview, sessions } + public var listOrigin: ListOrigin = .overview + + /// Esc in a session: back to the list it came from. False when a list (or no session) is on screen, so Esc then + /// closes the panel. + @discardableResult + public func escapeToList() -> Bool { + guard activeModuleID == "agents", selected != nil, !inboxOverview, section != "Sessions" else { return false } + switch listOrigin { + case .overview: inboxOverview = true + case .sessions: section = "Sessions" + } + return true + } + public func openInboxItem(_ item: WidgetInboxItem) { guard !stopping else { return } + listOrigin = .overview openingInbox = item select(item.key) selectedCardID = item.id @@ -969,6 +1062,7 @@ public final class WidgetModel: ObservableObject { public func collapse() { cancelHoverPrewarm() + agentFitFloor = nil openingInbox = nil followedTranscript = nil hoveredSurface = nil @@ -993,6 +1087,7 @@ public final class WidgetModel: ObservableObject { inboxOverview = false selectedKey = key lastSelected = snapshot?.sessions.first { $0.key == key } + if expanded != nil { refreshContext() } selectedCardID = nil action(["action": "selection", "key": .string(key)]) if let edge { open(edge) } @@ -1777,6 +1872,31 @@ public final class WidgetModel: ObservableObject { provider: session.target.provider) } + /// Context size per session key, for the header (`WidgetSessionFacts`). Read from the transcript cache when a + /// session opens: the conversation itself loads only on the Conversation tab, and the header shows on every tab. + @Published public private(set) var contextTokens: [String: Int] = [:] + private var contextTask: Task? + + private func refreshContext() { + guard let session = selected, session.target.provider != "unknown" else { return } + let key = session.key + let query = transcriptQuery(session) + contextTask?.cancel() + contextTask = Task { [weak self] in + guard let self, let envelope = try? await self.transcriptCache.value(for: query), !Task.isCancelled, + let tokens = Self.contextTokens(envelope.turns) else { return } + if self.contextTokens[key] != tokens { self.contextTokens[key] = tokens } + } + } + + /// The context the session's last model call carried: its fresh input plus its cache reads. nil when the turns + /// have no call with usage (a provider that reports none, or nothing loaded yet). + nonisolated static func contextTokens(_ turns: [TranscriptTurn]) -> Int? { + guard let usage = turns.last(where: { $0.role == "assistant" && $0.usage != nil })?.usage else { return nil } + let total = (usage.inputTokens ?? 0) + (usage.cacheReadTokens ?? 0) + return total > 0 ? total : nil + } + private func prewarmTranscript(on surface: WidgetSurfaceID? = nil) { let module = surface.map { moduleSelections[$0.key] ?? "agents" } ?? activeModuleID let hovered = surface.flatMap { surface in diff --git a/src/macos/GenesisKit/Sources/GenesisKit/Widgets/WidgetSessionBrowser.swift b/src/macos/GenesisKit/Sources/GenesisKit/Widgets/WidgetSessionBrowser.swift index 878b8a2384..a66d75fef3 100644 --- a/src/macos/GenesisKit/Sources/GenesisKit/Widgets/WidgetSessionBrowser.swift +++ b/src/macos/GenesisKit/Sources/GenesisKit/Widgets/WidgetSessionBrowser.swift @@ -114,7 +114,11 @@ public struct WidgetSessionBrowser: View { private func open(_ session: WidgetSession) { if let onOpen { onOpen(session) } - else { model.select(session.key); model.section = "Inbox" } + else { + model.select(session.key) + model.section = "Inbox" + model.listOrigin = .sessions + } } /// A top-level session: its name, project, account and sub-agent count, the same for every session. diff --git a/src/macos/GenesisKit/Sources/GenesisKit/Widgets/WidgetWireModels.swift b/src/macos/GenesisKit/Sources/GenesisKit/Widgets/WidgetWireModels.swift index 4711180179..d6c7614003 100644 --- a/src/macos/GenesisKit/Sources/GenesisKit/Widgets/WidgetWireModels.swift +++ b/src/macos/GenesisKit/Sources/GenesisKit/Widgets/WidgetWireModels.swift @@ -290,6 +290,14 @@ public struct WidgetPreferences: Codable, Equatable, Sendable { public var previewFilter: String? /// The agents hover list's order: "attention" (default), "recent" or "project". public var previewSort: String? + /// An answer's sound: "same" (default, `alertSound`), "none" or a system sound name. + public var alertAnswerSound: String? + /// Seconds the session that raised an alert flashes; 0 does not flash it. Default 5. + public var alertFlashSeconds: Double? + /// A session inbox's composer: "top" (default, under the tabs) or "bottom". + public var inboxComposer: String? + /// A rail bubble's tooltip: "auto" (default, only without hover previews), "always" or "never". + public var bubbleTooltip: String? public var layout: WidgetLayoutConfiguration { WidgetLayoutConfiguration( diff --git a/src/macos/GenesisKit/Sources/GenesisKit/Window/EdgePanelController.swift b/src/macos/GenesisKit/Sources/GenesisKit/Window/EdgePanelController.swift index bfeb413c40..5517fe7ee4 100644 --- a/src/macos/GenesisKit/Sources/GenesisKit/Window/EdgePanelController.swift +++ b/src/macos/GenesisKit/Sources/GenesisKit/Window/EdgePanelController.swift @@ -249,6 +249,9 @@ public final class EdgePanelController { let wasReshaping = transition?.reshape == true end(outcome: "interrupted") isExpanded = expanded + let branch = reduceMotion || !panel.isVisible || from == target ? "settle" + : motion.presentation == presentation && (!wasTransitioning || wasReshaping) ? "reshape" : "begin" + PerfLog.mark("edge.request window=\(panel.windowNumber) edge=\(placement.rawValue) shown=\(motion.presentation) to=\(presentation) \(branch) from=\(Int(from.width))x\(Int(from.height)) target=\(Int(target.width))x\(Int(target.height)) transitioning=\(wasTransitioning)") if reduceMotion || !panel.isVisible || from == target { settle(presentation, frame: target) } else if motion.presentation == presentation, !wasTransitioning || wasReshaping { diff --git a/src/macos/GenesisKit/Tests/GenesisKitTests/WidgetInboxTasksPolishTests.swift b/src/macos/GenesisKit/Tests/GenesisKitTests/WidgetInboxTasksPolishTests.swift index 538ca53dc3..cba590e773 100644 --- a/src/macos/GenesisKit/Tests/GenesisKitTests/WidgetInboxTasksPolishTests.swift +++ b/src/macos/GenesisKit/Tests/GenesisKitTests/WidgetInboxTasksPolishTests.swift @@ -617,6 +617,59 @@ final class WidgetInboxPolishTests: XCTestCase { .first?.key, "asks", "Beta before Fixture") } + func testAnAlertPlaysItsKindsSoundAndFlashesForTheConfiguredTime() throws { + let decision = WidgetInboxItem(id: "decision:d_1_s", sourceId: "d_1_s", kind: "decision", key: "k", at: 1, + needsAnswer: true) + let answer = WidgetInboxItem(id: "answer:a", sourceId: "a", kind: "answer", key: "k", at: 2, needsAnswer: false) + 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 split = try prefs(#"{"alertSound":"Ping","alertAnswerSound":"Pop","alertFlashSeconds":10}"#) + XCTAssertEqual(WidgetAlerts.sound(for: [answer], preferences: split), "Pop") + XCTAssertEqual(WidgetAlerts.sound(for: [answer, decision], preferences: split), "Ping", + "a burst with a question in it plays the decisions' sound") + XCTAssertEqual(WidgetAlerts.sound(for: [answer], preferences: try prefs(#"{"alertSound":"Ping","alertAnswerSound":"same"}"#)), + "Ping", "\"same\" follows the decisions' sound") + XCTAssertNil(WidgetAlerts.sound(for: [answer], preferences: nil), "no preferences: the default sound") + XCTAssertEqual(WidgetAlerts.flashSeconds(split), 10) + XCTAssertEqual(WidgetAlerts.flashSeconds(nil), 5, "flashing is on by default") + XCTAssertEqual(WidgetAlerts.flashSeconds(try prefs(#"{"alertFlashSeconds":0}"#)), 0) + } + + @MainActor + func testFlashingOneSessionLeavesEveryOtherSessionsTriggerAlone() { + let defaults = UserDefaults(suiteName: "widget.attention.\(UUID().uuidString)")! + let model = WidgetModel(binaryPath: "/fixture/no-process", defaults: defaults) + model.flashAttention("a", seconds: 5) + let other = model.attentionTrigger("b") + model.flashAttention("a", seconds: 5) + XCTAssertEqual(model.attentionTrigger("a"), 2) + XCTAssertEqual(model.attentionTrigger("b"), other, "an unchanged trigger never replays another session's flash") + model.flashAttention("b", seconds: 0) + XCTAssertEqual(model.attentionTrigger("b"), other, "0 seconds is off") + } + + func testTheHeaderReadsTheLastCallsContextAndRowsTheirNewestEntry() { + func turn(_ role: String, _ usage: TranscriptUsage?) -> TranscriptTurn { + TranscriptTurn(id: UUID().uuidString, role: role, at: nil, text: "", tools: [], usage: usage) + } + XCTAssertEqual(WidgetModel.contextTokens([ + turn("assistant", TranscriptUsage(inputTokens: 10, cacheReadTokens: 90)), + turn("assistant", TranscriptUsage(inputTokens: 5, cacheReadTokens: 120_000)), turn("user", nil), + ]), 120_005, "the newest call that reports usage, input plus cache reads") + XCTAssertNil(WidgetModel.contextTokens([turn("user", nil)])) + + let asked = WidgetInboxItem(id: "form:f", sourceId: "f", kind: "form", key: "k", at: 3_000, needsAnswer: true) + let answered = WidgetInboxItem(id: "answer:a", sourceId: "a", kind: "answer", key: "k", at: 9_000, + needsAnswer: false) + let both = WidgetInboxSession(key: "k", unread: 1, needsAnswer: 1, unreadItem: answered, pendingItem: asked) + XCTAssertEqual(WidgetSessionTimes.latest(both)?.verb, "asked", "a waiting question wins over a newer answer") + let read = WidgetInboxSession(key: "k", unread: 1, needsAnswer: 0, unreadItem: answered) + XCTAssertEqual(WidgetSessionTimes.latest(read)?.verb, "answered") + XCTAssertNil(WidgetSessionTimes.latest(nil)) + } + func testTheSessionInboxFilterSplitsInboxEntriesFromDecisions() { let cards = ["answer", "result", "form", "decision", "todo"].map { kind in WidgetCard(id: kind, kind: kind, sessionKey: "k", sourceId: kind, at: 1, title: kind, body: "", From cf4b57c107326107350c9efbfa90cc2df02f311b Mon Sep 17 00:00:00 2001 From: Martin Date: Fri, 23 Oct 2026 17:57:00 +0200 Subject: [PATCH 03/51] fix(GenesisKit, GenesisHub, widgets): a size the hover list asks for while the panel opens is no longer lost with a display link left firing every frame, the alert flash reaches the list rows the alert opened, the rail reads in the list's order (railOrder, Keep places for the old one), and HangWatch logs a main thread that spins without stalling --- .claude/skills/swift/references/gotchas.md | 48 +++++++++ .claude/skills/ux/references/checklist.md | 4 + .claude/skills/ux/references/findings.md | 17 +++ src/hub/lib/dev-monitor.test.ts | 12 +++ src/hub/lib/dev-monitor.ts | 20 +++- src/hub/lib/widget/types.ts | 2 + .../Flow/Support/AttentionPulse.swift | 27 ++++- .../GenesisKit/Widgets/LiveWidgetView.swift | 2 + .../Widgets/WidgetAgentPreview.swift | 3 +- .../GenesisKit/Widgets/WidgetBubble.swift | 4 +- .../Widgets/WidgetFeatureSettings.swift | 7 ++ .../GenesisKit/Widgets/WidgetHostView.swift | 5 +- .../Widgets/WidgetInboxOverview.swift | 1 + .../GenesisKit/Widgets/WidgetModel.swift | 56 +++++++--- .../Widgets/WidgetSessionBrowser.swift | 1 + .../GenesisKit/Widgets/WidgetWireModels.swift | 2 + .../Window/EdgePanelController.swift | 48 +++++++-- .../GenesisKitBase/Perf/HangWatch.swift | 101 +++++++++++++++++- .../EdgePanelGeometryTests.swift | 84 +++++++++++++++ .../FlowFocus/FocusAttentionTests.swift | 10 ++ .../GenesisKitTests/RenderProbeTests.swift | 30 ++++++ 21 files changed, 450 insertions(+), 34 deletions(-) diff --git a/.claude/skills/swift/references/gotchas.md b/.claude/skills/swift/references/gotchas.md index 97878181d9..bb30625820 100644 --- a/.claude/skills/swift/references/gotchas.md +++ b/.claude/skills/swift/references/gotchas.md @@ -406,3 +406,51 @@ Keep measured findings separate from hypotheses; faster CPU numbers alone do not modifiers come from `NSEvent.modifierFlags` inside the button action. - **"input file … was modified during the build" is another session editing GenesisKit**, not your code. Rerun the build through `.claude/skills/babysit/scripts/swift-slot.ts`; do not stash or touch their file. + +## A whole window ignores clicks after one exception in an action — 2026-10-23 16:58 + +- **`NSEvent.clickCount` raises for any event that is not a mouse click.** It is an Objective-C exception + (`NSInternalInconsistencyException`), not a Swift error, and `NSApp.currentEvent` inside a SwiftUI button action is + not always a mouse event: a key press, an accessibility press or a system-defined event can be current. The raise + unwound through SwiftUI's event dispatch and left the Genesis Markdown window ignoring every click, tab bar and + sidebar included, until relaunch (2026-10-23 16:36, the tab group chip's double-click check). Read the count through + GenesisKit `MouseClick.current` or `MouseClick.count(of:)`, which checks the event type first. lint-rules + `swift-current-event-click-count` fails `currentEvent?.clickCount`. Inside `mouseDown(with:)` or a monitor + filtered to mouse types, `event.clickCount` is safe. +- **When "nothing is clickable", read the app's unified log for an exception first.** `command log show --predicate + 'processID == ' --last 10m | rg -i "exception|raise|terminat"` found the line in one query; hit tests, AX + walks and layout theories had found nothing. +- **A test window's synthesized click always makes a mouse event current.** `NSApp.postEvent` down and up gives the + action a real `leftMouseUp`, so a test can never reproduce a crash that needs a key or AX event as the current + one. A unit test of the reader with a key event (`NSEvent.keyEvent(...)`) covers it; a passing click test does not. +- **An AX hit test over a `WKWebView` names the web area, not the view on top.** `tools control hittest` at a tab + that sat above the page reported `AXWebArea` (the web document's rect), while real clicks reached the tab. Judge + delivery by the app's own action log, never by the AX hit test. +- **A hidden test window has no accessibility tree, so find click targets with `FrameProbe`.** GenesisKit + `.frameProbe("name")` records a view's window frame only while `FrameProbe.enabled` (a test sets it); click the + centre of `FrameProbe.frames["name"]`. Never scan a grid of clicks to find a target: the scan pressed the toolbar's + Obsidian and Editor buttons and opened dialogs and editor tabs in the user's real apps. + +## Trigger animations, and loops no stall detector sees — 2026-10-23 17:47 + +- **An animation started from `onChange(of: trigger)` never plays in a view built after the change.** The widget's + hover list was opened by the same alert that bumped the flash counter, so its rows started with the new value and + did not flash. Pass the start time too and play the rest on appear (`AttentionPulse(since:)`). +- **A SwiftUI update loop never delays a main-queue ping.** Each pass returns to the run loop, so a queue-latency + watchdog reports nothing while the app burns a whole core (the widget, 2026-10-23 17:10, three minutes, no + log line). Read the main thread's CPU time instead: `thread_info(THREAD_BASIC_INFO)` on the port from + `mach_thread_self()` taken on the main thread (`HangWatch.SpinTracker`, `main-spin` lines). +- **Read a spin's sample for who schedules the next pass.** Inside the transaction, look for + `asyncTransaction` callers: `LazyLayoutViewCache.signalPrefetch` (a lazy stack that never settles) and + `EventBindingManager.enqueueHoverUpdateIfNeeded` (hover re-checked after every update) were the two in the widget's. +- **A test process has no `NSApp` until something makes one.** `NSApp.postEvent` or `nextEvent` then crashes + with "unexpectedly found nil" on the line that reads `NSApp`; call `_ = NSApplication.shared` first. +- **A window resize lays SwiftUI out synchronously, and content can call back into its controller from there.** The + edge panel moved its window in `begin`, the hover list measured itself in that layout and asked for its real size, + and the nested request started a reshape that `begin` then overwrote with its own transition: the panel kept the + estimate (336 instead of 489 pt). Defer a request made while a transition is being set up, and run the latest one + after it (`EdgePanelController.applyingPresentation`). +- **A `CADisplayLink` keeps a strong reference to its target.** A ticker held only by a struct that gets overwritten + never deinitialises and fires every frame for the life of the process. Stop it wherever the owner is replaced (a + `didSet` on the property), and count live ones for a test (`EdgeFrameTicker.live`). Find them in a sample of the + idle app: `EdgeFrameTicker.step` under `CA::Display::DisplayLink` with nothing animating. diff --git a/.claude/skills/ux/references/checklist.md b/.claude/skills/ux/references/checklist.md index 134cd00968..aed4d06072 100644 --- a/.claude/skills/ux/references/checklist.md +++ b/.claude/skills/ux/references/checklist.md @@ -76,6 +76,10 @@ Walk it for every view you change. Each line exists because it was missed at lea - A hover tooltip that duplicates a hover preview is off by default ("auto"), and the preview must open reliably: a control the pointer reaches inside a panel starts the panel's hover itself, since the panel's own hover fires only on crossing its edge. +- Two views of the same sessions (the rail bubbles and the hover list) read in one order; a bubble and its row sit + at the same place. A sticky order that never moves is an option, not the default (Martin, 2026-10-23 17:47). +- A flash reaches every place the session shows, including a list the alert itself opened: such a list is built + after the trigger changed, so it needs the flash start time, not only the trigger. ## Feedback - Every action answers within 100 ms: visible state change, sound for capture, progress for slow work. diff --git a/.claude/skills/ux/references/findings.md b/.claude/skills/ux/references/findings.md index f2293a5f68..62cb590f12 100644 --- a/.claude/skills/ux/references/findings.md +++ b/.claude/skills/ux/references/findings.md @@ -195,3 +195,20 @@ checklist.md or motion.md as well. user saw no feedback at all. Fix: the tree watches its root (FSEvents) and a reveal re-reads a folder that lacks the file; the reveal jumps a far row to the nearer edge, glides the last stretch and pulses the row. Before and after recordings in the notes vault's `GenesisTools.native/MarkdownViewer/Screenshots/2026-10-15-0618-*`. +- 2026-10-23 — **One press on a new tab group chip made the whole Genesis Markdown window ignore clicks.** The chip + read `NSApp.currentEvent?.clickCount`, which raises for a non-mouse event; the exception left SwiftUI's event + dispatch broken. I had checked the groups only as a rendered PNG and never pressed a chip in the real app, and my + synthesized test clicks always made a mouse event current, so no test could see it. Fix: GenesisKit `MouseClick`, + lint-rules `swift-current-event-click-count`, an exact-click test through `FrameProbe`, and the checklist line + "a render is not a click". My first test also swept clicks across the toolbar and opened Obsidian and the Editor in + Martin's apps; targets now come from `FrameProbe`. +- 2026-10-23 — **The alert flashed only the rail bubble, and the rail and the hover list disagreed on order.** The hover + list opened after the alert bumped the flash counter, so its rows never saw the change; the rail kept a sticky + order while the list sorted by attention. Fix: `AttentionPulse(since:)` plays the rest of a running flash on + appear; the rail follows the list order, with "Keep places" as the setting for the old one. +- 2026-10-23 — **The widget spun at 100% CPU for three minutes and nothing logged it.** A SwiftUI update loop + returns to the run loop between passes, so HangWatch's ping never ran late. HangWatch now also reads the main + thread's CPU time: 5 s above 85% logs `main-spin`, samples the stack and counts the bodies that ran (the dev + monitor shows it as `spin`). Strongest lead, fixed in the same commit: every preview open lost a resize and leaked + a display link that fired every frame for good (sampled live in the collapsed widget); not proven to be that spin, + because three hidden-window replays with the real inbox stayed idle. diff --git a/src/hub/lib/dev-monitor.test.ts b/src/hub/lib/dev-monitor.test.ts index 42a5777a09..b9d62a6400 100644 --- a/src/hub/lib/dev-monitor.test.ts +++ b/src/hub/lib/dev-monitor.test.ts @@ -53,6 +53,18 @@ describe("classifyPerfLine", () => { ).toBe("hang"); }); + test("a main thread spinning at full CPU is an event, and its sample names the file", () => { + expect(classifyPerfLine("[17:10:32.100] mark main-spin 5s at 99% cpu: the main thread never idles")?.kind).toBe( + "spin" + ); + expect(classifyPerfLine("[17:10:34.200] mark main-spin bodies in 2s: bodies[widget.inbox.row=812]")?.kind).toBe( + "spin" + ); + expect( + classifyPerfLine("[17:10:32.101] mark main-spin 5.0s so far — sampling to hang-2026-10-23-171032.txt")?.file + ).toEndWith("logs/hangs/hang-2026-10-23-171032.txt"); + }); + test("short stalls, quick main-thread spans and routine lines are left out", () => { expect(classifyPerfLine("[01:48:30.597] mark main-stall recovered after 319ms load=6.3")).toBeNull(); expect(classifyPerfLine("[01:48:30.597] mark main-stall recovered after 819ms load=6.3")?.kind).toBe("stall"); diff --git a/src/hub/lib/dev-monitor.ts b/src/hub/lib/dev-monitor.ts index 85d7894ecc..ccc70ec504 100644 --- a/src/hub/lib/dev-monitor.ts +++ b/src/hub/lib/dev-monitor.ts @@ -16,7 +16,18 @@ const log = logger.child({ component: "hub:dev-monitor" }); * agent on each instead of the user finding a "not responding" banner first. */ export interface DevEvent { - kind: "wedge" | "hang" | "stall" | "layout-loop" | "slow-main" | "jank" | "error" | "relay" | "crash" | "slow"; + kind: + | "wedge" + | "spin" + | "hang" + | "stall" + | "layout-loop" + | "slow-main" + | "jank" + | "error" + | "relay" + | "crash" + | "slow"; /** The time the source line carries ("01:46:40.479"), or the file's time for a crash or a hang file. */ time: string; text: string; @@ -137,7 +148,7 @@ export function classifyPerfLine(line: string, options: ClassifyOptions = DEFAUL return { kind: "wedge", time, text: short(body) }; } - const sampled = body.match(/main-stall .*sampling to (\S+)/); + const sampled = body.match(/main-(?:stall|spin) .*sampling to (\S+)/); if (sampled) { return { kind: "hang", time, text: short(body), file: genesisToolsDir("logs", "hangs", sampled[1]) }; } @@ -147,6 +158,11 @@ export function classifyPerfLine(line: string, options: ClassifyOptions = DEFAUL return { kind: "hang", time, text: short(body), file: genesisToolsDir("logs", "hangs", stacks[1]) }; } + // A main thread that never idles (an update loop) never delays a ping, so HangWatch reports it by CPU time. + if (/^main-spin /.test(body)) { + return { kind: "spin", time, text: short(body) }; + } + const recovered = body.match(/main-stall recovered after (\d+)ms/); if (recovered) { return Number(recovered[1]) >= options.minStallMs ? { kind: "stall", time, text: short(body) } : null; diff --git a/src/hub/lib/widget/types.ts b/src/hub/lib/widget/types.ts index f66048998f..14335f7931 100644 --- a/src/hub/lib/widget/types.ts +++ b/src/hub/lib/widget/types.ts @@ -79,6 +79,8 @@ export const widgetPreferencesSchema = z.object({ inboxComposer: z.enum(["top", "bottom"]).default("top"), /** A rail session bubble's tooltip: only while hover previews are off ("auto"), always, or never. */ bubbleTooltip: z.enum(["auto", "always", "never"]).default("auto"), + /** The rail's session bubbles: in the hover list's order ("list"), or each keeps its place ("stable"). */ + railOrder: z.enum(["list", "stable"]).default("list"), }); // Zod defaults also run inside partial objects; a patch must retain only supplied keys. diff --git a/src/macos/GenesisKit/Sources/GenesisKit/Flow/Support/AttentionPulse.swift b/src/macos/GenesisKit/Sources/GenesisKit/Flow/Support/AttentionPulse.swift index 98f3fcce68..969e9ff481 100644 --- a/src/macos/GenesisKit/Sources/GenesisKit/Flow/Support/AttentionPulse.swift +++ b/src/macos/GenesisKit/Sources/GenesisKit/Flow/Support/AttentionPulse.swift @@ -49,9 +49,14 @@ struct AttentionPulseStyle: Equatable { /// Plays one `AttentionPulseStyle` over the view each time `trigger` changes. A new trigger /// while a pulse runs restarts it rather than stacking a second one on top. +/// +/// `since` is when the flash began. A view that appears while it runs plays the rest of it: a list built after the +/// trigger changed (the widget's hover list, opened by the alert that flashed the session) never saw the change, so +/// only the rail bubble flashed (Martin, 2026-10-23). struct AttentionPulse: ViewModifier { let trigger: Trigger let style: AttentionPulseStyle + var since: Date? @State private var isOn = false @State private var running: Task? @@ -77,6 +82,13 @@ struct AttentionPulse: ViewModifier { } } + /// What is left of a flash that began at `since`, or nil when none runs. + static func remaining(since: Date?, now: Date = Date(), duration: TimeInterval) -> TimeInterval? { + guard let since else { return nil } + let left = duration - now.timeIntervalSince(since) + return left > 0.05 && left <= duration ? left : nil + } + func body(content: Content) -> some View { content // Drawn over everything and never hit-testable: a pulse must not eat the next click. @@ -92,14 +104,20 @@ struct AttentionPulse: ViewModifier { .accessibilityHidden(true) } .onChange(of: trigger) { _, _ in play() } + .onAppear { + guard running == nil, let left = Self.remaining(since: since, duration: style.duration) else { return } + var rest = style + rest.duration = left + play(rest) + } // Turning Reduce Motion on mid-pulse replaces the blinking with the static border at once. .onChange(of: reduceMotion) { _, _ in if running != nil { play() } } .onDisappear { running?.cancel() } } - private func play() { + private func play(_ style: AttentionPulseStyle? = nil) { running?.cancel() - let steps = Self.steps(style: style, reduceMotion: reduceMotion) + let steps = Self.steps(style: style ?? self.style, reduceMotion: reduceMotion) running = Task { @MainActor in for step in steps { if let duration = step.animation { @@ -119,8 +137,9 @@ extension View { /// Blinks this view for attention each time `trigger` changes. Pass a counter you bump. The default is a /// border-only flash in the accent colour; `.filledFlash` and `.slowBorder` are the other presets. func attentionPulse( - trigger: Trigger, style: AttentionPulseStyle = .borderFlash(color: .accentColor, cornerRadius: 12) + trigger: Trigger, style: AttentionPulseStyle = .borderFlash(color: .accentColor, cornerRadius: 12), + since: Date? = nil ) -> some View { - modifier(AttentionPulse(trigger: trigger, style: style)) + modifier(AttentionPulse(trigger: trigger, style: style, since: since)) } } diff --git a/src/macos/GenesisKit/Sources/GenesisKit/Widgets/LiveWidgetView.swift b/src/macos/GenesisKit/Sources/GenesisKit/Widgets/LiveWidgetView.swift index 0c2e1a825a..ec46dc2703 100644 --- a/src/macos/GenesisKit/Sources/GenesisKit/Widgets/LiveWidgetView.swift +++ b/src/macos/GenesisKit/Sources/GenesisKit/Widgets/LiveWidgetView.swift @@ -59,6 +59,7 @@ public struct LiveWidgetView: View { } public var body: some View { + let _ = RenderProbe.hit("widget.live") Group { if embedded { content @@ -472,6 +473,7 @@ public struct LiveWidgetView: View { private func inboxRow(_ card: WidgetCard) -> some View { let open = isOpen(card) + let _ = RenderProbe.hit("widget.inbox.row") return VStack(alignment: .leading, spacing: 0) { Button { toggle(card) } label: { HStack(alignment: .top, spacing: 9) { diff --git a/src/macos/GenesisKit/Sources/GenesisKit/Widgets/WidgetAgentPreview.swift b/src/macos/GenesisKit/Sources/GenesisKit/Widgets/WidgetAgentPreview.swift index d799a2d305..159a71a21c 100644 --- a/src/macos/GenesisKit/Sources/GenesisKit/Widgets/WidgetAgentPreview.swift +++ b/src/macos/GenesisKit/Sources/GenesisKit/Widgets/WidgetAgentPreview.swift @@ -65,6 +65,7 @@ struct AgentWidgetPreview: View { let surface: WidgetSurfaceID var body: some View { + let _ = RenderProbe.hit("widget.preview") VStack(alignment: .leading, spacing: 8) { HStack(spacing: 8) { Image(systemName: "tray").font(.system(size: 12, weight: .medium)) @@ -206,7 +207,7 @@ struct AgentWidgetPreview: View { model.inboxFor(session.key).map { $0.needsAnswer > 0 } == true || session.status == "waiting" ? Color.orange.opacity(0.065) : .white.opacity(0.025), in: RoundedRectangle(cornerRadius: 10)) - .attentionPulse(trigger: model.attentionTrigger(session.key), style: rowAttention) + .attentionPulse(trigger: model.attentionTrigger(session.key), style: rowAttention, since: model.attentionStart(session.key)) } private var rowAttention: AttentionPulseStyle { diff --git a/src/macos/GenesisKit/Sources/GenesisKit/Widgets/WidgetBubble.swift b/src/macos/GenesisKit/Sources/GenesisKit/Widgets/WidgetBubble.swift index f702453720..0b907df306 100644 --- a/src/macos/GenesisKit/Sources/GenesisKit/Widgets/WidgetBubble.swift +++ b/src/macos/GenesisKit/Sources/GenesisKit/Widgets/WidgetBubble.swift @@ -10,6 +10,8 @@ struct WidgetBubbleChrome: ViewModifier { let trigger: Int let attention: AttentionPulseStyle 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? @State private var ring = 0.0 @State private var settle: Task? @@ -17,7 +19,7 @@ struct WidgetBubbleChrome: ViewModifier { content .background(Circle().fill(Color.white.opacity(ring > 0 ? 0.07 : 0)).padding(2)) .overlay(Circle().strokeBorder(Color.white.opacity(ring), lineWidth: 1.5).padding(2).allowsHitTesting(false)) - .attentionPulse(trigger: trigger, style: attention) + .attentionPulse(trigger: trigger, style: attention, since: since) .onHover { inside in settle?.cancel() guard inside else { diff --git a/src/macos/GenesisKit/Sources/GenesisKit/Widgets/WidgetFeatureSettings.swift b/src/macos/GenesisKit/Sources/GenesisKit/Widgets/WidgetFeatureSettings.swift index 73b96f42db..b2661b0e7f 100644 --- a/src/macos/GenesisKit/Sources/GenesisKit/Widgets/WidgetFeatureSettings.swift +++ b/src/macos/GenesisKit/Sources/GenesisKit/Widgets/WidgetFeatureSettings.swift @@ -181,6 +181,13 @@ private struct WidgetGeneralSettings: View { Text("Never").tag("never") }.labelsHidden().pickerStyle(.segmented).frame(width: 250, alignment: .trailing) } + Divider() + NativeSettingsRow("Session bubble order", detail: "Same as the list follows the hover list's order. Keep places holds each bubble where it appeared.") { + Picker("Session bubble order", selection: string("railOrder", prefs?.railOrder ?? "list")) { + Text("Same as the list").tag("list") + Text("Keep places").tag("stable") + }.labelsHidden().pickerStyle(.segmented).frame(width: 250, alignment: .trailing) + } } NativeSettingsCard("Shape") { NativeSettingsRow("Side style", detail: "Classic keeps a compact monochrome rail with its handle below.") { diff --git a/src/macos/GenesisKit/Sources/GenesisKit/Widgets/WidgetHostView.swift b/src/macos/GenesisKit/Sources/GenesisKit/Widgets/WidgetHostView.swift index 4be2eefbb6..c71285e42a 100644 --- a/src/macos/GenesisKit/Sources/GenesisKit/Widgets/WidgetHostView.swift +++ b/src/macos/GenesisKit/Sources/GenesisKit/Widgets/WidgetHostView.swift @@ -72,6 +72,7 @@ struct WidgetHostView: View { private var keepsExpandedPane: Bool { warm && selected?.id == "agents" } var body: some View { + let _ = RenderProbe.hit("widget.host") Color.clear .overlay(alignment: .topLeading) { panelLayers } // The side rail anchors itself to the screen (ScreenAnchoredWidgetRail), so it needs the whole window @@ -417,7 +418,7 @@ struct WidgetHostView: View { var style = model.attentionStyle style.cornerRadius = 17 return WidgetBubbleChrome(trigger: model.attentionTrigger(session.key), attention: style, - reduceMotion: model.effectiveReduceMotion) + reduceMotion: model.effectiveReduceMotion, since: model.attentionStart(session.key)) } private func sessionActivity(_ session: WidgetSession) -> some View { @@ -508,7 +509,7 @@ struct WidgetPeekView: View { .contentShape(Rectangle()) } .buttonStyle(RowButtonStyle(cornerRadius: 10)) - .attentionPulse(trigger: model.attentionTrigger(item.key), style: popupAttention) + .attentionPulse(trigger: model.attentionTrigger(item.key), style: popupAttention, since: model.attentionStart(item.key)) .accessibilityLabel(WidgetAlerts.kicker(item) + ": " + (item.title ?? "")) } diff --git a/src/macos/GenesisKit/Sources/GenesisKit/Widgets/WidgetInboxOverview.swift b/src/macos/GenesisKit/Sources/GenesisKit/Widgets/WidgetInboxOverview.swift index dff6fa31de..73506b00d6 100644 --- a/src/macos/GenesisKit/Sources/GenesisKit/Widgets/WidgetInboxOverview.swift +++ b/src/macos/GenesisKit/Sources/GenesisKit/Widgets/WidgetInboxOverview.swift @@ -19,6 +19,7 @@ struct WidgetInboxOverview: View { var body: some View { let items = items + let _ = RenderProbe.hit("widget.overview") VStack(alignment: .leading, spacing: 10) { HStack(spacing: 8) { GenSegmentedTabs( diff --git a/src/macos/GenesisKit/Sources/GenesisKit/Widgets/WidgetModel.swift b/src/macos/GenesisKit/Sources/GenesisKit/Widgets/WidgetModel.swift index 592e976ea9..f6300e0b80 100644 --- a/src/macos/GenesisKit/Sources/GenesisKit/Widgets/WidgetModel.swift +++ b/src/macos/GenesisKit/Sources/GenesisKit/Widgets/WidgetModel.swift @@ -255,14 +255,18 @@ public final class WidgetModel: ObservableObject { /// built again on every hover, and its first layout passes measured it short (header and footer, then the rows): /// one hover asked for 336, 295 and 489 pt within 42 ms, the panel shrank and grew back, and its content blinked /// or opened cut at the top (edge.request log and Martin's screenshots, 2026-10-23). + /// + /// Before the first measurement the panel opens at the estimate (`previewHeight`), so a short first pass waits as + /// well: the first alert after a launch asked for 336 (the estimate), 295 and 489 pt (2026-10-23 17:10). func reportPreviewFit(_ height: CGFloat) { let rounded = ceil(height) - if let current = previewFitHeight, abs(rounded - current) < 2 { - previewShrink?.cancel() + let current = previewFitHeight ?? previewHeight + previewShrink?.cancel() + if abs(rounded - current) < 2 { + if previewFitHeight == nil { previewFitHeight = rounded } return } - previewShrink?.cancel() - if let current = previewFitHeight, rounded < current { + if rounded < current { previewShrink = Task { [weak self] in do { try await Task.sleep(for: Self.previewShrinkDelay) } catch { return } guard let self else { return } @@ -831,10 +835,14 @@ public final class WidgetModel: ObservableObject { inboxSessionPulses = inboxSessionPulses.filter { activeKeys.contains($0.key) } inbox = value inboxSessions = Dictionary(value.sessions.map { ($0.key, $0) }, uniquingKeysWith: { _, last in last }) - railActivitySessions = railSessions.filter { session in + let railActive = railSessions.filter { session in let inbox = inboxSessions[session.key] return session.visualStatus == .working || (inbox?.unread ?? 0) + (inbox?.needsAnswer ?? 0) > 0 } + // The rail reads in the hover list's order, so a bubble and its row sit at the same place (Martin, + // 2026-10-23: "the order should be the same in the right and the expanded list"); "stable" keeps places. + railActivitySessions = snapshot?.state.preferences.railOrder == "stable" + ? railActive : railActive.sorted(by: Self.previewOrder(inbox: inboxSessions, sort: previewSort)) inboxOverviewItems = Self.overviewItems(value.sessions) previewList = Self.previewRows( sessions: snapshot?.sessions ?? [], inbox: inboxSessions, @@ -850,27 +858,39 @@ public final class WidgetModel: ObservableObject { nonisolated static func previewRows( sessions: [WidgetSession], inbox: [String: WidgetInboxSession], filter: String, sort: String ) -> [WidgetSession] { - func needs(_ session: WidgetSession) -> Bool { - (inbox[session.key]?.needsAnswer ?? 0) > 0 || (session.visible && session.status == "waiting") - } let rows = sessions.filter { session in - if needs(session) { return true } + if needsYou(session, inbox: inbox) { return true } guard filter == "active", session.visible, session.parentKey == nil, session.agentId == nil else { return false } return session.status == "working" || (inbox[session.key]?.unread ?? 0) > 0 } + return rows.sorted(by: previewOrder(inbox: inbox, sort: sort)) + } + + nonisolated static func needsYou(_ session: WidgetSession, inbox: [String: WidgetInboxSession]) -> Bool { + (inbox[session.key]?.needsAnswer ?? 0) > 0 || (session.visible && session.status == "waiting") + } + + /// The hover list's order, which the rail's session bubbles share. Equal times fall back to the key, so the two + /// lists never disagree on a tie. + nonisolated static func previewOrder( + inbox: [String: WidgetInboxSession], sort: String + ) -> (WidgetSession, WidgetSession) -> Bool { func rank(_ session: WidgetSession) -> Int { - needs(session) ? 0 : session.status == "working" ? 1 : 2 + needsYou(session, inbox: inbox) ? 0 : session.status == "working" ? 1 : 2 + } + func newer(_ lhs: WidgetSession, _ rhs: WidgetSession) -> Bool { + lhs.activityAt == rhs.activityAt ? lhs.key < rhs.key : lhs.activityAt > rhs.activityAt } - return rows.sorted { lhs, rhs in + return { lhs, rhs in switch sort { case "recent": - return lhs.activityAt > rhs.activityAt + return newer(lhs, rhs) case "project": let order = lhs.project.localizedCaseInsensitiveCompare(rhs.project) - return order == .orderedSame ? lhs.activityAt > rhs.activityAt : order == .orderedAscending + return order == .orderedSame ? newer(lhs, rhs) : order == .orderedAscending default: let (left, right) = (rank(lhs), rank(rhs)) - return left == right ? lhs.activityAt > rhs.activityAt : left < right + return left == right ? newer(lhs, rhs) : left < right } } } @@ -896,12 +916,20 @@ public final class WidgetModel: ObservableObject { public func flashAttention(_ key: String, seconds: Double) { guard seconds > 0 else { return } attentionSeconds = seconds + attentionStarts[key] = Date() attentionCounts[key, default: 0] += 1 } + /// When each session's flash began, so a row built while it runs flashes for the rest (`AttentionPulse.since`). + /// Not published: the counter already re-renders the views that flash. + private var attentionStarts: [String: Date] = [:] + /// The pulse trigger for one session: changes only when that session is flashed. public func attentionTrigger(_ key: String) -> Int { attentionCounts[key] ?? 0 } + /// When the session's current flash began. + public func attentionStart(_ key: String) -> Date? { attentionStarts[key] } + /// The style every flash of a session that needs you uses. var attentionStyle: AttentionPulseStyle { var style = AttentionPulseStyle.borderFlash(color: .orange, cornerRadius: 12) diff --git a/src/macos/GenesisKit/Sources/GenesisKit/Widgets/WidgetSessionBrowser.swift b/src/macos/GenesisKit/Sources/GenesisKit/Widgets/WidgetSessionBrowser.swift index a66d75fef3..77bdd01878 100644 --- a/src/macos/GenesisKit/Sources/GenesisKit/Widgets/WidgetSessionBrowser.swift +++ b/src/macos/GenesisKit/Sources/GenesisKit/Widgets/WidgetSessionBrowser.swift @@ -16,6 +16,7 @@ public struct WidgetSessionBrowser: View { } public var body: some View { + let _ = RenderProbe.hit("widget.sessions") let groups = WidgetAgentTree.groups(model.snapshot?.sessions ?? [], query: query, onlyPinned: onlyPinned) LazyVStack(alignment: .leading, spacing: 10) { HStack(spacing: 8) { diff --git a/src/macos/GenesisKit/Sources/GenesisKit/Widgets/WidgetWireModels.swift b/src/macos/GenesisKit/Sources/GenesisKit/Widgets/WidgetWireModels.swift index d6c7614003..ca0dd282e2 100644 --- a/src/macos/GenesisKit/Sources/GenesisKit/Widgets/WidgetWireModels.swift +++ b/src/macos/GenesisKit/Sources/GenesisKit/Widgets/WidgetWireModels.swift @@ -298,6 +298,8 @@ public struct WidgetPreferences: Codable, Equatable, Sendable { public var inboxComposer: String? /// A rail bubble's tooltip: "auto" (default, only without hover previews), "always" or "never". public var bubbleTooltip: String? + /// The rail's session bubbles: "list" (default, the hover list's order) or "stable" (each keeps its place). + public var railOrder: String? public var layout: WidgetLayoutConfiguration { WidgetLayoutConfiguration( diff --git a/src/macos/GenesisKit/Sources/GenesisKit/Window/EdgePanelController.swift b/src/macos/GenesisKit/Sources/GenesisKit/Window/EdgePanelController.swift index 5517fe7ee4..77f26f093e 100644 --- a/src/macos/GenesisKit/Sources/GenesisKit/Window/EdgePanelController.swift +++ b/src/macos/GenesisKit/Sources/GenesisKit/Window/EdgePanelController.swift @@ -75,23 +75,31 @@ private final class EdgePanelRootView: NSView { /// Wakes once per display frame while a transition runs: it keeps the window shadow on the moving outline and records /// the callback timing. The motion itself runs in the render server and does not depend on these callbacks. -private final class EdgeFrameTicker: NSObject { +/// +/// A display link keeps a strong reference to its target, so a ticker nobody stops fires every frame for the life +/// of the process: an overwritten transition left one behind per lost reshape (2026-10-23, `EdgeFrameTicker.step` +/// in a sample of the collapsed widget). `live` counts the running ones for the tests. +final class EdgeFrameTicker: NSObject { + @MainActor static var live = 0 private var link: CADisplayLink? private let tick: () -> Void - init(view: NSView, tick: @escaping () -> Void) { + @MainActor init(view: NSView, tick: @escaping () -> Void) { self.tick = tick super.init() let link = view.displayLink(target: self, selector: #selector(step)) link.add(to: .main, forMode: .common) self.link = link + Self.live += 1 } @objc private func step(_ link: CADisplayLink) { tick() } - func stop() { - link?.invalidate() - link = nil + @MainActor func stop() { + guard let link else { return } + link.invalidate() + self.link = nil + Self.live -= 1 } } @@ -131,7 +139,12 @@ public final class EdgePanelController { private var sideCenterY: CGFloat? private var lastTarget: CGRect? private var lastPresentation: WidgetModulePresentation? - private var transition: Transition? + /// A transition that is replaced stops its ticker, whoever replaces it. + private var transition: Transition? { + didSet { + if let old = oldValue, old.token != transition?.token { old.ticker.stop() } + } + } private var generation = 0 public private(set) var isExpanded = false /// An expanded panel takes the keyboard (its composer). Off only for scripted motion checks on the user's own @@ -211,6 +224,7 @@ public final class EdgePanelController { public func hide() { lastTarget = nil + deferredPresentation = nil generation += 1 end(outcome: "interrupted") if let presentation = lastPresentation, motion.presentation != presentation { @@ -224,7 +238,29 @@ public final class EdgePanelController { setPresentation(expanded ? .expanded : .compact, reduceMotion: reduceMotion) } + /// Set while a request sets up its transition. Moving the window lays SwiftUI out at once, and content that + /// measures itself there (the widget's hover list) asks for its real size from inside that layout: the nested + /// request then started a reshape that the outer one overwrote with its own transition, and the panel stayed at + /// the estimate (2026-10-23 17:48, a reshape to 489 pt logged, the window kept 336 pt). + private var applyingPresentation = false + private var deferredPresentation: (presentation: WidgetModulePresentation, reduceMotion: Bool)? + public func setPresentation(_ presentation: WidgetModulePresentation, reduceMotion: Bool) { + if applyingPresentation { + deferredPresentation = (presentation, reduceMotion) + return + } + applyingPresentation = true + applyPresentation(presentation, reduceMotion: reduceMotion) + applyingPresentation = false + // The latest nested request runs against the transition that is now in place. + if let next = deferredPresentation { + deferredPresentation = nil + setPresentation(next.presentation, reduceMotion: next.reduceMotion) + } + } + + private func applyPresentation(_ presentation: WidgetModulePresentation, reduceMotion: Bool) { let expanded = presentation == .expanded let becameExpanded = expanded && !isExpanded let size: CGSize diff --git a/src/macos/GenesisKit/Sources/GenesisKitBase/Perf/HangWatch.swift b/src/macos/GenesisKit/Sources/GenesisKitBase/Perf/HangWatch.swift index a3458f996d..ef4511205f 100644 --- a/src/macos/GenesisKit/Sources/GenesisKitBase/Perf/HangWatch.swift +++ b/src/macos/GenesisKit/Sources/GenesisKitBase/Perf/HangWatch.swift @@ -11,6 +11,10 @@ // A block on the main queue only runs late when the main thread is actually // busy or starved. // +// A loop that returns to the run loop between its passes never delays the ping, so the same watchdog also +// reads the main thread's own CPU time: busy above 85% for 5 s is a "main-spin", sampled the same way (the +// widget sat at 100% CPU for three minutes in a SwiftUI update loop and logged nothing, 2026-10-23 17:10). +// // Stalls > 0.5 s land in PerfLog (the app's perf file + unified log). // When one crosses 1 s the watchdog runs `sample` on this process and writes // the main-thread call graph to `/hangs/